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
7 changes: 7 additions & 0 deletions .changeset/vercel-encryption.md
Original file line number Diff line number Diff line change
@@ -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`).
293 changes: 293 additions & 0 deletions packages/world-vercel/src/encryption.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
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 invalid prefix', async () => {
const invalidData = new TextEncoder().encode('invalidprefix');

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();
});
});
});
Comment on lines +4 to +234
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are no tests validating that createEncryptor() throws an error for invalid key lengths (e.g., 16 bytes, 24 bytes, 33 bytes, or 0 bytes). This is a critical validation path that should be tested to ensure the key length enforcement works as expected.

Consider adding test cases like:

  • Too short key (e.g., 16 bytes)
  • Too long key (e.g., 33 bytes)
  • Empty key (0 bytes)

Copilot uses AI. Check for mistakes.

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 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);
});
});
Comment on lines +236 to +293
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are no tests validating that createEncryptorFromEnv() throws an error when VERCEL_DEPLOYMENT_KEY contains a base64-encoded key that decodes to the wrong length (e.g., not 32 bytes). Users could provide an incorrectly sized key, and the error would only surface when createEncryptor() is called.

Consider adding test cases that verify:

  • Invalid key length from environment variable (e.g., base64 that decodes to 16 or 64 bytes)
  • Invalid base64 encoding in VERCEL_DEPLOYMENT_KEY

Copilot uses AI. Check for mistakes.
Loading
Loading