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,174 @@
import { describe, expect, it } from 'vitest'
import type { ClientHello, HostHello } from 'relay-contracts'
import { createClientHandshake, createHostHandshake } from '../src/handshake.js'
import { nobleEd25519Signer, nobleEd25519Verifier } from '../src/ed25519.js'
import { computeEnrollFpr } from '../src/fingerprint.js'
import { MemoryDevicePinStore } from '../src/keystore.js'
import { unwrapAeadKey } from '../src/aead-key.js'
import { FingerprintMismatchError, HandshakeStateError } from '../src/errors.js'
import { boundProofProvider, makeHost, makeHostIdentity, verifyBoundProof, bytesToHex } from './helpers.js'
function makeClient(hostId: string, pinStore = new MemoryDevicePinStore()) {
return createClientHandshake({
aeadOffer: ['xchacha20-poly1305', 'aes-256-gcm'],
deviceAuthProofProvider: boundProofProvider(),
verifier: nobleEd25519Verifier(),
pinStore,
hostId,
})
}
describe('T8 handshake', () => {
it('happy path: both sides derive the SAME DirectionalKeys + aead; relay sees no key', async () => {
const id = makeHostIdentity()
const client = makeClient('h1')
const host = makeHost(id.privateKey, id.agentPubkey)
const ch = await client.start()
const hh = await host.onClientHello(ch)
const rc = await client.onHostHello(hh, id.agentPubkey)
const rh = host.result!
expect(rc.aead).toBe('xchacha20-poly1305')
expect(rc.aead).toBe(rh.aead)
expect(bytesToHex(unwrapAeadKey(rc.keys.c2h).raw)).toBe(bytesToHex(unwrapAeadKey(rh.keys.c2h).raw))
expect(bytesToHex(unwrapAeadKey(rc.keys.h2c).raw)).toBe(bytesToHex(unwrapAeadKey(rh.keys.h2c).raw))
// The relay only ever sees the ClientHello/HostHello structs — no secret field present.
expect(Object.keys(ch)).toEqual(['clientEphPub', 'clientNonce', 'aeadOffer', 'deviceAuthProof'])
expect(Object.keys(hh)).toEqual(['hostEphPub', 'hostNonce', 'aeadChoice', 'enrollFpr', 'sig'])
})
it('AEAD negotiation: strongest common alg; empty intersection → HandshakeStateError', async () => {
const id = makeHostIdentity()
const client = createClientHandshake({
aeadOffer: ['aes-256-gcm'],
deviceAuthProofProvider: boundProofProvider(),
verifier: nobleEd25519Verifier(),
pinStore: new MemoryDevicePinStore(),
hostId: 'h1',
})
const host = makeHost(id.privateKey, id.agentPubkey, ['aes-256-gcm'])
const hh = await host.onClientHello(await client.start())
expect(hh.aeadChoice).toBe('aes-256-gcm')
const client2 = createClientHandshake({
aeadOffer: ['xchacha20-poly1305'],
deviceAuthProofProvider: boundProofProvider(),
verifier: nobleEd25519Verifier(),
pinStore: new MemoryDevicePinStore(),
hostId: 'h1',
})
const hostGcmOnly = makeHost(id.privateKey, id.agentPubkey, ['aes-256-gcm'])
await expect(hostGcmOnly.onClientHello(await client2.start())).rejects.toBeInstanceOf(
HandshakeStateError,
)
})
it('MITM abort: relay swaps hostEph/sig → sig fails vs registry pubkey → FingerprintMismatchError, no key', async () => {
const id = makeHostIdentity()
const evil = makeHostIdentity()
const client = makeClient('h1')
const host = makeHost(id.privateKey, id.agentPubkey)
const ch = await client.start()
const hh = await host.onClientHello(ch)
// Relay forges the signature with its own key while keeping the honest enrollFpr.
const forged: HostHello = { ...hh, sig: await nobleForge(evil.privateKey, hh) }
await expect(client.onHostHello(forged, id.agentPubkey)).rejects.toBeInstanceOf(
FingerprintMismatchError,
)
expect(client.phase).toBe('aborted')
})
it('finding #3: enrollFpr matches pin but supplied pubkey hashes differently → abort, no string-only match', async () => {
const id = makeHostIdentity()
const other = makeHostIdentity()
const client = makeClient('h1')
const host = makeHost(id.privateKey, id.agentPubkey)
const hh = await host.onClientHello(await client.start())
// hh.enrollFpr is the honest host's; but we hand onHostHello a DIFFERENT registry pubkey.
await expect(client.onHostHello(hh, other.agentPubkey)).rejects.toBeInstanceOf(
FingerprintMismatchError,
)
})
it('TOFU first use records the recomputed pin; a later different registry pubkey aborts (no auto-repin)', async () => {
const id = makeHostIdentity()
const pinStore = new MemoryDevicePinStore()
const client = makeClient('h1', pinStore)
const host = makeHost(id.privateKey, id.agentPubkey)
await client.onHostHello(await host.onClientHello(await client.start()), id.agentPubkey)
expect(await pinStore.get('h1')).toBe(computeEnrollFpr(id.agentPubkey))
const rotated = makeHostIdentity()
const client2 = makeClient('h1', pinStore)
const host2 = makeHost(rotated.privateKey, rotated.agentPubkey)
await expect(
client2.onHostHello(await host2.onClientHello(await client2.start()), rotated.agentPubkey),
).rejects.toBeInstanceOf(FingerprintMismatchError)
// pin untouched
expect(await pinStore.get('h1')).toBe(computeEnrollFpr(id.agentPubkey))
})
it('finding #2: a proof captured from handshake A cannot be replayed into handshake B', async () => {
const id = makeHostIdentity()
const clientA = makeClient('h1')
const chA = await clientA.start()
const clientB = makeClient('h1')
const chB = await clientB.start()
// Splice A's proof into B's (different clientEphPub) client_hello.
const spliced: ClientHello = { ...chB, deviceAuthProof: chA.deviceAuthProof }
const host = makeHost(id.privateKey, id.agentPubkey)
await expect(host.onClientHello(spliced)).rejects.toBeInstanceOf(HandshakeStateError)
// Sanity: the unbound proof really was bound to A, not B.
expect(verifyBoundProof(chA.deviceAuthProof, chB)).toBe(false)
})
it('deviceAuthProof gate (INV3): forged/absent proof → HandshakeStateError, no session', async () => {
const id = makeHostIdentity()
const client = makeClient('h1')
const ch = await client.start()
const host = makeHost(id.privateKey, id.agentPubkey)
await expect(host.onClientHello({ ...ch, deviceAuthProof: 'bogus' })).rejects.toBeInstanceOf(
HandshakeStateError,
)
expect(host.result).toBeNull()
})
it('state machine: out-of-order / double calls → HandshakeStateError; phases terminal', async () => {
const id = makeHostIdentity()
const client = makeClient('h1')
const host = makeHost(id.privateKey, id.agentPubkey)
await expect(client.onHostHello({} as HostHello, id.agentPubkey)).rejects.toBeInstanceOf(
HandshakeStateError,
)
const ch = await client.start()
await expect(client.start()).rejects.toBeInstanceOf(HandshakeStateError)
await host.onClientHello(ch)
await expect(host.onClientHello(ch)).rejects.toBeInstanceOf(HandshakeStateError)
expect(host.phase).toBe('established')
})
it('malformed input: bad HostHello shape → parse error before any crypto', async () => {
const id = makeHostIdentity()
const client = makeClient('h1')
await client.start()
await expect(
client.onHostHello({ hostEphPub: 'nope' } as unknown as HostHello, id.agentPubkey),
).rejects.toBeTruthy()
})
})
// Forge a signature over the honest transcript using a different key (relay MITM attempt).
import { buildTranscript } from '../src/transcript.js'
async function nobleForge(evilPriv: Uint8Array, hh: HostHello): Promise<Uint8Array> {
// The forger cannot know the real transcript's client fields here; sign an arbitrary transcript.
// What matters for the test is that the sig does NOT verify against the honest agentPubkey.
const t = buildTranscript({
clientEphPub: new Uint8Array(32),
clientNonce: new Uint8Array(32),
aeadOffer: ['xchacha20-poly1305'],
hostEphPub: hh.hostEphPub,
hostNonce: hh.hostNonce,
aeadChoice: hh.aeadChoice,
enrollFpr: hh.enrollFpr,
})
return nobleEd25519Signer(evilPriv).sign(t)
}