feat(relay): B7 — close browser DPoP-subprotocol loop + harden staging mint

- Read DPoP proof from term.dpop.<b64u> WS subprotocol (browsers can't set WS headers);
  header wins, else subprotocol. Fail-closed decoder. Unblocks real browser connect.
- F1: rate-limit /auth/mint per-IP via Redis token bucket (salted-hash key, 429 on burst,
  before password compare).
- F2: wire real per-tenant active WS count into activeSessionCount (was hardcoded 0).
- F5: scrub error logs to e.message/.code (no DSN leak, INV9).
relay-run: tsc clean, 92 tests pass (+18). F3/F4 -> Phase 2 backlog.
This commit is contained in:
Yaojia Wang
2026-07-06 16:26:05 +02:00
parent aa1912b962
commit bfe1be1dfe
8 changed files with 378 additions and 17 deletions

View File

@@ -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、B1B5、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.<b64u>` 子协议,**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.<b64u>` 子协议读 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 拼接,本地只到类型/单元层。E2E5 远端执行见 `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`)、通配多租户。

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
}
main().catch((e) => {
console.error('fatal:', e instanceof Error ? e.message : e)
console.error('fatal:', errText(e))
process.exit(1)
})

View File

@@ -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, 163 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<HostStore, 'getBySubdomain'>
/**
* 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<boolean> {
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<void> {
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)

View File

@@ -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<string, string> {
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.<b64u>` 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<string>) =>
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<string, number>()
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))

View File

@@ -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.<base64url(proofJws)>`
* — mirroring how the capability token rides `term.token.<b64u>` (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
}
}

View File

@@ -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<string, { tokens: number; ts: number }>()
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<CapturedRes> {
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 {

View File

@@ -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.<b64u>` 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<string, string | undefined>): 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.<b64u> 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)
})
})

View File

@@ -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.<b64u(proofJws)>` 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.<b64u> 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()
})
})