/** * B5 · unit tests for the STAGING operator token-mint (`POST /auth/mint`) and the P5 capability * signing-key loader. Covers: the DPoP-bound short-lived token happy path (claims + PoP binding), * the deny-by-default gates (bad password / unknown+revoked subdomain / malformed body / wrong * method / oversized body), route claiming semantics, and PEM+base64 key loading round-trips. */ import { describe, it, expect, beforeAll } from 'vitest' 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 const NOW = 1_800_000_000 const PASSWORD = 'staging-operator-secret' // A syntactically valid base64url SHA-256 JWK thumbprint (exactly 43 chars). const JKT = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNO12'.padEnd(43, 'X').slice(0, 43) interface Loaded { readonly signingKey: CryptoKey readonly publicKey: CryptoKey } let keys: Loaded beforeAll(async () => { const kp = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as { publicKey: CryptoKey privateKey: CryptoKey } keys = { signingKey: kp.privateKey, publicKey: kp.publicKey } }) function mkHost(overrides: Partial = {}): HostRecord { return { hostId: 'host-1', accountId: 'acct-1', subdomain: 'alice', agentPubkey: new Uint8Array(32), enrollFpr: 'fpr-host-1', status: 'online', lastSeen: '2026-01-01T00:00:00.000Z', createdAt: '2026-01-01T00:00:00.000Z', revokedAt: null, ...overrides, } } function fakeHosts(rec: HostRecord | null): SubdomainHostLookup { return { getBySubdomain: async () => rec } } interface CapturedRes { statusCode: number headers: Record body: string headersSent: boolean writeHead(status: number, headers: Record): CapturedRes end(chunk?: string): void } function fakeRes(): { res: ServerResponse; captured: CapturedRes; done: Promise } { let resolveDone!: () => void const done = new Promise((r) => (resolveDone = r)) const captured: CapturedRes = { statusCode: 0, headers: {}, body: '', headersSent: false, writeHead(status, headers) { this.statusCode = status this.headers = headers this.headersSent = true return this }, end(chunk?: string) { if (chunk !== undefined) this.body += chunk resolveDone() }, } return { res: captured as unknown as ServerResponse, captured, done } } 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 } function mkRoute(hosts: SubdomainHostLookup): (req: IncomingMessage, res: ServerResponse) => boolean { return createAuthMintRoute({ signingKey: keys.signingKey, hosts, operatorPassword: PASSWORD, now: () => NOW, onError: () => {}, }) } async function post( hosts: SubdomainHostLookup, bodyObj: unknown, ): Promise { const route = mkRoute(hosts) const { res, captured, done } = fakeRes() const claimed = route(fakeReq('POST', '/auth/mint', JSON.stringify(bodyObj)), res) expect(claimed).toBe(true) await done return captured } describe('createAuthMintRoute — happy path', () => { it('mints a short-lived capability token bound to the client jkt (INV3 identity from store)', async () => { const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'alice' }) expect(captured.statusCode).toBe(200) expect(captured.headers['cache-control']).toBe('no-store') const parsedBody = JSON.parse(captured.body) as { token: string } expect(typeof parsedBody.token).toBe('string') const claims = (await verifyPaseto(parsedBody.token, keys.publicKey)) as { sub: string aud: string host: string rights: string[] iat: number exp: number cnf: { jkt: string } } // Identity is the STORE row's, never the request body's (INV3). expect(claims.sub).toBe('acct-1') expect(claims.host).toBe('host-1') expect(claims.aud).toBe('alice') expect(claims.rights).toContain('attach') // DPoP proof-of-possession binding to the client-provided thumbprint. expect(claims.cnf.jkt).toBe(JKT) // Short-lived (<= 60 s). expect(claims.iat).toBe(NOW) expect(claims.exp - claims.iat).toBeLessThanOrEqual(60) expect(claims.exp - claims.iat).toBeGreaterThan(0) // The token itself must never leak into logs — asserted by construction (no console here). }) }) describe('createAuthMintRoute — deny by default', () => { it('rejects a wrong password with 401 (no token)', async () => { const captured = await post(fakeHosts(mkHost()), { password: 'wrong', jkt: JKT, subdomain: 'alice' }) expect(captured.statusCode).toBe(401) expect(captured.body).not.toContain('token') }) it('rejects an unknown subdomain with 404', async () => { const captured = await post(fakeHosts(null), { password: PASSWORD, jkt: JKT, subdomain: 'ghost' }) expect(captured.statusCode).toBe(404) }) it('rejects a revoked host with 403 (INV12: revoked never mints)', async () => { const captured = await post( fakeHosts(mkHost({ status: 'revoked', revokedAt: '2026-02-01T00:00:00.000Z' })), { password: PASSWORD, jkt: JKT, subdomain: 'alice' }, ) expect(captured.statusCode).toBe(403) }) it('rejects a malformed jkt with 400', async () => { const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: 'too-short', subdomain: 'alice' }) expect(captured.statusCode).toBe(400) }) it('rejects a malformed subdomain with 400', async () => { const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'Not_Valid!' }) expect(captured.statusCode).toBe(400) }) it('rejects invalid JSON with 400', async () => { const route = mkRoute(fakeHosts(mkHost())) const { res, captured, done } = fakeRes() const claimed = route(fakeReq('POST', '/auth/mint', '{not json'), res) expect(claimed).toBe(true) await done expect(captured.statusCode).toBe(400) }) it('rejects an oversized body with 413', async () => { const big = 'x'.repeat(5000) const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'alice', pad: big }) expect(captured.statusCode).toBe(413) }) }) describe('createAuthMintRoute — routing semantics', () => { it('claims /auth/mint but 405s a non-POST method', () => { const route = mkRoute(fakeHosts(mkHost())) const { res, captured } = fakeRes() const claimed = route(fakeReq('GET', '/auth/mint', undefined), res) expect(claimed).toBe(true) expect(captured.statusCode).toBe(405) }) it('does NOT claim a non-matching path (returns false, response untouched)', () => { const route = mkRoute(fakeHosts(mkHost())) const { res, captured } = fakeRes() const claimed = route(fakeReq('POST', '/index.html', undefined), res) expect(claimed).toBe(false) expect(captured.statusCode).toBe(0) }) it('claims /auth/mint even with a query string', () => { const route = mkRoute(fakeHosts(null)) const { res } = fakeRes() const claimed = route(fakeReq('GET', '/auth/mint?foo=1', undefined), res) expect(claimed).toBe(true) }) }) // ── 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 { publicKey: CryptoKey privateKey: CryptoKey } const der = new Uint8Array(await subtle.exportKey('pkcs8', kp.privateKey)) const b64 = Buffer.from(der).toString('base64') const pem = `-----BEGIN PRIVATE KEY-----\n${b64.match(/.{1,64}/g)!.join('\n')}\n-----END PRIVATE KEY-----\n` return { pem, b64, publicKey: kp.publicKey } } async function canSign(priv: CryptoKey, pub: CryptoKey): Promise { const data = new Uint8Array([1, 2, 3, 4]) const sig = new Uint8Array(await subtle.sign({ name: 'Ed25519' }, priv, data)) return subtle.verify({ name: 'Ed25519' }, pub, sig, data) } it('loads a PKCS#8 PEM into a usable signing key', async () => { const { pem, publicKey } = await exportPkcs8Pem() const key = await loadSigningKeyFromEnv(pem) expect(await canSign(key, publicKey)).toBe(true) }) it('loads a bare base64 PKCS#8 DER into a usable signing key', async () => { const { b64, publicKey } = await exportPkcs8Pem() const key = await loadSigningKeyFromEnv(b64) expect(await canSign(key, publicKey)).toBe(true) }) it('throws on an empty value', async () => { await expect(loadSigningKeyFromEnv(' ')).rejects.toThrow() }) it('throws on a non-key value (never leaking material)', async () => { await expect(loadSigningKeyFromEnv('not-a-real-key')).rejects.toThrow() }) })