faucet-core 1.14.0

Shared types, traits, and utilities for the faucet-stream ecosystem
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
//! Encryption at rest for faucet-managed local files (#207).
//!
//! Seals [`FileStateStore`](crate::state::FileStateStore) bookmark files and
//! (via the file sinks' `encryption` feature) per-line JSONL / DLQ output
//! with authenticated encryption, so resume positions and dead-lettered
//! records — which can embed sensitive values — are not stored in plaintext.
//! A per-line file binds its lines with the `SEALED_LINE*` contexts and
//! carries a header and trailer, so the file is authenticated whole, not only
//! each line (see `faucet_common_file::sealed_lines`).
//!
//! ## On-disk format (v1)
//!
//! ```text
//! +---------+--------+-----------+----------------------+
//! | "FCT1"  | format | nonce     | ciphertext ‖ GCM tag |
//! | 4 bytes | 1 byte | 12 bytes  | len(plaintext) + 16  |
//! +---------+--------+-----------+----------------------+
//! ```
//!
//! - Magic `FCT1` ("faucet ciphertext v1") marks a sealed payload; plaintext
//!   JSON can never start with these bytes, so [`is_encrypted`] is unambiguous.
//! - `format` byte `0x01` = AES-256-GCM. Unknown values are a typed error
//!   (never a panic), leaving room for future algorithms.
//! - The nonce is random per encryption (never reused across writes).
//! - The GCM tag authenticates the payload: any tampering — a flipped bit,
//!   truncation, a swapped nonce — fails decryption loudly.
//! - `format` byte `0x02` (v2) is AES-256-GCM with the payload's identity —
//!   for a state file, its key — bound in as associated data
//!   ([`CompiledEncryption::encrypt_bound`]), so a sealed file copied over
//!   another key's file fails to decrypt. v1 payloads stay readable.
//!
//! ## Keys
//!
//! The 32-byte AES key is **derived as `SHA-256(key string)`** — a
//! derivation, not a stretching KDF: it accepts arbitrary passphrases and
//! hex/base64 strings uniformly, but offers no brute-force hardening, so the
//! key string should be high-entropy material from a secrets manager
//! (`${vault:…}` / `${aws-sm:…}` / `${env:…}`), not a human password.
//!
//! Rotation: writes always seal with `key`; decryption tries `key` first and
//! then each entry of `previous_keys` in order, so a store can be rotated by
//! moving the old key into `previous_keys` — old files stay readable and are
//! re-sealed with the new key on their next write.

use crate::error::FaucetError;
use aes_gcm::aead::{Aead, AeadCore, Generate, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Nonce};

/// The AES-256-GCM nonce array type (12 bytes).
type GcmNonce = Nonce<<Aes256Gcm as AeadCore>::NonceSize>;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

/// Magic prefix marking a faucet-sealed payload.
pub const MAGIC: &[u8; 4] = b"FCT1";
/// Format byte for AES-256-GCM.
const FORMAT_AES256GCM: u8 = 0x01;
/// AES-256-GCM with the payload's identity bound in as associated data (v2).
const FORMAT_AES256GCM_BOUND: u8 = 0x02;
/// Prefix of a v2 payload's associated data, ahead of its context.
const BOUND_AAD_PREFIX: &[u8] = b"faucet:FCT1:v2\0";
/// AES-GCM nonce length in bytes.
const NONCE_LEN: usize = 12;
/// Header length: magic + format byte + nonce.
const HEADER_LEN: usize = 4 + 1 + NONCE_LEN;

/// Supported AEAD algorithms. A single variant today; the format byte in the
/// on-disk header leaves room for more without breaking old files.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum EncryptionAlgorithm {
    /// AES-256-GCM authenticated encryption (the default and only option).
    #[default]
    #[serde(rename = "aes-256-gcm")]
    Aes256Gcm,
}

/// The user-facing `encryption:` config block (state store / jsonl sink).
///
/// ```yaml
/// encryption:
///   key: ${vault:secret/faucet#state-key}
///   # previous_keys: ["${env:OLD_STATE_KEY}"]   # rotation: read-only
///   # algorithm: aes-256-gcm                     # default
/// ```
#[derive(Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct EncryptionSpec {
    /// Key material used to seal new writes (and tried first on reads).
    /// Should come from a secrets manager; derived to a 32-byte AES key via
    /// SHA-256.
    pub key: String,
    /// Older keys tried (in order) when decrypting, for rotation. Never used
    /// to seal new writes.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub previous_keys: Vec<String>,
    /// AEAD algorithm. Only `aes-256-gcm` today.
    #[serde(default)]
    pub algorithm: EncryptionAlgorithm,
}

