Skip to content

Commit 07e1998

Browse files
quextendani-garciaThomas-Avery
authored
[PM-26459] Implement data envelope (#336)
## 🎟️ Tracking <!-- Paste the link to the Jira or GitHub issue or otherwise describe / point to where this change is coming from. --> https://bitwarden.atlassian.net/browse/PM-26459 https://bitwarden.atlassian.net/wiki/spaces/EN/pages/2052653095/Draft+Tech+Breakdown+-+DataEnvelope ## 📔 Objective > [!IMPORTANT] > The confluence document has a more detailed breakdown. This is a shortened version. Consumers want to protect complex structs such as: - Vault exports - Ciphers (vault items) - Organization reports Currently, they serialize these themselves (organization reports are serialized as json), and encrypt fields individually. This is slow to handle during encrypt/decrypt, and complex to maintain. The server now has to know the same representation as clients. At the same time, this can be abused by the server omitting certain fields, or swapping encrypted fields encrypted under the same key. EncStrings are not the correct abstraction layer. A new abstraction / API that is safe and easier to use (an impossible to misuse) is needed. ### Security Definition **Attacker model:** The relevant attacker model here is a fully compromised server. That’s what E2E encryption / zero knowledge protect against. **Security Goals:** **SG1:** The structure should not be modifiable (malleable) - Reason: This can be abused by the attacker by tampering with the encrypted item in (to the developer) unexpected ways **SG2:** The attacker should not be able to infer anything about the contents of the structure aside from length - Reason: We have a “Zero knowledge” product and customers expect their data to not be readable by anyone but them. **SG3:** The structure should only be encryptable in the specific section of code that the developer intends - I.e it should not be possible to swap an encrypted vault item into an encrypted “user settings” “slot”, since the behavior here is undefined. - Reason: Same as SG1 This PR implements a "DataEnvelope". The DataEnvelope solves the problem of encrypting entire structs. The caller just provides a struct that is Serializable/Deserializable, and gets back an encrypted blob. The caller does not decide serialization. Further, we force the creation of a "content encryption key" by the interface. This ensures that teams do not create a coupling to keys. When implementing key-rotation we do not want to re-upload data, but only re-upload re-encrypted keys, and the content-encryption-key enforces this key being present. An example is provided to show usage. ## ⏰ Reminders before review - Contributor guidelines followed - All formatters and local linters executed and passed - Written new unit and / or integration tests where applicable - Protected functional changes with optionality (feature flags) - Used internationalization (i18n) for all UI strings - CI builds passed - Communicated to DevOps any deployment requirements - Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team ## 🦮 Reviewer guidelines <!-- Suggested interactions but feel free to use (or not) as you desire! --> - 👍 (`:+1:`) or similar for great changes - 📝 (`:memo:`) or ℹ️ (`:information_source:`) for notes or general info - ❓ (`:question:`) for questions - 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion - 🎨 (`:art:`) for suggestions / improvements - ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention - 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt - ⛏ (`:pick:`) for minor or nitpick changes --------- Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com> Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>
1 parent 0364659 commit 07e1998

File tree

15 files changed

+1200
-34
lines changed

15 files changed

+1200
-34
lines changed
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
//! This example demonstrates how to seal a piece of data.
2+
//!
3+
//! If there is a struct that should be kept secret, in can be sealed with a `DataEnvelope`. This
4+
//! will automatically create a content-encryption-key. This is useful because the key is stored
5+
//! separately. Rotating the encrypting key now only requires re-uploading the
6+
//! content-encryption-key instead of the entire data. Further, server-side tampering (swapping of
7+
//! individual fields encrypted by the same key) is prevented.
8+
//!
9+
//! In general, if a struct of data should be protected, the `DataEnvelope` should be used.
10+
11+
use bitwarden_crypto::{
12+
generate_versioned_sealable, key_ids,
13+
safe::{DataEnvelope, DataEnvelopeNamespace, SealableData, SealableVersionedData},
14+
};
15+
use serde::{Deserialize, Serialize};
16+
17+
#[derive(Serialize, Deserialize, PartialEq, Debug)]
18+
struct MyItemV1 {
19+
a: u32,
20+
b: String,
21+
}
22+
impl SealableData for MyItemV1 {}
23+
24+
#[derive(Serialize, Deserialize, PartialEq, Debug)]
25+
struct MyItemV2 {
26+
a: u32,
27+
b: bool,
28+
c: bool,
29+
}
30+
impl SealableData for MyItemV2 {}
31+
32+
generate_versioned_sealable!(
33+
MyItem,
34+
DataEnvelopeNamespace::VaultItem,
35+
[
36+
MyItemV1 => "1",
37+
MyItemV2 => "2",
38+
]
39+
);
40+
41+
fn main() {
42+
let store: bitwarden_crypto::KeyStore<ExampleIds> =
43+
bitwarden_crypto::KeyStore::<ExampleIds>::default();
44+
let mut ctx: bitwarden_crypto::KeyStoreContext<'_, ExampleIds> = store.context_mut();
45+
let mut disk = MockDisk::new();
46+
47+
let my_item: MyItem = MyItemV1 {
48+
a: 42,
49+
b: "Hello, World!".to_string(),
50+
}
51+
.into();
52+
53+
// Seals the item into an encrypted blob, and stores the content-encryption-key in the context.
54+
// Returned is the sealed item, along with the id of the content-encryption-key used to seal it
55+
// on the context. The cek has to be protected separately. Alternatively
56+
// `seal_with_wrapping_key` can be used to directly obtain back the wrapped cek.
57+
let (sealed_item, cek) = DataEnvelope::seal(my_item, &mut ctx).expect("Sealing should work");
58+
59+
// Store the sealed item on disk
60+
disk.save("sealed_item", (&sealed_item).into());
61+
let sealed_item = disk
62+
.load("sealed_item")
63+
.expect("Failed to load sealed item")
64+
.clone();
65+
let sealed_item = DataEnvelope::from(sealed_item);
66+
67+
// Unseal the item again, using the content-encryption-key stored in the context.
68+
let my_item: MyItem = sealed_item
69+
.unseal(cek, &mut ctx)
70+
.expect("Unsealing should work");
71+
assert!(matches!(my_item, MyItem::MyItemV1(item) if item.a == 42 && item.b == "Hello, World!"));
72+
}
73+
74+
pub(crate) struct MockDisk {
75+
map: std::collections::HashMap<String, Vec<u8>>,
76+
}
77+
78+
impl MockDisk {
79+
pub(crate) fn new() -> Self {
80+
MockDisk {
81+
map: std::collections::HashMap::new(),
82+
}
83+
}
84+
85+
pub(crate) fn save(&mut self, key: &str, value: Vec<u8>) {
86+
self.map.insert(key.to_string(), value);
87+
}
88+
89+
pub(crate) fn load(&self, key: &str) -> Option<&Vec<u8>> {
90+
self.map.get(key)
91+
}
92+
}
93+
94+
key_ids! {
95+
#[symmetric]
96+
pub enum ExampleSymmetricKey {
97+
#[local]
98+
ItemKey(LocalId)
99+
}
100+
101+
#[asymmetric]
102+
pub enum ExampleAsymmetricKey {
103+
Key(u8),
104+
#[local]
105+
Local(LocalId),
106+
}
107+
108+
#[signing]
109+
pub enum ExampleSigningKey {
110+
Key(u8),
111+
#[local]
112+
Local(LocalId),
113+
}
114+
pub ExampleIds => ExampleSymmetricKey, ExampleAsymmetricKey, ExampleSigningKey;
115+
}

crates/bitwarden-crypto/src/content_format.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ pub(crate) enum ContentFormat {
2424
CoseKey,
2525
/// CoseSign1 message
2626
CoseSign1,
27+
/// CoseEncrypt0 message
28+
CoseEncrypt0,
2729
/// Bitwarden Legacy Key
2830
/// There are three permissible byte values here:
2931
/// - `[u8; 32]` - AES-CBC (no hmac) key. This is to be removed and banned.
@@ -32,6 +34,8 @@ pub(crate) enum ContentFormat {
3234
BitwardenLegacyKey,
3335
/// Stream of bytes
3436
OctetStream,
37+
/// Cbor serialized data
38+
Cbor,
3539
}
3640

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

217+
/// CBOR serialized data
218+
#[derive(PartialEq, Eq, Clone, Debug)]
219+
pub struct CborContentFormat;
220+
impl private::Sealed for CborContentFormat {}
221+
impl ConstContentFormat for CborContentFormat {
222+
#[allow(private_interfaces)]
223+
fn content_format() -> ContentFormat {
224+
ContentFormat::Cbor
225+
}
226+
}
227+
/// CborBytes is a type alias for Bytes with `CborContentFormat`. This is used for CBOR serialized
228+
/// data.
229+
pub type CborBytes = Bytes<CborContentFormat>;
230+
231+
/// Content format for COSE Encrypt0 messages.
232+
#[derive(PartialEq, Eq, Clone, Debug)]
233+
pub struct CoseEncrypt0ContentFormat;
234+
impl private::Sealed for CoseEncrypt0ContentFormat {}
235+
impl ConstContentFormat for CoseEncrypt0ContentFormat {
236+
#[allow(private_interfaces)]
237+
fn content_format() -> ContentFormat {
238+
ContentFormat::CoseEncrypt0
239+
}
240+
}
241+
/// CoseEncrypt0Bytes is a type alias for Bytes with `CoseEncrypt0ContentFormat`. This is used for
242+
/// serialized COSE Encrypt0 messages.
243+
pub type CoseEncrypt0Bytes = Bytes<CoseEncrypt0ContentFormat>;
244+
213245
impl<Ids: KeyIds, T: ConstContentFormat> PrimitiveEncryptable<Ids, Ids::Symmetric, EncString>
214246
for Bytes<T>
215247
{

crates/bitwarden-crypto/src/cose.rs

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
66
use coset::{
77
CborSerializable, ContentType, Header, Label,
8-
iana::{self, CoapContentFormat},
8+
iana::{self, CoapContentFormat, KeyOperation},
99
};
1010
use generic_array::GenericArray;
1111
use thiserror::Error;
1212
use typenum::U32;
1313

1414
use crate::{
15-
ContentFormat, CryptoError, SymmetricCryptoKey, XChaCha20Poly1305Key,
15+
ContentFormat, CoseEncrypt0Bytes, CryptoError, SymmetricCryptoKey, XChaCha20Poly1305Key,
1616
content_format::{Bytes, ConstContentFormat, CoseContentFormat},
1717
error::{EncStringParseError, EncodingError},
1818
xchacha20,
@@ -33,20 +33,23 @@ pub(crate) const ARGON2_PARALLELISM: i64 = -71004;
3333
// Note: These are in the "unregistered" tree: https://datatracker.ietf.org/doc/html/rfc6838#section-3.4
3434
// These are only used within Bitwarden, and not meant for exchange with other systems.
3535
const CONTENT_TYPE_PADDED_UTF8: &str = "application/x.bitwarden.utf8-padded";
36+
pub(crate) const CONTENT_TYPE_PADDED_CBOR: &str = "application/x.bitwarden.cbor-padded";
3637
const CONTENT_TYPE_BITWARDEN_LEGACY_KEY: &str = "application/x.bitwarden.legacy-key";
3738
const CONTENT_TYPE_SPKI_PUBLIC_KEY: &str = "application/x.bitwarden.spki-public-key";
3839

3940
// Labels
4041
//
4142
/// The label used for the namespace ensuring strong domain separation when using signatures.
4243
pub(crate) const SIGNING_NAMESPACE: i64 = -80000;
44+
/// The label used for the namespace ensuring strong domain separation when using data envelopes.
45+
pub(crate) const DATA_ENVELOPE_NAMESPACE: i64 = -80001;
4346

4447
/// Encrypts a plaintext message using XChaCha20Poly1305 and returns a COSE Encrypt0 message
4548
pub(crate) fn encrypt_xchacha20_poly1305(
4649
plaintext: &[u8],
4750
key: &crate::XChaCha20Poly1305Key,
4851
content_format: ContentFormat,
49-
) -> Result<Vec<u8>, CryptoError> {
52+
) -> Result<CoseEncrypt0Bytes, CryptoError> {
5053
let mut plaintext = plaintext.to_vec();
5154

5255
let header_builder: coset::HeaderBuilder = content_format.into();
@@ -78,14 +81,15 @@ pub(crate) fn encrypt_xchacha20_poly1305(
7881
cose_encrypt0
7982
.to_vec()
8083
.map_err(|err| CryptoError::EncString(EncStringParseError::InvalidCoseEncoding(err)))
84+
.map(CoseEncrypt0Bytes::from)
8185
}
8286

8387
/// Decrypts a COSE Encrypt0 message, using a XChaCha20Poly1305 key
8488
pub(crate) fn decrypt_xchacha20_poly1305(
85-
cose_encrypt0_message: &[u8],
89+
cose_encrypt0_message: &CoseEncrypt0Bytes,
8690
key: &crate::XChaCha20Poly1305Key,
8791
) -> Result<(Vec<u8>, ContentFormat), CryptoError> {
88-
let msg = coset::CoseEncrypt0::from_slice(cose_encrypt0_message)
92+
let msg = coset::CoseEncrypt0::from_slice(cose_encrypt0_message.as_ref())
8993
.map_err(|err| CryptoError::EncString(EncStringParseError::InvalidCoseEncoding(err)))?;
9094

9195
let Some(ref alg) = msg.protected.header.alg else {
@@ -141,6 +145,25 @@ impl TryFrom<&coset::CoseKey> for SymmetricCryptoKey {
141145
})
142146
.ok_or(CryptoError::InvalidKey)?;
143147
let alg = cose_key.alg.as_ref().ok_or(CryptoError::InvalidKey)?;
148+
let key_opts = cose_key
149+
.key_ops
150+
.iter()
151+
.map(|op| match op {
152+
coset::RegisteredLabel::Assigned(iana::KeyOperation::Encrypt) => {
153+
Ok(KeyOperation::Encrypt)
154+
}
155+
coset::RegisteredLabel::Assigned(iana::KeyOperation::Decrypt) => {
156+
Ok(KeyOperation::Decrypt)
157+
}
158+
coset::RegisteredLabel::Assigned(iana::KeyOperation::WrapKey) => {
159+
Ok(KeyOperation::WrapKey)
160+
}
161+
coset::RegisteredLabel::Assigned(iana::KeyOperation::UnwrapKey) => {
162+
Ok(KeyOperation::UnwrapKey)
163+
}
164+
_ => Err(CryptoError::InvalidKey),
165+
})
166+
.collect::<Result<Vec<KeyOperation>, CryptoError>>()?;
144167

145168
match alg {
146169
coset::Algorithm::PrivateUse(XCHACHA20_POLY1305) => {
@@ -156,7 +179,11 @@ impl TryFrom<&coset::CoseKey> for SymmetricCryptoKey {
156179
.try_into()
157180
.map_err(|_| CryptoError::InvalidKey)?;
158181
Ok(SymmetricCryptoKey::XChaCha20Poly1305Key(
159-
XChaCha20Poly1305Key { enc_key, key_id },
182+
XChaCha20Poly1305Key {
183+
enc_key,
184+
key_id,
185+
supported_operations: key_opts,
186+
},
160187
))
161188
}
162189
_ => Err(CryptoError::InvalidKey),
@@ -180,12 +207,16 @@ impl From<ContentFormat> for coset::HeaderBuilder {
180207
}
181208
ContentFormat::CoseSign1 => header_builder.content_format(CoapContentFormat::CoseSign1),
182209
ContentFormat::CoseKey => header_builder.content_format(CoapContentFormat::CoseKey),
210+
ContentFormat::CoseEncrypt0 => {
211+
header_builder.content_format(CoapContentFormat::CoseEncrypt0)
212+
}
183213
ContentFormat::BitwardenLegacyKey => {
184214
header_builder.content_type(CONTENT_TYPE_BITWARDEN_LEGACY_KEY.to_string())
185215
}
186216
ContentFormat::OctetStream => {
187217
header_builder.content_format(CoapContentFormat::OctetStream)
188218
}
219+
ContentFormat::Cbor => header_builder.content_format(CoapContentFormat::Cbor),
189220
}
190221
}
191222
}
@@ -211,6 +242,7 @@ impl TryFrom<&coset::Header> for ContentFormat {
211242
Some(ContentType::Assigned(CoapContentFormat::OctetStream)) => {
212243
Ok(ContentFormat::OctetStream)
213244
}
245+
Some(ContentType::Assigned(CoapContentFormat::Cbor)) => Ok(ContentFormat::Cbor),
214246
_ => Err(CryptoError::EncString(
215247
EncStringParseError::CoseMissingContentType,
216248
)),
@@ -362,8 +394,16 @@ mod test {
362394
let key = XChaCha20Poly1305Key {
363395
key_id: KEY_ID,
364396
enc_key: Box::pin(*GenericArray::from_slice(&KEY_DATA)),
397+
supported_operations: vec![
398+
KeyOperation::Decrypt,
399+
KeyOperation::Encrypt,
400+
KeyOperation::WrapKey,
401+
KeyOperation::UnwrapKey,
402+
],
365403
};
366-
let decrypted = decrypt_xchacha20_poly1305(TEST_VECTOR_COSE_ENCRYPT0, &key).unwrap();
404+
let decrypted =
405+
decrypt_xchacha20_poly1305(&CoseEncrypt0Bytes::from(TEST_VECTOR_COSE_ENCRYPT0), &key)
406+
.unwrap();
367407
assert_eq!(
368408
decrypted,
369409
(TEST_VECTOR_PLAINTEXT.to_vec(), ContentFormat::OctetStream)
@@ -375,9 +415,15 @@ mod test {
375415
let key = XChaCha20Poly1305Key {
376416
key_id: [1; 16], // Different key ID
377417
enc_key: Box::pin(*GenericArray::from_slice(&KEY_DATA)),
418+
supported_operations: vec![
419+
KeyOperation::Decrypt,
420+
KeyOperation::Encrypt,
421+
KeyOperation::WrapKey,
422+
KeyOperation::UnwrapKey,
423+
],
378424
};
379425
assert!(matches!(
380-
decrypt_xchacha20_poly1305(TEST_VECTOR_COSE_ENCRYPT0, &key),
426+
decrypt_xchacha20_poly1305(&CoseEncrypt0Bytes::from(TEST_VECTOR_COSE_ENCRYPT0), &key),
381427
Err(CryptoError::WrongCoseKeyId)
382428
));
383429
}
@@ -394,11 +440,17 @@ mod test {
394440
.create_ciphertext(&[], &[], |_, _| Vec::new())
395441
.unprotected(coset::HeaderBuilder::new().iv(nonce.to_vec()).build())
396442
.build();
397-
let serialized_message = cose_encrypt0.to_vec().unwrap();
443+
let serialized_message = CoseEncrypt0Bytes::from(cose_encrypt0.to_vec().unwrap());
398444

399445
let key = XChaCha20Poly1305Key {
400446
key_id: KEY_ID,
401447
enc_key: Box::pin(*GenericArray::from_slice(&KEY_DATA)),
448+
supported_operations: vec![
449+
KeyOperation::Decrypt,
450+
KeyOperation::Encrypt,
451+
KeyOperation::WrapKey,
452+
KeyOperation::UnwrapKey,
453+
],
402454
};
403455
assert!(matches!(
404456
decrypt_xchacha20_poly1305(&serialized_message, &key),

0 commit comments

Comments
 (0)