Files
web-terminal/term-relay/data-plane/upgrade.ts
Yaojia Wang 2af57e6686 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.
2026-07-02 06:10:16 +02:00

125 lines
5.4 KiB
TypeScript

/**
* T8 · Upgrade authorization edge — a THIN ADAPTER over P5 (FIX 6a). Holds NO independent authz
* logic: it (a) parses the upgrade (subdomain + FIX-5 token extraction + salted remoteAddr hash),
* (b) resolves the subdomain → requestedHostId via P3's RouteResolver, (c) builds P5's
* UpgradeContext, (d) calls the injected P5 Authorizer, (e) maps AuthzOutcome → §4.1 MuxOpen or a
* 401/403. Every accept/reject verdict is P5's; the only LOCAL failures are the three parse guards.
*
* Security: capability token rides ONLY the two FIX-5 carriers (subprotocol PREFERRED, cookie
* FALLBACK) — NEVER the query string (bearer-equivalent secrets must not leak into logs/history/
* Referer). The accepted subprotocol echoed back is ALWAYS `APP_SUBPROTOCOL`, never the token
* entry (INV15). Identity (`host_id`/`account_id`) derives only from the signed token + registry
* via P5 (INV3). Logging discipline (INV9): callers MUST NOT log req.url / Authorization /
* Sec-WebSocket-Protocol — only `{subdomain, hostId, jti, decision, ts}`.
*/
import { createHmac } from 'node:crypto'
import { APP_SUBPROTOCOL, extractTokenFromSubprotocols, type MuxOpen, type CapabilityRight } from 'relay-contracts'
import { extractSubdomain, type RouteResolver } from './subdomain-router.js'
import type { Authorizer, UpgradeContext } from './authz-port.js'
/** Short-lived HttpOnly; Secure; SameSite=Strict cookie carrying the capability token (fallback). */
export const TOKEN_COOKIE_NAME = 'term_cap'
export interface UpgradeRequest {
readonly host: string
readonly origin: string | undefined
readonly url: string // requestPath ONLY (opaque passthrough) — NEVER a token source
readonly subprotocols: readonly string[] // parsed Sec-WebSocket-Protocol values (FIX-5 transport)
readonly cookies: Readonly<Record<string, string>> // parsed Cookie header (fallback transport)
readonly remoteAddr: string
readonly dpop: UpgradeContext['dpop']
readonly activeSessionCount: number
readonly sessionId?: string // present ⇒ reattach path (→ onReattach)
}
export type UpgradeDecision =
| {
readonly ok: true
readonly open: MuxOpen
readonly hostId: string
readonly acceptedSubprotocol: string
}
| { readonly ok: false; readonly status: 401 | 403 }
export interface AuthorizeDeps {
authorizer: Authorizer
resolver: RouteResolver
baseDomain: string
now: () => number
remoteAddrSalt: string
requiredRight: CapabilityRight
}
/**
* Extract the capability token from EXACTLY the two allowed FIX-5 carriers, in order:
* (1) `Sec-WebSocket-Protocol` `term.token.<b64u>` entry, (2) the `term_cap` cookie. Returns the
* opaque token + which carrier, or null. NEVER reads `req.url` (query-string tokens forbidden).
*/
export function extractCapabilityToken(
req: UpgradeRequest,
): { token: string; via: 'subprotocol' | 'cookie' } | null {
const fromSub = extractTokenFromSubprotocols(req.subprotocols)
if (fromSub !== null && fromSub.length > 0) return { token: fromSub, via: 'subprotocol' }
const fromCookie = req.cookies[TOKEN_COOKIE_NAME]
if (fromCookie !== undefined && fromCookie.length > 0) return { token: fromCookie, via: 'cookie' }
return null
}
/** Salted HMAC-SHA256 of the client IP, truncated — audit-only, never reversible (INV10). */
function hashRemoteAddr(salt: string, remoteAddr: string): string {
return createHmac('sha256', salt).update(remoteAddr).digest('hex').slice(0, 32)
}
/** The opaque request path (everything the relay forwards but NEVER parses for authz). */
function pathOf(url: string): string {
return url
}
/**
* Parse → resolve → build ctx → delegate to P5 → map. No decision branches beyond the three
* LOCAL parse guards (unparseable Host → 401, no token carrier → 401, unknown subdomain → 403).
*/
export async function authorizeUpgrade(
req: UpgradeRequest,
deps: AuthorizeDeps,
): Promise<UpgradeDecision> {
const subdomain = extractSubdomain(req.host, deps.baseDomain)
if (subdomain === null) return { ok: false, status: 401 } // unparseable Host (local)
const extracted = extractCapabilityToken(req)
if (extracted === null) return { ok: false, status: 401 } // no token carrier present (local)
const resolved = await deps.resolver.resolveSubdomain(subdomain)
if (resolved === null) return { ok: false, status: 403 } // unknown subdomain (local)
const remoteAddrHash = hashRemoteAddr(deps.remoteAddrSalt, req.remoteAddr)
const ctx: UpgradeContext = {
capabilityRaw: extracted.token,
originHeader: req.origin ?? '',
expectedAud: subdomain,
requestedHostId: resolved.hostId, // P5 gates token.host against this (INV1)
requiredRight: deps.requiredRight,
remoteAddrHash,
activeSessionCount: req.activeSessionCount,
dpop: req.dpop,
principal: null, // P5 resolves from the signed token (INV3)
}
const outcome =
req.sessionId !== undefined
? await deps.authorizer.onReattach({ ...ctx, sessionId: req.sessionId }, deps.now())
: await deps.authorizer.onUpgrade(ctx, deps.now())
if (!outcome.ok) return { ok: false, status: outcome.status }
const open: MuxOpen = {
streamId: 0, // allocated by MuxSession
subdomain,
requestPath: pathOf(req.url),
originHeader: req.origin ?? '',
remoteAddrHash,
capabilityTokenRef: outcome.jti, // OQ5 resolved: P5 surfaces the verified jti
}
return { ok: true, open, hostId: outcome.hostId, acceptedSubprotocol: APP_SUBPROTOCOL }
}