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,77 @@
/**
* T11 — admin-API principal derivation (INV3). `accountId` comes ONLY from the VERIFIED capability
* token (P5 owns the signing key + `verifyCapabilityToken`; we consume it as an injected verifier).
* Any body/query field named `account_id`/`tenant_id` is IGNORED for authz decisions. Deny-by-default:
* a missing/expired/foreign-`aud` token → 401.
*/
import { extractTokenFromSubprotocols, type CapabilityToken, type CapabilityRight } from 'relay-contracts'
export interface AdminPrincipal {
readonly accountId: string
readonly rights: readonly CapabilityRight[]
}
export class AuthzError extends Error {
constructor(
public readonly status: 401 | 403,
message: string,
) {
super(message)
}
}
/** P5's verifier, injected. Mirrors the frozen `verifyCapabilityToken` signature (§4.3). */
export interface CapabilityVerifier {
verify(raw: string, expectedAud: string, now: number): CapabilityToken
}
/** Minimal request shape we read for the bearer token (never a body account field). */
export interface AuthRequest {
readonly headers: Readonly<Record<string, string | string[] | undefined>>
}
export interface AuthzDeps {
readonly verifier: CapabilityVerifier
readonly expectedAud: string // admin audience the token must be scoped to (Host-confusion guard)
readonly now?: () => number
}
function extractRawToken(req: AuthRequest): string | null {
const auth = req.headers['authorization']
if (typeof auth === 'string' && auth.startsWith('Bearer ')) return auth.slice('Bearer '.length).trim()
// Also accept the WS-upgrade subprotocol transport (FIX 5), for parity with the relay edge.
const proto = req.headers['sec-websocket-protocol']
if (typeof proto === 'string') {
const values = proto.split(',').map((v) => v.trim())
return extractTokenFromSubprotocols(values)
}
return null
}
export interface Authorizer {
principalFromRequest(req: AuthRequest): AdminPrincipal
requireRight(principal: AdminPrincipal, right: CapabilityRight): void
}
export function createAuthorizer(deps: AuthzDeps): Authorizer {
const now = deps.now ?? (() => Math.floor(Date.now() / 1000))
return {
principalFromRequest(req) {
const raw = extractRawToken(req)
if (raw === null) throw new AuthzError(401, 'missing capability token')
let token: CapabilityToken
try {
token = deps.verifier.verify(raw, deps.expectedAud, now())
} catch (err: unknown) {
throw new AuthzError(401, `invalid capability token: ${err instanceof Error ? err.message : 'rejected'}`)
}
// accountId derives ONLY from the token principal — never from a client field (INV3).
return { accountId: token.sub, rights: token.rights }
},
requireRight(principal, right) {
if (!principal.rights.includes(right)) {
throw new AuthzError(403, `token scope lacks required right: ${right}`)
}
},
}
}

View File

