feat(control-plane): production native-CA wiring for on-disk P-256 CAs (A2-prep)

Wire the prod boot to issue frp-client + device leaves from the existing on-disk
P-256 CAs: NATIVE_* env vars, a file-backed KmsResolver (loads the PEM key,
validates prime256v1, never logs key material), buildFileBackedNativeCas threaded
into buildControlPlane via a nativeCas override. Fail-closed on partial NATIVE_*.
281 tests pass.
This commit is contained in:
Yaojia Wang
2026-07-18 15:04:04 +02:00
parent 10688b0dd1
commit af630143de
8 changed files with 597 additions and 10 deletions

View File

@@ -0,0 +1,99 @@
/**
* A2-prep — file-backed KMS resolver unit tests. Proves `fileKmsResolver` loads an on-disk PKCS#8
* PEM P-256 CA key and exposes it as a `CaSigner` whose signature verifies against the key's public
* half (the single-owner file-custody reality of the VPS deploy), and fails loud on bad material
* (missing file, malformed PEM, wrong curve) — NEVER leaking key bytes (INV9).
*/
import { describe, test, expect, beforeEach, afterEach } from 'vitest'
import { generateKeyPairSync, createPublicKey, verify as nodeVerify, randomBytes } from 'node:crypto'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileKmsResolver, fileKeyRef } from '../src/boot/ca-wiring.js'
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'cp-cawiring-'))
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
/** Write a fresh P-256 PKCS#8 PEM key to disk; return its path + the matching public KeyObject. */
function writeP256Key(name: string): { path: string; publicKey: ReturnType<typeof createPublicKey> } {
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' })
const pem = privateKey.export({ format: 'pem', type: 'pkcs8' }) as string
const path = join(dir, name)
writeFileSync(path, pem)
return { path, publicKey }
}
describe('fileKmsResolver — P-256 on-disk key custody', () => {
test('resolves a file: ref, loads the P-256 key, and signs verifiably', async () => {
const { path, publicKey } = writeP256Key('frp.key.pem')
const resolver = fileKmsResolver()
const { signer, policyRestrictedToServicePrincipal } = await resolver.resolve(fileKeyRef(path))
expect(policyRestrictedToServicePrincipal).toBe(true)
const tbs = randomBytes(48)
const sig = await signer.sign(new Uint8Array(tbs))
// signer emits raw P1363 r||s (64 bytes) — the same shape hardware/`inProcessP256CaSigner` produce.
expect(sig.length).toBe(64)
const ok = nodeVerify('sha256', tbs, { key: publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(sig))
expect(ok).toBe(true)
// the signer's public key equals the on-disk key's SPKI DER.
const spki = new Uint8Array(publicKey.export({ format: 'der', type: 'spki' }) as Buffer)
expect(Buffer.from(signer.publicKeyRaw).equals(Buffer.from(spki))).toBe(true)
})
test('resolves a bare path (no file: prefix)', async () => {
const { path, publicKey } = writeP256Key('bare.key.pem')
const { signer } = await fileKmsResolver().resolve(path)
const tbs = randomBytes(32)
const sig = await signer.sign(new Uint8Array(tbs))
expect(nodeVerify('sha256', tbs, { key: publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(sig))).toBe(true)
})
test('missing file → rejects, never echoing the path contents', async () => {
await expect(fileKmsResolver().resolve(fileKeyRef(join(dir, 'nope.pem')))).rejects.toThrow(/unreadable/)
})
test('malformed PEM → rejects', async () => {
const path = join(dir, 'bad.pem')
writeFileSync(path, 'not a pem key')
await expect(fileKmsResolver().resolve(fileKeyRef(path))).rejects.toThrow(/not a valid PEM private key/)
})
test('non-P-256 key (Ed25519) → rejected (curve mismatch, fail-closed)', async () => {
const { privateKey } = generateKeyPairSync('ed25519')
const path = join(dir, 'ed.key.pem')
writeFileSync(path, privateKey.export({ format: 'pem', type: 'pkcs8' }) as string)
await expect(fileKmsResolver().resolve(fileKeyRef(path))).rejects.toThrow(/P-256/)
})
test('wrong-curve EC key (P-384) → rejected', async () => {
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-384' })
const path = join(dir, 'p384.key.pem')
writeFileSync(path, privateKey.export({ format: 'pem', type: 'pkcs8' }) as string)
await expect(fileKmsResolver().resolve(fileKeyRef(path))).rejects.toThrow(/P-256/)
})
test('empty file: ref path → rejected', async () => {
await expect(fileKmsResolver().resolve('file:')).rejects.toThrow(/empty path/)
})
test('never leaks key material in the error message (INV9)', async () => {
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-384' })
const pem = privateKey.export({ format: 'pem', type: 'pkcs8' }) as string
const path = join(dir, 'secret.key.pem')
writeFileSync(path, pem)
try {
await fileKmsResolver().resolve(fileKeyRef(path))
throw new Error('expected rejection')
} catch (e) {
expect(e instanceof Error ? e.message : '').not.toContain(pem.slice(40, 80))
}
})
})

View File

@@ -115,3 +115,61 @@ describe('B1 operator-login env (set-together-or-none, fail-closed)', () => {
}
})
})
describe('A2-prep native-tunnel CA env (set-together-or-none, fail-closed)', () => {
const FRP_CERT = '/etc/cp/frp-client-ca.cert.pem'
const DEV_CERT = '/etc/cp/device-ca.cert.pem'
const ZONE = 'native.example.test'
test('none set → nativeCa undefined (injection/dev path)', () => {
const env = loadEnv(base())
expect(env.nativeCa).toBeUndefined()
})
test('cert paths + dns zone set → parsed, key paths DERIVED from cert paths', () => {
const env = loadEnv({
...base(),
NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT,
NATIVE_DEVICE_CA_CERT_PATH: DEV_CERT,
NATIVE_DNS_ZONE: ZONE,
})
expect(env.nativeCa).toEqual({
frpClientCaCertPath: FRP_CERT,
frpClientCaKeyPath: '/etc/cp/frp-client-ca.key.pem',
deviceCaCertPath: DEV_CERT,
deviceCaKeyPath: '/etc/cp/device-ca.key.pem',
dnsZone: ZONE,
})
})
test('explicit key paths are used verbatim (not derived)', () => {
const env = loadEnv({
...base(),
NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT,
NATIVE_FRP_CLIENT_CA_KEY_PATH: '/keys/frp.pem',
NATIVE_DEVICE_CA_CERT_PATH: DEV_CERT,
NATIVE_DEVICE_CA_KEY_PATH: '/keys/dev.pem',
NATIVE_DNS_ZONE: ZONE,
})
expect(env.nativeCa?.frpClientCaKeyPath).toBe('/keys/frp.pem')
expect(env.nativeCa?.deviceCaKeyPath).toBe('/keys/dev.pem')
})
test('partial set — only frp cert path → fail-closed listing the missing keys', () => {
expect(() => loadEnv({ ...base(), NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT })).toThrow(
/NATIVE_DEVICE_CA_CERT_PATH|NATIVE_DNS_ZONE/,
)
})
test('partial set — cert paths without a dns zone → fail-closed', () => {
expect(() =>
loadEnv({ ...base(), NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT, NATIVE_DEVICE_CA_CERT_PATH: DEV_CERT }),
).toThrow(/NATIVE_DNS_ZONE/)
})
test('partial set — a key path supplied WITHOUT its cert path → fail-closed', () => {
expect(() => loadEnv({ ...base(), NATIVE_FRP_CLIENT_CA_KEY_PATH: '/keys/frp.pem' })).toThrow(
/NATIVE_FRP_CLIENT_CA_CERT_PATH|NATIVE_DEVICE_CA_CERT_PATH|NATIVE_DNS_ZONE/,
)
})
})

