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