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 jsonl sink's `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//!
8//! ## On-disk format (v1)
9//!
10//! ```text
11//! +---------+--------+-----------+----------------------+
12//! | "FCT1"  | format | nonce     | ciphertext ‖ GCM tag |
13//! | 4 bytes | 1 byte | 12 bytes  | len(plaintext) + 16  |
14//! +---------+--------+-----------+----------------------+
15//! ```
16//!
17//! - Magic `FCT1` ("faucet ciphertext v1") marks a sealed payload; plaintext
18//!   JSON can never start with these bytes, so [`is_encrypted`] is unambiguous.
19//! - `format` byte `0x01` = AES-256-GCM. Unknown values are a typed error
20//!   (never a panic), leaving room for future algorithms.
21//! - The nonce is random per encryption (never reused across writes).
22//! - The GCM tag authenticates the payload: any tampering — a flipped bit,
23//!   truncation, a swapped nonce — fails decryption loudly.
24//!
25//! ## Keys
26//!
27//! The 32-byte AES key is **derived as `SHA-256(key string)`** — a
28//! derivation, not a stretching KDF: it accepts arbitrary passphrases and
29//! hex/base64 strings uniformly, but offers no brute-force hardening, so the
30//! key string should be high-entropy material from a secrets manager
31//! (`${vault:…}` / `${aws-sm:…}` / `${env:…}`), not a human password.
32//!
33//! Rotation: writes always seal with `key`; decryption tries `key` first and
34//! then each entry of `previous_keys` in order, so a store can be rotated by
35//! moving the old key into `previous_keys` — old files stay readable and are
36//! re-sealed with the new key on their next write.
37
38use crate::error::FaucetError;
39use aes_gcm::aead::{Aead, AeadCore, Generate, KeyInit};
40use aes_gcm::{Aes256Gcm, Nonce};
41
42/// The AES-256-GCM nonce array type (12 bytes).
43type GcmNonce = Nonce<<Aes256Gcm as AeadCore>::NonceSize>;
44use schemars::JsonSchema;
45use serde::{Deserialize, Serialize};
46use sha2::{Digest, Sha256};
47
48/// Magic prefix marking a faucet-sealed payload.
49pub const MAGIC: &[u8; 4] = b"FCT1";
50/// Format byte for AES-256-GCM.
51const FORMAT_AES256GCM: u8 = 0x01;
52/// AES-GCM nonce length in bytes.
53const NONCE_LEN: usize = 12;
54/// Header length: magic + format byte + nonce.
55const HEADER_LEN: usize = 4 + 1 + NONCE_LEN;
56
57/// Supported AEAD algorithms. A single variant today; the format byte in the
58/// on-disk header leaves room for more without breaking old files.
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
60pub enum EncryptionAlgorithm {
61    /// AES-256-GCM authenticated encryption (the default and only option).
62    #[default]
63    #[serde(rename = "aes-256-gcm")]
64    Aes256Gcm,
65}
66
67/// The user-facing `encryption:` config block (state store / jsonl sink).
68///
69/// ```yaml
70/// encryption:
71///   key: ${vault:secret/faucet#state-key}
72///   # previous_keys: ["${env:OLD_STATE_KEY}"]   # rotation: read-only
73///   # algorithm: aes-256-gcm                     # default
74/// ```
75#[derive(Clone, Serialize, Deserialize, JsonSchema)]
76#[serde(deny_unknown_fields)]
77pub struct EncryptionSpec {
78    /// Key material used to seal new writes (and tried first on reads).
79    /// Should come from a secrets manager; derived to a 32-byte AES key via
80    /// SHA-256.
81    pub key: String,
82    /// Older keys tried (in order) when decrypting, for rotation. Never used
83    /// to seal new writes.
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub previous_keys: Vec<String>,
86    /// AEAD algorithm. Only `aes-256-gcm` today.
87    #[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        // Never print key material.
94        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
105/// A compiled, validated encryption policy: the derived write key plus every
106/// derived read key (write key first).
107pub struct CompiledEncryption {
108    /// Cipher used for new writes.
109    write: Aes256Gcm,
110    /// Ciphers tried on read: the write key first, then previous keys.
111    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
122/// `true` when `data` carries the sealed-payload magic. Free function so
123/// callers can detect ciphertext without holding a key.
124pub 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    // SHA-256 output is exactly the AES-256 key size; `new_from_slice` on a
131    // 32-byte slice cannot fail.
132    Aes256Gcm::new_from_slice(&digest).expect("SHA-256 digest is a valid AES-256 key")
133}
134
135impl CompiledEncryption {
136    /// Validate and compile an [`EncryptionSpec`]. Fail-fast: empty key
137    /// material is a typed config error at load time, never mid-run.
138    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    /// Seal `plaintext` with the current key under a fresh random nonce.
156    pub fn encrypt(&self, plaintext: &[u8]) -> Vec<u8> {
157        let nonce = GcmNonce::generate();
158        let ciphertext = self
159            .write
160            .encrypt(&nonce, plaintext)
161            // AES-GCM encryption only fails on plaintexts beyond 2^36 bytes;
162            // faucet payloads (bookmarks, JSON lines) are nowhere near it.
163            .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    /// Open a sealed payload, trying the current key and then each previous
173    /// key. Every failure is a typed error — a wrong key or tampered file
174    /// must never be silently treated as "no data".
175    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        // Empty plaintext round-trips too.
231        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        // Flip a ciphertext byte.
249        let mut tampered = sealed.clone();
250        let mid = HEADER_LEN + 1;
251        tampered[mid] ^= 0x01;
252        assert!(enc.decrypt(&tampered).is_err());
253
254        // Flip a tag byte (the tag is the trailing 16 bytes).
255        let mut tampered = sealed.clone();
256        let last = tampered.len() - 1;
257        tampered[last] ^= 0x01;
258        assert!(enc.decrypt(&tampered).is_err());
259
260        // Truncate.
261        let truncated = &sealed[..sealed.len() - 4];
262        assert!(enc.decrypt(truncated).is_err());
263        // Truncated inside the header.
264        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        // Reads sealed-with-old.
289        assert_eq!(rotated.decrypt(&sealed_old).unwrap(), b"v1");
290        // Writes seal with the new key only.
291        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; // unknown format
310        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        // Unknown fields are rejected.
349        assert!(serde_json::from_value::<EncryptionSpec>(json!({"key": "k", "nope": 1})).is_err());
350        // Algorithm round-trips by its serde name.
351        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}