Add qr-login command to be able to approve qr code logins on other devices (#214)

- add QrApprover
- implement qr-login subcommand

closes #197
This commit is contained in:
Carson McManus 2023-06-24 13:45:03 -04:00 committed by GitHub
parent bff16bd341
commit a5be7b26bb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 273 additions and 4 deletions

7
Cargo.lock generated
View file

@ -686,6 +686,12 @@ dependencies = [
"sha1 0.2.0",
]
[[package]]
name = "hmac-sha256"
version = "1.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3688e69b38018fec1557254f64c8dc2cc8ec502890182f395dbb0aa997aa5735"
[[package]]
name = "html5ever"
version = "0.25.2"
@ -2050,6 +2056,7 @@ dependencies = [
"base64",
"cookie 0.14.4",
"hmac-sha1",
"hmac-sha256",
"lazy_static 1.4.0",
"log",
"maplit",

View file

@ -15,6 +15,7 @@ pub mod encrypt;
pub mod import;
#[cfg(feature = "qr")]
pub mod qr;
pub mod qr_login;
pub mod remove;
pub mod setup;
pub mod trade;
@ -27,6 +28,7 @@ pub use encrypt::EncryptCommand;
pub use import::ImportCommand;
#[cfg(feature = "qr")]
pub use qr::QrCommand;
pub use qr_login::QrLoginCommand;
pub use remove::RemoveCommand;
pub use setup::SetupCommand;
pub use trade::TradeCommand;
@ -117,6 +119,7 @@ pub(crate) enum Subcommands {
Code(CodeCommand),
#[cfg(feature = "qr")]
Qr(QrCommand),
QrLogin(QrLoginCommand),
}
#[derive(Debug, Clone, Copy, ArgEnum)]

64
src/commands/qr_login.rs Normal file
View file

@ -0,0 +1,64 @@
use std::sync::{Arc, Mutex};
use log::*;
use steamguard::{QrApprover, QrApproverError};
use crate::AccountManager;
use super::*;
#[derive(Debug, Clone, Parser)]
#[clap(about = "Log in to Steam on another device using the QR code that it's displaying.")]
pub struct QrLoginCommand {
#[clap(
long,
help = "The URL that would normally open in the Steam app. This is the URL that the QR code is displaying. It should start with \"https://s.team/...\""
)]
pub url: String,
}
impl AccountCommand for QrLoginCommand {
fn execute(
&self,
_manager: &mut AccountManager,
accounts: Vec<Arc<Mutex<SteamGuardAccount>>>,
) -> anyhow::Result<()> {
ensure!(
accounts.len() == 1,
"You can only log in to one account at a time."
);
let mut account = accounts[0].lock().unwrap();
info!("Approving login to {}", account.account_name);
if account.tokens.is_none() {
crate::do_login(&mut account)?;
}
loop {
let Some(tokens) = account.tokens.as_ref() else {
error!("No tokens found for {}. Can't approve login if we aren't logged in ourselves.", account.account_name);
return Err(anyhow!("No tokens found for {}", account.account_name));
};
let mut approver = QrApprover::new(tokens);
match approver.approve(&account, &self.url) {
Ok(_) => {
info!("Login approved.");
break;
}
Err(QrApproverError::Unauthorized) => {
warn!("tokens are invalid. Attempting to log in again.");
crate::do_login(&mut account)?;
}
Err(e) => {
error!("Failed to approve login: {}", e);
break;
}
}
}
Ok(())
}
}

View file

