feat(cert-manager): Zusammenführen mit Fullchain und Nur Kette

- Kettenbau mit Aussteller- und Signaturprüfung (checkIssued plus verify), Wurzel nur auf Wunsch
- build-Route für Fullchain und Nur Kette (PEM), eigene JSON-Grenze 512 KiB mit 413 und Code tooLarge
- Reiter Zusammenführen mit Kettenansicht, Hinweis bei fehlendem Zwischenzertifikat

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 15:03:23 +02:00
parent 75ea83fc01
commit 30118a2401
24 changed files with 1428 additions and 34 deletions
@@ -0,0 +1,152 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { buildChains } from './cert-chain';
import { detectBlob } from './cert-model';
import type { CertItem } 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: [] });
});
});