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
19 changes: 19 additions & 0 deletions .changeset/e2e-encryption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@workflow/cli": patch
"@workflow/core": patch
"@workflow/web-shared": patch
"@workflow/world-vercel": patch
"@workflow/world": patch
"@workflow/world-testing": patch
---

Add end-to-end encryption for workflow user data

This implements AES-256-GCM encryption with per-run key derivation via HKDF-SHA256 for workflow user data.

Key changes:
- Add encryption module with `createEncryptor()` and `createEncryptorFromEnv()` functions
- Add `Encryptor`, `EncryptionContext`, `KeyMaterial` interfaces to `@workflow/world`
- Make all (de)hydrate serialization functions async and accept encryptor parameter
- Update `runWorkflow()` to take world as 4th parameter
- Update `WorkflowOrchestratorContext` to include `runId` and `world`
4 changes: 2 additions & 2 deletions packages/core/src/runtime/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,11 @@ export class Run<TResult> {
const run = await this.world.runs.get(this.runId);

if (run.status === 'completed') {
return await hydrateWorkflowReturnValue(
return (await hydrateWorkflowReturnValue(
run.output,
this.runId,
this.world
);
)) as TResult;
Comment on lines +156 to +160
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.
}

if (run.status === 'cancelled') {
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/runtime/step-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,25 +280,25 @@ const stepHandler = getWorldHandlers().createQueueHandler(
// operations (e.g., stream loading) are added to `ops` and executed later
// via Promise.all(ops) - their timing is not included in this measurement.
const ops: Promise<void>[] = [];
const hydratedInput = await trace(
const hydratedInput = (await trace(
'step.hydrate',
{},
async (hydrateSpan) => {
const startTime = Date.now();
const result = await hydrateStepArguments(
const result = (await hydrateStepArguments(
step.input,
workflowRunId,
world,
ops
);
)) as any;
const durationMs = Date.now() - startTime;
hydrateSpan?.setAttributes({
...Attribute.StepArgumentsCount(result.args.length),
...Attribute.QueueDeserializeTimeMs(durationMs),
});
return result;
}
);
)) as any;

const args = hydratedInput.args;
const thisVal = hydratedInput.thisVal ?? null;
Expand Down
261 changes: 261 additions & 0 deletions packages/core/src/serialization.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { runInContext } from 'node:vm';
import type { WorkflowRuntimeError } from '@workflow/errors';
import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from '@workflow/serde';
import type { Encryptor } from '@workflow/world';
import { describe, expect, it } from 'vitest';
import { registerSerializationClass } from './class-serialization.js';
import { getStepFunction, registerStepFunction } from './private.js';
Expand Down Expand Up @@ -2883,3 +2884,263 @@ describe('decodeFormatPrefix legacy compatibility', () => {
expect(decoded).toBe('["test"]');
});
});
describe('encryption integration', () => {
// Create a mock encryptor that actually encrypts/decrypts
const createTestEncryptor = (): Encryptor => {
// Simple XOR-based "encryption" for testing (NOT secure, just for tests)
const xorKey = 0x42;

return {
async encrypt(data: Uint8Array, context: { runId: string }) {
// Add 'encr' prefix and XOR the data
const result = new Uint8Array(4 + data.length);
result.set(new TextEncoder().encode('encr'), 0);
for (let i = 0; i < data.length; i++) {
result[4 + i] =
data[i] ^
xorKey ^
(context.runId.charCodeAt(i % context.runId.length) & 0xff);
}
return result;
},
async decrypt(data: Uint8Array, context: { runId: string }) {
// Check prefix and XOR back
const prefix = new TextDecoder().decode(data.subarray(0, 4));
if (prefix !== 'encr') {
throw new Error(`Invalid prefix: ${prefix}`);
}
const result = new Uint8Array(data.length - 4);
for (let i = 0; i < result.length; i++) {
result[i] =
data[4 + i] ^
xorKey ^
(context.runId.charCodeAt(i % context.runId.length) & 0xff);
}
return result;
},
};
};

it('should encrypt workflow arguments when encryptor is provided', async () => {
const testEncryptor = createTestEncryptor();
const testRunId = 'wrun_test123';
const testValue = { message: 'secret data', count: 42 };

const encrypted = await dehydrateWorkflowArguments(
testValue,
testRunId,
testEncryptor,
[],
globalThis,
false
);

// Should be a Uint8Array with 'encr' prefix
expect(encrypted).toBeInstanceOf(Uint8Array);
const prefix = new TextDecoder().decode(
(encrypted as Uint8Array).subarray(0, 4)
);
expect(prefix).toBe('encr');
});

it('should decrypt workflow arguments when encryptor is provided', async () => {
const testEncryptor = createTestEncryptor();
const testRunId = 'wrun_test123';
const testValue = { message: 'secret data', count: 42 };

const encrypted = await dehydrateWorkflowArguments(
testValue,
testRunId,
testEncryptor,
[],
globalThis,
false
);

const decrypted = await hydrateWorkflowArguments(
encrypted,
testRunId,
testEncryptor,
globalThis,
{}
);

expect(decrypted).toEqual(testValue);
});

it('should fail to decrypt with wrong runId', async () => {
const testEncryptor = createTestEncryptor();
const testRunId = 'wrun_test123';
const wrongRunId = 'wrun_wrong456';
const testValue = { message: 'secret data' };

const encrypted = await dehydrateWorkflowArguments(
testValue,
testRunId,
testEncryptor,
[],
globalThis,
false
);

// Decrypting with wrong runId should produce garbage (in real crypto would fail auth)
// Our simple XOR produces bytes that aren't valid JSON, so hydration throws
await expect(
hydrateWorkflowArguments(
encrypted,
wrongRunId,
testEncryptor,
globalThis,
{}
)
).rejects.toThrow();
});

it('should not encrypt when no encryptor is provided', async () => {
const noEncryptor: Encryptor = {};
const testRunId = 'wrun_test123';
const testValue = { message: 'plain data' };

const serialized = await dehydrateWorkflowArguments(
testValue,
testRunId,
noEncryptor,
[],
globalThis,
false
);

// Should be a Uint8Array with 'devl' prefix (not encrypted)
expect(serialized).toBeInstanceOf(Uint8Array);
const prefix = new TextDecoder().decode(
(serialized as Uint8Array).subarray(0, 4)
);
expect(prefix).toBe('devl');
});

it('should handle unencrypted data when encryptor is provided', async () => {
const testEncryptor = createTestEncryptor();
const testRunId = 'wrun_test123';
const testValue = { message: 'plain data' };

// Serialize without encryption
const serialized = await dehydrateWorkflowArguments(
testValue,
testRunId,
{}, // no encryptor
[],
globalThis,
false
);

// Hydrate with encryptor - should still work because data isn't encrypted
const hydrated = await hydrateWorkflowArguments(
serialized,
testRunId,
testEncryptor,
globalThis,
{}
);

expect(hydrated).toEqual(testValue);
});

it('should encrypt step arguments', async () => {
const testEncryptor = createTestEncryptor();
const testRunId = 'wrun_test123';
const testValue = ['arg1', { nested: 'value' }, 123];

// dehydrateStepArguments signature: (value, runId, encryptor, global, v1Compat)
const encrypted = await dehydrateStepArguments(
testValue,
testRunId,
testEncryptor,
globalThis,
false
);

// Should have 'encr' prefix
expect(encrypted).toBeInstanceOf(Uint8Array);
const prefix = new TextDecoder().decode(
(encrypted as Uint8Array).subarray(0, 4)
);
expect(prefix).toBe('encr');

// Should round-trip correctly
// hydrateStepArguments signature: (value, runId, encryptor, ops, global)
const decrypted = await hydrateStepArguments(
encrypted,
testRunId,
testEncryptor,
[],
globalThis
);

expect(decrypted).toEqual(testValue);
});

it('should encrypt step return values', async () => {
const testEncryptor = createTestEncryptor();
const testRunId = 'wrun_test123';
const testValue = { result: 'success', data: [1, 2, 3] };

const encrypted = await dehydrateStepReturnValue(
testValue,
testRunId,
testEncryptor,
[],
globalThis
);

// Should have 'encr' prefix
expect(encrypted).toBeInstanceOf(Uint8Array);
const prefix = new TextDecoder().decode(
(encrypted as Uint8Array).subarray(0, 4)
);
expect(prefix).toBe('encr');

// Should round-trip correctly
const decrypted = await hydrateStepReturnValue(
encrypted,
testRunId,
testEncryptor,
globalThis
);

expect(decrypted).toEqual(testValue);
});

it('should encrypt workflow return values', async () => {
const testEncryptor = createTestEncryptor();
const testRunId = 'wrun_test123';
const testValue = { final: 'result', timestamp: Date.now() };

// dehydrateWorkflowReturnValue signature: (value, runId, encryptor, global)
const encrypted = await dehydrateWorkflowReturnValue(
testValue,
testRunId,
testEncryptor,
globalThis
);

// Should have 'encr' prefix
expect(encrypted).toBeInstanceOf(Uint8Array);
const prefix = new TextDecoder().decode(
(encrypted as Uint8Array).subarray(0, 4)
);
expect(prefix).toBe('encr');

// Should round-trip correctly
// hydrateWorkflowReturnValue signature: (value, runId, encryptor, ops, global, extraRevivers)
const decrypted = await hydrateWorkflowReturnValue(
encrypted,
testRunId,
testEncryptor,
[],
globalThis,
{}
);

expect(decrypted).toEqual(testValue);
});
});
Loading
Loading