From ba994635e87fb288390f60dc42f8f3e6eaf78bb0 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 1 Jul 2026 23:41:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(09-03):=20GREEN=20=E2=80=94=20implement=20?= =?UTF-8?q?parseCert=20+=20export=20CertDetails=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implemented CertManagerService.parseCert for PEM/DER/PFX/P7B inputs - Exported CertDetails interface (subject, issuer, validity, san, keyType, keyBits, serialNumber, signatureAlgorithm, fingerprint, pemPreview) - PFX with wrong password → BadRequestException (T-09-02: never logged, never echoed) - All forge operations wrapped in try/catch → BadRequestException (T-09-01) - buildReverseOids() converts OID → human-readable algorithm name - P7B handles both PEM-wrapped and binary DER (RESEARCH Pitfall 4) - Controller already wired correctly from Plan 01 (fileSize 5MB, pemText, file, password) - All 16 cert-manager tests pass (16/16) --- .../src/cert-manager/cert-manager.service.ts | 188 +++++++++++++++++- 1 file changed, 184 insertions(+), 4 deletions(-) diff --git a/apps/api/src/cert-manager/cert-manager.service.ts b/apps/api/src/cert-manager/cert-manager.service.ts index 3ce7225..17ed339 100644 --- a/apps/api/src/cert-manager/cert-manager.service.ts +++ b/apps/api/src/cert-manager/cert-manager.service.ts @@ -1,6 +1,36 @@ import { BadRequestException, Injectable, Logger, NotImplementedException } from '@nestjs/common'; import * as forge from 'node-forge'; +// --------------------------------------------------------------------------- +// 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; +} + +// --------------------------------------------------------------------------- +// 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. * @@ -72,17 +102,167 @@ export class CertManagerService { } // --------------------------------------------------------------------------- - // Operation method stubs (implemented in later plan slices) + // parseCert — CERT-01 + CERT-05 (read half) // --------------------------------------------------------------------------- - async parseCert(_input: { + /** + * 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?: any; pemText?: string; password?: string; - }): Promise { - throw new NotImplementedException('parseCert is not yet implemented'); + }): 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 as string, file.buffer as Buffer); + + if (format === 'pem') { + // ── PEM file ─────────────────────────────────────────────────────── + 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') { + // ── DER binary file ──────────────────────────────────────────────── + // 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 file ─────────────────────────────────────────────── + // wrong password → forge throws → caught below → BadRequestException (T-09-02) + 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/PKCS7 file — 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); + } + const p7Certs: forge.pki.Certificate[] = 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 + 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 + 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 + const sigOid = (cert.siginfo as any)?.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 (err) { + 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) + // --------------------------------------------------------------------------- + async splitCerts(_input: { file?: any; password?: string;