Skip to content

Commit 64dbf16

Browse files
committed
Encryption: derive unique key per operation for key binding
This PR tweaks the way workflow data is encrypted by ensuring that each operation uses a unique encryption key, achieving key binding. Currently, the encryption serialization format `encr` uses a different key for each workflow run ID. However, the same key is used for all encryption operations performed within the same workflow run, including workflow arguments & return values, step arguments & return values, and each frame in the serialized stream. The downsides of reusing the same key are: 1. While there's key binding for each specific workflow run (so, attackers cannot swap data from "workflow run 1" with "workflow run 2"), there's no binding within the same run, so for example the input of step 1 and 2 could be swapped undetected. 2. Encryption uses AES-GCM with a random nonce of "just" 96 bits. Due to the nature of GCM, re-using the same (key, nonce) pair has catastrophic effects (causes the plaintext key to be leaked). While the risk is very small (it's considered safe to re-use the same key up to ~4 billion times with random nonces), a different key removes the likelihood of nonce reuse entirely. Signed-off-by: ItalyPaleAle <43508+ItalyPaleAle@users.noreply.github.com>
1 parent bfb1a60 commit 64dbf16

File tree

21 files changed

+836
-138
lines changed

21 files changed

+836
-138
lines changed

.changeset/early-dolls-fix.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@workflow/core": patch
3+
"@workflow/cli": patch
4+
"@workflow/web-shared": patch
5+
---
6+
7+
Add `enc2` encryption for serialized workflow data, which includes key binding for each operation, also reducing the likelihood of nonce reuse for long-running workflows.

packages/cli/src/lib/inspect/hydration.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,8 +237,8 @@ async function maybeDecryptFields<
237237

238238
try {
239239
const rawKey = await resolver(runId);
240-
const { importKey } = await import('@workflow/core/encryption');
241-
const k = rawKey ? await importKey(rawKey) : undefined;
240+
const { importEncryptionKeys } = await import('@workflow/core/encryption');
241+
const k = rawKey ? await importEncryptionKeys(rawKey) : undefined;
242242

243243
// Decrypt input/output/error fields (WorkflowRun, Step)
244244
result.input = await maybeDecrypt(result.input, k);

packages/cli/src/lib/inspect/output.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { importKey } from '@workflow/core/encryption';
1+
import { importEncryptionKeys } from '@workflow/core/encryption';
22
import {
33
type EncryptionKeyParam,
44
getDeserializeStream,
@@ -859,7 +859,7 @@ export const showStream = async (
859859
encryptionKey = (async () => {
860860
const run = await world.runs.get(opts.runId!);
861861
const rawKey = await world.getEncryptionKeyForRun?.(run);
862-
return rawKey ? await importKey(rawKey) : undefined;
862+
return rawKey ? await importEncryptionKeys(rawKey) : undefined;
863863
})();
864864
} else if (opts.decrypt && !opts.runId) {
865865
logger.warn(

packages/core/src/encryption.ts

Lines changed: 245 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
11
/**
2-
* Browser-compatible AES-256-GCM encryption module.
2+
* Browser-compatible encryption helpers.
33
*
44
* Uses the Web Crypto API (`globalThis.crypto.subtle`) which works in
55
* both modern browsers and Node.js 20+. This module is intentionally
66
* free of Node.js-specific imports so it can be bundled for the browser.
77
*
88
* The World interface (`getEncryptionKeyForRun`) returns a raw 32-byte
9-
* AES-256 key. Callers should use `importKey()` once to convert it to a
10-
* `CryptoKey`, then pass that to `encrypt()`/`decrypt()` for all
11-
* operations within the same run. This avoids repeated `importKey()`
12-
* calls on every encrypt/decrypt invocation.
9+
* per-run key. Core imports that key twice:
10+
* - as `AES-GCM` for legacy `encr` payloads
11+
* - as `HKDF` for derived-key `enc2` payloads
1312
*
14-
* Wire format: `[nonce (12 bytes)][ciphertext + auth tag]`
15-
* The `encr` format prefix is NOT part of this module — it's added/stripped
16-
* by the serialization layer in `maybeEncrypt`/`maybeDecrypt`.
13+
* Wire format for AES-GCM blobs: `[nonce (12 bytes)][ciphertext + auth tag]`
14+
* The `encr` / `enc2` serialization prefixes are NOT part of this module —
15+
* they're added/stripped by the serialization layer.
1716
*/
1817

1918
// CryptoKey is a global type in browsers and Node.js 20+, but TypeScript's
@@ -24,42 +23,258 @@ export type CryptoKey = import('node:crypto').webcrypto.CryptoKey;
2423
const NONCE_LENGTH = 12;
2524
const TAG_LENGTH = 128; // bits
2625
const KEY_LENGTH = 32; // bytes (AES-256)
26+
const HKDF_SALT = new Uint8Array(KEY_LENGTH);
27+
const V2_HEADER_LENGTH_SIZE = 4;
28+
const MAX_V2_HEADER_LENGTH = 16 * 1024;
29+
30+
const encoder = new TextEncoder();
31+
const decoder = new TextDecoder();
32+
33+
export interface EncryptionKeyBundle {
34+
legacyKey: CryptoKey;
35+
derivationKey: CryptoKey;
36+
}
37+
38+
export type EncryptionKeyLike = CryptoKey | EncryptionKeyBundle;
39+
40+
export interface EncryptedV2Header {
41+
purpose: string;
42+
runId: string;
43+
activityId?: string;
44+
counter?: number;
45+
}
46+
47+
function validateRawKey(raw: Uint8Array): void {
48+
if (raw.byteLength !== KEY_LENGTH) {
49+
throw new Error(
50+
`Encryption key must be exactly ${KEY_LENGTH} bytes, got ${raw.byteLength}`
51+
);
52+
}
53+
}
54+
55+
export function isEncryptionKeyBundle(
56+
key: EncryptionKeyLike | undefined
57+
): key is EncryptionKeyBundle {
58+
return (
59+
typeof key === 'object' &&
60+
key !== null &&
61+
'legacyKey' in key &&
62+
'derivationKey' in key
63+
);
64+
}
65+
66+
export function getLegacyKey(
67+
key: EncryptionKeyLike | undefined
68+
): CryptoKey | undefined {
69+
if (!key) return undefined;
70+
return isEncryptionKeyBundle(key) ? key.legacyKey : key;
71+
}
72+
73+
export function getDerivationKey(
74+
key: EncryptionKeyLike | undefined
75+
): CryptoKey | undefined {
76+
if (!isEncryptionKeyBundle(key)) return undefined;
77+
return key.derivationKey;
78+
}
2779

2880
/**
29-
* Import a raw AES-256 key as a `CryptoKey` for use with `encrypt()`/`decrypt()`.
81+
* Import a raw AES-256 key as a legacy `AES-GCM` `CryptoKey`.
3082
*
31-
* Callers should call this once per run (after `getEncryptionKeyForRun()`)
32-
* and pass the resulting `CryptoKey` to all subsequent encrypt/decrypt calls.
83+
* This is kept for backwards compatibility with callers that still
84+
* explicitly work with the legacy `encr` path.
3385
*
3486
* @param raw - Raw 32-byte AES-256 key (from World.getEncryptionKeyForRun)
3587
* @returns CryptoKey ready for AES-GCM operations
3688
*/
37-
export async function importKey(raw: Uint8Array) {
38-
if (raw.byteLength !== KEY_LENGTH) {
39-
throw new Error(
40-
`Encryption key must be exactly ${KEY_LENGTH} bytes, got ${raw.byteLength}`
41-
);
42-
}
89+
export async function importLegacyKey(raw: Uint8Array) {
90+
validateRawKey(raw);
4391
return globalThis.crypto.subtle.importKey('raw', raw, 'AES-GCM', false, [
4492
'encrypt',
4593
'decrypt',
4694
]);
4795
}
4896

97+
/**
98+
* Import a raw per-run key as an `HKDF` base key for derived `enc2` keys.
99+
*
100+
* @param raw - Raw 32-byte per-run key
101+
* @returns CryptoKey ready for HKDF deriveKey operations
102+
*/
103+
export async function importDerivationKey(raw: Uint8Array) {
104+
validateRawKey(raw);
105+
return globalThis.crypto.subtle.importKey('raw', raw, 'HKDF', false, [
106+
'deriveKey',
107+
]);
108+
}
109+
110+
/**
111+
* Import a raw per-run key into the pair of keys needed by core.
112+
*/
113+
export async function importEncryptionKeys(
114+
raw: Uint8Array
115+
): Promise<EncryptionKeyBundle> {
116+
const [legacyKey, derivationKey] = await Promise.all([
117+
importLegacyKey(raw),
118+
importDerivationKey(raw),
119+
]);
120+
return { legacyKey, derivationKey };
121+
}
122+
123+
/**
124+
* Derive an activity-specific AES-256-GCM key from the per-run HKDF key.
125+
*
126+
* The `info` bytes should encode the full activity context. For `enc2`
127+
* payloads we reuse the exact serialized header bytes as both the HKDF
128+
* `info` parameter and the AES-GCM AAD.
129+
*/
130+
export async function deriveActivityKey(
131+
derivationKey: CryptoKey,
132+
info: Uint8Array
133+
): Promise<CryptoKey> {
134+
const derived = await globalThis.crypto.subtle.deriveKey(
135+
{
136+
name: 'HKDF',
137+
hash: 'SHA-256',
138+
salt: HKDF_SALT,
139+
info,
140+
},
141+
derivationKey,
142+
{ name: 'AES-GCM', length: KEY_LENGTH * 8 },
143+
false,
144+
['encrypt', 'decrypt']
145+
);
146+
return derived as CryptoKey;
147+
}
148+
149+
function isValidEncryptedV2Header(value: unknown): value is EncryptedV2Header {
150+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
151+
return false;
152+
}
153+
154+
const header = value as Record<string, unknown>;
155+
return (
156+
typeof header.purpose === 'string' &&
157+
header.purpose.length > 0 &&
158+
typeof header.runId === 'string' &&
159+
header.runId.length > 0 &&
160+
(header.activityId === undefined ||
161+
typeof header.activityId === 'string') &&
162+
(header.counter === undefined ||
163+
(typeof header.counter === 'number' &&
164+
Number.isInteger(header.counter) &&
165+
header.counter >= 0))
166+
);
167+
}
168+
169+
export function encodeEncryptedV2Header(header: EncryptedV2Header): Uint8Array {
170+
if (!isValidEncryptedV2Header(header)) {
171+
throw new Error('Invalid enc2 header');
172+
}
173+
174+
const normalized: EncryptedV2Header = {
175+
purpose: header.purpose,
176+
runId: header.runId,
177+
};
178+
if (header.activityId !== undefined) {
179+
normalized.activityId = header.activityId;
180+
}
181+
if (header.counter !== undefined) {
182+
normalized.counter = header.counter;
183+
}
184+
185+
return encoder.encode(JSON.stringify(normalized));
186+
}
187+
188+
export function decodeEncryptedV2Header(
189+
headerBytes: Uint8Array
190+
): EncryptedV2Header {
191+
let parsed: unknown;
192+
try {
193+
parsed = JSON.parse(decoder.decode(headerBytes));
194+
} catch (error) {
195+
throw new Error('Invalid enc2 header JSON', { cause: error });
196+
}
197+
198+
if (!isValidEncryptedV2Header(parsed)) {
199+
throw new Error('Invalid enc2 header fields');
200+
}
201+
202+
return parsed;
203+
}
204+
205+
/**
206+
* Encode the inner `enc2` payload body:
207+
* `[4-byte header length][header bytes][nonce + ciphertext + auth tag]`
208+
*/
209+
export function encodeEncryptedV2Body(
210+
headerBytes: Uint8Array,
211+
ciphertext: Uint8Array
212+
): Uint8Array {
213+
const result = new Uint8Array(
214+
V2_HEADER_LENGTH_SIZE + headerBytes.byteLength + ciphertext.byteLength
215+
);
216+
new DataView(result.buffer).setUint32(0, headerBytes.byteLength, false);
217+
result.set(headerBytes, V2_HEADER_LENGTH_SIZE);
218+
result.set(ciphertext, V2_HEADER_LENGTH_SIZE + headerBytes.byteLength);
219+
return result;
220+
}
221+
222+
export function decodeEncryptedV2Body(data: Uint8Array): {
223+
headerBytes: Uint8Array;
224+
ciphertext: Uint8Array;
225+
} {
226+
if (data.byteLength < V2_HEADER_LENGTH_SIZE) {
227+
throw new Error('enc2 payload too short to contain header length');
228+
}
229+
230+
const headerLength = new DataView(
231+
data.buffer,
232+
data.byteOffset,
233+
data.byteLength
234+
).getUint32(0, false);
235+
236+
if (headerLength === 0 || headerLength > MAX_V2_HEADER_LENGTH) {
237+
throw new Error(`Invalid enc2 header length: ${headerLength}`);
238+
}
239+
240+
const headerStart = V2_HEADER_LENGTH_SIZE;
241+
const headerEnd = headerStart + headerLength;
242+
if (data.byteLength < headerEnd) {
243+
throw new Error('enc2 payload truncated before header completed');
244+
}
245+
246+
const ciphertext = data.subarray(headerEnd);
247+
if (ciphertext.byteLength === 0) {
248+
throw new Error('enc2 payload missing ciphertext');
249+
}
250+
251+
return {
252+
headerBytes: data.subarray(headerStart, headerEnd),
253+
ciphertext,
254+
};
255+
}
256+
49257
/**
50258
* Encrypt data using AES-256-GCM.
51259
*
52-
* @param key - CryptoKey from `importKey()`
260+
* @param key - CryptoKey from `importLegacyKey()`
53261
* @param data - Plaintext to encrypt
262+
* @param aad - Optional AES-GCM additional authenticated data
54263
* @returns `[nonce (12 bytes)][ciphertext + GCM auth tag]`
55264
*/
56265
export async function encrypt(
57266
key: CryptoKey,
58-
data: Uint8Array
267+
data: Uint8Array,
268+
aad?: Uint8Array
59269
): Promise<Uint8Array> {
60270
const nonce = globalThis.crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
61271
const ciphertext = await globalThis.crypto.subtle.encrypt(
62-
{ name: 'AES-GCM', iv: nonce, tagLength: TAG_LENGTH },
272+
{
273+
name: 'AES-GCM',
274+
iv: nonce,
275+
tagLength: TAG_LENGTH,
276+
...(aad ? { additionalData: aad } : {}),
277+
},
63278
key,
64279
data
65280
);
@@ -72,13 +287,15 @@ export async function encrypt(
72287
/**
73288
* Decrypt data using AES-256-GCM.
74289
*
75-
* @param key - CryptoKey from `importKey()`
290+
* @param key - CryptoKey from `importLegacyKey()`
76291
* @param data - `[nonce (12 bytes)][ciphertext + GCM auth tag]`
292+
* @param aad - Optional AES-GCM additional authenticated data
77293
* @returns Decrypted plaintext
78294
*/
79295
export async function decrypt(
80296
key: CryptoKey,
81-
data: Uint8Array
297+
data: Uint8Array,
298+
aad?: Uint8Array
82299
): Promise<Uint8Array> {
83300
const minLength = NONCE_LENGTH + TAG_LENGTH / 8; // nonce + auth tag
84301
if (data.byteLength < minLength) {
@@ -89,7 +306,12 @@ export async function decrypt(
89306
const nonce = data.subarray(0, NONCE_LENGTH);
90307
const ciphertext = data.subarray(NONCE_LENGTH);
91308
const plaintext = await globalThis.crypto.subtle.decrypt(
92-
{ name: 'AES-GCM', iv: nonce, tagLength: TAG_LENGTH },
309+
{
310+
name: 'AES-GCM',
311+
iv: nonce,
312+
tagLength: TAG_LENGTH,
313+
...(aad ? { additionalData: aad } : {}),
314+
},
93315
key,
94316
ciphertext
95317
);

packages/core/src/private.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Utils used by the bundler when transforming code
33
*/
44

5-
import type { CryptoKey } from './encryption.js';
5+
import type { EncryptionKeyLike } from './encryption.js';
66
import type { EventsConsumer } from './events-consumer.js';
77
import type { QueueItem } from './global.js';
88
import type { Serializable } from './schemas.js';
@@ -114,7 +114,7 @@ export { __private_getClosureVars } from './step/get-closure-vars.js';
114114

115115
export interface WorkflowOrchestratorContext {
116116
runId: string;
117-
encryptionKey: CryptoKey | undefined;
117+
encryptionKey: EncryptionKeyLike | undefined;
118118
globalThis: typeof globalThis;
119119
eventsConsumer: EventsConsumer;
120120
/**

packages/core/src/runtime.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
WorkflowInvokePayloadSchema,
1414
type WorkflowRun,
1515
} from '@workflow/world';
16-
import { importKey } from './encryption.js';
16+
import { importEncryptionKeys } from './encryption.js';
1717
import { WorkflowSuspension } from './global.js';
1818
import { runtimeLogger } from './logger.js';
1919
import {
@@ -346,7 +346,7 @@ export function workflowEntrypoint(
346346
const rawKey =
347347
await world.getEncryptionKeyForRun?.(workflowRun);
348348
const encryptionKey = rawKey
349-
? await importKey(rawKey)
349+
? await importEncryptionKeys(rawKey)
350350
: undefined;
351351

352352
// --- User code execution ---

0 commit comments

Comments
 (0)