Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
e2bb9c1
Make context local Ids autogenerated
dani-garcia May 12, 2025
7218aba
Update docs
dani-garcia May 12, 2025
81c7439
Merge branch 'main' into ps/context-local-uuids
dani-garcia Jun 12, 2025
ab25b95
Add comments
dani-garcia Jun 12, 2025
d9707e0
Fix tests
dani-garcia Jun 12, 2025
175dbce
Merge branch 'main' into ps/context-local-uuids
dani-garcia Jun 26, 2025
237cb20
Add signing keys
dani-garcia Jun 26, 2025
d892b90
Merge branch 'main' into ps/context-local-uuids
dani-garcia Jun 30, 2025
7b2b49f
Introduce opaque local ids
dani-garcia Jun 30, 2025
817cf6e
Fmt
dani-garcia Jun 30, 2025
c4cf8ec
Missing docs
dani-garcia Jun 30, 2025
f0c31ed
Merge branch 'main' into ps/context-local-uuids
dani-garcia Aug 19, 2025
b67aaca
Merge branch 'main' into ps/context-local-uuids
dani-garcia Sep 22, 2025
16369db
Implement data envelope
quexten Oct 2, 2025
5153a99
Cargo fmt
quexten Oct 2, 2025
4dc80a2
Fix non wasm build
quexten Oct 2, 2025
3387d2b
Fix clippy issues
quexten Oct 2, 2025
fcc2d9c
Clean up docs and fix wasm
quexten Oct 6, 2025
5a8a4ec
Add versioned example
quexten Oct 6, 2025
72f224f
Clean-up and key opts
quexten Oct 8, 2025
1c7ee43
Verify key ops and add dataenvelope test vector
quexten Oct 8, 2025
1c5b17f
Add comment to key opts
quexten Oct 8, 2025
a92fa6f
Add newline
quexten Oct 8, 2025
ca989d6
Merge branch 'main' into km/beeep/safe-data-envelope
quexten Oct 8, 2025
b244735
Move namespace to trait implementation
quexten Oct 10, 2025
8ce366c
Merge branch 'km/beeep/safe-data-envelope' of github.com:bitwarden/sd…
quexten Oct 10, 2025
e59b40f
Require serialize and deserialize
quexten Oct 11, 2025
11f8ee2
Merge branch 'main' into ps/context-local-uuids
quexten Oct 13, 2025
d37361b
Remove unused imports
quexten Oct 13, 2025
476e6b3
Fix build
quexten Oct 13, 2025
831a005
Generate versioned sealable enum via macro
quexten Oct 13, 2025
bcfef62
Add padding
quexten Oct 13, 2025
217f649
Fix clippy issue
quexten Oct 13, 2025
0b58542
Merge branch 'ps/context-local-uuids' into km/data-envelope-follow-up
quexten Oct 22, 2025
99fbb45
Add convenience functions and fix build
quexten Oct 22, 2025
d704375
Fix clippy
quexten Oct 22, 2025
2e78546
Fix build
quexten Oct 22, 2025
8f57bec
Fix test
quexten Oct 22, 2025
da90552
Merge branch 'main' into km/beeep/safe-data-envelope
quexten Oct 22, 2025
0ff2877
Merge branch 'main' into km/beeep/safe-data-envelope
quexten Oct 23, 2025
b23fcbf
Merge branch 'km/data-envelope-follow-up' into km/beeep/safe-data-env…
quexten Oct 23, 2025
6c45d79
Merge branch 'km/beeep/safe-data-envelope' of github.com:bitwarden/sd…
quexten Oct 23, 2025
6865a2f
Fix build
quexten Oct 23, 2025
18ff1de
Fix build
quexten Oct 23, 2025
8c8568b
Fix build
quexten Oct 23, 2025
7322188
Remove crypto error variant for data envelope
quexten Oct 28, 2025
e8a9b2b
Add comment to vault item namespace
quexten Oct 28, 2025
334f084
Pass through b64 error and derive clone
quexten Oct 28, 2025
8b45178
Rename to supported_operations
quexten Oct 28, 2025
cbc84a3
Add jira ticket
quexten Oct 28, 2025
8f0ea2b
Update crates/bitwarden-crypto/src/safe/data_envelope.rs
quexten Oct 29, 2025
5ebdc2f
Update crates/bitwarden-crypto/src/safe/README.md
quexten Oct 29, 2025
74cb980
Address feedback
quexten Oct 29, 2025
6a73765
Merge branch 'main' into km/beeep/safe-data-envelope
quexten Oct 29, 2025
cfa3f4f
Fix formatting
quexten Oct 29, 2025
d044cf7
Apply feedback
quexten Oct 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions crates/bitwarden-crypto/examples/seal_struct.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//! 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::{
generate_versioned_sealable, key_ids,
safe::{DataEnvelope, DataEnvelopeNamespace, SealableData, SealableVersionedData},
};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct MyItemV1 {
a: u32,
b: String,
}
impl SealableData for MyItemV1 {}

