Skip to content

[PM-20614] Cose Content Type #203

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 43 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
bc5900b
Implement cose content format
quexten May 9, 2025
2e905c4
Cargo fmt
quexten May 9, 2025
b41f5ef
Fix docs
quexten May 9, 2025
eb70dfc
Fix formatting
quexten May 9, 2025
1e2e95c
Merge main
quexten May 9, 2025
68be6e6
Fix formatting
quexten May 9, 2025
d6a18a4
Fix comment
quexten May 9, 2025
6e9e526
Cleanup
quexten May 21, 2025
50d8f70
Switch from pass by ref to pass by value for enum
quexten May 21, 2025
e5bd251
Add CompositeEncryptable trait
quexten May 21, 2025
b92010b
Cleanup
quexten May 21, 2025
9635098
Merge branch 'main' into km/cose-content-format
quexten May 21, 2025
b8056c2
Fix typo
quexten May 21, 2025
9440825
Typed encryptable
quexten May 21, 2025
f8cb804
Apply cargo fmt
quexten May 22, 2025
e6939e6
Fix build
quexten May 22, 2025
2a202c1
Remove unused imports
quexten May 22, 2025
ba9e2c3
Rename to primitiveencryptablewithcontenttype
quexten May 22, 2025
a42b1e7
Set correct content type for pkcs8
quexten May 22, 2025
5447cbb
Add cose keywrap
quexten May 22, 2025
66c345f
Fix keywrap
quexten May 22, 2025
9b2f6c9
Run cargo fmt
quexten May 22, 2025
88b96b7
Fix encryptable
quexten May 22, 2025
7e69b7b
Run cargo fmt
quexten May 22, 2025
b7d2bc8
Cleanup
quexten May 29, 2025
acb6d12
Rename to PrimitiveEncryptableWithoutContentFormat
quexten May 29, 2025
723ec63
Rename to PrimitiveEncryptable
quexten May 29, 2025
36d8de9
Merge branch 'main' into km/cose-content-format
quexten May 29, 2025
33da07b
Add documentation for the encryptable traits
quexten May 29, 2025
2861a9a
Merge branch 'km/cose-content-format' of github.com:bitwarden/sdk-intโ€ฆ
quexten May 29, 2025
1681df9
Fix clippy errors
quexten Jun 2, 2025
0cd85fa
Fix docs
quexten Jun 2, 2025
f91f0b8
Cargo fmt
quexten Jun 2, 2025
34ee00e
Fix docs
quexten Jun 2, 2025
485b6d8
Fix docs
quexten Jun 2, 2025
f122ff0
Fix docs
quexten Jun 2, 2025
f22531c
Update crates/bitwarden-crypto/src/keys/utils.rs
quexten Jun 4, 2025
ba10631
Update crates/bitwarden-crypto/src/keys/utils.rs
quexten Jun 4, 2025
69d8c57
Update crates/bitwarden-crypto/src/keys/utils.rs
quexten Jun 4, 2025
8833146
Merge branch 'main' into km/cose-content-format
quexten Jun 4, 2025
47ced5a
Merge branch 'main' into km/cose-content-format
quexten Jun 6, 2025
42dccb0
Add docs
quexten Jun 6, 2025
9d0ea55
Merge branch 'km/cose-content-format' of github.com:bitwarden/sdk-intโ€ฆ
quexten Jun 6, 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
2 changes: 1 addition & 1 deletion bitwarden_license/bitwarden-sm/src/projects/create.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use bitwarden_api_api::models::ProjectCreateRequestModel;
use bitwarden_core::{key_management::SymmetricKeyId, Client};
use bitwarden_crypto::Encryptable;
use bitwarden_crypto::PrimitiveEncryptableWithContentType;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
Expand Down
2 changes: 1 addition & 1 deletion bitwarden_license/bitwarden-sm/src/projects/update.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use bitwarden_api_api::models::ProjectUpdateRequestModel;
use bitwarden_core::{key_management::SymmetricKeyId, Client};
use bitwarden_crypto::Encryptable;
use bitwarden_crypto::PrimitiveEncryptableWithContentType;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
Expand Down
2 changes: 1 addition & 1 deletion bitwarden_license/bitwarden-sm/src/secrets/create.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use bitwarden_api_api::models::SecretCreateRequestModel;
use bitwarden_core::{key_management::SymmetricKeyId, Client};
use bitwarden_crypto::Encryptable;
use bitwarden_crypto::PrimitiveEncryptableWithContentType;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
Expand Down
2 changes: 1 addition & 1 deletion bitwarden_license/bitwarden-sm/src/secrets/update.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use bitwarden_api_api::models::SecretUpdateRequestModel;
use bitwarden_core::{key_management::SymmetricKeyId, Client};
use bitwarden_crypto::Encryptable;
use bitwarden_crypto::PrimitiveEncryptableWithContentType;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
Expand Down
5 changes: 4 additions & 1 deletion crates/bitwarden-core/src/key_management/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
//! - [KeyIds] is a helper type that combines both symmetric and asymmetric key identifiers. This is
//! usually used in the type bounds of [KeyStore],
//! [KeyStoreContext](bitwarden_crypto::KeyStoreContext),
//! [Encryptable](bitwarden_crypto::Encryptable) and [Decryptable](bitwarden_crypto::Encryptable).
//! [PrimitiveEncryptable](bitwarden_crypto::PrimitiveEncryptable),
//! [PrimitiveEncryptableWithContentType](bitwarden_crypto::PrimitiveEncryptableWithContentType),
//! [CompositeEncryptable](bitwarden_crypto::CompositeEncryptable), and
//! [Decryptable](bitwarden_crypto::Decryptable).
use bitwarden_crypto::{key_ids, KeyStore, SymmetricCryptoKey};

