From e9fc5ae362e2e7b761c37ecfb5b25d85a9d053f9 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Thu, 5 Feb 2026 18:30:34 -0800 Subject: [PATCH 01/12] Add browser-compatible AES-GCM to core and HKDF key derivation to world-vercel --- .changeset/vercel-encryption.md | 10 ++ packages/core/package.json | 4 + packages/core/src/encryption.ts | 90 ++++++++++ packages/world-vercel/package.json | 1 + packages/world-vercel/src/encryption.test.ts | 179 +++++++++++++++++++ packages/world-vercel/src/encryption.ts | 115 ++++++++++++ packages/world-vercel/src/index.ts | 60 ++++++- pnpm-lock.yaml | 3 + 8 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 .changeset/vercel-encryption.md create mode 100644 packages/core/src/encryption.ts create mode 100644 packages/world-vercel/src/encryption.test.ts create mode 100644 packages/world-vercel/src/encryption.ts diff --git a/.changeset/vercel-encryption.md b/.changeset/vercel-encryption.md new file mode 100644 index 0000000000..b4fb0d92a3 --- /dev/null +++ b/.changeset/vercel-encryption.md @@ -0,0 +1,10 @@ +--- +"@workflow/world-vercel": patch +"@workflow/core": patch +--- + +Add AES-256-GCM encryption to core and HKDF key derivation to world-vercel + +Adds browser-compatible `encrypt()`/`decrypt()` functions to `@workflow/core/encryption` using the Web Crypto API (AES-256-GCM). Adds `deriveRunKey()` and `fetchDeploymentKey()` to `@workflow/world-vercel` for HKDF-SHA256 per-run key derivation and cross-deployment key retrieval. + +Implements `World.getEncryptionKeyForRun()` in `createVercelWorld()` — accepts either a `WorkflowRun` or a `runId` string, derives a per-run AES-256 key using HKDF with the deployment key and project ID, and returns raw key bytes for use with core's encrypt/decrypt functions. 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..6a0b0a4572 --- /dev/null +++ b/packages/core/src/encryption.ts @@ -0,0 +1,90 @@ +/** + * 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. This module uses that key directly for AES-GCM operations. + * + * 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`. + */ + +const NONCE_LENGTH = 12; +const TAG_LENGTH = 128; // bits +const KEY_LENGTH = 32; // bytes (AES-256) + +/** + * Encrypt data using AES-256-GCM. + * + * @param key - Raw 32-byte AES-256 key + * @param data - Plaintext to encrypt + * @returns `[nonce (12 bytes)][ciphertext + GCM auth tag]` + */ +export async function encrypt( + key: Uint8Array, + data: Uint8Array +): Promise { + if (key.byteLength !== KEY_LENGTH) { + throw new Error( + `Encryption key must be exactly ${KEY_LENGTH} bytes, got ${key.byteLength}` + ); + } + const cryptoKey = await importKey(key); + const nonce = globalThis.crypto.getRandomValues(new Uint8Array(NONCE_LENGTH)); + const ciphertext = await globalThis.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce, tagLength: TAG_LENGTH }, + cryptoKey, + 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 - Raw 32-byte AES-256 key + * @param data - `[nonce (12 bytes)][ciphertext + GCM auth tag]` + * @returns Decrypted plaintext + */ +export async function decrypt( + key: Uint8Array, + data: Uint8Array +): Promise { + if (key.byteLength !== KEY_LENGTH) { + throw new Error( + `Encryption key must be exactly ${KEY_LENGTH} bytes, got ${key.byteLength}` + ); + } + 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 cryptoKey = await importKey(key); + 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 }, + cryptoKey, + ciphertext + ); + return new Uint8Array(plaintext); +} + +/** + * Import a raw key as a CryptoKey for AES-GCM operations. + */ +async function importKey(raw: Uint8Array) { + return globalThis.crypto.subtle.importKey('raw', raw, 'AES-GCM', false, [ + 'encrypt', + 'decrypt', + ]); +} diff --git a/packages/world-vercel/package.json b/packages/world-vercel/package.json index c7e187750d..6897fa4c58 100644 --- a/packages/world-vercel/package.json +++ b/packages/world-vercel/package.json @@ -48,6 +48,7 @@ "devDependencies": { "@opentelemetry/api": "1.9.0", "@types/node": "catalog:", + "@workflow/core": "workspace:*", "@workflow/tsconfig": "workspace:*", "genversion": "3.2.0", "vitest": "catalog:" diff --git a/packages/world-vercel/src/encryption.test.ts b/packages/world-vercel/src/encryption.test.ts new file mode 100644 index 0000000000..7ec455ad6c --- /dev/null +++ b/packages/world-vercel/src/encryption.test.ts @@ -0,0 +1,179 @@ +import { decrypt, encrypt } from '@workflow/core/encryption'; +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'); + }); +}); + +describe('deriveRunKey + core encrypt/decrypt round-trip', () => { + it('should encrypt and decrypt data correctly', async () => { + const key = await deriveRunKey(testDeploymentKey, testProjectId, testRunId); + const plaintext = new TextEncoder().encode('Hello, World!'); + + const encrypted = await encrypt(key, plaintext); + const decrypted = await decrypt(key, encrypted); + + expect(decrypted).toEqual(plaintext); + expect(new TextDecoder().decode(decrypted)).toBe('Hello, World!'); + }); + + it('should encrypt and decrypt empty data', async () => { + const key = await deriveRunKey(testDeploymentKey, testProjectId, testRunId); + const plaintext = new Uint8Array(0); + + const encrypted = await encrypt(key, plaintext); + const decrypted = await decrypt(key, encrypted); + + expect(decrypted).toEqual(plaintext); + }); + + it('should encrypt and decrypt large data', async () => { + const key = await deriveRunKey(testDeploymentKey, testProjectId, testRunId); + const plaintext = new Uint8Array(65536); + crypto.getRandomValues(plaintext); + + const encrypted = await encrypt(key, plaintext); + const decrypted = await decrypt(key, encrypted); + + expect(decrypted).toEqual(plaintext); + }); + + it('should produce different ciphertext for same data (random nonce)', async () => { + const key = await deriveRunKey(testDeploymentKey, testProjectId, testRunId); + const plaintext = new TextEncoder().encode('test'); + + const encrypted1 = await encrypt(key, plaintext); + const encrypted2 = await encrypt(key, plaintext); + + expect(encrypted1).not.toEqual(encrypted2); + + const decrypted1 = await decrypt(key, encrypted1); + const decrypted2 = await decrypt(key, encrypted2); + expect(decrypted1).toEqual(plaintext); + expect(decrypted2).toEqual(plaintext); + }); + + it('should fail to decrypt with a key derived from a different runId', async () => { + const key1 = await deriveRunKey( + testDeploymentKey, + testProjectId, + 'wrun_run1' + ); + const key2 = await deriveRunKey( + testDeploymentKey, + testProjectId, + 'wrun_run2' + ); + + const plaintext = new TextEncoder().encode('sensitive data'); + const encrypted = await encrypt(key1, plaintext); + + await expect(decrypt(key2, encrypted)).rejects.toThrow(); + }); + + it('should fail to decrypt tampered ciphertext', async () => { + const key = await deriveRunKey(testDeploymentKey, testProjectId, testRunId); + const plaintext = new TextEncoder().encode('test'); + const encrypted = await encrypt(key, plaintext); + + const tampered = new Uint8Array(encrypted); + tampered[20] ^= 0xff; + + await expect(decrypt(key, tampered)).rejects.toThrow(); + }); + + it('should produce raw encrypted data without format prefix', async () => { + const key = await deriveRunKey(testDeploymentKey, testProjectId, testRunId); + const plaintext = new TextEncoder().encode('test'); + + const encrypted = await encrypt(key, plaintext); + + // Core encrypt produces [nonce][ciphertext], NOT 'encr' prefix + const prefix = new TextDecoder().decode(encrypted.subarray(0, 4)); + expect(prefix).not.toBe('encr'); + + // Minimum size: 12 (nonce) + 16 (auth tag) = 28 bytes + expect(encrypted.length).toBeGreaterThanOrEqual(28); + }); +}); diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts new file mode 100644 index 0000000000..cf15b8dbfb --- /dev/null +++ b/packages/world-vercel/src/encryption.ts @@ -0,0 +1,115 @@ +/** + * 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'; + +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 deployment key for a specific deployment from the Vercel API. + * + * 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 to fetch the key for + * @param options.token - Auth token (from config). Falls back to OIDC or VERCEL_TOKEN. + * @returns Raw 32-byte deployment key + */ +export async function fetchDeploymentKey( + deploymentId: 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 deployment key: no OIDC token or VERCEL_TOKEN available' + ); + } + + const response = await fetch( + `https://api.vercel.com/v1/workflow/deployment-key/${deploymentId}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch deployment key for ${deploymentId}: HTTP ${response.status}` + ); + } + + const data = (await response.json()) as { key: string }; + return Uint8Array.from(Buffer.from(data.key, 'base64')); +} diff --git a/packages/world-vercel/src/index.ts b/packages/world-vercel/src/index.ts index 37865424b0..af536a927e 100644 --- a/packages/world-vercel/src/index.ts +++ b/packages/world-vercel/src/index.ts @@ -1,18 +1,74 @@ -import type { World } from '@workflow/world'; +import type { WorkflowRun, World } from '@workflow/world'; +import { deriveRunKey, fetchDeploymentKey } 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 { deriveRunKey, fetchDeploymentKey } 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 { + const storage = createStorage(config); + // 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; + 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 = Uint8Array.from( + Buffer.from(deploymentKeyBase64, 'base64') + ); + return localDeploymentKey; + } + + // Instance-scoped cache for remote deployment keys. + // Must NOT be module-level to prevent key material leaking across + // tenants in multi-tenant environments (e.g., Vercel dashboard). + const remoteKeyCache = new Map(); + return { ...createQueue(config), - ...createStorage(config), + ...storage, ...createStreamer(config), + + async getEncryptionKeyForRun( + run: WorkflowRun | string + ): Promise { + if (!projectId) return undefined; + + const runId = typeof run === 'string' ? run : run.runId; + const deploymentId = + typeof run === 'string' ? undefined : run.deploymentId; + + // Same deployment (or run is just a string, i.e., from start()) + // → use local deployment key + if (!deploymentId || deploymentId === currentDeploymentId) { + const localKey = getLocalDeploymentKey(); + if (!localKey) return undefined; + return deriveRunKey(localKey, projectId, runId); + } + + // Different deployment — fetch key from Vercel API. + // This covers cross-deployment resumeHook() (using OIDC auth) + // and o11y tooling reading data from other deployments (using VERCEL_TOKEN). + let remoteKey = remoteKeyCache.get(deploymentId); + if (!remoteKey) { + remoteKey = await fetchDeploymentKey(deploymentId, { + token: config?.token, + }); + remoteKeyCache.set(deploymentId, remoteKey); + } + return deriveRunKey(remoteKey, projectId, runId); + }, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a25d1668ea..8760613071 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1400,6 +1400,9 @@ importers: '@types/node': specifier: 'catalog:' version: 22.19.0 + '@workflow/core': + specifier: workspace:* + version: link:../core '@workflow/tsconfig': specifier: workspace:* version: link:../tsconfig From fd74dafc0224ff4eb2433b94a7d834978e9bdd7e Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Sat, 14 Feb 2026 02:38:51 -0800 Subject: [PATCH 02/12] update changeset --- .changeset/vercel-encryption.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.changeset/vercel-encryption.md b/.changeset/vercel-encryption.md index b4fb0d92a3..928544dc93 100644 --- a/.changeset/vercel-encryption.md +++ b/.changeset/vercel-encryption.md @@ -1,10 +1,6 @@ --- -"@workflow/world-vercel": patch "@workflow/core": patch +"@workflow/world-vercel": patch --- Add AES-256-GCM encryption to core and HKDF key derivation to world-vercel - -Adds browser-compatible `encrypt()`/`decrypt()` functions to `@workflow/core/encryption` using the Web Crypto API (AES-256-GCM). Adds `deriveRunKey()` and `fetchDeploymentKey()` to `@workflow/world-vercel` for HKDF-SHA256 per-run key derivation and cross-deployment key retrieval. - -Implements `World.getEncryptionKeyForRun()` in `createVercelWorld()` — accepts either a `WorkflowRun` or a `runId` string, derives a per-run AES-256 key using HKDF with the deployment key and project ID, and returns raw key bytes for use with core's encrypt/decrypt functions. From c859fecd87b13ca6ba5ee954a9826154ff76e16e Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Sun, 15 Feb 2026 02:11:49 -0800 Subject: [PATCH 03/12] Move HKDF key derivation server-side: API returns per-run derived key --- packages/world-vercel/package.json | 1 - packages/world-vercel/src/encryption.test.ts | 93 -------------------- packages/world-vercel/src/encryption.ts | 23 +++-- packages/world-vercel/src/index.ts | 31 +++---- pnpm-lock.yaml | 3 - 5 files changed, 28 insertions(+), 123 deletions(-) diff --git a/packages/world-vercel/package.json b/packages/world-vercel/package.json index 6897fa4c58..c7e187750d 100644 --- a/packages/world-vercel/package.json +++ b/packages/world-vercel/package.json @@ -48,7 +48,6 @@ "devDependencies": { "@opentelemetry/api": "1.9.0", "@types/node": "catalog:", - "@workflow/core": "workspace:*", "@workflow/tsconfig": "workspace:*", "genversion": "3.2.0", "vitest": "catalog:" diff --git a/packages/world-vercel/src/encryption.test.ts b/packages/world-vercel/src/encryption.test.ts index 7ec455ad6c..cb9354f88f 100644 --- a/packages/world-vercel/src/encryption.test.ts +++ b/packages/world-vercel/src/encryption.test.ts @@ -1,4 +1,3 @@ -import { decrypt, encrypt } from '@workflow/core/encryption'; import { describe, expect, it } from 'vitest'; import { deriveRunKey } from './encryption.js'; @@ -85,95 +84,3 @@ describe('deriveRunKey', () => { ).rejects.toThrow('projectId must be a non-empty string'); }); }); - -describe('deriveRunKey + core encrypt/decrypt round-trip', () => { - it('should encrypt and decrypt data correctly', async () => { - const key = await deriveRunKey(testDeploymentKey, testProjectId, testRunId); - const plaintext = new TextEncoder().encode('Hello, World!'); - - const encrypted = await encrypt(key, plaintext); - const decrypted = await decrypt(key, encrypted); - - expect(decrypted).toEqual(plaintext); - expect(new TextDecoder().decode(decrypted)).toBe('Hello, World!'); - }); - - it('should encrypt and decrypt empty data', async () => { - const key = await deriveRunKey(testDeploymentKey, testProjectId, testRunId); - const plaintext = new Uint8Array(0); - - const encrypted = await encrypt(key, plaintext); - const decrypted = await decrypt(key, encrypted); - - expect(decrypted).toEqual(plaintext); - }); - - it('should encrypt and decrypt large data', async () => { - const key = await deriveRunKey(testDeploymentKey, testProjectId, testRunId); - const plaintext = new Uint8Array(65536); - crypto.getRandomValues(plaintext); - - const encrypted = await encrypt(key, plaintext); - const decrypted = await decrypt(key, encrypted); - - expect(decrypted).toEqual(plaintext); - }); - - it('should produce different ciphertext for same data (random nonce)', async () => { - const key = await deriveRunKey(testDeploymentKey, testProjectId, testRunId); - const plaintext = new TextEncoder().encode('test'); - - const encrypted1 = await encrypt(key, plaintext); - const encrypted2 = await encrypt(key, plaintext); - - expect(encrypted1).not.toEqual(encrypted2); - - const decrypted1 = await decrypt(key, encrypted1); - const decrypted2 = await decrypt(key, encrypted2); - expect(decrypted1).toEqual(plaintext); - expect(decrypted2).toEqual(plaintext); - }); - - it('should fail to decrypt with a key derived from a different runId', async () => { - const key1 = await deriveRunKey( - testDeploymentKey, - testProjectId, - 'wrun_run1' - ); - const key2 = await deriveRunKey( - testDeploymentKey, - testProjectId, - 'wrun_run2' - ); - - const plaintext = new TextEncoder().encode('sensitive data'); - const encrypted = await encrypt(key1, plaintext); - - await expect(decrypt(key2, encrypted)).rejects.toThrow(); - }); - - it('should fail to decrypt tampered ciphertext', async () => { - const key = await deriveRunKey(testDeploymentKey, testProjectId, testRunId); - const plaintext = new TextEncoder().encode('test'); - const encrypted = await encrypt(key, plaintext); - - const tampered = new Uint8Array(encrypted); - tampered[20] ^= 0xff; - - await expect(decrypt(key, tampered)).rejects.toThrow(); - }); - - it('should produce raw encrypted data without format prefix', async () => { - const key = await deriveRunKey(testDeploymentKey, testProjectId, testRunId); - const plaintext = new TextEncoder().encode('test'); - - const encrypted = await encrypt(key, plaintext); - - // Core encrypt produces [nonce][ciphertext], NOT 'encr' prefix - const prefix = new TextDecoder().decode(encrypted.subarray(0, 4)); - expect(prefix).not.toBe('encr'); - - // Minimum size: 12 (nonce) + 16 (auth tag) = 28 bytes - expect(encrypted.length).toBeGreaterThanOrEqual(28); - }); -}); diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts index cf15b8dbfb..c955ff8184 100644 --- a/packages/world-vercel/src/encryption.ts +++ b/packages/world-vercel/src/encryption.ts @@ -69,17 +69,25 @@ export async function deriveRunKey( } /** - * Fetch the deployment key for a specific deployment from the Vercel API. + * 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 to fetch the key for + * @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 Raw 32-byte deployment key + * @returns Derived 32-byte per-run AES-256 key */ -export async function fetchDeploymentKey( +export async function fetchRunKey( deploymentId: string, + projectId: string, + runId: string, options?: { /** Auth token (from config). Falls back to OIDC or VERCEL_TOKEN. */ token?: string; @@ -91,12 +99,13 @@ export async function fetchDeploymentKey( const token = options?.token ?? oidcToken ?? process.env.VERCEL_TOKEN; if (!token) { throw new Error( - 'Cannot fetch deployment key: no OIDC token or VERCEL_TOKEN available' + '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/deployment-key/${deploymentId}`, + `https://api.vercel.com/v1/workflow/run-key/${deploymentId}?${params}`, { headers: { Authorization: `Bearer ${token}`, @@ -106,7 +115,7 @@ export async function fetchDeploymentKey( if (!response.ok) { throw new Error( - `Failed to fetch deployment key for ${deploymentId}: HTTP ${response.status}` + `Failed to fetch run key for ${runId} (deployment ${deploymentId}): HTTP ${response.status}` ); } diff --git a/packages/world-vercel/src/index.ts b/packages/world-vercel/src/index.ts index af536a927e..5945616ee3 100644 --- a/packages/world-vercel/src/index.ts +++ b/packages/world-vercel/src/index.ts @@ -1,11 +1,11 @@ import type { WorkflowRun, World } from '@workflow/world'; -import { deriveRunKey, fetchDeploymentKey } from './encryption.js'; +import { deriveRunKey, fetchRunKey } 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 { deriveRunKey, fetchDeploymentKey } from './encryption.js'; +export { deriveRunKey, fetchRunKey } from './encryption.js'; export { createQueue } from './queue.js'; export { createStorage } from './storage.js'; export { createStreamer } from './streamer.js'; @@ -31,11 +31,6 @@ export function createVercelWorld(config?: APIConfig): World { return localDeploymentKey; } - // Instance-scoped cache for remote deployment keys. - // Must NOT be module-level to prevent key material leaking across - // tenants in multi-tenant environments (e.g., Vercel dashboard). - const remoteKeyCache = new Map(); - return { ...createQueue(config), ...storage, @@ -51,24 +46,22 @@ export function createVercelWorld(config?: APIConfig): World { typeof run === 'string' ? undefined : run.deploymentId; // Same deployment (or run is just a string, i.e., from start()) - // → use local deployment key + // → 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 key from Vercel API. - // This covers cross-deployment resumeHook() (using OIDC auth) - // and o11y tooling reading data from other deployments (using VERCEL_TOKEN). - let remoteKey = remoteKeyCache.get(deploymentId); - if (!remoteKey) { - remoteKey = await fetchDeploymentKey(deploymentId, { - token: config?.token, - }); - remoteKeyCache.set(deploymentId, remoteKey); - } - return deriveRunKey(remoteKey, 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). + // No caching needed here — callers (CLI/web) cache at a higher level. + return fetchRunKey(deploymentId, projectId, runId, { + token: config?.token, + }); }, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8760613071..a25d1668ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1400,9 +1400,6 @@ importers: '@types/node': specifier: 'catalog:' version: 22.19.0 - '@workflow/core': - specifier: workspace:* - version: link:../core '@workflow/tsconfig': specifier: workspace:* version: link:../tsconfig From 68ea9b97bb5ebf78fb8c70e03c4de0d15973bac5 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Wed, 18 Feb 2026 11:17:32 -0800 Subject: [PATCH 04/12] Refactor encrypt/decrypt to accept CryptoKey, export importKey for callers to import once per run --- packages/core/src/encryption.ts | 65 ++++++++++--------- packages/core/src/private.ts | 3 +- packages/core/src/runtime.ts | 6 +- packages/core/src/runtime/resume-hook.ts | 13 ++-- packages/core/src/runtime/run.ts | 6 +- packages/core/src/runtime/runs.ts | 4 +- packages/core/src/runtime/start.ts | 4 +- packages/core/src/runtime/step-handler.ts | 5 +- .../core/src/runtime/suspension-handler.ts | 4 +- packages/core/src/serialization.ts | 18 ++--- packages/core/src/workflow.ts | 3 +- 11 files changed, 77 insertions(+), 54 deletions(-) diff --git a/packages/core/src/encryption.ts b/packages/core/src/encryption.ts index 6a0b0a4572..730e4ee115 100644 --- a/packages/core/src/encryption.ts +++ b/packages/core/src/encryption.ts @@ -6,38 +6,61 @@ * 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. This module uses that key directly for AES-GCM operations. + * 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 - Raw 32-byte AES-256 key + * @param key - CryptoKey from `importKey()` * @param data - Plaintext to encrypt * @returns `[nonce (12 bytes)][ciphertext + GCM auth tag]` */ export async function encrypt( - key: Uint8Array, + key: CryptoKey, data: Uint8Array ): Promise { - if (key.byteLength !== KEY_LENGTH) { - throw new Error( - `Encryption key must be exactly ${KEY_LENGTH} bytes, got ${key.byteLength}` - ); - } - const cryptoKey = await importKey(key); const nonce = globalThis.crypto.getRandomValues(new Uint8Array(NONCE_LENGTH)); const ciphertext = await globalThis.crypto.subtle.encrypt( { name: 'AES-GCM', iv: nonce, tagLength: TAG_LENGTH }, - cryptoKey, + key, data ); const result = new Uint8Array(NONCE_LENGTH + ciphertext.byteLength); @@ -49,42 +72,26 @@ export async function encrypt( /** * Decrypt data using AES-256-GCM. * - * @param key - Raw 32-byte AES-256 key + * @param key - CryptoKey from `importKey()` * @param data - `[nonce (12 bytes)][ciphertext + GCM auth tag]` * @returns Decrypted plaintext */ export async function decrypt( - key: Uint8Array, + key: CryptoKey, data: Uint8Array ): Promise { - if (key.byteLength !== KEY_LENGTH) { - throw new Error( - `Encryption key must be exactly ${KEY_LENGTH} bytes, got ${key.byteLength}` - ); - } 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 cryptoKey = await importKey(key); 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 }, - cryptoKey, + key, ciphertext ); return new Uint8Array(plaintext); } - -/** - * Import a raw key as a CryptoKey for AES-GCM operations. - */ -async function importKey(raw: Uint8Array) { - return globalThis.crypto.subtle.importKey('raw', raw, 'AES-GCM', false, [ - 'encrypt', - 'decrypt', - ]); -} 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..5e72b9ac28 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 { @@ -250,8 +251,11 @@ export function workflowEntrypoint( ...Attribute.WorkflowEventsCount(events.length), }); // Resolve the encryption key for this run's deployment - const encryptionKey = + const rawKey = await world.getEncryptionKeyForRun?.(runId); + const encryptionKey = rawKey + ? await importKey(rawKey) + : undefined; return await runWorkflow( workflowCode, workflowRun, diff --git a/packages/core/src/runtime/resume-hook.ts b/packages/core/src/runtime/resume-hook.ts index 8f8c26c4be..8add3d3489 100644 --- a/packages/core/src/runtime/resume-hook.ts +++ b/packages/core/src/runtime/resume-hook.ts @@ -6,6 +6,7 @@ import { SPEC_VERSION_CURRENT, type WorkflowInvokePayload, } from '@workflow/world'; +import { type CryptoKey, importKey } from '../encryption.js'; import { dehydrateStepReturnValue, hydrateStepArguments, @@ -22,10 +23,11 @@ import { getWorld } from './world.js'; */ async function getHookByTokenWithKey( token: string -): Promise<{ hook: Hook; encryptionKey: Uint8Array | undefined }> { +): Promise<{ hook: Hook; encryptionKey: CryptoKey | undefined }> { const world = getWorld(); const hook = await world.hooks.getByToken(token); - const encryptionKey = await world.getEncryptionKeyForRun?.(hook.runId); + const rawKey = await world.getEncryptionKeyForRun?.(hook.runId); + const encryptionKey = rawKey ? await importKey(rawKey) : undefined; if (typeof hook.metadata !== 'undefined') { hook.metadata = await hydrateStepArguments( hook.metadata as any, @@ -80,7 +82,7 @@ export async function getHookByToken(token: string): Promise { export async function resumeHook( tokenOrHook: string | Hook, payload: T, - encryptionKeyOverride?: Uint8Array | undefined + encryptionKeyOverride?: CryptoKey | undefined ): Promise { return await waitedUntil(() => { return trace('hook.resume', async (span) => { @@ -88,16 +90,17 @@ export async function resumeHook( try { let hook: Hook; - let encryptionKey: Uint8Array | undefined; + let encryptionKey: CryptoKey | undefined; if (typeof tokenOrHook === 'string') { const result = await getHookByTokenWithKey(tokenOrHook); hook = result.hook; encryptionKey = encryptionKeyOverride ?? result.encryptionKey; } else { hook = tokenOrHook; + const rawKey = await world.getEncryptionKeyForRun?.(hook.runId); encryptionKey = encryptionKeyOverride ?? - (await world.getEncryptionKeyForRun?.(hook.runId)); + (rawKey ? await importKey(rawKey) : undefined); } span?.setAttributes({ diff --git a/packages/core/src/runtime/run.ts b/packages/core/src/runtime/run.ts index 2ed8667c09..7e2804bd64 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?.(this.runId); + 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..4902cb0caf 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?.(runId); + 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..310e4b69af 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'; @@ -121,7 +122,8 @@ export async function start( // 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); + const rawKey = await world.getEncryptionKeyForRun?.(runId); + 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..8bb3ac9db8 100644 --- a/packages/core/src/runtime/suspension-handler.ts +++ b/packages/core/src/runtime/suspension-handler.ts @@ -13,6 +13,7 @@ import type { WaitInvocationQueueItem, WorkflowSuspension, } from '../global.js'; +import { importKey } from '../encryption.js'; import { runtimeLogger } from '../logger.js'; import { dehydrateStepArguments } from '../serialization.js'; import * as Attribute from '../telemetry/semantic-conventions.js'; @@ -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?.(runId); + 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({ From 340e7527a61c795674e2aa213b89f8dc0ba453cf Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Wed, 18 Feb 2026 15:07:02 -0800 Subject: [PATCH 05/12] Pass WorkflowRun entity to getEncryptionKeyForRun where available (avoids redundant lookup) --- packages/core/src/runtime.ts | 2 +- packages/core/src/runtime/run.ts | 2 +- packages/core/src/runtime/runs.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 5e72b9ac28..070de04a46 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -252,7 +252,7 @@ export function workflowEntrypoint( }); // Resolve the encryption key for this run's deployment const rawKey = - await world.getEncryptionKeyForRun?.(runId); + await world.getEncryptionKeyForRun?.(workflowRun); const encryptionKey = rawKey ? await importKey(rawKey) : undefined; diff --git a/packages/core/src/runtime/run.ts b/packages/core/src/runtime/run.ts index 7e2804bd64..36e292ecf7 100644 --- a/packages/core/src/runtime/run.ts +++ b/packages/core/src/runtime/run.ts @@ -154,7 +154,7 @@ export class Run { const run = await this.world.runs.get(this.runId); if (run.status === 'completed') { - const rawKey = 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, diff --git a/packages/core/src/runtime/runs.ts b/packages/core/src/runtime/runs.ts index 4902cb0caf..e9dd3909f0 100644 --- a/packages/core/src/runtime/runs.ts +++ b/packages/core/src/runtime/runs.ts @@ -50,7 +50,7 @@ export async function recreateRunFromExisting( ): Promise { try { const run = await world.runs.get(runId, { resolveData: 'all' }); - const rawKey = await world.getEncryptionKeyForRun?.(runId); + const rawKey = await world.getEncryptionKeyForRun?.(run); const encryptionKey = rawKey ? await importKey(rawKey) : undefined; const workflowArgs = normalizeWorkflowArgs( await hydrateWorkflowArguments( From 9c320381b3acf747900b6ad7166952860bd81008 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Wed, 18 Feb 2026 15:09:33 -0800 Subject: [PATCH 06/12] . --- packages/core/src/runtime/resume-hook.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/core/src/runtime/resume-hook.ts b/packages/core/src/runtime/resume-hook.ts index 8add3d3489..866b8863a9 100644 --- a/packages/core/src/runtime/resume-hook.ts +++ b/packages/core/src/runtime/resume-hook.ts @@ -82,7 +82,7 @@ export async function getHookByToken(token: string): Promise { export async function resumeHook( tokenOrHook: string | Hook, payload: T, - encryptionKeyOverride?: CryptoKey | undefined + encryptionKeyOverride?: CryptoKey ): Promise { return await waitedUntil(() => { return trace('hook.resume', async (span) => { @@ -97,10 +97,12 @@ export async function resumeHook( encryptionKey = encryptionKeyOverride ?? result.encryptionKey; } else { hook = tokenOrHook; - const rawKey = await world.getEncryptionKeyForRun?.(hook.runId); - encryptionKey = - encryptionKeyOverride ?? - (rawKey ? await importKey(rawKey) : undefined); + if (encryptionKeyOverride) { + encryptionKey = encryptionKeyOverride; + } else { + const rawKey = await world.getEncryptionKeyForRun?.(hook.runId); + encryptionKey = rawKey ? await importKey(rawKey) : undefined; + } } span?.setAttributes({ From d0cf65dde432afe35137df52b49edf499b1b5b9a Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Wed, 18 Feb 2026 15:13:53 -0800 Subject: [PATCH 07/12] Refactor handleSuspension to accept WorkflowRun, pass run entity to getEncryptionKeyForRun --- packages/core/src/runtime.ts | 7 ++----- packages/core/src/runtime/suspension-handler.ts | 16 ++++++++-------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 070de04a46..6d144eeb71 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -138,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, { @@ -308,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/suspension-handler.ts b/packages/core/src/runtime/suspension-handler.ts index 8bb3ac9db8..f232c0d91e 100644 --- a/packages/core/src/runtime/suspension-handler.ts +++ b/packages/core/src/runtime/suspension-handler.ts @@ -5,15 +5,16 @@ import { type CreateEventRequest, type SerializedData, SPEC_VERSION_CURRENT, + type WorkflowRun, type World, } from '@workflow/world'; +import { importKey } from '../encryption.js'; import type { HookInvocationQueueItem, StepInvocationQueueItem, WaitInvocationQueueItem, WorkflowSuspension, } from '../global.js'; -import { importKey } from '../encryption.js'; import { runtimeLogger } from '../logger.js'; import { dehydrateStepArguments } from '../serialization.js'; import * as Attribute from '../telemetry/semantic-conventions.js'; @@ -40,9 +41,7 @@ function extractTraceHeaders( export interface SuspensionHandlerParams { suspension: WorkflowSuspension; world: World; - runId: string; - workflowName: string; - workflowStartedAt: number; + run: WorkflowRun; span?: Span; } @@ -62,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' @@ -79,7 +79,7 @@ export async function handleSuspension({ ); // Resolve encryption key for this run - const rawKey = 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) From 34ef7cc97aebd66aa321fe14f137a800d6bf6e82 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Wed, 18 Feb 2026 16:23:32 -0800 Subject: [PATCH 08/12] Overload getEncryptionKeyForRun: accept context for start(), fetch WorkflowRun in resume-hook --- packages/core/src/runtime/resume-hook.ts | 24 ++++++++++++++--------- packages/core/src/runtime/start.ts | 6 ++++-- packages/world-vercel/src/index.ts | 11 +++++++---- packages/world/src/interfaces.ts | 25 +++++++++++++----------- 4 files changed, 40 insertions(+), 26 deletions(-) diff --git a/packages/core/src/runtime/resume-hook.ts b/packages/core/src/runtime/resume-hook.ts index 866b8863a9..18a4123d6e 100644 --- a/packages/core/src/runtime/resume-hook.ts +++ b/packages/core/src/runtime/resume-hook.ts @@ -5,6 +5,7 @@ import { isLegacySpecVersion, SPEC_VERSION_CURRENT, type WorkflowInvokePayload, + type WorkflowRun, } from '@workflow/world'; import { type CryptoKey, importKey } from '../encryption.js'; import { @@ -19,14 +20,18 @@ 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: CryptoKey | 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 rawKey = 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( @@ -35,7 +40,7 @@ async function getHookByTokenWithKey( encryptionKey ); } - return { hook, encryptionKey }; + return { hook, run, encryptionKey }; } /** @@ -90,17 +95,20 @@ export async function resumeHook( try { let hook: Hook; + 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; + workflowRun = await world.runs.get(hook.runId); if (encryptionKeyOverride) { encryptionKey = encryptionKeyOverride; } else { - const rawKey = await world.getEncryptionKeyForRun?.(hook.runId); + const rawKey = await world.getEncryptionKeyForRun?.(workflowRun); encryptionKey = rawKey ? await importKey(rawKey) : undefined; } } @@ -143,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/start.ts b/packages/core/src/runtime/start.ts index 310e4b69af..97323a889f 100644 --- a/packages/core/src/runtime/start.ts +++ b/packages/core/src/runtime/start.ts @@ -121,8 +121,10 @@ 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 rawKey = 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) diff --git a/packages/world-vercel/src/index.ts b/packages/world-vercel/src/index.ts index 5945616ee3..792d382dc3 100644 --- a/packages/world-vercel/src/index.ts +++ b/packages/world-vercel/src/index.ts @@ -37,15 +37,19 @@ export function createVercelWorld(config?: APIConfig): World { ...createStreamer(config), async getEncryptionKeyForRun( - run: WorkflowRun | string + run: WorkflowRun | string, + context?: Record ): Promise { if (!projectId) return undefined; const runId = typeof run === 'string' ? run : run.runId; const deploymentId = - typeof run === 'string' ? undefined : run.deploymentId; + typeof run === 'string' + ? (context?.deploymentId as string | undefined) + : run.deploymentId; - // Same deployment (or run is just a string, i.e., from start()) + // 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(); @@ -58,7 +62,6 @@ export function createVercelWorld(config?: APIConfig): World { // raw deployment key never leaves the API boundary. // Covers cross-deployment resumeHook() (OIDC auth) and o11y // tooling reading data from other deployments (VERCEL_TOKEN). - // No caching needed here — callers (CLI/web) cache at a higher level. return fetchRunKey(deploymentId, projectId, runId, { token: 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; } From 1e3ba2962fb8da075f71994e7301d1f8e30be61d Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Wed, 18 Feb 2026 16:36:46 -0800 Subject: [PATCH 09/12] Split changeset into per-package descriptions for world, world-vercel, and core --- .changeset/vercel-encryption-world-vercel.md | 5 +++++ .changeset/vercel-encryption-world.md | 5 +++++ .changeset/vercel-encryption.md | 3 +-- 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 .changeset/vercel-encryption-world-vercel.md create mode 100644 .changeset/vercel-encryption-world.md 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 index 928544dc93..8d66f355b2 100644 --- a/.changeset/vercel-encryption.md +++ b/.changeset/vercel-encryption.md @@ -1,6 +1,5 @@ --- "@workflow/core": patch -"@workflow/world-vercel": patch --- -Add AES-256-GCM encryption to core and HKDF key derivation to world-vercel +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()` From 0e98eef8730e312a4acee3e98e8880618c34e458 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Wed, 18 Feb 2026 16:57:40 -0800 Subject: [PATCH 10/12] Remove unnecessary Uint8Array.from() wrapper around Buffer.from() --- packages/world-vercel/src/encryption.ts | 2 +- packages/world-vercel/src/index.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts index c955ff8184..c2a8d87d76 100644 --- a/packages/world-vercel/src/encryption.ts +++ b/packages/world-vercel/src/encryption.ts @@ -120,5 +120,5 @@ export async function fetchRunKey( } const data = (await response.json()) as { key: string }; - return Uint8Array.from(Buffer.from(data.key, 'base64')); + return Buffer.from(data.key, 'base64'); } diff --git a/packages/world-vercel/src/index.ts b/packages/world-vercel/src/index.ts index 792d382dc3..8706d24893 100644 --- a/packages/world-vercel/src/index.ts +++ b/packages/world-vercel/src/index.ts @@ -25,9 +25,7 @@ export function createVercelWorld(config?: APIConfig): World { if (localDeploymentKey) return localDeploymentKey; const deploymentKeyBase64 = process.env.VERCEL_DEPLOYMENT_KEY; if (!deploymentKeyBase64) return undefined; - localDeploymentKey = Uint8Array.from( - Buffer.from(deploymentKeyBase64, 'base64') - ); + localDeploymentKey = Buffer.from(deploymentKeyBase64, 'base64'); return localDeploymentKey; } From 00f3a61d816ea19405ebe64ad5a5057b3420c0ae Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Wed, 18 Feb 2026 17:01:24 -0800 Subject: [PATCH 11/12] Use zod to parse Vercel API response --- packages/world-vercel/src/encryption.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts index c2a8d87d76..5258bf35e7 100644 --- a/packages/world-vercel/src/encryption.ts +++ b/packages/world-vercel/src/encryption.ts @@ -10,6 +10,7 @@ * for HKDF and the Vercel API for key retrieval). */ +import * as z from 'zod'; import { webcrypto } from 'node:crypto'; import { getVercelOidcToken } from '@vercel/oidc'; @@ -119,6 +120,10 @@ export async function fetchRunKey( ); } - const data = (await response.json()) as { key: string }; - return Buffer.from(data.key, 'base64'); + 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'); } From c28baeeb311f23a17f13414c81955bd482d8f835 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Fri, 20 Feb 2026 14:23:08 -0800 Subject: [PATCH 12/12] Address peter's comment --- packages/world-vercel/src/encryption.ts | 60 ++++++++++++++++++++++++- packages/world-vercel/src/index.ts | 59 +++++------------------- 2 files changed, 71 insertions(+), 48 deletions(-) diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts index 5258bf35e7..869ed9ac26 100644 --- a/packages/world-vercel/src/encryption.ts +++ b/packages/world-vercel/src/encryption.ts @@ -10,9 +10,10 @@ * for HKDF and the Vercel API for key retrieval). */ -import * as z from 'zod'; 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) @@ -127,3 +128,60 @@ export async function fetchRunKey( } 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 8706d24893..5221e3fbc3 100644 --- a/packages/world-vercel/src/index.ts +++ b/packages/world-vercel/src/index.ts @@ -1,68 +1,33 @@ -import type { WorkflowRun, World } from '@workflow/world'; -import { deriveRunKey, fetchRunKey } from './encryption.js'; +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 { deriveRunKey, fetchRunKey } from './encryption.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 { - const storage = createStorage(config); // 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; - 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 { ...createQueue(config), - ...storage, + ...createStorage(config), ...createStreamer(config), - - async getEncryptionKeyForRun( - run: WorkflowRun | string, - context?: Record - ): Promise { - if (!projectId) return undefined; - - 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: config?.token, - }); - }, + getEncryptionKeyForRun: createGetEncryptionKeyForRun( + projectId, + config?.token + ), }; }