feat(cert-manager): alle Ausgabeformate, Konvertieren, Bundle und PFX

- build liefert jeden Inhalt in jedem Format: Einzelzertifikat (PEM, DER, PKCS#7 .p7b/.p7c), Fullchain und Nur Kette auch als PKCS#7, Zertifikat und Schlüssel in einer PEM-Datei, PFX, Schlüssel (PKCS#8, klassisch, DER, optional mit Passwort) und CSR (PEM, DER)
- PKCS#7 von Hand aus ASN.1 gebaut, jedes Zertifikat mit seinen eigenen Bytes (RSA und EC)
- PFX schreiben für RSA und EC über einen begrenzten, im finally zurückgesetzten forge-Austausch; Kompatibel (3DES/SHA-1, Vorgabe) oder Modern (AES-256)
- Schlüssel und Passwörter nur in der Anfrage, nie in Antwort, Fehlertext oder Log; falscher Schlüssel: keyMismatch, kein Schlüssel: keyMissing, ohne Passwort: passwordRequired
- Reiter Konvertieren für Zertifikat, Schlüssel und Anfrage; Zusammenführen mit Kettenformat, Zertifikat und Schlüssel sowie PFX (Passwort doppelt, Verschlüsselung wählbar)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 15:46:43 +02:00
parent fcac0a3bfd
commit 65a1dca80f
18 changed files with 1877 additions and 47 deletions
+185 -3
View File
@@ -1,10 +1,11 @@
import { X509Certificate } from 'node:crypto';
import { createPrivateKey, X509Certificate } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import * as forge from 'node-forge';
import { describe, expect, it, vi } from 'vitest';
import { candidatePasswords, keyIdOf } from './cert-keys';
import { detectBlob } from './cert-model';
import { isPkcs12Der, readPkcs12 } from './cert-pkcs12';
import { isPkcs12Der, readPkcs12, writePkcs12 } from './cert-pkcs12';
import type { CertItem, KeyItem } from './cert-types';
const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name));
@@ -110,3 +111,184 @@ describe('detectBlob: PKCS#12', () => {
expect(JSON.stringify(detect('rsa-modern.pfx', PASSWORD))).not.toContain(PASSWORD);
});
});
describe('writePkcs12', () => {
const NEW_PASSWORD = 'Neu-Pass-2026';
const OID_3DES = '1.2.840.113549.1.12.1.3';
const OID_PBES2 = '1.2.840.113549.1.5.13';
const OID_AES256 = '2.16.840.1.101.3.4.1.42';
const OID_LOCAL_KEY_ID = '1.2.840.113549.1.9.21';
const asn1Of = (der: Buffer) =>
forge.asn1.fromDer(forge.util.createBuffer(der.toString('binary')), {
strict: false,
} as unknown as boolean);
const kids = (n: forge.asn1.Asn1) => n.value as forge.asn1.Asn1[];
const oidOf = (n: forge.asn1.Asn1) => forge.asn1.derToOid(n.value as string);
/** Alle SafeBags im Container (Zertifikate unverschluesselt, Schluessel im selben Aufbau). */
function safeBags(der: Buffer): forge.asn1.Asn1[] {
const authSafe = kids(kids(asn1Of(der))[1])[1]; // ContentInfo -> [0] explicit
const inner = kids(authSafe)[0]; // OCTET STRING mit AuthenticatedSafe
const contentInfos = kids(forge.asn1.fromDer(forge.util.createBuffer(inner.value as string)));
const bags: forge.asn1.Asn1[] = [];
for (const ci of contentInfos) {
const octets = kids(kids(ci)[1])[0].value as string;
bags.push(...kids(forge.asn1.fromDer(forge.util.createBuffer(octets))));
}
return bags;
}
const keyBagOf = (der: Buffer) =>
safeBags(der).find(
(b) => oidOf(kids(b)[0]) === forge.pki.oids.pkcs8ShroudedKeyBag,
) as forge.asn1.Asn1;
function localKeyId(bag: forge.asn1.Asn1): string {
const attrs = kids(bag)[2];
for (const attr of kids(attrs)) {
if (oidOf(kids(attr)[0]) === OID_LOCAL_KEY_ID) {
return Buffer.from(kids(kids(attr)[1])[0].value as string, 'binary').toString('hex');
}
}
return '';
}
const certDers = (names: string[]) => names.map((n) => new X509Certificate(fx(n)).raw);
it.each([
['rsa', 'rsa-leaf-key.pem', ['rsa-leaf.pem', 'rsa-inter.pem']],
['ec', 'ec-leaf-key.pem', ['ec-leaf.pem', 'ec-inter.pem']],
] as const)('%s: Round-Trip ueber readPkcs12 in beiden Profilen', (_type, keyFile, certFiles) => {
for (const profile of ['compat', 'modern'] as const) {
const der = writePkcs12({
keyObject: createPrivateKey(fx(keyFile)),
certDers: certDers([...certFiles]),
password: NEW_PASSWORD,
profile,
friendlyName: 'server',
});
expect(isPkcs12Der(der)).toBe(true);
const read = readPkcs12(der, [], NEW_PASSWORD);
expect(read.ok).toBe(true);
if (!read.ok) return;
const fingerprints = read.contents.certDers.map((d) => new X509Certificate(d).fingerprint256);
expect(fingerprints).toEqual(certFiles.map(sha)); // Serverzertifikat zuerst
expect(read.contents.keys).toHaveLength(1);
expect(new X509Certificate(fx(certFiles[0])).checkPrivateKey(read.contents.keys[0].key)).toBe(
true,
);
// falsches Passwort oeffnet nicht
expect(readPkcs12(der, [], 'falsch')).toMatchObject({ ok: false, reason: 'passwordWrong' });
}
});
it('kompatibel: Schluesselbeutel mit pbeWithSHA1And3-KeyTripleDES-CBC', () => {
const der = writePkcs12({
keyObject: createPrivateKey(fx('ec-leaf-key.pem')),
certDers: certDers(['ec-leaf.pem']),
password: NEW_PASSWORD,
profile: 'compat',
friendlyName: 'server',
});
const algorithm = kids(kids(kids(keyBagOf(der))[1])[0])[0];
expect(oidOf(kids(algorithm)[0])).toBe(OID_3DES);
});
it('modern: Schluesselbeutel mit PBES2 und AES-256-CBC', () => {
const der = writePkcs12({
keyObject: createPrivateKey(fx('rsa-leaf-key.pem')),
certDers: certDers(['rsa-leaf.pem']),
password: NEW_PASSWORD,
profile: 'modern',
friendlyName: 'server',
});
const algorithm = kids(kids(kids(keyBagOf(der))[1])[0])[0];
expect(oidOf(kids(algorithm)[0])).toBe(OID_PBES2);
// PBES2-Parameter: { keyDerivation, encryptionScheme { AES-256-CBC, iv } }
const encryptionScheme = kids(kids(algorithm)[1])[1];
expect(oidOf(kids(encryptionScheme)[0])).toBe(OID_AES256);
});
it('Schluesselbeutel und Serverzertifikat tragen dieselbe localKeyId', () => {
const der = writePkcs12({
keyObject: createPrivateKey(fx('ec-leaf-key.pem')),
certDers: certDers(['ec-leaf.pem', 'ec-inter.pem']),
password: NEW_PASSWORD,
profile: 'compat',
friendlyName: 'server',
});
const bags = safeBags(der);
const certBags = bags.filter((b) => oidOf(kids(b)[0]) === forge.pki.oids.certBag);
expect(certBags).toHaveLength(2);
const keyId = localKeyId(keyBagOf(der));
expect(keyId).toMatch(/^[0-9a-f]{40}$/);
expect(localKeyId(certBags[0])).toBe(keyId);
});
it('ohne Schluessel: nur Zertifikate', () => {
const der = writePkcs12({
keyObject: null,
certDers: certDers(['rsa-leaf.pem', 'rsa-inter.pem']),
password: NEW_PASSWORD,
profile: 'compat',
friendlyName: 'server',
});
const read = readPkcs12(der, [], NEW_PASSWORD);
expect(read.ok && read.contents.certDers).toHaveLength(2);
expect(read.ok && read.contents.keys).toHaveLength(0);
});
it('forge ist danach unveraendert (nach Erfolg und nach einem Fehler mittendrin)', () => {
const pki = forge.pki as unknown as Record<string, unknown>;
const before = {
privateKeyToAsn1: pki.privateKeyToAsn1,
wrapRsaPrivateKey: pki.wrapRsaPrivateKey,
certificateToAsn1: pki.certificateToAsn1,
};
writePkcs12({
keyObject: createPrivateKey(fx('rsa-leaf-key.pem')),
certDers: certDers(['rsa-leaf.pem']),
password: NEW_PASSWORD,
profile: 'compat',
friendlyName: 'server',
});
expect(pki.privateKeyToAsn1).toBe(before.privateKeyToAsn1);
expect(pki.wrapRsaPrivateKey).toBe(before.wrapRsaPrivateKey);
expect(pki.certificateToAsn1).toBe(before.certificateToAsn1);
// forge scheitert mitten im Schreiben, waehrend die Durchreicher eingesetzt sind
let seenDuring: unknown;
const spy = vi.spyOn(forge.pkcs12, 'toPkcs12Asn1').mockImplementation(() => {
seenDuring = pki.certificateToAsn1;
throw new Error('boom');
});
try {
expect(() =>
writePkcs12({
keyObject: null,
certDers: certDers(['rsa-leaf.pem']),
password: NEW_PASSWORD,
profile: 'compat',
friendlyName: 'server',
}),
).toThrow('boom');
} finally {
spy.mockRestore();
}
expect(seenDuring).not.toBe(before.certificateToAsn1); // der Durchreicher war wirklich eingesetzt
expect(pki.privateKeyToAsn1).toBe(before.privateKeyToAsn1);
expect(pki.wrapRsaPrivateKey).toBe(before.wrapRsaPrivateKey);
expect(pki.certificateToAsn1).toBe(before.certificateToAsn1);
});
it('das Passwort steht nicht im Container', () => {
const der = writePkcs12({
keyObject: createPrivateKey(fx('rsa-leaf-key.pem')),
certDers: certDers(['rsa-leaf.pem']),
password: NEW_PASSWORD,
profile: 'modern',
friendlyName: 'server',
});
expect(der.includes(Buffer.from(NEW_PASSWORD))).toBe(false);
});
});