636fe0df8f
- Aufgabe 2: vier sichere Biome-Regeln (useImportType pfadgebunden auf apps/web+packages, noUselessEscapeInRegex, useConst, useExponentiationOperator) sowie fuenf ungesicherte Regeln (useNodejsImportProtocol, useLiteralKeys, useOptionalChain, useTemplate, useParseIntRadix) angewendet und den gesamten Diff von Hand gelesen (ldap.service.ts zeichenweise gegen Gross-/Kleinschreibung der AD-Merkmale, auth.service.ts/jwt.strategy.ts gegen Durchwinken bei fehlender Sitzung geprueft) - noUselessSwitchCase bleibt bewusst stehen (tender-normalizer.service.ts:60, die Fallmarke dokumentiert Absicht) - Toter Code (D-03): fuenf folgenlose Auffangvariablen entfernt, eine nicht benutzte Funktion (forSystemQuery, Pruefskript) entfernt, ein positionsgebundener Dekoratorparameter umbenannt (current-user.decorator.ts), fuenf Symptomfunde entfernt und als Folgeaufgaben zu melden (siehe unten) - Sechs weitere, im Plan nicht namentlich gelistete aber gleich-kategorische Dead-Code-Fundstellen in Testdateien zusaetzlich bereinigt (groups.service.spec.ts, cert-manager.test.tsx, ldap.service.spec.ts, prisma-tenant.extension.spec.ts x3) — noetig, um die vom Plan selbst verlangten Nullstaende bei noUnusedVariables/ noUnusedImports/noUnusedFunctionParameters zu erreichen Dekoratordaten aus apps/api unveraendert (593 Zeilen, sha256 6e1583f1...). Endstand 620 Befunde (541 echt, 79 Test) statt der im Plan geschaetzten 621/542 — eine Differenz von 1, weil das Streichen des Namens aus `catch (e: any)` in calendar.service.ts (Symptom-Fix) den dort ebenfalls gemeldeten noExplicitAny-Befund miteliminiert; das ist eine erwuenschte Nebenwirkung, keine Regression. Fehlerstufe 0, beide Testlaeufe punktgleich gruen (69/1124, 66/459), pnpm type-check 4/4, pnpm lint --force 5/5. Folgeaufgaben aus D-03 (nicht in diesem Vorgang behoben): - force-password-change.interceptor.ts: Freigabeliste prueft nur den Pfad, nicht die HTTP-Methode - change-password/page.tsx: nach erzwungenem Wechsel bleibt die Person auf der Seite stehen (keine Weiterleitung, keine Aktualisierung der Benutzerablage) - VehicleTable.tsx: Loeschschaltflaeche hat keinen Besetztzustand, laesst sich doppelt ausloesen - SplitTab.tsx: downloadAllAsZip erhielt eine ungenutzte Uebersetzungsfunktion, Hinweis auf fest verdrahtete Texte im Zip-Pfad Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
701 lines
28 KiB
TypeScript
701 lines
28 KiB
TypeScript
import { BadRequestException, Injectable, Logger } 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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SplitResponse — the structured result returned by splitCerts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type CertRole = 'root' | 'intermediate' | 'end-entity';
|
|
|
|
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 };
|
|
certRole: CertRole;
|
|
}
|
|
|
|
export interface SplitResponse {
|
|
count: number;
|
|
certs: SplitEntry[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FileResponse — the structured result returned by convertCert / mergeCerts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface FileResponse {
|
|
/** Suggested download filename, e.g. "converted.der" */
|
|
filename: string;
|
|
/** Base64-encoded file content */
|
|
content: string;
|
|
/** MIME type for the download */
|
|
mimeType: string;
|
|
}
|
|
|
|
/** Map from target format key to MIME type */
|
|
const FORMAT_MIME: Record<string, string> = {
|
|
pem: 'application/x-pem-file',
|
|
der: 'application/x-x509-ca-cert',
|
|
p7b: 'application/x-pkcs7-certificates',
|
|
pfx: 'application/x-pkcs12',
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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.
|
|
*
|
|
* 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));
|
|
}
|
|
|
|
private detectCertRole(cert: forge.pki.Certificate): CertRole {
|
|
const bc = cert.getExtension('basicConstraints') as { cA?: boolean } | null;
|
|
if (!bc?.cA) return 'end-entity';
|
|
// Self-signed = subject hash matches issuer hash → Root CA
|
|
return cert.subject.hash === cert.issuer.hash ? 'root' : 'intermediate';
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseCert — CERT-01 + CERT-05 (read half)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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<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 {
|
|
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)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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<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');
|
|
}
|
|
|
|
// ── Determine cert roles ───────────────────────────────────────────────
|
|
const roles: CertRole[] = certs.map((cert) => this.detectCertRole(cert));
|
|
|
|
// Build counters for filename disambiguation
|
|
const roleCounters: Record<CertRole, number> = { root: 0, intermediate: 0, 'end-entity': 0 };
|
|
const roleFilename = (role: CertRole): string => {
|
|
roleCounters[role]++;
|
|
const n = roleCounters[role];
|
|
if (role === 'root') return n === 1 ? 'root-ca.pem' : `root-ca-${n}.pem`;
|
|
if (role === 'intermediate') return `intermediate-${n}.pem`;
|
|
return n === 1 ? 'cert.pem' : `cert-${n}.pem`;
|
|
};
|
|
|
|
// ── 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();
|
|
const certRole = roles[index];
|
|
|
|
return {
|
|
index,
|
|
filename: roleFilename(certRole),
|
|
content,
|
|
subject: { cn },
|
|
validity: { notAfter },
|
|
certRole,
|
|
};
|
|
});
|
|
|
|
return {
|
|
count: certEntries.length,
|
|
certs: certEntries,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Merge multiple certificate files into a PEM chain or a password-protected PFX/PKCS12 bundle.
|
|
*
|
|
* Security contract (T-09-01, T-09-02, T-09-03):
|
|
* - All forge calls wrapped in try/catch → BadRequestException on malformed input
|
|
* - Password required for PFX output; never logged or echoed
|
|
* - File size limit enforced by FilesInterceptor (controller level)
|
|
*
|
|
* Open Question 1 resolution: `forge.pkcs12.toPkcs12Asn1(null, certs, password)` was tested
|
|
* at implementation time — node-forge 1.4.0 accepts null as the private key for cert-only PFX.
|
|
* No fallback to lower-level certBag construction was needed.
|
|
*/
|
|
async mergeCerts(input: {
|
|
files?: any[];
|
|
outputFormat: string;
|
|
password?: string;
|
|
}): Promise<FileResponse> {
|
|
const { files, outputFormat, password } = input;
|
|
|
|
if (!files || files.length === 0) {
|
|
throw new BadRequestException('No files provided');
|
|
}
|
|
|
|
// PFX output requires a non-empty password (T-09-02)
|
|
if (outputFormat === 'pfx' && (!password || password.trim() === '')) {
|
|
throw new BadRequestException('A password is required for PFX output');
|
|
}
|
|
|
|
// ── Parse all input files to forge Certificate objects ────────────────────
|
|
let certs: forge.pki.Certificate[];
|
|
|
|
try {
|
|
certs = (files as any[]).flatMap((file: any) => {
|
|
const format = this.detectFormat(
|
|
file.originalname as string,
|
|
file.buffer as Buffer,
|
|
);
|
|
|
|
if (format === 'pem') {
|
|
const pemStr = (file.buffer as Buffer).toString('utf-8');
|
|
const parsed = this.parsePemChain(pemStr);
|
|
if (parsed.length === 0) {
|
|
throw new Error(`No certificate block found in ${file.originalname as string}`);
|
|
}
|
|
return parsed;
|
|
} else if (format === 'der') {
|
|
// CRITICAL: binary encoding, never utf-8 (RESEARCH Pitfall 1)
|
|
const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
|
|
return [forge.pki.certificateFromAsn1(asn1)];
|
|
} else if (format === 'pfx') {
|
|
// Extract all certs from the PFX bag
|
|
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] ?? [];
|
|
return bags.map((bag) => bag.cert!);
|
|
} else {
|
|
// P7B/PKCS7 — 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);
|
|
}
|
|
return (p7.certificates as forge.pki.Certificate[]) ?? [];
|
|
}
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof BadRequestException) throw err;
|
|
// Password never logged (T-09-02)
|
|
this.logger.warn('mergeCerts: failed to parse one or more input files');
|
|
throw new BadRequestException(
|
|
'Failed to parse certificate files: invalid format or corrupted input',
|
|
);
|
|
}
|
|
|
|
if (certs.length === 0) {
|
|
throw new BadRequestException('No valid certificates found in uploaded files');
|
|
}
|
|
|
|
// ── Serialize to requested output format ──────────────────────────────────
|
|
try {
|
|
if (outputFormat === 'pem') {
|
|
// PEM chain: concatenate all certs
|
|
const chain = certs.map((cert) => forge.pki.certificateToPem(cert)).join('\n');
|
|
const content = Buffer.from(chain, 'utf-8').toString('base64');
|
|
return {
|
|
filename: 'chain.pem',
|
|
content,
|
|
mimeType: FORMAT_MIME.pem,
|
|
};
|
|
} else if (outputFormat === 'pfx') {
|
|
// Open Question 1 resolution: toPkcs12Asn1(null, certs, password) works in node-forge 1.4.0
|
|
// null as the private key produces a cert-only PKCS12 bundle (no key bag — cert bag only)
|
|
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
|
|
null as any, // cert-only PFX — null key accepted by node-forge 1.4.0
|
|
certs,
|
|
password!,
|
|
{ algorithm: '3des' },
|
|
);
|
|
// CRITICAL: bytesToHex → Buffer.from(hex,'hex') — avoids utf-8 corruption (Pitfall 1)
|
|
const p12Hex = forge.util.bytesToHex(forge.asn1.toDer(p12Asn1).getBytes());
|
|
const pfxBuffer = Buffer.from(p12Hex, 'hex');
|
|
const content = pfxBuffer.toString('base64');
|
|
return {
|
|
filename: 'bundle.pfx',
|
|
content,
|
|
mimeType: FORMAT_MIME.pfx,
|
|
};
|
|
} else {
|
|
throw new BadRequestException(
|
|
`Unsupported output format: "${outputFormat}". Supported: pem, pfx`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof BadRequestException) throw err;
|
|
this.logger.warn('mergeCerts: failed to serialize merged output');
|
|
throw new BadRequestException('Failed to create merged certificate output');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert a certificate between PEM, DER, and P7B formats.
|
|
*
|
|
* Security contract (T-09-01, T-09-06):
|
|
* - All forge calls wrapped in try/catch → BadRequestException on malformed input
|
|
* - DER output built via bytesToHex → Buffer.from(hex, 'hex') → base64 (never utf-8 round-trip)
|
|
* - Password is never passed to the logger (T-09-02)
|
|
*/
|
|
async convertCert(input: {
|
|
file?: any;
|
|
pemText?: string;
|
|
targetFormat: string;
|
|
password?: string;
|
|
}): Promise<FileResponse> {
|
|
const { file, pemText, targetFormat, password } = input;
|
|
|
|
// ── Validate targetFormat ──────────────────────────────────────────────
|
|
if (!FORMAT_MIME[targetFormat]) {
|
|
throw new BadRequestException(
|
|
`Unsupported target format: "${targetFormat}". Supported: pem, der, p7b, pfx`,
|
|
);
|
|
}
|
|
|
|
// PFX output requires a non-empty password (T-09-02)
|
|
if (targetFormat === 'pfx' && (!password || password.trim() === '')) {
|
|
throw new BadRequestException('A password is required for PFX output');
|
|
}
|
|
|
|
let cert: forge.pki.Certificate;
|
|
|
|
try {
|
|
// ── Resolve input to a forge Certificate ────────────────────────────
|
|
if (pemText) {
|
|
// PEM text pasted by the user
|
|
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') {
|
|
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') {
|
|
// 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 — extract first cert bag (wrong password → BadRequestException)
|
|
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 — extract first cert
|
|
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 {
|
|
throw new BadRequestException('No file or PEM text provided');
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof BadRequestException) throw err;
|
|
// Password never logged (T-09-02)
|
|
this.logger.warn('convertCert: failed to parse input certificate');
|
|
throw new BadRequestException(
|
|
'Failed to parse certificate: invalid format or wrong password',
|
|
);
|
|
}
|
|
|
|
// ── Serialize to targetFormat ─────────────────────────────────────────
|
|
try {
|
|
let content: string;
|
|
|
|
if (targetFormat === 'pem') {
|
|
// PEM text → base64 via utf-8
|
|
const pemOut = forge.pki.certificateToPem(cert);
|
|
content = Buffer.from(pemOut, 'utf-8').toString('base64');
|
|
} else if (targetFormat === 'der') {
|
|
// DER binary — CRITICAL: bytesToHex → Buffer.from(hex, 'hex') → base64
|
|
// Avoids utf-8 round-trip corruption (RESEARCH Pitfall 1 / T-09-06)
|
|
const derHex = forge.util.bytesToHex(
|
|
forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(),
|
|
);
|
|
content = Buffer.from(derHex, 'hex').toString('base64');
|
|
} else if (targetFormat === 'p7b') {
|
|
// P7B — PEM-wrapped PKCS7 SignedData containing the certificate
|
|
const p7 = forge.pkcs7.createSignedData();
|
|
p7.addCertificate(cert);
|
|
const p7DerBytes = forge.asn1.toDer(p7.toAsn1()).getBytes();
|
|
const p7PemStr = forge.pem.encode({ type: 'PKCS7', body: p7DerBytes });
|
|
content = Buffer.from(p7PemStr, 'utf-8').toString('base64');
|
|
} else {
|
|
// PFX — cert-only PKCS12 bundle (Open Question 1: null key works in node-forge 1.4.0)
|
|
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
|
|
null as any, // cert-only PFX — null key accepted by node-forge 1.4.0
|
|
[cert],
|
|
password!,
|
|
{ algorithm: '3des' },
|
|
);
|
|
// CRITICAL: bytesToHex → Buffer.from(hex,'hex') — avoids utf-8 corruption (Pitfall 1)
|
|
const p12Hex = forge.util.bytesToHex(forge.asn1.toDer(p12Asn1).getBytes());
|
|
content = Buffer.from(p12Hex, 'hex').toString('base64');
|
|
return {
|
|
filename: 'converted.pfx',
|
|
content,
|
|
mimeType: FORMAT_MIME.pfx,
|
|
};
|
|
}
|
|
|
|
return {
|
|
filename: `converted.${targetFormat}`,
|
|
content,
|
|
mimeType: FORMAT_MIME[targetFormat],
|
|
};
|
|
} catch {
|
|
this.logger.warn('convertCert: failed to serialize to target format');
|
|
throw new BadRequestException(
|
|
`Failed to convert certificate to ${targetFormat}: serialization error`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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);
|
|
}
|
|
}
|
|
}
|