feat(tunnel): zero-touch tunnel enrollment — control-plane PKI, host agent, iOS, nginx isolation

Customers install one command / log in once; hardware-generated keys never leave the
device; CSRs return certs + subdomain; frpc + base-app run as durable services. No .p12,
no manual cert import. Implements the MVP fast-path of docs/PLAN_TUNNEL_AUTOMATION.md.

Control-plane / PKI (control-plane/):
- ca/x509-assembler.ts: single KMS-signed real X.509 issuance primitive (Ed25519 + P-256)
- ca/csr-ec.ts: P-256 PKCS#10 proof-of-possession (verifyCsrPoPEc) + CSR-key routing
- ca/frpclient-issue.ts, ca/device-issue.ts: P-256 frp-client + device leaf signers
- ca/rotate.ts + api/renew.ts: real-X.509 /renew + /device/:id/renew (mTLS current cert)
- registry/devices.ts: device registry + per-account cap/rate-limit
- auth/session.ts: device:enroll capability token mint/verify
- api/device-enroll.ts: POST /device/enroll (ownership-gated, deny-by-default)
- pairing/native-redeem.ts + shared gateAndConsumePairingCode; api/provision.ts native arm
- boot/native-ca.ts + main.ts: wire two P-256 CAs + issuers + routers (dev / KMS fail-fast)

Contracts: relay-contracts enroll right; relay-auth SPIFFE /device/ arm + spiffeIdFor(kind)

Host agent (agent/):
- transport/frpcToml.ts; provision/frpcBinary.ts + untar.ts (verify-download + traversal-safe extract)
- keys P-256 keygen/CSR/loadIdentity; service two-unit install + BIND_HOST loopback S-GATE
- net/loopbackLiteral.ts strict guard; health/probe.ts + transport/frpSupervise.ts; cli pair --install

iOS (ios/Packages/ClientTLS): SecureEnclaveKey + CertificateSigningRequest + DeviceEnrollmentClient
+ Keychain enroll refactor (SecKey/Security.framework end-to-end, avoids the -25300 trap)

Isolation (deploy/nginx): njs/getCertSub.js SAN parser + zone-anchored map -> 403

Verified: 758 tests green (control-plane 246, agent 267, relay-auth 133, relay-contracts 85,
iOS ClientTLS 27), all tsc clean; real nginx+njs docker 403/200/400; Swift CSR accepted by
the real control-plane verifier; frpc extract byte-identical to `tar -xO`. Cross-validation
caught + fixed 5 real defects (1 critical, 4 high). Remaining = infra (KMS, nginx deploy,
VPS frps, physical iPhone) per PROGRESS_LOG runbook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-07-10 16:11:13 +02:00
parent 31054450fc
commit e7f3bd05f0
79 changed files with 9920 additions and 385 deletions

View File

