/** * Test helpers for generating real X.509 PEM certificates in unit tests. * * PR #501 (FED-M2-11) introduced strict `new X509Certificate(certPem)` parsing * in both EnrollmentService.extractCertNotAfter and CaService.issueCert — dummy * cert strings now throw `error:0680007B:asn1 encoding routines::header too long`. * * These helpers produce minimal but cryptographically valid self-signed EC P-256 * certificates via @peculiar/x509 + Node.js webcrypto, suitable for test mocks. * * Two variants: * - makeSelfSignedCert() Plain cert — satisfies node:crypto X509Certificate parse. * - makeMosaicIssuedCert(opts) Cert with custom Mosaic OID extensions — satisfies the * CRIT-1 OID presence + value checks in CaService.issueCert. */ import { webcrypto } from 'node:crypto'; import { X509CertificateGenerator, Extension, KeyUsagesExtension, KeyUsageFlags, BasicConstraintsExtension, cryptoProvider, } from '@peculiar/x509'; // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- /** * Encode a string as an ASN.1 UTF8String TLV: * 0x0C (tag) + 1-byte length (for strings ≤ 127 bytes) + UTF-8 bytes. * * CaService.issueCert reads the extension value as: * decoder.decode(grantIdExt.value.slice(2)) * i.e. it skips the tag + length byte and decodes the remainder as UTF-8. * So we must produce exactly this encoding as the OCTET STRING content. */ function encodeUtf8String(value: string): Uint8Array { const utf8 = new TextEncoder().encode(value); if (utf8.length > 127) { throw new Error('encodeUtf8String: value too long for single-byte length encoding'); } const buf = new Uint8Array(2 + utf8.length); buf[0] = 0x0c; // ASN.1 UTF8String tag buf[1] = utf8.length; buf.set(utf8, 2); return buf; } // --------------------------------------------------------------------------- // Mosaic OID constants (must match production CaService) // --------------------------------------------------------------------------- const OID_MOSAIC_GRANT_ID = '1.3.6.1.4.1.99999.1'; const OID_MOSAIC_SUBJECT_USER_ID = '1.3.6.1.4.1.99999.2'; // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Generate a minimal self-signed EC P-256 certificate valid for 1 day. * CN=harness-test, no custom extensions. * * Suitable for: * - EnrollmentService.extractCertNotAfter (just needs parseable PEM) * - Any mock that returns certPem / certChainPem without OID checks */ export async function makeSelfSignedCert(): Promise { // Ensure @peculiar/x509 uses Node.js webcrypto (available as globalThis.crypto in Node 19+, // but we set it explicitly here to be safe on all Node 18+ versions). cryptoProvider.set(webcrypto as unknown as Parameters[0]); const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const; const keys = await webcrypto.subtle.generateKey(alg, false, ['sign', 'verify']); const now = new Date(); const tomorrow = new Date(now.getTime() + 86_400_000); const cert = await X509CertificateGenerator.createSelfSigned({ serialNumber: '01', name: 'CN=harness-test', notBefore: now, notAfter: tomorrow, signingAlgorithm: alg, keys, extensions: [ new BasicConstraintsExtension(false), new KeyUsagesExtension(KeyUsageFlags.digitalSignature), ], }); return cert.toString('pem'); } /** * Generate a self-signed EC P-256 certificate that contains the two custom * Mosaic OID extensions required by CaService.issueCert's CRIT-1 check: * OID 1.3.6.1.4.1.99999.1 → mosaic_grant_id (value = grantId) * OID 1.3.6.1.4.1.99999.2 → mosaic_subject_user_id (value = subjectUserId) * * The extension value encoding matches the production parser's `.slice(2)` assumption: * each extension value is an OCTET STRING wrapping an ASN.1 UTF8String TLV. */ export async function makeMosaicIssuedCert(opts: { grantId: string; subjectUserId: string; }): Promise { // Ensure @peculiar/x509 uses Node.js webcrypto. cryptoProvider.set(webcrypto as unknown as Parameters[0]); const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const; const keys = await webcrypto.subtle.generateKey(alg, false, ['sign', 'verify']); const now = new Date(); const tomorrow = new Date(now.getTime() + 86_400_000); const cert = await X509CertificateGenerator.createSelfSigned({ serialNumber: '01', name: 'CN=mosaic-issued-test', notBefore: now, notAfter: tomorrow, signingAlgorithm: alg, keys, extensions: [ new BasicConstraintsExtension(false), new KeyUsagesExtension(KeyUsageFlags.digitalSignature), // mosaic_grant_id — OID 1.3.6.1.4.1.99999.1 new Extension(OID_MOSAIC_GRANT_ID, false, encodeUtf8String(opts.grantId)), // mosaic_subject_user_id — OID 1.3.6.1.4.1.99999.2 new Extension(OID_MOSAIC_SUBJECT_USER_ID, false, encodeUtf8String(opts.subjectUserId)), ], }); return cert.toString('pem'); }