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

96
agent/src/enroll/csr.ts Normal file
View File

@@ -0,0 +1,96 @@
/**
* PKCS#10 CSR over the Ed25519 identity — PLAN_RELAY_AGENT T4.
*
* Node has no built-in CSR generator, so this is a compact, self-contained DER encoder that
* emits a standard PKCS#10 CertificationRequest signed with the in-process Ed25519 key (INV4 —
* only the PUBLIC key + a signature leave; the private key is never serialized here).
*
* NOTE (cross-plan / open Q#1): the exact SPIFFE-style cert PROFILE (SAN = subdomain vs a SPIFFE
* URI) is set by P3's CA. This builds the standard subject-CN PKCS#10 shape; when P3 freezes its
* profile, extend `buildCsr`'s attributes here. That is the single integration point.
*/
import type { AgentIdentity } from '../keys/identity.js'
// --- minimal DER encoding helpers -------------------------------------------------------------
function derLen(len: number): Uint8Array {
if (len < 0x80) return Uint8Array.from([len])
const bytes: number[] = []
let n = len
while (n > 0) {
bytes.unshift(n & 0xff)
n >>= 8
}
return Uint8Array.from([0x80 | bytes.length, ...bytes])
}
function tlv(tag: number, value: Uint8Array): Uint8Array {
const len = derLen(value.length)
const out = new Uint8Array(1 + len.length + value.length)
out[0] = tag
out.set(len, 1)
out.set(value, 1 + len.length)
return out
}
function concat(chunks: readonly Uint8Array[]): Uint8Array {
const total = chunks.reduce((s, c) => s + c.length, 0)
const out = new Uint8Array(total)
let off = 0
for (const c of chunks) {
out.set(c, off)
off += c.length
}
return out
}
const SEQUENCE = 0x30
const SET = 0x31
const INTEGER = 0x02
const BIT_STRING = 0x03
const OID = 0x06
const UTF8_STRING = 0x0c
const CONTEXT_0 = 0xa0
// OID 2.5.4.3 (commonName) and 1.3.101.112 (Ed25519) as pre-encoded DER value bytes.
const OID_CN = Uint8Array.from([0x55, 0x04, 0x03])
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70])
/** SubjectPublicKeyInfo DER for a raw Ed25519 public key (fixed 44-byte structure). */
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
const algId = tlv(SEQUENCE, tlv(OID, OID_ED25519))
const pubBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), raw]))
return tlv(SEQUENCE, concat([algId, pubBits]))
}
/** X.501 Name with a single CN=<subject> RDN. */
function nameFromCn(cn: string): Uint8Array {
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
const rdn = tlv(SET, atv)
return tlv(SEQUENCE, rdn)
}
function toPem(der: Uint8Array, label: string): string {
const b64 = Buffer.from(der).toString('base64')
const lines = b64.match(/.{1,64}/g) ?? []
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
}
/**
* Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the Ed25519 key.
* The private key is used in-process only; never serialized into the output (INV4).
*/
export function buildCsr(id: AgentIdentity, subject: string): string {
const version = tlv(INTEGER, Uint8Array.from([0x00]))
const name = nameFromCn(subject)
const spki = spkiFromRawEd25519(id.publicKey)
const attributes = tlv(CONTEXT_0, new Uint8Array(0)) // [0] IMPLICIT empty SET OF Attribute
const requestInfo = tlv(SEQUENCE, concat([version, name, spki, attributes]))
const signature = id.sign(requestInfo) // Ed25519 over CertificationRequestInfo
const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519))
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
const csr = tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
return toPem(csr, 'CERTIFICATE REQUEST')
}

137
agent/src/enroll/pair.ts Normal file
View File

