Files
tessera-ctl/apps/api/src/cert-manager/cert-chain.spec.ts
T
schalli fcac0a3bfd 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>
2026-10-09 15:31:54 +02:00

225 lines
9.2 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { buildChains, matchKeys } from './cert-chain';
import { detectBlob } from './cert-model';
import type { AnyItem, CertItem, CsrItem, KeyItem } from './cert-types';
const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name));
function load(...names: string[]): CertItem[] {
const items: CertItem[] = [];
names.forEach((name, file) => {
const result = detectBlob(fx(name), { file, path: name, passwords: [] });
for (const item of result.items) if (item.kind === 'certificate') items.push(item);
});
return items;
}
const cnOf = (certs: CertItem[], id: string) => certs.find((c) => c.id === id)?.cn;
const pathCns = (certs: CertItem[], path: string[]) => path.map((id) => cnOf(certs, id));
describe('buildChains', () => {
it('Server + Zwischenzertifikat ohne Wurzel: unvollstaendig, Luecke nach der CA', () => {
const certs = load('rsa-leaf.pem', 'rsa-inter.pem');
const { chains } = buildChains(certs);
expect(chains).toHaveLength(1);
expect(pathCns(certs, chains[0].path)).toEqual(['www.example.test', 'Tessera Test Inter RSA']);
expect(chains[0].complete).toBe(false);
expect(chains[0].rootId).toBeNull();
expect(chains[0].gap).toMatchObject({
kind: 'afterCa',
missingIssuerCn: 'Tessera Test Root RSA',
});
});
it('mit Wurzel: vollstaendig, rootId gesetzt, keine Luecke', () => {
const certs = load('rsa-leaf.pem', 'rsa-inter.pem', 'rsa-root.pem');
const { chains } = buildChains(certs);
expect(chains).toHaveLength(1);
expect(chains[0].complete).toBe(true);
expect(cnOf(certs, chains[0].rootId as string)).toBe('Tessera Test Root RSA');
expect(chains[0].gap).toBeNull();
expect(chains[0].path).toHaveLength(3);
});
it('nur das Serverzertifikat: Luecke nach dem Server mit Adresse des Ausstellers', () => {
const certs = load('rsa-leaf.pem');
const { chains } = buildChains(certs);
expect(chains[0].gap).toMatchObject({
kind: 'afterLeaf',
missingIssuerCn: 'Tessera Test Inter RSA',
aiaUrls: ['http://pki.example.test/rsa-inter.cer'],
});
});
it('gleichnamige CA mit anderem Schluessel (Attrappe) wird nie genommen', () => {
const certs = load(
'rsa-leaf-noaki.pem',
'rsa-inter-decoy.pem',
'rsa-inter.pem',
'rsa-root.pem',
);
const { chains } = buildChains(certs);
expect(chains).toHaveLength(1);
const names = chains[0].path.map((id) => certs.find((c) => c.id === id));
expect(names[1]?.sources[0].path).toBe('rsa-inter.pem');
expect(chains[0].complete).toBe(true);
});
it('nur die Attrappe vorhanden: die Signaturpruefung lehnt sie ab, Luecke nach dem Server', () => {
const certs = load('rsa-leaf-noaki.pem', 'rsa-inter-decoy.pem', 'rsa-root.pem');
const { chains } = buildChains(certs);
expect(chains).toHaveLength(1);
expect(chains[0].path).toHaveLength(1);
expect(chains[0].gap?.kind).toBe('afterLeaf');
});
it('kreuzsigniertes Zwischenzertifikat fuehrt zur zweiten Wurzel', () => {
const certs = load('rsa-leaf.pem', 'rsa-inter-cross.pem', 'rsa-root2.pem');
const { chains } = buildChains(certs);
expect(chains[0].complete).toBe(true);
expect(cnOf(certs, chains[0].rootId as string)).toBe('Tessera Test Root RSA 2');
});
it('beide Varianten vorhanden: Hauptkette ueber rsa-inter und rsa-root, mindestens eine Alternative', () => {
const certs = load('rsa-leaf.pem', 'rsa-inter.pem', 'rsa-inter-cross.pem', 'rsa-root.pem');
const { chains } = buildChains(certs);
expect(chains).toHaveLength(1);
expect(chains[0].complete).toBe(true);
expect(cnOf(certs, chains[0].rootId as string)).toBe('Tessera Test Root RSA');
expect(chains[0].alternatives).toBeGreaterThanOrEqual(1);
});
it('abgelaufenes Zwischenzertifikat wird nicht als Hauptkette genommen', () => {
const certs = load('rsa-leaf.pem', 'rsa-inter-expired.pem', 'rsa-inter.pem', 'rsa-root.pem');
const { chains } = buildChains(certs);
const middle = certs.find((c) => c.id === chains[0].path[1]);
expect(middle?.isExpired).toBe(false);
expect(middle?.sources[0].path).toBe('rsa-inter.pem');
expect(chains[0].alternatives).toBeGreaterThanOrEqual(1);
});
it('die Reihenfolge der Eingabe aendert die Hauptkette nicht', () => {
const names = ['rsa-leaf.pem', 'rsa-inter-expired.pem', 'rsa-inter.pem', 'rsa-root.pem'];
const forward = load(...names);
const reversed = load(...[...names].reverse());
const a = buildChains(forward).chains[0];
const b = buildChains(reversed).chains[0];
expect(a.path).toEqual(b.path);
expect(a.alternatives).toBe(b.alternatives);
});
it('Zwischenzertifikat + Wurzel ohne Serverzertifikat: eine Kette mit dem Zwischenzertifikat als Kopf', () => {
const certs = load('rsa-inter.pem', 'rsa-root.pem');
const { chains } = buildChains(certs);
expect(chains).toHaveLength(1);
expect(cnOf(certs, chains[0].headId)).toBe('Tessera Test Inter RSA');
expect(chains[0].complete).toBe(true);
});
it('selbstsigniertes Serverzertifikat: Kette aus ihm selbst, vollstaendig, ohne Wurzel', () => {
const certs = load('selfsigned-leaf.pem');
const { chains } = buildChains(certs);
expect(chains[0].path).toEqual([certs[0].id]);
expect(chains[0].complete).toBe(true);
expect(chains[0].rootId).toBeNull();
expect(chains[0].gap).toBeNull();
});
it('EC-Kette wird genauso gebaut', () => {
const certs = load('ec-leaf.pem', 'ec-inter.pem', 'ec-root.pem');
const { chains } = buildChains(certs);
expect(pathCns(certs, chains[0].path)).toEqual([
'ec.example.test',
'Tessera Test Inter EC',
'Tessera Test Root EC',
]);
expect(chains[0].complete).toBe(true);
});
it('mit vorgegebenem Kopf wird genau diese Kette gebaut', () => {
const certs = load('rsa-leaf.pem', 'rsa-inter.pem', 'rsa-root.pem');
const inter = certs.find((c) => c.role === 'intermediate') as CertItem;
const { chains } = buildChains(certs, [inter.id]);
expect(chains).toHaveLength(1);
expect(chains[0].headId).toBe(inter.id);
});
it('leere Menge ergibt keine Ketten', () => {
expect(buildChains([])).toEqual({ chains: [] });
});
});
describe('matchKeys', () => {
const PASSWORD = 'Test-Pass-123';
function all(...names: [string, string?][]): AnyItem[] {
const items: AnyItem[] = [];
names.forEach(([name, password], file) => {
const result = detectBlob(fx(name), {
file,
path: name,
passwords: password ? [password] : [],
ownPassword: password ?? '',
});
items.push(...result.items);
});
return items;
}
const certsOf = (items: AnyItem[]) =>
items.filter((i): i is CertItem => i.kind === 'certificate');
const keysOf = (items: AnyItem[]) => items.filter((i): i is KeyItem => i.kind === 'privateKey');
const csrsOf = (items: AnyItem[]) => items.filter((i): i is CsrItem => i.kind === 'csr');
it('ordnet Schluessel und Anfrage dem Serverzertifikat zu (RSA und EC)', () => {
const items = all(
['rsa-leaf.pem'],
['rsa-leaf-key-enc-trad.pem', PASSWORD],
['rsa-leaf.csr'],
['ec-leaf.pem'],
['ec-leaf-key-sec1.der'],
['ec-leaf.csr.der'],
);
matchKeys(certsOf(items), keysOf(items), csrsOf(items));
const certs = certsOf(items);
const rsa = certs.find((c) => c.cn === 'www.example.test') as CertItem;
const ec = certs.find((c) => c.cn === 'ec.example.test') as CertItem;
const rsaKey = keysOf(items).find((k) => k.keyType === 'RSA') as KeyItem;
const ecKey = keysOf(items).find((k) => k.keyType === 'EC') as KeyItem;
const rsaCsr = csrsOf(items).find((r) => r.keyType === 'RSA') as CsrItem;
const ecCsr = csrsOf(items).find((r) => r.keyType === 'EC') as CsrItem;
expect(rsa.keyId).toBe(rsaKey.id);
expect(rsaKey.certIds).toEqual([rsa.id]);
expect(rsa.csrIds).toEqual([rsaCsr.id]);
expect(rsaCsr.keyId).toBe(rsaKey.id);
expect(rsaCsr.certIds).toEqual([rsa.id]);
expect(ec.keyId).toBe(ecKey.id);
expect(ecKey.certIds).toEqual([ec.id]);
expect(ecCsr.keyId).toBe(ecKey.id);
expect(ecCsr.certIds).toEqual([ec.id]);
});
it('ein fremder Schluessel gehoert zu keinem Zertifikat', () => {
const items = all(['rsa-leaf.pem'], ['ec-leaf-key.pem']);
matchKeys(certsOf(items), keysOf(items), csrsOf(items));
expect(certsOf(items)[0].keyId).toBeNull();
expect(keysOf(items)[0].certIds).toEqual([]);
});
it('Anfrage ohne Zertifikat und ohne Schluessel bleibt ohne Zuordnung', () => {
const items = all(['ec-leaf.csr']);
matchKeys(certsOf(items), keysOf(items), csrsOf(items));
expect(csrsOf(items)[0].keyId).toBeNull();
expect(csrsOf(items)[0].certIds).toEqual([]);
});
it('Zwischenzertifikat und Wurzel bekommen nie einen Schluessel, die Zuordnung ist wiederholbar', () => {
const items = all(['rsa-leaf.pem'], ['rsa-inter.pem'], ['rsa-leaf-key.pem']);
matchKeys(certsOf(items), keysOf(items), csrsOf(items));
matchKeys(certsOf(items), keysOf(items), csrsOf(items));
const withKey = certsOf(items).filter((c) => c.keyId !== null);
expect(withKey.map((c) => c.cn)).toEqual(['www.example.test']);
expect(keysOf(items)[0].certIds).toHaveLength(1);
});
});