Files
web-terminal/relay-e2e/src/handshake.ts
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

247 lines
8.8 KiB
TypeScript

/**
* 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)
}