Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.
Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay: native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent: host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e: browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth: Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web: browser login + Web Crypto E2E + client-side preview rendering
Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.
NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
53 KiB
P6 — Browser / Frontend (relay-web/) — Implementation Plan
Charter:
PLAN_RELAY_INDEX.md§5 "P6 — FRONTEND". Owns the browser bundle served from the tenant subdomain: login (Passkey), connecting through the relay (same-origin, scheme-followingwss:, M6), browser-side E2E by consuming the frozen §4.4 primitives from P4relay-e2e(buildClientHandshake→E2ESession,sealFrame/openFrameover an opaqueAeadKey/ direction-splitDirectionalKeys,@noble/ciphers) — NOT a singlesessionKeyvia raw SubtleCrypto AES-GCM, CLIENT-SIDE preview rendering (server previews die under E2E — the authorized, key-holding browser decrypts + renders a read-only xterm), and pairing/onboarding + dashboard (host●onlinestatus, add-machine flow). The core xterm byte path inpublic/stays byte-for-byte untouched. Source of intent:EXPLORE_RELAY_SERVICE.md§0 (LOCKED, CLOSED), §4c, §4e, §5. Conventions perPLAN.md/PLAN_VOICE_COMMANDS.md: stable task IDs → dependency waves, disjointOwns:, function-signature contracts, TDD (tests FIRST), explicit test cases (incl. security/negative), per-task security notes.PROGRESS_LOG.mdis orchestrator-only (subagents return a ready-to-paste entry, G1).
0. Scope fence — in / out
In P6 (this plan): everything the browser runs — the relay-web/ bundle served from
alice.term.<domain>: the login page(s), the terminal view that mounts xterm and pumps it through
an E2E-wrapped WebSocket, the dashboard (host status + add-machine/onboarding), and the client-side
preview grid. P6 is a consumer of the frozen contracts and of P3/P4/P5 endpoints — it defines
no wire format, no crypto primitive, no account record.
Out (other plans — do NOT touch): the mux frame format & data plane (P1 §4.1), the host-agent
(P2), account/host registries + pairing issuance + routing (P3 §4.2/§4.5), the E2E crypto core
(P4 relay-e2e/ §4.4 — P6 imports sealFrame/openFrame and the handshake driver, never
re-implements them), and server-side auth/authz/capability-token issuance & verification (P5 §4.3).
Untouched base app: public/**, src/**, protocol.ts, vitest.config.ts (base) — zero
edits. relay-web/ is a new top-level package with its own package.json, esbuild config, and
test suite. It may import xterm.js + @xterm/addon-fit as fresh dependencies (same libraries the
base uses) — it does not import or mutate public/. The base app's ALLOWED_ORIGINS config
touch-point (INDEX §0) is owned by the install flow, not by P6.
1. Security invariants enforced (from INDEX §3)
P6 owns/enforces and ships a test per:
| INV | How P6 enforces it (client-side) | Owning tasks |
|---|---|---|
| INV3 | The browser NEVER sends account_id/tenant_id as a source of truth. Every API call derives the account server-side from the passkey/cookie session; requests carry only the passkey assertion or the signed cookie. Client-side authz decisions do not exist — the UI reflects server responses. |
T2, T3, T7 |
| INV15 | From v0.9 on, every terminal WS upgrade carries a capability token (§4.3) obtained from P5; the client presents the raw token via the Sec-WebSocket-Protocol upgrade header (never a query string — T4/T8 §), never self-authorizes. In v0.8 there is no token (INDEX §1: "no capability tokens yet"); T4's passthrough rides the T3 signed cookie only — the INV15 client-half is a v0.9/v0.10 deliverable that lands with P5 issuance. Origin stays same-origin (M6) so the base-app CSWSH check is retained AND augmented. Step-up auth (T7) runs before opening a session, not just at login. |
T4 (v0.9+), T7, T8 |
P6 consumes (relies on, and adds defensive client-side checks for):
| INV | Client-side reliance / defensive check | Tasks |
|---|---|---|
| INV2 | The browser is the only place plaintext exists end-to-end; it hands ciphertext to the socket. P6 asserts it never logs plaintext and never persists it. | T8, T9 |
| INV13 | openFrame (P4) rejects replayed/reordered/tampered frames; P6 wires a strictly monotonic expectedSeq per direction and tears the socket down on any openFrame throw (AEAD-tag / seq failure) rather than swallowing it. P6 also ships a reflection/direction-confusion test (a captured frame from one direction re-injected as the other MUST be rejected). Now frozen (FIX 2): §4.4 is direction-bound via DirectionalKeys{c2h,h2c} — the client seals with c2h/opens with h2c and aad = directionLabel‖seq, so a reflected frame cannot verify (§8 Q#4 RESOLVED). |
T8 |
| INV4/§4.4 TOFU | The root of trust for the host fingerprint is the authenticated control-plane record — HostRecord.enrollFpr (§4.2), fetched over the P5-authenticated HTTPS API (api.getHost(hostId), T2), a channel out-of-band of the relay. The browser verifies the WS-negotiated host_hello.sig-derived fingerprint against that API-sourced value and aborts on any disagreement, including the very first connection — a malicious relay that forges host_hello on a device's first connect is caught because its fingerprint ≠ the API-sourced enrollFpr. HostPinStore (localStorage) is a cache/audit trail of the API-sourced fingerprint, NOT an independent trust root; it never blind-trustOnFirstUses a relay-forwarded value. |
T8 |
2. Files & ownership (all NEW under relay-web/)
| File | Task | Phase | Role |
|---|---|---|---|
relay-web/package.json, relay-web/build.mjs, relay-web/vitest.config.ts, relay-web/tsconfig.json |
T1 | v0.8 | Package scaffold, esbuild bundling, jsdom test config, coverage.include |
relay-web/src/config.ts |
T1 | v0.8 | Runtime config from location (subdomain, same-origin API base, scheme-following ws:/wss:) |
relay-web/src/api-client.ts |
T2 | v0.8 | Zod-validated control-plane HTTP client (login, token issuance, host list, pairing) |
relay-web/src/api-schemas.ts |
T2 | v0.8 | Zod request/response schemas (re-export §4.2/§4.3/§4.5 shapes from relay-contracts, never redefine) |
relay-web/src/login-password.ts |
T3 | v0.8 | v0.8 shared-clientToken password gate → signed cookie |
relay-web/src/terminal-view.ts |
T4 | v0.8 | Mount xterm + FitAddon; drive it through the socket adapter; base attach/input/resize/output/exit semantics |
relay-web/src/ws-transport.ts |
T4 | v0.8 | TerminalTransport interface + PassthroughTransport (v0.8, no E2E); scheme-following same-origin URL |
relay-web/src/dashboard.ts |
T5 | v0.9 | Host list with ●online/offline/draining/revoked status, live refresh |
relay-web/src/add-machine.ts |
T6 | v0.9 | Add-machine onboarding: request pairing code, show npx …agent pair <code>, poll until online |
relay-web/src/login-passkey.ts |
T7 | v0.10 | Passkey/WebAuthn register + authenticate ceremonies; step-up-before-session |
relay-web/src/webauthn.ts |
T7 | v0.10 | Thin navigator.credentials wrapper (base64url ↔ ArrayBuffer, error mapping) |
relay-web/src/e2e-socket.ts |
T8 | v0.10 | E2ETransport: drive the §4.4 buildClientHandshake → ClientHandshake over WS, pin enrollFpr, wrap send/recv through the E2ESession (seal/open over sealFrame/openFrame + direction-split DirectionalKeys) — imported from relay-contracts/relay-e2e, never redefined |
relay-web/src/host-pin-store.ts |
T8 | v0.10 | Cache/audit of the API-sourced enroll_fpr per host_id (NOT a trust root) + drift-vs-cache detection |
relay-web/src/preview-client.ts |
T9 | v0.10 | Decrypt a ciphertext replay + render a read-only xterm (headless dims) |
relay-web/src/preview-grid.ts |
T9 | v0.10 | Grid of preview-client cards (the manage-page moved client-side) |
relay-web/public/index.html, dashboard.html, pair.html |
T1/T5/T6 | — | Static entry pages (CSP headers documented, no inline script) |
relay-web/test/** |
each | — | Co-located tests, one file per module |
Disjoint by construction: transport (T4/T8), auth (T3/T7), dashboard/onboarding (T5/T6), preview
(T9) never edit each other's files. api-schemas.ts (T2) is the frozen client-contract surface — a
new field is added there (a coordination point mirroring relay-contracts), never redeclared
locally, per the src/types.ts discipline.
3. Dependency waves & cross-plan ordering
W0 T1 scaffold ─┬─▶ T2 api-client
│
W1 T3 password login ─┐ T4 terminal-view + passthrough transport
│ (both need T1 config; v0.8 T4 needs NO token — cookie only.
│ T2's issueCapabilityToken is wired into T4 in v0.9, not v0.8)
W2 T5 dashboard ──▶ T6 add-machine (need T2 + a login session)
W3 T7 passkey login T8 e2e-socket (T8 needs P4 relay-e2e + P5 token issuance)
W4 T9 client-side preview (needs §4.4 replay surface deriveContentKey/openReplayCiphertext (P4 T10)
+ hostContentSecret via P5 + T5 host list; NOT the ephemeral live key)
Cross-plan deps (reference their task IDs at build time):
- T2 → P3: host-list + pairing-status endpoints (P3 registries §4.2 / pairing §4.5 ISSUE).
- T2/T4/T7 → P5: WebAuthn challenge/verify + capability-token issuance (§4.3) + step-up.
- T4/T8 → P1: the terminal WS upgrade path routes by authenticated session on the subdomain
(INV1). In v0.8 the upgrade is authenticated by the T3 cookie (no token yet); from v0.9 it
requires the capability token (INV15), which P6 attaches via
Sec-WebSocket-Protocoland P1/P5 verify. - T8 → P4: imports the frozen §4.4 driver verbatim —
buildClientHandshake(P4 T11) →ClientHandshake.start/.onHostHello(P4 T8) →createE2ESession('client', result)(P4 T9), plussealFrame/openFrame/E2ESession/DirectionalKeys/AeadKey; the agent side runs thehost_hello/sealFramecounterpart (P2). P6 owns only the browser glue + fingerprint pin. - T9 → P4 + P5/P2: imports the frozen §4.4 replay surface
deriveContentKey/openReplayCiphertext(P4 T10); consumes the §4.5 host-scopedhostContentSecretdelivered via P5 (minted+wrapped by P3 at BIND, FIX 3); the agent (P2) seals replay-bound output withsealReplayFrameunder the sameK_content. - T6 → P2: the displayed
npx web-terminal-agent pair <code>command is redeemed by P2 (§4.5 REDEEM); P6 only issues the code (via P3) and polls host status.
Ordering rule (INDEX §2.5): P4 + P5 before P6's E2E/auth tasks. v0.8 tasks (T1–T4) ship against
the frp scaffold + password gate with no E2E and no passkey — the transport is a
PassthroughTransport behind the same TerminalTransport interface T8 later swaps in, so E2E "drops
in without reshaping" (INDEX §1 v0.8 bullet).
4. Tasks
T1 — Package scaffold + runtime config (v0.8, W0)
Owns: relay-web/package.json, relay-web/build.mjs, relay-web/tsconfig.json,
relay-web/vitest.config.ts, relay-web/src/config.ts, relay-web/public/index.html,
relay-web/test/config.test.ts.
Contract:
export interface RelayWebConfig {
readonly subdomain: string // 'alice' parsed from location.hostname (left-most label)
readonly wsUrl: (path: string) => string // scheme-following: wss: on https:, else ws:; SAME-ORIGIN (M6)
readonly apiBase: string // same-origin '' ; all control-plane calls are relative
}
export function readConfig(loc: Pick<Location, 'protocol' | 'host' | 'hostname'>): RelayWebConfig
TDD (RED first):
- RED
config.test.ts—readConfigderives subdomain + scheme-following URL → fails (module absent). - GREEN implement
config.ts; esbuildbuild.mjsbundlesindex.htmlentry.
Test cases (incl. negative):
https://alice.term.example.com→subdomain==='alice',wsUrl('/term')==='wss://alice.term.example.com/term'.http://alice.term.localhost:3000→ws://…(scheme follows page protocol, M6 — no mixed-content on TLS).- Negative: bare host with no subdomain label →
readConfigthrows a typed error (fail-fast at boundary), never defaults to another tenant's subdomain. - Security:
wsUrlis always same-origin — a passedpathcannot inject a foreign host (wsUrl('//evil.com')stays onlocation.host).
Security notes: same-origin + scheme-following is the M6 guarantee; no IP/host config knob exists in the client, so the browser can never be pointed at another tenant's origin (supports INV1 upstream).
T2 — Zod-validated control-plane API client (v0.8, W0)
Owns: relay-web/src/api-client.ts, relay-web/src/api-schemas.ts, relay-web/test/api-client.test.ts.
Contract: api-schemas.ts re-exports relay-contracts types verbatim (HostRecord,
HostStatus, CapabilityRight) and adds only response envelopes; it never declares an
account_id request field (INV3).
// v0.8 surface (ships against the flat SQLite table + cookie session):
export interface ApiClientV08 {
listHosts(): Promise<readonly HostRecord[]> // account derived server-side (INV3)
getHost(hostId: string): Promise<HostRecord> // authoritative HostRecord incl. enrollFpr (§4.2), for T8 TOFU root-of-trust
requestPairingCode(): Promise<{ readonly code: string; readonly expiresAt: string }> // §4.5 ISSUE
hostStatus(hostId: string): Promise<HostStatus>
}
// v0.9 GROWS the surface once P5 capability-token issuance exists (INDEX §1 v0.9). NOT present in v0.8:
export interface ApiClient extends ApiClientV08 {
issueCapabilityToken(hostId: string, rights: readonly CapabilityRight[]): Promise<string> // raw §4.3 token — v0.9+
}
export function createApiClient(cfg: RelayWebConfig, fetchImpl?: typeof fetch): ApiClient
getHost(hostId) returns the full authoritative HostRecord (§4.2) — its enrollFpr is the
out-of-band-of-the-relay trust root T8 verifies the E2E handshake against (see T8 / §1 INV4 row);
this method exists from v0.8 so the record is reachable before E2E lands. issueCapabilityToken is
phased in at v0.9 alongside P5 issuance (INDEX §1 v0.8: "no capability tokens yet"; "P4/P5 write
their contracts but ship no runtime") — a v0.8 build MUST NOT call it and MUST NOT require a token.
Every response is parsed through a Zod schema before return (validate-at-boundary, coding-style.md);
a parse failure throws a typed ApiError, never returns a partial object.
TDD:
- RED
api-client.test.tswith a mockedfetch— asserts request bodies contain noaccount_id/tenant_id, responses are Zod-validated → fails. - GREEN implement client + schemas.
Test cases (incl. security/negative):
listHosts()sends no account/tenant field in URL, body, or query (INV3 static assertion in test).- Malformed host in response (missing
subdomain) →ApiErrorthrown, not a torn object. getHost('h1')returns a Zod-validatedHostRecordwhoseenrollFpris a non-empty string; missing/emptyenrollFpr→ApiError(T8 must never fall back to a relay-forwarded fingerprint when the trust root is unavailable).- v0.9+
issueCapabilityToken('h1', ['attach'])returns the opaque raw token; client does not decode-to-trust it (verification is server-side). v0.8 phasing: a structural test asserts the v0.8 build path never callsissueCapabilityToken(the method is absent fromApiClientV08). - Negative: 401 response → typed
ApiError('unauthenticated'), surfaces a re-login prompt (no silent swallow). - Security: forging
hostIdthe account doesn't own → server returns 403; client renders the error, never assumes success (INV1 enforced upstream, respected here).
Security notes: INV3 is enforced structurally — the request builders have no parameter for account/tenant. All identity is the cookie/passkey session the browser already holds.
T3 — v0.8 password gate login (v0.8, W1)
Owns: relay-web/src/login-password.ts, relay-web/public/index.html (login markup),
relay-web/test/login-password.test.ts.
Contract:
export interface PasswordLogin { submit(clientToken: string): Promise<'ok' | 'rejected'> }
export function mountPasswordLogin(root: HTMLElement, cfg: RelayWebConfig): PasswordLogin
Posts clientToken to the relay edge (P5/relay), which sets the signed cookie; on 'ok' the app
routes to the terminal view. This is the base-app-never-had auth (EXPLORE §5) and is replaced by
T7 in v0.10 (kept behind the same route so the swap is contained).
TDD: RED test (mounts into jsdom, mocked fetch) → GREEN.
Test cases: correct token → 'ok' + cookie request issued; wrong token → 'rejected', no route
change, no token echoed to the DOM; XSS: an error string is set via textContent only (never
innerHTML); empty input → disabled submit (fail-fast).
Security notes: password is sent once over TLS to the relay, never stored in localStorage;
error UI is textContent-only; no console.log of the token (INV9 spirit — no secret logged).
T4 — Terminal view + passthrough transport (v0.8, W1)
Owns: relay-web/src/terminal-view.ts, relay-web/src/ws-transport.ts,
relay-web/test/terminal-view.test.ts, relay-web/test/ws-transport.test.ts.
Contract — the abstraction seam that lets E2E "drop in" (INDEX §1):
export interface TerminalTransport {
open(): Promise<void>
send(bytes: Uint8Array): void // plaintext in the browser; encoded by the impl
onMessage(cb: (bytes: Uint8Array) => void): void
onClose(cb: (reason: string) => void): void
close(): void
}
// v0.8: no crypto, NO capability token (INDEX §1) — auth is the T3 signed cookie sent
// automatically with the same-origin upgrade. `capabilityToken` is OPTIONAL and absent in v0.8;
// from v0.9 it is supplied and attached via the Sec-WebSocket-Protocol header (see below).
export interface PassthroughOpts { readonly capabilityToken?: string } // v0.9+ populates this
export function createPassthroughTransport(cfg: RelayWebConfig, opts?: PassthroughOpts): TerminalTransport
// Mounts xterm.js + FitAddon; wires keypress→onData→transport.send and transport.onMessage→xterm.write.
export function mountTerminalView(root: HTMLElement, transport: TerminalTransport): { dispose(): void }
mountTerminalView reproduces the base byte semantics (Enter → \r 0x0D, resize as its own frame
triggering TIOCSWINSZ, fit() only after the container has real dimensions — Gotchas) but against
the abstract TerminalTransport, so public/** is never touched.
Token-attachment mechanism (v0.9+, avoids bearer-credential leakage) — cite §4.3 verbatim, no local
format: the capability token (§4.3) is NEVER placed in the WS upgrade URL/query string (query
strings leak into proxy/access logs, browser history, and Referer; query-string transport is
forbidden by §4.3). It is attached as a Sec-WebSocket-Protocol subprotocol value on the upgrade
using the frozen §4.3 wire format — the client opens verbatim:
// §4.3 frozen constants: APP_SUBPROTOCOL='term.relay.v1', TOKEN_SUBPROTOCOL_PREFIX='term.token.'
new WebSocket(url, ['term.relay.v1', 'term.token.' + base64url(capabilityToken)])
// == new WebSocket(url, [APP_SUBPROTOCOL, encodeTokenSubprotocol(capabilityToken)])
i.e. the app subprotocol first, then the token entry = 'term.token.' + base64url(token) (P6
imports encodeTokenSubprotocol from relay-contracts rather than hand-rolling the prefix/encoding).
The relay edge (P5) reads the token from the term.token.<b64u> entry via extractTokenFromSubprotocols,
strips it before verify (§4.3), and — per the frozen echo rule — the accepted/echoed
Sec-WebSocket-Protocol MUST be term.relay.v1 (the app subprotocol), NEVER the token entry.
After the socket opens, the client asserts ws.protocol === 'term.relay.v1' and tears the socket down
otherwise (a relay that echoes the token entry, or no/foreign subprotocol, is rejected). In v0.8 no
token is attached at all — the same-origin signed cookie (T3) authenticates the upgrade, and the
base-app Origin/CSWSH check (M6) is retained. Origin stays same-origin regardless.
TDD:
- RED
ws-transport.test.ts— passthrough opens same-originwss:(v0.8: relies on the cookie, no token), forwards bytes round-trip (mockedWebSocket) → fails. - RED
terminal-view.test.ts(jsdom + mocked xterm) — keypress →transport.send; incoming bytes →xterm.write;resizeemits a resize frame → fails. - GREEN implement both.
Test cases (incl. security):
- v0.8: upgrade URL is same-origin scheme-following and carries no query-string token; the transport opens on the cookie alone (INDEX §1 "no capability tokens yet").
- v0.9+ token attachment (§4.3 frozen format): when
capabilityTokenis supplied the mockedWebSocketprotocols arg is asserted to equal['term.relay.v1', 'term.token.' + base64url(capabilityToken)]verbatim (app subprotocol first, token entry =encodeTokenSubprotocol(token)); it appears only in thatSec-WebSocket-Protocolupgrade arg, never in the URL/query (regression guard against bearer-credential leakage into logs/history/Referer); the mocked upgrade URL is asserted token-free. - v0.9+ echo rule (§4.3): after
open, if the mockedws.protocol === 'term.relay.v1'the transport proceeds; if it is the token entry, empty, or any foreign value → the transport tears the socket down (onClose), never proceeds — a structural assertion the client accepts only the app subprotocol back. - Enter emits
\rnot\n; resize is a distinct frame (base gotcha). fit()is not called while the container isdisplay:none(guards NaN dims).- Security/negative: a server
exit/close reason surfaces viaonClose(no silent swallow); attaching to ahost_idthe account can't reach → server 403 on upgrade →onClose('forbidden')rendered (INV1/INV6 respected, tested as a client tripwire mirror of P5's server tripwire).
Security notes: In v0.8 the transport authenticates with the T3 signed cookie only — there is
no capability token yet (INDEX §1), so INV15's client half is not an owned assertion here; it
lands in v0.9 when P5 issuance exists and the token is attached via Sec-WebSocket-Protocol (never
the URL, so it stays out of access logs/history/Referer). INV2 is likewise not active in v0.8
(plaintext passthrough is the acknowledged v0.8 shortcut). The seam guarantees T8 replaces the
transport with zero change to terminal-view.ts.
T5 — Dashboard: host status (v0.9, W2)
Owns: relay-web/src/dashboard.ts, relay-web/public/dashboard.html,
relay-web/test/dashboard.test.ts.
Contract:
export interface Dashboard { refresh(): Promise<void>; dispose(): void }
export function mountDashboard(root: HTMLElement, api: ApiClient, opts?: { pollMs?: number }): Dashboard
Renders api.listHosts() as cards showing subdomain, ● status dot colored by HostStatus
('online'|'offline'|'draining'|'revoked' — §4.2 string-literal union), last_seen, and an "open"
action that routes to the terminal view for that host_id. Polls every pollMs (default 5000) for
live ●online flips.
TDD: RED (jsdom, mocked ApiClient) → GREEN.
Test cases (incl. negative): three hosts render three cards with correct status classes;
'revoked' host shows a disabled/blocked state and no open action (INV12 respected in UI);
listHosts rejection → an error banner (textContent), poll continues; an unknown status string
(schema drift) → Zod parse error surfaces, never renders an undefined dot; no account_id appears in
any request the dashboard issues (INV3).
Security notes: the UI reflects server-authorized hosts only; it cannot enumerate or request a
foreign host_id (no such input path). A revoked host is non-interactive client-side, but the real
gate is server-side (INV12) — the UI is defense-in-depth, not the enforcement.
T6 — Add-machine onboarding (pairing) (v0.9, W2)
Owns: relay-web/src/add-machine.ts, relay-web/public/pair.html,
relay-web/test/add-machine.test.ts.
Contract:
export interface AddMachine { start(): Promise<void>; dispose(): void }
export function mountAddMachine(
root: HTMLElement, api: ApiClient,
opts?: { pollMs?: number; onPaired?: (hostId: string) => void },
): AddMachine
Flow (§4.5 from the browser's side): start() calls api.requestPairingCode() (P3 ISSUE), displays
the copy-paste command npx web-terminal-agent pair <code> and a countdown to expiresAt, then
polls api.hostStatus / api.listHosts until the new host flips ●online → calls onPaired.
KPI: first-shell-in-under-2-minutes (EXPLORE §6) — the UI is the make-or-break onboarding.
TDD: RED (jsdom, mocked ApiClient returning a code then an online host) → GREEN.
Test cases (incl. security/negative): happy path — code shown, poll detects online → onPaired
fires once; the raw pairing code is single-use and short-TTL — after expiresAt the UI shows
"expired, generate a new code" and stops polling (mirrors §4.5 single-use / short-TTL; the client
never re-displays a redeemed code); code render is textContent-only (no injection); a requestPairingCode
failure surfaces an error, no infinite poll.
Security notes: the pairing code is a single-use, short-TTL capability (§4.5, INV5 — raw code never stored server-side); the browser treats it as transient display state, never persists it. The Ed25519 keypair is generated on the host by P2 (INV4) — the browser never sees a private key.
T7 — Passkey / WebAuthn login + step-up (v0.10, W3)
Owns: relay-web/src/login-passkey.ts, relay-web/src/webauthn.ts,
relay-web/test/login-passkey.test.ts, relay-web/test/webauthn.test.ts.
Contract:
export interface WebAuthnClient {
register(challenge: PublicKeyCredentialCreationOptions): Promise<RegistrationResult>
authenticate(challenge: PublicKeyCredentialRequestOptions): Promise<AuthenticationResult>
}
export function createWebAuthnClient(creds?: CredentialsContainer): WebAuthnClient // navigator.credentials
export interface PasskeyLogin {
login(): Promise<'ok' | 'rejected'>
stepUp(hostId: string): Promise<'ok' | 'rejected'> // BEFORE opening a session (EXPLORE §4e / §0-5)
}
export function mountPasskeyLogin(root: HTMLElement, api: ApiClient, wa: WebAuthnClient): PasskeyLogin
login(): api fetches a challenge from P5 → wa.authenticate → posts the assertion to P5 (which
sets the session). stepUp(hostId) runs a fresh WebAuthn assertion immediately before
issueCapabilityToken — step-up before opening a session, not just at login. The base64url ↔
ArrayBuffer plumbing and error mapping live in webauthn.ts (kept <200 lines, pure-ish).
TDD: RED (webauthn.test.ts with a mocked CredentialsContainer; login-passkey.test.ts with a
mocked ApiClient + WebAuthnClient) → GREEN.
Test cases (incl. security/negative):
- Successful authenticate → assertion posted →
'ok'; user cancels the passkey prompt (NotAllowedError) →'rejected', no session, no throw leak. - No SMS/OTP path exists in the module surface (INDEX/EXPLORE §0-5: never SMS) — a structural test asserts no phone/SMS field.
stepUpruns a new ceremony each time (test assertsauthenticateis called again, not cached) — a stolen cookie alone cannot open a session (INV15 step-up).- base64url round-trip (challenge encode/decode) is lossless; a malformed server challenge → typed error, not a crashed ceremony.
- Security: the assertion is posted to a same-origin P5 endpoint; the WebAuthn
rpIdis the stable account-level domain resolved per §8 Open Question #5 (NOT the per-host subdomain) so one passkey authenticates across all of an account's host subdomains — a structural test assertsrpIdis read from that resolved value, never hard-wired tolocation.hostname. No credential material is logged (INV9).
Security notes: Passkey is primary (phishing-resistant); the account is established entirely
server-side from the assertion (INV3). Step-up-before-session is the EXPLORE §4e "HIGH" control moved
into the client flow. BLOCKED until §8 Open Question #5 (rpId scope) is resolved with P3/P5 —
because hosts.subdomain is UNIQUE per host (§4.2) and one account owns many host subdomains, an
rpId set to a per-host hostname would make a passkey registered on host A's subdomain fail
navigator.credentials.get() on host B's subdomain. T7 MUST NOT be implemented against a per-host
rpId; it consumes the resolved account-level rpId (and the matching T3 cookie domain scope) instead.
T8 — Browser-side E2E transport (consumes frozen §4.4 E2ESession/DirectionalKeys) (v0.10, W3)
Owns: relay-web/src/e2e-socket.ts, relay-web/src/host-pin-store.ts,
relay-web/test/e2e-socket.test.ts, relay-web/test/host-pin-store.test.ts.
Contract — implements the SAME TerminalTransport (T4) so terminal-view.ts is unchanged:
// Imports the FROZEN §4.4 surface from relay-contracts / P4 relay-e2e — cited VERBATIM, NEVER
// re-implemented here, and NOT a single sessionKey via raw SubtleCrypto AES-GCM:
// buildClientHandshake(ctx: AuthorizedDeviceContext, hostId, aeadOffer) -> ClientHandshake
// ClientHandshake.start(): Promise<ClientHello>
// ClientHandshake.onHostHello(msg, agentPubkey): Promise<HandshakeResult> // { keys: DirectionalKeys, aead, transcript }
// createE2ESession(role: 'client', result: HandshakeResult) -> E2ESession // holds BOTH c2h/h2c subkeys + seq guards
// E2ESession.seal(plaintext) / .open(dataPayload) / .rederive(next) // wrap sealFrame/openFrame w/ direction subkey
// AeadKey (opaque, NOT CryptoKey); DirectionalKeys { c2h, h2c }; sealFrame/openFrame are SYNCHRONOUS.
// P6 supplies the AuthorizedDeviceContext { deviceAuthProofProvider (P5), pinStore (P6), hostContentSecret (§4.5) }
// and drives the handshake THROUGH the relay as opaque DATA; it derives no key material itself.
// HostPinStore is a CACHE/AUDIT TRAIL of the API-sourced fingerprint — NOT an independent trust root.
// It never establishes trust from a relay-forwarded handshake; `record` only ever stores the value the
// caller already verified against the authenticated HostRecord.enrollFpr.
export interface HostPinStore {
get(hostId: string): string | null // last cached API-sourced enroll_fpr (§4.2) or null
record(hostId: string, apiSourcedFpr: string): void // cache the API-verified fpr (audit/drift detection)
}
export function createHostPinStore(storage?: Storage): HostPinStore
export function createE2ETransport(
cfg: RelayWebConfig,
capabilityToken: string, // §4.3; attached via Sec-WebSocket-Protocol, never the URL (see T4)
hostId: string,
expectedFpr: string, // AUTHORITATIVE HostRecord.enrollFpr from api.getHost(hostId) (T2) — the trust root
pins: HostPinStore, // cache/audit only
onPinMismatch: (expected: string, offered: string, source: 'api' | 'cache') => Promise<'trust' | 'abort'>,
): TerminalTransport
Trust root (fixes the first-connect TOFU gap): expectedFpr is sourced by the caller from
api.getHost(hostId).enrollFpr (T2) — the P5-authenticated HTTPS control-plane record, a channel
out-of-band of the relay. It is established server-side at pairing time (§4.5 BIND, TLS direct to
the control plane) and is present on HostRecord (§4.2). The relay-forwarded host_hello is never
allowed to bootstrap trust on its own.
Flow: open same-origin wss: (capability token via Sec-WebSocket-Protocol, INV15) → run the §4.4
handshake through the relay as opaque DATA (client_hello → host_hello) → derive the
fingerprint of host_hello.sig's signing key and compare it against expectedFpr (the API-sourced
value) — on ANY disagreement, INCLUDING THE VERY FIRST CONNECTION, fire onPinMismatch(expected, offered, 'api') whose default is 'abort'; the handshake never completes and no session key material is
derived. This catches a malicious/compromised relay that forges a signed host_hello on a device's
first connect (the default path for every newly paired host and every newly added device) — its
fingerprint cannot match the API-sourced enrollFpr without the host's Ed25519 private key (INV4). On
match, pins.record(hostId, expectedFpr) caches it (audit/drift trail); if the cache later disagrees
with a fresh expectedFpr, onPinMismatch(…, 'cache') surfaces the rotation for review — but the
API value, not the cache, is authoritative. → ClientHandshake.onHostHello(hostHello, agentPubkey)
returns a HandshakeResult { keys: DirectionalKeys{c2h,h2c}, aead, transcript }; P6 builds
session = createE2ESession('client', result) → thereafter send = session.seal(plaintext) (writes
under c2h, next send seq, sealFrame) and onMessage = session.open(dataPayload) (reads under
h2c, openFrame with the session's independent monotonic per-direction expectedSeq via its
internal SequenceGuard). On refresh/reconnect P6 calls session.rederive(nextResult) (a fresh
ephemeral handshake — see §8 Q#2) to install new DirectionalKeys and reset the seq guards (forward
secret); the recoverable replay path (T9) is separate. Any open/openFrame throw (AEAD-tag failure,
seq violation, or direction-confusion, INV13) tears the socket down — never render or swallow a bad frame.
Crypto engine (constraints to bake in) — owned by P4, consumed here: the X25519 ECDH, the
HKDF(sharedSecret, salt=clientNonce‖hostNonce, info="relay-e2e/v1") master derivation, and the
direction-split HKDF-Expand(master, "relay-e2e/v1/c2h" | "…/h2c") into DirectionalKeys{c2h,h2c}
all live inside P4's buildClientHandshake/ClientHandshake (§4.4) — P6 does not call
crypto.subtle.deriveBits or hand-roll HKDF. The frozen keys are AeadKey opaque wrappers
(NOT CryptoKey) and sealFrame/openFrame are synchronous (@noble/ciphers, isomorphic —
this is why WebCrypto's lack of xchacha20-poly1305 no longer forces the algorithm). P6's only crypto
input is the aeadOffer: readonly AeadAlg[] it passes to buildClientHandshake; it lists
aes-256-gcm first (fast native-adjacent path; final choice is the host's aeadChoice, per §8 Q#6).
Because AeadKey/DirectionalKeys are opaque module-held values that P6 never serializes, no key or
plaintext is ever written to localStorage/console (XSS cannot exfiltrate a value the module never
exposes) — the extractable:false CryptoKey guarantee of the old single-sessionKey shape is
preserved by construction.
TDD:
- RED
host-pin-store.test.ts—record/getcache round-trip + drift-vs-cache detection (the store never creates trust, only caches an already-verified value) → fails. - RED
e2e-socket.test.ts— with a loopback P4 pair (browser handshake state ↔ a test agent stub using the samerelay-e2ecore): round-trip seal/open; tampered ciphertext → tear-down; replayed frame → tear-down; reorderedseq→ tear-down; reflected/direction-confused frame → rejected; first-connect relay-forgedhost_hellowhose fingerprint ≠ APIexpectedFpr→ abort → fails. - GREEN implement.
Test cases (incl. security/negative — the crux):
- INV2: a known plaintext marker typed into
sendnever appears on the wire un-AEAD'd (the mocked socket sees onlyE2EEnvelopebytes). - INV13 replay: capture an
E2EEnvelope, re-inject →openFramethrows → transport closes; the marker is not re-rendered. - INV13 reorder: deliver
seq=5beforeseq=4→ rejected. - INV13 tamper: flip one ciphertext byte → AEAD tag fails → tear-down.
- INV13 reflection / direction-confusion (crux): capture a genuine browser→host envelope with
seq=Nand re-inject it into the browser's incoming path at the point its independentexpectedSeqfor host→browser also equalsN→ rejected. This confirms direction is cryptographically bound (per §8 Open Question #4 — direction-scoped HKDFinfo/ AAD), not merely counted; a same-key same-AAD reflection MUST NOT verify. - First-connect MITM (crux — the trust-root test): the relay presents a validly Ed25519-signed
host_hellowhose fingerprint ≠ the API-sourcedexpectedFpr(api.getHost(hostId).enrollFpr), with no prior cache entry →onPinMismatch(…, 'api')→ default'abort'→ handshake never completes, no session key derived, nothing is ever recorded. Asserts the browser NEVER blind-trusts a relay-forwarded firsthost_hello. - Happy path caches from API:
host_hellofingerprint ==expectedFpr→ handshake completes andpins.record(hostId, expectedFpr)stores the API-sourced value (audit trail), not a handshake-derived one. - Cache drift: a later
expectedFprdiffering from the cached value →onPinMismatch(…, 'cache')surfaces the rotation; the API value remains authoritative (cache is advisory). - Key non-exposure: the
DirectionalKeys/AeadKeyheld by theE2ESessionare opaque ({ __aeadKey }, NOT aCryptoKey) and never serialize to a readable form; a structural test asserts no key bytes reachlocalStorage/console/any outbound frame (the opaque-wrapper analog of the oldextractable:falseguarantee). - Same seam:
terminal-view.tsmounts identically onE2ETransportvsPassthroughTransport(proves the drop-in).
Security notes: this task is where INV2/INV13/§4.4-TOFU become real in the browser — the only
plaintext endpoint on the phone side. The relay/agent stay ciphertext-shuttles (INV2/INV11) because
the browser hands them only E2EEnvelopes. The anti-MITM root of trust is the API-sourced
HostRecord.enrollFpr (§4.2) fetched over the P5-authenticated, out-of-band-of-the-relay HTTPS
channel — NOT the relay-forwarded host_hello. The relay forwards the handshake but cannot forge
sig to match enrollFpr without the host's Ed25519 private key (INV4), so even a device's first
connect to a host is protected: there is no blind trust-on-first-use of a relay-supplied fingerprint.
HostPinStore is a cache/audit trail of that authenticated value. Direction-confusion resistance
(reflection test above) is now guaranteed by construction: §4.4 is frozen direction-split —
DirectionalKeys{c2h,h2c} with aad = directionLabel‖seq, so a c2h frame reflected as h2c uses a
different subkey and its AEAD tag cannot verify (FIX 2; §8 Q#4 RESOLVED, no longer a P6 blocker).
T9 — Client-side preview rendering (v0.10, W4)
Owns: relay-web/src/preview-client.ts, relay-web/src/preview-grid.ts,
relay-web/public/manage.html, relay-web/test/preview-client.test.ts,
relay-web/test/preview-grid.test.ts.
Why it exists: under E2E the relay cannot render a screen it cannot read (EXPLORE §0 accepted
consequence, §4c "biggest product hit"). Server-side preview thumbnails / manage-grid die; the
authorized, key-holding browser decrypts a ciphertext replay and renders a read-only
xterm. Ring-buffer replay survives a reload because the relay stores/forwards ciphertext (INDEX §1
v0.10) that the agent (P2) sealed with sealReplayFrame under the recoverable content key
K_content — NOT the ephemeral live DirectionalKeys (which are re-derived per handshake and
lost on reload). Per frozen §4.4 (FIX 3), the browser re-derives the same K_content via
deriveContentKey({ hostContentSecret, sessionId, alg }) — where hostContentSecret is the §4.5
host-scoped secret obtained (post auth/step-up) via P5, the input to ReplayKeyParams — and decrypts
each stored payload with openReplayCiphertext(kContent, dataPayload). This is why replay survives
where the ephemeral subkeys cannot (forward-secret live keys are gone after reload).
Contract:
// Imports the FROZEN §4.4 replay surface (FIX 3) from relay-contracts / P4 relay-e2e — cited verbatim:
// deriveContentKey(p: ReplayKeyParams): AeadKey // HKDF(hostContentSecret, salt=sessionId, info='relay-e2e/replay/v1')
// openReplayCiphertext(k: AeadKey, dataPayload): Uint8Array // decrypt ONE stored ciphertext payload
// K_content is the RECOVERABLE key (host-scoped hostContentSecret, §4.5, via P5) — NOT the ephemeral
// live DirectionalKeys and NOT E2ESession.open/openFrame (those are lost on reload).
export interface ReplaySource { // ciphertext ring-buffer replay for one host/session
readonly sessionId: string
readonly alg: AeadAlg // negotiated aead (matches how P2 sealed the replay)
readonly frames: readonly Uint8Array[] // stored DATA payloads, each a sealReplayFrame envelope
}
export interface PreviewClient { render(): Promise<void>; dispose(): void }
// Derives K_content = deriveContentKey({ hostContentSecret, sessionId, alg }) ONCE, then decrypts each
// frame with openReplayCiphertext(kContent, frame) and writes into a READ-ONLY headless xterm (no input path).
export function mountPreviewClient(
card: HTMLElement,
replay: ReplaySource,
hostContentSecret: Uint8Array, // §4.5, obtained via P5 after auth/step-up; NEVER logged/persisted
dims: { cols: number; rows: number },
): PreviewClient
export function mountPreviewGrid(
root: HTMLElement, api: ApiClient,
loadReplay: (hostId: string) => Promise<{ replay: ReplaySource; hostContentSecret: Uint8Array }>,
): { dispose(): void }
TDD: RED (jsdom + mocked read-only xterm + a ReplaySource of sealReplayFrame-sealed ciphertext
frames + a hostContentSecret, decrypted via deriveContentKey/openReplayCiphertext) → GREEN.
Test cases (incl. security):
- Recoverable-key decrypt (FIX 3 crux): frames sealed by a P2 stub with
sealReplayFrame(kContent, seq, plaintext)are decrypted byopenReplayCiphertext(deriveContentKey({ hostContentSecret, sessionId, alg }), frame)and written to the preview xterm; the rendered cells match the source screen.K_contentis re-derived fresh fromhostContentSecret(no in-memory ephemeral key), proving reload-survival. - Wrong / ephemeral key rejected: a replay decrypt attempted with the ephemeral live
DirectionalKeys(or a host-mismatchedhostContentSecret) →openReplayCiphertextthrows (AEAD tag fails) → the card shows "unavailable", never a torn/garbled screen — confirms replay is bound toK_content, not the live key (cross-host/cross-session isolation, INV1). - Read-only: the preview xterm has no input wiring — a keypress on the card sends nothing (structural assertion: no
seal/send path exists from a preview). - INV2: the preview never receives plaintext from the server — it decrypts
K_content-sealed ciphertext locally; the mocked source carries onlyE2EEnvelopepayloads. - A host the account can't reach →
loadReplayrejects /onClose('forbidden')→ the card shows "unavailable", never another tenant's screen (INV1). - Grid disposes all preview clients on unmount (no leaked xterms/sockets — EXPLORE §6 preview-cost discipline; pause when unfocused is a P3/P1 concern but the client must not hold dead resources), and
hostContentSecretis never written tolocalStorage/console.
Security notes: client-side preview is the only way previews exist post-E2E; it inherits INV2
(decrypt-locally) and INV1 (only authorized hosts). Decryption uses the recoverable K_content
(deriveContentKey over the §4.5 host-scoped hostContentSecret, obtained via P5 only after
auth/step-up), never the ephemeral live keys — so a reload re-derives the key and replay survives,
while a revoked device whose hostContentSecret wrap no longer unwraps (INV12) can derive no key and
sees nothing. hostContentSecret/K_content are transient in memory, never persisted or logged.
Read-only means the manage grid cannot become a covert input channel.
5. Security section (per-plan summary)
- INV3 (owned): the client has no code path that sends
account_id/tenant_idas truth (T2 request builders lack the parameter; identity is the passkey/cookie session). Tested inapi-client.test.tsas a structural assertion. - INV15 (owned, v0.9+): from v0.9 every terminal WS upgrade carries a §4.3 capability token
attached via the
Sec-WebSocket-Protocolheader (never the URL/query string — avoids leaking the bearer credential into access logs, browser history, andReferer); T4 (v0.9) and T8 (v0.10) present it. v0.8 has no token (INDEX §1) — the T3 signed cookie authenticates the upgrade. Origin stays same-origin so the base-app CSWSH check is retained AND augmented (M6). Step-up (T7) runs before opening a session, not only at login. - INV2 / INV13 (consumed, defended): plaintext exists only inside the browser boundary; the
socket carries
E2EEnvelopes (T8).openFramethrows (tamper/replay/reorder) tear the socket down — no swallowed crypto error (coding-style.mderror handling). - §4.4 TOFU / INV4 (defended): the browser's trust root is the API-sourced
HostRecord.enrollFpr(api.getHost, over the P5-authenticated channel out-of-band of the relay), not the relay-forwardedhost_hello. It verifies the handshake fingerprint against that value and aborts on any mismatch, including the first connection — closing the first-connect MITM gap a malicious relay would otherwise exploit.HostPinStoreis only a cache/audit trail of the authenticated value. - No secret at rest / no secret logged (INV5/INV9 spirit): the E2E keys are opaque
AeadKey/DirectionalKeysheld only inside theE2ESession(never serialized — the opaque-wrapper analog of the oldextractable:falseCryptoKey); no key,hostContentSecret/K_content, passkey material, password, or plaintext is written tolocalStorage/console. Only the hostenrollFpr(a public fingerprint) and non-sensitive UI prefs persist. - XSS discipline: all dynamic text via
textContent; noinnerHTMLwith server/user data; static HTML pages document a strict CSP (no inline script) — the terminal payload is untrusted bytes rendered by xterm, never HTML. - No
console.log, no mutation: immutable snapshots for view state (config/records arereadonly), early returns over deep nesting, functions <50 lines, files 200–400 (800 hard max).
6. Verification
# In relay-web/ (new package — base `npm test` and src/ are untouched)
npx vitest run config # T1 subdomain + scheme-following URL (M6)
npx vitest run api-client # T2 Zod validation + INV3 (no account_id sent)
npx vitest run login-password # T3 v0.8 gate
npx vitest run ws-transport terminal-view # T4 passthrough + xterm byte semantics; v0.8 cookie-only (no token), v0.9 token via Sec-WebSocket-Protocol (never URL)
npx vitest run dashboard # T5 host ●status, revoked non-interactive (INV12 UI)
npx vitest run add-machine # T6 pairing code single-use/short-TTL UX (§4.5)
npx vitest run login-passkey webauthn # T7 passkey + step-up, no-SMS structural check
npx vitest run e2e-socket host-pin-store # T8 INV2/INV13/TOFU: tamper/replay/reorder/reflection + first-connect fpr≠API → abort/tear-down
npx vitest run preview-client preview-grid # T9 client-side replay decrypt via openReplayCiphertext/deriveContentKey (K_content, not live key) + read-only render (INV2/INV1)
npx vitest run --coverage # 80%+ (statements/branches/functions/lines) per testing.md
npm run typecheck # relay-web tsconfig; base src/types.ts NOT imported/edited
node build.mjs # esbuild bundles all entry pages
git -C .. diff --quiet -- public src protocol.ts # PROVE base app untouched (must exit 0)
Cross-plan integration gates (run once P1/P3/P4/P5 land):
- Against a live P1 relay + P2 agent: open
alice.term.<domain>→ passkey login (P5) → step-up → E2E handshake completes, fingerprint pins, keystrokes round-trip,topredraws (resize frame). - Isolation tripwire (mirror of P5's server test): authenticated as account A, drive the client to
request a
host_idowned by B → server 403 → client renders "forbidden", never a foreign screen (INV1). This is the browser-side witness of the permanent CI tripwire P5 owns.
7. Phasing recap (INDEX §1)
| Phase | P6 tasks | Deliverable |
|---|---|---|
| v0.8 MVP | T1, T2 (basic: listHosts/getHost/requestPairingCode/hostStatus, no issueCapabilityToken), T3, T4 |
Password gate + subdomain connect + xterm view over a passthrough transport (no E2E, no capability token — cookie auth only, INDEX §1). Proves the café demo from the browser. |
| v0.9 SaaS | T5, T6 (+ T2 grows) | Dashboard host ●online, add-machine onboarding (pairing-code UX), self-serve. |
| v0.10 E2E-hardening | T7, T8, T9 | Passkey + step-up; browser-side E2E consuming the frozen §4.4 buildClientHandshake/E2ESession/DirectionalKeys (not a raw-SubtleCrypto single key) (the transport swap, zero change to the view); client-side preview rendering via openReplayCiphertext/deriveContentKey (server previews die). |
8. Open questions (for the orchestrator to resolve — do NOT guess, G1 BLOCKED-style)
- [RESOLVED — FIX 2, INDEX §4.4]
P4 handshake driver surface.The frozen §4.4 now exports the driver verbatim:buildClientHandshake(ctx: AuthorizedDeviceContext, hostId, aeadOffer): ClientHandshake(P4 T11) whoseClientHandshake(P4 T8createClientHandshake) hasstart(): Promise<ClientHello>andonHostHello(msg, agentPubkey): Promise<HandshakeResult>, followed bycreateE2ESession('client', result): E2ESession(P4 T9). T8 imports these verbatim — there is noCryptoKey/sessionKeyreturn and no local handshake code (the "no local crypto" fence holds by construction). - [RESOLVED — FIX 3, INDEX §4.4/§4.5]
Session-key recovery on refresh.The answer is both, split by path: live frames use ephemeralDirectionalKeysthat the browser re-derives with a fresh handshake on reload/reconnect viaE2ESession.rederive(next)(P4 T9, forward-secret); replay/preview frames use a recoverable, stable content keyK_content = deriveContentKey({ hostContentSecret, sessionId, alg })(P4 T10) sealed by P2 withsealReplayFrameand opened by T9 withopenReplayCiphertext.hostContentSecretis the §4.5 host-scoped secret delivered wrapped-to-agent (P3 at BIND) and to browser devices via P5 after auth/step-up — not an account-wide raw secret. T8 (live) and T9 (replay) now have unambiguous, disjoint key paths. - Capability-token issuance trigger. Does P5 issue the terminal token at login, or per-open
(recommended, pairs with step-up T7)? T4 (v0.9+)/T8 assume per-open (
issueCapabilityToken(hostId, rights)right before upgrade; not present in v0.8). Confirm with P5. - [RESOLVED — FIX 2, INDEX §4.4]
Direction-binding of§4.4 is now frozen direction-split: the master secret is HKDF-Expanded into two disjoint subkeys with direction-scopedsessionKey/nonce/AAD (BLOCKING for T8 — anti-reflection).info—DirectionalKeys{ c2h, h2c }(HKDF_INFO_C2H = 'relay-e2e/v1/c2h',HKDF_INFO_H2C = 'relay-e2e/v1/h2c', P4 T6deriveSessionKeys) — and theE2ESession(P4 T9) seals with the write subkey / opens with the read subkey, withaad = directionLabel‖seq. A reflected c2h frame therefore verifies underh2conly if its tag matches a different key — it cannot. T8 still ships the reflection/direction-confusion test, now as a regression witness for a guarantee that is met by construction rather than a blocking upstream confirmation. - WebAuthn
rpIdscope + cookie domain (BLOCKING for T7).hosts.subdomainis UNIQUE per host (§4.2) and one account owns many host subdomains (alice.term.…,alice2.term.…). A per-hostrpIdwould make a passkey registered on host A's subdomain failnavigator.credentials.get()on host B's subdomain (WebAuthn credentials arerpId-scoped) — forcing re-registration per host. Resolve with P3/P5 before T7: either (a) authentication happens on a dedicated account-level domain (app.<domain>or<account>.<domain>) withrpIdset to that stable parent, and the terminal/dashboard subdomains only consume the resulting session/capability token; or (b)rpIdis deliberately the registrable parent domain (term.<domain>) so one passkey spans all of an account's host subdomains — with the explicitly-accepted tradeoff that all tenant subdomains become a single WebAuthn security boundary. The choice also fixes the T3 session-cookie domain scope and the cross-subdomain CSRF/cookie posture. T7 MUST consume the resolvedrpId, neverlocation.hostname. xchacha20-poly1305in-browser. Not native to WebCrypto; T8 offersaes-256-gcmfirst. Confirm P4/P2 accept AES-GCM as the browser-negotiated default (or that P4 ships a WASM XChaCha) — affectsaeadOfferordering only, not the envelope.