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>
163 lines
6.3 KiB
TypeScript
163 lines
6.3 KiB
TypeScript
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) };
|
|
}
|