RELAY-PHASE1 Wave A2/B/C/D/E (12-agent workflow, all tsc-clean, 314/314 tests pass): - A2: control-plane server.ts entry + boot/redis.ts revocation-bus wiring + start script. - B1: relay-run shared-store EnforceDeps (relay-auth ports over the SAME Postgres+Redis as P3). - B2: registry-backed MtlsVerifier (verifyAgentCert, fail-closed, INV14). - B3: store-backed RouteResolver (subdomain->hostId). - B4: Redis relay:revocations subscriber -> tunnel teardown (INV12). - B5: main-phase1.ts production entry (public bind, real TLS, async-mTLS prefetch bridge) + staging /auth/mint. - B6 (PARTIAL): relay-web operator login + browser DPoP; proof offered via term.dpop.<b64u> subprotocol. - C: agent dist/cli.js build (esbuild) + runTunnel run-loop + CliDeps. - D1: same-origin static serve of relay-web/public from the browser WSS. - E: systemd units + gen-ca/gen-capability-key/issue-tls-cert scripts + deploy/RUNBOOK.md. Adversarial review: all hard invariants PASS. Follow-ups (B7): close DPoP-subprotocol read on browser-server (blocks browser connect); rate-limit /auth/mint (F1); wire activeSessionCount (F2); scrub error logs (F5). Excludes unrelated public/style.css (concurrent iOS job).
146 lines
5.9 KiB
TypeScript
146 lines
5.9 KiB
TypeScript
/**
|
|
* T4 — `TerminalTransport` seam + `PassthroughTransport` (v0.8, no E2E).
|
|
*
|
|
* The abstraction that lets browser-side E2E (T8) "drop in" without reshaping the view: both the
|
|
* passthrough and the E2E transport implement `TerminalTransport`, so `terminal-view.ts` never
|
|
* changes. In v0.8 there is NO capability token (INDEX §1) — the same-origin signed cookie (T3)
|
|
* authenticates the upgrade and the base-app Origin/CSWSH check (M6) is retained.
|
|
*
|
|
* Token attachment (v0.9+): the §4.3 capability token rides the `Sec-WebSocket-Protocol`
|
|
* subprotocol list (frozen §4.3 wire format via `encodeTokenSubprotocol`), NEVER the URL/query
|
|
* string (which would leak the bearer credential into proxy/access logs, history, and `Referer`).
|
|
* After open, the client asserts the echoed subprotocol is exactly `APP_SUBPROTOCOL` and tears the
|
|
* socket down otherwise (echo rule).
|
|
*/
|
|
import { APP_SUBPROTOCOL, encodeTokenSubprotocol } from 'relay-contracts'
|
|
import type { RelayWebConfig } from './config'
|
|
import { encodeDpopSubprotocol } from './dpop'
|
|
|
|
/** The seam every transport implements — plaintext in the browser, encoded by the impl. */
|
|
export interface TerminalTransport {
|
|
open(): Promise<void>
|
|
send(bytes: Uint8Array): void
|
|
onMessage(cb: (bytes: Uint8Array) => void): void
|
|
onClose(cb: (reason: string) => void): void
|
|
close(): void
|
|
}
|
|
|
|
/** Minimal structural WebSocket surface (so tests can inject a mock, no jsdom WS dependency). */
|
|
export interface WebSocketLike {
|
|
binaryType: string
|
|
protocol: string
|
|
send(data: ArrayBufferView | ArrayBufferLike | string): void
|
|
close(code?: number, reason?: string): void
|
|
onopen: ((ev: unknown) => void) | null
|
|
onmessage: ((ev: { data: unknown }) => void) | null
|
|
onclose: ((ev: { code?: number; reason?: string }) => void) | null
|
|
onerror: ((ev: unknown) => void) | null
|
|
}
|
|
|
|
export type WebSocketCtor = new (url: string, protocols?: string | readonly string[]) => WebSocketLike
|
|
|
|
export interface PassthroughOpts {
|
|
/** v0.9+ populates this; ABSENT in v0.8 (cookie-only auth). */
|
|
readonly capabilityToken?: string
|
|
/**
|
|
* Phase-1 STAGING (B6): a DPoP proof-of-possession JWS bound to the token's `cnf.jkt`. Browsers
|
|
* can't set the `dpop` request header on a native WS upgrade, so — mirroring the §4.3 token — it
|
|
* rides an extra `term.dpop.<b64u>` subprotocol entry (see dpop.ts `encodeDpopSubprotocol`). The
|
|
* current relay ignores it (its `handleProtocols` echoes only `APP_SUBPROTOCOL`), so the handshake
|
|
* is unaffected; true DPoP enforcement needs the relay to read this entry (server-lane, VPS-gated).
|
|
*/
|
|
readonly dpopProof?: string
|
|
/** DI seam: inject a mock WebSocket constructor in tests; defaults to global `WebSocket`. */
|
|
readonly wsCtor?: WebSocketCtor
|
|
}
|
|
|
|
/** Map a WS close event to a stable reason string the UI can render (no silent swallow). */
|
|
function closeReason(ev: { code?: number; reason?: string }): string {
|
|
if (ev.code === 4403) return 'forbidden' // INV1/INV6 upgrade denial mirror
|
|
if (ev.code === 4401) return 'unauthenticated'
|
|
if (ev.reason && ev.reason.length > 0) return ev.reason
|
|
return 'closed'
|
|
}
|
|
|
|
/** Coerce a WS message payload (ArrayBuffer | ArrayBufferView | string) to bytes. */
|
|
function toBytes(data: unknown): Uint8Array {
|
|
if (data instanceof Uint8Array) return data
|
|
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
|
if (ArrayBuffer.isView(data)) {
|
|
const view = data as ArrayBufferView
|
|
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
|
|
}
|
|
if (typeof data === 'string') return new TextEncoder().encode(data)
|
|
return new Uint8Array(0)
|
|
}
|
|
|
|
export function createPassthroughTransport(
|
|
cfg: RelayWebConfig,
|
|
opts: PassthroughOpts = {},
|
|
): TerminalTransport {
|
|
const Ctor: WebSocketCtor = opts.wsCtor ?? (globalThis.WebSocket as unknown as WebSocketCtor)
|
|
const token = opts.capabilityToken
|
|
const dpopProof = opts.dpopProof
|
|
const hasBearer = token !== undefined || dpopProof !== undefined
|
|
const url = cfg.wsUrl('/term') // SAME-ORIGIN, scheme-following; NEVER carries a token (T4 §)
|
|
|
|
let ws: WebSocketLike | null = null
|
|
let messageCb: ((bytes: Uint8Array) => void) | null = null
|
|
let closeCb: ((reason: string) => void) | null = null
|
|
|
|
// App subprotocol FIRST; token entry (v0.9+) second; DPoP proof entry (Phase-1 B6) last — a
|
|
// bearer NEVER touches the URL/query (would leak it to proxy/access logs, history, and Referer).
|
|
const protocols: readonly string[] = [
|
|
APP_SUBPROTOCOL,
|
|
...(token === undefined ? [] : [encodeTokenSubprotocol(token)]),
|
|
...(dpopProof === undefined ? [] : [encodeDpopSubprotocol(dpopProof)]),
|
|
]
|
|
|
|
function open(): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const socket = new Ctor(url, protocols)
|
|
socket.binaryType = 'arraybuffer'
|
|
ws = socket
|
|
|
|
socket.onopen = () => {
|
|
// Echo rule (v0.9+): with a bearer attached (token and/or DPoP), accept ONLY the app
|
|
// subprotocol back — a relay echoing a bearer entry, an empty value, or a foreign one is
|
|
// rejected (bearer-leak guard).
|
|
if (hasBearer && socket.protocol !== APP_SUBPROTOCOL) {
|
|
socket.close(4400, 'bad-subprotocol')
|
|
reject(new Error(`unexpected echoed subprotocol: '${socket.protocol}'`))
|
|
return
|
|
}
|
|
resolve()
|
|
}
|
|
socket.onmessage = (ev) => {
|
|
if (messageCb) messageCb(toBytes(ev.data))
|
|
}
|
|
socket.onclose = (ev) => {
|
|
if (closeCb) closeCb(closeReason(ev))
|
|
}
|
|
socket.onerror = () => {
|
|
// Surface as a close so callers never hang; open() rejection is handled by onclose too.
|
|
reject(new Error('websocket error'))
|
|
}
|
|
})
|
|
}
|
|
|
|
return {
|
|
open,
|
|
send(bytes: Uint8Array): void {
|
|
if (!ws) throw new Error('transport not open')
|
|
ws.send(bytes)
|
|
},
|
|
onMessage(cb: (bytes: Uint8Array) => void): void {
|
|
messageCb = cb
|
|
},
|
|
onClose(cb: (reason: string) => void): void {
|
|
closeCb = cb
|
|
},
|
|
close(): void {
|
|
if (ws) ws.close()
|
|
},
|
|
}
|
|
}
|