Skip to main content

rainier_crypt/
encrypter.rs

1//! Authenticated encryption — the [`Encrypter`] port and its AEAD
2//! implementation.
3
4use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
5use base64::Engine as _;
6use rainier_support::{Error, Result};
7use serde::de::DeserializeOwned;
8use serde::Serialize;
9
10use crate::cipher::Cipher;
11use crate::key::KeyRing;
12
13/// Reversible, tamper-evident encryption.
14///
15/// A port rather than a concrete type for the same reason [`Hasher`] is one in
16/// `rainier-auth`: the right primitive changes over time, and an application
17/// migrating between two should be able to run both.
18///
19/// [`Hasher`]: https://docs.rs/rainier-auth
20pub trait Encrypter: Send + Sync + 'static {
21    /// Encrypt `plain`, returning a self-describing, URL-safe payload.
22    fn encrypt_bytes(&self, plain: &[u8]) -> Result<String>;
23
24    /// Decrypt a payload produced by [`encrypt_bytes`](Self::encrypt_bytes).
25    ///
26    /// Fails if the payload was truncated, altered, or produced by a key this
27    /// encrypter does not hold. There is deliberately no way to decrypt
28    /// without authenticating.
29    fn decrypt_bytes(&self, payload: &str) -> Result<Vec<u8>>;
30}
31
32/// The typed conveniences every [`Encrypter`] gets.
33///
34/// Separate from the trait so that stays object-safe — the generic methods
35/// would otherwise keep `Arc<dyn Encrypter>` from existing.
36pub trait EncrypterExt: Encrypter {
37    /// Encrypt a string.
38    fn encrypt(&self, plain: &str) -> Result<String> {
39        self.encrypt_bytes(plain.as_bytes())
40    }
41
42    /// Decrypt to a string.
43    fn decrypt(&self, payload: &str) -> Result<String> {
44        let bytes = self.decrypt_bytes(payload)?;
45        String::from_utf8(bytes)
46            .map_err(|_| Error::internal("the decrypted value is not valid UTF-8"))
47    }
48
49    /// Encrypt any serialisable value as JSON.
50    fn encrypt_json<T: Serialize>(&self, value: &T) -> Result<String> {
51        let json = serde_json::to_vec(value)?;
52        self.encrypt_bytes(&json)
53    }
54
55    /// Decrypt JSON back into a value.
56    fn decrypt_json<T: DeserializeOwned>(&self, payload: &str) -> Result<T> {
57        let bytes = self.decrypt_bytes(payload)?;
58        serde_json::from_slice(&bytes).map_err(|e| {
59            // The payload authenticated, so this is our own format changing
60            // under us rather than anything an attacker did.
61            Error::internal(format!("the decrypted value is not the expected shape: {e}"))
62        })
63    }
64}
65
66impl<E: Encrypter + ?Sized> EncrypterExt for E {}
67
68/// An AEAD [`Cipher`] over a [`KeyRing`].
69///
70/// Writes with one cipher and reads **any** of them, because the payload names
71/// its own algorithm. So changing cipher is the same kind of change as
72/// [rotating a key](KeyRing): deploy the new setting, and everything already
73/// written still opens.
74///
75/// ```
76/// # use rainier_crypt::{AeadEncrypter, Cipher, EncrypterExt, Key, KeyRing};
77/// # fn main() -> rainier_support::Result<()> {
78/// let keys = KeyRing::new(Key::generate());
79/// let written = AeadEncrypter::new(keys.clone()).encrypt("before")?;
80///
81/// // Switched to AES; the old payload still reads.
82/// let now = AeadEncrypter::new(keys).with_cipher(Cipher::Aes256Gcm);
83/// assert_eq!(now.decrypt(&written)?, "before");
84/// assert!(now.encrypt("after")?.starts_with("a256gcm."));
85/// # Ok(()) }
86/// ```
87pub struct AeadEncrypter {
88    keys: KeyRing,
89    cipher: Cipher,
90}
91
92impl AeadEncrypter {
93    /// An encrypter over `keys`, using the default cipher.
94    pub fn new(keys: KeyRing) -> Self {
95        Self { keys, cipher: Cipher::default() }
96    }
97
98    /// Write with a specific cipher. Reading is unaffected.
99    pub fn with_cipher(mut self, cipher: Cipher) -> Self {
100        self.cipher = cipher;
101        self
102    }
103
104    /// The key ring, for a diagnostic command.
105    pub fn keys(&self) -> &KeyRing {
106        &self.keys
107    }
108
109    /// The cipher new payloads are written with.
110    pub fn cipher(&self) -> Cipher {
111        self.cipher
112    }
113}
114
115impl Encrypter for AeadEncrypter {
116    fn encrypt_bytes(&self, plain: &[u8]) -> Result<String> {
117        let key = self.keys.current();
118        let nonce = self.cipher.nonce();
119
120        // The header is authenticated but not encrypted, so a payload cannot
121        // be relabelled with a different cipher or key id and still open.
122        let header = format!("{}.{}", self.cipher.id(), key.id());
123        let sealed = self.cipher.encrypt(key, &nonce, header.as_bytes(), plain)?;
124
125        Ok(format!("{header}.{}.{}", B64.encode(&nonce), B64.encode(&sealed)))
126    }
127
128    fn decrypt_bytes(&self, payload: &str) -> Result<Vec<u8>> {
129        // One message for every malformed-or-forged case. Distinguishing "the
130        // padding was wrong" from "the tag was wrong" is how padding-oracle
131        // attacks start, and the caller can do nothing useful with the
132        // difference anyway.
133        let invalid = || Error::bad_request("the payload could not be decrypted");
134
135        let mut parts = payload.split('.');
136        let (Some(algorithm), Some(key_id), Some(nonce), Some(sealed), None) =
137            (parts.next(), parts.next(), parts.next(), parts.next(), parts.next())
138        else {
139            return Err(invalid());
140        };
141
142        let cipher = Cipher::from_id(algorithm).ok_or_else(invalid)?;
143
144        let key = self.keys.find(key_id).ok_or_else(|| {
145            // Worth its own message: this one is an operator error — a key was
146            // dropped from the ring while payloads written with it still
147            // exist — not an attack, and the fix is to put the key back.
148            Error::internal(format!(
149                "no key with id `{key_id}` is on the ring; it is still needed to read \
150                 payloads written with it"
151            ))
152        })?;
153
154        let nonce = B64.decode(nonce).map_err(|_| invalid())?;
155        let sealed = B64.decode(sealed).map_err(|_| invalid())?;
156
157        // The header as written, not as reconstructed from the parsed cipher —
158        // so a `v1` payload authenticates against `v1` rather than `xc20p`.
159        let header = format!("{algorithm}.{key_id}");
160        cipher.decrypt(key, &nonce, header.as_bytes(), &sealed)
161    }
162}
163
164impl std::fmt::Debug for AeadEncrypter {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.debug_struct("AeadEncrypter")
167            .field("cipher", &self.cipher.id())
168            .field("keys", &self.keys.ids())
169            .finish()
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::key::Key;
177
178    fn encrypter() -> AeadEncrypter {
179        AeadEncrypter::new(KeyRing::new(Key::generate()))
180    }
181
182    #[test]
183    fn a_value_round_trips() {
184        let crypt = encrypter();
185        let payload = crypt.encrypt("hello, world").unwrap();
186
187        assert_eq!(crypt.decrypt(&payload).unwrap(), "hello, world");
188    }
189
190    #[test]
191    fn the_ciphertext_does_not_contain_the_plaintext() {
192        let payload = encrypter().encrypt("correct-horse-battery-staple").unwrap();
193        assert!(!payload.contains("correct-horse"), "{payload}");
194    }
195
196    #[test]
197    fn the_same_value_encrypts_differently_every_time() {
198        let crypt = encrypter();
199
200        // A random nonce per message, so equal plaintexts are not detectable
201        // as equal — which is what makes an encrypted column safe to index on
202        // nothing and unsafe to compare for equality.
203        assert_ne!(crypt.encrypt("same").unwrap(), crypt.encrypt("same").unwrap());
204    }
205
206    #[test]
207    fn a_payload_is_url_safe() {
208        let payload = encrypter().encrypt("a value with / and + in mind").unwrap();
209
210        assert!(
211            payload.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')),
212            "{payload}"
213        );
214    }
215
216    #[test]
217    fn tampering_with_the_ciphertext_is_detected() {
218        let crypt = encrypter();
219        let payload = crypt.encrypt("transfer 10").unwrap();
220
221        let mut parts: Vec<&str> = payload.split('.').collect();
222        let flipped = {
223            let mut bytes = B64.decode(parts[3]).unwrap();
224            bytes[0] ^= 0x01;
225            B64.encode(bytes)
226        };
227        parts[3] = &flipped;
228
229        assert!(crypt.decrypt(&parts.join(".")).is_err());
230    }
231
232    #[test]
233    fn tampering_with_the_nonce_is_detected() {
234        let crypt = encrypter();
235        let payload = crypt.encrypt("transfer 10").unwrap();
236
237        let mut parts: Vec<&str> = payload.split('.').collect();
238        let other = B64.encode(vec![0u8; Cipher::default().nonce_len()]);
239        parts[2] = &other;
240
241        assert!(crypt.decrypt(&parts.join(".")).is_err());
242    }
243
244    #[test]
245    fn relabelling_the_cipher_is_detected() {
246        // The algorithm is part of the authenticated header, so claiming a
247        // payload was written with a different one does not open it.
248        let crypt = encrypter();
249        let payload = crypt.encrypt("secret").unwrap();
250
251        let mut parts: Vec<&str> = payload.split('.').collect();
252        parts[0] = "a256gcm";
253
254        assert!(crypt.decrypt(&parts.join(".")).is_err());
255    }
256
257    #[test]
258    fn every_cipher_round_trips_through_the_encrypter() {
259        let keys = KeyRing::new(Key::generate());
260
261        for cipher in Cipher::ALL {
262            let crypt = AeadEncrypter::new(keys.clone()).with_cipher(cipher);
263            let payload = crypt.encrypt("the message").unwrap();
264
265            assert!(payload.starts_with(&format!("{}.", cipher.id())), "{payload}");
266            assert_eq!(crypt.decrypt(&payload).unwrap(), "the message", "{cipher}");
267        }
268    }
269
270    #[test]
271    fn a_payload_written_by_any_cipher_reads_under_any_default() {
272        // The point of naming the algorithm in the payload: switching the
273        // configured cipher is a deploy, not a migration.
274        let keys = KeyRing::new(Key::generate());
275        let written: Vec<String> = Cipher::ALL
276            .iter()
277            .map(|c| AeadEncrypter::new(keys.clone()).with_cipher(*c).encrypt("old").unwrap())
278            .collect();
279
280        for reader in Cipher::ALL {
281            let crypt = AeadEncrypter::new(keys.clone()).with_cipher(reader);
282            for payload in &written {
283                assert_eq!(crypt.decrypt(payload).unwrap(), "old", "{reader} reading {payload}");
284            }
285        }
286    }
287
288    #[test]
289    fn an_unknown_cipher_id_is_refused() {
290        let crypt = encrypter();
291        assert_eq!(crypt.decrypt("rot13.abc.def.ghi").unwrap_err().status(), 400);
292    }
293
294    #[test]
295    fn a_legacy_v1_payload_still_opens() {
296        // What the first release wrote: `v1` in place of an algorithm name,
297        // XChaCha20-Poly1305, and the header authenticated as written.
298        let key = Key::generate();
299        let cipher = Cipher::XChaCha20Poly1305;
300        let nonce = cipher.nonce();
301        let header = format!("v1.{}", key.id());
302        let sealed = cipher.encrypt(&key, &nonce, header.as_bytes(), b"from before").unwrap();
303        let payload = format!("{header}.{}.{}", B64.encode(&nonce), B64.encode(&sealed));
304
305        let crypt = AeadEncrypter::new(KeyRing::new(key));
306        assert_eq!(crypt.decrypt(&payload).unwrap(), "from before");
307    }
308
309    #[test]
310    fn relabelling_the_header_is_detected() {
311        // The header is the AEAD's associated data, so claiming a payload was
312        // written by a different key does not let it open with that key.
313        let old = Key::generate();
314        let current = Key::generate();
315        let crypt = AeadEncrypter::new(KeyRing::new(current).with_previous(old.clone()));
316
317        let payload = crypt.encrypt("secret").unwrap();
318        let mut parts: Vec<&str> = payload.split('.').collect();
319        parts[1] = old.id();
320
321        assert!(crypt.decrypt(&parts.join(".")).is_err());
322    }
323
324    #[test]
325    fn a_malformed_payload_is_a_400_not_a_500() {
326        let crypt = encrypter();
327
328        for rubbish in ["", "nonsense", "v1.abc", "v1.abc.def.ghi.jkl", "v2.abc.def.ghi"] {
329            let err = crypt.decrypt(rubbish).unwrap_err();
330            assert_eq!(err.status(), 400, "{rubbish}");
331        }
332    }
333
334    #[test]
335    fn every_failure_reports_the_same_thing() {
336        // No padding oracle: the caller cannot tell *why* it failed.
337        let crypt = encrypter();
338        let truncated = crypt.decrypt("v1.abc.def.ghi").unwrap_err();
339        let garbage = crypt.decrypt("v1.abc.!!!.!!!").unwrap_err();
340
341        assert_eq!(truncated.message(), garbage.message());
342    }
343
344    #[test]
345    fn another_applications_key_cannot_read_the_payload() {
346        let payload = encrypter().encrypt("secret").unwrap();
347        assert!(encrypter().decrypt(&payload).is_err());
348    }
349
350    #[test]
351    fn a_retired_key_still_reads_what_it_wrote() {
352        let old = Key::generate();
353        let written = AeadEncrypter::new(KeyRing::new(old.clone())).encrypt("before").unwrap();
354
355        // Rotation: a new current key, the old one kept for reading.
356        let rotated = AeadEncrypter::new(KeyRing::new(Key::generate()).with_previous(old));
357
358        assert_eq!(rotated.decrypt(&written).unwrap(), "before");
359        assert!(
360            rotated.encrypt("after").unwrap().contains(rotated.keys().current().id()),
361            "new payloads use the new key"
362        );
363    }
364
365    #[test]
366    fn dropping_a_key_that_is_still_needed_says_so() {
367        let written = AeadEncrypter::new(KeyRing::new(Key::generate())).encrypt("x").unwrap();
368        let err = encrypter().decrypt(&written).unwrap_err();
369
370        assert_eq!(err.status(), 500, "an operator error, not a client one");
371        assert!(err.message().contains("still needed"), "{}", err.message());
372    }
373
374    #[test]
375    fn json_round_trips() {
376        #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
377        struct Token {
378            user: u64,
379            scopes: Vec<String>,
380        }
381
382        let crypt = encrypter();
383        let token = Token { user: 7, scopes: vec!["read".into()] };
384        let payload = crypt.encrypt_json(&token).unwrap();
385
386        assert_eq!(crypt.decrypt_json::<Token>(&payload).unwrap(), token);
387    }
388
389    #[test]
390    fn an_empty_value_round_trips() {
391        let crypt = encrypter();
392        assert_eq!(crypt.decrypt(&crypt.encrypt("").unwrap()).unwrap(), "");
393    }
394
395    #[test]
396    fn binary_round_trips() {
397        let crypt = encrypter();
398        let bytes: Vec<u8> = (0..=255u8).collect();
399
400        assert_eq!(crypt.decrypt_bytes(&crypt.encrypt_bytes(&bytes).unwrap()).unwrap(), bytes);
401    }
402
403    #[test]
404    fn the_port_is_object_safe() {
405        let crypt: std::sync::Arc<dyn Encrypter> = std::sync::Arc::new(encrypter());
406        // The ext trait must reach through the trait object, or the whole
407        // split was pointless.
408        assert_eq!(crypt.decrypt(&crypt.encrypt("via dyn").unwrap()).unwrap(), "via dyn");
409    }
410}