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:
239
docs/EXPLORE_RELAY_SERVICE.md
Normal file
239
docs/EXPLORE_RELAY_SERVICE.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# web-terminal → Rendezvous-Relay Service: A Decision-Ready Product Exploration
|
||||
|
||||
*Turning a single-host byte-shuttle into a multi-tenant "bridge external devices to each customer's self-hosted terminal" service. This is exploration, not a commitment.*
|
||||
|
||||
---
|
||||
|
||||
## 0. LOCKED DECISIONS (2026-07-01) — prerequisites for the implementation plan
|
||||
|
||||
All six §8 decisions are now resolved by the user:
|
||||
|
||||
| # | Decision | Choice |
|
||||
|---|----------|--------|
|
||||
| 1 | Tunnel substrate | **frp for the MVP scaffold → self-built native WS mux for the real product.** E2E is the driver (frp can't cleanly host per-stream E2E), not billing. Coarse billing (hosts/viewers) works on frp; native mux is for E2E + per-stream control. |
|
||||
| 2 | Relay trust model | **End-to-end encryption (E2E).** Relay is a *ciphertext-shuttle* — it never sees plaintext keystrokes/output. Steal the sshx crypto model (per-session key via authenticated X25519 ECDH through the relay, AEAD frames via Web Crypto). **The load-bearing security decision.** |
|
||||
| 3 | Routing key | **Per-tenant subdomain** (`alice.term.<domain>`) — own browser origin per tenant → cookie/localStorage/`sessionId`/CORS isolation for free. Capability token layered on top (subdomain = which host; token = may-you + which PTY). No shared-origin path routing. |
|
||||
| 4 | Product form | **Both** — open-source the relay + agent (free self-host = competitive necessity + funnel) AND run a hosted SaaS (revenue). Price on **paired hosts + concurrent viewers**, not bandwidth; Hetzner-class infra. |
|
||||
| 5 | Auth model | Humans: **Passkey/WebAuthn primary** (TOTP fallback, never SMS; OIDC SSO for teams). Agents: **per-host Ed25519 key via single-use pairing code, mTLS with SPIFFE-style short-lived rotating certs** (DB stores only public keys). MVP may start with a shared `clientToken` gate but MUST reach real accounts before the first paid signup. Step-up auth *before opening a session*. |
|
||||
| 6 | Liability | **Contract states it explicitly** — the agent runs a shell with the customer's own privileges (their accepted risk). Your liability = relay-as-injection-vector, which E2E bounds to "we can disrupt connectivity but cannot read or inject." (Only truthful *because* of decision 2.) Pair with hard revocation + audited operator access; SOC 2 + published security model at business scale. |
|
||||
|
||||
**Accepted consequence of E2E (decision 2):** server-side preview thumbnails / manage-page grid **move to CLIENT-SIDE rendering** (the key-holding browser decrypts + renders); relay can't render ciphertext. Ring-buffer replay and multi-device mirror survive (relay stores/forwards ciphertext). *(Assumption pending explicit user confirm; correct here if wrong.)*
|
||||
|
||||
---
|
||||
|
||||
## 1. Reframe: DDNS vs rendezvous-relay
|
||||
|
||||
Your instinct ("like DDNS, my middle server is the bridge") is directionally right about the *stable-name* part and wrong about the *reachability* part — and the gap between them is the whole engineering problem. Plain DDNS only maps a name to an IP; it assumes something can then connect **inbound** to that IP. Your customers can't offer that: they're behind NAT, and increasingly CGNAT (carrier-grade NAT, where the ISP shares one public IP across many subscribers), so there is **no inbound port to forward** — the customer doesn't even own a routable address. The pattern that actually works is a **rendezvous / reverse-tunnel-as-a-service** (the self-hosted ngrok / frp / Cloudflare-Tunnel model): the customer's machine runs a small **host-agent that dials OUT** to your relay over TLS/443 and holds that connection open; external devices connect to *your* relay, which splices them onto the right host's already-open outbound tunnel. Outbound-443 traverses NAT/CGNAT/corporate firewalls exactly the way a browser does, which is why it works where DDNS fundamentally can't. The "DDNS-like" element survives only as a cosmetic convenience: a **stable per-tenant name** (`alice.term.yourdomain.com`) that stays constant while the underlying tunnel reconnects and moves between relay nodes. Name = stable identity; tunnel = reachability. Don't conflate them.
|
||||
|
||||
---
|
||||
|
||||
## 2. Reference architecture & build-vs-reuse
|
||||
|
||||
**The single most important build decision: do NOT hand-roll the tunnel transport.** NAT-traversing, reconnecting, multiplexed, authenticated tunnels are a *commodity* solved by multiple mature open-source projects. Hand-rolling one is 6–12 months of undifferentiated infra, and because you're bridging **full interactive shells for many tenants**, every transport bug is catastrophic (RCE-shaped). Your moat is **not** the byte pipe — it's the persistent, agent-aware, phone-first session product (ring-buffer replay, walk-away/approve-from-phone, zero customer networking setup) layered on top. Invest ~90% of engineering there and treat the tunnel as a dependency you *configure*, not code you *write*.
|
||||
|
||||
**Substrate options, ranked for your exact topology (host dials out → your relay → route external clients by tenant → you provision/deprovision):**
|
||||
|
||||
| Candidate | What it is | Fit for you | Verdict |
|
||||
|---|---|---|---|
|
||||
| **frp** (fatedier/frp, Apache-2.0) | Go, ~90–100k★, `frps` server + `frpc` client, yamux multiplexing, subdomain/vhost routing, server-plugin HTTP hooks (`Login`/`NewProxy`/`NewUserConn`) | Canonical "host dials out + relay routes by subdomain." Hooks let your control plane authorize/provision. You build the tenancy model; frp gives the transport + hooks. | **Default recommendation for the owned substrate.** Free, vendor-independent, preserves the byte-shuttle. |
|
||||
| **rathole** (Rust, Apache-2.0) | Leaner frp-alike, lower mem/CPU, Noise encryption, high connection density | Great raw pipe, **thinner control surface** (fewer provisioning hooks) | Use *only* if host hardware footprint (Raspberry-Pi-class agents) forces it. |
|
||||
| **Pangolin** (2026) | Self-hostable tunneled reverse-proxy *management server*: WireGuard "Newt" agents, dashboard, auto-SSL, identity/access | Closest existing *product* to what you're building — already solves enrollment + per-host routing + control-plane UI | **Study or fork it.** Specialize its data path for the shell-mirror/`sessionId` model. |
|
||||
| **Teleport** (Community OSS) | Identity-aware access proxy; agents **dial out** to a central proxy, browser terminal, RBAC, session recording, tenant isolation, host↔account pairing — *your pattern, generalized* | Solves exactly the first-order concerns your base app lacks — but it's enterprise IAM (short-lived certs, RBAC ceremony) that fights your "zero-setup consumer" goal | **Read its reverse-tunnel + web-terminal source before designing.** Adopt as substrate only if you'll tolerate its weight. |
|
||||
| **inlets-uplink** (commercial) | Explicitly "multi-tenant tunnels for service providers" — your business, productized | Topological bullseye, but **per-tunnel licensing** (~$250/mo/cluster + per-tunnel) taxes your unit economics and couples margins to a vendor | Great to *feel* the provisioning API in a prototype; wrong to build the company on. |
|
||||
| **Cloudflare Tunnel / ngrok** | Production tunnels, subdomain routing, provisioning APIs | **You don't own the relay** — they *are* the middle server. Contradicts your entire "I am the bridge" thesis; customers' shells transit them. | **v0 shortcut to prove the UX only.** Never the permanent substrate. |
|
||||
| chisel / bore | Minimal single-binary tunnel primitives | Point-to-point, no multi-tenant routing model — you'd build all tenancy yourself | Fine for a *minimal* demo; more DIY than frp for the routing you need. |
|
||||
| Tailscale/Headscale | WireGuard mesh + self-hosted control server | **You explicitly ruled this out** — requires every customer (and every viewing phone) to install/enroll a client into your mesh. Wrong for productized zero-setup. | Right for *your own* infra access; wrong as the *customer-facing* transport. |
|
||||
|
||||
**Is this already a product?** Close cousins exist — **sshx** (E2E collaborative browser terminals, ephemeral), **tmate** (ephemeral SSH sharing), **Coder/Gitpod** (they *provision* the machine; you bridge to the customer's *own* machine). But **none is "hosted, multi-tenant, persistent, mobile-first tunnel for Claude Code walk-away sessions."** That specific bundle — survive-the-disconnect sessions + phone-first cockpit + remote approvals + zero customer networking setup — is **unoccupied**. That's your opening.
|
||||
|
||||
**Recommendation:** Prototype on Cloudflare Tunnel *or* frp to validate the UX (weeks), then **own the substrate on frp** and build your multi-tenant control plane against its plugin hooks. Borrow Teleport's security model; don't reinvent it.
|
||||
|
||||
---
|
||||
|
||||
## 3. System design
|
||||
|
||||
### The core split: control plane vs data plane
|
||||
|
||||
Physically separate the two so (a) the byte-forwarding hot loop has zero DB/auth logic, and (b) a slow or compromised control plane can't leak bytes between tenants.
|
||||
|
||||
- **Control plane** (stateful, low-QPS, strongly-consistent — Postgres + Redis): account registry, host registry (`host_id`, `account_id`, `subdomain`, `agent_pubkey`, `status`, `last_seen`), enrollment/pairing, the **live routing table** `host_id → relay_node_id` (ephemeral → Redis with heartbeat TTL; Postgres is source-of-truth for *ownership*, Redis for *where the live tunnel is right now*), and auth issuance (browser cookies/JWT, agent credentials).
|
||||
- **Data plane** (stateless, dumb, fast — the relay nodes): terminate inbound browser TLS, hold outbound agent tunnels, look up routing in Redis, verify a short-lived signed token, and shovel frames. **No tenant business logic in the pipe.** If a node crashes, agents reconnect and re-register; nothing durable is lost because there was none.
|
||||
|
||||
### Host-agent lifecycle
|
||||
|
||||
- **Enrollment (one-time):** customer clicks "Add machine" → control plane mints a **single-use, short-TTL pairing code** → on the host, `web-terminal-agent pair ABCD-1234` generates an **Ed25519 keypair locally**, sends the *public* key + code redemption over TLS → control plane binds `host_id → account_id`, stores the public key, assigns the stable subdomain. **Private key never leaves the machine.** (This is the `cloudflared`/Pangolin-Newt model, proven in 2026 production.)
|
||||
- **Steady state:** agent dials OUT `wss://relay/agent` (443, CGNAT-friendly), authenticates by proving key possession (mTLS client cert / signed challenge), relay writes `host_id → this_node` into Redis with heartbeat TTL.
|
||||
- **Liveness/reconnect:** ping/pong every ~15s; exponential backoff with jitter (1s/2s/4s… cap 30s — the *same policy the frontend already ships*). On reconnect the agent may land on a different node and re-registers; the subdomain is stable throughout — that's the DDNS-like guarantee the customer sees.
|
||||
- **Forwards to `127.0.0.1:3000`** — the *unmodified* web-terminal, speaking plain HTTP/WS. The base app thinks it's being hit by a local browser.
|
||||
|
||||
### Routing external clients
|
||||
|
||||
**Use per-customer subdomain as the primary routing key** — not path-prefix. `alice.term.yourdomain.com` → wildcard DNS `*.term...` → ingress → node holding Alice's tunnel. Subdomain wins because it gives each tenant its **own browser origin**, which naturally partitions cookies, `localStorage` (where web-terminal keeps `sessionId`!), and CORS/Origin. A shared-origin path scheme (`app.com/alice/...`) would let one tenant's page script reach another's WebSocket — catastrophic for a shell bridge. **Two layers of identity, never conflated:** *subdomain = which host*; *`sessionId` in the first `attach` frame = which PTY on that host* (base-app concept, stays entirely inside the tunnel — the relay never parses it; `?join=<id>` and share-QR keep working as passthrough query params).
|
||||
|
||||
### Multiplexing
|
||||
|
||||
One physical outbound connection per host carries N sessions × M mirrored clients + heartbeat. Run **yamux (or HTTP/2 streams) over a single `wss://`** — each inbound browser connection ⇒ one new logical stream to the agent ⇒ one fresh `localhost:3000` connection. Per-stream flow control means one heavy `vim`/`top` redraw can't starve another. **frp v0.69 ships exactly this** (TCP stream mux + work-conn pool), so you adopt it rather than hand-rolling the mux.
|
||||
|
||||
### Scaling / HA
|
||||
|
||||
- **Sticky routing:** a host's tunnel terminates on *one* node; its browsers must land there. Solve with **Redis routing table (source of truth) + L7 ingress (Envoy/Traefik/Nginx) that reads it** and forwards to the correct node's internal address. Consistent hashing is an optional latency optimization, not a replacement for Redis.
|
||||
- **Stateless nodes ⇒ trivially horizontal.** Graceful drain: mark node "draining" → send agents a `reconnect` control frame → they backoff-reconnect elsewhere → drain routing keys → take it down. **The PTY survives the whole time on the customer's machine** — a relay-node bounce is invisible to the running Claude Code task. This falls out *for free* from the existing PTY≠WS decoupling.
|
||||
- **Control-plane down blocks new connects/enrollment but does NOT drop existing byte-forwarding** (data plane already holds its tokens) — another payoff of the split. Postgres primary+replica; Redis Sentinel/cluster. Regional relay clusters + anycast when geography demands.
|
||||
|
||||
### End-to-end data path
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────── YOUR CLOUD ─────────────────┐ ┌── CUSTOMER MACHINE (behind NAT/CGNAT) ──┐
|
||||
│ Phone / │ │ │ │ │
|
||||
│ Browser │ wss │ ┌──────────┐ subdomain→ ┌─────────┐ │ │ ┌────────────┐ ┌──────────────┐ │
|
||||
│ alice.term │═════▶│ │ Ingress │──authz────── │ Control │ │ │ │ host-agent │ http/ │ web-terminal │ │
|
||||
│ /ws │ 443 │ │ L7 route │ Redis:host→ │ Plane │ │ │ │ dials OUT │ ws │ (UNCHANGED) │ │
|
||||
└─────────────┘ │ └────┬─────┘ node lookup └─────────┘ │ │ │ holds mux │──────▶ │ 127.0.0.1: │ │
|
||||
▲ │ │ forward to node holding Alice tunnel│ │ │ tunnel │ local │ 3000 │ │
|
||||
│ │ ▼ PG: accounts/hosts │ │ └─────▲──────┘ │ node-pty+ring│ │
|
||||
│ output │ ┌──────────────┐ │ │ │ └──────────────┘ │
|
||||
└──────────────│──│ Relay node 7 │ 1 mux stream per browser │ │ │ (outbound only; NO inbound port)│
|
||||
(verbatim │ │ (DATA PLANE) │═══ single wss + yamux ════│══════│════════┘ │
|
||||
bytes) │ │ stateless │ many streams / 1 link │ │ │
|
||||
│ └──────────────┘ │ └──────────────────────────────────────────┘
|
||||
└─────────────────────────────────────────────┘
|
||||
Byte hot-loop: Phone ─ Ingress ─ Relay7 ═yamux═ Agent ─ 127.0.0.1:3000 ─ pty.write()
|
||||
keypress → xterm onData → wss → ingress → relay → yamux → agent → local WS → pty.write() → shell stdin
|
||||
Nobody between the phone and pty.write() parses the terminal stream. attach/input/resize/output/exit stay end-to-end.
|
||||
```
|
||||
|
||||
### What stays UNCHANGED (the load-bearing claim)
|
||||
|
||||
The **server stays a byte-shuttle** — no ANSI/terminal parsing in relay or agent. **PTY≠WS decoupling, ring-buffer replay, multi-client mirror, latest-writer-wins sizing, orphan reclaim/`IDLE_TTL`, `pty.kill()` on exit** — all local to the customer's server, untouched. **`sessionId` first-frame, `?join`, share-QR, manage-page previews, hooks** — pure passthrough. **`protocol.ts` and the session model — byte-for-byte identical.** All 212 tests keep passing. The relay adds a transport layer *underneath* the existing WebSocket; it is not a rewrite of it.
|
||||
|
||||
**The one base-app touch-point (config, not code):** the Origin check. Traffic arriving via the loopback agent will carry Origin `https://alice.term.yourdomain.com` instead of a LAN IP. **Do not weaken the check.** Set the existing `ALLOWED_ORIGINS` env var (already a knob!) to include the assigned subdomain during agent install. Zero code change; CSWSH protection stays meaningful because the real browser Origin is validated end-to-end.
|
||||
|
||||
---
|
||||
|
||||
## 4. Security & tenant isolation (the crux — no hand-waving)
|
||||
|
||||
You are proposing to operate a central relay that bridges **full interactive shells for many customers**. That is one of the highest-blast-radius services you can build: a full relay compromise is, worst-case, **RCE as the logged-in user on every connected customer machine**. The base app was *deliberately* built with none of the required controls (no auth, no multi-user isolation, Origin-check only). Treat every control below as net-new, and **treat the shell bytes as radioactive**.
|
||||
|
||||
### 4a. Account & pairing — three distinct trust relationships, never conflated
|
||||
|
||||
1. **Host-agent → account.** **Never a shared static token in a config file** (leaks via dotfiles/backups/screen-share → attacker registers as the victim's host). Use the pairing-code → per-host Ed25519 keypair flow (§3). Then authenticate the tunnel with **mTLS using that per-host key**, not a bearer token. Issue **short-lived, auto-rotated certs (SPIFFE/SPIRE model)** — revocation becomes "just stop renewing" + kill the live tunnel, no CRL/OCSP distribution nightmare. *Why asymmetric matters:* a bearer token is a symmetric secret your DB also holds — a relay DB breach leaks every host's credential. A per-host **public** key in your DB is useless to a thief.
|
||||
2. **External device → account.** Boringly standard, strong, not invented by you: **Passkeys (WebAuthn) as primary** (phishing-resistant, GA across major IdPs in early 2026 — not optional when the payload is a root-capable shell); fallback email+password **with mandatory TOTP or a second passkey**; **never SMS OTP** (SIM-swap → shell = catastrophic); OIDC SSO for business customers. Short-lived access tokens + refresh, device-bound (DPoP) where possible.
|
||||
3. **Relay authorizes device → host (the actual gate).** On **every** connection: `session.account_id == host.account_id`? Only then splice the WebSocket. **Capability-scoped**, not just ownership: a token names the exact host(s) and rights (attach vs manage vs kill), so a QR share link can grant *one session, read-or-write, expiring* without granting the whole account.
|
||||
|
||||
### 4b. The hard isolation guarantee
|
||||
|
||||
**Invariant: customer A's browser can NEVER reach customer B's host.** Everything else is negotiable; this is not. Enforcement:
|
||||
|
||||
- Ownership join is the *only* path — **no code path resolves a host by raw address/port/user-supplied hostname**; the client names only a `host_id` it's authorized for.
|
||||
- **Deny-by-default, server-side.** `account_id` is derived from the *authenticated session*, never from a client-supplied field (client-supplied `tenant_id`/`host_id` as source-of-truth is the classic multi-tenant IDOR).
|
||||
- Push toward **silo for the data plane**: tunnels for different tenants must not share mutable in-process state.
|
||||
|
||||
**Cross-tenant failure modes to design against explicitly:** (1) **IDOR on `host_id`** → ownership join + capability tokens + a **CI test asserting cross-tenant attach → 403 as a permanent tripwire**; (2) **`host_id` guessing/reuse** → unguessable UUIDv4, never recycled; (3) **cross-tenant buffer bleed** — the base app's ~2MB ring buffer + `RingBuffer.tail()` previews become cross-tenant memory at a relay; a shared/global buffer pool or off-by-one can splice A's stdout into B's stream → **no global mutable buffers, per-connection allocation** (E2E makes this class *impossible*, not just unlikely); (4) **reconnect races** — reattach must re-validate `session_id` against `account_id`, never resolve purely by client-provided id; (5) **Origin/CSWSH is now insufficient** — keep it *and* require an authenticated capability token on the WS upgrade; (6) **wildcard-subdomain / Host-header confusion** → route by authenticated session, not the Host header.
|
||||
|
||||
### 4c. The trust decision: relay-sees-plaintext vs E2E
|
||||
|
||||
This is *the* decision — it determines your liability, your feature set, and whether a breach is "embarrassing" or "company-ending."
|
||||
|
||||
- **Option A — relay sees plaintext.** Cheapest; keeps *every* server-side feature (preview thumbnails, relay-side replay, server search, session recording). But you'd be **operating a live keylogger-shaped honeypot** for hundreds of developer machines — `sudo` passwords, `.env` secrets, API keys, and pointedly **Claude/Anthropic tokens** scrolling through in plaintext. You inherit SOC 2 / DPA obligations, encryption-at-rest, insider-access controls, breach-notification, and the standing reality that **one relay compromise = every customer's live credentials exfiltrated in real time.** For a small operator this is a near-uninsurable posture. **Not recommended as default.**
|
||||
|
||||
- **Option B — End-to-end encryption (RECOMMENDED).** Browser and host-agent negotiate a **per-session symmetric key the relay never learns**; the relay moves only ciphertext. This is exactly what **sshx** does (Argon2 + AES, key material in the URL fragment `#` which browsers never send to the server) — **steal its crypto model wholesale.** Concretely: authenticated **X25519 ECDH through the relay** (relay forwards the handshake, can't derive the secret), **bound to the host's enrollment key and the device's auth** so it's not a bare DH the relay could MITM (browser verifies the host's pubkey fingerprint, TOFU or pinned at enrollment); all `input`/`output`/`resize` frames **AEAD-encrypted** (AES-GCM / XChaCha20-Poly1305, per-message nonces) via Web Crypto. **This is a marketing asset too:** "we cannot see your terminal" is a *verifiable* claim.
|
||||
|
||||
**What E2E costs you (decide consciously):**
|
||||
- **Preview thumbnails / manage-page grid die server-side** — the relay can't render a screen it can't read. Move preview rendering **client-side** (the authorized, key-holding browser decrypts and renders), or drop live previews for unattended sessions. **This is the biggest product hit.**
|
||||
- **Server-side ring-buffer replay SURVIVES** — the relay stores/replays *ciphertext* (doesn't need to read it); the reconnecting browser presents the session key (re-derivable from account/passkey, not lost on refresh) to decrypt. "Refresh and the session is still there" still works.
|
||||
- **Server-side search & recording → client-side only** (i.e., not server-side at all).
|
||||
- **Multi-device mirror still works** — every authorized device derives the same key, but the key must reach each joining device through an **authenticated channel** (derived from account auth), *not* a plaintext QR a shoulder-surfer or the relay could reuse.
|
||||
|
||||
Even with E2E you still own **routing and availability** — so tenant isolation, auth, DoS, and MAC+sequence-numbering frames (to prevent replay/injection) remain your problem.
|
||||
|
||||
### 4d. Blast radius & liability — stated honestly
|
||||
|
||||
There is **no way to make this risk zero** while offering the product — the agent runs a shell with the user's own privileges (that *is* the product; it's the customer's risk to accept). The engineering job is to **bound the relay's blast radius** so a breach degrades instead of detonating. Controls, ranked by impact: (1) **E2E** — the biggest lever; a relay compromise yields ciphertext + routing metadata, not a live credential feed, and the attacker can't inject valid encrypted frames — *this is the difference between "we had an incident" and "we ended every customer";* (2) **per-host asymmetric keys, no shared secrets** — a DB dump can't impersonate a host; (3) **no shell bytes at rest** — ciphertext-only buffers, short TTL; (4) **least-privilege signed auto-updating agent** — runs as user not root, so you can push emergency patches (a compromised update = same blast radius, so sign + pin the channel); (5) **rapid global revocation** — one control to kill all tunnels + stop cert issuance + rotate relay keys; (6) **MFA-gated, time-boxed, audited operator access** — under E2E your own staff *cannot* silently read a session by construction.
|
||||
|
||||
The **honest customer-facing statement** — defensible *only if you ship E2E*: *"We route your session; with E2E we cannot read it. The agent runs on your machine with your privileges — that access is inherent to the product. A breach of our relay cannot read your terminal, but can disrupt connectivity; we cannot inject commands into an E2E session."*
|
||||
|
||||
### 4e. Minimum bar to operate responsibly (checklist)
|
||||
|
||||
**CRITICAL — block launch / do not onboard a second paying customer until all true:**
|
||||
- [ ] Auth on **every** device connection (passkey primary; TOTP fallback; no SMS). No anonymous shell access, ever.
|
||||
- [ ] Per-host asymmetric identity via single-use short-TTL pairing code; private key never leaves host; relay stores only the public key.
|
||||
- [ ] Server-side, deny-by-default tenant authz: `account_id` from the authenticated principal (never client-supplied), joined against `host.account_id` on every connect **and every reattach**.
|
||||
- [ ] Automated cross-tenant isolation test in CI (device A → host B = 403) as a permanent tripwire.
|
||||
- [ ] E2E encryption of terminal payload (per-session key via authenticated ECDH through the relay; relay routes ciphertext only). *If you launch Option A instead, you've accepted operating a secret-exfiltration honeypot and need the compliance program to match — advised against.*
|
||||
- [ ] No plaintext shell bytes at rest on the relay (ciphertext buffers, short TTL).
|
||||
- [ ] Unguessable, non-recycled `host_id`/`session_id`; reattach re-validated against account.
|
||||
- [ ] Global + per-host revocation that kills live tunnels within seconds.
|
||||
- [ ] Authenticated WS handshake (retain Origin/CSWSH **and** require a capability token).
|
||||
- [ ] Secrets in a manager/HSM, validated at startup, never logged. No public-internet exposure of raw host ports.
|
||||
|
||||
**HIGH — before scaling past a handful:** per-tenant rate limits/quotas (sessions, bytes, enrollments); SPIFFE-style short-lived auto-rotated host certs; immutable control-plane audit log with cross-tenant-crossing alerts and zero payload logging; step-up auth *before opening a session* (not just at login); signed/pinned agent auto-update; least-privilege agent; MFA-gated time-boxed operator access; "assume relay compromised" IR runbook.
|
||||
|
||||
**MEDIUM — maturity:** SOC 2 Type II; third-party pentest targeting tenant crossing + E2E MITM-by-malicious-relay; client-side preview rendering to recover the manage-page UX; login/session anomaly detection; published verifiable security-model doc.
|
||||
|
||||
**The one-line upgrade to the base app's philosophy:** "the server is a byte-shuttle" is the *right* instinct to carry into the relay — but upgrade it from **byte-shuttle to ciphertext-shuttle.** The relay should move opaque, authenticated, per-tenant-routed blobs and know as little as physically possible about what's inside.
|
||||
|
||||
---
|
||||
|
||||
## 5. MVP & codebase impact
|
||||
|
||||
**The MVP demo IS the pitch:** *"I'm at a café, I open `alice.term.yourdomain.com` on my phone, type my token, and I'm in the shell running on my laptop at home — and I never configured a router, a VPN, or a static IP."* Everything else is hardening.
|
||||
|
||||
**Thinnest end-to-end system (v0.8, single-node relay):**
|
||||
1. **One relay node** — one small VPS, wildcard DNS `*.term.yourdomain.com`, wildcard TLS (LetsEncrypt DNS-01 or terminate at Cloudflare in front). One process, no clustering.
|
||||
2. **Flat account table** (SQLite): `{ accountId, subdomain, agentToken(hashed), clientToken(hashed) }`. Two tokens because agent and browser authenticate on different legs. Manual provisioning is fine.
|
||||
3. **Host-agent** (~300–600 LOC) — outbound WSS to relay, present `agentToken`, register `subdomain`, hold open with the backoff logic you already ship, open loopback streams to `:3000`.
|
||||
4. **Data plane** — relay routes by subdomain to the agent's control connection, asks it to open a new logical stream; agent dials `ws://127.0.0.1:3000` (unmodified web-terminal) and splices. Relay sees opaque stream-tagged frames, never terminal semantics.
|
||||
5. **Browser auth** — relay demands `clientToken` at the subdomain root, sets a signed cookie, then upgrades. **This is the auth the base app deliberately never had, and it must live at the relay edge** — once you're on the public internet, Origin-checking stops CSWSH but not "a stranger with the URL."
|
||||
|
||||
Total: **1 VPS + wildcard DNS/TLS + ~300-line agent + ~500-line relay + a 4-column table + a password gate.** Two legit shortcuts: **wrap `frpc` as the agent** (fastest to demo, zero transport code) or **hand-roll WS-over-WS multiplex** (keeps everything in your TS stack, gives you the auth/metering hooks frp won't). **Decision: prototype on frp to prove demand, port to native multiplex once you charge money.**
|
||||
|
||||
**Codebase impact — what changes in `src/`:** *Nothing structural.* `src/server.ts` byte-shuttle, `wss.handleUpgrade`, PTY/session decoupling, ring-buffer replay, `src/session/*`, `src/protocol.ts`, `src/config.ts` — **100% unchanged.** The relay path terminates at `ws://127.0.0.1:3000` — the agent is *just another local WS client*. The **one subtlety** (`src/server.ts` Origin check, ~`:646`): the agent connects from loopback presenting a loopback-allowed Origin, or set `ALLOWED_ORIGINS` to include the subdomain at install — net change to `server.ts` is **a few lines, ideally zero.** Frontend (`public/main.ts`, same-origin scheme-following M6) needs **no change** — served from `alice.term...`, the browser connects same-host and the relay proxies. **New components live in new top-level packages/repos, NOT edits to any task's `Owns:`:** `agent/` (host-agent) and `term-relay/` (control+data plane + account store). Keep the immutability discipline from `src/session` — routing/account records are immutable snapshots swapped atomically, not mutated in place.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cost, distribution & competitive framing
|
||||
|
||||
**Cost physics: interactive terminal is cheap; persistence and previews are the tax.** A human typing + reading a shell is kilobytes/minute — a power user's *interactive* month is well under 1 GB. The real cost is **always-on connections**: every agent holds a persistent socket 24/7, and v0.4 **live-preview thumbnails stream even when idle** if a manage-page is open. **Budget the relay by concurrent connections (memory/FD), not GB.** Host on **Hetzner/bare VPS, not Fly/AWS** — Hetzner ~€5.49/mo with 20 TB included / ~€1/TB overage; egress on Fly/AWS is the hidden bill-killer for a persistent-bandwidth product. One Hetzner CX node fronts hundreds of idle agents comfortably; egress is a rounding error dominated by preview streams. **Product mitigations baked in:** pause preview streams when no viewer is focused (extend the existing attachment-gating in the session model), and idle-disconnect viewer legs while keeping the agent control connection alive.
|
||||
|
||||
**Pricing — offer both:**
|
||||
- **Free self-hosted relay (open-source relay + agent)** — competitive necessity. frp/Cloudflare-Tunnel/Tailscale all have free self-host; a closed relay with no free path is dead on arrival. Captures goodwill + funnel.
|
||||
- **Hosted SaaS** — the revenue. Tier on the two real cost drivers (**paired hosts** + **concurrent viewers/preview**), not bandwidth: *Free* (1 host, 1 viewer, random subdomain, no preview); *Personal ~$6–8/mo* (1–2 hosts, custom subdomain, preview, 30-day persistence — anchored to ngrok Personal $8 / Tailscale $6–8); *Pro ~$20/mo* (up to ~5 hosts, multiple viewers, IP allow-list — ngrok Pro is $20); *Team/Enterprise quote* (SSO, audit log, dedicated relay — Teleport's $12–40/resource + $50k self-host floor is your *ceiling*, not competitor). **Margin trap:** a customer leaving a 4-thumbnail manage page open on a big monitor 24/7 streams continuously — the preview-pause mitigation above is a *business* control, not just a UX one.
|
||||
|
||||
**Distribution (agent install), ranked by MVP-fit:** (1) **`npx web-terminal-agent pair <code>`** — customers already `npm install` the app; zero new toolchain (MVP default); (2) **single static binary** (`bun --compile`/`pkg`, or rewrite in Go like frpc) — one `curl | sh`, trivial `systemd`/`launchd` service (do at v0.9 once the protocol stabilizes); (3) Homebrew/Docker for power users. **Pairing UX is make-or-break** — the whole promise is "no networking setup," so onboarding must match: sign up → short pairing code → `npx web-terminal-agent pair ABCD-1234` (exchanges code for durable token, installs as a service, connects) → dashboard flips to "● online." **KPI: first-shell-in-under-2-minutes.**
|
||||
|
||||
**Differentiation (you compete with "what a dev reaches for to get their phone into their home shell," not with tunnel primitives):**
|
||||
- **vs ngrok** — ngrok is a generic tunnel; you'd still run web-terminal behind it and hand-roll auth. You sell **the terminal + tunnel + phone UI as one paired appliance.** ngrok's tightening 2026 per-endpoint pricing is actively pushing devs to alternatives — tailwind.
|
||||
- **vs Cloudflare Tunnel** — free and excellent, but raw infra the customer must configure (`cloudflared` + DNS + Access). Your wedge: **"you configure nothing."** (You can even run CF in front of your relay for TLS/DDoS.)
|
||||
- **vs Teleport** — the enterprise PAM ceiling ($12–40/resource, $50k+ self-host). **Don't fight it** — overkill/unaffordable for your walk-away vibe-coder persona; it's your future enterprise-tier *roadmap*, not your competitor.
|
||||
- **vs "just use Tailscale"** — your **sharpest, most honest wedge.** Tailscale makes the *customer* the network admin (install on laptop *and every phone*, understand the tailnet). Your pitch: **"the viewing device needs nothing but a browser and a URL"** — zero install on the phone in your hand, no mesh to reason about. That café/borrowed-device/hand-it-to-a-teammate moment is exactly what Tailscale *can't* do. **Lead every marketing sentence with it.**
|
||||
|
||||
**One-line positioning:** *"Ngrok's zero-config reachability + a real terminal + a phone UI, paired in one command — so you (and only you) can reach your own machines' Claude Code sessions from any browser, with no VPN and nothing to install on the device in your hand."*
|
||||
|
||||
---
|
||||
|
||||
## 7. Phased roadmap
|
||||
|
||||
| Phase | Scope | Effort |
|
||||
|---|---|---|
|
||||
| **v0.8 — MVP single-node relay** | 1 VPS, wildcard DNS+TLS, host-agent (wrap `frpc` or native WS-over-WS), 4-col account table (manual provisioning), password gate + signed cookie at the relay edge, `npx …agent pair`. Prove the café demo. | **1–2 wks** (small on frp; ~2 wks native). Biggest risk: TLS/wildcard + agent reconnect edge cases. |
|
||||
| **v0.9 — Multi-tenant SaaS** | Native WS multiplex (drop frp), self-serve signup + dashboard, pairing-code flow, per-account subdomain issuance, plan tiers + metering (hosts/viewers), preview-pause-when-unfocused, single static-binary agent + service install, basic rate-limit/abuse controls. | **3–5 wks.** The account/billing/dashboard plane is most of it; transport is small. |
|
||||
| **v0.10 — Security hardening** | Real accounts (passkey/OIDC) replacing shared `clientToken`, per-session capability tokens, IP allow-list, audit log of every attach, agent cert rotation, **E2E encryption phone↔agent (relay routes ciphertext by subdomain only)**, third-party pentest. | **4–8 wks.** **E2E is the hard, non-negotiable item** — the difference between "one bad day" and "every customer's shell." Do it **before** scaling users, not after. |
|
||||
| **v0.11+ — Scale** | Multiple relay nodes + geo/subdomain-sharded routing, horizontal control plane (Postgres/Redis), connection-count autoscaling, HA/failover, per-tenant observability, SLA + status page, optional enterprise tier. | **Ongoing.** Only when concurrent-agent FD/memory limits force it (hundreds→thousands). **YAGNI until the socket count forces it.** |
|
||||
|
||||
**Sequencing opinion:** ship v0.8 on frp *this month* — the demo, not the architecture, proves demand. But **gate paid signups (v0.9) behind the auth edge, and gate any growth push behind E2E (v0.10).** The base app's "no auth, no isolation" non-goals become CRITICAL the instant strangers can reach the port; a relay that can read every tenant's shell is a single catastrophic blast radius. Treat "keep the relay a dumb (cipher)pipe" as a *security* property — it can't leak what it can't parse — not just a simplicity one.
|
||||
|
||||
---
|
||||
|
||||
## 8. DECISIONS FOR USER
|
||||
|
||||
1. **Reuse a tunnel vs bespoke relay?** → **Reuse.** Prototype v0.8 on **frp** (or Cloudflare Tunnel purely for a throwaway demo), own the substrate on **frp** long-term (free, hooks, subdomain routing), port to native WS-multiplex by v0.9 when you charge money. Do **not** hand-roll NAT-traversal/mux, and do **not** build the *business* on Cloudflare/ngrok/inlets (being "the bridge" is your value prop — you must own the relay). *Open sub-question: adopt Teleport Community wholesale to inherit enterprise auth/isolation/audit, accepting its weight — or stay lightweight and build the tenancy glue yourself? Recommendation leans lightweight for the consumer persona, but read Teleport's source first.*
|
||||
|
||||
2. **Relay sees plaintext vs E2E?** → **The pivotal call.** Recommend **E2E (sshx model)** before scaling users. Plaintext is cheaper and keeps server-side previews/search/recording but makes you a live secret-exfiltration honeypot with an uninsurable breach posture. **Consciously accept the cost:** server-rendered previews die (move client-side), server search/recording die; replay + multi-device mirror survive. Decide this *before* v0.10, ideally design for it from v0.8.
|
||||
|
||||
3. **Subdomain vs token routing?** → **Per-tenant subdomain** as the routing key (own browser origin per tenant → cookie/localStorage/`sessionId`/CORS isolation for free). A capability *token* still authorizes the specific host+rights on top; the two layers coexist (subdomain = which host, token = may-you + which-PTY). Avoid shared-origin path-prefix routing.
|
||||
|
||||
4. **Self-host-relay product vs hosted SaaS?** → **Both.** Open-source the relay+agent (free self-host = competitive necessity + funnel) **and** run a hosted SaaS (the revenue), priced on **paired hosts + concurrent viewers**, not bandwidth, on **Hetzner-class infra**.
|
||||
|
||||
5. **Auth model?** → **Passkeys (WebAuthn) primary** for humans (TOTP fallback, never SMS; OIDC SSO for teams) + **per-host Ed25519 keys via single-use pairing codes, mTLS with SPIFFE-style short-lived certs** for agents. MVP may start with a shared `clientToken` gate but must reach real accounts before paid signups. **Step-up auth before opening a session**, not just at login.
|
||||
|
||||
6. **Who owns liability for bridged shells?** → Decide and put it in the contract. The agent runs a shell with the customer's own privileges — **that access is inherently the customer's accepted risk.** Your liability is the *relay as injection vector*, which **E2E bounds** to "we can disrupt connectivity but cannot read or inject." You cannot make this statement truthfully under the plaintext option — which is itself an argument for E2E. Pair it with a hard revocation control, audited operator access, and (at business scale) SOC 2 + a published, verifiable security-model doc.
|
||||
173
docs/EXPLORE_REMOTE_ACCESS.md
Normal file
173
docs/EXPLORE_REMOTE_ACCESS.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Using web-terminal off your LAN — exploration
|
||||
|
||||
> Exploration only (not a committed plan). Produced by a multi-agent research pass
|
||||
> (codebase-fit + security-bar verified against the repo; approach-landscape + relay-design
|
||||
> synthesized). The crux is **auth**, not transport.
|
||||
|
||||
## 1. Why a bridge/rendezvous is needed (the NAT reality)
|
||||
|
||||
The host sits behind NAT — very likely CGNAT — so it has **no public inbound reachability**:
|
||||
nothing on the internet can open a connection *to* it, and you can't reliably port-forward
|
||||
through carrier-grade NAT. The fix is always the same shape: the host makes an **outbound**
|
||||
connection to an always-reachable middle point, your remote device connects to that same
|
||||
middle point, and it splices the two together. That "middle point" is your bridge. The only
|
||||
real question is **who runs it** — you (self-hosted relay / VPS) or a product built for this
|
||||
(Tailscale coordination+DERP, Cloudflare Tunnel). All three solve NAT identically via an
|
||||
outbound dial; they differ enormously in how much security-critical code *you* end up owning.
|
||||
|
||||
## 2. The real headline: off-LAN makes AUTH mandatory
|
||||
|
||||
> **The app today has zero authentication, by design.** It hands a full interactive shell to
|
||||
> anyone who can reach the port. On the LAN that's fine — the attacker must already be on your
|
||||
> trusted WiFi, and Origin validation blocks the one browser attack (CSWSH). The moment the port
|
||||
> is reachable off-LAN, "already on my WiFi" evaporates and the attacker becomes the entire
|
||||
> internet plus bots that scan every new hostname within minutes. An unauthenticated shell
|
||||
> reachable publicly is remote-code-execution-as-a-service — game over on the first hit.
|
||||
|
||||
Auth was an explicit **v0.1 non-goal** (TECH_DOC §7, CLAUDE.md). Off-LAN changes that decision,
|
||||
independent of transport. Two accepted-on-LAN risks become unacceptable:
|
||||
|
||||
- **No auth anywhere in `src/`** — only Origin checks, rate-limiters, VAPID push secrets exist.
|
||||
- **Unauthenticated info-leak endpoints** — `/sessions`, `/live-sessions`, `/live-sessions/:id/preview`,
|
||||
`/projects` have no Origin guard by design (`src/server.ts:246-250`). `/sessions` returns Claude
|
||||
cwds + first ~120 chars of prompts + resumable session UUIDs; the preview route streams live
|
||||
screen contents. World-readable the instant a port is exposed.
|
||||
|
||||
**Origin validation is not authentication.** A public hostname is guessable; anyone who loads your
|
||||
real URL passes the Origin check. Keep it (still the CSWSH defense) but it gates no attacker who
|
||||
just visits the URL. `isOriginAllowed` (`src/http/origin.ts:22-53`) matches scheme+host+port
|
||||
strictly, default-denies `undefined`.
|
||||
|
||||
**Frame the work as: "add an auth/identity layer in front of the shell." Transport is secondary —
|
||||
and the best transports hand you that auth layer for free.**
|
||||
|
||||
## 3. Options spectrum
|
||||
|
||||
| Option | Reachability | Who runs the middle | Auth story | Setup | Cost | Code changes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **Tailscale tailnet** ⭐ RECOMMENDED | Your enrolled devices only; port invisible to internet | Tailscale coord; traffic E2E WireGuard | Device enrollment (SSO-backed) — strong, built-in | Low | Free tier | **None** — just add the MagicDNS origin to `ALLOWED_ORIGINS` |
|
||||
| **Tailscale Funnel** | Public HTTPS URL (anyone with link) | Tailscale edge | **None** — public ingress → back to needing app-auth | Low | Free | Whitelist origin **+ build app-auth** |
|
||||
| **Cloudflare Tunnel + Access** ⭐ (for a public URL) | Public HTTPS URL gated by SSO | Cloudflare edge (terminates TLS → sees bytes) | **Real SSO at edge** (email OTP / Google / GitHub / TOTP) | Low-Med | Free (≤50 users) | Whitelist origin; verify `CF-Access-Jwt-Assertion` |
|
||||
| **Self-hosted relay** (your mental model) | Whatever you allow | **You** (TLS, auth, uptime, abuse) | **You build all of it** | **High** | Small VPS | Whitelist origin + app-auth + relay must enforce Origin |
|
||||
| **Naked port-forward** ❌ DO NOT | Public IP | You + router | App-auth on bare internet | Med | Highest risk | Violates CLAUDE.md "never port-forward" |
|
||||
|
||||
**RECOMMENDED default: Tailscale.** Already the deployment CLAUDE.md/TECH_DOC §7 names; solves
|
||||
**auth + encryption + NAT in one**; adds **zero security code** to a byte-shuttle designed to have
|
||||
none; the internet can't even see the port to scan it. Config-only (`ALLOWED_ORIGINS` merge at
|
||||
`src/config.ts:214-223`).
|
||||
|
||||
**If you insist on a self-hosted bridge:** Cloudflare Tunnel and Tailscale *are* "the server in the
|
||||
middle," done correctly, with TLS/auth/DoS-protection you'd otherwise reinvent. Build your own only
|
||||
if you refuse both a VPN client and a third-party edge.
|
||||
|
||||
## 4. Minimum security bar (regardless of transport) — the crux
|
||||
|
||||
Every item must hold **before any off-LAN exposure**:
|
||||
|
||||
1. **Auth in front of the shell** — proxy identity (Tailscale/CF Access) or app-login. **No
|
||||
unauthenticated path to the WS upgrade** (`src/server.ts:635`).
|
||||
2. **TLS / wss on every off-LAN hop** — no plaintext `ws://` leaving the host (keystrokes carry
|
||||
passwords/API keys/Claude tokens). Frontend already picks `wss` on HTTPS (`public/terminal-session.ts:44-47`,
|
||||
the M6 fix) → a TLS-terminating proxy makes this automatic, no code change.
|
||||
3. **Keep Origin validation, whitelist the new hostname** — as `https://host` **with no `:443`**
|
||||
(`isOriginAllowed` compares `.port` strictly; standard ports parse to empty string, `origin.ts:50`).
|
||||
Forget it → every WS handshake 401s and every state-changing POST 403s.
|
||||
4. **No bare port-forward** — outbound tunnel/overlay only.
|
||||
5. **Brute-force protection on the auth endpoint** — reuse the per-IP sliding-window limiter
|
||||
(`src/server.ts:104`, e.g. 5/min/IP), **`crypto.timingSafeEqual` (never `===`)**, generic errors.
|
||||
6. **Secrets in env only**, validated at startup, never hardcoded/logged (VAPID precedent); hash
|
||||
tokens at rest, file mode 0600 (`src/push/subscription-store.ts:21`); scrub logs (`push-service.ts:148`).
|
||||
7. **Auth audit log** — login success/fail + IP + timestamp; every WS attach; every approve/reject.
|
||||
8. **Token/session expiry + revocation path.**
|
||||
9. **Stay single-operator** — this is "prove it's me/my device," NOT multi-tenant isolation.
|
||||
10. **Gate the info-leak endpoints** off-LAN (`src/server.ts:246-250`).
|
||||
|
||||
Two gotchas that bite:
|
||||
- **Browsers can't set custom headers on a WS handshake.** A WS token rides as a query param on the
|
||||
`/term` URL **or** an **HttpOnly cookie** validated in the upgrade handler. Query-string tokens leak
|
||||
via logs/history/Referer → **prefer a cookie set by `POST /login`**.
|
||||
- **`POST /login` needs the same `requireAllowedOrigin` CSRF guard** (`src/server.ts:332-341`) as other
|
||||
state-changing routes.
|
||||
|
||||
## 5. If self-hosted relay: architecture sketch
|
||||
|
||||
```
|
||||
Browser ──wss──▶ Relay (your VPS, public IP, TLS + auth) ◀──persistent outbound wss── Host (web-terminal)
|
||||
│ splices byte streams verbatim │
|
||||
remote device rendezvous / control behind NAT/CGNAT
|
||||
```
|
||||
|
||||
The host holds a persistent **outbound** WS/TCP control connection to the relay; a remote client
|
||||
connects (and authenticates **at the relay**); the relay multiplexes a channel and pipes `/term`
|
||||
frames byte-for-byte between the two.
|
||||
|
||||
**Why the app survives unchanged:** the session model is fully transport-agnostic. `sessionId` is
|
||||
client-generated and carried in the first `attach` frame (`src/protocol.ts`); PTY lifecycle is
|
||||
decoupled from WS lifecycle; ring-buffer replay and multi-client mirror operate on the byte stream
|
||||
**after** the handshake. A relay that forwards bytes verbatim is invisible to this layer.
|
||||
`BIND_HOST=0.0.0.0` (`src/config.ts:248`) already accepts a co-located agent's loopback forward.
|
||||
|
||||
**Relay-specific traps (why it earns "High" effort):**
|
||||
- The relay **collapses the apparent client**: `req.socket.remoteAddress` becomes the relay's IP, and
|
||||
unless the relay **forwards the browser's real `Origin` verbatim**, the check at `src/server.ts:645-651`
|
||||
sees the relay's origin. So either the relay forwards the true Origin, **or the relay itself becomes
|
||||
the CSWSH enforcement point**. You can't have neither.
|
||||
- You now own the relay's **TLS, auth/pairing, abuse control, DoS protection, uptime**.
|
||||
|
||||
**Build-vs-reuse verdict: DON'T hand-roll.** If you truly want self-hosted, put a proven reverse-tunnel
|
||||
between host and VPS — **`cloudflared` (self-hostable), `frp`, or `chisel`** — and terminate TLS +
|
||||
enforce auth at the VPS (Caddy/nginx + auth, or an identity proxy). A bespoke Node relay re-implements
|
||||
WebSocket multiplexing, framing, TLS, and auth for no benefit. **Effort:** reuse-a-tunnel ≈ a day of
|
||||
ops + the same app-auth as Option C; **bespoke relay ≈ 1-2 weeks** + an indefinite security-maintenance tail.
|
||||
|
||||
**Minimal app changes for any relay/tunnel path:**
|
||||
- Whitelist the public origin via env merge — `ALLOWED_ORIGINS=https://<name>` (`src/config.ts:214-223`). Config only.
|
||||
- Add the auth gate: HTTP middleware at `src/server.ts:229` (before `express.static` :243) and a
|
||||
token/cookie check inside the upgrade handler **right after the Origin check :646, before
|
||||
`wss.handleUpgrade` :654**. Auth enforced *before* `handleUpgrade` → the post-handshake stream,
|
||||
ring-buffer replay, and multi-client mirror are never touched. All invariants intact.
|
||||
|
||||
## 6. Recommended path (phased) — v0.8
|
||||
|
||||
**Phase 0 — Tailscale (do first; ship today).** Install Tailscale on host + your devices; add the
|
||||
MagicDNS origin to `ALLOWED_ORIGINS`; optionally Tailscale Serve for TLS/wss.
|
||||
- **Code changes: none** (config only). Outcome: "usable off-LAN from my own devices," fully solved,
|
||||
threat model kept honest (only enrolled devices reach the port). **For most of the stated use case,
|
||||
you can stop here.**
|
||||
|
||||
**Phase 1 — Off-LAN safety rail (small).** A `REMOTE_MODE=1` flag (`src/config.ts:245`) that **refuses
|
||||
to start unless TLS + an auth gate are configured**, so LAN-only stays the safe default and off-LAN
|
||||
can't be enabled by accident. Move the info-leak read routes behind the gate when remote mode is on.
|
||||
|
||||
**Phase 2 — App-level auth gate** (needed for a public URL without SSO, i.e. Funnel/self-hosted relay;
|
||||
worthwhile as defense-in-depth behind CF Access):
|
||||
- `POST /login` verifying an env secret with `crypto.timingSafeEqual`, rate-limited (`src/server.ts:104`),
|
||||
guarded by `requireAllowedOrigin`, setting an **HttpOnly+Secure+SameSite=Strict** cookie with expiry.
|
||||
- Cookie validated in the upgrade handler at `src/server.ts:646` (before `handleUpgrade`) and by an HTTP
|
||||
middleware at `src/server.ts:229`.
|
||||
- New `AUTH_TOKEN`/session config in `loadConfig`. Start single shared token → per-device revocable
|
||||
tokens (hashed table + labels) if needed. Full TDD + brute-force + rotation tests.
|
||||
- **Code changes: Medium-High.** This is the security-critical bit — why Phase 0 is so attractive.
|
||||
|
||||
**Phase 3 (optional) — Public URL for un-enrollable devices.** Prefer **Cloudflare Tunnel + Access**
|
||||
(SSO at edge, ≤50 users free) over a relay; verify `CF-Access-Jwt-Assertion` so a tunnel-bypass ≠
|
||||
instant shell. Self-hosted relay only if you reject both a VPN client and Cloudflare (still needs Phase 2).
|
||||
|
||||
## 7. Decisions for the user
|
||||
|
||||
1. **Whose devices?** Only devices *you own* (→ **Tailscale**, zero code) — or a device you can't enroll
|
||||
(→ **Cloudflare Tunnel + Access**, or Funnel/relay + app-auth)? *This one answer collapses most of the tree.*
|
||||
2. **Delegate auth to an identity proxy, or build it in the app?** Delegating adds no security-critical
|
||||
code to a byte-shuttle designed to have none (favored by KISS/YAGNI for a single operator).
|
||||
3. **Managed rendezvous vs self-hosted relay?** Cloudflare/Tailscale *are* the middle server, with
|
||||
TLS/auth/abuse-control included. Self-hosting means owning all of that + uptime.
|
||||
4. **Trust the proxy with plaintext?** Cloudflare terminates TLS at its edge (can see terminal bytes).
|
||||
Tailscale is **end-to-end WireGuard** — if you don't want a third party seeing keystrokes, Tailscale wins.
|
||||
5. **If app-auth is built: credential model?** Single shared token (simplest) → per-device revocable
|
||||
tokens (real revocation) → SSO/TOTP (genuine 2FA). Pick the start and whether 2FA is in scope now.
|
||||
|
||||
**Bottom line:** the instinct ("I need a bridge") is half-right about NAT and wrong about building it —
|
||||
the bridge already exists as a product. Start with **Tailscale (Phase 0, zero code)**; it solves NAT,
|
||||
TLS, and auth together and is already the project's recommended deployment. Reach for a public URL
|
||||
(Cloudflare Tunnel + Access) or a self-hosted relay only if you must open it on a device you can't
|
||||
enroll — and there, **auth-before-`handleUpgrade` is non-negotiable**.
|
||||
992
docs/PLAN_RELAY_AGENT.md
Normal file
992
docs/PLAN_RELAY_AGENT.md
Normal file
@@ -0,0 +1,992 @@
|
||||
# P2 — Host Agent (`agent/`) — Implementation Plan
|
||||
|
||||
> **Plan of the 6** (see [`PLAN_RELAY_INDEX.md`](./PLAN_RELAY_INDEX.md) — the coordination
|
||||
> point / analog of [`src/types.ts`](../src/types.ts)). This plan owns the **`agent/` package only**.
|
||||
> **Source of intent**: [`EXPLORE_RELAY_SERVICE.md`](./EXPLORE_RELAY_SERVICE.md) §0 LOCKED DECISIONS
|
||||
> (AUTHORITATIVE, CLOSED) + §3 host-agent lifecycle + §6 distribution.
|
||||
> **Style**: [`PLAN.md`](./PLAN.md) / [`PLAN_VOICE_COMMANDS.md`](./PLAN_VOICE_COMMANDS.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 (incl. security/negative), per-task security notes.
|
||||
> **All FROZEN SHARED CONTRACTS are cited by INDEX §-number and NEVER redefined here** — the agent
|
||||
> `import`s them read-only from `relay-contracts/`.
|
||||
> `PROGRESS_LOG.md` is **orchestrator-only** (subagents return a ready-to-paste entry, G1).
|
||||
|
||||
---
|
||||
|
||||
## 0. Scope & non-scope
|
||||
|
||||
**In scope (the `agent/` package, EXPLORE §3 "Host-agent lifecycle" + §6 "Distribution"):**
|
||||
|
||||
1. **Enrollment / pairing redemption** — INDEX **§4.5**: `npx web-terminal-agent pair ABCD-1234` generates
|
||||
an **Ed25519 keypair LOCALLY** (private key never leaves the host, INV4), redeems a **single-use,
|
||||
short-TTL** pairing code, receives the frozen §4.5 `EnrollResult`
|
||||
`{ host_id, subdomain, cert, caChain, hostContentSecret }` (FIX 3 — the last field wrapped to the agent
|
||||
identity, unwrapped + stored locally for recoverable replay).
|
||||
2. **Outbound dial + mTLS** — `wss://relay/agent` on 443 (CGNAT-friendly, EXPLORE §3), mutually
|
||||
authenticated with the per-host key + short-lived **auto-rotating** cert (INV14).
|
||||
3. **Registration** — relay writes `route:{host_id}` in Redis (P3 side); the agent proves key
|
||||
possession and holds the link.
|
||||
4. **Holding the §4.1 mux tunnel** — one physical outbound `wss://` carrying N sessions × M mirrored
|
||||
clients + heartbeat; frame demux/encode via the **frozen §4.1 codec** (imported from
|
||||
`relay-contracts/`, never re-implemented).
|
||||
5. **Forwarding logical streams to the UNCHANGED web-terminal** at `ws://127.0.0.1:3000` — one
|
||||
`OPEN` ⇒ one fresh loopback socket, replaying the real browser `Origin` (§4.1 `MuxOpen.originHeader`).
|
||||
6. **Agent-side E2E endpoint** — INDEX **§4.4**: the host side of the handshake (`host_hello`, transcript
|
||||
`sig` under the enrollment key) yielding a **`HandshakeResult` with direction-split `DirectionalKeys`
|
||||
(`c2h`/`h2c`)** (FIX 2 — **no** single `sessionKey: CryptoKey`), driven by P4's `createHostHandshake`
|
||||
with the **injected** device-auth-proof verifier (FIX 6b); per-stream **synchronous** `sealFrame`/
|
||||
`openFrame` over `AeadKey`; and — distinct from live `h2c` frames — sealing every **replay-bound**
|
||||
host→client output under the recoverable `K_content` via `sealReplayFrame` (FIX 3). The agent wires the
|
||||
**P4 `relay-e2e/` crypto core** (imported, not written here). The agent is one E2E endpoint; plaintext
|
||||
exists **only on the customer's own machine**.
|
||||
7. **Reconnection / backoff** — reuse the **1/2/4…cap-30s** policy the base app already ships
|
||||
(EXPLORE §3; mirrors `public/` reconnect), with jitter; 15s heartbeat miss ⇒ tunnel dead ⇒ reconnect.
|
||||
8. **Distribution** — `npx web-terminal-agent pair` (MVP) → single static binary (`bun --compile`);
|
||||
**install-as-service** (launchd on macOS, systemd on Linux).
|
||||
|
||||
**Explicitly NOT in scope (owned by other plans — do not touch):**
|
||||
- The **§4.1 frame codec itself**, stream-id allocation, per-stream flow-control *protocol*, and the
|
||||
relay data plane → **P1 TRANSPORT** (`term-relay/`). The agent is a **consumer** of §4.1.
|
||||
- **Account/host registries, pairing-code issuance, subdomain assignment, the mTLS CA/cert signing,
|
||||
routing table** → **P3 CONTROL PLANE**. The agent submits a CSR and *installs* the returned cert.
|
||||
- **The E2E crypto primitives** (`sealFrame`/`openFrame`, handshake state machine, HKDF) → **P4
|
||||
`relay-e2e/`**. The agent wires them into its splice.
|
||||
- **Capability-token issuance/verification, human auth, the CI cross-tenant tripwire, revocation
|
||||
policy/DB** → **P5**. The agent only *reacts* to revocation (tears the tunnel down) and never verifies
|
||||
capability tokens (the relay authorized the stream before it reached the agent — §4.1 OPEN note).
|
||||
- **Browser UI, client-side preview render** → **P6**.
|
||||
- **`src/` and `public/`** — byte-for-byte unchanged. The single base-app touch-point (`ALLOWED_ORIGINS`
|
||||
gains the subdomain) is applied **by the agent installer at install time as a config/env write**, not
|
||||
a code edit (INDEX §0, EXPLORE §3 "one base-app touch-point").
|
||||
|
||||
---
|
||||
|
||||
## 1. SECURITY INVARIANTS this plan enforces (INDEX §3)
|
||||
|
||||
Per INDEX §3, this plan lists the invariants it owns and **ships a test per owned invariant**:
|
||||
|
||||
| INV | How `agent/` enforces it | Owning task(s) |
|
||||
|---|---|---|
|
||||
| **INV1** — cross-tenant isolation (agent-side **defense-in-depth**; primary enforcement is P1/P5) | `handleOpen` compares `MuxOpen.subdomain` (§4.1) against the agent's own enrolled `AgentConfig.subdomain` **before** dialing loopback; any mismatch ⇒ RST the stream, never dial, audit-log (metadata-only, INV10). The one place the agent can catch a relay that cross-wires host B's OPEN into host A's tunnel. | T8 |
|
||||
| **INV2** — relay handles only ciphertext | Agent seals output → §4.1 `DATA` before it enters the tunnel; the relay only ever sees §4.4 `E2EEnvelope` bytes. Live frames use the ephemeral `DirectionalKeys.h2c`; **replay-bound** output is ALSO sealed under the recoverable `K_content` (`sealReplayFrame`, FIX 3) — still ciphertext to the relay. Plaintext lives only host-local (loopback ↔ agent). Handshake is device-auth-proof-gated (T15 aborts on a forged `ClientHello`, no key derived). | T15, T19 |
|
||||
| **INV4** — no shared secrets; per-host asymmetric identity; DB stores only public keys | Ed25519 keypair generated **locally**; private key persisted `0600`, **never transmitted** (only pubkey + CSR leave); mTLS proves possession without sending the key. | T3, T4, T12 |
|
||||
| **INV5** — no plaintext/secret at rest (agent-local) | Private key + cert stored with restrictive perms; raw pairing code **never written to disk**; no plaintext shell bytes buffered (pure shuttle). | T3, T4, T8 |
|
||||
| **INV9** — secrets in env/secret-manager, validated at startup, never logged | Config validated with Zod at boot; fail-fast on missing/invalid key material; structured logger redacts key/cert/code fields; no `console.log`. | T2, T18 |
|
||||
| **INV11** — no terminal parsing in the agent (ciphertext/byte-shuttle discipline) | The agent moves **opaque** bytes; imports **no** xterm/ANSI parser; even after E2E `openFrame` the decrypted bytes are treated as opaque to the loopback WS. | T7, T8, T15 |
|
||||
| **INV12** — fast revocation kills live tunnels in seconds | On a `'revoked'` `GOAWAY` (distinguished from the graceful `'operatorDrain'`/`'shutdown'` reasons via the **frozen** 3-value `relay-contracts` `GoAwayReason`/`decodeGoAwayReason`, never a local numeric guess; unknown reason ⇒ fail-closed to revoked) or cert-renewal refusal, the agent tears down all streams + the tunnel immediately and stops reconnect for a `revoked` host. | T13, T14 |
|
||||
| **INV14** — mTLS with SPIFFE-style short-lived, auto-rotating certs | Dial uses the client cert; a rotation timer renews **before** expiry via the P3 renewal endpoint; expired/absent cert ⇒ no dial. | T12, T13 |
|
||||
|
||||
**Cross-cutting discipline** (INDEX §0, coding-style): immutable snapshots (tunnel/stream state swapped
|
||||
atomically, never mutated in place); many small files (200–400 lines, 800 hard max); high cohesion / low
|
||||
coupling; TypeScript, `camelCase` fns / `PascalCase` types / string-literal unions over enums; **Zod
|
||||
validation at every boundary** (pairing response, `MuxOpen`, config); explicit error handling (no
|
||||
swallowed errors); **no `console.log`**; TDD 80%+ coverage.
|
||||
|
||||
---
|
||||
|
||||
## 2. Files & ownership (the `agent/` package — disjoint from all other plans)
|
||||
|
||||
```
|
||||
agent/
|
||||
package.json T1 scaffold (bin: web-terminal-agent)
|
||||
tsconfig.json T1
|
||||
vitest.config.ts T1
|
||||
src/
|
||||
cli.ts T5 arg parse → pair | run | status | install | uninstall
|
||||
config/agentConfig.ts T2 AgentConfig Zod schema + load/validate + paths
|
||||
transport/seams.ts T2 W0 shared seam types: WsLike, RevocationState, RevokeReason (cycle breaker)
|
||||
keys/identity.ts T3 Ed25519 keygen, load, fingerprint (enroll_fpr, §4.2)
|
||||
keys/keystore.ts T3 0600 on-disk read/write of key + cert (INV5)
|
||||
enroll/pair.ts T4 §4.5 REDEEM/RETURN client; CSR build
|
||||
enroll/csr.ts T4 PKCS#10 CSR from the Ed25519 key
|
||||
transport/dial.ts T12 outbound mTLS wss dial (client cert)
|
||||
transport/tunnel.ts T7 holds one §4.1 tunnel: demux loop, encode, GOAWAY
|
||||
transport/streamRouter.ts T8 streamId → loopback socket map; OPEN/DATA/CLOSE/RST
|
||||
transport/loopback.ts T8 dial ws://127.0.0.1:3000<path>, replay Origin, splice
|
||||
transport/heartbeat.ts T9 PING/PONG every 15s; miss ⇒ dead
|
||||
transport/backoff.ts T10 1/2/4…cap-30s + jitter reconnection policy
|
||||
transport/flowControl.ts T11 per-stream credit (§4.1 WINDOW_UPDATE) consumer
|
||||
transport/frpScaffold.ts T6 v0.8 frpc-wrap stepping-stone (retired at v0.9)
|
||||
e2e/hostEndpoint.ts T15 §4.4 host side: host_hello + seal/open splice transform
|
||||
e2e/replaySeal.ts T19 §4.4 replay surface: K_content seal of replay-bound output (FIX 3)
|
||||
certs/rotation.ts T13 short-lived cert renewal timer (INV14)
|
||||
lifecycle/revocation.ts T14 GOAWAY/revoke → teardown, refuse reconnect (INV12)
|
||||
service/install.ts T17 dispatch by platform
|
||||
service/launchd.ts T17 macOS plist writer + load/unload
|
||||
service/systemd.ts T17 Linux unit writer + enable/start
|
||||
service/originConfig.ts T17 writes ALLOWED_ORIGINS touch-point (base-app config)
|
||||
dist/buildBinary.ts T16 bun --compile config + npx wrapper
|
||||
log/logger.ts T2 structured redacting logger (no console.log, INV9)
|
||||
test/
|
||||
<mirrors src, one *.test.ts per module>
|
||||
fixtures/ shared test vectors (pairing resp, MuxOpen frames)
|
||||
```
|
||||
|
||||
**Imports (read-only) from other plans' frozen artifacts** — never redeclared here:
|
||||
- `relay-contracts/` (INDEX §4): `encodeMuxFrame`/`decodeMuxFrame`, `MuxFrameHeader`, `MuxOpen`,
|
||||
`HostRecord`, `HostStatus`, pairing Zod schemas + **§4.5 `EnrollResult`** (now carrying `hostContentSecret`,
|
||||
FIX 3); the **frozen §4.4 E2E surface** cited verbatim — `AeadAlg`, `AeadKey` (opaque wrapper, **not**
|
||||
`CryptoKey`), `ClientHello`, `HostHello`, `E2EEnvelope`, `DirectionalKeys` (`c2h`/`h2c`), `HandshakeResult`,
|
||||
`E2ESession`, `createE2ESession`, and the **synchronous** `sealFrame`/`openFrame`; the **§4.4 replay surface**
|
||||
`ReplayKeyParams`/`deriveContentKey`/`sealReplayFrame`/`REPLAY_KDF_INFO` (FIX 3); and the **frozen §4.1**
|
||||
`GoAwayReason` (`'operatorDrain' | 'revoked' | 'shutdown'` string-literal union) + `decodeGoAwayReason`
|
||||
(numeric reason-code → union: `1→operatorDrain · 2→revoked · 3→shutdown`, else throws), consumed **verbatim**
|
||||
by both P1 (sender) and P2/T14 (receiver); the agent never invents any of these shapes or the numeric mapping.
|
||||
- `relay-e2e/` (P4): the crypto **impls** re-exported for §4.4 — `sealFrame`, `openFrame`, `sealReplayFrame`,
|
||||
`deriveContentKey`, and the **host-handshake wiring `createHostHandshake({ verifyDeviceProof, … })`**.
|
||||
**FIX 6b:** the device-auth-proof **verifier is P5-owned** and reaches the agent **injected through P4's
|
||||
`createHostHandshake`** — the agent **does NOT `import { verifyDeviceAuthProof } from 'relay-e2e'`** (that
|
||||
old T15 import was wrong). The verifier is bound to `{ clientEphPub, clientNonce }` (§4.4 `ClientHello`),
|
||||
unified across P2/P4/P5. (`agent/e2e/hostEndpoint.ts` + `agent/e2e/replaySeal.ts` wire these; they contain
|
||||
**no** crypto primitives and **no** local verifier.)
|
||||
|
||||
**Intra-`agent/` shared seam (NOT a cross-plan contract):** `agent/src/transport/seams.ts` (T2, W0) declares
|
||||
`WsLike`, `RevocationState`, `RevokeReason` **once**; T7/T10/T12/T14 import them (kills type drift and the
|
||||
former T10↔T14 task cycle). Frozen cross-plan contracts still live only in `relay-contracts/`.
|
||||
|
||||
`agent/` **owns none of** `relay-contracts/`, `term-relay/`, `relay-e2e/`, `relay-web/`, `src/`, `public/`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Waves & tasks
|
||||
|
||||
```
|
||||
W0 scaffold + boundaries T1 pkg · T2 config+logger+seams · T3 identity/keystore
|
||||
│ (T2 seams.ts freezes WsLike + RevocationState at W0 —
|
||||
│ the cycle-breaker every W2/W3 transport task imports)
|
||||
▼
|
||||
W1 enrollment (v0.8→v0.9) T4 pairing/CSR · T5 CLI · T6 frp scaffold (v0.8 only)
|
||||
│
|
||||
▼
|
||||
W2 native tunnel (v0.9) T7 tunnel · T8 streamRouter+loopback · T9 heartbeat
|
||||
T10 backoff · T11 flow-control (all consume P1 §4.1)
|
||||
│ (T10 consumes only the W0 isRevoked seam — NO edge to T14)
|
||||
▼
|
||||
W3 mTLS + lifecycle (v0.9/v0.10) T12 mTLS dial · T13 cert rotation · T14 revocation/GOAWAY
|
||||
│ (T14 wires T10's loop one-way; depends on T7/T13, not T10)
|
||||
▼
|
||||
W4 E2E endpoint (v0.10) T15 host_hello + seal/open splice · T19 replay-frame sealer (K_content)
|
||||
│ (both consume P4 relay-e2e/; HARD GATE: BLOCKED until the injected
|
||||
│ device-proof verifier + forged-proof vector land — Q#2)
|
||||
▼
|
||||
W5 distribution + accept T16 static binary · T17 service install+origin · T18 acceptance
|
||||
```
|
||||
|
||||
**Wave-ordering integrity (no back-edges):** every task depends **only on earlier or same-wave** tasks.
|
||||
The former T10↔T14 cycle (and the W2→W3 back-edge it implied) is **removed**: the revocation short-circuit
|
||||
is an **injected `isRevoked: () => boolean` seam frozen in W0 `seams.ts`**, so T10 (W2) depends on T2 (W0)
|
||||
alone for it, and T14 (W3) wires T10's loop in a single forward direction (W3 consuming a W2 function is
|
||||
legal; the reverse is not). A builder can topologically order the plan as written.
|
||||
|
||||
**Cross-plan dependency ordering (INDEX §2):** `relay-contracts/` (§4) is program-W0 and must be frozen
|
||||
before this plan's W0 — **including the frozen §4.1 3-value `GoAwayReason`** (`operatorDrain`/`revoked`/`shutdown`) consumed by T14.
|
||||
**P1 TRANSPORT** §4.1 must exist before W2 (in v0.8 the T6 frp scaffold lets W1/W5 proceed against a stub;
|
||||
the native-mux swap in v0.9 is contract-compatible by construction). **P3 CONTROL PLANE** `/enroll` +
|
||||
cert-renewal + `/agent` endpoints must exist before W1 REDEEM (T4) and W3 dial (T12/T13) integrate — unit
|
||||
tests mock them. **P4 `relay-e2e/`** must exist before W4 (T15/T19) — and specifically P5's frozen
|
||||
device-proof verifier **wired into P4's `createHostHandshake`** (FIX 6b — P2 consumes it injected, never
|
||||
imports it) + the cross-plan forged-proof test vector is a **hard pre-W4 gate** (open Q#2), not a soft
|
||||
follow-up.
|
||||
|
||||
---
|
||||
|
||||
### W0 · Scaffold + boundaries
|
||||
|
||||
#### T1 · Package scaffold `[ ]` — v0.8
|
||||
- **Owns**: `agent/package.json`, `agent/tsconfig.json`, `agent/vitest.config.ts`
|
||||
- **Depends**: `relay-contracts/` frozen (INDEX §4). **Parallel-safe**: must be first in this plan.
|
||||
- **Steps (TDD-lite; config task)**:
|
||||
- [ ] `package.json` with `"bin": { "web-terminal-agent": "dist/cli.js" }`; deps: `ws`, `zod`,
|
||||
`relay-contracts` (workspace), `relay-e2e` (workspace, W4-only); dev: `typescript`, `tsx`,
|
||||
`vitest`, `@types/ws`, `@types/node`. **No xterm / ANSI parser dep** (INV11 static guarantee).
|
||||
- [ ] `tsconfig.json` (ES2022, NodeNext, strict, `noEmit` off, outDir `dist`); path alias to
|
||||
`relay-contracts`.
|
||||
- [ ] `vitest.config.ts` with `coverage.include: ['src/**']`, node environment.
|
||||
- [ ] smoke test: `test/scaffold.test.ts` asserts `relay-contracts` imports resolve and **no**
|
||||
transitive dep matches `/xterm|ansi|vt100/` (INV11 tripwire).
|
||||
- **Accept**: `npm --prefix agent test` runs (smoke green); `tsc --noEmit` clean.
|
||||
- **Security**: INV11 dependency tripwire lives here.
|
||||
|
||||
#### T2 · Config + redacting logger + shared seams `[ ]` — v0.8
|
||||
- **Owns**: `agent/src/config/agentConfig.ts`, `agent/src/log/logger.ts`, `agent/src/transport/seams.ts`,
|
||||
`agent/test/agentConfig.test.ts`, `agent/test/logger.test.ts`, `agent/test/seams.test.ts`
|
||||
- **Depends**: T1. **Parallel-safe**: with T3.
|
||||
- **Contracts**:
|
||||
```ts
|
||||
export interface AgentConfig {
|
||||
readonly relayUrl: string // wss://relay.<domain>/agent — validated wss:// only
|
||||
readonly enrollUrl: string // https://<domain>/enroll — https:// only
|
||||
readonly stateDir: string // where key/cert live (default ~/.web-terminal-agent)
|
||||
readonly localTargetUrl: string // ws://127.0.0.1:3000 (the UNCHANGED web-terminal)
|
||||
readonly subdomain: string | null // filled after pairing
|
||||
readonly hostId: string | null // filled after pairing (§4.2)
|
||||
}
|
||||
export const AgentConfigSchema: ZodType<AgentConfig> // wss/https/ws enforced by refine
|
||||
export function loadAgentConfig(env: NodeJS.ProcessEnv, argv: Partial<AgentConfig>): AgentConfig
|
||||
// logger — redacts key/cert/code/token fields; no console.log
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
export function createLogger(level: LogLevel): { log(l: LogLevel, msg: string, meta?: Record<string, unknown>): void }
|
||||
```
|
||||
**`agent/src/transport/seams.ts` — W0 shared injection seams (DEPENDENCY-CYCLE BREAKER).** These are
|
||||
intra-`agent/` seam *types only* (no cross-plan frozen contract lives here — those stay in
|
||||
`relay-contracts/`). Declaring them at W0 lets W2/W3 tasks consume a stable type **without a task-level
|
||||
cycle** (see the T10↔T14 note in §3):
|
||||
```ts
|
||||
// Minimal WS surface the transport layer needs (dial/tunnel/backoff share it; no per-task redeclare).
|
||||
export interface WsLike {
|
||||
send(d: Uint8Array): void
|
||||
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void
|
||||
close(): void
|
||||
}
|
||||
// Revocation seam — T14 IMPLEMENTS it, T10's reconnectLoop only CONSUMES the isRevoked() callback.
|
||||
export type RevokeReason = 'renewal-refused' | 'goaway-revoked' | 'operator'
|
||||
export interface RevocationState { isRevoked(): boolean; markRevoked(reason: RevokeReason): void }
|
||||
```
|
||||
Both `WsLike` and `RevocationState`/`RevokeReason` are **defined once here** and imported by T7/T10/T12/T14;
|
||||
they are **not** redeclared in any W2/W3 task (kills the drift and the cycle at the type level).
|
||||
- **TDD (RED→GREEN)**:
|
||||
- [ ] RED: `relayUrl='http://…'` → throws (only `wss://`); `localTargetUrl` non-loopback host → throws
|
||||
(INV: agent forwards **only** to loopback, never an arbitrary target — anti-SSRF).
|
||||
- [ ] RED: logger given `{ privateKey, cert, pairingCode, agentToken }` meta → asserts those values
|
||||
never appear in emitted string (INV9).
|
||||
- [ ] GREEN: implement with Zod `.refine`.
|
||||
- **Test cases**: valid config parses; missing `relayUrl` → fail-fast; `localTargetUrl=ws://10.0.0.5:3000`
|
||||
→ rejected (loopback-only); `localTargetUrl=ws://127.0.0.1:3000` → ok; logger redaction of each secret
|
||||
field; `debug` meta with a `nonce` key passes through (not a secret); **seams**: `seams.ts` exports
|
||||
`WsLike`, `RevocationState`, `RevokeReason` and is `import`-able with **no runtime dependency** (type-only
|
||||
file — a test asserts the compiled module has no side effects and no `ws`/crypto import), so W2/W3 can
|
||||
depend on it without pulling transport runtime.
|
||||
- **Security**: INV9 (validated at startup, never logged), loopback-only target (anti-SSRF); the seam file
|
||||
is types-only (no secret handling).
|
||||
|
||||
#### T3 · Agent identity + keystore `[ ]` — v0.9 (contract stub usable in v0.8)
|
||||
- **Owns**: `agent/src/keys/identity.ts`, `agent/src/keys/keystore.ts`,
|
||||
`agent/test/identity.test.ts`, `agent/test/keystore.test.ts`
|
||||
- **Depends**: T1, T2. **Parallel-safe**: with T2.
|
||||
- **Contracts** (uses `node:crypto` `generateKeyPairSync('ed25519')`; **no** third-party crypto):
|
||||
```ts
|
||||
export interface AgentIdentity {
|
||||
readonly publicKey: Uint8Array // Ed25519 raw pubkey → stored in host registry (§4.2 agent_pubkey)
|
||||
readonly enrollFpr: string // §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by browser E2E TOFU
|
||||
sign(message: Uint8Array): Uint8Array // uses private key IN-PROCESS; key never returned/serialized off-host
|
||||
}
|
||||
export function generateIdentity(): AgentIdentity // private key held in-memory only
|
||||
export function computeEnrollFpr(publicKey: Uint8Array): string
|
||||
// keystore — persists ONLY to stateDir with 0600 (INV4/INV5)
|
||||
export interface Keystore {
|
||||
saveIdentity(id: AgentIdentity): void // writes private key PEM 0600 + pubkey
|
||||
loadIdentity(): AgentIdentity | null
|
||||
saveCert(certPem: string, caChainPem: string): void // 0600
|
||||
loadCert(): { certPem: string; caChainPem: string } | null
|
||||
saveContentSecret(secret: Uint8Array): void // 0600 — FIX 3: the §4.5 hostContentSecret, UNWRAPPED with the enrollment key
|
||||
loadContentSecret(): Uint8Array | null // input to §4.4 deriveContentKey (ReplayKeyParams); never logged/egressed
|
||||
}
|
||||
export function openKeystore(stateDir: string): Keystore
|
||||
```
|
||||
- **TDD (RED→GREEN)**:
|
||||
- [ ] RED: `generateIdentity()` twice → distinct pubkeys; `sign`/verify round-trips against
|
||||
`crypto.verify`.
|
||||
- [ ] RED: `computeEnrollFpr` is deterministic + matches the §4.2 `enroll_fpr` derivation P4/P6 pin.
|
||||
- [ ] RED: `saveIdentity` then read the file mode → **`0600`** (INV5); a world-readable dir triggers
|
||||
a hard error, not a silent write.
|
||||
- [ ] RED (security): **assert there is no code path that serializes the private key into any network
|
||||
payload** — `AgentIdentity` exposes only `publicKey`, `enrollFpr`, `sign` (INV4). Grep test:
|
||||
no export/return of raw private-key bytes.
|
||||
- [ ] GREEN.
|
||||
- **Test cases**: keygen uniqueness; sign/verify; fpr determinism + cross-checks fixture P4/P6 will pin;
|
||||
keystore perms `0600`; reload identity yields same pubkey/fpr; corrupt key file → typed error;
|
||||
**negative**: no API returns/serializes the private key; **FIX 3**: `saveContentSecret`/`loadContentSecret`
|
||||
round-trip the `hostContentSecret` bytes, file mode `0600`, and the secret never appears in any log line.
|
||||
- **Security**: **INV4** (private key never leaves host — this is the load-bearing task), **INV5** (0600 at
|
||||
rest — key/cert **and** the FIX 3 `hostContentSecret`). Cross-check `enroll_fpr` derivation with P4 (§4.4
|
||||
TOFU pin) and P3 (§4.2 storage).
|
||||
|
||||
---
|
||||
|
||||
### W1 · Enrollment (v0.8 skeleton → v0.9 real Ed25519)
|
||||
|
||||
#### T4 · Pairing redemption + CSR `[ ]` — v0.9 (§4.5) — v0.8 ships a token-gate variant
|
||||
- **Owns**: `agent/src/enroll/pair.ts`, `agent/src/enroll/csr.ts`,
|
||||
`agent/test/pair.test.ts`, `agent/test/csr.test.ts`
|
||||
- **Depends**: T3 (identity), T2 (config); **cross-plan**: P3 `/enroll` endpoint (§4.5 BIND/RETURN) —
|
||||
mocked in unit tests. **Parallel-safe**: with T5/T6.
|
||||
- **Contracts** — implements INDEX **§4.5** steps 2–5 verbatim (agent side), validating the response with
|
||||
the **frozen pairing Zod schema** from `relay-contracts/`:
|
||||
```ts
|
||||
// §4.5 step 3 REDEEM: POST /enroll { code, agentPubkey, csr } (raw code NEVER persisted — INV5)
|
||||
// §4.5 step 5 RETURN → the FROZEN `EnrollResult` — IMPORTED from relay-contracts, NOT redeclared here (FIX 3).
|
||||
// Frozen shape (INDEX §4.5): { hostId, subdomain, cert, caChain, hostContentSecret } — the last field is
|
||||
// the FIX 3 host-scoped secret, WRAPPED to this agent's enrolled Ed25519 identity (agent_pubkey) by P3 at BIND.
|
||||
import type { EnrollResult } from 'relay-contracts'
|
||||
export function buildCsr(id: AgentIdentity, subject: string): string // PKCS#10 over the Ed25519 key
|
||||
export function redeemPairingCode(
|
||||
enrollUrl: string, code: string, id: AgentIdentity, ks: Keystore, fetchImpl?: typeof fetch,
|
||||
): Promise<EnrollResult>
|
||||
// On success (FIX 3): UNWRAP EnrollResult.hostContentSecret with the enrollment private key (in-process, INV4)
|
||||
// and persist the UNWRAPPED secret 0600 via ks.saveContentSecret (T3) — the WRAPPED bytes are never stored,
|
||||
// the unwrapped secret is never logged or re-sent (INV5/INV9). Feeds §4.4 deriveContentKey (T19).
|
||||
```
|
||||
- **TDD (RED→GREEN)**:
|
||||
- [ ] RED: `redeemPairingCode` sends `{ code, agentPubkey, csr }`; assert the **private key is NOT in
|
||||
the request body** (INV4) — only pubkey + CSR.
|
||||
- [ ] RED: raw `code` is **never written to `stateDir`** and never logged (INV5/INV9).
|
||||
- [ ] RED: malformed/HTTP-error `/enroll` response → typed `EnrollError` (no swallow); response not
|
||||
matching the frozen §4.5 schema → reject (boundary validation).
|
||||
- [ ] RED (single-use): a `409 already-redeemed` from P3 → surfaced as `PairingCodeSpentError`
|
||||
(agent must not retry-spam a spent code).
|
||||
- [ ] RED (**FIX 3**): `EnrollResult.hostContentSecret` (wrapped to the agent identity) is UNWRAPPED with
|
||||
the enrollment private key and stored `0600` via `Keystore.saveContentSecret` (T3); assert the
|
||||
**wrapped** bytes are never persisted and the unwrapped secret is never logged (INV5/INV9).
|
||||
- [ ] GREEN.
|
||||
- **Test cases**: happy path returns the frozen §4.5 shape `{hostId, subdomain, cert, caChain,
|
||||
hostContentSecret}` and keystore stores cert `0600` **and the unwrapped `hostContentSecret` `0600`**; body
|
||||
omits private key; expired-code (`410`) → `PairingCodeExpiredError`; spent-code (`409`) →
|
||||
`PairingCodeSpentError`; schema-mismatch response → rejected; `enrollUrl` non-https → refused (T2 guard).
|
||||
**v0.8 variant**: when no Ed25519 flow is enabled, `redeemPairingCode` degrades to presenting the shared
|
||||
`agentToken` (still hashed server-side by P3) — behind an explicit `EnrollMode = 'token' | 'ed25519'`
|
||||
string-literal union; a test asserts `'ed25519'` is the default from v0.9.
|
||||
- **Security**: **INV4** (only pubkey+CSR leave), **INV5** (raw code not at rest), boundary validation on
|
||||
the enroll response (untrusted external data), single-use respect.
|
||||
|
||||
#### T5 · CLI entrypoint `[ ]` — v0.8 skeleton → extended each wave
|
||||
- **Owns**: `agent/src/cli.ts`, `agent/test/cli.test.ts`
|
||||
- **Depends**: T2, T3, T4 (and later T7/T13/T17 handlers). **Parallel-safe**: with T4/T6.
|
||||
- **Contracts**:
|
||||
```ts
|
||||
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
|
||||
export interface CliArgs { readonly command: CliCommand; readonly code?: string; readonly flags: Readonly<Record<string, string | boolean>> }
|
||||
export function parseArgs(argv: readonly string[]): CliArgs
|
||||
export function runCli(args: CliArgs, deps: CliDeps): Promise<number> // returns process exit code
|
||||
```
|
||||
`runCli` dispatches: `pair <code>` → T4 redeem → keystore + config write → optional T17 install;
|
||||
`run` → T7 tunnel loop; `status` → read keystore/config, print host_id/subdomain/online (no secrets);
|
||||
`install`/`uninstall` → T17. Dependencies injected (`CliDeps`) so tests avoid real network/FS.
|
||||
- **TDD**: RED parse `pair ABCD-1234` → `{command:'pair', code:'ABCD-1234'}`; unknown command → nonzero
|
||||
exit + typed error (no throw to bare stack); `status` prints **no** key/cert material (INV9); missing
|
||||
`code` on `pair` → usage error. GREEN.
|
||||
- **Test cases**: arg parsing matrix; `pair` happy path calls redeem then install (mocked); `status`
|
||||
redaction; unknown command exit code; `run` before pairing → "not enrolled" error (fail-fast).
|
||||
- **Security**: INV9 (status shows no secrets); fail-fast when unenrolled.
|
||||
|
||||
#### T6 · frp scaffold (v0.8 stepping-stone) `[ ]` — v0.8 ONLY (retired at v0.9)
|
||||
- **Owns**: `agent/src/transport/frpScaffold.ts`, `agent/test/frpScaffold.test.ts`
|
||||
- **Depends**: T2, T5. **Parallel-safe**: with T4/T5.
|
||||
- **Rationale (EXPLORE §5 "wrap `frpc` as the agent", INDEX §1 v0.8)**: fastest path to the café demo;
|
||||
wraps `frpc` (child process) presenting the shared `agentToken`, registering `subdomain`, forwarding to
|
||||
`127.0.0.1:3000`. **Explicitly a stepping-stone** — the native mux (T7–T11) replaces it in v0.9; the
|
||||
§4.1 frame path is designed so ciphertext (T15) drops in without reshaping.
|
||||
- **Contracts**:
|
||||
```ts
|
||||
export interface FrpScaffold { start(): Promise<void>; stop(): Promise<void>; onExit(cb: (code: number) => void): void }
|
||||
export function spawnFrpc(cfg: AgentConfig, frpcPath: string): FrpScaffold // generates frpc.toml, spawns child
|
||||
```
|
||||
- **TDD**: RED generated `frpc.toml` has `local_ip=127.0.0.1 local_port=3000`, `subdomain=<cfg>`, tls
|
||||
enabled; child crash → `onExit` fires and (paired with T10) triggers backoff. GREEN with a mocked
|
||||
spawn.
|
||||
- **Test cases**: toml generation; child-exit propagation; **negative**: never forwards to a non-loopback
|
||||
`local_ip`. **Retirement test**: a v0.9 guard test asserts `frpScaffold` is not imported by `run`
|
||||
once `EnrollMode==='ed25519'` (prevents shipping both substrates).
|
||||
- **Security**: loopback-only forward; the shared token is a **v0.8-only** shortcut (INDEX §1 — "must reach
|
||||
real accounts before the first paid signup").
|
||||
|
||||
---
|
||||
|
||||
### W2 · Native mux tunnel (v0.9) — consumes P1 §4.1
|
||||
|
||||
#### T7 · Tunnel holder `[ ]` — v0.9
|
||||
- **Owns**: `agent/src/transport/tunnel.ts`, `agent/test/tunnel.test.ts`
|
||||
- **Depends**: T2 (W0 `seams.ts` `WsLike`), T3, dial (T12) at integration; **cross-plan**: P1 §4.1 codec
|
||||
(`encodeMuxFrame`/`decodeMuxFrame`) from `relay-contracts/`. **Parallel-safe**: with T8–T11 (disjoint
|
||||
files, shared frozen §4.1 interface).
|
||||
- **Contracts** (imports §4.1 `MuxFrameHeader`, `MuxFrameType`, codec — **never** re-declared; imports
|
||||
`WsLike` + the frozen `GoAwayReason` from `relay-contracts/`):
|
||||
```ts
|
||||
import { WsLike } from './seams' // W0 shared seam (T2)
|
||||
import { GoAwayReason } from 'relay-contracts' // FROZEN §4.1 3-value union: 'operatorDrain'|'revoked'|'shutdown'
|
||||
export interface Tunnel {
|
||||
send(header: MuxFrameHeader, payload: Uint8Array): void // encodeMuxFrame → socket
|
||||
onFrame(cb: (h: MuxFrameHeader, payload: Uint8Array) => void): void
|
||||
goAway(lastStreamId: number, reason: GoAwayReason): void // §4.1 GOAWAY — reason is the FROZEN union, not a raw int
|
||||
close(): void
|
||||
}
|
||||
export function holdTunnel(socket: WsLike, router: StreamRouter, heartbeat: Heartbeat): Tunnel
|
||||
```
|
||||
The demux loop: `decodeMuxFrame` each inbound frame → dispatch by `type` to `router` (OPEN/DATA/
|
||||
CLOSE/RST) or `heartbeat` (PING/PONG) or connection-level (GOAWAY/WINDOW_UPDATE streamId 0). **Illegal
|
||||
transitions** (DATA before OPEN, DATA after CLOSE, unknown streamId) ⇒ **RST that stream, never the
|
||||
tunnel** (§4.1 stream lifecycle).
|
||||
- **TDD (RED→GREEN)**:
|
||||
- [ ] RED: feed a byte-exact §4.1 frame fixture → `onFrame` yields the decoded header+payload.
|
||||
- [ ] RED: DATA on an unknown `streamId` → agent emits a `CLOSE` with `flags.RST` for that stream and
|
||||
the **tunnel stays up** (§4.1).
|
||||
- [ ] RED: **INV11** — tunnel imports no ANSI/xterm parser; DATA payload passes through opaque.
|
||||
- [ ] RED: a `GOAWAY` frame → in-flight streams allowed to finish, no new OPEN accepted (drain).
|
||||
- [ ] GREEN.
|
||||
- **Test cases**: round-trip encode→decode using the §4.1 fixture; RST-on-illegal-transition (DATA
|
||||
before OPEN, DATA after CLOSE, dup streamId); GOAWAY drain; oversize `payloadLen > maxFrameBytes` →
|
||||
frame rejected (boundary guard, safe-int); opaque-passthrough (INV11).
|
||||
- **Security**: INV11 (opaque), robust framing (malformed frames can't crash the tunnel or bleed streams).
|
||||
|
||||
#### T8 · Stream router + loopback forwarder `[ ]` — v0.9
|
||||
- **Owns**: `agent/src/transport/streamRouter.ts`, `agent/src/transport/loopback.ts`,
|
||||
`agent/test/streamRouter.test.ts`, `agent/test/loopback.test.ts`
|
||||
- **Depends**: T7; T2 (loopback target). **Parallel-safe**: with T9–T11.
|
||||
- **Contracts** (validates `MuxOpen` with the **frozen §4.1 Zod schema** from `relay-contracts/`):
|
||||
```ts
|
||||
export interface StreamRouter {
|
||||
handleOpen(open: MuxOpen): void // one OPEN ⇒ one loopback socket — AFTER the subdomain boundary check
|
||||
handleData(streamId: number, payload: Uint8Array): void
|
||||
handleClose(streamId: number, rst: boolean): void
|
||||
activeStreamCount(): number
|
||||
}
|
||||
export function createStreamRouter(cfg: AgentConfig, tunnel: Tunnel, dialLoopback: DialLoopback, transform: FrameTransform): StreamRouter
|
||||
export type DialLoopback = (path: string, origin: string) => Promise<WsLike>
|
||||
// dials ws://127.0.0.1:3000<path>, sets header `Origin: <origin>` from §4.1 MuxOpen.originHeader
|
||||
export function dialLoopback(target: string): DialLoopback
|
||||
// FrameTransform: identity in v0.9 (plaintext passthrough); replaced by the E2E codec in v0.10 (T15)
|
||||
export interface FrameTransform {
|
||||
inbound(streamId: number, cipher: Uint8Array): Uint8Array | null // tunnel→local (decrypt in v0.10)
|
||||
outbound(streamId: number, plain: Uint8Array): Uint8Array // local→tunnel (encrypt in v0.10)
|
||||
openStream(streamId: number): void; closeStream(streamId: number): void
|
||||
}
|
||||
export const identityTransform: FrameTransform // v0.9: inbound/outbound = passthrough
|
||||
```
|
||||
**Per-stream state is a fresh allocation** — **no global mutable buffers** (EXPLORE §4b failure-mode #3;
|
||||
cross-tenant buffer bleed is impossible because each stream owns its socket + transform state). Closing a
|
||||
stream (CLOSE/RST) frees the paired loopback socket (§4.1).
|
||||
|
||||
**MANDATORY subdomain boundary check (defense-in-depth against a compromised/buggy/misconfigured relay).**
|
||||
Per §4.1, authorization is the relay's job (P5) and there is exactly **one physical tunnel per host**, so a
|
||||
correctly-functioning relay never delivers a foreign-tenant `OPEN` here. But the whole premise is *don't
|
||||
fully trust the relay* — the agent is the last place that can catch a cross-wired frame. Therefore, the
|
||||
**first** thing `handleOpen` does — before allocating any state or dialing loopback — is compare the frozen
|
||||
`MuxOpen.subdomain` (§4.1) against this agent's own enrolled `AgentConfig.subdomain` (§4.2, filled at
|
||||
pairing):
|
||||
|
||||
```ts
|
||||
// handleOpen — first statement, before any allocation or dial:
|
||||
if (open.subdomain !== cfg.subdomain) {
|
||||
// never proceed to dial; RST this stream and audit-log the mismatch (metadata only, INV10 — no payload)
|
||||
tunnel.send(rstHeaderFor(open.streamId), EMPTY) // §4.1 CLOSE + flags.RST
|
||||
logger.log('error', 'open.subdomain mismatch — refusing to dial', { streamId: open.streamId })
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
This is a one-field comparison; it closes an otherwise-silent cross-tenant-bleed vector (host B's OPEN
|
||||
spliced into host A's tunnel) at the single point in the whole system where the agent can detect it. It
|
||||
**complements, never replaces**, relay-side authz (INV1, owned by P1/P5) — belt-and-suspenders.
|
||||
- **TDD (RED→GREEN)**:
|
||||
- [ ] RED: `handleOpen` dials `ws://127.0.0.1:3000` at `MuxOpen.requestPath` **replaying
|
||||
`MuxOpen.originHeader`** as the `Origin` header (so the UNCHANGED base app's Origin check passes
|
||||
against `ALLOWED_ORIGINS` — EXPLORE §3).
|
||||
- [ ] RED: loopback output bytes → `transform.outbound` → `tunnel.send(DATA)`; tunnel DATA →
|
||||
`transform.inbound` → loopback write (v0.9 passthrough).
|
||||
- [ ] RED: CLOSE/RST frees the loopback socket; loopback close → agent sends CLOSE upstream.
|
||||
- [ ] RED (**INV5/isolation**): two concurrent streams get **separate** socket + buffer objects; a
|
||||
marker written on stream A never appears on stream B's socket (per-connection allocation).
|
||||
- [ ] RED (**INV1 defense-in-depth / cross-tenant boundary**): an `OPEN` whose `MuxOpen.subdomain !==
|
||||
cfg.subdomain` is **RST immediately and NEVER dialed** — assert `dialLoopback` is not called, the
|
||||
stream is RST, and a metadata-only mismatch is logged (INV10). This is the load-bearing negative
|
||||
test: it must fail if a future refactor drops the guard.
|
||||
- [ ] RED: `MuxOpen` failing the frozen §4.1 schema → stream RST, not a crash.
|
||||
- [ ] GREEN.
|
||||
- **Test cases**: OPEN→dial with correct path+Origin; bidirectional splice; CLOSE frees socket; RST on
|
||||
invalid MuxOpen; **OPEN for a foreign subdomain is RST, never dialed** (cross-tenant boundary — assert no
|
||||
loopback dial); **two-stream isolation** (no cross-stream bleed); loopback connect-refused (base app
|
||||
down) → RST upstream + typed error (no swallow); backpressure hook calls into T11.
|
||||
- **Security**: **INV11** (opaque splice), **INV5** (per-connection allocation, no global buffers),
|
||||
**INV1 defense-in-depth** (subdomain-mismatch OPEN is RST before dial — the agent's own last-line check on
|
||||
a cross-wired relay frame; complements, never replaces, relay-side authz owned by P1/P5),
|
||||
Origin replay preserves CSWSH protection end-to-end (EXPLORE §3 "do not weaken the check").
|
||||
|
||||
#### T9 · Heartbeat `[ ]` — v0.9
|
||||
- **Owns**: `agent/src/transport/heartbeat.ts`, `agent/test/heartbeat.test.ts`
|
||||
- **Depends**: T7. **Parallel-safe**: with T8/T10/T11.
|
||||
- **Contracts** (§4.1 PING/PONG, streamId 0, **15s** — cited verbatim):
|
||||
```ts
|
||||
export interface Heartbeat { onPing(token: Uint8Array): void; start(): void; stop(): void; onDead(cb: () => void): void }
|
||||
export function createHeartbeat(tunnel: Tunnel, opts?: { intervalMs?: number; timer?: TimerLike }): Heartbeat
|
||||
export const HEARTBEAT_INTERVAL_MS = 15_000 // §4.1: "heartbeat every 15s, miss ⇒ tunnel dead"
|
||||
```
|
||||
- **TDD** (fake timers): RED — inbound PING → agent replies PONG echoing the 8-byte token; no PONG within
|
||||
interval → `onDead` fires (⇒ T10 reconnect). GREEN.
|
||||
- **Test cases**: PING→PONG echo; missed PONG → dead after `HEARTBEAT_INTERVAL_MS`; `stop()` cancels timer
|
||||
(no leak); token echoed byte-exact.
|
||||
- **Security**: liveness only; no secret material.
|
||||
|
||||
#### T10 · Reconnection / backoff `[ ]` — v0.9
|
||||
- **Owns**: `agent/src/transport/backoff.ts`, `agent/test/backoff.test.ts`
|
||||
- **Depends**: T2 (dial trigger **and** the W0 `seams.ts` `isRevoked` seam). **Does NOT depend on T14** —
|
||||
`reconnectLoop` only *consumes* an injected `isRevoked: () => boolean` callback (the seam type is frozen at
|
||||
W0 in `seams.ts`), so there is **no task-level cycle** with T14. T14 *implements* `RevocationState` and
|
||||
wires its `isRevoked` into this loop at integration; T10 never imports T14. **Parallel-safe**: with
|
||||
T8/T9/T11 (and with T14 — no edge either way).
|
||||
- **Contracts** — **reuses the base app's 1/2/4…cap-30s** policy (EXPLORE §3 "the *same policy the frontend
|
||||
already ships*"):
|
||||
```ts
|
||||
export interface BackoffPolicy { nextDelayMs(): number; reset(): void }
|
||||
export function createBackoff(opts?: { baseMs?: number; capMs?: number; jitter?: boolean }): BackoffPolicy
|
||||
export const BACKOFF_BASE_MS = 1_000
|
||||
export const BACKOFF_CAP_MS = 30_000 // 1s/2s/4s…cap 30s
|
||||
export function reconnectLoop(dial: () => Promise<Tunnel>, backoff: BackoffPolicy, isRevoked: () => boolean): Promise<void>
|
||||
```
|
||||
- **TDD** (fake timers): RED — delays follow 1s,2s,4s,8s,16s,30s(cap),30s…; `reset()` after a successful
|
||||
connect returns to 1s; **`isRevoked()===true` short-circuits the loop (no reconnect)** — INV12. GREEN.
|
||||
- **Test cases**: exact backoff sequence + cap; jitter within bounds when enabled; reset-on-success;
|
||||
**revoked host does not reconnect** (INV12); dial failure loops, dial success resolves.
|
||||
- **Security**: **INV12** (a revoked host must stop trying, not thrash the relay).
|
||||
|
||||
#### T11 · Per-stream flow control `[ ]` — v0.9
|
||||
- **Owns**: `agent/src/transport/flowControl.ts`, `agent/test/flowControl.test.ts`
|
||||
- **Depends**: T7, T8. **Parallel-safe**: with T8–T10 (own file).
|
||||
- **Contracts** — **consumes** the §4.1 `WINDOW_UPDATE` credit protocol (P1 owns the protocol; the agent
|
||||
is a credit-respecting sender/receiver so one heavy `vim`/`top` redraw can't starve another stream):
|
||||
```ts
|
||||
export interface FlowController {
|
||||
consume(streamId: number, bytes: number): boolean // false ⇒ credit exhausted, pause this stream
|
||||
grant(streamId: number, credit: number): void // on inbound WINDOW_UPDATE
|
||||
initWindow(streamId: number, initialCredit: number): void
|
||||
}
|
||||
export function createFlowController(): FlowController
|
||||
```
|
||||
- **TDD**: RED — sending past the initial window returns `false` (pause); a `WINDOW_UPDATE grant` resumes;
|
||||
credit is **per-stream** (exhausting A does not pause B). GREEN.
|
||||
- **Test cases**: pause at zero credit; resume on grant; per-stream independence (starvation guard);
|
||||
connection-level (streamId 0) credit applies to the whole link (§4.1).
|
||||
- **Security**: DoS resilience (per-stream fairness); no cross-stream state bleed.
|
||||
|
||||
---
|
||||
|
||||
### W3 · mTLS + lifecycle (v0.9 / v0.10)
|
||||
|
||||
#### T12 · Outbound mTLS dial `[ ]` — v0.9
|
||||
- **Owns**: `agent/src/transport/dial.ts`, `agent/test/dial.test.ts`
|
||||
- **Depends**: T3 (key), T4 (cert), T2 (relayUrl); **cross-plan**: P3 CA (validates server cert),
|
||||
P1 `/agent` endpoint (mocked in unit tests). **Parallel-safe**: with T13/T14.
|
||||
- **Contracts** (INV14 — mTLS, **no bearer token on the tunnel**, EXPLORE §4a.1):
|
||||
```ts
|
||||
import { WsLike } from './seams' // W0 shared seam (T2) — NOT redeclared here
|
||||
export function dialRelay(cfg: AgentConfig, ks: Keystore): Promise<WsLike>
|
||||
// builds a wss:// client with { cert, key(in-process), ca: caChain }; rejectUnauthorized: true
|
||||
```
|
||||
- **TDD (RED→GREEN)**:
|
||||
- [ ] RED: dial constructs a TLS client with the **client cert + in-process private key + CA chain**;
|
||||
`rejectUnauthorized` is **true** (never disabled — a common insecure default).
|
||||
- [ ] RED: **no bearer/agent token** is placed in a header or the URL (INV4 — mTLS is the auth).
|
||||
- [ ] RED: absent/expired cert in keystore → dial refused with `NotEnrolledError`/`CertExpiredError`
|
||||
(fail-fast, INV14).
|
||||
- [ ] RED: a relay server cert not chaining to the pinned CA → handshake rejected (no MITM).
|
||||
- [ ] GREEN (mock TLS layer).
|
||||
- **Test cases**: cert+key+CA wired; `rejectUnauthorized` true; no token header; missing cert →
|
||||
NotEnrolled; expired cert → refuse; bad server chain → reject.
|
||||
- **Security**: **INV14** (mTLS), **INV4** (asymmetric, no shared secret on the wire), pinned CA (anti-MITM).
|
||||
|
||||
#### T13 · Short-lived cert rotation `[ ]` — v0.9 → hardened v0.10
|
||||
- **Owns**: `agent/src/certs/rotation.ts`, `agent/test/rotation.test.ts`
|
||||
- **Depends**: T3, T4, T12; **cross-plan**: P3 renewal endpoint (SPIFFE-style, §4.5 signs only pubkeys in
|
||||
the registry). **Parallel-safe**: with T14.
|
||||
- **Contracts** (INV14 — auto-rotating, seamless):
|
||||
```ts
|
||||
export interface CertRotator { start(): void; stop(): void; onRotated(cb: () => void): void; onRevoked(cb: () => void): void }
|
||||
export function createCertRotator(cfg: AgentConfig, id: AgentIdentity, ks: Keystore, opts?: { renewBeforeMs?: number; timer?: TimerLike }): CertRotator
|
||||
// renews via P3 using a fresh CSR (T4) over the SAME Ed25519 key; installs new cert atomically (INV8-style swap)
|
||||
```
|
||||
- **TDD** (fake timers): RED — timer fires at `expiry - renewBeforeMs`, posts a fresh CSR, installs the
|
||||
new cert **atomically** (old cert never partially overwritten); a `403 revoked` from the renewal
|
||||
endpoint → `onRevoked` fires (⇒ T14 teardown, INV12); rotation mid-tunnel does **not** drop live
|
||||
streams (seamless). GREEN.
|
||||
- **Test cases**: renew-before-expiry timing; atomic cert swap (no torn read of cert file); renewal
|
||||
refusal (revoked) → onRevoked; renewal uses the same key (pubkey unchanged, only cert rotates);
|
||||
network error on renew → retry with backoff, tunnel stays up until cert actually expires.
|
||||
- **Security**: **INV14** (short-lived auto-rotation; revocation = "just stop renewing" per EXPLORE §4a.1),
|
||||
**INV12** (renewal-refusal path), **INV8** (atomic swap of cert-at-rest).
|
||||
|
||||
#### T14 · Revocation + GOAWAY teardown `[ ]` — v0.9 (drain) / v0.10 (revoke)
|
||||
- **Owns**: `agent/src/lifecycle/revocation.ts`, `agent/test/revocation.test.ts`
|
||||
- **Depends**: T7 (tunnel), T13 (renewal-refusal), **T2 (the W0 `seams.ts` `RevocationState`/`RevokeReason`
|
||||
seam)**; **cross-plan**: the **frozen INDEX §4.1 3-value `GoAwayReason`** union + `decodeGoAwayReason` in
|
||||
`relay-contracts/` (§4.1 line 225 — RESOLVED, no longer an open question). T14 **wires** T10's
|
||||
`reconnectLoop` (injects its `isRevoked` into the loop) but does **not** depend on T10 as a task — the
|
||||
edge is one-way (T14→consumes T10's function via the W0 seam type), so there is **no cycle**.
|
||||
**Parallel-safe**: with T12/T13.
|
||||
- **Contracts** (INV12 — fast revocation kills live tunnels in seconds). The `RevocationState`/`RevokeReason`
|
||||
seam is **imported from `agent/src/transport/seams.ts` (T2), not redeclared here**; the GOAWAY reason
|
||||
mapping is **imported from `relay-contracts/`, never invented locally**:
|
||||
```ts
|
||||
import { RevocationState, RevokeReason } from '../transport/seams' // W0 seam (T2)
|
||||
import { GoAwayReason, decodeGoAwayReason } from 'relay-contracts' // FROZEN §4.1 3-value union + decoder (see below)
|
||||
export function createRevocationState(onTeardown: () => void): RevocationState
|
||||
// markRevoked ⇒ close all streams + tunnel immediately AND make T10's isRevoked() true (no reconnect)
|
||||
// classifyGoAway maps the FROZEN 3-value wire reason to the agent's action:
|
||||
// 'operatorDrain' → 'drain' · 'shutdown' → 'drain' · 'revoked' → 'revoked' (an unknown numeric code never
|
||||
// reaches here — decodeGoAwayReason throws, and T14 fails closed to 'revoked'; see below).
|
||||
export function classifyGoAway(reason: GoAwayReason): 'drain' | 'revoked' // pure exhaustive map over the FROZEN 3-value union
|
||||
```
|
||||
**Finding-3 fix (RESOLVED) — GOAWAY reason semantics are a FROZEN shared contract, not a local numeric guess.**
|
||||
INDEX §4.1 (line 225) now freezes `GoAwayReason = 'operatorDrain' | 'revoked' | 'shutdown'` (string-literal
|
||||
union) + `decodeGoAwayReason` (`1→operatorDrain · 2→revoked · 3→shutdown`, else throws) — the **same** codes
|
||||
P1 `drain.ts` `DRAIN_REASON` (`operatorDrain:1, revoked:2, shutdown:3`) and P5 revocation (FIX 4) emit. T14
|
||||
imports this union + decoder **verbatim** from `relay-contracts/` and **must not** hard-code reason integers
|
||||
or redeclare the union locally. This closes the earlier sender/receiver drift where P1 emitted
|
||||
`operatorDrain`/`shutdown` while P2 assumed a 2-value `'drain' | 'revoked'` union the frozen decoder never
|
||||
emits — a genuine **revoke** misread as a **drain** would have let the agent *reconnect elsewhere* instead of
|
||||
*refusing to reconnect*, silently defeating INV12 with **both plans' unit tests still green**. `classifyGoAway`
|
||||
is a pure exhaustive map over the frozen 3-value union: the graceful reasons **`operatorDrain` and `shutdown`
|
||||
both → `'drain'`** (finish in-flight, reconnect elsewhere — EXPLORE §3 node drain/shutdown) and **`revoked` →
|
||||
`'revoked'`** (immediate teardown, no reconnect). An **unknown numeric code** never yields a valid
|
||||
`GoAwayReason` — `decodeGoAwayReason` throws — and T14 **fails closed: treats it as `'revoked'`** (a default
|
||||
that guesses "drain" is exactly the INV12-defeating failure). No stub/default mapping is permitted.
|
||||
- **TDD**: RED — a §4.1 `GOAWAY` decoding (via `decodeGoAwayReason`) to `'revoked'` → **immediate** teardown
|
||||
of all streams + tunnel and `isRevoked()` becomes true so T10 does **not** reconnect; an `'operatorDrain'`
|
||||
**or** `'shutdown'` GOAWAY → graceful (finish in-flight, reconnect elsewhere); an **unknown/unmapped** reason
|
||||
code (`decodeGoAwayReason` throws) → **fail-closed: treated as `'revoked'`** (never silently as drain) +
|
||||
typed error surfaced (no swallow). GREEN.
|
||||
- **Test cases**: revoke → teardown within one tick (seconds-scale, INV12); the three frozen reasons map
|
||||
correctly — **`operatorDrain`→drain, `shutdown`→drain, `revoked`→revoke** — distinguished **only** via the
|
||||
frozen `GoAwayReason`/`decodeGoAwayReason` (a test asserts no bare integer literal for a reason appears in
|
||||
`revocation.ts`, and that `classifyGoAway` is exhaustive over the 3-value union); **unknown reason code fails
|
||||
closed to revoked** (`decodeGoAwayReason` throws, INV12 safety); post-revoke reconnect suppressed (asserts
|
||||
the injected `isRevoked` returns true to T10's loop); operator-drain path reconnects elsewhere.
|
||||
- **Security**: **INV12** (global + per-host revocation the agent honors within seconds; a revoked host
|
||||
neither holds a tunnel nor reconnects; unknown reason codes fail closed to revoked). Distinguish
|
||||
node-drain/shutdown (`operatorDrain`/`shutdown` → reconnect elsewhere, EXPLORE §3) from revoke (`revoked` →
|
||||
stop) **only** through the frozen §4.1 3-value reason contract — never a locally-chosen mapping (cross-plan
|
||||
drift = silent INV12 defeat).
|
||||
|
||||
---
|
||||
|
||||
### W4 · Agent-side E2E endpoint (v0.10) — consumes P4 `relay-e2e/`
|
||||
|
||||
#### T15 · Host E2E endpoint (`host_hello` + seal/open splice) `[ ]` — v0.10
|
||||
- **Owns**: `agent/src/e2e/hostEndpoint.ts`, `agent/test/hostEndpoint.test.ts`
|
||||
- **Depends**: T3 (identity, for the transcript `sig`), T8 (`FrameTransform` seam), T19 (`ReplaySealer`,
|
||||
same wave — authored before T15 wires it); **cross-plan HARD PRE-W4 GATE** — **P4 `relay-e2e/`** must FIRST
|
||||
freeze BOTH (a) `sealFrame`/`openFrame` + the `createHostHandshake` host-handshake wiring AND (b) the
|
||||
**injected device-auth-proof verifier** — P5-owned `verifyDeviceProof(proof, { clientEphPub, clientNonce })`
|
||||
reaching T15 **through** P4's `createHostHandshake` (FIX 6b, **not** a direct `relay-e2e` import) — **plus a
|
||||
published cross-plan test vector** (a valid proof + at least one forged/failing proof). Browser side is P6.
|
||||
**Parallel-safe**: standalone file — **but see the blocking gate below: T15 does not START coding the
|
||||
proof-abort path until (b) lands.**
|
||||
|
||||
> **BLOCKING GATE (was §6 open Q#2 — now a hard, named pre-W4 gate). The anti-MITM guarantee of the
|
||||
> entire authenticated-ECDH-through-the-relay design rests on `deviceAuthProof` verification (§4.4
|
||||
> `ClientHello`).** T15 only *consumes* that verifier; its issuance+verification lives in **P5** and reaches
|
||||
> T15 **injected through P4's `createHostHandshake`** (FIX 6b) — the agent never `import`s it from
|
||||
> `relay-e2e`. Until P5's `verifyDeviceProof` (bound to `{ clientEphPub, clientNonce }`) is frozen, wired
|
||||
> into P4's `createHostHandshake`, **and** the shared test vector is published, there is no definition of
|
||||
> what "the proof fails" means. **T15 is BLOCKED, NOT DEGRADED**: it must **not** merge or ship against a
|
||||
> stubbed / no-op / always-true proof check to unblock the wave. A stubbed verifier would let a relay that
|
||||
> injects a forged `ClientHello` complete a full handshake and derive `DirectionalKeys` with the agent —
|
||||
> **exactly the threat this locked decision defends against** — silently defeating E2E. If the gate is not
|
||||
> yet met when T15 is dispatched, the builder returns `[!] BLOCKED` (never guesses a proof format). The
|
||||
> orchestrator resolves it by landing P5's verifier (via P4's wiring) + the vector, then re-dispatches.
|
||||
|
||||
- **Contracts** — implements the **host side** of INDEX **§4.4** and plugs a real `FrameTransform` into T8
|
||||
(replacing `identityTransform`). It **imports** P4's crypto **and drives P4's host-handshake wiring** (the
|
||||
device-auth-proof verifier reaches it **injected**, FIX 6b); it **re-declares nothing** from §4.4:
|
||||
```ts
|
||||
// FIX 6b — verifier is P5-owned and injected via P4's createHostHandshake; NEVER `import { verifyDeviceAuthProof } from 'relay-e2e'`.
|
||||
import { sealFrame, openFrame, createHostHandshake } from 'relay-e2e' // P4 — frozen §4.4 primitives + host-handshake wiring
|
||||
import { createE2ESession } from 'relay-contracts'
|
||||
import type {
|
||||
ClientHello, HostHello, HandshakeResult, DirectionalKeys, E2ESession, AeadKey,
|
||||
} from 'relay-contracts' // §4.4 FROZEN shapes — cited verbatim, no local redefinition
|
||||
// Host-side device-proof verifier — INJECTED (P5 issues+verifies; P4 wires it), bound to { clientEphPub, clientNonce } (FIX 6b):
|
||||
export type VerifyDeviceProof =
|
||||
(proof: string, binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array }) => Promise<boolean>
|
||||
// Host side of §4.4: receive ClientHello (relay DATA) → verify proof FIRST → reply HostHello → derive DirectionalKeys.
|
||||
// MITM defense: makeHostHello drives createHostHandshake({ verifyDeviceProof, identity: id }), which calls the injected
|
||||
// verifier bound to { clientEphPub, clientNonce } FIRST and ABORTS (throws MitmAbortError, derives NO keys) on failure —
|
||||
// there is no code path that skips this call.
|
||||
export function makeHostHello(
|
||||
clientHello: ClientHello, id: AgentIdentity, verifyDeviceProof: VerifyDeviceProof,
|
||||
): Promise<{ hello: HostHello; result: HandshakeResult }>
|
||||
// FIX 2 — returns HandshakeResult{ keys: DirectionalKeys{c2h,h2c}, aead, transcript }; NO single sessionKey/CryptoKey.
|
||||
// HostHello.sig = id.sign(transcript); HostHello.enrollFpr = id.enrollFpr (§4.4 — browser pins it, TOFU)
|
||||
export function createE2ETransform(
|
||||
id: AgentIdentity, verifyDeviceProof: VerifyDeviceProof, replay: ReplaySealer, // replay = T19 K_content sealer (FIX 3)
|
||||
): FrameTransform
|
||||
// per stream: absorb ClientHello → (verify proof → else abort) → emit HostHello →
|
||||
// session = createE2ESession('host', result) // host seals with h2c, opens with c2h (§4.4)
|
||||
// thereafter: inbound = session.open(dataPayload); outbound(LIVE) = session.seal(plain) [ephemeral h2c];
|
||||
// AND every replay-bound output is ALSO sealed via replay.seal(plain) [recoverable K_content, T19] — DISTINCT from live frames (FIX 3).
|
||||
// Synchronous AeadKey seal/open; monotonic seq per direction (§4.4 / INV13).
|
||||
```
|
||||
**Boundary**: after `openFrame` decrypts, the bytes are handed to the loopback WS **as opaque bytes** —
|
||||
the agent still **parses no terminal semantics** (INV11). Plaintext exists only host-local.
|
||||
- **TDD (RED→GREEN)**:
|
||||
- [ ] RED: `makeHostHello` produces a `HostHello` whose `sig` verifies under `enrollFpr` and whose ECDH
|
||||
yields a `HandshakeResult` whose **`keys: DirectionalKeys{ c2h, h2c }`** match what the P4 client-side
|
||||
test vector computes (§4.4 — direction-split, **no single `sessionKey`/`CryptoKey`**).
|
||||
- [ ] RED (**anti-MITM, gated on the frozen verifier + vector**): using **P4's published forged-proof
|
||||
vector**, a `ClientHello` whose `deviceAuthProof` fails the injected `verifyDeviceProof` → **ABORT**
|
||||
(`MitmAbortError`, **no `DirectionalKeys` derived, no `HostHello` emitted**); likewise a transcript
|
||||
mismatch → ABORT. This test is written against P4's real vector, **not** a local stand-in; it is the
|
||||
load-bearing MITM test.
|
||||
- [ ] RED (**no-stub CI guard, INV2 anti-MITM**): a static/CI guard fails the build if `makeHostHello`/
|
||||
`createE2ETransform` can reach key derivation **without** calling the real (non-stub) injected
|
||||
`verifyDeviceProof` — e.g. assert `hostEndpoint.ts` does **NOT** import any verifier from `relay-e2e`
|
||||
(the verifier arrives only via the `verifyDeviceProof` param wired through P4's `createHostHandshake`,
|
||||
FIX 6b), and a mutation test that replaces the injected verifier with `async () => true` makes the MITM
|
||||
test FAIL. This makes "shipped against a stubbed proof check" an automatic red build.
|
||||
- [ ] RED (**INV2**): given a stream's DATA frames, assert the **relay-visible** payload is the §4.4
|
||||
`E2EEnvelope` (ciphertext+nonce+tag+seq) — a known plaintext marker fed at the loopback never
|
||||
appears in any DATA payload bytes.
|
||||
- [ ] RED (**INV13**): `openFrame` rejects a **replayed** envelope (dup seq), a **reordered** seq, and a
|
||||
**bit-flipped** ciphertext (AEAD tag fail) → the stream is torn down, not silently dropped-through.
|
||||
- [ ] RED (**INV11**): even post-decrypt, no ANSI/xterm import; decrypted bytes forwarded opaque.
|
||||
- [ ] GREEN by wiring P4 primitives.
|
||||
- **Acceptance criteria (explicit — mirrored in T18):**
|
||||
1. **T15 is BLOCKED, not degraded, until the proof-verification contract exists** — no merge against a
|
||||
stub/no-op/always-true `verifyDeviceProof` (P5-owned, injected via P4's `createHostHandshake`, FIX 6b);
|
||||
the forged-proof vector from P4 must be present and the abort test green.
|
||||
2. The no-stub CI guard (mutation test above) is present and passing.
|
||||
3. All INV2/INV13/INV11 tests above green.
|
||||
- **Test cases**: handshake key-agreement (**`DirectionalKeys{c2h,h2c}`**) vs P4 vector; **forged-proof →
|
||||
ABORT (no keys) using P4's forged vector**; **no-stub CI guard (verifier-mutation → MITM test fails)**;
|
||||
envelope-only on the wire (INV2); replay/reorder/tamper rejection (INV13); multi-device — two independent
|
||||
`ClientHello`s derive two independent `HandshakeResult`s over the authenticated channel (§4.4,
|
||||
INV3-adjacent); passthrough remains opaque (INV11).
|
||||
- **Security**: **INV2** (relay sees only ciphertext), **INV13** (per-message nonce + monotonic seq),
|
||||
**INV11** (opaque even after decrypt), **enrollment-key-bound + device-auth-proof-gated handshake**
|
||||
(anti-MITM by a malicious relay — the guarantee is only real once the injected `verifyDeviceProof` is P5's
|
||||
real verifier wired via P4's `createHostHandshake`, never a stub; enforced by the no-stub CI guard).
|
||||
|
||||
#### T19 · Replay-frame sealer (recoverable `K_content`) `[ ]` — v0.10 (FIX 3)
|
||||
- **Owns**: `agent/src/e2e/replaySeal.ts`, `agent/test/replaySeal.test.ts`
|
||||
- **Depends**: T3 (`Keystore.loadContentSecret`), T2 (config); **cross-plan**: `relay-contracts/` §4.4 replay
|
||||
surface (`ReplayKeyParams`, `deriveContentKey`, `sealReplayFrame`, `REPLAY_KDF_INFO`) + P4 `relay-e2e/`
|
||||
impls. **Wired INTO T15's `createE2ETransform`** (same wave — authored before T15 consumes it).
|
||||
**Parallel-safe**: standalone file (no edge back to T15).
|
||||
- **Rationale (FIX 3 — recoverable replay under E2E).** Live host→client frames are sealed with the
|
||||
**ephemeral** `DirectionalKeys.h2c` (forward-secret — gone after reconnect). But the "refresh the page and
|
||||
the Claude session is still there" guarantee (EXPLORE §4c) needs the ring-buffer/preview ciphertext to be
|
||||
**recoverable** after a reload — so every replay-bound output is ALSO sealed under the **host-scoped
|
||||
recoverable** `K_content = deriveContentKey({ hostContentSecret, sessionId, alg })`, **DISTINCT** from the
|
||||
live `h2c` frame. The browser re-derives the identical `K_content` (via P5) and calls `openReplayCiphertext`
|
||||
(P6 T9). This is the single agent-side consumer of the FIX 3 recoverable key (closes the "ship as
|
||||
unconsumed library code" gap noted in RECONCILE FIX 3a).
|
||||
- **Contracts** (imports the FROZEN §4.4 replay surface from `relay-contracts/`; re-declares nothing):
|
||||
```ts
|
||||
import { deriveContentKey, sealReplayFrame } from 'relay-e2e' // P4 impls of the §4.4 replay surface
|
||||
import type { ReplayKeyParams, AeadKey, E2EEnvelope, AeadAlg } from 'relay-contracts' // §4.4 FROZEN shapes
|
||||
export interface ReplaySealer {
|
||||
seal(plaintext: Uint8Array): E2EEnvelope // K_content seal, monotonic seq per session (INV13); NOT the live h2c frame
|
||||
}
|
||||
// K_content derived once per (host, session): deriveContentKey({ hostContentSecret, sessionId, alg }).
|
||||
export function createReplaySealer(hostContentSecret: Uint8Array, sessionId: string, alg: AeadAlg): ReplaySealer
|
||||
```
|
||||
`hostContentSecret` comes from `Keystore.loadContentSecret()` (T3, unwrapped at T4) — **never** the
|
||||
ephemeral key, **never** logged, **never** sent to the relay (INV2/INV9).
|
||||
- **TDD (RED→GREEN)**:
|
||||
- [ ] RED: `deriveContentKey` with the same `{ hostContentSecret, sessionId, alg }` is **deterministic**
|
||||
(a reload re-derives the identical `K_content`); a different `sessionId` → a different key (per-session).
|
||||
- [ ] RED: `seal` emits a §4.4 `E2EEnvelope` with **monotonic seq** (INV13); a known plaintext marker
|
||||
never appears in the envelope bytes (INV2).
|
||||
- [ ] RED: for the same plaintext, the replay seal is **distinct** from the live `session.seal` (h2c) output
|
||||
(different key ⇒ different ciphertext) — the two seals are not interchangeable (FIX 3).
|
||||
- [ ] RED (cross-plan vector): a `sealReplayFrame` envelope round-trips through P6's `openReplayCiphertext`
|
||||
against a shared test vector (same `hostContentSecret`/`sessionId`).
|
||||
- [ ] GREEN by wiring P4 impls.
|
||||
- **Test cases**: deterministic `K_content` re-derivation; per-session key separation; monotonic seq;
|
||||
envelope-only (INV2); replay-seal ≠ live-seal; P6 round-trip vector.
|
||||
- **Security**: **INV2** (replay ciphertext still opaque to the relay), **INV13** (monotonic seq on replay
|
||||
frames too), **INV5/INV9** (`hostContentSecret` at rest `0600`, never logged/egressed). The recoverable
|
||||
`K_content` is **host-scoped + per-host revocable** (P3 can invalidate the wrap, INDEX §4.5) — **not** an
|
||||
account-wide secret.
|
||||
|
||||
---
|
||||
|
||||
### W5 · Distribution + acceptance
|
||||
|
||||
#### T16 · Static-binary build `[ ]` — v0.9 (EXPLORE §6 distribution rank 2)
|
||||
- **Owns**: `agent/src/dist/buildBinary.ts`, `agent/test/buildBinary.test.ts`
|
||||
- **Depends**: T5 (CLI). **Parallel-safe**: with T17.
|
||||
- **Contracts**: `export function buildBinaryConfig(target: BinaryTarget): BuildSpec` where
|
||||
`BinaryTarget = 'darwin-arm64' | 'darwin-x64' | 'linux-x64' | 'linux-arm64'` (string-literal union);
|
||||
produces a `bun --compile` spec (fallback `pkg`) for a one-`curl | sh` install. `npx web-terminal-agent`
|
||||
remains the MVP path (EXPLORE §6 rank 1 — "customers already `npm install`").
|
||||
- **TDD**: RED — build spec targets each supported triple; entry = `cli.ts`; the binary bundle **excludes**
|
||||
any dev/test dep and any terminal parser (INV11 re-checked at package boundary). GREEN.
|
||||
- **Test cases**: per-target spec; entrypoint correct; no ANSI-parser in the bundle graph; `npx` path
|
||||
documented.
|
||||
- **Security**: EXPLORE §4d — the agent is a **signed** artifact (a compromised update = same blast radius,
|
||||
so the channel is signed/pinned; **signing itself is P5/CI-owned**, this task exposes the hook).
|
||||
|
||||
#### T17 · Service install + Origin touch-point `[ ]` — v0.9 (EXPLORE §6)
|
||||
- **Owns**: `agent/src/service/install.ts`, `agent/src/service/launchd.ts`,
|
||||
`agent/src/service/systemd.ts`, `agent/src/service/originConfig.ts`,
|
||||
`agent/test/install.test.ts`, `agent/test/originConfig.test.ts`
|
||||
- **Depends**: T2, T5. **Parallel-safe**: with T16.
|
||||
- **Contracts**:
|
||||
```ts
|
||||
export type ServicePlatform = 'launchd' | 'systemd'
|
||||
export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null
|
||||
export function installService(cfg: AgentConfig, platform: ServicePlatform): Promise<void> // writes+loads unit
|
||||
export function uninstallService(platform: ServicePlatform): Promise<void>
|
||||
// the ONE base-app touch-point (INDEX §0, EXPLORE §3): add the subdomain to ALLOWED_ORIGINS as CONFIG
|
||||
export function ensureAllowedOrigin(baseAppEnvPath: string, subdomain: string, domain: string): void
|
||||
```
|
||||
`ensureAllowedOrigin` **appends** `https://<subdomain>.term.<domain>` to the base app's
|
||||
`ALLOWED_ORIGINS` env (idempotent) — **no `src/` code edit** (EXPLORE §3 "Zero code change"). The service
|
||||
runs the agent as the **logged-in user, not root** (EXPLORE §4d — least privilege).
|
||||
- **TDD**: RED — launchd plist / systemd unit contain the right `ExecStart` (`web-terminal-agent run`),
|
||||
restart-on-failure, and **run-as-user (never root)**; `ensureAllowedOrigin` is idempotent and never
|
||||
removes an existing origin (does not weaken the Origin check — EXPLORE §3). GREEN with a mock FS.
|
||||
- **Test cases**: plist/unit generation; run-as-user assertion (negative: refuses to install as root);
|
||||
idempotent origin append; existing origins preserved; uninstall unloads cleanly.
|
||||
- **Security**: least-privilege service (INV-adjacent, EXPLORE §4d); the Origin touch-point **augments,
|
||||
never weakens**, CSWSH protection (EXPLORE §3, INV15/INV6 spirit); no secrets written to the unit file
|
||||
(INV9 — key/cert stay in the keystore, referenced by path).
|
||||
|
||||
#### T18 · Acceptance / verification harness `[ ]` — spans phases
|
||||
- **Owns**: `agent/test/acceptance/*.test.ts` (agent-local integration), `agent/test/security/*.test.ts`
|
||||
- **Depends**: all above; **cross-plan** integration with P1/P3/P4 lives in their acceptance suites (this
|
||||
is the agent-local slice — do not edit other packages).
|
||||
- **Steps**:
|
||||
- [ ] **INV tripwires** (one per owned INV, §1): INV2 marker-never-on-wire; INV4 no-private-key-egress;
|
||||
INV5 keystore-0600 + code-not-at-rest; INV9 no-secret-in-logs; INV11 no-parser-in-bundle;
|
||||
INV12 revoke→teardown+no-reconnect; INV14 mTLS-required + rotation.
|
||||
- [ ] **INV1 defense-in-depth tripwire (T8)**: an OPEN whose `MuxOpen.subdomain !== cfg.subdomain` is RST
|
||||
and **never dialed** — the agent's last-line cross-tenant boundary check.
|
||||
- [ ] **INV12 GOAWAY-reason tripwire (T14)**: a `'revoked'` GOAWAY (decoded via the frozen
|
||||
`decodeGoAwayReason`) tears down + suppresses reconnect; `'operatorDrain'` **and** `'shutdown'`
|
||||
reconnect elsewhere; an **unknown reason code fails closed to revoked**; assert no bare integer reason
|
||||
literal in `revocation.ts` (cross-plan-drift guard).
|
||||
- [ ] **INV2 anti-MITM no-stub gate (T15)**: with P4's **forged-proof vector**, a failing `deviceAuthProof`
|
||||
→ ABORT (no `DirectionalKeys`). The **verifier-mutation guard** — replacing the injected
|
||||
`verifyDeviceProof` (P5-owned, wired via P4's `createHostHandshake`, FIX 6b) with a no-op makes this
|
||||
test FAIL — must be present and passing (proves T15 cannot ship against a stub, and that no verifier is
|
||||
imported directly from `relay-e2e`).
|
||||
- [ ] **FIX 3 recoverable-replay tripwire (T19)**: a replay-bound output sealed with `sealReplayFrame`
|
||||
(`K_content`) round-trips through P6's `openReplayCiphertext`, is **ciphertext to the relay** (INV2),
|
||||
and is **distinct** from the live `h2c` frame for the same plaintext.
|
||||
- [ ] **Café-demo agent slice**: pair (mock P3) → dial (mock P1) → OPEN → loopback splice against a
|
||||
stub `ws://127.0.0.1:3000` echo server → bytes flow both ways (EXPLORE §5 demo, agent portion).
|
||||
- [ ] Coverage ≥ 80% across `agent/src/**`.
|
||||
- **Accept**: all INV tripwires green (incl. the T8 subdomain boundary, T14 GOAWAY-reason fail-closed, the
|
||||
T15 no-stub anti-MITM guard, and the T19 recoverable-replay round-trip); café-demo slice green; coverage
|
||||
gate met. **T15 must not be marked accepted while its injected-`verifyDeviceProof` gate (Q#2) is
|
||||
unresolved** (T14's `GoAwayReason` contract is frozen in INDEX §4.1 — no longer a gate).
|
||||
|
||||
---
|
||||
|
||||
## 4. SECURITY (per-plan summary)
|
||||
|
||||
The host-agent is a **CIPHERTEXT-shuttle endpoint on the customer's own machine** (INDEX §0). Its security
|
||||
posture:
|
||||
|
||||
1. **Private key never leaves the host (INV4)** — Ed25519 generated locally (T3); only the **public** key
|
||||
+ a CSR are transmitted (T4); mTLS proves possession without sending the key (T12). A relay DB dump
|
||||
cannot impersonate a host (EXPLORE §4a.1 "a per-host public key in your DB is useless to a thief").
|
||||
2. **No shared secrets on the wire (INV4/INV14)** — the tunnel is authenticated by **mTLS**, not a bearer
|
||||
token; certs are **short-lived and auto-rotated** (T13); revocation = stop renewing + tear down (T14,
|
||||
INV12), where a **`revoked`** GOAWAY is distinguished from the graceful **`operatorDrain`/`shutdown`**
|
||||
GOAWAY reasons only via the **frozen** 3-value `relay-contracts` `GoAwayReason`/`decodeGoAwayReason` (never
|
||||
a locally-invented numeric mapping — a drift there would silently downgrade a revoke to a reconnect;
|
||||
unknown reason ⇒ fail-closed to revoked). The
|
||||
v0.8 shared `agentToken` (T6) is an explicit, retired stepping-stone.
|
||||
3. **Relay sees only ciphertext (INV2)** — from v0.10, every byte entering the tunnel is a §4.4
|
||||
`E2EEnvelope` sealed at the agent: **live** frames under the ephemeral `DirectionalKeys.h2c` (T15),
|
||||
**replay-bound** frames under the recoverable `K_content` via `sealReplayFrame` (T19, FIX 3) — both
|
||||
ciphertext to the relay. The handshake yields **`DirectionalKeys{c2h,h2c}`** (FIX 2, no single
|
||||
`sessionKey`) and is **enrollment-key-bound AND device-auth-proof-gated** so a malicious relay cannot MITM
|
||||
(browser pins `enroll_fpr`; the agent runs P4's `createHostHandshake` with the **injected, P5-owned**
|
||||
`verifyDeviceProof` bound to `{clientEphPub, clientNonce}` (FIX 6b) and ABORTS on a forged `ClientHello`,
|
||||
deriving no keys). **This guarantee is only real once that injected `verifyDeviceProof` is P5's real
|
||||
verifier (wired via P4), never imported from `relay-e2e` — T15 is BLOCKED, not degraded, against any stub,
|
||||
enforced by a no-stub CI mutation guard (§6 Q#2).** Anti-replay/injection via per-message nonce + monotonic
|
||||
seq (INV13).
|
||||
3b. **Agent-side cross-tenant boundary (INV1 defense-in-depth)** — although tenant authz is P1/P5's job and
|
||||
there is one physical tunnel per host, `handleOpen` (T8) still refuses to dial any `OPEN` whose
|
||||
`MuxOpen.subdomain` ≠ the agent's enrolled `subdomain`, RST-ing it and audit-logging the mismatch. Cheap,
|
||||
load-bearing, and the last line against a cross-wired relay frame — consistent with "don't fully trust
|
||||
the relay."
|
||||
4. **No terminal parsing anywhere in the agent (INV11)** — even after E2E decrypt, bytes are handed to the
|
||||
loopback WS opaque; the package has **no** ANSI/xterm dependency (enforced by a bundle-graph tripwire,
|
||||
T1/T16).
|
||||
5. **No plaintext/secret at rest (INV5)** — key/cert `0600`; raw pairing code never persisted or logged;
|
||||
no shell bytes buffered (pure shuttle, per-connection allocation, **no global buffers** — closes the
|
||||
EXPLORE §4b cross-tenant-buffer-bleed failure mode).
|
||||
6. **Secrets validated at startup, never logged (INV9)** — Zod-validated config; fail-fast on missing key
|
||||
material; a redacting logger; **no `console.log`**.
|
||||
7. **Least privilege (EXPLORE §4d)** — the installed service runs as the **logged-in user, not root**
|
||||
(T17); the loopback target is **loopback-only** (anti-SSRF, T2/T8).
|
||||
8. **Origin check augmented, never weakened (EXPLORE §3)** — the agent replays the real browser `Origin`
|
||||
(§4.1 `MuxOpen.originHeader`) to the base app and only **adds** the subdomain to `ALLOWED_ORIGINS` at
|
||||
install (T17); CSWSH protection stays meaningful end-to-end.
|
||||
|
||||
**Out-of-scope security owned elsewhere (do not implement here):** capability-token verification, human
|
||||
auth/step-up, the CI cross-tenant tripwire, the revocation *policy/DB*, the mTLS *CA* → **P5/P3**; the E2E
|
||||
crypto *primitives* → **P4**; the relay-side isolation enforcement → **P1/P5**.
|
||||
|
||||
---
|
||||
|
||||
## 5. VERIFICATION
|
||||
|
||||
```bash
|
||||
# Unit + security tripwires (agent-local)
|
||||
npm --prefix agent run typecheck # strict TS; imports relay-contracts / relay-e2e read-only
|
||||
npm --prefix agent test -- config logger # INV9 boundary + redaction
|
||||
npm --prefix agent test -- identity keystore # INV4 (no private-key egress) + INV5 (0600)
|
||||
npm --prefix agent test -- pair csr # §4.5 redemption; code-not-at-rest; single-use respect
|
||||
npm --prefix agent test -- tunnel streamRouter loopback # §4.1 demux, RST-on-illegal, INV11 opaque, 2-stream isolation
|
||||
npm --prefix agent test -- heartbeat backoff flowControl # 15s PING/PONG, 1/2/4…cap-30s, per-stream fairness
|
||||
npm --prefix agent test -- dial rotation revocation # INV14 mTLS + rotation, INV12 revoke→teardown+no-reconnect
|
||||
npm --prefix agent test -- hostEndpoint replaySeal # §4.4 handshake→DirectionalKeys vs P4 vector, INV2 envelope-only, INV13 replay/reorder/tamper; recoverable K_content seal (FIX 3)
|
||||
npm --prefix agent test -- install originConfig # run-as-user (not root), idempotent ALLOWED_ORIGINS append
|
||||
npm --prefix agent test -- acceptance security # all owned-INV tripwires + café-demo agent slice
|
||||
npm --prefix agent run test:coverage # ≥ 80% across agent/src/**
|
||||
|
||||
# Manual café-demo (cross-plan, once P1+P3 exist): on a host behind NAT
|
||||
npx web-terminal-agent pair ABCD-1234 # generates Ed25519 locally, redeems code, installs service
|
||||
web-terminal-agent status # prints host_id/subdomain/online — NO key/cert material
|
||||
# then open https://<subdomain>.term.<domain> on a phone → land in the host shell (P6 verifies the browser end)
|
||||
```
|
||||
|
||||
**Key manual checks:** (1) after `pair`, the private key file is `0600` and the request that hit `/enroll`
|
||||
carried **only** pubkey+CSR (INV4); (2) `status` leaks no secrets (INV9); (3) killing the relay node mid-
|
||||
session → the agent backs off (1/2/4…) and reconnects, **PTY survives** on the host (EXPLORE §3, INV7 on
|
||||
the relay side); (4) revoking the host → its tunnel drops within seconds and does not reconnect (INV12);
|
||||
(5) with E2E (v0.10) a plaintext marker typed in the shell never appears in captured relay traffic (INV2).
|
||||
|
||||
---
|
||||
|
||||
## 6. Open questions (for the orchestrator — do NOT guess; return `[!] BLOCKED` if hit mid-task)
|
||||
|
||||
1. **CSR format for an Ed25519 SPIFFE-style cert** — §4.5 says the agent sends a `csr`, but the exact
|
||||
profile (PKCS#10 vs a SPIFFE SVID request; SAN = the subdomain vs a SPIFFE URI) is set by **P3's CA**.
|
||||
T4/T13 must consume P3's chosen shape; if P3 hasn't frozen it, T4 is BLOCKED on P3.
|
||||
2. **[HARD PRE-W4 GATE — ownership RESOLVED by FIX 6b; the freeze/vector gate remains] `deviceAuthProof`
|
||||
verifier (§4.4 `ClientHello`).** Ownership is now settled: **P5 issues+verifies** the proof
|
||||
(`verifyDeviceProof(proof, { clientEphPub, clientNonce })`); **P4 wires it** into `createHostHandshake`;
|
||||
**P2/T15 consumes it INJECTED through P4** — never `import { verifyDeviceAuthProof } from 'relay-e2e'` (the
|
||||
old import was wrong, INDEX §6b). What remains a hard gate is timing: because the entire anti-MITM
|
||||
guarantee of authenticated-ECDH-through-the-relay rests on this check, **T15 is BLOCKED (not degraded)
|
||||
until P5's verifier is frozen, wired into P4's `createHostHandshake`, AND a cross-plan test vector (a valid
|
||||
proof + ≥1 forged/failing proof) is published.** T15 must never ship against a stub/no-op/always-true
|
||||
verifier (that would let a forged `ClientHello` complete a handshake and derive `DirectionalKeys` — the
|
||||
exact threat). A no-stub CI guard (verifier-mutation test) enforces this at the build. If unmet at
|
||||
dispatch, the builder returns `[!] BLOCKED`; the orchestrator lands the verifier+vector, then re-dispatches
|
||||
T15.
|
||||
3. **[RESOLVED — §4.1 addendum frozen] GOAWAY reason-code semantics.** INDEX §4.1 (line 225) now freezes the
|
||||
mapping as `GoAwayReason = 'operatorDrain' | 'revoked' | 'shutdown'` (string-literal union) +
|
||||
`decodeGoAwayReason` (`1→operatorDrain · 2→revoked · 3→shutdown`, else throws) — the **same** codes P1
|
||||
`drain.ts` `DRAIN_REASON` (`operatorDrain:1, revoked:2, shutdown:3`) and P5 revocation (FIX 4) emit. T14
|
||||
imports the union + decoder **verbatim** from `relay-contracts/` (no local redefinition), maps
|
||||
`operatorDrain`/`shutdown` → drain (reconnect elsewhere) and `revoked` → revoke (stop), and **fails closed
|
||||
to `revoked` on any unknown numeric code** (`decodeGoAwayReason` throws). This closes the earlier drift
|
||||
where P2 assumed a 2-value `'drain' | 'revoked'` union the frozen decoder never emits (a genuine revoke
|
||||
misread as a drain would silently defeat INV12 with both plans' unit tests still green). **T14 is no longer
|
||||
BLOCKED on this** — the contract is frozen in §4 per this INDEX's rule that shared semantics live there.
|
||||
4. **v0.8 `agentToken` transport auth vs frp's own token** — T6 wraps `frpc`; whether the shared token is
|
||||
frp's `auth.token` or a P3-issued value affects the P3 flat-table schema (§4.2 note). Confirm with P3
|
||||
before wiring T6's `frpc.toml`.
|
||||
5. **Cert-rotation renewal endpoint contract** — T13 posts a renewal CSR; the URL/auth (mTLS with the
|
||||
*current* cert? a short renewal token?) is **P3**. Blocked on P3 freezing the renewal route.
|
||||
6. **Multi-device key distribution channel (§4.4)** — "authenticated (account-derived) channel, not a
|
||||
plaintext QR": the agent side (T15) derives per-device keys via per-stream handshakes; whether any
|
||||
agent-side coordination is needed for the *same* session across devices is a **P4** design point —
|
||||
confirm the agent has no extra responsibility beyond per-stream handshake.
|
||||
```
|
||||
871
docs/PLAN_RELAY_AUTH_ISOLATION.md
Normal file
871
docs/PLAN_RELAY_AUTH_ISOLATION.md
Normal file
@@ -0,0 +1,871 @@
|
||||
# PLAN_RELAY_AUTH_ISOLATION — Auth & Tenant Isolation (P5)
|
||||
|
||||
> **Plan P5 of 6** for the Rendezvous-Relay Service. Source of intent:
|
||||
> [`EXPLORE_RELAY_SERVICE.md`](./EXPLORE_RELAY_SERVICE.md) (§0 LOCKED DECISIONS are **CLOSED**).
|
||||
> Coordination point & frozen contracts: [`PLAN_RELAY_INDEX.md`](./PLAN_RELAY_INDEX.md) — this plan
|
||||
> **cites §3 (invariants) and §4 (FROZEN CONTRACTS) by name and MUST NOT redefine any of them locally.**
|
||||
> Conventions follow [`PLAN.md`](./PLAN.md) / [`PLAN_VOICE_COMMANDS.md`](./PLAN_VOICE_COMMANDS.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 (incl. negative/security),
|
||||
> per-task security notes. Progress logged in [`PROGRESS_LOG.md`](./PROGRESS_LOG.md) — **orchestrator-only
|
||||
> writer** (subagents return a ready-to-paste entry, G1).
|
||||
|
||||
---
|
||||
|
||||
## 0. Scope
|
||||
|
||||
**This plan owns the "who are you / may you reach this host / prove it repeatedly" spine of the relay.**
|
||||
It is the *decision layer* — the byte/ciphertext path (P1), the accounts registry storage (P3), the crypto
|
||||
core (P4), and the browser UI (P6) call **into** P5 for every authorization decision but P5 never touches
|
||||
the byte path itself.
|
||||
|
||||
**In scope (this plan, no overlap with the other five):**
|
||||
1. **Human auth** — Passkey/WebAuthn **primary**, TOTP **fallback**, **NEVER SMS**, OIDC SSO for teams;
|
||||
**step-up auth before opening a session** (not just at login).
|
||||
2. **Agent auth** — per-host **mTLS with SPIFFE-style short-lived, auto-rotating certs** (verification +
|
||||
rotation **policy**; DB stores only public keys, §4.2 `agent_pubkey`, INV4). *P3 owns the signing
|
||||
endpoint; P5 owns the cert profile, SPIFFE-ID scheme, verification, and rotation controller.*
|
||||
3. **Capability tokens** on the WS upgrade — issuance + verification of §4.3 `CapabilityToken` (host + rights
|
||||
scope, INV15).
|
||||
4. **Deny-by-default tenant authz** — `account_id` derived from the authenticated principal, **NEVER
|
||||
client-supplied** (INV3), enforced on **every connect AND reattach** (INV6).
|
||||
5. **The hard cross-tenant isolation invariant** (INV1: A can never reach B) + a **permanent CI tripwire
|
||||
test** (device A → host B = **403**).
|
||||
6. **Per-tenant rate-limits / quotas** (sessions, bytes-signalled, enrollments, connect attempts).
|
||||
7. **Global + per-host revocation** that kills live tunnels in **seconds** (INV12).
|
||||
8. **Immutable audit log with ZERO payload** (INV10) + cross-tenant-crossing alerts.
|
||||
|
||||
**Explicitly NOT in scope (owned elsewhere — do not touch):** the mux frame format & data-plane forwarding
|
||||
(P1, §4.1); accounts/hosts/sessions/pairing_codes **table storage + issuance** and the mTLS **signing**
|
||||
endpoint (P3, §4.2/§4.5); E2E handshake & AEAD envelope (P4, §4.4); terminal bytes / any ANSI parsing
|
||||
(never — INV2/INV11); browser UI / SubtleCrypto / client-side preview render (P6). The base app `src/` and
|
||||
`public/` stay **byte-for-byte unchanged** (only P3's install step touches `ALLOWED_ORIGINS`).
|
||||
|
||||
### SECURITY INVARIANTS this plan enforces (from INDEX §3)
|
||||
|
||||
Primary owner (ships the test): **INV1** (cross-tenant isolation + CI tripwire), **INV3** (`account_id` from
|
||||
authenticated principal, never client-supplied), **INV6** (deny-by-default on connect AND reattach),
|
||||
**INV10** (immutable zero-payload audit + cross-tenant alert), **INV12** (fast global/per-host revocation),
|
||||
**INV15** (capability token required on WS upgrade). Co-owner: **INV4** (per-host asymmetric identity — with
|
||||
P2/P3), **INV14** (mTLS SPIFFE short-lived rotating certs — with P2/P3). Consumes/relies on: **INV9**
|
||||
(secrets in env/manager, validated at startup, never logged) applies to every task.
|
||||
|
||||
---
|
||||
|
||||
## 1. Files & packages owned
|
||||
|
||||
**All P5 runtime lives in a NEW top-level package `relay-auth/`** — a dependency-light authorization library
|
||||
(no `ws`, no terminal parser, no DOM) that P1's data plane and P3's control plane import read-only, exactly
|
||||
as `src/types.ts` is imported in the base app. It imports **`relay-contracts/` (§4) read-only** and never
|
||||
redefines a frozen contract. P5-local types that are NOT in §4 (WebAuthn credential, TOTP secret storage,
|
||||
OIDC identity, audit event, rate-limit policy, SPIFFE-ID) live here because §4 explicitly delegates the
|
||||
`webauthn_credentials` / `oidc_identities` tables and the audit/revocation mechanics to "P5-owned" (§4.2).
|
||||
|
||||
| Path | Owner task | Role |
|
||||
|---|---|---|
|
||||
| `relay-auth/package.json`, `relay-auth/tsconfig.json`, `relay-auth/vitest.config.ts` | T1 | package scaffold |
|
||||
| `relay-auth/src/types.ts` | T1 | P5-local Zod schemas + TS types (auth records, audit event, rate policy, SPIFFE-ID) — **frozen after T1**, changed only via T1 |
|
||||
| `relay-auth/src/capability/issue.ts`, `relay-auth/src/capability/verify.ts`, `relay-auth/src/capability/device-proof.ts` | T2 | §4.3 `CapabilityToken` issue + verify (Ed25519 PASETO/JWS) incl. DPoP PoP + single-use; §4.4 `deviceAuthProof` sign/verify |
|
||||
| `relay-auth/src/authz/principal.ts`, `relay-auth/src/authz/decide.ts` | T3 | deny-by-default authz core (INV1/INV3/INV6) |
|
||||
| `relay-auth/src/audit/log.ts`, `relay-auth/src/audit/redact.ts` | T4 | immutable zero-payload audit sink + alert emitter |
|
||||
| `relay-auth/src/human/webauthn/register.ts`, `.../authenticate.ts` | T5 | Passkey/WebAuthn ceremonies |
|
||||
| `relay-auth/src/human/totp/totp.ts` | T6 | TOTP fallback (RFC 6238) |
|
||||
| `relay-auth/src/human/oidc/oidc.ts` | T7 | OIDC SSO (auth-code + PKCE) |
|
||||
| `relay-auth/src/human/stepup/stepup.ts` | T8 | step-up assertion before session open |
|
||||
| `relay-auth/src/agent/spiffe.ts`, `relay-auth/src/agent/verify-mtls.ts`, `relay-auth/src/agent/rotate.ts` | T9 | SPIFFE-ID scheme, cert-chain verification, rotation controller |
|
||||
| `relay-auth/src/revocation/revoke.ts`, `relay-auth/src/revocation/check.ts` | T10 | global + per-host revocation, live-tunnel kill signal |
|
||||
| `relay-auth/src/ratelimit/quota.ts` | T11 | per-tenant token-bucket + quota checks |
|
||||
| `relay-auth/src/enforce/onUpgrade.ts`, `relay-auth/src/enforce/onReattach.ts` | T12 | the two enforcement entry points P1/P3 call |
|
||||
| `relay-auth/test/**` | each task co-owns its own `*.test.ts` | unit/negative tests (TDD, tests FIRST) |
|
||||
| `relay-auth/test/tripwire/cross-tenant.test.ts`, `.github/workflows/relay-tripwire.yml` | T13 | **permanent CI tripwire** (INV1) |
|
||||
|
||||
**Cross-plan storage/transport ports (interfaces, not tables):** P5 does **not** own Postgres/Redis storage
|
||||
or sockets — it defines narrow port interfaces (`HostRegistryPort`, `SessionRegistryPort`, `RevocationStore`
|
||||
incl. `consumeOnce`, `AuditSink`, `CredentialStore`, `TokenBucketStore`, and the push-only `RevocationBus`)
|
||||
that P3/P1 implement against §4.2 storage and the `relay:revocations` pub/sub channel. This keeps P5
|
||||
pure/testable, keeps the immutable-record + atomic-swap ownership (INV8) inside P3, and keeps P5 **socket-free**
|
||||
(the live-tunnel teardown is P1 injecting a §4.1 frame in response to a published `KillSignal`, Finding-2).
|
||||
|
||||
---
|
||||
|
||||
## 2. Dependency waves
|
||||
|
||||
```
|
||||
Wave A (foundations — parallel, file-disjoint)
|
||||
T1 relay-auth scaffold + P5-local types/schemas (v0.8: contracts only)
|
||||
│
|
||||
▼
|
||||
Wave B (mechanisms — parallel after T1)
|
||||
T2 capability token T3 deny-by-default authz T4 audit log
|
||||
T9 mTLS/SPIFFE verify+rotate T10 revocation T11 rate-limit/quota (v0.9 core)
|
||||
T5 WebAuthn T6 TOTP T7 OIDC T8 step-up (v0.10 human auth)
|
||||
│
|
||||
▼
|
||||
Wave C (enforcement + tripwire — converge)
|
||||
T12 enforce onUpgrade/onReattach ← T2,T3,T10,T11 (v0.9 CORE ships here)
|
||||
+ T4,T8 (v0.10 AUGMENTATION: audit emit + step-up gate)
|
||||
T13 CI cross-tenant tripwire ← T12 (v0.9 unit variant = 403 only)
|
||||
+ T4,T14 (v0.10 full-stack variant = audit+alert asserts)
|
||||
T14 audit-alert wiring ← T4,T12 (v0.10)
|
||||
```
|
||||
|
||||
**Phasing note (Finding-8 reconciliation):** T12's v0.9 **core** depends only on T2/T3/T10/T11 (all v0.9),
|
||||
so it ships in v0.9. Its dependency on T4 (audit) and T8 (step-up) is a v0.10 **augmentation** wired through
|
||||
injected deps (`audit`, `stepUpPolicyFor`) that are no-op-safe in v0.9 — a v0.9 task never blocks on a v0.10
|
||||
task. T13's v0.9 unit variant asserts only the 403 outcomes; the "audit event + alert fired" assertions are
|
||||
the v0.10 full-stack variant (needs T4 + T14). The wave diagram and every task `Depends:` line agree on this.
|
||||
|
||||
**Cross-plan dependencies (reference other plans' task IDs where their plans define them):**
|
||||
- **P-INDEX §4** (`relay-contracts/`) is W0 for the whole program — frozen before P5's T1.
|
||||
- **T3/T12 depend on P3's** `HostRegistry`/`SessionRegistry` (the §4.2 tables) to look up `host.account_id`
|
||||
/ `session.account_id`. P5 codes against the port interfaces; P3 supplies the impl.
|
||||
- **T9 depends on P3's mTLS signing endpoint** (§4.5 step 4 "sign short-lived cert") — P3 signs, P5 defines
|
||||
the profile it signs to and verifies + rotates it; and on **P2's** agent-side rotation client.
|
||||
- **T2/T12 are consumed by P1's WS upgrade** (§4.1 OPEN carries `capabilityTokenRef`) and by **P6's**
|
||||
token-request path (login → token).
|
||||
- **T10 publishes a `KillSignal` on the `RevocationBus`** (Redis pub/sub channel `relay:revocations`, §4.2);
|
||||
**P1** subscribes and injects a §4.1 `CLOSE`+RST (per stream) or `GOAWAY` (global) to tear the **live** mux
|
||||
tunnel down within `REVOCATION_PUSH_BUDGET_MS`; **P3** backs the store (`revoked:{jti}`, flip
|
||||
`hosts.status='revoked'`, set `revoked_at`) and the bus. P5 never touches a socket (Finding-2).
|
||||
- **T2 mints/verifies §4.4 `deviceAuthProof`** (`signDeviceAuthProof` / host-side `verifyDeviceProof`, bound to
|
||||
**`{ clientEphPub, clientNonce }`** per INDEX §6b) consumed by **P4's** E2E handshake as an **injected dep**
|
||||
(P4's `DeviceAuthProofProvider` + `createHostHandshake({ verifyDeviceProof, … })`), so P4 binds a device to
|
||||
an account via P5's `AuthenticatedPrincipal`, not its own ad-hoc derivation, and never imports crypto
|
||||
identity from `relay-auth` directly (Finding-7 / §6b).
|
||||
- **`hostContentSecret` delivery to browser devices (§4.5 / FIX 3).** P5 does **NOT** mint or wrap
|
||||
`hostContentSecret` — **P3 mints + wraps it at BIND, sealed to the host's enrolled Ed25519 `agent_pubkey`**
|
||||
(§4.5 `EnrollResult.hostContentSecret`), and **P2 unwraps it locally**. P5's role is the **authorized
|
||||
browser-device delivery gate**: an authorized device obtains `hostContentSecret` **via P5, unwrapped only
|
||||
after auth + step-up (T8)**, so it can re-derive P4's §4.4 `K_content` (`deriveContentKey`) and decrypt
|
||||
replayed ciphertext after a reload. Revoking the host/device (T10) invalidates the wrap → the secret can no
|
||||
longer be unwrapped for that host going forward (INV12). See T8.
|
||||
- **INV9** (secrets validated at startup, never logged) is enforced in every task's config loader.
|
||||
|
||||
---
|
||||
|
||||
## Wave A
|
||||
|
||||
### T1 · `relay-auth` scaffold + P5-local types/schemas · **v0.8 (contracts only)**
|
||||
- **Owns**: `relay-auth/package.json`, `relay-auth/tsconfig.json`, `relay-auth/vitest.config.ts`,
|
||||
`relay-auth/src/types.ts`, `relay-auth/test/types.test.ts`
|
||||
- **Depends**: `relay-contracts/` §4 frozen. **Parallel-safe**: root of all P5 tasks (freeze after T1, like
|
||||
`src/types.ts`).
|
||||
- **Why v0.8**: INDEX §1 v0.8 — "P4/P5 **write their contracts** (so v0.9/v0.10 don't reshape data) but ship
|
||||
no runtime." T1 is that contract freeze; no auth logic runs in v0.8 (the shared `clientToken` gate is P6's).
|
||||
- **Contracts** (P5-local — NOT in §4; §4 types are `import`ed from `relay-contracts`, never re-declared):
|
||||
```ts
|
||||
// Re-export the frozen §4 types for P5 consumers (import, never redefine):
|
||||
export type { CapabilityToken, CapabilityRight } from 'relay-contracts' // §4.3
|
||||
export type { HostRecord, HostStatus, PlanTier } from 'relay-contracts' // §4.2
|
||||
|
||||
// P5-local principal — the ONLY source of account_id in an authz decision (INV3):
|
||||
export type PrincipalKind = 'human' | 'agent' | 'share-grant'
|
||||
export interface AuthenticatedPrincipal {
|
||||
readonly kind: PrincipalKind
|
||||
readonly accountId: string // DERIVED from verified credential/cert — never from client input
|
||||
readonly principalId: string // device/credential id (WebAuthn credId, SPIFFE-ID, or grant jti)
|
||||
readonly amr: readonly AuthMethod[] // methods satisfied this session (for step-up, T8)
|
||||
readonly authAt: number // epoch seconds of last successful auth (login freshness)
|
||||
readonly stepUpAt: number | null // epoch seconds of last successful step-up (T8 freshness); null = never
|
||||
}
|
||||
export type AuthMethod = 'passkey' | 'totp' | 'oidc' | 'mtls' | 'stepup'
|
||||
|
||||
// FROZEN P5 CONVENTION for the §4.3 `CapabilityToken.sub` field (resolves former open-Q #3):
|
||||
// §4.3 defines `sub` as "principal id (account/device)". P5 (the token's sole issuer, T2) BINDS
|
||||
// `sub` to `principal.accountId` at issuance — the account is the authoritative unit for the
|
||||
// cross-tenant gate (INV1/INV3). Device identity is NOT carried in the token (it is not needed for
|
||||
// the tenant gate; step-up presence lives on the session `AuthenticatedPrincipal`, T8). This is a
|
||||
// semantic choice about the VALUE P5 writes into an existing frozen field — it does NOT redefine
|
||||
// §4.3. The single reader is T3's `accountIdFromToken` resolver (below).
|
||||
export const CAP_TOKEN_SUB_IS_ACCOUNT_ID = true as const
|
||||
|
||||
// Step-up policy is a P5-local type (not in §4); T8 evaluates it, T12 enforces it:
|
||||
export interface StepUpPolicy { readonly maxAgeSeconds: number; readonly requiredMethod: AuthMethod }
|
||||
|
||||
export interface WebAuthnCredential { // §4.2 "webauthn_credentials (P5-owned)"
|
||||
readonly credentialId: string; readonly accountId: string
|
||||
readonly publicKey: Uint8Array; readonly signCount: number
|
||||
readonly transports: readonly string[]; readonly createdAt: string
|
||||
}
|
||||
export interface OidcIdentity { // §4.2 "oidc_identities (P5-owned)"
|
||||
readonly issuer: string; readonly subject: string; readonly accountId: string
|
||||
}
|
||||
export interface TotpSecretRecord { // stored ENCRYPTED at rest (INV5); never SMS
|
||||
readonly accountId: string; readonly encSecret: Uint8Array; readonly confirmedAt: string | null
|
||||
}
|
||||
export interface AuditEvent { // INV10 — metadata only, ZERO payload
|
||||
readonly ts: string; readonly action: AuditAction
|
||||
readonly principalId: string; readonly accountId: string
|
||||
readonly hostId: string | null; readonly sessionId: string | null
|
||||
readonly jti: string | null; readonly outcome: 'allow' | 'deny'
|
||||
readonly reason: string; readonly remoteAddrHash: string // salted, §4.1 remoteAddrHash
|
||||
// COMPILE-TIME GUARD: no field may carry keystrokes/output. Enforced by redact.ts (T4) + lint.
|
||||
}
|
||||
export type AuditAction =
|
||||
| 'attach' | 'reattach' | 'manage' | 'kill' | 'enroll'
|
||||
| 'revoke' | 'login' | 'stepup' | 'token-issue' | 'cross-tenant-attempt'
|
||||
|
||||
export type SpiffeId = string // 'spiffe://relay.<domain>/account/<accountId>/host/<hostId>'
|
||||
export interface RateLimitPolicy {
|
||||
readonly connectPerMin: number; readonly enrollPerHour: number
|
||||
readonly maxConcurrentSessions: number; readonly maxPairedHosts: number // by PlanTier
|
||||
readonly preAuthPerMinPerIp: number // pre-principal throttle keyed on remoteAddrHash (T11, INV-DoS)
|
||||
readonly totpMaxFailsPerWindow: number // TOTP brute-force lockout (T6)
|
||||
readonly totpLockoutWindowSec: number
|
||||
}
|
||||
// Revocation kill-signal — FROZEN in relay-contracts §4.2 (FIX 4), NOT redefined here. The teardown
|
||||
// channel + signal shape were promoted OUT of relay-auth into relay-contracts so P1 can subscribe without
|
||||
// importing P5 (keeps the DAG acyclic). P5 (T10) only PUBLISHES; re-export the frozen types + constants:
|
||||
export type { RevocationScope, KillSignal, RevocationBus } from 'relay-contracts' // §4.2, FIX 4 — never redeclared
|
||||
export { RELAY_REVOCATIONS_CHANNEL, REVOCATION_PUSH_BUDGET_MS } from 'relay-contracts' // 'relay:revocations' · 2000ms
|
||||
// Read-only ports P3 implements (P5 stays storage-free & pure):
|
||||
export interface HostRegistryPort { getById(hostId: string): Promise<HostRecord | null> }
|
||||
export interface SessionRegistryPort { getById(sessionId: string): Promise<{ hostId: string; accountId: string } | null> }
|
||||
export interface RevocationStore {
|
||||
isRevoked(jti: string): Promise<boolean>
|
||||
revokeJti(jti: string, exp: number): Promise<void>
|
||||
// single-use consumption of a connect-scoped token (T2/T4 leak-blast-radius control):
|
||||
consumeOnce(jti: string, exp: number): Promise<boolean> // true = first use (proceed); false = replay (deny)
|
||||
}
|
||||
export interface AuditSink { append(e: AuditEvent): Promise<void> }
|
||||
export interface TokenBucketStore { // P3 Redis; keys are OPAQUE to P5
|
||||
take(key: string, refillPerSec: number, burst: number, now: number): Promise<boolean> // false = throttled
|
||||
}
|
||||
// Revocation push bus — `RevocationBus` is FROZEN in relay-contracts §4.2 (FIX 4), re-exported above, NOT
|
||||
// redeclared. P5 (T10) only PUBLISHES immutable `KillSignal`s on `RELAY_REVOCATIONS_CHANNEL`; it never
|
||||
// touches sockets. P3/P1 implement the transport (Redis pub/sub channel `relay:revocations`, §4.2). See T10.
|
||||
// (frozen shape, for reference: interface RevocationBus { publish(signal: KillSignal): Promise<void> })
|
||||
```
|
||||
Every exported interface has a matching **Zod schema** (`WebAuthnCredentialSchema`, `AuditEventSchema`, …)
|
||||
for boundary validation (`coding-style.md` "Input Validation").
|
||||
- **TDD steps**: (RED) `test/types.test.ts` asserts each Zod schema round-trips a valid record and **rejects**
|
||||
a forged one (extra `accountId` field on a client body is stripped; `AuditEvent` rejects any unknown key →
|
||||
zero-payload guard). (GREEN) write `types.ts`. (REFACTOR) file < 400 lines; split schemas into
|
||||
`types.ts` + `schemas.ts` if it crosses 400.
|
||||
- **Test cases**: valid `AuthenticatedPrincipal` parses; `AuditEvent` with an added `data`/`bytes` key →
|
||||
**throws** (INV10 tripwire at the type layer); `SpiffeId` regex accepts the account/host form and rejects a
|
||||
path-traversal (`../`) or wildcard; `CAP_TOKEN_SUB_IS_ACCOUNT_ID === true` (compile+runtime guard that the
|
||||
`sub`-binding convention is stated in exactly one place, consumed by T2 issuance and T3 `accountIdFromToken`).
|
||||
- **Security**: this task **defines** that `accountId` originates only inside `AuthenticatedPrincipal`
|
||||
(INV3) and that `CapabilityToken.sub` carries **that same `accountId`** (frozen P5 convention above) —
|
||||
so T3's cross-tenant gate compares two account ids derived from authenticated material, never a client
|
||||
field. No schema anywhere in P5 accepts a client-supplied `accountId`/`tenant_id` as authoritative.
|
||||
|
||||
---
|
||||
|
||||
## Wave B
|
||||
|
||||
### T2 · Capability token issue + verify (§4.3) + proof-of-possession + device-auth proof · **v0.9**
|
||||
- **Owns**: `relay-auth/src/capability/issue.ts`, `relay-auth/src/capability/verify.ts`,
|
||||
`relay-auth/src/capability/device-proof.ts`, `relay-auth/test/capability.test.ts`,
|
||||
`relay-auth/test/device-proof.test.ts`
|
||||
- **Depends**: T1. **Consumed by**: P1 WS upgrade (INV15), P6 login→token, T10 revocation, T12 enforce,
|
||||
**P4 E2E handshake** (host-side `verifyDeviceProof(proof, { clientEphPub, clientNonce })` validates §4.4
|
||||
`ClientHello.deviceAuthProof`, injected into P4 — INDEX §6b / Finding-7 fix).
|
||||
- **Contract** — implements §4.3 verbatim (`CapabilityToken`, `CapabilityRight`, `verifyCapabilityToken`):
|
||||
```ts
|
||||
import type { CapabilityToken, CapabilityRight } from 'relay-contracts' // §4.3, NOT redefined
|
||||
export interface IssueArgs {
|
||||
readonly principal: AuthenticatedPrincipal // sub := principal.accountId (INV3, T1 convention)
|
||||
readonly aud: string // subdomain (Host-confusion guard, INV1)
|
||||
readonly host: string // single host_id, verified owned by principal.accountId
|
||||
readonly rights: readonly CapabilityRight[] // least-privilege subset
|
||||
readonly ttlSeconds: number // CONNECT-scoped: clamped to [CONNECT_TOKEN_MIN_TTL_SEC=30,
|
||||
// CONNECT_TOKEN_MAX_TTL_SEC=60] — see TTL note below
|
||||
readonly cnfJkt: string // proof-of-possession: base64url SHA-256 of the client's
|
||||
// ephemeral public JWK (DPoP-style channel binding, Finding-4)
|
||||
}
|
||||
// NOTE: `cnfJkt` is embedded as an ADDITIONAL signed claim (`cnf.jkt`, RFC 7800) in the PASETO/JWS token
|
||||
// body. It ADDS a claim; it does NOT redefine the frozen §4.3 `CapabilityToken` interface (which stays the
|
||||
// authoritative semantic shape). Because it adds bytes that cross into P1/P6, it needs the same one-line
|
||||
// INDEX §4.3 confirmation as the token-format pick (§5 open-Q #1). Verifiers read it via `readCnfJkt()`
|
||||
// below, never by mutating the frozen type. Until confirmed, the shipped mitigations (short TTL + single-use
|
||||
// + memory-only client storage) already bound a leak; PoP is the belt-and-suspenders layer.
|
||||
export function readCnfJkt(token: CapabilityToken): string // reads the additive cnf.jkt claim off the verified token
|
||||
export function issueCapabilityToken(a: IssueArgs, signingKey: CryptoKey, now: number): Promise<string>
|
||||
// §4.3 signature — kept VERBATIM (no added params → no contract drift). Verifies token sig/exp/aud only:
|
||||
export function verifyCapabilityToken(raw: string, expectedAud: string, now: number): Promise<CapabilityToken>
|
||||
// DPoP proof-of-possession is a SEPARATE P5-local check (does NOT alter the §4.3 function). The presenting
|
||||
// connection signs (htm,htu,jti,now) with the ephemeral key whose JWK thumbprint == token `cnf.jkt`:
|
||||
export interface DpopContext { readonly proofJws: string; readonly htu: string; readonly htm: string }
|
||||
export function verifyDpopProof(token: CapabilityToken, dpop: DpopContext, now: number): Promise<boolean>
|
||||
// false if cnf.jkt ≠ thumbprint(dpop signer) OR dpop replayed OR htu/htm mismatch
|
||||
export function subAccountId(token: CapabilityToken): string // returns token.sub (the accountId, T1 convention)
|
||||
```
|
||||
**TTL & single-use policy (Finding-4 — leaked-token blast-radius):** connect-scoped tokens are
|
||||
**short-TTL 30–60 s** (`issueCapabilityToken` clamps `ttlSeconds` to that range and rejects longer values)
|
||||
and **single-use**: T12 calls `RevocationStore.consumeOnce(jti, exp)` on the FIRST successful upgrade, so a
|
||||
replayed token (captured in a proxy/log before E2E ships, or a copy-pasted link) is refused on the 2nd use.
|
||||
Longer-lived reuse is obtained only by re-issuing a fresh token via the authenticated login path (P6),
|
||||
never by minting a long TTL. Tokens carry a **`cnf.jkt`** proof-of-possession binding (DPoP-style): the
|
||||
presenting connection must sign a DPoP proof with the ephemeral key it generated, so a bearer copy without
|
||||
that private key cannot upgrade. **P6 MUST hold the token + ephemeral key memory-only (never `localStorage`)**;
|
||||
residual XSS risk is documented in §3.
|
||||
**Device-auth proof (Finding-7 / INDEX §6b — SOLE issuer+verifier of §4.4 `deviceAuthProof`; P4 consumes as
|
||||
an injected dep and never re-derives account binding):** the binding param is **unified across P2/P4/P5 to
|
||||
`{ clientEphPub, clientNonce }`** (frozen §4.4 / INDEX §6b) — a **per-handshake, non-replayable** binding, NOT
|
||||
`transcriptHash` and NOT a static bearer token, so a captured proof cannot be replayed into a different
|
||||
freshly-keyed `client_hello`. P5 mints from the authenticated `AuthenticatedPrincipal` (the proof asserts
|
||||
`principal.accountId`); the tenant gate itself (INV1) is the capability-token cross-tenant check at
|
||||
`onUpgrade` (T3/T12), *upstream* of the handshake.
|
||||
```ts
|
||||
// `relay-auth/src/capability/device-proof.ts` — the SOLE issuer/verifier of §4.4 ClientHello.deviceAuthProof.
|
||||
export type DeviceProofBinding = { readonly clientEphPub: Uint8Array; readonly clientNonce: Uint8Array } // == frozen §4.4 shape
|
||||
// Client-side minting — backs the §4.4 `DeviceAuthProofProvider.proofFor(hostId, binding)` P4 injects:
|
||||
export function signDeviceAuthProof(
|
||||
principal: AuthenticatedPrincipal, binding: DeviceProofBinding, signingKey: CryptoKey, now: number
|
||||
): Promise<string> // binds principal.accountId to THIS handshake's clientEphPub‖clientNonce (per-handshake, non-replayable)
|
||||
// Host-side verification — the injected `verifyDeviceProof` P4's createHostHandshake calls (§4.4 / §6b):
|
||||
export function verifyDeviceProof(
|
||||
proof: string, binding: DeviceProofBinding, now: number
|
||||
): Promise<boolean> // true iff P5-signed AND bound to THIS clientEphPub/clientNonce; a static/unbound or mis-bound proof → false
|
||||
```
|
||||
- **TDD (tests first)**: (RED) `capability.test.ts`, `device-proof.test.ts`. (GREEN) implement with Ed25519
|
||||
PASETO v4.public / JWS EdDSA. (REFACTOR) no `console.log`; typed errors (`CapabilityError` machine reason);
|
||||
split `issue.ts`/`verify.ts`/`device-proof.ts` each < 200 lines.
|
||||
- **Test cases (incl. negative/security)**:
|
||||
- round-trip: issue→verify returns the same `host`, `rights`, `jti`; `subAccountId(token) === principal.accountId`.
|
||||
- **`sub` binding**: `issueCapabilityToken` sets `token.sub = principal.accountId` (never `principalId`,
|
||||
never a client value) — assert directly (feeds T3's cross-tenant gate).
|
||||
- **expired** (`exp < now`) → reject; **not-yet-valid** (`iat > now+skew`) → reject.
|
||||
- **over-long TTL**: `ttlSeconds > 60` → **reject at issue** (clamp/refuse; no long-lived reusable token).
|
||||
- **single-use**: 2nd `verify` after `consumeOnce` has marked the `jti` → reject (replay).
|
||||
- **proof-of-possession**: valid token + DPoP proof from a **different** key (thumbprint ≠ `cnf.jkt`) → reject;
|
||||
replayed DPoP proof (same jti+now reused) → reject.
|
||||
- **wrong `aud`** (token for `alice`, presented at `bob.term.<domain>`) → reject (INV1 Host-confusion).
|
||||
- **tampered payload** (flip a rights bit) → signature fails → reject.
|
||||
- **wildcard `host`** (`'*'`) → reject at issue time (single host only).
|
||||
- **rights escalation**: a token with `rights:['attach']` — `hasRight(token,'kill')` is `false` (INV15).
|
||||
- forged token signed by a different key → reject.
|
||||
- **device-auth proof (binding to `{clientEphPub, clientNonce}`, §6b)**: a proof minted for handshake A (its
|
||||
`clientEphPub`/`clientNonce`) replayed into handshake B with a **different** `clientEphPub` →
|
||||
`verifyDeviceProof(proof, bindingB, now)` returns **`false`** (per-handshake binding defeats replay); a
|
||||
static/unbound bearer proof → `false`; a proof tampered after signing → `false`.
|
||||
- **Security**: `sub`/`host`/`account` are taken from `IssueArgs.principal`, never from a client field
|
||||
(INV3). Token is short-TTL (30–60 s) + single-use + `jti`-revocable (INV12, via T10) + PoP-bound (`cnf.jkt`).
|
||||
`deviceAuthProof` is minted/verified **only here** from P5's `AuthenticatedPrincipal`, bound to
|
||||
**`{ clientEphPub, clientNonce }`** (§6b), so P4's handshake consumes P5's binding rather than inventing its
|
||||
own (Finding-7). Signing key loaded from secret manager,
|
||||
validated at startup, never logged (INV9).
|
||||
|
||||
### T3 · Deny-by-default tenant authz core (INV1/INV3/INV6) · **v0.9**
|
||||
- **Owns**: `relay-auth/src/authz/principal.ts`, `relay-auth/src/authz/decide.ts`,
|
||||
`relay-auth/test/authz.test.ts`
|
||||
- **Depends**: T1, T2; **P3** `HostRegistryPort`/`SessionRegistryPort` (§4.2).
|
||||
- **Contract**:
|
||||
```ts
|
||||
export type AuthzOutcome =
|
||||
// `jti` = the jti of the VERIFIED capability token (resolves reconcile OQ5): P1's thin
|
||||
// adapter no longer verifies the token, so P5 surfaces it here for MuxOpen.capabilityTokenRef
|
||||
// (T10's per-jti splice index / single-device closeStream). P1 consumes read-only; never redefines.
|
||||
| { readonly ok: true; readonly principal: AuthenticatedPrincipal; readonly hostId: string; readonly jti: string }
|
||||
| { readonly ok: false; readonly status: 401 | 403; readonly reason: string }
|
||||
|
||||
export interface ConnectRequest {
|
||||
readonly capabilityRaw: string // token from the WS upgrade (INV15)
|
||||
readonly expectedAud: string // subdomain the request arrived on
|
||||
readonly requestedHostId: string // client NAMES a host_id it claims to own — NOT account_id (INV3)
|
||||
readonly requiredRight: CapabilityRight
|
||||
}
|
||||
export interface ReattachRequest extends ConnectRequest { readonly sessionId: string }
|
||||
|
||||
import type { DpopContext } from '../capability/verify' // T2 — proof-of-possession material (Finding-4), NOT redeclared
|
||||
|
||||
// T3-LOCAL resolver — the SINGLE point that maps a verified token → its authoritative accountId.
|
||||
// Per the frozen T1 convention (CAP_TOKEN_SUB_IS_ACCOUNT_ID), `sub` IS the accountId; this is the
|
||||
// real field the cross-tenant gate compares (resolves former open-Q #3; replaces the undefined
|
||||
// `token.accountBoundFromSub`). Kept as a named function so the mapping is testable & greppable.
|
||||
export function accountIdFromToken(token: CapabilityToken): string // returns token.sub (validated non-empty)
|
||||
|
||||
// The ONLY authorization path (INV1: no host resolved by raw addr/hostname):
|
||||
export function authorizeConnect(
|
||||
req: ConnectRequest, dpop: DpopContext, hosts: HostRegistryPort, revocation: RevocationStore, now: number
|
||||
): Promise<AuthzOutcome>
|
||||
export function authorizeReattach(
|
||||
req: ReattachRequest, dpop: DpopContext, hosts: HostRegistryPort, sessions: SessionRegistryPort,
|
||||
revocation: RevocationStore, now: number
|
||||
): Promise<AuthzOutcome>
|
||||
```
|
||||
**Decision algorithm (deny-by-default, early-return):**
|
||||
```
|
||||
token = await verifyCapabilityToken(req.capabilityRaw, req.expectedAud, now) // fail (sig/exp/aud) → 401
|
||||
if (!(await verifyDpopProof(token, dpop, now))) return 401 // proof-of-possession (Finding-4)
|
||||
if (await revocation.isRevoked(token.jti)) return 403 // INV12
|
||||
if (!token.rights.includes(req.requiredRight)) return 403 // INV15 least-priv
|
||||
if (token.host !== req.requestedHostId) return 403 // token scopes ONE host (INV1)
|
||||
host = await hosts.getById(req.requestedHostId); if (!host) return 403
|
||||
if (host.status === 'revoked') return 403 // INV12
|
||||
// account_id comes from the token's authenticated `sub`, host.account_id from the registry —
|
||||
// NEVER from a client field (INV3). accountIdFromToken() is the sole resolver (T1 convention):
|
||||
tokenAccountId = accountIdFromToken(token) // == token.sub == principal.accountId
|
||||
if (host.accountId !== tokenAccountId) return 403 // THE cross-tenant gate (INV1)
|
||||
// reattach ADDS: session must exist AND belong to same account (INV6 re-validate):
|
||||
if (reattach) { s = await sessions.getById(req.sessionId);
|
||||
if (!s || s.accountId !== host.accountId) return 403 }
|
||||
return { ok:true, principal, hostId: host.hostId }
|
||||
```
|
||||
There is **no `allow-if-unspecified` branch** — the function returns 403 unless every check passes.
|
||||
Note: single-use consumption (`RevocationStore.consumeOnce`) is done by T12 **after** a full allow, so a
|
||||
denied attempt does not burn the token; T3 stays a pure decision function.
|
||||
- **TDD (tests first)**: RED `authz.test.ts` with a fake `HostRegistryPort` seeded with host B owned by
|
||||
account B. GREEN implement. REFACTOR: `decide.ts` < 200 lines; each function < 50 lines; no nesting > 4.
|
||||
- **Test cases (security-critical)**:
|
||||
- **INV1 cross-tenant (THE tripwire, against the real `sub` field)**: a **validly signed** token whose
|
||||
`sub` (= account A) does **not** resolve to `host.accountId` (host owned by B) → `accountIdFromToken(token)`
|
||||
returns A, `host.accountId` is B, gate fails → **403**. This exercises the concrete field, not a placeholder.
|
||||
- **`accountIdFromToken` unit**: returns `token.sub`; throws on empty/malformed `sub` (deny-by-default input guard).
|
||||
- **INV6 reattach cross-tenant**: valid host-A token but `sessionId` owned by B → **403**.
|
||||
- **INV3 forged account**: a `ConnectRequest` shape carrying an extra `account_id` field is *type-impossible*
|
||||
(not in schema) → cannot influence the decision; assert the decision uses only `host.accountId` vs
|
||||
`accountIdFromToken(token)`, never any client-supplied value.
|
||||
- missing/invalid token → **401**; failing DPoP proof (PoP mismatch) → **401**; revoked `jti` → **403**;
|
||||
`requiredRight='kill'` with `attach`-only token → **403**; `token.host !== requestedHostId` → **403**
|
||||
(defeats IDOR by swapping the path host).
|
||||
- happy path: account A + own host + `attach` right + live host → `ok:true`.
|
||||
- **Security**: this is the single deny-by-default gate (INV1/INV3/INV6). Every branch that isn't an explicit
|
||||
allow is a deny. Never resolve a host by anything except a registry lookup keyed on the token-scoped
|
||||
`host_id`.
|
||||
|
||||
### T4 · Immutable zero-payload audit log (INV10) · **v0.10**
|
||||
- **Owns**: `relay-auth/src/audit/log.ts`, `relay-auth/src/audit/redact.ts`,
|
||||
`relay-auth/test/audit.test.ts`
|
||||
- **Depends**: T1; **P3** `AuditSink` (append-only Postgres table / WORM store).
|
||||
- **Contract**:
|
||||
```ts
|
||||
export function buildAuditEvent(p: {
|
||||
action: AuditAction; principal: AuthenticatedPrincipal | null; hostId: string | null
|
||||
sessionId: string | null; jti: string | null; outcome: 'allow' | 'deny'
|
||||
reason: string; remoteAddrHash: string; now: number
|
||||
}): AuditEvent
|
||||
export function assertZeroPayload(e: AuditEvent): void // throws if any value looks like terminal bytes
|
||||
export async function audit(sink: AuditSink, e: AuditEvent): Promise<void> // append-only; never update/delete
|
||||
```
|
||||
- **TDD**: RED `audit.test.ts`. GREEN implement `redact.ts` (`assertZeroPayload` rejects any string field >
|
||||
a metadata length cap or containing control/ESC bytes `\x1b`). REFACTOR keep < 200 lines.
|
||||
- **Test cases**: a well-formed `attach` allow event appends; a `deny` event records `reason`; **payload
|
||||
guard** — an event whose `reason` contains `\x1b[` or a >256-char blob → `assertZeroPayload` **throws**
|
||||
(INV10); the sink is append-only (a test double rejects `update`/`delete`).
|
||||
- **Security**: audit entries are metadata only — principal, host_id, action, ts, outcome (INV10). Terminal
|
||||
payload is *structurally* excluded (no field carries it). Immutable/append-only (INV8-adjacent).
|
||||
|
||||
### T5 · Passkey / WebAuthn (primary) · **v0.10**
|
||||
- **Owns**: `relay-auth/src/human/webauthn/register.ts`, `.../authenticate.ts`,
|
||||
`relay-auth/test/webauthn.test.ts`
|
||||
- **Depends**: T1; **P3** `CredentialStore` (persists `WebAuthnCredential`, §4.2 P5-owned table). Uses
|
||||
`@simplewebauthn/server` (battle-tested, per `development-workflow.md` reuse rule).
|
||||
- **Contract**:
|
||||
```ts
|
||||
export function startRegistration(accountId: string, rpId: string): Promise<RegistrationOptions>
|
||||
export function finishRegistration(accountId: string, resp: RegistrationResponse, expectedChallenge: string,
|
||||
rpId: string, origin: string): Promise<WebAuthnCredential> // verifies attestation
|
||||
export function startAuthentication(accountId: string, rpId: string): Promise<AuthenticationOptions>
|
||||
export function finishAuthentication(cred: WebAuthnCredential, resp: AuthenticationResponse,
|
||||
expectedChallenge: string, rpId: string, origin: string): Promise<AuthenticatedPrincipal>
|
||||
```
|
||||
- **TDD / test cases**: challenge is single-use (replayed challenge → reject); **origin mismatch** (assertion
|
||||
from `evil.com`) → reject; **signCount regression** (counter goes backwards → cloned-authenticator signal)
|
||||
→ reject; unknown `credentialId` → reject; happy path yields a principal with `amr:['passkey']`.
|
||||
- **Security**: Passkey is **primary** (phishing-resistant; the payload is a root-capable shell). `rpId` bound
|
||||
to the tenant subdomain. Never SMS. Challenges from CSPRNG, short TTL, never logged (INV9).
|
||||
|
||||
### T6 · TOTP fallback (RFC 6238, never SMS) · **v0.10**
|
||||
- **Owns**: `relay-auth/src/human/totp/totp.ts`, `relay-auth/test/totp.test.ts`
|
||||
- **Depends**: T1; T11 `TokenBucketStore` (attempt-lockout counter); `TotpSecretRecord` stored **encrypted**
|
||||
at rest (INV5).
|
||||
- **Contract**:
|
||||
```ts
|
||||
export function generateTotpSecret(): { secret: Uint8Array; otpauthUri: string }
|
||||
export function verifyTotp(secret: Uint8Array, code: string, now: number, window?: number): boolean // ±1 step
|
||||
// Brute-force lockout (Finding-6): a 6-digit code has only 10^6 space; without throttling it is
|
||||
// guessable within one 30 s step. Called BEFORE verifyTotp; records failures; locks per-account.
|
||||
export function checkTotpAttemptRate(
|
||||
accountId: string, policy: RateLimitPolicy, store: TokenBucketStore, now: number
|
||||
): Promise<boolean> // false = locked out (deny without even checking the code)
|
||||
export function recordTotpFailure(accountId: string, store: TokenBucketStore, now: number): Promise<void>
|
||||
```
|
||||
- **TDD / test cases**: known RFC-6238 vector passes; code from the previous 30 s step within `window` passes;
|
||||
two steps stale → fail; **replay guard** — the caller (T8) marks a consumed `(secret, step)` used, so the
|
||||
same code inside the window can't be reused; malformed code (non-numeric / wrong length) → `false`;
|
||||
**lockout (Finding-6)** — after `policy.totpMaxFailsPerWindow` (e.g. 5) consecutive bad codes within
|
||||
`policy.totpLockoutWindowSec`, `checkTotpAttemptRate` returns `false` (further attempts denied without
|
||||
hitting `verifyTotp`) and unlocks only after the window elapses (escalating backoff); a correct code
|
||||
after a failure below the threshold still succeeds.
|
||||
- **Security**: TOTP is a *fallback*, mandatory-second-factor style, **never SMS** (SIM-swap → shell =
|
||||
catastrophic, EXPLORE §4a.2). Secret encrypted at rest, decrypted only in-process (INV5/INV9). The
|
||||
per-account failed-attempt lockout (via T11's `TokenBucketStore`, keyed on the authenticated `accountId`,
|
||||
INV3) closes the brute-force hole — a 6-digit code is otherwise trivially guessable in one step window.
|
||||
|
||||
### T7 · OIDC SSO for teams · **v0.10**
|
||||
- **Owns**: `relay-auth/src/human/oidc/oidc.ts`, `relay-auth/test/oidc.test.ts`
|
||||
- **Depends**: T1; `OidcIdentity` store (P3). Auth-code flow **+ PKCE**, `state`/`nonce` validation.
|
||||
- **Contract**:
|
||||
```ts
|
||||
export function buildAuthUrl(cfg: OidcConfig, state: string, nonce: string, codeChallenge: string): string
|
||||
export function exchangeCode(cfg: OidcConfig, code: string, codeVerifier: string): Promise<OidcTokenSet>
|
||||
export function verifyIdToken(cfg: OidcConfig, idToken: string, expectedNonce: string, now: number)
|
||||
: Promise<OidcIdentity> // links issuer+subject → accountId (team mapping)
|
||||
```
|
||||
- **TDD / test cases**: `state` mismatch → reject (CSRF); `nonce` mismatch → reject (replay); expired/bad-`iss`
|
||||
id_token → reject; unknown issuer not in the allow-list → reject; happy path returns `OidcIdentity` mapped
|
||||
to the team account.
|
||||
- **Security**: PKCE mandatory; issuer allow-list from config (validated at startup, INV9); `account_id`
|
||||
derived from the verified `(iss, sub)` mapping, never from a client claim (INV3).
|
||||
|
||||
### T8 · Step-up auth before opening a session · **v0.10**
|
||||
- **Owns**: `relay-auth/src/human/stepup/stepup.ts`, `relay-auth/test/stepup.test.ts`
|
||||
- **Depends**: T1 (`StepUpPolicy` type lives there, imported not redefined); T5 (preferred) / T6;
|
||||
T2 (a session-open capability token is only issued after step-up passes). **Consumed by T12** (the
|
||||
enforcement path calls `needsStepUp` before returning `ok:true` — see T12 v0.10 augmentation).
|
||||
- **Contract** (`StepUpPolicy` imported from `relay-auth/src/types.ts`, T1 — NOT redefined here):
|
||||
```ts
|
||||
import type { StepUpPolicy } from '../../types' // T1
|
||||
export function policyForHost(host: HostRecord): StepUpPolicy // per-host step-up freshness requirement
|
||||
export function needsStepUp(principal: AuthenticatedPrincipal, policy: StepUpPolicy, now: number): boolean
|
||||
export function recordStepUp(principal: AuthenticatedPrincipal, method: AuthMethod, now: number): AuthenticatedPrincipal // immutable copy
|
||||
```
|
||||
`needsStepUp` is true when `principal.stepUpAt` is null OR `now - principal.stepUpAt > policy.maxAgeSeconds`
|
||||
OR `policy.requiredMethod ∉ principal.amr` — i.e. a *fresh login is not sufficient*; a recent step-up is
|
||||
required. `recordStepUp` returns a new principal with `stepUpAt = now` and `method` added to `amr`.
|
||||
- **TDD / test cases**: a principal freshly logged in (`authAt = now`) but with `stepUpAt = null` →
|
||||
`needsStepUp` **true** (login ≠ step-up — this is the exact case T12 must refuse, Finding-3); a principal
|
||||
whose `stepUpAt` is older than `maxAgeSeconds` → true; fresh passkey step-up (`stepUpAt = now`,
|
||||
requiredMethod in `amr`) → false; `recordStepUp` returns a **new** principal (immutability rule), original
|
||||
untouched; a token to **open a session** is refused by T12 unless `needsStepUp` is false.
|
||||
- **Security**: step-up gates **opening a session**, not just login (EXPLORE §4a.2 / INDEX §1 v0.10). This is
|
||||
the "before you drop into a live root-capable shell, re-assert presence" control. It is enforced in T12's
|
||||
`onUpgrade`/`onReattach` sequence (v0.10 augmentation), **not** merely asserted in prose (Finding-3).
|
||||
- **`hostContentSecret` browser-delivery gate (§4.5 / FIX 3).** A fresh step-up here is **also** the gate for
|
||||
releasing the §4.5 `hostContentSecret` to an authorized browser device: it is **unwrapped for the device only
|
||||
after `needsStepUp` is false**, so the device can re-derive P4's §4.4 `K_content` (`deriveContentKey`) and
|
||||
decrypt replayed ciphertext. **P5 does not mint/wrap it — P3 mints+wraps at BIND (sealed to `agent_pubkey`),
|
||||
P2 unwraps for the agent** — P5 only performs the step-up-gated release to browser devices; a revoked
|
||||
host/device (T10) can no longer obtain it (INV12).
|
||||
|
||||
### T9 · mTLS SPIFFE cert profile, verification & rotation (INV14/INV4) · **v0.9**
|
||||
- **Owns**: `relay-auth/src/agent/spiffe.ts`, `relay-auth/src/agent/verify-mtls.ts`,
|
||||
`relay-auth/src/agent/rotate.ts`, `relay-auth/test/mtls.test.ts`
|
||||
- **Depends**: T1; **P3** signing endpoint (§4.5 step 4 signs the cert P5 profiles); **P2** agent-side
|
||||
rotation client. P5 owns the **profile + verification + rotation policy**, not the CA private key (P3).
|
||||
- **Contract**:
|
||||
```ts
|
||||
export function spiffeIdFor(accountId: string, hostId: string): SpiffeId // 'spiffe://relay.<domain>/account/<a>/host/<h>'
|
||||
export function parseSpiffeId(uri: SpiffeId): { accountId: string; hostId: string }
|
||||
export interface MtlsVerifyResult { readonly ok: boolean; readonly hostId?: string; readonly accountId?: string; readonly reason?: string }
|
||||
// Verify the presented client cert chain at the agent tunnel handshake:
|
||||
export function verifyAgentCert(leafPem: string, caChainPem: string, now: number,
|
||||
hosts: HostRegistryPort): Promise<MtlsVerifyResult> // SPIFFE-ID SAN must match a host in registry
|
||||
export interface RotationPlan { readonly renewAtSeconds: number; readonly certTtlSeconds: number } // TTL short (e.g. 1h), renew at ~50%
|
||||
export function shouldRotate(certNotAfter: number, now: number, plan: RotationPlan): boolean
|
||||
```
|
||||
- **TDD / test cases**: cert whose SPIFFE-ID SAN account/host **is not in the registry** → `ok:false` (CA
|
||||
never validates a pubkey not enrolled, INV4); **expired** leaf → refuse; **SPIFFE-ID mismatch** (cert for
|
||||
host X presented on host Y's tunnel) → refuse; `shouldRotate` true at ~50% TTL and true after expiry, false
|
||||
when fresh; rotation mid-tunnel is seamless (a new valid cert supersedes without dropping — asserted via the
|
||||
rotation-plan boundary).
|
||||
- **Security**: agents authenticate with a **per-host Ed25519 key + short-TTL rotating cert** (INV14); DB
|
||||
holds only the **public** key (INV4). Revocation = "stop renewing + kill tunnel" (T10) — no CRL/OCSP
|
||||
nightmare. mismatched/expired/foreign certs are refused deny-by-default.
|
||||
|
||||
### T10 · Global + per-host revocation, kills live tunnels in seconds (INV12) · **v0.9 (fast-push: v0.10)**
|
||||
- **Owns**: `relay-auth/src/revocation/revoke.ts`, `relay-auth/src/revocation/check.ts`,
|
||||
`relay-auth/test/revocation.test.ts`
|
||||
- **Depends**: T1 (`RevocationScope`/`KillSignal` types), T2; **P3** `RevocationStore` (Redis `revoked:{jti}`,
|
||||
§4.2; flip `hosts.status='revoked'`, set `revoked_at`) + `RevocationBus` (Redis pub/sub); **P1** subscribes
|
||||
to the bus and force-closes the live streams.
|
||||
- **Contract** (`RevocationScope`/`KillSignal` imported from T1 `types.ts`, NOT redefined here):
|
||||
```ts
|
||||
// RevocationScope/KillSignal/RevocationBus + RELAY_REVOCATIONS_CHANNEL/REVOCATION_PUSH_BUDGET_MS are FROZEN
|
||||
// in relay-contracts §4.2 (FIX 4), re-exported by T1 types.ts — imported here, NEVER redefined:
|
||||
import type { RevocationScope, KillSignal, RevocationBus } from '../types' // T1 re-export of relay-contracts §4.2
|
||||
import { RELAY_REVOCATIONS_CHANNEL, REVOCATION_PUSH_BUDGET_MS } from '../types' // 'relay:revocations' · 2000ms
|
||||
export function revoke(
|
||||
scope: RevocationScope, store: RevocationStore, hosts: HostRegistryPort, bus: RevocationBus, now: number
|
||||
): Promise<KillSignal>
|
||||
// Steps (ordered): 1) mark revoked in store (revoked:{jti} / hosts.status='revoked', revoked_at)
|
||||
// 2) stop cert renewal (T9 shouldRotate sees revoked → never renews)
|
||||
// 3) PUBLISH the KillSignal on RevocationBus ← the push channel (Finding-2)
|
||||
// 4) return the KillSignal
|
||||
export function isTokenRevoked(jti: string, store: RevocationStore): Promise<boolean>
|
||||
export function killsScope(signal: KillSignal, hostAccountId: string, hostId: string): boolean // does this signal cover this stream?
|
||||
```
|
||||
**Live-tunnel teardown transport (resolves former open-Q #4, Finding-2; contract FROZEN in §4.2 by FIX 4):**
|
||||
revocation must tear down an **already-open** stream, not merely refuse the next connect. P5 stays socket-free:
|
||||
`revoke()` **publishes** an immutable `KillSignal` on the injected `RevocationBus`, whose transport is the
|
||||
**frozen `RELAY_REVOCATIONS_CHANNEL = 'relay:revocations'`** Redis pub/sub channel (relay-contracts §4.2, FIX 4 —
|
||||
P3/P1 back it). Every P1 relay node subscribes; on receipt it injects a **§4.1 control frame** for each affected
|
||||
stream — a per-stream `CLOSE` with `flags.RST` (abnormal close) for host/account scope, or a connection-level
|
||||
`GOAWAY` (streamId 0) for global drain — using the **existing frozen §4.1 frame format** (no new wire type, no
|
||||
P5↔socket coupling). `killsScope()` is the pure predicate P1 uses to decide which streams a signal covers (host
|
||||
match, account match, or global). Bounded latency target: **teardown within the frozen
|
||||
`REVOCATION_PUSH_BUDGET_MS` (= 2000 ms, §4.2)** of `revoke()` returning. **P3 OQ4's "control→node drain channel"
|
||||
reconciles to this exact channel** (FIX 4), so P3 and P5 publish to one named bus.
|
||||
- **TDD / test cases**: revoking a host → its capability `jti`s stop validating (T2 verify + T3 authz both
|
||||
now 403); **live-tunnel teardown (Finding-2)** — with a fake `RevocationBus` + a fake P1 subscriber holding
|
||||
an already-authorized open stream, `revoke()` publishes a `KillSignal` and the subscriber force-closes that
|
||||
stream (asserted via `killsScope` selecting it and an injected RST) **within `REVOCATION_PUSH_BUDGET_MS`** —
|
||||
i.e. the running shell is dropped, not left alive until the client happens to reconnect; a **subsequent
|
||||
reconnect is also refused** (INV12); global revocation covers all hosts (GOAWAY); account revocation covers
|
||||
only that account's hosts and no other tenant's (`killsScope` false for a foreign account); revocation is
|
||||
idempotent (re-publish is a no-op teardown).
|
||||
- **Security**: one control to kill all tunnels + stop cert issuance (INV12). Fast **push** path: Redis
|
||||
`revoked:{jti}` short-TTL keys (for the connect-time gate) **plus** a `relay:revocations` pub/sub `KillSignal`
|
||||
that the data plane (P1) turns into a §4.1 RST/GOAWAY, so an active session's socket drops in seconds — the
|
||||
live shell of a revoked account cannot keep streaming until token expiry.
|
||||
|
||||
### T11 · Rate-limits / quotas (pre-auth IP + per-tenant account) · **v0.9**
|
||||
- **Owns**: `relay-auth/src/ratelimit/quota.ts`, `relay-auth/test/quota.test.ts`
|
||||
- **Depends**: T1; `RateLimitPolicy` by `PlanTier` (§4.2); a `TokenBucketStore` port (P3 Redis, T1).
|
||||
- **Contract**:
|
||||
```ts
|
||||
export function policyForPlan(plan: PlanTier): RateLimitPolicy
|
||||
export const PRE_AUTH_POLICY: RateLimitPolicy // fixed, plan-independent (no account known yet)
|
||||
// PRE-AUTH throttle (Finding-5): applied in T12 onUpgrade BEFORE token verification, keyed on the
|
||||
// salted client-IP hash (§4.1 remoteAddrHash) — the ONLY identifier available before a principal exists.
|
||||
// Blunts subdomain enumeration, capability-token-verifier brute-force, and WebAuthn/TOTP guessing DoS.
|
||||
export function checkPreAuthRate(remoteAddrHash: string, store: TokenBucketStore, now: number): Promise<boolean>
|
||||
// PER-TENANT quotas (keyed on the authenticated accountId, INV3):
|
||||
export function checkConnectRate(accountId: string, policy: RateLimitPolicy, store: TokenBucketStore, now: number): Promise<boolean>
|
||||
export function checkConcurrentSessions(accountId: string, active: number, policy: RateLimitPolicy): boolean
|
||||
export function checkEnrollRate(accountId: string, policy: RateLimitPolicy, store: TokenBucketStore, now: number): Promise<boolean>
|
||||
```
|
||||
- **TDD / test cases**: **pre-auth (Finding-5)** — a single `remoteAddrHash` hammering the upgrade endpoint
|
||||
across **many distinct subdomains / `host_id`s with invalid or forged tokens** is throttled by
|
||||
`checkPreAuthRate` after `PRE_AUTH_POLICY.preAuthPerMinPerIp`, **independent of any account** (there is none
|
||||
yet), and the throttle refills after its window; connects beyond `connectPerMin` → `false` (429 at the
|
||||
caller); at limit → allowed, over → denied, refills after the window; concurrent sessions over
|
||||
`maxConcurrentSessions` → denied; enroll spam over `enrollPerHour` → denied; per-account limits are
|
||||
**per `accountId`** — account A hitting its limit does not affect account B; **cross-key isolation** — a
|
||||
pre-auth IP throttle and an account quota use disjoint key namespaces (no bleed).
|
||||
- **Security**: two layers. The **pre-auth IP throttle** stops enumeration/brute-force/DoS *before* a
|
||||
principal is established (Finding-5) — without it, an unauthenticated attacker could hammer the token
|
||||
verifier and WebAuthn/TOTP ceremonies unthrottled. The **per-tenant quotas** blunt authenticated
|
||||
DoS/enrollment abuse (EXPLORE §4e HIGH), keyed strictly on the authenticated `accountId` (INV3), never a
|
||||
client-supplied identifier. Pre-auth keys are the salted `remoteAddrHash` only (no raw IP at rest, INV5/INV9).
|
||||
|
||||
---
|
||||
|
||||
## Wave C
|
||||
|
||||
### T12 · Enforcement entry points (`onUpgrade` / `onReattach`) · **v0.9 core + v0.10 augmentation**
|
||||
- **Owns**: `relay-auth/src/enforce/onUpgrade.ts`, `relay-auth/src/enforce/onReattach.ts`,
|
||||
`relay-auth/test/enforce.test.ts`
|
||||
- **Depends**: **v0.9 core** → T2, T3, T10, T11 (this matches the Wave-C DAG; reconciled per Finding-8).
|
||||
**v0.10 augmentation** → T4 (audit emission) + T8 (step-up gate). The v0.9 build ships and passes with
|
||||
the audit/step-up hooks as **no-op-safe injected deps** (an `AuditSink` that appends and a step-up policy
|
||||
that returns "not required" when no policy is configured); v0.10 supplies the real audit sink and per-host
|
||||
step-up policy. No v0.9 task blocks on a v0.10 task. **Consumed by**: **P1** (calls `onUpgrade` on the WS
|
||||
handshake, INV15), **P3** (calls on connect/reattach). These are the *only* two functions the byte plane invokes.
|
||||
**Sole owner of the WS-upgrade authorization DECISION (INDEX §6a).** The full decision — Origin/CSWSH retained
|
||||
+ capability 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 `onUpgrade`/`onReattach`.
|
||||
**P1 T8 `authorizeUpgrade` is a THIN ADAPTER that DELEGATES here** (§6a): it parses the upgrade (Origin,
|
||||
subprotocol token via §4.3 FIX 5, subdomain), builds the `UpgradeContext` — **including `dpop: DpopContext`
|
||||
and `activeSessionCount`** — calls P5, and maps the returned `AuthzOutcome` to a §4.1 `MuxOpen` or a 401/403
|
||||
close. P1 holds **no** independent authz logic; P5 is the injected authorizer.
|
||||
- **Contract**:
|
||||
```ts
|
||||
export interface UpgradeContext {
|
||||
readonly capabilityRaw: string; readonly originHeader: string; readonly expectedAud: string
|
||||
readonly requestedHostId: string; readonly requiredRight: CapabilityRight; readonly remoteAddrHash: string
|
||||
readonly activeSessionCount: number
|
||||
readonly dpop: DpopContext // proof-of-possession material (T2/T3, Finding-4)
|
||||
readonly principal: AuthenticatedPrincipal | null // session principal for the step-up freshness check (T8)
|
||||
}
|
||||
export interface EnforceDeps {
|
||||
readonly hosts: HostRegistryPort; readonly sessions: SessionRegistryPort
|
||||
readonly revocation: RevocationStore; readonly buckets: TokenBucketStore; readonly audit: AuditSink
|
||||
readonly stepUpPolicyFor: (host: HostRecord) => StepUpPolicy // v0.10; v0.9 injects a "never required" policy
|
||||
}
|
||||
// Retain Origin/CSWSH check AND require capability token (INV15) + deny-by-default authz (INV6):
|
||||
export function onUpgrade(ctx: UpgradeContext, deps: EnforceDeps, allowedOrigins: readonly string[], now: number): Promise<AuthzOutcome>
|
||||
export function onReattach(ctx: UpgradeContext & { sessionId: string }, deps: EnforceDeps, allowedOrigins: readonly string[], now: number): Promise<AuthzOutcome>
|
||||
```
|
||||
**Sequence (deny-by-default, audited), in order:**
|
||||
1. **Origin/CSWSH**: `originHeader ∈ allowedOrigins` else **401** (retain base-app check, INV15).
|
||||
2. **Pre-auth throttle (Finding-5)**: `checkPreAuthRate(ctx.remoteAddrHash, …)` **before any token work**
|
||||
— keyed on the IP hash, no principal yet; over limit → **429-equiv deny**. Blunts enumeration/brute-force.
|
||||
3. **Per-tenant rate**: after a principal is known, `checkConnectRate(accountId, …)` (T11) → deny if over.
|
||||
4. **Authz (deny-by-default)**: `authorizeConnect`/`authorizeReattach(req, ctx.dpop, …)` (T3) — verifies the
|
||||
capability token incl. **DPoP proof-of-possession**, then the INV1 cross-tenant gate. Fail → 401/403.
|
||||
5. **Step-up gate (Finding-3, v0.10 augmentation)**: on authz **allow**, before returning `ok:true`, call
|
||||
`needsStepUp(ctx.principal, deps.stepUpPolicyFor(host), now)` (T8). If true → **403** + a
|
||||
`AuditEvent(action:'stepup', outcome:'deny')`. This is the exact guarantee T8 claims:
|
||||
fresh-login-but-stale-step-up is refused at `onUpgrade`, before a PTY is reachable.
|
||||
6. **Single-use consume (Finding-4)**: on a fully-allowed connect, `revocation.consumeOnce(token.jti, exp)` —
|
||||
if it returns false (already used) → **403** (replayed token). (Reattach reuses the same session-scoped
|
||||
token semantics; connect-scoped tokens are burned here.)
|
||||
7. **Audit + return**: on **deny** emit exactly one `deny` `AuditEvent` (T4; `cross-tenant-attempt` action
|
||||
when `host.accountId !== accountIdFromToken(token)`) and return the 401/403; on **allow** emit an
|
||||
`attach`/`reattach` allow event and return `ok:true`. No allow path writes payload (INV10).
|
||||
|
||||
**Session-open boundary (resolves former open-Q #2, Finding-3):** the step-up gate and the whole authz
|
||||
decision run at the **WS upgrade** (`onUpgrade`), i.e. **before** the relay dials `ws://127.0.0.1:3000` and
|
||||
before any `attach` frame can reach a PTY — the base app's `attach`-first-frame is *downstream* of a stream
|
||||
that P1 only opens after `onUpgrade` returns `ok:true`. So "opening a session" is gated at the upgrade, not
|
||||
left to the attach frame; a stale-step-up principal never gets a live stream.
|
||||
- **TDD / test cases**:
|
||||
- **v0.9 core**: foreign Origin → **401** even with a valid token (base-app behavior retained, INV15);
|
||||
valid Origin, no token → 401; failing DPoP proof → 401 (Finding-4); valid Origin + token for host B while
|
||||
authed as A → **403** (INV1); pre-auth: one IP hammering many subdomains with bad tokens → throttled
|
||||
**before** token verification, independent of account (Finding-5); per-account rate-limited → 429-equiv
|
||||
deny; reattach to a foreign session → 403; replayed single-use token (2nd upgrade) → 403; happy path →
|
||||
`ok:true`.
|
||||
- **v0.10 augmentation**: valid Origin + valid capability token + authz allow, but principal with
|
||||
`stepUpAt=null` (fresh login only) and a host policy requiring step-up → **403** at `onUpgrade`
|
||||
(Finding-3, matches T8's claimed guarantee); after `recordStepUp`, same request → `ok:true`; every deny
|
||||
path writes exactly one `AuditEvent` (incl. a `cross-tenant-attempt` on A→B and a `stepup` deny on stale
|
||||
step-up); no allow path writes payload.
|
||||
- **Security**: this is where Origin + pre-auth throttle + capability token (PoP + single-use) +
|
||||
deny-by-default authz + step-up + per-tenant rate + audit compose. Origin check is **retained AND
|
||||
augmented** (INV15) — never weakened (INDEX §2 rule 6). The step-up check is on the enforcement path, not
|
||||
prose (Finding-3); the pre-auth throttle precedes token work (Finding-5).
|
||||
|
||||
### T13 · Permanent CI cross-tenant tripwire (INV1) · **v0.9**
|
||||
- **Owns**: `relay-auth/test/tripwire/cross-tenant.test.ts`, `.github/workflows/relay-tripwire.yml`
|
||||
- **Depends**: T12 (and, for the full-stack variant, P1's data plane + P3's registry — the unit variant uses
|
||||
in-memory port fakes so the tripwire runs even before P1/P3 integrate).
|
||||
- **Contract**: a self-contained test that seeds account A (host A) and account B (host B), authenticates as A,
|
||||
and asserts **every** A→B path returns **403**. Two variants (reconciled per Finding-8 phasing):
|
||||
- **v0.9 unit variant (403 outcomes only — the required CI check):** uses in-memory port fakes; asserts
|
||||
the 403s. Depends only on T12 core (T2/T3/T10/T11):
|
||||
- `onUpgrade` with A's token but `requestedHostId = hostB` → **403**.
|
||||
- `onReattach` with A's token but a `sessionId` owned by B → **403**.
|
||||
- fuzz: 1000 random UUIDv4 `host_id`s not owned by A → all **403** (guessing/reuse defense, INV1).
|
||||
- `aud` confusion: A's token replayed at `bob.term.<domain>` → **403**.
|
||||
- **v0.10 full-stack variant (audit + alert):** once T4 (audit) and T14 (alert) ship, additionally assert a
|
||||
`cross-tenant-attempt` audit event **and** an alert fired for each A→B attempt. These assertions are
|
||||
**deferred to v0.10** so the v0.9 unit tripwire does not depend on v0.10 tasks (Finding-8).
|
||||
- **CI**: `.github/workflows/relay-tripwire.yml` runs this on **every push/PR** and is a **required check** —
|
||||
a green build is impossible if cross-tenant isolation regresses. This is the permanent tripwire from
|
||||
EXPLORE §4b / INV1; it must never be deleted or skipped (a comment in the file states this).
|
||||
- **Security**: this is the load-bearing regression guard for the single non-negotiable invariant (INV1).
|
||||
If this test ever goes red, **block merge** (CRITICAL, `code-review.md`).
|
||||
|
||||
### T14 · Audit-alert wiring (cross-tenant crossing) · **v0.10**
|
||||
- **Owns**: `relay-auth/src/audit/alert.ts`, `relay-auth/test/alert.test.ts`
|
||||
- **Depends**: T4, T12. **Consumed by**: an ops alerting channel (P3/infra).
|
||||
- **Contract**:
|
||||
```ts
|
||||
export interface Alerter { fire(kind: 'cross-tenant' | 'revocation' | 'auth-anomaly', e: AuditEvent): Promise<void> }
|
||||
export function onAuditEvent(e: AuditEvent, alerter: Alerter): Promise<void> // fires on action==='cross-tenant-attempt'
|
||||
```
|
||||
- **TDD / test cases**: a `cross-tenant-attempt` event fires a `cross-tenant` alert; an ordinary `attach`
|
||||
allow does **not** alert; alert payload carries metadata only (no terminal bytes, INV10).
|
||||
- **Security**: turns the INV1 tripwire into a **runtime** detector — an A→B attempt in production raises an
|
||||
operator alert immediately (EXPLORE §4e HIGH, INV10).
|
||||
|
||||
---
|
||||
|
||||
## 3. SECURITY (per-plan summary)
|
||||
|
||||
| Control | Task(s) | Invariant |
|
||||
|---|---|---|
|
||||
| `account_id` only ever from the authenticated principal; no client field trusted | T1, T3, T12 | **INV3** |
|
||||
| Deny-by-default authz on connect **and** reattach (no allow-if-unspecified branch) | T3, T12 | **INV6** |
|
||||
| Cross-tenant isolation A↛B + fuzz + `aud`-confusion guard + **permanent CI tripwire** | T3, T12, **T13** | **INV1** |
|
||||
| Capability token required on WS upgrade; Origin/CSWSH retained AND augmented; least-privilege rights | T2, T12 | **INV15** |
|
||||
| Capability tokens: **short-TTL 30–60 s**, **single-use** (`consumeOnce`), **DPoP proof-of-possession** (`cnf.jkt`) so a leaked bearer copy can't upgrade; token memory-only in P6 | T2, T12 | **INV15** (leak blast-radius) |
|
||||
| §4.4 `deviceAuthProof` minted/verified only by P5 from `AuthenticatedPrincipal` (P4 consumes, never re-derives account binding) | T2 (→P4) | **INV3** |
|
||||
| **Pre-auth** IP/`remoteAddrHash`-keyed throttle before token verification (enumeration/brute-force/DoS) **plus** per-tenant quotas | T11, T12 | (DoS/abuse, §4e HIGH) |
|
||||
| TOTP failed-attempt **lockout/backoff** (per-account, via T11 bucket) closes the 10^6 brute-force hole | T6, T11 | (second-factor integrity) |
|
||||
| Live-tunnel **push teardown**: `revoke()` → `RevocationBus` → P1 injects §4.1 RST/GOAWAY within `REVOCATION_PUSH_BUDGET_MS`; not just next-connect refusal | T10 (→P1) | **INV12** |
|
||||
| Per-host asymmetric identity; DB stores only public keys; private key never leaves host | T9 (with P2/P3) | **INV4** |
|
||||
| mTLS SPIFFE short-TTL auto-rotating certs; CA never signs a non-enrolled pubkey | T9 (with P2/P3) | **INV14** |
|
||||
| Fast global/per-host revocation kills live tunnels in seconds | T10 | **INV12** |
|
||||
| Immutable, append-only, ZERO-payload audit + cross-tenant alert | T4, T12, T14 | **INV10** |
|
||||
| Passkey primary (phishing-resistant), TOTP fallback, **never SMS**, OIDC PKCE, step-up before session open | T5–T8 | (auth model, LOCKED §0.5) |
|
||||
| Per-tenant rate-limits/quotas keyed on authenticated `accountId` | T11 | (DoS/abuse, §4e HIGH) |
|
||||
| Secrets in env/secret-manager, validated at startup, never logged | all (config loaders) | **INV9** |
|
||||
|
||||
**Residual risk — capability-token exfiltration (Finding-4, documented not hidden):** the payload behind a
|
||||
successful upgrade is a full, potentially root-capable shell. P5 shrinks the blast radius of a leaked token to
|
||||
near-zero — **short 30–60 s TTL**, **single-use** (`consumeOnce` burns the `jti` on first upgrade), and a
|
||||
**DPoP `cnf.jkt` proof-of-possession** so a bearer copy without the client's ephemeral private key cannot
|
||||
upgrade. The one residual vector is a browser **XSS** that both reads the in-memory token *and* uses the
|
||||
in-memory ephemeral key within the 30–60 s window; P6 MUST therefore hold both **memory-only (never
|
||||
`localStorage`/`sessionStorage`)** and P4's E2E (v0.10) further removes the plaintext-shell reward. This
|
||||
residual is accepted and tracked, not silently ignored.
|
||||
|
||||
**Deliberate non-goals (owned elsewhere / structural):** P5 never touches terminal bytes → INV2/INV11 hold
|
||||
**by construction** (the package has no `ws`, no xterm, no ANSI parser). Immutable storage records + atomic
|
||||
snapshot swap (INV8) live in P3; P5 consumes read-only ports and returns immutable copies (e.g. `recordStepUp`).
|
||||
|
||||
**Phasing recap (reconciled per Finding-8):** **v0.8** = T1 only (contracts frozen, no runtime — per INDEX
|
||||
§1). **v0.9** = T2 (capability tokens incl. PoP + single-use + `deviceAuthProof`), T3, T9, T10 (revoke +
|
||||
`RevocationBus` publish; **live push teardown** is available here, only the tighter latency SLO is tuned in
|
||||
v0.10), T11 (pre-auth IP throttle **+** per-tenant quotas), **T12 core** (Origin + capability verify +
|
||||
deny-by-default authz + pre-auth/rate limit), **T13 v0.9 unit variant** (A→B = 403 only) — per INDEX §1
|
||||
v0.9. **v0.10** = T4, T5, T6 (TOTP + lockout), T7, T8 (step-up), T14, **T12 augmentation** (audit emit +
|
||||
step-up gate), **T13 v0.10 full-stack variant** (audit+alert asserts) (Passkey/WebAuthn primary + step-up +
|
||||
fast-revocation SLO + immutable zero-payload audit — per INDEX §1 v0.10). **No v0.9 task depends on a v0.10
|
||||
task** — v0.10 items are injected-dep augmentations of already-shipping v0.9 code.
|
||||
|
||||
---
|
||||
|
||||
## 4. VERIFICATION
|
||||
|
||||
```bash
|
||||
# Unit + negative/security tests (TDD, tests FIRST), per task:
|
||||
npx vitest run capability # T2: sub:=accountId, expiry/aud/tamper/wildcard/rights-escalation, TTL≤60s clamp, single-use, DPoP PoP
|
||||
npx vitest run device-proof # T2: §4.4 deviceAuthProof bound to {clientEphPub,clientNonce}; replay-into-other-handshake/unbound/tampered → false (§6b/Finding-7)
|
||||
npx vitest run authz # T3: INV1 cross-tenant 403 via accountIdFromToken, INV6 reattach 403, INV3 no client account_id
|
||||
npx vitest run audit # T4: append-only + zero-payload guard (INV10)
|
||||
npx vitest run webauthn totp oidc stepup # T5–T8: origin/challenge/replay/TOTP lockout/step-up (stepUpAt) freshness
|
||||
npx vitest run mtls # T9: SPIFFE mismatch/expiry/non-enrolled → refuse (INV4/INV14)
|
||||
npx vitest run revocation # T10: revoke → token invalid + LIVE-TUNNEL push teardown (RevocationBus) within budget + reconnect refused (INV12)
|
||||
npx vitest run quota # T11: pre-auth IP throttle (no account) + per-tenant limits, no cross-tenant bleed
|
||||
npx vitest run enforce # T12: Origin+preAuth+token(PoP,single-use)+authz+step-up+rate+audit compose; foreign Origin 401 (INV15)
|
||||
|
||||
# THE permanent tripwire — must be a REQUIRED CI check (INV1):
|
||||
npx vitest run tripwire/cross-tenant # A→B = 403 on connect AND reattach + fuzz + aud-confusion
|
||||
# (also runs in .github/workflows/relay-tripwire.yml on every push/PR — never skip/delete)
|
||||
|
||||
# Whole-package gates:
|
||||
npx vitest run --coverage # 80%+ across relay-auth (testing.md)
|
||||
npm run typecheck # relay-auth strict tsc; relay-contracts (§4) imported read-only, unchanged
|
||||
npm run lint # asserts no console.log; no import of ws/xterm/ANSI parser (INV2/INV11)
|
||||
|
||||
# Static tripwires (grep-level, run in CI):
|
||||
# - no schema/decision reads a client-supplied account_id/tenant_id as authoritative (INV3)
|
||||
# - no at-rest store holds a private key or raw token (INV4/INV5); DB has agent_pubkey only
|
||||
# - no secret material in any log line (INV9)
|
||||
```
|
||||
|
||||
**Base-app regression obligation:** P5 adds **zero** code to `src/` / `public/` — the base app's 212 tests
|
||||
stay green untouched; the only relay-side config touch (`ALLOWED_ORIGINS` += subdomain) is P3's install step,
|
||||
not P5's. P5's Origin check in T12 *mirrors* the base-app CSWSH check at the relay edge (INV15) and must never
|
||||
weaken it.
|
||||
|
||||
**Manual / integration verification (post-P1/P3 integration, v0.9+):** enroll two accounts on a single relay
|
||||
node; from a browser authenticated as A, attempt to open `bob.term.<domain>` and to reattach B's `sessionId`
|
||||
via A's token → both **403** with a `cross-tenant-attempt` alert in the audit log; revoke A's host → its live
|
||||
tunnel drops within seconds and reconnect is refused; let A's cert reach ~50% TTL → seamless rotation, no
|
||||
tunnel drop.
|
||||
|
||||
---
|
||||
|
||||
## 5. Open questions (for the orchestrator — subagents STOP + return `[!] BLOCKED`, never guess)
|
||||
|
||||
**Resolved in this revision (were blocking; now closed in-plan — no longer subagent blockers):**
|
||||
|
||||
- ~~**(#2) Session-open step-up boundary.**~~ **RESOLVED**: the authz decision and step-up gate run at the
|
||||
**WS upgrade** (`onUpgrade`, T12), *before* P1 dials `ws://127.0.0.1:3000` and before any `attach` frame can
|
||||
reach a PTY. See T12 "Session-open boundary". (Fixes Finding-3's boundary dependency.)
|
||||
- ~~**(#3) `CapabilityToken.sub` → `accountId` mapping.**~~ **RESOLVED**: the frozen §4.3 `sub` field carries
|
||||
the **`accountId`** by P5 convention (`CAP_TOKEN_SUB_IS_ACCOUNT_ID`, T1); T2 issuance sets `sub :=
|
||||
principal.accountId`; T3's cross-tenant gate reads it via the sole resolver `accountIdFromToken(token)`. The
|
||||
former undefined `token.accountBoundFromSub` is deleted. This does **not** redefine §4.3 (it fixes the
|
||||
*value* P5 writes into an existing field). (Fixes Finding-1.)
|
||||
- ~~**(#4) Revocation propagation transport.**~~ **RESOLVED**: `revoke()` publishes a `KillSignal` on the
|
||||
injected `RevocationBus` (Redis pub/sub `relay:revocations`, §4.2); P1 injects a §4.1 `CLOSE`+RST / `GOAWAY`
|
||||
(existing frozen frame types) to tear down the **live** stream within `REVOCATION_PUSH_BUDGET_MS`. P5 stays
|
||||
socket-free. (Fixes Finding-2.)
|
||||
|
||||
**Still open (need the orchestrator / cross-plan confirmation — do NOT guess):**
|
||||
|
||||
1. **Capability-token format**: §4.3 says "Ed25519-signed **PASETO/JWS**" — pick one concretely
|
||||
(recommend **PASETO v4.public**: no alg-confusion, no `alg:none` foot-gun). Needs a one-line INDEX §4.3
|
||||
confirmation before T2 codes, since the token bytes cross into P1/P6. (The `cnf.jkt` PoP claim + DPoP proof
|
||||
added in T2 must be representable in whichever format is chosen — confirm alongside.)
|
||||
2. **OIDC team→account mapping** (T7): how does a verified `(iss, sub)` map to an existing `accountId` (JIT
|
||||
provisioning vs pre-linked)? This is a P3 account-model question surfaced by P5.
|
||||
821
docs/PLAN_RELAY_CONTROLPLANE.md
Normal file
821
docs/PLAN_RELAY_CONTROLPLANE.md
Normal file
@@ -0,0 +1,821 @@
|
||||
# P3 — Control Plane & Accounts — Implementation Plan
|
||||
|
||||
> **Plan of the 6** (see [`PLAN_RELAY_INDEX.md`](./PLAN_RELAY_INDEX.md) §5 charter for P3).
|
||||
> Owns the **stateful, low-QPS, strongly-consistent** half of the relay: the account & host
|
||||
> **registries**, **pairing-code** issuance+redemption, **subdomain** assignment, the **live routing
|
||||
> table**, **provisioning/deprovisioning** APIs, **billing-metering** hooks, the **mTLS cert-signing**
|
||||
> service (bind-time only), and **graceful relay-node drain** coordination.
|
||||
> **Source of intent**: [`EXPLORE_RELAY_SERVICE.md`](./EXPLORE_RELAY_SERVICE.md) §0 (LOCKED, closed) + §3
|
||||
> "control plane vs data plane". **Coordination point**: this plan **imports** the FROZEN SHARED
|
||||
> CONTRACTS from INDEX §4 read-only and **redefines none of them**. Conventions follow
|
||||
> [`PLAN.md`](./PLAN.md) / [`PLAN_VOICE_COMMANDS.md`](./PLAN_VOICE_COMMANDS.md): stable task IDs in
|
||||
> dependency waves, `Owns:` disjoint files, 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, boundaries, and enforced invariants
|
||||
|
||||
**In scope (this plan and no other):**
|
||||
- Account & host **registries** — Postgres, **immutable records** (INDEX §4.2 schema is frozen; TS
|
||||
`HostRecord`/`RouteEntry`/`HostStatus`/`PlanTier` imported from `relay-contracts/`).
|
||||
- **Pairing** issuance + redemption + BIND (INDEX §4.5) — atomic single-use `redeemed_at` CAS.
|
||||
- Per-tenant **subdomain assignment** (stable across reconnects).
|
||||
- **Live routing table** — Redis `route:{host_id} → RouteEntry` with heartbeat TTL; **Postgres is the
|
||||
ownership source of truth, Redis is only "where the live tunnel is right now"** (EXPLORE §3, INV7).
|
||||
- **Provisioning / deprovisioning** HTTP APIs (create/suspend/delete account; add/revoke host).
|
||||
- **Billing-metering hooks** — paired hosts + concurrent viewers (EXPLORE §6 cost drivers).
|
||||
- **mTLS cert-signing at BIND** (INDEX §4.5 step 4) — the CA verifies **CSR proof-of-possession** and
|
||||
**only** signs pubkeys already in the host registry (the INV14 registry-gate half), using a
|
||||
**KMS/HSM-held, non-exportable intermediate** key (§3.1); verification cadence/rotation policy is
|
||||
**P5's**, see §7.
|
||||
- **Graceful relay-node drain** coordination — emit `GOAWAY` (INDEX §4.1) via the node registry.
|
||||
- **Immutable audit substrate** — append-only, zero-payload writer (INV10) that P5 also calls.
|
||||
|
||||
**Explicitly NOT in scope (other plans — do not touch):** the mux frame codec & byte path (**P1**); the
|
||||
host-agent (**P2**); the E2E crypto handshake/AEAD (**P4**); human Passkey auth, capability-token
|
||||
*issuance/signing-key*, deny-by-default *enforcement at the WS upgrade*, per-tenant rate-limit
|
||||
enforcement, revocation *policy*, and cross-tenant CI tripwire (**P5**); browser UI (**P6**). This plan
|
||||
**consumes** `verifyCapabilityToken` (§4.3) to authorize its own admin APIs but does **not** own the
|
||||
signing key — that is P5.
|
||||
|
||||
**Enforced invariants (INDEX §3):** **INV1** (cross-tenant isolation — ownership join is the only host
|
||||
resolution path), **INV3** (`account_id` from the authenticated principal, never client-supplied),
|
||||
**INV4** (per-host asymmetric identity — DB stores only public keys), **INV5** (no plaintext/secret at
|
||||
rest — hash pairing codes/tokens, no shell bytes), **INV6** (deny-by-default authz on connect AND
|
||||
reattach — registry provides the ownership fact), **INV8** (immutable records + atomic snapshot swap),
|
||||
**INV10** (immutable audit log, zero payload), **INV12** (fast revocation kills live tunnels). Each task
|
||||
lists the subset it ships a test for.
|
||||
|
||||
**Package:** all new code lives in a **new top-level `control-plane/` package** — the physically
|
||||
separate control-plane half mandated by INDEX §0 ("`term-relay/` … physically split") and EXPLORE §3.
|
||||
No edits to `src/`, `public/`, `agent/`, `relay-e2e/`, or `relay-web/`. `relay-contracts/` is imported
|
||||
read-only.
|
||||
|
||||
---
|
||||
|
||||
## 1. Files & ownership (all under `control-plane/`)
|
||||
|
||||
| File | Task | Role |
|
||||
|------|------|------|
|
||||
| `control-plane/package.json`, `tsconfig.json`, `vitest.config.ts` | T1 | scaffold; deps `pg ioredis zod fastify` (+ dev `vitest @types/pg`) |
|
||||
| `control-plane/src/env.ts` | T1 | startup secret/config validation (Zod), fail-fast (INV9) |
|
||||
| `control-plane/db/migrations/*.sql` | T2 | Postgres DDL for §4.2 `accounts/hosts/sessions/pairing_codes` + `relay_nodes`, `metering_samples`, `audit_log` |
|
||||
| `control-plane/src/db/pool.ts` | T2 | pg pool + typed `query` wrapper (parameterized only) |
|
||||
| `control-plane/src/model/records.ts` | T2 | TS record types mirroring §4.2 SQL **verbatim** (`AccountRecord`, `SessionRecord`, `PairingCodeRecord`); re-exports `HostRecord`/`HostStatus`/`PlanTier`/`RouteEntry` from `relay-contracts/` |
|
||||
| `control-plane/src/registry/accounts.ts` | T3 | immutable account repository |
|
||||
| `control-plane/src/registry/hosts.ts` | T4 | immutable host repository (ownership source of truth) |
|
||||
| `control-plane/src/registry/sessions.ts` | T5 | session repository (denormalized `account_id` for O(1) authz) |
|
||||
| `control-plane/src/subdomain/assign.ts` | T6 | subdomain allocation + collision/validation |
|
||||
| `control-plane/src/pairing/issue.ts` | T7 | pairing-code issuance |
|
||||
| `control-plane/src/pairing/redeem.ts` | T8 | atomic single-use redemption + BIND + cert-sign hook + mint/wrap `hostContentSecret` (FIX 3) |
|
||||
| `control-plane/src/ca/sign.ts` | T8 | bind-time mTLS leaf signer (registry-gated, INV14 half) |
|
||||
| `control-plane/src/node-auth/identity.ts` | T9 | relay-node identity middleware — derives `nodeId` from the mTLS client-cert subject (never a request field) |
|
||||
| `control-plane/src/routing/table.ts` | T9 | Redis routing table (`route:{host_id}`, heartbeat TTL) |
|
||||
| `control-plane/src/routing/nodes.ts` | T10 | relay-node registry + drain coordination (GOAWAY dispatch) |
|
||||
| `control-plane/src/api/provision.ts` | T11 | provisioning/deprovisioning HTTP routes (incl. v0.9 minimal deprovision) |
|
||||
| `control-plane/src/api/authz.ts` | T11 | admin-API principal derivation (INV3) — consumes `verifyCapabilityToken` |
|
||||
| `control-plane/src/deprovision/deprovision.ts` | T11 | v0.9 minimal deprovision (status→'revoked' + dropRoute); no fast tunnel-kill (that is T13/v0.10) |
|
||||
| `control-plane/src/metering/collect.ts` | T12 | metering ingestion + immutable rollup |
|
||||
| `control-plane/src/revoke/revoke.ts` | T13 | global + per-host revocation (INV12) |
|
||||
| `control-plane/src/audit/log.ts` | T14 | immutable zero-payload audit writer (P5 also imports) |
|
||||
| `control-plane/src/ca/rotate.ts` | T15 | CA leaf **renewal** (`renewHostLeaf`) — registry-gated, distinct file from T8 signer |
|
||||
| `control-plane/src/boot/ca-wiring.ts` | T15 | CA-key / secret-manager bootstrap + KMS-signer factory; imported by `main.ts` (T11) |
|
||||
| `control-plane/src/main.ts` | T11 | Fastify bootstrap; wires env → pools → routes; imports `boot/ca-wiring.ts` (T15) |
|
||||
| `control-plane/test/**` | each | per-module unit + integration tests (Testcontainers PG/Redis) |
|
||||
| `control-plane/src/flat-store.ts`, `control-plane/db/flat.sqlite.sql` | T0 | **v0.8** flat SQLite `{accountId, subdomain, agentToken(hash), clientToken(hash)}` shim + manual-provision CLI |
|
||||
|
||||
Each `src/*.ts` stays 200–400 lines (800 hard max, `coding-style.md`). `records.ts` is the local
|
||||
frozen-shape source for P3-internal types; if a shared field is needed elsewhere it is promoted into
|
||||
`relay-contracts/` **via the INDEX**, never redeclared (open question OQ1).
|
||||
|
||||
---
|
||||
|
||||
## 2. Waves (dependency order)
|
||||
|
||||
```
|
||||
CP0 scaffold + schema (blocks all) T0(v0.8 flat) · T1 · T2
|
||||
│
|
||||
▼
|
||||
CP1 registries (immutable repos) T3 accounts · T4 hosts · T5 sessions · T6 subdomain
|
||||
│ (T3/T4/T5/T6 file-disjoint → parallel)
|
||||
▼
|
||||
CP2 pairing T7 issue · T8 redeem+BIND+CA
|
||||
│
|
||||
▼
|
||||
CP3 live routing & node coordination T9 routing table (+ node-identity mw) · T10 node registry+drain
|
||||
│
|
||||
▼
|
||||
CP4 provisioning API & metering T11 provision API (+ v0.9 minimal deprovision) · T12 metering
|
||||
│
|
||||
▼
|
||||
CP5 hardening (v0.10) T13 fast revocation · T14 audit · T15 cert-rotation (ca/rotate.ts) + secret bootstrap
|
||||
```
|
||||
|
||||
**Phase tags:** T0 = **v0.8**. T1–T2 scaffold in v0.8, Postgres DDL lands **v0.9**. T3–T12 = **v0.9**.
|
||||
T13–T15 = **v0.10**. (The v0.8 flat store is retired the moment T3/T4 land; the provisioning API in
|
||||
v0.8 is a manual CLI, promoted to HTTP in T11.) **No v0.9 task depends on a v0.10 task** — T11's
|
||||
`DELETE /hosts/:id` deprovision ships a **self-contained v0.9 tear-down** (`deprovision/deprovision.ts`:
|
||||
host status→`'revoked'` + `dropRoute`, T4+T9 only); the *fast, within-seconds tunnel-kill / cert-stop*
|
||||
hardening (INV12) is a **later, additive** upgrade in T13/v0.10 and is **not** a prerequisite for the
|
||||
v0.9 route to exist. See finding-driven split in §6/§7/§8.
|
||||
|
||||
**Cross-plan dependencies:**
|
||||
- T8 redemption is the server side of **P2** `POST /enroll` (agent generates the keypair + CSR). T8 also
|
||||
**mints + wraps `hostContentSecret`** (FIX 3, INDEX §4.5) to the enrolled `agent_pubkey` and returns it in
|
||||
`EnrollResult`; **P2** unwraps it locally with its enrollment private key (INV4); **P5** later hands the
|
||||
same host-scoped secret to authorized browser devices (unwrapped only after auth/step-up) so they
|
||||
re-derive the identical `K_content` (§4.4 `deriveContentKey`) for replay/preview decrypt.
|
||||
- **T9/T10/T12 authenticate the calling relay node** by its **mTLS client-cert identity** (the
|
||||
`nodeId`/`hostId` in every such RPC is derived from the verified cert subject, **never** a request
|
||||
field — the relay-node↔control-plane analog of INV3). This mirrors P1's node-side mTLS credential;
|
||||
the **node service-cert issuance/rotation** is a **P5/P1 boundary** — see **OQ5**.
|
||||
- T9/T10 are read by **P1** (data plane looks up `route:{host_id}`; obeys `GOAWAY`).
|
||||
- **T10/T13 tear down live tunnels over the FROZEN control→data-plane bus** (INDEX §4.2 FIX 4): P3
|
||||
PUBLISHES a `KillSignal` on the Redis pub/sub channel `RELAY_REVOCATIONS_CHANNEL` (`'relay:revocations'`)
|
||||
via `RevocationBus.publish`; **every P1 relay node SUBSCRIBES** and injects the existing §4.1
|
||||
`CLOSE`+`RST` / `GOAWAY` wire (no new frame type) within `REVOCATION_PUSH_BUDGET_MS` (2000ms). **Drain
|
||||
(T10) and revoke (T13) use the SAME named channel.** OQ4 is **RESOLVED** by this freeze — there is no
|
||||
separate undefined "P1 dispatcher".
|
||||
- T11 admin authz consumes **P5**'s `verifyCapabilityToken` (§4.3) and the P5 signing key (env).
|
||||
- T13 shares the Redis `revoked:{jti}` key with **P5**; T14's writer is imported by **P5**.
|
||||
- T4's `enroll_fpr` is read by **P4** (browser pins it for E2E TOFU, §4.4).
|
||||
|
||||
---
|
||||
|
||||
## 3. CP0 — scaffold & schema
|
||||
|
||||
### T0 · v0.8 flat SQLite store + manual-provision CLI `[v0.8]`
|
||||
- **Owns**: `control-plane/src/flat-store.ts`, `control-plane/db/flat.sqlite.sql`, `control-plane/bin/provision.ts`
|
||||
- **Depends**: none · **Parallel-safe**: with everything (retired by T3/T4)
|
||||
- **Contract** (INDEX §1 v0.8 "flat SQLite account table"):
|
||||
```ts
|
||||
export interface FlatAccount { // v0.8 ONLY — no immutable-record ceremony yet
|
||||
readonly accountId: string; readonly subdomain: string
|
||||
readonly agentTokenHash: string; readonly clientTokenHash: string
|
||||
}
|
||||
export function provisionFlat(subdomain: string): Promise<{ accountId: string; agentToken: string; clientToken: string }>
|
||||
export function lookupBySubdomain(subdomain: string): Promise<FlatAccount | null>
|
||||
export function verifyAgentToken(subdomain: string, raw: string): Promise<boolean> // constant-time compare of hashes
|
||||
```
|
||||
- **TDD**: RED test asserts `provisionFlat` stores **hashes only** (grep row → no raw token, INV5);
|
||||
`verifyAgentToken` true for correct / false for wrong; wrong subdomain → null.
|
||||
- **Security**: tokens hashed (argon2id/scrypt), constant-time compare, raw returned once at mint.
|
||||
Enforces INV5. This is the throwaway MVP gate; **all** its guarantees are re-shipped properly in CP1.
|
||||
|
||||
### T1 · package scaffold + startup env validation `[v0.8→v0.9]`
|
||||
- **Owns**: `control-plane/package.json`, `tsconfig.json`, `vitest.config.ts`, `control-plane/src/env.ts`
|
||||
- **Depends**: none · **Parallel-safe**: after this, all others
|
||||
- **Contract**:
|
||||
```ts
|
||||
export interface ControlPlaneEnv {
|
||||
readonly pgUrl: string; readonly redisUrl: string
|
||||
readonly capabilitySignPubkey: Uint8Array // P5 signing PUBLIC key, to VERIFY admin tokens (INV3)
|
||||
readonly caIntermediateKmsKeyRef: string // KMS/HSM key REF for the INTERMEDIATE leaf-signing key
|
||||
// (non-exportable; signing = KMS sign() call, never a raw
|
||||
// private key loaded into process memory — see §3.1 CA arch)
|
||||
readonly caIntermediateCertPath: string // intermediate cert (public) + chain up to the offline root
|
||||
readonly nodeMtlsTrustBundlePath: string // CA bundle used to VERIFY relay-node client certs (T9 node-auth)
|
||||
readonly baseDomain: string // 'term.<domain>' for subdomain assembly
|
||||
readonly heartbeatTtlSec: number
|
||||
readonly pairingTtlSec: number // pairing-code TTL; DEFAULT 600 (10 min) if unset (§5 T7)
|
||||
readonly pairingMaxRedeemAttempts: number // per-code lockout threshold; DEFAULT 5 (§5 T7/T8)
|
||||
}
|
||||
export function loadEnv(source: NodeJS.ProcessEnv): ControlPlaneEnv // Zod-validated; THROWS on missing/invalid
|
||||
```
|
||||
- **TDD**: RED — `loadEnv({})` throws listing every missing key; a bad `pgUrl` throws; a valid map parses;
|
||||
`pairingTtlSec`/`pairingMaxRedeemAttempts` default to 600/5 when unset; **CA-policy startup check** —
|
||||
boot fails fast if the KMS key referenced by `caIntermediateKmsKeyRef` cannot be resolved OR its key
|
||||
policy does not restrict `sign` to the control-plane service principal (assert both failure paths throw
|
||||
before the server binds a port).
|
||||
- **Security**: INV9 — secrets come from env/secret-manager, **validated at startup, fail-fast, never
|
||||
logged** (log only key *names* on failure, never values). The CA key is referenced by a **KMS/HSM ref**
|
||||
and is **never inlined and never loaded raw** into control-plane memory (§3.1).
|
||||
|
||||
### 3.1 CA key architecture (Finding-3 hardening — the highest-value secret in the plane)
|
||||
|
||||
The mTLS CA can mint a valid identity for **any host across every tenant**, so its custody is specified
|
||||
explicitly (it must not get *less* rigor than pairing-code hashing):
|
||||
|
||||
- **Two-tier hierarchy.** An **offline root CA** (kept off any online host — air-gapped / HSM, never in a
|
||||
running process) signs a **short-lived intermediate** used for day-to-day leaf signing. The control
|
||||
plane holds only the **intermediate**, never the root.
|
||||
- **Non-exportable KMS/HSM key.** The intermediate signing key is a **non-exportable KMS/HSM key**; every
|
||||
`signHostLeaf`/`renewHostLeaf` (T8/T15) performs a **KMS `sign()` call** — the raw private key is
|
||||
**never** loaded into control-plane process memory or written to disk (this is stricter than "by
|
||||
reference": the process can invoke signing but cannot read the key). The KMS key policy MUST restrict
|
||||
the `sign` operation to the control-plane **service principal only**; T1 fails fast at startup if the
|
||||
policy is broader (see TDD above).
|
||||
- **Intermediate rotation cadence.** The intermediate is rotated on a fixed cadence (default **30 days**,
|
||||
env-tunable) with overlap so in-flight leaves stay valid; rotation is coordinated by T15
|
||||
(`boot/ca-wiring.ts` provides the KMS-signer factory; `ca/rotate.ts` re-issues leaves under the current
|
||||
intermediate).
|
||||
- **Compromise runbook (documented, referenced by the incident process).** *Intermediate compromise* →
|
||||
revoke the intermediate at the root, mint a new intermediate, force **all hosts to renew** (T15
|
||||
`renewHostLeaf`) under the new intermediate; leaves signed by the old intermediate fail mTLS verify
|
||||
(P5). *Root compromise* (worst case) → stand up a new root out-of-band, re-issue intermediate,
|
||||
**force every host to re-enroll** (new pairing round, T7/T8); old chain distrusted at the relay edge.
|
||||
- Boundary note: **leaf issuance/renewal (signing) is P3**; **rotation *cadence policy* + mTLS *handshake
|
||||
verification* are P5/INV14** — this plan owns only the KMS-backed signing operation and the startup
|
||||
key-policy check (OQ2).
|
||||
|
||||
### T2 · Postgres DDL + typed pool + record types `[v0.9]`
|
||||
- **Owns**: `control-plane/db/migrations/*.sql`, `control-plane/src/db/pool.ts`, `control-plane/src/model/records.ts`
|
||||
- **Depends**: T1
|
||||
- **DDL**: transcribe INDEX **§4.2 verbatim** — `accounts`, `hosts`, `sessions`, `pairing_codes` — plus
|
||||
P3-owned `relay_nodes(node_id pk, addr, status, last_seen)`, `metering_samples`, `audit_log` (§4/§7),
|
||||
plus the **INV8 companion version tables** `account_status_versions(account_id, version, status,
|
||||
changed_at, changed_by, PRIMARY KEY(account_id, version))` and `host_status_versions(host_id, version,
|
||||
status, revoked_at, changed_at, changed_by, PRIMARY KEY(host_id, version))`, and a monotonic
|
||||
`status_version int NOT NULL DEFAULT 1` current-pointer column added to `accounts`/`hosts` (§4 INV8
|
||||
discipline; pending INDEX promotion, **OQ6**). `host_id`/`account_id`/`session_id` = `uuid` default
|
||||
`gen_random_uuid()`; `subdomain UNIQUE`; `agent_pubkey bytea`; lifecycle/business columns follow the
|
||||
versioned-pointer swap (no bare `UPDATE` of a business field without a matching `*_status_versions`
|
||||
insert in the same txn — see §4 discipline + T3/T4). Liveness columns (`last_seen`, `last_attach_at`)
|
||||
are advisory freshness caches, updated by a coalesced write, **not** versioned.
|
||||
- **Contract**:
|
||||
```ts
|
||||
export interface AccountRecord { // mirrors §4.2 accounts VERBATIM (not in §4 TS block; OQ1)
|
||||
readonly accountId: string; readonly plan: PlanTier
|
||||
readonly createdAt: string; readonly status: 'active' | 'suspended'
|
||||
}
|
||||
export interface SessionRecord { // mirrors §4.2 sessions
|
||||
readonly sessionId: string; readonly hostId: string; readonly accountId: string
|
||||
readonly createdAt: string; readonly lastAttachAt: string
|
||||
}
|
||||
export interface PairingCodeRecord { // mirrors §4.2 pairing_codes (code_hash only, INV5)
|
||||
readonly codeHash: string; readonly accountId: string
|
||||
readonly expiresAt: string; readonly redeemedAt: string | null
|
||||
}
|
||||
export { HostRecord, HostStatus, PlanTier, RouteEntry } from 'relay-contracts' // §4.2 — NOT redefined
|
||||
export function query<T>(sql: string, params: readonly unknown[]): Promise<readonly T[]> // parameterized ONLY
|
||||
```
|
||||
- **TDD** (Testcontainers PG): migration applies clean; `subdomain` UNIQUE rejects a dup insert;
|
||||
`pairing_codes.code_hash` is PK; a raw string in place of a `$n` param is impossible (wrapper signature
|
||||
forbids interpolation).
|
||||
- **Security**: **parameterized queries only** (no string concat → no SQLi); `agent_pubkey` is a
|
||||
**public** key (INV4); pairing code stored **hashed** (INV5). Enforces INV4, INV5.
|
||||
|
||||
---
|
||||
|
||||
## 4. CP1 — registries (immutable repositories)
|
||||
|
||||
**INV8 discipline (all of T3–T5) — reconciled with the FROZEN §4.2 schema (Finding-6).** The frozen
|
||||
§4.2 DDL declares `accounts`/`hosts`/`sessions` as **single-PK rows** (`host_id`/`account_id`/
|
||||
`session_id` is the PK), so a *second* row with the same PK is impossible — versioning cannot be done by
|
||||
re-inserting into those tables, and this plan does **not** silently invent `*_versions` tables (§4.2 is
|
||||
frozen; that change must go through the INDEX). The discipline is therefore split by field class:
|
||||
|
||||
- **Lifecycle/business fields** (`accounts.status`, `hosts.status`, `hosts.revoked_at`) — versioned via
|
||||
**companion append-only tables** `account_status_versions(account_id, version, status, changed_at,
|
||||
changed_by)` and `host_status_versions(host_id, version, status, revoked_at, changed_at, changed_by)`,
|
||||
with the base-table column acting as the **current pointer**. A status change is **one transaction**:
|
||||
`INSERT … status_versions (next version)` **then** `UPDATE base SET status/revoked_at = new,
|
||||
status_version = next` — the single-row pointer `UPDATE` is the only atomic write, and a reader either
|
||||
sees the old (status, version) pair or the new one, never a torn mix. **Prior snapshots stay readable
|
||||
from the `*_versions` table** (this is what the T3/T4 "old snapshot still retrievable" tests assert —
|
||||
against `*_status_versions`, not a duplicate base row). These two companion tables are **added to
|
||||
§4.2 and to T2's DDL** as a P3-local extension pending INDEX promotion — see **OQ6**.
|
||||
- **High-frequency liveness fields** (`hosts.last_seen`, `sessions.last_attach_at`) are **NOT versioned**
|
||||
— one row per heartbeat/attach would be unbounded write amplification. Per INV7, live location/liveness
|
||||
lives in **Redis** (`route:{host_id}` heartbeat-TTL, T9); the Postgres `last_seen`/`last_attach_at`
|
||||
columns are **advisory freshness caches** updated by a bounded, coalesced write and are **explicitly
|
||||
exempt** from the "prior snapshot readable" assertion. T3/T4/T5 tests assert immutability/versioning
|
||||
**only** for lifecycle/business fields, never for liveness columns.
|
||||
|
||||
No `UPDATE … SET <lifecycle_field>` happens without a matching `*_versions` insert in the same txn.
|
||||
|
||||
### T3 · account registry `[v0.9]`
|
||||
- **Owns**: `control-plane/src/registry/accounts.ts`
|
||||
- **Depends**: T2
|
||||
- **Contract**:
|
||||
```ts
|
||||
export function createAccount(plan: PlanTier): Promise<AccountRecord> // new immutable row
|
||||
export function getAccount(accountId: string): Promise<AccountRecord | null>
|
||||
export function setAccountStatus(accountId: string, status: 'active' | 'suspended'): Promise<AccountRecord> // NEW snapshot, atomic swap
|
||||
```
|
||||
- **TDD**: `createAccount` returns unguessable uuid + `status:'active'` (writes `status_version=1` + the
|
||||
seed `account_status_versions` row); `setAccountStatus` performs the single-txn `INSERT
|
||||
account_status_versions(next) + UPDATE accounts SET status,status_version=next` and returns the **new**
|
||||
record while the **prior `(status, version)` remains readable from `account_status_versions`** (assert
|
||||
old snapshot retrievable **from the versions table**, not a duplicate base row); concurrent
|
||||
`setAccountStatus` → one whole-or-nothing winner, versions strictly monotonic, no torn read (INV8).
|
||||
- **Security**: INV8. `accountId` unguessable + never recycled (INV1 precondition).
|
||||
|
||||
### T4 · host registry (ownership source of truth) `[v0.9]`
|
||||
- **Owns**: `control-plane/src/registry/hosts.ts`
|
||||
- **Depends**: T2, T3
|
||||
- **Contract** (returns the frozen §4.2 `HostRecord`):
|
||||
```ts
|
||||
export function bindHost(input: { // called ONLY from pairing BIND (T8), never from client input
|
||||
readonly accountId: string; readonly subdomain: string
|
||||
readonly agentPubkey: Uint8Array; readonly enrollFpr: string
|
||||
}): Promise<HostRecord>
|
||||
export function getHost(hostId: string): Promise<HostRecord | null>
|
||||
export function getHostBySubdomain(subdomain: string): Promise<HostRecord | null>
|
||||
export function listHosts(accountId: string): Promise<readonly HostRecord[]> // ownership scoped
|
||||
export function setHostStatus(hostId: string, status: HostStatus): Promise<HostRecord> // NEW snapshot
|
||||
export function ownsHost(accountId: string, hostId: string): Promise<boolean> // INV1/INV6 ownership join
|
||||
```
|
||||
- **TDD**: `bindHost` stores **only** the public key (grep row → no private material, INV4);
|
||||
`enroll_fpr` = fingerprint of `agent_pubkey`; **`ownsHost(A, hostOwnedByB)` → false** (the INV1
|
||||
ownership fact this plan supplies to P5's tripwire); `getHostBySubdomain` resolves the stable name;
|
||||
`setHostStatus('revoked')` performs the single-txn `INSERT host_status_versions(next) + UPDATE hosts
|
||||
SET status='revoked', revoked_at=now(), status_version=next` — prior status is still readable from
|
||||
`host_status_versions` (feeds INV12). Assert liveness `last_seen` updates do **not** create a version
|
||||
row (advisory cache, INV7).
|
||||
- **Security**: **INV1** (host is resolvable only via `host_id`/`subdomain` bound to an account — **no
|
||||
raw address/port/hostname resolution path exists in this module**), **INV4** (pubkey only), **INV6**
|
||||
(`ownsHost` is the deny-by-default ownership predicate), **INV8**.
|
||||
|
||||
### T5 · session registry `[v0.9]`
|
||||
- **Owns**: `control-plane/src/registry/sessions.ts`
|
||||
- **Depends**: T2, T4
|
||||
- **Contract**:
|
||||
```ts
|
||||
export function recordSession(input: { readonly sessionId: string; readonly hostId: string }): Promise<SessionRecord>
|
||||
// account_id is DENORMALIZED from the host at write time — NEVER supplied by the caller/client (INV3)
|
||||
export function getSession(sessionId: string): Promise<SessionRecord | null>
|
||||
export function sessionAccount(sessionId: string): Promise<string | null> // O(1) authz fact for reattach (INV6)
|
||||
```
|
||||
- **TDD**: `recordSession` derives `account_id` from `hosts` (ignores any caller-passed account); a
|
||||
reattach lookup for a `session_id` owned by another account resolves to that account's id (so P5's
|
||||
reattach check denies) — assert `sessionAccount` never echoes a client-supplied value; unknown
|
||||
`session_id` → null.
|
||||
- **Security**: **INV3** (account denormalized from host, never client), **INV6** (reattach
|
||||
re-validation fact).
|
||||
|
||||
### T6 · subdomain assignment `[v0.9]`
|
||||
- **Owns**: `control-plane/src/subdomain/assign.ts`
|
||||
- **Depends**: T2, T4
|
||||
- **Contract**:
|
||||
```ts
|
||||
export function normalizeSubdomain(raw: string): string // lowercase, RFC-1123 label, strip invalid
|
||||
export function isValidSubdomain(raw: string): boolean // 1–63 chars, [a-z0-9-], not leading/trailing '-'
|
||||
export function assignSubdomain(accountId: string, requested?: string): Promise<string> // UNIQUE, atomic reserve
|
||||
export function assembleFqdn(subdomain: string, baseDomain: string): string // 'alice' + 'term.x' → 'alice.term.x'
|
||||
```
|
||||
- **TDD**: reserved words (`www`, `api`, `admin`, `_`) rejected; UNIQUE collision → deterministic retry
|
||||
or 409 (never silently reassign an existing tenant's name); `assignSubdomain` is atomic under
|
||||
concurrency (two callers, same requested → exactly one wins); host-header-confusion inputs
|
||||
(`a.b`, `A LICE`, `../`) rejected by `isValidSubdomain`.
|
||||
- **Security**: subdomain is the browser-origin isolation boundary (EXPLORE §3) — a malformed/duplicate
|
||||
label must never collapse two tenants into one origin. Supports **INV1**. Validate at boundary
|
||||
(`coding-style.md`).
|
||||
|
||||
---
|
||||
|
||||
## 5. CP2 — pairing (INDEX §4.5)
|
||||
|
||||
### T7 · pairing-code issuance `[v0.9]`
|
||||
- **Owns**: `control-plane/src/pairing/issue.ts`
|
||||
- **Depends**: T2, T3 · **Cross-plan**: issued from an authenticated human session (**P5** provides the
|
||||
principal; INV3 — `accountId` from the session, never a request field)
|
||||
- **Contract** (INDEX §4.5 step 1):
|
||||
```ts
|
||||
export interface IssuedPairing { readonly code: string; readonly expiresAt: string } // raw code returned ONCE
|
||||
export function issuePairingCode(accountId: string): Promise<IssuedPairing>
|
||||
// mints single-use SHORT-TTL code; stores { code_hash, account_id, expires_at, redeem_attempts:0 };
|
||||
// raw code NEVER stored (INV5). Code format is FIXED (see below) — the entropy claim and the display
|
||||
// format are reconciled here so they cannot contradict.
|
||||
```
|
||||
**Entropy/display reconciliation (Finding-4 — resolves the `ABCD-1234` ≈ 32-bit contradiction).** A
|
||||
`ABCD-1234` shape (4 letters + 4 digits) is only ~32 bits and is **rejected as the wire code**. The
|
||||
pairing code is the **sole credential** gating `/enroll` (not capability-token gated, T11), so it is
|
||||
defined as **26 Crockford-base32 characters = 130 bits of real entropy**, displayed grouped for humans as
|
||||
**`XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-X`** (6 groups). This is a paste/QR-first credential, not a
|
||||
type-from-memory PIN. (INDEX §4.5's `pair ABCD-1234` is an **illustrative CLI placeholder**, not a
|
||||
frozen code format — §4.5 freezes only `code_hash` storage, no typed raw-code contract — so P3, the
|
||||
issuer, defines the normative format here without redefining any §4 contract.) **In addition**
|
||||
(defense-in-depth, since brute force of `/enroll` is P3's own attack
|
||||
surface even before P5's general limiter), redemption is throttled per code (T8): a strict,
|
||||
**code-scoped exponential lockout** on failed `redeemPairingCode` attempts, independent of P5's
|
||||
per-tenant rate-limiter. TTL is **short by default** — `pairingTtlSec` defaults to **600s (10 min)** (T1),
|
||||
not left purely env-open.
|
||||
- **TDD**: issued code is **≥ 128-bit entropy** (assert the sampled character space × length; reject a
|
||||
32-bit `ABCD-1234` shape); display grouping round-trips to the canonical 130-bit value; DB row stores
|
||||
**`code_hash` only** (grep → no raw code, INV5) with `redeem_attempts = 0`; `expires_at` = now +
|
||||
`pairingTtlSec` and defaults to a 10-minute window when the env var is unset; two issues → two distinct
|
||||
codes.
|
||||
- **Security**: INV5 (hash at rest), INV3 (account from authenticated principal). ≥128-bit entropy +
|
||||
short default TTL + code-scoped redeem lockout (T8) make offline/online guessing infeasible within the
|
||||
leak window (EXPLORE §4a).
|
||||
|
||||
### T8 · pairing-code redemption + BIND + bind-time cert `[v0.9]`
|
||||
- **Owns**: `control-plane/src/pairing/redeem.ts`, `control-plane/src/ca/sign.ts`
|
||||
- **Depends**: T4, T6, T7 · **Cross-plan**: server side of **P2** `POST /enroll { code, agentPubkey, csr }`
|
||||
- **Contract** (INDEX §4.5 steps 3–5):
|
||||
```ts
|
||||
// EnrollResult is FROZEN in INDEX §4.5 — IMPORTED from relay-contracts, NOT redefined locally (FIX 3).
|
||||
// Shape cited verbatim: { hostId, subdomain, cert: string, caChain: string, hostContentSecret: Uint8Array }.
|
||||
// - cert/caChain are PEM strings: redeem encodes signHostLeaf's DER (Uint8Array / Uint8Array[]) to PEM
|
||||
// before assembling the frozen EnrollResult (§4.5 field types are the authority).
|
||||
// - hostContentSecret (FIX 3): per-host CSPRNG secret, WRAPPED to the enrolled Ed25519 agent_pubkey.
|
||||
import type { EnrollResult } from 'relay-contracts' // §4.5 frozen shape — do not shadow
|
||||
|
||||
export function redeemPairingCode(input: {
|
||||
readonly code: string; readonly agentPubkey: Uint8Array; readonly csr: Uint8Array
|
||||
}): Promise<EnrollResult> // ATOMIC single-use: compare-and-set redeemed_at in ONE txn (no double-spend).
|
||||
// BIND (INDEX §4.5 step 4) also MINTS + WRAPS the host-scoped content secret and returns it in
|
||||
// EnrollResult: after bindHost + signHostLeaf, call mintHostContentSecret(agentPubkey) and set
|
||||
// EnrollResult.hostContentSecret = the wrapped blob (FIX 3). Single owner of minting = P3 (§4.5).
|
||||
// CODE-SCOPED LOCKOUT (Finding-4): a failed match atomically increments pairing_codes.redeem_attempts;
|
||||
// once it reaches `pairingMaxRedeemAttempts` (default 5, T1) the code is locked (treated as spent) with
|
||||
// exponential backoff between attempts — independent of P5's per-tenant rate limiter. Lockout is keyed
|
||||
// by `code_hash`, so brute-forcing one victim's code cannot be spread across accounts.
|
||||
|
||||
// control-plane/src/ca/sign.ts — registry-gated leaf signer (KMS-backed, §3.1):
|
||||
export function signHostLeaf(hostId: string, agentPubkey: Uint8Array, csr: Uint8Array): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }>
|
||||
// ORDER OF CHECKS (all must pass, same reject path):
|
||||
// 1. PROOF-OF-POSSESSION — verify the CSR's own PKCS#10 self-signature against the pubkey it carries
|
||||
// (standard PKI: the CA proves the requester holds the matching private key BEFORE signing).
|
||||
// 2. the CSR's embedded pubkey == the caller-supplied `agentPubkey` (no substitution).
|
||||
// 3. (hostId, agentPubkey) is an ACTIVE row in the host registry (INV14 registry-gate).
|
||||
// A failure at ANY step rejects with the SAME error path — a valid-but-unregistered CSR and a
|
||||
// registered-but-invalid-signature CSR are both refused. Signing itself = KMS sign() (§3.1), never a
|
||||
// raw CA private key in memory. Rotation/renewal CADENCE + mTLS handshake verify = P5 (§7).
|
||||
|
||||
// control-plane/src/pairing/redeem.ts — FIX 3: mint + wrap the host-scoped content secret at BIND.
|
||||
export function mintHostContentSecret(agentPubkey: Uint8Array): Promise<Uint8Array>
|
||||
// Generate a per-host 32-byte CSPRNG secret and SEAL/WRAP it to the host's enrolled Ed25519 agent_pubkey
|
||||
// (host-scoped — NOT a raw account-wide secret). The returned wrapped blob is EnrollResult.hostContentSecret;
|
||||
// P2 unwraps it locally with the enrollment private key (never leaves the host, INV4). The control plane
|
||||
// persists ONLY the wrapped blob (a per-host, per-wrap artifact the CP can invalidate on host/device
|
||||
// revocation, INV12) — the raw secret is NEVER stored, NEVER logged (INV5/INV9). Feeds §4.4 deriveContentKey.
|
||||
```
|
||||
- **TDD**:
|
||||
- **Happy path**: valid unexpired unredeemed code → `bindHost` runs, subdomain assigned, `redeemed_at`
|
||||
set, `EnrollResult` returned with `cert`/`caChain` as PEM strings, a cert whose subject pubkey ==
|
||||
`agentPubkey`, and a non-empty `hostContentSecret` (frozen §4.5 fields all present).
|
||||
- **hostContentSecret mint/wrap (FIX 3, security)**: `EnrollResult.hostContentSecret` is present and is
|
||||
the **wrapped** blob (grep PG/logs → the raw 32-byte secret NEVER appears, INV5/INV9); two enrollments
|
||||
(or two hosts) yield **distinct** wrapped secrets (per-host, per-wrap → per-host revocable, INV12);
|
||||
the wrap targets the enrolled `agent_pubkey` (P2 can unwrap with the matching private key, no other host can).
|
||||
- **Double-spend (security)**: two concurrent `redeemPairingCode` with the same code → **exactly one**
|
||||
succeeds, the other → `already_redeemed` (CAS on `redeemed_at`).
|
||||
- **Expired** code → reject; **unknown** code → reject; **CSR pubkey ≠ body `agentPubkey`** → reject.
|
||||
- **CSR proof-of-possession (security)**: a CSR whose PKCS#10 **self-signature does not validate**
|
||||
against its **own embedded pubkey** → **reject**, *independent of registry state* — assert this holds
|
||||
**even when that pubkey happens to be a registered, active host** (so a signature-forged CSR for a
|
||||
real registered key cannot obtain a leaf). Reject uses the same error path as the INV14 gate.
|
||||
- **INV14 gate**: `signHostLeaf` for a pubkey not in the registry → **throws** (assert the CA cannot be
|
||||
tricked into signing an unbound key), and the KMS `sign()` is never invoked when any check fails.
|
||||
- **INV5**: redemption matches by **hash** of the presented code, never a stored raw code.
|
||||
- **Lockout (security)**: after `pairingMaxRedeemAttempts` (default 5) failed redemptions against the
|
||||
**same `code_hash`**, the next attempt — **even with the correct code** — is refused as locked
|
||||
(`too_many_attempts`), and the counter increment is atomic under concurrency (no attempt escapes the
|
||||
count via a race). Assert lockout is per-`code_hash`, not per-account/IP.
|
||||
- **Security**: INV5, INV14 (registry-gated signing incl. CSR proof-of-possession), INV8 (BIND writes
|
||||
immutable host row), INV3 (account taken from the pairing row, not from the agent's request).
|
||||
Redemption is **atomic + single-use** with a **code-scoped brute-force lockout** (INDEX §4.5).
|
||||
|
||||
---
|
||||
|
||||
## 6. CP3 — live routing table & node coordination
|
||||
|
||||
### T9 · Redis routing table + relay-node identity `[v0.9]`
|
||||
- **Owns**: `control-plane/src/routing/table.ts`, `control-plane/src/node-auth/identity.ts`
|
||||
- **Depends**: T2, T4 · **Cross-plan**: **read by P1** (data plane splices browser → node holding tunnel);
|
||||
relay-node **service certs** issued/rotated by **P5/P1** — see **OQ5**
|
||||
- **Contract** (INDEX §4.2 Redis `route:{host_id}`, INV7):
|
||||
```ts
|
||||
// control-plane/src/node-auth/identity.ts — the relay-node↔control-plane trust boundary (analog of INV3)
|
||||
export interface NodeIdentity { readonly nodeId: string } // derived from the mTLS client-cert SUBJECT
|
||||
export function nodeIdentityFromRequest(req: FastifyRequest): NodeIdentity
|
||||
// reads the VERIFIED relay-node mTLS client-cert subject (SPIFFE-style node id) from the TLS session;
|
||||
// THROWS 401 if the connection is not mutually authenticated. The `nodeId` is NEVER taken from a body/
|
||||
// query/header field — a request that carries a `nodeId` payload has it IGNORED for identity.
|
||||
|
||||
// control-plane/src/routing/table.ts — every mutation is scoped to the AUTHENTICATED node identity:
|
||||
export function upsertRoute(caller: NodeIdentity, hostId: string, entry: RouteEntry, ttlSec: number): Promise<void>
|
||||
// entry.relayNodeId MUST equal caller.nodeId (a node can only route hosts to ITSELF); mismatch → reject
|
||||
export function heartbeatRoute(caller: NodeIdentity, hostId: string, ttlSec: number): Promise<void> // refresh TTL (~<15s)
|
||||
export function resolveRoute(hostId: string): Promise<RouteEntry | null> // Redis lookup; null ⇒ offline
|
||||
export function dropRoute(caller: NodeIdentity, hostId: string): Promise<void> // only the holding node (or drain/T13)
|
||||
```
|
||||
- **TDD** (Testcontainers Redis): `upsertRoute` then `resolveRoute` returns the entry; after `ttlSec`
|
||||
with no heartbeat → `resolveRoute` null (heartbeat-TTL expiry = liveness, INV7); `heartbeatRoute` from
|
||||
a `caller.nodeId` that is **not** the current route holder is rejected/no-ops (a stale/foreign node
|
||||
can't hijack a tenant's live tunnel); **node-identity (security)**: `upsertRoute` whose
|
||||
`entry.relayNodeId ≠ caller.nodeId` → **reject**, and `nodeIdentityFromRequest` on a
|
||||
non-mutually-authenticated connection → **401** (assert a caller cannot inject a route for a
|
||||
`hostId`/`nodeId` it is not the authenticated identity for, even if it guesses the ids); `dropRoute`
|
||||
removes it.
|
||||
- **Security**: **INV7** — Redis holds only *location*, never ownership or secrets; a missing/expired key
|
||||
fails **closed** (host treated offline, not "route anywhere"). Postgres remains ownership truth. The
|
||||
`nodeId` is **derived from the caller's authenticated mTLS identity, never a request field** — the
|
||||
relay-node↔control-plane analog of INV3, closing the "any reachable caller can redirect a tunnel" gap.
|
||||
|
||||
### T10 · relay-node registry + graceful drain `[v0.9]`
|
||||
- **Owns**: `control-plane/src/routing/nodes.ts`
|
||||
- **Depends**: T9 · **Cross-plan**: requests **P1** `GOAWAY` (INDEX §4.1) by PUBLISHING a `KillSignal` on
|
||||
the FROZEN `relay:revocations` bus (INDEX §4.2 FIX 4 — the **SAME** channel T13 revoke uses); P1 nodes
|
||||
subscribe, emit the §4.1 `GOAWAY`, agents reconnect. **OQ4 RESOLVED.**
|
||||
- **Contract** (EXPLORE §3 "graceful drain"). Every mutation is scoped to the caller's authenticated
|
||||
`NodeIdentity` (T9) — a node can only register/heartbeat/drain **itself**; admin-initiated drain arrives
|
||||
through the T11 admin API (capability-token authorized), not as a bare `nodeId` field:
|
||||
```ts
|
||||
export type NodeStatus = 'active' | 'draining'
|
||||
export function registerNode(caller: NodeIdentity, addr: string): Promise<void> // nodeId = caller.nodeId
|
||||
export function nodeHeartbeat(caller: NodeIdentity): Promise<void>
|
||||
export function beginDrain(caller: NodeIdentity): Promise<{ readonly hostIds: readonly string[] }>
|
||||
// SELF-drain only: marks caller.nodeId 'draining', enumerates its live routes; then a KillSignal is
|
||||
// published on the frozen relay:revocations bus (INDEX §4.2 FIX 4) so P1 emits GOAWAY(lastStreamId,
|
||||
// reason) for those streams. A caller cannot drain a node other than its own authenticated identity.
|
||||
export function completeDrain(caller: NodeIdentity): Promise<void> // after routes re-homed → deregister self
|
||||
// Operator-initiated drain (an admin draining a named node for maintenance) is a T11 admin route: it
|
||||
// runs its own capability-token authz (rights ⊇ 'manage') and THEN invokes the same drain path with a
|
||||
// server-derived NodeIdentity — the untrusted request never supplies a bare nodeId to this module.
|
||||
```
|
||||
- **TDD**: `beginDrain` flips status to `draining` and returns exactly the host_ids routed to that node;
|
||||
a drained node stops receiving new `upsertRoute` targeting it; **node-identity (security)** — a
|
||||
`registerNode`/`nodeHeartbeat`/self-`beginDrain` whose `nodeId` is not the caller's authenticated
|
||||
identity (and not a `manage`-scoped admin) is **rejected** (a forged/guessed `nodeId` cannot drain or
|
||||
re-register another node); **PTY-survival assertion** — draining a node does not touch any Postgres
|
||||
host/session row (the running task lives on the customer machine, EXPLORE §3); `completeDrain`
|
||||
deregisters only after routes moved.
|
||||
- **Security**: INV7 (stateless data plane — drain loses nothing durable). Drain uses the frozen §4.1
|
||||
`GOAWAY` semantics; this module **does not** encode the frame (that is P1) — it only decides *which*
|
||||
hosts drain and PUBLISHES a `KillSignal` on the **frozen `relay:revocations` bus** (INDEX §4.2 FIX 4,
|
||||
the SAME channel T13 uses) that every P1 node subscribes to and translates into the §4.1 `GOAWAY`.
|
||||
**OQ4 RESOLVED** — the channel is now a FROZEN SHARED CONTRACT in the INDEX, not an assumed dispatcher.
|
||||
`nodeId` in every call is the authenticated mTLS identity (Finding-2), never a trusted request field.
|
||||
|
||||
---
|
||||
|
||||
## 7. CP4 — provisioning API & metering
|
||||
|
||||
### T11 · provisioning / deprovisioning HTTP API `[v0.9]`
|
||||
- **Owns**: `control-plane/src/api/provision.ts`, `control-plane/src/api/authz.ts`,
|
||||
`control-plane/src/deprovision/deprovision.ts`, `control-plane/src/main.ts`
|
||||
- **Depends**: T3, T4, T6, T7, T9 · **Cross-plan**: authz consumes **P5** `verifyCapabilityToken` (§4.3)
|
||||
- **Phasing note (Finding-7):** the v0.9 `DELETE /hosts/:id` is **self-contained in v0.9** — it calls the
|
||||
**v0.9 minimal deprovision** in `deprovision/deprovision.ts` (`deprovisionHost`: `setHostStatus(hostId,
|
||||
'revoked')` via T4 + `dropRoute` via T9), which is enough to tear the host down for accounting/UX. It
|
||||
does **NOT** depend on T13 (v0.10). The **fast, within-seconds tunnel-kill + cert-stop** hardening
|
||||
(INV12) is an **additive upgrade** delivered by T13 in v0.10; when T13 lands, `DELETE /hosts/:id`
|
||||
delegates to `revokeHost` for the seconds-latency path. No v0.9 task depends on a v0.10 task.
|
||||
```ts
|
||||
// control-plane/src/deprovision/deprovision.ts — v0.9 minimal tear-down (superseded by T13 fast path)
|
||||
export function deprovisionHost(principal: AdminPrincipal, hostId: string): Promise<void>
|
||||
// requires ownsHost(principal.accountId, hostId); setHostStatus('revoked') (T4 versioned) + dropRoute (T9).
|
||||
// Best-effort tunnel drop only; NOT the INV12 within-seconds guarantee (that is T13/v0.10).
|
||||
```
|
||||
- **Contract**:
|
||||
```ts
|
||||
// control-plane/src/api/authz.ts
|
||||
export interface AdminPrincipal { readonly accountId: string; readonly rights: readonly CapabilityRight[] }
|
||||
export function principalFromRequest(req: FastifyRequest): AdminPrincipal
|
||||
// derives accountId from the VERIFIED capability token / authenticated session ONLY (INV3);
|
||||
// any body/query field named account_id/tenant_id is IGNORED for authz decisions.
|
||||
|
||||
// control-plane/src/api/provision.ts (all routes deny-by-default; scope every op to principal.accountId)
|
||||
// POST /accounts -> create account (admin-scoped)
|
||||
// POST /accounts/:id/pairing-codes -> issuePairingCode(principal.accountId) (T7)
|
||||
// POST /accounts/:id/status -> suspend/reactivate (T3)
|
||||
// GET /accounts/:id/hosts -> listHosts(principal.accountId) (T4, ownership-scoped)
|
||||
// DELETE /hosts/:hostId -> v0.9: deprovisionHost (T11) ; v0.10: revokeHost (T13 fast path)
|
||||
// — both require ownsHost(principal.accountId, hostId)
|
||||
// POST /enroll -> redeemPairingCode (T8) — agent leg, NOT capability-token gated
|
||||
export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync
|
||||
```
|
||||
- **TDD**:
|
||||
- **INV3/INV1 (security, the headline test)**: request authenticated as account **A** with body
|
||||
`{ account_id: B }` targeting a host owned by **B** → **403**; the forged `account_id` is never read.
|
||||
- `principalFromRequest` with a missing/expired/foreign-`aud` token → 401 (delegates to
|
||||
`verifyCapabilityToken`); scope `attach` cannot hit a `manage`/`kill` route (rights subset check).
|
||||
- `DELETE /hosts/:id` where `!ownsHost(principal.accountId, id)` → 403 (not 404-leak vs 403 — pick 403
|
||||
per INDEX INV1 tripwire wording).
|
||||
- Every route validates its body with **Zod** at the boundary; malformed → 400.
|
||||
- **Security**: **INV3** (principal-derived account), **INV6** (deny-by-default; every route re-checks
|
||||
ownership), **INV1** (ownership join on host-targeting routes). No route resolves a host by
|
||||
user-supplied address. `/enroll` is the one unauthenticated-by-capability-token route (it *is* the
|
||||
auth-bootstrap) but is guarded by the single-use pairing code (T8).
|
||||
|
||||
### T12 · billing-metering hooks `[v0.9]`
|
||||
- **Owns**: `control-plane/src/metering/collect.ts`
|
||||
- **Depends**: T4, T9 · **Cross-plan**: **P1** relay nodes POST viewer-count samples (they alone see
|
||||
live streams)
|
||||
- **Contract** (EXPLORE §6 — meter **paired hosts + concurrent viewers**, NOT bandwidth). The sample's
|
||||
origin `nodeId` is the caller's **authenticated mTLS identity** (T9 `NodeIdentity`), never a body field,
|
||||
and the sample's `accountId` is **re-derived server-side** from the host registry — the node-supplied
|
||||
`accountId` is treated as untrusted and validated, not trusted (INV1/INV3):
|
||||
```ts
|
||||
export interface MeteringSample { // pushed by relay nodes; immutable append
|
||||
readonly hostId: string
|
||||
readonly concurrentViewers: number; readonly sampledAt: string
|
||||
// NO client/node-supplied accountId or nodeId — both are derived server-side (see below)
|
||||
}
|
||||
export function ingestSample(caller: NodeIdentity, sample: MeteringSample): Promise<void>
|
||||
// sets nodeId = caller.nodeId; derives accountId via ownsHost/getHost(sample.hostId); append-only
|
||||
// INSERT (INV8) — never updates. A hostId the caller cannot substantiate against the registry is rejected.
|
||||
export function pairedHostCount(accountId: string): Promise<number> // from host registry (non-revoked)
|
||||
export function rollupUsage(accountId: string, from: string, to: string): Promise<{
|
||||
readonly pairedHostPeak: number; readonly viewerPeak: number; readonly viewerHours: number
|
||||
}>
|
||||
```
|
||||
- **TDD**: `ingestSample` inserts a new immutable row (no `UPDATE`) with `nodeId` set to the authenticated
|
||||
caller and `accountId` derived from the host registry; `pairedHostCount` excludes `status:'revoked'`
|
||||
hosts; `rollupUsage` computes peak concurrent viewers + viewer-hours over a window; **forged-attribution
|
||||
(security)**: a sample for a `hostId` whose derived owner differs from any node-hinted account, or from
|
||||
an unauthenticated caller, is **rejected** (metering can't be forged or attributed cross-tenant, INV1;
|
||||
a node cannot mint usage against another tenant); malformed sample → Zod 400.
|
||||
- **Security**: INV8 (append-only immutable samples), INV1 (attribution derived from ownership, never
|
||||
node-asserted), INV3-analog (nodeId from authenticated identity). Zero terminal payload in samples
|
||||
(INV10-adjacent) — counts and timestamps only.
|
||||
|
||||
---
|
||||
|
||||
## 8. CP5 — hardening (v0.10)
|
||||
|
||||
### T13 · revocation (global + per-host) `[v0.10]`
|
||||
- **Owns**: `control-plane/src/revoke/revoke.ts`
|
||||
- **Depends**: T4, T9, T10 · **Cross-plan**: shares Redis `revoked:{jti}` with **P5**; the **within-seconds
|
||||
tunnel-kill** step PUBLISHES a `KillSignal` on the FROZEN `relay:revocations` bus (INDEX §4.2 FIX 4) via
|
||||
`RevocationBus.publish` — the **SAME named channel** T10's drain uses; every P1 node subscribes and
|
||||
injects the §4.1 `CLOSE`+`RST` within `REVOCATION_PUSH_BUDGET_MS` (2000ms). **P5 also publishes** on this
|
||||
bus (token revocation); **P3 owns** host/account revocation publishes. **OQ4 RESOLVED.**
|
||||
- **Contract** (INDEX §4.2 `revoked:{jti}`, INV12):
|
||||
```ts
|
||||
// revoke.ts IMPORTS KillSignal / RevocationScope / RevocationBus / RELAY_REVOCATIONS_CHANNEL /
|
||||
// REVOCATION_PUSH_BUDGET_MS from relay-contracts (INDEX §4.2 FIX 4) — NONE redefined locally. It
|
||||
// constructs the RevocationBus over the Redis pub/sub client and only PUBLISHES (P1 owns the subscriber).
|
||||
// KillSignal.reason is metadata only (INV10, zero payload).
|
||||
export function revokeHost(hostId: string): Promise<void>
|
||||
// 1) host status -> 'revoked' + revoked_at (T4, versioned snapshot) 2) dropRoute (T9)
|
||||
// 3) bus.publish({ scope: { kind: 'host', hostId }, at, reason }) on RELAY_REVOCATIONS_CHANNEL
|
||||
// ('relay:revocations') — P1 subscribers inject the §4.1 CLOSE+RST (immediate, distinct from the
|
||||
// graceful GOAWAY) within REVOCATION_PUSH_BUDGET_MS (2000ms) 4) stop future cert signing for its
|
||||
// pubkey (T8 CA gate + T15 renew gate now refuse) — all within seconds (INV12)
|
||||
export function revokeAccount(accountId: string): Promise<void>
|
||||
// revoke every host of the account; bus.publish({ scope: { kind: 'account', accountId }, at, reason })
|
||||
// — one account-scoped KillSignal; P1 tears down all of the account's live streams (§4.2 FIX 4)
|
||||
export function revokeToken(jti: string, expUnix: number): Promise<void> // SET revoked:{jti} EX (exp-now)
|
||||
export function isTokenRevoked(jti: string): Promise<boolean>
|
||||
```
|
||||
- **TDD** (security): after `revokeHost`, `resolveRoute(hostId)` → null AND a **`KillSignal { scope:{ kind:
|
||||
'host', hostId } }` is published on `relay:revocations`** (assert a test subscriber receives it, so a P1
|
||||
node would tear the tunnel down within `REVOCATION_PUSH_BUDGET_MS`) AND the host's next reconnect is
|
||||
refused (status `revoked`) AND `signHostLeaf` refuses its pubkey (T8 gate) — all observable within a
|
||||
bounded time; `revokeAccount` publishes one **account-scoped** `KillSignal` and cascades to all hosts;
|
||||
`revokeToken` → `isTokenRevoked` true and stops validating (INV12); a revoked host's capability tokens
|
||||
stop authorizing (via `revoked:{jti}` + status). Assert the published `KillSignal.reason` carries **no**
|
||||
terminal payload (INV10).
|
||||
- **Security**: **INV12** (fast tunnel-killing revocation), **INV8** (revocation writes an immutable
|
||||
snapshot, doesn't mutate). Revocation is idempotent.
|
||||
|
||||
### T14 · immutable audit log (zero payload) `[v0.10]`
|
||||
- **Owns**: `control-plane/src/audit/log.ts`
|
||||
- **Depends**: T2 · **Cross-plan**: **imported by P5** for attach/manage/kill security events (P3 owns the
|
||||
*substrate + control-plane events*; P5 owns *connect-time event semantics + cross-tenant alerts*)
|
||||
- **Contract** (INV10):
|
||||
```ts
|
||||
export type AuditAction = 'account.create' | 'account.suspend' | 'host.bind' | 'host.revoke'
|
||||
| 'pairing.issue' | 'pairing.redeem' | 'subdomain.assign' | 'node.drain'
|
||||
| 'attach' | 'manage' | 'kill' | 'revoke' // the last four written by P5 through this API
|
||||
export interface AuditEntry {
|
||||
readonly action: AuditAction; readonly principalId: string; readonly accountId: string
|
||||
readonly hostId: string | null; readonly ts: string; readonly meta: Readonly<Record<string, string>>
|
||||
// NO terminal payload, NO keystrokes/output, NO secret — metadata only (INV10)
|
||||
}
|
||||
export function writeAuditEvent(entry: AuditEntry): Promise<void> // append-only INSERT; immutable
|
||||
export function queryAudit(accountId: string, from: string, to: string): Promise<readonly AuditEntry[]>
|
||||
```
|
||||
- **TDD** (security): a `writeAuditEvent` call carrying a `meta` value that looks like shell output is
|
||||
**rejected/stripped** by a payload guard (assert no field can hold >N bytes of opaque data / a known
|
||||
plaintext marker never lands in `audit_log`); entries are append-only (no `UPDATE`/`DELETE` path
|
||||
exists); every control-plane mutation in T3/T4/T6/T7/T8/T10/T13 emits exactly one audit entry.
|
||||
- **Security**: **INV10** — immutable, **zero-payload**, metadata-only. The writer is the single funnel
|
||||
P5 uses so cross-tenant alerts (P5-owned) sit atop the same append-only store.
|
||||
|
||||
### T15 · cert-rotation coordination + secret hardening `[v0.10]`
|
||||
- **Owns**: `control-plane/src/ca/rotate.ts` (renewal), `control-plane/src/boot/ca-wiring.ts`
|
||||
(CA-key/secret-manager bootstrap + KMS-signer factory). **Disjoint from T8** (`ca/sign.ts`) **and T11**
|
||||
(`main.ts`): `main.ts` merely *imports* `boot/ca-wiring.ts`; T15 edits **no** file owned by another task.
|
||||
The KMS-signer factory in `boot/ca-wiring.ts` is the shared primitive both `ca/sign.ts` (T8) and
|
||||
`ca/rotate.ts` (T15) call — neither loads a raw key (§3.1).
|
||||
- **Depends**: T8, T1 · **Cross-plan**: rotation *cadence + mTLS verify* is **P5/INV14**; P3 provides the
|
||||
registry-gated **signing** only
|
||||
- **Contract**:
|
||||
```ts
|
||||
// control-plane/src/boot/ca-wiring.ts
|
||||
export interface CaSigner { sign(tbsCert: Uint8Array): Promise<Uint8Array> } // wraps KMS sign(); no raw key
|
||||
export function buildCaSigner(env: ControlPlaneEnv): Promise<CaSigner>
|
||||
// resolves caIntermediateKmsKeyRef; FAILS FAST if the KMS key is unresolvable OR its policy does not
|
||||
// restrict `sign` to the control-plane service principal (§3.1 startup check, INV9).
|
||||
|
||||
// control-plane/src/ca/rotate.ts
|
||||
export function renewHostLeaf(hostId: string, csr: Uint8Array): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }>
|
||||
// re-sign a SHORT-TTL leaf for an already-bound, non-revoked host under the CURRENT intermediate.
|
||||
// Same guards as signHostLeaf (T8): (1) verify the CSR PKCS#10 SELF-SIGNATURE (proof-of-possession)
|
||||
// against its embedded pubkey; (2) that pubkey == the host's registered agent_pubkey; (3) host is
|
||||
// active/non-revoked. Any failure → same reject path. Signing = CaSigner.sign() (KMS), never a raw key.
|
||||
```
|
||||
- **TDD**: `renewHostLeaf` for an active host with a valid CSR → fresh short-TTL cert (same subject
|
||||
pubkey, signed under the current intermediate); a CSR whose **self-signature does not validate** →
|
||||
reject even for an active host (proof-of-possession, Finding-1); for a **revoked** host → throws (a
|
||||
drained/revoked host cannot renew, closing the INV12+INV14 loop); `buildCaSigner` **fails fast** at
|
||||
startup if `caIntermediateKmsKeyRef` is unresolvable OR its KMS policy is broader than the
|
||||
control-plane service principal (§3.1, INV9); assert renewal invokes `CaSigner.sign()` and never loads
|
||||
a raw private key.
|
||||
- **Security**: **INV14** (short-lived, registry-gated leaf with CSR proof-of-possession; CA never signs
|
||||
an unbound/revoked pubkey), **INV9** (CA intermediate key held in KMS/HSM by reference, signing via
|
||||
`sign()` call, validated + policy-checked at startup, never logged, never at rest in code — §3.1).
|
||||
|
||||
---
|
||||
|
||||
## 9. SECURITY section (per-plan summary)
|
||||
|
||||
| INV | How this plan enforces it | Owning task(s) / test |
|
||||
|-----|---------------------------|------------------------|
|
||||
| **INV1** | Host is resolvable **only** via `host_id`/`subdomain` bound to an account; `ownsHost` is the sole ownership predicate; no address/port/hostname resolution path exists. | T4 `ownsHost`, T6 subdomain validation, T11 403-on-forged-account |
|
||||
| **INV3** | `account_id` derived from the authenticated principal / pairing row / host row — **never** a client field. **Relay-node analog:** `nodeId` in T9/T10/T12 is derived from the caller's authenticated mTLS identity, never a request field. | T5, T7, T11 `principalFromRequest`; T9 `nodeIdentityFromRequest`, T10/T12 node-auth |
|
||||
| **INV4** | DB stores **public** Ed25519 keys only; private key generated on the host (P2), never received. | T2 schema, T4 `bindHost`, T8 CSR check |
|
||||
| **INV5** | Pairing codes + v0.8 tokens hashed at rest; no raw secret, no shell bytes stored. | T0, T2, T7 |
|
||||
| **INV6** | Deny-by-default: every admin route + reattach re-validates ownership; default reject. | T4 `ownsHost`, T5 `sessionAccount`, T11 |
|
||||
| **INV7** | Redis routing = *location only*, heartbeat-TTL, fails closed; Postgres = ownership truth; nodes hold no durable state. | T9, T10 |
|
||||
| **INV8** | Immutable lifecycle fields versioned via `*_status_versions` companion tables + atomic pointer-swap (prior snapshot readable); liveness fields are advisory caches (Redis is truth, INV7); append-only metering/audit. | T2, T3, T4, T5, T12, T14 |
|
||||
| **INV9** | Secrets from env/secret-manager, Zod-validated at startup, fail-fast, never logged. **CA intermediate key held in KMS/HSM (non-exportable); signing via `sign()` call, never loaded raw; startup key-policy check.** | T1, T15 (§3.1) |
|
||||
| **INV10** | Append-only, metadata-only audit writer with a payload guard; single funnel for P5 events. | T14 |
|
||||
| **INV12** | v0.9 `deprovisionHost` (status→revoked + dropRoute) for tear-down; v0.10 `revokeHost/Account/Token` drop route + kill tunnel by publishing a `KillSignal` on the frozen `relay:revocations` bus (INDEX §4.2 FIX 4) + stop cert signing within seconds. | T11, T13, T15 |
|
||||
| **INV14 (registry half)** | CA verifies **CSR proof-of-possession (PKCS#10 self-signature)** then signs **only** pubkeys active in the host registry; refuses revoked/absent. | T8, T15 |
|
||||
|
||||
**Not owned here (delegated, cited to avoid overlap):** capability-token **signing key** + WS-upgrade
|
||||
enforcement + per-tenant **rate-limit enforcement** + the **CI cross-tenant tripwire** + human Passkey
|
||||
auth = **P5**; the mux frame codec + `GOAWAY` wire encoding = **P1**; E2E crypto = **P4**.
|
||||
|
||||
---
|
||||
|
||||
## 10. VERIFICATION
|
||||
|
||||
```bash
|
||||
# unit + integration (Testcontainers spins ephemeral Postgres + Redis)
|
||||
cd control-plane && npm test # full P3 suite
|
||||
npx vitest run registry # T3–T5 immutable-record + ownership-join tests
|
||||
npx vitest run pairing # T7 ≥128-bit entropy + T8 atomic single-use / double-spend / CSR proof-of-possession / code-scoped lockout
|
||||
npx vitest run routing # T9 heartbeat-TTL liveness + node-identity (forged nodeId rejected) + T10 drain (PTY-survival)
|
||||
npx vitest run api/provision # T11 INV3/INV1 403-on-forged-account (headline security test)
|
||||
npx vitest run metering # T12 append-only + cross-tenant attribution reject
|
||||
npx vitest run revoke # T13 revoke → route dropped + KillSignal on relay:revocations + reconnect refused (INV12)
|
||||
npx vitest run audit # T14 zero-payload guard + append-only
|
||||
npm run typecheck # relay-contracts §4 imported read-only; no local redefinition
|
||||
npx vitest run --coverage # 80%+ (coding-style/testing.md)
|
||||
|
||||
# at-rest secret/plaintext scans (INV5/INV9/INV10 tripwires — run in CI)
|
||||
scripts/scan-at-rest.sh # grep PG/Redis/disk dumps for raw pairing codes, private keys, shell markers → MUST be empty
|
||||
```
|
||||
|
||||
**Manual / cross-plan integration (v0.9 gate):** issue a pairing code (T7) → redeem from a real agent
|
||||
(P2) → `route:{host_id}` appears in Redis (T9, written under the node's authenticated mTLS identity) → P1
|
||||
resolves it → `DELETE /hosts/:id` deprovisions (T11 minimal path: status→revoked + dropRoute) → reconnect
|
||||
refused. **NOTE:** graceful node drain (T10) and the within-seconds tunnel-kill (T13) both PUBLISH a
|
||||
`KillSignal` on the now-**frozen** `relay:revocations` bus (INDEX §4.2 FIX 4); the end-to-end integration
|
||||
asserts a P1 subscriber tears the live tunnel down within `REVOCATION_PUSH_BUDGET_MS` (2000ms) — **OQ4
|
||||
RESOLVED**, no longer a gate. **v0.10 gate:** `renewHostLeaf` rotates a live cert with no downtime; a
|
||||
revoked host cannot renew; audit log shows every lifecycle event with **zero** terminal payload.
|
||||
|
||||
---
|
||||
|
||||
## 11. OPEN QUESTIONS (return to orchestrator — do NOT guess, per CLAUDE.md)
|
||||
|
||||
- **OQ1 — TS shapes for `AccountRecord`/`SessionRecord`/`PairingCodeRecord`.** INDEX §4.2 freezes the
|
||||
**SQL** for these but gives TS interfaces only for `HostRecord`/`RouteEntry`. P5/P6/metering will want
|
||||
the account/session TS shapes too. **Should they be promoted into `relay-contracts/` §4.2 (via the
|
||||
INDEX) rather than mirrored locally in `control-plane/src/model/records.ts`?** Until decided, this plan
|
||||
mirrors them locally, matching the frozen SQL column-for-column.
|
||||
- **OQ2 — mTLS cert authority ownership split.** INDEX §5 assigns the "mTLS cert authority" to **P3**,
|
||||
but INV14's primary owners are listed as **P5, P2**. This plan takes the defensible split: **P3 owns
|
||||
the registry-gated *signing* (bind + renew), P5 owns rotation cadence + handshake verification.**
|
||||
Confirm this boundary so T8/T15 and P5 don't both implement rotation.
|
||||
- **OQ3 — Concurrent-viewer sampling source.** Metering (T12) needs live viewer counts that only the
|
||||
**P1** data plane observes. Confirm the push contract (relay node → `POST /metering/samples`) and its
|
||||
cadence lives in P1's charter, with P3 only ingesting.
|
||||
- **OQ4 — RESOLVED (INDEX §4.2 FIX 4).** The control-plane→relay-node teardown channel is now a **FROZEN
|
||||
SHARED CONTRACT**: the Redis pub/sub channel `RELAY_REVOCATIONS_CHANNEL = 'relay:revocations'` carrying a
|
||||
`KillSignal { scope, at, reason }` (`RevocationScope = { kind:'host' } | { kind:'account' } | { kind:
|
||||
'global' }`), published via `RevocationBus.publish` and **promoted into `relay-contracts`** so P3 never
|
||||
imports P5 (DAG stays acyclic). **P3/P5 publish; every P1 node subscribes** and injects the existing §4.1
|
||||
`CLOSE`+`RST` (host/account scope) or connection-level `GOAWAY` (global scope) — **no new wire type** —
|
||||
within `REVOCATION_PUSH_BUDGET_MS = 2000`. **T10 drain and T13 revoke use this SAME named channel** (this
|
||||
plan cites it verbatim, redefining nothing). **Residual:** the frozen `RevocationScope` has no `node`
|
||||
kind and no graceful-vs-immediate discriminator, so a single-node *operator* drain (T10) is expressed via
|
||||
per-host `KillSignal`s (with a drain `reason`) rather than one node-scoped signal — flagged back to the
|
||||
INDEX in case a `node` scope or a `mode: 'graceful' | 'immediate'` field is wanted later (an additive,
|
||||
INDEX-owned §4.2 change; not blocking). *(Originally raised by review Finding-8.)*
|
||||
- **OQ5 — Relay-node service-cert issuance/rotation owner (BLOCKS T9/T10/T12 node-auth).** T9's
|
||||
`nodeIdentityFromRequest` derives `nodeId` from a **verified relay-node mTLS client cert**, and
|
||||
T9/T10/T12 reject any bare request-supplied `nodeId`. Who **issues + rotates** those node service certs
|
||||
(SPIFFE-style), and what trust bundle (`nodeMtlsTrustBundlePath`, T1) the control plane pins to verify
|
||||
them, is a **P5/P1 boundary** not yet frozen. Confirm the owner and the node-cert SVID format so the
|
||||
control plane's verifier and the node's presented identity agree. *(Raised by review Finding-2.)*
|
||||
- **OQ6 — INV8 companion version tables must be promoted into frozen §4.2 (BLOCKS T2/T3/T4 as written).**
|
||||
§4.2 freezes `accounts`/`hosts`/`sessions` as single-PK rows, which cannot carry versioned snapshots.
|
||||
This plan adds `account_status_versions` / `host_status_versions` + a `status_version` pointer column to
|
||||
satisfy INV8 without in-place mutation of lifecycle fields (§4 discipline). Because §4.2 is frozen,
|
||||
these tables + column **must be promoted into INDEX §4.2** (like OQ1) rather than added only locally in
|
||||
T2's DDL. Confirm promotion; also confirm the ruling that high-frequency liveness fields (`last_seen`,
|
||||
`last_attach_at`) are **advisory caches exempt from versioning** (live truth is Redis, INV7) — so
|
||||
T3/T4/T5 do NOT ship "prior snapshot readable" assertions for liveness columns. *(Raised by review
|
||||
Finding-6.)*
|
||||
```
|
||||
658
docs/PLAN_RELAY_E2E.md
Normal file
658
docs/PLAN_RELAY_E2E.md
Normal file
@@ -0,0 +1,658 @@
|
||||
# PLAN_RELAY_E2E — End-to-End Encryption (ciphertext-shuttle) · P4
|
||||
|
||||
> **Plan P4 of the Rendezvous-Relay program.** Source of intent:
|
||||
> [`EXPLORE_RELAY_SERVICE.md`](./EXPLORE_RELAY_SERVICE.md) §0 (LOCKED, decision 2 = E2E) and §4c/§4d;
|
||||
> coordination point: [`PLAN_RELAY_INDEX.md`](./PLAN_RELAY_INDEX.md) — this plan **references its §3
|
||||
> invariants and §4 FROZEN CONTRACTS by name and MUST NOT redefine any of them locally.**
|
||||
> Conventions follow [`PLAN.md`](./PLAN.md) / [`PLAN_VOICE_COMMANDS.md`](./PLAN_VOICE_COMMANDS.md):
|
||||
> stable task IDs grouped into dependency waves, `Owns:` disjoint-file lists, function-signature-level
|
||||
> contracts, TDD (tests FIRST), explicit test cases incl. security/negative, per-task security notes.
|
||||
> `PROGRESS_LOG.md` is **orchestrator-only** — a dispatched builder ends with a ready-to-paste entry (G1).
|
||||
|
||||
---
|
||||
|
||||
## 0. Scope
|
||||
|
||||
Owns **browser↔agent end-to-end encryption**, delivered as the shared crypto core package
|
||||
**`relay-e2e/`** (INDEX §0 table) that is imported by both `agent/` (P2, host side) and the browser
|
||||
bundle `relay-web/` (P6, client side). Concretely, P4 implements exactly the pieces the INDEX §5 P4
|
||||
charter names, all **against the frozen §4 contracts**:
|
||||
|
||||
1. **Authenticated X25519 ECDH THROUGH the relay** — INDEX **§4.4** handshake (`ClientHello`/`HostHello`).
|
||||
The relay forwards handshake messages as opaque **§4.1 `DATA`** frames and **cannot derive the key**
|
||||
(INV2). Ephemeral X25519 + HKDF exactly as §4.4 specifies.
|
||||
2. **Host-key pinning / TOFU bound at enrollment** — the browser verifies the host's `enroll_fpr`
|
||||
(INDEX **§4.2** `hosts.enroll_fpr`); `HostHello.sig` = Ed25519 over the transcript by the host's
|
||||
enrolled key (§4.2 `agent_pubkey`). Fingerprint mismatch ⇒ **ABORT** (no malicious-relay MITM, §4d).
|
||||
3. **AEAD frame envelope** — INDEX **§4.4** `E2EEnvelope` (AES-256-GCM / XChaCha20-Poly1305), **deterministic
|
||||
per-`seq` nonce + strictly monotonic `seq` + direction-scoped subkeys** for anti-replay/injection and
|
||||
bidirectional-misuse resistance (INV13). `sealFrame`/`openFrame` per §4.4 — **the INDEX now freezes these
|
||||
as `sealFrame(key: AeadKey, seq, plaintext): E2EEnvelope` / `openFrame(key: AeadKey, env, expectedSeq)`,
|
||||
SYNCHRONOUS, with `AeadKey` (not `CryptoKey`), `aad = directionLabel‖seq`, and direction-split
|
||||
`DirectionalKeys{c2h,h2c}` (INDEX §4.4, FIX 2)**. P4 **cites these verbatim from `relay-contracts` and
|
||||
defines none of them locally** (the earlier drift/§9 Q4 amendment has landed).
|
||||
4. **Session-key lifecycle** — derived on attach, **re-derived on refresh/reconnect**; ring-buffer
|
||||
replay of *ciphertext* survives (EXPLORE §4c). The **recoverable-replay-key** design (T9/T10) is now a
|
||||
**frozen §4.4 surface** (`ReplayKeyParams`, `deriveContentKey`, `sealReplayFrame`, `openReplayCiphertext`,
|
||||
`REPLAY_KDF_INFO`; INDEX §4.4 + §4.5 `hostContentSecret`, FIX 3) — no longer an open §4.4 gap.
|
||||
5. **Multi-device key distribution over an authenticated (account-derived) channel — NOT a plaintext QR.**
|
||||
Each authorized device runs its own §4.4 handshake gated by an account-derived `deviceAuthProof`
|
||||
(INV3); the key never travels in cleartext and no shoulder-surfable QR carries it (§4c).
|
||||
6. **Documents the relay-sees-only-ciphertext guarantee** and the server features E2E kills — server-side
|
||||
preview thumbnails / manage grid / search / recording move **CLIENT-SIDE** (rendered in the
|
||||
key-holding browser, owned by P6). This plan ships the decrypt primitive P6 consumes; it does **not**
|
||||
own the UI.
|
||||
|
||||
### Out of scope (explicit fences — other plans own these)
|
||||
- **Transport framing / mux / routing** → P1 (§4.1). P4 payloads *ride inside* `DATA`; P4 never touches the header.
|
||||
- **Human auth (Passkey/WebAuthn/OIDC), capability tokens, `deviceAuthProof` issuance+verification, revocation, audit** → P5 (§4.3). P4 *carries* `deviceAuthProof` and *shape-validates* it, but its cryptographic meaning and verification are P5's.
|
||||
- **Enrollment / pairing / Ed25519 host keypair generation + storage** → P2 (§4.5) & P3 (§4.2). P4 *consumes* the enrolled pubkey (to verify `sig`) and the `enroll_fpr` (to pin); it does not generate or persist host identity keys.
|
||||
- **Account/host/session records, Postgres/Redis** → P3 (§4.2).
|
||||
- **Client-side preview UI, dashboard, xterm render** → P6. P4 exposes `E2ESession.open()`; P6 renders.
|
||||
|
||||
### Phasing (INDEX §1)
|
||||
- **v0.8 (MVP):** P4 **writes its contracts / wire format and ships test vectors, but NO live crypto in the byte path** (INDEX §1: "P4/P5 write their contracts … but ship no runtime"). Deliverables: the `E2EEnvelope` wire codec, the isomorphic crypto provider, AEAD primitives, and fingerprint helpers — all fully tested with KATs so the `DATA` payload is shaped for ciphertext to drop in without reshaping (INDEX §1 "the frame path is designed so ciphertext drops in"). Wave **E0**.
|
||||
- **v0.9:** no new P4 runtime (v0.9 stays plaintext; native mux + accounts land). P4 keeps E0 green against the native-mux `DATA` frame once P1 swaps substrate.
|
||||
- **v0.10 (E2E-hardening — the non-negotiable phase, done BEFORE scaling users):** full browser↔agent E2E — handshake state machine, session AEAD, anti-replay, key lifecycle, multi-device. Waves **E1–E3**.
|
||||
|
||||
### Security invariants this plan ENFORCES (INDEX §3)
|
||||
- **INV2** — relay handles only ciphertext (P4 is the reason it *can*: opaque AEAD envelopes; no key at the relay).
|
||||
- **INV5** — no plaintext/secret at rest (session keys are non-extractable `CryptoKey`s / zeroized buffers; ring buffer holds ciphertext only).
|
||||
- **INV13** — anti-replay/injection: per-message nonce + strictly monotonic `seq`; replay/reorder/tamper rejected.
|
||||
- **Consumes / reinforces:** INV1 (E2E makes cross-tenant buffer bleed *cryptographically* meaningless — B cannot open A's frames), INV3 (device bound to account via `deviceAuthProof`; no client-supplied identity in key derivation), INV9 (no secret material logged).
|
||||
|
||||
---
|
||||
|
||||
## 1. Package layout & file ownership
|
||||
|
||||
**All new code lives in `relay-e2e/`** (INDEX §0). Isomorphic ESM (browser + Node ≥20) — **zero `ws`/`pg`/DOM
|
||||
ambient**, so `agent/` and `relay-web/` can both import it. Small cohesive files (`coding-style.md`: 200–400
|
||||
typical, 800 hard max). `relay-contracts/` is **read-only** to this plan (frozen §4 types + Zod validators).
|
||||
|
||||
| File | Wave / Phase | Role |
|
||||
|------|------|------|
|
||||
| `relay-e2e/package.json` | E0 / v0.8 | Package manifest; deps: `@noble/ciphers`, `@noble/curves`, `@noble/hashes` (audited, isomorphic, **synchronous** primitives); dev: `vitest`, `esbuild`, `typescript`. Depends on `relay-contracts` (workspace). |
|
||||
| `relay-e2e/tsconfig.json` | E0 / v0.8 | Strict TS; `lib: ["ES2022","DOM"]` types-only (no DOM runtime use); dual ESM output. |
|
||||
| `relay-e2e/src/index.ts` | E3 / v0.10 | Barrel — re-exports the public surface only (handshake factories, `E2ESession`, `sealFrame`/`openFrame`, envelope codec, fingerprint, errors). |
|
||||
| `relay-e2e/src/errors.ts` | E0 / v0.8 | Typed error classes (§7). No `console`; callers handle. |
|
||||
| `relay-e2e/src/crypto-provider.ts` | E0 / v0.8 | Isomorphic entropy + constant-time compare: `randomBytes`, `timingSafeEqual`, `getWebCrypto()` (browser `crypto.subtle` / Node `webcrypto.subtle`) for the async handshake KDF/ECDH path. |
|
||||
| `relay-e2e/src/aead.ts` | E0 / v0.8 | Synchronous AEAD seal/open over `@noble/ciphers` (AES-256-GCM + XChaCha20-Poly1305); `nonceLength()`. **Sync** so it satisfies the frozen §4.4 `sealFrame`/`openFrame` *synchronous* signatures. |
|
||||
| `relay-e2e/src/envelope.ts` | E0 / v0.8 | Deterministic wire codec for §4.4 `E2EEnvelope` (`encodeEnvelope`/`decodeEnvelope`) — the bytes that ride inside a §4.1 `DATA` payload. |
|
||||
| `relay-e2e/src/fingerprint.ts` | E0 / v0.8 | `computeEnrollFpr` (matches §4.2 `enroll_fpr` format) + `verifyPinnedFingerprint` (timing-safe); TOFU pin-store interface. |
|
||||
| `relay-e2e/src/x25519.ts` | E1 / v0.10 | Ephemeral X25519 keygen + ECDH shared secret (WebCrypto X25519 with `@noble/curves` fallback for parity/KATs). |
|
||||
| `relay-e2e/src/hkdf.ts` | E1 / v0.10 | `deriveSessionKeys` — HKDF-SHA256 master per §4.4 (`salt = clientNonce‖hostNonce`, `info = "relay-e2e/v1"`), then a **direction-split** HKDF-Expand into two disjoint subkeys (`c2h`/`h2c`) so client-write and host-write never share key material (fixes bidirectional-AEAD misuse). |
|
||||
| `relay-e2e/src/sequence.ts` | E1 / v0.10 | `SequenceGuard` — send-side monotonic `next()`, recv-side strict `accept()` (INV13); one guard **per direction**, paired 1:1 with that direction's subkey. |
|
||||
| `relay-e2e/src/handshake.ts` | E2 / v0.10 | Client + host handshake state machines (§4.4): produce/consume `ClientHello`/`HostHello`, transcript sign/verify, derive `HandshakeResult`. |
|
||||
| `relay-e2e/src/session.ts` | E2 / v0.10 | `sealFrame`/`openFrame` (§4.4 verbatim) + `E2ESession` wrapper (wire seal/open, seq guarding, `rederive`). |
|
||||
| `relay-e2e/src/replay-key.ts` | E3 / v0.10 | Recoverable **content key** for ring-buffer ciphertext replay across reconnect (T9 / Open Question). |
|
||||
| `relay-e2e/src/keystore.ts` | E3 / v0.10 | Multi-device adapters: TOFU `DevicePinStore` + `DeviceAuthProofProvider` (account-derived, from P5). No key transport in cleartext. |
|
||||
| `relay-e2e/test/*.test.ts` | per task | Co-located vitest suites (one per src file) + KAT vectors + integration. |
|
||||
| `relay-e2e/test/vectors/*.json` | E0+ | Known-answer test vectors (AEAD, HKDF, envelope, fingerprint) — frozen so agent & browser agree byte-for-byte. |
|
||||
|
||||
**Frozen imports (read-only, never redefined here):** from `relay-contracts` — the **full §4.4 surface the
|
||||
INDEX froze under FIX 2/3**: `AeadAlg`, `AeadKey`, `SessionRole`, `ClientHello`, `HostHello`, `E2EEnvelope`,
|
||||
`DirectionalKeys`, `HandshakeResult`, `ClientHandshake`, `E2ESession`, `sealFrame`, `openFrame`,
|
||||
`createE2ESession`, `buildClientHandshake`, `AuthorizedDeviceContext`, `DeviceAuthProofProvider`, the HKDF
|
||||
label constants (`HKDF_INFO`, `HKDF_INFO_C2H`, `HKDF_INFO_H2C`), and the replay surface (`ReplayKeyParams`,
|
||||
`REPLAY_KDF_INFO`, `deriveContentKey`, `sealReplayFrame`, `openReplayCiphertext`); plus `CapabilityToken`
|
||||
(ref only), `HostRecord.enrollFpr`, and the Zod schemas (`ClientHelloSchema`, `HostHelloSchema`,
|
||||
`E2EEnvelopeSchema`). **`relay-contracts` owns the *shapes*; `relay-e2e/` owns the crypto *implementations*
|
||||
and re-exports them (INDEX §2.1).** P4 **calls `.parse()` at every deserialize boundary**
|
||||
(`coding-style.md` Input Validation) but **authors none of these shapes** (INDEX §4).
|
||||
|
||||
---
|
||||
|
||||
## 2. Waves & dependency ordering
|
||||
|
||||
```
|
||||
relay-contracts/ (§4 FROZEN) ──read-only──► all P4 files
|
||||
|
||||
E0 Foundations (v0.8 — contracts + wire, NO live path) [parallel, file-disjoint]
|
||||
T1 crypto-provider T2 aead T3 envelope T4 fingerprint T0 errors
|
||||
│ (E0 primitives frozen + KAT'd before any handshake consumes them)
|
||||
▼
|
||||
E1 Key agreement (v0.10) [parallel]
|
||||
T5 x25519 T6 hkdf T7 sequence
|
||||
│
|
||||
▼
|
||||
E2 Handshake + session (v0.10)
|
||||
T8 handshake (depends T5,T6,T4,errors) → T9 session (depends T2,T3,T6,T7,T8)
|
||||
│
|
||||
▼
|
||||
E3 Multi-device + integration (v0.10)
|
||||
T10 replay-key T11 keystore → T12 barrel + cross-package integration + INV2 tripwire
|
||||
```
|
||||
|
||||
**Cross-plan dependencies (reference other plans' task IDs where their tasks are defined):**
|
||||
- **P1 TRANSPORT (§4.1):** E2E envelopes are the `DATA` payload. T12's integration test uses a P1 loopback stub; the byte layout must satisfy P1's `payloadLen ≤ maxFrameBytes`. *Depends on P1's mux-frame task.*
|
||||
- **P2 AGENT (§4.5):** the **host** handshake (`createHostHandshake`) is wired into `agent/` and signs with the agent's enrolled Ed25519 **private** key (never in `relay-e2e`; injected via `HostSigner`). P2 also **seals every replay-bound host→client output under `K_content` via T10 `sealReplayFrame`** (see T10 frame-key routing) and **unwraps `hostContentSecret` from §4.5 `EnrollResult`** locally. *P2 imports P4's T8/T9/T10; P2's E2E-endpoint task depends on T8.*
|
||||
- **P3 CONTROL PLANE (§4.2):** browser fetches the raw `agentPubkey` (and `enrollFpr`) from the host registry **over an independent TLS-authenticated channel** — this is the pubkey `onHostHello` verifies against and recomputes the fingerprint from; it is **never** read out of the relay-carried `HostHello` (finding #3). *P4 consumes the §4.2 shape; P3's registry task provides it; the independent-channel property is a hard security precondition of T8.*
|
||||
- **P5 AUTH (§4.3, INV3):** issues + verifies a **per-handshake, non-replayable** `deviceAuthProof` **bound to `clientEphPub`/`clientNonce`** (finding #2) and delivers the **host-scoped** `hostContentSecret` wrapped to the agent identity (finding #5); gates the stream with a capability token *before* the handshake. P4 defines the field + Zod-validates it; **P5's device-proof task (binding semantics) and host-content-secret delivery/revocation are hard dependencies of T8/T10/T11.**
|
||||
- **P6 FRONTEND:** imports T8 (`createClientHandshake`), T9 (`E2ESession`), T10 (`openReplayCiphertext`) for connect + decrypt + client-side preview. *P6's connect + preview tasks depend on T12's published barrel.*
|
||||
|
||||
---
|
||||
|
||||
## 3. E0 — Foundations (v0.8, no live crypto in the byte path)
|
||||
|
||||
### T0 · errors + package scaffold · v0.8
|
||||
**Owns:** `relay-e2e/package.json`, `relay-e2e/tsconfig.json`, `relay-e2e/src/errors.ts`, `relay-e2e/test/errors.test.ts`
|
||||
**Depends:** `relay-contracts` frozen (INDEX §4, W0).
|
||||
|
||||
Contracts:
|
||||
```ts
|
||||
export class E2EError extends Error { readonly code: string }
|
||||
export class FingerprintMismatchError extends E2EError {} // pinned enroll_fpr != presented → MITM abort
|
||||
export class AeadOpenError extends E2EError {} // AEAD tag verify failed → drop + tear down
|
||||
export class ReplayError extends E2EError {} // seq non-monotonic / duplicate (INV13)
|
||||
export class HandshakeStateError extends E2EError {} // illegal transition / wrong phase
|
||||
export class EnvelopeFormatError extends E2EError {} // malformed wire bytes / bad version
|
||||
```
|
||||
TDD: assert each subclasses `E2EError`, carries a stable `code`, and (security) **its `message` never
|
||||
embeds key/nonce/plaintext material** (INV9 — a test greps thrown messages for injected secret markers).
|
||||
|
||||
### T1 · crypto-provider (isomorphic entropy + constant-time) · v0.8
|
||||
**Owns:** `relay-e2e/src/crypto-provider.ts`, `relay-e2e/test/crypto-provider.test.ts`
|
||||
**Depends:** T0.
|
||||
|
||||
```ts
|
||||
export function getWebCrypto(): SubtleCrypto // browser crypto.subtle | Node webcrypto.subtle; throws if absent
|
||||
export function randomBytes(len: number): Uint8Array // CSPRNG (crypto.getRandomValues)
|
||||
export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean // constant-time, length-safe
|
||||
```
|
||||
TDD (tests first):
|
||||
- returns a subtle instance in both jsdom and node vitest environments (matrixed via `environment` pragma).
|
||||
- `randomBytes(32)` length 32, two calls differ, never all-zero (statistical smoke).
|
||||
- **Security:** `timingSafeEqual` returns `false` on length mismatch **without** short-circuit branch on
|
||||
content length ordering; equal buffers → `true`; single-bit flip → `false`. (No `Buffer` dependency —
|
||||
hand-rolled XOR-accumulate so it runs in the browser.)
|
||||
- `getWebCrypto()` throws a typed `E2EError` (not a bare `TypeError`) when no WebCrypto — fail-fast (INV9-style boundary).
|
||||
|
||||
### T2 · AEAD primitives (synchronous) · v0.8
|
||||
**Owns:** `relay-e2e/src/aead.ts`, `relay-e2e/test/aead.test.ts`, `relay-e2e/test/vectors/aead.json`
|
||||
**Depends:** T0, T1. Imports `AeadAlg` **and `AeadKey`** from `relay-contracts` (frozen §4.4: `AeadAlg = 'aes-256-gcm' | 'xchacha20-poly1305'`; `AeadKey = { readonly __aeadKey: unique symbol }`, FIX 2).
|
||||
|
||||
```ts
|
||||
export function nonceLength(alg: AeadAlg): 12 | 24 // 12 GCM · 24 XChaCha (§4.4)
|
||||
export function tagLength(alg: AeadAlg): 16
|
||||
export function importAeadKey(raw: Uint8Array, alg: AeadAlg): AeadKey // opaque wrapper over 32-byte key
|
||||
export function aeadSeal(key: AeadKey, nonce: Uint8Array, plaintext: Uint8Array, aad: Uint8Array):
|
||||
{ ciphertext: Uint8Array; tag: Uint8Array } // SYNC (noble) — split ct/tag per §4.4 E2EEnvelope
|
||||
export function aeadOpen(key: AeadKey, nonce: Uint8Array, ciphertext: Uint8Array, tag: Uint8Array, aad: Uint8Array):
|
||||
Uint8Array // throws AeadOpenError on tag failure
|
||||
```
|
||||
**Design note (load-bearing) — contract now FROZEN (FIX 2), no longer drift:** `AeadKey` is the **frozen
|
||||
§4.4 opaque wrapper** over a 32-byte key (`type AeadKey = { readonly __aeadKey: unique symbol }`) — **imported
|
||||
from `relay-contracts`, not declared here**. The INDEX §4.4 amendment (FIX 2) has landed: it swaps
|
||||
`key: CryptoKey → AeadKey` and async → **synchronous**, so per-frame crypto uses **`@noble/ciphers` (audited,
|
||||
synchronous, isomorphic — and XChaCha, which WebCrypto lacks)**. `importAeadKey` is P4's local *constructor*
|
||||
that returns the frozen `AeadKey` type (an implementation detail permitted under INDEX §2.1: contracts own the
|
||||
shape, `relay-e2e/` owns the impl). T2's `AeadKey` and T9's `sealFrame`/`openFrame` are therefore **unblocked
|
||||
and cite §4.4 verbatim**. WebCrypto is reserved for the one-time handshake (T5/T6).
|
||||
TDD:
|
||||
- **KAT:** each RFC/reference vector in `aead.json` (GCM + XChaCha) seals to expected ct+tag and opens back.
|
||||
- round-trip: `aeadOpen(aeadSeal(...))` === plaintext for random inputs, both algs, empty & 1 MiB plaintext.
|
||||
- **Security (INV13 substrate):** flip one ciphertext bit → `AeadOpenError`; flip one AAD byte → `AeadOpenError`;
|
||||
wrong key → `AeadOpenError`; nonce reuse is **not** silently allowed (documented; enforcement is T7/T9).
|
||||
- `nonceLength` mismatch (12 given to XChaCha) → typed error, not a silent truncation.
|
||||
|
||||
### T3 · E2EEnvelope wire codec · v0.8
|
||||
**Owns:** `relay-e2e/src/envelope.ts`, `relay-e2e/test/envelope.test.ts`, `relay-e2e/test/vectors/envelope.json`
|
||||
**Depends:** T0, T2. Imports `E2EEnvelope`, `AeadAlg`, `E2EEnvelopeSchema` from `relay-contracts`.
|
||||
|
||||
Wire layout (P4-owned encoding of the frozen §4.4 `E2EEnvelope`; big-endian, rides inside one §4.1 `DATA` payload):
|
||||
```
|
||||
offset size field meaning
|
||||
0 1 envVersion 0x01
|
||||
1 1 aeadId 0x01 aes-256-gcm · 0x02 xchacha20-poly1305 (maps AeadAlg)
|
||||
2 8 seq uint64 BE (bigint) — strictly monotonic per direction (INV13)
|
||||
10 1 nonceLen 12 or 24 (must equal nonceLength(alg))
|
||||
11 N nonce per-message unique
|
||||
11+N var ciphertext AEAD ciphertext (length = total − 11 − N − 16)
|
||||
.. 16 tag AEAD auth tag (fixed 16)
|
||||
```
|
||||
```ts
|
||||
export function encodeEnvelope(env: E2EEnvelope, alg: AeadAlg): Uint8Array
|
||||
export function decodeEnvelope(buf: Uint8Array): { env: E2EEnvelope; alg: AeadAlg }
|
||||
```
|
||||
TDD:
|
||||
- KAT vectors in `envelope.json` encode to exact bytes; decode reproduces the struct (byte-for-byte agent↔browser parity).
|
||||
- round-trip `decodeEnvelope(encodeEnvelope(e))` deep-equals; `seq` survives as `bigint` (no float truncation past 2^53 — test `seq = 2n**63n - 1n`).
|
||||
- **Negative:** wrong `envVersion` → `EnvelopeFormatError`; truncated buffer (< 27 bytes min) → error; `nonceLen` inconsistent with `aeadId` → error; declared length overrun (`nonceLen` says 24, buffer too short) → error; unknown `aeadId` → error. Every deserialize path runs `E2EEnvelopeSchema.parse` (validation at boundary).
|
||||
- **Security:** codec **never** allocates based on an unchecked length field from the wire beyond `maxFrameBytes` (guards a decompression-bomb-style overrun; asserts a hard cap constant).
|
||||
|
||||
### T4 · fingerprint (host-key pin / TOFU) · v0.8
|
||||
**Owns:** `relay-e2e/src/fingerprint.ts`, `relay-e2e/test/fingerprint.test.ts`, `relay-e2e/test/vectors/fingerprint.json`
|
||||
**Depends:** T0, T1. Consumes §4.2 `enroll_fpr` format + §4.2 `agent_pubkey` (Ed25519 public key bytes).
|
||||
|
||||
```ts
|
||||
export function computeEnrollFpr(agentPubkey: Uint8Array): string // 'sha256:' + base64url(SHA-256(pubkey)) — matches §4.2 enroll_fpr
|
||||
export function verifyPinnedFingerprint(presentedPubkey: Uint8Array, pinnedFpr: string): boolean // timing-safe compare of fingerprints
|
||||
export interface DevicePinStore { // browser persists TOFU pins (impl injected by P6)
|
||||
get(hostId: string): string | null // pinned enroll_fpr for host, or null (first use → TOFU)
|
||||
pin(hostId: string, enrollFpr: string): void // set once; changing an existing pin is the caller's decision
|
||||
}
|
||||
// resolvePin takes the RAW agentPubkey bytes (fetched over the independent P3/TLS channel — see T8) and
|
||||
// recomputes the fingerprint locally; it MUST NOT accept a pre-computed fpr string, so the pin decision can
|
||||
// never be short-circuited into a relay-influenced 'presented string == pinned string' comparison.
|
||||
export function resolvePin(store: DevicePinStore, hostId: string, agentPubkey: Uint8Array):
|
||||
{ outcome: 'tofu-first-use' | 'match' | 'mismatch'; computedFpr: string; pinnedFpr: string | null }
|
||||
```
|
||||
TDD:
|
||||
- `computeEnrollFpr` matches KAT vectors and equals the value P3 stores as §4.2 `enroll_fpr` (shared vector file so P3's test and this test assert the *same* string).
|
||||
- `verifyPinnedFingerprint` is timing-safe (delegates to T1); true on match, false on any-byte diff and on length diff.
|
||||
- `resolvePin` **hashes the raw pubkey itself** (never trusts a caller-supplied fpr): empty store → `tofu-first-use`, `computedFpr` returned to persist; matching pin → `match`; **differing pin → `mismatch`** (malicious-relay / host-rotation MITM signal — the browser MUST abort/prompt, never auto-repin).
|
||||
- **Security:** `resolvePin` is given a pubkey whose locally-recomputed `computedFpr` differs from a (hypothetically relay-supplied) fpr string → the function keys its decision only off `computedFpr`, proving there is no string-only comparison path.
|
||||
- **Security:** a `mismatch` outcome never silently overwrites the stored pin (no TOFU downgrade). Fingerprint strings are compared as bytes, not with `===` on differing-length strings first (constant-time).
|
||||
|
||||
---
|
||||
|
||||
## 4. E1 — Key agreement (v0.10)
|
||||
|
||||
### T5 · x25519 ECDH · v0.10
|
||||
**Owns:** `relay-e2e/src/x25519.ts`, `relay-e2e/test/x25519.test.ts`
|
||||
**Depends:** T1.
|
||||
|
||||
```ts
|
||||
export interface EphemeralKeyPair { readonly publicKey: Uint8Array; readonly privateKey: CryptoKey } // priv = NON-EXTRACTABLE (INV5)
|
||||
export async function generateEphemeralKeyPair(): Promise<EphemeralKeyPair> // X25519, per handshake, fresh
|
||||
export async function deriveSharedSecret(privateKey: CryptoKey, peerPublicKey: Uint8Array): Promise<Uint8Array> // 32-byte raw
|
||||
```
|
||||
TDD:
|
||||
- classic ECDH agreement: two pairs derive the **same** shared secret from crossed pubkeys.
|
||||
- **Security:** the private key material is a **non-extractable `CryptoKey`** — assert `extractable === false`; a test proving `exportKey('raw', priv)` rejects (INV5: key never leaves as bytes).
|
||||
- **Negative:** all-zero / low-order peer public key → rejected (contributory behavior via WebCrypto; add explicit small-subgroup KAT from `@noble/curves` parity vectors).
|
||||
- WebCrypto-vs-noble parity: same private scalar + peer point → identical secret across both backends (guards a browser/node divergence).
|
||||
|
||||
### T6 · HKDF session-key derivation — DIRECTION-SPLIT subkeys · v0.10
|
||||
**Owns:** `relay-e2e/src/hkdf.ts`, `relay-e2e/test/hkdf.test.ts`, `relay-e2e/test/vectors/hkdf.json`
|
||||
**Depends:** T1, T5. Imports `AeadAlg` from `relay-contracts`.
|
||||
|
||||
**CRITICAL — one shared key across both directions is forbidden.** A single ECDH-derived key used for AEAD
|
||||
sealing in **both** client→host and host→client is the classic bidirectional-AEAD misuse: with a
|
||||
deterministic seq-derived nonce (T9), client-seq=N and host-seq=N produce an **identical (key, nonce)** pair
|
||||
⇒ GCM nonce reuse ⇒ two-time-pad key/plaintext recovery; and even with random nonces it lets a relay
|
||||
**reflect** a captured client→host frame back to the client as host→client at the same seq and have it
|
||||
decrypt. **Fix: derive two independent, direction-scoped subkeys** so client-write and host-read share one
|
||||
key and host-write and client-read share the *other*, never the same. A reflected frame is opened under the
|
||||
wrong subkey ⇒ `AeadOpenError`.
|
||||
|
||||
```ts
|
||||
// FROZEN in §4.4 (FIX 2) — imported from relay-contracts, NOT declared here:
|
||||
// export interface DirectionalKeys { readonly c2h: AeadKey; readonly h2c: AeadKey }
|
||||
// export const HKDF_INFO = 'relay-e2e/v1'; HKDF_INFO_C2H = 'relay-e2e/v1/c2h'; HKDF_INFO_H2C = 'relay-e2e/v1/h2c'
|
||||
import type { DirectionalKeys } from 'relay-contracts'
|
||||
import { HKDF_INFO, HKDF_INFO_C2H, HKDF_INFO_H2C } from 'relay-contracts'
|
||||
// c2h = client→host (client seals / host opens); h2c = host→client (host seals / client opens).
|
||||
// Master derivation is §4.4's frozen HKDF (salt = clientNonce‖hostNonce, info = HKDF_INFO), then a SECOND
|
||||
// HKDF-Expand splits the master into two disjoint direction subkeys under the frozen c2h/h2c labels. The INDEX
|
||||
// blessed these labels + the DirectionalKeys type (FIX 2), so this only IMPLEMENTS the frozen surface.
|
||||
export function deriveSessionKeys(sharedSecret: Uint8Array, clientNonce: Uint8Array, hostNonce: Uint8Array, alg: AeadAlg):
|
||||
Promise<DirectionalKeys>
|
||||
```
|
||||
TDD:
|
||||
- KAT vectors (`hkdf.json`) reproduce the expected 32-byte **master** and both 32-byte **direction subkeys** for fixed secret+nonces+info+labels.
|
||||
- deterministic: same inputs → same `DirectionalKeys`; changing **either** nonce → different keys (salt = both nonces, order fixed client‖host).
|
||||
- `info` is exactly `"relay-e2e/v1"`, labels exactly `"relay-e2e/v1/c2h"` / `"relay-e2e/v1/h2c"` (guards a silent version/label drift that would desync agent/browser).
|
||||
- **Security (direction disjointness):** `c2h !== h2c` bit-for-bit; each is 32 bytes; neither equals the master. A KAT asserts the exact label bytes so an implementer cannot collapse both directions to one key.
|
||||
- **Security:** distinct `alg` still derives 32-byte subkeys; each subkey feeds T2 as an opaque `AeadKey` (never surfaced as a plain `Uint8Array` beyond the wrap boundary — supports INV5).
|
||||
|
||||
### T7 · sequence guard (anti-replay/injection) · v0.10
|
||||
**Owns:** `relay-e2e/src/sequence.ts`, `relay-e2e/test/sequence.test.ts`
|
||||
**Depends:** T0.
|
||||
|
||||
```ts
|
||||
export class SequenceGuard {
|
||||
constructor(role: 'send' | 'recv')
|
||||
next(): bigint // send: returns current seq (starting 0n), then increments — strictly monotonic per direction
|
||||
accept(seq: bigint): void // recv: require seq === expected (last+1, from 0n); else throw ReplayError (out-of-order/dup/gap rejected, INV13)
|
||||
readonly lastAccepted: bigint | null
|
||||
}
|
||||
```
|
||||
**Design note:** the transport (§4.1 mux over an ordered WS/TCP stream) delivers per-stream in order, so
|
||||
INV13 is enforced as **strict successor** (`expected === last + 1`) — a duplicate, a reorder, a gap, or a
|
||||
rewind all raise `ReplayError`. There is deliberately **no sliding acceptance window** (a window would admit
|
||||
reordered frames the spec says to reject).
|
||||
TDD:
|
||||
- send side: `next()` yields `0n,1n,2n…` strictly increasing; two guards never share state.
|
||||
- recv side: in-order `0,1,2` accepted; **replay** of an accepted seq → `ReplayError`; **reorder** (`0` then `2`) → `ReplayError`; **rewind** (`5` then `4`) → `ReplayError`; **gap** (`0` then `2`) → `ReplayError`.
|
||||
- bigint boundary: accepts up to `2n**64n − 1n` without float error; wrap/overflow past u64 → typed error (forces re-key rather than nonce/seq reuse).
|
||||
|
||||
---
|
||||
|
||||
## 5. E2 — Handshake + session (v0.10)
|
||||
|
||||
### T8 · handshake state machines (client + host) · v0.10
|
||||
**Owns:** `relay-e2e/src/handshake.ts`, `relay-e2e/test/handshake.test.ts`
|
||||
**Depends:** T4, T5, T6, T0. Imports `ClientHello`, `HostHello`, `ClientHelloSchema`, `HostHelloSchema`,
|
||||
`HandshakeResult`, `ClientHandshake`, `DirectionalKeys`, `DeviceAuthProofProvider` from `relay-contracts` (§4.4).
|
||||
**Cross-plan:** consumes P2's `HostSigner` (Ed25519 over transcript) and P5's `DeviceAuthProofProvider`
|
||||
(§4.4 / FIX 6b — injected, P4 never imports crypto identity from `relay-auth`).
|
||||
|
||||
Implements the §4.4 message flow verbatim (`client_hello` → `host_hello`), producing the **frozen §4.4**
|
||||
`HandshakeResult`:
|
||||
```ts
|
||||
// FROZEN §4.4 (FIX 2), imported from relay-contracts — NOT redefined here:
|
||||
// export interface HandshakeResult { readonly keys: DirectionalKeys; readonly aead: AeadAlg; readonly transcript: Uint8Array }
|
||||
// export interface ClientHandshake { start(): Promise<ClientHello>; onHostHello(msg, agentPubkey): Promise<HandshakeResult> }
|
||||
// `keys` = c2h/h2c disjoint subkeys from T6 deriveSessionKeys (NOT one shared key); `transcript` binds the sig.
|
||||
export type HandshakePhase = 'init' | 'awaitHostHello' | 'awaitClientHello' | 'established' | 'aborted' // P4-internal state, not §4 wire
|
||||
export interface HostSigner { sign(transcript: Uint8Array): Promise<Uint8Array> } // provided by P2 (agent Ed25519 priv key)
|
||||
export interface HostVerifier { verify(pubkey: Uint8Array, transcript: Uint8Array, sig: Uint8Array): Promise<boolean> } // Ed25519
|
||||
|
||||
// CLIENT (browser, P6) — implements the FROZEN §4.4 `ClientHandshake` (start/onHostHello); `phase` is a
|
||||
// P4-internal state field layered on the frozen shape (not part of the §4 contract).
|
||||
export interface ClientHandshake {
|
||||
readonly phase: HandshakePhase
|
||||
// start() binds the device proof to THIS handshake: deviceAuthProof is produced over clientEphPub‖clientNonce
|
||||
// (or embeds a fresh P5 challenge carried in those fields) so a captured proof cannot be replayed into
|
||||
// another handshake with a different ephemeral key (fixes proof-replay by a malicious relay).
|
||||
start(): Promise<ClientHello>
|
||||
// agentPubkey is the RAW §4.2 Ed25519 host key, fetched by P6 from P3's host registry over an INDEPENDENT,
|
||||
// TLS-authenticated channel — it is NOT read out of the relay-carried HostHello (which the relay can forge).
|
||||
onHostHello(msg: HostHello, agentPubkey: Uint8Array): Promise<HandshakeResult>
|
||||
}
|
||||
export function createClientHandshake(deps: {
|
||||
aeadOffer: readonly AeadAlg[]
|
||||
deviceAuthProofProvider: DeviceAuthProofProvider // P5 — mints a per-handshake proof bound to the challenge (INV3)
|
||||
verifier: HostVerifier
|
||||
pinStore: DevicePinStore // T4 — TOFU/pin resolution over recomputed fingerprints
|
||||
hostId: string // names the registry entry whose agentPubkey/pin is resolved
|
||||
}): ClientHandshake
|
||||
|
||||
// HOST (agent, P2)
|
||||
export interface HostHandshake {
|
||||
readonly phase: HandshakePhase
|
||||
readonly result: HandshakeResult | null
|
||||
onClientHello(msg: ClientHello): Promise<HostHello> // verify proof binding, choose aead, sign transcript, derive keys
|
||||
}
|
||||
export function createHostHandshake(deps: {
|
||||
signer: HostSigner // agent Ed25519 priv key (P2) — NEVER in relay-e2e
|
||||
agentPubkey: Uint8Array // §4.2 agent_pubkey (for enrollFpr in host_hello)
|
||||
supported: readonly AeadAlg[]
|
||||
// verifyDeviceProof receives the proof AND the handshake binding material so it can assert the proof was
|
||||
// minted for THIS clientEphPub/clientNonce — a static bearer proof (unbound) MUST be rejected (INV1/INV3).
|
||||
verifyDeviceProof: (proof: string, binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array }) => Promise<boolean>
|
||||
}): HostHandshake
|
||||
```
|
||||
|
||||
**Host-key sourcing (anti-MITM, MUST be explicit — the pin string alone is NOT the defense).** `enrollFpr`
|
||||
is a hash of a **public** key that a malicious relay also knows, so string-equality on it proves nothing.
|
||||
`onHostHello` MUST: (a) receive the raw `agentPubkey` from the independent P3/TLS channel (never from the
|
||||
relay-carried `HostHello`); (b) recompute `computeEnrollFpr(agentPubkey)` locally and assert it equals
|
||||
**both** `msg.enrollFpr` **and** the `resolvePin` outcome for `hostId` — any disagreement ⇒
|
||||
`FingerprintMismatchError`, never a string-only fallback; (c) only then call
|
||||
`verifier.verify(agentPubkey, transcript, msg.sig)`. The pubkey passed to `verify()` is always the
|
||||
registry pubkey, never a value the relay can influence.
|
||||
|
||||
**Device-proof binding (anti-replay, MUST be explicit).** `ClientHello` carries **no signature** and is
|
||||
forwarded by the relay in cleartext-to-the-relay (pre-key) as an opaque `DATA` payload, so a raw bearer
|
||||
`deviceAuthProof` could be captured and replayed by the relay into its own freshly-keyed `client_hello`,
|
||||
impersonating the device with no crypto broken. Therefore `deviceAuthProof` MUST be a **per-handshake,
|
||||
non-replayable** value bound to `clientEphPub`/`clientNonce` (or a fresh P5-issued challenge carried in those
|
||||
fields) — never a static token. This is a **hard cross-plan requirement on P5** (§9 Q3), and T8 owns the
|
||||
gating call site, so the binding is asserted here even though issuance lives in P5.
|
||||
|
||||
TDD (tests first, with a stub `HostSigner`/`HostVerifier` using `@noble/curves` ed25519):
|
||||
- **Happy path:** client.start → host.onClientHello → client.onHostHello ⇒ both sides derive the **same** `DirectionalKeys` (`c2h`/`h2c`) and identical negotiated `aead`; relay (a passthrough spy in the test) **never** sees key material — assert the spy's captured bytes decode only to `ClientHello`/`HostHello` structs, no secret.
|
||||
- **AEAD negotiation:** host picks the strongest common alg from `aeadOffer`; empty intersection → `HandshakeStateError` (no silent fallback to an unoffered alg).
|
||||
- **MITM abort (headline):** a malicious relay swaps `hostEphPub`/`sig` → `sig` fails against the **registry** `agentPubkey` → `onHostHello` throws `FingerprintMismatchError`, `phase → 'aborted'`, **no key derived**.
|
||||
- **Pubkey/fpr inconsistency (finding #3 regression guard):** `msg.enrollFpr` matches the pinned string, but the raw `agentPubkey` supplied for verification hashes to a *different* fpr → abort with `FingerprintMismatchError`, **never** a string-only match; proves `verify()` is called with the registry pubkey, not a relay-supplied one.
|
||||
- **TOFU first use:** empty pin store → pin recorded (from the recomputed fpr) after a valid `host_hello`; a subsequent handshake whose registry pubkey hashes to a *different* fpr → abort (no auto-repin).
|
||||
- **deviceAuthProof binding (finding #2 regression guard):** a valid `deviceAuthProof` captured from handshake **A** (its own `clientEphPub`/`clientNonce`), replayed verbatim in handshake **B** with a *different* `clientEphPub`, is **rejected** by `verifyDeviceProof`/host → `HandshakeStateError`, no session. A static/unbound bearer proof is likewise rejected.
|
||||
- **deviceAuthProof gate (INV3):** host rejects a `client_hello` whose `deviceAuthProof` fails `verifyDeviceProof` → `HandshakeStateError`; forged/absent proof never yields a session (deny-by-default).
|
||||
- **State machine:** any out-of-order call (`onHostHello` before `start`, double `start`, `onClientHello` twice) → `HandshakeStateError`; `phase` transitions are monotone and terminal at `established`/`aborted`.
|
||||
- **Malformed input:** `onHostHello`/`onClientHello` run `HostHelloSchema`/`ClientHelloSchema.parse` first; bad shape → typed error, no crypto attempted (fail-fast at boundary).
|
||||
|
||||
### T9 · session AEAD + sealFrame/openFrame + lifecycle · v0.10
|
||||
**Owns:** `relay-e2e/src/session.ts`, `relay-e2e/test/session.test.ts`
|
||||
**Depends:** T2, T3, T6, T7, T8. Imports `E2EEnvelope`, `AeadKey`, `SessionRole`, `DirectionalKeys`,
|
||||
`HandshakeResult`, `E2ESession`, `createE2ESession`, `sealFrame`, `openFrame` from `relay-contracts` (§4.4).
|
||||
**Contract sync RESOLVED (FIX 2):** the INDEX §4.4 amendment has landed — the frozen signatures are now
|
||||
`sealFrame(key: AeadKey, seq, plaintext): E2EEnvelope` / `openFrame(key: AeadKey, env, expectedSeq): Uint8Array`,
|
||||
**synchronous**, with `aad = directionLabel‖seq`. T9 **implements the frozen §4.4 surface verbatim** (via T2
|
||||
noble AEAD) and **redefines none of it locally**; the earlier "pending amendment / §9 Q4" block is closed.
|
||||
|
||||
```ts
|
||||
// §4.4 FROZEN (FIX 2) — imported from relay-contracts; T9 provides the SYNCHRONOUS implementation via T2 noble AEAD.
|
||||
// Per-direction: the caller passes the correct DIRECTION subkey (c2h or h2c) — sealFrame/openFrame are
|
||||
// single-key primitives; direction selection is the E2ESession's job (below).
|
||||
export function sealFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope
|
||||
// nonce = deterministic f(seq) (see Nonce discipline); aad = directionLabel‖seq (u64 BE)
|
||||
export function openFrame(key: AeadKey, env: E2EEnvelope, expectedSeq: bigint): Uint8Array
|
||||
// require env.seq === expectedSeq (INV13); recompute nonce = f(seq); AEAD verify with aad; failure → AeadOpenError
|
||||
|
||||
// Stateful directional wrapper (§4.4 FROZEN — SessionRole/E2ESession/createE2ESession imported, not redefined).
|
||||
// It holds BOTH direction subkeys and a role, and MUST seal with its write subkey and open with its read subkey.
|
||||
// export type SessionRole = 'client' | 'host'
|
||||
// export interface E2ESession { role; seal(pt): Uint8Array; open(dataPayload): Uint8Array; rederive(next: HandshakeResult): void }
|
||||
// export function createE2ESession(role: SessionRole, result: HandshakeResult): E2ESession
|
||||
// seal: write subkey (client→c2h / host→h2c) → next send seq (T7) → sealFrame → encodeEnvelope
|
||||
// open: read subkey (client→h2c / host→c2h) → decodeEnvelope → SequenceGuard.accept → openFrame
|
||||
// rederive: re-key on refresh/reconnect; installs new DirectionalKeys; resets both seq guards
|
||||
```
|
||||
**Nonce discipline (INV13) — ONE pinned scheme, no random branch.** The nonce is **fully deterministic**:
|
||||
`nonce = seq encoded big-endian, left-zero-padded to nonceLength(alg)` (equivalently `HKDF-Expand(nonceKey, seq)`).
|
||||
The random-nonce option is **removed** — for a long-lived high-frame-count terminal stream, 96-bit random
|
||||
GCM nonces carry a non-trivial birthday-collision probability (NIST SP 800-38D bounds random-nonce
|
||||
invocations per key well below 2^32), and a GCM nonce collision is catastrophic. Determinism is safe **only
|
||||
because finding #1's direction split guarantees `seq` is unique per (direction subkey)**: client-write and
|
||||
host-write use disjoint keys, so seq=N in each direction never collides on (key, nonce). `aad = directionLabel || seq`
|
||||
binds both the direction and the sequence into the tag, so a frame can't be replayed under a different seq
|
||||
**or reflected across directions**. A KAT asserts the nonce is bit-for-bit deterministic and never regenerated randomly.
|
||||
TDD:
|
||||
- round-trip: `open(seal(x)) === x` for both algs, empty/large payloads. (`seal` twice on the same plaintext at the same seq is impossible in-session because seq is monotonic; a KAT fixes seq and asserts the deterministic nonce.)
|
||||
- **Direction split (finding #1 regression guards):**
|
||||
- a frame sealed by the **client** `E2ESession` **cannot be opened by that same client's `open`** (read subkey ≠ write subkey) → `AeadOpenError`.
|
||||
- a relay **reflecting** a client→host ciphertext back to the client as a host→client frame at the same seq → the client's `open` uses `h2c`, the frame was sealed under `c2h` → `AeadOpenError` (reflection rejected).
|
||||
- **Deterministic nonce KAT:** fixed key+seq → exact nonce bytes; assert `sealFrame` never calls `randomBytes` (spy/stub asserts the CSPRNG is untouched on the seal path).
|
||||
- **INV13 injection/replay:** capture a `seal` output, feed it to `open` twice → 2nd is `ReplayError`; reorder two frames → `ReplayError`; flip a ciphertext bit → `AeadOpenError`; re-encode with a bumped `seq` → `AeadOpenError` (aad mismatch).
|
||||
- **Lifecycle:** `rederive` with a fresh `HandshakeResult` resets both seq guards to 0 and decrypts frames sealed under the new `DirectionalKeys`; frames sealed under the **old** keys fail to open post-rederive (forward isolation).
|
||||
- **Cross-instance:** a browser (`client`) `E2ESession` opens what an agent (`host`) `E2ESession` sealed and vice-versa (isomorphic parity, correct direction subkeys), driven off shared vectors.
|
||||
|
||||
---
|
||||
|
||||
## 6. E3 — Multi-device, replay-key, integration (v0.10)
|
||||
|
||||
### T10 · recoverable replay content-key (ring-buffer ciphertext across reconnect) · v0.10
|
||||
**Owns:** `relay-e2e/src/replay-key.ts`, `relay-e2e/test/replay-key.test.ts`
|
||||
**Depends:** T2, T3, T6. **The two-key replay model is now a FROZEN §4.4/§4.5 surface (FIX 3) — T10 implements it; the former §9 open question is resolved (see §9).**
|
||||
|
||||
**Problem:** §4.4 derives `sessionKey` from **ephemeral** X25519 (forward secrecy) → a *fresh* key on every
|
||||
reconnect. But EXPLORE §4c requires **ring-buffer replay of ciphertext to survive a refresh** — the
|
||||
customer's unchanged base-app ring buffer stores whatever bytes the agent forwards, and a reconnecting
|
||||
browser must decrypt them. Ciphertext sealed under an ephemeral key that no longer exists is undecryptable.
|
||||
|
||||
**Resolution (two-key model, now a FROZEN §4.4 surface — FIX 3 — that P4 implements, not invents):**
|
||||
- **`K_content`** — a **recoverable** per-session content key = `deriveContentKey(ReplayKeyParams)` =
|
||||
`HKDF(hostContentSecret, salt = sessionId, info = REPLAY_KDF_INFO)` where `REPLAY_KDF_INFO = 'relay-e2e/replay/v1'`
|
||||
(both frozen §4.4). **CRITICAL — the input is a HOST-SCOPED secret, not a raw account secret.** The host agent is
|
||||
unattended and never performs a WebAuthn ceremony, so if it held a raw `accountRecoverableSecret` that would be a
|
||||
long-lived, non-rotatable, **account-wide** secret in agent memory/disk — a strictly larger blast radius than the
|
||||
per-host Ed25519 identity (INV4) and a regression from the non-extractable-key pattern (T5). Instead, **§4.5 (FIX 3)
|
||||
now assigns a single owner: P3 mints + wraps `hostContentSecret` at BIND, sealed to the host's enrolled Ed25519
|
||||
`agent_pubkey`** (host-scoped, per-host; delivered in §4.5 `EnrollResult.hostContentSecret`). **P2 unwraps it
|
||||
locally** with its enrollment private key (never leaves the host, INV4). The control plane can invalidate **that
|
||||
specific wrap** on host/device revocation (INV12) without a full account-secret rotation. **Authorized browser
|
||||
devices obtain the same `hostContentSecret` via P5** (unwrapped only after auth/step-up); each re-derives the
|
||||
identical `K_content` → decrypts replayed ciphertext after a refresh. Relay still sees only ciphertext (INV2).
|
||||
- **`sessionKey` direction subkeys** (§4.4 ephemeral `DirectionalKeys`) — protect **live** frames (forward secrecy)
|
||||
and are the authenticated channel over which nothing secret needs to travel (see T11).
|
||||
|
||||
```ts
|
||||
// FROZEN §4.4 (FIX 3) — imported from relay-contracts; T10 provides the implementation. ReplayKeyParams
|
||||
// bundles the KDF inputs so the shape is fixed once for P2 (seal) and P6 (open):
|
||||
// export interface ReplayKeyParams { readonly hostContentSecret: Uint8Array; readonly sessionId: string; readonly alg: AeadAlg }
|
||||
// export const REPLAY_KDF_INFO = 'relay-e2e/replay/v1'
|
||||
// hostContentSecret is host-scoped (delivered at §4.5 enrollment wrapped to agent_pubkey by P3; NOT a raw
|
||||
// account secret). Revoking the host/device invalidates the wrap so it can no longer be unwrapped going forward (INV12).
|
||||
export function deriveContentKey(p: ReplayKeyParams): AeadKey // HKDF(hostContentSecret, salt=sessionId, info=REPLAY_KDF_INFO)
|
||||
export function sealReplayFrame(k: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope // reuses T9 sealFrame under K_content — CALLED BY P2 (agent)
|
||||
export function openReplayCiphertext(k: AeadKey, dataPayload: Uint8Array): Uint8Array // client-side replay/preview decrypt — CALLED BY P6 (browser)
|
||||
```
|
||||
|
||||
**Cross-plan requirement — WHICH key seals WHICH frame (frozen routing; P2/P6 MUST honor it):**
|
||||
- **Live host→client output** (interactive stream, forward-secret) → sealed under the **ephemeral `h2c`** subkey
|
||||
via `E2ESession.seal` (T9). Live client→host input → **`c2h`**. These die on reconnect (re-derived, INV forward secrecy).
|
||||
- **Every replay-bound host→client output** — i.e. any byte the agent forwards that the base-app ring buffer will
|
||||
store for later scrollback replay / preview — is **additionally sealed under `K_content`** via **`sealReplayFrame`
|
||||
(owned/called by P2's agent-side E2E-endpoint task)**, so it survives a browser reload after the ephemeral subkeys
|
||||
are gone. The reconnecting or second authorized device re-derives `K_content` (`deriveContentKey`) and decrypts the
|
||||
stored ciphertext with **`openReplayCiphertext` (owned/called by P6's client-side preview/replay task)**.
|
||||
- **Client→host input is NEVER sealed under `K_content`** (it is not replay-bound; only host→client output is
|
||||
buffered for scrollback). This keeps `K_content` scoped to exactly the frames that must survive a reload.
|
||||
TDD:
|
||||
- **The load-bearing test:** device X seals output under `K_content`; the base-app ring buffer (a stub `Uint8Array[]`) stores the ciphertext; device X "reloads" (new object, no in-memory key), re-derives `K_content` from the same `hostContentSecret` + `sessionId`, and **decrypts the replayed ciphertext** → "refresh and the session is still there" works under E2E.
|
||||
- device Y (same account, authorized, holding the same `hostContentSecret`) re-derives the identical `K_content` → decrypts (basis for multi-device mirror).
|
||||
- **Revocation (INV12) — merge-blocking for T10/T11:** after a host/device is revoked, the wrap no longer unwraps → a revoked principal cannot obtain `hostContentSecret` → cannot derive `K_content` for that host's sessions going forward. Test: given a "revoked" wrap stub, `deriveContentKey`'s input is unavailable → no key derivable (P4 asserts the derivation is gated on the unwrap; the unwrap/revocation mechanism itself is P5/P2, a hard cross-plan dependency).
|
||||
- **Security:** a device with a *different* / host-mismatched `hostContentSecret` derives a different key → cannot open (cross-host + cross-account replay isolation, reinforces INV1); relay memory holding the ciphertext never yields plaintext (INV2); `K_content` is per-`sessionId` (no cross-session key reuse) and per-host (no cross-host reuse).
|
||||
|
||||
### T11 · multi-device key adapters (authenticated channel, NOT a QR) · v0.10
|
||||
**Owns:** `relay-e2e/src/keystore.ts`, `relay-e2e/test/keystore.test.ts`
|
||||
**Depends:** T4, T8, T10. **Cross-plan:** `DeviceAuthProofProvider` is implemented by P5; `DevicePinStore` by P6.
|
||||
|
||||
**Model (EXPLORE §4c / INV3):** multi-device does **not** ship a key over a scannable QR. **Each device runs
|
||||
its own §4.4 handshake**, gated by an **account-derived `deviceAuthProof`**, and independently re-derives the
|
||||
recoverable `K_content` (T10). No plaintext key material ever transits a shoulder-surfable or relay-readable
|
||||
channel. This file provides the injectable adapters and the assembly:
|
||||
```ts
|
||||
// FROZEN §4.4 (FIX 2/6b) — imported from relay-contracts; T11 supplies the injectable adapters + assembly, NOT the shapes:
|
||||
// export interface DeviceAuthProofProvider {
|
||||
// proofFor(hostId: string, binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array }): Promise<string>
|
||||
// }
|
||||
// export interface AuthorizedDeviceContext {
|
||||
// readonly deviceAuthProofProvider: DeviceAuthProofProvider // P5 — mints per-handshake proof bound to clientEphPub‖clientNonce
|
||||
// readonly pinStore: DevicePinStore // P6 — TOFU/pin over recomputed fingerprints
|
||||
// readonly hostContentSecret: Uint8Array // §4.5 host-scoped secret; NEVER logged, NEVER sent to relay
|
||||
// }
|
||||
// export function buildClientHandshake(ctx: AuthorizedDeviceContext, hostId: string, aeadOffer: readonly AeadAlg[]): ClientHandshake
|
||||
import type { AuthorizedDeviceContext, DeviceAuthProofProvider } from 'relay-contracts'
|
||||
import { buildClientHandshake } from 'relay-contracts' // re-exported from relay-e2e/ with the impl (INDEX §2.1)
|
||||
// proofFor mints a PER-HANDSHAKE, non-replayable proof bound to the fresh clientEphPub/clientNonce (FIX 6b): the
|
||||
// binding param `{ clientEphPub, clientNonce }` is unified across P2/P4/P5; the same hostId twice yields distinct,
|
||||
// binding-scoped proofs — NOT a reusable bearer token.
|
||||
```
|
||||
TDD:
|
||||
- two independent `AuthorizedDeviceContext`s (same account, same `hostContentSecret`) each complete a handshake with the host stub and each derive matching `K_content` → both mirror the same session.
|
||||
- **Security (INV3, per-handshake proof):** a context lacking a valid `deviceAuthProof` never yields a session (host rejects — T8 gate); a proof minted for handshake A cannot be reused for handshake B (binding-scoped — T8 replay guard). There is **no code path** that emits `hostContentSecret` or a derived key into any frame the relay forwards (static assertion + a spy test on all outbound bytes).
|
||||
- **Revocation (INV12):** a context whose `hostContentSecret` unwrap has been revoked cannot derive `K_content` (mirrors T10's revocation gate).
|
||||
- **No-QR assertion:** a documented negative test that the public surface exposes **no** function serializing a session/content key to a transferable string/QR blob (guards regression toward a plaintext-QR shortcut).
|
||||
|
||||
### T12 · barrel, build, cross-package integration + INV2 tripwire · v0.10
|
||||
**Owns:** `relay-e2e/src/index.ts`, `relay-e2e/test/integration.test.ts`, `relay-e2e/test/vectors/*` (freeze), build config in `package.json`
|
||||
**Depends:** T8, T9, T10, T11. **Cross-plan:** uses a **P1 loopback mux stub** as the relay.
|
||||
|
||||
Publishes the public surface (barrel) and proves the whole path end-to-end through a *simulated relay*:
|
||||
```
|
||||
client E2ESession ──DATA payload (ciphertext)──► [P1 mux stub = relay spy] ──► host E2ESession
|
||||
```
|
||||
TDD / integration:
|
||||
- **Full loop:** client handshake ↔ host handshake through the relay spy → `E2ESession` on each side → seal "echo test", forward as a §4.1 `DATA` payload (assert `payloadLen ≤ maxFrameBytes`), host opens → plaintext round-trips; reverse direction too.
|
||||
- **INV2 tripwire (permanent):** inject a unique plaintext marker (`E2E_PLAINTEXT_CANARY_<uuid>`) as the sealed input; assert the marker appears **nowhere** in the relay spy's captured buffers, in any thrown error message, or in a serialized snapshot of the spy — only ciphertext transits. This is P4's owned invariant test (INV2) and a merge-blocking tripwire.
|
||||
- **INV11-adjacent static check:** a test asserting `relay-e2e/src/**` imports **no** xterm/ANSI/terminal parser and **no** `ws`/`pg`/DOM runtime (dependency-cruiser or a grep-based test) — the package stays a pure crypto core.
|
||||
- **Isomorphic build:** `esbuild` produces a browser ESM bundle and a Node ESM bundle; a smoke test loads each and runs one seal/open (guards a WebCrypto/`@noble` divergence between environments).
|
||||
- **Vector freeze:** all `test/vectors/*.json` are asserted stable (a checksum test) so a future change that would desync the agent and browser encodings fails loudly.
|
||||
|
||||
---
|
||||
|
||||
## 7. Security section (per-plan)
|
||||
|
||||
P4 is the plan that *earns* the ciphertext-shuttle claim. Enforcement summary and the threats each control stops:
|
||||
|
||||
- **INV2 — relay sees only ciphertext.** The relay forwards §4.1 `DATA` payloads that are §4.4 `E2EEnvelope`
|
||||
bytes; the key is derived **only** on the two endpoints (T8 handshake). *Stops:* a compromised/curious relay
|
||||
reading keystrokes (`sudo`/`.env`/**Claude tokens**) — EXPLORE §4c. Proven by the T12 canary tripwire.
|
||||
- **Direction-scoped AEAD keys (bidirectional-misuse defense).** The single ECDH secret is split into two
|
||||
disjoint subkeys `c2h`/`h2c` (T6); each direction seals with its write subkey and opens with the other's
|
||||
(T9). *Stops:* (a) GCM nonce reuse across directions under a deterministic seq-nonce (client-seq=N and
|
||||
host-seq=N never share a key); (b) a relay **reflecting** a client→host ciphertext back to the client — it
|
||||
opens under `h2c`, was sealed under `c2h` → `AeadOpenError`. `aad = directionLabel || seq` binds both.
|
||||
- **INV13 — anti-replay/injection.** Deterministic per-`seq` nonce (T9, no random branch) + strictly
|
||||
monotonic `seq` (T7) + `aad = directionLabel || seq`. *Stops:* a relay replaying a captured
|
||||
`approve`/keystroke frame, reordering frames, or splicing frames from another stream/direction; any tamper
|
||||
flips the AEAD tag (`AeadOpenError`) and tears the session down.
|
||||
- **MITM by malicious relay.** `HostHello.sig` = Ed25519 over the transcript, verified against the raw
|
||||
`agentPubkey` **fetched from P3's host registry over an independent TLS channel** (never read from the
|
||||
relay-carried `HostHello`); `onHostHello` recomputes `computeEnrollFpr(agentPubkey)` and asserts it equals
|
||||
**both** `msg.enrollFpr` and the browser-**pinned** value before verifying (T4/T8). *Stops:* the relay
|
||||
substituting its own ephemeral key **or** feeding a forged pubkey to `verify()` — a string-only fpr match is
|
||||
never the defense; mismatch → `FingerprintMismatchError`, no key derived (§4d).
|
||||
- **INV3 — device bound to account, proof non-replayable.** The handshake carries a **per-handshake**
|
||||
`deviceAuthProof` bound to `clientEphPub`/`clientNonce` (T8/T11); the host denies by default without it.
|
||||
*Stops:* an authenticated-but-unauthorized device, a relay-forged identity, **and a relay replaying a
|
||||
captured proof into its own freshly-keyed `client_hello`** (bound proof fails against the new ephemeral key).
|
||||
The content key (T10) is **host-secret-bound**, so a foreign account/host can't decrypt replay ciphertext
|
||||
(reinforces **INV1** — cross-tenant buffer bleed becomes *cryptographically* impossible, EXPLORE §4b).
|
||||
- **INV5 / INV9 — no secret at rest / in logs.** Ephemeral X25519 privkeys are **non-extractable `CryptoKey`s**
|
||||
(T5); session/content keys live only as opaque `AeadKey` wrappers; error messages are asserted to carry no
|
||||
key/nonce/plaintext (T0); the ring buffer stores only ciphertext (T10). The **host-scoped** `hostContentSecret`
|
||||
(not a raw account secret) is delivered wrapped to the agent's Ed25519 identity, is revocable per-host
|
||||
(INV12), and is never sent to the relay and never logged (T10/T11).
|
||||
- **Forward secrecy + recoverable replay, reconciled.** Live frames use the ephemeral `sessionKey` (forward
|
||||
secret); replay-bound frames use the recoverable `K_content` (T10). This is the deliberate trade the product
|
||||
makes to keep "refresh and the session survives" under E2E — surfaced as an **Open Question** (§8) because it
|
||||
is the one point where §4.4 is silent.
|
||||
- **Downgrade resistance.** AEAD negotiation picks the strongest common alg from the client offer; no fallback
|
||||
to an unoffered/weaker alg (T8). Envelope `envVersion`/`HKDF_INFO` are pinned so a version-strip can't silently
|
||||
weaken derivation.
|
||||
- **What E2E kills (documented, handed to P6):** server-side preview thumbnails, manage-grid, server search,
|
||||
and server recording **cannot exist** — the relay can't render/scan ciphertext. P4 ships `openReplayCiphertext`
|
||||
(T10) so P6 renders previews **client-side** in the key-holding browser. Ring-buffer replay + multi-device
|
||||
mirror survive (relay stores/forwards ciphertext). This is the accepted §0 consequence, not a regression.
|
||||
|
||||
---
|
||||
|
||||
## 8. Verification
|
||||
|
||||
Run from repo root (workspace package `relay-e2e`):
|
||||
```bash
|
||||
npm -w relay-e2e run build # esbuild → browser ESM + node ESM bundles; tsc --noEmit strict
|
||||
npm -w relay-e2e test # vitest: all T0–T12 suites, jsdom + node environments
|
||||
npm -w relay-e2e run test:coverage # enforce ≥80% (testing.md); crypto core targets ≥95% on aead/envelope/sequence/session
|
||||
npm -w relay-e2e run test:vectors # KAT freeze check (aead/hkdf/envelope/fingerprint) — agent↔browser parity
|
||||
npm -w relay-e2e run lint:isolation # dependency-cruiser: no xterm/ANSI/ws/pg/DOM-runtime imports (INV2/INV11)
|
||||
```
|
||||
Acceptance gates (all must pass before P4 merges / before v0.10 ships):
|
||||
1. **INV2 tripwire (T12):** plaintext canary never appears in the relay spy's buffers/logs/snapshot. **Merge-blocking.**
|
||||
2. **INV13 (T7/T9):** replay, reorder, tamper, and seq-slot attacks all rejected with typed errors; nonce is deterministic per `seq` (no random-nonce path).
|
||||
3. **Direction split (T6/T9):** `c2h ≠ h2c`; a client-sealed frame cannot be opened by the client's own `open`; a reflected client→host frame replayed as host→client is rejected (`AeadOpenError`). **Merge-blocking.**
|
||||
4. **MITM abort (T8):** raw `agentPubkey` is sourced from the independent P3/TLS channel; a pubkey/fpr inconsistency (pinned string matches but pubkey hashes differently) → `FingerprintMismatchError`, no key derived, no string-only fallback; TOFU never auto-repins.
|
||||
5. **INV3 proof binding (T8/T11):** absent/forged `deviceAuthProof` → no session; a proof captured from handshake A replayed in handshake B (different `clientEphPub`) → rejected; no code path serializes a key to a QR/string.
|
||||
6. **INV5 (T5/T0):** ephemeral privkeys non-extractable (`exportKey` rejects); no secret in any thrown message.
|
||||
7. **Replay survives refresh (T10):** ciphertext stored under `K_content` decrypts after a simulated reload and on a second authorized device; a foreign account/host cannot decrypt.
|
||||
8. **Content-key revocation (T10/T11, INV12):** a revoked host/device cannot obtain `hostContentSecret` → cannot derive `K_content` for that host's sessions going forward. **Merge-blocking before T10/T11.**
|
||||
9. **Isomorphic parity (T9/T12):** the same vectors seal/open identically under WebCrypto (browser) and Node; browser bundle and node bundle both pass the smoke test.
|
||||
10. **Coverage ≥80%** overall; crypto-critical files ≥95%; **zero `console.*`** in `src/` (`coding-style.md`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions (surface to orchestrator — do NOT guess, per CLAUDE.md / PLAN §0)
|
||||
|
||||
**All four blocking questions are now RESOLVED by the INDEX reconciliation pass (FIX 2/3/6b) — the contracts
|
||||
were frozen at the coordination point, so P4 cites them verbatim and none is a subagent blocker any longer:**
|
||||
|
||||
- ~~**(Q1) Ephemeral forward-secrecy vs recoverable-replay-key (the one §4.4 gap).**~~ **RESOLVED (FIX 3).**
|
||||
The two-key model is now **frozen in §4.4/§4.5**: `ReplayKeyParams`, `REPLAY_KDF_INFO = 'relay-e2e/replay/v1'`,
|
||||
`deriveContentKey(p): AeadKey`, `sealReplayFrame`, `openReplayCiphertext` (§4.4) + `EnrollResult.hostContentSecret`
|
||||
(§4.5). Live frames use the ephemeral `DirectionalKeys`; replay-bound host→client output uses the recoverable
|
||||
`K_content`. T10 implements the frozen surface; no P4-local shape.
|
||||
- ~~**(Q2) Source of `hostContentSecret` — host-scoped, not a raw account secret.**~~ **RESOLVED (FIX 3).**
|
||||
§4.5 assigns a **single owner: P3 mints + wraps `hostContentSecret` at BIND, sealed to the host's enrolled
|
||||
Ed25519 `agent_pubkey`** (host-scoped, per-host, per-host-revocable — INV12), returned in `EnrollResult`.
|
||||
**P2 unwraps locally** (private key never leaves the host, INV4); **authorized browser devices obtain it via
|
||||
P5** after auth/step-up. P4 codes against the injected `hostContentSecret: Uint8Array` and assumes no mechanism.
|
||||
The host-scoping + per-host revocability precondition for §8 gate 8 is now contractually guaranteed.
|
||||
- ~~**(Q3) `deviceAuthProof` must be per-handshake and non-replayable.**~~ **RESOLVED (FIX 6b).** §4.4 +
|
||||
INDEX §6b freeze the binding to **`{clientEphPub, clientNonce}`** and assign **sole issuance/verification to
|
||||
P5** (`relay-auth/src/capability/device-proof.ts`). P4 consumes it as an **injected dependency** — the §4.4
|
||||
`DeviceAuthProofProvider.proofFor(hostId, { clientEphPub, clientNonce })` (client) and host-side
|
||||
`verifyDeviceProof(proof, { clientEphPub, clientNonce })` — and never imports crypto identity from `relay-auth`.
|
||||
T8 owns the gating call site + the replay-rejection test (§8 gate 5); the binding param is unified across P2/P4/P5.
|
||||
- ~~**(Q4) Coordination-point amendments to INDEX §4.4 (CryptoKey→AeadKey, async→sync, `aad=directionLabel‖seq`,
|
||||
direction-subkey labels).**~~ **RESOLVED (FIX 2).** All landed in the frozen §4.4: `AeadKey` opaque wrapper,
|
||||
**synchronous** `sealFrame`/`openFrame`, `aad = directionLabel‖seq`, `DirectionalKeys{c2h,h2c}` + the
|
||||
`HKDF_INFO`/`HKDF_INFO_C2H`/`HKDF_INFO_H2C` labels. T2's `AeadKey` and T9's `sealFrame`/`openFrame` are
|
||||
**unblocked and cite §4.4 verbatim** (they implement, they do not redefine — INDEX §2.1).
|
||||
|
||||
**Residual (non-blocking) confirmations:** adding `@noble/ciphers`/`@noble/curves`/`@noble/hashes` to
|
||||
`relay-e2e` deps is a battle-tested library choice (`development-workflow.md` "prefer libraries") and needs no
|
||||
contract change; the crypto *implementations* of the §4.4 functions live here and are re-exported (INDEX §2.1).
|
||||
696
docs/PLAN_RELAY_FRONTEND.md
Normal file
696
docs/PLAN_RELAY_FRONTEND.md
Normal file
@@ -0,0 +1,696 @@
|
||||
# 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.
|
||||
606
docs/PLAN_RELAY_INDEX.md
Normal file
606
docs/PLAN_RELAY_INDEX.md
Normal 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.
|
||||
97
docs/PLAN_RELAY_RECONCILE.md
Normal file
97
docs/PLAN_RELAY_RECONCILE.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Relay Plans — Cross-Plan Reconciliation TODO
|
||||
|
||||
The 6 relay plans + `PLAN_RELAY_INDEX.md` were generated and hardened in parallel
|
||||
(32-agent workflow: draft → security+architecture review → fix → re-review → global
|
||||
consistency). Each plan passed its own review (4–8 blocking findings fixed each). The
|
||||
**global consistency review then found 11 CRITICAL/HIGH cross-plan integration issues** —
|
||||
all one root cause: **plans drifted their SHARED contracts locally during hardening
|
||||
instead of routing changes through the frozen INDEX §4** (violating INDEX §2 rule 1).
|
||||
|
||||
These must be reconciled BEFORE any plan's implementation is dispatched, or the plans
|
||||
will not compose (they'd type-mismatch / fail at integration). The fix is a
|
||||
**reconciliation pass**: freeze the drifted/missing contracts at the INDEX coordination
|
||||
point, then sync the consumer plans. Below, the 11 findings collapse into 6 concrete fixes.
|
||||
|
||||
---
|
||||
|
||||
## FIX 1 — `relay-contracts/` (W0) has no owning task *(GLOBAL CRITICAL)*
|
||||
Every plan imports `relay-contracts/` read-only and INDEX says it's "frozen before any
|
||||
plan's W1", but **no plan contains a task to AUTHOR it** (Zod schemas + TS types + the
|
||||
mux binary/CBOR codec). It's also where several still-unfrozen shapes must land.
|
||||
- **Do:** add an INDEX-owned **W0 task** that builds `relay-contracts/`: §4 Zod schemas +
|
||||
inferred types, `encodeMuxFrame`/`decodeMuxFrame` (+ CBOR), `GoAwayReason`/`decodeGoAwayReason`,
|
||||
the amended §4.4 shapes (Fix 2), account/session/pairing TS types, INV8 version-table DDL.
|
||||
Freeze before dispatching any W1.
|
||||
|
||||
## FIX 2 — §4.4 E2E contract drift (single `sessionKey: CryptoKey` → evolved shape) *(E2E HIGH, GLOBAL HIGH, FRONTEND HIGH)*
|
||||
P4 (E2E) evolved to: `AeadKey` (not `CryptoKey`), **synchronous** `sealFrame`/`openFrame`,
|
||||
`aad = directionLabel‖seq`, **direction-split `DirectionalKeys` (`c2h`/`h2c`)** wrapped in a
|
||||
stateful `E2ESession` (`.seal()`/`.open()`/`.rederive()`), handshake via
|
||||
`buildClientHandshake(ctx, hostId, aeadOffer)`. But INDEX §4.4, **P2 T15 `makeHostHello`
|
||||
(returns `sessionKey: CryptoKey`)**, and **P6 T8 (raw SubtleCrypto AES-GCM, single key)**
|
||||
still use the ORIGINAL shape → they can't type-check against what P4 exports.
|
||||
- **Do:** land P4 §9 Q4's amendments into **INDEX §4.4 / relay-contracts** first
|
||||
(`AeadKey`, sync sigs, `DirectionalKeys` c2h/h2c, `aad=directionLabel‖seq`, `ReplayKeyParams`).
|
||||
Then update **P2 T15** → `HandshakeResult` carrying `DirectionalKeys`; **P6 T8/T9** →
|
||||
consume `E2ESession`/`buildClientHandshake`/`AeadKey`. Closes P6 §8 Q4 by construction.
|
||||
|
||||
## FIX 3 — recoverable content-key (`K_content`/`hostContentSecret`) has no wiring or delivery *(E2E HIGH, GLOBAL HIGH)*
|
||||
The LOCKED "ciphertext ring-buffer survives reconnect" decision (EXPLORE §0 dec.2 / §4c)
|
||||
needs a **recoverable** key: live frames use the ephemeral `h2c` key, but replay/preview
|
||||
frames must use `K_content` derived from a host-scoped `hostContentSecret`. Problems:
|
||||
(a) P2 never calls `sealReplayFrame`; P6 T9 never calls `openReplayCiphertext` → T10/T11
|
||||
ship as unconsumed library code; (b) `hostContentSecret` has **no delivery** — P2 T4 and
|
||||
P3 T8 `EnrollResult` return only `{hostId, subdomain, cert, caChain}`.
|
||||
- **Do:** add `hostContentSecret` (wrapped to the agent's identity) to the **§4.5 enrollment
|
||||
contract + `EnrollResult`**; assign minting/wrapping to **P3 or P5 at BIND**, receipt/keystore
|
||||
to **P2**. Add consumer tasks: **P2** seals every replay-bound host→client output with
|
||||
`sealReplayFrame`; **P6 T9** decrypts via `openReplayCiphertext`/`deriveContentKey`. Extend
|
||||
T10/T11's merge gate to include this wiring.
|
||||
|
||||
## FIX 4 — INV12 revocation teardown has 3 incompatible descriptions + no P1 subscriber *(AUTH HIGH, GLOBAL CRITICAL)*
|
||||
P5 T10 publishes `KillSignal` on Redis pub/sub `relay:revocations` and asserts "P1
|
||||
subscribes and injects §4.1 RST/GOAWAY". P3 T13 pushes over an unowned "control→node
|
||||
channel (OQ4)". P1 T10/T11 imply P5 calls it directly. **`relay:revocations` + `KillSignal`
|
||||
are NOT in INDEX §4.2** (only `route:{host_id}` / `revoked:{jti}` are), and **P1 has no
|
||||
subscriber task**; `KillSignal` is defined P5-locally (would force P1→P5 import, against DAG).
|
||||
- **Do:** freeze ONE control→data-plane teardown contract in **INDEX §4** — `relay:revocations`
|
||||
pub/sub + `KillSignal` shape, promoted into `relay-contracts`. Assign **P1** an explicit task
|
||||
that subscribes and turns `KillSignal` → §4.1 CLOSE+RST/GOAWAY via `closeStream`/`closeTunnel`.
|
||||
Reconcile **P3 OQ4** drain channel to the same named channel.
|
||||
|
||||
## FIX 5 — capability-token WS subprotocol wire format diverges *(FRONTEND HIGH)*
|
||||
P6 T4: `new WebSocket(url, ["relay.capability.v1", token])` (raw token). P1
|
||||
`extractCapabilityToken`: `['term.relay.v1', 'term.token.' + base64url(token)]` (prefixed +
|
||||
encoded, relay strips before verify; must echo only the app subprotocol). Neither is in
|
||||
INDEX §4.3 → invented independently and diverged → real upgrade 401s while isolated tests pass.
|
||||
- **Do:** freeze the exact app-subprotocol string + `term.token.` prefix + base64url encoding +
|
||||
"accepted subprotocol must be the app one, never the token" rule in **INDEX §4.3**. Update
|
||||
**P6 T4/T8** verbatim + assert `ws.protocol === 'term.relay.v1'` after open (tear down otherwise).
|
||||
|
||||
## FIX 6 — two more single-owner violations *(GLOBAL HIGH ×2)*
|
||||
- **6a. WS-upgrade auth implemented twice.** P1 T8 `authorizeUpgrade` re-implements the
|
||||
decision (Origin+token+cross-tenant+rights) while P5 T12 `onUpgrade` is the real gate
|
||||
(adds DPoP PoP, single-use burn, pre-auth throttle, step-up, per-tenant rate, audit).
|
||||
→ Make **P1 T8 a thin adapter that DELEGATES to P5 `onUpgrade`/`onReattach`**; add DPoP +
|
||||
`activeSessionCount` to P1's `UpgradeRequest`. Authorization DECISION has exactly one owner (P5).
|
||||
- **6b. `deviceAuthProof` verifier has 3 signatures + ambiguous home.** P5 T2 owns
|
||||
`verifyDeviceAuthProof(proof, expectedAccountId, transcriptHash, now)`; P4 T8 consumes an
|
||||
injected `verifyDeviceProof(proof, {clientEphPub, clientNonce})`; P2 T15 imports it from
|
||||
`relay-e2e` (wrong package). → **P5 owns** issuance+verification (binding to
|
||||
`{clientEphPub, clientNonce}` per P4 Q3); **P4 consumes as injected dep**; **P2 gets it via
|
||||
P4's host-handshake wiring** (fix the import). Unify the binding param across P2/P4/P5.
|
||||
|
||||
---
|
||||
|
||||
## Reconciliation pass shape (recommended)
|
||||
1. **Amend `PLAN_RELAY_INDEX.md`** (single agent) — land Fixes 1,2,3,4,5,6 contract decisions
|
||||
into §4 (+ the W0 relay-contracts task). This is the coordination point; do it first.
|
||||
2. **Sync consumer plans** (parallel, file-disjoint): P1 TRANSPORT (Fix 4 subscriber, 6a
|
||||
delegate, 5 confirm), P2 AGENT (Fix 2 DirectionalKeys, 3 seal+receive secret, 6b import),
|
||||
P3 CONTROLPLANE (Fix 3 EnrollResult, 4 OQ4 reconcile), P6 FRONTEND (Fix 2 T8/T9, 3 T9, 5 T4/T8).
|
||||
P4/P5 mostly already correct — minor confirmations.
|
||||
3. **Re-run the global consistency review** to confirm zero residual contract drift.
|
||||
|
||||
Effort: ~7 agents (1 index + ~5 consumer syncs + 1 re-review) — far smaller than the
|
||||
generation workflow. Do it as its own step, ideally in a fresh conversation (read this file
|
||||
+ the 7 plan docs).
|
||||
848
docs/PLAN_RELAY_TRANSPORT.md
Normal file
848
docs/PLAN_RELAY_TRANSPORT.md
Normal file
@@ -0,0 +1,848 @@
|
||||
# P1 — Tunnel & Mux Protocol + Relay Data Plane — Implementation Plan
|
||||
|
||||
> **Plan of record for the byte hot-loop.** Owns the native WS multiplexing protocol
|
||||
> (frame codec, stream lifecycle, per-stream credit flow-control, 15s heartbeat, reconnection,
|
||||
> graceful drain) between the host-agent and the **stateless** relay data plane; TLS termination;
|
||||
> per-tenant **subdomain** routing (route by *authenticated session*, never the Host header);
|
||||
> **opaque CIPHERTEXT forwarding** (parses zero terminal semantics); and the **v0.8 frp-based MVP
|
||||
> scaffold** as an explicit stepping-stone toward the native mux.
|
||||
>
|
||||
> **Authoritative inputs** (do not reopen): [`EXPLORE_RELAY_SERVICE.md`](./EXPLORE_RELAY_SERVICE.md)
|
||||
> §0 LOCKED DECISIONS, §3 (control/data split, multiplexing, routing, drain), §5 (MVP shortcuts);
|
||||
> [`PLAN_RELAY_INDEX.md`](./PLAN_RELAY_INDEX.md) §3 (security invariants), §4 (**FROZEN SHARED
|
||||
> CONTRACTS** — cited verbatim below, never redefined), §5 (P1 charter).
|
||||
> Conventions follow [`PLAN.md`](./PLAN.md) / [`PLAN_VOICE_COMMANDS.md`](./PLAN_VOICE_COMMANDS.md):
|
||||
> stable task IDs in dependency waves, disjoint `Owns:` per task, function-signature-level 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 & non-scope
|
||||
|
||||
**In scope (this plan, nothing else):**
|
||||
- The **native WS mux** substrate: binary frame codec for the §4.1 frame format, per-stream state
|
||||
machine (`OPEN → (DATA | WINDOW_UPDATE)* → CLOSE`), **credit-based per-stream flow-control /
|
||||
backpressure** (WINDOW_UPDATE), 15s **PING/PONG heartbeat**, and **GOAWAY** graceful drain.
|
||||
- The **stateless relay data-plane node**: TLS termination for inbound browser `wss://`, holding
|
||||
outbound agent mux tunnels, the WS-upgrade authorization edge (Origin **retained** + capability
|
||||
token **required**), **per-tenant subdomain routing** resolved from the authenticated principal,
|
||||
and the **opaque byte splice** browser-WS ↔ mux-stream ↔ (agent's) `ws://127.0.0.1:3000`.
|
||||
- The **v0.8 frp scaffold**: `frps` config (wildcard vhost/subdomain routing) as the transport
|
||||
stepping-stone, contract-compatible with the native mux so the v0.9 swap reshapes nothing.
|
||||
|
||||
**Explicitly NOT in scope (owned by other plans — depend on their interfaces, never their files):**
|
||||
- Accounts / host & routing registries / pairing issuance / cert authority / metering → **P3 CONTROL PLANE**.
|
||||
- Ed25519 enrollment, agent dial-out client, `frpc` wrap, loopback forwarder, `npx …-agent pair` → **P2 AGENT**.
|
||||
- Capability-token issuance/signing, mTLS cert verify, deny-by-default authz model, CI cross-tenant
|
||||
tripwire, revocation, audit log, rate-limits → **P5 AUTH & ISOLATION**. (P1 *enforces* these at the
|
||||
edge via injected interfaces; it does not *own* their logic.)
|
||||
- E2E handshake + AEAD envelope → **P4 E2E** (rides P1 DATA frames as opaque ciphertext).
|
||||
- Login / dashboard / browser-side crypto / client-side preview → **P6 FRONTEND**.
|
||||
|
||||
**Base app unchanged:** `src/`, `public/`, `protocol.ts`, the session model — **byte-for-byte
|
||||
untouched**. P1 terminates at `ws://127.0.0.1:3000` where the agent (P2) is *just another local WS
|
||||
client*. The single base-app touch-point (`ALLOWED_ORIGINS` gaining the subdomain) is applied at
|
||||
**agent-install** time (P2), not by P1.
|
||||
|
||||
---
|
||||
|
||||
## 1. Security invariants this plan enforces
|
||||
|
||||
Per INDEX §3, P1 is a **primary owner** of **INV1, INV2, INV5, INV6, INV7, INV11, INV15** and
|
||||
contributes to **INV9, INV12, INV13** (transport carries them). Mapping to enforcement points:
|
||||
|
||||
| INV | Where P1 enforces it | Task |
|
||||
|---|---|---|
|
||||
| **INV1** cross-tenant isolation — A can never reach B | `authorizeUpgrade` (thin adapter, FIX 6a) resolves the subdomain → `requestedHostId` and **delegates the `token.host == hostId` gate to P5's `onUpgrade`/`onReattach`**; Host header is a hint only, never the authority. No code path resolves a host by raw addr/port/user hostname. | T8 (→P5), T9, T13 |
|
||||
| **INV2** relay handles only ciphertext | DATA payloads are copied **opaque**; the splice never decodes them; relay imports **no** xterm/ANSI parser. | T2, T10, T13 |
|
||||
| **INV5** no plaintext/secret at rest | Data plane holds **no** durable buffers; per-connection allocation only, **no global mutable buffer pool**; nothing written to disk/Redis by P1 except metadata via P3. | T10, T13 |
|
||||
| **INV6** deny-by-default authz on connect AND reattach | The deny-by-default **decision** is P5's (`onUpgrade`/`onReattach`, FIX 6a); `authorizeUpgrade` is a thin adapter that opens a stream **only** on P5's `{ok:true}` and passes every 401/403 through verbatim. Reattach flows the same P5 gate via `onReattach` (base-app `sessionId` re-authorized as a claim, not trusted). | T8 (→P5) |
|
||||
| **INV7** stateless data plane | Node holds only in-RAM tunnel/stream maps; a crash loses nothing (agent reconnects + re-registers via P3). No DB-of-record on the node. | T9, T10, T11, T13 |
|
||||
| **INV11** no terminal parsing in relay/agent | Static tripwire: relay/mux code has zero dependency on any terminal/ANSI parser; frames pass through opaque. | T2, T10, T13 |
|
||||
| **INV15** capability token required on WS upgrade | The adapter extracts the token via the **frozen §4.3 FIX-5 subprotocol/cookie carriers only** (never the query string) and forwards it to P5, which enforces Origin/CSWSH + token validity + `aud`/`rights`. The relay echoes only `APP_SUBPROTOCOL` (`term.relay.v1`), never the token entry. | T8 (→P5) |
|
||||
| **INV9** secrets validated at startup, never logged | `loadDataPlaneConfig` fails fast on missing TLS/CA/secret material; no secret ever logged. **Data-plane edge access logs MUST NOT include the raw upgrade `req.url`, the `Authorization` header, or the `Sec-WebSocket-Protocol` header value** (any of which can carry the capability token); log only `{subdomain, hostId, jti, decision, ts}`. | T1, T8, T10 |
|
||||
| **INV12** fast revocation | **T14 subscribes to the frozen §4.2 `relay:revocations` bus** (FIX 4) and maps a `KillSignal` → immediate §4.1 teardown: host/account scope → `drainHost(reason=revoked)` RST+close (grace forced to 0, ≤ `REVOCATION_PUSH_BUDGET_MS`), global → node-wide GOAWAY+RST. Three-tier levers: `closeStream(hostId, {jti})` (single-device, see OQ6), `closeTunnel`/`drainHost(revoked)` (whole host, immediate), `operatorDrain`/`shutdown` (grace only for clean migration). P5 **publishes**; every P1 node **subscribes** (P1 never imports P5). | T6, T9, T10, T11, **T14** |
|
||||
| **INV13** anti-replay/injection | P1 preserves per-message ordering and never reorders/dedups DATA (so P4's `seq`/nonce guarantees hold end-to-end); illegal frame transitions RST the stream. | T3, T6 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Files & ownership (new package `term-relay/`, data-plane subtree only)
|
||||
|
||||
P1 and P3 co-inhabit `term-relay/` but own **disjoint subtrees** (INDEX §0). P1 owns `term-relay/mux/`
|
||||
(the reusable protocol library, also imported by P2), `term-relay/data-plane/`, and
|
||||
`term-relay/frp-scaffold/`. **Types** for §4.1 live in the INDEX-frozen `relay-contracts/`; P1 imports
|
||||
them read-only and never redefines them. Files ≤ 400 lines typical, 800 hard max (`coding-style.md`).
|
||||
|
||||
| File | Action | Task | Phase |
|
||||
|---|---|---|---|
|
||||
| `term-relay/package.json`, `term-relay/tsconfig.json`, `term-relay/vitest.config.ts` | create — package skeleton (P1 seeds; P3 extends its own subtree) | T1 | v0.8 |
|
||||
| `term-relay/data-plane/config.ts` | create — env → `DataPlaneConfig` (Zod, fail-fast, INV9) | T1 | v0.8 |
|
||||
| `term-relay/mux/frame-codec.ts` | create — `encodeMuxFrame`/`decodeMuxFrame`/`decodeHeader` (§4.1) | T2 | spec v0.8 / live v0.9 |
|
||||
| `term-relay/mux/type-bytes.ts` | create — `MuxFrameType ⇄ byte` + `flags` bit maps (§4.1) | T2 | v0.9 |
|
||||
| `term-relay/mux/frame-guards.ts` | create — Zod boundary validation of OPEN/WINDOW_UPDATE/GOAWAY payloads (CBOR decode + `relay-contracts` schema) | T2 | v0.9 |
|
||||
| `term-relay/mux/stream.ts` | create — pure stream state machine (`nextStreamState`, transitions) | T3 | v0.9 |
|
||||
| `term-relay/mux/flow-control.ts` | create — credit-based `FlowController` (per-stream + link) | T4 | v0.9 |
|
||||
| `term-relay/mux/heartbeat.ts` | create — 15s PING/PONG liveness (injectable timer) | T5 | v0.9 |
|
||||
| `term-relay/mux/mux-session.ts` | create — multiplexer over one WS: demux, streams, flow, heartbeat, GOAWAY | T6 | v0.9 |
|
||||
| `term-relay/data-plane/subdomain-router.ts` | create — `extractSubdomain` + `RouteResolver` interface (impl = P3) | T7 | v0.9 |
|
||||
| `term-relay/data-plane/upgrade.ts` | create — `authorizeUpgrade` edge (Origin + capability token, INV1/6/15) | T8 | v0.9 |
|
||||
| `term-relay/data-plane/agent-listener.ts` | create — accept agent dial-out, mTLS-verify (P5 iface), build `MuxSession`, register route (P3 iface) | T9 | v0.9 |
|
||||
| `term-relay/data-plane/relay-node.ts` | create — stateless node: browser upgrade → openStream → **opaque splice** | T10 | v0.9 |
|
||||
| `term-relay/data-plane/drain.ts` | create — GOAWAY graceful drain + `closeTunnel` for revocation (INV7/INV12) | T11 | v0.9 |
|
||||
| `term-relay/data-plane/revocation-subscriber.ts` | create — subscribe `relay:revocations` (§4.2, FIX 4) → `KillSignal`→§4.1 CLOSE+RST/GOAWAY via T10/T11 (INV12) | T14 | v0.9 |
|
||||
| `term-relay/frp-scaffold/frps.toml` | create — wildcard-subdomain vhost config (v0.8 substrate) | T12 | v0.8 |
|
||||
| `term-relay/frp-scaffold/README.md` | create — VPS + wildcard DNS/TLS + `frps` runbook; retirement note | T12 | v0.8 |
|
||||
| `term-relay/frp-scaffold/plugin-hook.ts` | create — thin frp server-plugin HTTP shim → delegates authz to P3 (no tenancy logic here) | T12 | v0.8 |
|
||||
| `term-relay/test/**` (mux + data-plane + tripwire) | create — vitest unit/integration + INV tripwires | T2–T14 | per task |
|
||||
| `relay-contracts/**` | **read-only** (INDEX-frozen §4.1 types) — P1 imports, never edits | — | — |
|
||||
|
||||
**Freeze rule:** T2's codec + T3–T5 pure modules export a stable surface consumed by T6, then T6 by
|
||||
T9/T10; freeze each layer before the next consumes it (same discipline as PLAN_VOICE_COMMANDS §2).
|
||||
|
||||
---
|
||||
|
||||
## 3. Dependency waves
|
||||
|
||||
```
|
||||
W0 package skeleton + config T1
|
||||
│
|
||||
▼
|
||||
W1 pure protocol leaves (all parallel) T2 codec · T3 stream · T4 flow · T5 heartbeat
|
||||
│ (also parallel, separate lane) T12 frp scaffold ── v0.8, no dep on W1
|
||||
▼
|
||||
W2 mux session (assembles T2–T5) T6
|
||||
│
|
||||
▼
|
||||
W3 data-plane wiring (parallel where file-disjoint) T7 router · T8 upgrade(→P5) · T9 agent-listener
|
||||
│ T10 relay-node (dep T6,T8,T9) · T11 drain (dep T6,T9)
|
||||
│ T14 revocation-subscriber (dep T10,T11) ── FIX 4
|
||||
▼
|
||||
W4 invariant tripwires + integration T13
|
||||
```
|
||||
|
||||
**Cross-plan dependencies (interfaces, not files):**
|
||||
- **T8** (FIX 6a) **delegates the authorization decision to P5** → injected `Authorizer` (`onUpgrade`/`onReattach`,
|
||||
§6a) consuming P5's frozen `UpgradeContext`/`AuthzOutcome`/`DpopContext`. T8 no longer imports a
|
||||
`CapabilityVerifier`/`OriginCheck` — P5 owns Origin/CSWSH + token verify + DPoP + rate + burn + audit.
|
||||
- **T7/T8** depend on **P3**'s host registry read → injected `RouteResolver` (subdomain → `{hostId, accountId}`);
|
||||
T8 uses it only to name `requestedHostId` for the `UpgradeContext` (P5 makes the cross-tenant call).
|
||||
- **T9** depends on **P5**'s mTLS peer-cert verify → injected `MtlsVerifier`; and **P3**'s Redis routing
|
||||
table write → injected `RouteRegistrar` (`route:{host_id}` heartbeat-TTL, §4.2).
|
||||
- **T10** DATA path is contract-shared with **P2** (agent side of §4.1) and carries **P4**'s `E2EEnvelope`
|
||||
opaquely (§4.4) — P1 needs no P4 code (INV2).
|
||||
- **T11** GOAWAY drain is invoked by **P3** node-drain coordination (`operatorDrain`/`shutdown`, grace honored); whole-host `closeTunnel`/`drainHost(reason=revoked)` (immediate, grace forced to 0) and single-device `closeStream(hostId, {jti})` are the levers.
|
||||
- **T14** (FIX 4) **subscribes to the frozen §4.2 `relay:revocations` bus** — **P3/P5 publish** a `KillSignal`,
|
||||
every P1 node consumes it and drives T10/T11 teardown. The `KillSignal`/`RevocationScope`/channel constants
|
||||
live in `relay-contracts` (promoted out of `relay-auth`) so **P1 never imports P5** (DAG stays acyclic). P3
|
||||
OQ4's "control→node drain channel" reconciles to this exact channel.
|
||||
|
||||
In **v0.8**, the frp scaffold (T12) lets P2/P3 integrate against a real transport while T2–T14 are
|
||||
built; the v0.9 native-mux swap is contract-compatible by construction (both speak §4.1 routing +
|
||||
opaque byte forwarding), so nothing downstream reshapes.
|
||||
|
||||
---
|
||||
|
||||
## 4. Frozen shared contracts consumed (cited verbatim from INDEX §4 — NOT redefined)
|
||||
|
||||
P1 **imports** these from `relay-contracts/`; it must never re-declare them locally.
|
||||
|
||||
**§4.1 Mux frame (15-byte header)** — `version(1B)=0x01 · type(1B) · flags(1B: bit0 FIN, bit1 RST) ·
|
||||
streamId(4B uint32, 0=link-level) · payloadLen(8B uint64 ≤ maxFrameBytes) · payload(var)`. Types:
|
||||
`0x01 OPEN · 0x02 DATA · 0x03 CLOSE · 0x04 PING · 0x05 PONG · 0x06 WINDOW_UPDATE · 0x07 GOAWAY`.
|
||||
Lifecycle `OPEN → (DATA | WINDOW_UPDATE)* → CLOSE`; illegal transition ⇒ **RST that stream, never the
|
||||
tunnel**; `streamId` allocated by the **relay**, monotonic per tunnel, **never reused**.
|
||||
|
||||
```ts
|
||||
// from relay-contracts (§4.1) — imported, frozen:
|
||||
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; readonly payloadLen: number
|
||||
}
|
||||
export interface MuxOpen {
|
||||
readonly streamId: number; readonly subdomain: string; readonly requestPath: string
|
||||
readonly originHeader: string; readonly remoteAddrHash: string; readonly capabilityTokenRef: string
|
||||
}
|
||||
export function encodeMuxFrame(h: MuxFrameHeader, payload: Uint8Array): Uint8Array
|
||||
export function decodeMuxFrame(buf: Uint8Array): { header: MuxFrameHeader; payload: Uint8Array }
|
||||
```
|
||||
|
||||
- **§4.3 CapabilityToken** — the token **shape** T8 references (but does **not** itself verify — verification
|
||||
is delegated to P5, FIX 6a): `sub` (principal, from authenticated session, **never client**), `aud`
|
||||
(subdomain, Host-confusion guard, INV1), `host` (exact `host_id`, single host, never wildcard),
|
||||
`rights: ('attach'|'manage'|'kill')[]`, `iat`/`exp` (short TTL), `jti` (revocation lookup). P1 extracts the
|
||||
raw token at the edge and hands it to P5's `onUpgrade`/`onReattach`, which calls `verifyCapabilityToken(raw,
|
||||
expectedAud, now)` internally — T8 never calls it directly.
|
||||
- **§4.3 WS-upgrade subprotocol wire format (FIX 5) — FROZEN, cited verbatim; T8's `extractCapabilityToken`
|
||||
MUST use these constants, never local literals**:
|
||||
```ts
|
||||
// from relay-contracts (§4.3) — imported, frozen. 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):** read the token from the `term.token.<b64u>` entry via `extractTokenFromSubprotocols`,
|
||||
strip it before handing to P5, and the accepted/echoed `Sec-WebSocket-Protocol` in the handshake response
|
||||
**MUST be `APP_SUBPROTOCOL` (`'term.relay.v1'`), NEVER the token entry**. Query-string token transport is
|
||||
forbidden. (The earlier local literals `'term.relay.v1'`/`'term.token.'` in this plan are now these frozen
|
||||
constants — no local redefinition.)
|
||||
- **§4.2 control→data-plane revocation teardown (FIX 4) — FROZEN, cited verbatim; consumed by T14**:
|
||||
```ts
|
||||
// from relay-contracts (§4.2) — imported, frozen. P3/P5 PUBLISH; every P1 node SUBSCRIBES.
|
||||
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> }
|
||||
export const RELAY_REVOCATIONS_CHANNEL = 'relay:revocations' as const
|
||||
export const REVOCATION_PUSH_BUDGET_MS = 2000 as const
|
||||
```
|
||||
P1 **subscribes** to `RELAY_REVOCATIONS_CHANNEL` and, per affected stream, injects a §4.1 `CLOSE`+`flags.RST`
|
||||
(host/account scope) or a link-level `GOAWAY` (global scope) using the **existing frozen §4.1 wire — no new
|
||||
frame type** — within `REVOCATION_PUSH_BUDGET_MS`. P5 stays socket-free (publishes only).
|
||||
- **§4.2 data model** — consumed read-only via `RouteResolver`/`RouteRegistrar`: `hosts.host_id`
|
||||
(unguessable UUIDv4, never recycled), `hosts.subdomain` (stable), `route:{host_id} → {relayNodeId,
|
||||
updatedAt}` (Redis heartbeat-TTL, INV7). P1 **reads** `hostId`/`accountId`; it **writes** only the
|
||||
routing entry through P3's registrar.
|
||||
- **§4.4 E2EEnvelope** — carried **inside** each DATA payload once E2E ships; P1 forwards it opaque and
|
||||
**never** derives keys or inspects `seq`/`nonce`/`ciphertext` (INV2). No P1 change at v0.10.
|
||||
|
||||
---
|
||||
|
||||
## 5. Task specifications
|
||||
|
||||
### W0
|
||||
|
||||
#### T1 · Package skeleton + data-plane config `[ ]` — v0.8
|
||||
- **Owns**: `term-relay/package.json`, `term-relay/tsconfig.json`, `term-relay/vitest.config.ts`,
|
||||
`term-relay/data-plane/config.ts`, `term-relay/test/config.test.ts`
|
||||
- **Depends**: `relay-contracts/` exists (INDEX W0). · **Parallel-safe**: with T12.
|
||||
- **Contract**:
|
||||
```ts
|
||||
export interface DataPlaneConfig {
|
||||
readonly baseDomain: string // e.g. 'term.example.com' — for extractSubdomain
|
||||
readonly bindHost: string // default '0.0.0.0'
|
||||
readonly bindPort: number // browser wss listener
|
||||
readonly agentBindPort: number // agent dial-out listener
|
||||
readonly tlsCertPath: string; readonly tlsKeyPath: string // relay's OWN server cert (browser-facing wss:)
|
||||
readonly agentCaCertPath: string // CA/trust-anchor that MUST sign the agent's client cert on agentBindPort (mTLS root)
|
||||
readonly agentCaChainPath: string // intermediate chain for agent-cert validation (rotation-safe; may equal agentCaCertPath if single-tier)
|
||||
readonly relayNodeId: string // stable node id written into route:{host_id}
|
||||
readonly maxFrameBytes: number // §4.1 payloadLen ceiling; default 1 MiB
|
||||
readonly initialWindowBytes: number // §4.1 credit initial window; default 256 KiB
|
||||
readonly heartbeatIntervalMs: number // default 15_000
|
||||
readonly routeTtlMs: number // Redis route TTL; default 45_000
|
||||
}
|
||||
export function loadDataPlaneConfig(env: NodeJS.ProcessEnv): DataPlaneConfig
|
||||
```
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `config.test.ts`: missing `TLS_CERT_PATH`/`TLS_KEY_PATH`/`BASE_DOMAIN` → `loadDataPlaneConfig` throws with a clear message (fail-fast, INV9); **missing `AGENT_CA_CERT_PATH` (the mTLS trust-anchor) → throws** — the agent-facing listener MUST NOT start without a CA to validate client certs against (else "mTLS" silently degrades to accept-any-cert, Finding-4 footgun); non-numeric `MAX_FRAME_BYTES` → throws; happy path returns typed frozen object; defaults applied.
|
||||
2. **GREEN** implement with a Zod schema (`z.coerce.number()` for ints, `z.string().min(1)` for paths). Return `Object.freeze(...)` (immutability). **Never log** cert/CA paths' contents; never log secrets.
|
||||
- **Security note (INV9)**: startup MUST fail if TLS server material, the `AGENT_CA_CERT_PATH` mTLS trust-anchor, or `BASE_DOMAIN` is absent; no secret value is ever emitted to logs. The CA-path presence check is the config-level guarantee that T9 can wire TLS-layer `requestCert: true`/`rejectUnauthorized: true` (no accept-any-cert path exists).
|
||||
- **Accept**: `npx vitest run config` green; `tsc --noEmit` clean.
|
||||
|
||||
### W1 — pure protocol leaves (all parallel, DOM-free, no `ws`/`pg`)
|
||||
|
||||
#### T2 · Mux frame codec `[ ]` — spec written v0.8, becomes substrate v0.9
|
||||
- **Owns**: `term-relay/mux/frame-codec.ts`, `term-relay/mux/type-bytes.ts`, `term-relay/mux/frame-guards.ts`, `term-relay/test/frame-codec.test.ts`
|
||||
- **Depends**: `relay-contracts` §4.1 types. · **Parallel-safe**: T3–T5, T12.
|
||||
- **Contract**:
|
||||
```ts
|
||||
export const MUX_HEADER_BYTES = 15
|
||||
export const MUX_VERSION = 1 as const
|
||||
export const TYPE_TO_BYTE: Readonly<Record<MuxFrameType, number>> // open→0x01 … goaway→0x07
|
||||
export const BYTE_TO_TYPE: ReadonlyMap<number, MuxFrameType>
|
||||
export function encodeMuxFrame(h: MuxFrameHeader, payload: Uint8Array): Uint8Array
|
||||
export function decodeHeader(buf: Uint8Array): MuxFrameHeader // header-only peek (15B)
|
||||
export function decodeMuxFrame(buf: Uint8Array): { header: MuxFrameHeader; payload: Uint8Array }
|
||||
// CBOR-encoded typed payloads, validated at the boundary (coding-style: validate untrusted data):
|
||||
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: number): Uint8Array
|
||||
export function decodeGoaway(payload: Uint8Array): { lastStreamId: number; reason: number }
|
||||
```
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `frame-codec.test.ts` (round-trip + adversarial):
|
||||
- round-trip each type; `encode∘decode === identity` for header fields incl. `fin`/`rst` bit packing.
|
||||
- `streamId=0` link-level frames decode with `streamId===0`.
|
||||
- `payloadLen` uses uint64 big-endian; a value > `Number.MAX_SAFE_INTEGER` → **throws** (safe-int guard, §4.1).
|
||||
- **negative**: truncated buffer (< 15B) → throws; `payloadLen` ≠ actual payload length → throws; unknown `type` byte (0x00, 0x08) → throws; `version ≠ 0x01` → throws; `payloadLen > maxFrameBytes` caller-check hook (codec exposes the raw length; the ceiling is enforced by T6 with config).
|
||||
- `decodeOpen` rejects a CBOR payload missing `subdomain`/`host`-scope fields (Zod) — **fuzz malformed CBOR → throws, never returns partial** (INV boundary validation).
|
||||
2. **GREEN** implement with `DataView` for the fixed header (big-endian) and `cbor-x` (or a tiny CBOR) for typed payloads; Zod-validate decoded OPEN/WINDOW_UPDATE/GOAWAY.
|
||||
- **Security note (INV2/INV11)**: this module imports **no** terminal/ANSI parser; DATA payloads are handled as opaque `Uint8Array` and never inspected. `frame-guards.ts` validates *control* frames only (OPEN/WU/GOAWAY), never DATA content.
|
||||
- **Accept**: `npx vitest run frame-codec` green; static check (T13) confirms no xterm/ansi import.
|
||||
|
||||
#### T3 · Stream state machine `[ ]` — v0.9
|
||||
- **Owns**: `term-relay/mux/stream.ts`, `term-relay/test/stream.test.ts`
|
||||
- **Depends**: `relay-contracts` types. · **Parallel-safe**: T2/T4/T5.
|
||||
- **Contract** (pure reducer — no I/O, no mutation of inputs):
|
||||
```ts
|
||||
export type StreamState = 'idle' | 'open' | 'halfClosedLocal' | 'halfClosedRemote' | 'closed'
|
||||
export type StreamTransition = { readonly next: StreamState } | { readonly illegal: true }
|
||||
export function initialStreamState(): StreamState // 'idle'
|
||||
export function nextStreamState(
|
||||
current: StreamState, type: MuxFrameType, fin: boolean, dir: 'inbound' | 'outbound'
|
||||
): StreamTransition
|
||||
export function isTerminal(s: StreamState): boolean
|
||||
```
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `stream.test.ts`: legal path `idle --OPEN--> open --DATA*--> open --CLOSE--> closed`; `DATA` with `fin` → half-closed in the sending direction; both-side FIN → `closed`. **Illegal (→ `{illegal:true}`, caller RSTs the stream):** DATA before OPEN; DATA after CLOSE; OPEN on an already-open streamId; any frame after `closed`. Direction matters (inbound FIN vs outbound FIN produce different half-closed states).
|
||||
2. **GREEN** implement as a total function returning a **new** state (never mutate).
|
||||
- **Security note (INV13)**: illegal transitions are *rejected at the stream level* (RST one stream), never silently coerced — this is what lets P4's `seq` monotonicity mean something end-to-end.
|
||||
- **Accept**: `npx vitest run stream` green; function < 50 lines.
|
||||
|
||||
#### T4 · Credit-based flow control `[ ]` — v0.9
|
||||
- **Owns**: `term-relay/mux/flow-control.ts`, `term-relay/test/flow-control.test.ts`
|
||||
- **Depends**: none (pure). · **Parallel-safe**: T2/T3/T5.
|
||||
- **Contract**:
|
||||
```ts
|
||||
export const DEFAULT_INITIAL_WINDOW = 256 * 1024
|
||||
export const WINDOW_REPLENISH_THRESHOLD = 0.5 // emit WINDOW_UPDATE when half consumed
|
||||
export interface FlowController {
|
||||
registerStream(streamId: number, initialWindow: number): void
|
||||
releaseStream(streamId: number): void
|
||||
canSend(streamId: number, bytes: number): boolean // false ⇒ backpressure (sender blocks)
|
||||
consumeSendCredit(streamId: number, bytes: number): void // deduct on outbound DATA
|
||||
grantCredit(streamId: number, credit: number): void // apply inbound WINDOW_UPDATE
|
||||
creditFor(streamId: number): number
|
||||
onDelivered(streamId: number, bytes: number): number // returns credit to replenish (0 if < threshold)
|
||||
}
|
||||
export function createFlowController(): FlowController
|
||||
```
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `flow-control.test.ts`: `canSend` true until credit exhausted, then false; `consumeSendCredit` past window is rejected by `canSend` (never negative credit); `grantCredit` unblocks; `onDelivered` returns a replenish amount only once the threshold is crossed, else 0; unknown streamId → `canSend` false (deny-by-default), `creditFor` 0. Per-stream isolation: exhausting stream A's window does **not** affect stream B (**heavy `vim`/`top` redraw can't starve another stream**, §4.1).
|
||||
2. **GREEN** implement with a per-stream `Map<number, {window, sent, delivered}>`; immutable-style updates (replace the record).
|
||||
- **Security note**: unknown/released streams are deny-by-default (no credit) — prevents send-after-close write amplification.
|
||||
- **Accept**: `npx vitest run flow-control` green.
|
||||
|
||||
#### T5 · Heartbeat / liveness `[ ]` — v0.9
|
||||
- **Owns**: `term-relay/mux/heartbeat.ts`, `term-relay/test/heartbeat.test.ts`
|
||||
- **Depends**: none. · **Parallel-safe**: T2–T4.
|
||||
- **Contract** (timer injectable, per PLAN_VOICE_COMMANDS confirm-window pattern):
|
||||
```ts
|
||||
export const HEARTBEAT_INTERVAL_MS = 15_000 // §4.1: PING every 15s
|
||||
export const HEARTBEAT_MISS_LIMIT = 3 // 3 missed PONGs ⇒ dead (~45s)
|
||||
export interface HeartbeatDeps {
|
||||
sendPing(token: Uint8Array): void
|
||||
onDead(): void
|
||||
now?: () => number
|
||||
schedule?: (fn: () => void, ms: number) => { cancel(): void } // default setInterval-backed
|
||||
}
|
||||
export interface Heartbeat { start(): void; stop(): void; onPong(token: Uint8Array): void }
|
||||
export function createHeartbeat(deps: HeartbeatDeps): Heartbeat
|
||||
```
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** with a fake scheduler: after `start`, a PING is sent every interval with a fresh 8-byte token; a matching `onPong` resets the miss counter; **3 intervals with no PONG → `onDead` fires exactly once** and no further PINGs; a PONG with a stale/unknown token does **not** reset the counter (prevents a spoofed/duplicated PONG from masking a dead link); `stop` cancels cleanly (no `onDead` after stop).
|
||||
2. **GREEN** implement with the injected scheduler; token = random 8 bytes (crypto RNG).
|
||||
- **Security note (INV13-adjacent)**: PONG must echo the exact outstanding token; an unmatched token is ignored, so a replayed PONG can't keep a dead/hijacked tunnel "alive".
|
||||
- **Accept**: `npx vitest run heartbeat` green.
|
||||
|
||||
### W2
|
||||
|
||||
#### T6 · Mux session (multiplexer over one WS) `[ ]` — v0.9
|
||||
- **Owns**: `term-relay/mux/mux-session.ts`, `term-relay/test/mux-session.test.ts`
|
||||
- **Depends**: T2, T3, T4, T5. · **Parallel-safe**: with T7, T12.
|
||||
- **Contract**:
|
||||
```ts
|
||||
export interface MuxStreamHandle {
|
||||
readonly streamId: number
|
||||
writeData(payload: Uint8Array): boolean // false ⇒ backpressured (buffered until WINDOW_UPDATE)
|
||||
close(rst?: boolean): void
|
||||
}
|
||||
export interface MuxSessionDeps {
|
||||
role: 'relay' | 'agent'
|
||||
sendWire(frame: Uint8Array): void // write encoded frame to the underlying ws
|
||||
onOpen(open: MuxOpen, stream: MuxStreamHandle): void // relay: n/a (relay opens); agent: dial :3000
|
||||
onData(streamId: number, payload: Uint8Array): void // OPAQUE bytes (INV2) — never parsed
|
||||
onClose(streamId: number, rst: boolean): void
|
||||
onDead(): void
|
||||
maxFrameBytes: number
|
||||
initialWindowBytes: number
|
||||
}
|
||||
export interface MuxSession {
|
||||
openStream(open: MuxOpen): MuxStreamHandle // relay-side: allocates monotonic streamId
|
||||
onWire(buf: Uint8Array): void // feed inbound bytes: decode → dispatch
|
||||
drain(lastStreamId: number, reason: number): void // send GOAWAY (T11)
|
||||
close(): void
|
||||
}
|
||||
export function createMuxSession(deps: MuxSessionDeps): MuxSession
|
||||
```
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `mux-session.test.ts` (two `MuxSession`s wired mouth-to-mouth via each other's `sendWire`):
|
||||
- relay `openStream(open)` → agent's `onOpen` fires with a fresh monotonic `streamId`; `streamId`s never reused within the session.
|
||||
- `writeData` on relay → agent `onData` receives **byte-identical** payload; a known plaintext marker in the payload is delivered verbatim and **never** inspected by the session (INV2).
|
||||
- **flow control**: fill a stream's window → `writeData` returns `false`; a WINDOW_UPDATE from the peer resumes flushing buffered data; stream A backpressure does not block stream B.
|
||||
- **heartbeat**: no PONG for `HEARTBEAT_MISS_LIMIT` intervals → `onDead`.
|
||||
- **lifecycle enforcement**: an inbound DATA for an unknown/closed streamId → session emits a **CLOSE with RST for that stream only**, the tunnel stays up (T3 illegal transition).
|
||||
- **frame ceiling**: an inbound frame with `payloadLen > maxFrameBytes` → RST that stream (or GOAWAY if link-level), never OOM.
|
||||
2. **GREEN** implement: incremental wire parser (buffer until a full 15B header + payload), `Map<streamId, streamCtx>` with T3 state + T4 credit, T5 heartbeat on streamId 0, GOAWAY handling.
|
||||
- **Security note (INV2/INV5/INV11)**: DATA is copied opaque; buffers are **per-stream, per-session** (no global pool), freed on CLOSE — no cross-tenant buffer bleed possible. The session imports no terminal parser.
|
||||
- **Accept**: `npx vitest run mux-session` green; file ≤ 400 lines (extract helpers if larger).
|
||||
|
||||
### W3 — data-plane wiring
|
||||
|
||||
#### T7 · Subdomain router `[ ]` — v0.9
|
||||
- **Owns**: `term-relay/data-plane/subdomain-router.ts`, `term-relay/test/subdomain-router.test.ts`
|
||||
- **Depends**: T1 (config). · **Parallel-safe**: T8, T9, T11, T12.
|
||||
- **Contract**:
|
||||
```ts
|
||||
// RouteResolver is IMPLEMENTED BY P3 (control plane); P1 owns only the interface it depends on.
|
||||
export interface ResolvedHost { readonly hostId: string; readonly accountId: string; readonly subdomain: string }
|
||||
export interface RouteResolver { resolveSubdomain(subdomain: string): Promise<ResolvedHost | null> }
|
||||
export function extractSubdomain(hostHeader: string, baseDomain: string): string | null
|
||||
```
|
||||
- **Steps (TDD)**:
|
||||
1. **RED**: `extractSubdomain('alice.term.example.com', 'term.example.com')` → `'alice'`; a bare `baseDomain` → `null`; a **wildcard/confusion attempt** `'alice.term.example.com.evil.com'` → `null`; multi-label subdomain `'a.b.term.example.com'` → `null` (reject nested; single-label tenant only, INV1 Host-confusion guard); case-insensitive host; trailing dot tolerated.
|
||||
2. **GREEN** implement a strict suffix match returning the single leftmost label only.
|
||||
- **Security note (INV1)**: `extractSubdomain` is a **hint** for candidate lookup; it is **never** the authority — T8 requires the capability token's `aud` to equal this subdomain and `token.host` to equal the resolver's `hostId`. Nested/confusing Host headers resolve to `null` (reject).
|
||||
- **Accept**: `npx vitest run subdomain-router` green.
|
||||
|
||||
#### T8 · Upgrade authorization edge — **THIN ADAPTER over P5** `[ ]` — v0.9 **(INV1/INV6/INV15 surface)**
|
||||
> **FIX 6a — single-owner authorization.** The authorization **decision** (Origin/CSWSH + capability-token
|
||||
> verify + DPoP proof-of-possession + single-use `jti` burn + pre-auth throttle + step-up + per-tenant rate +
|
||||
> deny-by-default cross-tenant gate + audit) has **exactly one owner: P5** (`relay-auth/src/enforce/onUpgrade.ts`
|
||||
> / `onReattach.ts`). **`authorizeUpgrade` holds NO independent authz logic** — it only (a) parses the upgrade
|
||||
> (subdomain + FIX-5 token extraction + remoteAddr hash), (b) resolves the subdomain → `requestedHostId` via P3's
|
||||
> `RouteResolver`, (c) builds P5's frozen `UpgradeContext` (incl. `dpop: DpopContext` + `activeSessionCount`),
|
||||
> (d) calls the injected P5 `Authorizer`, and (e) maps the returned `AuthzOutcome` → a §4.1 `MuxOpen` (or a
|
||||
> 401/403 close). Reattach flows the same gate via `onReattach` (base-app `sessionId` re-authorized as a claim).
|
||||
- **Owns**: `term-relay/data-plane/upgrade.ts`, `term-relay/test/upgrade.test.ts`
|
||||
- **Depends**: T2 (MuxOpen), T7 (resolver). **Cross-plan**: **P5 `Authorizer` = `onUpgrade`/`onReattach` + `UpgradeContext`/`AuthzOutcome`/`DpopContext` (§6a, imported read-only)**, P3 `RouteResolver`. · **Parallel-safe**: T9, T11.
|
||||
- **Contract** (all P5 types imported from `relay-auth`, never redefined here):
|
||||
```ts
|
||||
import { APP_SUBPROTOCOL, extractTokenFromSubprotocols } from 'relay-contracts' // §4.3 FIX 5, frozen
|
||||
import type { UpgradeContext, AuthzOutcome, DpopContext } from 'relay-auth' // §6a, P5-owned, frozen
|
||||
|
||||
// P5 is the sole authorizer. P1 injects it; P1 supplies the ctx, P5 makes the decision.
|
||||
export interface Authorizer {
|
||||
onUpgrade(ctx: UpgradeContext, now: number): Promise<AuthzOutcome>
|
||||
onReattach(ctx: UpgradeContext & { sessionId: string }, now: number): Promise<AuthzOutcome>
|
||||
}
|
||||
export interface UpgradeRequest {
|
||||
readonly host: string; readonly origin: string | undefined
|
||||
readonly url: string // requestPath ONLY (opaque passthrough) — NEVER a token source
|
||||
readonly subprotocols: readonly string[] // parsed Sec-WebSocket-Protocol values (FIX-5 token transport)
|
||||
readonly cookies: Readonly<Record<string, string>> // parsed Cookie header (fallback token transport)
|
||||
readonly remoteAddr: string
|
||||
readonly dpop: DpopContext // FIX 6a: proof-of-possession material P5 needs (passed through, not inspected)
|
||||
readonly activeSessionCount: number // FIX 6a: supplied for P5's per-tenant/step-up policy
|
||||
readonly sessionId?: string // present ⇒ reattach path (→ onReattach); absent ⇒ onUpgrade
|
||||
}
|
||||
export type UpgradeDecision =
|
||||
| { readonly ok: true; readonly open: MuxOpen; readonly hostId: string; readonly acceptedSubprotocol: string }
|
||||
| { readonly ok: false; readonly status: 401 | 403 }
|
||||
// Token transport is FIXED, not free-form (FIX 5). Extract ONLY from the two allowed carriers via the
|
||||
// frozen §4.3 helper, NEVER from req.url query. Returns the opaque token string, or null if absent.
|
||||
export function extractCapabilityToken(req: UpgradeRequest): { token: string; via: 'subprotocol' | 'cookie' } | null
|
||||
export function authorizeUpgrade(req: UpgradeRequest, deps: {
|
||||
authorizer: Authorizer; resolver: RouteResolver
|
||||
baseDomain: string; now: () => number; remoteAddrSalt: string
|
||||
requiredRight: CapabilityRight // 'attach' for a session upgrade
|
||||
}): Promise<UpgradeDecision>
|
||||
```
|
||||
- **Capability-token transport (FIX 5 — frozen §4.3, cited verbatim, INV15).** The browser's native `WebSocket`
|
||||
API cannot set arbitrary request headers, so the token rides one of exactly **two** carriers, in this order:
|
||||
1. **`Sec-WebSocket-Protocol` subprotocol (PREFERRED).** The client opens
|
||||
`new WebSocket(url, [APP_SUBPROTOCOL, encodeTokenSubprotocol(rawToken)])` — i.e.
|
||||
`['term.relay.v1', 'term.token.' + base64url(token)]`. `extractCapabilityToken` reads the token via the
|
||||
frozen `extractTokenFromSubprotocols(req.subprotocols)` (strips `TOKEN_SUBPROTOCOL_PREFIX`, base64url-decodes).
|
||||
The relay **MUST echo `acceptedSubprotocol = APP_SUBPROTOCOL` (`'term.relay.v1'`)** in the handshake response
|
||||
— **never** the `term.token.<b64u>` entry (echoing the token leaks the bearer secret). `UpgradeDecision.ok`
|
||||
therefore fixes `acceptedSubprotocol: 'term.relay.v1'` (non-nullable — the app subprotocol is always echoed).
|
||||
2. **Short-lived `HttpOnly; Secure; SameSite=Strict` cookie (FALLBACK).** Set by a prior authenticated
|
||||
HTTPS call (P6), scoped to the tenant subdomain, read from `req.cookies`. `HttpOnly` keeps it out of
|
||||
JS/`Referer`; `Secure` keeps it off plaintext; `SameSite=Strict` complements the retained Origin/CSWSH check.
|
||||
- **FORBIDDEN: the query string.** The token is a bearer-equivalent secret; `authorizeUpgrade` and
|
||||
`extractCapabilityToken` MUST NOT read any token from `req.url`. `req.url` carries **only** `requestPath`
|
||||
(opaque `?join=<id>` passthrough, never parsed for authz). Query-string tokens leak verbatim into
|
||||
reverse-proxy/CDN/frp access logs, browser history, and `Referer` headers.
|
||||
- **Adapter logic (NO local authz decision — parse, delegate, map; INV6 deny-by-default lives in P5)**:
|
||||
```
|
||||
subdomain = extractSubdomain(req.host, baseDomain); if !subdomain → 401 (unparseable Host, local)
|
||||
extracted = extractCapabilityToken(req); // FIX-5 carriers only; NEVER req.url query
|
||||
if !extracted → 401 (no token carrier present, local)
|
||||
resolved = await resolver.resolveSubdomain(subdomain); if !resolved → 403 (unknown subdomain, local)
|
||||
ctx: UpgradeContext = {
|
||||
capabilityRaw: extracted.token, originHeader: req.origin ?? '', expectedAud: subdomain,
|
||||
requestedHostId: resolved.hostId, // P1 NAMES the host it resolved; P5 gates token.host against it (INV1)
|
||||
requiredRight, remoteAddrHash: hash(remoteAddrSalt, req.remoteAddr),
|
||||
activeSessionCount: req.activeSessionCount, dpop: req.dpop, principal: null /* P5 resolves from token */,
|
||||
}
|
||||
outcome = req.sessionId
|
||||
? await authorizer.onReattach({ ...ctx, sessionId: req.sessionId }, now()) // P5 owns the decision
|
||||
: await authorizer.onUpgrade(ctx, now())
|
||||
if !outcome.ok → { ok:false, status: outcome.status }
|
||||
open = { streamId: 0 /*allocated by MuxSession*/, subdomain, requestPath: pathOf(req.url),
|
||||
originHeader: req.origin ?? '', remoteAddrHash: ctx.remoteAddrHash,
|
||||
capabilityTokenRef: outcome.jti /* OQ5 RESOLVED — P5 §6a AuthzOutcome.ok now carries the verified jti */ }
|
||||
return { ok:true, open, hostId: outcome.hostId, acceptedSubprotocol: APP_SUBPROTOCOL }
|
||||
```
|
||||
**`account_id`/`host_id` are derived by P5 from the signed token + registry — NEVER from client query/body/header (INV3).** The adapter passes `requestedHostId` (resolved from the subdomain), the raw token, Origin, and DPoP material to P5; **every accept/reject verdict is P5's**. `requestPath` is opaque passthrough the relay never parses for authz.
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `upgrade.test.ts` (mock **P5 `authorizer`** + `resolver` — assert delegation, not local re-decision):
|
||||
- **delegation:** a happy `onUpgrade` (returns `{ok:true, hostId, principal, jti}`) → `{ok:true}`, `open.hostId === outcome.hostId`, `open.subdomain === 'alice'`, `acceptedSubprotocol === APP_SUBPROTOCOL`; assert `authorizer.onUpgrade` was called **exactly once** with a `UpgradeContext` carrying `expectedAud==='alice'`, `requestedHostId===resolved.hostId`, `requiredRight`, `dpop`, and `activeSessionCount` (proves ctx is fully populated).
|
||||
- **no local authz:** when P5 returns `{ok:false, status:401}` (foreign Origin / bad token / expired / aud-mismatch — all decided in P5), the adapter returns that status **verbatim** and opens **no** stream; the adapter contains no Origin/token/rights/cross-tenant branch of its own (assert by construction + coverage: every reject status originates from the `authorizer` mock).
|
||||
- **reattach:** `req.sessionId` present → `authorizer.onReattach` is called (not `onUpgrade`) with `{...ctx, sessionId}`; its `{ok:false}` maps straight through.
|
||||
- **token transport (FIX 5/INV15):** a token in `Sec-WebSocket-Protocol` (`['term.relay.v1','term.token.<b64u>']`) is extracted via `extractTokenFromSubprotocols` and forwarded as `ctx.capabilityRaw`, and `acceptedSubprotocol === 'term.relay.v1'` (the `term.token.` entry is **never** echoed); a token in `cookies['term_cap']` is accepted; **a token placed ONLY in the query string (`req.url = '/term?cap=<token>'`) → 401 and `authorizer.onUpgrade` is NEVER called** (spy count 0) — `extractCapabilityToken` never reads `req.url`, so no ctx is even built.
|
||||
- **cross-tenant is P5's (INV1):** the adapter forwards `requestedHostId = resolved.hostId` and the raw token; the 403 for `token.host !== hostId` is asserted at P5's `onUpgrade` (its own test), **not** re-implemented here — T8 only asserts the status is passed through.
|
||||
- **local parse failures only:** unparseable Host → 401 without calling the authorizer; unknown subdomain (`resolver` null) → 403 without calling the authorizer (nothing to authorize).
|
||||
2. **GREEN** implement thin: parse → resolve → build ctx → `await authorizer.onUpgrade/onReattach` → map. `hash` = HMAC-SHA256(salt, ip) truncated (audit-only, INV10). No decision branches beyond the three local parse guards above.
|
||||
- **Security note**: FIX 6a makes this the **adapter**, not the gate — the "single hardest gate in P1" is now P5's `onUpgrade`/`onReattach`, so the decision cannot drift between two implementations. The adapter still enforces the FIX-5 transport (token only via subprotocol/cookie, never query; echo only `APP_SUBPROTOCOL`) and derives identity only from the signed token + registry via P5 (INV3). **Logging discipline (INV9):** the edge MUST NOT log `req.url`, the `Authorization` header, or the `Sec-WebSocket-Protocol` value (each can carry the bearer-equivalent token); it logs only `{subdomain, hostId, jti, decision, ts}`. P5 owns the *permanent CI cross-tenant tripwire*; T13 ships P1's data-plane-level assertion that the adapter never overrides a P5 deny.
|
||||
- **Accept**: `npx vitest run upgrade` green; adapter has **zero** authz branches (delegation-only), verified by the "every reject originates from the authorizer mock" assertion.
|
||||
|
||||
#### T9 · Agent tunnel listener `[ ]` — v0.9 **(INV4/INV7/INV12/INV14 surface)**
|
||||
- **Owns**: `term-relay/data-plane/agent-listener.ts`, `term-relay/test/agent-listener.test.ts`
|
||||
- **Depends**: T6 (MuxSession), T1 (config). **Cross-plan**: P5 `MtlsVerifier` (§ INV14), P3 `RouteRegistrar` (§4.2 `route:{host_id}`). · **Parallel-safe**: T8, T11.
|
||||
- **Contract**:
|
||||
```ts
|
||||
export interface MtlsVerifier { // P5 impl — verifies the peer cert against the host registry pubkey
|
||||
verifyPeer(peerCert: Uint8Array): { hostId: string; accountId: string } | null
|
||||
}
|
||||
export interface RouteRegistrar { // P3 impl — Redis route:{host_id} with heartbeat TTL (INV7)
|
||||
register(hostId: string, relayNodeId: string, ttlMs: number): Promise<void>
|
||||
heartbeat(hostId: string): Promise<void>
|
||||
deregister(hostId: string): Promise<void>
|
||||
}
|
||||
export interface AgentTunnel { readonly hostId: string; readonly session: MuxSession; closeTunnel(): void }
|
||||
export function createAgentListener(deps: {
|
||||
config: DataPlaneConfig; mtls: MtlsVerifier; registrar: RouteRegistrar
|
||||
onTunnel(t: AgentTunnel): void
|
||||
}): { attach(ws: WebSocketLike, peerCert: Uint8Array): void; tunnels(): ReadonlyMap<string, AgentTunnel> }
|
||||
```
|
||||
- **TLS-layer contract (two-tier defense, Finding-4):** the agent-facing TLS server on `agentBindPort`
|
||||
MUST be constructed with **`requestCert: true` AND `rejectUnauthorized: true`**, `ca` loaded from
|
||||
`config.agentCaCertPath` (+ `agentCaChainPath`). This makes the TLS handshake itself reject any
|
||||
connection that presents **no** client cert or a cert not chaining to our CA — **before** `attach()`
|
||||
or `verifyPeer()` is ever called. `verifyPeer` then runs only on a cert that has **already passed
|
||||
CA-chain validation**, and does the app-layer bind (cert pubkey → registry `agent_pubkey`, INV14/INV4).
|
||||
This is the explicit guard against the "send-any-cert, we check it ourselves after the fact" downgrade:
|
||||
presentation is enforced at the TLS layer, identity binding at the app layer — never one without the other.
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `agent-listener.test.ts` (mock ws + mtls + registrar):
|
||||
- valid peer cert → `MuxSession` created, `registrar.register(hostId, nodeId, routeTtlMs)` called, tunnel indexed by `hostId`.
|
||||
- **TLS-layer enforcement (Finding-4):** a TLS handshake with **no client cert presented** is rejected at the TLS layer (`rejectUnauthorized`) — `attach()`/`verifyPeer()` are **never** invoked; assert the `attach` spy count is 0. Likewise a cert not chaining to `config.agentCaCertPath` → TLS reject, `attach` never called. Assert the TLS server options passed to the server factory are `{ requestCert: true, rejectUnauthorized: true, ca: <from agentCaCertPath/agentCaChainPath> }`.
|
||||
- **invalid/expired cert (`verifyPeer` → null) → the ws is closed, NO route registered** (INV14 — CA never binds an unknown pubkey; no shared secret path, INV4). (This is the app-layer check that runs *after* a cert has already passed CA-chain validation, e.g. cert chains to our CA but its pubkey is not in the host registry.)
|
||||
- heartbeat: every `heartbeatIntervalMs`, `registrar.heartbeat(hostId)` is called; on `onDead`/ws-close, `registrar.deregister(hostId)` runs and the tunnel is removed (**stateless — nothing durable survives**, INV7).
|
||||
- **revocation (INV12):** `closeTunnel()` tears down the ws + deregisters within the test tick.
|
||||
- reconnection: a second `attach` for the same `hostId` (agent reconnected on this node) replaces the prior tunnel and re-registers (latest wins; old one deregistered).
|
||||
2. **GREEN** implement; the node holds only an in-RAM `Map<hostId, AgentTunnel>` — **no DB of record** (INV7). Build the TLS server with `requestCert: true`, `rejectUnauthorized: true`, `ca` from the config CA paths; `attach` is only ever reached for a TLS-validated peer cert.
|
||||
- **Security note (INV4/INV7/INV12/INV14)**: agent auth is **mTLS only**, enforced at **two tiers** — TLS-layer `requestCert`/`rejectUnauthorized` against `config.agentCaCertPath` guarantees a cert was presented *and* chains to our CA, then P5's `verifyPeer` binds that cert's pubkey to the registry `agent_pubkey`. No bearer/shared secret is ever accepted here; there is no code path that reaches `attach()` for an unauthenticated or no-cert peer. A node crash loses only the in-RAM map; agents reconnect and re-register (P3). `closeTunnel` is the revocation lever P5/P3 call to drop a live tunnel in seconds.
|
||||
- **Accept**: `npx vitest run agent-listener` green.
|
||||
|
||||
#### T10 · Stateless relay node + opaque splice `[ ]` — v0.9 **(INV2/INV5/INV7/INV11 core)**
|
||||
- **Owns**: `term-relay/data-plane/relay-node.ts`, `term-relay/test/relay-node.test.ts`
|
||||
- **Depends**: T6, T8, T9, T1. · **Parallel-safe**: T11 (disjoint file).
|
||||
- **Contract**:
|
||||
```ts
|
||||
export interface RelayNode {
|
||||
handleAgentTunnel(ws: WebSocketLike, peerCert: Uint8Array): void
|
||||
handleBrowserUpgrade(req: UpgradeRequest, ws: WebSocketLike): Promise<void> // authorize → openStream → splice
|
||||
drain(reason: number): Promise<void> // DRAIN_REASON value; delegates to T11
|
||||
closeTunnel(hostId: string): void // WHOLE-host revocation lever (P5/P3) — kicks every device
|
||||
// SINGLE-device revocation (Finding-3): RST exactly the stream(s) opened under one capability token,
|
||||
// leaving every other device on the same host attached. Driven by P5's revoked:{jti} list (§4.2).
|
||||
// Returns the number of streams RST (0 if none matched — deny-by-default: unknown selector is a no-op).
|
||||
closeStream(hostId: string, selector: { readonly jti: string } | { readonly streamId: number }): number
|
||||
}
|
||||
export function createRelayNode(deps: {
|
||||
config: DataPlaneConfig; listener: ReturnType<typeof createAgentListener>
|
||||
authorize: (req: UpgradeRequest) => Promise<UpgradeDecision> // = authorizeUpgrade bound to deps
|
||||
}): RelayNode
|
||||
```
|
||||
- **Splice (the byte hot-loop) — OPAQUE, zero parsing**:
|
||||
```
|
||||
decision = await authorize(req); if !decision.ok → ws.close(decision.status) (INV6/INV15)
|
||||
tunnel = listener.tunnels().get(decision.hostId); if !tunnel → ws.close(1013 /*try later*/)
|
||||
stream = tunnel.session.openStream(decision.open) // relay allocates streamId
|
||||
splices.add({ hostId: decision.hostId, streamId: stream.streamId, // per-jti index for closeStream (Finding-3)
|
||||
jti: decision.open.capabilityTokenRef, ws, stream })
|
||||
ws.onMessage(bytes => stream.writeData(bytes)) // browser → agent : verbatim Uint8Array (INV2)
|
||||
stream via session.onData(id, bytes) → ws.send(bytes) // agent → browser : verbatim (INV2)
|
||||
ws.onClose(() => { stream.close(); splices.remove(...) })
|
||||
stream.onClose(() => { ws.close(); splices.remove(...) })
|
||||
```
|
||||
Buffers are **per-connection** (each `ws`↔`stream` pair) — **no global mutable buffer pool** (INV5, kills cross-tenant buffer bleed). The node never decodes DATA (INV2/INV11): a byte is a byte.
|
||||
- **`closeStream` (single-device revocation, Finding-3/INV12)**: the node maintains an in-RAM splice
|
||||
index (`splices`) mapping `hostId → jti → {streamId, ws, stream}` (built at splice time above, torn down
|
||||
on close — still **stateless**, in-RAM only, INV7). `closeStream(hostId, {jti})` looks up every splice
|
||||
for that host+`jti`, calls `stream.close(/*rst*/ true)` (RSTs just those streams via the mux, tunnel
|
||||
stays up) and `ws.close(4403)` on each, removes them from the index, and returns the count. `{streamId}`
|
||||
is the same by exact stream. An unmatched selector RSTs nothing and returns 0 (deny-by-default no-op —
|
||||
never falls back to a broader kill). This is the primitive P5 calls when it adds a `jti` to
|
||||
`revoked:{jti}` (§4.2) to revoke **one shared device** while the legitimate owner's other devices
|
||||
(different `jti`s, latest-writer-wins sharing) stay attached.
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `relay-node.test.ts` (in-memory ws pairs + a fake agent MuxSession):
|
||||
- browser upgrade authorized → a stream opens to the correct `hostId`'s tunnel; bytes typed at the browser arrive **byte-identical** at the agent side and vice-versa.
|
||||
- **INV2 ciphertext marker:** push a unique marker through; assert the relay-node code never inspects it and the marker is not retained in any node-level structure after CLOSE.
|
||||
- unauthorized upgrade (decision 401/403) → ws closed with the status; **no** stream opened, **no** tunnel touched.
|
||||
- no tunnel for the host (agent offline) → ws closed (1013), no crash.
|
||||
- **INV7 crash-survival:** drop the node's in-RAM maps (simulate crash) → no persisted state; a fresh node with a reconnected tunnel serves new upgrades; in-flight browser ws just reconnect (base-app backoff).
|
||||
- **INV1 isolation at the node:** an upgrade authorized for `hostA` can only ever reach `hostA`'s tunnel — there is no API to reach a tunnel by index/address; lookup is strictly `decision.hostId` (from the token, INV3).
|
||||
- **single-device revocation (Finding-3/INV12):** open two browser splices on the same host under two different `jti`s (`jtiA`, `jtiB` — the base-app multi-device sharing case); `closeStream(hostId, {jti: jtiA})` RSTs **only** `jtiA`'s stream and closes its ws (code 4403), returns `1`, while `jtiB`'s splice keeps flowing bytes bidirectionally (the legitimate owner's other device stays attached). Two splices under the **same** `jti` → both RST, returns `2`. `closeStream(hostId, {jti: 'unknown'})` → returns `0`, RSTs nothing (deny-by-default no-op, never widens to a whole-host kill). After a ws/stream closes normally, its splice-index entry is gone so a later `closeStream` for its `jti` returns `0` (no stale handle).
|
||||
2. **GREEN** implement thin; keep the file ≤ 300 lines (extract the splice helper / splice-index into a small module if needed). The splice index is in-RAM only (INV7).
|
||||
- **Security note (INV2/INV5/INV7/INV11/INV12)**: the node is a **ciphertext-shuttle** — opaque splice, per-connection buffers, no durable state, no terminal parser. `closeStream` gives P5 a **surgical** revocation lever (RST exactly the streams under one revoked `jti`) so revoking a single compromised/shared device does **not** kick the owner's other devices — removing the "nuke everyone or nothing" false choice that would otherwise delay real revocation. This is the load-bearing "byte-shuttle → ciphertext-shuttle" upgrade.
|
||||
- **Accept**: `npx vitest run relay-node` green.
|
||||
|
||||
#### T11 · Graceful drain + GOAWAY `[ ]` — v0.9 **(INV7/INV12)**
|
||||
- **Owns**: `term-relay/data-plane/drain.ts`, `term-relay/test/drain.test.ts`
|
||||
- **Depends**: T6 (GOAWAY), T9 (tunnels). · **Parallel-safe**: T10 (disjoint file).
|
||||
- **Contract**:
|
||||
```ts
|
||||
export const DRAIN_REASON = { operatorDrain: 1, revoked: 2, shutdown: 3 } as const
|
||||
export type DrainReason = typeof DRAIN_REASON[keyof typeof DRAIN_REASON]
|
||||
export function drainNode(deps: {
|
||||
tunnels(): ReadonlyMap<string, AgentTunnel>
|
||||
registrar: RouteRegistrar
|
||||
reason: DrainReason
|
||||
inFlightGraceMs: number // HONORED ONLY for operatorDrain/shutdown; FORCED to 0 for revoked (Finding-2)
|
||||
}): Promise<void>
|
||||
export function drainHost(hostId: string, deps: {
|
||||
tunnels(): ReadonlyMap<string, AgentTunnel>
|
||||
registrar: RouteRegistrar
|
||||
reason: DrainReason
|
||||
inFlightGraceMs: number // HONORED ONLY for operatorDrain/shutdown; FORCED to 0 for revoked
|
||||
}): Promise<void> // single-host (revocation / operator drain)
|
||||
```
|
||||
- **Grace-window rule (reason-differentiated, Finding-2/INV12):** the effective grace is
|
||||
`effectiveGraceMs = (reason === DRAIN_REASON.revoked) ? 0 : inFlightGraceMs`. A nonzero
|
||||
`inFlightGraceMs` is a *convenience for graceful operator drains/shutdowns only* (let an in-flight
|
||||
vim/paste flush before moving the host to another node). **`reason=revoked` is a security action against
|
||||
a compromised/malicious host or device** — it MUST force immediate teardown regardless of any caller-
|
||||
supplied `inFlightGraceMs`: RST every stream now, close the tunnel now, `registrar.deregister` now. This
|
||||
is what makes INV12's testable assertion ("its live tunnel drops within seconds") hold even when the
|
||||
operator-drain grace is tuned to tens of seconds.
|
||||
- **Steps (TDD)**:
|
||||
1. **RED**:
|
||||
- `drainNode` with `reason=operatorDrain` sends **GOAWAY(lastStreamId, reason)** on streamId 0 to every tunnel; existing streams are allowed to finish within `inFlightGraceMs`, then tunnels close and routes are deregistered (P3). New `openStream` after GOAWAY is refused.
|
||||
- **`drainHost(hostId, {reason: DRAIN_REASON.revoked, inFlightGraceMs: N})` tears down all of that host's streams IMMEDIATELY regardless of N** (assert with a large N, e.g. 30_000): every stream is RST, the tunnel closes, and `registrar.deregister(hostId)` runs within the test tick — the grace window is **never** waited on. Same for `drainNode` with `reason=revoked` across all tunnels.
|
||||
- `drainHost` targets exactly one host and leaves siblings running (whole-host revocation, INV12).
|
||||
2. **GREEN** implement: compute `effectiveGraceMs` from `reason` before scheduling teardown; deregister via `registrar.deregister` so the ingress stops routing to this node/host.
|
||||
- **Security note (INV12)**: `drainHost` is the whole-host revocation path — for `reason=revoked` it is GOAWAY + **immediate** RST + close + deregister (zero grace), dropping a compromised host's live tunnel within seconds; `operatorDrain`/`shutdown` keep a grace window purely for clean migration. **The customer's PTY survives** on their machine (base-app PTY≠WS decoupling); a relay-node bounce is invisible to the running Claude Code task (EXPLORE §3). For revoking **one shared device without kicking the others**, use T10's `RelayNode.closeStream` (per-`jti`), not a whole-host drain.
|
||||
- **Accept**: `npx vitest run drain` green.
|
||||
|
||||
#### T14 · Revocation subscriber — `relay:revocations` → §4.1 teardown `[ ]` — v0.9 **(INV12 data-plane teardown owner, FIX 4)**
|
||||
> **FIX 4 — one control→data-plane teardown contract.** Fast revocation must tear down an **already-open**
|
||||
> tunnel, not merely refuse the next connect. The frozen §4.2 bus (`RELAY_REVOCATIONS_CHANNEL` +
|
||||
> `KillSignal`/`RevocationScope`, promoted into `relay-contracts` so P1 does **not** import P5 — DAG stays
|
||||
> acyclic) is the **single** named channel: **P3/P5 PUBLISH, every P1 node SUBSCRIBES**. This task is P1's
|
||||
> subscriber — the data-plane end of INV12. It injects **no new wire type**: it maps a `KillSignal` onto the
|
||||
> existing §4.1 `CLOSE`+`flags.RST` (per stream) or link-level `GOAWAY` (global) via T10/T11's levers.
|
||||
- **Owns**: `term-relay/data-plane/revocation-subscriber.ts`, `term-relay/test/revocation-subscriber.test.ts`
|
||||
- **Depends**: T10 (`RelayNode.closeStream`/`closeTunnel`), T11 (`drainHost`/`drainNode`). **Cross-plan**: subscribes to a Redis pub/sub client (injected); the publisher is **P5** `revoke()` / **P3** (§4.2). · **Parallel-safe**: none in W3 (consumes T10+T11); runs after both freeze.
|
||||
- **Contract** (frozen §4.2 types imported from `relay-contracts`, never redefined):
|
||||
```ts
|
||||
import type { KillSignal, RevocationScope } from 'relay-contracts'
|
||||
import { RELAY_REVOCATIONS_CHANNEL, REVOCATION_PUSH_BUDGET_MS } from 'relay-contracts' // 'relay:revocations', 2000
|
||||
|
||||
export interface RevocationSubscriberDeps {
|
||||
subscribe(channel: string, onMessage: (raw: string) => void): { close(): void } // injected Redis pub/sub
|
||||
node: Pick<RelayNode, 'closeTunnel'> // whole-host lever (T10)
|
||||
drainHost(hostId: string, reason: typeof DRAIN_REASON.revoked): Promise<void> // immediate host teardown (T11)
|
||||
tunnels(): ReadonlyMap<string, AgentTunnel> // to enumerate hosts for account/global scope
|
||||
routeResolver: RouteResolver // account → hostIds this node currently serves
|
||||
now: () => number
|
||||
onApplied?(signal: KillSignal, hostsAffected: number, elapsedMs: number): void // budget/audit metadata only (INV10)
|
||||
}
|
||||
// Pure scope→hosts selector (testable in isolation): which of THIS node's live hosts a signal hits.
|
||||
export function hostsForScope(scope: RevocationScope, liveHosts: ReadonlyMap<string, AgentTunnel>,
|
||||
accountOf: (hostId: string) => string): readonly string[]
|
||||
export function startRevocationSubscriber(deps: RevocationSubscriberDeps): { close(): void }
|
||||
```
|
||||
- **Mapping (KillSignal → §4.1 teardown, no new frame type)**:
|
||||
```
|
||||
on message on RELAY_REVOCATIONS_CHANNEL:
|
||||
signal = parseKillSignal(raw) // Zod-validate at the boundary; malformed → drop + count (no throw across the bus)
|
||||
switch signal.scope.kind:
|
||||
'host' → drainHost(scope.hostId, DRAIN_REASON.revoked) // GOAWAY? no — RST every stream now + close tunnel + deregister (T11, grace forced 0)
|
||||
'account' → for hostId in hostsForScope(scope, tunnels(), accountOf): drainHost(hostId, revoked)
|
||||
'global' → for every tunnel: drainHost(hostId, revoked) // link-level GOAWAY + RST all (node-wide)
|
||||
onApplied?(signal, hostsAffected, now()-startedAt) // metadata only; MUST NOT log the reason string as payload (INV10)
|
||||
```
|
||||
Every path uses **T11's `reason=revoked` immediate teardown (grace forced to 0)** so the §4.2
|
||||
`REVOCATION_PUSH_BUDGET_MS = 2000` budget is met. An unknown/absent host (already gone) is a **no-op**
|
||||
(deny-by-default: nothing to tear down, never a broader kill).
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `revocation-subscriber.test.ts` (fake pub/sub + spy `drainHost`/`closeTunnel`, in-RAM tunnels):
|
||||
- a `{scope:{kind:'host',hostId:H}}` message → `drainHost(H, revoked)` called once **within `REVOCATION_PUSH_BUDGET_MS`** (assert with a fake clock); a host **not** on this node → no-op (0 calls).
|
||||
- `{scope:{kind:'account',accountId:A}}` → exactly the node's live hosts owned by A are torn down; a sibling host of another account keeps flowing (INV1 blast-radius contained to the account).
|
||||
- `{scope:{kind:'global'}}` → every live tunnel torn down (node-wide GOAWAY+RST).
|
||||
- **immediacy (INV12):** teardown does **not** wait any grace — assert `drainHost` is invoked with `reason=revoked` (grace forced to 0 per T11), even if the node's operator-drain grace is large.
|
||||
- **malformed signal** (bad JSON / missing `scope`) → dropped, counted, **no** teardown fired, subscriber stays alive (a poisoned message can't kill the bus or trigger a spurious kill).
|
||||
- **no payload leak (INV10):** `signal.reason` is never written to a data structure or log as content; only `{scope, hostsAffected, elapsedMs}` metadata is emitted via `onApplied`.
|
||||
- `close()` unsubscribes cleanly (no teardown after close).
|
||||
2. **GREEN** implement thin: `subscribe(RELAY_REVOCATIONS_CHANNEL, …)`, Zod-parse, `hostsForScope`, fan out to `drainHost(…, DRAIN_REASON.revoked)`. `hostsForScope` is a pure function (unit-tested standalone).
|
||||
- **Security note (INV12/INV1/INV10)**: this closes the FIX-4 gap — P5/P3 publish a `KillSignal`, **every** P1 node reacts by RST-ing exactly the in-scope streams and dropping the tunnels **immediately** (grace 0), so a compromised host/account is off the data plane within the 2s budget **even for connections already open**. Scope selection is blast-radius-bounded (`host` ⊂ `account` ⊂ `global`); a host this node doesn't serve is a no-op (no cross-tenant reach, INV1). The `reason` string is metadata only — never forwarded as terminal payload (INV10). P1 **subscribes only**; it never publishes (P5 stays the sole revocation authority). **Single-device (per-`jti`) revocation** is a finer granularity than the frozen `KillSignal` scope expresses — see §9 OQ6.
|
||||
- **Accept**: `npx vitest run revocation-subscriber` green; teardown latency assertion ≤ `REVOCATION_PUSH_BUDGET_MS`.
|
||||
|
||||
### W1-parallel lane — v0.8 scaffold
|
||||
|
||||
#### T12 · frp MVP scaffold (v0.8 stepping-stone) `[ ]` — v0.8
|
||||
- **Owns**: `term-relay/frp-scaffold/frps.toml`, `term-relay/frp-scaffold/README.md`, `term-relay/frp-scaffold/plugin-hook.ts`, `term-relay/test/plugin-hook.test.ts`
|
||||
- **Depends**: none (independent lane). **Cross-plan**: P3 exposes an authz hook endpoint; P2 wraps `frpc` (agent side).
|
||||
- **Deliverables**:
|
||||
- `frps.toml`: single-node `frps` on the VPS, **wildcard vhost** so `*.term.<domain>` routes by subdomain to the agent's proxy (EXPLORE §5.4); TLS terminated here or at Cloudflare in front (M6 scheme-following preserved end-to-end); `vhostHTTPSPort`/`subdomainHost` set to `BASE_DOMAIN`.
|
||||
- `plugin-hook.ts`: a **thin** HTTP server-plugin shim implementing frp's `Login`/`NewProxy`/`NewUserConn` hooks by **delegating** the decision to P3's control-plane authz endpoint (checks `agentToken`/`clientToken` hashes, subdomain ownership). **No tenancy logic lives here** — it forwards and enforces the deny-by-default answer.
|
||||
- `README.md`: the café-demo runbook — VPS + wildcard DNS (`*.term.<domain>`) + wildcard TLS (LetsEncrypt DNS-01) + `frps` + the P2 `frpc`-wrapped agent; **plus an explicit "retirement" section**: what each frp piece maps to in the native mux (frp yamux → §4.1 mux; frp subdomain vhost → T7 router; frp login plugin → T8 upgrade gate) so v0.9 is a swap, not a rewrite.
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `plugin-hook.test.ts`: a `NewUserConn`/`Login` payload with an unknown token → hook returns **reject** (deny-by-default); a valid one whose subdomain the control plane owns → allow; the hook **forwards** to P3 and never decides tenancy itself; malformed payload (Zod) → reject.
|
||||
2. **GREEN** implement the shim (Zod-validate the frp hook body at the boundary).
|
||||
- **Security note**: even the v0.8 scaffold keeps auth at the relay edge (the auth the base app never had, EXPLORE §5.5). The shared `clientToken`/`agentToken` gate is a **known, temporary** shortcut (INDEX v0.8) — it is replaced by capability tokens (T8) + mTLS (T9) + Passkey/pairing (P2/P3/P5) in v0.9/v0.10; the frame path is already designed so ciphertext (P4) drops into DATA without reshaping (INV2). **No plaintext terminal bytes are parsed even in v0.8** — frp forwards opaque TCP/WS streams (INV11).
|
||||
- **Accept**: `npx vitest run plugin-hook` green; README runbook reproduces the café demo on one VPS.
|
||||
|
||||
### W4 — invariant tripwires & integration
|
||||
|
||||
#### T13 · P1 invariant tripwires + integration `[ ]` — v0.9
|
||||
- **Owns**: `term-relay/test/inv-tripwires.test.ts`, `term-relay/test/integration/data-plane.test.ts`
|
||||
- **Depends**: T2–T11, T14. · **Parallel-safe**: none (integration convergence, like PLAN §W4).
|
||||
- **Steps (tests)**:
|
||||
- **INV11 static:** assert the `term-relay/mux/**` and `term-relay/data-plane/**` dependency graph imports **no** xterm/ANSI/terminal parser (scan `import`s; fail if any match `/xterm|ansi|vt100|terminal-parser/i`).
|
||||
- **INV2 ciphertext:** end-to-end through a real `MuxSession` pair + `relay-node` splice, inject a unique plaintext marker; assert it appears **only** as opaque bytes in transit and is absent from every relay-side retained structure and from logs.
|
||||
- **INV1 cross-tenant (data-plane assertion, complements P5's permanent CI tripwire):** device authorized for host A → 403 when hitting host B's subdomain, on first connect **and** on reconnect/reattach.
|
||||
- **INV7 stateless:** kill a node mid-stream → no data at rest; reconnected tunnel on a new node serves fresh upgrades; PTY (base app) unaffected.
|
||||
- **INV6 deny-by-default (FIX 6a):** the `authorizeUpgrade` adapter opens a stream **only** on P5's `{ok:true}`; every P5 `{ok:false}` and every local parse-guard failure returns 401/403 with no stream opened — assert the adapter has no independent allow branch (a P5 deny is never overridden into an allow).
|
||||
- **INV13 ordering:** DATA frames are delivered in order and never reordered/deduped by the relay (so P4's `seq` holds); an illegal transition RSTs one stream, not the tunnel.
|
||||
- **INV12 revocation (Finding-2/Finding-3 + FIX 4):** end-to-end, a `KillSignal{scope:{kind:'host',hostId}}` published on `relay:revocations` is consumed by **T14** and tears down that host's live tunnel within `REVOCATION_PUSH_BUDGET_MS` (already-open streams, not just the next connect); an `account` scope tears down only that account's hosts on the node (siblings keep flowing, INV1); `closeStream(hostId, {jti})` drops exactly one shared device's stream while a sibling device (different `jti`) keeps flowing; `drainHost(hostId, {reason: revoked, inFlightGraceMs: large})` tears down the whole host **without** waiting the grace window (immediate), whereas `reason: operatorDrain` honors it.
|
||||
- **INV15 token transport (Finding-1):** a browser upgrade carrying the capability token **only** in the query string is rejected (401) end-to-end; a token via `Sec-WebSocket-Protocol` (or `HttpOnly` cookie) is accepted; assert no test log line contains the raw token, `req.url`, or the `Sec-WebSocket-Protocol` value (INV9 access-log discipline).
|
||||
- **INV14 mTLS presentation (Finding-4):** a simulated agent handshake with no client cert (or a cert not chaining to `agentCaCertPath`) is rejected at the TLS layer — `attach()`/`verifyPeer()` are never reached.
|
||||
- **Accept**: `npx vitest run` (all `term-relay/` P1 tests) green; coverage ≥ 80% across mux + data-plane (`coding-style`/`testing.md`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Security section (P1)
|
||||
|
||||
**Threat model:** the relay bridges full interactive shells for many tenants; a data-plane compromise
|
||||
is worst-case RCE-shaped (EXPLORE §4). P1's job is to keep the node a **dumb ciphertext-shuttle** so it
|
||||
*cannot* leak or inject what it *cannot* parse, and to make cross-tenant reach structurally impossible.
|
||||
|
||||
- **Cross-tenant isolation (INV1)** — the only path to a host is `authorizeUpgrade`, which (FIX 6a) resolves
|
||||
the subdomain → `requestedHostId` and **delegates the `token.host == hostId` gate to P5's `onUpgrade`/`onReattach`**
|
||||
— the decision has exactly one owner, so it cannot drift between two implementations. No code resolves a host
|
||||
by raw address/port/user hostname. Host header is a hint, never authority (`aud` guard + nested-subdomain
|
||||
rejection, T7; P5 owns the token/`aud` check). `host_id` is an unguessable, never-recycled UUIDv4 (§4.2).
|
||||
Fuzzed `host_id` guessing → 403 (P5 `onUpgrade`; T13 asserts the adapter passes it through).
|
||||
- **Ciphertext-shuttle (INV2/INV11)** — DATA payloads are opaque `Uint8Array`; no module imports a
|
||||
terminal parser (static tripwire T13). E2E envelopes (§4.4) ride DATA unread; **no P1 change at v0.10**.
|
||||
- **Deny-by-default authz (INV6) + capability token on upgrade (INV15)** — the full decision (Origin/CSWSH
|
||||
retained **AND** §4.3 token required, default reject, least-privilege rights, DPoP, single-use burn, rate,
|
||||
step-up, audit) is **owned solely by P5** (`onUpgrade`/`onReattach`, FIX 6a). P1's `authorizeUpgrade` is a
|
||||
**thin adapter**: it extracts the token via the frozen §4.3 FIX-5 carriers (subprotocol/cookie, never query),
|
||||
builds P5's `UpgradeContext` (incl. `dpop` + `activeSessionCount`), and opens a stream only on P5's `{ok:true}`.
|
||||
- **`account_id`/`host_id` never client-supplied (INV3)** — identity derives only from the token +
|
||||
registry; `requestPath` query (`?join=…`) is opaque passthrough, never used for authz.
|
||||
- **No shared secrets / mTLS (INV4/INV14)** — the agent listener accepts only mTLS-verified peers
|
||||
(P5 checks the cert against the registry **public** key); no bearer/shared-secret branch exists in v0.9+.
|
||||
- **Stateless, no plaintext at rest (INV5/INV7)** — the node holds only in-RAM maps + per-connection
|
||||
buffers (no global pool → no buffer bleed); a crash persists nothing; P1 writes no shell bytes anywhere.
|
||||
- **Fast revocation (INV12), FIX 4 single channel** — every P1 node **subscribes** to the frozen §4.2
|
||||
`relay:revocations` bus (T14); **P3/P5 publish** a `KillSignal`. On a `host`/`account`-scoped signal the
|
||||
subscriber tears down the in-scope tunnel(s) via `drainHost(reason=revoked)` — RST every stream + close +
|
||||
deregister **immediately (grace forced to 0, Finding-2)**, within `REVOCATION_PUSH_BUDGET_MS` — and on
|
||||
`global` a node-wide GOAWAY+RST. `operatorDrain`/`shutdown` keep a grace window only for clean migration.
|
||||
Single-device (per-`jti`) `closeStream` remains a T10 lever but currently has no bus scope (§9 OQ6). P1
|
||||
**subscribes only, never publishes** (P5 stays sole revocation authority; P1 does not import P5). The
|
||||
customer's PTY survives locally in every case.
|
||||
- **Secrets (INV9)** — `loadDataPlaneConfig` fails fast on missing TLS server material, the
|
||||
`AGENT_CA_CERT_PATH` mTLS trust-anchor, or `BASE_DOMAIN`; **no `console.log`** in P1 (`code-review.md`);
|
||||
nothing secret is ever logged. **The capability token is bearer-equivalent**, so the data-plane edge
|
||||
access log excludes the raw upgrade `req.url`, the `Authorization` header, and the
|
||||
`Sec-WebSocket-Protocol` value — it records only `{subdomain, hostId, jti, decision, ts}` (Finding-1).
|
||||
- **Anti-replay/injection carrier (INV13)** — P1 preserves DATA ordering and RSTs illegal stream
|
||||
transitions, so P4's per-message nonce + monotonic `seq` are meaningful end-to-end.
|
||||
|
||||
**v0.8 shortcut, stated honestly:** the frp scaffold uses a shared `clientToken`/`agentToken` gate at
|
||||
the edge (INDEX v0.8). It is a **known temporary** posture, replaced by capability tokens + mTLS +
|
||||
Passkey/pairing before the first paid signup (EXPLORE §7). Even in v0.8, frp forwards opaque streams —
|
||||
**no terminal parsing** (INV11).
|
||||
|
||||
---
|
||||
|
||||
## 7. Verification
|
||||
|
||||
```bash
|
||||
# --- pure protocol leaves ---
|
||||
npx vitest run --dir term-relay frame-codec # §4.1 codec round-trip + adversarial/fuzz
|
||||
npx vitest run --dir term-relay stream # stream state machine (legal + illegal transitions)
|
||||
npx vitest run --dir term-relay flow-control # credit windows, per-stream isolation, deny-by-default
|
||||
npx vitest run --dir term-relay heartbeat # 15s PING/PONG, miss→dead, stale-token ignored
|
||||
|
||||
# --- mux + data plane ---
|
||||
npx vitest run --dir term-relay mux-session # multiplex, backpressure, RST-one-stream, frame ceiling
|
||||
npx vitest run --dir term-relay subdomain-router # extractSubdomain, Host-confusion rejection
|
||||
npx vitest run --dir term-relay upgrade # FIX 6a: adapter delegates to P5 onUpgrade/onReattach; FIX 5 token transport; zero local authz branches
|
||||
npx vitest run --dir term-relay agent-listener # INV4/INV7/INV14: mTLS-only, register/heartbeat/deregister
|
||||
npx vitest run --dir term-relay relay-node # INV2/INV5/INV7/INV11: opaque splice, per-conn buffers
|
||||
npx vitest run --dir term-relay drain # INV7/INV12: GOAWAY drain + per-host revocation
|
||||
npx vitest run --dir term-relay revocation-subscriber # INV12/FIX4: relay:revocations KillSignal → §4.1 CLOSE+RST/GOAWAY ≤ 2s
|
||||
|
||||
# --- v0.8 scaffold ---
|
||||
npx vitest run --dir term-relay plugin-hook # frp hook deny-by-default → delegates to P3
|
||||
|
||||
# --- invariant tripwires + integration + coverage ---
|
||||
npx vitest run --dir term-relay inv-tripwires # INV11 static import scan, INV2 marker, INV1 data-plane 403
|
||||
npx vitest run --dir term-relay integration # end-to-end data-plane splice through a MuxSession pair
|
||||
npx vitest run --dir term-relay --coverage # ≥ 80% across mux + data-plane (testing.md)
|
||||
cd term-relay && npx tsc --noEmit # types clean; relay-contracts imported read-only
|
||||
grep -rEi 'xterm|ansi|vt100|terminal-parser' term-relay/mux term-relay/data-plane && echo FAIL || echo OK # INV11
|
||||
```
|
||||
|
||||
**Manual / deployment verification (v0.8 café demo, T12):** one VPS, wildcard DNS `*.term.<domain>`,
|
||||
wildcard TLS (LetsEncrypt DNS-01 or Cloudflare in front), `frps` running, a P2 `frpc`-wrapped agent
|
||||
paired on a laptop. From a phone browser open `alice.term.<domain>`, pass the `clientToken` gate, land
|
||||
in the laptop shell — **no router/VPN/static-IP configured**. Confirm: (1) a relay-node restart does
|
||||
**not** kill the running shell (PTY survives, INV7); (2) a foreign-Origin page cannot open the WS
|
||||
(CSWSH retained); (3) all 212 base-app tests still pass (`npm test` in repo root) — the relay adds a
|
||||
layer *underneath* the existing WebSocket, it does not modify `src/`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Phasing summary
|
||||
|
||||
| Phase | P1 tasks | Deliverable |
|
||||
|---|---|---|
|
||||
| **v0.8 MVP** | T1 (config), T12 (frp scaffold), **T2 spec-written** | Café demo on frp; §4.1 frame format authored (not yet the substrate); auth at the relay edge (shared-token shortcut). |
|
||||
| **v0.9 SaaS** | T2–T11, **T14**, T13 | Native WS mux **replaces frp**: §4.1 as the real substrate, per-stream flow control, 15s heartbeat, reconnection, GOAWAY drain, stateless node, subdomain routing by authenticated session, **upgrade gate as a thin adapter delegating to P5 `onUpgrade`/`onReattach` (FIX 6a)**, mTLS agent listener, opaque splice, and the **`relay:revocations` subscriber that turns a `KillSignal` into immediate §4.1 teardown (FIX 4)**. |
|
||||
| **v0.10 E2E-hardening** | *(no P1 code change)* | DATA already carries P4's `E2EEnvelope` opaquely (INV2) — the frame path was designed so ciphertext drops in without reshaping. P1 only re-runs its INV2/INV13 tripwires against the E2E build. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions (for the orchestrator — do NOT guess, per CLAUDE.md subagent rule)
|
||||
|
||||
1. **CBOR dependency for typed control-frame payloads (OPEN/WINDOW_UPDATE/GOAWAY).** §4.1 says OPEN is
|
||||
"CBOR of MuxOpen". Confirm the shared CBOR lib choice (`cbor-x`?) and whether it belongs in
|
||||
`relay-contracts/` (so P2 agent + P1 relay share one encoder) or is P1-owned in `mux/`. Leaning:
|
||||
put the CBOR codec in `relay-contracts/` next to the frozen types so both sides import one impl.
|
||||
2. **Where `encodeMuxFrame`/`decodeMuxFrame` physically live.** §4.1 lists the signatures under "P1
|
||||
owns" but the *types* are frozen in `relay-contracts/`. This plan puts the **binary codec impl** in
|
||||
`term-relay/mux/frame-codec.ts` (P1) and has P2 import it. Confirm P2 importing from `term-relay/mux/`
|
||||
is acceptable, or whether the codec should move into `relay-contracts/` to keep the agent from
|
||||
depending on the relay package. (Either is contract-compatible; it's a packaging call.)
|
||||
3. **TLS termination boundary.** EXPLORE §3 allows terminating TLS at the relay **or** at Cloudflare in
|
||||
front. `DataPlaneConfig` carries cert/key paths for in-process termination; confirm the default
|
||||
deployment (in-process LetsEncrypt vs CF-in-front) so the T12 runbook picks one as primary.
|
||||
4. **Ingress vs in-process routing at scale.** This plan keeps routing in-process on a single node
|
||||
(v0.8/v0.9, YAGNI per EXPLORE §7 v0.11). Confirm the Redis-routing-table + L7-ingress (Envoy/Traefik)
|
||||
split is deferred to a later multi-node plan and not P1's responsibility now.
|
||||
5. **`AuthzOutcome` surfaces `jti` — RESOLVED (reconcile OQ5).** T8 is a thin adapter that no longer verifies
|
||||
the capability token (P5's `onUpgrade` does), so it cannot read `token.jti` itself — but `MuxOpen.capabilityTokenRef`
|
||||
(used by T10's per-`jti` splice index for single-device `closeStream`) needs it. P5 now adds `readonly jti: string`
|
||||
to the `ok` branch of `AuthzOutcome` (PLAN_RELAY_AUTH_ISOLATION.md T3), surfacing the *verified* token's jti; P1
|
||||
consumes `outcome.jti` read-only for `capabilityTokenRef`. No local redefinition of `AuthzOutcome` in P1.
|
||||
6. **Single-device (per-`jti`) revocation vs the frozen `KillSignal` scope.** FIX 4 froze `RevocationScope` as
|
||||
`host | account | global` only — it has **no per-device/per-`jti` granularity**. But INV12 and T10's
|
||||
`closeStream(hostId, {jti})` still require revoking **one shared device while the owner's other devices stay
|
||||
attached**. The `relay:revocations` bus therefore cannot currently express single-device revocation; T14 only
|
||||
covers host/account/global. **Ask the INDEX owner** whether to (a) add a `{kind:'device'; hostId; jti}` arm to
|
||||
`RevocationScope` (then T14 routes it to `closeStream`), or (b) keep single-device revocation on a separate
|
||||
per-`jti` mechanism. Pending that decision, T10's `closeStream` remains but has no bus trigger. **Do NOT extend
|
||||
`RevocationScope` locally** — it must change at the INDEX coordination point (§4.2).
|
||||
Reference in New Issue
Block a user