Skip to content

Add Vercel encryption implementation (AES-256-GCM with HKDF)#956

Open
TooTallNate wants to merge 1 commit intonate/async-serializationfrom
nate/vercel-encryption
Open

Add Vercel encryption implementation (AES-256-GCM with HKDF)#956
TooTallNate wants to merge 1 commit intonate/async-serializationfrom
nate/vercel-encryption

Conversation

@TooTallNate
Copy link
Member

Summary

  • Adds createEncryptor() and createEncryptorFromEnv() to @workflow/world-vercel
  • Implements AES-256-GCM encryption with HKDF-SHA256 per-run key derivation
  • Format: [encr (4 bytes)][nonce (12 bytes)][ciphertext + auth tag]
  • Key derivation: HKDF(key=deploymentKey, salt=zeros, info="${projectId}|${runId}")
  • Validates deployment key is exactly 32 bytes
  • Wires createEncryptorFromEnv() into createVercelWorld() (reads VERCEL_DEPLOYMENT_KEY and VERCEL_PROJECT_ID env vars)
  • Includes 18 comprehensive tests: round-trip, format validation, per-run isolation, project isolation, tamper detection, env-based creation

This PR adds the encryption module but does not wire it into the serialization layer yet — that happens in the next PR.

Stack

Test plan

All 42 world-vercel tests pass (18 new encryption tests + 24 existing).

@vercel
Copy link
Contributor

vercel bot commented Feb 6, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Error Error Feb 7, 2026 10:37am
example-nextjs-workflow-webpack Error Error Feb 7, 2026 10:37am
example-workflow Ready Ready Preview, Comment Feb 7, 2026 10:37am
workbench-astro-workflow Ready Ready Preview, Comment Feb 7, 2026 10:37am
workbench-express-workflow Ready Ready Preview, Comment Feb 7, 2026 10:37am
workbench-fastify-workflow Ready Ready Preview, Comment Feb 7, 2026 10:37am
workbench-hono-workflow Ready Ready Preview, Comment Feb 7, 2026 10:37am
workbench-nitro-workflow Ready Ready Preview, Comment Feb 7, 2026 10:37am
workbench-nuxt-workflow Ready Ready Preview, Comment Feb 7, 2026 10:37am
workbench-sveltekit-workflow Ready Ready Preview, Comment Feb 7, 2026 10:37am
workbench-vite-workflow Ready Ready Preview, Comment Feb 7, 2026 10:37am
workflow-docs Ready Ready Preview, Comment, Open in v0 Feb 7, 2026 10:37am
workflow-nest Ready Ready Preview, Comment Feb 7, 2026 10:37am
workflow-swc-playground Ready Ready Preview, Comment Feb 7, 2026 10:37am

Copilot AI review requested due to automatic review settings February 6, 2026 02:30
@changeset-bot
Copy link

changeset-bot bot commented Feb 6, 2026

⚠️ No Changeset found

Latest commit: 2fcf24e

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 387 0 36 423
✅ 💻 Local Development 408 0 62 470
✅ 📦 Local Production 320 0 56 376
✅ 🐘 Local Postgres 320 0 56 376
✅ 🪟 Windows 44 0 3 47
❌ 🌍 Community Worlds 31 157 12 200
✅ 📋 Other 120 0 21 141
Total 1630 157 246 2033

❌ Failed Tests

🌍 Community Worlds (157 failed)

