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.
100 lines
4.5 KiB
TypeScript
100 lines
4.5 KiB
TypeScript
/**
|
|
* 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))
|
|
}
|
|
})
|
|
})
|