impl std::fmt::Debug for EncryptionSpec {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Never print key material.
        f.debug_struct("EncryptionSpec")
            .field("key", &"***")
            .field(
                "previous_keys",
                &format!("[{} keys]", self.previous_keys.len()),
            )
            .field("algorithm", &self.algorithm)
            .finish()
    }
}

/// A compiled, validated encryption policy: the derived write key plus every
/// derived read key (write key first).
pub struct CompiledEncryption {
    /// Cipher used for new writes.
    write: Aes256Gcm,
    /// Ciphers tried on read: the write key first, then previous keys.
    read: Vec<Aes256Gcm>,
}

impl std::fmt::Debug for CompiledEncryption {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("CompiledEncryption")
            .field("read_keys", &self.read.len())
            .finish()
    }
}

/// `true` when `data` carries the sealed-payload magic. Free function so
/// callers can detect ciphertext without holding a key.
pub fn is_encrypted(data: &[u8]) -> bool {
    data.len() >= MAGIC.len() && &data[..MAGIC.len()] == MAGIC
}

fn derive_key(key: &str) -> Aes256Gcm {
    let digest = Sha256::digest(key.as_bytes());
    // SHA-256 output is exactly the AES-256 key size; `new_from_slice` on a
    // 32-byte slice cannot fail.
    Aes256Gcm::new_from_slice(&digest).expect("SHA-256 digest is a valid AES-256 key")
}

impl CompiledEncryption {
    /// Validate and compile an [`EncryptionSpec`]. Fail-fast: empty key
    /// material is a typed config error at load time, never mid-run.
    pub fn compile(spec: &EncryptionSpec) -> Result<Self, FaucetError> {
        if spec.key.trim().is_empty() {
            return Err(FaucetError::Config(
                "encryption.key must not be empty".into(),
            ));
        }
        if let Some(idx) = spec.previous_keys.iter().position(|k| k.trim().is_empty()) {
            return Err(FaucetError::Config(format!(
                "encryption.previous_keys[{idx}] must not be empty"
            )));
        }
        let write = derive_key(&spec.key);
        let mut read = vec![derive_key(&spec.key)];
        read.extend(spec.previous_keys.iter().map(|k| derive_key(k)));
        Ok(Self { write, read })
    }

    /// Seal `plaintext` with the current key under a fresh random nonce.
    pub fn encrypt(&self, plaintext: &[u8]) -> Vec<u8> {
        self.seal(plaintext, FORMAT_AES256GCM, &[])
    }

    /// [`encrypt`](Self::encrypt), binding `context` (the identity of what is
    /// sealed — a state key, say) as associated data: the payload opens only
    /// through [`decrypt_bound`](Self::decrypt_bound) with the same context,
    /// so a sealed file copied over another key's file no longer decrypts.
    pub fn encrypt_bound(&self, plaintext: &[u8], context: &[u8]) -> Vec<u8> {
        self.seal(plaintext, FORMAT_AES256GCM_BOUND, &bound_aad(context))
    }

