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