76a30458fc
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
626 lines
168 KiB
Markdown
626 lines
168 KiB
Markdown
---
|
||
phase: quick-261009-ikt
|
||
plan: 01
|
||
type: execute
|
||
wave: 1
|
||
depends_on: []
|
||
quick_id: 261009-ikt
|
||
description: "Zertifikat-Manager (cert-manager) Umbau: ein Reiter „Dateien“ als gemeinsamer Arbeitsbereich (mehrere Dateien, ZIPs, eingefuegter PEM-Text), alle anderen Reiter arbeiten darauf; Fehler „zweite Datei ueberschreibt die erste“ behoben; Kettenbildung und Fullchain (Root waehlbar, Vorgabe ohne); alle gaengigen Formate rein und raus inkl. EC; Vorlagen fuer Zielsysteme; „Fehlendes Zertifikat holen“ (AIA, nur auf Knopfdruck, SSRF-sicher, gehaerteter gemeinsamer Adressschutz); Modulversion 1.2.0, Modul-Changelog, CHANGELOG und Anleitungen"
|
||
date: 2026-10-09
|
||
files_modified:
|
||
# Every path once; the comment names the tasks (T1–T8) that change it. Each task commits only its own files.
|
||
- apps/api/src/cert-manager/__fixtures__/ # T1: make-fixtures.sh, README.md and the generated PEM/DER/P7B/P7C/PFX/CSR/ZIP files
|
||
- apps/api/src/cert-manager/cert-types.ts # T1, T4, T6
|
||
- apps/api/src/cert-manager/cert-model.ts # T1, T3, T4
|
||
- apps/api/src/cert-manager/cert-model.spec.ts # T1, T3, T4
|
||
- apps/api/src/cert-manager/cert-output.ts # T1, T2, T5, T6
|
||
- apps/api/src/cert-manager/cert-output.spec.ts # T1, T2, T5, T6
|
||
- apps/api/src/cert-manager/cert-analyze.ts # T1, T2, T3, T4
|
||
- apps/api/src/cert-manager/cert-analyze.spec.ts # T1, T2, T3, T4
|
||
- apps/api/src/cert-manager/cert-manager.controller.ts # T1, T2, T4, T7
|
||
- apps/api/src/cert-manager/cert-manager.controller.spec.ts # T1, T2, T4, T5, T7
|
||
- apps/api/src/cert-manager/cert-manager.module.ts # T1
|
||
- apps/api/src/cert-manager/cert-bundle.ts # T1 deleted
|
||
- apps/api/src/cert-manager/cert-bundle.spec.ts # T1 deleted
|
||
- apps/api/src/cert-manager/cert-manager.service.ts # T1 deleted
|
||
- apps/api/src/cert-manager/cert-manager.service.spec.ts # T1 deleted
|
||
- apps/api/src/cert-manager/dto/convert-cert.dto.ts # T1 deleted
|
||
- apps/api/src/cert-manager/dto/merge-certs.dto.ts # T1 deleted
|
||
- apps/api/src/cert-manager/dto/parse-cert.dto.ts # T1 deleted
|
||
- apps/api/src/cert-manager/cert-chain.ts # T2, T4
|
||
- apps/api/src/cert-manager/cert-chain.spec.ts # T2, T4
|
||
- apps/api/src/cert-manager/dto/cert-build.dto.ts # T2, T5, T6
|
||
- apps/api/src/cert-manager/cert-json-body.ts # T2 (D-26)
|
||
- apps/api/src/cert-manager/cert-json-body.spec.ts # T2 (D-26)
|
||
- apps/api/src/main.ts # T2 (D-26 registration)
|
||
- apps/api/src/cert-manager/zip-expand.ts # T3
|
||
- apps/api/src/cert-manager/zip-expand.spec.ts # T3
|
||
- apps/api/src/cert-manager/cert-keys.ts # T4, T5
|
||
- apps/api/src/cert-manager/cert-keys.spec.ts # T4, T5
|
||
- apps/api/src/cert-manager/cert-pkcs12.ts # T4, T5
|
||
- apps/api/src/cert-manager/cert-pkcs12.spec.ts # T4, T5
|
||
- apps/api/src/cert-manager/cert-csr.ts # T4
|
||
- apps/api/src/cert-manager/cert-csr.spec.ts # T4
|
||
- apps/api/src/cert-manager/cert-templates.ts # T6
|
||
- apps/api/src/cert-manager/cert-templates.spec.ts # T6
|
||
- apps/api/src/cert-manager/cert-manager.changelog.ts # T6, T7
|
||
- apps/api/src/common/public-url-guard.ts # T7
|
||
- apps/api/src/common/public-url-guard.spec.ts # T7
|
||
- apps/api/src/cert-manager/cert-aia.ts # T7
|
||
- apps/api/src/cert-manager/cert-aia.spec.ts # T7
|
||
- apps/api/src/cert-manager/dto/cert-fetch-issuer.dto.ts # T7
|
||
- apps/web/src/app/(portal)/modules/cert-manager/actions.ts # T1, T2, T5, T6, T7
|
||
- apps/web/src/app/(portal)/modules/cert-manager/working-set.ts # T1, T3, T4, T7
|
||
- apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts # T1, T3, T4, T7
|
||
- apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts # T1, T3, T4, T7
|
||
- apps/web/src/app/(portal)/modules/cert-manager/page.tsx # T1, T2, T3, T5, T6
|
||
- apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx # T1, T2, T3, T5, T6
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx # T1, T3, T4, T7
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx # T1, T3, T4, T7
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx # T1 deleted
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx # T1 deleted
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.tsx # T1 deleted
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.test.tsx # T1 deleted
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/PasswordField.tsx # T1 deleted
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx # T1 deleted (old), T2 new, T5, T7
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx # T1 deleted (old), T2 new, T5
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.tsx # T2, T7
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.test.tsx # T7
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/EmptyWorkspace.tsx # T2
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx # T1 deleted (old), T3 new
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.test.tsx # T3
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/ItemCard.tsx # T3, T4, T7
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx # T3, T4, T7
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx # T3, T4
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/PasswordInput.tsx # T4
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx # T1 deleted (old), T5 new
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.test.tsx # T5
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/PfxOptions.tsx # T5
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/TemplatesTab.tsx # T6
|
||
- apps/web/src/app/(portal)/modules/cert-manager/components/TemplatesTab.test.tsx # T6
|
||
- apps/web/src/messages/de.json # T1–T7 (T8 only for review fixes)
|
||
- apps/web/src/messages/en.json # T1–T7 (T8 only for review fixes)
|
||
- apps/web/src/messages/umlaut-dictionary.ts # T1–T7 as the guard asks
|
||
- .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh # T1–T7 (one section each)
|
||
- .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs # T1
|
||
- CHANGELOG.md # T6, T7
|
||
- docs/anleitung-anwender.md # T6, T7 (T8 only label corrections)
|
||
- docs/anleitung-betrieb.md # T6, T7
|
||
- docs/anleitung-entwicklung.md # T6, T7
|
||
- .planning/todos/pending/2026-10-09-cert-manager-mehrere-dateien-zip-fullchain.md # T8 moved to .planning/todos/completed/
|
||
autonomous: true
|
||
requirements: [QUICK-261009-ikt]
|
||
|
||
estimate: # whole plan; each task runs in its own executor at about 60 000–80 000 tokens incl. the plan itself (Task 8 mostly browser work)
|
||
tokens: 600000
|
||
raw_tokens: 600000
|
||
tasks: 8
|
||
confidence: low
|
||
|
||
must_haves:
|
||
truths:
|
||
- "In the first tab „Dateien“ a user selects or drops several files one after another and a vendor ZIP; every file stays in the list (a second file never replaces the first), each row shows what Tessera recognised in it (for a ZIP per contained path, junk such as __MACOSX skipped, nested or password-protected ZIP entries named with the reason), single files are removed with their own button, PEM text can be pasted as an extra entry, and the tab says that the list exists only in this browser window and is gone after a reload"
|
||
- "Analysieren, Aufteilen, Zusammenführen, Konvertieren and Vorlagen have no upload field of their own; they all work on the same working set, and with an empty set they point to the „Dateien“ tab"
|
||
- "„Zusammenführen“ orders server certificate, intermediates and root by itself (issuer check plus real signature check, so a same-name CA with another key is never taken, cross-signed and expired variants are resolved deterministically) and downloads Fullchain or Nur Kette; the root goes in only when „Root-Zertifikat mitnehmen“ is ticked (off by default); a missing issuer is reported as „Zwischenzertifikat fehlt“ (after the server certificate) or as a calm note that the certificate above, usually the root, is missing; the API re-builds the order itself and never trusts the order sent by the browser"
|
||
- "RSA AND EC are recognised in every common input format: certificates as PEM/CRT/CER/CA-bundle and DER, PKCS#7 as PEM and DER, PKCS#12 (OpenSSL-3 default, compatible 3DES and legacy RC2, password per file, also inside a ZIP, also without the .pfx extension), private keys PKCS#1/PKCS#8/SEC1 in PEM and DER, unencrypted, encrypted PKCS#8 and traditionally encrypted, CSR in PEM and DER; keys and CSRs are matched to their certificates and a locked file asks for its password"
|
||
- "Outputs: single certificate (PEM, DER, PKCS#7 PEM .p7b, PKCS#7 DER .p7c), Fullchain and Nur Kette (PEM, .p7b, .p7c), Zertifikat und Schlüssel in one PEM file, PFX with a chosen password as „Kompatibel (auch ältere Windows-Server)“ = 3DES/SHA-1 (default) or „Modern (AES-256)“, key as PKCS#8, traditional (PKCS#1 for RSA, SEC1 for EC) or DER with optional password, CSR as PEM or DER; openssl reads every output in the e2e (verify, pkcs12 -info with the expected algorithm, pkcs7 -print_certs, pkey match)"
|
||
- "„Vorlagen“ delivers with one click the files and a configuration snippet for Nginx, Apache 2.4.8 and newer, Apache older than 2.4.8, Windows/IIS (PFX, compatible encryption preselected), Nginx Proxy Manager (certificate, intermediate, key), HAProxy (one combined PEM) and Tomcat/Java (PKCS#12); without the matching private key the templates explain why they are unavailable"
|
||
- "„Fehlendes Zertifikat holen“ appears only where an issuer is missing and the certificate names an http(s) caIssuers address; only the click makes the API fetch it, the address comes from the certificate on the server (never from the browser), only public addresses on ports 80/443 are contacted (shared guard hardened for hex IPv4-mapped, NAT64, 6to4 and further IPv6 forms, re-checked on every redirect and at connect time), 8 s and 256 KiB caps, and only a certificate that really issued the incomplete one is accepted and added as entry „nachgeladen von {host}“ — proven live with the letsencrypt.org certificate"
|
||
- "Private keys and passwords are never stored or logged; the old routes parse, split, merge, convert and export, the old service and forge's RSA-only certificate parsing are gone; module version 1.2.0 (new entry 2026-10-09) with module changelog, CHANGELOG (incl. the guard security fix), Anwender-, Betriebs- and Entwicklungsanleitung updated; screenshots in dark mode (and some in light mode) prove the flow"
|
||
- "Every `build` request within the DTO caps is parsed and answered with a result or an error that carries a code; a body over 512 KiB gets 413 with code tooLarge; every other route keeps the 100 kB JSON limit and Nest's global JSON parser stays active (D-26)"
|
||
- "After each of the eight tasks the module is usable on its own: only the tabs built so far are shown, no web code calls a missing route, and every live e2e section built so far passes on the rebuilt stack"
|
||
artifacts:
|
||
- path: "apps/api/src/cert-manager/cert-types.ts"
|
||
provides: "the analyze and build contract shared by all tasks (items, chains, locked, ignored, error codes)"
|
||
exports: ["CertItem", "KeyItem", "CsrItem", "AnalysisResult", "ChainInfo", "BuildInput", "BuildResult", "CertErrorCode"]
|
||
- path: "apps/api/src/cert-manager/zip-expand.ts"
|
||
provides: "ZIP detection by magic bytes and expansion with entry, size, ratio, total, nesting and encryption limits"
|
||
exports: ["expandZip", "isZip", "ZIP_LIMITS"]
|
||
- path: "apps/api/src/cert-manager/cert-model.ts"
|
||
provides: "the one parser: blob → certificates with role and selfSigned (node:crypto, Task 1), ZIP and PKCS#7 via ASN.1 walk (Task 3), keys, PKCS#12 and CSR detectors (Task 4), locked and ignored reports"
|
||
exports: ["detectBlob", "certItemFromDer"]
|
||
- path: "apps/api/src/cert-manager/cert-chain.ts"
|
||
provides: "chain building with checkIssued plus verify, ranking and gaps (Task 2), key and CSR matching (Task 4)"
|
||
exports: ["buildChains", "matchKeys"]
|
||
- path: "apps/api/src/cert-manager/cert-output.ts"
|
||
provides: "build(): every content × format, file names, re-validated order"
|
||
exports: ["buildOutput", "safeBaseName"]
|
||
- path: "apps/api/src/cert-manager/cert-pkcs12.ts"
|
||
provides: "PFX read (RSA and EC bags) and write (compat 3DES / modern AES-256) via a scoped, restored forge patch"
|
||
exports: ["readPkcs12", "writePkcs12"]
|
||
- path: "apps/api/src/cert-manager/cert-aia.ts"
|
||
provides: "button-only issuer fetch with SSRF guard per hop, guarded connect lookup, caps, issuer verification"
|
||
exports: ["fetchIssuer", "createGuardedLookup"]
|
||
- path: "apps/api/src/cert-manager/cert-templates.ts"
|
||
provides: "template id → files + config snippet"
|
||
exports: ["buildTemplate", "TEMPLATE_IDS"]
|
||
- path: "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts"
|
||
provides: "browser-only working set (entries, passwords, fetched entries) and re-analysis of the whole set with stale-response guard"
|
||
exports: ["useCertWorkspace"]
|
||
- path: "apps/api/src/cert-manager/cert-manager.changelog.ts"
|
||
provides: "module changelog with the new top entry 1.2.0 dated 2026-10-09"
|
||
contains: "version: '1.2.0'"
|
||
- path: ".planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh"
|
||
provides: "live e2e against the local API with openssl round trips: sections files, fullchain, zip, inputs, formats, templates, version, aia and all (every section built so far)"
|
||
- path: ".planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs"
|
||
provides: "message gate used by every task: de/en key parity, minimum key count, title present, no tenant/licence words, no arrow or middle-dot characters"
|
||
- path: "apps/api/src/cert-manager/cert-json-body.ts"
|
||
provides: "own JSON parser for POST build (512 KiB) and the coded 413/400 mapper (D-26)"
|
||
exports: ["CERT_BUILD_ROUTE", "CERT_BUILD_JSON_LIMIT", "certBuildJsonBody", "certBuildBodyErrors"]
|
||
key_links:
|
||
- from: "apps/api/src/main.ts"
|
||
to: "apps/api/src/cert-manager/cert-json-body.ts certBuildJsonBody + certBuildBodyErrors"
|
||
via: "app.use(CERT_BUILD_ROUTE, …) before app.listen, i.e. before Nest registers its global parsers in init()"
|
||
pattern: "certBuildJsonBody"
|
||
- from: "apps/api/src/cert-manager/cert-manager.controller.ts analyze"
|
||
to: "apps/api/src/cert-manager/cert-analyze.ts analyzeWorkingSet"
|
||
via: "multipart files (≤ 30 × 5 MiB, total ≤ 20 MiB) plus optional passwords array"
|
||
pattern: "analyzeWorkingSet\\("
|
||
- from: "apps/api/src/cert-manager/cert-model.ts detectBlob (ZIP slot)"
|
||
to: "apps/api/src/cert-manager/zip-expand.ts expandZip"
|
||
via: "every blob whose first bytes are a ZIP signature, limits checked before any entry is inflated"
|
||
pattern: "expandZip\\("
|
||
- from: "apps/api/src/cert-manager/cert-chain.ts buildChains"
|
||
to: "node:crypto X509Certificate checkIssued + verify"
|
||
via: "issuer candidates must pass both, self-signed = both against itself"
|
||
pattern: "checkIssued\\("
|
||
- from: "apps/api/src/cert-manager/cert-output.ts buildOutput"
|
||
to: "apps/api/src/cert-manager/cert-chain.ts buildChains"
|
||
via: "order of Fullchain, chain, bundle, PFX and templates is rebuilt from the sent certificates on every build"
|
||
pattern: "buildChains\\("
|
||
- from: "apps/api/src/cert-manager/cert-aia.ts fetchIssuer"
|
||
to: "apps/api/src/common/public-url-guard.ts isPublicHttpUrl + isPrivateIpAddress"
|
||
via: "guard before the first request and every redirect hop, guarded lookup in the undici dispatcher"
|
||
pattern: "isPublicHttpUrl"
|
||
- from: "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts"
|
||
to: "analyzeWorkingSet action → POST /modules/cert-manager/analyze"
|
||
via: "the whole entry list in order after every add, remove, password change or fetch; older answers dropped"
|
||
pattern: "analyzeWorkingSet\\("
|
||
- from: "apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.tsx gap row"
|
||
to: "fetchIssuer action → POST /modules/cert-manager/fetch-issuer"
|
||
via: "only on click of „Fehlendes Zertifikat holen“, body contains only the PEM of the incomplete certificate"
|
||
pattern: "fetchIssuer\\("
|
||
- from: "apps/api/src/cert-manager/cert-manager.seed.ts"
|
||
to: "CERT_MANAGER_CHANGELOG"
|
||
via: "latestVersion yields 1.2.0 (no version string in the seed)"
|
||
pattern: "latestVersion\\(CERT_MANAGER_CHANGELOG\\)"
|
||
---
|
||
|
||
<objective>
|
||
Module „Zertifikat-Manager“ (slug `cert-manager`, api `apps/api/src/cert-manager/`, web `apps/web/src/app/(portal)/modules/cert-manager/`) is rebuilt around ONE shared working set: the first tab „Dateien“ takes several files, ZIPs from a certificate vendor and pasted PEM text; every other tab works on that set. Tessera builds the chain itself and delivers Fullchain, chain only, single certificate, certificate plus key, PFX, every key and CSR format and one-click templates for common target systems — for RSA and EC. A missing intermediate can be fetched on button press from the certificate's AIA address, SSRF-safe.
|
||
|
||
Purpose: user test 09.10. (`.planning/todos/pending/2026-10-09-cert-manager-mehrere-dateien-zip-fullchain.md`): merging accepted only one file (a second selection replaced the first), ZIPs had to be handled by hand, and there was no „give me the Fullchain“ choice. Research found the bigger gap: node-forge cannot read any EC certificate, so every ECDSA certificate (Let's Encrypt default, most modern CAs) failed or was silently dropped.
|
||
|
||
Eight tasks, strictly in order, each run by its own fresh executor, each ending with a green verify chain and exactly one commit, and each leaving a module that is usable on its own (only the tabs built so far are shown; no web code calls a missing route). Task 1 is the tracer: several files into one working set, one node:crypto parser for RSA and EC certificates, every certificate shown with its role per file — through every layer, proven live. Each further task adds one capability on top: Task 2 Zusammenführen (chain building, Fullchain and Nur Kette, root checkbox, own body limit for `build`), Task 3 vendor ZIP, PKCS#7, pasted text, Analysieren and Aufteilen, Task 4 keys, PFX and CSR with a password per file, Task 5 every output format with Konvertieren and the complete Zusammenführen, Task 6 templates plus version 1.2.0, changelogs and guides, Task 7 „Fehlendes Zertifikat holen“ with the hardened guard, Task 8 the full gates and the browser proof.
|
||
|
||
Locked decisions — from CONTEXT.md (user, NON-NEGOTIABLE):
|
||
- D-01 ONE upload tab, the first one („Dateien“): select or drop several files and/or ZIPs, paste PEM text. Everything forms a shared working set (list with remove buttons and what was recognised in each). Analysieren, Aufteilen, Zusammenführen, Konvertieren and Vorlagen work on that set and have no upload field of their own.
|
||
- D-02 Root certificate in Fullchain: selectable, default WITHOUT root, checkbox „Root-Zertifikat mitnehmen“.
|
||
- D-03 Missing intermediate: Tessera reports the gap clearly („Zwischenzertifikat fehlt“) and offers „Fehlendes Zertifikat holen“, which fetches the issuer from the certificate's AIA caIssuers URL — ONLY on button press, never automatically. SSRF-safe: http/https only, public addresses only via the project's `isPublicHttpUrl` guard pattern, size and time limits, no redirects to private targets; the result is marked „nachgeladen“.
|
||
- D-04 One-click templates for target systems: Nginx, Apache, Windows/IIS (PFX), Nginx Proxy Manager, plus (Claude's choice) HAProxy combined PEM and Tomcat/Java as PKCS#12. Java keystore (JKS) is skipped because it needs native tools. Free choice of content and format stays available (Konvertieren, Zusammenführen).
|
||
- D-05 All common formats as input AND output: PEM/CRT/CER (Base64), DER, PKCS#7 (.p7b/.p7c), PKCS#12 (.pfx/.p12 with password), private keys (PKCS#1, PKCS#8, encrypted/unencrypted, RSA and EC), CSR; outputs Fullchain, chain only (intermediates), single certificate, certificate plus key (PEM bundle), PFX with a chosen password. Chain building via issuer/subject plus key identifiers (here: OpenSSL's `checkIssued`, which compares names, AKI/SKI and key usage, plus the real signature check), clear gap messages, verify that a private key matches its certificate.
|
||
- D-06 The bug „second file overwrites the first“ disappears structurally (one append-only working set). The existing analysis behaviour (what each part is, validity, what belongs together, calm note for an unneeded locked PFX) is kept and its gaps (EC, keys, CSR, PFX bags) are closed.
|
||
|
||
Locked decisions — orchestrator answers to the research's open questions (NON-NEGOTIABLE):
|
||
- D-07 PFX encryption is user-selectable: default „Kompatibel (auch ältere Windows-Server)“ = forge `algorithm: '3des'` (pbeWithSHA1And3-KeyTripleDES-CBC, HMAC-SHA1 MAC); option „Modern (AES-256)“ = forge `algorithm: 'aes256'` (PBES2/PBKDF2/AES-256-CBC key bag; forge keeps the SHA-1 MAC). The IIS template (and the Tomcat template) preselect the compatible profile.
|
||
- D-08 The old routes parse, split, merge, convert are removed together with `cert-manager.service.ts`, its spec and the three old DTOs; the old export route is replaced by the new `build`, `analyze` keeps its name with the new contract. ONE parser only: node:crypto (`X509Certificate`, `createPrivateKey`, `createPublicKey`, `KeyObject.export`) decides everything about certificates and keys; node-forge is used only for PKCS#12 and as a generic ASN.1 reader/writer (PKCS#7 walk and build, CSR walk). No production code calls forge's certificate, CSR or PKCS#7-message parsers (they are RSA-only).
|
||
- D-09 Module version 1.2.0: a NEW top entry dated 2026-10-09 in `cert-manager.changelog.ts` (1.1.0 from 2026-10-02 is released — latest Tessera release 1.10.1 on 2026-10-06). Root `CHANGELOG.md` bullets go under „## Unveröffentlicht“.
|
||
- D-10 Harden the shared SSRF guard `apps/api/src/common/public-url-guard.ts` (`isPrivateIpv6`): IPv4-mapped addresses in hex form (`::ffff:7f00:1`), NAT64 `64:ff9b::/96`, 6to4 `2002::/16` embedding a private IPv4 — with a new spec; mention it in CHANGELOG as a security fix.
|
||
- D-11 Private keys and passwords are never persisted and never logged; the working set lives in browser memory only and is lost on reload — the „Dateien“ tab and the user guide say so.
|
||
- D-12 Docs are mandatory: Anwenderanleitung section rewritten for the new flow incl. templates and the AIA button; Betriebsanleitung notes (upload limits through Nginx Proxy Manager, outbound HTTP for AIA); Entwicklungsanleitung module paragraph. App texts German with „Sie“, de AND en messages.
|
||
- D-13 Proof: spec fixtures for RSA and EC (certificate, chains incl. cross-signed and same-name CA, encrypted PKCS#8, traditionally encrypted key, DER key, CSR, PFX in both output encryptions and as input in OpenSSL-3/compat/legacy form, P7B, vendor-like ZIP), openssl round trips in the e2e script, and a Playwright browser check (dark mode first, a few light) of the upload tab with ZIP and several files, Fullchain output, a template download and the missing-intermediate button.
|
||
|
||
Claude's discretion (decided here, apply as written):
|
||
- D-14 API surface: `@Controller('modules/cert-manager')` with class-level `@UseModule('cert-manager')` (Benutzen level, global JwtAuthGuard and TenantGuard as before), exactly three POST routes, each `@HttpCode(200)`: `analyze` (multipart, Task 1, passwords from Task 4), `build` (JSON, Task 2, extended in Tasks 5 and 6), `fetch-issuer` (JSON, Task 7). All stateless, nothing stored. No new npm package (node:crypto of Node 24, node-forge 1.4.0, adm-zip 0.6.0, undici 7 and fflate are already installed; no install step, no package checkpoint).
|
||
- D-15 Analyze contract (in `cert-types.ts`, mirrored 1:1 in web `actions.ts`): `AnalysisResult { items: AnyItem[]; chains: ChainInfo[]; locked: LockedEntry[]; ignored: IgnoredEntry[] }`. `ItemSource { file: number (index of the uploaded file in request order); path: string (file name, or "zipname/entry/path" inside a ZIP; control characters removed, max 255) }`. `CertItem { id ('c-' + first 16 lowercase hex chars of sha256(DER)); kind 'certificate'; role 'end-entity' | 'intermediate' | 'root'; sources; pem (canonical PEM of the DER); baseName; cn; organization; issuerCn; issuerOrganization; notBefore; notAfter (ISO); isExpired; daysLeft; san: string[] (DNS names plain, others with prefix like 'IP:'); keyType ('RSA' | 'EC' | 'ED25519' | other upper-case name); keyBits: number | null; curve ('P-256' | 'P-384' | 'P-521' | raw name | null); serialNumber (upper-case hex); sha256 and sha1 (colon-separated upper-case hex, as Node prints); isCa; selfSigned; aiaIssuerUrls: string[] (http/https only, max 5); keyId: string | null; csrIds: string[] }`. `KeyItem { id ('k-' + 16 hex of sha256(SPKI DER)); kind 'privateKey'; sources; pem (unencrypted PKCS#8 PEM); baseName; keyType; keyBits; curve; wasEncrypted; certIds: string[] }`. `CsrItem { id ('r-' + 16 hex of sha256(DER)); kind 'csr'; sources; pem; baseName; cn; organization; san; keyType; keyBits; curve; keyId: string | null; certIds: string[] }`. `ChainInfo { headId; path: string[] (head first, then each issuer, root last when present); rootId: string | null; complete: boolean; gap: { certId; kind: 'afterLeaf' | 'afterCa'; missingIssuerCn; aiaUrls: string[] } | null; alternatives: number }`. `LockedEntry { file; path; container: 'pkcs12' | 'privateKey'; reason: 'passwordNeeded' | 'passwordWrong' }`. `IgnoredEntry { file; path; reason: 'unknown' | 'nestedZip' | 'encryptedZip' | 'brokenZip' | 'tooLarge' | 'suspicious' | 'zipTooLarge' | 'tooManyEntries' | 'unsupportedKey' }`. Items are deduplicated by id (sources merged), ordered certificates (end-entity, intermediate, root; then by cn, then notAfter descending), keys, CSRs. Chain heads: every end-entity certificate; when the set has none, every certificate that issued no other certificate of the set. Fallback base names as today: 'zertifikat' (end-entity), 'ca', 'schluessel', 'anfrage'; `safeBaseName` keeps its current rules (`*.` → `wildcard.`, other characters → `_`, max 80).
|
||
- D-16 Detection (one pipeline in `cert-model.ts`, every detector wrapped in try/catch, a bad blob never fails the request): ZIP by magic bytes `PK\x03\x04` / `PK\x05\x06` (not by extension) → `zip-expand.ts`; text with `-----BEGIN` (BOM and CRLF tolerated, text around blocks ignored) → each block by label: CERTIFICATE / X509 CERTIFICATE, TRUSTED CERTIFICATE (leading certificate SEQUENCE only), PKCS7 / CMS, PRIVATE KEY / RSA PRIVATE KEY / EC PRIVATE KEY / ENCRYPTED PRIVATE KEY (incl. `Proc-Type: 4,ENCRYPTED`), CERTIFICATE REQUEST / NEW CERTIFICATE REQUEST; otherwise DER in this order: X.509 certificate → PKCS#12 (top-level SEQUENCE starting with INTEGER 3, tried with the passwords) → PKCS#7 signedData (OID 1.2.840.113549.1.7.2) → private key (pkcs8, pkcs1, sec1, then encrypted pkcs8 with the passwords) → CSR → `ignored: unknown`. PKCS#7 certificates are read by walking the ASN.1 with `forge.asn1.fromDer` (ContentInfo → [0] SignedData → certificates [0] SET) and handing each certificate's DER to `X509Certificate`. Task 1 implements the certificate stages, Task 3 ZIP and PKCS#7, Task 4 keys, PKCS#12 and CSR — all into the same pipeline.
|
||
- D-17 Limits: multer `FilesInterceptor('files', 30, { limits: { fileSize: 5 MiB } })`; the controller rejects a request whose files together exceed 20 MiB with 413 `tooLarge`; the web refuses before upload more than 30 entries, a file over 5 MiB, a set over 10 MiB in total, pasted text over 256 000 characters, and an identical file (same name, size and lastModified) a second time. ZIP (`ZIP_LIMITS`, injectable for specs): one level only (an entry that is itself a ZIP → `nestedZip`), at most 100 non-junk entries (else the whole ZIP → `tooManyEntries`), junk skipped silently (directories, `__MACOSX/`, names starting with `.`, `Thumbs.db`, `desktop.ini`), declared size per entry ≤ 1 MiB (else `tooLarge`), declared size / max(compressed, 1) ≤ 100 (else `suspicious`), encrypted entry (general-purpose flag bit 0) → `encryptedZip`, sum of declared sizes of the kept entries ≤ 20 MiB checked BEFORE any entry is inflated (else the whole ZIP → `zipTooLarge`), unreadable archive → `brokenZip`; entry names are used for display only and never written to disk; adm-zip inflates at most the declared size and checks the CRC.
|
||
- D-18 Chains (`cert-chain.ts`, pure functions; `buildChains` in Task 2, `matchKeys` in Task 4; selfSigned and role below are per-certificate facts that `certItemFromDer` computes from Task 1 on): issuers of C = every other certificate I with `C.checkIssued(I) && C.verify(I.publicKey)`; selfSigned(C) = both against C itself; role = not CA → 'end-entity', CA and selfSigned → 'root', otherwise 'intermediate' (a self-signed non-CA stays end-entity with path [itself], complete true, rootId null, gap null). Path search: depth-first over verified issuers, depth ≤ 10, visited set; ranking of the found paths, in this order: ends at a self-signed certificate of the set first, fewer certificates that are expired or not yet valid, shorter, later notAfter of the first issuer, then sha256 ascending (deterministic); `alternatives` = number of other paths found (cap 10). An incomplete path ends at a certificate whose issuer is absent: `gap.kind` 'afterLeaf' when that certificate is the head end-entity, else 'afterCa', with the issuer CN from the certificate and its caIssuers URLs. Key match: `cert.checkPrivateKey(keyObject)`; CSR match: SPKI DER of the CSR equals the certificate's or the key's SPKI DER. Never match by name or modulus string.
|
||
- D-19 Build contract (`POST build`, JSON, own body limit 512 KiB per D-26, `BuildOutputDto` with class-validator): `{ content, format?, certPem? (≤ 16 384 characters), poolPems? (≤ 20, each ≤ 16 384 characters; an entry that is not a certificate → 400 notACertificate), keyPem? (≤ 16 384), csrPem? (≤ 16 384), includeRoot? (default false), includeChain? (default true, bundle only), password? (≤ 256), pfxEncryption? 'compat' | 'modern' (default compat), template? (Task 6), baseName? (≤ 120) }` → `{ files: [{ filename, content (base64), mimeType }], chainComplete: boolean, missingIssuerCn: string | null, snippet?: string | null }`. The API always rebuilds the order with `buildChains` over certPem plus poolPems and uses the primary chain of certPem (certificates in the pool that do not belong to it are dropped). Matrix and file names (keep today's conventions): leaf — pem `<base>.crt`, der `<base>.cer`, p7b `<base>.p7b` (PEM PKCS#7), p7c `<base>.p7c` (DER PKCS#7); fullchain — pem `<base>-fullchain.pem`, p7b, p7c; chain — pem `<base>-chain.pem`, p7b, p7c (no intermediate and no included root → 400 `noChain`); leafKey — pem `<base>-bundle.pem` (leaf, intermediates, root only with includeRoot, key last as unencrypted PKCS#8; includeChain false → leaf and key only); pfx — `<base>.pfx` (leaf, intermediates, root only with includeRoot, key when given; password required); key — pkcs8 `<base>.key` (PRIVATE KEY or, with password, ENCRYPTED PRIVATE KEY AES-256-CBC), traditional `<base>.rsa.key` / `<base>.ec.key` (PKCS#1 / SEC1, with password AES-256-CBC Proc-Type), pkcs8-der `<base>.key.der` (with password encrypted PKCS#8 DER); traditional for other key types → 400 `formatNotPossible`; csr — pem `<base>.csr`, der `<base>.csr.der`. PKCS#7 output is assembled with `forge.asn1` by hand (ContentInfo signedData, version 1, empty digestAlgorithms and signerInfos, encapContentInfo data, certificates [0] IMPLICIT with each certificate's own ASN.1), never through forge certificate objects. PEM outputs use Node's `X509Certificate#toString()` blocks joined in path order with a trailing newline.
|
||
- D-20 PKCS#12 (`cert-pkcs12.ts`): reading via forge `pkcs12FromAsn1` — try the file's own password, then '' (empty password), then the other passwords of the request (distinct, max 10); certificate bags: DER = `bag.asn1` when present (EC certificates: forge leaves `bag.cert` null) else forge's encoding of `bag.cert`; key bags (pkcs8ShroudedKeyBag and keyBag): `bag.asn1` → `createPrivateKey({ format: 'der', type: 'pkcs8' })` (EC keys: forge leaves `bag.key` false). Writing: the research's Pattern 3 — inside ONE synchronous function temporarily replace `pki.privateKeyToAsn1`, `pki.wrapRsaPrivateKey` and `pki.certificateToAsn1` with pass-through functions, call `forge.pkcs12.toPkcs12Asn1(keyAsn1OrNull, certs, password, { algorithm, friendlyName: baseName, generateLocalKeyId: true })`, restore the three originals in `finally` (the module is single-threaded and the call is synchronous, so no other caller can observe the patch); key DER comes from `KeyObject.export({ type: 'pkcs8', format: 'der' })`, certificates leaf first. A header comment explains why. No hand-rolled PBE or MAC.
|
||
- D-21 Templates (`cert-templates.ts`, Task 6; all need leaf plus matching key, else 400 `templateNeedsKey`; root only with includeRoot; key unencrypted): nginx → ZIP `<base>-nginx.zip` with `fullchain.pem`, `privkey.pem` (PKCS#8), snippet `ssl_certificate /etc/nginx/ssl/<base>/fullchain.pem;` and `ssl_certificate_key /etc/nginx/ssl/<base>/privkey.pem;`; apache (2.4.8 and newer) → `fullchain.pem`, `privkey.pem`, snippet `SSLCertificateFile …/fullchain.pem`, `SSLCertificateKeyFile …/privkey.pem`; apache-legacy (older than 2.4.8) → `cert.pem`, `chain.pem`, `privkey.pem`, snippet with `SSLCertificateFile`, `SSLCertificateKeyFile`, `SSLCertificateChainFile`; iis → single `<base>.pfx` (password required, pfxEncryption default compat), snippet `Import-PfxCertificate -FilePath .\<base>.pfx -CertStoreLocation Cert:\LocalMachine\My -Password (Read-Host -AsSecureString)`; npm (Nginx Proxy Manager, „Custom“ certificate) → `certificate.pem` (leaf only), `intermediate.pem` (intermediates, root only with includeRoot), `privkey.pem` (RSA as PKCS#1 „RSA PRIVATE KEY“, EC as SEC1 „EC PRIVATE KEY“, research A2), snippet null, steps from the web messages; haproxy → single `<base>.pem` = leaf, intermediates, key (PKCS#8), snippet `bind :443 ssl crt /etc/haproxy/certs/<base>.pem`; tomcat → single `<base>.p12` (password required, compat default, friendlyName = base name), snippet `<Certificate certificateKeystoreFile="conf/<base>.p12" certificateKeystorePassword="IHR-PASSWORT" certificateKeystoreType="PKCS12" certificateKeyAlias="<base>" />` (never the real password). The API returns the files and the snippet; the web zips multi-file templates with fflate and adds `ANLEITUNG.txt` (de) / `INSTRUCTIONS.txt` (en) made of the localized steps and the snippet; single-file templates download directly; the snippet is always shown with „Kopieren“.
|
||
- D-22 AIA fetch (`cert-aia.ts`, `POST fetch-issuer` with `FetchIssuerDto { pem: string ≤ 16 384 }` — whitelist strips any other field, a URL is never accepted from the browser): parse the PEM (`notACertificate` 400 otherwise); URLs from `toLegacyObject().infoAccess['CA Issuers - URI']` (Node's `infoAccess` getter is a plain string, verified), keep http/https, no username/password, length ≤ 2048, default port only (empty `url.port`), max 3 URLs tried in order; none → 422 `aiaMissing`. Per URL: the loop of `nextcloud-logo-fetch.ts` — `isPublicHttpUrl` before the first request and before every hop (refused → 422 `aiaInternal`), `redirect: 'manual'`, max 3 redirects with the same checks, one AbortController for 8 s plus `Promise.race` on abort, content-length pre-check and streamed cap 256 KiB (`aiaTooLarge` 502), no cookies, no credentials, no custom User-Agent, `discard()` non-final bodies; the undici fetch runs with an own `Agent({ connect: { lookup } })` whose lookup (`createGuardedLookup`) resolves the name and fails when any address is private (closes the DNS-rebinding window for this feature). Response parse: DER certificate, else PKCS#7 DER/PEM (the D-16 walker), else PEM text; accept only certificates where `target.checkIssued(c) && target.verify(c.publicKey)` (none → 422 `aiaNotIssuer`); other failures → 502 `aiaUnreachable`. Result `{ filename (`<safeBaseName(cn)>.crt`), pem (the accepted certificates), host, cn }`. One hop per click. Log one warn line with host and code on failure only — never the PEM or the URL path.
|
||
- D-23 Web structure: `page.tsx` holds `useCertWorkspace()` and renders `PageHeader` plus `TabBar` from `@/components/accounting/tab-bar`; tab ids and labels in this order: files „Dateien“ (with the count, e.g. „Dateien (3)“), analyze „Analysieren“, split „Aufteilen“, merge „Zusammenführen“, convert „Konvertieren“, templates „Vorlagen“ — Task 1 shows files, Task 2 adds merge, Task 3 analyze and split, Task 5 convert, Task 6 templates, always in the order above; start tab files; switching tabs keeps the set. Non-files tabs with an empty set render `EmptyWorkspace` („Noch keine Dateien. Laden Sie Ihre Zertifikate im Reiter „Dateien“ hoch.“ plus a button to that tab); with a set, a calm line „Grundlage: {count} Dateien aus dem Reiter „Dateien“.“. `working-set.ts` (pure) keeps `WorkingEntry { id, file: File, label, origin: 'upload' | 'paste' | 'fetched', host: string | null, password: string }`; pasted text becomes `pasted-<n>.pem` with the label „Eingefügter Text <n>“; fetched certificates become entries with origin fetched and their host. `use-cert-workspace.ts` re-sends the whole set after every change and drops answers of older requests (request counter). Design: Mosaik tokens, calm dense list, existing role badge colours of the old Übersicht, visible focus, no ALL-CAPS labels, no arrow characters or arrow buttons, no middle-dot meta strings, texts formal „Sie“ with real umlauts, no tenant or licence words; every text through `t()` (namespace `certManager`, `certManager.title` stays because `nav-store.ts` uses it).
|
||
- D-24 Errors: the API throws Nest exceptions with an object body `{ code, message }` (English message for developers): `invalidInput` 400, `notACertificate` 400, `noChain` 400, `keyMissing` 400, `keyMismatch` 400, `passwordRequired` 400, `formatNotPossible` 400, `templateNeedsKey` 400, `tooLarge` 413, `aiaMissing` 422, `aiaInternal` 422, `aiaNotIssuer` 422, `aiaUnreachable` 502, `aiaTooLarge` 502. The web maps `code` (and status 413 without code) to `certManager.errors.<code>` with German texts that say what to do, unknown → a generic text. Validation errors of the DTO arrive as Nest's default 400 → `invalidInput`.
|
||
- D-25 Tests: committed fixtures under `apps/api/src/cert-manager/__fixtures__/` (biome ignores this folder; specs read them with `readFileSync(join(__dirname, '__fixtures__', …))` like the tenders specs) generated once by `make-fixtures.sh` with the host openssl 3.5.7; CA keys stay in a temporary directory and are deleted, only leaf keys are committed; file names never end in `.key` (`.gitignore` line 48 ignores `*.key` for the updater signing key — fixtures use `-key.pem` / `-key-….der`). Specs never call openssl (CI has none) — they round-trip through Node and forge; the e2e script uses openssl. One e2e script `e2e-cert.sh [files|fullchain|zip|inputs|formats|templates|version|aia|all]` against the local API (each task adds its section; `all` runs every section built so far); the browser proof in Task 8.
|
||
- D-26 Request body of `POST build` (Claude's discretion; resolves the plan-checker finding that the DTO allowed far more than Express's default 100 kB JSON limit, so a valid request could fail with a 413 without code). `build` gets its own JSON parser with `CERT_BUILD_JSON_LIMIT = 512 * 1024` bytes in `cert-json-body.ts`, registered in `main.ts` as `app.use(CERT_BUILD_ROUTE, certBuildJsonBody, certBuildBodyErrors)` with `CERT_BUILD_ROUTE = '/modules/cert-manager/build'` (the API has no global prefix) after `cookieParser()` and before `app.listen` — `app.use` registers at once, Nest registers its own parsers later in `init()`. Size arithmetic: certPem 16 384 + 20 × 16 384 poolPems + keyPem 16 384 + csrPem 16 384 characters, password 256, baseName 120 and JSON-escaped PEM newlines (about +1.6 %) ≈ 384 kB < 512 KiB, so every body within the DTO caps is parsed and reaches validation; a bigger body gets 413 `{ code: 'tooLarge', message }` and malformed JSON 400 `{ code: 'invalidInput', message }` from `certBuildBodyErrors`; every other error goes to `next`. All other routes keep Nest's default 100 kB; `analyze` is multipart and `fetch-issuer` carries at most 16 384 characters. The parser is Express's own `json()` from the Express copy that Nest's adapter loads — `createRequire(createRequire(__filename).resolve('@nestjs/platform-express'))('express')`, typed via `typeof import('express')`; `express` itself is not resolvable from `apps/api` and no package is added — wrapped in a function named `certBuildJsonBody`, because Nest's `ExpressAdapter.registerParserMiddleware` skips its global JSON parser for EVERY route when a router layer whose function is named `jsonParser` already exists. body-parser 2.3.0 returns early for a request whose body was already read, so the global parser does not read the build body again. `cert-json-body.spec.ts` builds the maximal DTO body from the exported caps of `dto/cert-build.dto.ts` and asserts it stays below the limit, so a later cap change cannot reopen the gap. The Entwicklungs- and Betriebsanleitung mention the limit (Task 6).
|
||
|
||
Output: new parser, chain, ZIP, output, PKCS#12, CSR, key, template and AIA modules with specs, the build body parser, hardened shared guard with spec, three routes, rebuilt web module (working set, six tabs), messages de/en, fixtures, e2e script and message gate, module version 1.2.0 with changelog, CHANGELOG, three guides, screenshots. Eight commits on main (one per task), NOT pushed.
|
||
</objective>
|
||
|
||
<execution_context>
|
||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||
@~/.claude/gsd-core/templates/summary.md
|
||
</execution_context>
|
||
|
||
<context>
|
||
@.planning/STATE.md
|
||
@./CLAUDE.md
|
||
@.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/261009-ikt-CONTEXT.md
|
||
RESEARCH.md: `.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/261009-ikt-RESEARCH.md` — not loaded by default; read only the sections your task's `<read_first>` names.
|
||
|
||
Discovered facts the executor can rely on (verified during planning on 2026-10-09):
|
||
- Current API module: `cert-manager.controller.ts` (routes parse, split, merge, convert, analyze, export; class `@UseModule('cert-manager')`), `cert-manager.service.ts` (793 lines, forge), `cert-bundle.ts` (655 lines, forge; `analyzeBundle`, `exportBundleItem`, `safeBaseName`, ZIP expansion via adm-zip by `.zip` extension, 100 entries, 5 MiB per entry header size), `cert-manager.module.ts` (providers `[CertManagerService]`, seeds on init and logs „Cert-Manager module seeded in registry“), `cert-manager.seed.ts` (`version: latestVersion(CERT_MANAGER_CHANGELOG)`), `cert-manager.changelog.ts` (top entry 1.1.0 dated 2026-10-02), `dto/{convert-cert,merge-certs,parse-cert}.dto.ts` (plain classes). `CertManagerController` appears in no other spec (not in `module-manage-handlers.spec.ts`); `app.module.ts` imports `CertManagerModule` and `module-changelog.registry.ts` maps 'cert-manager' — both stay.
|
||
- Current web module: `page.tsx` (own tab nav, shared single-file `DropZone` + paste + `PasswordField` for every tab except Übersicht — root cause of the bug), components `OverviewTab`, `InspectTab`, `SplitTab` (fflate `zipSync` with filename dedupe), `MergeTab`, `ConvertTab`, `DropZone`, `PasswordField` (hard-coded German aria-labels), `actions.ts` (`API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'` — `/api-proxy` in production, `downloadBase64(filename, base64, mime)`, `postForm`, fetch with `credentials: 'include'`), `zip-filename.ts` (`sanitizeZipFilename`, `ZIP_FILENAME_FALLBACK = 'certificates.zip'`; keep it), `layout.tsx` (ModuleAccessGate; untouched). The old `OverviewTab.tsx` (card layout, `ROLE_STYLES`, `formatDate` in UTC, explanations) is the visual reference for Task 3's Analysieren tab — after Task 1 deleted it, read it with `T1=$(git log --format=%H -1 --grep='Parser für RSA und EC'); git show "$T1^:apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.tsx"` (the same way for the old `SplitTab.tsx` and `cert-bundle.ts`).
|
||
- Node 24 facts (probed on this host): `new X509Certificate(pemOrDer)`; `x.infoAccess` is a STRING (`"CA Issuers - URI:http://ye2.i.lencr.org/"`), the parsed form is `x.toLegacyObject().infoAccess` → `{ 'CA Issuers - URI': ['http://ye2.i.lencr.org/'] }`; `toLegacyObject().subject` → `{ CN: 'letsencrypt.org' }` (values can be arrays for multi-valued attributes); `x.publicKey.asymmetricKeyType` 'ec' / 'rsa', `asymmetricKeyDetails` `{ namedCurve: 'prime256v1' }` or `{ modulusLength }` (map prime256v1 → P-256, secp384r1 → P-384, secp521r1 → P-521); `x.subjectAltName` is a string like `DNS:a, DNS:b`; `x.ca`, `x.fingerprint256`, `x.fingerprint`, `x.serialNumber`, `x.validFromDate`/`x.validToDate`, `x.checkIssued(other)`, `x.verify(publicKey)`, `x.checkPrivateKey(key)`, `x.raw` (DER), `x.toString()` (PEM). Live chain: the letsencrypt.org leaf (EC P-256) names `http://ye2.i.lencr.org/`; that URL answers 200 `application/pkix-cert` with 656 bytes DER of „CN=YE2“ (EC); `leaf.checkIssued(ye2) && leaf.verify(ye2.publicKey)` is true; YE2 names `http://ye.i.lencr.org/` (issuer „Root YE“). The api container reaches it (`docker compose exec -T api node -e "fetch('http://ye2.i.lencr.org/')…"` → 200).
|
||
- Host tools: OpenSSL 3.5.7 (`pkcs12 -export -legacy` works, `req -x509 -not_before 20200101000000Z -not_after 20210101000000Z` works), `zip`, `unzip`, `python3`.
|
||
- Shared guard `apps/api/src/common/public-url-guard.ts`: `isPrivateIpv4`, `isPrivateIpv6` (only `::`, `::1`, prefixes fc/fd/`fe80:`/ff and dotted `::ffff:a.b.c.d`), `isPrivateIpAddress` (NOT exported), `isBlockedHostname`, `export async function isPublicHttpUrl(url: URL)` (http/https, blocked names, literal IPs, `dns/promises` lookup all). Users: `favorites/icon-discovery.service.ts`, `nextcloud-status/nextcloud-logo-fetch.ts` (and their specs); no guard spec exists. `nextcloud-logo-fetch.ts` is the loop to copy (options `fetchImpl`, `isPublic`, `timeoutMs`; `discard(response)`; abort race; streamed byte cap; one warn line with host and code).
|
||
- Body parser (probed 2026-10-09, basis of D-26): a JSON body over 100 kB is answered today with 413 `{"statusCode":413,"message":"request entity too large"}` (no code); `require.resolve('express')` from `apps/api` → MODULE_NOT_FOUND, while `createRequire(require.resolve('@nestjs/platform-express'))('express').json` works and returns a function named `jsonParser`; Nest 11.1.27 `NestApplication.init()` calls `ExpressAdapter.registerParserMiddleware`, which skips json/urlencoded when `isMiddlewareApplied(name)` finds a router layer whose `handle.name` equals `jsonParser`/`urlencodedParser`; body-parser 2.3.0 `read()` starts with `if (onFinished.isFinished(req)) next()`; in Vitest 3 of `apps/api` both `__filename` and `require` exist, and feeding a 5 000-byte body with `content-length` through `json({ limit: 1000 })` on a `PassThrough` with `headers` and `method` calls back with `status 413`, `type 'entity.too.large'`; the api image copies the full pnpm `node_modules` (`apps/api/Dockerfile` lines 40–42), so the same resolution works in the container; `biome check apps/api/src/main.ts` passes today.
|
||
- NestJS: global `ValidationPipe({ whitelist: true, transform: true })` in `main.ts`; no body-parser options → Express JSON limit 100 kB for every route except `build` from Task 2 on (D-26); `common/request-log.ts` logs method, path (query cut), status, ms and user only. Metadata keys: `MODULE_SLUG_KEY = 'moduleSlug'` in `module-registry/module.guard.ts`; route metadata via `Reflect.getMetadata('path', …)`, `'method'` (RequestMethod POST = 1) and `'__httpCode__'` (see `nextcloud-files.controller.spec.ts` for the pattern). An object passed to `new BadRequestException({ code, message })` becomes the response body.
|
||
- Web: shared `TabBar` at `apps/web/src/components/accounting/tab-bar.tsx` (`{ tabs: { id, label }[], active, onChange }`, buttons with `aria-current="page"`); `PageHeader` from `@/components/layout/page-header` (`moduleSlug`, `title`, `description`); tests use Vitest 4 + Testing Library; prefer rendering with `NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin"` (as `nextcloud-files-page.test.tsx`) so missing keys fail; mock `./actions` with `vi.mock` keeping `downloadBase64` spied. `messages/umlaut-guard.spec.ts` fails on new tokens with ae/oe/ue/ss that are not in `UMLAUT_ALLOWLIST` (`umlaut-dictionary.ts`) — add correct German words there; no message key for the module exists in any parity spec, so the verify's node check enforces de/en parity.
|
||
- Biome: `biome.json` excludes `**/__fixtures__`; baseline `biome check` already fails on the untouched `layout.tsx`, `zip-filename.test.ts` and `module-registry/module-changelog.spec.ts` (import order/format) — do not reformat them; `biome lint` on both module folders passes; `de.json`, `en.json`, `public-url-guard.ts`, `cert-manager.changelog.ts`, `cert-manager.seed.ts` pass `biome check` today and must keep passing.
|
||
- Module changelog rules (`docs/anleitung-entwicklung.md` „### Modulversion und Modul-Changelog pflegen“, ~line 299): new-item entry = minor bump; one jump per module between Tessera releases; items 1–2 sentences, `de` + `en`, kinds `new | changed | fixed`, real umlauts, „Sie“, no replacement spellings like „fuer“/„Aenderung“, no tenant/licence words, plain text. Guard: `apps/api/src/module-registry/module-changelog.spec.ts` (seed version = top entry, strictly descending versions, real dates descending). Marktplatz reads `GET /modules/changelog/:slug`. `CHANGELOG.md` starts with „## Unveröffentlicht“ → „### Neu“ / „### Geändert“ / „### Behoben“; module bumps are mentioned like „Modulversion 1.1.0.“ (see the DKV bullet).
|
||
- Guides: `docs/anleitung-anwender.md` „### Zertifikat-Manager“ at line 150 (intro plus four bullets plus a formats paragraph, ends before „### Domaincheck“ at line 161); `docs/anleitung-betrieb.md` chapter „## 3. Konfiguration“ with „### Dateien (Nextcloud)“ at line 186 (bullet with `client_max_body_size` ≥ `10m` at line 192) and the table „### Fehlerbilder“ (line ~755); `docs/anleitung-entwicklung.md` „## Konventionen und Fallstricke“ (line 693, paragraphs „**Titel (quick-id):** …“, last one „**Dateien (Nextcloud), Teilen (quick-261009-dkv):**“ around line 800). `docs/anleitung-administration.md` has no cert-manager text and the module has no settings — it stays unchanged.
|
||
- e2e harness `.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh` (source it; no side effects): `API` (http://localhost:3001), `E2E_TMP`, `e2e_fail`, `e2e_login <jar>` (admin/admin123), `e2e_status <jar> <method> <url> [json] [out]`, `e2e_expect`, `e2e_contains`, `e2e_activate <jar> cert-manager`, `e2e_wait_health`. Multipart upload with `curl -s -b <jar> -F "files=@<path>;filename=<name>" … $API/modules/cert-manager/analyze`; JSON handling with `python3 -I`.
|
||
- Stack: db, api :3001, web :3000 (production build through `/api-proxy`) and mailhog run; rebuild with `docker compose up -d --build api web` (plain `up` does not rebuild). Login admin/admin123. Playwright MCP is configured (`.mcp.json`, chromium); screenshots go to `.playwright-mcp/cert-manager/` (gitignored). Dark mode via the theme button in the header (never by adding the class by script). Never judge the UI by calling fetch from inside the page — navigate and read the rendered page.
|
||
- Git: commit ONLY the task's files: `git add <new files>`, `git rm <deleted files>`, then `git commit -m "…" -- <every file of the task>`. German subject with prefix `feat(cert-manager):`, body ends with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`. Never push (the user bundles pushes), never deploy to the test server, never read `.env` files.
|
||
|
||
Execution protocol for the eight tasks (read before starting your task):
|
||
- Tasks run strictly in order 1 → 8, each by its own fresh executor. Read the frontmatter, the objective with D-01 … D-26, this context and YOUR `<task>` only; open the files your `<read_first>` names; read RESEARCH.md only in the sections your task names.
|
||
- TDD: write or extend the specs and tests of your `<behavior>` first, see them fail, then implement.
|
||
- Before the verify chain: `docker compose up -d --build api web` (plain `up` does not rebuild); the e2e setup waits for /health. Every chain ends with `e2e-cert.sh all`, i.e. every section built so far.
|
||
- End with exactly one commit of exactly your task's files (new, changed, removed), message as your task names it. Never push (the user bundles pushes after Task 8), never deploy.
|
||
- After the commit append „## Task N“ to `.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/261009-ikt-SUMMARY.md` (create it if missing; never committed by an executor) with the measured gate table, deviations, threat status and the output items the `<output>` section assigns to your task.
|
||
</context>
|
||
|
||
<tasks>
|
||
|
||
<task type="tracer" tdd="true">
|
||
<name>Task 1: Tracer — several files into one working set, one node:crypto parser for RSA and EC certificates, every certificate recognised with its role in the tab „Dateien“, end to end and proven live</name>
|
||
<files>apps/api/src/cert-manager/__fixtures__/make-fixtures.sh, apps/api/src/cert-manager/__fixtures__/README.md, apps/api/src/cert-manager/__fixtures__/ (generated fixture files), apps/api/src/cert-manager/cert-types.ts, apps/api/src/cert-manager/cert-model.ts, apps/api/src/cert-manager/cert-model.spec.ts, apps/api/src/cert-manager/cert-output.ts, apps/api/src/cert-manager/cert-output.spec.ts, apps/api/src/cert-manager/cert-analyze.ts, apps/api/src/cert-manager/cert-analyze.spec.ts, apps/api/src/cert-manager/cert-manager.controller.ts, apps/api/src/cert-manager/cert-manager.controller.spec.ts, apps/api/src/cert-manager/cert-manager.module.ts, removed: apps/api/src/cert-manager/{cert-bundle.ts, cert-bundle.spec.ts, cert-manager.service.ts, cert-manager.service.spec.ts, dto/convert-cert.dto.ts, dto/merge-certs.dto.ts, dto/parse-cert.dto.ts}, apps/web/src/app/(portal)/modules/cert-manager/actions.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts, apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts, 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/FilesTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx, removed: apps/web/src/app/(portal)/modules/cert-manager/components/{DropZone.tsx, InspectTab.tsx, OverviewTab.tsx, OverviewTab.test.tsx, SplitTab.tsx, ConvertTab.tsx, PasswordField.tsx, MergeTab.tsx, MergeTab.test.tsx}, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh, .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs</files>
|
||
<read_first>apps/api/src/cert-manager/cert-manager.controller.ts, apps/api/src/cert-manager/cert-manager.module.ts, apps/api/src/cert-manager/cert-bundle.ts (only `safeBaseName` and the role wording — read it before you remove it), apps/web/src/app/(portal)/modules/cert-manager/page.tsx, apps/web/src/app/(portal)/modules/cert-manager/actions.ts, apps/web/src/components/accounting/tab-bar.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx (render pattern with NextIntlClientProvider), the e2e harness e2e-lib.sh named in the context, RESEARCH.md sections on node:crypto and the EC gap of node-forge</read_first>
|
||
<precondition>The local stack runs (`docker compose ps --status running --services` lists api, web and db) and `openssl version` reports 3.4 or newer (the fixture script needs `-legacy` and `-not_before`).</precondition>
|
||
<behavior>
|
||
- Fixtures: `make-fixtures.sh` regenerates every file of the D-25 list into its own folder; afterwards `git status --porcelain --ignored apps/api/src/cert-manager/__fixtures__` shows no ignored („!!“) entry and the folder contains no private key of a CA.
|
||
- cert-model: `rsa-leaf.pem` → one CertItem with cn `www.example.test`, san [`www.example.test`, `example.test`], issuerCn `Tessera Test Inter RSA`, role end-entity, keyType RSA, keyBits 2048, curve null, isCa false, selfSigned false, aiaIssuerUrls [`http://pki.example.test/rsa-inter.cer`], sha256 equal to Node's `fingerprint256` of the fixture and id `c-` plus the first 16 lowercase hex characters of that hash; `rsa-inter.pem` → role intermediate; `ec-leaf.pem` → keyType EC, curve P-256, keyBits 256; `ec-root.pem` → curve P-384, isCa true, selfSigned true, role root; `selfsigned-leaf.pem` → selfSigned true but role end-entity (not a CA); `ec-leaf.cer` (DER) → the same id as `ec-leaf.pem`; `ec-fullchain.pem` saved with a UTF-8 BOM, CRLF line endings and text before and after the blocks → three certificates; `rsa-trusted.pem` (TRUSTED CERTIFICATE) → the certificate; random bytes, an empty file, a truncated DER and a PEM block with broken Base64 → ignored unknown without throwing.
|
||
- cert-output: `safeBaseName('*.example.de', 'x')` = `wildcard.example.de` and the other cases of the old spec (moved, rules unchanged).
|
||
- cert-analyze: files [rsa-leaf.pem, rsa-inter.pem, ec-leaf.cer, ec-inter.pem, ec-root.pem, rsa-leaf.cer, readme.txt] → rsa-leaf is ONE item with two sources (file 0 path `rsa-leaf.pem`, file 5 path `rsa-leaf.cer`); items ordered end-entity, intermediate, root, then cn, then notAfter descending; ignored has `unknown` for readme.txt with file 6; `chains`, `locked` are empty arrays in this task; JSON.stringify of the result contains neither the text undefined nor NaN.
|
||
- Controller: class path `modules/cert-manager` with MODULE_SLUG_KEY `cert-manager`; the prototype has exactly the handler `analyze` (POST `analyze`, http code 200); no files → 400 with code invalidInput; files summing over 20 MiB → 413 with code tooLarge and no analysis; the files reach `analyzeWorkingSet` in request order.
|
||
- working-set (web, pure): two `addFiles` calls with one file each → two entries in call order; the same file again (name, size, lastModified) → rejected duplicate; the 31st file → rejected tooMany; a 6 MiB file → rejected tooLarge; a set over 10 MiB → rejected totalTooLarge; `removeEntry` keeps the order of the rest; `toFormData` appends `files` in entry order.
|
||
- FilesTab (real de messages, mocked actions): selecting one file and then another in a second selection lists both and the last `analyzeWorkingSet` call receives both (regression of the user's bug); one selection with two files appends both; dropping files appends; removing an entry re-analyses the rest and removing the last one clears the analysis without a call; each entry lists the certificates whose sources point to it with role label and CN; an unrecognised file shows the reason text for unknown; rejected files are listed with their reason; the note that the list exists only in this browser window is visible; while analysing a status text is shown; an analysis error shows the error text and „Erneut versuchen“ repeats the call.
|
||
- Page: PageHeader plus TabBar with the single tab „Dateien“ in this task, start tab files, the label shows the count („Dateien (2)“ after two files).
|
||
</behavior>
|
||
<action>
|
||
**Fixtures (per D-05, D-13, D-25).** Write `apps/api/src/cert-manager/__fixtures__/make-fixtures.sh` with the Write tool (bash, `set -euo pipefail`, header comment: test-only PKI for quick 261009-ikt, needs OpenSSL ≥ 3.4, CA keys live only in a `mktemp -d` folder removed by a trap, fixture password `Test-Pass-123`) and run it once. It produces, in its own folder: RSA 2048 PKI — `rsa-root.pem` (CN „Tessera Test Root RSA“, CA, SKI), `rsa-root2.pem` („Tessera Test Root RSA 2“), `rsa-inter.pem` („Tessera Test Inter RSA“, signed by rsa-root, AKI/SKI, caIssuers `http://pki.example.test/rsa-root.cer`), `rsa-inter-cross.pem` (same subject and key as rsa-inter, signed by rsa-root2), `rsa-inter-expired.pem` (same subject and key, signed by rsa-root, valid 2020-01-01 to 2021-01-01), `rsa-inter-decoy.pem` (same subject DN, different key, signed by rsa-root), `rsa-leaf.pem` (CN `www.example.test`, SAN `www.example.test` and `example.test`, caIssuers `http://pki.example.test/rsa-inter.cer`, signed by rsa-inter, 100 years validity like all others), `rsa-leaf.cer` (DER), `rsa-leaf-noaki.pem` (CN `noaki.example.test`, signed by rsa-inter with authorityKeyIdentifier and subjectKeyIdentifier set to none and no AIA), `rsa-fullchain.pem`, `rsa-chain.p7b` (PEM via `openssl crl2pkcs7 -nocrl`), `rsa-chain.p7c` (DER), `rsa-trusted.pem` (`openssl x509 -trustout`); EC PKI — `ec-root.pem` (P-384, „Tessera Test Root EC“), `ec-inter.pem` (P-384, „Tessera Test Inter EC“), `ec-leaf.pem` (P-256, CN `ec.example.test`, SAN, caIssuers `http://pki.example.test/ec-inter.cer`), `ec-leaf.cer`, `ec-fullchain.pem`, `ec-chain.p7b`; `selfsigned-leaf.pem` (RSA, not CA); `aia-private-leaf.pem` (signed by rsa-inter, caIssuers `http://127.0.0.1/inter.cer`, `http://169.254.169.254/latest` and an `ldap://` URL — used in Task 7); keys for both leaves — `rsa-leaf-key.pem` (PKCS#8), `rsa-leaf-key-pkcs1.pem`, `rsa-leaf-key-enc-pkcs8.pem` (AES-256), `rsa-leaf-key-enc-trad.pem` (`openssl rsa -traditional -aes256`), `rsa-leaf-key-pkcs8.der`, `rsa-leaf-key-pkcs1.der`, `ec-leaf-key.pem`, `ec-leaf-key-sec1.pem`, `ec-leaf-key-enc-pkcs8.pem` (`-v1 PBE-SHA1-3DES`), `ec-leaf-key-enc-trad.pem` (`openssl ec -aes256`), `ec-leaf-key-sec1.der`, `ec-leaf-key-enc-pkcs8.der`; CSRs — `rsa-leaf.csr`, `rsa-leaf.csr.der`, `ec-leaf.csr`, `ec-leaf.csr.der` (with SAN via `-addext`); PFX (leaf, key and chain) — `rsa-modern.pfx` and `ec-modern.pfx` (OpenSSL-3 default), `rsa-compat.pfx` and `ec-compat.pfx` (`-certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES -macalg sha1`), `rsa-legacy.pfx` (`-legacy`), `rsa-nopass.pfx` (empty password), `rsa-modern.bin` (a copy of rsa-modern.pfx without the extension); `encrypted-entry.zip` (`zip -P Test-Pass-123` with rsa-leaf.pem inside). Never use the `.key` extension (`.gitignore` line 48). Write `__fixtures__/README.md`: test-only data, no real certificates, the password, how to regenerate, that specs read the files and that a future secret scanner must allow-list this folder. Tasks 2–7 use the chain, PKCS#7, key, CSR, PFX, ZIP and AIA fixtures — generate everything now, never regenerate later (new CA keys would change every certificate).
|
||
|
||
**Contract (per D-15, D-19, D-24).** New `cert-types.ts` with every type of D-15 (KeyItem, CsrItem, ChainInfo and LockedEntry are filled from Tasks 2 and 4 on), `BuildInput`/`BuildResult` of D-19 and the `CertErrorCode` union of D-24 plus a small `certError(code, status, message)` helper that throws the matching Nest exception with body `{ code, message }`.
|
||
|
||
**One parser, certificates first (per D-08, D-16, D-18).** New `cert-model.ts`: `detectBlob(blob, ctx)` runs the D-16 pipeline with every stage as a named function; in this task the certificate stages are real (PEM labels CERTIFICATE, X509 CERTIFICATE, TRUSTED CERTIFICATE with the leading certificate SEQUENCE only; DER X.509) and the ZIP, PKCS#7, key, PKCS#12 and CSR stages are named slots that recognise nothing yet, so a blob no stage recognises ends as ignored unknown (Task 3 fills ZIP and PKCS#7, Task 4 keys, PKCS#12 and CSR). `certItemFromDer(der, source)` builds a CertItem from node:crypto only (fields of D-15, subject and issuer via `toLegacyObject()`, caIssuers via `toLegacyObject().infoAccess`, curve mapping as in the context facts, `safeBaseName`, and the per-certificate facts selfSigned and role of D-18). Do not call forge's certificate, CSR or PKCS#7-message parsers anywhere outside specs — they are RSA-only; the verify gate greps for the names of those forge functions, so keep them out of comments too. Write `cert-model.spec.ts` first.
|
||
|
||
**Base name (per D-15).** New `cert-output.ts` with `safeBaseName` only (moved from cert-bundle.ts, rules unchanged) and `cert-output.spec.ts` with the moved cases; Task 2 adds `buildOutput` to the same file.
|
||
|
||
**Facade, route, module (per D-08, D-14, D-17, D-24).** New `cert-analyze.ts`: `analyzeWorkingSet(files, passwords = [])` — detect every blob, dedupe by id with merged sources, order per D-15, return `AnalysisResult` (chains from Task 2, keys, CSRs and locked from Task 4; empty arrays until then). Rewrite `cert-manager.controller.ts`: header comment (stateless, nothing stored, passwords and keys never logged, the D-14 route list with the task that adds each route), constructor without services, `@Post('analyze')` with `@HttpCode(200)`, `FilesInterceptor('files', 30, { limits: { fileSize: 5 * 1024 * 1024 } })`, no files → invalidInput 400, files together over 20 MiB → tooLarge 413. Rewrite `cert-manager.module.ts` without providers (keep the seeding and its log line). Remove with `git rm`: `cert-bundle.ts`, `cert-bundle.spec.ts`, `cert-manager.service.ts`, `cert-manager.service.spec.ts` and the three old DTO files — the old routes and the old analyze contract go away in the same commit as the web code that called them. Write `cert-analyze.spec.ts` and `cert-manager.controller.spec.ts` (metadata per behavior; the 400 and 413 cases by calling the handler with fake files) first.
|
||
|
||
**Web (per D-01, D-06, D-11, D-23, D-24).** Rewrite `actions.ts`: keep `API_URL` and `downloadBase64`; mirror the D-15 and D-19 types; `CertManagerRequestError(status, code)`; `analyzeWorkingSet(entries)` (multipart via `toFormData`, credentials include); no password ever in a URL or log. New `working-set.ts` (pure, D-17 web limits, the full D-23 entry shape incl. `origin`, `host` and `password` so Tasks 3, 4 and 7 only add functions) with `working-set.test.ts`. New `use-cert-workspace.ts`: entries state, `addFiles(files) → rejected[]`, `remove(id)`, `clear()`, `retry()`, analysis state (`idle | analyzing | error`), re-analysis of the whole set after each change with a request counter that drops older answers, empty set → analysis null without a call. Rewrite `page.tsx` per D-23 with the tab files only. New `components/FilesTab.tsx`: one large drop button (real button for click AND drop, hidden `<input type="file" multiple>` with accept `.zip,.pem,.crt,.cer,.cert,.der,.ca-bundle,.chain,.p7b,.p7c,.pfx,.p12,.csr,.req,.txt` plus key extensions, drops accept any file because detection is by content), hint text with the limits, rejected files with their reason, the entry list (`<ul>`, per entry: label, size, the recognised certificates from `analysis.items` whose sources point to this entry with role label and CN, its ignored lines with reason texts, remove button with aria-label „„{name}“ entfernen“), „Alle entfernen“, the always visible note per D-11 („Die Dateien bleiben nur in diesem Browserfenster. Tessera speichert nichts davon; nach dem Neuladen oder Schließen der Seite ist die Liste leer.“), analysing status and error with „Erneut versuchen“. Remove with `git rm`: `DropZone.tsx`, `InspectTab.tsx`, `OverviewTab.tsx`, `OverviewTab.test.tsx`, `SplitTab.tsx`, `ConvertTab.tsx`, `PasswordField.tsx`, `MergeTab.tsx`, `MergeTab.test.tsx` (Tasks 2, 3 and 5 write the new tabs). Rewrite `cert-manager.test.tsx` (page) and write `FilesTab.test.tsx` per behavior first. Messages: restructure namespace `certManager` in de AND en (keep `title` and `description`; keys for tabs, files, roles, ignored reasons and `errors.<code>` for every D-24 code — all codes now, later tasks add only their own texts), German with „Sie“ and real umlauts, no arrow or middle-dot characters, no tenant or licence words; extend `UMLAUT_ALLOWLIST` only with correct German words the guard asks for.
|
||
|
||
**Message gate.** Write `.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs` (node, no dependencies, resolves the two message files relative to the repo root found via `git rev-parse --show-toplevel`): flattens `certManager` of de.json and en.json, exits 1 with `key mismatch` when the key sets differ, `too few keys` when de has fewer keys than the first argument, `title missing` without `certManager.title`, `bad text: <value>` for any value matching `/mandant|tenant|lizenz|licens|→|·/i` (write the arrow and the middle dot as escapes in the regex); otherwise prints `messages ok <count>`.
|
||
|
||
**Live e2e (per D-13, D-25).** Write `.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh` with the Write tool (bash, `set -euo pipefail`, sources `../../261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh` relative to its own directory, `FIX` = the fixtures folder relative to the repo root, header comment: test values only, reads no .env). Argument: one of files, fullchain, zip, inputs, formats, templates, version, aia or all; `all` runs every section implemented so far in exactly this order; an argument naming a section not yet implemented fails with „Abschnitt noch nicht gebaut“. This task implements setup and files; Tasks 2–7 each add their section and append it to `all`. Setup: wait for health, admin login, `e2e_activate <jar> cert-manager`, probe `POST $API/modules/cert-manager/parse` — anything but 404 fails with the hint „API-Container neu bauen: docker compose up -d --build api“. Section files: POST analyze (`curl -s -b <jar> -F "files=@<path>;filename=<name>" …`) with rsa-leaf.pem, rsa-inter.pem, ec-leaf.cer, ec-inter.pem, ec-root.pem and rsa-leaf.cer → 200, exactly five distinct certificates, rsa-leaf with two sources, the EC leaf with keyType EC and curve P-256, roles end-entity/intermediate/root as expected; POST analyze without files → 400 with code invalidInput; POST parse, split, merge, convert and export → 404 each. Print `e2e cert files ok`.
|
||
|
||
**Rebuild, run, commit.** `docker compose up -d --build api web`, wait for /health, run the verify chain. Commit `feat(cert-manager): Reiter „Dateien“ mit mehreren Dateien und ein Parser für RSA und EC – Durchstich` with exactly the files of this task (new, rewritten and removed). Do not push. Append „## Task 1“ to the SUMMARY with output item 1 (fixture list actually generated, openssl version).
|
||
</action>
|
||
<verify>
|
||
<automated>test -f apps/api/src/cert-manager/cert-model.spec.ts && test -f apps/api/src/cert-manager/cert-output.spec.ts && test -f apps/api/src/cert-manager/cert-analyze.spec.ts && test -f apps/api/src/cert-manager/cert-manager.controller.spec.ts && test -f "apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts" && test -f "apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx" && pnpm --filter @tessera/api exec vitest run src/cert-manager src/module-registry && pnpm --filter @tessera/web exec vitest run modules/cert-manager src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager" && pnpm exec biome check apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager/components" "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/working-set.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts" "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts" "apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx" apps/web/src/messages/de.json apps/web/src/messages/en.json && node .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs 15 && test ! -e apps/api/src/cert-manager/cert-manager.service.ts && test ! -e apps/api/src/cert-manager/cert-bundle.ts && test ! -e apps/api/src/cert-manager/dto/parse-cert.dto.ts && test ! -e "apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.tsx" && test ! -e "apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx" && test ! -e "apps/web/src/app/(portal)/modules/cert-manager/components/PasswordField.tsx" && ! grep -nE "@(Post|Get)\('(parse|split|merge|convert|export)'\)" apps/api/src/cert-manager/cert-manager.controller.ts && test -z "$(grep -rlE 'certificateFromPem|certificateFromAsn1|certificationRequestFromAsn1|messageFromPem|messageFromAsn1' apps/api/src/cert-manager --include=*.ts | grep -v '\.spec\.ts$')" && FIXSTAT=$(git status --porcelain --ignored apps/api/src/cert-manager/__fixtures__) && ! printf '%s\n' "$FIXSTAT" | grep -q '^!!' && test -s apps/api/src/cert-manager/__fixtures__/ec-chain.p7b && test -s apps/api/src/cert-manager/__fixtures__/rsa-legacy.pfx && test -s apps/api/src/cert-manager/__fixtures__/aia-private-leaf.pem && docker compose ps --status running --services | grep -qx api && bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all && echo "task1 ok"</automated>
|
||
<fails_when>non-zero exit of the chain and the last line `task1 ok` missing; the visible signal is one of: a `test -f`/`test ! -e`/`test -s`/`grep` gate stopping the chain without tool output (the api vitest config has passWithNoTests, so the `test -f` gates in front are what catch a filter matching no spec), vitest printing `FAIL` or a `failed` count in its `Test Files` line, tsc printing `error TS`, biome printing `Found` with errors, check-cert-messages printing `key mismatch`, `too few keys`, `title missing` or `bad text`, or e2e-cert.sh printing `E2E FAIL:`</fails_when>
|
||
</verify>
|
||
<done>Several files land in one append-only working set (a second file never replaces the first), RSA and EC certificates in PEM, DER and TRUSTED form are recognised by one node:crypto parser and shown with role and CN per file; the old routes, the old service, the old DTOs and the old web components are gone; specs, tsc, biome, the message gate and the live files section are green on the rebuilt stack; one commit on main, not pushed.</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="true">
|
||
<name>Task 2: Zusammenführen — chain building with issuer and signature check, Fullchain and Nur Kette as PEM with the root checkbox, and a per-route body limit with a coded 413</name>
|
||
<files>apps/api/src/cert-manager/cert-chain.ts, apps/api/src/cert-manager/cert-chain.spec.ts, apps/api/src/cert-manager/cert-analyze.ts, apps/api/src/cert-manager/cert-analyze.spec.ts, apps/api/src/cert-manager/cert-output.ts, apps/api/src/cert-manager/cert-output.spec.ts, apps/api/src/cert-manager/dto/cert-build.dto.ts, apps/api/src/cert-manager/cert-json-body.ts, apps/api/src/cert-manager/cert-json-body.spec.ts, apps/api/src/main.ts, apps/api/src/cert-manager/cert-manager.controller.ts, apps/api/src/cert-manager/cert-manager.controller.spec.ts, apps/web/src/app/(portal)/modules/cert-manager/actions.ts, 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/ChainView.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/EmptyWorkspace.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh</files>
|
||
<read_first>apps/api/src/cert-manager/cert-types.ts, cert-model.ts, cert-analyze.ts, cert-output.ts and cert-manager.controller.ts (Task 1), apps/api/src/main.ts, apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts, page.tsx and components/FilesTab.tsx (Task 1 patterns), the context facts on the body parser, RESEARCH.md section on chain building (name-only walk is wrong, checkIssued plus verify)</read_first>
|
||
<precondition>Task 1 is committed (`git log --oneline --grep='Parser für RSA und EC' | grep -q .`) and `bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all` passes on the running stack.</precondition>
|
||
<behavior>
|
||
- cert-chain (sets from fixtures): {rsa-leaf, rsa-inter} → path [leaf, inter], complete false, gap { kind 'afterCa', missingIssuerCn 'Tessera Test Root RSA' }; adding rsa-root → complete true, rootId = root, gap null; {rsa-leaf} → gap kind 'afterLeaf' with aiaUrls [`http://pki.example.test/rsa-inter.cer`]; {rsa-leaf-noaki, rsa-inter-decoy, rsa-inter, rsa-root} → path uses rsa-inter, never the decoy; {rsa-leaf-noaki, rsa-inter-decoy, rsa-root} → gap afterLeaf (the same-name decoy fails the signature check); {rsa-leaf, rsa-inter-cross, rsa-root2} → complete via root2; {rsa-leaf, rsa-inter, rsa-inter-cross, rsa-root} → primary via rsa-inter and rsa-root, alternatives ≥ 1; {rsa-leaf, rsa-inter-expired, rsa-inter, rsa-root} → primary uses the valid rsa-inter; the same set in reversed input order → the same primary path; {rsa-inter, rsa-root} → one chain with head rsa-inter; `selfsigned-leaf.pem` → path [itself], complete true, rootId null, gap null.
|
||
- cert-analyze: the Task-1 set now yields two chains (RSA incomplete afterCa, EC complete with rootId = ec-root).
|
||
- cert-output (buildOutput, fullchain and chain as PEM): fullchain for ec-leaf with pool [ec-root, ec-inter] in that (wrong) order plus rsa-inter → exactly two blocks, ec-leaf first then ec-inter, filename `ec.example.test-fullchain.pem`, chainComplete true; includeRoot true → three blocks ending with ec-root; chain → only ec-inter (with includeRoot ec-inter and ec-root), filename `ec.example.test-chain.pem`; chain for {rsa-leaf} alone → 400 noChain; certPem that is not a certificate → 400 notACertificate; a poolPems entry that is not a certificate → 400 notACertificate; {rsa-leaf, rsa-inter} fullchain → chainComplete false, missingIssuerCn 'Tessera Test Root RSA'.
|
||
- cert-json-body (D-26): the exported middleware's function name is `certBuildJsonBody` (never `jsonParser` or `urlencodedParser`); the maximal DTO body built from the exported caps (certPem, 20 poolPems, keyPem and csrPem each at `CERT_PEM_MAX` characters with a newline every 64 characters, password at `CERT_PASSWORD_MAX`, baseName at `CERT_BASENAME_MAX`) serialised with JSON.stringify is smaller than `CERT_BUILD_JSON_LIMIT` and, fed through `certBuildJsonBody` as a fake request stream, ends up in `req.body`; a 600 KiB body → `certBuildBodyErrors` answers 413 with body `{ code: 'tooLarge', message }`; malformed JSON → 400 `{ code: 'invalidInput', message }`; any other error is passed to `next` unchanged.
|
||
- Controller: the prototype has exactly the handlers `analyze` and `build` (POST `build`, http code 200).
|
||
- MergeTab (real de messages, mocked actions): one head → its chain listed in order with role labels; two heads → a radio group to choose; „Root-Zertifikat mitnehmen“ unchecked by default and disabled with the note that no root is available when the path has no root; „Fullchain herunterladen“ calls `buildOutput({ content: 'fullchain', format: 'pem', certPem: <head pem>, poolPems: <the other certificates of the path>, includeRoot: false, baseName })` once and then `downloadBase64` with the returned file; ticking the root → includeRoot true; „Nur Kette herunterladen“ sends content chain; a gap afterLeaf shows „Zwischenzertifikat fehlt“ with the missing name; a gap afterCa shows the calm note; an error code shows its German text; a 413 without code shows the tooLarge text.
|
||
- Page: tabs „Dateien“ and „Zusammenführen“ (D-23 order); on Zusammenführen with an empty set the EmptyWorkspace text and its button switch to Dateien; with a set the line „Grundlage: …“ is shown; entries survive switching tabs.
|
||
</behavior>
|
||
<action>
|
||
**Chains (per D-05, D-18).** New `cert-chain.ts`: `buildChains(certs)` → `{ chains }` per D-18 — heads per D-15, issuers of C = every other certificate passing `C.checkIssued(I) && C.verify(I.publicKey)`, DFS with visited set and depth cap 10, the full ranking (self-signed end first, fewer certificates expired or not yet valid, shorter, later notAfter of the first issuer, sha256 ascending), `alternatives`, gap kinds, `rootId`; it uses the role and selfSigned facts that `certItemFromDer` already computes. `analyzeWorkingSet` fills `chains`. Write `cert-chain.spec.ts` and the analyze case first.
|
||
|
||
**Output (per D-02, D-19).** Add `buildOutput(input)` to `cert-output.ts` for content fullchain and chain in format pem (Task 5 adds every other content and format to the same function): parse certPem and poolPems with `X509Certificate` (anything that is not a certificate → notACertificate), run `buildChains`, take the primary chain of certPem, drop the root unless includeRoot, return D-19 file names, base64 content, mime `application/x-pem-file`, chainComplete and missingIssuerCn. Extend `cert-output.spec.ts` first.
|
||
|
||
**DTO and body limit (per D-19, D-26).** New `dto/cert-build.dto.ts`: exported caps `CERT_PEM_MAX = 16384`, `CERT_POOL_MAX = 20`, `CERT_PASSWORD_MAX = 256`, `CERT_BASENAME_MAX = 120`; `BuildOutputDto` with class-validator — content IsIn fullchain and chain (Task 5 widens it), format IsIn pem, certPem IsString MaxLength, poolPems IsArray ArrayMaxSize with each IsString MaxLength, includeRoot IsBoolean, baseName IsString MaxLength, all optional except content; header comment with the size arithmetic of D-26. New `cert-json-body.ts` per D-26 (exports `CERT_BUILD_ROUTE`, `CERT_BUILD_JSON_LIMIT`, `certBuildJsonBody`, `certBuildBodyErrors`; header comment explaining the 512 KiB, why the function must not be called `jsonParser`, and that `express` is reached through `@nestjs/platform-express`). Write `cert-json-body.spec.ts` first (fake request = a `PassThrough` with `headers` and `method`, as in the context facts). In `apps/api/src/main.ts` register `app.use(CERT_BUILD_ROUTE, certBuildJsonBody, certBuildBodyErrors)` directly after `cookieParser()` and before `app.listen`, with a one-line comment pointing to quick-261009-ikt D-26.
|
||
|
||
**Route.** `@Post('build')` with `@HttpCode(200)` and `@Body() dto: BuildOutputDto` → `buildOutput`; header route list updated. Extend the controller spec first.
|
||
|
||
**Web (per D-01, D-02, D-23, D-24).** `actions.ts`: `buildOutput(input)` (JSON, credentials include), error mapping incl. 413 without code → tooLarge. New `components/ChainView.tsx` (ordered path with role badge, CN, issuer and validity per step, a thin connecting line instead of arrow characters, gap row per D-18: afterLeaf „Zwischenzertifikat fehlt: „{name}““ as a warning, afterCa as a calm note that the certificate above, usually the root, is missing and is not needed for a Fullchain without root; Task 7 adds the fetch button to this row). New `components/MergeTab.tsx` per behavior (head choice, ChainView, root checkbox per D-02, buttons „Fullchain herunterladen“ and „Nur Kette herunterladen“, busy state, error texts). New `components/EmptyWorkspace.tsx` per D-23. `page.tsx`: tab merge. Write `MergeTab.test.tsx` and extend the page test first. Messages de AND en for every new text (rules as in Task 1).
|
||
|
||
**Live e2e (per D-13, D-26).** Add section fullchain (after files in `all`): POST build with `{}` → 400 (the probe that the api container carries this task; else fail with the rebuild hint); fullchain for ec-leaf with ec-root and ec-inter in the wrong order plus rsa-inter → 200, decode the file with `python3 -I`, exactly two certificates, the first subject is ec.example.test, `openssl verify -CAfile ec-root.pem -untrusted <ec-inter> <first certificate>` OK; includeRoot → three; chain → only ec-inter; RSA fullchain without root → chainComplete false and missingIssuerCn „Tessera Test Root RSA“; body limit: a DTO-valid body of about 380 kB (certPem ec-leaf plus 20 poolPems of 16 000 characters that are not certificates) → 400 with code notACertificate (never 413); a body over 600 KiB → 413 with code tooLarge; `POST $API/auth/login` with a 150 kB JSON body → 413 (the global 100 kB limit of every other route is unchanged), while the JSON login of the setup still works (Nest's global JSON parser is still active). Every business error body seen carries a `code`; DTO validation errors are Nest's default 400 (D-24). Print `e2e cert fullchain ok`.
|
||
|
||
**Rebuild, run, commit.** `docker compose up -d --build api web`, wait for /health, run the verify chain. Commit `feat(cert-manager): Zusammenführen mit Fullchain und Nur Kette` with exactly the files of this task. Do not push. Append „## Task 2“ to the SUMMARY.
|
||
</action>
|
||
<verify>
|
||
<automated>test -f apps/api/src/cert-manager/cert-chain.spec.ts && test -f apps/api/src/cert-manager/cert-json-body.spec.ts && test -f "apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx" && pnpm --filter @tessera/api exec vitest run src/cert-manager src/module-registry && pnpm --filter @tessera/web exec vitest run modules/cert-manager src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager" && pnpm exec biome check apps/api/src/cert-manager apps/api/src/main.ts "apps/web/src/app/(portal)/modules/cert-manager/components" "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/working-set.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts" "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts" "apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx" apps/web/src/messages/de.json apps/web/src/messages/en.json && node .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs 25 && grep -q "certBuildJsonBody" apps/api/src/main.ts && test -z "$(grep -rlE 'certificateFromPem|certificateFromAsn1|certificationRequestFromAsn1|messageFromPem|messageFromAsn1' apps/api/src/cert-manager --include=*.ts | grep -v '\.spec\.ts$')" && docker compose ps --status running --services | grep -qx api && bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all && echo "task2 ok"</automated>
|
||
<fails_when>non-zero exit of the chain and the last line `task2 ok` missing; the visible signal is one of: a `test -f`/`grep` gate stopping the chain without tool output, vitest printing `FAIL` or a `failed` count in its `Test Files` line, tsc printing `error TS`, biome printing `Found` with errors, check-cert-messages printing `key mismatch`, `too few keys`, `title missing` or `bad text`, or e2e-cert.sh printing `E2E FAIL:` (among others when the login of the setup fails because the global JSON parser was disabled, or when a body within the DTO caps is answered with 413)</fails_when>
|
||
</verify>
|
||
<done>Zusammenführen builds the chain itself (issuer and signature check, decoy refused, cross-signed and expired variants resolved deterministically) and downloads Fullchain or Nur Kette as PEM, root only when ticked; gaps are named; every DTO-valid build body is parsed, a bigger one gets 413 with code tooLarge, every other route keeps 100 kB; specs, tsc, biome and the live files and fullchain sections are green on the rebuilt stack; one commit on main, not pushed.</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="true">
|
||
<name>Task 3: Vendor ZIP, PKCS#7 and pasted PEM text into the working set, plus the tabs Analysieren and Aufteilen</name>
|
||
<files>apps/api/src/cert-manager/zip-expand.ts, apps/api/src/cert-manager/zip-expand.spec.ts, apps/api/src/cert-manager/cert-model.ts, apps/api/src/cert-manager/cert-model.spec.ts, apps/api/src/cert-manager/cert-analyze.ts, apps/api/src/cert-manager/cert-analyze.spec.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts, apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts, 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/FilesTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/ItemCard.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh</files>
|
||
<read_first>apps/api/src/cert-manager/cert-model.ts, cert-analyze.ts, cert-types.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.ts, use-cert-workspace.ts, components/FilesTab.tsx, components/ChainView.tsx, zip-filename.ts; from history (the version before the tracer, `T1=$(git log --format=%H -1 --grep='Parser für RSA und EC')`): `git show "$T1^:apps/api/src/cert-manager/cert-bundle.ts"` (ZIP part), `git show "$T1^:apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.tsx"` (card layout, `ROLE_STYLES`, UTC `formatDate`, explanations) and `git show "$T1^:apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx"` (fflate pattern, filename dedupe); RESEARCH.md sections on ZIP limits and PKCS#7</read_first>
|
||
<precondition>Task 2 is committed (`git log --oneline --grep='Fullchain und Nur Kette' | grep -q .`) and `e2e-cert.sh all` passes on the running stack.</precondition>
|
||
<behavior>
|
||
- zip-expand (vendor ZIP built in the spec with adm-zip from fixture contents: `ServerCertificate.crt`, `Intermediate/CA.crt`, `__MACOSX/._ServerCertificate.crt`, `.DS_Store`, `Thumbs.db`, `readme.txt`, `inner.zip`): returns blobs for the two certificates and readme.txt with paths `vendor.zip/ServerCertificate.crt` and `vendor.zip/Intermediate/CA.crt`; the junk entries produce nothing; `inner.zip` → ignored nestedZip; the same ZIP named `bundle.dat` is still expanded (magic bytes); random bytes starting with `PK\x03\x04` → ignored brokenZip; the committed `encrypted-entry.zip` → its entry ignored encryptedZip; with injected limits: more entries than allowed → one ignored tooManyEntries for the whole ZIP and no blob; an entry declared larger than allowed → ignored tooLarge; 600 kB of zero bytes (ratio over 100) → ignored suspicious; kept entries summing over the total → one ignored zipTooLarge and no blob.
|
||
- cert-model: `rsa-chain.p7b`, `rsa-chain.p7c` and `ec-chain.p7b` (the EC case forge could not read) → three certificates each; a ZIP blob is expanded through zip-expand and its entries run through the same pipeline with source path `<zip name>/<entry path>`.
|
||
- cert-analyze: files [rsa-leaf.pem, rsa-inter.pem, vendor ZIP containing ec-leaf, ec-inter, ec-root, a copy of rsa-leaf, `inner.zip` and readme.txt] → rsa-leaf is ONE item with two sources (file 0 path `rsa-leaf.pem` and file 2 path `vendor.zip/…`); items ordered end-entity, intermediate, root; two chains (RSA incomplete afterCa, EC complete with root); ignored contains nestedZip and unknown for readme.txt with file 2.
|
||
- working-set: `addText` trims, ignores empty text, rejects more than 256 000 characters and creates `pasted-1.pem` labelled „Eingefügter Text 1“ with origin paste, the next one `pasted-2.pem`; pasted entries count toward the 30 entries.
|
||
- FilesTab: a ZIP entry shows its contained parts grouped by path with role and CN from the analysis; ignored lines nestedZip, encryptedZip, tooLarge, suspicious, zipTooLarge, tooManyEntries and brokenZip show their reason texts; the collapsible „PEM-Text einfügen“ area with „Hinzufügen“ adds „Eingefügter Text 1“ marked as pasted text.
|
||
- AnalyzeTab: chains section first (one ChainView per head), then one ItemCard per certificate — role badge in the colours of the old Übersicht, CN, issuer, validity state (valid, expires within 30 days, expired; dates in UTC), SAN, key type with size or curve, serial, SHA-256, SHA-1, sources with file and ZIP path — then the list of ignored entries with reasons; empty set → EmptyWorkspace.
|
||
- SplitTab: every item as a row with a download in its natural format straight from `item.pem` (certificate `<baseName>.crt`; the rows are generic over kind, so keys `.key` and CSRs `.csr` appear from Task 4 on without changes) via `downloadBase64`, and „Alle als ZIP herunterladen“ (fflate `zipSync`, duplicate names numbered, ZIP name via `sanitizeZipFilename`); no API call.
|
||
- Page: tabs Dateien, Analysieren, Aufteilen, Zusammenführen in this order.
|
||
</behavior>
|
||
<action>
|
||
**ZIP (per D-17).** New `zip-expand.ts`: `isZip(buffer)` by magic bytes; `expandZip(buffer, zipName, file, limits = ZIP_LIMITS)` → `{ blobs: { path, buffer }[], ignored: IgnoredEntry[] }` following D-17 exactly — all per-entry and total checks on the adm-zip headers before the first `getData()`; header comment with the rules and the note that adm-zip bounds inflation to the declared size. Write `zip-expand.spec.ts` first.
|
||
|
||
**ZIP and PKCS#7 slots (per D-08, D-16).** Fill the two slots of `cert-model.ts`: ZIP by magic bytes (not by extension) → `expandZip`, a ZIP inside a ZIP → nestedZip, the entries' blobs go through the same pipeline; PKCS#7 PEM (labels PKCS7 and CMS) and DER (signedData OID 1.2.840.113549.1.7.2) via the forge ASN.1 walk of D-16, each certificate's DER handed to `certItemFromDer`. Extend `cert-analyze.ts` for ZIP sources. Extend the specs first.
|
||
|
||
**Web (per D-01, D-06, D-11, D-23).** `working-set.ts` + `use-cert-workspace.ts`: `addText(text)`. `FilesTab.tsx` per behavior (ZIP grouping by contained path, reason texts, paste area). New `components/ItemCard.tsx` and `components/AnalyzeTab.tsx` (layout, badge colours, UTC date formatting and explanation texts of the old OverviewTab from history, adapted to the new items and to EC; Task 4 adds key and CSR cards). New `components/SplitTab.tsx` per behavior (fflate pattern of the old SplitTab from history). `page.tsx`: tabs analyze and split. Write `AnalyzeTab.test.tsx`, `SplitTab.test.tsx` and the extended FilesTab, working-set and page tests first. Messages de AND en (rules as in Task 1).
|
||
|
||
**Live e2e (per D-13, D-17).** Add section zip (after fullchain in `all`): build a vendor ZIP in `$E2E_TMP` with `python3 -I` (ec-leaf as `ServerCertificate.crt`, ec-inter, ec-root, `__MACOSX/._x`, an inner ZIP, `readme.txt`); POST analyze with rsa-leaf.pem, rsa-inter.pem and the ZIP → 200, five distinct certificates, two chains, ignored contains nestedZip and unknown for readme.txt, no `__MACOSX` path anywhere; the same ZIP uploaded as `bundle.dat` → the same certificates; `rsa-chain.p7c` and `ec-chain.p7b` → three certificates each; fullchain built from the EC certificates taken out of the ZIP analysis → two blocks. Print `e2e cert zip ok`.
|
||
|
||
**Rebuild, run, commit.** `docker compose up -d --build api web`, wait for /health, run the verify chain. Commit `feat(cert-manager): Hersteller-ZIP, PKCS#7, eingefügter Text, Analysieren und Aufteilen` with exactly the files of this task. Do not push. Append „## Task 3“ to the SUMMARY.
|
||
</action>
|
||
<verify>
|
||
<automated>test -f apps/api/src/cert-manager/zip-expand.spec.ts && test -f "apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx" && test -f "apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.test.tsx" && pnpm --filter @tessera/api exec vitest run src/cert-manager src/module-registry && pnpm --filter @tessera/web exec vitest run modules/cert-manager src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager" && pnpm exec biome check apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager/components" "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/working-set.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts" "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts" "apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx" apps/web/src/messages/de.json apps/web/src/messages/en.json && node .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs 40 && test -z "$(grep -rlE 'certificateFromPem|certificateFromAsn1|certificationRequestFromAsn1|messageFromPem|messageFromAsn1' apps/api/src/cert-manager --include=*.ts | grep -v '\.spec\.ts$')" && docker compose ps --status running --services | grep -qx api && bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all && echo "task3 ok"</automated>
|
||
<fails_when>non-zero exit of the chain and the last line `task3 ok` missing; the visible signal is one of: a `test -f`/`grep` gate stopping the chain without tool output, vitest printing `FAIL` or a `failed` count in its `Test Files` line, tsc printing `error TS`, biome printing `Found` with errors, check-cert-messages printing `key mismatch`, `too few keys`, `title missing` or `bad text`, or e2e-cert.sh printing `E2E FAIL:`</fails_when>
|
||
</verify>
|
||
<done>A vendor ZIP (also without the .zip name), PKCS#7 in PEM and DER for RSA and EC and pasted PEM text join the working set; junk is skipped silently, nested, encrypted, oversized and suspicious entries are named with their reason; Analysieren shows chains and every certificate in detail, Aufteilen downloads every part or all as ZIP; specs, tsc, biome and the live files, fullchain and zip sections are green on the rebuilt stack; one commit on main, not pushed.</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="true">
|
||
<name>Task 4: Private keys, PFX and CSR recognised in every common form, a password per file, keys and CSRs matched to their certificates</name>
|
||
<files>apps/api/src/cert-manager/cert-keys.ts, apps/api/src/cert-manager/cert-keys.spec.ts, apps/api/src/cert-manager/cert-pkcs12.ts, apps/api/src/cert-manager/cert-pkcs12.spec.ts, apps/api/src/cert-manager/cert-csr.ts, apps/api/src/cert-manager/cert-csr.spec.ts, apps/api/src/cert-manager/cert-types.ts, apps/api/src/cert-manager/cert-model.ts, apps/api/src/cert-manager/cert-model.spec.ts, apps/api/src/cert-manager/cert-chain.ts, apps/api/src/cert-manager/cert-chain.spec.ts, apps/api/src/cert-manager/cert-analyze.ts, apps/api/src/cert-manager/cert-analyze.spec.ts, apps/api/src/cert-manager/cert-manager.controller.ts, apps/api/src/cert-manager/cert-manager.controller.spec.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts, apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts, apps/web/src/app/(portal)/modules/cert-manager/components/PasswordInput.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/ItemCard.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh</files>
|
||
<read_first>apps/api/src/cert-manager/cert-types.ts, cert-model.ts, cert-chain.ts, cert-analyze.ts, cert-manager.controller.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.ts, use-cert-workspace.ts, components/FilesTab.tsx, components/ItemCard.tsx, components/AnalyzeTab.tsx; RESEARCH.md sections on PKCS#12 EC bags (bag.cert null, bag.key false), key lock detection, traditional encryption and per-file passwords</read_first>
|
||
<precondition>Task 3 is committed (`git log --oneline --grep='Hersteller-ZIP' | grep -q .`) and `e2e-cert.sh all` passes on the running stack.</precondition>
|
||
<behavior>
|
||
- Keys (cert-keys): `rsa-leaf-key.pem`, `rsa-leaf-key-pkcs1.pem`, `rsa-leaf-key-pkcs8.der`, `rsa-leaf-key-pkcs1.der`, `ec-leaf-key.pem`, `ec-leaf-key-sec1.pem` and `ec-leaf-key-sec1.der` → one KeyItem each with the same id per key pair (RSA 2048 / EC P-256), wasEncrypted false, pem starting with `-----BEGIN PRIVATE KEY-----`; `rsa-leaf-key-enc-pkcs8.pem`, `rsa-leaf-key-enc-trad.pem`, `ec-leaf-key-enc-pkcs8.pem` (PBE-SHA1-3DES), `ec-leaf-key-enc-trad.pem` and `ec-leaf-key-enc-pkcs8.der` with password `Test-Pass-123` → the same ids with wasEncrypted true; without a password → locked { container 'privateKey', reason 'passwordNeeded' }; with `falsch` → reason 'passwordWrong'; the analysis JSON never contains the password.
|
||
- PKCS#12 reading (cert-pkcs12 `readPkcs12`): `rsa-modern.pfx`, `rsa-compat.pfx`, `rsa-legacy.pfx`, `ec-modern.pfx`, `ec-compat.pfx` with the password → leaf, inter and root certificates (the EC leaf included although forge leaves its bag.cert null) and the key (EC included although forge leaves bag.key false); `rsa-nopass.pfx` opens without password; `rsa-modern.bin` is recognised by content; no password → locked { container 'pkcs12', reason 'passwordNeeded' }, wrong password → reason 'passwordWrong'; a PFX inside a ZIP is locked with its ZIP path and unlocked by the password of the ZIP's entry; a password typed for file 1 also unlocks file 2 when it is the same; at most 10 distinct passwords are tried.
|
||
- CSR (cert-csr): `rsa-leaf.csr`, `rsa-leaf.csr.der`, `ec-leaf.csr`, `ec-leaf.csr.der` → CsrItem with cn, organization, san from the extensionRequest, keyType and size/curve; PEM and DER of the same request share one id.
|
||
- Matching and analyze: the full set (rsa-leaf, rsa-inter, rsa-root, rsa-leaf-key-enc-trad.pem with password, rsa-leaf.csr, ec-compat.pfx with password, ec-leaf.csr.der) → rsa-leaf.keyId = the RSA key, the key's certIds [rsa-leaf], rsa-leaf.csrIds [the RSA CSR], the CSR's keyId and certIds set; the EC leaf from the PFX carries its key; the `passwords` multipart field (JSON array aligned with the files) reaches the parser; a `passwords` value that is not a JSON array of strings, longer than 30 or with an entry over 1024 characters → 400 invalidInput.
|
||
- Web: FilesTab shows a locked entry with „„{path}“ ist mit einem Passwort geschützt.“, a PasswordInput (show/hide button with translated aria-labels) and „Entsperren“ (also Enter) → setPassword and re-analysis with the password in the `passwords` field; passwordWrong shows „Das Passwort passt nicht.“; a locked PFX while the set already holds an end-entity certificate with a matching key appears as a calm muted note with „Trotzdem entsperren“ (behaviour of 1.1.0 kept); keys and CSRs are listed per entry like certificates. ItemCard: certificate cards show „Passender Schlüssel vorhanden“ when keyId is set; key cards with type, size or curve, „war verschlüsselt“ and the certificate they belong to; CSR cards with subject, SAN, key and matches. AnalyzeTab adds the locked summary. No rendered text contains a password.
|
||
</behavior>
|
||
<action>
|
||
**Keys (per D-05, D-11, D-16).** New `cert-keys.ts`: `detectKeyPem(block, passwords)` and `detectKeyDer(buffer, passwords)` on `createPrivateKey` only (PEM labels of D-16; DER types pkcs8, pkcs1, sec1, then encrypted pkcs8), trying the file's own password first and then the other distinct passwords (max 10); returns a KeyItem (pem = `export({ type: 'pkcs8', format: 'pem' })`, details from `asymmetricKeyType`/`asymmetricKeyDetails`), a LockedEntry (passwordNeeded when no own password was given, passwordWrong otherwise) or null; RSA-PSS, Ed25519 and other types readable by Node become KeyItems, unreadable key structures → ignored unsupportedKey. Never log a password or key. `exportKey` follows in Task 5. Write `cert-keys.spec.ts` first.
|
||
|
||
**PKCS#12 reading (per D-20).** New `cert-pkcs12.ts` with `readPkcs12(der, passwords)` → `{ certDers, keyObjects }` or locked per D-20 (detection only for a top-level SEQUENCE whose first element is INTEGER 3; own password, then '', then the other passwords). `writePkcs12` follows in Task 5. Write `cert-pkcs12.spec.ts` first.
|
||
|
||
**CSR (per D-05, D-16).** New `cert-csr.ts`: `csrItemFromDer(der, source)` walking CertificationRequestInfo with `forge.asn1.fromDer` — subject attributes via `forge.pki.RDNAttributesAsArray` (CN, O), SPKI DER → `createPublicKey({ format: 'der', type: 'spki' })` for type and size, SAN dNSName and iPAddress from the extensionRequest attribute (OID 1.2.840.113549.1.9.14, extension 2.5.29.17); no signature check. Write `cert-csr.spec.ts` first.
|
||
|
||
**Slots, matching, analyze, route (per D-15, D-16, D-18, D-24).** Fill the key, PKCS#12 and CSR slots of `cert-model.ts` (PEM labels and the DER order of D-16; PKCS#12 certificates and keys become ordinary items with the PFX as source). Add `matchKeys(certs, keys, csrs)` to `cert-chain.ts` per D-18 (`checkPrivateKey`; SPKI DER equality for CSRs; never names or modulus strings). `analyzeWorkingSet` runs `matchKeys`, fills keyId/certIds/csrIds and reports locked entries. The controller reads the optional multipart field `passwords` (validated per behavior, never logged) and hands it over. Extend the specs first.
|
||
|
||
**Web (per D-01, D-05, D-06, D-11, D-24).** `working-set.ts` + `use-cert-workspace.ts`: `setPassword(id, password)` and the `passwords` array in `toFormData`, aligned with the files; passwords exist only in this state and the multipart body. New `components/PasswordInput.tsx` (label, value, show/hide with translated aria-labels, `autoComplete="off"`). FilesTab, ItemCard and AnalyzeTab per behavior. Tests first (FilesTab, working-set, AnalyzeTab). Messages de AND en (rules as in Task 1).
|
||
|
||
**Live e2e (per D-11, D-13).** Add section inputs (after zip in `all`): analyze the full fixture set of the behavior with a `passwords` array → 200 with the expected kinds, the RSA leaf with keyId, the EC leaf from ec-compat.pfx with keyId, the CSRs matched; `rsa-modern.pfx` without password → locked passwordNeeded, with `falsch` → passwordWrong; `rsa-legacy.pfx` and `rsa-modern.bin` with the password → certificates plus key; `passwords` set to `nope` → 400 invalidInput; `docker compose logs api --since 10m` contains neither `Test-Pass-123` nor `PRIVATE KEY`. Print `e2e cert inputs ok`.
|
||
|
||
**Rebuild, run, commit.** `docker compose up -d --build api web`, wait for /health, run the verify chain. Commit `feat(cert-manager): Schlüssel, PFX und CSR erkennen, Passwort je Datei` with exactly the files of this task. Do not push. Append „## Task 4“ to the SUMMARY.
|
||
</action>
|
||
<verify>
|
||
<automated>test -f apps/api/src/cert-manager/cert-keys.spec.ts && test -f apps/api/src/cert-manager/cert-pkcs12.spec.ts && test -f apps/api/src/cert-manager/cert-csr.spec.ts && pnpm --filter @tessera/api exec vitest run src/cert-manager src/module-registry && pnpm --filter @tessera/web exec vitest run modules/cert-manager src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager" && pnpm exec biome check apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager/components" "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/working-set.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts" "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts" "apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx" apps/web/src/messages/de.json apps/web/src/messages/en.json && node .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs 50 && test -z "$(grep -rlE 'certificateFromPem|certificateFromAsn1|certificationRequestFromAsn1|messageFromPem|messageFromAsn1' apps/api/src/cert-manager --include=*.ts | grep -v '\.spec\.ts$')" && docker compose ps --status running --services | grep -qx api && bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all && echo "task4 ok"</automated>
|
||
<fails_when>non-zero exit of the chain and the last line `task4 ok` missing; the visible signal is one of: a `test -f`/`grep` gate stopping the chain without tool output, vitest printing `FAIL` or a `failed` count in its `Test Files` line, tsc printing `error TS`, biome printing `Found` with errors, check-cert-messages printing `key mismatch`, `too few keys`, `title missing` or `bad text`, or e2e-cert.sh printing `E2E FAIL:` (among others when the api log contains a fixture password or a private key)</fails_when>
|
||
</verify>
|
||
<done>Private keys (PKCS#1, PKCS#8, SEC1; PEM and DER; plain and encrypted), PFX files (OpenSSL-3, compatible and legacy; also in a ZIP and without extension) and CSRs of RSA and EC are recognised with a password per file and matched to their certificates; locked files ask for their password; no password or key reaches the api log; specs, tsc, biome and every live section so far are green on the rebuilt stack; one commit on main, not pushed.</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="true">
|
||
<name>Task 5: Every output format — single certificate, PKCS#7, Fullchain and chain in every format, certificate plus key, PFX (Kompatibel / Modern), key and CSR exports; tab Konvertieren and the complete Zusammenführen</name>
|
||
<files>apps/api/src/cert-manager/cert-keys.ts, apps/api/src/cert-manager/cert-keys.spec.ts, apps/api/src/cert-manager/cert-pkcs12.ts, apps/api/src/cert-manager/cert-pkcs12.spec.ts, apps/api/src/cert-manager/cert-output.ts, apps/api/src/cert-manager/cert-output.spec.ts, apps/api/src/cert-manager/dto/cert-build.dto.ts, apps/api/src/cert-manager/cert-manager.controller.spec.ts, apps/web/src/app/(portal)/modules/cert-manager/actions.ts, 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/PfxOptions.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.test.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh</files>
|
||
<read_first>apps/api/src/cert-manager/cert-output.ts, cert-keys.ts, cert-pkcs12.ts, cert-model.ts (the PKCS#7 walker), dto/cert-build.dto.ts, cert-json-body.ts (caps arithmetic), apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx, components/PasswordInput.tsx; RESEARCH.md Pattern 3 (scoped forge patch for EC PFX)</read_first>
|
||
<precondition>Task 4 is committed (`git log --oneline --grep='Passwort je Datei' | grep -q .`) and `e2e-cert.sh all` passes on the running stack.</precondition>
|
||
<behavior>
|
||
- PKCS#12 writing: `writePkcs12` for RSA and EC, profile compat and modern, re-read by `readPkcs12` gives leaf first, the chain and a key that `checkPrivateKey` accepts for the leaf; walking the output ASN.1 shows the key bag algorithm OID 1.2.840.113549.1.12.1.3 for compat and 1.2.840.113549.1.5.13 with AES-256-CBC 2.16.840.1.101.3.4.1.42 for modern; the key bag and the leaf certificate bag carry the same localKeyId; after a successful call AND after a call that throws inside, `forge.pki.certificateToAsn1`, `privateKeyToAsn1` and `wrapRsaPrivateKey` are the original functions again.
|
||
- Keys: `exportKey` — pkcs8 without password → PRIVATE KEY, with password → ENCRYPTED PRIVATE KEY that `createPrivateKey` opens with it; traditional RSA → RSA PRIVATE KEY, EC → EC PRIVATE KEY, with password the PEM carries `Proc-Type: 4,ENCRYPTED`; pkcs8-der with password → encrypted DER that opens with it; traditional for an Ed25519 key → formatNotPossible.
|
||
- Outputs (cert-output): leaf pem `.crt` / der `.cer` (re-read by X509Certificate equal to the input) / p7b / p7c (re-read by the Task-3 PKCS#7 walker: exactly the leaf); fullchain and chain as p7b and p7c contain the path in order (root only with includeRoot) for RSA AND EC; leafKey → `<base>-bundle.pem` with leaf, intermediates and key last, includeChain false → leaf and key only, a key of another certificate → 400 keyMismatch, no key → 400 keyMissing; pfx without password → 400 passwordRequired, with password `Neu-Pass-2026` and pfxEncryption modern → `<base>.pfx` re-read by readPkcs12 with that password, without key → certificates only; key outputs `<base>.key`, `<base>.rsa.key` / `<base>.ec.key`, `<base>.key.der`; csr pem `<base>.csr` and der `<base>.csr.der`; an unknown content/format pair → 400 invalidInput.
|
||
- DTO: content IsIn every D-19 content, keyPem and csrPem MaxLength `CERT_PEM_MAX`, password MaxLength `CERT_PASSWORD_MAX`, pfxEncryption IsIn compat and modern, includeChain IsBoolean; the maximal-body test of cert-json-body.spec still passes unchanged (Task 2 already counted these fields).
|
||
- Web: ConvertTab — an item select grouped by kind and per kind the formats of D-19 (certificate: PEM, DER, PKCS#7, PKCS#7 binär; key: PKCS#8, traditionell, DER, optional „Schlüssel mit Passwort schützen“ with PasswordInput; CSR: PEM, DER), „Herunterladen“ calls buildOutput with exactly content, format, the item's pem in the right field and the password only when chosen. MergeTab — format select for Fullchain and Nur Kette (PEM, PKCS#7 .p7b, PKCS#7 binär .p7c); „Zertifikat und Schlüssel (eine PEM-Datei)“ enabled only when the head has keyId, otherwise disabled with the reason; „PFX-Datei“ with PfxOptions (password and repeat, mismatch blocks the download, encryption select default „Kompatibel (auch ältere Windows-Server)“, option „Modern (AES-256)“ with a hint that older Windows servers such as Windows Server 2016 often cannot open it and „Kompatibel“ is the safe choice) → buildOutput with content pfx, keyPem of the matching key, password and pfxEncryption. Page: tabs Dateien, Analysieren, Aufteilen, Zusammenführen, Konvertieren.
|
||
</behavior>
|
||
<action>
|
||
**PKCS#12 writing (per D-07, D-20).** Add `writePkcs12({ keyObject | null, certDers, password, profile: 'compat' | 'modern', friendlyName })` to `cert-pkcs12.ts` with the scoped patch of D-20 and a header comment explaining why it is safe and where the original functions are restored. Extend `cert-pkcs12.spec.ts` first (round trips, OIDs, localKeyId, restoration also after an injected throw).
|
||
|
||
**Key export (per D-19).** Add `exportKey(keyObject, format, password?)` to `cert-keys.ts` (cipher `aes-256-cbc`; traditional = pkcs1 for RSA, sec1 for EC, else formatNotPossible). Extend the spec first.
|
||
|
||
**Outputs (per D-02, D-07, D-19).** Extend `buildOutput` with every remaining content × format of D-19 in one switch with exhaustive checking: the hand-built PKCS#7 (forge.asn1, no forge certificate objects), the bundle, PFX via `writePkcs12` (keyPem parsed with `createPrivateKey`, `checkPrivateKey` against the leaf → keyMismatch otherwise), key and CSR exports; mime types: pem `application/x-pem-file`, der `application/pkix-cert`, p7b/p7c `application/x-pkcs7-certificates`, pfx `application/x-pkcs12`, key `application/x-pem-file` or `application/octet-stream`, csr `application/pkcs10`. Widen `BuildOutputDto` per behavior. Extend `cert-output.spec.ts` and the controller spec first.
|
||
|
||
**Web (per D-02, D-05, D-07, D-23, D-24).** `actions.ts`: the full BuildInput. New `components/PfxOptions.tsx`, new `components/ConvertTab.tsx`, MergeTab extended, `page.tsx` tab convert — all per behavior. Write `ConvertTab.test.tsx` and extend `MergeTab.test.tsx` and the page test first. Messages de AND en (rules as in Task 1).
|
||
|
||
**Live e2e (per D-07, D-11, D-13).** Add section formats (after inputs in `all`): for RSA and EC build and check with openssl — leaf der (`openssl x509 -inform DER`), fullchain p7b and p7c (`openssl pkcs7 [-inform DER] -print_certs` lists the path in order), bundle (first certificate is the leaf, `openssl pkey -pubout` of the key equals `openssl x509 -pubkey -noout` of the leaf), pfx compat (`openssl pkcs12 -info -noout -passin pass:Neu-Pass-2026` output contains `pbeWithSHA1And3-KeyTripleDES-CBC`) and pfx modern (contains `AES-256-CBC`), encrypted pkcs8 key (`openssl pkey -passin pass:… -noout` OK), traditional key (`BEGIN RSA PRIVATE KEY` / `BEGIN EC PRIVATE KEY`), csr der (`openssl req -inform DER -noout -subject`); `docker compose logs api --since 10m` contains neither `Test-Pass-123`, `Neu-Pass-2026` nor `PRIVATE KEY`. Print `e2e cert formats ok`.
|
||
|
||
**Rebuild, run, commit.** `docker compose up -d --build api web`, wait for /health, run the verify chain. Commit `feat(cert-manager): alle Ausgabeformate, Konvertieren, Bundle und PFX` with exactly the files of this task. Do not push. Append „## Task 5“ to the SUMMARY with output item 2 (the openssl outputs that prove the PFX algorithms and the P7B/P7C order).
|
||
</action>
|
||
<verify>
|
||
<automated>test -f "apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.test.tsx" && grep -q "writePkcs12" apps/api/src/cert-manager/cert-pkcs12.spec.ts && grep -q "exportKey" apps/api/src/cert-manager/cert-keys.spec.ts && pnpm --filter @tessera/api exec vitest run src/cert-manager src/module-registry && pnpm --filter @tessera/web exec vitest run modules/cert-manager src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager" && pnpm exec biome check apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager/components" "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/working-set.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts" "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts" "apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx" apps/web/src/messages/de.json apps/web/src/messages/en.json && node .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs 60 && test -z "$(grep -rlE 'certificateFromPem|certificateFromAsn1|certificationRequestFromAsn1|messageFromPem|messageFromAsn1' apps/api/src/cert-manager --include=*.ts | grep -v '\.spec\.ts$')" && docker compose ps --status running --services | grep -qx api && bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all && echo "task5 ok"</automated>
|
||
<fails_when>non-zero exit of the chain and the last line `task5 ok` missing; the visible signal is one of: a `test -f`/`grep` gate stopping the chain without tool output, vitest printing `FAIL` or a `failed` count in its `Test Files` line, tsc printing `error TS`, biome printing `Found` with errors, check-cert-messages printing `key mismatch`, `too few keys`, `title missing` or `bad text`, or e2e-cert.sh printing `E2E FAIL:` (among others when openssl cannot read an output or the PFX info lacks the expected algorithm)</fails_when>
|
||
</verify>
|
||
<done>Every output of D-19 is produced for RSA and EC and read back by openssl in the e2e (both PFX profiles with the expected algorithm, P7B/P7C in order); Konvertieren converts any single item, Zusammenführen offers every chain format, certificate plus key and PFX; no password or key appears in the api log; specs, tsc, biome and every live section so far are green on the rebuilt stack; one commit on main, not pushed.</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="true">
|
||
<name>Task 6: Templates for target systems with one click and a configuration snippet; module version 1.2.0 with changelogs and guides for everything built so far</name>
|
||
<files>apps/api/src/cert-manager/cert-templates.ts, apps/api/src/cert-manager/cert-templates.spec.ts, apps/api/src/cert-manager/cert-types.ts, apps/api/src/cert-manager/cert-output.ts, apps/api/src/cert-manager/cert-output.spec.ts, apps/api/src/cert-manager/dto/cert-build.dto.ts, apps/api/src/cert-manager/cert-manager.changelog.ts, apps/web/src/app/(portal)/modules/cert-manager/actions.ts, 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/TemplatesTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/TemplatesTab.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-betrieb.md, docs/anleitung-entwicklung.md, .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh</files>
|
||
<read_first>apps/api/src/cert-manager/cert-output.ts, cert-keys.ts, cert-pkcs12.ts, dto/cert-build.dto.ts, cert-manager.changelog.ts, apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx and PfxOptions.tsx, docs/anleitung-entwicklung.md „### Modulversion und Modul-Changelog pflegen“ (~line 299), the head of CHANGELOG.md, docs/anleitung-anwender.md lines 150–161, docs/anleitung-betrieb.md „### Dateien (Nextcloud)“ (line 186) and „### Fehlerbilder“ (~line 755), docs/anleitung-entwicklung.md „## Konventionen und Fallstricke“ (line 693, last paragraph ~line 800); RESEARCH.md templates table and A2–A4</read_first>
|
||
<precondition>Task 5 is committed (`git log --oneline --grep='alle Ausgabeformate' | grep -q .`) and `e2e-cert.sh all` passes on the running stack.</precondition>
|
||
<behavior>
|
||
- Templates (cert-templates via buildOutput content template): nginx and apache for RSA and EC → files `fullchain.pem` (leaf and intermediates in order) and `privkey.pem` (PKCS#8 matching the leaf) plus the D-21 snippet with the base name; apache-legacy → `cert.pem`, `chain.pem`, `privkey.pem`; iis → one `<base>.pfx` that readPkcs12 opens with the password and whose key bag uses the compat OID (no pfxEncryption sent); npm → `certificate.pem` (only the leaf), `intermediate.pem` (only intermediates, root with includeRoot), `privkey.pem` starting with `-----BEGIN RSA PRIVATE KEY-----` for RSA and `-----BEGIN EC PRIVATE KEY-----` for EC, snippet null; haproxy → one `<base>.pem` with leaf, intermediates and key in this order; tomcat → one `<base>.p12` with friendlyName = base name and a snippet that contains `IHR-PASSWORT` and never the password sent; without key → 400 templateNeedsKey; iis/tomcat without password → 400 passwordRequired; an unknown template id → 400 invalidInput.
|
||
- TemplatesTab: head choice, „Root-Zertifikat mitnehmen“ (off), seven template cards (Nginx, Apache 2.4.8 und neuer, Apache älter als 2.4.8, Windows / IIS, Nginx Proxy Manager, HAProxy, Tomcat / Java) with what each delivers; without matching key all cards disabled with the reason; IIS and Tomcat show PfxOptions with „Kompatibel“ preselected; a multi-file template downloads one ZIP `<base>-<id>.zip` (fflate) containing the files and `ANLEITUNG.txt` with the steps and the snippet; single-file templates download the file directly; afterwards the snippet is shown with „Kopieren“ (navigator.clipboard.writeText). Page: all six tabs in the D-23 order.
|
||
- module-changelog.spec: cert-manager's top entry is 1.2.0 dated 2026-10-09 and the seed version equals it.
|
||
- Guides and CHANGELOG describe everything of Tasks 1–6; the missing-intermediate fetch is added by Task 7.
|
||
</behavior>
|
||
<action>
|
||
**Templates (per D-04, D-07, D-21).** New `cert-templates.ts` with `TEMPLATE_IDS` and `buildTemplate(id, ctx)` returning files and snippet per D-21, reusing the chain, `exportKey` and `writePkcs12`; wire content template plus `template` (IsIn TEMPLATE_IDS) into `buildOutput` and `BuildOutputDto`, `snippet` in BuildResult. Write `cert-templates.spec.ts` and the output case first.
|
||
|
||
**Web (per D-04, D-23).** `actions.ts`: the template fields. New `components/TemplatesTab.tsx` per behavior (per template: title, what you get, steps from messages `templates.<id>.steps`; for Nginx Proxy Manager the steps name SSL Certificates, „Add SSL Certificate“, „Custom“ and which file goes into Certificate Key, Certificate and Intermediate Certificate; English export `INSTRUCTIONS.txt`). `page.tsx`: tab templates. Write `TemplatesTab.test.tsx` and extend the page test first. Messages de AND en (rules as in Task 1).
|
||
|
||
**Version and changelogs (per D-09).** In `cert-manager.changelog.ts` add above 1.1.0 the entry version 1.2.0, date 2026-10-09 (1.1.0 unchanged) with these items (de / en, adjust wording only if the guard spec demands): new „Ein gemeinsamer Reiter „Dateien“: Laden Sie mehrere Dateien und ZIP-Dateien auf einmal hoch oder fügen Sie PEM-Text ein. Alle anderen Reiter arbeiten mit dieser Liste.“ / „One shared “Files” tab: upload several files and ZIP files at once or paste PEM text. All other tabs work with this list.“; new „„Zusammenführen“ ordnet die Kette selbst und liefert Fullchain, nur die Kette, Zertifikat mit Schlüssel oder eine PFX-Datei; das Root-Zertifikat nehmen Sie nur auf Wunsch mit.“ / „“Merge” orders the chain by itself and delivers a full chain, the chain only, certificate with key or a PFX file; the root certificate is only included on request.“; new „Alle gängigen Formate rein und raus, auch Zertifikate und Schlüssel mit elliptischen Kurven (EC) und verschlüsselte Schlüssel. PFX-Dateien wahlweise kompatibel oder modern verschlüsselt.“ / „All common formats in and out, including elliptic-curve (EC) certificates and keys and encrypted keys. PFX files with compatible or modern encryption.“; new „Vorlagen für Nginx, Apache, Windows/IIS, Nginx Proxy Manager, HAProxy und Tomcat liefern die passenden Dateien mit einem Klick.“ / „Templates for Nginx, Apache, Windows/IIS, Nginx Proxy Manager, HAProxy and Tomcat deliver the right files with one click.“; fixed „Beim Zusammenführen ersetzt eine zweite Datei nicht mehr die erste.“ / „When merging, a second file no longer replaces the first one.“ (Task 7 adds the item for the missing-intermediate fetch). In `CHANGELOG.md` under „## Unveröffentlicht“: in „### Neu“ two bullets „Zertifikat-Manager: …“ (the shared Dateien tab with ZIP and pasted text and the browser-only list; Zusammenführen with Fullchain, Nur Kette, bundle and PFX with the root checkbox, the Kompatibel/Modern choice, Vorlagen; „Modulversion 1.2.0.“), in „### Behoben“ one bullet for the replaced second file and the EC certificates and keys that were not recognised. Plain words, „Sie“, no file names.
|
||
|
||
**Guides (per D-11, D-12; everyday language, „Sie“, detailed, no tenant or licence wording).** `docs/anleitung-anwender.md` „### Zertifikat-Manager“: replace the whole section body including its old introduction sentence — the six tabs and the working-set idea, Dateien (several files, ZIPs, pasted text, limits 30 files and 10 MB, what the list shows, passwords per file, „Die Liste bleibt nur in diesem Browserfenster …“), Analysieren, Aufteilen, Zusammenführen (Fullchain, Nur Kette, Zertifikat und Schlüssel, PFX with password and Kompatibel/Modern advice, „Root-Zertifikat mitnehmen“ off by default and when you need it, what „Zwischenzertifikat fehlt“ means), Konvertieren, Vorlagen (one sentence per template: what you get and where it goes), supported formats; Task 7 adds the paragraph on „Fehlendes Zertifikat holen“. `docs/anleitung-betrieb.md`: new „### Zertifikat-Manager“ after „### Dateien (Nextcloud)“ in chapter 3 (uploads go through `/api-proxy`, one analysis sends at most 10 MB, so the `client_max_body_size` of at least `10m` from the Dateien section covers it; a single download request is at most 512 KiB; nothing is stored, no setting) and one row in „### Fehlerbilder“ (upload aborted with 413). `docs/anleitung-entwicklung.md` „## Konventionen und Fallstricke“: paragraph „**Zertifikat-Manager, Arbeitsbereich und Ketten (quick-261009-ikt):**“ (node:crypto decides, forge only for PKCS#12 and as ASN.1 tool and why; the stateless routes; chain rule checkIssued plus verify; the scoped PFX patch; ZIP limits; the per-route body limit of `build` from D-26 including the `jsonParser` name trap; fixtures in `__fixtures__` with `make-fixtures.sh`, never `.key` names). `docs/anleitung-administration.md` stays unchanged (no settings).
|
||
|
||
**Live e2e (per D-13).** Add sections templates and version (after formats in `all`). templates: for the RSA set and the EC set build every template, decode the files with `python3 -I` and check with openssl per behavior (nginx `openssl verify` of fullchain, key matches leaf; iis `openssl pkcs12 -info` contains `pbeWithSHA1And3-KeyTripleDES-CBC`; npm key headers; haproxy first block is the leaf and a key is present; tomcat readable). version: `GET $API/modules/changelog/cert-manager` → 200, first release 1.2.0 dated 2026-10-09; the catalog lists cert-manager with version 1.2.0. Print one ok line per section.
|
||
|
||
**Rebuild, run, commit.** `docker compose up -d --build api web`, wait for /health, check the api log for „Cert-Manager module seeded in registry“, run the verify chain. Commit `feat(cert-manager): Vorlagen für Zielsysteme, Modulversion 1.2.0 und Anleitungen` with exactly the files of this task. Do not push. Append „## Task 6“ to the SUMMARY.
|
||
</action>
|
||
<verify>
|
||
<automated>test -f apps/api/src/cert-manager/cert-templates.spec.ts && test -f "apps/web/src/app/(portal)/modules/cert-manager/components/TemplatesTab.test.tsx" && pnpm --filter @tessera/api exec vitest run src/cert-manager src/module-registry && pnpm --filter @tessera/web exec vitest run modules/cert-manager src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager" && pnpm exec biome check apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager/components" "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/working-set.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts" "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts" "apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx" apps/web/src/messages/de.json apps/web/src/messages/en.json && node .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs 80 && grep -q "version: '1.2.0'" apps/api/src/cert-manager/cert-manager.changelog.ts && grep -q "date: '2026-10-09'" apps/api/src/cert-manager/cert-manager.changelog.ts && awk '/^## Unveröffentlicht/{f=1;next} /^## /{f=0} f' CHANGELOG.md | grep -q "Zertifikat-Manager" && grep -q "Root-Zertifikat mitnehmen" docs/anleitung-anwender.md && ! grep -q "Ein Werkzeug rund um SSL/TLS-Zertifikate mit vier Reitern" docs/anleitung-anwender.md && grep -q "^### Zertifikat-Manager" docs/anleitung-betrieb.md && grep -q "quick-261009-ikt" docs/anleitung-entwicklung.md && test -z "$(grep -rlE 'certificateFromPem|certificateFromAsn1|certificationRequestFromAsn1|messageFromPem|messageFromAsn1' apps/api/src/cert-manager --include=*.ts | grep -v '\.spec\.ts$')" && docker compose ps --status running --services | grep -qx api && docker compose logs api 2>&1 | grep -q "Cert-Manager module seeded in registry" && bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all && echo "task6 ok"</automated>
|
||
<fails_when>non-zero exit of the chain and the last line `task6 ok` missing; the visible signal is one of: a `test -f`/`grep`/`awk` gate stopping the chain without tool output (version, date, CHANGELOG bullet or guide section missing, or the old introduction sentence still present), vitest printing `FAIL` or a `failed` count in its `Test Files` line (module-changelog.spec included), tsc printing `error TS`, biome printing `Found` with errors, check-cert-messages printing `key mismatch`, `too few keys`, `title missing` or `bad text`, or e2e-cert.sh printing `E2E FAIL:`</fails_when>
|
||
</verify>
|
||
<done>Seven target-system templates download with one click (with snippet, IIS and Tomcat compatible by default); module version 1.2.0 with its changelog, the CHANGELOG bullets and the three guides describe everything built so far; specs, tsc, biome and every live section so far incl. version are green on the rebuilt stack; one commit on main, not pushed.</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="true">
|
||
<name>Task 7: „Fehlendes Zertifikat holen“ — button-only AIA fetch through the hardened shared address guard, fetched entries marked „nachgeladen“, proven live</name>
|
||
<files>apps/api/src/common/public-url-guard.ts, apps/api/src/common/public-url-guard.spec.ts, apps/api/src/cert-manager/cert-aia.ts, apps/api/src/cert-manager/cert-aia.spec.ts, apps/api/src/cert-manager/dto/cert-fetch-issuer.dto.ts, apps/api/src/cert-manager/cert-manager.controller.ts, apps/api/src/cert-manager/cert-manager.controller.spec.ts, apps/api/src/cert-manager/cert-manager.changelog.ts, apps/web/src/app/(portal)/modules/cert-manager/actions.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts, apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts, apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.test.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/ItemCard.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-betrieb.md, docs/anleitung-entwicklung.md, .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh</files>
|
||
<read_first>apps/api/src/common/public-url-guard.ts, apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts (the loop to copy: `fetchImpl`, `isPublic`, `timeoutMs`, `discard`, abort race, streamed byte cap, one warn line), apps/api/src/cert-manager/cert-model.ts (PKCS#7 walker), cert-manager.controller.ts, apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.tsx, working-set.ts, use-cert-workspace.ts; RESEARCH.md sections on the AIA pattern, ports 80/443 (A8), the guarded lookup and the IPv6 weaknesses of the shared guard</read_first>
|
||
<precondition>Task 6 is committed (`git log --oneline --grep='Vorlagen für Zielsysteme' | grep -q .`), `e2e-cert.sh all` passes on the running stack, and the api container reaches the internet: `docker compose exec -T api node -e "fetch('http://ye2.i.lencr.org/').then(r=>console.log(r.status))"` prints 200.</precondition>
|
||
<behavior>
|
||
- Guard (new `public-url-guard.spec.ts`, exported `isPrivateIpAddress`): private/blocked → `127.0.0.1`, `10.1.2.3`, `100.64.0.1`, `169.254.169.254`, `::`, `::1`, `::ffff:127.0.0.1`, `::ffff:7f00:1`, `0:0:0:0:0:ffff:7f00:1`, `::ffff:a9fe:a9fe`, `::7f00:1` (IPv4-compatible), `64:ff9b::7f00:1`, `64:ff9b::10.0.0.1`, `64:ff9b:1::1`, `2002:7f00:1::1`, `2002:c0a8:101::1`, `fc00::1`, `fd12::1`, `fe80::1`, `fe80::1%eth0`, `febf::1`, `fec0::1`, `ff02::1`, `2001:db8::1`, `100::1`, `2001::1` (Teredo), a malformed address; public → `8.8.8.8`, `2606:4700:4700::1111`, `64:ff9b::808:808`, `2002:808:808::1`, `2a00:1450:4001:82a::200e`; `isPublicHttpUrl` with a mocked `node:dns/promises` lookup returning `{ address: '::ffff:7f00:1', family: 6 }` → false, returning 8.8.8.8 → true; ftp → false; the existing favorites and nextcloud-status specs stay green.
|
||
- AIA (cert-aia, injected `fetchImpl`, `isPublic`, `timeoutMs`): rsa-leaf with a fake answer of rsa-inter's DER → `{ host: 'pki.example.test', cn: 'Tessera Test Inter RSA', filename: 'Tessera_Test_Inter_RSA.crt' }` and the PEM verifies as issuer; an answer of `rsa-chain.p7c` → only rsa-inter is returned; a PEM text answer works; ec-inter's DER for rsa-leaf → 422 aiaNotIssuer; `aia-private-leaf.pem` with the real guard → 422 aiaInternal and fetchImpl never called; `rsa-leaf-noaki.pem` → 422 aiaMissing; a URL with port 8080 → skipped like a non-public address; a 302 to `http://10.0.0.5/x` → refused before the second request; four redirects → aiaUnreachable; content-length 300 000 → aiaTooLarge without reading; a streamed body over 256 KiB → aiaTooLarge; a hanging server → aiaUnreachable after the injected timeout; HTTP 404 → aiaUnreachable; `createGuardedLookup` with a fake resolver returning 10.0.0.1 errors, with 93.184.215.14 calls back with that address; the request headers contain no cookie and no authorization; a failure writes exactly one warn line containing host and code and no PEM text.
|
||
- Controller: handlers exactly analyze, build and fetchIssuer (`POST fetch-issuer`, http code 200); FetchIssuerDto rejects a pem over 16 384 characters and drops an extra `url` field.
|
||
- Web: ChainView gap row with aiaUrls shows „Fehlendes Zertifikat holen“ and the hint with the host of the first URL; without aiaUrls only the instruction to download it from the vendor; click → `fetchIssuer(<pem of the gap certificate>)` once, button busy and disabled meanwhile, success → `addFetched` adds an entry labelled with the returned filename, origin fetched, host, and the set is re-analysed; the same certificate twice is not added again; error codes show their texts; nothing is fetched on render or on re-analysis. FilesTab and ItemCard show „nachgeladen von {host}“ for fetched entries and their items.
|
||
- module-changelog.spec stays green with the extra 1.2.0 item.
|
||
</behavior>
|
||
<action>
|
||
**Guard hardening (per D-10).** In `apps/api/src/common/public-url-guard.ts` export `isPrivateIpAddress` and rewrite `isPrivateIpv6` on a small `expandIpv6(address)` helper (zone id stripped, `::` expanded, embedded dotted IPv4 converted, eight 16-bit groups): blocked = unspecified and loopback, all of `::/96` (IPv4-compatible) and `::ffff:0:0/96` judged by the embedded IPv4 through `isPrivateIpv4`, `64:ff9b::/96` judged by the embedded IPv4, `64:ff9b:1::/48` always, `2002::/16` judged by the IPv4 in groups 2–3, `2001::/32` (Teredo) always, `2001:db8::/32`, `100::/64`, `fc00::/7`, `fe80::/10`, `fec0::/10`, `ff00::/8`; anything unparsable stays blocked. Update the header comment (quick 261009-ikt, list of ranges). Write `public-url-guard.spec.ts` first per behavior (vi.mock of `node:dns/promises` for the lookup cases). Run the favorites and nextcloud-status specs too.
|
||
|
||
**AIA fetch (per D-03, D-22, D-24).** New `cert-aia.ts` with `fetchIssuer(pem, opts = {})` and `createGuardedLookup(resolve = dns.lookup)` exactly per D-22 (header comment listing every protection and the accepted remainder: any authenticated module user can make the API send one GET to a public address named in a certificate they upload; the answer is only returned when it is a verified issuer certificate). The real path passes `dispatcher: new Agent({ connect: { lookup: createGuardedLookup() } })` from undici. New `dto/cert-fetch-issuer.dto.ts` and `@Post('fetch-issuer')` with `@HttpCode(200)` in the controller (header route list updated). Write `cert-aia.spec.ts` first per behavior and extend the controller spec.
|
||
|
||
**Web (per D-01, D-03, D-11).** `actions.ts`: `fetchIssuer(pem)`. `working-set.ts`/`use-cert-workspace.ts`: `addFetched({ filename, pem, host })` (origin fetched; the same certificate twice is not added again). `ChainView.tsx`: gap row with the button per behavior (props `onFetched`, busy per gap, error text); MergeTab and AnalyzeTab pass the workspace's `addFetched`. `FilesTab.tsx` and `ItemCard.tsx`: the „nachgeladen von {host}“ marker. Tests first: `ChainView.test.tsx`, extended FilesTab and working-set tests. Messages de AND en (rules as in Task 1).
|
||
|
||
**Changelogs and guides for this feature (per D-03, D-09, D-10, D-12).** `cert-manager.changelog.ts`: add to the 1.2.0 entry the item new „Fehlt ein Zwischenzertifikat, holt „Fehlendes Zertifikat holen“ es auf Knopfdruck beim Aussteller.“ / „If an intermediate certificate is missing, “Fetch missing certificate” gets it from the issuer at the click of a button.“. `CHANGELOG.md` under „## Unveröffentlicht“: extend the Zusammenführen bullet with „Fehlendes Zertifikat holen“ only on click, and add one bullet „Sicherheit: …“ in plain words that the protection against fetching internal addresses (favourite icons, Nextcloud-Status logos and the new certificate fetch) now also recognises hidden spellings of internal IPv6 addresses. Anwenderanleitung: paragraph „Fehlendes Zertifikat holen“ (only on click, Tessera asks the issuer on the internet, the entry is marked nachgeladen, a second click may be needed for the next level). Betriebsanleitung „### Zertifikat-Manager“: the api container needs outbound http/https on ports 80 and 443 to the certificate issuers' addresses, otherwise the button reports „nicht erreichbar“; one row in „### Fehlerbilder“ (fetch reports nicht erreichbar). Entwicklungsanleitung paragraph of Task 6: the AIA guard with guarded lookup and the hardened shared guard.
|
||
|
||
**Live e2e (per D-03, D-13).** Add section aia (last in `all`): `aia-private-leaf.pem` → 422 aiaInternal; `rsa-leaf-noaki.pem` → 422 aiaMissing; a body `{ "pem": …, "url": "http://127.0.0.1/" }` behaves like without url; live — unless `CERT_E2E_OFFLINE=1` is set — fetch the letsencrypt.org leaf with `openssl s_client -connect letsencrypt.org:443 -servername letsencrypt.org` into `$E2E_TMP`, analyze it → gap afterLeaf with an http aia URL, POST fetch-issuer → 200, host ends with `lencr.org`, `openssl verify -partial_chain -trusted <fetched> <leaf>` OK, analyze leaf plus fetched → path of two with gap afterCa; `docker compose logs api --since 10m` contains no `BEGIN CERTIFICATE`. Print `e2e cert aia ok`.
|
||
|
||
**Rebuild, run, commit.** `docker compose up -d --build api web`, wait for /health, run the verify chain. Commit `feat(cert-manager): Fehlendes Zertifikat holen, gehärteter Adressschutz` with exactly the files of this task. Do not push. Append „## Task 7“ to the SUMMARY with output item 3 (live AIA result: host, fetched CN, what the next level showed — or why `CERT_E2E_OFFLINE=1` had to be used).
|
||
</action>
|
||
<verify>
|
||
<automated>test -f apps/api/src/common/public-url-guard.spec.ts && test -f apps/api/src/cert-manager/cert-aia.spec.ts && test -f "apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.test.tsx" && pnpm --filter @tessera/api exec vitest run src/cert-manager src/module-registry src/common src/favorites src/nextcloud-status && pnpm --filter @tessera/web exec vitest run modules/cert-manager src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/api/src/cert-manager apps/api/src/common "apps/web/src/app/(portal)/modules/cert-manager" && pnpm exec biome check apps/api/src/cert-manager apps/api/src/common/public-url-guard.ts apps/api/src/common/public-url-guard.spec.ts "apps/web/src/app/(portal)/modules/cert-manager/components" "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/working-set.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts" "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts" "apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx" apps/web/src/messages/de.json apps/web/src/messages/en.json && node .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs 85 && awk '/^## Unveröffentlicht/{f=1;next} /^## /{f=0} f' CHANGELOG.md | grep -q "IPv6" && grep -q "Fehlendes Zertifikat holen" docs/anleitung-anwender.md && grep -q "Fehlendes Zertifikat holen" apps/api/src/cert-manager/cert-manager.changelog.ts && test -z "$(grep -rlE 'certificateFromPem|certificateFromAsn1|certificationRequestFromAsn1|messageFromPem|messageFromAsn1' apps/api/src/cert-manager --include=*.ts | grep -v '\.spec\.ts$')" && docker compose ps --status running --services | grep -qx api && bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all && echo "task7 ok"</automated>
|
||
<fails_when>non-zero exit of the chain and the last line `task7 ok` missing; the visible signal is one of: a `test -f`/`grep`/`awk` gate stopping the chain without tool output (security bullet, guide paragraph or changelog item missing), vitest printing `FAIL` or a `failed` count in its `Test Files` line (favorites and nextcloud-status included), tsc printing `error TS`, biome printing `Found` with errors, check-cert-messages printing `key mismatch`, `too few keys`, `title missing` or `bad text`, or e2e-cert.sh printing `E2E FAIL:` (among others when a private address is contacted, the live letsencrypt fetch fails or the api log contains a certificate)</fails_when>
|
||
</verify>
|
||
<done>„Fehlendes Zertifikat holen“ fetches the verified issuer only on click through the hardened guard (proven live with letsencrypt.org) and marks it nachgeladen; the shared guard blocks every hidden IPv6 spelling of internal addresses with the old users' specs green; changelog item, CHANGELOG security bullet and guide paragraphs are in place; specs, tsc, biome and every live section are green on the rebuilt stack; one commit on main, not pushed.</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="false">
|
||
<name>Task 8: Final gates on the rebuilt stack, browser proof in dark and light mode with design review, todo closed</name>
|
||
<files>.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh (only if a check needs fixing), .planning/todos/pending/2026-10-09-cert-manager-mehrere-dateien-zip-fullchain.md (moved to .planning/todos/completed/), module web files under apps/web/src/app/(portal)/modules/cert-manager/ and apps/web/src/messages/{de,en}.json only where the design review finds something (each behaviour change with a test), docs/anleitung-anwender.md only where a label in the guide differs from the real page</files>
|
||
<read_first>apps/web/src/app/(portal)/modules/cert-manager/page.tsx and the components it renders (to know the labels), docs/anleitung-anwender.md section „### Zertifikat-Manager“, the context facts on Playwright (dark mode via the theme button, never judge by fetch from inside the page)</read_first>
|
||
<precondition>Task 7 is committed (`git log --oneline --grep='Fehlendes Zertifikat holen' | grep -q .`) and `e2e-cert.sh all` passes on the running stack including the live aia part.</precondition>
|
||
<behavior>
|
||
- Full api and web suites, both tsc runs, biome and `e2e-cert.sh all` are green on a freshly rebuilt stack; the api log shows the seed line.
|
||
- Browser: two separate selections both stay listed (the user's bug), ZIP and PFX join the list, the PFX unlocks, Fullchain downloads with two certificates when the root is unticked, a template downloads, the gap shows „Zwischenzertifikat fehlt“ with the button, the click adds the YE2 entry „nachgeladen von …“, after a reload the list is empty and the note is visible; eight dark and four light screenshots exist and were reviewed against D-23.
|
||
- Every label quoted in the Anwenderanleitung matches the page.
|
||
</behavior>
|
||
<action>
|
||
**Final gates.** `docker compose up -d --build api web`, wait for /health, check the seed line „Cert-Manager module seeded in registry“ in the api log, run the full `pnpm --filter @tessera/api test` and `pnpm --filter @tessera/web test`, both tsc, biome per the verify and `e2e-cert.sh all`.
|
||
|
||
**Browser proof (per D-13, D-23).** With Playwright MCP at http://localhost:3000 as admin/admin123. To keep this run small, take screenshots with a file name and request a page snapshot only when you need element references. Prepare in the session scratchpad a folder with copies of the fixtures: `server.crt` (rsa-leaf), `intermediate.crt` (rsa-inter), a vendor ZIP `zertifikat-paket.zip` (ec-leaf as ServerCertificate.crt, ec-inter, ec-root, ec-leaf-key.pem as `server.key`, a `__MACOSX` entry), `rsa-compat.pfx`, and `letsencrypt-leaf.pem` from `openssl s_client`. Switch to dark mode with the theme button and capture under `.playwright-mcp/cert-manager/`: select `server.crt`, then in a second selection `intermediate.crt` — both stay listed (the user's bug) — then the ZIP and the PFX, unlock the PFX with its password: `ikt-dark-files.png`; Analysieren: `ikt-dark-analyze.png`; Zusammenführen for the EC leaf with the root unticked, download the Fullchain and check the downloaded file holds two certificates (openssl on the saved file), open the PFX options: `ikt-dark-merge.png`; Konvertieren with the key to traditional with password: `ikt-dark-convert.png`; Vorlagen, download Nginx and Windows / IIS, snippet visible: `ikt-dark-templates.png`; „Alle entfernen“, add `letsencrypt-leaf.pem`, Zusammenführen shows „Zwischenzertifikat fehlt“ with the button: `ikt-dark-gap.png`; click it, the YE2 entry appears „nachgeladen von ye2.i.lencr.org“ (or the host the certificate names) and the next level shows its own calm note and button: `ikt-dark-fetched.png`; Dateien at 390×844: `ikt-dark-mobile.png`; reload the page and confirm the list is empty and the note visible. Then light mode: `ikt-light-files.png`, `ikt-light-merge.png`, `ikt-light-templates.png`, `ikt-light-gap.png`. Review every screenshot against D-23 (calm dense list, readable badges and warnings in both modes, visible focus, no ALL-CAPS labels, no arrow characters, no middle dots); fix findings in the module's web files with a test where the behaviour changes, rebuild web and re-shoot. Compare the labels quoted in the Anwenderanleitung with the page and correct the guide where they differ.
|
||
|
||
**Todo and commit.** `git mv` `.planning/todos/pending/2026-10-09-cert-manager-mehrere-dateien-zip-fullchain.md` to `.planning/todos/completed/` if it is still pending. Commit `feat(cert-manager): Browser-Nachweis und Abschluss` with exactly the files of this task (the todo move plus any review fixes). Do not push, do not deploy. Append „## Task 8“ to the SUMMARY with output items 4 to 6.
|
||
</action>
|
||
<verify>
|
||
<automated>pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/api/src/cert-manager apps/api/src/common "apps/web/src/app/(portal)/modules/cert-manager" && pnpm exec biome check apps/api/src/cert-manager apps/api/src/main.ts apps/api/src/common/public-url-guard.ts apps/api/src/common/public-url-guard.spec.ts "apps/web/src/app/(portal)/modules/cert-manager/components" "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/working-set.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts" "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts" "apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx" apps/web/src/messages/de.json apps/web/src/messages/en.json && node .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs 85 && test ! -e .planning/todos/pending/2026-10-09-cert-manager-mehrere-dateien-zip-fullchain.md && test -e .planning/todos/completed/2026-10-09-cert-manager-mehrere-dateien-zip-fullchain.md && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web && docker compose logs api 2>&1 | grep -q "Cert-Manager module seeded in registry" && bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all && test "$(ls .playwright-mcp/cert-manager/ikt-dark-*.png 2>/dev/null | wc -l)" -ge 8 && test "$(ls .playwright-mcp/cert-manager/ikt-light-*.png 2>/dev/null | wc -l)" -ge 4 && echo "task8 ok"</automated>
|
||
<fails_when>non-zero exit of the chain and the last line `task8 ok` missing; the visible signal is one of: vitest printing `FAIL` or a `failed` count in either full suite, tsc printing `error TS`, biome printing `Found` with errors, check-cert-messages printing `key mismatch`, `too few keys`, `title missing` or `bad text`, the todo still under pending, the seed line absent from the api log, e2e-cert.sh printing `E2E FAIL:`, or fewer than eight dark or four light screenshots</fails_when>
|
||
<human-check>After the user's own pull on alpha (the user deploys, not Claude): upload a real vendor ZIP in „Dateien“ and download the Fullchain; add a certificate in Nginx Proxy Manager with the „Nginx Proxy Manager“ template (research A2: field names and the RSA PRIVATE KEY header are assumptions to confirm there); import the „Windows / IIS“ PFX on a Windows server (compatible profile) and, if wanted, the „Modern“ PFX on a current Windows to see which systems accept it (research A1); check that „Fehlendes Zertifikat holen“ reaches the issuer from the alpha server (outbound http allowed).</human-check>
|
||
</verify>
|
||
<done>Full api and web suites, both tsc, biome and every e2e section are green on the rebuilt stack; eight dark and four light screenshots prove the flow (two selections kept, ZIP and PFX, Fullchain without root, template, gap and fetch, empty after reload) and were reviewed; guide labels match the page; todo moved; one commit on main, not pushed.</done>
|
||
</task>
|
||
|
||
</tasks>
|
||
|
||
<threat_model>
|
||
## Trust Boundaries
|
||
|
||
| Boundary | Description |
|
||
|----------|-------------|
|
||
| browser → API (`/modules/cert-manager/analyze`, `build`, `fetch-issuer`) | untrusted caller with a valid session; files, ZIPs, PEM text, passwords, chosen formats and every PEM sent back are untrusted |
|
||
| uploaded containers → parsers | ZIP, ASN.1, PKCS#7, PKCS#12, keys and CSRs from unknown vendors or attackers; parsers run in the API process |
|
||
| API → internet (AIA caIssuers fetch) | outbound GET to an address named inside an uploaded certificate; answer untrusted |
|
||
| API → browser | analysis answers carry unencrypted private keys of the user's own upload (needed for stateless build) |
|
||
| repository fixtures | committed test-only private keys |
|
||
|
||
## STRIDE Threat Register
|
||
|
||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||
| T-ikt-01 | Denial of Service | ZIP expansion (zip bomb, many entries, nesting) | high | mitigate | D-17: magic-byte detection, ≤ 100 entries, ≤ 1 MiB declared per entry, ratio ≤ 100, total ≤ 20 MiB checked before any inflate, one level only, encrypted entries skipped; adm-zip bounds inflation to the declared size; specs with injected limits |
|
||
| T-ikt-02 | Denial of Service | multipart upload size and build body | medium | mitigate | 30 files × 5 MiB (multer), total ≤ 20 MiB → 413 `tooLarge`, web refuses over 10 MiB before upload; `build` has its own JSON limit of 512 KiB just above the DTO maximum (≈ 384 kB) with a coded 413 beyond (D-26), every other route keeps 100 kB; e2e checks 380 kB → 400 with code, over 600 KiB → 413 tooLarge, login with 150 kB → 413 |
|
||
| T-ikt-17 | Denial of Service | build body parser registration in `main.ts` | medium | mitigate | the wrapper is named `certBuildJsonBody`, never `jsonParser`, because Nest would otherwise skip its global JSON parser for every route (D-26); the spec asserts the name, the JSON login in every e2e setup proves the global parser still runs |
|
||
| T-ikt-03 | Denial of Service | malformed ASN.1 / PEM / PKCS#12 | medium | mitigate | every detector in try/catch, bad blobs become `ignored`, never a 500; specs with random, truncated and broken input; PKCS#12 only for a SEQUENCE starting with INTEGER 3; at most 10 distinct passwords tried |
|
||
| T-ikt-04 | Tampering / SSRF | AIA fetch | high | mitigate | D-22: URL derived on the server from the uploaded certificate (DTO accepts only `pem`), http/https, default ports, no credentials, `isPublicHttpUrl` before the first request and every redirect (max 3), guarded connect lookup against DNS rebinding, 8 s, 256 KiB, no cookies or auth headers; specs per case; e2e proves 127.0.0.1 and extra `url` field are refused |
|
||
| T-ikt-05 | Tampering / SSRF | shared guard bypass via IPv6 spellings | high | mitigate | D-10 hardening with full IPv6 expansion (hex IPv4-mapped, IPv4-compatible, NAT64 incl. local-use, 6to4, Teredo, link/site-local, documentation, discard, zone ids) and a new spec; favorites and nextcloud-status specs re-run |
|
||
| T-ikt-06 | Spoofing | fetched certificate injected as issuer | medium | mitigate | only certificates with `checkIssued` AND `verify` against the incomplete certificate are returned; entry marked „nachgeladen von {host}“ |
|
||
| T-ikt-07 | Spoofing | wrong chain order or decoy CA from the browser | medium | mitigate | `build` re-runs `buildChains` on every call; a same-name CA with another key fails the signature check (spec with the decoy fixture); certificates outside the primary chain are dropped |
|
||
| T-ikt-08 | Information Disclosure | private keys and passwords | high | mitigate | D-11: nothing persisted, working set only in browser memory, passwords only in multipart/JSON bodies (never URLs), no logger call with bodies (request-log logs path and status only), errors carry codes only; e2e greps the api log for passwords, `PRIVATE KEY` and `BEGIN CERTIFICATE` |
|
||
| T-ikt-09 | Information Disclosure | AIA failure logging | low | mitigate | one warn line with host and code only, never PEM or URL path; spec asserts it |
|
||
| T-ikt-10 | Tampering | scoped forge patch in PFX writing | medium | mitigate | synchronous single call, originals restored in `finally`; spec asserts restoration after success and after an injected throw |
|
||
| T-ikt-11 | Elevation of Privilege | route access | medium | mitigate | class `@UseModule('cert-manager')` plus global JwtAuthGuard/TenantGuard as before; controller spec checks class metadata and the exact handler list; old routes removed (e2e: parse → 404) |
|
||
| T-ikt-12 | Tampering | file and friendly names from uploads | low | mitigate | names used for display only, never written to disk, control characters removed, max 255; download names and PFX friendlyName through `safeBaseName` |
|
||
| T-ikt-13 | Tampering (XSS) | subject strings, SANs, snippets rendered in the browser | low | mitigate | React text only, no `dangerouslySetInnerHTML`; snippet in a `<pre>` as text; clipboard gets plain text |
|
||
| T-ikt-14 | Denial of Service / Repudiation | AIA as a blind request trigger | low | accept | authenticated module users only, one GET per click to a public address on 80/443, answer only returned when it is a verified issuer certificate; noted in the header comment |
|
||
| T-ikt-15 | Information Disclosure | weak PFX encryption (3DES default) | low | accept | needed for older Windows servers (user decision D-07), „Modern (AES-256)“ selectable, guide explains the choice |
|
||
| T-ikt-16 | Information Disclosure | committed test private keys | low | accept | test-only PKI generated for this repo, CA keys never committed, README marks the folder for a future secret-scanner allow-list |
|
||
| T-ikt-SC | Tampering | npm/pip/cargo installs | low | accept | no new packages (research audit: all libraries already installed; adm-zip flagged SUS only for a recent release date and stays unchanged, no install) |
|
||
</threat_model>
|
||
|
||
<verification>
|
||
- Each task's `<automated>` chain passes and its commit exists. Every chain ends with `e2e-cert.sh all`, so each task re-proves every earlier live section on the rebuilt stack: Task 1 the working set, Task 2 Fullchain and the body limit, Task 3 ZIP and PKCS#7, Task 4 keys, PFX and CSR inputs, Task 5 every output with openssl round trips, Task 6 templates and the version, Task 7 the live AIA fetch; Task 8 reruns the full suites and adds the browser proof.
|
||
- After every task the committed state is usable on its own: the page shows only the tabs built so far and no web code calls a route that does not exist (the old routes and the old web components leave together in Task 1).
|
||
- Multi-source coverage audit:
|
||
|
||
| Source item | Covered by |
|
||
|-------------|------------|
|
||
| GOAL / todo 1: merging accepts only one file, second overwrites first | Task 1 (append-only working set, FilesTab regression test), Task 8 (browser proof with two selections) |
|
||
| GOAL / todo 2: vendor ZIP unpacked and analysed | Task 3 (zip-expand, analyze, e2e), Task 4 (PFX and keys inside ZIPs) |
|
||
| GOAL / todo 3: choose output such as Fullchain, Tessera orders leaf, intermediates, root optional | Task 2 (chain + fullchain/chain), Task 5 (bundle, PFX, formats) |
|
||
| D-01 one upload tab, shared set, remove buttons, recognised content, other tabs without upload | Task 1 (FilesTab, page), Task 2 (EmptyWorkspace), Tasks 3–6 (remaining tabs on the same set) |
|
||
| D-02 root selectable, default without | Task 2 (MergeTab, build includeRoot), Task 5 (bundle/PFX), Task 6 (templates) |
|
||
| D-03 gap message + button-only SSRF-safe AIA fetch, marked nachgeladen | Task 2 (gap kinds and texts), Task 7 (cert-aia, ChainView button, marker, e2e live) |
|
||
| D-04 templates Nginx, Apache, IIS, NPM + HAProxy, Tomcat; no JKS; free choice stays | Task 6 (cert-templates, TemplatesTab), Task 5 (Konvertieren, Zusammenführen) |
|
||
| D-05 all formats in and out, RSA + EC, key match, chain via issuer + key ids | Task 1 (certificates PEM/DER), Task 2 (chain), Task 3 (PKCS#7), Task 4 (keys, PKCS#12, CSR, matching), Task 5 (outputs) |
|
||
| D-06 bug fixed structurally, analysis behaviour kept | Task 1 (working set), Task 3 (Analysieren from the old Übersicht), Task 4 (calm locked-PFX note) |
|
||
| D-07 PFX compat default / modern option, IIS compat | Task 5 (writePkcs12, PfxOptions, e2e algorithms), Task 6 (IIS/Tomcat templates) |
|
||
| D-08 old routes, service, specs, DTOs removed; export replaced by build; one parser | Task 1 (deletions, controller spec, grep gates), Tasks 2–7 (forge grep gate in every chain) |
|
||
| D-09 version 1.2.0 new entry 2026-10-09, CHANGELOG Unveröffentlicht | Task 6 (entry, bullets, version e2e), Task 7 (AIA item) |
|
||
| D-10 guard hardening with tests + CHANGELOG security line | Task 7 |
|
||
| D-11 keys/passwords never stored or logged, browser memory only, said in UI and guide | Tasks 1 and 4 (state, note, log greps), Task 5 (log grep), Task 6 (guide), Task 8 (reload proof) |
|
||
| D-12 docs Anwender, Betrieb, Entwicklung; de + en Sie texts | Tasks 6–7 (guides), Tasks 1–7 (messages, check-cert-messages gate) |
|
||
| D-13 fixtures, openssl round trips, browser proof | Task 1 (fixtures), Tasks 1–7 (e2e sections), Task 5 (openssl round trips), Task 8 (screenshots) |
|
||
| D-26 build body limit (checker info item: DTO caps vs. Express 100 kB) | Task 2 (cert-json-body + spec incl. maximal-body arithmetic, main.ts, e2e 380 kB / 600 KiB / login 150 kB), Task 6 (Betriebs- and Entwicklungsanleitung) |
|
||
| CONTEXT discretion: working-set location, ZIP limits, key-match display, CSR display, file names, server crypto, tab names | D-14, D-15, D-17, D-19, D-23, D-26 in Tasks 1–5 |
|
||
| CONTEXT specifics: chain via issuer/subject + AKI/SKI, gap messages, key check, keep analysis, version rule | D-18 (Tasks 2 and 4), D-09 (Task 6) |
|
||
| RESEARCH: forge cannot read EC certs/keys/CSR, PKCS#7 with EC fails | D-08, D-16 (node:crypto, ASN.1 walk), Tasks 1, 3, 4 |
|
||
| RESEARCH: bug root cause (shared single-file DropZone) | Task 1 (DropZone removed, page rebuilt) |
|
||
| RESEARCH: name-only chain walk wrong → checkIssued + verify, decoy and cross-signed | Task 2 (cert-chain spec) |
|
||
| RESEARCH: PFX EC bags (bag.cert null, bag.key false), OpenSSL-3 PFX readable, sniff by content | Task 4 (readPkcs12) |
|
||
| RESEARCH Pattern 3: scoped forge patch for EC PFX | D-20, Task 5 (spec incl. restoration) |
|
||
| RESEARCH: key lock detection and traditional encryption | Task 4 (cert-keys), Task 5 (exportKey) |
|
||
| RESEARCH: ZIP limits, magic bytes, junk, nested, encrypted | D-17, Task 3 |
|
||
| RESEARCH: AIA pattern from nextcloud-logo-fetch, server-derived URL, issuer verification, ports 80/443 (A8), guarded lookup | D-22, Task 7 |
|
||
| RESEARCH: shared guard IPv6 weaknesses | D-10, Task 7 |
|
||
| RESEARCH: templates table incl. NPM PKCS#1/SEC1 key (A2), HAProxy order (A3), Tomcat PKCS#12 (A4), Apache 2.4.8 split | D-21, Task 6, human check |
|
||
| RESEARCH: JSON 100 kB limit, NPM upload limit, outbound egress | D-26 (Task 2), Task 6 (Betriebsanleitung upload), Task 7 (Betriebsanleitung egress) |
|
||
| RESEARCH: per-file passwords pitfall | D-20, Task 4 |
|
||
| RESEARCH: biome array keys and nested buttons, umlaut guard, de/en parity, hard-coded German strings | Tasks 1–7 (messages via t(), check-cert-messages gate) |
|
||
| RESEARCH open questions 1–3 (AES option, old endpoints, version) | decided by orchestrator: D-07, D-08, D-09 |
|
||
| RESEARCH A1 (forge AES PFX on Windows), A2 (NPM fields) | human check after the user's pull (Task 8) |
|
||
| Deferred / out of scope: JKS (native tools), licensing and multi-tenancy topics | not planned |
|
||
</verification>
|
||
|
||
<success_criteria>
|
||
- A user collects all files of a certificate delivery — several single files, a vendor ZIP, pasted text — in „Dateien“ without ever losing an earlier file, sees what each contains, and every other tab works on that list.
|
||
- Fullchain, Nur Kette, single certificate, certificate plus key, PFX (Kompatibel default, Modern optional), every key and CSR format and seven templates come out in the right order for RSA and EC, root only when ticked; openssl accepts every output in the e2e.
|
||
- A missing issuer is named clearly; „Fehlendes Zertifikat holen“ fetches it only on click through the hardened guard, accepts only the real issuer and marks it nachgeladen — proven live.
|
||
- No private key or password is stored or logged; the old routes, the old service and forge certificate parsing are gone; one parser remains.
|
||
- Module version 1.2.0 with module changelog, CHANGELOG (incl. the security fix), Anwender-, Betriebs- and Entwicklungsanleitung updated.
|
||
- A build request within the DTO caps is never cut off by the body limit; a larger one gets 413 with code tooLarge; every other route keeps 100 kB.
|
||
- After each of the eight tasks the gates of that task are green and the module is usable; at the end full api and web suites, both tsc runs, biome, all e2e sections green on the rebuilt stack; eight dark and four light screenshots reviewed; eight commits on main, nothing pushed, nothing deployed.
|
||
</success_criteria>
|
||
|
||
<output>
|
||
`.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/261009-ikt-SUMMARY.md` is written piece by piece (never committed by an executor): Task 1 creates it, every task appends „## Task N“ with its measured gate table, deviations and threat status. Assigned items: (1) Task 1 — the fixture list actually generated and the openssl version used; (2) Task 5 — the openssl outputs of the formats e2e that prove the PFX algorithms (compat 3DES, modern AES-256) and the P7B/P7C order; (3) Task 7 — the live AIA result (host, fetched CN, what the next level showed) or, if `CERT_E2E_OFFLINE=1` had to be used, why; (4) Task 8 — confirmation that the old routes return 404 and no forge certificate parser remains; (5) Task 8 — the screenshot list with paths and the findings of the design review; (6) Task 8 — a checklist for the user's real environment: real vendor ZIP, NPM custom certificate with the template, IIS import of the compatible PFX, optional test of the modern PFX on a current Windows, outbound http from alpha for the fetch button — deployment and pull stay with the user.
|
||
</output>
|