-
Notifications
You must be signed in to change notification settings - Fork 11
[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
base: main
Are you sure you want to change the base?
Changes from 18 commits
bc5900b
2e905c4
b41f5ef
eb70dfc
1e2e95c
68be6e6
d6a18a4
6e9e526
50d8f70
e5bd251
b92010b
9635098
b8056c2
9440825
f8cb804
e6939e6
2a202c1
ba9e2c3
a42b1e7
5447cbb
66c345f
9b2f6c9
88b96b7
7e69b7b
b7d2bc8
acb6d12
723ec63
36d8de9
33da07b
2861a9a
1681df9
0cd85fa
f91f0b8
34ee00e
485b6d8
f122ff0
f22531c
ba10631
69d8c57
8833146
47ced5a
42dccb0
9d0ea55
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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::{ | ||
|
@@ -15,22 +21,60 @@ | |
/// 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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue: While suggestion: Move it up out of cose. Maybe a top level concern? |
||
Utf8, | ||
Pkcs8, | ||
CoseKey, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?) There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we're going with a 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
(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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the hassle of being to specific is the crypto system always has to be updated with new types. But maybe that is something we desire? I think what I'm asking is should consumers be allowed to define a custom content format? The fact this is public seems to imply the caller decides the content? In reality we'd like the type system to decide this for us, since you SHOULD not be able to encrypt a key with the wrong content type. |
||
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 protected_header = coset::HeaderBuilder::new(); | ||
let protected_header = match content_format { | ||
// UTF-8 directly would leak the plaintext size. This is not acceptable for certain data | ||
// (passwords). | ||
ContentFormat::Utf8 => protected_header.content_type(CONTENT_TYPE_PADDED_UTF8.to_string()), | ||
ContentFormat::Pkcs8 => protected_header.content_format(CoapContentFormat::Pkcs8), | ||
ContentFormat::CoseKey => protected_header.content_format(CoapContentFormat::CoseKey), | ||
ContentFormat::OctetStream => { | ||
protected_header.content_format(CoapContentFormat::OctetStream) | ||
} | ||
}; | ||
let mut protected_header = protected_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 | ||
protected_header.alg = Some(coset::Algorithm::PrivateUse(XCHACHA20_POLY1305)); | ||
|
||
let encoded_plaintext = if content_format == ContentFormat::Utf8 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: Would it be better to move this up into the match on the content_format? This would protect us against accidentally only changing one in the future and having the content format mismatched from whether the contents are actually padded? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I feel like we should probably do two things here:
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> {
let Some(ref alg) = header.alg else {
return Err(CryptoError::EncString(
EncStringParseError::CoseMissingAlgorithm,
));
};
if *alg != coset::Algorithm::PrivateUse(XCHACHA20_POLY1305) {
return Err(CryptoError::WrongKeyType);
}
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,
)),
}
}
}
fn should_pad_content(format: &ContentFormat) -> bool {
matches!(format, ContentFormat::Utf8)
}
/// 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 plaintext = plaintext.to_vec();
let 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);
}
let mut nonce = [0u8; xchacha20::NONCE_SIZE];
let cose_encrypt0 = ... same as before ...
}
/// Decrypts a COSE Encrypt0 message, using a XChaCha20Poly1305 key
pub(crate) fn decrypt_xchacha20_poly1305(
cose_encrypt0_message: &[u8],
key: &crate::XChaCha20Poly1305Key,
) -> Result<(Vec<u8>, ContentFormat), CryptoError> {
let msg = coset::CoseEncrypt0::from_slice(cose_encrypt0_message)
.map_err(|err| CryptoError::EncString(EncStringParseError::InvalidCoseEncoding(err)))?;
let content_format = ContentFormat::try_from(&msg.protected.header)?;
let decrypted_message = ... same as before ...
if should_pad_content(&content_format) {
// Unpad the data to get the original plaintext
let data = crate::keys::utils::unpad_bytes(&decrypted_message)
.map_err(|_| CryptoError::InvalidPadding)?;
return Ok((data.to_vec(), content_format));
}
Ok((decrypted_message.to_vec(), ContentFormat::OctetStream))
} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice, changed to this split for the most part. I did not move the alg validation into the |
||
// Pad the data to a block size in order to hide plaintext length | ||
let mut plaintext = plaintext.to_vec(); | ||
crate::keys::utils::pad_bytes(&mut plaintext, XCHACHA20_TEXT_PAD_BLOCK_SIZE); | ||
plaintext | ||
} else { | ||
plaintext.to_vec() | ||
}; | ||
|
||
let mut nonce = [0u8; xchacha20::NONCE_SIZE]; | ||
let cose_encrypt0 = coset::CoseEncrypt0Builder::new() | ||
.protected(protected_header) | ||
.create_ciphertext(plaintext, &[], |data, aad| { | ||
.create_ciphertext(&encoded_plaintext, &[], |data, aad| { | ||
let ciphertext = | ||
crate::xchacha20::encrypt_xchacha20_poly1305(&(*key.enc_key).into(), data, aad); | ||
nonce = ciphertext.nonce(); | ||
|
@@ -71,6 +115,13 @@ | |
aad, | ||
) | ||
})?; | ||
|
||
if let Some(ref content_type) = msg.protected.header.content_type { | ||
if *content_type == ContentType::Text(CONTENT_TYPE_PADDED_UTF8.to_string()) { | ||
// Unpad the data to get the original plaintext | ||
return crate::keys::utils::unpad_bytes(&decrypted_message).map(|bytes| bytes.to_vec()); | ||
} | ||
} | ||
Ok(decrypted_message) | ||
} | ||
|
||
|
@@ -125,7 +176,8 @@ | |
}; | ||
|
||
let plaintext = b"Hello, world!"; | ||
let encrypted = encrypt_xchacha20_poly1305(plaintext, key).unwrap(); | ||
let encrypted = | ||
encrypt_xchacha20_poly1305(plaintext, key, ContentFormat::OctetStream).unwrap(); | ||
let decrypted = decrypt_xchacha20_poly1305(&encrypted, key).unwrap(); | ||
assert_eq!(decrypted, plaintext); | ||
} | ||
|
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am slightly concerned with
ContentFormat
leaking outside the crypto crate. Having to rely on the callers to set the content seems potentially fragile.I wonder if we could enforce compile type safety here, a loose thought I have is what if we define
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think @dani-garcia explored something tangentially but we had trouble getting a decent non clunky interface for it.