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