Skip to content

feat(core): add encp sealed-box encryption primitive - #3093

Merged
TooTallNate merged 3 commits into
mainfrom
nate/encp-sealed-box
Jul 27, 2026
Merged

feat(core): add encp sealed-box encryption primitive#3093
TooTallNate merged 3 commits into
mainfrom
nate/encp-sealed-box

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Jul 24, 2026

Copy link
Copy Markdown
Member

PR 1 of 7 in a stack that removes the ~350ms cross-deployment encryption-key lookup from every hot path.

Why

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-key API 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 K that World.getEncryptionKeyForRun() already returns, so key acquisition, the World interface, and the Vercel API are all untouched:

K (32 bytes, per-run, = HKDF(VERCEL_DEPLOYMENT_KEY, "projectId|runId"))
├── AES-256 key = K used directly → 'encr' envelopes (unchanged)
└── X25519 scalar = HKDF(K, versioned label) → 'encp' envelopes
└── public key = basepoint · scalar (published, not secret)

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 info as HPKE's kem_context does — 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 (encp prefix attached by the serialization layer in #3094):

[ephemeral public key (32)][nonce (12)][ciphertext + GCM tag (16)]

Nothing produces encp yet

This PR 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 (opening a sealed payload needs the private scalar, not the symmetric key).

Notes for reviewers

  • encapsulate/decapsulate are 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.
  • Web Crypto cannot import a raw X25519 private key, so a derived scalar is wrapped in a fixed PKCS#8 prefix (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 against node:crypto's native X25519.
  • Low-order public keys and wrong-length keys are rejected by the platform (OperationError on all-zero shared secret), giving us HPKE's contributory-behavior check for free — asserted in tests.
  • Optional AAD added to the AES helpers; passing no AAD is byte-identical to previous behavior.

30 new tests. Full core unit suite passes; workspace typecheck clean.

CopilotAI review requested due to automatic review settings July 24, 2026 20:56
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 24, 2026 20:56
@vercel

vercelBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 10850c2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
NameType
@workflow/coreMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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

@github-actions

github-actionsBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 10850c2 · Mon, 27 Jul 2026 18:57:37 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1302 (+83%) 🔻1351 🔴 (+31%) 🔻1380 🔴 (+27%) 🔻1394 🔴 (-1.8%)30
TTFSstream1219 (+42%) 🔻1339 🔴 (+40%) 🔻1349 🔴 (+39%) 🔻1562 🔴 (+57%) 🔻30
TTFShook + stream510 (-53%) 💚1589 🔴 (+29%) 🔻1642 🔴 (+30%) 🔻2450 🔴 (+67%) 🔻30
STSO1020 steps (1-20)175 (-3.3%)271 🔴 (-1.8%)489 🔴 (+61%) 🔻494 🔴 (+25%) 🔻19
STSO1020 steps (101-120)183 (-1.6%)269 🔴 (+0.7%)303 🔴 (+0.7%)1137 🔴 (-53%) 💚19
STSO1020 steps (1001-1020)462 (+3.4%)549 🔴 (+6.4%)614 🔴 (+14%)799 🔴 (+22%) 🔻19
WO1020 steps385142 (±0%)385142 (±0%)385142 (±0%)385142 (±0%)1
SLstream latency93 (+21%) 🔻179 🔴 (+44%) 🔻209 🔴 (+45%) 🔻366 🔴 (+44%) 🔻30
SOstream overhead (text)105 (+8.2%)149 (-3.9%)153 (-13%)176 (-11%)30
SOstream overhead (structured)106 (+5.0%)167 (-0.6%)243 (+37%) 🔻360 (-76%) 💚30
📜 Previous results (3)

916cffb

Sat, 25 Jul 2026 09:10:27 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep213 (-70%) 💚1322 🔴 (+27%) 🔻1339 🔴 (+21%) 🔻1456 🔴 (+26%) 🔻30
TTFSstream198 (-80%) 💚1322 🔴 (+23%) 🔻1362 🔴 (+18%) 🔻1564 🔴 (+22%) 🔻30
TTFShook + stream389 (-69%) 💚1525 🔴 (+8.5%)1545 🔴 (+6.6%)1652 🔴 (-1.4%)30
STSO1020 steps (1-20)181 (-9.0%)297 🔴 (+7.6%)489 🔴 (+42%) 🔻525 🔴 (+36%) 🔻19
STSO1020 steps (101-120)195 (-9.3%)258 🔴 (-7.9%)352 🔴 (+18%) 🔻360 🔴 (-4.5%)19
STSO1020 steps (1001-1020)471 (-3.7%)585 🔴 (+4.5%)606 🔴 (+1.7%)677 🔴 (+12%)19
WO1020 steps404095 (-2.0%)404095 (-2.0%)404095 (-2.0%)404095 (-2.0%)1
SLstream latency94 (-5.1%)168 🔴 (+28%) 🔻194 🔴 (+31%) 🔻669 🔴 (+222%) 🔻30
SOstream overhead (text)115 (-12%)252 🔴 (+16%) 🔻321 (+30%) 🔻682 (+153%) 🔻30
SOstream overhead (structured)117 (-16%) 💚194 (-12%)253 (+1.6%)372 (-47%) 💚30

bf9a09c

Fri, 24 Jul 2026 23:23:29 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep440 (-39%) 💚1322 🔴 (+28%) 🔻1338 🔴 (+25%) 🔻1677 🔴 (+33%) 🔻30
STSO1020 steps (1-20)180 (+9.1%)308 🔴 (+11%)358 🔴 (-12%)368 🔴 (-11%)19
STSO1020 steps (101-120)179 (+3.5%)297 🔴 (+26%) 🔻300 🔴 (+14%)351 🔴 (-14%)19
STSO1020 steps (1001-1020)486 (+5.7%)550 🔴 (+4.4%)664 🔴 (+18%) 🔻666 🔴 (+18%) 🔻19
WO1020 steps394683 (+1.1%)394683 (+1.1%)394683 (+1.1%)394683 (+1.1%)1
SLstream latency95 (+19%) 🔻174 🔴 (+58%) 🔻198 🔴 (+43%) 🔻316 🔴 (-8.4%)30
SOstream overhead (text)111 (+1.8%)150 (-4.5%)198 (+5.3%)246 (-7.5%)30
SOstream overhead (structured)113 (+24%) 🔻176 (+12%)219 (+30%) 🔻233 (-63%) 💚30

db8e937

