firstpass_proxy/key_custody.rs
1//! Per-tenant envelope key custody (ADR 0004 §D5).
2//!
3//! **EXPERIMENTAL / pre-external-review (ADR 0004 §D7).** This is a self-contained crypto
4//! building block, deliberately **not wired to any request flow or stored-key feature yet**.
5//! The default hosted plane is pure per-request BYOK — nothing tenant-supplied is stored — so
6//! there is no consumer for stored ciphertext today. This module exists so that when a
7//! stored-key feature lands, the vetted primitive is already here and already reviewed. It has
8//! not yet had the external crypto review ADR 0004 §D7 requires; do not treat it as blessed.
9//!
10//! # What it does
11//!
12//! [`KeyCustody`] is the seam ADR 0004 §D5 names: encrypt/decrypt blobs under a per-tenant
13//! envelope so a cloud-KMS implementation can slot in later behind the same trait. The local
14//! tier — [`LocalKeyCustody`] — is implemented here with real AES-256-GCM (the vetted
15//! [`aes_gcm`] / RustCrypto crate), a single 32-byte key-encryption key (KEK) from config, and
16//! the tenant id bound as AEAD associated data so a blob sealed for tenant A cannot be opened
17//! under tenant B.
18//!
19//! # Construction (the exact bytes on disk)
20//!
21//! - **Cipher:** AES-256-GCM (`Aes256Gcm`), 256-bit KEK, 128-bit auth tag.
22//! - **Nonce:** fresh random 96 bits per [`encrypt`](KeyCustody::encrypt) call, drawn from the
23//! OS ambient CSPRNG (`getrandom`'s `SysRng`). A nonce is **never** reused with the KEK; a
24//! 96-bit random nonce per message is the standard GCM construction.
25//! - **AAD:** the tenant id (`tenant.as_bytes()`) is passed as associated data on both encrypt
26//! and decrypt. It is authenticated but not encrypted; a mismatch fails the auth tag.
27//! - **Blob layout:** `nonce (12 bytes) ‖ ciphertext ‖ tag (16 bytes)`. Self-describing: the
28//! nonce is a fixed-width prefix, and `ciphertext ‖ tag` is exactly what `aes-gcm` returns.
29//! The blob is safe to store at rest; it reveals only the plaintext length.
30//!
31//! # KEK loading
32//!
33//! [`LocalKeyCustody::from_env`] reads, in order of precedence:
34//! - `FIRSTPASS_KEK` — the KEK **hex-encoded** (64 lowercase/uppercase hex chars = 32 bytes).
35//! Surrounding whitespace is trimmed. Hex (not base64) is used for parity with the rest of
36//! the crate, which already depends on `hex` (e.g. audit-trace digests).
37//! - `FIRSTPASS_KEK_FILE` — path to a file whose contents are exactly 32 **raw** bytes.
38//!
39//! A missing, short, over-long, or malformed KEK is a hard error ([`KeyCustodyError`]); the
40//! loader is **fail-closed** and never falls back to a zero/default key.
41
42// `Nonce<A>` here is the AeadCore-parameterized alias (`aes_gcm::aead::Nonce`), i.e. a nonce
43// sized for the given AEAD — NOT `aes_gcm::Nonce<NonceSize>`, which is parameterized by the
44// nonce width directly.
45use aes_gcm::aead::{Aead, Generate, Nonce, Payload};
46use aes_gcm::{Aes256Gcm, KeyInit};
47
48/// Size of the KEK in bytes (AES-256 → 32 bytes).
49const KEK_LEN: usize = 32;
50/// AES-GCM nonce size in bytes (96 bits — the standard GCM nonce width).
51const NONCE_LEN: usize = 12;
52
53/// Encrypt/decrypt opaque blobs under a per-tenant envelope.
54///
55/// This is the trust seam ADR 0004 §D5 names. The local tier ([`LocalKeyCustody`]) is
56/// implemented here; a cloud-KMS tier can implement the same trait later without touching
57/// callers. `tenant` is authenticated (bound as AEAD associated data), so a blob is only
58/// decryptable under the same tenant it was encrypted for.
59pub trait KeyCustody {
60 /// Seal `plaintext` for `tenant`, returning an opaque `nonce ‖ ciphertext ‖ tag` blob that
61 /// is safe to store at rest.
62 ///
63 /// # Errors
64 /// [`KeyCustodyError::Encrypt`] if the RNG or the AEAD layer fails. The error is opaque and
65 /// carries no key material.
66 fn encrypt(&self, tenant: &str, plaintext: &[u8]) -> Result<Vec<u8>, KeyCustodyError>;
67
68 /// Open a blob previously produced by [`encrypt`](KeyCustody::encrypt) for the **same**
69 /// `tenant`.
70 ///
71 /// # Errors
72 /// [`KeyCustodyError::Decrypt`] on any failure — tamper, wrong KEK, wrong tenant, or a
73 /// truncated blob. The failure mode is **indistinguishable** on purpose (no oracle): all of
74 /// them surface as the same opaque error, and never a partial/garbage plaintext.
75 fn decrypt(&self, tenant: &str, blob: &[u8]) -> Result<Vec<u8>, KeyCustodyError>;
76}
77
78/// Errors from key custody. No variant ever carries key material or plaintext.
79#[derive(Debug, thiserror::Error)]
80pub enum KeyCustodyError {
81 /// No KEK is configured (`FIRSTPASS_KEK` / `FIRSTPASS_KEK_FILE` both unset).
82 #[error("no KEK configured: set FIRSTPASS_KEK (hex-encoded 32 bytes) or FIRSTPASS_KEK_FILE")]
83 MissingKek,
84
85 /// A KEK was supplied but is unusable (wrong length, bad hex, unreadable file). The reason
86 /// describes the shape of the failure only — never the key bytes.
87 #[error("invalid KEK: {0}")]
88 BadKek(String),
89
90 /// Encryption failed (RNG or AEAD layer). Opaque by design.
91 #[error("encryption failed")]
92 Encrypt,
93
94 /// Decryption/authentication failed — tamper, wrong KEK, wrong tenant, or truncated blob.
95 /// Opaque by design: callers cannot tell which, so the type is not a decryption oracle.
96 #[error("decryption failed")]
97 Decrypt,
98}
99
100/// Local-tier [`KeyCustody`]: real AES-256-GCM under a single process-held KEK.
101///
102/// The KEK is baked into an [`Aes256Gcm`] key schedule at construction and the raw bytes are
103/// not retained. [`std::fmt::Debug`] is hand-written to redact it regardless.
104pub struct LocalKeyCustody {
105 cipher: Aes256Gcm,
106}
107
108impl LocalKeyCustody {
109 /// Build a custody instance from a raw 32-byte KEK.
110 ///
111 /// Infallible: a `[u8; KEK_LEN]` is always a valid AES-256 key. The key schedule is derived
112 /// once here, so per-message [`encrypt`](KeyCustody::encrypt)/[`decrypt`](KeyCustody::decrypt)
113 /// calls do not re-expand it.
114 #[must_use]
115 pub fn new(kek: [u8; KEK_LEN]) -> Self {
116 // `kek.into()` produces the `Key<Aes256Gcm>` (a 32-byte `Array`); a `[u8; 32]` is always
117 // the right length, so `new` (not `new_from_slice`) is the correct infallible path.
118 Self {
119 cipher: Aes256Gcm::new(&kek.into()),
120 }
121 }
122
123 /// Load the KEK from the environment. Precedence: `FIRSTPASS_KEK` (hex), then
124 /// `FIRSTPASS_KEK_FILE` (32 raw bytes).
125 ///
126 /// # Errors
127 /// - [`KeyCustodyError::MissingKek`] if neither is set.
128 /// - [`KeyCustodyError::BadKek`] if the value is malformed, the wrong length, or the file is
129 /// unreadable.
130 pub fn from_env() -> Result<Self, KeyCustodyError> {
131 Self::from_lookup(|k| std::env::var(k).ok())
132 }
133
134 /// [`from_env`](Self::from_env) with an injectable variable lookup — the testable seam
135 /// (mirrors [`crate::config::ProxyConfig::from_lookup`]), so KEK-loading tests need no
136 /// process-global env mutation.
137 ///
138 /// # Errors
139 /// Same as [`from_env`](Self::from_env).
140 pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self, KeyCustodyError> {
141 if let Some(hex_kek) = lookup("FIRSTPASS_KEK") {
142 let bytes = hex::decode(hex_kek.trim()).map_err(|e| {
143 KeyCustodyError::BadKek(format!("FIRSTPASS_KEK is not valid hex: {e}"))
144 })?;
145 return Self::from_bytes(&bytes);
146 }
147 if let Some(path) = lookup("FIRSTPASS_KEK_FILE") {
148 let bytes = std::fs::read(&path).map_err(|e| {
149 KeyCustodyError::BadKek(format!("cannot read FIRSTPASS_KEK_FILE ({path}): {e}"))
150 })?;
151 return Self::from_bytes(&bytes);
152 }
153 Err(KeyCustodyError::MissingKek)
154 }
155
156 /// Validate an exact-length KEK and build the cipher. Rejects any length other than
157 /// [`KEK_LEN`]; reports only the length, never the bytes.
158 fn from_bytes(bytes: &[u8]) -> Result<Self, KeyCustodyError> {
159 let kek: [u8; KEK_LEN] = bytes.try_into().map_err(|_| {
160 KeyCustodyError::BadKek(format!(
161 "KEK must be exactly {KEK_LEN} bytes, got {}",
162 bytes.len()
163 ))
164 })?;
165 Ok(Self::new(kek))
166 }
167}
168
169impl std::fmt::Debug for LocalKeyCustody {
170 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171 f.debug_struct("LocalKeyCustody")
172 .field("cipher", &"<redacted AES-256-GCM key>")
173 .finish()
174 }
175}
176
177impl KeyCustody for LocalKeyCustody {
178 fn encrypt(&self, tenant: &str, plaintext: &[u8]) -> Result<Vec<u8>, KeyCustodyError> {
179 // Fresh random 96-bit nonce per call from the OS CSPRNG. Fail closed if the RNG faults:
180 // a non-random nonce could collide and break GCM's security, so we never proceed.
181 let nonce = Nonce::<Aes256Gcm>::try_generate().map_err(|_| KeyCustodyError::Encrypt)?;
182
183 // Bind the tenant as AAD: authenticated, not encrypted. A wrong tenant on decrypt fails
184 // the auth tag.
185 let ciphertext = self
186 .cipher
187 .encrypt(
188 &nonce,
189 Payload {
190 msg: plaintext,
191 aad: tenant.as_bytes(),
192 },
193 )
194 .map_err(|_| KeyCustodyError::Encrypt)?;
195
196 let mut blob = Vec::with_capacity(NONCE_LEN + ciphertext.len());
197 blob.extend_from_slice(&nonce);
198 blob.extend_from_slice(&ciphertext);
199 Ok(blob)
200 }
201
202 fn decrypt(&self, tenant: &str, blob: &[u8]) -> Result<Vec<u8>, KeyCustodyError> {
203 // A well-formed blob is nonce ‖ (ciphertext ‖ 16-byte tag). Anything shorter than the
204 // nonce plus a tag cannot be authentic — treat as an ordinary decryption failure (no
205 // distinct "too short" oracle).
206 if blob.len() < NONCE_LEN {
207 return Err(KeyCustodyError::Decrypt);
208 }
209 let (nonce_bytes, ciphertext) = blob.split_at(NONCE_LEN);
210 let nonce =
211 <&Nonce<Aes256Gcm>>::try_from(nonce_bytes).map_err(|_| KeyCustodyError::Decrypt)?;
212
213 self.cipher
214 .decrypt(
215 nonce,
216 Payload {
217 msg: ciphertext,
218 aad: tenant.as_bytes(),
219 },
220 )
221 .map_err(|_| KeyCustodyError::Decrypt)
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 const KEK1: [u8; KEK_LEN] = [0x11; KEK_LEN];
230 const KEK2: [u8; KEK_LEN] = [0x22; KEK_LEN];
231
232 #[test]
233 fn round_trip_various_messages() {
234 let custody = LocalKeyCustody::new(KEK1);
235 let messages: &[&[u8]] = &[
236 b"", // empty
237 b"x", // single byte
238 b"hello tenant secrets", // typical
239 &[0xABu8; 64 * 1024], // large (64 KiB)
240 ];
241 for msg in messages {
242 let blob = custody.encrypt("acme", msg).expect("encrypt");
243 let out = custody.decrypt("acme", &blob).expect("decrypt");
244 assert_eq!(&out, msg, "round-trip must recover the exact plaintext");
245 }
246 }
247
248 #[test]
249 fn blob_layout_is_nonce_then_ciphertext_and_tag() {
250 let custody = LocalKeyCustody::new(KEK1);
251 let msg = b"layout check";
252 let blob = custody.encrypt("acme", msg).expect("encrypt");
253 // nonce (12) + ciphertext (== plaintext len for a stream cipher) + tag (16).
254 assert_eq!(blob.len(), NONCE_LEN + msg.len() + 16);
255 }
256
257 #[test]
258 fn tenant_binding_blocks_cross_tenant_decrypt() {
259 // The per-tenant isolation guarantee: a blob sealed for tenant-a must NOT open under
260 // tenant-b, because the tenant is bound as AEAD associated data.
261 let custody = LocalKeyCustody::new(KEK1);
262 let blob = custody.encrypt("tenant-a", b"a's data").expect("encrypt");
263 let result = custody.decrypt("tenant-b", &blob);
264 assert!(
265 matches!(result, Err(KeyCustodyError::Decrypt)),
266 "cross-tenant decrypt must fail the auth tag"
267 );
268 }
269
270 #[test]
271 fn tamper_of_any_region_is_detected() {
272 let custody = LocalKeyCustody::new(KEK1);
273 let msg = b"integrity matters";
274 let blob = custody.encrypt("acme", msg).expect("encrypt");
275 // Flip one byte in each region: nonce, ciphertext, and tag.
276 for idx in [0usize, NONCE_LEN + 1, blob.len() - 1] {
277 let mut tampered = blob.clone();
278 tampered[idx] ^= 0x01;
279 let result = custody.decrypt("acme", &tampered);
280 assert!(
281 matches!(result, Err(KeyCustodyError::Decrypt)),
282 "flipping byte {idx} must be detected"
283 );
284 }
285 }
286
287 #[test]
288 fn wrong_kek_cannot_decrypt() {
289 let sealer = LocalKeyCustody::new(KEK1);
290 let opener = LocalKeyCustody::new(KEK2);
291 let blob = sealer.encrypt("acme", b"secret").expect("encrypt");
292 let result = opener.decrypt("acme", &blob);
293 assert!(matches!(result, Err(KeyCustodyError::Decrypt)));
294 }
295
296 #[test]
297 fn nonce_is_fresh_per_call() {
298 // Two encryptions of the same plaintext under the same key/tenant must differ, proving a
299 // fresh nonce each call (nonce reuse would be catastrophic for GCM).
300 let custody = LocalKeyCustody::new(KEK1);
301 let a = custody.encrypt("acme", b"same message").expect("encrypt");
302 let b = custody.encrypt("acme", b"same message").expect("encrypt");
303 assert_ne!(a, b, "distinct nonces must yield distinct blobs");
304 assert_ne!(&a[..NONCE_LEN], &b[..NONCE_LEN], "nonces must differ");
305 }
306
307 #[test]
308 fn from_env_hex_key_loads_and_works() {
309 let hex_kek = hex::encode(KEK1);
310 let custody = LocalKeyCustody::from_lookup(|k| match k {
311 "FIRSTPASS_KEK" => Some(hex_kek.clone()),
312 _ => None,
313 })
314 .expect("valid hex KEK must load");
315 let blob = custody.encrypt("acme", b"env-loaded").expect("encrypt");
316 assert_eq!(
317 custody.decrypt("acme", &blob).expect("decrypt"),
318 b"env-loaded"
319 );
320 }
321
322 #[test]
323 fn from_env_trims_surrounding_whitespace() {
324 let hex_kek = format!(" {}\n", hex::encode(KEK1));
325 let custody =
326 LocalKeyCustody::from_lookup(|k| (k == "FIRSTPASS_KEK").then(|| hex_kek.clone()));
327 assert!(custody.is_ok(), "trimmed hex KEK must load");
328 }
329
330 #[test]
331 fn from_env_missing_is_missing_kek() {
332 let result = LocalKeyCustody::from_lookup(|_| None);
333 assert!(matches!(result, Err(KeyCustodyError::MissingKek)));
334 }
335
336 #[test]
337 fn from_env_short_key_is_bad_kek() {
338 let short = hex::encode([0x11u8; 16]); // 16 bytes, not 32
339 let result =
340 LocalKeyCustody::from_lookup(|k| (k == "FIRSTPASS_KEK").then(|| short.clone()));
341 assert!(matches!(result, Err(KeyCustodyError::BadKek(_))));
342 }
343
344 #[test]
345 fn from_env_malformed_hex_is_bad_kek() {
346 let result =
347 LocalKeyCustody::from_lookup(|k| (k == "FIRSTPASS_KEK").then(|| "nothex!!".to_owned()));
348 assert!(matches!(result, Err(KeyCustodyError::BadKek(_))));
349 }
350
351 #[test]
352 fn from_env_file_with_32_raw_bytes_loads() {
353 let dir = std::env::temp_dir();
354 let path = dir.join(format!("firstpass-kek-{}.bin", std::process::id()));
355 std::fs::write(&path, KEK1).expect("write temp KEK file");
356 let path_str = path.to_string_lossy().into_owned();
357 let custody =
358 LocalKeyCustody::from_lookup(|k| (k == "FIRSTPASS_KEK_FILE").then(|| path_str.clone()));
359 let _ = std::fs::remove_file(&path);
360 let custody = custody.expect("32-byte key file must load");
361 let blob = custody.encrypt("acme", b"file-loaded").expect("encrypt");
362 assert_eq!(
363 custody.decrypt("acme", &blob).expect("decrypt"),
364 b"file-loaded"
365 );
366 }
367
368 #[test]
369 fn from_env_file_wrong_length_is_bad_kek() {
370 let dir = std::env::temp_dir();
371 let path = dir.join(format!("firstpass-kek-short-{}.bin", std::process::id()));
372 std::fs::write(&path, [0x11u8; 10]).expect("write temp KEK file");
373 let path_str = path.to_string_lossy().into_owned();
374 let result =
375 LocalKeyCustody::from_lookup(|k| (k == "FIRSTPASS_KEK_FILE").then(|| path_str.clone()));
376 let _ = std::fs::remove_file(&path);
377 assert!(matches!(result, Err(KeyCustodyError::BadKek(_))));
378 }
379
380 #[test]
381 fn from_env_unreadable_file_is_bad_kek() {
382 let result = LocalKeyCustody::from_lookup(|k| {
383 (k == "FIRSTPASS_KEK_FILE").then(|| "/no/such/firstpass/kek".to_owned())
384 });
385 assert!(matches!(result, Err(KeyCustodyError::BadKek(_))));
386 }
387
388 #[test]
389 fn debug_redacts_the_kek() {
390 let custody = LocalKeyCustody::new(KEK1);
391 let rendered = format!("{custody:?}");
392 assert!(
393 rendered.contains("redacted"),
394 "Debug must mark the key redacted"
395 );
396 // The raw KEK (all 0x11) must never appear, in any common encoding.
397 assert!(
398 !rendered.contains("1111111111"),
399 "no raw key bytes in Debug"
400 );
401 assert!(
402 !rendered.contains(&hex::encode(KEK1)),
403 "no hex key in Debug"
404 );
405 }
406}