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
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:
247
agent/src/certs/nativeRenew.ts
Normal file
247
agent/src/certs/nativeRenew.ts
Normal 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
33
agent/src/certs/pem.ts
Normal 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(''),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
279
agent/test/nativeRenew.test.ts
Normal file
279
agent/test/nativeRenew.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
169
agent/test/nativeRenewTransport.test.ts
Normal file
169
agent/test/nativeRenewTransport.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
|
||||
@@ -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) ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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")))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
142
control-plane/src/api/auth-login.ts
Normal file
142
control-plane/src/api/auth-login.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* B1 — operator login → `device:enroll` bearer mint (registerable Fastify plugin; wired in `main.ts`).
|
||||
*
|
||||
* POST /auth/login { "password": "<operator secret>" }
|
||||
* -> 201 { "enrollToken": "<v4.public PASETO>", "accountId": "<uuid>", "expiresIn": <seconds> }
|
||||
*
|
||||
* The `enrollToken` is the short-lived §4.3 `device:enroll` capability token that `POST /device/enroll`
|
||||
* requires as `Authorization: Bearer <enrollToken>`. This is the ONE authenticated human action that
|
||||
* bootstraps the phone track (the honest "one bootstrap tap" constraint) — everything after (CSR →
|
||||
* cert → silent renew) is certificate-authenticated.
|
||||
*
|
||||
* SECURITY (this path ultimately mints device certs):
|
||||
* - credential compare is CONSTANT-TIME (delegated to `loginToAccountId` → `timingSafeEqualBytes`);
|
||||
* - login attempts are RATE-LIMITED per client (sliding window, mirrors registry/devices.ts);
|
||||
* - DENY-BY-DEFAULT + FAIL-CLOSED: unset operator credential ⇒ 503 (never mints); wrong ⇒ 401;
|
||||
* - the operator secret and the minted token are NEVER logged (INV9);
|
||||
* - input is Zod-validated at the boundary; `accountId` is server-config, never a client field.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import {
|
||||
loginToAccountId,
|
||||
mintDeviceEnrollToken,
|
||||
DeviceEnrollAuthError,
|
||||
DEFAULT_DEVICE_ENROLL_TTL_SEC,
|
||||
MIN_DEVICE_ENROLL_TTL_SEC,
|
||||
MAX_DEVICE_ENROLL_TTL_SEC,
|
||||
type LoginSeamConfig,
|
||||
} from '../auth/session.js'
|
||||
|
||||
/** Per-client login attempts allowed within the window (brute-force throttle). */
|
||||
export const DEFAULT_LOGIN_RATE_MAX = 10
|
||||
/** Login rate window (ms): 15 minutes. */
|
||||
export const DEFAULT_LOGIN_RATE_WINDOW_MS = 15 * 60 * 1000
|
||||
|
||||
export interface AuthLoginDeps {
|
||||
/** Ed25519 signing key for the enroll bearer (PRIVATE half of `CAPABILITY_SIGN_PUBKEY_B64`). */
|
||||
readonly signingKey: CryptoKey | null
|
||||
/** How a credential resolves to an accountId (single-operator MVP or an injected resolver). */
|
||||
readonly loginConfig: LoginSeamConfig
|
||||
/** Minted bearer TTL (seconds); clamped to the enroll-token bounds. Defaults to 10 min. */
|
||||
readonly enrollTtlSec?: number
|
||||
readonly rateMax?: number
|
||||
readonly rateWindowMs?: number
|
||||
/** Clock (ms) — injectable for tests. */
|
||||
readonly now?: () => number
|
||||
/** Client-bucket key for rate-limiting (defaults to the socket IP). Injectable for tests. */
|
||||
readonly clientKey?: (req: FastifyRequest) => string
|
||||
}
|
||||
|
||||
const LoginBodySchema = z.object({ password: z.string().min(1).max(512) }).strict()
|
||||
|
||||
/** Uniform rate-limit reject → 429. */
|
||||
class LoginRateError extends Error {
|
||||
constructor() {
|
||||
super('login rate limited')
|
||||
this.name = 'LoginRateError'
|
||||
}
|
||||
}
|
||||
|
||||
/** In-process sliding-window limiter keyed by client bucket (mirrors registry/devices.ts). */
|
||||
function createLoginLimiter(max: number, windowMs: number, now: () => number) {
|
||||
const hits = new Map<string, number[]>()
|
||||
return {
|
||||
check(key: string): void {
|
||||
const ts = now()
|
||||
const cutoff = ts - windowMs
|
||||
const arr = (hits.get(key) ?? []).filter((t) => t > cutoff)
|
||||
if (arr.length >= max) {
|
||||
hits.set(key, arr) // persist the pruned window; do NOT record this rejected attempt
|
||||
throw new LoginRateError()
|
||||
}
|
||||
arr.push(ts)
|
||||
hits.set(key, arr)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function clampEnrollTtl(ttl: number): number {
|
||||
return Math.min(Math.max(ttl, MIN_DEVICE_ENROLL_TTL_SEC), MAX_DEVICE_ENROLL_TTL_SEC)
|
||||
}
|
||||
|
||||
/** The feature is live only when a signing key AND a resolvable credential seam are BOTH present. */
|
||||
function isConfigured(deps: AuthLoginDeps): deps is AuthLoginDeps & { signingKey: CryptoKey } {
|
||||
if (deps.signingKey === null) return false
|
||||
const c = deps.loginConfig
|
||||
return c.resolve !== undefined || (c.operatorCredential !== undefined && c.accountId !== undefined)
|
||||
}
|
||||
|
||||
/** Map any thrown error to a uniform HTTP reject — never leak which internal check failed (no oracle). */
|
||||
function sendLoginError(reply: FastifyReply, err: unknown): void {
|
||||
if (err instanceof LoginRateError) {
|
||||
void reply.code(429).send({ error: 'rate_limited' })
|
||||
return
|
||||
}
|
||||
if (err instanceof DeviceEnrollAuthError) {
|
||||
void reply.code(err.status).send({ error: 'rejected' })
|
||||
return
|
||||
}
|
||||
if (err instanceof z.ZodError) {
|
||||
void reply.code(400).send({ error: 'invalid request' })
|
||||
return
|
||||
}
|
||||
void reply.code(400).send({ error: 'rejected' })
|
||||
}
|
||||
|
||||
export function buildAuthLoginRouter(deps: AuthLoginDeps): FastifyPluginAsync {
|
||||
const now = deps.now ?? (() => Date.now())
|
||||
const clientKey = deps.clientKey ?? ((req: FastifyRequest) => req.ip || 'unknown')
|
||||
const limiter = createLoginLimiter(deps.rateMax ?? DEFAULT_LOGIN_RATE_MAX, deps.rateWindowMs ?? DEFAULT_LOGIN_RATE_WINDOW_MS, now)
|
||||
const ttl = clampEnrollTtl(deps.enrollTtlSec ?? DEFAULT_DEVICE_ENROLL_TTL_SEC)
|
||||
|
||||
return async (app) => {
|
||||
app.post('/auth/login', async (req, reply) => {
|
||||
try {
|
||||
// Rate-limit BEFORE any credential work so brute force is throttled regardless of outcome.
|
||||
limiter.check(clientKey(req))
|
||||
const { password } = LoginBodySchema.parse(req.body)
|
||||
|
||||
// Fail-closed: an unconfigured login mints nothing (503, distinct from a wrong-credential 401).
|
||||
if (!isConfigured(deps)) {
|
||||
void reply.code(503).send({ error: 'login unavailable' })
|
||||
return
|
||||
}
|
||||
|
||||
// Constant-time credential → accountId (deny-by-default inside loginToAccountId). Any failure
|
||||
// becomes a uniform 401 — never distinguish wrong-password from unknown-credential.
|
||||
let accountId: string
|
||||
try {
|
||||
accountId = loginToAccountId(password, deps.loginConfig)
|
||||
} catch {
|
||||
throw new DeviceEnrollAuthError(401, 'login rejected')
|
||||
}
|
||||
|
||||
const enrollToken = await mintDeviceEnrollToken(accountId, { signingKey: deps.signingKey, ttlSeconds: ttl })
|
||||
await reply.code(201).send({ enrollToken, accountId, expiresIn: ttl })
|
||||
} catch (err) {
|
||||
sendLoginError(reply, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
26
control-plane/src/boot/session-signing.ts
Normal file
26
control-plane/src/boot/session-signing.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* B1 — load the `device:enroll` bearer SIGNING key (Ed25519) from the CP env's PKCS#8 DER.
|
||||
*
|
||||
* The enroll bearer must verify on the SAME §4.3 path the admin API uses (boot/verifier.ts, keyed off
|
||||
* `CAPABILITY_SIGN_PUBKEY_B64`), so the login route signs with the PRIVATE half of that same keypair.
|
||||
* The key is imported NON-EXPORTABLE + `sign`-only (INV9: raw key material is never held as bytes and
|
||||
* never logged). A malformed / non-Ed25519 key FAILS CLOSED (throws) — the CP refuses to serve a login
|
||||
* route it cannot mint from.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Import the Ed25519 PKCS#8 private key as a non-exportable, sign-only `CryptoKey`. Throws (fail-closed)
|
||||
* on any malformed key; the error message never echoes key material (INV9).
|
||||
*/
|
||||
export async function loadEnrollSigningKey(pkcs8Der: Uint8Array): Promise<CryptoKey> {
|
||||
// Copy into a fresh ArrayBuffer-backed view (WebCrypto BufferSource typing / no shared pool).
|
||||
const bytes = new Uint8Array(pkcs8Der.length)
|
||||
bytes.set(pkcs8Der)
|
||||
try {
|
||||
return await globalThis.crypto.subtle.importKey('pkcs8', bytes, { name: 'Ed25519' }, false, ['sign'])
|
||||
} catch (err: unknown) {
|
||||
throw new Error(
|
||||
`failed to load device:enroll signing key: ${err instanceof Error ? err.message : 'unknown'}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,41 @@ export interface ControlPlaneEnv {
|
||||
readonly heartbeatTtlSec: number
|
||||
readonly pairingTtlSec: number
|
||||
readonly pairingMaxRedeemAttempts: number
|
||||
/**
|
||||
* B1 operator-login seam (single-tenant MVP). ALL THREE are set together or NONE — a half-configured
|
||||
* login is a boot error (fail-fast). Unset ⇒ the `/auth/login` route is fail-closed (503). When set:
|
||||
* - `operatorPassword` — the operator login secret (mirrors WEBTERM_TOKEN / relay OPERATOR_PASSWORD;
|
||||
* 16–512 URL/cookie-safe chars). Constant-time compared, NEVER logged (INV9).
|
||||
* - `operatorAccountId` — the account the minted `device:enroll` bearer is scoped to (`sub`).
|
||||
* - `capabilitySignPrivkey` — Ed25519 PKCS#8 DER private key whose PUBLIC half is
|
||||
* `capabilitySignPubkey`; used ONLY to sign the enroll bearer so it verifies on the same §4.3 path.
|
||||
*/
|
||||
readonly operatorPassword?: string
|
||||
readonly operatorAccountId?: string
|
||||
readonly capabilitySignPrivkey?: Uint8Array
|
||||
/**
|
||||
* A2-prep — the native-tunnel PKI's on-disk P-256 CA material (the VPS `gen-device-ca.sh` output:
|
||||
* `frp-client-CA` for host frp-client leaves + `device-CA` for device leaves). Set together or NOT
|
||||
* at all — a partial set is a boot error (fail-closed). Undefined ⇒ the dev/test injection path
|
||||
* (self-signed in-process CAs) is used. The private KEY paths mirror `CA_INTERMEDIATE_KEY_PATH`
|
||||
* (optional; derived from the cert path when unset). The raw keys are custody-on-disk (single-owner
|
||||
* file-custody reality; a non-exportable KMS is the future upgrade).
|
||||
*/
|
||||
readonly nativeCa?: NativeCaEnvConfig
|
||||
}
|
||||
|
||||
/** Resolved on-disk material for the two native-tunnel P-256 CAs + the DNS zone their leaves live under. */
|
||||
export interface NativeCaEnvConfig {
|
||||
/** frp-client-CA (public) cert PEM path — the host-leaf trust anchor. */
|
||||
readonly frpClientCaCertPath: string
|
||||
/** frp-client-CA PKCS#8 PEM private key path (derived from the cert path when unset). */
|
||||
readonly frpClientCaKeyPath: string
|
||||
/** device-CA (public) cert PEM path — the device-leaf trust anchor. */
|
||||
readonly deviceCaCertPath: string
|
||||
/** device-CA PKCS#8 PEM private key path (derived from the cert path when unset). */
|
||||
readonly deviceCaKeyPath: string
|
||||
/** DNS zone the leaves' `dNSName <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 16–512 URL/cookie-safe chars')
|
||||
.optional(),
|
||||
OPERATOR_ACCOUNT_ID: z.string().trim().uuid('OPERATOR_ACCOUNT_ID must be a UUID').optional(),
|
||||
CAPABILITY_SIGN_PRIVKEY_B64: z.string().trim().min(1).optional(),
|
||||
// A2-prep native-tunnel CA material — all optional; cross-validated below (set together or not at all).
|
||||
NATIVE_FRP_CLIENT_CA_CERT_PATH: z.string().trim().optional(),
|
||||
NATIVE_FRP_CLIENT_CA_KEY_PATH: z.string().trim().optional(),
|
||||
NATIVE_DEVICE_CA_CERT_PATH: z.string().trim().optional(),
|
||||
NATIVE_DEVICE_CA_KEY_PATH: z.string().trim().optional(),
|
||||
NATIVE_DNS_ZONE: z.string().trim().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve the optional operator-login triplet. Deny-by-default + fail-fast: if the operator password
|
||||
* is set, the account id and the Ed25519 signing key MUST also be set (a half-configured login is a
|
||||
* boot error, never a silent half-open). Returns `{}` when the feature is unconfigured. NEVER echoes
|
||||
* secret VALUES — only key names on failure (INV9).
|
||||
*/
|
||||
function resolveOperatorLogin(e: {
|
||||
OPERATOR_PASSWORD: string | undefined
|
||||
OPERATOR_ACCOUNT_ID: string | undefined
|
||||
CAPABILITY_SIGN_PRIVKEY_B64: string | undefined
|
||||
}): { operatorPassword?: string; operatorAccountId?: string; capabilitySignPrivkey?: Uint8Array } {
|
||||
const has = (v: string | undefined): v is string => v !== undefined && v.length > 0
|
||||
const pw = e.OPERATOR_PASSWORD
|
||||
const acct = e.OPERATOR_ACCOUNT_ID
|
||||
const priv = e.CAPABILITY_SIGN_PRIVKEY_B64
|
||||
if (!has(pw) && !has(acct) && !has(priv)) {
|
||||
return {} // feature off — /auth/login is fail-closed at runtime
|
||||
}
|
||||
if (!has(pw) || !has(acct) || !has(priv)) {
|
||||
const missing = [
|
||||
has(pw) ? null : 'OPERATOR_PASSWORD',
|
||||
has(acct) ? null : 'OPERATOR_ACCOUNT_ID',
|
||||
has(priv) ? null : 'CAPABILITY_SIGN_PRIVKEY_B64',
|
||||
].filter((x): x is string => x !== null)
|
||||
throw new Error(`Invalid control-plane env: operator login requires all of ${missing.join(', ')} to be set`)
|
||||
}
|
||||
let capabilitySignPrivkey: Uint8Array
|
||||
try {
|
||||
capabilitySignPrivkey = base64ToBytes(priv)
|
||||
} catch {
|
||||
throw new Error('Invalid control-plane env: CAPABILITY_SIGN_PRIVKEY_B64 is not valid base64')
|
||||
}
|
||||
return { operatorPassword: pw, operatorAccountId: acct, capabilitySignPrivkey }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the optional native-tunnel CA material. Deny-by-default + fail-fast: the two (public) CA
|
||||
* cert paths and the DNS zone are the mandatory triad; if ANY native field is supplied (including a
|
||||
* lone KEY_PATH), all three MUST be present or boot fails (a partial set is never a silent half-open,
|
||||
* mirroring `resolveOperatorLogin`). The private KEY paths are optional and derived from their cert
|
||||
* path when unset (mirrors `CA_INTERMEDIATE_KEY_PATH`). Returns `{}` when the feature is unconfigured.
|
||||
* NEVER echoes path VALUES — only field NAMES on failure (INV9).
|
||||
*/
|
||||
function resolveNativeCa(e: {
|
||||
NATIVE_FRP_CLIENT_CA_CERT_PATH: string | undefined
|
||||
NATIVE_FRP_CLIENT_CA_KEY_PATH: string | undefined
|
||||
NATIVE_DEVICE_CA_CERT_PATH: string | undefined
|
||||
NATIVE_DEVICE_CA_KEY_PATH: string | undefined
|
||||
NATIVE_DNS_ZONE: string | undefined
|
||||
}): { nativeCa?: NativeCaEnvConfig } {
|
||||
const has = (v: string | undefined): v is string => v !== undefined && v.length > 0
|
||||
const frpCert = e.NATIVE_FRP_CLIENT_CA_CERT_PATH
|
||||
const frpKey = e.NATIVE_FRP_CLIENT_CA_KEY_PATH
|
||||
const devCert = e.NATIVE_DEVICE_CA_CERT_PATH
|
||||
const devKey = e.NATIVE_DEVICE_CA_KEY_PATH
|
||||
const zone = e.NATIVE_DNS_ZONE
|
||||
const anyPresent = [frpCert, frpKey, devCert, devKey, zone].some(has)
|
||||
if (!anyPresent) return {} // feature off — dev/test injection path (self-signed in-process CAs)
|
||||
if (!has(frpCert) || !has(devCert) || !has(zone)) {
|
||||
const missing = [
|
||||
has(frpCert) ? null : 'NATIVE_FRP_CLIENT_CA_CERT_PATH',
|
||||
has(devCert) ? null : 'NATIVE_DEVICE_CA_CERT_PATH',
|
||||
has(zone) ? null : 'NATIVE_DNS_ZONE',
|
||||
].filter((x): x is string => x !== null)
|
||||
throw new Error(
|
||||
`Invalid control-plane env: native-tunnel CA material requires all of ${missing.join(', ')} to be set`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
nativeCa: {
|
||||
frpClientCaCertPath: frpCert,
|
||||
frpClientCaKeyPath: has(frpKey) ? frpKey : deriveKeyPath(frpCert),
|
||||
deviceCaCertPath: devCert,
|
||||
deviceCaKeyPath: has(devKey) ? devKey : deriveKeyPath(devCert),
|
||||
dnsZone: zone,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse + validate control-plane config. THROWS (fail-fast) listing every missing/invalid
|
||||
* key by NAME. Never echoes secret VALUES (INV9).
|
||||
@@ -125,5 +253,17 @@ export function loadEnv(source: NodeJS.ProcessEnv): ControlPlaneEnv {
|
||||
heartbeatTtlSec: e.HEARTBEAT_TTL_SEC,
|
||||
pairingTtlSec: e.PAIRING_TTL_SEC,
|
||||
pairingMaxRedeemAttempts: e.PAIRING_MAX_REDEEM_ATTEMPTS,
|
||||
...resolveOperatorLogin({
|
||||
OPERATOR_PASSWORD: e.OPERATOR_PASSWORD,
|
||||
OPERATOR_ACCOUNT_ID: e.OPERATOR_ACCOUNT_ID,
|
||||
CAPABILITY_SIGN_PRIVKEY_B64: e.CAPABILITY_SIGN_PRIVKEY_B64,
|
||||
}),
|
||||
...resolveNativeCa({
|
||||
NATIVE_FRP_CLIENT_CA_CERT_PATH: e.NATIVE_FRP_CLIENT_CA_CERT_PATH,
|
||||
NATIVE_FRP_CLIENT_CA_KEY_PATH: e.NATIVE_FRP_CLIENT_CA_KEY_PATH,
|
||||
NATIVE_DEVICE_CA_CERT_PATH: e.NATIVE_DEVICE_CA_CERT_PATH,
|
||||
NATIVE_DEVICE_CA_KEY_PATH: e.NATIVE_DEVICE_CA_KEY_PATH,
|
||||
NATIVE_DNS_ZONE: e.NATIVE_DNS_ZONE,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
184
control-plane/test/auth-login.test.ts
Normal file
184
control-plane/test/auth-login.test.ts
Normal 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 30–60s connect clamp
|
||||
})
|
||||
|
||||
test('wrong password → 401, no token minted', async () => {
|
||||
const app = buildApp()
|
||||
await app.ready()
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: 'wrong-but-long-enough' } })
|
||||
expect(res.statusCode).toBe(401)
|
||||
expect(JSON.parse(res.body).enrollToken).toBeUndefined()
|
||||
})
|
||||
|
||||
test('missing password field → 400 (Zod at the boundary)', async () => {
|
||||
const app = buildApp()
|
||||
await app.ready()
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: {} })
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
test('fail-closed when the operator credential is unset → 503, never mints', async () => {
|
||||
const app = buildApp({ signingKey: null, loginConfig: {} })
|
||||
await app.ready()
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: OPERATOR_PASSWORD } })
|
||||
expect(res.statusCode).toBe(503)
|
||||
expect(JSON.parse(res.body).enrollToken).toBeUndefined()
|
||||
})
|
||||
|
||||
test('login attempts are rate-limited → 429 after the threshold', async () => {
|
||||
const app = buildApp({ rateMax: 2 })
|
||||
await app.ready()
|
||||
const attempt = () =>
|
||||
app.inject({ method: 'POST', url: '/auth/login', payload: { password: 'wrong-but-long-enough' } })
|
||||
expect((await attempt()).statusCode).toBe(401)
|
||||
expect((await attempt()).statusCode).toBe(401)
|
||||
expect((await attempt()).statusCode).toBe(429) // over the per-client window
|
||||
})
|
||||
})
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// Block B — end-to-end through the WIRED control-plane (login → bearer → /device/enroll)
|
||||
// ───────────────────────────────────────────────────────────────────────────────────────────────
|
||||
describe('B1 login → device:enroll e2e (wired app)', () => {
|
||||
let app: FastifyInstance
|
||||
|
||||
beforeEach(async () => {
|
||||
resetVerifyKeyForTest()
|
||||
// Capability keypair: raw public → boot verify key; PKCS#8 private → the enroll signing key env.
|
||||
const pair = await generateEd25519KeyPair()
|
||||
const rawPub = await exportEd25519PublicRaw(pair.publicKey)
|
||||
const pkcs8 = new Uint8Array(await webcrypto.subtle.exportKey('pkcs8', pair.privateKey))
|
||||
const env = loadEnv({
|
||||
PG_URL: 'postgres://u:p@localhost:5432/cp',
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
CAPABILITY_SIGN_PUBKEY_B64: bytesToBase64(rawPub),
|
||||
CAPABILITY_SIGN_PRIVKEY_B64: bytesToBase64(pkcs8),
|
||||
OPERATOR_PASSWORD,
|
||||
OPERATOR_ACCOUNT_ID: ACCOUNT_A,
|
||||
CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate',
|
||||
CA_INTERMEDIATE_CERT_PATH: '/etc/cp/int.pem',
|
||||
NODE_MTLS_TRUST_BUNDLE_PATH: '/etc/cp/node-ca.pem',
|
||||
RELAY_TRUST_DOMAIN: 'terminal.yaojia.wang',
|
||||
BASE_DOMAIN: 'term.example.com',
|
||||
})
|
||||
const stores = createMemoryStores()
|
||||
const built = await buildControlPlane(env, { stores, verifier: createCapabilityVerifier() })
|
||||
app = built.app
|
||||
await app.ready()
|
||||
// Onboard a host so ACCOUNT_A OWNS 'alice' (device-enroll ownership gate reads this registry).
|
||||
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||
const { publicKeyRaw } = generateEd25519()
|
||||
await hosts.bindHost({ accountId: ACCOUNT_A, subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
|
||||
})
|
||||
|
||||
test('login mints a bearer that /device/enroll accepts (201); without it → 401', async () => {
|
||||
const login = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: OPERATOR_PASSWORD } })
|
||||
expect(login.statusCode).toBe(201)
|
||||
const { enrollToken, accountId } = JSON.parse(login.body)
|
||||
expect(accountId).toBe(ACCOUNT_A)
|
||||
|
||||
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
|
||||
const payload = { csr: Buffer.from(csr).toString('base64'), keyAlg: 'ec-p256', subdomain: 'alice', deviceName: 'iphone' }
|
||||
|
||||
const enrolled = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: `Bearer ${enrollToken}` },
|
||||
payload,
|
||||
})
|
||||
expect(enrolled.statusCode).toBe(201)
|
||||
expect(JSON.parse(enrolled.body).deviceId.length).toBeGreaterThan(0)
|
||||
|
||||
const noBearer = await app.inject({ method: 'POST', url: '/device/enroll', payload })
|
||||
expect(noBearer.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
test('wrong operator password on the wired app → 401', async () => {
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: 'definitely-the-wrong-secret' } })
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
99
control-plane/test/ca-wiring.test.ts
Normal file
99
control-plane/test/ca-wiring.test.ts
Normal 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))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -66,3 +66,110 @@ describe('T1 loadEnv (INV9 fail-fast)', () => {
|
||||
expect(() => loadEnv({ ...base(), CAPABILITY_SIGN_PUBKEY_B64: short })).toThrow(/32 bytes/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('B1 operator-login env (set-together-or-none, fail-closed)', () => {
|
||||
const OPERATOR_PASSWORD = 'operator-secret-abcdef123456'
|
||||
const ACCOUNT = '11111111-1111-4111-8111-111111111111'
|
||||
const PRIVKEY = bytesToBase64(new Uint8Array(48).fill(3)) // shape-valid base64; import validated at boot
|
||||
|
||||
test('none set → login fields undefined (feature off, route fail-closed at runtime)', () => {
|
||||
const env = loadEnv(base())
|
||||
expect(env.operatorPassword).toBeUndefined()
|
||||
expect(env.operatorAccountId).toBeUndefined()
|
||||
expect(env.capabilitySignPrivkey).toBeUndefined()
|
||||
})
|
||||
|
||||
test('all three set → parsed together', () => {
|
||||
const env = loadEnv({
|
||||
...base(),
|
||||
OPERATOR_PASSWORD,
|
||||
OPERATOR_ACCOUNT_ID: ACCOUNT,
|
||||
CAPABILITY_SIGN_PRIVKEY_B64: PRIVKEY,
|
||||
})
|
||||
expect(env.operatorPassword).toBe(OPERATOR_PASSWORD)
|
||||
expect(env.operatorAccountId).toBe(ACCOUNT)
|
||||
expect(env.capabilitySignPrivkey?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('password without account/key → fail-fast (no silent half-open)', () => {
|
||||
expect(() => loadEnv({ ...base(), OPERATOR_PASSWORD })).toThrow(/OPERATOR_ACCOUNT_ID|CAPABILITY_SIGN_PRIVKEY_B64/)
|
||||
})
|
||||
|
||||
test('too-short operator password rejected (16–512 charset rule)', () => {
|
||||
expect(() =>
|
||||
loadEnv({ ...base(), OPERATOR_PASSWORD: 'short', OPERATOR_ACCOUNT_ID: ACCOUNT, CAPABILITY_SIGN_PRIVKEY_B64: PRIVKEY }),
|
||||
).toThrow(/OPERATOR_PASSWORD/)
|
||||
})
|
||||
|
||||
test('non-UUID operator account rejected', () => {
|
||||
expect(() =>
|
||||
loadEnv({ ...base(), OPERATOR_PASSWORD, OPERATOR_ACCOUNT_ID: 'not-a-uuid', CAPABILITY_SIGN_PRIVKEY_B64: PRIVKEY }),
|
||||
).toThrow(/OPERATOR_ACCOUNT_ID/)
|
||||
})
|
||||
|
||||
test('never echoes the operator secret on a partial-config failure (INV9)', () => {
|
||||
try {
|
||||
loadEnv({ ...base(), OPERATOR_PASSWORD })
|
||||
} catch (e) {
|
||||
expect(e instanceof Error ? e.message : '').not.toContain(OPERATOR_PASSWORD)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('A2-prep native-tunnel CA env (set-together-or-none, fail-closed)', () => {
|
||||
const FRP_CERT = '/etc/cp/frp-client-ca.cert.pem'
|
||||
const DEV_CERT = '/etc/cp/device-ca.cert.pem'
|
||||
const ZONE = 'native.example.test'
|
||||
|
||||
test('none set → nativeCa undefined (injection/dev path)', () => {
|
||||
const env = loadEnv(base())
|
||||
expect(env.nativeCa).toBeUndefined()
|
||||
})
|
||||
|
||||
test('cert paths + dns zone set → parsed, key paths DERIVED from cert paths', () => {
|
||||
const env = loadEnv({
|
||||
...base(),
|
||||
NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT,
|
||||
NATIVE_DEVICE_CA_CERT_PATH: DEV_CERT,
|
||||
NATIVE_DNS_ZONE: ZONE,
|
||||
})
|
||||
expect(env.nativeCa).toEqual({
|
||||
frpClientCaCertPath: FRP_CERT,
|
||||
frpClientCaKeyPath: '/etc/cp/frp-client-ca.key.pem',
|
||||
deviceCaCertPath: DEV_CERT,
|
||||
deviceCaKeyPath: '/etc/cp/device-ca.key.pem',
|
||||
dnsZone: ZONE,
|
||||
})
|
||||
})
|
||||
|
||||
test('explicit key paths are used verbatim (not derived)', () => {
|
||||
const env = loadEnv({
|
||||
...base(),
|
||||
NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT,
|
||||
NATIVE_FRP_CLIENT_CA_KEY_PATH: '/keys/frp.pem',
|
||||
NATIVE_DEVICE_CA_CERT_PATH: DEV_CERT,
|
||||
NATIVE_DEVICE_CA_KEY_PATH: '/keys/dev.pem',
|
||||
NATIVE_DNS_ZONE: ZONE,
|
||||
})
|
||||
expect(env.nativeCa?.frpClientCaKeyPath).toBe('/keys/frp.pem')
|
||||
expect(env.nativeCa?.deviceCaKeyPath).toBe('/keys/dev.pem')
|
||||
})
|
||||
|
||||
test('partial set — only frp cert path → fail-closed listing the missing keys', () => {
|
||||
expect(() => loadEnv({ ...base(), NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT })).toThrow(
|
||||
/NATIVE_DEVICE_CA_CERT_PATH|NATIVE_DNS_ZONE/,
|
||||
)
|
||||
})
|
||||
|
||||
test('partial set — cert paths without a dns zone → fail-closed', () => {
|
||||
expect(() =>
|
||||
loadEnv({ ...base(), NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT, NATIVE_DEVICE_CA_CERT_PATH: DEV_CERT }),
|
||||
).toThrow(/NATIVE_DNS_ZONE/)
|
||||
})
|
||||
|
||||
test('partial set — a key path supplied WITHOUT its cert path → fail-closed', () => {
|
||||
expect(() => loadEnv({ ...base(), NATIVE_FRP_CLIENT_CA_KEY_PATH: '/keys/frp.pem' })).toThrow(
|
||||
/NATIVE_FRP_CLIENT_CA_CERT_PATH|NATIVE_DEVICE_CA_CERT_PATH|NATIVE_DNS_ZONE/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
215
control-plane/test/native-ca.test.ts
Normal file
215
control-plane/test/native-ca.test.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
165
docs/PLAN_ZERO_TOUCH_ROLLOUT.md
Normal file
165
docs/PLAN_ZERO_TOUCH_ROLLOUT.md
Normal 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 (small–medium; mostly deploy + wire)
|
||||
|
||||
**Goal:** fresh Mac → one pairing code → `pair --install` → auto-connect + self-managing cert, reboot-durable.
|
||||
|
||||
| # | Task | Where | Effort | Notes / risk |
|
||||
|---|---|---|---|---|
|
||||
| A1 | Resolve pre-flight §3.1–3.6 (CA material, CP build, public `/enroll`, frps CA, manage bearer, token) | VPS | S–M | Gated by §3.1 KMS/CA material — the real unknown. |
|
||||
| A2 | Deploy + run the new control-plane in **production mode** (real CA material, PG/Redis, systemd unit) | VPS `control-plane/` | M | Replace the old relay-phase1 CP; keep loopback:8080. |
|
||||
| A3 | nginx vhost proxying `/enroll` `/device/enroll` `/renew` → 127.0.0.1:8080 over the LE wildcard TLS | `deploy/nginx` + VPS | S | Add to existing :443 SNI / a dedicated enroll host. |
|
||||
| A4 | Mint one **pairing code** (`POST /accounts/:id/pairing-codes`, `manage` bearer) | VPS | S | MVP = manually pasted code (RFC 8628 device-grant deferred). |
|
||||
| A5 | **Wire auto-renew into the native run-loop** — call `createCertRotator`/`renewCert` from `superviseNative` | `agent/src` (**only real host code gap**) | S | Logic already built+tested; today `superviseNative` only *monitors* freshness → cert dies at 24h. TDD it. |
|
||||
| A6 | Build the agent (`dist/cli.js`) + set env (`ENROLL_URL`,`TUNNEL_DOMAIN`,`TUNNEL_ZONE=terminal`,`FRP_AUTH_TOKEN`,`PORT`,`BIND_HOST=127.0.0.1`, dummy `RELAY_URL`) | Mac | S | |
|
||||
| A7 | Run `web-terminal-agent pair <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 (A1–A8)** → **M-Host**: fresh Mac, one command, reboot-durable, self-renewing. *Ships the host half of the requirement.*
|
||||
3. **B1 (login/bearer)** → unblocks everything phone-side.
|
||||
4. **B3 (iOS)** → **M-Device-iOS**: iPhone auto-enrolls, no `.p12`.
|
||||
5. **B4 (Android)** → **M-Device-Android**.
|
||||
6. Later: attestation → CRL → B2 (per-tenant) = gate for a 2nd distrusting customer.
|
||||
|
||||
## 8. Top risks
|
||||
|
||||
- **[R1] Production native-CA wiring in the new CP (§3.1)** — **RE-ASSESSED 2026-07-18: a real (small–medium)
|
||||
CODE task, the hard prerequisite for the VPS deploy.** The P-256 CA keys + certs exist on the VPS
|
||||
(`/etc/relay/{device-ca,frp-client-ca}/*.key.pem`), BUT: (1) `env.ts` does **not** define
|
||||
`NATIVE_FRP_CLIENT_CA_*` / `NATIVE_DEVICE_CA_*` (native-ca.ts:16 flags this as the "concrete follow-up");
|
||||
(2) `buildNativeCas` exists but is **never called from `server.ts`** — the prod boot doesn't wire the
|
||||
native CAs at all (the 246 tests inject dev `inProcessP256CaSigner`s); (3) production wants a KMS resolver
|
||||
and there is no file-backed signer to load the on-disk PEM keys. **Task A2-prep (do before deploy):** add
|
||||
the `NATIVE_*` env vars + a file-backed `KmsResolver` (load PEM P-256 key → sign) + call `buildNativeCas`
|
||||
in `server.ts` and thread the CAs into the enroll services, TDD + reviewed. File-key custody (not KMS) is
|
||||
acceptable for the single-owner VPS (mirrors the existing `CA_INTERMEDIATE_KEY_PATH` file fallback);
|
||||
document the KMS upgrade as future.
|
||||
- **[R2] frps control-channel CA mismatch** — must be `frp-client-CA`, not `device-CA`; crossed = silent
|
||||
1006/handshake failure (this class of bug already cost days in M1).
|
||||
- **[R3] nginx dNSName→Host binding** — the load-bearing isolation control; needs positive **and**
|
||||
negative tests (leaf-A must be refused for subdomain-B) even in MVP.
|
||||
- **[R4] Phone login trust seam (B1)** — the whole phone track hinges on a credential mechanism; a weak
|
||||
seam here is a security hole (it mints certs). Scope it deliberately even for single-operator.
|
||||
- **[R5] BIND_HOST loopback** — mandatory on every host; `0.0.0.0` re-opens an unauth shell on the LAN
|
||||
bypassing mTLS. The agent enforces it (S-GATE) — keep it enforced.
|
||||
|
||||
---
|
||||
_Grounded against `develop` + VPS `8.138.1.192` as of 2026-07-18. Design source: `docs/PLAN_TUNNEL_AUTOMATION.md`._
|
||||
275
ios/App/WebTerm/Screens/EnrollmentScreen.swift
Normal file
275
ios/App/WebTerm/Screens/EnrollmentScreen.swift
Normal 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 = "注册失败,请重试。"
|
||||
}
|
||||
@@ -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(扫码或手输地址),会话会出现在这里。"
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClientTLS
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Observation
|
||||
@@ -36,6 +37,20 @@ final class AppCoordinator {
|
||||
/// its own `ClientCertViewModel` over the keychain store; dismissal needs no
|
||||
/// refresh because the transports resolve the identity lazily per connection.
|
||||
var isDeviceCertPresented = false
|
||||
/// B3 · "自动获取证书" sheet — the zero-`.p12` enrollment flow
|
||||
/// (`EnrollmentScreen`): one login → Secure-Enclave key + CSR → device cert,
|
||||
/// presented automatically on the existing mTLS path. VM built per entry.
|
||||
var isEnrollmentPresented = false
|
||||
private(set) var enrollmentViewModel: EnrollmentViewModel?
|
||||
/// B3 · Re-entrancy guard so overlapping foregrounds never launch two silent
|
||||
/// renews at once (a shared PTY-style single-flight for the rotation pass).
|
||||
@ObservationIgnored private var isRotating = false
|
||||
/// B3 (HIGH observability fix) · Persistent, OBSERVABLE flag: the last silent
|
||||
/// rotation pass ended in `.failed` (keychain read fault or renew error). Set
|
||||
/// alongside the os.Logger line so a silently-failing renewal surfaces to the
|
||||
/// UI (a warning banner) instead of vanishing into the log. Cleared by the
|
||||
/// next non-failed pass — a recovered renewal drops the warning automatically.
|
||||
private(set) var isCertificateRenewalFailing = false
|
||||
|
||||
let sessionList: SessionListViewModel
|
||||
@ObservationIgnored let environment: AppEnvironment
|
||||
@@ -69,6 +84,7 @@ final class AppCoordinator {
|
||||
if route == .pairing {
|
||||
rootPairingViewModel = makePairingViewModel()
|
||||
}
|
||||
runCertificateRotationIfDue() // B3 · renew a due device cert on cold launch
|
||||
await deepLink.markReady() // flush a cold-launch deep link (T-iOS-22)
|
||||
}
|
||||
|
||||
@@ -120,6 +136,50 @@ final class AppCoordinator {
|
||||
isDeviceCertPresented = true
|
||||
}
|
||||
|
||||
// MARK: - Device enrollment (B3, zero-.p12 auto-cert)
|
||||
|
||||
/// Toolbar host-menu 入口:呈现「自动获取证书」注册 sheet。VM 每次进入重建。
|
||||
func presentEnrollment() {
|
||||
enrollmentViewModel = makeEnrollmentViewModel()
|
||||
isEnrollmentPresented = true
|
||||
}
|
||||
|
||||
/// Sheet 消失:丢弃 VM,并立刻跑一次轮换检查(刚注册完的证书据此登记续期)。
|
||||
func enrollmentDismissed() {
|
||||
enrollmentViewModel = nil
|
||||
runCertificateRotationIfDue()
|
||||
}
|
||||
|
||||
/// 生产 VM:登录 → 安全区生成密钥+CSR → `/device/enroll` → 存钥匙串,全部经
|
||||
/// `DeviceEnrollmentFlow`(复用 ClientTLS 库,本层零加密/钥匙串逻辑)。
|
||||
private func makeEnrollmentViewModel() -> EnrollmentViewModel {
|
||||
let http = environment.http
|
||||
return EnrollmentViewModel(
|
||||
enrollOperation: { password, subdomain, deviceName, url in
|
||||
try await makeDeviceEnrollmentFlow(controlPlaneURL: url, http: http)
|
||||
.run(password: password, subdomain: subdomain, deviceName: deviceName)
|
||||
},
|
||||
loadSummary: { (try? KeychainClientIdentityStore().loadSummary()) ?? nil }
|
||||
)
|
||||
}
|
||||
|
||||
/// B3 · Silent rotation: check the installed leaf's timing and, if the renew
|
||||
/// window has opened, renew over mTLS against the SAME Secure-Enclave key.
|
||||
/// Fire-and-forget on launch + every foreground; single-flight via
|
||||
/// `isRotating`. A `.notEnrolled` / `.notDue` pass is cheap and common.
|
||||
func runCertificateRotationIfDue() {
|
||||
guard !isRotating else { return }
|
||||
isRotating = true
|
||||
let scheduler = makeCertificateRotationScheduler(http: environment.http)
|
||||
Task { [weak self] in
|
||||
let outcome = await scheduler.runIfDue()
|
||||
self?.isRotating = false
|
||||
// Surface a silently-failing renewal as observable state (not only a
|
||||
// log line); a later successful/not-due pass clears it.
|
||||
self?.isCertificateRenewalFailing = (outcome == .failed)
|
||||
}
|
||||
}
|
||||
|
||||
/// "在此仓库开新会话":关 sheet → fresh spawn(`attach(null, cwd)`)+
|
||||
/// attach 后注入 `claude\r`(帧序由 engine 的 attach-first 队列保证)。
|
||||
func openProject(_ request: ProjectOpenRequest) {
|
||||
@@ -290,6 +350,7 @@ final class AppCoordinator {
|
||||
terminalController?.suspend()
|
||||
case .active:
|
||||
terminalController?.resumeIfNeeded()
|
||||
runCertificateRotationIfDue() // B3 · check for a due renewal each foreground
|
||||
case .inactive:
|
||||
break // transient; shade covers it at the view layer
|
||||
@unknown default:
|
||||
|
||||
90
ios/App/WebTerm/Wiring/DeviceEnrollmentWiring.swift
Normal file
90
ios/App/WebTerm/Wiring/DeviceEnrollmentWiring.swift
Normal 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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 = "设备证书自动续期失败,将在下次前台重试"
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
142
ios/App/WebTermTests/EnrollmentViewModelTests.swift
Normal file
142
ios/App/WebTermTests/EnrollmentViewModelTests.swift
Normal 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 error→copy mapping —
|
||||
/// including that the password never lingers after an attempt.
|
||||
@MainActor
|
||||
@Suite("EnrollmentViewModel")
|
||||
struct EnrollmentViewModelTests {
|
||||
/// Records enroll invocations + replays a scripted result/error.
|
||||
private actor EnrollScript {
|
||||
private(set) var calls: [(password: String, subdomain: String, deviceName: String, url: URL)] = []
|
||||
private let result: Result<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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user