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