35 KiB
Phase 9: Cert Manager Module - Research
Researched: 2026-07-01 Domain: X.509 certificate processing — node-forge, NestJS multipart upload, binary download, module registry pattern Confidence: HIGH (core patterns verified from codebase; node-forge API tagged per source)
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
- Server-side API — All crypto operations in NestJS backend, not client-side JS
- Ephemeral — No Prisma schema changes, no DB tables, no file storage. Upload → process → return result/download
- Files held in memory during request only (multer
memoryStorage) - Library:
node-forge— PEM, DER, PFX/PKCS12, P7B/PKCS7 in one pure-JS package. No native bindings. - Slug:
cert-manager, Category:security-tools - Module pattern:
OnModuleInitseed +@UseModule('cert-manager')guard (same as domaincheck) - API prefix:
/modules/cert-manager - Endpoints:
POST /parse,POST /split,POST /merge,POST /convert - Frontend path:
apps/web/src/app/(portal)/modules/cert-manager/ - Tab UI: Analysieren | Aufteilen | Zusammenführen | Konvertieren
- No new frontend dependencies (file input + fetch already available)
Claude's Discretion
- Internal service structure within
apps/api/src/cert-manager/(file count, method split) - Response shape for parsed cert details (JSON field names)
- Download mechanism: base64 JSON response vs. binary streaming
- File size limits (suggested: 5 MB per file — generous for any cert format)
- Test coverage scope within Vitest
Deferred Ideas (OUT OF SCOPE)
- Certificate expiry monitoring / alerts (needs DB + cron)
- Certificate store / saved cert library (needs DB)
- OCSP / CRL revocation check
- Private key generation </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| CERT-01 | User can upload a cert file (PEM, DER, PFX/P12, CRT, CER, P7B) or paste PEM/CRT text and see parsed details (subject, issuer, validity, SANs, fingerprint) | node-forge certificateFromPem, certificateFromAsn1, pkcs12FromAsn1 — all formats parseable |
| CERT-02 | User can split a fullchain.pem or P7B bundle into individual certificate files (downloadable) | forge PEM regex split + pkcs7.messageFromPem for P7B; JSON array of { filename, content_base64 } enables per-cert download |
| CERT-03 | User can merge multiple cert files into a PEM chain or a PFX bundle (with password) | FilesInterceptor for multi-upload; PEM chain = certificateToPem() concatenation; PFX = forge.pkcs12.toPkcs12Asn1() |
| CERT-04 | User can convert between PEM, DER, PFX/P12, P7B, CRT/CER formats | node-forge handles all conversions via parse + re-serialize |
| CERT-05 | Password-protected PFX/PKCS12 files can be opened (password prompt) and created (password input) | pkcs12FromAsn1(asn1, password) for read; toPkcs12Asn1(key, certs, password) for write |
| CERT-06 | Module appears in the module registry with slug cert-manager |
seedModule({ slug: 'cert-manager', category: 'security-tools', isSystem: true }) in OnModuleInit |
| </phase_requirements> |
Summary
Phase 9 adds a server-side certificate toolkit as a Tessera module. All cryptographic operations happen in the NestJS API using node-forge (pure JS, no native bindings, Docker-friendly). The frontend is a tab-based page following the established domaincheck/dkv-fleet pattern — no new deps, no shadcn. No Prisma schema changes are needed since all processing is ephemeral (upload → process → return).
The implementation closely mirrors the existing domaincheck module (single-operation pattern) and the DKV module's file upload pattern. The primary novel elements are: (1) multi-file upload for the merge endpoint using FilesInterceptor instead of FileInterceptor, and (2) returning binary cert data as base64 JSON so the frontend can create blob download URLs without re-fetching.
The isModuleActive check in ModuleGuard requires a TenantModuleActivation record. The module seed (isSystem: true) registers the module in the registry but does NOT auto-activate it per tenant. In development/testing, the cert-manager module must be activated via the marketplace UI (or a direct DB insert) before API endpoints are accessible.
Primary recommendation: Follow domaincheck module structure exactly (module.ts + seed.ts + controller.ts + service.ts + dto/). Add FilesInterceptor for the merge endpoint (the only multi-file endpoint). Return all binary output as { filename: string, content: string } (base64) in JSON responses — simpler than streaming binary and consistent with the ephemeral design.
Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|---|---|---|---|
| Certificate parsing (PEM/DER/PFX/P7B) | API / Backend | — | Crypto operations must not run in browser (security, consistency); node-forge is a server dep |
| Format conversion | API / Backend | — | Same as parsing — all conversion logic in NestJS service |
| File upload buffering | API / Backend | — | multer memoryStorage on NestJS — no disk writes |
| Binary download response | API / Backend | Frontend Server (SSR) | Backend sets filename via JSON response; frontend creates blob URL |
| Tab UI / file drop zone | Browser / Client | — | Interactive client component; no SSR needed for cert operations |
| Module registry seed | API / Backend | — | OnModuleInit at startup — same as domaincheck |
| Module activation gate | API / Backend | — | ModuleGuard checks TenantModuleActivation table per request |
| i18n strings | Frontend Server (SSR) | Browser / Client | useTranslations('certManager') per project convention |
Standard Stack
Core (new dependency)
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| node-forge | 1.4.0 | PEM/DER/PFX/PKCS7 parse + serialize | Pure JS (no native bindings), all cert formats in one package, 35M downloads/week, official DigitalBazaar package [VERIFIED: npm registry] |
| @types/node-forge | 1.3.14 | TypeScript types for node-forge | DefinitelyTyped — standard types companion [VERIFIED: npm registry] |
Already Available (no install needed)
| Library | Purpose | Source |
|---|---|---|
@nestjs/platform-express multer |
File upload (FileInterceptor, FilesInterceptor) | Already in apps/api/package.json |
express.Response |
Binary download via res.setHeader + res.send |
Already in apps/api/package.json |
@nestjs/common (FileInterceptor, FilesInterceptor, UploadedFile, UploadedFiles) |
NestJS decorators for upload | Part of @nestjs/platform-express |
Installation
cd /home/vicolab/projects/tessera-ctl
pnpm --filter @tessera/api add node-forge@^1.4.0
pnpm --filter @tessera/api add -D @types/node-forge@^1.3.14
Package Legitimacy Audit
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|---|---|---|---|---|---|---|
| node-forge | npm | ~10 yrs | 35.3M/wk | github.com/digitalbazaar/forge | OK | Approved |
| @types/node-forge | npm | ~8 yrs | 12.8M/wk | github.com/DefinitelyTyped | OK | Approved |
Packages removed due to SLOP verdict: none Packages flagged as suspicious (SUS): none
Registry verified 2026-07-01 via npm view node-forge version (1.4.0) and npm view @types/node-forge version (1.3.14).
Architecture Patterns
System Architecture Diagram
Browser
|
| multipart/form-data (file + optional password)
v
Next.js Client Component (cert-manager/page.tsx)
|
| fetch POST /modules/cert-manager/{parse|split|merge|convert}
| credentials: 'include' (JWT cookie)
v
NestJS API — CertManagerController
|-- ModuleGuard: isModuleActive(tenantId, 'cert-manager') → 403 if not activated
|-- FileInterceptor (parse/split/convert) OR FilesInterceptor (merge)
| multer memoryStorage → file.buffer (no disk write)
v
CertManagerService
|-- detect format (extension + content sniff for .cer ambiguity)
|-- node-forge: parse → forge.pki.Certificate object(s)
|-- node-forge: serialize → target format
v
JSON response: { result } or { filename, content } or { certs: [{ filename, content }] }
|
v
Next.js Client Component
|-- Parse: render key-value grid
|-- Split/Merge/Convert: base64 decode → Blob → URL.createObjectURL → <a download>
Recommended Project Structure
apps/api/src/cert-manager/
├── cert-manager.module.ts # OnModuleInit + seedModule
├── cert-manager.seed.ts # seedModule({ slug: 'cert-manager', ... })
├── cert-manager.controller.ts # 4 POST endpoints, @UseModule guard
├── cert-manager.service.ts # node-forge operations
└── dto/
├── parse-cert.dto.ts # optional: text paste (PEM string body)
├── merge-certs.dto.ts # outputFormat + optional password (from @Body)
└── convert-cert.dto.ts # targetFormat + optional password
apps/web/src/app/(portal)/modules/cert-manager/
├── page.tsx # 'use client', tab UI
├── actions.ts # fetch wrappers to API
└── components/
├── DropZone.tsx # file drop + click-to-browse
├── InspectTab.tsx # key-value result grid
├── SplitTab.tsx # list of downloadable certs
├── MergeTab.tsx # format selector + password + download
└── ConvertTab.tsx # format selector + password + download
apps/web/src/messages/de.json # add certManager namespace
apps/web/src/messages/en.json # add certManager namespace
Pattern 1: Module Registration (OnModuleInit + seedModule)
What: Each Tessera module seeds itself into the registry on startup via OnModuleInit.
When to use: Every new module — this is the standard pattern.
// Source: apps/api/src/domaincheck/domaincheck.module.ts [VERIFIED: codebase]
@Module({
imports: [ModuleRegistryModule],
controllers: [CertManagerController],
providers: [CertManagerService],
})
export class CertManagerModule implements OnModuleInit {
private readonly logger = new Logger(CertManagerModule.name);
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
async onModuleInit(): Promise<void> {
try {
await seedCertManagerModule(this.moduleRegistryService);
this.logger.log('Cert-Manager module seeded in registry');
} catch (error) {
this.logger.error('Failed to seed cert-manager module', error);
}
}
}
// cert-manager.seed.ts
export async function seedCertManagerModule(
moduleRegistryService: ModuleRegistryService,
): Promise<void> {
await moduleRegistryService.seedModule({
slug: 'cert-manager',
name: 'Cert Manager',
version: '1.0.0',
category: 'security-tools',
description: {
de: 'Zertifikate analysieren, konvertieren und verwalten',
en: 'Inspect, convert and manage certificates',
},
isSystem: true,
});
}
Pattern 2: Single File Upload (parse, split, convert)
What: FileInterceptor from @nestjs/platform-express buffers the file in memory. File arrives as file.buffer (Node.js Buffer). [VERIFIED: codebase — apps/api/src/dkv/dkv.controller.ts]
// Source: apps/api/src/dkv/dkv.controller.ts (adapted) [VERIFIED: codebase]
@Post('parse')
@UseModule('cert-manager')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB — generous for any cert format
}))
async parseCert(
@Req() req: any,
@UploadedFile() file: any,
@Body('password') password?: string,
@Body('pemText') pemText?: string,
) {
// file may be undefined if PEM text was pasted instead
return this.certManagerService.parseCert({ file, pemText, password });
}
Pattern 3: Multiple File Upload (merge)
What: FilesInterceptor (plural) from @nestjs/platform-express returns an array of multer files.
When to use: Merge endpoint only — accepts multiple cert files.
// Source: @nestjs/platform-express docs [ASSUMED — pattern not yet in codebase]
import { FilesInterceptor } from '@nestjs/platform-express';
@Post('merge')
@UseModule('cert-manager')
@UseInterceptors(FilesInterceptor('files', 20, {
limits: { fileSize: 5 * 1024 * 1024 },
}))
async mergeCerts(
@UploadedFiles() files: any[],
@Body('outputFormat') outputFormat: string, // 'pem' | 'pfx'
@Body('password') password?: string,
) {
return this.certManagerService.mergeCerts({ files, outputFormat, password });
}
Frontend FormData for multiple files:
// Source: pattern derived from DKV CSV import [VERIFIED: codebase]
const form = new FormData();
files.forEach(file => form.append('files', file)); // same field name, multiple values
form.append('outputFormat', 'pem');
await fetch(`${API_URL}/modules/cert-manager/merge`, {
method: 'POST',
body: form,
credentials: 'include',
// Do NOT set Content-Type — fetch sets multipart/form-data + boundary
});
Pattern 4: Binary Download Response
What: API returns { filename: string, content: string } where content is base64-encoded cert bytes. Frontend creates a blob URL for download. [ASSUMED — base64 JSON approach not yet used in codebase; DKV uses direct URL anchor which requires GET + no cookies barrier]
Rationale: Cert-manager endpoints are POST (not GET), so direct anchor href cannot send cookie auth. Base64 JSON is the correct pattern for POST-based authenticated binary downloads.
// Backend: return base64 JSON
return {
filename: 'certificate.pem',
content: Buffer.from(pemString, 'utf-8').toString('base64'),
mimeType: 'application/x-pem-file',
};
// or for DER/PFX:
return {
filename: 'certificate.der',
content: derBuffer.toString('base64'),
mimeType: 'application/x-x509-ca-cert',
};
// Frontend: blob URL download trigger
function downloadBase64(filename: string, content: string, mimeType: string) {
const bytes = Uint8Array.from(atob(content), c => c.charCodeAt(0));
const blob = new Blob([bytes], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
Pattern 5: node-forge Cert Operations
What: Core crypto library operations for certificate handling. [ASSUMED — training knowledge; not fetched from Context7 in this session due to disabled external search]
CRITICAL: Buffer → forge conversion. node-forge uses its own ByteStringBuffer. Use 'binary' encoding, never 'utf-8', for binary cert formats:
import * as forge from 'node-forge';
// PEM parse (text content)
const cert = forge.pki.certificateFromPem(pemString);
// DER parse (binary buffer — use 'binary' encoding!)
const asn1 = forge.asn1.fromDer(forge.util.createBuffer(derBuffer.toString('binary')));
const cert = forge.pki.certificateFromAsn1(asn1);
// PFX/PKCS12 parse
const p12Asn1 = forge.asn1.fromDer(forge.util.createBuffer(pfxBuffer.toString('binary')));
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? '');
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
const certs = (certBags[forge.pki.oids.certBag] ?? []).map(bag => bag.cert!);
// P7B (PKCS7) parse — can be PEM-wrapped or DER
// PEM-wrapped:
const p7 = forge.pkcs7.messageFromPem(p7bPemString);
const certs = p7.certificates ?? [];
// DER:
const p7Asn1 = forge.asn1.fromDer(forge.util.createBuffer(p7bBuffer.toString('binary')));
const p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
// Parse PEM chain (fullchain.pem — multiple certs in one file)
function 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));
}
// Cert → PEM output
const pemOut = forge.pki.certificateToPem(cert);
// Cert → DER output (Node.js Buffer)
const derHex = forge.util.bytesToHex(
forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes()
);
const derBuffer = Buffer.from(derHex, 'hex');
// Cert → PFX/PKCS12 (with or without private key)
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
privateKey ?? null, // null = cert-only bundle [ASSUMED: null allowed]
[cert],
password,
{ algorithm: '3des' }
);
const p12Hex = forge.util.bytesToHex(forge.asn1.toDer(p12Asn1).getBytes());
const pfxBuffer = Buffer.from(p12Hex, 'hex');
// Fingerprint (SHA-1 or SHA-256)
function 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();
}
// Subject/Issuer fields
const cn = cert.subject.getField('CN')?.value ?? '';
const o = cert.subject.getField('O')?.value ?? '';
// SANs (Subject Alternative Names)
const sanExt = cert.extensions.find(e => e.name === 'subjectAltName');
const sans: string[] = (sanExt?.altNames ?? []).map((n: any) =>
n.type === 2 ? n.value : `IP:${n.ip ?? n.value}`
);
// RSA key size
const publicKey = cert.publicKey as forge.pki.rsa.PublicKey;
const keyBits = publicKey.n?.bitLength() ?? 0;
Pattern 6: Format Detection (extension + content sniff)
What: .cer files are ambiguous — can be PEM or DER. Sniff content to resolve. [ASSUMED]
function 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';
}
Anti-Patterns to Avoid
- Using
file.buffer.toString('utf-8')for DER/PFX/P7B: Binary formats use arbitrary bytes — UTF-8 decoding corrupts them. Always use'binary'encoding. - Using
hexencoding increateBuffer:forge.util.createBuffer(buf.toString('hex'))is wrong. Usebuf.toString('binary'). - Storing cert files to disk: Phase decision is memory-only. Never write to
user-files/or temp directories. - Serving binary via direct GET URL: POST-only endpoints cannot use
<a href="...">with cookies. Use base64 JSON + blob URL pattern. - Not catching node-forge exceptions: Malformed certs throw synchronously inside node-forge. Wrap all parsing in try/catch and return
400 BadRequestException.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| PEM/DER/PFX/P7B parsing | Custom ASN.1 parser | node-forge |
ASN.1 is complex; node-forge handles all format variants, OIDs, nested structures |
| Fingerprint calculation | Custom SHA hash loop | forge.md.sha1/sha256.create() |
node-forge applies hash to the DER-encoded cert bytes (not the PEM string) — order matters |
| PEM chain splitting | Manual string split | Regex -----BEGIN CERTIFICATE-----...-----END CERTIFICATE----- |
Simpler and robust; no external dep |
| Password-protected PFX | Custom PKCS12 builder | forge.pkcs12.toPkcs12Asn1 |
PKCS12 structure has nested safeBags, MAC, iteration counts — too complex to hand-roll |
| Multi-file upload | Custom body parser | FilesInterceptor from @nestjs/platform-express |
Already available, handles boundary parsing and size limits |
Key insight: node-forge is purpose-built for this exact use case. Every operation in this phase has a direct node-forge API. Zero hand-rolled crypto.
Common Pitfalls
Pitfall 1: Binary Encoding Corruption
What goes wrong: DER, PFX, P7B binary data is corrupted when converted with .toString('utf-8') — Node.js UTF-8 decoding replaces invalid byte sequences, causing forge.asn1.fromDer to throw or produce garbage.
Why it happens: Developers reach for the default string encoding without thinking about binary formats.
How to avoid: Always use .toString('binary') when converting a Node.js Buffer to a forge-compatible string: forge.util.createBuffer(buffer.toString('binary')).
Warning signs: forge.asn1.fromDer throws "Too few bytes to read" or "Invalid DER" even though the file is valid.
Pitfall 2: Module Not Activated for Tenant
What goes wrong: Seeding registers the module in the Module table, but isModuleActive() queries TenantModuleActivation. API returns 403 "Module 'cert-manager' is not activated for this tenant".
Why it happens: isSystem: true in seedModule is metadata only — it does NOT auto-activate for any tenant. Activation is always an explicit admin action.
How to avoid: In development, activate the module via the marketplace UI (or POST /modules/cert-manager/activate with admin token). Include this as a verification step in every plan that adds a new module.
Warning signs: All cert-manager endpoints return 403 immediately after seeding.
Pitfall 3: forge.pkcs12.toPkcs12Asn1 with null key
What goes wrong: Creating a cert-only PFX (no private key) with toPkcs12Asn1(null, certs, password) may throw depending on node-forge version.
Why it happens: PKCS12 spec allows cert-only bundles, but some implementations expect at least a key bag.
How to avoid: Test this in the CertManagerService first. If null key throws, create the PKCS12 with only a certBag (lower-level API). [ASSUMED — verify during implementation]
Warning signs: TypeError: Cannot read property 'n' of null inside node-forge when key is null.
Pitfall 4: P7B Binary vs PEM Detection
What goes wrong: P7B files can be PEM-wrapped (-----BEGIN PKCS7-----) or raw DER binary. Using forge.pkcs7.messageFromPem on a binary P7B throws.
Why it happens: Windows Certificate Manager exports P7B as DER binary by default; OpenSSL exports as PEM.
How to avoid: Sniff the first bytes: if buffer.slice(0, 10).toString('ascii').includes('-----BEGIN') → PEM path; otherwise → DER path.
Warning signs: forge.pkcs7.messageFromPem throws "Invalid PEM formatted message" on a valid P7B file.
Pitfall 5: <a href="..." download> for POST-authenticated downloads
What goes wrong: Using a direct <a href="http://api/modules/cert-manager/convert"> anchor for file download skips the cookie authentication — browser navigation does not send the fetch credentials: 'include' behavior.
Why it happens: DKV uses GET endpoints for downloads (URL-based, static filenames). Cert-manager uses POST endpoints (dynamic, ephemeral content).
How to avoid: Return { filename, content } (base64) in JSON response → frontend creates blob URL → <a download> with blob URL (no auth needed for blob URLs).
Warning signs: Downloads return 401 Unauthorized.
Pitfall 6: FilesInterceptor vs FileInterceptor
What goes wrong: Using FileInterceptor (singular) for the merge endpoint — only one file is received even when multiple are sent.
Why it happens: FileInterceptor handles a single file field. For multiple files with the same field name, FilesInterceptor is required.
How to avoid: Use FilesInterceptor('files', maxCount) + @UploadedFiles() (plural) for the merge endpoint.
Code Examples
Inspect Response Shape
// Source: design derived from CONTEXT.md decisions + node-forge API [ASSUMED]
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[]; // "dns.example.com", "IP:1.2.3.4"
keyType: string; // "RSA", "EC"
keyBits: number; // 2048, 4096
serialNumber: string;
signatureAlgorithm: string; // "sha256WithRSAEncryption"
fingerprint: { sha1: string; sha256: string }; // "AA:BB:CC:..."
pemPreview: string; // full PEM of the cert
}
Split Response Shape
// For fullchain/P7B → individual certs
interface SplitResponse {
count: number;
certs: Array<{
index: number; // 0-based
filename: string; // "cert-1.pem"
content: string; // base64-encoded PEM
subject: { cn: string };
validity: { notAfter: string };
}>;
}
Convert/Merge Response Shape
interface FileResponse {
filename: string; // e.g. "converted.der", "bundle.pfx", "chain.pem"
content: string; // base64
mimeType: string; // "application/x-pem-file", "application/x-pkcs12", ...
}
i18n Namespace Structure (de.json)
{
"certManager": {
"title": "Zertifikat-Manager",
"description": "Zertifikate analysieren, aufteilen, zusammenführen und konvertieren.",
"tabs": {
"inspect": "Analysieren",
"split": "Aufteilen",
"merge": "Zusammenführen",
"convert": "Konvertieren"
},
"dropZone": {
"placeholder": "Datei hierher ziehen oder klicken",
"formats": ".pem, .crt, .cer, .der, .pfx, .p12, .p7b, .p7c"
},
"paste": { "placeholder": "PEM-Inhalt einfügen (-----BEGIN ...)" },
"password": { "label": "Passwort (PFX/P12)" },
"or": "oder",
"actions": {
"inspect": "Analysieren",
"split": "Aufteilen",
"merge": "Zusammenführen",
"convert": "Konvertieren",
"download": "Herunterladen",
"processing": "Wird verarbeitet..."
},
"emptyState": {
"inspect": "Kein Zertifikat geladen.",
"inspectBody": "Lade eine Datei hoch oder füge PEM-Text ein.",
"split": "Keine Datei geladen.",
"splitBody": "Lade eine Fullchain- oder P7B-Datei hoch.",
"merge": "Keine Zertifikate ausgewählt.",
"mergeBody": "Lade mindestens zwei Dateien hoch.",
"convert": "Keine Datei geladen.",
"convertBody": "Lade eine Datei hoch und wähle ein Ausgabeformat."
},
"error": {
"generic": "Verarbeitung fehlgeschlagen. Prüfe das Dateiformat oder das Passwort.",
"wrongPassword": "Falsches Passwort. PFX/P12-Datei konnte nicht entschlüsselt werden.",
"unknownFormat": "Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden."
}
}
}
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
node-forge Rust engine (older misconception) |
Pure JS since inception | Always pure JS | Docker-friendly — no native build step, no cargo |
| Manual P7B regex parsing | forge.pkcs7.messageFromPem/messageFromAsn1 |
N/A | P7B has nested ASN.1 structure — never hand-roll |
@types/node-forge separate install |
Still needed (not bundled) | — | Always install alongside node-forge |
No breaking changes noted: node-forge 1.x API (used here) is stable. No migration concerns between 1.3.x and 1.4.0.
Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | FilesInterceptor from @nestjs/platform-express works identically to FileInterceptor but returns array |
Pattern 3 | If API differs, use manual multer middleware setup |
| A2 | forge.pkcs12.toPkcs12Asn1(null, certs, password) works with null private key |
Pattern 5, Pitfall 3 | Must use lower-level certBag-only approach; test during implementation |
| A3 | Base64 JSON download pattern (not binary streaming) is correct for POST-auth endpoints | Pattern 4 | If chosen differently, need res.setHeader('Content-Disposition') + fetch-blob pattern in frontend |
| A4 | All node-forge API signatures accurate | Code Examples | Minor API differences possible — verify against node-forge source during implementation |
Open Questions
-
PFX cert-only creation (A2)
- What we know: node-forge
toPkcs12Asn1(key, certs, password)is documented - What's unclear: whether
nullfor key is accepted without throwing - Recommendation: Implement and test early in Wave 0; if null throws, use
forge.pkcs12lower-level API to create cert-only PKCS12 bag
- What we know: node-forge
-
Merge: does user also supply a private key file?
- What we know: CONTEXT.md says "cert + optional private key" for PFX
- What's unclear: How the private key is provided (separate file? paste?)
- Recommendation: Planner should scope the merge endpoint to accept an optional private key as a third file field. If omitted, create cert-only PFX.
Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|---|---|---|---|---|
| Node.js | API build/runtime | ✓ | 24.16.0 | — |
| pnpm | Package install | ✓ | 9.15.0 | — |
| Docker stack (api, web, db) | End-to-end testing | ✓ | Running | — |
| node-forge | CertManagerService | ✗ (not yet installed) | 1.4.0 (npm) | — |
| @types/node-forge | TypeScript compile | ✗ (not yet installed) | 1.3.14 (npm) | — |
Missing dependencies with no fallback: node-forge + @types/node-forge (install in Wave 0).
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | Vitest 3.x + @testing-library/react |
| Config file | apps/web/vitest.config.ts |
| Quick run command | pnpm --filter @tessera/web vitest run --reporter=verbose |
| Full suite command | pnpm --filter @tessera/web vitest run |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| CERT-01 | Inspect tab renders empty state when no file loaded | unit | pnpm --filter @tessera/web vitest run --reporter=verbose src/app/\(portal\)/modules/cert-manager |
❌ Wave 0 |
| CERT-01 | Error state shown on API failure | unit | same | ❌ Wave 0 |
| CERT-02 | Split tab renders download buttons for each returned cert | unit | same | ❌ Wave 0 |
| CERT-03 | Merge tab: merge button disabled when < 2 files selected | unit | same | ❌ Wave 0 |
| CERT-04 | Convert tab: format selector renders all output options | unit | same | ❌ Wave 0 |
| CERT-05 | Password field shown when .pfx extension detected | unit | same | ❌ Wave 0 |
| CERT-06 | Module visible in marketplace after seed | manual | Activate via marketplace UI → verify sidebar link | manual-only |
Sampling Rate
- Per task commit:
pnpm --filter @tessera/web vitest run --reporter=verbose src/app/\(portal\)/modules/cert-manager - Per wave merge:
pnpm --filter @tessera/web vitest run - Phase gate: Full suite green before
/gsd-verify-work
Wave 0 Gaps
apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx— covers CERT-01 through CERT-05 UI behavior- No new test infrastructure needed — Vitest + Testing Library already set up in
apps/web
Security Domain
Applicable ASVS Categories (Level 1)
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | no | Existing JWT session auth covers all endpoints |
| V3 Session Management | no | No new session logic |
| V4 Access Control | yes | @UseModule('cert-manager') guard; global JwtAuthGuard |
| V5 Input Validation | yes | File size limit (5 MB), extension check, try/catch on node-forge |
| V6 Cryptography | no | node-forge handles crypto — never hand-roll |
| V10.2 Malicious Code | yes | Catch all node-forge exceptions → 400 BadRequestException |
Known Threat Patterns
| Pattern | STRIDE | Standard Mitigation |
|---|---|---|
| Malformed cert causes unhandled throw | Tampering | Wrap all forge operations in try/catch → throw new BadRequestException(...) |
| Oversized PFX file causes OOM | DoS | limits: { fileSize: 5 * 1024 * 1024 } in FileInterceptor |
| Path traversal via filename | Tampering | Not applicable — no disk writes; filename only used for Content-Disposition string |
| Unauthenticated cert processing | Elevation of Privilege | Global JwtAuthGuard + ModuleGuard — both required |
| Password leakage in logs | Information Disclosure | Never log password parameter in controller or service |
Project Constraints (from CLAUDE.md)
| Constraint | Applies to Phase 9 |
|---|---|
| Docker-based stack — all components as containers | No new containers; cert-manager is part of existing API/web containers |
| PostgreSQL as database | No Prisma changes in this phase |
| NestJS 11 backend | Use NestJS 11 patterns (FileInterceptor from @nestjs/platform-express) |
| Next.js 16 + shadcn/ui + Tailwind 4 frontend | No shadcn (UI-SPEC confirms none); Tailwind 4 utilities only |
| Zustand 5 for client state | Not needed — local React state (useState) sufficient for single-page module |
| Biome 2.x for linting | All new files must pass Biome; no ESLint usage |
| pnpm workspaces | Install node-forge via pnpm --filter @tessera/api add |
| No reverse proxy inside Tessera containers | Frontend fetches directly to API via NEXT_PUBLIC_API_URL |
| All UI strings via i18n framework (t('key')) | Add certManager namespace to de.json + en.json; all strings via t() |
Sources
Primary (HIGH confidence — verified from codebase)
apps/api/src/domaincheck/— module registration pattern (OnModuleInit, seedModule, @UseModule guard)apps/api/src/dkv/dkv.controller.ts— FileInterceptor, binary download with res.setHeader, file.buffer usageapps/api/src/user/user.controller.ts— binary response pattern (setHeader + res.send)apps/api/src/module-registry/module-registry.service.ts— isModuleActive queries TenantModuleActivation (not isSystem flag)apps/web/src/lib/dkv-api.ts— FormData + fetch with credentials:include patternapps/api/package.json— node-forge NOT yet installed; @nestjs/platform-express IS installed
Secondary (MEDIUM confidence)
- npm registry:
node-forge@1.4.0(35M/wk, DigitalBazaar org, 10+ years) — OK verdict - npm registry:
@types/node-forge@1.3.14(DefinitelyTyped) — OK verdict
Tertiary (LOW confidence — training knowledge, marked [ASSUMED])
- node-forge API signatures (Pattern 5 code examples)
- FilesInterceptor behavior (Pattern 3)
- null key behavior in toPkcs12Asn1 (Open Question 1)
Metadata
Confidence breakdown:
- Standard stack: HIGH — node-forge legitimacy verified, versions confirmed from npm registry
- Architecture: HIGH — patterns verified directly from codebase (domaincheck + DKV)
- node-forge API: MEDIUM — well-known library, training knowledge, marked [ASSUMED] where unverified
- Pitfalls: HIGH — encoding pitfalls verified from library design; activation pitfall verified from module-registry code
Research date: 2026-07-01 Valid until: 2026-08-01 (node-forge is stable; patterns are codebase-verified)