--- phase: 09-cert-manager-module plan: 06 type: execute wave: 5 depends_on: [09-05] files_modified: - apps/api/src/cert-manager/cert-manager.service.ts - apps/api/src/cert-manager/cert-manager.controller.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 autonomous: true requirements: [CERT-03, CERT-04, CERT-05] must_haves: truths: - "A user uploads two or more certificates and downloads them merged as a single PEM chain" - "A user merges certs into a password-protected PFX/PKCS12 bundle by supplying a password; the resulting PFX opens with that password" - "The merge button is disabled until at least two files are selected; the password field appears when PFX output is chosen" artifacts: - "CertManagerService.mergeCerts implemented (PEM chain + PFX create with password)" - "POST /modules/cert-manager/merge wired to mergeCerts (FilesInterceptor)" - "MergeTab.tsx (multi-file + output selector + conditional password) and PFX output option added to ConvertTab" key_links: - "MergeTab -> mergeCertsAction(files, outputFormat, password) -> POST /modules/cert-manager/merge -> CertManagerService.mergeCerts" - "outputFormat 'pfx' -> forge.pkcs12.toPkcs12Asn1 with password -> base64 PFX -> downloadBase64" --- Final vertical slice: merge multiple certificates into a PEM chain or a password-protected PFX/PKCS12 bundle. Implement CertManagerService.mergeCerts (multi-file), wire POST /merge with FilesInterceptor, build the Merge tab (multi-file upload, output-format selector, conditional password field), and add PFX as an output option to the Convert tab. MVP: after this plan a user can combine certs into a chain or a password PFX and download it — completing CERT-03 and the write half of CERT-05. Purpose: Delivers CERT-03 and CERT-05 (PFX create); resolves RESEARCH Open Question 1 (null-key PFX) during implementation. Output: Working Merge tab end-to-end + tested mergeCerts service + PFX convert option. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/09-cert-manager-module/09-RESEARCH.md @.planning/phases/09-cert-manager-module/09-PATTERNS.md @.planning/phases/09-cert-manager-module/09-UI-SPEC.md @.planning/phases/09-cert-manager-module/09-05-SUMMARY.md ## Artifacts this plan produces - Implemented method: `CertManagerService.mergeCerts({ files, outputFormat, password? }): FileResponse` - PFX-create helper: cert(s) -> forge.pkcs12.toPkcs12Asn1 (cert-only, null-key path resolved per Open Question 1) -> base64 - Wired route: `POST /modules/cert-manager/merge` (FilesInterceptor('files', 20) + @Body outputFormat/password) - New action: `mergeCertsAction(files, outputFormat, password?)` in actions.ts - Implemented component: `MergeTab` (multi-file list + output selector + conditional password + download) - ConvertTab gains a 'pfx' output option (reuses PFX-create + password field) Task 1: RED — failing mergeCerts spec (PEM chain + password PFX) apps/api/src/cert-manager/cert-manager.service.spec.ts - apps/api/src/cert-manager/cert-manager.service.spec.ts (fixtures + prior tests) - apps/api/src/cert-manager/cert-manager.service.ts (mergeCerts stub + parse helpers) - .planning/phases/09-cert-manager-module/09-RESEARCH.md (Pattern 5 certificateToPem concat + pkcs12.toPkcs12Asn1; Pitfall 3 null-key PFX; Open Question 1) - Test: mergeCerts({ files: [ {buffer: certA PEM}, {buffer: certB PEM} ], outputFormat: 'pem' }) returns FileResponse whose base64 content decodes to a PEM containing exactly two BEGIN CERTIFICATE blocks, mimeType application/x-pem-file. - Test: mergeCerts({ files: [ {buffer: certA PEM} ], outputFormat: 'pfx', password: 'secret' }) returns a PFX whose base64 content, re-parsed via pkcs12FromAsn1 with password 'secret', yields the enclosed cert (round trip); mimeType application/x-pkcs12. - Test: mergeCerts({ files: [singleFile], outputFormat: 'pem' }) is allowed by the service (the 2-file minimum is enforced at the controller); OR assert controller-level guard separately — document which layer enforces the minimum. - Test: mergeCerts({ files: [ {buffer: garbage} ], outputFormat: 'pem' }) throws BadRequestException. Add the Behavior tests to cert-manager.service.spec.ts using the two self-signed cert fixtures. For the PFX test, re-open the produced bundle with pkcs12FromAsn1 + password 'secret' to prove it is password-protected and round-trips. Confirm RED against the mergeCerts stub. Do not implement mergeCerts here. pnpm --filter @tessera/api test cert-manager --run 2>&1 | grep -Eiq 'fail|NotImplemented|✗|×' && echo RED_CONFIRMED - mergeCerts tests exist for PEM-chain (2 certs), password-PFX round trip, and malformed input - Suite shows mergeCerts tests failing while all prior tests pass Failing mergeCerts spec committed (RED) including a password-PFX round-trip assertion. Task 2: GREEN — implement mergeCerts + PFX-create + wire POST /merge apps/api/src/cert-manager/cert-manager.service.ts, apps/api/src/cert-manager/cert-manager.controller.ts - apps/api/src/cert-manager/cert-manager.service.ts (parse helpers, toForgeBuffer, convertCert serialization) - apps/api/src/cert-manager/cert-manager.controller.ts (merge route stub + FilesInterceptor from Plan 01) - apps/api/src/cert-manager/cert-manager.service.spec.ts (RED contract) - .planning/phases/09-cert-manager-module/09-RESEARCH.md (Pattern 5 toPkcs12Asn1; Pitfall 3 + Open Question 1 null-key handling) Implement CertManagerService.mergeCerts({ files, outputFormat, password }): parse each file's buffer to a forge cert (reuse the shared input-resolution helper). For outputFormat 'pem': concatenate certificateToPem(cert) for all certs -> FileResponse { filename 'chain.pem', content base64(utf-8), mimeType application/x-pem-file }. For outputFormat 'pfx': build the PKCS12 via forge.pkcs12.toPkcs12Asn1 with the supplied password (require a non-empty password for PFX output -> BadRequestException if missing). Resolve Open Question 1: attempt cert-only creation with a null private key; if node-forge throws (Pitfall 3), fall back to the lower-level certBag-only construction. Serialize -> bytesToHex -> Buffer -> base64 -> FileResponse { filename 'bundle.pfx', mimeType application/x-pkcs12 }. Wrap all forge calls in try/catch -> BadRequestException; never log the password. In the controller, POST merge uses FilesInterceptor('files', 20, { limits: { fileSize: 5*1024*1024 } }), reads @Body('outputFormat') and @Body('password'), rejects when files.length < 2 (BadRequestException), and delegates. Run suite to GREEN. Note the Open Question 1 resolution (null-key worked vs. fallback used) in the SUMMARY. pnpm --filter @tessera/api test cert-manager --run - `pnpm --filter @tessera/api test cert-manager --run` exits 0 with mergeCerts tests passing including the password-PFX round trip - `grep -q "toPkcs12Asn1" apps/api/src/cert-manager/cert-manager.service.ts` - Missing password on PFX output returns BadRequestException (asserted in spec) - `grep -q "length < 2" apps/api/src/cert-manager/cert-manager.controller.ts` (or equivalent 2-file guard) - `pnpm --filter @tessera/api type-check` exits 0 mergeCerts produces a PEM chain and a password-protected PFX that round-trips; POST /merge wired with a 2-file minimum; API tests green. Task 3: Merge tab UI (multi-file + password) + PFX convert option + render tests 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 - apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx (empty-state stub) - apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx (format selector from Plan 05 — add 'pfx' option) - apps/web/src/app/(portal)/modules/cert-manager/page.tsx (shared PasswordField show-condition — extend for PFX output on merge/convert) - apps/web/src/app/(portal)/modules/cert-manager/actions.ts (postForm, downloadBase64) - apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx (wiring) - .planning/phases/09-cert-manager-module/09-UI-SPEC.md (Zusammenfuehren = multi-file list + output selector + single download; merge button disabled < 2 files; password field appears when PFX output selected) Add mergeCertsAction(files, outputFormat, password?) to actions.ts: build FormData appending each file under field 'files', plus outputFormat and optional password; call postForm('merge', form); return FileResponse. Implement MergeTab: accept the shared password value; maintain a local list of selected files (multi-select via a file input allowing multiple, or repeated DropZone adds). Render an output-format selector (pem | pfx). The primary 'Zusammenfuehren' button (t('actions.merge'), loading label swap) is disabled until files.length >= 2 (per UI-SPEC). When output is 'pfx', ensure the shared password field is shown (update the page.tsx show-condition so PasswordField appears when the active tab's chosen output is PFX). On success call downloadBase64(filename, content, mimeType). Empty state t('emptyState.merge'); localized errors in text-destructive. Update page.tsx PasswordField show-condition: show when the selected file is .pfx/.p12 (existing) OR the active MergeTab/ConvertTab output format is 'pfx'. Add a 'pfx' option to ConvertTab's selector and pass the password through to convertCertAction so Convert can also emit a password PFX (reuses the same backend path — Convert with target 'pfx' may route through convertCert delegating to the PFX-create helper, or document that PFX convert uses the merge/PFX helper). Extend cert-manager.test.tsx: assert the Zusammenfuehren button is disabled with fewer than two files and enabled with two; assert the password field becomes visible when PFX output is selected; mock mergeCertsAction to resolve a FileResponse and assert downloadBase64 is invoked. pnpm --filter @tessera/web test cert-manager --run - `grep -q "mergeCertsAction" apps/web/src/app/(portal)/modules/cert-manager/actions.ts` - Merge button is disabled when fewer than 2 files are selected and enabled at 2 (asserted in test) - Password field is shown when PFX output is selected (asserted in test) - ConvertTab selector now includes a 'pfx' option - `pnpm --filter @tessera/web test cert-manager --run` exits 0 including the new MergeTab tests - `pnpm --filter @tessera/web type-check` exits 0 Merge tab combines >=2 certs into a PEM chain or password PFX end-to-end; PFX output option added to Convert; web tests green. ## Trust Boundaries | Boundary | Description | |----------|-------------| | client -> API /merge | Multiple untrusted cert files + PFX password enter node-forge | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-09-01 | Tampering | mergeCerts (node-forge parse + pkcs12) | medium | mitigate | try/catch around parse + toPkcs12Asn1 -> BadRequestException; missing PFX password rejected as 400 | | T-09-02 | Information Disclosure | PFX password handling | high | mitigate | Password used only to build the PKCS12 MAC; never logged, never returned in the response, never in the filename | | T-09-03 | Denial of Service | POST /merge multi-upload | high | mitigate | FilesInterceptor maxCount 20 + `limits.fileSize` = 5 MB per file caps total memory | | T-09-04 | Elevation of Privilege | POST /merge | high | mitigate | Global JwtAuthGuard + `@UseModule('cert-manager')` | - `pnpm --filter @tessera/api test cert-manager --run` — mergeCerts green incl. password-PFX round trip - `pnpm --filter @tessera/web test cert-manager --run` — MergeTab disabled/enabled + password-visibility + download tests green - `pnpm --filter @tessera/api test --run && pnpm --filter @tessera/web test --run` — full phase suite green (phase gate) - type-checks clean - Manual (phase gate): merge two real certs to a PFX with a password, re-upload to Inspect with that password, confirm it opens - mergeCerts produces PEM chains and password-protected PFX bundles (CERT-03 + CERT-05 write) - Merge tab + PFX convert option work end-to-end with conditional password field - Full API + web suites green; type-checks clean Create `.planning/phases/09-cert-manager-module/09-06-SUMMARY.md` when done