/** * B1 — operator login → device:enroll bearer mint. Proves the PINNED contract: * POST /auth/login { password } -> 201 { enrollToken, accountId, expiresIn } * then POST /device/enroll Authorization: Bearer is reachable (201) and rejected * WITHOUT the bearer (401). * * Security surface under test: constant-time credential compare (via loginToAccountId), rate-limited * login attempts, deny-by-default / fail-closed when the operator credential is unset, and that the * minted bearer carries the correct right ('enroll') + audience ('device-enroll') + TTL — verified * through relay-auth's REAL §4.3 verify path (verifyDeviceEnrollToken), the same one production uses. */ import 'reflect-metadata' import { describe, test, expect, beforeEach } from 'vitest' import * as x509 from '@peculiar/x509' import { webcrypto } from 'node:crypto' import Fastify, { type FastifyInstance } from 'fastify' import { configureVerifyKey } from 'relay-auth' import { resetVerifyKeyForTest } from 'relay-auth/src/config/keys.js' import { generateEd25519KeyPair, exportEd25519PublicRaw } from 'relay-auth/src/crypto/ed25519.js' import { buildAuthLoginRouter, type AuthLoginDeps } from '../src/api/auth-login.js' import { verifyDeviceEnrollToken, DEVICE_ENROLL_AUD, DEFAULT_DEVICE_ENROLL_TTL_SEC, } from '../src/auth/session.js' import { buildControlPlane } from '../src/main.js' import { createCapabilityVerifier } from '../src/boot/verifier.js' import { loadEnv } from '../src/env.js' import { createMemoryStores } from '../src/store/memory.js' import { createHostRegistry } from '../src/registry/hosts.js' import { fingerprint } from '../src/ca/fingerprint.js' import { generateEd25519 } from '../src/util/crypto.js' import { buildCsrEc } from '../src/ca/csr-ec.js' import { bytesToBase64 } from '../src/util/bytes.js' x509.cryptoProvider.set(webcrypto) const ACCOUNT_A = '11111111-1111-4111-8111-111111111111' const OPERATOR_PASSWORD = 'operator-secret-abcdef123456' // ─────────────────────────────────────────────────────────────────────────────────────────────── // Block A — the login route in isolation (fast; verify-key configured so the mint round-trips) // ─────────────────────────────────────────────────────────────────────────────────────────────── describe('B1 POST /auth/login (route in isolation)', () => { let signingKey: CryptoKey beforeEach(async () => { resetVerifyKeyForTest() const pair = await generateEd25519KeyPair() await configureVerifyKey(pair.publicKey) signingKey = pair.privateKey }) function buildApp(overrides: Partial = {}): FastifyInstance { const deps: AuthLoginDeps = { signingKey, loginConfig: { operatorCredential: OPERATOR_PASSWORD, accountId: ACCOUNT_A }, clientKey: () => 'test-client', // deterministic rate-limit bucket ...overrides, } const app = Fastify({ logger: false }) void app.register(buildAuthLoginRouter(deps)) return app } test('valid password → 201 { enrollToken, accountId, expiresIn }', async () => { const app = buildApp() await app.ready() const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: OPERATOR_PASSWORD } }) expect(res.statusCode).toBe(201) const body = JSON.parse(res.body) expect(body.accountId).toBe(ACCOUNT_A) expect(body.expiresIn).toBe(DEFAULT_DEVICE_ENROLL_TTL_SEC) expect(typeof body.enrollToken).toBe('string') expect(body.enrollToken.startsWith('v4.public.')).toBe(true) }) test('the minted bearer carries the enroll right + device-enroll aud + minutes TTL', async () => { const app = buildApp() await app.ready() const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: OPERATOR_PASSWORD } }) const { enrollToken, expiresIn } = JSON.parse(res.body) // A successful verify through the REAL §4.3 path asserts aud === device-enroll AND the enroll right. const { accountId } = await verifyDeviceEnrollToken(enrollToken, { aud: DEVICE_ENROLL_AUD }) expect(accountId).toBe(ACCOUNT_A) expect(expiresIn).toBeGreaterThanOrEqual(60) // minutes-scale, separate from the 30–60s connect clamp }) test('wrong password → 401, no token minted', async () => { const app = buildApp() await app.ready() const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: 'wrong-but-long-enough' } }) expect(res.statusCode).toBe(401) expect(JSON.parse(res.body).enrollToken).toBeUndefined() }) test('missing password field → 400 (Zod at the boundary)', async () => { const app = buildApp() await app.ready() const res = await app.inject({ method: 'POST', url: '/auth/login', payload: {} }) expect(res.statusCode).toBe(400) }) test('fail-closed when the operator credential is unset → 503, never mints', async () => { const app = buildApp({ signingKey: null, loginConfig: {} }) await app.ready() const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: OPERATOR_PASSWORD } }) expect(res.statusCode).toBe(503) expect(JSON.parse(res.body).enrollToken).toBeUndefined() }) test('login attempts are rate-limited → 429 after the threshold', async () => { const app = buildApp({ rateMax: 2 }) await app.ready() const attempt = () => app.inject({ method: 'POST', url: '/auth/login', payload: { password: 'wrong-but-long-enough' } }) expect((await attempt()).statusCode).toBe(401) expect((await attempt()).statusCode).toBe(401) expect((await attempt()).statusCode).toBe(429) // over the per-client window }) }) // ─────────────────────────────────────────────────────────────────────────────────────────────── // Block B — end-to-end through the WIRED control-plane (login → bearer → /device/enroll) // ─────────────────────────────────────────────────────────────────────────────────────────────── describe('B1 login → device:enroll e2e (wired app)', () => { let app: FastifyInstance beforeEach(async () => { resetVerifyKeyForTest() // Capability keypair: raw public → boot verify key; PKCS#8 private → the enroll signing key env. const pair = await generateEd25519KeyPair() const rawPub = await exportEd25519PublicRaw(pair.publicKey) const pkcs8 = new Uint8Array(await webcrypto.subtle.exportKey('pkcs8', pair.privateKey)) const env = loadEnv({ PG_URL: 'postgres://u:p@localhost:5432/cp', REDIS_URL: 'redis://localhost:6379', CAPABILITY_SIGN_PUBKEY_B64: bytesToBase64(rawPub), CAPABILITY_SIGN_PRIVKEY_B64: bytesToBase64(pkcs8), OPERATOR_PASSWORD, OPERATOR_ACCOUNT_ID: ACCOUNT_A, CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate', CA_INTERMEDIATE_CERT_PATH: '/etc/cp/int.pem', NODE_MTLS_TRUST_BUNDLE_PATH: '/etc/cp/node-ca.pem', RELAY_TRUST_DOMAIN: 'terminal.yaojia.wang', BASE_DOMAIN: 'term.example.com', }) const stores = createMemoryStores() const built = await buildControlPlane(env, { stores, verifier: createCapabilityVerifier() }) app = built.app await app.ready() // Onboard a host so ACCOUNT_A OWNS 'alice' (device-enroll ownership gate reads this registry). const hosts = createHostRegistry({ hosts: stores.hosts }) const { publicKeyRaw } = generateEd25519() await hosts.bindHost({ accountId: ACCOUNT_A, subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) }) }) test('login mints a bearer that /device/enroll accepts (201); without it → 401', async () => { const login = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: OPERATOR_PASSWORD } }) expect(login.statusCode).toBe(201) const { enrollToken, accountId } = JSON.parse(login.body) expect(accountId).toBe(ACCOUNT_A) const { der: csr } = await buildCsrEc('CN=web-terminal-device') const payload = { csr: Buffer.from(csr).toString('base64'), keyAlg: 'ec-p256', subdomain: 'alice', deviceName: 'iphone' } const enrolled = await app.inject({ method: 'POST', url: '/device/enroll', headers: { authorization: `Bearer ${enrollToken}` }, payload, }) expect(enrolled.statusCode).toBe(201) expect(JSON.parse(enrolled.body).deviceId.length).toBeGreaterThan(0) const noBearer = await app.inject({ method: 'POST', url: '/device/enroll', payload }) expect(noBearer.statusCode).toBe(401) }) test('wrong operator password on the wired app → 401', async () => { const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: 'definitely-the-wrong-secret' } }) expect(res.statusCode).toBe(401) }) })