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:
142
control-plane/src/api/auth-login.ts
Normal file
142
control-plane/src/api/auth-login.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
import type { CapabilityRight, CapabilityToken } from 'relay-contracts'
|
||||
import { verifyCapabilityToken } from 'relay-auth'
|
||||
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'
|
||||
|
||||
/** 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
|
||||
}
|
||||
if (config.operatorCredential !== undefined && config.accountId !== undefined) {
|
||||
const a = new TextEncoder().encode(credential)
|
||||
const b = new TextEncoder().encode(config.operatorCredential)
|
||||
// SHA-256 both to a fixed 32 bytes BEFORE comparing, so the compare is
|
||||
// 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
|
||||
throw new DeviceEnrollAuthError(401, 'login rejected')
|
||||
}
|
||||
|
||||
26
control-plane/src/boot/session-signing.ts
Normal file
26
control-plane/src/boot/session-signing.ts
Normal 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'}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,18 @@ export interface ControlPlaneEnv {
|
||||
readonly heartbeatTtlSec: number
|
||||
readonly pairingTtlSec: 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;
|
||||
* 16–512 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. */
|
||||
@@ -77,8 +89,51 @@ const EnvSchema = z.object({
|
||||
HEARTBEAT_TTL_SEC: intWithDefault(15),
|
||||
PAIRING_TTL_SEC: intWithDefault(DEFAULT_PAIRING_TTL_SEC),
|
||||
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 16–512 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
|
||||
* key by NAME. Never echoes secret VALUES (INV9).
|
||||
@@ -125,5 +180,10 @@ export function loadEnv(source: NodeJS.ProcessEnv): ControlPlaneEnv {
|
||||
heartbeatTtlSec: e.HEARTBEAT_TTL_SEC,
|
||||
pairingTtlSec: e.PAIRING_TTL_SEC,
|
||||
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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ import { createFrpClientLeafSigner } from './ca/frpclient-issue.js'
|
||||
import { createDeviceLeafSigner, type DeviceLeafSigner } from './ca/device-issue.js'
|
||||
import { createLeafRenewer } from './ca/rotate.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 { buildNativeCas, DEFAULT_NATIVE_DNS_ZONE, type NativeCas, type NativeCaMaterial } from './boot/native-ca.js'
|
||||
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 }))
|
||||
// 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 }))
|
||||
// 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.
|
||||
await app.register(
|
||||
buildRenewRouter({
|
||||
|
||||
Reference in New Issue
Block a user