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,50 @@
/**
* Ed25519 primitives over WebCrypto (`globalThis.crypto.subtle`) — no native binary, no external
* dependency. Keys are `CryptoKey` (matching the plan's frozen signatures). Node 18.4+ / all
* browsers expose Ed25519 in SubtleCrypto.
*/
const subtle = globalThis.crypto.subtle
const ED25519 = { name: 'Ed25519' } as const
/** Coerce to an ArrayBuffer-backed view (WebCrypto's `BufferSource` requires `Uint8Array<ArrayBuffer>`). */
function ab(u: Uint8Array): Uint8Array<ArrayBuffer> {
const out = new Uint8Array(u.byteLength)
out.set(u)
return out
}
/** Local key-pair shape (avoids the DOM-only `CryptoKeyPair` global name under the Node lib set). */
export interface Ed25519KeyPair {
readonly publicKey: CryptoKey
readonly privateKey: CryptoKey
}
export async function generateEd25519KeyPair(): Promise<Ed25519KeyPair> {
return (await subtle.generateKey(ED25519, true, ['sign', 'verify'])) as Ed25519KeyPair
}
export async function signEd25519(privateKey: CryptoKey, data: Uint8Array): Promise<Uint8Array> {
const sig = await subtle.sign(ED25519, privateKey, ab(data))
return new Uint8Array(sig)
}
export async function verifyEd25519(
publicKey: CryptoKey,
signature: Uint8Array,
data: Uint8Array,
): Promise<boolean> {
return subtle.verify(ED25519, publicKey, ab(signature), ab(data))
}
export async function importEd25519PublicRaw(raw: Uint8Array): Promise<CryptoKey> {
return subtle.importKey('raw', ab(raw), ED25519, true, ['verify'])
}
export async function exportEd25519PublicRaw(publicKey: CryptoKey): Promise<Uint8Array> {
return new Uint8Array(await subtle.exportKey('raw', publicKey))
}
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
return new Uint8Array(await subtle.digest('SHA-256', ab(data)))
}

View File

@@ -0,0 +1,101 @@
/**
* Minimal PASETO v4.public token (Ed25519) — the format the plan explicitly RECOMMENDS for the
* §4.3 capability token (open-Q #1: "recommend PASETO v4.public: no alg-confusion, no `alg:none`
* foot-gun"). Self-contained: no external dependency, no native binary. The header is fixed
* (`v4.public.`) so there is NO `alg` field to confuse — the algorithm is Ed25519 by construction.
*
* INTEGRATION NOTE: the concrete token-format pick (PASETO v4.public vs JWS EdDSA) awaits a
* one-line PLAN_RELAY_INDEX §4.3 confirmation (plan §5 open-Q #1). This implements the plan's
* stated recommendation; swapping to JWS EdDSA would touch only this file + verify.ts.
*/
import { encodeBase64UrlBytes, decodeBase64UrlBytes } from 'relay-contracts'
import { signEd25519, verifyEd25519 } from './ed25519.js'
const HEADER = 'v4.public.'
const SIG_BYTES = 64
const textEncoder = new TextEncoder()
const textDecoder = new TextDecoder('utf-8', { fatal: true })
/** Little-endian uint64 (PASETO PAE length prefix; MSB of the high word is cleared per spec). */
function le64(n: number): Uint8Array {
const out = new Uint8Array(8)
let value = n
for (let i = 0; i < 8; i++) {
if (i === 7) value &= 0x7f
out[i] = value & 0xff
value = Math.floor(value / 256)
}
return out
}
/** PASETO Pre-Authentication Encoding (PAE) of a list of byte pieces. */
function pae(pieces: readonly Uint8Array[]): Uint8Array {
const parts: Uint8Array[] = [le64(pieces.length)]
for (const piece of pieces) {
parts.push(le64(piece.length))
parts.push(piece)
}
let total = 0
for (const p of parts) total += p.length
const out = new Uint8Array(total)
let off = 0
for (const p of parts) {
out.set(p, off)
off += p.length
}
return out
}
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
const out = new Uint8Array(a.length + b.length)
out.set(a, 0)
out.set(b, a.length)
return out
}
/** Sign an arbitrary JSON-serializable claims object into a `v4.public.` token string. */
export async function signPaseto(claims: unknown, privateKey: CryptoKey): Promise<string> {
const message = textEncoder.encode(JSON.stringify(claims))
const header = textEncoder.encode(HEADER)
const m2 = pae([header, message, new Uint8Array(0), new Uint8Array(0)])
const sig = await signEd25519(privateKey, m2)
return HEADER + encodeBase64UrlBytes(concat(message, sig))
}
export class PasetoError extends Error {
constructor(message: string) {
super(message)
this.name = 'PasetoError'
}
}
/** Verify a `v4.public.` token's Ed25519 signature and return the decoded JSON claims. */
export async function verifyPaseto(token: string, publicKey: CryptoKey): Promise<unknown> {
if (!token.startsWith(HEADER)) throw new PasetoError('bad PASETO header')
const body = decodeBase64UrlBytes(token.slice(HEADER.length))
if (body.length < SIG_BYTES) throw new PasetoError('truncated PASETO body')
const message = body.subarray(0, body.length - SIG_BYTES)
const sig = body.subarray(body.length - SIG_BYTES)
const header = textEncoder.encode(HEADER)
const m2 = pae([header, message, new Uint8Array(0), new Uint8Array(0)])
const ok = await verifyEd25519(publicKey, sig, m2)
if (!ok) throw new PasetoError('signature verification failed')
try {
return JSON.parse(textDecoder.decode(message))
} catch {
throw new PasetoError('claims are not valid JSON')
}
}
/** Decode claims WITHOUT verifying the signature (only safe AFTER a prior verify — e.g. read exp). */
export function peekPasetoClaims(token: string): unknown {
if (!token.startsWith(HEADER)) throw new PasetoError('bad PASETO header')
const body = decodeBase64UrlBytes(token.slice(HEADER.length))
if (body.length < SIG_BYTES) throw new PasetoError('truncated PASETO body')
const message = body.subarray(0, body.length - SIG_BYTES)
try {
return JSON.parse(textDecoder.decode(message))
} catch {
throw new PasetoError('claims are not valid JSON')
}
}

View File

@@ -0,0 +1,24 @@
/**
* RFC 7638 / RFC 8037 JWK thumbprint for an Ed25519 (OKP) public key — the `cnf.jkt`
* proof-of-possession binding (DPoP-style, Finding-4). Members are serialized in the
* REQUIRED lexicographic order `crv,kty,x` with no whitespace.
*/
import { encodeBase64UrlBytes } from 'relay-contracts'
import { sha256 } from './ed25519.js'
/** JWK `x` value (base64url of the 32-byte raw Ed25519 public key). */
export function ed25519PublicJwkX(rawPublic: Uint8Array): string {
return encodeBase64UrlBytes(rawPublic)
}
/** Canonical Ed25519 public JWK JSON (RFC 7638 member ordering: crv, kty, x). */
export function ed25519PublicJwkJson(rawPublic: Uint8Array): string {
return JSON.stringify({ crv: 'Ed25519', kty: 'OKP', x: ed25519PublicJwkX(rawPublic) })
}
/** base64url(SHA-256(canonical-JWK)) — the `jkt` thumbprint. */
export async function jwkThumbprint(rawPublic: Uint8Array): Promise<string> {
const json = ed25519PublicJwkJson(rawPublic)
const digest = await sha256(new TextEncoder().encode(json))
return encodeBase64UrlBytes(digest)
}