Files
tessera-ctl/apps/api/src/cert-manager/cert-pkcs12.spec.ts
T
schalli ff2226dfbd 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>
2026-10-09 17:12:01 +02:00

452 lines
18 KiB
TypeScript

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<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);
});
});
// ---------------------------------------------------------------------------
// 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);
});
});