From f96e55fc4009383fc7e0138d0a986f75000c43bf Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:38:36 -0700 Subject: [PATCH 1/3] perf(core): reuse suspension encryption key --- .changeset/reuse-suspension-encryption-key.md | 5 ++ packages/core/src/runtime.ts | 1 + .../src/runtime/suspension-handler.test.ts | 52 ++++++++++++++++++- .../core/src/runtime/suspension-handler.ts | 16 +++--- packages/core/src/workflow.ts | 4 ++ 5 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 .changeset/reuse-suspension-encryption-key.md diff --git a/.changeset/reuse-suspension-encryption-key.md b/.changeset/reuse-suspension-encryption-key.md new file mode 100644 index 0000000000..8f24a35f0a --- /dev/null +++ b/.changeset/reuse-suspension-encryption-key.md @@ -0,0 +1,5 @@ +--- +'@workflow/core': patch +--- + +Reuse the resolved workflow encryption key when serializing suspension events. diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index a48e99fde2..155e2aece4 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -1694,6 +1694,7 @@ export function workflowEntrypoint( suspension: err, world, run: workflowRun, + encryptionKey, span, requestId, eventLog: suspensionLog, diff --git a/packages/core/src/runtime/suspension-handler.test.ts b/packages/core/src/runtime/suspension-handler.test.ts index 404caea4d2..16eb2c7860 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 { importKey } from '../encryption.js'; import { WorkflowSuspension } from '../global.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,40 @@ 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 importKey(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 +131,7 @@ describe('handleSuspension', () => { suspension: new WorkflowSuspension(pending, globalThis), world, run, + encryptionKey: undefined, }); expect(eventsCreate).toHaveBeenCalledWith( @@ -136,6 +178,7 @@ describe('handleSuspension', () => { suspension: new WorkflowSuspension(pending, globalThis), world, run, + encryptionKey: undefined, }); expect(result.hasAwaitedHookCreation).toBe(true); @@ -163,6 +206,7 @@ describe('handleSuspension', () => { suspension: new WorkflowSuspension(pending, globalThis), world, run, + encryptionKey: undefined, }); expect(result.lazyInlineSteps.map((s) => s.correlationId)).toEqual([ @@ -202,6 +246,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 +294,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 +325,7 @@ describe('handleSuspension', () => { suspension: new WorkflowSuspension(pending, globalThis), world, run, + encryptionKey: undefined, }); expect(result.hasAwaitedHookCreation).toBe(false); @@ -318,6 +365,7 @@ describe('handleSuspension', () => { suspension: new WorkflowSuspension(pending, globalThis), world, run, + encryptionKey: undefined, }); const hookCalls = eventsCreate.mock.calls.map(([, event]) => ({ @@ -351,6 +399,7 @@ describe('handleSuspension', () => { suspension: new WorkflowSuspension(pending, globalThis), world, run, + encryptionKey: undefined, }); const hookCalls = eventsCreate.mock.calls.map(([, event]) => ({ @@ -387,6 +436,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 a9c102caeb..77de37002b 100644 --- a/packages/core/src/runtime/suspension-handler.ts +++ b/packages/core/src/runtime/suspension-handler.ts @@ -17,7 +17,7 @@ import { type WorkflowRun, type World, } from '@workflow/world'; -import { importKey } from '../encryption.js'; +import type { CryptoKey } from '../encryption.js'; import type { AttributeInvocationQueueItem, HookInvocationQueueItem, @@ -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: CryptoKey | undefined; span?: Span; requestId?: string; /** @@ -201,6 +207,7 @@ export async function handleSuspension({ suspension, world, run, + encryptionKey, span, requestId, eventLog, @@ -281,12 +288,7 @@ 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: only runs marked - // as possibly containing compressed payloads (spec >= 5) get gzip data. + // Gate payload compression on the run's specVersion. const compression = (run.specVersion ?? 0) >= SPEC_VERSION_SUPPORTS_COMPRESSION; diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 6c4a1ff5e7..6ab9990785 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -84,6 +84,7 @@ async function drainPendingQueueItems( pendingQueue: Map, vmGlobalThis: typeof globalThis, workflowRun: WorkflowRun, + encryptionKey: CryptoKey | undefined, outcome: 'completed' | 'failed', /** * Turbo mode only: resolves once the backgrounded `run_started` has landed. @@ -120,6 +121,7 @@ async function drainPendingQueueItems( suspension: synthesized, world, run: workflowRun, + encryptionKey, runReadyBarrier, }); } catch (err) { @@ -911,6 +913,7 @@ export async function runWorkflow( workflowContext.invocationsQueue, vmGlobalThis, workflowRun, + encryptionKey, 'completed', runReadyBarrier ); @@ -928,6 +931,7 @@ export async function runWorkflow( workflowContext.invocationsQueue, vmGlobalThis, workflowRun, + encryptionKey, 'failed', runReadyBarrier ); From 3765aef12472736d2c3e9025791eef9da0562498 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:23:06 -0700 Subject: [PATCH 2/3] Reuse start encryption key in run handles --- .changeset/reuse-suspension-encryption-key.md | 2 +- packages/core/src/runtime/run.ts | 22 ++++++++--- packages/core/src/runtime/runs.test.ts | 37 +++++++++++++++++++ packages/core/src/runtime/start.test.ts | 35 ++++++++++++++---- packages/core/src/runtime/start.ts | 2 +- 5 files changed, 82 insertions(+), 16 deletions(-) diff --git a/.changeset/reuse-suspension-encryption-key.md b/.changeset/reuse-suspension-encryption-key.md index 8f24a35f0a..f31ed41681 100644 --- a/.changeset/reuse-suspension-encryption-key.md +++ b/.changeset/reuse-suspension-encryption-key.md @@ -2,4 +2,4 @@ '@workflow/core': patch --- -Reuse the resolved workflow encryption key when serializing suspension events. +Reuse resolved workflow encryption keys during suspension handling and on run handles returned by `start()`. diff --git a/packages/core/src/runtime/run.ts b/packages/core/src/runtime/run.ts index 7ee8b9769e..ed4a0f71de 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'; @@ -114,9 +115,18 @@ export class Run { */ #resilientStart = false; - constructor(runId: string, opts?: { resilientStart?: boolean }) { + constructor( + runId: string, + opts?: { + resilientStart?: boolean; + encryptionKey?: CryptoKey | undefined; + } + ) { this.runId = runId; this.#resilientStart = opts?.resilientStart ?? false; + if (opts && 'encryptionKey' in opts) { + this.#encryptionKeyPromise = Promise.resolve(opts.encryptionKey); + } } /** @@ -125,12 +135,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 importKey(rawKey) : undefined; })(); } @@ -332,7 +342,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, @@ -348,7 +358,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/start.test.ts b/packages/core/src/runtime/start.test.ts index 4591a238df..dcf63d7fd9 100644 --- a/packages/core/src/runtime/start.test.ts +++ b/packages/core/src/runtime/start.test.ts @@ -14,7 +14,9 @@ import { it, vi, } from 'vitest'; +import { importKey } from '../encryption.js'; import { runtimeLogger } from '../logger.js'; +import { dehydrateWorkflowReturnValue } from '../serialization.js'; import type { Run } from './run.js'; import type { WorkflowFunction } from './start.js'; import { _resetLatestNoOpWarnForTests, start } from './start.js'; @@ -404,7 +406,11 @@ describe('start', () => { describe('encryption', () => { let mockEventsCreate: ReturnType; let mockQueue: ReturnType; + let mockRunsGet: ReturnType; let mockGetEncryptionKeyForRun: ReturnType; + const validWorkflow = Object.assign(() => Promise.resolve('result'), { + workflowId: 'test-workflow', + }); beforeEach(() => { mockEventsCreate = vi.fn().mockImplementation((runId) => { @@ -413,11 +419,13 @@ describe('start', () => { }); }); mockQueue = vi.fn().mockResolvedValue(undefined); + mockRunsGet = vi.fn(); mockGetEncryptionKeyForRun = vi.fn().mockResolvedValue(undefined); setWorld({ specVersion: SPEC_VERSION_CURRENT, getDeploymentId: vi.fn().mockResolvedValue('deploy_resolved'), + runs: { get: mockRunsGet }, events: { create: mockEventsCreate }, queue: mockQueue, getEncryptionKeyForRun: mockGetEncryptionKeyForRun, @@ -430,10 +438,6 @@ describe('start', () => { }); it('should pass resolved deploymentId to getEncryptionKeyForRun even when not in opts', async () => { - const validWorkflow = Object.assign(() => Promise.resolve('result'), { - workflowId: 'test-workflow', - }); - // Call start() without explicit deploymentId in options — it should // be resolved from world.getDeploymentId() and forwarded to // getEncryptionKeyForRun so the key can be fetched. @@ -448,10 +452,6 @@ describe('start', () => { }); it('should pass explicit deploymentId from opts to getEncryptionKeyForRun', async () => { - const validWorkflow = Object.assign(() => Promise.resolve('result'), { - workflowId: 'test-workflow', - }); - await start(validWorkflow, [], { deploymentId: 'deploy_explicit' }); expect(mockGetEncryptionKeyForRun).toHaveBeenCalledWith( @@ -461,6 +461,25 @@ describe('start', () => { }) ); }); + + it('should reuse the resolved key on the returned run handle', async () => { + const rawKey = new Uint8Array(32).fill(7); + mockGetEncryptionKeyForRun.mockResolvedValue(rawKey); + const run = await start(validWorkflow, []); + mockRunsGet.mockResolvedValue({ + runId: run.runId, + status: 'completed', + output: await dehydrateWorkflowReturnValue( + 'result', + run.runId, + await importKey(rawKey) + ), + }); + + await expect(run.returnValue).resolves.toBe('result'); + expect(mockRunsGet).toHaveBeenCalledTimes(1); + expect(mockGetEncryptionKeyForRun).toHaveBeenCalledTimes(1); + }); }); describe('deploymentId: latest', () => { diff --git a/packages/core/src/runtime/start.ts b/packages/core/src/runtime/start.ts index e86da84f69..5da11d8eca 100644 --- a/packages/core/src/runtime/start.ts +++ b/packages/core/src/runtime/start.ts @@ -580,7 +580,7 @@ export async function start( : {}), }); - return new Run(runId, { resilientStart }); + return new Run(runId, { resilientStart, encryptionKey }); }); }); } From 6574d45543ef4ea29d5c2aa07a9613b052f7e407 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:19:18 -0700 Subject: [PATCH 3/3] simplify payload key handling --- packages/core/src/serialization.ts | 14 +- packages/core/src/serialization/encryption.ts | 279 +++++------------- .../src/serialization/serialization.test.ts | 21 +- 3 files changed, 87 insertions(+), 227 deletions(-) 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); }); }); });