Skip to content

Repository files navigation

wosh

A real SSH session in a browser tab, tunnelled over iroh, reaching a machine with no inbound port open and no public address.

That is the point: the target sits behind NAT or a firewall, opens nothing, and forwards nothing. It makes an outbound connection to a relay and is reachable by its public key. Nothing is listening on the internet to be scanned, brute-forced, or caught by the next sshd CVE — and you get to it from a phone by scanning a QR code.

Three WebAssembly components:

  • the listener (listener-core) — a wasi:cli component you run on the machine you want a shell on. It mints (and persists) an iroh identity, binds an endpoint on a relay, prints a connection string as a link and a terminal QR code, then accepts iroh connections and byte-proxies each one to a configured TCP endpoint (your sshd). It also observes the host key of the sshd it is proxying — see "The fingerprint the listener prints".
  • the client (wosh-client, Rust) — runs in the browser under polyengine, and owns everything long-lived: it parses the connection string, dials the listener over iroh, pumps bytes, relays signatures and prompt batches, and drives an xterm.js UI through a custom WIT interface (wosh:terminal).
  • the SSH core (ssh-core, Go) — golang.org/x/crypto/ssh as a sans-I/O byte-and-tick machine behind wosh:ssh-core: it performs no I/O, holds no keys, and every export is a plain synchronous function. The client feeds it wire bytes and answers its two park-and-poll surfaces (signature requests, prompt batches).

The listener never sees SSH plaintext: it is a dumb pipe once the pairing token checks out. The SSH session is end-to-end between the browser and sshd, so the tunnel is network reachability, not a trust boundary you have to take on faith — the host key you confirm is the real sshd's, checked through the tunnel, not the listener's.

Status: the whole chain works. just e2e drives it into a real OpenSSH sshd, authenticated by a non-extractable WebCrypto key; the client is live at https://lann.github.io/wosh/. See "Where this actually is" for what remains. Experiment-grade code.

How it fits together

BROWSER (polyengine)                      TARGET HOST
┌────────────────────────────┐            ┌──────────────────────────────┐
│ xterm.js                   │            │ wosh-listener (native shell) │
│   ↕ wosh:terminal (WIT)    │            │   wasmtime + polymorph hosts │
│ wosh-client (Rust: dial,   │  iroh QUIC │   ┌──────────────────────────┐│
│   pump, signature relay)   │ ─────────► │   │ listener-core (wasi:cli) ││
│   ↕ wosh:ssh-core (WIT)    │ relay /    │   │   + polymorph-iroh       ││
│ ssh-core (Go: sans-I/O     │ webrtc     │   └──────────┬───────────────┘│
│   x/crypto/ssh)            │            │              ▼ TCP            │
│   + polymorph-iroh endpoint│            │                               │
└────────────────────────────┘            │                               │
         └──────────── SSH, end to end ───────────► sshd (127.0.0.1:22)   │
                                          └──────────────────────────────┘

The connection string (base64url, carried in the URL fragment so it stays out of HTTP logs) is a version byte followed by a postcard-encoded payload: the listener's 32-byte Ed25519 endpoint id, the relay (either a spelled-out URL or a varint index into an append-only table of the public iroh relays — indices are never reused, so old QR codes keep meaning the same relay), and an optional 16-byte pairing token. connstring/ owns the format, and is its only decoder: the Rust client links the crate directly (the previous Go client carried a hand-matched mirror decoder; the split retired it).

The token is never sent. In versions 1 and 2 it was a bearer secret — the client wrote it on the wire, the listener compared bytes — so anything that saw a pairing frame learned a secret good for pairing any device of its own, for as long as the token lived. From version 3 the client sends pairing_proof: an HMAC over both endpoint identities and the ALPN, keyed by the token. The listener recomputes it from the identity iroh authenticated during the handshake (connection.peer()), which is what makes a captured proof worthless to anyone who cannot also produce that peer's signature — that is, to anyone who is not that device already. It is compared with proof_eq, in constant time: a byte-by-byte == stops at the first difference, so its duration reports how much of a forgery was right.

