Skip to main content

klieo_ops/
crypter.rs

1//! Pluggable at-rest encryption for klieo-ops adapters that want to
2//! crypto-shred per-tenant data (GDPR Art 17 erasure).
3//!
4//! `Crypter` is the abstract port; `TenantKeyedCrypter` is the software
5//! default impl using AES-256-GCM. `KeyProvider` resolves tenant → key
6//! bytes; `TenantKeyedCrypter` consults `klieo_ops::tenant::current_tenant()`
7//! at encrypt/decrypt time so the same Crypter instance can serve
8//! multiple tenants.
9//!
10//! ## Wire format (v2 / v0.6)
11//!
12//! ```text
13//! nonce[12] || aad_len_u16_le || aad[aad_len] || ct_and_tag
14//! ```
15//!
16//! AAD = `tenant_bytes || version_byte` — bound to the ciphertext via the
17//! AEAD tag so key-version mismatches and cross-tenant decryption attempts
18//! are detected at the tag-check stage (defence-in-depth).
19//!
20//! The leading `version_u8` present in v0.5 has been removed. The version
21//! byte still lives as the last byte of the AAD payload where it is covered
22//! by the AEAD authentication tag. v0.5 ciphertext does NOT decrypt under
23//! v0.6 — see `CHANGELOG.md` for the migration story.
24//!
25//! Closes spec § 5.4 OQ-5. Integration with `EpisodicMemory` is the
26//! adapter author's responsibility per spec § 1.9 — see the chapter
27//! at `docs/book/src/ops/encryption.md` for the recommended wrapping
28//! pattern.
29
30use crate::tenant::current_tenant;
31use crate::types::TenantId;
32use aes_gcm::aead::{Aead, KeyInit, OsRng};
33use aes_gcm::{AeadCore, Aes256Gcm, Nonce};
34use async_trait::async_trait;
35use std::collections::HashMap;
36use std::sync::Arc;
37use thiserror::Error;
38
39/// Errors raised by [`Crypter`] / [`KeyProvider`] impls.
40#[derive(Debug, Error)]
41#[non_exhaustive]
42pub enum CrypterError {
43    /// Tenant key not available (crypto-shred or unconfigured tenant).
44    #[error("key destroyed or not configured for tenant {tenant:?}")]
45    KeyDestroyed {
46        /// Tenant tag that was looked up.
47        tenant: Option<TenantId>,
48    },
49    /// Underlying AEAD operation failed (e.g. authentication tag mismatch).
50    #[error("aead failed: {0}")]
51    Aead(String),
52    /// AAD embedded in the ciphertext does not match the current context
53    /// (wrong tenant scope or stale key version). Defence-in-depth: the
54    /// AEAD tag already fails; this error gives a more actionable signal.
55    #[error("aad mismatch: stored aad does not match current context")]
56    AadMismatch,
57    /// Ciphertext header is too short to contain the framing fields.
58    #[error("ciphertext too short: {0} bytes")]
59    Truncated(usize),
60    /// Other internal error.
61    #[error("internal: {0}")]
62    Internal(String),
63}
64
65/// Key material returned by a versioned lookup.
66#[derive(Clone, Debug)]
67#[non_exhaustive]
68pub struct KeyMaterial {
69    /// 32-byte AES-256 key.
70    pub key: [u8; 32],
71    /// Monotonic version counter used in AAD binding. `0` for
72    /// non-versioned providers.
73    pub version: u8,
74}
75
76impl KeyMaterial {
77    /// Construct key material from raw bytes and a version counter.
78    #[must_use]
79    pub fn new(key: [u8; 32], version: u8) -> Self {
80        Self { key, version }
81    }
82}
83
84/// Resolve a tenant tag to an encryption key. Implementations include
85/// [`StaticKeyProvider`] (in-memory map for dev) and operator HSM-backed
86/// adapters.
87///
88/// Returning `None` from `lookup` is the crypto-shred signal: past
89/// ciphertext written under this tenant is now unreadable.
90pub trait KeyProvider: Send + Sync {
91    /// Resolve the tenant to a 32-byte AES-256 key. `None` means
92    /// "key destroyed or unconfigured" — [`TenantKeyedCrypter`] raises
93    /// [`CrypterError::KeyDestroyed`].
94    fn lookup(&self, tenant: &Option<TenantId>) -> Option<[u8; 32]>;
95
96    /// Versioned lookup. Returns key bytes **and** a monotonic version
97    /// counter that will be embedded in the ciphertext AAD.
98    ///
99    /// The default implementation delegates to [`lookup`](Self::lookup)
100    /// and reports version `0`. Version-aware providers override this
101    /// method to supply the current key version.
102    fn lookup_versioned(&self, tenant: &Option<TenantId>) -> Option<KeyMaterial> {
103        self.lookup(tenant)
104            .map(|key| KeyMaterial { key, version: 0 })
105    }
106}
107
108/// In-memory [`KeyProvider`] for dev / non-regulated deployments.
109#[non_exhaustive]
110pub struct StaticKeyProvider {
111    keys: HashMap<Option<TenantId>, [u8; 32]>,
112}
113
114impl StaticKeyProvider {
115    /// Build from a map. The `None` entry serves as the default key for
116    /// unscoped operations (e.g. global audit events with no tenant).
117    #[must_use]
118    pub fn from_map(keys: HashMap<Option<TenantId>, [u8; 32]>) -> Self {
119        Self { keys }
120    }
121}
122
123impl KeyProvider for StaticKeyProvider {
124    fn lookup(&self, tenant: &Option<TenantId>) -> Option<[u8; 32]> {
125        self.keys.get(tenant).copied()
126    }
127}
128
129/// Abstract at-rest encryption port. Implementations are pure (no I/O).
130///
131/// Ciphertext format is implementation-defined; callers must feed the
132/// output of `encrypt` directly into `decrypt` on the same impl.
133#[async_trait]
134pub trait Crypter: Send + Sync {
135    /// Encrypt `plaintext`. The returned bytes carry whatever framing
136    /// the impl needs for later decryption (e.g. nonce prefix).
137    async fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CrypterError>;
138
139    /// Decrypt `ciphertext` previously produced by [`Crypter::encrypt`].
140    async fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CrypterError>;
141}
142
143/// Tenant-keyed AES-256-GCM [`Crypter`]. Looks up the current tenant's
144/// key via [`KeyProvider`] and encrypts / decrypts under it.
145///
146/// ## Wire format (v0.6)
147///
148/// ```text
149/// nonce[12] || aad_len_u16_le || aad[aad_len] || ct_and_tag
150/// ```
151///
152/// AAD = `tenant_bytes || version_byte`. The AEAD tag covers the AAD so
153/// cross-tenant and wrong-key-version decryptions fail at tag verification.
154/// The version byte lives solely inside the AAD trailing byte; there is no
155/// redundant leading prefix byte (removed in v0.6).
156#[non_exhaustive]
157pub struct TenantKeyedCrypter {
158    keys: Arc<dyn KeyProvider>,
159}
160
161impl TenantKeyedCrypter {
162    /// Build with the provided [`KeyProvider`].
163    #[must_use]
164    pub fn new(keys: Arc<dyn KeyProvider>) -> Self {
165        Self { keys }
166    }
167
168    fn resolve_key(&self, tenant: &Option<TenantId>) -> Result<KeyMaterial, CrypterError> {
169        self.keys
170            .lookup_versioned(tenant)
171            .ok_or_else(|| CrypterError::KeyDestroyed {
172                tenant: tenant.clone(),
173            })
174    }
175
176    /// Canonical AAD: tenant bytes (empty for `None`) concatenated with the
177    /// single key-version byte.
178    fn build_aad(tenant: &Option<TenantId>, version: u8) -> Vec<u8> {
179        let mut aad = match tenant {
180            Some(t) => t.0.as_bytes().to_vec(),
181            None => Vec::new(),
182        };
183        aad.push(version);
184        aad
185    }
186}
187
188// Header byte counts used for framing (v0.6 wire format).
189// The v0.5 leading `version_u8` prefix has been removed; the version byte
190// still lives as the trailing byte of the AAD, covered by the AEAD tag.
191const NONCE_LEN: usize = 12;
192const AAD_LEN_FIELD: usize = 2; // u16 little-endian
193const MIN_HEADER: usize = NONCE_LEN + AAD_LEN_FIELD;
194
195#[async_trait]
196impl Crypter for TenantKeyedCrypter {
197    async fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CrypterError> {
198        let tenant = current_tenant();
199        let km = self.resolve_key(&tenant)?;
200        let cipher = Aes256Gcm::new((&km.key).into());
201        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
202        let aad = Self::build_aad(&tenant, km.version);
203        let aad_len = aad.len() as u16;
204
205        let body = cipher
206            .encrypt(
207                &nonce,
208                aes_gcm::aead::Payload {
209                    msg: plaintext,
210                    aad: &aad,
211                },
212            )
213            .map_err(|e| CrypterError::Aead(format!("encrypt: {e}")))?;
214
215        // v0.6 wire format: nonce || aad_len_u16_le || aad || ct||tag
216        // No leading version_u8 — version lives in AAD trailing byte only.
217        let mut out = Vec::with_capacity(NONCE_LEN + AAD_LEN_FIELD + aad.len() + body.len());
218        out.extend_from_slice(nonce.as_ref());
219        out.extend_from_slice(&aad_len.to_le_bytes());
220        out.extend_from_slice(&aad);
221        out.extend_from_slice(&body);
222        Ok(out)
223    }
224
225    async fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CrypterError> {
226        if ciphertext.len() < MIN_HEADER {
227            return Err(CrypterError::Truncated(ciphertext.len()));
228        }
229
230        // v0.6 wire format: nonce[12] || aad_len_u16_le || aad[n] || ct||tag
231        // No leading version_u8 — version lives in the AAD trailing byte only,
232        // covered by the AEAD authentication tag.
233        let mut nonce_bytes = [0u8; NONCE_LEN];
234        nonce_bytes.copy_from_slice(&ciphertext[..NONCE_LEN]);
235        let nonce = Nonce::from(nonce_bytes);
236        let aad_len =
237            u16::from_le_bytes([ciphertext[NONCE_LEN], ciphertext[NONCE_LEN + 1]]) as usize;
238
239        let aad_start = MIN_HEADER;
240        let body_start = aad_start + aad_len;
241        if ciphertext.len() < body_start {
242            return Err(CrypterError::Truncated(ciphertext.len()));
243        }
244
245        let stored_aad = &ciphertext[aad_start..body_start];
246        let body = &ciphertext[body_start..];
247
248        let tenant = current_tenant();
249        let km = self.resolve_key(&tenant)?;
250
251        // Verify the AAD stored in the ciphertext matches what the current
252        // context and key version would produce. Using `km.version` (the
253        // provider's current version) ensures that a key rotation — where
254        // `km.version` advances — surfaces as `AadMismatch` before we even
255        // attempt the AEAD tag check. Cross-tenant attempts also fail here
256        // because the tenant bytes in `stored_aad` won't match the current
257        // tenant's bytes in `expected_aad`.
258        let expected_aad = Self::build_aad(&tenant, km.version);
259        if stored_aad != expected_aad.as_slice() {
260            return Err(CrypterError::AadMismatch);
261        }
262
263        let cipher = Aes256Gcm::new((&km.key).into());
264        cipher
265            .decrypt(
266                &nonce,
267                aes_gcm::aead::Payload {
268                    msg: body,
269                    aad: stored_aad,
270                },
271            )
272            .map_err(|e| CrypterError::Aead(format!("decrypt: {e}")))
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::tenant::scope_tenant;
280
281    fn provider_with(seed: u8, tenant: &str) -> StaticKeyProvider {
282        let mut keys = HashMap::new();
283        keys.insert(Some(TenantId(tenant.to_string())), [seed; 32]);
284        StaticKeyProvider::from_map(keys)
285    }
286
287    fn two_tenant_provider(
288        seed_a: u8,
289        tenant_a: &str,
290        seed_b: u8,
291        tenant_b: &str,
292    ) -> StaticKeyProvider {
293        let mut keys = HashMap::new();
294        keys.insert(Some(TenantId(tenant_a.to_string())), [seed_a; 32]);
295        keys.insert(Some(TenantId(tenant_b.to_string())), [seed_b; 32]);
296        StaticKeyProvider::from_map(keys)
297    }
298
299    #[tokio::test]
300    async fn encrypt_then_decrypt_round_trip() {
301        let crypter = TenantKeyedCrypter::new(Arc::new(provider_with(1, "BL_auto")));
302        let plaintext = b"sensitive";
303        let result = scope_tenant(Some(TenantId("BL_auto".into())), async {
304            let ct = crypter.encrypt(plaintext).await.expect("encrypt");
305            crypter.decrypt(&ct).await.expect("decrypt")
306        })
307        .await;
308        assert_eq!(result, plaintext);
309    }
310
311    #[tokio::test]
312    async fn missing_tenant_key_returns_key_destroyed() {
313        let crypter =
314            TenantKeyedCrypter::new(Arc::new(StaticKeyProvider::from_map(HashMap::new())));
315        let err = scope_tenant(Some(TenantId("BL_motor".into())), async {
316            crypter.encrypt(b"x").await.unwrap_err()
317        })
318        .await;
319        assert!(matches!(err, CrypterError::KeyDestroyed { .. }));
320    }
321
322    /// Cross-tenant decrypt must fail with AadMismatch even when both tenants
323    /// happen to share the same key bytes. The AAD binding prevents the
324    /// ciphertext from being interpreted under a different tenant context.
325    #[tokio::test]
326    async fn cross_tenant_decrypt_fails_with_aad_mismatch() {
327        // Both tenants use the same underlying key bytes (worst-case collision).
328        let crypter = TenantKeyedCrypter::new(Arc::new(two_tenant_provider(
329            42, "tenant_a", 42, "tenant_b",
330        )));
331        let plaintext = b"cross-tenant-secret";
332
333        // Encrypt under tenant_a.
334        let ciphertext = scope_tenant(Some(TenantId("tenant_a".into())), async {
335            crypter.encrypt(plaintext).await.expect("encrypt")
336        })
337        .await;
338
339        // Attempt to decrypt under tenant_b — must fail.
340        let err = scope_tenant(Some(TenantId("tenant_b".into())), async {
341            crypter.decrypt(&ciphertext).await.unwrap_err()
342        })
343        .await;
344
345        assert!(
346            matches!(err, CrypterError::AadMismatch),
347            "expected AadMismatch, got {err:?}"
348        );
349    }
350
351    /// A versioned key provider stub that reports version 1 for the active key.
352    struct VersionedProvider {
353        tenant: TenantId,
354        key: [u8; 32],
355        current_version: u8,
356    }
357
358    impl KeyProvider for VersionedProvider {
359        fn lookup(&self, tenant: &Option<TenantId>) -> Option<[u8; 32]> {
360            if tenant.as_ref() == Some(&self.tenant) {
361                Some(self.key)
362            } else {
363                None
364            }
365        }
366
367        fn lookup_versioned(&self, tenant: &Option<TenantId>) -> Option<KeyMaterial> {
368            self.lookup(tenant).map(|key| KeyMaterial {
369                key,
370                version: self.current_version,
371            })
372        }
373    }
374
375    /// Pin the v0.6 wire-format byte length so format regressions fail loudly.
376    ///
377    /// v0.6 format: `nonce[12] || aad_len_u16_le[2] || aad[aad_len] || ct||tag[16]`
378    /// No leading version_u8 prefix — that was removed in v0.6.
379    #[tokio::test]
380    async fn ciphertext_length_matches_v0_6_format() {
381        let tenant = TenantId("BL_format".into());
382        let crypter = TenantKeyedCrypter::new(Arc::new(provider_with(3, "BL_format")));
383        let plaintext = b"hello";
384
385        let ciphertext = scope_tenant(Some(tenant.clone()), async {
386            crypter.encrypt(plaintext).await.expect("encrypt")
387        })
388        .await;
389
390        // AAD = tenant_bytes || version_byte
391        let aad_len = tenant.0.len() + 1; // +1 for version byte
392        let expected_len = NONCE_LEN + AAD_LEN_FIELD + aad_len + plaintext.len() + 16;
393        assert_eq!(
394            ciphertext.len(),
395            expected_len,
396            "v0.6 wire format length mismatch: nonce({NONCE_LEN}) + aad_len_field({AAD_LEN_FIELD}) \
397             + aad({aad_len}) + plaintext({}) + tag(16) = {expected_len}",
398            plaintext.len()
399        );
400    }
401
402    /// Rotating to a new key version makes old ciphertexts unreadable.
403    /// The AEAD tag or AAD mismatch catches the stale version cleanly.
404    #[tokio::test]
405    async fn old_key_version_decrypt_fails_clean() {
406        let tenant = TenantId("tenant_rotation".into());
407
408        // Encrypt with version 0 key.
409        let provider_v0 = Arc::new(VersionedProvider {
410            tenant: tenant.clone(),
411            key: [7u8; 32],
412            current_version: 0,
413        });
414        let crypter_v0 = TenantKeyedCrypter::new(provider_v0);
415        let ciphertext = scope_tenant(Some(tenant.clone()), async {
416            crypter_v0.encrypt(b"old-secret").await.expect("encrypt v0")
417        })
418        .await;
419
420        // Attempt decrypt with version 1 key (same key bytes, different version).
421        let provider_v1 = Arc::new(VersionedProvider {
422            tenant: tenant.clone(),
423            key: [7u8; 32],
424            current_version: 1,
425        });
426        let crypter_v1 = TenantKeyedCrypter::new(provider_v1);
427        let err = scope_tenant(Some(tenant.clone()), async {
428            crypter_v1.decrypt(&ciphertext).await.unwrap_err()
429        })
430        .await;
431
432        assert!(
433            matches!(err, CrypterError::AadMismatch | CrypterError::Aead(_)),
434            "expected AadMismatch or Aead on version mismatch, got {err:?}"
435        );
436    }
437}