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; } // --------------------------------------------------------------------------- // SplitResponse — the structured result returned by splitCerts // --------------------------------------------------------------------------- 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 }; } 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', }; // --------------------------------------------------------------------------- // 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)); } // --------------------------------------------------------------------------- // 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?: any; 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 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) // --------------------------------------------------------------------------- /** * 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?: any; 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 as string, file.buffer as Buffer); if (format === 'pem') { // ── PEM chain (fullchain.pem, .crt — both map to 'pem' in detectFormat) ─ const pemStr = (file.buffer as 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 as Buffer) .slice(0, 27) .toString('ascii') .includes('-----BEGIN'); let p7: any; if (isPemP7b) { // PEM-wrapped PKCS7 (e.g. -----BEGIN PKCS7-----) p7 = forge.pkcs7.messageFromPem((file.buffer as Buffer).toString('utf-8')); } else { // Binary DER PKCS7 const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer)); p7 = forge.pkcs7.messageFromAsn1(p7Asn1); } certs = (p7.certificates as forge.pki.Certificate[]) ?? []; 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'); } // ── 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(); return { index, filename: `cert-${index + 1}.pem`, content, subject: { cn }, validity: { notAfter }, }; }); return { count: certEntries.length, certs: certEntries, }; } async mergeCerts(_input: { files?: any[]; outputFormat: string; password?: string; }): Promise { throw new NotImplementedException('mergeCerts is not yet implemented'); } /** * 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 { 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`, ); } } // --------------------------------------------------------------------------- // 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); } } }