Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/vercel-encryption-world-vercel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/world-vercel": patch
---

Implement `getEncryptionKeyForRun` with HKDF-SHA256 per-run key derivation and cross-deployment key resolution via `fetchRunKey` API
5 changes: 5 additions & 0 deletions .changeset/vercel-encryption-world.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/world": patch
---

Overload `getEncryptionKeyForRun` interface: accept `WorkflowRun` (preferred) or `runId` string with optional opaque world-specific context for `start()`
5 changes: 5 additions & 0 deletions .changeset/vercel-encryption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---

Add browser-compatible AES-256-GCM encryption module with `importKey`, `encrypt`, and `decrypt` functions; update all runtime callers to resolve `CryptoKey` once per run via `importKey()`
4 changes: 4 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@
"types": "./dist/serialization-format.d.ts",
"default": "./dist/serialization-format.js"
},
"./encryption": {
"types": "./dist/encryption.d.ts",
"default": "./dist/encryption.js"
},
"./_workflow": "./dist/workflow/index.js"
},
"scripts": {
Expand Down
97 changes: 97 additions & 0 deletions packages/core/src/encryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Browser-compatible AES-256-GCM encryption module.
*
* Uses the Web Crypto API (`globalThis.crypto.subtle`) which works in
* both modern browsers and Node.js 20+. This module is intentionally
* free of Node.js-specific imports so it can be bundled for the browser.
*
* The World interface (`getEncryptionKeyForRun`) returns a raw 32-byte
* AES-256 key. Callers should use `importKey()` once to convert it to a
* `CryptoKey`, then pass that to `encrypt()`/`decrypt()` for all
* operations within the same run. This avoids repeated `importKey()`
* calls on every encrypt/decrypt invocation.
*
* Wire format: `[nonce (12 bytes)][ciphertext + auth tag]`
* The `encr` format prefix is NOT part of this module — it's added/stripped
* by the serialization layer in `maybeEncrypt`/`maybeDecrypt`.
*/

// CryptoKey is a global type in browsers and Node.js 20+, but TypeScript's
// `es2022` lib doesn't include it. Re-export it from the node:crypto types
// so consumers can reference it without adding `dom` lib.
export type CryptoKey = import('node:crypto').webcrypto.CryptoKey;

const NONCE_LENGTH = 12;
const TAG_LENGTH = 128; // bits
const KEY_LENGTH = 32; // bytes (AES-256)

/**
* Import a raw AES-256 key as a `CryptoKey` for use with `encrypt()`/`decrypt()`.
*
* Callers should call this once per run (after `getEncryptionKeyForRun()`)
* and pass the resulting `CryptoKey` to all subsequent encrypt/decrypt calls.
*
* @param raw - Raw 32-byte AES-256 key (from World.getEncryptionKeyForRun)
* @returns CryptoKey ready for AES-GCM operations
*/
export async function importKey(raw: Uint8Array) {
if (raw.byteLength !== KEY_LENGTH) {
throw new Error(
`Encryption key must be exactly ${KEY_LENGTH} bytes, got ${raw.byteLength}`
);
}
return globalThis.crypto.subtle.importKey('raw', raw, 'AES-GCM', false, [
'encrypt',
'decrypt',
]);
}

/**
* Encrypt data using AES-256-GCM.
*
* @param key - CryptoKey from `importKey()`
* @param data - Plaintext to encrypt
* @returns `[nonce (12 bytes)][ciphertext + GCM auth tag]`
*/
export async function encrypt(
key: CryptoKey,
data: Uint8Array
): Promise<Uint8Array> {
const nonce = globalThis.crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
Comment thread
TooTallNate marked this conversation as resolved.
const ciphertext = await globalThis.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: nonce, tagLength: TAG_LENGTH },
key,
data
);
const result = new Uint8Array(NONCE_LENGTH + ciphertext.byteLength);
result.set(nonce, 0);
result.set(new Uint8Array(ciphertext), NONCE_LENGTH);
return result;
}

/**
* Decrypt data using AES-256-GCM.
*
* @param key - CryptoKey from `importKey()`
* @param data - `[nonce (12 bytes)][ciphertext + GCM auth tag]`
* @returns Decrypted plaintext
*/
export async function decrypt(
key: CryptoKey,
data: Uint8Array
): Promise<Uint8Array> {
const minLength = NONCE_LENGTH + TAG_LENGTH / 8; // nonce + auth tag
if (data.byteLength < minLength) {
throw new Error(
`Encrypted data too short: expected at least ${minLength} bytes, got ${data.byteLength}`
);
}
const nonce = data.subarray(0, NONCE_LENGTH);
const ciphertext = data.subarray(NONCE_LENGTH);
const plaintext = await globalThis.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: nonce, tagLength: TAG_LENGTH },
key,
ciphertext
);
return new Uint8Array(plaintext);
}
3 changes: 2 additions & 1 deletion packages/core/src/private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Utils used by the bundler when transforming code
*/

