diff --git a/src/commands/trade.rs b/src/commands/trade.rs index afdc503..2afd5d1 100644 --- a/src/commands/trade.rs +++ b/src/commands/trade.rs @@ -2,7 +2,7 @@ use std::sync::{Arc, Mutex}; use crossterm::tty::IsTty; use log::*; -use steamguard::Confirmation; +use steamguard::{Confirmation, Confirmer, ConfirmerError}; use crate::{tui, AccountManager}; @@ -42,16 +42,21 @@ impl AccountCommand for TradeCommand { info!("{}: Checking for trade confirmations", account.account_name); let confirmations: Vec; loop { - match account.get_trade_confirmations() { + let confirmer = Confirmer::new(&account); + + match confirmer.get_trade_confirmations() { Ok(confs) => { confirmations = confs; break; } - Err(err) => { - error!("Failed to get trade confirmations: {:#?}", err); - info!("failed to get trade confirmations, asking user to log in"); + Err(ConfirmerError::InvalidTokens) => { + info!("obtaining new tokens"); crate::do_login(&mut account)?; } + Err(err) => { + error!("Failed to get trade confirmations: {}", err); + return Err(err.into()); + } } } @@ -60,46 +65,46 @@ impl AccountCommand for TradeCommand { continue; } + let confirmer = Confirmer::new(&account); let mut any_failed = false; if self.accept_all { info!("accepting all confirmations"); for conf in &confirmations { - let result = account.accept_confirmation(conf); - if result.is_err() { - warn!("accept confirmation result: {:?}", result); - any_failed = true; - if self.fail_fast { - return result; + match confirmer.accept_confirmation(conf) { + Ok(_) => {} + Err(err) => { + warn!("accept confirmation result: {}", err); + any_failed = true; + if self.fail_fast { + return Err(err.into()); + } } - } else { - debug!("accept confirmation result: {:?}", result); } } } else if std::io::stdout().is_tty() { let (accept, deny) = tui::prompt_confirmation_menu(confirmations)?; for conf in &accept { - let result = account.accept_confirmation(conf); - if result.is_err() { - warn!("accept confirmation result: {:?}", result); - any_failed = true; - if self.fail_fast { - return result; + match confirmer.accept_confirmation(conf) { + Ok(_) => {} + Err(err) => { + warn!("accept confirmation result: {}", err); + any_failed = true; + if self.fail_fast { + return Err(err.into()); + } } - } else { - debug!("accept confirmation result: {:?}", result); } } for conf in &deny { - let result = account.deny_confirmation(conf); - debug!("deny confirmation result: {:?}", result); - if result.is_err() { - warn!("deny confirmation result: {:?}", result); - any_failed = true; - if self.fail_fast { - return result; + match confirmer.deny_confirmation(conf) { + Ok(_) => {} + Err(err) => { + warn!("deny confirmation result: {}", err); + any_failed = true; + if self.fail_fast { + return Err(err.into()); + } } - } else { - debug!("deny confirmation result: {:?}", result); } } } else { diff --git a/steamguard/src/confirmation.rs b/steamguard/src/confirmation.rs index 404b77c..faf3f71 100644 --- a/steamguard/src/confirmation.rs +++ b/steamguard/src/confirmation.rs @@ -1,5 +1,232 @@ +use std::collections::HashMap; + +use hmacsha1::hmac_sha1; +use log::*; +use reqwest::{ + cookie::CookieStore, + header::{COOKIE, USER_AGENT}, + Url, +}; +use secrecy::ExposeSecret; use serde::Deserialize; +use crate::{steamapi, SteamGuardAccount}; + +lazy_static! { + static ref STEAM_COOKIE_URL: Url = "https://steamcommunity.com".parse::().unwrap(); +} + +/// Provides an interface that wraps the Steam mobile confirmation API. +pub struct Confirmer<'a> { + account: &'a SteamGuardAccount, +} + +impl<'a> Confirmer<'a> { + pub fn new(account: &'a SteamGuardAccount) -> Self { + Self { account } + } + + fn get_confirmation_query_params(&self, tag: &str, time: u64) -> HashMap<&str, String> { + let mut params: HashMap<&str, String> = HashMap::new(); + params.insert("p", self.account.device_id.clone()); + params.insert("a", self.account.steam_id.to_string()); + params.insert( + "k", + generate_confirmation_hash_for_time( + time, + tag, + self.account.identity_secret.expose_secret(), + ), + ); + params.insert("t", time.to_string()); + params.insert("m", String::from("react")); + params.insert("tag", String::from(tag)); + params + } + + fn build_cookie_jar(&self) -> reqwest::cookie::Jar { + let cookies = reqwest::cookie::Jar::default(); + let tokens = self.account.tokens.as_ref().unwrap(); + // cookies.add_cookie_str("mobileClientVersion=0 (2.1.3)", &url); + // cookies.add_cookie_str("mobileClient=android", &url); + // cookies.add_cookie_str("Steam_Language=english", &url); + cookies.add_cookie_str("dob=", &STEAM_COOKIE_URL); + cookies.add_cookie_str( + format!("steamid={}", self.account.steam_id).as_str(), + &STEAM_COOKIE_URL, + ); + cookies.add_cookie_str( + format!( + "steamLoginSecure={}||{}", + self.account.steam_id, + tokens.access_token().expose_secret() + ) + .as_str(), + &STEAM_COOKIE_URL, + ); + cookies + } + + pub fn get_trade_confirmations(&self) -> Result, ConfirmerError> { + let cookies = self.build_cookie_jar(); + let client = reqwest::blocking::ClientBuilder::new() + .cookie_store(true) + .build()?; + + let time = steamapi::get_server_time()?.server_time(); + let resp = client + .get( + "https://steamcommunity.com/mobileconf/getlist" + .parse::() + .unwrap(), + ) + .header(USER_AGENT, "steamguard-cli") + .header(COOKIE, cookies.cookies(&STEAM_COOKIE_URL).unwrap()) + .query(&self.get_confirmation_query_params("conf", time)) + .send()?; + + trace!("{:?}", resp); + let text = resp.text().unwrap(); + debug!("Confirmations response: {}", text); + + let mut deser = serde_json::Deserializer::from_str(text.as_str()); + let body: ConfirmationListResponse = serde_path_to_error::deserialize(&mut deser)?; + + if body.needsauth.unwrap_or(false) { + return Err(ConfirmerError::InvalidTokens); + } + if !body.success { + return Err(anyhow!("Server responded with failure.").into()); + } + Ok(body.conf) + } + + /// Respond to a confirmation. + /// + /// Host: https://steamcommunity.com + /// Steam Endpoint: `GET /mobileconf/ajaxop` + fn send_confirmation_ajax( + &self, + conf: &Confirmation, + action: ConfirmationAction, + ) -> Result<(), ConfirmerError> { + let operation = action.to_operation(); + + let cookies = self.build_cookie_jar(); + let client = reqwest::blocking::ClientBuilder::new() + .cookie_store(true) + .build()?; + + let time = steamapi::get_server_time()?.server_time(); + let mut query_params = self.get_confirmation_query_params("conf", time); + query_params.insert("op", operation.to_owned()); + query_params.insert("cid", conf.id.to_string()); + query_params.insert("ck", conf.nonce.to_string()); + + let resp = client + .get( + "https://steamcommunity.com/mobileconf/ajaxop" + .parse::() + .unwrap(), + ) + .header(USER_AGENT, "steamguard-cli") + .header(COOKIE, cookies.cookies(&STEAM_COOKIE_URL).unwrap()) + .query(&query_params) + .send()?; + + trace!("send_confirmation_ajax() response: {:?}", &resp); + debug!( + "send_confirmation_ajax() response status code: {}", + &resp.status() + ); + + let raw = resp.text()?; + debug!("send_confirmation_ajax() response body: {:?}", &raw); + + let mut deser = serde_json::Deserializer::from_str(raw.as_str()); + let body: SendConfirmationResponse = serde_path_to_error::deserialize(&mut deser)?; + + if body.needsauth.unwrap_or(false) { + return Err(ConfirmerError::InvalidTokens); + } + if !body.success { + return Err(anyhow!("Server responded with failure.").into()); + } + + Ok(()) + } + + pub fn accept_confirmation(&self, conf: &Confirmation) -> Result<(), ConfirmerError> { + self.send_confirmation_ajax(conf, ConfirmationAction::Accept) + } + + pub fn deny_confirmation(&self, conf: &Confirmation) -> Result<(), ConfirmerError> { + self.send_confirmation_ajax(conf, ConfirmationAction::Deny) + } + + /// Steam Endpoint: `GET /mobileconf/details/:id` + pub fn get_confirmation_details(&self, conf: &Confirmation) -> anyhow::Result { + #[derive(Debug, Clone, Deserialize)] + struct ConfirmationDetailsResponse { + pub success: bool, + pub html: String, + } + + let cookies = self.build_cookie_jar(); + let client = reqwest::blocking::ClientBuilder::new() + .cookie_store(true) + .build()?; + + let time = steamapi::get_server_time()?.server_time(); + let query_params = self.get_confirmation_query_params("details", time); + + let resp = client + .get( + format!("https://steamcommunity.com/mobileconf/details/{}", conf.id) + .parse::() + .unwrap(), + ) + .header(USER_AGENT, "steamguard-cli") + .header(COOKIE, cookies.cookies(&STEAM_COOKIE_URL).unwrap()) + .query(&query_params) + .send()?; + + let text = resp.text()?; + let mut deser = serde_json::Deserializer::from_str(text.as_str()); + let body: ConfirmationDetailsResponse = serde_path_to_error::deserialize(&mut deser)?; + + ensure!(body.success); + Ok(body.html) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfirmationAction { + Accept, + Deny, +} + +impl ConfirmationAction { + fn to_operation(self) -> &'static str { + match self { + ConfirmationAction::Accept => "allow", + ConfirmationAction::Deny => "cancel", + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ConfirmerError { + #[error("Invalid tokens, login or token refresh required.")] + InvalidTokens, + #[error("Network failure: {0}")] + NetworkFailure(#[from] reqwest::Error), + #[error("Failed to deserialize response: {0}")] + DeserializeError(#[from] serde_path_to_error::Error), + #[error("Unknown error: {0}")] + Unknown(#[from] anyhow::Error), +} + /// A mobile confirmation. There are multiple things that can be confirmed, like trade offers. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub struct Confirmation { @@ -58,12 +285,33 @@ impl From for ConfirmationType { #[derive(Debug, Deserialize)] pub struct ConfirmationListResponse { pub success: bool, + #[serde(default)] + pub needsauth: Option, pub conf: Vec, } #[derive(Debug, Clone, Copy, Deserialize)] pub struct SendConfirmationResponse { pub success: bool, + #[serde(default)] + pub needsauth: Option, +} + +fn build_time_bytes(time: u64) -> [u8; 8] { + time.to_be_bytes() +} + +fn generate_confirmation_hash_for_time( + time: u64, + tag: &str, + identity_secret: impl AsRef<[u8]>, +) -> String { + let decode: &[u8] = &base64::decode(identity_secret).unwrap(); + let time_bytes = build_time_bytes(time); + let tag_bytes = tag.as_bytes(); + let array = [&time_bytes, tag_bytes].concat(); + let hash = hmac_sha1(decode, &array); + base64::encode(hash) } #[cfg(test)] @@ -98,4 +346,12 @@ mod tests { Ok(()) } + + #[test] + fn test_generate_confirmation_hash_for_time() { + assert_eq!( + generate_confirmation_hash_for_time(1617591917, "conf", "GQP46b73Ws7gr8GmZFR0sDuau5c="), + String::from("NaL8EIMhfy/7vBounJ0CvpKbrPk=") + ); + } } diff --git a/steamguard/src/lib.rs b/steamguard/src/lib.rs index 631766f..0e8594e 100644 --- a/steamguard/src/lib.rs +++ b/steamguard/src/lib.rs @@ -1,23 +1,14 @@ -use crate::confirmation::{ConfirmationListResponse, SendConfirmationResponse}; use crate::protobufs::service_twofactor::CTwoFactor_RemoveAuthenticator_Request; use crate::steamapi::EResult; use crate::{ steamapi::twofactor::TwoFactorClient, token::TwoFactorSecret, transport::WebApiTransport, }; pub use accountlinker::{AccountLinkError, AccountLinker, FinalizeLinkError}; -use anyhow::Result; -pub use confirmation::{Confirmation, ConfirmationType}; -use hmacsha1::hmac_sha1; -use log::*; +pub use confirmation::*; pub use qrapprover::{QrApprover, QrApproverError}; -use reqwest::{ - cookie::CookieStore, - header::{COOKIE, USER_AGENT}, - Url, -}; pub use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, io::Read}; +use std::io::Read; use token::Tokens; pub use userlogin::{DeviceDetails, LoginError, UserLogin}; @@ -63,23 +54,6 @@ pub struct SteamGuardAccount { pub tokens: Option, } -fn build_time_bytes(time: u64) -> [u8; 8] { - time.to_be_bytes() -} - -fn generate_confirmation_hash_for_time( - time: u64, - tag: &str, - identity_secret: impl AsRef<[u8]>, -) -> String { - let decode: &[u8] = &base64::decode(identity_secret).unwrap(); - let time_bytes = build_time_bytes(time); - let tag_bytes = tag.as_bytes(); - let array = [&time_bytes, tag_bytes].concat(); - let hash = hmac_sha1(decode, &array); - base64::encode(hash) -} - impl Default for SteamGuardAccount { fn default() -> Self { Self { @@ -122,156 +96,6 @@ impl SteamGuardAccount { self.shared_secret.generate_code(time) } - fn get_confirmation_query_params(&self, tag: &str, time: u64) -> HashMap<&str, String> { - let mut params = HashMap::new(); - params.insert("p", self.device_id.clone()); - params.insert("a", self.steam_id.to_string()); - params.insert( - "k", - generate_confirmation_hash_for_time(time, tag, self.identity_secret.expose_secret()), - ); - params.insert("t", time.to_string()); - params.insert("m", String::from("android")); - params.insert("tag", String::from(tag)); - params - } - - fn build_cookie_jar(&self) -> reqwest::cookie::Jar { - let url = "https://steamcommunity.com".parse::().unwrap(); - let cookies = reqwest::cookie::Jar::default(); - // let session = self.session.as_ref().unwrap().expose_secret(); - let tokens = self.tokens.as_ref().unwrap(); - cookies.add_cookie_str("mobileClientVersion=0 (2.1.3)", &url); - cookies.add_cookie_str("mobileClient=android", &url); - cookies.add_cookie_str("Steam_Language=english", &url); - cookies.add_cookie_str("dob=", &url); - // cookies.add_cookie_str(format!("sessionid={}", session.session_id).as_str(), &url); - cookies.add_cookie_str(format!("steamid={}", self.steam_id).as_str(), &url); - cookies.add_cookie_str( - format!( - "steamLoginSecure={}||{}", - self.steam_id, - tokens.access_token().expose_secret() - ) - .as_str(), - &url, - ); - cookies - } - - pub fn get_trade_confirmations(&self) -> Result, anyhow::Error> { - // uri: "https://steamcommunity.com/mobileconf/conf" - // confirmation details: - let url = "https://steamcommunity.com".parse::().unwrap(); - let cookies = self.build_cookie_jar(); - let client = reqwest::blocking::ClientBuilder::new() - .cookie_store(true) - .build()?; - - let time = steamapi::get_server_time()?.server_time(); - let resp = client - .get("https://steamcommunity.com/mobileconf/getlist".parse::().unwrap()) - .header("X-Requested-With", "com.valvesoftware.android.steam.community") - .header(USER_AGENT, "Mozilla/5.0 (Linux; U; Android 4.1.1; en-us; Google Nexus 4 - 4.1.1 - API 16 - 768x1280 Build/JRO03S) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30") - .header(COOKIE, cookies.cookies(&url).unwrap()) - .query(&self.get_confirmation_query_params("conf", time)) - .send()?; - - trace!("{:?}", resp); - let text = resp.text().unwrap(); - debug!("Confirmations response: {}", text); - - let mut deser = serde_json::Deserializer::from_str(text.as_str()); - let body: ConfirmationListResponse = serde_path_to_error::deserialize(&mut deser)?; - ensure!(body.success); - Ok(body.conf) - } - - /// Respond to a confirmation. - /// - /// Host: https://steamcommunity.com - /// Steam Endpoint: `GET /mobileconf/ajaxop` - fn send_confirmation_ajax(&self, conf: &Confirmation, operation: String) -> anyhow::Result<()> { - ensure!(operation == "allow" || operation == "cancel"); - - let url = "https://steamcommunity.com".parse::().unwrap(); - let cookies = self.build_cookie_jar(); - let client = reqwest::blocking::ClientBuilder::new() - .cookie_store(true) - .build()?; - - let time = steamapi::get_server_time()?.server_time(); - let mut query_params = self.get_confirmation_query_params("conf", time); - query_params.insert("op", operation); - query_params.insert("cid", conf.id.to_string()); - query_params.insert("ck", conf.nonce.to_string()); - - let resp = client.get("https://steamcommunity.com/mobileconf/ajaxop".parse::().unwrap()) - .header("X-Requested-With", "com.valvesoftware.android.steam.community") - .header(USER_AGENT, "Mozilla/5.0 (Linux; U; Android 4.1.1; en-us; Google Nexus 4 - 4.1.1 - API 16 - 768x1280 Build/JRO03S) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30") - .header(COOKIE, cookies.cookies(&url).unwrap()) - .query(&query_params) - .send()?; - - trace!("send_confirmation_ajax() response: {:?}", &resp); - debug!( - "send_confirmation_ajax() response status code: {}", - &resp.status() - ); - - let raw = resp.text()?; - debug!("send_confirmation_ajax() response body: {:?}", &raw); - - let mut deser = serde_json::Deserializer::from_str(raw.as_str()); - let body: SendConfirmationResponse = serde_path_to_error::deserialize(&mut deser)?; - - if !body.success { - return Err(anyhow!("Server responded with failure.")); - } - - Ok(()) - } - - pub fn accept_confirmation(&self, conf: &Confirmation) -> anyhow::Result<()> { - self.send_confirmation_ajax(conf, "allow".into()) - } - - pub fn deny_confirmation(&self, conf: &Confirmation) -> anyhow::Result<()> { - self.send_confirmation_ajax(conf, "cancel".into()) - } - - /// Steam Endpoint: `GET /mobileconf/details/:id` - pub fn get_confirmation_details(&self, conf: &Confirmation) -> anyhow::Result { - #[derive(Debug, Clone, Deserialize)] - struct ConfirmationDetailsResponse { - pub success: bool, - pub html: String, - } - - let url = "https://steamcommunity.com".parse::().unwrap(); - let cookies = self.build_cookie_jar(); - let client = reqwest::blocking::ClientBuilder::new() - .cookie_store(true) - .build()?; - - let time = steamapi::get_server_time()?.server_time(); - let query_params = self.get_confirmation_query_params("details", time); - - let resp = client.get(format!("https://steamcommunity.com/mobileconf/details/{}", conf.id).parse::().unwrap()) - .header("X-Requested-With", "com.valvesoftware.android.steam.community") - .header(USER_AGENT, "Mozilla/5.0 (Linux; U; Android 4.1.1; en-us; Google Nexus 4 - 4.1.1 - API 16 - 768x1280 Build/JRO03S) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30") - .header(COOKIE, cookies.cookies(&url).unwrap()) - .query(&query_params) - .send()?; - - let text = resp.text()?; - let mut deser = serde_json::Deserializer::from_str(text.as_str()); - let body: ConfirmationDetailsResponse = serde_path_to_error::deserialize(&mut deser)?; - - ensure!(body.success); - Ok(body.html) - } - /// Removes the mobile authenticator from the steam account. If this operation succeeds, this object can no longer be considered valid. /// Returns whether or not the operation was successful. pub fn remove_authenticator(&self, revocation_code: Option) -> anyhow::Result { @@ -295,16 +119,3 @@ impl SteamGuardAccount { } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_generate_confirmation_hash_for_time() { - assert_eq!( - generate_confirmation_hash_for_time(1617591917, "conf", "GQP46b73Ws7gr8GmZFR0sDuau5c="), - String::from("NaL8EIMhfy/7vBounJ0CvpKbrPk=") - ); - } -}