klieo-ops 3.4.0

Operational layer above klieo-core: supervisor, governor, gates, escalation, worklog, handoff.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
//! Pluggable at-rest encryption for klieo-ops adapters that want to
//! crypto-shred per-tenant data (GDPR Art 17 erasure).
//!
//! `Crypter` is the abstract port; `TenantKeyedCrypter` is the software
//! default impl using AES-256-GCM. `KeyProvider` resolves tenant → key
//! bytes; `TenantKeyedCrypter` consults `klieo_ops::tenant::current_tenant()`
//! at encrypt/decrypt time so the same Crypter instance can serve
//! multiple tenants.
//!
//! ## Wire format (v2 / v0.6)
//!
//! ```text
//! nonce[12] || aad_len_u16_le || aad[aad_len] || ct_and_tag
//! ```
//!
//! AAD = `tenant_bytes || version_byte` — bound to the ciphertext via the
//! AEAD tag so key-version mismatches and cross-tenant decryption attempts
//! are detected at the tag-check stage (defence-in-depth).
//!
//! The leading `version_u8` present in v0.5 has been removed. The version
//! byte still lives as the last byte of the AAD payload where it is covered
//! by the AEAD authentication tag. v0.5 ciphertext does NOT decrypt under
//! v0.6 — see `CHANGELOG.md` for the migration story.
//!
//! Closes spec § 5.4 OQ-5. Integration with `EpisodicMemory` is the
//! adapter author's responsibility per spec § 1.9 — see the chapter
//! at `docs/book/src/ops/encryption.md` for the recommended wrapping
//! pattern.

use crate::tenant::current_tenant;
use crate::types::TenantId;
use aes_gcm::aead::{Aead, KeyInit, OsRng};
use aes_gcm::{AeadCore, Aes256Gcm, Nonce};
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Arc;
use thiserror::Error;

/// Errors raised by [`Crypter`] / [`KeyProvider`] impls.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum CrypterError {
    /// Tenant key not available (crypto-shred or unconfigured tenant).
    #[error("key destroyed or not configured for tenant {tenant:?}")]
    KeyDestroyed {
        /// Tenant tag that was looked up.
        tenant: Option<TenantId>,
    },
    /// Underlying AEAD operation failed (e.g. authentication tag mismatch).
    #[error("aead failed: {0}")]
    Aead(String),
    /// AAD embedded in the ciphertext does not match the current context
    /// (wrong tenant scope or stale key version). Defence-in-depth: the
    /// AEAD tag already fails; this error gives a more actionable signal.
    #[error("aad mismatch: stored aad does not match current context")]
    AadMismatch,
    /// Ciphertext header is too short to contain the framing fields.
    #[error("ciphertext too short: {0} bytes")]
    Truncated(usize),
    /// Other internal error.
    #[error("internal: {0}")]
    Internal(String),
}

/// Key material returned by a versioned lookup.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct KeyMaterial {
    /// 32-byte AES-256 key.
    pub key: [u8; 32],
    /// Monotonic version counter used in AAD binding. `0` for
    /// non-versioned providers.
    pub version: u8,
}

impl KeyMaterial {
    /// Construct key material from raw bytes and a version counter.
    #[must_use]
    pub fn new(key: [u8; 32], version: u8) -> Self {
        Self { key, version }
    }
}

/// Resolve a tenant tag to an encryption key. Implementations include
/// [`StaticKeyProvider`] (in-memory map for dev) and operator HSM-backed
/// adapters.
///
/// Returning `None` from `lookup` is the crypto-shred signal: past
/// ciphertext written under this tenant is now unreadable.
pub trait KeyProvider: Send + Sync {
    /// Resolve the tenant to a 32-byte AES-256 key. `None` means
    /// "key destroyed or unconfigured" — [`TenantKeyedCrypter`] raises
    /// [`CrypterError::KeyDestroyed`].
    fn lookup(&self, tenant: &Option<TenantId>) -> Option<[u8; 32]>;

    /// Versioned lookup. Returns key bytes **and** a monotonic version
    /// counter that will be embedded in the ciphertext AAD.
    ///
    /// The default implementation delegates to [`lookup`](Self::lookup)
    /// and reports version `0`. Version-aware providers override this
    /// method to supply the current key version.
    fn lookup_versioned(&self, tenant: &Option<TenantId>) -> Option<KeyMaterial> {
        self.lookup(tenant)
            .map(|key| KeyMaterial { key, version: 0 })
    }
}

