Files
tessera-ctl/apps/api/src/cert-manager/cert-chain.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

217 lines
7.1 KiB
TypeScript

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