use aes_gcm::aead::{Aead, Generate, Nonce, Payload};
use aes_gcm::{Aes256Gcm, KeyInit};
const KEK_LEN: usize = 32;
const NONCE_LEN: usize = 12;
pub trait KeyCustody {
fn encrypt(&self, tenant: &str, plaintext: &[u8]) -> Result<Vec<u8>, KeyCustodyError>;
fn decrypt(&self, tenant: &str, blob: &[u8]) -> Result<Vec<u8>, KeyCustodyError>;
}
#[derive(Debug, thiserror::Error)]
pub enum KeyCustodyError {
#[error("no KEK configured: set FIRSTPASS_KEK (hex-encoded 32 bytes) or FIRSTPASS_KEK_FILE")]
MissingKek,
#[error("invalid KEK: {0}")]
BadKek(String),
#[error("encryption failed")]
Encrypt,
#[error("decryption failed")]
Decrypt,
}
pub struct LocalKeyCustody {
cipher: Aes256Gcm,
}
impl LocalKeyCustody {
#[must_use]
pub fn new(kek: [u8; KEK_LEN]) -> Self {
Self {
cipher: Aes256Gcm::new(&kek.into()),
}
}
pub fn from_env() -> Result<Self, KeyCustodyError> {
Self::from_lookup(|k| std::env::var(k).ok())
}
pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self, KeyCustodyError> {
if let Some(hex_kek) = lookup("FIRSTPASS_KEK") {
let bytes = hex::decode(hex_kek.trim()).map_err(|e| {
KeyCustodyError::BadKek(format!("FIRSTPASS_KEK is not valid hex: {e}"))
})?;
return Self::from_bytes(&bytes);
}
if let Some(path) = lookup("FIRSTPASS_KEK_FILE") {
let bytes = std::fs::read(&path).map_err(|e| {
KeyCustodyError::BadKek(format!("cannot read FIRSTPASS_KEK_FILE ({path}): {e}"))
})?;
return Self::from_bytes(&bytes);
}
Err(KeyCustodyError::MissingKek)
}
fn from_bytes(bytes: &[u8]) -> Result<Self, KeyCustodyError> {
let kek: [u8; KEK_LEN] = bytes.try_into().map_err(|_| {
KeyCustodyError::BadKek(format!(
"KEK must be exactly {KEK_LEN} bytes, got {}",
bytes.len()
))
})?;
Ok(Self::new(kek))
}
}
impl std::fmt::Debug for LocalKeyCustody {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LocalKeyCustody")
.field("cipher", &"<redacted AES-256-GCM key>")
.finish()
}
}
impl KeyCustody for LocalKeyCustody {
fn encrypt(&self, tenant: &str, plaintext: &[u8]) -> Result<Vec<u8>, KeyCustodyError> {
let nonce = Nonce::<Aes256Gcm>::try_generate().map_err(|_| KeyCustodyError::Encrypt)?;
let ciphertext = self
.cipher
.encrypt(
&nonce,
Payload {
msg: plaintext,
aad: tenant.as_bytes(),
},
)
.map_err(|_| KeyCustodyError::Encrypt)?;
let mut blob = Vec::with_capacity(NONCE_LEN + ciphertext.len());
blob.extend_from_slice(&nonce);
blob.extend_from_slice(&ciphertext);
Ok(blob)
}
fn decrypt(&self, tenant: &str, blob: &[u8]) -> Result<Vec<u8>, KeyCustodyError> {
if blob.len() < NONCE_LEN {
return Err(KeyCustodyError::Decrypt);
}
let (nonce_bytes, ciphertext) = blob.split_at(NONCE_LEN);
let nonce =
<&Nonce<Aes256Gcm>>::try_from(nonce_bytes).map_err(|_| KeyCustodyError::Decrypt)?;
self.cipher
.decrypt(
nonce,
Payload {
msg: ciphertext,
aad: tenant.as_bytes(),
},
)
.map_err(|_| KeyCustodyError::Decrypt)
}
}
#[cfg(test)]
mod tests {
use super::*;
const KEK1: [u8; KEK_LEN] = [0x11; KEK_LEN];
const KEK2: [u8; KEK_LEN] = [0x22; KEK_LEN];
#[test]
fn round_trip_various_messages() {
let custody = LocalKeyCustody::new(KEK1);
let messages: &[&[u8]] = &[
b"", b"x", b"hello tenant secrets", &[0xABu8; 64 * 1024], ];
for msg in messages {
let blob = custody.encrypt("acme", msg).expect("encrypt");
let out = custody.decrypt("acme", &blob).expect("decrypt");
assert_eq!(&out, msg, "round-trip must recover the exact plaintext");
}
}
#[test]
fn blob_layout_is_nonce_then_ciphertext_and_tag() {
let custody = LocalKeyCustody::new(KEK1);
let msg = b"layout check";
let blob = custody.encrypt("acme", msg).expect("encrypt");
assert_eq!(blob.len(), NONCE_LEN + msg.len() + 16);
}
#[test]
fn tenant_binding_blocks_cross_tenant_decrypt() {
let custody = LocalKeyCustody::new(KEK1);
let blob = custody.encrypt("tenant-a", b"a's data").expect("encrypt");
let result = custody.decrypt("tenant-b", &blob);
assert!(
matches!(result, Err(KeyCustodyError::Decrypt)),
"cross-tenant decrypt must fail the auth tag"
);
}
#[test]
fn tamper_of_any_region_is_detected() {
let custody = LocalKeyCustody::new(KEK1);
let msg = b"integrity matters";
let blob = custody.encrypt("acme", msg).expect("encrypt");
for idx in [0usize, NONCE_LEN + 1, blob.len() - 1] {
let mut tampered = blob.clone();
tampered[idx] ^= 0x01;
let result = custody.decrypt("acme", &tampered);
assert!(
matches!(result, Err(KeyCustodyError::Decrypt)),
"flipping byte {idx} must be detected"
);
}
}
#[test]
fn wrong_kek_cannot_decrypt() {
let sealer = LocalKeyCustody::new(KEK1);
let opener = LocalKeyCustody::new(KEK2);
let blob = sealer.encrypt("acme", b"secret").expect("encrypt");
let result = opener.decrypt("acme", &blob);
assert!(matches!(result, Err(KeyCustodyError::Decrypt)));
}
#[test]
fn nonce_is_fresh_per_call() {
let custody = LocalKeyCustody::new(KEK1);
let a = custody.encrypt("acme", b"same message").expect("encrypt");
let b = custody.encrypt("acme", b"same message").expect("encrypt");
assert_ne!(a, b, "distinct nonces must yield distinct blobs");
assert_ne!(&a[..NONCE_LEN], &b[..NONCE_LEN], "nonces must differ");
}
#[test]
fn from_env_hex_key_loads_and_works() {
let hex_kek = hex::encode(KEK1);
let custody = LocalKeyCustody::from_lookup(|k| match k {
"FIRSTPASS_KEK" => Some(hex_kek.clone()),
_ => None,
})
.expect("valid hex KEK must load");
let blob = custody.encrypt("acme", b"env-loaded").expect("encrypt");
assert_eq!(
custody.decrypt("acme", &blob).expect("decrypt"),
b"env-loaded"
);
}
#[test]
fn from_env_trims_surrounding_whitespace() {
let hex_kek = format!(" {}\n", hex::encode(KEK1));
let custody =
LocalKeyCustody::from_lookup(|k| (k == "FIRSTPASS_KEK").then(|| hex_kek.clone()));
assert!(custody.is_ok(), "trimmed hex KEK must load");
}
#[test]
fn from_env_missing_is_missing_kek() {
let result = LocalKeyCustody::from_lookup(|_| None);
assert!(matches!(result, Err(KeyCustodyError::MissingKek)));
}
#[test]
fn from_env_short_key_is_bad_kek() {
let short = hex::encode([0x11u8; 16]); let result =
LocalKeyCustody::from_lookup(|k| (k == "FIRSTPASS_KEK").then(|| short.clone()));
assert!(matches!(result, Err(KeyCustodyError::BadKek(_))));
}
#[test]
fn from_env_malformed_hex_is_bad_kek() {
let result =
LocalKeyCustody::from_lookup(|k| (k == "FIRSTPASS_KEK").then(|| "nothex!!".to_owned()));
assert!(matches!(result, Err(KeyCustodyError::BadKek(_))));
}
#[test]
fn from_env_file_with_32_raw_bytes_loads() {
let dir = std::env::temp_dir();
let path = dir.join(format!("firstpass-kek-{}.bin", std::process::id()));
std::fs::write(&path, KEK1).expect("write temp KEK file");
let path_str = path.to_string_lossy().into_owned();
let custody =
LocalKeyCustody::from_lookup(|k| (k == "FIRSTPASS_KEK_FILE").then(|| path_str.clone()));
let _ = std::fs::remove_file(&path);
let custody = custody.expect("32-byte key file must load");
let blob = custody.encrypt("acme", b"file-loaded").expect("encrypt");
assert_eq!(
custody.decrypt("acme", &blob).expect("decrypt"),
b"file-loaded"
);
}
#[test]
fn from_env_file_wrong_length_is_bad_kek() {
let dir = std::env::temp_dir();
let path = dir.join(format!("firstpass-kek-short-{}.bin", std::process::id()));
std::fs::write(&path, [0x11u8; 10]).expect("write temp KEK file");
let path_str = path.to_string_lossy().into_owned();
let result =
LocalKeyCustody::from_lookup(|k| (k == "FIRSTPASS_KEK_FILE").then(|| path_str.clone()));
let _ = std::fs::remove_file(&path);
assert!(matches!(result, Err(KeyCustodyError::BadKek(_))));
}
#[test]
fn from_env_unreadable_file_is_bad_kek() {
let result = LocalKeyCustody::from_lookup(|k| {
(k == "FIRSTPASS_KEK_FILE").then(|| "/no/such/firstpass/kek".to_owned())
});
assert!(matches!(result, Err(KeyCustodyError::BadKek(_))));
}
#[test]
fn debug_redacts_the_kek() {
let custody = LocalKeyCustody::new(KEK1);
let rendered = format!("{custody:?}");
assert!(
rendered.contains("redacted"),
"Debug must mark the key redacted"
);
assert!(
!rendered.contains("1111111111"),
"no raw key bytes in Debug"
);
assert!(
!rendered.contains(&hex::encode(KEK1)),
"no hex key in Debug"
);
}
}