From 3e33fa6f3f388112dd39bf5bada2c9fd2e98774e Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 8 Aug 2026 10:25:10 +1000 Subject: [PATCH 1/3] feat: expose winning-key index via decrypt_indexed for rotation drain observability (LAB-1645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During a rotation grace window an operator has no signal for when it is safe to drop a retiring master key: Keyring::decrypt and TenantKeyring::decrypt collapse the result to plaintext-or-error, discarding which entry satisfied the read, so "previous-key hit rate has reached zero" is unobservable and dropping a key risks a hard cut-over. Add decrypt_indexed to both Keyring and TenantKeyring, returning (plaintext, winning index) with 0 = current key. The sequencing loop moves into decrypt_indexed and decrypt delegates to it, so attempt semantics (current-first, identical AAD, only AuthenticationFailed advances, structural/config errors terminal, exhaustion = plain AuthenticationFailed) live in exactly one place per type and cannot drift between the two surfaces. Existing signatures unchanged; the new surface carries index only — no key material. --- src/encryption/keyring.rs | 149 +++++++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 2 deletions(-) diff --git a/src/encryption/keyring.rs b/src/encryption/keyring.rs index a02cbf4..71c86cb 100644 --- a/src/encryption/keyring.rs +++ b/src/encryption/keyring.rs @@ -220,10 +220,43 @@ impl Keyring { tenant_id: &str, aad: &[u8], ) -> Result, EncryptionError> { + self.decrypt_indexed(encryptor, ciphertext, tenant_id, aad) + .map(|(plaintext, _)| plaintext) + } + + /// [`decrypt`](Self::decrypt), additionally reporting **which keyring + /// entry** satisfied the read (0 = current key, 1.. = decrypt-only keys in + /// list order). + /// + /// This is the rotation **drain-observability** surface: during a rotation + /// grace window, count reads that return a non-zero index (previous-key + /// hits). When that rate reaches zero — every live entry has aged out via + /// TTL or been re-encrypted on write — the retiring key is no longer + /// serving reads and can be dropped from the decrypt-only list safely, + /// instead of guessing and risking a hard cut-over. + /// + /// Attempt sequencing is identical to [`decrypt`](Self::decrypt) — same + /// order, identical AAD per attempt, only + /// [`EncryptionError::AuthenticationFailed`] advances, structural and + /// configuration errors terminal, exhaustion yields plain + /// `AuthenticationFailed`. The index reveals only the position that + /// decrypted; no key material or fingerprint accompanies it. + /// + /// # Errors + /// + /// Identical to [`decrypt`](Self::decrypt). + pub fn decrypt_indexed( + &self, + encryptor: &ZeroKnowledgeEncryptor, + ciphertext: &[u8], + tenant_id: &str, + aad: &[u8], + ) -> Result<(Vec, usize), EncryptionError> { for index in 0..self.entry_count() { match self.decrypt_at(index, encryptor, ciphertext, tenant_id, aad) { + Ok(plaintext) => return Ok((plaintext, index)), Err(EncryptionError::AuthenticationFailed) => continue, - other => return other, + Err(err) => return Err(err), } } Err(EncryptionError::AuthenticationFailed) @@ -366,10 +399,43 @@ impl TenantKeyring { ciphertext: &[u8], aad: &[u8], ) -> Result, EncryptionError> { + self.decrypt_indexed(encryptor, ciphertext, aad) + .map(|(plaintext, _)| plaintext) + } + + /// [`decrypt`](Self::decrypt), additionally reporting **which keyring + /// entry** satisfied the read (0 = current key, 1.. = decrypt-only keys in + /// list order). + /// + /// This is the rotation **drain-observability** surface on the tenant-bound + /// (steady-state) path — the same contract as + /// [`Keyring::decrypt_indexed`]: during a rotation grace window, count + /// reads that return a non-zero index (previous-key hits). When that rate + /// reaches zero, the retiring key is no longer serving reads and can be + /// dropped from the decrypt-only list safely, instead of guessing and + /// risking a hard cut-over. + /// + /// Attempt sequencing is identical to [`decrypt`](Self::decrypt) — same + /// order, identical AAD per attempt, only + /// [`EncryptionError::AuthenticationFailed`] advances, structural errors + /// terminal, exhaustion yields plain `AuthenticationFailed`, no HKDF + /// anywhere on the path. The index reveals only the position that + /// decrypted; no key material or fingerprint accompanies it. + /// + /// # Errors + /// + /// Identical to [`decrypt`](Self::decrypt). + pub fn decrypt_indexed( + &self, + encryptor: &ZeroKnowledgeEncryptor, + ciphertext: &[u8], + aad: &[u8], + ) -> Result<(Vec, usize), EncryptionError> { for index in 0..self.keys.len() { match self.decrypt_at(index, encryptor, ciphertext, aad) { + Ok(plaintext) => return Ok((plaintext, index)), Err(EncryptionError::AuthenticationFailed) => continue, - other => return other, + Err(err) => return Err(err), } } Err(EncryptionError::AuthenticationFailed) @@ -568,6 +634,85 @@ mod tests { assert!(matches!(result, Err(EncryptionError::KeyDerivation(_)))); } + // ---- decrypt_indexed (rotation drain observability, LAB-1645) ---- + + #[test] + fn test_decrypt_indexed_reports_winning_entry() { + // AC: index 0 for a current-key hit, 1 for the first decrypt-only key, + // plaintext identical to plain decrypt in both cases. + let ciphertext_current = encrypt_under(&K2, b"fresh"); + let ciphertext_previous = encrypt_under(&K1, b"old"); + let encryptor = ZeroKnowledgeEncryptor::new().unwrap(); + let keyring = Keyring::new(&K2, &[&K1]).unwrap(); + + let (plaintext, index) = keyring + .decrypt_indexed(&encryptor, &ciphertext_current, TENANT, AAD) + .unwrap(); + assert_eq!((plaintext.as_slice(), index), (b"fresh".as_slice(), 0)); + + let (plaintext, index) = keyring + .decrypt_indexed(&encryptor, &ciphertext_previous, TENANT, AAD) + .unwrap(); + assert_eq!((plaintext.as_slice(), index), (b"old".as_slice(), 1)); + } + + #[test] + fn test_decrypt_indexed_exhaustion_and_terminal_errors_match_decrypt() { + // AC: exhaustion still yields plain AuthenticationFailed; structural + // and configuration errors stay terminal — identical to decrypt. + let ciphertext = encrypt_under(&K1, b"secret"); + let encryptor = ZeroKnowledgeEncryptor::new().unwrap(); + + let cut_over = Keyring::new(&K2, &[]).unwrap(); + assert!(matches!( + cut_over.decrypt_indexed(&encryptor, &ciphertext, TENANT, AAD), + Err(EncryptionError::AuthenticationFailed) + )); + + let keyring = Keyring::new(&K2, &[&K1]).unwrap(); + assert!(matches!( + keyring.decrypt_indexed(&encryptor, b"too short", TENANT, AAD), + Err(EncryptionError::InvalidCiphertext(_)) + )); + assert!(matches!( + keyring.decrypt_indexed(&encryptor, &ciphertext, "", AAD), + Err(EncryptionError::KeyDerivation(_)) + )); + } + + #[test] + fn test_tenant_keyring_decrypt_indexed_matches_unbound() { + // AC: same contract on the tenant-bound (SDK steady-state) path. + let ciphertext_current = encrypt_under(&K2, b"fresh"); + let ciphertext_previous = encrypt_under(&K1, b"old"); + let encryptor = ZeroKnowledgeEncryptor::new().unwrap(); + let ring = Keyring::new(&K2, &[&K1]) + .unwrap() + .for_tenant(TENANT) + .unwrap(); + + let (plaintext, index) = ring + .decrypt_indexed(&encryptor, &ciphertext_current, AAD) + .unwrap(); + assert_eq!((plaintext.as_slice(), index), (b"fresh".as_slice(), 0)); + + let (plaintext, index) = ring + .decrypt_indexed(&encryptor, &ciphertext_previous, AAD) + .unwrap(); + assert_eq!((plaintext.as_slice(), index), (b"old".as_slice(), 1)); + + // Exhaustion: plain AuthenticationFailed, structural errors terminal. + let cut_over = Keyring::new(&K2, &[]).unwrap().for_tenant(TENANT).unwrap(); + assert!(matches!( + cut_over.decrypt_indexed(&encryptor, &ciphertext_previous, AAD), + Err(EncryptionError::AuthenticationFailed) + )); + assert!(matches!( + ring.decrypt_indexed(&encryptor, b"too short", AAD), + Err(EncryptionError::InvalidCiphertext(_)) + )); + } + // ---- TenantKeyring (tenant-bound, derivation cached at construction) ---- #[test] From 6cc39758de94e57d36d8ab682437bc2c04bbf08d Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 8 Aug 2026 10:29:33 +1000 Subject: [PATCH 2/3] docs: doc-test the drain signal, dedupe TenantKeyring rustdoc (LAB-1645 panel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expert-panel findings: the feature's whole point (non-zero index on a previous-key read) had no runnable example in a crate where doc-tests are the executable docs — add one asserting index == 1 for a retiring- key read. TenantKeyring::decrypt_indexed restated the drain narrative verbatim; cut to the Keyring cross-ref plus this type's genuine deltas (no HKDF, no KeyDerivation class) so duplicated prose cannot drift. --- src/encryption/keyring.rs | 44 ++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/src/encryption/keyring.rs b/src/encryption/keyring.rs index 71c86cb..e514347 100644 --- a/src/encryption/keyring.rs +++ b/src/encryption/keyring.rs @@ -245,6 +245,31 @@ impl Keyring { /// # Errors /// /// Identical to [`decrypt`](Self::decrypt). + /// + /// # Examples + /// + /// A read served by the retiring key reports a non-zero index — the drain + /// signal that says the grace window is still live: + /// + /// ``` + /// use cachekit_core::{derive_domain_key, Keyring, ZeroKnowledgeEncryptor}; + /// + /// let k1 = [0x11u8; 32]; // retiring master key + /// let k2 = [0x22u8; 32]; // current master key after rotation + /// let encryptor = ZeroKnowledgeEncryptor::new()?; + /// + /// // Encrypted under k1, before the rotation... + /// let tenant_key = derive_domain_key(&k1, "encryption", b"tenant-123")?; + /// let ciphertext = encryptor.encrypt_aes_gcm(b"cached value", &tenant_key, b"aad")?; + /// + /// // ...a keyring [current=k2, decrypt-only=[k1]] serves it from entry 1: + /// // the retiring key is still draining, not yet safe to drop. + /// let keyring = Keyring::new(&k2, &[&k1])?; + /// let (plaintext, index) = keyring.decrypt_indexed(&encryptor, &ciphertext, "tenant-123", b"aad")?; + /// assert_eq!(plaintext, b"cached value"); + /// assert_eq!(index, 1); + /// # Ok::<(), Box>(()) + /// ``` pub fn decrypt_indexed( &self, encryptor: &ZeroKnowledgeEncryptor, @@ -407,20 +432,11 @@ impl TenantKeyring { /// entry** satisfied the read (0 = current key, 1.. = decrypt-only keys in /// list order). /// - /// This is the rotation **drain-observability** surface on the tenant-bound - /// (steady-state) path — the same contract as - /// [`Keyring::decrypt_indexed`]: during a rotation grace window, count - /// reads that return a non-zero index (previous-key hits). When that rate - /// reaches zero, the retiring key is no longer serving reads and can be - /// dropped from the decrypt-only list safely, instead of guessing and - /// risking a hard cut-over. - /// - /// Attempt sequencing is identical to [`decrypt`](Self::decrypt) — same - /// order, identical AAD per attempt, only - /// [`EncryptionError::AuthenticationFailed`] advances, structural errors - /// terminal, exhaustion yields plain `AuthenticationFailed`, no HKDF - /// anywhere on the path. The index reveals only the position that - /// decrypted; no key material or fingerprint accompanies it. + /// The rotation drain-observability surface on the tenant-bound + /// (steady-state) path — same contract and purpose as + /// [`Keyring::decrypt_indexed`], with this type's deltas: no HKDF anywhere + /// on the path, and no `KeyDerivation` class (derivation already happened + /// at [`Keyring::for_tenant`]). /// /// # Errors /// From 0b900a3f032b995adb8523c6e8ecbf7653160e98 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 8 Aug 2026 10:46:59 +1000 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20coderabbit=20review=20?= =?UTF-8?q?=E2=80=94=20zeroize=20caller-owned=20key=20buffers=20in=20decry?= =?UTF-8?q?pt=5Findexed=20doctest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keyring::new copies the master keys, so ZeroizeOnDrop clears only the keyring's copies — the example now wipes the caller-owned buffers after their last use, modelling the full hygiene a crypto-crate example should teach. CodeRabbit-Resolved: src/encryption/keyring.rs:271:Zeroise the doctest key buffers --- src/encryption/keyring.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/encryption/keyring.rs b/src/encryption/keyring.rs index e514347..53f8434 100644 --- a/src/encryption/keyring.rs +++ b/src/encryption/keyring.rs @@ -253,18 +253,22 @@ impl Keyring { /// /// ``` /// use cachekit_core::{derive_domain_key, Keyring, ZeroKnowledgeEncryptor}; + /// use zeroize::Zeroize; /// - /// let k1 = [0x11u8; 32]; // retiring master key - /// let k2 = [0x22u8; 32]; // current master key after rotation + /// let mut k1 = [0x11u8; 32]; // retiring master key + /// let mut k2 = [0x22u8; 32]; // current master key after rotation /// let encryptor = ZeroKnowledgeEncryptor::new()?; /// /// // Encrypted under k1, before the rotation... - /// let tenant_key = derive_domain_key(&k1, "encryption", b"tenant-123")?; + /// let mut tenant_key = derive_domain_key(&k1, "encryption", b"tenant-123")?; /// let ciphertext = encryptor.encrypt_aes_gcm(b"cached value", &tenant_key, b"aad")?; + /// tenant_key.zeroize(); /// /// // ...a keyring [current=k2, decrypt-only=[k1]] serves it from entry 1: /// // the retiring key is still draining, not yet safe to drop. /// let keyring = Keyring::new(&k2, &[&k1])?; + /// k1.zeroize(); // the keyring holds copies — wipe the caller-owned buffers + /// k2.zeroize(); /// let (plaintext, index) = keyring.decrypt_indexed(&encryptor, &ciphertext, "tenant-123", b"aad")?; /// assert_eq!(plaintext, b"cached value"); /// assert_eq!(index, 1);