feat(cert-manager): Schlüssel, PFX und CSR erkennen, Passwort je Datei

- Private Schlüssel (PKCS#1, PKCS#8, SEC1; PEM und DER; unverschlüsselt, verschlüsselt, klassisch verschlüsselt) für RSA und EC über node:crypto
- PKCS#12 lesen (OpenSSL 3, kompatibel, RC2; EC-Zertifikate und -Schlüssel), auch ohne Endung und im ZIP
- Zertifikatsanfragen (CSR) als PEM und DER mit Inhaber, SAN und Schlüssel
- Zuordnung von Schlüssel und Anfrage zum Zertifikat (checkPrivateKey, SPKI-Vergleich)
- Feld passwords je Datei, gesperrte Dateien fragen nach dem Passwort; kein Passwort in Antwort oder Log
- Oberfläche: Passwortfeld mit Anzeigen/Verbergen, Schlüssel- und Anfragekarten im Reiter Analysieren

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 15:31:54 +02:00
parent 1554ae83c1
commit fcac0a3bfd
28 changed files with 2140 additions and 100 deletions
@@ -0,0 +1,112 @@
import { X509Certificate } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { candidatePasswords, keyIdOf } from './cert-keys';
import { detectBlob } from './cert-model';
import { isPkcs12Der, readPkcs12 } from './cert-pkcs12';
import type { CertItem, KeyItem } from './cert-types';
const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name));
const PASSWORD = 'Test-Pass-123';
const sha = (name: string) => new X509Certificate(fx(name)).fingerprint256;
describe('readPkcs12', () => {
const cases = [
['rsa-modern.pfx', 'rsa', ['rsa-leaf.pem', 'rsa-inter.pem', 'rsa-root.pem']],
['rsa-compat.pfx', 'rsa', ['rsa-leaf.pem', 'rsa-inter.pem', 'rsa-root.pem']],
['rsa-legacy.pfx', 'rsa', ['rsa-leaf.pem', 'rsa-inter.pem', 'rsa-root.pem']],
['ec-modern.pfx', 'ec', ['ec-leaf.pem', 'ec-inter.pem', 'ec-root.pem']],
['ec-compat.pfx', 'ec', ['ec-leaf.pem', 'ec-inter.pem', 'ec-root.pem']],
] as const;
it.each(
cases,
)('%s: Zertifikate (auch EC) und Schluessel mit dem Passwort', (name, type, certFiles) => {
const result = readPkcs12(fx(name), [PASSWORD], PASSWORD);
expect(result.ok).toBe(true);
if (!result.ok) return;
const fingerprints = result.contents.certDers.map((d) => new X509Certificate(d).fingerprint256);
expect(fingerprints.sort()).toEqual(certFiles.map(sha).sort());
expect(result.contents.keys).toHaveLength(1);
expect(result.contents.keys[0].key.asymmetricKeyType).toBe(type);
expect(result.contents.keys[0].shrouded).toBe(true);
});
it('rsa-nopass.pfx oeffnet sich ohne Passwort (leeres Passwort)', () => {
const result = readPkcs12(fx('rsa-nopass.pfx'), []);
expect(result.ok).toBe(true);
if (result.ok) expect(result.contents.certDers).toHaveLength(3);
});
it('ohne Passwort: passwordNeeded, mit falschem: passwordWrong', () => {
expect(readPkcs12(fx('rsa-modern.pfx'), [])).toEqual({ ok: false, reason: 'passwordNeeded' });
expect(readPkcs12(fx('rsa-modern.pfx'), ['falsch'], 'falsch')).toEqual({
ok: false,
reason: 'passwordWrong',
});
});
it('probiert die uebrigen Passwoerter der Anfrage, wenn das eigene nicht passt', () => {
const result = readPkcs12(fx('ec-compat.pfx'), ['falsch', 'noch-falsch', PASSWORD], 'falsch');
expect(result.ok).toBe(true);
});
it('erkennt PKCS#12 am Aufbau, nicht an der Endung', () => {
expect(isPkcs12Der(fx('rsa-modern.bin'))).toBe(true);
expect(isPkcs12Der(fx('rsa-leaf.cer'))).toBe(false);
expect(isPkcs12Der(Buffer.from('kein Container'))).toBe(false);
});
});
describe('detectBlob: PKCS#12', () => {
function detect(name: string, own = '', others: string[] = []) {
return detectBlob(fx(name), {
file: 0,
path: name,
passwords: candidatePasswords(own, [own, ...others]),
ownPassword: own,
});
}
it('ec-compat.pfx: EC-Zertifikate und EC-Schluessel werden Eintraege mit dem Container als Quelle', () => {
const r = detect('ec-compat.pfx', PASSWORD);
expect(r.locked).toEqual([]);
const certs = r.items.filter((i): i is CertItem => i.kind === 'certificate');
expect(certs.map((c) => c.cn).sort()).toEqual([
'Tessera Test Inter EC',
'Tessera Test Root EC',
'ec.example.test',
]);
expect(certs.every((c) => c.keyType === 'EC')).toBe(true);
expect(certs[0].sources).toEqual([{ file: 0, path: 'ec-compat.pfx' }]);
const key = r.items.find((i): i is KeyItem => i.kind === 'privateKey');
const leaf = new X509Certificate(fx('ec-leaf.pem'));
expect(key?.id).toBe(keyIdOf(leaf.publicKey));
expect(key?.wasEncrypted).toBe(true);
});
it('rsa-modern.bin wird am Inhalt erkannt', () => {
const r = detect('rsa-modern.bin', PASSWORD);
expect(r.items.filter((i) => i.kind === 'certificate')).toHaveLength(3);
expect(r.items.filter((i) => i.kind === 'privateKey')).toHaveLength(1);
});
it('ohne Passwort: gesperrt mit Container pkcs12', () => {
expect(detect('rsa-modern.pfx').locked).toEqual([
{ file: 0, path: 'rsa-modern.pfx', container: 'pkcs12', reason: 'passwordNeeded' },
]);
expect(detect('rsa-modern.pfx', 'falsch').locked[0].reason).toBe('passwordWrong');
});
it('das Passwort einer anderen Datei oeffnet auch diese Datei', () => {
const r = detect('rsa-legacy.pfx', '', [PASSWORD]);
expect(r.locked).toEqual([]);
expect(r.items.length).toBeGreaterThan(0);
});
it('die Antwort enthaelt das Passwort nicht', () => {
expect(JSON.stringify(detect('rsa-modern.pfx', PASSWORD))).not.toContain(PASSWORD);
});
});