From af630143de05b81373edaf12cf40b37a38df009a Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sat, 18 Jul 2026 15:04:04 +0200 Subject: [PATCH] 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. --- control-plane/src/boot/ca-wiring.ts | 56 ++++++- control-plane/src/boot/native-ca.ts | 59 +++++++- control-plane/src/env.ts | 80 ++++++++++ control-plane/src/main.ts | 21 ++- control-plane/src/server.ts | 19 ++- control-plane/test/ca-wiring.test.ts | 99 ++++++++++++ control-plane/test/env.test.ts | 58 ++++++++ control-plane/test/native-ca.test.ts | 215 +++++++++++++++++++++++++++ 8 files changed, 597 insertions(+), 10 deletions(-) create mode 100644 control-plane/test/ca-wiring.test.ts create mode 100644 control-plane/test/native-ca.test.ts diff --git a/control-plane/src/boot/ca-wiring.ts b/control-plane/src/boot/ca-wiring.ts index 520bf37..9b49bf7 100644 --- a/control-plane/src/boot/ca-wiring.ts +++ b/control-plane/src/boot/ca-wiring.ts @@ -13,8 +13,9 @@ * `x509-assembler` normalizes to DER; both expose the same `sign()` so callers stay KMS-shaped. */ import { createPublicKey, generateKeyPairSync, sign as nodeSign } from 'node:crypto' +import { readFileSync } from 'node:fs' import type { ControlPlaneEnv } from '../env.js' -import { ed25519Sign, generateEd25519, type KeyObject } from '../util/crypto.js' +import { createPrivateKey, ed25519Sign, generateEd25519, type KeyObject } from '../util/crypto.js' /** Wraps KMS `sign()`; no raw key ever crosses this boundary. */ export interface CaSigner { @@ -98,3 +99,56 @@ export function inProcessP256CaSigner(privateKey?: KeyObject): CaSigner { }, } } + +/** Prefix marking a KMS key ref that is actually an on-disk PEM key path (`file:`). */ +const FILE_KEY_REF_PREFIX = 'file:' +/** OpenSSL's name for the P-256 (secp256r1) curve — the ONLY curve the native-tunnel CAs use. */ +const P256_CURVE = 'prime256v1' + +/** Build a `file:` KMS key ref from an on-disk native-CA private key path. */ +export function fileKeyRef(path: string): string { + return `${FILE_KEY_REF_PREFIX}${path}` +} + +/** + * Load a PKCS#8 PEM P-256 private key from disk and wrap it in the SAME `CaSigner` surface as + * `inProcessP256CaSigner` (raw P1363 `r||s` over the DER TBS). FAIL-FAST on an unreadable file, + * malformed PEM, or a non-P-256 key. The raw key stays in-process — NEVER logged/serialised (INV9). + */ +function loadP256FileSigner(path: string): CaSigner { + let pem: string + try { + pem = readFileSync(path, 'utf8') + } catch { + // Never echo the path contents — only that the file was unreadable (INV9). + throw new Error('native-CA private key file is unreadable') + } + let key: KeyObject + try { + key = createPrivateKey(pem) + } catch { + throw new Error('native-CA private key is not a valid PEM private key') + } + if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== P256_CURVE) { + throw new Error('native-CA private key must be a P-256 (prime256v1) EC key') + } + return inProcessP256CaSigner(key) +} + +/** + * FILE-BACKED KmsResolver — the production native-tunnel key custody for the single-owner VPS deploy. + * Resolves a key ref of the form `file:` (or a bare path) by loading the on-disk PEM P-256 key + * into an in-process `CaSigner`. `policyRestrictedToServicePrincipal` is TRUE because the on-disk key + * IS the control-plane's sole custody (documented file-custody reality; a real non-exportable KMS — + * `KmsResolver` in front of KMS/HSM — is the future upgrade). Throws if the ref cannot be resolved so + * `buildCaSigner` fails fast at boot. NEVER logs key material (INV9). + */ +export function fileKmsResolver(): KmsResolver { + return { + async resolve(keyRef: string) { + const path = keyRef.startsWith(FILE_KEY_REF_PREFIX) ? keyRef.slice(FILE_KEY_REF_PREFIX.length) : keyRef + if (path.length === 0) throw new Error('file KMS key ref has an empty path') + return { signer: loadP256FileSigner(path), policyRestrictedToServicePrincipal: true } + }, + } +} diff --git a/control-plane/src/boot/native-ca.ts b/control-plane/src/boot/native-ca.ts index 95c76ad..2cd75c4 100644 --- a/control-plane/src/boot/native-ca.ts +++ b/control-plane/src/boot/native-ca.ts @@ -21,9 +21,17 @@ import 'reflect-metadata' import * as x509 from '@peculiar/x509' import { webcrypto, generateKeyPairSync } from 'node:crypto' -import type { ControlPlaneEnv } from '../env.js' +import { readFileSync } from 'node:fs' +import type { ControlPlaneEnv, NativeCaEnvConfig } from '../env.js' import { assembleCertificate } from '../ca/x509-assembler.js' -import { buildCaSigner, inProcessP256CaSigner, type CaSigner, type KmsResolver } from './ca-wiring.js' +import { + buildCaSigner, + fileKeyRef, + fileKmsResolver, + inProcessP256CaSigner, + type CaSigner, + type KmsResolver, +} from './ca-wiring.js' x509.cryptoProvider.set(webcrypto) @@ -153,3 +161,50 @@ export async function buildNativeCas(env: ControlPlaneEnv, opts: BuildNativeCasO ]) return { frpClientCa, deviceCa } } + +/** Load one CA's (public) cert PEM from disk as anchor DER, failing loud on unreadable/unparseable material (INV9). */ +function loadCaCertDer(certPath: string): Uint8Array { + let pem: string + try { + pem = readFileSync(certPath, 'utf8') + } catch { + // Never echo the file contents — only that it was unreadable (INV9). + throw new Error('native-CA certificate file is unreadable — refusing to boot') + } + try { + return new Uint8Array(new x509.X509Certificate(pem).rawData) + } catch { + throw new Error('native-CA certificate material is not a parseable X.509 certificate — refusing to boot') + } +} + +/** + * PRODUCTION wiring from ON-DISK material — the A2-prep prerequisite for deploying the control-plane. + * Builds both native-tunnel CAs from the VPS `gen-device-ca.sh` output: each CA's (public) cert PEM + * becomes its trust-anchor DER and its PKCS#8 PEM private key path becomes a `file:` KMS ref resolved + * by `fileKmsResolver` (single-owner file-custody; a non-exportable KMS is the upgrade). Delegates to + * `buildNativeCas` in production mode so the SAME INV9 fail-fast applies — any unreadable/unparseable + * cert or non-P-256 key refuses to boot. Raw key bytes are never loaded/logged at this layer. + */ +export async function buildFileBackedNativeCas( + env: ControlPlaneEnv, + config: NativeCaEnvConfig, + opts: { readonly now?: () => number } = {}, +): Promise { + const material: NativeCaMaterial = { + frpClientCa: { + kmsKeyRef: fileKeyRef(config.frpClientCaKeyPath), + caCertDer: loadCaCertDer(config.frpClientCaCertPath), + }, + deviceCa: { + kmsKeyRef: fileKeyRef(config.deviceCaKeyPath), + caCertDer: loadCaCertDer(config.deviceCaCertPath), + }, + } + return buildNativeCas(env, { + production: true, + kmsResolver: fileKmsResolver(), + material, + ...(opts.now !== undefined ? { now: opts.now } : {}), + }) +} diff --git a/control-plane/src/env.ts b/control-plane/src/env.ts index d065d06..cf5fb9c 100644 --- a/control-plane/src/env.ts +++ b/control-plane/src/env.ts @@ -54,6 +54,29 @@ export interface ControlPlaneEnv { readonly operatorPassword?: string readonly operatorAccountId?: string readonly capabilitySignPrivkey?: Uint8Array + /** + * A2-prep — the native-tunnel PKI's on-disk P-256 CA material (the VPS `gen-device-ca.sh` output: + * `frp-client-CA` for host frp-client leaves + `device-CA` for device leaves). Set together or NOT + * at all — a partial set is a boot error (fail-closed). Undefined ⇒ the dev/test injection path + * (self-signed in-process CAs) is used. The private KEY paths mirror `CA_INTERMEDIATE_KEY_PATH` + * (optional; derived from the cert path when unset). The raw keys are custody-on-disk (single-owner + * file-custody reality; a non-exportable KMS is the future upgrade). + */ + readonly nativeCa?: NativeCaEnvConfig +} + +/** Resolved on-disk material for the two native-tunnel P-256 CAs + the DNS zone their leaves live under. */ +export interface NativeCaEnvConfig { + /** frp-client-CA (public) cert PEM path — the host-leaf trust anchor. */ + readonly frpClientCaCertPath: string + /** frp-client-CA PKCS#8 PEM private key path (derived from the cert path when unset). */ + readonly frpClientCaKeyPath: string + /** device-CA (public) cert PEM path — the device-leaf trust anchor. */ + readonly deviceCaCertPath: string + /** device-CA PKCS#8 PEM private key path (derived from the cert path when unset). */ + readonly deviceCaKeyPath: string + /** DNS zone the leaves' `dNSName .` SAN is stamped under (the nginx :8470 tenant key). */ + readonly dnsZone: string } /** A positive integer parsed from an env string, or a default when unset/empty. */ @@ -97,6 +120,12 @@ const EnvSchema = z.object({ .optional(), OPERATOR_ACCOUNT_ID: z.string().trim().uuid('OPERATOR_ACCOUNT_ID must be a UUID').optional(), CAPABILITY_SIGN_PRIVKEY_B64: z.string().trim().min(1).optional(), + // A2-prep native-tunnel CA material — all optional; cross-validated below (set together or not at all). + NATIVE_FRP_CLIENT_CA_CERT_PATH: z.string().trim().optional(), + NATIVE_FRP_CLIENT_CA_KEY_PATH: z.string().trim().optional(), + NATIVE_DEVICE_CA_CERT_PATH: z.string().trim().optional(), + NATIVE_DEVICE_CA_KEY_PATH: z.string().trim().optional(), + NATIVE_DNS_ZONE: z.string().trim().optional(), }) /** @@ -134,6 +163,50 @@ function resolveOperatorLogin(e: { return { operatorPassword: pw, operatorAccountId: acct, capabilitySignPrivkey } } +/** + * Resolve the optional native-tunnel CA material. Deny-by-default + fail-fast: the two (public) CA + * cert paths and the DNS zone are the mandatory triad; if ANY native field is supplied (including a + * lone KEY_PATH), all three MUST be present or boot fails (a partial set is never a silent half-open, + * mirroring `resolveOperatorLogin`). The private KEY paths are optional and derived from their cert + * path when unset (mirrors `CA_INTERMEDIATE_KEY_PATH`). Returns `{}` when the feature is unconfigured. + * NEVER echoes path VALUES — only field NAMES on failure (INV9). + */ +function resolveNativeCa(e: { + NATIVE_FRP_CLIENT_CA_CERT_PATH: string | undefined + NATIVE_FRP_CLIENT_CA_KEY_PATH: string | undefined + NATIVE_DEVICE_CA_CERT_PATH: string | undefined + NATIVE_DEVICE_CA_KEY_PATH: string | undefined + NATIVE_DNS_ZONE: string | undefined +}): { nativeCa?: NativeCaEnvConfig } { + const has = (v: string | undefined): v is string => v !== undefined && v.length > 0 + const frpCert = e.NATIVE_FRP_CLIENT_CA_CERT_PATH + const frpKey = e.NATIVE_FRP_CLIENT_CA_KEY_PATH + const devCert = e.NATIVE_DEVICE_CA_CERT_PATH + const devKey = e.NATIVE_DEVICE_CA_KEY_PATH + const zone = e.NATIVE_DNS_ZONE + const anyPresent = [frpCert, frpKey, devCert, devKey, zone].some(has) + if (!anyPresent) return {} // feature off — dev/test injection path (self-signed in-process CAs) + if (!has(frpCert) || !has(devCert) || !has(zone)) { + const missing = [ + has(frpCert) ? null : 'NATIVE_FRP_CLIENT_CA_CERT_PATH', + has(devCert) ? null : 'NATIVE_DEVICE_CA_CERT_PATH', + has(zone) ? null : 'NATIVE_DNS_ZONE', + ].filter((x): x is string => x !== null) + throw new Error( + `Invalid control-plane env: native-tunnel CA material requires all of ${missing.join(', ')} to be set`, + ) + } + return { + nativeCa: { + frpClientCaCertPath: frpCert, + frpClientCaKeyPath: has(frpKey) ? frpKey : deriveKeyPath(frpCert), + deviceCaCertPath: devCert, + deviceCaKeyPath: has(devKey) ? devKey : deriveKeyPath(devCert), + dnsZone: zone, + }, + } +} + /** * Parse + validate control-plane config. THROWS (fail-fast) listing every missing/invalid * key by NAME. Never echoes secret VALUES (INV9). @@ -185,5 +258,12 @@ export function loadEnv(source: NodeJS.ProcessEnv): ControlPlaneEnv { OPERATOR_ACCOUNT_ID: e.OPERATOR_ACCOUNT_ID, CAPABILITY_SIGN_PRIVKEY_B64: e.CAPABILITY_SIGN_PRIVKEY_B64, }), + ...resolveNativeCa({ + NATIVE_FRP_CLIENT_CA_CERT_PATH: e.NATIVE_FRP_CLIENT_CA_CERT_PATH, + NATIVE_FRP_CLIENT_CA_KEY_PATH: e.NATIVE_FRP_CLIENT_CA_KEY_PATH, + NATIVE_DEVICE_CA_CERT_PATH: e.NATIVE_DEVICE_CA_CERT_PATH, + NATIVE_DEVICE_CA_KEY_PATH: e.NATIVE_DEVICE_CA_KEY_PATH, + NATIVE_DNS_ZONE: e.NATIVE_DNS_ZONE, + }), } } diff --git a/control-plane/src/main.ts b/control-plane/src/main.ts index a3ad086..3fb37d5 100644 --- a/control-plane/src/main.ts +++ b/control-plane/src/main.ts @@ -58,6 +58,13 @@ export interface ControlPlaneOverrides { readonly caChainDer?: readonly Uint8Array[] /** Production mode → native-CA + renew-anchor material is fail-closed (INV9). Defaults to NODE_ENV. */ readonly production?: boolean + /** + * Pre-built native-tunnel CAs (A2-prep production path). When supplied, `buildControlPlane` uses + * these verbatim and does NOT call `buildNativeCas` — this is how `server.ts` threads the two + * on-disk P-256 CAs (`buildFileBackedNativeCas`) in without coupling them to the intermediate + * `kmsResolver`. Takes precedence over `nativeCaMaterial`. + */ + readonly nativeCas?: NativeCas /** Production-loaded native-tunnel CA material (per-CA KMS ref + public cert DER). */ readonly nativeCaMaterial?: NativeCaMaterial /** DNS zone the native-tunnel leaves are stamped under (defaults to `terminal.yaojia.wang`). */ @@ -185,11 +192,15 @@ export async function buildControlPlane( // the renew route MUST validate presented certs against non-empty anchors (renew.ts). DEV generates // self-signed in-process P-256 CAs so leaves chain to a real, re-parseable CA. const production = overrides.production ?? process.env.NODE_ENV === 'production' - const nativeCas = await buildNativeCas(env, { - production, - ...(overrides.kmsResolver !== undefined ? { kmsResolver: overrides.kmsResolver } : {}), - ...(overrides.nativeCaMaterial !== undefined ? { material: overrides.nativeCaMaterial } : {}), - }) + // `server.ts` builds the two on-disk P-256 CAs itself (buildFileBackedNativeCas, with the file + // resolver) and threads them in as `nativeCas` — decoupled from the intermediate `kmsResolver`. + const nativeCas = + overrides.nativeCas ?? + (await buildNativeCas(env, { + production, + ...(overrides.kmsResolver !== undefined ? { kmsResolver: overrides.kmsResolver } : {}), + ...(overrides.nativeCaMaterial !== undefined ? { material: overrides.nativeCaMaterial } : {}), + })) const nativeDnsZone = overrides.nativeDnsZone ?? DEFAULT_NATIVE_DNS_ZONE // ONE DeviceStore shared across the device registry, the device signer, and the renew path so the diff --git a/control-plane/src/server.ts b/control-plane/src/server.ts index 85c3748..c244279 100644 --- a/control-plane/src/server.ts +++ b/control-plane/src/server.ts @@ -16,7 +16,8 @@ import { runMigrations } from './db/migrate.js' import { createRedisRevocationBus } from './routing/bus.js' import { createCapabilityVerifier, configureCapabilityVerifyKey } from './boot/verifier.js' import { createRedisClient, createRedisPublisher } from './boot/redis.js' -import { buildControlPlane } from './main.js' +import { buildControlPlane, type ControlPlaneOverrides } from './main.js' +import { buildFileBackedNativeCas } from './boot/native-ca.js' /** Admin API binds to loopback by default — never expose the provisioning API on a public interface. */ const DEFAULT_CP_BIND_HOST = '127.0.0.1' @@ -57,7 +58,21 @@ async function main(): Promise { await configureCapabilityVerifyKey(env.capabilitySignPubkey) const verifier = createCapabilityVerifier() - const { app } = await buildControlPlane(env, { stores, bus, verifier }) + // A2-prep: when NATIVE_* on-disk CA material is configured, load the two REAL P-256 CAs (frp-client + // + device) from disk and thread them into the app so /enroll + /device/enroll issue leaves from the + // production CAs. `buildFileBackedNativeCas` fails fast (INV9) on unreadable/non-P-256 material. When + // unset, `buildControlPlane` keeps its existing behaviour (dev self-signed CAs, or fail-closed if + // NODE_ENV=production) — the dev/test injection path is untouched. + const nativeOverrides: Pick = + env.nativeCa !== undefined + ? { + production: true, + nativeCas: await buildFileBackedNativeCas(env, env.nativeCa), + nativeDnsZone: env.nativeCa.dnsZone, + } + : {} + + const { app } = await buildControlPlane(env, { stores, bus, verifier, ...nativeOverrides }) const host = resolveBindHost(process.env) const port = resolveBindPort(process.env) diff --git a/control-plane/test/ca-wiring.test.ts b/control-plane/test/ca-wiring.test.ts new file mode 100644 index 0000000..f554ce1 --- /dev/null +++ b/control-plane/test/ca-wiring.test.ts @@ -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 } { + 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)) + } + }) +}) diff --git a/control-plane/test/env.test.ts b/control-plane/test/env.test.ts index 2f0990e..44df54e 100644 --- a/control-plane/test/env.test.ts +++ b/control-plane/test/env.test.ts @@ -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/, + ) + }) +}) diff --git a/control-plane/test/native-ca.test.ts b/control-plane/test/native-ca.test.ts new file mode 100644 index 0000000..c96b696 --- /dev/null +++ b/control-plane/test/native-ca.test.ts @@ -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 = `.` 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 { + 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>['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 .', 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/) + }) +})