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:
77
control-plane/src/api/authz.ts
Normal file
77
control-plane/src/api/authz.ts
Normal 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}`)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
137
control-plane/src/api/provision.ts
Normal file
137
control-plane/src/api/provision.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user