fix: update version to 0.5.0, add support for PEM and PFX formats, and implement certificate conversion functionality

This commit is contained in:
2026-05-22 13:30:41 +02:00
parent 2790334110
commit e0f8b1b402
7 changed files with 122 additions and 10 deletions
+15 -3
View File
@@ -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",
+4 -2
View File
@@ -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"
},
+15
View File
@@ -34,6 +34,7 @@ function applyOverrides(options: Record<string, unknown>): 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 <port>', '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<string, unknown>) => {
applyOverrides(options);
@@ -120,6 +122,7 @@ sharedOptions(
.command('renew <domain>')
.description('Force-renew a certificate for a specific domain, bypassing the renewal threshold')
.option('--http <port>', '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<string, unknown>) => {
applyOverrides(options);
const config = loadConfig();
@@ -191,6 +194,18 @@ sharedOptions(
}
});
sharedOptions(
program
.command('convert <domain>')
.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<string, unknown>) => {
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);
+2
View File
@@ -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',
};
}
+4 -3
View File
@@ -47,15 +47,16 @@ export class KeyVaultStore {
return expiresOn.getTime() - Date.now() <= thresholdMs;
}
async importCertificate(name: string, pemBundle: string): Promise<void> {
async importCertificate(name: string, cert: string | Buffer, format: 'pem' | 'pfx' = 'pem'): Promise<void> {
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);
}
}
+52
View File
@@ -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(''),
};
}
+30 -2
View File
@@ -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<void> {
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<string> {
const certName = domainToCertName(domain);
const pem = await this.store.getSecret(certName);