# 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.`: 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 `, 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 ` 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:** ```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): 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 // account derived server-side (INV3) getHost(hostId: string): Promise // 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 } // 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 // 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 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.` 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; 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; 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 `** 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 authenticate(challenge: PublicKeyCredentialRequestOptions): Promise } 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 // ClientHandshake.onHostHello(msg, agentPubkey): Promise // { 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 browser→host envelope with `seq=N` and re-inject it into the browser's **incoming** path at the point its independent `expectedSeq` for host→browser 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/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 `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; 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 200–400 (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.` → 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`** and **`onHostHello(msg, agentPubkey): Promise`**, 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.4/§4.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.` or `.`) 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.`) 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.