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

Wire AES-GCM encryption into serialization layer with stream support
4 changes: 0 additions & 4 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,6 @@
"types": "./dist/serialization-format.d.ts",
"default": "./dist/serialization-format.js"
},
"./encryption": {
"types": "./dist/encryption.d.ts",
"default": "./dist/encryption.js"
},
"./_workflow": "./dist/workflow/index.js"
},
"scripts": {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/runtime/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,11 @@ export class Run<TResult> {
const encryptionKey = await this.world.getEncryptionKeyForRun?.(
this.runId
);
return await hydrateWorkflowReturnValue(
return (await hydrateWorkflowReturnValue(
run.output,
this.runId,
encryptionKey
);
)) as TResult;
}

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 @@ -300,27 +300,27 @@ 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 encryptionKey =
await world.getEncryptionKeyForRun?.(workflowRunId);
const result = await hydrateStepArguments(
const result = (await hydrateStepArguments(
step.input,
workflowRunId,
encryptionKey,
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
237 changes: 231 additions & 6 deletions packages/core/src/serialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1932,12 +1932,17 @@ describe('step function serialization', () => {
enumerable: false,
configurable: false,
});
const dehydrated = await dehydrateStepArguments([fnWithStepId], globalThis);
const ops: Promise<void>[] = [];
const dehydrated = await dehydrateStepArguments(
[fnWithStepId],
mockRunId,
undefined,
globalThis
);
const hydrated = await hydrateStepArguments(
dehydrated,
ops,
mockRunId,
undefined,
[],
globalThis
);
const result = hydrated[0];
Expand All @@ -1959,12 +1964,17 @@ describe('step function serialization', () => {
enumerable: false,
configurable: false,
});
const dehydrated = await dehydrateStepArguments([fnWithStepId], globalThis);
const ops: Promise<void>[] = [];
const dehydrated = await dehydrateStepArguments(
[fnWithStepId],
mockRunId,
undefined,
globalThis
);
const hydrated = await hydrateStepArguments(
dehydrated,
ops,
mockRunId,
undefined,
[],
globalThis
);
const result = hydrated[0];
Expand Down Expand Up @@ -3146,3 +3156,218 @@ describe('getDeserializeStream legacy fallback', () => {
expect(results[1]).toBe('world');
});
});

describe('encryption integration', () => {
// Real 32-byte AES-256 test key
const testKey = 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,
]);
// A different key for wrong-key tests
const wrongKey = new Uint8Array(32);
wrongKey.fill(0xff);

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

const encrypted = await dehydrateWorkflowArguments(
testValue,
testRunId,
testKey,
[],
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 with correct key', async () => {
const testRunId = 'wrun_test123';
const testValue = { message: 'secret data', count: 42 };

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

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

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

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

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

// AES-GCM auth tag check should fail with wrong key
await expect(
hydrateWorkflowArguments(encrypted, testRunId, wrongKey, globalThis, {})
).rejects.toThrow();
});

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

const serialized = await dehydrateWorkflowArguments(
testValue,
testRunId,
undefined,
[],
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 key is provided', async () => {
const testRunId = 'wrun_test123';
const testValue = { message: 'plain data' };

// Serialize without encryption
const serialized = await dehydrateWorkflowArguments(
testValue,
testRunId,
undefined,
[],
globalThis,
false
);

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

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

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

const encrypted = await dehydrateStepArguments(
testValue,
testRunId,
testKey,
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
const decrypted = await hydrateStepArguments(
encrypted,
testRunId,
testKey,
[],
globalThis
);

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

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

const encrypted = await dehydrateStepReturnValue(
testValue,
testRunId,
testKey,
[],
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,
testKey,
globalThis
);

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

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

const encrypted = await dehydrateWorkflowReturnValue(
testValue,
testRunId,
testKey,
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 hydrateWorkflowReturnValue(
encrypted,
testRunId,
testKey,
[],
globalThis,
{}
);

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