feat(09-04): GREEN — implement splitCerts + SplitResponse interface
Tessera CI/CD / Tests (push) Has been cancelled
Tessera CI/CD / Build & Publish Images (push) Has been cancelled
Tessera CI/CD / Lint & Type Check (push) Has been cancelled

- Export SplitEntry + SplitResponse interfaces
- splitCerts: PEM chain path via parsePemChain; P7B path via messageFromPem (PEM) or messageFromAsn1 (DER)
- Each cert entry: index, filename cert-N.pem, content base64 PEM, subject.cn, validity.notAfter
- BadRequestException on malformed input / unsupported format (T-09-01)
- POST /split already wired in controller with 5MB file limit (T-09-03, T-09-04)
- All 19 API tests green; type-check clean
This commit is contained in:
2026-07-02 07:14:22 +02:00
parent eec66311a7
commit 2c4ada347c
@@ -18,6 +18,24 @@ export interface CertDetails {
pemPreview: string;
}
// ---------------------------------------------------------------------------
// SplitResponse — the structured result returned by splitCerts
// ---------------------------------------------------------------------------
export interface SplitEntry {
index: number;
filename: string;
/** PEM content base64-encoded (one BEGIN CERTIFICATE block per entry) */
content: string;
subject: { cn: string };
validity: { notAfter: string };
}
export interface SplitResponse {
count: number;
certs: SplitEntry[];
}
// ---------------------------------------------------------------------------
// Reverse OID map (OID string -> human-readable algorithm name)
// Built once at module load — node-forge's pki.oids is name->OID
@@ -263,11 +281,88 @@ export class CertManagerService {
// Remaining operation stubs (implemented in later plan slices)
// ---------------------------------------------------------------------------
async splitCerts(_input: {
/**
* Split a fullchain PEM or P7B/PKCS7 bundle into individual certificates.
*
* Security contract (T-09-01):
* - All forge calls wrapped in try/catch → BadRequestException on malformed input
*
* Security contract (T-09-03):
* - File size limit 5 MB enforced by FileInterceptor in the controller
*/
async splitCerts(input: {
file?: any;
password?: string;
}): Promise<never> {
throw new NotImplementedException('splitCerts is not yet implemented');
}): Promise<SplitResponse> {
const { file } = input;
if (!file) {
throw new BadRequestException('No file provided');
}
let certs: forge.pki.Certificate[];
try {
const format = this.detectFormat(file.originalname as string, file.buffer as Buffer);
if (format === 'pem') {
// ── PEM chain (fullchain.pem, .crt — both map to 'pem' in detectFormat) ─
const pemStr = (file.buffer as Buffer).toString('utf-8');
certs = this.parsePemChain(pemStr);
if (certs.length === 0) {
throw new Error('No certificate blocks found in PEM file');
}
} else if (format === 'p7b') {
// ── P7B/PKCS7 bundle — 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) {
// PEM-wrapped PKCS7 (e.g. -----BEGIN PKCS7-----)
p7 = forge.pkcs7.messageFromPem((file.buffer as Buffer).toString('utf-8'));
} else {
// Binary DER PKCS7
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
}
certs = (p7.certificates as forge.pki.Certificate[]) ?? [];
if (certs.length === 0) {
throw new Error('No certificates found in P7B/PKCS7 bundle');
}
} else {
// DER / PFX — not a valid chain/bundle format for splitting
throw new BadRequestException(
'Only PEM chains (.pem, .crt) and P7B bundles (.p7b) can be split',
);
}
} catch (err) {
if (err instanceof BadRequestException) throw err;
this.logger.warn('splitCerts: failed to parse bundle');
throw new BadRequestException('Failed to split certificates: invalid format or corrupted file');
}
// ── Build SplitResponse ────────────────────────────────────────────────
const certEntries: SplitEntry[] = certs.map((cert, index) => {
const pemStr = forge.pki.certificateToPem(cert);
const content = Buffer.from(pemStr, 'utf-8').toString('base64');
const cn: string = cert.subject.getField('CN')?.value ?? '';
const notAfter: string = cert.validity.notAfter.toISOString();
return {
index,
filename: `cert-${index + 1}.pem`,
content,
subject: { cn },
validity: { notAfter },
};
});
return {
count: certEntries.length,
certs: certEntries,
};
}
async mergeCerts(_input: {