Fri, 24 Jul 2026 21:18:46 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep419 (-42%) 💚1170 🔴 (+12%)1261 🔴 (+18%) 🔻1421 🔴 (-1.3%)30
TTFSstream1108 (+16%) 🔻1153 🔴 (+13%)1167 🔴 (+13%)1384 🔴 (+32%) 🔻30
TTFShook + stream1451 (+22%) 🔻1521 🔴 (+15%) 🔻1632 🔴 (+18%) 🔻2153 🔴 (+29%) 🔻30
STSO1020 steps (1-20)176 (+8.6%)316 🔴 (+27%) 🔻341 🔴 (+13%)694 🔴 (+128%) 🔻19
STSO1020 steps (101-120)165 (-11%)239 🔴 (-14%)302 🔴 (-7.4%)402 🔴 (+13%)19
STSO1020 steps (1001-1020)456 (±0%)521 🔴 (-6.0%)550 🔴 (-8.0%)650 🔴 (-21%) 💚19
WO1020 steps384982 (+0.7%)384982 (+0.7%)384982 (+0.7%)384982 (+0.7%)1
SLstream latency81 (+8.0%)153 🔴 (+26%) 🔻163 🔴 (+18%) 🔻201 🔴 (-76%) 💚30
SOstream overhead (structured)101 (+3.1%)142 (-17%) 💚159 (-36%) 💚281 (±0%)30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

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 (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 encp as 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
FileDescription
packages/core/src/serialization/types.tsAdds SEALED: 'encp' format prefix with inline documentation.
packages/core/src/serialization-format.tsTreats encp as ciphertext for display; adds isSealedData; avoids AES decrypt attempts for sealed payloads in hydrateDataWithKey.
packages/core/src/serialization-format.test.tsAdds regression tests for encp detection and hydration pass-through behavior.
packages/core/src/sealed-box.tsNew sealed-box primitive (derive run keypair, encapsulate/decapsulate, seal/open, run-bound AAD helper).
packages/core/src/sealed-box.test.tsNew test suite validating correctness, tamper detection, AAD behavior, and cross-implementation checks.
packages/core/src/encryption.tsAdds optional AAD to AES-GCM encrypt/decrypt helpers.
packages/core/src/capabilities.tsAdds encp to the run capabilities format version table and history notes.
.changeset/encp-sealed-box.mdChangeset for @workflow/core minor release introducing encp primitive.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment threadpackages/core/src/sealed-box.ts Outdated
Comment threadpackages/core/src/sealed-box.ts
Comment threadpackages/core/src/sealed-box.ts Outdated

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestion:

Accidentally committed quickjs-assets.generated.ts imports quickjs-wasi, which is not a dependency of @workflow/core, causing tsc to fail with TS2307 and breaking every deployment.

Fix on Vercel

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.

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 4ba223a (AI decision).

This is new feature work: it adds an encp sealed-box encryption primitive (new sealed-box.ts module, new SEALED serialization format, new isSealedData export, optional AAD parameters on the AES helpers) as PR 1 of 7 in a stack that removes a cross-deployment key lookup. The commit explicitly ships with a minor changeset and nothing yet produces encp payloads, so there is no user-visible defect on stable being fixed. The defensive o11y hardening (not attempting AES decrypt on sealed payloads) only matters once later PRs in the stack start writing encp, which will never happen on stable.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

4ba223a01c56126aec5c982d3583f779ed96e8ca

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@TooTallNate@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(core): add `encp` sealed-box encryption primitive by TooTallNate · Pull Request #3093 · vercel/workflow · GitHub
Skip to content

feat(core): add encp sealed-box encryption primitive - #3093

Merged
TooTallNate merged 3 commits into
mainfrom
nate/encp-sealed-box
Jul 27, 2026
Merged

feat(core): add encp sealed-box encryption primitive#3093
TooTallNate merged 3 commits into
mainfrom
nate/encp-sealed-box

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Jul 24, 2026

Copy link
Copy Markdown
Member

PR 1 of 7 in a stack that removes the ~350ms cross-deployment encryption-key lookup from every hot path.

Why

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-key API 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 K that World.getEncryptionKeyForRun() already returns, so key acquisition, the World interface, and the Vercel API are all untouched:

K (32 bytes, per-run, = HKDF(VERCEL_DEPLOYMENT_KEY, "projectId|runId"))
├── AES-256 key = K used directly → 'encr' envelopes (unchanged)
└── X25519 scalar = HKDF(K, versioned label) → 'encp' envelopes
└── public key = basepoint · scalar (published, not secret)

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 info as HPKE's kem_context does — 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 (encp prefix attached by the serialization layer in #3094):

[ephemeral public key (32)][nonce (12)][ciphertext + GCM tag (16)]

Nothing produces encp yet

This PR 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 (opening a sealed payload needs the private scalar, not the symmetric key).

Notes for reviewers

  • encapsulate/decapsulate are 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.
  • Web Crypto cannot import a raw X25519 private key, so a derived scalar is wrapped in a fixed PKCS#8 prefix (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 against node:crypto's native X25519.
  • Low-order public keys and wrong-length keys are rejected by the platform (OperationError on all-zero shared secret), giving us HPKE's contributory-behavior check for free — asserted in tests.
  • Optional AAD added to the AES helpers; passing no AAD is byte-identical to previous behavior.

30 new tests. Full core unit suite passes; workspace typecheck clean.

CopilotAI review requested due to automatic review settings July 24, 2026 20:56
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 24, 2026 20:56
@vercel

vercelBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 10850c2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
NameType
@workflow/coreMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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

@github-actions

github-actionsBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 10850c2 · Mon, 27 Jul 2026 18:57:37 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1302 (+83%) 🔻1351 🔴 (+31%) 🔻1380 🔴 (+27%) 🔻1394 🔴 (-1.8%)30
TTFSstream1219 (+42%) 🔻1339 🔴 (+40%) 🔻1349 🔴 (+39%) 🔻1562 🔴 (+57%) 🔻30
TTFShook + stream510 (-53%) 💚1589 🔴 (+29%) 🔻1642 🔴 (+30%) 🔻2450 🔴 (+67%) 🔻30
STSO1020 steps (1-20)175 (-3.3%)271 🔴 (-1.8%)489 🔴 (+61%) 🔻494 🔴 (+25%) 🔻19
STSO1020 steps (101-120)183 (-1.6%)269 🔴 (+0.7%)303 🔴 (+0.7%)1137 🔴 (-53%) 💚19
STSO1020 steps (1001-1020)462 (+3.4%)549 🔴 (+6.4%)614 🔴 (+14%)799 🔴 (+22%) 🔻19
WO1020 steps385142 (±0%)385142 (±0%)385142 (±0%)385142 (±0%)1
SLstream latency93 (+21%) 🔻179 🔴 (+44%) 🔻209 🔴 (+45%) 🔻366 🔴 (+44%) 🔻30
SOstream overhead (text)105 (+8.2%)149 (-3.9%)153 (-13%)176 (-11%)30
SOstream overhead (structured)106 (+5.0%)167 (-0.6%)243 (+37%) 🔻360 (-76%) 💚30
📜 Previous results (3)

916cffb

Sat, 25 Jul 2026 09:10:27 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep213 (-70%) 💚1322 🔴 (+27%) 🔻1339 🔴 (+21%) 🔻1456 🔴 (+26%) 🔻30
TTFSstream198 (-80%) 💚1322 🔴 (+23%) 🔻1362 🔴 (+18%) 🔻1564 🔴 (+22%) 🔻30
TTFShook + stream389 (-69%) 💚1525 🔴 (+8.5%)1545 🔴 (+6.6%)1652 🔴 (-1.4%)30
STSO1020 steps (1-20)181 (-9.0%)297 🔴 (+7.6%)489 🔴 (+42%) 🔻525 🔴 (+36%) 🔻19
STSO1020 steps (101-120)195 (-9.3%)258 🔴 (-7.9%)352 🔴 (+18%) 🔻360 🔴 (-4.5%)19
STSO1020 steps (1001-1020)471 (-3.7%)585 🔴 (+4.5%)606 🔴 (+1.7%)677 🔴 (+12%)19
WO1020 steps404095 (-2.0%)404095 (-2.0%)404095 (-2.0%)404095 (-2.0%)1
SLstream latency94 (-5.1%)168 🔴 (+28%) 🔻194 🔴 (+31%) 🔻669 🔴 (+222%) 🔻30
SOstream overhead (text)115 (-12%)252 🔴 (+16%) 🔻321 (+30%) 🔻682 (+153%) 🔻30
SOstream overhead (structured)117 (-16%) 💚194 (-12%)253 (+1.6%)372 (-47%) 💚30

bf9a09c

Fri, 24 Jul 2026 23:23:29 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep440 (-39%) 💚1322 🔴 (+28%) 🔻1338 🔴 (+25%) 🔻1677 🔴 (+33%) 🔻30
STSO1020 steps (1-20)180 (+9.1%)308 🔴 (+11%)358 🔴 (-12%)368 🔴 (-11%)19
STSO1020 steps (101-120)179 (+3.5%)297 🔴 (+26%) 🔻300 🔴 (+14%)351 🔴 (-14%)19
STSO1020 steps (1001-1020)486 (+5.7%)550 🔴 (+4.4%)664 🔴 (+18%) 🔻666 🔴 (+18%) 🔻19
WO1020 steps394683 (+1.1%)394683 (+1.1%)394683 (+1.1%)394683 (+1.1%)1
SLstream latency95 (+19%) 🔻174 🔴 (+58%) 🔻198 🔴 (+43%) 🔻316 🔴 (-8.4%)30
SOstream overhead (text)111 (+1.8%)150 (-4.5%)198 (+5.3%)246 (-7.5%)30
SOstream overhead (structured)113 (+24%) 🔻176 (+12%)219 (+30%) 🔻233 (-63%) 💚30

db8e937

Fri, 24 Jul 2026 21:18:46 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep419 (-42%) 💚1170 🔴 (+12%)1261 🔴 (+18%) 🔻1421 🔴 (-1.3%)30
TTFSstream1108 (+16%) 🔻1153 🔴 (+13%)1167 🔴 (+13%)1384 🔴 (+32%) 🔻30
TTFShook + stream1451 (+22%) 🔻1521 🔴 (+15%) 🔻1632 🔴 (+18%) 🔻2153 🔴 (+29%) 🔻30
STSO1020 steps (1-20)176 (+8.6%)316 🔴 (+27%) 🔻341 🔴 (+13%)694 🔴 (+128%) 🔻19
STSO1020 steps (101-120)165 (-11%)239 🔴 (-14%)302 🔴 (-7.4%)402 🔴 (+13%)19
STSO1020 steps (1001-1020)456 (±0%)521 🔴 (-6.0%)550 🔴 (-8.0%)650 🔴 (-21%) 💚19
WO1020 steps384982 (+0.7%)384982 (+0.7%)384982 (+0.7%)384982 (+0.7%)1
SLstream latency81 (+8.0%)153 🔴 (+26%) 🔻163 🔴 (+18%) 🔻201 🔴 (-76%) 💚30
SOstream overhead (structured)101 (+3.1%)142 (-17%) 💚159 (-36%) 💚281 (±0%)30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

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 (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 encp as 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
FileDescription
packages/core/src/serialization/types.tsAdds SEALED: 'encp' format prefix with inline documentation.
packages/core/src/serialization-format.tsTreats encp as ciphertext for display; adds isSealedData; avoids AES decrypt attempts for sealed payloads in hydrateDataWithKey.
packages/core/src/serialization-format.test.tsAdds regression tests for encp detection and hydration pass-through behavior.
packages/core/src/sealed-box.tsNew sealed-box primitive (derive run keypair, encapsulate/decapsulate, seal/open, run-bound AAD helper).
packages/core/src/sealed-box.test.tsNew test suite validating correctness, tamper detection, AAD behavior, and cross-implementation checks.
packages/core/src/encryption.tsAdds optional AAD to AES-GCM encrypt/decrypt helpers.
packages/core/src/capabilities.tsAdds encp to the run capabilities format version table and history notes.
.changeset/encp-sealed-box.mdChangeset for @workflow/core minor release introducing encp primitive.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment threadpackages/core/src/sealed-box.ts Outdated
Comment threadpackages/core/src/sealed-box.ts
Comment threadpackages/core/src/sealed-box.ts Outdated

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestion:

Accidentally committed quickjs-assets.generated.ts imports quickjs-wasi, which is not a dependency of @workflow/core, causing tsc to fail with TS2307 and breaking every deployment.

Fix on Vercel

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.

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 4ba223a (AI decision).

This is new feature work: it adds an encp sealed-box encryption primitive (new sealed-box.ts module, new SEALED serialization format, new isSealedData export, optional AAD parameters on the AES helpers) as PR 1 of 7 in a stack that removes a cross-deployment key lookup. The commit explicitly ships with a minor changeset and nothing yet produces encp payloads, so there is no user-visible defect on stable being fixed. The defensive o11y hardening (not attempting AES decrypt on sealed payloads) only matters once later PRs in the stack start writing encp, which will never happen on stable.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

4ba223a01c56126aec5c982d3583f779ed96e8ca

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@TooTallNate@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(core): add `encp` sealed-box encryption primitive by TooTallNate · Pull Request #3093 · vercel/workflow · GitHub
Skip to content

feat(core): add encp sealed-box encryption primitive - #3093

Merged
TooTallNate merged 3 commits into
mainfrom
nate/encp-sealed-box
Jul 27, 2026
Merged

feat(core): add encp sealed-box encryption primitive#3093
TooTallNate merged 3 commits into
mainfrom
nate/encp-sealed-box

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Jul 24, 2026

Copy link
Copy Markdown
Member

PR 1 of 7 in a stack that removes the ~350ms cross-deployment encryption-key lookup from every hot path.

Why

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-key API 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 K that World.getEncryptionKeyForRun() already returns, so key acquisition, the World interface, and the Vercel API are all untouched:

K (32 bytes, per-run, = HKDF(VERCEL_DEPLOYMENT_KEY, "projectId|runId"))
├── AES-256 key = K used directly → 'encr' envelopes (unchanged)
└── X25519 scalar = HKDF(K, versioned label) → 'encp' envelopes
└── public key = basepoint · scalar (published, not secret)

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 info as HPKE's kem_context does — 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 (encp prefix attached by the serialization layer in #3094):

[ephemeral public key (32)][nonce (12)][ciphertext + GCM tag (16)]

Nothing produces encp yet

This PR 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 (opening a sealed payload needs the private scalar, not the symmetric key).

Notes for reviewers

  • encapsulate/decapsulate are 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.
  • Web Crypto cannot import a raw X25519 private key, so a derived scalar is wrapped in a fixed PKCS#8 prefix (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 against node:crypto's native X25519.
  • Low-order public keys and wrong-length keys are rejected by the platform (OperationError on all-zero shared secret), giving us HPKE's contributory-behavior check for free — asserted in tests.
  • Optional AAD added to the AES helpers; passing no AAD is byte-identical to previous behavior.

30 new tests. Full core unit suite passes; workspace typecheck clean.

CopilotAI review requested due to automatic review settings July 24, 2026 20:56
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 24, 2026 20:56
@vercel

vercelBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 10850c2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
NameType
@workflow/coreMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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

@github-actions

github-actionsBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 10850c2 · Mon, 27 Jul 2026 18:57:37 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1302 (+83%) 🔻1351 🔴 (+31%) 🔻1380 🔴 (+27%) 🔻1394 🔴 (-1.8%)30
TTFSstream1219 (+42%) 🔻1339 🔴 (+40%) 🔻1349 🔴 (+39%) 🔻1562 🔴 (+57%) 🔻30
TTFShook + stream510 (-53%) 💚1589 🔴 (+29%) 🔻1642 🔴 (+30%) 🔻2450 🔴 (+67%) 🔻30
STSO1020 steps (1-20)175 (-3.3%)271 🔴 (-1.8%)489 🔴 (+61%) 🔻494 🔴 (+25%) 🔻19
STSO1020 steps (101-120)183 (-1.6%)269 🔴 (+0.7%)303 🔴 (+0.7%)1137 🔴 (-53%) 💚19
STSO1020 steps (1001-1020)462 (+3.4%)549 🔴 (+6.4%)614 🔴 (+14%)799 🔴 (+22%) 🔻19
WO1020 steps385142 (±0%)385142 (±0%)385142 (±0%)385142 (±0%)1
SLstream latency93 (+21%) 🔻179 🔴 (+44%) 🔻209 🔴 (+45%) 🔻366 🔴 (+44%) 🔻30
SOstream overhead (text)105 (+8.2%)149 (-3.9%)153 (-13%)176 (-11%)30
SOstream overhead (structured)106 (+5.0%)167 (-0.6%)243 (+37%) 🔻360 (-76%) 💚30
📜 Previous results (3)

916cffb

Sat, 25 Jul 2026 09:10:27 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep213 (-70%) 💚1322 🔴 (+27%) 🔻1339 🔴 (+21%) 🔻1456 🔴 (+26%) 🔻30
TTFSstream198 (-80%) 💚1322 🔴 (+23%) 🔻1362 🔴 (+18%) 🔻1564 🔴 (+22%) 🔻30
TTFShook + stream389 (-69%) 💚1525 🔴 (+8.5%)1545 🔴 (+6.6%)1652 🔴 (-1.4%)30
STSO1020 steps (1-20)181 (-9.0%)297 🔴 (+7.6%)489 🔴 (+42%) 🔻525 🔴 (+36%) 🔻19
STSO1020 steps (101-120)195 (-9.3%)258 🔴 (-7.9%)352 🔴 (+18%) 🔻360 🔴 (-4.5%)19
STSO1020 steps (1001-1020)471 (-3.7%)585 🔴 (+4.5%)606 🔴 (+1.7%)677 🔴 (+12%)19
WO1020 steps404095 (-2.0%)404095 (-2.0%)404095 (-2.0%)404095 (-2.0%)1
SLstream latency94 (-5.1%)168 🔴 (+28%) 🔻194 🔴 (+31%) 🔻669 🔴 (+222%) 🔻30
SOstream overhead (text)115 (-12%)252 🔴 (+16%) 🔻321 (+30%) 🔻682 (+153%) 🔻30
SOstream overhead (structured)117 (-16%) 💚194 (-12%)253 (+1.6%)372 (-47%) 💚30

bf9a09c

Fri, 24 Jul 2026 23:23:29 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep440 (-39%) 💚1322 🔴 (+28%) 🔻1338 🔴 (+25%) 🔻1677 🔴 (+33%) 🔻30
STSO1020 steps (1-20)180 (+9.1%)308 🔴 (+11%)358 🔴 (-12%)368 🔴 (-11%)19
STSO1020 steps (101-120)179 (+3.5%)297 🔴 (+26%) 🔻300 🔴 (+14%)351 🔴 (-14%)19
STSO1020 steps (1001-1020)486 (+5.7%)550 🔴 (+4.4%)664 🔴 (+18%) 🔻666 🔴 (+18%) 🔻19
WO1020 steps394683 (+1.1%)394683 (+1.1%)394683 (+1.1%)394683 (+1.1%)1
SLstream latency95 (+19%) 🔻174 🔴 (+58%) 🔻198 🔴 (+43%) 🔻316 🔴 (-8.4%)30
SOstream overhead (text)111 (+1.8%)150 (-4.5%)198 (+5.3%)246 (-7.5%)30
SOstream overhead (structured)113 (+24%) 🔻176 (+12%)219 (+30%) 🔻233 (-63%) 💚30

db8e937

Fri, 24 Jul 2026 21:18:46 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep419 (-42%) 💚1170 🔴 (+12%)1261 🔴 (+18%) 🔻1421 🔴 (-1.3%)30
TTFSstream1108 (+16%) 🔻1153 🔴 (+13%)1167 🔴 (+13%)1384 🔴 (+32%) 🔻30
TTFShook + stream1451 (+22%) 🔻1521 🔴 (+15%) 🔻1632 🔴 (+18%) 🔻2153 🔴 (+29%) 🔻30
STSO1020 steps (1-20)176 (+8.6%)316 🔴 (+27%) 🔻341 🔴 (+13%)694 🔴 (+128%) 🔻19
STSO1020 steps (101-120)165 (-11%)239 🔴 (-14%)302 🔴 (-7.4%)402 🔴 (+13%)19
STSO1020 steps (1001-1020)456 (±0%)521 🔴 (-6.0%)550 🔴 (-8.0%)650 🔴 (-21%) 💚19
WO1020 steps384982 (+0.7%)384982 (+0.7%)384982 (+0.7%)384982 (+0.7%)1
SLstream latency81 (+8.0%)153 🔴 (+26%) 🔻163 🔴 (+18%) 🔻201 🔴 (-76%) 💚30
SOstream overhead (structured)101 (+3.1%)142 (-17%) 💚159 (-36%) 💚281 (±0%)30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

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 (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 encp as 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
FileDescription
packages/core/src/serialization/types.tsAdds SEALED: 'encp' format prefix with inline documentation.
packages/core/src/serialization-format.tsTreats encp as ciphertext for display; adds isSealedData; avoids AES decrypt attempts for sealed payloads in hydrateDataWithKey.
packages/core/src/serialization-format.test.tsAdds regression tests for encp detection and hydration pass-through behavior.
packages/core/src/sealed-box.tsNew sealed-box primitive (derive run keypair, encapsulate/decapsulate, seal/open, run-bound AAD helper).
packages/core/src/sealed-box.test.tsNew test suite validating correctness, tamper detection, AAD behavior, and cross-implementation checks.
packages/core/src/encryption.tsAdds optional AAD to AES-GCM encrypt/decrypt helpers.
packages/core/src/capabilities.tsAdds encp to the run capabilities format version table and history notes.
.changeset/encp-sealed-box.mdChangeset for @workflow/core minor release introducing encp primitive.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment threadpackages/core/src/sealed-box.ts Outdated
Comment threadpackages/core/src/sealed-box.ts
Comment threadpackages/core/src/sealed-box.ts Outdated

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestion:

Accidentally committed quickjs-assets.generated.ts imports quickjs-wasi, which is not a dependency of @workflow/core, causing tsc to fail with TS2307 and breaking every deployment.

Fix on Vercel

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.

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 4ba223a (AI decision).

This is new feature work: it adds an encp sealed-box encryption primitive (new sealed-box.ts module, new SEALED serialization format, new isSealedData export, optional AAD parameters on the AES helpers) as PR 1 of 7 in a stack that removes a cross-deployment key lookup. The commit explicitly ships with a minor changeset and nothing yet produces encp payloads, so there is no user-visible defect on stable being fixed. The defensive o11y hardening (not attempting AES decrypt on sealed payloads) only matters once later PRs in the stack start writing encp, which will never happen on stable.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

4ba223a01c56126aec5c982d3583f779ed96e8ca

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@TooTallNate@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(core): add `encp` sealed-box encryption primitive by TooTallNate · Pull Request #3093 · vercel/workflow · GitHub
Skip to content

feat(core): add encp sealed-box encryption primitive - #3093

Merged
TooTallNate merged 3 commits into
mainfrom
nate/encp-sealed-box
Jul 27, 2026
Merged

feat(core): add encp sealed-box encryption primitive#3093
TooTallNate merged 3 commits into
mainfrom
nate/encp-sealed-box

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Jul 24, 2026

Copy link
Copy Markdown
Member

PR 1 of 7 in a stack that removes the ~350ms cross-deployment encryption-key lookup from every hot path.

Why

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-key API 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 K that World.getEncryptionKeyForRun() already returns, so key acquisition, the World interface, and the Vercel API are all untouched:

K (32 bytes, per-run, = HKDF(VERCEL_DEPLOYMENT_KEY, "projectId|runId"))
├── AES-256 key = K used directly → 'encr' envelopes (unchanged)
└── X25519 scalar = HKDF(K, versioned label) → 'encp' envelopes
└── public key = basepoint · scalar (published, not secret)

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 info as HPKE's kem_context does — 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 (encp prefix attached by the serialization layer in #3094):

[ephemeral public key (32)][nonce (12)][ciphertext + GCM tag (16)]

Nothing produces encp yet

This PR 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 (opening a sealed payload needs the private scalar, not the symmetric key).

Notes for reviewers

  • encapsulate/decapsulate are 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.
  • Web Crypto cannot import a raw X25519 private key, so a derived scalar is wrapped in a fixed PKCS#8 prefix (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 against node:crypto's native X25519.
  • Low-order public keys and wrong-length keys are rejected by the platform (OperationError on all-zero shared secret), giving us HPKE's contributory-behavior check for free — asserted in tests.
  • Optional AAD added to the AES helpers; passing no AAD is byte-identical to previous behavior.

30 new tests. Full core unit suite passes; workspace typecheck clean.

CopilotAI review requested due to automatic review settings July 24, 2026 20:56
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 24, 2026 20:56
@vercel

vercelBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 10850c2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
NameType
@workflow/coreMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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

@github-actions

github-actionsBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 10850c2 · Mon, 27 Jul 2026 18:57:37 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1302 (+83%) 🔻1351 🔴 (+31%) 🔻1380 🔴 (+27%) 🔻1394 🔴 (-1.8%)30
TTFSstream1219 (+42%) 🔻1339 🔴 (+40%) 🔻1349 🔴 (+39%) 🔻1562 🔴 (+57%) 🔻30
TTFShook + stream510 (-53%) 💚1589 🔴 (+29%) 🔻1642 🔴 (+30%) 🔻2450 🔴 (+67%) 🔻30
STSO1020 steps (1-20)175 (-3.3%)271 🔴 (-1.8%)489 🔴 (+61%) 🔻494 🔴 (+25%) 🔻19
STSO1020 steps (101-120)183 (-1.6%)269 🔴 (+0.7%)303 🔴 (+0.7%)1137 🔴 (-53%) 💚19
STSO1020 steps (1001-1020)462 (+3.4%)549 🔴 (+6.4%)614 🔴 (+14%)799 🔴 (+22%) 🔻19
WO1020 steps385142 (±0%)385142 (±0%)385142 (±0%)385142 (±0%)1
SLstream latency93 (+21%) 🔻179 🔴 (+44%) 🔻209 🔴 (+45%) 🔻366 🔴 (+44%) 🔻30
SOstream overhead (text)105 (+8.2%)149 (-3.9%)153 (-13%)176 (-11%)30
SOstream overhead (structured)106 (+5.0%)167 (-0.6%)243 (+37%) 🔻360 (-76%) 💚30
📜 Previous results (3)

916cffb

Sat, 25 Jul 2026 09:10:27 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep213 (-70%) 💚1322 🔴 (+27%) 🔻1339 🔴 (+21%) 🔻1456 🔴 (+26%) 🔻30
TTFSstream198 (-80%) 💚1322 🔴 (+23%) 🔻1362 🔴 (+18%) 🔻1564 🔴 (+22%) 🔻30
TTFShook + stream389 (-69%) 💚1525 🔴 (+8.5%)1545 🔴 (+6.6%)1652 🔴 (-1.4%)30
STSO1020 steps (1-20)181 (-9.0%)297 🔴 (+7.6%)489 🔴 (+42%) 🔻525 🔴 (+36%) 🔻19
STSO1020 steps (101-120)195 (-9.3%)258 🔴 (-7.9%)352 🔴 (+18%) 🔻360 🔴 (-4.5%)19
STSO1020 steps (1001-1020)471 (-3.7%)585 🔴 (+4.5%)606 🔴 (+1.7%)677 🔴 (+12%)19
WO1020 steps404095 (-2.0%)404095 (-2.0%)404095 (-2.0%)404095 (-2.0%)1
SLstream latency94 (-5.1%)168 🔴 (+28%) 🔻194 🔴 (+31%) 🔻669 🔴 (+222%) 🔻30
SOstream overhead (text)115 (-12%)252 🔴 (+16%) 🔻321 (+30%) 🔻682 (+153%) 🔻30
SOstream overhead (structured)117 (-16%) 💚194 (-12%)253 (+1.6%)372 (-47%) 💚30

bf9a09c

Fri, 24 Jul 2026 23:23:29 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep440 (-39%) 💚1322 🔴 (+28%) 🔻1338 🔴 (+25%) 🔻1677 🔴 (+33%) 🔻30
STSO1020 steps (1-20)180 (+9.1%)308 🔴 (+11%)358 🔴 (-12%)368 🔴 (-11%)19
STSO1020 steps (101-120)179 (+3.5%)297 🔴 (+26%) 🔻300 🔴 (+14%)351 🔴 (-14%)19
STSO1020 steps (1001-1020)486 (+5.7%)550 🔴 (+4.4%)664 🔴 (+18%) 🔻666 🔴 (+18%) 🔻19
WO1020 steps394683 (+1.1%)394683 (+1.1%)394683 (+1.1%)394683 (+1.1%)1
SLstream latency95 (+19%) 🔻174 🔴 (+58%) 🔻198 🔴 (+43%) 🔻316 🔴 (-8.4%)30
SOstream overhead (text)111 (+1.8%)150 (-4.5%)198 (+5.3%)246 (-7.5%)30
SOstream overhead (structured)113 (+24%) 🔻176 (+12%)219 (+30%) 🔻233 (-63%) 💚30

db8e937

Fri, 24 Jul 2026 21:18:46 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep419 (-42%) 💚1170 🔴 (+12%)1261 🔴 (+18%) 🔻1421 🔴 (-1.3%)30
TTFSstream1108 (+16%) 🔻1153 🔴 (+13%)1167 🔴 (+13%)1384 🔴 (+32%) 🔻30
TTFShook + stream1451 (+22%) 🔻1521 🔴 (+15%) 🔻1632 🔴 (+18%) 🔻2153 🔴 (+29%) 🔻30
STSO1020 steps (1-20)176 (+8.6%)316 🔴 (+27%) 🔻341 🔴 (+13%)694 🔴 (+128%) 🔻19
STSO1020 steps (101-120)165 (-11%)239 🔴 (-14%)302 🔴 (-7.4%)402 🔴 (+13%)19
STSO1020 steps (1001-1020)456 (±0%)521 🔴 (-6.0%)550 🔴 (-8.0%)650 🔴 (-21%) 💚19
WO1020 steps384982 (+0.7%)384982 (+0.7%)384982 (+0.7%)384982 (+0.7%)1
SLstream latency81 (+8.0%)153 🔴 (+26%) 🔻163 🔴 (+18%) 🔻201 🔴 (-76%) 💚30
SOstream overhead (structured)101 (+3.1%)142 (-17%) 💚159 (-36%) 💚281 (±0%)30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

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 (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 encp as 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
FileDescription
packages/core/src/serialization/types.tsAdds SEALED: 'encp' format prefix with inline documentation.
packages/core/src/serialization-format.tsTreats encp as ciphertext for display; adds isSealedData; avoids AES decrypt attempts for sealed payloads in hydrateDataWithKey.
packages/core/src/serialization-format.test.tsAdds regression tests for encp detection and hydration pass-through behavior.
packages/core/src/sealed-box.tsNew sealed-box primitive (derive run keypair, encapsulate/decapsulate, seal/open, run-bound AAD helper).
packages/core/src/sealed-box.test.tsNew test suite validating correctness, tamper detection, AAD behavior, and cross-implementation checks.
packages/core/src/encryption.tsAdds optional AAD to AES-GCM encrypt/decrypt helpers.
packages/core/src/capabilities.tsAdds encp to the run capabilities format version table and history notes.
.changeset/encp-sealed-box.mdChangeset for @workflow/core minor release introducing encp primitive.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment threadpackages/core/src/sealed-box.ts Outdated
Comment threadpackages/core/src/sealed-box.ts
Comment threadpackages/core/src/sealed-box.ts Outdated

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestion:

Accidentally committed quickjs-assets.generated.ts imports quickjs-wasi, which is not a dependency of @workflow/core, causing tsc to fail with TS2307 and breaking every deployment.

Fix on Vercel

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.

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 4ba223a (AI decision).

This is new feature work: it adds an encp sealed-box encryption primitive (new sealed-box.ts module, new SEALED serialization format, new isSealedData export, optional AAD parameters on the AES helpers) as PR 1 of 7 in a stack that removes a cross-deployment key lookup. The commit explicitly ships with a minor changeset and nothing yet produces encp payloads, so there is no user-visible defect on stable being fixed. The defensive o11y hardening (not attempting AES decrypt on sealed payloads) only matters once later PRs in the stack start writing encp, which will never happen on stable.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

4ba223a01c56126aec5c982d3583f779ed96e8ca

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@TooTallNate@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(core): add `encp` sealed-box encryption primitive by TooTallNate · Pull Request #3093 · vercel/workflow · GitHub
Skip to content

feat(core): add encp sealed-box encryption primitive - #3093

Merged
TooTallNate merged 3 commits into
mainfrom
nate/encp-sealed-box
Jul 27, 2026
Merged

feat(core): add encp sealed-box encryption primitive#3093
TooTallNate merged 3 commits into
mainfrom
nate/encp-sealed-box

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Jul 24, 2026

Copy link
Copy Markdown
Member

PR 1 of 7 in a stack that removes the ~350ms cross-deployment encryption-key lookup from every hot path.

Why

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-key API 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 K that World.getEncryptionKeyForRun() already returns, so key acquisition, the World interface, and the Vercel API are all untouched:

K (32 bytes, per-run, = HKDF(VERCEL_DEPLOYMENT_KEY, "projectId|runId"))
├── AES-256 key = K used directly → 'encr' envelopes (unchanged)
└── X25519 scalar = HKDF(K, versioned label) → 'encp' envelopes
└── public key = basepoint · scalar (published, not secret)

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 info as HPKE's kem_context does — 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 (encp prefix attached by the serialization layer in #3094):

[ephemeral public key (32)][nonce (12)][ciphertext + GCM tag (16)]

Nothing produces encp yet

This PR 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 (opening a sealed payload needs the private scalar, not the symmetric key).

Notes for reviewers

  • encapsulate/decapsulate are 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.
  • Web Crypto cannot import a raw X25519 private key, so a derived scalar is wrapped in a fixed PKCS#8 prefix (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 against node:crypto's native X25519.
  • Low-order public keys and wrong-length keys are rejected by the platform (OperationError on all-zero shared secret), giving us HPKE's contributory-behavior check for free — asserted in tests.
  • Optional AAD added to the AES helpers; passing no AAD is byte-identical to previous behavior.

30 new tests. Full core unit suite passes; workspace typecheck clean.

CopilotAI review requested due to automatic review settings July 24, 2026 20:56
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 24, 2026 20:56
@vercel

vercelBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 10850c2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
NameType
@workflow/coreMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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

@github-actions

github-actionsBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 10850c2 · Mon, 27 Jul 2026 18:57:37 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1302 (+83%) 🔻1351 🔴 (+31%) 🔻1380 🔴 (+27%) 🔻1394 🔴 (-1.8%)30
TTFSstream1219 (+42%) 🔻1339 🔴 (+40%) 🔻1349 🔴 (+39%) 🔻1562 🔴 (+57%) 🔻30
TTFShook + stream510 (-53%) 💚1589 🔴 (+29%) 🔻1642 🔴 (+30%) 🔻2450 🔴 (+67%) 🔻30
STSO1020 steps (1-20)175 (-3.3%)271 🔴 (-1.8%)489 🔴 (+61%) 🔻494 🔴 (+25%) 🔻19
STSO1020 steps (101-120)183 (-1.6%)269 🔴 (+0.7%)303 🔴 (+0.7%)1137 🔴 (-53%) 💚19
STSO1020 steps (1001-1020)462 (+3.4%)549 🔴 (+6.4%)614 🔴 (+14%)799 🔴 (+22%) 🔻19
WO1020 steps385142 (±0%)385142 (±0%)385142 (±0%)385142 (±0%)1
SLstream latency93 (+21%) 🔻179 🔴 (+44%) 🔻209 🔴 (+45%) 🔻366 🔴 (+44%) 🔻30
SOstream overhead (text)105 (+8.2%)149 (-3.9%)153 (-13%)176 (-11%)30
SOstream overhead (structured)106 (+5.0%)167 (-0.6%)243 (+37%) 🔻360 (-76%) 💚30
📜 Previous results (3)

916cffb

Sat, 25 Jul 2026 09:10:27 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep213 (-70%) 💚1322 🔴 (+27%) 🔻1339 🔴 (+21%) 🔻1456 🔴 (+26%) 🔻30
TTFSstream198 (-80%) 💚1322 🔴 (+23%) 🔻1362 🔴 (+18%) 🔻1564 🔴 (+22%) 🔻30
TTFShook + stream389 (-69%) 💚1525 🔴 (+8.5%)1545 🔴 (+6.6%)1652 🔴 (-1.4%)30
STSO1020 steps (1-20)181 (-9.0%)297 🔴 (+7.6%)489 🔴 (+42%) 🔻525 🔴 (+36%) 🔻19
STSO1020 steps (101-120)195 (-9.3%)258 🔴 (-7.9%)352 🔴 (+18%) 🔻360 🔴 (-4.5%)19
STSO1020 steps (1001-1020)471 (-3.7%)585 🔴 (+4.5%)606 🔴 (+1.7%)677 🔴 (+12%)19
WO1020 steps404095 (-2.0%)404095 (-2.0%)404095 (-2.0%)404095 (-2.0%)1
SLstream latency94 (-5.1%)168 🔴 (+28%) 🔻194 🔴 (+31%) 🔻669 🔴 (+222%) 🔻30
SOstream overhead (text)115 (-12%)252 🔴 (+16%) 🔻321 (+30%) 🔻682 (+153%) 🔻30
SOstream overhead (structured)117 (-16%) 💚194 (-12%)253 (+1.6%)372 (-47%) 💚30

bf9a09c

Fri, 24 Jul 2026 23:23:29 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep440 (-39%) 💚1322 🔴 (+28%) 🔻1338 🔴 (+25%) 🔻1677 🔴 (+33%) 🔻30
STSO1020 steps (1-20)180 (+9.1%)308 🔴 (+11%)358 🔴 (-12%)368 🔴 (-11%)19
STSO1020 steps (101-120)179 (+3.5%)297 🔴 (+26%) 🔻300 🔴 (+14%)351 🔴 (-14%)19
STSO1020 steps (1001-1020)486 (+5.7%)550 🔴 (+4.4%)664 🔴 (+18%) 🔻666 🔴 (+18%) 🔻19
WO1020 steps394683 (+1.1%)394683 (+1.1%)394683 (+1.1%)394683 (+1.1%)1
SLstream latency95 (+19%) 🔻174 🔴 (+58%) 🔻198 🔴 (+43%) 🔻316 🔴 (-8.4%)30
SOstream overhead (text)111 (+1.8%)150 (-4.5%)198 (+5.3%)246 (-7.5%)30
SOstream overhead (structured)113 (+24%) 🔻176 (+12%)219 (+30%) 🔻233 (-63%) 💚30

db8e937

Fri, 24 Jul 2026 21:18:46 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep419 (-42%) 💚1170 🔴 (+12%)1261 🔴 (+18%) 🔻1421 🔴 (-1.3%)30
TTFSstream1108 (+16%) 🔻1153 🔴 (+13%)1167 🔴 (+13%)1384 🔴 (+32%) 🔻30
TTFShook + stream1451 (+22%) 🔻1521 🔴 (+15%) 🔻1632 🔴 (+18%) 🔻2153 🔴 (+29%) 🔻30
STSO1020 steps (1-20)176 (+8.6%)316 🔴 (+27%) 🔻341 🔴 (+13%)694 🔴 (+128%) 🔻19
STSO1020 steps (101-120)165 (-11%)239 🔴 (-14%)302 🔴 (-7.4%)402 🔴 (+13%)19
STSO1020 steps (1001-1020)456 (±0%)521 🔴 (-6.0%)550 🔴 (-8.0%)650 🔴 (-21%) 💚19
WO1020 steps384982 (+0.7%)384982 (+0.7%)384982 (+0.7%)384982 (+0.7%)1
SLstream latency81 (+8.0%)153 🔴 (+26%) 🔻163 🔴 (+18%) 🔻201 🔴 (-76%) 💚30
SOstream overhead (structured)101 (+3.1%)142 (-17%) 💚159 (-36%) 💚281 (±0%)30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

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 (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 encp as 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
FileDescription
packages/core/src/serialization/types.tsAdds SEALED: 'encp' format prefix with inline documentation.
packages/core/src/serialization-format.tsTreats encp as ciphertext for display; adds isSealedData; avoids AES decrypt attempts for sealed payloads in hydrateDataWithKey.
packages/core/src/serialization-format.test.tsAdds regression tests for encp detection and hydration pass-through behavior.
packages/core/src/sealed-box.tsNew sealed-box primitive (derive run keypair, encapsulate/decapsulate, seal/open, run-bound AAD helper).
packages/core/src/sealed-box.test.tsNew test suite validating correctness, tamper detection, AAD behavior, and cross-implementation checks.
packages/core/src/encryption.tsAdds optional AAD to AES-GCM encrypt/decrypt helpers.
packages/core/src/capabilities.tsAdds encp to the run capabilities format version table and history notes.
.changeset/encp-sealed-box.mdChangeset for @workflow/core minor release introducing encp primitive.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment threadpackages/core/src/sealed-box.ts Outdated
Comment threadpackages/core/src/sealed-box.ts
Comment threadpackages/core/src/sealed-box.ts Outdated

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestion:

Accidentally committed quickjs-assets.generated.ts imports quickjs-wasi, which is not a dependency of @workflow/core, causing tsc to fail with TS2307 and breaking every deployment.

Fix on Vercel

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.

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 4ba223a (AI decision).

This is new feature work: it adds an encp sealed-box encryption primitive (new sealed-box.ts module, new SEALED serialization format, new isSealedData export, optional AAD parameters on the AES helpers) as PR 1 of 7 in a stack that removes a cross-deployment key lookup. The commit explicitly ships with a minor changeset and nothing yet produces encp payloads, so there is no user-visible defect on stable being fixed. The defensive o11y hardening (not attempting AES decrypt on sealed payloads) only matters once later PRs in the stack start writing encp, which will never happen on stable.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

4ba223a01c56126aec5c982d3583f779ed96e8ca

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@TooTallNate@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(core): add `encp` sealed-box encryption primitive by TooTallNate · Pull Request #3093 · vercel/workflow · GitHub
Skip to content

feat(core): add encp sealed-box encryption primitive - #3093

Merged
TooTallNate merged 3 commits into
mainfrom
nate/encp-sealed-box
Jul 27, 2026
Merged

feat(core): add encp sealed-box encryption primitive#3093
TooTallNate merged 3 commits into
mainfrom
nate/encp-sealed-box

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Jul 24, 2026

Copy link
Copy Markdown
Member

PR 1 of 7 in a stack that removes the ~350ms cross-deployment encryption-key lookup from every hot path.

Why

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-key API 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 K that World.getEncryptionKeyForRun() already returns, so key acquisition, the World interface, and the Vercel API are all untouched:

K (32 bytes, per-run, = HKDF(VERCEL_DEPLOYMENT_KEY, "projectId|runId"))
├── AES-256 key = K used directly → 'encr' envelopes (unchanged)
└── X25519 scalar = HKDF(K, versioned label) → 'encp' envelopes
└── public key = basepoint · scalar (published, not secret)

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 info as HPKE's kem_context does — 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 (encp prefix attached by the serialization layer in #3094):

[ephemeral public key (32)][nonce (12)][ciphertext + GCM tag (16)]

Nothing produces encp yet

This PR 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 (opening a sealed payload needs the private scalar, not the symmetric key).

Notes for reviewers

  • encapsulate/decapsulate are 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.
  • Web Crypto cannot import a raw X25519 private key, so a derived scalar is wrapped in a fixed PKCS#8 prefix (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 against node:crypto's native X25519.
  • Low-order public keys and wrong-length keys are rejected by the platform (OperationError on all-zero shared secret), giving us HPKE's contributory-behavior check for free — asserted in tests.
  • Optional AAD added to the AES helpers; passing no AAD is byte-identical to previous behavior.

30 new tests. Full core unit suite passes; workspace typecheck clean.

CopilotAI review requested due to automatic review settings July 24, 2026 20:56
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 24, 2026 20:56
@vercel

vercelBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 10850c2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
NameType
@workflow/coreMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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

@github-actions

github-actionsBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 10850c2 · Mon, 27 Jul 2026 18:57:37 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1302 (+83%) 🔻1351 🔴 (+31%) 🔻1380 🔴 (+27%) 🔻1394 🔴 (-1.8%)30
TTFSstream1219 (+42%) 🔻1339 🔴 (+40%) 🔻1349 🔴 (+39%) 🔻1562 🔴 (+57%) 🔻30
TTFShook + stream510 (-53%) 💚1589 🔴 (+29%) 🔻1642 🔴 (+30%) 🔻2450 🔴 (+67%) 🔻30
STSO1020 steps (1-20)175 (-3.3%)271 🔴 (-1.8%)489 🔴 (+61%) 🔻494 🔴 (+25%) 🔻19
STSO1020 steps (101-120)183 (-1.6%)269 🔴 (+0.7%)303 🔴 (+0.7%)1137 🔴 (-53%) 💚19
STSO1020 steps (1001-1020)462 (+3.4%)549 🔴 (+6.4%)614 🔴 (+14%)799 🔴 (+22%) 🔻19
WO1020 steps385142 (±0%)385142 (±0%)385142 (±0%)385142 (±0%)1
SLstream latency93 (+21%) 🔻179 🔴 (+44%) 🔻209 🔴 (+45%) 🔻366 🔴 (+44%) 🔻30
SOstream overhead (text)105 (+8.2%)149 (-3.9%)153 (-13%)176 (-11%)30
SOstream overhead (structured)106 (+5.0%)167 (-0.6%)243 (+37%) 🔻360 (-76%) 💚30
📜 Previous results (3)

916cffb

Sat, 25 Jul 2026 09:10:27 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep213 (-70%) 💚1322 🔴 (+27%) 🔻1339 🔴 (+21%) 🔻1456 🔴 (+26%) 🔻30
TTFSstream198 (-80%) 💚1322 🔴 (+23%) 🔻1362 🔴 (+18%) 🔻1564 🔴 (+22%) 🔻30
TTFShook + stream389 (-69%) 💚1525 🔴 (+8.5%)1545 🔴 (+6.6%)1652 🔴 (-1.4%)30
STSO1020 steps (1-20)181 (-9.0%)297 🔴 (+7.6%)489 🔴 (+42%) 🔻525 🔴 (+36%) 🔻19
STSO1020 steps (101-120)195 (-9.3%)258 🔴 (-7.9%)352 🔴 (+18%) 🔻360 🔴 (-4.5%)19
STSO1020 steps (1001-1020)471 (-3.7%)585 🔴 (+4.5%)606 🔴 (+1.7%)677 🔴 (+12%)19
WO1020 steps404095 (-2.0%)404095 (-2.0%)404095 (-2.0%)404095 (-2.0%)1
SLstream latency94 (-5.1%)168 🔴 (+28%) 🔻194 🔴 (+31%) 🔻669 🔴 (+222%) 🔻30
SOstream overhead (text)115 (-12%)252 🔴 (+16%) 🔻321 (+30%) 🔻682 (+153%) 🔻30
SOstream overhead (structured)117 (-16%) 💚194 (-12%)253 (+1.6%)372 (-47%) 💚30

bf9a09c

Fri, 24 Jul 2026 23:23:29 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep440 (-39%) 💚1322 🔴 (+28%) 🔻1338 🔴 (+25%) 🔻1677 🔴 (+33%) 🔻30
STSO1020 steps (1-20)180 (+9.1%)308 🔴 (+11%)358 🔴 (-12%)368 🔴 (-11%)19
STSO1020 steps (101-120)179 (+3.5%)297 🔴 (+26%) 🔻300 🔴 (+14%)351 🔴 (-14%)19
STSO1020 steps (1001-1020)486 (+5.7%)550 🔴 (+4.4%)664 🔴 (+18%) 🔻666 🔴 (+18%) 🔻19
WO1020 steps394683 (+1.1%)394683 (+1.1%)394683 (+1.1%)394683 (+1.1%)1
SLstream latency95 (+19%) 🔻174 🔴 (+58%) 🔻198 🔴 (+43%) 🔻316 🔴 (-8.4%)30
SOstream overhead (text)111 (+1.8%)150 (-4.5%)198 (+5.3%)246 (-7.5%)30
SOstream overhead (structured)113 (+24%) 🔻176 (+12%)219 (+30%) 🔻233 (-63%) 💚30

db8e937

Fri, 24 Jul 2026 21:18:46 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep419 (-42%) 💚1170 🔴 (+12%)1261 🔴 (+18%) 🔻1421 🔴 (-1.3%)30
TTFSstream1108 (+16%) 🔻1153 🔴 (+13%)1167 🔴 (+13%)1384 🔴 (+32%) 🔻30
TTFShook + stream1451 (+22%) 🔻1521 🔴 (+15%) 🔻1632 🔴 (+18%) 🔻2153 🔴 (+29%) 🔻30
STSO1020 steps (1-20)176 (+8.6%)316 🔴 (+27%) 🔻341 🔴 (+13%)694 🔴 (+128%) 🔻19
STSO1020 steps (101-120)165 (-11%)239 🔴 (-14%)302 🔴 (-7.4%)402 🔴 (+13%)19
STSO1020 steps (1001-1020)456 (±0%)521 🔴 (-6.0%)550 🔴 (-8.0%)650 🔴 (-21%) 💚19
WO1020 steps384982 (+0.7%)384982 (+0.7%)384982 (+0.7%)384982 (+0.7%)1
SLstream latency81 (+8.0%)153 🔴 (+26%) 🔻163 🔴 (+18%) 🔻201 🔴 (-76%) 💚30
SOstream overhead (structured)101 (+3.1%)142 (-17%) 💚159 (-36%) 💚281 (±0%)30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

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 (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 encp as 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
FileDescription
packages/core/src/serialization/types.tsAdds SEALED: 'encp' format prefix with inline documentation.
packages/core/src/serialization-format.tsTreats encp as ciphertext for display; adds isSealedData; avoids AES decrypt attempts for sealed payloads in hydrateDataWithKey.
packages/core/src/serialization-format.test.tsAdds regression tests for encp detection and hydration pass-through behavior.
packages/core/src/sealed-box.tsNew sealed-box primitive (derive run keypair, encapsulate/decapsulate, seal/open, run-bound AAD helper).
packages/core/src/sealed-box.test.tsNew test suite validating correctness, tamper detection, AAD behavior, and cross-implementation checks.
packages/core/src/encryption.tsAdds optional AAD to AES-GCM encrypt/decrypt helpers.
packages/core/src/capabilities.tsAdds encp to the run capabilities format version table and history notes.
.changeset/encp-sealed-box.mdChangeset for @workflow/core minor release introducing encp primitive.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment threadpackages/core/src/sealed-box.ts Outdated
Comment threadpackages/core/src/sealed-box.ts
Comment threadpackages/core/src/sealed-box.ts Outdated

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestion:

Accidentally committed quickjs-assets.generated.ts imports quickjs-wasi, which is not a dependency of @workflow/core, causing tsc to fail with TS2307 and breaking every deployment.

Fix on Vercel

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.

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 4ba223a (AI decision).

This is new feature work: it adds an encp sealed-box encryption primitive (new sealed-box.ts module, new SEALED serialization format, new isSealedData export, optional AAD parameters on the AES helpers) as PR 1 of 7 in a stack that removes a cross-deployment key lookup. The commit explicitly ships with a minor changeset and nothing yet produces encp payloads, so there is no user-visible defect on stable being fixed. The defensive o11y hardening (not attempting AES decrypt on sealed payloads) only matters once later PRs in the stack start writing encp, which will never happen on stable.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

4ba223a01c56126aec5c982d3583f779ed96e8ca

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@TooTallNate@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat(core): add `encp` sealed-box encryption primitive by TooTallNate · Pull Request #3093 · vercel/workflow · GitHub
Skip to content

feat(core): add encp sealed-box encryption primitive - #3093

Merged
TooTallNate merged 3 commits into
mainfrom
nate/encp-sealed-box
Jul 27, 2026
Merged

feat(core): add encp sealed-box encryption primitive#3093
TooTallNate merged 3 commits into
mainfrom
nate/encp-sealed-box

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Jul 24, 2026

Copy link
Copy Markdown
Member

PR 1 of 7 in a stack that removes the ~350ms cross-deployment encryption-key lookup from every hot path.

Why

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-key API 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 K that World.getEncryptionKeyForRun() already returns, so key acquisition, the World interface, and the Vercel API are all untouched:

K (32 bytes, per-run, = HKDF(VERCEL_DEPLOYMENT_KEY, "projectId|runId"))
├── AES-256 key = K used directly → 'encr' envelopes (unchanged)
└── X25519 scalar = HKDF(K, versioned label) → 'encp' envelopes
└── public key = basepoint · scalar (published, not secret)

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 info as HPKE's kem_context does — 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 (encp prefix attached by the serialization layer in #3094):

[ephemeral public key (32)][nonce (12)][ciphertext + GCM tag (16)]

Nothing produces encp yet

This PR 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 (opening a sealed payload needs the private scalar, not the symmetric key).

Notes for reviewers

  • encapsulate/decapsulate are 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.
  • Web Crypto cannot import a raw X25519 private key, so a derived scalar is wrapped in a fixed PKCS#8 prefix (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 against node:crypto's native X25519.
  • Low-order public keys and wrong-length keys are rejected by the platform (OperationError on all-zero shared secret), giving us HPKE's contributory-behavior check for free — asserted in tests.
  • Optional AAD added to the AES helpers; passing no AAD is byte-identical to previous behavior.

30 new tests. Full core unit suite passes; workspace typecheck clean.

CopilotAI review requested due to automatic review settings July 24, 2026 20:56
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 24, 2026 20:56
@vercel

vercelBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 10850c2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
NameType
@workflow/coreMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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

@github-actions

github-actionsBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 10850c2 · Mon, 27 Jul 2026 18:57:37 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1302 (+83%) 🔻1351 🔴 (+31%) 🔻1380 🔴 (+27%) 🔻1394 🔴 (-1.8%)30
TTFSstream1219 (+42%) 🔻1339 🔴 (+40%) 🔻1349 🔴 (+39%) 🔻1562 🔴 (+57%) 🔻30
TTFShook + stream510 (-53%) 💚1589 🔴 (+29%) 🔻1642 🔴 (+30%) 🔻2450 🔴 (+67%) 🔻30
STSO1020 steps (1-20)175 (-3.3%)271 🔴 (-1.8%)489 🔴 (+61%) 🔻494 🔴 (+25%) 🔻19
STSO1020 steps (101-120)183 (-1.6%)269 🔴 (+0.7%)303 🔴 (+0.7%)1137 🔴 (-53%) 💚19
STSO1020 steps (1001-1020)462 (+3.4%)549 🔴 (+6.4%)614 🔴 (+14%)799 🔴 (+22%) 🔻19
WO1020 steps385142 (±0%)385142 (±0%)385142 (±0%)385142 (±0%)1
SLstream latency93 (+21%) 🔻179 🔴 (+44%) 🔻209 🔴 (+45%) 🔻366 🔴 (+44%) 🔻30
SOstream overhead (text)105 (+8.2%)149 (-3.9%)153 (-13%)176 (-11%)30
SOstream overhead (structured)106 (+5.0%)167 (-0.6%)243 (+37%) 🔻360 (-76%) 💚30
📜 Previous results (3)

916cffb

Sat, 25 Jul 2026 09:10:27 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep213 (-70%) 💚1322 🔴 (+27%) 🔻1339 🔴 (+21%) 🔻1456 🔴 (+26%) 🔻30
TTFSstream198 (-80%) 💚1322 🔴 (+23%) 🔻1362 🔴 (+18%) 🔻1564 🔴 (+22%) 🔻30
TTFShook + stream389 (-69%) 💚1525 🔴 (+8.5%)1545 🔴 (+6.6%)1652 🔴 (-1.4%)30
STSO1020 steps (1-20)181 (-9.0%)297 🔴 (+7.6%)489 🔴 (+42%) 🔻525 🔴 (+36%) 🔻19
STSO1020 steps (101-120)195 (-9.3%)258 🔴 (-7.9%)352 🔴 (+18%) 🔻360 🔴 (-4.5%)19
STSO1020 steps (1001-1020)471 (-3.7%)585 🔴 (+4.5%)606 🔴 (+1.7%)677 🔴 (+12%)19
WO1020 steps404095 (-2.0%)404095 (-2.0%)404095 (-2.0%)404095 (-2.0%)1
SLstream latency94 (-5.1%)168 🔴 (+28%) 🔻194 🔴 (+31%) 🔻669 🔴 (+222%) 🔻30
SOstream overhead (text)115 (-12%)252 🔴 (+16%) 🔻321 (+30%) 🔻682 (+153%) 🔻30
SOstream overhead (structured)117 (-16%) 💚194 (-12%)253 (+1.6%)372 (-47%) 💚30

bf9a09c

Fri, 24 Jul 2026 23:23:29 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep440 (-39%) 💚1322 🔴 (+28%) 🔻1338 🔴 (+25%) 🔻1677 🔴 (+33%) 🔻30
STSO1020 steps (1-20)180 (+9.1%)308 🔴 (+11%)358 🔴 (-12%)368 🔴 (-11%)19
STSO1020 steps (101-120)179 (+3.5%)297 🔴 (+26%) 🔻300 🔴 (+14%)351 🔴 (-14%)19
STSO1020 steps (1001-1020)486 (+5.7%)550 🔴 (+4.4%)664 🔴 (+18%) 🔻666 🔴 (+18%) 🔻19
WO1020 steps394683 (+1.1%)394683 (+1.1%)394683 (+1.1%)394683 (+1.1%)1
SLstream latency95 (+19%) 🔻174 🔴 (+58%) 🔻198 🔴 (+43%) 🔻316 🔴 (-8.4%)30
SOstream overhead (text)111 (+1.8%)150 (-4.5%)198 (+5.3%)246 (-7.5%)30
SOstream overhead (structured)113 (+24%) 🔻176 (+12%)219 (+30%) 🔻233 (-63%) 💚30

db8e937

Fri, 24 Jul 2026 21:18:46 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep419 (-42%) 💚1170 🔴 (+12%)1261 🔴 (+18%) 🔻1421 🔴 (-1.3%)30
TTFSstream1108 (+16%) 🔻1153 🔴 (+13%)1167 🔴 (+13%)1384 🔴 (+32%) 🔻30
TTFShook + stream1451 (+22%) 🔻1521 🔴 (+15%) 🔻1632 🔴 (+18%) 🔻2153 🔴 (+29%) 🔻30
STSO1020 steps (1-20)176 (+8.6%)316 🔴 (+27%) 🔻341 🔴 (+13%)694 🔴 (+128%) 🔻19
STSO1020 steps (101-120)165 (-11%)239 🔴 (-14%)302 🔴 (-7.4%)402 🔴 (+13%)19
STSO1020 steps (1001-1020)456 (±0%)521 🔴 (-6.0%)550 🔴 (-8.0%)650 🔴 (-21%) 💚19
WO1020 steps384982 (+0.7%)384982 (+0.7%)384982 (+0.7%)384982 (+0.7%)1
SLstream latency81 (+8.0%)153 🔴 (+26%) 🔻163 🔴 (+18%) 🔻201 🔴 (-76%) 💚30
SOstream overhead (structured)101 (+3.1%)142 (-17%) 💚159 (-36%) 💚281 (±0%)30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

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 (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 encp as 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
FileDescription
packages/core/src/serialization/types.tsAdds SEALED: 'encp' format prefix with inline documentation.
packages/core/src/serialization-format.tsTreats encp as ciphertext for display; adds isSealedData; avoids AES decrypt attempts for sealed payloads in hydrateDataWithKey.
packages/core/src/serialization-format.test.tsAdds regression tests for encp detection and hydration pass-through behavior.
packages/core/src/sealed-box.tsNew sealed-box primitive (derive run keypair, encapsulate/decapsulate, seal/open, run-bound AAD helper).
packages/core/src/sealed-box.test.tsNew test suite validating correctness, tamper detection, AAD behavior, and cross-implementation checks.
packages/core/src/encryption.tsAdds optional AAD to AES-GCM encrypt/decrypt helpers.
packages/core/src/capabilities.tsAdds encp to the run capabilities format version table and history notes.
.changeset/encp-sealed-box.mdChangeset for @workflow/core minor release introducing encp primitive.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment threadpackages/core/src/sealed-box.ts Outdated
Comment threadpackages/core/src/sealed-box.ts
Comment threadpackages/core/src/sealed-box.ts Outdated

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestion:

Accidentally committed quickjs-assets.generated.ts imports quickjs-wasi, which is not a dependency of @workflow/core, causing tsc to fail with TS2307 and breaking every deployment.

Fix on Vercel

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.

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 4ba223a (AI decision).

This is new feature work: it adds an encp sealed-box encryption primitive (new sealed-box.ts module, new SEALED serialization format, new isSealedData export, optional AAD parameters on the AES helpers) as PR 1 of 7 in a stack that removes a cross-deployment key lookup. The commit explicitly ships with a minor changeset and nothing yet produces encp payloads, so there is no user-visible defect on stable being fixed. The defensive o11y hardening (not attempting AES decrypt on sealed payloads) only matters once later PRs in the stack start writing encp, which will never happen on stable.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

4ba223a01c56126aec5c982d3583f779ed96e8ca

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@TooTallNate@karthikscale3