rustlavel_auth/
encryption.rs1use 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
22const VERSION: u8 = 1;
24const NONCE_LENGTH: usize = 12;
26const TAG_LENGTH: usize = 16;
28
29#[derive(Clone)]
34pub struct Encrypter {
35 key: AppKey,
36}
37
38impl Encrypter {
39 pub fn new(key: AppKey) -> Self {
43 Encrypter { key }
44 }
45
46 pub fn from_config(config: &Config) -> Result<Self> {
48 Ok(Encrypter::new(AppKey::from_config(config)?))
49 }
50
51 pub fn generate_key() -> String {
53 AppKey::generate()
54 }
55
56 pub fn encrypt(&self, plaintext: &str) -> Result<String> {
58 self.encrypt_bytes(plaintext.as_bytes())
59 }
60
61 pub fn encrypt_bytes(&self, plaintext: &[u8]) -> Result<String> {
68 let nonce_bytes = random::bytes(NONCE_LENGTH);
69 let cipher = self.cipher();
70 let ciphertext = cipher
71 .encrypt(Nonce::from_slice(&nonce_bytes), plaintext)
72 .map_err(|_| Error::msg("could not encrypt the payload"))?;
73
74 let mut payload = Vec::with_capacity(1 + NONCE_LENGTH + ciphertext.len());
75 payload.push(VERSION);
76 payload.extend_from_slice(&nonce_bytes);
77 payload.extend_from_slice(&ciphertext);
78
79 Ok(base64::encode_url(&payload))
80 }
81
82 pub fn decrypt(&self, payload: &str) -> Result<String> {
84 let plaintext = self.decrypt_bytes(payload)?;
85 String::from_utf8(plaintext)
86 .map_err(|_| Error::msg("the decrypted payload is not valid UTF-8"))
87 }
88
89 pub fn decrypt_bytes(&self, payload: &str) -> Result<Vec<u8>> {
96 let failed = || Error::msg("could not decrypt the payload: it is invalid or was tampered with");
97
98 let raw = base64::decode(payload).ok_or_else(failed)?;
99 if raw.len() < 1 + NONCE_LENGTH + TAG_LENGTH || raw[0] != VERSION {
100 return Err(failed());
101 }
102
103 let (nonce, ciphertext) = raw[1..].split_at(NONCE_LENGTH);
104 self.cipher()
105 .decrypt(Nonce::from_slice(nonce), ciphertext)
106 .map_err(|_| failed())
107 }
108
109 fn cipher(&self) -> Aes256Gcm {
110 let derived = self.key.derive("encryption");
111 Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&derived))
112 }
113}
114
115impl std::fmt::Debug for Encrypter {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 f.write_str("Encrypter { key: <redacted> }")
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 fn encrypter() -> Encrypter {
126 Encrypter::new(AppKey::from_base64(&AppKey::generate()).unwrap())
127 }
128
129 #[test]
130 fn encryption_round_trips() {
131 let encrypter = encrypter();
132 let payload = encrypter.encrypt("session=41; user=ada").unwrap();
133
134 assert_eq!(encrypter.decrypt(&payload).unwrap(), "session=41; user=ada");
135 }
136
137 #[test]
138 fn round_trips_empty_unicode_and_binary_payloads() {
139 let encrypter = encrypter();
140
141 assert_eq!(encrypter.decrypt(&encrypter.encrypt("").unwrap()).unwrap(), "");
142 assert_eq!(encrypter.decrypt(&encrypter.encrypt("héllo 🔐").unwrap()).unwrap(), "héllo 🔐");
143
144 let binary: Vec<u8> = (0..=255).collect();
145 let payload = encrypter.encrypt_bytes(&binary).unwrap();
146 assert_eq!(encrypter.decrypt_bytes(&payload).unwrap(), binary);
147 }
148
149 #[test]
150 fn two_encryptions_of_the_same_text_differ() {
151 let encrypter = encrypter();
152 let first = encrypter.encrypt("same message").unwrap();
153 let second = encrypter.encrypt("same message").unwrap();
154
155 assert_ne!(first, second, "each message must get a fresh nonce");
156 assert_eq!(encrypter.decrypt(&first).unwrap(), encrypter.decrypt(&second).unwrap());
157 }
158
159 #[test]
160 fn a_tampered_payload_fails_to_decrypt() {
161 let encrypter = encrypter();
162 let payload = encrypter.encrypt("balance=100").unwrap();
163 let raw = base64::decode(&payload).unwrap();
164
165 let mut edited = raw.clone();
168 edited[1 + NONCE_LENGTH] ^= 0x01;
169 assert!(
170 encrypter.decrypt(&base64::encode_url(&edited)).is_err(),
171 "GCM must reject a modified ciphertext"
172 );
173
174 let mut edited = raw.clone();
176 let last = edited.len() - 1;
177 edited[last] ^= 0x01;
178 assert!(encrypter.decrypt(&base64::encode_url(&edited)).is_err());
179
180 let mut edited = raw;
182 edited[1] ^= 0xff;
183 assert!(encrypter.decrypt(&base64::encode_url(&edited)).is_err());
184 }
185
186 #[test]
187 fn truncated_reordered_and_misversioned_payloads_are_rejected() {
188 let encrypter = encrypter();
189 let payload = encrypter.encrypt("secret").unwrap();
190
191 assert!(encrypter.decrypt(&payload[..payload.len() - 4]).is_err());
192 assert!(encrypter.decrypt("").is_err());
193 assert!(encrypter.decrypt("!!!not base64!!!").is_err());
194
195 let mut raw = base64::decode(&payload).unwrap();
197 raw[0] = 99;
198 assert!(encrypter.decrypt(&base64::encode_url(&raw)).is_err());
199 }
200
201 #[test]
202 fn a_payload_from_another_key_does_not_decrypt() {
203 let payload = encrypter().encrypt("mine").unwrap();
204
205 assert!(encrypter().decrypt(&payload).is_err());
206 }
207
208 #[test]
209 fn generated_keys_are_accepted_and_never_repeat() {
210 let first = Encrypter::generate_key();
211 let second = Encrypter::generate_key();
212
213 assert_ne!(first, second);
214 assert!(AppKey::from_base64(&first).is_ok());
215 }
216
217 #[test]
218 fn builds_from_configuration_and_complains_when_the_key_is_missing() {
219 let config = Config::new();
220 config.set("app.key", Encrypter::generate_key());
221 assert!(Encrypter::from_config(&config).is_ok());
222
223 let error = Encrypter::from_config(&Config::new()).unwrap_err().to_string();
224 assert!(error.contains("key:generate"), "the message should say how to fix it: {error}");
225 }
226
227 #[test]
228 fn debug_output_never_contains_the_key() {
229 assert_eq!(format!("{:?}", encrypter()), "Encrypter { key: <redacted> }");
230 }
231}