From 56797da710fe43d0af53fa26a7b3072bdf42b07e Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Mon, 24 Nov 2025 11:24:11 +0100 Subject: [PATCH 1/8] Tmp --- .../src/safe/identity_sealed_key_envelope.rs | 49 +++++++++++++++++++ crates/bitwarden-crypto/src/safe/mod.rs | 2 + 2 files changed, 51 insertions(+) create mode 100644 crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs diff --git a/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs b/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs new file mode 100644 index 000000000..a1bd0fab7 --- /dev/null +++ b/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs @@ -0,0 +1,49 @@ +//! Identity sealed key envelope is used to transport a key between two cryptographic identities. +//! +//! It implements signcryption of a key. The cryptographic objects strongly binds to the receiving and sending cryptographic identities. +//! The interfaces also require a cryptographic attestation, where the recipient provides a claim over the public encryption key it is +//! receiving on. + +use crate::{AsymmetricCryptoKey, SignedPublicKey, SymmetricCryptoKey, VerifyingKey, cose}; + +pub struct IdentitySealedKeyEnvelope { + cose_encrypt: coset::CoseSign, +} + +pub enum IdentitySealedKeyEnvelopeError { + VerificationFailed, +} + +impl IdentitySealedKeyEnvelope { + pub fn seal( + sender_verifying_key: VerifyingKey, + recipient_verifying_key: VerifyingKey, + recipient_public_key: SignedPublicKey, + key_to_share: &SymmetricCryptoKey, + ) -> Self { + let a = coset::CoseEncryptBuilder::new() + .add_recipient( + coset::HeaderBuilder::new().algorithm(coset::Algorithm::Assigned( + coset::AlgorithmAssigned::RsaOaep256, + )), + ) + .build(); + + todo!(); + } + + pub fn unseal( + &self, + sender_verifying_key: VerifyingKey, + recipient_verifying_key: VerifyingKey, + recipient_private_key: AsymmetricCryptoKey, + ) -> Result { + unimplemented!() + } +} + +#[cfg(test)] +mod tests { + #[test] + fn test_identity_sealed_key_envelope() {} +} diff --git a/crates/bitwarden-crypto/src/safe/mod.rs b/crates/bitwarden-crypto/src/safe/mod.rs index 91dc0f68a..5ead05c60 100644 --- a/crates/bitwarden-crypto/src/safe/mod.rs +++ b/crates/bitwarden-crypto/src/safe/mod.rs @@ -6,3 +6,5 @@ mod data_envelope; pub use data_envelope::*; mod data_envelope_namespace; pub use data_envelope_namespace::DataEnvelopeNamespace; +mod identity_sealed_key_envelope; +pub use identity_sealed_key_envelope::*; From 68680ff2c1a811169dd798809ea7654747773289 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Sun, 30 Nov 2025 15:03:17 +0100 Subject: [PATCH 2/8] Tmp --- .vscode/settings.json | 2 +- crates/bitwarden-crypto/src/rsa.rs | 2 +- .../src/safe/identity_sealed_key_envelope.rs | 220 ++++++++++++++++-- .../bitwarden-crypto/src/signing/namespace.rs | 3 + .../src/signing/signing_key.rs | 6 +- .../src/signing/verifying_key.rs | 2 +- 6 files changed, 211 insertions(+), 24 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 93f65fff1..67a0f86bf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -35,5 +35,5 @@ "zxcvbn" ], "rust-analyzer.cargo.targetDir": true, - "rust-analyzer.cargo.features": ["all"] + "rust-analyzer.cargo.features": "all" } diff --git a/crates/bitwarden-crypto/src/rsa.rs b/crates/bitwarden-crypto/src/rsa.rs index 9bad1a1c2..6756f4871 100644 --- a/crates/bitwarden-crypto/src/rsa.rs +++ b/crates/bitwarden-crypto/src/rsa.rs @@ -55,7 +55,7 @@ pub(crate) fn make_key_pair(key: &SymmetricCryptoKey) -> Result { } /// Encrypt data using RSA-OAEP-SHA1 with a 2048 bit key -pub(super) fn encrypt_rsa2048_oaep_sha1(public_key: &RsaPublicKey, data: &[u8]) -> Result> { +pub(crate) fn encrypt_rsa2048_oaep_sha1(public_key: &RsaPublicKey, data: &[u8]) -> Result> { let mut rng = rand::thread_rng(); let padding = Oaep::new::(); diff --git a/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs b/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs index a1bd0fab7..718ea075a 100644 --- a/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs +++ b/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs @@ -1,44 +1,228 @@ //! Identity sealed key envelope is used to transport a key between two cryptographic identities. //! -//! It implements signcryption of a key. The cryptographic objects strongly binds to the receiving and sending cryptographic identities. -//! The interfaces also require a cryptographic attestation, where the recipient provides a claim over the public encryption key it is -//! receiving on. +//! It implements signcryption of a key. The cryptographic objects strongly binds to the receiving +//! and sending cryptographic identities. The interfaces also require a cryptographic attestation, +//! where the recipient provides a claim over the public encryption key it is receiving on. +//! +//! The envelope is structured as a COSE Sign1 object (for sender authentication) containing +//! a COSE Encrypt object (for confidentiality). This provides: +//! - Confidentiality: Only the intended recipient can decrypt the key +//! - Authenticity: The recipient can verify the sender's identity +//! - Binding: The envelope is bound to both sender and recipient identities + +use coset::{CborSerializable, iana}; +use rsa::Oaep; -use crate::{AsymmetricCryptoKey, SignedPublicKey, SymmetricCryptoKey, VerifyingKey, cose}; +use crate::{ + AsymmetricCryptoKey, CryptoError, RawPrivateKey, RawPublicKey, SignedPublicKey, SigningKey, + SigningNamespace, SymmetricCryptoKey, VerifyingKey, +}; +/// An identity-sealed key envelope that securely transports a symmetric key between +/// two cryptographic identities. pub struct IdentitySealedKeyEnvelope { - cose_encrypt: coset::CoseSign, + /// The outer COSE Sign1 structure containing the signed COSE Encrypt + cose_sign1: coset::CoseSign1, } +/// Errors that can occur during identity sealed key envelope operations. +#[derive(Debug)] pub enum IdentitySealedKeyEnvelopeError { - VerificationFailed, + /// The signature verification failed + SignatureVerificationFailed, + /// The recipient's signed public key verification failed + RecipientPublicKeyVerificationFailed, + /// RSA encryption/decryption failed + RsaOperationFailed, + /// COSE encoding/decoding failed + CoseEncodingFailed, + /// The decrypted key is invalid + InvalidKey, + /// The namespace in the signed object does not match + InvalidNamespace, + /// Missing payload in COSE structure + MissingPayload, + /// Crypto error + CryptoError(CryptoError), +} + +impl From for IdentitySealedKeyEnvelopeError { + fn from(err: CryptoError) -> Self { + IdentitySealedKeyEnvelopeError::CryptoError(err) + } } impl IdentitySealedKeyEnvelope { + /// Seals a symmetric key for transport to a recipient. + /// + /// The process: + /// 1. Verify the recipient's public key against their verifying key + /// 2. Encrypt the key using RSA-OAEP with the recipient's public key (COSE Encrypt) + /// 3. Sign the encrypted blob with the sender's signing key (COSE Sign1) + /// + /// # Arguments + /// * `sender_signing_key` - The sender's signing key for authentication + /// * `recipient_verifying_key` - The recipient's verifying key to verify their public key + /// * `recipient_public_key` - The recipient's signed public encryption key + /// * `key_to_share` - The symmetric key to securely share pub fn seal( - sender_verifying_key: VerifyingKey, - recipient_verifying_key: VerifyingKey, + sender_signing_key: &SigningKey, + recipient_verifying_key: &VerifyingKey, recipient_public_key: SignedPublicKey, key_to_share: &SymmetricCryptoKey, - ) -> Self { - let a = coset::CoseEncryptBuilder::new() + ) -> Result { + let recipient_public_key = recipient_public_key + .verify_and_unwrap(recipient_verifying_key) + .map_err(|_| IdentitySealedKeyEnvelopeError::RecipientPublicKeyVerificationFailed)?; + + // Get the key bytes to encrypt + let key_bytes = key_to_share.to_encoded(); + + // Encrypt with RSA-OAEP-SHA1 + let encrypted_key = match recipient_public_key.inner() { + RawPublicKey::RsaOaepSha1(rsa_public_key) => { + crate::rsa::encrypt_rsa2048_oaep_sha1(rsa_public_key, key_bytes.as_ref()) + .map_err(|_| IdentitySealedKeyEnvelopeError::RsaOperationFailed)? + } + }; + + // Build COSE Encrypt structure with the encrypted key as ciphertext + // The recipient info contains the algorithm used + let cose_encrypt = coset::CoseEncryptBuilder::new() + .protected( + coset::HeaderBuilder::new() + .algorithm(iana::Algorithm::Direct) + .build(), + ) .add_recipient( - coset::HeaderBuilder::new().algorithm(coset::Algorithm::Assigned( - coset::AlgorithmAssigned::RsaOaep256, - )), + coset::CoseRecipientBuilder::new() + .protected( + coset::HeaderBuilder::new() + .algorithm(iana::Algorithm::RSA_OAEP) + .build(), + ) + .ciphertext(encrypted_key) + .build(), + ) + .build(); + + // Serialize the COSE Encrypt to bytes + let cose_encrypt_bytes = cose_encrypt + .to_vec() + .map_err(|_| IdentitySealedKeyEnvelopeError::CoseEncodingFailed)?; + + // Sign the COSE Encrypt bytes with the sender's signing key + let cose_sign1 = coset::CoseSign1Builder::new() + .protected( + coset::HeaderBuilder::new() + .algorithm(sender_signing_key.cose_algorithm()) + .key_id(Vec::from(&sender_signing_key.id)) + .content_format(iana::CoapContentFormat::CoseEncrypt) + .value( + crate::cose::SIGNING_NAMESPACE, + ciborium::Value::Integer( + SigningNamespace::IdentitySealedKeyEnvelope.as_i64().into(), + ), + ) + .build(), ) + .payload(cose_encrypt_bytes) + .create_signature(&[], |data| sender_signing_key.sign_raw(data)) .build(); - todo!(); + Ok(Self { cose_sign1 }) } + /// Unseals a key envelope and returns the shared symmetric key. + /// + /// The process: + /// 1. Verify the signature using the sender's verifying key + /// 2. Parse the COSE Encrypt from the signed payload + /// 3. Decrypt the key using the recipient's private key + /// + /// # Arguments + /// * `sender_verifying_key` - The sender's verifying key to verify the signature + /// * `recipient_private_key` - The recipient's private key for decryption pub fn unseal( &self, - sender_verifying_key: VerifyingKey, - recipient_verifying_key: VerifyingKey, - recipient_private_key: AsymmetricCryptoKey, + sender_verifying_key: &VerifyingKey, + recipient_private_key: &AsymmetricCryptoKey, ) -> Result { - unimplemented!() + // Verify the signature + self.cose_sign1 + .verify_signature(&[], |sig, data| sender_verifying_key.verify_raw(sig, data)) + .map_err(|_| IdentitySealedKeyEnvelopeError::SignatureVerificationFailed)?; + + // Verify the namespace + let namespace = self + .cose_sign1 + .protected + .header + .rest + .iter() + .find_map(|(key, value)| { + if let coset::Label::Int(key) = key { + if *key == crate::cose::SIGNING_NAMESPACE { + return value.as_integer(); + } + } + None + }) + .ok_or(IdentitySealedKeyEnvelopeError::InvalidNamespace)?; + + let expected_namespace = SigningNamespace::IdentitySealedKeyEnvelope.as_i64(); + if i128::from(namespace) != expected_namespace as i128 { + return Err(IdentitySealedKeyEnvelopeError::InvalidNamespace); + } + + // Extract the payload (COSE Encrypt bytes) + let cose_encrypt_bytes = self + .cose_sign1 + .payload + .as_ref() + .ok_or(IdentitySealedKeyEnvelopeError::MissingPayload)?; + + // Parse the COSE Encrypt + let cose_encrypt = coset::CoseEncrypt::from_slice(cose_encrypt_bytes) + .map_err(|_| IdentitySealedKeyEnvelopeError::CoseEncodingFailed)?; + + // Get the encrypted key from the first recipient + let recipient = cose_encrypt + .recipients + .first() + .ok_or(IdentitySealedKeyEnvelopeError::CoseEncodingFailed)?; + + let encrypted_key = recipient + .ciphertext + .as_ref() + .ok_or(IdentitySealedKeyEnvelopeError::MissingPayload)?; + + // Decrypt with RSA-OAEP-SHA1 + let decrypted_key_bytes = match recipient_private_key.inner() { + RawPrivateKey::RsaOaepSha1(rsa_private_key) => rsa_private_key + .decrypt(Oaep::new::(), encrypted_key) + .map_err(|_| IdentitySealedKeyEnvelopeError::RsaOperationFailed)?, + }; + + // Parse the decrypted bytes back into a SymmetricCryptoKey + SymmetricCryptoKey::try_from( + &crate::BitwardenLegacyKeyBytes::from(decrypted_key_bytes), + ) + .map_err(|_| IdentitySealedKeyEnvelopeError::InvalidKey) + } + + /// Serializes the envelope to CBOR bytes. + pub fn to_bytes(&self) -> Result, IdentitySealedKeyEnvelopeError> { + self.cose_sign1 + .to_vec() + .map_err(|_| IdentitySealedKeyEnvelopeError::CoseEncodingFailed) + } + + /// Deserializes an envelope from CBOR bytes. + pub fn from_bytes(bytes: &[u8]) -> Result { + let cose_sign1 = coset::CoseSign1::from_slice(bytes) + .map_err(|_| IdentitySealedKeyEnvelopeError::CoseEncodingFailed)?; + Ok(Self { cose_sign1 }) } } diff --git a/crates/bitwarden-crypto/src/signing/namespace.rs b/crates/bitwarden-crypto/src/signing/namespace.rs index f2201a0ae..63489e429 100644 --- a/crates/bitwarden-crypto/src/signing/namespace.rs +++ b/crates/bitwarden-crypto/src/signing/namespace.rs @@ -14,6 +14,8 @@ pub enum SigningNamespace { SignedPublicKey = 1, /// The namespace for SignedSecurityState SecurityState = 2, + /// The namespace for identity-sealed key envelopes used in secure key transport + IdentitySealedKeyEnvelope = 3, /// This namespace is only used in tests #[cfg(test)] ExampleNamespace = -1, @@ -36,6 +38,7 @@ impl TryFrom for SigningNamespace { match value { 1 => Ok(SigningNamespace::SignedPublicKey), 2 => Ok(SigningNamespace::SecurityState), + 3 => Ok(SigningNamespace::IdentitySealedKeyEnvelope), #[cfg(test)] -1 => Ok(SigningNamespace::ExampleNamespace), #[cfg(test)] diff --git a/crates/bitwarden-crypto/src/signing/signing_key.rs b/crates/bitwarden-crypto/src/signing/signing_key.rs index 5c3a273ce..0d0721d2d 100644 --- a/crates/bitwarden-crypto/src/signing/signing_key.rs +++ b/crates/bitwarden-crypto/src/signing/signing_key.rs @@ -30,7 +30,7 @@ enum RawSigningKey { /// derived from it. #[derive(Clone)] pub struct SigningKey { - pub(super) id: KeyId, + pub(crate) id: KeyId, inner: RawSigningKey, } @@ -57,7 +57,7 @@ impl SigningKey { } } - pub(super) fn cose_algorithm(&self) -> Algorithm { + pub(crate) fn cose_algorithm(&self) -> Algorithm { match &self.inner { RawSigningKey::Ed25519(_) => Algorithm::EdDSA, } @@ -77,7 +77,7 @@ impl SigningKey { /// Signs the given byte array with the signing key. /// This should not be used directly other than for generating namespace separated signatures or /// signed objects. - pub(super) fn sign_raw(&self, data: &[u8]) -> Vec { + pub(crate) fn sign_raw(&self, data: &[u8]) -> Vec { match &self.inner { RawSigningKey::Ed25519(key) => key.sign(data).to_bytes().to_vec(), } diff --git a/crates/bitwarden-crypto/src/signing/verifying_key.rs b/crates/bitwarden-crypto/src/signing/verifying_key.rs index 8f5736a13..a650125f3 100644 --- a/crates/bitwarden-crypto/src/signing/verifying_key.rs +++ b/crates/bitwarden-crypto/src/signing/verifying_key.rs @@ -43,7 +43,7 @@ impl VerifyingKey { /// Verifies the signature of the given data, for the given namespace. /// This should never be used directly, but only through the `verify` method, to enforce /// strong domain separation of the signatures. - pub(super) fn verify_raw(&self, signature: &[u8], data: &[u8]) -> Result<(), CryptoError> { + pub(crate) fn verify_raw(&self, signature: &[u8], data: &[u8]) -> Result<(), CryptoError> { match &self.inner { RawVerifyingKey::Ed25519(key) => { let sig = ed25519_dalek::Signature::from_bytes( From 5fe59ffb9119e486da4d3367e5079bcf2a6b3c8f Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Sun, 30 Nov 2025 18:59:05 +0100 Subject: [PATCH 3/8] Tmp --- Cargo.lock | 20 ++ crates/bitwarden-crypto/Cargo.toml | 1 + crates/bitwarden-crypto/src/cose.rs | 2 + .../src/keys/asymmetric_crypto_key.rs | 34 ++- .../src/safe/identity_sealed_key_envelope.rs | 194 +++++------------- .../src/signing/signing_key.rs | 2 +- .../src/signing/verifying_key.rs | 22 +- .../src/traits/key_fingerprint.rs | 21 ++ crates/bitwarden-crypto/src/traits/mod.rs | 3 + crates/bitwarden-crypto/src/xchacha20.rs | 6 + 10 files changed, 157 insertions(+), 148 deletions(-) create mode 100644 crates/bitwarden-crypto/src/traits/key_fingerprint.rs diff --git a/Cargo.lock b/Cargo.lock index 29fc0ae90..b3369a313 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -616,6 +616,7 @@ dependencies = [ "serde_repr", "sha1", "sha2", + "sha3", "subtle", "thiserror 2.0.12", "tsify", @@ -3032,6 +3033,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -4658,6 +4668,16 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest 0.10.7", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/crates/bitwarden-crypto/Cargo.toml b/crates/bitwarden-crypto/Cargo.toml index 2ed23211d..b5dcd2116 100644 --- a/crates/bitwarden-crypto/Cargo.toml +++ b/crates/bitwarden-crypto/Cargo.toml @@ -55,6 +55,7 @@ serde_bytes = { workspace = true } serde_repr.workspace = true sha1 = ">=0.10.5, <0.11" sha2 = ">=0.10.6, <0.11" +sha3 = "0.10.8" subtle = { workspace = true } thiserror = { workspace = true } tsify = { workspace = true, optional = true } diff --git a/crates/bitwarden-crypto/src/cose.rs b/crates/bitwarden-crypto/src/cose.rs index 957c9282c..106c10ef6 100644 --- a/crates/bitwarden-crypto/src/cose.rs +++ b/crates/bitwarden-crypto/src/cose.rs @@ -29,6 +29,8 @@ 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; +pub(crate) const IDENTITY_SEALED_ENVELOPE_RECIPIENT_FINGERPRINT: i64 = -71005; +pub(crate) const IDENTITY_SEALED_ENVELOPE_SENDER_FINGERPRINT: i64 = -71006; // 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. diff --git a/crates/bitwarden-crypto/src/keys/asymmetric_crypto_key.rs b/crates/bitwarden-crypto/src/keys/asymmetric_crypto_key.rs index 75b1907c2..5039c0a82 100644 --- a/crates/bitwarden-crypto/src/keys/asymmetric_crypto_key.rs +++ b/crates/bitwarden-crypto/src/keys/asymmetric_crypto_key.rs @@ -1,12 +1,13 @@ use std::pin::Pin; -use rsa::{RsaPrivateKey, RsaPublicKey, pkcs8::DecodePublicKey}; +use rsa::{RsaPrivateKey, RsaPublicKey, pkcs8::DecodePublicKey, traits::PublicKeyParts}; use serde_repr::{Deserialize_repr, Serialize_repr}; +use sha2::Digest as _; use super::key_encryptable::CryptoKey; use crate::{ Pkcs8PrivateKeyBytes, SpkiPublicKeyBytes, - error::{CryptoError, Result}, + error::{CryptoError, Result}, traits::DeriveFingerprint, }; /// Algorithm / public key encryption scheme used for encryption/decryption. @@ -58,6 +59,35 @@ impl AsymmetricPublicCryptoKey { } } +impl DeriveFingerprint for AsymmetricPublicCryptoKey { + fn fingerprint(&self) -> crate::traits::KeyFingerprint { + match &self.inner { + RawPublicKey::RsaOaepSha1(key) => { + // An RSA key has two components - the exponent e and the modulus n. To create a canonical + // representation, we serialize both components in big-endian byte order and concatenate. However, to prevent collisions, + // we prefix each of these with the length of the component as a 2-byte big-endian integer. + let e = key.e().to_bytes_be(); + let e_len = e.len() as u16; + let n = key.n().to_bytes_be(); + let n_len = n.len() as u16; + let mut canonical_representation = Vec::with_capacity(4 + e.len() + n.len()); + canonical_representation.extend_from_slice(&e_len.to_be_bytes()); + canonical_representation.extend_from_slice(&e); + canonical_representation.extend_from_slice(&n_len.to_be_bytes()); + canonical_representation.extend_from_slice(&n); + + // Now hash the canonical representation with SHA-256 to get the fingerprint + let digest = sha2::Sha256::digest(&canonical_representation); + let arr: [u8; 32] = digest + .as_slice() + .try_into() + .expect("SHA-256 digest should be 32 bytes"); + crate::traits::KeyFingerprint(arr) + } + } + } +} + #[derive(Clone)] pub(crate) enum RawPrivateKey { // RsaPrivateKey is not a Copy type so this isn't completely necessary, but diff --git a/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs b/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs index 718ea075a..91cc245f8 100644 --- a/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs +++ b/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs @@ -9,17 +9,19 @@ //! - Confidentiality: Only the intended recipient can decrypt the key //! - Authenticity: The recipient can verify the sender's identity //! - Binding: The envelope is bound to both sender and recipient identities +//! +//! In detail, the cose encrypt object contains the encrypted symmetric key as ciphertext. Currently this is encrypted with xchacha20-poly1305. The cek for the message is shared +//! to a single recipient. The currently only sharing algorithm implemented is RSA-OAEP with SHA-1, as this is currently the only public-key encryption key type supported. use coset::{CborSerializable, iana}; use rsa::Oaep; use crate::{ - AsymmetricCryptoKey, CryptoError, RawPrivateKey, RawPublicKey, SignedPublicKey, SigningKey, - SigningNamespace, SymmetricCryptoKey, VerifyingKey, + AsymmetricCryptoKey, AsymmetricPublicCryptoKey, ContentFormat, CryptoError, RawPrivateKey, RawPublicKey, SignedPublicKey, SigningKey, SigningNamespace, SymmetricCryptoKey, VerifyingKey, cose::{IDENTITY_SEALED_ENVELOPE_RECIPIENT_FINGERPRINT, IDENTITY_SEALED_ENVELOPE_SENDER_FINGERPRINT, XCHACHA20_POLY1305}, traits::DeriveFingerprint, xchacha20 }; /// An identity-sealed key envelope that securely transports a symmetric key between -/// two cryptographic identities. +/// two cryptographic identities. This provides sender authentication and recipient confidentiality. pub struct IdentitySealedKeyEnvelope { /// The outer COSE Sign1 structure containing the signed COSE Encrypt cose_sign1: coset::CoseSign1, @@ -53,181 +55,85 @@ impl From for IdentitySealedKeyEnvelopeError { } impl IdentitySealedKeyEnvelope { - /// Seals a symmetric key for transport to a recipient. - /// - /// The process: - /// 1. Verify the recipient's public key against their verifying key - /// 2. Encrypt the key using RSA-OAEP with the recipient's public key (COSE Encrypt) - /// 3. Sign the encrypted blob with the sender's signing key (COSE Sign1) - /// - /// # Arguments - /// * `sender_signing_key` - The sender's signing key for authentication - /// * `recipient_verifying_key` - The recipient's verifying key to verify their public key - /// * `recipient_public_key` - The recipient's signed public encryption key - /// * `key_to_share` - The symmetric key to securely share + /// Seals a symmetric key to be shared with a recipient. This requires the senders identity signature key pair, and the recipients identity verifying key, and a corresponding signed public key for encryption. pub fn seal( sender_signing_key: &SigningKey, recipient_verifying_key: &VerifyingKey, recipient_public_key: SignedPublicKey, key_to_share: &SymmetricCryptoKey, ) -> Result { + let (payload, content_type) = match key_to_share.to_encoded_raw() { + crate::EncodedSymmetricKey::BitwardenLegacyKey(bytes) => (bytes.to_vec(), ContentFormat::BitwardenLegacyKey), + crate::EncodedSymmetricKey::CoseKey(bytes) => (bytes.to_vec(), ContentFormat::CoseKey), + }; let recipient_public_key = recipient_public_key .verify_and_unwrap(recipient_verifying_key) .map_err(|_| IdentitySealedKeyEnvelopeError::RecipientPublicKeyVerificationFailed)?; - // Get the key bytes to encrypt - let key_bytes = key_to_share.to_encoded(); + let recipient_verifying_key_fingerprint = recipient_verifying_key.fingerprint(); + let sender_verifying_key_fingerprint = sender_signing_key.to_verifying_key().fingerprint(); + + // Generate CEK and encrypt it for the recipient + let (cek, cek_alg) = (xchacha20::make_xchacha20_poly1305_key(), Some(coset::Algorithm::PrivateUse(XCHACHA20_POLY1305))); - // Encrypt with RSA-OAEP-SHA1 - let encrypted_key = match recipient_public_key.inner() { + // Encrypt the CEK for the recipient using their public key + let (recipient_cek_ct, recipient_alg) = match recipient_public_key.inner() { RawPublicKey::RsaOaepSha1(rsa_public_key) => { - crate::rsa::encrypt_rsa2048_oaep_sha1(rsa_public_key, key_bytes.as_ref()) - .map_err(|_| IdentitySealedKeyEnvelopeError::RsaOperationFailed)? + (crate::rsa::encrypt_rsa2048_oaep_sha1(rsa_public_key, &cek) + .map_err(|_| IdentitySealedKeyEnvelopeError::RsaOperationFailed)?, Some(coset::Algorithm::Assigned(iana::Algorithm::RSAES_OAEP_RFC_8017_default))) } }; // Build COSE Encrypt structure with the encrypted key as ciphertext // The recipient info contains the algorithm used + let mut nonce = Vec::new(); let cose_encrypt = coset::CoseEncryptBuilder::new() .protected( - coset::HeaderBuilder::new() - .algorithm(iana::Algorithm::Direct) - .build(), + { + let mut hdr = coset::HeaderBuilder::new() + .value(IDENTITY_SEALED_ENVELOPE_RECIPIENT_FINGERPRINT, ciborium::Value::Bytes(recipient_verifying_key_fingerprint.0.to_vec())) + .value(IDENTITY_SEALED_ENVELOPE_SENDER_FINGERPRINT, ciborium::Value::Bytes(sender_verifying_key_fingerprint.0.to_vec())) + .build(); + hdr.alg = cek_alg.clone(); + hdr + } ) .add_recipient( coset::CoseRecipientBuilder::new() .protected( - coset::HeaderBuilder::new() - .algorithm(iana::Algorithm::RSA_OAEP) - .build(), - ) - .ciphertext(encrypted_key) - .build(), - ) - .build(); - - // Serialize the COSE Encrypt to bytes - let cose_encrypt_bytes = cose_encrypt - .to_vec() - .map_err(|_| IdentitySealedKeyEnvelopeError::CoseEncodingFailed)?; - - // Sign the COSE Encrypt bytes with the sender's signing key - let cose_sign1 = coset::CoseSign1Builder::new() - .protected( - coset::HeaderBuilder::new() - .algorithm(sender_signing_key.cose_algorithm()) - .key_id(Vec::from(&sender_signing_key.id)) - .content_format(iana::CoapContentFormat::CoseEncrypt) - .value( - crate::cose::SIGNING_NAMESPACE, - ciborium::Value::Integer( - SigningNamespace::IdentitySealedKeyEnvelope.as_i64().into(), - ), + { + let mut hdr = coset::HeaderBuilder::new() + .build(); + hdr.alg = recipient_alg; + hdr + } ) + .ciphertext(recipient_cek_ct) .build(), ) - .payload(cose_encrypt_bytes) - .create_signature(&[], |data| sender_signing_key.sign_raw(data)) + .try_create_ciphertext(&payload, &[], |data, aad| { + match cek_alg { + Some(coset::Algorithm::PrivateUse(XCHACHA20_POLY1305)) => { + let ciphertext = + crate::xchacha20::encrypt_xchacha20_poly1305(&cek, data, aad); + nonce = ciphertext.nonce().to_vec(); + Ok(ciphertext.encrypted_bytes().to_vec()) + }, + _ => return Err(IdentitySealedKeyEnvelopeError::InvalidKey), + } + })? + .unprotected(coset::HeaderBuilder::new().iv(nonce).build()) .build(); - Ok(Self { cose_sign1 }) + // Sign the COSE Encrypt structure + } - /// Unseals a key envelope and returns the shared symmetric key. - /// - /// The process: - /// 1. Verify the signature using the sender's verifying key - /// 2. Parse the COSE Encrypt from the signed payload - /// 3. Decrypt the key using the recipient's private key - /// - /// # Arguments - /// * `sender_verifying_key` - The sender's verifying key to verify the signature - /// * `recipient_private_key` - The recipient's private key for decryption pub fn unseal( &self, sender_verifying_key: &VerifyingKey, recipient_private_key: &AsymmetricCryptoKey, ) -> Result { - // Verify the signature - self.cose_sign1 - .verify_signature(&[], |sig, data| sender_verifying_key.verify_raw(sig, data)) - .map_err(|_| IdentitySealedKeyEnvelopeError::SignatureVerificationFailed)?; - - // Verify the namespace - let namespace = self - .cose_sign1 - .protected - .header - .rest - .iter() - .find_map(|(key, value)| { - if let coset::Label::Int(key) = key { - if *key == crate::cose::SIGNING_NAMESPACE { - return value.as_integer(); - } - } - None - }) - .ok_or(IdentitySealedKeyEnvelopeError::InvalidNamespace)?; - - let expected_namespace = SigningNamespace::IdentitySealedKeyEnvelope.as_i64(); - if i128::from(namespace) != expected_namespace as i128 { - return Err(IdentitySealedKeyEnvelopeError::InvalidNamespace); - } - - // Extract the payload (COSE Encrypt bytes) - let cose_encrypt_bytes = self - .cose_sign1 - .payload - .as_ref() - .ok_or(IdentitySealedKeyEnvelopeError::MissingPayload)?; - - // Parse the COSE Encrypt - let cose_encrypt = coset::CoseEncrypt::from_slice(cose_encrypt_bytes) - .map_err(|_| IdentitySealedKeyEnvelopeError::CoseEncodingFailed)?; - - // Get the encrypted key from the first recipient - let recipient = cose_encrypt - .recipients - .first() - .ok_or(IdentitySealedKeyEnvelopeError::CoseEncodingFailed)?; - - let encrypted_key = recipient - .ciphertext - .as_ref() - .ok_or(IdentitySealedKeyEnvelopeError::MissingPayload)?; - - // Decrypt with RSA-OAEP-SHA1 - let decrypted_key_bytes = match recipient_private_key.inner() { - RawPrivateKey::RsaOaepSha1(rsa_private_key) => rsa_private_key - .decrypt(Oaep::new::(), encrypted_key) - .map_err(|_| IdentitySealedKeyEnvelopeError::RsaOperationFailed)?, - }; - - // Parse the decrypted bytes back into a SymmetricCryptoKey - SymmetricCryptoKey::try_from( - &crate::BitwardenLegacyKeyBytes::from(decrypted_key_bytes), - ) - .map_err(|_| IdentitySealedKeyEnvelopeError::InvalidKey) - } - - /// Serializes the envelope to CBOR bytes. - pub fn to_bytes(&self) -> Result, IdentitySealedKeyEnvelopeError> { - self.cose_sign1 - .to_vec() - .map_err(|_| IdentitySealedKeyEnvelopeError::CoseEncodingFailed) + todo!() } - - /// Deserializes an envelope from CBOR bytes. - pub fn from_bytes(bytes: &[u8]) -> Result { - let cose_sign1 = coset::CoseSign1::from_slice(bytes) - .map_err(|_| IdentitySealedKeyEnvelopeError::CoseEncodingFailed)?; - Ok(Self { cose_sign1 }) - } -} - -#[cfg(test)] -mod tests { - #[test] - fn test_identity_sealed_key_envelope() {} } diff --git a/crates/bitwarden-crypto/src/signing/signing_key.rs b/crates/bitwarden-crypto/src/signing/signing_key.rs index 0d0721d2d..b0da158b6 100644 --- a/crates/bitwarden-crypto/src/signing/signing_key.rs +++ b/crates/bitwarden-crypto/src/signing/signing_key.rs @@ -16,7 +16,7 @@ use crate::{ content_format::CoseKeyContentFormat, cose::CoseSerializable, error::{EncodingError, Result}, - keys::KeyId, + keys::KeyId, traits::KeyFingerprint, }; /// A `SigningKey` without the key id. This enum contains a variant for each supported signature diff --git a/crates/bitwarden-crypto/src/signing/verifying_key.rs b/crates/bitwarden-crypto/src/signing/verifying_key.rs index a650125f3..1a0e24f26 100644 --- a/crates/bitwarden-crypto/src/signing/verifying_key.rs +++ b/crates/bitwarden-crypto/src/signing/verifying_key.rs @@ -8,6 +8,7 @@ use coset::{ CborSerializable, RegisteredLabel, RegisteredLabelWithPrivate, iana::{Algorithm, EllipticCurve, EnumI64, KeyOperation, KeyType, OkpKeyParameter}, }; +use sha2::Digest; use super::{SignatureAlgorithm, ed25519_verifying_key, key_id}; use crate::{ @@ -15,7 +16,7 @@ use crate::{ content_format::CoseKeyContentFormat, cose::CoseSerializable, error::{EncodingError, SignatureError}, - keys::KeyId, + keys::KeyId, traits::{DeriveFingerprint, KeyFingerprint}, }; /// A `VerifyingKey` without the key id. This enum contains a variant for each supported signature @@ -58,6 +59,25 @@ impl VerifyingKey { } } +impl DeriveFingerprint for VerifyingKey { + fn fingerprint(&self) -> KeyFingerprint { + match &self.inner { + RawVerifyingKey::Ed25519(key) => { + // Ed25519 public keys are directly and trivially a canonical and non-colliding representation of the key pair. + // While Ed25519 keys are already 256 bits, they are not pseudo-randomly distributed and do not + // satisfy the properties of a fingerprint directly. Therefore, they are hashed using SHA-256 + // to get a pseudo-random distribution. + let digest = sha2::Sha256::digest(&key.to_bytes()); + let arr: [u8; 32] = digest + .as_slice() + .try_into() + .expect("SHA-256 digest should be 32 bytes"); + KeyFingerprint(arr) + } + } + } +} + impl CoseSerializable for VerifyingKey { fn to_cose(&self) -> CoseKeyBytes { match &self.inner { diff --git a/crates/bitwarden-crypto/src/traits/key_fingerprint.rs b/crates/bitwarden-crypto/src/traits/key_fingerprint.rs new file mode 100644 index 000000000..c076a8fd6 --- /dev/null +++ b/crates/bitwarden-crypto/src/traits/key_fingerprint.rs @@ -0,0 +1,21 @@ +/// Fingerprints are 256-bit. Anything human readable can be derived from that. This is enough entropy for +/// all uses cases. +const FINGERPRINT_LENGTH: usize = 32; + +/// A key fingerprint is a short, unique identifier for a cryptographic key. It is typically derived +/// from the key material using a cryptographic hash function. It also has a pseudo-random distribution and +/// MUST be derived using a cryptographic hash function / there MUST NOT be direct control over the output. +pub struct KeyFingerprint(pub(crate) [u8; FINGERPRINT_LENGTH]); + +/// A trait for deriving a key fingerprint from a cryptographic key. To implement, this MUST take a canonical representation +/// of a public key of a signing, or public-key-encryption key pair, and derive the fingerprint material from that. +/// +/// This canonical representation MUST be stable, and MUST not collide with other representations. For key pairs that have multiple +/// components, such as RSA, a valid implementation MUST explain why the chosen representation is canonical and non-colliding. +/// +/// It is recommended to use a reasonable cryptographic hashing function, such as SHA-256 to derive the 256-Bit fingerprint from the canonical representation that can have arbitrary length. +/// Once implemented, for a key algorithm type the fingerprint MUST not change, because other cryptographic objects will rely on it, and plugging different fingerprint algorithms for a given public-key +/// algorithm is not supported. A new public key algorithm may choose a new implementation, with different canonical representation and/or hash function. +pub trait DeriveFingerprint { + fn fingerprint(&self) -> KeyFingerprint; +} \ No newline at end of file diff --git a/crates/bitwarden-crypto/src/traits/mod.rs b/crates/bitwarden-crypto/src/traits/mod.rs index 54946075d..b351c60c0 100644 --- a/crates/bitwarden-crypto/src/traits/mod.rs +++ b/crates/bitwarden-crypto/src/traits/mod.rs @@ -7,6 +7,9 @@ pub use decryptable::Decryptable; pub(crate) mod key_id; pub use key_id::{KeyId, KeyIds, LocalId}; +pub(crate) mod key_fingerprint; +pub use key_fingerprint::{DeriveFingerprint, KeyFingerprint}; + /// Types implementing [IdentifyKey] are capable of knowing which cryptographic key is /// needed to encrypt/decrypt them. pub trait IdentifyKey { diff --git a/crates/bitwarden-crypto/src/xchacha20.rs b/crates/bitwarden-crypto/src/xchacha20.rs index e3d2dbbff..e82ff4eb9 100644 --- a/crates/bitwarden-crypto/src/xchacha20.rs +++ b/crates/bitwarden-crypto/src/xchacha20.rs @@ -24,6 +24,12 @@ use crate::CryptoError; pub(crate) const NONCE_SIZE: usize = ::NonceSize::USIZE; pub(crate) const KEY_SIZE: usize = 32; +pub(crate) fn make_xchacha20_poly1305_key() -> [u8; KEY_SIZE] { + let mut key = [0u8; KEY_SIZE]; + rand::thread_rng().fill_bytes(&mut key); + key +} + pub(crate) struct XChaCha20Poly1305Ciphertext { nonce: GenericArray::NonceSize>, encrypted_bytes: Vec, From 7758d13ba52ac04ec0ac8ee30a1565a7a2ebe1ed Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Sun, 30 Nov 2025 19:57:17 +0100 Subject: [PATCH 4/8] Tmp --- crates/bitwarden-crypto/src/cose.rs | 2 +- .../src/safe/identity_sealed_key_envelope.rs | 304 +++++++++++++++++- crates/bitwarden-crypto/src/signing/cose.rs | 2 +- crates/bitwarden-crypto/src/signing/mod.rs | 1 + 4 files changed, 296 insertions(+), 13 deletions(-) diff --git a/crates/bitwarden-crypto/src/cose.rs b/crates/bitwarden-crypto/src/cose.rs index 106c10ef6..92f9b496e 100644 --- a/crates/bitwarden-crypto/src/cose.rs +++ b/crates/bitwarden-crypto/src/cose.rs @@ -36,7 +36,7 @@ pub(crate) const IDENTITY_SEALED_ENVELOPE_SENDER_FINGERPRINT: i64 = -71006; // 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"; pub(crate) const CONTENT_TYPE_PADDED_CBOR: &str = "application/x.bitwarden.cbor-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/safe/identity_sealed_key_envelope.rs b/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs index 91cc245f8..f9f21d662 100644 --- a/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs +++ b/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs @@ -13,11 +13,11 @@ //! In detail, the cose encrypt object contains the encrypted symmetric key as ciphertext. Currently this is encrypted with xchacha20-poly1305. The cek for the message is shared //! to a single recipient. The currently only sharing algorithm implemented is RSA-OAEP with SHA-1, as this is currently the only public-key encryption key type supported. -use coset::{CborSerializable, iana}; +use coset::{CborSerializable, iana::{self, CoapContentFormat}}; use rsa::Oaep; use crate::{ - AsymmetricCryptoKey, AsymmetricPublicCryptoKey, ContentFormat, CryptoError, RawPrivateKey, RawPublicKey, SignedPublicKey, SigningKey, SigningNamespace, SymmetricCryptoKey, VerifyingKey, cose::{IDENTITY_SEALED_ENVELOPE_RECIPIENT_FINGERPRINT, IDENTITY_SEALED_ENVELOPE_SENDER_FINGERPRINT, XCHACHA20_POLY1305}, traits::DeriveFingerprint, xchacha20 + AsymmetricCryptoKey, BitwardenLegacyKeyBytes, ContentFormat, CoseKeyBytes, CryptoError, EncodedSymmetricKey, RawPrivateKey, RawPublicKey, SignedPublicKey, SigningKey, SigningNamespace, SymmetricCryptoKey, VerifyingKey, cose::{CONTENT_TYPE_BITWARDEN_LEGACY_KEY, IDENTITY_SEALED_ENVELOPE_RECIPIENT_FINGERPRINT, IDENTITY_SEALED_ENVELOPE_SENDER_FINGERPRINT, XCHACHA20_POLY1305}, traits::DeriveFingerprint, xchacha20 }; /// An identity-sealed key envelope that securely transports a symmetric key between @@ -38,8 +38,10 @@ pub enum IdentitySealedKeyEnvelopeError { RsaOperationFailed, /// COSE encoding/decoding failed CoseEncodingFailed, - /// The decrypted key is invalid - InvalidKey, + /// Decryption of the envelope content failed + DecryptionFailed, + /// The decrypted key data is invalid and cannot be parsed + InvalidKeyData, /// The namespace in the signed object does not match InvalidNamespace, /// Missing payload in COSE structure @@ -92,8 +94,17 @@ impl IdentitySealedKeyEnvelope { { let mut hdr = coset::HeaderBuilder::new() .value(IDENTITY_SEALED_ENVELOPE_RECIPIENT_FINGERPRINT, ciborium::Value::Bytes(recipient_verifying_key_fingerprint.0.to_vec())) - .value(IDENTITY_SEALED_ENVELOPE_SENDER_FINGERPRINT, ciborium::Value::Bytes(sender_verifying_key_fingerprint.0.to_vec())) - .build(); + .value(IDENTITY_SEALED_ENVELOPE_SENDER_FINGERPRINT, ciborium::Value::Bytes(sender_verifying_key_fingerprint.0.to_vec())); + match content_type { + ContentFormat::BitwardenLegacyKey => { + hdr = hdr.content_type(CONTENT_TYPE_BITWARDEN_LEGACY_KEY.to_string()); + } + ContentFormat::CoseKey => { + hdr = hdr.content_format(CoapContentFormat::CoseKey); + } + _ => unreachable!("Only BitwardenLegacyKey and CoseKey are supported content formats"), + } + let mut hdr = hdr.build(); hdr.alg = cek_alg.clone(); hdr } @@ -111,7 +122,7 @@ impl IdentitySealedKeyEnvelope { .ciphertext(recipient_cek_ct) .build(), ) - .try_create_ciphertext(&payload, &[], |data, aad| { + .try_create_ciphertext(&payload, &[], |data, aad| -> Result, IdentitySealedKeyEnvelopeError> { match cek_alg { Some(coset::Algorithm::PrivateUse(XCHACHA20_POLY1305)) => { let ciphertext = @@ -119,21 +130,292 @@ impl IdentitySealedKeyEnvelope { nonce = ciphertext.nonce().to_vec(); Ok(ciphertext.encrypted_bytes().to_vec()) }, - _ => return Err(IdentitySealedKeyEnvelopeError::InvalidKey), + _ => unreachable!("CEK algorithm is always XChaCha20Poly1305"), } })? .unprotected(coset::HeaderBuilder::new().iv(nonce).build()) .build(); - // Sign the COSE Encrypt structure - + // Serialize the COSE Encrypt to bytes for signing + let cose_encrypt_bytes = cose_encrypt + .to_vec() + .map_err(|_| IdentitySealedKeyEnvelopeError::CoseEncodingFailed)?; + + // Sign the COSE Encrypt structure with the sender's signing key + // The signature binds the encrypted content to the sender's identity + let cose_sign1 = coset::CoseSign1Builder::new() + .protected( + coset::HeaderBuilder::new() + .algorithm(sender_signing_key.cose_algorithm()) + .key_id((&sender_signing_key.id).into()) + .value( + crate::cose::SIGNING_NAMESPACE, + ciborium::Value::Integer(ciborium::value::Integer::from( + SigningNamespace::IdentitySealedKeyEnvelope.as_i64(), + )), + ) + .build(), + ) + .payload(cose_encrypt_bytes) + .create_signature(&[], |data| sender_signing_key.sign_raw(data)) + .build(); + + Ok(Self { cose_sign1 }) } + /// Unseals the envelope and extracts the shared symmetric key. + /// This verifies the sender's signature and decrypts the key using the recipient's private key. pub fn unseal( &self, sender_verifying_key: &VerifyingKey, recipient_private_key: &AsymmetricCryptoKey, ) -> Result { - todo!() + // Verify the namespace in the signature + let namespace = crate::signing::namespace(&self.cose_sign1.protected) + .map_err(|_| IdentitySealedKeyEnvelopeError::InvalidNamespace)?; + if namespace != SigningNamespace::IdentitySealedKeyEnvelope { + return Err(IdentitySealedKeyEnvelopeError::InvalidNamespace); + } + + // Verify the signature + self.cose_sign1 + .verify_signature(&[], |sig, data| sender_verifying_key.verify_raw(sig, data)) + .map_err(|_| IdentitySealedKeyEnvelopeError::SignatureVerificationFailed)?; + + // Extract the COSE Encrypt payload + let cose_encrypt_bytes = self + .cose_sign1 + .payload + .as_ref() + .ok_or(IdentitySealedKeyEnvelopeError::MissingPayload)?; + + // Parse the COSE Encrypt structure + let cose_encrypt = coset::CoseEncrypt::from_slice(cose_encrypt_bytes) + .map_err(|_| IdentitySealedKeyEnvelopeError::CoseEncodingFailed)?; + + // Get the CEK algorithm from the protected header + let cek_alg = cose_encrypt + .protected + .header + .alg + .as_ref() + .expect("CEK algorithm must be present in COSE Encrypt protected header"); + + // Get the first recipient (we only support single recipient) + let recipient = cose_encrypt + .recipients + .first() + .expect("COSE Encrypt must have at least one recipient"); + + // Get the encrypted CEK from the recipient + let encrypted_cek = recipient + .ciphertext + .as_ref() + .ok_or(IdentitySealedKeyEnvelopeError::MissingPayload)?; + + // Decrypt the CEK using the recipient's private key + let cek = match recipient.protected.header.alg { + Some(coset::Algorithm::Assigned(iana::Algorithm::RSAES_OAEP_RFC_8017_default)) => { + match recipient_private_key.inner() { + RawPrivateKey::RsaOaepSha1(rsa_private_key) => { + rsa_private_key + .decrypt(Oaep::new::(), encrypted_cek) + .map_err(|_| IdentitySealedKeyEnvelopeError::RsaOperationFailed)? + } + } + } + _ => panic!("Unsupported recipient key encryption algorithm"), + }; + let cek = { + let mut cek_arr = [0u8; xchacha20::KEY_SIZE]; + cek_arr.copy_from_slice(&cek); + cek_arr + }; + + // Get the nonce from the unprotected header + let nonce = cose_encrypt + .unprotected + .iv + .as_slice(); + let nonce: [u8; xchacha20::NONCE_SIZE] = nonce + .try_into() + .expect("Nonce must be exactly NONCE_SIZE bytes"); + + // Get the ciphertext + let decrypted = cose_encrypt.decrypt(&[], |data, aad| { + crate::xchacha20::decrypt_xchacha20_poly1305( + &nonce, + &cek, + data, + aad, + ) + }).map_err(|_| IdentitySealedKeyEnvelopeError::DecryptionFailed)?; + + let content_format = ContentFormat::try_from(&cose_encrypt.protected.header).unwrap(); + let symmetric_key = match content_format { + ContentFormat::BitwardenLegacyKey => { + EncodedSymmetricKey::BitwardenLegacyKey(BitwardenLegacyKeyBytes::try_from(decrypted) + .map_err(|_| IdentitySealedKeyEnvelopeError::InvalidKeyData)?) + } + ContentFormat::CoseKey => { + EncodedSymmetricKey::CoseKey(CoseKeyBytes::try_from(decrypted) + .map_err(|_| IdentitySealedKeyEnvelopeError::InvalidKeyData)?) + } + _ => { + return Err(IdentitySealedKeyEnvelopeError::InvalidKeyData); + } + }; + SymmetricCryptoKey::try_from(symmetric_key) + .map_err(|_| IdentitySealedKeyEnvelopeError::InvalidKeyData) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + AsymmetricCryptoKey, PublicKeyEncryptionAlgorithm, SignatureAlgorithm, + SignedPublicKeyMessage, SymmetricCryptoKey, + }; + + #[test] + fn test_seal_unseal_roundtrip() { + // Create sender's signing key pair + let sender_signing_key = SigningKey::make(SignatureAlgorithm::Ed25519); + let sender_verifying_key = sender_signing_key.to_verifying_key(); + + // Create recipient's signing key pair (for identity) + let recipient_signing_key = SigningKey::make(SignatureAlgorithm::Ed25519); + let recipient_verifying_key = recipient_signing_key.to_verifying_key(); + + // Create recipient's encryption key pair + let recipient_private_key = + AsymmetricCryptoKey::make(PublicKeyEncryptionAlgorithm::RsaOaepSha1); + let recipient_public_key = recipient_private_key.to_public_key(); + + // Sign the recipient's public key with their signing key + let signed_public_key = SignedPublicKeyMessage::from_public_key(&recipient_public_key) + .expect("Failed to create signed public key message") + .sign(&recipient_signing_key) + .expect("Failed to sign public key"); + + // Create a symmetric key to share + let key_to_share = SymmetricCryptoKey::make_xchacha20_poly1305_key(); + + // Seal the key + let envelope = IdentitySealedKeyEnvelope::seal( + &sender_signing_key, + &recipient_verifying_key, + signed_public_key, + &key_to_share, + ) + .expect("Failed to seal key"); + + // Unseal the key + let unsealed_key = envelope + .unseal(&sender_verifying_key, &recipient_private_key) + .expect("Failed to unseal key"); + + // Verify the key matches + assert_eq!( + key_to_share.to_base64(), + unsealed_key.to_base64(), + "Unsealed key does not match original key" + ); + } + + #[test] + fn test_unseal_fails_with_wrong_sender_key() { + // Create sender's signing key pair + let sender_signing_key = SigningKey::make(SignatureAlgorithm::Ed25519); + + // Create a different sender's key (attacker) + let wrong_sender_signing_key = SigningKey::make(SignatureAlgorithm::Ed25519); + let wrong_sender_verifying_key = wrong_sender_signing_key.to_verifying_key(); + + // Create recipient's signing key pair + let recipient_signing_key = SigningKey::make(SignatureAlgorithm::Ed25519); + let recipient_verifying_key = recipient_signing_key.to_verifying_key(); + + // Create recipient's encryption key pair + let recipient_private_key = + AsymmetricCryptoKey::make(PublicKeyEncryptionAlgorithm::RsaOaepSha1); + let recipient_public_key = recipient_private_key.to_public_key(); + + // Sign the recipient's public key + let signed_public_key = SignedPublicKeyMessage::from_public_key(&recipient_public_key) + .expect("Failed to create signed public key message") + .sign(&recipient_signing_key) + .expect("Failed to sign public key"); + + // Create a symmetric key to share + let key_to_share = SymmetricCryptoKey::make_xchacha20_poly1305_key(); + + // Seal the key with the real sender + let envelope = IdentitySealedKeyEnvelope::seal( + &sender_signing_key, + &recipient_verifying_key, + signed_public_key, + &key_to_share, + ) + .expect("Failed to seal key"); + + // Try to unseal with wrong sender's verifying key - should fail + let result = envelope.unseal(&wrong_sender_verifying_key, &recipient_private_key); + assert!( + matches!( + result, + Err(IdentitySealedKeyEnvelopeError::SignatureVerificationFailed) + ), + "Expected signature verification to fail with wrong sender key" + ); + } + + #[test] + fn test_unseal_fails_with_wrong_recipient_key() { + // Create sender's signing key pair + let sender_signing_key = SigningKey::make(SignatureAlgorithm::Ed25519); + let sender_verifying_key = sender_signing_key.to_verifying_key(); + + // Create recipient's signing key pair + let recipient_signing_key = SigningKey::make(SignatureAlgorithm::Ed25519); + let recipient_verifying_key = recipient_signing_key.to_verifying_key(); + + // Create recipient's encryption key pair + let recipient_private_key = + AsymmetricCryptoKey::make(PublicKeyEncryptionAlgorithm::RsaOaepSha1); + let recipient_public_key = recipient_private_key.to_public_key(); + + // Create a different recipient's private key (attacker) + let wrong_recipient_private_key = + AsymmetricCryptoKey::make(PublicKeyEncryptionAlgorithm::RsaOaepSha1); + + // Sign the recipient's public key + let signed_public_key = SignedPublicKeyMessage::from_public_key(&recipient_public_key) + .expect("Failed to create signed public key message") + .sign(&recipient_signing_key) + .expect("Failed to sign public key"); + + // Create a symmetric key to share + let key_to_share = SymmetricCryptoKey::make_xchacha20_poly1305_key(); + + // Seal the key + let envelope = IdentitySealedKeyEnvelope::seal( + &sender_signing_key, + &recipient_verifying_key, + signed_public_key, + &key_to_share, + ) + .expect("Failed to seal key"); + + // Try to unseal with wrong recipient's private key - should fail + let result = envelope.unseal(&sender_verifying_key, &wrong_recipient_private_key); + assert!( + matches!( + result, + Err(IdentitySealedKeyEnvelopeError::RsaOperationFailed) + ), + "Expected RSA decryption to fail with wrong recipient key" + ); } } diff --git a/crates/bitwarden-crypto/src/signing/cose.rs b/crates/bitwarden-crypto/src/signing/cose.rs index 0227467aa..613b16e9c 100644 --- a/crates/bitwarden-crypto/src/signing/cose.rs +++ b/crates/bitwarden-crypto/src/signing/cose.rs @@ -15,7 +15,7 @@ use crate::{ /// Helper function to extract the namespace from a `ProtectedHeader`. The namespace is a custom /// header set on the protected headers of the signature object. -pub(super) fn namespace( +pub(crate) fn namespace( protected_header: &ProtectedHeader, ) -> Result { let namespace = protected_header diff --git a/crates/bitwarden-crypto/src/signing/mod.rs b/crates/bitwarden-crypto/src/signing/mod.rs index 84e35c41d..a77e86da6 100644 --- a/crates/bitwarden-crypto/src/signing/mod.rs +++ b/crates/bitwarden-crypto/src/signing/mod.rs @@ -29,6 +29,7 @@ mod cose; use cose::*; +pub(crate) use cose::namespace; mod namespace; pub use namespace::SigningNamespace; mod signed_object; From f8d1e09faf744894b642ac3812a1092c0f82bff1 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Sun, 30 Nov 2025 20:32:54 +0100 Subject: [PATCH 5/8] Tmp --- .../src/keys/asymmetric_crypto_key.rs | 11 +- .../src/safe/identity_sealed_key_envelope.rs | 377 ++++++++++++++---- crates/bitwarden-crypto/src/signing/mod.rs | 2 +- .../src/signing/signing_key.rs | 3 +- .../src/signing/verifying_key.rs | 12 +- .../src/traits/key_fingerprint.rs | 40 +- 6 files changed, 333 insertions(+), 112 deletions(-) diff --git a/crates/bitwarden-crypto/src/keys/asymmetric_crypto_key.rs b/crates/bitwarden-crypto/src/keys/asymmetric_crypto_key.rs index 5039c0a82..a0b8c9e2d 100644 --- a/crates/bitwarden-crypto/src/keys/asymmetric_crypto_key.rs +++ b/crates/bitwarden-crypto/src/keys/asymmetric_crypto_key.rs @@ -7,7 +7,8 @@ use sha2::Digest as _; use super::key_encryptable::CryptoKey; use crate::{ Pkcs8PrivateKeyBytes, SpkiPublicKeyBytes, - error::{CryptoError, Result}, traits::DeriveFingerprint, + error::{CryptoError, Result}, + traits::DeriveFingerprint, }; /// Algorithm / public key encryption scheme used for encryption/decryption. @@ -63,9 +64,11 @@ impl DeriveFingerprint for AsymmetricPublicCryptoKey { fn fingerprint(&self) -> crate::traits::KeyFingerprint { match &self.inner { RawPublicKey::RsaOaepSha1(key) => { - // An RSA key has two components - the exponent e and the modulus n. To create a canonical - // representation, we serialize both components in big-endian byte order and concatenate. However, to prevent collisions, - // we prefix each of these with the length of the component as a 2-byte big-endian integer. + // An RSA key has two components - the exponent e and the modulus n. To create a + // canonical representation, we serialize both components in + // big-endian byte order and concatenate. However, to prevent collisions, + // we prefix each of these with the length of the component as a 2-byte big-endian + // integer. let e = key.e().to_bytes_be(); let e_len = e.len() as u16; let n = key.n().to_bytes_be(); diff --git a/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs b/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs index f9f21d662..e5d918613 100644 --- a/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs +++ b/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs @@ -9,15 +9,35 @@ //! - Confidentiality: Only the intended recipient can decrypt the key //! - Authenticity: The recipient can verify the sender's identity //! - Binding: The envelope is bound to both sender and recipient identities -//! -//! In detail, the cose encrypt object contains the encrypted symmetric key as ciphertext. Currently this is encrypted with xchacha20-poly1305. The cek for the message is shared -//! to a single recipient. The currently only sharing algorithm implemented is RSA-OAEP with SHA-1, as this is currently the only public-key encryption key type supported. +//! +//! In detail, the cose encrypt object contains the encrypted symmetric key as ciphertext. Currently +//! this is encrypted with xchacha20-poly1305. The cek for the message is shared to a single +//! recipient. The currently only sharing algorithm implemented is RSA-OAEP with SHA-1, as this is +//! currently the only public-key encryption key type supported. + +use std::str::FromStr; -use coset::{CborSerializable, iana::{self, CoapContentFormat}}; +use bitwarden_encoding::B64; +use coset::{ + CborSerializable, + iana::{self, CoapContentFormat}, +}; use rsa::Oaep; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +#[cfg(feature = "wasm")] +use wasm_bindgen::convert::FromWasmAbi; use crate::{ - AsymmetricCryptoKey, BitwardenLegacyKeyBytes, ContentFormat, CoseKeyBytes, CryptoError, EncodedSymmetricKey, RawPrivateKey, RawPublicKey, SignedPublicKey, SigningKey, SigningNamespace, SymmetricCryptoKey, VerifyingKey, cose::{CONTENT_TYPE_BITWARDEN_LEGACY_KEY, IDENTITY_SEALED_ENVELOPE_RECIPIENT_FINGERPRINT, IDENTITY_SEALED_ENVELOPE_SENDER_FINGERPRINT, XCHACHA20_POLY1305}, traits::DeriveFingerprint, xchacha20 + AsymmetricCryptoKey, BitwardenLegacyKeyBytes, ContentFormat, CoseKeyBytes, CryptoError, + EncodedSymmetricKey, RawPrivateKey, RawPublicKey, SignedPublicKey, SigningKey, + SigningNamespace, SymmetricCryptoKey, VerifyingKey, + cose::{ + CONTENT_TYPE_BITWARDEN_LEGACY_KEY, IDENTITY_SEALED_ENVELOPE_RECIPIENT_FINGERPRINT, + IDENTITY_SEALED_ENVELOPE_SENDER_FINGERPRINT, XCHACHA20_POLY1305, + }, + traits::{DeriveFingerprint, KeyFingerprint}, + xchacha20, }; /// An identity-sealed key envelope that securely transports a symmetric key between @@ -28,36 +48,47 @@ pub struct IdentitySealedKeyEnvelope { } /// Errors that can occur during identity sealed key envelope operations. -#[derive(Debug)] +#[derive(Debug, Error)] pub enum IdentitySealedKeyEnvelopeError { /// The signature verification failed + #[error("Signature verification failed")] SignatureVerificationFailed, /// The recipient's signed public key verification failed + #[error("Recipient public key verification failed")] RecipientPublicKeyVerificationFailed, /// RSA encryption/decryption failed + #[error("RSA operation failed")] RsaOperationFailed, /// COSE encoding/decoding failed + #[error("COSE encoding/decoding failed")] CoseEncodingFailed, /// Decryption of the envelope content failed + #[error("Decryption failed")] DecryptionFailed, /// The decrypted key data is invalid and cannot be parsed + #[error("Invalid key data")] InvalidKeyData, /// The namespace in the signed object does not match + #[error("Invalid namespace")] InvalidNamespace, /// Missing payload in COSE structure + #[error("Missing payload in COSE structure")] MissingPayload, + /// The sender fingerprint in the envelope does not match the provided sender verifying key + #[error("Sender fingerprint mismatch")] + SenderFingerprintMismatch, + /// The recipient fingerprint in the envelope does not match the provided recipient verifying key + #[error("Recipient fingerprint mismatch")] + RecipientFingerprintMismatch, /// Crypto error - CryptoError(CryptoError), -} - -impl From for IdentitySealedKeyEnvelopeError { - fn from(err: CryptoError) -> Self { - IdentitySealedKeyEnvelopeError::CryptoError(err) - } + #[error("Crypto error: {0}")] + CryptoError(#[from] CryptoError), } impl IdentitySealedKeyEnvelope { - /// Seals a symmetric key to be shared with a recipient. This requires the senders identity signature key pair, and the recipients identity verifying key, and a corresponding signed public key for encryption. + /// Seals a symmetric key to be shared with a recipient. This requires the senders identity + /// signature key pair, and the recipients identity verifying key, and a corresponding signed + /// public key for encryption. pub fn seal( sender_signing_key: &SigningKey, recipient_verifying_key: &VerifyingKey, @@ -65,7 +96,9 @@ impl IdentitySealedKeyEnvelope { key_to_share: &SymmetricCryptoKey, ) -> Result { let (payload, content_type) = match key_to_share.to_encoded_raw() { - crate::EncodedSymmetricKey::BitwardenLegacyKey(bytes) => (bytes.to_vec(), ContentFormat::BitwardenLegacyKey), + crate::EncodedSymmetricKey::BitwardenLegacyKey(bytes) => { + (bytes.to_vec(), ContentFormat::BitwardenLegacyKey) + } crate::EncodedSymmetricKey::CoseKey(bytes) => (bytes.to_vec(), ContentFormat::CoseKey), }; let recipient_public_key = recipient_public_key @@ -76,63 +109,76 @@ impl IdentitySealedKeyEnvelope { let sender_verifying_key_fingerprint = sender_signing_key.to_verifying_key().fingerprint(); // Generate CEK and encrypt it for the recipient - let (cek, cek_alg) = (xchacha20::make_xchacha20_poly1305_key(), Some(coset::Algorithm::PrivateUse(XCHACHA20_POLY1305))); + let (cek, cek_alg) = ( + xchacha20::make_xchacha20_poly1305_key(), + Some(coset::Algorithm::PrivateUse(XCHACHA20_POLY1305)), + ); // Encrypt the CEK for the recipient using their public key let (recipient_cek_ct, recipient_alg) = match recipient_public_key.inner() { - RawPublicKey::RsaOaepSha1(rsa_public_key) => { - (crate::rsa::encrypt_rsa2048_oaep_sha1(rsa_public_key, &cek) - .map_err(|_| IdentitySealedKeyEnvelopeError::RsaOperationFailed)?, Some(coset::Algorithm::Assigned(iana::Algorithm::RSAES_OAEP_RFC_8017_default))) - } + RawPublicKey::RsaOaepSha1(rsa_public_key) => ( + crate::rsa::encrypt_rsa2048_oaep_sha1(rsa_public_key, &cek) + .map_err(|_| IdentitySealedKeyEnvelopeError::RsaOperationFailed)?, + Some(coset::Algorithm::Assigned( + iana::Algorithm::RSAES_OAEP_RFC_8017_default, + )), + ), }; // Build COSE Encrypt structure with the encrypted key as ciphertext // The recipient info contains the algorithm used let mut nonce = Vec::new(); let cose_encrypt = coset::CoseEncryptBuilder::new() - .protected( - { - let mut hdr = coset::HeaderBuilder::new() - .value(IDENTITY_SEALED_ENVELOPE_RECIPIENT_FINGERPRINT, ciborium::Value::Bytes(recipient_verifying_key_fingerprint.0.to_vec())) - .value(IDENTITY_SEALED_ENVELOPE_SENDER_FINGERPRINT, ciborium::Value::Bytes(sender_verifying_key_fingerprint.0.to_vec())); - match content_type { - ContentFormat::BitwardenLegacyKey => { - hdr = hdr.content_type(CONTENT_TYPE_BITWARDEN_LEGACY_KEY.to_string()); - } - ContentFormat::CoseKey => { - hdr = hdr.content_format(CoapContentFormat::CoseKey); - } - _ => unreachable!("Only BitwardenLegacyKey and CoseKey are supported content formats"), + .protected({ + let mut hdr = coset::HeaderBuilder::new() + .value( + IDENTITY_SEALED_ENVELOPE_RECIPIENT_FINGERPRINT, + ciborium::Value::Bytes(recipient_verifying_key_fingerprint.0.to_vec()), + ) + .value( + IDENTITY_SEALED_ENVELOPE_SENDER_FINGERPRINT, + ciborium::Value::Bytes(sender_verifying_key_fingerprint.0.to_vec()), + ); + match content_type { + ContentFormat::BitwardenLegacyKey => { + hdr = hdr.content_type(CONTENT_TYPE_BITWARDEN_LEGACY_KEY.to_string()); + } + ContentFormat::CoseKey => { + hdr = hdr.content_format(CoapContentFormat::CoseKey); } - let mut hdr = hdr.build(); - hdr.alg = cek_alg.clone(); - hdr + _ => unreachable!( + "Only BitwardenLegacyKey and CoseKey are supported content formats" + ), } - ) + let mut hdr = hdr.build(); + hdr.alg = cek_alg.clone(); + hdr + }) .add_recipient( coset::CoseRecipientBuilder::new() - .protected( - { - let mut hdr = coset::HeaderBuilder::new() - .build(); - hdr.alg = recipient_alg; - hdr - } - ) + .protected({ + let mut hdr = coset::HeaderBuilder::new().build(); + hdr.alg = recipient_alg; + hdr + }) .ciphertext(recipient_cek_ct) .build(), ) - .try_create_ciphertext(&payload, &[], |data, aad| -> Result, IdentitySealedKeyEnvelopeError> { - match cek_alg { - Some(coset::Algorithm::PrivateUse(XCHACHA20_POLY1305)) => { - let ciphertext = - crate::xchacha20::encrypt_xchacha20_poly1305(&cek, data, aad); - nonce = ciphertext.nonce().to_vec(); - Ok(ciphertext.encrypted_bytes().to_vec()) - }, - _ => unreachable!("CEK algorithm is always XChaCha20Poly1305"), - } - })? + .try_create_ciphertext( + &payload, + &[], + |data, aad| -> Result, IdentitySealedKeyEnvelopeError> { + match cek_alg { + Some(coset::Algorithm::PrivateUse(XCHACHA20_POLY1305)) => { + let ciphertext = + crate::xchacha20::encrypt_xchacha20_poly1305(&cek, data, aad); + nonce = ciphertext.nonce().to_vec(); + Ok(ciphertext.encrypted_bytes().to_vec()) + } + _ => unreachable!("CEK algorithm is always XChaCha20Poly1305"), + } + }, + )? .unprotected(coset::HeaderBuilder::new().iv(nonce).build()) .build(); @@ -164,10 +210,12 @@ impl IdentitySealedKeyEnvelope { } /// Unseals the envelope and extracts the shared symmetric key. - /// This verifies the sender's signature and decrypts the key using the recipient's private key. + /// To unseal correctly, this requires the sender's verifying key, the recipient's verifying key to match the key pairs used during sealing, and the + /// private key to be the private key corresponding to the signed public key used during sealing. pub fn unseal( &self, sender_verifying_key: &VerifyingKey, + recipient_verifying_key: &VerifyingKey, recipient_private_key: &AsymmetricCryptoKey, ) -> Result { // Verify the namespace in the signature @@ -177,12 +225,13 @@ impl IdentitySealedKeyEnvelope { return Err(IdentitySealedKeyEnvelopeError::InvalidNamespace); } - // Verify the signature self.cose_sign1 .verify_signature(&[], |sig, data| sender_verifying_key.verify_raw(sig, data)) .map_err(|_| IdentitySealedKeyEnvelopeError::SignatureVerificationFailed)?; - // Extract the COSE Encrypt payload + // The signature is verified. This means the outer message is verified to have come from the sender (Sender authentication). However, the same cannot be claimed + // for the contents of the contained COSE Encrypt message could have been stripped and relayed. + let cose_encrypt_bytes = self .cose_sign1 .payload @@ -193,8 +242,52 @@ impl IdentitySealedKeyEnvelope { let cose_encrypt = coset::CoseEncrypt::from_slice(cose_encrypt_bytes) .map_err(|_| IdentitySealedKeyEnvelopeError::CoseEncodingFailed)?; + // Extract and verify the sender fingerprint from the COSE Encrypt protected header + let sender_fingerprint_in_envelope = cose_encrypt + .protected + .header + .rest + .iter() + .find_map(|(key, value)| { + if let coset::Label::Int(key) = key { + if *key == IDENTITY_SEALED_ENVELOPE_SENDER_FINGERPRINT { + return value.as_bytes().map(|b| b.to_vec()); + } + } + None + }) + .map(|bytes| KeyFingerprint(bytes.try_into().unwrap())) + .ok_or(IdentitySealedKeyEnvelopeError::SenderFingerprintMismatch)?; + + let expected_sender_fingerprint = sender_verifying_key.fingerprint(); + if sender_fingerprint_in_envelope != expected_sender_fingerprint { + return Err(IdentitySealedKeyEnvelopeError::SenderFingerprintMismatch); + } + + // Extract and verify the recipient fingerprint from the COSE Encrypt protected header + let recipient_fingerprint_in_envelope = cose_encrypt + .protected + .header + .rest + .iter() + .find_map(|(key, value)| { + if let coset::Label::Int(key) = key { + if *key == IDENTITY_SEALED_ENVELOPE_RECIPIENT_FINGERPRINT { + return value.as_bytes().map(|b| b.to_vec()); + } + } + None + }) + .map(|bytes| KeyFingerprint(bytes.try_into().unwrap())) + .ok_or(IdentitySealedKeyEnvelopeError::RecipientFingerprintMismatch)?; + + let expected_recipient_fingerprint = recipient_verifying_key.fingerprint(); + if recipient_fingerprint_in_envelope != expected_recipient_fingerprint { + return Err(IdentitySealedKeyEnvelopeError::RecipientFingerprintMismatch); + } + // Get the CEK algorithm from the protected header - let cek_alg = cose_encrypt + let _cek_alg = cose_encrypt .protected .header .alg @@ -217,11 +310,9 @@ impl IdentitySealedKeyEnvelope { let cek = match recipient.protected.header.alg { Some(coset::Algorithm::Assigned(iana::Algorithm::RSAES_OAEP_RFC_8017_default)) => { match recipient_private_key.inner() { - RawPrivateKey::RsaOaepSha1(rsa_private_key) => { - rsa_private_key - .decrypt(Oaep::new::(), encrypted_cek) - .map_err(|_| IdentitySealedKeyEnvelopeError::RsaOperationFailed)? - } + RawPrivateKey::RsaOaepSha1(rsa_private_key) => rsa_private_key + .decrypt(Oaep::new::(), encrypted_cek) + .map_err(|_| IdentitySealedKeyEnvelopeError::RsaOperationFailed)?, } } _ => panic!("Unsupported recipient key encryption algorithm"), @@ -233,34 +324,28 @@ impl IdentitySealedKeyEnvelope { }; // Get the nonce from the unprotected header - let nonce = cose_encrypt - .unprotected - .iv - .as_slice(); + let nonce = cose_encrypt.unprotected.iv.as_slice(); let nonce: [u8; xchacha20::NONCE_SIZE] = nonce .try_into() .expect("Nonce must be exactly NONCE_SIZE bytes"); // Get the ciphertext - let decrypted = cose_encrypt.decrypt(&[], |data, aad| { - crate::xchacha20::decrypt_xchacha20_poly1305( - &nonce, - &cek, - data, - aad, - ) - }).map_err(|_| IdentitySealedKeyEnvelopeError::DecryptionFailed)?; + let decrypted = cose_encrypt + .decrypt(&[], |data, aad| { + crate::xchacha20::decrypt_xchacha20_poly1305(&nonce, &cek, data, aad) + }) + .map_err(|_| IdentitySealedKeyEnvelopeError::DecryptionFailed)?; let content_format = ContentFormat::try_from(&cose_encrypt.protected.header).unwrap(); let symmetric_key = match content_format { - ContentFormat::BitwardenLegacyKey => { - EncodedSymmetricKey::BitwardenLegacyKey(BitwardenLegacyKeyBytes::try_from(decrypted) - .map_err(|_| IdentitySealedKeyEnvelopeError::InvalidKeyData)?) - } - ContentFormat::CoseKey => { - EncodedSymmetricKey::CoseKey(CoseKeyBytes::try_from(decrypted) - .map_err(|_| IdentitySealedKeyEnvelopeError::InvalidKeyData)?) - } + ContentFormat::BitwardenLegacyKey => EncodedSymmetricKey::BitwardenLegacyKey( + BitwardenLegacyKeyBytes::try_from(decrypted) + .map_err(|_| IdentitySealedKeyEnvelopeError::InvalidKeyData)?, + ), + ContentFormat::CoseKey => EncodedSymmetricKey::CoseKey( + CoseKeyBytes::try_from(decrypted) + .map_err(|_| IdentitySealedKeyEnvelopeError::InvalidKeyData)?, + ), _ => { return Err(IdentitySealedKeyEnvelopeError::InvalidKeyData); } @@ -270,6 +355,104 @@ impl IdentitySealedKeyEnvelope { } } +// Conversion implementations + +impl From<&IdentitySealedKeyEnvelope> for Vec { + fn from(val: &IdentitySealedKeyEnvelope) -> Self { + val.cose_sign1 + .clone() + .to_vec() + .expect("COSE Sign1 serialization should never fail") + } +} + +impl TryFrom> for IdentitySealedKeyEnvelope { + type Error = IdentitySealedKeyEnvelopeError; + + fn try_from(data: Vec) -> Result { + let cose_sign1 = coset::CoseSign1::from_slice(&data) + .map_err(|_| IdentitySealedKeyEnvelopeError::CoseEncodingFailed)?; + Ok(IdentitySealedKeyEnvelope { cose_sign1 }) + } +} + +impl std::fmt::Debug for IdentitySealedKeyEnvelope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IdentitySealedKeyEnvelope").finish() + } +} + +impl FromStr for IdentitySealedKeyEnvelope { + type Err = IdentitySealedKeyEnvelopeError; + + fn from_str(s: &str) -> Result { + let data = + B64::try_from(s).map_err(|_| IdentitySealedKeyEnvelopeError::CoseEncodingFailed)?; + Self::try_from(data.into_bytes()) + } +} + +impl From for String { + fn from(val: IdentitySealedKeyEnvelope) -> Self { + let serialized: Vec = (&val).into(); + B64::from(serialized).to_string() + } +} + +impl<'de> Deserialize<'de> for IdentitySealedKeyEnvelope { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::from_str(&s).map_err(serde::de::Error::custom) + } +} + +impl Serialize for IdentitySealedKeyEnvelope { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let serialized: Vec = self.into(); + serializer.serialize_str(&B64::from(serialized).to_string()) + } +} + +impl std::fmt::Display for IdentitySealedKeyEnvelope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let serialized: Vec = self.into(); + write!(f, "{}", B64::from(serialized)) + } +} + +// WASM bindings + +#[cfg(feature = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(typescript_custom_section)] +const TS_CUSTOM_TYPES: &'static str = r#" +export type IdentitySealedKeyEnvelope = Tagged; +"#; + +#[cfg(feature = "wasm")] +impl wasm_bindgen::describe::WasmDescribe for IdentitySealedKeyEnvelope { + fn describe() { + ::describe(); + } +} + +#[cfg(feature = "wasm")] +impl FromWasmAbi for IdentitySealedKeyEnvelope { + type Abi = ::Abi; + + unsafe fn from_abi(abi: Self::Abi) -> Self { + use wasm_bindgen::UnwrapThrowExt; + + let s = unsafe { String::from_abi(abi) }; + Self::from_str(&s).unwrap_throw() + } +} + #[cfg(test)] mod tests { use super::*; @@ -311,9 +494,19 @@ mod tests { ) .expect("Failed to seal key"); + println!( + "Sealed IdentitySealedKeyEnvelope: {:?}", + envelope.to_string() + ); + println!("{:?}", envelope.cose_sign1); + // Unseal the key let unsealed_key = envelope - .unseal(&sender_verifying_key, &recipient_private_key) + .unseal( + &sender_verifying_key, + &recipient_verifying_key, + &recipient_private_key, + ) .expect("Failed to unseal key"); // Verify the key matches @@ -361,7 +554,11 @@ mod tests { .expect("Failed to seal key"); // Try to unseal with wrong sender's verifying key - should fail - let result = envelope.unseal(&wrong_sender_verifying_key, &recipient_private_key); + let result = envelope.unseal( + &wrong_sender_verifying_key, + &recipient_verifying_key, + &recipient_private_key, + ); assert!( matches!( result, @@ -409,7 +606,11 @@ mod tests { .expect("Failed to seal key"); // Try to unseal with wrong recipient's private key - should fail - let result = envelope.unseal(&sender_verifying_key, &wrong_recipient_private_key); + let result = envelope.unseal( + &sender_verifying_key, + &recipient_verifying_key, + &wrong_recipient_private_key, + ); assert!( matches!( result, diff --git a/crates/bitwarden-crypto/src/signing/mod.rs b/crates/bitwarden-crypto/src/signing/mod.rs index a77e86da6..c2ee303b0 100644 --- a/crates/bitwarden-crypto/src/signing/mod.rs +++ b/crates/bitwarden-crypto/src/signing/mod.rs @@ -28,8 +28,8 @@ //! then sign detached can be used. mod cose; -use cose::*; pub(crate) use cose::namespace; +use cose::*; mod namespace; pub use namespace::SigningNamespace; mod signed_object; diff --git a/crates/bitwarden-crypto/src/signing/signing_key.rs b/crates/bitwarden-crypto/src/signing/signing_key.rs index b0da158b6..5f32b7611 100644 --- a/crates/bitwarden-crypto/src/signing/signing_key.rs +++ b/crates/bitwarden-crypto/src/signing/signing_key.rs @@ -16,7 +16,8 @@ use crate::{ content_format::CoseKeyContentFormat, cose::CoseSerializable, error::{EncodingError, Result}, - keys::KeyId, traits::KeyFingerprint, + keys::KeyId, + traits::KeyFingerprint, }; /// A `SigningKey` without the key id. This enum contains a variant for each supported signature diff --git a/crates/bitwarden-crypto/src/signing/verifying_key.rs b/crates/bitwarden-crypto/src/signing/verifying_key.rs index 1a0e24f26..3a4d41b9b 100644 --- a/crates/bitwarden-crypto/src/signing/verifying_key.rs +++ b/crates/bitwarden-crypto/src/signing/verifying_key.rs @@ -16,7 +16,8 @@ use crate::{ content_format::CoseKeyContentFormat, cose::CoseSerializable, error::{EncodingError, SignatureError}, - keys::KeyId, traits::{DeriveFingerprint, KeyFingerprint}, + keys::KeyId, + traits::{DeriveFingerprint, KeyFingerprint}, }; /// A `VerifyingKey` without the key id. This enum contains a variant for each supported signature @@ -63,10 +64,11 @@ impl DeriveFingerprint for VerifyingKey { fn fingerprint(&self) -> KeyFingerprint { match &self.inner { RawVerifyingKey::Ed25519(key) => { - // Ed25519 public keys are directly and trivially a canonical and non-colliding representation of the key pair. - // While Ed25519 keys are already 256 bits, they are not pseudo-randomly distributed and do not - // satisfy the properties of a fingerprint directly. Therefore, they are hashed using SHA-256 - // to get a pseudo-random distribution. + // Ed25519 public keys are directly and trivially a canonical and non-colliding + // representation of the key pair. While Ed25519 keys are already + // 256 bits, they are not pseudo-randomly distributed and do not + // satisfy the properties of a fingerprint directly. Therefore, they are hashed + // using SHA-256 to get a pseudo-random distribution. let digest = sha2::Sha256::digest(&key.to_bytes()); let arr: [u8; 32] = digest .as_slice() diff --git a/crates/bitwarden-crypto/src/traits/key_fingerprint.rs b/crates/bitwarden-crypto/src/traits/key_fingerprint.rs index c076a8fd6..ec46271ee 100644 --- a/crates/bitwarden-crypto/src/traits/key_fingerprint.rs +++ b/crates/bitwarden-crypto/src/traits/key_fingerprint.rs @@ -1,21 +1,35 @@ -/// Fingerprints are 256-bit. Anything human readable can be derived from that. This is enough entropy for -/// all uses cases. +use subtle::ConstantTimeEq; + +/// Fingerprints are 256-bit. Anything human readable can be derived from that. This is enough +/// entropy for all uses cases. const FINGERPRINT_LENGTH: usize = 32; /// A key fingerprint is a short, unique identifier for a cryptographic key. It is typically derived -/// from the key material using a cryptographic hash function. It also has a pseudo-random distribution and -/// MUST be derived using a cryptographic hash function / there MUST NOT be direct control over the output. +/// from the key material using a cryptographic hash function. It also has a pseudo-random +/// distribution and MUST be derived using a cryptographic hash function / there MUST NOT be direct +/// control over the output. pub struct KeyFingerprint(pub(crate) [u8; FINGERPRINT_LENGTH]); -/// A trait for deriving a key fingerprint from a cryptographic key. To implement, this MUST take a canonical representation -/// of a public key of a signing, or public-key-encryption key pair, and derive the fingerprint material from that. -/// -/// This canonical representation MUST be stable, and MUST not collide with other representations. For key pairs that have multiple -/// components, such as RSA, a valid implementation MUST explain why the chosen representation is canonical and non-colliding. -/// -/// It is recommended to use a reasonable cryptographic hashing function, such as SHA-256 to derive the 256-Bit fingerprint from the canonical representation that can have arbitrary length. -/// Once implemented, for a key algorithm type the fingerprint MUST not change, because other cryptographic objects will rely on it, and plugging different fingerprint algorithms for a given public-key -/// algorithm is not supported. A new public key algorithm may choose a new implementation, with different canonical representation and/or hash function. +/// A trait for deriving a key fingerprint from a cryptographic key. To implement, this MUST take a +/// canonical representation of a public key of a signing, or public-key-encryption key pair, and +/// derive the fingerprint material from that. +/// +/// This canonical representation MUST be stable, and MUST not collide with other representations. +/// For key pairs that have multiple components, such as RSA, a valid implementation MUST explain +/// why the chosen representation is canonical and non-colliding. +/// +/// It is recommended to use a reasonable cryptographic hashing function, such as SHA-256 to derive +/// the 256-Bit fingerprint from the canonical representation that can have arbitrary length. +/// Once implemented, for a key algorithm type the fingerprint MUST not change, because other +/// cryptographic objects will rely on it, and plugging different fingerprint algorithms for a given +/// public-key algorithm is not supported. A new public key algorithm may choose a new +/// implementation, with different canonical representation and/or hash function. pub trait DeriveFingerprint { fn fingerprint(&self) -> KeyFingerprint; +} + +impl PartialEq for KeyFingerprint { + fn eq(&self, other: &Self) -> bool { + self.0.ct_eq(&other.0).into() + } } \ No newline at end of file From 6044032318fd73bc32a4aef1a8baf6ba8a76907c Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Sun, 30 Nov 2025 22:34:36 +0100 Subject: [PATCH 6/8] org memebership proto --- .../examples/organization_v2_protocol.rs | 92 ++++++++++++++++ crates/bitwarden-crypto/src/lib.rs | 2 +- crates/bitwarden-crypto/src/safe/README.md | 9 ++ .../src/safe/identity_sealed_key_envelope.rs | 104 ++++++++++++++++-- .../bitwarden-crypto/src/signing/namespace.rs | 4 + .../src/traits/key_fingerprint.rs | 3 + 6 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 crates/bitwarden-crypto/examples/organization_v2_protocol.rs diff --git a/crates/bitwarden-crypto/examples/organization_v2_protocol.rs b/crates/bitwarden-crypto/examples/organization_v2_protocol.rs new file mode 100644 index 000000000..aea12a2fe --- /dev/null +++ b/crates/bitwarden-crypto/examples/organization_v2_protocol.rs @@ -0,0 +1,92 @@ +//! This example demonstrates how to sign and verify structs. + +use bitwarden_crypto::{AsymmetricCryptoKey, CoseSerializable, CoseSign1Bytes, DeriveFingerprint, KeyFingerprint, PublicKeyEncryptionAlgorithm, SignedObject, SignedPublicKeyMessage, SigningNamespace, SymmetricCryptoKey, safe::IdentitySealedKeyEnvelope}; + +use serde::{Deserialize, Serialize}; + +const EXAMPLE_NAMESPACE: &SigningNamespace = &SigningNamespace::SignedPublicKey; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct IdentityClaim { + identity_fingerprint: KeyFingerprint, + identifier: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MembershipAgreement { + member_identity: KeyFingerprint, + organization_identity: KeyFingerprint, +} + +fn main() { + // Setup keys for both sides + // Alice + let alice_signature_key = + bitwarden_crypto::SigningKey::make(bitwarden_crypto::SignatureAlgorithm::Ed25519); + let alice_verifying_key = alice_signature_key.to_verifying_key(); + let alice_private_key = AsymmetricCryptoKey::make(PublicKeyEncryptionAlgorithm::RsaOaepSha1); + let signed_public_key = SignedPublicKeyMessage::from_public_key(&alice_private_key.to_public_key()).unwrap(); + let signed_public_key = signed_public_key.sign(&alice_signature_key).unwrap(); + // Admin + let admin_signature_key = + bitwarden_crypto::SigningKey::make(bitwarden_crypto::SignatureAlgorithm::Ed25519); + let admin_verifying_key = admin_signature_key.to_verifying_key(); + let org_symmetric_key = SymmetricCryptoKey::make_aes256_cbc_hmac_key(); + + + // Alice joins. This is Step 2 in the 3 step process + let identity_claim = IdentityClaim { + identity_fingerprint: admin_verifying_key.fingerprint(), + identifier: "organization_name".to_string(), + }; + alice_signature_key.sign(&identity_claim, &SigningNamespace::IdentityClaim).unwrap(); + let membership_agreement = MembershipAgreement { + member_identity: alice_verifying_key.fingerprint(), + organization_identity: admin_verifying_key.fingerprint(), + }; + let (signature, serialized_message) = admin_signature_key + .sign_detached(&membership_agreement, &SigningNamespace::MembershipAgreement) + .unwrap(); + // upload to server: serialized_message, signature + + // Admin verifies + assert!(signature.verify( + &serialized_message.as_bytes(), + &admin_verifying_key, + &SigningNamespace::MembershipAgreement, + )); + + let counter_signature = alice_signature_key + .counter_sign_detached( + serialized_message.as_bytes().to_vec(), + &signature, + &SigningNamespace::MembershipAgreement, + ) + .unwrap(); + let identity_sealed_key_envelope = IdentitySealedKeyEnvelope::seal( + &admin_signature_key, + &alice_verifying_key, + &signed_public_key, + &org_symmetric_key, + ).unwrap(); + // upload to server: identity_sealed_key_envelope, counter_signature + + // To load a key, alice will have to verify that the membership agreement was signed by admin and her. + assert!(signature.verify( + &serialized_message.as_bytes(), + &admin_verifying_key, + &SigningNamespace::MembershipAgreement, + )); + assert!(counter_signature.verify( + &serialized_message.as_bytes(), + &alice_verifying_key, + &SigningNamespace::MembershipAgreement, + )); + // Then, she unseals it + let key = identity_sealed_key_envelope + .unseal(&admin_verifying_key, &alice_verifying_key, &alice_private_key) + .expect("Failed to unseal organization key"); + assert_eq!(key, org_symmetric_key); +} diff --git a/crates/bitwarden-crypto/src/lib.rs b/crates/bitwarden-crypto/src/lib.rs index a86ac1623..8980fff09 100644 --- a/crates/bitwarden-crypto/src/lib.rs +++ b/crates/bitwarden-crypto/src/lib.rs @@ -43,7 +43,7 @@ pub use signing::*; mod traits; mod xchacha20; pub use traits::{ - CompositeEncryptable, Decryptable, IdentifyKey, KeyId, KeyIds, LocalId, PrimitiveEncryptable, + CompositeEncryptable, Decryptable, IdentifyKey, KeyId, KeyIds, LocalId, PrimitiveEncryptable, KeyFingerprint, DeriveFingerprint }; pub use zeroizing_alloc::ZeroAlloc as ZeroizingAllocator; diff --git a/crates/bitwarden-crypto/src/safe/README.md b/crates/bitwarden-crypto/src/safe/README.md index faf4119cb..be28a7615 100644 --- a/crates/bitwarden-crypto/src/safe/README.md +++ b/crates/bitwarden-crypto/src/safe/README.md @@ -28,3 +28,12 @@ Use the data envelope to protect a struct (document) of data. Examples include: The serialization of the data and the creation of a content encryption key is handled internally. Calling the API with a decrypted struct, the content encryption key ID and the encrypted data are returned. + +## Identity-sealed key envelope + +Use the identity sealed key envelope to share a symmetric key from one cryptographic identity to another cryptographic identity. Example use-cases include: +- Sharing a symmetric key for emergency access +- Sharing a symmetric key for organization membership +- Sharing a symmetric key for ad-hoc item sharing + +This provides sender authentication, so that the recipient knows that the key was intended for them, and knows who it was sent by. \ No newline at end of file diff --git a/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs b/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs index e5d918613..681ac0730 100644 --- a/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs +++ b/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs @@ -92,7 +92,7 @@ impl IdentitySealedKeyEnvelope { pub fn seal( sender_signing_key: &SigningKey, recipient_verifying_key: &VerifyingKey, - recipient_public_key: SignedPublicKey, + recipient_public_key: &SignedPublicKey, key_to_share: &SymmetricCryptoKey, ) -> Result { let (payload, content_type) = match key_to_share.to_encoded_raw() { @@ -102,6 +102,7 @@ impl IdentitySealedKeyEnvelope { crate::EncodedSymmetricKey::CoseKey(bytes) => (bytes.to_vec(), ContentFormat::CoseKey), }; let recipient_public_key = recipient_public_key + .to_owned() .verify_and_unwrap(recipient_verifying_key) .map_err(|_| IdentitySealedKeyEnvelopeError::RecipientPublicKeyVerificationFailed)?; @@ -489,17 +490,11 @@ mod tests { let envelope = IdentitySealedKeyEnvelope::seal( &sender_signing_key, &recipient_verifying_key, - signed_public_key, + &signed_public_key, &key_to_share, ) .expect("Failed to seal key"); - println!( - "Sealed IdentitySealedKeyEnvelope: {:?}", - envelope.to_string() - ); - println!("{:?}", envelope.cose_sign1); - // Unseal the key let unsealed_key = envelope .unseal( @@ -548,7 +543,7 @@ mod tests { let envelope = IdentitySealedKeyEnvelope::seal( &sender_signing_key, &recipient_verifying_key, - signed_public_key, + &signed_public_key, &key_to_share, ) .expect("Failed to seal key"); @@ -600,7 +595,7 @@ mod tests { let envelope = IdentitySealedKeyEnvelope::seal( &sender_signing_key, &recipient_verifying_key, - signed_public_key, + &signed_public_key, &key_to_share, ) .expect("Failed to seal key"); @@ -619,4 +614,93 @@ mod tests { "Expected RSA decryption to fail with wrong recipient key" ); } + + /// Generates test vectors for the identity sealed key envelope. + /// Run with: cargo test -p bitwarden-crypto generate_test_vectors -- --ignored --nocapture + #[test] + #[ignore] + fn generate_test_vectors() { + use crate::CoseSerializable; + + // Create sender's signing key pair + let sender_signing_key = SigningKey::make(SignatureAlgorithm::Ed25519); + let sender_verifying_key = sender_signing_key.to_verifying_key(); + + // Create recipient's signing key pair (for identity) + let recipient_signing_key = SigningKey::make(SignatureAlgorithm::Ed25519); + let recipient_verifying_key = recipient_signing_key.to_verifying_key(); + + // Create recipient's encryption key pair + let recipient_private_key = + AsymmetricCryptoKey::make(PublicKeyEncryptionAlgorithm::RsaOaepSha1); + let recipient_public_key = recipient_private_key.to_public_key(); + + // Sign the recipient's public key with their signing key + let signed_public_key = SignedPublicKeyMessage::from_public_key(&recipient_public_key) + .expect("Failed to create signed public key message") + .sign(&recipient_signing_key) + .expect("Failed to sign public key"); + + // Create a symmetric key to share + let key_to_share = SymmetricCryptoKey::make_xchacha20_poly1305_key(); + + // Seal the key + let envelope = IdentitySealedKeyEnvelope::seal( + &sender_signing_key, + &recipient_verifying_key, + &signed_public_key, + &key_to_share, + ) + .expect("Failed to seal key"); + + // Verify roundtrip works + let unsealed_key = envelope + .unseal( + &sender_verifying_key, + &recipient_verifying_key, + &recipient_private_key, + ) + .expect("Failed to unseal key"); + assert_eq!(key_to_share.to_base64(), unsealed_key.to_base64()); + + // Print test vectors + println!("// Test vectors for IdentitySealedKeyEnvelope"); + println!( + "const TEST_SENDER_SIGNING_KEY: &str = \"{}\";", + B64::from(sender_signing_key.to_cose().as_ref()) + ); + println!( + "const TEST_SENDER_VERIFYING_KEY: &str = \"{}\";", + B64::from(sender_verifying_key.to_cose().as_ref()) + ); + println!( + "const TEST_RECIPIENT_SIGNING_KEY: &str = \"{}\";", + B64::from(recipient_signing_key.to_cose().as_ref()) + ); + println!( + "const TEST_RECIPIENT_VERIFYING_KEY: &str = \"{}\";", + B64::from(recipient_verifying_key.to_cose().as_ref()) + ); + println!( + "const TEST_RECIPIENT_PRIVATE_KEY: &str = \"{}\";", + B64::from( + recipient_private_key + .to_der() + .expect("Failed to serialize private key") + .as_ref() + ) + ); + println!( + "const TEST_SIGNED_PUBLIC_KEY: &str = \"{}\";", + String::from(signed_public_key) + ); + println!( + "const TEST_KEY_TO_SHARE: &str = \"{}\";", + key_to_share.to_base64() + ); + println!( + "const TEST_ENVELOPE: &str = \"{}\";", + String::from(envelope) + ); + } } diff --git a/crates/bitwarden-crypto/src/signing/namespace.rs b/crates/bitwarden-crypto/src/signing/namespace.rs index 63489e429..35b278468 100644 --- a/crates/bitwarden-crypto/src/signing/namespace.rs +++ b/crates/bitwarden-crypto/src/signing/namespace.rs @@ -16,6 +16,10 @@ pub enum SigningNamespace { SecurityState = 2, /// The namespace for identity-sealed key envelopes used in secure key transport IdentitySealedKeyEnvelope = 3, + /// The namespace for an identity claim + IdentityClaim = 4, + /// The namespace for a membership agreement + MembershipAgreement = 5, /// This namespace is only used in tests #[cfg(test)] ExampleNamespace = -1, diff --git a/crates/bitwarden-crypto/src/traits/key_fingerprint.rs b/crates/bitwarden-crypto/src/traits/key_fingerprint.rs index ec46271ee..105ad1190 100644 --- a/crates/bitwarden-crypto/src/traits/key_fingerprint.rs +++ b/crates/bitwarden-crypto/src/traits/key_fingerprint.rs @@ -1,3 +1,4 @@ +use serde::{Deserialize, Serialize}; use subtle::ConstantTimeEq; /// Fingerprints are 256-bit. Anything human readable can be derived from that. This is enough @@ -8,6 +9,8 @@ const FINGERPRINT_LENGTH: usize = 32; /// from the key material using a cryptographic hash function. It also has a pseudo-random /// distribution and MUST be derived using a cryptographic hash function / there MUST NOT be direct /// control over the output. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(transparent)] pub struct KeyFingerprint(pub(crate) [u8; FINGERPRINT_LENGTH]); /// A trait for deriving a key fingerprint from a cryptographic key. To implement, this MUST take a From d12a1209616feeed25ee2c8e34aa890ceae27a7c Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Sun, 30 Nov 2025 22:49:33 +0100 Subject: [PATCH 7/8] Fix --- crates/bitwarden-crypto/examples/organization_v2_protocol.rs | 1 - crates/bitwarden-crypto/src/signing/namespace.rs | 2 ++ crates/bitwarden-crypto/src/signing/signature.rs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/bitwarden-crypto/examples/organization_v2_protocol.rs b/crates/bitwarden-crypto/examples/organization_v2_protocol.rs index aea12a2fe..c297f9953 100644 --- a/crates/bitwarden-crypto/examples/organization_v2_protocol.rs +++ b/crates/bitwarden-crypto/examples/organization_v2_protocol.rs @@ -84,7 +84,6 @@ fn main() { &alice_verifying_key, &SigningNamespace::MembershipAgreement, )); - // Then, she unseals it let key = identity_sealed_key_envelope .unseal(&admin_verifying_key, &alice_verifying_key, &alice_private_key) .expect("Failed to unseal organization key"); diff --git a/crates/bitwarden-crypto/src/signing/namespace.rs b/crates/bitwarden-crypto/src/signing/namespace.rs index 35b278468..4b2303283 100644 --- a/crates/bitwarden-crypto/src/signing/namespace.rs +++ b/crates/bitwarden-crypto/src/signing/namespace.rs @@ -43,6 +43,8 @@ impl TryFrom for SigningNamespace { 1 => Ok(SigningNamespace::SignedPublicKey), 2 => Ok(SigningNamespace::SecurityState), 3 => Ok(SigningNamespace::IdentitySealedKeyEnvelope), + 4 => Ok(SigningNamespace::IdentityClaim), + 5 => Ok(SigningNamespace::MembershipAgreement), #[cfg(test)] -1 => Ok(SigningNamespace::ExampleNamespace), #[cfg(test)] diff --git a/crates/bitwarden-crypto/src/signing/signature.rs b/crates/bitwarden-crypto/src/signing/signature.rs index 6c9611a11..648fe6986 100644 --- a/crates/bitwarden-crypto/src/signing/signature.rs +++ b/crates/bitwarden-crypto/src/signing/signature.rs @@ -55,7 +55,7 @@ impl Signature { return false; } - if self.namespace().ok().as_ref() != Some(namespace) { + if self.namespace().unwrap() != *namespace { return false; } From 6a9b37753e5dfbc12195a9ce30fbd200c8ab7fe0 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Sun, 30 Nov 2025 23:28:31 +0100 Subject: [PATCH 8/8] cleanup --- .../examples/organization_v2_protocol.rs | 271 ++++++++++++++---- 1 file changed, 208 insertions(+), 63 deletions(-) diff --git a/crates/bitwarden-crypto/examples/organization_v2_protocol.rs b/crates/bitwarden-crypto/examples/organization_v2_protocol.rs index c297f9953..bf8b1a736 100644 --- a/crates/bitwarden-crypto/examples/organization_v2_protocol.rs +++ b/crates/bitwarden-crypto/examples/organization_v2_protocol.rs @@ -1,91 +1,236 @@ -//! This example demonstrates how to sign and verify structs. - -use bitwarden_crypto::{AsymmetricCryptoKey, CoseSerializable, CoseSign1Bytes, DeriveFingerprint, KeyFingerprint, PublicKeyEncryptionAlgorithm, SignedObject, SignedPublicKeyMessage, SigningNamespace, SymmetricCryptoKey, safe::IdentitySealedKeyEnvelope}; +//! Implements the V2 organization protocol, that enhances cryptographic guarantees with respect to +//! a compromised server. Over the V1 protocol, this decomposes the cryptography to establish trust +//! once and only once, to be able to use this trust for other objects such as policies, and to +//! implement a new key transport mechanism that allows key rotation, and also provides sender +//! authentication. +use bitwarden_crypto::{ + AsymmetricCryptoKey, DeriveFingerprint, PublicKeyEncryptionAlgorithm, SerializedMessage, Signature, SignatureAlgorithm, SignedObject, SignedPublicKey, SignedPublicKeyMessage, SigningKey, SigningNamespace, SymmetricCryptoKey, VerifyingKey, safe::IdentitySealedKeyEnvelope +}; use serde::{Deserialize, Serialize}; -const EXAMPLE_NAMESPACE: &SigningNamespace = &SigningNamespace::SignedPublicKey; +/// Represents a user's cryptographic identity +struct UserIdentity { + name: String, + signing_key: SigningKey, + verifying_key: VerifyingKey, + private_key: AsymmetricCryptoKey, + signed_public_key: SignedPublicKey, +} +/// Represents an organization's cryptographic identity and key material +struct OrganizationIdentity { + name: String, + signing_key: SigningKey, + verifying_key: VerifyingKey, + symmetric_key: SymmetricCryptoKey, +} + +/// A claim that an identity belongs to a specific identifier (e.g., email, organization name) #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct IdentityClaim { - identity_fingerprint: KeyFingerprint, + identity_fingerprint: bitwarden_crypto::KeyFingerprint, identifier: String, } +/// An agreement between a member and an organization, signed by both parties #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct MembershipAgreement { - member_identity: KeyFingerprint, - organization_identity: KeyFingerprint, + member_identity: bitwarden_crypto::KeyFingerprint, + organization_identity: bitwarden_crypto::KeyFingerprint, } -fn main() { - // Setup keys for both sides - // Alice - let alice_signature_key = - bitwarden_crypto::SigningKey::make(bitwarden_crypto::SignatureAlgorithm::Ed25519); - let alice_verifying_key = alice_signature_key.to_verifying_key(); - let alice_private_key = AsymmetricCryptoKey::make(PublicKeyEncryptionAlgorithm::RsaOaepSha1); - let signed_public_key = SignedPublicKeyMessage::from_public_key(&alice_private_key.to_public_key()).unwrap(); - let signed_public_key = signed_public_key.sign(&alice_signature_key).unwrap(); - // Admin - let admin_signature_key = - bitwarden_crypto::SigningKey::make(bitwarden_crypto::SignatureAlgorithm::Ed25519); - let admin_verifying_key = admin_signature_key.to_verifying_key(); - let org_symmetric_key = SymmetricCryptoKey::make_aes256_cbc_hmac_key(); - - - // Alice joins. This is Step 2 in the 3 step process +fn setup_user() -> UserIdentity { + let signing_key = SigningKey::make(SignatureAlgorithm::Ed25519); + let verifying_key = signing_key.to_verifying_key(); + let private_key = AsymmetricCryptoKey::make(PublicKeyEncryptionAlgorithm::RsaOaepSha1); + let signed_public_key = SignedPublicKeyMessage::from_public_key(&private_key.to_public_key()) + .expect("Failed to create signed public key message") + .sign(&signing_key) + .expect("Failed to sign public key"); + + UserIdentity { + name: "Alice".to_string(), + signing_key, + verifying_key, + private_key, + signed_public_key, + } +} + +fn setup_organization() -> OrganizationIdentity { + let signing_key = SigningKey::make(SignatureAlgorithm::Ed25519); + let verifying_key = signing_key.to_verifying_key(); + let symmetric_key = SymmetricCryptoKey::make_xchacha20_poly1305_key(); + + OrganizationIdentity { + name: "My Org Name".to_string(), + signing_key, + verifying_key, + symmetric_key, + } +} + +// placeholder for out-of-band fingerprint verification +fn prompt_user_to_verify_fingerprint( + _org: &OrganizationIdentity, +) -> bool { + return true; +} + +// placeholder for out-of-band fingerprint verification +fn prompt_organization_to_verify_fingerprint( + _member: &UserIdentity, +) -> bool { + return true; +} + +/// Step 2: User accepts the invite by signing an identity claim and receiving a membership agreement +/// NOTE: REQUIRES OUT-OF-BAND VERIFICATION OF THE ORGANIZATION'S IDENTITY FINGERPRINT +fn user_accepts_invite( + org: &OrganizationIdentity, + member: &UserIdentity, +) -> (Signature, SerializedMessage, SignedObject) { let identity_claim = IdentityClaim { - identity_fingerprint: admin_verifying_key.fingerprint(), - identifier: "organization_name".to_string(), + identity_fingerprint: org.verifying_key.fingerprint(), + identifier: org.name.to_string(), }; - alice_signature_key.sign(&identity_claim, &SigningNamespace::IdentityClaim).unwrap(); + + // Admin signs the identity claim to assert ownership + let signed_claim = member + .signing_key + .sign(&identity_claim, &SigningNamespace::IdentityClaim) + .expect("Failed to sign identity claim"); + let membership_agreement = MembershipAgreement { - member_identity: alice_verifying_key.fingerprint(), - organization_identity: admin_verifying_key.fingerprint(), + member_identity: member.verifying_key.fingerprint(), + organization_identity: org.verifying_key.fingerprint(), }; - let (signature, serialized_message) = admin_signature_key + + let (signature, serialized_message) = member + .signing_key .sign_detached(&membership_agreement, &SigningNamespace::MembershipAgreement) - .unwrap(); - // upload to server: serialized_message, signature + .expect("Failed to sign membership agreement"); + (signature, serialized_message, signed_claim) +} + +/// Step 3: Member verifies and counter-signs the membership agreement +/// NOTE: REQUIRES ADMIN TO FIRST CONFIRM THE MEMBERS NAME TO THE FINGERPRINT OUT-OF-BAND +fn admin_confirms_join( + member: &UserIdentity, + org: &OrganizationIdentity, + signature: &Signature, + serialized_message: &SerializedMessage, +) -> (Signature, IdentitySealedKeyEnvelope, SignedObject) { + // Verify admin's signature + assert!( + signature.verify( + serialized_message.as_bytes(), + &member.verifying_key, + &SigningNamespace::MembershipAgreement, + ), + "Failed to verify admin's membership signature" + ); - // Admin verifies - assert!(signature.verify( - &serialized_message.as_bytes(), - &admin_verifying_key, - &SigningNamespace::MembershipAgreement, - )); + let identity_claim = IdentityClaim { + identity_fingerprint: member.verifying_key.fingerprint(), + identifier: member.name.to_string(), + }; + let signed_member_claim = org + .signing_key + .sign(&identity_claim, &SigningNamespace::IdentityClaim) + .expect("Failed to sign member identity claim"); - let counter_signature = alice_signature_key + // Counter-sign to indicate acceptance + let counter_signature = org + .signing_key .counter_sign_detached( serialized_message.as_bytes().to_vec(), - &signature, + signature, &SigningNamespace::MembershipAgreement, ) - .unwrap(); - let identity_sealed_key_envelope = IdentitySealedKeyEnvelope::seal( - &admin_signature_key, - &alice_verifying_key, - &signed_public_key, - &org_symmetric_key, - ).unwrap(); - // upload to server: identity_sealed_key_envelope, counter_signature - - // To load a key, alice will have to verify that the membership agreement was signed by admin and her. - assert!(signature.verify( - &serialized_message.as_bytes(), - &admin_verifying_key, - &SigningNamespace::MembershipAgreement, - )); - assert!(counter_signature.verify( - &serialized_message.as_bytes(), - &alice_verifying_key, - &SigningNamespace::MembershipAgreement, - )); - let key = identity_sealed_key_envelope - .unseal(&admin_verifying_key, &alice_verifying_key, &alice_private_key) + .expect("Failed to counter-sign membership agreement"); + let envelope = IdentitySealedKeyEnvelope::seal( + &org.signing_key, + &member.verifying_key, + &member.signed_public_key, + &org.symmetric_key, + ) + .expect("Failed to seal organization key"); + (counter_signature, envelope, signed_member_claim) +} + +/// Step 5: Member loads the organization key by verifying all signatures +fn load_shared_vault_key( + member: &UserIdentity, + org: &OrganizationIdentity, + admin_signature: &Signature, + member_signature: &Signature, + serialized_message: &SerializedMessage, + envelope: &IdentitySealedKeyEnvelope, +) -> SymmetricCryptoKey { + // Verify both signatures on the membership agreement + assert!( + admin_signature.verify( + serialized_message.as_bytes(), + &org.verifying_key, + &SigningNamespace::MembershipAgreement, + ), + "Failed to verify admin's membership signature" + ); + assert!( + member_signature.verify( + serialized_message.as_bytes(), + &member.verifying_key, + &SigningNamespace::MembershipAgreement, + ), + "Failed to verify member's membership signature" + ); + + // Unseal the organization key + let key = envelope + .unseal( + &org.verifying_key, + &member.verifying_key, + &member.private_key, + ) .expect("Failed to unseal organization key"); - assert_eq!(key, org_symmetric_key); + key +} + +fn main() { + // Setup identities + let alice = setup_user(); + let org = setup_organization(); + + // Step 2: Alice accepts the invite + if !prompt_user_to_verify_fingerprint(&org) { + panic!("User did not verify organization fingerprint"); + } + let (alice_signature, serialized_message, _signed_org_claim) = user_accepts_invite(&org, &alice); + // upload: alice_signature, serialized_message, _signed_org_claim + + // Step 3: Admin confirms alice + if !prompt_organization_to_verify_fingerprint(&alice) { + panic!("Organization did not verify member fingerprint"); + } + let (admin_signature, envelope, _signed_member_claim) = admin_confirms_join(&alice, &org, &alice_signature, &serialized_message); + // upload: admin_signature, envelope, _signed_member_claim + + // Alice loads her vault, including the organization key + let loaded_vault_key = load_shared_vault_key( + &alice, + &org, + &admin_signature, + &alice_signature, + &serialized_message, + &envelope, + ); + assert_eq!( + org.symmetric_key, + loaded_vault_key, + "Loaded key does not match original organization key" + ); }