mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
8a95d36c9a
* feat(core): seal forwarded stream writes to the owner's public key
When a parent forwards a `WritableStream` into a child run, the child
writes to the parent's server stream and must encrypt with the parent's
key. The descriptor carried `{name, runId, deploymentId}` — enough to
resolve the parent's *symmetric* key, which cross-deployment means the
same ~350ms `run-key` round trip the rest of this work removes.
Carrying the parent's public key in the descriptor closes that hole. The
parent owns the stream and already has its key material resolved on the
step context when it creates the handle, so deriving and stamping the
public key there costs nothing, and the child can seal immediately.
Resolution is now three tiers, cheapest first:
1. descriptor has the owner's public key → seal, zero I/O
2. descriptor has the owner's deployment → resolve symmetric key (API call)
3. neither (older SDKs) → load the owning run, then resolve its key
Tiers 2 and 3 import the key encrypt-only, which is an honor-system
restriction — the same bytes could decrypt. Tier 1 makes it a real
guarantee: a public key cannot read anything.
**On nonce discipline.** An earlier sketch amortized one KEM across a
whole stream and used counter nonces. That is unsafe here: a stream
reconnect or a durable replay restarts the writer, and a counter would
restart at zero under a still-live content key, repeating `(key, nonce)`
— which under AES-GCM leaks the plaintext XOR and the auth subkey. This
implementation instead seals each frame independently, so no content key
outlives a single frame and the hazard cannot arise by construction. The
cost is one ECDH and 32 bytes per frame; `encapsulate`/`decapsulate`
remain available if profiling later justifies amortizing, but doing so
would need connection-scoped nonce rules to stay safe.
A regression test asserts 20 frames of identical plaintext produce 20
distinct ephemeral keys and 20 distinct ciphertexts.
* review: assert the amortized-KEM invariant for forwarded streams
The forwarded-stream test asserted a distinct ephemeral key per frame,
which held when each frame was sealed independently. Now that the KEM is
amortized per writer, the invariant that actually matters is different and
the test says so: all frames of one stream share an ephemeral key, yet
identical plaintext still yields distinct ciphertext (nonces stay random),
and a second writer incarnation — a reconnect or durable replay — gets a
different ephemeral key rather than inheriting the previous content key.
* fix: keep the owner public key when reviving a forwarded writable
Forwarding a writable to another run is two hops, not one: `start()` hands the
parent's handle to the child WORKFLOW, and the child workflow then hands it to
the step that writes. The handle is revived and re-serialized in between.
The revivers re-attached the stream name, runId and deploymentId but not the
owner's X25519 public key, so it was dropped on that middle hop. The step then
found no key on the descriptor and fell back to fetching the owner's symmetric
key — reintroducing exactly the round trip sealing exists to remove. Sealing
therefore never engaged for `start()`-forwarded streams, while the single-hop
unit test still passed.
Re-attach the key at all three reviver sites, and cover the two-hop shape so
the end-to-end path is tested rather than just one serialization round.
211 B
211 B
@workflow/core
| @workflow/core |
|---|
| minor |
Carry the owning run's public key in forwarded WritableStream descriptors, so a run writing into another run's stream seals frames with no run fetch and no key-API round trip.