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,162 @@
/**
* Minimal deterministic CBOR (RFC 8949) subset — just enough for the §4.1 MuxOpen
* payload: text strings (major 3), unsigned integers (major 0), and one flat map
* (major 5). Hand-rolled (KISS / dependency-free) so the browser and agent bundles
* agree byte-for-byte without pulling a full CBOR library.
*
* Determinism: maps are encoded in a caller-fixed key order (MuxOpen has a frozen
* field order), giving a stable byte vector for KAT tests. Decoding is order-independent
* (reads into an object, then Zod validates).
*/
import { ContractDecodeError, ContractEncodeError } from '../errors.js'
import { concatBytes } from '../bytes.js'
export type CborScalar = string | number
export type CborMap = Readonly<Record<string, CborScalar>>
const textEncoder = new TextEncoder()
const textDecoder = new TextDecoder('utf-8', { fatal: true })
/** Encode a CBOR length/value head for the given major type (0..7). */
function encodeHead(major: number, n: number): Uint8Array {
if (!Number.isInteger(n) || n < 0) {
throw new ContractEncodeError(`CBOR head requires a non-negative integer; got ${n}`)
}
const prefix = major << 5
if (n < 24) return Uint8Array.of(prefix | n)
if (n < 0x100) return Uint8Array.of(prefix | 24, n)
if (n < 0x1_0000) return Uint8Array.of(prefix | 25, (n >> 8) & 0xff, n & 0xff)
if (n < 0x1_0000_0000) {
return Uint8Array.of(
prefix | 26,
(n >>> 24) & 0xff,
(n >>> 16) & 0xff,
(n >>> 8) & 0xff,
n & 0xff,
)
}
throw new ContractEncodeError(`CBOR head value too large for this subset: ${n}`)
}
function encodeUint(n: number): Uint8Array {
return encodeHead(0, n)
}
function encodeText(s: string): Uint8Array {
const utf8 = textEncoder.encode(s)
return concatBytes([encodeHead(3, utf8.length), utf8])
}
function encodeScalar(value: CborScalar): Uint8Array {
if (typeof value === 'string') return encodeText(value)
if (typeof value === 'number') {
if (!Number.isInteger(value) || value < 0) {
throw new ContractEncodeError(`CBOR scalar number must be a non-negative integer; got ${value}`)
}
return encodeUint(value)
}
throw new ContractEncodeError(`unsupported CBOR scalar type: ${typeof value}`)
}
/**
* Encode a flat map in the caller-provided key order (deterministic).
* `keyOrder` must list exactly the keys present in `map`.
*/
export function encodeCborMap(map: CborMap, keyOrder: readonly string[]): Uint8Array {
const keys = Object.keys(map)
if (keys.length !== keyOrder.length) {
throw new ContractEncodeError(
`CBOR map has ${keys.length} keys but keyOrder lists ${keyOrder.length}`,
)
}
const chunks: Uint8Array[] = [encodeHead(5, keyOrder.length)]
for (const key of keyOrder) {
if (!Object.prototype.hasOwnProperty.call(map, key)) {
throw new ContractEncodeError(`CBOR map missing key '${key}' named in keyOrder`)
}
chunks.push(encodeText(key))
chunks.push(encodeScalar(map[key]!))
}
return concatBytes(chunks)
}
interface Cursor {
readonly buf: Uint8Array
offset: number
}
function readHead(c: Cursor): { major: number; value: number } {
if (c.offset >= c.buf.length) {
throw new ContractDecodeError('CBOR truncated: expected a head byte')
}
const first = c.buf[c.offset]!
c.offset += 1
const major = first >> 5
const info = first & 0x1f
if (info < 24) return { major, value: info }
if (info === 24) {
if (c.offset + 1 > c.buf.length) throw new ContractDecodeError('CBOR truncated uint8 arg')
const v = c.buf[c.offset]!
c.offset += 1
return { major, value: v }
}
if (info === 25) {
if (c.offset + 2 > c.buf.length) throw new ContractDecodeError('CBOR truncated uint16 arg')
const v = (c.buf[c.offset]! << 8) | c.buf[c.offset + 1]!
c.offset += 2
return { major, value: v }
}
if (info === 26) {
if (c.offset + 4 > c.buf.length) throw new ContractDecodeError('CBOR truncated uint32 arg')
const v =
((c.buf[c.offset]! << 24) >>> 0) +
(c.buf[c.offset + 1]! << 16) +
(c.buf[c.offset + 2]! << 8) +
c.buf[c.offset + 3]!
c.offset += 4
return { major, value: v }
}
throw new ContractDecodeError(`unsupported CBOR additional-info ${info} in this subset`)
}
function readScalar(c: Cursor): CborScalar {
const { major, value } = readHead(c)
if (major === 0) return value // unsigned int
if (major === 3) {
// text string
const end = c.offset + value
if (end > c.buf.length) throw new ContractDecodeError('CBOR truncated text string')
const slice = c.buf.subarray(c.offset, end)
c.offset = end
try {
return textDecoder.decode(slice)
} catch {
throw new ContractDecodeError('CBOR text string is not valid UTF-8')
}
}
throw new ContractDecodeError(`unsupported CBOR scalar major type ${major} in this subset`)
}
/** Decode a flat CBOR map into a plain object of scalars. Rejects trailing bytes. */
export function decodeCborMap(buf: Uint8Array): Record<string, CborScalar> {
const c: Cursor = { buf, offset: 0 }
const { major, value: count } = readHead(c)
if (major !== 5) {
throw new ContractDecodeError(`expected CBOR map (major 5); got major ${major}`)
}
const out: Record<string, CborScalar> = {}
for (let i = 0; i < count; i++) {
const key = readScalar(c)
if (typeof key !== 'string') {
throw new ContractDecodeError('CBOR map key must be a text string')
}
if (Object.prototype.hasOwnProperty.call(out, key)) {
throw new ContractDecodeError(`duplicate CBOR map key '${key}'`)
}
out[key] = readScalar(c)
}
if (c.offset !== buf.length) {
throw new ContractDecodeError(`trailing bytes after CBOR map: ${buf.length - c.offset} extra`)
}
return out
}

