feat(control-plane): POST /auth/login mints device:enroll bearer (B1)

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.
This commit is contained in:
Yaojia Wang
2026-07-18 13:32:05 +02:00
parent 232ef22535
commit fff011bb7f
7 changed files with 482 additions and 3 deletions

View File

@@ -0,0 +1,142 @@
/**
* B1 — operator login → `device:enroll` bearer mint (registerable Fastify plugin; wired in `main.ts`).
*
* POST /auth/login { "password": "<operator secret>" }
* -> 201 { "enrollToken": "<v4.public PASETO>", "accountId": "<uuid>", "expiresIn": <seconds> }
*
* The `enrollToken` is the short-lived §4.3 `device:enroll` capability token that `POST /device/enroll`
* requires as `Authorization: Bearer <enrollToken>`. This is the ONE authenticated human action that
* bootstraps the phone track (the honest "one bootstrap tap" constraint) — everything after (CSR →
* cert → silent renew) is certificate-authenticated.
*
* SECURITY (this path ultimately mints device certs):
* - credential compare is CONSTANT-TIME (delegated to `loginToAccountId` → `timingSafeEqualBytes`);
* - login attempts are RATE-LIMITED per client (sliding window, mirrors registry/devices.ts);
* - DENY-BY-DEFAULT + FAIL-CLOSED: unset operator credential ⇒ 503 (never mints); wrong ⇒ 401;
* - the operator secret and the minted token are NEVER logged (INV9);
* - input is Zod-validated at the boundary; `accountId` is server-config, never a client field.
*/
import { z } from 'zod'
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import {
loginToAccountId,
mintDeviceEnrollToken,
DeviceEnrollAuthError,
DEFAULT_DEVICE_ENROLL_TTL_SEC,
MIN_DEVICE_ENROLL_TTL_SEC,
MAX_DEVICE_ENROLL_TTL_SEC,
type LoginSeamConfig,
} from '../auth/session.js'
/** Per-client login attempts allowed within the window (brute-force throttle). */
export const DEFAULT_LOGIN_RATE_MAX = 10
/** Login rate window (ms): 15 minutes. */
export const DEFAULT_LOGIN_RATE_WINDOW_MS = 15 * 60 * 1000
export interface AuthLoginDeps {
/** Ed25519 signing key for the enroll bearer (PRIVATE half of `CAPABILITY_SIGN_PUBKEY_B64`). */
readonly signingKey: CryptoKey | null
/** How a credential resolves to an accountId (single-operator MVP or an injected resolver). */
readonly loginConfig: LoginSeamConfig
/** Minted bearer TTL (seconds); clamped to the enroll-token bounds. Defaults to 10 min. */
readonly enrollTtlSec?: number
readonly rateMax?: number
readonly rateWindowMs?: number
/** Clock (ms) — injectable for tests. */
readonly now?: () => number
/** Client-bucket key for rate-limiting (defaults to the socket IP). Injectable for tests. */
readonly clientKey?: (req: FastifyRequest) => string
}
const LoginBodySchema = z.object({ password: z.string().min(1).max(512) }).strict()
/** Uniform rate-limit reject → 429. */
class LoginRateError extends Error {
constructor() {
super('login rate limited')
this.name = 'LoginRateError'
}
}
/** In-process sliding-window limiter keyed by client bucket (mirrors registry/devices.ts). */
function createLoginLimiter(max: number, windowMs: number, now: () => number) {
const hits = new Map<string, number[]>()
return {
check(key: string): void {
const ts = now()
const cutoff = ts - windowMs
const arr = (hits.get(key) ?? []).filter((t) => t > cutoff)
if (arr.length >= max) {
hits.set(key, arr) // persist the pruned window; do NOT record this rejected attempt
throw new LoginRateError()
}
arr.push(ts)
hits.set(key, arr)
},
}
}
function clampEnrollTtl(ttl: number): number {
return Math.min(Math.max(ttl, MIN_DEVICE_ENROLL_TTL_SEC), MAX_DEVICE_ENROLL_TTL_SEC)
}
/** The feature is live only when a signing key AND a resolvable credential seam are BOTH present. */
function isConfigured(deps: AuthLoginDeps): deps is AuthLoginDeps & { signingKey: CryptoKey } {
if (deps.signingKey === null) return false
const c = deps.loginConfig
return c.resolve !== undefined || (c.operatorCredential !== undefined && c.accountId !== undefined)
}
/** Map any thrown error to a uniform HTTP reject — never leak which internal check failed (no oracle). */
function sendLoginError(reply: FastifyReply, err: unknown): void {
if (err instanceof LoginRateError) {
void reply.code(429).send({ error: 'rate_limited' })
return
}
if (err instanceof DeviceEnrollAuthError) {
void reply.code(err.status).send({ error: 'rejected' })
return
}
if (err instanceof z.ZodError) {
void reply.code(400).send({ error: 'invalid request' })
return
}
void reply.code(400).send({ error: 'rejected' })
}
export function buildAuthLoginRouter(deps: AuthLoginDeps): FastifyPluginAsync {
const now = deps.now ?? (() => Date.now())
const clientKey = deps.clientKey ?? ((req: FastifyRequest) => req.ip || 'unknown')
const limiter = createLoginLimiter(deps.rateMax ?? DEFAULT_LOGIN_RATE_MAX, deps.rateWindowMs ?? DEFAULT_LOGIN_RATE_WINDOW_MS, now)
const ttl = clampEnrollTtl(deps.enrollTtlSec ?? DEFAULT_DEVICE_ENROLL_TTL_SEC)
return async (app) => {
app.post('/auth/login', async (req, reply) => {
try {
// Rate-limit BEFORE any credential work so brute force is throttled regardless of outcome.
limiter.check(clientKey(req))
const { password } = LoginBodySchema.parse(req.body)
// Fail-closed: an unconfigured login mints nothing (503, distinct from a wrong-credential 401).
if (!isConfigured(deps)) {
void reply.code(503).send({ error: 'login unavailable' })
return
}
// Constant-time credential → accountId (deny-by-default inside loginToAccountId). Any failure
// becomes a uniform 401 — never distinguish wrong-password from unknown-credential.
let accountId: string
try {
accountId = loginToAccountId(password, deps.loginConfig)
} catch {
throw new DeviceEnrollAuthError(401, 'login rejected')
}
const enrollToken = await mintDeviceEnrollToken(accountId, { signingKey: deps.signingKey, ttlSeconds: ttl })
await reply.code(201).send({ enrollToken, accountId, expiresIn: ttl })
} catch (err) {
sendLoginError(reply, err)
}
})
}
}

