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,111 @@
/**
* T2 · §4.4 `ClientHello.deviceAuthProof` — the SOLE issuer/verifier (Finding-7 / INDEX §6b).
*
* P5 mints from the authenticated `AuthenticatedPrincipal` (asserts `principal.accountId`), bound to
* `{ clientEphPub, clientNonce }` — a per-handshake, non-replayable binding (NOT a static bearer,
* NOT transcriptHash). P4 consumes `verifyDeviceProof` as an injected dep and never re-derives the
* account binding. A captured proof replayed into a different freshly-keyed client_hello → false.
*/
import { encodeBase64UrlBytes, decodeBase64UrlBytes } from 'relay-contracts'
import type { AuthenticatedPrincipal } from '../types.js'
import { signEd25519, verifyEd25519 } from '../crypto/ed25519.js'
import { getVerifyKey } from '../config/keys.js'
export type DeviceProofBinding = {
readonly clientEphPub: Uint8Array
readonly clientNonce: Uint8Array
}
const DOMAIN = 'relay-auth/device-auth/v1'
const PROOF_MAX_AGE_SEC = 120
interface DeviceProofPayload {
readonly acct: string
readonly iat: number
}
function concatAll(pieces: readonly Uint8Array[]): Uint8Array {
let total = 0
for (const p of pieces) total += p.length
const out = new Uint8Array(total)
let off = 0
for (const p of pieces) {
out.set(p, off)
off += p.length
}
return out
}
function le32(n: number): Uint8Array {
const out = new Uint8Array(4)
out[0] = n & 0xff
out[1] = (n >>> 8) & 0xff
out[2] = (n >>> 16) & 0xff
out[3] = (n >>> 24) & 0xff
return out
}
/** Domain-separated signed message = DOMAIN ‖ acct ‖ len(ceph)‖ceph ‖ len(cnon)‖cnon ‖ iat. */
function proofMessage(acct: string, binding: DeviceProofBinding, iat: number): Uint8Array {
const enc = new TextEncoder()
return concatAll([
enc.encode(DOMAIN),
enc.encode(acct),
le32(binding.clientEphPub.length),
binding.clientEphPub,
le32(binding.clientNonce.length),
binding.clientNonce,
le32(iat),
])
}
/** Client-side minting — backs the §4.4 `DeviceAuthProofProvider.proofFor` P4 injects. */
export async function signDeviceAuthProof(
principal: AuthenticatedPrincipal,
binding: DeviceProofBinding,
signingKey: CryptoKey,
now: number,
): Promise<string> {
const payload: DeviceProofPayload = { acct: principal.accountId, iat: now }
const message = proofMessage(payload.acct, binding, payload.iat)
const sig = await signEd25519(signingKey, message)
const p = encodeBase64UrlBytes(new TextEncoder().encode(JSON.stringify(payload)))
return `${p}.${encodeBase64UrlBytes(sig)}`
}
/** Host-side verification — the injected `verifyDeviceProof` P4's createHostHandshake calls. */
export async function verifyDeviceProof(
proof: string,
binding: DeviceProofBinding,
now: number,
): Promise<boolean> {
const parts = proof.split('.')
if (parts.length !== 2) return false
const [p, s] = parts as [string, string]
let payload: DeviceProofPayload
let sig: Uint8Array
try {
payload = JSON.parse(new TextDecoder().decode(decodeBase64UrlBytes(p))) as DeviceProofPayload
sig = decodeBase64UrlBytes(s)
} catch {
return false
}
if (typeof payload.acct !== 'string' || payload.acct.length === 0) return false
if (typeof payload.iat !== 'number' || Math.abs(now - payload.iat) > PROOF_MAX_AGE_SEC) return false
const message = proofMessage(payload.acct, binding, payload.iat)
return verifyEd25519(getVerifyKey(), sig, message)
}
/** Read the account a proof asserts (only meaningful after `verifyDeviceProof` returns true). */
export function deviceProofAccount(proof: string): string | null {
const parts = proof.split('.')
if (parts.length !== 2) return null
try {
const payload = JSON.parse(
new TextDecoder().decode(decodeBase64UrlBytes(parts[0]!)),
) as DeviceProofPayload
return typeof payload.acct === 'string' ? payload.acct : null
} catch {
return null
}
}

