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';
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user