/// In-memory [`KeyProvider`] for dev / non-regulated deployments.
#[non_exhaustive]
pub struct StaticKeyProvider {
    keys: HashMap<Option<TenantId>, [u8; 32]>,
}

impl StaticKeyProvider {
    /// Build from a map. The `None` entry serves as the default key for
    /// unscoped operations (e.g. global audit events with no tenant).
    #[must_use]
    pub fn from_map(keys: HashMap<Option<TenantId>, [u8; 32]>) -> Self {
        Self { keys }
    }
}

impl KeyProvider for StaticKeyProvider {
    fn lookup(&self, tenant: &Option<TenantId>) -> Option<[u8; 32]> {
        self.keys.get(tenant).copied()
    }
}

/// Abstract at-rest encryption port. Implementations are pure (no I/O).
///
/// Ciphertext format is implementation-defined; callers must feed the
/// output of `encrypt` directly into `decrypt` on the same impl.
#[async_trait]
pub trait Crypter: Send + Sync {
    /// Encrypt `plaintext`. The returned bytes carry whatever framing
    /// the impl needs for later decryption (e.g. nonce prefix).
    async fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CrypterError>;

    /// Decrypt `ciphertext` previously produced by [`Crypter::encrypt`].
    async fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CrypterError>;
}

/// Tenant-keyed AES-256-GCM [`Crypter`]. Looks up the current tenant's
/// key via [`KeyProvider`] and encrypts / decrypts under it.
///
/// ## Wire format (v0.6)
///
/// ```text
/// nonce[12] || aad_len_u16_le || aad[aad_len] || ct_and_tag
/// ```
///
/// AAD = `tenant_bytes || version_byte`. The AEAD tag covers the AAD so
/// cross-tenant and wrong-key-version decryptions fail at tag verification.
/// The version byte lives solely inside the AAD trailing byte; there is no
/// redundant leading prefix byte (removed in v0.6).
#[non_exhaustive]
pub struct TenantKeyedCrypter {
    keys: Arc<dyn KeyProvider>,
}

impl TenantKeyedCrypter {
    /// Build with the provided [`KeyProvider`].
    #[must_use]
    pub fn new(keys: Arc<dyn KeyProvider>) -> Self {
        Self { keys }
    }

    fn resolve_key(&self, tenant: &Option<TenantId>) -> Result<KeyMaterial, CrypterError> {
        self.keys
            .lookup_versioned(tenant)
            .ok_or_else(|| CrypterError::KeyDestroyed {
                tenant: tenant.clone(),
            })
    }

    /// Canonical AAD: tenant bytes (empty for `None`) concatenated with the
    /// single key-version byte.
    fn build_aad(tenant: &Option<TenantId>, version: u8) -> Vec<u8> {
        let mut aad = match tenant {
            Some(t) => t.0.as_bytes().to_vec(),
            None => Vec::new(),
        };
        aad.push(version);
        aad
    }
}

// Header byte counts used for framing (v0.6 wire format).
// The v0.5 leading `version_u8` prefix has been removed; the version byte
// still lives as the trailing byte of the AAD, covered by the AEAD tag.
const NONCE_LEN: usize = 12;
const AAD_LEN_FIELD: usize = 2; // u16 little-endian
const MIN_HEADER: usize = NONCE_LEN + AAD_LEN_FIELD;

#[async_trait]
impl Crypter for TenantKeyedCrypter {
    async fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CrypterError> {
        let tenant = current_tenant();
        let km = self.resolve_key(&tenant)?;
        let cipher = Aes256Gcm::new((&km.key).into());
        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
        let aad = Self::build_aad(&tenant, km.version);
        let aad_len = aad.len() as u16;

        let body = cipher
            .encrypt(
                &nonce,
                aes_gcm::aead::Payload {
                    msg: plaintext,
                    aad: &aad,
                },
            )
            .map_err(|e| CrypterError::Aead(format!("encrypt: {e}")))?;

        // v0.6 wire format: nonce || aad_len_u16_le || aad || ct||tag
        // No leading version_u8 — version lives in AAD trailing byte only.
        let mut out = Vec::with_capacity(NONCE_LEN + AAD_LEN_FIELD + aad.len() + body.len());
        out.extend_from_slice(nonce.as_ref());
        out.extend_from_slice(&aad_len.to_le_bytes());
        out.extend_from_slice(&aad);
        out.extend_from_slice(&body);
        Ok(out)
    }

