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.
247 lines
8.8 KiB
TypeScript
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)
|
|
}
|