From d8f1b7c656f5b04f89c61f1d1263d1e5a64f7df7 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 8 Aug 2026 00:23:22 +1000 Subject: [PATCH 1/5] feat(encryption): previousMasterKeys keyring rotation surface (LAB-685) Master-key rotation without invalidating existing entries, per protocol decisions/key-rotation.md + spec/encryption.md 'Key Rotation (Keyring)': - previousMasterKeys config (max 3, hex identical to masterKey; env CACHEKIT_PREVIOUS_MASTER_KEYS comma-separated). Load-time rejection: >3 keys throws (never truncates), masterKey in the list throws (forward-only rule, case-insensitive hex compare). - Keyring decrypt loop behind the NAPI boundary via cachekit-core 0.5.0 Keyring: sequential attempts, current key first, identical AAD per attempt; only auth failures advance. wasm binding mirrors NAPI so the same config works on Workers. Single-key path unchanged (no keyring, no per-decrypt HKDF). - Key bytes cross the boundary once at init; keyring material zeroizes on drop in cachekit-core. No derived-key bytes retained in JS. - NonceExhaustedError guidance now names forward-only rotation and links the rotation runbook (page authored by LAB-687). - cachekit-core pins bumped 0.4.0 -> 0.5.0 in both crates. --- .secrets.baseline | 6 +- packages/cachekit-core-ts/Cargo.lock | 4 +- packages/cachekit-core-ts/Cargo.toml | 2 +- packages/cachekit-core-ts/README.md | 4 +- packages/cachekit-core-ts/index.d.ts | 20 ++- packages/cachekit-core-ts/index.js | 104 +++++++------- packages/cachekit-core-ts/src/lib.rs | 83 ++++++++++-- packages/cachekit-core-wasm/Cargo.lock | 5 +- packages/cachekit-core-wasm/Cargo.toml | 5 +- packages/cachekit-core-wasm/README.md | 5 + packages/cachekit-core-wasm/index.d.ts | 15 ++- packages/cachekit-core-wasm/src/lib.rs | 77 +++++++++-- packages/cachekit/README.md | 39 ++++++ packages/cachekit/src/cache.rotation.test.ts | 127 ++++++++++++++++++ packages/cachekit/src/cache.ts | 3 +- packages/cachekit/src/constants.ts | 8 ++ .../src/encryption/manager-core.test.ts | 64 ++++++++- .../cachekit/src/encryption/manager-core.ts | 94 +++++++++++-- .../encryption/manager.integration.test.ts | 68 ++++++++++ packages/cachekit/src/encryption/manager.ts | 10 +- packages/cachekit/src/errors.ts | 10 +- packages/cachekit/src/intents-core.ts | 26 ++++ packages/cachekit/src/intents.test.ts | 43 ++++++ packages/cachekit/src/types/cache.ts | 10 ++ packages/cachekit/src/workers/runtime.ts | 13 +- .../encryption.protocol.workers.test.ts | 23 ++++ 26 files changed, 757 insertions(+), 111 deletions(-) create mode 100644 packages/cachekit/src/cache.rotation.test.ts 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..f0fe6ea 100644 --- a/packages/cachekit-core-ts/index.d.ts +++ b/packages/cachekit-core-ts/index.d.ts @@ -96,6 +96,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 +150,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/index.js b/packages/cachekit-core-ts/index.js index 143621a..e4f83e1 100644 --- a/packages/cachekit-core-ts/index.js +++ b/packages/cachekit-core-ts/index.js @@ -77,8 +77,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-android-arm64') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-android-arm64/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -93,8 +93,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-android-arm-eabi') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-android-arm-eabi/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -114,8 +114,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-win32-x64-gnu') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-win32-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -130,8 +130,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-win32-x64-msvc') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-win32-x64-msvc/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -147,8 +147,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-win32-ia32-msvc') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-win32-ia32-msvc/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -163,8 +163,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-win32-arm64-msvc') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-win32-arm64-msvc/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -182,8 +182,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-darwin-universal') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-darwin-universal/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -198,8 +198,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-darwin-x64') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-darwin-x64/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -214,8 +214,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-darwin-arm64') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-darwin-arm64/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -234,8 +234,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-freebsd-x64') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-freebsd-x64/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -250,8 +250,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-freebsd-arm64') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-freebsd-arm64/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -271,8 +271,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-x64-musl') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-x64-musl/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -287,8 +287,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-x64-gnu') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -305,8 +305,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-arm64-musl') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-arm64-musl/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -321,8 +321,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-arm64-gnu') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-arm64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -339,8 +339,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-arm-musleabihf') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-arm-musleabihf/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -355,8 +355,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-arm-gnueabihf') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-arm-gnueabihf/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -373,8 +373,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-loong64-musl') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-loong64-musl/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -389,8 +389,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-loong64-gnu') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-loong64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -407,8 +407,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-riscv64-musl') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-riscv64-musl/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -423,8 +423,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-riscv64-gnu') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-riscv64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -440,8 +440,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-ppc64-gnu') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-ppc64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -456,8 +456,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-s390x-gnu') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-s390x-gnu/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -476,8 +476,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-openharmony-arm64') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-openharmony-arm64/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -492,8 +492,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-openharmony-x64') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-openharmony-x64/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -508,8 +508,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-openharmony-arm') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-openharmony-arm/package.json').version - if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { diff --git a/packages/cachekit-core-ts/src/lib.rs b/packages/cachekit-core-ts/src/lib.rs index e94586c..44e87a1 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,11 @@ 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, } #[napi] @@ -269,17 +274,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 +316,42 @@ 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 }) + Ok(TenantKeys { + inner, + encryptor, + keyring, + }) } /// Encrypt plaintext using TenantKeys (keys stay in Rust memory). @@ -335,6 +386,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 +406,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..43f8581 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,6 +159,7 @@ name = "cachekit-core-wasm" version = "0.1.0" dependencies = [ "cachekit-core", + "js-sys", "wasm-bindgen", ] diff --git a/packages/cachekit-core-wasm/Cargo.toml b/packages/cachekit-core-wasm/Cargo.toml index 004cd1d..84b7620 100644 --- a/packages/cachekit-core-wasm/Cargo.toml +++ b/packages/cachekit-core-wasm/Cargo.toml @@ -18,7 +18,10 @@ 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. +js-sys = "0.3" +cachekit-core = { version = "0.5.0", features = ["encryption"] } # 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..1fa93d2 100644 --- a/packages/cachekit-core-wasm/README.md +++ b/packages/cachekit-core-wasm/README.md @@ -36,6 +36,11 @@ 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]); ``` ## Security notes diff --git a/packages/cachekit-core-wasm/index.d.ts b/packages/cachekit-core-wasm/index.d.ts index b02a07b..2115a2f 100644 --- a/packages/cachekit-core-wasm/index.d.ts +++ b/packages/cachekit-core-wasm/index.d.ts @@ -48,8 +48,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..2987bc8 100644 --- a/packages/cachekit-core-wasm/src/lib.rs +++ b/packages/cachekit-core-wasm/src/lib.rs @@ -18,7 +18,7 @@ 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; // Security limits to prevent DoS — identical to the NAPI crate. @@ -109,11 +109,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 +143,11 @@ 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, } #[wasm_bindgen] @@ -178,8 +179,15 @@ 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: &[u8], + tenant_id: &str, + previous_master_keys: Option>, +) -> Result { if master_key.len() != 32 { return Err(JsError::new(&format!( "Master key must be exactly 32 bytes, got {}", @@ -190,11 +198,38 @@ pub fn derive_tenant_keys(master_key: &[u8], tenant_id: &str) -> Result> = previous_master_keys + .unwrap_or_default() + .iter() + .map(|key| 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 }) + Ok(TenantKeys { + inner, + encryptor, + keyring, + }) } /// Encrypt plaintext using TenantKeys (keys stay in wasm memory). @@ -216,6 +251,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 +263,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 37f8d8a..e3ebbdb 100644 --- a/packages/cachekit/README.md +++ b/packages/cachekit/README.md @@ -120,6 +120,9 @@ const cache = createCache({ encryption: { masterKey: process.env.CACHEKIT_MASTER_KEY!, // hex-encoded, 32+ bytes tenantId: 'tenant-123', // for multi-tenant key isolation + // Optional: decrypt-only previous keys during a rotation grace window + // (max 3) — see "Master-key rotation" below + previousMasterKeys: process.env.CACHEKIT_PREVIOUS_MASTER_KEYS?.split(','), }, // Reliability settings @@ -136,6 +139,42 @@ const cache = createCache({ }); ``` +## 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) +``` + +All key material crosses into native memory once at initialization and is +zeroized on dispose — the keyring lives behind the NAPI (or wasm) boundary. + +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..5226eff --- /dev/null +++ b/packages/cachekit/src/cache.rotation.test.ts @@ -0,0 +1,127 @@ +/** + * 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'; + +const K1_HEX = '11'.repeat(32); +const K2_HEX = '22'.repeat(32); + +/** + * 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('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..8288b9c 100644 --- a/packages/cachekit/src/constants.ts +++ b/packages/cachekit/src/constants.ts @@ -126,6 +126,14 @@ export const MIN_MASTER_KEY_BYTES = 32; /** Minimum master key length in hex characters */ export const MIN_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..11f74b6 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); @@ -119,3 +119,65 @@ 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', async () => { + const { manager, bindings } = makeManager([K2_HEX]); + await manager.encrypt(new Uint8Array([1]), 'ns:k'); + + expect(bindings.deriveTenantKeys).toHaveBeenCalledTimes(1); + const [, , previous] = vi.mocked(bindings.deriveTenantKeys).mock.calls[0]; + expect(previous).toHaveLength(1); + expect(previous![0]).toBeInstanceOf(Uint8Array); + expect(Array.from(previous![0].slice(0, 2))).toEqual([0xcd, 0xcd]); + 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(); + }); +}); diff --git a/packages/cachekit/src/encryption/manager-core.ts b/packages/cachekit/src/encryption/manager-core.ts index 7bad569..3729240 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, + MIN_MASTER_KEY_BYTES, + MIN_MASTER_KEY_HEX_LENGTH, +} from '../constants.js'; /** * Tenant keys handle exposed by a bindings implementation (NAPI or wasm). @@ -31,7 +36,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 +58,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 !== MIN_MASTER_KEY_HEX_LENGTH) { + throw new ConfigurationError( + `${label} must be exactly ${MIN_MASTER_KEY_BYTES} bytes (${MIN_MASTER_KEY_HEX_LENGTH} hex characters), got ${key.length} hex characters` + ); + } +} + /** * High-level encryption manager over injected cachekit-core bindings. * @@ -75,23 +104,45 @@ export class EncryptionManagerCore { * - Use encrypted swap * - Rotate master keys periodically (recommended: 24-48 hours) * + * 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 (min 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'); + validateKeyHex(masterKey, 'Master key'); + if (previousMasterKeys.length > MAX_PREVIOUS_MASTER_KEYS) { + throw new ConfigurationError( + `previousMasterKeys accepts at most ${MAX_PREVIOUS_MASTER_KEYS} keys, got ${previousMasterKeys.length} — drop retired keys explicitly, the list is never truncated` + ); } - if (masterKey.length !== MIN_MASTER_KEY_HEX_LENGTH) { + for (const key of previousMasterKeys) { + validateKeyHex(key, 'Previous master key'); + } + // Case-insensitive: hex case differences encode the same key bytes. + // 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. + const current = masterKey.toLowerCase(); + if (previousMasterKeys.some((key) => key.toLowerCase() === current)) { 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` + 'masterKey must not appear in previousMasterKeys — rotation is forward-only to a new key; a retired key is never re-promoted' ); } } @@ -125,11 +176,18 @@ 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 — no key + // or derived-key bytes are retained on the JS side past this call. const effectiveTenantId = this.tenantId ?? 'default'; - const tenantKeys = this.native.deriveTenantKeys(masterKeyBytes, effectiveTenantId); + const tenantKeys = this.native.deriveTenantKeys( + masterKeyBytes, + effectiveTenantId, + previousKeyBytes.length > 0 ? previousKeyBytes : undefined + ); if (this.disposed) { // dispose() ran while init was in flight — zeroize immediately // instead of parking live key material on a disposed manager. @@ -173,9 +231,12 @@ export class EncryptionManagerCore { message.includes('Nonce counter exhausted') || message.includes('NonceCounterExhausted') ) { - throw new NonceExhaustedError(`Nonce counter exhausted. Key rotation required.`, { - cause: error instanceof Error ? error : undefined, - }); + throw new NonceExhaustedError( + 'Nonce counter exhausted. Key rotation required: rotate forward to a NEW master key ' + + '(never re-promote a retired key) and move this key into previousMasterKeys for the ' + + 'grace window. Runbook: https://docs.cachekit.io/concepts/key-rotation/', + { cause: error instanceof Error ? error : undefined } + ); } throw new EncryptionError(`Encryption failed: ${message}`, { cause: error instanceof Error ? error : undefined, @@ -188,6 +249,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..0694ff7 100644 --- a/packages/cachekit/src/encryption/manager.integration.test.ts +++ b/packages/cachekit/src/encryption/manager.integration.test.ts @@ -313,3 +313,71 @@ 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) + expect(() => napi.deriveTenantKeys(k2, 'tenant', [k2])).toThrow(); + // cap of 3 exceeded — rejected, never truncated + expect(() => napi.deriveTenantKeys(k2, 'tenant', others)).toThrow(); + // wrong-length previous key + expect(() => napi.deriveTenantKeys(k2, 'tenant', [Buffer.from('aabb', 'hex')])).toThrow(); + }); +}); diff --git a/packages/cachekit/src/encryption/manager.ts b/packages/cachekit/src/encryption/manager.ts index a2536dd..1cb73d2 100644 --- a/packages/cachekit/src/encryption/manager.ts +++ b/packages/cachekit/src/encryption/manager.ts @@ -44,9 +44,13 @@ export class EncryptionManager extends EncryptionManagerCore { * * @param masterKey - Hex-encoded master key (min 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..5d1a92f 100644 --- a/packages/cachekit/src/errors.ts +++ b/packages/cachekit/src/errors.ts @@ -97,10 +97,18 @@ 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): ' + + '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 ec6d2fa..772c323 100644 --- a/packages/cachekit/src/intents-core.ts +++ b/packages/cachekit/src/intents-core.ts @@ -98,6 +98,14 @@ export type SecureOptions = BaseIntentOptions & * 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; /** @@ -249,6 +257,7 @@ export function buildIntents( encryption: { masterKey, tenantId: options.tenantId, + previousMasterKeys: options.previousMasterKeys ?? envPreviousMasterKeys(), }, reliability: mergeReliability(PRODUCTION_RELIABILITY, options.reliability), compression: options.compression, @@ -348,6 +357,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 125344a..9c1f743 100644 --- a/packages/cachekit/src/types/cache.ts +++ b/packages/cachekit/src/types/cache.ts @@ -99,6 +99,16 @@ export interface EncryptionConfig { 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..e592b14 100644 --- a/packages/cachekit/src/workers/runtime.ts +++ b/packages/cachekit/src/workers/runtime.ts @@ -51,10 +51,14 @@ export class EncryptionManager extends EncryptionManagerCore { /** * @param masterKey - Hex-encoded master key (min 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/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(); + } + }); +}); From 420d6fc5e8d985bf47cd2129558e622dede1477f Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 8 Aug 2026 00:38:21 +1000 Subject: [PATCH 2/5] fix(encryption): keyring FFI attestation + panel findings (LAB-685) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expert-panel (critical-stakes) findings applied: - MAJ: attest the keyring survived the FFI boundary. NAPI silently drops extra arguments, so a version-skewed prebuilt binary would build a single-key handle and every pre-rotation entry would silently degrade to a miss (LAB-241 class). Both bindings now expose keyringEntryCount(); init throws ConfigurationError on mismatch, and the method's absence (pre-keyring binary) is itself the skew signal. - Reject duplicate previousMasterKeys entries (case-insensitive) — they silently burn keyring cap slots. - Per-key index in validation errors (Previous master key N ...). - Rotation guidance + runbook URL now lives once, in the NonceExhaustedError default message. - MIN_MASTER_KEY_* renamed MASTER_KEY_* (internal): validation enforces exact length, the 'minimum' name and '32+/min 32 bytes' doc claims were looser than shipped behavior. - README manual-config example no longer teaches raw split(',') env parsing; the Master-Key Rotation section is the single reference. --- packages/cachekit-core-ts/index.d.ts | 10 +++ packages/cachekit-core-ts/src/lib.rs | 18 ++++ packages/cachekit-core-wasm/index.d.ts | 5 ++ packages/cachekit-core-wasm/src/lib.rs | 14 +++ packages/cachekit/README.md | 7 +- packages/cachekit/src/constants.ts | 8 +- .../src/encryption/manager-core.test.ts | 81 ++++++++++++++--- .../cachekit/src/encryption/manager-core.ts | 87 +++++++++++++------ packages/cachekit/src/encryption/manager.ts | 2 +- packages/cachekit/src/errors.ts | 5 +- packages/cachekit/src/intents-core.ts | 2 +- packages/cachekit/src/types/cache.ts | 2 +- packages/cachekit/src/workers/runtime.ts | 2 +- 13 files changed, 191 insertions(+), 52 deletions(-) diff --git a/packages/cachekit-core-ts/index.d.ts b/packages/cachekit-core-ts/index.d.ts index f0fe6ea..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 } /** diff --git a/packages/cachekit-core-ts/src/lib.rs b/packages/cachekit-core-ts/src/lib.rs index 44e87a1..bd8b80c 100644 --- a/packages/cachekit-core-ts/src/lib.rs +++ b/packages/cachekit-core-ts/src/lib.rs @@ -238,6 +238,10 @@ pub struct TenantKeys { /// 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] @@ -262,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. @@ -347,10 +363,12 @@ pub fn derive_tenant_keys( let encryptor = ZeroKnowledgeEncryptor::new() .map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?; + let keyring_entries = 1 + previous.len() as u32; Ok(TenantKeys { inner, encryptor, keyring, + keyring_entries, }) } diff --git a/packages/cachekit-core-wasm/index.d.ts b/packages/cachekit-core-wasm/index.d.ts index 2115a2f..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). */ diff --git a/packages/cachekit-core-wasm/src/lib.rs b/packages/cachekit-core-wasm/src/lib.rs index 2987bc8..a1d6dcd 100644 --- a/packages/cachekit-core-wasm/src/lib.rs +++ b/packages/cachekit-core-wasm/src/lib.rs @@ -148,6 +148,10 @@ pub struct TenantKeys { /// 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] @@ -172,6 +176,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. @@ -225,10 +237,12 @@ pub fn derive_tenant_keys( 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()))?; + let keyring_entries = 1 + previous.len() as u32; Ok(TenantKeys { inner, encryptor, keyring, + keyring_entries, }) } diff --git a/packages/cachekit/README.md b/packages/cachekit/README.md index e3ebbdb..18427dc 100644 --- a/packages/cachekit/README.md +++ b/packages/cachekit/README.md @@ -116,13 +116,10 @@ const cache = createCache({ maxMemory: 50 * 1024 * 1024, // 50MB }, - // Optional: Client-side encryption + // Optional: Client-side encryption (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 - // Optional: decrypt-only previous keys during a rotation grace window - // (max 3) — see "Master-key rotation" below - previousMasterKeys: process.env.CACHEKIT_PREVIOUS_MASTER_KEYS?.split(','), }, // Reliability settings diff --git a/packages/cachekit/src/constants.ts b/packages/cachekit/src/constants.ts index 8288b9c..618882c 100644 --- a/packages/cachekit/src/constants.ts +++ b/packages/cachekit/src/constants.ts @@ -120,11 +120,11 @@ 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. diff --git a/packages/cachekit/src/encryption/manager-core.test.ts b/packages/cachekit/src/encryption/manager-core.test.ts index 11f74b6..1a145c9 100644 --- a/packages/cachekit/src/encryption/manager-core.test.ts +++ b/packages/cachekit/src/encryption/manager-core.test.ts @@ -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, @@ -180,4 +183,58 @@ describe('EncryptionManagerCore keyring config (previousMasterKeys)', () => { 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 } = mockBindings(); + vi.mocked(bindings.deriveTenantKeys).mockImplementation( + (_masterKey: Uint8Array, tenantId: string) => ({ + tenantId, + encryptionFingerprint: () => new Uint8Array(16), + getNonceCounter: () => 0, + keyringEntryCount: () => 1, // built no keyring despite the argument + }) + ); + 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/); + manager.dispose(); + }); }); diff --git a/packages/cachekit/src/encryption/manager-core.ts b/packages/cachekit/src/encryption/manager-core.ts index 3729240..68cc4b8 100644 --- a/packages/cachekit/src/encryption/manager-core.ts +++ b/packages/cachekit/src/encryption/manager-core.ts @@ -2,8 +2,8 @@ import { EncryptionError, ConfigurationError, NonceExhaustedError } from '../err import { AAD_VERSION, MAX_PREVIOUS_MASTER_KEYS, - MIN_MASTER_KEY_BYTES, - MIN_MASTER_KEY_HEX_LENGTH, + MASTER_KEY_BYTES, + MASTER_KEY_HEX_LENGTH, } from '../constants.js'; /** @@ -16,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. @@ -66,9 +74,9 @@ 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 !== MIN_MASTER_KEY_HEX_LENGTH) { + if (key.length !== MASTER_KEY_HEX_LENGTH) { throw new ConfigurationError( - `${label} must be exactly ${MIN_MASTER_KEY_BYTES} bytes (${MIN_MASTER_KEY_HEX_LENGTH} hex characters), got ${key.length} hex characters` + `${label} must be exactly ${MASTER_KEY_BYTES} bytes (${MASTER_KEY_HEX_LENGTH} hex characters), got ${key.length} hex characters` ); } } @@ -108,7 +116,7 @@ export class EncryptionManagerCore { * 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 (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 * @param loadBindings - Platform bindings loader (NAPI or wasm) * @param previousMasterKeys - Decrypt-only previous master keys (max 3, @@ -132,19 +140,30 @@ export class EncryptionManagerCore { `previousMasterKeys accepts at most ${MAX_PREVIOUS_MASTER_KEYS} keys, got ${previousMasterKeys.length} — drop retired keys explicitly, the list is never truncated` ); } - for (const key of previousMasterKeys) { - validateKeyHex(key, 'Previous master key'); - } - // Case-insensitive: hex case differences encode the same key bytes. - // 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. + // Case-insensitive comparisons throughout: hex case differences encode + // the same key bytes. const current = masterKey.toLowerCase(); - if (previousMasterKeys.some((key) => key.toLowerCase() === 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' - ); - } + 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); + }); } /** @@ -180,14 +199,33 @@ export class EncryptionManagerCore { // Derive tenant keys (uses cachekit-core's derive_tenant_keys with domain "encryption") // Keys stay in binding memory - never copied to the JavaScript heap. - // Previous keys build the native decrypt keyring once, here — no key - // or derived-key bytes are retained on the JS side past this call. + // Previous keys build the native decrypt keyring once, here — the + // decoded byte buffers are not retained on the JS side past this call + // (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, previousKeyBytes.length > 0 ? previousKeyBytes : undefined ); + + // 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. @@ -231,12 +269,11 @@ export class EncryptionManagerCore { message.includes('Nonce counter exhausted') || message.includes('NonceCounterExhausted') ) { - throw new NonceExhaustedError( - 'Nonce counter exhausted. Key rotation required: rotate forward to a NEW master key ' + - '(never re-promote a retired key) and move this key into previousMasterKeys for the ' + - 'grace window. Runbook: https://docs.cachekit.io/concepts/key-rotation/', - { cause: error instanceof Error ? error : undefined } - ); + // Guidance (forward-only rotation + runbook link) lives once, in the + // NonceExhaustedError default message. + throw new NonceExhaustedError(undefined, { + cause: error instanceof Error ? error : undefined, + }); } throw new EncryptionError(`Encryption failed: ${message}`, { cause: error instanceof Error ? error : undefined, diff --git a/packages/cachekit/src/encryption/manager.ts b/packages/cachekit/src/encryption/manager.ts index 1cb73d2..7fa3581 100644 --- a/packages/cachekit/src/encryption/manager.ts +++ b/packages/cachekit/src/encryption/manager.ts @@ -42,7 +42,7 @@ 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 * @param previousMasterKeys - Decrypt-only previous master keys (max 3, * same hex format) for a key-rotation grace window; reads attempt keys diff --git a/packages/cachekit/src/errors.ts b/packages/cachekit/src/errors.ts index 5d1a92f..4655741 100644 --- a/packages/cachekit/src/errors.ts +++ b/packages/cachekit/src/errors.ts @@ -107,8 +107,9 @@ export class ValueTooLargeError extends CachekitError { export class NonceExhaustedError extends EncryptionError { constructor( message: string = 'Nonce counter exhausted, key rotation required. ' + - 'Rotate forward to a NEW master key (never re-promote a retired key): ' + - 'https://docs.cachekit.io/concepts/key-rotation/', + '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 772c323..4fa565f 100644 --- a/packages/cachekit/src/intents-core.ts +++ b/packages/cachekit/src/intents-core.ts @@ -94,7 +94,7 @@ 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; diff --git a/packages/cachekit/src/types/cache.ts b/packages/cachekit/src/types/cache.ts index 9c1f743..99bb501 100644 --- a/packages/cachekit/src/types/cache.ts +++ b/packages/cachekit/src/types/cache.ts @@ -95,7 +95,7 @@ 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; diff --git a/packages/cachekit/src/workers/runtime.ts b/packages/cachekit/src/workers/runtime.ts index e592b14..27437e6 100644 --- a/packages/cachekit/src/workers/runtime.ts +++ b/packages/cachekit/src/workers/runtime.ts @@ -49,7 +49,7 @@ 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 * @param previousMasterKeys - Decrypt-only previous master keys (max 3, * same hex format) for a key-rotation grace window; reads attempt keys From 159e570d358d6c6e9eca99187bdef2804d0304cb Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 8 Aug 2026 01:12:53 +1000 Subject: [PATCH 3/5] =?UTF-8?q?fix(review):=20kody=20findings=20=E2=80=94?= =?UTF-8?q?=20exact-pin=20js-sys,=20drop=20stale=20import,=20explicit=20te?= =?UTF-8?q?st-key=20fixture=20(LAB-685)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - js-sys exact-pinned to =0.3.98, the release paired with the already exact-pinned wasm-bindgen 0.2.121 (same ABI-tracking rationale). OSV + cargo-audit evidence added to the PR description. - Drop the unused MIN_MASTER_KEY_BYTES import in the real-crypto integration test — the constant was renamed to MASTER_KEY_BYTES in this PR and the import was never used, so the integration lane's tsc-less vitest run masked it. - Rotation test master keys now come from a testMasterKeyHex helper that documents they are deterministic fixtures, not secrets. --- packages/cachekit-core-wasm/Cargo.toml | 5 +++-- packages/cachekit/src/cache.rotation.test.ts | 10 ++++++++-- .../encryption-real-crypto.integration.test.ts | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/cachekit-core-wasm/Cargo.toml b/packages/cachekit-core-wasm/Cargo.toml index 84b7620..80178a7 100644 --- a/packages/cachekit-core-wasm/Cargo.toml +++ b/packages/cachekit-core-wasm/Cargo.toml @@ -19,8 +19,9 @@ crate-type = ["cdylib"] [dependencies] wasm-bindgen = "=0.2.121" # js-sys ships from the wasm-bindgen workspace and tracks its ABI; needed to -# accept Uint8Array[] (previous master keys) across the boundary. -js-sys = "0.3" +# 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"] } # Standalone crate — never join an enclosing cargo workspace. diff --git a/packages/cachekit/src/cache.rotation.test.ts b/packages/cachekit/src/cache.rotation.test.ts index 5226eff..cfa39c0 100644 --- a/packages/cachekit/src/cache.rotation.test.ts +++ b/packages/cachekit/src/cache.rotation.test.ts @@ -13,8 +13,14 @@ import { createCache } from './cache.js'; import { EncryptionError } from './errors.js'; import type { Backend } from './backends/types.js'; -const K1_HEX = '11'.repeat(32); -const K2_HEX = '22'.repeat(32); +/** + * 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 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 From 8fa3a6185a7ad5833e3dd461651e276c33f4cf6b Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 8 Aug 2026 13:47:26 +1000 Subject: [PATCH 4/5] test(encryption): cover keyring decrypt on the L1 ciphertext path (LAB-685) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging main brought in #104 (LAB-238), which makes L1 hold ciphertext for a secure cache. That creates a path neither branch could test on its own: an L2 read under a previous key repopulates L1 with bytes the current key cannot open, so every subsequent L1 hit has to run the keyring loop again. The existing rotation tests all disable L1 — correct when they were written, since L1 then held plaintext and rotation could not reach it. Without keyring coverage on that path decodeL1Entry drops the entry and falls through to L2 on every read for the whole grace window: a silent L1 bypass under degradation, a throw on every old-key read without it. Verified by mutation — breaking the L1 decrypt path turns the single backend.get into two, and the test fails. --- packages/cachekit/src/cache.rotation.test.ts | 38 ++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/cachekit/src/cache.rotation.test.ts b/packages/cachekit/src/cache.rotation.test.ts index cfa39c0..11ee614 100644 --- a/packages/cachekit/src/cache.rotation.test.ts +++ b/packages/cachekit/src/cache.rotation.test.ts @@ -109,6 +109,44 @@ describe('E2E key rotation round-trip', () => { 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'; From df0661ec139156ce787015bfdf1f0efc223a1dc7 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 9 Aug 2026 23:22:44 +1000 Subject: [PATCH 5/5] fix(encryption): zeroize key staging buffers across both bindings (LAB-685) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit round (6 findings) + expert-panel follow-up: - wasm: previous-master-key staging copies wrapped in Zeroizing; master key now crosses as a JS handle and is copied under Zeroizing too (the &[u8] ABI copied it into linear memory and freed it unwiped — panel finding on the CodeRabbit fix) - manager-core: decoded master/previous key buffers wiped in a finally once the binding has consumed them, error paths included - READMEs: zeroization claim corrected (JS hex strings cannot be scrubbed); wasm example frees the rotating keyring handle - tests: wrong-count skew path asserts the orphaned handle is freed; previous-key bytes snapshot at call time + post-call wipe asserted; native keyring-invariant errors matched by exact text --- packages/cachekit-core-wasm/Cargo.lock | 1 + packages/cachekit-core-wasm/Cargo.toml | 4 +++ packages/cachekit-core-wasm/README.md | 2 ++ packages/cachekit-core-wasm/src/lib.rs | 19 ++++++++--- packages/cachekit/README.md | 7 ++-- .../src/encryption/manager-core.test.ts | 32 ++++++++++++------- .../cachekit/src/encryption/manager-core.ts | 24 ++++++++------ .../encryption/manager.integration.test.ts | 18 +++++++---- 8 files changed, 74 insertions(+), 33 deletions(-) diff --git a/packages/cachekit-core-wasm/Cargo.lock b/packages/cachekit-core-wasm/Cargo.lock index 43f8581..65ba2d4 100644 --- a/packages/cachekit-core-wasm/Cargo.lock +++ b/packages/cachekit-core-wasm/Cargo.lock @@ -161,6 +161,7 @@ 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 80178a7..593c6f4 100644 --- a/packages/cachekit-core-wasm/Cargo.toml +++ b/packages/cachekit-core-wasm/Cargo.toml @@ -23,6 +23,10 @@ wasm-bindgen = "=0.2.121" # 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 1fa93d2..b0a4f52 100644 --- a/packages/cachekit-core-wasm/README.md +++ b/packages/cachekit-core-wasm/README.md @@ -41,6 +41,8 @@ tenantKeys.free(); // zeroizes key material deterministically // 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/src/lib.rs b/packages/cachekit-core-wasm/src/lib.rs index a1d6dcd..a7a8f68 100644 --- a/packages/cachekit-core-wasm/src/lib.rs +++ b/packages/cachekit-core-wasm/src/lib.rs @@ -20,6 +20,7 @@ use cachekit_core::encryption::key_derivation::{ }; 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 @@ -196,10 +197,15 @@ impl TenantKeys { /// identical semantics to the NAPI binding. #[wasm_bindgen(js_name = deriveTenantKeys)] pub fn derive_tenant_keys( - master_key: &[u8], + 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 {}", @@ -210,10 +216,13 @@ pub fn derive_tenant_keys( return Err(JsError::new("tenant_id cannot be empty")); } - let previous: Vec> = previous_master_keys + // Copying out of JS memory is unavoidable here (js_sys::Uint8Array is not + // linear memory); Zeroizing wipes the staging copies on every return path, + // including the length-check early returns below. + let previous: Vec>> = previous_master_keys .unwrap_or_default() .iter() - .map(|key| key.to_vec()) + .map(|key| Zeroizing::new(key.to_vec())) .collect(); for key in &previous { if key.len() != 32 { @@ -230,11 +239,11 @@ pub fn derive_tenant_keys( 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()))?) + 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()))?; + 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()))?; let keyring_entries = 1 + previous.len() as u32; diff --git a/packages/cachekit/README.md b/packages/cachekit/README.md index 0134f7c..8b65c6d 100644 --- a/packages/cachekit/README.md +++ b/packages/cachekit/README.md @@ -207,8 +207,11 @@ const cache = createCache.secure({ // or: CACHEKIT_PREVIOUS_MASTER_KEYS=, (comma-separated) ``` -All key material crosses into native memory once at initialization and is -zeroized on dispose — the keyring lives behind the NAPI (or wasm) boundary. +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): diff --git a/packages/cachekit/src/encryption/manager-core.test.ts b/packages/cachekit/src/encryption/manager-core.test.ts index 1a145c9..9949800 100644 --- a/packages/cachekit/src/encryption/manager-core.test.ts +++ b/packages/cachekit/src/encryption/manager-core.test.ts @@ -163,15 +163,25 @@ describe('EncryptionManagerCore keyring config (previousMasterKeys)', () => { expect(() => makeManager([''])).toThrow(ConfigurationError); }); - it('hands decoded previous-key bytes to the bindings exactly once', async () => { + 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).toHaveLength(1); expect(previous![0]).toBeInstanceOf(Uint8Array); - expect(Array.from(previous![0].slice(0, 2))).toEqual([0xcd, 0xcd]); + 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(); }); @@ -221,20 +231,20 @@ describe('EncryptionManagerCore keyring config (previousMasterKeys)', () => { }); it('refuses to init when the binding reports a wrong keyring entry count', async () => { - const { bindings } = mockBindings(); - vi.mocked(bindings.deriveTenantKeys).mockImplementation( - (_masterKey: Uint8Array, tenantId: string) => ({ - tenantId, - encryptionFingerprint: () => new Uint8Array(16), - getNonceCounter: () => 0, - keyringEntryCount: () => 1, // built no keyring despite the argument - }) + 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 68cc4b8..d741c64 100644 --- a/packages/cachekit/src/encryption/manager-core.ts +++ b/packages/cachekit/src/encryption/manager-core.ts @@ -199,16 +199,22 @@ export class EncryptionManagerCore { // Derive tenant keys (uses cachekit-core's derive_tenant_keys with domain "encryption") // 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 not retained on the JS side past this call - // (the hex config strings remain on the manager for init retry, per - // the documented masterKey pattern). + // 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, - previousKeyBytes.length > 0 ? previousKeyBytes : undefined - ); + 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 diff --git a/packages/cachekit/src/encryption/manager.integration.test.ts b/packages/cachekit/src/encryption/manager.integration.test.ts index 0694ff7..4767c8f 100644 --- a/packages/cachekit/src/encryption/manager.integration.test.ts +++ b/packages/cachekit/src/encryption/manager.integration.test.ts @@ -373,11 +373,17 @@ describe('EncryptionManager keyring rotation (real NAPI keyring loop)', () => { 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) - expect(() => napi.deriveTenantKeys(k2, 'tenant', [k2])).toThrow(); - // cap of 3 exceeded — rejected, never truncated - expect(() => napi.deriveTenantKeys(k2, 'tenant', others)).toThrow(); - // wrong-length previous key - expect(() => napi.deriveTenantKeys(k2, 'tenant', [Buffer.from('aabb', 'hex')])).toThrow(); + // 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/ + ); }); });