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