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:
@@ -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,17 +102,167 @@ 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;
|
||||
password?: string;
|
||||
|
||||
Reference in New Issue
Block a user