#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct MyItemV2 {
a: u32,
b: bool,
c: bool,
}
impl SealableData for MyItemV2 {}

generate_versioned_sealable!(
MyItem,
DataEnvelopeNamespace::VaultItem,
[
MyItemV1 => "1",
MyItemV2 => "2",
]
);

fn main() {
let store: bitwarden_crypto::KeyStore<ExampleIds> =
bitwarden_crypto::KeyStore::<ExampleIds>::default();
let mut ctx: bitwarden_crypto::KeyStoreContext<'_, ExampleIds> = store.context_mut();
let mut disk = MockDisk::new();

let my_item: MyItem = MyItemV1 {
a: 42,
b: "Hello, World!".to_string(),
}
.into();

// Seals the item into an encrypted blob, and stores the content-encryption-key in the context.
// Returned is the sealed item, along with the id of the content-encryption-key used to seal it
// on the context. The cek has to be protected separately. Alternatively
// `seal_with_wrapping_key` can be used to directly obtain back the wrapped cek.
let (sealed_item, cek) = DataEnvelope::seal(my_item, &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 = DataEnvelope::from(sealed_item);

// Unseal the item again, using the content-encryption-key stored in the context.
let my_item: MyItem = sealed_item
.unseal(cek, &mut ctx)
.expect("Unsealing should work");
assert!(matches!(my_item, MyItem::MyItemV1(item) if item.a == 42 && item.b == "Hello, World!"));
}

pub(crate) struct MockDisk {
map: std::collections::HashMap<String, Vec<u8>>,
}

impl MockDisk {
pub(crate) fn new() -> Self {
MockDisk {
map: std::collections::HashMap::new(),
}
}

pub(crate) fn save(&mut self, key: &str, value: Vec<u8>) {
self.map.insert(key.to_string(), value);
}

pub(crate) fn load(&self, key: &str) -> Option<&Vec<u8>> {
self.map.get(key)
}
}

key_ids! {
#[symmetric]
pub enum ExampleSymmetricKey {
#[local]
ItemKey(LocalId)
}

#[asymmetric]
pub enum ExampleAsymmetricKey {
Key(u8),
#[local]
Local(LocalId),
}

#[signing]
pub enum ExampleSigningKey {
Key(u8),
#[local]
Local(LocalId),
}
pub ExampleIds => ExampleSymmetricKey, ExampleAsymmetricKey, ExampleSigningKey;
}
32 changes: 32 additions & 0 deletions crates/bitwarden-crypto/src/content_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -32,6 +34,8 @@ pub(crate) enum ContentFormat {
BitwardenLegacyKey,
/// Stream of bytes
OctetStream,
/// Cbor serialized data
Cbor,
}

mod private {
Expand Down Expand Up @@ -210,6 +214,34 @@ impl FromB64ContentFormat for CoseSign1ContentFormat {}
/// serialized COSE Sign1 messages.
pub type CoseSign1Bytes = Bytes<CoseSign1ContentFormat>;

/// 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<CborContentFormat>;

/// 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<CoseEncrypt0ContentFormat>;

impl<Ids: KeyIds, T: ConstContentFormat> PrimitiveEncryptable<Ids, Ids::Symmetric, EncString>
for Bytes<T>
{
Expand Down
70 changes: 61 additions & 9 deletions crates/bitwarden-crypto/src/cose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@

use coset::{
CborSerializable, ContentType, Header, Label,
iana::{self, CoapContentFormat},
iana::{self, CoapContentFormat, KeyOperation},
};
use generic_array::GenericArray;
use thiserror::Error;
use typenum::U32;

use crate::{
ContentFormat, CryptoError, SymmetricCryptoKey, XChaCha20Poly1305Key,
ContentFormat, CoseEncrypt0Bytes, CryptoError, SymmetricCryptoKey, XChaCha20Poly1305Key,
content_format::{Bytes, ConstContentFormat, CoseContentFormat},
error::{EncStringParseError, EncodingError},
xchacha20,
Expand All @@ -33,20 +33,23 @@ pub(crate) const ARGON2_PARALLELISM: i64 = -71004;
// Note: These are in the "unregistered" tree: https://datatracker.ietf.org/doc/html/rfc6838#section-3.4
// These are only used within Bitwarden, and not meant for exchange with other systems.
const CONTENT_TYPE_PADDED_UTF8: &str = "application/x.bitwarden.utf8-padded";
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";
const CONTENT_TYPE_SPKI_PUBLIC_KEY: &str = "application/x.bitwarden.spki-public-key";

// Labels
//
/// 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<Vec<u8>, CryptoError> {
) -> Result<CoseEncrypt0Bytes, CryptoError> {
let mut plaintext = plaintext.to_vec();

let header_builder: coset::HeaderBuilder = content_format.into();
Expand Down Expand Up @@ -78,14 +81,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<u8>, 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 {
Expand Down Expand Up @@ -141,6 +145,25 @@ impl TryFrom<&coset::CoseKey> for SymmetricCryptoKey {
})
.ok_or(CryptoError::InvalidKey)?;
let alg = cose_key.alg.as_ref().ok_or(CryptoError::InvalidKey)?;
let key_opts = cose_key
.key_ops
.iter()
.map(|op| match op {
coset::RegisteredLabel::Assigned(iana::KeyOperation::Encrypt) => {
Ok(KeyOperation::Encrypt)
}
coset::RegisteredLabel::Assigned(iana::KeyOperation::Decrypt) => {
Ok(KeyOperation::Decrypt)
}
coset::RegisteredLabel::Assigned(iana::KeyOperation::WrapKey) => {
Ok(KeyOperation::WrapKey)
}
coset::RegisteredLabel::Assigned(iana::KeyOperation::UnwrapKey) => {
Ok(KeyOperation::UnwrapKey)
}
_ => Err(CryptoError::InvalidKey),
})
.collect::<Result<Vec<KeyOperation>, CryptoError>>()?;

match alg {
coset::Algorithm::PrivateUse(XCHACHA20_POLY1305) => {
Expand All @@ -156,7 +179,11 @@ impl TryFrom<&coset::CoseKey> for SymmetricCryptoKey {
.try_into()
.map_err(|_| CryptoError::InvalidKey)?;
Ok(SymmetricCryptoKey::XChaCha20Poly1305Key(
XChaCha20Poly1305Key { enc_key, key_id },
XChaCha20Poly1305Key {
enc_key,
key_id,
supported_operations: key_opts,
},
))
}
_ => Err(CryptoError::InvalidKey),
Expand All @@ -180,12 +207,16 @@ impl From<ContentFormat> 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),
}
}
}
Expand All @@ -211,6 +242,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,
)),
Expand Down Expand Up @@ -362,8 +394,16 @@ mod test {
let key = XChaCha20Poly1305Key {
key_id: KEY_ID,
enc_key: Box::pin(*GenericArray::from_slice(&KEY_DATA)),
supported_operations: vec![
KeyOperation::Decrypt,
KeyOperation::Encrypt,
KeyOperation::WrapKey,
KeyOperation::UnwrapKey,
],
};
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)
Expand All @@ -375,9 +415,15 @@ mod test {
let key = XChaCha20Poly1305Key {
key_id: [1; 16], // Different key ID
enc_key: Box::pin(*GenericArray::from_slice(&KEY_DATA)),
supported_operations: vec![
KeyOperation::Decrypt,
KeyOperation::Encrypt,
KeyOperation::WrapKey,
KeyOperation::UnwrapKey,
],
};
assert!(matches!(
decrypt_xchacha20_poly1305(TEST_VECTOR_COSE_ENCRYPT0, &key),
decrypt_xchacha20_poly1305(&CoseEncrypt0Bytes::from(TEST_VECTOR_COSE_ENCRYPT0), &key),
Err(CryptoError::WrongCoseKeyId)
));
}
Expand All @@ -394,11 +440,17 @@ 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,
enc_key: Box::pin(*GenericArray::from_slice(&KEY_DATA)),
supported_operations: vec![
KeyOperation::Decrypt,
KeyOperation::Encrypt,
KeyOperation::WrapKey,
KeyOperation::UnwrapKey,
],
};
assert!(matches!(
decrypt_xchacha20_poly1305(&serialized_message, &key),
Expand Down
Loading
Loading