feat(09-05): implement convertCert + wire POST /convert (GREEN)

- Add FileResponse interface and FORMAT_MIME map to service
- Implement convertCert: parses any input format (PEM/DER/PFX/P7B) via
  same logic as parseCert; serializes to pem/der/p7b targetFormat
- DER output uses bytesToHex→Buffer.from(hex,'hex') to avoid utf-8
  corruption (Pitfall 1 / T-09-06)
- P7B output: pkcs7.createSignedData + pem.encode (PEM-wrapped PKCS7)
- Wrap all forge ops in try/catch → BadRequestException (T-09-01)
- Controller: add @Body('pemText') + reject when neither file nor pemText
- Fix: re-add NotImplementedException import for mergeCerts stub
- All 23 API cert-manager tests green (including 4 new convertCert)
This commit is contained in:
2026-07-02 07:37:41 +02:00
parent 37db58b816
commit 59694642dd
3 changed files with 153 additions and 8 deletions
@@ -93,7 +93,12 @@ export class CertManagerController {
/** /**
* POST /modules/cert-manager/convert * POST /modules/cert-manager/convert
* Convert a certificate between PEM, DER, PFX/P12, P7B, CRT/CER formats. * Convert a certificate between PEM, DER, and P7B formats.
* Accepts a multipart file upload OR a pemText body field.
*
* T-09-03: fileSize limit 5 MB (DoS mitigation)
* T-09-04: global JwtAuthGuard + @UseModule('cert-manager') ModuleGuard
* T-09-02: password is never passed to the logger
*/ */
@Post('convert') @Post('convert')
@UseInterceptors( @UseInterceptors(
@@ -105,10 +110,11 @@ export class CertManagerController {
@UploadedFile() file: any, @UploadedFile() file: any,
@Body('targetFormat') targetFormat: string, @Body('targetFormat') targetFormat: string,
@Body('password') password?: string, @Body('password') password?: string,
@Body('pemText') pemText?: string,
) { ) {
if (!file) { if (!file && !pemText) {
throw new BadRequestException('No file provided'); throw new BadRequestException('No file or PEM text provided');
} }
return this.certManagerService.convertCert({ file, targetFormat, password }); return this.certManagerService.convertCert({ file, pemText, targetFormat, password });
} }
} }
@@ -397,7 +397,8 @@ describe('convertCert', () => {
// Decode base64 P7B (PEM-wrapped PKCS7) and verify at least 1 cert enclosed // Decode base64 P7B (PEM-wrapped PKCS7) and verify at least 1 cert enclosed
const p7bContent = Buffer.from(result.content as string, 'base64').toString('utf-8'); const p7bContent = Buffer.from(result.content as string, 'base64').toString('utf-8');
const p7 = forge.pkcs7.messageFromPem(p7bContent); // eslint-disable-next-line @typescript-eslint/no-explicit-any
const p7 = forge.pkcs7.messageFromPem(p7bContent) as any;
expect((p7.certificates as forge.pki.Certificate[]).length).toBeGreaterThanOrEqual(1); expect((p7.certificates as forge.pki.Certificate[]).length).toBeGreaterThanOrEqual(1);
}); });
@@ -36,6 +36,26 @@ export interface SplitResponse {
certs: SplitEntry[]; 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<string, string> = {
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) // Reverse OID map (OID string -> human-readable algorithm name)
// Built once at module load — node-forge's pki.oids is name->OID // Built once at module load — node-forge's pki.oids is name->OID
@@ -373,12 +393,130 @@ export class CertManagerService {
throw new NotImplementedException('mergeCerts is not yet implemented'); throw new NotImplementedException('mergeCerts is not yet implemented');
} }
async convertCert(_input: { /**
* 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; file?: any;
pemText?: string;
targetFormat: string; targetFormat: string;
password?: string; password?: string;
}): Promise<never> { }): Promise<FileResponse> {
throw new NotImplementedException('convertCert is not yet implemented'); 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`,
);
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------