Files
web-terminal/docs/PLAN_RELAY_RECONCILE.md
Yaojia Wang 2af57e6686 feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)
Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.

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

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

NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
2026-07-02 06:10:16 +02:00

98 lines
7.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# 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 (48 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).