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/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/examples/organization_v2_protocol.rs b/crates/bitwarden-crypto/examples/organization_v2_protocol.rs new file mode 100644 index 000000000..bf8b1a736 --- /dev/null +++ b/crates/bitwarden-crypto/examples/organization_v2_protocol.rs @@ -0,0 +1,236 @@ +//! 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}; + +/// 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: 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: bitwarden_crypto::KeyFingerprint, + organization_identity: bitwarden_crypto::KeyFingerprint, +} + +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: org.verifying_key.fingerprint(), + identifier: org.name.to_string(), + }; + + // 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: member.verifying_key.fingerprint(), + organization_identity: org.verifying_key.fingerprint(), + }; + + let (signature, serialized_message) = member + .signing_key + .sign_detached(&membership_agreement, &SigningNamespace::MembershipAgreement) + .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" + ); + + 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"); + + // Counter-sign to indicate acceptance + let counter_signature = org + .signing_key + .counter_sign_detached( + serialized_message.as_bytes().to_vec(), + signature, + &SigningNamespace::MembershipAgreement, + ) + .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"); + 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" + ); +} diff --git a/crates/bitwarden-crypto/src/cose.rs b/crates/bitwarden-crypto/src/cose.rs index 957c9282c..92f9b496e 100644 --- a/crates/bitwarden-crypto/src/cose.rs +++ b/crates/bitwarden-crypto/src/cose.rs @@ -29,12 +29,14 @@ 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. 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/keys/asymmetric_crypto_key.rs b/crates/bitwarden-crypto/src/keys/asymmetric_crypto_key.rs index 75b1907c2..a0b8c9e2d 100644 --- a/crates/bitwarden-crypto/src/keys/asymmetric_crypto_key.rs +++ b/crates/bitwarden-crypto/src/keys/asymmetric_crypto_key.rs @@ -1,12 +1,14 @@ 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}, + traits::DeriveFingerprint, }; /// Algorithm / public key encryption scheme used for encryption/decryption. @@ -58,6 +60,37 @@ 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/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/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/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 new file mode 100644 index 000000000..681ac0730 --- /dev/null +++ b/crates/bitwarden-crypto/src/safe/identity_sealed_key_envelope.rs @@ -0,0 +1,706 @@ +//! 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. +//! +//! 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 +//! +//! 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 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, KeyFingerprint}, + xchacha20, +}; + +/// An identity-sealed key envelope that securely transports a symmetric key between +/// 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, +} + +/// Errors that can occur during identity sealed key envelope operations. +#[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 + #[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. + 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 + .to_owned() + .verify_and_unwrap(recipient_verifying_key) + .map_err(|_| IdentitySealedKeyEnvelopeError::RecipientPublicKeyVerificationFailed)?; + + 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 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, + )), + ), + }; + + // 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" + ), + } + 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 + }) + .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"), + } + }, + )? + .unprotected(coset::HeaderBuilder::new().iv(nonce).build()) + .build(); + + // 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. + /// 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 + let namespace = crate::signing::namespace(&self.cose_sign1.protected) + .map_err(|_| IdentitySealedKeyEnvelopeError::InvalidNamespace)?; + if namespace != SigningNamespace::IdentitySealedKeyEnvelope { + return Err(IdentitySealedKeyEnvelopeError::InvalidNamespace); + } + + self.cose_sign1 + .verify_signature(&[], |sig, data| sender_verifying_key.verify_raw(sig, data)) + .map_err(|_| IdentitySealedKeyEnvelopeError::SignatureVerificationFailed)?; + + // 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 + .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)?; + + // 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 + .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) + } +} + +// 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::*; + 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_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_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, + &recipient_verifying_key, + &wrong_recipient_private_key, + ); + assert!( + matches!( + result, + Err(IdentitySealedKeyEnvelopeError::RsaOperationFailed) + ), + "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/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::*; 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..c2ee303b0 100644 --- a/crates/bitwarden-crypto/src/signing/mod.rs +++ b/crates/bitwarden-crypto/src/signing/mod.rs @@ -28,6 +28,7 @@ //! then sign detached can be used. mod cose; +pub(crate) use cose::namespace; use cose::*; mod namespace; pub use namespace::SigningNamespace; diff --git a/crates/bitwarden-crypto/src/signing/namespace.rs b/crates/bitwarden-crypto/src/signing/namespace.rs index f2201a0ae..4b2303283 100644 --- a/crates/bitwarden-crypto/src/signing/namespace.rs +++ b/crates/bitwarden-crypto/src/signing/namespace.rs @@ -14,6 +14,12 @@ 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, + /// 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, @@ -36,6 +42,9 @@ impl TryFrom for SigningNamespace { match value { 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; } diff --git a/crates/bitwarden-crypto/src/signing/signing_key.rs b/crates/bitwarden-crypto/src/signing/signing_key.rs index 5c3a273ce..5f32b7611 100644 --- a/crates/bitwarden-crypto/src/signing/signing_key.rs +++ b/crates/bitwarden-crypto/src/signing/signing_key.rs @@ -17,6 +17,7 @@ use crate::{ cose::CoseSerializable, error::{EncodingError, Result}, keys::KeyId, + traits::KeyFingerprint, }; /// A `SigningKey` without the key id. This enum contains a variant for each supported signature @@ -30,7 +31,7 @@ enum RawSigningKey { /// derived from it. #[derive(Clone)] pub struct SigningKey { - pub(super) id: KeyId, + pub(crate) id: KeyId, inner: RawSigningKey, } @@ -57,7 +58,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 +78,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..3a4d41b9b 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::{ @@ -16,6 +17,7 @@ use crate::{ cose::CoseSerializable, error::{EncodingError, SignatureError}, keys::KeyId, + traits::{DeriveFingerprint, KeyFingerprint}, }; /// A `VerifyingKey` without the key id. This enum contains a variant for each supported signature @@ -43,7 +45,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( @@ -58,6 +60,26 @@ 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..105ad1190 --- /dev/null +++ b/crates/bitwarden-crypto/src/traits/key_fingerprint.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; +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. +#[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 +/// 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 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,