From d5218d770e0e3fc1ac9677eefb9977fdb8029ceb Mon Sep 17 00:00:00 2001 From: Carson McManus Date: Mon, 3 Jul 2023 10:23:56 -0400 Subject: [PATCH] add a new, faster encryption scheme (`Argon2idAes256`) and make it the default (#270) - move legacy scheme to new module - add argon2 crate - mvoe test - add argon2id aes encryption scheme - refactor encryption to be less shit - fix all the errors - fix lints --- Cargo.lock | 44 ++++++-- Cargo.toml | 1 + src/accountmanager.rs | 26 ++--- src/accountmanager/legacy.rs | 23 ++-- src/accountmanager/manifest.rs | 4 +- src/accountmanager/migrate.rs | 6 +- src/commands/encrypt.rs | 7 +- src/encryption.rs | 187 +++++---------------------------- src/encryption/argon2id_aes.rs | 148 ++++++++++++++++++++++++++ src/encryption/legacy.rs | 167 +++++++++++++++++++++++++++++ 10 files changed, 410 insertions(+), 203 deletions(-) create mode 100644 src/encryption/argon2id_aes.rs create mode 100644 src/encryption/legacy.rs diff --git a/Cargo.lock b/Cargo.lock index 1590cb2..8d88348 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,17 @@ version = "1.0.57" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08f9b8508dccb7687a1d6c4ce66b2b0ecef467c94667de27d8d7fe1f8d2a9cdc" +[[package]] +name = "argon2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95c2fcf79ad1932ac6269a738109997a83c227c09b75842ae564dc8ede6a861c" +dependencies = [ + "base64ct", + "blake2", + "password-hash", +] + [[package]] name = "async-broadcast" version = "0.5.1" @@ -261,6 +272,15 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -587,7 +607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83bd3bb4314701c568e340cd8cf78c975aa0ca79e03d3f6d1677d5b0c9c0c03" dependencies = [ "generic-array", - "rand_core 0.6.3", + "rand_core 0.6.4", "subtle", ] @@ -1697,6 +1717,17 @@ dependencies = [ "windows-sys 0.36.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pem-rfc7468" version = "0.2.4" @@ -2093,7 +2124,7 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha 0.3.1", - "rand_core 0.6.3", + "rand_core 0.6.4", ] [[package]] @@ -2113,7 +2144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.3", + "rand_core 0.6.4", ] [[package]] @@ -2142,9 +2173,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ "getrandom 0.2.6", ] @@ -2173,7 +2204,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d25bf25ec5ae4a3f1b92f929810509a2f53d7dca2f50b794ff57e3face536c8f" dependencies = [ - "rand_core 0.6.3", + "rand_core 0.6.4", ] [[package]] @@ -2852,6 +2883,7 @@ version = "0.9.7" dependencies = [ "aes 0.8.3", "anyhow", + "argon2", "base64", "cbc", "clap", diff --git a/Cargo.toml b/Cargo.toml index c3e41c8..7f8d575 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,7 @@ phonenumber = "0.3" cbc = { version = "0.1.2", features = ["std"] } inout = { version = "0.1.3", features = ["std"] } keyring = { version = "2.0.4", optional = true } +argon2 = { version = "0.5.0", features = ["std"] } [dev-dependencies] tempdir = "0.3" diff --git a/src/accountmanager.rs b/src/accountmanager.rs index c5e63ee..48e89b8 100644 --- a/src/accountmanager.rs +++ b/src/accountmanager.rs @@ -1,5 +1,5 @@ use crate::accountmanager::legacy::SdaManifest; -pub use crate::encryption::EntryEncryptionParams; +pub use crate::encryption::EncryptionScheme; use crate::encryption::EntryEncryptor; use log::*; use secrecy::{ExposeSecret, SecretString}; @@ -212,11 +212,9 @@ impl AccountManager { ); let final_buffer: Vec = match (&self.passkey, entry.encryption.as_ref()) { - (Some(passkey), Some(params)) => crate::encryption::LegacySdaCompatible::encrypt( - passkey.expose_secret(), - params, - serialized, - )?, + (Some(passkey), Some(scheme)) => { + scheme.encrypt(passkey.expose_secret(), serialized)? + } (None, Some(_)) => { bail!("maFiles are encrypted, but no passkey was provided."); } @@ -354,7 +352,7 @@ trait EntryLoader { &self, path: &Path, passkey: Option<&SecretString>, - encryption_params: Option<&EntryEncryptionParams>, + encryption_params: Option<&EncryptionScheme>, ) -> anyhow::Result; } @@ -363,20 +361,16 @@ impl EntryLoader for ManifestEntry { &self, path: &Path, passkey: Option<&SecretString>, - encryption_params: Option<&EntryEncryptionParams>, + encryption_params: Option<&EncryptionScheme>, ) -> anyhow::Result { debug!("loading entry: {:?}", path); let file = File::open(path)?; let mut reader = BufReader::new(file); let account: SteamGuardAccount = match (&passkey, encryption_params.as_ref()) { - (Some(passkey), Some(params)) => { + (Some(passkey), Some(scheme)) => { let mut ciphertext: Vec = vec![]; reader.read_to_end(&mut ciphertext)?; - let plaintext = crate::encryption::LegacySdaCompatible::decrypt( - passkey.expose_secret(), - params, - ciphertext, - )?; + let plaintext = scheme.decrypt(passkey.expose_secret(), ciphertext)?; if plaintext[0] != b'{' && plaintext[plaintext.len() - 1] != b'}' { return Err(ManifestAccountLoadError::IncorrectPasskey); } @@ -497,7 +491,7 @@ mod tests { "zvIayp3JPvtvX/QGHqsqKBk/44s=".into(), )?; manager.add_account(account); - manager.manifest.entries[0].encryption = Some(EntryEncryptionParams::generate()); + manager.manifest.entries[0].encryption = Some(EncryptionScheme::generate()); manager.submit_passkey(passkey.clone()); assert!(matches!(manager.save(), Ok(_))); @@ -550,7 +544,7 @@ mod tests { account.token_gid = "asdf1234".into(); manager.add_account(account); manager.submit_passkey(passkey.clone()); - manager.manifest.entries[0].encryption = Some(EntryEncryptionParams::generate()); + manager.manifest.entries[0].encryption = Some(EncryptionScheme::generate()); manager.save()?; let mut loaded_manager = AccountManager::load(manifest_path.as_path())?; diff --git a/src/accountmanager/legacy.rs b/src/accountmanager/legacy.rs index bdc7511..5cdcffe 100644 --- a/src/accountmanager/legacy.rs +++ b/src/accountmanager/legacy.rs @@ -12,11 +12,9 @@ use serde::Deserialize; use steamguard::{token::TwoFactorSecret, SecretString, SteamGuardAccount}; use zeroize::Zeroize; -use crate::encryption::{EncryptionScheme, EntryEncryptor}; +use crate::encryption::{EntryEncryptor, LegacySdaCompatible}; -use super::{ - EntryEncryptionParams, EntryLoader, ManifestAccountLoadError, ManifestEntry, ManifestV1, -}; +use super::{EncryptionScheme, EntryLoader, ManifestAccountLoadError, ManifestEntry, ManifestV1}; #[derive(Debug, Deserialize)] pub struct SdaManifest { @@ -74,20 +72,16 @@ impl EntryLoader for SdaManifestEntry { &self, path: &Path, passkey: Option<&SecretString>, - encryption_params: Option<&EntryEncryptionParams>, + encryption_params: Option<&EncryptionScheme>, ) -> anyhow::Result { debug!("loading entry: {:?}", path); let file = File::open(path)?; let mut reader = BufReader::new(file); let account: SdaAccount = match (&passkey, encryption_params.as_ref()) { - (Some(passkey), Some(params)) => { + (Some(passkey), Some(scheme)) => { let mut ciphertext: Vec = vec![]; reader.read_to_end(&mut ciphertext)?; - let plaintext = crate::encryption::LegacySdaCompatible::decrypt( - passkey.expose_secret(), - params, - ciphertext, - )?; + let plaintext = scheme.decrypt(passkey.expose_secret(), ciphertext)?; if plaintext[0] != b'{' && plaintext[plaintext.len() - 1] != b'}' { return Err(ManifestAccountLoadError::IncorrectPasskey); } @@ -115,13 +109,12 @@ pub struct SdaEntryEncryptionParams { pub salt: String, } -impl From for EntryEncryptionParams { +impl From for EncryptionScheme { fn from(sda: SdaEntryEncryptionParams) -> Self { - Self { + EncryptionScheme::LegacySdaCompatible(LegacySdaCompatible { iv: sda.iv, salt: sda.salt, - scheme: EncryptionScheme::LegacySdaCompatible, - } + }) } } diff --git a/src/accountmanager/manifest.rs b/src/accountmanager/manifest.rs index 2e68368..a4401d9 100644 --- a/src/accountmanager/manifest.rs +++ b/src/accountmanager/manifest.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use super::EntryEncryptionParams; +use super::EncryptionScheme; pub const CURRENT_MANIFEST_VERSION: u32 = 1; pub type Manifest = ManifestV1; @@ -19,7 +19,7 @@ pub struct ManifestEntryV1 { pub filename: String, pub steam_id: u64, pub account_name: String, - pub encryption: Option, + pub encryption: Option, } impl Default for ManifestV1 { diff --git a/src/accountmanager/migrate.rs b/src/accountmanager/migrate.rs index c4356b6..71c92e0 100644 --- a/src/accountmanager/migrate.rs +++ b/src/accountmanager/migrate.rs @@ -6,10 +6,12 @@ use serde::{de::Error, Deserialize}; use steamguard::SteamGuardAccount; use thiserror::Error; +use crate::encryption::EncryptionScheme; + use super::{ legacy::{SdaAccount, SdaManifest}, manifest::ManifestV1, - EntryEncryptionParams, EntryLoader, Manifest, + EntryLoader, Manifest, }; pub(crate) fn load_and_migrate( @@ -130,7 +132,7 @@ impl MigratingManifest { .entries .iter() .map(|e| { - let params: Option = + let params: Option = e.encryption.clone().map(|e| e.into()); e.load(&Path::join(folder, &e.filename), passkey, params.as_ref()) }) diff --git a/src/commands/encrypt.rs b/src/commands/encrypt.rs index 015bbc0..f4def57 100644 --- a/src/commands/encrypt.rs +++ b/src/commands/encrypt.rs @@ -1,6 +1,9 @@ use log::*; -use crate::{tui, AccountManager}; +use crate::{ + encryption::{EncryptionScheme, EntryEncryptor}, + tui, AccountManager, +}; use super::*; @@ -60,7 +63,7 @@ where } manager.load_accounts()?; for entry in manager.iter_mut() { - entry.encryption = Some(crate::accountmanager::EntryEncryptionParams::generate()); + entry.encryption = Some(EncryptionScheme::generate()); } manager.save()?; Ok(()) diff --git a/src/encryption.rs b/src/encryption.rs index 760cfdf..ce1bdef 100644 --- a/src/encryption.rs +++ b/src/encryption.rs @@ -1,123 +1,68 @@ -use aes::cipher::block_padding::Pkcs7; -use aes::cipher::{BlockDecryptMut, BlockEncryptMut, InvalidLength, KeyIvInit}; -use aes::Aes256; +use aes::cipher::InvalidLength; + use rand::Rng; -use ring::pbkdf2; + use ring::rand::SecureRandom; use serde::{Deserialize, Serialize}; use thiserror::Error; +mod argon2id_aes; #[cfg(feature = "keyring")] mod keyring; +mod legacy; + +pub use argon2id_aes::*; +pub use legacy::*; #[cfg(feature = "keyring")] pub use crate::encryption::keyring::*; -const SALT_LENGTH: usize = 8; -const IV_LENGTH: usize = 16; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EntryEncryptionParams { - pub iv: String, - pub salt: String, - 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)] +#[serde(tag = "scheme")] pub enum EncryptionScheme { - /// Encryption scheme that is compatible with SteamDesktopAuthenticator. - LegacySdaCompatible = -1, -} - -impl Default for EncryptionScheme { - fn default() -> Self { - Self::LegacySdaCompatible - } + Argon2idAes256(Argon2idAes256), + LegacySdaCompatible(LegacySdaCompatible), } pub trait EntryEncryptor { + fn generate() -> Self; fn encrypt( + &self, passkey: &str, - params: &EntryEncryptionParams, plaintext: Vec, ) -> anyhow::Result, EntryEncryptionError>; fn decrypt( + &self, passkey: &str, - 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 necessary to maintain compatibility with SteamDesktopAuthenticator. - const KEY_SIZE_BYTES: usize = 32; - - fn get_encryption_key(passkey: &str, salt: &str) -> 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, - ); - Ok(full_key) +impl EntryEncryptor for EncryptionScheme { + fn generate() -> Self { + EncryptionScheme::Argon2idAes256(Argon2idAes256::generate()) } -} -impl EntryEncryptor for LegacySdaCompatible { fn encrypt( + &self, passkey: &str, - params: &EntryEncryptionParams, plaintext: Vec, ) -> anyhow::Result, EntryEncryptionError> { - let key = Self::get_encryption_key(passkey, ¶ms.salt)?; - let mut iv = [0u8; IV_LENGTH]; - base64::decode_config_slice(¶ms.iv, base64::STANDARD, &mut iv)?; - - let cipher = cbc::Encryptor::::new_from_slices(&key, &iv)?; - - let ciphertext = cipher.encrypt_padded_vec_mut::(&plaintext); - - let encoded = base64::encode(ciphertext); - Ok(encoded.as_bytes().to_vec()) + match self { + EncryptionScheme::Argon2idAes256(scheme) => scheme.encrypt(passkey, plaintext), + EncryptionScheme::LegacySdaCompatible(scheme) => scheme.encrypt(passkey, plaintext), + } } fn decrypt( + &self, passkey: &str, - params: &EntryEncryptionParams, ciphertext: Vec, ) -> anyhow::Result, EntryEncryptionError> { - let key = Self::get_encryption_key(passkey, ¶ms.salt)?; - let mut iv = [0u8; IV_LENGTH]; - base64::decode_config_slice(¶ms.iv, base64::STANDARD, &mut iv)?; - let cipher = cbc::Decryptor::::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 decrypted = cipher.decrypt_padded_mut::(&mut buffer)?; - Ok(decrypted.to_vec()) + match self { + EncryptionScheme::Argon2idAes256(scheme) => scheme.decrypt(passkey, ciphertext), + EncryptionScheme::LegacySdaCompatible(scheme) => scheme.decrypt(passkey, ciphertext), + } } } @@ -172,81 +117,3 @@ pub fn generate_keyring_id() -> String { .map(char::from) .collect() } - -#[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", "GMhL0N2hqXg=") - .unwrap() - .as_slice(), - base64::decode("KtiRa4/OxW83MlB6URf+Z8rAGj7CBY+pDlwD/NuVo6Y=") - .unwrap() - .as_slice() - ); - - assert_eq!( - LegacySdaCompatible::get_encryption_key("password", "wTzTE9A6aN8=") - .unwrap() - .as_slice(), - base64::decode("Dqpej/3DqEat0roJaHmu3luYgDzRCUmzX94n4fqvWj8=") - .unwrap() - .as_slice() - ); - } - - #[test] - fn test_ensure_encryption_symmetric() -> anyhow::Result<()> { - let cases = [ - "foo", - "tactical glizzy", - "glizzy gladiator", - "shadow wizard money gang", - "shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells", - ]; - let passkey = "password"; - let params = EntryEncryptionParams::generate(); - for case in cases { - eprintln!("testing case: {} (len {})", case, case.len()); - let orig = case.as_bytes().to_vec(); - let encrypted = LegacySdaCompatible::encrypt(passkey, ¶ms, orig.clone()).unwrap(); - let result = LegacySdaCompatible::decrypt(passkey, ¶ms, encrypted).unwrap(); - assert_eq!(orig, result.to_vec()); - } - 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/encryption/argon2id_aes.rs b/src/encryption/argon2id_aes.rs new file mode 100644 index 0000000..7007256 --- /dev/null +++ b/src/encryption/argon2id_aes.rs @@ -0,0 +1,148 @@ +use aes::cipher::block_padding::Pkcs7; +use aes::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit}; +use aes::Aes256; +use argon2::Argon2; +use log::*; + +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Argon2idAes256 { + pub iv: String, + pub salt: String, +} + +impl Argon2idAes256 { + const KEY_SIZE_BYTES: usize = 32; + const IV_LENGTH: usize = 16; + const SALT_LENGTH: usize = 16; + + fn get_encryption_key(passkey: &str, salt: &str) -> 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]; + let deriver = Argon2::new( + argon2::Algorithm::Argon2id, + argon2::Version::V0x13, + Self::config(), + ); + deriver.hash_password_into(password_bytes, &salt_bytes, &mut full_key)?; + + Ok(full_key) + } + + fn config() -> argon2::Params { + argon2::Params::new( + 12 * 1024, // 12MB + 3, + 12, + Some(Self::KEY_SIZE_BYTES), + ) + .expect("Unable to create Argon2 config.") + } +} + +impl EntryEncryptor for Argon2idAes256 { + fn generate() -> Self { + let rng = ring::rand::SystemRandom::new(); + let mut salt = [0u8; Self::SALT_LENGTH]; + let mut iv = [0u8; Self::IV_LENGTH]; + rng.fill(&mut salt).expect("Unable to generate salt."); + rng.fill(&mut iv).expect("Unable to generate IV."); + Argon2idAes256 { + iv: base64::encode(iv), + salt: base64::encode(salt), + } + } + + fn encrypt( + &self, + passkey: &str, + plaintext: Vec, + ) -> anyhow::Result, EntryEncryptionError> { + let start = std::time::Instant::now(); + let key = Self::get_encryption_key(passkey, &self.salt)?; + debug!("key derivation took: {:?}", start.elapsed()); + + let start = std::time::Instant::now(); + let mut iv = [0u8; Self::IV_LENGTH]; + base64::decode_config_slice(&self.iv, base64::STANDARD, &mut iv)?; + let cipher = cbc::Encryptor::::new_from_slices(&key, &iv)?; + let ciphertext = cipher.encrypt_padded_vec_mut::(&plaintext); + let encoded = base64::encode(ciphertext); + debug!("encryption took: {:?}", start.elapsed()); + Ok(encoded.as_bytes().to_vec()) + } + + fn decrypt( + &self, + passkey: &str, + ciphertext: Vec, + ) -> anyhow::Result, EntryEncryptionError> { + let start = std::time::Instant::now(); + let key = Self::get_encryption_key(passkey, &self.salt)?; + debug!("key derivation took: {:?}", start.elapsed()); + + let start = std::time::Instant::now(); + let mut iv = [0u8; Self::IV_LENGTH]; + base64::decode_config_slice(&self.iv, base64::STANDARD, &mut iv)?; + let cipher = cbc::Decryptor::::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 decrypted = cipher.decrypt_padded_mut::(&mut buffer)?; + debug!("decryption took: {:?}", start.elapsed()); + Ok(decrypted.to_vec()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encryption_key() { + assert_eq!( + base64::encode( + Argon2idAes256::get_encryption_key("password", "GMhL0N2hqXg=") + .unwrap() + .as_slice() + ), + "DTm3hc95aKyAGmyVMZdLUPfcPjcXN1i1zYObYJg2GzY=" + ); + } + + #[test] + fn test_encryption_key2() { + assert_eq!( + base64::encode( + Argon2idAes256::get_encryption_key("password", "wTzTE9A6aN8=") + .unwrap() + .as_slice() + ), + "zwMjXhwggpJWCvkouG/xrSPZRWn2cUUyph3PAViRONA=" + ); + } + + #[test] + fn test_ensure_encryption_symmetric() -> anyhow::Result<()> { + let cases = [ + "foo", + "tactical glizzy", + "glizzy gladiator", + "shadow wizard money gang", + "shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells", + ]; + let passkey = "password"; + let scheme = Argon2idAes256::generate(); + for case in cases { + eprintln!("testing case: {} (len {})", case, case.len()); + let orig = case.as_bytes().to_vec(); + let encrypted = scheme.encrypt(passkey, orig.clone()).unwrap(); + let result = scheme.decrypt(passkey, encrypted).unwrap(); + assert_eq!(orig, result.to_vec()); + } + Ok(()) + } +} diff --git a/src/encryption/legacy.rs b/src/encryption/legacy.rs new file mode 100644 index 0000000..4cccd3d --- /dev/null +++ b/src/encryption/legacy.rs @@ -0,0 +1,167 @@ +use aes::cipher::block_padding::Pkcs7; +use aes::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit}; +use aes::Aes256; +use log::*; +use ring::pbkdf2; + +use super::*; + +/// Encryption scheme that is compatible with SteamDesktopAuthenticator. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LegacySdaCompatible { + pub iv: String, + pub salt: String, +} + +impl LegacySdaCompatible { + const PBKDF2_ITERATIONS: u32 = 50000; // This is necessary to maintain compatibility with SteamDesktopAuthenticator. + const KEY_SIZE_BYTES: usize = 32; + const SALT_LENGTH: usize = 8; + const IV_LENGTH: usize = 16; + + fn get_encryption_key(passkey: &str, salt: &str) -> 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, + ); + Ok(full_key) + } +} + +impl EntryEncryptor for LegacySdaCompatible { + fn generate() -> LegacySdaCompatible { + let rng = ring::rand::SystemRandom::new(); + let mut salt = [0u8; Self::SALT_LENGTH]; + let mut iv = [0u8; Self::IV_LENGTH]; + rng.fill(&mut salt).expect("Unable to generate salt."); + rng.fill(&mut iv).expect("Unable to generate IV."); + LegacySdaCompatible { + iv: base64::encode(iv), + salt: base64::encode(salt), + } + } + + fn encrypt( + &self, + passkey: &str, + plaintext: Vec, + ) -> anyhow::Result, EntryEncryptionError> { + let start = std::time::Instant::now(); + let key = Self::get_encryption_key(passkey, &self.salt)?; + debug!("key derivation took: {:?}", start.elapsed()); + + let start = std::time::Instant::now(); + let mut iv = [0u8; Self::IV_LENGTH]; + base64::decode_config_slice(&self.iv, base64::STANDARD, &mut iv)?; + let cipher = cbc::Encryptor::::new_from_slices(&key, &iv)?; + let ciphertext = cipher.encrypt_padded_vec_mut::(&plaintext); + let encoded = base64::encode(ciphertext); + debug!("encryption took: {:?}", start.elapsed()); + Ok(encoded.as_bytes().to_vec()) + } + + fn decrypt( + &self, + passkey: &str, + ciphertext: Vec, + ) -> anyhow::Result, EntryEncryptionError> { + let start = std::time::Instant::now(); + let key = Self::get_encryption_key(passkey, &self.salt)?; + debug!("key derivation took: {:?}", start.elapsed()); + + let start = std::time::Instant::now(); + let mut iv = [0u8; Self::IV_LENGTH]; + base64::decode_config_slice(&self.iv, base64::STANDARD, &mut iv)?; + let cipher = cbc::Decryptor::::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 decrypted = cipher.decrypt_padded_mut::(&mut buffer)?; + debug!("decryption took: {:?}", start.elapsed()); + Ok(decrypted.to_vec()) + } +} + +#[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", "GMhL0N2hqXg=") + .unwrap() + .as_slice(), + base64::decode("KtiRa4/OxW83MlB6URf+Z8rAGj7CBY+pDlwD/NuVo6Y=") + .unwrap() + .as_slice() + ); + + assert_eq!( + LegacySdaCompatible::get_encryption_key("password", "wTzTE9A6aN8=") + .unwrap() + .as_slice(), + base64::decode("Dqpej/3DqEat0roJaHmu3luYgDzRCUmzX94n4fqvWj8=") + .unwrap() + .as_slice() + ); + } + + #[test] + fn test_ensure_encryption_symmetric() -> anyhow::Result<()> { + let cases = [ + "foo", + "tactical glizzy", + "glizzy gladiator", + "shadow wizard money gang", + "shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells, shadow wizard money gang, we love casting spells", + ]; + let passkey = "password"; + let scheme = LegacySdaCompatible::generate(); + for case in cases { + eprintln!("testing case: {} (len {})", case, case.len()); + let orig = case.as_bytes().to_vec(); + let encrypted = scheme.encrypt(passkey, orig.clone()).unwrap(); + let result = scheme.decrypt(passkey, encrypted).unwrap(); + assert_eq!(orig, result.to_vec()); + } + Ok(()) + } + + prop_compose! { + /// An insecure but reproducible strategy for generating encryption params. + fn encryption_params()(salt in any::<[u8; LegacySdaCompatible::SALT_LENGTH]>(), iv in any::<[u8; LegacySdaCompatible::IV_LENGTH]>()) -> LegacySdaCompatible { + LegacySdaCompatible { + salt: base64::encode(salt), + iv: base64::encode(iv), + } + } + } + + // 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()); + // } + // } +}