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:
@@ -0,0 +1,148 @@
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
import type { CertItem, ChainGap, ChainInfo } from './cert-types';
|
||||
|
||||
/**
|
||||
* Kettenbau des Zertifikat-Managers (quick-261009-ikt, D-18). Reine Funktionen, kein Netz.
|
||||
*
|
||||
* Aussteller eines Zertifikats C ist jedes andere Zertifikat I der Menge mit
|
||||
* `C.checkIssued(I) && C.verify(I.publicKey)`: checkIssued vergleicht Namen, Schluesselkennungen
|
||||
* und Schluesselverwendung, die echte Signaturpruefung entscheidet. Nur beides zusammen
|
||||
* schuetzt vor einer gleichnamigen CA mit anderem Schluessel. Ein Abgleich nur ueber Namen
|
||||
* waere falsch (kreuzsignierte und neu ausgestellte Zwischenzertifikate tragen denselben Namen).
|
||||
*/
|
||||
|
||||
const MAX_DEPTH = 10;
|
||||
const MAX_PATHS = 200;
|
||||
const MAX_ALTERNATIVES = 10;
|
||||
|
||||
interface Node {
|
||||
item: CertItem;
|
||||
x: X509Certificate;
|
||||
/** Menge der Aussteller, die beide Pruefungen bestehen (Indizes in `nodes`) */
|
||||
issuers: number[];
|
||||
}
|
||||
|
||||
function toNodes(certs: CertItem[]): Node[] {
|
||||
const nodes: Node[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of certs) {
|
||||
if (seen.has(item.id)) continue;
|
||||
seen.add(item.id);
|
||||
try {
|
||||
nodes.push({ item, x: new X509Certificate(item.pem), issuers: [] });
|
||||
} catch {
|
||||
// kein lesbares Zertifikat: gehoert nicht in eine Kette
|
||||
}
|
||||
}
|
||||
nodes.forEach((child, ci) => {
|
||||
nodes.forEach((candidate, ii) => {
|
||||
if (ci === ii) return;
|
||||
try {
|
||||
if (child.x.checkIssued(candidate.x) && child.x.verify(candidate.x.publicKey)) {
|
||||
child.issuers.push(ii);
|
||||
}
|
||||
} catch {
|
||||
// nicht pruefbar (z. B. unbekannter Schluesseltyp): kein Aussteller
|
||||
}
|
||||
});
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
interface FoundPath {
|
||||
indices: number[];
|
||||
/** endet an einem selbstsignierten Zertifikat */
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
function findPaths(nodes: Node[], start: number): FoundPath[] {
|
||||
const found: FoundPath[] = [];
|
||||
const walk = (indices: number[]) => {
|
||||
if (found.length >= MAX_PATHS) return;
|
||||
const last = nodes[indices[indices.length - 1]];
|
||||
if (last.item.selfSigned) {
|
||||
found.push({ indices: [...indices], complete: true });
|
||||
return;
|
||||
}
|
||||
const next = last.issuers.filter((i) => !indices.includes(i));
|
||||
if (next.length === 0 || indices.length >= MAX_DEPTH) {
|
||||
found.push({ indices: [...indices], complete: false });
|
||||
return;
|
||||
}
|
||||
for (const i of next) walk([...indices, i]);
|
||||
};
|
||||
walk([start]);
|
||||
return found;
|
||||
}
|
||||
|
||||
function notCurrentlyValid(item: CertItem, now: number): boolean {
|
||||
return Date.parse(item.notBefore) > now || Date.parse(item.notAfter) < now;
|
||||
}
|
||||
|
||||
function rank(nodes: Node[], paths: FoundPath[], now: number): FoundPath[] {
|
||||
const score = (p: FoundPath) => ({
|
||||
complete: p.complete ? 0 : 1,
|
||||
invalid: p.indices.filter((i) => notCurrentlyValid(nodes[i].item, now)).length,
|
||||
length: p.indices.length,
|
||||
firstIssuerEnd: p.indices.length > 1 ? Date.parse(nodes[p.indices[1]].item.notAfter) : 0,
|
||||
key: p.indices.map((i) => nodes[i].item.sha256).join('|'),
|
||||
});
|
||||
const scored = paths.map((p) => ({ p, s: score(p) }));
|
||||
scored.sort(
|
||||
(a, b) =>
|
||||
a.s.complete - b.s.complete ||
|
||||
a.s.invalid - b.s.invalid ||
|
||||
a.s.length - b.s.length ||
|
||||
b.s.firstIssuerEnd - a.s.firstIssuerEnd ||
|
||||
(a.s.key < b.s.key ? -1 : a.s.key > b.s.key ? 1 : 0),
|
||||
);
|
||||
return scored.map((e) => e.p);
|
||||
}
|
||||
|
||||
function defaultHeads(nodes: Node[]): number[] {
|
||||
const endEntities = nodes.flatMap((n, i) => (n.item.role === 'end-entity' ? [i] : []));
|
||||
if (endEntities.length > 0) return endEntities;
|
||||
const issuing = new Set<number>();
|
||||
for (const n of nodes) for (const i of n.issuers) issuing.add(i);
|
||||
return nodes.flatMap((_, i) => (issuing.has(i) ? [] : [i]));
|
||||
}
|
||||
|
||||
function chainOf(nodes: Node[], head: number, now: number): ChainInfo {
|
||||
const ranked = rank(nodes, findPaths(nodes, head), now);
|
||||
const primary = ranked[0];
|
||||
const last = nodes[primary.indices[primary.indices.length - 1]].item;
|
||||
let gap: ChainGap | null = null;
|
||||
if (!primary.complete) {
|
||||
gap = {
|
||||
certId: last.id,
|
||||
kind: primary.indices.length === 1 && last.role === 'end-entity' ? 'afterLeaf' : 'afterCa',
|
||||
missingIssuerCn: last.issuerCn,
|
||||
aiaUrls: [...last.aiaIssuerUrls],
|
||||
};
|
||||
}
|
||||
return {
|
||||
headId: nodes[head].item.id,
|
||||
path: primary.indices.map((i) => nodes[i].item.id),
|
||||
rootId: primary.complete && last.role === 'root' ? last.id : null,
|
||||
complete: primary.complete,
|
||||
gap,
|
||||
alternatives: Math.min(ranked.length - 1, MAX_ALTERNATIVES),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut je Kopf eine Kette (Kopf zuerst, dann jeder Aussteller). Standardkoepfe: jedes Serverzertifikat,
|
||||
* sonst jedes Zertifikat, das kein anderes ausgestellt hat. `headIds` waehlt eigene Koepfe.
|
||||
* Das Ergebnis haengt nicht von der Reihenfolge der Eingabe ab.
|
||||
*/
|
||||
export function buildChains(certs: CertItem[], headIds?: string[]): { chains: ChainInfo[] } {
|
||||
const nodes = toNodes(certs);
|
||||
const heads = headIds
|
||||
? headIds.flatMap((id) => {
|
||||
const index = nodes.findIndex((n) => n.item.id === id);
|
||||
return index < 0 ? [] : [index];
|
||||
})
|
||||
: defaultHeads(nodes);
|
||||
const now = Date.now();
|
||||
return { chains: heads.map((h) => chainOf(nodes, h, now)) };
|
||||
}
|
||||
Reference in New Issue
Block a user