Skip to content

Security: arj03/seedkernel

Security

docs/SECURITY.md

Seed kernel — Security

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


13. End-to-end worked example

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:

  1. 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.
  2. 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.
  3. 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.
  4. Guest → entrypoint. The slot's realm invokes its guest's handle entrypoint 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 rejects, like every other seam refusal (§3).
  5. Invoke the module. Through the guest's module call, the host copies input into the module's scratch region and calls handle(input_len). The v1 module reads [pk 32][type][body], writes the render bytes [type][pk_len][pk][body] back at scratch, and returns their length (§11). It imports no capability, reaches nothing, and asks no one who sent this — everything it needs is in the input (§4.2).
  6. Render. The guest returns the render bytes from its handle; the shell postMessages 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 module 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 _from trustworthy in step 1; every frame afterward rides its session keys.
  • Once per install — admitting the chat bundle (§12.4) verified both halves of the author's hybrid Ed25519 + ML-DSA-65 bundle signature over the complete body's BLAKE2b-256 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.

14. Security considerations

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 boundaries depend on the protected property. Anyone with host-process access controls slot construction, claim routing, and admission, so bundle signatures constrain an author, never the operator. The host, its platform and sandbox engines, bundled host crypto, and shipped host JS are trusted foundations. A bundle's confinement limits its powers; its replaceability says how it can be updated. Neither determines whether a particular security property depends on its behaviour.

Protected property Trusted components and boundary
Code admission and guest confinement The host's verifier, loader, operator policy, sandbox engines, and capability seam enforce which code runs and what it can reach. Admitted guests and their private modules remain confined. A valid signature identifies the author and commits to the bytes; it does not establish that the code is safe.
Node identity-key custody and signing-scope separation The host keeps the private identity key and selects the domain and scope for each signing call. A guest can misuse its permitted signing oracle, but cannot extract that key or select another slot's scope through the seam.
Channel confidentiality, record authenticity, and peer attribution In addition to the host and crypto it uses, the transport guest and its private crypto modules are trusted. The transport holds session keys, sees plaintext, and supplies attribution through link/deliver; the host does not independently authenticate that attribution. A compromised transport can disclose channel data or fabricate attributed input while remaining within its granted capabilities.
Confidentiality and integrity of application exchanges The host's claim routing, the operator's choice of claim owners, and the receiving app are trusted for data delivered to that app and the answers it returns. Protocol claims select recipients of decrypted peer input; service claims select recipients of local call arguments. The transport is also trusted for exchanges carried over its channels.

The transport is replaceable signed content and remains in the trusted computing base for channel security. Host confinement still protects other slots' private state and signing scopes from direct access by that transport; it cannot protect plaintext or session keys already entrusted to it.

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 has three distinct contexts. Offline authoring produces the hybrid bundle-manifest signatures that install code (§12.4); a running shell verifies those signatures but does not author manifests. At runtime, the host-held Ed25519 identity signs (1) the channel AUTH transcript and (2) statements requested through an ordinary app's scoped node/sign. Both runtime uses are domain- and slot-scoped by the host, the private key never enters a realm, and neither lies on the steady-state message path.

Domain-separated signature contexts. Every signature the runtime verifies or makes has a context-specific prefix: offline bundle authors and the loader use DOMAIN_manifest (§12.4), while the host applies DOMAIN_guest to an app's node/sign and DOMAIN_link_scope to the slot holding raw links (§12.2, §12.6). The three prefixes are disjoint and never transmitted, so a signature harvested in one context cannot verify in another. The family lives in one file (§16.1) so disjointness is checked globally. Sub-separation inside a scope is the occupant's own: the transport's DOMAIN_channel tag is content, which lets a bundle update change the handshake format without changing the kernel domain.

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 plus an ephemeral ML-KEM-768 public key and ciphertext. Each side signs DOMAIN_channel ‖ root ‖ transcript-so-far ‖ its own id under the host's DOMAIN_link_scope; the transcript chains the suite, both X25519 ephemerals, the KEM exchange, and both nonces. Every key from msg2 onward derives from the X25519 and ML-KEM secrets plus the contact secret. A signature induced on one exchange is useless on another, and every post-handshake frame is a ChaCha20-Poly1305 record with an implicit (epoch, counter) nonce. The channel therefore does not rely on an external TLS/Noise tunnel, though WSS and WebRTC may add TLS/DTLS underneath.

