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.
155 lines
6.9 KiB
TypeScript
155 lines
6.9 KiB
TypeScript
/**
|
|
* T15 — CA-key / secret-manager bootstrap + KMS-signer factory (§3.1). The intermediate
|
|
* leaf-signing key is a non-exportable KMS/HSM key: signing is a `sign()` CALL, the raw private
|
|
* key is NEVER loaded into control-plane memory. `buildCaSigner` FAILS FAST at startup (INV9) if
|
|
* the key ref is unresolvable OR its policy does not restrict `sign` to the control-plane
|
|
* service principal. Both T8 `signHostLeaf` and T15 `renewHostLeaf` call this shared primitive —
|
|
* neither loads a raw key.
|
|
*
|
|
* Two in-process dev/test signers implement the SAME `CaSigner` surface (never a KMS — production
|
|
* mandates a real non-exportable key): `inProcessCaSigner` (Ed25519, the relay agent-CA path) and
|
|
* `inProcessP256CaSigner` (ECDSA-with-SHA256, the native-tunnel `frp-client-CA` / `device-CA` P-256
|
|
* path). The P-256 signer returns a raw P1363 `r||s` signature — the shape hardware emits — which
|
|
* `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 { createPrivateKey, ed25519Sign, generateEd25519, type KeyObject } from '../util/crypto.js'
|
|
|
|
/** Wraps KMS `sign()`; no raw key ever crosses this boundary. */
|
|
export interface CaSigner {
|
|
sign(tbsCert: Uint8Array): Promise<Uint8Array>
|
|
/** Public verifying key of the CA (for tests / chain assembly). */
|
|
readonly publicKeyRaw: Uint8Array
|
|
}
|
|
|
|
/**
|
|
* KMS resolver seam. Production resolves a real KMS/HSM key handle + policy; tests inject a fake.
|
|
* A resolver returns `{ canSign, policyRestrictedToServicePrincipal }` for the given ref, or throws
|
|
* if the ref cannot be resolved at all.
|
|
*/
|
|
export interface KmsResolver {
|
|
resolve(keyRef: string): Promise<{
|
|
readonly signer: CaSigner
|
|
readonly policyRestrictedToServicePrincipal: boolean
|
|
}>
|
|
}
|
|
|
|
/**
|
|
* Build the CA signer for the configured intermediate KMS key. Fail-fast (INV9, §3.1):
|
|
* - throws if the resolver cannot resolve the ref;
|
|
* - throws if the KMS key policy is broader than the control-plane service principal.
|
|
*/
|
|
export async function buildCaSigner(env: ControlPlaneEnv, resolver: KmsResolver): Promise<CaSigner> {
|
|
let resolved
|
|
try {
|
|
resolved = await resolver.resolve(env.caIntermediateKmsKeyRef)
|
|
} catch (err: unknown) {
|
|
// Never log the ref VALUE — only that resolution failed (INV9).
|
|
throw new Error(
|
|
`CA intermediate KMS key could not be resolved: ${err instanceof Error ? err.message : 'unknown'}`,
|
|
)
|
|
}
|
|
if (!resolved.policyRestrictedToServicePrincipal) {
|
|
throw new Error(
|
|
'CA intermediate KMS key policy is broader than the control-plane service principal (§3.1) — refusing to boot',
|
|
)
|
|
}
|
|
return resolved.signer
|
|
}
|
|
|
|
/**
|
|
* In-process Ed25519 CA signer — TEST/DEV ONLY (clearly NOT a KMS; the plan mandates a real
|
|
* non-exportable KMS key in production, §3.1). Exposes the same `sign()` surface so callers are
|
|
* KMS-shaped and never touch a raw key path themselves.
|
|
*/
|
|
export function inProcessCaSigner(privateKey?: KeyObject): CaSigner {
|
|
const key = privateKey ?? generateEd25519().privateKey
|
|
const pub = derivePublicRaw(key)
|
|
return {
|
|
publicKeyRaw: pub,
|
|
async sign(tbsCert) {
|
|
return ed25519Sign(key, tbsCert)
|
|
},
|
|
}
|
|
}
|
|
|
|
function derivePublicRaw(privateKey: KeyObject): Uint8Array {
|
|
// Recover the raw 32-byte Ed25519 public key from the private KeyObject.
|
|
const spki = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }) as Buffer
|
|
return new Uint8Array(spki.subarray(spki.length - 32))
|
|
}
|
|
|
|
/**
|
|
* In-process P-256 (ECDSA-with-SHA256) CA signer — TEST/DEV ONLY, same disclaimer as the Ed25519
|
|
* variant. This is the dev stand-in for the native-tunnel `frp-client-CA` / `device-CA` (both P-256).
|
|
* `sign()` returns a RAW P1363 `r||s` (64-byte) signature over the TBS — the same shape hardware
|
|
* (Secure Enclave / StrongBox / WebCrypto) emits — which `x509-assembler` normalizes to DER.
|
|
* `publicKeyRaw` carries the CA's SubjectPublicKeyInfo DER (not a bare point) so tests can import it
|
|
* directly as a verifying key.
|
|
*/
|
|
export function inProcessP256CaSigner(privateKey?: KeyObject): CaSigner {
|
|
const key = privateKey ?? generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey
|
|
const spkiDer = createPublicKey(key).export({ format: 'der', type: 'spki' }) as Buffer
|
|
return {
|
|
publicKeyRaw: new Uint8Array(spkiDer),
|
|
async sign(tbsCert) {
|
|
return new Uint8Array(nodeSign('sha256', Buffer.from(tbsCert), { key, dsaEncoding: 'ieee-p1363' }))
|
|
},
|
|
}
|
|
}
|
|
|
|
/** 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 }
|
|
},
|
|
}
|
|
}
|