From 90744188b9bf8986212da9ae1843e5207fac7925 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 15 Oct 2025 18:56:30 +0200 Subject: [PATCH 1/6] Add key id to passwordprotectedkeyenvelope headers --- crates/bitwarden-crypto/src/cose.rs | 1 + crates/bitwarden-crypto/src/keys/key_id.rs | 2 +- .../src/keys/symmetric_crypto_key.rs | 10 ++++ .../safe/password_protected_key_envelope.rs | 49 ++++++++++++++++++- 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/crates/bitwarden-crypto/src/cose.rs b/crates/bitwarden-crypto/src/cose.rs index 879053899..628a0303e 100644 --- a/crates/bitwarden-crypto/src/cose.rs +++ b/crates/bitwarden-crypto/src/cose.rs @@ -40,6 +40,7 @@ const CONTENT_TYPE_SPKI_PUBLIC_KEY: &str = "application/x.bitwarden.spki-public- // /// The label used for the namespace ensuring strong domain separation when using signatures. pub(crate) const SIGNING_NAMESPACE: i64 = -80000; +pub(crate) const CONTAINED_KEY_ID: i64 = -80001; /// Encrypts a plaintext message using XChaCha20Poly1305 and returns a COSE Encrypt0 message pub(crate) fn encrypt_xchacha20_poly1305( diff --git a/crates/bitwarden-crypto/src/keys/key_id.rs b/crates/bitwarden-crypto/src/keys/key_id.rs index 2184027a8..7b9856ed7 100644 --- a/crates/bitwarden-crypto/src/keys/key_id.rs +++ b/crates/bitwarden-crypto/src/keys/key_id.rs @@ -9,7 +9,7 @@ pub(crate) const KEY_ID_SIZE: usize = 16; /// A key id is a unique identifier for a single key. There is a 1:1 mapping between key ID and key /// bytes, so something like a user key rotation is replacing the key with ID A with a new key with /// ID B. -#[derive(Clone)] +#[derive(Clone, PartialEq)] pub(crate) struct KeyId(Uuid); /// Fixed length identifiers for keys. diff --git a/crates/bitwarden-crypto/src/keys/symmetric_crypto_key.rs b/crates/bitwarden-crypto/src/keys/symmetric_crypto_key.rs index 6c75c7d4b..f9b1e7064 100644 --- a/crates/bitwarden-crypto/src/keys/symmetric_crypto_key.rs +++ b/crates/bitwarden-crypto/src/keys/symmetric_crypto_key.rs @@ -230,6 +230,16 @@ impl SymmetricCryptoKey { pub fn to_base64(&self) -> B64 { B64::from(self.to_encoded().as_ref()) } + + /// Returns the key ID of the key, if it has one. Only + /// [SymmetricCryptoKey::XChaCha20Poly1305Key] has a key ID. + pub(crate) fn key_id(&self) -> Option { + match self { + Self::Aes256CbcKey(_) => None, + Self::Aes256CbcHmacKey(_) => None, + Self::XChaCha20Poly1305Key(key) => Some(KeyId::from(key.key_id)), + } + } } impl ConstantTimeEq for SymmetricCryptoKey { diff --git a/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs b/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs index e3cf46f26..64d0c2c17 100644 --- a/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs +++ b/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs @@ -28,8 +28,9 @@ use crate::{ KeyStoreContext, SymmetricCryptoKey, cose::{ ALG_ARGON2ID13, ARGON2_ITERATIONS, ARGON2_MEMORY, ARGON2_PARALLELISM, ARGON2_SALT, - CoseExtractError, extract_bytes, extract_integer, + CONTAINED_KEY_ID, CoseExtractError, extract_bytes, extract_integer, }, + keys::KeyId, xchacha20, }; @@ -120,7 +121,13 @@ impl PasswordProtectedKeyEnvelope { recipient.protected.header.alg = Some(coset::Algorithm::PrivateUse(ALG_ARGON2ID13)); recipient }) - .protected(HeaderBuilder::from(content_format).build()) + .protected({ + let mut hdr = HeaderBuilder::from(content_format); + if let Some(key_id) = key_to_seal.key_id() { + hdr = hdr.value(CONTAINED_KEY_ID, Value::from(Vec::from(&key_id))); + } + hdr.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()); @@ -227,6 +234,23 @@ impl PasswordProtectedKeyEnvelope { let unsealed = self.unseal_ref(password)?; Self::seal_ref(&unsealed, new_password) } + + /// Get the key ID of the contained key, if the key ID is stored on the envelope headers. + /// Only COSE keys have a key ID, legacy keys do not. + #[allow(dead_code)] + pub(crate) fn contained_key_id( + &self, + ) -> Result, PasswordProtectedKeyEnvelopeError> { + let key_id_bytes = extract_bytes( + &self.cose_encrypt.protected.header, + CONTAINED_KEY_ID, + "key id", + )?; + let key_id_array: [u8; 16] = key_id_bytes.as_slice().try_into().map_err(|_| { + PasswordProtectedKeyEnvelopeError::Parsing("Invalid key id".to_string()) + })?; + Ok(Some(KeyId::from(key_id_array))) + } } impl From<&PasswordProtectedKeyEnvelope> for Vec { @@ -642,4 +666,25 @@ mod tests { Err(PasswordProtectedKeyEnvelopeError::WrongPassword) )); } + + #[test] + fn test_key_id() { + 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(); + #[allow(deprecated)] + let key_id = ctx + .dangerous_get_symmetric_key(test_key) + .unwrap() + .key_id() + .unwrap() + .clone(); + + let password = "test_password"; + + // Seal the key with a password + let envelope = PasswordProtectedKeyEnvelope::seal(test_key, password, &ctx).unwrap(); + let contained_key_id = envelope.contained_key_id().unwrap(); + assert_eq!(Some(key_id), contained_key_id); + } } From c1b443584207d3d90b552d93468d5071690d5425 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Thu, 6 Nov 2025 15:43:09 +0100 Subject: [PATCH 2/6] Update values --- crates/bitwarden-crypto/src/cose.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/bitwarden-crypto/src/cose.rs b/crates/bitwarden-crypto/src/cose.rs index 4b8bcdbe3..2788e3c2b 100644 --- a/crates/bitwarden-crypto/src/cose.rs +++ b/crates/bitwarden-crypto/src/cose.rs @@ -18,17 +18,22 @@ use crate::{ xchacha20, }; +// Custom COSE algorithm values +// NOTE: Any algorithm value below -65536 is reserved for private use in the IANA allocations and can be used freely. /// XChaCha20 is used over ChaCha20 /// to be able to randomly generate nonces, and to not have to worry about key wearout. Since /// the draft was never published as an RFC, we use a private-use value for the algorithm. pub(crate) const XCHACHA20_POLY1305: i64 = -70000; -const XCHACHA20_TEXT_PAD_BLOCK_SIZE: usize = 32; - pub(crate) const ALG_ARGON2ID13: i64 = -71000; + +// Custom labels for COSE headers +// NOTE: Any label below -65536 is reserved for private use in the IANA allocations and can be used freely. 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; +/// Indicates for any object containing a key (wrapped key, password protected key envelope) which key ID that contained key has +pub(crate) const CONTAINED_KEY_ID: i64 = -71005; // 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. @@ -37,13 +42,13 @@ pub(crate) const CONTENT_TYPE_PADDED_CBOR: &str = "application/x.bitwarden.cbor- 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 -// +/// Namespaces /// The label used for the namespace ensuring strong domain separation when using signatures. pub(crate) const SIGNING_NAMESPACE: i64 = -80000; /// The label used for the namespace ensuring strong domain separation when using data envelopes. pub(crate) const DATA_ENVELOPE_NAMESPACE: i64 = -80001; -pub(crate) const CONTAINED_KEY_ID: i64 = -80002; + +const XCHACHA20_TEXT_PAD_BLOCK_SIZE: usize = 32; /// Encrypts a plaintext message using XChaCha20Poly1305 and returns a COSE Encrypt0 message pub(crate) fn encrypt_xchacha20_poly1305( From 551f4af21d345e224e292e749b03e294f4a6559e Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Thu, 6 Nov 2025 17:02:39 +0100 Subject: [PATCH 3/6] Make key id optional --- .../src/safe/password_protected_key_envelope.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs b/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs index 16be2618e..80428ce16 100644 --- a/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs +++ b/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs @@ -239,11 +239,16 @@ impl PasswordProtectedKeyEnvelope { &self.cose_encrypt.protected.header, CONTAINED_KEY_ID, "key id", - )?; - let key_id_array: [u8; 16] = key_id_bytes.as_slice().try_into().map_err(|_| { - PasswordProtectedKeyEnvelopeError::Parsing("Invalid key id".to_string()) - })?; - Ok(Some(KeyId::from(key_id_array))) + ); + + if let Some(bytes) = key_id_bytes.ok() { + let key_id_array: [u8; 16] = key_id_bytes.as_slice().try_into().map_err(|_| { + PasswordProtectedKeyEnvelopeError::Parsing("Invalid key id".to_string()) + })?; + Ok(Some(KeyId::from(key_id_array))) + } else { + Ok(None) + } } } From 58d08844d0ddeba83372189b69d15c5bbff0614d Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Mon, 10 Nov 2025 14:40:02 +0100 Subject: [PATCH 4/6] Fix build --- .../src/safe/password_protected_key_envelope.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs b/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs index 80428ce16..479c7af72 100644 --- a/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs +++ b/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs @@ -242,7 +242,7 @@ impl PasswordProtectedKeyEnvelope { ); if let Some(bytes) = key_id_bytes.ok() { - let key_id_array: [u8; 16] = key_id_bytes.as_slice().try_into().map_err(|_| { + let key_id_array: [u8; 16] = bytes.as_slice().try_into().map_err(|_| { PasswordProtectedKeyEnvelopeError::Parsing("Invalid key id".to_string()) })?; Ok(Some(KeyId::from(key_id_array))) From 8b1036a87973bb188e009747968679f7fbd0fc51 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Mon, 10 Nov 2025 14:42:31 +0100 Subject: [PATCH 5/6] Add test for no key id --- .../src/safe/password_protected_key_envelope.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs b/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs index 479c7af72..83b39585d 100644 --- a/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs +++ b/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs @@ -703,4 +703,18 @@ mod tests { let contained_key_id = envelope.contained_key_id().unwrap(); assert_eq!(Some(key_id), contained_key_id); } + + #[test] + fn test_no_key_id() { + let key_store = KeyStore::::default(); + let mut ctx: KeyStoreContext<'_, TestIds> = key_store.context_mut(); + let test_key = ctx.generate_symmetric_key(); + + let password = "test_password"; + + // Seal the key with a password + let envelope = PasswordProtectedKeyEnvelope::seal(test_key, password, &ctx).unwrap(); + let contained_key_id = envelope.contained_key_id().unwrap(); + assert_eq!(None, contained_key_id); + } } From a1a0f2710083d6fb68ed7061a35197f17eabe014 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Mon, 10 Nov 2025 15:00:04 +0100 Subject: [PATCH 6/6] Run cargo fmt --- crates/bitwarden-crypto/src/cose.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/bitwarden-crypto/src/cose.rs b/crates/bitwarden-crypto/src/cose.rs index 2788e3c2b..ac577d7b8 100644 --- a/crates/bitwarden-crypto/src/cose.rs +++ b/crates/bitwarden-crypto/src/cose.rs @@ -19,7 +19,8 @@ use crate::{ }; // Custom COSE algorithm values -// NOTE: Any algorithm value below -65536 is reserved for private use in the IANA allocations and can be used freely. +// NOTE: Any algorithm value below -65536 is reserved for private use in the IANA allocations and +// can be used freely. /// XChaCha20 is used over ChaCha20 /// to be able to randomly generate nonces, and to not have to worry about key wearout. Since /// the draft was never published as an RFC, we use a private-use value for the algorithm. @@ -27,12 +28,14 @@ pub(crate) const XCHACHA20_POLY1305: i64 = -70000; pub(crate) const ALG_ARGON2ID13: i64 = -71000; // Custom labels for COSE headers -// NOTE: Any label below -65536 is reserved for private use in the IANA allocations and can be used freely. +// NOTE: Any label below -65536 is reserved for private use in the IANA allocations and can be used +// freely. 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; -/// Indicates for any object containing a key (wrapped key, password protected key envelope) which key ID that contained key has +/// Indicates for any object containing a key (wrapped key, password protected key envelope) which +/// key ID that contained key has pub(crate) const CONTAINED_KEY_ID: i64 = -71005; // Note: These are in the "unregistered" tree: https://datatracker.ietf.org/doc/html/rfc6838#section-3.4