feat(09-03): GREEN — implement parseCert + export CertDetails interface

- Implemented CertManagerService.parseCert for PEM/DER/PFX/P7B inputs
- Exported CertDetails interface (subject, issuer, validity, san, keyType, keyBits, serialNumber, signatureAlgorithm, fingerprint, pemPreview)
- PFX with wrong password → BadRequestException (T-09-02: never logged, never echoed)
- All forge operations wrapped in try/catch → BadRequestException (T-09-01)
- buildReverseOids() converts OID → human-readable algorithm name
- P7B handles both PEM-wrapped and binary DER (RESEARCH Pitfall 4)
- Controller already wired correctly from Plan 01 (fileSize 5MB, pemText, file, password)
- All 16 cert-manager tests pass (16/16)
This commit is contained in:
2026-07-01 23:41:03 +02:00
parent 7c2e506a2e
commit ba994635e8
@@ -1,6 +1,36 @@
import { BadRequestException, Injectable, Logger, NotImplementedException } from '@nestjs/common';
import * as forge from 'node-forge';
// ---------------------------------------------------------------------------
// CertDetails — the structured result returned by parseCert
// ---------------------------------------------------------------------------
export interface CertDetails {
subject: { cn: string; o: string; ou: string; c: string };
issuer: { cn: string; o: string; c: string };
validity: { notBefore: string; notAfter: string; isExpired: boolean; daysLeft: number };
san: string[];
keyType: string; // "RSA" | "EC"
keyBits: number; // 2048, 4096, 256, ...
serialNumber: string;
signatureAlgorithm: string; // "sha256WithRSAEncryption", etc.
fingerprint: { sha1: string; sha256: string };
pemPreview: string;
}
// ---------------------------------------------------------------------------
// Reverse OID map (OID string -> human-readable algorithm name)
// Built once at module load — node-forge's pki.oids is name->OID
// ---------------------------------------------------------------------------
function buildReverseOids(): Record<string, string> {
const result: Record<string, string> = {};
for (const [name, oid] of Object.entries(forge.pki.oids as Record<string, string>)) {
result[oid] = name;
}
return result;
}
const REVERSE_OIDS = buildReverseOids();
/**
* CertManagerService — server-side certificate operations.
*
@@ -72,16 +102,166 @@ export class CertManagerService {
}
// ---------------------------------------------------------------------------
// Operation method stubs (implemented in later plan slices)
// parseCert — CERT-01 + CERT-05 (read half)
// ---------------------------------------------------------------------------
async parseCert(_input: {
/**
* Parse a certificate from PEM text or an uploaded file (PEM/DER/PFX/P7B).
*
* Security contract (T-09-01, T-09-02):
* - All forge calls wrapped in try/catch → BadRequestException (never an unhandled 500)
* - Wrong PFX password → generic 400 message (password value never logged or echoed)
*/
async parseCert(input: {
file?: any;
pemText?: string;
password?: string;
}): Promise<never> {
throw new NotImplementedException('parseCert is not yet implemented');
}): Promise<CertDetails> {
const { file, pemText, password } = input;
let cert: forge.pki.Certificate;
try {
if (pemText) {
// ── PEM text input ──────────────────────────────────────────────────
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') {
// ── PEM file ───────────────────────────────────────────────────────
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') {
// ── DER binary file ────────────────────────────────────────────────
// 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 file ───────────────────────────────────────────────
// wrong password → forge throws → caught below → BadRequestException (T-09-02)
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/PKCS7 file — 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);
}
const p7Certs: forge.pki.Certificate[] = p7.certificates ?? [];
if (p7Certs.length === 0) {
throw new Error('No certificate found in P7B/PKCS7');
}
cert = p7Certs[0];
}
} else {
// Neither file nor pemText — controller should have rejected this already,
// but guard here too (BadRequestException is NOT caught by the outer try/catch below)
throw new BadRequestException('No file or PEM text provided');
}
} catch (err) {
// Re-throw BadRequestException as-is; convert everything else to 400
if (err instanceof BadRequestException) throw err;
// Do NOT log the password (T-09-02)
this.logger.warn('parseCert: failed to parse certificate (format/password error)');
throw new BadRequestException(
'Failed to parse certificate: invalid format or wrong password',
);
}
// ── Build CertDetails ──────────────────────────────────────────────────
try {
const notBefore = cert.validity.notBefore;
const notAfter = cert.validity.notAfter;
const now = new Date();
const isExpired = notAfter < now;
const daysLeft = Math.ceil(
(notAfter.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
// Key type and size
const pubKey = cert.publicKey as any;
let keyType = 'RSA';
let keyBits = 0;
if (pubKey.n) {
keyType = 'RSA';
keyBits = pubKey.n.bitLength();
} else if (pubKey.curve) {
keyType = 'EC';
// EC key size from curve params — estimate from key length
keyBits = pubKey.params?.curve?.q?.bitLength() ?? 0;
}
// Subject Alternative Names
const sanExt = cert.extensions?.find((e: any) => e.name === 'subjectAltName');
const san: string[] = ((sanExt as any)?.altNames ?? []).map((n: any) =>
n.type === 2 ? (n.value as string) : `IP:${(n.ip ?? n.value) as string}`,
);
// Signature algorithm — OID → human-readable name
const sigOid = (cert.siginfo as any)?.algorithmOid ?? '';
const signatureAlgorithm = REVERSE_OIDS[sigOid] ?? sigOid;
// Fingerprints
const sha1 = this.getFingerprint(cert, 'sha1');
const sha256 = this.getFingerprint(cert, 'sha256');
return {
subject: {
cn: cert.subject.getField('CN')?.value ?? '',
o: cert.subject.getField('O')?.value ?? '',
ou: cert.subject.getField('OU')?.value ?? '',
c: cert.subject.getField('C')?.value ?? '',
},
issuer: {
cn: cert.issuer.getField('CN')?.value ?? '',
o: cert.issuer.getField('O')?.value ?? '',
c: cert.issuer.getField('C')?.value ?? '',
},
validity: {
notBefore: notBefore.toISOString(),
notAfter: notAfter.toISOString(),
isExpired,
daysLeft,
},
san,
keyType,
keyBits,
serialNumber: cert.serialNumber,
signatureAlgorithm,
fingerprint: { sha1, sha256 },
pemPreview: forge.pki.certificateToPem(cert),
};
} catch (err) {
this.logger.warn('parseCert: failed to extract CertDetails fields');
throw new BadRequestException('Failed to extract certificate details');
}
}
// ---------------------------------------------------------------------------
// Remaining operation stubs (implemented in later plan slices)
// ---------------------------------------------------------------------------
async splitCerts(_input: {
file?: any;