diff --git a/agent/src/certs/nativeRenew.ts b/agent/src/certs/nativeRenew.ts new file mode 100644 index 0000000..ac92762 --- /dev/null +++ b/agent/src/certs/nativeRenew.ts @@ -0,0 +1,247 @@ +/** + * Native-tunnel cert auto-renew wiring — TASK A5 (PLAN_ZERO_TOUCH_ROLLOUT). + * + * The native run-loop (`superviseNative`) used to only MONITOR the frp-client leaf's freshness; the + * leaf therefore expired at ~24h and the tunnel dropped until a manual re-pair. This module closes + * that gap by driving `createCertRotator`/`renewCert` (crypto is REUSED, never reimplemented): + * + * - `createMtlsFetch` — the injected `fetchImpl` the rotator hands to `renewCert`. It POSTs /renew + * over mTLS presenting the CURRENT keystore leaf (re-read on every call, so the first renewal + * after a rotation already authenticates with the freshly issued leaf). mTLS IS the auth — no + * token, `rejectUnauthorized` always true (INV4/INV14). The private key stays in-process. + * - `wireAutoRenew` — routes the rotator callbacks: rotated → restart frpc onto the new leaf and + * log (non-secret); revoked (403) → tear the tunnel down (INV12); error → log + let the rotator + * retry with backoff. A failed renewal NEVER crashes the supervisor. + * - `startNativeAutoRenew` — the builder `superviseNative` calls: loads the identity, builds the + * mTLS fetch + rotator at ~2/3-TTL, and starts it. Returns null (auto-renew disabled) if the host + * is not enrolled (no identity), rather than throwing into the run-loop. + */ +import { request as httpsRequest } from 'node:https' +import type { AgentConfig } from '../config/agentConfig.js' +import type { Keystore } from '../keys/keystore.js' +import type { Logger } from '../log/logger.js' +import type { TimerLike } from '../transport/seams.js' +import { createBackoff } from '../transport/backoff.js' +import { buildTlsOptions, type CertParser, type TlsClientOptions } from '../transport/dial.js' +import { DEFAULT_CERT_RENEW_WINDOW_MS } from '../health/probe.js' +import { createCertRotator, type CertRotator } from './rotation.js' + +/** Non-secret message from an unknown thrown value (never serializes cert/key material). */ +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +/** + * Socket-idle timeout for a /renew request. A stalled or overloaded control-plane (or a NAT that + * silently drops the connection after the TLS handshake) must NOT leave the renewal Promise pending + * forever — that would starve the rotator's backoff-retry loop and let the leaf silently expire. On + * timeout the request is destroyed and the rejection surfaces through the rotator's onError→backoff. + */ +export const RENEW_REQUEST_TIMEOUT_MS = 15_000 +/** + * Hard cap on the buffered /renew response body. The reply is a small `{cert,caChain}` JSON; anything + * beyond a few KB is malformed or hostile, so we destroy the stream and reject rather than buffer it. + */ +export const MAX_RENEW_RESPONSE_BYTES = 64 * 1024 + +// --- mTLS fetch -------------------------------------------------------------------------------- + +/** A single mTLS request the fetch shim delegates to (injectable so the shim is offline-testable). */ +export interface MtlsRequestInit { + readonly method: string + readonly headers: Record + readonly body?: string +} +export interface MtlsResponse { + readonly status: number + readonly body: string +} +export type MtlsRequest = ( + url: string, + tls: TlsClientOptions, + init: MtlsRequestInit, +) => Promise + +/** Default mTLS transport: a `node:https` POST presenting the client cert/key + pinned CA. */ +const defaultMtlsRequest: MtlsRequest = (url, tls, init) => + new Promise((resolve, reject) => { + const req = httpsRequest( + url, + { + method: init.method, + headers: init.headers, + cert: tls.cert, + key: tls.key, + // ca omitted ⇒ verify the server against the system roots (LE-fronted CP). Present only when a + // private CA is pinned (not for /renew). + ...(tls.ca !== undefined ? { ca: tls.ca } : {}), + rejectUnauthorized: tls.rejectUnauthorized, // always true (anti-MITM, INV14) + }, + (res) => { + const chunks: Buffer[] = [] + let total = 0 + res.on('data', (c: Buffer) => { + total += c.length + if (total > MAX_RENEW_RESPONSE_BYTES) { + res.destroy() // MEDIUM: refuse an unbounded body — a renew reply is a few-KB JSON + reject(new Error(`renew response body exceeded ${MAX_RENEW_RESPONSE_BYTES} byte cap`)) + return + } + chunks.push(c) + }) + res.on('end', () => + resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }), + ) + res.on('error', reject) // a mid-stream socket error must reject, not hang + }, + ) + // HIGH: bound the request so a peer that accepts the connection but never replies rejects (and the + // rotator re-enters backoff) instead of pending forever — destroy(err) emits 'error' → reject below. + req.setTimeout(RENEW_REQUEST_TIMEOUT_MS, () => { + req.destroy(new Error(`renew request timed out after ${RENEW_REQUEST_TIMEOUT_MS}ms`)) + }) + req.on('error', reject) + if (init.body !== undefined) req.write(init.body) + req.end() + }) + +function toHeaderRecord(headers: RequestInit['headers']): Record { + if (!headers) return {} + if (headers instanceof Headers) { + const out: Record = {} + headers.forEach((v, k) => { + out[k] = v + }) + return out + } + if (Array.isArray(headers)) return Object.fromEntries(headers) + return { ...(headers as Record) } +} + +/** + * Build the `fetch`-shaped shim `renewCert` uses. Each call re-reads the CURRENT keystore leaf via + * `buildTlsOptions` (which fail-fast throws NotEnrolled/CertExpired — the rotator then logs + retries + * with backoff, never crashing) and delegates to the mTLS transport, mapping the result to a real + * `Response` (so `res.ok`/`res.status`/`res.json()` behave exactly as `renewCert` expects). + */ +export function createMtlsFetch( + ks: Keystore, + opts: { request?: MtlsRequest; certParser?: CertParser } = {}, +): typeof fetch { + const request = opts.request ?? defaultMtlsRequest + const shim = async (input: Parameters[0], init?: RequestInit): Promise => { + const url = typeof input === 'string' ? input : input.toString() + // Present the current frp-client leaf (client auth), but verify the /renew SERVER cert against the + // SYSTEM roots — its host (the LE-fronted control-plane) is publicly trusted; pinning the private + // enroll caChain here fails with "unable to get local issuer certificate". So drop `ca` (absent → + // node uses the default roots); rejectUnauthorized stays true. + const full = buildTlsOptions(ks, { ...(opts.certParser ? { certParser: opts.certParser } : {}) }) + const tls: TlsClientOptions = { cert: full.cert, key: full.key, rejectUnauthorized: full.rejectUnauthorized } + const reqInit: MtlsRequestInit = { + method: init?.method ?? 'GET', + headers: toHeaderRecord(init?.headers), + ...(typeof init?.body === 'string' ? { body: init.body } : {}), + } + const { status, body } = await request(url, tls, reqInit) + return new Response(body, { status }) + } + return shim as typeof fetch +} + +// --- rotator wiring ---------------------------------------------------------------------------- + +/** Non-secret identifiers logged alongside renew events (INV9). */ +export interface AutoRenewLogIds { + readonly subdomain: string | null + readonly hostId: string | null +} + +/** The two run-loop effects the rotator drives. */ +export interface AutoRenewHooks { + /** Restart the supervised frpc so it re-reads the rotated cert (a leaf rotation only). */ + restartChild(): void + /** Tear the tunnel down (host revoked ⇒ never reconnect, INV12). */ + stop(): void +} + +/** Handle for the wired auto-renew loop. */ +export interface AutoRenewController { + stop(): void +} + +/** + * Wire a rotator's callbacks to the run-loop and start it. Rotated → restart frpc; revoked → stop; + * error → log (non-secret) and let the rotator retry with backoff. Returns a controller that stops + * the rotator's scheduled timer. + */ +export function wireAutoRenew( + rotator: CertRotator, + hooks: AutoRenewHooks, + logger: Logger, + ids: AutoRenewLogIds, +): AutoRenewController { + const meta = { subdomain: ids.subdomain, hostId: ids.hostId } + rotator.onRotated(() => { + logger.log('info', 'frp-client cert rotated; restarting frpc onto the fresh leaf', meta) + hooks.restartChild() + }) + rotator.onRevoked(() => { + logger.log('warn', 'frp-client cert renewal refused (host revoked); tearing down tunnel', meta) + hooks.stop() + }) + rotator.onError((err) => { + logger.log('warn', 'frp-client cert renewal failed; will retry with backoff', { + ...meta, + error: errorMessage(err), + }) + }) + rotator.start() + return { stop: () => rotator.stop() } +} + +// --- builder ----------------------------------------------------------------------------------- + +/** Injection seams for `startNativeAutoRenew` (all optional; unset ⇒ real transport/timers). */ +export interface NativeAutoRenewOpts { + readonly mtlsRequest?: MtlsRequest + readonly certParser?: CertParser + readonly timer?: TimerLike + readonly renewBeforeMs?: number + readonly retryBaseMs?: number + readonly now?: () => Date + readonly parseCert?: (pem: string) => Date +} + +/** + * Build + start native cert auto-renew for `superviseNative`. Renews at ~2/3 of the leaf TTL + * (default `DEFAULT_CERT_RENEW_WINDOW_MS`, the same window the health probe alarms on). Returns null + * (auto-renew disabled, logged) when the host has no identity — an unenrolled run-loop must not throw. + */ +export function startNativeAutoRenew( + cfg: AgentConfig, + ks: Keystore, + hooks: AutoRenewHooks, + logger: Logger, + opts: NativeAutoRenewOpts = {}, +): AutoRenewController | null { + const id = ks.loadIdentity() + if (id === null) { + logger.log('warn', 'no identity in keystore — cert auto-renew disabled', {}) + return null + } + const fetchImpl = createMtlsFetch(ks, { + ...(opts.mtlsRequest ? { request: opts.mtlsRequest } : {}), + ...(opts.certParser ? { certParser: opts.certParser } : {}), + }) + const rotator = createCertRotator(cfg, id, ks, { + fetchImpl, + renewBeforeMs: opts.renewBeforeMs ?? DEFAULT_CERT_RENEW_WINDOW_MS, + ...(opts.timer ? { timer: opts.timer } : {}), + ...(opts.now ? { now: opts.now } : {}), + ...(opts.parseCert ? { parseCert: opts.parseCert } : {}), + ...(opts.retryBaseMs !== undefined + ? { retryBackoff: createBackoff({ baseMs: opts.retryBaseMs, jitter: false }) } + : {}), + }) + return wireAutoRenew(rotator, hooks, logger, { subdomain: cfg.subdomain, hostId: cfg.hostId }) +} diff --git a/agent/src/certs/pem.ts b/agent/src/certs/pem.ts new file mode 100644 index 0000000..b031f31 --- /dev/null +++ b/agent/src/certs/pem.ts @@ -0,0 +1,33 @@ +/** + * PEM helpers shared by the native enroll (enroll/pair.ts) and renew (certs/rotation.ts) paths. + * + * The control-plane returns the frp-client leaf + CA chain as base64-encoded DER (cert as a string, + * caChain as a string[]); the keystore + frpc need PEM files. `derBase64ToPem` wraps a base64 DER body + * back into CERTIFICATE armor at 64 columns. + */ + +/** base64(DER) → PEM (CERTIFICATE armor, 64-col wrapped). */ +export function derBase64ToPem(derBase64: string, label = 'CERTIFICATE'): string { + const body = derBase64.replace(/\s+/g, '') + const lines = body.match(/.{1,64}/g) ?? [] + return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n` +} + +/** + * Normalize a control-plane cert response ({cert: base64 DER, caChain: base64 DER[]}) to PEM strings + * for the keystore. Throws if the shape is wrong. Shared by enroll + renew so both stay in lockstep. + */ +export function certResponseToPem(cert: unknown, caChain: unknown): { certPem: string; caChainPem: string } { + if ( + typeof cert !== 'string' || + !Array.isArray(caChain) || + caChain.length === 0 || + !caChain.every((c) => typeof c === 'string') + ) { + throw new Error('cert response missing cert/caChain') + } + return { + certPem: derBase64ToPem(cert), + caChainPem: (caChain as string[]).map((c) => derBase64ToPem(c)).join(''), + } +} diff --git a/agent/src/certs/rotation.ts b/agent/src/certs/rotation.ts index f243af5..9ad15e0 100644 --- a/agent/src/certs/rotation.ts +++ b/agent/src/certs/rotation.ts @@ -13,7 +13,9 @@ import type { AgentConfig } from '../config/agentConfig.js' import type { AgentIdentity } from '../keys/identity.js' import type { Keystore } from '../keys/keystore.js' import type { TimerLike } from '../transport/seams.js' +import { createBackoff, type BackoffPolicy } from '../transport/backoff.js' import { buildCsr } from '../enroll/csr.js' +import { certResponseToPem } from './pem.js' export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry @@ -22,6 +24,8 @@ export interface CertRotator { stop(): void onRotated(cb: () => void): void onRevoked(cb: () => void): void + /** A renewal attempt failed (network/HTTP, NOT a 403 revoke). The rotator retries with backoff. */ + onError(cb: (err: unknown) => void): void } export type RenewOutcome = 'rotated' | 'revoked' @@ -61,11 +65,11 @@ export async function renewCert( }) if (res.status === 403) return 'revoked' if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`) - const json = (await res.json()) as { cert?: string; caChain?: string } - if (typeof json.cert !== 'string' || typeof json.caChain !== 'string') { - throw new Error('cert renewal response missing cert/caChain') - } - ks.saveCert(json.cert, json.caChain) // atomic whole-file install + // The control-plane returns cert=base64(DER) + caChain=base64(DER)[]; normalize to PEM for the + // keystore + frpc (same shape as native enroll). + const json = (await res.json()) as { cert?: unknown; caChain?: unknown } + const { certPem, caChainPem } = certResponseToPem(json.cert, json.caChain) + ks.saveCert(certPem, caChainPem) // atomic whole-file install return 'rotated' } @@ -79,6 +83,8 @@ export function createCertRotator( fetchImpl?: typeof fetch now?: () => Date parseCert?: (pem: string) => Date + /** Backoff policy for retrying a FAILED renewal (default 1s→30s). Reset after a success. */ + retryBackoff?: BackoffPolicy } = {}, ): CertRotator { const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS @@ -91,9 +97,11 @@ export function createCertRotator( } const doFetch = opts.fetchImpl ?? fetch const now = opts.now ?? (() => new Date()) + const retryBackoff = opts.retryBackoff ?? createBackoff({ jitter: true }) let handle: unknown = null let rotatedCb: (() => void) | null = null let revokedCb: (() => void) | null = null + let errorCb: ((err: unknown) => void) | null = null function schedule(): void { const certs = ks.loadCert() @@ -109,12 +117,16 @@ export function createCertRotator( revokedCb?.() return } + retryBackoff.reset() // a healthy renewal clears the retry backoff for the next cycle rotatedCb?.() schedule() }) - .catch(() => { - // network error: retry after renewBeforeMs; the tunnel stays up meanwhile. - handle = timer.setTimeout(runRenewal, renewBeforeMs) + .catch((err: unknown) => { + // Network/HTTP failure (never a 403 revoke): surface it (caller logs, no secret) and retry + // with backoff. The cert is still valid until expiry, so the tunnel stays up meanwhile — a + // failed renewal must NEVER tear the supervisor down. + errorCb?.(err) + handle = timer.setTimeout(runRenewal, retryBackoff.nextDelayMs()) }) } @@ -132,5 +144,8 @@ export function createCertRotator( onRevoked(cb): void { revokedCb = cb }, + onError(cb): void { + errorCb = cb + }, } } diff --git a/agent/src/cli/deps.ts b/agent/src/cli/deps.ts index 6696e74..c2616c4 100644 --- a/agent/src/cli/deps.ts +++ b/agent/src/cli/deps.ts @@ -23,6 +23,7 @@ import { runTunnel } from '../transport/runTunnel.js' import { buildNativeFrpcToml } from '../transport/frpcToml.js' import { superviseFrpc } from '../transport/frpSupervise.js' import { provisionFrpc } from '../provision/frpcBinary.js' +import { startNativeAutoRenew } from '../certs/nativeRenew.js' import { probeLoopbackBaseApp, renderHealthStatus, @@ -98,7 +99,7 @@ async function enrollNative( id: AgentIdentity, ks: Keystore, ): Promise { - const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks) + const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks, { allowMissingContentSecret: true }) return { hostId: enroll.hostId, subdomain: enroll.subdomain } } @@ -156,9 +157,12 @@ export function readFrpcLog(stateDir: string): string { } /** - * Native run-loop (B4/H4): supervise the pinned frpc child with restart-on-exit backoff while a + * Native run-loop (B4/H4 + A5): supervise the pinned frpc child with restart-on-exit backoff while a * periodic health probe (frpc alive, base-app loopback reachable, proxy-started, cert-not-expiring) - * logs NON-SECRET status only (INV9). Resolves when the supervisor stops (SIGTERM/SIGINT). + * logs NON-SECRET status only (INV9), AND auto-renew the frp-client leaf at ~2/3 TTL so the tunnel + * never drops on cert expiry (A5): a successful renewal restarts frpc onto the fresh leaf, a 403 + * revoke tears the tunnel down, and a failed renewal retries with backoff without crashing the + * supervisor. Resolves when the supervisor stops (SIGTERM/SIGINT). */ function superviseNative(cfg: AgentConfig, ks: Keystore): Promise { const logger = createLogger('info') @@ -184,12 +188,28 @@ function superviseNative(cfg: AgentConfig, ks: Keystore): Promise { for (const line of renderHealthStatus(ids, report)) logger.log('info', line) }, ) + // A5: silently renew the leaf before it expires. Restart frpc onto the fresh cert on rotation; + // stop the whole supervisor on a 403 revoke (INV12). Null ⇒ unenrolled (auto-renew disabled). + const autoRenew = startNativeAutoRenew( + cfg, + ks, + { + restartChild: () => handle.restartChild(), + stop: () => { + void handle.stop() + }, + }, + logger, + ) const onSignal = (): void => { void handle.stop() } process.once('SIGTERM', onSignal) process.once('SIGINT', onSignal) - return handle.done.finally(() => monitor.stop()) + return handle.done.finally(() => { + monitor.stop() + autoRenew?.stop() + }) } /** Build the concrete CliDeps used by the real CLI entrypoint. */ diff --git a/agent/src/enroll/pair.ts b/agent/src/enroll/pair.ts index 9a06fdc..ea7a0a9 100644 --- a/agent/src/enroll/pair.ts +++ b/agent/src/enroll/pair.ts @@ -15,6 +15,7 @@ import type { EnrollResult } from 'relay-contracts' import type { AgentIdentity } from '../keys/identity.js' import type { Keystore } from '../keys/keystore.js' import { buildCsr } from './csr.js' +import { derBase64ToPem } from '../certs/pem.js' /** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */ export type EnrollMode = 'token' | 'ed25519' @@ -53,6 +54,12 @@ export interface RedeemOptions { readonly agentToken?: string readonly unwrapContentSecret?: UnwrapContentSecret readonly subject?: string + /** + * Native frp-client enroll has NO E2E content secret — the control-plane returns + * `hostContentSecret: null`. When true, tolerate its absence (skip unwrap + storage); the plain + * frpc byte tunnel needs no content key. Defaults false so the legacy relay path still requires it. + */ + readonly allowMissingContentSecret?: boolean } interface EnrollResponseJson { @@ -63,10 +70,32 @@ interface EnrollResponseJson { hostContentSecret: string // base64url over the wire } -function parseEnrollResult(json: unknown): EnrollResult { +function parseEnrollResult(json: unknown, allowMissingContentSecret = false): EnrollResult { const j = json as Partial if (typeof j.hostContentSecret !== 'string') { - throw new EnrollError('enroll response missing hostContentSecret') + if (!allowMissingContentSecret) { + throw new EnrollError('enroll response missing hostContentSecret') + } + // Native frp-client enroll: cert = base64(DER) string, caChain = base64(DER) string[], no content + // key. The keystore + frpc need PEM, so convert here. Empty secret sentinel is never stored. + const caChain: unknown = j.caChain + if ( + typeof j.hostId !== 'string' || + typeof j.subdomain !== 'string' || + typeof j.cert !== 'string' || + !Array.isArray(caChain) || + caChain.length === 0 || + !caChain.every((c) => typeof c === 'string') + ) { + throw new EnrollError('enroll response missing required fields') + } + return { + hostId: j.hostId, + subdomain: j.subdomain, + cert: derBase64ToPem(j.cert), + caChain: (caChain as string[]).map((c) => derBase64ToPem(c)).join(''), + hostContentSecret: new Uint8Array(0), + } } const candidate = { hostId: j.hostId, @@ -128,10 +157,13 @@ export async function redeemPairingCode( throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`) } - const enroll = parseEnrollResult(json) + const enroll = parseEnrollResult(json, opts.allowMissingContentSecret ?? false) ks.saveCert(enroll.cert, enroll.caChain) // FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored). - const unwrapped = unwrap(enroll.hostContentSecret, id) - ks.saveContentSecret(unwrapped) + // Native frp-client enroll has no content secret (empty sentinel) → nothing to unwrap/store. + if (enroll.hostContentSecret.length > 0) { + const unwrapped = unwrap(enroll.hostContentSecret, id) + ks.saveContentSecret(unwrapped) + } return enroll } diff --git a/agent/src/service/install.ts b/agent/src/service/install.ts index f859c9c..8563098 100644 --- a/agent/src/service/install.ts +++ b/agent/src/service/install.ts @@ -11,6 +11,7 @@ * - FIX M-host-2service: base-app env (BIND_HOST/ALLOWED_ORIGINS/PORT/…) is routed to the * base-app unit ONLY; the agent unit (which supervises frpc) never carries it. */ +import { dirname, join } from 'node:path' import type { AgentConfig } from '../config/agentConfig.js' import { agentLabel, @@ -202,13 +203,37 @@ export async function installService( // so nothing is ever written for a rejected install. const baseAppEnv = resolveBaseAppEnv(cfg, options) const bin = deps.binPath() - const baseAppExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC + const rawExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC + // launchd/systemd start with a minimal PATH that excludes /usr/local/bin (where a nvm/brew `node` + // symlink usually lives), so a bare `node` program dies with EX_CONFIG(78). Use the absolute node + // (process.execPath) and export a PATH so the units — and the base app's tmux/node-pty/frpc + // subprocesses — resolve their tools. + const nodePath = process.execPath + const unitPath = `${dirname(nodePath)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin` + const baseAppExec = rawExec[0] === 'node' ? [nodePath, ...rawExec.slice(1)] : rawExec + const baseAppEnvWithPath = { ...baseAppEnv, PATH: unitPath } + // The agent unit runs `run`, which loadConfig()-validates ENROLL_URL(https)/RELAY_URL(wss) up front + // (fail-fast). Without these in the unit env the supervisor exits 1 on every launch — so inject the + // agent's own runtime config (NOT the base-app env) alongside PATH. + const agentEnv: Record = { + PATH: unitPath, + ENROLL_URL: cfg.enrollUrl, + RELAY_URL: cfg.relayUrl, + STATE_DIR: cfg.stateDir, + LOCAL_TARGET_URL: cfg.localTargetUrl, + } if (platform === 'launchd') { const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel()) - deps.writeFile(baseAppPath, buildLaunchdPlist(baseAppExec, baseAppEnv, baseAppLabel())) + deps.writeFile( + baseAppPath, + buildLaunchdPlist(baseAppExec, baseAppEnvWithPath, baseAppLabel(), join(cfg.stateDir, 'base-app.log')), + ) const agentPath = launchdPlistPath(deps.homedir(), agentLabel()) - deps.writeFile(agentPath, buildLaunchdPlist([bin, 'run'], {}, agentLabel())) + deps.writeFile( + agentPath, + buildLaunchdPlist([nodePath, bin, 'run'], agentEnv, agentLabel(), join(cfg.stateDir, 'agent.log')), + ) for (const path of [baseAppPath, agentPath]) { const { cmd, args } = launchdLoadCommand(path) await deps.runCommand(cmd, args) @@ -217,8 +242,8 @@ export async function installService( } const baseAppOptions: SystemdUnitOptions = options.envFile - ? { env: baseAppEnv, envFile: options.envFile } - : { env: baseAppEnv } + ? { env: baseAppEnvWithPath, envFile: options.envFile } + : { env: baseAppEnvWithPath } const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName()) deps.writeFile( baseAppPath, @@ -227,7 +252,7 @@ export async function installService( const agentPath = systemdUnitPath(deps.homedir(), agentUnitName()) deps.writeFile( agentPath, - buildSystemdUnit(`${bin} run`, deps.username(), {}, 'web-terminal host agent (frpc supervisor)'), + buildSystemdUnit(`${nodePath} ${bin} run`, deps.username(), { env: agentEnv }, 'web-terminal host agent (frpc supervisor)'), ) for (const unit of [baseAppUnitName(), agentUnitName()]) { const { cmd, args } = systemdEnableCommand(unit) diff --git a/agent/src/service/launchd.ts b/agent/src/service/launchd.ts index b4d1e76..772ca4b 100644 --- a/agent/src/service/launchd.ts +++ b/agent/src/service/launchd.ts @@ -78,6 +78,7 @@ export function buildLaunchdPlist( programArguments: readonly string[], env: ServiceEnv = {}, label: string = AGENT_LABEL, + logPath?: string, ): string { return [ '', @@ -91,6 +92,16 @@ export function buildLaunchdPlist( ' ', ' KeepAlive', ' ', + // launchd's default has no log sink (unlike systemd's journald), so a crashing unit is silent. + // Route stdout+stderr to a file so `pair --install` failures are diagnosable out of the box. + ...(logPath !== undefined + ? [ + ' StandardOutPath', + ` ${escapeXml(logPath)}`, + ' StandardErrorPath', + ` ${escapeXml(logPath)}`, + ] + : []), ...environmentVariablesBlock(env), '', '', diff --git a/agent/src/transport/dial.ts b/agent/src/transport/dial.ts index cd2d9dc..8e645c9 100644 --- a/agent/src/transport/dial.ts +++ b/agent/src/transport/dial.ts @@ -25,7 +25,13 @@ export class CertExpiredError extends Error { export interface TlsClientOptions { readonly cert: string readonly key: string - readonly ca: string + /** + * CA(s) to verify the SERVER cert against. Set to the private tunnel CA for the relay dial; left + * undefined for a request whose server is publicly trusted (the LE-fronted control-plane /renew) so + * Node verifies against the system roots — pinning the private CA there fails with "unable to get + * local issuer certificate". + */ + readonly ca?: string readonly rejectUnauthorized: true } diff --git a/agent/src/transport/frpSupervise.ts b/agent/src/transport/frpSupervise.ts index 6ab4667..cddd559 100644 --- a/agent/src/transport/frpSupervise.ts +++ b/agent/src/transport/frpSupervise.ts @@ -62,6 +62,12 @@ export interface FrpSuperviseHandle { readonly done: Promise /** True while a frpc child is currently running (for the health probe). */ isChildAlive(): boolean + /** + * Kill the current child WITHOUT stopping the supervisor (A5): the restart-on-exit loop respawns a + * fresh frpc that re-reads the (now rotated) cert/key/CA files. A no-op once `stop()` was called — + * it must never resurrect a supervisor that is shutting down. + */ + restartChild(): void } /** A frpc run lasting at least this long is "stable" ⇒ reset the restart backoff. */ @@ -196,5 +202,8 @@ export function superviseFrpc( }, done, isChildAlive: () => child?.isAlive() ?? false, + restartChild(): void { + if (!stopped) child?.kill() + }, } } diff --git a/agent/test/frpSupervise.test.ts b/agent/test/frpSupervise.test.ts index e19d0ce..7bc8268 100644 --- a/agent/test/frpSupervise.test.ts +++ b/agent/test/frpSupervise.test.ts @@ -145,4 +145,44 @@ describe('superviseFrpc (B4/H4 — restart-on-exit backoff)', () => { await stopping expect(spawn).toHaveBeenCalledTimes(1) }) + + it('restartChild() kills the running child so the loop respawns onto the fresh cert files', async () => { + const flush = (): Promise => new Promise((r) => setImmediate(r)) + const children = [makeChild(), makeChild()] + let n = 0 + const spawn: SpawnFrpc = () => children[n++]!.child + const handle = superviseFrpc('/frpc', '/toml', { + spawn, + sleep: async () => {}, + logger: silentLogger, + now: () => 1_000_000, // fixed clock: run counts as unstable, but sleep is a no-op → respawn is immediate + }) + await flush() + expect(handle.isChildAlive()).toBe(true) // child[0] + + handle.restartChild() // A5: rotator calls this after a cert rotation to reload the new leaf + expect(children[0]!.killed).toBe(true) + + await flush() + expect(n).toBe(2) // child[1] spawned — frpc now reads the rotated cert on disk + expect(handle.isChildAlive()).toBe(true) + await handle.stop() + }) + + it('restartChild() after stop is a no-op (no spawn past shutdown)', async () => { + const first = makeChild() + let n = 0 + const spawn: SpawnFrpc = vi.fn(() => (n++ === 0 ? first.child : makeChild().child)) + const handle = superviseFrpc('/frpc', '/toml', { + spawn, + sleep: async () => {}, + logger: silentLogger, + }) + await Promise.resolve() + const stopping = handle.stop() + first.exit(0) + await stopping + handle.restartChild() // must not resurrect the supervisor + expect(spawn).toHaveBeenCalledTimes(1) + }) }) diff --git a/agent/test/install.test.ts b/agent/test/install.test.ts index bd8331d..8ccbcd3 100644 --- a/agent/test/install.test.ts +++ b/agent/test/install.test.ts @@ -81,8 +81,10 @@ describe('installService — two distinct units (FIX M-host-2service)', () => { expect(d.writes).toHaveLength(2) const baseApp = unitWith(d, baseAppUnitName()) const agent = unitWith(d, agentUnitName()) - // agent unit supervises frpc via ` run`; base-app runs the node server (loopback) - expect(agent).toContain('ExecStart=/usr/local/bin/web-terminal-agent run') + // agent unit supervises frpc via ` run` (absolute node so a minimal service PATH + // that lacks /usr/local/bin can't fail with EX_CONFIG); base-app runs the node server (loopback) + expect(agent).toContain('/usr/local/bin/web-terminal-agent run') + expect(agent).toMatch(/ExecStart=\S*node\S* \/usr\/local\/bin\/web-terminal-agent run/) expect(baseApp).toContain('ExecStart=') expect(baseApp).toContain('server.js') expect(baseApp).not.toContain('web-terminal-agent run') diff --git a/agent/test/nativeRenew.test.ts b/agent/test/nativeRenew.test.ts new file mode 100644 index 0000000..b4d3a4e --- /dev/null +++ b/agent/test/nativeRenew.test.ts @@ -0,0 +1,279 @@ +/** + * A5 native cert auto-renew wiring — TDD. + * + * Covers the host-side glue that was the "one real host code gap": the native run-loop must actually + * RENEW the frp-client leaf (not merely monitor its freshness). Three units: + * - `createMtlsFetch` — the injected `fetchImpl` `renewCert` uses: it POSTs /renew over mTLS + * presenting the CURRENT keystore leaf (read fresh on every call, so a post-rotation renewal + * authenticates with the new leaf) and maps the transport response to a `Response`. + * - `wireAutoRenew` — routes the rotator's rotated→restartChild(+log), revoked→stop(+log), + * error→log(no secret) callbacks and starts it. + * - `startNativeAutoRenew` — the end-to-end builder `superviseNative` calls (identity + mTLS fetch + * + rotator), returning null (disabled) when unenrolled. + */ +import { describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { AgentConfig } from '../src/config/agentConfig.js' +import { generateP256Identity } from '../src/keys/identity.js' +import { openKeystore } from '../src/keys/keystore.js' +import { createLogger } from '../src/log/logger.js' +import type { CertRotator } from '../src/certs/rotation.js' +import { + createMtlsFetch, + startNativeAutoRenew, + wireAutoRenew, + type MtlsRequest, +} from '../src/certs/nativeRenew.js' +import { FakeTimer } from './fixtures/fakes.js' + +const CFG: AgentConfig = { + relayUrl: 'wss://relay/agent', + enrollUrl: 'https://cp.example.com/enroll', + stateDir: '/tmp/x', + localTargetUrl: 'ws://127.0.0.1:3000', + subdomain: 'host-42', + hostId: 'h-1', +} + +function enrolledKs(): { dir: string; ks: ReturnType } { + const dir = mkdtempSync(join(tmpdir(), 'wta-nr-')) + const ks = openKeystore(dir) + ks.saveIdentity(generateP256Identity()) + ks.saveCert('LEAFCERT', 'CACHAIN') + return { dir, ks } +} + +const farFuture = (): { validTo: Date } => ({ validTo: new Date(Date.now() + 86_400_000) }) +const flush = (): Promise => new Promise((r) => setImmediate(r)) + +describe('createMtlsFetch (A5)', () => { + it('presents the current keystore cert/key/CA over mTLS and maps the transport response', async () => { + const { dir, ks } = enrolledKs() + const seen: Array<{ url: string; tls: Record; init: Record }> = [] + const request: MtlsRequest = async (url, tls, init) => { + seen.push({ url, tls: tls as unknown as Record, init: init as unknown as Record }) + return { status: 200, body: JSON.stringify({ cert: 'NEW', caChain: 'NEWCA' }) } + } + const f = createMtlsFetch(ks, { request, certParser: farFuture }) + + const res = await f('https://cp.example.com/renew', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{"csr":"x"}', + }) + + expect(res.status).toBe(200) + expect(res.ok).toBe(true) + expect(await res.json()).toEqual({ cert: 'NEW', caChain: 'NEWCA' }) + expect(seen[0]!.url).toBe('https://cp.example.com/renew') + expect(seen[0]!.tls.cert).toBe('LEAFCERT') + // /renew verifies the LE-fronted control-plane against the SYSTEM roots, so no private CA is pinned + // (pinning the enroll caChain here fails with "unable to get local issuer certificate"). + expect(seen[0]!.tls.ca).toBeUndefined() + expect(seen[0]!.tls.rejectUnauthorized).toBe(true) + expect(String(seen[0]!.tls.key)).toContain('PRIVATE KEY') // in-process PKCS#8 key, mTLS only + expect(seen[0]!.init.method).toBe('POST') + expect(seen[0]!.init.body).toBe('{"csr":"x"}') + rmSync(dir, { recursive: true, force: true }) + }) + + it('reads the current cert on every call so a post-rotation renewal uses the new leaf', async () => { + const { dir, ks } = enrolledKs() + const certs: string[] = [] + const request: MtlsRequest = async (_url, tls) => { + certs.push(tls.cert) + return { status: 200, body: '{}' } + } + const f = createMtlsFetch(ks, { request, certParser: farFuture }) + + await f('https://x/renew', { method: 'POST' }) + ks.saveCert('ROTATEDLEAF', 'CACHAIN') // simulate a completed rotation + await f('https://x/renew', { method: 'POST' }) + + expect(certs).toEqual(['LEAFCERT', 'ROTATEDLEAF']) + rmSync(dir, { recursive: true, force: true }) + }) + + it('propagates a not-enrolled keystore as a throw (renewCert then retries with backoff)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'wta-nr-')) + const ks = openKeystore(dir) + const request: MtlsRequest = async () => ({ status: 200, body: '{}' }) + const f = createMtlsFetch(ks, { request, certParser: farFuture }) + await expect(f('https://x/renew', { method: 'POST' })).rejects.toThrow() + rmSync(dir, { recursive: true, force: true }) + }) +}) + +describe('wireAutoRenew (A5)', () => { + function fakeRotator(): { + rotator: CertRotator + fire: { rotated?: () => void; revoked?: () => void; error?: (e: unknown) => void } + start: ReturnType + stop: ReturnType + } { + const fire: { rotated?: () => void; revoked?: () => void; error?: (e: unknown) => void } = {} + const start = vi.fn() + const stop = vi.fn() + const rotator: CertRotator = { + start, + stop, + onRotated: (cb) => { + fire.rotated = cb + }, + onRevoked: (cb) => { + fire.revoked = cb + }, + onError: (cb) => { + fire.error = cb + }, + } + return { rotator, fire, start, stop } + } + + it('routes rotated→restartChild, revoked→stop, error→log; starts and stops the rotator', () => { + const { rotator, fire, start, stop } = fakeRotator() + const restartChild = vi.fn() + const stopSupervisor = vi.fn() + const lines: string[] = [] + const logger = createLogger('info', (l) => lines.push(l)) + + const controller = wireAutoRenew(rotator, { restartChild, stop: stopSupervisor }, logger, { + subdomain: 'host-42', + hostId: 'h-1', + }) + expect(start).toHaveBeenCalledTimes(1) + + fire.rotated!() + expect(restartChild).toHaveBeenCalledTimes(1) + + fire.revoked!() + expect(stopSupervisor).toHaveBeenCalledTimes(1) + + fire.error!(new Error('network down')) + + controller.stop() + expect(stop).toHaveBeenCalledTimes(1) + + const joined = lines.join('\n') + expect(joined).toContain('host-42') // non-secret identifier is logged + expect(joined).not.toContain('LEAFCERT') // never a leaf/key/CSR + }) +}) + +describe('startNativeAutoRenew (A5 end-to-end)', () => { + it('a scheduled 200 renewal rotates the leaf on disk and restarts frpc with it', async () => { + const { dir, ks } = enrolledKs() + const timer = new FakeTimer() + const request: MtlsRequest = async () => ({ + status: 200, + body: JSON.stringify({ cert: 'FRESHLEAF', caChain: ['CACHAIN'] }), + }) + const restartChild = vi.fn() + const stop = vi.fn() + + const controller = startNativeAutoRenew( + CFG, + ks, + { restartChild, stop }, + createLogger('error', () => {}), + { mtlsRequest: request, certParser: farFuture, timer, renewBeforeMs: 1000, now: () => new Date(0), parseCert: () => new Date(2000) }, + )! + expect(controller).not.toBeNull() + + timer.advance(1000) // renewal fires at ~2/3 TTL + await flush() + + expect(ks.loadCert()!.certPem).toContain('FRESHLEAF') // atomic persist + expect(restartChild).toHaveBeenCalledTimes(1) // frpc restarted onto the fresh leaf + expect(stop).not.toHaveBeenCalled() + controller.stop() + rmSync(dir, { recursive: true, force: true }) + }) + + it('a scheduled 403 renewal tears the tunnel down (revoked) and never rotates', async () => { + const { dir, ks } = enrolledKs() + const timer = new FakeTimer() + const request: MtlsRequest = async () => ({ status: 403, body: '' }) + const restartChild = vi.fn() + const stop = vi.fn() + + const controller = startNativeAutoRenew( + CFG, + ks, + { restartChild, stop }, + createLogger('error', () => {}), + { mtlsRequest: request, certParser: farFuture, timer, renewBeforeMs: 1000, now: () => new Date(0), parseCert: () => new Date(2000) }, + )! + + timer.advance(1000) + await flush() + + expect(stop).toHaveBeenCalledTimes(1) + expect(restartChild).not.toHaveBeenCalled() + expect(ks.loadCert()!.certPem).toBe('LEAFCERT') // untouched + controller.stop() + rmSync(dir, { recursive: true, force: true }) + }) + + it('a failing renewal is logged (no secret) and retried without crashing, then rotates', async () => { + const { dir, ks } = enrolledKs() + const timer = new FakeTimer() + let calls = 0 + const request: MtlsRequest = async () => { + calls += 1 + if (calls === 1) throw new Error('ECONNREFUSED') + return { status: 200, body: JSON.stringify({ cert: 'FRESHLEAF', caChain: ['CACHAIN'] }) } + } + const restartChild = vi.fn() + const stop = vi.fn() + const lines: string[] = [] + + const controller = startNativeAutoRenew( + CFG, + ks, + { restartChild, stop }, + createLogger('warn', (l) => lines.push(l)), + { + mtlsRequest: request, + certParser: farFuture, + timer, + renewBeforeMs: 1000, + retryBaseMs: 500, + now: () => new Date(0), + parseCert: () => new Date(2000), + }, + )! + + timer.advance(1000) // first attempt throws + await flush() + expect(restartChild).not.toHaveBeenCalled() + expect(stop).not.toHaveBeenCalled() // a failure NEVER tears down + expect(lines.join('\n')).toContain('retry') + + timer.advance(500) // backoff retry fires and succeeds + await flush() + expect(ks.loadCert()!.certPem).toContain('FRESHLEAF') + expect(restartChild).toHaveBeenCalledTimes(1) + expect(lines.join('\n')).not.toContain('LEAFCERT') + controller.stop() + rmSync(dir, { recursive: true, force: true }) + }) + + it('returns null (auto-renew disabled) when the keystore has no identity', () => { + const dir = mkdtempSync(join(tmpdir(), 'wta-nr-')) + const ks = openKeystore(dir) + const lines: string[] = [] + const controller = startNativeAutoRenew( + CFG, + ks, + { restartChild: () => {}, stop: () => {} }, + createLogger('warn', (l) => lines.push(l)), + {}, + ) + expect(controller).toBeNull() + expect(lines.join('\n')).toContain('auto-renew disabled') + rmSync(dir, { recursive: true, force: true }) + }) +}) diff --git a/agent/test/nativeRenewTransport.test.ts b/agent/test/nativeRenewTransport.test.ts new file mode 100644 index 0000000..893462e --- /dev/null +++ b/agent/test/nativeRenewTransport.test.ts @@ -0,0 +1,169 @@ +/** + * A5 default mTLS transport (`defaultMtlsRequest`) — the ONE seam the other nativeRenew tests inject + * past, so the real `node:https` transport had zero coverage. These tests mock `node:https` and drive + * the actual transport to prove: + * - the exact request options (rejectUnauthorized:true + client cert/key + pinned CA + method/body), + * - the HIGH fix: a request timeout is armed and a stalled peer REJECTS (never hangs forever), + * - the MEDIUM fix: an oversized response body is capped (destroyed + rejected, not buffered). + */ +import { EventEmitter } from 'node:events' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { requestMock } = vi.hoisted(() => ({ requestMock: vi.fn() })) +vi.mock('node:https', () => ({ request: requestMock, default: { request: requestMock } })) + +import { generateP256Identity } from '../src/keys/identity.js' +import { openKeystore } from '../src/keys/keystore.js' +import { + createMtlsFetch, + RENEW_REQUEST_TIMEOUT_MS, + MAX_RENEW_RESPONSE_BYTES, +} from '../src/certs/nativeRenew.js' + +const farFuture = (): { validTo: Date } => ({ validTo: new Date(Date.now() + 86_400_000) }) + +/** Fake `http.ClientRequest`: records setTimeout/write/end and emits 'error' on destroy(err). */ +class FakeClientRequest extends EventEmitter { + readonly setTimeoutCalls: Array<{ ms: number; cb: () => void }> = [] + readonly written: string[] = [] + ended = false + destroyedWith: Error | undefined + setTimeout(ms: number, cb: () => void): this { + this.setTimeoutCalls.push({ ms, cb }) + return this + } + write(chunk: string): boolean { + this.written.push(chunk) + return true + } + end(): this { + this.ended = true + return this + } + destroy(err?: Error): this { + this.destroyedWith = err + if (err) this.emit('error', err) + return this + } +} + +/** Fake `http.IncomingMessage`: an EventEmitter with a statusCode and a real destroy(). */ +class FakeIncomingMessage extends EventEmitter { + statusCode = 200 + destroyed = false + destroy(): this { + this.destroyed = true + return this + } +} + +type ReqCb = (res: FakeIncomingMessage) => void +let dirs: string[] = [] + +function enrolledKs(): ReturnType { + const dir = mkdtempSync(join(tmpdir(), 'wta-nrt-')) + dirs.push(dir) + const ks = openKeystore(dir) + ks.saveIdentity(generateP256Identity()) + ks.saveCert('LEAFCERT', 'CACHAIN') + return ks +} + +beforeEach(() => { + requestMock.mockReset() +}) +afterEach(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }) + dirs = [] +}) + +describe('defaultMtlsRequest transport (A5 — real node:https)', () => { + it('passes rejectUnauthorized:true + the client cert/key/CA + method/body, and arms the timeout', async () => { + const ks = enrolledKs() + let seenUrl = '' + let seenOpts: Record = {} + let seenReq: FakeClientRequest | undefined + requestMock.mockImplementation((url: string, opts: Record, cb: ReqCb) => { + seenUrl = url + seenOpts = opts + const req = new FakeClientRequest() + seenReq = req + queueMicrotask(() => { + const res = new FakeIncomingMessage() + res.statusCode = 200 + cb(res) + res.emit('data', Buffer.from('{"cert":"NEW",')) + res.emit('data', Buffer.from('"caChain":"NEWCA"}')) + res.emit('end') + }) + return req + }) + + const f = createMtlsFetch(ks, { certParser: farFuture }) // NO request seam → real defaultMtlsRequest + const res = await f('https://cp.example.com/renew', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{"csr":"x"}', + }) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ cert: 'NEW', caChain: 'NEWCA' }) + expect(seenUrl).toBe('https://cp.example.com/renew') + expect(seenOpts.rejectUnauthorized).toBe(true) // anti-MITM (INV14) + expect(seenOpts.method).toBe('POST') + expect(seenOpts.cert).toBe('LEAFCERT') + // ca omitted ⇒ verify the LE-fronted control-plane against the system roots (not the private CA). + expect(seenOpts.ca).toBeUndefined() + expect(String(seenOpts.key)).toContain('PRIVATE KEY') // in-process PKCS#8 key + // HIGH fix: a socket timeout is armed with the sane default so a stall can never hang forever. + expect(seenReq!.setTimeoutCalls).toHaveLength(1) + expect(seenReq!.setTimeoutCalls[0]!.ms).toBe(RENEW_REQUEST_TIMEOUT_MS) + expect(seenReq!.written).toEqual(['{"csr":"x"}']) + expect(seenReq!.ended).toBe(true) + }) + + it('HIGH: a stalled peer (accepts TLS, never responds) REJECTS via the timeout, not hangs', async () => { + const ks = enrolledKs() + let seenReq: FakeClientRequest | undefined + requestMock.mockImplementation(() => { + const req = new FakeClientRequest() + seenReq = req + return req // never invokes the response callback — a stalled control-plane + }) + + const f = createMtlsFetch(ks, { certParser: farFuture }) + const p = f('https://cp.example.com/renew', { method: 'POST', body: '{}' }) + + // Fire the armed socket-timeout callback (what node does when the socket idles past the limit). + expect(seenReq!.setTimeoutCalls).toHaveLength(1) + expect(seenReq!.setTimeoutCalls[0]!.ms).toBe(RENEW_REQUEST_TIMEOUT_MS) + seenReq!.setTimeoutCalls[0]!.cb() + + await expect(p).rejects.toThrow(/timed out/i) + expect(seenReq!.destroyedWith).toBeInstanceOf(Error) // socket was torn down + }) + + it('MEDIUM: an oversized response body is capped — res destroyed + promise rejected', async () => { + const ks = enrolledKs() + let seenRes: FakeIncomingMessage | undefined + requestMock.mockImplementation((_url: string, _opts: unknown, cb: ReqCb) => { + const req = new FakeClientRequest() + queueMicrotask(() => { + const res = new FakeIncomingMessage() + res.statusCode = 200 + seenRes = res + cb(res) + res.emit('data', Buffer.alloc(MAX_RENEW_RESPONSE_BYTES + 1)) // one byte over the cap + // deliberately NO 'end' — a capped stream must reject on its own, not wait for end + }) + return req + }) + + const f = createMtlsFetch(ks, { certParser: farFuture }) + await expect(f('https://cp.example.com/renew', { method: 'POST' })).rejects.toThrow(/cap|exceed/i) + expect(seenRes!.destroyed).toBe(true) + }) +}) diff --git a/agent/test/rotation.test.ts b/agent/test/rotation.test.ts index b5c3359..2567f30 100644 --- a/agent/test/rotation.test.ts +++ b/agent/test/rotation.test.ts @@ -11,6 +11,7 @@ import { renewCert, renewalUrlFor, } from '../src/certs/rotation.js' +import { createBackoff } from '../src/transport/backoff.js' import { FakeTimer } from './fixtures/fakes.js' const CFG: AgentConfig = { @@ -52,10 +53,10 @@ describe('renewCert (T13)', () => { it('installs a fresh cert atomically on success (same key)', async () => { const { dir, ks } = enrolledKs() const before = ks.loadIdentity()!.publicKey - const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })) + const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })) const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch) expect(out).toBe('rotated') - expect(ks.loadCert()).toEqual({ certPem: 'NEWCERT', caChainPem: 'NEWCA' }) + expect(ks.loadCert()!.certPem).toContain('NEWCERT'); expect(ks.loadCert()!.caChainPem).toContain('NEWCA') // pubkey unchanged — only the cert rotated expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true) rmSync(dir, { recursive: true, force: true }) @@ -101,7 +102,7 @@ describe('createCertRotator (T13)', () => { const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, { timer, renewBeforeMs: 1000, - fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })) as unknown as typeof fetch, + fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })) as unknown as typeof fetch, now: () => new Date(0), parseCert: () => new Date(2000), }) @@ -113,7 +114,47 @@ describe('createCertRotator (T13)', () => { timer.advance(1000) await flush() expect(rotated).toBe(1) - expect(ks.loadCert()!.certPem).toBe('NEWCERT') + expect(ks.loadCert()!.certPem).toContain('NEWCERT') + rotator.stop() + rmSync(dir, { recursive: true, force: true }) + }) + + it('invokes onError and retries with backoff (not renewBeforeMs) when a renewal throws', async () => { + const { dir, ks } = enrolledKs() + const timer = new FakeTimer() + let calls = 0 + const fetchImpl = (async () => { + calls += 1 + if (calls === 1) throw new Error('network down') + return jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] }) + }) as unknown as typeof fetch + const errors: unknown[] = [] + let rotated = 0 + const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, { + timer, + renewBeforeMs: 1000, + retryBackoff: createBackoff({ baseMs: 500, jitter: false }), + fetchImpl, + now: () => new Date(0), + parseCert: () => new Date(2000), // initial renewal scheduled at ~1000ms + }) + rotator.onError((e) => errors.push(e)) + rotator.onRotated(() => { + rotated += 1 + }) + rotator.start() + + timer.advance(1000) // first attempt fires → throws + await flush() + expect(errors).toHaveLength(1) + expect(rotated).toBe(0) + + // The retry is armed at the 500ms backoff delay, NOT renewBeforeMs (1000): advancing only 500 + // must fire it. A crash-loop never escapes here (the supervisor keeps running). + timer.advance(500) + await flush() + expect(rotated).toBe(1) + expect(ks.loadCert()!.certPem).toContain('NEWCERT') rotator.stop() rmSync(dir, { recursive: true, force: true }) }) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/CertificateSigningRequest.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/CertificateSigningRequest.kt new file mode 100644 index 0000000..d9ac0c5 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/CertificateSigningRequest.kt @@ -0,0 +1,172 @@ +package wang.yaojia.webterm.api.enroll + +/** + * B4 · Manual, canonical-DER encoder for a P-256 PKCS#10 `CertificationRequest` — a byte-for-byte + * port of the iOS `ClientTLS.CertificateSigningRequest`. + * + * Built by hand (no JCA CSR helper) so the exact bytes are under our control and the request is + * signed by the [CsrSigner] (an AndroidKeyStore hardware key in production, a software P-256 key in + * tests) via `SHA256withECDSA`. The output must satisfy the control-plane `verifyCsrPoPEc`: an EC + * P-256 `SubjectPublicKeyInfo` (`id-ecPublicKey` + `prime256v1`), an `ecdsa-with-SHA256` + * self-signature, and a valid PoP. Encoding is strictly canonical DER (minimal lengths) so the + * server's re-serialization of `CertificationRequestInfo` matches the bytes we signed. + * + * ``` + * CertificationRequest ::= SEQUENCE { + * certificationRequestInfo CertificationRequestInfo, + * signatureAlgorithm AlgorithmIdentifier, -- ecdsa-with-SHA256 + * signature BIT STRING } -- X9.62 DER ECDSA-Sig + * + * CertificationRequestInfo ::= SEQUENCE { + * version INTEGER { v1(0) }, + * subject Name, + * subjectPKInfo SubjectPublicKeyInfo, + * attributes [0] IMPLICIT SET OF Attribute } -- empty + * ``` + */ +public object CertificateSigningRequest { + /** P-256 uncompressed public point is `0x04 || X(32) || Y(32)` = 65 bytes. */ + private const val UNCOMPRESSED_P256_POINT_LENGTH = 65 + + /** + * Build and self-sign a P-256 PKCS#10 CSR DER for [signer]'s key. + * + * @param subjectCommonName the CSR subject CN. The device leaf's identity is driven server-side + * by the ownership-verified subdomain SAN, so this is descriptive only; it must be non-empty. + * @param signer the P-256 hardware key that provides the public key and signs the + * `CertificationRequestInfo`. + * @throws CsrException.InvalidSubject on an empty CN; [CsrException.InvalidPublicKey] if the + * signer's public key is not a 65-byte X9.63 P-256 point. + */ + public fun der(subjectCommonName: String, signer: CsrSigner): ByteArray { + if (subjectCommonName.isEmpty()) throw CsrException.InvalidSubject + + val publicPoint = signer.publicKeyX963() + if (publicPoint.size != UNCOMPRESSED_P256_POINT_LENGTH || publicPoint[0].toInt() != 0x04) { + throw CsrException.InvalidPublicKey + } + + val requestInfo = certificationRequestInfo(subjectCommonName, publicPoint) + val signature = signer.sign(requestInfo) + + return DerWriter.sequence( + listOf( + requestInfo, + ECDSA_WITH_SHA256_ALGORITHM_IDENTIFIER, + DerWriter.bitString(signature), + ), + ) + } + + // ── CertificationRequestInfo ──────────────────────────────────────────────────────────── + + private fun certificationRequestInfo(subjectCommonName: String, publicPoint: ByteArray): ByteArray = + DerWriter.sequence( + listOf( + DerWriter.INTEGER_0, // version v1(0) + name(subjectCommonName), + subjectPublicKeyInfo(publicPoint), + DerWriter.EMPTY_ATTRIBUTES_CONTEXT0, // [0] IMPLICIT SET OF Attribute (empty) + ), + ) + + /** `Name ::= SEQUENCE OF RelativeDistinguishedName` with a single CN RDN. */ + private fun name(commonName: String): ByteArray { + val attribute = DerWriter.sequence( + listOf(DerWriter.oid(Oid.COMMON_NAME), DerWriter.utf8String(commonName)), + ) + val rdn = DerWriter.set(listOf(attribute)) + return DerWriter.sequence(listOf(rdn)) + } + + /** + * `SubjectPublicKeyInfo` for an EC P-256 key: `id-ecPublicKey` + `prime256v1` named curve, then + * the uncompressed point as a BIT STRING. + */ + private fun subjectPublicKeyInfo(publicPoint: ByteArray): ByteArray { + val algorithm = DerWriter.sequence( + listOf(DerWriter.oid(Oid.EC_PUBLIC_KEY), DerWriter.oid(Oid.PRIME256V1)), + ) + return DerWriter.sequence(listOf(algorithm, DerWriter.bitString(publicPoint))) + } + + /** + * `AlgorithmIdentifier` for `ecdsa-with-SHA256` — no parameters (absent, per RFC 5758), which is + * exactly what the server's verifier expects. + */ + private val ECDSA_WITH_SHA256_ALGORITHM_IDENTIFIER: ByteArray = + DerWriter.sequence(listOf(DerWriter.oid(Oid.ECDSA_WITH_SHA256))) +} + +/** Object identifiers (DER content bytes; tag/length added by [DerWriter.oid]). */ +private object Oid { + /** 1.2.840.10045.2.1 — id-ecPublicKey. */ + val EC_PUBLIC_KEY = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01) + + /** 1.2.840.10045.3.1.7 — prime256v1 / secp256r1. */ + val PRIME256V1 = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x03, 0x01, 0x07) + + /** 1.2.840.10045.4.3.2 — ecdsa-with-SHA256. */ + val ECDSA_WITH_SHA256 = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x04, 0x03, 0x02) + + /** 2.5.4.3 — id-at-commonName. */ + val COMMON_NAME = byteArrayOf(0x55, 0x04, 0x03) +} + +/** + * A tiny canonical-DER encoder. Every helper returns a fully-formed TLV so callers just concatenate + * children — canonical minimal-length encoding throughout. Internal so its byte layout is + * unit-testable in isolation. + */ +internal object DerWriter { + private const val TAG_INTEGER: Byte = 0x02 + private const val TAG_BIT_STRING: Byte = 0x03 + private const val TAG_OID: Byte = 0x06 + private const val TAG_UTF8_STRING: Byte = 0x0C + private const val TAG_SEQUENCE: Byte = 0x30 + private const val TAG_SET: Byte = 0x31 + private const val TAG_CONTEXT0_CONSTRUCTED: Byte = 0xA0.toByte() + + /** `INTEGER 0` — the fixed PKCS#10 version v1(0). */ + val INTEGER_0: ByteArray = byteArrayOf(TAG_INTEGER, 0x01, 0x00) + + /** `[0] IMPLICIT SET OF Attribute`, empty — `A0 00`. */ + val EMPTY_ATTRIBUTES_CONTEXT0: ByteArray = byteArrayOf(TAG_CONTEXT0_CONSTRUCTED, 0x00) + + fun sequence(children: List): ByteArray = tlv(TAG_SEQUENCE, concat(children)) + + fun set(children: List): ByteArray = tlv(TAG_SET, concat(children)) + + fun oid(content: ByteArray): ByteArray = tlv(TAG_OID, content) + + fun utf8String(value: String): ByteArray = tlv(TAG_UTF8_STRING, value.encodeToByteArray()) + + /** BIT STRING with zero unused bits (all our bit strings are byte-aligned). */ + fun bitString(content: ByteArray): ByteArray = tlv(TAG_BIT_STRING, byteArrayOf(0x00) + content) + + /** Tag-Length-Value with canonical DER length encoding. */ + private fun tlv(tag: Byte, value: ByteArray): ByteArray = byteArrayOf(tag) + length(value.size) + value + + /** DER length: short form (<128) or long form (`0x80 | byteCount`, big-endian). */ + private fun length(count: Int): ByteArray { + if (count < 0x80) return byteArrayOf(count.toByte()) + var value = count + val bytes = ArrayDeque() + while (value > 0) { + bytes.addFirst((value and 0xFF).toByte()) + value = value ushr 8 + } + return byteArrayOf((0x80 or bytes.size).toByte()) + bytes.toByteArray() + } + + private fun concat(chunks: List): ByteArray { + val total = chunks.sumOf { it.size } + val out = ByteArray(total) + var offset = 0 + for (chunk in chunks) { + System.arraycopy(chunk, 0, out, offset, chunk.size) + offset += chunk.size + } + return out + } +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/CsrSigner.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/CsrSigner.kt new file mode 100644 index 0000000..d266a70 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/CsrSigner.kt @@ -0,0 +1,37 @@ +package wang.yaojia.webterm.api.enroll + +/** + * B4 · The signing key abstraction the PKCS#10 CSR encoder ([CertificateSigningRequest]) drives — + * the Android analogue of iOS `P256HardwareKey`. + * + * In production this is backed by a NON-EXPORTABLE P-256 key living inside AndroidKeyStore + * (StrongBox → TEE), so `sign` runs inside secure hardware and the private key never leaves it + * (`:client-tls-android` `HardwareBackedKey`). In JVM unit tests it is backed by a software P-256 + * key via the SAME `Signature("SHA256withECDSA")` path, so the CSR-encoding bytes are exercised + * identically without an emulator. + */ +public interface CsrSigner { + /** + * The public key in ANSI X9.63 uncompressed form: `0x04 || X(32) || Y(32)` (65 bytes for + * P-256). This is exactly what wraps into the CSR's `SubjectPublicKeyInfo` BIT STRING. + */ + public fun publicKeyX963(): ByteArray + + /** + * ECDSA-sign `message` over SHA-256, returning the X9.62 DER signature + * (`SEQUENCE { r INTEGER, s INTEGER }`) — exactly the shape a PKCS#10 `signature` BIT STRING + * and the server's `verifyCsrPoPEc` expect. The digest is computed by the algorithm + * (`SHA256withECDSA`), so callers pass the raw message (the DER of `CertificationRequestInfo`), + * NOT a pre-hash. + */ + public fun sign(message: ByteArray): ByteArray +} + +/** Structural failures building a CSR — the client refuses to emit a malformed request. */ +public sealed class CsrException(message: String) : Exception(message) { + /** The signer's public key was not the expected 65-byte X9.63 uncompressed P-256 point. */ + public data object InvalidPublicKey : CsrException("CSR public key is not a 65-byte X9.63 P-256 point") + + /** The subject CN was empty (not encodable / rejected by the server). */ + public data object InvalidSubject : CsrException("CSR subject common name must not be empty") +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt new file mode 100644 index 0000000..00ea5eb --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt @@ -0,0 +1,184 @@ +package wang.yaojia.webterm.api.enroll + +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest +import wang.yaojia.webterm.wire.HttpResponse +import wang.yaojia.webterm.wire.HttpTransport + +/** + * B4 · Talks to the control-plane device-enrollment API over the shared [HttpTransport] seam (the + * same seam `:transport-okhttp` implements and `:test-support` fakes), so the enroll flow rides the + * app's normal HTTP stack. Android analogue of iOS `DeviceEnrollmentClient`, extended with the login + * step (B4 pinned contract): + * + * `POST /auth/login` `{ password }` → 201 `{ enrollToken, accountId, expiresIn }` + * `POST /device/enroll` [Bearer enrollToken] `{ csr, keyAlg:'ec-p256', subdomain, + * deviceName, attestation? }` → 201 `{ deviceId, cert, caChain, + * notBefore, notAfter, renewAfter }` + * `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 (same shape). The + * server schema is `.strict()`; NO keyAlg/subdomain/deviceName. + * + * Deliberately logic-free about TLS/keys: it only builds requests and maps responses. The `csr` is + * sent as standard base64(DER), which the server's `decodeCsrWire` accepts directly; response DERs + * are standard-base64 (`bytesToBase64` = Node `Buffer.toString('base64')`). + * + * Immutable: constructed once with a [baseUrl] + [http]; the short-lived enroll bearer is passed + * per-call and never held/logged (leaked-bearer blast radius). + */ +public class DeviceEnrollmentClient( + baseUrl: String, + private val http: HttpTransport, +) { + /** Base control-plane URL with any trailing slash removed, so `base + path` is well-formed. */ + private val base: String = baseUrl.trim().trimEnd('/') + + /** + * One-time operator login → a short-lived `device:enroll` bearer. An empty [password] is + * rejected client-side (`InvalidRequest`) before any network I/O — never send a blank credential. + */ + public suspend fun login(password: String): LoginResult { + if (password.isEmpty()) throw DeviceEnrollmentError.InvalidRequest + val body = EnrollJson.encodeToString(LoginRequestBody.serializer(), LoginRequestBody(password)) + val response = http.send(jsonRequest(HttpMethod.POST, PATH_LOGIN, body.encodeToByteArray(), bearer = null)) + val dto = decodeOn201(response, LoginResponseDto.serializer()) + return LoginResult( + enrollToken = dto.enrollToken, + accountId = dto.accountId, + expiresInSeconds = dto.expiresIn, + ) + } + + /** + * Enroll a freshly-generated hardware key: POST the [csrDer] under the enroll [bearerToken], + * receive the leaf. Required fields are validated client-side (`InvalidRequest`) before any I/O. + */ + public suspend fun enroll( + bearerToken: String, + csrDer: ByteArray, + subdomain: String, + deviceName: String, + attestation: String? = null, + ): EnrollmentResult { + if (bearerToken.isEmpty() || csrDer.isEmpty() || subdomain.isEmpty() || deviceName.isEmpty()) { + throw DeviceEnrollmentError.InvalidRequest + } + val body = EnrollJson.encodeToString( + EnrollRequestBody.serializer(), + EnrollRequestBody( + csr = base64(csrDer), + keyAlg = KEY_ALG_EC_P256, + subdomain = subdomain, + deviceName = deviceName, + attestation = attestation, + ), + ) + val response = http.send(jsonRequest(HttpMethod.POST, PATH_ENROLL, body.encodeToByteArray(), bearerToken)) + return toResult(decodeOn201(response, EnrollResponseDto.serializer())) + } + + /** + * Renew against the SAME hardware key: a fresh CSR to `/device/:id/renew` (silent-rotation seam). + * + * The renew endpoint is authenticated by the CURRENT device certificate over mTLS — the body is + * `{ csr }` ONLY and NO bearer is sent (mirrors iOS, which passes `bearerToken: nil`). [bearerToken] + * is therefore OPTIONAL and defaults to absent; the `Authorization` header is omitted when it is + * null/blank. The seam still accepts a bearer for a hypothetical bearer-authenticated renew, but the + * production caller passes none. [deviceId] and [csrDer] are validated client-side before any I/O. + */ + public suspend fun renew(deviceId: String, csrDer: ByteArray, bearerToken: String? = null): EnrollmentResult { + if (deviceId.isEmpty() || csrDer.isEmpty()) { + throw DeviceEnrollmentError.InvalidRequest + } + // Body is `{ csr }` ONLY — the renew endpoint authenticates by the presented mTLS device cert + // and its schema is `.strict()`, so any enroll-only extra (keyAlg/subdomain/deviceName) is + // rejected. Identity/key come from the current cert + registry record, never the body. + val body = EnrollJson.encodeToString( + RenewRequestBody.serializer(), + RenewRequestBody(csr = base64(csrDer)), + ) + val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/renew" + val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearerToken)) + return toResult(decodeOn201(response, EnrollResponseDto.serializer())) + } + + // ── Request/response plumbing ──────────────────────────────────────────────────────────── + + private fun jsonRequest( + method: HttpMethod, + path: String, + jsonBody: ByteArray, + bearer: String?, + ): HttpRequest { + val headers = LinkedHashMap() + headers[HEADER_CONTENT_TYPE] = CONTENT_TYPE_JSON + // Omit Authorization entirely when there is no bearer (the mTLS-only renew path) — an empty + // string must never emit a bare "Bearer " header. + if (!bearer.isNullOrEmpty()) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer" + return HttpRequest(method = method, url = base + path, headers = headers, body = jsonBody) + } + + /** 201 → decode with [serializer]; else → [DeviceEnrollmentError.Http] with the server's `error` + * code (never the raw body); an undecodable 201 body → [DeviceEnrollmentError.MalformedResponse]. */ + private fun decodeOn201(response: HttpResponse, serializer: kotlinx.serialization.KSerializer): T { + if (response.status != HTTP_CREATED) { + throw DeviceEnrollmentError.Http(response.status, errorCode(response.body)) + } + return runCatching { EnrollJson.decodeFromString(serializer, response.body.decodeToString()) } + .getOrNull() ?: throw DeviceEnrollmentError.MalformedResponse + } + + private fun toResult(dto: EnrollResponseDto): EnrollmentResult { + val certificate = decodeBase64OrNull(dto.cert) ?: throw DeviceEnrollmentError.MalformedResponse + val chain = dto.caChain.map { entry -> + decodeBase64OrNull(entry) ?: throw DeviceEnrollmentError.MalformedResponse + } + return EnrollmentResult( + deviceId = dto.deviceId, + certificate = certificate, + caChain = chain, + notBefore = parseInstantOrNull(dto.notBefore), + notAfter = parseInstantOrNull(dto.notAfter), + renewAfter = parseInstantOrNull(dto.renewAfter), + ) + } + + private fun errorCode(body: ByteArray): String? = + runCatching { EnrollJson.decodeFromString(ErrorDto.serializer(), body.decodeToString()).error }.getOrNull() + + private companion object { + const val PATH_LOGIN = "/auth/login" + const val PATH_ENROLL = "/device/enroll" + const val PATH_DEVICE = "/device" + const val KEY_ALG_EC_P256 = "ec-p256" + const val HTTP_CREATED = 201 + + const val HEADER_CONTENT_TYPE = "Content-Type" + const val HEADER_AUTHORIZATION = "Authorization" + const val CONTENT_TYPE_JSON = "application/json" + const val BEARER_PREFIX = "Bearer " + + private val BASE64_ENCODER = java.util.Base64.getEncoder() + private val BASE64_DECODER = java.util.Base64.getDecoder() + + fun base64(bytes: ByteArray): String = BASE64_ENCODER.encodeToString(bytes) + + fun decodeBase64OrNull(text: String): ByteArray? = + runCatching { BASE64_DECODER.decode(text) }.getOrNull() + + /** Percent-encode a `:id` path segment's non-unreserved bytes (defence: device ids are + * server-minted UUIDs, but never build a URL from an unescaped field). */ + fun encodePathSegment(value: String): String { + val sb = StringBuilder() + for (byte in value.encodeToByteArray()) { + val code = byte.toInt() and 0xFF + val ch = code.toChar() + if (ch in UNRESERVED) sb.append(ch) else sb.append('%').append(HEX[code ushr 4]).append(HEX[code and 0x0F]) + } + return sb.toString() + } + + private val UNRESERVED: Set = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~".toSet() + private val HEX = "0123456789ABCDEF".toCharArray() + } +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EcPointEncoding.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EcPointEncoding.kt new file mode 100644 index 0000000..8859989 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EcPointEncoding.kt @@ -0,0 +1,54 @@ +package wang.yaojia.webterm.api.enroll + +import java.math.BigInteger +import java.security.interfaces.ECPublicKey + +/** + * B4 · Pure encoder from a JCA [ECPublicKey] to the ANSI X9.63 uncompressed point + * `0x04 || X || Y` that a P-256 `SubjectPublicKeyInfo` BIT STRING carries. + * + * Kept in the pure `:api-client` module (no Android dependency) so it is reused by BOTH the + * JVM-unit-test software signer AND the framework `HardwareBackedKey` (`:client-tls-android`), + * and so this security-load-bearing byte layout is unit-tested at JVM speed. + */ +public object EcPointEncoding { + /** P-256 field element width in bytes (256 bits). */ + public const val P256_COORDINATE_BYTES: Int = 32 + + /** Uncompressed-point prefix (`0x04`) per SEC 1 §2.3.3. */ + private const val UNCOMPRESSED_PREFIX: Byte = 0x04 + + /** + * Encode [publicKey]'s affine (x, y) as `0x04 || X(32) || Y(32)` (65 bytes). Each coordinate is + * an unsigned big-endian integer left-padded (or, defensively, high-byte-trimmed) to exactly + * [P256_COORDINATE_BYTES]. Throws [IllegalArgumentException] if a coordinate genuinely does not + * fit 32 bytes (i.e. the key is not on a 256-bit curve). + */ + public fun x963(publicKey: ECPublicKey): ByteArray { + val point = publicKey.w + val x = toFixedLengthUnsigned(point.affineX, P256_COORDINATE_BYTES) + val y = toFixedLengthUnsigned(point.affineY, P256_COORDINATE_BYTES) + val out = ByteArray(1 + P256_COORDINATE_BYTES * 2) + out[0] = UNCOMPRESSED_PREFIX + System.arraycopy(x, 0, out, 1, P256_COORDINATE_BYTES) + System.arraycopy(y, 0, out, 1 + P256_COORDINATE_BYTES, P256_COORDINATE_BYTES) + return out + } + + /** + * Convert a non-negative [value] to a big-endian byte array of exactly [length] bytes. A + * `BigInteger` may carry a leading 0x00 sign byte (drop it) or be shorter than [length] + * (left-pad with zeros). A value that needs MORE than [length] significant bytes is rejected — + * silently truncating a coordinate would corrupt the key. + */ + internal fun toFixedLengthUnsigned(value: BigInteger, length: Int): ByteArray { + require(value.signum() >= 0) { "EC coordinate must be non-negative" } + val raw = value.toByteArray() // big-endian, possibly with a leading 0x00 sign byte + val start = if (raw.size > length && raw[0].toInt() == 0) 1 else 0 + val significant = raw.size - start + require(significant <= length) { "EC coordinate does not fit $length bytes (got $significant)" } + val out = ByteArray(length) + System.arraycopy(raw, start, out, length - significant, significant) + return out + } +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EnrollmentModels.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EnrollmentModels.kt new file mode 100644 index 0000000..27148a8 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EnrollmentModels.kt @@ -0,0 +1,124 @@ +package wang.yaojia.webterm.api.enroll + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.time.Instant + +/** + * B4 · Typed result of the one-time operator login (`POST /auth/login`). The [enrollToken] is a + * short-lived `device:enroll` bearer — hold it ONLY for the immediately-following enroll call and + * NEVER persist or log it. [accountId] identifies the tenant the device will be scoped under. + */ +public data class LoginResult( + val enrollToken: String, + val accountId: String, + val expiresInSeconds: Long, +) + +/** + * B4 · Typed result of a successful `POST /device/enroll` (or `/device/:id/renew`): the issued leaf + * plus its issuer chain and rotation timing. The private key is NOT here — it stays non-exportable + * in AndroidKeyStore. Mirrors iOS `EnrollmentResult`. + * + * NOTE: [certificate]/[caChain] are `ByteArray`, so the generated `data class` equality is by + * reference (transient DER carriers, not value-equality keys) — compare with `contentEquals`. + */ +public data class EnrollmentResult( + val deviceId: String, + /** Leaf certificate DER (decoded from the response's base64). */ + val certificate: ByteArray, + /** Issuer chain DERs (device-CA etc.), leaf excluded. */ + val caChain: List, + val notBefore: Instant?, + val notAfter: Instant?, + /** When to renew from the same hardware key (~2/3 of the lifetime). */ + val renewAfter: Instant?, +) { + /** + * The rotation seam: is the leaf due for renewal as of [now]? A missing [renewAfter] never + * triggers (fail-safe — the TLS stack is the real gate; the scheduler only pre-empts expiry). + */ + public fun isRenewalDue(now: Instant = Instant.now()): Boolean { + val due = renewAfter ?: return false + return !now.isBefore(due) // now >= renewAfter + } +} + +/** Typed failures for the device-enrollment surface. Transport-level errors propagate UNWRAPPED. */ +public sealed class DeviceEnrollmentError(message: String) : Exception(message) { + /** + * A non-success HTTP status with the server's uniform `{ error }` code, if any (401 + * missing/rejected token, 403 subdomain-not-owned, 429 rate_limited, 400 rejected + * CSR/subdomain). Never leaks the response body. + */ + public data class Http(val status: Int, val code: String?) : + DeviceEnrollmentError("device enrollment rejected: HTTP $status" + (code?.let { " ($it)" } ?: "")) + + /** A success body that did not decode to the expected shape (or an undecodable base64 cert). */ + public data object MalformedResponse : + DeviceEnrollmentError("device enrollment response was not the expected shape") + + /** A required request field was empty — rejected client-side BEFORE any network I/O. */ + public data object InvalidRequest : + DeviceEnrollmentError("device enrollment request was missing a required field") +} + +// ── Wire DTOs + JSON config (internal to the enroll package) ───────────────────────────────────── + +/** + * ENCODE omits absent optionals (`encodeDefaults = false` drops the default-null `attestation`; + * `explicitNulls = false` never writes an explicit `null`) and DECODE is tolerant of unknown keys + * (the server is untrusted at this boundary). `keyAlg` carries NO default, so it is ALWAYS encoded. + */ +internal val EnrollJson: Json = Json { + encodeDefaults = false + explicitNulls = false + ignoreUnknownKeys = true + isLenient = true +} + +@Serializable +internal data class LoginRequestBody(val password: String) + +@Serializable +internal data class EnrollRequestBody( + val csr: String, + val keyAlg: String, + val subdomain: String, + val deviceName: String, + val attestation: String? = null, +) + +/** + * The `/device/:id/renew` request body. The endpoint authenticates by the presented mTLS device cert + * and its server schema is `{ csr }` ONLY (`.strict()`), so it carries the single new CSR and NO + * enroll-only fields (keyAlg/subdomain/deviceName) — an extra key would be rejected as a 400. + */ +@Serializable +internal data class RenewRequestBody(val csr: String) + +@Serializable +internal data class LoginResponseDto( + val enrollToken: String, + val accountId: String, + val expiresIn: Long, +) + +@Serializable +internal data class EnrollResponseDto( + val deviceId: String, + val cert: String, + val caChain: List = emptyList(), + val notBefore: String? = null, + val notAfter: String? = null, + val renewAfter: String? = null, +) + +@Serializable +internal data class ErrorDto(val error: String? = null) + +/** Parse an ISO-8601 instant, degrading an absent/unparseable value to null (dates are advisory). */ +internal fun parseInstantOrNull(text: String?): Instant? { + if (text == null) return null + return runCatching { Instant.parse(text) }.getOrNull() +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/CertificateSigningRequestTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/CertificateSigningRequestTest.kt new file mode 100644 index 0000000..70df2fc --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/CertificateSigningRequestTest.kt @@ -0,0 +1,211 @@ +package wang.yaojia.webterm.api.enroll + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.security.KeyPairGenerator +import java.security.Signature +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec + +/** + * B4 · Proves the manual PKCS#10 encoder produces a well-formed, self-signed P-256 CSR that the + * control-plane `verifyCsrPoPEc` (id-ecPublicKey + prime256v1 SPKI, ecdsa-with-SHA256 + * self-signature) accepts. Runs headless with a SOFTWARE P-256 key via the SAME + * `Signature("SHA256withECDSA")` path the on-device AndroidKeyStore key uses — so the signing path + * is byte-identical. Real StrongBox keygen is device-only (`:client-tls-android`). + */ +class CertificateSigningRequestTest { + /** Software P-256 signer via the SAME JCA `SHA256withECDSA` path used on-device (no StrongBox). */ + private class SoftwareEcSigner : CsrSigner { + val keyPair = KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1")) + }.generateKeyPair() + + override fun publicKeyX963(): ByteArray = EcPointEncoding.x963(keyPair.public as ECPublicKey) + + override fun sign(message: ByteArray): ByteArray = + Signature.getInstance("SHA256withECDSA").apply { + initSign(keyPair.private) + update(message) + }.sign() + } + + @Test + fun csrIsCanonicalPkcs10SequenceOfExactlyThreeElements() { + val signer = SoftwareEcSigner() + + val der = CertificateSigningRequest.der("web-terminal-device", signer) + + val outer = TestDer.read(der, 0)!! + assertEquals(0x30, outer.tag, "outer CertificationRequest is a SEQUENCE") + assertEquals(der.size, outer.end, "no trailing garbage after the CSR") + val parts = TestDer.children(der, outer) + assertEquals(3, parts.size) + assertEquals(0x30, parts[0].tag) // certificationRequestInfo + assertEquals(0x30, parts[1].tag) // signatureAlgorithm + assertEquals(0x03, parts[2].tag) // signature BIT STRING + } + + @Test + fun csrSelfSignatureVerifiesAgainstTheEmbeddedP256Key() { + val signer = SoftwareEcSigner() + + val der = CertificateSigningRequest.der("web-terminal-device", signer) + + // Extract the exact CertificationRequestInfo bytes that were signed and the ECDSA signature + // (the same crypto check verifyCsrPoPEc's req.verify() runs). + val outer = TestDer.read(der, 0)!! + val parts = TestDer.children(der, outer) + val infoBytes = der.copyOfRange(parts[0].start, parts[0].end) + val bitString = parts[2] // BIT STRING: first content byte is unused-bits (0x00) + val signature = der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd) + + val ok = Signature.getInstance("SHA256withECDSA").apply { + initVerify(signer.keyPair.public) + update(infoBytes) + }.verify(signature) + assertTrue(ok, "the CSR self-signature must verify against its own SubjectPublicKeyInfo") + } + + @Test + fun csrEmbedsAP256SubjectPublicKeyInfoTheServerVerifierAccepts() { + val signer = SoftwareEcSigner() + val point = signer.publicKeyX963() + + val der = CertificateSigningRequest.der("web-terminal-device", signer) + + val outer = TestDer.read(der, 0)!! + val info = TestDer.children(der, outer)[0] + val infoChildren = TestDer.children(der, info) + assertEquals(4, infoChildren.size) + // version v1(0) + assertArrayEquals(byteArrayOf(0x02, 0x01, 0x00), der.copyOfRange(infoChildren[0].start, infoChildren[0].end)) + assertEquals(0xA0, infoChildren[3].tag) // [0] IMPLICIT attributes + assertEquals(0, infoChildren[3].valueEnd - infoChildren[3].valueStart) // empty SET + + // subjectPublicKeyInfo ::= SEQUENCE { AlgorithmIdentifier, BIT STRING point } + val spki = infoChildren[2] + val spkiChildren = TestDer.children(der, spki) + assertEquals(2, spkiChildren.size) + val algIdChildren = TestDer.children(der, spkiChildren[0]) + // AlgorithmIdentifier { id-ecPublicKey, prime256v1 } — the exact OIDs verifyCsrPoPEc pins. + assertArrayEquals( + byteArrayOf(0x06, 0x07, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01), + der.copyOfRange(algIdChildren[0].start, algIdChildren[0].end), + ) + assertArrayEquals( + byteArrayOf(0x06, 0x08, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x03, 0x01, 0x07), + der.copyOfRange(algIdChildren[1].start, algIdChildren[1].end), + ) + // BIT STRING content = 0x00 unused-bits + the exact 65-byte point. + val bitString = spkiChildren[1] + assertEquals(0x03, bitString.tag) + assertEquals(0x00, der[bitString.valueStart].toInt() and 0xFF) + assertArrayEquals(point, der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd)) + } + + @Test + fun signatureAlgorithmIsEcdsaWithSha256() { + val signer = SoftwareEcSigner() + val der = CertificateSigningRequest.der("web-terminal-device", signer) + val outer = TestDer.read(der, 0)!! + val algId = TestDer.children(der, outer)[1] + val oid = TestDer.children(der, algId)[0] + assertArrayEquals( + byteArrayOf(0x06, 0x08, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x04, 0x03, 0x02), + der.copyOfRange(oid.start, oid.end), + ) + } + + @Test + fun subjectCommonNameIsEncodedAsUtf8String() { + val signer = SoftwareEcSigner() + val der = CertificateSigningRequest.der("my-pixel", signer) + val outer = TestDer.read(der, 0)!! + val info = TestDer.children(der, outer)[0] + val name = TestDer.children(der, info)[1] // subject Name + val rdn = TestDer.children(der, name)[0] // SET + val attr = TestDer.children(der, rdn)[0] // SEQUENCE { OID, value } + val attrChildren = TestDer.children(der, attr) + // OID 2.5.4.3 (commonName), then a UTF8String (tag 0x0C) carrying the CN bytes. + assertArrayEquals(byteArrayOf(0x06, 0x03, 0x55, 0x04, 0x03), der.copyOfRange(attrChildren[0].start, attrChildren[0].end)) + assertEquals(0x0C, attrChildren[1].tag) + assertArrayEquals("my-pixel".toByteArray(), der.copyOfRange(attrChildren[1].valueStart, attrChildren[1].valueEnd)) + } + + @Test + fun emptySubjectCommonNameIsRejected() { + val signer = SoftwareEcSigner() + assertThrows(CsrException.InvalidSubject::class.java) { + CertificateSigningRequest.der("", signer) + } + } + + @Test + fun aNon65BytePublicKeyIsRejected() { + val badSigner = object : CsrSigner { + override fun publicKeyX963(): ByteArray = ByteArray(64) { 0x04 } // wrong length + override fun sign(message: ByteArray): ByteArray = ByteArray(0) + } + assertThrows(CsrException.InvalidPublicKey::class.java) { + CertificateSigningRequest.der("d", badSigner) + } + } + + @Test + fun aPublicKeyWithoutTheUncompressedPrefixIsRejected() { + val badSigner = object : CsrSigner { + override fun publicKeyX963(): ByteArray = ByteArray(65) { 0x02 } // right length, wrong prefix + override fun sign(message: ByteArray): ByteArray = ByteArray(0) + } + assertThrows(CsrException.InvalidPublicKey::class.java) { + CertificateSigningRequest.der("d", badSigner) + } + } +} + +/** + * A throwaway canonical-DER reader for structural assertions (the enroll path itself does no DER + * parsing — the server verifies; this mirrors the iOS `TestDER` test helper). + */ +internal object TestDer { + data class Element(val tag: Int, val start: Int, val valueStart: Int, val valueEnd: Int) { + val end: Int get() = valueEnd + } + + fun read(bytes: ByteArray, start: Int): Element? { + if (start < 0 || start + 1 >= bytes.size) return null + val tag = bytes[start].toInt() and 0xFF + var index = start + 1 + val first = bytes[index].toInt() and 0xFF + index += 1 + var length = 0 + if (first and 0x80 == 0) { + length = first + } else { + val count = first and 0x7F + if (count == 0 || count > 4 || index + count > bytes.size) return null + repeat(count) { + length = (length shl 8) or (bytes[index].toInt() and 0xFF) + index += 1 + } + } + val valueEnd = index + length + if (valueEnd > bytes.size) return null + return Element(tag = tag, start = start, valueStart = index, valueEnd = valueEnd) + } + + fun children(bytes: ByteArray, parent: Element): List { + val elements = mutableListOf() + var index = parent.valueStart + while (index < parent.valueEnd) { + val element = read(bytes, index) ?: break + elements.add(element) + index = element.valueEnd + } + return elements + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt new file mode 100644 index 0000000..4deadf2 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt @@ -0,0 +1,268 @@ +package wang.yaojia.webterm.api.enroll + +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest +import java.time.Instant +import java.util.Base64 + +/** + * B4 · DeviceEnrollmentClient request-building + response-mapping against the pinned login/enroll + * contract, driven by the shared `FakeHttpTransport` (no network). Mirrors the iOS + * `DeviceEnrollmentClientTests`, extended with the login step. + */ +class DeviceEnrollmentClientTest { + private companion object { + const val BASE = "https://cp.terminal.yaojia.wang" + const val BEARER = "device-enroll-token-abc" + } + + private val transport = FakeHttpTransport() + private val client = DeviceEnrollmentClient(BASE, transport) + + private fun bodyObject(request: HttpRequest): JsonObject = + Json.parseToJsonElement(request.body!!.decodeToString()) as JsonObject + + private fun enrollResponse( + deviceId: String = "dev-1", + cert: ByteArray = byteArrayOf(0x30, 0x01, 0x02), + caChain: List = listOf(byteArrayOf(0x30, 0xAA.toByte())), + notBefore: String = "2026-07-08T00:00:00.000Z", + notAfter: String = "2026-10-06T00:00:00.000Z", + renewAfter: String = "2026-09-05T00:00:00.000Z", + ): ByteArray { + val b64 = Base64.getEncoder() + val chainJson = caChain.joinToString(",") { "\"${b64.encodeToString(it)}\"" } + return """ + {"deviceId":"$deviceId","cert":"${b64.encodeToString(cert)}","caChain":[$chainJson], + "notBefore":"$notBefore","notAfter":"$notAfter","renewAfter":"$renewAfter"} + """.trimIndent().toByteArray() + } + + // ── login ──────────────────────────────────────────────────────────────────────────────── + + @Test + fun loginPostsPasswordAndMapsThe201Bearer() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, + url = "$BASE/auth/login", + status = 201, + body = """{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray(), + ) + + val result = client.login("hunter2") + + val request = transport.recordedRequests.single() + assertEquals(HttpMethod.POST, request.method) + assertEquals("$BASE/auth/login", request.url) + assertEquals("application/json", request.headers["Content-Type"]) + assertNull(request.headers["Authorization"], "login carries no bearer") + assertEquals("hunter2", bodyObject(request)["password"]!!.jsonPrimitive.content) + + assertEquals("tok-xyz", result.enrollToken) + assertEquals("acct-1", result.accountId) + assertEquals(600L, result.expiresInSeconds) + } + + @Test + fun loginRejectsAnEmptyPasswordBeforeAnyNetworkIo() = runTest { + val error = runCatching { client.login("") }.exceptionOrNull() + assertEquals(DeviceEnrollmentError.InvalidRequest, error) + assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an empty password") + } + + @Test + fun loginSurfacesA401AsHttpWithTheServerCode() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, + url = "$BASE/auth/login", + status = 401, + body = """{"error":"rejected"}""".toByteArray(), + ) + val error = runCatching { client.login("wrong") }.exceptionOrNull() + assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error) + } + + // ── enroll ─────────────────────────────────────────────────────────────────────────────── + + @Test + fun enrollBuildsABearerAuthenticatedPostWithTheContractBody() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse(), + ) + val csr = byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()) + + client.enroll(BEARER, csr, subdomain = "alice", deviceName = "Alice Pixel") + + val request = transport.recordedRequests.single() + assertEquals(HttpMethod.POST, request.method) + assertEquals("$BASE/device/enroll", request.url) + assertEquals("Bearer $BEARER", request.headers["Authorization"]) + assertEquals("application/json", request.headers["Content-Type"]) + + val obj = bodyObject(request) + assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content) + assertEquals("ec-p256", obj["keyAlg"]!!.jsonPrimitive.content) + assertEquals("alice", obj["subdomain"]!!.jsonPrimitive.content) + assertEquals("Alice Pixel", obj["deviceName"]!!.jsonPrimitive.content) + assertFalse(obj.containsKey("attestation"), "attestation is omitted when not provided") + } + + @Test + fun enrollForwardsAttestationWhenProvided() = runTest { + transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse()) + client.enroll(BEARER, byteArrayOf(0x01), "a", "d", attestation = "attest-blob") + assertEquals("attest-blob", bodyObject(transport.recordedRequests.single())["attestation"]!!.jsonPrimitive.content) + } + + @Test + fun enrollMapsA201IntoATypedResult() = runTest { + val cert = byteArrayOf(0x30, 0x82.toByte(), 0x01, 0x23) + val ca = byteArrayOf(0x30, 0x82.toByte(), 0x02, 0x00) + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, + body = enrollResponse(deviceId = "dev-xyz", cert = cert, caChain = listOf(ca)), + ) + + val result = client.enroll(BEARER, byteArrayOf(0x01), "alice", "Pixel") + + assertEquals("dev-xyz", result.deviceId) + assertArrayEquals(cert, result.certificate) + assertEquals(1, result.caChain.size) + assertArrayEquals(ca, result.caChain.single()) + assertTrue(result.renewAfter!!.isBefore(result.notAfter)) + } + + @Test + fun enrollRejectsEmptyRequiredFieldsBeforeAnyNetworkIo() = runTest { + assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll("", byteArrayOf(1), "a", "d") }.exceptionOrNull()) + assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, ByteArray(0), "a", "d") }.exceptionOrNull()) + assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, byteArrayOf(1), "", "d") }.exceptionOrNull()) + assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "") }.exceptionOrNull()) + assertTrue(transport.recordedRequests.isEmpty()) + } + + @Test + fun enrollSurfacesA403SubdomainNotOwnedWithTheServerCode() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/enroll", status = 403, + body = """{"error":"rejected"}""".toByteArray(), + ) + assertEquals( + DeviceEnrollmentError.Http(403, "rejected"), + runCatching { client.enroll(BEARER, byteArrayOf(1), "bob", "d") }.exceptionOrNull(), + ) + } + + @Test + fun enrollSurfacesA429RateLimited() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/enroll", status = 429, + body = """{"error":"rate_limited"}""".toByteArray(), + ) + assertEquals( + DeviceEnrollmentError.Http(429, "rate_limited"), + runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(), + ) + } + + @Test + fun enrollThrowsMalformedResponseOnANonJson201Body() = runTest { + transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = "not json".toByteArray()) + assertEquals( + DeviceEnrollmentError.MalformedResponse, + runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(), + ) + } + + @Test + fun enrollThrowsMalformedResponseWhenTheCertIsNotValidBase64() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, + body = """{"deviceId":"d","cert":"@@not-base64@@","caChain":[]}""".toByteArray(), + ) + assertEquals( + DeviceEnrollmentError.MalformedResponse, + runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(), + ) + } + + @Test + fun enrollDegradesAbsentDatesToNull() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, + body = """{"deviceId":"d","cert":"MAEC","caChain":[]}""".toByteArray(), + ) + val result = client.enroll(BEARER, byteArrayOf(1), "a", "d") + assertNull(result.notAfter) + assertNull(result.renewAfter) + assertFalse(result.isRenewalDue(Instant.parse("2030-01-01T00:00:00Z")), "absent renewAfter never triggers") + } + + // ── renew (silent rotation seam — mTLS-only, NO bearer) ───────────────────────────────────── + + @Test + fun renewTargetsDeviceIdRenewWithTheMinimalBodyAndNoAuthorizationHeader() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, body = enrollResponse(deviceId = "dev-9"), + ) + val csr = byteArrayOf(0x02) + + // Production renew passes NO bearer — the endpoint authenticates by the current device cert (mTLS). + val result = client.renew("dev-9", csr) + + val request = transport.recordedRequests.single() + assertEquals("$BASE/device/dev-9/renew", request.url) + assertNull(request.headers["Authorization"], "renew authenticates by mTLS — it must send NO Authorization header") + val obj = bodyObject(request) + assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content) + // The server's /device/:id/renew authenticates by the presented mTLS device cert and its body + // schema is `{ csr }` ONLY (.strict()) — any enroll-only extra (keyAlg/subdomain/deviceName) + // is rejected. The renew wire body must therefore carry the single `csr` key and nothing else. + assertEquals(setOf("csr"), obj.keys, "renew body is {csr}-only — no keyAlg/subdomain/deviceName") + assertFalse(obj.containsKey("keyAlg"), "renew must not send the enroll-only keyAlg field") + assertFalse(obj.containsKey("subdomain"), "renew body carries no subdomain/deviceName") + assertEquals("dev-9", result.deviceId) + } + + @Test + fun renewForwardsAnExplicitBearerWhenTheOptionalSeamIsUsed() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, body = enrollResponse(deviceId = "dev-9"), + ) + + // The bearer is optional/absent by default; when a caller DOES pass one it rides as a header. + client.renew("dev-9", byteArrayOf(0x02), bearerToken = BEARER) + + assertEquals("Bearer $BEARER", transport.recordedRequests.single().headers["Authorization"]) + } + + @Test + fun renewRejectsEmptyDeviceIdOrCsrBeforeAnyNetworkIo() = runTest { + assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("", byteArrayOf(1)) }.exceptionOrNull()) + assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("d", ByteArray(0)) }.exceptionOrNull()) + assertTrue(transport.recordedRequests.isEmpty()) + } + + // ── isRenewalDue seam ────────────────────────────────────────────────────────────────────── + + @Test + fun isRenewalDueFlipsAtRenewAfter() = runTest { + transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse()) + val result = client.enroll(BEARER, byteArrayOf(1), "a", "d") + assertFalse(result.isRenewalDue(Instant.parse("2026-09-04T00:00:00Z"))) + assertTrue(result.isRenewalDue(Instant.parse("2026-09-06T00:00:00Z"))) + } + + private fun assertArrayEquals(expected: ByteArray, actual: ByteArray) = + org.junit.jupiter.api.Assertions.assertArrayEquals(expected, actual) +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/EcPointEncodingTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/EcPointEncodingTest.kt new file mode 100644 index 0000000..74b3e22 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/EcPointEncodingTest.kt @@ -0,0 +1,57 @@ +package wang.yaojia.webterm.api.enroll + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import java.math.BigInteger +import java.security.KeyPairGenerator +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec + +/** B4 · The X9.63 uncompressed-point encoder — the security-load-bearing SubjectPublicKeyInfo bytes. */ +class EcPointEncodingTest { + @Test + fun encodesAGeneratedP256KeyAs65UncompressedBytesRoundTrippingToTheCoordinates() { + val kp = KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1")) + }.generateKeyPair() + val pub = kp.public as ECPublicKey + + val encoded = EcPointEncoding.x963(pub) + + assertEquals(65, encoded.size, "0x04 || X(32) || Y(32)") + assertEquals(0x04, encoded[0].toInt() and 0xFF, "uncompressed-point prefix") + // The 32-byte big-endian halves must be exactly the affine coordinates. + val x = BigInteger(1, encoded.copyOfRange(1, 33)) + val y = BigInteger(1, encoded.copyOfRange(33, 65)) + assertEquals(pub.w.affineX, x) + assertEquals(pub.w.affineY, y) + } + + @Test + fun leftPadsAShortCoordinateToTheFixedWidth() { + // A small value must be left-padded with leading zeros to exactly 32 bytes. + val padded = EcPointEncoding.toFixedLengthUnsigned(BigInteger.valueOf(1), 32) + assertEquals(32, padded.size) + assertEquals(1, padded[31].toInt()) + assertEquals(0, padded[0].toInt()) + } + + @Test + fun dropsTheBigIntegerSignByteWhenPresent() { + // A value whose top bit is set carries a leading 0x00 sign byte in BigInteger.toByteArray(); + // it must be dropped, not counted toward the width. + val highBit = BigInteger(1, ByteArray(32) { 0xFF.toByte() }) + val encoded = EcPointEncoding.toFixedLengthUnsigned(highBit, 32) + assertEquals(32, encoded.size) + assertEquals(0xFF, encoded[0].toInt() and 0xFF) + } + + @Test + fun rejectsACoordinateThatDoesNotFit() { + val tooBig = BigInteger.ONE.shiftLeft(256) // needs 33 bytes + assertThrows(IllegalArgumentException::class.java) { + EcPointEncoding.toFixedLengthUnsigned(tooBig, 32) + } + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt index 94c636f..3a25ba3 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt @@ -11,8 +11,11 @@ import okhttp3.OkHttpClient import wang.yaojia.webterm.tlsandroid.AndroidIdentityRepository import wang.yaojia.webterm.tlsandroid.AndroidKeyStoreImporter import wang.yaojia.webterm.tlsandroid.CertStore +import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore +import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher import wang.yaojia.webterm.tlsandroid.IdentityRepository import wang.yaojia.webterm.tlsandroid.TinkCertStore +import wang.yaojia.webterm.tlsandroid.TinkEnrollmentRecordStore import javax.inject.Singleton /** @@ -41,16 +44,38 @@ public object TlsModule { @Singleton public fun provideCertStore(@ApplicationContext context: Context): CertStore = TinkCertStore(context) + /** The Tink-AEAD-encrypted enrollment record (deviceId + key alias) the zero-`.p12` renew path reads. */ @Provides @Singleton - public fun provideIdentityRepository( + public fun provideEnrollmentRecordStore( + @ApplicationContext context: Context, + ): EnrollmentRecordStore = TinkEnrollmentRecordStore(context) + + /** + * The ONE mTLS device-identity repository, provided as the concrete type so BOTH the + * [IdentityRepository] surface (import/rotate/remove/summary) and the narrow [IdentityCacheRefresher] + * seam (B4 enroll cache-freshness) resolve to the SAME singleton instance. + */ + @Provides + @Singleton + public fun provideAndroidIdentityRepository( importer: AndroidKeyStoreImporter, certStore: CertStore, connectionPool: ConnectionPool, - ): IdentityRepository { - // Evict-only client: no mTLS of its own, just the SHARED pool so remove()/rotate()'s + ): AndroidIdentityRepository { + // Evict-only client: no mTLS of its own, just the SHARED pool so remove()/rotate()/refresh's // `connectionPool.evictAll()` clears the connections the real transports pooled (R4/§8). val evictClient = OkHttpClient.Builder().connectionPool(connectionPool).build() return AndroidIdentityRepository(importer, certStore, evictClient) } + + @Provides + @Singleton + public fun provideIdentityRepository(repository: AndroidIdentityRepository): IdentityRepository = repository + + /** FIX 3: the enroll/renew commit refreshes THIS same repository's in-memory cache (no restart). */ + @Provides + @Singleton + public fun provideIdentityCacheRefresher(repository: AndroidIdentityRepository): IdentityCacheRefresher = + repository } diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/NavGraph.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/NavGraph.kt index 0ba6072..f50dea7 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/nav/NavGraph.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/NavGraph.kt @@ -67,6 +67,8 @@ public fun WebTermNavHost( composable(NavRoutes.CERT) { ClientCertPane(env = env, onBack = { navController.popBackStack() }) } + composable(NavRoutes.ENROLL) { EnrollmentPane(env = env, onBack = { navController.popBackStack() }) } + composable( route = TERMINAL_ROUTE, arguments = listOf( @@ -211,6 +213,9 @@ public object NavRoutes { /** Device-certificate management (A27). */ public const val CERT: String = "cert" + /** Zero-`.p12` device enrollment (B4) — obtains a hardware-bound cert with no file. */ + public const val ENROLL: String = "enroll" + /** Terminal route pattern with the required host + session path args (A21). */ public const val TERMINAL_PATTERN: String = "terminal/{${NavArg.HOST_ID}}/{${NavArg.SESSION_ID}}" diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt index 5f22814..6ebbb5d 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt @@ -20,11 +20,13 @@ import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.screens.ClientCertScreen import wang.yaojia.webterm.screens.DiffScreen +import wang.yaojia.webterm.screens.EnrollmentScreen import wang.yaojia.webterm.screens.PairingScreen import wang.yaojia.webterm.screens.ProjectDetailScreen import wang.yaojia.webterm.viewmodels.ApiClientGitWriteGateway import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway import wang.yaojia.webterm.viewmodels.ClientCertViewModel +import wang.yaojia.webterm.viewmodels.EnrollmentViewModel import wang.yaojia.webterm.viewmodels.DiffViewModel import wang.yaojia.webterm.viewmodels.HttpDiffFetcher import wang.yaojia.webterm.viewmodels.PairingViewModel @@ -75,6 +77,38 @@ public fun ClientCertPane( ClientCertScreen(viewModel = viewModel, onBack = onBack, modifier = modifier) } +// ── Device enrollment (B4, zero-.p12 auto-cert) ───────────────────────────────────────────────────── + +/** + * Hosts [EnrollmentScreen] over a fresh [EnrollmentViewModel] (mirrors iOS `AppCoordinator`'s + * `makeEnrollmentViewModel`). The enroll flow and the installed-summary read run OFF `Main` + * (`Dispatchers.IO`) — resolving the enroller resolves the lazy shared client (keystore/TLS I/O) and the + * enroll itself does hardware-keygen + network. The typed control-plane URL builds the enroller per attempt. + */ +@Composable +public fun EnrollmentPane( + env: AppEnvironment, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val viewModel = remember(env) { + EnrollmentViewModel( + enrollOperation = { password, subdomain, deviceName, controlPlaneUrl -> + withContext(Dispatchers.IO) { + env.enrollmentFlowFactory.create(controlPlaneUrl).enroll(password, subdomain, deviceName) + } + }, + loadSummary = { + withContext(Dispatchers.IO) { + runCatching { env.identityRepository.currentSummary() }.getOrNull() + } + }, + defaultDeviceName = android.os.Build.MODEL ?: "", + ) + } + EnrollmentScreen(viewModel = viewModel, onBack = onBack, modifier = modifier) +} + // ── Project detail (A23) ────────────────────────────────────────────────────────────────────────────── /** diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt index 4d6a300..3899926 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt @@ -87,6 +87,7 @@ public fun SessionsHome( onOpenSession = openSession, onNewSession = openNewSession, onPairHost = { navController.navigate(NavRoutes.PAIRING) }, + onEnroll = { navController.navigate(NavRoutes.ENROLL) }, onImportCert = { navController.navigate(NavRoutes.CERT) }, ) } diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/EnrollmentScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/EnrollmentScreen.kt new file mode 100644 index 0000000..cbaa73b --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/EnrollmentScreen.kt @@ -0,0 +1,385 @@ +package wang.yaojia.webterm.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import wang.yaojia.webterm.designsystem.Spacing +import wang.yaojia.webterm.designsystem.WebTermColors +import wang.yaojia.webterm.designsystem.WebTermTheme +import wang.yaojia.webterm.designsystem.WebTermType +import wang.yaojia.webterm.viewmodels.CertSummaryView +import wang.yaojia.webterm.viewmodels.EnrollError +import wang.yaojia.webterm.viewmodels.EnrollPhase +import wang.yaojia.webterm.viewmodels.EnrollmentUiState +import wang.yaojia.webterm.viewmodels.EnrollmentViewModel + +/** + * # EnrollmentScreen (B4) — zero-`.p12` device enrollment (the phone half of zero-touch). + * + * The Compose shell over [EnrollmentViewModel], reached from the session-list host menu "自动获取证书" + * (mirrors iOS `EnrollmentScreen` presented from `SessionListScreen`'s host menu). One operator login + * generates a NON-EXPORTABLE hardware key + CSR and obtains a device cert with no file at all; the cert is + * committed to the shared store and presented automatically on the existing mTLS path (cache-refreshed, so + * no restart). The manual `.p12` path ([ClientCertScreen]) remains available alongside this one. + * + * ### Secrets & trust discipline (plan §8) + * - The operator password is a masked field bound straight to the ViewModel and cleared after every + * attempt — never logged, persisted, or echoed. The private key is generated non-exportably in secure + * hardware inside the library; this screen never sees it. + * - Every cert-derived string (CNs, expiry) and every error message is inert [Text] — no autolink/markdown. + * Error copy is app-authored, never a server/exception string. + * + * The keystore/network I/O it drives is device-QA (plan §7); the state machine is JVM-tested in + * `EnrollmentViewModelTest`. + */ +@Composable +public fun EnrollmentScreen( + viewModel: EnrollmentViewModel, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(viewModel) { viewModel.bind(this) } + + EnrollmentScreen( + state = state, + onControlPlaneUrlChange = viewModel::onControlPlaneUrlChange, + onSubdomainChange = viewModel::onSubdomainChange, + onDeviceNameChange = viewModel::onDeviceNameChange, + onPasswordChange = viewModel::onPasswordChange, + onEnroll = viewModel::enroll, + onDismissError = viewModel::clearError, + modifier = modifier, + onBack = onBack, + ) +} + +/** Stateless body — pure inputs so a [Preview] and any host can drive it without the VM machinery. */ +@Composable +public fun EnrollmentScreen( + state: EnrollmentUiState, + onControlPlaneUrlChange: (String) -> Unit, + onSubdomainChange: (String) -> Unit, + onDeviceNameChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onEnroll: () -> Unit, + onDismissError: () -> Unit, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, +) { + Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(Spacing.lg16), + verticalArrangement = Arrangement.spacedBy(Spacing.md12), + ) { + Header(onBack = onBack) + + if (state.phase == EnrollPhase.LOADING) { + LoadingRow() + return@Column + } + + InstalledSection(summary = state.summary) + + state.error?.let { ErrorCard(error = it, onDismiss = onDismissError) } + if (state.didSucceed && state.error == null) SuccessCard() + + EnrollForm( + state = state, + onControlPlaneUrlChange = onControlPlaneUrlChange, + onSubdomainChange = onSubdomainChange, + onDeviceNameChange = onDeviceNameChange, + onPasswordChange = onPasswordChange, + onEnroll = onEnroll, + ) + + Text( + text = EnrollCopy.FOOTER, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun Header(onBack: (() -> Unit)?) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + if (onBack != null) { + TextButton(onClick = onBack) { Text("返回") } + } + Text(text = EnrollCopy.TITLE, style = MaterialTheme.typography.headlineSmall) + } +} + +/** The currently-installed identity summary (or "none" copy) — the "已安装证书" section. */ +@Composable +private fun InstalledSection(summary: CertSummaryView?) { + if (summary == null) { + Text( + text = EnrollCopy.NONE_INSTALLED, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.lg16), + verticalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + SummaryRow(label = "设备(CN)", value = summary.subjectCommonName) + SummaryRow(label = "签发方(CN)", value = summary.issuerCommonName) + SummaryRow(label = "到期", value = summary.expiry) + if (summary.isExpired) { + HorizontalDivider(color = MaterialTheme.colorScheme.outline) + Text( + text = "⚠ 证书已过期,请重新注册。", + style = MaterialTheme.typography.bodyMedium, + color = WebTermColors.statusStuck, + ) + } + } + } +} + +@Composable +private fun SummaryRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Spacing.md12), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + // Cert-derived value: inert monospaced Text — no linkify/markdown (plan §8). + Text(text = value, style = WebTermType.monoTabular(13), color = MaterialTheme.colorScheme.onSurface) + } +} + +@Composable +private fun EnrollForm( + state: EnrollmentUiState, + onControlPlaneUrlChange: (String) -> Unit, + onSubdomainChange: (String) -> Unit, + onDeviceNameChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onEnroll: () -> Unit, +) { + val enrolling = state.phase == EnrollPhase.ENROLLING + Text( + text = if (state.summary == null) EnrollCopy.ENROLL_HEADER else EnrollCopy.ROTATE_HEADER, + style = MaterialTheme.typography.titleSmall, + ) + OutlinedTextField( + value = state.controlPlaneUrl, + onValueChange = onControlPlaneUrlChange, + label = { Text(EnrollCopy.CONTROL_PLANE_URL) }, + singleLine = true, + enabled = !enrolling, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.subdomain, + onValueChange = onSubdomainChange, + label = { Text(EnrollCopy.SUBDOMAIN) }, + singleLine = true, + enabled = !enrolling, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.deviceName, + onValueChange = onDeviceNameChange, + label = { Text(EnrollCopy.DEVICE_NAME) }, + singleLine = true, + enabled = !enrolling, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.password, + onValueChange = onPasswordChange, + label = { Text(EnrollCopy.PASSWORD) }, + singleLine = true, + enabled = !enrolling, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = onEnroll, + enabled = state.canEnroll, + modifier = Modifier.fillMaxWidth(), + ) { + if (enrolling) { + CircularProgressIndicator(modifier = Modifier.padding(Spacing.xs4)) + } else { + Text(if (state.summary == null) EnrollCopy.ENROLL_ACTION else EnrollCopy.ROTATE_ACTION) + } + } +} + +@Composable +private fun ErrorCard(error: EnrollError, onDismiss: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.md12), + verticalArrangement = Arrangement.spacedBy(Spacing.xs4), + ) { + Text( + text = errorCopy(error), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { Text("知道了") } + } + } +} + +@Composable +private fun SuccessCard() { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Text( + text = EnrollCopy.SUCCESS, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.md12), + ) + } +} + +@Composable +private fun LoadingRow() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + } +} + +/** Maps the coarse [EnrollError] to inert, app-authored copy (never an exception/server string, §8). */ +private fun errorCopy(error: EnrollError): String = when (error) { + EnrollError.INVALID_URL -> "控制面地址无效,请输入 https:// 开头的完整地址。" + EnrollError.MISSING_FIELDS -> "请填写子域名、设备名称和操作口令。" + EnrollError.BAD_CREDENTIAL -> "操作口令错误,请重试。" + EnrollError.SUBDOMAIN_NOT_OWNED -> "该账号未拥有此子域名,无法签发证书。" + EnrollError.RATE_LIMITED -> "请求过于频繁,请稍后再试。" + EnrollError.REJECTED -> "请求被拒绝:子域名或证书请求无效。" + EnrollError.ENROLL_FAILED -> "注册失败,请稍后重试。" + EnrollError.SERVER -> "服务器返回异常,请稍后重试。" + EnrollError.KEYGEN -> "生成硬件密钥失败(本设备可能不支持安全硬件)。" + EnrollError.UNKNOWN -> "注册失败,请重试。" +} + +/** User-facing copy (Chinese), mirroring iOS `EnrollmentCopy`. */ +private object EnrollCopy { + const val TITLE = "自动获取证书" + const val NONE_INSTALLED = "尚未安装设备证书。" + const val ENROLL_HEADER = "注册本设备" + const val ROTATE_HEADER = "重新注册" + const val CONTROL_PLANE_URL = "控制面地址" + const val SUBDOMAIN = "子域名(你拥有的隧道名)" + const val DEVICE_NAME = "设备名称" + const val PASSWORD = "操作口令" + const val ENROLL_ACTION = "注册本设备" + const val ROTATE_ACTION = "重新注册" + const val SUCCESS = "已注册,证书已保存到本设备安全硬件,连接隧道主机时将自动出示。" + const val FOOTER = + "首次登录一次即可:本设备在安全硬件(StrongBox / TEE)生成不可导出的私钥,向控制面申请证书并自动保存;" + + "之后连接隧道主机时自动出示,无需再手动导入 .p12。" +} + +@Preview(name = "EnrollmentScreen — none installed") +@Composable +private fun EnrollmentScreenNonePreview() { + WebTermTheme { + EnrollmentScreen( + state = EnrollmentUiState( + controlPlaneUrl = "https://cp.terminal.yaojia.wang", + subdomain = "alice", + deviceName = "Pixel 8", + phase = EnrollPhase.IDLE, + ), + onControlPlaneUrlChange = {}, + onSubdomainChange = {}, + onDeviceNameChange = {}, + onPasswordChange = {}, + onEnroll = {}, + onDismissError = {}, + ) + } +} + +@Preview(name = "EnrollmentScreen — installed + error") +@Composable +private fun EnrollmentScreenInstalledPreview() { + WebTermTheme { + EnrollmentScreen( + state = EnrollmentUiState( + controlPlaneUrl = "https://cp.terminal.yaojia.wang", + subdomain = "alice", + deviceName = "Pixel 8", + summary = CertSummaryView("alice-pixel", "webterm-device-ca", "2027年1月8日", isExpired = false), + phase = EnrollPhase.IDLE, + error = EnrollError.SUBDOMAIN_NOT_OWNED, + ), + onControlPlaneUrlChange = {}, + onSubdomainChange = {}, + onDeviceNameChange = {}, + onPasswordChange = {}, + onEnroll = {}, + onDismissError = {}, + ) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt index fde7fce..cc9e03c 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt @@ -77,6 +77,7 @@ import java.util.UUID * @param onOpenSession open the terminal (A21) for a tapped session id (its dot is cleared first). * @param onNewSession start a new session on the active host (`attach(null)`; A21 owns the terminal). * @param onPairHost host-menu **配对新主机** → the pairing flow (A19). + * @param onEnroll host-menu **自动获取证书** → the zero-`.p12` enrollment screen (B4). * @param onImportCert host-menu **设备证书** → the device-cert screen (A27). * @param thumbnails off-screen preview seam; production wires it to the active host's * [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline] (§6.7). `null` = placeholder tiles. @@ -87,6 +88,7 @@ public fun SessionListScreen( onOpenSession: (UUID) -> Unit, onNewSession: () -> Unit, onPairHost: () -> Unit, + onEnroll: () -> Unit, onImportCert: () -> Unit, modifier: Modifier = Modifier, thumbnails: SessionThumbnails? = null, @@ -110,6 +112,7 @@ public fun SessionListScreen( onSelectHost = { id -> scope.launch { viewModel.selectHost(id) } }, onNewSession = onNewSession, onPairHost = onPairHost, + onEnroll = onEnroll, onImportCert = onImportCert, ) }, @@ -138,6 +141,7 @@ private fun SessionListTopBar( onSelectHost: (String) -> Unit, onNewSession: () -> Unit, onPairHost: () -> Unit, + onEnroll: () -> Unit, onImportCert: () -> Unit, ) { var menuOpen by remember { mutableStateOf(false) } @@ -156,6 +160,7 @@ private fun SessionListTopBar( onDismiss = { menuOpen = false }, onSelectHost = { menuOpen = false; onSelectHost(it) }, onPairHost = { menuOpen = false; onPairHost() }, + onEnroll = { menuOpen = false; onEnroll() }, onImportCert = { menuOpen = false; onImportCert() }, ) } @@ -163,7 +168,7 @@ private fun SessionListTopBar( ) } -/** The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the two actions. */ +/** The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the actions. */ @Composable private fun HostMenu( expanded: Boolean, @@ -171,6 +176,7 @@ private fun HostMenu( onDismiss: () -> Unit, onSelectHost: (String) -> Unit, onPairHost: () -> Unit, + onEnroll: () -> Unit, onImportCert: () -> Unit, ) { DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { @@ -184,6 +190,8 @@ private fun HostMenu( } if (hosts.isNotEmpty()) HorizontalDivider() DropdownMenuItem(text = { Text("配对新主机") }, onClick = onPairHost) + // 自动获取证书 (zero-.p12 enroll) sits beside 设备证书 (.p12 import) — same host-menu surface as iOS. + DropdownMenuItem(text = { Text("自动获取证书") }, onClick = onEnroll) DropdownMenuItem(text = { Text("设备证书") }, onClick = onImportCert) } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/EnrollmentViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/EnrollmentViewModel.kt new file mode 100644 index 0000000..0debf4b --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/EnrollmentViewModel.kt @@ -0,0 +1,265 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError +import wang.yaojia.webterm.clienttls.CertificateSummary +import java.net.URI +import java.security.GeneralSecurityException +import java.time.Instant +import java.time.ZoneId + +/** + * # EnrollmentViewModel (B4) — the zero-`.p12` device-enrollment presenter. + * + * The Android port of iOS `EnrollmentViewModel` (`EnrollmentScreen.swift`). Drives the "自动获取证书" + * screen reached from the session-list host menu: one operator login (password) → a short-lived + * `device:enroll` bearer → a NON-EXPORTABLE hardware key + PKCS#10 CSR → `POST /device/enroll` → the + * returned leaf is committed to the shared cert store and presented AUTOMATICALLY on the existing mTLS + * path (no manual `.p12` import). Cache freshness (FIX 3) means the enrolled cert is live without a restart. + * + * A **plain presenter** (mirrors [ClientCertViewModel] / [PairingViewModel]), NOT an + * `androidx.lifecycle.ViewModel`, so it runs under `runTest` virtual time with no `Dispatchers.Main`. The + * enroll flow and the installed-summary read are injected as suspend closures ([enrollOperation] / + * [loadSummary]) so the VM is unit-testable without a keystore or network; production wires them (in the + * enrollment pane) to [wang.yaojia.webterm.wiring.EnrollmentFlowFactory] over a `Dispatchers.IO` hop. + * + * ### Secrets discipline (plan §8) + * The operator password lives only in [EnrollmentUiState] and is **cleared after every attempt** (success + * OR failure) so it never lingers in memory; it is never logged and never placed in error copy. Errors are + * a coarse [EnrollError] enum the screen maps to app-authored, inert copy — never a server/exception string. + * + * @param enrollOperation login → hardware keygen+CSR → enroll → commit, yielding the installed leaf summary. + * @param loadSummary the currently-installed device-cert summary (for the "已安装证书" section), or null. + * @param defaultControlPlaneUrl prefilled control-plane URL (the operator can edit it). + * @param defaultDeviceName prefilled device name (the Android device/model in production). + * @param zone / now formatting + expiry clock for the installed-cert summary (fixed in tests). + */ +public class EnrollmentViewModel( + private val enrollOperation: suspend (password: String, subdomain: String, deviceName: String, controlPlaneUrl: String) -> CertificateSummary, + private val loadSummary: suspend () -> CertificateSummary?, + defaultControlPlaneUrl: String = DEFAULT_CONTROL_PLANE_URL, + defaultDeviceName: String = "", + private val zone: ZoneId = ZoneId.systemDefault(), + private val now: () -> Instant = Instant::now, +) { + private val _uiState = MutableStateFlow( + EnrollmentUiState(controlPlaneUrl = defaultControlPlaneUrl, deviceName = defaultDeviceName), + ) + + /** The single snapshot the enrollment screen renders from. */ + public val uiState: StateFlow = _uiState.asStateFlow() + + private var scope: CoroutineScope? = null + private var job: Job? = null + + /** Bind the scope actions launch into (the screen passes a lifecycle scope) and load any installed cert. */ + public fun bind(scope: CoroutineScope) { + this.scope = scope + refresh() + } + + // ── Field edits (two-way bound from the Compose form) ───────────────────────────────────────────── + + public fun onControlPlaneUrlChange(value: String) { + _uiState.value = _uiState.value.copy(controlPlaneUrl = value) + } + + public fun onSubdomainChange(value: String) { + _uiState.value = _uiState.value.copy(subdomain = value) + } + + public fun onDeviceNameChange(value: String) { + _uiState.value = _uiState.value.copy(deviceName = value) + } + + public fun onPasswordChange(value: String) { + _uiState.value = _uiState.value.copy(password = value) + } + + /** Dismiss the current error banner. */ + public fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } + + private fun refresh() { + val scope = scope ?: return + job?.cancel() + _uiState.value = _uiState.value.copy(phase = EnrollPhase.LOADING) + job = scope.launch { + // loadSummary is designed to degrade to null on a storage fault (never throw); guard anyway. + val summary = runCatching { loadSummary() }.getOrNull() + _uiState.value = _uiState.value.copy(phase = EnrollPhase.IDLE, summary = summary?.toView()) + } + } + + /** + * Run enrollment: validate the control-plane URL (must be `https` with a host) and the fields BEFORE + * any network, then drive [enrollOperation]. The password is cleared after every attempt so it never + * lingers. A double-tap while an enroll is in flight is ignored. + */ + public fun enroll() { + val scope = scope ?: return + val snapshot = _uiState.value + if (snapshot.phase == EnrollPhase.ENROLLING) return + + val url = validControlPlaneUrl(snapshot.controlPlaneUrl) + if (url == null) { + _uiState.value = snapshot.copy(error = EnrollError.INVALID_URL, didSucceed = false) + return + } + val subdomain = snapshot.subdomain.trim() + val deviceName = snapshot.deviceName.trim() + val password = snapshot.password + if (subdomain.isEmpty() || deviceName.isEmpty() || password.isEmpty()) { + _uiState.value = snapshot.copy(error = EnrollError.MISSING_FIELDS, didSucceed = false) + return + } + + _uiState.value = snapshot.copy(phase = EnrollPhase.ENROLLING, error = null, didSucceed = false) + job?.cancel() + job = scope.launch { + val outcome = try { + Result.success(enrollOperation(password, subdomain, deviceName, url)) + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + Result.failure(error) + } + // Clear the password whatever the outcome — never linger after an attempt. + _uiState.value = outcome.fold( + onSuccess = { summary -> + _uiState.value.copy( + phase = EnrollPhase.IDLE, + summary = summary.toView(), + password = "", + didSucceed = true, + error = null, + ) + }, + onFailure = { e -> + _uiState.value.copy( + phase = EnrollPhase.IDLE, + password = "", + didSucceed = false, + error = classifyEnroll(e), + ) + }, + ) + } + } + + /** Fields all present + not already enrolling → the button is enabled (deeper URL check on tap). */ + private fun CertificateSummary.toView() = toSummaryView(now(), zone) + + public companion object { + /** The default control-plane URL (matches iOS `EnrollmentCopy.defaultControlPlaneURL`). */ + public const val DEFAULT_CONTROL_PLANE_URL: String = "https://cp.terminal.yaojia.wang" + } +} + +/** + * Validate a control-plane URL exactly as iOS does before any network: it must parse, be `https`, and + * carry a non-blank host. Returns the trimmed URL on success, or null (→ [EnrollError.INVALID_URL]). + */ +internal fun validControlPlaneUrl(raw: String): String? { + val trimmed = raw.trim() + if (trimmed.isEmpty()) return null + val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null + if (!"https".equals(uri.scheme, ignoreCase = true)) return null + if (uri.host.isNullOrBlank()) return null + return trimmed +} + +/** + * Map an enroll throwable to the coarse, non-secret [EnrollError] the screen renders inertly. Android's + * login + enroll share one [DeviceEnrollmentError.Http] type, so the HTTP status disambiguates (401 login + * vs 403 subdomain-not-owned vs 429 vs 400). Keystore/hardware faults (incl. StrongBox-unavailable, a + * [GeneralSecurityException] subclass) map to KEYGEN. Never carries the password/bearer. + */ +internal fun classifyEnroll(error: Throwable): EnrollError = when (error) { + is DeviceEnrollmentError.Http -> when (error.status) { + 401 -> EnrollError.BAD_CREDENTIAL + 403 -> EnrollError.SUBDOMAIN_NOT_OWNED + 429 -> EnrollError.RATE_LIMITED + 400 -> EnrollError.REJECTED + else -> EnrollError.ENROLL_FAILED + } + is DeviceEnrollmentError.MalformedResponse -> EnrollError.SERVER + is DeviceEnrollmentError -> EnrollError.ENROLL_FAILED // InvalidRequest etc. (post-validation, rare) + is GeneralSecurityException -> EnrollError.KEYGEN // hardware key / StrongBox / keystore fault + else -> EnrollError.UNKNOWN +} + +/** The load/action phase the enrollment screen renders. */ +public enum class EnrollPhase { + /** The initial installed-summary read is in flight. */ + LOADING, + + /** Idle — the form is editable and [EnrollmentUiState.summary] shows any installed cert. */ + IDLE, + + /** An enroll is in flight (login → keygen+CSR → enroll → commit). */ + ENROLLING, +} + +/** The coarse, non-secret enrollment failure taxonomy — the screen maps each to app-authored inert copy (§8). */ +public enum class EnrollError { + /** The control-plane URL is not a valid `https://…` URL with a host (set before any network). */ + INVALID_URL, + + /** A required field (subdomain / device name / password) was empty (set before any network). */ + MISSING_FIELDS, + + /** Operator login rejected (401) — the password is wrong. */ + BAD_CREDENTIAL, + + /** The account does not own the requested subdomain (403) — no cert can be issued. */ + SUBDOMAIN_NOT_OWNED, + + /** Too many requests (429) — back off and retry. */ + RATE_LIMITED, + + /** The subdomain or CSR was rejected (400). */ + REJECTED, + + /** Any other enroll failure (server-side or transport). */ + ENROLL_FAILED, + + /** The server returned a malformed response. */ + SERVER, + + /** Hardware key generation failed (no StrongBox/TEE, or a keystore fault). */ + KEYGEN, + + /** An unclassified failure. */ + UNKNOWN, +} + +/** The immutable snapshot the enrollment screen renders. */ +public data class EnrollmentUiState( + val controlPlaneUrl: String = "", + val subdomain: String = "", + val deviceName: String = "", + val password: String = "", + /** The currently-installed device identity's display summary, or `null` when none is installed. */ + val summary: CertSummaryView? = null, + val phase: EnrollPhase = EnrollPhase.LOADING, + /** The last enroll failure, or `null`. Surfaced as inert, app-authored copy. */ + val error: EnrollError? = null, + /** Whether the most recent enroll succeeded (drives the success affordance). */ + val didSucceed: Boolean = false, +) { + /** Every field present and no enroll in flight → the submit button is enabled. */ + val canEnroll: Boolean + get() = phase != EnrollPhase.ENROLLING && + controlPlaneUrl.trim().isNotEmpty() && + subdomain.trim().isNotEmpty() && + deviceName.trim().isNotEmpty() && + password.isNotEmpty() +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt index 2ac98da..7ef05a7 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt @@ -49,6 +49,11 @@ public class AppEnvironment @Inject constructor( public val apiClientFactory: ApiClientFactory, public val sessionEngineFactory: SessionEngineFactory, public val coldStartPolicy: ColdStartPolicy, + /** + * B4 · Builds a [DeviceEnroller][wang.yaojia.webterm.tlsandroid.DeviceEnroller] per control-plane URL + * for the zero-`.p12` enrollment screen (A-enroll). App-scoped; the screen calls it off `Main`. + */ + public val enrollmentFlowFactory: EnrollmentFlowFactory, private val identityRepositoryLazy: Lazy, private val httpTransportLazy: Lazy, private val termTransportLazy: Lazy, diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/EnrollmentFlowFactory.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/EnrollmentFlowFactory.kt new file mode 100644 index 0000000..3941c94 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/EnrollmentFlowFactory.kt @@ -0,0 +1,51 @@ +package wang.yaojia.webterm.wiring + +import dagger.Lazy +import okhttp3.OkHttpClient +import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient +import wang.yaojia.webterm.tlsandroid.CertStore +import wang.yaojia.webterm.tlsandroid.DeviceEnroller +import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore +import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher +import wang.yaojia.webterm.wire.HttpTransport +import javax.inject.Inject +import javax.inject.Singleton + +/** + * B4 · Builds a [DeviceEnroller] bound to a user-supplied control-plane URL, over the app's frozen object + * graph — the Android analogue of iOS `makeDeviceEnrollmentFlow` (`DeviceEnrollmentWiring.swift`). + * + * The control-plane base URL is a RUNTIME value (the operator types it on the enrollment screen), so the + * enroller cannot be a plain singleton; this factory is the singleton and mints one enroller per enroll + * attempt with the typed URL, wiring in the app-scoped collaborators: + * - the SAME shared [HttpTransport] / [OkHttpClient] the mTLS transports use, so the renew ride presents + * the current device cert and the pool eviction drops stale connections, + * - the shared [CertStore] + [EnrollmentRecordStore] the running [IdentityRepository] resolves from, and + * - the [IdentityCacheRefresher] (FIX 3) so a freshly enrolled leaf is presented with no restart. + * + * ### Off-`Main` discipline + * [httpTransport] and [sharedClient] are behind `dagger.Lazy` because resolving either builds the shared + * `OkHttpClient` (mTLS/keystore I/O). [create] therefore MUST be called off the UI thread — the enrollment + * ViewModel invokes it inside a `Dispatchers.IO` hop (mirroring `AppEnvironment.warmUp`). + */ +@Singleton +public class EnrollmentFlowFactory @Inject constructor( + private val httpTransport: Lazy, + private val sharedClient: Lazy, + private val certStore: CertStore, + private val recordStore: EnrollmentRecordStore, + private val cacheRefresher: IdentityCacheRefresher, +) { + /** + * Mint a [DeviceEnroller] targeting [controlPlaneBaseUrl] (any trailing slash is trimmed by the + * client). Call OFF `Main` — resolving the lazy transport/client does keystore/TLS I/O. + */ + public fun create(controlPlaneBaseUrl: String): DeviceEnroller = + DeviceEnroller( + client = DeviceEnrollmentClient(controlPlaneBaseUrl, httpTransport.get()), + certStore = certStore, + recordStore = recordStore, + sharedClient = sharedClient.get(), + cacheRefresher = cacheRefresher, + ) +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/EnrollmentViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/EnrollmentViewModelTest.kt new file mode 100644 index 0000000..a6d8061 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/EnrollmentViewModelTest.kt @@ -0,0 +1,213 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError +import wang.yaojia.webterm.clienttls.CertificateSummary +import java.security.KeyStoreException +import java.time.Instant +import java.time.ZoneId + +/** + * [EnrollmentViewModel] / [validControlPlaneUrl] / [classifyEnroll] (B4) — the JVM-tested zero-`.p12` + * enrollment core, mirroring iOS `EnrollmentViewModelTests`. The enroll flow itself is covered headlessly + * in `DeviceEnrollerTest`; these cover the VM's boundary validation, success bookkeeping (summary + + * password-clear), and the error→[EnrollError] mapping — including that the password never lingers after + * an attempt. The keystore / network / Compose shell is device-QA (plan §7); THIS is the pure core. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class EnrollmentViewModelTest { + + /** Records enroll invocations and replays a scripted result/throwable (mirrors iOS EnrollScript). */ + private class EnrollScript(private val result: Result) { + val calls = mutableListOf() + data class Call(val password: String, val subdomain: String, val deviceName: String, val url: String) + + suspend fun run(password: String, subdomain: String, deviceName: String, url: String): CertificateSummary { + calls.add(Call(password, subdomain, deviceName, url)) + return result.getOrThrow() + } + } + + private val fixedNow = Instant.parse("2026-01-01T00:00:00Z") + private val utc = ZoneId.of("UTC") + + private fun summary( + subject: String? = "alice-pixel", + issuer: String? = "webterm-device-ca", + notAfter: Instant? = Instant.parse("2026-10-06T00:00:00Z"), + ) = CertificateSummary(subjectCommonName = subject, issuerCommonName = issuer, notAfter = notAfter) + + private fun newVm( + script: EnrollScript, + installed: CertificateSummary? = null, + controlPlaneUrl: String = "https://cp.terminal.yaojia.wang", + subdomain: String = "alice", + deviceName: String = "Pixel 8", + ): EnrollmentViewModel { + val vm = EnrollmentViewModel( + enrollOperation = { p, s, d, u -> script.run(p, s, d, u) }, + loadSummary = { installed }, + defaultControlPlaneUrl = controlPlaneUrl, + defaultDeviceName = deviceName, + zone = utc, + now = { fixedNow }, + ) + vm.onSubdomainChange(subdomain) + return vm + } + + // ── Success ───────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `a successful enroll sets the summary, flags success and clears the password`() = + runTest(UnconfinedTestDispatcher()) { + val script = EnrollScript(Result.success(summary(subject = "alice-pixel"))) + val vm = newVm(script) + vm.bind(backgroundScope) + vm.onPasswordChange("operator-secret") + + vm.enroll() + + val state = vm.uiState.value + assertEquals(1, script.calls.size) + assertEquals("operator-secret", script.calls.single().password, "the entered password reaches the flow") + assertEquals("alice-pixel", state.summary?.subjectCommonName) + assertTrue(state.didSucceed) + assertNull(state.error) + assertEquals("", state.password, "the password must never linger after a successful enroll") + assertEquals(EnrollPhase.IDLE, state.phase) + } + + // ── Boundary validation (no network) ────────────────────────────────────────────────────────────── + + @Test + fun `a non-https control-plane URL is rejected before any network`() = + runTest(UnconfinedTestDispatcher()) { + val script = EnrollScript(Result.success(summary())) + val vm = newVm(script, controlPlaneUrl = "http://cp.terminal.yaojia.wang") + vm.bind(backgroundScope) + vm.onPasswordChange("pw") + + vm.enroll() + + assertTrue(script.calls.isEmpty(), "an insecure URL must never hit the network") + assertEquals(EnrollError.INVALID_URL, vm.uiState.value.error) + assertFalse(vm.uiState.value.didSucceed) + } + + @Test + fun `missing fields are rejected before any network`() = runTest(UnconfinedTestDispatcher()) { + val script = EnrollScript(Result.success(summary())) + val vm = newVm(script, subdomain = " ") // whitespace-only subdomain + vm.bind(backgroundScope) + vm.onPasswordChange("pw") + + vm.enroll() + + assertTrue(script.calls.isEmpty()) + assertEquals(EnrollError.MISSING_FIELDS, vm.uiState.value.error) + } + + // ── Error → copy mapping (password still cleared) ───────────────────────────────────────────────── + + @Test + fun `a 403 subdomain-not-owned maps to actionable copy and clears the password`() = + runTest(UnconfinedTestDispatcher()) { + val script = EnrollScript(Result.failure(DeviceEnrollmentError.Http(403, "rejected"))) + val vm = newVm(script) + vm.bind(backgroundScope) + vm.onPasswordChange("operator-secret") + + vm.enroll() + + val state = vm.uiState.value + assertEquals(EnrollError.SUBDOMAIN_NOT_OWNED, state.error) + assertFalse(state.didSucceed) + assertEquals("", state.password, "the password is cleared even after a failed enroll") + } + + @Test + fun `a 401 login maps to the bad-credential copy`() = runTest(UnconfinedTestDispatcher()) { + val script = EnrollScript(Result.failure(DeviceEnrollmentError.Http(401, "rejected"))) + val vm = newVm(script) + vm.bind(backgroundScope) + vm.onPasswordChange("wrong") + + vm.enroll() + + assertEquals(EnrollError.BAD_CREDENTIAL, vm.uiState.value.error) + } + + @Test + fun `a hardware keystore fault maps to the keygen copy`() = runTest(UnconfinedTestDispatcher()) { + val script = EnrollScript(Result.failure(KeyStoreException("no StrongBox / TEE"))) + val vm = newVm(script) + vm.bind(backgroundScope) + vm.onPasswordChange("operator-secret") + + vm.enroll() + + assertEquals(EnrollError.KEYGEN, vm.uiState.value.error) + } + + // ── canEnroll gating ────────────────────────────────────────────────────────────────────────────── + + @Test + fun `canEnroll requires every field including a non-empty password`() = + runTest(UnconfinedTestDispatcher()) { + val vm = newVm(EnrollScript(Result.success(summary()))) + vm.bind(backgroundScope) + + assertFalse(vm.uiState.value.canEnroll, "password empty → disabled") + vm.onPasswordChange("pw") + assertTrue(vm.uiState.value.canEnroll) + vm.onSubdomainChange(" ") + assertFalse(vm.uiState.value.canEnroll, "whitespace-only subdomain → disabled") + } + + @Test + fun `clearError dismisses a surfaced error`() = runTest(UnconfinedTestDispatcher()) { + val vm = newVm(newFailing()) + vm.bind(backgroundScope) + vm.onPasswordChange("pw") + vm.enroll() + assertEquals(EnrollError.REJECTED, vm.uiState.value.error) + + vm.clearError() + + assertNull(vm.uiState.value.error) + } + + private fun newFailing() = EnrollScript(Result.failure(DeviceEnrollmentError.Http(400, "rejected"))) + + // ── Pure helpers ────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `validControlPlaneUrl accepts https with a host and rejects everything else`() { + assertEquals("https://cp.terminal.yaojia.wang", validControlPlaneUrl(" https://cp.terminal.yaojia.wang ")) + assertNull(validControlPlaneUrl("http://cp.terminal.yaojia.wang"), "http is rejected") + assertNull(validControlPlaneUrl("https://"), "no host is rejected") + assertNull(validControlPlaneUrl("not a url"), "unparseable is rejected") + assertNull(validControlPlaneUrl(""), "empty is rejected") + } + + @Test + fun `classifyEnroll maps each throwable to its coarse EnrollError`() { + assertEquals(EnrollError.BAD_CREDENTIAL, classifyEnroll(DeviceEnrollmentError.Http(401, null))) + assertEquals(EnrollError.SUBDOMAIN_NOT_OWNED, classifyEnroll(DeviceEnrollmentError.Http(403, null))) + assertEquals(EnrollError.RATE_LIMITED, classifyEnroll(DeviceEnrollmentError.Http(429, null))) + assertEquals(EnrollError.REJECTED, classifyEnroll(DeviceEnrollmentError.Http(400, null))) + assertEquals(EnrollError.ENROLL_FAILED, classifyEnroll(DeviceEnrollmentError.Http(500, null))) + assertEquals(EnrollError.SERVER, classifyEnroll(DeviceEnrollmentError.MalformedResponse)) + assertEquals(EnrollError.ENROLL_FAILED, classifyEnroll(DeviceEnrollmentError.InvalidRequest)) + assertEquals(EnrollError.KEYGEN, classifyEnroll(KeyStoreException("x"))) + assertEquals(EnrollError.UNKNOWN, classifyEnroll(IllegalStateException("x"))) + } +} diff --git a/android/client-tls-android/build.gradle.kts b/android/client-tls-android/build.gradle.kts index 809163d..e5dc88d 100644 --- a/android/client-tls-android/build.gradle.kts +++ b/android/client-tls-android/build.gradle.kts @@ -29,17 +29,34 @@ android { minSdk = 29 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + testOptions { + unitTests { + // The device-enroll orchestration commit logs via android.util.Log — let the JVM unit + // tests stub it (return 0) instead of throwing "not mocked". The security-critical paths + // (commit sequencing, error handling) run on the JVM with a software key double. + isReturnDefaultValues = true + } + } } kotlin { jvmToolchain(17) } +// JVM (local) unit tests use JUnit 5 (matching the pure modules); AGP's testDebug/ReleaseUnitTest +// tasks are `Test` tasks, so opt them into the JUnit Platform. +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { // Pure half: Pkcs12Parse (parse+validate), ClientKeyManagerLogic (alias truth table), // CertificateSummary(Reader). `api` so :app sees the shared ParsedClientIdentity/summary types. // (No :wire-protocol dep — nothing in src/main references wang.yaojia.webterm.wire*.) api(project(":client-tls")) + // B4 device-enroll: the pure CSR encoder + login/enroll/renew client + HttpTransport seam live in + // :api-client (JVM-unit-tested); the framework HardwareBackedKey/DeviceEnroller drive them. + implementation(project(":api-client")) implementation(libs.tink.android) implementation(libs.okhttp) // Mutex serializes the two-store rotation commit (single-commit invariant, A11). @@ -50,4 +67,10 @@ dependencies { androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.kotlinx.coroutines.core) // runBlocking for suspend mutators + + // Local JVM unit tests (src/test) — the DeviceEnroller enroll/commit orchestration driven with a + // software P-256 key double + the shared FakeHttpTransport (no emulator, no AndroidKeyStore). + testImplementation(project(":test-support")) + testImplementation(libs.bundles.unit.test) + testRuntimeOnly(libs.junit.platform.launcher) } diff --git a/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/HardwareBackedKeyTest.kt b/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/HardwareBackedKeyTest.kt new file mode 100644 index 0000000..f76af67 --- /dev/null +++ b/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/HardwareBackedKeyTest.kt @@ -0,0 +1,138 @@ +package wang.yaojia.webterm.tlsandroid + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.security.Signature +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.api.enroll.CertificateSigningRequest + +/** + * B4 · Instrumented (real AndroidKeyStore — NOT Robolectric, plan §7) proof that the generated + * device key is hardware-backed, NON-EXPORTABLE, and produces a self-signed P-256 CSR the + * control-plane accepts. COMPILES in CI here; RUNS on a device/emulator during device QA + * (StrongBox availability is device-dependent — [HardwareKeyStore.generate] falls back to the TEE). + */ +@RunWith(AndroidJUnit4::class) +class HardwareBackedKeyTest { + private val alias = "test-device-enroll-key" + + @Before + fun clean() = HardwareKeyStore.delete(alias) + + @After + fun tearDown() = HardwareKeyStore.delete(alias) + + @Test + fun generate_producesA65ByteX963PublicPoint() { + val key = HardwareKeyStore.generate(alias) + val point = key.publicKeyX963() + assertEquals(65, point.size) + assertEquals(0x04, point[0].toInt() and 0xFF) + } + + @Test + fun generatedKeyIsNonExportable() { + HardwareKeyStore.generate(alias) + val loaded = HardwareKeyStore.load(alias) + assertNotNull(loaded) + // AndroidKeyStore private keys have no exportable encoding — the material never leaves HW. + assertNull("AndroidKeyStore key must expose no encoded form", loaded!!.keyHandle.encoded) + } + + @Test + fun csrSignedByHardwareKeySelfVerifies() { + val key = HardwareKeyStore.generate(alias) + + val der = CertificateSigningRequest.der("t1-android", key) + + // Re-parse the CertificationRequestInfo + signature and verify with the embedded public key. + val outer = TestDer.read(der, 0)!! + val parts = TestDer.children(der, outer) + val info = der.copyOfRange(parts[0].start, parts[0].end) + val bitString = parts[2] + val signature = der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd) + + // Rebuild a JCA public key from the X9.63 point to run the same crypto check the server does. + val point = key.publicKeyX963() + val pub = X963PublicKeys.p256(point) + val ok = Signature.getInstance("SHA256withECDSA").apply { + initVerify(pub) + update(info) + }.verify(signature) + assertTrue("hardware-signed CSR must self-verify", ok) + } + + @Test + fun loadAfterGenerateReturnsAKeyWithTheSamePublicPoint() { + val generated = HardwareKeyStore.generate(alias) + val reloaded = HardwareKeyStore.load(alias) + assertNotNull(reloaded) + assertArrayEquals(generated.publicKeyX963(), reloaded!!.publicKeyX963()) + } + + @Test + fun loadReturnsNullWhenNoKeyExists() { + assertNull(HardwareKeyStore.load("absent-alias-xyz")) + } +} + +/** Reconstruct a P-256 public key from an X9.63 uncompressed point, for on-device signature checks. */ +private object X963PublicKeys { + fun p256(point: ByteArray): java.security.PublicKey { + val params = java.security.AlgorithmParameters.getInstance("EC").apply { + init(java.security.spec.ECGenParameterSpec("secp256r1")) + } + val spec = params.getParameterSpec(java.security.spec.ECParameterSpec::class.java) + val x = java.math.BigInteger(1, point.copyOfRange(1, 33)) + val y = java.math.BigInteger(1, point.copyOfRange(33, 65)) + val pubSpec = java.security.spec.ECPublicKeySpec(java.security.spec.ECPoint(x, y), spec) + return java.security.KeyFactory.getInstance("EC").generatePublic(pubSpec) + } +} + +/** A throwaway canonical-DER reader for structural assertions (device-side mirror of the JVM test). */ +private object TestDer { + data class Element(val tag: Int, val start: Int, val valueStart: Int, val valueEnd: Int) { + val end: Int get() = valueEnd + } + + fun read(bytes: ByteArray, start: Int): Element? { + if (start < 0 || start + 1 >= bytes.size) return null + val tag = bytes[start].toInt() and 0xFF + var index = start + 1 + val first = bytes[index].toInt() and 0xFF + index += 1 + var length = 0 + if (first and 0x80 == 0) { + length = first + } else { + val count = first and 0x7F + if (count == 0 || count > 4 || index + count > bytes.size) return null + repeat(count) { + length = (length shl 8) or (bytes[index].toInt() and 0xFF) + index += 1 + } + } + val valueEnd = index + length + if (valueEnd > bytes.size) return null + return Element(tag, start, index, valueEnd) + } + + fun children(bytes: ByteArray, parent: Element): List { + val elements = mutableListOf() + var index = parent.valueStart + while (index < parent.valueEnd) { + val element = read(bytes, index) ?: break + elements.add(element) + index = element.valueEnd + } + return elements + } +} diff --git a/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepositoryTest.kt b/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepositoryTest.kt index 6830306..810c83d 100644 --- a/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepositoryTest.kt +++ b/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepositoryTest.kt @@ -100,6 +100,31 @@ class IdentityRepositoryTest { assertEquals(importer.primarySlot, certStore.load()?.keyStoreAlias) } + /** + * FIX 3 (cache freshness): a device cert committed OUT OF BAND of a running repository (the zero-`.p12` + * [DeviceEnroller] writes the leaf straight into the shared [CertStore] + AndroidKeyStore) is picked up + * by [AndroidIdentityRepository.refreshFromStore] WITHOUT a process restart — the cached "no identity" + * flips to the freshly-committed leaf and is presented on the next handshake. + */ + @Test + fun refreshFromStore_publishesAnOutOfBandCommittedIdentity_withoutRestart() = runBlocking { + val running = newRepository() + // Touch it while nothing is installed — caches the (null) initial identity. + assertFalse(running.hasInstalledIdentity()) + + // Simulate DeviceEnroller committing an identity out of band (a second repo over the SAME stores). + newRepository().importIdentity(Fixtures.leafP12(), Fixtures.PASSPHRASE) + + // The running repo still shows its stale cache (no restart yet). + assertFalse("stale cache still reports no identity before a refresh", running.hasInstalledIdentity()) + + running.refreshFromStore() + + // The refresh re-read the committed live-pointer → the enrolled leaf is now live. + assertTrue("refreshFromStore must publish the out-of-band committed identity", running.hasInstalledIdentity()) + assertEquals(Fixtures.LEAF_SUBJECT_CN, running.currentSummary()?.subjectCommonName) + } + @Test fun rotateThenRemove_evictsPooledConnections_andClearsIdentity() = runBlocking { val repository = newRepository() diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt new file mode 100644 index 0000000..6230355 --- /dev/null +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt @@ -0,0 +1,155 @@ +package wang.yaojia.webterm.tlsandroid + +import android.util.Log +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.OkHttpClient +import wang.yaojia.webterm.api.enroll.CertificateSigningRequest +import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient +import wang.yaojia.webterm.api.enroll.EnrollmentResult +import wang.yaojia.webterm.clienttls.CertificateSummary +import wang.yaojia.webterm.clienttls.CertificateSummaryReader + +/** + * B4 · The Android device-enroll orchestrator — the `.p12`-free path that mirrors iOS + * `KeychainClientIdentityStore.enroll/renew`. It composes the five B4 pieces: + * + * 1. generate a NON-EXPORTABLE hardware key ([HardwareKeyStore]: StrongBox → TEE), + * 2. self-sign a P-256 PKCS#10 CSR with it ([CertificateSigningRequest], `:api-client`), + * 3. run the login → `POST /device/enroll` flow ([DeviceEnrollmentClient], `:api-client`), + * 4. store the returned leaf + issuer chain into the SAME [CertStore] + AndroidKeyStore slot the + * existing [AndroidIdentityRepository] resolves from — so it is presented on the EXISTING + * re-reading `X509KeyManager` mTLS path with no change to that module, and + * 5. expose a silent [renew] against `/device/:id/renew` using the SAME hardware key. + * + * The mutating methods are serialized by a [Mutex] so an enroll and a rotation can never interleave + * the two-store commit (cert live-pointer + enrollment record). + * + * ### The commit + * The cert-store save is THE durable live-pointer flip (identical to the import/rotation path). It is + * written LAST, after the enrollment record, so a successful cert-store save always means the mTLS + * identity is fully live; the pool is then evicted so the next handshake presents the new leaf. + */ +public class DeviceEnroller( + private val client: DeviceEnrollmentClient, + private val certStore: CertStore, + private val recordStore: EnrollmentRecordStore, + private val sharedClient: OkHttpClient, + private val keyAlias: String = AndroidKeyStoreImporter.DEFAULT_ALIAS, + private val keyProvider: DeviceKeyProvider = HardwareDeviceKeyProvider, + // FIX 3 (cache freshness): the in-memory identity cache (AndroidIdentityRepository) is refreshed + // AFTER each commit so a mid-session enroll/renew is presented on the NEXT handshake with no + // process restart. Optional so the JVM orchestration tests can construct the enroller without it. + private val cacheRefresher: IdentityCacheRefresher? = null, +) { + private val commitMutex = Mutex() + + /** Raised when a state-changing enroll/renew precondition is not met. Never leaks a secret. */ + public class EnrollmentStateException(message: String) : Exception(message) + + /** + * One-time enrollment: login (operator password → short-lived `device:enroll` bearer) → generate + * a non-exportable hardware key → CSR → `POST /device/enroll` → store the leaf + present it. + * Returns the installed leaf's display summary. The bearer is held only for this call, never + * persisted or logged. + */ + public suspend fun enroll( + password: String, + subdomain: String, + deviceName: String, + ): CertificateSummary = commitMutex.withLock { + val login = client.login(password) + // Generate the hardware key ONLY after a successful login, so a rejected credential never + // burns a fresh key slot; overwrites any stale key at the alias. + val key = keyProvider.generate(keyAlias) + try { + val csr = CertificateSigningRequest.der(deviceName, key) + val result = client.enroll(login.enrollToken, csr, subdomain, deviceName) + commitIdentity(result, deviceName, key.alias) + summaryOf(result) + } catch (e: Exception) { + // The enroll failed AFTER keygen: drop the orphan key so a retry starts clean and no + // unreferenced key lingers in secure hardware. + runCatching { keyProvider.delete(key.alias) } + throw e + } + } + + /** + * Silent rotation: re-CSR from the SAME hardware key and replace the leaf via + * `POST /device/:id/renew`. The renew endpoint authenticates by the CURRENT device certificate over + * mTLS (the presented client cert), so [bearerToken] is OPTIONAL and defaults to absent — the + * production caller passes none (mirrors iOS, which renews with `bearerToken: nil`). The seam still + * accepts a bearer for a hypothetical bearer-authenticated renew, but bakes in no credential policy; + * it only re-signs and re-commits. Throws [EnrollmentStateException] if there is nothing enrolled to + * renew or the key is gone. + */ + public suspend fun renew(bearerToken: String? = null): CertificateSummary = commitMutex.withLock { + val record = recordStore.load() + ?: throw EnrollmentStateException("no enrollment record — nothing to renew") + val key = keyProvider.load(record.keyStoreAlias) + ?: throw EnrollmentStateException("device key missing — a fresh enroll is required") + val csr = CertificateSigningRequest.der(record.deviceName, key) + val result = client.renew(record.deviceId, csr, bearerToken) + commitIdentity(result, record.deviceName, record.keyStoreAlias) + summaryOf(result) + } + + /** Remove the enrolled identity: cert pointer, enrollment record, and the hardware key. */ + public suspend fun remove(): Unit = commitMutex.withLock { + certStore.clear() + recordStore.clear() + keyProvider.delete(keyAlias) + sharedClient.connectionPool.evictAll() + } + + /** + * Persist the enrollment record (deviceId → renew), THEN commit the cert live-pointer (the mTLS + * flip), THEN evict pooled/resumed connections so the next handshake presents the new leaf via + * the existing re-reading `X509KeyManager`. The key already lives in AndroidKeyStore at [alias]; + * the private key never enters storage. + */ + private fun commitIdentity(result: EnrollmentResult, deviceName: String, alias: String) { + val leaf = parseCertificate(result.certificate) + val issuers = result.caChain.map { parseCertificate(it) } + + recordStore.save( + EnrollmentRecord( + deviceId = result.deviceId, + deviceName = deviceName, + keyStoreAlias = alias, + renewAfterEpochSeconds = result.renewAfter?.epochSecond ?: 0L, + ), + ) + certStore.save( + StoredIdentityMetadata( + alias = alias, + keyAlgorithm = KEY_ALGORITHM_EC, + keyStoreAlias = alias, + certificateChain = listOf(leaf) + issuers, + ), + ) + sharedClient.connectionPool.evictAll() + // FIX 3: re-read the just-committed live-pointer into the in-memory identity cache so the + // newly enrolled/renewed leaf is presented on the NEXT mTLS handshake without a restart. Done + // AFTER the durable commit + pool eviction so the cache can never publish an un-committed leaf. + cacheRefresher?.refreshFromStore() + Log.i(TAG, "Device identity enrolled/renewed and committed for alias '$alias'") + } + + private fun summaryOf(result: EnrollmentResult): CertificateSummary = + CertificateSummaryReader.summarize(parseCertificate(result.certificate)) + + private fun parseCertificate(der: ByteArray): X509Certificate = + CertificateFactory.getInstance(X509).generateCertificate(der.inputStream()) as X509Certificate + + private companion object { + const val TAG = "DeviceEnroller" + const val X509 = "X.509" + + /** AndroidKeyStore EC keys report algorithm "EC" — matched by `ClientKeyManagerLogic`. */ + const val KEY_ALGORITHM_EC = "EC" + } +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceKeyProvider.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceKeyProvider.kt new file mode 100644 index 0000000..e2e1b9b --- /dev/null +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceKeyProvider.kt @@ -0,0 +1,33 @@ +package wang.yaojia.webterm.tlsandroid + +/** + * B4 · A seam over the three non-exportable hardware-key operations [DeviceEnroller]'s + * enroll/renew orchestration needs. Production wires the real AndroidKeyStore-backed + * [HardwareKeyStore] (StrongBox → TEE); a JVM unit test wires a software P-256 double, so the + * enroll/commit orchestration (request shaping, error handling, the two-store commit sequencing) + * can be exercised without an emulator. NOTHING about the hardware-key policy leaks through this + * seam beyond generate/load/delete — the key stays non-exportable in the real implementation. + */ +public interface DeviceKeyProvider { + /** Generate a fresh non-exportable key at [alias], overwriting any prior entry there. */ + public fun generate(alias: String): HardwareBackedKey + + /** Load a previously-generated key by [alias], or null if no entry exists (pre-enroll state). */ + public fun load(alias: String): HardwareBackedKey? + + /** Delete the key entry at [alias]. Idempotent (a missing alias is a no-op). */ + public fun delete(alias: String) +} + +/** + * The production [DeviceKeyProvider] — a thin delegate to the AndroidKeyStore-backed + * [HardwareKeyStore]. Kept as a stateless object so it can be the [DeviceEnroller] constructor + * default without any wiring. + */ +public object HardwareDeviceKeyProvider : DeviceKeyProvider { + override fun generate(alias: String): HardwareBackedKey = HardwareKeyStore.generate(alias) + + override fun load(alias: String): HardwareBackedKey? = HardwareKeyStore.load(alias) + + override fun delete(alias: String): Unit = HardwareKeyStore.delete(alias) +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt new file mode 100644 index 0000000..d027574 --- /dev/null +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt @@ -0,0 +1,143 @@ +package wang.yaojia.webterm.tlsandroid + +import android.content.Context +import android.content.SharedPreferences +import android.util.Base64 +import com.google.crypto.tink.Aead +import com.google.crypto.tink.KeyTemplates +import com.google.crypto.tink.RegistryConfiguration +import com.google.crypto.tink.aead.AeadConfig +import com.google.crypto.tink.integration.android.AndroidKeysetManager +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream + +/** + * B4 · The auxiliary enrollment record needed to drive silent rotation: the server-minted + * [deviceId] (the `/device/:id/renew` path segment), the [deviceName] re-used as the renew CSR + * subject CN, the AndroidKeyStore [keyStoreAlias] holding the SAME non-exportable key to re-sign + * with, and [renewAfterEpochSeconds] (0 = unknown) for the rotation scheduler. + * + * This is deliberately SEPARATE from [StoredIdentityMetadata] (the mTLS live-pointer): the cert + * identity is what the handshake presents; this record only exists so renew can find the device and + * its key. The private key is never here — it stays non-exportable in AndroidKeyStore. + */ +public data class EnrollmentRecord( + val deviceId: String, + val deviceName: String, + val keyStoreAlias: String, + val renewAfterEpochSeconds: Long, +) { + init { + require(deviceId.isNotBlank()) { "deviceId must not be blank" } + require(keyStoreAlias.isNotBlank()) { "keyStoreAlias must not be blank" } + } +} + +/** Length-prefixed binary codec for [EnrollmentRecord] (KISS — three UTF strings + one long). */ +public object EnrollmentRecordCodec { + public fun encode(record: EnrollmentRecord): ByteArray { + val out = ByteArrayOutputStream() + DataOutputStream(out).use { data -> + data.writeUTF(record.deviceId) + data.writeUTF(record.deviceName) + data.writeUTF(record.keyStoreAlias) + data.writeLong(record.renewAfterEpochSeconds) + } + return out.toByteArray() + } + + /** Decode [bytes]; any structural failure → [CorruptStoredIdentityException]. */ + public fun decode(bytes: ByteArray): EnrollmentRecord = + try { + DataInputStream(ByteArrayInputStream(bytes)).use { data -> + EnrollmentRecord( + deviceId = data.readUTF(), + deviceName = data.readUTF(), + keyStoreAlias = data.readUTF(), + renewAfterEpochSeconds = data.readLong(), + ) + } + } catch (e: Exception) { + throw CorruptStoredIdentityException("Stored enrollment record was truncated/malformed", e) + } +} + +/** + * Storage contract for the [EnrollmentRecord] — repository pattern so [DeviceEnroller] depends on + * the operation set and a fault/blank can be injected in tests. Idempotent [clear]. + */ +public interface EnrollmentRecordStore { + public fun save(record: EnrollmentRecord) + + public fun load(): EnrollmentRecord? + + public fun clear() +} + +/** + * Tink-AEAD-encrypted [EnrollmentRecordStore] over an app-private `SharedPreferences` file, mirroring + * [TinkCertStore]'s custody model (AndroidKeystore-wrapped master key; uninstall-wiped; useless off + * this device). Kept in its own key/file namespace so it never collides with the cert live-pointer. + */ +public class TinkEnrollmentRecordStore( + context: Context, + private val keysetName: String = DEFAULT_KEYSET_NAME, + private val prefFileName: String = DEFAULT_PREF_FILE, + private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI, +) : EnrollmentRecordStore { + private val appContext: Context = context.applicationContext + private val aead: Aead by lazy { buildAead() } + + override fun save(record: EnrollmentRecord) { + val ciphertext = aead.encrypt(EnrollmentRecordCodec.encode(record), ASSOCIATED_DATA) + val committed = prefs().edit() + .putString(BLOB_KEY, Base64.encodeToString(ciphertext, Base64.NO_WRAP)) + .commit() + if (!committed) throw java.io.IOException("Failed to durably persist the device enrollment record") + } + + override fun load(): EnrollmentRecord? { + val encoded = prefs().getString(BLOB_KEY, null) ?: return null + val ciphertext = try { + Base64.decode(encoded, Base64.NO_WRAP) + } catch (e: IllegalArgumentException) { + throw CorruptStoredIdentityException("Enrollment record blob was not valid base64", e) + } + val plaintext = try { + aead.decrypt(ciphertext, ASSOCIATED_DATA) + } catch (e: java.security.GeneralSecurityException) { + throw CorruptStoredIdentityException("Enrollment record blob failed AEAD decryption", e) + } + return EnrollmentRecordCodec.decode(plaintext) + } + + override fun clear() { + val committed = prefs().edit().remove(BLOB_KEY).commit() + if (!committed) throw java.io.IOException("Failed to durably clear the device enrollment record") + } + + private fun buildAead(): Aead { + AeadConfig.register() + val keysetHandle = AndroidKeysetManager.Builder() + .withSharedPref(appContext, keysetName, prefFileName) + .withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE)) + .withMasterKeyUri(masterKeyUri) + .build() + .keysetHandle + return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java) + } + + private fun prefs(): SharedPreferences = + appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE) + + public companion object { + private const val DEFAULT_KEYSET_NAME = "webterm_enroll_keyset" + private const val DEFAULT_PREF_FILE = "webterm_enroll_record_prefs" + private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_enroll_master_key" + private const val AEAD_KEY_TEMPLATE = "AES256_GCM" + private const val BLOB_KEY = "enrollment_record_blob" + private val ASSOCIATED_DATA: ByteArray = "webterm.client-tls.enrollment-record".toByteArray() + } +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/HardwareBackedKey.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/HardwareBackedKey.kt new file mode 100644 index 0000000..2f91115 --- /dev/null +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/HardwareBackedKey.kt @@ -0,0 +1,122 @@ +package wang.yaojia.webterm.tlsandroid + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException +import android.util.Log +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.Signature +import java.security.cert.X509Certificate +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import wang.yaojia.webterm.api.enroll.CsrSigner +import wang.yaojia.webterm.api.enroll.EcPointEncoding + +/** + * B4 · A P-256 signing key that lives ENTIRELY inside AndroidKeyStore and is NON-EXPORTABLE by + * construction (AndroidKeyStore has no key-material getter). It is the Android analogue of the iOS + * `SecureEnclaveKey`: `sign` runs inside secure hardware (StrongBox → TEE) and drives the same + * `Signature("SHA256withECDSA")` path the JVM-unit-test software key uses, so [CsrSigner] callers + * (`CertificateSigningRequest`) are exercised identically. + * + * The wrapped [privateKey] is the opaque AndroidKeyStore handle — presented to the re-reading + * `X509KeyManager` for the TLS `CertificateVerify` and never exported. [publicKey] is only used to + * emit the CSR's `SubjectPublicKeyInfo`. + */ +public class HardwareBackedKey internal constructor( + public val alias: String, + private val privateKey: PrivateKey, + private val publicKey: ECPublicKey, +) : CsrSigner { + + override fun publicKeyX963(): ByteArray = EcPointEncoding.x963(publicKey) + + override fun sign(message: ByteArray): ByteArray = + Signature.getInstance(SIGNATURE_ALGORITHM).apply { + initSign(privateKey) + update(message) + }.sign() + + /** The opaque, non-exportable AndroidKeyStore private-key handle presented on the mTLS path. */ + public val keyHandle: PrivateKey get() = privateKey + + public companion object { + private const val SIGNATURE_ALGORITHM = "SHA256withECDSA" + } +} + +/** + * Creates / loads / deletes the device's non-exportable P-256 key in AndroidKeyStore. + * + * Generation prefers **StrongBox** (dedicated secure element) and falls back to the **TEE** when the + * device has no StrongBox — the security posture (non-exportable, hardware-backed, silent-signing) + * is identical either way; StrongBox is a hardening bonus, not a requirement. The key is + * `PURPOSE_SIGN` only with a broad digest set so TLS 1.2/1.3 signature negotiation for the client + * `CertificateVerify` works, and NO user-authentication is required so silent enroll/renew never + * blocks on a biometric prompt. + */ +public object HardwareKeyStore { + private const val TAG = "HardwareKeyStore" + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val CURVE = "secp256r1" + + /** + * Generate a fresh non-exportable P-256 key at [alias], overwriting any prior entry there. + * StrongBox-backed when available, else TEE-backed. Throws the underlying keystore exception if + * BOTH paths fail (never returns a half-generated key). + */ + public fun generate(alias: String): HardwareBackedKey { + val keyPair = try { + generateKeyPair(alias, strongBox = true) + } catch (_: StrongBoxUnavailableException) { + Log.i(TAG, "StrongBox unavailable; generating a TEE-backed device key (non-exportable)") + delete(alias) // clear any partial StrongBox entry before the TEE retry + generateKeyPair(alias, strongBox = false) + } + return HardwareBackedKey(alias, keyPair.private, keyPair.public as ECPublicKey) + } + + /** + * Load a previously-generated key by [alias] (renew path, after relaunch). The public key is + * recovered from the self-signed placeholder certificate AndroidKeyStore stored at generation. + * Returns null if no key entry exists (the normal pre-enroll state). + */ + public fun load(alias: String): HardwareBackedKey? { + val keyStore = androidKeyStore() + val privateKey = keyStore.getKey(alias, null) as? PrivateKey ?: return null + val publicKey = (keyStore.getCertificate(alias) as? X509Certificate)?.publicKey as? ECPublicKey + ?: return null + return HardwareBackedKey(alias, privateKey, publicKey) + } + + /** Delete the key entry at [alias]. Idempotent (a missing alias is a no-op). */ + public fun delete(alias: String) { + val keyStore = androidKeyStore() + if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias) + } + + /** Cheap existence check (does NOT read key material). */ + public fun exists(alias: String): Boolean = androidKeyStore().containsAlias(alias) + + private fun generateKeyPair(alias: String, strongBox: Boolean): KeyPair { + val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN) + .setAlgorithmParameterSpec(ECGenParameterSpec(CURVE)) + .setDigests( + KeyProperties.DIGEST_NONE, + KeyProperties.DIGEST_SHA256, + KeyProperties.DIGEST_SHA384, + KeyProperties.DIGEST_SHA512, + ) + .setIsStrongBoxBacked(strongBox) + .build() + val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, ANDROID_KEYSTORE) + generator.initialize(spec) + return generator.generateKeyPair() + } + + private fun androidKeyStore(): KeyStore = + KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepository.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepository.kt index c9c90ed..d5450c7 100644 --- a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepository.kt +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepository.kt @@ -56,6 +56,18 @@ public interface IdentityRepository { public suspend fun remove() } +/** + * B4 · A narrow seam the zero-`.p12` enroll/renew commit ([DeviceEnroller]) fires so an in-memory + * identity cache re-reads the freshly-committed live-pointer and presents the new leaf on the NEXT + * mTLS handshake WITHOUT a process restart. Kept separate from [IdentityRepository] so the enroller + * depends only on this one operation (it never needs the import/rotate/remove surface). The production + * implementation is [AndroidIdentityRepository]; a JVM test uses a recording double. + */ +public fun interface IdentityCacheRefresher { + /** Reload the persisted live identity into the in-memory cache and drop stale pooled connections. */ + public fun refreshFromStore() +} + /** * Default [IdentityRepository] over [AndroidKeyStoreImporter] (key home) + [CertStore] (encrypted * live-pointer at rest) + the shared [OkHttpClient] (for `connectionPool.evictAll()`). @@ -90,7 +102,7 @@ public class AndroidIdentityRepository( private val importer: AndroidKeyStoreImporter, private val certStore: CertStore, private val sharedClient: OkHttpClient, -) : IdentityRepository { +) : IdentityRepository, IdentityCacheRefresher { /** The live identity as tracked by the repo: the KeyManager view + which physical slot holds the key. */ private class LiveIdentity(val installed: InstalledIdentity, val keyStoreAlias: String) @@ -150,6 +162,20 @@ public class AndroidIdentityRepository( sharedClient.connectionPool.evictAll() } + /** + * FIX 3 (cache freshness) · Re-read the persisted live-pointer into the in-memory cache. Used when a + * device certificate is committed OUT OF BAND of this repository — the zero-`.p12` [DeviceEnroller] + * writes the leaf straight into the shared [CertStore] + AndroidKeyStore, so without this the running + * repo would keep presenting its cached (pre-enroll) identity until process restart. Publishing the + * freshly-loaded snapshot as [liveOverride] and evicting pooled connections makes the enrolled leaf + * present on the NEXT handshake. Reloading to `null` (a fault/absent pointer) is a valid outcome and + * simply reports "no identity". Not `suspend` — the enroller already runs this off the UI thread. + */ + override fun refreshFromStore() { + liveOverride = Box(loadInstalledOrNull()) + sharedClient.connectionPool.evictAll() + } + /** * Single-commit install/rotation (see the class KDoc). Validation throws before any mutation; * the new key is imported into the non-live slot; the COMMIT is one atomic [CertStore.save] that diff --git a/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt new file mode 100644 index 0000000..f999228 --- /dev/null +++ b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt @@ -0,0 +1,313 @@ +package wang.yaojia.webterm.tlsandroid + +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient +import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.HttpMethod +import java.security.KeyPairGenerator +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec + +/** + * B4 · JVM unit coverage for the [DeviceEnroller] enroll/renew ORCHESTRATION — the layer that runs + * the security-critical two-store commit. Driven with a software P-256 key ([DeviceKeyProvider] + * double) + the shared [FakeHttpTransport], so request shaping, error handling, and — most + * importantly — the commit SEQUENCING run without an emulator or a real AndroidKeyStore. + * + * The security-critical invariant under test: the enrollment record is persisted BEFORE the cert + * live-pointer flip (the mTLS commit), so a successful cert-store save always means the identity is + * fully live (see [DeviceEnroller.commitIdentity]). + */ +class DeviceEnrollerTest { + private companion object { + const val BASE = "https://cp.terminal.yaojia.wang" + const val ALIAS = "test-device-key" + + // Real self-signed P-256 X.509 certs (base64 DER) so commitIdentity's CertificateFactory / + // CertificateSummaryReader parse them exactly as they parse a server-issued leaf. + const val LEAF_CN = "t1-device" + const val CA_CN = "webterm-device-ca" + const val LEAF_B64 = + "MIIBfzCCASWgAwIBAgIUH+MotJdtckTE7470KQz73GPZa+IwCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJdDEt" + + "ZGV2aWNlMCAXDTI2MDcxODExMjExMVoYDzIxMjYwNjI0MTEyMTExWjAUMRIwEAYDVQQDDAl0MS1kZXZpY2Uw" + + "WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQLKEwBsNSMTDfKsdr0qtKUtZCcglWICSMJYRowgIN546ctWw+h" + + "cXXeZ7ru9F198rt3k2Z4Wesf0n3tUm9jdn/Oo1MwUTAdBgNVHQ4EFgQU1+o809OaRKV3p/P5dhY5yAdOrr0w" + + "HwYDVR0jBBgwFoAU1+o809OaRKV3p/P5dhY5yAdOrr0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNI" + + "ADBFAiEAotIxEXaCEp2rtEG6KLOtmJYS6Jc/JaJFERGRH4Q/qsMCIB4Rkb06AB7pQUsAHLj81BXcYEd04GY" + + "cdoleWDlqcMKU" + const val CA_B64 = + "MIIBjzCCATWgAwIBAgIUXGwe1gOYBewwVZQoVj1IgiirwnUwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwRd2Vi" + + "dGVybS1kZXZpY2UtY2EwIBcNMjYwNzE4MTEyMTExWhgPMjEyNjA2MjQxMTIxMTFaMBwxGjAYBgNVBAMMEXdl" + + "YnRlcm0tZGV2aWNlLWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkwVx9McuEN+rTZwYfsYl8YPhpyWt" + + "e8PT06OpifVsIdCyDH3bPoENOsPJf8mjRqkgoLSHgetuUf2T2Ot28qRiuaNTMFEwHQYDVR0OBBYEFGkPHz9w" + + "4FVyZRgo8g1PO8F/v6ggMB8GA1UdIwQYMBaAFGkPHz9w4FVyZRgo8g1PO8F/v6ggMA8GA1UdEwEB/wQFMAMB" + + "Af8wCgYIKoZIzj0EAwIDSAAwRQIhAJlUm4M4K2fHMOtip2Hs5LxvS0T7RJwUbflz5wHGQiyJAiAHXp1oNUkQ" + + "YloHuEAg+kngzA33m52aWtublai4L+eybg==" + + fun loginBody(): ByteArray = + """{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray() + + fun enrollBody(deviceId: String = "dev-1"): ByteArray = + """ + {"deviceId":"$deviceId","cert":"$LEAF_B64","caChain":["$CA_B64"], + "notBefore":"2026-07-08T00:00:00.000Z","notAfter":"2026-10-06T00:00:00.000Z", + "renewAfter":"2026-09-05T00:00:00.000Z"} + """.trimIndent().toByteArray() + + fun softwareKey(alias: String): HardwareBackedKey { + val kpg = KeyPairGenerator.getInstance("EC") + kpg.initialize(ECGenParameterSpec("secp256r1")) + val kp = kpg.generateKeyPair() + return HardwareBackedKey(alias, kp.private, kp.public as ECPublicKey) + } + } + + private val events = mutableListOf() + private val transport = FakeHttpTransport() + private val certStore = RecordingCertStore(events) + private val recordStore = RecordingRecordStore(events) + private val keyProvider = RecordingKeyProvider(events) + private val refresher = RecordingRefresher(events) + + private fun enroller(): DeviceEnroller = + DeviceEnroller( + client = DeviceEnrollmentClient(BASE, transport), + certStore = certStore, + recordStore = recordStore, + sharedClient = OkHttpClient(), + keyAlias = ALIAS, + keyProvider = keyProvider, + cacheRefresher = refresher, + ) + + // ── enroll: commit sequencing (the security-critical invariant) ──────────────────────────── + + @Test + fun enrollPersistsTheRecordBeforeTheCertLivePointerFlip() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody()) + transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody()) + + val summary = enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel") + + // Record.save strictly precedes cert.save — the mTLS pointer flip is written LAST. + assertTrue(events.contains("record.save") && events.contains("cert.save")) + assertTrue( + events.indexOf("record.save") < events.indexOf("cert.save"), + "the enrollment record must be committed BEFORE the cert live-pointer flip", + ) + // Both stores received the issued identity; the stored chain is leaf + issuer (from caChain). + assertEquals("dev-1", recordStore.saved!!.deviceId) + assertEquals(ALIAS, recordStore.saved!!.keyStoreAlias) + val chain = certStore.saved!!.certificateChain + assertEquals(2, chain.size, "stored chain = leaf + one caChain issuer") + assertTrue(chain[0].subjectX500Principal.name.contains(LEAF_CN), "chain[0] is the leaf") + assertTrue(chain[1].subjectX500Principal.name.contains(CA_CN), "chain[1] is the device-CA issuer") + // The install summary is read off the leaf via the production CertificateSummaryReader. + assertEquals(LEAF_CN, summary.subjectCommonName) + } + + @Test + fun enrollRefreshesTheIdentityCacheAfterTheCommit() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody()) + transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody()) + + enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel") + + // FIX 3: the in-memory identity cache is refreshed AFTER the durable cert live-pointer flip, so + // the newly enrolled leaf is presented on the next handshake with no restart. + assertTrue(events.contains("cache.refresh"), "the identity cache must be refreshed on enroll") + assertTrue( + events.indexOf("cert.save") < events.indexOf("cache.refresh"), + "the cache refresh must run AFTER the cert live-pointer commit (never publish an un-committed leaf)", + ) + } + + @Test + fun enrollShapesTheLoginAndEnrollRequests() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody()) + transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody()) + + enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel") + + val login = transport.recordedRequests[0] + assertEquals("$BASE/auth/login", login.url) + assertTrue(login.body!!.decodeToString().contains("\"password\":\"hunter2\"")) + + val enroll = transport.recordedRequests[1] + assertEquals("$BASE/device/enroll", enroll.url) + assertEquals("Bearer tok-xyz", enroll.headers["Authorization"], "enroll rides the login bearer") + val enrollBodyStr = enroll.body!!.decodeToString() + assertTrue(enrollBodyStr.contains("\"subdomain\":\"alice\"")) + assertTrue(enrollBodyStr.contains("\"deviceName\":\"Alice Pixel\"")) + } + + // ── enroll: error handling ───────────────────────────────────────────────────────────────── + + @Test + fun enrollDropsTheOrphanKeyWhenEnrollFailsAfterKeygen() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody()) + transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 403, body = """{"error":"rejected"}""".toByteArray()) + + val error = runCatching { + enroller().enroll(password = "hunter2", subdomain = "bob", deviceName = "Bob Pixel") + }.exceptionOrNull() + + assertEquals(DeviceEnrollmentError.Http(403, "rejected"), error) + assertEquals(listOf(ALIAS), keyProvider.generatedAliases, "the key was generated after login") + assertEquals(listOf(ALIAS), keyProvider.deletedAliases, "the orphan key is dropped on enroll failure") + assertNull(recordStore.saved, "no record is committed when enroll fails") + assertNull(certStore.saved, "the cert live-pointer is never flipped when enroll fails") + } + + @Test + fun enrollNeverBurnsAKeyWhenLoginIsRejected() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 401, body = """{"error":"rejected"}""".toByteArray()) + + val error = runCatching { + enroller().enroll(password = "wrong", subdomain = "alice", deviceName = "Alice Pixel") + }.exceptionOrNull() + + assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error) + assertTrue(keyProvider.generatedAliases.isEmpty(), "a rejected credential must not burn a key slot") + assertNull(recordStore.saved) + assertNull(certStore.saved) + } + + // ── renew: preconditions + request shaping + commit ──────────────────────────────────────── + + @Test + fun renewThrowsWhenNothingIsEnrolled() = runTest { + val error = runCatching { enroller().renew() }.exceptionOrNull() + assertTrue(error is DeviceEnroller.EnrollmentStateException) + assertTrue(transport.recordedRequests.isEmpty(), "no network I/O when there is nothing to renew") + } + + @Test + fun renewThrowsWhenTheDeviceKeyIsMissing() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L)) + // keyProvider has no key at ALIAS → load() returns null. + val error = runCatching { enroller().renew() }.exceptionOrNull() + assertTrue(error is DeviceEnroller.EnrollmentStateException) + assertTrue(transport.recordedRequests.isEmpty(), "no renew call when the hardware key is gone") + } + + @Test + fun renewReCsrsFromTheSameKeyOverMtlsWithNoBearerAndACsrOnlyBody() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + transport.queueSuccess(HttpMethod.POST, "$BASE/device/dev-1/renew", 201, body = enrollBody()) + + // FIX 2: production renew passes NO bearer — the endpoint authenticates by the current cert (mTLS). + enroller().renew() + + val renew = transport.recordedRequests.single() + assertEquals("$BASE/device/dev-1/renew", renew.url) + assertNull(renew.headers["Authorization"], "renew authenticates by mTLS — it must send NO Authorization header") + val body = renew.body!!.decodeToString() + // The renew body is {csr}-only — the server's .strict() schema rejects any enroll-only extra. + assertTrue(body.contains("\"csr\":"), "renew sends the fresh CSR") + assertFalse(body.contains("keyAlg"), "renew must not send the enroll-only keyAlg") + assertFalse(body.contains("subdomain"), "renew must not send subdomain") + assertFalse(body.contains("deviceName"), "renew must not send deviceName") + // Same commit sequencing on the rotation path: record before cert, then cache refresh last. + assertTrue(events.indexOf("record.save") < events.indexOf("cert.save")) + assertTrue(events.indexOf("cert.save") < events.indexOf("cache.refresh"), "cache refresh runs after the renew commit") + } + + // ── remove: full teardown ────────────────────────────────────────────────────────────────── + + @Test + fun removeClearsBothStoresAndDeletesTheKey() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + + enroller().remove() + + assertTrue(certStore.cleared) + assertTrue(recordStore.cleared) + assertEquals(listOf(ALIAS), keyProvider.deletedAliases, "the hardware key is deleted on remove") + } + + // ── recording doubles ────────────────────────────────────────────────────────────────────── + + private class RecordingCertStore(private val events: MutableList) : CertStore { + var saved: StoredIdentityMetadata? = null + var cleared = false + + override fun save(metadata: StoredIdentityMetadata) { + saved = metadata + events += "cert.save" + } + + override fun load(): StoredIdentityMetadata? = saved + + override fun clear() { + cleared = true + saved = null + events += "cert.clear" + } + } + + private class RecordingRecordStore(private val events: MutableList) : EnrollmentRecordStore { + var saved: EnrollmentRecord? = null + var cleared = false + private var current: EnrollmentRecord? = null + + fun seed(record: EnrollmentRecord) { + current = record + } + + override fun save(record: EnrollmentRecord) { + saved = record + current = record + events += "record.save" + } + + override fun load(): EnrollmentRecord? = current + + override fun clear() { + cleared = true + current = null + events += "record.clear" + } + } + + private class RecordingRefresher(private val events: MutableList) : IdentityCacheRefresher { + override fun refreshFromStore() { + events += "cache.refresh" + } + } + + private class RecordingKeyProvider(private val events: MutableList) : DeviceKeyProvider { + private val keys = mutableMapOf() + val generatedAliases = mutableListOf() + val deletedAliases = mutableListOf() + + fun seed(alias: String, key: HardwareBackedKey) { + keys[alias] = key + } + + override fun generate(alias: String): HardwareBackedKey { + val key = softwareKey(alias) + keys[alias] = key + generatedAliases += alias + events += "generate:$alias" + return key + } + + override fun load(alias: String): HardwareBackedKey? = keys[alias] + + override fun delete(alias: String) { + keys.remove(alias) + deletedAliases += alias + events += "delete:$alias" + } + } +} 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/api/renew.ts b/control-plane/src/api/renew.ts index 7851967..8d2c5a1 100644 --- a/control-plane/src/api/renew.ts +++ b/control-plane/src/api/renew.ts @@ -171,7 +171,15 @@ export function headerPresentedCert(headerName: string = DEFAULT_CLIENT_CERT_HEA const value = Array.isArray(raw) ? raw[0] : raw if (typeof value !== 'string' || value.length === 0) return null try { - return new Uint8Array(Buffer.from(value, 'base64')) + // The terminator may forward the verified cert as base64 DER, OR as PEM — including nginx's + // header-safe `$ssl_client_escaped_cert` (URL-encoded PEM). Normalize all three to raw DER: + // URL-decode if escaped, then strip PEM armor + whitespace to recover the base64 DER body. + let s = value.includes('%') ? safeDecodeUri(value) : value + if (s.includes('BEGIN CERTIFICATE')) { + s = s.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '') + } + const der = Buffer.from(s, 'base64') + return der.length > 0 ? new Uint8Array(der) : null } catch { return null } @@ -179,6 +187,15 @@ export function headerPresentedCert(headerName: string = DEFAULT_CLIENT_CERT_HEA } } +/** URL-decode `value`, returning it unchanged if it is not valid percent-encoding. */ +function safeDecodeUri(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + /** * Trust-anchor verification of the presented current cert (defense in depth: the mTLS terminator has * already verified it, but this route must not trust a cert the terminator would never have accepted). 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/ca-wiring.ts b/control-plane/src/boot/ca-wiring.ts index 520bf37..9b49bf7 100644 --- a/control-plane/src/boot/ca-wiring.ts +++ b/control-plane/src/boot/ca-wiring.ts @@ -13,8 +13,9 @@ * `x509-assembler` normalizes to DER; both expose the same `sign()` so callers stay KMS-shaped. */ import { createPublicKey, generateKeyPairSync, sign as nodeSign } from 'node:crypto' +import { readFileSync } from 'node:fs' import type { ControlPlaneEnv } from '../env.js' -import { ed25519Sign, generateEd25519, type KeyObject } from '../util/crypto.js' +import { createPrivateKey, ed25519Sign, generateEd25519, type KeyObject } from '../util/crypto.js' /** Wraps KMS `sign()`; no raw key ever crosses this boundary. */ export interface CaSigner { @@ -98,3 +99,56 @@ export function inProcessP256CaSigner(privateKey?: KeyObject): CaSigner { }, } } + +/** Prefix marking a KMS key ref that is actually an on-disk PEM key path (`file:`). */ +const FILE_KEY_REF_PREFIX = 'file:' +/** OpenSSL's name for the P-256 (secp256r1) curve — the ONLY curve the native-tunnel CAs use. */ +const P256_CURVE = 'prime256v1' + +/** Build a `file:` KMS key ref from an on-disk native-CA private key path. */ +export function fileKeyRef(path: string): string { + return `${FILE_KEY_REF_PREFIX}${path}` +} + +/** + * Load a PKCS#8 PEM P-256 private key from disk and wrap it in the SAME `CaSigner` surface as + * `inProcessP256CaSigner` (raw P1363 `r||s` over the DER TBS). FAIL-FAST on an unreadable file, + * malformed PEM, or a non-P-256 key. The raw key stays in-process — NEVER logged/serialised (INV9). + */ +function loadP256FileSigner(path: string): CaSigner { + let pem: string + try { + pem = readFileSync(path, 'utf8') + } catch { + // Never echo the path contents — only that the file was unreadable (INV9). + throw new Error('native-CA private key file is unreadable') + } + let key: KeyObject + try { + key = createPrivateKey(pem) + } catch { + throw new Error('native-CA private key is not a valid PEM private key') + } + if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== P256_CURVE) { + throw new Error('native-CA private key must be a P-256 (prime256v1) EC key') + } + return inProcessP256CaSigner(key) +} + +/** + * FILE-BACKED KmsResolver — the production native-tunnel key custody for the single-owner VPS deploy. + * Resolves a key ref of the form `file:` (or a bare path) by loading the on-disk PEM P-256 key + * into an in-process `CaSigner`. `policyRestrictedToServicePrincipal` is TRUE because the on-disk key + * IS the control-plane's sole custody (documented file-custody reality; a real non-exportable KMS — + * `KmsResolver` in front of KMS/HSM — is the future upgrade). Throws if the ref cannot be resolved so + * `buildCaSigner` fails fast at boot. NEVER logs key material (INV9). + */ +export function fileKmsResolver(): KmsResolver { + return { + async resolve(keyRef: string) { + const path = keyRef.startsWith(FILE_KEY_REF_PREFIX) ? keyRef.slice(FILE_KEY_REF_PREFIX.length) : keyRef + if (path.length === 0) throw new Error('file KMS key ref has an empty path') + return { signer: loadP256FileSigner(path), policyRestrictedToServicePrincipal: true } + }, + } +} diff --git a/control-plane/src/boot/native-ca.ts b/control-plane/src/boot/native-ca.ts index 95c76ad..2cd75c4 100644 --- a/control-plane/src/boot/native-ca.ts +++ b/control-plane/src/boot/native-ca.ts @@ -21,9 +21,17 @@ import 'reflect-metadata' import * as x509 from '@peculiar/x509' import { webcrypto, generateKeyPairSync } from 'node:crypto' -import type { ControlPlaneEnv } from '../env.js' +import { readFileSync } from 'node:fs' +import type { ControlPlaneEnv, NativeCaEnvConfig } from '../env.js' import { assembleCertificate } from '../ca/x509-assembler.js' -import { buildCaSigner, inProcessP256CaSigner, type CaSigner, type KmsResolver } from './ca-wiring.js' +import { + buildCaSigner, + fileKeyRef, + fileKmsResolver, + inProcessP256CaSigner, + type CaSigner, + type KmsResolver, +} from './ca-wiring.js' x509.cryptoProvider.set(webcrypto) @@ -153,3 +161,50 @@ export async function buildNativeCas(env: ControlPlaneEnv, opts: BuildNativeCasO ]) return { frpClientCa, deviceCa } } + +/** Load one CA's (public) cert PEM from disk as anchor DER, failing loud on unreadable/unparseable material (INV9). */ +function loadCaCertDer(certPath: string): Uint8Array { + let pem: string + try { + pem = readFileSync(certPath, 'utf8') + } catch { + // Never echo the file contents — only that it was unreadable (INV9). + throw new Error('native-CA certificate file is unreadable — refusing to boot') + } + try { + return new Uint8Array(new x509.X509Certificate(pem).rawData) + } catch { + throw new Error('native-CA certificate material is not a parseable X.509 certificate — refusing to boot') + } +} + +/** + * PRODUCTION wiring from ON-DISK material — the A2-prep prerequisite for deploying the control-plane. + * Builds both native-tunnel CAs from the VPS `gen-device-ca.sh` output: each CA's (public) cert PEM + * becomes its trust-anchor DER and its PKCS#8 PEM private key path becomes a `file:` KMS ref resolved + * by `fileKmsResolver` (single-owner file-custody; a non-exportable KMS is the upgrade). Delegates to + * `buildNativeCas` in production mode so the SAME INV9 fail-fast applies — any unreadable/unparseable + * cert or non-P-256 key refuses to boot. Raw key bytes are never loaded/logged at this layer. + */ +export async function buildFileBackedNativeCas( + env: ControlPlaneEnv, + config: NativeCaEnvConfig, + opts: { readonly now?: () => number } = {}, +): Promise { + const material: NativeCaMaterial = { + frpClientCa: { + kmsKeyRef: fileKeyRef(config.frpClientCaKeyPath), + caCertDer: loadCaCertDer(config.frpClientCaCertPath), + }, + deviceCa: { + kmsKeyRef: fileKeyRef(config.deviceCaKeyPath), + caCertDer: loadCaCertDer(config.deviceCaCertPath), + }, + } + return buildNativeCas(env, { + production: true, + kmsResolver: fileKmsResolver(), + material, + ...(opts.now !== undefined ? { now: opts.now } : {}), + }) +} 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..cf5fb9c 100644 --- a/control-plane/src/env.ts +++ b/control-plane/src/env.ts @@ -42,6 +42,41 @@ 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 + /** + * A2-prep — the native-tunnel PKI's on-disk P-256 CA material (the VPS `gen-device-ca.sh` output: + * `frp-client-CA` for host frp-client leaves + `device-CA` for device leaves). Set together or NOT + * at all — a partial set is a boot error (fail-closed). Undefined ⇒ the dev/test injection path + * (self-signed in-process CAs) is used. The private KEY paths mirror `CA_INTERMEDIATE_KEY_PATH` + * (optional; derived from the cert path when unset). The raw keys are custody-on-disk (single-owner + * file-custody reality; a non-exportable KMS is the future upgrade). + */ + readonly nativeCa?: NativeCaEnvConfig +} + +/** Resolved on-disk material for the two native-tunnel P-256 CAs + the DNS zone their leaves live under. */ +export interface NativeCaEnvConfig { + /** frp-client-CA (public) cert PEM path — the host-leaf trust anchor. */ + readonly frpClientCaCertPath: string + /** frp-client-CA PKCS#8 PEM private key path (derived from the cert path when unset). */ + readonly frpClientCaKeyPath: string + /** device-CA (public) cert PEM path — the device-leaf trust anchor. */ + readonly deviceCaCertPath: string + /** device-CA PKCS#8 PEM private key path (derived from the cert path when unset). */ + readonly deviceCaKeyPath: string + /** DNS zone the leaves' `dNSName .` SAN is stamped under (the nginx :8470 tenant key). */ + readonly dnsZone: string } /** A positive integer parsed from an env string, or a default when unset/empty. */ @@ -77,8 +112,101 @@ 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(), + // A2-prep native-tunnel CA material — all optional; cross-validated below (set together or not at all). + NATIVE_FRP_CLIENT_CA_CERT_PATH: z.string().trim().optional(), + NATIVE_FRP_CLIENT_CA_KEY_PATH: z.string().trim().optional(), + NATIVE_DEVICE_CA_CERT_PATH: z.string().trim().optional(), + NATIVE_DEVICE_CA_KEY_PATH: z.string().trim().optional(), + NATIVE_DNS_ZONE: z.string().trim().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 } +} + +/** + * Resolve the optional native-tunnel CA material. Deny-by-default + fail-fast: the two (public) CA + * cert paths and the DNS zone are the mandatory triad; if ANY native field is supplied (including a + * lone KEY_PATH), all three MUST be present or boot fails (a partial set is never a silent half-open, + * mirroring `resolveOperatorLogin`). The private KEY paths are optional and derived from their cert + * path when unset (mirrors `CA_INTERMEDIATE_KEY_PATH`). Returns `{}` when the feature is unconfigured. + * NEVER echoes path VALUES — only field NAMES on failure (INV9). + */ +function resolveNativeCa(e: { + NATIVE_FRP_CLIENT_CA_CERT_PATH: string | undefined + NATIVE_FRP_CLIENT_CA_KEY_PATH: string | undefined + NATIVE_DEVICE_CA_CERT_PATH: string | undefined + NATIVE_DEVICE_CA_KEY_PATH: string | undefined + NATIVE_DNS_ZONE: string | undefined +}): { nativeCa?: NativeCaEnvConfig } { + const has = (v: string | undefined): v is string => v !== undefined && v.length > 0 + const frpCert = e.NATIVE_FRP_CLIENT_CA_CERT_PATH + const frpKey = e.NATIVE_FRP_CLIENT_CA_KEY_PATH + const devCert = e.NATIVE_DEVICE_CA_CERT_PATH + const devKey = e.NATIVE_DEVICE_CA_KEY_PATH + const zone = e.NATIVE_DNS_ZONE + const anyPresent = [frpCert, frpKey, devCert, devKey, zone].some(has) + if (!anyPresent) return {} // feature off — dev/test injection path (self-signed in-process CAs) + if (!has(frpCert) || !has(devCert) || !has(zone)) { + const missing = [ + has(frpCert) ? null : 'NATIVE_FRP_CLIENT_CA_CERT_PATH', + has(devCert) ? null : 'NATIVE_DEVICE_CA_CERT_PATH', + has(zone) ? null : 'NATIVE_DNS_ZONE', + ].filter((x): x is string => x !== null) + throw new Error( + `Invalid control-plane env: native-tunnel CA material requires all of ${missing.join(', ')} to be set`, + ) + } + return { + nativeCa: { + frpClientCaCertPath: frpCert, + frpClientCaKeyPath: has(frpKey) ? frpKey : deriveKeyPath(frpCert), + deviceCaCertPath: devCert, + deviceCaKeyPath: has(devKey) ? devKey : deriveKeyPath(devCert), + dnsZone: zone, + }, + } +} + /** * Parse + validate control-plane config. THROWS (fail-fast) listing every missing/invalid * key by NAME. Never echoes secret VALUES (INV9). @@ -125,5 +253,17 @@ 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, + }), + ...resolveNativeCa({ + NATIVE_FRP_CLIENT_CA_CERT_PATH: e.NATIVE_FRP_CLIENT_CA_CERT_PATH, + NATIVE_FRP_CLIENT_CA_KEY_PATH: e.NATIVE_FRP_CLIENT_CA_KEY_PATH, + NATIVE_DEVICE_CA_CERT_PATH: e.NATIVE_DEVICE_CA_CERT_PATH, + NATIVE_DEVICE_CA_KEY_PATH: e.NATIVE_DEVICE_CA_KEY_PATH, + NATIVE_DNS_ZONE: e.NATIVE_DNS_ZONE, + }), } } diff --git a/control-plane/src/main.ts b/control-plane/src/main.ts index f079e1e..3fb37d5 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' @@ -55,6 +58,13 @@ export interface ControlPlaneOverrides { readonly caChainDer?: readonly Uint8Array[] /** Production mode → native-CA + renew-anchor material is fail-closed (INV9). Defaults to NODE_ENV. */ readonly production?: boolean + /** + * Pre-built native-tunnel CAs (A2-prep production path). When supplied, `buildControlPlane` uses + * these verbatim and does NOT call `buildNativeCas` — this is how `server.ts` threads the two + * on-disk P-256 CAs (`buildFileBackedNativeCas`) in without coupling them to the intermediate + * `kmsResolver`. Takes precedence over `nativeCaMaterial`. + */ + readonly nativeCas?: NativeCas /** Production-loaded native-tunnel CA material (per-CA KMS ref + public cert DER). */ readonly nativeCaMaterial?: NativeCaMaterial /** DNS zone the native-tunnel leaves are stamped under (defaults to `terminal.yaojia.wang`). */ @@ -182,11 +192,15 @@ export async function buildControlPlane( // the renew route MUST validate presented certs against non-empty anchors (renew.ts). DEV generates // self-signed in-process P-256 CAs so leaves chain to a real, re-parseable CA. const production = overrides.production ?? process.env.NODE_ENV === 'production' - const nativeCas = await buildNativeCas(env, { - production, - ...(overrides.kmsResolver !== undefined ? { kmsResolver: overrides.kmsResolver } : {}), - ...(overrides.nativeCaMaterial !== undefined ? { material: overrides.nativeCaMaterial } : {}), - }) + // `server.ts` builds the two on-disk P-256 CAs itself (buildFileBackedNativeCas, with the file + // resolver) and threads them in as `nativeCas` — decoupled from the intermediate `kmsResolver`. + const nativeCas = + overrides.nativeCas ?? + (await buildNativeCas(env, { + production, + ...(overrides.kmsResolver !== undefined ? { kmsResolver: overrides.kmsResolver } : {}), + ...(overrides.nativeCaMaterial !== undefined ? { material: overrides.nativeCaMaterial } : {}), + })) const nativeDnsZone = overrides.nativeDnsZone ?? DEFAULT_NATIVE_DNS_ZONE // ONE DeviceStore shared across the device registry, the device signer, and the renew path so the @@ -243,6 +257,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/src/server.ts b/control-plane/src/server.ts index 85c3748..c244279 100644 --- a/control-plane/src/server.ts +++ b/control-plane/src/server.ts @@ -16,7 +16,8 @@ import { runMigrations } from './db/migrate.js' import { createRedisRevocationBus } from './routing/bus.js' import { createCapabilityVerifier, configureCapabilityVerifyKey } from './boot/verifier.js' import { createRedisClient, createRedisPublisher } from './boot/redis.js' -import { buildControlPlane } from './main.js' +import { buildControlPlane, type ControlPlaneOverrides } from './main.js' +import { buildFileBackedNativeCas } from './boot/native-ca.js' /** Admin API binds to loopback by default — never expose the provisioning API on a public interface. */ const DEFAULT_CP_BIND_HOST = '127.0.0.1' @@ -57,7 +58,21 @@ async function main(): Promise { await configureCapabilityVerifyKey(env.capabilitySignPubkey) const verifier = createCapabilityVerifier() - const { app } = await buildControlPlane(env, { stores, bus, verifier }) + // A2-prep: when NATIVE_* on-disk CA material is configured, load the two REAL P-256 CAs (frp-client + // + device) from disk and thread them into the app so /enroll + /device/enroll issue leaves from the + // production CAs. `buildFileBackedNativeCas` fails fast (INV9) on unreadable/non-P-256 material. When + // unset, `buildControlPlane` keeps its existing behaviour (dev self-signed CAs, or fail-closed if + // NODE_ENV=production) — the dev/test injection path is untouched. + const nativeOverrides: Pick = + env.nativeCa !== undefined + ? { + production: true, + nativeCas: await buildFileBackedNativeCas(env, env.nativeCa), + nativeDnsZone: env.nativeCa.dnsZone, + } + : {} + + const { app } = await buildControlPlane(env, { stores, bus, verifier, ...nativeOverrides }) const host = resolveBindHost(process.env) const port = resolveBindPort(process.env) 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/ca-wiring.test.ts b/control-plane/test/ca-wiring.test.ts new file mode 100644 index 0000000..f554ce1 --- /dev/null +++ b/control-plane/test/ca-wiring.test.ts @@ -0,0 +1,99 @@ +/** + * A2-prep — file-backed KMS resolver unit tests. Proves `fileKmsResolver` loads an on-disk PKCS#8 + * PEM P-256 CA key and exposes it as a `CaSigner` whose signature verifies against the key's public + * half (the single-owner file-custody reality of the VPS deploy), and fails loud on bad material + * (missing file, malformed PEM, wrong curve) — NEVER leaking key bytes (INV9). + */ +import { describe, test, expect, beforeEach, afterEach } from 'vitest' +import { generateKeyPairSync, createPublicKey, verify as nodeVerify, randomBytes } from 'node:crypto' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileKmsResolver, fileKeyRef } from '../src/boot/ca-wiring.js' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'cp-cawiring-')) +}) +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +/** Write a fresh P-256 PKCS#8 PEM key to disk; return its path + the matching public KeyObject. */ +function writeP256Key(name: string): { path: string; publicKey: ReturnType } { + const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }) + const pem = privateKey.export({ format: 'pem', type: 'pkcs8' }) as string + const path = join(dir, name) + writeFileSync(path, pem) + return { path, publicKey } +} + +describe('fileKmsResolver — P-256 on-disk key custody', () => { + test('resolves a file: ref, loads the P-256 key, and signs verifiably', async () => { + const { path, publicKey } = writeP256Key('frp.key.pem') + const resolver = fileKmsResolver() + + const { signer, policyRestrictedToServicePrincipal } = await resolver.resolve(fileKeyRef(path)) + expect(policyRestrictedToServicePrincipal).toBe(true) + + const tbs = randomBytes(48) + const sig = await signer.sign(new Uint8Array(tbs)) + // signer emits raw P1363 r||s (64 bytes) — the same shape hardware/`inProcessP256CaSigner` produce. + expect(sig.length).toBe(64) + const ok = nodeVerify('sha256', tbs, { key: publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(sig)) + expect(ok).toBe(true) + // the signer's public key equals the on-disk key's SPKI DER. + const spki = new Uint8Array(publicKey.export({ format: 'der', type: 'spki' }) as Buffer) + expect(Buffer.from(signer.publicKeyRaw).equals(Buffer.from(spki))).toBe(true) + }) + + test('resolves a bare path (no file: prefix)', async () => { + const { path, publicKey } = writeP256Key('bare.key.pem') + const { signer } = await fileKmsResolver().resolve(path) + const tbs = randomBytes(32) + const sig = await signer.sign(new Uint8Array(tbs)) + expect(nodeVerify('sha256', tbs, { key: publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(sig))).toBe(true) + }) + + test('missing file → rejects, never echoing the path contents', async () => { + await expect(fileKmsResolver().resolve(fileKeyRef(join(dir, 'nope.pem')))).rejects.toThrow(/unreadable/) + }) + + test('malformed PEM → rejects', async () => { + const path = join(dir, 'bad.pem') + writeFileSync(path, 'not a pem key') + await expect(fileKmsResolver().resolve(fileKeyRef(path))).rejects.toThrow(/not a valid PEM private key/) + }) + + test('non-P-256 key (Ed25519) → rejected (curve mismatch, fail-closed)', async () => { + const { privateKey } = generateKeyPairSync('ed25519') + const path = join(dir, 'ed.key.pem') + writeFileSync(path, privateKey.export({ format: 'pem', type: 'pkcs8' }) as string) + await expect(fileKmsResolver().resolve(fileKeyRef(path))).rejects.toThrow(/P-256/) + }) + + test('wrong-curve EC key (P-384) → rejected', async () => { + const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-384' }) + const path = join(dir, 'p384.key.pem') + writeFileSync(path, privateKey.export({ format: 'pem', type: 'pkcs8' }) as string) + await expect(fileKmsResolver().resolve(fileKeyRef(path))).rejects.toThrow(/P-256/) + }) + + test('empty file: ref path → rejected', async () => { + await expect(fileKmsResolver().resolve('file:')).rejects.toThrow(/empty path/) + }) + + test('never leaks key material in the error message (INV9)', async () => { + const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-384' }) + const pem = privateKey.export({ format: 'pem', type: 'pkcs8' }) as string + const path = join(dir, 'secret.key.pem') + writeFileSync(path, pem) + try { + await fileKmsResolver().resolve(fileKeyRef(path)) + throw new Error('expected rejection') + } catch (e) { + expect(e instanceof Error ? e.message : '').not.toContain(pem.slice(40, 80)) + } + }) +}) diff --git a/control-plane/test/env.test.ts b/control-plane/test/env.test.ts index 8fa17cd..44df54e 100644 --- a/control-plane/test/env.test.ts +++ b/control-plane/test/env.test.ts @@ -66,3 +66,110 @@ 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) + } + }) +}) + +describe('A2-prep native-tunnel CA env (set-together-or-none, fail-closed)', () => { + const FRP_CERT = '/etc/cp/frp-client-ca.cert.pem' + const DEV_CERT = '/etc/cp/device-ca.cert.pem' + const ZONE = 'native.example.test' + + test('none set → nativeCa undefined (injection/dev path)', () => { + const env = loadEnv(base()) + expect(env.nativeCa).toBeUndefined() + }) + + test('cert paths + dns zone set → parsed, key paths DERIVED from cert paths', () => { + const env = loadEnv({ + ...base(), + NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT, + NATIVE_DEVICE_CA_CERT_PATH: DEV_CERT, + NATIVE_DNS_ZONE: ZONE, + }) + expect(env.nativeCa).toEqual({ + frpClientCaCertPath: FRP_CERT, + frpClientCaKeyPath: '/etc/cp/frp-client-ca.key.pem', + deviceCaCertPath: DEV_CERT, + deviceCaKeyPath: '/etc/cp/device-ca.key.pem', + dnsZone: ZONE, + }) + }) + + test('explicit key paths are used verbatim (not derived)', () => { + const env = loadEnv({ + ...base(), + NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT, + NATIVE_FRP_CLIENT_CA_KEY_PATH: '/keys/frp.pem', + NATIVE_DEVICE_CA_CERT_PATH: DEV_CERT, + NATIVE_DEVICE_CA_KEY_PATH: '/keys/dev.pem', + NATIVE_DNS_ZONE: ZONE, + }) + expect(env.nativeCa?.frpClientCaKeyPath).toBe('/keys/frp.pem') + expect(env.nativeCa?.deviceCaKeyPath).toBe('/keys/dev.pem') + }) + + test('partial set — only frp cert path → fail-closed listing the missing keys', () => { + expect(() => loadEnv({ ...base(), NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT })).toThrow( + /NATIVE_DEVICE_CA_CERT_PATH|NATIVE_DNS_ZONE/, + ) + }) + + test('partial set — cert paths without a dns zone → fail-closed', () => { + expect(() => + loadEnv({ ...base(), NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT, NATIVE_DEVICE_CA_CERT_PATH: DEV_CERT }), + ).toThrow(/NATIVE_DNS_ZONE/) + }) + + test('partial set — a key path supplied WITHOUT its cert path → fail-closed', () => { + expect(() => loadEnv({ ...base(), NATIVE_FRP_CLIENT_CA_KEY_PATH: '/keys/frp.pem' })).toThrow( + /NATIVE_FRP_CLIENT_CA_CERT_PATH|NATIVE_DEVICE_CA_CERT_PATH|NATIVE_DNS_ZONE/, + ) + }) +}) diff --git a/control-plane/test/native-ca.test.ts b/control-plane/test/native-ca.test.ts new file mode 100644 index 0000000..c96b696 --- /dev/null +++ b/control-plane/test/native-ca.test.ts @@ -0,0 +1,215 @@ +/** + * A2-prep — PRODUCTION native-tunnel CA wiring from ON-DISK P-256 material. Generates throwaway + * P-256 CAs (key + cert PEM) in a tmp dir — standing in for the VPS `gen-device-ca.sh` output — then + * proves `buildFileBackedNativeCas` binds each CA's signer to its on-disk key and its anchor to its + * on-disk cert, that a leaf issued by the wired signer CHAINS to that on-disk cert, that a WIRED + * control-plane `/enroll` mints an frp-client leaf whose dNSName SAN = `.` from the real + * on-disk CA, and that partial/missing/malformed material FAILS CLOSED (INV9). + */ +import 'reflect-metadata' +import { describe, test, expect, beforeEach, afterEach } from 'vitest' +import * as x509 from '@peculiar/x509' +import { webcrypto, generateKeyPairSync } from 'node:crypto' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { buildNativeCas, buildFileBackedNativeCas } from '../src/boot/native-ca.js' +import { inProcessP256CaSigner } from '../src/boot/ca-wiring.js' +import { assembleCertificate } from '../src/ca/x509-assembler.js' +import { createFrpClientLeafSigner } from '../src/ca/frpclient-issue.js' +import { createHostRegistry } from '../src/registry/hosts.js' +import { createMemoryStores } from '../src/store/memory.js' +import { fingerprint } from '../src/ca/fingerprint.js' +import { buildCsrEc } from '../src/ca/csr-ec.js' +import { createPairingIssuer } from '../src/pairing/issue.js' +import { buildControlPlane } from '../src/main.js' +import { loadEnv, type ControlPlaneEnv, type NativeCaEnvConfig } from '../src/env.js' +import { bytesToBase64 } from '../src/util/bytes.js' +import type { Stores } from '../src/store/ports.js' + +x509.cryptoProvider.set(webcrypto) + +const ACCOUNT_A = '11111111-1111-4111-8111-111111111111' +const ZONE = 'native.example.test' +const DAY_MS = 24 * 60 * 60 * 1000 + +let dir: string +let env: ControlPlaneEnv + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'cp-nativeca-')) + env = loadEnv({ + PG_URL: 'postgres://u:p@localhost:5432/cp', + REDIS_URL: 'redis://localhost:6379', + CAPABILITY_SIGN_PUBKEY_B64: bytesToBase64(new Uint8Array(32).fill(7)), + CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate', + CA_INTERMEDIATE_CERT_PATH: join(dir, 'int.cert.pem'), + NODE_MTLS_TRUST_BUNDLE_PATH: join(dir, 'node-ca.pem'), + RELAY_TRUST_DOMAIN: ZONE, + BASE_DOMAIN: 'term.example.com', + }) +}) +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +/** Write a self-signed throwaway P-256 CA (key + cert PEM) to disk — mirrors the VPS gen-device-ca output. */ +async function writeThrowawayP256Ca(cn: string): Promise<{ keyPath: string; certPath: string; caDer: Uint8Array }> { + const caKeys = generateKeyPairSync('ec', { namedCurve: 'P-256' }) + const keyPem = caKeys.privateKey.export({ format: 'pem', type: 'pkcs8' }) as string + const caSpki = new Uint8Array(caKeys.publicKey.export({ format: 'der', type: 'spki' })) + const signer = inProcessP256CaSigner(caKeys.privateKey) + const now = Date.now() + const caDer = await assembleCertificate({ + subjectPublicKey: caSpki, + subject: `CN=${cn}`, + issuer: `CN=${cn}`, + serialNumber: Uint8Array.from([0x01]), + notBefore: new Date(now - DAY_MS), + notAfter: new Date(now + 365 * DAY_MS), + extensions: [ + new x509.BasicConstraintsExtension(true, undefined, true), + new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), + ], + signer, + sigAlg: 'ecdsa-p256', + }) + const certPem = new x509.X509Certificate(caDer).toString('pem') + const keyPath = join(dir, `${cn}.key.pem`) + const certPath = join(dir, `${cn}.cert.pem`) + writeFileSync(keyPath, keyPem) + writeFileSync(certPath, certPem) + return { keyPath, certPath, caDer } +} + +async function writeNativeCaConfig(): Promise<{ config: NativeCaEnvConfig; frpCaDer: Uint8Array; deviceCaDer: Uint8Array }> { + const frp = await writeThrowawayP256Ca('frp-client-CA') + const dev = await writeThrowawayP256Ca('device-CA') + return { + config: { + frpClientCaCertPath: frp.certPath, + frpClientCaKeyPath: frp.keyPath, + deviceCaCertPath: dev.certPath, + deviceCaKeyPath: dev.keyPath, + dnsZone: ZONE, + }, + frpCaDer: frp.caDer, + deviceCaDer: dev.caDer, + } +} + +function b64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64') +} + +function sanNames(der: Uint8Array): ReturnType { + return new x509.X509Certificate(der).getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON() +} + +describe('buildFileBackedNativeCas — anchors + signer from on-disk P-256 material', () => { + test('each CA anchor DER == its on-disk cert DER; issuer names parse', async () => { + const { config, frpCaDer, deviceCaDer } = await writeNativeCaConfig() + const cas = await buildFileBackedNativeCas(env, config) + + expect(b64(cas.frpClientCa.caCertDer)).toBe(b64(frpCaDer)) + expect(b64(cas.deviceCa.caCertDer)).toBe(b64(deviceCaDer)) + expect(cas.frpClientCa.anchorsDer).toEqual([cas.frpClientCa.caCertDer]) + expect(cas.deviceCa.anchorsDer).toEqual([cas.deviceCa.caCertDer]) + expect(cas.frpClientCa.issuerName.toString()).toContain('frp-client-CA') + expect(cas.deviceCa.issuerName.toString()).toContain('device-CA') + }) + + test('a leaf minted by the wired frp-client signer CHAINS to the on-disk CA cert', async () => { + const { config, frpCaDer } = await writeNativeCaConfig() + const cas = await buildFileBackedNativeCas(env, config) + + // Bind a P-256 host so the signer will issue for it. + const stores = createMemoryStores() + const hosts = createHostRegistry({ hosts: stores.hosts }) + const { der: csr, keys } = await buildCsrEc('CN=alice') + const spki = new Uint8Array(await webcrypto.subtle.exportKey('spki', keys.publicKey)) + const host = await hosts.bindHost({ accountId: ACCOUNT_A, subdomain: 'alice', agentPubkey: spki, enrollFpr: fingerprint(spki) }) + + const signer = createFrpClientLeafSigner({ + hosts: stores.hosts, + signer: cas.frpClientCa.signer, + issuerName: cas.frpClientCa.issuerName, + caChainDer: cas.frpClientCa.anchorsDer, + trustDomain: ZONE, + dnsZone: ZONE, + }) + const { cert } = await signer.signHostLeaf(host.hostId, spki, csr) + + // The leaf verifies against the ON-DISK CA cert's public key (real chain) … + const onDiskCa = new x509.X509Certificate(frpCaDer) + expect(await new x509.X509Certificate(cert).verify({ publicKey: onDiskCa.publicKey, signatureOnly: true })).toBe(true) + // … and carries the enforcement dNSName SAN under the configured zone. + expect(sanNames(cert)).toContainEqual({ type: 'dns', value: `alice.${ZONE}` }) + }) +}) + +describe('WIRED control-plane /enroll from on-disk native CAs', () => { + async function ownedApp(): Promise<{ app: Awaited>['app']; stores: Stores; frpCaDer: Uint8Array }> { + const { config, frpCaDer } = await writeNativeCaConfig() + const nativeCas = await buildFileBackedNativeCas(env, config) + const stores = createMemoryStores() + const built = await buildControlPlane(env, { stores, production: true, nativeCas, nativeDnsZone: ZONE }) + await built.app.ready() + return { app: built.app, stores, frpCaDer } + } + + test('POST /enroll (native EC arm) → 201 frp-client leaf that chains to the on-disk CA + dNSName SAN .', async () => { + const { app, stores, frpCaDer } = await ownedApp() + const issuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: 3600 }) + const { code } = await issuer.issuePairingCode(ACCOUNT_A) + const { der: csr, keys } = await buildCsrEc('CN=web-terminal-host') + const spki = new Uint8Array(await webcrypto.subtle.exportKey('spki', keys.publicKey)) + + const res = await app.inject({ + method: 'POST', + url: '/enroll', + payload: { code, machineId: 'MB-1', agentPubkey: b64(spki), csr: b64(csr) }, + }) + expect(res.statusCode).toBe(201) + const body = JSON.parse(res.body) + expect(typeof body.subdomain).toBe('string') + expect(body.hostContentSecret).toBeNull() + + const leafDer = new Uint8Array(Buffer.from(body.cert, 'base64')) + // chains to the REAL on-disk frp-client-CA … + const onDiskCa = new x509.X509Certificate(frpCaDer) + expect(await new x509.X509Certificate(leafDer).verify({ publicKey: onDiskCa.publicKey, signatureOnly: true })).toBe(true) + // … the caChain the server returned IS that on-disk anchor … + expect(b64(new Uint8Array(Buffer.from(body.caChain[0], 'base64')))).toBe(b64(frpCaDer)) + // … and the leaf carries the server-assigned dNSName SAN under the env-configured zone. + expect(sanNames(leafDer)).toContainEqual({ type: 'dns', value: `${body.subdomain}.${ZONE}` }) + }) +}) + +describe('native-tunnel CA fail-closed (INV9)', () => { + test('buildNativeCas production without material → refuses to boot', async () => { + await expect(buildNativeCas(env, { production: true })).rejects.toThrow(/unresolvable in production/) + }) + + test('buildFileBackedNativeCas with an unreadable cert path → refuses to boot', async () => { + const { config } = await writeNativeCaConfig() + const broken: NativeCaEnvConfig = { ...config, frpClientCaCertPath: join(dir, 'missing.cert.pem') } + await expect(buildFileBackedNativeCas(env, broken)).rejects.toThrow(/certificate file is unreadable/) + }) + + test('buildFileBackedNativeCas with a NON-P256 key file → refuses to boot (fail-closed)', async () => { + const { config } = await writeNativeCaConfig() + const edPath = join(dir, 'ed.key.pem') + writeFileSync(edPath, generateKeyPairSync('ed25519').privateKey.export({ format: 'pem', type: 'pkcs8' }) as string) + const broken: NativeCaEnvConfig = { ...config, frpClientCaKeyPath: edPath } + await expect(buildFileBackedNativeCas(env, broken)).rejects.toThrow(/P-256|could not be resolved/) + }) + + test('buildFileBackedNativeCas with a malformed cert file → refuses to boot', async () => { + const { config } = await writeNativeCaConfig() + const badCert = join(dir, 'bad.cert.pem') + writeFileSync(badCert, 'not a certificate') + const broken: NativeCaEnvConfig = { ...config, deviceCaCertPath: badCert } + await expect(buildFileBackedNativeCas(env, broken)).rejects.toThrow(/parseable X.509 certificate/) + }) +}) diff --git a/desktop/src/embedded-server.ts b/desktop/src/embedded-server.ts index 04b2254..61e58a4 100644 --- a/desktop/src/embedded-server.ts +++ b/desktop/src/embedded-server.ts @@ -23,6 +23,8 @@ import { buildServerEnv } from './server-config.js' /** Default listen port when the user hasn't pinned one (free-port fallback still applies). */ const DEFAULT_PORT = 3000 +/** How long to wait for the probe of an already-running base app before giving up. */ +const PROBE_TIMEOUT_MS = 1500 export interface EmbeddedServerDeps { readonly prefs: DesktopPrefs @@ -34,6 +36,26 @@ interface ServerHandle { close(): Promise } +/** + * Probe whether a web-terminal base app is ALREADY serving on `port` (typically + * the always-on tunnel daemon that owns :3000 + frpc). `GET /config/ui` returns a + * small JSON object with `allowAutoMode` only from our backend, so it doubles as a + * cheap "is this one of ours?" fingerprint. Any failure (nothing listening, wrong + * shape, timeout) → false, and we spawn our own server as before. + */ +async function probeExistingBaseApp(port: number): Promise { + try { + const res = await fetch(`http://127.0.0.1:${port}/config/ui`, { + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }) + if (!res.ok) return false + const body: unknown = await res.json() + return typeof body === 'object' && body !== null && 'allowAutoMode' in body + } catch { + return false + } +} + /** * Start the embedded backend and return a lifecycle handle. Throws (after * logging) if the compiled server can't be imported or fails to start — the app @@ -41,7 +63,23 @@ interface ServerHandle { */ export async function startEmbeddedServer(deps: EmbeddedServerDeps): Promise { const { prefs, logger } = deps - const port = await pickFreePort(prefs.port ?? DEFAULT_PORT) + const preferred = prefs.port ?? DEFAULT_PORT + + // If a base app is already up on the preferred port (the always-on tunnel + // daemon), RIDE it instead of spawning a duplicate on another port: one base app + // then serves the desktop UI, LAN, and the frpc tunnel alike. close() is a no-op + // because we don't own that process — quitting the window must not kill it. + if (await probeExistingBaseApp(preferred)) { + logger.info(`reusing base app already on http://127.0.0.1:${preferred} (tunnel daemon) — not spawning a duplicate`) + return { + port: preferred, + bindHost: '127.0.0.1', + allowedOrigins: [], + close: async () => {}, + } + } + + const port = await pickFreePort(preferred) const env = buildServerEnv({ prefs, port, platform: process.platform, env: process.env }) // dist/ is a sibling of the desktop dir in dev; under resourcesPath when packaged. diff --git a/docs/PLAN_ZERO_TOUCH_ROLLOUT.md b/docs/PLAN_ZERO_TOUCH_ROLLOUT.md new file mode 100644 index 0000000..c3b0a15 --- /dev/null +++ b/docs/PLAN_ZERO_TOUCH_ROLLOUT.md @@ -0,0 +1,165 @@ +# Zero-Touch Enrollment — Rollout & Integration Plan + +> **This is the EXECUTION plan** (deploy + wire + fill the gaps), grounded in the *current* +> deployment reality. The DESIGN blueprint is `docs/PLAN_TUNNEL_AUTOMATION.md`; this doc says +> what to actually do next, in order, to reach the product requirement. +> +> **Product requirement (operator, 2026-07-18):** ① 主机端启动即自动连服务器 + 自动管理证书(零手动装证书);② 客户端(手机)连接时自动处理好证书(零手动 .p12)。 + +--- + +## 0. The honest constraint (read first) + +The **first** enrollment of each identity cannot be zero *human* actions: issuing a tunnel cert to +an unknown machine/phone with no auth = anyone who reaches the endpoint gets a cert. So exactly **one +authenticated human action at bootstrap is unavoidable**: paste a one-time pairing code (host) / log +in once (phone). **Everything after** — renew, rotate, reboot-survival — is genuinely silent. + +> "Zero-touch" here honestly means **"one bootstrap tap, then never again"**, not "no human step ever." +> Every step below is designed so that single tap is the *only* manual action for the product lifetime. + +--- + +## 1. Current state (verified 2026-07-18) + +| Piece | State | +|---|---| +| `control-plane/` (PKI/CA + `/enroll` + `/device/enroll` + `/renew`) | **Built, 246 tests pass.** Loopback `127.0.0.1:8080`, fail-closed prod boot (needs real KMS CA material). | +| `agent/` host agent (`pair --install`) | **Built, 267 tests pass.** launchd user-agent; keygen→CSR→enroll→frpc.toml→2 units→supervised frpc. | +| iOS device-enroll library (SecureEnclave→CSR→cert) | **Built + unit-tested**, but **zero app callers** (no UI). | +| Android / Desktop device-enroll | **None** (Android from scratch; Desktop deferred). | +| Server **login route + `device:enroll` bearer mint** | **MISSING — hard blocker for the phone track.** `loginToAccountId` is a deny-all stub. | +| VPS control-plane | **NOT running** (`:8080` down); code on VPS is the **older `feat/relay-phase1`** branch. | +| VPS tunnel data path (frps + nginx:8470 mTLS + LE wildcard cert) | **UP** (from M1). LE `*.terminal.yaojia.wang` valid → 2026-10-05. | +| My manual `mac1` host tunnel | **Live**, but static hand-issued certs — bypasses the control-plane. Control cert correctly from **frp-client-CA**, so CA lineage already matches the agent path (low migration risk). | + +**Bottom line:** the automated system is ~built but **not deployed and not wired**; the phone track has a +real server-code hole (login/bearer). My `mac1` is a manual stand-in to be retired. + +--- + +## 2. Architecture (target flows) + +**Host** (`web-terminal-agent pair --install`): generate P-256 key (never leaves host, 0600) → +PKCS#10 CSR → `POST /enroll {code, machineId, agentPubkey, csr}` → CA returns frp-client leaf +(`dNSName SAN = .terminal.yaojia.wang`) + caChain → SHA-256-verify-download pinned `frpc` v0.61.1 → +write `frpc.toml` (mTLS control channel, `serverName=frp.terminal.yaojia.wang`, one http proxy → +`127.0.0.1:PORT`) + base-app `ALLOWED_ORIGINS` → install **two launchd units** (base app + supervised +agent) → reboot-durable. **Renew** silently at ~2/3 TTL via `POST /renew` (mTLS w/ current cert). + +**Phone**: one-time **account login** → short-lived `device:enroll` bearer → app generates a +**non-exportable hardware key** (Secure Enclave / StrongBox) → hardware-signed CSR → +`POST /device/enroll {csr, subdomain, deviceName, attestation?}` (Bearer) → CA verifies token + +account-owns-subdomain (deny-by-default) + PoP + cap/rate-limit → returns device leaf + caChain → +app stores identity against the hardware key, presents on the **already-wired mTLS path**. Silent renew +via `POST /device/:id/renew`. + +**Three PKI roots (do not conflate):** frp-client-CA (P-256, host control channel, frps:7000) · +device-CA (P-256, device data path, nginx:8470 `ssl_verify_client`) · relay agent-CA (Ed25519, legacy). + +--- + +## 3. PRE-FLIGHT: deployment unknowns to verify (blockers before any build) + +Run against VPS `8.138.1.192`. **These gate feasibility — resolve before committing to the tracks.** + +1. **CP CA material** — does production KMS/CA material exist for the *native* CAs the new CP needs + (`NATIVE_FRP_CLIENT_CA_*`, `NATIVE_DEVICE_CA_*`, `CA_INTERMEDIATE_*`)? CP is **fail-closed**: no real + material ⇒ it won't boot (or falls back to dev placeholder leaves = unusable). **This is the #1 risk.** + The relay-phase1 deploy had `/etc/relay/ca/{root,intermediate}` + a P-256 device-CA + frp-client-CA + on disk — confirm these can back the new CP's signer (KMS refs vs on-disk keys). +2. **CP branch/deploy** — the newer CP (with the enroll/renew endpoints as-tested) must be checked out + & built on the VPS (currently `feat/relay-phase1`). Confirm PG + Redis reachable + migrated. +3. **Public `/enroll` reachability** — CP is loopback; an nginx vhost must proxy `/enroll`,`/device/enroll`, + `/renew` over public TLS. `curl -i https:///enroll` should give a 4xx (validation), not 502. +4. **frps trusts `frp-client-CA`** on the `:7000` control channel (NOT device-CA). Inspect frps `trustedCaFile`. +5. **`manage` bearer + `CAPABILITY_SIGN_PUBKEY_B64`** set (else the verifier is `refuseAll` and no pairing + code can be minted). +6. **`FRP_AUTH_TOKEN`** identical on agent side and frps. +7. **nginx :8470 dNSName→Host binding** deployed (njs `getCertSub` + `map` → 403 on mismatch) — the + load-bearing isolation control for the device path. +8. **Login/`device:enroll` route** — expected: **absent today**; confirm (defines phone-track start). + +--- + +## 4. TRACK A — Host auto-enroll (small–medium; mostly deploy + wire) + +**Goal:** fresh Mac → one pairing code → `pair --install` → auto-connect + self-managing cert, reboot-durable. + +| # | Task | Where | Effort | Notes / risk | +|---|---|---|---|---| +| A1 | Resolve pre-flight §3.1–3.6 (CA material, CP build, public `/enroll`, frps CA, manage bearer, token) | VPS | S–M | Gated by §3.1 KMS/CA material — the real unknown. | +| A2 | Deploy + run the new control-plane in **production mode** (real CA material, PG/Redis, systemd unit) | VPS `control-plane/` | M | Replace the old relay-phase1 CP; keep loopback:8080. | +| A3 | nginx vhost proxying `/enroll` `/device/enroll` `/renew` → 127.0.0.1:8080 over the LE wildcard TLS | `deploy/nginx` + VPS | S | Add to existing :443 SNI / a dedicated enroll host. | +| A4 | Mint one **pairing code** (`POST /accounts/:id/pairing-codes`, `manage` bearer) | VPS | S | MVP = manually pasted code (RFC 8628 device-grant deferred). | +| A5 | **Wire auto-renew into the native run-loop** — call `createCertRotator`/`renewCert` from `superviseNative` | `agent/src` (**only real host code gap**) | S | Logic already built+tested; today `superviseNative` only *monitors* freshness → cert dies at 24h. TDD it. | +| A6 | Build the agent (`dist/cli.js`) + set env (`ENROLL_URL`,`TUNNEL_DOMAIN`,`TUNNEL_ZONE=terminal`,`FRP_AUTH_TOKEN`,`PORT`,`BIND_HOST=127.0.0.1`, dummy `RELAY_URL`) | Mac | S | | +| A7 | Run `web-terminal-agent pair --install`; verify 2 launchd units + `https://.terminal.yaojia.wang` live via device cert | Mac | S | | +| A8 | **Retire manual `mac1`** — bootout `com.webterm.tunnel.{baseapp,frpc}`, remove `frpc.toml`/certs | Mac | S | Only after A7 green. | +| A9 | (optional) `machineId` dedup so reinstall keeps the same subdomain | `agent/src`, `control-plane/registry` | S | Deferred; cosmetic (`status` output). | + +**Deliverable A:** computer start → auto-connect + auto-managed/renewing cert; one pairing code at first +setup, silent thereafter. **Blocking risk = §3.1 (production CA material).** + +--- + +## 5. TRACK B — Phone auto-enroll (build-heavy; server hole first) + +**Goal:** phone → one login → device cert auto-issued to hardware key → connect, no `.p12`, ever. + +| # | Task | Where | Effort | Notes / risk | +|---|---|---|---|---| +| B1 | **Build + deploy the CP login route + `device:enroll` bearer mint** (`auth/session.ts mintDeviceEnrollToken`, add `/login` or `/auth`, add `enroll` to `CapabilityRightSchema`) | `control-plane/src/auth` | M | **HARD BLOCKER — do first; nothing in the phone flow is reachable without a bearer.** Even a single-operator credential seam unblocks it (full OIDC later). | +| B2 | Bridge: after login, mint bearer + scope to the account's subdomain(s) (deny-by-default already enforced by CP) | `control-plane` | S | | +| B3 | **iOS**: wire `KeychainClientIdentityStore.enroll` into an enrollment screen (login → bearer → CSR → `/device/enroll` → store SE identity → present on existing mTLS path) + rotation scheduler | `ios/` | M | Library exists + unit-tested; this is UI + flow wiring. | +| B4 | **Android**: build the enroll library from scratch (StrongBox keygen + CSR + `/device/enroll` client) + present via existing `X509KeyManager` | `android/` | L | Mirror the iOS ClientTLS package; no existing code. | +| B5 | (optional) scan-to-enroll: make the pairing QR carry a `device:enroll` credential (QR & device-enroll are disjoint today) | app + CP `pairing` | M | UX nicety; not required for correctness. | +| B6 | Desktop (Electron) programmatic OS-keychain install | `desktop/src` | M | **Deferred** — Chromium only reads certs from the OS store; today assumes pre-install. | + +**Deliverable B:** phone → login once → connects with an auto-issued, hardware-bound, auto-renewing cert. + +--- + +## 6. Deliberately deferred (layer on later, no rework) + +Attestation verifiers (Apple App Attest / Android Key-Attestation) — mandatory only before a 2nd +distrusting tenant · signed X.509 **CRL** + VPS reload webhook (MVP relies on 24h passive revocation) · +**B2** per-tenant name-constrained intermediates (gate for a 2nd customer) · RFC 8628 device-grant +(approve-in-app) wrapping the same `/enroll` · Windows service · agent autoupdate · legacy `.p12` +dual-trust migration window. + +--- + +## 7. Suggested order & milestones + +1. **Pre-flight §3** (esp. §3.1 CA material) — a spike; decides whether Track A is "deploy" or "also build a signer." +2. **Track A (A1–A8)** → **M-Host**: fresh Mac, one command, reboot-durable, self-renewing. *Ships the host half of the requirement.* +3. **B1 (login/bearer)** → unblocks everything phone-side. +4. **B3 (iOS)** → **M-Device-iOS**: iPhone auto-enrolls, no `.p12`. +5. **B4 (Android)** → **M-Device-Android**. +6. Later: attestation → CRL → B2 (per-tenant) = gate for a 2nd distrusting customer. + +## 8. Top risks + +- **[R1] Production native-CA wiring in the new CP (§3.1)** — **RE-ASSESSED 2026-07-18: a real (small–medium) + CODE task, the hard prerequisite for the VPS deploy.** The P-256 CA keys + certs exist on the VPS + (`/etc/relay/{device-ca,frp-client-ca}/*.key.pem`), BUT: (1) `env.ts` does **not** define + `NATIVE_FRP_CLIENT_CA_*` / `NATIVE_DEVICE_CA_*` (native-ca.ts:16 flags this as the "concrete follow-up"); + (2) `buildNativeCas` exists but is **never called from `server.ts`** — the prod boot doesn't wire the + native CAs at all (the 246 tests inject dev `inProcessP256CaSigner`s); (3) production wants a KMS resolver + and there is no file-backed signer to load the on-disk PEM keys. **Task A2-prep (do before deploy):** add + the `NATIVE_*` env vars + a file-backed `KmsResolver` (load PEM P-256 key → sign) + call `buildNativeCas` + in `server.ts` and thread the CAs into the enroll services, TDD + reviewed. File-key custody (not KMS) is + acceptable for the single-owner VPS (mirrors the existing `CA_INTERMEDIATE_KEY_PATH` file fallback); + document the KMS upgrade as future. +- **[R2] frps control-channel CA mismatch** — must be `frp-client-CA`, not `device-CA`; crossed = silent + 1006/handshake failure (this class of bug already cost days in M1). +- **[R3] nginx dNSName→Host binding** — the load-bearing isolation control; needs positive **and** + negative tests (leaf-A must be refused for subdomain-B) even in MVP. +- **[R4] Phone login trust seam (B1)** — the whole phone track hinges on a credential mechanism; a weak + seam here is a security hole (it mints certs). Scope it deliberately even for single-operator. +- **[R5] BIND_HOST loopback** — mandatory on every host; `0.0.0.0` re-opens an unauth shell on the LAN + bypassing mTLS. The agent enforces it (S-GATE) — keep it enforced. + +--- +_Grounded against `develop` + VPS `8.138.1.192` as of 2026-07-18. Design source: `docs/PLAN_TUNNEL_AUTOMATION.md`._ diff --git a/ios/App/WebTerm/Screens/EnrollmentScreen.swift b/ios/App/WebTerm/Screens/EnrollmentScreen.swift new file mode 100644 index 0000000..068f6d1 --- /dev/null +++ b/ios/App/WebTerm/Screens/EnrollmentScreen.swift @@ -0,0 +1,275 @@ +import ClientTLS +import Foundation +import Observation +import SwiftUI +import UIKit +import WireProtocol + +/// B3 · Zero-`.p12` device-enrollment screen — the phone half of zero-touch. +/// +/// Flow: one operator login → a short-lived `device:enroll` bearer → a +/// NON-EXPORTABLE Secure-Enclave key + PKCS#10 CSR → `POST /device/enroll` → +/// the returned leaf is stored against the SE key and presents AUTOMATICALLY on +/// the existing mTLS path (no manual certificate import, ever). After this one +/// login the cert renews itself silently (`CertificateRotationScheduler`). +/// +/// Pure presentation over `EnrollmentViewModel`; validation, the flow, and error +/// mapping live in the VM. Presented from the host-menu ("自动获取证书"). +struct EnrollmentScreen: View { + @State private var model: EnrollmentViewModel + + init(model: EnrollmentViewModel) { + _model = State(initialValue: model) + } + + var body: some View { + Form { + installedSection + enrollSection + } + .navigationTitle(EnrollmentCopy.title) + .task { model.refresh() } + } + + // MARK: - Sections + + @ViewBuilder private var installedSection: some View { + Section(EnrollmentCopy.installedHeader) { + if let summary = model.summary { + LabeledContent(EnrollmentCopy.subject, value: summary.subjectCommonName ?? "—") + LabeledContent(EnrollmentCopy.issuer, value: summary.issuerCommonName ?? "—") + LabeledContent(EnrollmentCopy.expiry, value: model.expiryText(summary)) + if summary.isExpired() { + Label(EnrollmentCopy.expiredWarning, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } + } else { + Text(EnrollmentCopy.noneInstalled).foregroundStyle(.secondary) + } + } + } + + @ViewBuilder private var enrollSection: some View { + Section { + TextField(EnrollmentCopy.controlPlaneURL, text: $model.controlPlaneURLText) + .textContentType(.URL) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField(EnrollmentCopy.subdomain, text: $model.subdomain) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField(EnrollmentCopy.deviceName, text: $model.deviceName) + SecureField(EnrollmentCopy.password, text: $model.password) + .textContentType(.password) + .autocorrectionDisabled() + Button { + Task { await model.enroll() } + } label: { + if model.isEnrolling { + ProgressView() + } else { + Label( + model.summary == nil + ? EnrollmentCopy.enrollAction + : EnrollmentCopy.rotateAction, + systemImage: "checkmark.shield" + ) + } + } + .disabled(!model.canEnroll) + .accessibilityIdentifier("enroll.submit") + + if let error = model.errorMessage { + Text(error).foregroundStyle(.red).font(.footnote) + } + if model.didSucceed { + Label(EnrollmentCopy.success, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green).font(.footnote) + } + } header: { + Text(model.summary == nil ? EnrollmentCopy.enrollHeader : EnrollmentCopy.rotateHeader) + } footer: { + Text(EnrollmentCopy.footer) + } + } +} + +/// B3 · Enrollment state machine. Validates inputs at the boundary, drives +/// `DeviceEnrollmentFlow`, and maps every failure to actionable copy — never +/// swallowed, never leaking the password or bearer. +@MainActor +@Observable +final class EnrollmentViewModel { + /// The enroll operation: login → SE keygen+CSR → enroll → store. Injected so + /// the VM is unit-testable without a keychain / network (production wires it + /// to `DeviceEnrollmentFlow`). + typealias EnrollOperation = @Sendable ( + _ password: String, _ subdomain: String, _ deviceName: String, _ controlPlaneURL: URL + ) async throws -> ClientCertificateSummary? + + var controlPlaneURLText: String + var subdomain: String + var deviceName: String + var password = "" + + private(set) var summary: ClientCertificateSummary? + private(set) var isEnrolling = false + private(set) var didSucceed = false + private(set) var errorMessage: String? + + @ObservationIgnored private let enrollOperation: EnrollOperation + @ObservationIgnored private let loadSummary: @Sendable () -> ClientCertificateSummary? + @ObservationIgnored private let persistSettings: @Sendable (_ url: String, _ subdomain: String) -> Void + + init( + controlPlaneURLText: String = EnrollmentSettings.controlPlaneURL ?? EnrollmentCopy.defaultControlPlaneURL, + subdomain: String = EnrollmentSettings.subdomain ?? "", + deviceName: String = UIDevice.current.name, + enrollOperation: @escaping EnrollOperation, + loadSummary: @escaping @Sendable () -> ClientCertificateSummary?, + persistSettings: @escaping @Sendable (_ url: String, _ subdomain: String) -> Void = { url, sub in + EnrollmentSettings.controlPlaneURL = url + EnrollmentSettings.subdomain = sub + } + ) { + self.controlPlaneURLText = controlPlaneURLText + self.subdomain = subdomain + self.deviceName = deviceName + self.enrollOperation = enrollOperation + self.loadSummary = loadSummary + self.persistSettings = persistSettings + } + + /// Fields all present → the button is enabled (deeper URL validation on tap). + var canEnroll: Bool { + !isEnrolling + && !controlPlaneURLText.trimmed.isEmpty + && !subdomain.trimmed.isEmpty + && !deviceName.trimmed.isEmpty + && !password.isEmpty + } + + func refresh() { + summary = loadSummary() + } + + func expiryText(_ summary: ClientCertificateSummary) -> String { + guard let notAfter = summary.notAfter else { return "—" } + return notAfter.formatted(date: .abbreviated, time: .omitted) + } + + /// Run enrollment. Validates the control-plane URL (must be https with a + /// host) before any network, then drives the flow. The password is cleared + /// after every attempt (success OR failure) so it never lingers in memory. + func enroll() async { + errorMessage = nil + didSucceed = false + let trimmedURL = controlPlaneURLText.trimmed + let trimmedSubdomain = subdomain.trimmed + let trimmedName = deviceName.trimmed + guard let url = URL(string: trimmedURL), + url.scheme?.lowercased() == "https", + let host = url.host, !host.isEmpty else { + errorMessage = EnrollmentCopy.errInvalidURL + return + } + guard !trimmedSubdomain.isEmpty, !trimmedName.isEmpty, !password.isEmpty else { + errorMessage = EnrollmentCopy.errMissingFields + return + } + isEnrolling = true + defer { isEnrolling = false } + let secret = password + do { + summary = try await enrollOperation(secret, trimmedSubdomain, trimmedName, url) + persistSettings(trimmedURL, trimmedSubdomain) + password = "" // never linger after success + didSucceed = true + refresh() + } catch { + password = "" // never linger after failure either + errorMessage = Self.copy(for: error) + } + } + + /// Map a login/enroll/keychain error to enrollment-UX copy (never swallowed, + /// never containing the password/token). + private static func copy(for error: any Error) -> String { + switch error { + case ControlPlaneLoginError.emptyPassword: + return EnrollmentCopy.errEmptyPassword + case ControlPlaneLoginError.http(let status, _) where status == 401: + return EnrollmentCopy.errBadCredential + case ControlPlaneLoginError.http: + return EnrollmentCopy.errLoginFailed + case ControlPlaneLoginError.malformedResponse: + return EnrollmentCopy.errServer + case DeviceEnrollmentError.http(let status, _) where status == 403: + return EnrollmentCopy.errSubdomainNotOwned + case DeviceEnrollmentError.http(let status, _) where status == 429: + return EnrollmentCopy.errRateLimited + case DeviceEnrollmentError.http(let status, _) where status == 400: + return EnrollmentCopy.errRejected + case DeviceEnrollmentError.http: + return EnrollmentCopy.errEnrollFailed + case DeviceEnrollmentError.malformedResponse: + return EnrollmentCopy.errServer + case SecureEnclaveKeyError.secureEnclaveUnavailable: + return EnrollmentCopy.errNoSecureEnclave + case is SecureEnclaveKeyError: + return EnrollmentCopy.errKeygen + case ClientIdentityStoreError.keychain, ClientIdentityStoreError.corruptStoredBlob: + return EnrollmentCopy.errKeychain + case DeviceEnrollmentWiringError.missingControlPlaneURL, + DeviceEnrollmentWiringError.invalidControlPlaneURL: + return EnrollmentCopy.errInvalidURL + default: + return EnrollmentCopy.errUnknown + } + } +} + +private extension String { + var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } +} + +/// User-facing copy (Chinese, matching the rest of the app). +enum EnrollmentCopy { + static let defaultControlPlaneURL = "https://cp.terminal.yaojia.wang" + + static let title = "自动获取证书" + static let installedHeader = "已安装证书" + static let subject = "设备 (CN)" + static let issuer = "签发 CA" + static let expiry = "有效期至" + static let expiredWarning = "证书已过期,请重新注册。" + static let noneInstalled = "尚未安装设备证书。" + + static let enrollHeader = "注册本设备" + static let rotateHeader = "重新注册" + static let controlPlaneURL = "控制面地址" + static let subdomain = "子域名 (你拥有的隧道名)" + static let deviceName = "设备名称" + static let password = "操作口令" + static let enrollAction = "注册本设备" + static let rotateAction = "重新注册" + static let success = "已注册,证书已保存到本设备安全区。" + static let footer = + "首次登录一次即可:本设备在安全区 (Secure Enclave) 生成不可导出的私钥,向控制面申请证书并自动保存;之后连接隧道主机时自动出示,证书到期前会自动续期,无需再手动导入 .p12。" + + static let errInvalidURL = "控制面地址无效,请输入 https:// 开头的完整地址。" + static let errMissingFields = "请填写子域名、设备名称和操作口令。" + static let errEmptyPassword = "请输入操作口令。" + static let errBadCredential = "操作口令错误,请重试。" + static let errLoginFailed = "登录失败,请稍后重试。" + static let errSubdomainNotOwned = "该账号未拥有此子域名,无法签发证书。" + static let errRateLimited = "请求过于频繁,请稍后再试。" + static let errRejected = "请求被拒绝:子域名或证书请求无效。" + static let errEnrollFailed = "注册失败,请稍后重试。" + static let errNoSecureEnclave = "本设备不支持安全区 (Secure Enclave)(模拟器?),无法生成硬件密钥。" + static let errKeygen = "生成硬件密钥失败,请重试。" + static let errKeychain = "保存到钥匙串失败,请重试。" + static let errServer = "服务器返回异常,请稍后重试。" + static let errUnknown = "注册失败,请重试。" +} diff --git a/ios/App/WebTerm/Screens/SessionListScreen.swift b/ios/App/WebTerm/Screens/SessionListScreen.swift index 7113323..d04ebe5 100644 --- a/ios/App/WebTerm/Screens/SessionListScreen.swift +++ b/ios/App/WebTerm/Screens/SessionListScreen.swift @@ -29,6 +29,11 @@ struct SessionListScreen: View { /// settings-like surface and is present in every empty state, so this is the /// nav idiom that makes the orphaned screen reachable. var onDeviceCert: () -> Void = {} + /// B3 · "自动获取证书" entry — presents `EnrollmentScreen` (the zero-`.p12` + /// enrollment flow: login → Secure-Enclave key + CSR → device cert). Same + /// host-menu surface as `onDeviceCert`; that screen imports a `.p12`, this + /// one obtains a hardware-bound cert with no file at all. + var onEnroll: () -> Void = {} /// T-iOS-28 (additive slot) · shared thumbnail pipeline: one cache + one /// render-concurrency gate across ALL rows (scrolling must never spawn /// unbounded offscreen terminals). `@State` keeps it stable across body @@ -219,6 +224,12 @@ struct SessionListScreen: View { } label: { Label(ScreenCopy.addHost, systemImage: "plus") } + Button { + onEnroll() + } label: { + Label(ScreenCopy.enroll, systemImage: "checkmark.shield") + } + .accessibilityIdentifier("sessions.enroll") Button { onDeviceCert() } label: { @@ -355,6 +366,7 @@ private enum ScreenCopy { static let kill = "结束" static let addHost = "配对新主机" static let deviceCert = "设备证书" + static let enroll = "自动获取证书" static let hostMenuFallback = "主机" static let notPairedTitle = "还没有配对的主机" static let notPairedHint = "先配对你电脑上的 web-terminal(扫码或手输地址),会话会出现在这里。" diff --git a/ios/App/WebTerm/Wiring/AdaptiveRootView.swift b/ios/App/WebTerm/Wiring/AdaptiveRootView.swift index 716e380..b91f9da 100644 --- a/ios/App/WebTerm/Wiring/AdaptiveRootView.swift +++ b/ios/App/WebTerm/Wiring/AdaptiveRootView.swift @@ -54,6 +54,12 @@ struct AdaptiveRootView: View { .sheet(isPresented: $coordinator.isDeviceCertPresented) { deviceCertSheet } + .sheet( + isPresented: $coordinator.isEnrollmentPresented, + onDismiss: { coordinator.enrollmentDismissed() } + ) { + enrollmentSheet + } } // MARK: - Layout branch (the SOLE size-class consumer) @@ -126,4 +132,21 @@ struct AdaptiveRootView: View { } } } + + // MARK: - Device enrollment sheet (B3, zero-.p12 auto-cert) + + /// 自带 NavigationStack + 右上「完成」。VM 由 coordinator 每次进入构建 + /// (`presentEnrollment`);关闭后 `enrollmentDismissed` 丢弃 VM 并跑一次轮换。 + @ViewBuilder private var enrollmentSheet: some View { + if let viewModel = coordinator.enrollmentViewModel { + NavigationStack { + EnrollmentScreen(model: viewModel) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(RootCopy.done) { coordinator.isEnrollmentPresented = false } + } + } + } + } + } } diff --git a/ios/App/WebTerm/Wiring/AppCoordinator.swift b/ios/App/WebTerm/Wiring/AppCoordinator.swift index 0f1b30e..ed457e0 100644 --- a/ios/App/WebTerm/Wiring/AppCoordinator.swift +++ b/ios/App/WebTerm/Wiring/AppCoordinator.swift @@ -1,3 +1,4 @@ +import ClientTLS import Foundation import HostRegistry import Observation @@ -36,6 +37,20 @@ final class AppCoordinator { /// its own `ClientCertViewModel` over the keychain store; dismissal needs no /// refresh because the transports resolve the identity lazily per connection. var isDeviceCertPresented = false + /// B3 · "自动获取证书" sheet — the zero-`.p12` enrollment flow + /// (`EnrollmentScreen`): one login → Secure-Enclave key + CSR → device cert, + /// presented automatically on the existing mTLS path. VM built per entry. + var isEnrollmentPresented = false + private(set) var enrollmentViewModel: EnrollmentViewModel? + /// B3 · Re-entrancy guard so overlapping foregrounds never launch two silent + /// renews at once (a shared PTY-style single-flight for the rotation pass). + @ObservationIgnored private var isRotating = false + /// B3 (HIGH observability fix) · Persistent, OBSERVABLE flag: the last silent + /// rotation pass ended in `.failed` (keychain read fault or renew error). Set + /// alongside the os.Logger line so a silently-failing renewal surfaces to the + /// UI (a warning banner) instead of vanishing into the log. Cleared by the + /// next non-failed pass — a recovered renewal drops the warning automatically. + private(set) var isCertificateRenewalFailing = false let sessionList: SessionListViewModel @ObservationIgnored let environment: AppEnvironment @@ -69,6 +84,7 @@ final class AppCoordinator { if route == .pairing { rootPairingViewModel = makePairingViewModel() } + runCertificateRotationIfDue() // B3 · renew a due device cert on cold launch await deepLink.markReady() // flush a cold-launch deep link (T-iOS-22) } @@ -120,6 +136,50 @@ final class AppCoordinator { isDeviceCertPresented = true } + // MARK: - Device enrollment (B3, zero-.p12 auto-cert) + + /// Toolbar host-menu 入口:呈现「自动获取证书」注册 sheet。VM 每次进入重建。 + func presentEnrollment() { + enrollmentViewModel = makeEnrollmentViewModel() + isEnrollmentPresented = true + } + + /// Sheet 消失:丢弃 VM,并立刻跑一次轮换检查(刚注册完的证书据此登记续期)。 + func enrollmentDismissed() { + enrollmentViewModel = nil + runCertificateRotationIfDue() + } + + /// 生产 VM:登录 → 安全区生成密钥+CSR → `/device/enroll` → 存钥匙串,全部经 + /// `DeviceEnrollmentFlow`(复用 ClientTLS 库,本层零加密/钥匙串逻辑)。 + private func makeEnrollmentViewModel() -> EnrollmentViewModel { + let http = environment.http + return EnrollmentViewModel( + enrollOperation: { password, subdomain, deviceName, url in + try await makeDeviceEnrollmentFlow(controlPlaneURL: url, http: http) + .run(password: password, subdomain: subdomain, deviceName: deviceName) + }, + loadSummary: { (try? KeychainClientIdentityStore().loadSummary()) ?? nil } + ) + } + + /// B3 · Silent rotation: check the installed leaf's timing and, if the renew + /// window has opened, renew over mTLS against the SAME Secure-Enclave key. + /// Fire-and-forget on launch + every foreground; single-flight via + /// `isRotating`. A `.notEnrolled` / `.notDue` pass is cheap and common. + func runCertificateRotationIfDue() { + guard !isRotating else { return } + isRotating = true + let scheduler = makeCertificateRotationScheduler(http: environment.http) + Task { [weak self] in + let outcome = await scheduler.runIfDue() + self?.isRotating = false + // Surface a silently-failing renewal as observable state (not only a + // log line); a later successful/not-due pass clears it. + self?.isCertificateRenewalFailing = (outcome == .failed) + } + } + /// "在此仓库开新会话":关 sheet → fresh spawn(`attach(null, cwd)`)+ /// attach 后注入 `claude\r`(帧序由 engine 的 attach-first 队列保证)。 func openProject(_ request: ProjectOpenRequest) { @@ -290,6 +350,7 @@ final class AppCoordinator { terminalController?.suspend() case .active: terminalController?.resumeIfNeeded() + runCertificateRotationIfDue() // B3 · check for a due renewal each foreground case .inactive: break // transient; shade covers it at the view layer @unknown default: diff --git a/ios/App/WebTerm/Wiring/DeviceEnrollmentWiring.swift b/ios/App/WebTerm/Wiring/DeviceEnrollmentWiring.swift new file mode 100644 index 0000000..da15941 --- /dev/null +++ b/ios/App/WebTerm/Wiring/DeviceEnrollmentWiring.swift @@ -0,0 +1,90 @@ +import ClientTLS +import Foundation +import WireProtocol + +/// B3 · Production wiring that connects the ClientTLS enrollment library to the +/// app's real transports. Kept in the assembly layer (not the leaf package) so +/// `ClientTLS` stays dependency-free and unit-testable; here we only adapt types +/// and read persisted config — no crypto, no keychain of our own. + +/// Adapts the app's `HTTPTransport` (WireProtocol) to `ClientTLS`'s +/// `EnrollmentTransport`. The two protocols have an identical `send` shape; this +/// one-line bridge lets the SAME `URLSessionHTTPTransport` — which already +/// presents the device identity on a client-cert challenge (mTLS) — serve login, +/// enroll AND the silent renew, so renew authenticates with the current cert. +struct EnrollmentTransportAdapter: EnrollmentTransport { + private let http: any HTTPTransport + + init(http: any HTTPTransport) { + self.http = http + } + + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + try await http.send(request) + } +} + +/// B3 · Non-secret enrollment config persisted for the silent rotation scheduler +/// (the control-plane URL to renew against) and to prefill the re-enroll form. +/// Namespaced statics over `UserDefaults.standard` — nothing secret is stored +/// here (the bearer is never persisted; the private key stays in the Secure +/// Enclave; the leaf/chain live in the keychain via `KeychainClientIdentityStore`). +enum EnrollmentSettings { + private static let controlPlaneURLKey = "webterm.enroll.controlPlaneURL" + private static let subdomainKey = "webterm.enroll.subdomain" + + static var controlPlaneURL: String? { + get { UserDefaults.standard.string(forKey: controlPlaneURLKey) } + set { UserDefaults.standard.set(newValue, forKey: controlPlaneURLKey) } + } + + static var subdomain: String? { + get { UserDefaults.standard.string(forKey: subdomainKey) } + set { UserDefaults.standard.set(newValue, forKey: subdomainKey) } + } +} + +enum DeviceEnrollmentWiringError: Error, Equatable, Sendable { + /// The control-plane URL is missing/invalid — cannot build a renew client. + case missingControlPlaneURL + /// The typed control-plane URL is not a valid absolute URL. + case invalidControlPlaneURL +} + +/// Build the enrollment flow (login → SE keygen+CSR → enroll → store) against the +/// given control-plane URL, reusing the app's mTLS-capable transport. +func makeDeviceEnrollmentFlow( + controlPlaneURL: URL, + http: any HTTPTransport, + store: KeychainClientIdentityStore = KeychainClientIdentityStore() +) -> DeviceEnrollmentFlow { + DeviceEnrollmentFlow( + baseURL: controlPlaneURL, + transport: EnrollmentTransportAdapter(http: http), + store: store + ) +} + +/// Build the silent rotation scheduler. `renewalState` reads the installed leaf's +/// timing; `performRenew` re-CSRs from the SAME Secure-Enclave key and POSTs +/// `/device/:id/renew` over mTLS (nil bearer — the current cert authenticates). +/// The control-plane URL is read fresh from `EnrollmentSettings` at renew time. +func makeCertificateRotationScheduler( + http: any HTTPTransport, + store: KeychainClientIdentityStore = KeychainClientIdentityStore() +) -> CertificateRotationScheduler { + let transport = EnrollmentTransportAdapter(http: http) + return CertificateRotationScheduler( + renewalState: { try store.renewalState() }, + performRenew: { + guard let urlString = EnrollmentSettings.controlPlaneURL, + let url = URL(string: urlString) else { + throw DeviceEnrollmentWiringError.missingControlPlaneURL + } + // nil bearer: /device/:id/renew authenticates by the presented + // client cert (mTLS), which the adapter's transport supplies. + let client = DeviceEnrollmentClient(baseURL: url, bearerToken: nil, transport: transport) + return try await store.renew(using: client) + } + ) +} diff --git a/ios/App/WebTerm/Wiring/RootView.swift b/ios/App/WebTerm/Wiring/RootView.swift index cd22681..a2f7255 100644 --- a/ios/App/WebTerm/Wiring/RootView.swift +++ b/ios/App/WebTerm/Wiring/RootView.swift @@ -75,9 +75,13 @@ struct StackRootView: View { viewModel: coordinator.sessionList, onOpen: { coordinator.open($0) }, onAddHost: { coordinator.presentAddHost() }, - onDeviceCert: { coordinator.presentDeviceCert() } + onDeviceCert: { coordinator.presentDeviceCert() }, + onEnroll: { coordinator.presentEnrollment() } ) .safeAreaInset(edge: .bottom) { continueLastBanner } + // B3 (HIGH) · A silently-failing device-cert renewal is surfaced here + // (top inset), so it is observable instead of buried in os.Logger. + .safeAreaInset(edge: .top) { certRenewalWarningBanner } // 横幅出现/消失走 DS 动效(reduceMotion 时塌成瞬切,无位移)。 .animation( DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion), @@ -98,6 +102,15 @@ struct StackRootView: View { } } + // MARK: - Device-cert renewal warning (B3 HIGH observability fix) + + @ViewBuilder private var certRenewalWarningBanner: some View { + if coordinator.isCertificateRenewalFailing { + CertRenewalWarningBanner() + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + // MARK: - Terminal push private var terminalBinding: Binding { @@ -155,6 +168,32 @@ struct ContinueLastBanner: View { } } +// MARK: - Device-cert renewal warning (B3 HIGH observability fix) + +/// A quiet, non-blocking warning that the silent device-certificate renewal is +/// failing — surfaced from `AppCoordinator.isCertificateRenewalFailing` so a +/// failing renewal is observable in the UI instead of only in os.Logger. Amber +/// (the `waiting`/needs-me status color) + an SF Symbol so meaning is never +/// carried by color alone. Non-interactive: the existing cert stays valid until +/// expiry, and the next foreground retries automatically. +struct CertRenewalWarningBanner: View { + var body: some View { + Label(RootCopy.certRenewalFailing, systemImage: "exclamationmark.shield") + .font(DS.Typography.caption) + .foregroundStyle(DS.Palette.statusWaiting) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, DS.Space.lg16) + .padding(.vertical, DS.Space.sm8) + .background(.regularMaterial) + .overlay(alignment: .bottom) { + Rectangle() + .fill(DS.Palette.hairline) + .frame(height: DS.Stroke.hairline) + } + .accessibilityIdentifier("root.certRenewalWarning") + } +} + // MARK: - Projects toolbar item (shared stack + split, DRY) /// 「项目」leading 工具栏入口 —— stack 与 split 共用(同 disabled 条件、同 @@ -180,4 +219,6 @@ enum RootCopy { static let continueLast = "继续上次会话" static let projects = "项目" static let done = "完成" + /// B3 (HIGH) · Shown when the silent device-cert renewal keeps failing. + static let certRenewalFailing = "设备证书自动续期失败,将在下次前台重试" } diff --git a/ios/App/WebTerm/Wiring/SplitRootView.swift b/ios/App/WebTerm/Wiring/SplitRootView.swift index 97bb24d..362d8eb 100644 --- a/ios/App/WebTerm/Wiring/SplitRootView.swift +++ b/ios/App/WebTerm/Wiring/SplitRootView.swift @@ -40,7 +40,8 @@ struct SplitRootView: View { coordinator.selectSidebarItem(sidebarItem(for: request)) }, onAddHost: { coordinator.presentAddHost() }, - onDeviceCert: { coordinator.presentDeviceCert() } + onDeviceCert: { coordinator.presentDeviceCert() }, + onEnroll: { coordinator.presentEnrollment() } ) .safeAreaInset(edge: .bottom) { continueLastBanner } .animation( diff --git a/ios/App/WebTermTests/EnrollmentViewModelTests.swift b/ios/App/WebTermTests/EnrollmentViewModelTests.swift new file mode 100644 index 0000000..39aa85b --- /dev/null +++ b/ios/App/WebTermTests/EnrollmentViewModelTests.swift @@ -0,0 +1,142 @@ +import ClientTLS +import Foundation +import Testing +@testable import WebTerm + +/// B3 · EnrollmentViewModel — the zero-`.p12` enrollment flow's state machine. +/// The enroll operation is injected as a closure (the flow logic itself is +/// covered headlessly in ClientTLS's DeviceEnrollmentFlowTests); these cover the +/// VM's boundary validation, success bookkeeping, and error→copy mapping — +/// including that the password never lingers after an attempt. +@MainActor +@Suite("EnrollmentViewModel") +struct EnrollmentViewModelTests { + /// Records enroll invocations + replays a scripted result/error. + private actor EnrollScript { + private(set) var calls: [(password: String, subdomain: String, deviceName: String, url: URL)] = [] + private let result: Result + + init(result: Result) { + self.result = result + } + + func run(_ p: String, _ s: String, _ d: String, _ u: URL) throws -> ClientCertificateSummary? { + calls.append((p, s, d, u)) + return try result.get() + } + + var callCount: Int { calls.count } + var lastPassword: String? { calls.last?.password } + } + + /// Records persisted settings so a test can assert them without real defaults. + private final class PersistSpy: @unchecked Sendable { + private let lock = NSLock() + private(set) var url: String? + private(set) var subdomain: String? + func persist(_ url: String, _ subdomain: String) { + lock.withLock { self.url = url; self.subdomain = subdomain } + } + } + + private func makeVM( + script: EnrollScript, + persist: PersistSpy = PersistSpy(), + url: String = "https://cp.terminal.yaojia.wang", + subdomain: String = "alice", + summary: ClientCertificateSummary? = nil + ) -> EnrollmentViewModel { + EnrollmentViewModel( + controlPlaneURLText: url, + subdomain: subdomain, + deviceName: "Test iPhone", + enrollOperation: { p, s, d, u in try await script.run(p, s, d, u) }, + loadSummary: { summary }, + persistSettings: { persist.persist($0, $1) } + ) + } + + @Test("a successful enroll sets the summary, flags success, clears the password, persists settings") + func successPath() async { + let sum = ClientCertificateSummary( + subjectCommonName: "Test iPhone", issuerCommonName: "webterm-device-ca", notAfter: nil + ) + let script = EnrollScript(result: .success(sum)) + let persist = PersistSpy() + let vm = makeVM(script: script, persist: persist, summary: sum) + vm.password = "operator-secret" + + await vm.enroll() + + #expect(await script.callCount == 1) + #expect(vm.didSucceed == true) + #expect(vm.errorMessage == nil) + #expect(vm.password == "") // never lingers + #expect(vm.summary?.subjectCommonName == "Test iPhone") + #expect(persist.url == "https://cp.terminal.yaojia.wang") + #expect(persist.subdomain == "alice") + } + + @Test("a non-https control-plane URL is rejected before any network") + func rejectsInsecureURL() async { + let script = EnrollScript(result: .success(nil)) + let vm = makeVM(script: script, url: "http://cp.terminal.yaojia.wang") + vm.password = "pw" + + await vm.enroll() + + #expect(await script.callCount == 0) // zero network + #expect(vm.errorMessage == EnrollmentCopy.errInvalidURL) + #expect(vm.didSucceed == false) + } + + @Test("a 403 subdomain-not-owned maps to actionable copy; password still cleared") + func mapsSubdomainNotOwned() async { + let script = EnrollScript( + result: .failure(DeviceEnrollmentError.http(status: 403, code: "rejected")) + ) + let vm = makeVM(script: script) + vm.password = "operator-secret" + + await vm.enroll() + + #expect(vm.errorMessage == EnrollmentCopy.errSubdomainNotOwned) + #expect(vm.didSucceed == false) + #expect(vm.password == "") // cleared even on failure + } + + @Test("a 401 login maps to the bad-credential copy") + func mapsBadCredential() async { + let script = EnrollScript( + result: .failure(ControlPlaneLoginError.http(status: 401, code: "login rejected")) + ) + let vm = makeVM(script: script) + vm.password = "wrong" + + await vm.enroll() + #expect(vm.errorMessage == EnrollmentCopy.errBadCredential) + } + + @Test("Secure Enclave unavailable (simulator) maps to a clear message") + func mapsNoSecureEnclave() async { + let script = EnrollScript( + result: .failure(SecureEnclaveKeyError.secureEnclaveUnavailable("no entitlement")) + ) + let vm = makeVM(script: script) + vm.password = "operator-secret" + + await vm.enroll() + #expect(vm.errorMessage == EnrollmentCopy.errNoSecureEnclave) + } + + @Test("canEnroll requires every field including a non-empty password") + func canEnrollGating() { + let script = EnrollScript(result: .success(nil)) + let vm = makeVM(script: script) + #expect(vm.canEnroll == false) // password empty + vm.password = "pw" + #expect(vm.canEnroll == true) + vm.subdomain = " " + #expect(vm.canEnroll == false) // whitespace-only subdomain + } +} diff --git a/ios/Packages/ClientTLS/Sources/ClientTLS/CertificateRotationScheduler.swift b/ios/Packages/ClientTLS/Sources/ClientTLS/CertificateRotationScheduler.swift new file mode 100644 index 0000000..a4ee797 --- /dev/null +++ b/ios/Packages/ClientTLS/Sources/ClientTLS/CertificateRotationScheduler.swift @@ -0,0 +1,107 @@ +import Foundation +import os + +/// B3 · Rotation timing of an installed enrolled (Secure-Enclave) identity. The +/// scheduler reads this to decide whether to pre-empt expiry. The private key +/// itself never leaves the Secure Enclave, so only advisory timing surfaces. +public struct DeviceRenewalState: Equatable, Sendable { + /// The enrolled device id — drives `POST /device/:id/renew`. + public let deviceId: String + /// Leaf `notAfter` (hard expiry — the TLS stack rejects past it). + public let notAfter: Date? + /// When to renew from the same hardware key (~2/3 of the lifetime). + public let renewAfter: Date? + + public init(deviceId: String, notAfter: Date?, renewAfter: Date?) { + self.deviceId = deviceId + self.notAfter = notAfter + self.renewAfter = renewAfter + } + + /// Is the leaf due for renewal as of `now`? A missing `renewAfter` NEVER + /// triggers (fail-safe — the TLS stack is the real gate; the scheduler only + /// pre-empts expiry). Mirrors `EnrollmentResult.isRenewalDue`. + public func isRenewalDue(asOf now: Date) -> Bool { + guard let renewAfter else { return false } + return now >= renewAfter + } +} + +/// B3 · The silent rotation scheduler: on a trigger (app foreground / launch) it +/// checks the installed identity's rotation timing and, if the renew window has +/// opened, drives an mTLS renew against the SAME Secure-Enclave key. This is the +/// "one bootstrap tap, then never again" half of zero-touch — after the first +/// login+enroll, the cert renews itself with no human action. +/// +/// Deliberately pure and transport-free: `renewalState` and `performRenew` are +/// injected closures, so the whole decision table is unit-testable with fakes +/// (no keychain, no network). Production wires `renewalState` to the keychain +/// store and `performRenew` to an mTLS `DeviceEnrollmentClient` renew (nil +/// bearer — the current cert authenticates). +/// +/// Failure posture: a keychain read fault or a renew error is surfaced as +/// `.failed` and logged (never a secret), NEVER a crash — the existing cert +/// stays valid until `notAfter`, so a transient renew failure is recoverable on +/// the next trigger. +public struct CertificateRotationScheduler: Sendable { + /// Current rotation timing; `nil` = nothing enrolled to renew. + private let renewalState: @Sendable () throws -> DeviceRenewalState? + /// The mTLS renew operation (re-CSR from the SE key → replace the leaf). + private let performRenew: @Sendable () async throws -> ClientCertificateSummary? + private let now: @Sendable () -> Date + + public init( + renewalState: @escaping @Sendable () throws -> DeviceRenewalState?, + performRenew: @escaping @Sendable () async throws -> ClientCertificateSummary?, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.renewalState = renewalState + self.performRenew = performRenew + self.now = now + } + + /// The outcome of a single scheduler pass — total over the decision table. + public enum Outcome: Equatable, Sendable { + /// No enrolled identity (fresh install / legacy `.p12`): nothing to do. + case notEnrolled + /// Enrolled but the renew window has not opened yet. + case notDue(renewAfter: Date?) + /// The renew succeeded; the new leaf is installed for the next handshake. + case renewed + /// A read/renew error occurred; logged, cert still valid until expiry. + case failed + } + + /// Run one pass: read timing → decide → renew if due. Idempotent and safe to + /// call on every foreground; `.notDue` is the common (cheap) case. + public func runIfDue() async -> Outcome { + let state: DeviceRenewalState? + do { + state = try renewalState() + } catch { + RotationLog.log.error( + "rotation: renewal-state read failed: \(String(describing: error), privacy: .public)" + ) + return .failed + } + guard let state else { return .notEnrolled } + guard state.isRenewalDue(asOf: now()) else { + return .notDue(renewAfter: state.renewAfter) + } + do { + _ = try await performRenew() + RotationLog.log.info("rotation: device certificate renewed silently") + return .renewed + } catch { + // No secrets in the log — only the error shape (never token/cert bytes). + RotationLog.log.error( + "rotation: silent renew failed: \(String(describing: error), privacy: .public)" + ) + return .failed + } + } +} + +private enum RotationLog { + static let log = Logger(subsystem: "com.yaojia.webterm", category: "cert-rotation") +} diff --git a/ios/Packages/ClientTLS/Sources/ClientTLS/ControlPlaneLoginClient.swift b/ios/Packages/ClientTLS/Sources/ClientTLS/ControlPlaneLoginClient.swift new file mode 100644 index 0000000..afe4d9a --- /dev/null +++ b/ios/Packages/ClientTLS/Sources/ClientTLS/ControlPlaneLoginClient.swift @@ -0,0 +1,108 @@ +import Foundation + +/// B3 · The phone-track bootstrap: exchanges the operator's one-time credential +/// for a short-lived `device:enroll` bearer at the control-plane. +/// +/// PINNED login contract (all pieces MUST agree — iOS / Android / server B1): +/// +/// `POST /auth/login { "password": "" }` +/// → 201 { "enrollToken": "", "accountId": "", "expiresIn": } +/// +/// The returned `enrollToken` is then presented as `Authorization: Bearer …` to +/// `DeviceEnrollmentClient.enroll`. This client is deliberately logic-free about +/// TLS and reuses the SAME `EnrollmentTransport` seam as `DeviceEnrollmentClient`, +/// so production injects `URLSessionHTTPTransport` and tests inject a stub. +/// +/// Security posture: +/// - The password is validated at the boundary (non-empty) and sent ONCE; it is +/// never persisted, never logged, and no other field rides along in the body. +/// - The `enrollToken` is a secret bearer: it is never logged here either. Only +/// the transport (which sees it in a header) and the caller hold it, briefly. +public struct ControlPlaneLoginClient: Sendable { + private let baseURL: URL + private let transport: any EnrollmentTransport + + public init(baseURL: URL, transport: any EnrollmentTransport) { + self.baseURL = baseURL + self.transport = transport + } + + /// Exchange the operator credential for a `device:enroll` bearer. + /// + /// - Throws: `ControlPlaneLoginError.emptyPassword` (before any network), + /// `.http(status:code:)` on a non-201 (401 = bad/missing credential), + /// `.malformedResponse` on a 201 body that does not decode. + public func login(password: String) async throws -> LoginResult { + // Boundary validation — fail fast, never send an empty credential. + guard !password.isEmpty else { throw ControlPlaneLoginError.emptyPassword } + + guard let url = URL(string: "/auth/login", relativeTo: baseURL) else { + throw ControlPlaneLoginError.malformedResponse + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + // Password ONLY — no accountId / device fields leak from the client. + request.httpBody = try JSONSerialization.data(withJSONObject: ["password": password]) + + let (data, response) = try await transport.send(request) + guard response.statusCode == 201 else { + throw ControlPlaneLoginError.http(status: response.statusCode, code: errorCode(in: data)) + } + guard let dto = try? JSONDecoder().decode(LoginResponseDTO.self, from: data) else { + throw ControlPlaneLoginError.malformedResponse + } + return dto.toResult() + } + + private func errorCode(in data: Data) -> String? { + (try? JSONDecoder().decode(LoginErrorDTO.self, from: data))?.error + } +} + +// MARK: - Result & errors + +/// The login exchange result. The `enrollToken` is a short-lived secret bearer; +/// hold it only as long as needed to drive `DeviceEnrollmentClient.enroll`. +public struct LoginResult: Equatable, Sendable { + /// The `device:enroll` capability bearer to present to `/device/enroll`. + public let enrollToken: String + /// The authenticated account (the token `sub`); the device enrolls under a + /// subdomain this account owns (server enforces ownership, deny-by-default). + public let accountId: String + /// Token lifetime in seconds (minutes-scale). Advisory: the enrollment must + /// complete within this window or the bearer is re-minted via another login. + public let expiresIn: Int + + public init(enrollToken: String, accountId: String, expiresIn: Int) { + self.enrollToken = enrollToken + self.accountId = accountId + self.expiresIn = expiresIn + } +} + +public enum ControlPlaneLoginError: Error, Equatable, Sendable { + /// The password was empty — rejected before any network call. + case emptyPassword + /// Non-success HTTP status with the server's uniform `{ error }` code, if any + /// (401 = bad/missing credential, 429 = rate_limited). + case http(status: Int, code: String?) + /// A 2xx body that did not decode to the expected shape. + case malformedResponse +} + +// MARK: - Wire DTOs + +private struct LoginResponseDTO: Decodable { + let enrollToken: String + let accountId: String + let expiresIn: Int + + func toResult() -> LoginResult { + LoginResult(enrollToken: enrollToken, accountId: accountId, expiresIn: expiresIn) + } +} + +private struct LoginErrorDTO: Decodable { + let error: String +} diff --git a/ios/Packages/ClientTLS/Sources/ClientTLS/DeviceEnrollmentClient.swift b/ios/Packages/ClientTLS/Sources/ClientTLS/DeviceEnrollmentClient.swift index a0f533c..c283900 100644 --- a/ios/Packages/ClientTLS/Sources/ClientTLS/DeviceEnrollmentClient.swift +++ b/ios/Packages/ClientTLS/Sources/ClientTLS/DeviceEnrollmentClient.swift @@ -16,11 +16,15 @@ import Foundation /// server's `decodeCsrWire` accepts directly. public struct DeviceEnrollmentClient: Sendable { private let baseURL: URL - /// The one-time account `device:enroll` bearer obtained at login. - private let bearerToken: String + /// The one-time account `device:enroll` bearer obtained at login. `nil` on + /// the renew path, which authenticates by the CURRENT device client cert + /// (mTLS) — the silent rotation scheduler has no fresh bearer weeks later, so + /// it constructs the client with `nil` and relies on the transport presenting + /// the installed identity. When `nil`, no `Authorization` header is sent. + private let bearerToken: String? private let transport: any EnrollmentTransport - public init(baseURL: URL, bearerToken: String, transport: any EnrollmentTransport) { + public init(baseURL: URL, bearerToken: String? = nil, transport: any EnrollmentTransport) { self.baseURL = baseURL self.bearerToken = bearerToken self.transport = transport @@ -42,10 +46,12 @@ public struct DeviceEnrollmentClient: Sendable { } /// Renew against the SAME hardware key (A6 seam): a fresh CSR to - /// `/device/:id/renew`. Server support lands in A6; the client shape is here - /// so the rotation scheduler has an endpoint to drive. + /// `/device/:id/renew`. The endpoint authenticates by the presented mTLS cert + /// and its schema is strict — the body is `{ csr }` ONLY (NOT the enroll body). + /// A stray `keyAlg` (or any non-`csr` field) trips server validation and 400s + /// every silent renewal, so it is deliberately absent here. public func renew(deviceId: String, csrDER: Data) async throws -> EnrollmentResult { - let body = ["csr": csrDER.base64EncodedString(), "keyAlg": "ec-p256"] + let body = ["csr": csrDER.base64EncodedString()] let path = "/device/\(deviceId)/renew" let request = try makeJSONRequest(path: path, jsonObject: body) return try await sendExpectingLeaf(request) @@ -73,7 +79,11 @@ public struct DeviceEnrollmentClient: Sendable { } var request = URLRequest(url: url) request.httpMethod = "POST" - request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") + // Bearer only when present (enroll). The renew path passes nil and + // authenticates via the presented client certificate (mTLS). + if let bearerToken { + request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") + } request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: jsonObject) return request diff --git a/ios/Packages/ClientTLS/Sources/ClientTLS/DeviceEnrollmentFlow.swift b/ios/Packages/ClientTLS/Sources/ClientTLS/DeviceEnrollmentFlow.swift new file mode 100644 index 0000000..fbcf706 --- /dev/null +++ b/ios/Packages/ClientTLS/Sources/ClientTLS/DeviceEnrollmentFlow.swift @@ -0,0 +1,63 @@ +import Foundation + +/// B3 · The phone-track enrollment FLOW — the composition that turns the pinned +/// bootstrap contract into an installed, hardware-bound device identity: +/// +/// 1. `POST /auth/login { password }` → short-lived `device:enroll` bearer. +/// 2. Build a `DeviceEnrollmentClient` authenticated with THAT bearer. +/// 3. `install` = generate a NON-EXPORTABLE Secure-Enclave key, self-sign a +/// PKCS#10 CSR, `POST /device/enroll`, and store the returned leaf against +/// the SE key — so it presents automatically on the existing mTLS path. +/// +/// It reuses the enrollment library wholesale (`ControlPlaneLoginClient`, +/// `DeviceEnrollmentClient`, `KeychainClientIdentityStore.enroll`) and adds NO +/// crypto/keychain of its own — it is pure orchestration, so it is unit-testable +/// with a stub transport + a fake installer (no network, no keychain). +/// +/// The `install` step is injected (rather than a hard dependency on the keychain +/// store) so the composition is testable off-device; production supplies the +/// `KeychainClientIdentityStore` convenience initializer below. +public struct DeviceEnrollmentFlow: Sendable { + /// The install step: given the bearer-authenticated client + the requested + /// subdomain/name, generate the SE key + CSR, enroll, and persist the leaf. + /// Returns the installed cert summary (nil only if the leaf can't be read). + public typealias Install = @Sendable ( + _ client: DeviceEnrollmentClient, _ subdomain: String, _ deviceName: String + ) async throws -> ClientCertificateSummary? + + private let baseURL: URL + private let transport: any EnrollmentTransport + private let install: Install + + public init(baseURL: URL, transport: any EnrollmentTransport, install: @escaping Install) { + self.baseURL = baseURL + self.transport = transport + self.install = install + } + + /// Production convenience: wire `install` to the keychain store's + /// Secure-Enclave enrollment (the `.p12`-free path). The SE key is generated + /// inside `store.enroll` and never leaves hardware. + public init( + baseURL: URL, transport: any EnrollmentTransport, store: KeychainClientIdentityStore + ) { + self.init(baseURL: baseURL, transport: transport) { client, subdomain, deviceName in + try await store.enroll(using: client, subdomain: subdomain, deviceName: deviceName) + } + } + + /// Run the full bootstrap. Errors propagate unchanged so the UI can map them: + /// `ControlPlaneLoginError` (login step), `DeviceEnrollmentError` (enroll + /// step, e.g. 403 subdomain-not-owned), or a keychain/CSR error from install. + /// A login failure short-circuits — the enroll step never runs. + public func run( + password: String, subdomain: String, deviceName: String + ) async throws -> ClientCertificateSummary? { + let login = try await ControlPlaneLoginClient(baseURL: baseURL, transport: transport) + .login(password: password) + let client = DeviceEnrollmentClient( + baseURL: baseURL, bearerToken: login.enrollToken, transport: transport + ) + return try await install(client, subdomain, deviceName) + } +} diff --git a/ios/Packages/ClientTLS/Sources/ClientTLS/KeychainClientIdentityStore.swift b/ios/Packages/ClientTLS/Sources/ClientTLS/KeychainClientIdentityStore.swift index ab21fd6..ec0cb71 100644 --- a/ios/Packages/ClientTLS/Sources/ClientTLS/KeychainClientIdentityStore.swift +++ b/ios/Packages/ClientTLS/Sources/ClientTLS/KeychainClientIdentityStore.swift @@ -172,6 +172,20 @@ public struct KeychainClientIdentityStore: ClientIdentityStore { return try loadSummary() } + /// B3 · Rotation timing of the installed enrolled (Secure-Enclave) identity, + /// read for the silent rotation scheduler. `nil` when there is no enrolled + /// identity (a fresh install, or a legacy `.p12`-only device — neither of + /// which the scheduler can silently renew). The private key never leaves the + /// Secure Enclave, so only the advisory timing + deviceId surface here. + public func renewalState() throws -> DeviceRenewalState? { + guard let record = try readEnrollment() else { return nil } + return DeviceRenewalState( + deviceId: record.deviceId, + notAfter: record.notAfter, + renewAfter: record.renewAfter + ) + } + /// The Secure-Enclave-backed identity: the assembled `SecIdentity` (leaf bound /// to the SE key) plus the stored issuer chain. `nil` when not enrolled. func loadDeviceIdentity() throws -> ClientIdentity? { @@ -205,31 +219,62 @@ public struct KeychainClientIdentityStore: ClientIdentityStore { return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess } - /// Persist the enrolled leaf (delete-then-add so rotation replaces the prior - /// one) plus the enrollment record. The leaf binds to the permanent SE key, - /// letting the keychain form the identity on load. + /// Persist the enrolled leaf plus the enrollment record. The leaf binds to the + /// permanent SE key, letting the keychain form the identity on load. + /// + /// ATOMIC replace (reached automatically by the rotation scheduler): ADD the + /// new leaf BEFORE deleting the prior one — the inverse of delete-then-add, + /// whose window has zero leaves installed. An interrupted write therefore + /// never strands the device without an identity. The prior leaf is deleted by + /// its captured persistent ref (both share `leafLabel`, so a label-only delete + /// would also remove the just-added replacement). private func storeEnrolledLeaf(_ result: EnrollmentResult, deviceName: String) throws { guard let certificate = SecCertificateCreateWithData(nil, result.certificate as CFData) else { throw ClientIdentityStoreError.corruptStoredBlob // not a valid DER cert } - let deleteLeaf: [String: Any] = [ - kSecClass as String: kSecClassCertificate, - kSecAttrLabel as String: leafLabel, - ] - let deleteStatus = SecItemDelete(deleteLeaf as CFDictionary) - guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else { - throw ClientIdentityStoreError.keychain(deleteStatus) - } - let addLeaf: [String: Any] = [ - kSecClass as String: kSecClassCertificate, - kSecValueRef as String: certificate, - kSecAttrLabel as String: leafLabel, - ] - let addStatus = SecItemAdd(addLeaf as CFDictionary, nil) - guard addStatus == errSecSuccess else { - throw ClientIdentityStoreError.keychain(addStatus) - } + // Capture the prior leaf BEFORE the add so it can be deleted by identity + // afterwards. `nil` on first enroll (nothing to replace). + let priorLeafRef = existingLeafPersistentRef() + var addedDistinctLeaf = false + try replaceKeychainItemAtomically( + addNew: { + let addLeaf: [String: Any] = [ + kSecClass as String: kSecClassCertificate, + kSecValueRef as String: certificate, + kSecAttrLabel as String: leafLabel, + ] + let addStatus = SecItemAdd(addLeaf as CFDictionary, nil) + switch addStatus { + case errSecSuccess: + addedDistinctLeaf = true // a new, distinct leaf now coexists with the old + case errSecDuplicateItem: + // Byte-identical re-store (same serial): the leaf is already + // installed. Keep it and DO NOT delete the prior ref — it is + // the very item we just tried to add. + addedDistinctLeaf = false + default: + throw ClientIdentityStoreError.keychain(addStatus) + } + }, + deleteOld: { + // Only sweep the prior leaf once a distinct replacement is in place. + guard addedDistinctLeaf, let priorLeafRef else { return } + let deleteStatus = SecItemDelete( + [kSecValuePersistentRef as String: priorLeafRef] as CFDictionary + ) + guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else { + throw ClientIdentityStoreError.keychain(deleteStatus) + } + }, + reportStaleDeleteFailure: { error in + // Non-fatal: the new leaf is installed (renewal succeeded). A stale + // leftover binds to the same SE key and is swept on the next write. + ClientTLSLog.identity.error( + "storeEnrolledLeaf: stale leaf delete failed (new leaf installed): \(String(describing: error), privacy: .public)" + ) + } + ) try writeEnrollment( StoredEnrollment( deviceId: result.deviceId, @@ -241,6 +286,22 @@ public struct KeychainClientIdentityStore: ClientIdentityStore { ) } + /// Persistent reference of the currently-installed enrolled leaf (by label), + /// captured before an atomic replace so the prior copy can be deleted by + /// identity. `nil` when none is installed or the lookup fails (first enroll). + private func existingLeafPersistentRef() -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassCertificate, + kSecAttrLabel as String: leafLabel, + kSecReturnPersistentRef as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return data + } + /// Delete the SE key, the enrolled leaf, and the enrollment record. Idempotent. private func removeEnrolled() throws { let deleteLeaf: [String: Any] = [ @@ -273,9 +334,17 @@ public struct KeychainClientIdentityStore: ClientIdentityStore { } catch { throw ClientIdentityStoreError.corruptStoredBlob } - let deleteStatus = SecItemDelete(enrollmentQuery() as CFDictionary) - guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else { - throw ClientIdentityStoreError.keychain(deleteStatus) + // ATOMIC upsert (reached automatically by the rotation scheduler): update + // the DATA in place — the (service, account) primary key and protection + // class are unchanged on renewal, so no delete-then-add window can strand + // the device without its enrollment record. Fall back to add on first write. + let updateStatus = SecItemUpdate( + enrollmentQuery() as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { + throw ClientIdentityStoreError.keychain(updateStatus) } var attributes = enrollmentQuery() attributes[kSecValueData as String] = data diff --git a/ios/Packages/ClientTLS/Sources/ClientTLS/KeychainItemReplace.swift b/ios/Packages/ClientTLS/Sources/ClientTLS/KeychainItemReplace.swift new file mode 100644 index 0000000..dbed93c --- /dev/null +++ b/ios/Packages/ClientTLS/Sources/ClientTLS/KeychainItemReplace.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Replace a keychain-backed item so a crash/failure mid-write can NEVER leave +/// zero installed copies — which, for the device identity, would lock the device +/// out of its own mTLS cert until a manual re-enroll. This is the inverse of a +/// delete-then-add sequence, whose window between the two calls has zero items: +/// +/// 1. `addNew` FIRST. Once it succeeds the replacement is installed, so at +/// least one valid item exists from here on. If `addNew` throws, the prior +/// item is left untouched (the device keeps a working identity) and the +/// error propagates. +/// 2. `deleteOld` SECOND, removing the prior copy. A failure here is NON-fatal: +/// the new item is already installed, so the write is a success. The stale +/// leftover is reported via `reportStaleDeleteFailure` (never a lockout) and +/// swept on the next write — throwing here would strand a working new cert. +/// +/// Kept pure (closures, no `Security` types) so the ordering invariant is unit- +/// testable with fakes; the keychain plumbing lives at the call sites. +func replaceKeychainItemAtomically( + addNew: () throws -> Void, + deleteOld: () throws -> Void, + reportStaleDeleteFailure: (Error) -> Void = { _ in } +) throws { + try addNew() + do { + try deleteOld() + } catch { + reportStaleDeleteFailure(error) + } +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/CertificateRotationSchedulerTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/CertificateRotationSchedulerTests.swift new file mode 100644 index 0000000..af58086 --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/CertificateRotationSchedulerTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing +@testable import ClientTLS + +// B3 · The silent rotation scheduler decides — from the installed identity's +// rotation timing — whether to renew before expiry, then drives an injected +// mTLS renew. Pure and transport-free: fully unit-testable with fakes. + +private let t0 = ISO8601DateFormatter().date(from: "2026-09-05T00:00:00Z")! +private let renewAfter = ISO8601DateFormatter().date(from: "2026-09-05T00:00:00Z")! +private let notAfter = ISO8601DateFormatter().date(from: "2026-10-06T00:00:00Z")! +private let before = ISO8601DateFormatter().date(from: "2026-09-04T00:00:00Z")! +private let after = ISO8601DateFormatter().date(from: "2026-09-06T00:00:00Z")! + +private func state(renewAfter: Date? = renewAfter) -> DeviceRenewalState { + DeviceRenewalState(deviceId: "dev-1", notAfter: notAfter, renewAfter: renewAfter) +} + +/// Records how many times the renew operation ran. `@unchecked Sendable`: guarded +/// by a lock. +private final class RenewSpy: @unchecked Sendable { + private let lock = NSLock() + private var _count = 0 + var count: Int { lock.withLock { _count } } + func bump() { lock.withLock { _count += 1 } } +} + +@Test("no enrolled identity → notEnrolled, and renew never runs") +func schedulerNotEnrolled() async { + let spy = RenewSpy() + let scheduler = CertificateRotationScheduler( + renewalState: { nil }, + performRenew: { spy.bump(); return nil }, + now: { after } + ) + let outcome = await scheduler.runIfDue() + #expect(outcome == .notEnrolled) + #expect(spy.count == 0) +} + +@Test("cert not yet due → notDue, and renew never runs") +func schedulerNotDue() async { + let spy = RenewSpy() + let scheduler = CertificateRotationScheduler( + renewalState: { state() }, + performRenew: { spy.bump(); return nil }, + now: { before } + ) + let outcome = await scheduler.runIfDue() + #expect(outcome == .notDue(renewAfter: renewAfter)) + #expect(spy.count == 0) +} + +@Test("past renewAfter → renews exactly once") +func schedulerRenews() async { + let spy = RenewSpy() + let scheduler = CertificateRotationScheduler( + renewalState: { state() }, + performRenew: { spy.bump(); return nil }, + now: { after } + ) + let outcome = await scheduler.runIfDue() + #expect(outcome == .renewed) + #expect(spy.count == 1) +} + +@Test("due exactly at renewAfter (>= boundary) renews") +func schedulerRenewsAtBoundary() async { + let scheduler = CertificateRotationScheduler( + renewalState: { state() }, + performRenew: { nil }, + now: { renewAfter } // now == renewAfter + ) + #expect(await scheduler.runIfDue() == .renewed) +} + +@Test("a missing renewAfter never triggers (fail-safe — TLS stack is the gate)") +func schedulerMissingRenewAfterNeverDue() async { + let scheduler = CertificateRotationScheduler( + renewalState: { state(renewAfter: nil) }, + performRenew: { nil }, + now: { after } + ) + #expect(await scheduler.runIfDue() == .notDue(renewAfter: nil)) +} + +@Test("a renew that throws is surfaced as .failed (never crashes, cert still valid)") +func schedulerRenewFailure() async { + struct Boom: Error {} + let scheduler = CertificateRotationScheduler( + renewalState: { state() }, + performRenew: { throw Boom() }, + now: { after } + ) + #expect(await scheduler.runIfDue() == .failed) +} + +@Test("a keychain read fault is surfaced as .failed, not a crash") +func schedulerReadFailure() async { + struct Boom: Error {} + let scheduler = CertificateRotationScheduler( + renewalState: { throw Boom() }, + performRenew: { nil }, + now: { after } + ) + #expect(await scheduler.runIfDue() == .failed) +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/ControlPlaneLoginClientTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/ControlPlaneLoginClientTests.swift new file mode 100644 index 0000000..febabad --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/ControlPlaneLoginClientTests.swift @@ -0,0 +1,123 @@ +import Foundation +import Testing +@testable import ClientTLS + +// B3 · ControlPlaneLoginClient request-building + response-mapping, driven by a +// stub transport (no network). Mirrors the PINNED login contract exactly: +// +// POST /auth/login { "password": "" } +// → 201 { "enrollToken": "", "accountId": "", "expiresIn": } + +private let cpBaseURL = URL(string: "https://cp.terminal.yaojia.wang")! + +/// Records the last request and replays a canned response. `@unchecked Sendable`: +/// the mutable capture is guarded by a lock. +private final class LoginStubTransport: EnrollmentTransport, @unchecked Sendable { + private let lock = NSLock() + private var _lastRequest: URLRequest? + private let status: Int + private let body: Data + + init(status: Int, body: Data) { + self.status = status + self.body = body + } + + var lastRequest: URLRequest? { lock.withLock { _lastRequest } } + + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + lock.withLock { _lastRequest = request } + let response = HTTPURLResponse( + url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil + )! + return (body, response) + } +} + +private func loginBody( + enrollToken: String = "v4.public.enroll-token", + accountId: String = "11111111-2222-3333-4444-555555555555", + expiresIn: Int = 600 +) -> Data { + let json: [String: Any] = [ + "enrollToken": enrollToken, + "accountId": accountId, + "expiresIn": expiresIn, + ] + return try! JSONSerialization.data(withJSONObject: json) +} + +@Test("login builds a POST /auth/login carrying only the password (never bearer)") +func loginBuildsRequest() async throws { + // Arrange + let stub = LoginStubTransport(status: 201, body: loginBody()) + let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub) + + // Act + _ = try await client.login(password: "operator-secret") + + // Assert + let request = try #require(stub.lastRequest) + #expect(request.httpMethod == "POST") + #expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/auth/login") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + // Login is credential-based, NOT bearer-authed — no Authorization header. + #expect(request.value(forHTTPHeaderField: "Authorization") == nil) + + let sent = try #require(request.httpBody) + let object = try #require(try JSONSerialization.jsonObject(with: sent) as? [String: Any]) + #expect(object["password"] as? String == "operator-secret") + #expect(object.keys.count == 1) // password ONLY — no extra fields leak +} + +@Test("login maps a 201 response into a typed LoginResult") +func loginMapsResponse() async throws { + // Arrange + let stub = LoginStubTransport(status: 201, body: loginBody( + enrollToken: "v4.public.abc", accountId: "acct-uuid", expiresIn: 900 + )) + let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub) + + // Act + let result = try await client.login(password: "pw") + + // Assert + #expect(result.enrollToken == "v4.public.abc") + #expect(result.accountId == "acct-uuid") + #expect(result.expiresIn == 900) +} + +@Test("an empty password fails fast without any network call") +func loginEmptyPasswordRejected() async throws { + // Arrange + let stub = LoginStubTransport(status: 201, body: loginBody()) + let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub) + + // Act / Assert — boundary validation: reject before sending. + await #expect(throws: ControlPlaneLoginError.emptyPassword) { + _ = try await client.login(password: "") + } + #expect(stub.lastRequest == nil) // proves zero network happened +} + +@Test("a 401 login response surfaces http with the server error code") +func loginRejectSurfacesStatus() async throws { + // The login stub seam denies-by-default → { error: "login rejected" }. + let body = try! JSONSerialization.data(withJSONObject: ["error": "login rejected"]) + let stub = LoginStubTransport(status: 401, body: body) + let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub) + + await #expect(throws: ControlPlaneLoginError.http(status: 401, code: "login rejected")) { + _ = try await client.login(password: "wrong") + } +} + +@Test("a malformed 201 body throws malformedResponse") +func loginMalformedBody() async throws { + let stub = LoginStubTransport(status: 201, body: Data("not json".utf8)) + let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub) + + await #expect(throws: ControlPlaneLoginError.malformedResponse) { + _ = try await client.login(password: "pw") + } +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/DeviceEnrollmentClientTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/DeviceEnrollmentClientTests.swift index f2f1426..fb23287 100644 --- a/ios/Packages/ClientTLS/Tests/ClientTLSTests/DeviceEnrollmentClientTests.swift +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/DeviceEnrollmentClientTests.swift @@ -169,3 +169,46 @@ func renewTargetsRenewEndpoint() async throws { #expect(stub.lastRequest?.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/dev-9/renew") } + +@Test("renew body is {csr}-ONLY (server's strict schema rejects any extra field)") +func renewSendsCsrOnlyBody() async throws { + // The control-plane /device/:id/renew schema authenticates by the presented + // mTLS cert and accepts a body of exactly { csr } — a stray `keyAlg` (or any + // non-csr field) trips its strict validation and 400s every silent renewal. + let stub = StubTransport(status: 201, body: enrollBody()) + let client = DeviceEnrollmentClient(baseURL: baseURL, transport: stub) + let csr = Data([0x0A, 0x0B, 0x0C]) + + _ = try await client.renew(deviceId: "dev-9", csrDER: csr) + + let request = try #require(stub.lastRequest) + let sent = try #require(request.httpBody) + let object = try #require(try JSONSerialization.jsonObject(with: sent) as? [String: Any]) + #expect(object["csr"] as? String == csr.base64EncodedString()) + #expect(object["keyAlg"] == nil) // NOT sent — the enroll-only field must be dropped + #expect(object.keys.sorted() == ["csr"]) // exactly one field +} + +@Test("renew with no bearer sends NO Authorization header (mTLS-authenticated)") +func renewMTLSOmitsBearer() async throws { + // /device/:id/renew authenticates by the CURRENT device client cert (mTLS), + // NOT a bearer — the silent rotation scheduler has no fresh bearer weeks + // later, so it constructs the client with a nil bearer and relies on the + // transport presenting the installed identity. The Authorization header + // must therefore be ABSENT (a stale/empty "Bearer " would be wrong). + let stub = StubTransport(status: 201, body: enrollBody()) + let client = DeviceEnrollmentClient(baseURL: baseURL, transport: stub) // bearer defaults to nil + _ = try await client.renew(deviceId: "dev-9", csrDER: Data([0x02])) + let request = try #require(stub.lastRequest) + #expect(request.value(forHTTPHeaderField: "Authorization") == nil) + // Content-Type is still set — it's a JSON POST regardless of auth mechanism. + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") +} + +@Test("enroll still sets the bearer when one is supplied (unchanged contract)") +func enrollKeepsBearerWhenSupplied() async throws { + let stub = StubTransport(status: 201, body: enrollBody()) + let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub) + _ = try await client.enroll(csrDER: Data([0x01]), subdomain: "a", deviceName: "d") + #expect(stub.lastRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer \(bearer)") +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/DeviceEnrollmentFlowTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/DeviceEnrollmentFlowTests.swift new file mode 100644 index 0000000..7a0bd31 --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/DeviceEnrollmentFlowTests.swift @@ -0,0 +1,136 @@ +import Foundation +import Testing +@testable import ClientTLS + +// B3 · DeviceEnrollmentFlow composes the pinned bootstrap end-to-end: +// login(password) → bearer → enroll(CSR, subdomain) with that bearer → leaf. +// Driven by a path-routing stub transport (no network, no keychain): asserts the +// bearer minted at login is exactly what authenticates the enroll call. + +private let flowBaseURL = URL(string: "https://cp.terminal.yaojia.wang")! + +/// Routes by path: `/auth/login` → login body, `/device/enroll` → enroll body. +/// Records every request so the test can assert the enroll bearer. `@unchecked +/// Sendable`: mutable captures guarded by a lock. +private final class RoutingStubTransport: EnrollmentTransport, @unchecked Sendable { + private let lock = NSLock() + private var _requests: [URLRequest] = [] + private let loginStatus: Int + private let loginBody: Data + + init(loginStatus: Int = 201, loginBody: Data) { + self.loginStatus = loginStatus + self.loginBody = loginBody + } + + var requests: [URLRequest] { lock.withLock { _requests } } + func request(forPathSuffix suffix: String) -> URLRequest? { + lock.withLock { _requests.first { $0.url?.path.hasSuffix(suffix) == true } } + } + + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + lock.withLock { _requests.append(request) } + let path = request.url?.path ?? "" + let (status, body): (Int, Data) + if path.hasSuffix("/auth/login") { + (status, body) = (loginStatus, loginBody) + } else { + // /device/enroll → a minimal 201 leaf. + (status, body) = (201, enrollLeafBody()) + } + let response = HTTPURLResponse( + url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil + )! + return (body, response) + } +} + +private func loginResponseBody(enrollToken: String) -> Data { + try! JSONSerialization.data(withJSONObject: [ + "enrollToken": enrollToken, + "accountId": "acct-123", + "expiresIn": 600, + ]) +} + +private func enrollLeafBody() -> Data { + try! JSONSerialization.data(withJSONObject: [ + "deviceId": "dev-1", + "cert": Data([0x30, 0x01]).base64EncodedString(), + "caChain": [Data([0x30, 0x02]).base64EncodedString()], + "notBefore": "2026-07-18T00:00:00Z", + "notAfter": "2026-10-16T00:00:00Z", + "renewAfter": "2026-09-15T00:00:00Z", + ]) +} + +/// A fake installer that invokes the real `client.enroll` (so the transport sees +/// the enroll request with the login-minted bearer), then returns a summary. +/// Records the subdomain/deviceName it was handed. `@unchecked Sendable`: lock. +private final class SpyInstaller: @unchecked Sendable { + private let lock = NSLock() + private(set) var subdomain: String? + private(set) var deviceName: String? + + func install( + _ client: DeviceEnrollmentClient, _ subdomain: String, _ deviceName: String + ) async throws -> ClientCertificateSummary? { + lock.withLock { self.subdomain = subdomain; self.deviceName = deviceName } + _ = try await client.enroll(csrDER: Data([0xAB]), subdomain: subdomain, deviceName: deviceName) + return ClientCertificateSummary( + subjectCommonName: deviceName, issuerCommonName: "webterm-device-ca", notAfter: nil + ) + } +} + +@Test("flow logs in then enrolls with the login-minted bearer (end to end)") +func flowEndToEnd() async throws { + // Arrange + let transport = RoutingStubTransport(loginBody: loginResponseBody(enrollToken: "TOKEN-XYZ")) + let installer = SpyInstaller() + let flow = DeviceEnrollmentFlow( + baseURL: flowBaseURL, + transport: transport, + install: installer.install + ) + + // Act + let summary = try await flow.run(password: "pw", subdomain: "alice", deviceName: "Alice iPhone") + + // Assert — login happened, THEN enroll with the exact bearer from login. + #expect(transport.request(forPathSuffix: "/auth/login") != nil) + let enroll = try #require(transport.request(forPathSuffix: "/device/enroll")) + #expect(enroll.value(forHTTPHeaderField: "Authorization") == "Bearer TOKEN-XYZ") + #expect(installer.subdomain == "alice") + #expect(installer.deviceName == "Alice iPhone") + #expect(summary?.subjectCommonName == "Alice iPhone") +} + +@Test("a login failure short-circuits: enroll is never attempted") +func flowLoginFailureShortCircuits() async { + // 401 login → the flow must NOT reach /device/enroll. + let body = try! JSONSerialization.data(withJSONObject: ["error": "login rejected"]) + let transport = RoutingStubTransport(loginStatus: 401, loginBody: body) + let installer = SpyInstaller() + let flow = DeviceEnrollmentFlow( + baseURL: flowBaseURL, transport: transport, install: installer.install + ) + + await #expect(throws: ControlPlaneLoginError.http(status: 401, code: "login rejected")) { + _ = try await flow.run(password: "wrong", subdomain: "alice", deviceName: "d") + } + #expect(transport.request(forPathSuffix: "/device/enroll") == nil) + #expect(installer.subdomain == nil) // installer never ran +} + +@Test("an empty password is rejected before any network") +func flowEmptyPassword() async { + let transport = RoutingStubTransport(loginBody: loginResponseBody(enrollToken: "t")) + let flow = DeviceEnrollmentFlow( + baseURL: flowBaseURL, transport: transport, install: { _, _, _ in nil } + ) + await #expect(throws: ControlPlaneLoginError.emptyPassword) { + _ = try await flow.run(password: "", subdomain: "alice", deviceName: "d") + } + #expect(transport.requests.isEmpty) +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainItemReplaceTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainItemReplaceTests.swift new file mode 100644 index 0000000..300186e --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainItemReplaceTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import ClientTLS + +// C-iOS · The keychain-replace helper backs the rotation scheduler's identity +// updates. Its one job: a crash/failure mid-write must NEVER leave zero installed +// items (which would lock the device out of its own mTLS identity until a manual +// re-enroll). It enforces ADD-new-BEFORE-DELETE-old ordering — the inverse of the +// delete-then-add sequence whose window has zero items installed. + +/// A tiny in-memory stand-in for the keychain: the current set of installed item +/// ids plus the smallest count ever observed, so a test can assert the invariant +/// that the installed set is NEVER empty at any step of the replace. +private final class FakeKeychain { + private(set) var items: Set + private(set) var log: [String] = [] + private(set) var minCount: Int + init(seed: Set) { + items = seed + minCount = seed.count + } + + func add(_ id: String) { + items.insert(id) + log.append("add(\(id))") + minCount = min(minCount, items.count) + } + + func delete(_ id: String) { + items.remove(id) + log.append("delete(\(id))") + minCount = min(minCount, items.count) + } +} + +@Test("replace ADDS the new item before DELETING the old — never a zero-item window") +func replaceAddsBeforeDeletes() throws { + let keychain = FakeKeychain(seed: ["old"]) + try replaceKeychainItemAtomically( + addNew: { keychain.add("new") }, + deleteOld: { keychain.delete("old") } + ) + #expect(keychain.items == ["new"]) + #expect(keychain.log == ["add(new)", "delete(old)"]) // add strictly precedes delete + #expect(keychain.minCount >= 1) // at least one identity installed at every step +} + +@Test("a failed add leaves the prior item intact and never runs the delete") +func replaceAddFailureKeepsOld() { + struct Boom: Error {} + let keychain = FakeKeychain(seed: ["old"]) + #expect(throws: Boom.self) { + try replaceKeychainItemAtomically( + addNew: { throw Boom() }, + deleteOld: { keychain.delete("old") } + ) + } + #expect(keychain.items == ["old"]) // old preserved — a working identity remains + #expect(keychain.log.contains("delete(old)") == false) + #expect(keychain.minCount >= 1) +} + +@Test("a failed delete-of-old is non-fatal — the new item stays installed and the fault is surfaced") +func replaceDeleteFailureKeepsNew() throws { + struct Boom: Error {} + let keychain = FakeKeychain(seed: ["old"]) + var reported: Error? + try replaceKeychainItemAtomically( + addNew: { keychain.add("new") }, + deleteOld: { throw Boom() }, + reportStaleDeleteFailure: { reported = $0 } + ) + #expect(keychain.items.contains("new")) // renewal succeeded despite the stale-delete fault + #expect(reported is Boom) // surfaced (not silently swallowed) + #expect(keychain.minCount >= 1) +}