Neither identity 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 keys derived from the ephemeral X25519 and ML-KEM secrets 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 or KEM key, so the identity Ed25519 key stays signing-only (§12.6.2b). 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 bundle module. The runtime fixes Ed25519, BLAKE2b-256, and ML-DSA-65 as the manifest trust root (§16.1). Ed25519 and BLAKE2b come from the shared libsodium core; ML-DSA comes from a separate mldsa65.wasm host artifact shared byte-for-byte by all targets. This is not an optimization: a verifier cannot be delivered through the format it verifies. A PQ verifier admitted by a classical verifier would be only as strong as that classical admission. BLAKE2b-256 remains the one system hash for content, author-id derivation, the AKE KDF, and block ids. The trust anchors are therefore the host artifact and the operator's policy author set (§12.5). The host-internal app key is the unambiguous literal (author, app) identity rather than a hash-derived public namespace; bootstrap and routing claim names are literal manifest strings (§5.1).

What that root may grow, it may grow only under a test. Every host name is vocabulary every target must implement identically, so an addition is admitted only when one of three things holds:

  1. The TCB already calls it for work the host itself must perform.
  2. It cannot be delivered as a module at the endpoints — the manifest verifier is the canonical example, because it cannot be admitted through the format it verifies.
  3. It is a migration the host itself must call. Provisioning a transform merely so some future bundle can utter its name does not qualify.

A pure function of bytes the guest already holds is computation, not a capability, and belongs at the endpoint unless the endpoint cannot implement it correctly. Raw Ed25519 verification remains on SeamCrypto because the host calls it for scoped node/verify; it is not a second guest-visible name. Seed store reaches HOST_TRANSFORM_NAMES only for hashing and record encryption, and uses scoped node/verify for signatures; the transport carries ML-KEM in its own mlkem module. The host-transform table is not a primitive catalog or a promise that more algorithms will be provisioned there.

The same test is why the residual table does not shrink further. It gates additions; read backwards as a removal mandate it would trade tested primitives for vendored ones to delete a name. blake2b-256 is test 1 outright — the host hashes bundles, derives author ids and subkeys with it, and it is the one system hash — so the guest name is a second caller of code the TCB carries regardless; on the native target it is additionally a Go primitive on the storage hot path. chacha20poly1305-ietf/{seal,open} is the channel's per-frame record layer, not a twice-per-handshake step like ML-KEM, so relocating it buys a scratch copy per record against a library the host already links. x25519/dh is the one name that fails the test on its merits, but no X25519 exists in the vendored PQ trees (WASM/pq), so dropping it means vendoring a second, unreviewed, handshake-critical implementation; the libsodium core is 217 KB with or without it. And a bundle module cannot borrow the host's copy: a module runs in its own isolate under exactly three imports — abort, seed, trace (host/module-table.ts) — and reaching libsodium would mean exporting the linear memory that holds the node secret key on every node/sign. The seam is that import in its safe form. The honest remaining win is narrowing what the host's crypto loader publishes, which is an export surface, not this ABI.

