diff --git a/Cargo.lock b/Cargo.lock index e46c786..dc5c2dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,18 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aes" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "495ee669413bfbe9e8cace80f4d3d78e6d8c8d99579f97fb93bde351b185f2d4" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", + "opaque-debug", +] + [[package]] name = "aho-corasick" version = "0.7.15" @@ -86,12 +98,43 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0d27fb6b6f1e43147af148af49d49329413ba781aa0d5e10979831c210173b5" +[[package]] +name = "bit-set" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e11e16035ea35e4e5997b393eacbf6f63983188f7a2ad25bfb13465f5ad59de" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" +[[package]] +name = "block-modes" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cb03d1bed155d89dce0f845b7899b18a9a163e148fd004e1c28421a783e2d8e" +dependencies = [ + "block-padding", + "cipher", +] + +[[package]] +name = "block-padding" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d696c370c750c948ada61c69a0ee2cbbb9c50b1019ddb86d9317157a99c2cae" + [[package]] name = "bumpalo" version = "3.6.1" @@ -135,6 +178,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + [[package]] name = "clap" version = "2.33.3" @@ -211,6 +263,15 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b" +[[package]] +name = "cpufeatures" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66c99696f6c9dd7f35d486b9d04d7e6e202aa3e8c40d553f2fdf5e7e0c6a71ef" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.2.1" @@ -690,9 +751,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.90" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba4aede83fc3617411dc6993bc8c70919750c1c257c6ca6a502aed6e0e2394ae" +checksum = "a7f823d141fe0a24df1e23b4af4e3c7ba9e5966ec514ea068c93024aa7deb765" [[package]] name = "libm" @@ -897,10 +958,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af8b08b04175473088b46763e51ee54da5f9a164bc162f615b91bc179dbf15a3" [[package]] -name = "openssl" -version = "0.10.33" +name = "opaque-debug" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a61075b62a23fef5a29815de7536d940aa35ce96d18ce0cc5076272db678a577" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + +[[package]] +name = "openssl" +version = "0.10.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "549430950c79ae24e6d02e0b7404534ecf311d94cc9f861e9e4020187d13d885" dependencies = [ "bitflags", "cfg-if", @@ -918,9 +985,9 @@ checksum = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de" [[package]] name = "openssl-sys" -version = "0.9.61" +version = "0.9.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "313752393519e876837e09e1fa183ddef0be7735868dced3196f4472d536277f" +checksum = "7a7907e3bfa08bb85105209cdfcb6c63d109f8f6c1ed6ca318fff5c1853fbc1d" dependencies = [ "autocfg 1.0.1", "cc", @@ -1096,6 +1163,26 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "proptest" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0d9cc07f18492d879586c92b485def06bc850da3118075cd45d50e9c95b0e5" +dependencies = [ + "bit-set", + "bitflags", + "byteorder", + "lazy_static 1.4.0", + "num-traits", + "quick-error 2.0.1", + "rand 0.8.4", + "rand_chacha 0.3.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", +] + [[package]] name = "publicsuffix" version = "1.5.6" @@ -1106,6 +1193,18 @@ dependencies = [ "url", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.9" @@ -1234,6 +1333,15 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_xorshift" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25bf25ec5ae4a3f1b92f929810509a2f53d7dca2f50b794ff57e3face536c8f" +dependencies = [ + "rand_core 0.6.2", +] + [[package]] name = "rdrand" version = "0.4.0" @@ -1337,6 +1445,21 @@ dependencies = [ "winreg", ] +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted", + "web-sys", + "winapi", +] + [[package]] name = "rpassword" version = "5.0.1" @@ -1385,6 +1508,18 @@ dependencies = [ "semver 0.11.0", ] +[[package]] +name = "rusty-fork" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.5" @@ -1706,17 +1841,21 @@ dependencies = [ name = "steamguard-cli" version = "0.2.0" dependencies = [ + "aes", "anyhow", "base64", + "block-modes", "clap", "cookie", "dirs", "hmac-sha1", "lazy_static 1.4.0", "log", + "proptest", "rand 0.8.4", "regex", "reqwest", + "ring", "rpassword", "rsa", "serde", @@ -1727,6 +1866,7 @@ dependencies = [ "tempdir", "termion", "text_io", + "thiserror", "uuid", ] @@ -2082,6 +2222,12 @@ dependencies = [ "void", ] +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "url" version = "2.2.1" @@ -2133,6 +2279,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" +[[package]] +name = "wait-timeout" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +dependencies = [ + "libc", +] + [[package]] name = "want" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index 639b619..12672c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,11 @@ uuid = { version = "0.8", features = ["v4"] } termion = "1.5.6" steamguard = { path = "./steamguard" } dirs = "3.0.2" +ring = "0.16.20" +aes = "0.7.4" +block-modes = "0.8.1" +thiserror = "1.0.26" [dev-dependencies] tempdir = "0.3" +proptest = "1" diff --git a/src/accountmanager.rs b/src/accountmanager.rs index 6c09897..cb9fb1e 100644 --- a/src/accountmanager.rs +++ b/src/accountmanager.rs @@ -1,15 +1,19 @@ +pub use crate::encryption::EntryEncryptionParams; +use crate::encryption::EntryEncryptor; use log::*; use serde::{Deserialize, Serialize}; -use std::io::{BufReader, Write}; +use std::fs::File; +use std::io::{BufReader, Read, Write}; use std::path::Path; use std::sync::{Arc, Mutex}; -use std::{cell::Cell, fs::File}; use steamguard::SteamGuardAccount; +use thiserror::Error; #[derive(Debug, Serialize, Deserialize)] pub struct Manifest { - pub encrypted: bool, pub entries: Vec, + /// Not really used, kept mostly for compatibility with SDA. + pub encrypted: bool, /// Not implemented, kept for compatibility with SDA. pub first_run: bool, /// Not implemented, kept for compatibility with SDA. @@ -40,14 +44,6 @@ pub struct ManifestEntry { 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 { @@ -84,13 +80,36 @@ impl Manifest { return Ok(manifest); } - pub fn load_accounts(&mut self) -> anyhow::Result<()> { + 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 reader = BufReader::new(file); - let account: SteamGuardAccount = serde_json::from_reader(reader)?; + let mut reader = BufReader::new(file); + let account: SteamGuardAccount; + match (passkey, entry.encryption.as_ref()) { + (Some(passkey), Some(params)) => { + let mut ciphertext: Vec = vec![]; + reader.read_to_end(&mut ciphertext)?; + let plaintext = crate::encryption::LegacySdaCompatible::decrypt( + passkey, params, ciphertext, + )?; + if plaintext[0] != '{' as u8 && plaintext[plaintext.len() - 1] != '}' as u8 { + return Err(ManifestAccountLoadError::IncorrectPasskey); + } + let s = std::str::from_utf8(&plaintext).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))); } @@ -132,21 +151,37 @@ impl Manifest { self.entries.remove(index); } - pub fn save(&self) -> anyhow::Result<()> { + 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_string(account.as_ref())?; + 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)) => { + final_buffer = crate::encryption::LegacySdaCompatible::encrypt( + passkey, params, serialized, + )?; + } + (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(serialized.as_bytes())?; + file.write_all(final_buffer.as_slice())?; file.sync_data()?; } debug!("saving manifest"); @@ -159,6 +194,41 @@ impl Manifest { } } +#[derive(Debug, Error)] +pub enum ManifestAccountLoadError { + #[error("Manifest accounts are encrypted, but no passkey was provided.")] + MissingPasskey, + #[error("Incorrect passkey provided.")] + IncorrectPasskey, + #[error("Failed to decrypt account. {self:?}")] + DecryptionFailed(#[from] crate::encryption::EntryEncryptionError), + #[error("Failed to deserialize the account.")] + DeserializationFailed(#[from] serde_json::Error), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +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::*; @@ -169,7 +239,7 @@ mod tests { 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(), Ok(_))); + assert!(matches!(manifest.save(&None), Ok(_))); } #[test] @@ -182,12 +252,12 @@ mod tests { account.revocation_code = "R12345".into(); account.shared_secret = "secret".into(); manifest.add_account(account); - assert!(matches!(manifest.save(), Ok(_))); + 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(), Ok(_))); + assert!(matches!(loaded_manifest.load_accounts(&None), Ok(_))); assert_eq!( loaded_manifest.entries.len(), loaded_manifest.accounts.len() @@ -206,6 +276,82 @@ mod tests { ); } + #[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(); @@ -216,7 +362,7 @@ mod tests { account.revocation_code = "R12345".into(); account.shared_secret = "secret".into(); manifest.add_account(account); - assert!(matches!(manifest.save(), Ok(_))); + assert!(matches!(manifest.save(&None), Ok(_))); std::fs::remove_file(&manifest_path).unwrap(); let mut loaded_manifest = Manifest::new(manifest_path.as_path()); @@ -251,13 +397,42 @@ mod tests { #[test] fn test_sda_compatibility_1() { - let path = Path::new("src/fixtures/maFiles/1-account/manifest.json"); + let path = Path::new("src/fixtures/maFiles/compat/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(), Ok(_))); + 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/compat/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 diff --git a/src/encryption.rs b/src/encryption.rs new file mode 100644 index 0000000..a1a74cc --- /dev/null +++ b/src/encryption.rs @@ -0,0 +1,242 @@ +use aes::Aes256; +use block_modes::block_padding::{NoPadding, Padding, Pkcs7}; +use block_modes::{BlockMode, Cbc}; +use ring::pbkdf2; +use ring::rand::SecureRandom; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +const SALT_LENGTH: usize = 8; +const IV_LENGTH: usize = 16; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EntryEncryptionParams { + #[serde(rename = "encryption_iv")] + pub iv: String, + #[serde(rename = "encryption_salt")] + pub salt: String, + #[serde(default, rename = "encryption_scheme")] + pub scheme: EncryptionScheme, +} + +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), + scheme: Default::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum EncryptionScheme { + /// Encryption scheme that is compatible with SteamDesktopAuthenticator. + LegacySdaCompatible = -1, +} + +impl Default for EncryptionScheme { + fn default() -> Self { + Self::LegacySdaCompatible + } +} + +pub trait EntryEncryptor { + fn encrypt( + passkey: &String, + params: &EntryEncryptionParams, + plaintext: Vec, + ) -> anyhow::Result, EntryEncryptionError>; + fn decrypt( + passkey: &String, + params: &EntryEncryptionParams, + ciphertext: Vec, + ) -> anyhow::Result, EntryEncryptionError>; +} + +/// Encryption scheme that is compatible with SteamDesktopAuthenticator. +pub struct LegacySdaCompatible; + +impl LegacySdaCompatible { + const PBKDF2_ITERATIONS: u32 = 50000; // This is excessive, but necessary to maintain compatibility with SteamDesktopAuthenticator. + const KEY_SIZE_BYTES: usize = 32; + + fn get_encryption_key( + passkey: &String, + salt: &String, + ) -> anyhow::Result<[u8; Self::KEY_SIZE_BYTES]> { + let password_bytes = passkey.as_bytes(); + let salt_bytes = base64::decode(salt)?; + let mut full_key: [u8; Self::KEY_SIZE_BYTES] = [0u8; Self::KEY_SIZE_BYTES]; + pbkdf2::derive( + pbkdf2::PBKDF2_HMAC_SHA1, + std::num::NonZeroU32::new(Self::PBKDF2_ITERATIONS).unwrap(), + &salt_bytes, + password_bytes, + &mut full_key, + ); + return Ok(full_key); + } +} + +type Aes256Cbc = Cbc; +impl EntryEncryptor for LegacySdaCompatible { + // ngl, this logic sucks ass. its kinda annoying that the logic is not completely symetric. + + fn encrypt( + passkey: &String, + params: &EntryEncryptionParams, + plaintext: Vec, + ) -> anyhow::Result, EntryEncryptionError> { + let key = Self::get_encryption_key(&passkey.into(), ¶ms.salt)?; + let iv = base64::decode(¶ms.iv)?; + let cipher = Aes256Cbc::new_from_slices(&key, &iv)?; + + 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)?; + let final_buffer = base64::encode(&ciphertext); + return Ok(final_buffer.as_bytes().to_vec()); + } + + fn decrypt( + passkey: &String, + params: &EntryEncryptionParams, + ciphertext: Vec, + ) -> anyhow::Result, EntryEncryptionError> { + let key = Self::get_encryption_key(&passkey.into(), ¶ms.salt)?; + let iv = base64::decode(¶ms.iv)?; + let cipher = Aes256Cbc::new_from_slices(&key, &iv)?; + + let decoded = base64::decode(ciphertext)?; + let size: usize = decoded.len() / 16 + (if decoded.len() % 16 == 0 { 0 } else { 1 }); + let mut buffer = vec![0xffu8; 16 * size]; + buffer[..decoded.len()].copy_from_slice(&decoded); + let mut decrypted = cipher.decrypt(&mut buffer)?; + let unpadded = Pkcs7::unpad(&mut decrypted)?; + return Ok(unpadded.to_vec()); + } +} + +#[derive(Debug, Error)] +pub enum EntryEncryptionError { + #[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 EntryEncryptionError { + fn from(error: block_modes::BlockModeError) -> Self { + return Self::Unknown(anyhow::Error::from(error)); + } +} +impl From for EntryEncryptionError { + fn from(error: block_modes::InvalidKeyIvLength) -> Self { + return Self::Unknown(anyhow::Error::from(error)); + } +} +impl From for EntryEncryptionError { + fn from(error: block_modes::block_padding::PadError) -> Self { + return Self::Unknown(anyhow!("PadError")); + } +} +impl From for EntryEncryptionError { + fn from(error: block_modes::block_padding::UnpadError) -> Self { + return Self::Unknown(anyhow!("UnpadError")); + } +} +impl From for EntryEncryptionError { + fn from(error: base64::DecodeError) -> Self { + return Self::Unknown(anyhow::Error::from(error)); + } +} +impl From for EntryEncryptionError { + fn from(error: std::io::Error) -> Self { + return Self::Unknown(anyhow::Error::from(error)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + /// This test ensures compatibility with SteamDesktopAuthenticator and with previous versions of steamguard-cli + #[test] + fn test_encryption_key() { + assert_eq!( + LegacySdaCompatible::get_encryption_key(&"password".into(), &"GMhL0N2hqXg=".into()) + .unwrap(), + base64::decode("KtiRa4/OxW83MlB6URf+Z8rAGj7CBY+pDlwD/NuVo6Y=") + .unwrap() + .as_slice() + ); + + assert_eq!( + LegacySdaCompatible::get_encryption_key(&"password".into(), &"wTzTE9A6aN8=".into()) + .unwrap(), + base64::decode("Dqpej/3DqEat0roJaHmu3luYgDzRCUmzX94n4fqvWj8=") + .unwrap() + .as_slice() + ); + } + + #[test] + fn test_ensure_encryption_symmetric() -> anyhow::Result<()> { + let passkey = "password"; + let params = EntryEncryptionParams::generate(); + let orig = "tactical glizzy".as_bytes().to_vec(); + let encrypted = + LegacySdaCompatible::encrypt(&passkey.clone().into(), ¶ms, orig.clone()).unwrap(); + let result = LegacySdaCompatible::decrypt(&passkey.into(), ¶ms, encrypted).unwrap(); + assert_eq!(orig, result.to_vec()); + return Ok(()); + } + + prop_compose! { + /// An insecure but reproducible strategy for generating encryption params. + fn encryption_params()(salt in any::<[u8; SALT_LENGTH]>(), iv in any::<[u8; IV_LENGTH]>()) -> EntryEncryptionParams { + EntryEncryptionParams { + salt: base64::encode(&salt), + iv: base64::encode(&iv), + scheme: EncryptionScheme::LegacySdaCompatible, + } + } + } + + // proptest! { + // #[test] + // fn ensure_encryption_symmetric( + // passkey in ".{1,}", + // params in encryption_params(), + // data in any::>(), + // ) { + // prop_assume!(data.len() >= 2); + // let mut orig = data; + // orig[0] = '{' as u8; + // let n = orig.len() - 1; + // orig[n] = '}' as u8; + // let encrypted = LegacySdaCompatible::encrypt(&passkey.clone().into(), ¶ms, orig.clone()).unwrap(); + // let result = LegacySdaCompatible::decrypt(&passkey.into(), ¶ms, encrypted).unwrap(); + // prop_assert_eq!(orig, result.to_vec()); + // } + // } +} diff --git a/src/fixtures/maFiles/compat/1-account-encrypted/1234.maFile b/src/fixtures/maFiles/compat/1-account-encrypted/1234.maFile new file mode 100644 index 0000000..1ee5507 --- /dev/null +++ b/src/fixtures/maFiles/compat/1-account-encrypted/1234.maFile @@ -0,0 +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 diff --git a/src/fixtures/maFiles/compat/1-account-encrypted/manifest.json b/src/fixtures/maFiles/compat/1-account-encrypted/manifest.json new file mode 100644 index 0000000..fed7737 --- /dev/null +++ b/src/fixtures/maFiles/compat/1-account-encrypted/manifest.json @@ -0,0 +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 diff --git a/src/fixtures/maFiles/1-account/1234.maFile b/src/fixtures/maFiles/compat/1-account/1234.maFile similarity index 100% rename from src/fixtures/maFiles/1-account/1234.maFile rename to src/fixtures/maFiles/compat/1-account/1234.maFile diff --git a/src/fixtures/maFiles/1-account/manifest.json b/src/fixtures/maFiles/compat/1-account/manifest.json similarity index 100% rename from src/fixtures/maFiles/1-account/manifest.json rename to src/fixtures/maFiles/compat/1-account/manifest.json diff --git a/src/main.rs b/src/main.rs index 2b57a60..5311a42 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,9 +16,14 @@ use steamguard::{ extern crate lazy_static; #[macro_use] extern crate anyhow; +extern crate base64; extern crate dirs; +#[cfg(test)] +extern crate proptest; +extern crate ring; mod accountmanager; mod demos; +mod encryption; mod tui; fn cli() -> App<'static, 'static> { @@ -55,6 +60,7 @@ fn cli() -> App<'static, 'static> { .long("passkey") .short("p") .help("Specify your encryption passkey.") + .takes_value(true) ) .arg( Arg::with_name("verbosity") @@ -101,6 +107,14 @@ fn cli() -> App<'static, 'static> { App::new("remove") .about("Remove the authenticator from an account.") ) + .subcommand( + App::new("encrypt") + .about("Encrypt maFiles.") + ) + .subcommand( + App::new("decrypt") + .about("Decrypt maFiles.") + ) .subcommand( App::new("debug") .arg( @@ -172,9 +186,26 @@ fn main() { } } - manifest - .load_accounts() - .expect("Failed to load accounts in manifest"); + let mut passkey: Option = matches.value_of("passkey").map(|s| s.into()); + + loop { + match manifest.load_accounts(&passkey) { + Ok(_) => break, + Err( + accountmanager::ManifestAccountLoadError::MissingPasskey + | accountmanager::ManifestAccountLoadError::IncorrectPasskey, + ) => { + if passkey.is_some() { + error!("Incorrect passkey"); + } + passkey = rpassword::prompt_password_stdout("Enter encryption passkey: ").ok(); + } + Err(e) => { + error!("Could not load accounts: {}", e); + return; + } + } + } if matches.is_present("setup") { println!("Log in to the account that you want to link to steamguard-cli"); @@ -215,7 +246,7 @@ fn main() { } } manifest.add_account(account); - match manifest.save() { + match manifest.save(&passkey) { Ok(_) => {} Err(err) => { error!("Aborting the account linking process because we failed to save the manifest. This is really bad. Here is the error: {}", err); @@ -260,7 +291,7 @@ fn main() { } println!("Authenticator finalized."); - match manifest.save() { + match manifest.save(&None) { Ok(_) => {} Err(err) => { println!( @@ -284,7 +315,30 @@ fn main() { } } - manifest.save().expect("Failed to save manifest."); + manifest.save(&passkey).expect("Failed to save manifest."); + return; + } else if matches.is_present("encrypt") { + if passkey.is_none() { + loop { + passkey = rpassword::prompt_password_stdout("Enter encryption passkey: ").ok(); + let passkey_confirm = + rpassword::prompt_password_stdout("Confirm encryption passkey: ").ok(); + if passkey == passkey_confirm { + break; + } + error!("Passkeys do not match, try again."); + } + } + for entry in &mut manifest.entries { + entry.encryption = Some(accountmanager::EntryEncryptionParams::generate()); + } + manifest.save(&passkey).expect("Failed to save manifest."); + return; + } else if matches.is_present("decrypt") { + for entry in &mut manifest.entries { + entry.encryption = None; + } + manifest.save(&passkey).expect("Failed to save manifest."); return; } @@ -361,7 +415,7 @@ fn main() { } } - manifest.save().expect("Failed to save manifest"); + manifest.save(&passkey).expect("Failed to save manifest"); } else if let Some(_) = matches.subcommand_matches("remove") { println!( "This will remove the mobile authenticator from {} accounts: {}", @@ -409,7 +463,7 @@ fn main() { manifest.remove_account(account_name); } - manifest.save().expect("Failed to save manifest."); + manifest.save(&passkey).expect("Failed to save manifest."); } else { let server_time = steamapi::get_server_time(); for account in selected_accounts {