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:
144
agent/src/e2e/hostEndpoint.ts
Normal file
144
agent/src/e2e/hostEndpoint.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
49
agent/src/e2e/replaySeal.ts
Normal file
49
agent/src/e2e/replaySeal.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user