feat(core): add encp sealed-box encryption primitive - #3093
Conversation
🦋 Changeset detectedLatest commit: 10850c2 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
📊 Workflow Benchmarkscommit Backend:
📜 Previous results (3)916cffbSat, 25 Jul 2026 09:10:27 GMT · run logs
bf9a09cFri, 24 Jul 2026 23:23:29 GMT · run logs
db8e937Fri, 24 Jul 2026 21:18:46 GMT · run logs
ℹ️ Metric definitions & methodologyBest/P75/P90/P99 deltas compare against the most recent benchmark run on Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window) Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost 🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120 All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor ( Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the |
🧪 E2E Test Results✅ All tests passed E2E Test SummarySummary
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
✅ 📋 Other
✅ vercel-multi-region
|
There was a problem hiding this comment.
Pull request overview
Adds a new encp (“sealed”) serialization format and introduces a sealed-box encryption primitive in @workflow/core to enable cross-run writes that only require the recipient run’s public key (avoiding symmetric key distribution and cross-deployment key lookups).
Changes:
- Introduces
sealed-box(X25519 + HKDF-SHA256 + AES-256-GCM) primitives: derive per-run keypairs, seal/open payloads, and support amortized KEM for streaming. - Extends serialization-format helpers to recognize
encpas ciphertext (UI-safe), and ensures o11y hydration won’t attempt AES-GCM decrypt on sealed payloads. - Adds optional AES-GCM AAD support in the shared encryption helpers, plus unit tests and a capabilities table entry.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/core/src/serialization/types.ts | Adds SEALED: 'encp' format prefix with inline documentation. |
| packages/core/src/serialization-format.ts | Treats encp as ciphertext for display; adds isSealedData; avoids AES decrypt attempts for sealed payloads in hydrateDataWithKey. |
| packages/core/src/serialization-format.test.ts | Adds regression tests for encp detection and hydration pass-through behavior. |
| packages/core/src/sealed-box.ts | New sealed-box primitive (derive run keypair, encapsulate/decapsulate, seal/open, run-bound AAD helper). |
| packages/core/src/sealed-box.test.ts | New test suite validating correctness, tamper detection, AAD behavior, and cross-implementation checks. |
| packages/core/src/encryption.ts | Adds optional AAD to AES-GCM encrypt/decrypt helpers. |
| packages/core/src/capabilities.ts | Adds encp to the run capabilities format version table and history notes. |
| .changeset/encp-sealed-box.md | Changeset for @workflow/core minor release introducing encp primitive. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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
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.
bf9a09c to
916cffb
Compare
karthikscale3
left a comment
There was a problem hiding this comment.
ai review: The sealed-box primitive is well scoped and thoroughly tested. I verified the X25519/HKDF/AES-GCM construction, AAD binding, malformed-key and tamper handling, observability fallback, and the additive legacy AES helper change. The three existing inline findings are fixed on this head and their threads are resolved. The broad Vercel production E2E failures do not appear specific to this change; the focused unit, module-boundary, local framework, and multi-region checks pass.
|
No backport to This is new feature work: it adds an To override, re-run the Backport to stable workflow manually via |
PR 1 of 7 in a stack that removes the ~350ms cross-deployment encryption-key lookup from every hot path.
encpsealed-box encryption primitive #3093 (this PR) — theencpsealed-box primitiveresumeHook()seals instead of fetching the key ← the payoffstart()gets the key from the probe it already awaitsWhy
Cross-run writes — a hook resumption targeting another run, a child writing into a parent's forwarded stream — currently require the writer to hold the recipient's symmetric key. That grants decrypt capability the writer doesn't need, and obtaining it across a deployment boundary costs a
run-keyAPI round trip.A sealed box fixes both: the writer needs only a public key, which isn't secret and can therefore travel on the run entity for free.
Key hierarchy
Both keys descend from the per-run key material
KthatWorld.getEncryptionKeyForRun()already returns, so key acquisition, the World interface, and the Vercel API are all untouched:Construction
ECIES-style over the same primitives as HPKE base mode (DHKEM(X25519), HKDF-SHA256, AES-256-GCM), binding both public keys into the KDF
infoas HPKE'skem_contextdoes — that's the part that prevents key-substitution/unknown-key-share 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.Wire format (
encpprefix attached by the serialization layer in #3094):Nothing produces
encpyetThis PR is the primitive only. The o11y layer is hardened defensively so sealed payloads render as ciphertext rather than throwing
Unsupported serialization format, andhydrateDataWithKeyskips the AES path for them (opening a sealed payload needs the private scalar, not the symmetric key).Notes for reviewers
encapsulate/decapsulateare split out so stream writers can amortize the KEM across frames. The docs state the obligation that comes with that: keep random per-frame nonces and re-encapsulate per connection attempt. A long-lived content key plus counter nonces would repeat(key, nonce)after a reconnect or a durable replay — catastrophic for AES-GCM. Enforced in feat(core): route sealed envelopes through the serialization layer #3094.PKCS#8prefix (RFC 8410) and its public half read from a JWK export. That's subtle enough that the test suite cross-validates the derived public key againstnode:crypto's native X25519.OperationErroron all-zero shared secret), giving us HPKE's contributory-behavior check for free — asserted in tests.30 new tests. Full core unit suite passes; workspace typecheck clean.