diff --git a/.secrets.baseline b/.secrets.baseline index 20ad116..4ecd569 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -173,14 +173,14 @@ "filename": "packages/cachekit/src/intents.test.ts", "hashed_secret": "42c48ae0d1c6bc8d47b3b25fdcf2eb1156cd0c6a", "is_verified": false, - "line_number": 206 + "line_number": 249 }, { "type": "Secret Keyword", "filename": "packages/cachekit/src/intents.test.ts", "hashed_secret": "18060b49185cba9a51b0d10290136007c3c8ab00", "is_verified": false, - "line_number": 242 + "line_number": 285 } ], "packages/cachekit/test/protocol/cross-sdk-interop.protocol.test.ts": [ @@ -782,5 +782,5 @@ } ] }, - "generated_at": "2026-07-29T00:08:01Z" + "generated_at": "2026-08-07T14:22:29Z" } diff --git a/packages/cachekit-core-ts/Cargo.lock b/packages/cachekit-core-ts/Cargo.lock index 2ca505b..3031c03 100644 --- a/packages/cachekit-core-ts/Cargo.lock +++ b/packages/cachekit-core-ts/Cargo.lock @@ -130,9 +130,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cachekit-core" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aba1513135a7b92a124ad6983f7e80e5f5c78c4f9c74384079efa3fbf491eab" +checksum = "12089baacc5ff661a62d2071588c895973bc48e42ed359178afaee22decb5559" dependencies = [ "aes", "aes-gcm", diff --git a/packages/cachekit-core-ts/Cargo.toml b/packages/cachekit-core-ts/Cargo.toml index 087ce08..4e573d4 100644 --- a/packages/cachekit-core-ts/Cargo.toml +++ b/packages/cachekit-core-ts/Cargo.toml @@ -10,7 +10,7 @@ crate-type = ["cdylib"] [dependencies] napi = { version = "3", features = ["napi6"] } napi-derive = "3" -cachekit-core = { version = "0.4.0", features = ["encryption"] } +cachekit-core = { version = "0.5.0", features = ["encryption"] } [build-dependencies] napi-build = "2" diff --git a/packages/cachekit-core-ts/README.md b/packages/cachekit-core-ts/README.md index cef5567..b205148 100644 --- a/packages/cachekit-core-ts/README.md +++ b/packages/cachekit-core-ts/README.md @@ -31,7 +31,9 @@ If your platform isn't listed, the package will fail to load at runtime. Open an Public exports (consumed by `@cachekit-io/cachekit`): - `ByteStorage` — LZ4 compression + xxHash3-64 integrity envelope -- `TenantKeys` — HKDF-SHA256 per-tenant derived keys with `ZeroizeOnDrop` +- `TenantKeys` — HKDF-SHA256 per-tenant derived keys with `ZeroizeOnDrop`; + optionally holds a decrypt-only keyring (max 3 previous master keys) for + key-rotation grace windows — sequential decrypt attempts, current key first - `deriveKey` — single-domain HKDF key derivation - `encrypt` / `decrypt` — AES-256-GCM with AAD binding - `version` — version string from the underlying Cargo crate diff --git a/packages/cachekit-core-ts/index.d.ts b/packages/cachekit-core-ts/index.d.ts index 40a922a..5a853d6 100644 --- a/packages/cachekit-core-ts/index.d.ts +++ b/packages/cachekit-core-ts/index.d.ts @@ -89,6 +89,16 @@ export declare class TenantKeys { * This matches Python's ZeroKnowledgeEncryptor.get_nonce_counter(). */ getNonceCounter(): number + /** + * Number of keyring entries built at derivation (1 current key + + * decrypt-only previous keys). + * + * The SDK asserts this equals `1 + previousMasterKeys.length` right + * after deriveTenantKeys: a version-skewed native binary that ignored + * the keyring argument would otherwise silently decrypt with the + * current key only, turning every pre-rotation entry into a miss. + */ + keyringEntryCount(): number } /** @@ -96,6 +106,11 @@ export declare class TenantKeys { * * Uses the encryptor stored in TenantKeys for consistency. * + * With previous master keys configured (rotation grace window), decryption + * runs cachekit-core's keyring loop: sequential attempts, current key + * first, identical AAD every attempt. Only an AES-GCM authentication + * failure advances to the next key; structural errors are terminal. + * * # Arguments * * `ciphertext` - Previously encrypted data * * `aad` - Must match AAD used during encryption @@ -145,17 +160,30 @@ export declare function deriveKey(masterKey: Uint8Array, domain: string, tenantS * # Arguments * * `master_key` - 32-byte master encryption key * * `tenant_id` - Tenant identifier for key isolation + * * `previous_master_keys` - Optional decrypt-only previous master keys + * (max 3, each 32 bytes) retained during a key-rotation grace window. + * Reads attempt keys sequentially, current first, identical AAD per + * attempt (protocol `spec/encryption.md` → "Key Rotation (Keyring)"). + * Writes always use `master_key`. * * # Returns * TenantKeys object with derived keys (stays in Rust memory) * + * # Errors + * Returns InvalidArg if any key has the wrong length, more than 3 previous + * keys are supplied (rejected, never truncated), or `master_key` also + * appears in `previous_master_keys` (forward-only rule: a key that ever + * encrypted is never re-promoted). + * * # Example * ```javascript * const masterKey = Buffer.from(process.env.MASTER_KEY, 'hex'); * const tenantKeys = deriveTenantKeys(masterKey, 'tenant-123'); + * // During a rotation grace window: + * const rotating = deriveTenantKeys(newKey, 'tenant-123', [oldKey]); * ``` */ -export declare function deriveTenantKeys(masterKey: Uint8Array, tenantId: string): TenantKeys +export declare function deriveTenantKeys(masterKey: Uint8Array, tenantId: string, previousMasterKeys?: Array | undefined | null): TenantKeys /** * Encrypt plaintext using TenantKeys (keys stay in Rust memory). diff --git a/packages/cachekit-core-ts/src/lib.rs b/packages/cachekit-core-ts/src/lib.rs index e94586c..bd8b80c 100644 --- a/packages/cachekit-core-ts/src/lib.rs +++ b/packages/cachekit-core-ts/src/lib.rs @@ -6,7 +6,7 @@ use napi_derive::napi; use cachekit_core::encryption::key_derivation::{ derive_tenant_keys as core_derive_tenant_keys, TenantKeys as CoreTenantKeys, }; -use cachekit_core::encryption::{derive_domain_key, ZeroKnowledgeEncryptor}; +use cachekit_core::encryption::{derive_domain_key, Keyring, ZeroKnowledgeEncryptor}; use cachekit_core::ByteStorage as CoreByteStorage; // Security limits to prevent DoS @@ -233,6 +233,15 @@ pub struct TenantKeys { /// Shared encryptor for consistent nonce tracking across operations. /// Matches Python pattern where each EncryptionWrapper has ONE encryptor. encryptor: ZeroKnowledgeEncryptor, + /// Decrypt keyring, present only during a rotation grace window + /// (previousMasterKeys configured). None keeps the single-key decrypt + /// path on the pre-derived tenant key. All keyring material zeroizes + /// on drop inside cachekit-core. + keyring: Option, + /// Keyring entries actually built (1 current + decrypt-only keys). + /// Exposed so the SDK can attest that rotation config survived the + /// FFI boundary — an older binding would silently drop the argument. + keyring_entries: u32, } #[napi] @@ -257,6 +266,18 @@ impl TenantKeys { pub fn get_nonce_counter(&self) -> i64 { self.encryptor.get_nonce_counter() as i64 } + + /// Number of keyring entries built at derivation (1 current key + + /// decrypt-only previous keys). + /// + /// The SDK asserts this equals `1 + previousMasterKeys.length` right + /// after deriveTenantKeys: a version-skewed native binary that ignored + /// the keyring argument would otherwise silently decrypt with the + /// current key only, turning every pre-rotation entry into a miss. + #[napi] + pub fn keyring_entry_count(&self) -> u32 { + self.keyring_entries + } } /// Derive per-tenant keys using HKDF-SHA256. @@ -269,17 +290,34 @@ impl TenantKeys { /// # Arguments /// * `master_key` - 32-byte master encryption key /// * `tenant_id` - Tenant identifier for key isolation +/// * `previous_master_keys` - Optional decrypt-only previous master keys +/// (max 3, each 32 bytes) retained during a key-rotation grace window. +/// Reads attempt keys sequentially, current first, identical AAD per +/// attempt (protocol `spec/encryption.md` → "Key Rotation (Keyring)"). +/// Writes always use `master_key`. /// /// # Returns /// TenantKeys object with derived keys (stays in Rust memory) /// +/// # Errors +/// Returns InvalidArg if any key has the wrong length, more than 3 previous +/// keys are supplied (rejected, never truncated), or `master_key` also +/// appears in `previous_master_keys` (forward-only rule: a key that ever +/// encrypted is never re-promoted). +/// /// # Example /// ```javascript /// const masterKey = Buffer.from(process.env.MASTER_KEY, 'hex'); /// const tenantKeys = deriveTenantKeys(masterKey, 'tenant-123'); +/// // During a rotation grace window: +/// const rotating = deriveTenantKeys(newKey, 'tenant-123', [oldKey]); /// ``` #[napi] -pub fn derive_tenant_keys(master_key: Uint8Array, tenant_id: String) -> Result { +pub fn derive_tenant_keys( + master_key: Uint8Array, + tenant_id: String, + previous_master_keys: Option>, +) -> Result { if master_key.len() != 32 { return Err(Error::new( Status::InvalidArg, @@ -294,13 +332,44 @@ pub fn derive_tenant_keys(master_key: Uint8Array, tenant_id: String) -> Result = previous.iter().map(|k| k.as_ref()).collect(); + Some( + Keyring::new(&master_key, &refs) + .map_err(|e| Error::new(Status::InvalidArg, e.to_string()))?, + ) + }; + let inner = core_derive_tenant_keys(&master_key, &tenant_id) .map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?; let encryptor = ZeroKnowledgeEncryptor::new() .map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?; - Ok(TenantKeys { inner, encryptor }) + let keyring_entries = 1 + previous.len() as u32; + Ok(TenantKeys { + inner, + encryptor, + keyring, + keyring_entries, + }) } /// Encrypt plaintext using TenantKeys (keys stay in Rust memory). @@ -335,6 +404,11 @@ pub fn encrypt_with_tenant_keys( /// /// Uses the encryptor stored in TenantKeys for consistency. /// +/// With previous master keys configured (rotation grace window), decryption +/// runs cachekit-core's keyring loop: sequential attempts, current key +/// first, identical AAD every attempt. Only an AES-GCM authentication +/// failure advances to the next key; structural errors are terminal. +/// /// # Arguments /// * `ciphertext` - Previously encrypted data /// * `aad` - Must match AAD used during encryption @@ -350,9 +424,20 @@ pub fn decrypt_with_tenant_keys( ) -> Result { validate_decryption_input(ciphertext.len(), aad.len())?; - tenant_keys - .encryptor - .decrypt_aes_gcm(&ciphertext, &tenant_keys.inner.encryption_key, &aad) - .map(|plaintext| plaintext.into()) - .map_err(|e| Error::new(Status::GenericFailure, e.to_string())) + match &tenant_keys.keyring { + Some(keyring) => keyring + .decrypt( + &tenant_keys.encryptor, + &ciphertext, + &tenant_keys.inner.tenant_id, + &aad, + ) + .map(|plaintext| plaintext.into()) + .map_err(|e| Error::new(Status::GenericFailure, e.to_string())), + None => tenant_keys + .encryptor + .decrypt_aes_gcm(&ciphertext, &tenant_keys.inner.encryption_key, &aad) + .map(|plaintext| plaintext.into()) + .map_err(|e| Error::new(Status::GenericFailure, e.to_string())), + } } diff --git a/packages/cachekit-core-wasm/Cargo.lock b/packages/cachekit-core-wasm/Cargo.lock index a33fc20..65ba2d4 100644 --- a/packages/cachekit-core-wasm/Cargo.lock +++ b/packages/cachekit-core-wasm/Cargo.lock @@ -130,9 +130,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cachekit-core" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aba1513135a7b92a124ad6983f7e80e5f5c78c4f9c74384079efa3fbf491eab" +checksum = "12089baacc5ff661a62d2071588c895973bc48e42ed359178afaee22decb5559" dependencies = [ "aes", "aes-gcm", @@ -159,7 +159,9 @@ name = "cachekit-core-wasm" version = "0.1.0" dependencies = [ "cachekit-core", + "js-sys", "wasm-bindgen", + "zeroize", ] [[package]] diff --git a/packages/cachekit-core-wasm/Cargo.toml b/packages/cachekit-core-wasm/Cargo.toml index 004cd1d..593c6f4 100644 --- a/packages/cachekit-core-wasm/Cargo.toml +++ b/packages/cachekit-core-wasm/Cargo.toml @@ -18,7 +18,15 @@ crate-type = ["cdylib"] [dependencies] wasm-bindgen = "=0.2.121" -cachekit-core = { version = "0.4.0", features = ["encryption"] } +# js-sys ships from the wasm-bindgen workspace and tracks its ABI; needed to +# accept Uint8Array[] (previous master keys) across the boundary. Exact-pinned +# to the release paired with wasm-bindgen 0.2.121 above. +js-sys = "=0.3.98" +cachekit-core = { version = "0.5.0", features = ["encryption"] } +# Wipes the owned previous-master-key staging buffers on drop (the NAPI crate +# borrows and never copies; this crate must copy out of JS memory). Already in +# the tree transitively via cachekit-core — same resolved version. +zeroize = "1" # Standalone crate — never join an enclosing cargo workspace. [workspace] diff --git a/packages/cachekit-core-wasm/README.md b/packages/cachekit-core-wasm/README.md index 07cab55..b0a4f52 100644 --- a/packages/cachekit-core-wasm/README.md +++ b/packages/cachekit-core-wasm/README.md @@ -36,6 +36,13 @@ const tenantKeys = deriveTenantKeys(masterKeyBytes, 'tenant-123'); const ciphertext = encryptWithTenantKeys(plaintext, aad, tenantKeys); const plaintext2 = decryptWithTenantKeys(ciphertext, aad, tenantKeys); tenantKeys.free(); // zeroizes key material deterministically + +// Key-rotation grace window: up to 3 decrypt-only previous keys. Decrypt +// attempts keys sequentially (current first, identical AAD); encrypt always +// uses the current key. +const rotating = deriveTenantKeys(newKeyBytes, 'tenant-123', [oldKeyBytes]); +const old = decryptWithTenantKeys(oldCiphertext, aad, rotating); +rotating.free(); // zeroize the whole keyring when the grace window ends ``` ## Security notes diff --git a/packages/cachekit-core-wasm/index.d.ts b/packages/cachekit-core-wasm/index.d.ts index b02a07b..e85d81e 100644 --- a/packages/cachekit-core-wasm/index.d.ts +++ b/packages/cachekit-core-wasm/index.d.ts @@ -39,6 +39,11 @@ export declare class TenantKeys { encryptionFingerprint(): Uint8Array; /** Current nonce counter value — rotate before 2^32. */ getNonceCounter(): number; + /** + * Keyring entries built at derivation (1 current + decrypt-only previous + * keys) — SDK attestation that rotation config survived the boundary. + */ + keyringEntryCount(): number; } /** Derive a 32-byte domain key using HKDF-SHA256 (RFC 5869). */ @@ -48,8 +53,19 @@ export declare function deriveKey( tenantSalt: string ): Uint8Array; -/** Derive per-tenant keys (encryption / authentication / cache_keys domains). */ -export declare function deriveTenantKeys(masterKey: Uint8Array, tenantId: string): TenantKeys; +/** + * Derive per-tenant keys (encryption / authentication / cache_keys domains). + * + * `previousMasterKeys` (max 3, each 32 bytes) holds decrypt-only previous + * master keys retained during a key-rotation grace window: reads attempt + * keys sequentially, current first, identical AAD per attempt; writes always + * use `masterKey`. + */ +export declare function deriveTenantKeys( + masterKey: Uint8Array, + tenantId: string, + previousMasterKeys?: Uint8Array[] | null +): TenantKeys; /** Encrypt with AES-256-GCM: [nonce(12)][ciphertext][auth_tag(16)]. */ export declare function encryptWithTenantKeys( diff --git a/packages/cachekit-core-wasm/src/lib.rs b/packages/cachekit-core-wasm/src/lib.rs index c8ed8f0..a7a8f68 100644 --- a/packages/cachekit-core-wasm/src/lib.rs +++ b/packages/cachekit-core-wasm/src/lib.rs @@ -18,8 +18,9 @@ use wasm_bindgen::prelude::*; use cachekit_core::encryption::key_derivation::{ derive_tenant_keys as core_derive_tenant_keys, TenantKeys as CoreTenantKeys, }; -use cachekit_core::encryption::{derive_domain_key, ZeroKnowledgeEncryptor}; +use cachekit_core::encryption::{derive_domain_key, Keyring, ZeroKnowledgeEncryptor}; use cachekit_core::ByteStorage as CoreByteStorage; +use zeroize::Zeroizing; // Security limits to prevent DoS — identical to the NAPI crate. const MAX_PLAINTEXT_SIZE: usize = 100 * 1024 * 1024; // 100 MB @@ -109,11 +110,7 @@ impl ByteStorage { /// Key derivation using HKDF-SHA256 (RFC 5869). Same validation as NAPI. #[wasm_bindgen(js_name = deriveKey)] -pub fn derive_key( - master_key: &[u8], - domain: &str, - tenant_salt: &str, -) -> Result, JsError> { +pub fn derive_key(master_key: &[u8], domain: &str, tenant_salt: &str) -> Result, JsError> { if master_key.len() != 32 { return Err(JsError::new(&format!( "Master key must be 32 bytes, got {}", @@ -147,6 +144,15 @@ pub fn derive_key( pub struct TenantKeys { inner: CoreTenantKeys, encryptor: ZeroKnowledgeEncryptor, + /// Decrypt keyring, present only during a rotation grace window + /// (previousMasterKeys configured). None keeps the single-key decrypt + /// path on the pre-derived tenant key. All keyring material zeroizes + /// on drop inside cachekit-core. + keyring: Option, + /// Keyring entries actually built (1 current + decrypt-only keys). + /// Exposed so the SDK can attest that rotation config survived the + /// FFI boundary — an older binding would silently drop the argument. + keyring_entries: u32, } #[wasm_bindgen] @@ -171,6 +177,14 @@ impl TenantKeys { pub fn get_nonce_counter(&self) -> f64 { self.encryptor.get_nonce_counter() as f64 } + + /// Number of keyring entries built at derivation (1 current key + + /// decrypt-only previous keys) — SDK attestation that rotation config + /// survived the boundary; identical to the NAPI binding. + #[wasm_bindgen(js_name = keyringEntryCount)] + pub fn keyring_entry_count(&self) -> u32 { + self.keyring_entries + } } /// Derive per-tenant keys using HKDF-SHA256. @@ -178,8 +192,20 @@ impl TenantKeys { /// Matches Python's `derive_tenant_keys()` and the NAPI binding exactly: /// encryption_key ("encryption"), authentication_key ("authentication"), /// cache_key_salt ("cache_keys"). +/// `previous_master_keys` (optional) holds decrypt-only previous master keys +/// (max 3, each 32 bytes) retained during a key-rotation grace window — +/// identical semantics to the NAPI binding. #[wasm_bindgen(js_name = deriveTenantKeys)] -pub fn derive_tenant_keys(master_key: &[u8], tenant_id: &str) -> Result { +pub fn derive_tenant_keys( + master_key: js_sys::Uint8Array, + tenant_id: &str, + previous_master_keys: Option>, +) -> Result { + // Taken as a JS handle, not &[u8]: the &[u8] ABI would copy the current + // master key into linear memory and free it unwiped. Copying here under + // Zeroizing keeps every staging copy wiped on all return paths — same + // treatment as the previous keys below. + let master_key = Zeroizing::new(master_key.to_vec()); if master_key.len() != 32 { return Err(JsError::new(&format!( "Master key must be exactly 32 bytes, got {}", @@ -190,11 +216,43 @@ pub fn derive_tenant_keys(master_key: &[u8], tenant_id: &str) -> Result>> = previous_master_keys + .unwrap_or_default() + .iter() + .map(|key| Zeroizing::new(key.to_vec())) + .collect(); + for key in &previous { + if key.len() != 32 { + return Err(JsError::new(&format!( + "Previous master key must be exactly 32 bytes, got {}", + key.len() + ))); + } + } + // Keyring only exists during a rotation grace window; None keeps the + // pre-derived single-key decrypt path. Keyring::new re-validates the + // cap (3) and the current-key collision (config errors). + let keyring = if previous.is_empty() { + None + } else { + let refs: Vec<&[u8]> = previous.iter().map(|k| k.as_slice()).collect(); + Some(Keyring::new(&master_key, &refs).map_err(|e| JsError::new(&e.to_string()))?) + }; + + let inner = + core_derive_tenant_keys(&master_key, tenant_id).map_err(|e| JsError::new(&e.to_string()))?; let encryptor = ZeroKnowledgeEncryptor::new().map_err(|e| JsError::new(&e.to_string()))?; - Ok(TenantKeys { inner, encryptor }) + let keyring_entries = 1 + previous.len() as u32; + Ok(TenantKeys { + inner, + encryptor, + keyring, + keyring_entries, + }) } /// Encrypt plaintext using TenantKeys (keys stay in wasm memory). @@ -216,6 +274,10 @@ pub fn encrypt_with_tenant_keys( } /// Decrypt ciphertext using TenantKeys (keys stay in wasm memory). +/// +/// With previous master keys configured (rotation grace window), decryption +/// runs cachekit-core's keyring loop: sequential attempts, current key +/// first, identical AAD every attempt — identical to the NAPI binding. #[wasm_bindgen(js_name = decryptWithTenantKeys)] pub fn decrypt_with_tenant_keys( ciphertext: &[u8], @@ -224,8 +286,18 @@ pub fn decrypt_with_tenant_keys( ) -> Result, JsError> { validate_decryption_input(ciphertext.len(), aad.len())?; - tenant_keys - .encryptor - .decrypt_aes_gcm(ciphertext, &tenant_keys.inner.encryption_key, aad) - .map_err(|e| JsError::new(&e.to_string())) + match &tenant_keys.keyring { + Some(keyring) => keyring + .decrypt( + &tenant_keys.encryptor, + ciphertext, + &tenant_keys.inner.tenant_id, + aad, + ) + .map_err(|e| JsError::new(&e.to_string())), + None => tenant_keys + .encryptor + .decrypt_aes_gcm(ciphertext, &tenant_keys.inner.encryption_key, aad) + .map_err(|e| JsError::new(&e.to_string())), + } } diff --git a/packages/cachekit/README.md b/packages/cachekit/README.md index 175c28f..8b65c6d 100644 --- a/packages/cachekit/README.md +++ b/packages/cachekit/README.md @@ -120,8 +120,9 @@ const cache = createCache({ // L1 holds the same ciphertext L2 does, so an L1 hit costs a decrypt and // AAD verify rather than being free, and no plaintext is resident in the // heap between reads. Matches cachekit-py and cachekit-rs. + // Key rotation: see "Master-Key Rotation" below. encryption: { - masterKey: process.env.CACHEKIT_MASTER_KEY!, // hex-encoded, 32+ bytes + masterKey: process.env.CACHEKIT_MASTER_KEY!, // hex-encoded, exactly 32 bytes tenantId: 'tenant-123', // for multi-tenant key isolation }, @@ -188,6 +189,45 @@ entry's cache file directly. (Backends have their own hard ceilings too: Workers KV values cap at 25 MiB, Memcached items at 1 MiB server-side, CachekitIO per plan.) +## Master-Key Rotation + +Rotate the encryption master key without invalidating existing entries: +configure up to **3** decrypt-only previous keys for the grace window. Reads +attempt keys sequentially (current key first, identical AAD every attempt); +writes always use the current `masterKey`. Old-key entries age out via TTL — +nothing is re-encrypted on read, and nothing on the wire changes. + +```typescript +// Grace window after promoting k2: old k1 entries stay readable +const cache = createCache.secure({ + url: 'redis://localhost:6379', + masterKey: process.env.CACHEKIT_MASTER_KEY!, // k2 (current) + previousMasterKeys: [process.env.OLD_MASTER_KEY!], // k1 (decrypt-only) +}); +// or: CACHEKIT_PREVIOUS_MASTER_KEYS=, (comma-separated) +``` + +The derived keyring lives behind the NAPI (or wasm) boundary and is zeroized +on dispose; the decoded key buffers are wiped as soon as the keyring is built. +The hex key strings themselves (config values, environment variables) live in +JavaScript memory and cannot be reliably scrubbed — treat them as sensitive +for the lifetime of the process. + +Rules enforced at load (`ConfigurationError`, never truncated or ignored): + +- Each previous key uses the same hex format and length as `masterKey`. +- More than 3 previous keys is rejected. +- `masterKey` must not appear in `previousMasterKeys` — **rotation is + forward-only, always to a NEW key**. A retired key is never re-promoted: + that would resume a used, unknowable AES-GCM nonce budget. + +Once a previous key is dropped from the list, entries written under it fail +authentication: a miss under the default graceful degradation, an +`EncryptionError` with `reliability: { degradation: false }`. + +Full choreography (three-phase zero-miss rotation, compromise response): +see the [key rotation runbook](https://docs.cachekit.io/concepts/key-rotation/). + ## Stampede Protection A cold cache key hit by N concurrent callers would normally execute the wrapped diff --git a/packages/cachekit/src/cache.rotation.test.ts b/packages/cachekit/src/cache.rotation.test.ts new file mode 100644 index 0000000..11ee614 --- /dev/null +++ b/packages/cachekit/src/cache.rotation.test.ts @@ -0,0 +1,171 @@ +/** + * End-to-end master-key rotation round-trip (LAB-685). + * + * Exercises the full operator flow from protocol decisions/key-rotation.md: + * a value written under k₁ stays readable after k₂ is promoted to masterKey + * with k₁ in previousMasterKeys — without re-encryption — and dropping k₁ + * makes the entry fail per the configured reliability policy (miss under + * degradation, EncryptionError without it). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { createCache } from './cache.js'; +import { EncryptionError } from './errors.js'; +import type { Backend } from './backends/types.js'; + +/** + * Deterministic test fixture, not a secret: a single byte repeated to the + * 32-byte master-key length. Real key material is never a repeated byte. + */ +const testMasterKeyHex = (byte: string): string => byte.repeat(32); + +const K1_HEX = testMasterKeyHex('11'); +const K2_HEX = testMasterKeyHex('22'); + +/** + * In-memory backend shared across cache instances. close() is deliberately + * a no-op: several caches share one store here, and the first cache.close() + * must not wipe the entries the next cache is about to read. + */ +class SharedBackend implements Backend { + private store = new Map(); + + async get(key: string): Promise { + return this.store.get(key) ?? null; + } + + async set(key: string, value: Uint8Array, _ttl: number): Promise { + this.store.set(key, value); + } + + async delete(key: string): Promise { + return this.store.delete(key); + } + + async exists(key: string): Promise { + return this.store.has(key); + } + + async close(): Promise { + // no-op: shared across cache instances + } + + snapshot(key: string): Uint8Array | undefined { + return this.store.get(key); + } +} + +describe('E2E key rotation round-trip', () => { + it('reads a k1 entry through the k2+[k1] keyring without re-encryption, then fails once k1 is dropped', async () => { + const backend = new SharedBackend(); + const setSpy = vi.spyOn(backend, 'set'); + const key = 'rotate:entry'; + const value = { user: 'ada', roles: ['admin'] }; + + // Phase 0: write under k1. + const before = createCache({ + backend, + encryption: { masterKey: K1_HEX }, + l1: { enabled: false }, + }); + await before.set(key, value); + await before.close(); + + const storedUnderK1 = backend.snapshot(key)!; + expect(setSpy).toHaveBeenCalledTimes(1); + + // Phase 1: rotation grace window — k2 current, k1 decrypt-only. + const during = createCache({ + backend, + encryption: { masterKey: K2_HEX, previousMasterKeys: [K1_HEX] }, + l1: { enabled: false }, + }); + await expect(during.get(key)).resolves.toEqual(value); + await during.close(); + + // No re-encryption on read: the backend saw no further write and the + // stored bytes are untouched (old entries age out via TTL by design). + expect(setSpy).toHaveBeenCalledTimes(1); + expect(backend.snapshot(key)).toBe(storedUnderK1); + + // Phase 2: k1 dropped — degradation (default) turns the decrypt + // failure into a miss. + const after = createCache({ + backend, + encryption: { masterKey: K2_HEX }, + l1: { enabled: false }, + }); + await expect(after.get(key)).resolves.toBeNull(); + await after.close(); + + // Same drop, fail-closed policy: the decrypt failure surfaces. + const afterStrict = createCache({ + backend, + encryption: { masterKey: K2_HEX }, + l1: { enabled: false }, + reliability: { degradation: false, retry: { maxAttempts: 1 } }, + }); + await expect(afterStrict.get(key)).rejects.toThrow(EncryptionError); + await afterStrict.close(); + }); + + it('serves a previous-key entry from L1 during the grace window', async () => { + // L1 holds ciphertext for a secure cache (LAB-238), so an L2 read under a + // previous key repopulates L1 with bytes the CURRENT key cannot open — + // every subsequent hit has to run the keyring loop again. If it did not, + // decodeL1Entry would drop the entry and fall through to L2 on every read + // for the whole grace window: a silent L1 bypass under degradation, and a + // throw on every old-key read without it. + const backend = new SharedBackend(); + const key = 'rotate:l1-entry'; + const value = { user: 'grace', roles: ['reader'] }; + + const before = createCache({ + backend, + encryption: { masterKey: K1_HEX }, + l1: { enabled: false }, + }); + await before.set(key, value); + await before.close(); + + const during = createCache({ + backend, + encryption: { masterKey: K2_HEX, previousMasterKeys: [K1_HEX] }, + l1: { enabled: true }, + }); + const getSpy = vi.spyOn(backend, 'get'); + + // First read comes from L2 and seeds L1 with the k1 ciphertext. + await expect(during.get(key)).resolves.toEqual(value); + expect(getSpy).toHaveBeenCalledTimes(1); + + // Second read is served from L1 — decrypted through the keyring, so the + // backend is never consulted again. + await expect(during.get(key)).resolves.toEqual(value); + expect(getSpy).toHaveBeenCalledTimes(1); + + await during.close(); + }); + + it('keeps new writes on the current key during the grace window', async () => { + const backend = new SharedBackend(); + const key = 'rotate:new-write'; + + const during = createCache({ + backend, + encryption: { masterKey: K2_HEX, previousMasterKeys: [K1_HEX] }, + l1: { enabled: false }, + }); + await during.set(key, 'fresh'); + await during.close(); + + // Readable with k2 alone — proof the write used the current key, not k1. + const cutOver = createCache({ + backend, + encryption: { masterKey: K2_HEX }, + l1: { enabled: false }, + }); + await expect(cutOver.get(key)).resolves.toBe('fresh'); + await cutOver.close(); + }); +}); diff --git a/packages/cachekit/src/cache.ts b/packages/cachekit/src/cache.ts index 075f3b2..a17666c 100644 --- a/packages/cachekit/src/cache.ts +++ b/packages/cachekit/src/cache.ts @@ -47,7 +47,8 @@ const nodeRuntime: CacheRuntime = { }, createMetrics: (config) => createMetrics(true, config), createByteStorage: () => new ByteStorage(), - createEncryption: (config) => new EncryptionManager(config.masterKey, config.tenantId), + createEncryption: (config) => + new EncryptionManager(config.masterKey, config.tenantId, config.previousMasterKeys), createInvalidationChannel: (config: InvalidationConfig) => new RedisInvalidationChannel(config.redis, { channelName: config.channelName }), }; diff --git a/packages/cachekit/src/constants.ts b/packages/cachekit/src/constants.ts index 9e73358..618882c 100644 --- a/packages/cachekit/src/constants.ts +++ b/packages/cachekit/src/constants.ts @@ -120,11 +120,19 @@ export const REDIS_RETRY_MAX_DELAY = 30000; /** AAD version byte (v0x03 includes cache_key binding) */ export const AAD_VERSION = 0x03; -/** Minimum master key length in bytes */ -export const MIN_MASTER_KEY_BYTES = 32; +/** Required master key length in bytes (exact — validation rejects any other length) */ +export const MASTER_KEY_BYTES = 32; -/** Minimum master key length in hex characters */ -export const MIN_MASTER_KEY_HEX_LENGTH = 64; +/** Required master key length in hex characters (exact) */ +export const MASTER_KEY_HEX_LENGTH = 64; + +/** + * Maximum decrypt-only previous master keys in a rotation keyring. + * Matches cachekit-core's MAX_DECRYPT_ONLY_KEYS (protocol spec/encryption.md + * → "Key Rotation (Keyring)"). Exceeding the cap is a configuration error, + * rejected at load — never truncated. + */ +export const MAX_PREVIOUS_MASTER_KEYS = 3; // ============================================================================ // Stampede / Single-Flight Constants diff --git a/packages/cachekit/src/encryption/manager-core.test.ts b/packages/cachekit/src/encryption/manager-core.test.ts index e260820..9949800 100644 --- a/packages/cachekit/src/encryption/manager-core.test.ts +++ b/packages/cachekit/src/encryption/manager-core.test.ts @@ -12,7 +12,7 @@ import { type EncryptionBindings, type EncryptionTenantKeys, } from './manager-core.js'; -import { EncryptionError, NonceExhaustedError } from '../errors.js'; +import { ConfigurationError, EncryptionError, NonceExhaustedError } from '../errors.js'; const MASTER_KEY_HEX = 'ab'.repeat(32); @@ -20,18 +20,21 @@ function mockBindings(overrides?: Partial) { const freed: EncryptionTenantKeys[] = []; const derived: EncryptionTenantKeys[] = []; const bindings: EncryptionBindings = { - deriveTenantKeys: vi.fn((_masterKey: Uint8Array, tenantId: string) => { - const keys: EncryptionTenantKeys = { - tenantId, - encryptionFingerprint: () => new Uint8Array(16), - getNonceCounter: () => 0, - free() { - freed.push(keys); - }, - }; - derived.push(keys); - return keys; - }), + deriveTenantKeys: vi.fn( + (_masterKey: Uint8Array, tenantId: string, previousMasterKeys?: Uint8Array[]) => { + const keys: EncryptionTenantKeys = { + tenantId, + encryptionFingerprint: () => new Uint8Array(16), + getNonceCounter: () => 0, + keyringEntryCount: () => 1 + (previousMasterKeys?.length ?? 0), + free() { + freed.push(keys); + }, + }; + derived.push(keys); + return keys; + } + ), encryptWithTenantKeys: vi.fn(() => new Uint8Array([1])), decryptWithTenantKeys: vi.fn(() => new Uint8Array([2])), ...overrides, @@ -119,3 +122,129 @@ describe('EncryptionManagerCore', () => { expect(freed.length).toBe(1); }); }); + +describe('EncryptionManagerCore keyring config (previousMasterKeys)', () => { + const K2_HEX = 'cd'.repeat(32); + + function makeManager(previousMasterKeys?: readonly string[]) { + const mocks = mockBindings(); + const manager = new EncryptionManagerCore( + MASTER_KEY_HEX, + undefined, + async () => mocks.bindings, + previousMasterKeys + ); + return { manager, ...mocks }; + } + + it('rejects more than 3 previous keys at load — never truncates', () => { + const four = ['11', '22', '33', '44'].map((b) => b.repeat(32)); + expect(() => makeManager(four)).toThrow(ConfigurationError); + expect(() => makeManager(four)).toThrow(/at most 3 keys, got 4/); + }); + + it('accepts exactly 3 previous keys', () => { + const three = ['11', '22', '33'].map((b) => b.repeat(32)); + expect(() => makeManager(three)).not.toThrow(); + }); + + it('rejects masterKey appearing in previousMasterKeys (forward-only rule)', () => { + expect(() => makeManager([MASTER_KEY_HEX])).toThrow(ConfigurationError); + expect(() => makeManager([K2_HEX, MASTER_KEY_HEX])).toThrow(/forward-only/); + }); + + it('rejects masterKey collision case-insensitively — hex case is not key identity', () => { + expect(() => makeManager([MASTER_KEY_HEX.toUpperCase()])).toThrow(ConfigurationError); + }); + + it('validates previous keys with rules identical to masterKey', () => { + expect(() => makeManager(['zz'.repeat(32)])).toThrow(/hex-encoded/); + expect(() => makeManager(['ab'.repeat(16)])).toThrow(/exactly 32 bytes/); + expect(() => makeManager([''])).toThrow(ConfigurationError); + }); + + it('hands decoded previous-key bytes to the bindings exactly once, then wipes them', async () => { + const { manager, bindings } = makeManager([K2_HEX]); + // Snapshot at call time — the manager zeroizes its staging buffers as + // soon as the binding has consumed them, so the retained mock.calls + // reference reads zeros afterwards. + const original = vi.mocked(bindings.deriveTenantKeys).getMockImplementation()!; + const seenAtCallTime: number[][] = []; + vi.mocked(bindings.deriveTenantKeys).mockImplementation((masterKey, tenantId, previous) => { + for (const bytes of previous ?? []) seenAtCallTime.push(Array.from(bytes.slice(0, 2))); + return original(masterKey, tenantId, previous); + }); + await manager.encrypt(new Uint8Array([1]), 'ns:k'); + + expect(bindings.deriveTenantKeys).toHaveBeenCalledTimes(1); + const [, , previous] = vi.mocked(bindings.deriveTenantKeys).mock.calls[0]; + expect(previous![0]).toBeInstanceOf(Uint8Array); + expect(seenAtCallTime).toEqual([[0xcd, 0xcd]]); + // Staging buffers hold plaintext key bytes — wiped once the keyring is built. + expect(Array.from(previous![0].slice(0, 2))).toEqual([0, 0]); + manager.dispose(); + }); + + it('omits the keyring argument entirely when no previous keys are configured', async () => { + const { manager, bindings } = makeManager(); + await manager.encrypt(new Uint8Array([1]), 'ns:k'); + + const [, , previous] = vi.mocked(bindings.deriveTenantKeys).mock.calls[0]; + expect(previous).toBeUndefined(); + manager.dispose(); + }); + + it('rejects duplicate previousMasterKeys entries (case-insensitive)', () => { + expect(() => makeManager([K2_HEX, K2_HEX])).toThrow(ConfigurationError); + expect(() => makeManager([K2_HEX, K2_HEX.toUpperCase()])).toThrow(/duplicates/); + }); + + it('refuses to init when a version-skewed binding drops the keyring (no attestation method)', async () => { + // An older native binary predates keyringEntryCount AND silently ignores + // the third deriveTenantKeys argument — absence of the method must fail + // loud instead of silently decrypting with the current key only. + const { bindings, freed } = mockBindings(); + vi.mocked(bindings.deriveTenantKeys).mockImplementation( + (_masterKey: Uint8Array, tenantId: string) => { + const keys: EncryptionTenantKeys = { + tenantId, + encryptionFingerprint: () => new Uint8Array(16), + getNonceCounter: () => 0, + // no keyringEntryCount — pre-keyring binding + free() { + freed.push(keys); + }, + }; + return keys; + } + ); + const manager = new EncryptionManagerCore(MASTER_KEY_HEX, undefined, async () => bindings, [ + K2_HEX, + ]); + + await expect(manager.encrypt(new Uint8Array([1]), 'ns:k')).rejects.toThrow( + /version skew|keyring/ + ); + // The orphaned handle must be zeroized, not parked + expect(freed.length).toBe(1); + manager.dispose(); + }); + + it('refuses to init when the binding reports a wrong keyring entry count', async () => { + const { bindings, freed } = mockBindings(); + // Reuse the factory mock but drop the previous-keys argument — the handle + // then reports keyringEntryCount() === 1 despite the keyring config. + const original = vi.mocked(bindings.deriveTenantKeys).getMockImplementation()!; + vi.mocked(bindings.deriveTenantKeys).mockImplementation((masterKey, tenantId) => + original(masterKey, tenantId) + ); + const manager = new EncryptionManagerCore(MASTER_KEY_HEX, undefined, async () => bindings, [ + K2_HEX, + ]); + + await expect(manager.decrypt(new Uint8Array(28), 'ns:k')).rejects.toThrow(/version skew/); + // The orphaned handle must be zeroized, not parked + expect(freed.length).toBe(1); + manager.dispose(); + }); +}); diff --git a/packages/cachekit/src/encryption/manager-core.ts b/packages/cachekit/src/encryption/manager-core.ts index 7bad569..d741c64 100644 --- a/packages/cachekit/src/encryption/manager-core.ts +++ b/packages/cachekit/src/encryption/manager-core.ts @@ -1,5 +1,10 @@ import { EncryptionError, ConfigurationError, NonceExhaustedError } from '../errors.js'; -import { AAD_VERSION, MIN_MASTER_KEY_BYTES, MIN_MASTER_KEY_HEX_LENGTH } from '../constants.js'; +import { + AAD_VERSION, + MAX_PREVIOUS_MASTER_KEYS, + MASTER_KEY_BYTES, + MASTER_KEY_HEX_LENGTH, +} from '../constants.js'; /** * Tenant keys handle exposed by a bindings implementation (NAPI or wasm). @@ -11,6 +16,14 @@ export interface EncryptionTenantKeys { encryptionFingerprint(): Uint8Array; /** Get the current nonce counter from the Rust encryptor (for monitoring) */ getNonceCounter(): number; + /** + * Keyring entries actually built at derivation (1 current key + + * decrypt-only previous keys). Optional in the type because older binding + * binaries predate it — the manager treats its absence, when + * previousMasterKeys are configured, as version skew and refuses to init + * rather than silently decrypting with the current key only. + */ + keyringEntryCount?(): number; /** * Deterministic zeroize-and-release (wasm bindings). NAPI handles zeroize * via GC finalizer instead and don't expose this. @@ -31,7 +44,16 @@ export interface EncryptionTenantKeys { * a core wording change must update this contract and the classifier below. */ export interface EncryptionBindings { - deriveTenantKeys(masterKey: Uint8Array, tenantId: string): EncryptionTenantKeys; + /** + * `previousMasterKeys` (max 3, each 32 bytes) are decrypt-only keys for a + * rotation grace window. The binding constructs the cachekit-core keyring + * natively — key bytes cross the boundary once and stay there. + */ + deriveTenantKeys( + masterKey: Uint8Array, + tenantId: string, + previousMasterKeys?: Uint8Array[] + ): EncryptionTenantKeys; encryptWithTenantKeys( plaintext: Uint8Array, aad: Uint8Array, @@ -44,6 +66,21 @@ export interface EncryptionBindings { ): Uint8Array; } +/** + * Validate a hex-encoded master key. Identical rules for the current key and + * every previousMasterKeys entry — one validator, so they cannot drift. + */ +function validateKeyHex(key: string, label: string): void { + if (!/^[0-9a-fA-F]+$/.test(key)) { + throw new ConfigurationError(`${label} must be hex-encoded`); + } + if (key.length !== MASTER_KEY_HEX_LENGTH) { + throw new ConfigurationError( + `${label} must be exactly ${MASTER_KEY_BYTES} bytes (${MASTER_KEY_HEX_LENGTH} hex characters), got ${key.length} hex characters` + ); + } +} + /** * High-level encryption manager over injected cachekit-core bindings. * @@ -75,25 +112,58 @@ export class EncryptionManagerCore { * - Use encrypted swap * - Rotate master keys periodically (recommended: 24-48 hours) * - * @param masterKey - Hex-encoded master key (min 32 bytes = 64 hex chars) + * Keyring exposure is all-keys exposure: during a rotation grace window + * this process holds the current AND previous master keys — treat exposure + * of the keyring configuration as exposure of every key in it. + * + * @param masterKey - Hex-encoded master key (exactly 32 bytes = 64 hex chars) * @param tenantId - Optional tenant ID for key derivation isolation * @param loadBindings - Platform bindings loader (NAPI or wasm) - * @throws {ConfigurationError} if masterKey is invalid + * @param previousMasterKeys - Decrypt-only previous master keys (max 3, + * same hex format as masterKey) retained during a key-rotation grace + * window. Reads attempt keys sequentially, current first; writes always + * use masterKey. Rotation is forward-only: masterKey must not appear + * here — a key that ever encrypted is never re-promoted. + * @throws {ConfigurationError} if any key is invalid, more than 3 previous + * keys are configured (rejected, never truncated), or masterKey appears + * in previousMasterKeys */ constructor( private readonly masterKey: string, private readonly tenantId: string | undefined, - private readonly loadBindings: () => Promise + private readonly loadBindings: () => Promise, + private readonly previousMasterKeys: readonly string[] = [] ) { - // Validate master key format - if (!/^[0-9a-fA-F]+$/.test(masterKey)) { - throw new ConfigurationError('Master key must be hex-encoded'); - } - if (masterKey.length !== MIN_MASTER_KEY_HEX_LENGTH) { + validateKeyHex(masterKey, 'Master key'); + if (previousMasterKeys.length > MAX_PREVIOUS_MASTER_KEYS) { throw new ConfigurationError( - `Master key must be exactly ${MIN_MASTER_KEY_BYTES} bytes (${MIN_MASTER_KEY_HEX_LENGTH} hex characters), got ${masterKey.length} hex characters` + `previousMasterKeys accepts at most ${MAX_PREVIOUS_MASTER_KEYS} keys, got ${previousMasterKeys.length} — drop retired keys explicitly, the list is never truncated` ); } + // Case-insensitive comparisons throughout: hex case differences encode + // the same key bytes. + const current = masterKey.toLowerCase(); + const seen = new Set(); + previousMasterKeys.forEach((key, index) => { + validateKeyHex(key, `Previous master key ${index + 1}`); + const canonical = key.toLowerCase(); + // Forward-only rule (protocol decisions/key-rotation.md): a key that + // ever occupied the encrypting slot is never re-promoted, because that + // would resume a used, unknowable AES-GCM nonce budget. + if (canonical === current) { + throw new ConfigurationError( + 'masterKey must not appear in previousMasterKeys — rotation is forward-only to a new key; a retired key is never re-promoted' + ); + } + // Duplicates are config errors too: they silently burn keyring slots + // (cap of 3) and double the decrypt attempts for old entries. + if (seen.has(canonical)) { + throw new ConfigurationError( + `previousMasterKeys entry ${index + 1} duplicates an earlier entry — each decrypt-only key may appear once` + ); + } + seen.add(canonical); + }); } /** @@ -125,11 +195,43 @@ export class EncryptionManagerCore { // Decode hex master key to bytes const masterKeyBytes = this.hexToBytes(this.masterKey); + const previousKeyBytes = this.previousMasterKeys.map((key) => this.hexToBytes(key)); // Derive tenant keys (uses cachekit-core's derive_tenant_keys with domain "encryption") - // Keys stay in binding memory - never copied to the JavaScript heap + // Keys stay in binding memory - never copied to the JavaScript heap. + // Previous keys build the native decrypt keyring once, here. The decoded + // byte buffers are wiped in the finally below as soon as the binding has + // consumed them — on error paths too (the hex config strings remain on + // the manager for init retry, per the documented masterKey pattern). const effectiveTenantId = this.tenantId ?? 'default'; - const tenantKeys = this.native.deriveTenantKeys(masterKeyBytes, effectiveTenantId); + let tenantKeys: EncryptionTenantKeys; + try { + tenantKeys = this.native.deriveTenantKeys( + masterKeyBytes, + effectiveTenantId, + previousKeyBytes.length > 0 ? previousKeyBytes : undefined + ); + } finally { + masterKeyBytes.fill(0); + for (const bytes of previousKeyBytes) bytes.fill(0); + } + + // Attest the keyring survived the FFI boundary. NAPI silently ignores + // extra arguments, so a version-skewed native binary that predates + // previousMasterKeys would build a single-key handle and every + // pre-rotation entry would silently degrade to a miss (LAB-241 class). + // Absence of keyringEntryCount on the handle is itself the skew signal. + if (previousKeyBytes.length > 0) { + const built = tenantKeys.keyringEntryCount?.() ?? 1; + if (built !== 1 + previousKeyBytes.length) { + tenantKeys.free?.(); + throw new ConfigurationError( + `previousMasterKeys configured (${previousKeyBytes.length} keys) but the native bindings ` + + `built a keyring with ${built} entr${built === 1 ? 'y' : 'ies'} — ` + + 'native module version skew; reinstall dependencies so the bindings match the SDK version' + ); + } + } if (this.disposed) { // dispose() ran while init was in flight — zeroize immediately // instead of parking live key material on a disposed manager. @@ -173,7 +275,9 @@ export class EncryptionManagerCore { message.includes('Nonce counter exhausted') || message.includes('NonceCounterExhausted') ) { - throw new NonceExhaustedError(`Nonce counter exhausted. Key rotation required.`, { + // Guidance (forward-only rotation + runbook link) lives once, in the + // NonceExhaustedError default message. + throw new NonceExhaustedError(undefined, { cause: error instanceof Error ? error : undefined, }); } @@ -188,6 +292,11 @@ export class EncryptionManagerCore { * * Uses TenantKeys pattern - keys never leave binding memory. * + * With previousMasterKeys configured, the binding runs cachekit-core's + * keyring loop natively: sequential attempts, current key first, the + * identical AAD rebuilt for every attempt (ts entries carry no per-entry + * key identity — protocol spec/encryption.md "Key Rotation (Keyring)"). + * * @param ciphertext - Encrypted data * @param cacheKey - Cache key that was bound during encryption * @returns Decrypted plaintext diff --git a/packages/cachekit/src/encryption/manager.integration.test.ts b/packages/cachekit/src/encryption/manager.integration.test.ts index c6ee4f5..4767c8f 100644 --- a/packages/cachekit/src/encryption/manager.integration.test.ts +++ b/packages/cachekit/src/encryption/manager.integration.test.ts @@ -313,3 +313,77 @@ describe('EncryptionManager with empty tenant ID (Edge Case)', () => { } }); }); + +describe('EncryptionManager keyring rotation (real NAPI keyring loop)', () => { + // Distinct 32-byte keys, hex-encoded + const K1_HEX = '11'.repeat(32); + const K2_HEX = '22'.repeat(32); + const DATA = new Uint8Array([0xca, 0xfe, 0xba, 0xbe]); + const CACHE_KEY = 'ns:rotation:test'; + + it('decrypts a k1-encrypted value with masterKey=k2, previousMasterKeys=[k1]', async () => { + const writer = new EncryptionManager(K1_HEX); + const rotated = new EncryptionManager(K2_HEX, undefined, [K1_HEX]); + + try { + const ciphertext = await writer.encrypt(DATA, CACHE_KEY); + const plaintext = await rotated.decrypt(ciphertext, CACHE_KEY); + expect(Array.from(plaintext)).toEqual(Array.from(DATA)); + } finally { + writer.dispose(); + rotated.dispose(); + } + }); + + it('fails to decrypt the same value with masterKey=k2 and an empty keyring', async () => { + const writer = new EncryptionManager(K1_HEX); + const cutOver = new EncryptionManager(K2_HEX); + + try { + const ciphertext = await writer.encrypt(DATA, CACHE_KEY); + await expect(cutOver.decrypt(ciphertext, CACHE_KEY)).rejects.toThrow(EncryptionError); + } finally { + writer.dispose(); + cutOver.dispose(); + } + }); + + it('still writes under the current key during a grace window', async () => { + // Writes always use masterKey: a value encrypted by the rotated manager + // must NOT be readable by a keyring holding only k1. + const rotated = new EncryptionManager(K2_HEX, undefined, [K1_HEX]); + const oldOnly = new EncryptionManager(K1_HEX); + + try { + const ciphertext = await rotated.encrypt(DATA, CACHE_KEY); + await expect(oldOnly.decrypt(ciphertext, CACHE_KEY)).rejects.toThrow(EncryptionError); + // ...and stays readable by the writer itself (current key, first attempt). + const plaintext = await rotated.decrypt(ciphertext, CACHE_KEY); + expect(Array.from(plaintext)).toEqual(Array.from(DATA)); + } finally { + rotated.dispose(); + oldOnly.dispose(); + } + }); + + it('enforces the keyring invariants natively too (defense in depth behind NAPI)', async () => { + // The JS constructor rejects these at load; the native layer must also + // reject them if reached directly — config errors, not auth failures. + const napi = await import('@cachekit-io/cachekit-core-ts'); + const k2 = Buffer.from(K2_HEX, 'hex'); + const others = ['33', '44', '55', '66'].map((b) => Buffer.from(b.repeat(32), 'hex')); + + // current key in the decrypt-only list (forward-only rule) — cachekit-core Keyring::new + expect(() => napi.deriveTenantKeys(k2, 'tenant', [k2])).toThrow( + /Current key must not appear in the decrypt-only list/ + ); + // cap of 3 exceeded — rejected, never truncated — cachekit-core Keyring::new + expect(() => napi.deriveTenantKeys(k2, 'tenant', others)).toThrow( + /Keyring cap exceeded: at most 3 decrypt-only keys/ + ); + // wrong-length previous key — NAPI binding length check + expect(() => napi.deriveTenantKeys(k2, 'tenant', [Buffer.from('aabb', 'hex')])).toThrow( + /Previous master key must be exactly 32 bytes/ + ); + }); +}); diff --git a/packages/cachekit/src/encryption/manager.ts b/packages/cachekit/src/encryption/manager.ts index a2536dd..7fa3581 100644 --- a/packages/cachekit/src/encryption/manager.ts +++ b/packages/cachekit/src/encryption/manager.ts @@ -42,11 +42,15 @@ export class EncryptionManager extends EncryptionManagerCore { /** * Create an EncryptionManager backed by the NAPI bindings (lazy-loaded). * - * @param masterKey - Hex-encoded master key (min 32 bytes = 64 hex chars) + * @param masterKey - Hex-encoded master key (exactly 32 bytes = 64 hex chars) * @param tenantId - Optional tenant ID for key derivation isolation - * @throws {ConfigurationError} if masterKey is invalid + * @param previousMasterKeys - Decrypt-only previous master keys (max 3, + * same hex format) for a key-rotation grace window; reads attempt keys + * sequentially, current first, writes always use masterKey + * @throws {ConfigurationError} if any key is invalid, more than 3 previous + * keys are configured, or masterKey appears in previousMasterKeys */ - constructor(masterKey: string, tenantId?: string) { - super(masterKey, tenantId, loadNapiBindings); + constructor(masterKey: string, tenantId?: string, previousMasterKeys?: readonly string[]) { + super(masterKey, tenantId, loadNapiBindings, previousMasterKeys); } } diff --git a/packages/cachekit/src/errors.ts b/packages/cachekit/src/errors.ts index cadef30..4655741 100644 --- a/packages/cachekit/src/errors.ts +++ b/packages/cachekit/src/errors.ts @@ -97,10 +97,19 @@ export class ValueTooLargeError extends CachekitError { /** * Thrown when nonce counter approaches exhaustion. * Indicates key rotation is required. + * + * Rotation is always forward, to a NEW master key — a retired key is never + * re-promoted, because that would resume a used, unknowable AES-GCM nonce + * budget. Promote a fresh key to `masterKey` and move the exhausted key into + * `previousMasterKeys` so existing entries stay readable through the grace + * window. Runbook: https://docs.cachekit.io/concepts/key-rotation/ */ export class NonceExhaustedError extends EncryptionError { constructor( - message: string = 'Nonce counter exhausted, key rotation required', + message: string = 'Nonce counter exhausted, key rotation required. ' + + 'Rotate forward to a NEW master key (never re-promote a retired key) and move the ' + + 'exhausted key into previousMasterKeys for the grace window. ' + + 'Runbook: https://docs.cachekit.io/concepts/key-rotation/', options?: ErrorOptions ) { super(message, options); diff --git a/packages/cachekit/src/intents-core.ts b/packages/cachekit/src/intents-core.ts index e964099..dea8e92 100644 --- a/packages/cachekit/src/intents-core.ts +++ b/packages/cachekit/src/intents-core.ts @@ -101,10 +101,18 @@ export type ProductionOptions = BaseIntentOptions & export type SecureOptions = BaseIntentOptions & IntentBackendOptions & { /** - * Master encryption key (hex-encoded, min 32 bytes / 64 hex chars). + * Master encryption key (hex-encoded, exactly 32 bytes / 64 hex chars). * Falls back to CACHEKIT_MASTER_KEY env var if not provided. */ masterKey?: string; + /** + * Decrypt-only previous master keys (max 3, same hex format as + * masterKey) retained during a key-rotation grace window. Falls back to + * the CACHEKIT_PREVIOUS_MASTER_KEYS env var (comma-separated hex) if + * not provided. More than 3 keys, or repeating masterKey, throws + * ConfigurationError at load. + */ + previousMasterKeys?: string[]; /** Tenant ID for key derivation isolation */ tenantId?: string; /** @@ -256,6 +264,7 @@ export function buildIntents( encryption: { masterKey, tenantId: options.tenantId, + previousMasterKeys: options.previousMasterKeys ?? envPreviousMasterKeys(), }, reliability: mergeReliability(PRODUCTION_RELIABILITY, options.reliability), compression: options.compression, @@ -355,6 +364,23 @@ function envVar(name: string): string | undefined { return typeof process !== 'undefined' ? process.env?.[name] : undefined; } +/** + * Parse CACHEKIT_PREVIOUS_MASTER_KEYS (comma-separated hex) into a keyring + * list. Whitespace around entries is tolerated; empty segments are dropped + * (a trailing comma is not a key). Per-key validation — hex format, length, + * the cap of 3, the masterKey collision — happens in EncryptionManagerCore, + * identically to explicitly configured keys. + */ +function envPreviousMasterKeys(): string[] | undefined { + const raw = envVar('CACHEKIT_PREVIOUS_MASTER_KEYS'); + if (!raw) return undefined; + const keys = raw + .split(',') + .map((key) => key.trim()) + .filter((key) => key.length > 0); + return keys.length > 0 ? keys : undefined; +} + function mergeReliability( defaults: ReliabilityConfig, overrides?: Partial diff --git a/packages/cachekit/src/intents.test.ts b/packages/cachekit/src/intents.test.ts index 4866b01..659232e 100644 --- a/packages/cachekit/src/intents.test.ts +++ b/packages/cachekit/src/intents.test.ts @@ -36,6 +36,7 @@ describe('Intent-based Cache API', () => { afterEach(() => { delete process.env.CACHEKIT_MASTER_KEY; + delete process.env.CACHEKIT_PREVIOUS_MASTER_KEYS; delete process.env.CACHEKIT_API_KEY; }); @@ -196,6 +197,48 @@ describe('Intent-based Cache API', () => { expect(capturedOptions!.encryption?.masterKey).toBe(MASTER_KEY); }); + + it('passes previousMasterKeys to encryption config', () => { + const previous = ['b'.repeat(64), 'c'.repeat(64)]; + createCache.secure({ + url: 'redis://localhost:6379', + masterKey: MASTER_KEY, + previousMasterKeys: previous, + }); + + expect(capturedOptions!.encryption?.previousMasterKeys).toEqual(previous); + }); + + it('resolves previousMasterKeys from CACHEKIT_PREVIOUS_MASTER_KEYS (comma-separated hex)', () => { + // Whitespace tolerated, empty segments (trailing comma) dropped + process.env.CACHEKIT_PREVIOUS_MASTER_KEYS = `${'b'.repeat(64)}, ${'c'.repeat(64)},`; + + createCache.secure({ url: 'redis://localhost:6379', masterKey: MASTER_KEY }); + + expect(capturedOptions!.encryption?.previousMasterKeys).toEqual([ + 'b'.repeat(64), + 'c'.repeat(64), + ]); + }); + + it('explicit previousMasterKeys takes precedence over env var', () => { + process.env.CACHEKIT_PREVIOUS_MASTER_KEYS = 'd'.repeat(64); + const previous = ['b'.repeat(64)]; + + createCache.secure({ + url: 'redis://localhost:6379', + masterKey: MASTER_KEY, + previousMasterKeys: previous, + }); + + expect(capturedOptions!.encryption?.previousMasterKeys).toEqual(previous); + }); + + it('leaves previousMasterKeys undefined when neither option nor env is set', () => { + createCache.secure({ url: 'redis://localhost:6379', masterKey: MASTER_KEY }); + + expect(capturedOptions!.encryption?.previousMasterKeys).toBeUndefined(); + }); }); // ======================================================================== diff --git a/packages/cachekit/src/types/cache.ts b/packages/cachekit/src/types/cache.ts index 2cd1326..58bb272 100644 --- a/packages/cachekit/src/types/cache.ts +++ b/packages/cachekit/src/types/cache.ts @@ -117,10 +117,20 @@ export type WrapOptions = WrapOptionsBase & * Encryption configuration for cache. */ export interface EncryptionConfig { - /** Master encryption key (hex-encoded, min 32 bytes) */ + /** Master encryption key (hex-encoded, exactly 32 bytes) */ masterKey: string; /** Tenant ID for key derivation isolation */ tenantId?: string; + /** + * Decrypt-only previous master keys (max 3, same hex format as masterKey) + * retained during a key-rotation grace window. Entries written under a + * previous key stay readable without re-encryption; writes always use + * masterKey. Rotation is forward-only: masterKey must not appear here. + * + * Configuring more than 3 keys, or repeating masterKey, throws + * ConfigurationError at load — the list is never truncated. + */ + previousMasterKeys?: string[]; } /** diff --git a/packages/cachekit/src/workers/runtime.ts b/packages/cachekit/src/workers/runtime.ts index 4f87565..27437e6 100644 --- a/packages/cachekit/src/workers/runtime.ts +++ b/packages/cachekit/src/workers/runtime.ts @@ -49,12 +49,16 @@ function wasmEncryptionBindings(): EncryptionBindings { */ export class EncryptionManager extends EncryptionManagerCore { /** - * @param masterKey - Hex-encoded master key (min 32 bytes = 64 hex chars) + * @param masterKey - Hex-encoded master key (exactly 32 bytes = 64 hex chars) * @param tenantId - Optional tenant ID for key derivation isolation - * @throws {ConfigurationError} if masterKey is invalid + * @param previousMasterKeys - Decrypt-only previous master keys (max 3, + * same hex format) for a key-rotation grace window; reads attempt keys + * sequentially, current first, writes always use masterKey + * @throws {ConfigurationError} if any key is invalid, more than 3 previous + * keys are configured, or masterKey appears in previousMasterKeys */ - constructor(masterKey: string, tenantId?: string) { - super(masterKey, tenantId, async () => wasmEncryptionBindings()); + constructor(masterKey: string, tenantId?: string, previousMasterKeys?: readonly string[]) { + super(masterKey, tenantId, async () => wasmEncryptionBindings(), previousMasterKeys); } } @@ -87,7 +91,8 @@ const workersRuntime: CacheRuntime = { ); }, createByteStorage: () => new ByteStorage(), - createEncryption: (config) => new EncryptionManager(config.masterKey, config.tenantId), + createEncryption: (config) => + new EncryptionManager(config.masterKey, config.tenantId, config.previousMasterKeys), // No createInvalidationChannel: Redis Pub/Sub is Node-only. cache-core // fails fast with a ConfigurationError if `invalidation` is configured. diff --git a/packages/cachekit/test/integration/encryption-real-crypto.integration.test.ts b/packages/cachekit/test/integration/encryption-real-crypto.integration.test.ts index f492e07..022a7df 100644 --- a/packages/cachekit/test/integration/encryption-real-crypto.integration.test.ts +++ b/packages/cachekit/test/integration/encryption-real-crypto.integration.test.ts @@ -14,7 +14,7 @@ import { decryptWithTenantKeys, TenantKeys, } from '@cachekit-io/cachekit-core-ts'; -import { AAD_VERSION, MIN_MASTER_KEY_BYTES } from '../../src/constants.js'; +import { AAD_VERSION } from '../../src/constants.js'; // Test master key (32 bytes for AES-256) const TEST_MASTER_KEY = new Uint8Array(32).fill(0x61); // 'a' repeated diff --git a/packages/cachekit/test/workers/encryption.protocol.workers.test.ts b/packages/cachekit/test/workers/encryption.protocol.workers.test.ts index 16912bb..79fd612 100644 --- a/packages/cachekit/test/workers/encryption.protocol.workers.test.ts +++ b/packages/cachekit/test/workers/encryption.protocol.workers.test.ts @@ -269,3 +269,26 @@ describe('encryption + envelope composition (wasm end-to-end)', () => { } }); }); + +describe('keyring rotation — Workers EncryptionManager (wasm keyring loop)', () => { + const K1_HEX = '11'.repeat(32); + const K2_HEX = '22'.repeat(32); + const CACHE_KEY = 'ns:workers:rotation'; + + it('decrypts a k1-encrypted value with masterKey=k2, previousMasterKeys=[k1]; fails without it', async () => { + const writer = new EncryptionManager(K1_HEX, tenantId); + const rotated = new EncryptionManager(K2_HEX, tenantId, [K1_HEX]); + const cutOver = new EncryptionManager(K2_HEX, tenantId); + try { + const data = new TextEncoder().encode('rotate me'); + const ciphertext = await writer.encrypt(data, CACHE_KEY, false); + + expect(await rotated.decrypt(ciphertext, CACHE_KEY, false)).toEqual(data); + await expect(cutOver.decrypt(ciphertext, CACHE_KEY, false)).rejects.toThrow(); + } finally { + writer.dispose(); + rotated.dispose(); + cutOver.dispose(); + } + }); +});