Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 167 additions & 2 deletions src/encryption/keyring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,72 @@ impl Keyring {
tenant_id: &str,
aad: &[u8],
) -> Result<Vec<u8>, 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).
///
/// # 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};
/// use zeroize::Zeroize;
///
/// 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 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);
/// # Ok::<(), Box<dyn std::error::Error>>(())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/// ```
pub fn decrypt_indexed(
&self,
encryptor: &ZeroKnowledgeEncryptor,
ciphertext: &[u8],
tenant_id: &str,
aad: &[u8],
) -> Result<(Vec<u8>, 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)
Expand Down Expand Up @@ -366,10 +428,34 @@ impl TenantKeyring {
ciphertext: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, 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).
///
/// 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
///
/// Identical to [`decrypt`](Self::decrypt).
pub fn decrypt_indexed(
&self,
encryptor: &ZeroKnowledgeEncryptor,
ciphertext: &[u8],
aad: &[u8],
) -> Result<(Vec<u8>, 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)
Expand Down Expand Up @@ -568,6 +654,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!(
Comment thread
27Bslash6 marked this conversation as resolved.
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]
Expand Down
Loading