Skip to main content

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}