import * as crypto from 'node:crypto'; /** * Certificate Authority for the ACME server. * Generates a self-signed root CA and signs certificates from CSRs. * Uses @peculiar/x509 (already a project dependency). */ export class AcmeServerCA { private caKeyPair!: CryptoKeyPair; private caCert!: InstanceType; private caCertPem!: string; private certValidityDays: number; constructor(private options: { commonName?: string; validityDays?: number; certValidityDays?: number } = {}) { this.certValidityDays = options.certValidityDays ?? 90; } async init(): Promise { const x509 = await import('@peculiar/x509'); const { webcrypto } = crypto; x509.cryptoProvider.set(webcrypto as any); // Generate RSA key pair for the CA this.caKeyPair = await webcrypto.subtle.generateKey( { name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256', }, true, ['sign', 'verify'], ) as CryptoKeyPair; const cn = this.options.commonName ?? 'SmartACME Test CA'; const validityDays = this.options.validityDays ?? 3650; const notBefore = new Date(); const notAfter = new Date(); notAfter.setDate(notAfter.getDate() + validityDays); // Create self-signed root CA certificate this.caCert = await x509.X509CertificateGenerator.createSelfSigned({ serialNumber: this.randomSerialNumber(), name: `CN=${cn}`, notBefore, notAfter, signingAlgorithm: { name: 'RSASSA-PKCS1-v1_5' }, keys: this.caKeyPair, extensions: [ new x509.BasicConstraintsExtension(true, undefined, true), new x509.KeyUsagesExtension( x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true, ), await x509.SubjectKeyIdentifierExtension.create(this.caKeyPair.publicKey), ], }); this.caCertPem = this.caCert.toString('pem'); } /** * Sign a CSR and return a PEM certificate chain (end-entity + root CA). * @param csrDerBase64url - The CSR in base64url-encoded DER format (as sent by ACME clients) */ async signCsr(csrDerBase64url: string): Promise { const x509 = await import('@peculiar/x509'); const { webcrypto } = crypto; x509.cryptoProvider.set(webcrypto as any); // Parse the CSR const csrDer = Buffer.from(csrDerBase64url, 'base64url'); const csr = new x509.Pkcs10CertificateRequest(csrDer); // Extract Subject Alternative Names from CSR extensions const sanNames: { type: 'dns'; value: string }[] = []; const sanExt = csr.extensions?.find( (ext) => ext.type === '2.5.29.17', // OID for SubjectAlternativeName ); if (sanExt) { const san = new x509.SubjectAlternativeNameExtension(sanExt.rawData); if (san.names) { const jsonNames = san.names.toJSON(); for (const name of jsonNames) { if (name.type === 'dns') { sanNames.push({ type: 'dns', value: name.value }); } } } } // If no SAN found, use CN from subject if (sanNames.length === 0) { const cnMatch = csr.subject.match(/CN=([^,]+)/); if (cnMatch) { sanNames.push({ type: 'dns', value: cnMatch[1] }); } } const notBefore = new Date(); const notAfter = new Date(); notAfter.setDate(notAfter.getDate() + this.certValidityDays); // Sign the certificate const cert = await x509.X509CertificateGenerator.create({ serialNumber: this.randomSerialNumber(), subject: csr.subject, issuer: this.caCert.subject, notBefore, notAfter, signingAlgorithm: { name: 'RSASSA-PKCS1-v1_5' }, publicKey: csr.publicKey, signingKey: this.caKeyPair.privateKey, extensions: [ new x509.BasicConstraintsExtension(false, undefined, true), new x509.KeyUsagesExtension( x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment, true, ), new x509.ExtendedKeyUsageExtension( ['1.3.6.1.5.5.7.3.1'], // serverAuth true, ), new x509.SubjectAlternativeNameExtension(sanNames), await x509.AuthorityKeyIdentifierExtension.create(this.caKeyPair.publicKey), ], }); // Return PEM chain: end-entity cert + root CA cert const certPem = cert.toString('pem'); return `${certPem}\n${this.caCertPem}`; } getCaCertPem(): string { return this.caCertPem; } private randomSerialNumber(): string { return crypto.randomBytes(16).toString('hex'); } }