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

@@ -5,8 +5,14 @@
* the key ref is unresolvable OR its policy does not restrict `sign` to the control-plane
* service principal. Both T8 `signHostLeaf` and T15 `renewHostLeaf` call this shared primitive —
* neither loads a raw key.
*
* Two in-process dev/test signers implement the SAME `CaSigner` surface (never a KMS — production
* mandates a real non-exportable key): `inProcessCaSigner` (Ed25519, the relay agent-CA path) and
* `inProcessP256CaSigner` (ECDSA-with-SHA256, the native-tunnel `frp-client-CA` / `device-CA` P-256
* path). The P-256 signer returns a raw P1363 `r||s` signature — the shape hardware emits — which
* `x509-assembler` normalizes to DER; both expose the same `sign()` so callers stay KMS-shaped.
*/
import { createPublicKey } from 'node:crypto'
import { createPublicKey, generateKeyPairSync, sign as nodeSign } from 'node:crypto'
import type { ControlPlaneEnv } from '../env.js'
import { ed25519Sign, generateEd25519, type KeyObject } from '../util/crypto.js'
@@ -73,3 +79,22 @@ function derivePublicRaw(privateKey: KeyObject): Uint8Array {
const spki = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }) as Buffer
return new Uint8Array(spki.subarray(spki.length - 32))
}
/**
* In-process P-256 (ECDSA-with-SHA256) CA signer — TEST/DEV ONLY, same disclaimer as the Ed25519
* variant. This is the dev stand-in for the native-tunnel `frp-client-CA` / `device-CA` (both P-256).
* `sign()` returns a RAW P1363 `r||s` (64-byte) signature over the TBS — the same shape hardware
* (Secure Enclave / StrongBox / WebCrypto) emits — which `x509-assembler` normalizes to DER.
* `publicKeyRaw` carries the CA's SubjectPublicKeyInfo DER (not a bare point) so tests can import it
* directly as a verifying key.
*/
export function inProcessP256CaSigner(privateKey?: KeyObject): CaSigner {
const key = privateKey ?? generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey
const spkiDer = createPublicKey(key).export({ format: 'der', type: 'spki' }) as Buffer
return {
publicKeyRaw: new Uint8Array(spkiDer),
async sign(tbsCert) {
return new Uint8Array(nodeSign('sha256', Buffer.from(tbsCert), { key, dsaEncoding: 'ieee-p1363' }))
},
}
}

View File

