diff --git a/package-lock.json b/package-lock.json index d13fff7..fd4c4ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "azure-acme-provisioner", - "version": "0.4.6", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "azure-acme-provisioner", - "version": "0.4.6", + "version": "0.5.0", "license": "MIT", "dependencies": { "@azure/arm-authorization": "^9.0.0", @@ -18,7 +18,8 @@ "@peculiar/x509": "^2.0.0", "acme-client": "^5.4.0", "commander": "^14.0.0", - "express": "^5.2.1" + "express": "^5.2.1", + "node-forge": "^1.4.0" }, "bin": { "azure-acme-provisioner": "dist/cli.js" @@ -26,6 +27,7 @@ "devDependencies": { "@types/express": "^5.0.6", "@types/node": "^24.0.0", + "@types/node-forge": "^1.3.14", "rimraf": "^6.1.3", "typescript": "^6.0.0" }, @@ -692,6 +694,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/node-forge": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", + "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", diff --git a/package.json b/package.json index 32e7c89..309008a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "azure-acme-provisioner", - "version": "0.4.6", + "version": "0.5.0", "author": { "name": "Sławomir Koszewski", "url": "https://github.com/skoszewski" @@ -46,11 +46,13 @@ "@peculiar/x509": "^2.0.0", "acme-client": "^5.4.0", "commander": "^14.0.0", - "express": "^5.2.1" + "express": "^5.2.1", + "node-forge": "^1.4.0" }, "devDependencies": { "@types/express": "^5.0.6", "@types/node": "^24.0.0", + "@types/node-forge": "^1.3.14", "rimraf": "^6.1.3", "typescript": "^6.0.0" }, diff --git a/src/cli.ts b/src/cli.ts index 233144a..eb5dd96 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -34,6 +34,7 @@ function applyOverrides(options: Record): void { if (options['renewalThreshold']) process.env['ACME_RENEWAL_THRESHOLD_DAYS'] = String(options['renewalThreshold']); if (options['logLevel']) process.env['ACME_LOG_LEVEL'] = String(options['logLevel']); if (options['http']) process.env['ACME_HTTP_PORT'] = String(options['http']); + if (options['pem']) process.env['ACME_CERT_FORMAT'] = 'pem'; if (options['keyvaultName'] && !options['keyvaultUrl']) process.env['ACME_KEYVAULT_URL'] = `https://${options['keyvaultName']}.vault.azure.net`; } @@ -59,6 +60,7 @@ sharedOptions( .command('run', { isDefault: true }) .description('Scan DNS zones and issue or renew certificates') .option('--http ', 'Use HTTP-01 challenge on the given port instead of DNS-01') + .option('--pem', 'Store certificate as PEM bundle instead of PFX (PKCS#12)') .option('--dry-run', 'Show what would be done without making changes') ).action(async (options: Record) => { applyOverrides(options); @@ -120,6 +122,7 @@ sharedOptions( .command('renew ') .description('Force-renew a certificate for a specific domain, bypassing the renewal threshold') .option('--http ', 'Use HTTP-01 challenge on the given port instead of DNS-01') + .option('--pem', 'Store certificate as PEM bundle instead of PFX (PKCS#12)') ).action(async (domain: string, options: Record) => { applyOverrides(options); const config = loadConfig(); @@ -191,6 +194,18 @@ sharedOptions( } }); +sharedOptions( + program + .command('convert ') + .description('Convert a stored certificate between PFX (PKCS#12) and PEM format') + .option('--pem', 'Convert to PEM bundle instead of PFX (PKCS#12)') +).action(async (domain: string, options: Record) => { + applyOverrides(options); + const config = loadConfig(); + const provisioner = new Provisioner(config); + await provisioner.convert(domain, config.pfx ? 'pfx' : 'pem'); +}); + program.parseAsync(process.argv).catch((err: unknown) => { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); diff --git a/src/lib/config.ts b/src/lib/config.ts index daee2df..3a5753b 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -10,6 +10,7 @@ export interface Config { dnsChallengeTtl: number; logLevel: 'debug' | 'info' | 'warn' | 'error'; httpChallengePort?: number; + pfx: boolean; } export class ConfigError extends Error { @@ -65,5 +66,6 @@ export function loadConfig(): Config { dnsChallengeTtl: optionalEnvInt('ACME_DNS_CHALLENGE_TTL', 60), logLevel: logLevel as Config['logLevel'], httpChallengePort: optionalEnvInt('ACME_HTTP_PORT', 0) || undefined, + pfx: optionalEnv('ACME_CERT_FORMAT', 'pfx') !== 'pem', }; } diff --git a/src/lib/keyvault.ts b/src/lib/keyvault.ts index cb5f484..1dad298 100644 --- a/src/lib/keyvault.ts +++ b/src/lib/keyvault.ts @@ -47,15 +47,16 @@ export class KeyVaultStore { return expiresOn.getTime() - Date.now() <= thresholdMs; } - async importCertificate(name: string, pemBundle: string): Promise { + async importCertificate(name: string, cert: string | Buffer, format: 'pem' | 'pfx' = 'pem'): Promise { const options: ImportCertificateOptions = { policy: { - contentType: 'application/x-pem-file', + contentType: format === 'pfx' ? 'application/x-pkcs12' : 'application/x-pem-file', issuerName: 'Unknown', subject: 'CN=unknown', }, }; - await this.certClient.importCertificate(name, Buffer.from(pemBundle), options); + const certBuffer = typeof cert === 'string' ? Buffer.from(cert) : cert; + await this.certClient.importCertificate(name, certBuffer, options); } } diff --git a/src/lib/pfx.ts b/src/lib/pfx.ts new file mode 100644 index 0000000..1978f67 --- /dev/null +++ b/src/lib/pfx.ts @@ -0,0 +1,52 @@ +import * as forge from 'node-forge'; + +export interface PemBundle { + privateKeyPem: string; + certPem: string; + chainPem: string; +} + +export function pemToPfx(privateKeyPem: string, certPem: string, chainPem: string, password = ''): Buffer { + const key = forge.pki.privateKeyFromPem(privateKeyPem); + const cert = forge.pki.certificateFromPem(certPem); + const chain = chainPem + .split(/(?=-----BEGIN CERTIFICATE-----)/) + .filter(Boolean) + .map(p => forge.pki.certificateFromPem(p)); + + const p12 = forge.pkcs12.toPkcs12Asn1(key, [cert, ...chain], password, { algorithm: '3des' }); + const der = forge.asn1.toDer(p12).getBytes(); + return Buffer.from(der, 'binary'); +} + +export function pfxToPem(pfxBuffer: Buffer, password = ''): PemBundle { + const p12Asn1 = forge.asn1.fromDer(forge.util.createBuffer(pfxBuffer.toString('binary'))); + const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password); + + const keyBags = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag }); + const certBags = p12.getBags({ bagType: forge.pki.oids.certBag }); + + const keyBag = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag]?.[0]; + const allCertBags = certBags[forge.pki.oids.certBag] ?? []; + + if (!keyBag?.key) throw new Error('No private key found in PFX'); + if (allCertBags.length === 0) throw new Error('No certificates found in PFX'); + + const [first, ...rest] = allCertBags.map(bag => forge.pki.certificateToPem(bag.cert!)); + return { + privateKeyPem: forge.pki.privateKeyToPem(keyBag.key), + certPem: first, + chainPem: rest.join(''), + }; +} + +export function parsePemBundle(bundle: string): PemBundle { + const blocks = bundle.match(/-----BEGIN [^-]+-----[\s\S]+?-----END [^-]+-----/g) ?? []; + const privateKeyPem = blocks.find(b => b.includes('PRIVATE KEY')) ?? ''; + const certs = blocks.filter(b => b.includes('CERTIFICATE')); + return { + privateKeyPem, + certPem: certs[0] ?? '', + chainPem: certs.slice(1).join(''), + }; +} diff --git a/src/lib/provisioner.ts b/src/lib/provisioner.ts index 45c2d87..9551335 100644 --- a/src/lib/provisioner.ts +++ b/src/lib/provisioner.ts @@ -5,6 +5,7 @@ import { Config } from './config.js'; import { DnsChallengeManager, DomainRecord, scanDnsZones } from './dns.js'; import { HttpChallengeServer } from './http-challenge.js'; import { KeyVaultStore } from './keyvault.js'; +import { parsePemBundle, pemToPfx, pfxToPem } from './pfx.js'; export interface ProvisioningResult { domainsScanned: number; @@ -104,8 +105,10 @@ export class Provisioner { const fqdns = group.map(d => d.fqdn); const issued = await this.acme.orderCertificate(fqdns, this.challengeHandler); - const pemBundle = issued.privateKeyPem + issued.certificatePem + issued.chainPem; - await this.store.importCertificate(certName, pemBundle); + const [cert, format] = this.config.pfx + ? [pemToPfx(issued.privateKeyPem, issued.certificatePem, issued.chainPem), 'pfx' as const] + : [issued.privateKeyPem + issued.certificatePem + issued.chainPem, 'pem' as const]; + await this.store.importCertificate(certName, cert, format); if (status === 'missing') { result.certificatesIssued.push(primary.fqdn); @@ -131,6 +134,31 @@ export class Provisioner { return result; } + async convert(domain: string, targetFormat: 'pem' | 'pfx'): Promise { + const certName = domainToCertName(domain); + const cert = await this.store.getCertificate(certName); + if (!cert) throw new Error(`Certificate not found in KeyVault: ${certName}`); + + const currentFormat = cert.policy?.contentType === 'application/x-pkcs12' ? 'pfx' : 'pem'; + if (currentFormat === targetFormat) { + this.log(`Certificate ${certName} is already in ${targetFormat.toUpperCase()} format`); + return; + } + + const secretValue = await this.store.getSecret(certName); + if (!secretValue) throw new Error(`Certificate secret not found: ${certName}`); + + if (currentFormat === 'pem') { + const { privateKeyPem, certPem, chainPem } = parsePemBundle(secretValue); + await this.store.importCertificate(certName, pemToPfx(privateKeyPem, certPem, chainPem), 'pfx'); + } else { + const { privateKeyPem, certPem, chainPem } = pfxToPem(Buffer.from(secretValue, 'base64')); + await this.store.importCertificate(certName, privateKeyPem + certPem + chainPem, 'pem'); + } + + this.log(`Certificate ${certName} converted to ${targetFormat.toUpperCase()}`); + } + async download(domain: string): Promise { const certName = domainToCertName(domain); const pem = await this.store.getSecret(certName);