diff --git a/.changeset/reuse-suspension-encryption-key.md b/.changeset/reuse-suspension-encryption-key.md new file mode 100644 index 0000000000..6039138721 --- /dev/null +++ b/.changeset/reuse-suspension-encryption-key.md @@ -0,0 +1,5 @@ +--- +'@workflow/core': patch +--- + +Reuse resolved workflow encryption keys during suspension handling and avoid duplicate run lookups while hydrating return values. diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 20094392fa..672b065138 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -1640,6 +1640,7 @@ export function workflowEntrypoint( suspension: err, world, run: workflowRun, + encryptionKey, span, requestId, eventLog: suspensionLog, diff --git a/packages/core/src/runtime/run.ts b/packages/core/src/runtime/run.ts index 41307388f2..4c3ccc16b6 100644 --- a/packages/core/src/runtime/run.ts +++ b/packages/core/src/runtime/run.ts @@ -7,6 +7,7 @@ import { import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from '@workflow/serde'; import { SPEC_VERSION_CURRENT, + type WorkflowRun, type WorkflowRunStatus, type World, } from '@workflow/world'; @@ -128,12 +129,12 @@ export class Run { * to be resolved once. * @internal */ - #getEncryptionKey(): Promise { + #getEncryptionKey(run?: WorkflowRun): Promise { if (!this.#encryptionKeyPromise) { this.#encryptionKeyPromise = (async () => { const world = await this.#lazyWorldPromise; - const run = await world.runs.get(this.runId); - const rawKey = await world.getEncryptionKeyForRun?.(run); + const resolvedRun = run ?? (await world.runs.get(this.runId)); + const rawKey = await world.getEncryptionKeyForRun?.(resolvedRun); return rawKey ? await deriveRunPayloadKeys(rawKey) : undefined; })(); } @@ -335,7 +336,7 @@ export class Run { const run = await world.runs.get(this.runId); if (run.status === 'completed') { - const encryptionKey = await this.#getEncryptionKey(); + const encryptionKey = await this.#getEncryptionKey(run); return await hydrateWorkflowReturnValue( run.output, this.runId, @@ -351,7 +352,7 @@ export class Run { // Hydrate the serialized run error so the original thrown value // (with its type identity, cause chain, etc.) is set as the // `cause` on WorkflowRunFailedError. - const encryptionKey = await this.#getEncryptionKey(); + const encryptionKey = await this.#getEncryptionKey(run); let hydratedError: unknown; try { hydratedError = await hydrateRunError( diff --git a/packages/core/src/runtime/runs.test.ts b/packages/core/src/runtime/runs.test.ts index 73ec1f1efc..57c8d3cf14 100644 --- a/packages/core/src/runtime/runs.test.ts +++ b/packages/core/src/runtime/runs.test.ts @@ -25,9 +25,11 @@ vi.mock('../serialization.js', async (importActual) => { }); import { registerSerializationClass } from '../class-serialization.js'; +import { importKey } from '../encryption.js'; import { dehydrateRunError, dehydrateStepReturnValue, + dehydrateWorkflowReturnValue, hydrateStepReturnValue, } from '../serialization.js'; import { Run } from './run.js'; @@ -396,6 +398,41 @@ describe('Run custom serialization', () => { }); }); +describe('Run.returnValue encryption key resolution', () => { + afterEach(() => { + setWorld(undefined as unknown as World); + }); + + it('reuses the polled run when resolving its encryption key', async () => { + const rawKey = new Uint8Array(32).fill(7); + const encryptionKey = await importKey(rawKey); + const world = createMockWorld({ + run: { + status: 'completed', + output: await dehydrateWorkflowReturnValue( + 'result', + 'wrun_123', + encryptionKey + ), + completedAt: new Date(), + }, + }); + const getEncryptionKeyForRun = vi.fn().mockResolvedValue(rawKey); + world.getEncryptionKeyForRun = getEncryptionKeyForRun; + setWorld(world); + + await expect(new Run('wrun_123').returnValue).resolves.toBe('result'); + + expect(world.runs.get).toHaveBeenCalledTimes(1); + expect(getEncryptionKeyForRun).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 'wrun_123', + status: 'completed', + }) + ); + }); +}); + describe('Run.returnValue when run.status === "failed"', () => { // Register the FatalError class so the run-error serialization pipeline // can find it during hydration (the SWC plugin does this in production). diff --git a/packages/core/src/runtime/suspension-handler.test.ts b/packages/core/src/runtime/suspension-handler.test.ts index 404caea4d2..cf6701318c 100644 --- a/packages/core/src/runtime/suspension-handler.test.ts +++ b/packages/core/src/runtime/suspension-handler.test.ts @@ -1,6 +1,12 @@ -import type { WorkflowRun, World } from '@workflow/world'; +import type { CreateEventRequest, WorkflowRun, World } from '@workflow/world'; import { describe, expect, it, vi } from 'vitest'; import { WorkflowSuspension } from '../global.js'; +import { deriveRunPayloadKeys } from '../serialization/encryption.js'; +import { + hydrateStepArguments, + peekFormatPrefix, + SerializationFormat, +} from '../serialization.js'; import { handleSuspension } from './suspension-handler.js'; vi.mock('../version.js', () => ({ version: '0.0.0-test' })); @@ -52,6 +58,7 @@ describe('handleSuspension', () => { suspension: new WorkflowSuspension(pending, globalThis), world, run, + encryptionKey: undefined, }); expect(eventsCreate).toHaveBeenCalledWith( @@ -67,6 +74,42 @@ describe('handleSuspension', () => { ); }); + it('uses the supplied encryption key without resolving it again', async () => { + const eventsCreate = vi.fn().mockImplementation(async (_runId, event) => ({ + event, + })); + const world = createWorld(eventsCreate); + const encryptionKey = await deriveRunPayloadKeys( + new Uint8Array(32).fill(7) + ); + const pending = new Map([ + [ + 'hook_with_metadata', + { + type: 'hook' as const, + correlationId: 'hook_with_metadata', + token: 'order:encrypted', + metadata: { visibility: 'private' }, + }, + ], + ]); + + await handleSuspension({ + suspension: new WorkflowSuspension(pending, globalThis), + world, + run, + encryptionKey, + }); + + expect(world.getEncryptionKeyForRun).not.toHaveBeenCalled(); + const createdEvent = eventsCreate.mock.calls[0]?.[1] as CreateEventRequest; + const metadata = createdEvent.eventData?.metadata as Uint8Array; + expect(peekFormatPrefix(metadata)).toBe(SerializationFormat.ENCRYPTED); + await expect( + hydrateStepArguments(metadata, run.runId, encryptionKey) + ).resolves.toEqual({ visibility: 'private' }); + }); + it('marks hook.getConflict()-awaited creations without converting them into wait timeouts', async () => { const eventsCreate = vi.fn().mockResolvedValue({ event: { @@ -90,6 +133,7 @@ describe('handleSuspension', () => { suspension: new WorkflowSuspension(pending, globalThis), world, run, + encryptionKey: undefined, }); expect(eventsCreate).toHaveBeenCalledWith( @@ -136,6 +180,7 @@ describe('handleSuspension', () => { suspension: new WorkflowSuspension(pending, globalThis), world, run, + encryptionKey: undefined, }); expect(result.hasAwaitedHookCreation).toBe(true); @@ -163,6 +208,7 @@ describe('handleSuspension', () => { suspension: new WorkflowSuspension(pending, globalThis), world, run, + encryptionKey: undefined, }); expect(result.lazyInlineSteps.map((s) => s.correlationId)).toEqual([ @@ -202,6 +248,7 @@ describe('handleSuspension', () => { suspension: new WorkflowSuspension(pending, globalThis), world, run, + encryptionKey: undefined, }); // Cap of 1: only the first step is deferred; s2 and s3 are eager-created. @@ -249,6 +296,7 @@ describe('handleSuspension', () => { suspension: new WorkflowSuspension(pending, globalThis), world, run, + encryptionKey: undefined, }); // Nothing runs inline: the step keeps its eager step_created (owned) and is @@ -279,6 +327,7 @@ describe('handleSuspension', () => { suspension: new WorkflowSuspension(pending, globalThis), world, run, + encryptionKey: undefined, }); expect(result.hasAwaitedHookCreation).toBe(false); @@ -318,6 +367,7 @@ describe('handleSuspension', () => { suspension: new WorkflowSuspension(pending, globalThis), world, run, + encryptionKey: undefined, }); const hookCalls = eventsCreate.mock.calls.map(([, event]) => ({ @@ -351,6 +401,7 @@ describe('handleSuspension', () => { suspension: new WorkflowSuspension(pending, globalThis), world, run, + encryptionKey: undefined, }); const hookCalls = eventsCreate.mock.calls.map(([, event]) => ({ @@ -387,6 +438,7 @@ describe('handleSuspension', () => { suspension: new WorkflowSuspension(pending, globalThis), world, run, + encryptionKey: undefined, }); expect(result.hasHookConflict).toBe(true); diff --git a/packages/core/src/runtime/suspension-handler.ts b/packages/core/src/runtime/suspension-handler.ts index 921986d7bd..00373c23d4 100644 --- a/packages/core/src/runtime/suspension-handler.ts +++ b/packages/core/src/runtime/suspension-handler.ts @@ -17,7 +17,6 @@ import { type WorkflowRun, type World, } from '@workflow/world'; -import { importKey } from '../encryption.js'; import type { AttributeInvocationQueueItem, HookInvocationQueueItem, @@ -26,6 +25,7 @@ import type { WorkflowSuspension, } from '../global.js'; import { runtimeLogger } from '../logger.js'; +import type { PayloadKey } from '../serialization/encryption.js'; import { dehydrateStepArguments } from '../serialization.js'; import * as Attribute from '../telemetry/semantic-conventions.js'; import { getAbortStreamIdFromToken } from '../util.js'; @@ -36,6 +36,12 @@ export interface SuspensionHandlerParams { suspension: WorkflowSuspension; world: World; run: WorkflowRun; + /** + * The run's already-resolved encryption key. The workflow runtime shares + * this with replay payload hydration and suspension serialization so the + * World is not asked to derive or fetch the same key twice. + */ + encryptionKey: PayloadKey | undefined; span?: Span; requestId?: string; /** @@ -201,6 +207,7 @@ export async function handleSuspension({ suspension, world, run, + encryptionKey, span, requestId, eventLog, @@ -281,10 +288,6 @@ export async function handleSuspension({ } } - // Resolve encryption key for this run - const rawKey = await world.getEncryptionKeyForRun?.(run); - const encryptionKey = rawKey ? await importKey(rawKey) : undefined; - // Gate payload compression on the run's specVersion. const compression = (run.specVersion ?? 0) >= SPEC_VERSION_SUPPORTS_COMPRESSION; diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts index 11251df91b..4b05414977 100644 --- a/packages/core/src/serialization.ts +++ b/packages/core/src/serialization.ts @@ -6,7 +6,6 @@ import { import { envNumber } from '@workflow/world'; import { parse, stringify, unflatten } from 'devalue'; import { monotonicFactory } from 'ulid'; -import { bytesToBase64, decodeRunPublicKey } from './sealed-box.js'; import { importKey } from './encryption.js'; import { createFlushableState, @@ -26,7 +25,12 @@ import { getStepFunction } from './private.js'; // "Turbopack NFT Tracing Errors in V2 Combined Flow Route" section of // `docs/content/docs/changelog/eager-processing.mdx`. import { getWorldLazy } from './runtime/get-world-lazy.js'; -import { createOpenSession, createSealSession } from './sealed-box.js'; +import { + bytesToBase64, + createOpenSession, + createSealSession, + decodeRunPublicKey, +} from './sealed-box.js'; import * as clientModule from './serialization/client.js'; import { type CompressionStats, @@ -34,7 +38,6 @@ import { decompress, } from './serialization/compression.js'; import { - aesKeyOf, decrypt, deriveRunPayloadKeys, type EncryptionKeyParam, @@ -42,8 +45,8 @@ import { isRunPayloadKeys, isSealTarget, type PayloadKey, - resolveEncryptionKey, type RunPayloadKeys, + resolveEncryptionKey, runPayloadKeys, type SealTarget, sealTo, @@ -124,7 +127,6 @@ export { deriveRunPayloadKeys, isSealTarget, isRunPayloadKeys, - aesKeyOf, }; // Re-export the legacy SerializationFormatType for backwards compatibility. @@ -382,7 +384,7 @@ export function getDeserializeStream( // wrong kind of key" have very different causes. const usable = sealed ? isRunPayloadKeys(keyState.key) - : aesKeyOf(keyState.key) !== undefined; + : keyState.key !== undefined && !isSealTarget(keyState.key); if (!usable) { controller.error( new RuntimeDecryptionError( diff --git a/packages/core/src/serialization/encryption.ts b/packages/core/src/serialization/encryption.ts index e3942d0ce2..51ddd7a7d4 100644 --- a/packages/core/src/serialization/encryption.ts +++ b/packages/core/src/serialization/encryption.ts @@ -1,12 +1,3 @@ -/** - * Composable encryption layer for serialized data. - * - * Wraps/unwraps serialized payloads with either symmetric AES-256-GCM - * encryption (`encr`) or an asymmetric sealed box (`encp`), using the format - * prefix system to mark which. See {@link PayloadKey} for how a caller - * declares which capabilities it holds. - */ - import { RuntimeDecryptionError } from '@workflow/errors'; import { decrypt as aesGcmDecrypt, @@ -29,118 +20,51 @@ import { SerializationFormat } from './types.js'; export type { CryptoKey, RunKeyPair }; -/** - * Brands distinguishing the structured key variants from a bare `CryptoKey`. - * - * `Symbol.for` rather than `Symbol()` because these values cross realm - * boundaries (host ↔ workflow VM), and the global symbol registry is shared - * across realms while unique symbols are not. - */ -const SEAL_TARGET_BRAND = Symbol.for('workflow.serialization.sealTarget'); -const RUN_KEYS_BRAND = Symbol.for('workflow.serialization.runKeys'); - /** * A write-only capability: seal payloads to a run's X25519 public key. - * - * This is what a *cross-run* writer holds — a hook resumption targeting - * another run, or a child workflow writing into a parent's forwarded stream. - * It carries no ability to decrypt anything, and because it is a distinct - * nominal type it cannot be mistaken for symmetric key material. - * - * That last property matters more than it looks: a raw 32-byte X25519 public - * key is a *structurally valid* AES-256 key, so handing one to - * `importKey(..., 'AES-GCM')` succeeds and silently produces ciphertext that - * nobody can ever open. Routing public keys exclusively through - * {@link sealTo} makes that mistake unrepresentable rather than merely - * discouraged. */ export interface SealTarget { - readonly [SEAL_TARGET_BRAND]: true; - /** The recipient run's raw 32-byte X25519 public key. */ + readonly kind: 'seal'; readonly recipientPublicKey: Uint8Array; - /** Additional authenticated data binding the payload to the recipient run. */ - readonly aad?: Uint8Array; + readonly aad: Uint8Array | undefined; } /** - * The full key capability of a run's *own* runtime (and of o11y tooling - * acting on its behalf): the symmetric key for its own payloads, plus the - * keypair needed to open sealed payloads that other runs wrote to it. + * A run's own AES key plus the keypair used to open sealed payloads. */ export interface RunPayloadKeys { - readonly [RUN_KEYS_BRAND]: true; - /** Symmetric AES-256-GCM key — the run's own `encr` payloads. */ + readonly kind: 'run'; readonly aes: CryptoKey; - /** X25519 keypair — opens `encp` payloads sealed to this run. */ readonly keyPair: RunKeyPair; - /** Additional authenticated data expected on sealed payloads. */ - readonly aad?: Uint8Array; + readonly aad: Uint8Array | undefined; } /** - * A resolved key for reading or writing a serialized payload. - * - * The bare `CryptoKey` variant is the historical shape and remains valid: it - * means "symmetric only", which is exactly right for a run that predates - * sealed boxes or for any same-run payload. The structured variants are - * additive: - * - * | Variant | Writes | Reads | Held by | - * | ------------------ | ------- | -------------- | ------------------------ | - * | `CryptoKey` | `encr` | `encr` | same-run (legacy shape) | - * | {@link RunPayloadKeys} | `encr` | `encr`, `encp` | the owning run, o11y | - * | {@link SealTarget} | `encp` | — | cross-run writers | - * - * A run's own payloads deliberately stay symmetric even when the writer could - * seal: sealing costs a fresh ECDH per envelope and 32 extra bytes, and buys - * nothing when the writer already holds the decryption key. + * A bare `CryptoKey` is the legacy symmetric-only shape. SDK-owned key + * capabilities use `kind` so they can be handled exhaustively. */ export type PayloadKey = CryptoKey | SealTarget | RunPayloadKeys; -/** - * The subset of {@link PayloadKey} that can actually *read* a payload. - * - * Excludes {@link SealTarget}, which is write-only by construction: it holds a - * public key, so it can open neither `encp` (needs the private scalar) nor - * `encr` (needs the symmetric key). Decrypt-side signatures should take this - * rather than `PayloadKey`, so passing a seal target is a compile error instead - * of a guaranteed runtime failure. - */ +/** Key capabilities that can decrypt payloads. */ export type DecryptionKey = CryptoKey | RunPayloadKeys; -/** - * Build a write-only seal capability for a recipient run's public key. - * - * @param recipientPublicKey - The recipient run's raw 32-byte X25519 public key - * @param aad - Additional authenticated data, conventionally `runAad(projectId, runId)` - */ export function sealTo( recipientPublicKey: Uint8Array, aad?: Uint8Array ): SealTarget { - return { [SEAL_TARGET_BRAND]: true, recipientPublicKey, aad }; + return { kind: 'seal', recipientPublicKey, aad }; } -/** - * Bundle a run's symmetric key with the keypair that opens payloads sealed - * to it. - */ export function runPayloadKeys( aes: CryptoKey, keyPair: RunKeyPair, aad?: Uint8Array ): RunPayloadKeys { - return { [RUN_KEYS_BRAND]: true, aes, keyPair, aad }; + return { kind: 'run', aes, keyPair, aad }; } /** - * Build the full key capability for a run from its raw 32-byte key material — - * the value `World.getEncryptionKeyForRun()` returns. - * - * Use this anywhere a run reads its own event log: it yields a key that opens - * both its own symmetric (`encr`) payloads and sealed (`encp`) payloads other - * runs wrote to it. Resolving only `importKey(material)` would leave the - * reader unable to open sealed writes. + * Derive every key needed to read a run's symmetric and sealed payloads. */ export async function deriveRunPayloadKeys( runKeyMaterial: Uint8Array @@ -152,36 +76,8 @@ export async function deriveRunPayloadKeys( return runPayloadKeys(aes, keyPair); } -export function isSealTarget(value: unknown): value is SealTarget { - return ( - typeof value === 'object' && - value !== null && - (value as Partial)[SEAL_TARGET_BRAND] === true - ); -} - -export function isRunPayloadKeys(value: unknown): value is RunPayloadKeys { - return ( - typeof value === 'object' && - value !== null && - (value as Partial)[RUN_KEYS_BRAND] === true - ); -} - -/** - * The symmetric key to use for `encr` operations, or undefined when this key - * has no symmetric capability (i.e. it is a seal-only target). - */ -export function aesKeyOf(key: PayloadKey | undefined): CryptoKey | undefined { - if (!key) return undefined; - if (isRunPayloadKeys(key)) return key.aes; - if (isSealTarget(key)) return undefined; - return key; -} - /** - * Encryption key parameter type. Accepts a resolved key, undefined (no encryption), - * a promise, or a resolver that can defer fetching the key until data needs it. + * A key can be resolved eagerly or lazily. `undefined` disables encryption. */ export type EncryptionKeyParam = | PayloadKey @@ -189,110 +85,93 @@ export type EncryptionKeyParam = | Promise | (() => Promise); +export function isSealTarget(key: PayloadKey | undefined): key is SealTarget { + return key !== undefined && 'kind' in key && key.kind === 'seal'; +} + +export function isRunPayloadKeys( + key: EncryptionKeyParam +): key is RunPayloadKeys { + return typeof key === 'object' && 'kind' in key && key.kind === 'run'; +} + export async function resolveEncryptionKey( key: EncryptionKeyParam ): Promise { return typeof key === 'function' ? key() : key; } -/** - * Encrypt a format-prefixed payload if a key is provided. - * - * Wraps the data with the `encr` prefix for symmetric keys, or the `encp` - * prefix when handed a {@link SealTarget} — a cross-run writer that holds only - * the recipient's public key. - * - * @param data - The format-prefixed serialized data - * @param key - Encryption key (undefined to skip encryption) - * @returns The encrypted data with its format prefix, or the original data if no key - */ -export async function encrypt( - data: Uint8Array | unknown, - key: PayloadKey | undefined -): Promise { - if (!key || !(data instanceof Uint8Array)) return data; - - if (isSealTarget(key)) { - const sealed = await sealToPublicKey(key.recipientPublicKey, data, key.aad); - return encodeWithFormatPrefix(SerializationFormat.SEALED, sealed); +function attachFormatPrefix(error: unknown, formatPrefix: string): void { + if (RuntimeDecryptionError.is(error) && error.context) { + error.context.formatPrefix = formatPrefix; } - - // Symmetric path — a run's own payloads, including when the caller holds - // the full `RunPayloadKeys` bundle and *could* seal. See `PayloadKey`. - const aesKey = isRunPayloadKeys(key) ? key.aes : key; - const encrypted = await aesGcmEncrypt(aesKey, data); - return encodeWithFormatPrefix(SerializationFormat.ENCRYPTED, encrypted); } -/** - * Decrypt a format-prefixed payload if it's encrypted or sealed. - * - * Strips the `encr`/`encp` format prefix and recovers the inner payload. - * Opening a sealed (`encp`) payload requires the run's X25519 keypair, so the - * caller must supply {@link RunPayloadKeys} — a bare symmetric key cannot do - * it, and neither can a {@link SealTarget} (which is write-only by design). - * - * @param data - The potentially encrypted data - * @param key - Encryption key (undefined to skip decryption) - * @returns The decrypted inner payload, or the original data if not encrypted - */ -/** - * Open a sealed (`encp`) envelope. Split out from {@link decrypt} to keep - * each scheme's error handling readable on its own. - */ -async function openSealedEnvelope( - data: Uint8Array, +export async function encrypt( + data: unknown, key: PayloadKey | undefined -): Promise { - // Sealed payloads need the private scalar. Anything else — no key, a bare - // symmetric key, or a write-only seal target — cannot open them. - if (!isRunPayloadKeys(key)) { - throw new RuntimeDecryptionError( - 'Sealed data encountered but no run keypair is available. ' + - "Opening a sealed payload requires the run's own encryption key " + - 'material; a symmetric key or a seal-only target cannot decrypt it.', - { - context: { - operation: 'decrypt', - byteLength: data.byteLength, - formatPrefix: 'encp', - }, - } - ); - } +): Promise { + if (!key || !(data instanceof Uint8Array)) return data; - const { payload } = decodeFormatPrefix(data); - try { - return await openSealed(key.keyPair, payload, key.aad); - } catch (error) { - // The sealed-box layer only sees the stripped payload, so it cannot - // record the outer envelope prefix. Enrich it here before rethrowing. - if (RuntimeDecryptionError.is(error) && error.context) { - error.context.formatPrefix = SerializationFormat.SEALED; + if ('kind' in key) { + switch (key.kind) { + case 'seal': { + const sealed = await sealToPublicKey( + key.recipientPublicKey, + data, + key.aad + ); + return encodeWithFormatPrefix(SerializationFormat.SEALED, sealed); + } + case 'run': + key = key.aes; + break; + default: + key satisfies never; + throw new TypeError('Unknown payload key kind'); } - throw error; } + + const encrypted = await aesGcmEncrypt(key, data); + return encodeWithFormatPrefix(SerializationFormat.ENCRYPTED, encrypted); } export async function decrypt( - data: Uint8Array | unknown, + data: unknown, key: PayloadKey | undefined -): Promise { - // Non-binary data is returned as-is. +): Promise { if (!(data instanceof Uint8Array)) return data; const format = peekFormatPrefix(data); if (format === SerializationFormat.SEALED) { - return openSealedEnvelope(data, key); + if (!isRunPayloadKeys(key)) { + throw new RuntimeDecryptionError( + 'Sealed data encountered but no run keypair is available. ' + + "Opening a sealed payload requires the run's own encryption key " + + 'material; a symmetric key or a seal-only target cannot decrypt it.', + { + context: { + operation: 'decrypt', + byteLength: data.byteLength, + formatPrefix: 'encp', + }, + } + ); + } + + const { payload } = decodeFormatPrefix(data); + try { + return await openSealed(key.keyPair, payload, key.aad); + } catch (error) { + attachFormatPrefix(error, format); + throw error; + } } - // If the data is not encrypted, return it unchanged. if (format !== SerializationFormat.ENCRYPTED) return data; - // If the data is encrypted but no symmetric key is available, fail fast. - const aesKey = aesKeyOf(key); - if (!aesKey) { + if (!key || isSealTarget(key)) { throw new RuntimeDecryptionError( 'Encrypted data encountered but no encryption key is available. ' + 'Encryption is not configured or no key was provided for this run.', @@ -306,17 +185,13 @@ export async function decrypt( ); } + if (isRunPayloadKeys(key)) key = key.aes; + const { payload } = decodeFormatPrefix(data); try { - return await aesGcmDecrypt(aesKey, payload); + return await aesGcmDecrypt(key, payload); } catch (error) { - // The low-level AES layer only sees the stripped payload, so it cannot - // record the outer envelope prefix. This layer peeked it (`encr`), so - // enrich the diagnostic context with the real format prefix before - // rethrowing. - if (RuntimeDecryptionError.is(error) && error.context) { - error.context.formatPrefix = format; - } + attachFormatPrefix(error, format); throw error; } } diff --git a/packages/core/src/serialization/serialization.test.ts b/packages/core/src/serialization/serialization.test.ts index d711dec538..241010c06a 100644 --- a/packages/core/src/serialization/serialization.test.ts +++ b/packages/core/src/serialization/serialization.test.ts @@ -7,7 +7,6 @@ import { deriveRunKeyPair, runAad } from '../sealed-box.js'; import * as client from './client.js'; import { devalueCodec } from './codec-devalue.js'; import { - aesKeyOf, decrypt, encrypt, isRunPayloadKeys, @@ -508,8 +507,8 @@ describe('sealed envelopes', () => { }); }); - describe('type-confusion guards', () => { - it('distinguishes a seal target from a symmetric key at runtime', async () => { + describe('key capabilities', () => { + it('distinguishes seal targets from run keys', async () => { const { aes, keyPair } = await makeRunKeys(); const target = sealTo(keyPair.publicKey); @@ -517,22 +516,6 @@ describe('sealed envelopes', () => { expect(isSealTarget(aes)).toBe(false); expect(isRunPayloadKeys(target)).toBe(false); expect(isRunPayloadKeys(runPayloadKeys(aes, keyPair))).toBe(true); - - // `aesKeyOf` is the single funnel every symmetric operation goes - // through, so a seal target can never reach AES-GCM as a key. - expect(aesKeyOf(target)).toBeUndefined(); - expect(aesKeyOf(aes)).toBe(aes); - expect(aesKeyOf(runPayloadKeys(aes, keyPair))).toBe(aes); - expect(aesKeyOf(undefined)).toBeUndefined(); - }); - - it('does not treat plain objects as key variants', () => { - expect(isSealTarget({ recipientPublicKey: new Uint8Array(32) })).toBe( - false - ); - expect(isSealTarget(null)).toBe(false); - expect(isSealTarget(new Uint8Array(32))).toBe(false); - expect(isRunPayloadKeys({ aes: 1, keyPair: 2 })).toBe(false); }); }); }); diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 3f73899687..886101a045 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -10,7 +10,6 @@ import type { Event, WorkflowRun, WorldCapabilities } from '@workflow/world'; import { SPEC_VERSION_SUPPORTS_COMPRESSION } from '@workflow/world'; import * as nanoid from 'nanoid'; import { monotonicFactory } from 'ulid'; -import type { PayloadKey } from './serialization/encryption.js'; import { EventConsumerResult, EventsConsumer } from './events-consumer.js'; import type { QueueItem } from './global.js'; import { ENOTSUP, WorkflowSuspension } from './global.js'; @@ -21,6 +20,7 @@ import { getPortLazy } from './runtime/get-port-lazy.js'; import { runIdCreatedAt } from './runtime/run-id-time.js'; import { handleSuspension } from './runtime/suspension-handler.js'; import { getWorld } from './runtime/world.js'; +import type { PayloadKey } from './serialization/encryption.js'; import { dehydrateWorkflowReturnValue, hydrateWorkflowArguments, @@ -68,6 +68,7 @@ async function drainPendingQueueItems( pendingQueue: Map, vmGlobalThis: typeof globalThis, workflowRun: WorkflowRun, + encryptionKey: PayloadKey | undefined, outcome: 'completed' | 'failed', /** * In turbo mode, gates final `*_created` writes on backgrounded @@ -99,6 +100,7 @@ async function drainPendingQueueItems( suspension: synthesized, world, run: workflowRun, + encryptionKey, runReadyBarrier, }); } catch (err) { @@ -850,6 +852,7 @@ export async function runWorkflow( workflowContext.invocationsQueue, vmGlobalThis, workflowRun, + encryptionKey, 'completed', runReadyBarrier ); @@ -867,6 +870,7 @@ export async function runWorkflow( workflowContext.invocationsQueue, vmGlobalThis, workflowRun, + encryptionKey, 'failed', runReadyBarrier );