# 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 (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: `OnModuleInit` seed + `@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
---
## 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` |
---
## 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
```bash
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 →
```
### 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.
```typescript
// 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 {
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);
}
}
}
```
```typescript
// cert-manager.seed.ts
export async function seedCertManagerModule(
moduleRegistryService: ModuleRegistryService,
): Promise {
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`]
```typescript
// 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.
```typescript
// 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:
```typescript
// 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.
```typescript
// 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',
};
```
```typescript
// 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:
```typescript
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]
```typescript
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 `hex` encoding in `createBuffer`:** `forge.util.createBuffer(buf.toString('hex'))` is wrong. Use `buf.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 `` 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: `` for POST-authenticated downloads
**What goes wrong:** Using a direct `` 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 → `` 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
```typescript
// 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
```typescript
// 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
```typescript
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)
```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
1. **PFX cert-only creation (A2)**
- What we know: node-forge `toPkcs12Asn1(key, certs, password)` is documented
- What's unclear: whether `null` for key is accepted without throwing
- Recommendation: Implement and test early in Wave 0; if null throws, use `forge.pkcs12` lower-level API to create cert-only PKCS12 bag
2. **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 usage
- `apps/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 pattern
- `apps/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)