Files
vercel__workflow/.changeset/encp-sealed-box.md
Nathan Rajlich 4ba223a01c feat(core): add encp sealed-box encryption primitive (#3093)
* feat(core): add `encp` sealed-box encryption primitive

Cross-run writes (hook resumptions targeting another run, forwarded
writable stream frames) currently require the writer to hold the
recipient run's symmetric key, which also grants decrypt capability and
costs a ~350ms `run-key` API round trip across a deployment boundary.

Add the crypto foundation for sealing those writes to a public key
instead. Both keys descend from the per-run key material `K` that
`World.getEncryptionKeyForRun()` already returns, so key acquisition, the
World interface, and the Vercel API are all untouched:

    K
    ├── AES-256 key = K used directly        → 'encr' (unchanged)
    └── X25519 scalar = HKDF(K, label)       → 'encp'
        └── public key (published, not secret)

`sealed-box.ts` implements an ECIES-style construction over the same
primitives as HPKE base mode (DHKEM(X25519, HKDF-SHA256), AES-256-GCM),
binding both public keys into the KDF `info` as HPKE's `kem_context` does
to prevent key-substitution attacks. The deviation from strict RFC 9180
framing is documented, and the HKDF labels are versioned so a conformant
profile can be added later without touching existing payloads.

Nothing produces `encp` payloads yet — this is the primitive only. The
o11y layer is hardened defensively so sealed payloads render as
ciphertext rather than throwing `Unsupported serialization format`, and
`hydrateDataWithKey` skips the AES path for them since opening a sealed
payload needs the private scalar rather than the symmetric key.

- optional AAD on the AES helpers, used to bind `projectId|runId`
- `encapsulate`/`decapsulate` split so stream writers can amortize the
  KEM across frames; documented that they must keep random per-frame
  nonces and re-encapsulate per connection attempt, since a long-lived
  content key plus counter nonces would repeat `(key, nonce)` after a
  reconnect or a durable replay
- public key derivation is cross-validated against node:crypto's native
  X25519 in tests, since it reads the public half out of a JWK export

* review: tighten sealed-box docs, key-length checks, and constants

Addresses review feedback on the sealed-box primitive:

- The module doc pointed at `getSerializeStream` as enforcing the
  re-encapsulate-per-writer rule, but nothing in-tree uses `encapsulate`
  yet, and the stream path added later seals per frame instead. Reworded
  to state the two rules as the caller's contract, since this module
  enforces neither.
- `derivePublicKeyFromScalar` now asserts the JWK-derived public key is
  32 bytes. That decode is the one place this module trusts an external
  encoding; a short value would otherwise fail much later inside key
  agreement with a far less obvious message.
- `open()` used bare 12/16 for the nonce and tag sizes. Those now come
  from exported `NONCE_LENGTH`/`TAG_BYTES` in the AES layer, so the wire
  format check cannot drift from the implementation.
2026-07-27 12:35:22 -07:00

238 B

@workflow/core
@workflow/core
minor

Add the encp sealed-box encryption primitive: X25519 keypairs derived from the existing per-run key material, letting cross-run writers encrypt with only a public key. Not yet wired into any write path.