Skip to main content

rainier_crypt/
cipher.rs

1//! The symmetric AEAD algorithms, and the key derivation that feeds them.
2
3use aes_gcm::{Aes128Gcm, Aes256Gcm};
4use aes_gcm_siv::Aes256GcmSiv;
5use chacha20poly1305::aead::{Aead, KeyInit, Payload};
6use chacha20poly1305::{ChaCha20Poly1305, XChaCha20Poly1305};
7use hkdf::Hkdf;
8use rainier_support::{Error, Result};
9use sha2::Sha256;
10
11use crate::key::Key;
12
13/// A symmetric authenticated cipher.
14///
15/// Every one here is an **AEAD**: it authenticates as well as encrypts, so
16/// there is no way to decrypt without checking integrity first. A plain cipher
17/// with no tag is not offered, because using one correctly requires bolting a
18/// MAC on in exactly the right order and getting it wrong is silent.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum Cipher {
21    /// XChaCha20-Poly1305. **The default, and the one to use.**
22    ///
23    /// Its 192-bit nonce is large enough that a random nonce per message has no
24    /// meaningful collision risk, so there is no counter to keep and no state
25    /// to synchronise between processes.
26    #[default]
27    XChaCha20Poly1305,
28
29    /// ChaCha20-Poly1305, as used by TLS.
30    ///
31    /// A 96-bit nonce, which is small enough that random nonces have a real
32    /// birthday bound — safe for a few billion messages under one key, and a
33    /// reason to prefer the X variant when you are not constrained by a peer.
34    ChaCha20Poly1305,
35
36    /// AES-256-GCM. For interoperating with something that requires it, or
37    /// where hardware AES makes it measurably faster.
38    ///
39    /// The same 96-bit nonce caveat as `ChaCha20Poly1305`, and a harsher
40    /// failure: repeating a nonce under GCM leaks the authentication key, not
41    /// just the plaintext relationship.
42    Aes256Gcm,
43
44    /// AES-128-GCM. A shorter key, derived from the same 256-bit key material.
45    Aes128Gcm,
46
47    /// AES-256-GCM-SIV — nonce-misuse resistant.
48    ///
49    /// Repeating a nonce here reveals only that two plaintexts were equal,
50    /// rather than breaking the cipher. Worth choosing when nonces come from
51    /// somewhere you do not fully control.
52    Aes256GcmSiv,
53}
54
55impl Cipher {
56    /// Every cipher, for a diagnostic listing and for tests.
57    pub const ALL: [Cipher; 5] = [
58        Cipher::XChaCha20Poly1305,
59        Cipher::ChaCha20Poly1305,
60        Cipher::Aes256Gcm,
61        Cipher::Aes128Gcm,
62        Cipher::Aes256GcmSiv,
63    ];
64
65    /// The short identifier written into a payload.
66    pub fn id(self) -> &'static str {
67        match self {
68            Cipher::XChaCha20Poly1305 => "xc20p",
69            Cipher::ChaCha20Poly1305 => "c20p",
70            Cipher::Aes256Gcm => "a256gcm",
71            Cipher::Aes128Gcm => "a128gcm",
72            Cipher::Aes256GcmSiv => "a256siv",
73        }
74    }
75
76    /// The cipher a payload names.
77    ///
78    /// `v1` is accepted as an alias for XChaCha20-Poly1305: it is what the
79    /// first release wrote, before payloads named their algorithm, and those
80    /// payloads still have to open.
81    pub fn from_id(id: &str) -> Option<Self> {
82        match id {
83            "xc20p" | "v1" => Some(Cipher::XChaCha20Poly1305),
84            "c20p" => Some(Cipher::ChaCha20Poly1305),
85            "a256gcm" => Some(Cipher::Aes256Gcm),
86            "a128gcm" => Some(Cipher::Aes128Gcm),
87            "a256siv" => Some(Cipher::Aes256GcmSiv),
88            _ => None,
89        }
90    }
91
92    /// The key length this cipher needs, in bytes.
93    pub fn key_len(self) -> usize {
94        match self {
95            Cipher::Aes128Gcm => 16,
96            _ => 32,
97        }
98    }
99
100    /// The nonce length this cipher needs, in bytes.
101    pub fn nonce_len(self) -> usize {
102        match self {
103            Cipher::XChaCha20Poly1305 => 24,
104            _ => 12,
105        }
106    }
107
108    /// Whether repeating a nonce is survivable.
109    pub fn is_nonce_misuse_resistant(self) -> bool {
110        matches!(self, Cipher::Aes256GcmSiv)
111    }
112
113    /// Derive this cipher's key from a ring key.
114    ///
115    /// HKDF with the cipher's id as `info`, which does two things: it produces
116    /// the right length for AES-128, and it gives every cipher a **different**
117    /// key from the same material. Without that separation, encrypting one
118    /// value under two ciphers with one key would reuse key material across
119    /// primitives — which is the kind of thing that is fine until a specific
120    /// pair of algorithms interacts badly.
121    fn derive(self, key: &Key) -> Result<Vec<u8>> {
122        let hkdf = Hkdf::<Sha256>::new(Some(b"rainier-cipher"), key.bytes());
123        let mut out = vec![0u8; self.key_len()];
124        hkdf.expand(self.id().as_bytes(), &mut out)
125            .map_err(|_| Error::internal("could not derive a cipher key"))?;
126        Ok(out)
127    }
128
129    /// Encrypt `plain`, authenticating `aad` alongside it.
130    pub fn encrypt(self, key: &Key, nonce: &[u8], aad: &[u8], plain: &[u8]) -> Result<Vec<u8>> {
131        self.check_nonce(nonce)?;
132        let derived = self.derive(key)?;
133        let payload = Payload { msg: plain, aad };
134        let failed = || Error::internal("encryption failed");
135
136        match self {
137            Cipher::XChaCha20Poly1305 => XChaCha20Poly1305::new_from_slice(&derived)
138                .map_err(|_| failed())?
139                .encrypt(nonce.into(), payload)
140                .map_err(|_| failed()),
141            Cipher::ChaCha20Poly1305 => ChaCha20Poly1305::new_from_slice(&derived)
142                .map_err(|_| failed())?
143                .encrypt(nonce.into(), payload)
144                .map_err(|_| failed()),
145            Cipher::Aes256Gcm => {
146                use aes_gcm::aead::Aead as _;
147                aes_gcm::KeyInit::new_from_slice(&derived)
148                    .map(|cipher: Aes256Gcm| cipher)
149                    .map_err(|_| failed())?
150                    .encrypt(nonce.into(), payload)
151                    .map_err(|_| failed())
152            }
153            Cipher::Aes128Gcm => {
154                use aes_gcm::aead::Aead as _;
155                aes_gcm::KeyInit::new_from_slice(&derived)
156                    .map(|cipher: Aes128Gcm| cipher)
157                    .map_err(|_| failed())?
158                    .encrypt(nonce.into(), payload)
159                    .map_err(|_| failed())
160            }
161            Cipher::Aes256GcmSiv => {
162                use aes_gcm_siv::aead::Aead as _;
163                aes_gcm_siv::KeyInit::new_from_slice(&derived)
164                    .map(|cipher: Aes256GcmSiv| cipher)
165                    .map_err(|_| failed())?
166                    .encrypt(nonce.into(), payload)
167                    .map_err(|_| failed())
168            }
169        }
170    }
171
172    /// Decrypt, verifying `aad`.
173    ///
174    /// Every failure is the same error: a caller can do nothing useful with the
175    /// difference between "the tag was wrong" and "the length was wrong", and
176    /// telling them apart is how padding-oracle attacks start.
177    pub fn decrypt(self, key: &Key, nonce: &[u8], aad: &[u8], sealed: &[u8]) -> Result<Vec<u8>> {
178        self.check_nonce(nonce)?;
179        let derived = self.derive(key)?;
180        let payload = Payload { msg: sealed, aad };
181        let failed = || Error::bad_request("the payload could not be decrypted");
182
183        match self {
184            Cipher::XChaCha20Poly1305 => XChaCha20Poly1305::new_from_slice(&derived)
185                .map_err(|_| failed())?
186                .decrypt(nonce.into(), payload)
187                .map_err(|_| failed()),
188            Cipher::ChaCha20Poly1305 => ChaCha20Poly1305::new_from_slice(&derived)
189                .map_err(|_| failed())?
190                .decrypt(nonce.into(), payload)
191                .map_err(|_| failed()),
192            Cipher::Aes256Gcm => {
193                use aes_gcm::aead::Aead as _;
194                aes_gcm::KeyInit::new_from_slice(&derived)
195                    .map(|cipher: Aes256Gcm| cipher)
196                    .map_err(|_| failed())?
197                    .decrypt(nonce.into(), payload)
198                    .map_err(|_| failed())
199            }
200            Cipher::Aes128Gcm => {
201                use aes_gcm::aead::Aead as _;
202                aes_gcm::KeyInit::new_from_slice(&derived)
203                    .map(|cipher: Aes128Gcm| cipher)
204                    .map_err(|_| failed())?
205                    .decrypt(nonce.into(), payload)
206                    .map_err(|_| failed())
207            }
208            Cipher::Aes256GcmSiv => {
209                use aes_gcm_siv::aead::Aead as _;
210                aes_gcm_siv::KeyInit::new_from_slice(&derived)
211                    .map(|cipher: Aes256GcmSiv| cipher)
212                    .map_err(|_| failed())?
213                    .decrypt(nonce.into(), payload)
214                    .map_err(|_| failed())
215            }
216        }
217    }
218
219    /// A fresh nonce of the right length for this cipher.
220    pub fn nonce(self) -> Vec<u8> {
221        use rand::RngCore;
222
223        let mut nonce = vec![0u8; self.nonce_len()];
224        rand::rngs::OsRng.fill_bytes(&mut nonce);
225        nonce
226    }
227
228    fn check_nonce(self, nonce: &[u8]) -> Result<()> {
229        if nonce.len() == self.nonce_len() {
230            Ok(())
231        } else {
232            Err(Error::bad_request("the payload could not be decrypted"))
233        }
234    }
235}
236
237impl std::fmt::Display for Cipher {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        f.write_str(self.id())
240    }
241}
242
243impl std::str::FromStr for Cipher {
244    type Err = Error;
245
246    fn from_str(value: &str) -> Result<Self> {
247        Self::from_id(value).ok_or_else(|| {
248            Error::internal(format!(
249                "`{value}` is not a known cipher; expected one of {}",
250                Cipher::ALL.iter().map(|c| c.id()).collect::<Vec<_>>().join(", ")
251            ))
252        })
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    fn key() -> Key {
261        Key::generate()
262    }
263
264    #[test]
265    fn every_cipher_round_trips() {
266        let key = key();
267
268        for cipher in Cipher::ALL {
269            let nonce = cipher.nonce();
270            let sealed = cipher.encrypt(&key, &nonce, b"aad", b"the message").unwrap();
271            let opened = cipher.decrypt(&key, &nonce, b"aad", &sealed).unwrap();
272
273            assert_eq!(opened, b"the message", "{cipher}");
274            assert_ne!(sealed, b"the message", "{cipher}");
275        }
276    }
277
278    #[test]
279    fn every_cipher_detects_tampering() {
280        let key = key();
281
282        for cipher in Cipher::ALL {
283            let nonce = cipher.nonce();
284            let mut sealed = cipher.encrypt(&key, &nonce, b"aad", b"transfer 10").unwrap();
285            sealed[0] ^= 0x01;
286
287            assert!(cipher.decrypt(&key, &nonce, b"aad", &sealed).is_err(), "{cipher}");
288        }
289    }
290
291    #[test]
292    fn every_cipher_authenticates_its_aad() {
293        let key = key();
294
295        for cipher in Cipher::ALL {
296            let nonce = cipher.nonce();
297            let sealed = cipher.encrypt(&key, &nonce, b"header-a", b"message").unwrap();
298
299            assert!(
300                cipher.decrypt(&key, &nonce, b"header-b", &sealed).is_err(),
301                "{cipher} must not open with different associated data"
302            );
303        }
304    }
305
306    #[test]
307    fn a_wrong_nonce_length_is_refused() {
308        let key = key();
309
310        for cipher in Cipher::ALL {
311            assert!(cipher.encrypt(&key, &[0u8; 7], b"", b"x").is_err(), "{cipher}");
312            assert!(cipher.decrypt(&key, &[0u8; 7], b"", b"x").is_err(), "{cipher}");
313        }
314    }
315
316    #[test]
317    fn ids_round_trip_and_are_distinct() {
318        let mut seen = std::collections::HashSet::new();
319
320        for cipher in Cipher::ALL {
321            assert_eq!(Cipher::from_id(cipher.id()), Some(cipher));
322            assert!(seen.insert(cipher.id()), "duplicate id: {cipher}");
323        }
324    }
325
326    #[test]
327    fn the_legacy_v1_id_still_names_xchacha() {
328        // Payloads written by the first release say `v1`, and they must open.
329        assert_eq!(Cipher::from_id("v1"), Some(Cipher::XChaCha20Poly1305));
330    }
331
332    #[test]
333    fn an_unknown_id_is_none_and_parses_to_a_helpful_error() {
334        assert_eq!(Cipher::from_id("rot13"), None);
335
336        let err = "rot13".parse::<Cipher>().unwrap_err();
337        assert!(err.message().contains("xc20p"), "{}", err.message());
338    }
339
340    #[test]
341    fn the_default_is_xchacha() {
342        assert_eq!(Cipher::default(), Cipher::XChaCha20Poly1305);
343    }
344
345    #[test]
346    fn each_cipher_derives_a_different_key_from_the_same_material() {
347        // Domain separation: the same ring key must not produce the same
348        // cipher key for two different algorithms.
349        let key = key();
350        let mut derived = std::collections::HashSet::new();
351
352        for cipher in Cipher::ALL {
353            assert!(derived.insert(cipher.derive(&key).unwrap()), "{cipher} shares a key");
354        }
355    }
356
357    #[test]
358    fn derivation_is_deterministic() {
359        let key = key();
360        assert_eq!(
361            Cipher::Aes256Gcm.derive(&key).unwrap(),
362            Cipher::Aes256Gcm.derive(&key).unwrap()
363        );
364    }
365
366    #[test]
367    fn aes_128_gets_a_short_key_and_the_rest_a_long_one() {
368        let key = key();
369
370        assert_eq!(Cipher::Aes128Gcm.derive(&key).unwrap().len(), 16);
371        for cipher in Cipher::ALL.iter().filter(|c| **c != Cipher::Aes128Gcm) {
372            assert_eq!(cipher.derive(&key).unwrap().len(), 32, "{cipher}");
373        }
374    }
375
376    #[test]
377    fn one_cipher_cannot_open_anothers_payload() {
378        let key = key();
379        let nonce = Cipher::Aes256Gcm.nonce();
380        let sealed = Cipher::Aes256Gcm.encrypt(&key, &nonce, b"", b"secret").unwrap();
381
382        // Same nonce length, same key material, different algorithm and
383        // therefore a different derived key.
384        assert!(Cipher::ChaCha20Poly1305.decrypt(&key, &nonce, b"", &sealed).is_err());
385    }
386
387    #[test]
388    fn nonces_are_the_right_length_and_not_repeated() {
389        for cipher in Cipher::ALL {
390            let a = cipher.nonce();
391            let b = cipher.nonce();
392
393            assert_eq!(a.len(), cipher.nonce_len(), "{cipher}");
394            assert_ne!(a, b, "{cipher}");
395        }
396    }
397
398    #[test]
399    fn only_siv_claims_misuse_resistance() {
400        assert!(Cipher::Aes256GcmSiv.is_nonce_misuse_resistant());
401        for cipher in Cipher::ALL.iter().filter(|c| **c != Cipher::Aes256GcmSiv) {
402            assert!(!cipher.is_nonce_misuse_resistant(), "{cipher}");
403        }
404    }
405
406    #[test]
407    fn a_repeated_nonce_under_siv_reveals_only_equality() {
408        // Not a licence to reuse nonces — a demonstration of the difference.
409        let key = key();
410        let nonce = Cipher::Aes256GcmSiv.nonce();
411
412        let one = Cipher::Aes256GcmSiv.encrypt(&key, &nonce, b"", b"same").unwrap();
413        let two = Cipher::Aes256GcmSiv.encrypt(&key, &nonce, b"", b"same").unwrap();
414        let other = Cipher::Aes256GcmSiv.encrypt(&key, &nonce, b"", b"different").unwrap();
415
416        assert_eq!(one, two, "SIV is deterministic for a given key, nonce and message");
417        assert_ne!(one, other);
418        assert_eq!(Cipher::Aes256GcmSiv.decrypt(&key, &nonce, b"", &one).unwrap(), b"same");
419    }
420
421    #[test]
422    fn empty_and_large_messages_round_trip() {
423        let key = key();
424        let big = vec![7u8; 100_000];
425
426        for cipher in Cipher::ALL {
427            let nonce = cipher.nonce();
428
429            assert_eq!(
430                cipher
431                    .decrypt(&key, &nonce, b"", &cipher.encrypt(&key, &nonce, b"", b"").unwrap())
432                    .unwrap(),
433                Vec::<u8>::new(),
434                "{cipher}"
435            );
436            assert_eq!(
437                cipher
438                    .decrypt(&key, &nonce, b"", &cipher.encrypt(&key, &nonce, b"", &big).unwrap())
439                    .unwrap(),
440                big,
441                "{cipher}"
442            );
443        }
444    }
445}