diff --git a/Cargo.lock b/Cargo.lock index 56ebea6..8f595f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/src/commands.rs b/src/commands.rs index aa25e49..73d04dc 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -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)] diff --git a/src/commands/qr_login.rs b/src/commands/qr_login.rs new file mode 100644 index 0000000..9e7f673 --- /dev/null +++ b/src/commands/qr_login.rs @@ -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>>, + ) -> 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(()) + } +} diff --git a/src/main.rs b/src/main.rs index 6884ab3..c8fd9f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { diff --git a/steamguard/Cargo.toml b/steamguard/Cargo.toml index 93869d6..23aef9e 100644 --- a/steamguard/Cargo.toml +++ b/steamguard/Cargo.toml @@ -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" diff --git a/steamguard/src/lib.rs b/steamguard/src/lib.rs index d2155bb..148e3a3 100644 --- a/steamguard/src/lib.rs +++ b/steamguard/src/lib.rs @@ -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; diff --git a/steamguard/src/qrapprover.rs b/steamguard/src/qrapprover.rs new file mode 100644 index 0000000..9cda119 --- /dev/null +++ b/steamguard/src/qrapprover.rs @@ -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, +} + +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::::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 { + 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 for QrApproverError { + fn from(result: EResult) -> Self { + match result { + EResult::DuplicateRequest => Self::DuplicateRequest, + _ => Self::UnknownEResult(result), + } + } +} + +impl From for QrApproverError { + fn from(err: anyhow::Error) -> Self { + Self::Unknown(err) + } +} + +impl From 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 + ] + ); + } +} diff --git a/steamguard/src/steamapi/authentication.rs b/steamguard/src/steamapi/authentication.rs index 0fff3c9..2f2fbbe 100644 --- a/steamguard/src/steamapi/authentication.rs +++ b/steamguard/src/steamapi/authentication.rs @@ -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> - { + access_token: &Jwt, + ) -> Result< + ApiResponse, + TransportError, + > { let req = ApiRequest::new( SERVICE_NAME, "UpdateAuthSessionWithMobileConfirmation", 1u32, req, - ); + ) + .with_access_token(access_token); let resp = self .transport .send_request::( diff --git a/steamguard/src/token.rs b/steamguard/src/token.rs index 931567b..dff0c59 100644 --- a/steamguard/src/token.rs +++ b/steamguard/src/token.rs @@ -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 {