What this does not defend, and is not meant to: a leaked connection string. The token is printed in a QR code and handed around on purpose, and whoever holds it can compute proofs of their own. Rotation — and enrollment surviving it — is that story. The version bump is what makes the change legible: the payload is byte-identical to v2's, so an older client refuses a v3 link by version rather than sending a bearer token to a listener that will only answer "bad pairing token".

Authentication is SSH's own. The browser mints an Ed25519 key through polymorph:webcrypto with extractable: false, prints it as an authorized_keys line, and signs with it during publickey auth — the private half is a capability handle the component can use but never read, so it survives XSS as use-only. The host key fingerprint is shown for confirmation before any credential is sent; that ordering is structural, because x/crypto/ssh runs authentication strictly after its host-key callback returns.

A passkey can be that key instead. OpenSSH has accepted browser-made WebAuthn assertions as publickey credentials since 8.4, under the signature algorithm webauthn-sk-ecdsa-sha2-nistp256@openssh.com. The target needs an ordinary authorized_keys line (sk-ecdsa-sha2-nistp256@openssh.com …, the same kind of line a hardware token gets) and nothing else installed — no agent, no helper. Nothing in the listener participates. What it buys over the browser key: the private half lives in the platform authenticator rather than in browser storage, and every signature costs a deliberate human act — sshd enforces that, rejecting a signature whose user-presence flag is clear, and verify-required in authorized_keys can demand user verification too.

One server-side caveat, and it is a configuration line rather than a capability: only OpenSSH 10.3 and later put that algorithm in the default PubkeyAcceptedAlgorithms (upstream enabled it in February 2026). Every release from 8.4 on can verify these signatures, but 8.4 through 10.2 refuse the offer before looking at one — sshd logs signature algorithm ... not in PubkeyAcceptedAlgorithms at LogLevel VERBOSE. On those, add:

PubkeyAcceptedAlgorithms +webauthn-sk-ecdsa-sha2-nistp256@openssh.com

Without it the client reports the server did not accept the offered key (webauthn-sk-ecdsa-sha2-nistp256@openssh.com), and sshd at LogLevel VERBOSE says signature algorithm ... not in PubkeyAcceptedAlgorithms. Note that the client's message names the key rather than whatever method the server steered to afterwards — on a server that also offers passwords, the refusal arrives as a password prompt this session cannot answer, and reporting that would point at the wrong thing entirely.

Two consequences of how WebAuthn works, worth knowing. The key's application field is the site's domain, not the customary ssh:, because the authenticator signs over sha256(rp-id) and sshd rebuilds that hash from the application in authorized_keys — so the identity is stamped with the origin that minted it, and a clone of this page on another domain cannot use the same line. And because a WebAuthn assertion does not return the credential's public key (nor is there anywhere in the credential to stash it: the user handle is fixed at registration, before the key exists), the public half has to live in this browser's storage — which a passkey outlives. It outlives being carried to a second device, and it outlives eviction of the storage itself, which is a real event and not a hypothetical one: browsers reclaim IndexedDB from sites you have not visited lately.

The public half is not a secret, so both cures are mundane:

  • Adopt — paste the same authorized_keys line into the other device. One touch, which confirms the claim by asserting once before storing it. Preferred when the line is to hand.
  • Recover — work the public key back out of the credential itself. ECDSA verification reconstructs a point from a signature's r; run backwards, one signature narrows the key to a couple of candidates, and two assertions from the same passkey have exactly one in common. Two touches, and it needs nothing external — not the line, not the target, not another device — which is what makes it the answer when storage went away and the line is only readable from a host you can no longer log in to. It reconstructs the same key, so the line already installed on the target keeps working untouched; both gates assert that byte for byte.

The client also remembers where the credential lives. WebAuthn reports that once, at registration (getTransports()), and expects it back in later get() calls; a client that drops it leaves the browser unable to tell a platform passkey from one on a phone or a security key, so it must ask which provider to use — on every connection. That value is stored with the identity and replayed, so the ceremony goes straight to the authenticator that holds the key. An identity that arrived by adopt or recover has no such record (only registration reports it), so the first ceremony re-learns it from the attachment the browser reports.

Offering both is the default under server-steered auto: the passkey is offered first, each key is offered unsigned before any is signed for, so an sshd that will not take webauthn declines the passkey and the browser key answers inside the same connection, with no ceremony spent.

