Skip to main content

rustlavel_auth/
encryption.rs

1//! Authenticated encryption for anything the application hands to a client.
2//!
3//! AES-256-GCM, which is *authenticated* encryption: decryption verifies a tag
4//! over the ciphertext before returning a single byte of plaintext. That matters
5//! more than confidentiality here — an encrypted cookie that could be edited by
6//! its holder would be worse than no cookie at all, because the application
7//! would trust the forgery.
8//!
9//! A payload is a self-describing string: base64url of
10//! `version(1) || nonce(12) || ciphertext || tag(16)`. Nothing but the key is
11//! needed to read it back, the version byte leaves room to change cipher later
12//! without invalidating what is already in people's browsers, and the URL-safe
13//! alphabet means the result drops into a cookie or a query string untouched.
14
15use crate::base64;
16use crate::key::AppKey;
17use crate::random;
18use aes_gcm::aead::Aead;
19use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce};
20use rustlavel_core::{Config, Error, Result};
21
22/// The payload format this build writes. Present in every ciphertext.
23const VERSION: u8 = 1;
24/// AES-GCM's standard nonce size; the only one with a security proof behind it.
25const NONCE_LENGTH: usize = 12;
26/// The GCM authentication tag appended by the cipher.
27const TAG_LENGTH: usize = 16;
28
29/// Encrypts and decrypts with the application key.
30///
31/// Cheap to clone and safe to share, so it is normally registered once as
32/// application state and reached with `req.state::<Encrypter>()`.
33#[derive(Clone)]
34pub struct Encrypter {
35    key: AppKey,
36}
37
38impl Encrypter {
39    /// Build from an application key. Encryption uses its own derived sub-key,
40    /// never the raw `APP_KEY`, so a ciphertext and a URL signature can never
41    /// be confused for one another.
42    pub fn new(key: AppKey) -> Self {
43        Encrypter { key }
44    }
45
46    /// Build from `app.key` in the configuration tree.
47    pub fn from_config(config: &Config) -> Result<Self> {
48        Ok(Encrypter::new(AppKey::from_config(config)?))
49    }
50
51    /// A fresh `APP_KEY` line for `.env`, used by `rustlavel key:generate`.
52    pub fn generate_key() -> String {
53        AppKey::generate()
54    }
55
56    /// Encrypt a string.
57    pub fn encrypt(&self, plaintext: &str) -> Result<String> {
58        self.encrypt_bytes(plaintext.as_bytes())
59    }
60
61    /// Encrypt arbitrary bytes.
62    ///
63    /// A fresh nonce is drawn for every message. Reusing a nonce under one key
64    /// is the single fatal mistake in GCM — it leaks the XOR of two plaintexts
65    /// and, worse, the authentication subkey — so the nonce is never derived
66    /// from the message, a counter, or the clock.
67    pub fn encrypt_bytes(&self, plaintext: &[u8]) -> Result<String> {
68        let nonce_bytes = random::bytes(NONCE_LENGTH);
69        let nonce: [u8; NONCE_LENGTH] =
70            nonce_bytes.as_slice().try_into().expect("random::bytes returned the length asked for");
71        let cipher = self.cipher();
72        // `Nonce::from` on a fixed-size array rather than `from_slice`: the
73        // latter is deprecated in newer generic-array releases, and a caller
74        // who runs `cargo update` should not inherit a warning from here.
75        let ciphertext = cipher
76            .encrypt(&Nonce::from(nonce), plaintext)
77            .map_err(|_| Error::msg("could not encrypt the payload"))?;
78
79        let mut payload = Vec::with_capacity(1 + NONCE_LENGTH + ciphertext.len());
80        payload.push(VERSION);
81        payload.extend_from_slice(&nonce_bytes);
82        payload.extend_from_slice(&ciphertext);
83
84        Ok(base64::encode_url(&payload))
85    }
86
87    /// Decrypt a payload back into a string.
88    pub fn decrypt(&self, payload: &str) -> Result<String> {
89        let plaintext = self.decrypt_bytes(payload)?;
90        String::from_utf8(plaintext)
91            .map_err(|_| Error::msg("the decrypted payload is not valid UTF-8"))
92    }
93
94    /// Decrypt a payload back into bytes.
95    ///
96    /// Every failure — wrong key, flipped bit, truncated string, unknown
97    /// version — returns the same kind of error and reveals nothing about
98    /// which one it was. A caller that told an attacker "the tag was wrong"
99    /// rather than "the base64 was wrong" would be handing out an oracle.
100    pub fn decrypt_bytes(&self, payload: &str) -> Result<Vec<u8>> {
101        let failed = || Error::msg("could not decrypt the payload: it is invalid or was tampered with");
102
103        let raw = base64::decode(payload).ok_or_else(failed)?;
104        if raw.len() < 1 + NONCE_LENGTH + TAG_LENGTH || raw[0] != VERSION {
105            return Err(failed());
106        }
107
108        let (nonce, ciphertext) = raw[1..].split_at(NONCE_LENGTH);
109        let nonce: [u8; NONCE_LENGTH] = nonce.try_into().map_err(|_| failed())?;
110        self.cipher()
111            .decrypt(&Nonce::from(nonce), ciphertext)
112            .map_err(|_| failed())
113    }
114
115    fn cipher(&self) -> Aes256Gcm {
116        let derived = self.key.derive("encryption");
117        Aes256Gcm::new(&Key::<Aes256Gcm>::from(derived))
118    }
119}
120
121impl std::fmt::Debug for Encrypter {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        f.write_str("Encrypter { key: <redacted> }")
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn encrypter() -> Encrypter {
132        Encrypter::new(AppKey::from_base64(&AppKey::generate()).unwrap())
133    }
134
135    #[test]
136    fn encryption_round_trips() {
137        let encrypter = encrypter();
138        let payload = encrypter.encrypt("session=41; user=ada").unwrap();
139
140        assert_eq!(encrypter.decrypt(&payload).unwrap(), "session=41; user=ada");
141    }
142
143    #[test]
144    fn round_trips_empty_unicode_and_binary_payloads() {
145        let encrypter = encrypter();
146
147        assert_eq!(encrypter.decrypt(&encrypter.encrypt("").unwrap()).unwrap(), "");
148        assert_eq!(encrypter.decrypt(&encrypter.encrypt("héllo 🔐").unwrap()).unwrap(), "héllo 🔐");
149
150        let binary: Vec<u8> = (0..=255).collect();
151        let payload = encrypter.encrypt_bytes(&binary).unwrap();
152        assert_eq!(encrypter.decrypt_bytes(&payload).unwrap(), binary);
153    }
154
155    #[test]
156    fn two_encryptions_of_the_same_text_differ() {
157        let encrypter = encrypter();
158        let first = encrypter.encrypt("same message").unwrap();
159        let second = encrypter.encrypt("same message").unwrap();
160
161        assert_ne!(first, second, "each message must get a fresh nonce");
162        assert_eq!(encrypter.decrypt(&first).unwrap(), encrypter.decrypt(&second).unwrap());
163    }
164
165    #[test]
166    fn a_tampered_payload_fails_to_decrypt() {
167        let encrypter = encrypter();
168        let payload = encrypter.encrypt("balance=100").unwrap();
169        let raw = base64::decode(&payload).unwrap();
170
171        // Flip one bit of the ciphertext: the plaintext an attacker is trying
172        // to change from `balance=100`.
173        let mut edited = raw.clone();
174        edited[1 + NONCE_LENGTH] ^= 0x01;
175        assert!(
176            encrypter.decrypt(&base64::encode_url(&edited)).is_err(),
177            "GCM must reject a modified ciphertext"
178        );
179
180        // Flip one bit of the authentication tag.
181        let mut edited = raw.clone();
182        let last = edited.len() - 1;
183        edited[last] ^= 0x01;
184        assert!(encrypter.decrypt(&base64::encode_url(&edited)).is_err());
185
186        // Swap in a different nonce, leaving ciphertext and tag intact.
187        let mut edited = raw;
188        edited[1] ^= 0xff;
189        assert!(encrypter.decrypt(&base64::encode_url(&edited)).is_err());
190    }
191
192    #[test]
193    fn truncated_reordered_and_misversioned_payloads_are_rejected() {
194        let encrypter = encrypter();
195        let payload = encrypter.encrypt("secret").unwrap();
196
197        assert!(encrypter.decrypt(&payload[..payload.len() - 4]).is_err());
198        assert!(encrypter.decrypt("").is_err());
199        assert!(encrypter.decrypt("!!!not base64!!!").is_err());
200
201        // A payload whose version byte we do not recognise is not guessed at.
202        let mut raw = base64::decode(&payload).unwrap();
203        raw[0] = 99;
204        assert!(encrypter.decrypt(&base64::encode_url(&raw)).is_err());
205    }
206
207    #[test]
208    fn a_payload_from_another_key_does_not_decrypt() {
209        let payload = encrypter().encrypt("mine").unwrap();
210
211        assert!(encrypter().decrypt(&payload).is_err());
212    }
213
214    #[test]
215    fn generated_keys_are_accepted_and_never_repeat() {
216        let first = Encrypter::generate_key();
217        let second = Encrypter::generate_key();
218
219        assert_ne!(first, second);
220        assert!(AppKey::from_base64(&first).is_ok());
221    }
222
223    #[test]
224    fn builds_from_configuration_and_complains_when_the_key_is_missing() {
225        let config = Config::new();
226        config.set("app.key", Encrypter::generate_key());
227        assert!(Encrypter::from_config(&config).is_ok());
228
229        let error = Encrypter::from_config(&Config::new()).unwrap_err().to_string();
230        assert!(error.contains("key:generate"), "the message should say how to fix it: {error}");
231    }
232
233    #[test]
234    fn debug_output_never_contains_the_key() {
235        assert_eq!(format!("{:?}", encrypter()), "Encrypter { key: <redacted> }");
236    }
237}