From d2452c0e0fc447b09067bdd958d50fd00437ece0 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Mon, 7 Sep 2026 17:46:31 -0400 Subject: [PATCH] The worker runs without JSPI: iroh identity from the seed, signing in-guest polymorph-iroh 8ca991e adds identity-from-seed behind the guest-ed25519-signing feature: the endpoint holds its private key and signs with ed25519-dalek in-guest, so rustls's synchronous Signer::sign no longer blocks on an async webcrypto import and the accept side of a QUIC handshake needs no JSPI. The runtime world imports identity-from-seed in place of identity-from-keys and the ed25519 webcrypto interfaces; the endpoint component is built locally with the feature (the JSR artifact excludes it) by just endpoint at the pinned rev. Every realm is jspi:false and the browser floor is multi-memory alone. The trade is recorded in docs/design.md: identity signatures run in wasm rather than the platform's native crypto, and the seed rests in the endpoint's memory. --- .github/workflows/ci.yml | 24 +++++++ .github/workflows/pages.yml | 12 ++++ Cargo.lock | 1 - deno.json | 1 - deno.lock | 19 +----- docs/design.md | 47 +++++++------- e2e/run.ts | 26 ++++---- justfile | 58 +++++++++++++++-- runtime/component/Cargo.toml | 6 -- runtime/component/src/component.rs | 8 +++ runtime/component/src/net.rs | 79 ++++++++---------------- runtime/wit/deps/polymorph-iroh/iroh.wit | 37 ++++++++++- runtime/wit/internal.wit | 20 +++--- visor/src/component.rs | 7 +++ web/boot.ts | 2 +- web/fetch-endpoint.ts | 67 -------------------- web/jspi_test.ts | 56 +++++------------ web/worker.ts | 28 +++------ 18 files changed, 243 insertions(+), 255 deletions(-) delete mode 100644 web/fetch-endpoint.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23b822c7..d81f51ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,18 @@ jobs: with: tool: wasm-tools,just,wac-cli - uses: Swatinem/rust-cache@v2 + # The endpoint component is built from polymorph-iroh's source, not + # taken from its jsr package (justfile `endpoint`: the interface the + # runtime binds is feature-gated). That is a ~20 s cargo build of a + # second dependency tree; the 2 MB artifact is what the composition + # actually needs, so cache the artifact rather than the checkout. The + # key is the justfile because the pinned revision lives there — a + # justfile edit costs one rebuild, a stale artifact is impossible + # (`endpoint` looks for the revision's own filename). + - uses: actions/cache@v4 + with: + path: target/iroh_endpoint-*.wasm + key: iroh-endpoint-${{ runner.os }}-${{ hashFiles('justfile') }} - run: just ci e2e: @@ -42,6 +54,18 @@ jobs: with: tool: wasm-tools,just,wac-cli - uses: Swatinem/rust-cache@v2 + # The endpoint component is built from polymorph-iroh's source, not + # taken from its jsr package (justfile `endpoint`: the interface the + # runtime binds is feature-gated). That is a ~20 s cargo build of a + # second dependency tree; the 2 MB artifact is what the composition + # actually needs, so cache the artifact rather than the checkout. The + # key is the justfile because the pinned revision lives there — a + # justfile edit costs one rebuild, a stale artifact is impossible + # (`endpoint` looks for the revision's own filename). + - uses: actions/cache@v4 + with: + path: target/iroh_endpoint-*.wasm + key: iroh-endpoint-${{ runner.os }}-${{ hashFiles('justfile') }} # Chromium plus its shared-library deps, at the exact version deno.json # imports — a newer CLI fetches a build the library will not find. - run: npx -y playwright@1.62.1 install --with-deps chromium diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 93814af6..b7a46978 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -28,6 +28,18 @@ jobs: with: tool: wasm-tools,just,wac-cli - uses: Swatinem/rust-cache@v2 + # The endpoint component is built from polymorph-iroh's source, not + # taken from its jsr package (justfile `endpoint`: the interface the + # runtime binds is feature-gated). That is a ~20 s cargo build of a + # second dependency tree; the 2 MB artifact is what the composition + # actually needs, so cache the artifact rather than the checkout. The + # key is the justfile because the pinned revision lives there — a + # justfile edit costs one rebuild, a stale artifact is impossible + # (`endpoint` looks for the revision's own filename). + - uses: actions/cache@v4 + with: + path: target/iroh_endpoint-*.wasm + key: iroh-endpoint-${{ runner.os }}-${{ hashFiles('justfile') }} # `site` composes first: the shipped runtime is the wac-plugged # artifact (justfile `compose`), and a dist built from the bare one # fails loudly in web/build.ts. diff --git a/Cargo.lock b/Cargo.lock index cf3bf747..a1d7688d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1742,7 +1742,6 @@ dependencies = [ name = "polyvisor-runtime" version = "0.1.0" dependencies = [ - "ed25519-dalek", "futures", "polyvisor-kernel", "wit-bindgen", diff --git a/deno.json b/deno.json index d2e7d863..19319750 100644 --- a/deno.json +++ b/deno.json @@ -12,7 +12,6 @@ "@polymorph/webcrypto": "jsr:@polymorph/webcrypto@^0.6.0", "@polymorph/websocket": "jsr:@polymorph/websocket@^0.6.0", "@polymorph/webrtc-datachannels": "jsr:@polymorph/webrtc-datachannels@^0.6.0", - "@polymorph/iroh": "jsr:@polymorph/iroh@^0.6.0", "npm:node-datachannel@0.32.3/polyfill": "./web/platform/no-node-datachannel.ts", "@remote-dom/core": "npm:@remote-dom/core@^1.11.1", "@remote-dom/core/receivers": "npm:@remote-dom/core@^1.11.1/receivers", diff --git a/deno.lock b/deno.lock index 56a24883..43472851 100644 --- a/deno.lock +++ b/deno.lock @@ -2,13 +2,9 @@ "version": "5", "specifiers": { "jsr:@polyengine/protocol@~0.3.1": "0.3.1", - "jsr:@polyengine/runtime@~0.6.3": "0.6.7", "jsr:@polyengine/runtime@~0.6.7": "0.6.7", - "jsr:@polyengine/translator@~0.6.3": "0.6.7", "jsr:@polyengine/translator@~0.6.7": "0.6.7", - "jsr:@polyengine/wasi@~0.6.3": "0.6.7", "jsr:@polyengine/wasi@~0.6.7": "0.6.7", - "jsr:@polymorph/iroh@0.6": "0.6.1", "jsr:@polymorph/webcrypto@0.6": "0.6.1", "jsr:@polymorph/webrtc-datachannels@0.6": "0.6.2", "jsr:@polymorph/websocket@0.6": "0.6.1", @@ -36,7 +32,7 @@ "@polyengine/translator@0.6.7": { "integrity": "6797db05f37e00a5e466da7641722c9deb444051a9384ac04290e2b231944555", "dependencies": [ - "jsr:@polyengine/runtime@~0.6.7" + "jsr:@polyengine/runtime" ] }, "@polyengine/wasi@0.6.7": { @@ -45,18 +41,6 @@ "jsr:@polyengine/protocol" ] }, - "@polymorph/iroh@0.6.1": { - "integrity": "5a6007af90a84ef0b9a69c5dc74ecd51faeaed3d016d274cb21877ce5bf89605", - "dependencies": [ - "jsr:@polyengine/protocol", - "jsr:@polyengine/runtime@~0.6.3", - "jsr:@polyengine/translator@~0.6.3", - "jsr:@polyengine/wasi@~0.6.3", - "jsr:@polymorph/webcrypto", - "jsr:@polymorph/webrtc-datachannels", - "jsr:@polymorph/websocket" - ] - }, "@polymorph/webcrypto@0.6.1": { "integrity": "b21fdb9ee9ef0904db4161c8e393b9d3d7503cf147c6ad54da6de2da24dd8194", "dependencies": [ @@ -425,7 +409,6 @@ "jsr:@polyengine/runtime@~0.6.7", "jsr:@polyengine/translator@~0.6.7", "jsr:@polyengine/wasi@~0.6.7", - "jsr:@polymorph/iroh@0.6", "jsr:@polymorph/webcrypto@0.6", "jsr:@polymorph/webrtc-datachannels@0.6", "jsr:@polymorph/websocket@0.6", diff --git a/docs/design.md b/docs/design.md index c3e139a3..35ec9bff 100644 --- a/docs/design.md +++ b/docs/design.md @@ -104,23 +104,27 @@ checkpoint path bound `wasi:filesystem@0.2` (sync WIT) over OPFS Ruling: the runtime uses `wasi:filesystem@0.3` (async in WIT) through generated bindings, not `std::fs`; every glue-implemented import is -async in WIT; the visor and frame embedders force `jspi: false`, so a -regression there fails loudly. - -**The worker is the one exception, for now.** The composed iroh endpoint -authenticates its QUIC connections with rustls, and rustls has no async -signing path: `Signer::sign` is synchronous, and polymorph-iroh -implements it as `block_on` over the async `polymorph:webcrypto` sign -import (`core/src/crypto/sign.rs`). A sync lower of an async import is -exactly what JSPI exists for, so the accept side of every connection -needs it (found in M3a: with `jspi: false` the acceptor stalls in -`CertificateVerify`). The general fact: a platform-held, non-extractable -key as the TLS identity implies JSPI in a browser. So the worker is -instantiated with `jspi: true`, tolerated only until the transport's -signer is in-guest (polymorph-iroh: an identity built from a seed, which -is the posture the kernel already holds); then the worker returns to -`jspi: false` and the browser floor is wasm multi-memory alone. The -visor and frame realms never needed JSPI and stay without it. +async in WIT; every embedder forces `jspi: false`, so a regression +anywhere fails loudly. + +**No realm is an exception.** The worker was one through M3a: the composed +iroh endpoint authenticates its QUIC connections with rustls, whose +`Signer::sign` is synchronous, and an identity built from platform key +handles (`polymorph:iroh/identity-from-keys`) reaches its key through an +async import — a sync lower of an async import, which is exactly what JSPI +exists for, so the accept side of every connection needed it (with +`jspi: false` the acceptor stalled in `CertificateVerify`). The general +fact stands: a platform-held, non-extractable key as the TLS identity +implies JSPI in a browser. polymorph-iroh's `identity-from-seed` (behind +the `guest-ed25519-signing` feature) takes the other side of that: the +identity holds its private key in the endpoint component's memory and +signs there with ed25519-dalek. So every realm is `jspi: false` and the +browser floor is wasm multi-memory alone. The trade, recorded: identity +signatures run in wasm rather than in the platform's native crypto, and +the device seed rests in the endpoint's memory for the endpoint's +lifetime — it was already in the kernel's, and passed through guest memory +at every bind, so this widens where it rests and not whether it is +there. ## The app frame @@ -187,7 +191,7 @@ form. Polyvisor owns five implementations: | `Transport` | one per connection over `polymorph:iroh` streams, relay-only: WebRTC is off in the worker because a SharedWorker has no `RTCPeerConnection` (the host backend never resolves there). Framing per `subduction_iroh` (u32 BE length prefix) so native subduction peers interoperate | | `Storage` | M3a: an in-memory item store serialized into the sealed checkpoint with the automerge docs. Items in their own files under the state root is the follow-up once checkpoint size matters | | `Policy` | group membership, read off the user-system document (`polyvisor:us`): a remote peer may read/write exactly while its key is a member. App-tree envelopes are keyhive's (M3c, `engine/src/vault.rs`) | -| `Signer` / `NodeEffect::Sign` | M3a: `ed25519-dalek` over a seed held in the sealed checkpoint (the seed posture; the same seed, imported through `polymorph:webcrypto`, builds the iroh identity). Later: a non-extractable platform key — signing is an effect with external custody, which is exactly what that needs | +| `Signer` / `NodeEffect::Sign` | M3a: `ed25519-dalek` over a seed held in the sealed checkpoint (the seed posture; the same seed builds the iroh identity, through `polymorph:iroh/identity-from-seed`). Later: a non-extractable platform key — signing is an effect with external custody, which is exactly what that needs | | `Clock` | `wasi:clocks@0.3` | Why the branch rather than the released crates: one driver loop the @@ -378,7 +382,8 @@ the handshake. persisted in IndexedDB would rest under the same profile protection, so it buys nothing at this tier. The signing identity is a seed in the same sealed checkpoint (M3a); platform-held keys enter with the passkey - PRF rung (M5) and, for the transport, once its TLS signer is in-guest. + PRF rung (M5); the transport's TLS signer is in-guest instead (see + "No JSPI"). - **Checkpoints** are AES-GCM over the kernel's serialized state, written to `//gen-/` on the OPFS root through `wasi:filesystem@0.3` after every mutation (state is small until the engine lands; a debounce is a @@ -420,8 +425,8 @@ native tests, so browser gates are mandatory for every visor change. | polymorph-stream-dom | git rev (see Cargo.toml / deno.json) | unpublished, moving; policy object and asset handles landed in #15 | | subduction | git `sansio` rev | above | | keyhive | git rev `a509a2d` | `keyhive_core` / `keyhive_crypto` / `beekem`, unreleased and moving. The sealed plaintext is keyhive's own `Envelope` and the read-back walk is keyhive's own `try_causal_decrypt`, so a rev bump is a wire-format change for every stored blob: its own PR | -| `@polymorph/*` | 0.6.1 (iroh, webcrypto, websocket), 0.6.2 (webrtc-datachannels) | the cuts current at the polyengine 0.6.7 pin; taken within the `^0.6` range | -| polymorph:iroh WIT | provisional | being upgraded upstream in parallel; re-checked before M3a, the first milestone that exercises it | +| `@polymorph/*` | 0.6.1 (webcrypto, websocket), 0.6.2 (webrtc-datachannels) | the cuts current at the polyengine 0.6.7 pin; taken within the `^0.6` range | +| polymorph-iroh | git rev `8ca991e` | the endpoint component is built from source, not taken from the jsr package: the runtime binds its identity through `identity-from-seed`, which the package gates behind the cargo feature `guest-ed25519-signing` and its published artifact excludes. `just endpoint` clones and builds the pin; the vendored `runtime/wit/deps/polymorph-iroh/iroh.wit` is that revision's | | `wasi:*` WIT | 0.3.1 (consolidated WASI release) | what `@polyengine/wasi` serves on the `@0.3` track | ## Delivery diff --git a/e2e/run.ts b/e2e/run.ts index 17ef74ac..6f158dd8 100644 --- a/e2e/run.ts +++ b/e2e/run.ts @@ -1453,11 +1453,13 @@ const scenarios: Scenario[] = [ { // Both realms on this side, named: the visor on the main thread and the - // runtime in the SharedWorker. The worker is the exception that makes - // the name worth spelling out — a page can see its own realm fail, but - // a worker that throws while instantiating does so out of sight, and - // `workerBooted` is the only evidence on this side that it did not. - name: "visor-and-frame-without-jspi", + // runtime in the SharedWorker. The worker is worth spelling out — a + // page can see its own realm fail, but a worker that throws while + // instantiating does so out of sight, and `workerBooted` is the only + // evidence on this side that it did not. The frame realm is covered by + // every scenario that launches an app: a frame that suspended would + // never mount. + name: "instantiates-without-jspi", async run(ctx, origin) { const page = await open(ctx, origin); await visorReady(page); @@ -1472,12 +1474,14 @@ const scenarios: Scenario[] = [ undefined, { timeout: 30_000 }, ); - // Both realms on this side instantiated, and both `instantiate` calls - // passed `{ jspi: false }` (web/jspi_test.ts pins that at the source - // level). Under that option polyengine refuses a sync-typed import - // that returns a Promise, so the visor having rendered its strip and - // the worker having answered `booted` — which needs the runtime - // component's exports — proves no import took a suspending path. + // Both realms on this side instantiated, and every `instantiate` call + // in the repository — visor, frame and worker alike — passes + // `{ jspi: false }` (web/jspi_test.ts pins that at the source level). + // Under that option polyengine refuses a sync-typed import that + // returns a Promise, so the visor having rendered its strip and the + // worker having answered `booted` — which needs the runtime + // component's exports, and its endpoint's in-guest signer — proves no + // import took a suspending path. const marks = await page.evaluate(() => (globalThis as Record).__polyvisor ); diff --git a/justfile b/justfile index daa9ef00..32d7500b 100644 --- a/justfile +++ b/justfile @@ -1,8 +1,20 @@ +# polymorph-iroh, as a git revision rather than a jsr version: the endpoint +# component this repository plugs is built here, from source (see +# `endpoint`), because the interface the runtime needs is feature-gated. +iroh_rev := "8ca991e07cac01368f6df5abf4f96e0beaf6c223" +iroh_rev_short := replace_regex(iroh_rev, '^(.{7}).*$', '$1') +iroh_wasm := "target/iroh_endpoint-" + iroh_rev_short + ".wasm" + # Parse both WIT packages (docs/design.md "Contracts": the public and # private halves). -o /dev/null: we only want the parse/resolve check. +# +# `--features guest-ed25519-signing`: `polymorph:iroh/identity-from-seed` is +# `@unstable(feature = guest-ed25519-signing)` (iroh.wit), and an unstable +# item is invisible — so an interface `world runtime` imports would resolve +# to "interface not found" — unless the feature is named here too. wit: wasm-tools component wit wit/ -o /dev/null - wasm-tools component wit runtime/wit/ -o /dev/null + wasm-tools component wit runtime/wit/ --features guest-ed25519-signing -o /dev/null check: cargo fmt --check @@ -16,19 +28,55 @@ test: build-wasm: cargo build --workspace --target wasm32-wasip2 --release +# Build polymorph-iroh's endpoint component, from source at `iroh_rev`. +# +# From source and not from the jsr package: the runtime binds the endpoint's +# identity through `polymorph:iroh/identity-from-seed`, which the package +# gates behind the cargo feature `guest-ed25519-signing` and its published +# artifact is built without. An identity from a seed signs in-guest, which +# is what lets the worker realm run without JSPI (docs/design.md "No JSPI"). +# +# The build runs under this repository's toolchain, not polymorph-iroh's own +# `rust-toolchain.toml` (1.97.0): RUSTUP_TOOLCHAIN overrides the file, 1.98.1 +# compiles it, and CI then needs no second toolchain installed. +# +# Idempotent twice over: the checkout is fetched-or-cloned, and a +# materialized artifact for this revision is left alone, so `just compose` in +# a loop neither talks to the network nor re-runs cargo. +endpoint: + #!/usr/bin/env bash + set -euo pipefail + if [ -f {{ iroh_wasm }} ]; then + echo "endpoint: {{ iroh_wasm }} is already there" + exit 0 + fi + if [ -d target/polymorph-iroh/.git ]; then + git -C target/polymorph-iroh fetch --quiet origin + else + mkdir -p target + git clone --quiet https://github.com/polymorph-components/polymorph-iroh \ + target/polymorph-iroh + fi + git -C target/polymorph-iroh checkout --quiet --detach {{ iroh_rev }} + cd target/polymorph-iroh + RUSTUP_TOOLCHAIN=1.98.1 cargo build -p iroh-endpoint \ + --features guest-ed25519-signing --target wasm32-wasip2 --release + cd ../.. + cp target/polymorph-iroh/target/wasm32-wasip2/release/iroh_endpoint.wasm {{ iroh_wasm }} + echo "endpoint: wrote {{ iroh_wasm }}" + # Plug polymorph-iroh's endpoint component into the runtime. # -# The runtime imports `polymorph:iroh/{endpoint,identity-from-keys}` and +# The runtime imports `polymorph:iroh/{endpoint,identity-from-seed}` and # nothing in this repository implements them (internal.wit `world runtime`: # "the endpoint component ... composed in at build time with `wac plug`"). # What the worker instantiates is therefore never the cargo artifact but this # composition, whose remaining imports are the endpoint's own — websocket, # webrtc-datachannels, webcrypto, a sockets stub — which the worker glue # provides. -compose: - deno run -A web/fetch-endpoint.ts +compose: endpoint wac plug target/wasm32-wasip2/release/polyvisor_runtime.wasm \ - --plug target/iroh_endpoint.wasm \ + --plug {{ iroh_wasm }} \ -o target/polyvisor_runtime.composed.wasm # web/dist: exactly what a home origin serves. diff --git a/runtime/component/Cargo.toml b/runtime/component/Cargo.toml index 078af50c..da4289fc 100644 --- a/runtime/component/Cargo.toml +++ b/runtime/component/Cargo.toml @@ -13,10 +13,4 @@ crate-type = ["cdylib"] [dependencies] polyvisor-kernel = { path = "../crates/kernel" } wit-bindgen.workspace = true -# `Net::bind` is handed the device's Ed25519 SEED, and `polymorph:webcrypto` -# deliberately offers no way to derive a public key from a private import -# (webcrypto.wit `interface signature`) while `identity-from-keys` requires -# both halves. So the public half is derived here, from the same crate the -# engine signs with. -ed25519-dalek.workspace = true futures.workspace = true diff --git a/runtime/component/src/component.rs b/runtime/component/src/component.rs index 50aeb615..fc1f038f 100644 --- a/runtime/component/src/component.rs +++ b/runtime/component/src/component.rs @@ -20,9 +20,17 @@ use crate::net::IrohNet; // default bindings mode will be used"), which is exactly the contract: // everything in `polyvisor:internal` is `async func`, as is // `wasi:http/client.send`, and nothing else here is. +// `features`: `polymorph:iroh/identity-from-seed` is +// `@unstable(feature = guest-ed25519-signing)` (iroh.wit), and `world +// runtime` imports it. Unstable items are invisible to the resolver unless +// their feature is named, so without this the world fails to resolve +// (wit-bindgen-rust-macro 0.60 lib.rs:205 — the listed features are pushed +// into `Resolve::features`). The endpoint component is built with the +// matching cargo feature; justfile `endpoint`. wit_bindgen::generate!({ path: "../wit", world: "runtime", + features: ["guest-ed25519-signing"], generate_all, }); diff --git a/runtime/component/src/net.rs b/runtime/component/src/net.rs index 36f52010..2a938f80 100644 --- a/runtime/component/src/net.rs +++ b/runtime/component/src/net.rs @@ -11,11 +11,25 @@ //! `subduction_iroh` so native subduction peers interoperate"); //! * a clean end that reads as a clean end rather than as a failure. //! +//! The identity comes from `polymorph:iroh/identity-from-seed`, which is the +//! one constructor interface whose identity keeps its private key inside the +//! endpoint component and signs there ("an identity made by +//! `identity-from-seed` holds its private key in the component's memory +//! instead" — iroh.wit, `interface identity`). The alternative, +//! `identity-from-keys`, keeps the key in the platform's store and reaches +//! it through an async import, which means rustls's `Signer::sign` — a +//! synchronous call on the handshake path — blocks, and the worker realm +//! has to be instantiated with JSPI to let it. The trade, recorded in +//! docs/design.md "No JSPI": TLS signatures now run in wasm ed25519-dalek +//! rather than in the browser's native crypto, and the seed sits in the +//! endpoint's memory for the endpoint's lifetime. It already passed through +//! guest memory here at every bind — this widens where it rests, not +//! whether it is there. +//! //! Nothing here is subduction-aware: frames in, frames out. use std::cell::{Cell, RefCell}; -use ed25519_dalek::SigningKey; use polyvisor_kernel::{ Accepted, Bound, Dialed, EngineTransport, LocalFuture, Net, NetHandle, PAIRING_ALPN, SUBDUCTION_ALPN, @@ -26,10 +40,8 @@ use crate::component::polymorph::iroh::endpoint::{ Connection, Endpoint, EndpointOptions, RecvStream, SendStream, }; use crate::component::polymorph::iroh::identity::Identity; -use crate::component::polymorph::iroh::identity_from_keys; +use crate::component::polymorph::iroh::identity_from_seed; use crate::component::polymorph::iroh::types::{EndpointAddr, TransportAddr}; -use crate::component::polymorph::webcrypto::signature::SigningKeyOptions; -use crate::component::polymorph::webcrypto::{ed25519_sign, ed25519_verify}; use crate::z32; /// Both wires this endpoint serves, in the kernel's spelling @@ -51,30 +63,6 @@ const MAX_FRAME: usize = 50 * 1024 * 1024; /// only about throughput here, not boundaries. const READ_CHUNK: u32 = 64 * 1024; -/// RFC 8410 §7's PKCS#8 PrivateKeyInfo for an Ed25519 seed: version 0, -/// AlgorithmIdentifier 1.3.101.112, and the seed inside a nested OCTET -/// STRING (`04 20` — a CurvePrivateKey of 32 bytes). Every length in it is -/// fixed by the seed's length, so the whole envelope is this constant -/// prefix and nothing else. -/// -/// `polymorph:webcrypto/ed25519-sign` admits PKCS#8 and JWK and refuses a -/// bare seed on purpose (ed25519.wit, `interface ed25519-sign`: "never as a -/// bare seed"), so wrapping is the price of the platform's key store. -const PKCS8_PREFIX: [u8; 16] = [ - 0x30, 0x2e, // SEQUENCE, 46 bytes - 0x02, 0x01, 0x00, // INTEGER version 0 - 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, // AlgorithmIdentifier: id-Ed25519 - 0x04, 0x22, // OCTET STRING, 34 bytes - 0x04, 0x20, // CurvePrivateKey: OCTET STRING, 32 bytes -]; - -fn pkcs8(seed: &[u8; 32]) -> Vec { - let mut der = Vec::with_capacity(PKCS8_PREFIX.len() + 32); - der.extend_from_slice(&PKCS8_PREFIX); - der.extend_from_slice(seed); - der -} - /// The kernel's [`Net`] over `polymorph:iroh`. /// /// The relay lives here rather than in the kernel: it is `boot-config.relay`, @@ -114,34 +102,17 @@ impl Net for IrohNet { /// seed the engine signs with, so a device's endpoint id and its subduction /// peer id are one key. /// -/// The public half is derived here, with ed25519-dalek. It has to come from -/// somewhere: `identity-from-keys` needs both halves, and -/// `polymorph:webcrypto` deliberately provides no derivation from a private -/// import ("there is deliberately no way to derive a `verifying-key` from a -/// `signing-key`" — webcrypto.wit, `interface signature`). The two halves -/// are then checked against each other by `identity-from-keys` with a -/// sign/verify probe, so a seed the platform's key store reads differently -/// than ed25519-dalek does fails here at boot rather than as a handshake -/// failure against every peer this device ever dials. +/// The public half is never computed here: an identity from a seed derives +/// it itself ("the expansion of the seed to the signing scalar happens +/// here" — iroh.wit, `identity-from-seed.from-seed`), and `endpoint.id()` +/// below is what this device is known by. A seed of any length but 32 is +/// `error.invalid-argument`; every 32-byte value is a valid seed, so the +/// only way this fails is a caller bug. /// /// Returns the endpoint id as `device-status.endpoint-id` spells it, /// alongside the handle everything else goes through. async fn bind(seed: &[u8; 32], relay: &str) -> Result<(String, IrohEndpoint), String> { - let verifying = SigningKey::from_bytes(seed).verifying_key().to_bytes(); - let options = SigningKeyOptions::new(); - // The sole usage, and an untouched options resource is refused - // (webcrypto.wit, `resource signing-key-options`). Not extractable: the - // seed is already in our hands, so an exportable handle would widen - // nothing and weaken the later move to a platform-held key. - options.can_sign(true); - let signing = ed25519_sign::import_signing_key_pkcs8(pkcs8(seed), options) - .await - .map_err(|e| format!("this device's signing key was refused: {e:?}"))?; - let verifying = ed25519_verify::import_verifying_key_raw(verifying.to_vec()) - .await - .map_err(|e| format!("this device's public key was refused: {e:?}"))?; - let identity = identity_from_keys::from_keys(signing, verifying) - .await + let identity = identity_from_seed::from_seed(seed) .map_err(|e| format!("this device's identity was refused: {e:?}"))?; let options = EndpointOptions::new(&identity); @@ -172,8 +143,8 @@ async fn bind(seed: &[u8; 32], relay: &str) -> Result<(String, IrohEndpoint), St // Held for the endpoint's life. The WIT lets one identity // configure any number of endpoints and says nothing about the // bound endpoint keeping it alive, so dropping it here would be - // a bet on an implementation detail of the key handle's - // lifetime. + // a bet on an implementation detail of the identity's lifetime + // — and this identity is what holds the signing key. _identity: identity, relay: relay.to_string(), }, diff --git a/runtime/wit/deps/polymorph-iroh/iroh.wit b/runtime/wit/deps/polymorph-iroh/iroh.wit index 8b0bc3c1..6af7ac27 100644 --- a/runtime/wit/deps/polymorph-iroh/iroh.wit +++ b/runtime/wit/deps/polymorph-iroh/iroh.wit @@ -151,9 +151,11 @@ interface types { interface identity { use types.{endpoint-id}; - /// An endpoint identity: an Ed25519 key pair whose private half - /// lives behind the component's crypto import and is never present - /// in this component's memory. + /// An endpoint identity: an Ed25519 key pair. For every constructor + /// interface except `identity-from-seed`, the private half lives + /// behind the component's crypto import and is never present in this + /// component's memory; an identity made by `identity-from-seed` + /// holds its private key in the component's memory instead. /// /// An identity is valid by construction — every constructor /// interface validates before minting — and reusable: any number @@ -201,6 +203,33 @@ interface identity-from-keys { from-keys: async func(signing: signing-key, verifying: verifying-key) -> result; } +/// Identity construction from a raw Ed25519 private-key seed. +/// +/// The private key is held in this component's memory for the +/// identity's lifetime, and this component signs with it directly. +/// Every other constructor interface keeps the private key behind the +/// crypto import. Use this interface only where that trade is +/// acceptable, for example to load a key that the embedder already +/// holds in the clear. The interface is present only in builds that +/// enable the `guest-ed25519-signing` feature. +@unstable(feature = guest-ed25519-signing) +interface identity-from-seed { + @unstable(feature = guest-ed25519-signing) + use types.{error}; + @unstable(feature = guest-ed25519-signing) + use identity.{identity}; + + /// Mint an identity from an Ed25519 private-key seed: the 32-byte + /// private key of RFC 8032 section 5.1.5. The expansion of the seed + /// to the signing scalar happens here, so the caller supplies the + /// seed, not the scalar. + /// + /// `seed` must be exactly 32 bytes; any other length fails + /// `error.invalid-argument`. Every 32-byte value is a valid seed. + @unstable(feature = guest-ed25519-signing) + from-seed: func(seed: list) -> result; +} + /// The stateful endpoint surface: one identity, its connections, and /// their streams. /// @@ -547,5 +576,7 @@ world iroh-endpoint { export identity; export identity-generate; export identity-from-keys; + @unstable(feature = guest-ed25519-signing) + export identity-from-seed; export endpoint; } diff --git a/runtime/wit/internal.wit b/runtime/wit/internal.wit index 176f6136..50886232 100644 --- a/runtime/wit/internal.wit +++ b/runtime/wit/internal.wit @@ -490,8 +490,7 @@ world runtime { /// `read-directory` with a Promise. So the kernel never lists a /// directory: every path it touches is named, from a generation /// pointer it keeps in `kv`. That is what keeps the kernel's filesystem - /// path JSPI-free (the worker itself runs with JSPI for the endpoint's - /// TLS signer — docs/design.md "No JSPI"). + /// path JSPI-free — as every realm now is (docs/design.md "No JSPI"). import wasi:filesystem/types@0.3.1; import wasi:filesystem/preopens@0.3.1; /// The transport. The endpoint component (polymorph-iroh's, composed @@ -499,14 +498,17 @@ world runtime { /// sees are the endpoint's own imports (websocket, webrtc, webcrypto, /// a sockets stub). import polymorph:iroh/endpoint@0.1.0; - import polymorph:iroh/identity-from-keys@0.1.0; /// The device's signing identity rests as an Ed25519 seed inside the - /// sealed checkpoint (the "seed" posture); at boot the seed is imported - /// into the platform's key store so the iroh identity can be built - /// from it. A platform-held, non-extractable identity is a later - /// hardening. - import polymorph:webcrypto/ed25519-sign@0.1.0; - import polymorph:webcrypto/ed25519-verify@0.1.0; + /// sealed checkpoint (the "seed" posture), and the endpoint is built + /// from that seed directly: `identity-from-seed` holds the private key + /// in the endpoint component's own memory and signs there, rather than + /// behind an async crypto import. Sync signing is what lets this realm + /// run without JSPI; the trade is recorded in docs/design.md + /// "No JSPI". The interface is `@unstable(feature = + /// guest-ed25519-signing)`, so every tool that reads this world needs + /// that feature enabled (justfile `wit`, `endpoint`; the runtime + /// component's `generate!`). + import polymorph:iroh/identity-from-seed@0.1.0; export lifecycle; export device; diff --git a/visor/src/component.rs b/visor/src/component.rs index e19c511a..6e25bedb 100644 --- a/visor/src/component.rs +++ b/visor/src/component.rs @@ -15,9 +15,16 @@ // wasmtime catches it. Every function in this world is `async func` // anyway, so following the WIT annotations is both correct today and // proof against a sync one arriving later. +// `features`: not for this world, which imports nothing unstable — for the +// package. `world runtime` in the same directory imports +// `polymorph:iroh/identity-from-seed`, which is +// `@unstable(feature = guest-ed25519-signing)`, and resolution is +// package-wide: without the feature that import is an unresolvable +// reference and the whole parse fails, whichever world is being generated. wit_bindgen::generate!({ path: "../runtime/wit", world: "visor", + features: ["guest-ed25519-signing"], with: { "polymorph:stream-dom/types@0.1.0": stream_dom_guest::bindings::polymorph::stream_dom::types, "polymorph:stream-dom/queries@0.1.0": stream_dom_guest::bindings::polymorph::stream_dom::queries, diff --git a/web/boot.ts b/web/boot.ts index d40f9e4b..331a9365 100644 --- a/web/boot.ts +++ b/web/boot.ts @@ -128,7 +128,7 @@ interface ComponentArtifacts { plan: string; } -/** What the e2e scenarios read (`visor-and-frame-without-jspi`): the page's own +/** What the e2e scenarios read (`instantiates-without-jspi`): the page's own * record that the worker got its runtime up, which the main thread cannot * see any other way. */ const marks: { workerBooted: boolean } = { diff --git a/web/fetch-endpoint.ts b/web/fetch-endpoint.ts deleted file mode 100644 index 3df3d53f..00000000 --- a/web/fetch-endpoint.ts +++ /dev/null @@ -1,67 +0,0 @@ -// Materialize the iroh endpoint component under `target/`, for `just -// compose` to plug into the runtime. -// -// The runtime imports `polymorph:iroh/{endpoint,identity-from-keys}` -// (internal.wit `world runtime`) and nothing in this repository implements -// them: the implementation is polymorph-iroh's own component, composed in at -// build time with `wac plug`. So the bytes have to be on disk before `wac` -// runs, and they are not in the repository — a two-megabyte binary is a -// dependency, not a source file. -// -// The bytes come from the jsr package's PUBLIC surface. `loadArtifacts()` -// hands back the packaged build's `componentBytes`; the package's own -// `endpointComponentBytes()` lives in a module the package does not export -// (`src/endpoint_component.ts`), so reaching it means deep-linking past the -// export map — an undeclared path that can move in any patch release. The -// cost of the public path is that `loadArtifacts` also translates the -// component, which is wasted work here. It is build-time work, once. -// -// Idempotent and cached: a materialized file of the pinned version is left -// alone, so `just compose` in a loop does not decode two megabytes each -// time. - -import { ensureDir } from "@std/fs"; -import { join } from "@std/path"; - -import { loadArtifacts } from "@polymorph/iroh"; - -// The pin is `import.meta.resolve`, not a literal here: deno.json maps -// `@polymorph/iroh` to a jsr semver range, and `deno.lock` is what actually -// resolves it to a concrete version each install. Resolving the specifier -// yields `https://jsr.io/@polymorph/iroh//...`; parsing -// out of that URL means the cache filename always names the version deno.lock -// actually picked, so a lockfile bump cannot silently reuse a stale artifact. -// `wac plug` (justfile `compose`) can't name a versioned path without -// re-deriving the version itself, so after fetching we also copy to a -// version-free stable path (`iroh_endpoint.wasm`) that's the one the -// justfile plugs. -const resolved = import.meta.resolve("@polymorph/iroh"); -const match = resolved.match(/^https:\/\/jsr\.io\/@polymorph\/iroh\/([^/]+)\//); -if (!match) { - throw new Error( - `fetch-endpoint: could not parse a version out of resolved specifier ${resolved}`, - ); -} -const VERSION = match[1]; - -const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); -const OUT = join(ROOT, "target", `iroh_endpoint-${VERSION}.wasm`); -const STABLE = join(ROOT, "target", `iroh_endpoint.wasm`); - -async function exists(path: string): Promise { - try { - return (await Deno.stat(path)).isFile; - } catch { - return false; - } -} - -if (await exists(OUT)) { - console.log(`fetch-endpoint: ${OUT} is already there`); -} else { - const { componentBytes } = await loadArtifacts(); - await ensureDir(join(ROOT, "target")); - await Deno.writeFile(OUT, componentBytes); - console.log(`fetch-endpoint: wrote ${OUT} (${componentBytes.length} bytes)`); -} -await Deno.copyFile(OUT, STABLE); diff --git a/web/jspi_test.ts b/web/jspi_test.ts index 2b150dcf..d67c13ed 100644 --- a/web/jspi_test.ts +++ b/web/jspi_test.ts @@ -8,25 +8,19 @@ // site, and an omission is invisible at runtime: it just silently permits // the suspending path. So the glue's own text is the gate. // -// The worker is the one exception, and it is pinned just as hard in the -// other direction. The composed iroh endpoint authenticates QUIC with -// rustls, whose `Signer::sign` is synchronous, over an async webcrypto -// import; with `jspi: false` the accepting side of every connection stalls -// in `CertificateVerify`. So `worker.ts` must pass `jspi: true` — asserted -// explicitly, so that returning it to `false` (which is the plan, once the -// transport's signer is in-guest) is a deliberate edit of this test and not -// something that can drift in unnoticed either way. +// There is no exception. The worker was one through M3a, because the +// composed iroh endpoint authenticated QUIC with rustls over an async +// webcrypto sign import and the accepting side stalled in +// `CertificateVerify` without JSPI; polymorph-iroh's `identity-from-seed` +// signs in-guest and closed that hole. -import { assert, assertEquals } from "@std/assert"; +import { assert } from "@std/assert"; -/** Realms that must never suspend a wasm frame. `boot.ts` and `frame.ts` - * reach the embedder through `mount.ts` today and have no `instantiate(` of - * their own; they are scanned so that a direct call added later is caught - * by this test rather than by nothing. */ -const WITHOUT_JSPI = ["boot.ts", "frame.ts", "mount.ts"]; - -/** The realm that must have it, until the endpoint's signer is in-guest. */ -const WITH_JSPI = "worker.ts"; +/** Every realm's embedder. `boot.ts` and `frame.ts` reach theirs through + * `mount.ts` today and have no `instantiate(` of their own; they are + * scanned so that a direct call added later is caught by this test rather + * than by nothing. */ +const REALMS = ["boot.ts", "frame.ts", "mount.ts", "worker.ts"]; /** Index just past the `)` closing the parenthesis opened at `open`. */ function balanced(text: string, open: number): number { @@ -53,9 +47,9 @@ async function callSites(name: string): Promise { return sites; } -Deno.test("the visor and frame realms instantiate with jspi: false", async () => { +Deno.test("every realm instantiates with jspi: false", async () => { let sites = 0; - for (const name of WITHOUT_JSPI) { + for (const name of REALMS) { for (const call of await callSites(name)) { sites++; assert( @@ -64,25 +58,7 @@ Deno.test("the visor and frame realms instantiate with jspi: false", async () => ); } } - // `mount.ts` instantiates both producers (the visor and every app). A drop - // to zero would make this vacuous. - assert(sites >= 1, `expected instantiate call sites, found ${sites}`); -}); - -Deno.test("the worker realm instantiates with jspi: true, deliberately", async () => { - const sites = await callSites(WITH_JSPI); - // Exactly one: a second, unannotated instantiate in the worker would be a - // realm this test says nothing about. - assertEquals( - sites.length, - 1, - `${WITH_JSPI}: expected exactly one instantiate( call site`, - ); - assert( - /jspi:\s*true/.test(sites[0]), - `${WITH_JSPI}: the worker is the documented JSPI exception (design.md ` + - `"No JSPI") and must pass jspi: true until polymorph-iroh's TLS ` + - `signer is in-guest. If that landed, this test is what you edit:\n` + - sites[0], - ); + // `mount.ts` instantiates both producers (the visor and every app) and + // `worker.ts` the runtime. A drop to zero would make this vacuous. + assert(sites >= 2, `expected instantiate call sites, found ${sites}`); }); diff --git a/web/worker.ts b/web/worker.ts index 4267accb..18dc0e57 100644 --- a/web/worker.ts +++ b/web/worker.ts @@ -220,24 +220,16 @@ async function loadRuntime(device: string): Promise { "polyvisor:internal/kv@0.1.0": kv, [I.locks]: locks, }, - // THE ONE REALM WITH JSPI (docs/design.md "No JSPI", "The worker is the - // one exception, for now"). Nothing polyvisor writes needs it: every - // glue-implemented import is `async func` and the callback ABI - // wit-bindgen emits never blocks a frame. The composed iroh endpoint - // does. It authenticates QUIC with rustls, whose `Signer::sign` is - // synchronous, and polymorph-iroh implements that as `block_on` over the - // async `polymorph:webcrypto` sign import — a sync lower of an async - // import, which is precisely what JSPI is for. With `jspi: false` the - // ACCEPTING side of every connection stalls in `CertificateVerify` and - // polyengine raises `NeedsJspi` (found in M3a; dialling out survives, - // which is why it presents as a peer stuck at "connecting"). - // - // This reverts to `jspi: false` when the transport's signer is in-guest - // — an identity built from a seed, the posture the kernel already holds - // — and web/jspi_test.ts asserts `true` here explicitly, so that flip - // back has to be a deliberate edit of the test too. The visor and frame - // realms never needed it and stay without it. - { jspi: true }, + // No realm needs JSPI (docs/design.md "No JSPI"). Every glue-implemented + // import is an `async func` whose callback ABI never blocks a frame, and + // the composed iroh endpoint — the one thing here that ever did need it + // — now signs its QUIC handshakes in-guest: the identity comes from + // `polymorph:iroh/identity-from-seed`, so rustls's synchronous + // `Signer::sign` no longer reaches an async webcrypto import. `false` is + // therefore an assertion, not a default: polyengine refuses loudly if a + // sync-typed import ever returns a Promise. web/jspi_test.ts pins it at + // every call site in this repository. + { jspi: false }, ); return instance.exports as unknown as Exports; }