From 6326064ad3584f063d03acb58a0dc47471d940d2 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 2 Jul 2026 07:49:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(09-06):=20GREEN=20=E2=80=94=20implement=20?= =?UTF-8?q?mergeCerts=20+=20PFX-create=20+=20convertCert=20pfx=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CertManagerService.mergeCerts: parse all files via detectFormat/parsePemChain/toForgeBuffer, concatenate PEM chain or build PKCS12 via toPkcs12Asn1 - Open Question 1 resolved: toPkcs12Asn1(null, certs, password) works in node-forge 1.4.0 (null private key accepted — cert-only PFX without fallback needed) - PFX output requires non-empty password → BadRequestException if missing (T-09-02) - All forge calls in try/catch → BadRequestException; password never logged (T-09-02) - bytesToHex→Buffer.from(hex,'hex')→base64 for binary safety (Pitfall 1 avoidance) - convertCert gains pfx output target (reuses same null-key toPkcs12Asn1 pattern) - FORMAT_MIME extended with pfx: 'application/x-pkcs12' - NotImplementedException import removed (no longer used) - 27/27 API cert-manager tests green; tsc --noEmit exits 0 --- .../src/cert-manager/cert-manager.service.ts | 152 +++++++++++++++++- 1 file changed, 146 insertions(+), 6 deletions(-) diff --git a/apps/api/src/cert-manager/cert-manager.service.ts b/apps/api/src/cert-manager/cert-manager.service.ts index 7b49220..c9f8430 100644 --- a/apps/api/src/cert-manager/cert-manager.service.ts +++ b/apps/api/src/cert-manager/cert-manager.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, Logger, NotImplementedException } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import * as forge from 'node-forge'; // --------------------------------------------------------------------------- @@ -54,6 +54,7 @@ const FORMAT_MIME: Record = { pem: 'application/x-pem-file', der: 'application/x-x509-ca-cert', p7b: 'application/x-pkcs7-certificates', + pfx: 'application/x-pkcs12', }; // --------------------------------------------------------------------------- @@ -385,12 +386,130 @@ export class CertManagerService { }; } - async mergeCerts(_input: { + /** + * 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?: any[]; outputFormat: string; password?: string; - }): Promise { - throw new NotImplementedException('mergeCerts is not yet implemented'); + }): 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 as any[]).flatMap((file: any) => { + const format = this.detectFormat( + file.originalname as string, + file.buffer as Buffer, + ); + + if (format === 'pem') { + const pemStr = (file.buffer as Buffer).toString('utf-8'); + const parsed = this.parsePemChain(pemStr); + if (parsed.length === 0) { + throw new Error(`No certificate block found in ${file.originalname as string}`); + } + return parsed; + } else if (format === 'der') { + // CRITICAL: binary encoding, never utf-8 (RESEARCH Pitfall 1) + const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as 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 as Buffer)); + const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? ''); + const certBags = p12.getBags({ bagType: forge.pki.oids.certBag }); + const bags = certBags[forge.pki.oids.certBag] ?? []; + return bags.map((bag) => bag.cert!); + } else { + // P7B/PKCS7 — PEM-wrapped or binary DER (Pitfall 4) + const isPemP7b = (file.buffer as Buffer) + .slice(0, 27) + .toString('ascii') + .includes('-----BEGIN'); + let p7: any; + if (isPemP7b) { + p7 = forge.pkcs7.messageFromPem((file.buffer as Buffer).toString('utf-8')); + } else { + const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer)); + p7 = forge.pkcs7.messageFromAsn1(p7Asn1); + } + return (p7.certificates as forge.pki.Certificate[]) ?? []; + } + }); + } 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( + 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'); + } } /** @@ -412,10 +531,15 @@ export class CertManagerService { // ── Validate targetFormat ────────────────────────────────────────────── if (!FORMAT_MIME[targetFormat]) { throw new BadRequestException( - `Unsupported target format: "${targetFormat}". Supported: pem, der, p7b`, + `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 { @@ -497,13 +621,29 @@ export class CertManagerService { forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(), ); content = Buffer.from(derHex, 'hex').toString('base64'); - } else { + } 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( + 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 {