diff --git a/crates/bitwarden-core/src/key_management/mod.rs b/crates/bitwarden-core/src/key_management/mod.rs index 10be377a8..9a30bdde9 100644 --- a/crates/bitwarden-core/src/key_management/mod.rs +++ b/crates/bitwarden-core/src/key_management/mod.rs @@ -22,6 +22,9 @@ pub use crypto_client::CryptoClient; mod security_state; #[cfg(feature = "internal")] pub use security_state::{SecurityState, SignedSecurityState}; +/// A set of type-safe wrappers that can be exposed via FFI +#[cfg(feature = "internal")] +pub mod wrappers; key_ids! { #[symmetric] diff --git a/crates/bitwarden-core/src/key_management/wrappers.rs b/crates/bitwarden-core/src/key_management/wrappers.rs new file mode 100644 index 000000000..ca7a67584 --- /dev/null +++ b/crates/bitwarden-core/src/key_management/wrappers.rs @@ -0,0 +1,48 @@ +use std::ops::Deref; + +use serde::{Deserialize, Serialize}; +use tsify::Tsify; + +use crate::key_management::KeyIds; + +/// A non-generic wrapper around `bitwarden_crypto::safe::DataEnvelope` that can be used in FFI +/// contexts. +#[derive(Serialize, Deserialize)] +#[serde(transparent)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +pub struct DataEnvelope( + #[cfg_attr(feature = "wasm", tsify(type = r#"Tagged"#))] + bitwarden_crypto::safe::DataEnvelope, +); + +impl From for bitwarden_crypto::safe::DataEnvelope { + fn from(val: DataEnvelope) -> Self { + val.0 + } +} + +impl From> for DataEnvelope { + fn from(val: bitwarden_crypto::safe::DataEnvelope) -> Self { + DataEnvelope(val) + } +} + +impl Deref for DataEnvelope { + type Target = bitwarden_crypto::safe::DataEnvelope; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::fmt::Debug for DataEnvelope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl Clone for DataEnvelope { + fn clone(&self) -> Self { + DataEnvelope(self.0.clone()) + } +} diff --git a/crates/bitwarden-core/src/uniffi_support.rs b/crates/bitwarden-core/src/uniffi_support.rs index 059c7510e..68b909364 100644 --- a/crates/bitwarden-core/src/uniffi_support.rs +++ b/crates/bitwarden-core/src/uniffi_support.rs @@ -1,11 +1,11 @@ //! This module contains custom type converters for Uniffi. -use std::num::NonZeroU32; +use std::{num::NonZeroU32, str::FromStr}; use bitwarden_crypto::CryptoError; use uuid::Uuid; -use crate::key_management::SignedSecurityState; +use crate::key_management::{wrappers::DataEnvelope, SignedSecurityState}; uniffi::use_remote_type!(bitwarden_crypto::NonZeroU32); @@ -35,3 +35,10 @@ uniffi::custom_type!(SignedSecurityState, String, { }, lower: |obj| obj.into(), }); + +uniffi::custom_type!(DataEnvelope, String, { + try_lift: |val| bitwarden_crypto::safe::DataEnvelope::from_str(val.as_str()) + .map_err(|e| e.into()) + .map(|envelope| DataEnvelope::from(envelope)), + lower: |obj| obj.to_string(), +}); diff --git a/crates/bitwarden-crypto/examples/seal_struct.rs b/crates/bitwarden-crypto/examples/seal_struct.rs new file mode 100644 index 000000000..09ddbd1d8 --- /dev/null +++ b/crates/bitwarden-crypto/examples/seal_struct.rs @@ -0,0 +1,96 @@ +//! This example demonstrates how to seal a piece of data. +//! +//! If there is a struct that should be kept secret, in can be sealed with a `DataEnvelope`. This +//! will automatically create a content-encryption-key. This is useful because the key is stored +//! separately. Rotating the encrypting key now only requires re-uploading the +//! content-encryption-key instead of the entire data. Further, server-side tampering (swapping of +//! individual fields encrypted by the same key) is prevented. +//! +//! In general, if a struct of data should be protected, the `DataEnvelope` should be used. + +use bitwarden_crypto::{key_ids, safe::SealableData}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +struct MyItem { + a: u64, + b: String, +} +impl SealableData for MyItem {} + +fn main() { + let store = bitwarden_crypto::KeyStore::::default(); + let mut ctx: bitwarden_crypto::KeyStoreContext<'_, ExampleIds> = store.context_mut(); + let mut disk = MockDisk::new(); + + let my_item = MyItem { + a: 42, + b: "Hello, World!".to_string(), + }; + // Seal the item into an encrypted blob, and store the content-encryption-key in the context. + let sealed_item = bitwarden_crypto::safe::DataEnvelope::::seal( + my_item, + &bitwarden_crypto::safe::DataEnvelopeNamespace::VaultItem, + ExampleSymmetricKey::ItemKey, + &mut ctx, + ) + .expect("Sealing should work"); + + // Store the sealed item on disk + disk.save("sealed_item", (&sealed_item).into()); + let sealed_item = disk + .load("sealed_item") + .expect("Failed to load sealed item") + .clone(); + let sealed_item: bitwarden_crypto::safe::DataEnvelope = + bitwarden_crypto::safe::DataEnvelope::from(sealed_item); + + let my_item: MyItem = sealed_item + .unseal( + &bitwarden_crypto::safe::DataEnvelopeNamespace::VaultItem, + ExampleSymmetricKey::ItemKey, + &mut ctx, + ) + .expect("Unsealing should work"); + assert!(my_item.a == 42); + assert!(my_item.b == "Hello, World!"); +} + +pub(crate) struct MockDisk { + map: std::collections::HashMap>, +} + +impl MockDisk { + pub(crate) fn new() -> Self { + MockDisk { + map: std::collections::HashMap::new(), + } + } + + pub(crate) fn save(&mut self, key: &str, value: Vec) { + self.map.insert(key.to_string(), value); + } + + pub(crate) fn load(&self, key: &str) -> Option<&Vec> { + self.map.get(key) + } +} + +key_ids! { + #[symmetric] + pub enum ExampleSymmetricKey { + #[local] + ItemKey + } + + #[asymmetric] + pub enum ExampleAsymmetricKey { + Key(u8), + } + + #[signing] + pub enum ExampleSigningKey { + Key(u8), + } + pub ExampleIds => ExampleSymmetricKey, ExampleAsymmetricKey, ExampleSigningKey; +} diff --git a/crates/bitwarden-crypto/src/content_format.rs b/crates/bitwarden-crypto/src/content_format.rs index 1f44dc65c..7afe42f98 100644 --- a/crates/bitwarden-crypto/src/content_format.rs +++ b/crates/bitwarden-crypto/src/content_format.rs @@ -24,6 +24,8 @@ pub(crate) enum ContentFormat { CoseKey, /// CoseSign1 message CoseSign1, + /// CoseEncrypt0 message + CoseEncrypt0, /// Bitwarden Legacy Key /// There are three permissible byte values here: /// - `[u8; 32]` - AES-CBC (no hmac) key. This is to be removed and banned. @@ -32,6 +34,8 @@ pub(crate) enum ContentFormat { BitwardenLegacyKey, /// Stream of bytes OctetStream, + /// Cbor serialized data + Cbor, } mod private { @@ -191,6 +195,34 @@ impl CoseContentFormat for CoseSign1ContentFormat {} /// serialized COSE Sign1 messages. pub type CoseSign1Bytes = Bytes; +/// CBOR serialized data +#[derive(PartialEq, Eq, Clone, Debug)] +pub struct CborContentFormat; +impl private::Sealed for CborContentFormat {} +impl ConstContentFormat for CborContentFormat { + #[allow(private_interfaces)] + fn content_format() -> ContentFormat { + ContentFormat::Cbor + } +} +/// CborBytes is a type alias for Bytes with `CborContentFormat`. This is used for CBOR serialized +/// data. +pub type CborBytes = Bytes; + +/// Content format for COSE Encrypt0 messages. +#[derive(PartialEq, Eq, Clone, Debug)] +pub struct CoseEncrypt0ContentFormat; +impl private::Sealed for CoseEncrypt0ContentFormat {} +impl ConstContentFormat for CoseEncrypt0ContentFormat { + #[allow(private_interfaces)] + fn content_format() -> ContentFormat { + ContentFormat::CoseEncrypt0 + } +} +/// CoseEncrypt0Bytes is a type alias for Bytes with `CoseEncrypt0ContentFormat`. This is used for +/// serialized COSE Encrypt0 messages. +pub type CoseEncrypt0Bytes = Bytes; + impl PrimitiveEncryptable for Bytes { diff --git a/crates/bitwarden-crypto/src/cose.rs b/crates/bitwarden-crypto/src/cose.rs index b2a39ab56..54aea9d2a 100644 --- a/crates/bitwarden-crypto/src/cose.rs +++ b/crates/bitwarden-crypto/src/cose.rs @@ -13,7 +13,8 @@ use typenum::U32; use crate::{ content_format::{Bytes, ConstContentFormat, CoseContentFormat}, error::{EncStringParseError, EncodingError}, - xchacha20, ContentFormat, CryptoError, SymmetricCryptoKey, XChaCha20Poly1305Key, + xchacha20, ContentFormat, CoseEncrypt0Bytes, CryptoError, SymmetricCryptoKey, + XChaCha20Poly1305Key, }; /// XChaCha20 is used over ChaCha20 @@ -32,13 +33,15 @@ const CONTENT_TYPE_SPKI_PUBLIC_KEY: &str = "application/x.bitwarden.spki-public- // /// The label used for the namespace ensuring strong domain separation when using signatures. pub(crate) const SIGNING_NAMESPACE: i64 = -80000; +/// The label used for the namespace ensuring strong domain separation when using data envelopes. +pub(crate) const DATA_ENVELOPE_NAMESPACE: i64 = -80001; /// Encrypts a plaintext message using XChaCha20Poly1305 and returns a COSE Encrypt0 message pub(crate) fn encrypt_xchacha20_poly1305( plaintext: &[u8], key: &crate::XChaCha20Poly1305Key, content_format: ContentFormat, -) -> Result, CryptoError> { +) -> Result { let mut plaintext = plaintext.to_vec(); let header_builder: coset::HeaderBuilder = content_format.into(); @@ -68,14 +71,15 @@ pub(crate) fn encrypt_xchacha20_poly1305( cose_encrypt0 .to_vec() .map_err(|err| CryptoError::EncString(EncStringParseError::InvalidCoseEncoding(err))) + .map(CoseEncrypt0Bytes::from) } /// Decrypts a COSE Encrypt0 message, using a XChaCha20Poly1305 key pub(crate) fn decrypt_xchacha20_poly1305( - cose_encrypt0_message: &[u8], + cose_encrypt0_message: &CoseEncrypt0Bytes, key: &crate::XChaCha20Poly1305Key, ) -> Result<(Vec, ContentFormat), CryptoError> { - let msg = coset::CoseEncrypt0::from_slice(cose_encrypt0_message) + let msg = coset::CoseEncrypt0::from_slice(cose_encrypt0_message.as_ref()) .map_err(|err| CryptoError::EncString(EncStringParseError::InvalidCoseEncoding(err)))?; let Some(ref alg) = msg.protected.header.alg else { @@ -170,12 +174,16 @@ impl From for coset::HeaderBuilder { } ContentFormat::CoseSign1 => header_builder.content_format(CoapContentFormat::CoseSign1), ContentFormat::CoseKey => header_builder.content_format(CoapContentFormat::CoseKey), + ContentFormat::CoseEncrypt0 => { + header_builder.content_format(CoapContentFormat::CoseEncrypt0) + } ContentFormat::BitwardenLegacyKey => { header_builder.content_type(CONTENT_TYPE_BITWARDEN_LEGACY_KEY.to_string()) } ContentFormat::OctetStream => { header_builder.content_format(CoapContentFormat::OctetStream) } + ContentFormat::Cbor => header_builder.content_format(CoapContentFormat::Cbor), } } } @@ -201,6 +209,7 @@ impl TryFrom<&coset::Header> for ContentFormat { Some(ContentType::Assigned(CoapContentFormat::OctetStream)) => { Ok(ContentFormat::OctetStream) } + Some(ContentType::Assigned(CoapContentFormat::Cbor)) => Ok(ContentFormat::Cbor), _ => Err(CryptoError::EncString( EncStringParseError::CoseMissingContentType, )), @@ -307,7 +316,9 @@ mod test { key_id: KEY_ID, enc_key: Box::pin(*GenericArray::from_slice(&KEY_DATA)), }; - let decrypted = decrypt_xchacha20_poly1305(TEST_VECTOR_COSE_ENCRYPT0, &key).unwrap(); + let decrypted = + decrypt_xchacha20_poly1305(&CoseEncrypt0Bytes::from(TEST_VECTOR_COSE_ENCRYPT0), &key) + .unwrap(); assert_eq!( decrypted, (TEST_VECTOR_PLAINTEXT.to_vec(), ContentFormat::OctetStream) @@ -321,7 +332,7 @@ mod test { enc_key: Box::pin(*GenericArray::from_slice(&KEY_DATA)), }; assert!(matches!( - decrypt_xchacha20_poly1305(TEST_VECTOR_COSE_ENCRYPT0, &key), + decrypt_xchacha20_poly1305(&CoseEncrypt0Bytes::from(TEST_VECTOR_COSE_ENCRYPT0), &key), Err(CryptoError::WrongCoseKeyId) )); } @@ -338,7 +349,7 @@ mod test { .create_ciphertext(&[], &[], |_, _| Vec::new()) .unprotected(coset::HeaderBuilder::new().iv(nonce.to_vec()).build()) .build(); - let serialized_message = cose_encrypt0.to_vec().unwrap(); + let serialized_message = CoseEncrypt0Bytes::from(cose_encrypt0.to_vec().unwrap()); let key = XChaCha20Poly1305Key { key_id: KEY_ID, diff --git a/crates/bitwarden-crypto/src/enc_string/symmetric.rs b/crates/bitwarden-crypto/src/enc_string/symmetric.rs index 7bf1debd5..9740ef093 100644 --- a/crates/bitwarden-crypto/src/enc_string/symmetric.rs +++ b/crates/bitwarden-crypto/src/enc_string/symmetric.rs @@ -8,8 +8,8 @@ use super::{check_length, from_b64, from_b64_vec, split_enc_string}; use crate::{ error::{CryptoError, EncStringParseError, Result, UnsupportedOperation}, util::FromStrVisitor, - Aes256CbcHmacKey, ContentFormat, KeyDecryptable, KeyEncryptable, KeyEncryptableWithContentType, - SymmetricCryptoKey, Utf8Bytes, XChaCha20Poly1305Key, + Aes256CbcHmacKey, ContentFormat, CoseEncrypt0Bytes, KeyDecryptable, KeyEncryptable, + KeyEncryptableWithContentType, SymmetricCryptoKey, Utf8Bytes, XChaCha20Poly1305Key, }; #[cfg(feature = "wasm")] @@ -266,7 +266,9 @@ impl EncString { content_format: ContentFormat, ) -> Result { let data = crate::cose::encrypt_xchacha20_poly1305(data_dec, key, content_format)?; - Ok(EncString::Cose_Encrypt0_B64 { data }) + Ok(EncString::Cose_Encrypt0_B64 { + data: data.to_vec(), + }) } /// The numerical representation of the encryption type of the [EncString]. @@ -311,8 +313,10 @@ impl KeyDecryptable> for EncString { EncString::Cose_Encrypt0_B64 { data }, SymmetricCryptoKey::XChaCha20Poly1305Key(key), ) => { - let (decrypted_message, _) = - crate::cose::decrypt_xchacha20_poly1305(data.as_slice(), key)?; + let (decrypted_message, _) = crate::cose::decrypt_xchacha20_poly1305( + &CoseEncrypt0Bytes::from(data.as_slice()), + key, + )?; Ok(decrypted_message) } _ => Err(CryptoError::WrongKeyType), diff --git a/crates/bitwarden-crypto/src/error.rs b/crates/bitwarden-crypto/src/error.rs index f572cec94..34b632b17 100644 --- a/crates/bitwarden-crypto/src/error.rs +++ b/crates/bitwarden-crypto/src/error.rs @@ -4,7 +4,7 @@ use bitwarden_error::bitwarden_error; use thiserror::Error; use uuid::Uuid; -use crate::fingerprint::FingerprintError; +use crate::{fingerprint::FingerprintError, safe::DataEnvelopeError}; #[allow(missing_docs)] #[bitwarden_error(flat)] @@ -67,6 +67,9 @@ pub enum CryptoError { #[error("Signature error, {0}")] SignatureError(#[from] SignatureError), + #[error("DataEnvelope error, {0}")] + DataEnvelopeError(#[from] DataEnvelopeError), + #[error("Encoding error, {0}")] EncodingError(#[from] EncodingError), } diff --git a/crates/bitwarden-crypto/src/lib.rs b/crates/bitwarden-crypto/src/lib.rs index e5e86891b..1d8efa7ee 100644 --- a/crates/bitwarden-crypto/src/lib.rs +++ b/crates/bitwarden-crypto/src/lib.rs @@ -36,6 +36,8 @@ pub use store::{ }; mod cose; pub use cose::CoseSerializable; +/// A set of safe-by-default cryptographic primitives +pub mod safe; mod signing; pub use signing::*; mod traits; diff --git a/crates/bitwarden-crypto/src/safe/data_envelope.rs b/crates/bitwarden-crypto/src/safe/data_envelope.rs new file mode 100644 index 000000000..5468fc1c3 --- /dev/null +++ b/crates/bitwarden-crypto/src/safe/data_envelope.rs @@ -0,0 +1,483 @@ +use std::{marker::PhantomData, str::FromStr}; + +use base64::{engine::general_purpose::STANDARD, Engine}; +use ciborium::value::Integer; +#[allow(unused_imports)] +use coset::{iana::CoapContentFormat, CborSerializable, ProtectedHeader, RegisteredLabel}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ + cose::{DATA_ENVELOPE_NAMESPACE, XCHACHA20_POLY1305}, + error::EncStringParseError, + safe::DataEnvelopeNamespace, + CoseEncrypt0Bytes, CryptoError, FromStrVisitor, KeyIds, SerializedMessage, SymmetricCryptoKey, + XChaCha20Poly1305Key, +}; + +use crate::xchacha20; + +/// Marker trait for data that can be sealed in a `DataEnvelope`. +pub trait SealableData {} + +/// `DataEnvelope` allows sealing structs entire structs to encrypted blobs. +/// +/// Sealing a struct results in an encrypted blob, and a content-encryption-key. The +/// content-encryption-key must be provided again when unsealing the data. A content encryption key +/// allows easy key-rotation of the encrypting-key, as now just the content-encryption-keys need to +/// be re-uploaded, instead of all data. +pub struct DataEnvelope { + envelope_data: CoseEncrypt0Bytes, + _phantom: PhantomData, +} + +impl Clone for DataEnvelope { + fn clone(&self) -> Self { + DataEnvelope { + envelope_data: self.envelope_data.clone(), + _phantom: PhantomData, + } + } +} + +impl DataEnvelope { + /// Seals a struct into an encrypted blob, and stores the content-encryption-key in the provided + /// context. + pub fn seal( + data: T, + namespace: &DataEnvelopeNamespace, + cek_keyslot: Ids::Symmetric, + ctx: &mut crate::store::KeyStoreContext, + ) -> Result + where + T: Serialize + SealableData, + { + let (envelope, cek) = Self::seal_ref(&data, namespace)?; + #[allow(deprecated)] + ctx.set_symmetric_key(cek_keyslot, SymmetricCryptoKey::XChaCha20Poly1305Key(cek)) + .map_err(|_| DataEnvelopeError::KeyStoreError("Failed to set symmetric key".into()))?; + Ok(envelope) + } + + /// Seals a struct into an encrypted blob, and returns the encrypted blob and the + /// content-encryption-key. + fn seal_ref( + data: &T, + namespace: &DataEnvelopeNamespace, + ) -> Result<(DataEnvelope, XChaCha20Poly1305Key), DataEnvelopeError> + where + T: Serialize + SealableData, + { + let cek = SymmetricCryptoKey::make_xchacha20_poly1305_key(); + let cek = match cek { + SymmetricCryptoKey::XChaCha20Poly1305Key(ref key) => key, + _ => return Err(DataEnvelopeError::UnsupportedContentFormat), + }; + + let serialized_message = SerializedMessage::encode(&data).map_err(|e| { + DataEnvelopeError::EncodingError(format!("Failed to encode serialized message: {}", e)) + })?; + + let mut a = coset::HeaderBuilder::new() + .key_id(cek.key_id.to_vec()) + .content_format(serialized_message.content_type()) + .value( + DATA_ENVELOPE_NAMESPACE, + ciborium::Value::Integer(Integer::from(namespace.as_i64())), + ) + .build(); + a.alg = Some(coset::Algorithm::PrivateUse(XCHACHA20_POLY1305)); + + let mut nonce = [0u8; xchacha20::NONCE_SIZE]; + let encrypt0 = coset::CoseEncrypt0Builder::new() + .protected(a) + .create_ciphertext(&serialized_message.as_bytes(), &[], |data, aad| { + let ciphertext = + crate::xchacha20::encrypt_xchacha20_poly1305(&(*cek.enc_key).into(), data, aad); + nonce = ciphertext.nonce(); + ciphertext.encrypted_bytes().to_vec() + }) + .unprotected(coset::HeaderBuilder::new().iv(nonce.to_vec()).build()) + .build(); + + let envelope_data = encrypt0 + .to_vec() + .map_err(|err| CryptoError::EncString(EncStringParseError::InvalidCoseEncoding(err))) + .map(CoseEncrypt0Bytes::from) + .map_err(|_| { + DataEnvelopeError::EncodingError("Failed to encode COSE Encrypt0".into()) + })?; + + Ok(( + DataEnvelope { + envelope_data, + _phantom: PhantomData, + }, + cek.clone(), + )) + } + + /// Unseals the data from the encrypted blob using a content-encryption-key stored in the + /// context. + pub fn unseal( + &self, + namespace: &DataEnvelopeNamespace, + cek_keyslot: Ids::Symmetric, + ctx: &mut crate::store::KeyStoreContext, + ) -> Result + where + T: DeserializeOwned + SealableData, + { + #[allow(deprecated)] + let cek = ctx + .dangerous_get_symmetric_key(cek_keyslot) + .map_err(|_| DataEnvelopeError::KeyStoreError("Failed to get symmetric key".into()))?; + + match cek { + SymmetricCryptoKey::XChaCha20Poly1305Key(ref key) => self.unseal_ref(namespace, key), + _ => Err(DataEnvelopeError::UnsupportedContentFormat), + } + } + + /// Unseals the data from the encrypted blob using the provided content-encryption-key. + fn unseal_ref( + &self, + namespace: &DataEnvelopeNamespace, + cek: &XChaCha20Poly1305Key, + ) -> Result + where + T: DeserializeOwned + SealableData, + { + let msg = coset::CoseEncrypt0::from_slice(self.envelope_data.as_ref()).map_err(|err| { + DataEnvelopeError::DecodingError(format!( + "Failed to decode COSE Encrypt0 message: {}", + err + )) + })?; + + let Some(ref alg) = msg.protected.header.alg else { + return Err(DataEnvelopeError::DecryptionError); + }; + + if *alg != coset::Algorithm::PrivateUse(XCHACHA20_POLY1305) { + return Err(DataEnvelopeError::DecryptionError); + } + + if cek.key_id != *msg.protected.header.key_id { + return Err(DataEnvelopeError::DecryptionError); + } + + // Validate namespace + let envelope_namespace = extract_namespace(&msg.protected.header)?; + if envelope_namespace != *namespace { + return Err(DataEnvelopeError::InvalidNamespace); + } + + let decrypted_message = msg + .decrypt(&[], |data, aad| { + let nonce = msg.unprotected.iv.as_slice(); + crate::xchacha20::decrypt_xchacha20_poly1305( + nonce + .try_into() + .map_err(|_| DataEnvelopeError::DecryptionError)?, + &(*cek.enc_key).into(), + data, + aad, + ) + }) + .map_err(|_| DataEnvelopeError::DecryptionError)?; + + let content_type = content_type(&msg.protected).unwrap(); + let serialized_message = SerializedMessage::from_bytes(decrypted_message, content_type); + let res = serialized_message.decode().map_err(|_| { + DataEnvelopeError::DecodingError("Failed to decode serialized message".into()) + }); + return res; + } +} + +/// Helper function to extract the namespace from a `ProtectedHeader`. The namespace is stored +/// as a custom header value using the DATA_ENVELOPE_NAMESPACE label. +fn extract_namespace(header: &coset::Header) -> Result { + let namespace_value = header + .rest + .iter() + .find(|(label, _)| { + if let coset::Label::Int(label_int) = label { + *label_int == DATA_ENVELOPE_NAMESPACE + } else { + false + } + }) + .map(|(_, value)| value) + .ok_or(DataEnvelopeError::InvalidNamespace)?; + + let namespace_int = match namespace_value { + ciborium::Value::Integer(int) => { + let int_val: i128 = (*int).into(); + int_val + } + _ => return Err(DataEnvelopeError::InvalidNamespace), + }; + + DataEnvelopeNamespace::try_from(namespace_int).map_err(|_| DataEnvelopeError::InvalidNamespace) +} + +/// Helper function to extract the content type from a `ProtectedHeader`. The content type is a +/// standardized header set on the protected headers of the signature object. Currently we only +/// support registered values, but PrivateUse values are also allowed in the COSE specification. +pub(super) fn content_type( + protected_header: &ProtectedHeader, +) -> Result { + protected_header + .header + .content_type + .as_ref() + .and_then(|ct| match ct { + RegisteredLabel::Assigned(content_format) => Some(*content_format), + _ => None, + }) + .ok_or_else(|| DataEnvelopeError::DecryptionError.into()) +} + +impl From<&DataEnvelope> for Vec { + fn from(val: &DataEnvelope) -> Self { + val.envelope_data.to_vec() + } +} + +impl From> for DataEnvelope { + fn from(data: Vec) -> Self { + DataEnvelope { + envelope_data: CoseEncrypt0Bytes::from(data), + _phantom: PhantomData, + } + } +} + +impl std::fmt::Debug for DataEnvelope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DataEnvelope") + .field("envelope_data", &self.envelope_data) + .finish() + } +} + +impl FromStr for DataEnvelope { + type Err = DataEnvelopeError; + + fn from_str(s: &str) -> Result { + let data = STANDARD.decode(s).map_err(|_| { + DataEnvelopeError::ParsingError("Invalid DataEnvelope Base64 encoding".to_string()) + })?; + Self::try_from(data).map_err(|_| { + DataEnvelopeError::ParsingError("Failed to parse DataEnvelope".to_string()) + }) + } +} + +impl From> for String { + fn from(val: DataEnvelope) -> Self { + let serialized: Vec = (&val).into(); + STANDARD.encode(serialized) + } +} + +impl<'de, Ids: KeyIds> Deserialize<'de> for DataEnvelope { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_str(FromStrVisitor::new()) + } +} + +impl Serialize for DataEnvelope { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let serialized: Vec = self.into(); + serializer.serialize_str(&STANDARD.encode(serialized)) + } +} + +impl std::fmt::Display for DataEnvelope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let serialized: Vec = self.into(); + write!(f, "{}", STANDARD.encode(serialized)) + } +} + +/// Error type for `DataEnvelope` operations. +#[derive(Debug, Error)] +pub enum DataEnvelopeError { + /// Indicates that the content format is not supported. + #[error("Unsupported content format")] + UnsupportedContentFormat, + /// Indicates that there was an error during decoding of the message. + #[error("Decoding error: {0}")] + DecodingError(String), + /// Indicates that there was an error during encoding of the message. + #[error("Encoding error: {0}")] + EncodingError(String), + /// Indicates that there was an error with the key store. + #[error("KeyStore error: {0}")] + KeyStoreError(String), + /// Indicates that there was an error during decryption. + #[error("Decryption error")] + DecryptionError, + /// Indicates that there was an error during encryption. + #[error("Encryption error")] + EncryptionError, + /// Indicates that there was an error parsing the DataEnvelope. + #[error("Parsing error: {0}")] + ParsingError(String), + /// Indicates that the data envelope namespace is invalid. + #[error("Invalid namespace")] + InvalidNamespace, +} + +#[cfg(test)] +mod tests { + use serde::Deserialize; + + use super::*; + use crate::traits::tests::TestIds; + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestData { + field2: u32, + } + impl SealableData for TestData {} + + #[test] + fn test_data_envelope() { + // Create an instance of TestData + let data = TestData { field2: 42 }; + + // Seal the data + let (envelope, cek) = + DataEnvelope::::seal_ref(&data, &DataEnvelopeNamespace::ExampleNamespace) + .unwrap(); + let unsealed_data: TestData = envelope + .unseal_ref(&DataEnvelopeNamespace::ExampleNamespace, &cek) + .unwrap(); + + // Verify that the unsealed data matches the original data + assert_eq!(unsealed_data, data); + } + + #[test] + fn test_namespace_validation_success() { + let data = TestData { field2: 123 }; + + // Test with ExampleNamespace + let (envelope1, cek1) = + DataEnvelope::::seal_ref(&data, &DataEnvelopeNamespace::ExampleNamespace) + .unwrap(); + let unsealed_data1: TestData = envelope1 + .unseal_ref(&DataEnvelopeNamespace::ExampleNamespace, &cek1) + .unwrap(); + assert_eq!(unsealed_data1, data); + + // Test with ExampleNamespace2 + let (envelope2, cek2) = + DataEnvelope::::seal_ref(&data, &DataEnvelopeNamespace::ExampleNamespace2) + .unwrap(); + let unsealed_data2: TestData = envelope2 + .unseal_ref(&DataEnvelopeNamespace::ExampleNamespace2, &cek2) + .unwrap(); + assert_eq!(unsealed_data2, data); + } + + #[test] + fn test_namespace_validation_failure() { + let data = TestData { field2: 456 }; + + // Seal with ExampleNamespace + let (envelope, cek) = + DataEnvelope::::seal_ref(&data, &DataEnvelopeNamespace::ExampleNamespace) + .unwrap(); + + // Try to unseal with wrong namespace - should fail + let result: Result = + envelope.unseal_ref(&DataEnvelopeNamespace::ExampleNamespace2, &cek); + assert!(matches!(result, Err(DataEnvelopeError::InvalidNamespace))); + + // Verify correct namespace still works + let unsealed_data: TestData = envelope + .unseal_ref(&DataEnvelopeNamespace::ExampleNamespace, &cek) + .unwrap(); + assert_eq!(unsealed_data, data); + } + + #[test] + fn test_namespace_validation_with_keystore() { + let data = TestData { field2: 789 }; + let key_store = crate::store::KeyStore::::default(); + let mut ctx = key_store.context_mut(); + + // Seal with keystore using ExampleNamespace + let envelope = DataEnvelope::seal( + data, + &DataEnvelopeNamespace::ExampleNamespace, + crate::traits::tests::TestSymmKey::A(0), + &mut ctx, + ) + .unwrap(); + + // Try to unseal with wrong namespace - should fail + let result: Result = envelope.unseal( + &DataEnvelopeNamespace::ExampleNamespace2, + crate::traits::tests::TestSymmKey::A(0), + &mut ctx, + ); + assert!(matches!(result, Err(DataEnvelopeError::InvalidNamespace))); + + // Unseal with correct namespace - should succeed + let unsealed_data: TestData = envelope + .unseal( + &DataEnvelopeNamespace::ExampleNamespace, + crate::traits::tests::TestSymmKey::A(0), + &mut ctx, + ) + .unwrap(); + assert_eq!(unsealed_data.field2, 789); + } + + #[test] + fn test_namespace_cross_contamination_protection() { + let data1 = TestData { field2: 111 }; + let data2 = TestData { field2: 222 }; + + // Seal two different pieces of data with different namespaces + let (envelope1, cek1) = + DataEnvelope::::seal_ref(&data1, &DataEnvelopeNamespace::ExampleNamespace) + .unwrap(); + let (envelope2, cek2) = + DataEnvelope::::seal_ref(&data2, &DataEnvelopeNamespace::ExampleNamespace2) + .unwrap(); + + // Verify each envelope only opens with its correct namespace + let unsealed1: TestData = envelope1 + .unseal_ref(&DataEnvelopeNamespace::ExampleNamespace, &cek1) + .unwrap(); + assert_eq!(unsealed1, data1); + + let unsealed2: TestData = envelope2 + .unseal_ref(&DataEnvelopeNamespace::ExampleNamespace2, &cek2) + .unwrap(); + assert_eq!(unsealed2, data2); + + // Cross-unsealing should fail + assert!(matches!( + envelope1.unseal_ref::(&DataEnvelopeNamespace::ExampleNamespace2, &cek1), + Err(DataEnvelopeError::InvalidNamespace) + )); + assert!(matches!( + envelope2.unseal_ref::(&DataEnvelopeNamespace::ExampleNamespace, &cek2), + Err(DataEnvelopeError::InvalidNamespace) + )); + } +} diff --git a/crates/bitwarden-crypto/src/safe/data_envelope_namespace.rs b/crates/bitwarden-crypto/src/safe/data_envelope_namespace.rs new file mode 100644 index 000000000..daeb6c9d9 --- /dev/null +++ b/crates/bitwarden-crypto/src/safe/data_envelope_namespace.rs @@ -0,0 +1,53 @@ +use crate::{safe::DataEnvelopeError, CryptoError}; + +/// Data envelopes are domain-separated within bitwarden, to prevent cross protocol attacks. +/// +/// A new struct shall use a new data envelope namespace. Generally, this means +/// that a data envelope namespace has exactly one associated valid message struct. +/// +/// If there is a new version of a message added, it should (generally) use a new namespace, since +/// this prevents downgrades to the old type of message, and makes optional fields unnecessary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DataEnvelopeNamespace { + /// The namespace for vault items + VaultItem = 1, + /// This namespace is only used in tests + #[cfg(test)] + ExampleNamespace = -1, + /// This namespace is only used in tests + #[cfg(test)] + ExampleNamespace2 = -2, +} + +impl DataEnvelopeNamespace { + /// Returns the numeric value of the namespace. + pub fn as_i64(&self) -> i64 { + *self as i64 + } +} + +impl TryFrom for DataEnvelopeNamespace { + type Error = CryptoError; + + fn try_from(value: i64) -> Result { + match value { + 1 => Ok(DataEnvelopeNamespace::VaultItem), + #[cfg(test)] + -1 => Ok(DataEnvelopeNamespace::ExampleNamespace), + #[cfg(test)] + -2 => Ok(DataEnvelopeNamespace::ExampleNamespace2), + _ => Err(DataEnvelopeError::InvalidNamespace.into()), + } + } +} + +impl TryFrom for DataEnvelopeNamespace { + type Error = CryptoError; + + fn try_from(value: i128) -> Result { + let Ok(value) = i64::try_from(value) else { + return Err(DataEnvelopeError::InvalidNamespace.into()); + }; + Self::try_from(value) + } +} diff --git a/crates/bitwarden-crypto/src/safe/mod.rs b/crates/bitwarden-crypto/src/safe/mod.rs new file mode 100644 index 000000000..183b51ac8 --- /dev/null +++ b/crates/bitwarden-crypto/src/safe/mod.rs @@ -0,0 +1,4 @@ +mod data_envelope; +pub use data_envelope::*; +mod data_envelope_namespace; +pub use data_envelope_namespace::DataEnvelopeNamespace; diff --git a/crates/bitwarden-crypto/src/signing/message.rs b/crates/bitwarden-crypto/src/signing/message.rs index 6556a7a67..fa0d9338c 100644 --- a/crates/bitwarden-crypto/src/signing/message.rs +++ b/crates/bitwarden-crypto/src/signing/message.rs @@ -47,12 +47,12 @@ impl SerializedMessage { &self.serialized_message_bytes } - pub(super) fn content_type(&self) -> CoapContentFormat { + pub(crate) fn content_type(&self) -> CoapContentFormat { self.content_type } /// Encodes a message into a `SerializedMessage` using CBOR serialization. - pub(super) fn encode(message: &Message) -> Result { + pub(crate) fn encode(message: &Message) -> Result { let mut buffer = Vec::new(); ciborium::ser::into_writer(message, &mut buffer) .map_err(|_| EncodingError::InvalidCborSerialization)?; diff --git a/crates/bitwarden-crypto/src/store/context.rs b/crates/bitwarden-crypto/src/store/context.rs index 312cc30ab..c8bd759ae 100644 --- a/crates/bitwarden-crypto/src/store/context.rs +++ b/crates/bitwarden-crypto/src/store/context.rs @@ -9,10 +9,10 @@ use zeroize::Zeroizing; use super::KeyStoreInner; use crate::{ derive_shareable_key, error::UnsupportedOperation, signing, store::backend::StoreBackend, - AsymmetricCryptoKey, BitwardenLegacyKeyBytes, ContentFormat, CryptoError, EncString, KeyId, - KeyIds, PublicKeyEncryptionAlgorithm, Result, RotatedUserKeys, Signature, SignatureAlgorithm, - SignedObject, SignedPublicKey, SignedPublicKeyMessage, SigningKey, SymmetricCryptoKey, - UnsignedSharedKey, + AsymmetricCryptoKey, BitwardenLegacyKeyBytes, ContentFormat, CoseEncrypt0Bytes, CryptoError, + EncString, KeyId, KeyIds, PublicKeyEncryptionAlgorithm, Result, RotatedUserKeys, Signature, + SignatureAlgorithm, SignedObject, SignedPublicKey, SignedPublicKeyMessage, SigningKey, + SymmetricCryptoKey, UnsignedSharedKey, }; /// The context of a crypto operation using [super::KeyStore] @@ -171,8 +171,10 @@ impl KeyStoreContext<'_, Ids> { EncString::Cose_Encrypt0_B64 { data }, SymmetricCryptoKey::XChaCha20Poly1305Key(key), ) => { - let (content_bytes, content_format) = - crate::cose::decrypt_xchacha20_poly1305(data, key)?; + let (content_bytes, content_format) = crate::cose::decrypt_xchacha20_poly1305( + &CoseEncrypt0Bytes::from(data.clone()), + key, + )?; match content_format { ContentFormat::BitwardenLegacyKey => { SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(content_bytes))? @@ -475,7 +477,10 @@ impl KeyStoreContext<'_, Ids> { EncString::Cose_Encrypt0_B64 { data }, SymmetricCryptoKey::XChaCha20Poly1305Key(key), ) => { - let (data, _) = crate::cose::decrypt_xchacha20_poly1305(data, key)?; + let (data, _) = crate::cose::decrypt_xchacha20_poly1305( + &CoseEncrypt0Bytes::from(data.clone()), + key, + )?; Ok(data) } _ => Err(CryptoError::InvalidKey),