Files
tessera-ctl/.planning/phases/09-cert-manager-module/09-RESEARCH.md
T

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: 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 </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>
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 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 <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 (RESOLVED)

  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
    • RESOLVED: Plan 06 implements: attempt toPkcs12Asn1(null, certs, password) first; if node-forge throws on null key, fall back to constructing a cert-only PKCS12 bag via lower-level forge.pkcs12 certBag API. Resolution happens at execution time in Wave 5 Task 1 — no pre-execution blocker.
  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?)
    • RESOLVED: Private key support is explicitly deferred per CONTEXT.md <deferred> section (private key handling, key generation, PKCS#8 import). Initial implementation is cert-only PFX merge. No private key file field in Wave 5 merge endpoint. This is a conscious scope decision, not an oversight.

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)