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,41 @@
/**
* Pairing-code format (Finding-4). The code is the SOLE credential gating `/enroll`, so it is
* 26 Crockford-base32 characters = 130 bits of real entropy (NOT the ~32-bit `ABCD-1234` shape,
* which is rejected). Displayed grouped for humans as 6 dash-separated groups; a paste/QR-first
* credential, not a type-from-memory PIN.
*/
import { randomBytes } from 'node:crypto'
/** Crockford base32 alphabet (excludes I L O U to avoid confusion). */
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
export const PAIRING_CODE_LEN = 26 // 26 * 5 bits = 130 bits
export const PAIRING_CODE_BITS = PAIRING_CODE_LEN * 5
/** Generate a canonical (undashed, uppercase) 26-char Crockford code. */
export function generatePairingCode(): string {
// Draw enough random bytes, map 5 bits per char.
const bytes = randomBytes(PAIRING_CODE_LEN)
let out = ''
for (let i = 0; i < PAIRING_CODE_LEN; i++) {
out += CROCKFORD[(bytes[i] as number) & 0x1f]
}
return out
}
/** Group the canonical code for display: XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-X. */
export function formatPairingCode(canonical: string): string {
return (canonical.match(/.{1,5}/g) ?? []).join('-')
}
/**
* Normalize a user-presented code back to canonical form: uppercase, strip dashes/spaces, map
* Crockford-confusable characters (I/L→1, O→0). Redemption hashes THIS canonical form.
*/
export function normalizePairingCode(raw: string): string {
return raw
.toUpperCase()
.replace(/[\s-]/g, '')
.replace(/[IL]/g, '1')
.replace(/O/g, '0')
.replace(/U/g, 'V')
}

View File

@@ -0,0 +1,52 @@
/**
* T7 — pairing-code issuance (INDEX §4.5 step 1). Mints a single-use short-TTL code; stores
* `{ code_hash, account_id, expires_at, redeem_attempts:0 }` — the RAW code is never stored
* (INV5). `accountId` comes from the authenticated principal (INV3), never a request field.
*/
import type { PairingCodeRecord } from '../model/records.js'
import type { PairingStore } from '../store/ports.js'
import type { AuditWriter } from '../audit/log.js'
import { noopAuditWriter } from '../audit/log.js'
import { generatePairingCode, formatPairingCode } from './code.js'
import { sha256Hex } from '../util/hash.js'
import { nowIso } from '../util/ids.js'
export interface IssuedPairing {
readonly code: string // display-grouped, returned ONCE
readonly expiresAt: string
}
export interface PairingIssuer {
issuePairingCode(accountId: string): Promise<IssuedPairing>
}
export interface PairingIssuerDeps {
readonly pairing: PairingStore
readonly pairingTtlSec: number
readonly audit?: AuditWriter
readonly actor?: string
}
export function createPairingIssuer(deps: PairingIssuerDeps): PairingIssuer {
const audit = deps.audit ?? noopAuditWriter()
const actor = deps.actor ?? 'system'
return {
async issuePairingCode(accountId) {
const canonical = generatePairingCode()
const codeHash = sha256Hex(canonical) // deterministic hash of a 130-bit input (INV5)
const expiresAt = new Date(Date.now() + deps.pairingTtlSec * 1000).toISOString()
const record: PairingCodeRecord = { codeHash, accountId, expiresAt, redeemedAt: null }
await deps.pairing.insert(record)
await audit.writeAuditEvent({
action: 'pairing.issue',
principalId: actor,
accountId,
hostId: null,
ts: nowIso(),
meta: { expiresAt },
})
// Raw code returned ONCE, display-grouped; never persisted.
return { code: formatPairingCode(canonical), expiresAt }
},
}
}

View File

