diff --git a/src/accountmanager.rs b/src/accountmanager.rs index 440edec..106b713 100644 --- a/src/accountmanager.rs +++ b/src/accountmanager.rs @@ -16,6 +16,7 @@ mod legacy; pub mod manifest; pub mod migrate; mod steamv2; +mod winauth; pub use manifest::*; diff --git a/src/accountmanager/migrate.rs b/src/accountmanager/migrate.rs index bf14ffe..3354910 100644 --- a/src/accountmanager/migrate.rs +++ b/src/accountmanager/migrate.rs @@ -1,6 +1,6 @@ use std::{fs::File, io::Read, path::Path}; -use log::debug; +use log::*; use secrecy::SecretString; use serde::{de::Error, Deserialize}; use steamguard::SteamGuardAccount; @@ -12,6 +12,7 @@ use super::{ legacy::{SdaAccount, SdaManifest}, manifest::ManifestV1, steamv2::SteamMobileV2, + winauth::parse_winauth_exports, EntryLoader, Manifest, }; @@ -261,23 +262,48 @@ impl From for SteamGuardAccount { } } -pub fn load_and_upgrade_external_account(path: &Path) -> anyhow::Result { - let file = File::open(path)?; - let mut deser = serde_json::Deserializer::from_reader(&file); - let account: ExternalAccount = serde_path_to_error::deserialize(&mut deser) - .map_err(|err| anyhow::anyhow!("Failed to deserialize account: {}", err))?; - let mut account = MigratingAccount::External(account); - while !account.is_latest() { - account = account.upgrade(); - } +pub fn load_and_upgrade_external_accounts(path: &Path) -> anyhow::Result> { + let mut file = File::open(path)?; + let mut buf = vec![]; + file.read_to_end(&mut buf)?; + let mut deser = serde_json::Deserializer::from_slice(&buf); + let accounts = match serde_path_to_error::deserialize(&mut deser) { + Ok(account) => { + vec![MigratingAccount::External(account)] + } + Err(json_err) => { + // the file is not JSON, so it's probably a winauth export + match parse_winauth_exports(buf) { + Ok(accounts) => accounts + .into_iter() + .map(MigratingAccount::External) + .collect(), + Err(winauth_err) => { + bail!( + "Failed to parse as JSON: {}\nFailed to parse as Winauth export: {}", + json_err, + winauth_err + ) + } + } + } + }; - Ok(account.into()) + Ok(accounts + .into_iter() + .map(|mut account| { + while !account.is_latest() { + account = account.upgrade(); + } + account.into() + }) + .collect()) } #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] #[allow(clippy::large_enum_variant)] -enum ExternalAccount { +pub(crate) enum ExternalAccount { Sda(SdaAccount), SteamMobileV2(SteamMobileV2), } @@ -390,10 +416,16 @@ mod tests { account_name: "afarihm", steam_id: 76561199441992970, }, + Test { + mafile: "src/fixtures/maFiles/compat/winauth/exports.txt", + account_name: "example", + steam_id: 1234, + }, ]; for case in cases { eprintln!("testing: {:?}", case); - let account = load_and_upgrade_external_account(Path::new(case.mafile))?; + let accounts = load_and_upgrade_external_accounts(Path::new(case.mafile))?; + let account = accounts[0].clone(); assert_eq!(account.account_name, case.account_name); assert_eq!(account.steam_id, case.steam_id); } diff --git a/src/accountmanager/winauth.rs b/src/accountmanager/winauth.rs new file mode 100644 index 0000000..077aa36 --- /dev/null +++ b/src/accountmanager/winauth.rs @@ -0,0 +1,50 @@ +//! Accounts exported from Winauth are in the following format: +//! +//! One account per line, with each account represented as a URL. +//! +//! ```ignore +//! otpauth://totp/Steam:?secret=&digits=5&issuer=Steam&deviceid=&data= +//! ``` +//! +//! The `data` field is a URL encoded JSON object with the following fields: +//! +//! ```json +//! {"steamid":"","status":1,"shared_secret":"","serial_number":"","revocation_code":"","uri":"","server_time":"","account_name":"","token_gid":"","identity_secret":"","secret_1":"","steamguard_scheme":"2"} +//! ``` + +use anyhow::Context; +use log::*; +use reqwest::Url; + +use super::migrate::ExternalAccount; + +pub(crate) fn parse_winauth_exports(buf: Vec) -> anyhow::Result> { + let buf = String::from_utf8(buf)?; + let mut accounts = Vec::new(); + for line in buf.split('\n') { + if line.is_empty() { + continue; + } + let url = Url::parse(line).context("parsing as winauth export URL")?; + let mut query = url.query_pairs(); + let issuer = query + .find(|(key, _)| key == "issuer") + .context("missing issuer field")? + .1; + if issuer != "Steam" { + debug!("skipping non-Steam account: {}", issuer); + continue; + } + let data = query + .find(|(key, _)| key == "data") + .context("missing data field")? + .1; + + trace!("data: {}", data); + + let mut deser = serde_json::Deserializer::from_str(&data); + let account = serde_path_to_error::deserialize(&mut deser)?; + accounts.push(account); + } + Ok(accounts) +} diff --git a/src/commands/import.rs b/src/commands/import.rs index b7c4770..1908c2f 100644 --- a/src/commands/import.rs +++ b/src/commands/import.rs @@ -25,6 +25,7 @@ where manager: &mut AccountManager, _args: &GlobalArgs, ) -> anyhow::Result<()> { + let mut accounts_added = 0; for file_path in self.files.iter() { debug!("loading entry: {:?}", file_path); match manager.import_account(file_path) { @@ -38,25 +39,31 @@ where debug!("Falling back to external account import",); let path = Path::new(&file_path); - let account = - match crate::accountmanager::migrate::load_and_upgrade_external_account( + let accounts = + match crate::accountmanager::migrate::load_and_upgrade_external_accounts( path, ) { - Ok(account) => account, + Ok(accounts) => accounts, Err(err) => { error!("Failed to import account: {} {}", &file_path, err); error!("The original error was: {}", orig_err); continue; } }; - manager.add_account(account); - info!("Imported account: {}", &file_path); + for account in accounts { + manager.add_account(account); + info!("Imported account: {}", &file_path); + accounts_added += 1; + } } Err(err) => { bail!("Failed to import account: {} {}", &file_path, err); } } } + if accounts_added > 0 { + info!("Imported {} accounts", accounts_added); + } manager.save()?; Ok(()) diff --git a/src/fixtures/maFiles/compat/winauth/exports.txt b/src/fixtures/maFiles/compat/winauth/exports.txt new file mode 100644 index 0000000..5dd7b0a --- /dev/null +++ b/src/fixtures/maFiles/compat/winauth/exports.txt @@ -0,0 +1,2 @@ +otpauth://totp/Steam:example?secret=ASDF&issuer=Steam&data=%7B%22shared%5Fsecret%22%3A%22zvIayp3JPvtvX%2FQGHqsqKBk%2F44s%3D%22%2C%22serial%5Fnumber%22%3A%22kljasfhds%22%2C%22revocation%5Fcode%22%3A%22R12345%22%2C%22uri%22%3A%22otpauth%3A%2F%2Ftotp%2FSteam%3Aexample%3Fsecret%3DASDF%26issuer%3DSteam%22%2C%22server%5Ftime%22%3A1602522478%2C%22account%5Fname%22%3A%22example%22%2C%22token%5Fgid%22%3A%22jkkjlhkhjgf%22%2C%22identity%5Fsecret%22%3A%22kjsdlwowiqe%3D%22%2C%22secret%5F1%22%3A%22sklduhfgsdlkjhf%3D%22%2C%22status%22%3A1%2C%22device%5Fid%22%3A%22android%3A99d2ad0e%2D4bad%2D4247%2Db111%2D26393aae0be3%22%2C%22fully%5Fenrolled%22%3Atrue%2C%22Session%22%3A%7B%22SessionID%22%3A%22a%3Blskdjf%22%2C%22SteamLogin%22%3A%22983498437543%22%2C%22SteamLoginSecure%22%3A%22dlkjdsl%3Bj%257C%2532984730298%22%2C%22WebCookie%22%3A%22%3Blkjsed%3Bklfjas98093%22%2C%22OAuthToken%22%3A%22asdk%3Blf%3Bdsjlkfd%22%2C%22SteamID%22%3A1234%7D%7D +otpauth://totp/Steam:example2?secret=ASDF&issuer=Steam&data=%7B%22shared%5Fsecret%22%3A%22zvIayp3JPvtvX%2FQGHqsqKBk%2F44s%3D%22%2C%22serial%5Fnumber%22%3A%22kljasfhds%22%2C%22revocation%5Fcode%22%3A%22R56789%22%2C%22uri%22%3A%22otpauth%3A%2F%2Ftotp%2FSteam%3Aexample%3Fsecret%3DASDF%26issuer%3DSteam%22%2C%22server%5Ftime%22%3A1602522478%2C%22account%5Fname%22%3A%22example2%22%2C%22token%5Fgid%22%3A%22jkkjlhkhjgf%22%2C%22identity%5Fsecret%22%3A%22kjsdlwowiqe%3D%22%2C%22secret%5F1%22%3A%22sklduhfgsdlkjhf%3D%22%2C%22status%22%3A1%2C%22device%5Fid%22%3A%22android%3A99d2ad0e%2D4bad%2D4247%2Db111%2D26393aae0be3%22%2C%22fully%5Fenrolled%22%3Atrue%2C%22Session%22%3A%7B%22SessionID%22%3A%22a%3Blskdjf%22%2C%22SteamLogin%22%3A%22983498437543%22%2C%22SteamLoginSecure%22%3A%22dlkjdsl%3Bj%257C%2532984730298%22%2C%22WebCookie%22%3A%22%3Blkjsed%3Bklfjas98093%22%2C%22OAuthToken%22%3A%22asdk%3Blf%3Bdsjlkfd%22%2C%22SteamID%22%3A5678%7D%7D \ No newline at end of file