diff --git a/.changeset/vercel-encryption-world-vercel.md b/.changeset/vercel-encryption-world-vercel.md new file mode 100644 index 0000000000..a98f876f6e --- /dev/null +++ b/.changeset/vercel-encryption-world-vercel.md @@ -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 diff --git a/.changeset/vercel-encryption-world.md b/.changeset/vercel-encryption-world.md new file mode 100644 index 0000000000..08bed0b3ab --- /dev/null +++ b/.changeset/vercel-encryption-world.md @@ -0,0 +1,5 @@ +--- +"@workflow/world": patch +--- + +Overload `getEncryptionKeyForRun` interface: accept `WorkflowRun` (preferred) or `runId` string with optional opaque world-specific context for `start()` diff --git a/.changeset/vercel-encryption.md b/.changeset/vercel-encryption.md new file mode 100644 index 0000000000..8d66f355b2 --- /dev/null +++ b/.changeset/vercel-encryption.md @@ -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()` diff --git a/packages/core/package.json b/packages/core/package.json index 51279b8a9d..0b69036666 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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": { diff --git a/packages/core/src/encryption.ts b/packages/core/src/encryption.ts new file mode 100644 index 0000000000..730e4ee115 --- /dev/null +++ b/packages/core/src/encryption.ts @@ -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 { + const nonce = globalThis.crypto.getRandomValues(new Uint8Array(NONCE_LENGTH)); + 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 { + 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); +} diff --git a/packages/core/src/private.ts b/packages/core/src/private.ts index 99796dbb96..b7067a7b88 100644 --- a/packages/core/src/private.ts +++ b/packages/core/src/private.ts @@ -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'; @@ -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; /** diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 4767bbacda..6d144eeb71 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -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 { @@ -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, { @@ -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, @@ -304,9 +307,7 @@ export function workflowEntrypoint( const result = await handleSuspension({ suspension: err, world, - runId, - workflowName, - workflowStartedAt, + run: workflowRun, span, }); diff --git a/packages/core/src/runtime/resume-hook.ts b/packages/core/src/runtime/resume-hook.ts index 8f8c26c4be..18a4123d6e 100644 --- a/packages/core/src/runtime/resume-hook.ts +++ b/packages/core/src/runtime/resume-hook.ts @@ -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, @@ -18,14 +20,19 @@ 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, @@ -33,7 +40,7 @@ async function getHookByTokenWithKey( encryptionKey ); } - return { hook, encryptionKey }; + return { hook, run, encryptionKey }; } /** @@ -80,7 +87,7 @@ export async function getHookByToken(token: string): Promise { export async function resumeHook( tokenOrHook: string | Hook, payload: T, - encryptionKeyOverride?: Uint8Array | undefined + encryptionKeyOverride?: CryptoKey ): Promise { return await waitedUntil(() => { return trace('hook.resume', async (span) => { @@ -88,16 +95,22 @@ export async function resumeHook( 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); + encryptionKey = rawKey ? await importKey(rawKey) : undefined; + } } span?.setAttributes({ @@ -138,8 +151,6 @@ export async function resumeHook( { v1Compat } ); - const workflowRun = await world.runs.get(hook.runId); - span?.setAttributes({ ...Attribute.WorkflowName(workflowRun.workflowName), }); diff --git a/packages/core/src/runtime/run.ts b/packages/core/src/runtime/run.ts index 2ed8667c09..36e292ecf7 100644 --- a/packages/core/src/runtime/run.ts +++ b/packages/core/src/runtime/run.ts @@ -8,6 +8,7 @@ import { type WorkflowRunStatus, type World, } from '@workflow/world'; +import { importKey } from '../encryption.js'; import { getExternalRevivers, hydrateWorkflowReturnValue, @@ -153,9 +154,8 @@ export class Run { 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, diff --git a/packages/core/src/runtime/runs.ts b/packages/core/src/runtime/runs.ts index 9707ac72b5..e9dd3909f0 100644 --- a/packages/core/src/runtime/runs.ts +++ b/packages/core/src/runtime/runs.ts @@ -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'; @@ -49,7 +50,8 @@ export async function recreateRunFromExisting( ): Promise { 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, diff --git a/packages/core/src/runtime/start.ts b/packages/core/src/runtime/start.ts index 18422aefe2..97323a889f 100644 --- a/packages/core/src/runtime/start.ts +++ b/packages/core/src/runtime/start.ts @@ -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'; @@ -120,8 +121,11 @@ export async function start( // 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 diff --git a/packages/core/src/runtime/step-handler.ts b/packages/core/src/runtime/step-handler.ts index 64aa9f8c1a..635b66449b 100644 --- a/packages/core/src/runtime/step-handler.ts +++ b/packages/core/src/runtime/step-handler.ts @@ -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 { @@ -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[] = []; - 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', {}, diff --git a/packages/core/src/runtime/suspension-handler.ts b/packages/core/src/runtime/suspension-handler.ts index 23c3611c67..f232c0d91e 100644 --- a/packages/core/src/runtime/suspension-handler.ts +++ b/packages/core/src/runtime/suspension-handler.ts @@ -5,8 +5,10 @@ import { type CreateEventRequest, type SerializedData, SPEC_VERSION_CURRENT, + type WorkflowRun, type World, } from '@workflow/world'; +import { importKey } from '../encryption.js'; import type { HookInvocationQueueItem, StepInvocationQueueItem, @@ -39,9 +41,7 @@ function extractTraceHeaders( export interface SuspensionHandlerParams { suspension: WorkflowSuspension; world: World; - runId: string; - workflowName: string; - workflowStartedAt: number; + run: WorkflowRun; span?: Span; } @@ -61,11 +61,12 @@ export interface SuspensionHandlerResult { export async function handleSuspension({ suspension, world, - runId, - workflowName, - workflowStartedAt, + run, span, }: SuspensionHandlerParams): Promise { + const runId = run.runId; + const workflowName = run.workflowName; + const workflowStartedAt = run.startedAt ? +run.startedAt : Date.now(); // Separate queue items by type const stepItems = suspension.steps.filter( (item): item is StepInvocationQueueItem => item.type === 'step' @@ -78,7 +79,8 @@ export async function handleSuspension({ ); // Resolve encryption key for this run - const encryptionKey = await world.getEncryptionKeyForRun?.(runId); + const rawKey = await world.getEncryptionKeyForRun?.(run); + const encryptionKey = rawKey ? await importKey(rawKey) : undefined; // Build hook_created events (World will atomically create hook entities) const hookEvents: CreateEventRequest[] = await Promise.all( diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts index 8c13eb2131..18fafe4653 100644 --- a/packages/core/src/serialization.ts +++ b/packages/core/src/serialization.ts @@ -1,9 +1,9 @@ import { WorkflowRuntimeError } from '@workflow/errors'; import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from '@workflow/serde'; - import { DevalueError, parse, stringify, unflatten } from 'devalue'; import { monotonicFactory } from 'ulid'; import { getSerializationClass } from './class-serialization.js'; +import type { CryptoKey } from './encryption.js'; import { createFlushableState, flushablePipe, @@ -1382,7 +1382,7 @@ function getStepRevivers( export async function dehydrateWorkflowArguments( value: unknown, runId: string, - _key: Uint8Array | undefined, + _key: CryptoKey | undefined, ops: Promise[] = [], global: Record = globalThis, v1Compat = false @@ -1416,7 +1416,7 @@ export async function dehydrateWorkflowArguments( export async function hydrateWorkflowArguments( value: Uint8Array | unknown, _runId: string, - _key: Uint8Array | undefined, + _key: CryptoKey | undefined, global: Record = globalThis, extraRevivers: Record any> = {} ) { @@ -1455,7 +1455,7 @@ export async function hydrateWorkflowArguments( export async function dehydrateWorkflowReturnValue( value: unknown, _runId: string, - _key: Uint8Array | undefined, + _key: CryptoKey | undefined, global: Record = globalThis, v1Compat = false ): Promise { @@ -1490,7 +1490,7 @@ export async function dehydrateWorkflowReturnValue( export async function hydrateWorkflowReturnValue( value: Uint8Array | unknown, runId: string, - _key: Uint8Array | undefined, + _key: CryptoKey | undefined, ops: Promise[] = [], global: Record = globalThis, extraRevivers: Record any> = {} @@ -1531,7 +1531,7 @@ export async function hydrateWorkflowReturnValue( export async function dehydrateStepArguments( value: unknown, _runId: string, - _key: Uint8Array | undefined, + _key: CryptoKey | undefined, global: Record = globalThis, v1Compat = false ): Promise { @@ -1565,7 +1565,7 @@ export async function dehydrateStepArguments( export async function hydrateStepArguments( value: Uint8Array | unknown, runId: string, - _key: Uint8Array | undefined, + _key: CryptoKey | undefined, ops: Promise[] = [], global: Record = globalThis, extraRevivers: Record any> = {} @@ -1607,7 +1607,7 @@ export async function hydrateStepArguments( export async function dehydrateStepReturnValue( value: unknown, runId: string, - _key: Uint8Array | undefined, + _key: CryptoKey | undefined, ops: Promise[] = [], global: Record = globalThis, v1Compat = false @@ -1641,7 +1641,7 @@ export async function dehydrateStepReturnValue( export async function hydrateStepReturnValue( value: Uint8Array | unknown, _runId: string, - _key: Uint8Array | undefined, + _key: CryptoKey | undefined, global: Record = globalThis, extraRevivers: Record any> = {} ) { diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 07b4b00f08..8168dc26bb 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -6,6 +6,7 @@ import { parseWorkflowName } from '@workflow/utils/parse-name'; import type { Event, WorkflowRun } from '@workflow/world'; import * as nanoid from 'nanoid'; import { monotonicFactory } from 'ulid'; +import type { CryptoKey } from './encryption.js'; import { EventConsumerResult, EventsConsumer } from './events-consumer.js'; import { ENOTSUP } from './global.js'; import type { WorkflowOrchestratorContext } from './private.js'; @@ -35,7 +36,7 @@ export async function runWorkflow( workflowCode: string, workflowRun: WorkflowRun, events: Event[], - encryptionKey: Uint8Array | undefined + encryptionKey: CryptoKey | undefined ): Promise { return trace(`workflow.run ${workflowRun.workflowName}`, async (span) => { span?.setAttributes({ diff --git a/packages/world-vercel/src/encryption.test.ts b/packages/world-vercel/src/encryption.test.ts new file mode 100644 index 0000000000..cb9354f88f --- /dev/null +++ b/packages/world-vercel/src/encryption.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { deriveRunKey } from './encryption.js'; + +const testProjectId = 'prj_test123'; +const testRunId = 'wrun_abc123'; +// 32 bytes for AES-256 +const testDeploymentKey = new Uint8Array([ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, + 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, + 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, +]); + +describe('deriveRunKey', () => { + it('should derive a 32-byte key', async () => { + const key = await deriveRunKey(testDeploymentKey, testProjectId, testRunId); + expect(key).toBeInstanceOf(Uint8Array); + expect(key.byteLength).toBe(32); + }); + + it('should derive the same key for the same inputs', async () => { + const key1 = await deriveRunKey( + testDeploymentKey, + testProjectId, + testRunId + ); + const key2 = await deriveRunKey( + testDeploymentKey, + testProjectId, + testRunId + ); + expect(key1).toEqual(key2); + }); + + it('should derive different keys for different runIds', async () => { + const key1 = await deriveRunKey( + testDeploymentKey, + testProjectId, + 'wrun_run1' + ); + const key2 = await deriveRunKey( + testDeploymentKey, + testProjectId, + 'wrun_run2' + ); + expect(key1).not.toEqual(key2); + }); + + it('should derive different keys for different projectIds', async () => { + const key1 = await deriveRunKey( + testDeploymentKey, + 'prj_project1', + testRunId + ); + const key2 = await deriveRunKey( + testDeploymentKey, + 'prj_project2', + testRunId + ); + expect(key1).not.toEqual(key2); + }); + + it('should derive different keys for different deployment keys', async () => { + const otherKey = new Uint8Array(32); + crypto.getRandomValues(otherKey); + + const key1 = await deriveRunKey( + testDeploymentKey, + testProjectId, + testRunId + ); + const key2 = await deriveRunKey(otherKey, testProjectId, testRunId); + expect(key1).not.toEqual(key2); + }); + + it('should throw for invalid key length', async () => { + await expect( + deriveRunKey(new Uint8Array(16), testProjectId, testRunId) + ).rejects.toThrow('expected 32 bytes for AES-256, got 16 bytes'); + }); + + it('should throw for empty projectId', async () => { + await expect( + deriveRunKey(testDeploymentKey, '', testRunId) + ).rejects.toThrow('projectId must be a non-empty string'); + }); +}); diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts new file mode 100644 index 0000000000..869ed9ac26 --- /dev/null +++ b/packages/world-vercel/src/encryption.ts @@ -0,0 +1,187 @@ +/** + * Vercel-specific key management for workflow encryption. + * + * This module handles: + * - HKDF key derivation (deployment key + projectId + runId → per-run key) + * - Cross-deployment key retrieval via the Vercel API + * + * The actual AES-GCM encrypt/decrypt operations are in @workflow/core/encryption + * which is browser-compatible. This module is Node.js only (uses node:crypto + * for HKDF and the Vercel API for key retrieval). + */ + +import { webcrypto } from 'node:crypto'; +import { getVercelOidcToken } from '@vercel/oidc'; +import type { WorkflowRun, World } from '@workflow/world'; +import * as z from 'zod'; + +const KEY_BYTES = 32; // 256 bits = 32 bytes (AES-256) + +/** + * Derive a per-run AES-256 encryption key using HKDF-SHA256. + * + * The derivation uses `projectId|runId` as the HKDF info parameter, + * ensuring that each run has a unique encryption key even when sharing + * the same deployment key. + * + * @param deploymentKey - Raw 32-byte deployment key + * @param projectId - Vercel project ID for context isolation + * @param runId - Workflow run ID for per-run key isolation + * @returns Raw 32-byte AES-256 key + */ +export async function deriveRunKey( + deploymentKey: Uint8Array, + projectId: string, + runId: string +): Promise { + if (deploymentKey.length !== KEY_BYTES) { + throw new Error( + `Invalid deployment key length: expected ${KEY_BYTES} bytes for AES-256, got ${deploymentKey.length} bytes` + ); + } + if (!projectId || typeof projectId !== 'string') { + throw new Error('projectId must be a non-empty string'); + } + + const baseKey = await webcrypto.subtle.importKey( + 'raw', + deploymentKey, + 'HKDF', + false, + ['deriveBits'] + ); + + const info = new TextEncoder().encode(`${projectId}|${runId}`); + + // Zero salt is acceptable per RFC 5869 Section 3.1 when the input key + // material has high entropy (as is the case with our random deployment key). + // The `info` parameter provides per-run context separation. + const derivedBits = await webcrypto.subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(32), + info, + }, + baseKey, + KEY_BYTES * 8 // bits + ); + + return new Uint8Array(derivedBits); +} + +/** + * Fetch the per-run encryption key from the Vercel API. + * + * The API performs HKDF-SHA256 derivation server-side, so the raw + * deployment key never leaves the API boundary. The returned key + * is ready-to-use for AES-GCM encrypt/decrypt operations. + * + * Uses OIDC token authentication (for cross-deployment runtime calls like + * resumeHook) or falls back to VERCEL_TOKEN (for external tooling like o11y). + * + * @param deploymentId - The deployment ID that holds the base key material + * @param projectId - The project ID for HKDF context isolation + * @param runId - The workflow run ID for per-run key derivation + * @param options.token - Auth token (from config). Falls back to OIDC or VERCEL_TOKEN. + * @returns Derived 32-byte per-run AES-256 key + */ +export async function fetchRunKey( + deploymentId: string, + projectId: string, + runId: string, + options?: { + /** Auth token (from config). Falls back to OIDC or VERCEL_TOKEN. */ + token?: string; + } +): Promise { + // Authenticate via provided token (CLI/config), OIDC token (runtime), + // or VERCEL_TOKEN env var (external tooling) + const oidcToken = await getVercelOidcToken().catch(() => null); + const token = options?.token ?? oidcToken ?? process.env.VERCEL_TOKEN; + if (!token) { + throw new Error( + 'Cannot fetch run key: no OIDC token or VERCEL_TOKEN available' + ); + } + + const params = new URLSearchParams({ projectId, runId }); + const response = await fetch( + `https://api.vercel.com/v1/workflow/run-key/${deploymentId}?${params}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch run key for ${runId} (deployment ${deploymentId}): HTTP ${response.status}` + ); + } + + const data = await response.json(); + const result = z.object({ key: z.string() }).safeParse(data); + if (!result.success) { + throw new Error('Invalid response from Vercel API, missing "key" field'); + } + return Buffer.from(result.data.key, 'base64'); +} + +/** + * Create the `getEncryptionKeyForRun` implementation for a Vercel World. + * + * Resolves the per-run AES-256 key by either: + * - Deriving it locally via HKDF when the run belongs to the current deployment + * - Fetching it from the Vercel API when the run belongs to a different deployment + * + * @param projectId - Vercel project ID for HKDF context isolation + * @param token - Optional auth token from config + * @returns The `getEncryptionKeyForRun` function, or `undefined` if no projectId + */ +export function createGetEncryptionKeyForRun( + projectId: string | undefined, + token?: string +): World['getEncryptionKeyForRun'] { + if (!projectId) return undefined; + + const currentDeploymentId = process.env.VERCEL_DEPLOYMENT_ID; + + // Parse the local deployment key from env (lazy, only when encryption is used) + let localDeploymentKey: Uint8Array | undefined; + function getLocalDeploymentKey(): Uint8Array | undefined { + if (localDeploymentKey) return localDeploymentKey; + const deploymentKeyBase64 = process.env.VERCEL_DEPLOYMENT_KEY; + if (!deploymentKeyBase64) return undefined; + localDeploymentKey = Buffer.from(deploymentKeyBase64, 'base64'); + return localDeploymentKey; + } + + return async function getEncryptionKeyForRun( + run: WorkflowRun | string, + context?: Record + ): Promise { + const runId = typeof run === 'string' ? run : run.runId; + const deploymentId = + typeof run === 'string' + ? (context?.deploymentId as string | undefined) + : run.deploymentId; + + // Same deployment, or no deploymentId provided (e.g., start() on + // current deployment, or step-handler during same-deployment execution) + // → use local deployment key + local HKDF derivation + if (!deploymentId || deploymentId === currentDeploymentId) { + const localKey = getLocalDeploymentKey(); + if (!localKey) return undefined; + return deriveRunKey(localKey, projectId, runId); + } + + // Different deployment — fetch the derived per-run key from the + // Vercel API. The API performs HKDF derivation server-side so the + // raw deployment key never leaves the API boundary. + // Covers cross-deployment resumeHook() (OIDC auth) and o11y + // tooling reading data from other deployments (VERCEL_TOKEN). + return fetchRunKey(deploymentId, projectId, runId, { token }); + }; +} diff --git a/packages/world-vercel/src/index.ts b/packages/world-vercel/src/index.ts index 37865424b0..5221e3fbc3 100644 --- a/packages/world-vercel/src/index.ts +++ b/packages/world-vercel/src/index.ts @@ -1,18 +1,33 @@ import type { World } from '@workflow/world'; +import { createGetEncryptionKeyForRun } from './encryption.js'; import { createQueue } from './queue.js'; import { createStorage } from './storage.js'; import { createStreamer } from './streamer.js'; import type { APIConfig } from './utils.js'; +export { + createGetEncryptionKeyForRun, + deriveRunKey, + fetchRunKey, +} from './encryption.js'; export { createQueue } from './queue.js'; export { createStorage } from './storage.js'; export { createStreamer } from './streamer.js'; export type { APIConfig } from './utils.js'; export function createVercelWorld(config?: APIConfig): World { + // Project ID for HKDF key derivation context. + // Use config value first (set correctly by CLI/web), fall back to env var (runtime). + const projectId = + config?.projectConfig?.projectId || process.env.VERCEL_PROJECT_ID; + return { ...createQueue(config), ...createStorage(config), ...createStreamer(config), + getEncryptionKeyForRun: createGetEncryptionKeyForRun( + projectId, + config?.token + ), }; } diff --git a/packages/world/src/interfaces.ts b/packages/world/src/interfaces.ts index 4a940e2977..9fe73b7d1d 100644 --- a/packages/world/src/interfaces.ts +++ b/packages/world/src/interfaces.ts @@ -195,20 +195,23 @@ export interface World extends Queue, Storage, Streamer { * (e.g., HKDF from a deployment key). The core encryption module uses * this key directly for AES-GCM encrypt/decrypt operations. * - * Accepts either a full `WorkflowRun` object or a plain `runId` string: - * - `WorkflowRun` — Used by the o11y/CLI path when the run entity is - * already available. Provides `deploymentId` for cross-deployment key - * resolution without a redundant lookup. - * - `string` (runId) — Used by `start()` and `step-handler` in the - * runtime path where the run entity may not exist yet or isn't needed. - * The World assumes the current deployment for key resolution. + * Two overloads: * - * When not implemented, encryption is disabled — data is stored unencrypted. + * - `getEncryptionKeyForRun(run)` — Preferred. Pass a `WorkflowRun` when + * the run entity already exists. The World reads any context it needs + * (e.g., `deploymentId`) directly from the run. + * + * - `getEncryptionKeyForRun(runId, context?)` — Used only by `start()` + * when the run entity has not yet been created. The `context` parameter + * carries opaque world-specific data (e.g., `{ deploymentId }` for + * world-vercel) that the World needs to resolve the correct key. + * When `context` is omitted, the World assumes the current deployment. * - * @param run - A WorkflowRun entity or a runId string - * @returns The per-run AES-256 key, or undefined if encryption is not configured + * When not implemented, encryption is disabled — data is stored unencrypted. */ + getEncryptionKeyForRun?(run: WorkflowRun): Promise; getEncryptionKeyForRun?( - run: WorkflowRun | string + runId: string, + context?: Record ): Promise; }