View File

@@ -0,0 +1,64 @@
/**
* §4.1 control-frame payload codecs: WINDOW_UPDATE and GOAWAY.
*
* Per §4.1's explicit byte spec these are fixed-width big-endian integers (NOT CBOR):
* WINDOW_UPDATE payload = uint32 credit
* GOAWAY payload = uint32 lastStreamId ‖ uint32 reason
* (PLAN_RELAY_INDEX §2.1 loosely groups them under "CBOR payload codecs"; §4.1 is the
* authoritative wire and wins — see report deviation note.)
*/
import { ContractDecodeError } from '../errors.js'
import { assertUint32, readUint32BE, writeUint32BE } from '../bytes.js'
import {
GOAWAY_CODE_TO_REASON,
GOAWAY_REASON_TO_CODE,
type GoAwayReason,
} from './types.js'
/** Map a GOAWAY wire code to its frozen reason label; throws on unknown code. */
export function decodeGoAwayReason(code: number): GoAwayReason {
const reason = GOAWAY_CODE_TO_REASON[code]
if (reason === undefined) {
throw new ContractDecodeError(`unknown GOAWAY reason code ${code}`)
}
return reason
}
/** WINDOW_UPDATE: encode a uint32 credit increment. */
export function encodeWindowUpdate(credit: number): Uint8Array {
assertUint32(credit, 'credit')
const out = new Uint8Array(4)
writeUint32BE(out, 0, credit)
return out
}
/** WINDOW_UPDATE: decode the uint32 credit increment. */
export function decodeWindowUpdate(payload: Uint8Array): number {
if (payload.length !== 4) {
throw new ContractDecodeError(`WINDOW_UPDATE payload must be 4 bytes; got ${payload.length}`)
}
return readUint32BE(payload, 0)
}
/** GOAWAY: encode uint32 lastStreamId ‖ uint32 reason-code. */
export function encodeGoaway(lastStreamId: number, reason: GoAwayReason): Uint8Array {
assertUint32(lastStreamId, 'lastStreamId')
const code = GOAWAY_REASON_TO_CODE[reason]
if (code === undefined) {
throw new ContractDecodeError(`unknown GOAWAY reason label '${reason}'`)
}
const out = new Uint8Array(8)
let offset = writeUint32BE(out, 0, lastStreamId)
writeUint32BE(out, offset, code)
return out
}
/** GOAWAY: decode uint32 lastStreamId ‖ uint32 reason-code. */
export function decodeGoaway(payload: Uint8Array): { lastStreamId: number; reason: GoAwayReason } {
if (payload.length !== 8) {
throw new ContractDecodeError(`GOAWAY payload must be 8 bytes; got ${payload.length}`)
}
const lastStreamId = readUint32BE(payload, 0)
const reason = decodeGoAwayReason(readUint32BE(payload, 4))
return { lastStreamId, reason }
}

View File