    fn seal(&self, plaintext: &[u8], format: u8, aad: &[u8]) -> Vec<u8> {
        let nonce = GcmNonce::generate();
        let ciphertext = self
            .write
            .encrypt(
                &nonce,
                Payload {
                    msg: plaintext,
                    aad,
                },
            )
            // AES-GCM encryption only fails on plaintexts beyond 2^36 bytes;
            // faucet payloads (bookmarks, JSON lines) are nowhere near it.
            .expect("AES-GCM encryption of an in-memory payload cannot fail");
        let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len());
        out.extend_from_slice(MAGIC);
        out.push(format);
        out.extend_from_slice(&nonce);
        out.extend_from_slice(&ciphertext);
        out
    }

    /// Open a sealed payload, trying the current key and then each previous
    /// key. Every failure is a typed error — a wrong key or tampered file
    /// must never be silently treated as "no data". A context-bound payload
    /// needs [`decrypt_bound`](Self::decrypt_bound).
    pub fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>, FaucetError> {
        self.open(data, None)
    }

    /// Open a payload sealed by [`encrypt_bound`](Self::encrypt_bound) under
    /// `context`, or a legacy unbound one.
    pub fn decrypt_bound(&self, data: &[u8], context: &[u8]) -> Result<Vec<u8>, FaucetError> {
        self.open(data, Some(context))
    }

    fn open(&self, data: &[u8], context: Option<&[u8]>) -> Result<Vec<u8>, FaucetError> {
        if !is_encrypted(data) {
            return Err(FaucetError::State(
                "payload is not faucet-encrypted (missing FCT1 header)".into(),
            ));
        }
        if data.len() < HEADER_LEN {
            return Err(FaucetError::State(
                "encrypted payload is truncated (shorter than its header)".into(),
            ));
        }
        let format = data[MAGIC.len()];
        let aad = match (format, context) {
            (FORMAT_AES256GCM, _) => Vec::new(),
            (FORMAT_AES256GCM_BOUND, Some(context)) => bound_aad(context),
            (FORMAT_AES256GCM_BOUND, None) => {
                return Err(FaucetError::State(
                    "encrypted payload is bound to its identity and must be opened with it".into(),
                ));
            }
            _ => {
                return Err(FaucetError::State(format!(
                    "unknown encrypted-payload format byte 0x{format:02x} — written by a newer \
                     faucet version?"
                )));
            }
        };
        let nonce_bytes: [u8; NONCE_LEN] = data[MAGIC.len() + 1..HEADER_LEN]
            .try_into()
            .expect("slice length checked above");
        let nonce = GcmNonce::from(nonce_bytes);
        let ciphertext = &data[HEADER_LEN..];
        for cipher in &self.read {
            if let Ok(plaintext) = cipher.decrypt(
                &nonce,
                Payload {
                    msg: ciphertext,
                    aad: &aad,
                },
            ) {
                return Ok(plaintext);
            }
        }
        Err(FaucetError::State(
            "decryption failed — wrong or rotated key, or a payload sealed for another \
             identity? (tried the configured key and every previous_keys entry)"
                .into(),
        ))
    }
}

/// Associated-data context of a record line in a JSON Lines file sealed per
/// line (#789 FILE-24). A file of such lines opens with a header line, ends
/// with a trailer line carrying the record count and a digest of every line,
/// and each line is bound so it can not be read as an unbound one.
pub const SEALED_LINE_CONTEXT: &[u8] = b"faucet.sealed-lines.v2.line";
/// Associated-data context of a per-line sealed file's header line.
pub const SEALED_LINES_HEADER_CONTEXT: &[u8] = b"faucet.sealed-lines.v2.header";
/// Associated-data context of a per-line sealed file's trailer line.
pub const SEALED_LINES_TRAILER_CONTEXT: &[u8] = b"faucet.sealed-lines.v2.trailer";

/// One opened line of a per-line sealed file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SealedLine {
    /// A record line (bound, or a legacy unbound line) and its plaintext.
    Data(Vec<u8>),
    /// The file's header line.
    Header(Vec<u8>),
    /// The file's trailer line.
    Trailer(Vec<u8>),
}

impl CompiledEncryption {
    /// Open one sealed line of a per-line sealed file, telling record lines
    /// from the header and trailer by the context each was sealed under.
    pub fn open_line(&self, sealed: &[u8]) -> Result<SealedLine, FaucetError> {
        let data = self.decrypt_bound(sealed, SEALED_LINE_CONTEXT);
        if let Ok(plain) = data {
            return Ok(SealedLine::Data(plain));
        }
        if let Ok(plain) = self.decrypt_bound(sealed, SEALED_LINES_HEADER_CONTEXT) {
            return Ok(SealedLine::Header(plain));
        }
        if let Ok(plain) = self.decrypt_bound(sealed, SEALED_LINES_TRAILER_CONTEXT) {
            return Ok(SealedLine::Trailer(plain));
        }
        data.map(SealedLine::Data)
    }
}

fn bound_aad(context: &[u8]) -> Vec<u8> {
    let mut aad = BOUND_AAD_PREFIX.to_vec();
    aad.extend_from_slice(context);
    aad
}

