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