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:
@@ -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) };
|
||||
}
|
||||
Reference in New Issue
Block a user