Skip to content

Wire encryption into serialization layer#957

Open
TooTallNate wants to merge 1 commit intonate/vercel-encryptionfrom
nate/wire-encryption
Open

Wire encryption into serialization layer#957
TooTallNate wants to merge 1 commit intonate/vercel-encryptionfrom
nate/wire-encryption

Conversation

@TooTallNate
Copy link
Member

Summary

  • Adds maybeEncrypt() / maybeDecrypt() helpers that conditionally encrypt/decrypt based on whether an Encryptor is provided
  • Adds peekFormatPrefix(), isEncrypted() helper functions
  • Adds ENCRYPTED ('encr') format prefix to SerializationFormat
  • Adds getEncryptStream() / getDecryptStream() TransformStream factories for stream encryption
  • Wires encryption into all 8 dehydrate/hydrate functions — dehydrate calls maybeEncrypt after serialization, hydrate calls maybeDecrypt before deserialization
  • Adds stream encryption in WorkflowServerWritableStream (encrypts chunks before writing to storage)
  • Adds stream decryption in WorkflowServerReadableStream (decrypts chunks when reading from storage)
  • Passes runId to WorkflowServerReadableStream for decryption context
  • Adds 8 encryption integration tests using a mock XOR encryptor
  • Adds changeset

This is the "light the fuse" PR that actually enables encryption. When a World implementation provides encrypt/decrypt functions (e.g., via VERCEL_DEPLOYMENT_KEY env var), all workflow user data will be encrypted at rest.

Stack

Test plan

All 311 core tests pass (303 existing + 8 new encryption integration tests). Build succeeds.

Copilot AI review requested due to automatic review settings February 6, 2026 02:35
@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 Open in v0 Feb 7, 2026 10:36am
example-nextjs-workflow-webpack Error Error Open in v0 Feb 7, 2026 10:36am
example-workflow Error Error Open in v0 Feb 7, 2026 10:36am
workbench-astro-workflow Error Error Open in v0 Feb 7, 2026 10:36am
workbench-express-workflow Error Error Open in v0 Feb 7, 2026 10:36am
workbench-fastify-workflow Error Error Open in v0 Feb 7, 2026 10:36am
workbench-hono-workflow Error Error Open in v0 Feb 7, 2026 10:36am
workbench-nitro-workflow Error Error Open in v0 Feb 7, 2026 10:36am
workbench-nuxt-workflow Error Error Open in v0 Feb 7, 2026 10:36am
workbench-sveltekit-workflow Error Error Open in v0 Feb 7, 2026 10:36am
workbench-vite-workflow Error Error Open in v0 Feb 7, 2026 10:36am
workflow-docs Ready Ready Preview, Comment, Open in v0 Feb 7, 2026 10:36am
workflow-nest Error Error Open in v0 Feb 7, 2026 10:36am
workflow-swc-playground Ready Ready Preview, Comment, Open in v0 Feb 7, 2026 10:36am

@changeset-bot
Copy link

changeset-bot bot commented Feb 6, 2026

🦋 Changeset detected

Latest commit: 180ed09

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 18 packages
Name Type
@workflow/cli Patch
@workflow/core Patch
@workflow/web-shared Patch
@workflow/world-vercel Patch
@workflow/world Patch
@workflow/world-testing Patch
workflow Patch
@workflow/builders Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/world-local Patch
@workflow/world-postgres Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt Patch

Not sure what this means? Click here to learn what changesets are.

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

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

🧪 E2E Test Results

No test result files found.


