Files
vercel__workflow/.changeset/encp-resume-hook-seal.md
Nathan Rajlich 0b1ca15dec feat(core): seal hook payloads to the target run's public key (#3096)
* feat(core): seal hook payloads to the target run's public key

This is the payoff for the sealed-box work: cross-deployment
`resumeHook()` no longer calls `getEncryptionKeyForRun`, which on Vercel
means a ~350ms `run-key` API round trip (Cosmos reads, a 50–75KB
`encrypted_env.json` fetch from S3, up to three KMS decrypts) to recover
32 bytes. When the target run publishes a public key, the resumer seals to
it using only the run entity it already fetched.

More than half of `resumeHook()` calls miss the same-deployment fast path,
so this is the dominant term in hook-resumption latency — and for Eve,
hook resumption is what an agent turn waits on.

It also reduces privilege. Fetching the symmetric key grants read access
to everything in the run; sealing grants only the ability to write one
payload to it. A resumer is now cryptographically unable to read the run
it resumes.

Sealing is gated on the presence of `encryptionPublicKey`, deliberately
*not* on the capability version table the way `encr` and `gzip` are. A run
only carries a public key if the runtime that created it could also open a
sealed payload, and runs are pinned to their creating deployment — so
presence is a stronger attestation than comparing versions, and it stays
correct when `@workflow/core` and `@workflow/world-vercel` versions drift
independently (each single-axis version gate wedges a run under one drift
direction; presence wedges under neither).

`resumeWebhook` keeps using the symmetric key it already had to fetch in
order to hydrate hook metadata — sealing there would add an ECDH without
saving a round trip.

**Read path.** Sealing is useless if the run cannot open the result, and
every reader previously resolved a bare symmetric `CryptoKey`, which by
construction cannot open `encp`. Key resolution now yields the full
capability (symmetric key + X25519 keypair) at every read site:
`memoizeEncryptionKey`, the `Run` class, `runs.ts`, and
`getHookByTokenWithKey`. Without this the first sealed hook payload would
have wedged its run with `RuntimeDecryptionError`. Holder types widen from
`CryptoKey` to `PayloadKey`, which is a pure widening.

Falls back to the symmetric path when the run has no public key (older
SDKs), when the stored value is malformed, or when encryption is off — all
covered by tests, along with an end-to-end assertion that a sealed payload
actually hydrates with the keys the owning deployment re-derives.

* review: correct stale docs on the key-resolution path

- `memoizeEncryptionKey`'s JSDoc still described importing an AES-256
  `CryptoKey`. It now resolves a run's full capability (symmetric key plus
  X25519 keypair), and the reason matters: a run reading its own event log
  can meet sealed payloads another run wrote to it, and resolving only the
  symmetric key would leave those unopenable. Documented explicitly so the
  next reader does not "simplify" it back.
- A comment in `resumeHook` pointed at the `encr` capability gate as being
  "above" when it is in the fallback branch below. Reworded.
- Added the `decodeRunPublicKey` boundary test that belongs with the strict
  base64 work but needed this branch's code to exist.
2026-07-27 18:45:25 -07:00

283 B

@workflow/core
@workflow/core
minor

resumeHook() now seals its payload to the target run's published public key when one is present, removing the cross-deployment run-key API round trip from hook resumption. Readers resolve the full key capability so they can open sealed payloads.