Bundle freshness. The manifest carries a monotonic version, enforced against a persisted per-(author, app) high-water mark, read both when the load is admitted and again in its commit window so a slow candidate cannot land over a newer one that beat it (§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 the admitted hybrid author key set clears the freshness guard by construction — sign version + 1 — and lands in the same (author, app) slot with whatever claims that replacement declares. The operator does name the slot being retired (§12.5), but there is nothing in the bundle for them to name it against: 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 set 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 — that is what the name means for the slot, derived once at load — 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 scope and never reconstructs host-owned prefix bytes. An ordinary app slot 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_link_scope) — nor in any other app's scope, since distinct app slots derive disjoint scopes. The slot reaching link gets DOMAIN_link_scope instead — the host confines channel signing to this domain, and ordinary apps never gain link. Network separation is enforced by the transport through root = H(DOMAIN_channel ‖ network_key) in its signed handshake content. A compromised transport can request signatures for another network using the node's identity; the host does not independently restrict that authority to a network. Honest peers still enforce their contact-secret and identity-admission rules.

The transport is the one occupant whose node/sign/node/verify are scoped to the constant DOMAIN_link_scope — which is precisely the authority the transport is (§12.5), authorized by explicit selection at boot or by an install whose replaces names the current link owner. Naming link in signed guest.requires cannot acquire that authority through an ordinary app install, or by replacing an ordinary app. Every live transport change, even from the same author, names the owner it retires. 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. And the host owns only that separation — the handshake's own format tag (DOMAIN_channel) and everything under it are the bundle's, which is what lets the AKE be content, changed by shipping a new transport bundle rather than a new kernel, while the node's identity key stays behind the seam. Because the one scope is a function of admitted facts (slotSignScope), it is also the same on every load path — an in-place transport upgrade cannot silently re-scope a running node. 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, and exactly what the transport does with DOMAIN_channel.

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's private state is disposable (§4.2), so neither supplies durable replay history. 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.

Claims select data recipients without granting host capabilities. The signed protocols and services lists (§12.10) cannot make unadmitted code run, widen guest.requires, or let an app act in another's signing scope (§12.2). They do determine which admitted app receives input and supplies a response. Delivering decrypted messages or sensitive local call arguments to the wrong app is a confidentiality breach; trusting its answers can compromise application integrity. Uninstalling that app stops future delivery but cannot retract disclosed data. Collision checks prevent a different identity from taking an occupied claim; they do not reserve free names, including names released by uninstall or an update. Operators should pin sensitive claims to approved (author, app) owners in the existing admission predicate (§12.5; code in CLIENT.md), for ordinary app admission; transport candidates are authorized through explicit transport selection. Channel authentication identifies the immediate peer node, not the author or app serving a protocol on that node; any stronger remote-app assurance belongs to the application's protocol.

Structural sandbox — no capabilities are imported. A module is a restartable transform with no runtime seam (§4.2), so it cannot open a socket or file even if compromised; fixed inert language-runtime shims confer no authority. A guest's reach is exactly its manifest guest.requires (the host) and guest.calls (co-resident guests); 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 under that same per-call remainder, for 1.07–1.21x on the compute (§14). The killed call rejects, exactly as a trap does — one failure shape for all four causes, distinct from the zero-length answer of a module that ran and returned nothing. Reachable only through a guest calling one of its own modules (§12.10)
JS guest Bounded — realm heap cap (§12.3) Bounded — 5s execution and handoff deadline (§12.3, §16.1)

The table above bounds what runs. Resource boundedness then has three separate laws (§12.3): retained space uses continuous custody, causal lifetime uses a monotone absolute deadline, and initiation rate uses explicit scheduling. They are related but not interchangeable. Every host-side byte caused by untrusted input has an owner from creation until destruction, and a handoff reserves in the receiver before releasing the sender. Every call descended from an admitted invocation carries that invocation's live deadline, never one supplied or renewed by a callee. A timer fire is different: it is the one fresh invocation root a guest can create for itself, so it receives a causal clock that follows promise continuations, modules, and cross-realm descendants even when the entrypoint does not await them. The realm wake debits their measured execution, not time parked on I/O, against a per-realm share. Network-originated application roots arrive only over mutually authenticated links, giving the replaceable transport a stable peer identity for future per-peer pacing or fair queuing. Authentication is attribution rather than trust, so an authenticated peer may still be hostile. Until the transport schedules that ingress, peer- and host-initiated roots are bounded per invocation, not in aggregate; there is no node-wide CPU guarantee.

