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