mongodb (39 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

redis (39 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

starter (40 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (CLI) - workflow health command reports healthy endpoints
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

turso (39 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 43 0 4
✅ example 43 0 4
✅ express 43 0 4
✅ fastify 43 0 4
✅ hono 43 0 4
✅ nitro 43 0 4
✅ nuxt 43 0 4
✅ sveltekit 43 0 4
✅ vite 43 0 4
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 40 0 7
✅ express-stable 40 0 7
✅ fastify-stable 40 0 7
✅ hono-stable 40 0 7
✅ nextjs-turbopack-stable 44 0 3
✅ nextjs-webpack-stable 44 0 3
✅ nitro-stable 40 0 7
✅ nuxt-stable 40 0 7
✅ sveltekit-stable 40 0 7
✅ vite-stable 40 0 7
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 40 0 7
✅ express-stable 40 0 7
✅ fastify-stable 40 0 7
✅ hono-stable 40 0 7
✅ nitro-stable 40 0 7
✅ nuxt-stable 40 0 7
✅ sveltekit-stable 40 0 7
✅ vite-stable 40 0 7
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 40 0 7
✅ express-stable 40 0 7
✅ fastify-stable 40 0 7
✅ hono-stable 40 0 7
✅ nitro-stable 40 0 7
✅ nuxt-stable 40 0 7
✅ sveltekit-stable 40 0 7
✅ vite-stable 40 0 7
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 44 0 3
❌ 🌍 Community Worlds
App Passed Failed Skipped
✅ mongodb-dev 3 0 0
❌ mongodb 5 39 3
✅ redis-dev 3 0 0
❌ redis 5 39 3
✅ starter-dev 3 0 0
❌ starter 4 40 3
✅ turso-dev 3 0 0
❌ turso 5 39 3
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 40 0 7
✅ e2e-local-postgres-nest-stable 40 0 7
✅ e2e-local-prod-nest-stable 40 0 7

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: failure
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds AES-256-GCM encryption with HKDF-SHA256 key derivation to the @workflow/world-vercel package. The implementation provides per-run encryption isolation by deriving unique keys from a deployment key, project ID, and run ID. It integrates seamlessly with the async serialization infrastructure added in PR #955 and uses the client-generated run IDs from PR #954.

Changes:

  • Implements createEncryptor() and createEncryptorFromEnv() functions with full Encryptor interface support
  • Adds AES-256-GCM encryption with random nonces and 128-bit authentication tags
  • Uses HKDF-SHA256 for per-run key derivation with projectId and runId as context
  • Wires encryption into createVercelWorld() via environment variables (VERCEL_DEPLOYMENT_KEY, VERCEL_PROJECT_ID)
  • Includes 18 comprehensive tests covering round-trip, format validation, isolation, and tamper detection

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 12 comments.

File Description
packages/world-vercel/src/encryption.ts Core encryption implementation with key derivation, encrypt/decrypt, and key material access
packages/world-vercel/src/encryption.test.ts Comprehensive test suite with 18 tests covering functionality and security properties
packages/world-vercel/src/index.ts Integration into World creation and public API exports
Comments suppressed due to low confidence (1)

packages/world-vercel/src/index.ts:12

  • The VercelEncryptionConfig interface is exported from encryption.ts but not re-exported from index.ts. Users who want to use createEncryptor() directly would need to import from the internal encryption module, which is not a typical pattern.

Consider adding to index.ts:

export type { VercelEncryptionConfig } from './encryption.js';

This follows the pattern already established with APIConfig and makes the public API more discoverable.

export { createEncryptor, createEncryptorFromEnv } from './encryption.js';
export { createQueue } from './queue.js';
export { createStorage } from './storage.js';
export { createStreamer } from './streamer.js';
export type { APIConfig } from './utils.js';

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

* Create an Encryptor from environment variables.
*
* Requires:
* - VERCEL_DEPLOYMENT_KEY: Base64-encoded key material (32+ bytes recommended)
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.

The documentation comment says "32+ bytes recommended" but the actual implementation requires exactly 32 bytes. This discrepancy could confuse users who provide keys longer than 32 bytes, which would fail at runtime.

The documentation should be updated to state "exactly 32 bytes" to match the validation in createEncryptor(), or the implementation should be changed to accept keys >= 32 bytes and truncate/hash them to 32 bytes.

Suggested change
* - VERCEL_DEPLOYMENT_KEY: Base64-encoded key material (32+ bytes recommended)
* - VERCEL_DEPLOYMENT_KEY: Base64-encoded key material (exactly 32 bytes / 256 bits)

Copilot uses AI. Check for mistakes.
Comment on lines +4 to +234
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();
});
});
});
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.
{
name: 'HKDF',
hash: 'SHA-256',
salt: new Uint8Array(32), // Zero salt - key material is already random
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.

The HKDF implementation uses a zero salt (32 bytes of zeros). While this is acceptable when the input key material has sufficient entropy (which should be the case for a 32-byte deployment key), best practice would be to use a random or constant non-zero salt.

The comment "Zero salt - key material is already random" is correct from a security perspective, but consider:

  1. Using a constant non-zero salt (e.g., "vercel-workflow-v1") for better defense-in-depth
  2. Documenting why zero salt is acceptable in this specific case
  3. Following HKDF RFC 5869 recommendations more closely

This is marked as nit because the current implementation is cryptographically sound given the high-entropy IKM and unique info parameter.

Copilot uses AI. Check for mistakes.
);
if (prefix !== FORMAT_PREFIX) {
throw new Error(
`Expected '${FORMAT_PREFIX}' prefix for encrypted data, got '${prefix}'`
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.

The error message reveals the exact prefix value from the encrypted data, which could potentially leak information about the data format or partial content. While "encr" itself is not sensitive, revealing the actual bytes received could help an attacker understand the data structure.

Consider changing the error message to not include the actual prefix value:

`Invalid encrypted data format: expected '${FORMAT_PREFIX}' prefix`

This follows the principle of minimal information disclosure in error messages.

Suggested change
`Expected '${FORMAT_PREFIX}' prefix for encrypted data, got '${prefix}'`
`Invalid encrypted data format: expected '${FORMAT_PREFIX}' prefix`

Copilot uses AI. Check for mistakes.
Comment on lines +171 to +172
const deploymentKey = Uint8Array.from(
Buffer.from(deploymentKeyBase64, 'base64')
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.

If VERCEL_DEPLOYMENT_KEY contains invalid base64, Buffer.from() will silently decode what it can, potentially producing a key of incorrect length. This could lead to a confusing error message from createEncryptor() about key length rather than the root cause being invalid base64.

Consider wrapping the base64 decoding in a try-catch to provide a more helpful error message:

try {
  const deploymentKey = Uint8Array.from(Buffer.from(deploymentKeyBase64, 'base64'));
} catch (e) {
  throw new Error('VERCEL_DEPLOYMENT_KEY contains invalid base64 encoding');
}

Alternatively, validate that the base64 string only contains valid base64 characters before decoding.

Suggested change
const deploymentKey = Uint8Array.from(
Buffer.from(deploymentKeyBase64, 'base64')
const trimmedDeploymentKeyBase64 = deploymentKeyBase64.trim();
// Basic base64 validation to provide a clear error on misconfiguration
const base64Regex = /^[A-Za-z0-9+/]+={0,2}$/;
const isLengthValid = trimmedDeploymentKeyBase64.length % 4 === 0;
if (!isLengthValid || !base64Regex.test(trimmedDeploymentKeyBase64)) {
throw new Error(
'VERCEL_DEPLOYMENT_KEY contains invalid base64 encoding'
);
}
const deploymentKey = Uint8Array.from(
Buffer.from(trimmedDeploymentKeyBase64, 'base64')

Copilot uses AI. Check for mistakes.
Comment on lines +236 to +293
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);
});
});
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.
Comment on lines +33 to +46
async function deriveKey(
keyMaterial: Uint8Array,
projectId: string,
runId: string
): Promise<CryptoKey> {
const baseKey = await webcrypto.subtle.importKey(
'raw',
keyMaterial,
'HKDF',
false,
['deriveKey']
);

const info = new TextEncoder().encode(`${projectId}|${runId}`);
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.

The deriveKey function doesn't validate that projectId and runId are non-empty strings. If either is empty, the HKDF info parameter would be just a pipe character ("|") or similar, which could lead to unexpected key derivation behavior. While the Encryptor interface may guarantee these are valid, defensive validation would make the code more robust.

Consider adding validation:

if (!projectId || !runId) {
  throw new Error('projectId and runId must be non-empty strings');
}

This is especially important since these values come from external input (environment variables and runtime context).

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +120
if (prefix !== FORMAT_PREFIX) {
throw new Error(
`Expected '${FORMAT_PREFIX}' prefix for encrypted data, got '${prefix}'`
);
}
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.

The prefix comparison using string equality could potentially be vulnerable to timing attacks, though this is a minor concern since the prefix is not secret. However, for defense-in-depth, consider using a constant-time comparison or simply accepting that the prefix check is not security-critical (since the real authentication happens via the GCM tag).

This is marked as a nit because:

  1. The prefix "encr" is public information
  2. AES-GCM's authentication tag provides cryptographic integrity
  3. Timing attacks on the prefix are impractical

However, it's worth documenting that the prefix check is for format validation only, not security.

Copilot uses AI. Check for mistakes.
Comment on lines +138 to +149
async getKeyMaterial(
_options: Record<string, unknown>
): Promise<KeyMaterial | null> {
// Return the key material for external decryption (e.g., o11y tooling)
// In production, this might fetch from a secure endpoint based on deploymentId
return {
key: deploymentKey,
derivationContext: { projectId },
algorithm: 'AES-256-GCM',
kdf: 'HKDF-SHA256',
};
},
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.

The getKeyMaterial() function returns the raw deployment key unconditionally. While this is intended for observability tooling as noted in the comment, exposing the key material without any access control could be a security risk if this function is ever called through an API or exposed to untrusted code.

The comment mentions "In production, this might fetch from a secure endpoint based on deploymentId" which suggests this is a known limitation. However, consider:

  1. Adding a TODO comment to implement proper access control
  2. Documenting in the function's JSDoc that this method returns sensitive key material
  3. Considering whether to return null by default unless explicitly configured to allow key material access

This is particularly important since getKeyMaterial is part of the public Encryptor interface and could be called by any code with access to the World instance.

Copilot uses AI. Check for mistakes.
Comment on lines +112 to +120
// Verify format prefix
const prefix = new TextDecoder().decode(
data.subarray(0, FORMAT_PREFIX_LENGTH)
);
if (prefix !== FORMAT_PREFIX) {
throw new Error(
`Expected '${FORMAT_PREFIX}' prefix for encrypted data, got '${prefix}'`
);
}
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.

The decrypt() function doesn't validate the minimum length of encrypted data before attempting to extract components. While the WebCrypto API will eventually throw an error for invalid data, checking the length upfront would provide clearer error messages and prevent potential edge cases.

Consider adding explicit length validation before the prefix check:

const minLength = FORMAT_PREFIX_LENGTH + NONCE_LENGTH + 16; // 16 bytes for auth tag
if (data.length < minLength) {
  throw new Error(`Encrypted data is too short: expected at least ${minLength} bytes, got ${data.length} bytes`);
}

This would make debugging easier for users and follows defensive programming practices.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

TooTallNate commented Feb 6, 2026

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@TooTallNate TooTallNate force-pushed the nate/async-serialization branch from 2118740 to 8a2fcdc Compare February 7, 2026 10:21
@TooTallNate TooTallNate force-pushed the nate/vercel-encryption branch from 0d60577 to 2fcf24e Compare February 7, 2026 10:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant