feat(09-06): GREEN — implement mergeCerts + PFX-create + convertCert pfx output

- 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
This commit is contained in:
2026-07-02 07:49:42 +02:00
parent f43e92c49f
commit 6326064ad3
@@ -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'; import * as forge from 'node-forge';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -54,6 +54,7 @@ const FORMAT_MIME: Record<string, string> = {
pem: 'application/x-pem-file', pem: 'application/x-pem-file',
der: 'application/x-x509-ca-cert', der: 'application/x-x509-ca-cert',
p7b: 'application/x-pkcs7-certificates', 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[]; files?: any[];
outputFormat: string; outputFormat: string;
password?: string; password?: string;
}): Promise<never> { }): Promise<FileResponse> {
throw new NotImplementedException('mergeCerts is not yet implemented'); 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 ────────────────────────────────────────────── // ── Validate targetFormat ──────────────────────────────────────────────
if (!FORMAT_MIME[targetFormat]) { if (!FORMAT_MIME[targetFormat]) {
throw new BadRequestException( 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; let cert: forge.pki.Certificate;
try { try {
@@ -497,13 +621,29 @@ export class CertManagerService {
forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(), forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(),
); );
content = Buffer.from(derHex, 'hex').toString('base64'); content = Buffer.from(derHex, 'hex').toString('base64');
} else { } else if (targetFormat === 'p7b') {
// P7B — PEM-wrapped PKCS7 SignedData containing the certificate // P7B — PEM-wrapped PKCS7 SignedData containing the certificate
const p7 = forge.pkcs7.createSignedData(); const p7 = forge.pkcs7.createSignedData();
p7.addCertificate(cert); p7.addCertificate(cert);
const p7DerBytes = forge.asn1.toDer(p7.toAsn1()).getBytes(); const p7DerBytes = forge.asn1.toDer(p7.toAsn1()).getBytes();
const p7PemStr = forge.pem.encode({ type: 'PKCS7', body: p7DerBytes }); const p7PemStr = forge.pem.encode({ type: 'PKCS7', body: p7DerBytes });
content = Buffer.from(p7PemStr, 'utf-8').toString('base64'); 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 { return {