Skip to main content

faucet_core/
encryption.rs

1//! Encryption at rest for faucet-managed local files (#207).
2//!
3//! Seals [`FileStateStore`](crate::state::FileStateStore) bookmark files and
4//! (via the file sinks' `encryption` feature) per-line JSONL / DLQ output
5//! with authenticated encryption, so resume positions and dead-lettered
6//! records — which can embed sensitive values — are not stored in plaintext.
7//! A per-line file binds its lines with the `SEALED_LINE*` contexts and
8//! carries a header and trailer, so the file is authenticated whole, not only
9//! each line (see `faucet_common_file::sealed_lines`).
10//!
11//! ## On-disk format (v1)
12//!
13//! ```text
14//! +---------+--------+-----------+----------------------+
15//! | "FCT1"  | format | nonce     | ciphertext ‖ GCM tag |
16//! | 4 bytes | 1 byte | 12 bytes  | len(plaintext) + 16  |
17//! +---------+--------+-----------+----------------------+
18//! ```
19//!
20//! - Magic `FCT1` ("faucet ciphertext v1") marks a sealed payload; plaintext
21//!   JSON can never start with these bytes, so [`is_encrypted`] is unambiguous.
22//! - `format` byte `0x01` = AES-256-GCM. Unknown values are a typed error
23//!   (never a panic), leaving room for future algorithms.
24//! - The nonce is random per encryption (never reused across writes).
25//! - The GCM tag authenticates the payload: any tampering — a flipped bit,
26//!   truncation, a swapped nonce — fails decryption loudly.
27//! - `format` byte `0x02` (v2) is AES-256-GCM with the payload's identity —
28//!   for a state file, its key — bound in as associated data
29//!   ([`CompiledEncryption::encrypt_bound`]), so a sealed file copied over
30//!   another key's file fails to decrypt. v1 payloads stay readable.
31//!
32//! ## Keys
33//!
34//! The 32-byte AES key is **derived as `SHA-256(key string)`** — a
35//! derivation, not a stretching KDF: it accepts arbitrary passphrases and
36//! hex/base64 strings uniformly, but offers no brute-force hardening, so the
37//! key string should be high-entropy material from a secrets manager
38//! (`${vault:…}` / `${aws-sm:…}` / `${env:…}`), not a human password.
39//!
40//! Rotation: writes always seal with `key`; decryption tries `key` first and
41//! then each entry of `previous_keys` in order, so a store can be rotated by
42//! moving the old key into `previous_keys` — old files stay readable and are
43//! re-sealed with the new key on their next write.
44
45use crate::error::FaucetError;
46use aes_gcm::aead::{Aead, AeadCore, Generate, KeyInit, Payload};
47use aes_gcm::{Aes256Gcm, Nonce};
48
49/// The AES-256-GCM nonce array type (12 bytes).
50type GcmNonce = Nonce<<Aes256Gcm as AeadCore>::NonceSize>;
51use schemars::JsonSchema;
52use serde::{Deserialize, Serialize};
53use sha2::{Digest, Sha256};
54
55/// Magic prefix marking a faucet-sealed payload.
56pub const MAGIC: &[u8; 4] = b"FCT1";
57/// Format byte for AES-256-GCM.
58const FORMAT_AES256GCM: u8 = 0x01;
59/// AES-256-GCM with the payload's identity bound in as associated data (v2).
60const FORMAT_AES256GCM_BOUND: u8 = 0x02;
61/// Prefix of a v2 payload's associated data, ahead of its context.
62const BOUND_AAD_PREFIX: &[u8] = b"faucet:FCT1:v2\0";
63/// AES-GCM nonce length in bytes.
64const NONCE_LEN: usize = 12;
65/// Header length: magic + format byte + nonce.
66const HEADER_LEN: usize = 4 + 1 + NONCE_LEN;
67
68/// Supported AEAD algorithms. A single variant today; the format byte in the
69/// on-disk header leaves room for more without breaking old files.
70#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
71pub enum EncryptionAlgorithm {
72    /// AES-256-GCM authenticated encryption (the default and only option).
73    #[default]
74    #[serde(rename = "aes-256-gcm")]
75    Aes256Gcm,
76}
77
78/// The user-facing `encryption:` config block (state store / jsonl sink).
79///
80/// ```yaml
81/// encryption:
82///   key: ${vault:secret/faucet#state-key}
83///   # previous_keys: ["${env:OLD_STATE_KEY}"]   # rotation: read-only
84///   # algorithm: aes-256-gcm                     # default
85/// ```
86#[derive(Clone, Serialize, Deserialize, JsonSchema)]
87#[serde(deny_unknown_fields)]
88pub struct EncryptionSpec {
89    /// Key material used to seal new writes (and tried first on reads).
90    /// Should come from a secrets manager; derived to a 32-byte AES key via
91    /// SHA-256.
92    pub key: String,
93    /// Older keys tried (in order) when decrypting, for rotation. Never used
94    /// to seal new writes.
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub previous_keys: Vec<String>,
97    /// AEAD algorithm. Only `aes-256-gcm` today.
98    #[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        // Never print key material.
105        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
116/// A compiled, validated encryption policy: the derived write key plus every
117/// derived read key (write key first).
118pub struct CompiledEncryption {
119    /// Cipher used for new writes.
120    write: Aes256Gcm,
121    /// Ciphers tried on read: the write key first, then previous keys.
122    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
133/// `true` when `data` carries the sealed-payload magic. Free function so
134/// callers can detect ciphertext without holding a key.
135pub 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    // SHA-256 output is exactly the AES-256 key size; `new_from_slice` on a
142    // 32-byte slice cannot fail.
143    Aes256Gcm::new_from_slice(&digest).expect("SHA-256 digest is a valid AES-256 key")
144}
145
146impl CompiledEncryption {
147    /// Validate and compile an [`EncryptionSpec`]. Fail-fast: empty key
148    /// material is a typed config error at load time, never mid-run.
149    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    /// Seal `plaintext` with the current key under a fresh random nonce.
167    pub fn encrypt(&self, plaintext: &[u8]) -> Vec<u8> {
168        self.seal(plaintext, FORMAT_AES256GCM, &[])
169    }
170
171    /// [`encrypt`](Self::encrypt), binding `context` (the identity of what is
172    /// sealed — a state key, say) as associated data: the payload opens only
173    /// through [`decrypt_bound`](Self::decrypt_bound) with the same context,
174    /// so a sealed file copied over another key's file no longer decrypts.
175    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            // AES-GCM encryption only fails on plaintexts beyond 2^36 bytes;
191            // faucet payloads (bookmarks, JSON lines) are nowhere near it.
192            .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    /// Open a sealed payload, trying the current key and then each previous
202    /// key. Every failure is a typed error — a wrong key or tampered file
203    /// must never be silently treated as "no data". A context-bound payload
204    /// needs [`decrypt_bound`](Self::decrypt_bound).
205    pub fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>, FaucetError> {
206        self.open(data, None)
207    }
208
209    /// Open a payload sealed by [`encrypt_bound`](Self::encrypt_bound) under
210    /// `context`, or a legacy unbound one.
211    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
266/// Associated-data context of a record line in a JSON Lines file sealed per
267/// line (#789 FILE-24). A file of such lines opens with a header line, ends
268/// with a trailer line carrying the record count and a digest of every line,
269/// and each line is bound so it can not be read as an unbound one.
270pub const SEALED_LINE_CONTEXT: &[u8] = b"faucet.sealed-lines.v2.line";
271/// Associated-data context of a per-line sealed file's header line.
272pub const SEALED_LINES_HEADER_CONTEXT: &[u8] = b"faucet.sealed-lines.v2.header";
273/// Associated-data context of a per-line sealed file's trailer line.
274pub const SEALED_LINES_TRAILER_CONTEXT: &[u8] = b"faucet.sealed-lines.v2.trailer";
275
276/// One opened line of a per-line sealed file.
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub enum SealedLine {
279    /// A record line (bound, or a legacy unbound line) and its plaintext.
280    Data(Vec<u8>),
281    /// The file's header line.
282    Header(Vec<u8>),
283    /// The file's trailer line.
284    Trailer(Vec<u8>),
285}
286
287impl CompiledEncryption {
288    /// Open one sealed line of a per-line sealed file, telling record lines
289    /// from the header and trailer by the context each was sealed under.
290    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        // A v1 payload still opens through the bound reader.
375        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        // Empty plaintext round-trips too.
401        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        // Flip a ciphertext byte.
419        let mut tampered = sealed.clone();
420        let mid = HEADER_LEN + 1;
421        tampered[mid] ^= 0x01;
422        assert!(enc.decrypt(&tampered).is_err());
423
424        // Flip a tag byte (the tag is the trailing 16 bytes).
425        let mut tampered = sealed.clone();
426        let last = tampered.len() - 1;
427        tampered[last] ^= 0x01;
428        assert!(enc.decrypt(&tampered).is_err());
429
430        // Truncate.
431        let truncated = &sealed[..sealed.len() - 4];
432        assert!(enc.decrypt(truncated).is_err());
433        // Truncated inside the header.
434        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        // Reads sealed-with-old.
459        assert_eq!(rotated.decrypt(&sealed_old).unwrap(), b"v1");
460        // Writes seal with the new key only.
461        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; // unknown format
480        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        // Unknown fields are rejected.
519        assert!(serde_json::from_value::<EncryptionSpec>(json!({"key": "k", "nope": 1})).is_err());
520        // Algorithm round-trips by its serde name.
521        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}