import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import * as forge from 'node-forge'; /** * Was `forge.pkcs7.messageFromPem()` bzw. `messageFromAsn1()` zurueckgeben — * der mitgelieferte Typ aus `@types/node-forge`, nicht ein eigener. * * Nur die signierte Form traegt `certificates`; die Lesestellen grenzen * deshalb mit `'certificates' in p7` ein. Das ist verhaltensgleich zum * bisherigen `p7.certificates ?? []`: bei einer enveloped-Nachricht fehlt * das Feld, und beide Schreibweisen liefern dann die leere Liste. */ type P7Message = forge.pkcs7.Captured< forge.pkcs7.PkcsEnvelopedData | forge.pkcs7.PkcsSignedData >; import type { UploadedFileLike } from '../auth/types/auth-user'; /** * Eine hochgeladene Zertifikatsdatei, so weit dieser Dienst sie liest: * Inhalt und eingereichter Name (der Name geht ausschliesslich in * `detectFormat` und in Fehlermeldungen). Abgeleitet aus `UploadedFileLike` * statt daneben erfunden (quick-260921-m34); `mimetype` und `size` bleiben * bewusst draussen, weil kein Zweig dieses Dienstes sie liest. */ type CertFileLike = Pick; // --------------------------------------------------------------------------- // CertDetails — the structured result returned by parseCert // --------------------------------------------------------------------------- export interface CertDetails { subject: { cn: string; o: string; ou: string; c: string }; issuer: { cn: string; o: string; c: string }; validity: { notBefore: string; notAfter: string; isExpired: boolean; daysLeft: number }; san: string[]; keyType: string; // "RSA" | "EC" keyBits: number; // 2048, 4096, 256, ... serialNumber: string; signatureAlgorithm: string; // "sha256WithRSAEncryption", etc. fingerprint: { sha1: string; sha256: string }; pemPreview: string; } // --------------------------------------------------------------------------- // SplitResponse — the structured result returned by splitCerts // --------------------------------------------------------------------------- export type CertRole = 'root' | 'intermediate' | 'end-entity'; export interface SplitEntry { index: number; filename: string; /** PEM content base64-encoded (one BEGIN CERTIFICATE block per entry) */ content: string; subject: { cn: string }; validity: { notAfter: string }; certRole: CertRole; } export interface SplitResponse { count: number; certs: SplitEntry[]; } // --------------------------------------------------------------------------- // FileResponse — the structured result returned by convertCert / mergeCerts // --------------------------------------------------------------------------- export interface FileResponse { /** Suggested download filename, e.g. "converted.der" */ filename: string; /** Base64-encoded file content */ content: string; /** MIME type for the download */ mimeType: string; } /** Map from target format key to MIME type */ const FORMAT_MIME: Record = { pem: 'application/x-pem-file', der: 'application/x-x509-ca-cert', p7b: 'application/x-pkcs7-certificates', pfx: 'application/x-pkcs12', }; // --------------------------------------------------------------------------- // Reverse OID map (OID string -> human-readable algorithm name) // Built once at module load — node-forge's pki.oids is name->OID // --------------------------------------------------------------------------- function buildReverseOids(): Record { const result: Record = {}; for (const [name, oid] of Object.entries(forge.pki.oids as Record)) { result[oid] = name; } return result; } const REVERSE_OIDS = buildReverseOids(); /** * CertManagerService — server-side certificate operations. * * All cryptographic processing is ephemeral (upload → process → return). * No data is persisted to disk or database. * * SECURITY NOTES: * - Binary buffers MUST use toString('binary') for forge (never 'utf-8' — Pitfall 1) * - Password parameters are never passed to the logger * - All forge operations wrapped in try/catch → BadRequestException */ @Injectable() export class CertManagerService { private readonly logger = new Logger(CertManagerService.name); // --------------------------------------------------------------------------- // Shared helpers (used by all operation methods) // --------------------------------------------------------------------------- /** * Detect the format of a certificate file from extension + content sniff. * .cer is ambiguous — resolved by inspecting the first bytes of the buffer. */ detectFormat( filename: string, buffer: Buffer, ): 'pem' | 'der' | 'pfx' | 'p7b' { const ext = filename.split('.').pop()?.toLowerCase() ?? ''; const isPemContent = buffer.slice(0, 27).toString('ascii').includes('-----BEGIN'); if (ext === 'pfx' || ext === 'p12') return 'pfx'; if (ext === 'p7b' || ext === 'p7c') return 'p7b'; if (ext === 'der') return 'der'; if (ext === 'pem' || ext === 'crt') return 'pem'; if (ext === 'cer') return isPemContent ? 'pem' : 'der'; // .cer is ambiguous // Fallback: sniff content return isPemContent ? 'pem' : 'der'; } /** * Convert a Node.js Buffer to a forge ByteStringBuffer using 'binary' encoding. * * CRITICAL: Always use 'binary' encoding — UTF-8 corrupts DER/PFX/P7B binary data. * See RESEARCH.md Pitfall 1. */ toForgeBuffer(buffer: Buffer): forge.util.ByteStringBuffer { return forge.util.createBuffer(buffer.toString('binary')); } /** * Compute SHA-1 or SHA-256 fingerprint of a certificate. * Hash is computed over the DER-encoded bytes, returned as uppercase colon-joined hex. */ getFingerprint(cert: forge.pki.Certificate, algorithm: 'sha1' | 'sha256'): string { const md = algorithm === 'sha1' ? forge.md.sha1.create() : forge.md.sha256.create(); const der = forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(); md.update(der); return md.digest().toHex().match(/.{2}/g)!.join(':').toUpperCase(); } /** * Split a PEM string containing one or more concatenated certificates. * Returns an array of parsed forge Certificate objects. */ parsePemChain(pem: string): forge.pki.Certificate[] { const blocks = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []; return blocks.map((b) => forge.pki.certificateFromPem(b)); } private detectCertRole(cert: forge.pki.Certificate): CertRole { const bc = cert.getExtension('basicConstraints') as { cA?: boolean } | null; if (!bc?.cA) return 'end-entity'; // Self-signed = subject hash matches issuer hash → Root CA return cert.subject.hash === cert.issuer.hash ? 'root' : 'intermediate'; } // --------------------------------------------------------------------------- // parseCert — CERT-01 + CERT-05 (read half) // --------------------------------------------------------------------------- /** * Parse a certificate from PEM text or an uploaded file (PEM/DER/PFX/P7B). * * Security contract (T-09-01, T-09-02): * - All forge calls wrapped in try/catch → BadRequestException (never an unhandled 500) * - Wrong PFX password → generic 400 message (password value never logged or echoed) */ async parseCert(input: { file?: CertFileLike; pemText?: string; password?: string; }): Promise { const { file, pemText, password } = input; let cert: forge.pki.Certificate; try { if (pemText) { // ── PEM text input ────────────────────────────────────────────────── const certs = this.parsePemChain(pemText); if (certs.length === 0) { throw new Error('No certificate block found in PEM text'); } cert = certs[0]; } else if (file) { const format = this.detectFormat(file.originalname, file.buffer); if (format === 'pem') { // ── PEM file ─────────────────────────────────────────────────────── const pemStr = file.buffer.toString('utf-8'); const certs = this.parsePemChain(pemStr); if (certs.length === 0) { throw new Error('No certificate block found in PEM file'); } cert = certs[0]; } else if (format === 'der') { // ── DER binary file ──────────────────────────────────────────────── // CRITICAL: binary encoding, never utf-8 (RESEARCH Pitfall 1) const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); cert = forge.pki.certificateFromAsn1(asn1); } else if (format === 'pfx') { // ── PFX/PKCS12 file ─────────────────────────────────────────────── // wrong password → forge throws → caught below → BadRequestException (T-09-02) const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? ''); const certBags = p12.getBags({ bagType: forge.pki.oids.certBag }); const bags = certBags[forge.pki.oids.certBag] ?? []; if (bags.length === 0) { throw new Error('No certificate bag found in PFX/PKCS12'); } // node-forge sets bag.cert to null when the bag's content parses as // valid DER but is not a readable X.509 certificate (lib/pkcs12.js // certBag decoder). An explicit guard here — not an assertion — so // the 400 names the real cause (quick-260921-iwr, D-01/D-04). const parsedCert = bags[0].cert; if (!parsedCert) { throw new BadRequestException( 'Certificate bag in PFX/PKCS12 does not contain a readable X.509 certificate', ); } cert = parsedCert; } else { // ── P7B/PKCS7 file — PEM-wrapped or binary DER (Pitfall 4) ──────── const isPemP7b = file.buffer .slice(0, 27) .toString('ascii') .includes('-----BEGIN'); // siehe P7Message oben — mitgelieferter Typ, keine Behauptung. let p7: P7Message; if (isPemP7b) { p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8')); } else { const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); p7 = forge.pkcs7.messageFromAsn1(p7Asn1); } const p7Certs: forge.pki.Certificate[] = 'certificates' in p7 ? p7.certificates : []; if (p7Certs.length === 0) { throw new Error('No certificate found in P7B/PKCS7'); } cert = p7Certs[0]; } } else { // Neither file nor pemText — controller should have rejected this already, // but guard here too (BadRequestException is NOT caught by the outer try/catch below) throw new BadRequestException('No file or PEM text provided'); } } catch (err) { // Re-throw BadRequestException as-is; convert everything else to 400 if (err instanceof BadRequestException) throw err; // Do NOT log the password (T-09-02) this.logger.warn('parseCert: failed to parse certificate (format/password error)'); throw new BadRequestException( 'Failed to parse certificate: invalid format or wrong password', ); } // ── Build CertDetails ────────────────────────────────────────────────── try { const notBefore = cert.validity.notBefore; const notAfter = cert.validity.notAfter; const now = new Date(); const isExpired = notAfter < now; const daysLeft = Math.ceil( (notAfter.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), ); // Key type and size // BLEIBT als any, mit Begruendung (260921-m34, Aufgabe 3c, D-01/D-02): // @types/node-forge kennt nur `PublicKey = rsa.PublicKey | ed25519.Key` // (index.d.ts:232). Der EC-Zweig unten liest `curve` und // `params.curve.q.bitLength()` — Felder, die node-forge zur Laufzeit // liefert, die der mitgelieferte Typ aber GAR NICHT kennt. Eine // Umdeutung ueber zwei Stufen wuerde dieselbe Luecke verdecken und // zusaetzlich so aussehen, als sei sie geprueft. Ein ehrliches any mit // dieser Zeile ist hier das bessere Ergebnis. const pubKey = cert.publicKey as any; let keyType = 'RSA'; let keyBits = 0; if (pubKey.n) { keyType = 'RSA'; keyBits = pubKey.n.bitLength(); } else if (pubKey.curve) { keyType = 'EC'; // EC key size from curve params — estimate from key length keyBits = pubKey.params?.curve?.q?.bitLength() ?? 0; } // Subject Alternative Names // // BLEIBEN als any, mit Begruendung (260921-m34, Aufgabe 3c, D-01/D-02): // @types/node-forge deklariert `Certificate.extensions` als `any[]` // (index.d.ts:435) und sagt damit ueber den Inhalt einer Erweiterung // NICHTS aus. Jede Schnittstelle, die wir hier selbst fuer `altNames` // schrieben, waere unbelegt — der Compiler koennte sie an keiner // Stelle gegen etwas pruefen, sie saehe aber geprueft aus. Die drei // any-Stellen dieses Blocks bleiben deshalb sichtbar stehen, statt // gegen eine Behauptung getauscht zu werden. const sanExt = cert.extensions?.find((e: any) => e.name === 'subjectAltName'); const san: string[] = ((sanExt as any)?.altNames ?? []).map((n: any) => n.type === 2 ? (n.value as string) : `IP:${(n.ip ?? n.value) as string}`, ); // Signature algorithm — OID → human-readable name // @types/node-forge deklariert siginfo.algorithmOid als string — die // Zusicherung war ueberfluessig. Das ?. bleibt woertlich erhalten. const sigOid = cert.siginfo?.algorithmOid ?? ''; const signatureAlgorithm = REVERSE_OIDS[sigOid] ?? sigOid; // Fingerprints const sha1 = this.getFingerprint(cert, 'sha1'); const sha256 = this.getFingerprint(cert, 'sha256'); return { subject: { cn: cert.subject.getField('CN')?.value ?? '', o: cert.subject.getField('O')?.value ?? '', ou: cert.subject.getField('OU')?.value ?? '', c: cert.subject.getField('C')?.value ?? '', }, issuer: { cn: cert.issuer.getField('CN')?.value ?? '', o: cert.issuer.getField('O')?.value ?? '', c: cert.issuer.getField('C')?.value ?? '', }, validity: { notBefore: notBefore.toISOString(), notAfter: notAfter.toISOString(), isExpired, daysLeft, }, san, keyType, keyBits, serialNumber: cert.serialNumber, signatureAlgorithm, fingerprint: { sha1, sha256 }, pemPreview: forge.pki.certificateToPem(cert), }; } catch { this.logger.warn('parseCert: failed to extract CertDetails fields'); throw new BadRequestException('Failed to extract certificate details'); } } // --------------------------------------------------------------------------- // Remaining operation stubs (implemented in later plan slices) // --------------------------------------------------------------------------- /** * Split a fullchain PEM or P7B/PKCS7 bundle into individual certificates. * * Security contract (T-09-01): * - All forge calls wrapped in try/catch → BadRequestException on malformed input * * Security contract (T-09-03): * - File size limit 5 MB enforced by FileInterceptor in the controller */ async splitCerts(input: { file?: CertFileLike; password?: string; }): Promise { const { file } = input; if (!file) { throw new BadRequestException('No file provided'); } let certs: forge.pki.Certificate[]; try { const format = this.detectFormat(file.originalname, file.buffer); if (format === 'pem') { // ── PEM chain (fullchain.pem, .crt — both map to 'pem' in detectFormat) ─ const pemStr = file.buffer.toString('utf-8'); certs = this.parsePemChain(pemStr); if (certs.length === 0) { throw new Error('No certificate blocks found in PEM file'); } } else if (format === 'p7b') { // ── P7B/PKCS7 bundle — PEM-wrapped or binary DER (Pitfall 4) ───────── const isPemP7b = file.buffer .slice(0, 27) .toString('ascii') .includes('-----BEGIN'); // Der mitgelieferte Typ traegt hier: messageFromPem/messageFromAsn1 // liefern beide Captured. let p7: P7Message; if (isPemP7b) { // PEM-wrapped PKCS7 (e.g. -----BEGIN PKCS7-----) p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8')); } else { // Binary DER PKCS7 const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); p7 = forge.pkcs7.messageFromAsn1(p7Asn1); } certs = 'certificates' in p7 ? p7.certificates : []; if (certs.length === 0) { throw new Error('No certificates found in P7B/PKCS7 bundle'); } } else { // DER / PFX — not a valid chain/bundle format for splitting throw new BadRequestException( 'Only PEM chains (.pem, .crt) and P7B bundles (.p7b) can be split', ); } } catch (err) { if (err instanceof BadRequestException) throw err; this.logger.warn('splitCerts: failed to parse bundle'); throw new BadRequestException('Failed to split certificates: invalid format or corrupted file'); } // ── Determine cert roles ─────────────────────────────────────────────── const roles: CertRole[] = certs.map((cert) => this.detectCertRole(cert)); // Build counters for filename disambiguation const roleCounters: Record = { root: 0, intermediate: 0, 'end-entity': 0 }; const roleFilename = (role: CertRole): string => { roleCounters[role]++; const n = roleCounters[role]; if (role === 'root') return n === 1 ? 'root-ca.pem' : `root-ca-${n}.pem`; if (role === 'intermediate') return `intermediate-${n}.pem`; return n === 1 ? 'cert.pem' : `cert-${n}.pem`; }; // ── Build SplitResponse ──────────────────────────────────────────────── const certEntries: SplitEntry[] = certs.map((cert, index) => { const pemStr = forge.pki.certificateToPem(cert); const content = Buffer.from(pemStr, 'utf-8').toString('base64'); const cn: string = cert.subject.getField('CN')?.value ?? ''; const notAfter: string = cert.validity.notAfter.toISOString(); const certRole = roles[index]; return { index, filename: roleFilename(certRole), content, subject: { cn }, validity: { notAfter }, certRole, }; }); return { count: certEntries.length, certs: certEntries, }; } /** * Merge multiple certificate files into a PEM chain or a password-protected PFX/PKCS12 bundle. * * Security contract (T-09-01, T-09-02, T-09-03): * - All forge calls wrapped in try/catch → BadRequestException on malformed input * - Password required for PFX output; never logged or echoed * - File size limit enforced by FilesInterceptor (controller level) * * Open Question 1 resolution: `forge.pkcs12.toPkcs12Asn1(null, certs, password)` was tested * at implementation time — node-forge 1.4.0 accepts null as the private key for cert-only PFX. * No fallback to lower-level certBag construction was needed. */ async mergeCerts(input: { files?: CertFileLike[]; outputFormat: string; password?: string; }): Promise { const { files, outputFormat, password } = input; if (!files || files.length === 0) { throw new BadRequestException('No files provided'); } // PFX output requires a non-empty password (T-09-02) if (outputFormat === 'pfx' && (!password || password.trim() === '')) { throw new BadRequestException('A password is required for PFX output'); } // ── Parse all input files to forge Certificate objects ──────────────────── let certs: forge.pki.Certificate[]; try { certs = files.flatMap((file) => { const format = this.detectFormat(file.originalname, file.buffer); if (format === 'pem') { const pemStr = file.buffer.toString('utf-8'); const parsed = this.parsePemChain(pemStr); if (parsed.length === 0) { throw new Error(`No certificate block found in ${file.originalname}`); } return parsed; } else if (format === 'der') { // CRITICAL: binary encoding, never utf-8 (RESEARCH Pitfall 1) const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); return [forge.pki.certificateFromAsn1(asn1)]; } else if (format === 'pfx') { // Extract all certs from the PFX bag const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? ''); const certBags = p12.getBags({ bagType: forge.pki.oids.certBag }); const bags = certBags[forge.pki.oids.certBag] ?? []; // node-forge sets bag.cert to null when a bag's content parses as // valid DER but is not a readable X.509 certificate (lib/pkcs12.js // certBag decoder). No entry may be silently dropped (D-04) — check // every bag and reject the whole file, naming it, before returning. const bagCerts = bags.map((bag) => bag.cert); // @types/node-forge declares Bag.cert as `Certificate | undefined`, // but node-forge's own runtime sets it to `null` for an unreadable // bag (lib/pkcs12.js certBag decoder) — check both, not just `=== // undefined`, so the guard actually catches what the library does. const missingCertIndex = bagCerts.findIndex((c) => c === undefined || c === null); if (missingCertIndex !== -1) { throw new BadRequestException( `Certificate bag in "${file.originalname}" does not contain a readable X.509 certificate`, ); } return bagCerts.filter((c): c is forge.pki.Certificate => c !== undefined && c !== null); } else { // P7B/PKCS7 — PEM-wrapped or binary DER (Pitfall 4) const isPemP7b = file.buffer .slice(0, 27) .toString('ascii') .includes('-----BEGIN'); // siehe P7Message oben — mitgelieferter Typ, keine Behauptung. let p7: P7Message; if (isPemP7b) { p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8')); } else { const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); p7 = forge.pkcs7.messageFromAsn1(p7Asn1); } return 'certificates' in p7 ? p7.certificates : []; } }); } catch (err) { if (err instanceof BadRequestException) throw err; // Password never logged (T-09-02) this.logger.warn('mergeCerts: failed to parse one or more input files'); throw new BadRequestException( 'Failed to parse certificate files: invalid format or corrupted input', ); } if (certs.length === 0) { throw new BadRequestException('No valid certificates found in uploaded files'); } // ── Serialize to requested output format ────────────────────────────────── try { if (outputFormat === 'pem') { // PEM chain: concatenate all certs const chain = certs.map((cert) => forge.pki.certificateToPem(cert)).join('\n'); const content = Buffer.from(chain, 'utf-8').toString('base64'); return { filename: 'chain.pem', content, mimeType: FORMAT_MIME.pem, }; } else if (outputFormat === 'pfx') { // Open Question 1 resolution: toPkcs12Asn1(null, certs, password) works in node-forge 1.4.0 // null as the private key produces a cert-only PKCS12 bundle (no key bag — cert bag only) const p12Asn1 = forge.pkcs12.toPkcs12Asn1( // BLEIBT (260921-m34, Aufgabe 3c): node-forge 1.4.0 nimmt hier einen // fehlenden Schluessel an und erzeugt ein reines // Zertifikatsbuendel; @types/node-forge schliesst null aus. Die // mitgelieferten Typen beschreiben die Bibliothek an dieser Stelle // also nachweislich falsch — ein erzwungener Typ waere eine // Behauptung ueber etwas, das nicht stimmt. null as any, // cert-only PFX — null key accepted by node-forge 1.4.0 certs, password!, { algorithm: '3des' }, ); // CRITICAL: bytesToHex → Buffer.from(hex,'hex') — avoids utf-8 corruption (Pitfall 1) const p12Hex = forge.util.bytesToHex(forge.asn1.toDer(p12Asn1).getBytes()); const pfxBuffer = Buffer.from(p12Hex, 'hex'); const content = pfxBuffer.toString('base64'); return { filename: 'bundle.pfx', content, mimeType: FORMAT_MIME.pfx, }; } else { throw new BadRequestException( `Unsupported output format: "${outputFormat}". Supported: pem, pfx`, ); } } catch (err) { if (err instanceof BadRequestException) throw err; this.logger.warn('mergeCerts: failed to serialize merged output'); throw new BadRequestException('Failed to create merged certificate output'); } } /** * Convert a certificate between PEM, DER, and P7B formats. * * Security contract (T-09-01, T-09-06): * - All forge calls wrapped in try/catch → BadRequestException on malformed input * - DER output built via bytesToHex → Buffer.from(hex, 'hex') → base64 (never utf-8 round-trip) * - Password is never passed to the logger (T-09-02) */ async convertCert(input: { file?: CertFileLike; pemText?: string; targetFormat: string; password?: string; }): Promise { const { file, pemText, targetFormat, password } = input; // ── Validate targetFormat ────────────────────────────────────────────── if (!FORMAT_MIME[targetFormat]) { throw new BadRequestException( `Unsupported target format: "${targetFormat}". Supported: pem, der, p7b, pfx`, ); } // PFX output requires a non-empty password (T-09-02) if (targetFormat === 'pfx' && (!password || password.trim() === '')) { throw new BadRequestException('A password is required for PFX output'); } let cert: forge.pki.Certificate; try { // ── Resolve input to a forge Certificate ──────────────────────────── if (pemText) { // PEM text pasted by the user const certs = this.parsePemChain(pemText); if (certs.length === 0) { throw new Error('No certificate block found in PEM text'); } cert = certs[0]; } else if (file) { const format = this.detectFormat(file.originalname, file.buffer); if (format === 'pem') { const pemStr = file.buffer.toString('utf-8'); const certs = this.parsePemChain(pemStr); if (certs.length === 0) { throw new Error('No certificate block found in PEM file'); } cert = certs[0]; } else if (format === 'der') { // CRITICAL: binary encoding, never utf-8 (RESEARCH Pitfall 1) const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); cert = forge.pki.certificateFromAsn1(asn1); } else if (format === 'pfx') { // PFX/PKCS12 — extract first cert bag (wrong password → BadRequestException) const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? ''); const certBags = p12.getBags({ bagType: forge.pki.oids.certBag }); const bags = certBags[forge.pki.oids.certBag] ?? []; if (bags.length === 0) { throw new Error('No certificate bag found in PFX/PKCS12'); } // node-forge sets bag.cert to null when the bag's content parses as // valid DER but is not a readable X.509 certificate (lib/pkcs12.js // certBag decoder). An explicit guard here — not an assertion — so // the 400 names the real cause (quick-260921-iwr, D-01/D-04). const parsedCert = bags[0].cert; if (!parsedCert) { throw new BadRequestException( 'Certificate bag in PFX/PKCS12 does not contain a readable X.509 certificate', ); } cert = parsedCert; } else { // P7B — extract first cert const isPemP7b = file.buffer .slice(0, 27) .toString('ascii') .includes('-----BEGIN'); // siehe P7Message oben — mitgelieferter Typ, keine Behauptung. let p7: P7Message; if (isPemP7b) { p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8')); } else { const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); p7 = forge.pkcs7.messageFromAsn1(p7Asn1); } const p7Certs: forge.pki.Certificate[] = 'certificates' in p7 ? p7.certificates : []; if (p7Certs.length === 0) { throw new Error('No certificate found in P7B/PKCS7'); } cert = p7Certs[0]; } } else { throw new BadRequestException('No file or PEM text provided'); } } catch (err) { if (err instanceof BadRequestException) throw err; // Password never logged (T-09-02) this.logger.warn('convertCert: failed to parse input certificate'); throw new BadRequestException( 'Failed to parse certificate: invalid format or wrong password', ); } // ── Serialize to targetFormat ───────────────────────────────────────── try { let content: string; if (targetFormat === 'pem') { // PEM text → base64 via utf-8 const pemOut = forge.pki.certificateToPem(cert); content = Buffer.from(pemOut, 'utf-8').toString('base64'); } else if (targetFormat === 'der') { // DER binary — CRITICAL: bytesToHex → Buffer.from(hex, 'hex') → base64 // Avoids utf-8 round-trip corruption (RESEARCH Pitfall 1 / T-09-06) const derHex = forge.util.bytesToHex( forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(), ); content = Buffer.from(derHex, 'hex').toString('base64'); } else if (targetFormat === 'p7b') { // P7B — PEM-wrapped PKCS7 SignedData containing the certificate const p7 = forge.pkcs7.createSignedData(); p7.addCertificate(cert); const p7DerBytes = forge.asn1.toDer(p7.toAsn1()).getBytes(); const p7PemStr = forge.pem.encode({ type: 'PKCS7', body: p7DerBytes }); content = Buffer.from(p7PemStr, 'utf-8').toString('base64'); } else { // PFX — cert-only PKCS12 bundle (Open Question 1: null key works in node-forge 1.4.0) const p12Asn1 = forge.pkcs12.toPkcs12Asn1( // BLEIBT (260921-m34, Aufgabe 3c): node-forge 1.4.0 nimmt hier einen // fehlenden Schluessel an und erzeugt ein reines // Zertifikatsbuendel; @types/node-forge schliesst null aus. Die // mitgelieferten Typen beschreiben die Bibliothek an dieser Stelle // also nachweislich falsch — ein erzwungener Typ waere eine // Behauptung ueber etwas, das nicht stimmt. null as any, // cert-only PFX — null key accepted by node-forge 1.4.0 [cert], password!, { algorithm: '3des' }, ); // CRITICAL: bytesToHex → Buffer.from(hex,'hex') — avoids utf-8 corruption (Pitfall 1) const p12Hex = forge.util.bytesToHex(forge.asn1.toDer(p12Asn1).getBytes()); content = Buffer.from(p12Hex, 'hex').toString('base64'); return { filename: 'converted.pfx', content, mimeType: FORMAT_MIME.pfx, }; } return { filename: `converted.${targetFormat}`, content, mimeType: FORMAT_MIME[targetFormat], }; } catch { this.logger.warn('convertCert: failed to serialize to target format'); throw new BadRequestException( `Failed to convert certificate to ${targetFormat}: serialization error`, ); } } // --------------------------------------------------------------------------- // Internal helpers for later slices // --------------------------------------------------------------------------- /** Wrap a node-forge operation and re-throw as BadRequestException on failure */ protected _parseOrThrow(fn: () => T, errorMsg: string): T { try { return fn(); } catch (_err) { this.logger.warn(`Cert parse failed: ${errorMsg}`); throw new BadRequestException(errorMsg); } } }