mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
4ba223a01c
* 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.
238 B
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.