@@ -0,0 +1,137 @@
/**
* T11 — provisioning / deprovisioning HTTP routes. ALL admin routes are deny-by-default and scope
* every operation to `principal.accountId` (INV3/INV6). Host-targeting routes re-check
* `ownsHost` (INV1). No route resolves a host by a user-supplied address. `/enroll` is the one
* route NOT gated by a capability token (it IS the auth bootstrap) but is guarded by the single-use
* pairing code (T8). Every body is Zod-validated at the boundary; malformed → 400.
*/
import { z } from 'zod'
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import type { PlanTier } from 'relay-contracts'
import type { Authorizer, AdminPrincipal } from './authz.js'
import { AuthzError } from './authz.js'
import type { AccountRegistry } from '../registry/accounts.js'
import type { HostRegistry } from '../registry/hosts.js'
import type { PairingIssuer } from '../pairing/issue.js'
import type { PairingRedeemer } from '../pairing/redeem.js'
import { RedeemError } from '../pairing/redeem.js'
import type { Deprovisioner } from '../deprovision/deprovision.js'
export interface ProvisionDeps {
readonly authorizer: Authorizer
readonly accounts: AccountRegistry
readonly hosts: HostRegistry
readonly pairingIssuer: PairingIssuer
readonly redeemer: PairingRedeemer
readonly deprovisioner: Deprovisioner
}
const PlanSchema = z.enum(['free', 'personal', 'pro', 'team'])
const StatusSchema = z.object({ status: z.enum(['active', 'suspended']) })
const EnrollSchema = z.object({
code: z.string().min(1),
agentPubkey: z.string().min(1), // base64
csr: z.string().min(1), // base64
})
function sendError(reply: FastifyReply, err: unknown): void {
if (err instanceof AuthzError) {
void reply.code(err.status).send({ error: err.message })
return
}
if (err instanceof RedeemError) {
const status = err.code === 'too_many_attempts' ? 429 : 400
void reply.code(status).send({ error: err.code })
return
}
void reply.code(400).send({ error: err instanceof Error ? err.message : 'bad request' })
}
/** Enforce that the path :id equals the authenticated principal's account (INV1). */
function assertOwnAccount(principal: AdminPrincipal, pathAccountId: string): void {
if (principal.accountId !== pathAccountId) {
throw new AuthzError(403, 'account scope mismatch') // never operate on another tenant
}
}
export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
return async (app) => {
const principal = (req: FastifyRequest): AdminPrincipal =>
deps.authorizer.principalFromRequest({ headers: req.headers })
app.post('/accounts', async (req, reply) => {
try {
const p = principal(req)
deps.authorizer.requireRight(p, 'manage')
const plan: PlanTier = PlanSchema.parse((req.body as { plan?: unknown })?.plan ?? 'free')
const account = await deps.accounts.createAccount(plan)
await reply.code(201).send(account)
} catch (err) {
sendError(reply, err)
}
})
app.post('/accounts/:id/pairing-codes', async (req, reply) => {
try {
const p = principal(req)
deps.authorizer.requireRight(p, 'manage')
assertOwnAccount(p, (req.params as { id: string }).id)
const issued = await deps.pairingIssuer.issuePairingCode(p.accountId)
await reply.code(201).send(issued)
} catch (err) {
sendError(reply, err)
}
})
app.post('/accounts/:id/status', async (req, reply) => {
try {
const p = principal(req)
deps.authorizer.requireRight(p, 'manage')
assertOwnAccount(p, (req.params as { id: string }).id)
const { status } = StatusSchema.parse(req.body)
const account = await deps.accounts.setAccountStatus(p.accountId, status)
await reply.send(account)
} catch (err) {
sendError(reply, err)
}
})
app.get('/accounts/:id/hosts', async (req, reply) => {
try {
const p = principal(req)
assertOwnAccount(p, (req.params as { id: string }).id)
const hosts = await deps.hosts.listHosts(p.accountId) // ownership-scoped
await reply.send(hosts.map((h) => ({ ...h, agentPubkey: Buffer.from(h.agentPubkey).toString('base64') })))
} catch (err) {
sendError(reply, err)
}
})
app.delete('/hosts/:hostId', async (req, reply) => {
try {
const p = principal(req)
deps.authorizer.requireRight(p, 'manage')
// account_id in the body is IGNORED — authz uses ONLY the token principal (INV3).
await deps.deprovisioner.deprovisionHost(p, (req.params as { hostId: string }).hostId)
await reply.code(204).send()
} catch (err) {
sendError(reply, err)
}
})
app.post('/enroll', async (req, reply) => {
// NOT capability-token gated — guarded by the single-use pairing code (T8).
try {
const body = EnrollSchema.parse(req.body)
const result = await deps.redeemer.redeemPairingCode({
code: body.code,
agentPubkey: new Uint8Array(Buffer.from(body.agentPubkey, 'base64')),
csr: new Uint8Array(Buffer.from(body.csr, 'base64')),
})
await reply.code(201).send({ ...result, hostContentSecret: Buffer.from(result.hostContentSecret).toString('base64') })
} catch (err) {
sendError(reply, err)
}
})
}
}