Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/vm-determinism-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@workflow/core': major
'workflow': major
---

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).
5 changes: 4 additions & 1 deletion docs/content/docs/v5/api-reference/workflow-globals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

<Callout type="info">
You can safely use `Math.random()`, `Date.now()`, and `crypto.randomUUID()` in workflow functions. The framework ensures these return the same values across replays.
Expand Down Expand Up @@ -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.
155 changes: 155 additions & 0 deletions packages/core/src/vm/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down Expand Up @@ -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));
});
});
101 changes: 91 additions & 10 deletions packages/core/src/vm/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -10,6 +12,36 @@ export interface CreateContextOptions {
fixedTimestamp: number;
}

// WebCrypto digest algorithm names → node:crypto (OpenSSL) names.
const DIGEST_ALGORITHMS: Record<string, string> = {
'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.
Expand Down Expand Up @@ -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<string, Record<string, unknown>>;
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<ArrayBuffer> => {
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) {
Expand All @@ -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.`
);
};
},
});
}
Expand Down
Loading