/** * CSR handling (INV14) — REAL PKCS#10 over the agent's Ed25519 identity. * * 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 'reflect-metadata' import * as x509 from '@peculiar/x509' import { webcrypto } from 'node:crypto' import { ed25519Sign, type KeyObject } from '../util/crypto.js' // Ed25519 signing/verification runs on Node's WebCrypto (Ed25519 supported on Node 20+). x509.cryptoProvider.set(webcrypto) // --- 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 // --- minimal DER encoding helpers (test/agent-side CSR construction) --------------------------- 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 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 } }