-
Notifications
You must be signed in to change notification settings - Fork 190
Add AES-256-GCM encryption to core and HKDF key derivation to world-vercel #956
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
TooTallNate
wants to merge
3
commits into
nate/fix-project-config
Choose a base branch
from
nate/vercel-encryption
base: nate/fix-project-config
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+359
−2
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', | ||
| ]); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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')); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }, | ||
| }; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.