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

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

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

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

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

View File

@@ -0,0 +1,144 @@
/**
* Host E2E endpoint (§4.4) — PLAN_RELAY_AGENT T15. The HOST side of the authenticated ECDH: absorb
* ClientHello → verify the device-auth-proof FIRST → reply HostHello → derive DirectionalKeys →
* per-stream seal(h2c)/open(c2h). Distinct from live frames, every replay-bound output is ALSO
* sealed under K_content via the T19 ReplaySealer (FIX 3).
*
* ANTI-MITM (INV2): the device-auth-proof `verifyDeviceProof` is P5-OWNED and reaches this module
* INJECTED (FIX 6b) — this file MUST NOT `import { verifyDeviceAuthProof } from 'relay-e2e'`. The
* proof is verified BEFORE any key derivation; a forged proof ⇒ MitmAbortError, NO HostHello, NO
* DirectionalKeys. A no-stub guard test asserts this file imports no verifier and that swapping the
* injected verifier for `async () => true` makes the MITM test fail.
*
* BLOCKING GATE (open Q#2 — DEFERRED): the real §4.4 crypto (`sealFrame`/`openFrame`/
* `createE2ESession`) + host-handshake wiring (`createHostHandshake`) live in P4 `relay-e2e/`, and
* the P5 verifier + forged-proof vector are a hard pre-W4 gate. P4 is NOT built yet, so those are
* INJECTED here as seams typed to the frozen relay-contracts signatures — production passes the
* relay-e2e impls verbatim, NEVER a stub. INV11: even after open(), bytes go to loopback OPAQUE.
*/
import type {
ClientHello,
E2ESession,
HandshakeResult,
HostHello,
} from 'relay-contracts'
import type { AgentIdentity } from '../keys/identity.js'
import type { FrameTransform } from '../transport/streamRouter.js'
import type { ReplaySealer } from './replaySeal.js'
/** Host-side device-proof verifier — INJECTED (P5 issues+verifies), bound to the ClientHello. */
export type VerifyDeviceProof = (
proof: string,
binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array },
) => Promise<boolean>
export class MitmAbortError extends Error {
constructor(message: string) {
super(message)
this.name = 'MitmAbortError'
}
}
/** P4 host-handshake wiring seam (`createHostHandshake`), typed to §4.4 shapes. */
export interface HostHandshake {
respond(clientHello: ClientHello): Promise<{ hello: HostHello; result: HandshakeResult }>
}
export type CreateHostHandshake = (deps: {
verifyDeviceProof: VerifyDeviceProof
identity: AgentIdentity
}) => HostHandshake
/** P4 §4.4 crypto core, injected (impls live in relay-e2e/). */
export interface E2ECryptoDeps {
createHostHandshake: CreateHostHandshake
createE2ESession(role: 'host', result: HandshakeResult): E2ESession
/** Wire codec for the HostHello reply (P4/P6-owned framing). */
encodeHostHello(hello: HostHello): Uint8Array
}
/**
* Produce the HostHello + HandshakeResult for a ClientHello. The proof is verified FIRST; a forged
* proof aborts with MitmAbortError and NO key derivation (anti-MITM). FIX 2: the result carries
* DirectionalKeys{c2h,h2c}, never a single sessionKey.
*/
export async function makeHostHello(
clientHello: ClientHello,
id: AgentIdentity,
verifyDeviceProof: VerifyDeviceProof,
deps: Pick<E2ECryptoDeps, 'createHostHandshake'>,
): Promise<{ hello: HostHello; result: HandshakeResult }> {
const ok = await verifyDeviceProof(clientHello.deviceAuthProof, {
clientEphPub: clientHello.clientEphPub,
clientNonce: clientHello.clientNonce,
})
if (!ok) {
throw new MitmAbortError('device-auth-proof verification failed — aborting, no keys derived')
}
const handshake = deps.createHostHandshake({ verifyDeviceProof, identity: id })
return handshake.respond(clientHello)
}
/**
* FrameTransform + a `seedSession` seam. The wiring layer intercepts the first (ClientHello) frame,
* runs `makeHostHello` (async, verify-first), then calls `seedSession` with the derived
* E2ESession + the encoded HostHello (queued as a control frame the router flushes upstream).
* After seeding: inbound = session.open(c2h) → loopback (OPAQUE, INV11); outbound = session.seal
* (h2c) → tunnel AND replay.seal(K_content) — two DISTINCT ciphertexts (FIX 3).
*/
export interface E2ETransform extends FrameTransform {
seedSession(streamId: number, session: E2ESession, hostHelloBytes: Uint8Array): void
}
interface StreamE2EState {
session: E2ESession | null
readonly control: Uint8Array[]
}
export function createE2ETransform(
_id: AgentIdentity,
_verifyDeviceProof: VerifyDeviceProof,
replay: ReplaySealer,
): E2ETransform {
const streams = new Map<number, StreamE2EState>()
function stateFor(streamId: number): StreamE2EState {
let s = streams.get(streamId)
if (s === undefined) {
s = { session: null, control: [] }
streams.set(streamId, s)
}
return s
}
return {
openStream(streamId: number): void {
stateFor(streamId)
},
closeStream(streamId: number): void {
streams.delete(streamId)
},
seedSession(streamId: number, session: E2ESession, hostHelloBytes: Uint8Array): void {
const s = stateFor(streamId)
s.session = session
s.control.push(hostHelloBytes)
},
inbound(streamId: number, cipher: Uint8Array): Uint8Array | null {
const s = stateFor(streamId)
if (s.session === null) return null // handshake not yet seeded; wiring layer handles it
return s.session.open(cipher) // opaque plaintext to loopback (INV11)
},
outbound(streamId: number, plain: Uint8Array): Uint8Array {
const s = stateFor(streamId)
if (s.session === null) {
throw new MitmAbortError('cannot seal before the E2E session is established')
}
replay.seal(plain) // FIX 3: recoverable K_content seal, DISTINCT from the live h2c frame
return s.session.seal(plain)
},
takeControlFrames(streamId: number): Uint8Array[] {
const s = streams.get(streamId)
if (s === undefined || s.control.length === 0) return []
return s.control.splice(0, s.control.length)
},
}
}

