fix(cert-manager): ZIP-Bombe, PEM-Scanner und Umlaut-Passwörter in PFX behoben

- ZIP: eigenes Entpacken mit hartem Deckel (maxOutputLength), Größe, Verhältnis und
  CRC am echten Ergebnis; eine gefälschte Kopfgröße 0 umgeht keine Grenze mehr (CR-01)
- PEM: linearer Scanner statt Regex mit quadratischer Laufzeit, gemeinsam für
  Erkennung und Abruf (CR-02)
- PKCS#12: PBES2/AES leitet das Passwort aus UTF-8 ab, Lesen und Schreiben, mit
  OpenSSL-Gegenprobe für „pässwörd“ und „pw€“ (CR-03)
- Grenzen je Anfrage: höchstens 200 Zertifikate, 50 Schlüssel, 50 Anfragen (413
  tooManyItems); Ableitungsrunden höchstens 1 Million je Verfahren und 6 Millionen je
  Anfrage (protectionTooExpensive) (WR-04, WR-05)
- Upload: Gesamtgrenze beim Empfang, multer-Grenzen für Dateien, Felder, Teile (WR-06)
- build-Leser hinter CORS, damit 413/400 CORS-Kopfzeilen tragen (WR-01)
- Selbstsignierte Wurzeln ohne basicConstraints (Version 1, keyCertSign) sind Wurzeln,
  Serverzertifikate bleiben es (WR-07)
- Adressschutz: IPv6-Adressen in Klammern werden beurteilt; „CA Issuers“-Filter für
  Anzeige und Abruf gemeinsam, eigener Code aiaNotAllowed (IN-01, IN-02)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 17:10:31 +02:00
parent a2fc2cb300
commit ff2226dfbd
40 changed files with 1739 additions and 151 deletions
+158 -1
View File
@@ -1,8 +1,9 @@
import { createPrivateKey, X509Certificate } from 'node:crypto';
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';
@@ -292,3 +293,159 @@ describe('writePkcs12', () => {
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<string, unknown> }).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);
});
});