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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/vercel-encryption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@workflow/core": patch
"@workflow/world-vercel": patch
---

Add AES-256-GCM encryption to core and HKDF key derivation to world-vercel
4 changes: 4 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@
"types": "./dist/serialization-format.d.ts",
"default": "./dist/serialization-format.js"
},
"./encryption": {
"types": "./dist/encryption.d.ts",
"default": "./dist/encryption.js"
},
"./_workflow": "./dist/workflow/index.js"
},
"scripts": {
Expand Down
90 changes: 90 additions & 0 deletions packages/core/src/encryption.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array> {
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<Uint8Array> {
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',
]);
}
86 changes: 86 additions & 0 deletions packages/world-vercel/src/encryption.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
115 changes: 115 additions & 0 deletions packages/world-vercel/src/encryption.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array> {
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<Uint8Array> {
// 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'));
}
60 changes: 58 additions & 2 deletions packages/world-vercel/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, Uint8Array>();

return {
...createQueue(config),
...createStorage(config),
...storage,
...createStreamer(config),

async getEncryptionKeyForRun(
run: WorkflowRun | string
): Promise<Uint8Array | undefined> {
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);
},
};
}
Loading