    async fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CrypterError> {
        if ciphertext.len() < MIN_HEADER {
            return Err(CrypterError::Truncated(ciphertext.len()));
        }

        // v0.6 wire format: nonce[12] || aad_len_u16_le || aad[n] || ct||tag
        // No leading version_u8 — version lives in the AAD trailing byte only,
        // covered by the AEAD authentication tag.
        let mut nonce_bytes = [0u8; NONCE_LEN];
        nonce_bytes.copy_from_slice(&ciphertext[..NONCE_LEN]);
        let nonce = Nonce::from(nonce_bytes);
        let aad_len =
            u16::from_le_bytes([ciphertext[NONCE_LEN], ciphertext[NONCE_LEN + 1]]) as usize;

        let aad_start = MIN_HEADER;
        let body_start = aad_start + aad_len;
        if ciphertext.len() < body_start {
            return Err(CrypterError::Truncated(ciphertext.len()));
        }

        let stored_aad = &ciphertext[aad_start..body_start];
        let body = &ciphertext[body_start..];

        let tenant = current_tenant();
        let km = self.resolve_key(&tenant)?;

        // Verify the AAD stored in the ciphertext matches what the current
        // context and key version would produce. Using `km.version` (the
        // provider's current version) ensures that a key rotation — where
        // `km.version` advances — surfaces as `AadMismatch` before we even
        // attempt the AEAD tag check. Cross-tenant attempts also fail here
        // because the tenant bytes in `stored_aad` won't match the current
        // tenant's bytes in `expected_aad`.
        let expected_aad = Self::build_aad(&tenant, km.version);
        if stored_aad != expected_aad.as_slice() {
            return Err(CrypterError::AadMismatch);
        }

        let cipher = Aes256Gcm::new((&km.key).into());
        cipher
            .decrypt(
                &nonce,
                aes_gcm::aead::Payload {
                    msg: body,
                    aad: stored_aad,
                },
            )
            .map_err(|e| CrypterError::Aead(format!("decrypt: {e}")))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tenant::scope_tenant;

    fn provider_with(seed: u8, tenant: &str) -> StaticKeyProvider {
        let mut keys = HashMap::new();
        keys.insert(Some(TenantId(tenant.to_string())), [seed; 32]);
        StaticKeyProvider::from_map(keys)
    }

    fn two_tenant_provider(
        seed_a: u8,
        tenant_a: &str,
        seed_b: u8,
        tenant_b: &str,
    ) -> StaticKeyProvider {
        let mut keys = HashMap::new();
        keys.insert(Some(TenantId(tenant_a.to_string())), [seed_a; 32]);
        keys.insert(Some(TenantId(tenant_b.to_string())), [seed_b; 32]);
        StaticKeyProvider::from_map(keys)
    }

    #[tokio::test]
    async fn encrypt_then_decrypt_round_trip() {
        let crypter = TenantKeyedCrypter::new(Arc::new(provider_with(1, "BL_auto")));
        let plaintext = b"sensitive";
        let result = scope_tenant(Some(TenantId("BL_auto".into())), async {
            let ct = crypter.encrypt(plaintext).await.expect("encrypt");
            crypter.decrypt(&ct).await.expect("decrypt")
        })
        .await;
        assert_eq!(result, plaintext);
    }

    #[tokio::test]
    async fn missing_tenant_key_returns_key_destroyed() {
        let crypter =
            TenantKeyedCrypter::new(Arc::new(StaticKeyProvider::from_map(HashMap::new())));
        let err = scope_tenant(Some(TenantId("BL_motor".into())), async {
            crypter.encrypt(b"x").await.unwrap_err()
        })
        .await;
        assert!(matches!(err, CrypterError::KeyDestroyed { .. }));
    }

    /// Cross-tenant decrypt must fail with AadMismatch even when both tenants
    /// happen to share the same key bytes. The AAD binding prevents the
    /// ciphertext from being interpreted under a different tenant context.
    #[tokio::test]
    async fn cross_tenant_decrypt_fails_with_aad_mismatch() {
        // Both tenants use the same underlying key bytes (worst-case collision).
        let crypter = TenantKeyedCrypter::new(Arc::new(two_tenant_provider(
            42, "tenant_a", 42, "tenant_b",
        )));
        let plaintext = b"cross-tenant-secret";

        // Encrypt under tenant_a.
        let ciphertext = scope_tenant(Some(TenantId("tenant_a".into())), async {
            crypter.encrypt(plaintext).await.expect("encrypt")
        })
        .await;

        // Attempt to decrypt under tenant_b — must fail.
        let err = scope_tenant(Some(TenantId("tenant_b".into())), async {
            crypter.decrypt(&ciphertext).await.unwrap_err()
        })
        .await;

        assert!(
            matches!(err, CrypterError::AadMismatch),
            "expected AadMismatch, got {err:?}"
        );
    }

    /// A versioned key provider stub that reports version 1 for the active key.
    struct VersionedProvider {
        tenant: TenantId,
        key: [u8; 32],
        current_version: u8,
    }

    impl KeyProvider for VersionedProvider {
        fn lookup(&self, tenant: &Option<TenantId>) -> Option<[u8; 32]> {
            if tenant.as_ref() == Some(&self.tenant) {
                Some(self.key)
            } else {
                None
            }
        }

        fn lookup_versioned(&self, tenant: &Option<TenantId>) -> Option<KeyMaterial> {
            self.lookup(tenant).map(|key| KeyMaterial {
                key,
                version: self.current_version,
            })
        }
    }

    /// Pin the v0.6 wire-format byte length so format regressions fail loudly.
    ///
    /// v0.6 format: `nonce[12] || aad_len_u16_le[2] || aad[aad_len] || ct||tag[16]`
    /// No leading version_u8 prefix — that was removed in v0.6.
    #[tokio::test]
    async fn ciphertext_length_matches_v0_6_format() {
        let tenant = TenantId("BL_format".into());
        let crypter = TenantKeyedCrypter::new(Arc::new(provider_with(3, "BL_format")));
        let plaintext = b"hello";

        let ciphertext = scope_tenant(Some(tenant.clone()), async {
            crypter.encrypt(plaintext).await.expect("encrypt")
        })
        .await;

        // AAD = tenant_bytes || version_byte
        let aad_len = tenant.0.len() + 1; // +1 for version byte
        let expected_len = NONCE_LEN + AAD_LEN_FIELD + aad_len + plaintext.len() + 16;
        assert_eq!(
            ciphertext.len(),
            expected_len,
            "v0.6 wire format length mismatch: nonce({NONCE_LEN}) + aad_len_field({AAD_LEN_FIELD}) \
             + aad({aad_len}) + plaintext({}) + tag(16) = {expected_len}",
            plaintext.len()
        );
    }

    /// Rotating to a new key version makes old ciphertexts unreadable.
    /// The AEAD tag or AAD mismatch catches the stale version cleanly.
    #[tokio::test]
    async fn old_key_version_decrypt_fails_clean() {
        let tenant = TenantId("tenant_rotation".into());

        // Encrypt with version 0 key.
        let provider_v0 = Arc::new(VersionedProvider {
            tenant: tenant.clone(),
            key: [7u8; 32],
            current_version: 0,
        });
        let crypter_v0 = TenantKeyedCrypter::new(provider_v0);
        let ciphertext = scope_tenant(Some(tenant.clone()), async {
            crypter_v0.encrypt(b"old-secret").await.expect("encrypt v0")
        })
        .await;

        // Attempt decrypt with version 1 key (same key bytes, different version).
        let provider_v1 = Arc::new(VersionedProvider {
            tenant: tenant.clone(),
            key: [7u8; 32],
            current_version: 1,
        });
        let crypter_v1 = TenantKeyedCrypter::new(provider_v1);
        let err = scope_tenant(Some(tenant.clone()), async {
            crypter_v1.decrypt(&ciphertext).await.unwrap_err()
        })
        .await;

        assert!(
            matches!(err, CrypterError::AadMismatch | CrypterError::Aead(_)),
            "expected AadMismatch or Aead on version mismatch, got {err:?}"
        );
    }
}