diff --git a/.vscode/settings.json b/.vscode/settings.json index 49d117177..32caeaa34 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,6 +15,7 @@ "encryptable", "Hkdf", "Hmac", + "keyslot", "Maybeable", "Oaep", "Pbkdf", 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..e5243398a --- /dev/null +++ b/crates/bitwarden-crypto/examples/protect_key_with_password.rs @@ -0,0 +1,118 @@ +//! This example demonstrates how to securely protect keys with a password using the +//! [PasswordProtectedKeyEnvelope]. + +use bitwarden_crypto::{ + key_ids, + safe::{PasswordProtectedKeyEnvelope, PasswordProtectedKeyEnvelopeError}, + KeyStore, KeyStoreContext, +}; + +fn main() { + let key_store = KeyStore::::default(); + let mut ctx: KeyStoreContext<'_, ExampleIds> = key_store.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 the symmetric key protected with + // a PIN. + let vault_key = ctx + .generate_symmetric_key(ExampleSymmetricKey::VaultKey) + .expect("Generating vault key should work"); + + // Seal the key with the PIN + // The KDF settings are chosen for you, and do not need to be separately tracked or synced + // Next, store 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().expect("Saving envelope should work"), + ); + + // 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") + .expect("Loading from disk should work"), + ) + .expect("Deserializing envelope should work"); + deserialized + .unseal(ExampleSymmetricKey::VaultKey, pin, &mut ctx) + .expect("Unsealing should work"); + + // 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().expect("Saving envelope should work"), + ); + + // Alice wants to change the protected key. This requires creating a new envelope + ctx.generate_symmetric_key(ExampleSymmetricKey::VaultKey) + .expect("Generating vault key should work"); + let envelope = PasswordProtectedKeyEnvelope::seal(ExampleSymmetricKey::VaultKey, "0000", &ctx) + .expect("Sealing should work"); + disk.save( + "vault_key_envelope", + (&envelope).try_into().expect("Saving envelope should work"), + ); + + // 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..b3cfa23c5 100644 --- a/crates/bitwarden-crypto/src/cose.rs +++ b/crates/bitwarden-crypto/src/cose.rs @@ -5,9 +5,10 @@ use coset::{ iana::{self, CoapContentFormat}, - CborSerializable, ContentType, Label, + CborSerializable, ContentType, Header, Label, }; use generic_array::GenericArray; +use thiserror::Error; use typenum::U32; use crate::{ @@ -22,10 +23,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 @@ -221,6 +228,52 @@ pub trait CoseSerializable { where Self: Sized; } + +pub(crate) fn extract_integer( + header: &Header, + target_label: i64, + value_name: &str, +) -> Result { + Ok(header + .rest + .iter() + .find_map(|(label, value)| match (label, value) { + (Label::Int(label_value), ciborium::Value::Integer(int_value)) + if *label_value == target_label => + { + Some(*int_value) + } + _ => None, + }) + .ok_or(CoseExtractError::MissingValue(value_name.to_string()))? + .into()) +} + +pub(crate) fn extract_bytes( + header: &Header, + target_label: i64, + value_name: &str, +) -> Result, CoseExtractError> { + header + .rest + .iter() + .find_map(|(label, value)| match (label, value) { + (Label::Int(label_value), ciborium::Value::Bytes(byte_value)) + if *label_value == target_label => + { + Some(byte_value.clone()) + } + _ => None, + }) + .ok_or(CoseExtractError::MissingValue(value_name.to_string())) +} + +#[derive(Debug, Error)] +pub(crate) enum CoseExtractError { + #[error("Missing value {0}")] + MissingValue(String), +} + #[cfg(test)] mod test { use super::*; 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 e5e86891b..0c714fb23 100644 --- a/crates/bitwarden-crypto/src/lib.rs +++ b/crates/bitwarden-crypto/src/lib.rs @@ -36,6 +36,7 @@ pub use store::{ }; mod cose; pub use cose::CoseSerializable; +pub mod safe; mod signing; pub use signing::*; mod traits; diff --git a/crates/bitwarden-crypto/src/safe/README.md b/crates/bitwarden-crypto/src/safe/README.md new file mode 100644 index 000000000..f098c7c9d --- /dev/null +++ b/crates/bitwarden-crypto/src/safe/README.md @@ -0,0 +1,16 @@ +# Bitwarden-crypto safe module + +The safe module provides high-level cryptographic tools for building secure protocols and features. +When developing new features, use this module first before considering lower-level primitives from +other parts of `bitwarden-crypto`. + +## Password-protected key envelope + +Use the password protected key envelope to protect a symmetric key with a password. Examples +include: + +- locking a vault with a PIN/Password +- protecting exports with a password + +Internally, the module uses a KDF to protect against brute-forcing, but it does not expose this to +the consumer. The consumer only provides a password and key. diff --git a/crates/bitwarden-crypto/src/safe/mod.rs b/crates/bitwarden-crypto/src/safe/mod.rs new file mode 100644 index 000000000..04b88ca58 --- /dev/null +++ b/crates/bitwarden-crypto/src/safe/mod.rs @@ -0,0 +1,4 @@ +#![doc = include_str!("./README.md")] + +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..c25a57743 --- /dev/null +++ b/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs @@ -0,0 +1,590 @@ +//! Password protected key envelope is a cryptographic building block that allows sealing a +//! symmetric key with a low entropy secret (password, PIN, etc.). +//! +//! It is implemented by using a KDF (Argon2ID) combined with secret key encryption +//! (XChaCha20-Poly1305). The KDF prevents brute-force by requiring work to be done to derive the +//! key from the password. +//! +//! For the consumer, the output is an opaque blob that can be later unsealed with the same +//! password. The KDF parameters and salt are contained in the envelope, and don't need to be +//! provided for unsealing. +//! +//! Internally, the envelope is a CoseEncrypt object. The KDF parameters / salt are placed in the +//! single recipient's unprotected headers. The output from the KDF - "envelope key", is used to +//! wrap the symmetric key, that is sealed by the envelope. + +use std::{marker::PhantomData, num::TryFromIntError}; + +use argon2::Params; +use ciborium::{value::Integer, Value}; +use coset::{ + iana::CoapContentFormat, CborSerializable, ContentType, CoseError, Header, HeaderBuilder, +}; +use rand::RngCore; +use thiserror::Error; + +use crate::{ + cose::{ + extract_bytes, extract_integer, CoseExtractError, ALG_ARGON2ID13, ARGON2_ITERATIONS, + ARGON2_MEMORY, ARGON2_PARALLELISM, ARGON2_SALT, CONTENT_TYPE_BITWARDEN_LEGACY_KEY, + }, + xchacha20, BitwardenLegacyKeyBytes, ContentFormat, CoseKeyBytes, EncodedSymmetricKey, KeyIds, + KeyStoreContext, SymmetricCryptoKey, +}; + +/// 16 is the RECOMMENDED salt size for all applications: +/// +const ENVELOPE_ARGON2_SALT_SIZE: usize = 16; +/// 32 is chosen to match the size of an XChaCha20-Poly1305 key +const ENVELOPE_ARGON2_OUTPUT_KEY_SIZE: usize = 32; + +/// 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 envelope and do not have to +/// be provided. +/// +/// Internally, Argon2 is used as the KDF and XChaCha20-Poly1305 is used to encrypt the key. +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) + .map_err(|_| PasswordProtectedKeyEnvelopeError::KeyMissingError)?; + Self::seal_ref(key_ref, password) + } + + /// Seals a key reference with a password. This function is not public since callers are + /// expected to only work with key store references. + fn seal_ref( + key_to_seal: &SymmetricCryptoKey, + password: &str, + ) -> Result { + Self::seal_ref_with_settings( + key_to_seal, + password, + &Argon2RawSettings::default_for_platform(), + ) + } + + /// Seals a key reference with a password and custom provided settings. This function is not + /// public since callers are expected to only work with key store references, and to not + /// control the KDF difficulty where possible. + 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" mentioned in the COSE spec. 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)?; + + 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]; + + // The message is constructed by placing the KDF settings in a recipient struct's + // unprotected headers. They do not need to live in the protected header, since to + // authenticate the protected header, the settings must be correct. + 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) + .map_err(|_| PasswordProtectedKeyEnvelopeError::KeyStoreError)?; + 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.first().ok_or( + PasswordProtectedKeyEnvelopeError::ParsingError("Missing recipient".to_string()), + )?; + 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).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 nonce: [u8; crate::xchacha20::NONCE_SIZE] = self + .cose_encrypt + .unprotected + .iv + .clone() + .try_into() + .map_err(|_| { + PasswordProtectedKeyEnvelopeError::ParsingError("Invalid IV".to_string()) + })?; + + let key_bytes = self + .cose_encrypt + .decrypt(&[], |data, aad| { + xchacha20::decrypt_xchacha20_poly1305(&nonce, &envelope_key, data, aad) + }) + // If decryption fails, the envelope-key is incorrect and thus the password is incorrect + // since the KDF parameters & salt are guaranteed to be correct + .map_err(|_| PasswordProtectedKeyEnvelopeError::WrongPassword)?; + + 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(), + )); + } + }, + ) + .map_err(|_| { + PasswordProtectedKeyEnvelopeError::ParsingError("Failed to decode key".to_string()) + }) + } + + /// 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 in KiB + memory: u32, + parallelism: u32, + salt: [u8; ENVELOPE_ARGON2_SALT_SIZE], +} + +impl Argon2RawSettings { + /// Creates default Argon2 settings based on the platform. This currently is a static preset + /// based on the target os + fn default_for_platform() -> Self { + // iOS has memory limitations in the auto-fill context. So, the memory is halved + // but the iterations are doubled + if cfg!(target_os = "ios") { + // The SECOND RECOMMENDED option from: https://datatracker.ietf.org/doc/rfc9106/, with halved memory and doubled iteration count + Self { + iterations: 6, + memory: 32 * 1024, // 32 MiB + parallelism: 4, + salt: make_salt(), + } + } else { + // The SECOND RECOMMENDED option from: https://datatracker.ietf.org/doc/rfc9106/ + // The FIRST RECOMMENDED option currently still has too much memory consumption for most + // clients except desktop. + Self { + iterations: 3, + memory: 64 * 1024, // 64 MiB + parallelism: 4, + salt: make_salt(), + } + } + } +} + +impl From<&Argon2RawSettings> for Header { + fn from(settings: &Argon2RawSettings) -> Header { + let builder = HeaderBuilder::new() + .value(ARGON2_ITERATIONS, Integer::from(settings.iterations).into()) + .value(ARGON2_MEMORY, Integer::from(settings.memory).into()) + .value( + ARGON2_PARALLELISM, + Integer::from(settings.parallelism).into(), + ) + .value(ARGON2_SALT, Value::from(settings.salt.to_vec())); + + let mut header = builder.build(); + header.alg = Some(coset::Algorithm::PrivateUse(ALG_ARGON2ID13)); + header + } +} + +impl TryInto for &Argon2RawSettings { + type Error = PasswordProtectedKeyEnvelopeError; + + fn try_into(self) -> Result { + Params::new(self.memory, self.iterations, self.parallelism, Some(32)) + .map_err(|_| PasswordProtectedKeyEnvelopeError::KdfError) + } +} + +impl TryInto for &Header { + type Error = PasswordProtectedKeyEnvelopeError; + + fn try_into(self) -> Result { + Ok(Argon2RawSettings { + iterations: extract_integer(self, ARGON2_ITERATIONS, "iterations")?.try_into()?, + memory: extract_integer(self, ARGON2_MEMORY, "memory")?.try_into()?, + parallelism: extract_integer(self, ARGON2_PARALLELISM, "parallelism")?.try_into()?, + salt: extract_bytes(self, ARGON2_SALT, "salt")? + .try_into() + .map_err(|_| { + PasswordProtectedKeyEnvelopeError::ParsingError( + "Invalid Argon2 salt".to_string(), + ) + })?, + }) + } +} + +fn make_salt() -> [u8; ENVELOPE_ARGON2_SALT_SIZE] { + let mut salt = [0u8; ENVELOPE_ARGON2_SALT_SIZE]; + rand::thread_rng().fill_bytes(&mut salt); + salt +} + +fn derive_key( + argon2_settings: &Argon2RawSettings, + password: &str, +) -> Result<[u8; ENVELOPE_ARGON2_OUTPUT_KEY_SIZE], PasswordProtectedKeyEnvelopeError> { + use argon2::*; + + let mut hash = [0u8; ENVELOPE_ARGON2_OUTPUT_KEY_SIZE]; + Argon2::new( + Algorithm::Argon2id, + Version::V0x13, + argon2_settings.try_into()?, + ) + .hash_password_into(password.as_bytes(), &argon2_settings.salt, &mut hash) + .map_err(|_| PasswordProtectedKeyEnvelopeError::KdfError)?; + + 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, + /// There is no key for the provided key id in the key store + #[error("Key missing error")] + KeyMissingError, + /// The key store could not be written to, for example due to being read-only + #[error("Could not write to key store")] + KeyStoreError, +} + +impl From for PasswordProtectedKeyEnvelopeError { + fn from(err: CoseExtractError) -> Self { + let CoseExtractError::MissingValue(label) = err; + PasswordProtectedKeyEnvelopeError::ParsingError(format!("Missing value for {}", label)) + } +} + +impl From for PasswordProtectedKeyEnvelopeError { + fn from(err: TryFromIntError) -> Self { + PasswordProtectedKeyEnvelopeError::ParsingError(format!("Invalid integer: {}", err)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + traits::tests::{TestIds, TestSymmKey}, + KeyStore, + }; + + const TEST_UNSEALED_COSEKEY_ENCODED: &[u8] = &[ + 165, 1, 4, 2, 80, 63, 208, 189, 183, 204, 37, 72, 170, 179, 236, 190, 208, 22, 65, 227, + 183, 3, 58, 0, 1, 17, 111, 4, 132, 3, 4, 5, 6, 32, 88, 32, 88, 25, 68, 85, 205, 28, 133, + 28, 90, 147, 160, 145, 48, 3, 178, 184, 30, 11, 122, 132, 64, 59, 51, 233, 191, 117, 159, + 117, 23, 168, 248, 36, 1, + ]; + const TESTVECTOR_COSEKEY_ENVELOPE: &[u8] = &[ + 132, 68, 161, 3, 24, 101, 161, 5, 88, 24, 1, 31, 58, 230, 10, 92, 195, 233, 212, 7, 166, + 252, 67, 115, 221, 58, 3, 191, 218, 188, 181, 192, 28, 11, 88, 84, 141, 183, 137, 167, 166, + 161, 33, 82, 30, 255, 23, 10, 179, 149, 88, 24, 39, 60, 74, 232, 133, 44, 90, 98, 117, 31, + 41, 69, 251, 76, 250, 141, 229, 83, 191, 6, 237, 107, 127, 93, 238, 110, 49, 125, 201, 37, + 162, 120, 157, 32, 116, 195, 208, 143, 83, 254, 223, 93, 97, 158, 0, 24, 95, 197, 249, 35, + 240, 3, 20, 71, 164, 97, 180, 29, 203, 69, 31, 151, 249, 244, 197, 91, 101, 174, 129, 131, + 71, 161, 1, 58, 0, 1, 21, 87, 165, 1, 58, 0, 1, 21, 87, 58, 0, 1, 21, 89, 3, 58, 0, 1, 21, + 90, 26, 0, 1, 0, 0, 58, 0, 1, 21, 91, 4, 58, 0, 1, 21, 88, 80, 165, 253, 56, 243, 255, 54, + 246, 252, 231, 230, 33, 252, 49, 175, 1, 111, 246, + ]; + const TEST_UNSEALED_LEGACYKEY_ENCODED: &[u8] = &[ + 135, 114, 97, 155, 115, 209, 215, 224, 175, 159, 231, 208, 15, 244, 40, 171, 239, 137, 57, + 98, 207, 167, 231, 138, 145, 254, 28, 136, 236, 60, 23, 163, 4, 246, 219, 117, 104, 246, + 86, 10, 152, 52, 90, 85, 58, 6, 70, 39, 111, 128, 93, 145, 143, 180, 77, 129, 178, 242, 82, + 72, 57, 61, 192, 64, + ]; + const TESTVECTOR_LEGACYKEY_ENVELOPE: &[u8] = &[ + 132, 88, 38, 161, 3, 120, 34, 97, 112, 112, 108, 105, 99, 97, 116, 105, 111, 110, 47, 120, + 46, 98, 105, 116, 119, 97, 114, 100, 101, 110, 46, 108, 101, 103, 97, 99, 121, 45, 107, + 101, 121, 161, 5, 88, 24, 218, 72, 22, 79, 149, 30, 12, 36, 180, 212, 44, 21, 167, 208, + 214, 221, 7, 91, 178, 12, 104, 17, 45, 219, 88, 80, 114, 38, 14, 165, 85, 229, 103, 108, + 17, 175, 41, 43, 203, 175, 119, 125, 227, 127, 163, 214, 213, 138, 12, 216, 163, 204, 38, + 222, 47, 11, 44, 231, 239, 170, 63, 8, 249, 56, 102, 18, 134, 34, 232, 193, 44, 19, 228, + 17, 187, 199, 238, 187, 2, 13, 30, 112, 103, 110, 5, 31, 238, 58, 4, 24, 19, 239, 135, 57, + 206, 190, 144, 83, 128, 204, 59, 155, 21, 80, 180, 34, 129, 131, 71, 161, 1, 58, 0, 1, 21, + 87, 165, 1, 58, 0, 1, 21, 87, 58, 0, 1, 21, 89, 3, 58, 0, 1, 21, 90, 26, 0, 1, 0, 0, 58, 0, + 1, 21, 91, 4, 58, 0, 1, 21, 88, 80, 212, 91, 185, 112, 92, 177, 108, 33, 182, 202, 26, 141, + 11, 133, 95, 235, 246, + ]; + + const TESTVECTOR_PASSWORD: &str = "test_password"; + + #[test] + fn test_testvector_cosekey() { + let key_store = KeyStore::::default(); + let mut ctx: KeyStoreContext<'_, TestIds> = key_store.context_mut(); + let envelope = + PasswordProtectedKeyEnvelope::try_from(&TESTVECTOR_COSEKEY_ENVELOPE.to_vec()) + .expect("Key envelope should be valid"); + envelope + .unseal(TestSymmKey::A(0), TESTVECTOR_PASSWORD, &mut ctx) + .expect("Unsealing should succeed"); + #[allow(deprecated)] + let unsealed_key = ctx + .dangerous_get_symmetric_key(TestSymmKey::A(0)) + .expect("Key should exist in the key store"); + assert_eq!( + unsealed_key.to_encoded().to_vec(), + TEST_UNSEALED_COSEKEY_ENCODED + ); + } + + #[test] + fn test_testvector_legacykey() { + let key_store = KeyStore::::default(); + let mut ctx: KeyStoreContext<'_, TestIds> = key_store.context_mut(); + let envelope = + PasswordProtectedKeyEnvelope::try_from(&TESTVECTOR_LEGACYKEY_ENVELOPE.to_vec()) + .expect("Key envelope should be valid"); + envelope + .unseal(TestSymmKey::A(0), TESTVECTOR_PASSWORD, &mut ctx) + .expect("Unsealing should succeed"); + #[allow(deprecated)] + let unsealed_key = ctx + .dangerous_get_symmetric_key(TestSymmKey::A(0)) + .expect("Key should exist in the key store"); + assert_eq!( + unsealed_key.to_encoded().to_vec(), + TEST_UNSEALED_LEGACYKEY_ENCODED + ); + } + + #[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 312cc30ab..2e11e809a 100644 --- a/crates/bitwarden-crypto/src/store/context.rs +++ b/crates/bitwarden-crypto/src/store/context.rs @@ -309,6 +309,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) + } + /// Makes a new asymmetric encryption key using the current default algorithm, and stores it in /// the context pub fn make_asymmetric_key(&mut self, key_id: Ids::Asymmetric) -> Result {