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:
@@ -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' }))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
155
control-plane/src/boot/native-ca.ts
Normal file
155
control-plane/src/boot/native-ca.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user