Merge feat/zero-touch-enrollment: zero-touch tunnel enrollment (host + phone)
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled

Host track LIVE + verified end-to-end (pair --install → CP native enroll →
launchd base-app+agent → frpc tunnel → h7fd8.terminal.yaojia.wang, auto-renew
proven). Phone track: CP login + device-enroll verified via curl; iOS wired,
Android wired. New CP production native-CA wiring, /auth/login + device:enroll
bearer, nginx enroll+renew vhost. ~12 real-deploy bugs fixed. 281 CP + 281 agent
tests + android assembleDebug/kover green.
This commit is contained in:
Yaojia Wang
2026-07-19 09:42:47 +02:00
75 changed files with 6891 additions and 82 deletions

View File

@@ -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<string, string>
readonly body?: string
}
export interface MtlsResponse {
readonly status: number
readonly body: string
}
export type MtlsRequest = (
url: string,
tls: TlsClientOptions,
init: MtlsRequestInit,
) => Promise<MtlsResponse>
/** Default mTLS transport: a `node:https` POST presenting the client cert/key + pinned CA. */
const defaultMtlsRequest: MtlsRequest = (url, tls, init) =>
new Promise<MtlsResponse>((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<string, string> {
if (!headers) return {}
if (headers instanceof Headers) {
const out: Record<string, string> = {}
headers.forEach((v, k) => {
out[k] = v
})
return out
}
if (Array.isArray(headers)) return Object.fromEntries(headers)
return { ...(headers as Record<string, string>) }
}
/**
* 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<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
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 })
}

33
agent/src/certs/pem.ts Normal file
View File

@@ -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(''),
}
}

View File

@@ -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
},
}
}

View File

@@ -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<NativeEnrollResult> {
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<number> {
const logger = createLogger('info')
@@ -184,12 +188,28 @@ function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
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. */

View File

@@ -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<EnrollResponseJson>
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
}

View File

@@ -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<string, string> = {
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)

View File

@@ -78,6 +78,7 @@ export function buildLaunchdPlist(
programArguments: readonly string[],
env: ServiceEnv = {},
label: string = AGENT_LABEL,
logPath?: string,
): string {
return [
'<?xml version="1.0" encoding="UTF-8"?>',
@@ -91,6 +92,16 @@ export function buildLaunchdPlist(
' <true/>',
' <key>KeepAlive</key>',
' <true/>',
// 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
? [
' <key>StandardOutPath</key>',
` <string>${escapeXml(logPath)}</string>`,
' <key>StandardErrorPath</key>',
` <string>${escapeXml(logPath)}</string>`,
]
: []),
...environmentVariablesBlock(env),
'</dict>',
'</plist>',

View File

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

View File

@@ -62,6 +62,12 @@ export interface FrpSuperviseHandle {
readonly done: Promise<number>
/** 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()
},
}
}

View File

@@ -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<void> => 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)
})
})

View File

@@ -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 `<bin> run`; base-app runs the node server (loopback)
expect(agent).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
// agent unit supervises frpc via `<node> <bin> 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')

View File

@@ -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<typeof openKeystore> } {
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<void> => 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<string, unknown>; init: Record<string, unknown> }> = []
const request: MtlsRequest = async (url, tls, init) => {
seen.push({ url, tls: tls as unknown as Record<string, unknown>, init: init as unknown as Record<string, unknown> })
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<typeof vi.fn>
stop: ReturnType<typeof vi.fn>
} {
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 })
})
})

View File

@@ -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<typeof openKeystore> {
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<string, unknown> = {}
let seenReq: FakeClientRequest | undefined
requestMock.mockImplementation((url: string, opts: Record<string, unknown>, 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)
})
})

View File

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

View File

@@ -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>): ByteArray = tlv(TAG_SEQUENCE, concat(children))
fun set(children: List<ByteArray>): 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<Byte>()
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>): 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
}
}

View File

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

View File

@@ -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<String, String>()
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 <T> decodeOn201(response: HttpResponse, serializer: kotlinx.serialization.KSerializer<T>): 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<Char> =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~".toSet()
private val HEX = "0123456789ABCDEF".toCharArray()
}
}

View File

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

View File

@@ -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<ByteArray>,
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<String> = 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()
}

View File

@@ -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<Element> {
val elements = mutableListOf<Element>()
var index = parent.valueStart
while (index < parent.valueEnd) {
val element = read(bytes, index) ?: break
elements.add(element)
index = element.valueEnd
}
return elements
}
}

View File

@@ -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<ByteArray> = 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)
}

View File

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

View File

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

View File

@@ -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}}"

View File

@@ -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) ──────────────────────────────────────────────────────────────────────────────
/**

View File

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

View File

@@ -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 = {},
)
}
}

View File

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

View File

@@ -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<EnrollmentUiState> = _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()
}

View File

@@ -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<IdentityRepository>,
private val httpTransportLazy: Lazy<HttpTransport>,
private val termTransportLazy: Lazy<TermTransport>,

View File

@@ -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<HttpTransport>,
private val sharedClient: Lazy<OkHttpClient>,
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,
)
}

View File

@@ -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<CertificateSummary>) {
val calls = mutableListOf<Call>()
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")))
}
}

View File

@@ -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<Test>().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)
}

View File

@@ -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<Element> {
val elements = mutableListOf<Element>()
var index = parent.valueStart
while (index < parent.valueEnd) {
val element = read(bytes, index) ?: break
elements.add(element)
index = element.valueEnd
}
return elements
}
}

View File

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

View File

@@ -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"
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<String>()
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<String>) : 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<String>) : 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<String>) : IdentityCacheRefresher {
override fun refreshFromStore() {
events += "cache.refresh"
}
}
private class RecordingKeyProvider(private val events: MutableList<String>) : DeviceKeyProvider {
private val keys = mutableMapOf<String, HardwareBackedKey>()
val generatedAliases = mutableListOf<String>()
val deletedAliases = mutableListOf<String>()
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"
}
}
}

View File

@@ -0,0 +1,142 @@
/**
* B1 — operator login → `device:enroll` bearer mint (registerable Fastify plugin; wired in `main.ts`).
*
* POST /auth/login { "password": "<operator secret>" }
* -> 201 { "enrollToken": "<v4.public PASETO>", "accountId": "<uuid>", "expiresIn": <seconds> }
*
* The `enrollToken` is the short-lived §4.3 `device:enroll` capability token that `POST /device/enroll`
* requires as `Authorization: Bearer <enrollToken>`. This is the ONE authenticated human action that
* bootstraps the phone track (the honest "one bootstrap tap" constraint) — everything after (CSR →
* cert → silent renew) is certificate-authenticated.
*
* SECURITY (this path ultimately mints device certs):
* - credential compare is CONSTANT-TIME (delegated to `loginToAccountId` → `timingSafeEqualBytes`);
* - login attempts are RATE-LIMITED per client (sliding window, mirrors registry/devices.ts);
* - DENY-BY-DEFAULT + FAIL-CLOSED: unset operator credential ⇒ 503 (never mints); wrong ⇒ 401;
* - the operator secret and the minted token are NEVER logged (INV9);
* - input is Zod-validated at the boundary; `accountId` is server-config, never a client field.
*/
import { z } from 'zod'
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import {
loginToAccountId,
mintDeviceEnrollToken,
DeviceEnrollAuthError,
DEFAULT_DEVICE_ENROLL_TTL_SEC,
MIN_DEVICE_ENROLL_TTL_SEC,
MAX_DEVICE_ENROLL_TTL_SEC,
type LoginSeamConfig,
} from '../auth/session.js'
/** Per-client login attempts allowed within the window (brute-force throttle). */
export const DEFAULT_LOGIN_RATE_MAX = 10
/** Login rate window (ms): 15 minutes. */
export const DEFAULT_LOGIN_RATE_WINDOW_MS = 15 * 60 * 1000
export interface AuthLoginDeps {
/** Ed25519 signing key for the enroll bearer (PRIVATE half of `CAPABILITY_SIGN_PUBKEY_B64`). */
readonly signingKey: CryptoKey | null
/** How a credential resolves to an accountId (single-operator MVP or an injected resolver). */
readonly loginConfig: LoginSeamConfig
/** Minted bearer TTL (seconds); clamped to the enroll-token bounds. Defaults to 10 min. */
readonly enrollTtlSec?: number
readonly rateMax?: number
readonly rateWindowMs?: number
/** Clock (ms) — injectable for tests. */
readonly now?: () => number
/** Client-bucket key for rate-limiting (defaults to the socket IP). Injectable for tests. */
readonly clientKey?: (req: FastifyRequest) => string
}
const LoginBodySchema = z.object({ password: z.string().min(1).max(512) }).strict()
/** Uniform rate-limit reject → 429. */
class LoginRateError extends Error {
constructor() {
super('login rate limited')
this.name = 'LoginRateError'
}
}
/** In-process sliding-window limiter keyed by client bucket (mirrors registry/devices.ts). */
function createLoginLimiter(max: number, windowMs: number, now: () => number) {
const hits = new Map<string, number[]>()
return {
check(key: string): void {
const ts = now()
const cutoff = ts - windowMs
const arr = (hits.get(key) ?? []).filter((t) => t > cutoff)
if (arr.length >= max) {
hits.set(key, arr) // persist the pruned window; do NOT record this rejected attempt
throw new LoginRateError()
}
arr.push(ts)
hits.set(key, arr)
},
}
}
function clampEnrollTtl(ttl: number): number {
return Math.min(Math.max(ttl, MIN_DEVICE_ENROLL_TTL_SEC), MAX_DEVICE_ENROLL_TTL_SEC)
}
/** The feature is live only when a signing key AND a resolvable credential seam are BOTH present. */
function isConfigured(deps: AuthLoginDeps): deps is AuthLoginDeps & { signingKey: CryptoKey } {
if (deps.signingKey === null) return false
const c = deps.loginConfig
return c.resolve !== undefined || (c.operatorCredential !== undefined && c.accountId !== undefined)
}
/** Map any thrown error to a uniform HTTP reject — never leak which internal check failed (no oracle). */
function sendLoginError(reply: FastifyReply, err: unknown): void {
if (err instanceof LoginRateError) {
void reply.code(429).send({ error: 'rate_limited' })
return
}
if (err instanceof DeviceEnrollAuthError) {
void reply.code(err.status).send({ error: 'rejected' })
return
}
if (err instanceof z.ZodError) {
void reply.code(400).send({ error: 'invalid request' })
return
}
void reply.code(400).send({ error: 'rejected' })
}
export function buildAuthLoginRouter(deps: AuthLoginDeps): FastifyPluginAsync {
const now = deps.now ?? (() => Date.now())
const clientKey = deps.clientKey ?? ((req: FastifyRequest) => req.ip || 'unknown')
const limiter = createLoginLimiter(deps.rateMax ?? DEFAULT_LOGIN_RATE_MAX, deps.rateWindowMs ?? DEFAULT_LOGIN_RATE_WINDOW_MS, now)
const ttl = clampEnrollTtl(deps.enrollTtlSec ?? DEFAULT_DEVICE_ENROLL_TTL_SEC)
return async (app) => {
app.post('/auth/login', async (req, reply) => {
try {
// Rate-limit BEFORE any credential work so brute force is throttled regardless of outcome.
limiter.check(clientKey(req))
const { password } = LoginBodySchema.parse(req.body)
// Fail-closed: an unconfigured login mints nothing (503, distinct from a wrong-credential 401).
if (!isConfigured(deps)) {
void reply.code(503).send({ error: 'login unavailable' })
return
}
// Constant-time credential → accountId (deny-by-default inside loginToAccountId). Any failure
// becomes a uniform 401 — never distinguish wrong-password from unknown-credential.
let accountId: string
try {
accountId = loginToAccountId(password, deps.loginConfig)
} catch {
throw new DeviceEnrollAuthError(401, 'login rejected')
}
const enrollToken = await mintDeviceEnrollToken(accountId, { signingKey: deps.signingKey, ttlSeconds: ttl })
await reply.code(201).send({ enrollToken, accountId, expiresIn: ttl })
} catch (err) {
sendLoginError(reply, err)
}
})
}
}

View File

@@ -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).

View File

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

View File

@@ -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:<path>`). */
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:<path>` 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:<path>` (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 }
},
}
}

View File

@@ -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<NativeCas> {
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 } : {}),
})
}

View File

@@ -0,0 +1,26 @@
/**
* B1 — load the `device:enroll` bearer SIGNING key (Ed25519) from the CP env's PKCS#8 DER.
*
* The enroll bearer must verify on the SAME §4.3 path the admin API uses (boot/verifier.ts, keyed off
* `CAPABILITY_SIGN_PUBKEY_B64`), so the login route signs with the PRIVATE half of that same keypair.
* The key is imported NON-EXPORTABLE + `sign`-only (INV9: raw key material is never held as bytes and
* never logged). A malformed / non-Ed25519 key FAILS CLOSED (throws) — the CP refuses to serve a login
* route it cannot mint from.
*/
/**
* Import the Ed25519 PKCS#8 private key as a non-exportable, sign-only `CryptoKey`. Throws (fail-closed)
* on any malformed key; the error message never echoes key material (INV9).
*/
export async function loadEnrollSigningKey(pkcs8Der: Uint8Array): Promise<CryptoKey> {
// Copy into a fresh ArrayBuffer-backed view (WebCrypto BufferSource typing / no shared pool).
const bytes = new Uint8Array(pkcs8Der.length)
bytes.set(pkcs8Der)
try {
return await globalThis.crypto.subtle.importKey('pkcs8', bytes, { name: 'Ed25519' }, false, ['sign'])
} catch (err: unknown) {
throw new Error(
`failed to load device:enroll signing key: ${err instanceof Error ? err.message : 'unknown'}`,
)
}
}

View File

@@ -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;
* 16512 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 <sub>.<zone>` 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 16512 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,
}),
}
}