@@ -0,0 +1,137 @@
/**
* §4.5 pairing-code redemption (agent side) — PLAN_RELAY_AGENT T4.
*
* REDEEM: POST /enroll { code, agentPubkey, csr } (raw code NEVER persisted — INV5).
* RETURN: the FROZEN EnrollResult (imported from relay-contracts, never redeclared), validated
* with EnrollResultSchema at the boundary (untrusted external data). On success the FIX 3
* `hostContentSecret` (wrapped to this agent's Ed25519 identity by P3) is UNWRAPPED in-process
* and persisted 0600 via the keystore; the WRAPPED bytes are never stored, the unwrapped secret
* is never logged/re-sent (INV5/INV9).
*
* Only the PUBLIC key + CSR leave the host (INV4).
*/
import { EnrollResultSchema, decodeBase64UrlBytes, encodeBase64UrlBytes } from 'relay-contracts'
import type { EnrollResult } from 'relay-contracts'
import type { AgentIdentity } from '../keys/identity.js'
import type { Keystore } from '../keys/keystore.js'
import { buildCsr } from './csr.js'
/** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */
export type EnrollMode = 'token' | 'ed25519'
export const DEFAULT_ENROLL_MODE: EnrollMode = 'ed25519'
export class EnrollError extends Error {
constructor(message: string) {
super(message)
this.name = 'EnrollError'
}
}
export class PairingCodeSpentError extends EnrollError {
constructor() {
super('pairing code already redeemed (single-use)')
this.name = 'PairingCodeSpentError'
}
}
export class PairingCodeExpiredError extends EnrollError {
constructor() {
super('pairing code expired')
this.name = 'PairingCodeExpiredError'
}
}
/**
* Unwrap the P3-wrapped `hostContentSecret` using the agent's enrollment identity (in-process).
* P3's exact wrap scheme is not yet frozen (cross-plan integration point); this seam is injected
* so the real unwrap drops in without touching the redeem flow. Default = passthrough.
*/
export type UnwrapContentSecret = (wrapped: Uint8Array, id: AgentIdentity) => Uint8Array
const passthroughUnwrap: UnwrapContentSecret = (wrapped) => wrapped
export interface RedeemOptions {
readonly fetchImpl?: typeof fetch
readonly mode?: EnrollMode
readonly agentToken?: string
readonly unwrapContentSecret?: UnwrapContentSecret
readonly subject?: string
}
interface EnrollResponseJson {
hostId: string
subdomain: string
cert: string
caChain: string
hostContentSecret: string // base64url over the wire
}
function parseEnrollResult(json: unknown): EnrollResult {
const j = json as Partial<EnrollResponseJson>
if (typeof j.hostContentSecret !== 'string') {
throw new EnrollError('enroll response missing hostContentSecret')
}
const candidate = {
hostId: j.hostId,
subdomain: j.subdomain,
cert: j.cert,
caChain: j.caChain,
hostContentSecret: decodeBase64UrlBytes(j.hostContentSecret),
}
const result = EnrollResultSchema.safeParse(candidate)
if (!result.success) {
throw new EnrollError(`enroll response failed schema validation: ${result.error.message}`)
}
return result.data
}
/**
* Redeem `code` at `enrollUrl`. Sends only pubkey + CSR (INV4); never persists the raw code
* (INV5). Stores the returned cert + CA chain and the unwrapped hostContentSecret 0600.
*/
export async function redeemPairingCode(
enrollUrl: string,
code: string,
id: AgentIdentity,
ks: Keystore,
opts: RedeemOptions = {},
): Promise<EnrollResult> {
const doFetch = opts.fetchImpl ?? fetch
const mode = opts.mode ?? DEFAULT_ENROLL_MODE
const unwrap = opts.unwrapContentSecret ?? passthroughUnwrap
const body =
mode === 'token'
? { code, agentToken: opts.agentToken ?? '' }
: {
code,
agentPubkey: encodeBase64UrlBytes(id.publicKey),
csr: buildCsr(id, opts.subject ?? 'web-terminal-agent'),
}
let res: Response
try {
res = await doFetch(enrollUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
} catch (err) {
throw new EnrollError(`enroll request failed: ${(err as Error).message}`)
}
if (res.status === 409) throw new PairingCodeSpentError()
if (res.status === 410) throw new PairingCodeExpiredError()
if (!res.ok) throw new EnrollError(`enroll returned HTTP ${res.status}`)
let json: unknown
try {
json = await res.json()
} catch (err) {
throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`)
}
const enroll = parseEnrollResult(json)
ks.saveCert(enroll.cert, enroll.caChain)
// FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored).
const unwrapped = unwrap(enroll.hostContentSecret, id)
ks.saveContentSecret(unwrapped)
return enroll
}