The resource owners are ordinary — an active-call registry, a realm wake, a link, a realm-entry queue, a storage quota — and each keeps its natural implementation; what they share is the law, not a queue abstraction. Two properties make custody a bound rather than a habit: no operation name buys an exemption (a name may tighten what an owner admits, never relax it, and the owner of the resource enforces it rather than the dispatcher that routed the call), and every owner is bounded in time as well as size, since a release path nothing is committed to calling only defers the leak. Per-owner ceilings draw from a parent allowance or have a bounded population at the node, so finite memory limits cannot multiply without bound by realm or link count.

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 deadline covers both execution and wall-clock custody. QuickJS charges the segments in which guest code actually runs, while the absolute handoff deadline keeps advancing through realm queue wait, a parked host call, socket backlog, and __deferred. Each running invocation is interrupted by its own deadline; queued invocations retain theirs and cannot draw a fresh segment after a predecessor releases the realm. A legitimate longer wait therefore needs a longer initiating budget; transport content cannot infer one from progress and grant it to itself.

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 it cannot land inside the call. What lands instead is the deadline the call carries: the calling guest's live remainder (§12.3), computed by the realm at handoff. The worker or wazero context kills the module when that remainder is gone, and the actual module burn is billed back to the guest's compute account when the call settles. Both halves are needed: the absolute deadline covers the parked caller and the engine lever stops the core actually running the module. A guest therefore cannot renew time by sequencing await host.call(…) turns.

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 rejects at the guest seam and its worker is 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 for app modules. Each call context carries the calling guest's live remaining segment. Construction also runs under the shared default guest deadline because instantiation itself can execute a start section. Go may retain an opaque map behind each slot handle; that is target plumbing, not shell state.

The loader uses arj03/wazero, branch inline-termination-check, pinned by commit in native/go.mod. It checks the module's Closed word inline at each loop back-edge and calls the termination trampoline when set. Measured execution time with checks enabled is 1.21× on RS encode, 1.15× on RS decode and 1.07× on XChaCha20 (native/module_bound_bench_test.go); overhead depends on the number of loop back-edges per byte.

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 ~80 ns per call, with no allocation, to arm and disarm the deadline — one shared timer, re-armed per call (BenchmarkBoundCallOverhead). That fixed part is the one to watch, because unlike the checks it does not scale with the work — but it is noise 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 host TCB modules (libsodium and the ML-DSA verifier) run on a separate, unarmed runtime: a wedged trust-root module is a host bug, not a confinement breach. ML-KEM is instead a private module of the signed transport bundle and uses the ordinary bounded module path. 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 one rejected call 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. A locally unbounded realm still honours a finite incoming handoff; with neither a local nor incoming bound the handler remains dormant.

The native lever needs the in-repo QuickJS shim. Upstream's qjs.wasm accepts New_QJS's max_execution_time but installs no interrupt handler for it, so a 1 ms limit does not stop for(;;){f()} while the memory and stack arguments beside it do work. Without an interpreter-level lever the only native bound is an outside-in wazero deadline over the whole engine call, which compiles a termination check into every loop of QuickJS itself — 2.3× on guest realm dispatch and 2.05× on a network round trip, since the transport is a bundle and the entire data path runs inside a realm. native/qjs/build-qjs.sh therefore builds the engine from native/qjs/csrc, which installs the handler QJS_SetDeadline arms.

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 the live work the realm still owes: callers get an error and the realm stays usable. The exhausted deadline remains in force while the interrupted frame unwinds, so leftover jobs do not buy a fresh allowance each time the loop pumps; the next independently admitted entrypoint uses its own deadline. Both targets apply the same 5s default.