View File

@@ -43,6 +43,9 @@ import { createFrpClientLeafSigner } from './ca/frpclient-issue.js'
import { createDeviceLeafSigner, type DeviceLeafSigner } from './ca/device-issue.js'
import { createLeafRenewer } from './ca/rotate.js'
import { buildDeviceEnrollRouter, type SubdomainOwnershipResolver } from './api/device-enroll.js'
import { buildAuthLoginRouter } from './api/auth-login.js'
import { loadEnrollSigningKey } from './boot/session-signing.js'
import type { LoginSeamConfig } from './auth/session.js'
import { buildRenewRouter } from './api/renew.js'
import { buildNativeCas, DEFAULT_NATIVE_DNS_ZONE, type NativeCas, type NativeCaMaterial } from './boot/native-ca.js'
import type { RevocationBus } from 'relay-contracts'
@@ -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({

View File

@@ -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<void> {
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<ControlPlaneOverrides, 'production' | 'nativeCas' | 'nativeDnsZone'> =
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)

View File

@@ -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 <enrollToken> 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<AuthLoginDeps> = {}): 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 3060s 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)
})
})

View File

@@ -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<typeof createPublicKey> } {
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))
}
})
})

View File

@@ -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 (16512 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/,
)
})
})

View File

@@ -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 = `<sub>.<zone>` 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<x509.GeneralNames['toJSON']> {
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<ReturnType<typeof buildControlPlane>>['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 <sub>.<zone>', 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/)
})
})