View File

@@ -0,0 +1,22 @@
/** Typed capability errors carrying a machine-readable reason (no console.log; explicit errors). */
export type CapabilityErrorReason =
| 'ttl_too_long'
| 'ttl_too_short'
| 'wildcard_host'
| 'empty_sub'
| 'malformed'
| 'expired'
| 'not_yet_valid'
| 'aud_mismatch'
| 'bad_signature'
| 'replayed'
| 'no_cnf'
export class CapabilityError extends Error {
readonly reason: CapabilityErrorReason
constructor(reason: CapabilityErrorReason, message?: string) {
super(message ?? reason)
this.name = 'CapabilityError'
this.reason = reason
}
}

View File

@@ -0,0 +1,65 @@
/**
* T2 · §4.3 capability-token ISSUE. `sub`/`host`/`account` come from `IssueArgs.principal`,
* never a client field (INV3). Connect-scoped: short TTL 3060 s (Finding-4 leak blast-radius),
* single host (no wildcard), least-privilege rights, DPoP `cnf.jkt` proof-of-possession binding.
*/
import type { CapabilityRight } from 'relay-contracts'
import { CapabilityRightSchema } from 'relay-contracts'
import type { AuthenticatedPrincipal } from '../types.js'
import { signPaseto } from '../crypto/paseto.js'
import { CapabilityError } from './errors.js'
/** Connect-scoped TTL clamp (Finding-4): 3060 s; longer values are REFUSED at issue. */
export const CONNECT_TOKEN_MIN_TTL_SEC = 30 as const
export const CONNECT_TOKEN_MAX_TTL_SEC = 60 as const
export interface IssueArgs {
readonly principal: AuthenticatedPrincipal // sub := principal.accountId (INV3, T1 convention)
readonly aud: string // subdomain (Host-confusion guard, INV1)
readonly host: string // single host_id, verified owned by principal.accountId
readonly rights: readonly CapabilityRight[] // least-privilege subset
readonly ttlSeconds: number // clamped to [30, 60]
readonly cnfJkt: string // base64url SHA-256 JWK thumbprint of the client's ephemeral public key
}
/** Internal token body = frozen §4.3 fields + the ADDITIVE `cnf.jkt` PoP claim (RFC 7800). */
export interface TokenBody {
readonly sub: string
readonly aud: string
readonly host: string
readonly rights: readonly CapabilityRight[]
readonly iat: number
readonly exp: number
readonly jti: string
readonly cnf: { readonly jkt: string }
}
function randomJti(): string {
return globalThis.crypto.randomUUID()
}
export async function issueCapabilityToken(
a: IssueArgs,
signingKey: CryptoKey,
now: number,
): Promise<string> {
if (a.principal.accountId.length === 0) throw new CapabilityError('empty_sub')
if (a.host === '*' || a.host.length === 0) throw new CapabilityError('wildcard_host')
if (a.ttlSeconds > CONNECT_TOKEN_MAX_TTL_SEC) throw new CapabilityError('ttl_too_long')
for (const r of a.rights) CapabilityRightSchema.parse(r)
if (a.rights.length === 0) throw new CapabilityError('malformed', 'rights must be non-empty')
if (a.cnfJkt.length === 0) throw new CapabilityError('no_cnf')
const ttl = Math.max(a.ttlSeconds, CONNECT_TOKEN_MIN_TTL_SEC)
const body: TokenBody = {
sub: a.principal.accountId, // INV3: the account is the authoritative cross-tenant unit
aud: a.aud,
host: a.host,
rights: [...new Set(a.rights)],
iat: now,
exp: now + ttl,
jti: randomJti(),
cnf: { jkt: a.cnfJkt },
}
return signPaseto(body, signingKey)
}