#[cfg(test)]
mod tests {
    #[test]
    fn sealed_lines_open_by_their_context() {
        let enc = CompiledEncryption::compile(&EncryptionSpec {
            key: "k".into(),
            previous_keys: vec![],
            algorithm: Default::default(),
        })
        .unwrap();
        let line = enc.encrypt_bound(b"r", SEALED_LINE_CONTEXT);
        let head = enc.encrypt_bound(b"h", SEALED_LINES_HEADER_CONTEXT);
        let tail = enc.encrypt_bound(b"t", SEALED_LINES_TRAILER_CONTEXT);
        assert_eq!(
            enc.open_line(&line).unwrap(),
            SealedLine::Data(b"r".to_vec())
        );
        assert_eq!(
            enc.open_line(&head).unwrap(),
            SealedLine::Header(b"h".to_vec())
        );
        assert_eq!(
            enc.open_line(&tail).unwrap(),
            SealedLine::Trailer(b"t".to_vec())
        );
        assert_eq!(
            enc.open_line(&enc.encrypt(b"old")).unwrap(),
            SealedLine::Data(b"old".to_vec())
        );
        assert!(
            enc.decrypt(&line).is_err(),
            "a bound line never reads as unbound"
        );
        assert!(enc.open_line(&enc.encrypt_bound(b"x", b"other")).is_err());
    }

    use super::*;
    use serde_json::json;

    fn spec(key: &str) -> EncryptionSpec {
        EncryptionSpec {
            key: key.into(),
            previous_keys: vec![],
            algorithm: EncryptionAlgorithm::default(),
        }
    }

    #[test]
    fn bound_payloads_open_only_under_their_context() {
        let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
        let sealed = enc.encrypt_bound(b"bookmark", b"pipeline::a");
        assert_eq!(sealed[MAGIC.len()], FORMAT_AES256GCM_BOUND);
        assert_eq!(
            enc.decrypt_bound(&sealed, b"pipeline::a").unwrap(),
            b"bookmark"
        );
        let err = enc
            .decrypt_bound(&sealed, b"pipeline::b")
            .unwrap_err()
            .to_string();
        assert!(err.contains("another"), "{err}");
        let err = enc.decrypt(&sealed).unwrap_err().to_string();
        assert!(err.contains("bound to its identity"), "{err}");
        // A v1 payload still opens through the bound reader.
        let legacy = enc.encrypt(b"old");
        assert_eq!(enc.decrypt_bound(&legacy, b"pipeline::a").unwrap(), b"old");
        let mut unknown = legacy.clone();
        unknown[MAGIC.len()] = 0x7f;
        assert!(enc.decrypt_bound(&unknown, b"x").is_err());
    }

    #[test]
    fn a_serialized_spec_round_trips() {
        let spec: EncryptionSpec =
            serde_json::from_value(serde_json::json!({"key": "k1", "previous_keys": ["k0"]}))
                .unwrap();
        let back: EncryptionSpec =
            serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap();
        assert_eq!(back.key, "k1");
        assert_eq!(back.previous_keys, vec!["k0".to_string()]);
        assert!(!format!("{spec:?}").contains("k1"));
    }

    #[test]
    fn round_trips() {
        let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
        let sealed = enc.encrypt(b"hello bookmark");
        assert!(is_encrypted(&sealed));
        assert_eq!(enc.decrypt(&sealed).unwrap(), b"hello bookmark");
        // Empty plaintext round-trips too.
        let sealed = enc.encrypt(b"");
        assert_eq!(enc.decrypt(&sealed).unwrap(), b"");
    }

    #[test]
    fn nonces_are_unique_per_encryption() {
        let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
        let a = enc.encrypt(b"same plaintext");
        let b = enc.encrypt(b"same plaintext");
        assert_ne!(a, b, "two seals of the same plaintext must differ");
    }

    #[test]
    fn tampering_is_detected() {
        let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
        let sealed = enc.encrypt(b"payload");

        // Flip a ciphertext byte.
        let mut tampered = sealed.clone();
        let mid = HEADER_LEN + 1;
        tampered[mid] ^= 0x01;
        assert!(enc.decrypt(&tampered).is_err());

        // Flip a tag byte (the tag is the trailing 16 bytes).
        let mut tampered = sealed.clone();
        let last = tampered.len() - 1;
        tampered[last] ^= 0x01;
        assert!(enc.decrypt(&tampered).is_err());

        // Truncate.
        let truncated = &sealed[..sealed.len() - 4];
        assert!(enc.decrypt(truncated).is_err());
        // Truncated inside the header.
        assert!(enc.decrypt(&sealed[..6]).is_err());
    }

