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:
@@ -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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -54,6 +54,7 @@ const FORMAT_MIME: Record<string, string> = {
|
||||
pem: 'application/x-pem-file',
|
||||
der: 'application/x-x509-ca-cert',
|
||||
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[];
|
||||
outputFormat: string;
|
||||
password?: string;
|
||||
}): Promise<never> {
|
||||
throw new NotImplementedException('mergeCerts is not yet implemented');
|
||||
}): Promise<FileResponse> {
|
||||
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 ──────────────────────────────────────────────
|
||||
if (!FORMAT_MIME[targetFormat]) {
|
||||
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;
|
||||
|
||||
try {
|
||||
@@ -497,13 +621,29 @@ export class CertManagerService {
|
||||
forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(),
|
||||
);
|
||||
content = Buffer.from(derHex, 'hex').toString('base64');
|
||||
} else {
|
||||
} else if (targetFormat === 'p7b') {
|
||||
// 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');
|
||||
} 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 {
|
||||
|
||||
Reference in New Issue
Block a user