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

@@ -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 }
},
}
}

View File

@@ -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 } : {}),
})
}