diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2fb41a0..8b45597 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -147,7 +147,7 @@ Six harnesses measure the client. **Call shape (runs in `pnpm test`).** `packages/cachekit/test/transport/call-shape.test.ts` runs the CacheKit Cloud backend against a local TLS fake of CacheKit Cloud. It asserts the exact requests each op sends (a wrap miss is `GET` then `PUT`, and with `writeBehind` the caller waits for the `GET` only; a locked miss is `GET`, lock, `PUT`, unlock) and the connections 100 ops use. Each extra request is a round trip, and each extra connection is a TCP and TLS handshake. So if you change one of these numbers, change the expectation in the same PR and say why. The fake mints a throwaway certificate with the `openssl` CLI, and it needs Node 22.19+ or 24.5+ for `tls.setDefaultCACertificates`. Where either is missing, the tests fail with a message that names the requirement. -**Instructions per op (`pnpm --filter @cachekit-io/cachekit bench:ir`).** This counts the main-thread instructions of each hot path under callgrind: key generation through the NAPI hash (`keygen`, `keygen-1kb`) and the wasm hash (`keygen-wasm`, `keygen-wasm-1kb`), at 45 B and 1 KB of arguments, the serializer, the NAPI and wasm envelopes (1 KB, plus 40 B under the core's 64 B literals-only cutover and 64 KiB), both encrypted paths (1 KB, plus 64 KiB of incompressible bytes), and the L1 hit through `get()` (`get-l1-hit`) and through `wrap()`, once with an object among the arguments and once with the single integer argument the README benchmark passes, the encrypted `wrap()` L1 hit at 100 B, 10 KB and 1 MB, and the encrypted `get()` L1 hit at 100 B (`secure-get-l1-hit`). Unlike wall-clock timing, instruction counts repeat on a busy machine. The suite pins V8's flags and counts only the measured ops. It also lowers V8's TurboFan threshold, so no optimizing compile lands in any workload's window. For all but the two 1 MB workloads, the window counts the code V8 runs in steady state. Those run so few ops that the `set()` and `wrap()` code around each 1 MB encode or decode stays in V8's interpreter for the whole run, which costs too little to show beside the encode or decode. A workload that tiers up later sets its own `warmup`; `bench/ir-workload.mjs` says how to check one with `--trace-opt`. The 100,000-entry L1 at-capacity workload collects garbage just before its window, so V8 does not finish collecting the entries its setup made inside it: that had been two thirds of its count, and it moved with whatever the build allocated at load. The 1,000- and 10,000-entry ones do not collect: at 10,000 a collection made the count move 2.4% with heap layout. On Node 22 and 24, every path repeats within 0.01%. On Node 26, paths through the native cores repeat within about 0.3%, but `napi-encrypted` and the encrypted `wrap()` hits at 100 B and 1 MB sometimes have a run up to 1.7% off the rest, which makes them read inconclusive. Gate on Node 22 or 24. It needs Linux and valgrind. It imports `dist/` and the wasm build, so run `pnpm build` and `pnpm --filter @cachekit-io/cachekit-core-wasm build:wasm` first; the second needs the `wasm32-unknown-unknown` target, `wasm-bindgen` and `wasm-opt`, and without it pass `--only` to skip the seven wasm workloads (`wasm-envelope`, `wasm-envelope-40b`, `wasm-envelope-64kb`, `wasm-encrypted`, `wasm-encrypted-64kb`, `keygen-wasm`, `keygen-wasm-1kb`). To A/B a change, run it on both builds on the same machine: +**Instructions per op (`pnpm --filter @cachekit-io/cachekit bench:ir`).** This counts the main-thread instructions of each hot path under callgrind: key generation through the NAPI hash (`keygen`, `keygen-1kb`) and the wasm hash (`keygen-wasm`, `keygen-wasm-1kb`), at 45 B and 1 KB of arguments, the serializer, the NAPI and wasm envelopes (1 KB, plus 40 B under the core's 64 B literals-only cutover and 64 KiB), both encrypted paths (1 KB, plus 64 KiB of incompressible bytes), and the L1 hit through `get()` (`get-l1-hit`) and through `wrap()`, once with an object among the arguments and once with the single integer argument the README benchmark passes, the encrypted `wrap()` L1 hit at 100 B, 10 KB and 1 MB, the encrypted `get()` L1 hit at 100 B (`secure-get-l1-hit`), and the encrypted `set()` at 100 B (`secure-set-100b`). Unlike wall-clock timing, instruction counts repeat on a busy machine. The suite pins V8's flags and counts only the measured ops. It also lowers V8's TurboFan threshold, so no optimizing compile lands in any workload's window. For all but the two 1 MB workloads, the window counts the code V8 runs in steady state. Those run so few ops that the `set()` and `wrap()` code around each 1 MB encode or decode stays in V8's interpreter for the whole run, which costs too little to show beside the encode or decode. A workload that tiers up later sets its own `warmup`; `bench/ir-workload.mjs` says how to check one with `--trace-opt`. The 100,000-entry L1 at-capacity workload collects garbage just before its window, so V8 does not finish collecting the entries its setup made inside it: that had been two thirds of its count, and it moved with whatever the build allocated at load. The 1,000- and 10,000-entry ones do not collect: at 10,000 a collection made the count move 2.4% with heap layout. On Node 22 and 24, every path repeats within 0.01%. On Node 26, paths through the native cores repeat within about 0.3%, but `napi-encrypted` and the encrypted `wrap()` hits at 100 B and 1 MB sometimes have a run up to 1.7% off the rest, which makes them read inconclusive. Gate on Node 22 or 24. It needs Linux and valgrind. It imports `dist/` and the wasm build, so run `pnpm build` and `pnpm --filter @cachekit-io/cachekit-core-wasm build:wasm` first; the second needs the `wasm32-unknown-unknown` target, `wasm-bindgen` and `wasm-opt`, and without it pass `--only` to skip the seven wasm workloads (`wasm-envelope`, `wasm-envelope-40b`, `wasm-envelope-64kb`, `wasm-encrypted`, `wasm-encrypted-64kb`, `keygen-wasm`, `keygen-wasm-1kb`). To A/B a change, run it on both builds on the same machine: ```bash git checkout -- packages/cachekit/bench # in the base checkout: your bench/, as CI does diff --git a/packages/cachekit/README.md b/packages/cachekit/README.md index 2b59056..296de5d 100644 --- a/packages/cachekit/README.md +++ b/packages/cachekit/README.md @@ -1189,7 +1189,7 @@ What one call costs in the client, on an in-memory backend, so no figure include - **`wrap()` L1 hit**: the hit returns the stored object as it is, so the value's size barely matters. A wrapped function remembers the key of each call whose arguments are all strings, numbers, booleans, `null` or `undefined`, and the L1 entry that call was last served from. A repeat call is served from that entry on one check, with no key generation and no lookup by key, while L1 still holds the entry and it is short of its stale-while-revalidate point (the top of that point's ±10% jitter band); once L1 evicts or expires it, or it reaches that band, the call looks its key up in L1 again. `delete()`, `invalidate()` and an invalidation event empty the memo too, so the next call generates its key first, and after `close()` every call is rejected. It remembers a call only if it has at most 8 arguments, no `NaN`, and strings whose `length` adds up to 256 or less. Every other call generates its key, which adds about the cost of the key generation row: an object, array, `Date`, `Map`, `Set` or binary argument, a call past those limits, a call with `skipL1` or under `ttl: 0`, every `interop` call, and every call on a cache that encrypts or has L1 off, or on Cloudflare Workers. - **Key memo**: a wrapped function remembers at most 1,024 keys and starts again when full: about 160 KiB when each call passes one number, and at most about 1 MiB. It holds copies of the arguments, so a cache that encrypts keeps no memo, and `delete()`, `invalidate()`, `close()` and, with `invalidation` configured, an invalidation event from another instance empty every memo of the cache. A timer also empties a memo one second after its first key, or one `ttl` after it if that is sooner, even if the function is not called again, so it is set to drop an argument no later than L1 stops serving a value stored with that `ttl`. The timer can run late, for example when the event loop is busy or the process is suspended, and the memo keeps its arguments until it runs. A call that comes one `ttl` or more after the first key, before the timer has run, empties the memo first. The call that stores the first key sets that timer, which keeps the call's async context, `AsyncLocalStorage` stores included, until it runs: one second or less after it was set, unless it runs late. So a function called again and again with the same arguments generates their key about once a second, or once per `ttl` if that is shorter. Wrap a function once, at module scope: a function wrapped inside each request keeps that request's async context and its memo until the memo's timer runs, one second later or one `ttl` later if that is sooner. With a `ttl` of a second or more, at 1,000 requests a second with a 16 KiB store, that is about 16 MB. A wrap under `ttl: 0`, which never expires, keeps no memo, and nor does a Worker, where a timer can be cancelled when its request ends. -- **Encrypted L1 hit**: L1 holds the same ciphertext as L2, so every hit also generates the key, decrypts, verifies the AAD and decodes the value. That cost grows with the value. A read from L2 also walks the document to check its nesting depth, before the decode and again after it. So does the first hit on an entry, which then keeps its own copy of the ciphertext in L1; later hits on that copy skip the walks (an `interop` entry keeps the check after the decode). On Node.js the decrypt is a synchronous call into the native core, so once the cache has derived its keys, at its first encrypt or decrypt, a hit through `get()`, `exists()` or `wrap()` awaits no promise. On Cloudflare Workers, WebCrypto decrypts asynchronously, so the hit waits for it. +- **Encrypted L1 hit**: L1 holds the same ciphertext as L2, so every hit also generates the key, decrypts, verifies the AAD and decodes the value. That cost grows with the value. A read from L2 also walks the document to check its nesting depth, before the decode and again after it. So does the first hit on an entry, which then keeps its own copy of the ciphertext in L1; later hits on that copy skip the walks (an `interop` entry keeps the check after the decode). On Node.js the decrypt and the encrypt are synchronous calls into the native core, so once the cache has derived its keys, at its first encrypt or decrypt, a hit through `get()`, `exists()` or `wrap()` awaits no promise, and a `set()` awaits none around its encrypt. On Cloudflare Workers, WebCrypto decrypts and encrypts asynchronously, so the hit and the set wait for it. - **L2 hit and `set`**: L1 is off, so each call reads or writes the backend and decodes or encodes the value. A real backend adds its round trip, which for Redis or CacheKit Cloud is a network call. To print this table for your own machine, run `pnpm install` and `pnpm build`, then `node packages/cachekit/bench/readme.mjs`, from a clone of the repository, or run the **Bench** workflow from its Actions tab. Across 11 runs on this CPU model, the 100 B hit took 290-360 ns, 320 ns at the median, and every other cell agreed within 10%. The encrypted L1 hit row is the median of four later runs on this CPU model, which read 9.0-9.7 µs and 119-125 µs. Hosted runners also come with other CPU models, on which the 100 B hit took from 160 ns (an EPYC 9V45) to 330 ns (an Intel Xeon Platinum 8370C or 8573C) in the same batch of runs, and other machines give other figures. diff --git a/packages/cachekit/bench/ir-workload.mjs b/packages/cachekit/bench/ir-workload.mjs index a94efb4..ec2418e 100644 --- a/packages/cachekit/bench/ir-workload.mjs +++ b/packages/cachekit/bench/ir-workload.mjs @@ -450,6 +450,25 @@ export const WORKLOADS = { }; }, }, + // an encrypted set() on Node, L1 on: encode 100 B of msgpack, the AAD-size + // pre-flight, encrypt through the NAPI core, the L1 fill with the + // ciphertext and the backend write. Setup's set derives the keys. + 'secure-set-100b': { + n: 20_000, + setup: async () => { + const { createCache } = await load('index.js'); + const cache = createCache({ + backend: new MemoryBackend(), + encryption: { masterKey: MASTER_KEY }, + metrics: false, + }); + const value = rowsOf(3); + await cache.set(KEY, value); + return async () => { + await cache.set(KEY, value); + }; + }, + }, // get() served from a plaintext L1, which holds the decoded value: the // lookup and the promises around it (l1-get-hit times L1Cache.get alone). // 4,000 warm-up ops: V8 compiles getEntry with TurboFan at ~2,200. diff --git a/packages/cachekit/src/cache-core.ts b/packages/cachekit/src/cache-core.ts index 3f04ae5..7b38e51 100644 --- a/packages/cachekit/src/cache-core.ts +++ b/packages/cachekit/src/cache-core.ts @@ -275,7 +275,12 @@ export interface ByteStorageLike { /** Encryption surface CacheImpl drives (see EncryptionManagerCore). */ export interface EncryptionLike { - encrypt(data: Uint8Array, cacheKey: string, compressed?: boolean): Promise; + /** The ciphertext when the encrypt need not suspend (NAPI), else a Promise of it. */ + encryptNow( + data: Uint8Array, + cacheKey: string, + compressed?: boolean + ): Uint8Array | Promise; /** The plaintext when the decrypt need not suspend (NAPI), else a Promise of it. */ decryptNow( ciphertext: Uint8Array, @@ -1502,8 +1507,12 @@ export class CacheImpl implements SecureCache { let data: Uint8Array; try { data = useEnvelope ? this.withEnvelopeCodec((codec) => codec.pack(serialized)) : serialized; - if (this.encryption) - data = await this.encryption.encrypt(data, this.aadKey(key), useEnvelope); + if (this.encryption) { + // On Node.js, once the keys are derived, the encrypt does not suspend, + // and awaiting its plain value would cost the set a microtask turn. + const encrypted = this.encryption.encryptNow(data, this.aadKey(key), useEnvelope); + data = isThenable(encrypted) ? await encrypted : encrypted; + } } catch (error) { this.recordFailure('set', error); if (!this.degradationEnabled) throw error; diff --git a/packages/cachekit/src/cache.encryption-l1.test.ts b/packages/cachekit/src/cache.encryption-l1.test.ts index e3b2463..6faa234 100644 --- a/packages/cachekit/src/cache.encryption-l1.test.ts +++ b/packages/cachekit/src/cache.encryption-l1.test.ts @@ -374,12 +374,13 @@ describe('L1 zero-knowledge for encrypted caches', () => { }); }); -// On Node.js the decrypt is a synchronous NAPI call, so once the keys are -// derived an encrypted L1 hit has nothing to await, and awaiting even a -// settled value costs the hit a microtask turn: the promise it hands back -// must already be settled. On Workers WebCrypto decrypts asynchronously, so -// there the hit awaits the decrypt. -describe('an encrypted L1 hit awaits only a decrypt that suspends', () => { +// On Node.js the decrypt and the encrypt are synchronous NAPI calls, so once +// the keys are derived an encrypted L1 hit has nothing to await, and awaiting +// even a settled value costs the hit a microtask turn: the promise it hands +// back must already be settled. A set still awaits its backend write, so it +// must take as many turns as a plaintext set. On Workers WebCrypto decrypts +// and encrypts asynchronously, so there the hit and the set await it. +describe('an encrypted L1 hit or set awaits only a decrypt or encrypt that suspends', () => { const caches: SecureCache[] = []; afterEach(async () => { @@ -501,7 +502,7 @@ describe('an encrypted L1 hit awaits only a decrypt that suspends', () => { expect(backend.gets).toBe(reads); }); - // Before its keys are derived a cache decrypts through an async function, + // Before its keys are derived a cache decrypts and encrypts through an async function, // whose Promise is the engine's own: `instanceof` against a replaced global // Promise misses it, and a Promise taken for a value is never awaited. async function withReplacedPromise(op: () => Promise): Promise { @@ -603,4 +604,54 @@ describe('an encrypted L1 hit awaits only a decrypt that suspends', () => { expect(fn).toHaveBeenCalledTimes(2); } ); + + /** Microtask turns a plaintext set() of VALUE takes, on a cache configured as makeCache's. */ + async function plaintextSetTurns(): Promise { + const plaintext = createCache({ + backend: new InMemoryBackend(), + defaultTtl: 3600, + l1: { enabled: true, maxEntries: 100 }, + metrics: false, + }); + try { + return await turnsToSettle(plaintext.set(KEY, VALUE)); + } finally { + await plaintext.close(); + } + } + + it('on Node.js, an encrypted set() takes as many turns as a plaintext one once the keys are derived', async () => { + const backend = new InMemoryBackend(); + const cache = makeCache(backend); + const plaintextTurns = await plaintextSetTurns(); + + // The first set derives the keys through encrypt(), which suspends. + expect(await turnsToSettle(cache.set('users:1', VALUE))).toBeGreaterThan(plaintextTurns); + for (const key of ['users:1', 'users:2']) { + expect(await turnsToSettle(cache.set(key, VALUE))).toBe(plaintextTurns); + expectCiphertext(l1Entry(cache, key), backend.store.get(key)); + } + expect(await makeCache(backend).get('users:2')).toEqual(VALUE); + }); + + it('an encrypt that suspends, as on Workers, is awaited, and its ciphertext stored', async () => { + const backend = new InMemoryBackend(); + const cache = makeCache(backend, { suspends: true }); + await cache.set('users:1', VALUE); // derives the keys + + expect(await turnsToSettle(cache.set('users:2', VALUE))).toBeGreaterThan( + await plaintextSetTurns() + ); + expectCiphertext(l1Entry(cache, 'users:2'), backend.store.get('users:2')); + expect(await makeCache(backend).get('users:2')).toEqual(VALUE); + }); + + it('a fresh cache’s first set() stores ciphertext when a library has replaced the global Promise', async () => { + const backend = new InMemoryBackend(); + const cache = makeCache(backend); + + await withReplacedPromise(() => cache.set(KEY, VALUE)); + expectCiphertext(l1Entry(cache, KEY), backend.store.get(KEY)); + expect(await makeCache(backend).get(KEY)).toEqual(VALUE); + }); }); diff --git a/packages/cachekit/src/cache.l2-write-failure.test.ts b/packages/cachekit/src/cache.l2-write-failure.test.ts index 6f2c252..ad4a732 100644 --- a/packages/cachekit/src/cache.l2-write-failure.test.ts +++ b/packages/cachekit/src/cache.l2-write-failure.test.ts @@ -61,17 +61,22 @@ function breakerOf(cache: AnyCache) { return reliabilityExecutor.getCircuitBreakerState(); } -/** Make the cache's envelope pack or its encrypt step fail with `error` on every call. */ +/** + * Make the cache's envelope pack or its encrypt step fail with `error` on every + * call. Both throw in the call, as the NAPI encrypt does on Node.js once the + * keys are derived. + */ function failStep(cache: AnyCache, step: 'pack' | 'encrypt', error: Error) { const internals = cache as unknown as { byteStorage: { pack: (data: Uint8Array) => Uint8Array }; - encryption: { encrypt: (data: Uint8Array) => Promise }; + encryption: { encryptNow: (data: Uint8Array) => Uint8Array | Promise }; + }; + const fail = () => { + throw error; }; return step === 'pack' - ? vi.spyOn(internals.byteStorage, 'pack').mockImplementation(() => { - throw error; - }) - : vi.spyOn(internals.encryption, 'encrypt').mockRejectedValue(error); + ? vi.spyOn(internals.byteStorage, 'pack').mockImplementation(fail) + : vi.spyOn(internals.encryption, 'encryptNow').mockImplementation(fail); } const caches: AnyCache[] = []; @@ -159,13 +164,14 @@ describe('a failed L2 write still fills L1', () => { expect(await cache.get('users:1')).toEqual({ ssn: CANARY }); }); - it('on an encrypted cache L1 holds nothing when encryption itself threw', async () => { + // A rejected Promise, as from the Workers bindings; failStep covers a throw. + it('on an encrypted cache L1 holds nothing when encryption itself rejected', async () => { setLogger(() => {}); const { backend, calls } = rejectingBackend('authentication'); const cache = makeCache(backend, { encrypted: true }); - const encryption = (cache as unknown as { encryption: { encrypt: () => Promise } }) + const encryption = (cache as unknown as { encryption: { encryptNow: () => Promise } }) .encryption; - vi.spyOn(encryption, 'encrypt').mockRejectedValue(new Error('encrypt failed')); + vi.spyOn(encryption, 'encryptNow').mockRejectedValue(new Error('encrypt failed')); await cache.set('users:1', { ssn: CANARY }); diff --git a/packages/cachekit/src/cache.test.ts b/packages/cachekit/src/cache.test.ts index 80024dc..4c0e1c9 100644 --- a/packages/cachekit/src/cache.test.ts +++ b/packages/cachekit/src/cache.test.ts @@ -2065,7 +2065,7 @@ describe('Cache Integration', () => { }); it('never calls encrypt for an over-limit key', async () => { - const encrypt = vi.spyOn(EncryptionManagerCore.prototype, 'encrypt'); + const encrypt = vi.spyOn(EncryptionManagerCore.prototype, 'encryptNow'); const c = secureCache(new InMemoryBackend()); await expect(c.set(overLimitKey, 'v')).rejects.toThrow(ConfigurationError); @@ -2232,7 +2232,7 @@ describe('Cache Integration', () => { ])( 'keyPrefix $keyPrefix binds $bound at encrypt, decrypt and the AAD-size pre-flight', async ({ keyPrefix, bound }) => { - const encrypt = vi.spyOn(EncryptionManagerCore.prototype, 'encrypt'); + const encrypt = vi.spyOn(EncryptionManagerCore.prototype, 'encryptNow'); const decrypt = vi.spyOn(EncryptionManagerCore.prototype, 'decryptNow'); const validateKey = vi.spyOn(EncryptionManagerCore.prototype, 'validateKey'); const c = secureOver(new PrefixingBackend(keyPrefix, new Map())); diff --git a/packages/cachekit/src/encryption/manager-core.test.ts b/packages/cachekit/src/encryption/manager-core.test.ts index 3c02306..a12ef41 100644 --- a/packages/cachekit/src/encryption/manager-core.test.ts +++ b/packages/cachekit/src/encryption/manager-core.test.ts @@ -232,6 +232,56 @@ describe('EncryptionManagerCore', () => { expect(build).toThrow(ConfigurationError); expect(build).toThrow(/tenantId must not be empty/); }); + + it('decryptNow decrypts in the call once the keys are derived, and throws what decrypt() rejects with', async () => { + const { bindings } = mockBindings(); + const manager = new TestManager(async () => bindings); + + // Before the keys are derived it loads the bindings first: a Promise. + const first = manager.decryptNow(new Uint8Array([1]), 'ns:k'); + expect(first).toBeInstanceOf(Promise); + expect(await first).toEqual(new Uint8Array([2])); + + const plaintext = manager.decryptNow(new Uint8Array([1]), 'ns:k', true); + expect(plaintext).not.toBeInstanceOf(Promise); + expect(plaintext).toEqual(new Uint8Array([2])); + vi.mocked(bindings.decryptWithTenantKeys).mockImplementationOnce(() => { + throw new Error('Authentication verification failed'); + }); + expect(() => manager.decryptNow(new Uint8Array(28), 'ns:k')).toThrow( + new EncryptionError('Decryption failed: Authentication verification failed') + ); + + // After dispose() it rejects, as decrypt() does. + manager.dispose(); + await expect(manager.decryptNow(new Uint8Array([1]), 'ns:k')).rejects.toThrow( + 'EncryptionManager has been disposed' + ); + }); + + it('encryptNow encrypts in the call once the keys are derived, and throws what encrypt() rejects with', async () => { + const { bindings } = mockBindings(); + const manager = new TestManager(async () => bindings); + + // Before the keys are derived it loads the bindings first: a Promise. + const first = manager.encryptNow(new Uint8Array([9]), 'ns:k'); + expect(first).toBeInstanceOf(Promise); + expect(await first).toEqual(new Uint8Array([1])); + + const ciphertext = manager.encryptNow(new Uint8Array([9]), 'ns:k', true); + expect(ciphertext).not.toBeInstanceOf(Promise); + expect(ciphertext).toEqual(new Uint8Array([1])); + vi.mocked(bindings.encryptWithTenantKeys).mockImplementationOnce(() => { + throw new Error('Encryption failed: Nonce counter exhausted - key rotation required'); + }); + expect(() => manager.encryptNow(new Uint8Array([1]), 'ns:k')).toThrow(NonceExhaustedError); + + // After dispose() it rejects, as encrypt() does. + manager.dispose(); + await expect(manager.encryptNow(new Uint8Array([1]), 'ns:k')).rejects.toThrow( + 'EncryptionManager has been disposed' + ); + }); }); describe('EncryptionManagerCore keyring config (previousMasterKeys)', () => { @@ -398,31 +448,64 @@ describe('EncryptionManagerCore keyring config (previousMasterKeys)', () => { await expect(inFlight).rejects.toThrow(EncryptionError); }); +}); - it('decryptNow decrypts in the call once the keys are derived, and throws what decrypt() rejects with', async () => { - const { bindings } = mockBindings(); - const manager = new TestManager(async () => bindings); - - // Before the keys are derived it loads the bindings first: a Promise. - const first = manager.decryptNow(new Uint8Array([1]), 'ns:k'); - expect(first).toBeInstanceOf(Promise); - expect(await first).toEqual(new Uint8Array([2])); +// decrypt() and encrypt() keep their own copy of the error mapping that +// decryptNow() and encryptNow() take from a helper, because sharing it slowed +// a caller of the public methods (see decrypt()). One failing binding run +// through both must give the same error. +describe('decryptNow() and encryptNow() fail as decrypt() and encrypt() do', () => { + /** The Error `call` threw or rejected with. The manager never fails with anything else. */ + async function failureOf(call: () => unknown): Promise { + try { + await call(); + } catch (error) { + if (error instanceof Error) return error; + throw new Error('the call failed with a non-Error', { cause: error }); + } + throw new Error('the call succeeded'); + } - const plaintext = manager.decryptNow(new Uint8Array([1]), 'ns:k', true); - expect(plaintext).not.toBeInstanceOf(Promise); - expect(plaintext).toEqual(new Uint8Array([2])); - vi.mocked(bindings.decryptWithTenantKeys).mockImplementationOnce(() => { - throw new Error('Authentication verification failed'); - }); - expect(() => manager.decryptNow(new Uint8Array(28), 'ns:k')).toThrow( - new EncryptionError('Decryption failed: Authentication verification failed') - ); + function expectSameError(actual: Error, expected: Error): void { + expect(actual.constructor).toBe(expected.constructor); + expect(actual.message).toBe(expected.message); + expect(actual.cause).toBe(expected.cause); + } - // After dispose() it rejects, as decrypt() does. - manager.dispose(); - await expect(manager.decryptNow(new Uint8Array([1]), 'ns:k')).rejects.toThrow( - 'EncryptionManager has been disposed' - ); + it.each([ + ['an Error', new Error('Authentication verification failed')], + ['nonce exhaustion', new Error('Nonce counter exhausted - key rotation required')], + ['nonce exhaustion by its variant name', new Error('NonceCounterExhausted')], + ['a value that is not an Error', 'not an Error'], + ])('%s, thrown or rejected by the binding', async (_, failure) => { + for (const suspends of [false, true]) { + const fail = vi.fn((): Promise => { + if (suspends) return Promise.reject(failure); + throw failure; + }); + const { bindings } = mockBindings({ + encryptWithTenantKeys: fail, + decryptWithTenantKeys: fail, + }); + const manager = new TestManager(async () => bindings); + // Derives the keys, so the *Now methods call the binding themselves. + await manager.isHardwareAccelerated(); + + const decrypted = await failureOf(() => manager.decrypt(new Uint8Array(28), 'ns:k')); + const encrypted = await failureOf(() => manager.encrypt(new Uint8Array([1]), 'ns:k')); + expect(decrypted.cause).toBe(failure instanceof Error ? failure : undefined); + expect(encrypted.cause).toBe(failure instanceof Error ? failure : undefined); + expectSameError( + await failureOf(() => manager.decryptNow(new Uint8Array(28), 'ns:k')), + decrypted + ); + expectSameError( + await failureOf(() => manager.encryptNow(new Uint8Array([1]), 'ns:k')), + encrypted + ); + expect(fail).toHaveBeenCalledTimes(4); + manager.dispose(); + } }); }); @@ -486,6 +569,25 @@ describe('EncryptionManagerCore over asynchronous bindings', () => { manager.dispose(); }); + it('encryptNow returns the binding’s Promise once the keys are derived, its rejection mapped as encrypt() maps it', async () => { + const { bindings } = mockBindings({ + encryptWithTenantKeys: vi + .fn() + .mockRejectedValueOnce(new Error('Nonce counter exhausted - key rotation required')) + .mockResolvedValue(new Uint8Array([7])), + }); + const manager = new TestManager(async () => bindings); + await manager.isHardwareAccelerated(); // derives the keys + + await expect(manager.encryptNow(new Uint8Array([1]), 'ns:k')).rejects.toThrow( + NonceExhaustedError + ); + const ciphertext = manager.encryptNow(new Uint8Array([1]), 'ns:k'); + expect(ciphertext).toBeInstanceOf(Promise); + expect(await ciphertext).toEqual(new Uint8Array([7])); + manager.dispose(); + }); + it('returns the value an asynchronous encrypt and decrypt resolve to', async () => { const { bindings } = mockBindings({ encryptWithTenantKeys: vi.fn(async () => new Uint8Array([7])), diff --git a/packages/cachekit/src/encryption/manager-core.ts b/packages/cachekit/src/encryption/manager-core.ts index b7fc67f..275c774 100644 --- a/packages/cachekit/src/encryption/manager-core.ts +++ b/packages/cachekit/src/encryption/manager-core.ts @@ -27,6 +27,20 @@ function decryptionFailed(error: unknown): EncryptionError { ); } +/** + * The error encrypt() builds for a binding failure, for encryptNow() to throw + * or reject with: NonceExhaustedError when the encryptor's nonce counter is + * spent, EncryptionError otherwise. + */ +function encryptionFailed(error: unknown): EncryptionError { + const message = error instanceof Error ? error.message : 'Unknown error'; + const cause = error instanceof Error ? error : undefined; + if (message.includes('Nonce counter exhausted') || message.includes('NonceCounterExhausted')) { + return new NonceExhaustedError(undefined, { cause }); + } + return new EncryptionError(`Encryption failed: ${message}`, { cause }); +} + function concatBytes(...parts: Uint8Array[]): Uint8Array { const out = new Uint8Array(parts.reduce((sum, p) => sum + p.length, 0)); let offset = 0; @@ -78,9 +92,10 @@ export interface EncryptionTenantKeys { * Error contract: when the encryptor's nonce counter is exhausted, * encryptWithTenantKeys MUST throw an Error whose message contains * "Nonce counter exhausted" (cachekit-core's EncryptionError Display) — - * encrypt() classifies that substring into NonceExhaustedError so rotation - * alerting works. Both bindings inherit the string from the same core crate; - * a core wording change must update this contract and the classifier below. + * encrypt() and encryptNow() classify that substring into NonceExhaustedError + * so rotation alerting works. Both bindings inherit the string from the same + * core crate; a core wording change must update this contract and both + * classifiers: encrypt()'s catch and encryptionFailed(). */ export interface EncryptionBindings { /** @@ -316,6 +331,8 @@ export class EncryptionManagerCore { } } + // encryptNow() repeats this method's binding call and error mapping rather + // than sharing them, for the reason decrypt() gives. /** * Encrypt data with AAD binding to cache key. * @@ -356,6 +373,37 @@ export class EncryptionManagerCore { } } + /** + * encrypt() without its awaits, for the cache's write path. Once the keys are + * derived, a binding that returns a plain value (NAPI on Node.js) encrypts in + * this call: it returns the ciphertext, or throws the NonceExhaustedError or + * EncryptionError encrypt() would reject with. Otherwise it returns a + * Promise: encrypt()'s before the keys are derived or after dispose(), and + * the binding's on Workers, where WebCrypto encrypts asynchronously. Both + * bindings reserve the nonce inside their own call, before their first + * await. encrypt() keeps its own copy of the error classifier, for the + * reason decrypt() gives. Package-internal. + * @internal + */ + encryptNow( + data: Uint8Array, + cacheKey: string, + compressed = false + ): Uint8Array | Promise { + if (!this.tenantKeys) return this.encrypt(data, cacheKey, compressed); + let result: Uint8Array | Promise; + try { + const aad = this.buildAAD(cacheKey, compressed); + result = this.native!.encryptWithTenantKeys(data, aad, this.tenantKeys); + } catch (error) { + throw encryptionFailed(error); + } + if (!isThenable(result)) return result; + return Promise.resolve(result).catch((error: unknown) => { + throw encryptionFailed(error); + }); + } + /** * Decrypt data and verify AAD binding. * diff --git a/packages/cachekit/src/utils/thenable.test.ts b/packages/cachekit/src/utils/thenable.test.ts new file mode 100644 index 0000000..5d6d505 --- /dev/null +++ b/packages/cachekit/src/utils/thenable.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { isThenable } from './thenable.js'; + +class SubPromise extends Promise {} + +// An `instanceof Promise` check gets every row right but the plain thenable. The case +// that made the cache test for a thenable, the engine's own Promise checked +// against a replaced global Promise, is held by the replaced-Promise tests in +// cache.encryption-l1.test.ts. +describe('isThenable', () => { + it.each([ + ['a native Promise', Promise.resolve(1), true], + ['a Promise subclass', SubPromise.resolve(1), true], + ['a plain thenable', { then: () => {} }, true], + ['an object whose then is not a function', { then: 1 }, false], + ['a Uint8Array', new Uint8Array([1]), false], + ['a { value } wrapper', { value: 1 }, false], + ['null', null, false], + ['undefined', undefined, false], + ])('%s: %s', (_, value, expected) => { + expect(isThenable(value)).toBe(expected); + }); +}); diff --git a/packages/cachekit/test/protocol/encryption.protocol.test.ts b/packages/cachekit/test/protocol/encryption.protocol.test.ts index 4a46db1..904e160 100644 --- a/packages/cachekit/test/protocol/encryption.protocol.test.ts +++ b/packages/cachekit/test/protocol/encryption.protocol.test.ts @@ -571,10 +571,10 @@ describe('encryption.json decrypted_container (protocol 1.5.0, ENC-3) — the co describe('encryption.json aad_hex — the shipped AAD builder', () => { // The Workers lane proves the builder only end to end, by decrypting; this - // pins its bytes. Mock bindings capture the AAD the manager hands encrypt, - // decrypt() and decryptNow() (the cache's read path once the keys are - // derived, which builds its own binding call), so no key material or native - // code is involved. + // pins its bytes. Mock bindings capture the AAD the manager hands encrypt(), + // encryptNow(), decrypt() and decryptNow() (the cache's write and read paths + // once the keys are derived, each of which builds its own binding call), so + // no key material or native code is involved. async function managerAad( tenantId: string | undefined, cacheKey: string, @@ -597,6 +597,9 @@ describe('encryption.json aad_hex — the shipped AAD builder', () => { ); try { await manager.encrypt(new Uint8Array([1]), cacheKey, compressed); + expect(manager.encryptNow(new Uint8Array([9]), cacheKey, compressed)).toEqual( + new Uint8Array([1]) + ); await manager.decrypt(new Uint8Array([1]), cacheKey, compressed); expect(manager.decryptNow(new Uint8Array([1]), cacheKey, compressed)).toEqual( new Uint8Array([2]) @@ -635,12 +638,13 @@ describe('encryption.json aad_hex — the shipped AAD builder', () => { }); it.each(rows.map((v) => [v.name, v] as const))( - '%s: encrypt, decrypt() and decryptNow() hand the binding aad_hex', + '%s: encrypt(), encryptNow(), decrypt() and decryptNow() hand the binding aad_hex', async (_name, v) => { expect(await managerAad(v.tenantId, v.cache_key, v.compressed)).toEqual([ v.aad_hex, v.aad_hex, v.aad_hex, + v.aad_hex, ]); } ); @@ -674,6 +678,7 @@ describe('encryption.json aad_hex — the shipped AAD builder', () => { expected, expected, expected, + expected, ]); } );