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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user