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

@@ -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({