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:
@@ -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:<path>`). */
|
||||
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:<path>` 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:<path>` (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 }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NativeCas> {
|
||||
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 } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 <sub>.<zone>` 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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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<ControlPlaneOverrides, 'production' | 'nativeCas' | 'nativeDnsZone'> =
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user