feat(control-plane): real X.509 agent leaf issuance (closes enrollment↔mTLS gap)
- ca/issue.ts: real X.509 v3 Ed25519 leaf with SPIFFE URI SAN, signed by the intermediate; SAN built via relay-auth spiffeIdFor so it can't drift - ca/csr.ts: parse real PKCS#10 (agent's PEM) + async PoP verify; decodeCsrWire accepts PEM or base64(DER) - main.ts/env.ts: real issuer when CA_INTERMEDIATE_KEY_PATH present, else dev fallback - interop.test.ts: oracle proving relay-auth verifyAgentCert accepts the leaf (6 tests) - deps: @peculiar/x509, reflect-metadata
This commit is contained in:
@@ -1,53 +1,147 @@
|
||||
/**
|
||||
* CSR proof-of-possession (INV14). Models a PKCS#10 CSR as `embeddedPub(32) || sig(64)` where
|
||||
* `sig = Ed25519(privateKey, CSR_CHALLENGE || embeddedPub)`. Verifying the signature against the
|
||||
* embedded pubkey proves the requester holds the matching private key (the exact property a real
|
||||
* PKCS#10 self-signature provides) — using builtin crypto only.
|
||||
* CSR handling (INV14) — REAL PKCS#10 over the agent's Ed25519 identity.
|
||||
*
|
||||
* INTEGRATION SEAM: production parses a real PKCS#10 DER (`@peculiar/x509`) and checks the
|
||||
* self-signature. Swapping it in does not change `signHostLeaf`'s check ORDER or reject path.
|
||||
* The agent (`agent/src/enroll/csr.ts`) emits a standard PKCS#10 `CertificationRequest` signed by
|
||||
* its in-process Ed25519 key. This module parses that request with `@peculiar/x509`, verifies its
|
||||
* self-signature (proof-of-possession — the requester holds the private key for the embedded
|
||||
* pubkey), and exposes the embedded raw Ed25519 public key so `sign.ts` can gate on
|
||||
* embeddedPub == agentPubkey. Parsing/verification is FAIL-CLOSED and returns a UNIFORM result: any
|
||||
* malformed/forged input yields `{ ok: false }` with no distinguishing detail (see `verifyCsrPoP`).
|
||||
*
|
||||
* `@peculiar/x509` uses `tsyringe`, which requires the `reflect-metadata` polyfill to be loaded
|
||||
* before the library — hence the side-effect import on the first line (must stay first).
|
||||
*/
|
||||
import { ed25519Sign, ed25519Verify, type KeyObject } from '../util/crypto.js'
|
||||
import 'reflect-metadata'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { webcrypto } from 'node:crypto'
|
||||
import { ed25519Sign, type KeyObject } from '../util/crypto.js'
|
||||
|
||||
const CSR_CHALLENGE = new TextEncoder().encode('relay-cp/csr-pop/v1|')
|
||||
// Ed25519 signing/verification runs on Node's WebCrypto (Ed25519 supported on Node 20+).
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
/** Test/agent helper: build a CSR proving possession of `privateKey` for `embeddedPub`. */
|
||||
export function buildCsr(privateKey: KeyObject, embeddedPub: Uint8Array): Uint8Array {
|
||||
const message = concat(CSR_CHALLENGE, embeddedPub)
|
||||
const sig = ed25519Sign(privateKey, message)
|
||||
return concat(embeddedPub, sig)
|
||||
}
|
||||
// --- Ed25519 SPKI constants -------------------------------------------------------------------
|
||||
/** Fixed 12-byte SPKI prefix for an Ed25519 SubjectPublicKeyInfo; the raw key is the trailing 32. */
|
||||
const ED25519_SPKI_PREFIX = Uint8Array.from([
|
||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||
])
|
||||
const ED25519_SPKI_LEN = ED25519_SPKI_PREFIX.length + 32 // 44
|
||||
const RAW_ED25519_LEN = 32
|
||||
|
||||
export interface CsrParts {
|
||||
readonly embeddedPub: Uint8Array
|
||||
readonly sig: Uint8Array
|
||||
}
|
||||
// --- minimal DER encoding helpers (test/agent-side CSR construction) ---------------------------
|
||||
|
||||
/** Parse the modelled CSR bytes. Throws on wrong length. */
|
||||
export function parseCsr(csr: Uint8Array): CsrParts {
|
||||
if (csr.length !== 96) throw new Error('malformed csr')
|
||||
return { embeddedPub: csr.subarray(0, 32), sig: csr.subarray(32, 96) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify CSR proof-of-possession: the embedded pubkey's private key signed the challenge.
|
||||
* Independent of any registry state (a forged signature is refused even for a registered key).
|
||||
*/
|
||||
export function verifyCsrPoP(csr: Uint8Array): { ok: boolean; embeddedPub: Uint8Array } {
|
||||
let parts: CsrParts
|
||||
try {
|
||||
parts = parseCsr(csr)
|
||||
} catch {
|
||||
return { ok: false, embeddedPub: new Uint8Array(0) }
|
||||
function derLen(len: number): Uint8Array {
|
||||
if (len < 0x80) return Uint8Array.from([len])
|
||||
const bytes: number[] = []
|
||||
let n = len
|
||||
while (n > 0) {
|
||||
bytes.unshift(n & 0xff)
|
||||
n >>= 8
|
||||
}
|
||||
const message = concat(CSR_CHALLENGE, parts.embeddedPub)
|
||||
const ok = ed25519Verify(parts.embeddedPub, message, parts.sig)
|
||||
return { ok, embeddedPub: parts.embeddedPub }
|
||||
return Uint8Array.from([0x80 | bytes.length, ...bytes])
|
||||
}
|
||||
|
||||
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(a.length + b.length)
|
||||
out.set(a, 0)
|
||||
out.set(b, a.length)
|
||||
function tlv(tag: number, value: Uint8Array): Uint8Array {
|
||||
const len = derLen(value.length)
|
||||
const out = new Uint8Array(1 + len.length + value.length)
|
||||
out[0] = tag
|
||||
out.set(len, 1)
|
||||
out.set(value, 1 + len.length)
|
||||
return out
|
||||
}
|
||||
|
||||
function concat(chunks: readonly Uint8Array[]): Uint8Array {
|
||||
const out = new Uint8Array(chunks.reduce((s, c) => s + c.length, 0))
|
||||
let off = 0
|
||||
for (const c of chunks) {
|
||||
out.set(c, off)
|
||||
off += c.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const SEQUENCE = 0x30
|
||||
const SET = 0x31
|
||||
const INTEGER = 0x02
|
||||
const BIT_STRING = 0x03
|
||||
const OID = 0x06
|
||||
const UTF8_STRING = 0x0c
|
||||
const CONTEXT_0 = 0xa0
|
||||
const OID_CN = Uint8Array.from([0x55, 0x04, 0x03]) // 2.5.4.3 commonName
|
||||
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70]) // 1.3.101.112 Ed25519
|
||||
|
||||
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
|
||||
const algId = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
||||
const pubBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), raw]))
|
||||
return tlv(SEQUENCE, concat([algId, pubBits]))
|
||||
}
|
||||
|
||||
function nameFromCn(cn: string): Uint8Array {
|
||||
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
|
||||
return tlv(SEQUENCE, tlv(SET, atv))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a real PKCS#10 `CertificationRequest` DER for `embeddedPub`, self-signed by `privateKey`
|
||||
* (Ed25519). Mirrors the agent's on-wire request shape (subject CN, empty attributes). Used by the
|
||||
* control-plane's own tests to exercise the real parse/verify path; production requests come from
|
||||
* the agent unchanged.
|
||||
*/
|
||||
export function buildCsr(privateKey: KeyObject, embeddedPub: Uint8Array): Uint8Array {
|
||||
const version = tlv(INTEGER, Uint8Array.from([0x00]))
|
||||
const requestInfo = tlv(
|
||||
SEQUENCE,
|
||||
concat([version, nameFromCn('web-terminal-agent'), spkiFromRawEd25519(embeddedPub), tlv(CONTEXT_0, new Uint8Array(0))]),
|
||||
)
|
||||
const signature = ed25519Sign(privateKey, requestInfo)
|
||||
const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
||||
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
|
||||
return tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
|
||||
}
|
||||
|
||||
// --- wire decoding + verification --------------------------------------------------------------
|
||||
|
||||
const PEM_CSR_RE = /-----BEGIN CERTIFICATE REQUEST-----([\s\S]+?)-----END CERTIFICATE REQUEST-----/
|
||||
|
||||
/**
|
||||
* Decode the `csr` field as it arrives on the `/enroll` wire into raw PKCS#10 DER bytes. Accepts
|
||||
* BOTH shapes the codebase produces: the agent sends a PEM `CERTIFICATE REQUEST` block, while the
|
||||
* control-plane's own HTTP tests send `base64(DER)`. Both normalise to the same DER — this is the
|
||||
* single place the wire encoding is interpreted (boundary validation, coding-style §Input).
|
||||
*/
|
||||
export function decodeCsrWire(wire: string): Uint8Array {
|
||||
const pem = PEM_CSR_RE.exec(wire)
|
||||
const body = pem !== null ? pem[1]!.replace(/\s+/g, '') : wire.trim()
|
||||
return new Uint8Array(Buffer.from(body, 'base64'))
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||
}
|
||||
|
||||
/** Extract the raw 32-byte Ed25519 key from a SubjectPublicKeyInfo DER, or null if not Ed25519. */
|
||||
function rawEd25519FromSpki(spki: Uint8Array): Uint8Array | null {
|
||||
if (spki.length !== ED25519_SPKI_LEN) return null
|
||||
for (let i = 0; i < ED25519_SPKI_PREFIX.length; i++) {
|
||||
if (spki[i] !== ED25519_SPKI_PREFIX[i]) return null
|
||||
}
|
||||
return spki.subarray(ED25519_SPKI_PREFIX.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify CSR proof-of-possession: parse the PKCS#10 DER and check its self-signature against the
|
||||
* embedded public key. Returns the embedded raw Ed25519 pubkey on success. FAIL-CLOSED and uniform:
|
||||
* malformed DER, a non-Ed25519 key, or a bad signature all yield `{ ok: false, embeddedPub: [] }`
|
||||
* with no leak of which check failed. Independent of any registry state.
|
||||
*/
|
||||
export async function verifyCsrPoP(csr: Uint8Array): Promise<{ ok: boolean; embeddedPub: Uint8Array }> {
|
||||
const fail = { ok: false, embeddedPub: new Uint8Array(0) }
|
||||
try {
|
||||
const req = new x509.Pkcs10CertificateRequest(toArrayBuffer(csr))
|
||||
const embeddedPub = rawEd25519FromSpki(new Uint8Array(req.publicKey.rawData))
|
||||
if (embeddedPub === null || embeddedPub.length !== RAW_ED25519_LEN) return fail
|
||||
const ok = await req.verify()
|
||||
return ok ? { ok: true, embeddedPub: new Uint8Array(embeddedPub) } : fail
|
||||
} catch {
|
||||
return fail
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user