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:
246
relay-e2e/src/handshake.ts
Normal file
246
relay-e2e/src/handshake.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* T8 — client + host handshake state machines (§4.4 `client_hello` → `host_hello`).
|
||||
*
|
||||
* Anti-MITM (MUST be explicit): the pin string alone is NOT the defense — `enrollFpr` hashes a
|
||||
* PUBLIC key a malicious relay also knows. `onHostHello` receives the raw `agentPubkey` from P3's
|
||||
* INDEPENDENT TLS channel (never from the relay-carried HostHello), recomputes the fpr locally,
|
||||
* asserts it equals BOTH `msg.enrollFpr` AND the pin outcome, and only then verifies the signature
|
||||
* against that registry pubkey. Any disagreement ⇒ FingerprintMismatchError, no key derived.
|
||||
*
|
||||
* Anti-proof-replay: `deviceAuthProof` is bound to THIS handshake's `clientEphPub`/`clientNonce`
|
||||
* (issued by P5, injected). The host's `verifyDeviceProof` receives that binding so a captured
|
||||
* bearer proof replayed into a different ephemeral handshake is rejected (INV3).
|
||||
*/
|
||||
import type {
|
||||
AeadAlg,
|
||||
ClientHandshake,
|
||||
ClientHello,
|
||||
DeviceAuthProofProvider,
|
||||
DevicePinStore,
|
||||
HandshakeResult,
|
||||
HostHello,
|
||||
} from 'relay-contracts'
|
||||
import { ClientHelloSchema, HostHelloSchema } from 'relay-contracts'
|
||||
import { randomBytes } from './crypto-provider.js'
|
||||
import { computeEnrollFpr, resolvePin, verifyPinnedFingerprint } from './fingerprint.js'
|
||||
import { deriveSessionKeys } from './hkdf.js'
|
||||
import { FingerprintMismatchError, HandshakeStateError } from './errors.js'
|
||||
import { buildTranscript } from './transcript.js'
|
||||
import { deriveSharedSecret, generateEphemeralKeyPair, type EphemeralKeyPair } from './x25519.js'
|
||||
|
||||
const NONCE_BYTES = 32
|
||||
|
||||
/** Fixed AEAD strength preference (strongest first). No fallback to an unoffered alg. */
|
||||
export const AEAD_PREFERENCE: readonly AeadAlg[] = ['xchacha20-poly1305', 'aes-256-gcm']
|
||||
|
||||
export type HandshakePhase =
|
||||
| 'init'
|
||||
| 'awaitHostHello'
|
||||
| 'awaitClientHello'
|
||||
| 'established'
|
||||
| 'aborted'
|
||||
|
||||
export interface HostSigner {
|
||||
sign(transcript: Uint8Array): Promise<Uint8Array>
|
||||
}
|
||||
export interface HostVerifier {
|
||||
verify(pubkey: Uint8Array, transcript: Uint8Array, sig: Uint8Array): Promise<boolean>
|
||||
}
|
||||
|
||||
/** Pick the strongest alg present in both offers; null if the intersection is empty. */
|
||||
function negotiate(offer: readonly AeadAlg[], supported: readonly AeadAlg[]): AeadAlg | null {
|
||||
for (const alg of AEAD_PREFERENCE) {
|
||||
if (offer.includes(alg) && supported.includes(alg)) return alg
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLIENT (browser, P6)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ClientHandshakeWithPhase extends ClientHandshake {
|
||||
readonly phase: HandshakePhase
|
||||
}
|
||||
|
||||
export interface CreateClientHandshakeDeps {
|
||||
readonly aeadOffer: readonly AeadAlg[]
|
||||
readonly deviceAuthProofProvider: DeviceAuthProofProvider
|
||||
readonly verifier: HostVerifier
|
||||
readonly pinStore: DevicePinStore
|
||||
readonly hostId: string
|
||||
}
|
||||
|
||||
class ClientHandshakeImpl implements ClientHandshakeWithPhase {
|
||||
#phase: HandshakePhase = 'init'
|
||||
#eph: EphemeralKeyPair | null = null
|
||||
#clientNonce: Uint8Array | null = null
|
||||
constructor(private readonly deps: CreateClientHandshakeDeps) {}
|
||||
|
||||
get phase(): HandshakePhase {
|
||||
return this.#phase
|
||||
}
|
||||
|
||||
async start(): Promise<ClientHello> {
|
||||
if (this.#phase !== 'init') {
|
||||
throw new HandshakeStateError(`start() illegal in phase ${this.#phase}`)
|
||||
}
|
||||
const eph = await generateEphemeralKeyPair()
|
||||
const clientNonce = randomBytes(NONCE_BYTES)
|
||||
const deviceAuthProof = await this.deps.deviceAuthProofProvider.proofFor(this.deps.hostId, {
|
||||
clientEphPub: eph.publicKey,
|
||||
clientNonce,
|
||||
})
|
||||
this.#eph = eph
|
||||
this.#clientNonce = clientNonce
|
||||
this.#phase = 'awaitHostHello'
|
||||
return {
|
||||
clientEphPub: eph.publicKey,
|
||||
clientNonce,
|
||||
aeadOffer: this.deps.aeadOffer,
|
||||
deviceAuthProof,
|
||||
}
|
||||
}
|
||||
|
||||
async onHostHello(msg: HostHello, agentPubkey: Uint8Array): Promise<HandshakeResult> {
|
||||
if (this.#phase !== 'awaitHostHello' || !this.#eph || !this.#clientNonce) {
|
||||
throw new HandshakeStateError(`onHostHello() illegal in phase ${this.#phase}`)
|
||||
}
|
||||
const parsed = HostHelloSchema.parse(msg)
|
||||
|
||||
// (a)+(b) Host-key sourcing: recompute fpr from the REGISTRY pubkey; assert it matches BOTH the
|
||||
// relay-carried enrollFpr string AND the browser pin. Never a string-only fallback.
|
||||
const computedFpr = computeEnrollFpr(agentPubkey)
|
||||
if (!verifyPinnedFingerprint(agentPubkey, parsed.enrollFpr)) {
|
||||
this.#phase = 'aborted'
|
||||
throw new FingerprintMismatchError('host_hello enrollFpr != recomputed registry fpr')
|
||||
}
|
||||
const pin = await resolvePin(this.deps.pinStore, this.deps.hostId, agentPubkey)
|
||||
if (pin.outcome === 'mismatch') {
|
||||
this.#phase = 'aborted'
|
||||
throw new FingerprintMismatchError('pinned fingerprint mismatch (no auto-repin)')
|
||||
}
|
||||
|
||||
if (!this.deps.aeadOffer.includes(parsed.aeadChoice)) {
|
||||
this.#phase = 'aborted'
|
||||
throw new HandshakeStateError('host chose an AEAD alg not in the client offer')
|
||||
}
|
||||
|
||||
const transcript = buildTranscript({
|
||||
clientEphPub: this.#eph.publicKey,
|
||||
clientNonce: this.#clientNonce,
|
||||
aeadOffer: this.deps.aeadOffer,
|
||||
hostEphPub: parsed.hostEphPub,
|
||||
hostNonce: parsed.hostNonce,
|
||||
aeadChoice: parsed.aeadChoice,
|
||||
enrollFpr: computedFpr,
|
||||
})
|
||||
|
||||
// (c) Verify signature against the REGISTRY pubkey (never a relay-supplied value).
|
||||
const ok = await this.deps.verifier.verify(agentPubkey, transcript, parsed.sig)
|
||||
if (!ok) {
|
||||
this.#phase = 'aborted'
|
||||
throw new FingerprintMismatchError('host_hello signature invalid')
|
||||
}
|
||||
|
||||
// TOFU: record the pin only after a fully valid host_hello.
|
||||
if (pin.outcome === 'tofu-first-use') {
|
||||
await this.deps.pinStore.pin(this.deps.hostId, computedFpr)
|
||||
}
|
||||
|
||||
const shared = await deriveSharedSecret(this.#eph.privateKey, parsed.hostEphPub)
|
||||
const keys = await deriveSessionKeys(
|
||||
shared,
|
||||
this.#clientNonce,
|
||||
parsed.hostNonce,
|
||||
parsed.aeadChoice,
|
||||
)
|
||||
this.#phase = 'established'
|
||||
return { keys, aead: parsed.aeadChoice, transcript }
|
||||
}
|
||||
}
|
||||
|
||||
export function createClientHandshake(deps: CreateClientHandshakeDeps): ClientHandshakeWithPhase {
|
||||
return new ClientHandshakeImpl(deps)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HOST (agent, P2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HostHandshake {
|
||||
readonly phase: HandshakePhase
|
||||
readonly result: HandshakeResult | null
|
||||
onClientHello(msg: ClientHello): Promise<HostHello>
|
||||
}
|
||||
|
||||
export interface CreateHostHandshakeDeps {
|
||||
readonly signer: HostSigner
|
||||
readonly agentPubkey: Uint8Array
|
||||
readonly supported: readonly AeadAlg[]
|
||||
readonly verifyDeviceProof: (
|
||||
proof: string,
|
||||
binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array },
|
||||
) => Promise<boolean>
|
||||
}
|
||||
|
||||
class HostHandshakeImpl implements HostHandshake {
|
||||
#phase: HandshakePhase = 'init'
|
||||
#result: HandshakeResult | null = null
|
||||
constructor(private readonly deps: CreateHostHandshakeDeps) {}
|
||||
|
||||
get phase(): HandshakePhase {
|
||||
return this.#phase
|
||||
}
|
||||
get result(): HandshakeResult | null {
|
||||
return this.#result
|
||||
}
|
||||
|
||||
async onClientHello(msg: ClientHello): Promise<HostHello> {
|
||||
if (this.#phase !== 'init') {
|
||||
throw new HandshakeStateError(`onClientHello() illegal in phase ${this.#phase}`)
|
||||
}
|
||||
const parsed = ClientHelloSchema.parse(msg)
|
||||
|
||||
// INV3 gate: proof MUST be bound to this handshake's ephemeral material.
|
||||
const proofOk = await this.deps.verifyDeviceProof(parsed.deviceAuthProof, {
|
||||
clientEphPub: parsed.clientEphPub,
|
||||
clientNonce: parsed.clientNonce,
|
||||
})
|
||||
if (!proofOk) {
|
||||
this.#phase = 'aborted'
|
||||
throw new HandshakeStateError('deviceAuthProof failed verification (deny-by-default)')
|
||||
}
|
||||
|
||||
const aeadChoice = negotiate(parsed.aeadOffer, this.deps.supported)
|
||||
if (!aeadChoice) {
|
||||
this.#phase = 'aborted'
|
||||
throw new HandshakeStateError('no common AEAD algorithm')
|
||||
}
|
||||
|
||||
const eph = await generateEphemeralKeyPair()
|
||||
const hostNonce = randomBytes(NONCE_BYTES)
|
||||
const enrollFpr = computeEnrollFpr(this.deps.agentPubkey)
|
||||
|
||||
const transcript = buildTranscript({
|
||||
clientEphPub: parsed.clientEphPub,
|
||||
clientNonce: parsed.clientNonce,
|
||||
aeadOffer: parsed.aeadOffer,
|
||||
hostEphPub: eph.publicKey,
|
||||
hostNonce,
|
||||
aeadChoice,
|
||||
enrollFpr,
|
||||
})
|
||||
const sig = await this.deps.signer.sign(transcript)
|
||||
|
||||
const shared = await deriveSharedSecret(eph.privateKey, parsed.clientEphPub)
|
||||
const keys = await deriveSessionKeys(shared, parsed.clientNonce, hostNonce, aeadChoice)
|
||||
this.#result = { keys, aead: aeadChoice, transcript }
|
||||
this.#phase = 'established'
|
||||
return { hostEphPub: eph.publicKey, hostNonce, aeadChoice, enrollFpr, sig }
|
||||
}
|
||||
}
|
||||
|
||||
export function createHostHandshake(deps: CreateHostHandshakeDeps): HostHandshake {
|
||||
return new HostHandshakeImpl(deps)
|
||||
}
|
||||
Reference in New Issue
Block a user