import * as crypto from 'node:crypto'; import type { IAcmeCsrOptions } from './acme.interfaces.js'; /** * All cryptographic operations for the ACME protocol. * Uses node:crypto for key gen, JWK, JWS signing. * Uses @peculiar/x509 for CSR generation (no native Node.js CSR API). */ export class AcmeCrypto { /** * Generate an RSA private key in PEM format */ static createRsaPrivateKey(modulusLength = 2048): string { const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); return privateKey; } /** * Export public JWK from PEM private key, keys sorted alphabetically per RFC 7638 */ static getJwk(keyPem: string): Record { const keyObj = crypto.createPublicKey(keyPem); const jwk = keyObj.export({ format: 'jwk' }) as Record; if (jwk.kty === 'RSA') { return { e: jwk.e, kty: jwk.kty, n: jwk.n }; } else if (jwk.kty === 'EC') { return { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }; } throw new Error(`Unsupported key type: ${jwk.kty}`); } /** * Compute JWK Thumbprint (SHA-256, base64url) per RFC 7638 */ static getJwkThumbprint(jwk: Record): string { let canonical: string; if (jwk.kty === 'RSA') { canonical = JSON.stringify({ e: jwk.e, kty: jwk.kty, n: jwk.n }); } else if (jwk.kty === 'EC') { canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }); } else { throw new Error(`Unsupported key type: ${jwk.kty}`); } const hash = crypto.createHash('sha256').update(canonical).digest(); return hash.toString('base64url'); } /** * Create a flattened JWS for ACME requests (RFC 7515) * payload=null means POST-as-GET (empty string payload) */ static createJws( keyPem: string, url: string, payload: any | null, options: { nonce: string; kid?: string; jwk?: Record }, ): { protected: string; payload: string; signature: string } { const header: Record = { alg: AcmeCrypto.getAlg(keyPem), nonce: options.nonce, url, }; if (options.kid) { header.kid = options.kid; } else if (options.jwk) { header.jwk = options.jwk; } else { header.jwk = AcmeCrypto.getJwk(keyPem); } const protectedB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); const payloadB64 = payload !== null ? Buffer.from(JSON.stringify(payload)).toString('base64url') : ''; const signingInput = `${protectedB64}.${payloadB64}`; const keyObj = crypto.createPrivateKey(keyPem); const alg = AcmeCrypto.getAlg(keyPem); let signature: Buffer; if (alg.startsWith('RS')) { signature = crypto.sign('sha256', Buffer.from(signingInput), keyObj); } else if (alg.startsWith('ES')) { signature = crypto.sign('sha256', Buffer.from(signingInput), { key: keyObj, dsaEncoding: 'ieee-p1363', }); } else { throw new Error(`Unsupported algorithm: ${alg}`); } return { protected: protectedB64, payload: payloadB64, signature: signature.toString('base64url'), }; } /** * Create a CSR (PKCS#10) via @peculiar/x509 * Returns [privateKeyPem, csrPem] */ static async createCsr( options: IAcmeCsrOptions, existingKeyPem?: string, ): Promise<[string, string]> { const x509 = await import('@peculiar/x509'); const { webcrypto } = crypto; x509.cryptoProvider.set(webcrypto as any); let keys: CryptoKeyPair; let keyPem: string; if (existingKeyPem) { keys = await AcmeCrypto.importKeyPairToWebCrypto(existingKeyPem, webcrypto); keyPem = existingKeyPem; } else { keys = (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 pkcs8 = await webcrypto.subtle.exportKey('pkcs8', keys.privateKey); const b64 = Buffer.from(pkcs8).toString('base64'); const lines = b64.match(/.{1,64}/g)!; keyPem = `-----BEGIN PRIVATE KEY-----\n${lines.join('\n')}\n-----END PRIVATE KEY-----\n`; } // Collect all DNS names for SAN (CN is always included) const sanNames: string[] = [options.commonName]; if (options.altNames) { for (const name of options.altNames) { if (!sanNames.includes(name)) { sanNames.push(name); } } } const csr = await x509.Pkcs10CertificateRequestGenerator.create({ name: `CN=${options.commonName}`, keys, signingAlgorithm: { name: 'RSASSA-PKCS1-v1_5' }, extensions: [ new x509.SubjectAlternativeNameExtension( sanNames.map((name) => ({ type: 'dns' as const, value: name })), ), ], }); // Convert to PEM const csrPem = csr.toString('pem'); return [keyPem, csrPem]; } /** * Convert PEM to raw DER Buffer (strip headers, decode base64) */ static pemToBuffer(pem: string): Buffer { const lines = pem .split('\n') .filter((line) => !line.startsWith('-----') && line.trim().length > 0); return Buffer.from(lines.join(''), 'base64'); } /** * Determine JWS algorithm from key type */ private static getAlg(keyPem: string): string { const keyObj = crypto.createPrivateKey(keyPem); const keyType = keyObj.asymmetricKeyType; if (keyType === 'rsa') return 'RS256'; if (keyType === 'ec') { const details = keyObj.asymmetricKeyDetails; if (details?.namedCurve === 'prime256v1' || details?.namedCurve === 'P-256') return 'ES256'; if (details?.namedCurve === 'secp384r1' || details?.namedCurve === 'P-384') return 'ES384'; return 'ES256'; } throw new Error(`Unsupported key type: ${keyType}`); } /** * Import a PEM private key into WebCrypto as a CryptoKeyPair */ private static async importKeyPairToWebCrypto( keyPem: string, wc: typeof crypto.webcrypto, ): Promise { const keyObj = crypto.createPrivateKey(keyPem); const pkcs8Der = keyObj.export({ type: 'pkcs8', format: 'der' }); const privateKey = await wc.subtle.importKey( 'pkcs8', pkcs8Der, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, true, ['sign'], ); const pubKeyObj = crypto.createPublicKey(keyPem); const spkiDer = pubKeyObj.export({ type: 'spki', format: 'der' }); const publicKey = await wc.subtle.importKey( 'spki', spkiDer, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, true, ['verify'], ); return { privateKey: privateKey as unknown as CryptoKey, publicKey: publicKey as unknown as CryptoKey }; } }