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.
98 lines
7.1 KiB
Markdown
98 lines
7.1 KiB
Markdown
# 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).
|