diff --git a/.changeset/vercel-encryption.md b/.changeset/vercel-encryption.md new file mode 100644 index 000000000..e15cc154b --- /dev/null +++ b/.changeset/vercel-encryption.md @@ -0,0 +1,7 @@ +--- +"@workflow/world-vercel": patch +--- + +Add Vercel encryption implementation (AES-256-GCM with HKDF) + +Adds `createEncryptor()` and `createEncryptorFromEnv()` to `@workflow/world-vercel`, implementing AES-256-GCM encryption with HKDF-SHA256 per-run key derivation. Wired into `createVercelWorld()` via environment variables (`VERCEL_DEPLOYMENT_KEY`, `VERCEL_PROJECT_ID`). diff --git a/packages/world-vercel/src/encryption.test.ts b/packages/world-vercel/src/encryption.test.ts new file mode 100644 index 000000000..36bf8c623 --- /dev/null +++ b/packages/world-vercel/src/encryption.test.ts @@ -0,0 +1,351 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createEncryptor, createEncryptorFromEnv } from './encryption.js'; + +describe('createEncryptor', () => { + 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, + ]); + + const encryptor = createEncryptor({ + deploymentKey: testDeploymentKey, + projectId: testProjectId, + }); + + describe('encrypt/decrypt round-trip', () => { + it('should encrypt and decrypt data correctly', async () => { + const plaintext = new TextEncoder().encode('Hello, World!'); + const context = { runId: testRunId }; + + const encrypted = await encryptor.encrypt(plaintext, context); + const decrypted = await encryptor.decrypt(encrypted, context); + + expect(decrypted).toEqual(plaintext); + expect(new TextDecoder().decode(decrypted)).toBe('Hello, World!'); + }); + + it('should encrypt and decrypt empty data', async () => { + const plaintext = new Uint8Array(0); + const context = { runId: testRunId }; + + const encrypted = await encryptor.encrypt(plaintext, context); + const decrypted = await encryptor.decrypt(encrypted, context); + + expect(decrypted).toEqual(plaintext); + }); + + it('should encrypt and decrypt large data', async () => { + // 64KB of random data (max for getRandomValues) + const plaintext = new Uint8Array(65536); + crypto.getRandomValues(plaintext); + const context = { runId: testRunId }; + + const encrypted = await encryptor.encrypt(plaintext, context); + const decrypted = await encryptor.decrypt(encrypted, context); + + expect(decrypted).toEqual(plaintext); + }); + + it('should encrypt and decrypt binary data with all byte values', async () => { + // All possible byte values 0-255 + const plaintext = new Uint8Array(256); + for (let i = 0; i < 256; i++) { + plaintext[i] = i; + } + const context = { runId: testRunId }; + + const encrypted = await encryptor.encrypt(plaintext, context); + const decrypted = await encryptor.decrypt(encrypted, context); + + expect(decrypted).toEqual(plaintext); + }); + }); + + describe('encrypted data format', () => { + it('should produce encrypted data with "encr" prefix', async () => { + const plaintext = new TextEncoder().encode('test'); + const context = { runId: testRunId }; + + const encrypted = await encryptor.encrypt(plaintext, context); + + // Check prefix is "encr" + const prefix = new TextDecoder().decode(encrypted.subarray(0, 4)); + expect(prefix).toBe('encr'); + }); + + it('should produce encrypted data with correct structure', async () => { + const plaintext = new TextEncoder().encode('test'); + const context = { runId: testRunId }; + + const encrypted = await encryptor.encrypt(plaintext, context); + + // Format: [encr (4 bytes)][nonce (12 bytes)][ciphertext + auth tag] + // Minimum size: 4 + 12 + 16 (auth tag) = 32 bytes + expect(encrypted.length).toBeGreaterThanOrEqual(32); + + // Ciphertext should be at least as long as plaintext + auth tag (16 bytes) + const ciphertextLength = encrypted.length - 4 - 12; + expect(ciphertextLength).toBeGreaterThanOrEqual(plaintext.length + 16); + }); + + it('should produce different ciphertext for same data (random nonce)', async () => { + const plaintext = new TextEncoder().encode('test'); + const context = { runId: testRunId }; + + const encrypted1 = await encryptor.encrypt(plaintext, context); + const encrypted2 = await encryptor.encrypt(plaintext, context); + + // Encrypted data should be different due to random nonce + expect(encrypted1).not.toEqual(encrypted2); + + // But both should decrypt to the same plaintext + const decrypted1 = await encryptor.decrypt(encrypted1, context); + const decrypted2 = await encryptor.decrypt(encrypted2, context); + expect(decrypted1).toEqual(plaintext); + expect(decrypted2).toEqual(plaintext); + }); + }); + + describe('per-run key isolation', () => { + it('should produce different ciphertext for different runIds', async () => { + const plaintext = new TextEncoder().encode('sensitive data'); + + const encrypted1 = await encryptor.encrypt(plaintext, { + runId: 'wrun_run1', + }); + const encrypted2 = await encryptor.encrypt(plaintext, { + runId: 'wrun_run2', + }); + + // Even ignoring the random nonce, the key derivation differs + // So the auth tags will be different + expect(encrypted1).not.toEqual(encrypted2); + }); + + it('should fail to decrypt with wrong runId', async () => { + const plaintext = new TextEncoder().encode('sensitive data'); + const encrypted = await encryptor.encrypt(plaintext, { + runId: 'wrun_run1', + }); + + // Try to decrypt with a different runId - should fail auth check + await expect( + encryptor.decrypt(encrypted, { runId: 'wrun_run2' }) + ).rejects.toThrow(); + }); + }); + + describe('decrypt validation', () => { + it('should throw on data that is too short', async () => { + const shortData = new Uint8Array(10); + + await expect( + encryptor.decrypt(shortData, { runId: testRunId }) + ).rejects.toThrow('Encrypted data too short'); + }); + + it('should throw on invalid prefix', async () => { + // Must be at least 32 bytes to pass minimum length check + const invalidData = new Uint8Array(40); + invalidData.set(new TextEncoder().encode('baad'), 0); + + await expect( + encryptor.decrypt(invalidData, { runId: testRunId }) + ).rejects.toThrow("expected 'encr' prefix"); + }); + + it('should throw on tampered ciphertext', async () => { + const plaintext = new TextEncoder().encode('test'); + const encrypted = await encryptor.encrypt(plaintext, { + runId: testRunId, + }); + + // Tamper with the ciphertext (flip a bit in the middle) + const tampered = new Uint8Array(encrypted); + tampered[20] ^= 0xff; + + // AES-GCM should detect tampering via auth tag + await expect( + encryptor.decrypt(tampered, { runId: testRunId }) + ).rejects.toThrow(); + }); + + it('should throw on truncated data', async () => { + const plaintext = new TextEncoder().encode('test'); + const encrypted = await encryptor.encrypt(plaintext, { + runId: testRunId, + }); + + // Truncate the data + const truncated = encrypted.subarray(0, 20); + + await expect( + encryptor.decrypt(truncated, { runId: testRunId }) + ).rejects.toThrow(); + }); + }); + + describe('getKeyMaterial', () => { + it('should return key material with correct structure', async () => { + const keyMaterial = await encryptor.getKeyMaterial({}); + + expect(keyMaterial).not.toBeNull(); + expect(keyMaterial!.key).toEqual(testDeploymentKey); + expect(keyMaterial!.derivationContext).toEqual({ + projectId: testProjectId, + }); + expect(keyMaterial!.algorithm).toBe('AES-256-GCM'); + expect(keyMaterial!.kdf).toBe('HKDF-SHA256'); + }); + }); + + describe('different project isolation', () => { + it('should fail to decrypt with different projectId', async () => { + const encryptor2 = createEncryptor({ + deploymentKey: testDeploymentKey, + projectId: 'prj_different', + }); + + const plaintext = new TextEncoder().encode('test'); + const encrypted = await encryptor.encrypt(plaintext, { + runId: testRunId, + }); + + // Different projectId means different derived key + await expect( + encryptor2.decrypt(encrypted, { runId: testRunId }) + ).rejects.toThrow(); + }); + + it('should fail to decrypt with different deploymentKey', async () => { + const differentKey = new Uint8Array(32); + crypto.getRandomValues(differentKey); + + const encryptor2 = createEncryptor({ + deploymentKey: differentKey, + projectId: testProjectId, + }); + + const plaintext = new TextEncoder().encode('test'); + const encrypted = await encryptor.encrypt(plaintext, { + runId: testRunId, + }); + + // Different key means decryption fails + await expect( + encryptor2.decrypt(encrypted, { runId: testRunId }) + ).rejects.toThrow(); + }); + }); +}); + +describe('createEncryptor validation', () => { + it('should throw for key that is too short', () => { + expect(() => + createEncryptor({ + deploymentKey: new Uint8Array(16), + projectId: 'prj_test', + }) + ).toThrow('expected 32 bytes for AES-256, got 16 bytes'); + }); + + it('should throw for key that is too long', () => { + expect(() => + createEncryptor({ + deploymentKey: new Uint8Array(33), + projectId: 'prj_test', + }) + ).toThrow('expected 32 bytes for AES-256, got 33 bytes'); + }); + + it('should throw for empty key', () => { + expect(() => + createEncryptor({ + deploymentKey: new Uint8Array(0), + projectId: 'prj_test', + }) + ).toThrow('expected 32 bytes for AES-256, got 0 bytes'); + }); + + it('should throw for empty projectId', () => { + expect(() => + createEncryptor({ + deploymentKey: new Uint8Array(32), + projectId: '', + }) + ).toThrow('projectId must be a non-empty string'); + }); +}); + +describe('createEncryptorFromEnv', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should return empty object when VERCEL_DEPLOYMENT_KEY is not set', () => { + delete process.env.VERCEL_DEPLOYMENT_KEY; + delete process.env.VERCEL_PROJECT_ID; + + const encryptor = createEncryptorFromEnv(); + + expect(encryptor.encrypt).toBeUndefined(); + expect(encryptor.decrypt).toBeUndefined(); + expect(encryptor.getKeyMaterial).toBeUndefined(); + }); + + it('should return empty object when VERCEL_PROJECT_ID is not set', () => { + // 32 bytes base64 encoded + process.env.VERCEL_DEPLOYMENT_KEY = + 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8='; + delete process.env.VERCEL_PROJECT_ID; + + const encryptor = createEncryptorFromEnv(); + + expect(encryptor.encrypt).toBeUndefined(); + expect(encryptor.decrypt).toBeUndefined(); + }); + + it('should throw when VERCEL_DEPLOYMENT_KEY decodes to wrong length', () => { + // 16 bytes base64 encoded (too short) + process.env.VERCEL_DEPLOYMENT_KEY = 'AAECAwQFBgcICQoLDA0ODw=='; + process.env.VERCEL_PROJECT_ID = 'prj_test'; + + expect(() => createEncryptorFromEnv()).toThrow( + 'expected 32 bytes for AES-256' + ); + }); + + it('should return working encryptor when both env vars are set', async () => { + // 32 bytes base64 encoded + process.env.VERCEL_DEPLOYMENT_KEY = + 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8='; + process.env.VERCEL_PROJECT_ID = 'prj_test'; + + const encryptor = createEncryptorFromEnv(); + + expect(encryptor.encrypt).toBeDefined(); + expect(encryptor.decrypt).toBeDefined(); + expect(encryptor.getKeyMaterial).toBeDefined(); + + // Test round-trip + const plaintext = new TextEncoder().encode('test'); + const encrypted = await encryptor.encrypt!(plaintext, { + runId: 'wrun_123', + }); + const decrypted = await encryptor.decrypt!(encrypted, { + runId: 'wrun_123', + }); + expect(decrypted).toEqual(plaintext); + }); +}); diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts new file mode 100644 index 000000000..b8511d61f --- /dev/null +++ b/packages/world-vercel/src/encryption.ts @@ -0,0 +1,222 @@ +import { webcrypto } from 'node:crypto'; +import type { + EncryptionContext, + Encryptor, + KeyMaterial, +} from '@workflow/world'; + +type CryptoKey = webcrypto.CryptoKey; + +const ALGORITHM = 'AES-GCM'; +const KEY_LENGTH = 256; +const KEY_BYTES = 32; // 256 bits = 32 bytes +const NONCE_LENGTH = 12; // 96 bits for GCM +const TAG_LENGTH = 128; // 128-bit auth tag +const FORMAT_PREFIX = 'encr'; +const FORMAT_PREFIX_LENGTH = 4; + +export interface VercelEncryptionConfig { + /** Raw key bytes (must be exactly 32 bytes for AES-256) */ + deploymentKey: Uint8Array; + /** Project ID for key derivation context */ + projectId: string; +} + +/** + * Derive a per-run encryption key using HKDF. + * Results are cached per (keyMaterial, projectId, runId) for performance during replay. + * + * @param keyMaterial - Base key material + * @param projectId - Project ID for context + * @param runId - Run ID for per-run isolation + * @param keyCache - Per-encryptor cache for derived keys + * @returns Derived AES-256 key + */ +async function deriveKey( + keyMaterial: Uint8Array, + projectId: string, + runId: string, + keyCache: Map +): Promise { + const cacheKey = `${projectId}|${runId}`; + const cached = keyCache.get(cacheKey); + if (cached) return cached; + + const baseKey = await webcrypto.subtle.importKey( + 'raw', + keyMaterial, + 'HKDF', + false, + ['deriveKey'] + ); + + const info = new TextEncoder().encode(cacheKey); + + // 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 derived = await webcrypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(32), + info, + }, + baseKey, + { name: ALGORITHM, length: KEY_LENGTH }, + false, + ['encrypt', 'decrypt'] + ); + + keyCache.set(cacheKey, derived); + return derived; +} + +/** + * Create an Encryptor implementation for Vercel deployments. + * + * @param config - Encryption configuration + * @returns Encryptor implementation with encrypt, decrypt, and getKeyMaterial methods + */ +export function createEncryptor( + config: VercelEncryptionConfig +): Required { + const { deploymentKey, projectId } = config; + + // Validate key length - must be exactly 32 bytes for AES-256 + 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'); + } + + // Per-encryptor cache scoped to this deployment key + const derivedKeyCache = new Map(); + + return { + async encrypt( + data: Uint8Array, + context: EncryptionContext + ): Promise { + const key = await deriveKey( + deploymentKey, + projectId, + context.runId, + derivedKeyCache + ); + const nonce = webcrypto.getRandomValues(new Uint8Array(NONCE_LENGTH)); + + const ciphertext = await webcrypto.subtle.encrypt( + { name: ALGORITHM, iv: nonce, tagLength: TAG_LENGTH }, + key, + data + ); + + // Format: [encr (4 bytes)][nonce (12 bytes)][ciphertext + auth tag] + const result = new Uint8Array( + FORMAT_PREFIX_LENGTH + NONCE_LENGTH + ciphertext.byteLength + ); + result.set(new TextEncoder().encode(FORMAT_PREFIX), 0); + result.set(nonce, FORMAT_PREFIX_LENGTH); + result.set( + new Uint8Array(ciphertext), + FORMAT_PREFIX_LENGTH + NONCE_LENGTH + ); + + return result; + }, + + async decrypt( + data: Uint8Array, + context: EncryptionContext + ): Promise { + // Minimum: prefix (4) + nonce (12) + auth tag (16) = 32 bytes + const minLength = FORMAT_PREFIX_LENGTH + NONCE_LENGTH + 16; + if (data.length < minLength) { + throw new Error( + `Encrypted data too short: expected at least ${minLength} bytes, got ${data.length} bytes` + ); + } + + // Verify format prefix (format validation only, not security-critical — + // AES-GCM's auth tag provides cryptographic integrity) + const prefix = new TextDecoder().decode( + data.subarray(0, FORMAT_PREFIX_LENGTH) + ); + if (prefix !== FORMAT_PREFIX) { + throw new Error( + `Invalid encrypted data format: expected '${FORMAT_PREFIX}' prefix` + ); + } + + const key = await deriveKey( + deploymentKey, + projectId, + context.runId, + derivedKeyCache + ); + const nonce = data.subarray( + FORMAT_PREFIX_LENGTH, + FORMAT_PREFIX_LENGTH + NONCE_LENGTH + ); + const ciphertext = data.subarray(FORMAT_PREFIX_LENGTH + NONCE_LENGTH); + + const plaintext = await webcrypto.subtle.decrypt( + { name: ALGORITHM, iv: nonce, tagLength: TAG_LENGTH }, + key, + ciphertext + ); + + return new Uint8Array(plaintext); + }, + + /** + * Returns the master deployment key material for external decryption + * (e.g., observability tooling). Callers must derive per-run keys using + * HKDF with the returned derivationContext and the target run's runId. + * + * WARNING: This returns sensitive master key material. Access should be + * gated at the infrastructure layer (e.g., authenticated API endpoints + * with audit logging). + */ + async getKeyMaterial( + _options: Record + ): Promise { + return { + key: deploymentKey, + derivationContext: { projectId }, + algorithm: 'AES-256-GCM', + kdf: 'HKDF-SHA256', + }; + }, + }; +} + +/** + * Create an Encryptor from environment variables. + * + * Requires: + * - VERCEL_DEPLOYMENT_KEY: Base64-encoded key material (exactly 32 bytes / 256 bits) + * - VERCEL_PROJECT_ID: Project ID for key derivation context + * + * @returns Encryptor if environment variables are set, empty object otherwise + */ +export function createEncryptorFromEnv(): Partial { + const deploymentKeyBase64 = process.env.VERCEL_DEPLOYMENT_KEY; + const projectId = process.env.VERCEL_PROJECT_ID; + + if (!deploymentKeyBase64 || !projectId) { + // Encryption not available - return empty object + return {}; + } + + const deploymentKey = Uint8Array.from( + Buffer.from(deploymentKeyBase64, 'base64') + ); + + return createEncryptor({ deploymentKey, projectId }); +} diff --git a/packages/world-vercel/src/index.ts b/packages/world-vercel/src/index.ts index 37865424b..605438c8c 100644 --- a/packages/world-vercel/src/index.ts +++ b/packages/world-vercel/src/index.ts @@ -1,9 +1,11 @@ import type { World } from '@workflow/world'; +import { createEncryptorFromEnv } 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 { createEncryptor, createEncryptorFromEnv } from './encryption.js'; export { createQueue } from './queue.js'; export { createStorage } from './storage.js'; export { createStreamer } from './streamer.js'; @@ -14,5 +16,6 @@ export function createVercelWorld(config?: APIConfig): World { ...createQueue(config), ...createStorage(config), ...createStreamer(config), + ...createEncryptorFromEnv(), }; }