faucet_core/
encryption.rs1use crate::error::FaucetError;
39use aes_gcm::aead::{Aead, AeadCore, Generate, KeyInit};
40use aes_gcm::{Aes256Gcm, Nonce};
41
42type GcmNonce = Nonce<<Aes256Gcm as AeadCore>::NonceSize>;
44use schemars::JsonSchema;
45use serde::{Deserialize, Serialize};
46use sha2::{Digest, Sha256};
47
48pub const MAGIC: &[u8; 4] = b"FCT1";
50const FORMAT_AES256GCM: u8 = 0x01;
52const NONCE_LEN: usize = 12;
54const HEADER_LEN: usize = 4 + 1 + NONCE_LEN;
56
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
60pub enum EncryptionAlgorithm {
61 #[default]
63 #[serde(rename = "aes-256-gcm")]
64 Aes256Gcm,
65}
66
67#[derive(Clone, Serialize, Deserialize, JsonSchema)]
76#[serde(deny_unknown_fields)]
77pub struct EncryptionSpec {
78 pub key: String,
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 pub previous_keys: Vec<String>,
86 #[serde(default)]
88 pub algorithm: EncryptionAlgorithm,
89}
90
91impl std::fmt::Debug for EncryptionSpec {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 f.debug_struct("EncryptionSpec")
95 .field("key", &"***")
96 .field(
97 "previous_keys",
98 &format!("[{} keys]", self.previous_keys.len()),
99 )
100 .field("algorithm", &self.algorithm)
101 .finish()
102 }
103}
104
105pub struct CompiledEncryption {
108 write: Aes256Gcm,
110 read: Vec<Aes256Gcm>,
112}
113
114impl std::fmt::Debug for CompiledEncryption {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 f.debug_struct("CompiledEncryption")
117 .field("read_keys", &self.read.len())
118 .finish()
119 }
120}
121
122pub fn is_encrypted(data: &[u8]) -> bool {
125 data.len() >= MAGIC.len() && &data[..MAGIC.len()] == MAGIC
126}
127
128fn derive_key(key: &str) -> Aes256Gcm {
129 let digest = Sha256::digest(key.as_bytes());
130 Aes256Gcm::new_from_slice(&digest).expect("SHA-256 digest is a valid AES-256 key")
133}
134
135impl CompiledEncryption {
136 pub fn compile(spec: &EncryptionSpec) -> Result<Self, FaucetError> {
139 if spec.key.trim().is_empty() {
140 return Err(FaucetError::Config(
141 "encryption.key must not be empty".into(),
142 ));
143 }
144 if let Some(idx) = spec.previous_keys.iter().position(|k| k.trim().is_empty()) {
145 return Err(FaucetError::Config(format!(
146 "encryption.previous_keys[{idx}] must not be empty"
147 )));
148 }
149 let write = derive_key(&spec.key);
150 let mut read = vec![derive_key(&spec.key)];
151 read.extend(spec.previous_keys.iter().map(|k| derive_key(k)));
152 Ok(Self { write, read })
153 }
154
155 pub fn encrypt(&self, plaintext: &[u8]) -> Vec<u8> {
157 let nonce = GcmNonce::generate();
158 let ciphertext = self
159 .write
160 .encrypt(&nonce, plaintext)
161 .expect("AES-GCM encryption of an in-memory payload cannot fail");
164 let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len());
165 out.extend_from_slice(MAGIC);
166 out.push(FORMAT_AES256GCM);
167 out.extend_from_slice(&nonce);
168 out.extend_from_slice(&ciphertext);
169 out
170 }
171
172 pub fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>, FaucetError> {
176 if !is_encrypted(data) {
177 return Err(FaucetError::State(
178 "payload is not faucet-encrypted (missing FCT1 header)".into(),
179 ));
180 }
181 if data.len() < HEADER_LEN {
182 return Err(FaucetError::State(
183 "encrypted payload is truncated (shorter than its header)".into(),
184 ));
185 }
186 let format = data[MAGIC.len()];
187 if format != FORMAT_AES256GCM {
188 return Err(FaucetError::State(format!(
189 "unknown encrypted-payload format byte 0x{format:02x} — written by a newer \
190 faucet version?"
191 )));
192 }
193 let nonce_bytes: [u8; NONCE_LEN] = data[MAGIC.len() + 1..HEADER_LEN]
194 .try_into()
195 .expect("slice length checked above");
196 let nonce = GcmNonce::from(nonce_bytes);
197 let ciphertext = &data[HEADER_LEN..];
198 for cipher in &self.read {
199 if let Ok(plaintext) = cipher.decrypt(&nonce, ciphertext) {
200 return Ok(plaintext);
201 }
202 }
203 Err(FaucetError::State(
204 "decryption failed — wrong or rotated key? (tried the configured key and every \
205 previous_keys entry)"
206 .into(),
207 ))
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use serde_json::json;
215
216 fn spec(key: &str) -> EncryptionSpec {
217 EncryptionSpec {
218 key: key.into(),
219 previous_keys: vec![],
220 algorithm: EncryptionAlgorithm::default(),
221 }
222 }
223
224 #[test]
225 fn round_trips() {
226 let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
227 let sealed = enc.encrypt(b"hello bookmark");
228 assert!(is_encrypted(&sealed));
229 assert_eq!(enc.decrypt(&sealed).unwrap(), b"hello bookmark");
230 let sealed = enc.encrypt(b"");
232 assert_eq!(enc.decrypt(&sealed).unwrap(), b"");
233 }
234
235 #[test]
236 fn nonces_are_unique_per_encryption() {
237 let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
238 let a = enc.encrypt(b"same plaintext");
239 let b = enc.encrypt(b"same plaintext");
240 assert_ne!(a, b, "two seals of the same plaintext must differ");
241 }
242
243 #[test]
244 fn tampering_is_detected() {
245 let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
246 let sealed = enc.encrypt(b"payload");
247
248 let mut tampered = sealed.clone();
250 let mid = HEADER_LEN + 1;
251 tampered[mid] ^= 0x01;
252 assert!(enc.decrypt(&tampered).is_err());
253
254 let mut tampered = sealed.clone();
256 let last = tampered.len() - 1;
257 tampered[last] ^= 0x01;
258 assert!(enc.decrypt(&tampered).is_err());
259
260 let truncated = &sealed[..sealed.len() - 4];
262 assert!(enc.decrypt(truncated).is_err());
263 assert!(enc.decrypt(&sealed[..6]).is_err());
265 }
266
267 #[test]
268 fn wrong_key_is_a_typed_error() {
269 let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
270 let other = CompiledEncryption::compile(&spec("k2")).unwrap();
271 let sealed = enc.encrypt(b"payload");
272 let err = other.decrypt(&sealed).unwrap_err();
273 assert!(matches!(err, FaucetError::State(_)));
274 assert!(err.to_string().contains("wrong or rotated key"));
275 }
276
277 #[test]
278 fn rotation_reads_old_writes_new() {
279 let old = CompiledEncryption::compile(&spec("old-key")).unwrap();
280 let sealed_old = old.encrypt(b"v1");
281
282 let rotated = CompiledEncryption::compile(&EncryptionSpec {
283 key: "new-key".into(),
284 previous_keys: vec!["old-key".into()],
285 algorithm: EncryptionAlgorithm::default(),
286 })
287 .unwrap();
288 assert_eq!(rotated.decrypt(&sealed_old).unwrap(), b"v1");
290 let sealed_new = rotated.encrypt(b"v2");
292 let new_only = CompiledEncryption::compile(&spec("new-key")).unwrap();
293 assert_eq!(new_only.decrypt(&sealed_new).unwrap(), b"v2");
294 assert!(old.decrypt(&sealed_new).is_err());
295 }
296
297 #[test]
298 fn is_encrypted_detection() {
299 assert!(!is_encrypted(b""));
300 assert!(!is_encrypted(b"FC"));
301 assert!(!is_encrypted(b"{\"json\": true}"));
302 assert!(is_encrypted(b"FCT1 anything"));
303 }
304
305 #[test]
306 fn unknown_format_byte_is_a_typed_error() {
307 let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
308 let mut sealed = enc.encrypt(b"x");
309 sealed[4] = 0x7f; let err = enc.decrypt(&sealed).unwrap_err();
311 assert!(err.to_string().contains("format byte 0x7f"));
312 }
313
314 #[test]
315 fn non_encrypted_input_is_a_typed_error() {
316 let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
317 let err = enc.decrypt(b"plain text").unwrap_err();
318 assert!(err.to_string().contains("not faucet-encrypted"));
319 }
320
321 #[test]
322 fn empty_keys_are_rejected_at_compile_time() {
323 assert!(matches!(
324 CompiledEncryption::compile(&spec(" ")),
325 Err(FaucetError::Config(_))
326 ));
327 let bad_prev = EncryptionSpec {
328 key: "k".into(),
329 previous_keys: vec!["ok".into(), "".into()],
330 algorithm: EncryptionAlgorithm::default(),
331 };
332 let err = CompiledEncryption::compile(&bad_prev).unwrap_err();
333 assert!(err.to_string().contains("previous_keys[1]"));
334 }
335
336 #[test]
337 fn spec_serde_shape_and_debug_masking() {
338 let s: EncryptionSpec = serde_json::from_value(json!({
339 "key": "SECRET-MATERIAL",
340 "previous_keys": ["OLD-SECRET"],
341 }))
342 .unwrap();
343 assert_eq!(s.algorithm, EncryptionAlgorithm::Aes256Gcm);
344 let rendered = format!("{s:?}");
345 assert!(!rendered.contains("SECRET-MATERIAL"));
346 assert!(!rendered.contains("OLD-SECRET"));
347 assert!(rendered.contains("[1 keys]"));
348 assert!(serde_json::from_value::<EncryptionSpec>(json!({"key": "k", "nope": 1})).is_err());
350 let s: EncryptionSpec =
352 serde_json::from_value(json!({"key": "k", "algorithm": "aes-256-gcm"})).unwrap();
353 assert_eq!(s.algorithm, EncryptionAlgorithm::Aes256Gcm);
354 }
355
356 #[test]
357 fn compiled_debug_never_prints_key_material() {
358 let enc = CompiledEncryption::compile(&spec("TOPSECRET")).unwrap();
359 let rendered = format!("{enc:?}");
360 assert!(!rendered.contains("TOPSECRET"));
361 }
362}