Files
web-terminal/docs/PLAN_RELAY_FRONTEND.md
Yaojia Wang 2af57e6686 feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)
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.
2026-07-02 06:10:16 +02:00

697 lines
53 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# P6 — Browser / Frontend (`relay-web/`) — Implementation Plan
> **Charter**: [`PLAN_RELAY_INDEX.md`](./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-following `wss:`, M6), **browser-side E2E by consuming the frozen §4.4 primitives from P4 `relay-e2e`
> (`buildClientHandshake` → `E2ESession`, `sealFrame`/`openFrame` over an opaque `AeadKey` / direction-split
> `DirectionalKeys`, `@noble/ciphers`) — NOT a single `sessionKey` via 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 `●online`
> status, add-machine flow). **The core xterm byte path in `public/` stays byte-for-byte untouched.**
> Source of intent: [`EXPLORE_RELAY_SERVICE.md`](./EXPLORE_RELAY_SERVICE.md) §0 (LOCKED, CLOSED),
> §4c, §4e, §5. Conventions per [`PLAN.md`](./PLAN.md) / [`PLAN_VOICE_COMMANDS.md`](./PLAN_VOICE_COMMANDS.md):
> stable task IDs → dependency waves, disjoint `Owns:`, function-signature contracts, **TDD (tests
> FIRST)**, explicit test cases (incl. security/negative), per-task security notes. `PROGRESS_LOG.md`
> is **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-`trustOnFirstUse`s 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-Protocol` and 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), plus
`sealFrame`/`openFrame`/`E2ESession`/`DirectionalKeys`/`AeadKey`; the agent side runs the
`host_hello`/`sealFrame` counterpart (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-scoped `hostContentSecret` delivered via P5 (minted+wrapped by P3 at
BIND, FIX 3); the agent (P2) seals replay-bound output with `sealReplayFrame` under the same `K_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 (T1T4) 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:**
```ts
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):**
1. **RED** `config.test.ts``readConfig` derives subdomain + scheme-following URL → fails (module absent).
2. **GREEN** implement `config.ts`; esbuild `build.mjs` bundles `index.html` entry.
**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 → `readConfig` throws a typed error (fail-fast at boundary), never defaults to another tenant's subdomain.
- **Security:** `wsUrl` is always same-origin — a passed `path` cannot inject a foreign host (`wsUrl('//evil.com')` stays on `location.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).
```ts
// 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:**
1. **RED** `api-client.test.ts` with a mocked `fetch` — asserts request bodies contain **no**
`account_id`/`tenant_id`, responses are Zod-validated → fails.
2. **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`) → `ApiError` thrown, not a torn object.
- `getHost('h1')` returns a Zod-validated `HostRecord` whose `enrollFpr` is a non-empty string; missing/empty `enrollFpr``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 calls `issueCapabilityToken` (the method is absent from `ApiClientV08`).
- **Negative:** 401 response → typed `ApiError('unauthenticated')`, surfaces a re-login prompt (no silent swallow).
- **Security:** forging `hostId` the 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:**
```ts
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):**
```ts
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**:
```ts
// §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:**
1. **RED** `ws-transport.test.ts` — passthrough opens same-origin `wss:` (v0.8: relies on the cookie,
no token), forwards bytes round-trip (mocked `WebSocket`) → fails.
2. **RED** `terminal-view.test.ts` (jsdom + mocked xterm) — keypress → `transport.send`; incoming
bytes → `xterm.write`; `resize` emits a resize frame → fails.
3. **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 `capabilityToken` is supplied the mocked `WebSocket` protocols arg is asserted to equal **`['term.relay.v1', 'term.token.' + base64url(capabilityToken)]`** verbatim (app subprotocol first, token entry = `encodeTokenSubprotocol(token)`); it appears **only** in that `Sec-WebSocket-Protocol` upgrade 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 mocked `ws.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 `\r` not `\n`; resize is a distinct frame (base gotcha).
- `fit()` is not called while the container is `display:none` (guards NaN dims).
- **Security/negative:** a server `exit`/close reason surfaces via `onClose` (no silent swallow); attaching to a `host_id` the 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:**
```ts
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:**
```ts
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:**
```ts
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.
- `stepUp` runs a **new** ceremony each time (test asserts `authenticate` is 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 `rpId` is 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 asserts `rpId` is read from that resolved value, never hard-wired to `location.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:**
```ts
// 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:**
1. **RED** `host-pin-store.test.ts` `record`/`get` cache round-trip + drift-vs-cache detection (the
store never *creates* trust, only caches an already-verified value) fails.
2. **RED** `e2e-socket.test.ts` with a **loopback P4 pair** (browser handshake state a test agent
stub using the same `relay-e2e` core): round-trip seal/open; tampered ciphertext tear-down;
replayed frame tear-down; reordered `seq` tear-down; **reflected/direction-confused frame
rejected**; **first-connect relay-forged `host_hello` whose fingerprint API `expectedFpr`
abort** fails.
3. **GREEN** implement.
**Test cases (incl. security/negative — the crux):**
- **INV2:** a known plaintext marker typed into `send` never appears on the wire un-AEAD'd (the mocked socket sees only `E2EEnvelope` bytes).
- **INV13 replay:** capture an `E2EEnvelope`, re-inject `openFrame` throws transport closes; the marker is **not** re-rendered.
- **INV13 reorder:** deliver `seq=5` before `seq=4` rejected.
- **INV13 tamper:** flip one ciphertext byte AEAD tag fails tear-down.
- **INV13 reflection / direction-confusion (crux):** capture a genuine browserhost envelope with `seq=N` and re-inject it into the browser's **incoming** path at the point its independent `expectedSeq` for hostbrowser also equals `N` **rejected**. This confirms direction is cryptographically bound (per §8 Open Question #4 direction-scoped HKDF `info` / 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_hello` whose fingerprint the API-sourced `expectedFpr` (`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 first `host_hello`.
- **Happy path caches from API:** `host_hello` fingerprint == `expectedFpr` handshake completes and `pins.record(hostId, expectedFpr)` stores the API-sourced value (audit trail), not a handshake-derived one.
- **Cache drift:** a later `expectedFpr` differing from the cached value `onPinMismatch(…, 'cache')` surfaces the rotation; the API value remains authoritative (cache is advisory).
- **Key non-exposure:** the `DirectionalKeys`/`AeadKey` held by the `E2ESession` are opaque (`{ __aeadKey }`, NOT a `CryptoKey`) and never serialize to a readable form; a structural test asserts no key bytes reach `localStorage`/`console`/any outbound frame (the opaque-wrapper analog of the old `extractable:false` guarantee).
- **Same seam:** `terminal-view.ts` mounts identically on `E2ETransport` vs `PassthroughTransport` (proves the drop-in).
**Security notes:** this task is where INV2/INV134.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 `E2EEnvelope`s. **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:**
```ts
// 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 by `openReplayCiphertext(deriveContentKey({ hostContentSecret, sessionId, alg }), frame)` and written to the preview xterm; the rendered cells match the source screen. `K_content` is re-derived **fresh** from `hostContentSecret` (no in-memory ephemeral key), proving reload-survival.
- **Wrong / ephemeral key rejected:** a replay decrypt attempted with the ephemeral live `DirectionalKeys` (or a host-mismatched `hostContentSecret`) `openReplayCiphertext` throws (AEAD tag fails) the card shows "unavailable", never a torn/garbled screen confirms replay is bound to `K_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 only `E2EEnvelope` payloads.
- A host the account can't reach `loadReplay` rejects / `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 `hostContentSecret` is never written to `localStorage`/`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_id` as truth
(T2 request builders lack the parameter; identity is the passkey/cookie session). Tested in
`api-client.test.ts` as 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-Protocol` header (never the URL/query string** avoids leaking the
bearer credential into access logs, browser history, and `Referer`); 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 `E2EEnvelope`s (T8). `openFrame` throws (tamper/replay/reorder) **tear the socket
down** no swallowed crypto error (`coding-style.md` error 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-forwarded
`host_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. `HostPinStore` is 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`/
`DirectionalKeys` held only inside the `E2ESession` (never serialized the opaque-wrapper analog of the
old `extractable:false` `CryptoKey`); no key, `hostContentSecret`/`K_content`, passkey material, password,
or plaintext is written to `localStorage`/`console`. Only the host `enrollFpr` (a public fingerprint) and
non-sensitive UI prefs persist.
- **XSS discipline:** all dynamic text via `textContent`; no `innerHTML` with 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 are `readonly`),
early returns over deep nesting, functions <50 lines, files 200400 (800 hard max).
---
## 6. Verification
```bash
# 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, `top` redraws (resize frame).
- **Isolation tripwire (mirror of P5's server test):** authenticated as account A, drive the client to
request a `host_id` owned 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)
1. **[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**) whose `ClientHandshake` (P4 **T8** `createClientHandshake`) has
**`start(): Promise<ClientHello>`** and **`onHostHello(msg, agentPubkey): Promise<HandshakeResult>`**,
followed by **`createE2ESession('client', result): E2ESession`** (P4 **T9**). T8 imports these
verbatim there is **no** `CryptoKey`/`sessionKey` return and no local handshake code (the "no local
crypto" fence holds by construction).
2. **[RESOLVED FIX 3, INDEX §4.44.5]** ~~Session-key recovery on refresh.~~ The answer is **both,
split by path**: **live** frames use ephemeral `DirectionalKeys` that the browser re-derives with a
**fresh handshake** on reload/reconnect via **`E2ESession.rederive(next)`** (P4 **T9**, forward-secret);
**replay/preview** frames use a **recoverable, stable** content key **`K_content = deriveContentKey({
hostContentSecret, sessionId, alg })`** (P4 **T10**) sealed by P2 with `sealReplayFrame` and opened by
T9 with **`openReplayCiphertext`**. `hostContentSecret` is 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.
3. **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.
4. **[RESOLVED FIX 2, INDEX §4.4]** ~~Direction-binding of `sessionKey`/nonce/AAD (BLOCKING for T8
anti-reflection).~~ §4.4 is now **frozen direction-split**: the master secret is HKDF-Expanded into
**two disjoint subkeys** with direction-scoped `info` **`DirectionalKeys{ c2h, h2c }`** (`HKDF_INFO_C2H
= 'relay-e2e/v1/c2h'`, `HKDF_INFO_H2C = 'relay-e2e/v1/h2c'`, P4 **T6** `deriveSessionKeys`) and the
`E2ESession` (P4 **T9**) seals with the write subkey / opens with the read subkey, with `aad =
directionLabel‖seq`. A reflected c2h frame therefore verifies under `h2c` **only 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.
5. **WebAuthn `rpId` scope + cookie domain (BLOCKING for T7).** `hosts.subdomain` is UNIQUE **per host**
4.2) and one account owns many host subdomains (`alice.term.…`, `alice2.term.…`). A per-host
`rpId` would make a passkey registered on host A's subdomain fail `navigator.credentials.get()` on
host B's subdomain (WebAuthn credentials are `rpId`-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>`) with `rpId` set to that stable parent, and the
terminal/dashboard subdomains only consume the resulting session/capability token; or (b) `rpId` is
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 resolved `rpId`, never `location.hostname`.
6. **`xchacha20-poly1305` in-browser.** Not native to WebCrypto; T8 offers `aes-256-gcm` first. Confirm
P4/P2 accept AES-GCM as the browser-negotiated default (or that P4 ships a WASM XChaCha) affects
`aeadOffer` ordering only, not the envelope.