View File

@@ -0,0 +1,175 @@
/**
* T2 · §4.3 capability-token VERIFY + DPoP proof-of-possession.
*
* `verifyCapabilityToken` keeps the FROZEN §4.3 signature verbatim (raw, expectedAud, now) — the
* verifying key comes from the startup registry (config/keys.ts), never a parameter. The additive
* `cnf.jkt` PoP claim (Finding-4) is NOT part of the frozen `CapabilityToken` shape, so it is kept
* in a WeakMap keyed on the returned token object and read via `readCnfJkt` — the frozen type is
* never mutated.
*/
import type { CapabilityToken } from 'relay-contracts'
import { CapabilityTokenSchema, encodeBase64UrlBytes, decodeBase64UrlBytes } from 'relay-contracts'
import { z } from 'zod'
import { verifyPaseto, peekPasetoClaims } from '../crypto/paseto.js'
import { verifyEd25519, importEd25519PublicRaw } from '../crypto/ed25519.js'
import { jwkThumbprint } from '../crypto/thumbprint.js'
import { getVerifyKey } from '../config/keys.js'
import { CapabilityError } from './errors.js'
/** Allowed clock skew for `iat` (seconds). */
export const CLOCK_SKEW_SEC = 5 as const
/** DPoP proof freshness window (seconds). */
export const DPOP_MAX_AGE_SEC = 30 as const
const CnfSchema = z.object({ jkt: z.string().min(1) }).strict()
/** Ties an additive `cnf.jkt` PoP binding to a verified token object without mutating §4.3. */
const cnfByToken = new WeakMap<CapabilityToken, string>()
/** Read the additive `cnf.jkt` PoP claim off a verified token (Finding-4). */
export function readCnfJkt(token: CapabilityToken): string {
const jkt = cnfByToken.get(token)
if (jkt === undefined) throw new CapabilityError('no_cnf')
return jkt
}
/** Returns `token.sub` (the accountId, T1 convention). */
export function subAccountId(token: CapabilityToken): string {
return token.sub
}
/** Read `exp` WITHOUT verifying — only safe after a prior successful verify (e.g. for consumeOnce). */
export function peekExp(raw: string): number {
const claims = peekPasetoClaims(raw) as { exp?: unknown }
if (typeof claims.exp !== 'number') throw new CapabilityError('malformed')
return claims.exp
}
/**
* FROZEN §4.3 signature. Verifies Ed25519 signature (startup key), `aud === expectedAud`, and
* `now < exp` / `iat` skew; returns the validated §4.3 claims. Rejects deny-by-default.
*/
export async function verifyCapabilityToken(
raw: string,
expectedAud: string,
now: number,
): Promise<CapabilityToken> {
let claims: unknown
try {
claims = await verifyPaseto(raw, getVerifyKey())
} catch {
throw new CapabilityError('bad_signature')
}
const obj = claims as Record<string, unknown>
const cnf = CnfSchema.safeParse(obj.cnf)
if (!cnf.success) throw new CapabilityError('no_cnf')
const { cnf: _cnf, ...core } = obj
const parsed = CapabilityTokenSchema.safeParse(core)
if (!parsed.success) throw new CapabilityError('malformed')
const token = parsed.data as CapabilityToken
if (token.aud !== expectedAud) throw new CapabilityError('aud_mismatch')
if (token.iat > now + CLOCK_SKEW_SEC) throw new CapabilityError('not_yet_valid')
if (token.exp <= now) throw new CapabilityError('expired')
cnfByToken.set(token, cnf.data.jkt)
return token
}
// ── DPoP proof-of-possession ────────────────────────────────────────────────────────────────────
export interface DpopContext {
readonly proofJws: string
readonly htu: string
readonly htm: string
}
interface DpopHeader {
readonly typ: string
readonly jwk: { readonly crv: string; readonly kty: string; readonly x: string }
}
interface DpopPayload {
readonly htu: string
readonly htm: string
readonly jti: string
readonly iat: number
}
/** Bounded in-memory replay cache for DPoP `jti`s (single-process verifier). */
const seenDpopJti = new Map<string, number>()
function rememberDpop(jti: string, exp: number, now: number): boolean {
for (const [k, e] of seenDpopJti) if (e < now) seenDpopJti.delete(k)
if (seenDpopJti.has(jti)) return false
seenDpopJti.set(jti, exp)
return true
}
/** TEST-ONLY: clear the DPoP replay cache between suites. */
export function resetDpopCacheForTest(): void {
seenDpopJti.clear()
}
/**
* Verify a DPoP proof: the presenting connection signed (htu, htm, jti, iat) with the ephemeral
* key whose JWK thumbprint MUST equal the token's `cnf.jkt`. false if thumbprint mismatch, replay,
* or htu/htm/age mismatch (Finding-4).
*/
export async function verifyDpopProof(
token: CapabilityToken,
dpop: DpopContext,
now: number,
): Promise<boolean> {
const expectedJkt = readCnfJkt(token)
const parts = dpop.proofJws.split('.')
if (parts.length !== 3) return false
const [h, p, s] = parts as [string, string, string]
let header: DpopHeader
let payload: DpopPayload
try {
header = JSON.parse(new TextDecoder().decode(decodeBase64UrlBytes(h))) as DpopHeader
payload = JSON.parse(new TextDecoder().decode(decodeBase64UrlBytes(p))) as DpopPayload
} catch {
return false
}
if (header.jwk?.kty !== 'OKP' || header.jwk?.crv !== 'Ed25519') return false
let rawPub: Uint8Array
try {
rawPub = decodeBase64UrlBytes(header.jwk.x)
} catch {
return false
}
if ((await jwkThumbprint(rawPub)) !== expectedJkt) return false
if (payload.htu !== dpop.htu || payload.htm !== dpop.htm) return false
if (typeof payload.iat !== 'number' || Math.abs(now - payload.iat) > DPOP_MAX_AGE_SEC) return false
const signed = new TextEncoder().encode(`${h}.${p}`)
const pubKey = await importEd25519PublicRaw(rawPub)
let sig: Uint8Array
try {
sig = decodeBase64UrlBytes(s)
} catch {
return false
}
if (!(await verifyEd25519(pubKey, sig, signed))) return false
return rememberDpop(payload.jti, payload.iat + DPOP_MAX_AGE_SEC, now)
}
/** Build a DPoP proof (client-side helper; also used by tests). Signs with the ephemeral key. */
export async function buildDpopProof(
ephemeralPrivate: CryptoKey,
ephemeralPublicRaw: Uint8Array,
ctx: { htu: string; htm: string; jti: string; iat: number },
): Promise<string> {
const header: DpopHeader = {
typ: 'dpop+ed25519',
jwk: { crv: 'Ed25519', kty: 'OKP', x: encodeBase64UrlBytes(ephemeralPublicRaw) },
}
const payload: DpopPayload = { htu: ctx.htu, htm: ctx.htm, jti: ctx.jti, iat: ctx.iat }
const enc = (o: unknown) => encodeBase64UrlBytes(new TextEncoder().encode(JSON.stringify(o)))
const h = enc(header)
const p = enc(payload)
const { signEd25519 } = await import('../crypto/ed25519.js')
const sig = await signEd25519(ephemeralPrivate, new TextEncoder().encode(`${h}.${p}`))
return `${h}.${p}.${encodeBase64UrlBytes(sig)}`
}
/** Least-privilege check (INV15). */
export function hasRight(token: CapabilityToken, right: CapabilityToken['rights'][number]): boolean {
return token.rights.includes(right)
}