@@ -0,0 +1,263 @@
/**
* A4 — device registry (FIX C-native-1 / H-native-4). Mirrors `registry/hosts.ts`: the ownership
* source of truth for enrolled DEVICES (the mTLS data-path identity), keyed by an unguessable
* `deviceId` bound to an account. Only the PUBLIC P-256 key is stored (INV4). Status changes are
* versioned append-only snapshots with an atomic single-row pointer swap (INV8), exactly like
* `store/memory.ts`'s host store. Adds the per-account controls the enroll surface needs: a device
* CAP and an enroll RATE-LIMIT (leaked-bootstrap blast-radius, §5).
*
* The `DeviceStore` port lives in `store/ports.ts` (add-only); the in-memory adapter is provided
* here (`createMemoryDeviceStore`) so the device path is self-contained and does not touch the
* shared `store/memory.ts` `Stores` wiring.
*/
import type { DeviceStore } from '../store/ports.js'
import { newUuid, nowIso } from '../util/ids.js'
import { bytesToHex } from '../util/bytes.js'
import { randomBytes } from 'node:crypto'
/** Lifecycle status (INV8-versioned). */
export type DeviceStatus = 'active' | 'revoked'
/** Best-effort attestation strength recorded at enroll (verification deferred, §1.1). */
export type AttestationLevel = 'none' | 'software' | 'hardware'
/** Immutable device record. Only the PUBLIC P-256 SPKI is stored (INV4). */
export interface DeviceRecord {
readonly deviceId: string
readonly accountId: string
/**
* The subdomain label the device leaf is bound to (the nginx enforcement key, dNSName SAN). This is
* SAN-critical and must be a canonical RFC-1123 label the `accountId` actually OWNS — the caller is
* responsible for validating (subdomain/assign.ts `isValidSubdomain`) + ownership-gating it BEFORE
* `registerDevice` (see api/device-enroll.ts). The registry stores it verbatim; it does not re-check.
*/
readonly subdomainScope: string
/** P-256 SubjectPublicKeyInfo DER (the CSR-embedded public key). */
readonly ecPubkeySpki: Uint8Array
/** Issued leaf serial (hex) — authoritative expiry pairing for revocation/CRL. */
readonly serial: string
readonly status: DeviceStatus
/** ISO expiry of the issued leaf. */
readonly notAfter: string
readonly attestationLevel: AttestationLevel
readonly createdAt: string
readonly revokedAt: string | null
}
/** Append-only companion row for `device.status`/`revoked_at` versioning (INV8). */
export interface DeviceStatusVersionRow {
readonly deviceId: string
readonly version: number
readonly status: DeviceStatus
readonly revokedAt: string | null
readonly changedAt: string
readonly changedBy: string
}
/** Per-account device cap default (leaked-bootstrap blast radius, §5). */
export const DEFAULT_DEVICE_CAP = 20
/** Per-account enroll rate default within the window. */
export const DEFAULT_DEVICE_ENROLL_RATE_MAX = 20
/** Enroll rate window (ms). Mirrors the "enrollPerHour" shape. */
export const DEFAULT_DEVICE_ENROLL_RATE_WINDOW_MS = 60 * 60 * 1000
/** Device leaf TTL bounds (24h floor .. 7d ceiling, §1.3). */
export const DEFAULT_DEVICE_LEAF_TTL_SEC = 24 * 60 * 60
export const MIN_DEVICE_LEAF_TTL_SEC = 24 * 60 * 60
export const MAX_DEVICE_LEAF_TTL_SEC = 7 * 24 * 60 * 60
/** Uniform per-account limit reject → 429 at the route. `code` is machine-readable, not user-facing. */
export class DeviceLimitError extends Error {
constructor(public readonly code: 'cap_exceeded' | 'rate_limited') {
super('device enrollment limit') // uniform message — never leak counts/thresholds
this.name = 'DeviceLimitError'
}
}
export interface RegisterDeviceInput {
readonly accountId: string
readonly subdomainScope: string
readonly ecPubkeySpki: Uint8Array
readonly attestationLevel: AttestationLevel
}
/**
* Narrow renewal-time capability: recompute + persist a device's leaf expiry. Split out so the leaf
* renewer (`ca/rotate.ts`) depends only on this, not the whole registry.
*/
export interface DeviceExpiryRenewer {
/**
* Recompute + persist a fresh leaf expiry (`now()+ttl`, mirroring the host path) for a KNOWN, ACTIVE
* device; returns the updated record. Unknown/revoked devices are NOT mutated (deny-by-default) —
* the value passed through unchanged (`null` for unknown) and the downstream leaf gate rejects.
*/
renewDeviceExpiry(deviceId: string): Promise<DeviceRecord | null>
}
export interface DeviceRegistry extends DeviceExpiryRenewer {
registerDevice(input: RegisterDeviceInput): Promise<DeviceRecord>
getDevice(deviceId: string): Promise<DeviceRecord | null>
listDevices(accountId: string): Promise<readonly DeviceRecord[]>
setDeviceStatus(deviceId: string, status: DeviceStatus): Promise<DeviceRecord>
ownsDevice(accountId: string, deviceId: string): Promise<boolean>
/** Throws `DeviceLimitError('cap_exceeded')` when the account already holds `deviceCap` ACTIVE devices. */
assertUnderCap(accountId: string): Promise<void>
/** Records + checks the per-account enroll rate; throws `DeviceLimitError('rate_limited')` when over. */
checkRateLimit(accountId: string): void
}
function clampLeafTtl(ttl: number): number {
return Math.min(Math.max(ttl, MIN_DEVICE_LEAF_TTL_SEC), MAX_DEVICE_LEAF_TTL_SEC)
}
export interface DeviceRegistryDeps {
readonly devices: DeviceStore
readonly actor?: string
readonly deviceCap?: number
readonly rateMax?: number
readonly rateWindowMs?: number
readonly leafTtlSec?: number
/** Clock (ms) — injectable for tests. */
readonly now?: () => number
}
// NOTE: device lifecycle audit is intentionally NOT written here — the `AuditAction` enum
// (audit/log.ts) has no `device.*` members and extending it is out of this task's scope (a shared
// coordination point). Wire device audit once the enum gains `device.enroll`/`device.revoke`.
export function createDeviceRegistry(deps: DeviceRegistryDeps): DeviceRegistry {
const actor = deps.actor ?? 'system'
const deviceCap = deps.deviceCap ?? DEFAULT_DEVICE_CAP
const rateMax = deps.rateMax ?? DEFAULT_DEVICE_ENROLL_RATE_MAX
const rateWindowMs = deps.rateWindowMs ?? DEFAULT_DEVICE_ENROLL_RATE_WINDOW_MS
const leafTtlSec = clampLeafTtl(deps.leafTtlSec ?? DEFAULT_DEVICE_LEAF_TTL_SEC)
const now = deps.now ?? (() => Date.now())
// Per-account sliding-window enroll timestamps (in-process rate-limiter, mirrors memRouteStore state).
const rateHits = new Map<string, number[]>()
return {
async registerDevice(input) {
const ts = now()
const rec: DeviceRecord = {
deviceId: newUuid(), // unguessable UUIDv4 (INV1)
accountId: input.accountId,
subdomainScope: input.subdomainScope,
// Defensive copy → fresh ArrayBuffer-backed array (immutability + INV4 public-key-only).
ecPubkeySpki: new Uint8Array(input.ecPubkeySpki),
serial: bytesToHex(randomBytes(16)),
status: 'active',
notAfter: new Date(ts + leafTtlSec * 1000).toISOString(),
attestationLevel: input.attestationLevel,
createdAt: nowIso(),
revokedAt: null,
}
await deps.devices.insert(rec)
return rec
},
async getDevice(deviceId) {
return deps.devices.get(deviceId)
},
async listDevices(accountId) {
return deps.devices.listByAccount(accountId) // ownership-scoped
},
async setDeviceStatus(deviceId, status) {
const revokedAt = status === 'revoked' ? nowIso() : null
return deps.devices.swapStatus(deviceId, status, revokedAt, actor)
},
async renewDeviceExpiry(deviceId) {
// Fresh expiry EACH renewal (mirrors the host `now()+ttl`). Without this the device leaf would
// reproduce the ORIGINAL enrollment expiry on every renewal — eventually issuing already-expired
// certs. Deny-by-default: only KNOWN + ACTIVE devices are extended; unknown/revoked are left
// untouched (the downstream device-leaf gate produces the canonical uniform reject).
const record = await deps.devices.get(deviceId)
if (record === null || record.status === 'revoked') return record
const notAfter = new Date(now() + leafTtlSec * 1000).toISOString()
return deps.devices.renewLeafExpiry(deviceId, notAfter)
},
async ownsDevice(accountId, deviceId) {
const dev = await deps.devices.get(deviceId)
// Deny-by-default: unknown device or account mismatch ⇒ false (INV1/INV6).
return dev !== null && dev.accountId === accountId
},
async assertUnderCap(accountId) {
const active = await deps.devices.countActiveByAccount(accountId)
if (active >= deviceCap) throw new DeviceLimitError('cap_exceeded')
},
checkRateLimit(accountId) {
const ts = now()
const cutoff = ts - rateWindowMs
const hits = (rateHits.get(accountId) ?? []).filter((t) => t > cutoff)
if (hits.length >= rateMax) {
rateHits.set(accountId, hits) // persist the pruned window; do NOT record this rejected attempt
throw new DeviceLimitError('rate_limited')
}
hits.push(ts)
rateHits.set(accountId, hits)
},
}
}
// ── In-memory DeviceStore adapter (mirrors store/memory.ts memHostStore, INV8) ────────────────────
interface DeviceCell {
record: DeviceRecord
version: number
versions: DeviceStatusVersionRow[]
}
export function createMemoryDeviceStore(): DeviceStore {
const cells = new Map<string, DeviceCell>()
return {
async insert(rec) {
if (cells.has(rec.deviceId)) throw new Error('duplicate deviceId')
cells.set(rec.deviceId, {
record: rec,
version: 1,
versions: [
{
deviceId: rec.deviceId,
version: 1,
status: rec.status,
revokedAt: rec.revokedAt,
changedAt: rec.createdAt,
changedBy: 'system',
},
],
})
},
async get(id) {
return cells.get(id)?.record ?? null
},
async listByAccount(accountId) {
return [...cells.values()].filter((c) => c.record.accountId === accountId).map((c) => c.record)
},
async countActiveByAccount(accountId) {
let n = 0
for (const c of cells.values()) {
if (c.record.accountId === accountId && c.record.status === 'active') n++
}
return n
},
async swapStatus(id, status, revokedAt, changedBy) {
const cell = cells.get(id)
if (cell === undefined) throw new Error('device not found')
// --- atomic critical section (no await) ---
const nextVersion = cell.version + 1
const changedAt = new Date().toISOString()
const nextRecord: DeviceRecord = { ...cell.record, status, revokedAt }
cell.versions.push({ deviceId: id, version: nextVersion, status, revokedAt, changedAt, changedBy })
cell.record = nextRecord
cell.version = nextVersion
// --- end critical section ---
return nextRecord
},
async renewLeafExpiry(id, notAfter) {
const cell = cells.get(id)
if (cell === undefined) throw new Error('device not found')
// --- atomic critical section (no await) ---
const nextRecord: DeviceRecord = { ...cell.record, notAfter } // immutable: fresh expiry, new record
cell.record = nextRecord
// --- end critical section ---
return nextRecord
},
async versions(id) {
return (cells.get(id)?.versions ?? []).slice()
},
}
}