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>
This commit is contained in:
2026-10-09 15:31:54 +02:00
parent 1554ae83c1
commit fcac0a3bfd
28 changed files with 2140 additions and 100 deletions
+162
View File
@@ -0,0 +1,162 @@
import { createPublicKey } from 'node:crypto';
import * as forge from 'node-forge';
import { describeKey, keyIdOf, sha256Hex, spkiDerOf } from './cert-keys';
import { safeBaseName } from './cert-names';
import type { CsrItem, ItemSource } from './cert-types';
/**
* Zertifikatsanfragen (CSR) des Zertifikat-Managers (quick-261009-ikt, D-05, D-16).
*
* Die CSR-Leser von forge koennen nur RSA. Darum wird der ASN.1-Aufbau selbst gelesen
* (CertificationRequestInfo: Version, Inhaber, oeffentlicher Schluessel, Attribute) und der
* Schluessel an node:crypto gegeben. Die Selbstunterschrift wird nicht geprueft: die Anfrage
* dient hier nur der Anzeige und der Zuordnung zu Zertifikat und Schluessel.
*/
const OID_EXTENSION_REQUEST = '1.2.840.113549.1.9.14';
const OID_SUBJECT_ALT_NAME = '2.5.29.17';
type Asn1 = forge.asn1.Asn1;
function children(node: Asn1): Asn1[] {
return Array.isArray(node.value) ? (node.value as Asn1[]) : [];
}
function readAsn1(der: Buffer): Asn1 {
// Typdefinition kennt nur `strict: boolean`; decodeBitStrings aus, damit der Schluessel
// (SPKI) beim erneuten Schreiben Byte fuer Byte dem Original entspricht.
const options = { decodeBitStrings: false } as unknown as boolean;
return forge.asn1.fromDer(forge.util.createBuffer(der.toString('binary')), options);
}
function toBuffer(node: Asn1): Buffer {
return Buffer.from(forge.asn1.toDer(node).getBytes(), 'binary');
}
interface ParsedRequest {
subject: Asn1;
spki: Asn1;
attributes: Asn1 | null;
}
/** Prueft den Aufbau streng genug, dass weder ein Zertifikat noch ein Schluessel als CSR durchgeht. */
function parseRequest(der: Buffer): ParsedRequest {
const root = readAsn1(der);
const [info, algorithm, signature] = children(root);
if (
root.type !== forge.asn1.Type.SEQUENCE ||
children(root).length !== 3 ||
algorithm.type !== forge.asn1.Type.SEQUENCE ||
signature.type !== forge.asn1.Type.BITSTRING ||
info.type !== forge.asn1.Type.SEQUENCE
) {
throw new Error('not a certification request');
}
const [version, subject, spki, attributes] = children(info);
if (
version?.type !== forge.asn1.Type.INTEGER ||
version.value !== '\x00' ||
subject?.type !== forge.asn1.Type.SEQUENCE ||
spki?.type !== forge.asn1.Type.SEQUENCE ||
children(spki).length !== 2 ||
children(spki)[1].type !== forge.asn1.Type.BITSTRING
) {
throw new Error('not a certification request');
}
const hasAttributes =
attributes?.tagClass === forge.asn1.Class.CONTEXT_SPECIFIC && attributes.type === 0;
return { subject, spki, attributes: hasAttributes ? attributes : null };
}
function formatIp(bytes: string): string | null {
if (bytes.length === 4) return [...bytes].map((c) => c.charCodeAt(0)).join('.');
if (bytes.length === 16) {
const groups: string[] = [];
for (let i = 0; i < 16; i += 2) {
groups.push(
((bytes.charCodeAt(i) << 8) | bytes.charCodeAt(i + 1)).toString(16).toUpperCase(),
);
}
return groups.join(':');
}
return null;
}
/** SAN aus dem Attribut „extensionRequest“: DNS-Namen unveraendert, IP-Adressen mit Praefix 'IP:'. */
function sanOf(attributes: Asn1 | null): string[] {
const names: string[] = [];
if (!attributes) return names;
for (const attribute of children(attributes)) {
const [oid, set] = children(attribute);
if (!oid || oid.type !== forge.asn1.Type.OID) continue;
if (forge.asn1.derToOid(oid.value as string) !== OID_EXTENSION_REQUEST) continue;
const extensionList = children(set ?? attribute)[0];
const extensions = extensionList ? children(extensionList) : [];
for (const extension of extensions) {
const parts = children(extension);
if (parts[0]?.type !== forge.asn1.Type.OID) continue;
if (forge.asn1.derToOid(parts[0].value as string) !== OID_SUBJECT_ALT_NAME) continue;
const octets = parts[parts.length - 1];
if (octets?.type !== forge.asn1.Type.OCTETSTRING) continue;
const generalNames = forge.asn1.fromDer(forge.util.createBuffer(octets.value as string));
for (const name of children(generalNames)) {
if (name.tagClass !== forge.asn1.Class.CONTEXT_SPECIFIC) continue;
if (name.type === 2) names.push(name.value as string); // dNSName
if (name.type === 7) {
const ip = formatIp(name.value as string); // iPAddress
if (ip) names.push(`IP:${ip}`);
}
}
}
}
return names;
}
/** forge.pki.RDNAttributesAsArray fehlt in den Typdefinitionen, ist aber Teil von forge (liest nur den Namen). */
const rdnAttributesAsArray = (
forge.pki as unknown as {
RDNAttributesAsArray(rdn: Asn1): { shortName?: string; value: unknown }[];
}
).RDNAttributesAsArray;
function rdnValue(subject: Asn1, shortName: string): string {
const attributes = rdnAttributesAsArray(subject);
const found = attributes.find((a) => a.shortName === shortName);
return typeof found?.value === 'string' ? found.value : '';
}
function pemOf(der: Buffer): string {
const lines = der.toString('base64').match(/.{1,64}/g) ?? [];
return `-----BEGIN CERTIFICATE REQUEST-----\n${lines.join('\n')}\n-----END CERTIFICATE REQUEST-----\n`;
}
/** Baut den Eintrag aus einem DER-CSR. Wirft, wenn es keine Zertifikatsanfrage ist. `keyId` setzt matchKeys. */
export function csrItemFromDer(der: Buffer, source: ItemSource): CsrItem {
const request = parseRequest(der);
const publicKey = createPublicKey({ key: toBuffer(request.spki), format: 'der', type: 'spki' });
const details = describeKey(publicKey);
const cn = rdnValue(request.subject, 'CN');
return {
id: `r-${sha256Hex(der).slice(0, 16)}`,
kind: 'csr',
sources: [{ ...source }],
pem: pemOf(der),
baseName: safeBaseName(cn, 'anfrage'),
cn,
organization: rdnValue(request.subject, 'O'),
san: sanOf(request.attributes),
keyType: details.keyType,
keyBits: details.keyBits,
curve: details.curve,
keyId: null,
certIds: [],
};
}
/** Kennung des Schluessels einer Anfrage (aus dem PEM des Eintrags), fuer die Zuordnung. */
export function csrPublicKeyOf(pem: string): { id: string; spki: Buffer } {
const body = pem.replace(/-----(BEGIN|END) CERTIFICATE REQUEST-----/g, '').replace(/\s+/g, '');
const request = parseRequest(Buffer.from(body, 'base64'));
const publicKey = createPublicKey({ key: toBuffer(request.spki), format: 'der', type: 'spki' });
return { id: keyIdOf(publicKey), spki: spkiDerOf(publicKey) };
}