import { createPrivateKey, createPublicKey, X509Certificate } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import * as forge from 'node-forge'; import { describe, expect, it, vi } from 'vitest'; import { MAX_KDF_ITERATIONS, RequestBudget } from './cert-budget'; import { candidatePasswords, keyIdOf } from './cert-keys'; import { detectBlob } from './cert-model'; import { isPkcs12Der, readPkcs12, writePkcs12 } 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); }); }); 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; 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); }); }); // --------------------------------------------------------------------------- // Review CR-03: Passwoerter mit Umlauten und Eurozeichen (UTF-8 wie OpenSSL 3, Windows, Java) // --------------------------------------------------------------------------- /** Die drei Ableitungsfunktionen von forge, die readPkcs12 und writePkcs12 zeitweise austauschen. */ function forgeKdf(): unknown[] { const pbe = (forge.pki as unknown as { pbe: Record }).pbe; return [forge.pkcs5.pbkdf2, pbe.generatePkcs12Key, forge.pkcs12.generateKey]; } const UMLAUT_PASSWORDS = [ ['pässwörd', 'umlaut'], ['pw€', 'euro'], ] as const; describe('PKCS#12 mit Umlaut- und Euro-Passwoertern (OpenSSL-Dateien lesen)', () => { const files = UMLAUT_PASSWORDS.flatMap(([password, name]) => (['modern', 'compat'] as const).map( (profile) => [`rsa-${name}-${profile}.pfx`, password] as const, ), ); it.each(files)('%s: das richtige Passwort oeffnet die Datei', (file, password) => { const result = readPkcs12(fx(file), [password], password); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.contents.certDers).toHaveLength(3); expect(result.contents.keys).toHaveLength(1); expect(result.contents.keys[0].key.asymmetricKeyType).toBe('rsa'); }); it.each(files)('%s: ein falsches Passwort bleibt passwordWrong', (file) => { expect(readPkcs12(fx(file), ['passwoerd'], 'passwoerd')).toEqual({ ok: false, reason: 'passwordWrong', }); }); it('forge ist nach dem Lesen unveraendert (PBKDF2 und PKCS#12-Ableitung)', () => { const before = forgeKdf(); readPkcs12(fx('rsa-umlaut-modern.pfx'), ['pässwörd'], 'pässwörd'); readPkcs12(fx('rsa-umlaut-modern.pfx'), ['falsch'], 'falsch'); expect(forgeKdf()).toEqual(before); }); }); /** Der verschluesselte Schluessel (EncryptedPrivateKeyInfo) aus einem PFX, ohne forges Entschluesselung. */ function shroudedKeyDer(pfx: Buffer): Buffer { const OID_SHROUDED = '1.2.840.113549.1.12.10.1.2'; const root = forge.asn1.fromDer(forge.util.createBuffer(pfx.toString('binary'))); const authSafeContent = (root.value as forge.asn1.Asn1[])[1]; const octets = ((authSafeContent.value as forge.asn1.Asn1[])[1].value as forge.asn1.Asn1[])[0]; const authSafe = forge.asn1.fromDer(forge.util.createBuffer(octets.value as string)); for (const info of authSafe.value as forge.asn1.Asn1[]) { const wrapped = ((info.value as forge.asn1.Asn1[])[1].value as forge.asn1.Asn1[])[0]; if (typeof wrapped.value !== 'string') continue; // verschluesselte Zertifikate const safeContents = forge.asn1.fromDer(forge.util.createBuffer(wrapped.value)); for (const bag of safeContents.value as forge.asn1.Asn1[]) { const [oid, content] = bag.value as forge.asn1.Asn1[]; if (forge.asn1.derToOid(oid.value as string) === OID_SHROUDED) { const encrypted = (content.value as forge.asn1.Asn1[])[0]; return Buffer.from(forge.asn1.toDer(encrypted).getBytes(), 'binary'); } } } throw new Error('no shrouded key bag'); } describe('writePkcs12 mit Umlaut- und Euro-Passwoertern (fuer OpenSSL, Windows und Java lesbar)', () => { const key = createPrivateKey(fx('rsa-leaf-key.pem')); const certs = ['rsa-leaf.pem', 'rsa-inter.pem'].map((f) => new X509Certificate(fx(f)).raw); it.each( UMLAUT_PASSWORDS.flatMap( ([password]) => [ ['modern', password], ['compat', password], ] as const, ), )('%s mit %s: der Schluessel oeffnet sich mit UTF-8 (wie OpenSSL) und das Passwort ist lesbar', (profile, password) => { const pfx = writePkcs12({ keyObject: key, certDers: certs, password, profile, friendlyName: 'server', }); // Unabhaengig von forge: node:crypto leitet PBES2 aus den UTF-8-Bytes ab (wie OpenSSL 3). if (profile === 'modern') { const opened = createPrivateKey({ key: shroudedKeyDer(pfx), format: 'der', type: 'pkcs8', passphrase: password, }); expect(keyIdOf(createPublicKey(opened))).toBe(keyIdOf(createPublicKey(key))); } const back = readPkcs12(pfx, [password], password); expect(back.ok).toBe(true); if (back.ok) expect(back.contents.keys).toHaveLength(1); }); }); // --------------------------------------------------------------------------- // Review WR-04: Obergrenze fuer Ableitungsrunden // --------------------------------------------------------------------------- describe('PKCS#12: zu aufwendiger Passwortschutz', () => { it('2 000 000 Runden: tooExpensive, schnell und ohne zu rechnen', () => { const started = performance.now(); const result = readPkcs12(fx('rsa-expensive.pfx'), ['Test-Pass-123'], 'Test-Pass-123'); expect(result).toEqual({ ok: false, reason: 'tooExpensive' }); expect(performance.now() - started).toBeLessThan(1000); }); it('detectBlob meldet protectionTooExpensive statt gesperrt', () => { const r = detectBlob(fx('rsa-expensive.pfx'), { file: 0, path: 'teuer.pfx', passwords: ['Test-Pass-123'], ownPassword: 'Test-Pass-123', }); expect(r.items).toEqual([]); expect(r.locked).toEqual([]); expect(r.ignored).toEqual([{ file: 0, path: 'teuer.pfx', reason: 'protectionTooExpensive' }]); }); it('die Grenze steht bei einer Million Runden', () => { expect(MAX_KDF_ITERATIONS).toBe(1_000_000); const budget = new RequestBudget(); expect(() => budget.spendKdf(1_000_000)).not.toThrow(); expect(() => new RequestBudget().spendKdf(1_000_001)).toThrow(); }); it('die Summe je Anfrage ist begrenzt (viele Versuche mit hoher, erlaubter Rundenzahl)', () => { const budget = new RequestBudget(); let accepted = 0; try { for (let i = 0; i < 100; i++) { budget.spendKdf(600_000); accepted++; } } catch { // erwartet } expect(accepted).toBe(10); }); it('forge ist nach einer abgelehnten Datei unveraendert', () => { const before = forgeKdf(); readPkcs12(fx('rsa-expensive.pfx'), ['Test-Pass-123'], 'Test-Pass-123'); expect(forgeKdf()).toEqual(before); }); });