Some E2E test jobs failed:

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

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 implements end-to-end encryption for workflow user data by wiring encryption functionality into the serialization layer. It builds on previous PRs that generated runId client-side (#954), made serialization functions async (#955), and added the Vercel encryption implementation (#956).

Changes:

  • Adds encryption/decryption helper functions (maybeEncrypt, maybeDecrypt, isEncrypted, peekFormatPrefix) and unused stream encryption utilities (getEncryptStream, getDecryptStream)
  • Adds ENCRYPTED ('encr') format prefix to SerializationFormat enum
  • Wires encryption into all 8 dehydrate/hydrate functions by calling maybeEncrypt after serialization and maybeDecrypt before deserialization
  • Implements inline stream encryption in WorkflowServerWritableStream and WorkflowServerReadableStream
  • Passes runId to WorkflowServerReadableStream for decryption context
  • Adds 8 comprehensive integration tests using a mock XOR encryptor
  • Removes unused _ prefixes from encryptor and runId parameters (now actively used)
  • Adds type casts (as any[], as any, as TResult) to hydration call sites to preserve type information

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
packages/core/src/serialization.ts Core encryption implementation: adds format prefix, helper functions, encryption wiring in dehydrate/hydrate functions, and inline stream encryption/decryption
packages/core/src/workflow.ts Adds type cast for hydrated workflow arguments
packages/core/src/runtime/step-handler.ts Adds type cast for hydrated step arguments
packages/core/src/runtime/run.ts Adds type cast for hydrated workflow return value
packages/core/src/serialization.test.ts Adds 8 encryption integration tests with mock XOR encryptor
.changeset/e2e-encryption.md Documents the end-to-end encryption feature addition

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

Comment on lines +284 to +335
export function getEncryptStream(
encryptor: Encryptor,
runId: string
): TransformStream<Uint8Array, Uint8Array> {
return new TransformStream<Uint8Array, Uint8Array>({
async transform(chunk, controller) {
if (!encryptor.encrypt) {
// No encryption available, pass through unchanged
controller.enqueue(chunk);
return;
}

const encrypted = await encryptor.encrypt(chunk, { runId });
controller.enqueue(encrypted);
},
});
}

/**
* Create a transform stream that decrypts each chunk.
* Used to decrypt stream data read from storage.
*
* @param encryptor - Encryptor instance with decrypt function
* @param runId - Run ID for decryption context
* @returns TransformStream that decrypts Uint8Array chunks
*/
export function getDecryptStream(
encryptor: Encryptor,
runId: string
): TransformStream<Uint8Array, Uint8Array> {
return new TransformStream<Uint8Array, Uint8Array>({
async transform(chunk, controller) {
// Check if chunk is encrypted
if (!isEncrypted(chunk)) {
// Not encrypted, pass through unchanged
controller.enqueue(chunk);
return;
}

if (!encryptor.decrypt) {
throw new WorkflowRuntimeError(
'Encrypted stream data encountered but Encryptor does not support decryption. ' +
'Ensure VERCEL_DEPLOYMENT_KEY is set or provide an Encryptor with decryption support.'
);
}

const decrypted = await encryptor.decrypt(chunk, { runId });
controller.enqueue(decrypted);
},
});
}

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 functions getEncryptStream and getDecryptStream are defined but never used in the codebase. Stream encryption and decryption are instead implemented inline within WorkflowServerWritableStream (lines 419-424) and WorkflowServerReadableStream (lines 364-379).

These functions should either be removed as dead code, or the inline implementations should be refactored to use these TransformStream factories for consistency and maintainability.

Suggested change
export function getEncryptStream(
encryptor: Encryptor,
runId: string
): TransformStream<Uint8Array, Uint8Array> {
return new TransformStream<Uint8Array, Uint8Array>({
async transform(chunk, controller) {
if (!encryptor.encrypt) {
// No encryption available, pass through unchanged
controller.enqueue(chunk);
return;
}
const encrypted = await encryptor.encrypt(chunk, { runId });
controller.enqueue(encrypted);
},
});
}
/**
* Create a transform stream that decrypts each chunk.
* Used to decrypt stream data read from storage.
*
* @param encryptor - Encryptor instance with decrypt function
* @param runId - Run ID for decryption context
* @returns TransformStream that decrypts Uint8Array chunks
*/
export function getDecryptStream(
encryptor: Encryptor,
runId: string
): TransformStream<Uint8Array, Uint8Array> {
return new TransformStream<Uint8Array, Uint8Array>({
async transform(chunk, controller) {
// Check if chunk is encrypted
if (!isEncrypted(chunk)) {
// Not encrypted, pass through unchanged
controller.enqueue(chunk);
return;
}
if (!encryptor.decrypt) {
throw new WorkflowRuntimeError(
'Encrypted stream data encountered but Encryptor does not support decryption. ' +
'Ensure VERCEL_DEPLOYMENT_KEY is set or provide an Encryptor with decryption support.'
);
}
const decrypted = await encryptor.decrypt(chunk, { runId });
controller.enqueue(decrypted);
},
});
}

Copilot uses AI. Check for mistakes.
Comment on lines 630 to 635
const args = (await hydrateWorkflowArguments(
workflowRun.input,
workflowRun.runId,
world,
vmGlobalThis
);
)) as any[];
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 return value of hydrateWorkflowArguments is cast to any[] using a type assertion. While this is necessary because the function returns Promise<unknown>, this cast bypasses type safety.

The hydrated workflow arguments should be an array to be spread into the workflow function call on line 643. Consider changing the return type of hydrateWorkflowArguments to Promise<any[]> to make this contract explicit, or add runtime validation to ensure the result is actually an array before casting.

Copilot uses AI. Check for mistakes.
Comment on lines 265 to 270
const hydratedInput = (await hydrateStepArguments(
step.input,
workflowRunId,
world,
ops
);
)) as any;
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 return value of hydrateStepArguments is cast to any using a type assertion. While this is necessary because the function returns Promise<unknown>, this cast bypasses type safety.