View File

@@ -22,7 +22,7 @@
import type { CapabilityRight, CapabilityToken } from 'relay-contracts' import type { CapabilityRight, CapabilityToken } from 'relay-contracts'
import { verifyCapabilityToken } from 'relay-auth' import { verifyCapabilityToken } from 'relay-auth'
import { signPaseto } from 'relay-auth/src/crypto/paseto.js' import { signPaseto } from 'relay-auth/src/crypto/paseto.js'
import { randomBytes, randomUUID } from 'node:crypto' import { createHash, randomBytes, randomUUID } from 'node:crypto'
import { timingSafeEqualBytes } from '../util/bytes.js' import { timingSafeEqualBytes } from '../util/bytes.js'
/** Distinct audience for device enrollment (Host-confusion guard — never a subdomain aud). */ /** Distinct audience for device enrollment (Host-confusion guard — never a subdomain aud). */
@@ -157,8 +157,11 @@ export function loginToAccountId(credential: string, config: LoginSeamConfig): s
return acct return acct
} }
if (config.operatorCredential !== undefined && config.accountId !== undefined) { if (config.operatorCredential !== undefined && config.accountId !== undefined) {
const a = new TextEncoder().encode(credential) // SHA-256 both to a fixed 32 bytes BEFORE comparing, so the compare is
const b = new TextEncoder().encode(config.operatorCredential) // length-independent — timingSafeEqualBytes short-circuits on unequal length,
// which would otherwise leak the operator credential's length via timing.
const a = createHash('sha256').update(credential, 'utf8').digest()
const b = createHash('sha256').update(config.operatorCredential, 'utf8').digest()
if (timingSafeEqualBytes(a, b)) return config.accountId if (timingSafeEqualBytes(a, b)) return config.accountId
throw new DeviceEnrollAuthError(401, 'login rejected') throw new DeviceEnrollAuthError(401, 'login rejected')
} }

View File

