1use crate::error::FaucetError;
46use aes_gcm::aead::{Aead, AeadCore, Generate, KeyInit, Payload};
47use aes_gcm::{Aes256Gcm, Nonce};
48
49type GcmNonce = Nonce<<Aes256Gcm as AeadCore>::NonceSize>;
51use schemars::JsonSchema;
52use serde::{Deserialize, Serialize};
53use sha2::{Digest, Sha256};
54
55pub const MAGIC: &[u8; 4] = b"FCT1";
57const FORMAT_AES256GCM: u8 = 0x01;
59const FORMAT_AES256GCM_BOUND: u8 = 0x02;
61const BOUND_AAD_PREFIX: &[u8] = b"faucet:FCT1:v2\0";
63const NONCE_LEN: usize = 12;
65const HEADER_LEN: usize = 4 + 1 + NONCE_LEN;
67
68#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
71pub enum EncryptionAlgorithm {
72 #[default]
74 #[serde(rename = "aes-256-gcm")]
75 Aes256Gcm,
76}
77
78#[derive(Clone, Serialize, Deserialize, JsonSchema)]
87#[serde(deny_unknown_fields)]
88pub struct EncryptionSpec {
89 pub key: String,
93 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub previous_keys: Vec<String>,
97 #[serde(default)]
99 pub algorithm: EncryptionAlgorithm,
100}
101
102impl std::fmt::Debug for EncryptionSpec {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 f.debug_struct("EncryptionSpec")
106 .field("key", &"***")
107 .field(
108 "previous_keys",
109 &format!("[{} keys]", self.previous_keys.len()),
110 )
111 .field("algorithm", &self.algorithm)
112 .finish()
113 }
114}
115
116pub struct CompiledEncryption {
119 write: Aes256Gcm,
121 read: Vec<Aes256Gcm>,
123}
124
125impl std::fmt::Debug for CompiledEncryption {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 f.debug_struct("CompiledEncryption")
128 .field("read_keys", &self.read.len())
129 .finish()
130 }
131}
132
133pub fn is_encrypted(data: &[u8]) -> bool {
136 data.len() >= MAGIC.len() && &data[..MAGIC.len()] == MAGIC
137}
138
139fn derive_key(key: &str) -> Aes256Gcm {
140 let digest = Sha256::digest(key.as_bytes());
141 Aes256Gcm::new_from_slice(&digest).expect("SHA-256 digest is a valid AES-256 key")
144}
145
146impl CompiledEncryption {
147 pub fn compile(spec: &EncryptionSpec) -> Result<Self, FaucetError> {
150 if spec.key.trim().is_empty() {
151 return Err(FaucetError::Config(
152 "encryption.key must not be empty".into(),
153 ));
154 }
155 if let Some(idx) = spec.previous_keys.iter().position(|k| k.trim().is_empty()) {
156 return Err(FaucetError::Config(format!(
157 "encryption.previous_keys[{idx}] must not be empty"
158 )));
159 }
160 let write = derive_key(&spec.key);
161 let mut read = vec![derive_key(&spec.key)];
162 read.extend(spec.previous_keys.iter().map(|k| derive_key(k)));
163 Ok(Self { write, read })
164 }
165
166 pub fn encrypt(&self, plaintext: &[u8]) -> Vec<u8> {
168 self.seal(plaintext, FORMAT_AES256GCM, &[])
169 }
170
171 pub fn encrypt_bound(&self, plaintext: &[u8], context: &[u8]) -> Vec<u8> {
176 self.seal(plaintext, FORMAT_AES256GCM_BOUND, &bound_aad(context))
177 }
178
179 fn seal(&self, plaintext: &[u8], format: u8, aad: &[u8]) -> Vec<u8> {
180 let nonce = GcmNonce::generate();
181 let ciphertext = self
182 .write
183 .encrypt(
184 &nonce,
185 Payload {
186 msg: plaintext,
187 aad,
188 },
189 )
190 .expect("AES-GCM encryption of an in-memory payload cannot fail");
193 let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len());
194 out.extend_from_slice(MAGIC);
195 out.push(format);
196 out.extend_from_slice(&nonce);
197 out.extend_from_slice(&ciphertext);
198 out
199 }
200
201 pub fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>, FaucetError> {
206 self.open(data, None)
207 }
208
209 pub fn decrypt_bound(&self, data: &[u8], context: &[u8]) -> Result<Vec<u8>, FaucetError> {
212 self.open(data, Some(context))
213 }
214
215 fn open(&self, data: &[u8], context: Option<&[u8]>) -> Result<Vec<u8>, FaucetError> {
216 if !is_encrypted(data) {
217 return Err(FaucetError::State(
218 "payload is not faucet-encrypted (missing FCT1 header)".into(),
219 ));
220 }
221 if data.len() < HEADER_LEN {
222 return Err(FaucetError::State(
223 "encrypted payload is truncated (shorter than its header)".into(),
224 ));
225 }
226 let format = data[MAGIC.len()];
227 let aad = match (format, context) {
228 (FORMAT_AES256GCM, _) => Vec::new(),
229 (FORMAT_AES256GCM_BOUND, Some(context)) => bound_aad(context),
230 (FORMAT_AES256GCM_BOUND, None) => {
231 return Err(FaucetError::State(
232 "encrypted payload is bound to its identity and must be opened with it".into(),
233 ));
234 }
235 _ => {
236 return Err(FaucetError::State(format!(
237 "unknown encrypted-payload format byte 0x{format:02x} — written by a newer \
238 faucet version?"
239 )));
240 }
241 };
242 let nonce_bytes: [u8; NONCE_LEN] = data[MAGIC.len() + 1..HEADER_LEN]
243 .try_into()
244 .expect("slice length checked above");
245 let nonce = GcmNonce::from(nonce_bytes);
246 let ciphertext = &data[HEADER_LEN..];
247 for cipher in &self.read {
248 if let Ok(plaintext) = cipher.decrypt(
249 &nonce,
250 Payload {
251 msg: ciphertext,
252 aad: &aad,
253 },
254 ) {
255 return Ok(plaintext);
256 }
257 }
258 Err(FaucetError::State(
259 "decryption failed — wrong or rotated key, or a payload sealed for another \
260 identity? (tried the configured key and every previous_keys entry)"
261 .into(),
262 ))
263 }
264}
265
266pub const SEALED_LINE_CONTEXT: &[u8] = b"faucet.sealed-lines.v2.line";
271pub const SEALED_LINES_HEADER_CONTEXT: &[u8] = b"faucet.sealed-lines.v2.header";
273pub const SEALED_LINES_TRAILER_CONTEXT: &[u8] = b"faucet.sealed-lines.v2.trailer";
275
276#[derive(Debug, Clone, PartialEq, Eq)]
278pub enum SealedLine {
279 Data(Vec<u8>),
281 Header(Vec<u8>),
283 Trailer(Vec<u8>),
285}
286
287impl CompiledEncryption {
288 pub fn open_line(&self, sealed: &[u8]) -> Result<SealedLine, FaucetError> {
291 let data = self.decrypt_bound(sealed, SEALED_LINE_CONTEXT);
292 if let Ok(plain) = data {
293 return Ok(SealedLine::Data(plain));
294 }
295 if let Ok(plain) = self.decrypt_bound(sealed, SEALED_LINES_HEADER_CONTEXT) {
296 return Ok(SealedLine::Header(plain));
297 }
298 if let Ok(plain) = self.decrypt_bound(sealed, SEALED_LINES_TRAILER_CONTEXT) {
299 return Ok(SealedLine::Trailer(plain));
300 }
301 data.map(SealedLine::Data)
302 }
303}
304
305fn bound_aad(context: &[u8]) -> Vec<u8> {
306 let mut aad = BOUND_AAD_PREFIX.to_vec();
307 aad.extend_from_slice(context);
308 aad
309}
310
311#[cfg(test)]
312mod tests {
313 #[test]
314 fn sealed_lines_open_by_their_context() {
315 let enc = CompiledEncryption::compile(&EncryptionSpec {
316 key: "k".into(),
317 previous_keys: vec![],
318 algorithm: Default::default(),
319 })
320 .unwrap();
321 let line = enc.encrypt_bound(b"r", SEALED_LINE_CONTEXT);
322 let head = enc.encrypt_bound(b"h", SEALED_LINES_HEADER_CONTEXT);
323 let tail = enc.encrypt_bound(b"t", SEALED_LINES_TRAILER_CONTEXT);
324 assert_eq!(
325 enc.open_line(&line).unwrap(),
326 SealedLine::Data(b"r".to_vec())
327 );
328 assert_eq!(
329 enc.open_line(&head).unwrap(),
330 SealedLine::Header(b"h".to_vec())
331 );
332 assert_eq!(
333 enc.open_line(&tail).unwrap(),
334 SealedLine::Trailer(b"t".to_vec())
335 );
336 assert_eq!(
337 enc.open_line(&enc.encrypt(b"old")).unwrap(),
338 SealedLine::Data(b"old".to_vec())
339 );
340 assert!(
341 enc.decrypt(&line).is_err(),
342 "a bound line never reads as unbound"
343 );
344 assert!(enc.open_line(&enc.encrypt_bound(b"x", b"other")).is_err());
345 }
346
347 use super::*;
348 use serde_json::json;
349
350 fn spec(key: &str) -> EncryptionSpec {
351 EncryptionSpec {
352 key: key.into(),
353 previous_keys: vec![],
354 algorithm: EncryptionAlgorithm::default(),
355 }
356 }
357
358 #[test]
359 fn bound_payloads_open_only_under_their_context() {
360 let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
361 let sealed = enc.encrypt_bound(b"bookmark", b"pipeline::a");
362 assert_eq!(sealed[MAGIC.len()], FORMAT_AES256GCM_BOUND);
363 assert_eq!(
364 enc.decrypt_bound(&sealed, b"pipeline::a").unwrap(),
365 b"bookmark"
366 );
367 let err = enc
368 .decrypt_bound(&sealed, b"pipeline::b")
369 .unwrap_err()
370 .to_string();
371 assert!(err.contains("another"), "{err}");
372 let err = enc.decrypt(&sealed).unwrap_err().to_string();
373 assert!(err.contains("bound to its identity"), "{err}");
374 let legacy = enc.encrypt(b"old");
376 assert_eq!(enc.decrypt_bound(&legacy, b"pipeline::a").unwrap(), b"old");
377 let mut unknown = legacy.clone();
378 unknown[MAGIC.len()] = 0x7f;
379 assert!(enc.decrypt_bound(&unknown, b"x").is_err());
380 }
381
382 #[test]
383 fn a_serialized_spec_round_trips() {
384 let spec: EncryptionSpec =
385 serde_json::from_value(serde_json::json!({"key": "k1", "previous_keys": ["k0"]}))
386 .unwrap();
387 let back: EncryptionSpec =
388 serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap();
389 assert_eq!(back.key, "k1");
390 assert_eq!(back.previous_keys, vec!["k0".to_string()]);
391 assert!(!format!("{spec:?}").contains("k1"));
392 }
393
394 #[test]
395 fn round_trips() {
396 let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
397 let sealed = enc.encrypt(b"hello bookmark");
398 assert!(is_encrypted(&sealed));
399 assert_eq!(enc.decrypt(&sealed).unwrap(), b"hello bookmark");
400 let sealed = enc.encrypt(b"");
402 assert_eq!(enc.decrypt(&sealed).unwrap(), b"");
403 }
404
405 #[test]
406 fn nonces_are_unique_per_encryption() {
407 let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
408 let a = enc.encrypt(b"same plaintext");
409 let b = enc.encrypt(b"same plaintext");
410 assert_ne!(a, b, "two seals of the same plaintext must differ");
411 }
412
413 #[test]
414 fn tampering_is_detected() {
415 let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
416 let sealed = enc.encrypt(b"payload");
417
418 let mut tampered = sealed.clone();
420 let mid = HEADER_LEN + 1;
421 tampered[mid] ^= 0x01;
422 assert!(enc.decrypt(&tampered).is_err());
423
424 let mut tampered = sealed.clone();
426 let last = tampered.len() - 1;
427 tampered[last] ^= 0x01;
428 assert!(enc.decrypt(&tampered).is_err());
429
430 let truncated = &sealed[..sealed.len() - 4];
432 assert!(enc.decrypt(truncated).is_err());
433 assert!(enc.decrypt(&sealed[..6]).is_err());
435 }
436
437 #[test]
438 fn wrong_key_is_a_typed_error() {
439 let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
440 let other = CompiledEncryption::compile(&spec("k2")).unwrap();
441 let sealed = enc.encrypt(b"payload");
442 let err = other.decrypt(&sealed).unwrap_err();
443 assert!(matches!(err, FaucetError::State(_)));
444 assert!(err.to_string().contains("wrong or rotated key"));
445 }
446
447 #[test]
448 fn rotation_reads_old_writes_new() {
449 let old = CompiledEncryption::compile(&spec("old-key")).unwrap();
450 let sealed_old = old.encrypt(b"v1");
451
452 let rotated = CompiledEncryption::compile(&EncryptionSpec {
453 key: "new-key".into(),
454 previous_keys: vec!["old-key".into()],
455 algorithm: EncryptionAlgorithm::default(),
456 })
457 .unwrap();
458 assert_eq!(rotated.decrypt(&sealed_old).unwrap(), b"v1");
460 let sealed_new = rotated.encrypt(b"v2");
462 let new_only = CompiledEncryption::compile(&spec("new-key")).unwrap();
463 assert_eq!(new_only.decrypt(&sealed_new).unwrap(), b"v2");
464 assert!(old.decrypt(&sealed_new).is_err());
465 }
466
467 #[test]
468 fn is_encrypted_detection() {
469 assert!(!is_encrypted(b""));
470 assert!(!is_encrypted(b"FC"));
471 assert!(!is_encrypted(b"{\"json\": true}"));
472 assert!(is_encrypted(b"FCT1 anything"));
473 }
474
475 #[test]
476 fn unknown_format_byte_is_a_typed_error() {
477 let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
478 let mut sealed = enc.encrypt(b"x");
479 sealed[4] = 0x7f; let err = enc.decrypt(&sealed).unwrap_err();
481 assert!(err.to_string().contains("format byte 0x7f"));
482 }
483
484 #[test]
485 fn non_encrypted_input_is_a_typed_error() {
486 let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
487 let err = enc.decrypt(b"plain text").unwrap_err();
488 assert!(err.to_string().contains("not faucet-encrypted"));
489 }
490
491 #[test]
492 fn empty_keys_are_rejected_at_compile_time() {
493 assert!(matches!(
494 CompiledEncryption::compile(&spec(" ")),
495 Err(FaucetError::Config(_))
496 ));
497 let bad_prev = EncryptionSpec {
498 key: "k".into(),
499 previous_keys: vec!["ok".into(), "".into()],
500 algorithm: EncryptionAlgorithm::default(),
501 };
502 let err = CompiledEncryption::compile(&bad_prev).unwrap_err();
503 assert!(err.to_string().contains("previous_keys[1]"));
504 }
505
506 #[test]
507 fn spec_serde_shape_and_debug_masking() {
508 let s: EncryptionSpec = serde_json::from_value(json!({
509 "key": "SECRET-MATERIAL",
510 "previous_keys": ["OLD-SECRET"],
511 }))
512 .unwrap();
513 assert_eq!(s.algorithm, EncryptionAlgorithm::Aes256Gcm);
514 let rendered = format!("{s:?}");
515 assert!(!rendered.contains("SECRET-MATERIAL"));
516 assert!(!rendered.contains("OLD-SECRET"));
517 assert!(rendered.contains("[1 keys]"));
518 assert!(serde_json::from_value::<EncryptionSpec>(json!({"key": "k", "nope": 1})).is_err());
520 let s: EncryptionSpec =
522 serde_json::from_value(json!({"key": "k", "algorithm": "aes-256-gcm"})).unwrap();
523 assert_eq!(s.algorithm, EncryptionAlgorithm::Aes256Gcm);
524 }
525
526 #[test]
527 fn compiled_debug_never_prints_key_material() {
528 let enc = CompiledEncryption::compile(&spec("TOPSECRET")).unwrap();
529 let rendered = format!("{enc:?}");
530 assert!(!rendered.contains("TOPSECRET"));
531 }
532}