Skip to main content

cachekit/
encryption.rs

1//! Zero-knowledge encryption layer using AES-256-GCM with AAD v0x03 format.
2//!
3//! Wraps `cachekit_core::ZeroKnowledgeEncryptor` with tenant key derivation
4//! and cache-key-bound Additional Authenticated Data (AAD). The AAD binding
5//! prevents ciphertext substitution attacks within the same tenant (CVSS 8.5).
6//!
7//! # AAD v0x03 Format
8//!
9//! ```text
10//! [version(0x03)][len(4)][tenant_id][len(4)][cache_key][len(4)][format][len(4)][compressed]
11//! ```
12//!
13//! Each component is length-prefixed with a 4-byte big-endian u32 to prevent
14//! collision attacks from boundary confusion.
15
16use std::sync::atomic::{AtomicU64, Ordering};
17
18use zeroize::Zeroizing;
19
20use cachekit_core::{Keyring, ZeroKnowledgeEncryptor};
21
22use crate::error::CachekitError;
23
24/// AAD protocol version byte.
25const AAD_VERSION: u8 = 0x03;
26
27/// Required length of a raw-bytes master key (`spec/intent-presets.md`
28/// § Master Key Input rule 4).
29const RAW_KEY_LEN: usize = 32;
30
31/// Zero-knowledge encryption layer with per-tenant key derivation.
32///
33/// Holds a derived encryption key (zeroized on drop), a [`Keyring`] of master
34/// keys for multi-key decrypt during rotation, and the
35/// `ZeroKnowledgeEncryptor` from cachekit-core for AES-256-GCM operations.
36///
37/// L1 stores **ciphertext**, not plaintext — the zero-knowledge property
38/// is preserved across all cache layers.
39///
40/// # Key rotation
41///
42/// Writes always encrypt under the current master key. Reads decrypt via the
43/// keyring: sequential attempts, current key first, then each decrypt-only
44/// previous key in order, rebuilding the identical AAD per attempt
45/// (cachekit-rs entries carry no per-entry key identity — sequential
46/// attempts are the spec-assigned branch, `protocol/spec/encryption.md` →
47/// "Key Rotation (Keyring)"). Reads served by a previous key are counted —
48/// see [`Self::previous_key_hits`] for the rotation drain workflow.
49pub struct EncryptionLayer {
50    encryptor: ZeroKnowledgeEncryptor,
51    derived_key: Zeroizing<[u8; 32]>,
52    keyring: Keyring,
53    tenant_id: String,
54    /// `hits[i]` ↔ `previous_keys[i]`; see [`Self::previous_key_hits`].
55    previous_key_hits: Vec<AtomicU64>,
56}
57
58impl EncryptionLayer {
59    /// Create a new encryption layer with HKDF-derived tenant keys.
60    ///
61    /// Equivalent to [`Self::with_previous_keys`] with no previous keys.
62    ///
63    /// # Arguments
64    /// * `master_key_bytes` — Raw master key, exactly 32 bytes
65    /// * `tenant_id` — Tenant identifier for cryptographic isolation
66    ///
67    /// # Errors
68    /// - Master key not exactly 32 bytes ([`CachekitError::Config`])
69    /// - HKDF derivation failure
70    /// - Encryptor initialization failure
71    pub fn new(master_key_bytes: &[u8], tenant_id: &str) -> Result<Self, CachekitError> {
72        Self::with_previous_keys(master_key_bytes, &[], tenant_id)
73    }
74
75    /// Create an encryption layer with decrypt-only previous master keys.
76    ///
77    /// `previous_keys` are retained during a key-rotation grace window, in
78    /// attempt order. Writes always use `master_key_bytes`; reads attempt it
79    /// first, then each previous key sequentially.
80    ///
81    /// Every key, current and previous, must be **exactly 32 raw bytes** —
82    /// the only length every CacheKit SDK accepts. Pass the decoded key, never
83    /// the ASCII bytes of a hex string (64 bytes, which would derive a key no
84    /// other SDK derives); hex keys go through
85    /// [`CacheKitBuilder::encryption_with_previous`](crate::client::CacheKitBuilder::encryption_with_previous),
86    /// the hex counterpart for rotation.
87    ///
88    /// # Errors
89    /// - Any key not exactly 32 bytes ([`CachekitError::Config`])
90    /// - More than 3 previous keys ([`CachekitError::Config`] — rejected,
91    ///   never truncated)
92    /// - The current master key also present in `previous_keys`
93    ///   ([`CachekitError::Config`] — rotation is forward-only)
94    /// - HKDF derivation failure
95    /// - Encryptor initialization failure
96    pub fn with_previous_keys(
97        master_key_bytes: &[u8],
98        previous_keys: &[&[u8]],
99        tenant_id: &str,
100    ) -> Result<Self, CachekitError> {
101        // Raw bytes carry no encoding to check, so length is the only guard
102        // against a hex string passed as bytes. Hex paths are floored at
103        // `>= 32` by `decode_master_key_hex`; `from_hex_decoded_keys`
104        // re-checks that floor at the crypto layer for its internal callers.
105        for (i, key) in previous_keys.iter().enumerate() {
106            if key.len() != RAW_KEY_LEN {
107                return Err(CachekitError::Config(format!(
108                    "previous master key {i} must be exactly {RAW_KEY_LEN} bytes; got {}",
109                    key.len()
110                )));
111            }
112        }
113        if master_key_bytes.len() != RAW_KEY_LEN {
114            return Err(CachekitError::Config(format!(
115                "master key must be exactly {RAW_KEY_LEN} bytes; got {} \
116                 (pass hex-encoded keys to `.encryption()`, not their ASCII bytes)",
117                master_key_bytes.len()
118            )));
119        }
120        Self::from_hex_decoded_keys(master_key_bytes, previous_keys, tenant_id)
121    }
122
123    /// Build from hex-decoded keys, which may be longer than 32 bytes
124    /// (`spec/intent-presets.md` § Master Key Input rule 3). Crate-private:
125    /// only the hex paths reach it, after `decode_master_key_hex`.
126    pub(crate) fn from_hex_decoded_keys(
127        master_key_bytes: &[u8],
128        previous_keys: &[&[u8]],
129        tenant_id: &str,
130    ) -> Result<Self, CachekitError> {
131        for (i, key) in previous_keys.iter().enumerate() {
132            if key.len() < 32 {
133                return Err(CachekitError::Config(format!(
134                    "previous master key {i} must be at least 32 bytes; got {}",
135                    key.len()
136                )));
137            }
138        }
139        // Key-length and tenant checks are configuration errors, kept in the
140        // same class as the previous-key and Keyring construction checks
141        // below (config errors must not fold into crypto failures).
142        if master_key_bytes.len() < 32 {
143            return Err(CachekitError::Config(format!(
144                "master key must be at least 32 bytes; got {}",
145                master_key_bytes.len()
146            )));
147        }
148        if tenant_id.is_empty() {
149            return Err(CachekitError::Config(
150                "tenant_id must not be empty".to_owned(),
151            ));
152        }
153        if tenant_id.len() > 255 {
154            return Err(CachekitError::Config(format!(
155                "tenant_id must be at most 255 bytes; got {}",
156                tenant_id.len()
157            )));
158        }
159
160        let tenant_keys = cachekit_core::encryption::key_derivation::derive_tenant_keys(
161            master_key_bytes,
162            tenant_id,
163        )
164        .map_err(|e| CachekitError::Encryption(format!("key derivation failed: {e}")))?;
165
166        let encryptor = ZeroKnowledgeEncryptor::new()
167            .map_err(|e| CachekitError::Encryption(format!("encryptor init failed: {e}")))?;
168
169        // The shared multi-key decrypt helper (cachekit-core) owns
170        // the keyring invariants: cap of 3, forward-only self-collision,
171        // sequential current-first attempts. Its construction failures are
172        // configuration errors, not crypto failures.
173        let keyring = Keyring::new(master_key_bytes, previous_keys)
174            .map_err(|e| CachekitError::Config(format!("keyring: {e}")))?;
175
176        Ok(Self {
177            encryptor,
178            derived_key: Zeroizing::new(tenant_keys.encryption_key),
179            keyring,
180            tenant_id: tenant_id.to_owned(),
181            previous_key_hits: previous_keys.iter().map(|_| AtomicU64::new(0)).collect(),
182        })
183    }
184
185    /// Encrypt plaintext with AAD bound to the cache key.
186    ///
187    /// Output format: `[nonce(12)][ciphertext + auth_tag(16)]`
188    pub fn encrypt(&self, plaintext: &[u8], cache_key: &str) -> Result<Vec<u8>, CachekitError> {
189        // compressed=false is normative, not a stub — see build_aad's invariant note.
190        let aad = self.build_aad(cache_key, false);
191        self.encryptor
192            .encrypt_aes_gcm(plaintext, &*self.derived_key, &aad)
193            .map_err(|e| CachekitError::Encryption(format!("encrypt failed: {e}")))
194    }
195
196    /// Decrypt ciphertext with AAD bound to the cache key.
197    ///
198    /// Returns the original plaintext. Fails if the cache key does not match
199    /// the one used during encryption (ciphertext substitution protection).
200    ///
201    /// Decryption goes through the keyring: the current master key is
202    /// attempted first, then each decrypt-only previous key in order, with
203    /// the identical AAD per attempt. Entries written before a key rotation
204    /// stay readable as long as their key remains in the previous list.
205    ///
206    /// A read served by a previous key is counted in
207    /// [`Self::previous_key_hits`] (the rotation drain signal).
208    pub fn decrypt(&self, ciphertext: &[u8], cache_key: &str) -> Result<Vec<u8>, CachekitError> {
209        // compressed=false is normative, not a stub — see build_aad's invariant note.
210        let aad = self.build_aad(cache_key, false);
211        let (plaintext, index) = self
212            .keyring
213            .decrypt_indexed(&self.encryptor, ciphertext, &self.tenant_id, &aad)
214            .map_err(|e| match e {
215                // Config-class errors stay config-class:
216                // a derivation failure or caller bug must never masquerade as
217                // a decrypt failure that fail-open callers read as a miss.
218                cachekit_core::EncryptionError::KeyDerivation(_)
219                | cachekit_core::EncryptionError::KeyringIndexOutOfRange { .. } => {
220                    CachekitError::Config(format!("keyring decrypt misconfiguration: {e}"))
221                }
222                _ => CachekitError::Encryption(format!("decrypt failed: {e}")),
223            })?;
224        // index 0 = current key (no signal); i >= 1 = previous_keys[i - 1].
225        // The keyring was built from the same slice, so the slot always
226        // exists; `get` keeps a core bug from panicking the read path.
227        debug_assert!(
228            index <= self.previous_key_hits.len(),
229            "keyring index {index} beyond previous-key slots"
230        );
231        if let Some(hits) = index
232            .checked_sub(1)
233            .and_then(|i| self.previous_key_hits.get(i))
234        {
235            hits.fetch_add(1, Ordering::Relaxed);
236        }
237        Ok(plaintext)
238    }
239
240    /// Reads decrypted by each previous key, by position in the previous-key
241    /// list (`hits[i]` ↔ `previous_keys[i]`); empty when there are none.
242    /// Reads served by the current key are not counted.
243    ///
244    /// This is the rotation **drain signal**. It confirms that a grace window
245    /// has drained; it does not shorten one. Follow the protocol's
246    /// [scheduled-rotation runbook](https://github.com/cachekit-io/protocol/blob/main/decisions/key-rotation.md#runbooks-normative-for-docs):
247    /// audit for non-expiring entries first, add the incoming key as
248    /// decrypt-only fleet-wide, then promote it. The window clock starts only
249    /// when that promotion deploy has completed on every instance — until
250    /// then a lagging instance still writes fresh ciphertext under the
251    /// retiring key and reads it silently as *its* current key (index 0).
252    /// From that point, wait at least the longest TTL in use (per-entry TTLs
253    /// passed to `set_with_ttl` count, not just the default), aggregating
254    /// counts across every instance — they are per process and reset on
255    /// restart. Only when the retiring key's count has stayed flat over that
256    /// whole window has every live entry aged out or been re-encrypted on
257    /// write, and the key can be dropped from the previous list without a
258    /// hard cut-over. Positions are comparable across instances only once
259    /// they all run the same keyring configuration. The signal carries no
260    /// key material — positions and counts only.
261    ///
262    /// ```
263    /// use cachekit::EncryptionLayer;
264    ///
265    /// let k1 = [0x11u8; 32]; // retiring master key
266    /// let k2 = [0x22u8; 32]; // current master key after rotation
267    ///
268    /// // Encrypted under k1, before the rotation...
269    /// let ciphertext = EncryptionLayer::new(&k1, "tenant-123")?.encrypt(b"cached value", "user:1")?;
270    ///
271    /// // ...a layer [current=k2, previous=[k1]] serves it from previous[0]:
272    /// // the retiring key is still draining, not yet safe to drop.
273    /// let layer = EncryptionLayer::with_previous_keys(&k2, &[&k1], "tenant-123")?;
274    /// assert_eq!(layer.previous_key_hits(), vec![0]);
275    /// assert_eq!(layer.decrypt(&ciphertext, "user:1")?, b"cached value");
276    /// assert_eq!(layer.previous_key_hits(), vec![1]);
277    /// # Ok::<(), cachekit::CachekitError>(())
278    /// ```
279    pub fn previous_key_hits(&self) -> Vec<u64> {
280        self.previous_key_hits
281            .iter()
282            .map(|h| h.load(Ordering::Relaxed))
283            .collect()
284    }
285
286    /// Whether cachekit-core detected AES hardware support on this host.
287    ///
288    /// Forwards `ZeroKnowledgeEncryptor::hardware_acceleration_enabled()`.
289    /// Informational only — `ring`/`aes-gcm` dispatch independently of it; the
290    /// per-architecture semantics are core's ([README → Zero-Knowledge
291    /// Encryption](https://github.com/cachekit-io/cachekit-rs#zero-knowledge-encryption)).
292    pub fn hardware_acceleration_enabled(&self) -> bool {
293        self.encryptor.hardware_acceleration_enabled()
294    }
295
296    /// Return the tenant ID used for key derivation.
297    pub fn tenant_id(&self) -> &str {
298        &self.tenant_id
299    }
300
301    /// Build AAD v0x03 for a given cache key and compression flag.
302    ///
303    /// Format: `[0x03][len][tenant_id][len][cache_key][len]["msgpack"][len]["True"/"False"]`
304    ///
305    /// All lengths are 4-byte big-endian u32 to prevent boundary-confusion attacks.
306    ///
307    /// # Invariant: `compressed` is always `false` in production
308    ///
309    /// `encrypt` and `decrypt` pass the literal `false`, and that is normative, not a
310    /// gap: this SDK's only cross-SDK encrypted surface is interop mode, and
311    /// `protocol/spec/interop-mode.md` ("Encryption in Interop Mode") mandates the AAD
312    /// components `format = "msgpack"`, `compressed = "False"` — there is no
313    /// compression in interop mode.
314    ///
315    /// Why a reader cannot recover from a wrong flag here: in the general flow a reader
316    /// rebuilds the AAD from the **stored cleartext metadata** written alongside the
317    /// ciphertext (`protocol/spec/encryption.md`, encrypt step 6 / decrypt step 3).
318    /// cachekit-rs stores no such per-entry metadata — it writes plain
319    /// `nonce ‖ ciphertext ‖ tag` with no header — so its readers have nothing to read
320    /// the flag from and must reconstruct the AAD from the interop-pinned constants
321    /// alone. Combined with the no-retry rule (a reader MUST NOT retry decryption with
322    /// any alternative AAD input, `protocol/spec/encryption.md`), emitting
323    /// `compressed = "True"` here would produce ciphertext that no conformant peer can
324    /// authenticate, and none may probe for.
325    ///
326    /// So do not flip this literal to thread a live compression flag through
327    /// `encrypt`/`decrypt`. If this SDK ever does compress, the flag must be *stored*
328    /// per entry and threaded from that stored value — `protocol/spec/encryption.md`
329    /// requires `compressed` to describe the actual plaintext, and claiming `"False"`
330    /// over compressed bytes is the cachekit-py#166 conformance bug (round-trips
331    /// in-process, fails authentication for every correct second reader). A compressed
332    /// cross-SDK profile is a versioned protocol change (interop/v2),
333    /// not an SDK flag.
334    ///
335    /// The `"True"`/`"False"` tokens are frozen byte-level protocol constants —
336    /// normative byte table in `protocol/spec/encryption.md`, section
337    /// "`compressed` tokens"; decided in
338    /// [protocol#12](https://github.com/cachekit-io/protocol/issues/12) (resolved
339    /// 2026-07-19: a spec correction, not a wire change). The `true` branch stays,
340    /// exercised by conformance tests, so those bytes remain pinned.
341    pub fn build_aad(&self, cache_key: &str, compressed: bool) -> Vec<u8> {
342        let format_str = b"msgpack";
343        let compressed_str = if compressed {
344            b"True" as &[u8]
345        } else {
346            b"False"
347        };
348
349        let tenant_bytes = self.tenant_id.as_bytes();
350        let key_bytes = cache_key.as_bytes();
351
352        // Pre-allocate: version(1) + 4 length fields(16) + data
353        let capacity =
354            1 + 16 + tenant_bytes.len() + key_bytes.len() + format_str.len() + compressed_str.len();
355        let mut aad = Vec::with_capacity(capacity);
356
357        aad.push(AAD_VERSION);
358
359        // All components are bounded: tenant_id <= 255 (validated in new()),
360        // cache_key <= 1024 (validated by client), format/compressed are constants.
361        // Safe to use len_u32 helper which saturates on overflow.
362
363        // tenant_id
364        aad.extend_from_slice(&len_u32(tenant_bytes.len()).to_be_bytes());
365        aad.extend_from_slice(tenant_bytes);
366
367        // cache_key
368        aad.extend_from_slice(&len_u32(key_bytes.len()).to_be_bytes());
369        aad.extend_from_slice(key_bytes);
370
371        // format
372        aad.extend_from_slice(&len_u32(format_str.len()).to_be_bytes());
373        aad.extend_from_slice(format_str);
374
375        // compressed flag
376        aad.extend_from_slice(&len_u32(compressed_str.len()).to_be_bytes());
377        aad.extend_from_slice(compressed_str);
378
379        aad
380    }
381}
382
383/// Convert a usize length to u32 for AAD encoding, saturating on overflow.
384/// In practice all inputs are validated to fit (tenant_id <= 255, cache_key <= 1024).
385#[allow(clippy::cast_possible_truncation)]
386fn len_u32(len: usize) -> u32 {
387    u32::try_from(len).unwrap_or(u32::MAX)
388}
389
390impl std::fmt::Debug for EncryptionLayer {
391    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
392        f.debug_struct("EncryptionLayer")
393            .field("tenant_id", &self.tenant_id)
394            .field("derived_key", &"[REDACTED]")
395            .finish()
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    const TEST_MASTER_KEY: &[u8] = b"test_master_key_32_bytes_long!!!";
404    const TEST_TENANT: &str = "test-tenant";
405
406    #[test]
407    fn roundtrip_encrypt_decrypt() {
408        let layer = EncryptionLayer::new(TEST_MASTER_KEY, TEST_TENANT).unwrap();
409        let plaintext = b"hello, zero-knowledge world";
410
411        let ciphertext = layer.encrypt(plaintext, "my:key").unwrap();
412        let decrypted = layer.decrypt(&ciphertext, "my:key").unwrap();
413
414        assert_eq!(decrypted, plaintext);
415    }
416
417    #[test]
418    fn wrong_cache_key_fails_decryption() {
419        let layer = EncryptionLayer::new(TEST_MASTER_KEY, TEST_TENANT).unwrap();
420        let ciphertext = layer.encrypt(b"secret", "key:a").unwrap();
421
422        let result = layer.decrypt(&ciphertext, "key:b");
423        assert!(result.is_err(), "decryption with wrong cache key must fail");
424    }
425
426    #[test]
427    fn different_tenants_produce_different_ciphertext() {
428        let layer_a = EncryptionLayer::new(TEST_MASTER_KEY, "tenant-a").unwrap();
429        let layer_b = EncryptionLayer::new(TEST_MASTER_KEY, "tenant-b").unwrap();
430
431        let ct_a = layer_a.encrypt(b"same data", "same:key").unwrap();
432        let ct_b = layer_b.encrypt(b"same data", "same:key").unwrap();
433
434        // Nonces differ, so ciphertext differs, but also keys differ
435        assert_ne!(ct_a, ct_b);
436
437        // Cross-tenant decryption must fail
438        assert!(layer_b.decrypt(&ct_a, "same:key").is_err());
439    }
440
441    #[test]
442    fn master_key_too_short() {
443        let result = EncryptionLayer::new(b"short", "tenant");
444        assert!(result.is_err());
445        let msg = result.unwrap_err().to_string();
446        assert!(msg.contains("exactly 32 bytes"), "got: {msg}");
447    }
448
449    fn assert_config_err(result: &Result<EncryptionLayer, CachekitError>, what: &str) {
450        assert!(
451            matches!(result, Err(CachekitError::Config(_))),
452            "{what}: expected Config error, got {result:?}"
453        );
454    }
455
456    #[test]
457    fn new_requires_exactly_32_bytes() {
458        assert_config_err(&EncryptionLayer::new(&[7; 31], TEST_TENANT), "31 bytes");
459        assert_config_err(&EncryptionLayer::new(&[7; 33], TEST_TENANT), "33 bytes");
460        assert!(EncryptionLayer::new(&[7; 32], TEST_TENANT).is_ok());
461    }
462
463    #[test]
464    fn with_previous_keys_requires_exactly_32_byte_current_key() {
465        for len in [31, 33, 48] {
466            let current = vec![7u8; len];
467            assert_config_err(
468                &EncryptionLayer::with_previous_keys(&current, &[K1], TEST_TENANT),
469                &format!("{len}-byte current key"),
470            );
471        }
472        assert!(EncryptionLayer::with_previous_keys(K2, &[K1], TEST_TENANT).is_ok());
473    }
474
475    #[test]
476    fn with_previous_keys_requires_exactly_32_byte_previous_keys() {
477        for len in [31, 33] {
478            let previous = vec![7u8; len];
479            assert_config_err(
480                &EncryptionLayer::with_previous_keys(K2, &[K1, &previous], TEST_TENANT),
481                &format!("{len}-byte previous key"),
482            );
483        }
484    }
485
486    #[test]
487    fn hex_decoded_keys_accept_longer_than_32_bytes() {
488        // Rule 3: the hex path accepts >= 32 decoded bytes.
489        let long = [7u8; 48];
490        let layer =
491            EncryptionLayer::from_hex_decoded_keys(&long, &[&long[..33]], TEST_TENANT).unwrap();
492        let ct = layer.encrypt(b"v", "k").unwrap();
493        assert_eq!(layer.decrypt(&ct, "k").unwrap(), b"v");
494        assert_config_err(
495            &EncryptionLayer::from_hex_decoded_keys(&long[..31], &[], TEST_TENANT),
496            "hex path keeps its 32-byte floor",
497        );
498    }
499
500    #[test]
501    fn aad_v03_format() {
502        let layer = EncryptionLayer::new(TEST_MASTER_KEY, TEST_TENANT).unwrap();
503        let aad = layer.build_aad("user:42", false);
504
505        // Version byte
506        assert_eq!(aad[0], 0x03);
507
508        // tenant_id length (4 bytes BE) + tenant_id
509        let tenant_len = u32::from_be_bytes(aad[1..5].try_into().unwrap()) as usize;
510        assert_eq!(tenant_len, TEST_TENANT.len());
511        assert_eq!(&aad[5..5 + tenant_len], TEST_TENANT.as_bytes());
512
513        // cache_key length + cache_key
514        let offset = 5 + tenant_len;
515        let key_len = u32::from_be_bytes(aad[offset..offset + 4].try_into().unwrap()) as usize;
516        assert_eq!(key_len, 7); // "user:42"
517        assert_eq!(&aad[offset + 4..offset + 4 + key_len], b"user:42");
518
519        // format length + format
520        let offset = offset + 4 + key_len;
521        let fmt_len = u32::from_be_bytes(aad[offset..offset + 4].try_into().unwrap()) as usize;
522        assert_eq!(&aad[offset + 4..offset + 4 + fmt_len], b"msgpack");
523
524        // compressed length + compressed
525        let offset = offset + 4 + fmt_len;
526        let comp_len = u32::from_be_bytes(aad[offset..offset + 4].try_into().unwrap()) as usize;
527        assert_eq!(&aad[offset + 4..offset + 4 + comp_len], b"False");
528    }
529
530    #[test]
531    fn aad_compressed_flag() {
532        let layer = EncryptionLayer::new(TEST_MASTER_KEY, TEST_TENANT).unwrap();
533        let aad_false = layer.build_aad("k", false);
534        let aad_true = layer.build_aad("k", true);
535
536        assert_ne!(aad_false, aad_true);
537        // "True" is at the end
538        assert!(aad_true.ends_with(b"True"));
539        assert!(aad_false.ends_with(b"False"));
540    }
541
542    // ── Keyring rotation ─────────────────────────────────────────────────────
543
544    const K1: &[u8] = &[0x11; 32];
545    const K2: &[u8] = &[0x22; 32];
546
547    #[test]
548    fn previous_key_decrypts_after_rotation() {
549        // Written under k1 before the rotation...
550        let old_layer = EncryptionLayer::new(K1, TEST_TENANT).unwrap();
551        let ciphertext = old_layer.encrypt(b"pre-rotation value", "user:1").unwrap();
552
553        // ...still readable with current=k2, previous=[k1] (identical AAD)...
554        let rotated = EncryptionLayer::with_previous_keys(K2, &[K1], TEST_TENANT).unwrap();
555        let plaintext = rotated.decrypt(&ciphertext, "user:1").unwrap();
556        assert_eq!(plaintext, b"pre-rotation value");
557
558        // ...and unreadable after a hard cut-over (previous=[]).
559        let cut_over = EncryptionLayer::with_previous_keys(K2, &[], TEST_TENANT).unwrap();
560        assert!(cut_over.decrypt(&ciphertext, "user:1").is_err());
561    }
562
563    #[test]
564    fn rotated_layer_still_writes_under_current_key() {
565        let rotated = EncryptionLayer::with_previous_keys(K2, &[K1], TEST_TENANT).unwrap();
566        let ciphertext = rotated.encrypt(b"fresh write", "user:2").unwrap();
567
568        // A current-key-only layer reads it: writes never use previous keys.
569        let current_only = EncryptionLayer::new(K2, TEST_TENANT).unwrap();
570        assert_eq!(
571            current_only.decrypt(&ciphertext, "user:2").unwrap(),
572            b"fresh write"
573        );
574    }
575
576    #[test]
577    fn exactly_three_previous_keys_is_accepted() {
578        // Boundary success: a `>=` cap check instead of `>` would reject a
579        // legitimate three-key rotation window and still pass every
580        // rejecting-side test.
581        let keys: Vec<[u8; 32]> = (1..=3).map(|i| [i; 32]).collect();
582        let refs: Vec<&[u8]> = keys.iter().map(|k| k.as_slice()).collect();
583
584        let layer = EncryptionLayer::with_previous_keys(K2, &refs, TEST_TENANT);
585        assert!(
586            layer.is_ok(),
587            "cap is 3: exactly three previous keys must build"
588        );
589    }
590
591    #[test]
592    fn more_than_three_previous_keys_is_config_error() {
593        let keys: Vec<[u8; 32]> = (1..=4).map(|i| [i; 32]).collect();
594        let refs: Vec<&[u8]> = keys.iter().map(|k| k.as_slice()).collect();
595
596        let result = EncryptionLayer::with_previous_keys(K2, &refs, TEST_TENANT);
597        assert!(
598            matches!(result, Err(CachekitError::Config(_))),
599            "cap of 3 must reject, never truncate"
600        );
601    }
602
603    #[test]
604    fn current_key_in_previous_list_is_config_error() {
605        let result = EncryptionLayer::with_previous_keys(K2, &[K1, K2], TEST_TENANT);
606        assert!(
607            matches!(result, Err(CachekitError::Config(_))),
608            "forward-only: current key must not be decrypt-only"
609        );
610    }
611
612    #[test]
613    fn short_previous_key_is_config_error() {
614        let short = [0x01u8; 16]; // core would accept 16; raw keys must be exactly 32
615        let result = EncryptionLayer::with_previous_keys(K2, &[&short], TEST_TENANT);
616        assert!(matches!(result, Err(CachekitError::Config(_))));
617    }
618
619    // ── Rotation drain signal ────────────────────────────────────────────────
620
621    #[test]
622    fn current_key_hit_is_not_counted() {
623        // No previous keys: the drain signal has nothing to report.
624        let single = EncryptionLayer::new(K2, TEST_TENANT).unwrap();
625        let ct = single.encrypt(b"v", "user:3").unwrap();
626        single.decrypt(&ct, "user:3").unwrap();
627        assert!(single.previous_key_hits().is_empty());
628
629        // With a previous key, a current-key read (index 0) is a zero reading.
630        let rotated = EncryptionLayer::with_previous_keys(K2, &[K1], TEST_TENANT).unwrap();
631        let ct = rotated.encrypt(b"fresh write", "user:3").unwrap();
632        rotated.decrypt(&ct, "user:3").unwrap();
633        assert_eq!(rotated.previous_key_hits(), vec![0]);
634    }
635
636    #[test]
637    fn previous_key_hit_is_counted_at_its_position() {
638        const K3: &[u8] = &[0x33; 32];
639        let k1_ct = EncryptionLayer::new(K1, TEST_TENANT)
640            .unwrap()
641            .encrypt(b"k1 era", "user:4")
642            .unwrap();
643        let k2_ct = EncryptionLayer::new(K2, TEST_TENANT)
644            .unwrap()
645            .encrypt(b"k2 era", "user:5")
646            .unwrap();
647
648        // current=k3, previous=[k2, k1]: counts index by position in the previous list.
649        let rotated = EncryptionLayer::with_previous_keys(K3, &[K2, K1], TEST_TENANT).unwrap();
650        assert_eq!(rotated.previous_key_hits(), vec![0, 0]);
651
652        rotated.decrypt(&k1_ct, "user:4").unwrap();
653        assert_eq!(rotated.previous_key_hits(), vec![0, 1], "k1 is previous[1]");
654
655        rotated.decrypt(&k2_ct, "user:5").unwrap();
656        rotated.decrypt(&k2_ct, "user:5").unwrap();
657        assert_eq!(rotated.previous_key_hits(), vec![2, 1], "k2 is previous[0]");
658    }
659
660    #[test]
661    fn failed_decrypt_is_not_a_previous_key_hit() {
662        let k1_ct = EncryptionLayer::new(K1, TEST_TENANT)
663            .unwrap()
664            .encrypt(b"v", "key:a")
665            .unwrap();
666        let rotated = EncryptionLayer::with_previous_keys(K2, &[K1], TEST_TENANT).unwrap();
667
668        // Wrong cache key: every attempt fails authentication — no key won.
669        assert!(rotated.decrypt(&k1_ct, "key:b").is_err());
670        assert_eq!(rotated.previous_key_hits(), vec![0]);
671    }
672
673    #[test]
674    fn hardware_acceleration_reports_core_detection() {
675        let layer = EncryptionLayer::new(TEST_MASTER_KEY, TEST_TENANT).unwrap();
676        // Delegation, not a constant: agrees with a fresh core encryptor...
677        let core = ZeroKnowledgeEncryptor::new().unwrap();
678        assert_eq!(
679            layer.hardware_acceleration_enabled(),
680            core.hardware_acceleration_enabled()
681        );
682        // ...and on x86_64 with the CPU itself. This pins core's runtime
683        // probe — the only executed evidence in any repo that the bool is live
684        // detection, not a constant.
685        #[cfg(target_arch = "x86_64")]
686        assert_eq!(
687            layer.hardware_acceleration_enabled(),
688            std::arch::is_x86_feature_detected!("aes")
689        );
690    }
691
692    #[test]
693    fn debug_redacts_key() {
694        let layer = EncryptionLayer::new(TEST_MASTER_KEY, TEST_TENANT).unwrap();
695        let debug = format!("{layer:?}");
696        assert!(debug.contains("[REDACTED]"));
697        assert!(!debug.contains("test_master_key"));
698    }
699}