Files

190 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.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
</context>
<artifacts>
## 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)
</artifacts>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: RED — failing mergeCerts spec (PEM chain + password PFX)</name>
<files>apps/api/src/cert-manager/cert-manager.service.spec.ts</files>
<read_first>
- 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)
</read_first>
<behavior>
- 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.
</behavior>
<action>
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.
</action>
<verify>
<automated>pnpm --filter @tessera/api test cert-manager --run 2>&1 | grep -Eiq 'fail|NotImplemented|✗|×' && echo RED_CONFIRMED</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>Failing mergeCerts spec committed (RED) including a password-PFX round-trip assertion.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: GREEN — implement mergeCerts + PFX-create + wire POST /merge</name>
<files>apps/api/src/cert-manager/cert-manager.service.ts, apps/api/src/cert-manager/cert-manager.controller.ts</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>pnpm --filter @tessera/api test cert-manager --run</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>mergeCerts produces a PEM chain and a password-protected PFX that round-trips; POST /merge wired with a 2-file minimum; API tests green.</done>
</task>
<task type="auto">
<name>Task 3: Merge tab UI (multi-file + password) + PFX convert option + render tests</name>
<files>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</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>pnpm --filter @tessera/web test cert-manager --run</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>Merge tab combines >=2 certs into a PEM chain or password PFX end-to-end; PFX output option added to Convert; web tests green.</done>
</task>
</tasks>
<threat_model>
## 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')` |
</threat_model>
<verification>
- `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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
Create `.planning/phases/09-cert-manager-module/09-06-SUMMARY.md` when done
</output>