The result is expected to have properties args, thisVal, and closureVars (used on lines 273, 274, 300). Consider creating a dedicated interface for the step input structure (e.g., StepInput { args: any[], thisVal?: any, closureVars?: Record<string, any> }) and changing the return type to make this contract explicit.

Copilot uses AI. Check for mistakes.
Comment on lines +156 to +160
return (await hydrateWorkflowReturnValue(
run.output,
this.runId,
this.world
);
)) as TResult;
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 return value of hydrateWorkflowReturnValue is cast to TResult using a type assertion. While this is necessary because the function returns Promise<unknown>, this cast bypasses type safety and could lead to runtime errors if the deserialized value doesn't match the expected type.

However, this is likely acceptable in this context as workflow return types are user-defined and can't be validated at compile time. The cast ensures the caller gets the expected generic type.

Copilot uses AI. Check for mistakes.
Comment on lines +364 to +379
// Decrypt chunk if encrypted and runId is provided
let chunk = result.value;
if (runId && isEncrypted(chunk)) {
const world = getWorld();
if (!world.decrypt) {
controller.error(
new WorkflowRuntimeError(
'Encrypted stream data encountered but World does not support decryption. ' +
'Ensure VERCEL_DEPLOYMENT_KEY is set.'
)
);
return;
}
chunk = await world.decrypt(chunk, { runId });
}
controller.enqueue(chunk);
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 decryption logic in WorkflowServerReadableStream only decrypts if runId is provided. However, if encrypted data is encountered when runId is undefined, the encrypted data will be passed through unchanged (line 379), which will likely cause downstream errors when trying to deserialize the encrypted bytes.

Consider adding an error check: if isEncrypted(chunk) returns true but runId is undefined, throw an error indicating that encrypted data was encountered but decryption context is missing.

Copilot uses AI. Check for mistakes.
Comment on lines +1366 to +1370
* Encrypt data if the world supports encryption.
* Returns original data if encryption is not available.
*
* @param data - Serialized data to encrypt
* @param world - World instance (may have encrypt function)
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 JSDoc comment incorrectly states @param world - World instance (may have encrypt function) but the actual parameter is named encryptor and is of type Encryptor, not World. The documentation should be updated to reflect the actual parameter name and type.

Suggested change
* Encrypt data if the world supports encryption.
* Returns original data if encryption is not available.
*
* @param data - Serialized data to encrypt
* @param world - World instance (may have encrypt function)
* Encrypt data if the encryptor supports encryption.
* Returns original data if encryption is not available.
*
* @param data - Serialized data to encrypt
* @param encryptor - Encryptor instance (may have encrypt function)

Copilot uses AI. Check for mistakes.
Comment on lines 1420 to 1431
/**
* Called from the `start()` function to serialize the workflow arguments
* into a format that can be saved to the database and then hydrated from
* within the workflow execution environment.
*
* @param value
* @param global
* @param runId
* @param value - The value to serialize
* @param runId - The workflow run ID (required for encryption context)
* @param world - World instance for encryption support
* @param ops - Promise array for stream operations
* @param global - Global object for serialization context
* @param v1Compat - Enable legacy v1 compatibility mode
* @returns The dehydrated value as binary data (Uint8Array) with format 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.

Several JSDoc comments throughout the dehydrate/hydrate functions incorrectly reference @param world - World instance for encryption support when the actual parameter is named encryptor and is of type Encryptor. This affects lines:

  • 1427 (dehydrateWorkflowArguments)
  • 1468 (hydrateWorkflowArguments)
  • 1510 (dehydrateWorkflowReturnValue)
  • 1545 (hydrateWorkflowReturnValue)
  • 1590 (dehydrateStepArguments)
  • 1629 (hydrateStepArguments)
  • 1674 (dehydrateStepReturnValue)
  • 1715 (hydrateStepReturnValue)

All of these should be updated to say @param encryptor - Encryptor instance for encryption support or similar.

Copilot uses AI. Check for mistakes.
Comment on lines 1514 to +1518
export async function dehydrateWorkflowReturnValue(
value: unknown,
_runId: string,
_encryptor: Encryptor,
global: Record<string, any> = globalThis,
v1Compat = false
runId: string,
encryptor: Encryptor,
global: Record<string, any> = globalThis
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 v1Compat parameter and its associated logic (lines 1522-1524 in the original code) have been removed from dehydrateWorkflowReturnValue. However, this parameter is still present in the other dehydrate functions (dehydrateWorkflowArguments, dehydrateStepArguments, dehydrateStepReturnValue).

This creates an API inconsistency where three functions support v1 compatibility mode but one doesn't. This is likely unintentional, as the PR description doesn't mention removing v1 compatibility support.

Additionally, the test wrapper function at line 70-76 in serialization.test.ts is passing a v1Compat parameter to this function, which will be silently ignored.

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/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