A worked example of a message arriving at a node, then the trust model, the load-bearing invariants, and the channel AKE, collected in one place.
Part of the seed kernel spec. Section numbers are global across the doc set — a
(§X.Y)reference points to whichever file below holds that section:README §1 · PROTOCOL §2–§5, §16 · RUNTIME §10–§12 · SECURITY §13–§14
A chat text message arriving at a fully bootstrapped node, traced through every boundary. Assume nodes A and B are already linked over an authenticated channel (§12.6 — the AKE handshake has completed, so the link is encrypted and bound to each end's channel key), and B has the chat v1 app installed from a signed bundle (§12.4, §11) — a guest plus its chat module — with the protocol id chat-v1 bound to it (§12.10). A sends the text "hello, world".
Pipeline trace at B:
- The link opens an AEAD record. The transport bundle's guest program (§12.6) decrypts the inbound ChaCha20-Poly1305 record under the receive key and the next expected per-direction counter (§12.6). A bad tag or an out-of-order counter tears the link down; a good one yields the plaintext frame and the authenticated peer key
_from = <A pubkey>. There is no per-message signature to check — the AUTH exchanged once at handshake already bound this channel to A's identity, so every frame it carries is attributed to A by construction. - The shell parses its own frame and resolves the protocol. The Transport req frame carries
[protocolId "chat-v1"][type 0x00][chatType 0x00][body "hello, world"](§12.6, §12.10). It resolves the protocol claim directly to the complete slot B installed, then invokes that slot's guest. A protocol no installed bundle claims reaches no guest and is answered with an empty body. A names only the protocol; which verified code runs is settled entirely by B's installed claim map. - The shell assembles the guest input. It prepends the authenticated sender key from the channel:
input = <A pubkey 32> ‖ 0x00 ‖ "hello, world". The identity comes from_from, never from anything inside the frame body — a peer cannot claim to be someone else, because the bytes it can choose don't include the attribution. - Guest → entrypoint. The slot's realm invokes its guest's
handleentrypoint with the assembled input, under the execution budget (§12.3).host.call("chat", …)resolves only in the private pure-module set captured by that slot; no app key or cross-app namespace is involved. An unknown name is refused like any other catalog error; a module that runs and fails answers empty (§3). - Invoke the pure transform. Through the guest's module call, the host copies
inputinto the module'sscratchregion and callshandle(input_len). The v1 module reads[pk 32][type][body], writes the render bytes[type][pk_len][pk][body]back atscratch, and returns their length (§11). It imports nothing, reaches nothing, and asks no one who sent this — everything it needs is in the input (§4.2). - Render. The guest returns the render bytes from its
handle; the shellpostMessages them to the sandboxed iframe, which draws the line attributed to A. Done.
That is the entire steady-state pipeline: one AEAD open, one routing lookup, one guest entrypoint call, one pure-transform call, one copy each way. No asymmetric cryptography, no signature verification, no dispatch recursion — the message path is pure data movement.
The two places cryptography does run, both off this path:
- Once per link — the AKE handshake (§12.6). That is what made
_fromtrustworthy in step 1; every frame afterward rides its session keys. - Once per install — admitting the chat bundle (§12.4) verified the author's Ed25519 manifest signature over the module's content hash. A relayed
OFFER(§11) re-runs exactly that check on the receiving node, which is why a bundle authenticates against its original author across any number of relays while a chat message authenticates only its single hop. An app that needs the message to survive relaying — a feed or forum — reintroduces a per-message signature and a backlink chain itself (§5.1); that is a different app from chat.
The security properties are introduced where they arise (§3.1, §4.2–§4.3, §12.4–§12.6); this section collects the trust assumptions and the load-bearing invariants in one place so an implementer or auditor can see the whole model at once.
Trust boundary / TCB. The host is the trusted computing base. Anyone with host-process access controls slot construction, claim routing, and admission, so bundle signatures constrain an author, never the operator. The bundled crypto library and shipped host JS are part of that TCB. Everything above it is sandboxed WASM or a zero-authority JS guest, and neither reaches the outside world except through what its slot wires.
Authenticity is the channel's, per hop. The runtime carries no per-message signature. The node↔node transport (§12.6) opens each link with an authenticated key exchange and then attributes every frame to the peer that sent it — so "who sent this frame" is answered by the channel, cryptographically, for the immediate peer. That is end-to-end for a direct exchange like chat, where a message travels one hop (§11). It is not end-to-end across relays: a node that forwards another peer's message can only be authenticated as the forwarder, not the origin. An application that relays messages — a feed, a forum, store-and-forward gossip — therefore authenticates the original author itself, with a per-message signature plus backlinks (a hash-chain, §5.1); the runtime does not do this for it, and doing it in the host would be the wrong layer.
Signing survives in exactly two places. With per-message envelope signatures gone, the runtime produces signatures only for (1) the bundle manifest that installs code (§12.4) — which must authenticate its author across relays, exactly the relayed-content case above — and (2) the channel AUTH transcript that opens a link (§12.6). Both are off the message path. Only the first is host-authored: the AUTH transcript is assembled by the transport bundle's guest and signed through the ordinary guest-seam node/sign name, with the host supplying the domain and the scope from the asking bundle's slot. Which is the general rule — every signature a guest obtains is domain-scoped by the host, and the private key never enters a realm (below).
Domain-separated signature contexts. Every signature the runtime makes is over a context-specific prefix: DOMAIN_manifest for bundle manifests (§12.4), DOMAIN_guest for guest node/sign (§12.2), and DOMAIN_channel for the handshake transcript (§12.6). The three prefixes are disjoint and prepended before signing (never transmitted), so a signature harvested in one context cannot verify in another — cross-context replay is closed by construction. The family lives in one file (§16.1) so disjointness is checked as a property of the whole set; with the envelope wrapper gone, so is the one target that used to restate a fourth prefix (DOMAIN_env) by hand.
The channel handshake is an authenticated key exchange (AKE), and it conceals both identities. §12.6's four messages carry a fresh ephemeral X25519 key from each end, and each side signs DOMAIN_channel ‖ transcript-so-far ‖ its own id — the transcript chaining the suite, both ephemeral keys and both nonces — so the identity signature binds the key exchange (SIGMA-style; since each signature covers its own identity explicitly there is no separate identity-MAC seam). A node induced to sign produces a signature bound to that exchange, useless elsewhere, so frame attribution stays sound against impersonation and misattribution. From the ephemeral–ephemeral DH, the contact secret and the transcript hash both ends derive per-direction session keys, and every post-handshake frame is a ChaCha20-Poly1305 record with an implicit (epoch, counter) nonce — forgery resistance, replay resistance (strict counter enforcement), and confidentiality in one mechanism, forward-secret because the DH keys are ephemeral. So the channel needs no external TLS/Noise tunnel.
Neither public key crosses the wire in the clear, and the ordering is deliberate: only a dialer speaks unprompted, and the caller names itself before the receiver names itself. So an observer who can open sockets cannot enumerate which identities are live where, and a caller the receiver declines learns nothing about it. Both identities travel under the ephemeral–ephemeral secret rather than under any long-term key, so seizing a node later does not retroactively deanonymise the peers that dialed it. The handshake uses no long-term Diffie–Hellman key at all, so the identity Ed25519 key stays signing-only and never becomes key-exchange material (§12.6.2b) — and unlike Secret Handshake, which converts an Ed25519 key into a DH key, keeping the two disjoint costs nothing here. A node address stays pk[.secret]@host:port — one key plus, optionally, that node's contact secret (§12.6.3).
The residual exposure is what any AEAD channel leaves, plus two things concealment does not reach. Traffic-analysis metadata (frame sizes and timing) and the endpoints remain visible, and a compromised node still sees its own plaintext. Beyond that: a stable host:port still identifies its node to anyone holding an address book and a packet capture — concealment defeats probing and flow attribution, not an observer who already knows where to look; hiding the communication graph itself is mixnet territory. And the cleartext suite byte makes the traffic fingerprintable as seedkernel, which identifies the protocol rather than the peer; hiding it would cost the self-describing format and the migration path of §14.1, which is not a trade worth making. The full limits list, and the reasoning behind each choice, is in CHANNEL.
The cryptographic root is the host, not a loadable module. The runtime fixes its signing algorithms and one hash — Ed25519, BLAKE2b-256 (the genesis algorithms, §16.1), and ML-DSA-65 as the PQ half of manifest suite 0x02 (§14.1) — and they are compiled into the host, not loaded and hash-pinned at boot. This is not an optimization: a verifier can never be delivered through its own mechanism. A PQ verifier shipped as a bundle would be admitted by the classical verifier, so it would be exactly as strong as the signature that let it in, and no stronger — you cannot bootstrap a stronger verifier through a weaker one. The version floor does not close this either: it refuses a low version inside a document whose signature is the thing that failed, so it stops rollback by a network attacker and not forgery by a capable one. That is why the manifest suite is the one component whose migration must land ahead of need. Both are libsodium core-API primitives, and BLAKE2b-256 is the only hash the whole system uses (content hashing, the blake2b-256 primitive a guest reaches by name, the AKE KDF, block ids), so the crypto backend a target must supply is just core libsodium. So the trust anchors are the host artifact itself (in the TCB) and the operator's policy author set (§12.5): swapping the genesis hash would shift every bytes_hash (§12.4) and every hash-derived app name, but bootstrap names are literal ASCII (§5.1), so they are independent of it. Pick the genesis algorithms once per deployment; §14.1 covers what changing them later costs, and which of them is on a clock.
What that root may grow, it may grow only under a test. Every name in the catalog (PRIMITIVE_NAMES and AUTHORITY_CALLS, core/domains.ts) is a name every target must implement identically and no deployment can replace, so the catalog is the shape of the TCB — and an addition is admitted only when one of three things holds:
- It costs the trusted base nothing, because the shipped trust root or the shipped transport bundle already carries the implementation.
blake2b-256anded25519/verifyare the manifest verifier's own and the channel's AUTH;x25519/dhandchacha20poly1305-ietf/{seal,open}are the handshake and the record layer;xchacha20/xoris a further core-API call of that same libsodium, which is why an app may reach it for encryption at rest without the TCB growing by a line. - It cannot be delivered as a module at all — the ML-DSA-65 verifier (a PQ verifier admitted by the classical one is exactly as strong as the classical one), the channel suite byte, the master-seed scheme: the three that would have to authorize their own replacement.
- It is a §14.1 migration provisioned ahead of its caller — today exactly
ml-kem-768/*.
What this refuses is the addition that needs a new implementation in the trusted base, and the refusal has teeth because the complement is where the interesting code lives: seed store is a complete storage layer — client-side encryption, Reed–Solomon erasure coding, content addressing, repair — and it added no name to either table, shipping its erasure coding and content addressing as two of its own WASM modules.
Bundle freshness. The manifest carries a monotonic version, enforced against a persisted per-(author, app) high-water mark (§12.4). The complete slot loads wholesale and neither modules nor guest has a per-item version, so one number guards the set. An equal-version reload rebuilds the same slot. A deliberate rollback remains an out-of-band operator action.
Freshness is not revocation, and the difference is the whole exposure of a stolen key. version orders an author's releases against each other; it cannot say the key stopped being the author's. An attacker holding a signing key admitted by the policy clears the freshness guard by construction — sign version + 1 — and lands on the app's existing names, because those names are derived from that same key (§5.1). Freshness sees an ordinary upgrade, and will see one for every release the attacker makes afterward. Signature verification is working exactly as specified throughout; what it proves is that the key signed the bundle, which is no longer the thing the operator wants to know.
The remedy is the written-off author set (§12.5), checked ahead of the version on every load: a revoked key's bundles are refused whatever they claim, and revoking uninstalls what the key already landed in the same action. It is local operator state, not a signed object — deliberately, because a distributable revocation needs a second trust set to say who may issue one, and that is a larger design than a deployment with an operator-held author allowlist needs (§12.5). The residual is the window between the compromise and the operator noticing it, which no host-side mechanism shortens; what this bounds is the damage afterward, and it removes the failure mode where a hurried operator uninstalls the app but leaves the key admitted, or edits the allowlist while the compromised code keeps serving. Detection remains out of scope: nothing in the runtime notices that a key has changed hands.
Guest signatures are domain-confined; the node's raw key never signs guest bytes. The guest ABI's node/sign (§12.2) is not a raw oracle: the host chooses both the domain prefix and the scope from the asking bundle's slot and signs domain ‖ scope ‖ msg over a suffix it does not read, never a preimage the guest supplied. Verification is confined the same way — node/verify applies the identical prefix to a caller-named key's signature, so an app checks signatures under its own bundle's namespace and never reconstructs host-owned prefix bytes. An ordinary app gets DOMAIN_guest ‖ (author_pk, app), so no app-obtainable signature verifies in any protocol context — not as a manifest (DOMAIN_manifest) or a channel AUTH (DOMAIN_channel) — nor in any other app's scope, since distinct bundles derive disjoint scopes.
The transport is the one occupant that gets DOMAIN_channel, with the network key as its scope — which is precisely the authority the transport is (§12.5), granted by a deliberate grants.link entry and by nothing an app can put in its own manifest (the bundle format has no role field a bundle could claim; naming a link/* name only moves the author under a grant they must already hold). It is still not raw: the domain, the scope and the key are all the host's choice, so the occupant cannot sign a manifest, cannot sign in an app's namespace, and cannot obtain the key itself. That is what lets the AKE be content — no handshake shape is pinned into the core — while the node's identity key stays behind the seam. What remains is deliberate: within its own scope an admitted app speaks for the node — a compromised guest can sign arbitrary statements in its namespace (a storage app's chunk descriptors, say). That is the authority the operator granted by running the bundle, and the oracle form contains it: the key can be used but never exfiltrated, so unloading the bundle revokes it instantly, where an app-held key would outlive its own compromise. Apps SHOULD sub-separate their object types under their scope (a distinct leading tag per signed format) — the same prefix-family discipline, one level down.
Replay and ordering are settled where the bytes live, never in the table. The table carries no state at all beyond the name→module map, and a module is stateless-by-input (§4.2), so neither has a notion of "seen this before." Each layer that owns bytes closes its own replay: the transport's strict per-direction counter rejects a replayed frame within a session (§12.6); bundle freshness refuses an older install (§12.4). What neither covers is a relayed message reaching a node over a different link than it originated on — precisely the case a relayed-message app takes on itself with per-message signatures and a backlink chain (§5.1). Installation touches no replay state because it is a host call under the loader, not a self-authenticating message anyone could resend (§12.4).
Ownership is structural. A slot contains the verified bundle and the exact realm, modules, filesystem scope, and signing scope derived for it. Modules never enter a shared namespace, while a claim points directly to that value. No ownership register or module-table lineage can drift from the code it describes; a second author creates a distinct verified identity even when app matches.
Nor is a log of installs missing. Bundle version supplies the ordering and downgrade protection successive slots need, scoped per verified (author, app); the loader is not an untrusted replicated history verifier.
This is the same boundary drawn for messages above: an app that needs verifiable lineage across relays is exactly the case for signatures + backlinks, and it builds that on top — logging its signed manifests or messages in a replicated append-only log (Bamboo is the design to borrow — entries commit to a payload hash, keeping it small) and feeding the loader or the app from it. The lineage lives where it can be verified, and the loader stays stateless.
A protocol claim is a preference, not a permission. The routing from protocol ids to apps (§12.10) is a projection of each installed manifest's signed protocols field, and it carries no security property: it cannot make unadmitted code run, widen a guest's requires, or let an app act in another's signing scope (§12.2). Every such decision is made by the admission policy (§12.5) and the signed manifest before routing is ever consulted, so the worst a wrong claim does is deliver messages to the wrong already-installed app. That is what makes it safe for an author to state the claim in what they sign and for the load to honour it: the operator's control stays where the authority is, in the policy that decided the code may run at all. The case that would carry authority — a capability-holding guest receiving someone else's protocol as a second, observing app — is therefore held to a different bar (§12.10) and is not what the single-valued map expresses.
Structural sandbox — nothing to pin, because nothing is imported. A module is a pure transform with no runtime seam (§4.2), so it cannot open a socket or file even if compromised. A guest's reach is exactly its manifest requires; an ungranted backend is not wired. Its modules, filesystem namespace, and signing oracle are already private values on its slot, so cross-app access is unrepresentable rather than checked by app key on each call.
Resource bounds. Confinement answers what untrusted code can reach; it says nothing about what it can consume. Memory is bounded by declaration, compute by deadline — the module-compute cell at each target's own engine lever rather than at a shared mechanism, since no engine mechanism is shared, but armed by default on both:
| Memory | Compute | |
|---|---|---|
| WASM module | Bounded — declared in the module, refused at admission (§4.1) | Bounded and interruptible, armed by default on every target. A module call carries a deadline — the calling guest's remaining execution segment (§4.3) — and a call that burns it is killed at the engine: on the JS targets the module runs in its own worker and terminate() destroys the isolate (the one interrupt JS exposes); on the native target wazero's WithCloseOnContextDone aborts it at a loop back-edge, at the same 5 s guest budget unless SEEDKERNEL_MODULE_DEADLINE_MS says otherwise (0 disarms), for 1.07–1.21x on the compute (§14). The killed call answers empty, exactly as a trap does. Reachable only through a guest calling one of its own modules (§12.10) |
| JS guest | Bounded — realm heap cap (§12.3) | Bounded — 5s execution budget (§12.3, §16.1) |
Module memory is bounded at admission, from the module's own declared limits, because instantiation is what allocates it — a check after the fact would run after the damage. It therefore lives on the shared load path (§3.2), where both targets get one implementation of it, rather than in each host's instantiation code where the two could drift. A module that declares no maximum is refused outright: an embedder cannot impose one afterwards, so undeclared means unbounded.
The guest's execution budget is measured over guest run time, not wall clock, which is what lets a single number bound a wedged holder without killing an initiator legitimately parked on the network (§12.3). It matters because the holder path runs guest code on the node's only thread in response to an inbound frame — and, since invocations are serialized per realm (§12.3), a wedged holder holds its realm's queue as well as the thread.
Module CPU is bounded at the engine, not in the realm. QuickJS's interrupt handler ticks between bytecode executions and a WASM call is one bytecode, so the guest's own budget cannot land inside a module call. What lands instead is a deadline the call carries: the calling guest's remaining execution segment (§12.3), computed by the realm at the moment of the call. The burn is then billed back to that segment when the call settles, and a segment with nothing left refuses the next call — which is what bounds a sequence of calls and not merely each one. Both halves are needed: the guest is parked while its module runs, so no clock of the realm's is open to notice the time; and the budget's own kill arrives through QuickJS's interrupt handler, consulted per bytecode executed, which a guest whose every turn is await host.call(…) would otherwise outlive indefinitely while holding a core. Module CPU is therefore bounded per call and in aggregate: a guest gets one budget's worth, however it spends it.
On the JS targets the lever is the worker kill. Each module runs in its own worker, instantiated once while the candidate slot is built. A call that burns its deadline is answered empty and its worker terminated; a fresh instance respawns for the next call. A spinning module burns at most one core for one budget, and the host thread never blocks.
That round trip measures (this machine, Node 20, WASM/tests/bench-module-call.mjs, on ws.wasm) as a fixed ~30 µs hop on a small call and ~160 µs on a 64 KiB transfer both ways. Standing a worker up costs ~30 ms per module at slot construction and once per kill-and-respawn; native instead pays wazero checks during compute.
On the native target the lever is wazero's WithCloseOnContextDone, armed by default. Both construction and calls run under a deadline because instantiation itself can execute a start section. SEEDKERNEL_MODULE_DEADLINE_MS overrides the default and 0 disables both the bound and its checks. Go may retain an opaque map behind each slot handle; that is target plumbing, not shell state.
The cost is a termination check compiled into every loop of every module on the armed runtime, and it is why the loader runs a patched wazero (arj03/wazero, branch inline-termination-check, pinned by commit in the replace in native/go.mod). Stock wazero pays for that check in the wrong currency: an unconditional exit from native code into Go on every back-edge — 2.1–3.1x on the seedstore write path — to fetch a single word, the module instance's Closed, which whoever interrupts the call has already written. The patch tests it inline and calls the trampoline only when it is set: same check, same granularity, same kill, at 1.21x on RS encode, 1.15x on RS decode, 1.07x on XChaCha20 (native/module_bound_bench_test.go). What is left is the branch, not the exits, so the tax scales with how many back-edges a module executes per byte — as much the module's number as the engine's, which is why reshaping RS's decode loop was worth it (seedstore WASM/assembly/codec/rs.ts: armed tax 1.50x → 1.15x).
Why it is a default and not a flag. A bound nobody turns on bounds nothing, and the price is small enough not to ask: ~1.1–1.2x on app modules, nothing at all on the TCB's, plus a fixed ~600 ns per call for the deadline context itself (BenchmarkBoundCallOverhead). That fixed part is the one to watch, because unlike the checks it does not scale with the work — but it is under a microsecond against the ~400 µs RS calls it sits in front of, and it buys not handing a spinning module the node's only thread. The JS targets have been armed all along, so an unarmed native target was the asymmetry, not the safe choice.
Two things do not change with the default. The TCB's own modules (libsodium, ML-DSA, ML-KEM) run on a separate, unarmed runtime: a wedged libsodium is a host bug, not a confinement breach. And the back-edge still exits to Go every 256 iterations, which it must — Go cannot asynchronously preempt a goroutine running compiled wasm, so that exit is a spinning loop's only safepoint, and an inline check alone would deadlock rather than merely slow GC: a stop-the-world waits on the spinning goroutine while freezing the very goroutine that would write Closed, so the deadline could never fire at all. 256 bounds that wait while GC pause and throughput come back to what exiting on every back-edge gave, so raising it buys nothing.
Every app is a guest (§12.4), so every inbound frame enters under the guest's execution budget (§12.3) and a module is reachable only through a guest calling one of its own by name, under that guest's remaining segment. A permissive policy (§12.5) multiplies the exposure across many installs. Deployers exposed to untrusted installs can pre-validate module bytecode in the admission policy (a bounded loop checker); the module-call bound itself is already armed on every target, so a wedged module costs an empty answer on a bounded budget, not the node.
Both targets enforce the guest budget by the same mechanism: QuickJS's interrupt handler, so exceeding the budget throws inside the guest and the realm survives it. On the JS/browser target that is setInterruptHandler (safe-js.ts); on the native target it is QJS_SetDeadline in the engine shim (native/qjs/csrc/qjs.c), armed by qjs.Runtime.Budget and consulted by the interpreter once every few thousand bytecodes. An unbounded realm installs no handler at all on either target, so a realm nobody budgeted pays nothing for the guard.
That parity was bought rather than inherited: New_QJS's maxExecutionTime argument is inert in upstream's qjs.wasm — its handler is commented out, so a 1 ms limit does not interrupt for(;;){f()} while the memory and stack arguments beside it do work. That left the native target with no interpreter-level lever, only an outside-in wazero deadline terminating the whole engine call — which put a termination check in every loop of QuickJS itself, 2.3x on a guest realm dispatch and 2.05x on a network round trip once the transport became a bundle and the whole data path ran inside a realm. Hence our own shim (native/qjs/build-qjs.sh).
On both targets a realm that is stopped mid-flight must settle whatever it still owes: a guest promise can only be resolved from inside the realm, so a kill that lands in a continuation leaves the caller waiting on something nothing can settle. safe-js fails its outstanding callers explicitly (and on dispose() too); the native realm does the same from within, then nudges the event loop, since rejecting a promise from Go only queues a microtask that the loop must still pump. It has to be host business rather than the guest's own promises finishing the job: the throw rejects the entrypoint's promise, but delivering that rejection is more queued guest work, which would run under the budget just exhausted and be interrupted in turn — so the host observes the interrupt (QJS_TakeInterrupted) to break the circle, a job's exception being consumed by the job loop and never surfacing to the caller that pumped it. A bound that converts a runaway guest into a hung host is not a bound.
A guest that overruns therefore fails one invocation, on either target: the caller gets an error and the realm stays usable. The exhausted budget stands for the rest of that invocation, so leftover jobs from the interrupted frame are stopped at their first check rather than buying a fresh allowance each time the loop pumps; the next entrypoint starts a fresh clock. Both targets bound the guest to the same 5s and now cost the same thing to exceed.
The runtime uses public-key cryptography in exactly two roles, and they are on different clocks. Treating them as one migration is the common mistake; the split is what makes the plan cheap.
Confidentiality is the urgent one, because the attack is retroactive. Every link derives its session keys from an ephemeral–ephemeral X25519 exchange (§12.6). An adversary who records ciphertext today and gains a cryptanalytically-relevant quantum computer later decrypts that recording then — harvest-now, decrypt-later. Forward secrecy does not help here: ephemeral keys protect against later compromise of a stored key, not against the DH problem itself becoming easy. So the exposure is set by how long a deployment's traffic stays sensitive, and traffic already on the wire cannot be retrofitted. This is the one place where waiting has a cost that cannot be paid off later.
Authenticity is not harvest-vulnerable. Ed25519 signs the bundle manifest (§12.4) and the channel AUTH transcript (§12.6). A signature only has to be unforgeable at the moment it is verified — a recording of one buys an attacker nothing, and a forgery capability acquired in 2035 cannot retroactively install a bundle in 2026. A signature migration can therefore begin when a break is credibly near, not before, and it does not need to be coordinated with the transport one.
The hash is already fine. BLAKE2b-256 is the one system hash (§5.1). Grover gives at most a quadratic speedup on preimage search — 256 bits down to a ~128-bit work factor — and the serial-depth requirements make even that unrealistic. Nothing here needs to change.
What the runtime does today to keep the path open. Both places the runtime uses public-key cryptography carry a suite byte, and both follow one discipline: the byte is the first field of the structure it governs, and it is part of what that structure's signature covers.
- Channel (§12.6) — first field of the initiator's opening message, and folded into the transcript hash both ends sign. An in-path attacker who flips it makes the two ends sign different transcripts, so verification fails.
- Manifest (§12.4) — first byte of the envelope, and part of the signed preimage under it. An attacker who rewrites it invalidates the manifest. It is what lets a verifier read the field widths before it can verify anything, and what makes the retirement of
0x01a refusal by name rather than a parse of the wrong widths.
Neither is a negotiation — one suite per link, one per manifest, unknown ids refused. The byte deliberately buys one thing: the format becomes self-describing, so a future suite can change every field width without the two formats being ambiguous. That is the difference between a rollout and a flag day where everything must cut over simultaneously. And because each byte must be read before verification (another suite's keys and signatures are other widths) while being covered by that verification, it is legible up front yet not editable underneath — so a suite is chosen by an endpoint and never forced by an attacker. Algorithm confusion between two suites is unrepresentable rather than merely unlikely.
The two ids are independent namespaces on independent clocks, which is why they are named apart (SUITE_CHANNEL_CONCEALED, SUITE_MANIFEST_HYBRID_PQ) rather than sharing one constant — and they are not merely named apart but declared in different layers. The manifest ids are the host's (core/domains.ts), because the loader reads the byte before anything is trusted; the channel id is the transport bundle's (transport/src/ake.js), because the AKE that reads it is entirely that program. So moving the channel suite is shipping a signed bundle, and moving the manifest suite is rebuilding the artifact — the clocks are independent because the delivery mechanisms are. Both have now moved, for unrelated reasons, which is what that looks like in practice. The channel is at 0x02 because the genesis suite 0x01, which carried both identities in cleartext, was removed outright rather than kept for compatibility: a node accepting both would have had the concealment of the weaker one. The manifest is at 0x02 because that migration is the one that can never get cheaper (below) — and its 0x01 was removed on the same argument, once the artifact shipped hybrid from the first build and all three targets verified it: with no deployed genesis authors there was no overlap to keep legible, only a second envelope, a second identity rule and a policy dial to maintain. The channel can move again, to a hybrid KEM, without touching manifest signing, and should, because its threat is the nearer one.
What a post-quantum suite would change. A 0x03 suite is the expected shape: a hybrid key exchange in which msg1 gains the initiator's ML-KEM-768 encapsulation key and msg2 the responder's ciphertext, with session keys derived from both the X25519 and the KEM secret, so the link is no weaker than today's against a classical attacker and safe against a future quantum one. Hybrid rather than pure PQ is the point — the classical half stays load-bearing while the PQ half is young.
Several things make this a field-width change rather than a redesign. The primitive itself is no longer among them: ml-kem-768/{keypair,encaps,decaps} are in the host's primitive catalog (PRIMITIVE_NAMES, §12.2) on all three targets, driven from one mlkem768.wasm and pinned to NIST's ACVP vectors. That was the only part of a 0x03 suite that could not have been delivered as a bundle, so what remains is content: a signed transport bundle and one policy entry. The message widths are derived in one place, the key schedule already takes a list of shared secrets, and no long-term DH key is published — so a KEM never enters a node address. The full porting checklist, the combiner rule and the DoS re-measurement it obliges are in CHANNEL §11.
The cost is size: ML-KEM-768 encapsulation keys are 1184 bytes and ciphertexts 1088, against 32 bytes for X25519, so msg1 grows from 81 bytes to roughly 1.3 KB and msg2 similarly. That is a per-connection cost, amortised across the session, and it does not touch the steady-state frame path — the record layer stays ChaCha20-Poly1305, which is symmetric and unaffected.
The signature migration has landed, ahead of its threat. Manifest suite 0x02 is hybrid Ed25519 + ML-DSA-65: both signatures over DOMAIN_manifest ‖ suite ‖ ed_pk ‖ ml_dsa_pk ‖ json, and both must verify (§12.4). It is in the artifact before the threat is near, for the reason above — authenticity is not harvest-vulnerable, but it is the migration that can never get cheaper, so late is the one thing it cannot be. Hybrid answers the obvious objection to shipping a young algorithm into the component you cannot cheaply fix: requiring both means a flaw in ML-DSA fails closed (valid bundles rejected — recoverable) rather than open (forged bundles admitted — not), and against a classical attacker the bundle is exactly as strong as an Ed25519-only one was.
Three properties carry it:
- The author id binds the whole key set. The id is
genesisHash(DOMAIN_manifest_author ‖ suite ‖ ed_pk ‖ ml_dsa_pk), not the Ed25519 key. Otherwise an attacker who breaks Ed25519 forges that half, supplies a fresh ML-DSA key of their own for the other, and lands on the author's names under an unchanged id — hybrid signing buying nothing at exactly the moment it was supposed to pay. The id is 32 bytes, so name derivation (§5.1), policy files and freshness marks are written against one fixed-width identity, and with one suite there is one rule producing it. - Both keys are inside both preimages, so the pair cannot be spliced: keeping the sound half's key and signature while substituting a key for the broken half invalidates the survivor.
- A host without an ML-DSA verifier refuses the bundle with its own error rather than falling back to the Ed25519 signature alone — which would be the downgrade the whole construction exists to prevent. It is a legibility failure ("this bundle wants a host I am not"), reported as such, not a verdict on the bundle. An envelope claiming the retired
0x01gets the same answer, by the same rule: there is no Ed25519-only path left to fall into.
One implementation, three targets. ML-DSA-65 comes from a single freestanding wasm module built from the pinned mldsa-native submodule: the browser fetches it, Node reads it, and the Go loader instantiates it under wazero, exactly as all three share libsodium.wasm for Ed25519. This is not an optimization — the accept/reject boundary of a verifier is consensus, and two independent implementations of a lattice scheme can disagree at the edges while both pass their own tests. The module is checked against NIST's published ACVP vectors rather than only against itself.
There is no operator dial for which suites a deployment accepts, because with one suite "can this host check it" and "will this deployment trust it" are the same question, answered by the verifier before any policy runs. Sizes cost nothing here — an ML-DSA-65 signature is ~3.3 KB and its public key 1,952 bytes against Ed25519's 64 and 32, but a manifest is verified once per install, off the message path entirely (§13).
What is deliberately not solved. Neither suite byte makes a live deployment agile — there is still exactly one accepted suite per layer at any moment, compiled in. That is the intended trade. Runtime algorithm negotiation would mean shipping several implementations, keeping a downgrade-resistant selection protocol correct across versions, and admitting an attacker-influenced choice into the handshake; the byte instead buys the ability to change the constant without the format going ambiguous, which is the part that is expensive to retrofit and cheap to reserve. Migration remains an operator action, deliberately.
The manifest suite landing makes it easy to read the whole post-quantum question as answered. It is not: one of the two clocks in §14.1 has been stopped and the other has not, and the inventory those two clocks reason over turns out to be short by one entry. What follows is the adversarial read of the same ground — where the exposure actually sits today, and what an implementation of the channel suite would have to decide before it starts. None of it has been reviewed by a cryptographer.
Every link established today is retroactively readable, and that is a running total. Session keys come from one ephemeral–ephemeral X25519 exchange (§12.6) with no PQ component, so an adversary recording frames now decrypts them the day a CRQC exists. The contact secret is mixed into the same schedule and is symmetric, so a recorder who never held it must break X25519 and guess 32 bytes — but it is distributed with the node's address (§12.6.3), so it is not a defence against anyone who was ever given one, which is the adversary that matters here. §14.1 states this as a rationale for ordering the migrations; stated as exposure it has a sharper edge — this is not a future risk to be scheduled but an inventory that grows with every connection until the channel suite ships. A deployment carrying data with a multi-year sensitivity horizon is already accruing loss, and this is the one item here with no remediation after the fact. Everything else on this page can be fixed later; this one cannot.
Sealed boxes are a third asymmetric role, but they are the consumers', not the runtime's — and the analysis lives there. §14.1 counts two roles because two are all the runtime uses: since the concealed suite the handshake takes no long-term Diffie–Hellman key at all (§12.6), nothing on seedkernel's own path converts an identity key to Curve25519, and no target publishes crypto_box_seal above the seam — not in the realm (ShellSodium carries no such function, §12.9), not in the guest catalog (PRIMITIVE_NAMES, §12.2), not on the native bridge (sodium.go keeps the conversions and the box Go-side, with no caller above it). The published ./libsodium package export is the stock wrappers and therefore carries it, but nothing in the runtime's own formats does. The one audited use — seedstore's sealing of per-file content keys at rest — and the migration question that goes with it are a seedstore concern, documented in its README; the runtime's part is just the provisioned ml-kem-768 catalog entry, which is exactly what a consumer building a PQ seal would reach for.
"Authenticity has no deadline" holds for live-verified signatures, and app-layer signing is not one. The argument in §14.1 — that a signature need only be unforgeable at the moment it is verified — is exactly right for the two cases it names: a manifest is verified at install (§12.4), an AUTH transcript at handshake (§12.6), and a recording of either buys an attacker nothing. But the runtime hands out a third class of signature, the guest-obtainable ones scoped under DOMAIN_guest (§12.2, §16.1) and made with the node's one identity key (§12.6.2b) — a separation of scope, never of algorithm: it is the same Ed25519. Those are made for apps, and §5.1 and §13 both point at the case where an app keeps a replicated append-only log with per-message signatures and a backlink chain. A signature in such a log is verified not once but indefinitely, by every future replica, so its unforgeability deadline is set by the record's lifetime rather than by a verification event. Once Ed25519 falls, an attacker can mint history that verifies. The manifest suite does not cover this axis — the hybrid construction is per-manifest and the guest seam still signs with Ed25519 alone. An app that needs long-horizon non-repudiation wants a PQ signature or an external anchor (timestamping, a witnessed checkpoint), not Ed25519 on its own.
The channel's combiner and flight layout are settled by the shape already there; what is left is a porting discipline, not a design. The key schedule takes an ordered list of secrets and the transcript chains whole messages, so a KEM secret joins the first and its ciphertext binds under the second with no new rule; four messages with explicit roles mean the responder encapsulates where it is already generating an ephemeral, so nothing gains a flight. The two quiet ways to get it wrong — entering the PQ secret at the session keys rather than at the first key derived after decapsulation, and relaxing "the bytes sent are the bytes folded into the transcript" — are written down with the rest of the checklist in CHANNEL §11. What is worth stating here is that none of it is a residual risk of the reservation: the shape was chosen before the primitive existed and the port inherits it.
Pre-authentication cost grows by about an order of magnitude, and the constant that would have blocked it has already moved. MAX_HANDSHAKE_FRAME_BYTES is the pre-auth reassembly cap (§12.6.2), and at the 512 bytes it was originally sized to a 0x03 msg1 would not have fitted: the socket seam refusing the message the primitive catalog had just made expressible, a core rev standing in the way of a change that is supposed to be content. At 8 KiB it clears the PQ widths with room. It stays a cap rather than becoming a per-suite width — deferring to MAX_FRAME_BYTES (2 MiB) before authentication is the exact thing it exists to prevent — and it stays the host's number (core/net-limits.ts), which the occupant may ask to raise on authentication but never declare. That is the whole of the exposure this axis leaves; the numbers to re-measure are in CHANNEL §11.
No-negotiation is strong against tampering and silent about attrition — on both axes. §12.6 is emphatic and correct for the channel: one suite per link, an unrecognised id draws silence, the byte folded into the transcript root so both identity signatures cover it, so a suite is chosen by an endpoint and never forced in flight. That closes downgrade-by-modification completely. It says nothing about downgrade-by-refusal, and here the silence discipline cuts the other way: a 0x03 dialer meeting a node that still speaks only 0x02 gets exactly what it would get from a wrong contact secret, a wrong network key, a declined identity or a dead port — nothing at all. The failure is indistinguishable from absence, so the operational pressure during an overlap is not "add a fallback" but "add a fallback because the peer looks down", which is worse pressure for the same reintroduced downgrade. Retry-at-the-older-suite after silence must therefore be forbidden outright; the sanctioned pattern is a node that accepts both suites while dialing at a per-peer pinned preference, alongside the expectPeerId pin an outbound dial already carries. The 0x01→0x02 transition did not answer this — it dodged it, by removing the cleartext suite outright and leaving no overlap to attack.
The manifest axis avoided this shape rather than solving it, the same way the channel's 0x01→0x02 transition did: the genesis suite was removed outright, so there is no overlap to attack and no policy dial for an operator to widen under an outage. The loader's behaviour is the whole of it — an envelope whose suite it cannot check is refused, never verified with the half it can — and it is not configurable, which is the property a dial would have taken away. What that costs is the ability to keep an unmigrated author running, which is exactly the pressure that reintroduces a downgrade; what it buys is that there is nothing to widen. A future 0x03 would have to make the same choice deliberately, and this is the precedent for it.
One primitive, one artifact — the precedent is set, the KEM should follow it. The genesis algorithms are compiled in rather than loaded (§14), so a PQ suite adds an implementation to the TCB. §12.9 makes that harder than a dependency bump: a Go node's crypto is byte-identical to a JS node's because it drives the same wasm blob over wazero, and that parity is load-bearing. Reaching for a target-native library — Go's own crypto/mlkem on the native side — would break the one-implementation rule the shell is built around. The manifest suite is the worked example of the alternative: mldsa65.wasm is built once from a pinned source and instantiated by all three targets, so the accept/reject boundary cannot drift between a node that admits a bundle and a node that refuses it. The KEM should land the same way.
One thing about that artifact remains open. Constant-time behaviour under this build is a review item in its own right — the upstream C is checked with CBMC and valgrind, but nobody has looked at what the wasm toolchain did to it, and lattice implementations have had exploitable timing variation in decapsulation. It does not matter on the verify path, where every input is public, but the same module signs.
The parity claim itself is now checked rather than asserted: native/mldsa_test.go runs the same ACVP fixture the JS suite runs against the copy the Go binary embeds, and drives a whole hybrid-signed bundle — assembled by a second, Go-side implementation of the envelope writer — through the production loader, including the tampering cases. That test earns its keep: the first time it ran it failed, because the native target's generated shared-JS bundle is a build artifact and nobody had rebuilt it, so the Go loader was verifying manifests with a bundle.js that had never heard of suite 0x02. A claim about three targets is worth exactly the test that crosses all three.
The hash is fine, and the reason is collision resistance. §14.1 argues Grover and preimage resistance, which is correct as far as it goes, but the property the system leans on hardest is collision: an app key is derived from (author, app) (§5.1), a content-addressed block admits on genesisHash(bytes) == block_id (§12.6), and — since the hybrid suite — an author's identity is genesisHash over its key set (§12.4). Quantum collision search offers no practical improvement over the classical birthday bound at these sizes, so the conclusion is unchanged. Naming the operative property is worth doing anyway, so that a later reader does not re-derive it from the wrong premise.
What is still missing is the operator-facing half. §14.1 establishes that the two migrations are independent and that only the channel is on a deadline. It does not give an operator a plan: which deployments hold traffic sensitive past which horizon, what event begins a migration, what the overlap looks like in practice. The manifest axis needs none — it has no overlap, because the genesis suite was retired rather than kept admissible (§12.4) — and the one operator-facing fact it leaves is that a shell able to verify a suite should not be assumed able to sign it: verification has to be universal before production begins. The native loader is deliberately in exactly that state — it verifies and cannot sign, which is also what keeps it from being turned into a signing oracle (§12.4). For the channel there is nothing equivalent yet.
Exposure as it stands.
| Use | Primitive | Suite byte | Clock | Status |
|---|---|---|---|---|
| Channel session keys (§12.6) | ephemeral X25519 | yes (§16.1) | urgent — harvest-now | accruing, no retrofit |
| Channel identity / AUTH (§12.6) | Ed25519 | yes (§16.1) | relaxed — verified live | adequate per §14.1 |
| Manifest signature (§12.4) | Ed25519 + ML-DSA-65 | yes (§16.1) | relaxed — verified at install | closed: suite 0x02, admitted by policy |
| Guest / app signatures (§12.2) | Ed25519 | no | depends on record lifetime | the relaxed clock does not transfer |
| Names, block ids, author ids (§5.1, §12.4) | BLAKE2b-256 | n/a | none | no change needed |
The two-clock model is the right frame and the suite bytes were the right reservation — the signature axis went from reservation to implementation without any format ambiguity, which is the thing the byte was for. What remains is that the inventory is short by one entry, and that the channel plan still stops at the point where an implementation would have to make decisions: the combiner, the flight layout, and what an implementation must refuse to do during the overlap.