View File

@@ -0,0 +1,215 @@
/**
* A2-prep — PRODUCTION native-tunnel CA wiring from ON-DISK P-256 material. Generates throwaway
* P-256 CAs (key + cert PEM) in a tmp dir — standing in for the VPS `gen-device-ca.sh` output — then
* proves `buildFileBackedNativeCas` binds each CA's signer to its on-disk key and its anchor to its
* on-disk cert, that a leaf issued by the wired signer CHAINS to that on-disk cert, that a WIRED
* control-plane `/enroll` mints an frp-client leaf whose dNSName SAN = `<sub>.<zone>` from the real
* on-disk CA, and that partial/missing/malformed material FAILS CLOSED (INV9).
*/
import 'reflect-metadata'
import { describe, test, expect, beforeEach, afterEach } from 'vitest'
import * as x509 from '@peculiar/x509'
import { webcrypto, generateKeyPairSync } from 'node:crypto'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { buildNativeCas, buildFileBackedNativeCas } from '../src/boot/native-ca.js'
import { inProcessP256CaSigner } from '../src/boot/ca-wiring.js'
import { assembleCertificate } from '../src/ca/x509-assembler.js'
import { createFrpClientLeafSigner } from '../src/ca/frpclient-issue.js'
import { createHostRegistry } from '../src/registry/hosts.js'
import { createMemoryStores } from '../src/store/memory.js'
import { fingerprint } from '../src/ca/fingerprint.js'
import { buildCsrEc } from '../src/ca/csr-ec.js'
import { createPairingIssuer } from '../src/pairing/issue.js'
import { buildControlPlane } from '../src/main.js'
import { loadEnv, type ControlPlaneEnv, type NativeCaEnvConfig } 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 ZONE = 'native.example.test'
const DAY_MS = 24 * 60 * 60 * 1000
let dir: string
let env: ControlPlaneEnv
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'cp-nativeca-'))
env = loadEnv({
PG_URL: 'postgres://u:p@localhost:5432/cp',
REDIS_URL: 'redis://localhost:6379',
CAPABILITY_SIGN_PUBKEY_B64: bytesToBase64(new Uint8Array(32).fill(7)),
CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate',
CA_INTERMEDIATE_CERT_PATH: join(dir, 'int.cert.pem'),
NODE_MTLS_TRUST_BUNDLE_PATH: join(dir, 'node-ca.pem'),
RELAY_TRUST_DOMAIN: ZONE,
BASE_DOMAIN: 'term.example.com',
})
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
/** Write a self-signed throwaway P-256 CA (key + cert PEM) to disk — mirrors the VPS gen-device-ca output. */
async function writeThrowawayP256Ca(cn: string): Promise<{ keyPath: string; certPath: string; caDer: Uint8Array }> {
const caKeys = generateKeyPairSync('ec', { namedCurve: 'P-256' })
const keyPem = caKeys.privateKey.export({ format: 'pem', type: 'pkcs8' }) as string
const caSpki = new Uint8Array(caKeys.publicKey.export({ format: 'der', type: 'spki' }))
const signer = 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,
sigAlg: 'ecdsa-p256',
})
const certPem = new x509.X509Certificate(caDer).toString('pem')
const keyPath = join(dir, `${cn}.key.pem`)
const certPath = join(dir, `${cn}.cert.pem`)
writeFileSync(keyPath, keyPem)
writeFileSync(certPath, certPem)
return { keyPath, certPath, caDer }
}
async function writeNativeCaConfig(): Promise<{ config: NativeCaEnvConfig; frpCaDer: Uint8Array; deviceCaDer: Uint8Array }> {
const frp = await writeThrowawayP256Ca('frp-client-CA')
const dev = await writeThrowawayP256Ca('device-CA')
return {
config: {
frpClientCaCertPath: frp.certPath,
frpClientCaKeyPath: frp.keyPath,
deviceCaCertPath: dev.certPath,
deviceCaKeyPath: dev.keyPath,
dnsZone: ZONE,
},
frpCaDer: frp.caDer,
deviceCaDer: dev.caDer,
}
}
function b64(bytes: Uint8Array): string {
return Buffer.from(bytes).toString('base64')
}
function sanNames(der: Uint8Array): ReturnType<x509.GeneralNames['toJSON']> {
return new x509.X509Certificate(der).getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON()
}
describe('buildFileBackedNativeCas — anchors + signer from on-disk P-256 material', () => {
test('each CA anchor DER == its on-disk cert DER; issuer names parse', async () => {
const { config, frpCaDer, deviceCaDer } = await writeNativeCaConfig()
const cas = await buildFileBackedNativeCas(env, config)
expect(b64(cas.frpClientCa.caCertDer)).toBe(b64(frpCaDer))
expect(b64(cas.deviceCa.caCertDer)).toBe(b64(deviceCaDer))
expect(cas.frpClientCa.anchorsDer).toEqual([cas.frpClientCa.caCertDer])
expect(cas.deviceCa.anchorsDer).toEqual([cas.deviceCa.caCertDer])
expect(cas.frpClientCa.issuerName.toString()).toContain('frp-client-CA')
expect(cas.deviceCa.issuerName.toString()).toContain('device-CA')
})
test('a leaf minted by the wired frp-client signer CHAINS to the on-disk CA cert', async () => {
const { config, frpCaDer } = await writeNativeCaConfig()
const cas = await buildFileBackedNativeCas(env, config)
// Bind a P-256 host so the signer will issue for it.
const stores = createMemoryStores()
const hosts = createHostRegistry({ hosts: stores.hosts })
const { der: csr, keys } = await buildCsrEc('CN=alice')
const spki = new Uint8Array(await webcrypto.subtle.exportKey('spki', keys.publicKey))
const host = await hosts.bindHost({ accountId: ACCOUNT_A, subdomain: 'alice', agentPubkey: spki, enrollFpr: fingerprint(spki) })
const signer = createFrpClientLeafSigner({
hosts: stores.hosts,
signer: cas.frpClientCa.signer,
issuerName: cas.frpClientCa.issuerName,
caChainDer: cas.frpClientCa.anchorsDer,
trustDomain: ZONE,
dnsZone: ZONE,
})
const { cert } = await signer.signHostLeaf(host.hostId, spki, csr)
// The leaf verifies against the ON-DISK CA cert's public key (real chain) …
const onDiskCa = new x509.X509Certificate(frpCaDer)
expect(await new x509.X509Certificate(cert).verify({ publicKey: onDiskCa.publicKey, signatureOnly: true })).toBe(true)
// … and carries the enforcement dNSName SAN under the configured zone.
expect(sanNames(cert)).toContainEqual({ type: 'dns', value: `alice.${ZONE}` })
})
})
describe('WIRED control-plane /enroll from on-disk native CAs', () => {
async function ownedApp(): Promise<{ app: Awaited<ReturnType<typeof buildControlPlane>>['app']; stores: Stores; frpCaDer: Uint8Array }> {
const { config, frpCaDer } = await writeNativeCaConfig()
const nativeCas = await buildFileBackedNativeCas(env, config)
const stores = createMemoryStores()
const built = await buildControlPlane(env, { stores, production: true, nativeCas, nativeDnsZone: ZONE })
await built.app.ready()
return { app: built.app, stores, frpCaDer }
}
test('POST /enroll (native EC arm) → 201 frp-client leaf that chains to the on-disk CA + dNSName SAN <sub>.<zone>', async () => {
const { app, stores, frpCaDer } = await ownedApp()
const issuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: 3600 })
const { code } = await issuer.issuePairingCode(ACCOUNT_A)
const { der: csr, keys } = await buildCsrEc('CN=web-terminal-host')
const spki = new Uint8Array(await webcrypto.subtle.exportKey('spki', keys.publicKey))
const res = await app.inject({
method: 'POST',
url: '/enroll',
payload: { code, machineId: 'MB-1', agentPubkey: b64(spki), csr: b64(csr) },
})
expect(res.statusCode).toBe(201)
const body = JSON.parse(res.body)
expect(typeof body.subdomain).toBe('string')
expect(body.hostContentSecret).toBeNull()
const leafDer = new Uint8Array(Buffer.from(body.cert, 'base64'))
// chains to the REAL on-disk frp-client-CA …
const onDiskCa = new x509.X509Certificate(frpCaDer)
expect(await new x509.X509Certificate(leafDer).verify({ publicKey: onDiskCa.publicKey, signatureOnly: true })).toBe(true)
// … the caChain the server returned IS that on-disk anchor …
expect(b64(new Uint8Array(Buffer.from(body.caChain[0], 'base64')))).toBe(b64(frpCaDer))
// … and the leaf carries the server-assigned dNSName SAN under the env-configured zone.
expect(sanNames(leafDer)).toContainEqual({ type: 'dns', value: `${body.subdomain}.${ZONE}` })
})
})
describe('native-tunnel CA fail-closed (INV9)', () => {
test('buildNativeCas production without material → refuses to boot', async () => {
await expect(buildNativeCas(env, { production: true })).rejects.toThrow(/unresolvable in production/)
})
test('buildFileBackedNativeCas with an unreadable cert path → refuses to boot', async () => {
const { config } = await writeNativeCaConfig()
const broken: NativeCaEnvConfig = { ...config, frpClientCaCertPath: join(dir, 'missing.cert.pem') }
await expect(buildFileBackedNativeCas(env, broken)).rejects.toThrow(/certificate file is unreadable/)
})
test('buildFileBackedNativeCas with a NON-P256 key file → refuses to boot (fail-closed)', async () => {
const { config } = await writeNativeCaConfig()
const edPath = join(dir, 'ed.key.pem')
writeFileSync(edPath, generateKeyPairSync('ed25519').privateKey.export({ format: 'pem', type: 'pkcs8' }) as string)
const broken: NativeCaEnvConfig = { ...config, frpClientCaKeyPath: edPath }
await expect(buildFileBackedNativeCas(env, broken)).rejects.toThrow(/P-256|could not be resolved/)
})
test('buildFileBackedNativeCas with a malformed cert file → refuses to boot', async () => {
const { config } = await writeNativeCaConfig()
const badCert = join(dir, 'bad.cert.pem')
writeFileSync(badCert, 'not a certificate')
const broken: NativeCaEnvConfig = { ...config, deviceCaCertPath: badCert }
await expect(buildFileBackedNativeCas(env, broken)).rejects.toThrow(/parseable X.509 certificate/)
})
})