import { createPrivateKey, createPublicKey, X509Certificate } from 'node:crypto'; import { csrPublicKeyOf } from './cert-csr'; import { spkiDerOf } from './cert-keys'; import type { CertItem, ChainGap, ChainInfo, CsrItem, KeyItem } 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(); 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(); 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)) }; } /** * Ordnet Schluessel und Zertifikatsanfragen ihren Zertifikaten zu (D-18) und traegt die Zuordnung * in die Eintraege ein (`keyId`, `certIds`, `csrIds`); vorherige Zuordnungen werden ersetzt. * * - Schluessel und Zertifikat: `cert.checkPrivateKey(key)`. * - Anfrage und Zertifikat oder Schluessel: der oeffentliche Schluessel (SPKI-DER) ist derselbe. * Nie ueber Namen oder Modulus-Zeichenketten: das geht bei EC nicht und waere bei RSA unsauber. */ export function matchKeys(certs: CertItem[], keys: KeyItem[], csrs: CsrItem[]): void { const certObjects = certs.map((item) => { item.keyId = null; item.csrIds = []; try { const x = new X509Certificate(item.pem); return { item, x, spki: spkiDerOf(x.publicKey) }; } catch { return { item, x: null, spki: null }; } }); const keyObjects = keys.map((item) => { item.certIds = []; try { const key = createPrivateKey(item.pem); return { item, key, spki: spkiDerOf(createPublicKey(key)) }; } catch { return { item, key: null, spki: null }; } }); for (const c of certObjects) { if (!c.x) continue; for (const k of keyObjects) { if (!k.key) continue; let matches = false; try { matches = c.x.checkPrivateKey(k.key); } catch { // nicht pruefbar (unbekannter Schluesseltyp): kein Treffer } if (matches) { c.item.keyId ??= k.item.id; k.item.certIds.push(c.item.id); } } } for (const csr of csrs) { csr.keyId = null; csr.certIds = []; let spki: Buffer; try { spki = csrPublicKeyOf(csr.pem).spki; } catch { continue; } const key = keyObjects.find((k) => k.spki?.equals(spki)); if (key) csr.keyId = key.item.id; for (const c of certObjects) { if (c.spki?.equals(spki)) { csr.certIds.push(c.item.id); c.item.csrIds.push(csr.id); } } } }