key_ids! {
Expand Down
8 changes: 4 additions & 4 deletions crates/bitwarden-core/src/mobile/crypto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ use std::collections::HashMap;

use base64::{engine::general_purpose::STANDARD, Engine};
use bitwarden_crypto::{
AsymmetricCryptoKey, CryptoError, EncString, Kdf, KeyDecryptable, KeyEncryptable, MasterKey,
SymmetricCryptoKey, UnsignedSharedKey, UserKey,
AsymmetricCryptoKey, ContentFormat, CryptoError, EncString, Kdf, KeyDecryptable,
KeyEncryptable, MasterKey, SymmetricCryptoKey, UnsignedSharedKey, UserKey,
};
use serde::{Deserialize, Serialize};
#[cfg(feature = "wasm")]
Expand Down Expand Up @@ -334,7 +334,7 @@ pub(super) fn derive_pin_key(

Ok(DerivePinKeyResponse {
pin_protected_user_key,
encrypted_pin: pin.encrypt_with_key(user_key)?,
encrypted_pin: pin.encrypt_with_key(user_key, ContentFormat::Utf8)?,
})
}

Expand Down Expand Up @@ -870,7 +870,7 @@ mod tests {
let invalid_private_key = "bad_key"
.to_string()
.into_bytes()
.encrypt_with_key(&user_key.0)
.encrypt_with_key(&user_key.0, ContentFormat::Utf8)
.unwrap();

let request = VerifyAsymmetricKeysRequest {
Expand Down
4 changes: 2 additions & 2 deletions crates/bitwarden-core/src/secrets_manager/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

use std::{fmt::Debug, path::Path};

use bitwarden_crypto::{EncString, KeyDecryptable, KeyEncryptable};
use bitwarden_crypto::{ContentFormat, EncString, KeyDecryptable, KeyEncryptable};
use serde::{Deserialize, Serialize};

use crate::auth::AccessToken;
Expand Down Expand Up @@ -73,7 +73,7 @@
) -> Result<(), StateFileError> {
let serialized_state: String = serde_json::to_string(&state)?;
let encrypted_state: EncString =
serialized_state.encrypt_with_key(&access_token.encryption_key)?;
serialized_state.encrypt_with_key(&access_token.encryption_key, ContentFormat::Utf8)?;

Check warning on line 76 in crates/bitwarden-core/src/secrets_manager/state.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden-core/src/secrets_manager/state.rs#L76

Added line #L76 was not covered by tests
let state_string: String = encrypted_state.to_string();

Ok(std::fs::write(state_file, state_string)?)
Expand Down
4 changes: 2 additions & 2 deletions crates/bitwarden-crypto/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ secure.
## Example:

```rust
use bitwarden_crypto::{SymmetricCryptoKey, KeyEncryptable, KeyDecryptable, CryptoError};
use bitwarden_crypto::{SymmetricCryptoKey, KeyEncryptable, KeyDecryptable, CryptoError, ContentFormat};

async fn example() -> Result<(), CryptoError> {
let key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();

let data = "Hello, World!".to_owned();
let encrypted = data.clone().encrypt_with_key(&key)?;
let encrypted = data.clone().encrypt_with_key(&key, ContentFormat::Utf8)?;
let decrypted: String = encrypted.decrypt_with_key(&key)?;

assert_eq!(data, decrypted);
Expand Down
146 changes: 138 additions & 8 deletions crates/bitwarden-crypto/src/cose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@
//! unless there is a a clear benefit, such as a clear cryptographic benefit, which MUST
//! be documented publicly.

use coset::{iana, CborSerializable, Label};
use coset::{
iana::{self, CoapContentFormat},
CborSerializable, ContentType, Label,
};
use generic_array::GenericArray;
use serde::{Deserialize, Serialize};
#[cfg(feature = "wasm")]
use tsify_next::Tsify;
use typenum::U32;

use crate::{
Expand All @@ -15,13 +21,42 @@
/// to be able to randomly generate nonces, and to not have to worry about key wearout. Since
/// the draft was never published as an RFC, we use a private-use value for the algorithm.
pub(crate) const XCHACHA20_POLY1305: i64 = -70000;
const XCHACHA20_TEXT_PAD_BLOCK_SIZE: usize = 32;
const CONTENT_TYPE_PADDED_UTF8: &str = "application/utf8-padded";

/// The content format describes the format of the contained bytes. Message encryption always
/// happens on the byte level, and this allows determining what format the contained data has. For
/// instance, an `EncString` in most cases contains UTF-8 encoded text. In some cases it may contain
/// a Pkcs8 private key, or a COSE key. Specifically, for COSE keys, this allows distinguishing
/// between the old symmetric key format, represented as `ContentFormat::OctetStream`, and the new
/// COSE key format, represented as `ContentFormat::CoseKey`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
pub enum ContentFormat {
/// UTF-8 encoded text
Utf8,
/// Pkcs8 private key DER
Pkcs8,
/// COSE serialized CoseKey
CoseKey,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opinion question: Are we fine with OctetStream for old bitwarden keys? Should we make a custom content format - "BitwardenLegacyKey"?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems reasonable to me to create our own custom content format. Attachments and file sends would probably also use the same content type, and there maybe be some others as well, like the FIDO2 private key (or is that just a base64 string?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, so for FIDO2, the private key seems to be a PKCS8 DER, B64 encoded (but without the typical PEM header/footer) before encrypting. So we cannot give it the pkcs8 content type, as that refers to PKCS8 DER without B64, but string/our padded string seems reasonable. (Or, I guess we could skip the b64 encoding step when encrypting with a COSE key, and mark it as PKCS8? I'm not sure whether that is worth the effort to ensure correctness with old encryption in all places...)

I'm not sure about files (attachments/send attachments); they seem like just byte buffers, so octetstream seems fine.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're going with a BitwardenLegacyKey, is there a reason not to use this enum to precisely define content of anything that encrypted?

That definitely has value to me from a type safety perspective, but I don't know if a format flag is the right place to do that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm OK with using a custom content format here.

Some things to keep in mind: Custom content formats - in contrast to standardized ones - are defined using strings. The standardized ones sadly don't offer a privateuse category:

0-255 | Expert Review
-- | --
256-9999 | IETF Review or IESG Approval
10000-64999 | First Come First Served
65000-65535 | Experimental use (no operational use)

(This is also the case with encstrings, that contain padded utf8 text, which are much more common right now).

The second: So far I've introduced content formats only where they are required to distinguish. (PKCS8 / CoseKey, Utf8/Octetstream CoseKey/OctetStream[or BitwardenLegacyKey].). OctetStream should be unique here, but BitwardenLegacyKey makes this much clearer.

Both of these are not a big deal, and don't hinder using it, and I'm happy to change to it. Given the context, should I change it?

/// Stream of bytes
OctetStream,
}

/// 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> {
let mut protected_header = coset::HeaderBuilder::new().build();
let mut plaintext = plaintext.to_vec();

let mut protected_header: coset::Header = content_format.into();

if should_pad_content(&content_format) {
// Pad the data to a block size in order to hide plaintext length
crate::keys::utils::pad_bytes(&mut plaintext, XCHACHA20_TEXT_PAD_BLOCK_SIZE);
}
// This should be adjusted to use the builder pattern once implemented in coset.
// The related coset upstream issue is:
// https://github.com/google/coset/issues/105
Expand All @@ -30,7 +65,7 @@
let mut nonce = [0u8; xchacha20::NONCE_SIZE];
let cose_encrypt0 = coset::CoseEncrypt0Builder::new()
.protected(protected_header)
.create_ciphertext(plaintext, &[], |data, aad| {
.create_ciphertext(&plaintext, &[], |data, aad| {
let ciphertext =
crate::xchacha20::encrypt_xchacha20_poly1305(&(*key.enc_key).into(), data, aad);
nonce = ciphertext.nonce();
Expand All @@ -48,9 +83,10 @@
pub(crate) fn decrypt_xchacha20_poly1305(
cose_encrypt0_message: &[u8],
key: &crate::XChaCha20Poly1305Key,
) -> Result<Vec<u8>, CryptoError> {
) -> Result<(Vec<u8>, ContentFormat), CryptoError> {
let msg = coset::CoseEncrypt0::from_slice(cose_encrypt0_message)
.map_err(|err| CryptoError::EncString(EncStringParseError::InvalidCoseEncoding(err)))?;

let Some(ref alg) = msg.protected.header.alg else {
return Err(CryptoError::EncString(
EncStringParseError::CoseMissingAlgorithm,
Expand All @@ -59,6 +95,8 @@
if *alg != coset::Algorithm::PrivateUse(XCHACHA20_POLY1305) {
return Err(CryptoError::WrongKeyType);
}
let content_format = ContentFormat::try_from(&msg.protected.header)
.map_err(|_| CryptoError::EncString(EncStringParseError::CoseMissingContentType))?;

let decrypted_message = msg.decrypt(&[], |data, aad| {
let nonce = msg.unprotected.iv.as_slice();
Expand All @@ -71,7 +109,14 @@
aad,
)
})?;
Ok(decrypted_message)

if should_pad_content(&content_format) {
// Unpad the data to get the original plaintext
let data = crate::keys::utils::unpad_bytes(&decrypted_message)?;
return Ok((data.to_vec(), content_format));
}

Ok((decrypted_message, content_format))
}

const SYMMETRIC_KEY: Label = Label::Int(iana::SymmetricKeyParameter::K as i64);
Expand Down Expand Up @@ -112,21 +157,106 @@
}
}

impl From<ContentFormat> for coset::Header {
fn from(format: ContentFormat) -> Self {
let header = coset::HeaderBuilder::new();
let header = match format {
ContentFormat::Utf8 => header.content_type(CONTENT_TYPE_PADDED_UTF8.to_string()),
ContentFormat::Pkcs8 => header.content_format(CoapContentFormat::Pkcs8),
ContentFormat::CoseKey => header.content_format(CoapContentFormat::CoseKey),
ContentFormat::OctetStream => header.content_format(CoapContentFormat::OctetStream),
};
let mut header = header.build();
// This should be adjusted to use the builder pattern once implemented in coset.
// The related coset upstream issue is:
// https://github.com/google/coset/issues/105
header.alg = Some(coset::Algorithm::PrivateUse(XCHACHA20_POLY1305));
header
}
}

impl TryFrom<&coset::Header> for ContentFormat {
type Error = CryptoError;

fn try_from(header: &coset::Header) -> Result<Self, Self::Error> {
match header.content_type.as_ref() {
Some(ContentType::Text(format)) if format == CONTENT_TYPE_PADDED_UTF8 => {
Ok(ContentFormat::Utf8)
}
Some(ContentType::Assigned(CoapContentFormat::Pkcs8)) => Ok(ContentFormat::Pkcs8),
Some(ContentType::Assigned(CoapContentFormat::CoseKey)) => Ok(ContentFormat::CoseKey),
Some(ContentType::Assigned(CoapContentFormat::OctetStream)) => {
Ok(ContentFormat::OctetStream)
}
_ => Err(CryptoError::EncString(
EncStringParseError::CoseMissingContentType,
)),

Check warning on line 193 in crates/bitwarden-crypto/src/cose.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden-crypto/src/cose.rs#L191-L193

Added lines #L191 - L193 were not covered by tests
}
}
}

fn should_pad_content(format: &ContentFormat) -> bool {
matches!(format, ContentFormat::Utf8)
}

#[cfg(test)]
mod test {
use super::*;

#[test]
fn test_encrypt_decrypt_roundtrip() {
fn test_encrypt_decrypt_roundtrip_octetstream() {
let SymmetricCryptoKey::XChaCha20Poly1305Key(ref key) =
SymmetricCryptoKey::make_xchacha20_poly1305_key()
else {
panic!("Failed to create XChaCha20Poly1305Key");

Check warning on line 211 in crates/bitwarden-crypto/src/cose.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden-crypto/src/cose.rs#L211

Added line #L211 was not covered by tests
};

let plaintext = b"Hello, world!";
let encrypted =
encrypt_xchacha20_poly1305(plaintext, key, ContentFormat::OctetStream).unwrap();
let decrypted = decrypt_xchacha20_poly1305(&encrypted, key).unwrap();
assert_eq!(decrypted, (plaintext.to_vec(), ContentFormat::OctetStream));
}

#[test]
fn test_encrypt_decrypt_roundtrip_utf8() {
let SymmetricCryptoKey::XChaCha20Poly1305Key(ref key) =
SymmetricCryptoKey::make_xchacha20_poly1305_key()
else {
panic!("Failed to create XChaCha20Poly1305Key");

Check warning on line 226 in crates/bitwarden-crypto/src/cose.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden-crypto/src/cose.rs#L226

Added line #L226 was not covered by tests
};

let plaintext = b"Hello, world!";
let encrypted = encrypt_xchacha20_poly1305(plaintext, key, ContentFormat::Utf8).unwrap();
let decrypted = decrypt_xchacha20_poly1305(&encrypted, key).unwrap();
assert_eq!(decrypted, (plaintext.to_vec(), ContentFormat::Utf8));
}

#[test]
fn test_encrypt_decrypt_roundtrip_pkcs8() {
let SymmetricCryptoKey::XChaCha20Poly1305Key(ref key) =
SymmetricCryptoKey::make_xchacha20_poly1305_key()
else {
panic!("Failed to create XChaCha20Poly1305Key");

Check warning on line 240 in crates/bitwarden-crypto/src/cose.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden-crypto/src/cose.rs#L240

Added line #L240 was not covered by tests
};

let plaintext = b"Hello, world!";
let encrypted = encrypt_xchacha20_poly1305(plaintext, key, ContentFormat::Pkcs8).unwrap();
let decrypted = decrypt_xchacha20_poly1305(&encrypted, key).unwrap();
assert_eq!(decrypted, (plaintext.to_vec(), ContentFormat::Pkcs8));
}

#[test]
fn test_encrypt_decrypt_roundtrip_cosekey() {
let SymmetricCryptoKey::XChaCha20Poly1305Key(ref key) =
SymmetricCryptoKey::make_xchacha20_poly1305_key()
else {
panic!("Failed to create XChaCha20Poly1305Key");
};

let plaintext = b"Hello, world!";
let encrypted = encrypt_xchacha20_poly1305(plaintext, key).unwrap();
let encrypted = encrypt_xchacha20_poly1305(plaintext, key, ContentFormat::CoseKey).unwrap();
let decrypted = decrypt_xchacha20_poly1305(&encrypted, key).unwrap();
assert_eq!(decrypted, plaintext);
assert_eq!(decrypted, (plaintext.to_vec(), ContentFormat::CoseKey));
}
}
Loading
Loading