diff --git a/base_layer/wallet/src/error.rs b/base_layer/wallet/src/error.rs index 72e3780964..8c89ea3f8d 100644 --- a/base_layer/wallet/src/error.rs +++ b/base_layer/wallet/src/error.rs @@ -173,6 +173,8 @@ pub enum WalletStorageError { KeyManagerError(#[from] KeyManagerError), #[error("Recovery Seed Error: {0}")] RecoverySeedError(String), + #[error("Bad encryption version: `{0}`")] + BadEncryptionVersion(String), } impl From for ExitError { diff --git a/base_layer/wallet/src/storage/database.rs b/base_layer/wallet/src/storage/database.rs index 0e19128114..409b16db0d 100644 --- a/base_layer/wallet/src/storage/database.rs +++ b/base_layer/wallet/src/storage/database.rs @@ -68,8 +68,9 @@ pub enum DbKey { BaseNodeChainMetadata, ClientKey(String), MasterSeed, - PassphraseHash, - EncryptionSalt, + EncryptedMainKey, // the database encryption key, itself encrypted with the secondary key + SecondaryKeySalt, // the salt used (with the user's passphrase) to derive the secondary key + SecondaryKeyVersion, // the parameter version for the secondary key, which determines how it is derived WalletBirthday, } @@ -82,8 +83,9 @@ impl DbKey { DbKey::TorId => "TorId".to_string(), DbKey::ClientKey(k) => format!("ClientKey.{}", k), DbKey::BaseNodeChainMetadata => "BaseNodeChainMetadata".to_string(), - DbKey::PassphraseHash => "PassphraseHash".to_string(), - DbKey::EncryptionSalt => "EncryptionSalt".to_string(), + DbKey::EncryptedMainKey => "EncryptedMainKey".to_string(), + DbKey::SecondaryKeySalt => "SecondaryKeySalt".to_string(), + DbKey::SecondaryKeyVersion => "SecondaryKeyVersion".to_string(), DbKey::WalletBirthday => "WalletBirthday".to_string(), DbKey::CommsIdentitySignature => "CommsIdentitySignature".to_string(), } @@ -99,8 +101,9 @@ pub enum DbValue { ValueCleared, BaseNodeChainMetadata(ChainMetadata), MasterSeed(CipherSeed), - PassphraseHash(String), - EncryptionSalt(String), + EncryptedMainKey(String), + SecondaryKeySalt(String), + SecondaryKeyVersion(String), WalletBirthday(String), } @@ -333,8 +336,9 @@ impl Display for DbValue { DbValue::CommsAddress(_) => f.write_str("Comms Address"), DbValue::TorId(v) => f.write_str(&format!("Tor ID: {}", v)), DbValue::BaseNodeChainMetadata(v) => f.write_str(&format!("Last seen Chain metadata from base node:{}", v)), - DbValue::PassphraseHash(h) => f.write_str(&format!("PassphraseHash: {}", h)), - DbValue::EncryptionSalt(s) => f.write_str(&format!("EncryptionSalt: {}", s)), + DbValue::EncryptedMainKey(k) => f.write_str(&format!("EncryptedMainKey: {:?}", k)), + DbValue::SecondaryKeySalt(s) => f.write_str(&format!("SecondaryKeySalt: {}", s)), + DbValue::SecondaryKeyVersion(v) => f.write_str(&format!("SecondaryKeyVersion: {}", v)), DbValue::WalletBirthday(b) => f.write_str(&format!("WalletBirthday: {}", b)), DbValue::CommsIdentitySignature(_) => f.write_str("CommsIdentitySignature"), } diff --git a/base_layer/wallet/src/storage/sqlite_db/wallet.rs b/base_layer/wallet/src/storage/sqlite_db/wallet.rs index 3903d873cb..211dc5cd53 100644 --- a/base_layer/wallet/src/storage/sqlite_db/wallet.rs +++ b/base_layer/wallet/src/storage/sqlite_db/wallet.rs @@ -27,9 +27,9 @@ use std::{ sync::{Arc, RwLock}, }; -use argon2::{ - password_hash::{rand_core::OsRng, Decimal, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, - Argon2, +use argon2::password_hash::{ + rand_core::{OsRng, RngCore}, + SaltString, }; use chacha20poly1305::{Key, KeyInit, Tag, XChaCha20Poly1305, XNonce}; use diesel::{prelude::*, SqliteConnection}; @@ -43,6 +43,7 @@ use tari_comms::{ use tari_key_manager::cipher_seed::CipherSeed; use tari_utilities::{ hex::{from_hex, Hex}, + hidden_type, safe_array::SafeArray, Hidden, SafePassword, @@ -64,6 +65,41 @@ use crate::{ const LOG_TARGET: &str = "wallet::storage::wallet"; +// The main `XChaCha20-Poly1305` key used for database encryption +// This isn't a `SafeArray` because of how we populate it from an authenticated decryption +// However, it is `Hidden` and therefore should be safe to use +hidden_type!(WalletMainEncryptionKey, Vec); + +// The secondary `XChaCha20-Poly1305` key used to encrypt the main key +hidden_type!(WalletSecondaryEncryptionKey, SafeArray() }>); + +/// A structure to hold `Argon2` parameter versions, which may change over time and must be supported +pub struct Argon2Parameters { + id: u8, // version identifier + algorithm: argon2::Algorithm, // algorithm variant + version: argon2::Version, // algorithm version + params: argon2::Params, // memory, iteration count, parallelism, output length +} +impl Argon2Parameters { + /// Construct and return `Argon2` parameters by version identifier + /// If you pass in `None`, you'll get the most recent + pub fn from_version(id: Option) -> Result { + // Each subsequent version identifier _must_ increase! + // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id + match id { + // Be sure to update the `None` behavior when updating this! + None | Some(1) => Ok(Argon2Parameters { + id: 1, + algorithm: argon2::Algorithm::Argon2id, + version: argon2::Version::V0x13, + params: argon2::Params::new(46 * 1024, 1, 1, Some(size_of::())) + .map_err(|e| WalletStorageError::AeadError(e.to_string()))?, + }), + Some(id) => Err(WalletStorageError::BadEncryptionVersion(id.to_string())), + } + } +} + /// A Sqlite backend for the Output Manager Service. The Backend is accessed via a connection pool to the Sqlite file. #[derive(Clone)] pub struct WalletSqliteDatabase { @@ -292,8 +328,9 @@ impl WalletSqliteDatabase { DbKey::CommsFeatures | DbKey::CommsAddress | DbKey::BaseNodeChainMetadata | - DbKey::PassphraseHash | - DbKey::EncryptionSalt | + DbKey::EncryptedMainKey | + DbKey::SecondaryKeySalt | + DbKey::SecondaryKeyVersion | DbKey::WalletBirthday | DbKey::CommsIdentitySignature => { return Err(WalletStorageError::OperationNotSupported); @@ -337,8 +374,9 @@ impl WalletBackend for WalletSqliteDatabase { DbKey::TorId => self.get_tor_id(&conn)?, DbKey::CommsFeatures => self.get_comms_features(&conn)?.map(DbValue::CommsFeatures), DbKey::BaseNodeChainMetadata => self.get_chain_metadata(&conn)?.map(DbValue::BaseNodeChainMetadata), - DbKey::PassphraseHash => WalletSettingSql::get(key, &conn)?.map(DbValue::PassphraseHash), - DbKey::EncryptionSalt => WalletSettingSql::get(key, &conn)?.map(DbValue::EncryptionSalt), + DbKey::EncryptedMainKey => WalletSettingSql::get(key, &conn)?.map(DbValue::EncryptedMainKey), + DbKey::SecondaryKeySalt => WalletSettingSql::get(key, &conn)?.map(DbValue::SecondaryKeySalt), + DbKey::SecondaryKeyVersion => WalletSettingSql::get(key, &conn)?.map(DbValue::SecondaryKeyVersion), DbKey::WalletBirthday => WalletSettingSql::get(key, &conn)?.map(DbValue::WalletBirthday), DbKey::CommsIdentitySignature => WalletSettingSql::get(key, &conn)? .and_then(|s| from_hex(&s).ok()) @@ -402,11 +440,7 @@ impl WalletBackend for WalletSqliteDatabase { } } -/// Confirm if database is encrypted or not and if a cipher is provided confirm the cipher is correct. -/// Unencrypted the database should contain a MasterSecretKey and associated MasterPublicKey -/// Encrypted the data should contain a Master Public Key in the clear and an encrypted MasterSecretKey -/// To confirm if the provided Cipher is correct we decrypt the Master PrivateSecretKey and see if it produces the same -/// Master Public Key that is stored in the db +/// If the database is encrypted, produce a cipher that can be used for this purpose #[allow(clippy::too_many_lines)] fn get_db_encryption( database_connection: &WalletDbConnection, @@ -416,43 +450,53 @@ fn get_db_encryption( let conn = database_connection.get_pooled_connection()?; let acquire_lock = start.elapsed(); - let db_passphrase_hash = WalletSettingSql::get(&DbKey::PassphraseHash, &conn)?; - let db_encryption_salt = WalletSettingSql::get(&DbKey::EncryptionSalt, &conn)?; - - let secret_seed = WalletSettingSql::get(&DbKey::MasterSeed, &conn)?; - - let cipher = get_cipher_for_db_encryption(passphrase, db_passphrase_hash, db_encryption_salt, &secret_seed, &conn)?; - - if let Some(mut sk) = secret_seed { - // We need to make sure the secret key was encrypted. Try to decrypt it - let mut sk_bytes: Vec = from_hex(sk.as_str())?; - - if sk_bytes.len() < size_of::() + size_of::() { - // zeroize sk and sk_bytes, so no memory leak happens, as sk could have been decrypted - sk.zeroize(); - sk_bytes.zeroize(); + // We use the user's passphrase and this salt to derive the _secondary key_ + // This key decrypts the _main key_ stored in the database, which is used for other field storage + let secondary_key_version = WalletSettingSql::get(&DbKey::SecondaryKeyVersion, &conn)?; + let secondary_key_salt = WalletSettingSql::get(&DbKey::SecondaryKeySalt, &conn)?; + let encrypted_main_key = WalletSettingSql::get(&DbKey::EncryptedMainKey, &conn)?; + + // Fetch the encrypted seed if available + // This is a legacy check, and it's unclear if it's actually necessary or useful + let secret_seed = WalletSettingSql::get(&DbKey::MasterSeed, &conn)?.map(Hidden::hide); + + let cipher = get_cipher_for_db_encryption( + passphrase, + secondary_key_version, + secondary_key_salt, + encrypted_main_key, + &secret_seed, + &conn, + )?; + + // Test that the encrypted secret key represents a valid cipher seed + // This is a legacy check, and it's unclear if it's actually necessary or useful + if let Some(secret_seed) = secret_seed { + let secret_seed_bytes = Hidden::hide(from_hex(secret_seed.reveal().as_str())?); + + // If an invalid size, the seed must not be encrypted + if secret_seed_bytes.reveal().len() < size_of::() + size_of::() { return Err(WalletStorageError::MissingNonce); } - // We try to decrypt the secret seed data, using our computed cipher. Moreover, - // decrypted key contains sensitive data, we make sure we appropriately zeroize - // the corresponding data buffer, when leaving the current scope - let decrypted_key = Hidden::hide( - decrypt_bytes_integral_nonce(&cipher, b"wallet_setting_master_seed".to_vec(), &sk_bytes).map_err(|e| { - error!(target: LOG_TARGET, "Incorrect passphrase ({})", e); - // zeroize sk and sk_bytes, so no memory leak happens, as sk could have been decrypted - sk.zeroize(); - sk_bytes.zeroize(); + // Authenticate and decrypt the encrypted seed + let seed_bytes = Hidden::hide( + decrypt_bytes_integral_nonce( + &cipher, + b"wallet_setting_master_seed".to_vec(), + secret_seed_bytes.reveal(), + ) + .map_err(|e| { + error!(target: LOG_TARGET, "Unable to decrypt encrypted seed: {}", e); + WalletStorageError::InvalidPassphrase })?, ); - // from this point on, we are sure that sk and thus sk_bytes were encrypted, so we might safely not zeroize them - let _cipher_seed = CipherSeed::from_enciphered_bytes(decrypted_key.reveal(), None).map_err(|_| { - error!( - target: LOG_TARGET, - "Decrypted Master Secret Key cannot be parsed into a Cipher Seed" - ); + // Test for a valid cipher seed + let _cipher_seed = CipherSeed::from_enciphered_bytes(seed_bytes.reveal(), None).map_err(|_| { + error!(target: LOG_TARGET, "Unable to parse seed"); + WalletStorageError::InvalidEncryptionCipher })?; } @@ -472,99 +516,108 @@ fn get_db_encryption( fn get_cipher_for_db_encryption( passphrase: SafePassword, - passphrase_hash: Option, - encryption_salt: Option, - secret_seed: &Option, + secondary_key_version: Option, + secondary_key_salt: Option, + encrypted_main_key: Option, + secret_seed: &Option>, conn: &SqliteConnection, ) -> Result { - let encryption_salt = match (passphrase_hash, encryption_salt) { - (None, None) => { - // Use the recommended OWASP parameters, which are not the default: - // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id - - // These are the parameters for the passphrase hash - let params_passphrase = argon2::Params::new( - 46 * 1024, // m-cost: 46 MiB, converted to KiB - 1, // t-cost - 1, // p-cost - None, // output length: default is fine for this use - ) - .map_err(|e| WalletStorageError::AeadError(e.to_string()))?; - - // Hash the passphrase to a PHC string for later verification - let passphrase_salt = SaltString::generate(&mut OsRng); - let passphrase_hash = argon2::Argon2::default() - .hash_password_customized( + // We'll bind the version identifier to this domain before it's used + let main_key_domain = b"wallet_main_key_encryption_v".to_vec(); + + let main_key = match (secondary_key_version, secondary_key_salt, encrypted_main_key) { + // Encryption is not set up yet + (None, None, None) => { + // Generate a high-entropy main key + let mut main_key = WalletMainEncryptionKey::from(vec![0u8; size_of::()]); + let mut rng = OsRng; + rng.fill_bytes(main_key.reveal_mut()); + + // We'll be encrypting the main key shortly, so keep a clone around + let main_key_clone = main_key.clone(); + + // Use the most recent `Argon2` parameters + let argon2_params = Argon2Parameters::from_version(None)?; + + // Derive the secondary key from the user's passphrase and a high-entropy salt + let secondary_key_salt = SaltString::generate(&mut rng); + let mut secondary_key = WalletSecondaryEncryptionKey::from(SafeArray::default()); + argon2::Argon2::new(argon2_params.algorithm, argon2_params.version, argon2_params.params) + .hash_password_into( passphrase.reveal(), - Some(argon2::Algorithm::Argon2id.ident()), - Some(argon2::Version::V0x13 as Decimal), // the API requires the numerical version representation - params_passphrase, - &passphrase_salt, + secondary_key_salt.as_bytes(), + secondary_key.reveal_mut(), ) - .map_err(|e| WalletStorageError::AeadError(e.to_string()))? - .to_string(); - - // Hash the passphrase to produce a ChaCha20-Poly1305 key - let encryption_salt = SaltString::generate(&mut OsRng); - - // insert passphrase hash and encryption salt on the wallet db - WalletSettingSql::new(DbKey::PassphraseHash, passphrase_hash).set(conn)?; - WalletSettingSql::new(DbKey::EncryptionSalt, encryption_salt.to_string()).set(conn)?; - - encryption_salt.to_string() + .map_err(|e| WalletStorageError::AeadError(e.to_string()))?; + + // Use the secondary key to encrypt the main key, authenticating with the version to mitigate mismatch + // attacks + let main_key_cipher = XChaCha20Poly1305::new(Key::from_slice(secondary_key.reveal())); + let mut aad = main_key_domain; + aad.push(argon2_params.id); + let encrypted_main_key = + encrypt_bytes_integral_nonce(&main_key_cipher, aad, Hidden::hide(main_key.reveal().clone())) + .map_err(WalletStorageError::AeadError)?; + + // Store the secondary key version, secondary key salt, and encrypted main key + WalletSettingSql::new(DbKey::SecondaryKeyVersion, argon2_params.id.to_string()).set(conn)?; + WalletSettingSql::new(DbKey::SecondaryKeySalt, secondary_key_salt.to_string()).set(conn)?; + WalletSettingSql::new(DbKey::EncryptedMainKey, encrypted_main_key.to_hex()).set(conn)?; + + // Return the unencrypted main key + main_key_clone }, - (Some(ph), Some(es)) => { - let argon2 = Argon2::default(); - let stored_hash = PasswordHash::new(&ph).map_err(|e| WalletStorageError::AeadError(e.to_string()))?; - - // Check the passphrase PHC string against the provided passphrase - if let Err(e) = argon2.verify_password(passphrase.reveal(), &stored_hash) { - error!(target: LOG_TARGET, "Incorrect passphrase ({})", e); - return Err(WalletStorageError::InvalidPassphrase); - } - + // Encryption has already been set up + (Some(secondary_key_version), Some(secondary_key_salt), Some(encrypted_main_key)) => { + // Use the given version if it is valid + let version = u8::from_str(&secondary_key_version) + .map_err(|e| WalletStorageError::BadEncryptionVersion(e.to_string()))?; + let argon2_params = Argon2Parameters::from_version(Some(version))?; + + // Ensure there is encrypted seed data present (we test it later for validity) + // This is a legacy check, and it's unclear if it's actually necessary or useful if secret_seed.is_none() { error!( target: LOG_TARGET, - "Cipher is provided but there is no Master Secret Key in DB to decrypt" + "Encryption is set up, but there is no encrypted seed present" ); return Err(WalletStorageError::InvalidEncryptionCipher); } - es + // Derive the secondary key from the user's passphrase and salt + let mut secondary_key = WalletSecondaryEncryptionKey::from(SafeArray::default()); + argon2::Argon2::new(argon2_params.algorithm, argon2_params.version, argon2_params.params) + .hash_password_into( + passphrase.reveal(), + secondary_key_salt.as_bytes(), + secondary_key.reveal_mut(), + ) + .map_err(|e| WalletStorageError::AeadError(e.to_string()))?; + + // Attempt to decrypt the encrypted main key + let main_key_cipher = XChaCha20Poly1305::new(Key::from_slice(secondary_key.reveal())); + let mut aad = main_key_domain; + aad.push(version); + + WalletMainEncryptionKey::from( + decrypt_bytes_integral_nonce( + &main_key_cipher, + aad, + &from_hex(&encrypted_main_key).map_err(|e| WalletStorageError::ConversionError(e.to_string()))?, + ) + .map_err(|_| WalletStorageError::InvalidPassphrase)?, + ) }, + // We don't have all the data required for encryption _ => { - error!( - target: LOG_TARGET, - "Only passphrase hash or encryption hash were provided, need both values for successful encryption" - ); + error!(target: LOG_TARGET, "Not enough data provided to set up encryption"); return Err(WalletStorageError::UnexpectedResult( - "Encryption should be possible only by providing both passphrase hash and encrypted salt".into(), + "Not enough data provided to set up encryption".into(), )); }, }; - // Use the recommended OWASP parameters, which are not the default: - // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id - let params_encryption = argon2::Params::new( - 46 * 1024, // m-cost: 46 MiB, converted to KiB - 1, // t-cost - 1, // p-cost - Some(size_of::()), // output length: ChaCha20-Poly1305 key size - ) - .map_err(|e| WalletStorageError::AeadError(e.to_string()))?; - - // Hash the passphrase to produce a ChaCha20-Poly1305 key - let mut derived_encryption_key = Hidden::hide(SafeArray::() }>::default()); - argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params_encryption) - .hash_password_into( - passphrase.reveal(), - encryption_salt.as_bytes(), - derived_encryption_key.reveal_mut(), - ) - .map_err(|e| WalletStorageError::AeadError(e.to_string()))?; - - Ok(XChaCha20Poly1305::new(Key::from_slice(derived_encryption_key.reveal()))) + Ok(XChaCha20Poly1305::new(Key::from_slice(main_key.reveal()))) } /// A Sql version of the wallet setting key-value table