14.1 Cryptographic suites and update boundaries

Channel confidentiality, live authentication and long-lived application signatures have different exposure to future cryptographic breaks. Bundle admission and channel establishment use the fixed suites below; guest-obtainable signatures have application-defined lifetimes (§14.2).

Recorded channel confidentiality depends on both ephemeral secrets. Suite 0x03 derives every key from msg2 onward from X25519 and ML-KEM-768 (§12.6). Breaking X25519 alone is insufficient to decrypt a recording. Endpoint compromise while secrets or plaintext are live remains outside that protection.

Live-verified authenticity is not harvest-vulnerable. Bundle manifests already require hybrid Ed25519 + ML-DSA-65 signatures (§12.4), while a live channel AUTH remains Ed25519 (§12.6). A recording of either verification buys an attacker nothing: a future forgery capability cannot retroactively install a bundle or complete a past handshake. This reasoning does not extend to app signatures kept as long-lived records (§14.2).

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 lets the verifier reject unsupported ids before parsing their fields.

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, which is why they are named apart (SUITE_CHANNEL_CONCEALED, SUITE_MANIFEST_HYBRID_PQ) rather than sharing one constant. The manifest id is the host's because the loader reads it before anything is trusted; the channel id is the transport bundle's because the AKE that reads it is entirely that program. The default channel is 0x03, hybrid X25519 + ML-KEM-768; the manifest is 0x02, hybrid Ed25519 + ML-DSA-65.

Channel suite 0x03. Suite 0x03 is a hybrid key exchange: msg1 carries the initiator's ML-KEM-768 encapsulation key, msg2 carries the responder's ciphertext, and every key from msg2 onward is derived from both the X25519 and KEM secrets. Hybrid rather than pure PQ keeps the classical half load-bearing while the PQ half is young.

mlkem768.wasm is one of the transport bundle's private modules, reached as host.call("mlkem", …). It is import-free, passes the generic scratch/handle ABI on both module loaders, and is pinned to 40 NIST ACVP cases. No long-term DH or KEM key enters a node address.

The cost is size: ML-KEM-768 encapsulation keys are 1,184 bytes and ciphertexts 1,088, so msg1 is 1,265 bytes and msg2 is 1,168. 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.

Manifest suite 0x02. Hybrid Ed25519 + ML-DSA-65 signatures cover DOMAIN_manifest ‖ suite ‖ ed_pk ‖ ml_dsa_pk ‖ json, and both must verify (§12.4). Requiring both preserves the sound algorithm's protection if the other is broken; a verifier that rejects valid signatures instead causes admission to fail closed.

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 replaces the author's (author, app) slot under an unchanged id — hybrid signing buying nothing at exactly the moment it was supposed to pay. The id is 32 bytes, so app-key construction (§5.1), policy files and freshness marks use 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 0x01 is also refused; Ed25519-only verification is not supported.

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.

The supported manifest suite is fixed by the verifier. Admission policy separately decides whether to trust a verified bundle. 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.

14.2 Post-quantum exposure and remaining limits

Manifests require hybrid signatures and channels use hybrid key establishment. Channel AUTH and guest signatures remain Ed25519. None of this design has been reviewed by a cryptographer.

Recorded links have a PQ confidentiality component. Breaking X25519 later is insufficient: the recorder must also break ML-KEM-768 or recover an endpoint's ephemeral KEM secret. Both ephemeral secrets are erased when session keys are derived and again at teardown. This does not protect an endpoint compromised while the secrets or plaintext are live.

Sealed boxes are a third asymmetric role, but they are the consumer's, not the runtime's. Nothing on seedkernel's own path uses a long-term DH/KEM key or publishes sealed-box calls above the guest seam. A consumer that needs PQ sealing ships and versions that transform in its own bundle; the host does not provision ml-kem-768/* for it.

