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