Skip to content

Commit 84e27c4

Browse files
Decode replay payloads synchronously
1 parent e9e7488 commit 84e27c4

6 files changed

Lines changed: 268 additions & 20 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/core": patch
3+
---
4+
5+
Decode replay payloads synchronously with Node AES-GCM and zstd when the runtime supports it.

packages/core/src/encryption.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { RuntimeDecryptionError } from '@workflow/errors';
22
import { describe, expect, it } from 'vitest';
3-
import { type CryptoKey, decrypt, encrypt, importKey } from './encryption.js';
3+
import {
4+
type CryptoKey,
5+
decrypt,
6+
decryptSync,
7+
encrypt,
8+
importKey,
9+
} from './encryption.js';
410

511
const RAW_KEY = new Uint8Array(32).fill(7);
612
const OTHER_RAW_KEY = new Uint8Array(32).fill(8);
@@ -27,6 +33,16 @@ describe('encryption', () => {
2733
const decoded = await decrypt(key, ciphertext);
2834
expect(new TextDecoder().decode(decoded)).toBe('hello, workflow');
2935
});
36+
37+
it('decryptSync() returns the plaintext without a promise', async () => {
38+
const key = await getKey();
39+
const plaintext = new TextEncoder().encode('synchronous replay');
40+
const ciphertext = await encrypt(key, plaintext);
41+
42+
const decoded = decryptSync(key, ciphertext);
43+
expect(decoded).toBeInstanceOf(Uint8Array);
44+
expect(new TextDecoder().decode(decoded)).toBe('synchronous replay');
45+
});
3046
});
3147

