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)
}
})
}
}