@ -69,6 +69,7 @@ fn run() -> anyhow::Result<()> {
Subcommands::Code(args) => CommandType::Account(Box::new(args)),
#[cfg(feature = "qr")]
Subcommands::Qr(args) => CommandType::Account(Box::new(args)),
Subcommands::QrLogin(args) => CommandType::Account(Box::new(args)),
};
if let CommandType::Const(cmd) = cmd {

View file

@ -32,6 +32,7 @@ secrecy = { version = "0.8", features = ["serde"] }
zeroize = "^1.4.3"
protobuf = "3.2.0"
protobuf-json-mapping = "3.2.0"
hmac-sha256 = "1.1.7"
[build-dependencies]
anyhow = "^1.0"

View file

@ -9,6 +9,7 @@ use anyhow::Result;
pub use confirmation::{Confirmation, ConfirmationType};
use hmacsha1::hmac_sha1;
use log::*;
pub use qrapprover::{QrApprover, QrApproverError};
use reqwest::{
cookie::CookieStore,
header::{COOKIE, USER_AGENT},
@ -30,6 +31,7 @@ pub mod accountlinker;
mod api_responses;
mod confirmation;
pub mod protobufs;
mod qrapprover;
pub mod refresher;
mod secret_string;
pub mod steamapi;

View file

@ -0,0 +1,183 @@
use log::debug;
use reqwest::IntoUrl;
use crate::{
protobufs::steammessages_auth_steamclient::CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request,
steamapi::{AuthenticationClient, EResult},
token::{Tokens, TwoFactorSecret},
transport::WebApiTransport,
SteamGuardAccount,
};
/// QR code login approver
///
/// This can be used to approve a login request from another device that is displaying a QR code.
pub struct QrApprover<'a> {
tokens: &'a Tokens,
client: AuthenticationClient<WebApiTransport>,
}
impl<'a> QrApprover<'a> {
pub fn new(tokens: &'a Tokens) -> Self {
let client = AuthenticationClient::new(WebApiTransport::new());
Self { tokens, client }
}
/// Approve a login request from a challenge URL
pub fn approve(
&mut self,
account: &SteamGuardAccount,
challenge_url: impl IntoUrl,
) -> Result<(), QrApproverError> {
debug!("building signature");
let challenge = parse_challenge_url(challenge_url)?;
let signature = build_signature(&account.shared_secret, account.steam_id, &challenge);
debug!("approving login");
let mut req = CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request::new();
req.set_steamid(account.steam_id);
req.set_version(challenge.version.into());
req.set_client_id(challenge.client_id);
req.set_signature(signature.to_vec());
req.set_confirm(true);
req.set_persistence(
crate::protobufs::enums::ESessionPersistence::k_ESessionPersistence_Persistent,
);
let resp = self
.client
.update_session_with_mobile_confirmation(req, self.tokens.access_token())?;
if resp.result != EResult::OK {
return Err(resp.result.into());
}
Ok(())
}
}
fn build_signature(
shared_secret: &TwoFactorSecret,
steam_id: u64,
challenge: &Challenge,
) -> [u8; 32] {
let mut data = Vec::<u8>::with_capacity(18);
data.extend_from_slice(&challenge.version.to_le_bytes());
data.extend_from_slice(&challenge.client_id.to_le_bytes());
data.extend_from_slice(&steam_id.to_le_bytes());
hmac_sha256::HMAC::mac(data, shared_secret.expose_secret())
}
fn parse_challenge_url(challenge_url: impl IntoUrl) -> Result<Challenge, QrApproverError> {
let url = challenge_url
.into_url()
.map_err(|_| QrApproverError::InvalidChallengeUrl)?;
let regex = regex::Regex::new(r"^https?://s.team/q/(\d+)/(\d+)(\?|$)").unwrap();
let captures = regex
.captures(url.as_str())
.ok_or(QrApproverError::InvalidChallengeUrl)?;
let version = captures[1].parse().expect("regex should only match digits");
let client_id = captures[2].parse().expect("regex should only match digits");
Ok(Challenge { version, client_id })
}
#[derive(Debug)]
struct Challenge {
version: u16,
client_id: u64,
}
#[derive(Debug, thiserror::Error)]
pub enum QrApproverError {
#[error("Invalid challenge URL")]
InvalidChallengeUrl,
#[error("Steam says that this qr login challege has already been used. Try again with a new QR code.")]
DuplicateRequest,
#[error("Steam says that this qr login challege has expired. Try again with a new QR code.")]
Expired,
#[error("Unauthorized")]
Unauthorized,
#[error("Transport error: {0}")]
TransportError(crate::transport::TransportError),
#[error("Unknown EResult: {0:?}")]
UnknownEResult(EResult),
#[error("Unknown error: {0}")]
Unknown(anyhow::Error),
}
impl From<EResult> for QrApproverError {
fn from(result: EResult) -> Self {
match result {
EResult::DuplicateRequest => Self::DuplicateRequest,
_ => Self::UnknownEResult(result),
}
}
}
impl From<anyhow::Error> for QrApproverError {
fn from(err: anyhow::Error) -> Self {
Self::Unknown(err)
}
}
impl From<crate::transport::TransportError> for QrApproverError {
fn from(err: crate::transport::TransportError) -> Self {
match err {
crate::transport::TransportError::Unauthorized => Self::Unauthorized,
_ => Self::TransportError(err),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_challenge_url() {
let url = "https://s.team/q/1/2372462679780599330";
let challenge = parse_challenge_url(url).unwrap();
assert_eq!(challenge.version, 1);
assert_eq!(challenge.client_id, 2372462679780599330);
}
#[test]
fn test_parse_challenge_url_fail() {
let urls = [
"https://s.team/q/1/asdf",
"https://s.team/q/1/123asdf",
"https://s.team/q/a/123",
"https://s.team/q/123a/123",
];
for url in urls {
let challenge = parse_challenge_url(url);
assert!(challenge.is_err(), "url: {}", url);
}
}
#[test]
fn test_build_signature() {
let challenge = Challenge {
version: 1,
client_id: 2372462679780599330,
};
let secret =
TwoFactorSecret::parse_shared_secret("zvIayp3JPvtvX/QGHqsqKBk/44s=".to_owned())
.unwrap();
let steam_id = 76561197960265728;
let signature = build_signature(&secret, steam_id, &challenge);
assert_eq!(
signature,
[
56, 233, 253, 249, 254, 89, 110, 161, 18, 35, 35, 144, 14, 217, 210, 150, 170, 110,
61, 166, 176, 161, 140, 211, 108, 78, 138, 202, 61, 52, 85, 46
]
);
}
}

View file

@ -4,7 +4,7 @@ use crate::{
steammessages_auth_steamclient::*,
},
token::Jwt,
transport::Transport,
transport::{Transport, TransportError},
};
const SERVICE_NAME: &str = "IAuthenticationService";
@ -138,14 +138,18 @@ where
pub fn update_session_with_mobile_confirmation(
&mut self,
req: CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request,
) -> anyhow::Result<ApiResponse<CAuthentication_UpdateAuthSessionWithMobileConfirmation_Response>>
{
access_token: &Jwt,
) -> Result<
ApiResponse<CAuthentication_UpdateAuthSessionWithMobileConfirmation_Response>,
TransportError,
> {
let req = ApiRequest::new(
SERVICE_NAME,
"UpdateAuthSessionWithMobileConfirmation",
1u32,
req,
);
)
.with_access_token(access_token);
let resp = self
.transport
.send_request::<CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request, CAuthentication_UpdateAuthSessionWithMobileConfirmation_Response>(

View file

@ -52,6 +52,10 @@ impl TwoFactorSecret {
String::from_utf8(code_array.to_vec()).unwrap()
}
pub(crate) fn expose_secret(&self) -> &[u8; 20] {
self.0.expose_secret()
}
}
impl Serialize for TwoFactorSecret {