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.
|
* `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 { createPublicKey, generateKeyPairSync, sign as nodeSign } from 'node:crypto'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
import type { ControlPlaneEnv } from '../env.js'
|
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. */
|
/** Wraps KMS `sign()`; no raw key ever crosses this boundary. */
|
||||||
export interface CaSigner {
|
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 'reflect-metadata'
|
||||||
import * as x509 from '@peculiar/x509'
|
import * as x509 from '@peculiar/x509'
|
||||||
import { webcrypto, generateKeyPairSync } from 'node:crypto'
|
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 { 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)
|
x509.cryptoProvider.set(webcrypto)
|
||||||
|
|
||||||
@@ -153,3 +161,50 @@ export async function buildNativeCas(env: ControlPlaneEnv, opts: BuildNativeCasO
|
|||||||
])
|
])
|
||||||
return { frpClientCa, deviceCa }
|
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 operatorPassword?: string
|
||||||
readonly operatorAccountId?: string
|
readonly operatorAccountId?: string
|
||||||
readonly capabilitySignPrivkey?: Uint8Array
|
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. */
|
/** A positive integer parsed from an env string, or a default when unset/empty. */
|
||||||
@@ -97,6 +120,12 @@ const EnvSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
OPERATOR_ACCOUNT_ID: z.string().trim().uuid('OPERATOR_ACCOUNT_ID must be a UUID').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(),
|
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 }
|
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
|
* Parse + validate control-plane config. THROWS (fail-fast) listing every missing/invalid
|
||||||
* key by NAME. Never echoes secret VALUES (INV9).
|
* 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,
|
OPERATOR_ACCOUNT_ID: e.OPERATOR_ACCOUNT_ID,
|
||||||
CAPABILITY_SIGN_PRIVKEY_B64: e.CAPABILITY_SIGN_PRIVKEY_B64,
|
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[]
|
readonly caChainDer?: readonly Uint8Array[]
|
||||||
/** Production mode → native-CA + renew-anchor material is fail-closed (INV9). Defaults to NODE_ENV. */
|
/** Production mode → native-CA + renew-anchor material is fail-closed (INV9). Defaults to NODE_ENV. */
|
||||||
readonly production?: boolean
|
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). */
|
/** Production-loaded native-tunnel CA material (per-CA KMS ref + public cert DER). */
|
||||||
readonly nativeCaMaterial?: NativeCaMaterial
|
readonly nativeCaMaterial?: NativeCaMaterial
|
||||||
/** DNS zone the native-tunnel leaves are stamped under (defaults to `terminal.yaojia.wang`). */
|
/** 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
|
// 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.
|
// 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 production = overrides.production ?? process.env.NODE_ENV === 'production'
|
||||||
const nativeCas = await buildNativeCas(env, {
|
// `server.ts` builds the two on-disk P-256 CAs itself (buildFileBackedNativeCas, with the file
|
||||||
production,
|
// resolver) and threads them in as `nativeCas` — decoupled from the intermediate `kmsResolver`.
|
||||||
...(overrides.kmsResolver !== undefined ? { kmsResolver: overrides.kmsResolver } : {}),
|
const nativeCas =
|
||||||
...(overrides.nativeCaMaterial !== undefined ? { material: overrides.nativeCaMaterial } : {}),
|
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
|
const nativeDnsZone = overrides.nativeDnsZone ?? DEFAULT_NATIVE_DNS_ZONE
|
||||||
|
|
||||||
// ONE DeviceStore shared across the device registry, the device signer, and the renew path so the
|
// 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 { createRedisRevocationBus } from './routing/bus.js'
|
||||||
import { createCapabilityVerifier, configureCapabilityVerifyKey } from './boot/verifier.js'
|
import { createCapabilityVerifier, configureCapabilityVerifyKey } from './boot/verifier.js'
|
||||||
import { createRedisClient, createRedisPublisher } from './boot/redis.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. */
|
/** 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'
|
const DEFAULT_CP_BIND_HOST = '127.0.0.1'
|
||||||
@@ -57,7 +58,21 @@ async function main(): Promise<void> {
|
|||||||
await configureCapabilityVerifyKey(env.capabilitySignPubkey)
|
await configureCapabilityVerifyKey(env.capabilitySignPubkey)
|
||||||
const verifier = createCapabilityVerifier()
|
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 host = resolveBindHost(process.env)
|
||||||
const port = resolveBindPort(process.env)
|
const port = resolveBindPort(process.env)
|
||||||
|
|||||||
99
control-plane/test/ca-wiring.test.ts
Normal file
99
control-plane/test/ca-wiring.test.ts
Normal 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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
215
control-plane/test/native-ca.test.ts
Normal file
215
control-plane/test/native-ca.test.ts
Normal 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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user