Files
schalli daff82ea76
Tessera CI/CD / Tests (push) Has been cancelled
Tessera CI/CD / Build & Publish Images (push) Has been cancelled
Tessera CI/CD / Lint & Type Check (push) Has been cancelled
docs(09-06): complete merge-pfx plan — final plan of phase 09
2026-07-02 07:55:54 +02:00

209 lines
12 KiB
Markdown

---
phase: 09-cert-manager-module
plan: "06"
subsystem: api+frontend
tags: [cert-manager, mergeCerts, node-forge, pkcs12, pfx, merge-tab, tdd, vitest, file-response]
dependency_graph:
requires: [09-01, 09-02, 09-03, 09-04, 09-05]
provides: [cert-manager-merge-endpoint, merge-tab-ui, pfx-create, pfx-convert-option]
affects:
- apps/api/src/cert-manager/cert-manager.service.ts
- apps/api/src/cert-manager/cert-manager.service.spec.ts
- apps/web/src/app/(portal)/modules/cert-manager/actions.ts
- apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx
- apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx
- apps/web/src/app/(portal)/modules/cert-manager/page.tsx
- apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx
tech_stack:
added: []
patterns: [tdd-red-green, null-key-pkcs12, multi-file-formdata, lifted-output-format-state, merge-disabled-guard]
key_files:
created: []
modified:
- apps/api/src/cert-manager/cert-manager.service.ts
- apps/api/src/cert-manager/cert-manager.service.spec.ts
- apps/web/src/app/(portal)/modules/cert-manager/actions.ts
- apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx
- apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx
- apps/web/src/app/(portal)/modules/cert-manager/page.tsx
- apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx
decisions:
- "Open Question 1 RESOLVED: toPkcs12Asn1(null, certs, password) works in node-forge 1.4.0 — null private key accepted for cert-only PFX (no fallback to lower-level certBag API needed)"
- "2-file minimum enforced at controller level (not service) — service accepts 1+ files; controller rejects < 2 with 400"
- "MergeTab owns local file list state (separate from shared DropZone in page.tsx) — shared password prop from page.tsx"
- "onOutputFormatChange callback pattern: MergeTab+ConvertTab notify page.tsx of format change; page.tsx lifts mergeOutputFormat+convertOutputFormat state to control shared PasswordField visibility"
- "PFX output added to convertCert (reuses same null-key toPkcs12Asn1 pattern as mergeCerts)"
- "FORMAT_MIME extended with pfx: 'application/x-pkcs12'"
metrics:
duration: "~7 minutes"
completed: "2026-07-02T07:54:00Z"
tasks_completed: 3
files_created: 0
files_modified: 7
requirements: [CERT-03, CERT-04, CERT-05]
status: complete
---
# Phase 09 Plan 06: Merge + PFX Vertical Slice Summary
**One-liner:** mergeCerts produces PEM chains and password-protected cert-only PFX bundles via toPkcs12Asn1(null, certs, password) — Open Question 1 confirmed working in node-forge 1.4.0; Merge tab with multi-file list, output selector, and shared PasswordField integration completes the cert-manager vertical stack.
## Objective
Final vertical slice: merge multiple certificates into a PEM chain or password-protected PFX/PKCS12 bundle. Implements CERT-03 and the write half of CERT-05. Resolves RESEARCH Open Question 1 (null-key PFX creation) during implementation.
## Tasks Completed
| # | Name | Type | Commit | Status |
|---|------|------|--------|--------|
| 1 | RED — failing mergeCerts spec (PEM chain + password-PFX round-trip) | test | f43e92c | done |
| 2 | GREEN — implement mergeCerts + PFX-create + wire POST /merge | feat | 6326064 | done |
| 3 | Merge tab UI + PFX convert option + render tests | feat | 8b15a00 | done |
## What Was Built
### Task 1: RED — failing mergeCerts spec
Added 4 new mergeCerts tests to `cert-manager.service.spec.ts`:
- **PEM chain (2 files):** asserts `base64→decoded` contains exactly 2 BEGIN CERTIFICATE blocks, mimeType `application/x-pem-file`
- **Password-PFX round-trip:** calls `mergeCerts({ files:[1 file], outputFormat:'pfx', password:'secret' })`, decodes base64 PFX, re-parses via `pkcs12FromAsn1(p12Asn1, 'secret')`, asserts cert bags present (proves password-protected and correct)
- **Missing PFX password:** asserts `BadRequestException` when `password` is absent on PFX output
- **Garbage input:** asserts `BadRequestException` for malformed file buffers
Note: 2-file minimum tested at controller level (existing guard `files.length < 2`); service tests use 1+ files directly.
All 4 fail against NotImplementedException stub (RED confirmed). Prior 23 tests remain green.
### Task 2: GREEN — mergeCerts + PFX-create + convertCert pfx output
**`cert-manager.service.ts`:**
- Replaced `mergeCerts` stub with full implementation:
- Parses each file using `detectFormat` → PEM via `parsePemChain`; DER via `asn1.fromDer(toForgeBuffer)`; PFX via `pkcs12FromAsn1`; P7B via sniff + `messageFromPem/messageFromAsn1`
- PFX output requires non-empty password → BadRequestException if missing (T-09-02)
- PEM output: `certificateToPem()` concatenation → `Buffer.from(chain,'utf-8').toString('base64')` → FileResponse `chain.pem`
- PFX output: `toPkcs12Asn1(null, certs, password, {algorithm:'3des'})` → `bytesToHex` → `Buffer.from(hex,'hex')` → base64 → FileResponse `bundle.pfx` (Pitfall 1 avoidance)
- All forge calls in try/catch → BadRequestException; password never logged (T-09-02)
- `convertCert` gains PFX output target (reuses same null-key pattern, single cert)
- `FORMAT_MIME` extended with `pfx: 'application/x-pkcs12'`
- `NotImplementedException` removed from import (no longer used)
**Open Question 1 resolution:** `forge.pkcs12.toPkcs12Asn1(null as any, certs, password, {algorithm:'3des'})` works correctly in node-forge 1.4.0. Null private key produces a cert-only PKCS12 bundle (cert bag only, no key bag). No fallback to lower-level certBag construction was needed.
**Result: 27/27 API tests pass (23 prior + 4 new mergeCerts); tsc --noEmit exits 0.**
### Task 3: MergeTab UI + ConvertTab pfx + page.tsx update + render tests
**`actions.ts`:**
- Added `mergeCertsAction(files: File[], outputFormat: string, password?: string): Promise<FileResponse>` — builds FormData with `files.forEach(f => form.append('files', f))`, appends outputFormat and optional password, delegates to `postForm('merge', form)` (T-09-02/T-09-04)
**`components/MergeTab.tsx`** (fully replaced stub):
- `useState<File[]>` for local file list (separate from shared DropZone)
- `data-testid="merge-file-input"` native file input with `multiple` + all cert extensions
- Output selector: pem ('PEM-Kette') / pfx ('PFX / PKCS12')
- `data-testid="merge-action-btn"` Zusammenfuehren button disabled when `files.length < 2`
- `onOutputFormatChange?: (format: string) => void` callback to notify page.tsx for shared PasswordField visibility
- On success: `downloadBase64(filename, content, mimeType)` (T-09-02)
- Error classification: wrongPassword / unknownFormat / generic
**`components/ConvertTab.tsx`:**
- Added `pfx` option to format selector ('PFX / PKCS12')
- Added `onTargetFormatChange?: (format: string) => void` prop (same callback pattern)
- Type `TargetFormat = 'pem' | 'der' | 'p7b' | 'pfx'`
**`page.tsx`:**
- Lifts `mergeOutputFormat` + `convertOutputFormat` state (both default 'pem')
- `showPassword` updated: true when PFX file selected OR active-merge-tab pfx OR active-convert-tab pfx
- Passes `onOutputFormatChange={setMergeOutputFormat}` to MergeTab
- Passes `onTargetFormatChange={setConvertOutputFormat}` to ConvertTab
- MergeTab receives only `password` (not file/pemText which are irrelevant for merge)
**`cert-manager.test.tsx`:**
- 5 new tests:
- MergeTab action button disabled with 0 files
- MergeTab action button enabled after 2 files added via `fireEvent.change`
- Shared PasswordField appears in page.tsx when PFX output selected in MergeTab
- `mergeCertsAction` mocked → `downloadBase64` called on merge success
- ConvertTab selector contains all 4 options including pfx
**Result: 19/19 web cert-manager tests pass (14 prior + 5 new); all production cert-manager files type-clean.**
## Verification Results
| Check | Status | Notes |
|-------|--------|-------|
| `pnpm --filter @tessera/api exec vitest run cert-manager` | PASS | 27/27 tests |
| `pnpm --filter @tessera/web exec vitest run cert-manager` | PASS | 19/19 tests |
| `pnpm --filter @tessera/api exec tsc --noEmit` | PASS | 0 errors |
| `pnpm --filter @tessera/web exec tsc --noEmit` (prod files) | PASS | 0 errors in production files |
| Full web suite `vitest run` | PARTIAL | 102/107 pass — 5 pre-existing dashboard failures (Phase 8, out of scope) |
| `grep -q "toPkcs12Asn1"` | PASS | Open Question 1 resolved |
| `grep -q "length < 2"` controller | PASS | 2-file minimum at controller level |
| `mergeCertsAction` in actions.ts | PASS | Action wired |
| Merge button disabled < 2 files | PASS | Verified by test |
| Password field shown on PFX output | PASS | Verified by integration test |
| ConvertTab includes pfx option | PASS | Verified by test |
## Open Question 1 Resolution
**Question:** Does `forge.pkcs12.toPkcs12Asn1(null, certs, password)` work with null private key?
**Result: YES — works as expected in node-forge 1.4.0.**
`toPkcs12Asn1(null as any, certs, password!, { algorithm: '3des' })` produces a valid PKCS12 file containing only a cert bag (no key bag). The produced PFX:
- Opens correctly with `pkcs12FromAsn1(p12Asn1, 'secret')` with the correct password
- Rejects with a forge exception on wrong password (verified by round-trip test)
- Contains all provided certificates in the cert bag
No fallback to lower-level `forge.pkcs12` certBag API construction was needed. The Pitfall 3 concern from RESEARCH.md did not materialize with node-forge 1.4.0.
## Deviations from Plan
### Auto-fixed Issues
None — plan executed exactly as written.
## Known Stubs
None — `mergeCerts` is now fully implemented. All four cert-manager service methods are complete.
## Deferred Items (Out of Scope — Phase 8)
Pre-existing dashboard test failures (NOT caused by this plan):
- `dashboard-grid.test.tsx`: 2 failures — react-grid-layout mock missing `noCompactor` export
- `note-widget.test.tsx`: 3 failures — fetch assertion errors (hideToolbar-Prop issue from 01.07)
These were tracked in `M` git status before plan execution. Logged to context for Phase 8 cleanup.
## Threat Model Compliance
| Threat ID | Status |
|-----------|--------|
| T-09-01 (malformed input → unhandled throw) | Mitigated — try/catch on all forge operations in mergeCerts → BadRequestException |
| T-09-02 (PFX password handling) | Mitigated — password never logged, only used in toPkcs12Asn1 MAC; never in filename or response |
| T-09-03 (multi-upload DoS) | Mitigated — FilesInterceptor maxCount 20 + fileSize 5 MB (wired in Plan 01) |
| T-09-04 (unauthenticated) | Mitigated — global JwtAuthGuard + @UseModule('cert-manager') guard (Plan 01) |
## Self-Check: PASSED
| Check | Result |
|-------|--------|
| apps/api/src/cert-manager/cert-manager.service.ts | FOUND + MODIFIED |
| apps/api/src/cert-manager/cert-manager.service.spec.ts | FOUND + MODIFIED |
| apps/web/src/app/(portal)/modules/cert-manager/actions.ts | FOUND + MODIFIED |
| apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx | FOUND + MODIFIED |
| apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx | FOUND + MODIFIED |
| apps/web/src/app/(portal)/modules/cert-manager/page.tsx | FOUND + MODIFIED |
| apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx | FOUND + MODIFIED |
| Commit f43e92c (RED mergeCerts spec) | FOUND |
| Commit 6326064 (GREEN mergeCerts + pfx) | FOUND |
| Commit 8b15a00 (MergeTab UI + tests) | FOUND |
| 27/27 API cert-manager tests passing | VERIFIED |
| 19/19 web cert-manager tests passing | VERIFIED |
| toPkcs12Asn1 in service | VERIFIED |
| 2-file guard in controller | VERIFIED |
| mergeCertsAction in actions.ts | VERIFIED |
| PFX option in ConvertTab | VERIFIED |
| Merge button disabled < 2 files | VERIFIED |
| Password field on PFX output | VERIFIED |