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,134 @@
import { describe, test, expect } from 'vitest'
import { createMemoryStores } from '../src/store/memory.js'
import { createHostRegistry } from '../src/registry/hosts.js'
import { createSubdomainAssigner } from '../src/subdomain/assign.js'
import { createPairingIssuer } from '../src/pairing/issue.js'
import { createPairingRedeemer, RedeemError } from '../src/pairing/redeem.js'
import { createLeafSigner } from '../src/ca/sign.js'
import { inProcessCaSigner, type CaSigner } from '../src/boot/ca-wiring.js'
import { buildCsr } from '../src/ca/csr.js'
import { generatePairingCode, formatPairingCode, normalizePairingCode, PAIRING_CODE_BITS } from '../src/pairing/code.js'
import { sha256Hex } from '../src/util/hash.js'
import { generateEd25519 } from '../src/util/crypto.js'
const ACCOUNT = '11111111-1111-4111-8111-111111111111'
function harness(opts?: { ttlSec?: number; maxAttempts?: number; signer?: CaSigner }) {
const stores = createMemoryStores()
const hosts = createHostRegistry({ hosts: stores.hosts })
const subdomains = createSubdomainAssigner({ subdomains: stores.subdomains })
const signer = opts?.signer ?? inProcessCaSigner()
const leafSigner = createLeafSigner({ hosts: stores.hosts, signer, caChainDer: [new Uint8Array([1, 2, 3])] })
const issuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: opts?.ttlSec ?? 600 })
const redeemer = createPairingRedeemer({
pairing: stores.pairing,
hosts,
subdomains,
leafSigner,
pairingMaxRedeemAttempts: opts?.maxAttempts ?? 5,
})
return { stores, issuer, redeemer }
}
function agentEnroll() {
const { publicKeyRaw, privateKey } = generateEd25519()
return { agentPubkey: publicKeyRaw, csr: buildCsr(privateKey, publicKeyRaw) }
}
describe('T7 pairing issuance (INV5, entropy)', () => {
test('code is >= 128-bit entropy, display round-trips to canonical', () => {
expect(PAIRING_CODE_BITS).toBeGreaterThanOrEqual(128)
const canonical = generatePairingCode()
expect(canonical.length).toBe(26)
expect(normalizePairingCode(formatPairingCode(canonical))).toBe(canonical)
})
test('stored row holds code_hash only — no raw code at rest (INV5)', async () => {
const { stores, issuer } = harness()
const { code } = await issuer.issuePairingCode(ACCOUNT)
const codeHash = sha256Hex(normalizePairingCode(code))
const row = await stores.pairing.get(codeHash)
expect(row).not.toBeNull()
expect(JSON.stringify(row)).not.toContain(normalizePairingCode(code))
expect(row?.redeemAttempts).toBe(0)
})
test('two issues → two distinct codes', async () => {
const { issuer } = harness()
const a = await issuer.issuePairingCode(ACCOUNT)
const b = await issuer.issuePairingCode(ACCOUNT)
expect(a.code).not.toBe(b.code)
})
})
describe('T8 redemption + BIND + cert + content-secret', () => {
test('happy path → EnrollResult with frozen fields, subject pubkey == agentPubkey', async () => {
const { issuer, redeemer } = harness()
const { code } = await issuer.issuePairingCode(ACCOUNT)
const { agentPubkey, csr } = agentEnroll()
const result = await redeemer.redeemPairingCode({ code, agentPubkey, csr })
expect(result.hostId).toMatch(/[0-9a-f-]{36}/)
expect(result.subdomain.length).toBeGreaterThan(0)
expect(result.cert).toContain('BEGIN CERTIFICATE')
expect(result.caChain).toContain('BEGIN CERTIFICATE')
expect(result.hostContentSecret.length).toBeGreaterThan(0)
// subject pubkey inside the cert == agentPubkey
const certJson = JSON.parse(Buffer.from(result.cert.split('\n').slice(1, -2).join(''), 'base64').toString())
const tbs = JSON.parse(Buffer.from(certJson.tbs, 'base64').toString())
expect(tbs.subjectSpki).toBe(Buffer.from(agentPubkey).toString('base64'))
})
test('hostContentSecret: wrapped, raw secret absent, distinct per enrollment (FIX 3)', async () => {
const { issuer, redeemer } = harness()
const c1 = (await issuer.issuePairingCode(ACCOUNT)).code
const c2 = (await issuer.issuePairingCode(ACCOUNT)).code
const r1 = await redeemer.redeemPairingCode({ code: c1, ...agentEnroll() })
const r2 = await redeemer.redeemPairingCode({ code: c2, ...agentEnroll() })
expect(Buffer.from(r1.hostContentSecret).equals(Buffer.from(r2.hostContentSecret))).toBe(false)
})
test('double-spend: two concurrent redeems → exactly one wins (CAS)', async () => {
const { issuer, redeemer } = harness()
const { code } = await issuer.issuePairingCode(ACCOUNT)
const results = await Promise.allSettled([
redeemer.redeemPairingCode({ code, ...agentEnroll() }),
redeemer.redeemPairingCode({ code, ...agentEnroll() }),
])
const ok = results.filter((r) => r.status === 'fulfilled')
const rejected = results.filter((r) => r.status === 'rejected')
expect(ok.length).toBe(1)
expect(rejected.length).toBe(1)
expect((rejected[0] as PromiseRejectedResult).reason).toBeInstanceOf(RedeemError)
expect(((rejected[0] as PromiseRejectedResult).reason as RedeemError).code).toBe('already_redeemed')
})
test('expired / unknown / CSR-substitution rejected', async () => {
const expiredH = harness({ ttlSec: -10 })
const { code } = await expiredH.issuer.issuePairingCode(ACCOUNT)
await expect(expiredH.redeemer.redeemPairingCode({ code, ...agentEnroll() })).rejects.toMatchObject({ code: 'expired' })
const h = harness()
await expect(h.redeemer.redeemPairingCode({ code: 'ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-Z', ...agentEnroll() })).rejects.toMatchObject({ code: 'unknown' })
// CSR embeds a DIFFERENT pubkey than the presented agentPubkey → bad_csr
const good = (await h.issuer.issuePairingCode(ACCOUNT)).code
const a = generateEd25519()
const b = generateEd25519()
const substitutedCsr = buildCsr(b.privateKey, b.publicKeyRaw)
await expect(h.redeemer.redeemPairingCode({ code: good, agentPubkey: a.publicKeyRaw, csr: substitutedCsr })).rejects.toMatchObject({ code: 'bad_csr' })
})
test('code-scoped lockout after maxAttempts — correct code then refused (Finding-4)', async () => {
const h = harness({ maxAttempts: 3 })
const { code } = await h.issuer.issuePairingCode(ACCOUNT)
const bad = generateEd25519()
const badCsr = buildCsr(bad.privateKey, bad.publicKeyRaw)
const victim = agentEnroll()
for (let i = 0; i < 3; i++) {
// substitution failure against the SAME code_hash
await expect(h.redeemer.redeemPairingCode({ code, agentPubkey: victim.agentPubkey, csr: badCsr })).rejects.toMatchObject({ code: 'bad_csr' })
}
// now even a correct redemption is locked out
await expect(h.redeemer.redeemPairingCode({ code, ...victim })).rejects.toMatchObject({ code: 'too_many_attempts' })
})
})