    #[test]
    fn wrong_key_is_a_typed_error() {
        let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
        let other = CompiledEncryption::compile(&spec("k2")).unwrap();
        let sealed = enc.encrypt(b"payload");
        let err = other.decrypt(&sealed).unwrap_err();
        assert!(matches!(err, FaucetError::State(_)));
        assert!(err.to_string().contains("wrong or rotated key"));
    }

    #[test]
    fn rotation_reads_old_writes_new() {
        let old = CompiledEncryption::compile(&spec("old-key")).unwrap();
        let sealed_old = old.encrypt(b"v1");

        let rotated = CompiledEncryption::compile(&EncryptionSpec {
            key: "new-key".into(),
            previous_keys: vec!["old-key".into()],
            algorithm: EncryptionAlgorithm::default(),
        })
        .unwrap();
        // Reads sealed-with-old.
        assert_eq!(rotated.decrypt(&sealed_old).unwrap(), b"v1");
        // Writes seal with the new key only.
        let sealed_new = rotated.encrypt(b"v2");
        let new_only = CompiledEncryption::compile(&spec("new-key")).unwrap();
        assert_eq!(new_only.decrypt(&sealed_new).unwrap(), b"v2");
        assert!(old.decrypt(&sealed_new).is_err());
    }

    #[test]
    fn is_encrypted_detection() {
        assert!(!is_encrypted(b""));
        assert!(!is_encrypted(b"FC"));
        assert!(!is_encrypted(b"{\"json\": true}"));
        assert!(is_encrypted(b"FCT1 anything"));
    }

    #[test]
    fn unknown_format_byte_is_a_typed_error() {
        let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
        let mut sealed = enc.encrypt(b"x");
        sealed[4] = 0x7f; // unknown format
        let err = enc.decrypt(&sealed).unwrap_err();
        assert!(err.to_string().contains("format byte 0x7f"));
    }

    #[test]
    fn non_encrypted_input_is_a_typed_error() {
        let enc = CompiledEncryption::compile(&spec("k1")).unwrap();
        let err = enc.decrypt(b"plain text").unwrap_err();
        assert!(err.to_string().contains("not faucet-encrypted"));
    }

    #[test]
    fn empty_keys_are_rejected_at_compile_time() {
        assert!(matches!(
            CompiledEncryption::compile(&spec("  ")),
            Err(FaucetError::Config(_))
        ));
        let bad_prev = EncryptionSpec {
            key: "k".into(),
            previous_keys: vec!["ok".into(), "".into()],
            algorithm: EncryptionAlgorithm::default(),
        };
        let err = CompiledEncryption::compile(&bad_prev).unwrap_err();
        assert!(err.to_string().contains("previous_keys[1]"));
    }

    #[test]
    fn spec_serde_shape_and_debug_masking() {
        let s: EncryptionSpec = serde_json::from_value(json!({
            "key": "SECRET-MATERIAL",
            "previous_keys": ["OLD-SECRET"],
        }))
        .unwrap();
        assert_eq!(s.algorithm, EncryptionAlgorithm::Aes256Gcm);
        let rendered = format!("{s:?}");
        assert!(!rendered.contains("SECRET-MATERIAL"));
        assert!(!rendered.contains("OLD-SECRET"));
        assert!(rendered.contains("[1 keys]"));
        // Unknown fields are rejected.
        assert!(serde_json::from_value::<EncryptionSpec>(json!({"key": "k", "nope": 1})).is_err());
        // Algorithm round-trips by its serde name.
        let s: EncryptionSpec =
            serde_json::from_value(json!({"key": "k", "algorithm": "aes-256-gcm"})).unwrap();
        assert_eq!(s.algorithm, EncryptionAlgorithm::Aes256Gcm);
    }

    #[test]
    fn compiled_debug_never_prints_key_material() {
        let enc = CompiledEncryption::compile(&spec("TOPSECRET")).unwrap();
        let rendered = format!("{enc:?}");
        assert!(!rendered.contains("TOPSECRET"));
    }
}