@@ -0,0 +1,97 @@
/**
* §4.1 Mux frame codec — the 15-byte fixed header + opaque payload.
*
* Layout (big-endian):
* offset size field
* 0 1 version 0x01
* 1 1 type 0x01 OPEN … 0x07 GOAWAY
* 2 1 flags bit0 FIN · bit1 RST · others reserved=0
* 3 4 streamId uint32 (0 = connection-level)
* 7 8 payloadLen uint64 (safe-int guarded)
* 15 var payload opaque bytes
*/
import { ContractDecodeError, ContractEncodeError } from '../errors.js'
import { assertUint32, readSafeUint64BE, writeUint32BE, writeUint64BE } from '../bytes.js'
import {
FLAG_FIN,
FLAG_RST,
FLAG_RESERVED_MASK,
MUX_CODE_TO_TYPE,
MUX_HEADER_BYTES,
MUX_TYPE_TO_CODE,
MUX_VERSION,
type MuxFrameHeader,
} from './types.js'
function encodeFlags(fin: boolean, rst: boolean): number {
return (fin ? FLAG_FIN : 0) | (rst ? FLAG_RST : 0)
}
/** Encode a full mux frame. `header.payloadLen` MUST equal `payload.length`. */
export function encodeMuxFrame(header: MuxFrameHeader, payload: Uint8Array): Uint8Array {
if (header.version !== MUX_VERSION) {
throw new ContractEncodeError(`unsupported mux version ${header.version} (expected ${MUX_VERSION})`)
}
const typeCode = MUX_TYPE_TO_CODE[header.type]
if (typeCode === undefined) {
throw new ContractEncodeError(`unknown mux frame type '${header.type}'`)
}
assertUint32(header.streamId, 'streamId')
if (header.payloadLen !== payload.length) {
throw new ContractEncodeError(
`header.payloadLen (${header.payloadLen}) must equal payload.length (${payload.length})`,
)
}
const out = new Uint8Array(MUX_HEADER_BYTES + payload.length)
out[0] = MUX_VERSION
out[1] = typeCode
out[2] = encodeFlags(header.fin, header.rst)
let offset = writeUint32BE(out, 3, header.streamId)
offset = writeUint64BE(out, offset, BigInt(payload.length))
out.set(payload, MUX_HEADER_BYTES)
return out
}
/** Decode ONLY the 15-byte header (no payload slice). */
export function decodeHeader(buf: Uint8Array): MuxFrameHeader {
if (buf.length < MUX_HEADER_BYTES) {
throw new ContractDecodeError(
`mux frame shorter than header: ${buf.length} < ${MUX_HEADER_BYTES}`,
)
}
const version = buf[0]!
if (version !== MUX_VERSION) {
throw new ContractDecodeError(`unsupported mux version ${version} (expected ${MUX_VERSION})`)
}
const type = MUX_CODE_TO_TYPE[buf[1]!]
if (type === undefined) {
throw new ContractDecodeError(`unknown mux frame type code 0x${buf[1]!.toString(16)}`)
}
const flags = buf[2]!
if ((flags & FLAG_RESERVED_MASK) !== 0) {
throw new ContractDecodeError(`reserved flag bits set: 0x${flags.toString(16)}`)
}
const streamId = ((buf[3]! << 24) >>> 0) + (buf[4]! << 16) + (buf[5]! << 8) + buf[6]!
const payloadLen = readSafeUint64BE(buf, 7, 'payloadLen')
return {
version: MUX_VERSION,
type,
fin: (flags & FLAG_FIN) !== 0,
rst: (flags & FLAG_RST) !== 0,
streamId,
payloadLen,
}
}
/** Decode a full mux frame into its header + payload slice. Rejects a truncated payload. */
export function decodeMuxFrame(buf: Uint8Array): { header: MuxFrameHeader; payload: Uint8Array } {
const header = decodeHeader(buf)
const end = MUX_HEADER_BYTES + header.payloadLen
if (buf.length < end) {
throw new ContractDecodeError(
`truncated payload: have ${buf.length - MUX_HEADER_BYTES} bytes, header says ${header.payloadLen}`,
)
}
const payload = buf.slice(MUX_HEADER_BYTES, end)
return { header, payload }
}

View File

@@ -0,0 +1,41 @@
/**
* §4.1 OPEN payload codec — CBOR of MuxOpen, Zod-guarded on decode.
* Deterministic fixed field order so the byte vector is stable for KAT.
*/
import { ContractDecodeError } from '../errors.js'
import { decodeCborMap, encodeCborMap, type CborMap } from './cbor.js'
import { MuxOpenSchema, type MuxOpen } from './types.js'
/** Frozen deterministic CBOR key order for MuxOpen (§4.1). */
export const MUX_OPEN_KEY_ORDER = [
'streamId',
'subdomain',
'requestPath',
'originHeader',
'remoteAddrHash',
'capabilityTokenRef',
] as const
/** Encode a MuxOpen into its CBOR payload. Validates the shape before encoding. */
export function encodeOpen(open: MuxOpen): Uint8Array {
const parsed = MuxOpenSchema.parse(open)
const map: CborMap = {
streamId: parsed.streamId,
subdomain: parsed.subdomain,
requestPath: parsed.requestPath,
originHeader: parsed.originHeader,
remoteAddrHash: parsed.remoteAddrHash,
capabilityTokenRef: parsed.capabilityTokenRef,
}
return encodeCborMap(map, MUX_OPEN_KEY_ORDER)
}
/** Decode a CBOR OPEN payload into a validated MuxOpen (throws on bad shape). */
export function decodeOpen(payload: Uint8Array): MuxOpen {
const raw = decodeCborMap(payload)
const result = MuxOpenSchema.safeParse(raw)
if (!result.success) {
throw new ContractDecodeError(`invalid MuxOpen payload: ${result.error.message}`)
}
return result.data
}

