From 59694642ddefcf57a3d423b228aa69a1dde729dd Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 2 Jul 2026 07:37:41 +0200 Subject: [PATCH] feat(09-05): implement convertCert + wire POST /convert (GREEN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add FileResponse interface and FORMAT_MIME map to service - Implement convertCert: parses any input format (PEM/DER/PFX/P7B) via same logic as parseCert; serializes to pem/der/p7b targetFormat - DER output uses bytesToHex→Buffer.from(hex,'hex') to avoid utf-8 corruption (Pitfall 1 / T-09-06) - P7B output: pkcs7.createSignedData + pem.encode (PEM-wrapped PKCS7) - Wrap all forge ops in try/catch → BadRequestException (T-09-01) - Controller: add @Body('pemText') + reject when neither file nor pemText - Fix: re-add NotImplementedException import for mergeCerts stub - All 23 API cert-manager tests green (including 4 new convertCert) --- .../cert-manager/cert-manager.controller.ts | 14 +- .../cert-manager/cert-manager.service.spec.ts | 3 +- .../src/cert-manager/cert-manager.service.ts | 144 +++++++++++++++++- 3 files changed, 153 insertions(+), 8 deletions(-) diff --git a/apps/api/src/cert-manager/cert-manager.controller.ts b/apps/api/src/cert-manager/cert-manager.controller.ts index edfd7eb..82a862e 100644 --- a/apps/api/src/cert-manager/cert-manager.controller.ts +++ b/apps/api/src/cert-manager/cert-manager.controller.ts @@ -93,7 +93,12 @@ export class CertManagerController { /** * POST /modules/cert-manager/convert - * Convert a certificate between PEM, DER, PFX/P12, P7B, CRT/CER formats. + * Convert a certificate between PEM, DER, and P7B formats. + * Accepts a multipart file upload OR a pemText body field. + * + * T-09-03: fileSize limit 5 MB (DoS mitigation) + * T-09-04: global JwtAuthGuard + @UseModule('cert-manager') ModuleGuard + * T-09-02: password is never passed to the logger */ @Post('convert') @UseInterceptors( @@ -105,10 +110,11 @@ export class CertManagerController { @UploadedFile() file: any, @Body('targetFormat') targetFormat: string, @Body('password') password?: string, + @Body('pemText') pemText?: string, ) { - if (!file) { - throw new BadRequestException('No file provided'); + if (!file && !pemText) { + throw new BadRequestException('No file or PEM text provided'); } - return this.certManagerService.convertCert({ file, targetFormat, password }); + return this.certManagerService.convertCert({ file, pemText, targetFormat, password }); } } diff --git a/apps/api/src/cert-manager/cert-manager.service.spec.ts b/apps/api/src/cert-manager/cert-manager.service.spec.ts index e0a4ba2..9b3237d 100644 --- a/apps/api/src/cert-manager/cert-manager.service.spec.ts +++ b/apps/api/src/cert-manager/cert-manager.service.spec.ts @@ -397,7 +397,8 @@ describe('convertCert', () => { // Decode base64 P7B (PEM-wrapped PKCS7) and verify at least 1 cert enclosed const p7bContent = Buffer.from(result.content as string, 'base64').toString('utf-8'); - const p7 = forge.pkcs7.messageFromPem(p7bContent); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const p7 = forge.pkcs7.messageFromPem(p7bContent) as any; expect((p7.certificates as forge.pki.Certificate[]).length).toBeGreaterThanOrEqual(1); }); diff --git a/apps/api/src/cert-manager/cert-manager.service.ts b/apps/api/src/cert-manager/cert-manager.service.ts index 6d37308..7b49220 100644 --- a/apps/api/src/cert-manager/cert-manager.service.ts +++ b/apps/api/src/cert-manager/cert-manager.service.ts @@ -36,6 +36,26 @@ export interface SplitResponse { 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', +}; + // --------------------------------------------------------------------------- // Reverse OID map (OID string -> human-readable algorithm name) // Built once at module load — node-forge's pki.oids is name->OID @@ -373,12 +393,130 @@ export class CertManagerService { throw new NotImplementedException('mergeCerts is not yet implemented'); } - async convertCert(_input: { + /** + * 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?: any; + pemText?: string; targetFormat: string; password?: string; - }): Promise { - throw new NotImplementedException('convertCert is not yet implemented'); + }): Promise { + const { file, pemText, targetFormat, password } = input; + + // ── Validate targetFormat ────────────────────────────────────────────── + if (!FORMAT_MIME[targetFormat]) { + throw new BadRequestException( + `Unsupported target format: "${targetFormat}". Supported: pem, der, p7b`, + ); + } + + 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 as string, file.buffer as Buffer); + + if (format === 'pem') { + const pemStr = (file.buffer as 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 as 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 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] ?? []; + if (bags.length === 0) { + throw new Error('No certificate bag found in PFX/PKCS12'); + } + cert = bags[0].cert!; + } else { + // P7B — extract first cert + 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); + } + const p7Certs: forge.pki.Certificate[] = 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 { + // 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'); + } + + return { + filename: `converted.${targetFormat}`, + content, + mimeType: FORMAT_MIME[targetFormat], + }; + } catch (err) { + this.logger.warn('convertCert: failed to serialize to target format'); + throw new BadRequestException( + `Failed to convert certificate to ${targetFormat}: serialization error`, + ); + } } // ---------------------------------------------------------------------------