The confirmation can be remembered, per listener, with an explicit opt-in checkbox: the page pins the approved fingerprint keyed by the listener's endpoint id (the one identity iroh itself authenticates during the dial), skips the prompt when the same listener presents the same host key, and warns LOUDLY when it presents a different one. To make those pins survive restarts, the listener persists its iroh identity (default ${XDG_DATA_HOME:-~/.local/share}/wosh, overridable with --identity-dir, disabled with --ephemeral-identity); the key pair rides polymorph:webcrypto handles end to end and is stored as PKCS#8 + raw public key. First contact with an unknown fingerprint is always confirmed interactively — the pin store can only ever suppress the prompt for a fingerprint a human explicitly approved.

Pairing is TOFU in the other direction. The browser persists an iroh pairing identity of its own (IndexedDB, via the host's pairing-store), and a client that once presents a valid token is enrolled by endpoint id (wosh-data/paired, next to the listener's key). iroh authenticates that id on every dial, so an enrolled device keeps reconnecting after the listener restarts and its token rotates — a printed QR stays valid for the devices that already used it, and token rotation gates new devices only. The stakes are deliberately low on this outer layer: pairing gates the tunnel, and SSH — the host-key gate, real authentication — remains the boundary that matters.

Sessions survive transport death. The tunnel (tunnel/, protocol v3) frames the byte streams with cumulative offsets and bounded replay buffers, so a dropped connection — relay restart, network roam, laptop sleep — parks the session on the listener (sshd leg held open, default --resume-grace 600) while the client redials with backoff and both sides retransmit exactly the bytes the other missed. The SSH stack never notices: the sans-I/O core is simply not told the wire broke unless the resume budget is exhausted — 600s of TRYING, matched to the listener's default grace, because every definitive answer (a refusal, a replay gap) still fails fast and the long window is only ever spent on unreachability, where giving up early buys nothing. Both endpoints also REBIND themselves after a relay restart (an iroh endpoint shares fate with its relay websocket; the persistent identities are what make the rebind invisible), and refusals travel as legible errors — a stale token says "bad pairing token" at the client instead of silently dropping.