@@ -0,0 +1,155 @@
/**
* Native-tunnel CA boot wiring. Builds the TWO P-256 CAs of the native-tunnel PKI — `frp-client-CA`
* (host frp-client leaves) and `device-CA` (device data-path leaves) — as
* `{ signer, caCertDer, anchorsDer, issuerName }`. Both are SEPARATE trust roots from the Ed25519
* relay agent-CA (§1.3); each is P-256 (the VPS `gen-device-ca.sh` "P-256 never Ed25519" rule).
*
* DEV/TEST path (default): generate a self-signed P-256 CA whose key is `inProcessP256CaSigner`, so
* every issued leaf chains to a REAL, re-parseable CA (mirrors `rotate.test.ts` `makeP256Ca`). This
* is NOT a KMS — the plan mandates a real non-exportable key in production (§3.1).
*
* PROD path: resolve each CA's KMS-non-exportable P-256 signer via `buildCaSigner` (reusing its INV9
* key-policy fail-fast) with a PER-CA KMS key ref, plus that CA's (public) cert DER loaded from
* configured `material`. FAIL-FAST (INV9) if the native-CA material is unresolvable in production —
* never silently fall back to an in-process dev CA. The env does not yet carry per-CA key refs /
* native-CA cert paths, so production wiring supplies them via `material`; the documented follow-up
* is to add `NATIVE_FRP_CLIENT_CA_*` / `NATIVE_DEVICE_CA_*` (KMS ref + cert path) to `env.ts` and map
* them here.
*
* `reflect-metadata` must load before `@peculiar/x509` (tsyringe polyfill) — keep the side-effect first.
*/
import 'reflect-metadata'
import * as x509 from '@peculiar/x509'
import { webcrypto, generateKeyPairSync } from 'node:crypto'
import type { ControlPlaneEnv } from '../env.js'
import { assembleCertificate } from '../ca/x509-assembler.js'
import { buildCaSigner, inProcessP256CaSigner, type CaSigner, type KmsResolver } from './ca-wiring.js'
x509.cryptoProvider.set(webcrypto)
/** DNS zone the reachable subdomain lives under (leftmost label = the nginx :8470 enforcement key). */
export const DEFAULT_NATIVE_DNS_ZONE = 'terminal.yaojia.wang'
/** Dev CA validity: 1y forward, backdated 1d for clock skew (dev-only self-signed anchor). */
const DEV_CA_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000
const DEV_CA_BACKDATE_MS = 24 * 60 * 60 * 1000
/** One native CA: its KMS-shaped signer, its (public) CA cert DER, the anchor set, and issuer DN. */
export interface NativeCa {
readonly signer: CaSigner
/** DER of the CA's own (self-signed in dev) certificate. */
readonly caCertDer: Uint8Array
/** Trust-anchor DER set the renew route chain-validates presented certs against (non-empty). */
readonly anchorsDer: readonly Uint8Array[]
/** The CA subject DN — used verbatim as the leaf issuer so `checkIssued` matches. */
readonly issuerName: x509.Name
}
/** The two native-tunnel CAs. */
export interface NativeCas {
readonly frpClientCa: NativeCa
readonly deviceCa: NativeCa
}
/** Production material for one CA: its KMS key ref + its (public) CA cert DER. */
export interface NativeCaMaterialItem {
readonly kmsKeyRef: string
readonly caCertDer: Uint8Array
}
/** Production material for BOTH native CAs (supplied by the deployment, not generated). */
export interface NativeCaMaterial {
readonly frpClientCa: NativeCaMaterialItem
readonly deviceCa: NativeCaMaterialItem
}
export interface BuildNativeCasOpts {
/** Production mode → fail-fast unless real KMS-backed material is supplied (INV9). */
readonly production?: boolean
/** KMS resolver (per-CA key ref → non-exportable P-256 signer). Required in production. */
readonly kmsResolver?: KmsResolver
/** Production-loaded per-CA material (KMS ref + public cert DER). Required in production. */
readonly material?: NativeCaMaterial
/** Clock (ms) — injectable for tests. */
readonly now?: () => number
}
/** Parse a CA cert DER into an `x509.Name` issuer, failing loud on unparseable material (INV9). */
function issuerNameOf(caCertDer: Uint8Array): x509.Name {
try {
return new x509.X509Certificate(caCertDer).subjectName
} catch {
throw new Error('native-CA certificate material is not a parseable X.509 certificate — refusing to boot')
}
}
/**
* DEV/TEST: a self-signed P-256 CA (subject key == signer key) so issued leaves chain to a real CA.
* BasicConstraints CA:true + KeyUsage keyCertSign|cRLSign, exactly like the test fixtures.
*/
async function buildDevNativeCa(cn: string, nowMs: number): Promise<NativeCa> {
const caKeys = generateKeyPairSync('ec', { namedCurve: 'P-256' })
const caSpki = new Uint8Array(caKeys.publicKey.export({ format: 'der', type: 'spki' }))
const signer = inProcessP256CaSigner(caKeys.privateKey)
const caCertDer = await assembleCertificate({
subjectPublicKey: caSpki,
subject: `CN=${cn}`,
issuer: `CN=${cn}`,
serialNumber: Uint8Array.from([0x01]),
notBefore: new Date(nowMs - DEV_CA_BACKDATE_MS),
notAfter: new Date(nowMs + DEV_CA_VALIDITY_MS),
extensions: [
new x509.BasicConstraintsExtension(true, undefined, true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true),
],
signer,
sigAlg: 'ecdsa-p256',
})
return { signer, caCertDer, anchorsDer: [caCertDer], issuerName: issuerNameOf(caCertDer) }
}
/**
* PROD: resolve a KMS-non-exportable P-256 signer for this CA's key ref (reusing `buildCaSigner`'s
* INV9 policy fail-fast) and pair it with the supplied (public) CA cert DER as the trust anchor.
*/
async function buildProdNativeCa(
item: NativeCaMaterialItem,
env: ControlPlaneEnv,
kmsResolver: KmsResolver,
): Promise<NativeCa> {
// Reuse the intermediate-key policy check by targeting this CA's key ref (INV9, §3.1).
const signer = await buildCaSigner({ ...env, caIntermediateKmsKeyRef: item.kmsKeyRef }, kmsResolver)
return {
signer,
caCertDer: item.caCertDer,
anchorsDer: [item.caCertDer],
issuerName: issuerNameOf(item.caCertDer),
}
}
/**
* Build both native-tunnel CAs. DEV default → self-signed in-process P-256 CAs. PRODUCTION → resolve
* KMS-backed signers + loaded CA certs from `material`; FAIL-FAST if either is missing (INV9 — never
* boot a public-shell PKI on ephemeral, unattested in-process keys).
*/
export async function buildNativeCas(env: ControlPlaneEnv, opts: BuildNativeCasOpts = {}): Promise<NativeCas> {
const production = opts.production ?? false
if (production) {
if (opts.material === undefined || opts.kmsResolver === undefined) {
throw new Error(
'native-tunnel CA material is unresolvable in production (per-CA KMS key refs + CA certs required) — refusing to boot',
)
}
const [frpClientCa, deviceCa] = await Promise.all([
buildProdNativeCa(opts.material.frpClientCa, env, opts.kmsResolver),
buildProdNativeCa(opts.material.deviceCa, env, opts.kmsResolver),
])
return { frpClientCa, deviceCa }
}
const nowMs = opts.now?.() ?? Date.now()
const [frpClientCa, deviceCa] = await Promise.all([
buildDevNativeCa('frp-client-CA', nowMs),
buildDevNativeCa('device-CA', nowMs),
])
return { frpClientCa, deviceCa }
}