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.
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.
<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>
- `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