Unblocks the phone-enrollment track: an operator-password login mints a short-lived device:enroll capability token that POST /device/enroll requires. Constant-time (SHA-256 fixed-length) compare, per-client rate-limit, fail-closed when unset. 260 tests pass.
185 lines
9.3 KiB
TypeScript
185 lines
9.3 KiB
TypeScript
/**
|
||
* 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 <enrollToken> 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<AuthLoginDeps> = {}): 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)
|
||
})
|
||
})
|