docs(09): research phase 9 cert-manager module
This commit is contained in:
@@ -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>
|
||||
## 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
|
||||
|
||||
```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 → <a download>
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
apps/api/src/cert-manager/
|
||||
├── cert-manager.module.ts # OnModuleInit + seedModule
|
||||
├── cert-manager.seed.ts # seedModule({ slug: 'cert-manager', ... })
|
||||
├── cert-manager.controller.ts # 4 POST endpoints, @UseModule guard
|
||||
├── cert-manager.service.ts # node-forge operations
|
||||
└── dto/
|
||||
├── parse-cert.dto.ts # optional: text paste (PEM string body)
|
||||
├── merge-certs.dto.ts # outputFormat + optional password (from @Body)
|
||||
└── convert-cert.dto.ts # targetFormat + optional password
|
||||
|
||||
apps/web/src/app/(portal)/modules/cert-manager/
|
||||
├── page.tsx # 'use client', tab UI
|
||||
├── actions.ts # fetch wrappers to API
|
||||
└── components/
|
||||
├── DropZone.tsx # file drop + click-to-browse
|
||||
├── InspectTab.tsx # key-value result grid
|
||||
├── SplitTab.tsx # list of downloadable certs
|
||||
├── MergeTab.tsx # format selector + password + download
|
||||
└── ConvertTab.tsx # format selector + password + download
|
||||
|
||||
apps/web/src/messages/de.json # add certManager namespace
|
||||
apps/web/src/messages/en.json # add certManager namespace
|
||||
```
|
||||
|
||||
### Pattern 1: Module Registration (OnModuleInit + seedModule)
|
||||
|
||||
**What:** Each Tessera module seeds itself into the registry on startup via `OnModuleInit`.
|
||||
**When to use:** Every new module — this is the standard pattern.
|
||||
|
||||
```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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 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`]
|
||||
|
||||
```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 `<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
|
||||
|
||||
```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)
|
||||
Reference in New Issue
Block a user