diff --git a/steamguard/src/accountlinker.rs b/steamguard/src/accountlinker.rs index 078adf6..89bb5c3 100644 --- a/steamguard/src/accountlinker.rs +++ b/steamguard/src/accountlinker.rs @@ -1,7 +1,6 @@ use crate::{ - steamapi::{ - AddAuthenticatorResponse, FinalizeAddAuthenticatorResponse, Session, SteamApiClient, - }, + api_responses::{AddAuthenticatorResponse, FinalizeAddAuthenticatorResponse}, + steamapi::{Session, SteamApiClient}, SteamGuardAccount, }; use log::*; diff --git a/steamguard/src/api_responses/ITwoFactorService.rs b/steamguard/src/api_responses/ITwoFactorService.rs new file mode 100644 index 0000000..160c61b --- /dev/null +++ b/steamguard/src/api_responses/ITwoFactorService.rs @@ -0,0 +1,130 @@ +use crate::{token::TwoFactorSecret, SteamGuardAccount}; + +use super::parse_json_string_as_number; +use serde::{Deserialize, Serialize}; + +/// Represents the response from `/ITwoFactorService/QueryTime/v0001` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryTimeResponse { + /// The time that the server will use to check your two factor code. + #[serde(deserialize_with = "parse_json_string_as_number")] + pub server_time: u64, + #[serde(deserialize_with = "parse_json_string_as_number")] + pub skew_tolerance_seconds: u64, + #[serde(deserialize_with = "parse_json_string_as_number")] + pub large_time_jink: u64, + pub probe_frequency_seconds: u64, + pub adjusted_time_probe_frequency_seconds: u64, + pub hint_probe_frequency_seconds: u64, + pub sync_timeout: u64, + pub try_again_seconds: u64, + pub max_attempts: u64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AddAuthenticatorResponse { + /// Shared secret between server and authenticator + #[serde(default)] + pub shared_secret: String, + /// Authenticator serial number (unique per token) + #[serde(default)] + pub serial_number: String, + /// code used to revoke authenticator + #[serde(default)] + pub revocation_code: String, + /// URI for QR code generation + #[serde(default)] + pub uri: String, + /// Current server time + #[serde(default, deserialize_with = "parse_json_string_as_number")] + pub server_time: u64, + /// Account name to display on token client + #[serde(default)] + pub account_name: String, + /// Token GID assigned by server + #[serde(default)] + pub token_gid: String, + /// Secret used for identity attestation (e.g., for eventing) + #[serde(default)] + pub identity_secret: String, + /// Spare shared secret + #[serde(default)] + pub secret_1: String, + /// Result code + pub status: i32, + #[serde(default)] + pub phone_number_hint: Option, +} + +impl AddAuthenticatorResponse { + pub fn to_steam_guard_account(self) -> SteamGuardAccount { + SteamGuardAccount { + shared_secret: TwoFactorSecret::parse_shared_secret(self.shared_secret).unwrap(), + serial_number: self.serial_number.clone(), + revocation_code: self.revocation_code.into(), + uri: self.uri.into(), + server_time: self.server_time, + account_name: self.account_name.clone(), + token_gid: self.token_gid.clone(), + identity_secret: self.identity_secret.into(), + secret_1: self.secret_1.into(), + fully_enrolled: false, + device_id: "".into(), + session: None, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FinalizeAddAuthenticatorResponse { + pub status: i32, + #[serde(deserialize_with = "parse_json_string_as_number")] + pub server_time: u64, + pub want_more: bool, + pub success: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RemoveAuthenticatorResponse { + pub success: bool, +} + +#[cfg(test)] +mod test { + use super::*; + use crate::api_responses::SteamApiResponse; + + #[test] + fn test_parse_add_auth_response() { + let result = serde_json::from_str::>( + include_str!("../fixtures/api-responses/add-authenticator-1.json"), + ); + + assert!( + matches!(result, Ok(_)), + "got error: {}", + result.unwrap_err() + ); + let resp = result.unwrap().response; + + assert_eq!(resp.server_time, 1628559846); + assert_eq!(resp.shared_secret, "wGwZx=sX5MmTxi6QgA3Gi"); + assert_eq!(resp.revocation_code, "R123456"); + } + + #[test] + fn test_parse_add_auth_response2() { + let result = serde_json::from_str::>( + include_str!("../fixtures/api-responses/add-authenticator-2.json"), + ); + + assert!( + matches!(result, Ok(_)), + "got error: {}", + result.unwrap_err() + ); + let resp = result.unwrap().response; + + assert_eq!(resp.status, 29); + } +} diff --git a/steamguard/src/api_responses/login.rs b/steamguard/src/api_responses/login.rs new file mode 100644 index 0000000..a327946 --- /dev/null +++ b/steamguard/src/api_responses/login.rs @@ -0,0 +1,137 @@ +use serde::{Deserialize, Deserializer, Serialize}; + +use super::parse_json_string_as_number; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginTransferParameters { + pub steamid: String, + pub token_secure: String, + pub auth: String, + pub remember_login: bool, + pub webcookie: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct OAuthData { + pub oauth_token: String, + pub steamid: String, + pub wgtoken: String, + pub wgtoken_secure: String, + #[serde(default)] + pub webcookie: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RsaResponse { + pub success: bool, + pub publickey_exp: String, + pub publickey_mod: String, + pub timestamp: String, + pub token_gid: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LoginResponse { + pub success: bool, + #[serde(default)] + pub login_complete: bool, + #[serde(default)] + pub captcha_needed: bool, + #[serde(default)] + pub captcha_gid: String, + #[serde(default, deserialize_with = "parse_json_string_as_number")] + pub emailsteamid: u64, + #[serde(default)] + pub emailauth_needed: bool, + #[serde(default)] + pub requires_twofactor: bool, + #[serde(default)] + pub message: String, + #[serde(default, deserialize_with = "oauth_data_from_string")] + pub oauth: Option, + pub transfer_urls: Option>, + pub transfer_parameters: Option, +} + +/// For some reason, the `oauth` field in the login response is a string of JSON, not a JSON object. +/// Deserializes to `Option` because the `oauth` field is not always there. +fn oauth_data_from_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + // for some reason, deserializing to &str doesn't work but this does. + let s: String = Deserialize::deserialize(deserializer)?; + let data: OAuthData = serde_json::from_str(s.as_str()).map_err(serde::de::Error::custom)?; + Ok(Some(data)) +} + +impl LoginResponse { + pub fn needs_transfer_login(&self) -> bool { + self.transfer_urls.is_some() || self.transfer_parameters.is_some() + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_oauth_data_parse() { + // This example is from a login response that did not contain any transfer URLs. + let oauth: OAuthData = serde_json::from_str("{\"steamid\":\"78562647129469312\",\"account_name\":\"feuarus\",\"oauth_token\":\"fd2fdb3d0717bcd2220d98c7ec61c7bd\",\"wgtoken\":\"72E7013D598A4F68C7E268F6FA3767D89D763732\",\"wgtoken_secure\":\"21061EA13C36D7C29812CAED900A215171AD13A2\",\"webcookie\":\"6298070A226E5DAD49938D78BCF36F7A7118FDD5\"}").unwrap(); + + assert_eq!(oauth.steamid, "78562647129469312"); + assert_eq!(oauth.oauth_token, "fd2fdb3d0717bcd2220d98c7ec61c7bd"); + assert_eq!(oauth.wgtoken, "72E7013D598A4F68C7E268F6FA3767D89D763732"); + assert_eq!( + oauth.wgtoken_secure, + "21061EA13C36D7C29812CAED900A215171AD13A2" + ); + assert_eq!(oauth.webcookie, "6298070A226E5DAD49938D78BCF36F7A7118FDD5"); + } + + #[test] + fn test_login_response_parse() { + let result = serde_json::from_str::(include_str!( + "../fixtures/api-responses/login-response1.json" + )); + + assert!( + matches!(result, Ok(_)), + "got error: {}", + result.unwrap_err() + ); + let resp = result.unwrap(); + + let oauth = resp.oauth.unwrap(); + assert_eq!(oauth.steamid, "78562647129469312"); + assert_eq!(oauth.oauth_token, "fd2fdb3d0717bad2220d98c7ec61c7bd"); + assert_eq!(oauth.wgtoken, "72E7013D598A4F68C7E268F6FA3767D89D763732"); + assert_eq!( + oauth.wgtoken_secure, + "21061EA13C36D7C29812CAED900A215171AD13A2" + ); + assert_eq!(oauth.webcookie, "6298070A226E5DAD49938D78BCF36F7A7118FDD5"); + } + + #[test] + fn test_login_response_parse_missing_webcookie() { + let result = serde_json::from_str::(include_str!( + "../fixtures/api-responses/login-response-missing-webcookie.json" + )); + + assert!( + matches!(result, Ok(_)), + "got error: {}", + result.unwrap_err() + ); + let resp = result.unwrap(); + + let oauth = resp.oauth.unwrap(); + assert_eq!(oauth.steamid, "92591609556178617"); + assert_eq!(oauth.oauth_token, "1cc83205dab2979e558534dab29f6f3aa"); + assert_eq!(oauth.wgtoken, "3EDA9DEF07D7B39361D95203525D8AFE82A"); + assert_eq!(oauth.wgtoken_secure, "F31641B9AFC2F8B0EE7B6F44D7E73EA3FA48"); + assert_eq!(oauth.webcookie, ""); + } +} diff --git a/steamguard/src/api_responses/mod.rs b/steamguard/src/api_responses/mod.rs new file mode 100644 index 0000000..cd225fc --- /dev/null +++ b/steamguard/src/api_responses/mod.rs @@ -0,0 +1,23 @@ +mod ITwoFactorService; +mod login; +mod phone_ajax; + +pub use login::*; +pub use phone_ajax::*; +pub use ITwoFactorService::*; + +use serde::{Deserialize, Deserializer}; + +pub(crate) fn parse_json_string_as_number<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + // for some reason, deserializing to &str doesn't work but this does. + let s: String = Deserialize::deserialize(deserializer)?; + Ok(s.parse().unwrap()) +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SteamApiResponse { + pub response: T, +} diff --git a/steamguard/src/api_responses/phone_ajax.rs b/steamguard/src/api_responses/phone_ajax.rs new file mode 100644 index 0000000..a692ed6 --- /dev/null +++ b/steamguard/src/api_responses/phone_ajax.rs @@ -0,0 +1,11 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct PhoneValidateResponse { + success: bool, + number: String, + is_valid: bool, + is_voip: bool, + is_fixed: bool, +} diff --git a/steamguard/src/lib.rs b/steamguard/src/lib.rs index 59fb957..810ae6b 100644 --- a/steamguard/src/lib.rs +++ b/steamguard/src/lib.rs @@ -4,7 +4,6 @@ use anyhow::Result; pub use confirmation::{Confirmation, ConfirmationType}; use hmacsha1::hmac_sha1; use log::*; -use regex::Regex; use reqwest::{ cookie::CookieStore, header::{COOKIE, USER_AGENT}, @@ -24,6 +23,7 @@ extern crate anyhow; extern crate maplit; mod accountlinker; +mod api_responses; mod confirmation; mod secret_string; pub mod steamapi; diff --git a/steamguard/src/steamapi.rs b/steamguard/src/steamapi.rs index 5d002da..a402712 100644 --- a/steamguard/src/steamapi.rs +++ b/steamguard/src/steamapi.rs @@ -1,5 +1,4 @@ -use crate::token::TwoFactorSecret; -use crate::SteamGuardAccount; +use crate::api_responses::*; use log::*; use reqwest::{ blocking::RequestBuilder, @@ -9,7 +8,7 @@ use reqwest::{ Url, }; use secrecy::{CloneableSecret, DebugSecret, ExposeSecret, SerializableSecret}; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::iter::FromIterator; use std::str::FromStr; @@ -21,77 +20,6 @@ lazy_static! { static ref STEAM_API_BASE: String = "https://api.steampowered.com".into(); } -#[derive(Debug, Clone, Deserialize)] -pub struct LoginResponse { - pub success: bool, - #[serde(default)] - pub login_complete: bool, - #[serde(default)] - pub captcha_needed: bool, - #[serde(default)] - pub captcha_gid: String, - #[serde(default, deserialize_with = "parse_json_string_as_number")] - pub emailsteamid: u64, - #[serde(default)] - pub emailauth_needed: bool, - #[serde(default)] - pub requires_twofactor: bool, - #[serde(default)] - pub message: String, - // #[serde(rename = "oauth")] - // oauth_raw: String, - #[serde(default, deserialize_with = "oauth_data_from_string")] - oauth: Option, - transfer_urls: Option>, - transfer_parameters: Option, -} - -/// For some reason, the `oauth` field in the login response is a string of JSON, not a JSON object. -/// Deserializes to `Option` because the `oauth` field is not always there. -fn oauth_data_from_string<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - // for some reason, deserializing to &str doesn't work but this does. - let s: String = Deserialize::deserialize(deserializer)?; - let data: OAuthData = serde_json::from_str(s.as_str()).map_err(serde::de::Error::custom)?; - Ok(Some(data)) -} - -impl LoginResponse { - pub fn needs_transfer_login(&self) -> bool { - self.transfer_urls.is_some() || self.transfer_parameters.is_some() - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct LoginTransferParameters { - steamid: String, - token_secure: String, - auth: String, - remember_login: bool, - webcookie: String, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct RsaResponse { - pub success: bool, - pub publickey_exp: String, - pub publickey_mod: String, - pub timestamp: String, - pub token_gid: String, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct OAuthData { - oauth_token: String, - steamid: String, - wgtoken: String, - wgtoken_secure: String, - #[serde(default)] - webcookie: String, -} - #[derive(Debug, Clone, Serialize, Deserialize, Zeroize)] #[zeroize(drop)] pub struct Session { @@ -113,24 +41,6 @@ impl SerializableSecret for Session {} impl CloneableSecret for Session {} impl DebugSecret for Session {} -/// Represents the response from `/ITwoFactorService/QueryTime/v0001` -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct QueryTimeResponse { - /// The time that the server will use to check your two factor code. - #[serde(deserialize_with = "parse_json_string_as_number")] - pub server_time: u64, - #[serde(deserialize_with = "parse_json_string_as_number")] - pub skew_tolerance_seconds: u64, - #[serde(deserialize_with = "parse_json_string_as_number")] - pub large_time_jink: u64, - pub probe_frequency_seconds: u64, - pub adjusted_time_probe_frequency_seconds: u64, - pub hint_probe_frequency_seconds: u64, - pub sync_timeout: u64, - pub try_again_seconds: u64, - pub max_attempts: u64, -} - /// Queries Steam for the current time. /// /// Endpoint: `/ITwoFactorService/QueryTime/v0001` @@ -565,189 +475,3 @@ impl SteamApiClient { return Ok(resp.response); } } - -#[test] -fn test_oauth_data_parse() { - // This example is from a login response that did not contain any transfer URLs. - let oauth: OAuthData = serde_json::from_str("{\"steamid\":\"78562647129469312\",\"account_name\":\"feuarus\",\"oauth_token\":\"fd2fdb3d0717bcd2220d98c7ec61c7bd\",\"wgtoken\":\"72E7013D598A4F68C7E268F6FA3767D89D763732\",\"wgtoken_secure\":\"21061EA13C36D7C29812CAED900A215171AD13A2\",\"webcookie\":\"6298070A226E5DAD49938D78BCF36F7A7118FDD5\"}").unwrap(); - - assert_eq!(oauth.steamid, "78562647129469312"); - assert_eq!(oauth.oauth_token, "fd2fdb3d0717bcd2220d98c7ec61c7bd"); - assert_eq!(oauth.wgtoken, "72E7013D598A4F68C7E268F6FA3767D89D763732"); - assert_eq!( - oauth.wgtoken_secure, - "21061EA13C36D7C29812CAED900A215171AD13A2" - ); - assert_eq!(oauth.webcookie, "6298070A226E5DAD49938D78BCF36F7A7118FDD5"); -} - -#[test] -fn test_login_response_parse() { - let result = serde_json::from_str::(include_str!( - "fixtures/api-responses/login-response1.json" - )); - - assert!( - matches!(result, Ok(_)), - "got error: {}", - result.unwrap_err() - ); - let resp = result.unwrap(); - - let oauth = resp.oauth.unwrap(); - assert_eq!(oauth.steamid, "78562647129469312"); - assert_eq!(oauth.oauth_token, "fd2fdb3d0717bad2220d98c7ec61c7bd"); - assert_eq!(oauth.wgtoken, "72E7013D598A4F68C7E268F6FA3767D89D763732"); - assert_eq!( - oauth.wgtoken_secure, - "21061EA13C36D7C29812CAED900A215171AD13A2" - ); - assert_eq!(oauth.webcookie, "6298070A226E5DAD49938D78BCF36F7A7118FDD5"); -} - -#[test] -fn test_login_response_parse_missing_webcookie() { - let result = serde_json::from_str::(include_str!( - "fixtures/api-responses/login-response-missing-webcookie.json" - )); - - assert!( - matches!(result, Ok(_)), - "got error: {}", - result.unwrap_err() - ); - let resp = result.unwrap(); - - let oauth = resp.oauth.unwrap(); - assert_eq!(oauth.steamid, "92591609556178617"); - assert_eq!(oauth.oauth_token, "1cc83205dab2979e558534dab29f6f3aa"); - assert_eq!(oauth.wgtoken, "3EDA9DEF07D7B39361D95203525D8AFE82A"); - assert_eq!(oauth.wgtoken_secure, "F31641B9AFC2F8B0EE7B6F44D7E73EA3FA48"); - assert_eq!(oauth.webcookie, ""); -} - -#[derive(Debug, Clone, Deserialize)] -pub struct SteamApiResponse { - pub response: T, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct AddAuthenticatorResponse { - /// Shared secret between server and authenticator - #[serde(default)] - pub shared_secret: String, - /// Authenticator serial number (unique per token) - #[serde(default)] - pub serial_number: String, - /// code used to revoke authenticator - #[serde(default)] - pub revocation_code: String, - /// URI for QR code generation - #[serde(default)] - pub uri: String, - /// Current server time - #[serde(default, deserialize_with = "parse_json_string_as_number")] - pub server_time: u64, - /// Account name to display on token client - #[serde(default)] - pub account_name: String, - /// Token GID assigned by server - #[serde(default)] - pub token_gid: String, - /// Secret used for identity attestation (e.g., for eventing) - #[serde(default)] - pub identity_secret: String, - /// Spare shared secret - #[serde(default)] - pub secret_1: String, - /// Result code - pub status: i32, - #[serde(default)] - pub phone_number_hint: Option, -} - -impl AddAuthenticatorResponse { - pub fn to_steam_guard_account(self) -> SteamGuardAccount { - SteamGuardAccount { - shared_secret: TwoFactorSecret::parse_shared_secret(self.shared_secret).unwrap(), - serial_number: self.serial_number.clone(), - revocation_code: self.revocation_code.into(), - uri: self.uri.into(), - server_time: self.server_time, - account_name: self.account_name.clone(), - token_gid: self.token_gid.clone(), - identity_secret: self.identity_secret.into(), - secret_1: self.secret_1.into(), - fully_enrolled: false, - device_id: "".into(), - session: None, - } - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct FinalizeAddAuthenticatorResponse { - pub status: i32, - #[serde(deserialize_with = "parse_json_string_as_number")] - pub server_time: u64, - pub want_more: bool, - pub success: bool, -} - -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct PhoneValidateResponse { - success: bool, - number: String, - is_valid: bool, - is_voip: bool, - is_fixed: bool, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct RemoveAuthenticatorResponse { - pub success: bool, -} - -fn parse_json_string_as_number<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - // for some reason, deserializing to &str doesn't work but this does. - let s: String = Deserialize::deserialize(deserializer)?; - Ok(s.parse().unwrap()) -} - -#[test] -fn test_parse_add_auth_response() { - let result = serde_json::from_str::>(include_str!( - "fixtures/api-responses/add-authenticator-1.json" - )); - - assert!( - matches!(result, Ok(_)), - "got error: {}", - result.unwrap_err() - ); - let resp = result.unwrap().response; - - assert_eq!(resp.server_time, 1628559846); - assert_eq!(resp.shared_secret, "wGwZx=sX5MmTxi6QgA3Gi"); - assert_eq!(resp.revocation_code, "R123456"); -} - -#[test] -fn test_parse_add_auth_response2() { - let result = serde_json::from_str::>(include_str!( - "fixtures/api-responses/add-authenticator-2.json" - )); - - assert!( - matches!(result, Ok(_)), - "got error: {}", - result.unwrap_err() - ); - let resp = result.unwrap().response; - - assert_eq!(resp.status, 29); -} diff --git a/steamguard/src/userlogin.rs b/steamguard/src/userlogin.rs index 4400f61..e7a6e46 100644 --- a/steamguard/src/userlogin.rs +++ b/steamguard/src/userlogin.rs @@ -1,4 +1,7 @@ -use crate::steamapi::{LoginResponse, RsaResponse, Session, SteamApiClient}; +use crate::{ + api_responses::{LoginResponse, RsaResponse}, + steamapi::{Session, SteamApiClient}, +}; use log::*; use rsa::{PublicKey, RsaPublicKey}; use secrecy::ExposeSecret;