Files
vercel__workflow/.changeset/encp-payload-key-union.md
Nathan Rajlich 7a6503048a feat(core): route sealed envelopes through the serialization layer (#3094)
* feat(core): route sealed envelopes through the serialization layer

Adds the plumbing that lets a cross-run writer emit `encp` payloads. The
serialization layer previously had no way to express "seal to this public
key": every consumer expected a symmetric `CryptoKey`, and the encrypt
primitive was unconditionally AES-GCM.

That gap was also a live footgun. A 32-byte X25519 public key is a
structurally valid AES-256 key, so `importKey(pubkey, 'AES-GCM')`
succeeds and silently produces ciphertext nobody can ever open — no
compile error, no runtime error, just unreadable data. Making public keys
reachable only through `sealTo()` turns that mistake from "avoided by
convention" into "unrepresentable".

`PayloadKey` is a widening rather than a replacement, so no existing call
site changes:

  | Variant          | Writes | Reads        | Held by                 |
  | ---------------- | ------ | ------------ | ----------------------- |
  | `CryptoKey`      | encr   | encr         | same-run (legacy shape) |
  | `RunPayloadKeys` | encr   | encr, encp   | the owning run, o11y    |
  | `SealTarget`     | encp   | —            | cross-run writers       |

A run's own payloads deliberately stay symmetric even when the holder
could seal: sealing costs a fresh ECDH and 32 bytes per envelope and buys
nothing when the writer already holds the decryption key.

The variants are branded with `Symbol.for` (not `Symbol()`) because these
values cross the host ↔ workflow VM realm boundary, where only the global
symbol registry is shared.

Both stream directions handle sealed frames, keeping the length header in
the clear so frame boundaries stay findable without a key. Frames keep
per-frame random nonces rather than a counter — a reconnect or a durable
replay restarts the writer, and a counter would repeat `(key, nonce)` and
break AES-GCM catastrophically. Regression tests assert that 100 frames
of identical plaintext produce 100 distinct ciphertexts, and that ten
writer incarnations never share a content key.

Capability shortfalls fail loudly and specifically: opening a sealed
payload with a symmetric key (or a write-only seal target, or nothing)
reports "no run keypair is available" rather than surfacing as a
mysterious auth-tag failure further down.

* review: amortize the sealed-box KEM across stream frames

Sealing each frame independently meant a fresh X25519 keygen + ECDH +
HKDF per chunk — O(frames) crypto for a long stream. As review pointed
out, amortizing the KEM is safe here so long as nonces stay random, which
they already are; the hazard I had been guarding against was counter
nonces specifically, not a long-lived content key.

`createSealSession` performs one encapsulation per writer instance and
reuses the content key, with a fresh random nonce per frame.
`createOpenSession` mirrors it on the read side, caching decapsulation by
ephemeral public key — since every frame from one writer carries the same
key, that is an ECDH per writer rather than per frame.

Both safety properties are preserved and now asserted directly:

- nonces stay random, so sharing a content key cannot repeat
  `(key, nonce)` (100 identical-plaintext frames -> 100 distinct frames)
- a session is scoped to one stream instance, so a reconnect or durable
  replay never inherits a previous content key (10 writer incarnations ->
  10 distinct ephemeral keys)

The envelope layout is byte-identical to one-shot `seal`, so readers
cannot tell which path produced a frame and need no matching session.

Two edge cases the read-side cache introduces, both covered: frames from
two writers interleaved on one stream (eviction on every frame, so
correctness cannot depend on hit rate), and a failed decapsulation
clearing rather than poisoning the entry.
2026-07-27 14:24:01 -07:00

235 B

@workflow/core
@workflow/core
minor

Teach the serialization layer to read and write encp sealed envelopes via a PayloadKey union, so a cross-run writer can encrypt with only a public key. Existing symmetric call sites are unaffected.