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
+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);