use crate::error::FaucetError;
use aes_gcm::aead::{Aead, AeadCore, Generate, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Nonce};
type GcmNonce = Nonce<<Aes256Gcm as AeadCore>::NonceSize>;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const MAGIC: &[u8; 4] = b"FCT1";
const FORMAT_AES256GCM: u8 = 0x01;
const FORMAT_AES256GCM_BOUND: u8 = 0x02;
const BOUND_AAD_PREFIX: &[u8] = b"faucet:FCT1:v2\0";
const NONCE_LEN: usize = 12;
const HEADER_LEN: usize = 4 + 1 + NONCE_LEN;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum EncryptionAlgorithm {
#[default]
#[serde(rename = "aes-256-gcm")]
Aes256Gcm,
}
#[derive(Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct EncryptionSpec {
pub key: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub previous_keys: Vec<String>,
#[serde(default)]
pub algorithm: EncryptionAlgorithm,
}
impl std::fmt::Debug for EncryptionSpec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EncryptionSpec")
.field("key", &"***")
.field(
"previous_keys",
&format!("[{} keys]", self.previous_keys.len()),
)
.field("algorithm", &self.algorithm)
.finish()
}
}
pub struct CompiledEncryption {
write: Aes256Gcm,
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()
}
}
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());
Aes256Gcm::new_from_slice(&digest).expect("SHA-256 digest is a valid AES-256 key")
}
impl CompiledEncryption {
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 })
}
pub fn encrypt(&self, plaintext: &[u8]) -> Vec<u8> {
self.seal(plaintext, FORMAT_AES256GCM, &[])
}
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,
},
)
.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
}
pub fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>, FaucetError> {
self.open(data, None)
}
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(),
))
}
}
pub const SEALED_LINE_CONTEXT: &[u8] = b"faucet.sealed-lines.v2.line";
pub const SEALED_LINES_HEADER_CONTEXT: &[u8] = b"faucet.sealed-lines.v2.header";
pub const SEALED_LINES_TRAILER_CONTEXT: &[u8] = b"faucet.sealed-lines.v2.trailer";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SealedLine {
Data(Vec<u8>),
Header(Vec<u8>),
Trailer(Vec<u8>),
}
impl CompiledEncryption {
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}");
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");
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");
let mut tampered = sealed.clone();
let mid = HEADER_LEN + 1;
tampered[mid] ^= 0x01;
assert!(enc.decrypt(&tampered).is_err());
let mut tampered = sealed.clone();
let last = tampered.len() - 1;
tampered[last] ^= 0x01;
assert!(enc.decrypt(&tampered).is_err());
let truncated = &sealed[..sealed.len() - 4];
assert!(enc.decrypt(truncated).is_err());
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();
assert_eq!(rotated.decrypt(&sealed_old).unwrap(), b"v1");
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; 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]"));
assert!(serde_json::from_value::<EncryptionSpec>(json!({"key": "k", "nope": 1})).is_err());
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"));
}
}