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:
263
control-plane/src/registry/devices.ts
Normal file
263
control-plane/src/registry/devices.ts
Normal 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user