Dead transports are DETECTED, not waited out. v3 adds liveness frames (PING/PONG) to the tunnel: the client probes after 10s of inbound silence and treats a probe unanswered for 5s as a verdict, a keystroke sent into a quiet link piggybacks a probe (typing into a black hole earns a verdict in ~5–7s, not the 30 QUIC's idle timeout takes), and a page returning from hidden/frozen probes immediately rather than trusting a connection that may have died in its sleep. The page shows "reconnecting…" while the resume machine runs — status deliberately stays ready, because the SSH session IS alive — via the link-state export, which reports the transport's truth separately.

When a session CANNOT be resumed — the listener restarted and its registry is memory, the replay window was overrun — the death is classified (close-kind: ended / failed / lost), and on lost the page falls through to a FRESH session on the same parameters by itself, when it can do so silently: pinned host key, enrolled pairing (which is what lets the stale connstring keep working across the restart's token rotation), and a credential that needs no typing. A dim [wosh] divider marks the seam in the scrollback; the new shell is new, which is the truth. Deliberate detaches and authentication failures never auto-reconnect. (This fall-through is also where opt-in application-level resumption — tmux/dtach reattach — will slot in later.)

The page keeps a connection history (opt-out checkbox, on by default; unchecking records nothing and forgets nothing): listener endpoint id, relay, and user name — never the pairing token. Tapping a recent connection rebuilds a tokenless connection string and dials; it works because this device's pairing enrollment already vouches for it, which is also what makes a copied history worthless off-device. Combined with a pinned host key, a remembered connection is scan-once-tap-forever: open the page, tap, shell — no QR, no prompt. History rows show user@endpoint… and a key pinned badge; the relay and full endpoint id live in the hover detail. Forgetting an entry is a two-step (arm, confirm) affordance on the row itself, and does not touch its host-key pin — losing interest in a host and distrusting its key are different decisions.

Running it

scripts/setup.sh          # pins + builds the external chain into .deps/
just web-deps             # xterm + the browser-gate driver (once)
just build                # both components + native hosts
just listener             # SSH to this box via the public iroh relay
just serve                # the site on :8080 (or `just site out/` to deploy)

just listener exposes 127.0.0.1:22 through n0's public relay; see --help for --relay, --target, pairing-token and identity options. It uses the machine's own identity (~/.local/share/wosh), and exactly one listener may hold it — a second fails fast rather than fighting over the relay registration. For hacking in a checkout, just dev-listener is the same thing with a worktree-local identity, so several can run at once.

The listener prints a QR code and a link. Open it, confirm the fingerprint, paste the authorized_keys line it shows you, connect. With the page already open, the home screen's scan button opens the connect sheet from the camera instead (the platform's BarcodeDetector where there is one, jsQR everywhere else) — that needs an https origin or localhost, like every camera on the web; on a plain http LAN address the button says so. Machines you have connected to become cards on the home screen: tap to reconnect (tokenless — pairing enrollment vouches), for per-connection options (nickname, run-on-connect command, auto-resume, forget). Served over https the site installs as a PWA; over plain http (local development) the service worker deliberately never registers, so a stale cache cannot confuse iteration or the browser gate.

Findings

Measured, not assumed. Each one changed the design.

1. WASI 0.3.1's async run is load-bearing, not a nicety. Binding an iroh endpoint suspends across real network I/O. Under the 0.2 wasi:cli/command world — whose run is synchronously lifted — that traps outright:

wasm trap: cannot block a synchronous task before returning

So the listener targets the real wasi:cli@0.3.1 world, whose run is async func. wasmtime-wasi 47 ships no p3 Command helper, so listener-host hand-rolls that bindgen. The guest imports wasi:sockets@0.3.1 while wasmtime implements @0.3.0; those share the @0.3 compatibility track, which is exactly what the linker's name resolution bridges. Rust's wasm32-wasip2 target auto-generates the 0.2 world for a fn main(), which is why this only surfaces once you stop using main and export run yourself.

2. Go can call async WIT imports — but only from async-lifted exports. Go has no async/await; componentize-go maps async imports onto goroutines plus the Component Model callback ABI, so monotonic-clock.wait-for appears as an ordinary blocking Go call. What matters is how the export is lifted:

export lifting calling an async import
sync (func) traps (SubtaskWaitunreachable)
async (async func) works

Both were measured (spikes/go-async). The restriction is the ABI's — a synchronously-lifted task may not block — not a Go limitation.

3. A never-returning "keepalive" export makes background goroutines work; without one, they silently lose their task. This is the subtle one, and it decides the whole architecture.

componentize-go's async runtime ends a task when the guest goes idle with no Component-Model waitable outstanding:

} else if len(state.pending) == 0 { return CALLBACK_CODE_EXIT }

"Idle with nothing pending" also describes a goroutine blocked on a Go channel waiting for a future export call — so a background goroutine doing long-lived network I/O has its host task exit out from under it. Observed as async-lifted export failed to produce a result the moment an SSH handshake parked waiting for credentials.

Compare Rust's wit-bindgen, which keeps the export's root future and every spawn_local'd future in one set, and only exits when all of them finish:

Poll::Ready(()) => { assert!(me.tasks.is_empty());
                     if me.remaining_work() { Wait } else { Exit } }
Poll::Pending  => { assert!(!me.tasks.is_empty()); ... Wait }

There is a sharper corollary, and it cost real debugging time: an async-lifted export must never block on an in-language primitive. The exit decision is per-task, so an export whose closure is parked on a Go channel gets declared complete the instant nothing Component-Model visible is pending for that task — never calling task-return, which surfaces as the same async-lifted export failed to produce a result. The keepalive keeps background goroutines running; it cannot rescue a blocked export closure. Hence the authenticate-* calls latch a credential and resolve at once, answer-prompts latches its answers the same way, and the caller polls status — the sans-I/O discipline, arrived at the hard way.

Findings 2 and 3 eventually reshaped the client outright. The first working client was one Go component doing its own iroh I/O, living inside these rules (async-lifted everything, a caller-supplied keepalive, no export that blocks). The shipped design moves every long-lived concern into Rust (wosh-client), where wit-bindgen's task tracking makes them legal, and shrinks the Go side to a sans-I/O core (ssh-core) whose exports are all synchronous and whose imports are none — a component the findings simply cannot reach: no keepalive, no task-exit races, nothing async to trap. The latch-then-poll contract survives at the wosh:terminal surface because it is a good contract, not because Go still requires it.

