Files
tessera-ctl/apps/api/src/cert-manager/cert-manager.service.ts
T
schalli d8fb9ae07d refactor(quick-260921-m34): Aufgabe 3c - Randschicht beurteilt, drei Befunde gemeldet, 15 bleiben mit Urteil
httpntlm (exchange.provider, exchange-inbox.provider): NtlmOptions und
NtlmResponse beschreiben genau das, was uebergeben und gelesen wird. Die
ueberfluessige Zusicherung (httpntlm as any) faellt weg.

Graph-Rueckrufe (exchange.provider :157/:313): AuthProviderCallback aus dem
SDK selbst statt Handannotation - als import type, also ohne den dynamischen
Import zur Laufzeit zurueckzunehmen.

imapflow: streamToBuffer() nimmt Readable statt NodeJS.ReadableStream (alle
drei Aufrufer reichen client.download().content herein, imapflow deklariert
das als Readable) - damit traegt der Typ destroy() und die Zusicherung
faellt. node.parameters?.name war ebenfalls schon getypt.

nodemailer: ResolvedTransport.options wird SMTPTransport.Options; beide
Zweige bauen reine SMTP-Optionen, createTransport() nimmt sie ohne
Zusicherung.

node-forge: die vier let p7: any werden Captured<PkcsEnvelopedData |
PkcsSignedData> - der MITGELIEFERTE Typ. Die Lesestellen grenzen mit
'certificates' in p7 ein statt zuzusichern; verhaltensgleich, weil der
enveloped-Form das Feld fehlt und beide Schreibweisen dann die leere Liste
liefern. cert.siginfo war bereits getypt.

apps/web/src/test/setup.ts: expect.extend(matchers) traegt ohne Zusicherung
- geprueft im echten Typlauf (setup.ts liegt im include von
apps/web/tsconfig.json, mit einem absichtlichen Fehler nachgewiesen).

BEFUND 4 (D-03, gemeldet, NICHT repariert) imap.provider.ts:78 - der
Ausdruck (node as any).disposition?.parameters?.filename liest .parameters
von einer ZEICHENKETTE: imapflow deklariert disposition als string
(imap-flow.d.ts:448), die Parameter liegen in dispositionParameters (:450).
dispositionFilename ist damit zur Laufzeit immer ''. Folge: Outlook-Anhaenge,
die als application/octet-stream kommen, werden ueber den Dateinamen aus
Content-Disposition NICHT erkannt - nur ueber den aus Content-Type. Umbiegen
waere eine Verhaltensaenderung; die Zusicherung bleibt sichtbar stehen.

BEFUND 5 (D-03, gemeldet, NICHT repariert) imap.provider.ts:402 -
requireTLS kommt in imapflow 1.4.3 NIRGENDS vor, weder in ImapFlowOptions
noch im Laufzeitcode (beides durchsucht). Die Option wird still verworfen;
STARTTLS wird durch sie nicht erzwungen. Genau das } as any hat es
verdeckt. Bleibt stehen, damit der Befund in der Zaehlung sichtbar ist.

BEFUND 6 (D-03, gemeldet, Verhalten unveraendert) httpntlm liefert den
Rumpf als Zeichenkette, nicht als Buffer: httpreq setzt ihn nur bei
gesetzter Option binary auf Buffer (httpreq@1.1.1/lib/httpreq.js:391),
keiner der beiden Aufrufer setzt sie. Der Bestand rief unbesehen
.toString('utf-8') auf - das ging nur gut, weil String.toString() sein
Argument ignoriert. Die Testdoppel reichen dagegen wirklich Buffer herein.
NtlmResponse.body nennt jetzt beide Formen, die Fallunterscheidung liefert
fuer jede exakt dasselbe Ergebnis wie zuvor.

Urteil BLEIBT mit Begruendung im Code an allen 15 verbleibenden Stellen:
3x addCronJob (require-Umweg aus 07-04), 5x node-forge (EC-Zweig und
extensions: any[] sind in @types/node-forge nicht beschrieben, 2x null as
any wo die Typen die Bibliothek nachweislich falsch beschreiben), 2x
imap-Befunde oben, 2x tx: any plus 2x Gefolge (Aufgabe 1), 1x
disposition-Befund.

noExplicitAny in apps/api/src: 31 -> 15 (Ausgang 288, Schranke 45), apps/web
1 -> 0. type-check 4/4, lint 5/5 (0 error), apps/api 72/1143, apps/web
73/531, rls-access-inventory 30/30. noNonNullAssertion 56, as unknown as 33,
ts-expect-error/ts-ignore 0/0, Unterdrueckungsmarker 1. biome.json, alle
package.json und pnpm-lock.yaml unveraendert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 17:29:28 +02:00

