diff --git a/README.md b/README.md index 3a26f04..24332fe 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ gcc -o example example.c -L target/release -lcachekit_core -I include │ Each tenant key provides: │ │ • Cryptographic isolation (compromise one ≠ compromise all) │ │ • Domain separation (cache vs auth vs sessions) │ -│ • Forward secrecy with key rotation │ +│ • Master-key rotation via decrypt-only keyring (grace window) │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` @@ -263,7 +263,7 @@ cachekit-core/ │ │ ├── mod.rs # Module exports │ │ ├── core.rs # AES-256-GCM implementation │ │ ├── key_derivation.rs # HKDF-SHA256 + tenant isolation -│ │ └── key_rotation.rs # Graceful key rotation support +│ │ └── keyring.rs # Multi-key decrypt keyring (master-key rotation) │ │ │ └── ffi/ # (feature = "ffi") │ ├── mod.rs # FFI exports diff --git a/src/encryption/core.rs b/src/encryption/core.rs index 9538f4d..7ffd4d2 100644 --- a/src/encryption/core.rs +++ b/src/encryption/core.rs @@ -154,8 +154,25 @@ pub enum EncryptionError { #[error("Nonce counter exhausted - key rotation required")] NonceCounterExhausted, - #[error("Key rotation not yet implemented")] - NotImplemented(String), + #[error("Invalid master key length: expected at least 16 bytes, got {0}")] + InvalidMasterKeyLength(usize), + + #[error( + "Keyring cap exceeded: at most {max} decrypt-only keys allowed, got {0}", + max = super::keyring::MAX_DECRYPT_ONLY_KEYS + )] + KeyringCapExceeded(usize), + + #[error( + "Current key must not appear in the decrypt-only list (forward-only rotation invariant)" + )] + CurrentKeyInDecryptOnlyList, + + #[error("Keyring entry index {index} out of range (entry count {count})")] + KeyringIndexOutOfRange { index: usize, count: usize }, + + #[error("Key derivation failed: {0}")] + KeyDerivation(#[from] super::key_derivation::KeyDerivationError), } /// Zero-knowledge encryptor using AES-256-GCM with hardware acceleration detection @@ -480,22 +497,6 @@ impl ZeroKnowledgeEncryptor { .unwrap_or_else(|_| OperationMetrics::new()) } - /// Key rotation API (stub for future implementation) - /// - /// This method will support gradual key migration to allow rotating encryption keys - /// without downtime. Future implementation will: - /// - Support dual-key mode (read from both old and new key, write with new key only) - /// - Add version byte to ciphertext header indicating which key was used - /// - Implement gradual migration strategy - /// - /// Currently returns NotImplemented error. - pub fn rotate_key(&mut self, _new_master_key: &[u8]) -> Result<(), EncryptionError> { - Err(EncryptionError::NotImplemented( - "Key rotation will be implemented in a future release with gradual migration support" - .into(), - )) - } - /// Encrypt data using AES-256-GCM with authenticated additional data (wasm32) /// /// Uses RustCrypto's `aes-gcm` crate (pure Rust, compiles on wasm32-unknown-unknown). diff --git a/src/encryption/key_derivation.rs b/src/encryption/key_derivation.rs index 854563c..2e49e2b 100644 --- a/src/encryption/key_derivation.rs +++ b/src/encryption/key_derivation.rs @@ -160,7 +160,7 @@ pub fn derive_tenant_keys( // Allow unused_assignments: Zeroize derive macro generates assignment code for #[zeroize(skip)] // fields that triggers false positive in Rust 1.92+. The tenant_id field IS read in tests/fuzz. #[allow(unused_assignments)] -#[derive(Debug, Zeroize, ZeroizeOnDrop)] +#[derive(Zeroize, ZeroizeOnDrop)] pub struct TenantKeys { pub encryption_key: [u8; 32], pub authentication_key: [u8; 32], @@ -170,6 +170,18 @@ pub struct TenantKeys { pub tenant_id: String, } +// Manual Debug: key material must never reach logs via `{:?}` (CWE-215). +// Only the tenant id and the encryption-key fingerprint are printed. +impl std::fmt::Debug for TenantKeys { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TenantKeys") + .field("tenant_id", &self.tenant_id) + .field("encryption_fingerprint", &self.encryption_fingerprint()) + .field("keys", &"") + .finish() + } +} + impl TenantKeys { /// Get fingerprint for the encryption key pub fn encryption_fingerprint(&self) -> [u8; 16] { diff --git a/src/encryption/key_rotation.rs b/src/encryption/key_rotation.rs deleted file mode 100644 index 253f3eb..0000000 --- a/src/encryption/key_rotation.rs +++ /dev/null @@ -1,305 +0,0 @@ -//! Key rotation support for AES-256-GCM encryption -//! -//! Enables zero-downtime key rotation using dual-key mode: -//! - Read from both old and new keys (backward compatibility) -//! - Write only with new key (migration forward) -//! - Key version bytes in ciphertext header track which key was used - -// Zeroize derive macro generates code that triggers false positive unused_assignments -// lint in Rust 1.92+ for #[zeroize(skip)] fields. The KeyRotationState.rotation_active field IS read. -#![allow(unused_assignments)] - -use std::convert::TryInto; -use zeroize::{Zeroize, ZeroizeOnDrop}; - -/// Encryption header with key version support for rotation -/// -/// Format: `[version(1)][algorithm(1)][fingerprint(16)][tenant_hash(8)][domain(4)][key_version(1)][reserved(1)]` = 32 bytes -#[derive(Debug, Clone)] -pub struct RotationAwareHeader { - pub version: u8, - pub algorithm: u8, - pub key_fingerprint: [u8; 16], - pub tenant_id_hash: [u8; 8], - pub domain: [u8; 4], - /// Which key version encrypted this data: 0 = original, 1 = rotated - pub key_version: u8, -} - -impl RotationAwareHeader { - pub const SIZE: usize = 32; - - pub fn new( - key_fingerprint: [u8; 16], - tenant_id_hash: [u8; 8], - domain: [u8; 4], - key_version: u8, - ) -> Self { - Self { - version: 1, - algorithm: 0, // AES-256-GCM - key_fingerprint, - tenant_id_hash, - domain, - key_version, - } - } - - pub fn to_bytes(&self) -> [u8; Self::SIZE] { - let mut bytes = [0u8; Self::SIZE]; - bytes[0] = self.version; - bytes[1] = self.algorithm; - bytes[2..18].copy_from_slice(&self.key_fingerprint); - bytes[18..26].copy_from_slice(&self.tenant_id_hash); - bytes[26..30].copy_from_slice(&self.domain); - bytes[30] = self.key_version; - // bytes[31] reserved - bytes - } - - pub fn from_bytes(bytes: &[u8]) -> Result { - if bytes.len() < Self::SIZE { - return Err(super::EncryptionError::InvalidHeader( - "Header too short".into(), - )); - } - - let version = bytes[0]; - let algorithm = bytes[1]; - - if version != 1 { - return Err(super::EncryptionError::UnsupportedVersion(version)); - } - - if algorithm != 0 { - return Err(super::EncryptionError::UnsupportedAlgorithm(algorithm)); - } - - let key_fingerprint: [u8; 16] = bytes[2..18] - .try_into() - .map_err(|_| super::EncryptionError::InvalidHeader("Invalid fingerprint".into()))?; - let tenant_id_hash: [u8; 8] = bytes[18..26] - .try_into() - .map_err(|_| super::EncryptionError::InvalidHeader("Invalid tenant hash".into()))?; - let domain: [u8; 4] = bytes[26..30] - .try_into() - .map_err(|_| super::EncryptionError::InvalidHeader("Invalid domain".into()))?; - let key_version = bytes[30]; - - Ok(Self { - version, - algorithm, - key_fingerprint, - tenant_id_hash, - domain, - key_version, - }) - } -} - -/// State for managing key rotation with dual-key mode -/// -/// During rotation: -/// - `old_key`: Optional previous key (for decryption only, backward compatibility) -/// - `new_key`: Current active key (for encryption and decryption) -/// -/// Rotation strategy: -/// 1. Set `new_key` to rotated master key -/// 2. Keep `old_key` for reading old ciphertext -/// 3. All new encryptions use `new_key` -/// 4. After migration window, remove `old_key` -/// -/// # Security -/// Key material is securely erased from memory on drop via `ZeroizeOnDrop`. -/// Clone is intentionally not derived to prevent key proliferation in memory. -/// -/// ```compile_fail -/// use cachekit_core::encryption::key_rotation::KeyRotationState; -/// let state = KeyRotationState::new([0u8; 32]); -/// let cloned = state.clone(); // ERROR: Clone not implemented -/// ``` -// Allow unused_assignments: Zeroize derive macro generates assignment code for #[zeroize(skip)] -// fields that triggers false positive in Rust 1.92+. The rotation_active field IS read. -#[allow(unused_assignments)] -#[derive(Debug, Zeroize, ZeroizeOnDrop)] -pub struct KeyRotationState { - /// Old key for reading legacy ciphertext (backward compatibility during migration) - pub old_key: Option<[u8; 32]>, - /// New key for all encryption and decryption after rotation - pub new_key: [u8; 32], - /// Indicates if rotation is currently active (old_key exists) - #[zeroize(skip)] - #[allow(unused_assignments)] // False positive: field IS read, Zeroize derive triggers lint - pub rotation_active: bool, -} - -impl KeyRotationState { - /// Create a new rotation state with just the initial key - pub fn new(key: [u8; 32]) -> Self { - Self { - old_key: None, - new_key: key, - rotation_active: false, - } - } - - /// Start key rotation: set new key, keep old for backward compatibility - pub fn start_rotation(&mut self, new_key: [u8; 32]) { - self.old_key = Some(self.new_key); - self.new_key = new_key; - self.rotation_active = true; - } - - /// Complete key rotation: remove old key, finalize migration - pub fn complete_rotation(&mut self) { - self.old_key = None; - self.rotation_active = false; - } - - /// Get the key to use for encryption (always new key) - pub fn encryption_key(&self) -> &[u8; 32] { - &self.new_key - } - - /// Get key for decryption based on version byte in ciphertext - pub fn decryption_key(&self, key_version: u8) -> Option<&[u8; 32]> { - match key_version { - 0 => { - // Original key: use old_key if available (during rotation), otherwise new_key - if self.rotation_active { - self.old_key.as_ref() - } else { - Some(&self.new_key) - } - } - 1 => { - // New key: use new_key - Some(&self.new_key) - } - _ => None, // Unknown version - } - } - - /// Check if rotation is still in progress - pub fn is_rotating(&self) -> bool { - self.rotation_active && self.old_key.is_some() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_rotation_aware_header_roundtrip() { - let header = RotationAwareHeader::new([0x12; 16], [0x34; 8], *b"ench", 1); - - let bytes = header.to_bytes(); - let decoded = RotationAwareHeader::from_bytes(&bytes).unwrap(); - - assert_eq!(decoded.version, 1); - assert_eq!(decoded.algorithm, 0); - assert_eq!(decoded.key_version, 1); - assert_eq!(decoded.domain, *b"ench"); - } - - #[test] - fn test_key_rotation_state_new() { - let key = [0xAB; 32]; - let state = KeyRotationState::new(key); - - assert_eq!(state.new_key, key); - assert_eq!(state.old_key, None); - assert!(!state.rotation_active); - } - - #[test] - fn test_key_rotation_start() { - let old_key = [0xAA; 32]; - let new_key = [0xBB; 32]; - - let mut state = KeyRotationState::new(old_key); - state.start_rotation(new_key); - - assert_eq!(state.new_key, new_key); - assert_eq!(state.old_key, Some(old_key)); - assert!(state.rotation_active); - } - - #[test] - fn test_key_rotation_decryption_keys() { - let old_key = [0xAA; 32]; - let new_key = [0xBB; 32]; - - let mut state = KeyRotationState::new(old_key); - state.start_rotation(new_key); - - // Version 0 should use old key during rotation - assert_eq!(state.decryption_key(0), Some(&old_key)); - // Version 1 should use new key - assert_eq!(state.decryption_key(1), Some(&new_key)); - // Unknown versions return None - assert_eq!(state.decryption_key(2), None); - } - - #[test] - fn test_key_rotation_complete() { - let old_key = [0xAA; 32]; - let new_key = [0xBB; 32]; - - let mut state = KeyRotationState::new(old_key); - state.start_rotation(new_key); - state.complete_rotation(); - - assert_eq!(state.new_key, new_key); - assert_eq!(state.old_key, None); - assert!(!state.rotation_active); - } - - #[test] - fn test_encryption_always_uses_new_key() { - let old_key = [0xAA; 32]; - let new_key = [0xBB; 32]; - - let mut state = KeyRotationState::new(old_key); - state.start_rotation(new_key); - - // Encryption should always use new key - assert_eq!(state.encryption_key(), &new_key); - } - - /// Test that KeyRotationState can be created and dropped. - /// The actual memory zeroization is verified by the zeroize crate's guarantees. - /// We cannot verify memory is zeroed in safe Rust. - #[test] - fn test_key_rotation_state_zeroization_drop() { - // Create state with key material - let key = [0xDE; 32]; - let old_key = [0xAD; 32]; - - { - let mut state = KeyRotationState::new(key); - state.start_rotation(old_key); - - // Verify keys are set - assert_eq!(state.new_key, old_key); - assert_eq!(state.old_key, Some(key)); - assert!(state.rotation_active); - - // State drops here - ZeroizeOnDrop should securely erase key material - } - - // If we got here, drop was called successfully. - // Actual memory zeroization relies on zeroize crate correctness. - } - - /// Compile-time verification that Clone is NOT implemented. - /// This test documents the compile_fail doctest on the struct. - /// The actual verification is done by the compile_fail doctest on KeyRotationState. - #[test] - fn test_clone_not_implemented() { - // Note: We can't easily assert !Clone in stable Rust without negative trait bounds. - // The compile_fail doctest on KeyRotationState verifies Clone is unavailable. - // This test exists to document that behavior and ensure the tests module compiles. - } -} diff --git a/src/encryption/keyring.rs b/src/encryption/keyring.rs new file mode 100644 index 0000000..73e658a --- /dev/null +++ b/src/encryption/keyring.rs @@ -0,0 +1,421 @@ +//! Multi-key decrypt keyring for master-key rotation +//! +//! Implements the client-side keyring from the protocol spec +//! (`spec/encryption.md` → "Key Rotation (Keyring)", decision record +//! `decisions/key-rotation.md`): one **current** master key that encrypts and +//! decrypts, plus an ordered list of at most [`MAX_DECRYPT_ONLY_KEYS`] +//! **decrypt-only** master keys retained during a rotation grace window. +//! +//! Rotation state is configuration, not a state machine: writes always use the +//! current key; reads attempt keyring keys sequentially, current first, with +//! identical AAD per attempt. Old-key entries age out via TTL or re-encrypt on +//! the next write. Nothing on the wire changes — the ciphertext format and AAD +//! carry no key identity. +//! +//! All master-key material held by the keyring zeroizes on drop, decrypt-only +//! entries included, so SDK bindings can keep every keyring key behind the +//! native boundary. + +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use super::core::{EncryptionError, ZeroKnowledgeEncryptor}; +use super::key_derivation::{derive_domain_key, key_fingerprint}; +use super::KeyDomain; + +/// Maximum number of decrypt-only keys a keyring accepts. +/// +/// Bounds worst-case sequential decrypt attempts and resident key material +/// while still allowing a forced mid-window second rotation (e.g. an +/// offboarding landing during a long-TTL compliance window). Exceeding the cap +/// is a configuration error, rejected at construction — never truncated. +pub const MAX_DECRYPT_ONLY_KEYS: usize = 3; + +/// A master-key keyring: one current key plus decrypt-only previous keys. +/// +/// Each entry independently derives per-tenant keys via the crate's HKDF +/// construction ([`derive_domain_key`]); salts, domains, and fingerprints are +/// unchanged from single-key operation. Entry order is current key first, then +/// the decrypt-only keys in the order supplied. +/// +/// # Invariants (enforced at construction) +/// +/// - At most [`MAX_DECRYPT_ONLY_KEYS`] decrypt-only keys +/// ([`EncryptionError::KeyringCapExceeded`]). +/// - The current key must not appear in the decrypt-only list — the detectable +/// subset of the forward-only rule: a key that ever occupied the encrypting +/// slot is never re-promoted, because that would resume a used, unknowable +/// AES-GCM nonce budget ([`EncryptionError::CurrentKeyInDecryptOnlyList`]). +/// - Every key is at least 16 bytes +/// ([`EncryptionError::InvalidMasterKeyLength`]). +/// +/// # Examples +/// +/// A value encrypted under a retiring key stays readable through rotation as +/// long as that key remains in the decrypt-only list: +/// +/// ``` +/// 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")?; +/// +/// // ...still decrypts with keyring [current=k2, decrypt-only=[k1]]. +/// let keyring = Keyring::new(&k2, &[&k1])?; +/// let plaintext = keyring.decrypt(&encryptor, &ciphertext, "tenant-123", b"aad")?; +/// assert_eq!(plaintext, b"cached value"); +/// +/// // A hard cut-over (empty decrypt-only list) cannot read the old entry. +/// let cut_over = Keyring::new(&k2, &[])?; +/// assert!(cut_over.decrypt(&encryptor, &ciphertext, "tenant-123", b"aad").is_err()); +/// # Ok::<(), Box>(()) +/// ``` +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct Keyring { + current: Vec, + decrypt_only: Vec>, +} + +impl Keyring { + /// Create a keyring from a current master key and decrypt-only master keys. + /// + /// `decrypt_only` is ordered: on sequential decrypt, keys are attempted + /// current first, then in the order given here. + /// + /// # Errors + /// + /// - [`EncryptionError::KeyringCapExceeded`] if more than + /// [`MAX_DECRYPT_ONLY_KEYS`] decrypt-only keys are supplied. + /// - [`EncryptionError::CurrentKeyInDecryptOnlyList`] if the current key + /// also appears in the decrypt-only list. + /// - [`EncryptionError::InvalidMasterKeyLength`] if any key is shorter + /// than 16 bytes. + pub fn new(current: &[u8], decrypt_only: &[&[u8]]) -> Result { + if decrypt_only.len() > MAX_DECRYPT_ONLY_KEYS { + return Err(EncryptionError::KeyringCapExceeded(decrypt_only.len())); + } + + for key in std::iter::once(current).chain(decrypt_only.iter().copied()) { + if key.len() < 16 { + return Err(EncryptionError::InvalidMasterKeyLength(key.len())); + } + } + + // Plain equality is fine here: both operands are operator-supplied + // configuration this process already holds, so there is no timing + // oracle — this is config validation, not a secret comparison. + if decrypt_only.contains(¤t) { + return Err(EncryptionError::CurrentKeyInDecryptOnlyList); + } + + Ok(Self { + current: current.to_vec(), + decrypt_only: decrypt_only.iter().map(|key| key.to_vec()).collect(), + }) + } + + /// Total number of keyring entries (1 current + decrypt-only keys). + /// + /// Private on purpose: bindings that need the count get it as + /// `encryption_fingerprints().len()`, which they must fetch for selection + /// anyway. + fn entry_count(&self) -> usize { + 1 + self.decrypt_only.len() + } + + /// Keyring entries in attempt order: current key first. + fn entries(&self) -> impl Iterator { + std::iter::once(self.current.as_slice()) + .chain(self.decrypt_only.iter().map(|key| key.as_slice())) + } + + /// Per-entry fingerprints of the HKDF-derived per-tenant **encryption** + /// key, in attempt order (current key first). + /// + /// The fingerprint is computed over the derived per-tenant encryption key, + /// not the master key — this matches the per-entry key fingerprint that + /// cachekit-py stores as frame metadata, so fingerprint-based keyring + /// selection compares like with like. + pub fn encryption_fingerprints( + &self, + tenant_id: &str, + ) -> Result, EncryptionError> { + self.entries() + .map(|master| { + let mut key = derive_encryption_key(master, tenant_id)?; + let fingerprint = key_fingerprint(&key); + key.zeroize(); + Ok(fingerprint) + }) + .collect() + } + + /// Decrypt with a specific keyring entry (0 = current key). + /// + /// For fingerprint-based selection: match the entry via + /// [`encryption_fingerprints`](Self::encryption_fingerprints), then decrypt + /// with exactly that entry. A fingerprint match is binding — if the matched + /// key fails AES-GCM authentication the failure is terminal; do not fall + /// back to other entries. + /// + /// # Errors + /// + /// - [`EncryptionError::KeyringIndexOutOfRange`] for an out-of-range index + /// — a caller bug, deliberately distinct from any crypto failure. + /// - [`EncryptionError::KeyDerivation`] if per-tenant key derivation fails + /// (e.g. an invalid `tenant_id`) — a configuration error, not a miss. + /// - [`EncryptionError::AuthenticationFailed`] when this entry's key does + /// not authenticate the ciphertext. + /// - [`EncryptionError::InvalidCiphertext`] for malformed ciphertext. + pub fn decrypt_at( + &self, + index: usize, + encryptor: &ZeroKnowledgeEncryptor, + ciphertext: &[u8], + tenant_id: &str, + aad: &[u8], + ) -> Result, EncryptionError> { + let master = self + .entries() + .nth(index) + .ok_or(EncryptionError::KeyringIndexOutOfRange { + index, + count: self.entry_count(), + })?; + let mut key = derive_encryption_key(master, tenant_id)?; + let result = encryptor.decrypt_aes_gcm(ciphertext, &key, aad); + key.zeroize(); + result + } + + /// Decrypt by sequential keyring attempts: current key first, then each + /// decrypt-only key in order, rebuilding nothing between attempts — every + /// attempt uses the identical `aad`. + /// + /// Only an AES-GCM authentication failure (the wrong-key signal) advances + /// to the next key. Structural errors (e.g. ciphertext too short) are + /// terminal immediately: they would fail identically under every key. + /// + /// # Errors + /// + /// - [`EncryptionError::AuthenticationFailed`] when no keyring key decrypts + /// the ciphertext — the caller's existing fail-open / fail-closed policy + /// applies, no new failure mode. + /// - Terminal (never retried across keys): structural ciphertext errors + /// ([`EncryptionError::InvalidCiphertext`]) and configuration errors + /// ([`EncryptionError::KeyDerivation`], e.g. an invalid `tenant_id`). + pub fn decrypt( + &self, + encryptor: &ZeroKnowledgeEncryptor, + ciphertext: &[u8], + tenant_id: &str, + aad: &[u8], + ) -> Result, EncryptionError> { + for index in 0..self.entry_count() { + match self.decrypt_at(index, encryptor, ciphertext, tenant_id, aad) { + Err(EncryptionError::AuthenticationFailed) => continue, + other => return other, + } + } + Err(EncryptionError::AuthenticationFailed) + } +} + +/// Derive the per-tenant encryption key for one keyring entry. +/// +/// Identical to the `encryption_key` produced by +/// [`derive_tenant_keys`](super::key_derivation::derive_tenant_keys) — same +/// HKDF construction, same salt/domain — so keyring-derived keys and +/// fingerprints agree byte-for-byte with single-key operation. +fn derive_encryption_key(master: &[u8], tenant_id: &str) -> Result<[u8; 32], EncryptionError> { + // Surfaces as EncryptionError::KeyDerivation — a configuration error kept + // deliberately distinct from AuthenticationFailed/DecryptionFailed so a bad + // tenant_id cannot masquerade as a cache miss under fail-open policies. + Ok(derive_domain_key( + master, + KeyDomain::Encryption.as_str(), + tenant_id.as_bytes(), + )?) +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::super::key_derivation::derive_tenant_keys; + use super::*; + + const K1: [u8; 32] = [0x11; 32]; + const K2: [u8; 32] = [0x22; 32]; + const TENANT: &str = "tenant-123"; + const AAD: &[u8] = b"test_aad"; + + fn encrypt_under(master: &[u8], plaintext: &[u8]) -> Vec { + let encryptor = ZeroKnowledgeEncryptor::new().unwrap(); + let key = derive_encryption_key(master, TENANT).unwrap(); + encryptor.encrypt_aes_gcm(plaintext, &key, AAD).unwrap() + } + + #[test] + fn test_previous_key_entry_decrypts_after_rotation() { + // AC: value encrypted under k1 decrypts with keyring [current=k2, prev=[k1]] ... + let ciphertext = encrypt_under(&K1, b"secret"); + let encryptor = ZeroKnowledgeEncryptor::new().unwrap(); + + let keyring = Keyring::new(&K2, &[&K1]).unwrap(); + let plaintext = keyring + .decrypt(&encryptor, &ciphertext, TENANT, AAD) + .unwrap(); + assert_eq!(plaintext, b"secret"); + + // ... and the same value FAILS with keyring [current=k2, prev=[]] (hard cut-over) + let cut_over = Keyring::new(&K2, &[]).unwrap(); + let result = cut_over.decrypt(&encryptor, &ciphertext, TENANT, AAD); + assert!(matches!(result, Err(EncryptionError::AuthenticationFailed))); + } + + #[test] + fn test_current_key_decrypts_first() { + let ciphertext = encrypt_under(&K2, b"fresh write"); + let encryptor = ZeroKnowledgeEncryptor::new().unwrap(); + + let keyring = Keyring::new(&K2, &[&K1]).unwrap(); + // Entry 0 is the current key — decrypt_at(0) must succeed directly. + let plaintext = keyring + .decrypt_at(0, &encryptor, &ciphertext, TENANT, AAD) + .unwrap(); + assert_eq!(plaintext, b"fresh write"); + } + + #[test] + fn test_cap_rejected_never_truncated() { + // AC: more than MAX_DECRYPT_ONLY_KEYS decrypt-only keys is an error. + let a = [0x01u8; 32]; + let b = [0x02u8; 32]; + let c = [0x03u8; 32]; + let d = [0x04u8; 32]; + + // At the cap: fine. + assert!(Keyring::new(&K2, &[&a, &b, &c]).is_ok()); + + // One over the cap: rejected with the offending count, never truncated. + let result = Keyring::new(&K2, &[&a, &b, &c, &d]); + assert!(matches!( + result, + Err(EncryptionError::KeyringCapExceeded(4)) + )); + } + + #[test] + fn test_current_key_in_decrypt_only_list_rejected() { + // AC: detectable subset of the forward-only invariant. + let result = Keyring::new(&K2, &[&K1, &K2]); + assert!(matches!( + result, + Err(EncryptionError::CurrentKeyInDecryptOnlyList) + )); + } + + #[test] + fn test_short_master_key_rejected() { + let short = [0x01u8; 15]; + assert!(matches!( + Keyring::new(&short, &[]), + Err(EncryptionError::InvalidMasterKeyLength(15)) + )); + assert!(matches!( + Keyring::new(&K2, &[&short[..]]), + Err(EncryptionError::InvalidMasterKeyLength(15)) + )); + } + + #[test] + fn test_fingerprints_are_derived_key_fingerprints() { + // AC: per-entry fingerprint == fingerprint of that entry's derived + // tenant_keys.encryption_key (NOT the master key), in attempt order. + let keyring = Keyring::new(&K2, &[&K1]).unwrap(); + let fingerprints = keyring.encryption_fingerprints(TENANT).unwrap(); + + let k2_tenant = derive_tenant_keys(&K2, TENANT).unwrap(); + let k1_tenant = derive_tenant_keys(&K1, TENANT).unwrap(); + + assert_eq!(fingerprints.len(), 2); + assert_eq!(fingerprints[0], k2_tenant.encryption_fingerprint()); + assert_eq!(fingerprints[1], k1_tenant.encryption_fingerprint()); + + // And explicitly NOT the master-key fingerprints. + assert_ne!(fingerprints[0], key_fingerprint(&K2)); + assert_ne!(fingerprints[1], key_fingerprint(&K1)); + } + + #[test] + fn test_identical_aad_required_across_all_attempts() { + // Spec: sequential attempts rebuild the identical AAD; a different AAD + // must fail even though the encrypting key is present in the keyring. + let ciphertext = encrypt_under(&K1, b"secret"); + let encryptor = ZeroKnowledgeEncryptor::new().unwrap(); + + let keyring = Keyring::new(&K2, &[&K1]).unwrap(); + let result = keyring.decrypt(&encryptor, &ciphertext, TENANT, b"different_aad"); + assert!(matches!(result, Err(EncryptionError::AuthenticationFailed))); + } + + #[test] + fn test_structural_error_is_terminal() { + // A too-short ciphertext is not a wrong-key signal — it must surface + // as InvalidCiphertext, not be retried into AuthenticationFailed. + let encryptor = ZeroKnowledgeEncryptor::new().unwrap(); + let keyring = Keyring::new(&K2, &[&K1]).unwrap(); + + let result = keyring.decrypt(&encryptor, b"too short", TENANT, AAD); + assert!(matches!(result, Err(EncryptionError::InvalidCiphertext(_)))); + } + + #[test] + fn test_decrypt_at_out_of_range() { + let encryptor = ZeroKnowledgeEncryptor::new().unwrap(); + let keyring = Keyring::new(&K2, &[]).unwrap(); + let ciphertext = encrypt_under(&K2, b"x"); + + let result = keyring.decrypt_at(1, &encryptor, &ciphertext, TENANT, AAD); + assert!(matches!( + result, + Err(EncryptionError::KeyringIndexOutOfRange { index: 1, count: 1 }) + )); + } + + #[test] + fn test_bad_tenant_id_is_config_error_not_miss() { + // A derivation failure (empty tenant_id) must surface as KeyDerivation, + // never as AuthenticationFailed — a bad config cannot masquerade as a + // cache miss under a fail-open SDK policy. + let ciphertext = encrypt_under(&K2, b"x"); + let encryptor = ZeroKnowledgeEncryptor::new().unwrap(); + let keyring = Keyring::new(&K2, &[&K1]).unwrap(); + + let result = keyring.decrypt(&encryptor, &ciphertext, "", AAD); + assert!(matches!(result, Err(EncryptionError::KeyDerivation(_)))); + } + + #[test] + fn test_all_key_material_zeroizes() { + // AC: keyring key material zeroizes, decrypt-only entries included. + // ZeroizeOnDrop runs this same Zeroize impl on drop; verifying the + // explicit zeroize() proves every field is covered (reading freed + // memory after an actual drop would be UB). + let mut keyring = Keyring::new(&K2, &[&K1]).unwrap(); + keyring.zeroize(); + + assert!(keyring.current.iter().all(|&b| b == 0) || keyring.current.is_empty()); + assert!(keyring + .decrypt_only + .iter() + .all(|key| key.iter().all(|&b| b == 0) || key.is_empty())); + + // Compile-time proof the drop guarantee exists at all. + fn assert_zeroize_on_drop() {} + assert_zeroize_on_drop::(); + } +} diff --git a/src/encryption/mod.rs b/src/encryption/mod.rs index eb65299..8bbd346 100644 --- a/src/encryption/mod.rs +++ b/src/encryption/mod.rs @@ -12,15 +12,12 @@ pub mod core; pub mod key_derivation; -pub mod key_rotation; +pub mod keyring; // Re-exports for convenience pub use core::{EncryptionError, ZeroKnowledgeEncryptor}; pub use key_derivation::{derive_domain_key, KeyDerivationError}; -pub use key_rotation::{KeyRotationState, RotationAwareHeader}; - -// RotationAwareHeader is the canonical encryption header -pub type EncryptionHeader = RotationAwareHeader; +pub use keyring::{Keyring, MAX_DECRYPT_ONLY_KEYS}; /// Domain contexts for key derivation #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -47,32 +44,6 @@ impl KeyDomain { mod tests { use super::*; - #[test] - fn test_encryption_header_roundtrip() { - // RotationAwareHeader (now canonical EncryptionHeader) with version 0 for non-rotated - let header = RotationAwareHeader::new([0x12; 16], [0x34; 8], *b"ench", 0); - - let bytes = header.to_bytes(); - let decoded = RotationAwareHeader::from_bytes(&bytes).unwrap(); - - assert_eq!(decoded.version, 1); - assert_eq!(decoded.key_fingerprint, [0x12; 16]); - assert_eq!(decoded.domain, *b"ench"); - assert_eq!(decoded.key_version, 0); // Non-rotated data - // Verify algorithm is always AES-256-GCM (byte value 0) - assert_eq!(bytes[1], 0); - } - - #[test] - fn test_unsupported_algorithm_rejected() { - let mut bytes = [0u8; RotationAwareHeader::SIZE]; - bytes[0] = 1; // version - bytes[1] = 99; // unsupported algorithm - - let result = RotationAwareHeader::from_bytes(&bytes); - assert!(result.is_err()); - } - #[test] fn test_domain_strings() { assert_eq!(KeyDomain::Encryption.as_str(), "encryption"); diff --git a/src/ffi/error.rs b/src/ffi/error.rs index cbdff8b..2d785ef 100644 --- a/src/ffi/error.rs +++ b/src/ffi/error.rs @@ -58,7 +58,13 @@ impl From for CachekitError { EncryptionError::UnsupportedAlgorithm(_) => CachekitError::InvalidInput, EncryptionError::AuthenticationFailed => CachekitError::DecryptionFailed, EncryptionError::NonceCounterExhausted => CachekitError::CounterExhausted, - EncryptionError::NotImplemented(_) => CachekitError::InvalidInput, + EncryptionError::InvalidMasterKeyLength(_) => CachekitError::InvalidKeyLength, + EncryptionError::KeyringCapExceeded(_) => CachekitError::InvalidInput, + EncryptionError::CurrentKeyInDecryptOnlyList => CachekitError::InvalidInput, + // Caller bugs / config errors — deliberately NOT DecryptionFailed, + // so fail-open callers cannot mistake them for cache misses. + EncryptionError::KeyringIndexOutOfRange { .. } => CachekitError::InvalidInput, + EncryptionError::KeyDerivation(e) => e.into(), } } } diff --git a/src/lib.rs b/src/lib.rs index ba0dc8f..79466f3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -80,8 +80,8 @@ pub use byte_storage::{ByteStorage, StorageEnvelope}; pub mod encryption; #[cfg(feature = "encryption")] pub use encryption::{ - derive_domain_key, EncryptionError, EncryptionHeader, KeyDerivationError, KeyDomain, - KeyRotationState, RotationAwareHeader, ZeroKnowledgeEncryptor, + derive_domain_key, EncryptionError, KeyDerivationError, KeyDomain, Keyring, + ZeroKnowledgeEncryptor, MAX_DECRYPT_ONLY_KEYS, }; // C FFI layer (feature-gated)