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).
261 lines
9.2 KiB
TypeScript
261 lines
9.2 KiB
TypeScript
/**
|
|
* 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 {
|
|
createAuthMintRoute,
|
|
loadSigningKeyFromEnv,
|
|
type SubdomainHostLookup,
|
|
} 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> = {}): 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<string, string>
|
|
body: string
|
|
headersSent: boolean
|
|
writeHead(status: number, headers: Record<string, string>): CapturedRes
|
|
end(chunk?: string): void
|
|
}
|
|
|
|
function fakeRes(): { res: ServerResponse; captured: CapturedRes; done: Promise<void> } {
|
|
let resolveDone!: () => void
|
|
const done = new Promise<void>((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): 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
|
|
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<CapturedRes> {
|
|
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)
|
|
})
|
|
})
|
|
|
|
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<boolean> {
|
|
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()
|
|
})
|
|
})
|