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:
255
control-plane/test/frpclient-issue.test.ts
Normal file
255
control-plane/test/frpclient-issue.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* B1 acceptance (FIX H-host-2, H-host-4) — the P-256 HOST frp-client leaf signer. Proves an issued
|
||||
* leaf is a REAL, tool-parseable, signature-verifiable X.509 v3 cert off the P-256 frp-client-CA,
|
||||
* carrying the dNSName ENFORCEMENT key + the host SPIFFE-ID (which relay-auth's parser accepts),
|
||||
* EKU clientAuth + CA:false; that the registry gate rejects unregistered / revoked / pubkey-mismatch
|
||||
* UNIFORMLY without ever invoking the CA signer; and that a P-256 CSR built by the AGENT's own
|
||||
* `enroll/csr.ts` passes `verifyCsrPoPEc` and flows its embedded pubkey through to issuance.
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { AsnConvert } from '@peculiar/asn1-schema'
|
||||
import { Certificate } from '@peculiar/asn1-x509'
|
||||
import { webcrypto, generateKeyPairSync, randomUUID } from 'node:crypto'
|
||||
import { parseSpiffeId, spiffeIdFor } from 'relay-auth/src/agent/spiffe.js'
|
||||
import { createMemoryStores } from '../src/store/memory.js'
|
||||
import { createHostRegistry } from '../src/registry/hosts.js'
|
||||
import { fingerprint } from '../src/ca/fingerprint.js'
|
||||
import { assembleCertificate } from '../src/ca/x509-assembler.js'
|
||||
import { buildCsrEc, verifyCsrPoPEc } from '../src/ca/csr-ec.js'
|
||||
import { inProcessP256CaSigner, type CaSigner } from '../src/boot/ca-wiring.js'
|
||||
import { createFrpClientLeafSigner } from '../src/ca/frpclient-issue.js'
|
||||
import { LeafSignError } from '../src/ca/sign.js'
|
||||
// Cross-track proof (vi): drive the ACTUAL agent-side P-256 identity + CSR encoder.
|
||||
import { generateP256Identity } from '../../agent/src/keys/identity.js'
|
||||
import { buildCsr as buildAgentCsr } from '../../agent/src/enroll/csr.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const TRUST_DOMAIN = 'terminal.yaojia.wang'
|
||||
const DNS_ZONE = 'terminal.yaojia.wang'
|
||||
|
||||
interface FrpClientCa {
|
||||
readonly caSigner: CaSigner
|
||||
readonly caCert: x509.X509Certificate
|
||||
readonly caDer: Uint8Array
|
||||
}
|
||||
|
||||
/** Self-signed P-256 `frp-client-CA` (subject key == signer key) so leaves chain to a real CA. */
|
||||
async function makeFrpClientCa(): Promise<FrpClientCa> {
|
||||
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=frp-client-CA',
|
||||
issuer: 'CN=frp-client-CA',
|
||||
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))
|
||||
}
|
||||
|
||||
/** A memory-store host registry with a P-256 host bound under `subdomain`, plus its CSR + SPKI. */
|
||||
async function boundHost(subdomain = 'alice') {
|
||||
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) })
|
||||
return { stores, hosts, host, csr, spki, accountId }
|
||||
}
|
||||
|
||||
function makeSigner(ca: FrpClientCa, stores: ReturnType<typeof createMemoryStores>) {
|
||||
return createFrpClientLeafSigner({
|
||||
hosts: stores.hosts,
|
||||
signer: ca.caSigner,
|
||||
issuerName: ca.caCert.subjectName,
|
||||
caChainDer: [ca.caDer],
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
dnsZone: DNS_ZONE,
|
||||
})
|
||||
}
|
||||
|
||||
/** Assert a promise rejects with a `LeafSignError` and return it so the caller can check `.code`. */
|
||||
async function expectLeafSignError(p: Promise<unknown>): Promise<LeafSignError> {
|
||||
const err = await p.then(
|
||||
() => {
|
||||
throw new Error('expected the promise to reject with LeafSignError')
|
||||
},
|
||||
(e: unknown) => e,
|
||||
)
|
||||
expect(err).toBeInstanceOf(LeafSignError)
|
||||
return err as LeafSignError
|
||||
}
|
||||
|
||||
describe('frpclient-issue — (i) issued leaf re-parses as X.509 & (ii) chains to the frp-client-CA', () => {
|
||||
test('leaf re-parses via new x509.X509Certificate and its signature verifies against the CA pubkey', async () => {
|
||||
const ca = await makeFrpClientCa()
|
||||
const { stores, host, csr, spki } = await boundHost()
|
||||
const { cert, caChain } = await makeSigner(ca, stores).signHostLeaf(host.hostId, spki, csr)
|
||||
|
||||
expect(() => new x509.X509Certificate(cert)).not.toThrow()
|
||||
const leaf = new x509.X509Certificate(cert)
|
||||
expect(leaf.issuer).toBe(ca.caCert.subject) // issuer DN == CA subject DN (chain sanity)
|
||||
const caPub = await importP256Public(ca.caSigner.publicKeyRaw)
|
||||
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
|
||||
// negative: does NOT verify under a different CA key
|
||||
const otherCa = await importP256Public(inProcessP256CaSigner().publicKeyRaw)
|
||||
expect(await leaf.verify({ publicKey: otherCa, signatureOnly: true })).toBe(false)
|
||||
expect(caChain).toEqual([ca.caDer])
|
||||
})
|
||||
|
||||
test('subject key is the enrolled host P-256 key (SPKI byte-equal)', async () => {
|
||||
const ca = await makeFrpClientCa()
|
||||
const { stores, host, csr, spki } = await boundHost()
|
||||
const { cert } = await makeSigner(ca, stores).signHostLeaf(host.hostId, spki, csr)
|
||||
const leaf = new x509.X509Certificate(cert)
|
||||
expect(Buffer.from(leaf.publicKey.rawData).equals(Buffer.from(spki))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('frpclient-issue — (iii) SAN carries dNSName + host SPIFFE URI', () => {
|
||||
test("SAN = { dNSName '<sub>.terminal.yaojia.wang', URI host-SPIFFE } and parseSpiffeId accepts it as kind host", async () => {
|
||||
const ca = await makeFrpClientCa()
|
||||
const { stores, host, csr, spki, accountId } = await boundHost('alice')
|
||||
const { cert } = await makeSigner(ca, stores).signHostLeaf(host.hostId, spki, csr)
|
||||
const leaf = new x509.X509Certificate(cert)
|
||||
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(uri).toBe(spiffeIdFor(accountId, 'alice', TRUST_DOMAIN, 'host'))
|
||||
// relay-auth's OWN parser accepts the URI as a host identity (never drifts from the verifier).
|
||||
expect(parseSpiffeId(uri)).toEqual({ accountId, id: 'alice', kind: 'host' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('frpclient-issue — (iv) EKU clientAuth + CA:false + KeyUsage digitalSignature', () => {
|
||||
test('leaf is a client-auth end-entity cert', async () => {
|
||||
const ca = await makeFrpClientCa()
|
||||
const { stores, host, csr, spki } = await boundHost()
|
||||
const { cert } = await makeSigner(ca, stores).signHostLeaf(host.hostId, spki, csr)
|
||||
const leaf = new x509.X509Certificate(cert)
|
||||
|
||||
const bc = leaf.getExtension(x509.BasicConstraintsExtension)
|
||||
expect(bc!.ca).toBe(false)
|
||||
const eku = leaf.getExtension(x509.ExtendedKeyUsageExtension)
|
||||
expect(eku!.usages).toContain(x509.ExtendedKeyUsage.clientAuth)
|
||||
const ku = leaf.getExtension(x509.KeyUsagesExtension)
|
||||
expect(ku!.usages & x509.KeyUsageFlags.digitalSignature).not.toBe(0)
|
||||
// signatureAlgorithm is ecdsa-with-SHA256 (a P-256 leaf, not Ed25519).
|
||||
const parsed = AsnConvert.parse(cert.buffer.slice(cert.byteOffset, cert.byteOffset + cert.byteLength) as ArrayBuffer, Certificate)
|
||||
expect(parsed.signatureAlgorithm.algorithm).toBe('1.2.840.10045.4.3.2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('frpclient-issue — (v) registry gate rejects uniformly, CA signer never invoked', () => {
|
||||
test('unregistered host → LeafSignError(not_registered), sign() never called', async () => {
|
||||
const ca = await makeFrpClientCa()
|
||||
const signSpy = vi.spyOn(ca.caSigner, 'sign')
|
||||
const { stores, csr, spki } = await boundHost()
|
||||
const err = await expectLeafSignError(makeSigner(ca, stores).signHostLeaf(randomUUID(), spki, csr))
|
||||
expect(err.code).toBe('not_registered')
|
||||
expect(signSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('revoked host → LeafSignError(not_registered), sign() never called', async () => {
|
||||
const ca = await makeFrpClientCa()
|
||||
const signSpy = vi.spyOn(ca.caSigner, 'sign')
|
||||
const { stores, hosts, host, csr, spki } = await boundHost()
|
||||
await hosts.setHostStatus(host.hostId, 'revoked')
|
||||
const err = await expectLeafSignError(makeSigner(ca, stores).signHostLeaf(host.hostId, spki, csr))
|
||||
expect(err.code).toBe('not_registered')
|
||||
expect(signSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('registry pubkey mismatch (present a different key than registered) → not_registered', async () => {
|
||||
const ca = await makeFrpClientCa()
|
||||
const signSpy = vi.spyOn(ca.caSigner, 'sign')
|
||||
const { stores, host } = await boundHost() // host registered under key A
|
||||
// A DIFFERENT P-256 key B — CSR PoP + substitution checks pass (embedded == presented), but the
|
||||
// registry stored key A, so step 3 rejects.
|
||||
const { der: csrB, keys: keysB } = await buildCsrEc('CN=alice')
|
||||
const spkiB = await spkiOf(keysB.publicKey)
|
||||
const err = await expectLeafSignError(makeSigner(ca, stores).signHostLeaf(host.hostId, spkiB, csrB))
|
||||
expect(err.code).toBe('not_registered')
|
||||
expect(signSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('bad CSR proof-of-possession → LeafSignError(csr_rejected), sign() never called', async () => {
|
||||
const ca = await makeFrpClientCa()
|
||||
const signSpy = vi.spyOn(ca.caSigner, 'sign')
|
||||
const { stores, host, csr, spki } = await boundHost()
|
||||
const forged = Uint8Array.from(csr)
|
||||
forged[forged.length - 1] = forged[forged.length - 1]! ^ 0xff // corrupt the signature
|
||||
const err = await expectLeafSignError(makeSigner(ca, stores).signHostLeaf(host.hostId, spki, forged))
|
||||
expect(err.code).toBe('csr_rejected')
|
||||
expect(signSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('all rejects share the SAME uniform message (never leaks which check failed)', async () => {
|
||||
const ca = await makeFrpClientCa()
|
||||
const { stores, hosts, host, csr, spki } = await boundHost()
|
||||
const signer = makeSigner(ca, stores)
|
||||
const unregistered = await expectLeafSignError(signer.signHostLeaf(randomUUID(), spki, csr))
|
||||
await hosts.setHostStatus(host.hostId, 'revoked')
|
||||
const revoked = await expectLeafSignError(signer.signHostLeaf(host.hostId, spki, csr))
|
||||
expect(unregistered.message).toBe(revoked.message)
|
||||
expect(unregistered.message).toBe('leaf signing refused')
|
||||
})
|
||||
})
|
||||
|
||||
describe('frpclient-issue — (vi) an AGENT-built P-256 CSR passes verifyCsrPoPEc & issues', () => {
|
||||
test('agent enroll/csr.ts CSR: verifyCsrPoPEc ok + embedded pubkey flows into the leaf', async () => {
|
||||
// Drive the REAL agent-side encoder (not the control-plane test helper).
|
||||
const id = generateP256Identity()
|
||||
const agentCsrPem = buildAgentCsr(id, 'alice.terminal.yaojia.wang')
|
||||
const agentCsrDer = new Uint8Array(
|
||||
Buffer.from(agentCsrPem.replace(/-----[A-Z ]+-----/g, '').replace(/\s+/g, ''), 'base64'),
|
||||
)
|
||||
|
||||
// (a) the control-plane P-256 PoP verifier accepts the agent's hand-rolled CSR.
|
||||
const pop = await verifyCsrPoPEc(agentCsrDer)
|
||||
expect(pop.ok).toBe(true)
|
||||
// its embedded pubkey is exactly the agent identity's EC SPKI.
|
||||
expect(Buffer.from(pop.embeddedPubSpki).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
|
||||
// (b) that same pubkey flows through issuance: register it, sign, and the leaf carries it.
|
||||
const ca = await makeFrpClientCa()
|
||||
const stores = createMemoryStores()
|
||||
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||
const accountId = randomUUID()
|
||||
const host = await hosts.bindHost({
|
||||
accountId,
|
||||
subdomain: 'alice',
|
||||
agentPubkey: id.publicKey,
|
||||
enrollFpr: fingerprint(id.publicKey),
|
||||
})
|
||||
const { cert } = await makeSigner(ca, stores).signHostLeaf(host.hostId, id.publicKey, agentCsrDer)
|
||||
const leaf = new x509.X509Certificate(cert)
|
||||
expect(Buffer.from(leaf.publicKey.rawData).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
const caPub = await importP256Public(ca.caSigner.publicKeyRaw)
|
||||
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user