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