@@ -0,0 +1,131 @@
/**
* T8 — pairing-code redemption + BIND + bind-time cert + content-secret mint (INDEX §4.5 35).
* ATOMIC single-use: `casRedeem` compare-and-sets `redeemed_at` so exactly one concurrent caller
* wins (no double-spend). A CODE-SCOPED lockout (Finding-4) counts failed redemptions per
* `code_hash` and locks after `pairingMaxRedeemAttempts` — independent of P5's per-tenant limiter.
* `accountId` is taken from the pairing ROW (INV3), never the agent's request. Returns the FROZEN
* `EnrollResult` (imported from relay-contracts, never redefined).
*/
import type { EnrollResult } from 'relay-contracts'
import type { PairingStore } from '../store/ports.js'
import type { HostRegistry } from '../registry/hosts.js'
import type { SubdomainAssigner } from '../subdomain/assign.js'
import type { LeafSigner } from '../ca/sign.js'
import type { AuditWriter } from '../audit/log.js'
import { noopAuditWriter } from '../audit/log.js'
import { verifyCsrPoP } from '../ca/csr.js'
import { fingerprint } from '../ca/fingerprint.js'
import { sha256Hex } from '../util/hash.js'
import { timingSafeEqualBytes, bytesToBase64 } from '../util/bytes.js'
import { sealToX25519 } from '../util/crypto.js'
import { normalizePairingCode } from './code.js'
import { nowIso } from '../util/ids.js'
import { randomBytes } from 'node:crypto'
export type RedeemErrorCode =
| 'unknown'
| 'expired'
| 'already_redeemed'
| 'too_many_attempts'
| 'bad_csr'
export class RedeemError extends Error {
constructor(public readonly code: RedeemErrorCode) {
super(`pairing redemption refused: ${code}`)
}
}
export interface RedeemInput {
readonly code: string
readonly agentPubkey: Uint8Array
readonly csr: Uint8Array
}
/**
* FIX 3 — mint + WRAP the host-scoped content secret at BIND. A per-host 32-byte CSPRNG secret
* is sealed to the host's enrolled identity; the raw secret is NEVER stored or logged (INV5/INV9)
* and each wrap is distinct (per-host revocable, INV12). P2 unwraps locally with its private key.
*/
export async function mintHostContentSecret(agentPubkey: Uint8Array): Promise<Uint8Array> {
const secret = new Uint8Array(randomBytes(32))
const wrapped = sealToX25519(agentPubkey, secret)
secret.fill(0) // best-effort scrub of the raw secret
return wrapped
}
export interface RedeemDeps {
readonly pairing: PairingStore
readonly hosts: HostRegistry
readonly subdomains: SubdomainAssigner
readonly leafSigner: LeafSigner
readonly pairingMaxRedeemAttempts: number
readonly audit?: AuditWriter
/** Injectable content-secret minter (test seam); defaults to `mintHostContentSecret`. */
readonly mintSecret?: (agentPubkey: Uint8Array) => Promise<Uint8Array>
}
export interface PairingRedeemer {
redeemPairingCode(input: RedeemInput): Promise<EnrollResult>
}
function toPem(label: string, der: Uint8Array): string {
const b64 = bytesToBase64(der)
const lines = b64.match(/.{1,64}/g) ?? [b64]
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
}
export function createPairingRedeemer(deps: RedeemDeps): PairingRedeemer {
const audit = deps.audit ?? noopAuditWriter()
const mint = deps.mintSecret ?? mintHostContentSecret
return {
async redeemPairingCode(input) {
const codeHash = sha256Hex(normalizePairingCode(input.code))
const row = await deps.pairing.get(codeHash)
if (row === null) throw new RedeemError('unknown') // blind guess; P5 limiter covers volume
// Lockout FIRST — a locked code is refused even with a correct code (Finding-4).
if (row.redeemAttempts >= deps.pairingMaxRedeemAttempts) throw new RedeemError('too_many_attempts')
if (Date.parse(row.record.expiresAt) <= Date.now()) throw new RedeemError('expired')
if (row.record.redeemedAt !== null) throw new RedeemError('already_redeemed')
// CSR proof-of-possession + no-substitution. A failure counts toward the code-scoped lockout.
const pop = verifyCsrPoP(input.csr)
if (!pop.ok || !timingSafeEqualBytes(pop.embeddedPub, input.agentPubkey)) {
await deps.pairing.registerFailure(codeHash)
throw new RedeemError('bad_csr')
}
// Atomic single-use CAS (double-spend guard) — exactly one concurrent caller wins.
const cas = await deps.pairing.casRedeem(codeHash, nowIso())
if (cas !== 'ok') throw new RedeemError('already_redeemed')
const accountId = row.record.accountId // from the pairing row (INV3)
const subdomain = await deps.subdomains.assignSubdomain(accountId)
const host = await deps.hosts.bindHost({
accountId,
subdomain,
agentPubkey: input.agentPubkey,
enrollFpr: fingerprint(input.agentPubkey),
})
const leaf = await deps.leafSigner.signHostLeaf(host.hostId, input.agentPubkey, input.csr)
const hostContentSecret = await mint(input.agentPubkey)
await audit.writeAuditEvent({
action: 'pairing.redeem',
principalId: `host:${host.hostId}`,
accountId,
hostId: host.hostId,
ts: nowIso(),
meta: { subdomain },
})
return {
hostId: host.hostId,
subdomain,
cert: toPem('CERTIFICATE', leaf.cert),
caChain: leaf.caChain.map((der) => toPem('CERTIFICATE', der)).join(''),
hostContentSecret,
}
},
}
}