import { createPrivateKey, type KeyObject, X509Certificate } from 'node:crypto'; import * as forge from 'node-forge'; import { buildChains } from './cert-chain'; import { csrItemFromDer } from './cert-csr'; import { exportKey, type KeyExportFormat } from './cert-keys'; import { certItemFromDer } from './cert-model'; import { safeBaseName } from './cert-names'; import { writePkcs12 } from './cert-pkcs12'; import { buildTemplate, isTemplateId } from './cert-templates'; import { type BuildContent, type BuildFile, type BuildInput, type BuildResult, type CertItem, type CsrItem, certError, } from './cert-types'; /** * Ausgabe-Bausteine des Zertifikat-Managers (quick-261009-ikt, D-19). * Jeder Inhalt in jedem Format laeuft durch die eine Funktion buildOutput. Die Reihenfolge baut die API * immer selbst aus den gesendeten Zertifikaten (buildChains); eine vom Browser mitgeschickte Reihenfolge * wird nie uebernommen. Schluessel und Passwoerter werden nur verarbeitet, nie gespeichert oder * protokolliert, und erscheinen in keiner Fehlermeldung. */ export { safeBaseName }; const MIME = { pem: 'application/x-pem-file', der: 'application/pkix-cert', pkcs7: 'application/x-pkcs7-certificates', pfx: 'application/x-pkcs12', binary: 'application/octet-stream', csr: 'application/pkcs10', } as const; /** Erlaubte Formate je Inhalt; das erste ist die Vorgabe. */ const FORMATS: Record = { leaf: ['pem', 'der', 'p7b', 'p7c'], fullchain: ['pem', 'p7b', 'p7c'], chain: ['pem', 'p7b', 'p7c'], leafKey: ['pem'], pfx: ['pfx'], key: ['pkcs8', 'traditional', 'pkcs8-der'], csr: ['pem', 'der'], template: ['template'], }; function parseCertificate(pem: unknown): CertItem { if (typeof pem !== 'string' || pem.trim() === '') { certError('notACertificate', 400, 'Value is not a certificate'); } try { return certItemFromDer(new X509Certificate(pem).raw, { file: 0, path: '' }); } catch { return certError('notACertificate', 400, 'Value is not a certificate'); } } function parseKey(pem: string | undefined): KeyObject { if (typeof pem !== 'string' || pem.trim() === '') { certError('keyMissing', 400, 'A private key is required'); } try { return createPrivateKey(pem); } catch { return certError('invalidInput', 400, 'keyPem is not a readable private key'); } } const CSR_BLOCK = /-----BEGIN (?:NEW )?CERTIFICATE REQUEST-----([\s\S]*?)-----END (?:NEW )?CERTIFICATE REQUEST-----/; function parseCsr(pem: string | undefined): { der: Buffer; item: CsrItem } { const body = typeof pem === 'string' ? CSR_BLOCK.exec(pem)?.[1] : undefined; if (!body) certError('invalidInput', 400, 'csrPem is not a certificate request'); try { const der = Buffer.from(body.replace(/\s+/g, ''), 'base64'); return { der, item: csrItemFromDer(der, { file: 0, path: '' }) }; } catch { return certError('invalidInput', 400, 'csrPem is not a certificate request'); } } function pemBlock(label: string, der: Buffer): string { const lines = der.toString('base64').match(/.{1,64}/g) ?? []; return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`; } /** PEM-Bloecke in Kettenreihenfolge, jeder genau einmal mit abschliessendem Zeilenumbruch. */ function joinPem(certs: CertItem[]): string { return certs.map((c) => `${c.pem.trim()}\n`).join(''); } function derOf(cert: CertItem): Buffer { return new X509Certificate(cert.pem).raw; } const { asn1 } = forge; function sequence(value: forge.asn1.Asn1[]): forge.asn1.Asn1 { return asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, value); } function oid(value: string): forge.asn1.Asn1 { return asn1.create(asn1.Class.UNIVERSAL, asn1.Type.OID, false, asn1.oidToDer(value).getBytes()); } /** * PKCS#7 „certs only“ (signedData ohne Inhalt und ohne Unterschriften, wie .p7b/.p7c von CAs), von Hand * aus ASN.1 gebaut: ContentInfo { signedData, [0] { version 1, leere Verfahrensliste, data, [0] Zertifikate, * leere Unterschriftenliste } }. Jedes Zertifikat geht mit seinen eigenen Bytes hinein; forges * Zertifikatobjekte (nur RSA) kommen nie vor. */ function pkcs7Der(certs: CertItem[]): Buffer { const options = { decodeBitStrings: false } as unknown as boolean; const certAsn1 = certs.map((c) => asn1.fromDer(forge.util.createBuffer(derOf(c).toString('binary')), options), ); const signedData = sequence([ asn1.create(asn1.Class.UNIVERSAL, asn1.Type.INTEGER, false, asn1.integerToDer(1).getBytes()), asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SET, true, []), sequence([oid('1.2.840.113549.1.7.1')]), asn1.create(asn1.Class.CONTEXT_SPECIFIC, 0, true, certAsn1), asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SET, true, []), ]); const contentInfo = sequence([ oid('1.2.840.113549.1.7.2'), asn1.create(asn1.Class.CONTEXT_SPECIFIC, 0, true, [signedData]), ]); return Buffer.from(asn1.toDer(contentInfo).getBytes(), 'binary'); } function file(filename: string, data: Buffer | string, mimeType: string): BuildFile { const bytes = typeof data === 'string' ? Buffer.from(data, 'utf8') : data; return { filename, content: bytes.toString('base64'), mimeType }; } /** Zertifikatsliste als PEM, PKCS#7 PEM (.p7b) oder PKCS#7 DER (.p7c) unter dem Namen `.`. */ function certListFile(stem: string, pemName: string, format: string, certs: CertItem[]): BuildFile { if (format === 'p7b') { return file(`${stem}.p7b`, pemBlock('PKCS7', pkcs7Der(certs)), MIME.pkcs7); } if (format === 'p7c') return file(`${stem}.p7c`, pkcs7Der(certs), MIME.pkcs7); return file(pemName, joinPem(certs), MIME.pem); } /** Baut die gewuenschte Ausgabe. Wirft Nest-Ausnahmen mit Code (D-24); nie mit Passwort oder Schluessel im Text. */ export function buildOutput(input: BuildInput): BuildResult { const allowed = FORMATS[input.content]; if (!allowed) certError('invalidInput', 400, 'Unknown content'); const format = input.format ?? allowed[0]; if (!allowed.includes(format)) certError('invalidInput', 400, 'Format is not available here'); if (input.content === 'key') return buildKey(input, format as KeyExportFormat); if (input.content === 'csr') return buildCsr(input, format); if (!input.certPem) certError('invalidInput', 400, 'certPem is required'); const head = parseCertificate(input.certPem); const pool = (input.poolPems ?? []).map(parseCertificate); const unique = new Map(); for (const c of [head, ...pool]) if (!unique.has(c.id)) unique.set(c.id, c); const all = [...unique.values()]; const chain = buildChains(all, [head.id]).chains[0]; const byId = new Map(all.map((c) => [c.id, c])); const path = chain.path.map((id) => byId.get(id) as CertItem); const includeRoot = input.includeRoot === true; const withoutRoot = path.filter((c) => !(c.role === 'root' && c.id !== head.id)); const shown = includeRoot ? path : withoutRoot; const base = safeBaseName(input.baseName ?? '', head.baseName); let files: BuildFile[]; let snippet: string | null = null; switch (input.content) { case 'leaf': if (format === 'der') files = [file(`${base}.cer`, derOf(head), MIME.der)]; else files = [certListFile(base, `${base}.crt`, format, [head])]; break; case 'fullchain': files = [certListFile(`${base}-fullchain`, `${base}-fullchain.pem`, format, shown)]; break; case 'chain': { const issuers = shown.filter((c) => c.id !== head.id); if (issuers.length === 0) { certError('noChain', 400, 'No intermediate or root certificate available'); } files = [certListFile(`${base}-chain`, `${base}-chain.pem`, format, issuers)]; break; } case 'leafKey': { const key = matchingKey(head, input.keyPem); const certs = input.includeChain === false ? [head] : shown; const keyPem = key.export({ type: 'pkcs8', format: 'pem' }) as string; files = [file(`${base}-bundle.pem`, joinPem(certs) + keyPem, MIME.pem)]; break; } case 'pfx': { if (!input.password) certError('passwordRequired', 400, 'A password is required'); const key = input.keyPem ? matchingKey(head, input.keyPem) : null; const der = writePkcs12({ keyObject: key, certDers: shown.map(derOf), password: input.password, profile: input.pfxEncryption === 'modern' ? 'modern' : 'compat', friendlyName: base, }); files = [file(`${base}.pfx`, der, MIME.pfx)]; break; } case 'template': { if (!isTemplateId(input.template)) certError('invalidInput', 400, 'Unknown template'); // Eine Vorlage ohne passenden Schluessel gibt es nicht (templateNeedsKey); ein falscher Schluessel ist keyMismatch. if (!input.keyPem || input.keyPem.trim() === '') { certError('templateNeedsKey', 400, 'The template needs the matching private key'); } const key = matchingKey(head, input.keyPem); const built = buildTemplate(input.template, { head, shown, key, base, password: input.password, pfxProfile: input.pfxEncryption === 'modern' ? 'modern' : 'compat', }); files = built.files; snippet = built.snippet; break; } default: return certError('invalidInput', 400, 'Unknown content'); } return { files, chainComplete: chain.complete, missingIssuerCn: chain.gap?.missingIssuerCn ?? null, ...(input.content === 'template' ? { snippet } : {}), }; } /** Liest den Schluessel und prueft, dass er zum Serverzertifikat gehoert (sonst keyMismatch). */ function matchingKey(head: CertItem, keyPem: string | undefined): KeyObject { const key = parseKey(keyPem); if (!new X509Certificate(head.pem).checkPrivateKey(key)) { certError('keyMismatch', 400, 'The key does not belong to the certificate'); } return key; } function buildKey(input: BuildInput, format: KeyExportFormat): BuildResult { const key = parseKey(input.keyPem); const base = safeBaseName(input.baseName ?? '', 'schluessel'); const data = exportKey(key, format, input.password || undefined); let filename: string; let mime: string = MIME.pem; if (format === 'pkcs8') filename = `${base}.key`; else if (format === 'traditional') { filename = key.asymmetricKeyType === 'rsa' ? `${base}.rsa.key` : `${base}.ec.key`; } else { filename = `${base}.key.der`; mime = MIME.binary; } return { files: [file(filename, data, mime)], chainComplete: true, missingIssuerCn: null }; } function buildCsr(input: BuildInput, format: string): BuildResult { const { der, item } = parseCsr(input.csrPem); const base = safeBaseName(input.baseName ?? '', item.baseName); const out = format === 'der' ? file(`${base}.csr.der`, der, MIME.csr) : file(`${base}.csr`, item.pem, MIME.csr); return { files: [out], chainComplete: true, missingIssuerCn: null }; }