feat(09-01): scaffold cert-manager module + shared node-forge helpers (GREEN)
- cert-manager.module.ts: OnModuleInit + seedCertManagerModule (CERT-06)
- cert-manager.seed.ts: slug='cert-manager', category='security-tools', isSystem=true
- cert-manager.service.ts: detectFormat, toForgeBuffer, getFingerprint, parsePemChain;
operation stubs parseCert/splitCerts/mergeCerts/convertCert throw NotImplementedException
- cert-manager.controller.ts: 4 POST routes with FileInterceptor/FilesInterceptor
(5 MB limit each), @UseModule('cert-manager') guard, BadRequestException on missing input
- dto/: ParseCertDto, MergeCertsDto, ConvertCertDto
- app.module.ts: CertManagerModule added to imports array
- All 11 Vitest tests pass; type-check clean
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import { BadRequestException, Injectable, Logger, NotImplementedException } from '@nestjs/common';
|
||||
import * as forge from 'node-forge';
|
||||
|
||||
/**
|
||||
* CertManagerService — server-side certificate operations.
|
||||
*
|
||||
* All cryptographic processing is ephemeral (upload → process → return).
|
||||
* No data is persisted to disk or database.
|
||||
*
|
||||
* SECURITY NOTES:
|
||||
* - Binary buffers MUST use toString('binary') for forge (never 'utf-8' — Pitfall 1)
|
||||
* - Password parameters are never passed to the logger
|
||||
* - All forge operations wrapped in try/catch → BadRequestException
|
||||
*/
|
||||
@Injectable()
|
||||
export class CertManagerService {
|
||||
private readonly logger = new Logger(CertManagerService.name);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers (used by all operation methods)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect the format of a certificate file from extension + content sniff.
|
||||
* .cer is ambiguous — resolved by inspecting the first bytes of the buffer.
|
||||
*/
|
||||
detectFormat(
|
||||
filename: string,
|
||||
buffer: Buffer,
|
||||
): 'pem' | 'der' | 'pfx' | 'p7b' {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
|
||||
const isPemContent = buffer.slice(0, 27).toString('ascii').includes('-----BEGIN');
|
||||
|
||||
if (ext === 'pfx' || ext === 'p12') return 'pfx';
|
||||
if (ext === 'p7b' || ext === 'p7c') return 'p7b';
|
||||
if (ext === 'der') return 'der';
|
||||
if (ext === 'pem' || ext === 'crt') return 'pem';
|
||||
if (ext === 'cer') return isPemContent ? 'pem' : 'der'; // .cer is ambiguous
|
||||
// Fallback: sniff content
|
||||
return isPemContent ? 'pem' : 'der';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Node.js Buffer to a forge ByteStringBuffer using 'binary' encoding.
|
||||
*
|
||||
* CRITICAL: Always use 'binary' encoding — UTF-8 corrupts DER/PFX/P7B binary data.
|
||||
* See RESEARCH.md Pitfall 1.
|
||||
*/
|
||||
toForgeBuffer(buffer: Buffer): forge.util.ByteStringBuffer {
|
||||
return forge.util.createBuffer(buffer.toString('binary'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SHA-1 or SHA-256 fingerprint of a certificate.
|
||||
* Hash is computed over the DER-encoded bytes, returned as uppercase colon-joined hex.
|
||||
*/
|
||||
getFingerprint(cert: forge.pki.Certificate, algorithm: 'sha1' | 'sha256'): string {
|
||||
const md = algorithm === 'sha1' ? forge.md.sha1.create() : forge.md.sha256.create();
|
||||
const der = forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes();
|
||||
md.update(der);
|
||||
return md.digest().toHex().match(/.{2}/g)!.join(':').toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a PEM string containing one or more concatenated certificates.
|
||||
* Returns an array of parsed forge Certificate objects.
|
||||
*/
|
||||
parsePemChain(pem: string): forge.pki.Certificate[] {
|
||||
const blocks =
|
||||
pem.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? [];
|
||||
return blocks.map((b) => forge.pki.certificateFromPem(b));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operation method stubs (implemented in later plan slices)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async parseCert(_input: {
|
||||
file?: any;
|
||||
pemText?: string;
|
||||
password?: string;
|
||||
}): Promise<never> {
|
||||
throw new NotImplementedException('parseCert is not yet implemented');
|
||||
}
|
||||
|
||||
async splitCerts(_input: {
|
||||
file?: any;
|
||||
password?: string;
|
||||
}): Promise<never> {
|
||||
throw new NotImplementedException('splitCerts is not yet implemented');
|
||||
}
|
||||
|
||||
async mergeCerts(_input: {
|
||||
files?: any[];
|
||||
outputFormat: string;
|
||||
password?: string;
|
||||
}): Promise<never> {
|
||||
throw new NotImplementedException('mergeCerts is not yet implemented');
|
||||
}
|
||||
|
||||
async convertCert(_input: {
|
||||
file?: any;
|
||||
targetFormat: string;
|
||||
password?: string;
|
||||
}): Promise<never> {
|
||||
throw new NotImplementedException('convertCert is not yet implemented');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers for later slices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Wrap a node-forge operation and re-throw as BadRequestException on failure */
|
||||
protected _parseOrThrow<T>(fn: () => T, errorMsg: string): T {
|
||||
try {
|
||||
return fn();
|
||||
} catch (_err) {
|
||||
this.logger.warn(`Cert parse failed: ${errorMsg}`);
|
||||
throw new BadRequestException(errorMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user