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