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:
@@ -1,49 +1,245 @@
|
||||
/**
|
||||
* A6 (FIX H-host-3) — leaf RENEWAL upgraded off the DEV JSON-blob placeholder to REAL X.509. Proves
|
||||
* `createLeafRenewer` re-issues a tool-parseable, CA-verifiable X.509 v3 leaf (host + device) that
|
||||
* carries the SAME subdomain the registry holds (no smuggling), enforces the SAME-KEY rule (a
|
||||
* different-key CSR is rejected), and refuses a revoked identity — all by DELEGATING to the P-256
|
||||
* issuers (`frpclient-issue` / `device-issue`) that route through `assembleCertificate`. The final
|
||||
* `buildCaSigner` block (startup KMS key-policy fail-fast, INV9) is unrelated to rotation but colocated.
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { webcrypto, generateKeyPairSync, randomUUID } from 'node:crypto'
|
||||
import { parseSpiffeId } from 'relay-auth/src/agent/spiffe.js'
|
||||
import { createMemoryStores } from '../src/store/memory.js'
|
||||
import { createHostRegistry } from '../src/registry/hosts.js'
|
||||
import { createDeviceRegistry, createMemoryDeviceStore } from '../src/registry/devices.js'
|
||||
import { fingerprint } from '../src/ca/fingerprint.js'
|
||||
import { assembleCertificate } from '../src/ca/x509-assembler.js'
|
||||
import { buildCsrEc } from '../src/ca/csr-ec.js'
|
||||
import { createFrpClientLeafSigner } from '../src/ca/frpclient-issue.js'
|
||||
import { createDeviceLeafSigner } from '../src/ca/device-issue.js'
|
||||
import { createLeafRenewer } from '../src/ca/rotate.js'
|
||||
import { LeafSignError } from '../src/ca/sign.js'
|
||||
import { buildCaSigner, inProcessCaSigner, type KmsResolver } from '../src/boot/ca-wiring.js'
|
||||
import { buildCsr } from '../src/ca/csr.js'
|
||||
import { fingerprint } from '../src/ca/fingerprint.js'
|
||||
import { generateEd25519 } from '../src/util/crypto.js'
|
||||
import { DeviceLeafSignError } from '../src/ca/device-issue.js'
|
||||
import {
|
||||
buildCaSigner,
|
||||
inProcessCaSigner,
|
||||
inProcessP256CaSigner,
|
||||
type CaSigner,
|
||||
type KmsResolver,
|
||||
} from '../src/boot/ca-wiring.js'
|
||||
import { loadEnv } from '../src/env.js'
|
||||
import { bytesToBase64 } from '../src/util/bytes.js'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
const ACCOUNT = '11111111-1111-4111-8111-111111111111'
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
async function boundHost() {
|
||||
const stores = createMemoryStores()
|
||||
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||
const { publicKeyRaw, privateKey } = generateEd25519()
|
||||
const host = await hosts.bindHost({ accountId: ACCOUNT, subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
|
||||
const renewer = createLeafRenewer({ hosts: stores.hosts, signer: inProcessCaSigner(), caChainDer: [] })
|
||||
return { stores, hosts, host, publicKeyRaw, privateKey, renewer }
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const TRUST_DOMAIN = 'terminal.yaojia.wang'
|
||||
const DNS_ZONE = 'terminal.yaojia.wang'
|
||||
const BASE_DOMAIN = 'terminal.yaojia.wang'
|
||||
|
||||
interface P256Ca {
|
||||
readonly caSigner: CaSigner
|
||||
readonly caCert: x509.X509Certificate
|
||||
readonly caDer: Uint8Array
|
||||
}
|
||||
|
||||
describe('T15 renewHostLeaf (INV14)', () => {
|
||||
test('active host + valid CSR → fresh short-TTL leaf (same subject pubkey)', async () => {
|
||||
const { host, publicKeyRaw, privateKey, renewer } = await boundHost()
|
||||
const { cert } = await renewer.renewHostLeaf(host.hostId, buildCsr(privateKey, publicKeyRaw))
|
||||
const certJson = JSON.parse(Buffer.from(cert).toString())
|
||||
const tbs = JSON.parse(Buffer.from(certJson.tbs, 'base64').toString())
|
||||
expect(tbs.subjectSpki).toBe(Buffer.from(publicKeyRaw).toString('base64'))
|
||||
expect(tbs.renewed).toBe(true)
|
||||
/** Self-signed P-256 CA (subject key == signer key) so leaves chain to a real CA. */
|
||||
async function makeP256Ca(cn: string): Promise<P256Ca> {
|
||||
const caKeys = generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
||||
const caSpki = new Uint8Array(caKeys.publicKey.export({ format: 'der', type: 'spki' }))
|
||||
const caSigner = inProcessP256CaSigner(caKeys.privateKey)
|
||||
const now = Date.now()
|
||||
const caDer = await assembleCertificate({
|
||||
subjectPublicKey: caSpki,
|
||||
subject: `CN=${cn}`,
|
||||
issuer: `CN=${cn}`,
|
||||
serialNumber: Uint8Array.from([0x01]),
|
||||
notBefore: new Date(now - DAY_MS),
|
||||
notAfter: new Date(now + 365 * DAY_MS),
|
||||
extensions: [
|
||||
new x509.BasicConstraintsExtension(true, undefined, true),
|
||||
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true),
|
||||
],
|
||||
signer: caSigner,
|
||||
sigAlg: 'ecdsa-p256',
|
||||
})
|
||||
return { caSigner, caCert: new x509.X509Certificate(caDer), caDer }
|
||||
}
|
||||
|
||||
async function importP256Public(spkiDer: Uint8Array): Promise<CryptoKey> {
|
||||
return webcrypto.subtle.importKey('spki', new Uint8Array(spkiDer), { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'])
|
||||
}
|
||||
|
||||
async function spkiOf(pub: CryptoKey): Promise<Uint8Array> {
|
||||
return new Uint8Array(await webcrypto.subtle.exportKey('spki', pub))
|
||||
}
|
||||
|
||||
// ── Host renewal setup ─────────────────────────────────────────────────────────────────────────────
|
||||
async function hostRenewFixture(subdomain = 'alice') {
|
||||
const ca = await makeP256Ca('frp-client-CA')
|
||||
const stores = createMemoryStores()
|
||||
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||
const { der: csr, keys } = await buildCsrEc(`CN=${subdomain}`)
|
||||
const spki = await spkiOf(keys.publicKey)
|
||||
const accountId = randomUUID()
|
||||
const host = await hosts.bindHost({ accountId, subdomain, agentPubkey: spki, enrollFpr: fingerprint(spki) })
|
||||
const hostSigner = createFrpClientLeafSigner({
|
||||
hosts: stores.hosts,
|
||||
signer: ca.caSigner,
|
||||
issuerName: ca.caCert.subjectName,
|
||||
caChainDer: [ca.caDer],
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
dnsZone: DNS_ZONE,
|
||||
})
|
||||
// Device signer + expiry renewer are required by the renewer shape but unused in host tests — a
|
||||
// throwaway pair sharing one store.
|
||||
const throwawayDeviceStore = createMemoryDeviceStore()
|
||||
const deviceSigner = createDeviceLeafSigner({
|
||||
signer: (await makeP256Ca('device-CA')).caSigner,
|
||||
issuer: 'CN=device-CA',
|
||||
caChainDer: [],
|
||||
sanBaseDomain: BASE_DOMAIN,
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
devices: throwawayDeviceStore,
|
||||
})
|
||||
const renewer = createLeafRenewer({
|
||||
hostSigner,
|
||||
deviceSigner,
|
||||
deviceExpiry: createDeviceRegistry({ devices: throwawayDeviceStore }),
|
||||
})
|
||||
return { ca, stores, hosts, host, csr, spki, keys, accountId, renewer }
|
||||
}
|
||||
|
||||
describe('A6 renewHostLeaf → REAL X.509 (FIX H-host-3)', () => {
|
||||
test('active host + same-key CSR → a re-parseable, CA-verifiable X.509 leaf carrying the SAME subdomain', async () => {
|
||||
const { ca, host, csr, accountId, renewer } = await hostRenewFixture('alice')
|
||||
const { cert, caChain, notAfter } = await renewer.renewHostLeaf(host.hostId, host.agentPubkey, csr)
|
||||
|
||||
// (1) it is a REAL X.509 cert — not a JSON blob — that re-parses.
|
||||
expect(() => new x509.X509Certificate(cert)).not.toThrow()
|
||||
const leaf = new x509.X509Certificate(cert)
|
||||
// (2) it chains to the frp-client-CA (signature verifies under the CA key).
|
||||
const caPub = await importP256Public(ca.caSigner.publicKeyRaw)
|
||||
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
|
||||
// (3) SAN carries the registry subdomain as the dNSName enforcement key + the host SPIFFE URI.
|
||||
const san = leaf.getExtension(x509.SubjectAlternativeNameExtension)
|
||||
const names = san!.names.toJSON()
|
||||
expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' })
|
||||
const uri = names.find((n) => n.type === 'url')!.value
|
||||
expect(parseSpiffeId(uri)).toEqual({ accountId, id: 'alice', kind: 'host' })
|
||||
// (4) renewal surfaces a real future expiry + the CA chain.
|
||||
expect(notAfter.getTime()).toBeGreaterThan(Date.now())
|
||||
expect(caChain).toEqual([ca.caDer])
|
||||
})
|
||||
|
||||
test('CSR proof-of-possession fails → reject even for an active host', async () => {
|
||||
const { host, publicKeyRaw, renewer } = await boundHost()
|
||||
const forged = new Uint8Array(96)
|
||||
forged.set(publicKeyRaw, 0)
|
||||
forged.set(randomBytes(64), 32) // garbage signature
|
||||
await expect(renewer.renewHostLeaf(host.hostId, forged)).rejects.toBeInstanceOf(LeafSignError)
|
||||
test('a CSR with a DIFFERENT key (key-swap attempt) → rejected (MVP same-key)', async () => {
|
||||
const { host, renewer } = await hostRenewFixture('alice')
|
||||
const { der: otherCsr } = await buildCsrEc('CN=alice') // fresh, different P-256 key
|
||||
await expect(renewer.renewHostLeaf(host.hostId, host.agentPubkey, otherCsr)).rejects.toBeInstanceOf(LeafSignError)
|
||||
})
|
||||
|
||||
test('revoked host cannot renew (INV12+INV14 loop)', async () => {
|
||||
const { hosts, host, publicKeyRaw, privateKey, renewer } = await boundHost()
|
||||
test('a revoked host cannot renew (INV12+INV14 loop)', async () => {
|
||||
const { hosts, host, csr, renewer } = await hostRenewFixture('alice')
|
||||
await hosts.setHostStatus(host.hostId, 'revoked')
|
||||
await expect(renewer.renewHostLeaf(host.hostId, buildCsr(privateKey, publicKeyRaw))).rejects.toBeInstanceOf(LeafSignError)
|
||||
await expect(renewer.renewHostLeaf(host.hostId, host.agentPubkey, csr)).rejects.toBeInstanceOf(LeafSignError)
|
||||
})
|
||||
|
||||
test('the re-issued subdomain comes from the REGISTRY, never the CSR subject (no smuggling)', async () => {
|
||||
const { host, keys, renewer } = await hostRenewFixture('alice')
|
||||
// A malicious renewal CSR over the SAME key but with a foreign subject label.
|
||||
const evilCsr = new Uint8Array(
|
||||
(
|
||||
await x509.Pkcs10CertificateRequestGenerator.create({
|
||||
name: 'CN=bob.terminal.yaojia.wang',
|
||||
keys,
|
||||
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
|
||||
})
|
||||
).rawData,
|
||||
)
|
||||
const { cert } = await renewer.renewHostLeaf(host.hostId, host.agentPubkey, evilCsr)
|
||||
const names = new x509.X509Certificate(cert).getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON()
|
||||
expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' }) // still alice
|
||||
expect(names).not.toContainEqual({ type: 'dns', value: 'bob.terminal.yaojia.wang' })
|
||||
})
|
||||
})
|
||||
|
||||
// ── Device renewal setup ────────────────────────────────────────────────────────────────────────────
|
||||
async function deviceRenewFixture(subdomainScope = 'alice', now?: () => number) {
|
||||
const ca = await makeP256Ca('device-CA')
|
||||
const store = createMemoryDeviceStore()
|
||||
const registry = createDeviceRegistry({ devices: store, ...(now ? { now } : {}) })
|
||||
const { der: csr, keys } = await buildCsrEc('CN=web-terminal-device')
|
||||
const spki = await spkiOf(keys.publicKey)
|
||||
const record = await registry.registerDevice({
|
||||
accountId: randomUUID(),
|
||||
subdomainScope,
|
||||
ecPubkeySpki: spki,
|
||||
attestationLevel: 'none',
|
||||
})
|
||||
const deviceSigner = createDeviceLeafSigner({
|
||||
signer: ca.caSigner,
|
||||
issuer: ca.caCert.subjectName,
|
||||
caChainDer: [ca.caDer],
|
||||
sanBaseDomain: BASE_DOMAIN,
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
devices: store,
|
||||
})
|
||||
// Host signer is required by the renewer shape but unused in device tests — a throwaway.
|
||||
const hostSigner = createFrpClientLeafSigner({
|
||||
hosts: createMemoryStores().hosts,
|
||||
signer: (await makeP256Ca('frp-client-CA')).caSigner,
|
||||
issuerName: 'CN=frp-client-CA',
|
||||
caChainDer: [],
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
dnsZone: DNS_ZONE,
|
||||
})
|
||||
const renewer = createLeafRenewer({ hostSigner, deviceSigner, deviceExpiry: registry })
|
||||
return { ca, store, registry, record, csr, spki, keys, renewer }
|
||||
}
|
||||
|
||||
describe('A6 renewDeviceLeaf → REAL X.509 (FIX H-host-3)', () => {
|
||||
test('active device + same-key CSR → a CA-verifiable X.509 leaf carrying the SAME subdomainScope', async () => {
|
||||
const { ca, record, csr, renewer } = await deviceRenewFixture('alice')
|
||||
const { cert, caChain, notAfter } = await renewer.renewDeviceLeaf(record.deviceId, csr)
|
||||
|
||||
const leaf = new x509.X509Certificate(cert)
|
||||
const caPub = await importP256Public(ca.caSigner.publicKeyRaw)
|
||||
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
|
||||
const names = leaf.getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON()
|
||||
expect(names).toContainEqual({ type: 'dns', value: 'alice.terminal.yaojia.wang' })
|
||||
const uri = names.find((n) => n.type === 'url')!.value
|
||||
expect(parseSpiffeId(uri)).toEqual({ accountId: record.accountId, id: record.deviceId, kind: 'device' })
|
||||
expect(notAfter.getTime()).toBeGreaterThan(Date.now())
|
||||
expect(caChain).toEqual([ca.caDer])
|
||||
})
|
||||
|
||||
test('a CSR with a DIFFERENT key → rejected (MVP same-key)', async () => {
|
||||
const { record, renewer } = await deviceRenewFixture('alice')
|
||||
const { der: otherCsr } = await buildCsrEc('CN=web-terminal-device')
|
||||
await expect(renewer.renewDeviceLeaf(record.deviceId, otherCsr)).rejects.toBeInstanceOf(DeviceLeafSignError)
|
||||
})
|
||||
|
||||
test('a revoked device cannot renew', async () => {
|
||||
const { registry, record, csr, renewer } = await deviceRenewFixture('alice')
|
||||
await registry.setDeviceStatus(record.deviceId, 'revoked')
|
||||
await expect(renewer.renewDeviceLeaf(record.deviceId, csr)).rejects.toBeInstanceOf(DeviceLeafSignError)
|
||||
})
|
||||
|
||||
test('renewing TWICE across an advanced clock EXTENDS validity — the 2nd notAfter is later than the 1st', async () => {
|
||||
// Regression for the H finding: the device signer stamps record.notAfter (set once at enroll), so
|
||||
// without a per-renewal bump both renewals returned a byte-identical expiry — eventually in the
|
||||
// PAST. Advance the registry clock a comfortable margin (X.509 notAfter is second-resolution) so
|
||||
// the increase is unambiguous in the DER, then assert the second expiry strictly exceeds the first.
|
||||
let clock = Date.now()
|
||||
const { record, csr, renewer } = await deviceRenewFixture('alice', () => clock)
|
||||
const first = await renewer.renewDeviceLeaf(record.deviceId, csr)
|
||||
clock += 2 * DAY_MS
|
||||
const second = await renewer.renewDeviceLeaf(record.deviceId, csr)
|
||||
expect(second.notAfter.getTime()).toBeGreaterThan(first.notAfter.getTime())
|
||||
// And the extension is REAL (roughly the clock advance), not a rounding artefact.
|
||||
expect(second.notAfter.getTime() - first.notAfter.getTime()).toBeGreaterThanOrEqual(DAY_MS)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user