diff --git a/apps/api/src/cert-manager/cert-manager.service.ts b/apps/api/src/cert-manager/cert-manager.service.ts index 17ed339..6d37308 100644 --- a/apps/api/src/cert-manager/cert-manager.service.ts +++ b/apps/api/src/cert-manager/cert-manager.service.ts @@ -18,6 +18,24 @@ export interface CertDetails { 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[]; +} + // --------------------------------------------------------------------------- // Reverse OID map (OID string -> human-readable algorithm name) // Built once at module load — node-forge's pki.oids is name->OID @@ -263,11 +281,88 @@ export class CertManagerService { // Remaining operation stubs (implemented in later plan slices) // --------------------------------------------------------------------------- - async splitCerts(_input: { + /** + * 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 { - throw new NotImplementedException('splitCerts is not yet implemented'); + }): 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: {