fcac0a3bfd
- 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>
217 lines
7.1 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|
|
}
|