"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.

No suite byte on the seam, and no key in the guest. A suite byte makes a format self-describing, and node/sign is a call, not a format — a scope oracle whose algorithm is the host's business on exactly the footing the domain and the scope are (§12.2) — so the byte belongs in the app's own record, beside its backlinks and version, where the surviving format actually is. The second is not agility but custody. Putting a PQ private key in the guest would surrender the guarantee §14 above rests on: a guest compromise could exfiltrate it, and unloading the bundle would no longer revoke its use.

The combiner enters at the first possible key. The responder encapsulates after the contact-secret probe opens; msg2's encryption key already contains both ee and the KEM secret, and msg3, msg4 and both session directions inherit the same ordered pair. Whole wire messages are transcript-hashed, including the KEM public key and ciphertext.

Pre-authentication frames are bounded. MAX_HANDSHAKE_FRAME_BYTES is the signed transport framer's pre-auth reassembly cap (§12.6.2); at 8 KiB it clears the 1,265-byte msg1 with room. It stays a cap rather than becoming a per-suite width. The separate post-authentication application-frame ceiling is host-owned. The contact-secret proof is checked before the responder invokes the KEM module.

No negotiation or fallback. The channel accepts only 0x03; an unrecognised id draws silence. The manifest loader accepts only 0x02 and refuses any suite it cannot verify. Neither layer retries an older suite after failure. The signed suite byte closes downgrade by modification — a flipped byte makes the two ends sign different transcripts — but not downgrade by refusal: a dialer speaking a newer suite than the peer accepts gets silence, indistinguishable from a wrong contact secret, a wrong network key, a declined identity or a dead port. The pressure that creates is to add a fallback because the peer looks down, which reintroduces the downgrade. A deployment that ever accepts two channel suites at once therefore needs an explicit rollout rule: never retry at an older suite after silence, and pin each peer's dial preference alongside the expectPeerId an outbound dial already carries. Accepting both while dialing at a pin is the sanctioned pattern; a fallback triggered by absence is not.

One primitive, one artifact — with different trust placement. mldsa65.wasm is a host artifact because the verifier cannot arrive through the bundle format it verifies. mlkem768.wasm is instead a private module inside the signed transport bundle and runs through the ordinary bounded module loader. Each artifact is built once from pinned source and exercised byte-identically by all targets; there is no target-native ML-KEM implementation or native bridge to drift. Constant-time behaviour of the built signing and decapsulation paths remains an independent review item; passing functional vectors does not establish that property.

native/mldsa_test.go checks the embedded verifier against the same ACVP fixture as the JS suite and sends hybrid-signed bundles, including tampered envelopes, through the production loader.

The hash is fine, and the reason includes collision resistance. Content-addressed blocks admit on genesisHash(bytes) == block_id, and an author's identity is genesisHash over its hybrid key set (§12.4). Those uses need collision resistance as well as preimage resistance; at 256 bits the conclusion in §14.1 is unchanged. The host-internal app key is not another hash use: it is the unambiguous literal (author, app) identity (§5.1).

Multi-suite rollout policy is unspecified. No current deployment accepts overlapping suites; one that did would need the selection and retry rules above. Verification and authoring are separate capabilities: the native loader verifies hybrid manifests but does not sign them (§12.4).

Exposure as it stands.

Use Primitive Suite byte Clock Status
Channel session keys (§12.6) ephemeral X25519 + ML-KEM-768 yes (§16.1) harvest-now clock closed for suite 0x03
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, deliberately depends on record lifetime the relaxed clock does not transfer; the app's to close, inside its own record
Block ids and author ids (§5.1, §12.4) BLAKE2b-256 n/a none no change needed

Long-lived application records need protection appropriate to their lifetime. The scoped node/sign API supplies Ed25519 only; a durable PQ signature or external checkpoint is the application's responsibility.

There aren't any published security advisories