794 lines
34 KiB
TypeScript

import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import * as forge from 'node-forge';
/**
* Was `forge.pkcs7.messageFromPem()` bzw. `messageFromAsn1()` zurueckgeben —
* der mitgelieferte Typ aus `@types/node-forge`, nicht ein eigener.
*
* Nur die signierte Form traegt `certificates`; die Lesestellen grenzen
* deshalb mit `'certificates' in p7` ein. Das ist verhaltensgleich zum
* bisherigen `p7.certificates ?? []`: bei einer enveloped-Nachricht fehlt
* das Feld, und beide Schreibweisen liefern dann die leere Liste.
*/
type P7Message = forge.pkcs7.Captured<
forge.pkcs7.PkcsEnvelopedData | forge.pkcs7.PkcsSignedData
>;
import type { UploadedFileLike } from '../auth/types/auth-user';
/**
* Eine hochgeladene Zertifikatsdatei, so weit dieser Dienst sie liest:
* Inhalt und eingereichter Name (der Name geht ausschliesslich in
* `detectFormat` und in Fehlermeldungen). Abgeleitet aus `UploadedFileLike`
* statt daneben erfunden (quick-260921-m34); `mimetype` und `size` bleiben
* bewusst draussen, weil kein Zweig dieses Dienstes sie liest.
*/
type CertFileLike = Pick<UploadedFileLike, 'buffer' | 'originalname'>;
// ---------------------------------------------------------------------------
// 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?: CertFileLike;
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, file.buffer);
if (format === 'pem') {
// ── PEM file ───────────────────────────────────────────────────────
const pemStr = file.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));
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));
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');
}
// node-forge sets bag.cert to null when the bag's content parses as
// valid DER but is not a readable X.509 certificate (lib/pkcs12.js
// certBag decoder). An explicit guard here — not an assertion — so
// the 400 names the real cause (quick-260921-iwr, D-01/D-04).
const parsedCert = bags[0].cert;
if (!parsedCert) {
throw new BadRequestException(
'Certificate bag in PFX/PKCS12 does not contain a readable X.509 certificate',
);
}
cert = parsedCert;
} else {
// ── P7B/PKCS7 file — PEM-wrapped or binary DER (Pitfall 4) ────────
const isPemP7b = file.buffer
.slice(0, 27)
.toString('ascii')
.includes('-----BEGIN');
// siehe P7Message oben — mitgelieferter Typ, keine Behauptung.
let p7: P7Message;
if (isPemP7b) {
p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8'));
} else {
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
}
const p7Certs: forge.pki.Certificate[] =
'certificates' in p7 ? 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
// BLEIBT als any, mit Begruendung (260921-m34, Aufgabe 3c, D-01/D-02):
// @types/node-forge kennt nur `PublicKey = rsa.PublicKey | ed25519.Key`
// (index.d.ts:232). Der EC-Zweig unten liest `curve` und
// `params.curve.q.bitLength()` — Felder, die node-forge zur Laufzeit
// liefert, die der mitgelieferte Typ aber GAR NICHT kennt. Eine
// Umdeutung ueber zwei Stufen wuerde dieselbe Luecke verdecken und
// zusaetzlich so aussehen, als sei sie geprueft. Ein ehrliches any mit
// dieser Zeile ist hier das bessere Ergebnis.
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
//
// BLEIBEN als any, mit Begruendung (260921-m34, Aufgabe 3c, D-01/D-02):
// @types/node-forge deklariert `Certificate.extensions` als `any[]`
// (index.d.ts:435) und sagt damit ueber den Inhalt einer Erweiterung
// NICHTS aus. Jede Schnittstelle, die wir hier selbst fuer `altNames`
// schrieben, waere unbelegt — der Compiler koennte sie an keiner
// Stelle gegen etwas pruefen, sie saehe aber geprueft aus. Die drei
// any-Stellen dieses Blocks bleiben deshalb sichtbar stehen, statt
// gegen eine Behauptung getauscht zu werden.
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
// @types/node-forge deklariert siginfo.algorithmOid als string — die
// Zusicherung war ueberfluessig. Das ?. bleibt woertlich erhalten.
const sigOid = cert.siginfo?.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?: CertFileLike;
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, file.buffer);
if (format === 'pem') {
// ── PEM chain (fullchain.pem, .crt — both map to 'pem' in detectFormat) ─
const pemStr = file.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
.slice(0, 27)
.toString('ascii')
.includes('-----BEGIN');
// Der mitgelieferte Typ traegt hier: messageFromPem/messageFromAsn1
// liefern beide Captured<PkcsEnvelopedData | PkcsSignedData>.
let p7: P7Message;
if (isPemP7b) {
// PEM-wrapped PKCS7 (e.g. -----BEGIN PKCS7-----)
p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8'));
} else {
// Binary DER PKCS7
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
}
certs = 'certificates' in p7 ? p7.certificates : [];
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?: CertFileLike[];
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.flatMap((file) => {
const format = this.detectFormat(file.originalname, file.buffer);
if (format === 'pem') {
const pemStr = file.buffer.toString('utf-8');
const parsed = this.parsePemChain(pemStr);
if (parsed.length === 0) {
throw new Error(`No certificate block found in ${file.originalname}`);
}
return parsed;
} else if (format === 'der') {
// CRITICAL: binary encoding, never utf-8 (RESEARCH Pitfall 1)
const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.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));
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? '');
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
const bags = certBags[forge.pki.oids.certBag] ?? [];
// node-forge sets bag.cert to null when a bag's content parses as
// valid DER but is not a readable X.509 certificate (lib/pkcs12.js
// certBag decoder). No entry may be silently dropped (D-04) — check
// every bag and reject the whole file, naming it, before returning.
const bagCerts = bags.map((bag) => bag.cert);
// @types/node-forge declares Bag.cert as `Certificate | undefined`,
// but node-forge's own runtime sets it to `null` for an unreadable
// bag (lib/pkcs12.js certBag decoder) — check both, not just `===
// undefined`, so the guard actually catches what the library does.
const missingCertIndex = bagCerts.findIndex((c) => c === undefined || c === null);
if (missingCertIndex !== -1) {
throw new BadRequestException(
`Certificate bag in "${file.originalname}" does not contain a readable X.509 certificate`,
);
}
return bagCerts.filter((c): c is forge.pki.Certificate => c !== undefined && c !== null);
} else {
// P7B/PKCS7 — PEM-wrapped or binary DER (Pitfall 4)
const isPemP7b = file.buffer
.slice(0, 27)
.toString('ascii')
.includes('-----BEGIN');
// siehe P7Message oben — mitgelieferter Typ, keine Behauptung.
let p7: P7Message;
if (isPemP7b) {
p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8'));
} else {
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
}
return 'certificates' in p7 ? p7.certificates : [];
}
});
} 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(
// BLEIBT (260921-m34, Aufgabe 3c): node-forge 1.4.0 nimmt hier einen
// fehlenden Schluessel an und erzeugt ein reines
// Zertifikatsbuendel; @types/node-forge schliesst null aus. Die
// mitgelieferten Typen beschreiben die Bibliothek an dieser Stelle
// also nachweislich falsch — ein erzwungener Typ waere eine
// Behauptung ueber etwas, das nicht stimmt.
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?: CertFileLike;
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, file.buffer);
if (format === 'pem') {
const pemStr = file.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));
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));
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');
}
// node-forge sets bag.cert to null when the bag's content parses as
// valid DER but is not a readable X.509 certificate (lib/pkcs12.js
// certBag decoder). An explicit guard here — not an assertion — so
// the 400 names the real cause (quick-260921-iwr, D-01/D-04).
const parsedCert = bags[0].cert;
if (!parsedCert) {
throw new BadRequestException(
'Certificate bag in PFX/PKCS12 does not contain a readable X.509 certificate',
);
}
cert = parsedCert;
} else {
// P7B — extract first cert
const isPemP7b = file.buffer
.slice(0, 27)
.toString('ascii')
.includes('-----BEGIN');
// siehe P7Message oben — mitgelieferter Typ, keine Behauptung.
let p7: P7Message;
if (isPemP7b) {
p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8'));
} else {
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
}
const p7Certs: forge.pki.Certificate[] =
'certificates' in p7 ? 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(
// BLEIBT (260921-m34, Aufgabe 3c): node-forge 1.4.0 nimmt hier einen
// fehlenden Schluessel an und erzeugt ein reines
// Zertifikatsbuendel; @types/node-forge schliesst null aus. Die
// mitgelieferten Typen beschreiben die Bibliothek an dieser Stelle
// also nachweislich falsch — ein erzwungener Typ waere eine
// Behauptung ueber etwas, das nicht stimmt.
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);
}
}
}