View File

@@ -0,0 +1,106 @@
/**
* §4.1 Mux frame format — shared types & frozen wire codes.
*
* Frame header is 15 bytes fixed (big-endian / network order), then `payloadLen` bytes.
* All numeric wire codes below are FROZEN by PLAN_RELAY_INDEX §4.1 and MUST NOT be
* redefined by any plan.
*/
import { z } from 'zod'
/** Fixed header size in bytes (§4.1). */
export const MUX_HEADER_BYTES = 15 as const
/** Protocol version byte (§4.1: version = 0x01). */
export const MUX_VERSION = 1 as const
/** Frame type as a string-literal union (NOT an enum), per §4.1. */
export type MuxFrameType =
| 'open'
| 'data'
| 'close'
| 'ping'
| 'pong'
| 'windowUpdate'
| 'goaway'
/** Frozen label ⇄ wire-code maps (§4.1: 0x01 OPEN … 0x07 GOAWAY). */
export const MUX_TYPE_TO_CODE: Readonly<Record<MuxFrameType, number>> = {
open: 0x01,
data: 0x02,
close: 0x03,
ping: 0x04,
pong: 0x05,
windowUpdate: 0x06,
goaway: 0x07,
}
export const MUX_CODE_TO_TYPE: Readonly<Record<number, MuxFrameType>> = {
0x01: 'open',
0x02: 'data',
0x03: 'close',
0x04: 'ping',
0x05: 'pong',
0x06: 'windowUpdate',
0x07: 'goaway',
}
/** flags byte bit positions (§4.1: bit0 FIN, bit1 RST, others reserved=0). */
export const FLAG_FIN = 0b0000_0001 as const
export const FLAG_RST = 0b0000_0010 as const
export const FLAG_RESERVED_MASK = 0b1111_1100 as const
/** streamId 0 is connection-level control (PING/PONG/GOAWAY/WINDOW_UPDATE for the whole link). */
export const CONNECTION_STREAM_ID = 0 as const
export const UINT32_MAX = 0xffff_ffff as const
/** Frame header — 15 bytes fixed (§4.1). */
export interface MuxFrameHeader {
readonly version: 1
readonly type: MuxFrameType
readonly fin: boolean
readonly rst: boolean
readonly streamId: number // uint32; 0 = connection-level
readonly payloadLen: number // uint64 (safe-int guarded ≤ maxFrameBytes)
}
/** OPEN payload (relay→agent), carried as CBOR (§4.1). Carries NO terminal bytes. */
export interface MuxOpen {
readonly streamId: number
readonly subdomain: string
readonly requestPath: string // e.g. '/term?join=<id>' — opaque passthrough
readonly originHeader: string // real browser Origin, validated end-to-end
readonly remoteAddrHash: string // salted hash of client IP (audit only, INV10)
readonly capabilityTokenRef: string // jti of the token that authorized this stream (P5)
}
/**
* Zod schema for MuxOpen — validated at the decode boundary (§4.1, "Input Validation").
* streamId is a uint32 > 0 (0 is reserved for connection-level control, never a stream OPEN).
*/
export const MuxOpenSchema = z
.object({
streamId: z.number().int().gt(0).max(UINT32_MAX),
subdomain: z.string(),
requestPath: z.string(),
originHeader: z.string(),
remoteAddrHash: z.string(),
capabilityTokenRef: z.string(),
})
.strict()
/** GOAWAY reason as a string-literal union (NOT an enum); wire code ⇄ label (§4.1). */
export type GoAwayReason = 'operatorDrain' | 'revoked' | 'shutdown'
/** Frozen reason label ⇄ wire-code maps (§4.1: 1 operatorDrain · 2 revoked · 3 shutdown). */
export const GOAWAY_REASON_TO_CODE: Readonly<Record<GoAwayReason, number>> = {
operatorDrain: 1,
revoked: 2,
shutdown: 3,
}
export const GOAWAY_CODE_TO_REASON: Readonly<Record<number, GoAwayReason>> = {
1: 'operatorDrain',
2: 'revoked',
3: 'shutdown',
}