feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)

Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.

Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
  capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay:   native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent:        host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e:    browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth:   Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web:    browser login + Web Crypto E2E + client-side preview rendering

Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.

NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
This commit is contained in:
Yaojia Wang
2026-07-02 06:10:16 +02:00
parent e4c327e25e
commit 2af57e6686
326 changed files with 40877 additions and 0 deletions

606
docs/PLAN_RELAY_INDEX.md Normal file
View File

@@ -0,0 +1,606 @@
# Rendezvous-Relay Service — Plan-of-Plans (INDEX)
> **Product**: a multi-tenant rendezvous-relay SERVICE that bridges external devices to each
> customer's self-hosted web-terminal — an "ngrok-for-Claude-Code" **with end-to-end encryption**.
> **Source of intent**: [`EXPLORE_RELAY_SERVICE.md`](./EXPLORE_RELAY_SERVICE.md) — its **§0 LOCKED
> DECISIONS are AUTHORITATIVE and CLOSED**; do not reopen them in any plan.
> This INDEX is the **coordination point for the 6 implementation plans** — the analog of
> [`src/types.ts`](../src/types.ts) for the base app. It fixes: overview & phasing (§1), the
> cross-plan **dependency DAG** (§2), the numbered **security invariants** (§3), the **FROZEN
> SHARED CONTRACTS** every plan must cite verbatim (§4), and a **per-plan charter** (§5).
> **The 6 plans MUST reference §3 and §4 by name and MUST NOT redefine any contract locally.**
> Conventions follow [`PLAN.md`](./PLAN.md): stable task IDs grouped into dependency waves, an
> `Owns:` disjoint-file list per task, function-signature-level contracts, TDD (tests FIRST),
> explicit test cases, per-task security notes. Progress is logged in
> [`PROGRESS_LOG.md`](./PROGRESS_LOG.md) — **orchestrator-only writer** (subagents return a
> ready-to-paste entry, G1).
---
## 0. Scope boundary — what does NOT change
The existing web-terminal (`src/`) is a **byte-shuttle** and stays **byte-for-byte unchanged**
except **one config touch-point**: `ALLOWED_ORIGINS` gains the assigned subdomain at agent-install
time (EXPLORE §3 "one base-app touch-point"). No new code in `src/`, `public/`, `protocol.ts`, or
the session model. **All new code lives in NEW top-level packages**:
| Package | Owner plan | Role |
|---|---|---|
| `relay-contracts/` | **INDEX-frozen** (§4) — the coordination point, analog of `src/types.ts` | Shared Zod schemas + TS types for every §4 contract. Dependency-free (no `ws`/`pg`/DOM ambient) so agent, relay, control-plane, and browser can all import it. **Frozen here; changed only via this INDEX.** |
| `term-relay/` | P1 (data plane) + P3 (control plane) | Relay nodes (stateless data plane) and the control plane (accounts, routing, pairing, metering). Physically split — EXPLORE §3 "control plane vs data plane". |
| `agent/` | P2 | Host-agent: enrollment, mTLS dial-out, mux tunnel holder, loopback forwarder, E2E endpoint. |
| `relay-e2e/` | P4 | Shared E2E crypto core (handshake state machine, AEAD envelope), imported by `agent/` and the browser bundle. |
| `relay-web/` | P6 | Browser bundle served from the tenant subdomain: login, dashboard, client-side E2E + preview. |
**The upgraded discipline: byte-shuttle → CIPHERTEXT-shuttle.** The relay and the agent move opaque,
authenticated, per-tenant-routed blobs and parse **zero** terminal semantics (INV2, INV11).
---
## 1. Overview & phasing
Three phases, mapped 1:1 to EXPLORE §7. Each plan's tasks are tagged with the phase they land in;
a plan spans phases (e.g. TRANSPORT ships an frp scaffold in v0.8 and the native mux in v0.9).
### v0.8 — MVP single-node relay ("prove the café demo")
The pitch *is* the demo (EXPLORE §5): open `alice.term.<domain>` on a phone, type a token, land in the
laptop's shell — no router/VPN/static-IP configured. **Deliberate shortcuts, retired later:**
- **frp scaffold** as the transport stepping-stone (P1) — `frps` on one VPS + `frpc`-wrapped agent (P2).
- **Flat SQLite account table**, manual provisioning (P3): `{ accountId, subdomain, agentToken(hashed), clientToken(hashed) }`.
- **Shared `clientToken` password gate + signed cookie at the relay edge** (P6) — the auth the base app never had.
- **No E2E, no Passkey, no capability tokens yet** — but the frame path is designed so ciphertext drops in without reshaping it.
- **Plans active**: P1 (frp scaffold + native-mux frame spec written but not yet the substrate), P2 (frpc-wrap + `npx …agent pair` skeleton), P3 (flat table), P6 (password gate). P4/P5 **write their contracts** (so v0.9/v0.10 don't reshape data) but ship no runtime.
### v0.9 — Multi-tenant SaaS (native mux, real accounts, dashboard, metering)
Retire every v0.8 shortcut except plaintext:
- **Native WS mux replaces frp** (P1) — the §4.1 frame format becomes the real substrate; per-stream flow control, heartbeat, reconnection.
- **Ed25519 pairing + mTLS with short-lived rotating certs** (P2 + P3 + P5) — DB stores only public keys (INV4).
- **Postgres control plane** (P3): immutable `accounts`/`hosts`/`sessions`, pairing-code issuance/redemption, per-tenant subdomain assignment, Redis routing table (`host_id → relay_node` heartbeat-TTL), metering hooks (paired hosts + concurrent viewers), graceful relay-node drain.
- **Capability tokens on the WS upgrade + deny-by-default tenant authz + the CI cross-tenant tripwire** (P5).
- **Self-serve dashboard**: host ●online status, add-machine flow, static-binary agent + service install (P6 + P2).
### v0.10 — E2E-hardening (the non-negotiable phase, done BEFORE scaling users)
- **Full browser↔agent E2E** (P4): authenticated X25519 ECDH through the relay, host-key TOFU/pinning, AEAD frames with per-message nonce + monotonic sequence, session-key lifecycle, authenticated multi-device key distribution.
- **Passkey/WebAuthn primary + step-up before opening a session + fast global/per-host revocation + immutable zero-payload audit log** (P5).
- **CLIENT-SIDE preview rendering** (P6) — server previews die under E2E; the key-holding browser decrypts + renders a read-only xterm.
- **Third-party pentest** targeting tenant-crossing and E2E-MITM-by-malicious-relay.
---
## 2. Dependency DAG across the 6 plans
```
relay-contracts/ (§4 FROZEN — this INDEX)
│ every plan imports it read-only
┌──────── P1 TRANSPORT ────────┐ (mux frame + stream lifecycle = the substrate)
│ │
▼ ▼
P2 AGENT ◀────────── mux ──────────▶ P3 CONTROL PLANE
(dial-out, mTLS, holds tunnel, (accounts, routing table, pairing
forwards to :3000, E2E endpoint) issuance, mTLS cert authority, metering)
│ │
└───────────┬──────────────────┘
┌────────── P5 AUTH & ISOLATION ──────────┐ (capability tokens, deny-by-default
│ enforces authz on P1 upgrade + P3 │ authz, mTLS verify, CI tripwire,
│ connect/reattach; cert rotation │ revocation, audit)
│ │
│ P4 E2E ──────────────────────┤ (rides P1 DATA frames as opaque
│ (handshake through relay, AEAD │ ciphertext; keys bound to P2
│ envelope, key lifecycle) │ enrollment pubkey + P5 device auth)
└───────────────────┬──────────────────────┘
P6 FRONTEND
(login/passkey, dashboard, connect via subdomain, browser-side E2E
via SubtleCrypto, CLIENT-SIDE preview render; core xterm byte path untouched)
```
**Ordering rules (what must exist before what):**
1. **`relay-contracts/` (§4) is W0 for the whole program** — frozen before any plan's W1. A plan needing a new shared field changes it *here* (a coordination point), never locally.
2. **P1 TRANSPORT before P2 AGENT & P3 CONTROL PLANE** — both consume the §4.1 mux frame + stream lifecycle. (In v0.8 the frp scaffold lets P2/P3 start against a stub; the native-mux swap in v0.9 is contract-compatible by construction.)
3. **P2 + P3 before P5 AUTH** — capability tokens sign over `account_id`/`host_id` from the P3 registries; mTLS verifies P2's enrolled pubkey.
4. **P2 + P3 before P4 E2E** — the E2E handshake pins the host's enrollment pubkey (P2) fetched from the host registry (P3); relay forwarding of handshake frames uses P1 DATA frames.
5. **P4 + P5 before P6 FRONTEND** — the browser needs the capability-token issuance path (P5) and the E2E core (P4) before it can connect + decrypt + render client-side previews.
6. **Cross-cutting**: every plan cites its enforced invariants (§3). No plan may weaken the base-app Origin check; it is *retained AND augmented* by a capability token (INV6/INV15).
### 2.1 W0 task — author `relay-contracts/` (INDEX-owned; FIX 1)
Every plan imports `relay-contracts/` read-only, but **no plan owned authoring it** (FIX 1) — so the INDEX
owns one W0 task, **`W0-CONTRACTS`**, that builds and freezes it **before any plan's W1 dispatches**.
- **Owns (disjoint from all six plans):** `relay-contracts/package.json`, `relay-contracts/tsconfig.json`,
`relay-contracts/src/**`, `relay-contracts/test/**`. **Dependency-free** — no `ws`/`pg`/DOM ambient — so
agent (P2), relay (P1/P3), auth (P5), E2E core (P4), and browser (P6) can all import it.
- **Authors the entire §4 surface as Zod schemas + inferred TS types** (string-literal unions, not enums):
- **§4.1** `MuxFrameType`/`MuxFrameHeader`/`MuxOpen`; `encodeMuxFrame`/`decodeMuxFrame`/`decodeHeader`;
CBOR payload codecs (`encodeOpen`/`decodeOpen`, `encodeWindowUpdate`/`decodeWindowUpdate`,
`encodeGoaway`/`decodeGoaway`); `GoAwayReason` + `decodeGoAwayReason`.
- **§4.2** `HostRecord`/`HostStatus`/`PlanTier`/`RouteEntry`; the FIX 4 teardown shapes
(`RevocationScope`/`KillSignal`/`RevocationBus`, `RELAY_REVOCATIONS_CHANNEL`,
`REVOCATION_PUSH_BUDGET_MS`); the **INV8 version-table DDL** (`host_versions`/`hosts_current`).
- **§4.3** `CapabilityToken`/`CapabilityRight`/`verifyCapabilityToken`; the FIX 5 subprotocol constants
(`APP_SUBPROTOCOL`, `TOKEN_SUBPROTOCOL_PREFIX`, `encodeTokenSubprotocol`, `extractTokenFromSubprotocols`).
- **§4.4** the **FIX 2** evolved E2E surface (`AeadKey`, `DirectionalKeys`, sync `sealFrame`/`openFrame`,
`E2ESession`/`createE2ESession`, `HandshakeResult`, `ClientHello`/`HostHello`/`E2EEnvelope`,
`buildClientHandshake`/`AuthorizedDeviceContext`/`DeviceAuthProofProvider`, HKDF label constants) plus the
**FIX 3** replay surface (`ReplayKeyParams`, `deriveContentKey`, `sealReplayFrame`, `openReplayCiphertext`).
- **§4.5** `EnrollResult` (incl. `hostContentSecret`, FIX 3) + `PairingCodeRecord`.
- **Verify:** `tsc --noEmit` strict + a KAT/round-trip test per codec (mux frame, CBOR payloads, envelope,
subprotocol) so P1↔P2↔P4↔P6 agree byte-for-byte. **Freeze gate:** all §4 exports test-green before
`W0-CONTRACTS` is done; thereafter changed **only** through this INDEX (§2 rule 1). Crypto *implementations*
of the §4.4 functions live in P4's `relay-e2e/` (which re-exports them); `relay-contracts` owns the **shapes**.
---
## 3. Cross-cutting SECURITY INVARIANTS (numbered, testable)
These are program-wide. **Each of the 6 plans MUST list which invariants it enforces and ship a test
per invariant it owns.** Violating any INV blocks merge (CRITICAL, per `code-review.md`).
| ID | Invariant | Testable assertion | Primary owners |
|---|---|---|---|
| **INV1** | **Cross-tenant isolation — A can never reach B.** No code path resolves a host by raw address/port/user-supplied hostname; the client names only a `host_id` it is authorized for. | **Permanent CI tripwire**: device authenticated as account A requesting `host_id` owned by B → **403**, on both first connect and reattach. Fuzz `host_id` guessing → 403. | P5 (owns tripwire), P1, P3 |
| **INV2** | **Relay handles only ciphertext.** The data plane never holds plaintext terminal bytes; DATA-frame payloads are opaque AEAD envelopes (§4.4). | Inject a known plaintext marker through the tunnel; assert it never appears in relay memory dumps / buffers / logs. Relay code imports **no** xterm/ANSI parser. | P1, P4, P2 |
| **INV3** | **`account_id` always derives from the authenticated principal — NEVER client-supplied.** No request/frame field named `account_id`/`tenant_id` is ever trusted as source of truth. | Send a forged `account_id` in body/query/frame → ignored; authz uses only the session/cert-derived principal. Static check: no read of client `account_id` in an authz decision. | P5, P3, P6 |
| **INV4** | **No shared secrets — per-host asymmetric identity.** Agents authenticate with a per-host Ed25519 key + mTLS; the DB stores only **public** keys. | DB dump contains no private key / bearer secret capable of impersonating a host. Enrollment private key never leaves the host process. | P2, P3, P5 |
| **INV5** | **No plaintext / secret at rest.** Ciphertext-only buffers with short TTL on the relay; control-plane stores hashes/pubkeys, never raw tokens or shell bytes. | Grep at-rest stores (Redis/PG/disk) for plaintext markers and secret material → none. Ring/replay buffers hold ciphertext. | P1, P3, P4 |
| **INV6** | **Deny-by-default authz on connect AND reattach.** Every WS upgrade and every session reattach re-validates `session.account_id == host.account_id`; default is reject. | Drop the token → reject. Reattach with a `session_id` not owned by the principal → 403. No "allow if unspecified" branch. | P5, P1, P3 |
| **INV7** | **Stateless data plane.** A relay node holds no durable tenant state; a crash loses nothing (agents reconnect + re-register). | Kill a node mid-session → agent reconnects elsewhere, PTY survives, no data loss; node process has no DB-of-record. | P1, P3 |
| **INV8** | **Immutable records + atomic snapshot swap.** Account/host/routing records are never mutated in place; updates create a new record/snapshot swapped atomically. | Concurrent update test observes only whole old or whole new snapshot, never a torn read. No in-place field mutation in registries. | P3 |
| **INV9** | **Secrets in env/secret-manager, validated at startup, never logged.** | Startup fails fast if a required secret is missing/invalid. Log scan asserts zero secret material emitted. | all |
| **INV10** | **Immutable audit log with ZERO payload.** Every attach/manage/kill/enroll/revoke is logged; terminal payload is never logged; cross-tenant-crossing attempts raise an alert. | Audit entries contain metadata only (principal, host_id, action, ts) — never keystrokes/output. A → B attempt emits an alert event. | P5, P3 |
| **INV11** | **No terminal parsing in relay/agent (ciphertext/byte-shuttle discipline).** Neither relay nor agent interprets ANSI/attach/input/resize semantics. | Relay/agent codebase has no dependency on terminal parsers; frames pass through opaque. | P1, P2 |
| **INV12** | **Fast revocation kills live tunnels in seconds.** Global + per-host revocation stops cert renewal AND tears down the live tunnel. | Revoke a host → its live tunnel drops and reconnect is refused within seconds; capability tokens for it stop validating. | P5, P3, P2 |
| **INV13** | **Anti-replay/injection: per-message nonce + strictly monotonic sequence.** AEAD frames carry a unique nonce and a monotonic `seq`; out-of-order/duplicate/tampered frames are rejected. | Replay a captured frame → rejected. Reorder `seq` → rejected. Flip a ciphertext bit → AEAD tag fails. | P4 |
| **INV14** | **mTLS with SPIFFE-style short-lived, auto-rotating certs.** Agent↔relay is mutually authenticated; certs are short-TTL and rotate without downtime. | Expired cert → handshake refused. Rotation mid-tunnel is seamless. CA never signs a cert for a pubkey not in the host registry. | P5, P2 |
| **INV15** | **Capability token required on the WS upgrade.** Origin/CSWSH check is retained AND a signed capability token (host + rights scope) is required; token scopes exactly one host and a rights subset. | Upgrade without a valid token → 401. Token scoped to `attach` cannot `kill`. Foreign-Origin still 401 (base-app behavior retained). | P5, P1, P6 |
---
## 4. FROZEN SHARED CONTRACTS
These live in **`relay-contracts/`** as **Zod schemas + inferred TS types** (validation at every
boundary is mandatory — `coding-style.md` "Input Validation"). Types are `PascalCase`, functions
`camelCase`, unions are **string-literal unions, not enums**. **No plan may redefine any of these
locally.** Byte-level fields are big-endian (network order) unless noted. Wire values are hex here.
### 4.1 Mux frame format & stream lifecycle (P1 owns; P2 consumes)
**One physical outbound `wss://` per host carries N sessions × M mirrored clients + heartbeat.** Each
inbound browser connection ⇒ one logical **stream** ⇒ one fresh `ws://127.0.0.1:3000` on the host.
**Frame header — 15 bytes fixed, then `length` bytes of payload:**
```
offset size field meaning
0 1 version 0x01
1 1 type 0x01 OPEN · 0x02 DATA · 0x03 CLOSE · 0x04 PING · 0x05 PONG · 0x06 WINDOW_UPDATE · 0x07 GOAWAY
2 1 flags bit0 FIN (last DATA on stream) · bit1 RST (abnormal close) · others reserved=0
3 4 streamId uint32; 0 = connection-level control (PING/PONG/GOAWAY/WINDOW_UPDATE for whole link)
7 8 payloadLen uint64 byte length of payload (MUST be ≤ negotiated maxFrameBytes)
15 var payload opaque bytes (see per-type below)
```
**Per-type payload:**
- **OPEN** (relay→agent): CBOR of `MuxOpen { streamId, subdomain, requestPath, originHeader, remoteAddrHash, capabilityTokenRef }`. Agent dials `ws://127.0.0.1:3000<requestPath>` replaying `Origin: originHeader`. **The relay has already authorized this stream (P5); the agent trusts the authenticated tunnel.** Carries **no** terminal bytes.
- **DATA**: **opaque ciphertext** — the E2E encrypted envelope (§4.4) once E2E ships; a raw WS payload passthrough in v0.8 pre-E2E. **The relay never inspects it (INV2/INV11).**
- **CLOSE**: empty; `flags.RST` distinguishes error close from graceful. Closing a stream on either side frees the paired localhost socket.
- **PING/PONG** (streamId 0): 8-byte opaque token echoed back; heartbeat every **15s**, miss ⇒ tunnel dead ⇒ agent reconnects.
- **WINDOW_UPDATE**: `uint32 credit` increment for `streamId` (or 0 for the whole link) — **credit-based per-stream flow control** so one heavy `vim`/`top` redraw can't starve another stream. Initial window is negotiated at tunnel setup; a sender blocks a stream when its credit hits 0.
- **GOAWAY** (streamId 0): `uint32 lastStreamId` + `uint32 reason` — graceful drain (EXPLORE §3 relay-node drain): agent finishes in-flight streams then reconnects elsewhere. Reason codes are the frozen `GoAwayReason` map below (`1 operatorDrain · 2 revoked · 3 shutdown`) — the same codes P1 `drain.ts` `DRAIN_REASON` and P5 revocation (FIX 4) emit; `revoked` forces grace-to-0.
**Stream lifecycle**: `OPEN → (DATA | WINDOW_UPDATE)* → CLOSE`. Illegal transitions (DATA before OPEN,
DATA after CLOSE, unknown streamId) ⇒ RST that stream, never the tunnel. `streamId`s are allocated by
the relay, monotonically increasing per tunnel, never reused within a tunnel's lifetime.
```ts
export type MuxFrameType = 'open' | 'data' | 'close' | 'ping' | 'pong' | 'windowUpdate' | 'goaway'
export interface MuxFrameHeader {
readonly version: 1
readonly type: MuxFrameType
readonly fin: boolean
readonly rst: boolean
readonly streamId: number // uint32; 0 = connection-level
readonly payloadLen: number // uint64 (safe-int guarded ≤ maxFrameBytes)
}
export interface MuxOpen {
readonly streamId: number
readonly subdomain: string
readonly requestPath: string // e.g. '/term?join=<id>' — opaque passthrough
readonly originHeader: string // real browser Origin, validated end-to-end
readonly remoteAddrHash: string // salted hash of client IP (audit only, INV10)
readonly capabilityTokenRef: string // jti of the token that authorized this stream (P5)
}
export function encodeMuxFrame(h: MuxFrameHeader, payload: Uint8Array): Uint8Array
export function decodeMuxFrame(buf: Uint8Array): { header: MuxFrameHeader; payload: Uint8Array }
// Typed control-frame payload codecs (CBOR) — authored by W0-CONTRACTS (§2.1), consumed by P1/P2:
export function encodeOpen(open: MuxOpen): Uint8Array
export function decodeOpen(payload: Uint8Array): MuxOpen // Zod-guarded; throws on bad shape
export function encodeWindowUpdate(credit: number): Uint8Array // uint32
export function decodeWindowUpdate(payload: Uint8Array): number
export function encodeGoaway(lastStreamId: number, reason: GoAwayReason): Uint8Array
export function decodeGoaway(payload: Uint8Array): { lastStreamId: number; reason: GoAwayReason }
// GOAWAY reason as a string-literal union (NOT an enum); wire code ⇄ label via decodeGoAwayReason:
export type GoAwayReason = 'operatorDrain' | 'revoked' | 'shutdown'
export function decodeGoAwayReason(code: number): GoAwayReason // 1→operatorDrain · 2→revoked · 3→shutdown; else throws
```
### 4.2 Account / host data model (P3 owns; Postgres = ownership source of truth, Redis = live location)
**Immutable records (INV8): updates insert a new row / swap a snapshot; no in-place mutation.**
```
accounts
account_id uuid PK -- unguessable, never recycled
plan text -- 'free'|'personal'|'pro'|'team' (string-literal union)
created_at timestamptz
status text -- 'active'|'suspended'
-- auth principals join via webauthn_credentials / oidc_identities (P5-owned tables)
hosts
host_id uuid PK -- unguessable UUIDv4, NEVER recycled (INV1)
account_id uuid FK -> accounts -- ownership source of truth
subdomain text UNIQUE -- 'alice' in alice.term.<domain>; stable across reconnects
agent_pubkey bytea -- Ed25519 PUBLIC key only (INV4); private key never leaves host
enroll_fpr text -- fingerprint of agent_pubkey, pinned by browser for E2E TOFU (§4.4)
status text -- 'online'|'offline'|'draining'|'revoked' (string-literal union)
last_seen timestamptz -- heartbeat freshness
created_at timestamptz
revoked_at timestamptz NULL -- set on revocation (INV12); status flips to 'revoked'
sessions
session_id uuid PK -- base-app sessionId, opaque to relay; unguessable
host_id uuid FK -> hosts
account_id uuid -- DENORMALIZED for O(1) deny-by-default authz (INV3/INV6)
created_at timestamptz
last_attach_at timestamptz
pairing_codes -- §4.5
code_hash text PK -- hash of single-use code; raw code never stored (INV5)
account_id uuid FK
expires_at timestamptz -- short TTL
redeemed_at timestamptz NULL -- single-use: non-null ⇒ spent
-- Redis (ephemeral, heartbeat-TTL) — NOT source of truth:
route:{host_id} -> { relayNodeId, updatedAt } EXPIRE = heartbeat_ttl -- live routing table (INV7)
revoked:{jti} -> 1 EXPIRE = token_exp -- capability-token revocation (INV12)
```
```ts
export type HostStatus = 'online' | 'offline' | 'draining' | 'revoked'
export type PlanTier = 'free' | 'personal' | 'pro' | 'team'
export interface HostRecord {
readonly hostId: string; readonly accountId: string; readonly subdomain: string
readonly agentPubkey: Uint8Array; readonly enrollFpr: string
readonly status: HostStatus; readonly lastSeen: string; readonly createdAt: string
readonly revokedAt: string | null
}
export interface RouteEntry { readonly relayNodeId: string; readonly updatedAt: string }
```
**Control→data-plane revocation teardown (FIX 4 — INV12).** Fast revocation must tear down an
**already-open** tunnel, not merely refuse the next connect. The teardown channel + signal shape are
frozen **here** (promoted out of `relay-auth` so P1 does not import P5, keeping the DAG acyclic):
- **Redis pub/sub channel: `relay:revocations`** — the one named control→data-plane bus. **P3/P5 PUBLISH**
a `KillSignal` on it (P5 `revoke()` after marking `revoked:{jti}` / flipping `hosts.status='revoked'`);
**every P1 relay node SUBSCRIBES** and, per affected stream, injects a §4.1 `CLOSE`+`flags.RST`
(host/account scope) or a connection-level `GOAWAY` (global scope) using the existing frozen §4.1 wire —
**no new frame type**. Teardown budget: `REVOCATION_PUSH_BUDGET_MS = 2000`. P3 OQ4's "control→node
drain channel" reconciles to this exact channel; P5 stays socket-free (publishes only).
```ts
// FROZEN in relay-contracts (§4.2). P5 publishes; P1 subscribes; P1's killsScope() predicate selects streams.
export type RevocationScope =
| { readonly kind: 'host'; readonly hostId: string }
| { readonly kind: 'account'; readonly accountId: string }
| { readonly kind: 'global' }
export interface KillSignal {
readonly scope: RevocationScope
readonly at: number // epoch seconds the revocation was issued
readonly reason: string // metadata only, ZERO payload (INV10)
}
export interface RevocationBus { publish(signal: KillSignal): Promise<void> } // P3/P1 implement the `relay:revocations` transport
export const RELAY_REVOCATIONS_CHANNEL = 'relay:revocations' as const
export const REVOCATION_PUSH_BUDGET_MS = 2000 as const
```
**INV8 version-table DDL (immutable records + atomic snapshot swap).** Mutable columns above
(`hosts.status`/`last_seen`, `sessions.last_attach_at`) are never updated in place; a new version row is
inserted and the current pointer is swapped atomically, so a concurrent read sees whole-old or whole-new:
```
host_versions
version_id uuid PK
host_id uuid FK -> hosts
snapshot jsonb -- full immutable HostRecord snapshot
created_at timestamptz
supersedes uuid NULL -- prior version_id (audit chain)
hosts_current
host_id uuid PK
version_id uuid FK -> host_versions -- CAS-swapped pointer to the live snapshot (atomic)
```
### 4.3 Capability token (P5 owns; verified on every P1 upgrade and P3 connect/reattach)
Signed, **stateless**, short-TTL (Ed25519-signed PASETO/JWS). `sub` and `host` come from the
authenticated principal + registry (INV3) — **never** from client input. Revocable by `jti` (INV12).
```ts
export type CapabilityRight = 'attach' | 'manage' | 'kill'
export interface CapabilityToken {
readonly sub: string // principal id (account/device) — from authenticated session, NOT client
readonly aud: string // subdomain this token is valid at (Host-confusion guard, INV1)
readonly host: string // exact host_id scope — single host, never wildcard
readonly rights: readonly CapabilityRight[] // least-privilege subset; 'attach' ⊉ 'kill'
readonly iat: number; readonly exp: number // short TTL
readonly jti: string // unique id for revocation-list lookup
// signature over the above by the control-plane signing key (verified at the relay edge)
}
export function verifyCapabilityToken(raw: string, expectedAud: string, now: number): CapabilityToken
```
Rule: a share-QR / device grant issues a token scoped to **one host, a rights subset, expiring**
granting one session read-or-write without granting the account (EXPLORE §4a.3).
**WS-upgrade token transport — FROZEN wire format (FIX 5).** The browser's native `WebSocket` API cannot
set request headers, so the token rides the **`Sec-WebSocket-Protocol`** subprotocol list (preferred) or a
short-lived `HttpOnly; Secure; SameSite=Strict` cookie (fallback). **Never the query string** (leaks into
proxy/CDN/frp logs, history, `Referer`). The subprotocol encoding is frozen so P1 (relay) and P6 (browser)
agree exactly — they diverged locally (`relay.capability.v1` vs `term.relay.v1`) and this pins one:
```ts
// FROZEN in relay-contracts (§4.3). The client opens:
// new WebSocket(url, [APP_SUBPROTOCOL, TOKEN_SUBPROTOCOL_PREFIX + base64url(rawToken)])
export const APP_SUBPROTOCOL = 'term.relay.v1' as const // the app subprotocol — the ONLY value ever accepted/echoed
export const TOKEN_SUBPROTOCOL_PREFIX = 'term.token.' as const // the token entry = this prefix + base64url(token)
export function encodeTokenSubprotocol(rawToken: string): string // => 'term.token.' + base64url(rawToken)
export function extractTokenFromSubprotocols(values: readonly string[]): string | null // strips prefix, base64url-decodes; null if absent
```
**Echo rule (MUST):** the relay reads the token from the `term.token.<b64u>` entry, strips it before verify,
and the accepted/echoed `Sec-WebSocket-Protocol` in the handshake response **MUST be `term.relay.v1` (the
app subprotocol), NEVER the token entry** — echoing the token would leak the bearer secret into logs and the
handshake response. After open, the client asserts `ws.protocol === 'term.relay.v1'` and tears down otherwise.
### 4.4 E2E handshake sequence & encrypted frame envelope (P4 owns; relay forwards, never derives)
**Authenticated X25519 ECDH THROUGH the relay** — the relay forwards these as opaque §4.1 DATA frames
and **cannot derive the key** (INV2). The browser **pins the host's enrollment fingerprint** (`enroll_fpr`
from §4.2, TOFU or pinned) so a malicious relay cannot MITM.
```
Browser (device, authenticated via P5) Host-agent (enrolled, §4.5)
│ client_hello ─────────────────(relay DATA)──────────▶ │
│ { clientEphPub(X25519), clientNonce, aeadOffer, │
│ deviceAuthProof } // proof = account-derived, binds device to account (INV3)
│ │
│ ◀────────────────(relay DATA)──────── host_hello ──────│
│ { hostEphPub(X25519), hostNonce, aeadChoice, │
│ enrollFpr, sig } // sig = Ed25519(agent_pubkey) over transcript
│ │
│ verify sig with pinned enrollFpr (§4.2); mismatch ⇒ ABORT (no MITM)
│ sharedSecret = X25519(clientEphPriv, hostEphPub) │ = X25519(hostEphPriv, clientEphPub)
│ master = HKDF(sharedSecret, salt=clientNonce||hostNonce, info="relay-e2e/v1")
│ keys = DirectionalKeys{ c2h, h2c } = HKDF-Expand(master, "relay-e2e/v1/c2h" | "…/h2c")
│ ── both sides hold BOTH direction subkeys; relay never did ── │
```
**Direction-split (FIX 2 — anti-reflection / anti-nonce-reuse):** the master secret is split into two
disjoint AEAD subkeys — `c2h` (client→host) and `h2c` (host→client) — so a deterministic per-`seq` nonce
never produces the same `(key, nonce)` in both directions and a relay cannot reflect a c2h frame back as
h2c. Live frames use these ephemeral subkeys; replay/preview frames use the recoverable `K_content` below.
**This block supersedes the original single-`sessionKey: CryptoKey` shape (FIX 2).** P4 converged on an
**`AeadKey`** opaque wrapper (not `CryptoKey`) with **synchronous** `sealFrame`/`openFrame` (audited
`@noble/ciphers`, isomorphic, and XChaCha which WebCrypto lacks), `aad = directionLabel‖seq`, and
direction-split `DirectionalKeys`. P2 T15 and P6 T8/T9 cite THIS surface verbatim — no local redefinition.
```ts
export type AeadAlg = 'aes-256-gcm' | 'xchacha20-poly1305'
export type AeadKey = { readonly __aeadKey: unique symbol } // opaque wrapper over a 32-byte key (NOT CryptoKey)
export type SessionRole = 'client' | 'host'
export interface ClientHello {
readonly clientEphPub: Uint8Array; readonly clientNonce: Uint8Array
readonly aeadOffer: readonly AeadAlg[]
readonly deviceAuthProof: string // per-handshake, bound to clientEphPub‖clientNonce (FIX 6b); P5 mints, P4 shape-validates
}
export interface HostHello {
readonly hostEphPub: Uint8Array; readonly hostNonce: Uint8Array
readonly aeadChoice: AeadAlg; readonly enrollFpr: string; readonly sig: Uint8Array
}
// Direction-split subkeys (FIX 2). Client seals with c2h & opens with h2c; host seals with h2c & opens with c2h.
export interface DirectionalKeys { readonly c2h: AeadKey; readonly h2c: AeadKey }
export const HKDF_INFO = 'relay-e2e/v1' as const
export const HKDF_INFO_C2H = 'relay-e2e/v1/c2h' as const
export const HKDF_INFO_H2C = 'relay-e2e/v1/h2c' as const
// Encrypted frame envelope carried inside every §4.1 DATA payload once E2E is live:
export interface E2EEnvelope {
readonly seq: bigint // strictly monotonic per direction (anti-replay, INV13)
readonly nonce: Uint8Array // DETERMINISTIC f(seq), 12B GCM / 24B XChaCha (no random branch — see P4 §T9)
readonly ciphertext: Uint8Array // AEAD(subkey, plaintext, aad = directionLabel‖seq)
readonly tag: Uint8Array // AEAD auth tag; failure ⇒ drop + tear down
}
// SYNCHRONOUS single-key primitives (key: AeadKey, NOT CryptoKey). aad = directionLabel‖seq (u64 BE).
export function sealFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope
export function openFrame(key: AeadKey, env: E2EEnvelope, expectedSeq: bigint): Uint8Array
// Stateful directional wrapper the agent/browser use over a stream (holds BOTH subkeys + role + seq guards):
export interface E2ESession {
readonly role: SessionRole
seal(plaintext: Uint8Array): Uint8Array // write subkey → next send seq → sealFrame → encoded DATA payload
open(dataPayload: Uint8Array): Uint8Array // read subkey → decode → SequenceGuard.accept → openFrame
rederive(next: HandshakeResult): void // re-key on refresh/reconnect; installs new DirectionalKeys; resets seq guards
}
export interface HandshakeResult {
readonly keys: DirectionalKeys; readonly aead: AeadAlg; readonly transcript: Uint8Array
}
export interface ClientHandshake {
start(): Promise<ClientHello>
onHostHello(msg: HostHello, agentPubkey: Uint8Array): Promise<HandshakeResult> // agentPubkey from P3 registry, independent TLS channel
}
export function createE2ESession(role: SessionRole, result: HandshakeResult): E2ESession
// Handshake factory P6 calls; ctx carries the P5 device-proof provider, TOFU pin store, and host-scoped secret (§4.5):
export function buildClientHandshake(ctx: AuthorizedDeviceContext, hostId: string, aeadOffer: readonly AeadAlg[]): ClientHandshake
export interface AuthorizedDeviceContext {
readonly deviceAuthProofProvider: DeviceAuthProofProvider // P5 — mints per-handshake proof bound to clientEphPub‖clientNonce (FIX 6b)
readonly pinStore: DevicePinStore // P6 — TOFU/pin over recomputed fingerprints
readonly hostContentSecret: Uint8Array // §4.5 host-scoped secret; NEVER logged, NEVER sent to relay
}
export interface DeviceAuthProofProvider {
proofFor(hostId: string, binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array }): Promise<string>
}
// Recoverable replay content-key (FIX 3) — live frames use ephemeral DirectionalKeys; ring-buffer/preview
// frames use K_content, recoverable after reload so "refresh and the session survives" holds under E2E:
export interface ReplayKeyParams {
readonly hostContentSecret: Uint8Array // host-scoped, delivered wrapped-to-agent at §4.5 enrollment (NOT a raw account secret)
readonly sessionId: string // base-app sessionId — salt; K_content is per-session, per-host
readonly alg: AeadAlg
}
export const REPLAY_KDF_INFO = 'relay-e2e/replay/v1' as const
export function deriveContentKey(p: ReplayKeyParams): AeadKey // HKDF(hostContentSecret, salt=sessionId, info=REPLAY_KDF_INFO)
export function sealReplayFrame(k: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope // agent seals replay-bound output (P2)
export function openReplayCiphertext(k: AeadKey, dataPayload: Uint8Array): Uint8Array // browser client-side replay/preview decrypt (P6)
```
**Session-key lifecycle**: ephemeral direction subkeys are derived on attach and **re-derived on
refresh/reconnect** (`E2ESession.rederive`, forward-secret). Ring-buffer replay of *ciphertext* survives a
reload because it is sealed under the **recoverable** `K_content` (`deriveContentKey`), not the ephemeral
subkeys (EXPLORE §4c). **Multi-device**: each authorized device runs its own handshake gated by an
account-derived `deviceAuthProof` and re-derives the identical `K_content` over an **authenticated channel —
NOT a plaintext QR** (INV3). `DevicePinStore` / `SequenceGuard` are P4-internal helper types (not §4 wire).
### 4.5 Pairing protocol (P3 issues, P2 redeems)
```
1. ISSUE (human authenticated, P5) → control plane mints single-use short-TTL pairing code;
stores { code_hash, account_id, expires_at } (raw code never stored, INV5)
2. GENERATE (host, P2) `npx web-terminal-agent pair ABCD-1234`
→ agent generates Ed25519 keypair LOCALLY (private key stays, INV4)
3. REDEEM (host → control plane, TLS) POST /enroll { code, agentPubkey, csr }
4. BIND (control plane, P3) verify code unredeemed+unexpired → bind host_id ↔ account_id,
store agent_pubkey + enroll_fpr, assign stable subdomain,
sign short-lived mTLS cert (INV14), MINT + WRAP hostContentSecret
(FIX 3), mark code redeemed (single-use)
5. RETURN EnrollResult { host_id, subdomain, cert, caChain, hostContentSecret }
→ agent installs as service; unwraps + stores hostContentSecret in its keystore
```
**Host-scoped content secret (FIX 3 — recoverable replay key delivery).** `EnrollResult` gains
`hostContentSecret`, the input to §4.4 `deriveContentKey` (`ReplayKeyParams`). **Single owner: P3 mints and
wraps it at BIND** — a per-host random secret **sealed/wrapped to the host's own enrolled Ed25519 identity**
(`agent_pubkey`), so it is host-scoped (NOT a raw account-wide secret) and the control plane can invalidate
**that specific wrap** on host/device revocation (INV12) without an account-wide rotation. P2 receives it in
`EnrollResult`, unwraps with its enrollment private key (never leaves the host, INV4), and holds it for
`deriveContentKey`. Authorized browser devices obtain the same `hostContentSecret` via P5 (unwrapped only
after auth/step-up) → re-derive the identical `K_content` → decrypt replayed ciphertext after a reload.
```ts
// FROZEN in relay-contracts (§4.5). hostContentSecret is wrapped-to-agent-identity bytes; P2 unwraps locally.
export interface EnrollResult {
readonly hostId: string; readonly subdomain: string
readonly cert: string; readonly caChain: string
readonly hostContentSecret: Uint8Array // FIX 3 — wrapped to agent_pubkey by P3 at BIND; feeds §4.4 deriveContentKey
}
export interface PairingCodeRecord {
readonly codeHash: string; readonly accountId: string
readonly expiresAt: string; readonly redeemedAt: string | null
}
```
Redemption is **atomic + single-use**: a `redeemed_at` compare-and-set prevents double-spend. Steady
state after pairing: agent dials `wss://relay/agent`, proves key possession via mTLS, relay writes
`route:{host_id}` in Redis with heartbeat TTL (INV7). Reconnect uses the **same 1/2/4…cap-30s** backoff
the base app already ships (EXPLORE §3).
---
## 5. Per-plan charters (scope fences — no overlap, no gaps)
**P1 — [`PLAN_RELAY_TRANSPORT.md`](./PLAN_RELAY_TRANSPORT.md) · Tunnel & Mux Protocol + Relay Data Plane.**
Owns the native WS multiplexing protocol (§4.1 frame format, stream open/close lifecycle, per-stream
credit-based flow-control/backpressure, 15s heartbeat, reconnection) between host-agent and the stateless
relay data plane; TLS termination; per-tenant **subdomain** routing (route by authenticated session, not
Host header, INV1); **opaque CIPHERTEXT forwarding** (never parses terminal data, INV2/INV11); and the
**v0.8 frp-based MVP scaffold as an explicit stepping-stone** toward the native mux. Enforces
INV1, INV2, INV5, INV6, INV7, INV11, INV15. Does **not** own accounts, crypto keys, or UI.
**P2 — [`PLAN_RELAY_AGENT.md`](./PLAN_RELAY_AGENT.md) · Host Agent (`agent/`).**
Owns enrollment/pairing redemption (§4.5: locally-generated Ed25519 keypair + single-use pairing-code,
private key never leaves host, INV4), outbound dial + mTLS with short-lived certs (INV14), registration,
holding the §4.1 mux tunnel, forwarding logical streams to the **UNCHANGED** web-terminal at
`127.0.0.1:3000`, the agent-side E2E endpoint (§4.4 `host_hello`/`sealFrame`), reconnection/backoff
(reuse the 1/2/4…cap-30s policy), and distribution (`npx …-agent pair` → static binary; launchd/systemd
service install). Enforces INV2, INV4, INV11, INV12, INV14. Does **not** own the frame spec (imports
§4.1 from P1) or account issuance.
**P3 — [`PLAN_RELAY_CONTROLPLANE.md`](./PLAN_RELAY_CONTROLPLANE.md) · Control Plane & Accounts.**
Owns the account & host registries (§4.2 Postgres, **immutable** records, INV8), pairing-code
issuance+redemption (§4.5), per-tenant subdomain assignment, the live routing table (Redis
`route:{host_id}` heartbeat-TTL; Postgres = ownership source of truth, INV7), provisioning/deprovisioning
APIs, billing-metering hooks (paired hosts + concurrent viewers), the mTLS cert authority (signs only
pubkeys in the host registry, INV14), and graceful relay-node **drain** coordination (§4.1 GOAWAY).
Enforces INV1, INV3, INV4, INV5, INV6, INV8, INV10, INV12. Does **not** own the byte path or crypto
frame format.
**P4 — [`PLAN_RELAY_E2E.md`](./PLAN_RELAY_E2E.md) · End-to-End Encryption (`relay-e2e/`).**
Owns browser↔agent E2E: authenticated X25519 ECDH **through** the relay (§4.4; relay forwards, never
derives), host-key pinning/TOFU bound at enrollment (browser verifies `enroll_fpr` from §4.2), the AEAD
frame envelope (§4.4 `E2EEnvelope`, AES-256-GCM / XChaCha20-Poly1305, **per-message nonce + monotonic
sequence** for anti-replay/injection, INV13), session-key lifecycle + re-derivation on refresh, and
multi-device key distribution over an **authenticated (account-derived) channel — not a plaintext QR**.
Documents the relay-sees-only-ciphertext guarantee and the features it kills (server previews →
client-side, P6). Enforces INV2, INV5, INV13. Does **not** own transport framing or human auth.
**P5 — [`PLAN_RELAY_AUTH_ISOLATION.md`](./PLAN_RELAY_AUTH_ISOLATION.md) · Auth & Tenant Isolation.**
Owns human auth (Passkey/WebAuthn primary, TOTP fallback, **never SMS**, OIDC SSO for teams, **step-up
before opening a session**); agent auth (per-host mTLS + SPIFFE-style short-lived auto-rotating certs,
DB stores only public keys, INV4/INV14); **capability tokens** on the WS upgrade (§4.3 host+rights scope,
INV15); the **deny-by-default tenant-authz model** (`account_id` from the authenticated principal, NEVER
client-supplied, INV3) enforced on **every connect AND reattach** (INV6); the **hard cross-tenant
isolation invariant** (INV1) + a **permanent CI tripwire test** (device A → host B = 403); per-tenant
rate-limits/quotas; global + per-host **revocation** that kills live tunnels in seconds (INV12); and an
**immutable audit log with zero payload logging** + cross-tenant-crossing alerts (INV10). Enforces
INV1, INV3, INV4, INV6, INV10, INV12, INV14, INV15. Does **not** own the byte path.
**P6 — [`PLAN_RELAY_FRONTEND.md`](./PLAN_RELAY_FRONTEND.md) · Browser / Frontend (`relay-web/`).**
Owns the login UI (passkey), connecting through the relay served from the tenant **subdomain**
(same-origin, scheme-following `wss:`, M6), browser-side E2E via Web Crypto (SubtleCrypto, consuming §4.4
`relay-e2e/`), **CLIENT-SIDE preview rendering** (server previews die under E2E — the authorized,
key-holding browser decrypts + renders a read-only xterm), and the pairing/onboarding + dashboard (host
●online status, add-machine flow). **The core xterm byte path in `public/` stays untouched.** Enforces
INV3, INV15 (client side), and consumes INV2/INV13 guarantees. Does **not** own server-side routing,
accounts, or crypto primitives.
---
## 6. Single-owner decisions (reconciliation — FIX 6)
Two capabilities were implemented in more than one plan; the DECISION now has exactly one owner and the
others delegate. Consumer plans cite these verbatim — no re-implementation.
**6a. WS-upgrade authorization — sole owner P5 (`onUpgrade`/`onReattach`).** The full authorization decision
(Origin/CSWSH retained + capability token verify + DPoP proof-of-possession + single-use `jti` burn +
pre-auth throttle + step-up gate + per-tenant rate + deny-by-default cross-tenant gate + audit) lives **only**
in P5 `relay-auth/src/enforce/onUpgrade.ts` / `onReattach.ts`. **P1 T8 `authorizeUpgrade` is a THIN ADAPTER
that delegates to P5** — it parses the upgrade (Origin, subprotocol token via FIX 5, subdomain), builds the
`UpgradeContext`, calls P5, and maps the `AuthzOutcome` to a §4.1 `MuxOpen` (or a 401/403 close). P1 T8 must
supply P5's `UpgradeContext` fields — including `dpop: DpopContext` and `activeSessionCount` — and holds **no**
independent authz logic. P5 is the injected `CapabilityVerifier`/authorizer; P1 owns only the byte splice.
**6b. `deviceAuthProof` — sole issuer/verifier P5 (`relay-auth`).** P5 `capability/device-proof.ts` owns
minting (`signDeviceAuthProof`) and verification (`verifyDeviceAuthProof`), binding the proof to
**`{clientEphPub, clientNonce}`** (per §4.4 `ClientHello`) so a captured proof cannot be replayed into a
different handshake. **P4 consumes it as an INJECTED dependency** — the §4.4 `DeviceAuthProofProvider`
(client side) and a `verifyDeviceProof(proof, { clientEphPub, clientNonce })` host-side dep — and never
imports crypto identity from P5 directly. **P2 obtains the verifier via P4's host-handshake wiring**
(`createHostHandshake({ verifyDeviceProof, … })`), **NOT** by importing from `relay-e2e` (the old P2 T15
`import … from 'relay-e2e'` was wrong). Binding param `{ clientEphPub, clientNonce }` is unified across
P2/P4/P5. `deviceAuthProof` issuance/verification therefore has exactly one home (P5); P4 carries + shape-
validates it; P2 receives the verifier through P4.