diff --git a/Cargo.lock b/Cargo.lock index dc5c2dd..ad3ad67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1552,6 +1552,16 @@ dependencies = [ "tendril", ] +[[package]] +name = "secrets" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b9d59a8542189a7931c0f18811e59db46529efab6d566541625590e994b945" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "security-framework" version = "2.1.2" @@ -1830,6 +1840,7 @@ dependencies = [ "reqwest", "rsa", "scraper", + "secrets", "serde", "serde_json", "standback", diff --git a/src/accountmanager.rs b/src/accountmanager.rs index cb9fb1e..2b6faac 100644 --- a/src/accountmanager.rs +++ b/src/accountmanager.rs @@ -243,21 +243,24 @@ mod tests { } #[test] - fn test_should_save_and_load_manifest() { - let tmp_dir = TempDir::new("steamguard-cli-test").unwrap(); + fn test_should_save_and_load_manifest() -> anyhow::Result<()> { + let tmp_dir = TempDir::new("steamguard-cli-test")?; let manifest_path = tmp_dir.path().join("manifest.json"); + println!("tempdir: {}", manifest_path.display()); let mut manifest = Manifest::new(manifest_path.as_path()); let mut account = SteamGuardAccount::new(); account.account_name = "asdf1234".into(); account.revocation_code = "R12345".into(); - account.shared_secret = "secret".into(); + account.shared_secret = steamguard::token::TwoFactorSecret::parse_shared_secret( + "zvIayp3JPvtvX/QGHqsqKBk/44s=".into(), + )?; manifest.add_account(account); - assert!(matches!(manifest.save(&None), Ok(_))); + manifest.save(&None)?; - let mut loaded_manifest = Manifest::load(manifest_path.as_path()).unwrap(); + let mut loaded_manifest = Manifest::load(manifest_path.as_path())?; assert_eq!(loaded_manifest.entries.len(), 1); assert_eq!(loaded_manifest.entries[0].filename, "asdf1234.maFile"); - assert!(matches!(loaded_manifest.load_accounts(&None), Ok(_))); + loaded_manifest.load_accounts(&None)?; assert_eq!( loaded_manifest.entries.len(), loaded_manifest.accounts.len() @@ -272,8 +275,11 @@ mod tests { ); assert_eq!( loaded_manifest.accounts[0].lock().unwrap().shared_secret, - "secret" + steamguard::token::TwoFactorSecret::parse_shared_secret( + "zvIayp3JPvtvX/QGHqsqKBk/44s=".into() + )?, ); + return Ok(()); } #[test] @@ -285,7 +291,10 @@ mod tests { let mut account = SteamGuardAccount::new(); account.account_name = "asdf1234".into(); account.revocation_code = "R12345".into(); - account.shared_secret = "secret".into(); + account.shared_secret = steamguard::token::TwoFactorSecret::parse_shared_secret( + "zvIayp3JPvtvX/QGHqsqKBk/44s=".into(), + ) + .unwrap(); manifest.add_account(account); manifest.entries[0].encryption = Some(EntryEncryptionParams::generate()); assert!(matches!(manifest.save(&passkey), Ok(_))); @@ -308,7 +317,10 @@ mod tests { ); assert_eq!( loaded_manifest.accounts[0].lock().unwrap().shared_secret, - "secret" + steamguard::token::TwoFactorSecret::parse_shared_secret( + "zvIayp3JPvtvX/QGHqsqKBk/44s=".into() + ) + .unwrap(), ); } @@ -321,7 +333,10 @@ mod tests { let mut account = SteamGuardAccount::new(); account.account_name = "asdf1234".into(); account.revocation_code = "R12345".into(); - account.shared_secret = "secret".into(); + account.shared_secret = steamguard::token::TwoFactorSecret::parse_shared_secret( + "zvIayp3JPvtvX/QGHqsqKBk/44s=".into(), + ) + .unwrap(); account.uri = "otpauth://;laksdjf;lkasdjf;lkasdj;flkasdjlkf;asjdlkfjslk;adjfl;kasdjf;lksdjflk;asjd;lfajs;ldkfjaslk;djf;lsakdjf;lksdj".into(); account.token_gid = "asdf1234".into(); manifest.add_account(account); @@ -346,7 +361,10 @@ mod tests { ); assert_eq!( loaded_manifest.accounts[0].lock().unwrap().shared_secret, - "secret" + steamguard::token::TwoFactorSecret::parse_shared_secret( + "zvIayp3JPvtvX/QGHqsqKBk/44s=".into() + ) + .unwrap(), ); return Ok(()); @@ -360,7 +378,10 @@ mod tests { let mut account = SteamGuardAccount::new(); account.account_name = "asdf1234".into(); account.revocation_code = "R12345".into(); - account.shared_secret = "secret".into(); + account.shared_secret = steamguard::token::TwoFactorSecret::parse_shared_secret( + "zvIayp3JPvtvX/QGHqsqKBk/44s=".into(), + ) + .unwrap(); manifest.add_account(account); assert!(matches!(manifest.save(&None), Ok(_))); std::fs::remove_file(&manifest_path).unwrap(); @@ -391,7 +412,10 @@ mod tests { ); assert_eq!( loaded_manifest.accounts[0].lock().unwrap().shared_secret, - "secret" + steamguard::token::TwoFactorSecret::parse_shared_secret( + "zvIayp3JPvtvX/QGHqsqKBk/44s=".into() + ) + .unwrap(), ); } diff --git a/src/fixtures/maFiles/compat/1-account-encrypted/1234.maFile b/src/fixtures/maFiles/compat/1-account-encrypted/1234.maFile index 1ee5507..e4a9df8 100644 --- a/src/fixtures/maFiles/compat/1-account-encrypted/1234.maFile +++ b/src/fixtures/maFiles/compat/1-account-encrypted/1234.maFile @@ -1 +1 @@ -ruZBuGG4+r5RU4JPjjHgkBLB205+1qSJjikDR5jrOpZsYgPSdJV9N4Rp7OnOQ63YBSiu1JpoNIUrSxJnW4UaPBIkgbWHsAngSiuZAhkZq/FJuRie0OcGN2NnwuE2xZUQNfREqU4DTiq8VLRBqvHTueeI5pReJ1vGradhjOgyWVG4MxMlVylVTIPqmZf1NrfBQbEL6Nip3dRGdzXsLhtnxhr8/0meBNOwtk+5sm6b+XVEd81aSsQNYEK5RUdmzNbOdK+UGUTOgACw6rBoBUP3zpv7U0gEC7u+iIIv1CQXp0HUH4p06Edu8r0APDQHIgt8/WmnRtm6EAWfgo/RxbMoTFTKo6Qfa18baGCkQnNJPiiIIQc1e4/31mw9DbGhDfkYJL4O5A9wbWeSRg92qxe2d4odHx0NIfyZ9CsKfIYc4/azq0I7K3hjcpt5JgYOQowm4YrYMQrmkyw90HMAMcOHoKtMVU5i58JumY3cYAnf+skCOra29D0Py7k0mGqm+9W2OWmO+XE3QfhuOK0FwFts5umyVI5AK4qZG90ioPt6CHgDZKgCdzuV7iTWwhuhd+EYalcbuSAQHX5cnNVVt9Z+0c9t+fnjz0t9w8wChpKhRcFGUeMvzOFO0lhP5kneJ3N/xKo0bhO2spjzzUw8lXE5wFv7TFmr058m+rzW/ucvtcl6KSvvr0RGNBcwUo+G8Q4jvJNTDFhqT0ElnWpgF2Rxb9m5UKZz5bfLszlf0KbSO2ZXg9wO65/itqXQnKzq1ALI \ No newline at end of file +ZQqJk4KW7pb5bndkra244z3ttIks58UplODn5IgljrOK3bKORSwrnZYb9Iv6YsirmrT0hQ3tx381GhQu4Nyj/PFHHCfFTrduaCoMLRrHDsmsexOW93Yo02acHXNfPSvxtjGfZpsuIZVlhVy8JDH/ESXp88cKn2zqjXQWu6pLnah1YPlpZuTfArw69+Em7V1OH1CoKnsYuhCo/x4u7fXhMNgDlRBvDbO4enGzaixonPu9er5Zp6iNEeuAUqmD0DHASygNmzBhUBHv8Avng8YKbvu611yQVT2KybnIoL+Q11Y36GoFhWskmG3lLwh/1OlReGwJ1iX0lDDthoel/Ygj6EC2+wkR8V7eMQf48R15xdBILYTzcrjtiuLCr9MBc/HM/ToEa3QCGwGkXvshR/meJ1BiqaRARKfvJcj4eMSpiUvhDe1QFXXjfXRdetJcknyJ8Pv6v10G/OV3ELYwdx2dYL5C+Ao4qj9QCjoD8bb/juCjtZoSxMncbm4T7ORbXs/Ulx+TEuOUmRAjxr+zaWzzO7ZfYJMhIPz+LSixKpaVmxP89DEK5LJ1T6jA50QmPft6AbOMuoq99haWH9lMgqrIfBB+ZNNHSEE9PwMUxhX/TQP3oJgmrnZcMV4nBOcovbWM5s+odu2mvzoAcRMHVxEztASbMBwdJ7amJvoRRrXTtp642FQHAe8pFPlW14x1hShXAO4YfYkmmAhLqlujammuQ6bRg7XjBvh0zE88i5UBRiRsH+MV35u3c0ciugUmbrAZ0u9Yfv19MNSMVNMsREDuxQ== \ No newline at end of file diff --git a/src/fixtures/maFiles/compat/1-account-encrypted/manifest.json b/src/fixtures/maFiles/compat/1-account-encrypted/manifest.json index fed7737..c619b39 100644 --- a/src/fixtures/maFiles/compat/1-account-encrypted/manifest.json +++ b/src/fixtures/maFiles/compat/1-account-encrypted/manifest.json @@ -1 +1 @@ -{"encrypted":true,"first_run":true,"entries":[{"encryption_iv":"xLHUZJzinEfUwLzEzqfI7Q==","encryption_salt":"bcp2z6P88A0=","filename":"1234.maFile","steamid":1234}],"periodic_checking":false,"periodic_checking_interval":5,"periodic_checking_checkall":false,"auto_confirm_market_transactions":false,"auto_confirm_trades":false} \ No newline at end of file +{"encrypted":true,"first_run":true,"entries":[{"encryption_iv":"ifChnv66eA+/dYqGsQMIOA==","encryption_salt":"O2K8FAOWK9c=","filename":"1234.maFile","steamid":1234}],"periodic_checking":false,"periodic_checking_interval":5,"periodic_checking_checkall":false,"auto_confirm_market_transactions":false,"auto_confirm_trades":false} \ No newline at end of file diff --git a/src/fixtures/maFiles/compat/1-account/1234.maFile b/src/fixtures/maFiles/compat/1-account/1234.maFile index 8576895..344ac66 100644 --- a/src/fixtures/maFiles/compat/1-account/1234.maFile +++ b/src/fixtures/maFiles/compat/1-account/1234.maFile @@ -1 +1 @@ -{"shared_secret":"secret1234","serial_number":"kljasfhds","revocation_code":"R12345","uri":"otpauth://totp/Steam:example?secret=ASDF&issuer=Steam","server_time":1602522478,"account_name":"example","token_gid":"jkkjlhkhjgf","identity_secret":"kjsdlwowiqe=","secret_1":"sklduhfgsdlkjhf=","status":1,"device_id":"android:99d2ad0e-4bad-4247-b111-26393aae0be3","fully_enrolled":true,"Session":{"SessionID":"a;lskdjf","SteamLogin":"983498437543","SteamLoginSecure":"dlkjdsl;j%7C%32984730298","WebCookie":";lkjsed;klfjas98093","OAuthToken":"asdk;lf;dsjlkfd","SteamID":1234}} \ No newline at end of file +{"shared_secret":"zvIayp3JPvtvX/QGHqsqKBk/44s=","serial_number":"kljasfhds","revocation_code":"R12345","uri":"otpauth://totp/Steam:example?secret=ASDF&issuer=Steam","server_time":1602522478,"account_name":"example","token_gid":"jkkjlhkhjgf","identity_secret":"kjsdlwowiqe=","secret_1":"sklduhfgsdlkjhf=","status":1,"device_id":"android:99d2ad0e-4bad-4247-b111-26393aae0be3","fully_enrolled":true,"Session":{"SessionID":"a;lskdjf","SteamLogin":"983498437543","SteamLoginSecure":"dlkjdsl;j%7C%32984730298","WebCookie":";lkjsed;klfjas98093","OAuthToken":"asdk;lf;dsjlkfd","SteamID":1234}} \ No newline at end of file diff --git a/steamguard/Cargo.toml b/steamguard/Cargo.toml index f7e91d6..f66da0e 100644 --- a/steamguard/Cargo.toml +++ b/steamguard/Cargo.toml @@ -24,3 +24,4 @@ log = "0.4.14" scraper = "0.12.0" maplit = "1.0.2" thiserror = "1.0.26" +secrets = "1.1.0" diff --git a/steamguard/src/lib.rs b/steamguard/src/lib.rs index ad42e40..f1f3a38 100644 --- a/steamguard/src/lib.rs +++ b/steamguard/src/lib.rs @@ -1,3 +1,4 @@ +use crate::token::TwoFactorSecret; pub use accountlinker::{AccountLinkError, AccountLinker, FinalizeLinkError}; use anyhow::Result; pub use confirmation::{Confirmation, ConfirmationType}; @@ -24,6 +25,7 @@ extern crate maplit; mod accountlinker; mod confirmation; pub mod steamapi; +pub mod token; mod userlogin; // const STEAMAPI_BASE: String = "https://api.steampowered.com"; @@ -42,7 +44,7 @@ pub struct SteamGuardAccount { pub account_name: String, pub serial_number: String, pub revocation_code: String, - pub shared_secret: String, + pub shared_secret: TwoFactorSecret, pub token_gid: String, pub identity_secret: String, pub server_time: u64, @@ -58,12 +60,6 @@ fn build_time_bytes(time: i64) -> [u8; 8] { return time.to_be_bytes(); } -pub fn parse_shared_secret(secret: String) -> anyhow::Result<[u8; 20]> { - ensure!(secret.len() != 0, "unable to parse empty shared secret"); - let result = base64::decode(secret)?.try_into(); - return Ok(result.unwrap()); -} - fn generate_confirmation_hash_for_time(time: i64, tag: &str, identity_secret: &String) -> String { let decode: &[u8] = &base64::decode(&identity_secret).unwrap(); let time_bytes = build_time_bytes(time); @@ -80,7 +76,7 @@ impl SteamGuardAccount { account_name: String::from(""), serial_number: String::from(""), revocation_code: String::from(""), - shared_secret: String::from(""), + shared_secret: TwoFactorSecret::new(), token_gid: String::from(""), identity_secret: String::from(""), server_time: 0, @@ -93,29 +89,7 @@ impl SteamGuardAccount { } pub fn generate_code(&self, time: i64) -> String { - let steam_guard_code_translations: [u8; 26] = [ - 50, 51, 52, 53, 54, 55, 56, 57, 66, 67, 68, 70, 71, 72, 74, 75, 77, 78, 80, 81, 82, 84, - 86, 87, 88, 89, - ]; - - // this effectively makes it so that it creates a new code every 30 seconds. - let time_bytes: [u8; 8] = build_time_bytes(time / 30i64); - let shared_secret: [u8; 20] = parse_shared_secret(self.shared_secret.clone()).unwrap(); - let hashed_data = hmacsha1::hmac_sha1(&shared_secret, &time_bytes); - let mut code_array: [u8; 5] = [0; 5]; - let b = (hashed_data[19] & 0xF) as usize; - let mut code_point: i32 = ((hashed_data[b] & 0x7F) as i32) << 24 - | ((hashed_data[b + 1] & 0xFF) as i32) << 16 - | ((hashed_data[b + 2] & 0xFF) as i32) << 8 - | ((hashed_data[b + 3] & 0xFF) as i32); - - for i in 0..5 { - code_array[i] = steam_guard_code_translations - [code_point as usize % steam_guard_code_translations.len()]; - code_point /= steam_guard_code_translations.len() as i32; - } - - return String::from_utf8(code_array.iter().map(|c| *c).collect()).unwrap(); + return self.shared_secret.generate_code(time); } fn get_confirmation_query_params(&self, tag: &str) -> HashMap<&str, String> { @@ -306,26 +280,6 @@ fn parse_confirmations(text: String) -> anyhow::Result> { mod tests { use super::*; - #[test] - fn test_build_time_bytes() { - let t1 = build_time_bytes(1617591917i64); - let t2: [u8; 8] = [0, 0, 0, 0, 96, 106, 126, 109]; - assert!( - t1.iter().zip(t2.iter()).all(|(a, b)| a == b), - "Arrays are not equal, got {:?}", - t1 - ); - } - - #[test] - fn test_generate_code() { - let mut account = SteamGuardAccount::new(); - account.shared_secret = String::from("zvIayp3JPvtvX/QGHqsqKBk/44s="); - - let code = account.generate_code(1616374841i64); - assert_eq!(code, "2F9J5") - } - #[test] fn test_generate_confirmation_hash_for_time() { assert_eq!( diff --git a/steamguard/src/steamapi.rs b/steamguard/src/steamapi.rs index 4a96864..c95ec05 100644 --- a/steamguard/src/steamapi.rs +++ b/steamguard/src/steamapi.rs @@ -1,3 +1,5 @@ +use crate::token::TwoFactorSecret; +use crate::SteamGuardAccount; use log::*; use reqwest::{ blocking::RequestBuilder, @@ -12,8 +14,6 @@ use std::iter::FromIterator; use std::str::FromStr; use std::time::{SystemTime, UNIX_EPOCH}; -use crate::SteamGuardAccount; - lazy_static! { static ref STEAM_COOKIE_URL: Url = "https://steamcommunity.com".parse::().unwrap(); static ref STEAM_API_BASE: String = "https://api.steampowered.com".into(); @@ -609,9 +609,9 @@ pub struct AddAuthenticatorResponse { } impl AddAuthenticatorResponse { - pub fn to_steam_guard_account(&self) -> SteamGuardAccount { + pub fn to_steam_guard_account(self) -> SteamGuardAccount { SteamGuardAccount { - shared_secret: self.shared_secret.clone(), + shared_secret: TwoFactorSecret::parse_shared_secret(self.shared_secret).unwrap(), serial_number: self.serial_number.clone(), revocation_code: self.revocation_code.clone(), uri: self.uri.clone(), diff --git a/steamguard/src/token.rs b/steamguard/src/token.rs new file mode 100644 index 0000000..a9d6a49 --- /dev/null +++ b/steamguard/src/token.rs @@ -0,0 +1,141 @@ +use secrets::SecretBox; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::convert::TryInto; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TwoFactorSecret(SecretBox<[u8; 20]>); + +impl TwoFactorSecret { + pub fn new() -> Self { + return Self(SecretBox::from(&mut [0u8; 20])); + } + + pub fn parse_shared_secret(secret: String) -> anyhow::Result { + ensure!(secret.len() != 0, "unable to parse empty shared secret"); + let mut result: [u8; 20] = base64::decode(secret)?.try_into().unwrap(); + return Ok(Self(SecretBox::from(&mut result))); + } + + /// Generate a 5 character 2FA code to that can be used to log in to Steam. + pub fn generate_code(&self, time: i64) -> String { + let steam_guard_code_translations: [u8; 26] = [ + 50, 51, 52, 53, 54, 55, 56, 57, 66, 67, 68, 70, 71, 72, 74, 75, 77, 78, 80, 81, 82, 84, + 86, 87, 88, 89, + ]; + + // this effectively makes it so that it creates a new code every 30 seconds. + let time_bytes: [u8; 8] = build_time_bytes(time / 30i64); + let hashed_data = hmacsha1::hmac_sha1(&self.0.borrow().to_vec(), &time_bytes); + let mut code_array: [u8; 5] = [0; 5]; + let b = (hashed_data[19] & 0xF) as usize; + let mut code_point: i32 = ((hashed_data[b] & 0x7F) as i32) << 24 + | ((hashed_data[b + 1] & 0xFF) as i32) << 16 + | ((hashed_data[b + 2] & 0xFF) as i32) << 8 + | ((hashed_data[b + 3] & 0xFF) as i32); + + for i in 0..5 { + code_array[i] = steam_guard_code_translations + [code_point as usize % steam_guard_code_translations.len()]; + code_point /= steam_guard_code_translations.len() as i32; + } + + return String::from_utf8(code_array.iter().map(|c| *c).collect()).unwrap(); + } +} + +impl Serialize for TwoFactorSecret { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(base64::encode(&self.0.borrow().to_vec()).as_str()) + } +} + +impl<'de> Deserialize<'de> for TwoFactorSecret { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(TwoFactorSecret::parse_shared_secret(String::deserialize(deserializer)?).unwrap()) + } +} + +fn build_time_bytes(time: i64) -> [u8; 8] { + return time.to_be_bytes(); +} + +mod tests { + use super::*; + + #[test] + fn test_serialize() -> anyhow::Result<()> { + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] + struct FooBar { + secret: TwoFactorSecret, + } + + let secret = FooBar { + secret: TwoFactorSecret::parse_shared_secret("zvIayp3JPvtvX/QGHqsqKBk/44s=".into())?, + }; + + let serialized = serde_json::to_string(&secret)?; + assert_eq!(serialized, "{\"secret\":\"zvIayp3JPvtvX/QGHqsqKBk/44s=\"}"); + + return Ok(()); + } + + #[test] + fn test_deserialize() -> anyhow::Result<()> { + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] + struct FooBar { + secret: TwoFactorSecret, + } + + let secret: FooBar = + serde_json::from_str(&"{\"secret\":\"zvIayp3JPvtvX/QGHqsqKBk/44s=\"}")?; + + let code = secret.secret.generate_code(1616374841i64); + assert_eq!(code, "2F9J5"); + + return Ok(()); + } + + #[test] + fn test_serialize_and_deserialize() -> anyhow::Result<()> { + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] + struct FooBar { + secret: TwoFactorSecret, + } + + let secret = FooBar { + secret: TwoFactorSecret::parse_shared_secret("zvIayp3JPvtvX/QGHqsqKBk/44s=".into())?, + }; + + let serialized = serde_json::to_string(&secret)?; + let deserialized: FooBar = serde_json::from_str(&serialized)?; + assert_eq!(deserialized, secret); + + return Ok(()); + } + + #[test] + fn test_build_time_bytes() { + let t1 = build_time_bytes(1617591917i64); + let t2: [u8; 8] = [0, 0, 0, 0, 96, 106, 126, 109]; + assert!( + t1.iter().zip(t2.iter()).all(|(a, b)| a == b), + "Arrays are not equal, got {:?}", + t1 + ); + } + + #[test] + fn test_generate_code() -> anyhow::Result<()> { + let secret = TwoFactorSecret::parse_shared_secret("zvIayp3JPvtvX/QGHqsqKBk/44s=".into())?; + + let code = secret.generate_code(1616374841i64); + assert_eq!(code, "2F9J5"); + return Ok(()); + } +}