diff --git a/.changeset/vm-determinism-hardening.md b/.changeset/vm-determinism-hardening.md new file mode 100644 index 0000000000..8c3bbdd6a2 --- /dev/null +++ b/.changeset/vm-determinism-hardening.md @@ -0,0 +1,6 @@ +--- +'@workflow/core': minor +'workflow': minor +--- + +Deterministic sandbox hardening: `crypto.subtle.digest` in workflow functions now computes synchronously via `node:crypto` (byte-identical values, deterministic timing under replay); `Atomics.waitAsync`, async `WebAssembly` compilation, `WeakRef`, and `FinalizationRegistry` are no longer exposed (wall-clock timing and GC observation cannot be replayed). diff --git a/docs/content/docs/v5/api-reference/workflow-globals.mdx b/docs/content/docs/v5/api-reference/workflow-globals.mdx index 6ec32b6b0b..6faa7e3df6 100644 --- a/docs/content/docs/v5/api-reference/workflow-globals.mdx +++ b/docs/content/docs/v5/api-reference/workflow-globals.mdx @@ -26,7 +26,7 @@ These APIs are available but are **seeded or fixed** to ensure deterministic beh | [`Date`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) / `Date.now()` / `new Date()` | Returns a fixed timestamp that advances with the workflow's logical clock | | [`crypto.getRandomValues()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) | Seeded — produces deterministic output for a given workflow run | | [`crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID) | Seeded — produces deterministic UUIDs for a given workflow run | -| [`crypto.subtle.digest()`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest) | Passes through to the real implementation (SHA-256, etc. are deterministic by nature) | +| [`crypto.subtle.digest()`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest) | Computed synchronously via `node:crypto` (values are byte-identical to WebCrypto), so the promise settles at a deterministic point during replay | You can safely use `Math.random()`, `Date.now()`, and `crypto.randomUUID()` in workflow functions. The framework ensures these return the same values across replays. @@ -100,3 +100,6 @@ The following are **not available** in workflow functions. Move this logic to [s - **Global `fetch`**: Use [`import { fetch } from "workflow"`](/docs/api-reference/workflow/fetch) instead. See [fetch-in-workflow](/docs/errors/fetch-in-workflow). - **Timers**: `setTimeout`, `setInterval`, `setImmediate`, and their `clear*` counterparts. Use [`sleep()`](/docs/api-reference/workflow/sleep) instead. See [timeout-in-workflow](/docs/errors/timeout-in-workflow). - **`Buffer`**: Node.js-specific API. Use `Uint8Array` with `toBase64()` / `fromBase64()` / `toHex()` / `fromHex()` for binary data encoding, or `atob()` / `btoa()` for string-based base64. +- **`WeakRef` and `FinalizationRegistry`**: garbage-collection timing is not deterministic, so observing it would make workflow code impossible to replay. (`WeakMap` and `WeakSet` remain available — they do not expose GC state.) +- **`Atomics.waitAsync`**: a wall-clock timer, which cannot be replayed. Use [`sleep()`](/docs/api-reference/workflow/sleep) instead. +- **Async `WebAssembly` compilation** (`compile`, `instantiate`, `compileStreaming`, `instantiateStreaming`): resolves on compile-thread timing. The synchronous `new WebAssembly.Module()` and `new WebAssembly.Instance()` constructors remain available. diff --git a/packages/core/src/vm/index.test.ts b/packages/core/src/vm/index.test.ts index 0381b60f63..3f2ebbabeb 100644 --- a/packages/core/src/vm/index.test.ts +++ b/packages/core/src/vm/index.test.ts @@ -171,6 +171,22 @@ describe('createContext', () => { expect(err?.message).toContain('not available inside a workflow function'); }); + it('throws for the remaining async `crypto.subtle` methods', () => { + // These would settle on host threadpool timing (breaking the quiescence + // invariant a retained VM relies on), so they must stay unreachable. + const { context } = createContext({ seed, fixedTimestamp }); + for (const call of [ + 'crypto.subtle.importKey("raw", new Uint8Array(16), { name: "AES-GCM" }, false, ["encrypt"])', + 'crypto.subtle.encrypt({ name: "AES-GCM" }, {}, new Uint8Array(4))', + 'crypto.subtle.sign("HMAC", {}, new Uint8Array(4))', + 'crypto.subtle.deriveBits({ name: "PBKDF2" }, {}, 128)', + ]) { + expect(() => vm.runInContext(call, context)).toThrow( + /not available inside a workflow function/ + ); + } + }); + it('should call `onWorkflowError` when a workflow error occurs', async () => { const { context } = createContext({ seed, fixedTimestamp }); @@ -257,3 +273,142 @@ describe('createContext', () => { expect(result).toBe('undefined'); }); }); + +describe('host-timed async', () => { + it('does not expose any API that settles on host timing', () => { + const { context } = createContext({ seed, fixedTimestamp }); + + expect(vm.runInContext('typeof Atomics.waitAsync', context)).toBe( + 'undefined' + ); + expect(vm.runInContext('typeof WebAssembly.compile', context)).toBe( + 'undefined' + ); + expect(vm.runInContext('typeof WebAssembly.instantiate', context)).toBe( + 'undefined' + ); + expect( + vm.runInContext('typeof WebAssembly.compileStreaming', context) + ).toBe('undefined'); + expect( + vm.runInContext('typeof WebAssembly.instantiateStreaming', context) + ).toBe('undefined'); + // Deterministic synchronous WebAssembly stays available. + expect(vm.runInContext('typeof WebAssembly.Module', context)).toBe( + 'function' + ); + }); + + it('does not expose WeakRef or FinalizationRegistry', () => { + const { context } = createContext({ seed, fixedTimestamp }); + + expect(vm.runInContext('typeof WeakRef', context)).toBe('undefined'); + expect(vm.runInContext('typeof FinalizationRegistry', context)).toBe( + 'undefined' + ); + // WeakMap/WeakSet do not expose GC state and stay available. + expect(vm.runInContext('typeof WeakMap', context)).toBe('function'); + expect(vm.runInContext('typeof WeakSet', context)).toBe('function'); + }); +}); + +describe('crypto.subtle.digest', () => { + it.each([ + 'SHA-1', + 'SHA-256', + 'SHA-384', + 'SHA-512', + ])('matches WebCrypto for %s', async (algorithm) => { + const { context } = createContext({ seed, fixedTimestamp }); + + const result = await vm.runInContext( + `crypto.subtle.digest(${JSON.stringify(algorithm)}, new Uint8Array([1,2,3,4,5,255,0,128]))`, + context + ); + const expected = await globalThis.crypto.subtle.digest( + algorithm, + new Uint8Array([1, 2, 3, 4, 5, 255, 0, 128]) + ); + expect(result).toBeInstanceOf(ArrayBuffer); + expect(new Uint8Array(result)).toEqual(new Uint8Array(expected)); + }); + + it('accepts the { name } algorithm form and ArrayBuffer input', async () => { + const { context } = createContext({ seed, fixedTimestamp }); + + const result = await vm.runInContext( + 'crypto.subtle.digest({ name: "sha-256" }, new ArrayBuffer(4))', + context + ); + const expected = await globalThis.crypto.subtle.digest( + 'SHA-256', + new ArrayBuffer(4) + ); + expect(new Uint8Array(result)).toEqual(new Uint8Array(expected)); + }); + + it('respects typed-array subviews', async () => { + const { context } = createContext({ seed, fixedTimestamp }); + + const result = await vm.runInContext( + 'const bytes = new Uint8Array([9, 1, 2, 9]); crypto.subtle.digest("SHA-256", bytes.subarray(1, 3))', + context + ); + const expected = await globalThis.crypto.subtle.digest( + 'SHA-256', + new Uint8Array([1, 2]) + ); + expect(new Uint8Array(result)).toEqual(new Uint8Array(expected)); + }); + + it('rejects unsupported algorithms like WebCrypto does', async () => { + const { context } = createContext({ seed, fixedTimestamp }); + + await expect( + vm.runInContext('crypto.subtle.digest("MD5", new Uint8Array(1))', context) + ).rejects.toMatchObject({ name: 'NotSupportedError' }); + }); + + it.each([ + ['a number', '2000000000'], + ['a plain object', '({})'], + ['a string', '"data"'], + ['null', 'null'], + ])('rejects %s with TypeError', async (_label, expression) => { + const { context } = createContext({ seed, fixedTimestamp }); + + await expect( + vm.runInContext(`crypto.subtle.digest("SHA-256", ${expression})`, context) + ).rejects.toThrow(TypeError); + }); + + it('rejects SharedArrayBuffer-backed views like WebCrypto does', async () => { + const { context } = createContext({ seed, fixedTimestamp }); + + await expect( + vm.runInContext( + 'crypto.subtle.digest("SHA-256", new Uint8Array(new SharedArrayBuffer(4)))', + context + ) + ).rejects.toThrow(TypeError); + }); + + it('reads view ranges from internal slots, ignoring shadowed properties', async () => { + const { context } = createContext({ seed, fixedTimestamp }); + + const result = await vm.runInContext( + `(() => { + const view = new Uint8Array([1, 2, 3, 4]); + Object.defineProperty(view, "byteLength", { value: 0 }); + Object.defineProperty(view, "byteOffset", { value: 2 }); + return crypto.subtle.digest("SHA-256", view); + })()`, + context + ); + const expected = await globalThis.crypto.subtle.digest( + 'SHA-256', + new Uint8Array([1, 2, 3, 4]) + ); + expect(new Uint8Array(result)).toEqual(new Uint8Array(expected)); + }); +}); diff --git a/packages/core/src/vm/index.ts b/packages/core/src/vm/index.ts index c0003bdc39..0355abb884 100644 --- a/packages/core/src/vm/index.ts +++ b/packages/core/src/vm/index.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; +import { types } from 'node:util'; import { runInContext, createContext as vmCreateContext } from 'node:vm'; import { WorkflowRuntimeError } from '@workflow/errors'; import seedrandom from 'seedrandom'; @@ -10,6 +12,36 @@ export interface CreateContextOptions { fixedTimestamp: number; } +// WebCrypto digest algorithm names → node:crypto (OpenSSL) names. +const DIGEST_ALGORITHMS: Record = { + 'SHA-1': 'sha1', + 'SHA-256': 'sha256', + 'SHA-384': 'sha384', + 'SHA-512': 'sha512', +}; + +// WebCrypto BufferSource conversion. The `util.types` brand checks work +// across vm realms, and node:crypto's `hash.update()` reads view ranges from +// internal slots (own properties shadowing `byteOffset`/`byteLength` are +// ignored) — matching WebCrypto, which also reads internal slots. +function toDigestInput( + data: ArrayBuffer | ArrayBufferView +): NodeJS.ArrayBufferView { + if (types.isTypedArray(data) || types.isDataView(data)) { + // WebCrypto's BufferSource excludes SharedArrayBuffer-backed views. + if (types.isSharedArrayBuffer(data.buffer)) { + throw new TypeError( + 'crypto.subtle.digest does not accept SharedArrayBuffer-backed views' + ); + } + return data; + } + if (!types.isArrayBuffer(data)) { + throw new TypeError('data must be an ArrayBuffer or ArrayBufferView'); + } + return new Uint8Array(data); +} + /** * Creates a Node.js `vm.Context` configured to be usable for * executing workflow logic in a deterministic environment. @@ -58,7 +90,52 @@ export function createContext(options: CreateContextOptions) { const randomUUID = createRandomUUID(rng); - const boundDigest = originalSubtle.digest.bind(originalSubtle); + // The sandbox must not hand workflow code a promise that settles on host + // timing or a way to observe host state (best-effort, for non-adversarial + // code): every promise a workflow creates settles either from the event log + // or within its own microtask cascade, so a suspended VM is fully quiescent + // and can be retained across inline steps. + // + // - `Atomics.waitAsync` is a wall-clock timer (via SharedArrayBuffer). + // - The async `WebAssembly` entry points resolve on compile-thread timing; + // the synchronous `new WebAssembly.Module()` / `Instance()` remain. + // - `WeakRef.deref()` and finalizer callbacks observe GC timing. + // - Dynamic `import()` settles within a microtask (rejected: no + // `importModuleDynamically`), so it needs no handling. + // - All `crypto.subtle` methods except the deterministic `digest` override + // below throw explicitly (see the subtle proxy) — binding them to the + // host subtle would hand workflows a host-timing promise. + const intrinsics = g as unknown as Record>; + delete intrinsics.Atomics.waitAsync; + delete intrinsics.WebAssembly.compile; + delete intrinsics.WebAssembly.instantiate; + delete intrinsics.WebAssembly.compileStreaming; + delete intrinsics.WebAssembly.instantiateStreaming; + delete intrinsics.WeakRef; + delete intrinsics.FinalizationRegistry; + + // `crypto.subtle.digest` computes synchronously via node:crypto, so its + // promise settles on a deterministic microtask instead of host threadpool + // timing — a digest can never advance a suspended workflow, and + // digest-using VMs stay retainable. Values are byte-identical to WebCrypto. + const digest = async ( + algorithm: string | { name: string }, + data: ArrayBuffer | ArrayBufferView + ): Promise => { + const name = typeof algorithm === 'string' ? algorithm : algorithm.name; + const ossl = DIGEST_ALGORITHMS[name.toUpperCase()]; + if (!ossl) { + throw new DOMException( + `Unrecognized algorithm name: ${name}`, + 'NotSupportedError' + ); + } + const hash = createHash(ossl).update(toDigestInput(data)).digest(); + // Copy out: small Buffers share the internal pool allocation. + const out = new ArrayBuffer(hash.byteLength); + new Uint8Array(out).set(hash); + return out; + }; g.crypto = new Proxy(originalCrypto, { get(target, prop) { @@ -71,16 +148,20 @@ export function createContext(options: CreateContextOptions) { if (prop === 'subtle') { return new Proxy(originalSubtle, { get(target, prop) { - if (prop === 'generateKey') { - return () => { - throw new WorkflowRuntimeError( - '`crypto.subtle.generateKey()` is not available inside a workflow function. Move key generation to a step function where full Node.js crypto is available.' - ); - }; - } else if (prop === 'digest') { - return boundDigest; + if (prop === 'digest') { + return digest; + } + const value = target[prop as keyof typeof originalSubtle]; + if (typeof value !== 'function') { + return value; } - return target[prop as keyof typeof originalSubtle]; + // Every other subtle method (`encrypt`, `sign`, `importKey`, …) + // settles on host threadpool timing, which cannot be replayed. + return () => { + throw new WorkflowRuntimeError( + `\`crypto.subtle.${String(prop)}()\` is not available inside a workflow function. Move it to a step function where full Node.js crypto is available.` + ); + }; }, }); }