diff --git a/.planning/phases/09-cert-manager-module/09-RESEARCH.md b/.planning/phases/09-cert-manager-module/09-RESEARCH.md new file mode 100644 index 0000000..fcdec58 --- /dev/null +++ b/.planning/phases/09-cert-manager-module/09-RESEARCH.md @@ -0,0 +1,734 @@ +# 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)