View File

@@ -0,0 +1,49 @@
/**
* Replay-frame sealer (recoverable K_content) — PLAN_RELAY_AGENT T19 (FIX 3).
*
* Live host→client frames use the EPHEMERAL DirectionalKeys.h2c (forward-secret, gone after
* reconnect). But "refresh the page and the Claude session is still there" needs the ring-buffer /
* preview ciphertext to be RECOVERABLE — 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 (P5) and opens
* it (P6). This is the single agent-side consumer of the FIX 3 recoverable key.
*
* INTEGRATION SEAM: the frozen §4.4 replay-crypto IMPLEMENTATIONS live in P4 `relay-e2e/`
* (`deriveContentKey`, `sealReplayFrame`). P4 is not built yet, so they are INJECTED here typed to
* the frozen relay-contracts signatures — production wiring passes the relay-e2e impls verbatim.
* `hostContentSecret` comes from Keystore.loadContentSecret() (T3); NEVER the ephemeral key,
* NEVER logged, NEVER sent to the relay (INV2/INV9).
*/
import type { AeadAlg, AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
/** The two §4.4 replay primitives, typed to the frozen relay-contracts signatures (P4 impls). */
export interface ReplayCrypto {
deriveContentKey(params: ReplayKeyParams): AeadKey
sealReplayFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope
}
export interface ReplaySealer {
/** K_content seal with monotonic seq per session (INV13); NOT the live h2c frame. */
seal(plaintext: Uint8Array): E2EEnvelope
}
/**
* Build a per-(host, session) replay sealer. K_content is derived ONCE from
* { hostContentSecret, sessionId, alg }; seq is strictly monotonic from 0 (INV13).
*/
export function createReplaySealer(
hostContentSecret: Uint8Array,
sessionId: string,
alg: AeadAlg,
crypto: ReplayCrypto,
): ReplaySealer {
const key = crypto.deriveContentKey({ hostContentSecret, sessionId, alg })
let seq = 0n
return {
seal(plaintext: Uint8Array): E2EEnvelope {
const env = crypto.sealReplayFrame(key, seq, plaintext)
seq += 1n
return env
},
}
}