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:
@@ -93,7 +93,12 @@ export class CertManagerController {
|
||||
|
||||
/**
|
||||
* 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')
|
||||
@UseInterceptors(
|
||||
@@ -105,10 +110,11 @@ export class CertManagerController {
|
||||
@UploadedFile() file: any,
|
||||
@Body('targetFormat') targetFormat: string,
|
||||
@Body('password') password?: string,
|
||||
@Body('pemText') pemText?: string,
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file provided');
|
||||
if (!file && !pemText) {
|
||||
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
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,26 @@ export interface SplitResponse {
|
||||
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)
|
||||
// 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');
|
||||
}
|
||||
|
||||
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;
|
||||
pemText?: string;
|
||||
targetFormat: string;
|
||||
password?: string;
|
||||
}): Promise<never> {
|
||||
throw new NotImplementedException('convertCert is not yet implemented');
|
||||
}): Promise<FileResponse> {
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user