diff --git a/crates/bitwarden-crypto/examples/protect_key_with_password.rs b/crates/bitwarden-crypto/examples/protect_key_with_password.rs new file mode 100644 index 000000000..b3fd4f5a2 --- /dev/null +++ b/crates/bitwarden-crypto/examples/protect_key_with_password.rs @@ -0,0 +1,103 @@ +//! This example demonstrates how to securely protect keys with a password using the +//! [PasswordProtectedKeyEnvelope]. + +use bitwarden_crypto::{ + key_ids, KeyStore, KeyStoreContext, PasswordProtectedKeyEnvelope, + PasswordProtectedKeyEnvelopeError, +}; + +fn main() { + let key_story = KeyStore::::default(); + let mut ctx: KeyStoreContext<'_, ExampleIds> = key_story.context_mut(); + let mut disk = MockDisk::new(); + + // Alice wants to protect a key with a password. + // For example to: + // - Protect her vault with a pin + // - Protect her exported vault with a password + // - Protect a send with a URL fragment secret + // For this, the `PasswordProtectedKeyEnvelope` is used. + + // Alice has a vault protected with a symmetric key. She wants this protected with a PIN. + let vault_key = ctx + .generate_symmetric_key(ExampleSymmetricKey::VaultKey) + .unwrap(); + + // Seal the key with the PIN + // The KDF settings are chosen for you, and do not need to be separately tracked or synced + // Next, story this protected key envelope on disk. + let pin = "1234"; + let envelope = + PasswordProtectedKeyEnvelope::seal(vault_key, pin, &ctx).expect("Sealing should work"); + disk.save("vault_key_envelope", (&envelope).try_into().unwrap()); + + // Wipe the context to simulate new session + ctx.clear_local(); + + // Load the envelope from disk and unseal it with the PIN, and store it in the context. + let deserialized: PasswordProtectedKeyEnvelope = + PasswordProtectedKeyEnvelope::try_from(disk.load("vault_key_envelope").unwrap()).unwrap(); + deserialized + .unseal(ExampleSymmetricKey::VaultKey, pin, &mut ctx) + .unwrap(); + + // Alice wants to change her password; also her KDF settings are below the minimums. + // Re-sealing will update the password, and KDF settings. + let envelope = envelope + .reseal(pin, "0000") + .expect("The password should be valid"); + disk.save("vault_key_envelope", (&envelope).try_into().unwrap()); + + // Alice wants to change the protected key. This requires creating a new envelope + ctx.generate_symmetric_key(ExampleSymmetricKey::VaultKey) + .unwrap(); + let envelope = PasswordProtectedKeyEnvelope::seal(ExampleSymmetricKey::VaultKey, "0000", &ctx) + .expect("Sealing should work"); + disk.save("vault_key_envelope", (&envelope).try_into().unwrap()); + + // Alice tries the password but it is wrong + assert!(matches!( + envelope.unseal(ExampleSymmetricKey::VaultKey, "9999", &mut ctx), + Err(PasswordProtectedKeyEnvelopeError::WrongPassword) + )); +} + +pub(crate) struct MockDisk { + map: std::collections::HashMap>, +} + +impl MockDisk { + pub(crate) fn new() -> Self { + MockDisk { + map: std::collections::HashMap::new(), + } + } + + pub(crate) fn save(&mut self, key: &str, value: Vec) { + self.map.insert(key.to_string(), value); + } + + pub(crate) fn load(&self, key: &str) -> Option<&Vec> { + self.map.get(key) + } +} + +key_ids! { + #[symmetric] + pub enum ExampleSymmetricKey { + #[local] + VaultKey + } + + #[asymmetric] + pub enum ExampleAsymmetricKey { + Key(u8), + } + + #[signing] + pub enum ExampleSigningKey { + Key(u8), + } + + pub ExampleIds => ExampleSymmetricKey, ExampleAsymmetricKey, ExampleSigningKey; +} diff --git a/crates/bitwarden-crypto/src/cose.rs b/crates/bitwarden-crypto/src/cose.rs index b2a39ab56..67f4990b3 100644 --- a/crates/bitwarden-crypto/src/cose.rs +++ b/crates/bitwarden-crypto/src/cose.rs @@ -22,10 +22,16 @@ use crate::{ pub(crate) const XCHACHA20_POLY1305: i64 = -70000; const XCHACHA20_TEXT_PAD_BLOCK_SIZE: usize = 32; +pub(crate) const ALG_ARGON2ID13: i64 = -71000; +pub(crate) const ARGON2_SALT: i64 = -71001; +pub(crate) const ARGON2_ITERATIONS: i64 = -71002; +pub(crate) const ARGON2_MEMORY: i64 = -71003; +pub(crate) const ARGON2_PARALLELISM: i64 = -71004; + // Note: These are in the "unregistered" tree: https://datatracker.ietf.org/doc/html/rfc6838#section-3.4 // These are only used within Bitwarden, and not meant for exchange with other systems. const CONTENT_TYPE_PADDED_UTF8: &str = "application/x.bitwarden.utf8-padded"; -const CONTENT_TYPE_BITWARDEN_LEGACY_KEY: &str = "application/x.bitwarden.legacy-key"; +pub(crate) const CONTENT_TYPE_BITWARDEN_LEGACY_KEY: &str = "application/x.bitwarden.legacy-key"; const CONTENT_TYPE_SPKI_PUBLIC_KEY: &str = "application/x.bitwarden.spki-public-key"; // Labels diff --git a/crates/bitwarden-crypto/src/keys/mod.rs b/crates/bitwarden-crypto/src/keys/mod.rs index afa1b3233..d6eb65f38 100644 --- a/crates/bitwarden-crypto/src/keys/mod.rs +++ b/crates/bitwarden-crypto/src/keys/mod.rs @@ -9,7 +9,7 @@ mod symmetric_crypto_key; #[cfg(test)] pub use symmetric_crypto_key::derive_symmetric_key; pub use symmetric_crypto_key::{ - Aes256CbcHmacKey, Aes256CbcKey, SymmetricCryptoKey, XChaCha20Poly1305Key, + Aes256CbcHmacKey, Aes256CbcKey, EncodedSymmetricKey, SymmetricCryptoKey, XChaCha20Poly1305Key, }; mod asymmetric_crypto_key; pub use asymmetric_crypto_key::{ diff --git a/crates/bitwarden-crypto/src/keys/symmetric_crypto_key.rs b/crates/bitwarden-crypto/src/keys/symmetric_crypto_key.rs index de2b54a8a..4fcdeac09 100644 --- a/crates/bitwarden-crypto/src/keys/symmetric_crypto_key.rs +++ b/crates/bitwarden-crypto/src/keys/symmetric_crypto_key.rs @@ -407,6 +407,7 @@ impl From for Vec { } } impl EncodedSymmetricKey { + /// Returns the content format of the encoded symmetric key. #[allow(private_interfaces)] pub fn content_format(&self) -> ContentFormat { match self { diff --git a/crates/bitwarden-crypto/src/lib.rs b/crates/bitwarden-crypto/src/lib.rs index d4cc15f45..e9dcbfaa4 100644 --- a/crates/bitwarden-crypto/src/lib.rs +++ b/crates/bitwarden-crypto/src/lib.rs @@ -36,6 +36,8 @@ pub use store::{ }; mod cose; pub use cose::CoseSerializable; +mod safe; +pub use safe::*; mod signing; pub use signing::*; mod traits; diff --git a/crates/bitwarden-crypto/src/safe/mod.rs b/crates/bitwarden-crypto/src/safe/mod.rs new file mode 100644 index 000000000..fa863bc19 --- /dev/null +++ b/crates/bitwarden-crypto/src/safe/mod.rs @@ -0,0 +1,2 @@ +mod password_protected_key_envelope; +pub use password_protected_key_envelope::*; diff --git a/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs b/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs new file mode 100644 index 000000000..bb5162d55 --- /dev/null +++ b/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs @@ -0,0 +1,462 @@ +use std::marker::PhantomData; + +use ciborium::{value::Integer, Value}; +use coset::{ + iana::CoapContentFormat, CborSerializable, ContentType, CoseError, Header, HeaderBuilder, Label, +}; +use rand::RngCore; +use thiserror::Error; + +use crate::{ + cose::{ + ALG_ARGON2ID13, ARGON2_ITERATIONS, ARGON2_MEMORY, ARGON2_PARALLELISM, ARGON2_SALT, + CONTENT_TYPE_BITWARDEN_LEGACY_KEY, + }, + xchacha20, BitwardenLegacyKeyBytes, ContentFormat, CoseKeyBytes, EncodedSymmetricKey, KeyIds, + KeyStoreContext, SymmetricCryptoKey, +}; + +/// A password-protected key envelope can seal a symmetric key, and protect it with a password. It +/// does so by using a Key Derivation Function (KDF), to increase the difficulty of brute-forcing +/// the password. +/// +/// The KDF parameters such as iterations and salt are stored in the key-envelope and do not have to +/// be provided. +pub struct PasswordProtectedKeyEnvelope { + _phantom: PhantomData, + cose_encrypt: coset::CoseEncrypt, +} + +impl PasswordProtectedKeyEnvelope { + /// Seals a symmetric key with a password, using the current default KDF parameters and a random + /// salt. + /// + /// This should never fail, except for memory allocation error, when running the KDF. + pub fn seal( + key_to_seal: Ids::Symmetric, + password: &str, + ctx: &KeyStoreContext, + ) -> Result { + #[allow(deprecated)] + let key_ref = ctx + .dangerous_get_symmetric_key(key_to_seal) + .expect("Key should exist in the key store"); + Self::seal_ref(&key_ref, password) + } + + fn seal_ref( + key_to_seal: &SymmetricCryptoKey, + password: &str, + ) -> Result { + let kdf = Argon2RawSettings { + iterations: 3, + memory: 64 * 1024, // 64 MiB + parallelism: 4, + salt: make_salt(), + }; + Self::seal_ref_with_settings(key_to_seal, password, &kdf) + } + + fn seal_ref_with_settings( + key_to_seal: &SymmetricCryptoKey, + password: &str, + kdf_settings: &Argon2RawSettings, + ) -> Result { + // Cose does not yet have a standardized way to protect a key using a password. + // This implements content encryption using direct encryption with a KDF derived key, + // similar to "Direct Key with KDF". The KDF settings are placed in a single + // recipient struct. + + // The envelope key is directly derived from the KDF and used as the key to encrypt the key + // that should be sealed. + let envelope_key = derive_key(kdf_settings, password) + .map_err(|_| PasswordProtectedKeyEnvelopeError::KdfError)?; + + #[allow(deprecated)] + let (content_format, key_to_seal_bytes) = match key_to_seal.to_encoded_raw() { + EncodedSymmetricKey::BitwardenLegacyKey(key_bytes) => { + (ContentFormat::BitwardenLegacyKey, key_bytes.to_vec()) + } + EncodedSymmetricKey::CoseKey(key_bytes) => (ContentFormat::CoseKey, key_bytes.to_vec()), + }; + + let mut nonce = [0u8; crate::xchacha20::NONCE_SIZE]; + let mut cose_encrypt = coset::CoseEncryptBuilder::new() + .add_recipient({ + let mut recipient = coset::CoseRecipientBuilder::new() + .unprotected(kdf_settings.into()) + .build(); + recipient.protected.header.alg = Some(coset::Algorithm::PrivateUse(ALG_ARGON2ID13)); + recipient + }) + .protected(HeaderBuilder::from(content_format).build()) + .create_ciphertext(&key_to_seal_bytes, &[], |data, aad| { + let ciphertext = xchacha20::encrypt_xchacha20_poly1305(&envelope_key, data, aad); + nonce.copy_from_slice(&ciphertext.nonce()); + ciphertext.encrypted_bytes().to_vec() + }) + .build(); + cose_encrypt.unprotected.iv = nonce.into(); + + Ok(PasswordProtectedKeyEnvelope { + _phantom: PhantomData, + cose_encrypt, + }) + } + + /// Unseals a symmetric key from the password-protected envelope, and stores it in the key store + /// context. + pub fn unseal( + &self, + target_keyslot: Ids::Symmetric, + password: &str, + ctx: &mut KeyStoreContext, + ) -> Result { + let key = self.unseal_ref(password)?; + #[allow(deprecated)] + ctx.set_symmetric_key(target_keyslot, key).unwrap(); + Ok(target_keyslot) + } + + fn unseal_ref( + &self, + password: &str, + ) -> Result { + // There must be exactly one recipient in the COSE Encrypt object, which contains the KDF + // parameters. + if self.cose_encrypt.recipients.len() != 1 { + return Err(PasswordProtectedKeyEnvelopeError::ParsingError( + "Invalid number of recipients".to_string(), + )); + } + + let recipient = self + .cose_encrypt + .recipients + .get(0) + .expect("Recipient should exist"); + if recipient.protected.header.alg != Some(coset::Algorithm::PrivateUse(ALG_ARGON2ID13)) { + return Err(PasswordProtectedKeyEnvelopeError::ParsingError( + "Unknown or unsupported KDF algorithm".to_string(), + )); + } + + let kdf_settings: Argon2RawSettings = + recipient.unprotected.clone().try_into().map_err(|_| { + PasswordProtectedKeyEnvelopeError::ParsingError( + "Invalid or missing KDF parameters".to_string(), + ) + })?; + let envelope_key = derive_key(&kdf_settings, password) + .map_err(|_| PasswordProtectedKeyEnvelopeError::KdfError)?; + + let key_bytes = self + .cose_encrypt + .decrypt(&[], |data, aad| { + xchacha20::decrypt_xchacha20_poly1305( + &self.cose_encrypt.unprotected.iv.clone().try_into().unwrap(), + &envelope_key, + data, + aad, + ) + }) + .map_err(|_| PasswordProtectedKeyEnvelopeError::WrongPassword)?; + + let key = SymmetricCryptoKey::try_from( + match self.cose_encrypt.protected.header.content_type.as_ref() { + Some(ContentType::Text(format)) if format == CONTENT_TYPE_BITWARDEN_LEGACY_KEY => { + EncodedSymmetricKey::BitwardenLegacyKey(BitwardenLegacyKeyBytes::from( + key_bytes, + )) + } + Some(ContentType::Assigned(CoapContentFormat::CoseKey)) => { + EncodedSymmetricKey::CoseKey(CoseKeyBytes::from(key_bytes)) + } + _ => { + return Err(PasswordProtectedKeyEnvelopeError::ParsingError( + "Unknown or unsupported content format".to_string(), + )); + } + }, + ) + .unwrap(); + Ok(key) + } + + /// Re-seals the key with new KDF parameters (updated settings, salt), and a new password + pub fn reseal( + &self, + password: &str, + new_password: &str, + ) -> Result { + let unsealed = self.unseal_ref(password)?; + Self::seal_ref(&unsealed, new_password) + } +} + +impl TryInto> for &PasswordProtectedKeyEnvelope { + type Error = CoseError; + + fn try_into(self) -> Result, Self::Error> { + self.cose_encrypt.clone().to_vec() + } +} + +impl TryFrom<&Vec> for PasswordProtectedKeyEnvelope { + type Error = CoseError; + + fn try_from(value: &Vec) -> Result { + let cose_encrypt = coset::CoseEncrypt::from_slice(&value)?; + Ok(PasswordProtectedKeyEnvelope { + _phantom: PhantomData, + cose_encrypt, + }) + } +} + +/// Raw argon2 settings differ from the KDF struct defined for existing master-password unlock. +/// The memory is represented in kibibytes (KiB) instead of mebibytes (MiB), and the salt is a fixed +/// size of 32 bytes, and randomly generated, instead of being derived from the email. +struct Argon2RawSettings { + iterations: u32, + memory: u32, + parallelism: u32, + salt: [u8; 32], +} + +impl Into
for &Argon2RawSettings { + fn into(self) -> Header { + let builder = HeaderBuilder::new() + .value(ARGON2_ITERATIONS, Integer::from(self.iterations).into()) + .value(ARGON2_MEMORY, Integer::from(self.memory).into()) + .value(ARGON2_PARALLELISM, Integer::from(self.parallelism).into()) + .value(ARGON2_SALT, Value::from(self.salt.to_vec())); + + let mut header = builder.build(); + header.alg = Some(coset::Algorithm::PrivateUse(ALG_ARGON2ID13)); + header + } +} + +impl TryInto for Header { + type Error = PasswordProtectedKeyEnvelopeError; + + fn try_into(self) -> Result { + let iterations = self + .rest + .iter() + .find_map(|(label, value)| match (label, value) { + (Label::Int(ARGON2_ITERATIONS), ciborium::Value::Integer(value)) => Some(value), + _ => None, + }) + .ok_or(PasswordProtectedKeyEnvelopeError::ParsingError( + "Missing Argon2 iterations".to_string(), + ))?; + let memory = self + .rest + .iter() + .find_map(|(label, value)| match (label, value) { + (Label::Int(ARGON2_MEMORY), ciborium::Value::Integer(value)) => Some(value), + _ => None, + }) + .ok_or(PasswordProtectedKeyEnvelopeError::ParsingError( + "Missing Argon2 memory".to_string(), + ))?; + let parallelism = self + .rest + .iter() + .find_map(|(label, value)| match (label, value) { + (Label::Int(ARGON2_PARALLELISM), ciborium::Value::Integer(value)) => Some(value), + _ => None, + }) + .ok_or(PasswordProtectedKeyEnvelopeError::ParsingError( + "Missing Argon2 parallelism".to_string(), + ))?; + let salt: [u8; 32] = self + .rest + .iter() + .find_map(|(label, value)| match (label, value) { + (Label::Int(ARGON2_SALT), ciborium::Value::Bytes(value)) if value.len() == 32 => { + Some(value.as_slice().try_into().unwrap()) + } + _ => None, + }) + .ok_or(PasswordProtectedKeyEnvelopeError::ParsingError( + "Missing or invalid Argon2 salt".to_string(), + ))?; + + Ok(Argon2RawSettings { + iterations: i128::from(*iterations).try_into().map_err(|_| { + PasswordProtectedKeyEnvelopeError::ParsingError( + "Invalid Argon2 iterations".to_string(), + ) + })?, + memory: i128::from(*memory).try_into().map_err(|_| { + PasswordProtectedKeyEnvelopeError::ParsingError("Invalid Argon2 memory".to_string()) + })?, + parallelism: i128::from(*parallelism).try_into().map_err(|_| { + PasswordProtectedKeyEnvelopeError::ParsingError( + "Invalid Argon2 parallelism".to_string(), + ) + })?, + salt, + }) + } +} + +fn make_salt() -> [u8; 32] { + let mut salt = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut salt); + salt +} + +fn derive_key( + argon2_settings: &Argon2RawSettings, + password: &str, +) -> Result<[u8; 32], crate::CryptoError> { + use argon2::*; + + let params = Params::new( + argon2_settings.memory, + argon2_settings.iterations, + argon2_settings.parallelism, + Some(32), + )?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + + let mut hash = [0u8; 32]; + argon.hash_password_into(password.as_bytes(), &argon2_settings.salt, &mut hash)?; + Ok(hash) +} + +/// Errors that can occur when sealing or unsealing a key with the `PasswordProtectedKeyEnvelope`. +#[derive(Debug, Error)] +pub enum PasswordProtectedKeyEnvelopeError { + /// The password provided is incorrect or the envelope was tampered with + #[error("Wrong password")] + WrongPassword, + /// The envelope could not be parsed correctly, or the KDF parameters are invalid + #[error("Parsing error {0}")] + ParsingError(String), + /// The KDF failed to derive a key, possibly due to invalid parameters or memory allocation + /// issues + #[error("Kdf error")] + KdfError, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + traits::tests::{TestIds, TestSymmKey}, + KeyStore, + }; + + #[test] + fn test_make_envelope() { + let key_store = KeyStore::::default(); + let mut ctx: KeyStoreContext<'_, TestIds> = key_store.context_mut(); + let test_key = ctx.make_cose_symmetric_key(TestSymmKey::A(0)).unwrap(); + + let password = "test_password"; + + // Seal the key with a password + let envelope = PasswordProtectedKeyEnvelope::seal(test_key, password, &ctx).unwrap(); + let serialized: Vec = (&envelope).try_into().unwrap(); + + // Unseal the key from the envelope + let deserialized: PasswordProtectedKeyEnvelope = + PasswordProtectedKeyEnvelope::try_from(&serialized).unwrap(); + deserialized + .unseal(TestSymmKey::A(1), password, &mut ctx) + .unwrap(); + + // Verify that the unsealed key matches the original key + #[allow(deprecated)] + let unsealed_key = ctx + .dangerous_get_symmetric_key(TestSymmKey::A(1)) + .expect("Key should exist in the key store"); + + #[allow(deprecated)] + let key_before_sealing = ctx + .dangerous_get_symmetric_key(test_key) + .expect("Key should exist in the key store"); + + assert_eq!(unsealed_key, key_before_sealing); + } + + #[test] + fn test_make_envelope_legacy_key() { + let key_store = KeyStore::::default(); + let mut ctx: KeyStoreContext<'_, TestIds> = key_store.context_mut(); + let test_key = ctx.generate_symmetric_key(TestSymmKey::A(0)).unwrap(); + + let password = "test_password"; + + // Seal the key with a password + let envelope = PasswordProtectedKeyEnvelope::seal(test_key, password, &ctx).unwrap(); + let serialized: Vec = (&envelope).try_into().unwrap(); + + // Unseal the key from the envelope + let deserialized: PasswordProtectedKeyEnvelope = + PasswordProtectedKeyEnvelope::try_from(&serialized).unwrap(); + deserialized + .unseal(TestSymmKey::A(1), password, &mut ctx) + .unwrap(); + + // Verify that the unsealed key matches the original key + #[allow(deprecated)] + let unsealed_key = ctx + .dangerous_get_symmetric_key(TestSymmKey::A(1)) + .expect("Key should exist in the key store"); + + #[allow(deprecated)] + let key_before_sealing = ctx + .dangerous_get_symmetric_key(test_key) + .expect("Key should exist in the key store"); + + assert_eq!(unsealed_key, key_before_sealing); + } + + #[test] + fn test_reseal_envelope() { + let key = SymmetricCryptoKey::make_xchacha20_poly1305_key(); + let password = "test_password"; + let new_password = "new_test_password"; + + // Seal the key with a password + let envelope: PasswordProtectedKeyEnvelope = + PasswordProtectedKeyEnvelope::seal_ref(&key, password).expect("Sealing should work"); + // Reseal + let envelope = envelope + .reseal(password, new_password) + .expect("Resealing should work"); + let unsealed = envelope + .unseal_ref(new_password) + .expect("Unsealing should work"); + + // Verify that the unsealed key matches the original key + assert_eq!(unsealed, key); + } + + #[test] + fn test_wrong_password() { + let key_store = KeyStore::::default(); + let mut ctx: KeyStoreContext<'_, TestIds> = key_store.context_mut(); + let test_key = ctx.make_cose_symmetric_key(TestSymmKey::A(0)).unwrap(); + + let password = "test_password"; + let wrong_password = "wrong_password"; + + // Seal the key with a password + let envelope = PasswordProtectedKeyEnvelope::seal(test_key, password, &ctx).unwrap(); + + // Attempt to unseal with the wrong password + let deserialized: PasswordProtectedKeyEnvelope = + PasswordProtectedKeyEnvelope::try_from(&(&envelope).try_into().unwrap()).unwrap(); + assert!(matches!( + deserialized.unseal(TestSymmKey::A(1), wrong_password, &mut ctx), + Err(PasswordProtectedKeyEnvelopeError::WrongPassword) + )); + } +} diff --git a/crates/bitwarden-crypto/src/store/context.rs b/crates/bitwarden-crypto/src/store/context.rs index 2df08a1f8..bcb2145fc 100644 --- a/crates/bitwarden-crypto/src/store/context.rs +++ b/crates/bitwarden-crypto/src/store/context.rs @@ -308,6 +308,18 @@ impl KeyStoreContext<'_, Ids> { Ok(key_id) } + /// Generate a new random xchacha20-poly1305 symmetric key and store it in the context + #[cfg(test)] + pub(crate) fn make_cose_symmetric_key( + &mut self, + key_id: Ids::Symmetric, + ) -> Result { + let key = SymmetricCryptoKey::make_xchacha20_poly1305_key(); + #[allow(deprecated)] + self.set_symmetric_key(key_id, key)?; + Ok(key_id) + } + /// Generate a new signature key using the current default algorithm, and store it in the /// context pub fn make_signing_key(&mut self, key_id: Ids::Signing) -> Result {