use aes::Aes256; use block_modes::block_padding::{NoPadding, Padding, Pkcs7}; use block_modes::{BlockMode, Cbc}; use log::*; use ring::pbkdf2; use ring::rand::SecureRandom; use serde::{Deserialize, Serialize}; use std::fs::File; use std::io::{BufReader, Read, Write}; use std::path::Path; use std::sync::{Arc, Mutex}; use steamguard::SteamGuardAccount; use thiserror::Error; type Aes256Cbc = Cbc; #[derive(Debug, Serialize, Deserialize)] pub struct Manifest { pub encrypted: bool, pub entries: Vec, /// Not implemented, kept for compatibility with SDA. pub first_run: bool, /// Not implemented, kept for compatibility with SDA. pub periodic_checking: bool, /// Not implemented, kept for compatibility with SDA. pub periodic_checking_interval: i32, /// Not implemented, kept for compatibility with SDA. pub periodic_checking_checkall: bool, /// Not implemented, kept for compatibility with SDA. pub auto_confirm_market_transactions: bool, /// Not implemented, kept for compatibility with SDA. pub auto_confirm_trades: bool, #[serde(skip)] pub accounts: Vec>>, #[serde(skip)] folder: String, // I wanted to use a Path here, but it was too hard to make it work... } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ManifestEntry { pub filename: String, #[serde(default, rename = "steamid")] pub steam_id: u64, #[serde(default)] pub account_name: String, #[serde(default, flatten)] pub encryption: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EntryEncryptionParams { #[serde(rename = "encryption_iv")] pub iv: String, #[serde(rename = "encryption_salt")] pub salt: String, } impl Default for Manifest { fn default() -> Self { Manifest { encrypted: false, entries: vec![], first_run: false, periodic_checking: false, periodic_checking_interval: 0, periodic_checking_checkall: false, auto_confirm_market_transactions: false, auto_confirm_trades: false, accounts: vec![], folder: "".into(), } } } impl Manifest { /// `path` should be the path to manifest.json pub fn new(path: &Path) -> Self { Manifest { folder: String::from(path.parent().unwrap().to_str().unwrap()), ..Default::default() } } pub fn load(path: &Path) -> anyhow::Result { debug!("loading manifest: {:?}", &path); let file = File::open(path)?; let reader = BufReader::new(file); let mut manifest: Manifest = serde_json::from_reader(reader)?; manifest.folder = String::from(path.parent().unwrap().to_str().unwrap()); return Ok(manifest); } pub fn load_accounts( &mut self, passkey: &Option, ) -> anyhow::Result<(), ManifestAccountLoadError> { for entry in &mut self.entries { let path = Path::new(&self.folder).join(&entry.filename); debug!("loading account: {:?}", path); let file = File::open(path)?; let mut reader = BufReader::new(file); let account: SteamGuardAccount; match (passkey, entry.encryption.as_ref()) { (Some(passkey), Some(params)) => { let key = get_encryption_key(&passkey.into(), ¶ms.salt)?; let iv = base64::decode(¶ms.iv)?; let cipher = Aes256Cbc::new_from_slices(&key, &iv)?; // This sucks. let mut ciphertext: Vec = vec![]; reader.read_to_end(&mut ciphertext)?; ciphertext = base64::decode(ciphertext)?; let size: usize = ciphertext.len() / 16 + (if ciphertext.len() % 16 == 0 { 0 } else { 1 }); let mut buffer = vec![0xffu8; 16 * size]; buffer[..ciphertext.len()].copy_from_slice(&ciphertext); let mut decrypted = cipher.decrypt(&mut buffer)?; if decrypted[0] != '{' as u8 { return Err(ManifestAccountLoadError::DecryptionFailed); } // UnpadError does not implement Error for some fucking reason, so we have to do this. let unpadded = Pkcs7::unpad(&mut decrypted) .map_err(|_| ManifestAccountLoadError::DecryptionFailed)?; let s = std::str::from_utf8(&unpadded).unwrap(); account = serde_json::from_str(&s)?; } (None, Some(_)) => { return Err(ManifestAccountLoadError::MissingPasskey); } (_, None) => { account = serde_json::from_reader(reader)?; } }; entry.account_name = account.account_name.clone(); self.accounts.push(Arc::new(Mutex::new(account))); } Ok(()) } pub fn add_account(&mut self, account: SteamGuardAccount) { debug!("adding account to manifest: {}", account.account_name); let steamid = account.session.as_ref().map_or(0, |s| s.steam_id); self.entries.push(ManifestEntry { filename: format!("{}.maFile", &account.account_name), steam_id: steamid, account_name: account.account_name.clone(), encryption: None, }); self.accounts.push(Arc::new(Mutex::new(account))); } pub fn import_account(&mut self, import_path: String) -> anyhow::Result<()> { let path = Path::new(&import_path); ensure!(path.exists(), "{} does not exist.", import_path); ensure!(path.is_file(), "{} is not a file.", import_path); let file = File::open(path)?; let reader = BufReader::new(file); let account: SteamGuardAccount = serde_json::from_reader(reader)?; self.add_account(account); return Ok(()); } pub fn remove_account(&mut self, account_name: String) { let index = self .accounts .iter() .position(|a| a.lock().unwrap().account_name == account_name) .unwrap(); self.accounts.remove(index); self.entries.remove(index); } pub fn save(&self, passkey: &Option) -> anyhow::Result<()> { ensure!( self.entries.len() == self.accounts.len(), "Manifest entries don't match accounts." ); for (entry, account) in self.entries.iter().zip(&self.accounts) { debug!("saving {}", entry.filename); let serialized = serde_json::to_vec(account.as_ref())?; ensure!( serialized.len() > 2, "Something extra weird happened and the account was serialized into nothing." ); let final_buffer: Vec; match (passkey, entry.encryption.as_ref()) { (Some(passkey), Some(params)) => { let key = get_encryption_key(&passkey.into(), ¶ms.salt)?; let iv = base64::decode(¶ms.iv)?; let cipher = Aes256Cbc::new_from_slices(&key, &iv)?; // This also sucks. Extremely confusing. let plaintext = serialized; let origsize = plaintext.len(); let buffersize: usize = (origsize / 16 + (if origsize % 16 == 0 { 0 } else { 1 })) * 16; let mut buffer = vec![]; for chunk in plaintext.as_slice().chunks(256) { let chunksize = chunk.len(); let buffersize = (chunksize / 16 + (if chunksize % 16 == 0 { 0 } else { 1 })) * 16; let mut chunkbuffer = vec![0xffu8; buffersize]; chunkbuffer[..chunksize].copy_from_slice(&chunk); if buffersize != chunksize { chunkbuffer = Pkcs7::pad(&mut chunkbuffer, chunksize, buffersize) .unwrap() .to_vec(); } buffer.append(&mut chunkbuffer); } let ciphertext = cipher.encrypt(&mut buffer, buffersize)?; final_buffer = base64::encode(&ciphertext).as_bytes().to_vec(); } (None, Some(_)) => { bail!("maFiles are encrypted, but no passkey was provided."); } (_, None) => { final_buffer = serialized; } }; let path = Path::new(&self.folder).join(&entry.filename); let mut file = File::create(path)?; file.write_all(final_buffer.as_slice())?; file.sync_data()?; } debug!("saving manifest"); let manifest_serialized = serde_json::to_string(&self)?; let path = Path::new(&self.folder).join("manifest.json"); let mut file = File::create(path)?; file.write_all(manifest_serialized.as_bytes())?; file.sync_data()?; Ok(()) } } const PBKDF2_ITERATIONS: u32 = 50000; // This is excessive. Is this needed to remain compatible with SDA? const SALT_LENGTH: usize = 8; const KEY_SIZE_BYTES: usize = 32; const IV_LENGTH: usize = 16; fn get_encryption_key(passkey: &String, salt: &String) -> anyhow::Result<[u8; KEY_SIZE_BYTES]> { let password_bytes = passkey.as_bytes(); let salt_bytes = base64::decode(salt)?; let mut full_key: [u8; KEY_SIZE_BYTES] = [0u8; KEY_SIZE_BYTES]; pbkdf2::derive( pbkdf2::PBKDF2_HMAC_SHA1, std::num::NonZeroU32::new(PBKDF2_ITERATIONS).unwrap(), &salt_bytes, password_bytes, &mut full_key, ); return Ok(full_key); } impl EntryEncryptionParams { pub fn generate() -> EntryEncryptionParams { let rng = ring::rand::SystemRandom::new(); let mut salt = [0u8; SALT_LENGTH]; let mut iv = [0u8; IV_LENGTH]; rng.fill(&mut salt).expect("Unable to generate salt."); rng.fill(&mut iv).expect("Unable to generate IV."); EntryEncryptionParams { salt: base64::encode(salt), iv: base64::encode(iv), } } } #[derive(Debug, Error)] pub enum ManifestAccountLoadError { #[error("Manifest accounts are encrypted, but no passkey was provided.")] MissingPasskey, #[error("Failed to decrypt account.")] #[from(block_modes::block_padding::UnpadError)] DecryptionFailed, #[error("Failed to deserialize the account.")] DeserializationFailed(#[from] serde_json::Error), #[error(transparent)] Unknown(#[from] anyhow::Error), } /// For some reason, these errors do not get converted to `ManifestAccountLoadError`s, even though they get converted into `anyhow::Error` just fine. I am too lazy to figure out why right now. impl From for ManifestAccountLoadError { fn from(error: block_modes::BlockModeError) -> Self { return Self::Unknown(anyhow::Error::from(error)); } } impl From for ManifestAccountLoadError { fn from(error: base64::DecodeError) -> Self { return Self::Unknown(anyhow::Error::from(error)); } } impl From for ManifestAccountLoadError { fn from(error: block_modes::InvalidKeyIvLength) -> Self { return Self::Unknown(anyhow::Error::from(error)); } } impl From for ManifestAccountLoadError { fn from(error: std::io::Error) -> Self { return Self::Unknown(anyhow::Error::from(error)); } } #[cfg(test)] mod tests { use super::*; use tempdir::TempDir; #[test] fn test_should_save_new_manifest() { let tmp_dir = TempDir::new("steamguard-cli-test").unwrap(); let manifest_path = tmp_dir.path().join("manifest.json"); let manifest = Manifest::new(manifest_path.as_path()); assert!(matches!(manifest.save(&None), Ok(_))); } #[test] fn test_should_save_and_load_manifest() { let tmp_dir = TempDir::new("steamguard-cli-test").unwrap(); let manifest_path = tmp_dir.path().join("manifest.json"); 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(); manifest.add_account(account); assert!(matches!(manifest.save(&None), Ok(_))); let mut loaded_manifest = Manifest::load(manifest_path.as_path()).unwrap(); assert_eq!(loaded_manifest.entries.len(), 1); assert_eq!(loaded_manifest.entries[0].filename, "asdf1234.maFile"); assert!(matches!(loaded_manifest.load_accounts(&None), Ok(_))); assert_eq!( loaded_manifest.entries.len(), loaded_manifest.accounts.len() ); assert_eq!( loaded_manifest.accounts[0].lock().unwrap().account_name, "asdf1234" ); assert_eq!( loaded_manifest.accounts[0].lock().unwrap().revocation_code, "R12345" ); assert_eq!( loaded_manifest.accounts[0].lock().unwrap().shared_secret, "secret" ); } #[test] fn test_should_save_and_load_manifest_encrypted() { let passkey: Option = Some("password".into()); let tmp_dir = TempDir::new("steamguard-cli-test").unwrap(); let manifest_path = tmp_dir.path().join("manifest.json"); 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(); manifest.add_account(account); manifest.entries[0].encryption = Some(EntryEncryptionParams::generate()); assert!(matches!(manifest.save(&passkey), Ok(_))); let mut loaded_manifest = Manifest::load(manifest_path.as_path()).unwrap(); assert_eq!(loaded_manifest.entries.len(), 1); assert_eq!(loaded_manifest.entries[0].filename, "asdf1234.maFile"); assert!(matches!(loaded_manifest.load_accounts(&passkey), Ok(_))); assert_eq!( loaded_manifest.entries.len(), loaded_manifest.accounts.len() ); assert_eq!( loaded_manifest.accounts[0].lock().unwrap().account_name, "asdf1234" ); assert_eq!( loaded_manifest.accounts[0].lock().unwrap().revocation_code, "R12345" ); assert_eq!( loaded_manifest.accounts[0].lock().unwrap().shared_secret, "secret" ); } #[test] fn test_should_save_and_load_manifest_encrypted_longer() -> anyhow::Result<()> { let passkey: Option = Some("password".into()); let tmp_dir = TempDir::new("steamguard-cli-test").unwrap(); let manifest_path = tmp_dir.path().join("manifest.json"); 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.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); manifest.entries[0].encryption = Some(EntryEncryptionParams::generate()); manifest.save(&passkey)?; 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"); loaded_manifest.load_accounts(&passkey)?; assert_eq!( loaded_manifest.entries.len(), loaded_manifest.accounts.len() ); assert_eq!( loaded_manifest.accounts[0].lock().unwrap().account_name, "asdf1234" ); assert_eq!( loaded_manifest.accounts[0].lock().unwrap().revocation_code, "R12345" ); assert_eq!( loaded_manifest.accounts[0].lock().unwrap().shared_secret, "secret" ); return Ok(()); } #[test] fn test_should_import() { let tmp_dir = TempDir::new("steamguard-cli-test").unwrap(); let manifest_path = tmp_dir.path().join("manifest.json"); 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(); manifest.add_account(account); assert!(matches!(manifest.save(&None), Ok(_))); std::fs::remove_file(&manifest_path).unwrap(); let mut loaded_manifest = Manifest::new(manifest_path.as_path()); assert!(matches!( loaded_manifest.import_account( tmp_dir .path() .join("asdf1234.maFile") .into_os_string() .into_string() .unwrap() ), Ok(_) )); assert_eq!( loaded_manifest.entries.len(), loaded_manifest.accounts.len() ); assert_eq!( loaded_manifest.accounts[0].lock().unwrap().account_name, "asdf1234" ); assert_eq!( loaded_manifest.accounts[0].lock().unwrap().revocation_code, "R12345" ); assert_eq!( loaded_manifest.accounts[0].lock().unwrap().shared_secret, "secret" ); } #[test] fn test_sda_compatibility_1() { let path = Path::new("src/fixtures/maFiles/1-account/manifest.json"); assert!(path.is_file()); let result = Manifest::load(path); assert!(matches!(result, Ok(_))); let mut manifest = result.unwrap(); assert!(matches!(manifest.entries.last().unwrap().encryption, None)); assert!(matches!(manifest.load_accounts(&None), Ok(_))); assert_eq!( manifest.entries.last().unwrap().account_name, manifest .accounts .last() .unwrap() .lock() .unwrap() .account_name ); } #[test] fn test_sda_compatibility_1_encrypted() { let path = Path::new("src/fixtures/maFiles/1-account-encrypted/manifest.json"); assert!(path.is_file()); let result = Manifest::load(path); assert!(matches!(result, Ok(_))); let mut manifest = result.unwrap(); assert!(matches!( manifest.entries.last().unwrap().encryption, Some(_) )); let result = manifest.load_accounts(&Some("password".into())); assert!( matches!(result, Ok(_)), "error when loading accounts: {:?}", result.unwrap_err() ); assert_eq!( manifest.entries.last().unwrap().account_name, manifest .accounts .last() .unwrap() .lock() .unwrap() .account_name ); } } #[cfg(test)] mod encryption_tests { use super::*; /// This test ensures compatibility with SteamDesktopAuthenticator and with previous versions of steamguard-cli #[test] fn test_encryption_key() { assert_eq!( get_encryption_key(&"password".into(), &"GMhL0N2hqXg=".into()).unwrap(), base64::decode("KtiRa4/OxW83MlB6URf+Z8rAGj7CBY+pDlwD/NuVo6Y=") .unwrap() .as_slice() ); assert_eq!( get_encryption_key(&"password".into(), &"wTzTE9A6aN8=".into()).unwrap(), base64::decode("Dqpej/3DqEat0roJaHmu3luYgDzRCUmzX94n4fqvWj8=") .unwrap() .as_slice() ); } }