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,166 @@
/**
* A4 (FIX C-native-1) — the MINIMAL device:enroll session-token subsystem + a login stub seam.
*
* `/device/enroll` is the one endpoint that cannot require a client cert (chicken-and-egg), so it is
* gated by a short-lived, narrowly-scoped `device:enroll` bearer minted after the user's one-time
* login. That bearer IS a §4.3 capability token with `rights:['enroll']` (the right added in A2a),
* `sub = accountId`, a DISTINCT audience (`device-enroll`), and a MINUTES-scale TTL that is
* deliberately SEPARATE from the 3060s connect clamp.
*
* WHY NOT `issueCapabilityToken`: relay-auth's connect-token issuer hard-clamps TTL to ≤60s (the
* Finding-4 blast-radius clamp) and requires a single-host + DPoP `cnf.jkt`. An enroll token is not
* host-scoped and must live for minutes, so we mint via the SAME underlying primitive
* (`signPaseto`, Ed25519 v4.public — relay-auth crypto, NO new crypto) and build the identical §4.3
* body shape so the FROZEN verifier (`verifyCapabilityToken`) accepts it unchanged. Verification
* therefore goes through the exact path the control-plane already uses (boot/verifier.ts), then
* asserts the `enroll` right — deny-by-default on anything else.
*
* LOGIN is a STUB SEAM for the single-tenant MVP (`loginToAccountId`): it resolves an authenticated
* credential to an `accountId`. A full end-user auth layer (RFC 8628 device grant / OIDC) layers on
* here later WITHOUT changing the token shape or the verify path.
*/
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 { timingSafeEqualBytes } from '../util/bytes.js'
/** Distinct audience for device enrollment (Host-confusion guard — never a subdomain aud). */
export const DEVICE_ENROLL_AUD = 'device-enroll'
/** Default enroll-token TTL: 10 minutes (minutes-scale, separate from the 3060s connect clamp). */
export const DEFAULT_DEVICE_ENROLL_TTL_SEC = 10 * 60
/** Floor: a device:enroll token must outlive the connect clamp to be meaningfully separate. */
export const MIN_DEVICE_ENROLL_TTL_SEC = 60
/** Ceiling: still short-lived (leaked-bearer blast radius). */
export const MAX_DEVICE_ENROLL_TTL_SEC = 60 * 60
/** Enroll tokens are not host-scoped; the frozen §4.3 shape requires a non-empty `host` — sentinel. */
const ENROLL_HOST_SENTINEL = 'device-enroll'
/** Uniform auth reject for the device-enroll surface. 401 = bad/missing token, 403 = lacks right. */
export class DeviceEnrollAuthError extends Error {
constructor(
public readonly status: 401 | 403,
message = 'device enrollment auth rejected',
) {
super(message)
this.name = 'DeviceEnrollAuthError'
}
}
/** The frozen §4.3 body + the additive `cnf.jkt` claim the verifier requires (RFC 7800). */
interface EnrollTokenBody {
readonly sub: string
readonly aud: string
readonly host: string
readonly rights: readonly CapabilityRight[]
readonly iat: number
readonly exp: number
readonly jti: string
readonly cnf: { readonly jkt: string }
}
export interface MintDeviceEnrollOpts {
/** The session subsystem's Ed25519 signing key (WebCrypto). Never held globally (INV9). */
readonly signingKey: CryptoKey
readonly ttlSeconds?: number
readonly now?: number // epoch seconds
readonly aud?: string
/** Optional DPoP thumbprint. Enroll-token PoP binding is deferred; a placeholder is used if absent. */
readonly cnfJkt?: string
readonly jti?: string
}
function clampTtl(ttl: number): number {
return Math.min(Math.max(ttl, MIN_DEVICE_ENROLL_TTL_SEC), MAX_DEVICE_ENROLL_TTL_SEC)
}
/** A 43-char base64url placeholder satisfying the shared verifier's `cnf.jkt` (min-1) requirement. */
function placeholderJkt(): string {
return Buffer.from(randomBytes(32)).toString('base64url')
}
/**
* Mint a `device:enroll` capability token: `rights:['enroll']`, `sub = accountId`, `aud = device-enroll`,
* minutes-scale TTL. Signed with the caller-supplied Ed25519 key via relay-auth's PASETO primitive.
*/
export async function mintDeviceEnrollToken(accountId: string, opts: MintDeviceEnrollOpts): Promise<string> {
if (accountId.length === 0) throw new DeviceEnrollAuthError(401, 'empty accountId')
const now = opts.now ?? Math.floor(Date.now() / 1000)
const ttl = clampTtl(opts.ttlSeconds ?? DEFAULT_DEVICE_ENROLL_TTL_SEC)
const body: EnrollTokenBody = {
sub: accountId,
aud: opts.aud ?? DEVICE_ENROLL_AUD,
host: ENROLL_HOST_SENTINEL,
rights: ['enroll'],
iat: now,
exp: now + ttl,
jti: opts.jti ?? randomUUID(),
cnf: { jkt: opts.cnfJkt ?? placeholderJkt() },
}
return signPaseto(body, opts.signingKey)
}
/** Least-privilege check: the bearer must carry the `enroll` right, else 403 (uniform). */
export function requireEnrollRight(token: CapabilityToken): void {
if (!token.rights.includes('enroll')) {
throw new DeviceEnrollAuthError(403, 'token scope lacks the enroll right')
}
}
export interface VerifyDeviceEnrollOpts {
readonly now?: number // epoch seconds
readonly aud?: string
}
/**
* Verify a `device:enroll` bearer through the FROZEN §4.3 verifier (same path as boot/verifier.ts):
* Ed25519 signature (startup key), `aud === device-enroll`, and `iat`/`exp` window — then assert the
* `enroll` right. Returns `{ accountId }` (from the token `sub`, never a client field). Uniform reject:
* any signature/audience/expiry failure → 401; a missing `enroll` right → 403.
*/
export async function verifyDeviceEnrollToken(
raw: string,
opts: VerifyDeviceEnrollOpts = {},
): Promise<{ accountId: string }> {
const aud = opts.aud ?? DEVICE_ENROLL_AUD
const now = opts.now ?? Math.floor(Date.now() / 1000)
let token: CapabilityToken
try {
token = await verifyCapabilityToken(raw, aud, now)
} catch {
throw new DeviceEnrollAuthError(401, 'device enroll token rejected')
}
requireEnrollRight(token)
return { accountId: token.sub }
}
/**
* LOGIN STUB SEAM (single-tenant MVP). Resolves an authenticated credential to an `accountId`.
* Deny-by-default: an empty credential, an unresolved credential, or an unconfigured seam all reject.
* A full end-user auth layer (RFC 8628 device grant / OIDC) replaces the body here without touching
* callers. Two supported bindings:
* - `resolve(credential) → accountId | null` — an injectable resolver (the real login layer);
* - `{ operatorCredential, accountId }` — the single operator credential of the MVP fleet.
*/
export interface LoginSeamConfig {
readonly resolve?: (credential: string) => string | null
readonly operatorCredential?: string
readonly accountId?: string
}
export function loginToAccountId(credential: string, config: LoginSeamConfig): string {
if (credential.length === 0) throw new DeviceEnrollAuthError(401, 'login rejected')
if (config.resolve !== undefined) {
const acct = config.resolve(credential)
if (acct === null || acct.length === 0) throw new DeviceEnrollAuthError(401, 'login rejected')
return acct
}
if (config.operatorCredential !== undefined && config.accountId !== undefined) {
const a = new TextEncoder().encode(credential)
const b = new TextEncoder().encode(config.operatorCredential)
if (timingSafeEqualBytes(a, b)) return config.accountId
throw new DeviceEnrollAuthError(401, 'login rejected')
}
throw new DeviceEnrollAuthError(401, 'login not configured')
}