import { BadRequestException } from '@nestjs/common'; import AdmZip from 'adm-zip'; import * as forge from 'node-forge'; import type { UploadedFileLike } from '../auth/types/auth-user'; /** * Zertifikatspaket (quick-261001-l4q): alles, was ein Aussteller liefert — * Zertifikat mit Kette (.pem/.crt/.cer/.p7b), privater Schluessel (.key), * Zertifikatsanfrage (.csr), PFX/P12, gern als ZIP — auf einmal hochladen, * erkennen, was was ist, und jedes Teil in jedem passenden Format * herunterladen. * * Zustandslos wie der Rest des Moduls: `analyzeBundle` gibt je Teil den * kanonischen PEM-Text zurueck, `exportBundleItem` baut daraus die Datei. * Nichts wird gespeichert, Passwoerter werden nie protokolliert. */ type BundleFile = Pick; export type BundleCertRole = 'end-entity' | 'intermediate' | 'root'; export type BundleItemKind = 'certificate' | 'privateKey' | 'csr'; export interface BundleItem { id: string; kind: BundleItemKind; /** Nur bei Zertifikaten. */ role?: BundleCertRole; /** Dateien, in denen dieses Teil gefunden wurde (Duplikate zusammengefasst). */ sources: string[]; /** Kanonischer PEM-Text — Grundlage fuer jeden Export. */ pem: string; /** Vorschlag fuer den Dateinamen ohne Endung, aus dem CN abgeleitet. */ baseName: string; cn: string; organization: string; issuerCn: string; notBefore: string | null; notAfter: string | null; isExpired: boolean | null; daysLeft: number | null; san: string[]; keyType: string; keyBits: number; serialNumber: string; sha256: string; /** Zertifikat: id des passenden Schluessels; Schluessel/CSR: id des passenden Zertifikats. */ matchId: string | null; /** Zertifikat: ids der Kette darueber (Aussteller, dessen Aussteller ...). */ chainIds: string[]; /** Formate, die `exportBundleItem` fuer dieses Teil liefern kann. */ formats: BundleExportFormat[]; } export interface BundleAnalysis { items: BundleItem[]; /** PFX/P12 oder verschluesselte Schluessel, die ohne (richtiges) Passwort nicht lesbar sind. */ locked: string[]; /** Dateien ohne erkennbares Zertifikat, Schluessel oder CSR. */ ignored: string[]; } export type BundleExportFormat = | 'crt' | 'cer' | 'fullchain' | 'p7b' | 'pfx' | 'key' | 'key-rsa' | 'key-der' | 'csr' | 'csr-der'; export interface BundleExportInput { kind: BundleItemKind; pem: string; format: BundleExportFormat; baseName?: string; /** Zertifikat: PEMs der Kette darueber (fuer Fullchain/P7B/PFX). */ chain?: string[]; /** Zertifikat: PEM des passenden privaten Schluessels (fuer PFX). */ keyPem?: string; /** PFX: Passwort fuer die neue Datei. */ password?: string; } export interface BundleExportFile { filename: string; /** Base64 */ content: string; mimeType: string; } const MAX_ZIP_ENTRIES = 100; const MAX_ENTRY_BYTES = 5 * 1024 * 1024; const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----[\s\S]+?-----END \1-----/g; // --------------------------------------------------------------------------- // Hilfen // --------------------------------------------------------------------------- function binary(buffer: Buffer): forge.util.ByteStringBuffer { return forge.util.createBuffer(buffer.toString('binary')); } function bytesToBase64(bytes: string): string { return Buffer.from(forge.util.bytesToHex(bytes), 'hex').toString('base64'); } function textToBase64(text: string): string { return Buffer.from(text, 'utf-8').toString('base64'); } function sha256Of(cert: forge.pki.Certificate): string { const md = forge.md.sha256.create(); md.update(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes()); return (md.digest().toHex().match(/.{2}/g) ?? []).join(':').toUpperCase(); } function field(name: forge.pki.Certificate['subject'], short: string): string { return (name.getField(short)?.value as string | undefined) ?? ''; } /** Dateiname ohne Pfad und ohne gefaehrliche Zeichen, z. B. „*.example.de“ -> „wildcard.example.de“. */ export function safeBaseName(raw: string, fallback: string): string { const cleaned = raw .replace(/^\*\./, 'wildcard.') .replace(/[^A-Za-z0-9._-]+/g, '_') .replace(/^[._]+/, '') .slice(0, 80); return cleaned || fallback; } function certRole(cert: forge.pki.Certificate): BundleCertRole { const bc = cert.getExtension('basicConstraints') as { cA?: boolean } | null; if (!bc?.cA) return 'end-entity'; return cert.subject.hash === cert.issuer.hash ? 'root' : 'intermediate'; } function publicKeyInfo(key: unknown): { keyType: string; keyBits: number; modulus: string } { // node-forge liefert RSA-Schluessel mit `n`; EC-Schluessel kennt es nur // eingeschraenkt (siehe Kommentar in CertManagerService.parseCert). const k = key as { n?: forge.jsbn.BigInteger }; if (k?.n) return { keyType: 'RSA', keyBits: k.n.bitLength(), modulus: k.n.toString(16) }; return { keyType: 'EC', keyBits: 0, modulus: '' }; } function sanOf(extensions: unknown[] | undefined): string[] { const ext = (extensions ?? []).find((e) => (e as { name?: string }).name === 'subjectAltName') as | { altNames?: { type: number; value?: string; ip?: string }[] } | undefined; return (ext?.altNames ?? []).map((n) => n.type === 2 ? (n.value ?? '') : `IP:${n.ip ?? n.value ?? ''}`, ); } // --------------------------------------------------------------------------- // Einsammeln: Dateien (inkl. ZIP) -> rohe Teile // --------------------------------------------------------------------------- interface RawKey { source: string; pem: string; modulus: string; keyType: string; keyBits: number; } interface RawCsr { source: string; pem: string; } interface Collected { certs: { source: string; cert: forge.pki.Certificate }[]; keys: RawKey[]; csrs: RawCsr[]; locked: string[]; ignored: string[]; } function expandZips(files: BundleFile[]): { name: string; buffer: Buffer }[] { const out: { name: string; buffer: Buffer }[] = []; for (const file of files) { if (!file.originalname.toLowerCase().endsWith('.zip')) { out.push({ name: file.originalname, buffer: file.buffer }); continue; } let zip: AdmZip; try { zip = new AdmZip(file.buffer); } catch { throw new BadRequestException(`"${file.originalname}" is not a readable ZIP archive`); } const entries = zip .getEntries() .filter((e) => !e.isDirectory && !e.entryName.startsWith('__MACOSX/')); if (entries.length > MAX_ZIP_ENTRIES) { throw new BadRequestException(`"${file.originalname}" contains too many files`); } for (const entry of entries) { // Groesse aus dem Kopf pruefen, BEVOR entpackt wird (Zip-Bombe). if (entry.header.size > MAX_ENTRY_BYTES) { throw new BadRequestException( `"${entry.entryName}" in "${file.originalname}" is too large`, ); } const name = entry.entryName.split('/').pop() ?? entry.entryName; out.push({ name, buffer: entry.getData() }); } } return out; } function addKey(c: Collected, source: string, privateKey: forge.pki.rsa.PrivateKey): void { const info = publicKeyInfo(privateKey); const pem = forge.pki.privateKeyInfoToPem( forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(privateKey)), ); c.keys.push({ source, pem, ...info }); } function collectPemText(c: Collected, source: string, text: string, password: string): boolean { let found = false; for (const match of text.matchAll(PEM_BLOCK)) { const [block, type] = match; try { if (type === 'CERTIFICATE' || type === 'TRUSTED CERTIFICATE') { c.certs.push({ source, cert: forge.pki.certificateFromPem(block) }); found = true; } else if (type === 'PRIVATE KEY' || type === 'RSA PRIVATE KEY') { const key = forge.pki.privateKeyFromPem(block) as forge.pki.rsa.PrivateKey; addKey(c, source, key); found = true; } else if (type === 'ENCRYPTED PRIVATE KEY') { const key = password ? forge.pki.decryptRsaPrivateKey(block, password) : null; if (key) addKey(c, source, key as forge.pki.rsa.PrivateKey); else c.locked.push(source); found = true; } else if (type === 'EC PRIVATE KEY') { // node-forge kann EC nicht umrechnen — Teil bleibt im Original erhalten. c.keys.push({ source, pem: block, modulus: '', keyType: 'EC', keyBits: 0 }); found = true; } else if (type === 'CERTIFICATE REQUEST' || type === 'NEW CERTIFICATE REQUEST') { c.csrs.push({ source, pem: block.replace(/NEW CERTIFICATE REQUEST/g, 'CERTIFICATE REQUEST'), }); found = true; } else if (type === 'PKCS7') { const p7 = forge.pkcs7.messageFromPem(block); for (const cert of 'certificates' in p7 ? p7.certificates : []) c.certs.push({ source, cert }); found = true; } } catch { // PKCS#8 mit EC-Schluessel o. ae.: node-forge kann ihn nicht lesen — // im Original behalten statt zu verwerfen. if (type === 'PRIVATE KEY') { c.keys.push({ source, pem: block, modulus: '', keyType: 'EC', keyBits: 0 }); found = true; } } } return found; } function collectPfx(c: Collected, source: string, buffer: Buffer, password: string): void { let p12: forge.pkcs12.Pkcs12Pfx | null = null; for (const candidate of password ? [password, ''] : ['']) { try { p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(binary(buffer)), candidate); break; } catch { // naechstes Passwort versuchen } } if (!p12) { c.locked.push(source); return; } for (const bag of p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag] ?? []) { if (bag.cert) c.certs.push({ source, cert: bag.cert }); } for (const oid of [forge.pki.oids.pkcs8ShroudedKeyBag, forge.pki.oids.keyBag]) { for (const bag of p12.getBags({ bagType: oid })[oid] ?? []) { if (bag.key) addKey(c, source, bag.key as forge.pki.rsa.PrivateKey); } } } function collectDer(c: Collected, source: string, buffer: Buffer): boolean { try { const asn1 = forge.asn1.fromDer(binary(buffer)); try { c.certs.push({ source, cert: forge.pki.certificateFromAsn1(asn1) }); return true; } catch { /* kein einzelnes Zertifikat */ } try { const p7 = forge.pkcs7.messageFromAsn1(asn1); const certs = 'certificates' in p7 ? p7.certificates : []; for (const cert of certs) c.certs.push({ source, cert }); if (certs.length > 0) return true; } catch { /* kein PKCS#7 */ } try { forge.pki.certificationRequestFromAsn1(asn1); const body = forge.asn1.toDer(asn1).getBytes(); c.csrs.push({ source, pem: forge.pem.encode({ type: 'CERTIFICATE REQUEST', body }) }); return true; } catch { /* keine CSR */ } } catch { /* kein DER */ } return false; } function collect(files: BundleFile[], password: string): Collected { const c: Collected = { certs: [], keys: [], csrs: [], locked: [], ignored: [] }; for (const { name, buffer } of expandZips(files)) { const ext = name.split('.').pop()?.toLowerCase() ?? ''; if (ext === 'pfx' || ext === 'p12') { collectPfx(c, name, buffer, password); continue; } const head = buffer.subarray(0, 4096).toString('latin1'); const found = head.includes('-----BEGIN') ? collectPemText(c, name, buffer.toString('utf-8'), password) : collectDer(c, name, buffer); if (!found) c.ignored.push(name); } return c; } // --------------------------------------------------------------------------- // analyzeBundle // --------------------------------------------------------------------------- const CERT_FORMATS: BundleExportFormat[] = ['crt', 'cer', 'fullchain', 'p7b', 'pfx']; export function analyzeBundle(files: BundleFile[], password = ''): BundleAnalysis { if (files.length === 0) throw new BadRequestException('No files provided'); const c = collect(files, password); // Zertifikate nach Fingerabdruck zusammenfassen (PEM und PFX enthalten oft dieselben). const certMap = new Map }>(); for (const { source, cert } of c.certs) { const fp = sha256Of(cert); const entry = certMap.get(fp) ?? { cert, sources: new Set() }; entry.sources.add(source); certMap.set(fp, entry); } const now = Date.now(); const certItems: BundleItem[] = [...certMap.entries()].map(([fp, { cert, sources }]) => { const info = publicKeyInfo(cert.publicKey); const cn = field(cert.subject, 'CN'); const notAfter = cert.validity.notAfter; const role = certRole(cert); return { id: `cert-${fp.replace(/:/g, '').slice(0, 16).toLowerCase()}`, kind: 'certificate', role, sources: [...sources], pem: forge.pki.certificateToPem(cert), baseName: safeBaseName(cn, role === 'end-entity' ? 'zertifikat' : 'ca'), cn, organization: field(cert.subject, 'O'), issuerCn: field(cert.issuer, 'CN'), notBefore: cert.validity.notBefore.toISOString(), notAfter: notAfter.toISOString(), isExpired: notAfter.getTime() < now, daysLeft: Math.ceil((notAfter.getTime() - now) / 86_400_000), san: sanOf(cert.extensions), keyType: info.keyType, keyBits: info.keyBits, serialNumber: cert.serialNumber, sha256: fp, matchId: null, chainIds: [], formats: CERT_FORMATS, // nur intern fuer Kette/Zuordnung, wird unten entfernt _cert: cert, _modulus: info.modulus, } as BundleItem & { _cert: forge.pki.Certificate; _modulus: string }; }); // Kette: zu jedem Zertifikat den Aussteller im Paket suchen. type Internal = BundleItem & { _cert: forge.pki.Certificate; _modulus: string }; const internals = certItems as Internal[]; for (const item of internals) { let current = item._cert; const seen = new Set([item.id]); for (let depth = 0; depth < 10; depth++) { if (current.subject.hash === current.issuer.hash) break; const issuer = internals.find( (o) => !seen.has(o.id) && o._cert.subject.hash === current.issuer.hash, ); if (!issuer) break; item.chainIds.push(issuer.id); seen.add(issuer.id); current = issuer._cert; } } // Schluessel: Duplikate zusammenfassen, dem Zertifikat zuordnen. const keyMap = new Map }>(); for (const key of c.keys) { const id = key.modulus || key.pem; const entry = keyMap.get(id) ?? { key, sources: new Set() }; entry.sources.add(key.source); keyMap.set(id, entry); } const keyItems: BundleItem[] = [...keyMap.values()].map(({ key, sources }, i) => { const cert = key.modulus ? internals.find((o) => o._modulus === key.modulus) : undefined; const id = `key-${i + 1}`; if (cert) cert.matchId = id; const isRsa = key.keyType === 'RSA'; return { id, kind: 'privateKey', sources: [...sources], pem: key.pem, baseName: cert?.baseName ?? safeBaseName( sources .values() .next() .value?.replace(/\.[^.]+$/, '') ?? '', 'schluessel', ), cn: cert?.cn ?? '', organization: '', issuerCn: '', notBefore: null, notAfter: null, isExpired: null, daysLeft: null, san: [], keyType: key.keyType, keyBits: key.keyBits, serialNumber: '', sha256: '', matchId: cert?.id ?? null, chainIds: [], formats: isRsa ? ['key', 'key-rsa', 'key-der'] : ['key'], }; }); // CSRs: Duplikate zusammenfassen, Details lesen, dem Zertifikat zuordnen. const csrMap = new Map }>(); for (const csr of c.csrs) { const norm = csr.pem.replace(/\s+/g, ''); const entry = csrMap.get(norm) ?? { pem: csr.pem, sources: new Set() }; entry.sources.add(csr.source); csrMap.set(norm, entry); } const csrItems: BundleItem[] = [...csrMap.values()].map(({ pem, sources }, i) => { let cn = ''; let organization = ''; let info = { keyType: '', keyBits: 0, modulus: '' }; let san: string[] = []; try { const csr = forge.pki.certificationRequestFromPem(pem); cn = field(csr.subject as forge.pki.Certificate['subject'], 'CN'); organization = field(csr.subject as forge.pki.Certificate['subject'], 'O'); info = publicKeyInfo(csr.publicKey); const ext = csr.getAttribute({ name: 'extensionRequest' }) as { extensions?: unknown[]; } | null; san = sanOf(ext?.extensions); } catch { // EC-CSR: node-forge liest sie nicht — Teil bleibt trotzdem herunterladbar. } const cert = info.modulus ? internals.find((o) => o._modulus === info.modulus) : undefined; return { id: `csr-${i + 1}`, kind: 'csr', sources: [...sources], pem, baseName: cert?.baseName ?? safeBaseName(cn, 'anfrage'), cn, organization, issuerCn: '', notBefore: null, notAfter: null, isExpired: null, daysLeft: null, san, keyType: info.keyType, keyBits: info.keyBits, serialNumber: '', sha256: '', matchId: cert?.id ?? null, chainIds: [], formats: ['csr', 'csr-der'], }; }); // Reihenfolge: Serverzertifikat(e), Zwischen-, Stammzertifikate, Schluessel, CSR. const roleOrder: Record = { 'end-entity': 0, intermediate: 1, root: 2 }; internals.sort( (a, b) => roleOrder[a.role ?? 'end-entity'] - roleOrder[b.role ?? 'end-entity'] || b.chainIds.length - a.chainIds.length, ); const certsClean: BundleItem[] = internals.map(({ _cert, _modulus, ...rest }) => rest); return { items: [...certsClean, ...keyItems, ...csrItems], locked: [...new Set(c.locked)], ignored: [...new Set(c.ignored)], }; } // --------------------------------------------------------------------------- // exportBundleItem // --------------------------------------------------------------------------- const MIME = { pem: 'application/x-pem-file', der: 'application/x-x509-ca-cert', p7b: 'application/x-pkcs7-certificates', pfx: 'application/x-pkcs12', key: 'application/x-pem-file', octet: 'application/octet-stream', } as const; function pemBody(pem: string): string { const [msg] = forge.pem.decode(pem); if (!msg) throw new Error('no PEM block'); return msg.body; } export function exportBundleItem(input: BundleExportInput): BundleExportFile { const { kind, pem, format, chain = [], keyPem, password } = input; const base = safeBaseName( input.baseName ?? '', kind === 'csr' ? 'anfrage' : kind === 'privateKey' ? 'schluessel' : 'zertifikat', ); if (!pem || typeof pem !== 'string') throw new BadRequestException('No PEM provided'); if (format === 'pfx' && (!password || password.trim() === '')) { throw new BadRequestException('A password is required for PFX output'); } try { if (kind === 'certificate') { const cert = forge.pki.certificateFromPem(pem); const chainCerts = chain.map((p) => forge.pki.certificateFromPem(p)); switch (format) { case 'crt': return { filename: `${base}.crt`, content: textToBase64(forge.pki.certificateToPem(cert)), mimeType: MIME.pem, }; case 'cer': return { filename: `${base}.cer`, content: bytesToBase64(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes()), mimeType: MIME.der, }; case 'fullchain': { const text = [cert, ...chainCerts].map((x) => forge.pki.certificateToPem(x)).join(''); return { filename: `${base}-fullchain.pem`, content: textToBase64(text), mimeType: MIME.pem, }; } case 'p7b': { const p7 = forge.pkcs7.createSignedData(); for (const x of [cert, ...chainCerts]) p7.addCertificate(x); const text = forge.pem.encode({ type: 'PKCS7', body: forge.asn1.toDer(p7.toAsn1()).getBytes(), }); return { filename: `${base}.p7b`, content: textToBase64(text), mimeType: MIME.p7b }; } case 'pfx': { const key = keyPem ? (forge.pki.privateKeyFromPem(keyPem) as forge.pki.rsa.PrivateKey) : null; const p12 = forge.pkcs12.toPkcs12Asn1( // null = reines Zertifikatsbuendel ohne Schluessel (siehe // CertManagerService.mergeCerts). key, [cert, ...chainCerts], password as string, // oben geprueft: PFX verlangt ein Passwort { algorithm: '3des', friendlyName: input.baseName || undefined }, ); return { filename: `${base}.pfx`, content: bytesToBase64(forge.asn1.toDer(p12).getBytes()), mimeType: MIME.pfx, }; } } } else if (kind === 'privateKey') { switch (format) { case 'key': return { filename: `${base}.key`, content: textToBase64(`${pem.trim()}\n`), mimeType: MIME.key, }; case 'key-rsa': { const key = forge.pki.privateKeyFromPem(pem); return { filename: `${base}.rsa.key`, content: textToBase64(forge.pki.privateKeyToPem(key)), mimeType: MIME.key, }; } case 'key-der': { const key = forge.pki.privateKeyFromPem(pem); const info = forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(key)); return { filename: `${base}.key.der`, content: bytesToBase64(forge.asn1.toDer(info).getBytes()), mimeType: MIME.octet, }; } } } else if (kind === 'csr') { switch (format) { case 'csr': return { filename: `${base}.csr`, content: textToBase64(`${pem.trim()}\n`), mimeType: MIME.pem, }; case 'csr-der': return { filename: `${base}.csr.der`, content: bytesToBase64(pemBody(pem)), mimeType: MIME.octet, }; } } } catch { // Passwort und Schluessel nie protokollieren oder zurueckgeben. throw new BadRequestException(`Failed to export ${kind} as ${format}`); } throw new BadRequestException(`Unsupported format "${format}" for ${kind}`); }