diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 76aaf27..95470ba 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -28,7 +28,8 @@ - **目标**: DEPLOY_RELAY §4 的 **Phase 1「1b 全量持久化 staging」**。VPS 上 Docker 自建 Postgres+Redis;已备案域名走 443 + Let's Encrypt;agent 跑在操作者本机向外拨号。完整文件级方案见 [PLAN_RELAY_PHASE1.md](./PLAN_RELAY_PHASE1.md)。 - **进度**: **Wave A/B/C/D/E 代码全部完成并通过 Verify** —— 一个后台 Workflow(12 agents,0 error)跑完 A2、B1–B5、B6、C、D1、E。Verify 门槛:**四个包 tsc 全干净、可构建包全 build 成功、314/314 测试通过**(control-plane 103 / relay-run 74 / agent 137 + relay-web 118)。对抗式安全评审:**所有硬不变量 PASS**(INV2 opaque splice、Origin/CSWSH 精确匹配、mTLS INV14 registry 门控、token-mint 密码学、撤销 INV12、jti 单次、参数化 SQL)。 - **各任务**: A2 P3 server 入口+Redis 总线 `[x]`;B1 共享 store EnforceDeps `[x]`;B2 registry-backed MtlsVerifier `[x]`(异步阻抗由 B5 的 `bridgeAsyncMtls` per-DER 预取桥无锁解决);B3 store RouteResolver `[x]`;B4 撤销订阅 `[x]`;B5 `main-phase1.ts` 组合入口 + staging `/auth/mint` 签发 `[x]`;C agent `dist/cli.js` 构建+`runTunnel` 运行入口 `[x]`;D1 同源托管 relay-web `[x]`;E systemd+脚本+RUNBOOK `[x]`;**B6 relay-web 登录/DPoP `[~] PARTIAL`**。 -- **两个待收尾(B7)**: ① **功能阻塞** — 浏览器 WS 无法设请求头,B6 把 DPoP proof 放进 `term.dpop.` 子协议,**relay 侧(browser-server.ts)必须从子协议读取并喂给 `UpgradeRequest.dpop`**,否则真实浏览器一律被 DPoP 拒(401)。② **F1 安全** — 公网 :443 上的 `/auth/mint` 无限速/锁定(评审最大攻击面);顺带 F2(`activeSessionCount` 硬编码 0,并发上限失效)、F5(裸 error 日志或泄露 DSN)。F3/F4(撤销后 agent 空转重连、slowloris)为 LOW,记入 Phase 2 backlog。 +- **B7 收尾 `[x]` DONE**: ① 功能阻塞已闭环 — `browser-server.ts` 现从 `term.dpop.` 子协议读 DPoP proof 喂 `UpgradeRequest.dpop`(头优先、否则回退子协议),真实浏览器可通过 DPoP 门。② F1 `/auth/mint` 加 Redis 按 IP 限流(HMAC 盐哈希 key,burst5/refill0.2,早于口令比对,耗尽 429);F2 `activeSessionCount` 接真实同租户在线 WS 计数;F5 错误日志只记 `e.message(+.code)` 防 DSN 泄漏。relay-run `npx vitest run` → **10 files / 92 passed**、tsc 干净。**F3/F4(撤销后 agent 空转重连、slowloris)为 LOW → Phase 2 backlog。** +- **⚠️ 端到端仅可在 VPS 验证**:浏览器登录→token→DPoP→upgrade→agent 拼接,本地只到类型/单元层。E2–E5 远端执行见 `deploy/RUNBOOK.md`(需 VPS 访问权)。 - **远端(需 VPS 执行,见 `deploy/RUNBOOK.md`)**: DNS、Let's Encrypt 签证、私有 enrollment CA + capability keypair 生成、阿里云安全组放行 443/AGENT_PORT、systemd 起两个服务、端到端 enroll→dial→浏览器点进 shell。 - **未做/推迟到 Phase 2**: 真 KMS、F6 replay、WebAuthn step-up(staging 用 `NO_STEPUP_POLICY`)、通配多租户、F3/F4。 - **未做/推迟到 Phase 2**: 真 KMS(dev signer 暂用)、F6 replay(`loadReplay` fail-closed)、WebAuthn step-up(staging 用 `NO_STEPUP_POLICY`)、通配多租户。 diff --git a/relay-run/src/main-phase1.ts b/relay-run/src/main-phase1.ts index 3046a82..f5ca366 100644 --- a/relay-run/src/main-phase1.ts +++ b/relay-run/src/main-phase1.ts @@ -71,6 +71,19 @@ function intEnv(name: string, fallback: number): number { return n } +/** + * F5/INV9 · render an error for logs WITHOUT leaking the raw object. A raw PG/Redis error can carry + * the connection DSN (with password) in its properties, so we log only `.message` (+ `.code` when + * present, e.g. 'ECONNREFUSED') and never the object itself. + */ +function errText(e: unknown): string { + if (e instanceof Error) { + const code = (e as { code?: unknown }).code + return code === undefined ? e.message : `${e.message} (code=${String(code)})` + } + return String(e) +} + // ── async mTLS → sync-slot bridge ─────────────────────────────────────────────────────────────── interface MtlsBridge { @@ -169,9 +182,9 @@ async function main(): Promise { caChainPem: readFileSync(agentCaChainPath, 'utf8'), hosts: deps.hosts, now, - onError: (e) => console.error('[mtls-verify]', e), + onError: (e) => console.error('[mtls-verify]', errText(e)), }) - const mtlsBridge = bridgeAsyncMtls(asyncMtls, (e) => console.error('[mtls-bridge]', e)) + const mtlsBridge = bridgeAsyncMtls(asyncMtls, (e) => console.error('[mtls-bridge]', errText(e))) const authorizer = createAuthorizer({ deps, allowedOrigins, now }) @@ -183,7 +196,7 @@ async function main(): Promise { bindHost, bindPort: agentBindPort, onListening: () => console.log(`[agent-mtls] listening wss://${bindHost}:${agentBindPort}`), - onError: (e) => console.error('[agent-mtls]', e), + onError: (e) => console.error('[agent-mtls]', errText(e)), }) const dp = buildDataPlane({ @@ -193,7 +206,7 @@ async function main(): Promise { mtls: mtlsBridge.sync, now, caBundle: [readFileSync(agentCaCertPath)], - onError: (e) => console.error('[data-plane]', e), + onError: (e) => console.error('[data-plane]', errText(e)), tlsServerFactory: mtlsBridge.wrap(agentTlsFactory), }) @@ -205,12 +218,16 @@ async function main(): Promise { let mintEnabled = false if (operatorPassword.length > 0 && signPrivRaw.length > 0) { const signingKey = await loadSigningKeyFromEnv(signPrivRaw) + // F1: the public :443 mint MUST be rate-limited (brute-force of OPERATOR_PASSWORD → full shell). + // Reuse the shared Redis token bucket keyed on a salted hash of the client IP (raw IP never stored). + const mintRateSalt = process.env.MINT_RATE_SALT || 'relay-run-mint-rate-salt' onRequest = createAuthMintRoute({ signingKey, hosts: stores.hosts, operatorPassword, now, - onError: (e) => console.error('[auth-mint]', e), + rateLimit: { buckets: deps.buckets, salt: mintRateSalt }, + onError: (e) => console.error('[auth-mint]', errText(e)), }) mintEnabled = true } else { @@ -229,7 +246,7 @@ async function main(): Promise { staticRoot: webRoot, ...(onRequest ? { onRequest } : {}), onListening: () => console.log(`[browser-wss] listening https://${bindHost}:${bindPort}`), - onError: (e) => console.error('[browser-wss]', e), + onError: (e) => console.error('[browser-wss]', errText(e)), }) // INV12: a Redis relay:revocations kill-signal tears matching live tunnel(s) down on this node. @@ -245,7 +262,7 @@ async function main(): Promise { onApplied: (signal, hostsAffected) => console.log(`[revocation] applied scope=${signal.scope.kind} hostsAffected=${hostsAffected}`), onDropped: () => console.warn('[revocation] dropped malformed kill-signal'), - onError: (e) => console.error('[revocation]', e), + onError: (e) => console.error('[revocation]', errText(e)), }) console.log('\n=== relay-run Phase 1 READY ===') @@ -267,7 +284,7 @@ async function main(): Promise { dp.listener.close() await Promise.allSettled([redis.quit(), redisSubscriber.quit(), pool.end()]) } catch (e) { - console.error('[shutdown]', e) + console.error('[shutdown]', errText(e)) } finally { process.exit(0) } @@ -277,6 +294,6 @@ async function main(): Promise { } main().catch((e) => { - console.error('fatal:', e instanceof Error ? e.message : e) + console.error('fatal:', errText(e)) process.exit(1) }) diff --git a/relay-run/src/servers/auth-mint.ts b/relay-run/src/servers/auth-mint.ts index e4e4617..ccf869b 100644 --- a/relay-run/src/servers/auth-mint.ts +++ b/relay-run/src/servers/auth-mint.ts @@ -22,8 +22,8 @@ * - Input is validated at the boundary (Zod) BEFORE any store/crypto work; body size is capped. */ import type { IncomingMessage, ServerResponse } from 'node:http' -import { createHash, timingSafeEqual } from 'node:crypto' -import { issueCapabilityToken, type AuthenticatedPrincipal } from 'relay-auth' +import { createHash, createHmac, timingSafeEqual } from 'node:crypto' +import { issueCapabilityToken, type AuthenticatedPrincipal, type TokenBucketStore } from 'relay-auth' import type { CapabilityRight } from 'relay-contracts' import type { HostStore } from 'control-plane/src/store/ports.js' @@ -37,12 +37,35 @@ const MAX_BODY_BYTES = 4096 const MINT_RIGHTS: readonly CapabilityRight[] = ['attach'] /** A base64url SHA-256 JWK thumbprint is exactly 43 chars of [A-Za-z0-9_-]. */ const JKT_RE = /^[A-Za-z0-9_-]{43}$/ +/** + * F1 · public-mint brute-force throttle (per remote IP). Strict & low: a full bucket allows a short + * burst, then admits ~1 attempt every 5 s — enough for an operator's retries, useless for guessing + * OPERATOR_PASSWORD. Tuned so a fresh IP gets `MINT_RATE_BURST` immediate tries. + */ +export const MINT_RATE_BURST = 5 +export const MINT_RATE_REFILL_PER_SEC = 0.2 /** A single DNS label (tenant subdomain): lowercase alnum + hyphens, 1–63 chars, no leading/trailing '-'. */ const SUBDOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/ /** The single CP `hosts` capability this route needs (interface segregation). */ export type SubdomainHostLookup = Pick +/** + * F1 · per-remote-address throttle for the public `/auth/mint`. Reuses the shared (Redis) token + * bucket. Keyed on a SALTED HASH of the client IP — the raw IP is never used as a key. Omit to + * disable the throttle (unit tests / trusted-network deployments); production ALWAYS supplies it. + */ +export interface MintRateLimit { + /** Token-bucket store (Redis-backed in prod; the SAME store the relay's pre-auth limiter uses). */ + readonly buckets: TokenBucketStore + /** Salt for hashing the client IP into the bucket key — prevents storing/inferring the raw IP. */ + readonly salt: string + /** Bucket capacity (max immediate burst). Default {@link MINT_RATE_BURST}. */ + readonly burst?: number + /** Refill tokens/second. Default {@link MINT_RATE_REFILL_PER_SEC}. */ + readonly refillPerSec?: number +} + export interface AuthMintDeps { /** P5 capability signing PRIVATE key (Ed25519 CryptoKey with ['sign']). NEVER logged. */ readonly signingKey: CryptoKey @@ -52,6 +75,8 @@ export interface AuthMintDeps { readonly operatorPassword: string /** Epoch-SECONDS clock (token iat/exp). */ readonly now: () => number + /** F1 per-IP brute-force throttle. When set, exhaustion returns 429 BEFORE the password compare. */ + readonly rateLimit?: MintRateLimit /** Observability seam — receives thrown errors only (never bodies/tokens/keys). */ readonly onError?: (e: unknown) => void } @@ -79,6 +104,23 @@ function safeEqual(a: string, b: string): boolean { return timingSafeEqual(ha, hb) } +/** Salted HMAC-SHA256 of the client IP → an OPAQUE, non-reversible bucket key (never the raw IP). */ +function mintBucketKey(salt: string, remoteAddr: string): string { + return 'mint:' + createHmac('sha256', salt).update(remoteAddr).digest('hex').slice(0, 32) +} + +/** + * F1 · consume one token from the per-IP mint bucket. Returns true when the request may proceed, + * false when the IP is throttled. A thrown store error is NOT swallowed here — it propagates to + * handleMint's outer catch, which denies (500, no token) and reports via `onError` (deny-by-default). + */ +async function allowMintAttempt(rl: MintRateLimit, remoteAddr: string, now: number): Promise { + const key = mintBucketKey(rl.salt, remoteAddr) + const burst = rl.burst ?? MINT_RATE_BURST + const refillPerSec = rl.refillPerSec ?? MINT_RATE_REFILL_PER_SEC + return rl.buckets.take(key, refillPerSec, burst, now) +} + function sendJson(res: ServerResponse, status: number, body: unknown): void { const payload = JSON.stringify(body) res.writeHead(status, { @@ -130,6 +172,16 @@ async function handleMint( deps: AuthMintDeps, ): Promise { try { + // F1: per-IP throttle BEFORE any body buffering / password compare — blunts a brute-force of + // OPERATOR_PASSWORD on the public :443 mint. Keyed on the SALTED IP hash (raw IP never stored). + if (deps.rateLimit !== undefined) { + const remoteAddr = req.socket?.remoteAddress ?? '' + if (!(await allowMintAttempt(deps.rateLimit, remoteAddr, deps.now()))) { + sendJson(res, 429, { error: 'rate_limited' }) + return + } + } + let raw: string try { raw = await readBody(req, MAX_BODY_BYTES) diff --git a/relay-run/src/servers/browser-server.ts b/relay-run/src/servers/browser-server.ts index 50aeeee..cc88fa7 100644 --- a/relay-run/src/servers/browser-server.ts +++ b/relay-run/src/servers/browser-server.ts @@ -13,6 +13,7 @@ import type { UpgradeRequest } from 'term-relay/data-plane/upgrade.js' import type { RelayNode } from 'term-relay/data-plane/relay-node.js' import { wsToWebSocketLike } from '../wiring/socket-pipe.js' import { serveStatic } from './static-web.js' +import { extractDpopProofFromSubprotocols } from './dpop-subprotocol.js' export interface BrowserServerOptions { readonly certPath: string @@ -50,12 +51,26 @@ function parseCookies(header: string | undefined): Record { return out } -function buildUpgradeRequest(req: IncomingMessage): UpgradeRequest { +/** + * Build the P1 `UpgradeRequest` from the raw upgrade request. + * + * DPoP transport (B7): the `dpop` REQUEST HEADER is read first (a proxy or native client MAY set it), + * but a browser's native WebSocket API cannot set headers, so relay-web offers the proof as an extra + * `term.dpop.` subprotocol entry. The header WINS when both are present; otherwise the proof + * falls back to the subprotocol (fail-closed → null when absent/malformed). htu/htm are NOT set here: + * the authorizer re-derives them from the request's own resolved authority (`expectedAud`), never from + * client-supplied claims — so both transports feed the SAME downstream DPoP binding. + * + * `activeSessionCount` is supplied by the caller (the live per-tenant connection count, F2). + */ +export function buildUpgradeRequest(req: IncomingMessage, activeSessionCount: number): UpgradeRequest { const proto = req.headers['sec-websocket-protocol'] const subprotocols = (typeof proto === 'string' ? proto.split(',') : []) .map((v) => v.trim()) .filter((v) => v.length > 0) const dpopHeader = req.headers['dpop'] + const headerProof = typeof dpopHeader === 'string' && dpopHeader.length > 0 ? dpopHeader : null + const proof = headerProof ?? extractDpopProofFromSubprotocols(subprotocols) return { host: req.headers.host ?? '', origin: typeof req.headers.origin === 'string' ? req.headers.origin : undefined, @@ -63,8 +78,8 @@ function buildUpgradeRequest(req: IncomingMessage): UpgradeRequest { subprotocols, cookies: parseCookies(req.headers.cookie), remoteAddr: req.socket.remoteAddress ?? '', - dpop: { proof: typeof dpopHeader === 'string' ? dpopHeader : null, publicKeyThumbprint: null }, - activeSessionCount: 0, + dpop: { proof, publicKeyThumbprint: null }, + activeSessionCount, } } @@ -98,9 +113,24 @@ export function startBrowserServer(opts: BrowserServerOptions): Server { handleProtocols: (protocols: Set) => protocols.has(APP_SUBPROTOCOL) ? APP_SUBPROTOCOL : false, }) + // F2: best-effort concurrent-session count per tenant. The relay-node keeps its browser↔agent + // splice index PRIVATE (no public per-host stream count), so we approximate the account's "active + // sessions" with the number of browser WS connections currently open to the SAME tenant origin + // (single-tenant staging ⇒ subdomain↔host/account is 1:1). We pass the count of the OTHER live + // connections (this one excluded) so P5's `checkConcurrentSessions` sees only prior sessions. This + // over-counts unauthorized/failing connects until they close, which fails SAFE (toward the cap). + const liveByTenant = new Map() wss.on('connection', (ws: WsWebSocket, req: IncomingMessage) => { + const tenantKey = req.headers.host ?? '' + const priorCount = liveByTenant.get(tenantKey) ?? 0 + liveByTenant.set(tenantKey, priorCount + 1) + ws.once('close', () => { + const next = (liveByTenant.get(tenantKey) ?? 1) - 1 + if (next <= 0) liveByTenant.delete(tenantKey) + else liveByTenant.set(tenantKey, next) + }) opts.node - .handleBrowserUpgrade(buildUpgradeRequest(req), wsToWebSocketLike(ws)) + .handleBrowserUpgrade(buildUpgradeRequest(req, priorCount), wsToWebSocketLike(ws)) .catch((e) => opts.onError?.(e)) }) server.on('error', (e) => opts.onError?.(e)) diff --git a/relay-run/src/servers/dpop-subprotocol.ts b/relay-run/src/servers/dpop-subprotocol.ts new file mode 100644 index 0000000..d7e41a9 --- /dev/null +++ b/relay-run/src/servers/dpop-subprotocol.ts @@ -0,0 +1,36 @@ +/** + * B7 · Decode the browser's DPoP proof carried as an EXTRA WS subprotocol entry. + * + * A browser's native WebSocket API cannot set request headers, so relay-web offers the §4.3 DPoP + * proof-of-possession JWS as an additional `Sec-WebSocket-Protocol` entry + * `term.dpop.` + * — mirroring how the capability token rides `term.token.` (relay-contracts subprotocol.ts). + * This is the relay-side inverse of relay-web `encodeDpopSubprotocol`; the wire format is decoded + * byte-for-byte with the SAME isomorphic base64url helper both sides share (relay-contracts), so + * issuance and verification can never drift. + * + * FAIL-CLOSED (INV15): an absent entry OR malformed base64url / non-UTF-8 payload → `null`, so the + * downstream DPoP gate denies through the audited path instead of ever seeing a half-decoded proof. + */ +import { decodeBase64UrlString } from 'relay-contracts' + +/** Subprotocol entry prefix carrying the DPoP proof (mirrors relay-web `DPOP_SUBPROTOCOL_PREFIX`). */ +export const DPOP_SUBPROTOCOL_PREFIX = 'term.dpop.' as const + +/** + * Extract the DPoP proof JWS from a parsed subprotocol list: find the first `term.dpop.` entry, + * strip the prefix, and base64url-decode the remainder. Returns `null` when the entry is absent, + * empty, or does not decode (deny-by-default). + */ +export function extractDpopProofFromSubprotocols(values: readonly string[]): string | null { + const entry = values.find((v) => v.startsWith(DPOP_SUBPROTOCOL_PREFIX)) + if (entry === undefined) return null + const encoded = entry.slice(DPOP_SUBPROTOCOL_PREFIX.length) + if (encoded.length === 0) return null + try { + const decoded = decodeBase64UrlString(encoded) + return decoded.length > 0 ? decoded : null + } catch { + return null // fail-closed: malformed base64url or invalid UTF-8 + } +} diff --git a/relay-run/tests/auth-mint.test.ts b/relay-run/tests/auth-mint.test.ts index eba26c2..b7be144 100644 --- a/relay-run/tests/auth-mint.test.ts +++ b/relay-run/tests/auth-mint.test.ts @@ -9,10 +9,13 @@ import { Readable } from 'node:stream' import type { IncomingMessage, ServerResponse } from 'node:http' import type { HostRecord } from 'control-plane/src/model/records.js' import { verifyPaseto } from 'relay-auth/src/crypto/paseto.js' +import type { TokenBucketStore } from 'relay-auth' import { createAuthMintRoute, loadSigningKeyFromEnv, + MINT_RATE_BURST, type SubdomainHostLookup, + type MintRateLimit, } from '../src/servers/auth-mint.js' const subtle = globalThis.crypto.subtle @@ -86,11 +89,12 @@ function fakeRes(): { res: ServerResponse; captured: CapturedRes; done: Promise< return { res: captured as unknown as ServerResponse, captured, done } } -function fakeReq(method: string, url: string, body?: string): IncomingMessage { +function fakeReq(method: string, url: string, body?: string, remoteAddr = '203.0.113.7'): IncomingMessage { const chunks = body === undefined ? [] : [Buffer.from(body, 'utf8')] const req = Readable.from(chunks) as unknown as IncomingMessage ;(req as { method?: string }).method = method ;(req as { url?: string }).url = url + ;(req as { socket?: { remoteAddress: string } }).socket = { remoteAddress: remoteAddr } return req } @@ -220,6 +224,96 @@ describe('createAuthMintRoute — routing semantics', () => { }) }) +// ── F1 · per-IP mint throttle ───────────────────────────────────────────────────────────────────── + +/** Lazy-refill token bucket mirroring the production Redis Lua — deterministic at a fixed `now`. */ +function inMemoryBucket(): TokenBucketStore { + const state = new Map() + return { + async take(key, refillPerSec, burst, now) { + const s = state.get(key) ?? { tokens: burst, ts: now } + const elapsed = now - s.ts + let tokens = elapsed > 0 ? Math.min(burst, s.tokens + elapsed * refillPerSec) : s.tokens + const allowed = tokens >= 1 + if (allowed) tokens -= 1 + state.set(key, { tokens, ts: now }) + return allowed + }, + } +} + +function mkRouteRl( + hosts: SubdomainHostLookup, + rateLimit: MintRateLimit, +): (req: IncomingMessage, res: ServerResponse) => boolean { + return createAuthMintRoute({ + signingKey: keys.signingKey, + hosts, + operatorPassword: PASSWORD, + now: () => NOW, + rateLimit, + onError: () => {}, + }) +} + +async function postRl( + route: (req: IncomingMessage, res: ServerResponse) => boolean, + bodyObj: unknown, + remoteAddr: string, +): Promise { + const { res, captured, done } = fakeRes() + route(fakeReq('POST', '/auth/mint', JSON.stringify(bodyObj), remoteAddr), res) + await done + return captured +} + +describe('createAuthMintRoute — F1 per-IP throttle', () => { + const GOOD = { password: PASSWORD, jkt: JKT, subdomain: 'alice' } + + it('admits the burst then returns 429 once the bucket is exhausted (same IP)', async () => { + const route = mkRouteRl(fakeHosts(mkHost()), { buckets: inMemoryBucket(), salt: 's' }) + const ip = '198.51.100.4' + for (let i = 0; i < MINT_RATE_BURST; i++) { + const ok = await postRl(route, GOOD, ip) + expect(ok.statusCode).toBe(200) + } + const throttled = await postRl(route, GOOD, ip) + expect(throttled.statusCode).toBe(429) + expect(throttled.body).not.toContain('token') + }) + + it('throttles BEFORE the password compare (exhausted IP + WRONG password → 429, not 401)', async () => { + const route = mkRouteRl(fakeHosts(mkHost()), { buckets: inMemoryBucket(), salt: 's', burst: 1 }) + const ip = '198.51.100.9' + expect((await postRl(route, GOOD, ip)).statusCode).toBe(200) // drains the single token + const wrong = await postRl(route, { ...GOOD, password: 'nope' }, ip) + expect(wrong.statusCode).toBe(429) // throttle wins over the 401 password check + }) + + it('keeps per-IP buckets independent (a throttled IP does not affect another)', async () => { + const route = mkRouteRl(fakeHosts(mkHost()), { buckets: inMemoryBucket(), salt: 's', burst: 1 }) + expect((await postRl(route, GOOD, '10.0.0.1')).statusCode).toBe(200) + expect((await postRl(route, GOOD, '10.0.0.1')).statusCode).toBe(429) // IP-A exhausted + expect((await postRl(route, GOOD, '10.0.0.2')).statusCode).toBe(200) // IP-B unaffected + }) + + it('keys the bucket on a SALTED HASH, never the raw client IP', async () => { + const seenKeys: string[] = [] + const spyBucket: TokenBucketStore = { + async take(key) { + seenKeys.push(key) + return true + }, + } + const rawIp = '203.0.113.55' + const route = mkRouteRl(fakeHosts(mkHost()), { buckets: spyBucket, salt: 'pepper' }) + await postRl(route, GOOD, rawIp) + expect(seenKeys).toHaveLength(1) + expect(seenKeys[0]).toMatch(/^mint:[0-9a-f]{32}$/) + expect(seenKeys[0]).not.toContain(rawIp) + }) +}) + describe('loadSigningKeyFromEnv', () => { async function exportPkcs8Pem(): Promise<{ pem: string; b64: string; publicKey: CryptoKey }> { const kp = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as { diff --git a/relay-run/tests/browser-server.test.ts b/relay-run/tests/browser-server.test.ts new file mode 100644 index 0000000..7df6e61 --- /dev/null +++ b/relay-run/tests/browser-server.test.ts @@ -0,0 +1,68 @@ +/** + * B7 · unit tests for `buildUpgradeRequest` — the browser upgrade → P1 `UpgradeRequest` builder. + * Focus: the DPoP proof transport (FUNCTIONAL BLOCKER). A browser cannot set the `dpop` request + * header, so the proof rides the `term.dpop.` subprotocol entry; the builder must read it there + * while keeping the header path working (header WINS if both). Also pins `activeSessionCount` plumbing. + */ +import { describe, it, expect } from 'vitest' +import type { IncomingMessage } from 'node:http' +import { encodeBase64UrlString, APP_SUBPROTOCOL } from 'relay-contracts' +import { buildUpgradeRequest } from '../src/servers/browser-server.js' +import { DPOP_SUBPROTOCOL_PREFIX } from '../src/servers/dpop-subprotocol.js' + +const PROOF = 'eyJhbGciOiJFZERTQSJ9.eyJodHUiOiJodHRwczovL2FsaWNlL3dzIn0.c2ln' + +function dpopEntry(proofJws: string): string { + return DPOP_SUBPROTOCOL_PREFIX + encodeBase64UrlString(proofJws) +} + +function mkReq(headers: Record): IncomingMessage { + return { + headers: { host: 'alice.example.com', ...headers }, + url: '/ws', + socket: { remoteAddress: '1.2.3.4' }, + } as unknown as IncomingMessage +} + +describe('buildUpgradeRequest — DPoP transport', () => { + it('reads the proof from the term.dpop. subprotocol when no header is set (the browser path)', () => { + const req = mkReq({ 'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${dpopEntry(PROOF)}` }) + const upgrade = buildUpgradeRequest(req, 0) + expect(upgrade.dpop.proof).toBe(PROOF) + }) + + it('prefers the dpop HEADER over the subprotocol entry when both are present (header wins)', () => { + const req = mkReq({ + dpop: 'header-proof-jws', + 'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${dpopEntry(PROOF)}`, + }) + expect(buildUpgradeRequest(req, 0).dpop.proof).toBe('header-proof-jws') + }) + + it('still honours the dpop header alone (header path unchanged)', () => { + const req = mkReq({ dpop: 'header-only', 'sec-websocket-protocol': APP_SUBPROTOCOL }) + expect(buildUpgradeRequest(req, 0).dpop.proof).toBe('header-only') + }) + + it('yields proof=null when neither header nor subprotocol carries a proof', () => { + const req = mkReq({ 'sec-websocket-protocol': APP_SUBPROTOCOL }) + expect(buildUpgradeRequest(req, 0).dpop.proof).toBeNull() + }) + + it('yields proof=null (fail-closed) for a malformed term.dpop. subprotocol entry', () => { + const req = mkReq({ 'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${DPOP_SUBPROTOCOL_PREFIX}not*b64u!` }) + expect(buildUpgradeRequest(req, 0).dpop.proof).toBeNull() + }) + + it('parses the comma-separated subprotocol list and always carries the app subprotocol through', () => { + const req = mkReq({ 'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${dpopEntry(PROOF)}` }) + expect(buildUpgradeRequest(req, 0).subprotocols).toContain(APP_SUBPROTOCOL) + }) +}) + +describe('buildUpgradeRequest — activeSessionCount plumbing (F2)', () => { + it('carries the caller-supplied active-session count verbatim', () => { + const req = mkReq({ 'sec-websocket-protocol': APP_SUBPROTOCOL }) + expect(buildUpgradeRequest(req, 3).activeSessionCount).toBe(3) + }) +}) diff --git a/relay-run/tests/dpop-subprotocol.test.ts b/relay-run/tests/dpop-subprotocol.test.ts new file mode 100644 index 0000000..4c00833 --- /dev/null +++ b/relay-run/tests/dpop-subprotocol.test.ts @@ -0,0 +1,63 @@ +/** + * B7 · unit tests for the relay-side DPoP subprotocol decoder (`extractDpopProofFromSubprotocols`). + * The browser (relay-web) offers the DPoP proof as an extra `term.dpop.` entry on + * the WS upgrade because native WebSocket cannot set request headers. These tests pin the wire + * contract (valid decodes; absent → null; malformed → null, fail-closed) against the SAME isomorphic + * base64url helper relay-web encodes with (relay-contracts), so the two sides agree byte-for-byte. + */ +import { describe, it, expect } from 'vitest' +import { encodeBase64UrlString } from 'relay-contracts' +import { + extractDpopProofFromSubprotocols, + DPOP_SUBPROTOCOL_PREFIX, +} from '../src/servers/dpop-subprotocol.js' +import { APP_SUBPROTOCOL, TOKEN_SUBPROTOCOL_PREFIX } from 'relay-contracts' + +/** Build the wire entry exactly as relay-web `encodeDpopSubprotocol` does. */ +function dpopEntry(proofJws: string): string { + return DPOP_SUBPROTOCOL_PREFIX + encodeBase64UrlString(proofJws) +} + +// A representative three-part DPoP proof JWS (header.payload.sig) — content is opaque to the decoder. +const PROOF = 'eyJhbGciOiJFZERTQSJ9.eyJodHUiOiJodHRwczovL2FsaWNlL3dzIn0.c2ln' + +describe('extractDpopProofFromSubprotocols — valid', () => { + it('decodes a term.dpop. entry back to the exact proof JWS', () => { + const subprotocols = [APP_SUBPROTOCOL, dpopEntry(PROOF)] + expect(extractDpopProofFromSubprotocols(subprotocols)).toBe(PROOF) + }) + + it('finds the DPoP entry regardless of position (after app + token entries)', () => { + const subprotocols = [ + APP_SUBPROTOCOL, + TOKEN_SUBPROTOCOL_PREFIX + encodeBase64UrlString('v4.public.raw-token'), + dpopEntry(PROOF), + ] + expect(extractDpopProofFromSubprotocols(subprotocols)).toBe(PROOF) + }) +}) + +describe('extractDpopProofFromSubprotocols — absent → null', () => { + it('returns null when no term.dpop. entry is present', () => { + expect(extractDpopProofFromSubprotocols([APP_SUBPROTOCOL])).toBeNull() + }) + + it('returns null for an empty list', () => { + expect(extractDpopProofFromSubprotocols([])).toBeNull() + }) + + it('returns null when the prefix is present but the payload is empty', () => { + expect(extractDpopProofFromSubprotocols([DPOP_SUBPROTOCOL_PREFIX])).toBeNull() + }) +}) + +describe('extractDpopProofFromSubprotocols — malformed → null (fail-closed)', () => { + it('returns null for a non-base64url payload (illegal characters)', () => { + expect(extractDpopProofFromSubprotocols([DPOP_SUBPROTOCOL_PREFIX + 'not*base64url!'])).toBeNull() + }) + + it('returns null for base64url bytes that are not valid UTF-8', () => { + // 0xFF 0xFE is not a valid UTF-8 sequence; base64url of those two bytes is "__4". + expect(extractDpopProofFromSubprotocols([DPOP_SUBPROTOCOL_PREFIX + '__4'])).toBeNull() + }) +})