This is a library-design difference, not a Component Model limit. A Rust future is a value the binding owns and can enumerate; a goroutine is opaque — once you go f(), the runtime owns it, and there is no portable way to distinguish "finished" from "blocked until someone calls in again". Any language with runtime-managed, opaque concurrency would hit this; any language where background work is a first-class object the binding holds (Rust futures, JS promises) would not.

The workaround, measured in spikes/go-async: an async export that never returns, sleeping on an async import in a loop. Its task always has a pending subtask, so its callback returns WAIT, the host keeps resuming it, and every resume runs all runnable goroutines — whose imports attach to that task.

after 600ms with ZERO export calls: bg-count = 20

Twenty of twenty background iterations completed with no export call driving them. Sharp edge: this is user-space emulation of a missing library feature. If that export is ever awaited to completion or dropped, background I/O stops silently. The clean fix is upstream — a witAsync.Spawn that tracks live background goroutines and returns WAIT while any remain would give Go parity with Rust.

Layout

  • connstring/ — the pairing format (shared by both ends), with tests.
  • tunnel/ — the tunnel framing (protocol v3): resumable sessions as frames + cumulative offsets + bounded replay buffers, plus liveness (PING/PONG) frames, one codec linked by both Rust ends, with golden-byte tests. v2 (no liveness) and the v1 raw pipe remain accepted, so old pages and old listeners keep working.
  • webauthn-ssh/ — the WebAuthn-to-SSH wire mapping: a passkey's authorized_keys line, a browser assertion turned into an OpenSSH webauthn-sk-ecdsa signature, and the public-key recovery that gets an identity back from nothing but two signatures. A crate of its own because every rule it enforces is one sshd enforces silently, several round trips away, so they are cheaper as unit tests than as failed logins.
  • listener-core/ — the wasi:cli@0.3.1 listener component.
  • listener-host/ — its native shell: wasmtime + the polymorph webcrypto/websocket/webrtc host crates + hand-rolled 0.3.1 bindgen.
  • wosh-client/ — the browser SSH client's Rust half, and the wosh:terminal exporter: connection-string parsing (links connstring/ directly), the iroh dial and pairing frame, the never-cancelled reader and single-writer byte pump, and the signature relay to the host's identity-store and passkey-store.
  • ssh-core/ — the Go half: x/crypto/ssh as a sans-I/O component behind wosh:ssh-core. No I/O, no keys, no non-WASI imports; every export is a plain synchronous function, and every state change happens inside one (feed bytes, tick, answer a parked signature or prompt batch). Its engine internals are plain Go with host-runnable tests (ssh-core/core/) against an in-process x/crypto server.
  • scripts/gate-proc.sh — the gates' process ownership: background processes started under a name and stopped by pid, never by pattern, with logs and pidfiles under .deps/run/ (per worktree). Its own self-test is just test-gate-proc; just gates-down stops whatever this worktree left running.
  • smoke-test/ — the end-to-end gate: the composed client under wasmtime, over real iroh, through the listener, into a real OpenSSH sshd.
  • site/ — the static site: xterm.js in front of the client component, runtime-linked in-page by polyengine. Installable as a PWA: the service worker precaches the tree version-keyed, which matters because the tree carries ~12 MB of wasm (the component plus polyengine's translator) and because polyengine runtime-links the component against the page bundle, so a cache mixing two deploys would be incoherent.
  • site/lifecycle.mjs — the page's lifecycle wired to the session: hidden/frozen/pagehide mean "stop redialing, the network is being taken away", visible/resumed mean "redial now". No mobile gate on it: a desktop tab left in the background has the same problem.
  • site/separator.mjs — the session bookends: every session opens with a green start rule (user and time, the first session included) and closes with a red end rule the moment the client knows it is over for good — labeled by how it ended (session lost, session ended, detached), so the scrollback reads as a timeline of connects and disconnects. A resumable outage draws nothing: the session is not over, and an end mark that might be taken back would be a lie. Each rule is an xterm marker + decoration, so it scrolls with the content it bounds and is styled as page chrome the remote cannot fake; the writes are awaited where no session output can interleave (before the pump starts, after it stops).
  • site/links.mjs — the link policy: terminal links activate on a SINGLE click or tap (a modifier convention does not exist on a phone) and open only through a confirmation dialog showing the URI verbatim — an accidental tap costs a dismissal, never a navigation to whatever a hostile cat painted. The dialog's checkbox turns future confirmations off (opt-in, persisted, http(s)-only either way; opens are noopener,noreferrer). The rest of the addon family rides along in app.mjs: Unicode 11 widths, OSC 52 clipboard wired WRITE-ONLY (yanks from tmux/vim reach the local clipboard; a remote host asking to READ it is answered with an empty string — that is an exfiltration primitive, not a feature), the WebGL renderer (falling back to DOM on context loss), and inline images (sixel + iTerm IIP).
  • host-test/ — browser gates driven by playwright-core against the real assembled site.
  • spikes/go-async/ — the three measurements above, as runnable code.
  • wit/ — vendored upstream WIT (polymorph-iroh, polymorph-webcrypto, WASI 0.3.1), plus this project's own interfaces.
  • .deps/ — pinned, digest-verified artifacts (the iroh endpoint component; the translator shim cache), fetched by scripts/setup.sh. Deliberately inside the repo: iroh_endpoint.wasm is required to compose either component, so it must not live in a scratch directory that can be reclaimed.

The fingerprint the listener prints

The browser confirms the target's SSH host key end to end — that is the point of the tunnel being reachability and not trust. But the human answering that prompt has to compare it with something, and until recently there was nothing on the other side of the room to compare it against.

There is now. The host key crosses the wire in the clear: SSH encrypts nothing until NEWKEYS, and the server's key exchange reply carries the host key blob K_S verbatim. The listener is already proxying those bytes, so it observes them passively — hostkey/ is the whole of it: a framing state machine over the cleartext phase, with RustCrypto's ssh-key doing every piece of parsing (PublicKey::from_bytes on K_S, SHA256 fingerprints, known_hosts lines). Nothing is hand-decoded, and the observer is a tap: it sees copies, and any confusion — desync, a packet it cannot believe, traffic that is not SSH — disables it silently rather than touching the pipe.

Which message carries the key depends on the negotiated kex (31 is the ECDH reply, but under group exchange 31 is a prime and 33 is the reply), so rather than track the negotiation it validates by parse: read the leading string, and accept it only if it parses as a public key. A prime does not.

A fingerprint is only worth printing if the listener can say why it believes it, so the printout is governed by the operator's own known_hosts (passed in by the native host as content — never a handle to ~/.ssh, which also holds private keys):

known_hosts says what the listener does
this key, for this target prints it, noting the corroboration
a different key refuses the connection, naming both fingerprints (--allow-host-key-mismatch downgrades this to a loud warning)
@revoked refuses, always — a revocation is an instruction, not a stale record
nothing, target is loopback prints it: the listener is on the machine, so this is self-observation
nothing, target is remote prints no fingerprint, and says why

just browser-e2e pins the part that matters: the fingerprint the listener sniffed off the wire must equal the one the page made a human confirm, in the same run. Two independent observers — one reading the key end to end through the tunnel, one watching the handshake it is proxying — and the gate fails if they ever disagree.

Where this actually is

Verified working:

  • The listener, end to end: identity minted through webcrypto, endpoint bound on a live relay, connstring + QR + link printed, pairing token enforced, and a peer's bytes proxied to a TCP service and back.
  • The whole thing, end to end (just e2e): the browser client component under wasmtime obtains its identity's public half from the host's identity-store, emits an authorized_keys line, dials the listener over real iroh, verifies the host key fingerprint against the real sshd's, authenticates by publickey with a signature the store produces (no private-key handle exists anywhere in the component graph; the sshd's own verification is what judges the signature), gets an interactive pty, round-trips a command through the tunnel, resizes, and detaches cleanly. A second leg does the same via authenticate-auto: the server steers method selection, and against this publickey-only sshd that must resolve to the same silent signature flow -- the leg fails if any prompt surfaces.
  • Keyboard-interactive auth (just e2e-kbdint): the same composed client answers two server-driven prompt batches (echoed and masked prompts, RFC 4256) over the same iroh path and reaches a shell; a wrong answer is refused legibly; and an authenticate-auto leg proves the server steers auto to keyboard-interactive when that is all it offers. Runs against a scripted x/crypto/ssh stand-in server (kbdint-sshd/), because a user-mode OpenSSH sshd has no keyboard-interactive backend without PAM. This is what PAM OTP/2FA setups need; the prompts render in the page with masking honored.
  • Pairing across token rotation (just e2e-pairing): a client that once presented a valid token is enrolled by its persistent iroh id; after a listener restart rotates the token, the SAME device connects with the stale connstring (a printed QR keeps working) while a NEW device with that connstring is refused, legibly.
  • Session resume (just browser-resume): a live browser session rides out a relay restart — the client detects the transport death, rebinds its endpoint, redials with backoff and resumes; the listener rebinds its accept loop, re-registers under the same identity, and replays from the parked session. The page never changes state; a post-restart keystroke round-trips. The client's resume budget is spent in time spent TRYING, not in time elapsed: a backgrounded phone suspends the component mid-loop and a hidden tab has its timers throttled, so wall-clock would burn the budget without a single attempt being made -- for a session the listener is still holding parked. (The listener's accept loop previously went permanently deaf on the first relay hiccup — found by this gate's drill.)
  • Fresh-session fall-through (just browser-fallthrough): the listener restarts mid-session (its session registry is memory, so the parked session is unrecoverable by construction); the client's liveness probe detects the dead transport in ~15s, the resume is refused legibly, the death classifies as lost, and the page opens a fresh session by itself — pinned host key, enrolled pairing outliving the token rotation, silent browser-key auth, and a dim [wosh] divider marking the seam. No dialog, no human.
  • The freeze drill (just browser-freeze): SIGSTOP the page's renderer 45s — a faithful phone-in-pocket — then thaw and demand the wake-probe/resume ladder recover the parked session, which it does in under a second. The drill flushed out three upstream bugs; the two the pinned chain now fixes are what let it run in check. A polymorph-iroh resource that outlived its connection acted on the slot's NEXT occupant (handle reuse), so the dead attachment's dying send stream reset every freshly resumed connection at birth (polymorph-iroh#78; the pin carries the epoch guard). And the wasmtime webrtc host ran its per-connection driver tasks on the embedder's own runtime, so one wedged driver froze the whole component — deaf endpoint, zombie sessions never idle-timing-out — while starving the very teardown that would have ended the wedge (polymorph-webrtc-datachannels#158; the pin carries driver reactor isolation, under which the wedge costs a pool thread and then self-heals). The wedge's root — rtc-ice pins a failed agent's wake-up deadline in the past, and the webrtc driver loop spins on it at full speed — is fixed upstream in lann/rtc#2 and no longer gates anything here.
  • All three spike measurements above.
  • The site in real headless Chromium (just browser): the page loads, xterm mounts, polyengine instantiates the composed component and runs guest code in-page, the browser's SSH identity survives a page reload (the non-extractable CryptoKey pair persists in IndexedDB, so an installed authorized_keys line keeps working across visits), and the PWA shell is coherent (manifest parsed, icons resolve, service worker version-keyed with the component in its precache).
  • The page, live, end to end (just browser-e2e): headless Chromium drives the real UI -- form, interactive host-key confirmation (the prompt must appear and the session parks on it: TOFU is asserted, not assumed), the default auto method steering to publickey with the browser-minted key (silently: no prompt may surface), keystrokes round-tripping through xterm to the sshd and back, and a rejected fingerprint ending the attempt with nothing sent. Plus the whole pinning ladder: approval alone persists nothing (opt-in is asserted), approval with "remember" checked skips the prompt on the next connect, and a pinned listener presenting a DIFFERENT host key gets the loud changed-key warning showing both fingerprints.
  • The addon family and the link policy (just browser-links): against the real assembled site with nothing else running — an emoji is two cells wide (Unicode 11 tables active, matching what the remote pty computes), the clipboard is wired write-only, the WebGL renderer is up, a sixel decodes into the image addon's storage, and a click on a painted URL opens the CONFIRMATION with the verbatim URI: cancel opens nothing, confirming with "always open" checked opens the link and persists, after which the next click opens without asking.
  • The mobile layer (just browser-mobile): synthesized touch in headless Chromium drives the key strip against the page's real stylesheet -- a tap types (without taking focus off the terminal, which would drop the soft keyboard), a finger that drags off a key types nothing, and a sideways flick scrolls the strip instead of emitting the key it started on. The bar sits under the thumb and its strip scrolls, so press-vs-drag is a live conflict. Plus the rule that keeps the keyboard reachable: on a touch device a freshly opened page leaves nothing focused, because a focus the page took for itself cannot summon a keyboard -- it can only make the taps that would look like no-ops. And scrolling, against a real xterm: a finger drags the scrollback (or the scrollbar's thumb) instead of panning the page, without the drag also landing as a tap that summons the keyboard. Plus the lifecycle wiring: hidden, pagehide and freeze all suspend the session, visible, pageshow and resume all wake it, and a missing or torn-down session is survived quietly -- these run on the browser's way out of the page, where there is nobody to report an exception to. And the chrome's shape, against the real boot.mjs with only the component stubbed: a fresh home screen is the task (scan + paste, no cards, the extra-keys bar hidden until a session exists), every question -- host-key confirmation, prompt batches, the passkey ceremony -- arrives as ONE modal bottom sheet that owns the screen (never below a phone's fold, never outlived by its attempt: superseding an ask resolves it null), the fingerprint's textContent is the exact string (its 4-char grouping is visual only), and the identity screen reveals the adopt field and guidance on demand. No other gate can see any of this; the e2e legs type through xterm, and none of them synthesize a finger that moves.
  • Listener identity persistence: the endpoint id (and so the browser's pins) survives listener restarts; --ephemeral-identity restores the old per-run behavior.
  • Gate isolation (just test-gate-proc): stopping one gate's process kills that process and nothing else. The gates used to open by pkill-ing every wosh-listener on the machine — other worktrees' gates and the operator's own dev listener included — which is also how strays accumulated in the first place (nothing stopped what it started). Now each is named, pid-tracked and stopped from a trap, test listeners are --ephemeral-identity (nothing on disk to share), and the logs gates read connstrings from live under .deps/run/ rather than fixed /tmp paths two worktrees would clobber. One pattern-kill survives on purpose: browser-resume restarts the RELAY, which is shared machine-wide, so that gate still reaches further than its own worktree.

Not finished:

  • CI.

Deploying

.github/workflows/pages.yml publishes site/ to GitHub Pages on every push to main. What ships is the same component the gates run -- polyengine is a runtime linker, so there is no transpiled variant that could drift from what was tested.

The listener's --qr-base defaults to that deployed client, because the QR code is the entire bootstrap and it has to point at something a phone can open. Pass --qr-base to aim at your own copy: scripts/site-deploy-tree.sh <dir> produces a tree any static host can serve, and the whole thing is relative-path clean.

The origin serving the client is part of the trusted computing base, as it is for any web application: whoever controls it controls the code that runs. Trust has to start somewhere — with a web client it is the origin, with a native client it is your package manager and whoever signed the binary. Neither is free.

What is worth knowing is where that trust does not have to extend. The listener is not trusted: SSH runs end-to-end, so it carries ciphertext and the host key you confirm is the target sshd's. The relay is not trusted: it sees QUIC packets between two keys it cannot read. And the target exposes nothing to the internet at all, which is the substantive difference from port-forwarding sshd and hoping.

If you would rather not depend on this origin, scripts/site-deploy-tree.sh <dir> produces a tree any static host can serve; the whole thing is relative-path clean and needs no build at the far end. Note the service worker does not help here — it caches a version-keyed tree for offline start-up, but it will happily install the next deploy, so it is a startup and coherency mechanism, not a pin.

Licence and provenance

Experiment-grade; no stability promised. Built against ssh-key (SSH key, fingerprint and known_hosts parsing), polymorph-iroh (the iroh endpoint as a component), polyengine (the JS component host), and golang.org/x/crypto/ssh.

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages