use crate::error::FaucetError;
use aes_gcm::aead::{Aead, AeadCore, Generate, KeyInit};
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 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> {
let nonce = GcmNonce::generate();
let ciphertext = self
.write
.encrypt(&nonce, plaintext)
.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_AES256GCM);
out.extend_from_slice(&nonce);
out.extend_from_slice(&ciphertext);
out
}
pub fn decrypt(&self, data: &[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()];
if format != FORMAT_AES256GCM {
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, ciphertext) {
return Ok(plaintext);
}
}
Err(FaucetError::State(
"decryption failed — wrong or rotated key? (tried the configured key and every \
previous_keys entry)"
.into(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn spec(key: &str) -> EncryptionSpec {
EncryptionSpec {
key: key.into(),
previous_keys: vec![],
algorithm: EncryptionAlgorithm::default(),
}
}
#[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"));
}
}