@@ -0,0 +1,26 @@
/**
* B1 — load the `device:enroll` bearer SIGNING key (Ed25519) from the CP env's PKCS#8 DER.
*
* The enroll bearer must verify on the SAME §4.3 path the admin API uses (boot/verifier.ts, keyed off
* `CAPABILITY_SIGN_PUBKEY_B64`), so the login route signs with the PRIVATE half of that same keypair.
* The key is imported NON-EXPORTABLE + `sign`-only (INV9: raw key material is never held as bytes and
* never logged). A malformed / non-Ed25519 key FAILS CLOSED (throws) — the CP refuses to serve a login
* route it cannot mint from.
*/
/**
* Import the Ed25519 PKCS#8 private key as a non-exportable, sign-only `CryptoKey`. Throws (fail-closed)
* on any malformed key; the error message never echoes key material (INV9).
*/
export async function loadEnrollSigningKey(pkcs8Der: Uint8Array): Promise<CryptoKey> {
// Copy into a fresh ArrayBuffer-backed view (WebCrypto BufferSource typing / no shared pool).
const bytes = new Uint8Array(pkcs8Der.length)
bytes.set(pkcs8Der)
try {
return await globalThis.crypto.subtle.importKey('pkcs8', bytes, { name: 'Ed25519' }, false, ['sign'])
} catch (err: unknown) {
throw new Error(
`failed to load device:enroll signing key: ${err instanceof Error ? err.message : 'unknown'}`,
)
}
}

View File

