/** * PKCS#10 CSR over the Ed25519 identity — PLAN_RELAY_AGENT T4. * * Node has no built-in CSR generator, so this is a compact, self-contained DER encoder that * emits a standard PKCS#10 CertificationRequest signed with the in-process Ed25519 key (INV4 — * only the PUBLIC key + a signature leave; the private key is never serialized here). * * NOTE (cross-plan / open Q#1): the exact SPIFFE-style cert PROFILE (SAN = subdomain vs a SPIFFE * URI) is set by P3's CA. This builds the standard subject-CN PKCS#10 shape; when P3 freezes its * profile, extend `buildCsr`'s attributes here. That is the single integration point. */ import type { AgentIdentity } from '../keys/identity.js' // --- minimal DER encoding helpers ------------------------------------------------------------- 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 } return Uint8Array.from([0x80 | bytes.length, ...bytes]) } 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 total = chunks.reduce((s, c) => s + c.length, 0) const out = new Uint8Array(total) 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 // OID 2.5.4.3 (commonName), 1.3.101.112 (Ed25519), 1.2.840.10045.4.3.2 (ecdsa-with-SHA256) as // pre-encoded DER value bytes. const OID_CN = Uint8Array.from([0x55, 0x04, 0x03]) const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70]) const OID_ECDSA_WITH_SHA256 = Uint8Array.from([0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02]) /** SubjectPublicKeyInfo DER for a raw Ed25519 public key (fixed 44-byte structure). */ 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])) } /** * SubjectPublicKeyInfo bytes for the CSR. For P-256, `id.publicKey` IS already the full EC SPKI DER * (built by `keys/identity.ts`), so it is embedded verbatim; for Ed25519 the raw 32-byte key is * wrapped into the fixed SPKI structure. */ function spkiFor(id: AgentIdentity): Uint8Array { return id.alg === 'p256' ? id.publicKey : spkiFromRawEd25519(id.publicKey) } /** * The signatureAlgorithm AlgorithmIdentifier: `SEQUENCE { OID }` (no parameters — RFC 5758 §3.2 for * ecdsa-with-SHA256, and Ed25519 likewise omits parameters). */ function sigAlgFor(id: AgentIdentity): Uint8Array { const oid = id.alg === 'p256' ? OID_ECDSA_WITH_SHA256 : OID_ED25519 return tlv(SEQUENCE, tlv(OID, oid)) } /** X.501 Name with a single CN= RDN. */ function nameFromCn(cn: string): Uint8Array { const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))])) const rdn = tlv(SET, atv) return tlv(SEQUENCE, rdn) } function toPem(der: Uint8Array, label: string): string { const b64 = Buffer.from(der).toString('base64') const lines = b64.match(/.{1,64}/g) ?? [] return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n` } /** * Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the identity's key. The * Ed25519 and P-256 (ecdsa-with-SHA256, FIX H-host-2) paths share this one encoder — only the SPKI * and the signatureAlgorithm differ. The private key is used in-process only; never serialized into * the output (INV4). */ export function buildCsr(id: AgentIdentity, subject: string): string { const version = tlv(INTEGER, Uint8Array.from([0x00])) const name = nameFromCn(subject) const spki = spkiFor(id) const attributes = tlv(CONTEXT_0, new Uint8Array(0)) // [0] IMPLICIT empty SET OF Attribute const requestInfo = tlv(SEQUENCE, concat([version, name, spki, attributes])) const signature = id.sign(requestInfo) // over CertificationRequestInfo, per id.alg const sigAlg = sigAlgFor(id) const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature])) const csr = tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits])) return toPem(csr, 'CERTIFICATE REQUEST') }