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,372 @@
/**
* Native-tunnel wiring E2E (fastify inject against the WIRED control-plane app from `main.ts`).
*
* Proves the full DEVICE path through the real composition root:
* mint a device:enroll token (auth/session, verified by the REAL §4.3 verifier the boot wires) →
* POST /device/enroll {csr:<real P-256 CSR>, subdomain:<owned>} → 201 with a cert that RE-PARSES,
* CHAINS to the wired dev device-CA, and carries dNSName <sub>.terminal.yaojia.wang → present that
* cert to POST /device/:id/renew → 201 fresh cert for the SAME subdomain.
* Plus the A4 ownership gate end-to-end: enroll for an UNOWNED (and a foreign-owned) subdomain → 403.
*
* Also proves the HOST /renew path through the wired app: the initial frp-client leaf is minted via
* the WIRED host signer (native host HTTP /enroll is a documented follow-up — the existing pairing
* `/enroll` relay route is left untouched), then presented to /renew → 201 for the SAME subdomain.
*
* The verifier is the SAME seam the admin API uses (`boot/verifier.ts`), keyed off the boot env's
* capability pubkey — so the enroll token is minted with the matching private key and travels the
* exact verify path production uses.
*/
import 'reflect-metadata'
import { describe, test, expect, beforeEach } from 'vitest'
import * as x509 from '@peculiar/x509'
import { webcrypto } from 'node:crypto'
import type { FastifyInstance } from 'fastify'
import { generateEd25519KeyPair, exportEd25519PublicRaw } from 'relay-auth/src/crypto/ed25519.js'
import { createMemoryStores } from '../src/store/memory.js'
import { createHostRegistry } from '../src/registry/hosts.js'
import { createPairingIssuer } from '../src/pairing/issue.js'
import { fingerprint } from '../src/ca/fingerprint.js'
import { generateEd25519 } from '../src/util/crypto.js'
import { buildCsr } from '../src/ca/csr.js'
import { buildCsrEc } from '../src/ca/csr-ec.js'
import { buildControlPlane, type BuiltControlPlane } from '../src/main.js'
import { createCapabilityVerifier } from '../src/boot/verifier.js'
import { mintDeviceEnrollToken } from '../src/auth/session.js'
import { loadEnv, type ControlPlaneEnv } from '../src/env.js'
import { bytesToBase64 } from '../src/util/bytes.js'
import type { Stores } from '../src/store/ports.js'
x509.cryptoProvider.set(webcrypto)
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
const ACCOUNT_B = '22222222-2222-4222-8222-222222222222'
const DNS_ZONE = 'terminal.yaojia.wang'
let signingKey: CryptoKey
let env: ControlPlaneEnv
beforeEach(async () => {
// Ed25519 capability keypair: the raw public key becomes the boot env's verify key, so the
// device:enroll token minted with the private key verifies through the wired real §4.3 verifier.
const pair = await generateEd25519KeyPair()
signingKey = pair.privateKey
const rawPub = await exportEd25519PublicRaw(pair.publicKey)
env = loadEnv({
PG_URL: 'postgres://u:p@localhost:5432/cp',
REDIS_URL: 'redis://localhost:6379',
CAPABILITY_SIGN_PUBKEY_B64: bytesToBase64(rawPub),
CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate',
CA_INTERMEDIATE_CERT_PATH: '/etc/cp/int.pem',
NODE_MTLS_TRUST_BUNDLE_PATH: '/etc/cp/node-ca.pem',
RELAY_TRUST_DOMAIN: DNS_ZONE,
BASE_DOMAIN: 'term.example.com',
})
})
async function buildApp(): Promise<{ app: FastifyInstance; stores: Stores; built: BuiltControlPlane }> {
const stores = createMemoryStores()
const built = await buildControlPlane(env, { stores, verifier: createCapabilityVerifier() })
await built.app.ready()
return { app: built.app, stores, built }
}
/** Onboard a host so `accountId` OWNS `subdomain` in the ownership source of truth (registry). */
async function ownSubdomain(stores: Stores, accountId: string, subdomain: string): Promise<void> {
const hosts = createHostRegistry({ hosts: stores.hosts })
const { publicKeyRaw } = generateEd25519()
await hosts.bindHost({ accountId, subdomain, agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
}
/** Mint a single-use pairing code bound to `accountId` (the enroll gate reads accountId from the row). */
async function mintPairingCode(stores: Stores, accountId: string): Promise<string> {
const issuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: 3600 })
const { code } = await issuer.issuePairingCode(accountId)
return code
}
/** A fresh P-256 host identity: its real PKCS#10 CSR + the SPKI DER the agent presents as agentPubkey. */
async function ecHostIdentity(subject = 'CN=web-terminal-host'): Promise<{ csr: Uint8Array; spki: Uint8Array }> {
const { der: csr, keys } = await buildCsrEc(subject)
const spki = new Uint8Array(await webcrypto.subtle.exportKey('spki', keys.publicKey))
return { csr, spki }
}
/** Bind a host whose frp-client identity is a P-256 key (so the wired host signer can issue for it). */
async function bindP256Host(
stores: Stores,
accountId: string,
subdomain: string,
): Promise<{ hostId: string; csr: Uint8Array; spki: Uint8Array }> {
const hosts = createHostRegistry({ hosts: stores.hosts })
const { der: csr, keys } = await buildCsrEc(`CN=${subdomain}`)
const spki = new Uint8Array(await webcrypto.subtle.exportKey('spki', keys.publicKey))
const host = await hosts.bindHost({ accountId, subdomain, agentPubkey: spki, enrollFpr: fingerprint(spki) })
return { hostId: host.hostId, csr, spki }
}
function b64(bytes: Uint8Array): string {
return Buffer.from(bytes).toString('base64')
}
function sanNames(der: Uint8Array): ReturnType<x509.GeneralNames['toJSON']> {
const leaf = new x509.X509Certificate(der)
return leaf.getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON()
}
describe('native-tunnel wiring — DEVICE e2e (enroll → renew)', () => {
test('enroll an owned subdomain → 201 real leaf chaining to the dev device-CA → renew → 201 fresh cert, same subdomain', async () => {
const { app, stores, built } = await buildApp()
await ownSubdomain(stores, ACCOUNT_A, 'alice')
const token = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey })
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
// ── ENROLL ───────────────────────────────────────────────────────────────────────────────────
const enroll = await app.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: `Bearer ${token}` },
payload: { csr: b64(csr), keyAlg: 'ec-p256', subdomain: 'alice', deviceName: 'iphone' },
})
expect(enroll.statusCode).toBe(201)
const enrolled = JSON.parse(enroll.body)
expect(typeof enrolled.deviceId).toBe('string')
expect(enrolled.deviceId.length).toBeGreaterThan(0)
// the returned cert re-parses as a real X.509 leaf …
const leafDer = new Uint8Array(Buffer.from(enrolled.cert, 'base64'))
const leaf = new x509.X509Certificate(leafDer)
// … chains to the CA the app returned …
const caDer = new Uint8Array(Buffer.from(enrolled.caChain[0], 'base64'))
const caCert = new x509.X509Certificate(caDer)
expect(await leaf.verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
// … which IS the wired dev device-CA anchor …
expect(b64(caDer)).toBe(b64(built.nativeTunnel.nativeCas.deviceCa.caCertDer))
// … and carries the tenant-isolation dNSName SAN.
expect(sanNames(leafDer)).toContainEqual({ type: 'dns', value: `alice.${DNS_ZONE}` })
// ── RENEW (present the enroll-issued cert as the current mTLS client cert) ──────────────────────
const renew = await app.inject({
method: 'POST',
url: `/device/${enrolled.deviceId}/renew`,
headers: { 'x-client-cert': b64(leafDer) },
payload: { csr: b64(csr) }, // same-key CSR
})
expect(renew.statusCode).toBe(201)
const renewed = JSON.parse(renew.body)
const renewedDer = new Uint8Array(Buffer.from(renewed.cert, 'base64'))
expect(() => new x509.X509Certificate(renewedDer)).not.toThrow()
// same subdomain — the renew stamps the registry scope, never client input.
expect(sanNames(renewedDer)).toContainEqual({ type: 'dns', value: `alice.${DNS_ZONE}` })
// and the fresh cert still chains to the device-CA anchor.
expect(await new x509.X509Certificate(renewedDer).verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
})
test('enroll for an UNOWNED subdomain → 403, no cert issued (A4 ownership gate, end-to-end)', async () => {
const { app, stores } = await buildApp()
await ownSubdomain(stores, ACCOUNT_A, 'alice')
const token = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey })
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
const res = await app.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: `Bearer ${token}` },
payload: { csr: b64(csr), keyAlg: 'ec-p256', subdomain: 'charlie', deviceName: 'x' },
})
expect(res.statusCode).toBe(403)
expect(JSON.parse(res.body).cert).toBeUndefined()
})
test('account B enrolling account A\'s subdomain → 403 (cross-tenant, no cert)', async () => {
const { app, stores } = await buildApp()
await ownSubdomain(stores, ACCOUNT_A, 'alice')
const tokenB = await mintDeviceEnrollToken(ACCOUNT_B, { signingKey })
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
const res = await app.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: `Bearer ${tokenB}` },
payload: { csr: b64(csr), keyAlg: 'ec-p256', subdomain: 'alice', deviceName: 'x' },
})
expect(res.statusCode).toBe(403)
expect(JSON.parse(res.body).cert).toBeUndefined()
})
test('missing enroll token → 401 (deny-by-default through the wired verifier seam)', async () => {
const { app, stores } = await buildApp()
await ownSubdomain(stores, ACCOUNT_A, 'alice')
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
const res = await app.inject({
method: 'POST',
url: '/device/enroll',
payload: { csr: b64(csr), keyAlg: 'ec-p256', subdomain: 'alice', deviceName: 'x' },
})
expect(res.statusCode).toBe(401)
})
})
describe('native-tunnel wiring — HOST /renew e2e', () => {
test('initial frp-client leaf (wired host signer) → /renew → 201 fresh cert, SAME subdomain, chains to frp-client-CA', async () => {
const { app, stores, built } = await buildApp()
const { hostId, csr, spki } = await bindP256Host(stores, ACCOUNT_A, 'alice')
// Initial leaf, minted by the WIRED host signer (chains to the wired frp-client-CA anchor).
const { cert: currentCert } = await built.nativeTunnel.hostSigner.signHostLeaf(hostId, spki, csr)
const res = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': b64(currentCert) },
payload: { csr: b64(csr) },
})
expect(res.statusCode).toBe(201)
const renewedDer = new Uint8Array(Buffer.from(JSON.parse(res.body).cert, 'base64'))
expect(sanNames(renewedDer)).toContainEqual({ type: 'dns', value: `alice.${DNS_ZONE}` })
const caCert = new x509.X509Certificate(built.nativeTunnel.nativeCas.frpClientCa.caCertDer)
expect(await new x509.X509Certificate(renewedDer).verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
})
test('a current cert from an UNTRUSTED (different-instance) frp-client-CA is rejected by the wired anchor set → 401', async () => {
const { app } = await buildApp()
// A second, independent wired control-plane = a DIFFERENT (untrusted) frp-client-CA. Bind the same
// host+identity there and mint a leaf under ITS CA, then present it to the REAL app's /renew.
const rogueStores = createMemoryStores()
const rogue = await buildControlPlane(env, { stores: rogueStores, verifier: createCapabilityVerifier() })
const rogueBound = await bindP256Host(rogueStores, ACCOUNT_A, 'alice')
const { cert: rogueCert } = await rogue.nativeTunnel.hostSigner.signHostLeaf(
rogueBound.hostId,
rogueBound.spki,
rogueBound.csr,
)
const res = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': b64(rogueCert) },
payload: { csr: b64(rogueBound.csr) },
})
expect(res.statusCode).toBe(401)
})
})
describe('native-tunnel wiring — HOST /enroll HTTP arm (P-256 frp-client leaf)', () => {
test('full host onboard: mint code → POST /enroll (EC CSR) → 201 frp-client leaf (chains to frp-client-CA + dNSName SAN, hostContentSecret null) → /renew → 201 SAME subdomain', async () => {
const { app, stores, built } = await buildApp()
const code = await mintPairingCode(stores, ACCOUNT_A)
const { csr, spki } = await ecHostIdentity()
// ── ENROLL (native P-256 arm — routed by CSR key algorithm) ────────────────────────────────────
const enroll = await app.inject({
method: 'POST',
url: '/enroll',
payload: { code, machineId: 'MB-A1B2C3', agentPubkey: b64(spki), csr: b64(csr) },
})
expect(enroll.statusCode).toBe(201)
const body = JSON.parse(enroll.body)
expect(typeof body.hostId).toBe('string')
expect(body.hostId.length).toBeGreaterThan(0)
expect(typeof body.subdomain).toBe('string')
expect(body.subdomain.length).toBeGreaterThan(0)
// native tunnel has no E2E-relay content secret (L-host-hcs).
expect(body.hostContentSecret).toBeNull()
// the returned cert re-parses as a real X.509 leaf …
const leafDer = new Uint8Array(Buffer.from(body.cert, 'base64'))
const leaf = new x509.X509Certificate(leafDer)
// … chains to the wired frp-client-CA anchor …
const caCert = new x509.X509Certificate(built.nativeTunnel.nativeCas.frpClientCa.caCertDer)
expect(await leaf.verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
const caDer = new Uint8Array(Buffer.from(body.caChain[0], 'base64'))
expect(b64(caDer)).toBe(b64(built.nativeTunnel.nativeCas.frpClientCa.caCertDer))
// … and carries the AUTHORITATIVE (server-assigned) dNSName SAN <sub>.<zone>.
expect(sanNames(leafDer)).toContainEqual({ type: 'dns', value: `${body.subdomain}.${DNS_ZONE}` })
// the host binding persisted the SAME authoritative subdomain (not client input).
const host = await stores.hosts.get(body.hostId)
expect(host?.subdomain).toBe(body.subdomain)
expect(host?.accountId).toBe(ACCOUNT_A)
// ── RENEW (present the enroll-issued cert as the current mTLS client cert) ──────────────────────
const renew = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': b64(leafDer) },
payload: { csr: b64(csr) }, // same-key CSR
})
expect(renew.statusCode).toBe(201)
const renewedDer = new Uint8Array(Buffer.from(JSON.parse(renew.body).cert, 'base64'))
// the renew stamps the registry scope — SAME subdomain, never client input.
expect(sanNames(renewedDer)).toContainEqual({ type: 'dns', value: `${body.subdomain}.${DNS_ZONE}` })
expect(await new x509.X509Certificate(renewedDer).verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
})
test('anti-smuggling: a client body requesting a DIFFERENT subdomain does NOT change the issued SAN', async () => {
const { app, stores } = await buildApp()
const code = await mintPairingCode(stores, ACCOUNT_A)
const { csr, spki } = await ecHostIdentity()
const enroll = await app.inject({
method: 'POST',
url: '/enroll',
// the attacker tries to steer the SAN to a subdomain it was never assigned:
payload: { code, machineId: 'x', agentPubkey: b64(spki), csr: b64(csr), subdomain: 'victim' },
})
expect(enroll.statusCode).toBe(201)
const body = JSON.parse(enroll.body)
// the SAN is the SERVER-assigned subdomain, never the client-supplied 'victim'.
expect(body.subdomain).not.toBe('victim')
const leafDer = new Uint8Array(Buffer.from(body.cert, 'base64'))
expect(sanNames(leafDer)).toContainEqual({ type: 'dns', value: `${body.subdomain}.${DNS_ZONE}` })
expect(sanNames(leafDer)).not.toContainEqual({ type: 'dns', value: `victim.${DNS_ZONE}` })
// and the host registry bound the server-assigned subdomain, not 'victim'.
const host = await stores.hosts.get(body.hostId)
expect(host?.subdomain).toBe(body.subdomain)
expect(host?.subdomain).not.toBe('victim')
})
test('single-use: replaying an already-redeemed pairing code → NOT 201 (double-spend guard)', async () => {
const { app, stores } = await buildApp()
const code = await mintPairingCode(stores, ACCOUNT_A)
const first = await ecHostIdentity('CN=host-1')
const ok = await app.inject({
method: 'POST',
url: '/enroll',
payload: { code, machineId: 'a', agentPubkey: b64(first.spki), csr: b64(first.csr) },
})
expect(ok.statusCode).toBe(201)
const second = await ecHostIdentity('CN=host-2')
const replay = await app.inject({
method: 'POST',
url: '/enroll',
payload: { code, machineId: 'a', agentPubkey: b64(second.spki), csr: b64(second.csr) },
})
expect(replay.statusCode).not.toBe(201)
})
test('no-substitution: agentPubkey that does not match the CSR key → NOT 201', async () => {
const { app, stores } = await buildApp()
const code = await mintPairingCode(stores, ACCOUNT_A)
const { csr } = await ecHostIdentity('CN=host-real')
const other = await ecHostIdentity('CN=host-other')
const res = await app.inject({
method: 'POST',
url: '/enroll',
// present a DIFFERENT key than the CSR embeds — the gate's no-substitution check must reject.
payload: { code, machineId: 'a', agentPubkey: b64(other.spki), csr: b64(csr) },
})
expect(res.statusCode).not.toBe(201)
})
test('non-regression: an Ed25519 relay /enroll still issues the PEM relay leaf + wrapped secret (native arm does not hijack it)', async () => {
const { app, stores } = await buildApp()
const code = await mintPairingCode(stores, ACCOUNT_A)
const { publicKeyRaw, privateKey } = generateEd25519()
const res = await app.inject({
method: 'POST',
url: '/enroll',
payload: { code, agentPubkey: bytesToBase64(publicKeyRaw), csr: bytesToBase64(buildCsr(privateKey, publicKeyRaw)) },
})
expect(res.statusCode).toBe(201)
const body = JSON.parse(res.body)
expect(body.cert).toContain('BEGIN CERTIFICATE') // PEM relay leaf, unchanged
expect(typeof body.hostContentSecret).toBe('string') // base64url wrapped secret, NOT null
})
})