@@ -42,6 +42,18 @@ export interface ControlPlaneEnv {
readonly heartbeatTtlSec: number readonly heartbeatTtlSec: number
readonly pairingTtlSec: number readonly pairingTtlSec: number
readonly pairingMaxRedeemAttempts: number readonly pairingMaxRedeemAttempts: number
/**
* B1 operator-login seam (single-tenant MVP). ALL THREE are set together or NONE — a half-configured
* login is a boot error (fail-fast). Unset ⇒ the `/auth/login` route is fail-closed (503). When set:
* - `operatorPassword` — the operator login secret (mirrors WEBTERM_TOKEN / relay OPERATOR_PASSWORD;
* 16512 URL/cookie-safe chars). Constant-time compared, NEVER logged (INV9).
* - `operatorAccountId` — the account the minted `device:enroll` bearer is scoped to (`sub`).
* - `capabilitySignPrivkey` — Ed25519 PKCS#8 DER private key whose PUBLIC half is
* `capabilitySignPubkey`; used ONLY to sign the enroll bearer so it verifies on the same §4.3 path.
*/
readonly operatorPassword?: string
readonly operatorAccountId?: string
readonly capabilitySignPrivkey?: Uint8Array
} }
/** A positive integer parsed from an env string, or a default when unset/empty. */ /** A positive integer parsed from an env string, or a default when unset/empty. */
@@ -77,8 +89,51 @@ const EnvSchema = z.object({
HEARTBEAT_TTL_SEC: intWithDefault(15), HEARTBEAT_TTL_SEC: intWithDefault(15),
PAIRING_TTL_SEC: intWithDefault(DEFAULT_PAIRING_TTL_SEC), PAIRING_TTL_SEC: intWithDefault(DEFAULT_PAIRING_TTL_SEC),
PAIRING_MAX_REDEEM_ATTEMPTS: intWithDefault(DEFAULT_PAIRING_MAX_REDEEM_ATTEMPTS), PAIRING_MAX_REDEEM_ATTEMPTS: intWithDefault(DEFAULT_PAIRING_MAX_REDEEM_ATTEMPTS),
// B1 operator-login seam — all optional; cross-validated below (set together or not at all).
OPERATOR_PASSWORD: z
.string()
.trim()
.regex(/^[A-Za-z0-9._~+/=-]{16,512}$/, 'OPERATOR_PASSWORD must be 16512 URL/cookie-safe chars')
.optional(),
OPERATOR_ACCOUNT_ID: z.string().trim().uuid('OPERATOR_ACCOUNT_ID must be a UUID').optional(),
CAPABILITY_SIGN_PRIVKEY_B64: z.string().trim().min(1).optional(),
}) })
/**
* Resolve the optional operator-login triplet. Deny-by-default + fail-fast: if the operator password
* is set, the account id and the Ed25519 signing key MUST also be set (a half-configured login is a
* boot error, never a silent half-open). Returns `{}` when the feature is unconfigured. NEVER echoes
* secret VALUES — only key names on failure (INV9).
*/
function resolveOperatorLogin(e: {
OPERATOR_PASSWORD: string | undefined
OPERATOR_ACCOUNT_ID: string | undefined
CAPABILITY_SIGN_PRIVKEY_B64: string | undefined
}): { operatorPassword?: string; operatorAccountId?: string; capabilitySignPrivkey?: Uint8Array } {
const has = (v: string | undefined): v is string => v !== undefined && v.length > 0
const pw = e.OPERATOR_PASSWORD
const acct = e.OPERATOR_ACCOUNT_ID
const priv = e.CAPABILITY_SIGN_PRIVKEY_B64
if (!has(pw) && !has(acct) && !has(priv)) {
return {} // feature off — /auth/login is fail-closed at runtime
}
if (!has(pw) || !has(acct) || !has(priv)) {
const missing = [
has(pw) ? null : 'OPERATOR_PASSWORD',
has(acct) ? null : 'OPERATOR_ACCOUNT_ID',
has(priv) ? null : 'CAPABILITY_SIGN_PRIVKEY_B64',
].filter((x): x is string => x !== null)
throw new Error(`Invalid control-plane env: operator login requires all of ${missing.join(', ')} to be set`)
}
let capabilitySignPrivkey: Uint8Array
try {
capabilitySignPrivkey = base64ToBytes(priv)
} catch {
throw new Error('Invalid control-plane env: CAPABILITY_SIGN_PRIVKEY_B64 is not valid base64')
}
return { operatorPassword: pw, operatorAccountId: acct, capabilitySignPrivkey }
}
/** /**
* Parse + validate control-plane config. THROWS (fail-fast) listing every missing/invalid * Parse + validate control-plane config. THROWS (fail-fast) listing every missing/invalid
* key by NAME. Never echoes secret VALUES (INV9). * key by NAME. Never echoes secret VALUES (INV9).
@@ -125,5 +180,10 @@ export function loadEnv(source: NodeJS.ProcessEnv): ControlPlaneEnv {
heartbeatTtlSec: e.HEARTBEAT_TTL_SEC, heartbeatTtlSec: e.HEARTBEAT_TTL_SEC,
pairingTtlSec: e.PAIRING_TTL_SEC, pairingTtlSec: e.PAIRING_TTL_SEC,
pairingMaxRedeemAttempts: e.PAIRING_MAX_REDEEM_ATTEMPTS, pairingMaxRedeemAttempts: e.PAIRING_MAX_REDEEM_ATTEMPTS,
...resolveOperatorLogin({
OPERATOR_PASSWORD: e.OPERATOR_PASSWORD,
OPERATOR_ACCOUNT_ID: e.OPERATOR_ACCOUNT_ID,
CAPABILITY_SIGN_PRIVKEY_B64: e.CAPABILITY_SIGN_PRIVKEY_B64,
}),
} }
} }

View File

@@ -43,6 +43,9 @@ import { createFrpClientLeafSigner } from './ca/frpclient-issue.js'
import { createDeviceLeafSigner, type DeviceLeafSigner } from './ca/device-issue.js' import { createDeviceLeafSigner, type DeviceLeafSigner } from './ca/device-issue.js'
import { createLeafRenewer } from './ca/rotate.js' import { createLeafRenewer } from './ca/rotate.js'
import { buildDeviceEnrollRouter, type SubdomainOwnershipResolver } from './api/device-enroll.js' import { buildDeviceEnrollRouter, type SubdomainOwnershipResolver } from './api/device-enroll.js'
import { buildAuthLoginRouter } from './api/auth-login.js'
import { loadEnrollSigningKey } from './boot/session-signing.js'
import type { LoginSeamConfig } from './auth/session.js'
import { buildRenewRouter } from './api/renew.js' import { buildRenewRouter } from './api/renew.js'
import { buildNativeCas, DEFAULT_NATIVE_DNS_ZONE, type NativeCas, type NativeCaMaterial } from './boot/native-ca.js' import { buildNativeCas, DEFAULT_NATIVE_DNS_ZONE, type NativeCas, type NativeCaMaterial } from './boot/native-ca.js'
import type { RevocationBus } from 'relay-contracts' import type { RevocationBus } from 'relay-contracts'
@@ -243,6 +246,18 @@ export async function buildControlPlane(
await app.register(buildRouter({ authorizer, accounts, hosts, pairingIssuer, redeemer, deprovisioner, nativeEnroller })) await app.register(buildRouter({ authorizer, accounts, hosts, pairingIssuer, redeemer, deprovisioner, nativeEnroller }))
// Device enrollment is bearer-gated by the SAME capability verifier seam the admin API uses. // Device enrollment is bearer-gated by the SAME capability verifier seam the admin API uses.
await app.register(buildDeviceEnrollRouter({ verifier, devices: deviceRegistry, signer: deviceSigner, ownership })) await app.register(buildDeviceEnrollRouter({ verifier, devices: deviceRegistry, signer: deviceSigner, ownership }))
// B1 — operator login → device:enroll bearer mint. The route ALWAYS registers (so a phone client
// gets a coherent response), but is FAIL-CLOSED (503) unless the operator triplet is env-configured
// (env.ts cross-validates set-together-or-none). The bearer is signed with the PRIVATE half of
// `CAPABILITY_SIGN_PUBKEY_B64` so it verifies on the same §4.3 path /device/enroll checks. INV9: the
// signing key is imported non-exportable + sign-only; the operator secret is never logged.
const enrollSigningKey =
env.capabilitySignPrivkey !== undefined ? await loadEnrollSigningKey(env.capabilitySignPrivkey) : null
const loginConfig: LoginSeamConfig =
env.operatorPassword !== undefined && env.operatorAccountId !== undefined
? { operatorCredential: env.operatorPassword, accountId: env.operatorAccountId }
: {}
await app.register(buildAuthLoginRouter({ signingKey: enrollSigningKey, loginConfig }))
// Leaf renewal is mTLS-authenticated (current client cert) — anchors chain-validate the presented cert. // Leaf renewal is mTLS-authenticated (current client cert) — anchors chain-validate the presented cert.
await app.register( await app.register(
buildRenewRouter({ buildRenewRouter({

View File

@@ -0,0 +1,184 @@
/**
* 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 3060s 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)
})
})

View File

@@ -66,3 +66,52 @@ describe('T1 loadEnv (INV9 fail-fast)', () => {
expect(() => loadEnv({ ...base(), CAPABILITY_SIGN_PUBKEY_B64: short })).toThrow(/32 bytes/) expect(() => loadEnv({ ...base(), CAPABILITY_SIGN_PUBKEY_B64: short })).toThrow(/32 bytes/)
}) })
}) })
describe('B1 operator-login env (set-together-or-none, fail-closed)', () => {
const OPERATOR_PASSWORD = 'operator-secret-abcdef123456'
const ACCOUNT = '11111111-1111-4111-8111-111111111111'
const PRIVKEY = bytesToBase64(new Uint8Array(48).fill(3)) // shape-valid base64; import validated at boot
test('none set → login fields undefined (feature off, route fail-closed at runtime)', () => {
const env = loadEnv(base())
expect(env.operatorPassword).toBeUndefined()
expect(env.operatorAccountId).toBeUndefined()
expect(env.capabilitySignPrivkey).toBeUndefined()
})
test('all three set → parsed together', () => {
const env = loadEnv({
...base(),
OPERATOR_PASSWORD,
OPERATOR_ACCOUNT_ID: ACCOUNT,
CAPABILITY_SIGN_PRIVKEY_B64: PRIVKEY,
})
expect(env.operatorPassword).toBe(OPERATOR_PASSWORD)
expect(env.operatorAccountId).toBe(ACCOUNT)
expect(env.capabilitySignPrivkey?.length).toBeGreaterThan(0)
})
test('password without account/key → fail-fast (no silent half-open)', () => {
expect(() => loadEnv({ ...base(), OPERATOR_PASSWORD })).toThrow(/OPERATOR_ACCOUNT_ID|CAPABILITY_SIGN_PRIVKEY_B64/)
})
test('too-short operator password rejected (16512 charset rule)', () => {
expect(() =>
loadEnv({ ...base(), OPERATOR_PASSWORD: 'short', OPERATOR_ACCOUNT_ID: ACCOUNT, CAPABILITY_SIGN_PRIVKEY_B64: PRIVKEY }),
).toThrow(/OPERATOR_PASSWORD/)
})
test('non-UUID operator account rejected', () => {
expect(() =>
loadEnv({ ...base(), OPERATOR_PASSWORD, OPERATOR_ACCOUNT_ID: 'not-a-uuid', CAPABILITY_SIGN_PRIVKEY_B64: PRIVKEY }),
).toThrow(/OPERATOR_ACCOUNT_ID/)
})
test('never echoes the operator secret on a partial-config failure (INV9)', () => {
try {
loadEnv({ ...base(), OPERATOR_PASSWORD })
} catch (e) {
expect(e instanceof Error ? e.message : '').not.toContain(OPERATOR_PASSWORD)
}
})
})