3248
describe('importKey', () => {
@@ -92,6 +108,19 @@ describe('encryption', () => {
92108
expect(cause?.name).toBe('OperationError');
93109
});
94110

111+
it('keeps RuntimeDecryptionError on synchronous auth failure', async () => {
112+
const key = await getKey();
113+
const ciphertext = await encrypt(
114+
key,
115+
new TextEncoder().encode('tamper me')
116+
);
117+
ciphertext[ciphertext.length - 1] ^= 0xff;
118+
119+
expect(() => decryptSync(key, ciphertext)).toThrowError(
120+
RuntimeDecryptionError
121+
);
122+
});
123+
95124
it('does not record a formatPrefix at the low-level layer', async () => {
96125
// This function only ever sees the stripped AES payload
97126
// (`[nonce][ciphertext+tag]`), never the outer `encr` envelope marker.

packages/core/src/encryption.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,46 @@ import { RuntimeDecryptionError, WorkflowRuntimeError } from '@workflow/errors';
2323
// so consumers can reference it without adding `dom` lib.
2424
export type CryptoKey = import('node:crypto').webcrypto.CryptoKey;
2525

26+
/**
27+
* Raw key material retained alongside keys imported by this module.
28+
*
29+
* Node's synchronous cipher API cannot consume a Web Crypto `CryptoKey`, and
30+
* our keys are deliberately non-extractable. Keeping the original bytes in a
31+
* WeakMap gives the Node replay path access to the same key without making it
32+
* extractable or extending its lifetime beyond the `CryptoKey`. Browser/edge
33+
* callers continue to use Web Crypto and never consult this map.
34+
*/
35+
const importedKeyMaterial = new WeakMap<CryptoKey, Uint8Array>();
36+
37+
interface NodeDecipher {
38+
setAAD(data: Uint8Array): NodeDecipher;
39+
setAuthTag(tag: Uint8Array): NodeDecipher;
40+
update(data: Uint8Array): Uint8Array;
41+
final(): Uint8Array;
42+
}
43+
44+
interface NodeCrypto {
45+
createDecipheriv(
46+
algorithm: string,
47+
key: Uint8Array,
48+
iv: Uint8Array,
49+
options?: { authTagLength?: number }
50+
): NodeDecipher;
51+
}
52+
53+
/** Resolve node:crypto without a static import, preserving browser bundles. */
54+
function getNodeCrypto(): NodeCrypto | undefined {
55+
try {
56+
return (
57+
globalThis as {
58+
process?: { getBuiltinModule?: (id: string) => NodeCrypto };
59+
}
60+
).process?.getBuiltinModule?.('node:crypto');
61+
} catch {
62+
return undefined;
63+
}
64+
}
65+
2666
/** AES-GCM nonce length in bytes. */
2767
export const NONCE_LENGTH = 12;
2868
/** AES-GCM authentication tag length in bits. */
@@ -55,7 +95,7 @@ export async function importKey(
5595
`Encryption key must be exactly ${KEY_LENGTH} bytes, got ${raw.byteLength}`
5696
);
5797
}
58-
return globalThis.crypto.subtle.importKey(
98+
const key = await globalThis.crypto.subtle.importKey(
5999
'raw',
60100
raw,
61101
'AES-GCM',
@@ -65,6 +105,82 @@ export async function importKey(
65105
// a strict subset of `KeyUsage[]`, so this cast is sound.
66106
usages as ('encrypt' | 'decrypt')[]
67107
);
108+
// Copy the caller's bytes: a caller may reuse/mutate its input buffer after
109+
// importKey(), while a CryptoKey's material is immutable.
110+
importedKeyMaterial.set(key, raw.slice());
111+
return key;
112+
}
113+
114+
/**
115+
* Decrypt AES-256-GCM synchronously when running on Node and the key was
116+
* imported by this module.
117+
*
118+
* Returns `undefined` when the portable Web Crypto fallback is required (for
119+
* example in a browser, or for an externally-created CryptoKey). Authentication
120+
* failures throw the same RuntimeDecryptionError shape as {@link decrypt}.
121+
*/
122+
export function decryptSync(
123+
key: CryptoKey,
124+
data: Uint8Array,
125+
aad?: Uint8Array
126+
): Uint8Array | undefined {
127+
const material = importedKeyMaterial.get(key);
128+
const nodeCrypto = getNodeCrypto();
129+
if (!material || !nodeCrypto) return undefined;
130+
if (!key.usages.includes('decrypt')) {
131+
throw new RuntimeDecryptionError(
132+
'AES-256-GCM decryption failed: CryptoKey does not support decrypt',
133+
{
134+
context: { operation: 'decrypt', byteLength: data.byteLength },
135+
}
136+
);
137+
}
138+
139+
const minLength = NONCE_LENGTH + TAG_BYTES;
140+
if (data.byteLength < minLength) {
141+
throw new RuntimeDecryptionError(
142+
`Encrypted data too short: expected at least ${minLength} bytes, got ${data.byteLength}`,
143+
{
144+
context: {
145+
operation: 'decrypt',
146+
byteLength: data.byteLength,
147+
},
148+
}
149+
);
150+
}
151+
152+
const nonce = data.subarray(0, NONCE_LENGTH);
153+
const ciphertextEnd = data.byteLength - TAG_BYTES;
154+
const ciphertext = data.subarray(NONCE_LENGTH, ciphertextEnd);
155+
const authTag = data.subarray(ciphertextEnd);
156+
try {
157+
const decipher = nodeCrypto.createDecipheriv(
158+
'aes-256-gcm',
159+
material,
160+
nonce,
161+
{ authTagLength: TAG_BYTES }
162+
);
163+
if (aad) decipher.setAAD(aad);
164+
decipher.setAuthTag(authTag);
165+
const head = decipher.update(ciphertext);
166+
const tail = decipher.final();
167+
if (tail.byteLength === 0) return head;
168+
const plaintext = new Uint8Array(head.byteLength + tail.byteLength);
169+
plaintext.set(head, 0);
170+
plaintext.set(tail, head.byteLength);
171+
return plaintext;
172+
} catch (cause) {
173+
throw new RuntimeDecryptionError(
174+
`AES-256-GCM decryption failed: ${cause instanceof Error ? cause.message : String(cause)}`,
175+
{
176+
cause,
177+
context: {
178+
operation: 'decrypt',
179+
byteLength: data.byteLength,
180+
},
181+
}
182+
);
183+
}
68184
}
69185

70186
/**

packages/core/src/serialization.ts

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,12 @@ import {
3838
type CompressionStats,
3939
compress,
4040
decompress,
41+
decompressReplayPayload,
4142
} from './serialization/compression.js';
4243
import {
4344
aesKeyOf,
4445
decrypt,
46+
decryptReplayPayload,
4547
deriveRunPayloadKeys,
4648
type EncryptionKeyParam,
4749
encrypt,
@@ -3386,8 +3388,8 @@ export interface PreparedReplayPayload {
33863388

33873389
/**
33883390
* Swappable implementation of the host-side preparation stage. Supporting
3389-
* both direct and promised results lets a future synchronous Node decryptor use
3390-
* the same cache contract as today's asynchronous Web Crypto implementation.
3391+
* both direct and promised results covers synchronous Node AES/zstd as well as
3392+
* the portable Web Crypto and sealed-envelope fallbacks.
33913393
*/
33923394
export type ReplayPayloadPreparer = (
33933395
value: unknown,
@@ -3398,18 +3400,49 @@ export type ReplayPayloadPreparer = (
33983400
* Decrypt and decompress persisted data without parsing it into JavaScript.
33993401
* Legacy non-binary values pass through unchanged for their consumer to revive.
34003402
*/
3401-
export const prepareReplayPayload: ReplayPayloadPreparer = async (
3402-
value,
3403-
key
3404-
) => {
3403+
function prepareReplayPayloadWithStats(
3404+
value: unknown,
3405+
key: PayloadKey | undefined,
3406+
compressionStats?: CompressionStats
3407+
): PreparedReplayPayload | Promise<PreparedReplayPayload> {
3408+
const finish = (prepared: unknown): PreparedReplayPayload => ({
3409+
data: prepared,
3410+
});
3411+
const decompressPrepared = (
3412+
decrypted: unknown
3413+
): PreparedReplayPayload | Promise<PreparedReplayPayload> => {
3414+
const prepared = decompressReplayPayload(decrypted, compressionStats);
3415+
return prepared instanceof Promise
3416+
? prepared.then(finish)
3417+
: finish(prepared);
3418+
};
3419+
3420+
const decrypted = decryptReplayPayload(value, key);
3421+
return decrypted instanceof Promise
3422+
? decrypted.then(decompressPrepared)
3423+
: decompressPrepared(decrypted);
3424+
}
3425+
3426+
// Replay preparation is event-at-a-time and may run inside the response
3427+
// decoder. Per-payload compression attributes would add a detached O(N)
3428+
// microtask tail and repeatedly overwrite one span, so the replay fast path
3429+
// records only its aggregate preparation span.
3430+
export const prepareReplayPayload: ReplayPayloadPreparer = (value, key) =>
3431+
prepareReplayPayloadWithStats(value, key);
3432+
3433+
async function prepareReplayPayloadWithTelemetry(
3434+
value: unknown,
3435+
key: PayloadKey | undefined
3436+
): Promise<PreparedReplayPayload> {
34053437
const compressionStats: CompressionStats = {};
3406-
const prepared = await decompress(
3407-
await decrypt(value, key),
3438+
const prepared = await prepareReplayPayloadWithStats(
3439+
value,
3440+
key,
34083441
compressionStats
34093442
);
34103443
await recordCompression(compressionStats, 'deserialize');
3411-
return { data: prepared };
3412-
};
3444+
return prepared;
3445+
}
34133446

34143447
/**
34153448
* Parse a prepared workflow argument or successful step/hook payload using the
@@ -3536,7 +3569,7 @@ export async function hydrateWorkflowArguments(
35363569
prepared?: PreparedReplayPayload
35373570
): Promise<any> {
35383571
return deserializePreparedReplayPayload(
3539-
prepared ?? (await prepareReplayPayload(value, key)),
3572+
prepared ?? (await prepareReplayPayloadWithTelemetry(value, key)),
35403573
global,
35413574
extraRevivers
35423575
);
@@ -3833,7 +3866,7 @@ export async function hydrateStepError(
38333866
prepared?: PreparedReplayPayload
38343867
): Promise<unknown> {
38353868
return deserializePreparedStepError(
3836-
prepared ?? (await prepareReplayPayload(value, key)),
3869+
prepared ?? (await prepareReplayPayloadWithTelemetry(value, key)),
38373870
global,
38383871
extraRevivers
38393872
);
@@ -3958,7 +3991,7 @@ export async function hydrateStepReturnValue(
39583991
prepared?: PreparedReplayPayload
39593992
): Promise<any> {
39603993
return deserializePreparedReplayPayload(
3961-
prepared ?? (await prepareReplayPayload(value, key)),
3994+
prepared ?? (await prepareReplayPayloadWithTelemetry(value, key)),
39623995
global,
39633996
extraRevivers
39643997
);

packages/core/src/serialization/compression.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -309,10 +309,10 @@ export async function compress(
309309
* Non-compressed data (including non-binary legacy data) is returned
310310
* unchanged, so this is safe to apply unconditionally on read paths.
311311
*/
312-
export async function decompress(
312+
export function decompressReplayPayload(
313313
data: Uint8Array | unknown,
314314
stats?: CompressionStats
315-
): Promise<Uint8Array | unknown> {
315+
): Uint8Array | unknown | Promise<Uint8Array> {
316316
if (!(data instanceof Uint8Array)) return data;
317317
const prefix = peekFormatPrefix(data);
318318

@@ -332,15 +332,24 @@ export async function decompress(
332332
);
333333
}
334334
const { payload } = decodeFormatPrefix(data);
335-
const inflated = await gunzipBytes(payload);
336-
recordStats(stats, 'gzip', inflated.length, data.length);
337-
return inflated;
335+
return gunzipBytes(payload).then((inflated) => {
336+
recordStats(stats, 'gzip', inflated.length, data.length);
337+
return inflated;
338+
});
338339
}
339340

340341
recordStats(stats, 'none', data.length, data.length);
341342
return data;
342343
}
343344

345+
/** Portable always-Promise facade retained for existing callers. */
346+
export async function decompress(
347+
data: Uint8Array | unknown,
348+
stats?: CompressionStats
349+
): Promise<Uint8Array | unknown> {
350+
return decompressReplayPayload(data, stats);
351+
}
352+
344353
/**
345354
* Check if data is compressed (has a 'zstd' or 'gzip' format prefix).
346355
*/

0 commit comments

Comments
 (0)