import type { CryptoKey } from './encryption.js';
import type { EventsConsumer } from './events-consumer.js';
import type { QueueItem } from './global.js';
import type { Serializable } from './schemas.js';
Expand Down Expand Up @@ -89,7 +90,7 @@ export { __private_getClosureVars } from './step/get-closure-vars.js';

export interface WorkflowOrchestratorContext {
runId: string;
encryptionKey: Uint8Array | undefined;
encryptionKey: CryptoKey | undefined;
globalThis: typeof globalThis;
eventsConsumer: EventsConsumer;
/**
Expand Down
15 changes: 8 additions & 7 deletions packages/core/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
WorkflowInvokePayloadSchema,
type WorkflowRun,
} from '@workflow/world';
import { importKey } from './encryption.js';
import { WorkflowSuspension } from './global.js';
import { runtimeLogger } from './logger.js';
import {
Expand Down Expand Up @@ -137,9 +138,8 @@ export function workflowEntrypoint(

return await withThrottleRetry(async () => {
let workflowStartedAt = -1;
let workflowRun = await world.runs.get(runId);
try {
let workflowRun = await world.runs.get(runId);

if (workflowRun.status === 'pending') {
// Transition run to 'running' via event (event-sourced architecture)
const result = await world.events.create(runId, {
Expand Down Expand Up @@ -250,8 +250,11 @@ export function workflowEntrypoint(
...Attribute.WorkflowEventsCount(events.length),
});
// Resolve the encryption key for this run's deployment
const encryptionKey =
await world.getEncryptionKeyForRun?.(runId);
const rawKey =
await world.getEncryptionKeyForRun?.(workflowRun);
const encryptionKey = rawKey
? await importKey(rawKey)
: undefined;
return await runWorkflow(
workflowCode,
workflowRun,
Expand Down Expand Up @@ -304,9 +307,7 @@ export function workflowEntrypoint(
const result = await handleSuspension({
suspension: err,
world,
runId,
workflowName,
workflowStartedAt,
run: workflowRun,
span,
});

Expand Down
37 changes: 24 additions & 13 deletions packages/core/src/runtime/resume-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
isLegacySpecVersion,
SPEC_VERSION_CURRENT,
type WorkflowInvokePayload,
type WorkflowRun,
} from '@workflow/world';
import { type CryptoKey, importKey } from '../encryption.js';
import {
dehydrateStepReturnValue,
hydrateStepArguments,
Expand All @@ -18,22 +20,27 @@ import { getWorkflowQueueName } from './helpers.js';
import { getWorld } from './world.js';

/**
* Internal helper that returns both the hook and the resolved encryption key.
* Internal helper that returns the hook, the associated workflow run,
* and the resolved encryption key.
*/
async function getHookByTokenWithKey(
token: string
): Promise<{ hook: Hook; encryptionKey: Uint8Array | undefined }> {
async function getHookByTokenWithKey(token: string): Promise<{
hook: Hook;
run: WorkflowRun;
encryptionKey: CryptoKey | undefined;
}> {
const world = getWorld();
const hook = await world.hooks.getByToken(token);
const encryptionKey = await world.getEncryptionKeyForRun?.(hook.runId);
const run = await world.runs.get(hook.runId);
const rawKey = await world.getEncryptionKeyForRun?.(run);
const encryptionKey = rawKey ? await importKey(rawKey) : undefined;
if (typeof hook.metadata !== 'undefined') {
hook.metadata = await hydrateStepArguments(
hook.metadata as any,
hook.runId,
encryptionKey
);
}
return { hook, encryptionKey };
return { hook, run, encryptionKey };
}

/**
Expand Down Expand Up @@ -80,24 +87,30 @@ export async function getHookByToken(token: string): Promise<Hook> {
export async function resumeHook<T = any>(
tokenOrHook: string | Hook,
payload: T,
encryptionKeyOverride?: Uint8Array | undefined
encryptionKeyOverride?: CryptoKey
): Promise<Hook> {
return await waitedUntil(() => {
return trace('hook.resume', async (span) => {
const world = getWorld();

try {
let hook: Hook;
let encryptionKey: Uint8Array | undefined;
let workflowRun: WorkflowRun;
let encryptionKey: CryptoKey | undefined;
if (typeof tokenOrHook === 'string') {
const result = await getHookByTokenWithKey(tokenOrHook);
hook = result.hook;
workflowRun = result.run;
encryptionKey = encryptionKeyOverride ?? result.encryptionKey;
} else {
hook = tokenOrHook;
encryptionKey =
encryptionKeyOverride ??
(await world.getEncryptionKeyForRun?.(hook.runId));
workflowRun = await world.runs.get(hook.runId);
if (encryptionKeyOverride) {
encryptionKey = encryptionKeyOverride;
} else {
const rawKey = await world.getEncryptionKeyForRun?.(workflowRun);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For cross-deployment hook resumptions / fetching of the encryption key, we should consider enriching the "source" of the request for the key (i.e. resumeHook in this case) for the audit log to include the context.

encryptionKey = rawKey ? await importKey(rawKey) : undefined;
}
}

span?.setAttributes({
Expand Down Expand Up @@ -138,8 +151,6 @@ export async function resumeHook<T = any>(
{ v1Compat }
);

const workflowRun = await world.runs.get(hook.runId);

span?.setAttributes({
...Attribute.WorkflowName(workflowRun.workflowName),
});
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/runtime/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type WorkflowRunStatus,
type World,
} from '@workflow/world';
import { importKey } from '../encryption.js';
import {
getExternalRevivers,
hydrateWorkflowReturnValue,
Expand Down Expand Up @@ -153,9 +154,8 @@ export class Run<TResult> {
const run = await this.world.runs.get(this.runId);

if (run.status === 'completed') {
const encryptionKey = await this.world.getEncryptionKeyForRun?.(
this.runId
);
const rawKey = await this.world.getEncryptionKeyForRun?.(run);
const encryptionKey = rawKey ? await importKey(rawKey) : undefined;
return await hydrateWorkflowReturnValue(
run.output,
this.runId,
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/runtime/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
SPEC_VERSION_LEGACY,
type World,
} from '@workflow/world';
import { importKey } from '../encryption.js';
import { hydrateWorkflowArguments } from '../serialization.js';
import { getWorkflowQueueName } from './helpers.js';
import { start } from './start.js';
Expand Down Expand Up @@ -49,7 +50,8 @@ export async function recreateRunFromExisting(
): Promise<string> {
try {
const run = await world.runs.get(runId, { resolveData: 'all' });
const encryptionKey = await world.getEncryptionKeyForRun?.(runId);
const rawKey = await world.getEncryptionKeyForRun?.(run);
const encryptionKey = rawKey ? await importKey(rawKey) : undefined;
const workflowArgs = normalizeWorkflowArgs(
await hydrateWorkflowArguments(
run.input,
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/runtime/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { WorkflowRuntimeError } from '@workflow/errors';
import type { WorkflowInvokePayload, World } from '@workflow/world';
import { isLegacySpecVersion, SPEC_VERSION_CURRENT } from '@workflow/world';
import { monotonicFactory } from 'ulid';
import { importKey } from '../encryption.js';
import type { Serializable } from '../schemas.js';
import { dehydrateWorkflowArguments } from '../serialization.js';
import * as Attribute from '../telemetry/semantic-conventions.js';
Expand Down Expand Up @@ -120,8 +121,11 @@ export async function start<TArgs extends unknown[], TResult>(
// Resolve encryption key for the new run. The runId has already been
// generated above (client-generated ULID) and will be used for both
// key derivation and the run_created event. The World implementation
// uses the runId for per-run HKDF key derivation.
const encryptionKey = await world.getEncryptionKeyForRun?.(runId);
// uses the runId for per-run HKDF key derivation. The opts object is
// passed as opaque context so the World can read world-specific fields
// (e.g., deploymentId for world-vercel) needed for key resolution.
const rawKey = await world.getEncryptionKeyForRun?.(runId, { ...opts });
const encryptionKey = rawKey ? await importKey(rawKey) : undefined;

// Create run via run_created event (event-sourced architecture)
// Pass client-generated runId - server will accept and use it
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/runtime/step-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { pluralize } from '@workflow/utils';
import { getPort } from '@workflow/utils/get-port';
import { SPEC_VERSION_CURRENT, StepInvokePayloadSchema } from '@workflow/world';
import { importKey } from '../encryption.js';
import { runtimeLogger, stepLogger } from '../logger.js';
import { getStepFunction } from '../private.js';
import {
Expand Down Expand Up @@ -293,8 +294,8 @@ const stepHandler = getWorldHandlers().createQueueHandler(
// operations (e.g., stream loading) are added to `ops` and executed later
// via Promise.all(ops) - their timing is not included in this measurement.
const ops: Promise<void>[] = [];
const encryptionKey =
await world.getEncryptionKeyForRun?.(workflowRunId);
const rawKey = await world.getEncryptionKeyForRun?.(workflowRunId);
const encryptionKey = rawKey ? await importKey(rawKey) : undefined;
const hydratedInput = await trace(
'step.hydrate',
{},
Expand Down
Loading
Loading