View File

@@ -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<void>
}
/**
* 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<boolean> {
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<EmbeddedServer> {
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.

View File

@@ -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 <CODE> --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 <CODE> --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 = <sub>.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-host>/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 (smallmedium; 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.13.6 (CA material, CP build, public `/enroll`, frps CA, manage bearer, token) | VPS | SM | 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 <CODE> --install`; verify 2 launchd units + `https://<sub>.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 (A1A8)****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 (smallmedium)
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`._

View File

@@ -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 = "注册失败,请重试。"
}

View File

@@ -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扫码或手输地址会话会出现在这里。"

View File

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

View File

@@ -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 sheetVM
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:

View File

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

View File

@@ -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<Bool> {
@@ -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 = "设备证书自动续期失败,将在下次前台重试"
}

View File

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

View File

@@ -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 errorcopy 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<ClientCertificateSummary?, any Error>
init(result: Result<ClientCertificateSummary?, any Error>) {
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
}
}

View File

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

View File

@@ -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": "<operator secret>" }`
/// 201 { "enrollToken": "<jwt/capability>", "accountId": "<uuid>", "expiresIn": <seconds> }
///
/// 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "<operator secret>" }
// 201 { "enrollToken": "<jwt/capability>", "accountId": "<uuid>", "expiresIn": <seconds> }
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")
}
}

View File

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

View File

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

View File

@@ -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<String>
private(set) var log: [String] = []
private(set) var minCount: Int
init(seed: Set<String>) {
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)
}