diff --git a/packages/cachekit-core-wasm/README.md b/packages/cachekit-core-wasm/README.md index 6cea303d..cd0ccc83 100644 --- a/packages/cachekit-core-wasm/README.md +++ b/packages/cachekit-core-wasm/README.md @@ -60,6 +60,11 @@ rotating.free(); // zeroize the whole keyring when the grace window ends Linear memory is a host-readable `ArrayBuffer`, so this is weaker isolation than the NAPI binding's Rust heap — on Workers the host is your own isolate, making it roughly JS-heap-equivalent in threat model. +- `deriveKey` reads the master key from the JS `Uint8Array` and returns the + derived key as a new JS `Uint8Array`: neither is staged in a linear-memory + buffer that is freed unwiped, and the build checks the generated glue for + that. The returned key is the caller's to wipe. HKDF's working buffers stay + on the stack in linear memory until later calls overwrite them. - `blake2b256Hex` copies its input (a cache key's encoded arguments) into linear memory and wipes that copy before freeing it. The hasher's last block of input, up to 128 bytes and so all of a short key, stays on the stack in diff --git a/packages/cachekit-core-wasm/index.d.ts b/packages/cachekit-core-wasm/index.d.ts index bb9db60c..c280c560 100644 --- a/packages/cachekit-core-wasm/index.d.ts +++ b/packages/cachekit-core-wasm/index.d.ts @@ -50,7 +50,13 @@ export declare class TenantKeys { hardwareAccelerationEnabled(): boolean; } -/** Derive a 32-byte domain key using HKDF-SHA256 (RFC 5869). */ +/** + * Derive a 32-byte domain key using HKDF-SHA256 (RFC 5869). The master key is + * read from, and the derived key returned as, JS memory: no linear-memory heap + * buffer holding either is freed unwiped. HKDF's working buffers stay on the + * wasm stack, which is linear memory, until later calls overwrite them. Wipe + * the returned key when done. + */ export declare function deriveKey( masterKey: Uint8Array, domain: string, diff --git a/packages/cachekit-core-wasm/scripts/build.sh b/packages/cachekit-core-wasm/scripts/build.sh index a3a962b7..27c7f828 100755 --- a/packages/cachekit-core-wasm/scripts/build.sh +++ b/packages/cachekit-core-wasm/scripts/build.sh @@ -65,4 +65,21 @@ if [ "$DRIFT" -ne 0 ]; then exit 1 fi +# Key-material glue check: the Workers SDK takes its AES keys from deriveKey, +# so its generated glue must not stage the master key in linear memory +# (passArray8ToWasm0) nor copy the derived key out of a linear-memory buffer +# it then frees unwiped (getArrayU8FromWasm0 + the free export, which +# wasm-bindgen may rename to __wbindgen_exportN). lib.rs passes both as JS +# Uint8Array handles instead. +DERIVE_KEY_GLUE=$(awk '/^export function deriveKey\(/,/^}/' pkg/cachekit_core_wasm.js) +if [ -z "$DERIVE_KEY_GLUE" ]; then + echo "error: deriveKey not found in pkg/cachekit_core_wasm.js" >&2 + exit 1 +fi +if grep -qE 'passArray8ToWasm0|getArrayU8FromWasm0|__wbindgen_free|wasm\.__wbindgen_export[0-9]*\([^)]*, 1\)' <<< "$DERIVE_KEY_GLUE"; then + echo "error: deriveKey glue copies key bytes through linear memory:" >&2 + echo "$DERIVE_KEY_GLUE" >&2 + exit 1 +fi + echo "build OK" diff --git a/packages/cachekit-core-wasm/src/lib.rs b/packages/cachekit-core-wasm/src/lib.rs index fb0f0c28..ba6cfb23 100644 --- a/packages/cachekit-core-wasm/src/lib.rs +++ b/packages/cachekit-core-wasm/src/lib.rs @@ -136,14 +136,29 @@ impl ByteStorage { } /// Key derivation using HKDF-SHA256 (RFC 5869). Same validation as NAPI. +/// +/// The master key arrives as a JS handle and the derived key leaves as a new +/// JS `Uint8Array`, so neither crosses through a linear-memory buffer that is +/// freed unwiped (the `&[u8]` argument and `Vec` return ABIs both do). +/// The staging copy and this function's copy of the derived key are wiped on +/// every return path. HKDF's own working buffers, the derived key returned by +/// value included, stay on the wasm stack until later calls overwrite them. +/// The Workers SDK takes its AES key from here once per manager. #[wasm_bindgen(js_name = deriveKey)] -pub fn derive_key(master_key: &[u8], domain: &str, tenant_salt: &str) -> Result, JsError> { - if master_key.len() != 32 { +pub fn derive_key( + master_key: js_sys::Uint8Array, + domain: &str, + tenant_salt: &str, +) -> Result { + // Checked before the copy: linear memory never shrinks, so copying an + // oversized array first would grow it for good. + if master_key.length() != 32 { return Err(JsError::new(&format!( "Master key must be 32 bytes, got {}", - master_key.len() + master_key.length() ))); } + let master_key = Zeroizing::new(master_key.to_vec()); if domain.is_empty() { return Err(JsError::new( "Domain cannot be empty - domain separation required for security", @@ -155,9 +170,11 @@ pub fn derive_key(master_key: &[u8], domain: &str, tenant_salt: &str) -> Result< )); } - derive_domain_key(master_key, domain, tenant_salt.as_bytes()) - .map(|derived| derived.to_vec()) - .map_err(|e| JsError::new(&e.to_string())) + let derived = Zeroizing::new( + derive_domain_key(&master_key, domain, tenant_salt.as_bytes()) + .map_err(|e| JsError::new(&e.to_string()))?, + ); + Ok(js_sys::Uint8Array::from(&derived[..])) } /// Per-tenant derived keys with automatic zeroization. diff --git a/packages/cachekit/README.md b/packages/cachekit/README.md index ff52c22b..e6a616ac 100644 --- a/packages/cachekit/README.md +++ b/packages/cachekit/README.md @@ -317,8 +317,10 @@ const cache = createCache.secure({ // or: CACHEKIT_PREVIOUS_MASTER_KEYS=, (comma-separated) ``` -The derived keyring lives behind the NAPI (or wasm) boundary and is zeroized -on dispose; the decoded key buffers are wiped as soon as the keyring is built. +On Node, the derived keyring lives behind the NAPI boundary and is zeroized +when the GC finalizes the handle after dispose. On Workers, the keys are non-extractable WebCrypto keys that +dispose drops for the garbage collector (see the Workers section). On both +runtimes, 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. @@ -726,12 +728,13 @@ runtimes apart. encryption manager forwards cachekit-core's detection. Informational only: the crypto backend picks its implementation independently, so use it to explain `.secure` latency, not to change behaviour. It initialises the bindings if -needed, and returns `null` (unknown) only when the installed binding -predates the accessor. The per-architecture semantics are core's — as of -cachekit-core 0.7.0 a runtime AES-NI probe on x86/x86_64, a runtime probe for -the Armv8 Crypto Extension's AES instructions on aarch64 (a Raspberry Pi 4, a -Cortex-A72 without the Crypto Extension, reports `false`), and `false` on -Cloudflare Workers (wasm32 has no AES instructions). +needed, and returns `null` (unknown) when the installed binding predates the +accessor, and always on Cloudflare Workers: there AES-GCM runs through the +runtime's WebCrypto, and an isolate cannot see whether that uses the host's +AES instructions. On Node the per-architecture semantics are core's — as of +cachekit-core 0.7.0 a runtime AES-NI probe on x86/x86_64, and a runtime probe +for the Armv8 Crypto Extension's AES instructions on aarch64 (a Raspberry Pi 4, +a Cortex-A72 without the Crypto Extension, reports `false`). ```typescript import { EncryptionManager } from '@cachekit-io/cachekit'; @@ -750,11 +753,12 @@ The SDK ships a Workers-native entrypoint: `@cachekit-io/cachekit/workers` (bare `import '@cachekit-io/cachekit'` also resolves to it under wrangler via the `workerd` export condition). It needs **no `nodejs_compat` flag** — the bundle contains no `node:*` builtins, no native addons, no ioredis, and no -prom-client. Crypto (AES-256-GCM + HKDF-SHA256, counter nonces) and the +prom-client. Key derivation (HKDF-SHA256), the key fingerprint and the ByteStorage wire envelope (LZ4 + xxHash3-64) run on a wasm32 build of the same audited Rust core the Node SDK uses (`@cachekit-io/cachekit-core-wasm`, -~66 KB gzipped), so ciphertexts and envelopes are byte-compatible across -Node, Workers, Python, and Rust. +~66 KB gzipped). AES-256-GCM runs through the runtime's WebCrypto, which is +native code, with the core's counter-nonce scheme. Ciphertexts and envelopes +are byte-compatible across Node, Workers, Python, and Rust. ```typescript import { createCache, type WorkersCache } from '@cachekit-io/cachekit/workers'; @@ -791,7 +795,7 @@ export default { > uniqueness rests on a random 64-bit instance id (a weaker, birthday-bounded > guarantee at very high request volumes) and leave wasm allocations behind > on hot isolates. If you do create short-lived caches, call `cache.close()` -> — it zeroizes key material and frees the wasm codec deterministically. +> — it releases key material and frees the wasm codec deterministically. ### Native edge storage: Workers KV and the Cache API @@ -879,11 +883,14 @@ reported through the library logger by key digest. release can be cancelled, leaving the lease held for `lockTimeoutMs` while other callers wait out `lockWaitMs`. - **No Prometheus metrics.** -- **Key material semantics**: keys are derived and held in wasm linear - memory, which is a host-readable `ArrayBuffer` — weaker isolation than the - NAPI Rust heap on Node. On Workers, the host is your own isolate, making - this roughly JS-heap-equivalent in threat model; `dispose()`/`close()` - still zeroizes deterministically. +- **Key material semantics**: keys are derived in wasm linear memory, a + host-readable `ArrayBuffer`, then imported once as non-extractable + WebCrypto `CryptoKey`s (decrypt-only for previous keys). The wasm key + handle and every JS copy of derived key bytes are wiped before the first + operation runs; HKDF's working buffers can stay on the wasm stack until + later calls overwrite them. A `CryptoKey` cannot be zeroized: + `dispose()`/`close()` drops it for the garbage collector. As on Node, the + master key itself stays in JS memory as its configured hex string. - **Startup**: wasm instantiation is a small one-time cost per isolate (~186 KB module, no I/O). A cache with compression off and no encryption never needs the module, so its keys are hashed in JS; once any cache has diff --git a/packages/cachekit/bench/ir-workload.mjs b/packages/cachekit/bench/ir-workload.mjs index 304b3f60..4dcae6d7 100644 --- a/packages/cachekit/bench/ir-workload.mjs +++ b/packages/cachekit/bench/ir-workload.mjs @@ -289,7 +289,9 @@ export const WORKLOADS = { // secure cache on Node: AAD v0x03 + AES-256-GCM through the NAPI core, decrypt + encrypt 'napi-encrypted': napiEncrypted(encoded, 10_000), 'napi-encrypted-64kb': napiEncrypted(incompressible, 500), - // secure cache on Workers: the same through the wasm core + // wasm-AES baseline: the same through the wasm core's AES-256-GCM. The + // Workers entry encrypts through WebCrypto instead, which Node cannot stand + // in for; bench/workers-crypto times that path inside workerd. 'wasm-encrypted': wasmEncrypted(encoded, 5_000), 'wasm-encrypted-64kb': wasmEncrypted(incompressible, 50), // a secure cache's L2 round trip on Node, L1 off: set + get, each running the diff --git a/packages/cachekit/bench/workers-crypto/ab.bench-workers.ts b/packages/cachekit/bench/workers-crypto/ab.bench-workers.ts new file mode 100644 index 00000000..91bb586e --- /dev/null +++ b/packages/cachekit/bench/workers-crypto/ab.bench-workers.ts @@ -0,0 +1,177 @@ +// AES-256-GCM per op on Workers, A/B inside workerd: the wasm core (A) against +// WebCrypto (B), both driven through EncryptionManagerCore on the current build. +// +// pnpm --filter @cachekit-io/cachekit bench:workers-crypto +// +// Method: +// - Sizes 100 B, 1 KB, 16 KB, 64 KB and 1 MB; encrypt and decrypt each. +// - A, B and an A/A twin (a second wasm manager) are interleaved within every +// rep, in an order rotated each rep, so host drift lands on all three alike. +// 60 reps at 1 KB and below, 9 above. +// - workerd's clock has 1 ms resolution, so each sample is a batch of k ops, +// k calibrated per arm to fill about 40 ms. The same k of no-op awaits is +// timed right before it and subtracted. +// - Per size and op: median µs/op of each arm; the A/A floor is +// |A − A/A| / A; the gain is (A − B) / A. Each rep also gives a paired gain +// (A_i − B_i) / A_i; a seeded bootstrap (2,000 resamples) of their median +// gives a 95% CI. PASS when the gain clears the floor or the CI excludes +// zero; REGRESS when the loss clears the floor or the CI is below zero; +// otherwise PARITY. The run fails unless every row passes. +// +// Local workerd numbers are indicative, not production timings. +import { expect, it } from 'vitest'; +import * as wasm from '@cachekit-io/cachekit-core-wasm'; +import { + EncryptionManagerCore, + type EncryptionBindings, +} from '../../src/encryption/manager-core.js'; +import { webCryptoEncryptionBindings } from '../../src/workers/webcrypto.js'; + +const MASTER_KEY = 'ab'.repeat(32); +const KEY = 'ns:bench:func:mod.fn:args:' + '0'.repeat(64) + ':'; +const SIZES = [100, 1024, 16 * 1024, 64 * 1024, 1024 * 1024]; +const TARGET_MS = 40; +const reps = (size: number) => (size <= 1024 ? 60 : 9); +const RESAMPLES = 2000; + +function wasmBindings(): EncryptionBindings { + wasm.ensureInitialized(); + return { + deriveTenantKeys: wasm.deriveTenantKeys, + encryptWithTenantKeys: (p, a, tk) => wasm.encryptWithTenantKeys(p, a, tk as wasm.TenantKeys), + decryptWithTenantKeys: (c, a, tk) => wasm.decryptWithTenantKeys(c, a, tk as wasm.TenantKeys), + }; +} + +function webCryptoBindings(): EncryptionBindings { + wasm.ensureInitialized(); + return webCryptoEncryptionBindings({ + deriveKey: wasm.deriveKey, + deriveTenantKeys: wasm.deriveTenantKeys, + }); +} + +type Op = () => Promise; +const noop: Op = async () => undefined; + +async function batchMs(k: number, op: Op): Promise { + const t0 = performance.now(); + for (let i = 0; i < k; i++) await op(); + return performance.now() - t0; +} + +/** Ops per batch so one batch fills about TARGET_MS. */ +async function calibrate(op: Op): Promise { + let k = 1; + for (;;) { + const ms = await batchMs(k, op); + if (ms >= TARGET_MS / 4) return Math.max(3, Math.ceil((k * TARGET_MS) / ms)); + k *= 4; + } +} + +/** One sample: µs per op, the no-op await baseline subtracted. */ +async function sample(k: number, op: Op): Promise { + const base = await batchMs(k, noop); + return ((await batchMs(k, op)) - base) * (1000 / k); +} + +/** mulberry32: a seeded PRNG, so a rerun of the same samples gives the same CI. */ +function prng(seed: number): () => number { + return () => { + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** 95% percentile-bootstrap CI of the median paired gain. */ +function pairedCi(gains: number[]): [number, number] { + const random = prng(0x5eed); + const medians: number[] = []; + for (let r = 0; r < RESAMPLES; r++) { + medians.push(median(gains.map(() => gains[Math.floor(random() * gains.length)]))); + } + medians.sort((a, b) => a - b); + return [medians[Math.floor(RESAMPLES * 0.025)], medians[Math.ceil(RESAMPLES * 0.975) - 1]]; +} + +const median = (xs: number[]) => { + const s = [...xs].sort((a, b) => a - b); + const m = s.length >> 1; + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; +}; + +interface Arm { + ops: Record<'encrypt' | 'decrypt', Op>; + k: Record<'encrypt' | 'decrypt', number>; + us: Record<'encrypt' | 'decrypt', number[]>; +} + +async function arm(bindings: EncryptionBindings, data: Uint8Array) { + const manager = new EncryptionManagerCore(MASTER_KEY, undefined, async () => bindings); + const ciphertext = await manager.encrypt(data, KEY); + const ops = { + encrypt: () => manager.encrypt(data, KEY), + decrypt: () => manager.decrypt(ciphertext, KEY), + }; + const k = { encrypt: await calibrate(ops.encrypt), decrypt: await calibrate(ops.decrypt) }; + const result: Arm = { ops, k, us: { encrypt: [], decrypt: [] } }; + return { arm: result, manager }; +} + +it('WebCrypto vs wasm AES-256-GCM, interleaved A/B with an A/A row', async () => { + const rows: string[] = []; + let failures = 0; + for (const size of SIZES) { + const data = new Uint8Array(size); + for (let i = 0; i < size; i++) data[i] = (i * 31 + 7) & 0xff; + const built = [ + await arm(wasmBindings(), data), // A + await arm(webCryptoBindings(), data), // B + await arm(wasmBindings(), data), // A/A + ]; + const arms = built.map((b) => b.arm); + for (let rep = 0; rep < reps(size); rep++) { + for (let i = 0; i < arms.length; i++) { + const a = arms[(rep + i) % arms.length]; + for (const op of ['encrypt', 'decrypt'] as const) + a.us[op].push(await sample(a.k[op], a.ops[op])); + } + } + for (const b of built) b.manager.dispose(); + + for (const op of ['encrypt', 'decrypt'] as const) { + const [a, b, aa] = arms.map((x) => median(x.us[op])); + const floor = Math.abs(a - aa) / a; + const gain = (a - b) / a; + const [lo, hi] = pairedCi(arms[0].us[op].map((ai, i) => (ai - arms[1].us[op][i]) / ai)); + const verdict = + gain > floor || lo > 0 ? 'PASS' : gain < -floor || hi < 0 ? 'REGRESS' : 'PARITY'; + if (verdict !== 'PASS') failures++; + rows.push( + [ + String(size).padStart(8), + op.padEnd(8), + a.toFixed(2).padStart(10), + b.toFixed(2).padStart(10), + aa.toFixed(2).padStart(10), + `${(floor * 100).toFixed(1)}%`.padStart(8), + `${(gain * 100).toFixed(1)}%`.padStart(8), + `${(a / b).toFixed(1)}x`.padStart(8), + `[${(lo * 100).toFixed(1)}%, ${(hi * 100).toFixed(1)}%]`.padStart(18), + verdict, + ].join(' ') + ); + } + } + console.log( + [ + 'AES-256-GCM per op in local workerd (indicative): A = wasm core, B = WebCrypto', + ' size op A µs B µs A/A µs A/A flr gain speedup paired 95% CI', + ...rows, + ].join('\n') + ); + expect(failures).toBe(0); +}); diff --git a/packages/cachekit/bench/workers-crypto/vitest.config.ts b/packages/cachekit/bench/workers-crypto/vitest.config.ts new file mode 100644 index 00000000..9cf240c7 --- /dev/null +++ b/packages/cachekit/bench/workers-crypto/vitest.config.ts @@ -0,0 +1,12 @@ +import workers from '../../vitest.workers.config.js'; + +// The Workers test lane's workerd setup (wasm module loading included), +// running only the A/B harness, with room for its timed loops. +export default { + ...workers, + test: { + ...workers.test, + include: ['bench/workers-crypto/ab.bench-workers.ts'], + testTimeout: 600_000, + }, +}; diff --git a/packages/cachekit/package.json b/packages/cachekit/package.json index 58ee7063..3b615c3c 100644 --- a/packages/cachekit/package.json +++ b/packages/cachekit/package.json @@ -66,6 +66,7 @@ "check:workers-bundle": "node scripts/check-workers-bundle.mjs", "bench:ir": "node bench/ir.mjs", "bench:cold-start": "node bench/cold-start.mjs", + "bench:workers-crypto": "vitest run --config bench/workers-crypto/vitest.config.ts --reporter=verbose", "bench:runtime": "node bench/runtime/run.mjs", "bench:live": "node bench/live/wall-time-probe.mjs", "bench:readme": "node bench/readme.mjs" diff --git a/packages/cachekit/src/cache-core.ts b/packages/cachekit/src/cache-core.ts index 02be7e59..e801f268 100644 --- a/packages/cachekit/src/cache-core.ts +++ b/packages/cachekit/src/cache-core.ts @@ -744,7 +744,7 @@ export class CacheImpl implements SecureCache { * decrypts at read time) and cachekit-rs with it. Holding post-decrypt * plaintext here would put the entire L1 working set into any heap dump, * core dump, or Node diagnostic report for the full TTL, and that plaintext - * would outlive the key zeroization in close(), since L1 entries are held + * would outlive the key release in close(), since L1 entries are held * independently of tenant keys. * * The bytes are copied when they are a window into a larger buffer: a Node @@ -1738,7 +1738,7 @@ export class CacheImpl implements SecureCache { if (this.closed) return; this.closed = true; - // Every step runs even if an earlier one throws — key zeroization, wasm + // Every step runs even if an earlier one throws — key release, wasm // frees, and the backend connection must not be leaked behind a failing // invalidation channel. Errors are collected and re-thrown (never // swallowed): the single-failure case keeps its original error type. @@ -1772,7 +1772,8 @@ export class CacheImpl implements SecureCache { } } - // Dispose encryption (zeroizes key material) + // Dispose encryption (releases key material: NAPI zeroizes it via the GC + // finalizer; on Workers the CryptoKeys are dropped for the GC) attempt(() => this.encryption?.dispose()); // Release the envelope codecs (zeroizes/frees wasm resources on Workers; diff --git a/packages/cachekit/src/constants.test.ts b/packages/cachekit/src/constants.test.ts new file mode 100644 index 00000000..0a006882 --- /dev/null +++ b/packages/cachekit/src/constants.test.ts @@ -0,0 +1,40 @@ +/** + * The encryption size limits live in three places: both native bindings' + * lib.rs and constants.ts, which the Workers WebCrypto adapter enforces. This + * pins the TypeScript copies to the Rust ones, so a change to one fails here. + */ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { MAX_AAD_SIZE, MAX_CIPHERTEXT_SIZE, MAX_PLAINTEXT_SIZE } from './constants.js'; + +const BINDINGS = ['cachekit-core-ts', 'cachekit-core-wasm']; + +/** Evaluate `const NAME: usize = ;`, where expr is integers, `*`, `+` and earlier names. */ +function rustConst(source: string, name: string, known: Record): number { + const match = new RegExp(`const ${name}: usize = ([^;]+);`).exec(source); + if (!match) throw new Error(`${name} not found`); + return match[1].split('+').reduce((sum, term) => { + const product = term + .trim() + .split('*') + .reduce((acc, factor) => { + const f = factor.trim(); + const value = /^\d+$/.test(f) ? Number(f) : known[f]; + if (value === undefined) throw new Error(`${name}: cannot evaluate "${f}"`); + return acc * value; + }, 1); + return sum + product; + }, 0); +} + +describe('encryption size limits match the native bindings', () => { + it.each(BINDINGS)('%s', (binding) => { + const source = readFileSync(new URL(`../../${binding}/src/lib.rs`, import.meta.url), 'utf8'); + const plaintext = rustConst(source, 'MAX_PLAINTEXT_SIZE', {}); + expect(plaintext).toBe(MAX_PLAINTEXT_SIZE); + expect(rustConst(source, 'MAX_CIPHERTEXT_SIZE', { MAX_PLAINTEXT_SIZE: plaintext })).toBe( + MAX_CIPHERTEXT_SIZE + ); + expect(rustConst(source, 'MAX_AAD_SIZE', {})).toBe(MAX_AAD_SIZE); + }); +}); diff --git a/packages/cachekit/src/constants.ts b/packages/cachekit/src/constants.ts index 1cbb9134..6a777055 100644 --- a/packages/cachekit/src/constants.ts +++ b/packages/cachekit/src/constants.ts @@ -137,11 +137,20 @@ export const AAD_VERSION = 0x03; /** * Maximum AAD size in bytes. Mirrors MAX_AAD_SIZE in both native bindings * (packages/cachekit-core-ts/src/lib.rs, packages/cachekit-core-wasm/src/lib.rs), - * which reject a larger AAD. The AAD carries the full cache key, so this caps - * the key length a secure cache can store. + * which reject a larger AAD; the Workers WebCrypto adapter + * (src/workers/webcrypto.ts) enforces it too. The AAD carries the full cache key, so this caps the key length + * a secure cache can store. */ export const MAX_AAD_SIZE = 64 * 1024; +/** + * Maximum plaintext and ciphertext sizes in bytes, mirroring the native + * bindings' MAX_PLAINTEXT_SIZE and MAX_CIPHERTEXT_SIZE (the same two files). + * The Workers WebCrypto adapter enforces them itself. + */ +export const MAX_PLAINTEXT_SIZE = 100 * 1024 * 1024; +export const MAX_CIPHERTEXT_SIZE = MAX_PLAINTEXT_SIZE + 1024; + /** Required master key length in bytes (exact — validation rejects any other length) */ export const MASTER_KEY_BYTES = 32; diff --git a/packages/cachekit/src/encryption/manager-core.test.ts b/packages/cachekit/src/encryption/manager-core.test.ts index 9122d9fb..4ef43bdb 100644 --- a/packages/cachekit/src/encryption/manager-core.test.ts +++ b/packages/cachekit/src/encryption/manager-core.test.ts @@ -399,3 +399,85 @@ describe('EncryptionManagerCore keyring config (previousMasterKeys)', () => { await expect(inFlight).rejects.toThrow(EncryptionError); }); }); + +// The Workers bindings are asynchronous (WebCrypto); the seam accepts a +// Promise from every method and must classify its rejection exactly as it +// classifies a synchronous throw. +describe('EncryptionManagerCore over asynchronous bindings', () => { + it('maps a rejected encrypt to NonceExhaustedError on exhaustion, EncryptionError otherwise', async () => { + const exhausted = mockBindings({ + encryptWithTenantKeys: vi.fn(() => + Promise.reject(new Error('Nonce counter exhausted - key rotation required')) + ), + }); + const other = mockBindings({ + encryptWithTenantKeys: vi.fn(() => Promise.reject(new Error('boom'))), + }); + const a = new TestManager(async () => exhausted.bindings); + const b = new TestManager(async () => other.bindings); + + const error = await a.encrypt(new Uint8Array([1]), 'ns:k').catch((e: unknown) => e); + expect(error).toBeInstanceOf(NonceExhaustedError); + expect((error as Error & { cause?: Error }).cause?.message).toContain( + 'Nonce counter exhausted' + ); + const otherError = await b.encrypt(new Uint8Array([1]), 'ns:k').catch((e: unknown) => e); + expect(otherError).toBeInstanceOf(EncryptionError); + expect(otherError).not.toBeInstanceOf(NonceExhaustedError); + a.dispose(); + b.dispose(); + }); + + it('maps a rejected decrypt to EncryptionError', async () => { + const { bindings } = mockBindings({ + decryptWithTenantKeys: vi.fn(() => + Promise.reject(new Error('Authentication verification failed')) + ), + }); + const manager = new TestManager(async () => bindings); + + await expect(manager.decrypt(new Uint8Array(28), 'ns:k')).rejects.toThrow(EncryptionError); + manager.dispose(); + }); + + it('returns the value an asynchronous encrypt and decrypt resolve to', async () => { + const { bindings } = mockBindings({ + encryptWithTenantKeys: vi.fn(async () => new Uint8Array([7])), + decryptWithTenantKeys: vi.fn(async () => new Uint8Array([8])), + }); + const manager = new TestManager(async () => bindings); + + expect(await manager.encrypt(new Uint8Array([1]), 'ns:k')).toEqual(new Uint8Array([7])); + expect(await manager.decrypt(new Uint8Array([1]), 'ns:k')).toEqual(new Uint8Array([8])); + manager.dispose(); + }); + + it('zero-fills the master-key bytes only after an asynchronous derive settles, on success and on rejection', async () => { + for (const outcome of ['resolve', 'reject'] as const) { + const { bindings } = mockBindings(); + const original = vi.mocked(bindings.deriveTenantKeys).getMockImplementation()!; + let seen: Uint8Array | undefined; + let settle!: () => void; + vi.mocked(bindings.deriveTenantKeys).mockImplementation((masterKey, tenantId, previous) => { + seen = masterKey; + return new Promise((resolve, reject) => { + settle = () => + outcome === 'resolve' + ? resolve(original(masterKey, tenantId, previous) as EncryptionTenantKeys) + : reject(new Error('importKey failed')); + }); + }); + const manager = new TestManager(async () => bindings); + + const pending = manager.encrypt(new Uint8Array([1]), 'ns:k'); + await vi.waitFor(() => expect(seen).toBeDefined()); + // Still pending: the bytes must be intact for the binding. + expect(Array.from(seen!.slice(0, 2))).toEqual([0xab, 0xab]); + settle(); + if (outcome === 'resolve') await pending; + else await expect(pending).rejects.toThrow(EncryptionError); + expect(seen!.every((b) => b === 0)).toBe(true); + manager.dispose(); + } + }); +}); diff --git a/packages/cachekit/src/encryption/manager-core.ts b/packages/cachekit/src/encryption/manager-core.ts index edca263c..b58d0cff 100644 --- a/packages/cachekit/src/encryption/manager-core.ts +++ b/packages/cachekit/src/encryption/manager-core.ts @@ -18,6 +18,14 @@ function aadComponent(text: string): Uint8Array { return component; } +/** + * Whether a binding returned a Promise. The NAPI bindings return plain values, + * and awaiting one would still cost a microtask hop on every Node operation. + */ +function isThenable(value: T | PromiseLike): value is PromiseLike { + return typeof (value as { then?: unknown }).then === 'function'; +} + function concatBytes(...parts: Uint8Array[]): Uint8Array { const out = new Uint8Array(parts.reduce((sum, p) => sum + p.length, 0)); let offset = 0; @@ -29,14 +37,14 @@ function concatBytes(...parts: Uint8Array[]): Uint8Array { } /** - * Tenant keys handle exposed by a bindings implementation (NAPI or wasm). - * Keys stay inside the binding's memory (Rust heap under NAPI, wasm linear - * memory on Workers) — never copied into the JS heap. + * Tenant keys handle exposed by a bindings implementation. Under NAPI the + * keys stay in the Rust heap, never copied into the JS heap. On Workers they + * are non-extractable WebCrypto CryptoKeys, derived by wasm cachekit-core. */ export interface EncryptionTenantKeys { readonly tenantId: string; encryptionFingerprint(): Uint8Array; - /** Get the current nonce counter from the Rust encryptor (for monitoring) */ + /** Get the current nonce counter from the encryptor (for monitoring) */ getNonceCounter(): number; /** * Keyring entries actually built at derivation (1 current key + @@ -48,20 +56,23 @@ export interface EncryptionTenantKeys { keyringEntryCount?(): number; /** * cachekit-core's AES hardware detection (informational). Optional: older - * binding binaries predate it, and the manager reports `null` for those. + * binding binaries predate it, and the Workers bindings cannot tell; the + * manager reports `null` (unknown) for both. */ hardwareAccelerationEnabled?(): boolean; /** - * Deterministic zeroize-and-release (wasm bindings). NAPI handles zeroize - * via GC finalizer instead and don't expose this. + * Deterministic release (Workers bindings). NAPI handles zeroize via GC + * finalizer instead and don't expose this. */ free?(): void; } /** * The cachekit-core binding surface EncryptionManagerCore drives. Implemented - * by @cachekit-io/cachekit-core-ts (NAPI, Node) and - * @cachekit-io/cachekit-core-wasm (wasm32, Cloudflare Workers). + * by @cachekit-io/cachekit-core-ts (NAPI, Node, synchronous) and by + * src/workers/webcrypto.ts (Cloudflare Workers: WebCrypto AES-GCM on keys + * derived by @cachekit-io/cachekit-core-wasm, asynchronous). Each method may + * return its result or a Promise of it; the manager awaits either. * * Error contract: when the encryptor's nonce counter is exhausted, * encryptWithTenantKeys MUST throw an Error whose message contains @@ -73,24 +84,25 @@ export interface EncryptionTenantKeys { export interface EncryptionBindings { /** * `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. + * rotation grace window. The binding builds its decrypt keyring once, here. + * It must be done with `masterKey` and `previousMasterKeys` by the time it + * returns (a Promise or not): the manager zero-fills them after awaiting it. */ deriveTenantKeys( masterKey: Uint8Array, tenantId: string, previousMasterKeys?: Uint8Array[] - ): EncryptionTenantKeys; + ): EncryptionTenantKeys | Promise; encryptWithTenantKeys( plaintext: Uint8Array, aad: Uint8Array, tenantKeys: EncryptionTenantKeys - ): Uint8Array; + ): Uint8Array | Promise; decryptWithTenantKeys( ciphertext: Uint8Array, aad: Uint8Array, tenantKeys: EncryptionTenantKeys - ): Uint8Array; + ): Uint8Array | Promise; } /** @@ -134,8 +146,8 @@ export class EncryptionManagerCore { private readonly aadHead: Uint8Array; private readonly aadTailCompressed: Uint8Array; private readonly aadTailUncompressed: Uint8Array; - // Note: Nonce tracking is done in Rust via getNonceCounter(). - // The Rust encryptor throws NonceCounterExhausted when the limit is reached. + // Note: Nonce tracking is done by the bindings' encryptor via getNonceCounter(). + // The encryptor throws "Nonce counter exhausted" when the limit is reached. /** * Create an encryption manager core. @@ -221,8 +233,10 @@ export class EncryptionManagerCore { /** * Initialize the bindings (lazy loading). * - * Uses the TenantKeys pattern from cachekit-core - keys stay in binding - * memory and are zeroized when the manager is disposed. + * Uses the TenantKeys pattern from cachekit-core. Under NAPI the keys stay + * in the Rust heap and are zeroized after dispose (GC finalizer). On Workers + * the derived bytes cross into JS once, are wiped at init, and are held as + * non-extractable CryptoKeys that dispose drops for the GC. */ private async ensureInitialized(): Promise { if (this.tenantKeys) return; @@ -249,15 +263,15 @@ export class EncryptionManagerCore { 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. - // 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). + // Derive tenant keys (uses cachekit-core's derive_tenant_keys with domain "encryption"). + // Previous keys build the decrypt keyring once, here. The decoded byte + // buffers are wiped in the finally below once the binding has consumed + // them (its Promise, if any, settled) — on error paths too (the hex + // config strings remain on the manager for init retry, per the + // documented masterKey pattern). let tenantKeys: EncryptionTenantKeys; try { - tenantKeys = this.native.deriveTenantKeys( + tenantKeys = await this.native.deriveTenantKeys( masterKeyBytes, this.effectiveTenantId, previousKeyBytes.length > 0 ? previousKeyBytes : undefined @@ -284,7 +298,7 @@ export class EncryptionManagerCore { } } if (this.disposed) { - // dispose() ran while init was in flight — zeroize immediately + // dispose() ran while init was in flight — release immediately // instead of parking live key material on a disposed manager. tenantKeys.free?.(); throw new EncryptionError('EncryptionManager has been disposed'); @@ -304,8 +318,9 @@ export class EncryptionManagerCore { /** * Encrypt data with AAD binding to cache key. * - * Uses TenantKeys pattern - keys never leave binding memory. - * Nonce exhaustion is detected by the Rust encryptor (at u32::MAX = 2^32). + * Uses the TenantKeys pattern: the keys stay with the binding (see + * ensureInitialized for where each binding holds them). + * Nonce exhaustion is detected by the bindings' encryptor (at counter 2^32 - 1). * * @param data - Plaintext data to encrypt * @param cacheKey - Cache key to bind in AAD (prevents ciphertext substitution) @@ -318,7 +333,9 @@ export class EncryptionManagerCore { try { const aad = this.buildAAD(cacheKey, compressed); - return this.native!.encryptWithTenantKeys(data, aad, this.tenantKeys!); + const result = this.native!.encryptWithTenantKeys(data, aad, this.tenantKeys!); + // Awaited inside the try, so a rejected Promise is classified below. + return isThenable(result) ? await result : result; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; // Detect nonce exhaustion from Rust error message @@ -341,10 +358,12 @@ export class EncryptionManagerCore { /** * Decrypt data and verify AAD binding. * - * Uses TenantKeys pattern - keys never leave binding memory. + * Uses the TenantKeys pattern: the keys stay with the binding (see + * ensureInitialized for where each binding holds them). * * With previousMasterKeys configured, the binding runs cachekit-core's - * keyring loop natively: sequential attempts, current key first, the + * keyring loop (natively under NAPI, in JS on Workers): 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)"). * @@ -358,7 +377,8 @@ export class EncryptionManagerCore { try { const aad = this.buildAAD(cacheKey, compressed); - return this.native!.decryptWithTenantKeys(ciphertext, aad, this.tenantKeys!); + const result = this.native!.decryptWithTenantKeys(ciphertext, aad, this.tenantKeys!); + return isThenable(result) ? await result : result; } catch (error) { throw new EncryptionError( `Decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`, @@ -368,10 +388,10 @@ export class EncryptionManagerCore { } /** - * Get the current nonce counter from the Rust encryptor. + * Get the current nonce counter from the bindings' encryptor. * * Monitor this value - rotate key before reaching 2^32. - * This directly reflects the Rust encryptor's nonce counter, + * This directly reflects the encryptor's nonce counter, * matching Python's ZeroKnowledgeEncryptor.get_nonce_counter(). */ async getEncryptionCount(): Promise { @@ -391,7 +411,8 @@ export class EncryptionManagerCore { * Whether AES-256-GCM is hardware-accelerated on this host, per * cachekit-core's detection (informational — see the README). Initialises * the bindings if needed, so it answers at startup. `null` = unknown: the - * installed binding predates the accessor. + * installed binding predates the accessor, or the manager runs on Workers, + * where the isolate cannot see whether WebCrypto uses AES instructions. * * @throws {EncryptionError} if the manager is disposed or bindings fail to load */ @@ -408,10 +429,12 @@ export class EncryptionManagerCore { } /** - * Dispose of the encryption manager and zeroize keys. + * Dispose of the encryption manager and release its keys. * * NAPI: releasing the reference triggers ZeroizeOnDrop via the GC finalizer. - * wasm: free() zeroizes deterministically, right now. + * Workers: free() drops the non-extractable CryptoKeys for the GC; a + * CryptoKey cannot be zeroized. Their derived bytes were already wiped at + * init. */ dispose(): void { this.disposed = true; diff --git a/packages/cachekit/src/workers/runtime.ts b/packages/cachekit/src/workers/runtime.ts index 7d8762d8..d842b848 100644 --- a/packages/cachekit/src/workers/runtime.ts +++ b/packages/cachekit/src/workers/runtime.ts @@ -1,26 +1,25 @@ /** * Cloudflare Workers runtime wiring: CachekitIO backend + wasm32 - * cachekit-core bindings (@cachekit-io/cachekit-core-wasm). + * cachekit-core bindings (@cachekit-io/cachekit-core-wasm), with AES-256-GCM + * through WebCrypto (./webcrypto.ts). * * Everything protocol-critical (AAD construction, envelope semantics, * interop) is shared with Node via cache-core.ts / manager-core.ts — this * file only swaps the platform bindings. * * Semantics deltas vs Node (documented in the README Workers section): - * - Keys live in wasm linear memory, which is a host-readable ArrayBuffer — - * weaker isolation than NAPI's Rust heap. On Workers, the host is your own - * isolate, so the threat model is roughly JS-heap-equivalent; free() - * still zeroizes deterministically on dispose(). + * - wasm cachekit-core derives the keys; each derived AES key is then + * imported once as a non-extractable CryptoKey, and its JS copy and the + * wasm key handle are wiped at init. The master key, like on Node, stays + * in the JS heap as the configured hex string. * - wasm instantiation is a one-time synchronous cost per isolate. */ import { ensureInitialized, ByteStorage as WasmByteStorage, - TenantKeys as WasmTenantKeys, + deriveKey, deriveTenantKeys, - encryptWithTenantKeys, - decryptWithTenantKeys, } from '@cachekit-io/cachekit-core-wasm'; import * as core from '@cachekit-io/cachekit-core-wasm'; import type { CacheOptions, SecureCache } from '../types/cache.js'; @@ -28,6 +27,7 @@ import type { CachekitIOBackendConfig } from '../backends/types.js'; import { cachekitio } from '../backends/cachekitio-factory.js'; import { CacheImpl, type CacheRuntime, type ExecutionContextLike } from '../cache-core.js'; import { EncryptionManagerCore, type EncryptionBindings } from '../encryption/manager-core.js'; +import { webCryptoEncryptionBindings } from './webcrypto.js'; import { ConfigurationError } from '../errors.js'; import { KEY_GEN_MAX_SIZE } from '../constants.js'; import { @@ -60,19 +60,14 @@ function initWasm(): void { ); } -function wasmEncryptionBindings(): EncryptionBindings { +function workersEncryptionBindings(): EncryptionBindings { initWasm(); - return { - deriveTenantKeys, - encryptWithTenantKeys: (plaintext, aad, tenantKeys) => - encryptWithTenantKeys(plaintext, aad, tenantKeys as WasmTenantKeys), - decryptWithTenantKeys: (ciphertext, aad, tenantKeys) => - decryptWithTenantKeys(ciphertext, aad, tenantKeys as WasmTenantKeys), - }; + return webCryptoEncryptionBindings({ deriveKey, deriveTenantKeys }); } /** - * High-level encryption manager backed by the wasm32 cachekit-core build. + * High-level encryption manager: keys derived by the wasm32 cachekit-core + * build, AES-256-GCM through WebCrypto. * * Same protocol behavior as the Node EncryptionManager (AAD v0x03, * HKDF-SHA256 tenant keys, counter nonces, getEncryptionCount monitoring) — @@ -89,7 +84,7 @@ export class EncryptionManager extends EncryptionManagerCore { * {@link EncryptionManagerCore} constructor */ constructor(masterKey: string, tenantId?: string, previousMasterKeys?: readonly string[]) { - super(masterKey, tenantId, async () => wasmEncryptionBindings(), previousMasterKeys); + super(masterKey, tenantId, async () => workersEncryptionBindings(), previousMasterKeys); } } diff --git a/packages/cachekit/src/workers/webcrypto.ts b/packages/cachekit/src/workers/webcrypto.ts new file mode 100644 index 00000000..07ddec4f --- /dev/null +++ b/packages/cachekit/src/workers/webcrypto.ts @@ -0,0 +1,264 @@ +/** + * WebCrypto AES-256-GCM bindings for the Workers EncryptionManager. + * + * Split of work with wasm cachekit-core: + * - cachekit-core (wasm) derives every key (HKDF-SHA256), computes the key + * fingerprint and enforces the tenant-id bounds, at init only. + * - This file runs AES-256-GCM through `crypto.subtle`, which workerd + * executes natively; wasm32 has no AES instructions, so the software AES + * and GHASH in the wasm build cost far more per byte. + * + * It reproduces the rest of the wasm binding's encrypt/decrypt contract + * exactly, because ciphertext must stay byte-identical across SDKs: + * - counter nonces: the encryptor's 8-byte instance id (big-endian), then a + * 4-byte big-endian counter from 0. The counter value is reserved in one + * synchronous step before any await, is never rolled back, and fails at + * 2^32−1 (cachekit-core `generate_nonce`). + * - instance ids: one counter per isolate, seeded once with 8 random bytes, + * giving each encryptor the next value, wrapping mod 2^64. + * - wire format: nonce(12) ‖ ciphertext ‖ tag(16). + * - the keyring decrypt loop: current key first, then each decrypt-only key + * in order, identical AAD each time; only an authentication failure moves + * on (cachekit-core `Keyring::decrypt`). + * - the binding's input limits, checked before `crypto.subtle` is reached. + */ + +import type { EncryptionBindings, EncryptionTenantKeys } from '../encryption/manager-core.js'; +import { MAX_AAD_SIZE, MAX_CIPHERTEXT_SIZE, MAX_PLAINTEXT_SIZE } from '../constants.js'; + +/** The two cachekit-core-wasm exports this adapter derives keys through. */ +export interface KeyDerivationCore { + deriveTenantKeys( + masterKey: Uint8Array, + tenantId: string + ): { encryptionFingerprint(): Uint8Array; free(): void }; + deriveKey(masterKey: Uint8Array, domain: string, tenantSalt: string): Uint8Array; +} + +// cachekit-core's minimum ciphertext, nonce(12) + tag(16). +const NONCE_BYTES = 12; +const MIN_CIPHERTEXT_SIZE = NONCE_BYTES + 16; +/** Counter values at or above this fail: cachekit-core checks `counter >= u32::MAX`. */ +const NONCE_COUNTER_LIMIT = 0xffffffff; + +// Messages carry cachekit-core's Display strings. EncryptionManagerCore +// classifies "Nonce counter exhausted" into NonceExhaustedError. +const NONCE_EXHAUSTED = 'Nonce counter exhausted - key rotation required'; +const AUTHENTICATION_FAILED = 'Authentication verification failed'; + +// WebCrypto's BufferSource excludes SharedArrayBuffer views, which the +// manager never passes; one that arrived would reject, as an EncryptionError. +type Bytes = Uint8Array; + +let instanceCounter: bigint | undefined; + +/** + * The next encryptor instance id. Seeded on first use, not at import: Workers + * refuse randomness in global scope. + */ +function nextInstanceId(): bigint { + if (instanceCounter === undefined) { + const seed = crypto.getRandomValues(new Uint8Array(8)); + instanceCounter = new DataView(seed.buffer).getBigUint64(0, false); + } + const id = instanceCounter; + instanceCounter = BigInt.asUintN(64, id + 1n); + return id; +} + +/** + * Test seam: set the isolate's instance-id counter, or clear it so the next + * manager reseeds from `crypto.getRandomValues`. + * + * @internal + */ +export function setInstanceCounterForTesting(seed?: bigint): void { + instanceCounter = seed === undefined ? undefined : BigInt.asUintN(64, seed); +} + +/** + * One manager's keys: the current key (encrypt and decrypt) first, then the + * decrypt-only previous keys, all non-extractable CryptoKeys. + */ +export class WebCryptoTenantKeys implements EncryptionTenantKeys { + /** + * The next counter value an encrypt reserves. Advanced by every encrypt + * that passes the size checks, including one that then fails. + */ + private nonceCounter = 0; + private keys: readonly CryptoKey[] | null; + /** The nonce's first 8 bytes: the instance id, big-endian. */ + private readonly noncePrefix = new Uint8Array(8); + + constructor( + readonly tenantId: string, + instanceId: bigint, + private readonly fingerprint: Uint8Array, + keys: readonly CryptoKey[] + ) { + this.keys = keys; + new DataView(this.noncePrefix.buffer).setBigUint64(0, instanceId, false); + } + + encryptionFingerprint(): Uint8Array { + return this.fingerprint.slice(); + } + + getNonceCounter(): number { + return this.nonceCounter; + } + + /** + * Test seam: set the next counter value an encrypt reserves. + * + * @internal + */ + setNonceCounterForTesting(value: number): void { + this.nonceCounter = value; + } + + keyringEntryCount(): number { + return this.keys?.length ?? 0; + } + + // No hardwareAccelerationEnabled: an isolate cannot see whether the host's + // WebCrypto uses AES instructions, so the manager reports null (unknown). + + /** Drop the CryptoKeys. They are not extractable, so the GC reclaims them. */ + free(): void { + this.keys = null; + } + + async encrypt(plaintext: Uint8Array, aad: Uint8Array): Promise { + if (plaintext.length > MAX_PLAINTEXT_SIZE) { + throw new Error(`Plaintext exceeds maximum size of ${MAX_PLAINTEXT_SIZE} bytes`); + } + checkAad(aad); + const key = this.liveKeys()[0]; + // Reserve the counter value before the first await: concurrent encrypts + // on one manager must never share a nonce. + const counter = this.nonceCounter++; + if (counter >= NONCE_COUNTER_LIMIT) throw new Error(NONCE_EXHAUSTED); + const nonce = new Uint8Array(NONCE_BYTES); + nonce.set(this.noncePrefix); + nonce[8] = counter >>> 24; + nonce[9] = counter >>> 16; + nonce[10] = counter >>> 8; + nonce[11] = counter; + + const sealed = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce, additionalData: aad as Bytes, tagLength: 128 }, + key, + plaintext as Bytes + ); + const out = new Uint8Array(NONCE_BYTES + sealed.byteLength); + out.set(nonce); + out.set(new Uint8Array(sealed), NONCE_BYTES); + return out; + } + + async decrypt(ciphertext: Uint8Array, aad: Uint8Array): Promise { + if (ciphertext.length > MAX_CIPHERTEXT_SIZE) { + throw new Error(`Ciphertext exceeds maximum size of ${MAX_CIPHERTEXT_SIZE} bytes`); + } + checkAad(aad); + if (ciphertext.length < MIN_CIPHERTEXT_SIZE) { + throw new Error('Invalid ciphertext format: Ciphertext too short'); + } + const keys = this.liveKeys(); + const params = { + name: 'AES-GCM', + iv: ciphertext.subarray(0, NONCE_BYTES) as Bytes, + additionalData: aad as Bytes, + tagLength: 128, + }; + const sealed = ciphertext.subarray(NONCE_BYTES) as Bytes; + for (const key of keys) { + try { + return new Uint8Array(await crypto.subtle.decrypt(params, key, sealed)); + } catch (error) { + // OperationError is WebCrypto's authentication failure: the wrong-key + // signal. Anything else would fail the same way under every key. + // Checked by name: a DOMException need not be an Error instance. + if ((error as { name?: unknown } | null)?.name !== 'OperationError') throw error; + } + } + throw new Error(AUTHENTICATION_FAILED); + } + + private liveKeys(): readonly CryptoKey[] { + if (this.keys === null) throw new Error('Tenant keys have been freed'); + return this.keys; + } +} + +function checkAad(aad: Uint8Array): void { + if (aad.length > MAX_AAD_SIZE) { + throw new Error(`AAD exceeds maximum size of ${MAX_AAD_SIZE} bytes`); + } +} + +/** + * Derive one manager's keys. Every wasm call runs before the first await, so + * all of them have returned before the caller can zero-fill the master-key + * bytes: deriveKey checks only the key's length, and would silently derive + * from an all-zero key. + * + * The finally below can zero-fill derived bytes while an import is still + * pending (Promise.all rejects on the first failure). That is safe only + * because WebCrypto's importKey copies keyData before it returns; never swap + * in an API that reads the bytes later. + */ +async function deriveTenantKeys( + core: KeyDerivationCore, + masterKey: Uint8Array, + tenantId: string, + previousMasterKeys: readonly Uint8Array[] +): Promise { + // deriveTenantKeys enforces the tenant bounds and yields the fingerprint. + // Its handle holds the raw derived keys, so it is freed (zeroized) at once. + const handle = core.deriveTenantKeys(masterKey, tenantId); + let fingerprint: Uint8Array; + try { + fingerprint = handle.encryptionFingerprint(); + } finally { + handle.free(); + } + + const derived: Uint8Array[] = []; + try { + for (const master of [masterKey, ...previousMasterKeys]) { + derived.push(core.deriveKey(master, 'encryption', tenantId)); + } + const instanceId = nextInstanceId(); + const keys = await Promise.all( + derived.map((bytes, index) => + crypto.subtle.importKey( + 'raw', + bytes as Bytes, + { name: 'AES-GCM' }, + false, + index === 0 ? ['encrypt', 'decrypt'] : ['decrypt'] + ) + ) + ); + return new WebCryptoTenantKeys(tenantId, instanceId, fingerprint, keys); + } finally { + for (const bytes of derived) bytes.fill(0); + } +} + +/** + * EncryptionBindings that encrypt and decrypt through `crypto.subtle`, on keys + * cachekit-core derives. `core` is the initialised cachekit-core-wasm module. + */ +export function webCryptoEncryptionBindings(core: KeyDerivationCore): EncryptionBindings { + return { + deriveTenantKeys: (masterKey, tenantId, previousMasterKeys) => + deriveTenantKeys(core, masterKey, tenantId, previousMasterKeys ?? []), + encryptWithTenantKeys: (plaintext, aad, tenantKeys) => + (tenantKeys as WebCryptoTenantKeys).encrypt(plaintext, aad), + decryptWithTenantKeys: (ciphertext, aad, tenantKeys) => + (tenantKeys as WebCryptoTenantKeys).decrypt(ciphertext, aad), + }; +} diff --git a/packages/cachekit/test/workers/encryption.protocol.workers.test.ts b/packages/cachekit/test/workers/encryption.protocol.workers.test.ts index 7fcab223..dfc69732 100644 --- a/packages/cachekit/test/workers/encryption.protocol.workers.test.ts +++ b/packages/cachekit/test/workers/encryption.protocol.workers.test.ts @@ -11,8 +11,9 @@ * 1. Raw wasm bindings decrypt EVERY ground-truth vector using the vector's * own aad_hex (covers arrow/orjson/original_type AADs the TS SDK never * builds itself). - * 2. The Workers EncryptionManager decrypts the msgpack vectors from - * (cacheKey, compressed) alone — AES-GCM authentication makes a + * 2. The Workers EncryptionManager (AES-GCM through WebCrypto, keys from + * the wasm build; webcrypto.workers.test.ts pins that path) decrypts the + * msgpack vectors from (cacheKey, compressed) alone — AES-GCM authentication makes a * successful decrypt a cryptographic proof its AAD construction matches * the ground-truth aad_hex byte-for-byte. * 3. Fingerprint, AAD literal equality, round-trips, counter-nonce @@ -288,7 +289,7 @@ describe('encryption + envelope composition (wasm end-to-end)', () => { }); }); -describe('keyring rotation — Workers EncryptionManager (wasm keyring loop)', () => { +describe('keyring rotation — Workers EncryptionManager (WebCrypto keyring loop)', () => { const K1_HEX = '11'.repeat(32); const K2_HEX = '22'.repeat(32); const CACHE_KEY = 'ns:workers:rotation'; diff --git a/packages/cachekit/test/workers/webcrypto.workers.test.ts b/packages/cachekit/test/workers/webcrypto.workers.test.ts new file mode 100644 index 00000000..a9e1b8eb --- /dev/null +++ b/packages/cachekit/test/workers/webcrypto.workers.test.ts @@ -0,0 +1,622 @@ +/** + * WebCrypto AES-256-GCM bindings — Workers lane + * + * The Workers EncryptionManager encrypts and decrypts through crypto.subtle + * (src/workers/webcrypto.ts) on keys wasm cachekit-core derives. These tests + * pin, inside real workerd: + * - byte parity with the protocol vectors (encrypt reproduces every + * ciphertext_hex from its nonce; every vector decrypts; fingerprints match); + * - interop with the wasm bindings at 64 KiB and 1 MB, both directions; + * - the counter-nonce scheme: instance id ‖ counter, reserved before any + * await, never rolled back, exhausted at 2^32−1; + * - tenant bounds from core, the input limits, error mapping, the keyring + * loop, and how key material is held and wiped. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as wasm from '@cachekit-io/cachekit-core-wasm'; +import { EncryptionManager } from '../../src/workers/runtime.js'; +import { + setInstanceCounterForTesting, + webCryptoEncryptionBindings, + type KeyDerivationCore, + type WebCryptoTenantKeys, +} from '../../src/workers/webcrypto.js'; +import { + EncryptionManagerCore, + type EncryptionBindings, +} from '../../src/encryption/manager-core.js'; +import { ConfigurationError, EncryptionError, NonceExhaustedError } from '../../src/errors.js'; +import { MAX_CIPHERTEXT_SIZE, MAX_PLAINTEXT_SIZE } from '../../src/constants.js'; +import { bytesToHex, hexToBytes } from '../fixtures/wire-vectors.js'; +import fixture from './fixtures/encryption.json' with { type: 'json' }; + +interface EncryptionVector { + name: string; + plaintext_hex: string; + cache_key: string; + compressed: boolean; + aad_hex: string; + ciphertext_hex: string; +} + +const vectors = fixture.vectors as EncryptionVector[]; +const masterKeyHex = fixture.master_key_hex as string; +const tenantId = fixture.tenant_id as string; +const keyring = fixture.keyring as { + tenant_id: string; + entries: { id: string; master_key_hex: string; derived_key_fingerprint_hex: string }[]; + vectors: (EncryptionVector & { encrypted_with: string })[]; +}; +const defaultTenant = fixture.default_tenant as { + tenant_id: string; + derived_key_fingerprint_hex: string; + vectors: EncryptionVector[]; +}; +const keyringEntry = (id: string) => keyring.entries.find((e) => e.id === id)!; + +const K1_HEX = '11'.repeat(32); +const K2_HEX = '22'.repeat(32); +const K3_HEX = '33'.repeat(32); +const MiB = 1024 * 1024; + +function realCore(): KeyDerivationCore { + wasm.ensureInitialized(); + return { deriveKey: wasm.deriveKey, deriveTenantKeys: wasm.deriveTenantKeys }; +} + +/** Byte equality without toEqual, whose element-wise diff takes seconds on 1 MB. */ +function sameBytes(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +/** Deterministic, cheap filler bytes for large payloads. */ +function payload(size: number): Uint8Array { + const bytes = new Uint8Array(size); + for (let i = 0; i < size; i++) bytes[i] = (i * 31 + 7) & 0xff; + return bytes; +} + +/** + * A manager over the WebCrypto bindings with spies on the wasm derive calls, + * exposing the tenant keys the bindings built. + */ +function spiedManager( + masterHex = masterKeyHex, + tenant: string | undefined = tenantId, + previous: string[] = [] +) { + const core = realCore(); + const derives: { master: number[]; tenant: string }[] = []; + const handles: { free: ReturnType }[] = []; + const derivedCopies: Uint8Array[] = []; + const masterArgs: Uint8Array[] = []; + const spiedCore: KeyDerivationCore = { + deriveTenantKeys: (masterKey, t) => { + masterArgs.push(masterKey); + const handle = core.deriveTenantKeys(masterKey, t); + const realFree = handle.free.bind(handle); + const spied = { + encryptionFingerprint: () => handle.encryptionFingerprint(), + free: vi.fn(realFree), + }; + handles.push(spied); + return spied; + }, + deriveKey: (masterKey, domain, salt) => { + masterArgs.push(masterKey); + derives.push({ master: Array.from(masterKey), tenant: salt }); + const out = core.deriveKey(masterKey, domain, salt); + derivedCopies.push(out); + return out; + }, + }; + const bindings = webCryptoEncryptionBindings(spiedCore); + let tenantKeys: WebCryptoTenantKeys | undefined; + const capturing: EncryptionBindings = { + ...bindings, + deriveTenantKeys: async (...args) => { + tenantKeys = (await bindings.deriveTenantKeys(...args)) as WebCryptoTenantKeys; + return tenantKeys; + }, + }; + const manager = new EncryptionManagerCore(masterHex, tenant, async () => capturing, previous); + return { + manager, + derives, + handles, + derivedCopies, + masterArgs, + tenantKeys: () => tenantKeys!, + }; +} + +/** Raw bindings plus tenant keys for (master, tenant), for vector-level tests. */ +async function rawBindings(masterHex: string, tenant: string, previous: string[] = []) { + const bindings = webCryptoEncryptionBindings(realCore()); + const tk = (await bindings.deriveTenantKeys( + hexToBytes(masterHex), + tenant, + previous.map(hexToBytes) + )) as WebCryptoTenantKeys; + return { bindings, tk }; +} + +afterEach(() => { + vi.restoreAllMocks(); + // Tests that seed the instance-id counter must not leak the seed on failure. + setInstanceCounterForTesting(); +}); + +describe('byte parity with the protocol vectors', () => { + it.each(vectors.map((v) => [v.name, v] as const))( + 'encrypt reproduces %s exactly from its nonce and aad_hex', + async (_name, vector) => { + const ciphertext = hexToBytes(vector.ciphertext_hex); + const nonce = new DataView(ciphertext.buffer); + // Steer the real nonce path to the vector's nonce: its first 8 bytes are + // the instance id, its last 4 the counter. + setInstanceCounterForTesting(nonce.getBigUint64(0, false)); + const { bindings, tk } = await rawBindings(masterKeyHex, tenantId); + tk.setNonceCounterForTesting(nonce.getUint32(8, false)); + const out = await bindings.encryptWithTenantKeys( + hexToBytes(vector.plaintext_hex), + hexToBytes(vector.aad_hex), + tk + ); + expect(bytesToHex(out)).toBe(vector.ciphertext_hex); + } + ); + + it.each(vectors.map((v) => [v.name, v] as const))( + 'decrypts vector %s with its aad_hex', + async (_name, vector) => { + const { bindings, tk } = await rawBindings(masterKeyHex, tenantId); + const plaintext = await bindings.decryptWithTenantKeys( + hexToBytes(vector.ciphertext_hex), + hexToBytes(vector.aad_hex), + tk + ); + expect(bytesToHex(plaintext)).toBe(vector.plaintext_hex); + } + ); + + it.each(keyring.vectors.map((v) => [v.name, v] as const))( + 'decrypts keyring vector %s through masterKey=k2, previousMasterKeys=[k1]', + async (_name, vector) => { + const { bindings, tk } = await rawBindings( + keyringEntry('k2').master_key_hex, + keyring.tenant_id, + [keyringEntry('k1').master_key_hex] + ); + const plaintext = await bindings.decryptWithTenantKeys( + hexToBytes(vector.ciphertext_hex), + hexToBytes(vector.aad_hex), + tk + ); + expect(bytesToHex(plaintext)).toBe(vector.plaintext_hex); + } + ); + + it.each(defaultTenant.vectors.map((v) => [v.name, v] as const))( + 'decrypts default-tenant vector %s', + async (_name, vector) => { + const { bindings, tk } = await rawBindings(masterKeyHex, defaultTenant.tenant_id); + const plaintext = await bindings.decryptWithTenantKeys( + hexToBytes(vector.ciphertext_hex), + hexToBytes(vector.aad_hex), + tk + ); + expect(bytesToHex(plaintext)).toBe(vector.plaintext_hex); + } + ); + + it('getKeyFingerprint matches the main, default-tenant and keyring fingerprints', async () => { + const cases: [EncryptionManager, string][] = [ + [new EncryptionManager(masterKeyHex, tenantId), fixture.derived_key_fingerprint_hex], + [new EncryptionManager(masterKeyHex), defaultTenant.derived_key_fingerprint_hex], + ...keyring.entries.map( + (e) => + [ + new EncryptionManager(e.master_key_hex, keyring.tenant_id), + e.derived_key_fingerprint_hex, + ] as [EncryptionManager, string] + ), + ]; + for (const [manager, expected] of cases) { + await manager.encrypt(new Uint8Array([1]), 'ns:fp'); + expect(bytesToHex((await manager.getKeyFingerprint())!)).toBe(expected); + manager.dispose(); + } + }); +}); + +describe('large-payload interop with the wasm bindings', () => { + const aad = new TextEncoder().encode('cachekit large-payload interop aad'); + + it.each([ + ['64 KiB', 64 * 1024], + ['1 MB', MiB], + ])('%s: WebCrypto → wasm and wasm → WebCrypto', async (_label, size) => { + const data = payload(size); + const { bindings, tk } = await rawBindings(masterKeyHex, tenantId); + wasm.ensureInitialized(); + const wasmKeys = wasm.deriveTenantKeys(hexToBytes(masterKeyHex), tenantId); + try { + const fromWebCrypto = await bindings.encryptWithTenantKeys(data, aad, tk); + expect(sameBytes(wasm.decryptWithTenantKeys(fromWebCrypto, aad, wasmKeys), data)).toBe(true); + + const fromWasm = wasm.encryptWithTenantKeys(data, aad, wasmKeys); + expect(sameBytes(await bindings.decryptWithTenantKeys(fromWasm, aad, tk), data)).toBe(true); + } finally { + wasmKeys.free(); + } + }); +}); + +describe('counter nonces', () => { + const nonceHex = (ciphertext: Uint8Array) => bytesToHex(ciphertext.subarray(0, 12)); + + it( + 'never repeats over 100,000 sequential encrypts on one manager', + { timeout: 120_000 }, + async () => { + const manager = new EncryptionManager(masterKeyHex, tenantId); + const seen = new Set(); + const data = new Uint8Array([1, 2, 3]); + for (let i = 0; i < 100_000; i++) { + seen.add(nonceHex(await manager.encrypt(data, 'ns:seq'))); + } + expect(seen.size).toBe(100_000); + expect(await manager.getEncryptionCount()).toBe(100_000); + manager.dispose(); + } + ); + + it( + 'gives 10,000 concurrent encrypts on one manager 10,000 distinct nonces', + { timeout: 60_000 }, + async () => { + const manager = new EncryptionManager(masterKeyHex, tenantId); + const data = new Uint8Array([4, 5, 6]); + const out = await Promise.all( + Array.from({ length: 10_000 }, () => manager.encrypt(data, 'ns:par')) + ); + expect(new Set(out.map(nonceHex)).size).toBe(10_000); + manager.dispose(); + } + ); + + it('advances the counter on a failed encrypt and never rolls it back', async () => { + const { manager } = spiedManager(); + await manager.encrypt(new Uint8Array([1]), 'ns:k'); + expect(await manager.getEncryptionCount()).toBe(1); + + vi.spyOn(crypto.subtle, 'encrypt').mockRejectedValueOnce(new Error('stubbed failure')); + await expect(manager.encrypt(new Uint8Array([1]), 'ns:k')).rejects.toThrow(EncryptionError); + expect(await manager.getEncryptionCount()).toBe(2); + + const next = await manager.encrypt(new Uint8Array([1]), 'ns:k'); + expect(new DataView(next.buffer, next.byteOffset).getUint32(8, false)).toBe(2); + expect(await manager.getEncryptionCount()).toBe(3); + manager.dispose(); + }); + + it('builds each nonce from the 8-byte instance id and the 4-byte big-endian counter', async () => { + setInstanceCounterForTesting(0x0102030405060708n); + const manager = new EncryptionManager(masterKeyHex, tenantId); + expect(await manager.getEncryptionCount()).toBe(0); + const first = await manager.encrypt(new Uint8Array([1]), 'ns:k'); + expect(await manager.getEncryptionCount()).toBe(1); + const second = await manager.encrypt(new Uint8Array([1]), 'ns:k'); + expect(await manager.getEncryptionCount()).toBe(2); + expect(nonceHex(first)).toBe('010203040506070800000000'); + expect(nonceHex(second)).toBe('010203040506070800000001'); + manager.dispose(); + }); + + it('writes every byte of the counter big-endian', async () => { + const { manager, tenantKeys } = spiedManager(); + await manager.encrypt(new Uint8Array([1]), 'ns:k'); + tenantKeys().setNonceCounterForTesting(0xa1b2c3d4); + const ct = await manager.encrypt(new Uint8Array([1]), 'ns:k'); + expect(bytesToHex(ct.subarray(8, 12))).toBe('a1b2c3d4'); + expect(await manager.decrypt(ct, 'ns:k')).toEqual(new Uint8Array([1])); + manager.dispose(); + }); + + it('gives two managers consecutive instance ids, wrapping mod 2^64', async () => { + setInstanceCounterForTesting(2n ** 64n - 1n); + const a = new EncryptionManager(masterKeyHex, tenantId); + const b = new EncryptionManager(masterKeyHex, tenantId); + const fromA = await a.encrypt(new Uint8Array([1]), 'ns:k'); + const fromB = await b.encrypt(new Uint8Array([1]), 'ns:k'); + expect(nonceHex(fromA)).toBe('ffffffffffffffff00000000'); + expect(nonceHex(fromB)).toBe('000000000000000000000000'); + a.dispose(); + b.dispose(); + }); + + it('seeds the instance-id counter once per isolate from 8 random bytes', async () => { + // Warm up first, so cachekit-core's own wasm instance counter is already + // seeded and only the adapter's seed can draw 8 bytes below. + const warm = new EncryptionManager(masterKeyHex, tenantId); + await warm.encrypt(new Uint8Array([1]), 'ns:k'); + warm.dispose(); + setInstanceCounterForTesting(); + + const realGetRandomValues = crypto.getRandomValues.bind(crypto); + const seedDraws: number[] = []; + vi.spyOn(crypto, 'getRandomValues').mockImplementation( + (array: T): T => { + if (array instanceof Uint8Array && array.length === 8) { + seedDraws.push(array.length); + array.set([0xaa, 0xbb, 0xcc, 0xdd, 0x00, 0x00, 0x00, 0x10]); + return array; + } + return realGetRandomValues(array as unknown as Uint8Array) as unknown as T; + } + ); + + const a = new EncryptionManager(masterKeyHex, tenantId); + const b = new EncryptionManager(masterKeyHex, tenantId); + const fromA = await a.encrypt(new Uint8Array([1]), 'ns:k'); + const fromB = await b.encrypt(new Uint8Array([1]), 'ns:k'); + expect(seedDraws).toEqual([8]); + expect(nonceHex(fromA)).toBe('aabbccdd0000001000000000'); + expect(nonceHex(fromB)).toBe('aabbccdd0000001100000000'); + a.dispose(); + b.dispose(); + }); + + it('throws NonceExhaustedError at counter 2^32−1, and again on the next encrypt', async () => { + const { manager, tenantKeys } = spiedManager(); + await manager.encrypt(new Uint8Array([1]), 'ns:k'); + tenantKeys().setNonceCounterForTesting(2 ** 32 - 1); + + for (let attempt = 0; attempt < 2; attempt++) { + const error = await manager.encrypt(new Uint8Array([1]), 'ns:k').catch((e: unknown) => e); + expect(error).toBeInstanceOf(NonceExhaustedError); + // The binding's error carries the contract string the manager classifies. + expect((error as Error & { cause?: Error }).cause?.message).toContain( + 'Nonce counter exhausted' + ); + } + manager.dispose(); + }); +}); + +describe('tenant bounds come from cachekit-core', () => { + it('derives keys for a tenant id of exactly 1,024 UTF-8 bytes', async () => { + const manager = new EncryptionManager(masterKeyHex, 'a'.repeat(1024)); + const ct = await manager.encrypt(new Uint8Array([1]), 'ns:k'); + expect(await manager.decrypt(ct, 'ns:k')).toEqual(new Uint8Array([1])); + manager.dispose(); + }); + + it.each([ + ['1,025 ASCII bytes', 'a'.repeat(1025)], + // 513 UTF-16 units, 1,026 UTF-8 bytes: a UTF-16 length check would pass it. + ['a multibyte tenant under 1,025 UTF-16 units', 'é'.repeat(513)], + ])('fails the first operation with EncryptionError for %s', async (_label, tenant) => { + const manager = new EncryptionManager(masterKeyHex, tenant); + await expect(manager.encrypt(new Uint8Array([1]), 'ns:k')).rejects.toThrow(EncryptionError); + manager.dispose(); + }); + + it('rejects an empty tenant id at construction', () => { + expect(() => new EncryptionManager(masterKeyHex, '')).toThrow(ConfigurationError); + }); +}); + +describe('input limits are checked before crypto.subtle', () => { + // A real 100 MiB buffer would crowd the isolate's memory, so the oversize + // plaintext and ciphertext are views that only report their length: the + // limit is checked before anything reads the bytes. + const oversize = (length: number) => ({ length }) as Uint8Array; + + it('rejects plaintext over 100 MiB and AAD over 64 KiB without calling encrypt', async () => { + const { bindings, tk } = await rawBindings(masterKeyHex, tenantId); + const encrypt = vi.spyOn(crypto.subtle, 'encrypt'); + await expect( + bindings.encryptWithTenantKeys(oversize(MAX_PLAINTEXT_SIZE + 1), new Uint8Array(1), tk) + ).rejects.toThrow(/Plaintext exceeds maximum size/); + await expect( + bindings.encryptWithTenantKeys(new Uint8Array(1), new Uint8Array(64 * 1024 + 1), tk) + ).rejects.toThrow(/AAD exceeds maximum size/); + expect(encrypt).not.toHaveBeenCalled(); + // Rejected inputs consume no nonce. + expect(tk.getNonceCounter()).toBe(0); + }); + + it('rejects ciphertext over 100 MiB + 1024, under 28 bytes, or AAD over 64 KiB without calling decrypt', async () => { + const { bindings, tk } = await rawBindings(masterKeyHex, tenantId); + const decrypt = vi.spyOn(crypto.subtle, 'decrypt'); + await expect( + bindings.decryptWithTenantKeys(oversize(MAX_CIPHERTEXT_SIZE + 1), new Uint8Array(1), tk) + ).rejects.toThrow(/Ciphertext exceeds maximum size/); + await expect( + bindings.decryptWithTenantKeys(new Uint8Array(28), new Uint8Array(64 * 1024 + 1), tk) + ).rejects.toThrow(/AAD exceeds maximum size/); + await expect( + bindings.decryptWithTenantKeys(new Uint8Array(27), new Uint8Array(1), tk) + ).rejects.toThrow(/Ciphertext too short/); + expect(decrypt).not.toHaveBeenCalled(); + }); + + it('accepts AAD of exactly 64 KiB', async () => { + const { bindings, tk } = await rawBindings(masterKeyHex, tenantId); + const aad = new Uint8Array(64 * 1024); + const ct = await bindings.encryptWithTenantKeys(new Uint8Array([9]), aad, tk); + expect(await bindings.decryptWithTenantKeys(ct, aad, tk)).toEqual(new Uint8Array([9])); + }); +}); + +describe('error mapping through the manager', () => { + it('maps a rejected crypto.subtle.encrypt to EncryptionError, not NonceExhaustedError', async () => { + const manager = new EncryptionManager(masterKeyHex, tenantId); + vi.spyOn(crypto.subtle, 'encrypt').mockRejectedValueOnce(new Error('stubbed failure')); + const error = await manager.encrypt(new Uint8Array([1]), 'ns:k').catch((e: unknown) => e); + expect(error).toBeInstanceOf(EncryptionError); + expect(error).not.toBeInstanceOf(NonceExhaustedError); + manager.dispose(); + }); + + it('maps a rejected decrypt to EncryptionError', async () => { + const manager = new EncryptionManager(masterKeyHex, tenantId); + await expect(manager.decrypt(new Uint8Array(27), 'ns:k')).rejects.toThrow(EncryptionError); + await expect(manager.decrypt(new Uint8Array(64), 'ns:k')).rejects.toThrow(EncryptionError); + manager.dispose(); + }); +}); + +describe('keyring decrypt loop', () => { + const CACHE_KEY = 'ns:workers:keyring'; + + async function writtenWith(masterHex: string, data: Uint8Array): Promise { + const writer = new EncryptionManager(masterHex, tenantId); + const ct = await writer.encrypt(data, CACHE_KEY); + writer.dispose(); + return ct; + } + + it('tries the current key, then each previous key in order, with the same AAD', async () => { + const data = new TextEncoder().encode('rotated'); + const fromK1 = await writtenWith(K1_HEX, data); + const fromK2 = await writtenWith(K2_HEX, data); + const reader = new EncryptionManager(K3_HEX, tenantId, [K2_HEX, K1_HEX]); + await reader.encrypt(new Uint8Array([0]), CACHE_KEY); // init outside the spy + + const decrypt = vi.spyOn(crypto.subtle, 'decrypt'); + expect(await reader.decrypt(fromK2, CACHE_KEY)).toEqual(data); + expect(decrypt).toHaveBeenCalledTimes(2); + decrypt.mockClear(); + + expect(await reader.decrypt(fromK1, CACHE_KEY)).toEqual(data); + expect(decrypt).toHaveBeenCalledTimes(3); + const keys = decrypt.mock.calls.map(([, key]) => key); + // The current key alone may encrypt; the k2 attempt precedes the k1 one + // (k2 is the second call here and decrypted fromK2 above as the second). + expect(keys[0].usages.slice().sort()).toEqual(['decrypt', 'encrypt']); + expect(keys[1].usages).toEqual(['decrypt']); + expect(keys[2].usages).toEqual(['decrypt']); + expect(new Set(keys).size).toBe(3); + const aads = decrypt.mock.calls.map(([params]) => + bytesToHex((params as { additionalData: Uint8Array }).additionalData) + ); + expect(new Set(aads).size).toBe(1); + reader.dispose(); + }); + + it('stops at a non-authentication error instead of trying the next key', async () => { + const reader = new EncryptionManager(K3_HEX, tenantId, [K2_HEX, K1_HEX]); + const fromK1 = await writtenWith(K1_HEX, new Uint8Array([1])); + await reader.encrypt(new Uint8Array([0]), CACHE_KEY); + const decrypt = vi + .spyOn(crypto.subtle, 'decrypt') + .mockRejectedValueOnce(new TypeError('structural failure')); + await expect(reader.decrypt(fromK1, CACHE_KEY)).rejects.toThrow(EncryptionError); + expect(decrypt).toHaveBeenCalledTimes(1); + reader.dispose(); + }); + + it('makes no attempt with any key for a ciphertext under 28 bytes', async () => { + const reader = new EncryptionManager(K3_HEX, tenantId, [K2_HEX, K1_HEX]); + await reader.encrypt(new Uint8Array([0]), CACHE_KEY); + const decrypt = vi.spyOn(crypto.subtle, 'decrypt'); + await expect(reader.decrypt(new Uint8Array(27), CACHE_KEY)).rejects.toThrow(EncryptionError); + expect(decrypt).not.toHaveBeenCalled(); + reader.dispose(); + }); + + it('derives and imports each keyring key once per manager across 10 decrypts', async () => { + const data = new Uint8Array([1, 2, 3]); + const fromK1 = await writtenWith(K1_HEX, data); + const importKey = vi.spyOn(crypto.subtle, 'importKey'); + const { manager, derives, tenantKeys } = spiedManager(K3_HEX, tenantId, [K2_HEX, K1_HEX]); + for (let i = 0; i < 10; i++) { + expect(await manager.decrypt(fromK1, CACHE_KEY)).toEqual(data); + } + expect(derives.map((d) => bytesToHex(Uint8Array.from(d.master)))).toEqual([ + K3_HEX, + K2_HEX, + K1_HEX, + ]); + expect(importKey).toHaveBeenCalledTimes(3); + expect(tenantKeys().keyringEntryCount()).toBe(3); + manager.dispose(); + }); +}); + +describe('key material', () => { + it('imports every key non-extractable: current encrypt+decrypt, previous decrypt only', async () => { + const importKey = vi.spyOn(crypto.subtle, 'importKey'); + const manager = new EncryptionManager(K3_HEX, tenantId, [K2_HEX, K1_HEX]); + await manager.encrypt(new Uint8Array([1]), 'ns:k'); + const keys = await Promise.all( + importKey.mock.results.map((r) => r.value as Promise) + ); + expect(keys).toHaveLength(3); + for (const call of importKey.mock.calls) expect(call[3]).toBe(false); + for (const key of keys) expect(key.extractable).toBe(false); + expect(keys[0].usages.slice().sort()).toEqual(['decrypt', 'encrypt']); + expect(keys[1].usages).toEqual(['decrypt']); + expect(keys[2].usages).toEqual(['decrypt']); + manager.dispose(); + }); + + it('wipes the derived bytes and the master-key bytes after init', async () => { + const { manager, derives, derivedCopies, masterArgs } = spiedManager(K3_HEX, tenantId, [ + K2_HEX, + ]); + await manager.encrypt(new Uint8Array([1]), 'ns:k'); + // Every derive saw the real key bytes: none ran after the zero-fill. + expect(derives.map((d) => d.master.every((b) => b === 0))).toEqual([false, false]); + expect(derivedCopies).toHaveLength(2); + for (const bytes of [...derivedCopies, ...masterArgs]) { + expect(bytes.every((b) => b === 0)).toBe(true); + } + manager.dispose(); + }); + + it('wipes the derived bytes and the master-key bytes when importKey rejects', async () => { + vi.spyOn(crypto.subtle, 'importKey').mockRejectedValue(new Error('import refused')); + const { manager, derives, derivedCopies, masterArgs } = spiedManager(K3_HEX, tenantId, [ + K2_HEX, + ]); + await expect(manager.encrypt(new Uint8Array([1]), 'ns:k')).rejects.toThrow(EncryptionError); + expect(derives).toHaveLength(2); + expect(derives.map((d) => d.master.every((b) => b === 0))).toEqual([false, false]); + for (const bytes of [...derivedCopies, ...masterArgs]) { + expect(bytes.every((b) => b === 0)).toBe(true); + } + manager.dispose(); + }); + + it('frees the wasm TenantKeys handle before init resolves, and keeps the fingerprint', async () => { + const { manager, handles } = spiedManager(); + await manager.encrypt(new Uint8Array([1]), 'ns:k'); + expect(handles).toHaveLength(1); + expect(handles[0].free).toHaveBeenCalledTimes(1); + expect(bytesToHex((await manager.getKeyFingerprint())!)).toBe( + fixture.derived_key_fingerprint_hex + ); + manager.dispose(); + }); + + it('deriveKey rejects a master key that is not 32 bytes', () => { + const core = realCore(); + expect(() => core.deriveKey(new Uint8Array(31), 'encryption', tenantId)).toThrow(/32 bytes/); + expect(() => core.deriveKey(new Uint8Array(1024), 'encryption', tenantId)).toThrow( + /32 bytes, got 1024/ + ); + }); + + it('reports isHardwareAccelerated() as null (unknown) on Workers', async () => { + const manager = new EncryptionManager(masterKeyHex, tenantId); + expect(await manager.isHardwareAccelerated()).toBeNull(); + manager.dispose(); + }); +});