diff --git a/control-plane/src/api/auth-login.ts b/control-plane/src/api/auth-login.ts new file mode 100644 index 0000000..8ed9eea --- /dev/null +++ b/control-plane/src/api/auth-login.ts @@ -0,0 +1,142 @@ +/** + * B1 — operator login → `device:enroll` bearer mint (registerable Fastify plugin; wired in `main.ts`). + * + * POST /auth/login { "password": "" } + * -> 201 { "enrollToken": "", "accountId": "", "expiresIn": } + * + * The `enrollToken` is the short-lived §4.3 `device:enroll` capability token that `POST /device/enroll` + * requires as `Authorization: Bearer `. 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() + 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) + } + }) + } +} diff --git a/control-plane/src/auth/session.ts b/control-plane/src/auth/session.ts index e194e42..cf97c4d 100644 --- a/control-plane/src/auth/session.ts +++ b/control-plane/src/auth/session.ts @@ -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') } diff --git a/control-plane/src/boot/session-signing.ts b/control-plane/src/boot/session-signing.ts new file mode 100644 index 0000000..4aff886 --- /dev/null +++ b/control-plane/src/boot/session-signing.ts @@ -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 { + // 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'}`, + ) + } +} diff --git a/control-plane/src/env.ts b/control-plane/src/env.ts index 29f660e..d065d06 100644 --- a/control-plane/src/env.ts +++ b/control-plane/src/env.ts @@ -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, + }), } } diff --git a/control-plane/src/main.ts b/control-plane/src/main.ts index f079e1e..a3ad086 100644 --- a/control-plane/src/main.ts +++ b/control-plane/src/main.ts @@ -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({ diff --git a/control-plane/test/auth-login.test.ts b/control-plane/test/auth-login.test.ts new file mode 100644 index 0000000..6c8effc --- /dev/null +++ b/control-plane/test/auth-login.test.ts @@ -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 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 = {}): 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) + }) +}) diff --git a/control-plane/test/env.test.ts b/control-plane/test/env.test.ts index 8fa17cd..2f0990e 100644 --- a/control-plane/test/env.test.ts +++ b/control-plane/test/env.test.ts @@ -66,3 +66,52 @@ describe('T1 loadEnv (INV9 fail-fast)', () => { 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 (16–512 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) + } + }) +})