bitwarden_crypto/
cose.rs

1//! This file contains private-use constants for COSE encoded key types and algorithms.
2//! Standardized values from <https://www.iana.org/assignments/cose/cose.xhtml> should always be preferred
3//! unless there is a a clear benefit, such as a clear cryptographic benefit, which MUST
4//! be documented publicly.
5
6use coset::{
7    CborSerializable, ContentType, Header, Label,
8    iana::{self, CoapContentFormat, KeyOperation},
9};
10use generic_array::GenericArray;
11use thiserror::Error;
12use tracing::instrument;
13use typenum::U32;
14
15use crate::{
16    ContentFormat, CoseEncrypt0Bytes, CryptoError, SymmetricCryptoKey, XChaCha20Poly1305Key,
17    content_format::{Bytes, ConstContentFormat, CoseContentFormat},
18    error::{EncStringParseError, EncodingError},
19    xchacha20,
20};
21
22// Custom COSE algorithm values
23// NOTE: Any algorithm value below -65536 is reserved for private use in the IANA allocations and
24// can be used freely.
25/// XChaCha20 <https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-xchacha-03> is used over ChaCha20
26/// to be able to randomly generate nonces, and to not have to worry about key wearout. Since
27/// the draft was never published as an RFC, we use a private-use value for the algorithm.
28pub(crate) const XCHACHA20_POLY1305: i64 = -70000;
29pub(crate) const ALG_ARGON2ID13: i64 = -71000;
30
31// Custom labels for COSE headers
32// NOTE: Any label below -65536 is reserved for private use in the IANA allocations and can be used
33// freely.
34pub(crate) const ARGON2_SALT: i64 = -71001;
35pub(crate) const ARGON2_ITERATIONS: i64 = -71002;
36pub(crate) const ARGON2_MEMORY: i64 = -71003;
37pub(crate) const ARGON2_PARALLELISM: i64 = -71004;
38/// Indicates for any object containing a key (wrapped key, password protected key envelope) which
39/// key ID that contained key has
40pub(crate) const CONTAINED_KEY_ID: i64 = -71005;
41
42// Note: These are in the "unregistered" tree: https://datatracker.ietf.org/doc/html/rfc6838#section-3.4
43// These are only used within Bitwarden, and not meant for exchange with other systems.
44const CONTENT_TYPE_PADDED_UTF8: &str = "application/x.bitwarden.utf8-padded";
45pub(crate) const CONTENT_TYPE_PADDED_CBOR: &str = "application/x.bitwarden.cbor-padded";
46const CONTENT_TYPE_BITWARDEN_LEGACY_KEY: &str = "application/x.bitwarden.legacy-key";
47const CONTENT_TYPE_SPKI_PUBLIC_KEY: &str = "application/x.bitwarden.spki-public-key";
48
49/// Namespaces
50/// The label used for the namespace ensuring strong domain separation when using signatures.
51pub(crate) const SIGNING_NAMESPACE: i64 = -80000;
52/// The label used for the namespace ensuring strong domain separation when using data envelopes.
53pub(crate) const DATA_ENVELOPE_NAMESPACE: i64 = -80001;
54
55const XCHACHA20_TEXT_PAD_BLOCK_SIZE: usize = 32;
56
57/// Encrypts a plaintext message using XChaCha20Poly1305 and returns a COSE Encrypt0 message
58pub(crate) fn encrypt_xchacha20_poly1305(
59    plaintext: &[u8],
60    key: &crate::XChaCha20Poly1305Key,
61    content_format: ContentFormat,
62) -> Result<CoseEncrypt0Bytes, CryptoError> {
63    let mut plaintext = plaintext.to_vec();
64
65    let header_builder: coset::HeaderBuilder = content_format.into();
66    let mut protected_header = header_builder.key_id(key.key_id.to_vec()).build();
67    // This should be adjusted to use the builder pattern once implemented in coset.
68    // The related coset upstream issue is:
69    // https://github.com/google/coset/issues/105
70    protected_header.alg = Some(coset::Algorithm::PrivateUse(XCHACHA20_POLY1305));
71
72    if should_pad_content(&content_format) {
73        // Pad the data to a block size in order to hide plaintext length
74        let min_length =
75            XCHACHA20_TEXT_PAD_BLOCK_SIZE * (1 + (plaintext.len() / XCHACHA20_TEXT_PAD_BLOCK_SIZE));
76        crate::keys::utils::pad_bytes(&mut plaintext, min_length)?;
77    }
78
79    let mut nonce = [0u8; xchacha20::NONCE_SIZE];
80    let cose_encrypt0 = coset::CoseEncrypt0Builder::new()
81        .protected(protected_header)
82        .create_ciphertext(&plaintext, &[], |data, aad| {
83            let ciphertext =
84                crate::xchacha20::encrypt_xchacha20_poly1305(&(*key.enc_key).into(), data, aad);
85            nonce = ciphertext.nonce();
86            ciphertext.encrypted_bytes().to_vec()
87        })
88        .unprotected(coset::HeaderBuilder::new().iv(nonce.to_vec()).build())
89        .build();
90
91    cose_encrypt0
92        .to_vec()
93        .map_err(|err| CryptoError::EncString(EncStringParseError::InvalidCoseEncoding(err)))
94        .map(CoseEncrypt0Bytes::from)
95}
96
97/// Decrypts a COSE Encrypt0 message, using a XChaCha20Poly1305 key
98pub(crate) fn decrypt_xchacha20_poly1305(
99    cose_encrypt0_message: &CoseEncrypt0Bytes,
100    key: &crate::XChaCha20Poly1305Key,
101) -> Result<(Vec<u8>, ContentFormat), CryptoError> {
102    let msg = coset::CoseEncrypt0::from_slice(cose_encrypt0_message.as_ref())
103        .map_err(|err| CryptoError::EncString(EncStringParseError::InvalidCoseEncoding(err)))?;
104
105    let Some(ref alg) = msg.protected.header.alg else {
106        return Err(CryptoError::EncString(
107            EncStringParseError::CoseMissingAlgorithm,
108        ));
109    };
110
111    if *alg != coset::Algorithm::PrivateUse(XCHACHA20_POLY1305) {
112        return Err(CryptoError::WrongKeyType);
113    }
114
115    let content_format = ContentFormat::try_from(&msg.protected.header)
116        .map_err(|_| CryptoError::EncString(EncStringParseError::CoseMissingContentType))?;
117
118    if key.key_id != *msg.protected.header.key_id {
119        return Err(CryptoError::WrongCoseKeyId);
120    }
121
122    let decrypted_message = msg.decrypt_ciphertext(
123        &[],
124        || CryptoError::MissingField("ciphertext"),
125        |data, aad| {
126            let nonce = msg.unprotected.iv.as_slice();
127            crate::xchacha20::decrypt_xchacha20_poly1305(
128                nonce
129                    .try_into()
130                    .map_err(|_| CryptoError::InvalidNonceLength)?,
131                &(*key.enc_key).into(),
132                data,
133                aad,
134            )
135        },
136    )?;
137
138    if should_pad_content(&content_format) {
139        // Unpad the data to get the original plaintext
140        let data = crate::keys::utils::unpad_bytes(&decrypted_message)?;
141        return Ok((data.to_vec(), content_format));
142    }
143
144    Ok((decrypted_message, content_format))
145}
146
147const SYMMETRIC_KEY: Label = Label::Int(iana::SymmetricKeyParameter::K as i64);
148
149impl TryFrom<&coset::CoseKey> for SymmetricCryptoKey {
150    type Error = CryptoError;
151
152    #[instrument(err, skip_all)]
153    fn try_from(cose_key: &coset::CoseKey) -> Result<Self, Self::Error> {
154        let key_bytes = cose_key
155            .params
156            .iter()
157            .find_map(|(label, value)| match (label, value) {
158                (&SYMMETRIC_KEY, ciborium::Value::Bytes(bytes)) => Some(bytes),
159                _ => None,
160            })
161            .ok_or(CryptoError::InvalidKey)?;
162        let alg = cose_key.alg.as_ref().ok_or(CryptoError::InvalidKey)?;
163        let key_opts = cose_key
164            .key_ops
165            .iter()
166            .map(|op| match op {
167                coset::RegisteredLabel::Assigned(iana::KeyOperation::Encrypt) => {
168                    Ok(KeyOperation::Encrypt)
169                }
170                coset::RegisteredLabel::Assigned(iana::KeyOperation::Decrypt) => {
171                    Ok(KeyOperation::Decrypt)
172                }
173                coset::RegisteredLabel::Assigned(iana::KeyOperation::WrapKey) => {
174                    Ok(KeyOperation::WrapKey)
175                }
176                coset::RegisteredLabel::Assigned(iana::KeyOperation::UnwrapKey) => {
177                    Ok(KeyOperation::UnwrapKey)
178                }
179                _ => Err(CryptoError::InvalidKey),
180            })
181            .collect::<Result<Vec<KeyOperation>, CryptoError>>()?;
182
183        match alg {
184            coset::Algorithm::PrivateUse(XCHACHA20_POLY1305) => {
185                // Ensure the length is correct since `GenericArray::clone_from_slice` panics if it
186                // receives the wrong length.
187                if key_bytes.len() != xchacha20::KEY_SIZE {
188                    return Err(CryptoError::InvalidKey);
189                }
190                let enc_key = Box::pin(GenericArray::<u8, U32>::clone_from_slice(key_bytes));
191                let key_id = cose_key
192                    .key_id
193                    .as_slice()
194                    .try_into()
195                    .map_err(|_| CryptoError::InvalidKey)?;
196                Ok(SymmetricCryptoKey::XChaCha20Poly1305Key(
197                    XChaCha20Poly1305Key {
198                        enc_key,
199                        key_id,
200                        supported_operations: key_opts,
201                    },
202                ))
203            }
204            _ => Err(CryptoError::InvalidKey),
205        }
206    }
207}
208
209impl From<ContentFormat> for coset::HeaderBuilder {
210    fn from(format: ContentFormat) -> Self {
211        let header_builder = coset::HeaderBuilder::new();
212
213        match format {
214            ContentFormat::Utf8 => {
215                header_builder.content_type(CONTENT_TYPE_PADDED_UTF8.to_string())
216            }
217            ContentFormat::Pkcs8PrivateKey => {
218                header_builder.content_format(CoapContentFormat::Pkcs8)
219            }
220            ContentFormat::SPKIPublicKeyDer => {
221                header_builder.content_type(CONTENT_TYPE_SPKI_PUBLIC_KEY.to_string())
222            }
223            ContentFormat::CoseSign1 => header_builder.content_format(CoapContentFormat::CoseSign1),
224            ContentFormat::CoseKey => header_builder.content_format(CoapContentFormat::CoseKey),
225            ContentFormat::CoseEncrypt0 => {
226                header_builder.content_format(CoapContentFormat::CoseEncrypt0)
227            }
228            ContentFormat::BitwardenLegacyKey => {
229                header_builder.content_type(CONTENT_TYPE_BITWARDEN_LEGACY_KEY.to_string())
230            }
231            ContentFormat::OctetStream => {
232                header_builder.content_format(CoapContentFormat::OctetStream)
233            }
234            ContentFormat::Cbor => header_builder.content_format(CoapContentFormat::Cbor),
235        }
236    }
237}
238
239impl TryFrom<&coset::Header> for ContentFormat {
240    type Error = CryptoError;
241
242    fn try_from(header: &coset::Header) -> Result<Self, Self::Error> {
243        match header.content_type.as_ref() {
244            Some(ContentType::Text(format)) if format == CONTENT_TYPE_PADDED_UTF8 => {
245                Ok(ContentFormat::Utf8)
246            }
247            Some(ContentType::Text(format)) if format == CONTENT_TYPE_BITWARDEN_LEGACY_KEY => {
248                Ok(ContentFormat::BitwardenLegacyKey)
249            }
250            Some(ContentType::Text(format)) if format == CONTENT_TYPE_SPKI_PUBLIC_KEY => {
251                Ok(ContentFormat::SPKIPublicKeyDer)
252            }
253            Some(ContentType::Assigned(CoapContentFormat::Pkcs8)) => {
254                Ok(ContentFormat::Pkcs8PrivateKey)
255            }
256            Some(ContentType::Assigned(CoapContentFormat::CoseKey)) => Ok(ContentFormat::CoseKey),
257            Some(ContentType::Assigned(CoapContentFormat::OctetStream)) => {
258                Ok(ContentFormat::OctetStream)
259            }
260            Some(ContentType::Assigned(CoapContentFormat::Cbor)) => Ok(ContentFormat::Cbor),
261            _ => Err(CryptoError::EncString(
262                EncStringParseError::CoseMissingContentType,
263            )),
264        }
265    }
266}
267
268fn should_pad_content(format: &ContentFormat) -> bool {
269    matches!(format, ContentFormat::Utf8)
270}
271
272/// Trait for structs that are serializable to COSE objects.
273pub trait CoseSerializable<T: CoseContentFormat + ConstContentFormat> {
274    /// Serializes the struct to COSE serialization
275    fn to_cose(&self) -> Bytes<T>;
276    /// Deserializes a serialized COSE object to a struct
277    fn from_cose(bytes: &Bytes<T>) -> Result<Self, EncodingError>
278    where
279        Self: Sized;
280}
281
282pub(crate) fn extract_integer(
283    header: &Header,
284    target_label: i64,
285    value_name: &str,
286) -> Result<i128, CoseExtractError> {
287    header
288        .rest
289        .iter()
290        .find_map(|(label, value)| match (label, value) {
291            (Label::Int(label_value), ciborium::Value::Integer(int_value))
292                if *label_value == target_label =>
293            {
294                Some(*int_value)
295            }
296            _ => None,
297        })
298        .map(Into::into)
299        .ok_or_else(|| CoseExtractError::MissingValue(value_name.to_string()))
300}
301
302pub(crate) fn extract_bytes(
303    header: &Header,
304    target_label: i64,
305    value_name: &str,
306) -> Result<Vec<u8>, CoseExtractError> {
307    header
308        .rest
309        .iter()
310        .find_map(|(label, value)| match (label, value) {
311            (Label::Int(label_value), ciborium::Value::Bytes(byte_value))
312                if *label_value == target_label =>
313            {
314                Some(byte_value.clone())
315            }
316            _ => None,
317        })
318        .ok_or(CoseExtractError::MissingValue(value_name.to_string()))
319}
320
321#[derive(Debug, Error)]
322pub(crate) enum CoseExtractError {
323    #[error("Missing value {0}")]
324    MissingValue(String),
325}
326
327#[cfg(test)]
328mod test {
329    use super::*;
330
331    const KEY_ID: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
332    const KEY_DATA: [u8; 32] = [
333        0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
334        0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d,
335        0x1e, 0x1f,
336    ];
337    const TEST_VECTOR_PLAINTEXT: &[u8] = b"Message test vector";
338    const TEST_VECTOR_COSE_ENCRYPT0: &[u8] = &[
339        131, 88, 28, 163, 1, 58, 0, 1, 17, 111, 3, 24, 42, 4, 80, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
340        11, 12, 13, 14, 15, 161, 5, 88, 24, 78, 20, 28, 157, 180, 246, 131, 220, 82, 104, 72, 73,
341        75, 43, 69, 139, 216, 167, 145, 220, 67, 168, 144, 173, 88, 35, 127, 234, 194, 83, 189,
342        172, 65, 29, 156, 73, 98, 87, 231, 87, 129, 15, 235, 127, 125, 97, 211, 51, 212, 211, 2,
343        13, 36, 123, 53, 12, 31, 191, 40, 13, 175,
344    ];
345
346    #[test]
347    fn test_encrypt_decrypt_roundtrip_octetstream() {
348        let SymmetricCryptoKey::XChaCha20Poly1305Key(ref key) =
349            SymmetricCryptoKey::make_xchacha20_poly1305_key()
350        else {
351            panic!("Failed to create XChaCha20Poly1305Key");
352        };
353
354        let plaintext = b"Hello, world!";
355        let encrypted =
356            encrypt_xchacha20_poly1305(plaintext, key, ContentFormat::OctetStream).unwrap();
357        let decrypted = decrypt_xchacha20_poly1305(&encrypted, key).unwrap();
358        assert_eq!(decrypted, (plaintext.to_vec(), ContentFormat::OctetStream));
359    }
360
361    #[test]
362    fn test_encrypt_decrypt_roundtrip_utf8() {
363        let SymmetricCryptoKey::XChaCha20Poly1305Key(ref key) =
364            SymmetricCryptoKey::make_xchacha20_poly1305_key()
365        else {
366            panic!("Failed to create XChaCha20Poly1305Key");
367        };
368
369        let plaintext = b"Hello, world!";
370        let encrypted = encrypt_xchacha20_poly1305(plaintext, key, ContentFormat::Utf8).unwrap();
371        let decrypted = decrypt_xchacha20_poly1305(&encrypted, key).unwrap();
372        assert_eq!(decrypted, (plaintext.to_vec(), ContentFormat::Utf8));
373    }
374
375    #[test]
376    fn test_encrypt_decrypt_roundtrip_pkcs8() {
377        let SymmetricCryptoKey::XChaCha20Poly1305Key(ref key) =
378            SymmetricCryptoKey::make_xchacha20_poly1305_key()
379        else {
380            panic!("Failed to create XChaCha20Poly1305Key");
381        };
382
383        let plaintext = b"Hello, world!";
384        let encrypted =
385            encrypt_xchacha20_poly1305(plaintext, key, ContentFormat::Pkcs8PrivateKey).unwrap();
386        let decrypted = decrypt_xchacha20_poly1305(&encrypted, key).unwrap();
387        assert_eq!(
388            decrypted,
389            (plaintext.to_vec(), ContentFormat::Pkcs8PrivateKey)
390        );
391    }
392
393    #[test]
394    fn test_encrypt_decrypt_roundtrip_cosekey() {
395        let SymmetricCryptoKey::XChaCha20Poly1305Key(ref key) =
396            SymmetricCryptoKey::make_xchacha20_poly1305_key()
397        else {
398            panic!("Failed to create XChaCha20Poly1305Key");
399        };
400
401        let plaintext = b"Hello, world!";
402        let encrypted = encrypt_xchacha20_poly1305(plaintext, key, ContentFormat::CoseKey).unwrap();
403        let decrypted = decrypt_xchacha20_poly1305(&encrypted, key).unwrap();
404        assert_eq!(decrypted, (plaintext.to_vec(), ContentFormat::CoseKey));
405    }
406
407    #[test]
408    fn test_decrypt_test_vector() {
409        let key = XChaCha20Poly1305Key {
410            key_id: KEY_ID,
411            enc_key: Box::pin(*GenericArray::from_slice(&KEY_DATA)),
412            supported_operations: vec![
413                KeyOperation::Decrypt,
414                KeyOperation::Encrypt,
415                KeyOperation::WrapKey,
416                KeyOperation::UnwrapKey,
417            ],
418        };
419        let decrypted =
420            decrypt_xchacha20_poly1305(&CoseEncrypt0Bytes::from(TEST_VECTOR_COSE_ENCRYPT0), &key)
421                .unwrap();
422        assert_eq!(
423            decrypted,
424            (TEST_VECTOR_PLAINTEXT.to_vec(), ContentFormat::OctetStream)
425        );
426    }
427
428    #[test]
429    fn test_fail_wrong_key_id() {
430        let key = XChaCha20Poly1305Key {
431            key_id: [1; 16], // Different key ID
432            enc_key: Box::pin(*GenericArray::from_slice(&KEY_DATA)),
433            supported_operations: vec![
434                KeyOperation::Decrypt,
435                KeyOperation::Encrypt,
436                KeyOperation::WrapKey,
437                KeyOperation::UnwrapKey,
438            ],
439        };
440        assert!(matches!(
441            decrypt_xchacha20_poly1305(&CoseEncrypt0Bytes::from(TEST_VECTOR_COSE_ENCRYPT0), &key),
442            Err(CryptoError::WrongCoseKeyId)
443        ));
444    }
445
446    #[test]
447    fn test_fail_wrong_algorithm() {
448        let protected_header = coset::HeaderBuilder::new()
449            .algorithm(iana::Algorithm::A256GCM)
450            .key_id(KEY_ID.to_vec())
451            .build();
452        let nonce = [0u8; 16];
453        let cose_encrypt0 = coset::CoseEncrypt0Builder::new()
454            .protected(protected_header)
455            .create_ciphertext(&[], &[], |_, _| Vec::new())
456            .unprotected(coset::HeaderBuilder::new().iv(nonce.to_vec()).build())
457            .build();
458        let serialized_message = CoseEncrypt0Bytes::from(cose_encrypt0.to_vec().unwrap());
459
460        let key = XChaCha20Poly1305Key {
461            key_id: KEY_ID,
462            enc_key: Box::pin(*GenericArray::from_slice(&KEY_DATA)),
463            supported_operations: vec![
464                KeyOperation::Decrypt,
465                KeyOperation::Encrypt,
466                KeyOperation::WrapKey,
467                KeyOperation::UnwrapKey,
468            ],
469        };
470        assert!(matches!(
471            decrypt_xchacha20_poly1305(&serialized_message, &key),
472            Err(CryptoError::WrongKeyType)
473        ));
474    }
475}