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 { BadRequestException, Injectable, Logger, NotImplementedException } from '@nestjs/common';
|
||||||
import * as forge from 'node-forge';
|
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.
|
* 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;
|
file?: any;
|
||||||
pemText?: string;
|
pemText?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
}): Promise<never> {
|
}): Promise<CertDetails> {
|
||||||
throw new NotImplementedException('parseCert is not yet implemented');
|
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: {
|
async splitCerts(_input: {
|
||||||
file?: any;
|
file?: any;
|
||||||
|
|||||||
Reference in New Issue
Block a user