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_ciphertext(
114        &[],
115        || CryptoError::MissingField("ciphertext"),
116        |data, aad| {
117            let nonce = msg.unprotected.iv.as_slice();
118            crate::xchacha20::decrypt_xchacha20_poly1305(
119                nonce
120                    .try_into()
121                    .map_err(|_| CryptoError::InvalidNonceLength)?,
122                &(*key.enc_key).into(),
123                data,
124                aad,
125            )
126        },
127    )?;
128
129    if should_pad_content(&content_format) {
130        // Unpad the data to get the original plaintext
131        let data = crate::keys::utils::unpad_bytes(&decrypted_message)?;
132        return Ok((data.to_vec(), content_format));
133    }
134
135    Ok((decrypted_message, content_format))
136}
137
138const SYMMETRIC_KEY: Label = Label::Int(iana::SymmetricKeyParameter::K as i64);
139
140impl TryFrom<&coset::CoseKey> for SymmetricCryptoKey {
141    type Error = CryptoError;
142
143    #[instrument(err, skip_all)]
144    fn try_from(cose_key: &coset::CoseKey) -> Result<Self, Self::Error> {
145        let key_bytes = cose_key
146            .params
147            .iter()
148            .find_map(|(label, value)| match (label, value) {
149                (&SYMMETRIC_KEY, ciborium::Value::Bytes(bytes)) => Some(bytes),
150                _ => None,
151            })
152            .ok_or(CryptoError::InvalidKey)?;
153        let alg = cose_key.alg.as_ref().ok_or(CryptoError::InvalidKey)?;
154        let key_opts = cose_key
155            .key_ops
156            .iter()
157            .map(|op| match op {
158                coset::RegisteredLabel::Assigned(iana::KeyOperation::Encrypt) => {
159                    Ok(KeyOperation::Encrypt)
160                }
161                coset::RegisteredLabel::Assigned(iana::KeyOperation::Decrypt) => {
162                    Ok(KeyOperation::Decrypt)
163                }
164                coset::RegisteredLabel::Assigned(iana::KeyOperation::WrapKey) => {
165                    Ok(KeyOperation::WrapKey)
166                }
167                coset::RegisteredLabel::Assigned(iana::KeyOperation::UnwrapKey) => {
168                    Ok(KeyOperation::UnwrapKey)
169                }
170                _ => Err(CryptoError::InvalidKey),
171            })
172            .collect::<Result<Vec<KeyOperation>, CryptoError>>()?;
173
174        match alg {
175            coset::Algorithm::PrivateUse(XCHACHA20_POLY1305) => {
176                // Ensure the length is correct since `GenericArray::clone_from_slice` panics if it
177                // receives the wrong length.
178                if key_bytes.len() != xchacha20::KEY_SIZE {
179                    return Err(CryptoError::InvalidKey);
180                }
181                let enc_key = Box::pin(GenericArray::<u8, U32>::clone_from_slice(key_bytes));
182                let key_id = cose_key
183                    .key_id
184                    .as_slice()
185                    .try_into()
186                    .map_err(|_| CryptoError::InvalidKey)?;
187                Ok(SymmetricCryptoKey::XChaCha20Poly1305Key(
188                    XChaCha20Poly1305Key {
189                        enc_key,
190                        key_id,
191                        supported_operations: key_opts,
192                    },
193                ))
194            }
195            _ => Err(CryptoError::InvalidKey),
196        }
197    }
198}
199
200impl From<ContentFormat> for coset::HeaderBuilder {
201    fn from(format: ContentFormat) -> Self {
202        let header_builder = coset::HeaderBuilder::new();
203
204        match format {
205            ContentFormat::Utf8 => {
206                header_builder.content_type(CONTENT_TYPE_PADDED_UTF8.to_string())
207            }
208            ContentFormat::Pkcs8PrivateKey => {
209                header_builder.content_format(CoapContentFormat::Pkcs8)
210            }
211            ContentFormat::SPKIPublicKeyDer => {
212                header_builder.content_type(CONTENT_TYPE_SPKI_PUBLIC_KEY.to_string())
213            }
214            ContentFormat::CoseSign1 => header_builder.content_format(CoapContentFormat::CoseSign1),
215            ContentFormat::CoseKey => header_builder.content_format(CoapContentFormat::CoseKey),
216            ContentFormat::CoseEncrypt0 => {
217                header_builder.content_format(CoapContentFormat::CoseEncrypt0)
218            }
219            ContentFormat::BitwardenLegacyKey => {
220                header_builder.content_type(CONTENT_TYPE_BITWARDEN_LEGACY_KEY.to_string())
221            }
222            ContentFormat::OctetStream => {
223                header_builder.content_format(CoapContentFormat::OctetStream)
224            }
225            ContentFormat::Cbor => header_builder.content_format(CoapContentFormat::Cbor),
226        }
227    }
228}
229
230impl TryFrom<&coset::Header> for ContentFormat {
231    type Error = CryptoError;
232
233    fn try_from(header: &coset::Header) -> Result<Self, Self::Error> {
234        match header.content_type.as_ref() {
235            Some(ContentType::Text(format)) if format == CONTENT_TYPE_PADDED_UTF8 => {
236                Ok(ContentFormat::Utf8)
237            }
238            Some(ContentType::Text(format)) if format == CONTENT_TYPE_BITWARDEN_LEGACY_KEY => {
239                Ok(ContentFormat::BitwardenLegacyKey)
240            }
241            Some(ContentType::Text(format)) if format == CONTENT_TYPE_SPKI_PUBLIC_KEY => {
242                Ok(ContentFormat::SPKIPublicKeyDer)
243            }
244            Some(ContentType::Assigned(CoapContentFormat::Pkcs8)) => {
245                Ok(ContentFormat::Pkcs8PrivateKey)
246            }
247            Some(ContentType::Assigned(CoapContentFormat::CoseKey)) => Ok(ContentFormat::CoseKey),
248            Some(ContentType::Assigned(CoapContentFormat::OctetStream)) => {
249                Ok(ContentFormat::OctetStream)
250            }
251            Some(ContentType::Assigned(CoapContentFormat::Cbor)) => Ok(ContentFormat::Cbor),
252            _ => Err(CryptoError::EncString(
253                EncStringParseError::CoseMissingContentType,
254            )),
255        }
256    }
257}
258
259fn should_pad_content(format: &ContentFormat) -> bool {
260    matches!(format, ContentFormat::Utf8)
261}
262
263/// Trait for structs that are serializable to COSE objects.
264pub trait CoseSerializable<T: CoseContentFormat + ConstContentFormat> {
265    /// Serializes the struct to COSE serialization
266    fn to_cose(&self) -> Bytes<T>;
267    /// Deserializes a serialized COSE object to a struct
268    fn from_cose(bytes: &Bytes<T>) -> Result<Self, EncodingError>
269    where
270        Self: Sized;
271}
272
273pub(crate) fn extract_integer(
274    header: &Header,
275    target_label: i64,
276    value_name: &str,
277) -> Result<i128, CoseExtractError> {
278    header
279        .rest
280        .iter()
281        .find_map(|(label, value)| match (label, value) {
282            (Label::Int(label_value), ciborium::Value::Integer(int_value))
283                if *label_value == target_label =>
284            {
285                Some(*int_value)
286            }
287            _ => None,
288        })
289        .map(Into::into)
290        .ok_or_else(|| CoseExtractError::MissingValue(value_name.to_string()))
291}
292
293pub(crate) fn extract_bytes(
294    header: &Header,
295    target_label: i64,
296    value_name: &str,
297) -> Result<Vec<u8>, CoseExtractError> {
298    header
299        .rest
300        .iter()
301        .find_map(|(label, value)| match (label, value) {
302            (Label::Int(label_value), ciborium::Value::Bytes(byte_value))
303                if *label_value == target_label =>
304            {
305                Some(byte_value.clone())
306            }
307            _ => None,
308        })
309        .ok_or(CoseExtractError::MissingValue(value_name.to_string()))
310}
311
312#[derive(Debug, Error)]
313pub(crate) enum CoseExtractError {
314    #[error("Missing value {0}")]
315    MissingValue(String),
316}
317
318#[cfg(test)]
319mod test {
320    use super::*;
321
322    const KEY_ID: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
323    const KEY_DATA: [u8; 32] = [
324        0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
325        0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d,
326        0x1e, 0x1f,
327    ];
328    const TEST_VECTOR_PLAINTEXT: &[u8] = b"Message test vector";
329    const TEST_VECTOR_COSE_ENCRYPT0: &[u8] = &[
330        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,
331        11, 12, 13, 14, 15, 161, 5, 88, 24, 78, 20, 28, 157, 180, 246, 131, 220, 82, 104, 72, 73,
332        75, 43, 69, 139, 216, 167, 145, 220, 67, 168, 144, 173, 88, 35, 127, 234, 194, 83, 189,
333        172, 65, 29, 156, 73, 98, 87, 231, 87, 129, 15, 235, 127, 125, 97, 211, 51, 212, 211, 2,
334        13, 36, 123, 53, 12, 31, 191, 40, 13, 175,
335    ];
336
337    #[test]
338    fn test_encrypt_decrypt_roundtrip_octetstream() {
339        let SymmetricCryptoKey::XChaCha20Poly1305Key(ref key) =
340            SymmetricCryptoKey::make_xchacha20_poly1305_key()
341        else {
342            panic!("Failed to create XChaCha20Poly1305Key");
343        };
344
345        let plaintext = b"Hello, world!";
346        let encrypted =
347            encrypt_xchacha20_poly1305(plaintext, key, ContentFormat::OctetStream).unwrap();
348        let decrypted = decrypt_xchacha20_poly1305(&encrypted, key).unwrap();
349        assert_eq!(decrypted, (plaintext.to_vec(), ContentFormat::OctetStream));
350    }
351
352    #[test]
353    fn test_encrypt_decrypt_roundtrip_utf8() {
354        let SymmetricCryptoKey::XChaCha20Poly1305Key(ref key) =
355            SymmetricCryptoKey::make_xchacha20_poly1305_key()
356        else {
357            panic!("Failed to create XChaCha20Poly1305Key");
358        };
359
360        let plaintext = b"Hello, world!";
361        let encrypted = encrypt_xchacha20_poly1305(plaintext, key, ContentFormat::Utf8).unwrap();
362        let decrypted = decrypt_xchacha20_poly1305(&encrypted, key).unwrap();
363        assert_eq!(decrypted, (plaintext.to_vec(), ContentFormat::Utf8));
364    }
365
366    #[test]
367    fn test_encrypt_decrypt_roundtrip_pkcs8() {
368        let SymmetricCryptoKey::XChaCha20Poly1305Key(ref key) =
369            SymmetricCryptoKey::make_xchacha20_poly1305_key()
370        else {
371            panic!("Failed to create XChaCha20Poly1305Key");
372        };
373
374        let plaintext = b"Hello, world!";
375        let encrypted =
376            encrypt_xchacha20_poly1305(plaintext, key, ContentFormat::Pkcs8PrivateKey).unwrap();
377        let decrypted = decrypt_xchacha20_poly1305(&encrypted, key).unwrap();
378        assert_eq!(
379            decrypted,
380            (plaintext.to_vec(), ContentFormat::Pkcs8PrivateKey)
381        );
382    }
383
384    #[test]
385    fn test_encrypt_decrypt_roundtrip_cosekey() {
386        let SymmetricCryptoKey::XChaCha20Poly1305Key(ref key) =
387            SymmetricCryptoKey::make_xchacha20_poly1305_key()
388        else {
389            panic!("Failed to create XChaCha20Poly1305Key");
390        };
391
392        let plaintext = b"Hello, world!";
393        let encrypted = encrypt_xchacha20_poly1305(plaintext, key, ContentFormat::CoseKey).unwrap();
394        let decrypted = decrypt_xchacha20_poly1305(&encrypted, key).unwrap();
395        assert_eq!(decrypted, (plaintext.to_vec(), ContentFormat::CoseKey));
396    }
397
398    #[test]
399    fn test_decrypt_test_vector() {
400        let key = XChaCha20Poly1305Key {
401            key_id: KEY_ID,
402            enc_key: Box::pin(*GenericArray::from_slice(&KEY_DATA)),
403            supported_operations: vec![
404                KeyOperation::Decrypt,
405                KeyOperation::Encrypt,
406                KeyOperation::WrapKey,
407                KeyOperation::UnwrapKey,
408            ],
409        };
410        let decrypted =
411            decrypt_xchacha20_poly1305(&CoseEncrypt0Bytes::from(TEST_VECTOR_COSE_ENCRYPT0), &key)
412                .unwrap();
413        assert_eq!(
414            decrypted,
415            (TEST_VECTOR_PLAINTEXT.to_vec(), ContentFormat::OctetStream)
416        );
417    }
418
419    #[test]
420    fn test_fail_wrong_key_id() {
421        let key = XChaCha20Poly1305Key {
422            key_id: [1; 16], // Different key ID
423            enc_key: Box::pin(*GenericArray::from_slice(&KEY_DATA)),
424            supported_operations: vec![
425                KeyOperation::Decrypt,
426                KeyOperation::Encrypt,
427                KeyOperation::WrapKey,
428                KeyOperation::UnwrapKey,
429            ],
430        };
431        assert!(matches!(
432            decrypt_xchacha20_poly1305(&CoseEncrypt0Bytes::from(TEST_VECTOR_COSE_ENCRYPT0), &key),
433            Err(CryptoError::WrongCoseKeyId)
434        ));
435    }
436
437    #[test]
438    fn test_fail_wrong_algorithm() {
439        let protected_header = coset::HeaderBuilder::new()
440            .algorithm(iana::Algorithm::A256GCM)
441            .key_id(KEY_ID.to_vec())
442            .build();
443        let nonce = [0u8; 16];
444        let cose_encrypt0 = coset::CoseEncrypt0Builder::new()
445            .protected(protected_header)
446            .create_ciphertext(&[], &[], |_, _| Vec::new())
447            .unprotected(coset::HeaderBuilder::new().iv(nonce.to_vec()).build())
448            .build();
449        let serialized_message = CoseEncrypt0Bytes::from(cose_encrypt0.to_vec().unwrap());
450
451        let key = XChaCha20Poly1305Key {
452            key_id: KEY_ID,
453            enc_key: Box::pin(*GenericArray::from_slice(&KEY_DATA)),
454            supported_operations: vec![
455                KeyOperation::Decrypt,
456                KeyOperation::Encrypt,
457                KeyOperation::WrapKey,
458                KeyOperation::UnwrapKey,
459            ],
460        };
461        assert!(matches!(
462            decrypt_xchacha20_poly1305(&serialized_message, &key),
463            Err(CryptoError::WrongKeyType)
464        ));
465    }
466}