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.
697 lines
53 KiB
Markdown
697 lines
53 KiB
Markdown
# 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 (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<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 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<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 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.<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.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.<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.
|