diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 3416c65..1a431ce 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -294,7 +294,15 @@ Decimal phases appear between their surrounding integers in numeric order. 5. Password-protected PFX/PKCS12 files can be opened (password prompt) and created (password input) 6. Module appears in the module registry with slug `cert-manager` -**Plans**: 0/0 plans created +**Plans**: 6 plans + +Plans: +- [ ] 09-01-PLAN.md — API foundation: install node-forge + Vitest runner, scaffold module, seed registry (CERT-06), shared node-forge helpers +- [ ] 09-02-PLAN.md — Frontend shell: tab page, DropZone, conditional password field, download helpers, certManager i18n (de/en) +- [ ] 09-03-PLAN.md — Inspect slice: parseCert (PEM/DER/PFX/P7B) + POST /parse + Inspect tab (CERT-01, CERT-05 read) +- [ ] 09-04-PLAN.md — Split slice: splitCerts (fullchain/P7B) + POST /split + Split tab download list (CERT-02) +- [ ] 09-05-PLAN.md — Convert slice: convertCert (PEM/DER/P7B round-trips) + POST /convert + Convert tab (CERT-04) +- [ ] 09-06-PLAN.md — Merge/PFX slice: mergeCerts (PEM chain + password PFX) + POST /merge + Merge tab + PFX convert option (CERT-03, CERT-05 write) **UI hint**: yes @@ -313,4 +321,4 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 | 6. Desktop Client & CI/CD | 2/3 | In Progress| | | 7. DKV Fleet Module | 6/6 | Complete | 2026-06-27 | | 8. Dashboard Widgets Vollimplementierung | 4/4 | Complete | 2026-07-01 | -| 9. Cert Manager Module | 0/0 | Not started | - | +| 9. Cert Manager Module | 0/6 | Not started | - | diff --git a/.planning/phases/09-cert-manager-module/09-01-PLAN.md b/.planning/phases/09-cert-manager-module/09-01-PLAN.md new file mode 100644 index 0000000..690885a --- /dev/null +++ b/.planning/phases/09-cert-manager-module/09-01-PLAN.md @@ -0,0 +1,192 @@ +--- +phase: 09-cert-manager-module +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - apps/api/package.json + - apps/api/vitest.config.ts + - apps/api/src/cert-manager/cert-manager.module.ts + - apps/api/src/cert-manager/cert-manager.seed.ts + - apps/api/src/cert-manager/cert-manager.service.ts + - apps/api/src/cert-manager/cert-manager.controller.ts + - apps/api/src/cert-manager/dto/parse-cert.dto.ts + - apps/api/src/cert-manager/dto/merge-certs.dto.ts + - apps/api/src/cert-manager/dto/convert-cert.dto.ts + - apps/api/src/cert-manager/cert-manager.service.spec.ts + - apps/api/src/app.module.ts +autonomous: true +requirements: [CERT-06] +user_setup: + - service: marketplace-activation + why: "isSystem:true seeds the module in the registry but does NOT auto-activate it per tenant. ModuleGuard returns 403 until an admin activates cert-manager via the Marketplace UI." + dashboard_config: + - task: "Activate the cert-manager module for the tenant" + location: "Tessera Portal -> Marketplace -> Cert Manager -> Aktivieren (after this plan runs and the API is restarted)" + +must_haves: + truths: + - "The API boots and seeds a Module registry row with slug 'cert-manager' on startup" + - "The cert-manager Vitest suite runs via `pnpm --filter @tessera/api test`" + - "Shared node-forge helpers (format detection, fingerprint, PEM-chain split, buffer conversion) exist and are unit-tested" + artifacts: + - "apps/api/vitest.config.ts (node environment)" + - "apps/api/src/cert-manager/cert-manager.module.ts (OnModuleInit seed)" + - "apps/api/src/cert-manager/cert-manager.seed.ts (seedCertManagerModule)" + - "apps/api/src/cert-manager/cert-manager.service.ts (shared helpers + operation method stubs)" + - "apps/api/src/cert-manager/cert-manager.controller.ts (4 POST endpoints, @UseModule guard)" + - "apps/api/src/cert-manager/cert-manager.service.spec.ts (RED/GREEN helper + seed tests)" + key_links: + - "CertManagerModule registered in app.module.ts imports array" + - "seedCertManagerModule -> moduleRegistryService.seedModule({ slug: 'cert-manager' })" + - "@Controller('modules/cert-manager') + @UseModule('cert-manager') -> ModuleGuard" +--- + + +Establish the API foundation for the cert-manager module: install node-forge and a Vitest runner for `@tessera/api`, scaffold the NestJS module following the domaincheck analog exactly, seed the module into the registry (CERT-06), and implement + unit-test the shared node-forge helpers every later slice depends on. + +This is the first vertical slice's enabling half: after this plan the module registers itself at startup so it can be activated in the Marketplace and its endpoints become reachable. + +Purpose: All later feature slices (Inspect, Split, Convert, Merge/PFX) build on this module skeleton, the shared crypto helpers, and the API test harness. +Output: Registered cert-manager module + running API test suite + tested shared helpers. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/09-cert-manager-module/09-CONTEXT.md +@.planning/phases/09-cert-manager-module/09-RESEARCH.md +@.planning/phases/09-cert-manager-module/09-PATTERNS.md +@apps/api/src/domaincheck/domaincheck.module.ts +@apps/api/src/domaincheck/domaincheck.seed.ts +@apps/api/src/domaincheck/domaincheck.controller.ts +@apps/api/src/app.module.ts + + + +## Artifacts this plan produces + +- New file: `apps/api/vitest.config.ts` — Vitest config, `test.environment: 'node'`, `test.include: ['src/**/*.spec.ts']` +- New npm scripts in `apps/api/package.json`: `test` (`vitest run`), `test:watch` (`vitest`) +- New deps in `apps/api`: `node-forge@^1.4.0`, `@types/node-forge@^1.3.14` (dev), `vitest@^3` (dev), `@vitest/*` as needed +- New symbol: `CertManagerModule` (class, implements OnModuleInit) +- New symbol: `seedCertManagerModule(moduleRegistryService)` (async function) +- New symbol: `CertManagerService` (@Injectable) with methods: `parseCert`, `splitCerts`, `mergeCerts`, `convertCert` (operation stubs) and helpers `detectFormat`, `toForgeBuffer`, `getFingerprint`, `parsePemChain` +- New symbol: `CertManagerController` (@Controller('modules/cert-manager'), @UseModule('cert-manager')) with routes `POST parse`, `POST split`, `POST merge`, `POST convert` +- New DTO classes: `ParseCertDto`, `MergeCertsDto`, `ConvertCertDto` +- New test file: `apps/api/src/cert-manager/cert-manager.service.spec.ts` + + + + + + Task 1: Install node-forge + Vitest runner for @tessera/api + apps/api/package.json, apps/api/vitest.config.ts + + - apps/api/package.json (current scripts + deps — no test runner exists yet) + - apps/web/vitest.config.ts (reference Vitest config shape; API uses environment 'node' instead of 'jsdom', no react plugin) + - .planning/phases/09-cert-manager-module/09-RESEARCH.md (Installation section + Package Legitimacy Audit — node-forge is Approved) + + + Install runtime + dev deps in the API workspace: run `pnpm --filter @tessera/api add node-forge@^1.4.0`, then `pnpm --filter @tessera/api add -D @types/node-forge@^1.3.14 vitest@^3`. node-forge is Approved in the Package Legitimacy Audit (npm, 35.3M/wk, github.com/digitalbazaar/forge) — no legitimacy checkpoint required. + Create apps/api/vitest.config.ts using `defineConfig` from `vitest/config` with: `test.environment` set to `'node'`, `test.globals` set to `true`, `test.include` set to `['src/**/*.spec.ts']`. Do NOT add jsdom or the react plugin (API is server-only). + Add two scripts to apps/api/package.json: `"test": "vitest run"` and `"test:watch": "vitest"`. Do not add watch flags to the `test` script (must exit). + + + pnpm --filter @tessera/api test --run 2>&1 | grep -Eiq 'no test files|passed|Test Files' && echo VITEST_OK + + + - `node -e "const p=require('./apps/api/package.json'); process.exit(p.dependencies['node-forge']?0:1)"` exits 0 + - `node -e "const p=require('./apps/api/package.json'); process.exit(p.devDependencies['@types/node-forge']&&p.devDependencies['vitest']?0:1)"` exits 0 + - `apps/api/package.json` `scripts.test` equals `vitest run` + - `apps/api/vitest.config.ts` exists and contains `environment: 'node'` + - `pnpm --filter @tessera/api test --run` exits 0 (0 tests or passing tests, never a runner error) + + node-forge + @types/node-forge + vitest installed in @tessera/api; `pnpm --filter @tessera/api test` runs Vitest in a node environment and exits cleanly. + + + + Task 2: Scaffold cert-manager module + shared node-forge helpers with failing spec + apps/api/src/cert-manager/cert-manager.module.ts, apps/api/src/cert-manager/cert-manager.seed.ts, apps/api/src/cert-manager/cert-manager.service.ts, apps/api/src/cert-manager/cert-manager.controller.ts, apps/api/src/cert-manager/dto/parse-cert.dto.ts, apps/api/src/cert-manager/dto/merge-certs.dto.ts, apps/api/src/cert-manager/dto/convert-cert.dto.ts, apps/api/src/cert-manager/cert-manager.service.spec.ts, apps/api/src/app.module.ts + + - apps/api/src/domaincheck/domaincheck.module.ts (OnModuleInit + seed pattern to copy exactly) + - apps/api/src/domaincheck/domaincheck.seed.ts (seedModule call shape) + - apps/api/src/domaincheck/domaincheck.service.ts (Injectable + Logger structure) + - apps/api/src/domaincheck/dto/check-domain.dto.ts (DTO style) + - .planning/phases/09-cert-manager-module/09-PATTERNS.md (Pattern Assignments: full module.ts, seed.ts, service structure, DTO shapes) + - .planning/phases/09-cert-manager-module/09-RESEARCH.md (Pattern 5 node-forge helpers + Pattern 6 format detection + Pitfall 1 binary encoding) + + + - Test: seedCertManagerModule calls moduleRegistryService.seedModule once with an object whose slug is 'cert-manager', category is 'security-tools', isSystem is true (CERT-06). Use a mock ModuleRegistryService. + - Test: detectFormat('cert.pfx', anyBuffer) returns 'pfx'; detectFormat('cert.p7b', anyBuffer) returns 'p7b'; detectFormat('cert.der', anyBuffer) returns 'der'; detectFormat('cert.pem', pemBuffer) returns 'pem'. + - Test: detectFormat('cert.cer', buffer starting with '-----BEGIN') returns 'pem'; detectFormat('cert.cer', binaryBuffer) returns 'der' (ambiguous .cer resolved by content sniff). + - Test: getFingerprint(cert, 'sha256') returns an uppercase colon-separated hex string (matches /^[0-9A-F]{2}(:[0-9A-F]{2})+$/) computed over DER bytes, for a self-signed cert generated in beforeAll via forge.pki.rsa.generateKeyPair + forge.pki.createCertificate. + - Test: parsePemChain(concatenation of two cert PEMs) returns an array of length 2. + + + Create the module directory apps/api/src/cert-manager/ mirroring domaincheck. + cert-manager.module.ts: copy domaincheck.module.ts structure — @Module imports [ModuleRegistryModule], controllers [CertManagerController], providers [CertManagerService], implements OnModuleInit, constructor injects ModuleRegistryService, onModuleInit calls seedCertManagerModule and logs success/failure via a Logger named CertManagerModule. + cert-manager.seed.ts: export async seedCertManagerModule(moduleRegistryService) calling moduleRegistryService.seedModule with 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 (per PATTERNS seed pattern — implements CERT-06). + cert-manager.service.ts: @Injectable with a Logger named CertManagerService. Implement the shared helpers concretely: detectFormat(filename, buffer) per RESEARCH Pattern 6; toForgeBuffer(buffer) returning forge.util.createBuffer(buffer.toString('binary')) (NEVER 'utf-8' — Pitfall 1); getFingerprint(cert, algorithm) per RESEARCH Pattern 5 (hash the DER bytes, uppercase colon-joined hex); parsePemChain(pem) using the BEGIN/END CERTIFICATE regex. Add operation method stubs parseCert, splitCerts, mergeCerts, convertCert that each throw a NestJS NotImplementedException for now (filled by later slices). Never log the password parameter. + cert-manager.controller.ts: @Controller('modules/cert-manager') decorated with @UseModule('cert-manager') from ../module-registry/module.guard; constructor injects CertManagerService. Declare the four POST routes (parse, split, merge, convert) delegating to the service; parse/split/convert use FileInterceptor('file', { limits: { fileSize: 5*1024*1024 } }), merge uses FilesInterceptor('files', 20, { limits: { fileSize: 5*1024*1024 } }) per PATTERNS controller pattern. Reject missing input with BadRequestException. Route bodies may delegate to the (still-stubbed) service methods. + dto/parse-cert.dto.ts, dto/merge-certs.dto.ts, dto/convert-cert.dto.ts: per PATTERNS DTO shapes (ParseCertDto { pemText, password? }, MergeCertsDto { outputFormat: 'pem'|'pfx', password? }, ConvertCertDto { targetFormat: 'pem'|'der'|'pfx'|'p7b', password? }). + Register the module: add `import { CertManagerModule } from './cert-manager/cert-manager.module';` to apps/api/src/app.module.ts and add `CertManagerModule` to the @Module imports array (after DomaincheckModule). + Create cert-manager.service.spec.ts implementing the Behavior tests above. Write the tests FIRST and confirm they fail (RED) against empty helpers, then implement the helpers until they pass (GREEN). Generate the test cert(s) in a beforeAll using node-forge (self-signed), so no key material is committed. + + + pnpm --filter @tessera/api test cert-manager --run + + + - `pnpm --filter @tessera/api test cert-manager --run` exits 0 with the seed, detectFormat, getFingerprint, and parsePemChain tests passing + - `grep -q "slug: 'cert-manager'" apps/api/src/cert-manager/cert-manager.seed.ts` + - `grep -q "@UseModule('cert-manager')" apps/api/src/cert-manager/cert-manager.controller.ts` + - `grep -q "CertManagerModule" apps/api/src/app.module.ts` + - `grep -q "toString('binary')" apps/api/src/cert-manager/cert-manager.service.ts` and no occurrence of `toString('utf-8')` in a forge.util.createBuffer call + - `pnpm --filter @tessera/api type-check` exits 0 + + The cert-manager module is scaffolded per the domaincheck analog, registered in app.module.ts, seeds slug 'cert-manager' (CERT-06), and the shared node-forge helpers are implemented and green under Vitest. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| client -> API upload | Untrusted certificate bytes cross into node-forge parsing | +| npm registry -> build | Third-party crypto dependency (node-forge) enters the build | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-09-SC | Tampering | node-forge / @types/node-forge install | high | mitigate | node-forge Approved in Package Legitimacy Audit (npm, 35.3M/wk, DigitalBazaar); pinned `^1.4.0`; no [ASSUMED]/[SUS] packages so no legitimacy checkpoint | +| T-09-04 | Elevation of Privilege | CertManagerController routes | high | mitigate | Global JwtAuthGuard + TenantGuard (app.module) plus `@UseModule('cert-manager')` ModuleGuard on the controller — unauthenticated/unactivated requests get 401/403 | +| T-09-03 | Denial of Service | FileInterceptor / FilesInterceptor upload | high | mitigate | `limits: { fileSize: 5 * 1024 * 1024 }` on every upload interceptor caps memory per request | +| T-09-02 | Information Disclosure | service/controller password param | high | mitigate | `password` is never passed to a logger; service Logger only logs failure category text | + + + +- `pnpm --filter @tessera/api test cert-manager --run` — seed + helper suite green +- `pnpm --filter @tessera/api type-check` — API compiles with new module +- Manual (deferred to phase gate): restart API, activate cert-manager in Marketplace, confirm no startup errors and the registry row exists + + + +- node-forge + Vitest installed in @tessera/api; `pnpm --filter @tessera/api test` runs +- cert-manager module registered and seeding slug 'cert-manager' (CERT-06) +- Shared helpers implemented and unit-tested; binary encoding uses 'binary' not 'utf-8' +- API type-checks clean + + + +Create `.planning/phases/09-cert-manager-module/09-01-SUMMARY.md` when done + diff --git a/.planning/phases/09-cert-manager-module/09-02-PLAN.md b/.planning/phases/09-cert-manager-module/09-02-PLAN.md new file mode 100644 index 0000000..4e2c480 --- /dev/null +++ b/.planning/phases/09-cert-manager-module/09-02-PLAN.md @@ -0,0 +1,192 @@ +--- +phase: 09-cert-manager-module +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - apps/web/src/messages/de.json + - apps/web/src/messages/en.json + - apps/web/src/app/(portal)/modules/cert-manager/page.tsx + - apps/web/src/app/(portal)/modules/cert-manager/actions.ts + - apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx + - apps/web/src/app/(portal)/modules/cert-manager/components/PasswordField.tsx + - apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx + - apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx + - 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/cert-manager.test.tsx +autonomous: true +requirements: [CERT-06] + +must_haves: + truths: + - "The /modules/cert-manager page renders the title, description and four tabs (Analysieren, Aufteilen, Zusammenfuehren, Konvertieren)" + - "The shared input card shows a drag-and-drop DropZone, an OR divider, a PEM textarea, and a conditionally-shown password field" + - "Selecting a .pfx/.p12 file OR choosing PFX output reveals the password field; otherwise it is hidden" + - "The certManager i18n namespace resolves in both de.json and en.json with no missing keys" + artifacts: + - "apps/web/src/app/(portal)/modules/cert-manager/page.tsx (tab shell + shared input state)" + - "apps/web/src/app/(portal)/modules/cert-manager/actions.ts (API_URL, downloadBase64, postForm helpers)" + - "apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx + PasswordField.tsx + 4 tab stubs" + - "certManager namespace in de.json and en.json" + - "apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx (shell tests)" + key_links: + - "page.tsx passes { file, pemText, password } down to the active tab component" + - "useTranslations('certManager') resolves keys defined in messages/de.json + en.json" +--- + + +Build the frontend shell for the cert-manager module: the tab-based page, shared input card (DropZone + PEM textarea + conditional password field), reusable download/fetch helpers, empty tab-component stubs, and the full `certManager` i18n namespace in German and English. + +MVP framing — this delivers the visible half of the first vertical slice: after this plan a user who activates the module can open `/modules/cert-manager`, see all four tabs and the input card, and read localized copy, even though no operation is wired yet. + +Purpose: Every feature slice (Inspect, Split, Convert, Merge) fills in one tab component and one action against this shell. +Output: Rendering cert-manager page + localized strings + shared client helpers. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/09-cert-manager-module/09-CONTEXT.md +@.planning/phases/09-cert-manager-module/09-UI-SPEC.md +@.planning/phases/09-cert-manager-module/09-PATTERNS.md +@apps/web/src/app/(portal)/modules/domaincheck/page.tsx +@apps/web/src/app/(portal)/modules/domaincheck/actions.ts +@apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/CsvImportButton.tsx +@apps/web/src/messages/de.json + + + +## Artifacts this plan produces + +- New route page: `CertManagerPage` (default export, 'use client') at `apps/web/src/app/(portal)/modules/cert-manager/page.tsx` +- New symbols in `actions.ts`: `API_URL` const, `downloadBase64(filename, content, mimeType)`, `postForm(endpoint, form)` (fetch wrapper, credentials:'include', throws on !ok) +- New components: `DropZone` (props: onFile, accept), `PasswordField` (props: value, onChange, show), `InspectTab`, `SplitTab`, `MergeTab`, `ConvertTab` (each props: file, pemText, password — render empty state for now) +- New i18n namespace `certManager` added to `apps/web/src/messages/de.json` and `en.json` +- New test file: `apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx` + + + + + + Task 1: Add certManager i18n namespace (de + en) + apps/web/src/messages/de.json, apps/web/src/messages/en.json + + - apps/web/src/messages/de.json (locate the existing domaincheck namespace; append certManager as a sibling — do not restructure) + - apps/web/src/messages/en.json (same) + - .planning/phases/09-cert-manager-module/09-RESEARCH.md (i18n Namespace Structure de.json — canonical key set) + - .planning/phases/09-cert-manager-module/09-UI-SPEC.md (Copywriting Contract — exact German strings) + + + Add a certManager namespace to de.json using the exact keys and German strings from the RESEARCH i18n Namespace Structure and the UI-SPEC Copywriting Contract: title 'Zertifikat-Manager', description 'Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.', tabs.{inspect,split,merge,convert} = Analysieren/Aufteilen/Zusammenfuehren/Konvertieren, dropZone.{placeholder,formats}, paste.placeholder, password.label 'Passwort (PFX/P12)', or 'oder', actions.{inspect,split,merge,convert,download,processing}, emptyState.{inspect,inspectBody,split,splitBody,merge,mergeBody,convert,convertBody}, error.{generic,wrongPassword,unknownFormat}. Use the exact umlaut spellings from UI-SPEC. Add the same key structure to en.json with English equivalents. Preserve existing JSON ordering/formatting; append the namespace only. + + + node -e "const de=require('./apps/web/src/messages/de.json'); const en=require('./apps/web/src/messages/en.json'); const k=Object.keys(de.certManager.tabs).sort().join(','); if(k!=='convert,inspect,merge,split') throw new Error('de tabs '+k); if(!en.certManager.actions.download) throw new Error('en missing download'); console.log('I18N_OK');" + + + - `de.certManager.title` equals 'Zertifikat-Manager' and `de.certManager.tabs.merge` equals 'Zusammenfuehren' + - de.json and en.json share identical key paths under certManager (same tabs, actions, emptyState, error keys) + - `pnpm --filter @tessera/web type-check` still passes (valid JSON) + + certManager namespace exists in both de.json and en.json with the full key set; German copy matches the UI-SPEC Copywriting Contract. + + + + Task 2: Build page shell, shared input card, DropZone, PasswordField, tab stubs, and client helpers + apps/web/src/app/(portal)/modules/cert-manager/page.tsx, apps/web/src/app/(portal)/modules/cert-manager/actions.ts, apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/PasswordField.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx, 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/domaincheck/page.tsx (client component + useTranslations + Card layout + loading/error state pattern) + - apps/web/src/app/(portal)/modules/domaincheck/actions.ts (fetch wrapper + credentials:'include' pattern) + - apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/CsvImportButton.tsx (hidden file input + drag-over DropZone pattern) + - .planning/phases/09-cert-manager-module/09-UI-SPEC.md (Layout Contract, Conditional Elements, Interaction Contract, Color/Spacing/Typography) + - .planning/phases/09-cert-manager-module/09-PATTERNS.md (page.tsx header/layout pattern, DropZone pattern, actions.ts pattern, downloadBase64 helper) + + + Create page.tsx as a 'use client' component using useTranslations('certManager'). State: activeTab ('inspect'|'split'|'merge'|'convert'), file (File|null), pemText (string), password (string). Layout per UI-SPEC: max-w-4xl mx-auto p-6 space-y-6; header (h1 text-2xl font-bold tracking-tight + p text-sm text-muted-foreground); shared input Card (rounded-lg border border-border bg-card p-6 shadow-sm space-y-4) containing DropZone, an 'oder' divider (t('or')), a PEM textarea (t('paste.placeholder')), and PasswordField shown only when the selected file extension is .pfx/.p12 OR activeTab is 'merge' with PFX output (pass a `show` prop). Tab nav (border-b border-border flex gap-6; active tab border-b-2 border-primary text-foreground, inactive text-muted-foreground). Tab content Card renders the active tab component, passing { file, pemText, password }. Selecting a file clears pemText and vice versa (single active source per Interaction Contract). Changing the active tab clears the previous tab's result but keeps the shared input. + Create components/DropZone.tsx per PATTERNS DropZone pattern: hidden file input, click-to-browse, drag-over highlight (border-primary bg-primary/5), accept prop, calls onFile; reset e.target.value to allow re-selecting the same file. Use i18n for placeholder text. + Create components/PasswordField.tsx: input[type=password] with a show/hide toggle rendered as an inline SVG eye icon (no external icon lib per UI-SPEC); props value, onChange, show (render null when show is false — no reflow). Label from t('password.label'). + Create components/InspectTab.tsx, SplitTab.tsx, MergeTab.tsx, ConvertTab.tsx as stubs: each accepts { file, pemText, password } and renders the corresponding empty state from t('emptyState.*'). No API calls yet — later slices fill these in. + Create actions.ts with: API_URL const (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'); downloadBase64(filename, content, mimeType) per PATTERNS (atob -> Uint8Array -> Blob -> object URL -> anchor click -> revoke); a postForm(endpoint, form) helper that fetches `${API_URL}/modules/cert-manager/${endpoint}` with method POST, body form, credentials 'include', no manual Content-Type, and throws Error(`${status} ${body}`) on !response.ok, else returns response.json(). + All strings via t(); no hardcoded UI copy. No shadcn, no Radix, Tailwind utilities only. + + + pnpm --filter @tessera/web test cert-manager --run + + + - `apps/web/src/app/(portal)/modules/cert-manager/page.tsx` starts with `'use client'` and calls `useTranslations('certManager')` + - `grep -q "max-w-4xl" apps/web/src/app/(portal)/modules/cert-manager/page.tsx` + - `grep -q "credentials: 'include'" apps/web/src/app/(portal)/modules/cert-manager/actions.ts` + - `grep -q "URL.createObjectURL" apps/web/src/app/(portal)/modules/cert-manager/actions.ts` + - PasswordField renders nothing when `show` is false and renders an input[type=password] with a toggle when true + - `pnpm --filter @tessera/web type-check` exits 0 + + The page renders the header, four tabs, shared input card with DropZone + textarea + conditional PasswordField, and delegates to tab stubs; actions.ts exposes downloadBase64 + postForm helpers. + + + + Task 3: Shell render tests + apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx + + - apps/web/vitest.config.ts (jsdom env, globals, setupFiles ./src/test/setup.ts) + - apps/web/src/test/setup.ts (existing test setup — how providers/i18n are wired for tests) + - Any existing *.test.tsx under apps/web/src/app/(portal)/modules (reference NextIntlClientProvider wiring in component tests) + + + - Test: page renders the title 'Zertifikat-Manager' and all four tab labels (Analysieren, Aufteilen, Zusammenfuehren, Konvertieren). + - Test: the password field is NOT in the document on initial render (no PFX file, inspect tab). + - Test: each tab, when active, shows its empty-state text from certManager.emptyState. + + + Create cert-manager.test.tsx rendering CertManagerPage wrapped in NextIntlClientProvider with the de messages (follow the existing module component-test wiring found in read_first). Implement the Behavior assertions using @testing-library/react queries (getByText / queryByLabelText). Write the tests to describe the shell contract; they should pass against the Task 2 implementation (GREEN). If the shell is missing anything they assert, fix the shell. + + + pnpm --filter @tessera/web test cert-manager --run + + + - `pnpm --filter @tessera/web test cert-manager --run` exits 0 with the title, tab-label, hidden-password, and empty-state assertions passing + - The test file imports NextIntlClientProvider and renders with de messages + + Shell render tests are green: title, four tabs, hidden password field, and per-tab empty states are asserted. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| browser -> API | Client sends uploaded cert bytes + optional password to the API via fetch | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-09-02 | Information Disclosure | PasswordField / actions.ts | high | mitigate | Password is held in local React state and sent only in the FormData body over the authenticated fetch; never placed in URL query, console.log, or download filename | +| T-09-04 | Elevation of Privilege | client fetch to /modules/cert-manager/* | high | mitigate | All requests use `credentials: 'include'`; the API enforces JwtAuthGuard + ModuleGuard, so an unauthenticated/unactivated client cannot process certs | +| T-09-05 | Tampering | client-side accept filter | low | accept | `accept=".pem,.crt,..."` is a UX guard only; real validation happens server-side in the API (Plan 01/03+) — client filter is not a security boundary | + + + +- `pnpm --filter @tessera/web test cert-manager --run` — shell tests green +- `pnpm --filter @tessera/web type-check` — web compiles +- Manual (deferred to phase gate): open /modules/cert-manager after activation, confirm tabs, DropZone drag highlight, and password field toggling on .pfx selection + + + +- certManager i18n namespace complete in de + en +- Page shell renders title, four tabs, shared input card, conditional password field +- actions.ts exposes downloadBase64 + postForm; no shadcn/Radix used +- Shell tests green; web type-checks clean + + + +Create `.planning/phases/09-cert-manager-module/09-02-SUMMARY.md` when done + diff --git a/.planning/phases/09-cert-manager-module/09-03-PLAN.md b/.planning/phases/09-cert-manager-module/09-03-PLAN.md new file mode 100644 index 0000000..49461cb --- /dev/null +++ b/.planning/phases/09-cert-manager-module/09-03-PLAN.md @@ -0,0 +1,184 @@ +--- +phase: 09-cert-manager-module +plan: 03 +type: execute +wave: 2 +depends_on: [09-01, 09-02] +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/InspectTab.tsx + - apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx +autonomous: true +requirements: [CERT-01, CERT-05] + +must_haves: + truths: + - "A user uploads (or pastes) a PEM/DER/PFX/P7B certificate and sees subject, issuer, validity, SANs, key type/size, serial, signature algorithm, and SHA-1 + SHA-256 fingerprints" + - "A password-protected PFX is parsed when the correct password is supplied; a wrong password returns HTTP 400 (not 500)" + - "The Inspect tab renders a key-value result grid on success and a localized error on failure" + artifacts: + - "CertManagerService.parseCert implemented (PEM/DER/PFX/P7B -> CertDetails)" + - "POST /modules/cert-manager/parse wired to parseCert" + - "InspectTab.tsx renders the CertDetails grid + inspect action" + key_links: + - "InspectTab -> inspectCertAction -> POST /modules/cert-manager/parse -> CertManagerService.parseCert" + - "parseCert wraps node-forge in try/catch -> BadRequestException (wrong password / malformed)" +--- + + +First functional vertical slice: certificate inspection. Implement CertManagerService.parseCert to accept an uploaded file (PEM/DER/PFX/P7B) or pasted PEM text plus an optional PFX password, and return structured CertDetails. Wire the POST /parse endpoint and build the Inspect tab to render the result grid. + +MVP: after this plan a user can activate the module, upload a cert, and read its parsed details — a complete end-to-end capability (CERT-01). Password-protected PFX open (CERT-05 read half) is covered because parsing a .pfx requires the supplied password. + +Purpose: Delivers CERT-01 and the read half of CERT-05; establishes the parse-and-render pattern reused by later slices. +Output: Working Inspect tab end-to-end + tested parseCert service. + + + +@$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-CONTEXT.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-01-SUMMARY.md +@.planning/phases/09-cert-manager-module/09-02-SUMMARY.md + + + +## Artifacts this plan produces + +- Implemented method: `CertManagerService.parseCert({ file?, pemText?, password? }): CertDetails` +- New TS interface: `CertDetails` (subject, issuer, validity{notBefore,notAfter,isExpired,daysLeft}, san[], keyType, keyBits, serialNumber, signatureAlgorithm, fingerprint{sha1,sha256}, pemPreview) — per RESEARCH Inspect Response Shape +- Wired route: `POST /modules/cert-manager/parse` (FileInterceptor('file') + @Body pemText/password) +- New action: `inspectCertAction(input)` in actions.ts (JSON path for pemText, multipart path for file) +- Implemented component: `InspectTab` (key-value result grid + inspect button + loading/error) + + + + + + Task 1: RED — failing parseCert spec + apps/api/src/cert-manager/cert-manager.service.spec.ts + + - apps/api/src/cert-manager/cert-manager.service.spec.ts (existing helper/seed tests + beforeAll self-signed cert generator from Plan 01) + - apps/api/src/cert-manager/cert-manager.service.ts (current parseCert stub throwing NotImplementedException + helpers) + - .planning/phases/09-cert-manager-module/09-RESEARCH.md (Pattern 5 node-forge parse APIs; Inspect Response Shape; Pitfall 1 binary encoding) + + + - Test: parseCert({ pemText: }) returns CertDetails with subject.cn matching the generated CN, fingerprint.sha256 matching /^[0-9A-F]{2}(:[0-9A-F]{2})+$/, keyType 'RSA', keyBits 2048, and validity.isExpired false. + - Test: parseCert({ file: { originalname:'c.der', buffer: } }) returns the same subject.cn (DER path uses 'binary' encoding). + - Test: parseCert({ file: { originalname:'c.pfx', buffer: }, password: 'secret' }) returns CertDetails for the enclosed cert. + - Test: parseCert({ file: { originalname:'c.pfx', buffer: }, password: 'wrong' }) throws BadRequestException (asserted via rejects.toThrow / expect(() => ...).toThrow with the Nest exception). + - Test: parseCert({ pemText: 'not a cert' }) throws BadRequestException. + + + Extend cert-manager.service.spec.ts with the Behavior tests above. Build the DER and password-protected PFX fixtures in the spec from the beforeAll self-signed cert using node-forge (forge.asn1.toDer + forge.pkcs12.toPkcs12Asn1 with password 'secret'). Run the suite and confirm these new tests FAIL against the current parseCert stub (RED). Do not implement parseCert in this task. + + + pnpm --filter @tessera/api test cert-manager --run 2>&1 | grep -Eiq 'fail|NotImplemented|✗|×' && echo RED_CONFIRMED + + + - New parseCert tests exist in cert-manager.service.spec.ts covering PEM, DER, PFX-correct-password, PFX-wrong-password (BadRequestException), and malformed input + - Running the suite shows the parseCert tests failing (RED) while the Plan 01 helper/seed tests still pass + + Failing parseCert spec committed (RED) covering all input formats + wrong-password + malformed cases. + + + + Task 2: GREEN — implement parseCert + wire POST /parse + 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 (helpers detectFormat/toForgeBuffer/getFingerprint/parsePemChain from Plan 01) + - apps/api/src/cert-manager/cert-manager.controller.ts (parse route stub + FileInterceptor from Plan 01) + - apps/api/src/cert-manager/cert-manager.service.spec.ts (the RED tests from Task 1 — target contract) + - .planning/phases/09-cert-manager-module/09-RESEARCH.md (Pattern 5 full node-forge API: certificateFromPem, fromDer, pkcs12FromAsn1, subject/issuer getField, SAN extraction, RSA bitLength; Inspect Response Shape) + + + Implement CertManagerService.parseCert to: resolve input (pemText -> parse as PEM/chain; file -> detectFormat, then PEM via certificateFromPem, DER via asn1.fromDer(toForgeBuffer(...)) + certificateFromAsn1, PFX via pkcs12FromAsn1(asn1, password ?? '') then extract certBag, P7B via messageFromPem or messageFromAsn1 depending on content sniff). Build CertDetails: subject/issuer CN/O/OU/C via cert.subject.getField / cert.issuer.getField; validity.notBefore/notAfter from cert.validity, isExpired and daysLeft computed against now; san[] from the subjectAltName extension; keyType 'RSA'/'EC' and keyBits from the public key bitLength; serialNumber; signatureAlgorithm from the cert; fingerprint.sha1 and .sha256 via getFingerprint; pemPreview via certificateToPem. Wrap ALL node-forge calls in try/catch and throw BadRequestException with a generic message on failure (covers malformed cert AND wrong PFX password -> 400, threat T-09-01/T-09-02). Never log the password. + Define and export the CertDetails interface (co-located in the service or a types file). + In cert-manager.controller.ts, ensure POST parse uses FileInterceptor('file', { limits: { fileSize: 5*1024*1024 } }), reads @Body('pemText') and @Body('password'), rejects when neither file nor pemText present (BadRequestException), and delegates to parseCert. Run the suite until all parseCert tests pass (GREEN). + + + pnpm --filter @tessera/api test cert-manager --run + + + - `pnpm --filter @tessera/api test cert-manager --run` exits 0 with all parseCert tests passing + - `grep -q "BadRequestException" apps/api/src/cert-manager/cert-manager.service.ts` in the parseCert catch path + - No `console.log`/logger call in the service references the password value (grep shows no `password` argument passed to logger) + - `grep -q "fileSize: 5" apps/api/src/cert-manager/cert-manager.controller.ts` + - `pnpm --filter @tessera/api type-check` exits 0 + + parseCert returns full CertDetails for PEM/DER/PFX/P7B, throws 400 on wrong password/malformed input, and POST /parse is wired; API tests green. + + + + Task 3: Inspect tab UI + action + render test + apps/web/src/app/(portal)/modules/cert-manager/actions.ts, apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx + + - apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx (empty-state stub from Plan 02) + - apps/web/src/app/(portal)/modules/cert-manager/actions.ts (API_URL, postForm, downloadBase64 from Plan 02) + - apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx (shell tests + i18n wiring from Plan 02) + - apps/web/src/app/(portal)/modules/domaincheck/page.tsx (loading/error state pattern) + - .planning/phases/09-cert-manager-module/09-UI-SPEC.md (Analysieren result = key-value grid grid-cols-2 gap-2 text-sm; loading label swap; error text-destructive) + + + Add inspectCertAction to actions.ts: if pemText is present, POST JSON { pemText, password } to /modules/cert-manager/parse with Content-Type application/json; else build FormData with file + optional password and use the postForm('parse', form) helper. Return the parsed CertDetails JSON; throw on !ok (reuse postForm error behavior for the multipart path). + Implement InspectTab: accept { file, pemText, password }. Render a primary 'Analysieren' button (t('actions.inspect'), disabled + label t('actions.processing') while loading, per UI-SPEC). On click call inspectCertAction and store the result; on error store a localized message (wrong-password -> t('error.wrongPassword'), unknown format -> t('error.unknownFormat'), else t('error.generic')). Render the empty state (t('emptyState.inspect')) when no result; render a grid grid-cols-2 gap-2 text-sm of subject/issuer/validity/SANs/keyType/keyBits/serial/signatureAlgorithm/fingerprint.sha1/fingerprint.sha256 on success; render error in text-sm text-destructive. All labels via t(). No shadcn. + Extend cert-manager.test.tsx with a test that mocks inspectCertAction to resolve a CertDetails object and asserts the InspectTab renders the subject CN and the sha256 fingerprint after clicking Analysieren; and a test that mocks a rejection and asserts a text-destructive error is shown. + + + pnpm --filter @tessera/web test cert-manager --run + + + - `grep -q "inspectCertAction" apps/web/src/app/(portal)/modules/cert-manager/actions.ts` + - InspectTab shows the empty state before a result and a key-value grid (grid-cols-2) after a successful inspect + - `pnpm --filter @tessera/web test cert-manager --run` exits 0 including the new success + error InspectTab tests + - `pnpm --filter @tessera/web type-check` exits 0 + + Inspect tab loads a cert end-to-end, renders the details grid on success and a localized destructive error on failure; web tests green. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| client -> API /parse | Untrusted cert bytes + optional PFX password enter node-forge parsing | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-09-01 | Tampering | CertManagerService.parseCert (node-forge) | medium | mitigate | Every node-forge call wrapped in try/catch; malformed cert -> BadRequestException (400), never an unhandled 500 | +| T-09-02 | Information Disclosure | parseCert password handling | high | mitigate | Wrong PFX password caught -> generic 400 message; password value never logged and never echoed in the response | +| T-09-03 | Denial of Service | POST /parse upload | high | mitigate | FileInterceptor `limits.fileSize` = 5 MB caps in-memory buffer | +| T-09-04 | Elevation of Privilege | POST /parse | high | mitigate | Global JwtAuthGuard + `@UseModule('cert-manager')` on the controller | + + + +- `pnpm --filter @tessera/api test cert-manager --run` — parseCert suite green (all formats + wrong password 400) +- `pnpm --filter @tessera/web test cert-manager --run` — InspectTab success + error tests green +- `pnpm --filter @tessera/api type-check` and `pnpm --filter @tessera/web type-check` clean +- Manual (phase gate): upload a real cert, verify grid; upload a password PFX with wrong then right password + + + +- parseCert returns full CertDetails for PEM/DER/PFX/P7B (CERT-01) and opens password PFX with correct password / 400 on wrong (CERT-05 read) +- Inspect tab works end-to-end with localized errors +- All API + web tests green; type-checks clean + + + +Create `.planning/phases/09-cert-manager-module/09-03-SUMMARY.md` when done + diff --git a/.planning/phases/09-cert-manager-module/09-04-PLAN.md b/.planning/phases/09-cert-manager-module/09-04-PLAN.md new file mode 100644 index 0000000..d1adfa7 --- /dev/null +++ b/.planning/phases/09-cert-manager-module/09-04-PLAN.md @@ -0,0 +1,175 @@ +--- +phase: 09-cert-manager-module +plan: 04 +type: execute +wave: 3 +depends_on: [09-03] +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/SplitTab.tsx + - apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx +autonomous: true +requirements: [CERT-02] + +must_haves: + truths: + - "A user uploads a fullchain.pem or a P7B bundle and receives each individual certificate as a separately downloadable file" + - "The Split tab lists one download button per returned certificate with its subject CN and expiry" + artifacts: + - "CertManagerService.splitCerts implemented (fullchain PEM + P7B -> array of certs)" + - "POST /modules/cert-manager/split wired to splitCerts" + - "SplitTab.tsx renders per-cert download list" + key_links: + - "SplitTab -> splitCertsAction -> POST /modules/cert-manager/split -> CertManagerService.splitCerts" + - "each returned cert.content (base64 PEM) -> downloadBase64 on click" +--- + + +Vertical slice: split a fullchain.pem or a P7B/PKCS7 bundle into its individual certificates, each downloadable. Implement CertManagerService.splitCerts, wire POST /split, and build the Split tab to list per-cert download buttons. + +MVP: after this plan a user can upload a chain/bundle and download each cert individually — a complete capability (CERT-02). + +Purpose: Delivers CERT-02, reusing the parse helpers and the base64-download pattern. +Output: Working Split tab end-to-end + tested splitCerts service. + + + +@$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-03-SUMMARY.md + + + +## Artifacts this plan produces + +- Implemented method: `CertManagerService.splitCerts({ file }): SplitResponse` +- New TS interface: `SplitResponse` ({ count, certs: [{ index, filename, content(base64 PEM), subject{cn}, validity{notAfter} }] }) per RESEARCH Split Response Shape +- Wired route: `POST /modules/cert-manager/split` (FileInterceptor('file')) +- New action: `splitCertsAction(file)` in actions.ts +- Implemented component: `SplitTab` (per-cert download list) + + + + + + Task 1: RED — failing splitCerts spec + apps/api/src/cert-manager/cert-manager.service.spec.ts + + - apps/api/src/cert-manager/cert-manager.service.spec.ts (self-signed cert generator + fixtures from prior plans) + - apps/api/src/cert-manager/cert-manager.service.ts (splitCerts stub + parsePemChain helper) + - .planning/phases/09-cert-manager-module/09-RESEARCH.md (Pattern 5 parsePemChain + pkcs7 messageFromPem/messageFromAsn1; Split Response Shape; Pitfall 4 P7B binary vs PEM) + + + - Test: splitCerts({ file: { originalname:'fullchain.pem', buffer: } }) returns count 2 and certs[0]/certs[1] each with a base64 content that decodes to a single valid PEM (contains one BEGIN CERTIFICATE block) and a subject.cn. + - Test: splitCerts on a P7B PEM bundle (built via forge.pkcs7 from the test certs) returns the enclosed certs count. + - Test: splitCerts({ file: { originalname:'x.pem', buffer: } }) throws BadRequestException. + + + Add the Behavior tests to cert-manager.service.spec.ts. Build the fullchain fixture by concatenating two self-signed cert PEMs; build the P7B fixture with node-forge pkcs7. Confirm the tests FAIL against the splitCerts stub (RED). Do not implement splitCerts here. + + + pnpm --filter @tessera/api test cert-manager --run 2>&1 | grep -Eiq 'fail|NotImplemented|✗|×' && echo RED_CONFIRMED + + + - splitCerts tests exist covering fullchain PEM, P7B bundle, and malformed input + - The suite shows splitCerts tests failing while all prior tests still pass + + Failing splitCerts spec committed (RED). + + + + Task 2: GREEN — implement splitCerts + wire POST /split + 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 (parsePemChain, detectFormat, toForgeBuffer helpers) + - apps/api/src/cert-manager/cert-manager.controller.ts (split route stub + FileInterceptor) + - apps/api/src/cert-manager/cert-manager.service.spec.ts (RED contract from Task 1) + - .planning/phases/09-cert-manager-module/09-RESEARCH.md (Pattern 5 PEM chain split + pkcs7 parse; Pitfall 4 sniff -----BEGIN for PEM vs DER P7B) + + + Implement CertManagerService.splitCerts({ file }): detectFormat; for PEM/CRT use parsePemChain to get the cert array; for P7B sniff the first bytes — if the buffer contains '-----BEGIN' use forge.pkcs7.messageFromPem, else asn1.fromDer(toForgeBuffer(...)) + messageFromAsn1 — and read the .certificates array. Build SplitResponse: count plus certs[] where each entry has index, filename `cert-${index+1}.pem`, content = base64 of certificateToPem(cert), subject.cn and validity.notAfter. Wrap in try/catch -> BadRequestException. In the controller, POST split uses FileInterceptor('file', { limits: { fileSize: 5*1024*1024 } }), rejects a missing file, and delegates. Run the suite to GREEN. + + + pnpm --filter @tessera/api test cert-manager --run + + + - `pnpm --filter @tessera/api test cert-manager --run` exits 0 with splitCerts tests passing + - Each returned cert content base64-decodes to exactly one BEGIN CERTIFICATE block + - `grep -q "messageFromPem" apps/api/src/cert-manager/cert-manager.service.ts` (P7B path present) + - `pnpm --filter @tessera/api type-check` exits 0 + + splitCerts returns individual base64 PEM certs for fullchain + P7B, 400 on malformed; POST /split wired; API tests green. + + + + Task 3: Split tab UI + action + render test + apps/web/src/app/(portal)/modules/cert-manager/actions.ts, apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx + + - apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx (empty-state stub) + - apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx (established loading/error/result pattern from Plan 03) + - apps/web/src/app/(portal)/modules/cert-manager/actions.ts (postForm, downloadBase64) + - apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx (test wiring) + - .planning/phases/09-cert-manager-module/09-UI-SPEC.md (Aufteilen result = list of certs, each with a bg-secondary download button; empty state 'Keine Datei geladen.') + + + Add splitCertsAction(file) to actions.ts: build FormData with the file and call postForm('split', form); return the SplitResponse. + Implement SplitTab: accept { file }. Primary 'Aufteilen' button (t('actions.split'), disabled + t('actions.processing') while loading). On success store certs[] and render a list — each row shows the cert subject.cn + validity.notAfter and a secondary download button (bg-secondary text-secondary-foreground, t('actions.download')) that calls downloadBase64(filename, content, 'application/x-pem-file'). Empty state t('emptyState.split') when no result; localized error (t('error.generic')/t('error.unknownFormat')) in text-destructive on failure. + Extend cert-manager.test.tsx: mock splitCertsAction to resolve two certs and assert SplitTab renders two download buttons after clicking Aufteilen. + + + pnpm --filter @tessera/web test cert-manager --run + + + - `grep -q "splitCertsAction" apps/web/src/app/(portal)/modules/cert-manager/actions.ts` + - SplitTab renders one download button per returned cert; clicking it calls downloadBase64 + - `pnpm --filter @tessera/web test cert-manager --run` exits 0 including the new SplitTab test + - `pnpm --filter @tessera/web type-check` exits 0 + + Split tab uploads a chain/bundle and lists downloadable per-cert files end-to-end; web tests green. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| client -> API /split | Untrusted chain/bundle bytes enter node-forge parsing | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-09-01 | Tampering | splitCerts (node-forge PEM/PKCS7) | medium | mitigate | try/catch around parsePemChain + pkcs7 parse -> BadRequestException on malformed bundle | +| T-09-03 | Denial of Service | POST /split upload | high | mitigate | FileInterceptor `limits.fileSize` = 5 MB | +| T-09-04 | Elevation of Privilege | POST /split | high | mitigate | Global JwtAuthGuard + `@UseModule('cert-manager')` | + + + +- `pnpm --filter @tessera/api test cert-manager --run` — splitCerts green +- `pnpm --filter @tessera/web test cert-manager --run` — SplitTab green +- type-checks clean +- Manual (phase gate): upload a real fullchain.pem, download each cert, verify each opens as a valid single cert + + + +- splitCerts splits fullchain PEM + P7B into individual downloadable certs (CERT-02) +- Split tab works end-to-end +- All tests green; type-checks clean + + + +Create `.planning/phases/09-cert-manager-module/09-04-SUMMARY.md` when done + diff --git a/.planning/phases/09-cert-manager-module/09-05-PLAN.md b/.planning/phases/09-cert-manager-module/09-05-PLAN.md new file mode 100644 index 0000000..e27cdad --- /dev/null +++ b/.planning/phases/09-cert-manager-module/09-05-PLAN.md @@ -0,0 +1,178 @@ +--- +phase: 09-cert-manager-module +plan: 05 +type: execute +wave: 4 +depends_on: [09-04] +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/ConvertTab.tsx + - apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx +autonomous: true +requirements: [CERT-04] + +must_haves: + truths: + - "A user uploads a certificate in any supported format and downloads it converted to a chosen target format (PEM, DER, P7B)" + - "A PEM -> DER -> PEM round trip yields a byte-identical certificate" + - "The Convert tab offers a target-format selector and a download button for the converted file" + artifacts: + - "CertManagerService.convertCert implemented (any input -> PEM/DER/P7B target)" + - "POST /modules/cert-manager/convert wired to convertCert" + - "ConvertTab.tsx renders format selector + download" + key_links: + - "ConvertTab -> convertCertAction -> POST /modules/cert-manager/convert -> CertManagerService.convertCert" + - "convertCert returns { filename, content(base64), mimeType } -> downloadBase64" +--- + + +Vertical slice: convert a certificate between formats. Implement CertManagerService.convertCert (parse any supported input, re-serialize to the chosen target), wire POST /convert, and build the Convert tab with a target-format selector and download. + +MVP: after this plan a user can upload a cert and download it in a different format — a complete capability (CERT-04). (PFX output as a convert target is delivered together with the PFX-create logic in Plan 06; this plan covers PEM/DER/P7B targets.) + +Purpose: Delivers CERT-04 for PEM/DER/P7B round-trips, reusing parse helpers + base64-download. +Output: Working Convert tab end-to-end + tested convertCert service. + + + +@$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-04-SUMMARY.md + + + +## Artifacts this plan produces + +- Implemented method: `CertManagerService.convertCert({ file?, pemText?, targetFormat, password? }): FileResponse` +- New TS interface: `FileResponse` ({ filename, content(base64), mimeType }) per RESEARCH Convert/Merge Response Shape +- Target-format -> mimeType map (pem: application/x-pem-file, der: application/x-x509-ca-cert, p7b: application/x-pkcs7-certificates) +- Wired route: `POST /modules/cert-manager/convert` (FileInterceptor('file') + @Body targetFormat/password) +- New action: `convertCertAction(input, targetFormat)` in actions.ts +- Implemented component: `ConvertTab` (format selector + convert button + download) + + + + + + Task 1: RED — failing convertCert spec + 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 (convertCert stub + parse helpers reused from parseCert) + - .planning/phases/09-cert-manager-module/09-RESEARCH.md (Pattern 5 certificateToPem / certificateToAsn1 -> toDer; Convert Response Shape; Pitfall 1 binary encoding) + + + - Test: convertCert({ pemText: , targetFormat: 'der' }) returns FileResponse with mimeType application/x-x509-ca-cert and content that base64-decodes to DER bytes which, re-parsed, equal the original cert (round-trip). + - Test: convertCert({ file: { originalname:'c.der', buffer: }, targetFormat: 'pem' }) returns a PEM whose parsed cert subject.cn equals the original (DER->PEM round trip identical). + - Test: convertCert({ pemText: , targetFormat: 'p7b' }) returns a P7B whose enclosed cert count is 1. + - Test: convertCert({ pemText: 'garbage', targetFormat: 'der' }) throws BadRequestException. + + + Add the Behavior tests to cert-manager.service.spec.ts, building DER fixtures from the self-signed cert. Assert round-trip identity by re-parsing the converted output and comparing the DER bytes (or subject + fingerprint). Confirm RED against the convertCert stub. Do not implement convertCert here. + + + pnpm --filter @tessera/api test cert-manager --run 2>&1 | grep -Eiq 'fail|NotImplemented|✗|×' && echo RED_CONFIRMED + + + - convertCert tests exist for PEM->DER, DER->PEM (identity), PEM->P7B, and malformed input + - Suite shows convertCert tests failing while all prior tests pass + + Failing convertCert spec committed (RED) including a round-trip identity assertion. + + + + Task 2: GREEN — implement convertCert + wire POST /convert + 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, detectFormat, toForgeBuffer) + - apps/api/src/cert-manager/cert-manager.controller.ts (convert route stub) + - apps/api/src/cert-manager/cert-manager.service.spec.ts (RED contract) + - .planning/phases/09-cert-manager-module/09-RESEARCH.md (Pattern 5 serialization: certificateToPem, certificateToAsn1 -> asn1.toDer -> bytesToHex -> Buffer; pkcs7 create for P7B) + + + Implement CertManagerService.convertCert: parse the input to a forge cert reusing the same input-resolution logic as parseCert (extract a shared private helper if helpful). Serialize to targetFormat: 'pem' via certificateToPem; 'der' via asn1.toDer(certificateToAsn1(cert)).getBytes() -> Buffer.from(bytesToHex, 'hex'); 'p7b' via forge.pkcs7.createSignedData / addCertificate then messageToPem (or asn1 -> DER). Build FileResponse: filename `converted.${targetFormat}`, content = base64 of the output bytes (for PEM/P7B text use Buffer.from(str,'utf-8').toString('base64'); for DER use derBuffer.toString('base64')), mimeType from the target->mime map. Reject an unsupported targetFormat and wrap all forge calls in try/catch -> BadRequestException. Never log password. In the controller, POST convert uses FileInterceptor('file', { limits: { fileSize: 5*1024*1024 } }), reads @Body('targetFormat') and @Body('password'), rejects when neither file nor pemText present. Run suite to GREEN. + + + pnpm --filter @tessera/api test cert-manager --run + + + - `pnpm --filter @tessera/api test cert-manager --run` exits 0 with convertCert tests passing including the round-trip identity test + - `grep -q "converted." apps/api/src/cert-manager/cert-manager.service.ts` (filename built) and DER path uses toString('base64') on a Buffer, not 'utf-8' + - `pnpm --filter @tessera/api type-check` exits 0 + + convertCert converts between PEM/DER/P7B with byte-identical round trips and 400 on malformed input; POST /convert wired; API tests green. + + + + Task 3: Convert tab UI + action + render test + apps/web/src/app/(portal)/modules/cert-manager/actions.ts, apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx + + - apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx (empty-state stub) + - apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx (result/loading/error pattern) + - 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 (Konvertieren = format selector + single download button; empty state 'Keine Datei geladen.' + 'waehle ein Ausgabeformat') + + + Add convertCertAction(input, targetFormat) to actions.ts: build FormData with file (or send JSON with pemText) plus targetFormat and optional password; call postForm('convert', form); return FileResponse. + Implement ConvertTab: accept { file, pemText, password }. Render a target-format selector (native select) offering pem, der, p7b (labels localized; PFX intentionally not offered here — added in Plan 06). Primary 'Konvertieren' button (t('actions.convert'), loading label swap). On success call downloadBase64(filename, content, mimeType) from the FileResponse. Empty state t('emptyState.convert'); localized error in text-destructive on failure. + Extend cert-manager.test.tsx: assert the format selector renders pem/der/p7b options; mock convertCertAction to resolve a FileResponse and assert downloadBase64 is invoked after clicking Konvertieren. + + + pnpm --filter @tessera/web test cert-manager --run + + + - `grep -q "convertCertAction" apps/web/src/app/(portal)/modules/cert-manager/actions.ts` + - ConvertTab format selector renders pem, der, p7b options + - `pnpm --filter @tessera/web test cert-manager --run` exits 0 including the new ConvertTab tests + - `pnpm --filter @tessera/web type-check` exits 0 + + Convert tab converts and downloads end-to-end for PEM/DER/P7B; web tests green. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| client -> API /convert | Untrusted cert bytes enter node-forge parse + re-serialize | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-09-01 | Tampering | convertCert (node-forge) | medium | mitigate | try/catch around parse + serialize -> BadRequestException; unsupported targetFormat rejected as 400 | +| T-09-06 | Tampering | binary encoding on DER output | medium | mitigate | DER built via bytesToHex -> Buffer.from(hex) -> base64; never utf-8 round-trip (Pitfall 1) | +| T-09-03 | Denial of Service | POST /convert upload | high | mitigate | FileInterceptor `limits.fileSize` = 5 MB | +| T-09-04 | Elevation of Privilege | POST /convert | high | mitigate | Global JwtAuthGuard + `@UseModule('cert-manager')` | + + + +- `pnpm --filter @tessera/api test cert-manager --run` — convertCert green incl. round-trip identity +- `pnpm --filter @tessera/web test cert-manager --run` — ConvertTab green +- type-checks clean +- Manual (phase gate): convert a real PEM to DER, re-upload the DER to Inspect, confirm identical cert + + + +- convertCert converts PEM/DER/P7B with byte-identical round trips (CERT-04) +- Convert tab works end-to-end +- All tests green; type-checks clean + + + +Create `.planning/phases/09-cert-manager-module/09-05-SUMMARY.md` when done + diff --git a/.planning/phases/09-cert-manager-module/09-06-PLAN.md b/.planning/phases/09-cert-manager-module/09-06-PLAN.md new file mode 100644 index 0000000..6859dec --- /dev/null +++ b/.planning/phases/09-cert-manager-module/09-06-PLAN.md @@ -0,0 +1,189 @@ +--- +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-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 +