` 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.
+` 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: ` 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 `.
+
+**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 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 -F "files=@;filename=" …`) 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).
+ ` 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) | +
`. Für Vorlagen mit ZIP-Antwort: das Archiv entsteht im Browser (fflate), die API liefert einzelne Dateien plus `snippet`.
+- Rebuild dauerte wieder rund 3 Minuten (im Hintergrund starten; bei `TMPDIR`-losen Shells den Scratchpad-Pfad für das Log verwenden).
+
+## Task 6
+
+**Commit:** 47b2621 `feat(cert-manager): Vorlagen für Zielsysteme, Modulversion 1.2.0 und Anleitungen` (19 Dateien, 4 neu, keine Löschungen). Nicht gepusht. Das e2e-Skript unter .planning ist wie vorgegeben nicht eingecheckt.
+
+### Gates (gemessen, Stack neu gebaut mit `docker compose up -d --build api web`)
+
+| Gate | Ergebnis |
+|------|----------|
+| api vitest `src/cert-manager src/module-registry` | 17 Dateien, 476 Tests grün (cert-templates 25, cert-output 47; module-changelog.spec mit 1.2.0 grün) |
+| web vitest `modules/cert-manager src/messages` | 12 Dateien, 133 Tests grün (TemplatesTab 11, Seitentest mit sechs Reitern, umlaut-guard) |
+| tsc api / web | ohne Fehler |
+| biome lint beide Modulordner, biome check eigene Dateien | sauber |
+| check-cert-messages.cjs 80 | `messages ok 228` |
+| 1.2.0 / 2026-10-09 im Changelog, CHANGELOG-Punkt, Anleitungs-Gates, keine forge-Zertifikat-/CSR-/PKCS#7-Leser | grün |
+| `e2e-cert.sh all` | `files`, `fullchain`, `zip`, `inputs`, `formats`, `templates`, `version` ok |
+| gesamte ``-Kette | letzte Zeile `task6 ok` |
+
+Live bewiesen (Abschnitt templates, RSA und EC): Nginx und Apache `openssl verify` der Fullchain gegen die Wurzel (zwei Zertifikate ohne Wurzel, drei mit `includeRoot`), `privkey.pem` ist PKCS#8 und passt zum Serverzertifikat; Apache älter: `cert.pem` allein, `chain.pem` nur das Zwischenzertifikat, Verify besteht; IIS: PFX ohne `pfxEncryption` in der Anfrage, `openssl pkcs12 -info` zeigt `pbeWithSHA1And3-KeyTripleDES-CBC`, Schlüssel passt; Nginx Proxy Manager: `BEGIN RSA PRIVATE KEY` bzw. `BEGIN EC PRIVATE KEY`, `certificate.pem` und `intermediate.pem` getrennt, Schnipsel leer; HAProxy: erstes Zertifikat ist der Server, Schlüssel hinter den Zertifikaten; Tomcat: `.p12` mit Passwort lesbar, Schnipsel mit `IHR-PASSWORT` und ohne das echte Passwort. Fehler mit Code: `templateNeedsKey`, `keyMismatch`, `passwordRequired` (iis und tomcat); unbekannte Vorlage 400 (durch die DTO-Prüfung, ohne Code). API-Log ohne `Neu-Pass-2026` und `PRIVATE KEY`. Abschnitt version: Changelog erster Eintrag 1.2.0 vom 2026-10-09, danach 1.1.0; Katalog nennt cert-manager mit 1.2.0.
+
+### Abweichungen
+
+1. **TDD-Reihenfolge.** `cert-templates.spec.ts` wurde zusammen mit der Umsetzung geschrieben; die Fälle der `` sind abgedeckt. Zwei Spec-Annahmen wurden im ersten Lauf korrigiert (der Anzeigename steckt verschlüsselt im Container und kommt von forge als roher BMPString zurück).
+2. **Neue Dateien nicht nötig, aber `cert-templates.ts` kennt `cert-output.ts` nicht** (Importschleife): `file`, `joinPem`, `derOf` stehen dort in Kurzform noch einmal. `isTemplateId` ist exportiert, `buildOutput` prüft die Kennung vor dem Schlüssel.
+3. **`BuildContent` hat den neuen Inhalt `template`** (cert-types.ts, DTO `BUILD_CONTENTS`, Web `actions.ts` mit `TemplateId`); `FORMATS.template = ['template']`, ein mitgesendetes `format` ergibt `invalidInput`. `snippet` steht nur in der Antwort von Vorlagen.
+4. **Apache-legacy und Nginx Proxy Manager ohne Aussteller:** `chain.pem` bzw. `intermediate.pem` entfallen (und die Zeile `SSLCertificateChainFile`), statt eine leere Datei zu liefern. Mit Kette sind es wie geplant drei Dateien.
+5. **Nginx Proxy Manager, Schlüssel:** bei Schlüsseltypen ohne klassisches Format (nicht RSA/EC) fällt die Vorlage auf PKCS#8 zurück statt mit `formatNotPossible` zu scheitern.
+6. **Unbekannte Vorlagenkennung über HTTP:** die DTO-Prüfung (`IsIn`) antwortet mit Nests Standard-400 ohne Code; `invalidInput` mit Code gibt es nur direkt aus `buildOutput` (Spec). Die Oberfläche kann es nicht auslösen.
+7. **CHANGELOG:** drei statt zwei Punkte unter „Neu“ (Arbeitsbereich, Zusammenführen/Formate/Konvertieren, Vorlagen) und ein Punkt unter „Behoben“; Modulversion 1.2.0 im ersten Punkt.
+8. **Rule 3 - Blocking:** `umlaut-dictionary.ts` um `ssl`, `neuer`, `klassischen`, `SSLHostConfig`, `PASSWORT` ergänzt (Guard).
+9. **Betriebsanleitung:** zusätzlich zur Zeile in „Fehlerbilder“ (413 beim Hochladen) ein Satz zum Rechenaufwand bei Passwörtern; Abschnitt sagt auch, dass der Zertifikat-Manager von sich aus nichts im Internet abruft (Task 7 ändert diesen Satz auf den Knopf „Fehlendes Zertifikat holen“).
+10. Keine Stubs.
+
+### Bedrohungsstatus
+
+- T-ikt-08 (Schlüssel/Passwörter): Vorlagen-Passwörter nur im Anfragekörper; Schnipsel enthält `IHR-PASSWORT`, nie das Passwort (Spec + e2e); Passwort steht nicht im dargestellten Text der Oberfläche (Test); API-Log im e2e ohne Passwort und Schlüssel; Fehlertexte ohne Schlüssel/Passwort (Spec).
+- Vorlagen verlangen immer ein passendes Schlüssel-Zertifikat-Paar (`checkPrivateKey`, sonst `keyMismatch`); die Reihenfolge der Kette baut die API selbst.
+- Der Schlüssel liegt in den Dateien der Vorlagen unverschlüsselt (Zielsysteme erwarten das); Oberfläche und Anleitung weisen darauf hin, die Dateien zu schützen (HAProxy-Schritt).
+
+### Hinweise für Task 7
+
+- API: `cert-templates.ts`/`cert-output.ts` brauchen keine Änderung. Neue Dateien laut Plan: `common/public-url-guard.ts` (+Spec), `cert-aia.ts`, `dto/cert-fetch-issuer.dto.ts`; Controller bekommt `fetch-issuer` (Platzhalterkommentar steht schon im Klassenkopf). `ChainInfo.gap.aiaUrls` ist schon gefüllt, `CertItem.aiaIssuerUrls` ebenso.
+- Web: `page.tsx` hat jetzt `TabId = files | analyze | split | merge | convert | templates`. `ChainView.tsx` zeigt die Lücke („Zwischenzertifikat fehlt“) und ist der Ort für den Knopf; `working-set.ts` braucht `origin: 'fetched'` mit Host (steht im Typ); `useCertWorkspace` hat noch keine Funktion zum Hinzufügen nachgeladener Einträge (neben `addFiles`/`addText` ergänzen). Der Knopf soll in Zusammenführen und Vorlagen (beide nutzen `ChainView`) und Analysieren erscheinen, wo `ChainView` vorkommt.
+- Messages: neu `tabs.templates`, `templates.*` (Karten `nginx`, `apache`, `apacheLegacy`, `iis`, `npm`, `haproxy`, `tomcat` mit `title`, `delivers`, `steps.1..3`); `check-cert-messages.cjs` zählt 228 Schlüssel (Mindestzahl in Task 7 mindestens 228). Umlaut-Allowlist-Ergänzungen siehe oben.
+- e2e-cert.sh: `BUILT="files fullchain zip inputs formats templates version"`; `run_section` hat noch den Platzhalter `aia) e2e_fail`; neuen Abschnitt `section_aia` anlegen und dort einhängen. Der Abschnitt `version` prüft jetzt Changelog-Top-Eintrag 1.2.0: Task 7 ergänzt dort ein Item (Anzahl `>= 5` bleibt gültig).
+- Docs: Anwenderanleitung hat den Abschnitt „Fehlendes Zertifikat holen“ noch nicht (nur der Satz im Zusammenführen-Absatz über „Fügen Sie das fehlende Zertifikat im Reiter ‚Dateien‘ hinzu“ ist anzupassen); Betriebsanleitung „### Zertifikat-Manager“ sagt aktuell „ruft von sich aus nichts im Internet ab“ (um den Knopf und die Ports 80/443 ergänzen); Entwicklungsanleitung: neuer Absatz neben „Zertifikat-Manager, Arbeitsbereich und Ketten (quick-261009-ikt)“, der Satz über `fetch-issuer` dort steht schon.
+- Rebuild dauerte wieder rund 3 Minuten (im Hintergrund starten; Log im Scratchpad).
+
+## Task 7
+
+**Commit:** a2fc2cb `feat(cert-manager): Fehlendes Zertifikat holen, gehärteter Adressschutz` (32 Dateien, 5 neu, keine Löschungen). Nicht gepusht. Das e2e-Skript unter .planning ist wie vorgegeben nicht eingecheckt.
+
+### Gates (gemessen, Stack neu gebaut mit `docker compose up -d --build api web`)
+
+| Gate | Ergebnis |
+|------|----------|
+| api vitest `src/cert-manager src/module-registry src/common src/favorites src/nextcloud-status` | 36 Dateien, 889 Tests grün (public-url-guard 51, cert-aia 26, controller 31; favorites und nextcloud-status unverändert grün) |
+| web vitest `modules/cert-manager src/messages` | 13 Dateien, 150 Tests grün (ChainView 9, working-set +5, FilesTab +2, AnalyzeTab +1, umlaut-guard) |
+| tsc api / web | ohne Fehler |
+| biome lint beide Modulordner + `common`, biome check der Kettenliste | sauber (nur eigene Dateien mit `--write` formatiert) |
+| check-cert-messages.cjs 85 | `messages ok 237` |
+| CHANGELOG „IPv6“ unter Unveröffentlicht, Anleitung-Gate, Changelog-Gate, keine forge-Zertifikat-/CSR-/PKCS#7-Leser | grün |
+| `e2e-cert.sh all` | `files`, `fullchain`, `zip`, `inputs`, `formats`, `templates`, `version`, `aia` ok |
+| gesamte ``-Kette | letzte Zeile `task7 ok` |
+
+### Output-Punkt 3: Live-AIA-Ergebnis (kein CERT_E2E_OFFLINE nötig, der api-Container kommt ins Internet)
+
+- Blatt: das Zertifikat von letsencrypt.org (EC), Lücke `afterLeaf` mit Adresse `http://ye2.i.lencr.org/`.
+- Geholt über `POST fetch-issuer`: Server **ye2.i.lencr.org**, Name **YE2**, Datei `YE2.crt`; `openssl verify -partial_chain -trusted ` bestanden.
+- Nächste Stufe: Analyse von Blatt plus YE2 ergibt den Weg `letsencrypt.org, YE2` mit Lücke `afterCa`, es fehlt „Root YE“, Adresse `http://ye.i.lencr.org/` (dort erscheint der Knopf erneut).
+- Offline-Fälle live: `aia-private-leaf.pem` ergibt 422 `aiaInternal`, `rsa-leaf-noaki.pem` 422 `aiaMissing`, Text 400 `notACertificate`, eine mitgeschickte `url` ändert nichts, 16 385 Zeichen 400, ohne Anmeldung 401. API-Log der letzten 10 Minuten enthält kein `BEGIN CERTIFICATE`.
+- Der echte Abruf lief durch den eigenen undici-Agent mit `createGuardedLookup` (der Lookup bei Verbindungsaufbau funktioniert also real, mit `all`-Option und ohne).
+
+### Abweichungen
+
+1. **TDD-Reihenfolge.** Die Guard-Umsetzung stand vor dem Guard-Spec; cert-aia.ts vor cert-aia.spec.ts. Alle Fälle der `` sind abgedeckt und liefen grün (ein Spec-Fehler im ersten Lauf war ein Testartefakt: ein `ReadableStream` ruft `pull` einmal vorab auf, deshalb `highWaterMark: 0`).
+2. **`cert-model.ts` angefasst (nicht in der Dateiliste):** `pkcs7Certificates` ist jetzt exportiert (der D-16-Walker, von cert-aia.ts benutzt; kein zweiter Leser).
+3. **Fünf Test-Attrappen angefasst (Rule 3, wie in Task 3/4):** `CertWorkspace` hat jetzt `addFetched`; in `AnalyzeTab.test.tsx`, `MergeTab.test.tsx`, `ConvertTab.test.tsx`, `SplitTab.test.tsx` und `TemplatesTab.test.tsx` kam je eine Zeile `addFetched: () => 'added'` dazu. `TemplatesTab.tsx` reicht `workspace.addFetched` an `ChainView` (nicht in der Dateiliste, laut Task 6 aber ein ChainView-Nutzer, der Knopf soll auch in Vorlagen erscheinen).
+4. **Guard strenger als der Plan:** der ganze Bereich `::/96` ist immer gesperrt (der Plan wollte ihn nach der eingebetteten IPv4 beurteilen); dazu `::ffff:0:0:0/96` (IPv4-übersetzt, nach eingebetteter IPv4) und `3fff::/20` (Dokumentation). `isPrivateIpv6` ist jetzt ebenfalls exportiert. Der Plan nannte nur `isPrivateIpAddress`.
+5. **Port-Prüfung im Spec:** das Fixture `aia-private-leaf.pem` hat keine Adresse mit Port 8080 (CA-Schlüssel sind gelöscht, keine Neuerzeugung). Die Port-Regel (`hasDefaultPort`, gleiche Bedingung wie der Adressschutz) wird deshalb über eine Weiterleitung auf `:8080` im Spec bewiesen; für die erste Adresse nutzt der Code dieselbe Bedingung.
+6. **Mehrere Adressen:** bei Misserfolg gewinnt die Meldung mit dem meisten Fortschritt (`aiaNotIssuer` vor `aiaTooLarge` vor `aiaUnreachable` vor `aiaInternal`); eine Warnzeile je Aufruf mit allen versuchten Servernamen. Der Zeitrahmen von 8 s gilt je Adresse.
+7. **Antwort `host`** ist der Server der tatsächlich antwortenden Stelle (nach Weiterleitungen), nicht der erste.
+8. **Web-Zusatzmeldungen:** `chain.fetchButton/fetching/fetchHint/fetchNoAddress/fetchAlready/fetchListFull/fetchListTooLarge/fetchFailed`, `files.fetchedFrom`. `WorkingEntry` hat das optionale Feld `pem` (nur bei nachgeladenen Einträgen), damit dasselbe Zertifikat nicht doppelt angehängt wird. Bei einer Lücke ohne Adresse zeigt `ChainView` nur den Hinweis zum Herunterladen (hinter einem Zwischenzertifikat bleibt der ruhige Hinweis ohne Zusatz).
+9. **CHANGELOG:** der Sicherheitspunkt steht unter „Behoben“ (nicht „Neu“); das Zusammenführen-Bullet nennt den Knopf. Modul-Changelog 1.2.0 hat jetzt sechs Punkte.
+10. Keine Stubs.
+
+### Bedrohungsstatus
+
+- SSRF: Adresse nur aus dem Zertifikat (DTO-Whitelist, Spec und e2e mit mitgeschickter `url`); Standardport, kein Login in der Adresse, ≤ 2048 Zeichen, höchstens 3 Adressen; Adressschutz vor der ersten Anfrage und vor jedem Sprung (`fetchImpl` wird bei internen Adressen nie gerufen, Spec); DNS-Rebinding: `createGuardedLookup` im undici-Agent (Spec mit nachgebildeter Auflösung, live bewiesen); 8 s, 256 KiB (vorab und beim Lesen), 3 Weiterleitungen.
+- Fremdes Zertifikat: nur Aussteller mit `checkIssued` und Signaturprüfung; Spec mit EC-Zertifikat, gleichnamigem Zertifikat mit anderem Schlüssel (`rsa-inter-decoy`) und Nicht-Zertifikat-Antwort.
+- Log: genau eine Warnzeile mit Servername und Code, nie PEM oder Pfad (Spec, e2e).
+- Gemeinsame Nutzer des Guards (Favoriten-Symbole, Nextcloud-Logo): Specs unverändert grün; Wirkung nur strenger.
+- Akzeptierter Rest (im Kopfkommentar von cert-aia.ts): jeder angemeldete Benutzer des Moduls kann einen einzigen GET an eine öffentliche Adresse aus seinem hochgeladenen Zertifikat auslösen.
+
+### Hinweise für Task 8
+
+- Der Knopf erscheint in Analysieren, Zusammenführen und Vorlagen (alle drei nutzen `ChainView` mit `onFetched={workspace.addFetched}`). Für den Browsernachweis: `letsencrypt.org`-Blatt aus dem e2e (`openssl s_client -connect letsencrypt.org:443 -servername letsencrypt.org`) im Reiter „Dateien“ hochladen (wird hinter `$E2E_TMP` nicht aufbewahrt, neu holen), dann Reiter „Zusammenführen“: Meldung „Zwischenzertifikat fehlt“ mit Knopf und Hinweis auf `ye2.i.lencr.org`; Klick holt YE2, Eintrag „YE2.crt“ mit „nachgeladen von ye2.i.lencr.org“, darunter erscheint der Knopf erneut (Lücke hinter YE2, „Root YE“). Screenshots dunkel zuerst.
+- Gate-Mindestzahl `check-cert-messages.cjs` zählt jetzt 237 Schlüssel; `e2e-cert.sh`: `BUILT="files fullchain zip inputs formats templates version aia"`, Abschnitt `aia` ist der letzte in `all`; `CERT_E2E_OFFLINE=1` überspringt nur den Live-Teil.
+- Nachweisliste für Output-Punkt 4 (Task 8): alte Routen `parse`, `split`, `merge`, `convert`, `export` liefern 404 (das Setup des e2e prüft `parse` bei jedem Lauf), keine forge-Zertifikatleser im Produktivcode (Gate grün in jedem Task).
+- Checkliste für die echte Umgebung (Output-Punkt 6) um diesen Punkt ergänzen: Aus alpha muss der `api`-Container ausgehend per HTTP/HTTPS (Ports 80 und 443) ins Internet kommen, sonst meldet der Knopf „nicht erreichbar“ (Betriebsanleitung beschreibt Prüfbefehl und Fehlerbild).
+- Rebuild dauerte wieder rund 3 Minuten (im Hintergrund; Log im Scratchpad).
+
+## Task 8
+
+**Commit:** keiner. Der Browser-Nachweis ergab keinen Befund, der eine Codeänderung nötig machte; die Anwenderanleitung stimmt mit den Beschriftungen der Seite überein. Die einzige Dateiänderung ist das Verschieben des Todos nach `.planning/todos/completed/` (laut Vorgabe nicht eingecheckt, nur umbenannt, nicht gestaged). Nichts gepusht, nichts ausgerollt.
+
+### Gates (gemessen, Stack neu gebaut mit `docker compose up -d --build api web`, Seed-Zeile „Cert-Manager module seeded in registry“ im api-Log)
+
+| Gate | Ergebnis |
+|------|----------|
+| api vitest (gesamte Suite) | 165 Dateien, 3497 Tests grün |
+| web vitest (gesamte Suite) | 158 Dateien, 1915 Tests grün (siehe Hinweis zu domains-page unten) |
+| tsc api / web | ohne Fehler |
+| biome lint (api cert-manager + common, web Modulordner) und biome check der Dateiliste der Verify-Kette | sauber |
+| check-cert-messages.cjs 85 | `messages ok 237` |
+| Todo unter completed, nicht mehr unter pending | ja |
+| `e2e-cert.sh all` | `files`, `fullchain`, `zip`, `inputs`, `formats`, `templates`, `version`, `aia` ok (inkl. Live-Abruf bei ye2.i.lencr.org) |
+| 8 dunkle und 4 helle Bilder | 9 dunkle, 4 helle vorhanden |
+| gesamte ``-Kette | letzte Zeile `task8 ok` |
+
+Hinweis: Im ersten Lauf der Kette schlug einmal `domains-page.test.tsx` fehl (Zeile 312, `waitFor` mit `mockSaveSettings`, Zeitüberschreitung unter Last, während parallel Browser und Docker liefen). Die Datei gehört zu einem anderen Modul, wurde von diesem Auftrag nicht angefasst, lief danach dreimal einzeln grün (27 Tests) und im zweiten Gesamtlauf grün. Als zeitabhängig (flaky) eingestuft, nicht behoben (außerhalb des Auftrags).
+
+### Output-Punkt 4: alte Routen und forge
+
+- `POST parse`, `split`, `merge`, `convert`, `export` unter `/modules/cert-manager/` liefern 404; das e2e (Abschnitt files) prüft das bei jedem Lauf und war grün. Der Controller kennt nur `analyze`, `build` und `fetch-issuer`.
+- Im Produktivcode von `apps/api/src` gibt es keine forge-Leser für Zertifikat, CSR oder PKCS#7 (grep nach `certificateFromPem/Asn1`, `certificationRequestFromPem/Asn1`, `pkcs7.messageFromPem/Asn1`: keine Treffer). forge bleibt nur für ASN.1, PKCS#12 und den Schreibweg.
+
+### Output-Punkt 5: Browser-Nachweis
+
+Werkzeug: playwright-core mit dem lokalen Chromium (Playwright MCP stand nicht zur Verfügung), App unter http://localhost:3000, Anmeldung admin, Skript im Scratchpad (`proof.cjs`), nichts per fetch aus der Seite gemessen. Dunkelmodus zuerst über den Theme-Knopf, danach Hellmodus. Eingabedateien aus den Fixtures: `server.crt` (rsa-leaf), `intermediate.crt` (rsa-inter), `zertifikat-paket.zip` (EC-Server als ServerCertificate.crt, EC-Zwischen, EC-Wurzel, `server.key`, ein `__MACOSX`-Eintrag), `rsa-compat.pfx`, `letsencrypt-leaf.pem` (live von letsencrypt.org geholt).
+
+Bilder unter `/home/vicolab/projects/tessera-ctl/.playwright-mcp/cert-manager/` (Ordner ist gitignoriert):
+
+| Datei | Zeigt |
+|-------|-------|
+| ikt-dark-empty.png | leerer Arbeitsbereich, Hinweis zum Browserfenster |
+| ikt-dark-files.png | zwei getrennte Auswahlen (server.crt, dann intermediate.crt) beide in der Liste, dazu ZIP (je Pfad erkannt, `__MACOSX` fehlt) und entsperrte PFX (Zertifikate plus Schlüssel „war verschlüsselt“) |
+| ikt-dark-analyze.png | Ketten (EC und RSA), Karten je Zertifikat mit „Passender Schlüssel vorhanden“, Schlüsselkarten |
+| ikt-dark-merge.png | Zusammenführen für das EC-Serverzertifikat, Root-Haken aus, PFX-Optionen ausgefüllt (Kompatibel vorgewählt) |
+| ikt-dark-convert.png | Schlüssel, Zielformat Traditionell, Passwortschutz |
+| ikt-dark-templates.png | alle sieben Vorlagen, Windows-/IIS-Karte mit Passwort, Schnipsel sichtbar |
+| ikt-dark-gap.png | nur das Letsencrypt-Blatt: „Zwischenzertifikat fehlt: „YE2““ mit Knopf „Fehlendes Zertifikat holen“ und Hinweis auf ye2.i.lencr.org |
+| ikt-dark-fetched.png | nach dem Klick: Kette Server, YE2; darüber ruhiger Hinweis auf „Root YE“ mit erneutem Knopf (ye.i.lencr.org) |
+| ikt-dark-mobile.png | Reiter Dateien bei 390 x 844, Eintrag „YE2.crt“ mit „nachgeladen von ye2.i.lencr.org“ |
+| ikt-light-files.png, ikt-light-merge.png, ikt-light-templates.png, ikt-light-gap.png | dieselben Ansichten im Hellmodus |
+| step-after-reload.png, step-files-after-fetch.png, step-mobile-merge.png | Hilfsbilder (leer nach Neuladen, Liste nach dem Nachladen, Zusammenführen mobil) |
+
+Gemessene Ergebnisse des Ablaufs:
+
+- Zwei getrennte Auswahlen bleiben beide in der Liste (der gemeldete Fehler ist behoben); ZIP und PFX kommen dazu, die PFX lässt sich mit dem Passwort entsperren (Schlüssel „RSA, 2048 Bit, war verschlüsselt“, Zertifikate erscheinen).
+- Root-Haken ist standardmäßig aus. Die im Browser heruntergeladene Fullchain `ec.example.test-fullchain.pem` enthält mit `openssl` geprüft genau zwei Zertifikate (Server, Zwischen), ohne Wurzel.
+- Die im Browser erzeugte PFX (Kompatibel) wurde mit `openssl pkcs12 -info` gelesen (MAC sha1, Zertifikat-Beutel); die Windows-/IIS-Vorlage liefert `pbeWithSHA1And3-KeyTripleDES-CBC` (Shrouded Keybag); die Nginx-Vorlage liefert ein ZIP aus `fullchain.pem`, `privkey.pem` und `ANLEITUNG.txt`; der Schlüssel in „Traditionell mit Passwort“ ist `BEGIN EC PRIVATE KEY` mit `Proc-Type: 4,ENCRYPTED` (AES-256-CBC) und lässt sich mit `openssl ec -passin` öffnen.
+- „Fehlendes Zertifikat holen“ holte live YE2 (Server ye2.i.lencr.org); die Dateiliste zeigt „YE2.crt, nachgeladen von ye2.i.lencr.org, Zwischenzertifikat YE2“; danach erscheint der Knopf eine Ebene höher (Root YE).
+- Nach dem Neuladen der Seite ist die Liste leer (kein Listenabschnitt) und der Hinweis „Die Dateien bleiben nur in diesem Browserfenster …“ sichtbar.
+
+Design-Review gegen D-23 (jedes Bild einzeln angesehen):
+
+- Ruhige, dichte Liste, lesbare Rollenplaketten und Warnkasten in beiden Modi (gelb auf dunklem Braun bzw. dunkles Braun auf hellem Gelb), Passwortfelder und Auswahlfelder sind in beiden Modi als Felder erkennbar, kein Großschrift-Etikett, keine Pfeilzeichen oder Mittelpunkte in der Oberfläche, formale Anrede.
+- Fokus ist sichtbar (gelber Rand am Passwortfeld in ikt-dark-convert.png, hervorgehobener Hauptknopf in ikt-dark-merge.png).
+- Befund: keiner, der eine Änderung verlangt. Beobachtungen ohne Handlungsbedarf: Auf 390 Pixel Breite läuft die Reiterleiste seitlich weiter („Zusammenführen“ angeschnitten; gemeinsames Reiterbauteil, wischbar); die Zeilen mit PowerShell-Zeile in den Vorlagen laufen seitlich (Schnipsel scrollt in seinem Kasten); die Seitenleiste zeigt „Cert Manager“ als Modulname (Katalog, nicht Teil dieses Auftrags).
+- Zwischenstand der Bilder: Die ersten Aufnahmen zeigten bei langen Seiten die mitscrollende Kopfleiste mitten im Bild; das war ein Artefakt der Ganzseitenaufnahme (Scrollposition), nicht der Seite. Das Skript scrollt jetzt vor jeder Aufnahme nach oben, alle abgegebenen Bilder sind danach neu entstanden.
+- Anwenderanleitung: alle in „…“ und fett genannten Beschriftungen der Zertifikat-Manager-Anleitung kommen im Wortlaut auf der Seite vor (Dateien, Analysieren, Aufteilen, Zusammenführen, Konvertieren, Vorlagen, „Root-Zertifikat mitnehmen“, „Kompatibel (auch ältere Windows-Server)“, „Modern (AES-256)“, „Fehlendes Zertifikat holen“, „Zwischenzertifikat fehlt“, „nachgeladen von …“). Keine Korrektur nötig.
+
+### Abweichungen
+
+1. Keine Codeänderung und damit kein Task-8-Commit (der Plan sah ihn nur für die Todo-Verschiebung und Review-Befunde vor; die Anweisung dieses Laufs verbietet Einchecken unter `.planning/`). Das Todo wurde mit `mv` nach `.planning/todos/completed/` verschoben, nicht mit `git mv` und nicht gestaged.
+2. Playwright MCP war nicht verfügbar; Ersatz: playwright-core mit dem lokalen Chromium (so vorgegeben).
+3. Ein einmaliger zeitabhängiger Fehlschlag von `domains-page.test.tsx` (siehe oben), bei Wiederholung grün.
+
+## Gesamtübersicht
+
+| Task | Commit | Inhalt |
+|------|--------|--------|
+| 1 | 75ea83f | Reiter „Dateien“, gemeinsamer Arbeitsbereich, ein Parser für RSA und EC (Durchstich) |
+| 2 | 30118a2 | Zusammenführen, Kettenbildung, Fullchain und Nur Kette, eigene Körpergrenze für `build` |
+| 3 | 1554ae8 | Hersteller-ZIP, PKCS#7, eingefügter Text, Analysieren, Aufteilen |
+| 4 | fcac0a3 | Schlüssel, PFX, CSR, Passwort je Datei |
+| 5 | 65a1dca | alle Ausgabeformate, Konvertieren, Bundle, PFX (kompatibel und modern) |
+| 6 | 47b2621 | Vorlagen, Modulversion 1.2.0, Changelogs, Anleitungen |
+| 7 | a2fc2cb | „Fehlendes Zertifikat holen“, gehärteter gemeinsamer Adressschutz |
+| 8 | keiner | Endgates, Browser-Nachweis (13 Bilder), Todo abgeschlossen |
+
+Alle acht Commits liegen lokal auf `main`, nichts gepusht, nichts ausgerollt. Der Stack läuft lokal mit dem Stand von Task 7.
+
+## Checkliste für die echte Umgebung (macht der Nutzer nach dem eigenen Pull auf alpha)
+
+1. Neubau mit `--build` bzw. `--force-recreate` (ein schlichtes `up` baut nicht neu); API-Log prüfen: „Cert-Manager module seeded in registry“, Modulversion 1.2.0 im Marktplatz.
+2. Echtes Hersteller-ZIP im Reiter „Dateien“ hochladen, Zusammenführen, Fullchain herunterladen und auf dem Zielsystem prüfen (ohne Wurzel, die meisten Server brauchen sie nicht).
+3. Nginx Proxy Manager: Vorlage „Nginx Proxy Manager“ benutzen und in „SSL Certificates“, „Add SSL Certificate“, „Custom“ die Dateien `certificate.pem`, `intermediate.pem` und `privkey.pem` in die Felder legen. Zu bestätigen (Annahme aus der Recherche A2): NPM akzeptiert die Dateien, und der Kopf `BEGIN RSA PRIVATE KEY` bzw. `BEGIN EC PRIVATE KEY` wird als Schlüssel angenommen. Dazu der Nginx-Proxy-Manager-Upload: große ZIPs/Dateien blockt NPM bei Uploads; die Dateien hier sind klein (wenige KB), die 413-Grenze der Plattform liegt bei 20 MiB gesamt und muss in NPM nicht angehoben werden, solange nur kleine Dateien hochgeladen werden. Bei 413 beim Hochladen einer größeren ZIP: Upload-Grenze des NPM-Hosts (`client_max_body_size`) prüfen.
+4. Windows-Server: die PFX „Kompatibel (auch ältere Windows-Server)“ (3DES/SHA-1, Vorgabe in der Vorlage „Windows / IIS“) importieren und im IIS-Manager an eine Bindung hängen; zusätzlich, wenn gewünscht, die PFX „Modern (AES-256)“ auf einem aktuellen Windows testen, um zu sehen, welche Systeme sie annehmen (Recherche A1). Beide Varianten sind mit OpenSSL lesbar; ob ein bestimmter Windows-Server sie importiert, ließ sich hier nicht prüfen.
+5. „Fehlendes Zertifikat holen“: aus dem `api`-Container auf alpha muss ausgehend per HTTP und HTTPS (Ports 80 und 443) ins Internet möglich sein, sonst meldet der Knopf „nicht erreichbar“ (Prüfbefehl und Fehlerbild stehen in der Betriebsanleitung). Test: Letsencrypt-Blatt hochladen, Reiter „Zusammenführen“, Knopf klicken, es muss YE2 nachgeladen werden. Interne CAs mit privaten Adressen werden absichtlich nicht angefragt (Meldung „aiaInternal“); dort das Zwischenzertifikat von Hand hinzufügen.
+6. Der gehärtete gemeinsame Adressschutz (Favoriten-Symbole, Nextcloud-Logo) ist strenger geworden: kurz prüfen, dass Favoriten-Symbole und das Nextcloud-Logo auf alpha weiterhin laden.
+7. Basic-Auth vor alpha bleibt wie sie ist; Updater-Verhalten ist von diesem Auftrag nicht berührt.
+
+## Review fixes
+
+Alle Befunde aus `261009-ikt-REVIEW.md` (CR-01 bis CR-03, WR-01 bis WR-07, IN-01 bis IN-05) sind behoben; Einzelheiten je Befund stehen im Abschnitt „Fix status“ der Review-Datei. Zwei lokale Commits, nichts gepusht, nichts ausgerollt:
+
+- **bfcf6d4** `fix(cert-manager): ZIP-Bombe, PEM-Scanner und Umlaut-Passwörter in PFX behoben` (40 Dateien: API, 10 neue Fixtures, keine Löschungen)
+- **c5bffe9** `fix(cert-manager): Reiter zeigen nur noch passende Auswertungen, Aufteilen schützt Schlüssel` (23 Dateien: Web, Meldungen, Anleitungen, CHANGELOG, keine Löschungen)
+
+Der Trailer beider Commits ist `Co-Authored-By: Claude Sonnet 5.5 ` (laut Attributionsvorgabe der Umgebung; der Auftrag nannte „Opus 5.5“).
+
+### Abweichungen und Entscheidungen
+
+1. **CR-03 ohne node:crypto-Schlüsselbeutel.** Statt den PKCS#8-Beutel selbst zu bauen und zu öffnen, ersetzt `withForgeKdf` forges PBKDF2 für die Dauer des synchronen Aufrufs durch eine Ableitung aus UTF-8-Bytes mit `node:crypto` (gleiche Technik wie beim PFX-Schreiben, im `finally` zurückgesetzt, Spec prüft das). Damit stimmen Lesen und Schreiben mit OpenSSL 3 überein; alle Kombinationen (`pässwörd`, `pw€`, compat und modern) gehen, kein Passwort wird abgelehnt. Nebenwirkung: SHA-256/512-PBKDF2 läuft jetzt nativ statt in reinem JavaScript (7,5 µs je Runde vorher).
+2. **Neue Fehler- und Ignorier-Codes:** `tooManyItems` (413), `aiaNotAllowed` (422), `protectionTooExpensive` (Datei übersprungen). Dazu Meldungen (de/en), `errors.buildFailed` und `workspace.stale`. `check-cert-messages.cjs` zählt jetzt 250 Schlüssel (Mindestzahl künftig mindestens 250).
+3. **WR-02 als `fresh`-Feld.** `CertWorkspace.fresh` ist neu; Tests, die einen `CertWorkspace` nachbauen, brauchen das Feld (fünf Attrappen angepasst; `tsc` prüft Testdateien nicht, sie liefen sonst mit einer unsichtbaren Analyse).
+4. **WR-03:** Beim Aufteilen sind Downloads jetzt asynchron (Schlüssel mit Schutz kommen von `build`); die Tests von `SplitTab` warten mit `waitFor`. Die entsperrte Schlüsselanzeige in der Analyse-Antwort bleibt unverschlüsselt (dafür gebaut, nicht geloggt).
+5. **`main.ts` verschlankt:** die HTTP-Einrichtung liegt in `http-setup.ts` (`configureHttp`), damit die CORS-Reihenfolge testbar ist. Das alte Task-2-Gate „`certBuildJsonBody` in main.ts“ trifft damit nicht mehr zu (jetzt `http-setup.ts`).
+6. **Fixtures:** `make-fixtures.sh` um Umlaut-/Euro-PFX, Zu-teuer-Dateien und die drei Wurzeln ohne basicConstraints ergänzt; die neuen Dateien wurden mit denselben Befehlen gegen die vorhandenen Test-Zertifikate erzeugt (die Skriptdatei erzeugt bei einem Neulauf alles neu, wie gehabt). Die Specs rufen OpenSSL weiter nie auf.
+7. **Modulversion bleibt 1.2.0:** Punkte der bestehenden Einträge erweitert (Umlaut-Passwörter, geschützte Schlüssel beim Aufteilen), kein neuer Eintrag. CHANGELOG.md: Zusätze in den bestehenden Zertifikat-Manager-Punkten und im Sicherheitspunkt unter „Behoben“.
+8. **e2e-cert.sh:** neuer Abschnitt `review` (CR-01, CR-02, CR-03 in beide Richtungen mit openssl, WR-01, WR-04 bis WR-07), steht in `BUILT`; Datei liegt wie die anderen unter `.planning` und ist nicht eingecheckt.
+
+### Gates (gemessen, Stack neu gebaut mit `docker compose up -d --build api web`)
+
+| Gate | Ergebnis |
+|------|----------|
+| api vitest (gesamt) | 169 Dateien, 3573 Tests grün (cert-manager neu u. a. pem-scan 10, cert-budget-Spezifisches in cert-pkcs12/cert-keys/cert-analyze, cert-upload 6, http-setup 3, cert-aia-url 13); enthält favorites, nextcloud-status, common |
+| web vitest (gesamt) | 160 Dateien, 1945 Tests grün (neu: use-cert-workspace 6, stale-analysis 15, SplitTab +9) |
+| tsc api / web | ohne Fehler |
+| biome check auf allen geänderten und neuen Dateien (63 Pfade der Commits, davon 47 Quelldateien) | ohne Befund |
+| check-cert-messages.cjs 237 | `messages ok 250` |
+| `e2e-cert.sh all` | files, fullchain, zip, inputs, formats, templates, version, aia (live, ye2.i.lencr.org), review: alle ok |
+| Browser (dunkel) | Datei entfernen: Zusammenführen und Aufteilen zeigen nur „Dateien werden geprüft …“ ohne Downloads, danach die neue Liste; fehlgeschlagene Prüfung: Meldung mit „Erneut versuchen“, keine Downloads; geschützter Schlüssel: Schutz vorgewählt, Datei `ENCRYPTED PRIVATE KEY`, mit neuem Passwort lesbar. Bilder in `.playwright-mcp/cert-manager/ikt-fix-*.png` (merge-before, merge-analysing, split-analysing, split-after-remove, merge-after-remove, split-error, split-key-protected, split-key-plain-warning) |
diff --git a/.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/261009-ikt-VERIFICATION.md b/.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/261009-ikt-VERIFICATION.md
new file mode 100644
index 0000000..e67da6e
--- /dev/null
+++ b/.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/261009-ikt-VERIFICATION.md
@@ -0,0 +1,77 @@
+---
+phase: quick-261009-ikt
+verified: 2026-10-09T16:40:00Z
+status: passed
+score: 9/9 must-haves verified
+covered_files:
+ - .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/261009-ikt-PLAN.md
+ - .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/261009-ikt-SUMMARY.md
+ - apps/api/src/cert-manager/cert-aia.ts
+ - apps/api/src/cert-manager/cert-chain.ts
+ - apps/api/src/cert-manager/cert-output.ts
+ - apps/api/src/common/public-url-guard.ts
+covered_digest: "v3:sha256:37fd7e62185dc46fa1ca7614efb6287503b7d9b803e857a1f4818008b9547ee6"
+behavior_unverified: 0
+overrides_applied: 0
+re_verification: false
+---
+
+# Quick 261009-ikt: Cert Manager Umbau - Verification Report
+
+**Goal:** One upload tab (files, ZIPs, pasted PEM) as a shared working set for all other tabs; "second file overwrites first" fixed; chain building plus Fullchain (root optional, default off); all common formats in and out incl. EC; PFX output (3DES default, AES option); target-system templates; "Fehlendes Zertifikat holen" on button only, SSRF-safe; module 1.2.0, module changelog, CHANGELOG, guides.
+**Status:** passed
+**Re-verification:** No, initial verification
+
+## Independent evidence (not taken from SUMMARY.md)
+
+| Check | Command | Result |
+|-------|---------|--------|
+| API unit tests | `vitest run src/cert-manager src/common src/module-registry` (apps/api) | 20 files, 559 tests green (cert-aia 26, public-url-guard 51, cert-output 47, cert-keys 39, cert-templates 25, cert-pkcs12 22, zip-expand 15 ...) |
+| Web tests | `vitest run modules/cert-manager src/messages` (apps/web) | 13 files, 150 tests green (incl. umlaut/messages guard) |
+| Type check | `tsc --noEmit` api and web | both exit 0 |
+| Live e2e on the running stack | `e2e/e2e-cert.sh all` | `files`, `fullchain`, `zip`, `inputs`, `formats`, `templates`, `version`, `aia` all ok. openssl reads outputs: pkcs12 compat = `pbeWithSHA1And3-KeyTripleDES-CBC`, modern = `PBES2 AES-256-CBC`, IIS template (RSA and EC) = 3DES, p7b/p7c `print_certs` lists the full chain. Live AIA fetch obtained YE2 from ye2.i.lencr.org and reported the next missing level (Root YE) |
+| Old forge cert parsers gone | grep `certificateFromPem/Asn1`, `certificationRequestFrom*`, `pkcs7.messageFrom*` in non-spec api code | no hits |
+| Old routes gone | controller exposes only `analyze`, `build`, `fetch-issuer`; e2e setup asserts `POST parse` = 404 (passed) | ok |
+| Debt markers | grep TBD/FIXME/XXX in module code (api + web, non-test) | none |
+| UI evidence | viewed `.playwright-mcp/cert-manager/ikt-dark-files.png` | Single "Dateien" tab with 4 entries (two separate selections plus ZIP per contained path without __MACOSX, plus unlocked PFX), per-entry "Entfernen", browser-window-only notice, tabs Analysieren/Aufteilen/Zusammenführen/Konvertieren/Vorlagen. 13 dark/light proof images present |
+
+## Observable Truths
+
+| # | Truth | Status | Evidence |
+|---|-------|--------|----------|
+| 1 | "Dateien" tab: several files/ZIP stay in list (no overwrite), per-row recognition, per-ZIP-path, junk skipped, remove buttons, pasted PEM, browser-only notice | VERIFIED | FilesTab.tsx is the only component with a file input; screenshot shows the behaviour; e2e files/zip ok; zip-expand spec 15 tests (limits, nested, encrypted) |
+| 2 | Other tabs have no own upload; empty set points to "Dateien" | VERIFIED | grep for file inputs: only FilesTab (and PfxOptions matched on an unrelated pattern for `compat`/`modern`, not a file input); EmptyWorkspace component present; page tests green |
+| 3 | Merge orders chain itself (issuer + real signature check), Fullchain / Nur Kette, root only when ticked (default off), gaps reported, API re-builds order | VERIFIED | MergeTab `includeRoot` default `false`; `buildChains` uses `checkIssued` + `verify` (decoy fixture with same name/SKI covered in cert-chain spec); `buildOutput` calls `buildChains`; e2e fullchain: openssl verify OK, wrong order + foreign inter gives exactly 2 blocks, 3 with root, gap reported |
+| 4 | RSA and EC in all common input formats (PEM/DER, PKCS#7, PKCS#12 incl. 3DES/RC2/modern, keys PKCS#1/8/SEC1 enc/unenc, CSR), matching, password per file | VERIFIED | model/keys/pkcs12/csr specs green (39 + 22 + 7 tests plus model/analyze); 47 generated fixtures incl. EC and legacy PFX; e2e inputs ok; `parsePasswords` in controller |
+| 5 | Outputs: cert PEM/DER/p7b/p7c, Fullchain/Kette in PEM/p7b/p7c, cert+key PEM, PFX compat (default) / modern, key formats with optional password, CSR PEM/DER; openssl reads all | VERIFIED | PfxOptions default `compat`; e2e formats shows openssl pkcs12 -info algorithms and pkcs7 -print_certs; cert-output spec 47 tests; build DTO `pfxEncryption: 'compat' | 'modern'` |
+| 6 | Vorlagen: Nginx, Apache >=2.4.8, Apache older, IIS (PFX compat), NPM, HAProxy, Tomcat; explain when key missing | VERIFIED | cert-templates.ts/spec (25 tests); TemplatesTab; e2e templates ok (IIS = 3DES for RSA and EC); screenshot per SUMMARY ikt-dark-templates.png exists |
+| 7 | "Fehlendes Zertifikat holen" only on click, server reads URL from cert, public addresses only, ports 80/443, hardened guard, per-hop re-check and connect-time lookup guard, 8 s / 256 KiB, issuer verification, added as "nachgeladen von {host}" | VERIFIED | `fetchIssuer(cert.pem)` called only in ChainView click handler (`onClick={click}`); DTO carries only `pem`; cert-aia.ts: `AIA_TIMEOUT_MS=8000`, `AIA_MAX_BYTES=256*1024`, `redirect:'manual'`, max 3 hops, `createGuardedLookup` in undici Agent, standard-port-only check, fingerprint/issuer verification; public-url-guard expands IPv6 to 8 groups (mapped hex, NAT64, 6to4, Teredo ...) with 51 spec tests; live e2e fetched YE2 from the real letsencrypt chain |
+| 8 | No key/password storage or logging; old routes/service gone; module 1.2.0 (2026-10-09), module changelog, CHANGELOG incl. guard security fix, three guides updated; screenshots | VERIFIED | Only logger call with data is a warn with hosts and error code (no PEM/passwords); changelog top entry `1.2.0` / `2026-10-09`, seed uses `latestVersion(CERT_MANAGER_CHANGELOG)`, e2e version ok; CHANGELOG "Unveröffentlicht" has 3 new entries plus "Sicherheit" guard fix and EC fix; docs diff: anwender +25, betrieb +10, entwicklung +105; module-changelog spec green; images present |
+| 9 | `build` has own 512 KiB JSON parser with coded 413 / 400; other routes keep 100 kB; global parser active; each task leaves a usable module | VERIFIED | `app.use(CERT_BUILD_ROUTE, certBuildJsonBody, certBuildBodyErrors)` in main.ts; cert-json-body spec 5 tests; e2e fullchain covers 600 KiB to 413 tooLarge, bad JSON to 400, login 150 kB to 413; seven task commits each present in git log (75ea83f, 30118a2, 1554ae8, fcac0a3, 65a1dca, 47b2621, a2fc2cb) |
+
+**Score:** 9/9 truths verified, 0 behavior-unverified (each state/ordering invariant has a passing spec and a live e2e section).
+
+### Requirements Coverage
+
+| Requirement | Status | Evidence |
+|-------------|--------|----------|
+| QUICK-261009-ikt | SATISFIED | All truths above |
+
+### Anti-Patterns Found
+
+None blocking. Notes (info only):
+- Web upload caps are stricter than the API (web 10 MiB total vs API 20 MiB); intentional, pre-checks only.
+- SUMMARY records one flaky unrelated test (`domains-page.test.tsx`), not part of this task.
+
+### Human Verification
+
+Not required for status. Real-environment checks the SUMMARY already lists for the user after pulling to alpha (not goal blockers, they need infrastructure this verifier cannot reach): NPM accepting `certificate.pem`/`intermediate.pem`/`privkey.pem` (EC key header), importing the compat PFX on a real Windows Server, outbound ports 80/443 from the alpha api container, and favicon/Nextcloud-logo loading under the stricter shared guard.
+
+## Gaps Summary
+
+No gaps. The goal is achieved in the codebase: tests, type checks and the live e2e suite (including a real AIA fetch) pass on an independent run, the old routes and forge certificate parsers are removed, and changelog/docs/version are in place. Planning-only files (the todo move, this task directory) remain uncommitted by design.
+
+---
+
+_Verified: 2026-10-09_
+_Verifier: Claude (gsd-verifier)_
diff --git a/.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs b/.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs
new file mode 100644
index 0000000..5bd3cae
--- /dev/null
+++ b/.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs
@@ -0,0 +1,53 @@
+#!/usr/bin/env node
+// Nachrichten-Pruefung fuer den Zertifikat-Manager (quick-261009-ikt), keine Abhaengigkeiten.
+// Aufruf: node check-cert-messages.cjs
+// Prueft: gleiche Schluessel in de.json und en.json, Mindestanzahl, certManager.title vorhanden,
+// keine Wörter zu Mandanten oder Lizenzen, keine Pfeil- und Mittelpunkt-Zeichen.
+const { execFileSync } = require('node:child_process');
+const { readFileSync } = require('node:fs');
+const { join } = require('node:path');
+
+const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim();
+const min = Number(process.argv[2] ?? 0);
+
+function flatten(value, prefix, out) {
+ if (value && typeof value === 'object') {
+ for (const [key, inner] of Object.entries(value)) flatten(inner, `${prefix}.${key}`, out);
+ } else {
+ out.set(prefix, String(value));
+ }
+ return out;
+}
+
+function load(name) {
+ const data = JSON.parse(readFileSync(join(root, 'apps/web/src/messages', name), 'utf8'));
+ return flatten(data.certManager ?? {}, 'certManager', new Map());
+}
+
+const de = load('de.json');
+const en = load('en.json');
+
+const onlyDe = [...de.keys()].filter((k) => !en.has(k));
+const onlyEn = [...en.keys()].filter((k) => !de.has(k));
+if (onlyDe.length || onlyEn.length) {
+ console.error(`key mismatch: only de [${onlyDe.join(', ')}] only en [${onlyEn.join(', ')}]`);
+ process.exit(1);
+}
+if (de.size < min) {
+ console.error(`too few keys: ${de.size} < ${min}`);
+ process.exit(1);
+}
+if (!de.has('certManager.title') || !en.has('certManager.title')) {
+ console.error('title missing');
+ process.exit(1);
+}
+const bad = /mandant|tenant|lizenz|licens|→|·/i;
+for (const map of [de, en]) {
+ for (const value of map.values()) {
+ if (bad.test(value)) {
+ console.error(`bad text: ${value}`);
+ process.exit(1);
+ }
+ }
+}
+console.log(`messages ok ${de.size}`);
diff --git a/.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh b/.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh
new file mode 100755
index 0000000..7c707e0
--- /dev/null
+++ b/.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh
@@ -0,0 +1,922 @@
+#!/usr/bin/env bash
+# Live-Pruefung des Zertifikat-Managers (quick-261009-ikt) gegen die lokale API.
+# Nur Testwerte aus den Fixtures; liest keine .env-Dateien.
+# Aufruf: e2e-cert.sh [files|fullchain|zip|inputs|formats|templates|version|aia|review|all]
+# all = jeder bisher gebaute Abschnitt in dieser Reihenfolge (Task 1: files, Task 2: fullchain, Task 3: zip, Task 4: inputs, Task 5: formats, Task 6: templates und version, Task 7: aia).
+# Voraussetzung: Stack laeuft und die API wurde neu gebaut (docker compose up -d --build api web).
+set -euo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# shellcheck source=/dev/null
+source "$HERE/../../261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh"
+ROOT="$(git -C "$HERE" rev-parse --show-toplevel)"
+FIX="$ROOT/apps/api/src/cert-manager/__fixtures__"
+JAR="$E2E_TMP/cert.jar"
+BUILT="files fullchain zip inputs formats templates version aia review" # Abschnitte, die schon gebaut sind (jeder Task haengt seinen an)
+
+setup() {
+ e2e_wait_health
+ e2e_login "$JAR"
+ e2e_activate "$JAR" cert-manager
+ local code
+ code=$(e2e_status "$JAR" POST "$API/modules/cert-manager/parse" '{}')
+ if [ "$code" != "404" ]; then
+ e2e_fail "API-Container neu bauen: docker compose up -d --build api (parse antwortet noch mit $code)"
+ fi
+}
+
+# analyze ... — schickt Fixtures (oder absolute Pfade) als multipart, gibt den HTTP-Status aus.
+# analyze_pw ... — wie analyze, mit dem Feld `passwords` (nur fuer diesen Aufruf).
+analyze_pw() { local ANALYZE_PASSWORDS=$1; shift; analyze "$@"; }
+
+# Mit gesetztem (lokalem) ANALYZE_PASSWORDS wird das Feld `passwords` mitgeschickt.
+analyze() {
+ local out=$1; shift
+ local args=() spec path name
+ [ -z "${ANALYZE_PASSWORDS:-}" ] || args+=(-F "passwords=$ANALYZE_PASSWORDS")
+ for spec in "$@"; do
+ path="${spec%%:*}"; name="${spec#*:}"
+ case "$path" in /*) ;; *) path="$FIX/$path" ;; esac
+ args+=(-F "files=@$path;filename=$name")
+ done
+ curl -s -o "$out" -w '%{http_code}' -b "$JAR" "${args[@]}" "$API/modules/cert-manager/analyze"
+}
+
+section_files() {
+ local out="$E2E_TMP/analyze.out" code
+ code=$(analyze "$out" rsa-leaf.pem:rsa-leaf.pem rsa-inter.pem:rsa-inter.pem ec-leaf.cer:ec-leaf.cer \
+ ec-inter.pem:ec-inter.pem ec-root.pem:ec-root.pem rsa-leaf.cer:rsa-leaf.cer)
+ e2e_expect 200 "$code" "analyze mit sechs Dateien"
+ python3 -I - "$out" <<'PY' || e2e_fail "analyze-Antwort stimmt nicht"
+import json, sys
+d = json.load(open(sys.argv[1]))
+certs = [i for i in d["items"] if i["kind"] == "certificate"]
+assert len(certs) == 5, f"fuenf verschiedene Zertifikate erwartet, waren {len(certs)}"
+by_cn = {c["cn"]: c for c in certs}
+leaf = by_cn["www.example.test"]
+assert [s["file"] for s in leaf["sources"]] == [0, 5], leaf["sources"]
+ec = by_cn["ec.example.test"]
+assert ec["keyType"] == "EC" and ec["curve"] == "P-256", (ec["keyType"], ec["curve"])
+assert ec["role"] == "end-entity"
+assert by_cn["Tessera Test Inter RSA"]["role"] == "intermediate"
+assert by_cn["Tessera Test Inter EC"]["role"] == "intermediate"
+assert by_cn["Tessera Test Root EC"]["role"] == "root"
+assert d["locked"] == [] and d["ignored"] == []
+# Task 2: zwei Ketten, RSA unvollstaendig (Luecke nach der CA), EC vollstaendig mit Wurzel
+assert len(d["chains"]) == 2, d["chains"]
+by_id = {c["id"]: c for c in certs}
+rsa = [c for c in d["chains"] if by_id[c["headId"]]["cn"] == "www.example.test"][0]
+ec_chain = [c for c in d["chains"] if by_id[c["headId"]]["cn"] == "ec.example.test"][0]
+assert rsa["complete"] is False and rsa["gap"]["kind"] == "afterCa", rsa
+assert rsa["gap"]["missingIssuerCn"] == "Tessera Test Root RSA"
+assert ec_chain["complete"] is True and by_id[ec_chain["rootId"]]["cn"] == "Tessera Test Root EC"
+PY
+
+ code=$(curl -s -o "$out" -w '%{http_code}' -b "$JAR" -X POST "$API/modules/cert-manager/analyze")
+ e2e_expect 400 "$code" "analyze ohne Dateien"
+ e2e_contains "$out" 'invalidInput' "analyze ohne Dateien: Code"
+
+ code=$(analyze "$out" README.md:readme.txt)
+ e2e_expect 200 "$code" "analyze mit unbekannter Datei"
+ e2e_contains "$out" '"reason":"unknown"' "unbekannte Datei gemeldet"
+
+ local route
+ for route in parse split merge convert export; do
+ code=$(e2e_status "$JAR" POST "$API/modules/cert-manager/$route" '{}')
+ e2e_expect 404 "$code" "alte Route $route"
+ done
+ echo "e2e cert files ok"
+}
+
+# build_json ... — schreibt den Anfragekoerper.
+build_json() {
+ local out=$1 content=$2 cert=$3 root=$4; shift 4
+ python3 -I - "$out" "$content" "$FIX/$cert" "$root" "$@" <<'PY'
+import json, sys
+out, content, cert, root = sys.argv[1:5]
+pool = [open(f"{__import__('os').path.dirname(cert)}/{n}").read() for n in sys.argv[5:]]
+body = {"content": content, "format": "pem", "certPem": open(cert).read(), "poolPems": pool,
+ "includeRoot": root == "true"}
+json.dump(body, open(out, "w"))
+PY
+}
+
+# post_build — gibt den HTTP-Status aus.
+post_build() {
+ curl -s -o "$2" -w '%{http_code}' -b "$JAR" -X POST -H 'Content-Type: application/json' \
+ --data-binary "@$1" "$API/modules/cert-manager/build"
+}
+
+# pem_subjects — schreibt die Zertifikate der ersten Datei als cert-N.pem nach $E2E_TMP
+# und gibt die CNs zeilenweise aus.
+pem_subjects() {
+ python3 -I - "$1" "$E2E_TMP" <<'PY'
+import base64, json, re, sys
+d = json.load(open(sys.argv[1]))
+text = base64.b64decode(d["files"][0]["content"]).decode()
+blocks = re.findall(r"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----\n", text, re.S)
+for i, b in enumerate(blocks):
+ open(f"{sys.argv[2]}/cert-{i}.pem", "w").write(b)
+print(len(blocks))
+PY
+}
+
+section_fullchain() {
+ local out="$E2E_TMP/build.out" req="$E2E_TMP/build.req" code n
+ # Pruefung, dass der API-Container diesen Task traegt: build existiert (leerer Koerper -> 400, nicht 404)
+ code=$(e2e_status "$JAR" POST "$API/modules/cert-manager/build" '{}' "$out")
+ [ "$code" != "404" ] || e2e_fail "API-Container neu bauen: docker compose up -d --build api (build fehlt)"
+ e2e_expect 400 "$code" "build mit leerem Koerper"
+
+ # Fullchain: EC-Server, Pool in falscher Reihenfolge plus fremdes RSA-Zwischenzertifikat
+ build_json "$req" fullchain ec-leaf.pem false ec-root.pem ec-inter.pem rsa-inter.pem
+ code=$(post_build "$req" "$out")
+ e2e_expect 200 "$code" "Fullchain ohne Wurzel"
+ n=$(pem_subjects "$out")
+ [ "$n" = "2" ] || e2e_fail "Fullchain ohne Wurzel: zwei Zertifikate erwartet, waren $n"
+ openssl x509 -in "$E2E_TMP/cert-0.pem" -noout -subject | grep -q 'ec.example.test' \
+ || e2e_fail "Fullchain: erstes Zertifikat ist nicht der Server"
+ openssl verify -CAfile "$FIX/ec-root.pem" -untrusted "$E2E_TMP/cert-1.pem" "$E2E_TMP/cert-0.pem" \
+ | grep -q ': OK' || e2e_fail "openssl verify der Fullchain schlug fehl"
+ python3 -I - "$out" <<'PY' || e2e_fail "Fullchain-Antwort stimmt nicht"
+import json, sys
+d = json.load(open(sys.argv[1]))
+assert d["files"][0]["filename"] == "ec.example.test-fullchain.pem", d["files"][0]["filename"]
+assert d["chainComplete"] is True and d["missingIssuerCn"] is None
+PY
+
+ build_json "$req" fullchain ec-leaf.pem true ec-root.pem ec-inter.pem rsa-inter.pem
+ code=$(post_build "$req" "$out")
+ e2e_expect 200 "$code" "Fullchain mit Wurzel"
+ n=$(pem_subjects "$out")
+ [ "$n" = "3" ] || e2e_fail "Fullchain mit Wurzel: drei Zertifikate erwartet, waren $n"
+ openssl x509 -in "$E2E_TMP/cert-2.pem" -noout -subject | grep -q 'Root EC' \
+ || e2e_fail "Fullchain mit Wurzel: Wurzel nicht zuletzt"
+
+ build_json "$req" chain ec-leaf.pem false ec-root.pem ec-inter.pem rsa-inter.pem
+ code=$(post_build "$req" "$out")
+ e2e_expect 200 "$code" "Nur Kette"
+ n=$(pem_subjects "$out")
+ [ "$n" = "1" ] || e2e_fail "Nur Kette: ein Zertifikat erwartet, waren $n"
+ openssl x509 -in "$E2E_TMP/cert-0.pem" -noout -subject | grep -q 'Inter EC' \
+ || e2e_fail "Nur Kette: nicht das Zwischenzertifikat"
+
+ # RSA ohne Wurzel: unvollstaendig, Name des fehlenden Ausstellers
+ build_json "$req" fullchain rsa-leaf.pem false rsa-inter.pem
+ code=$(post_build "$req" "$out")
+ e2e_expect 200 "$code" "RSA-Fullchain ohne Wurzel"
+ python3 -I - "$out" <<'PY' || e2e_fail "RSA-Fullchain: Luecke nicht gemeldet"
+import json, sys
+d = json.load(open(sys.argv[1]))
+assert d["chainComplete"] is False
+assert d["missingIssuerCn"] == "Tessera Test Root RSA", d["missingIssuerCn"]
+PY
+
+ # Fehlercodes
+ build_json "$req" chain rsa-leaf.pem false
+ code=$(post_build "$req" "$out")
+ e2e_expect 400 "$code" "Nur Kette ohne Zwischenzertifikat"
+ e2e_contains "$out" '"code":"noChain"' "noChain"
+ python3 -I - "$req" <<'PY'
+import json, sys
+p = sys.argv[1]
+d = json.load(open(p)); d["certPem"] = "kein Zertifikat"; json.dump(d, open(p, "w"))
+PY
+ code=$(post_build "$req" "$out")
+ e2e_expect 400 "$code" "certPem kein Zertifikat"
+ e2e_contains "$out" '"code":"notACertificate"' "notACertificate"
+
+ # Grenze: ein Koerper innerhalb der DTO-Grenzen (rund 330 kB) wird gelesen, nie mit 413 abgewiesen
+ python3 -I - "$req" "$FIX/ec-leaf.pem" <<'PY'
+import json, sys
+pool = ["\n".join(["A" * 64] * 255)[:16384] for _ in range(20)]
+json.dump({"content": "fullchain", "format": "pem", "certPem": open(sys.argv[2]).read(),
+ "poolPems": pool}, open(sys.argv[1], "w"))
+PY
+ [ "$(wc -c < "$req")" -gt 300000 ] || e2e_fail "Pruefkoerper zu klein"
+ code=$(post_build "$req" "$out")
+ e2e_expect 400 "$code" "Koerper innerhalb der DTO-Grenzen"
+ e2e_contains "$out" '"code":"notACertificate"' "Koerper innerhalb der Grenzen: Code"
+
+ # Ein Koerper ueber 512 KiB: 413 mit Code
+ python3 -I - "$req" <<'PY'
+import json, sys
+json.dump({"content": "fullchain", "baseName": "x" * (600 * 1024)}, open(sys.argv[1], "w"))
+PY
+ code=$(post_build "$req" "$out")
+ e2e_expect 413 "$code" "Koerper ueber 512 KiB"
+ e2e_contains "$out" '"code":"tooLarge"' "tooLarge"
+
+ # Kaputtes JSON: 400 mit Code
+ printf '{"content": ' > "$req"
+ code=$(post_build "$req" "$out")
+ e2e_expect 400 "$code" "kaputtes JSON"
+ e2e_contains "$out" '"code":"invalidInput"' "invalidInput bei kaputtem JSON"
+
+ # Alle anderen Routen behalten 100 kB (Anmeldung mit 150 kB -> 413), Nests JSON-Leser bleibt aktiv
+ python3 -I - "$req" <<'PY'
+import json, sys
+json.dump({"username": "admin", "password": "x" * 150000}, open(sys.argv[1], "w"))
+PY
+ code=$(curl -s -o "$out" -w '%{http_code}' -X POST -H 'Content-Type: application/json' \
+ --data-binary "@$req" "$API/auth/login")
+ e2e_expect 413 "$code" "Anmeldung mit 150 kB"
+ e2e_login "$E2E_TMP/cert-relogin.jar"
+ echo "e2e cert fullchain ok"
+}
+
+# Abschnitt zip (Task 3): Hersteller-ZIP, PKCS#7 und die daraus gebaute Fullchain
+section_zip() {
+ local out="$E2E_TMP/zip.out" out2="$E2E_TMP/zip2.out" req="$E2E_TMP/zip.req" code
+ local vendor="$E2E_TMP/vendor.zip"
+ python3 -I - "$FIX" "$vendor" <<'PY'
+import io, sys, zipfile
+fix, out = sys.argv[1], sys.argv[2]
+rd = lambda n: open(f"{fix}/{n}", "rb").read()
+inner = io.BytesIO()
+with zipfile.ZipFile(inner, "w") as z:
+ z.writestr("x.txt", "innen")
+with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
+ z.writestr("ServerCertificate.crt", rd("ec-leaf.pem"))
+ z.writestr("Intermediate/CA.crt", rd("ec-inter.pem"))
+ z.writestr("Root/root.crt", rd("ec-root.pem"))
+ z.writestr("__MACOSX/._ServerCertificate.crt", b"mac")
+ z.writestr("inner.zip", inner.getvalue())
+ z.writestr("readme.txt", "Bitte lesen")
+PY
+
+ code=$(analyze "$out" rsa-leaf.pem:rsa-leaf.pem rsa-inter.pem:rsa-inter.pem "$vendor:vendor.zip")
+ e2e_expect 200 "$code" "analyze mit Hersteller-ZIP"
+ python3 -I - "$out" <<'PY' || e2e_fail "ZIP-Analyse stimmt nicht"
+import json, sys
+raw = open(sys.argv[1]).read()
+assert "MACOSX" not in raw, "MACOSX darf nirgends vorkommen"
+d = json.loads(raw)
+certs = [i for i in d["items"] if i["kind"] == "certificate"]
+assert len(certs) == 5, f"fuenf verschiedene Zertifikate erwartet, waren {len(certs)}"
+assert len(d["chains"]) == 2, d["chains"]
+reasons = {(i["path"], i["reason"]) for i in d["ignored"]}
+assert ("vendor.zip/inner.zip", "nestedZip") in reasons, reasons
+assert ("vendor.zip/readme.txt", "unknown") in reasons, reasons
+ec = [c for c in certs if c["cn"] == "ec.example.test"][0]
+assert ec["sources"] == [{"file": 2, "path": "vendor.zip/ServerCertificate.crt"}], ec["sources"]
+PY
+
+ # dasselbe ZIP ohne die Endung .zip: erkannt an den Anfangsbytes
+ code=$(analyze "$out2" "$vendor:bundle.dat")
+ e2e_expect 200 "$code" "analyze mit ZIP als bundle.dat"
+ python3 -I - "$out2" <<'PY' || e2e_fail "ZIP als bundle.dat: Zertifikate stimmen nicht"
+import json, sys
+d = json.load(open(sys.argv[1]))
+cns = sorted(i["cn"] for i in d["items"] if i["kind"] == "certificate")
+assert cns == ["Tessera Test Inter EC", "Tessera Test Root EC", "ec.example.test"], cns
+assert any(i["path"].startswith("bundle.dat/") for i in d["ignored"]), d["ignored"]
+PY
+
+ # PKCS#7: DER (RSA) und PEM (EC, von forge allein nicht lesbar), je drei Zertifikate
+ local p7 n
+ for p7 in rsa-chain.p7c ec-chain.p7b; do
+ code=$(analyze "$out" "$p7:$p7")
+ e2e_expect 200 "$code" "analyze $p7"
+ n=$(python3 -I -c 'import json,sys; print(sum(1 for i in json.load(open(sys.argv[1]))["items"] if i["kind"]=="certificate"))' "$out")
+ [ "$n" = "3" ] || e2e_fail "$p7: drei Zertifikate erwartet, waren $n"
+ done
+
+ # Fullchain aus den EC-Zertifikaten, die aus dem ZIP erkannt wurden: zwei Bloecke, openssl prueft die Kette
+ python3 -I - "$out2" "$req" <<'PY'
+import json, sys
+d = json.load(open(sys.argv[1]))
+by = {i["cn"]: i["pem"] for i in d["items"] if i["kind"] == "certificate"}
+json.dump({"content": "fullchain", "format": "pem", "certPem": by["ec.example.test"],
+ "poolPems": [by["Tessera Test Root EC"], by["Tessera Test Inter EC"]],
+ "includeRoot": False}, open(sys.argv[2], "w"))
+PY
+ code=$(post_build "$req" "$out")
+ e2e_expect 200 "$code" "Fullchain aus ZIP-Zertifikaten"
+ n=$(pem_subjects "$out")
+ [ "$n" = "2" ] || e2e_fail "Fullchain aus ZIP: zwei Bloecke erwartet, waren $n"
+ openssl verify -CAfile "$FIX/ec-root.pem" -untrusted "$E2E_TMP/cert-1.pem" "$E2E_TMP/cert-0.pem" \
+ | grep -q ': OK' || e2e_fail "openssl verify der Fullchain aus dem ZIP schlug fehl"
+ echo "e2e cert zip ok"
+}
+
+# Abschnitt inputs (Task 4): Schluessel, PFX und CSR mit Passwort je Datei, Zuordnung, kein Passwort im Log
+section_inputs() {
+ local out="$E2E_TMP/inputs.out" code
+ local pw='Test-Pass-123'
+
+ # Vollstaendiger Satz mit Passwoertern im Takt der Dateien
+ code=$(analyze_pw "[\"\",\"\",\"\",\"$pw\",\"\",\"$pw\",\"\"]" "$out" rsa-leaf.pem:rsa-leaf.pem rsa-inter.pem:rsa-inter.pem rsa-root.pem:rsa-root.pem \
+ rsa-leaf-key-enc-trad.pem:rsa-leaf-key-enc-trad.pem rsa-leaf.csr:rsa-leaf.csr \
+ ec-compat.pfx:ec-compat.pfx ec-leaf.csr.der:ec-leaf.csr.der)
+ e2e_expect 200 "$code" "analyze mit Schluessel, PFX und CSR"
+ python3 -I - "$out" <<'PY' || e2e_fail "inputs: Analyse stimmt nicht"
+import json, sys
+raw = open(sys.argv[1]).read()
+assert "Test-Pass-123" not in raw, "Passwort in der Antwort"
+d = json.loads(raw)
+by_kind = {}
+for i in d["items"]:
+ by_kind.setdefault(i["kind"], []).append(i)
+assert len(by_kind["certificate"]) == 6, len(by_kind["certificate"])
+assert sorted(k["keyType"] for k in by_kind["privateKey"]) == ["EC", "RSA"]
+assert sorted(r["keyType"] for r in by_kind["csr"]) == ["EC", "RSA"]
+assert d["locked"] == [], d["locked"]
+certs = {c["cn"]: c for c in by_kind["certificate"]}
+keys = {k["keyType"]: k for k in by_kind["privateKey"]}
+csrs = {r["keyType"]: r for r in by_kind["csr"]}
+rsa, ec = certs["www.example.test"], certs["ec.example.test"]
+assert rsa["keyId"] == keys["RSA"]["id"] and keys["RSA"]["certIds"] == [rsa["id"]]
+assert keys["RSA"]["wasEncrypted"] is True
+assert rsa["csrIds"] == [csrs["RSA"]["id"]]
+assert csrs["RSA"]["keyId"] == keys["RSA"]["id"] and csrs["RSA"]["certIds"] == [rsa["id"]]
+assert ec["keyId"] == keys["EC"]["id"], "EC-Schluessel aus der PFX fehlt am EC-Zertifikat"
+assert csrs["EC"]["certIds"] == [ec["id"]] and csrs["EC"]["keyId"] == keys["EC"]["id"]
+assert "PRIVATE KEY" in keys["RSA"]["pem"] # nur als PKCS#8-Antwort fuer die Oberflaeche, nie im Log
+PY
+
+ # PFX ohne Passwort gesperrt, mit falschem Passwort passwordWrong
+ code=$(analyze "$out" rsa-modern.pfx:rsa-modern.pfx)
+ e2e_expect 200 "$code" "analyze mit gesperrter PFX"
+ e2e_contains "$out" '"reason":"passwordNeeded"' "PFX ohne Passwort"
+ e2e_contains "$out" '"container":"pkcs12"' "Container pkcs12"
+ code=$(analyze_pw '["falsch"]' "$out" rsa-modern.pfx:rsa-modern.pfx)
+ e2e_expect 200 "$code" "analyze mit falschem Passwort"
+ e2e_contains "$out" '"reason":"passwordWrong"' "PFX mit falschem Passwort"
+
+ # Altes RC2-Format und PFX ohne Endung, mit Passwort: Zertifikate plus Schluessel
+ local f
+ for f in rsa-legacy.pfx rsa-modern.bin; do
+ code=$(analyze_pw "[\"$pw\"]" "$out" "$f:$f")
+ e2e_expect 200 "$code" "analyze $f"
+ python3 -I - "$out" "$f" <<'PY' || e2e_fail "$f: Zertifikate und Schluessel erwartet"
+import json, sys
+d = json.load(open(sys.argv[1]))
+kinds = [i["kind"] for i in d["items"]]
+assert kinds.count("certificate") == 3 and kinds.count("privateKey") == 1, (sys.argv[2], kinds)
+assert d["locked"] == []
+PY
+ done
+
+ # Verschluesselter Schluessel: ohne Passwort gesperrt, mit falschem passwordWrong
+ code=$(analyze "$out" ec-leaf-key-enc-pkcs8.der:ec-leaf-key-enc-pkcs8.der)
+ e2e_contains "$out" '"container":"privateKey"' "verschluesselter Schluessel gesperrt"
+ code=$(analyze_pw '["falsch"]' "$out" ec-leaf-key-enc-pkcs8.der:ec-leaf-key-enc-pkcs8.der)
+ e2e_contains "$out" '"reason":"passwordWrong"' "verschluesselter Schluessel mit falschem Passwort"
+
+ # Ungueltiges Feld passwords: 400 invalidInput
+ code=$(analyze_pw 'nope' "$out" rsa-leaf.pem:rsa-leaf.pem)
+ e2e_expect 400 "$code" "passwords kein JSON"
+ e2e_contains "$out" '"code":"invalidInput"' "passwords: invalidInput"
+
+ # Kein Passwort und kein Schluessel im API-Log
+ local logs
+ logs=$(cd "$ROOT" && docker compose logs api --since 10m 2>&1)
+ if printf '%s' "$logs" | grep -q "$pw"; then e2e_fail "Passwort im API-Log"; fi
+ if printf '%s' "$logs" | grep -q 'PRIVATE KEY'; then e2e_fail "Schluessel im API-Log"; fi
+ echo "e2e cert inputs ok"
+}
+
+# mkbody — schreibt den Anfragekoerper; Werte "@datei" werden durch den Inhalt der Fixture ersetzt.
+mkbody() {
+ python3 -I - "$1" "$2" "$FIX" <<'PY'
+import json, sys
+out, spec, fix = sys.argv[1:4]
+def sub(v):
+ if isinstance(v, str) and v.startswith("@"):
+ return open(f"{fix}/{v[1:]}").read()
+ if isinstance(v, list):
+ return [sub(x) for x in v]
+ return v
+json.dump({k: sub(v) for k, v in json.loads(spec).items()}, open(out, "w"))
+PY
+}
+
+# build_files — schickt build (Status 200 erwartet) und legt alle Dateien der Antwort im Ordner ab.
+build_files() {
+ local dir=$1 body=$2 code
+ mkdir -p "$dir"
+ mkbody "$dir/request.json" "$body"
+ code=$(post_build "$dir/request.json" "$dir/response.json")
+ e2e_expect 200 "$code" "build $(basename "$dir")"
+ python3 -I - "$dir/response.json" "$dir" <<'PY'
+import base64, json, os, sys
+d = json.load(open(sys.argv[1]))
+for f in d["files"]:
+ open(os.path.join(sys.argv[2], os.path.basename(f["filename"])), "wb").write(base64.b64decode(f["content"]))
+PY
+}
+
+# build_fails — build muss mit diesem Status und Code scheitern.
+build_fails() {
+ local want=$1 wcode=$2 body=$3 code
+ mkbody "$E2E_TMP/fail.req" "$body"
+ code=$(post_build "$E2E_TMP/fail.req" "$E2E_TMP/fail.out")
+ e2e_expect "$want" "$code" "build-Fehler $wcode"
+ e2e_contains "$E2E_TMP/fail.out" "\"code\":\"$wcode\"" "build-Fehlercode $wcode"
+}
+
+# Abschnitt formats (Task 5): jede Ausgabe, von openssl gelesen
+section_formats() {
+ local pfxpw='Neu-Pass-2026' keypw='Test-Pass-123'
+ local set cn leaf key pool_inter pool_root inter_cn root_cn dir pool_json
+ for set in rsa ec; do
+ if [ "$set" = rsa ]; then
+ cn='www.example.test'; inter_cn='Tessera Test Inter RSA'; root_cn='Tessera Test Root RSA'
+ else
+ cn='ec.example.test'; inter_cn='Tessera Test Inter EC'; root_cn='Tessera Test Root EC'
+ fi
+ leaf="$set-leaf.pem"; key="$set-leaf-key.pem"
+ pool_json="[\"@$set-root.pem\",\"@$set-inter.pem\"]"
+
+ # Einzelzertifikat als DER
+ dir="$E2E_TMP/fmt-$set-leaf"
+ build_files "$dir" "{\"content\":\"leaf\",\"format\":\"der\",\"certPem\":\"@$leaf\"}"
+ openssl x509 -inform DER -in "$dir/$cn.cer" -noout -subject | grep -q "$cn" \
+ || e2e_fail "$set: DER-Zertifikat von openssl nicht lesbar"
+ # Einzelzertifikat als PKCS#7 (nur das Serverzertifikat)
+ build_files "$dir-p7b" "{\"content\":\"leaf\",\"format\":\"p7b\",\"certPem\":\"@$leaf\"}"
+ [ "$(openssl pkcs7 -in "$dir-p7b/$cn.p7b" -print_certs | grep -c '^subject=')" = 1 ] \
+ || e2e_fail "$set: leaf p7b enthaelt nicht genau ein Zertifikat"
+
+ # Fullchain als p7b und p7c mit Wurzel: openssl listet Server, Zwischen, Wurzel in dieser Reihenfolge
+ dir="$E2E_TMP/fmt-$set-chain"
+ build_files "$dir" "{\"content\":\"fullchain\",\"format\":\"p7b\",\"certPem\":\"@$leaf\",\"poolPems\":$pool_json,\"includeRoot\":true}"
+ build_files "$dir-c" "{\"content\":\"fullchain\",\"format\":\"p7c\",\"certPem\":\"@$leaf\",\"poolPems\":$pool_json,\"includeRoot\":true}"
+ local order_b order_c want
+ order_b=$(openssl pkcs7 -in "$dir/$cn-fullchain.p7b" -print_certs | grep '^subject=' | sed -E 's/.*CN ?= ?//')
+ order_c=$(openssl pkcs7 -inform DER -in "$dir-c/$cn-fullchain.p7c" -print_certs | grep '^subject=' | sed -E 's/.*CN ?= ?//')
+ want=$(printf '%s\n%s\n%s' "$cn" "$inter_cn" "$root_cn")
+ [ "$order_b" = "$want" ] || e2e_fail "$set: Fullchain p7b Reihenfolge falsch: $order_b"
+ [ "$order_c" = "$want" ] || e2e_fail "$set: Fullchain p7c Reihenfolge falsch: $order_c"
+ echo "--- $set Fullchain p7b/p7c (openssl pkcs7 -print_certs): $(printf '%s' "$order_b" | paste -sd ',' -) | $(printf '%s' "$order_c" | paste -sd ',' -)"
+ # ohne Wurzel (Vorgabe) nur Server und Zwischenzertifikat; Nur Kette ohne Server
+ build_files "$dir-n" "{\"content\":\"fullchain\",\"format\":\"p7b\",\"certPem\":\"@$leaf\",\"poolPems\":$pool_json}"
+ [ "$(openssl pkcs7 -in "$dir-n/$cn-fullchain.p7b" -print_certs | grep -c '^subject=')" = 2 ] \
+ || e2e_fail "$set: Fullchain p7b ohne Wurzel sollte zwei Zertifikate haben"
+ build_files "$dir-k" "{\"content\":\"chain\",\"format\":\"p7c\",\"certPem\":\"@$leaf\",\"poolPems\":$pool_json}"
+ [ "$(openssl pkcs7 -inform DER -in "$dir-k/$cn-chain.p7c" -print_certs | grep '^subject=' | sed -E 's/.*CN ?= ?//')" = "$inter_cn" ] \
+ || e2e_fail "$set: Nur Kette p7c sollte nur das Zwischenzertifikat enthalten"
+
+ # Zertifikat und Schluessel in einer PEM-Datei: erstes Zertifikat ist der Server, Schluessel passt
+ dir="$E2E_TMP/fmt-$set-bundle"
+ build_files "$dir" "{\"content\":\"leafKey\",\"format\":\"pem\",\"certPem\":\"@$leaf\",\"poolPems\":$pool_json,\"keyPem\":\"@$key\"}"
+ openssl x509 -in "$dir/$cn-bundle.pem" -noout -subject | grep -q "$cn" \
+ || e2e_fail "$set: erstes Zertifikat im Bundle ist nicht der Server"
+ [ "$(grep -c 'BEGIN CERTIFICATE' "$dir/$cn-bundle.pem")" = 2 ] || e2e_fail "$set: Bundle sollte zwei Zertifikate haben"
+ [ "$(openssl pkey -in "$dir/$cn-bundle.pem" -pubout)" = "$(openssl x509 -in "$dir/$cn-bundle.pem" -pubkey -noout)" ] \
+ || e2e_fail "$set: Schluessel im Bundle passt nicht zum Zertifikat"
+
+ # PFX in beiden Verschluesselungen
+ local enc info
+ for enc in compat modern; do
+ dir="$E2E_TMP/fmt-$set-pfx-$enc"
+ build_files "$dir" "{\"content\":\"pfx\",\"certPem\":\"@$leaf\",\"poolPems\":$pool_json,\"keyPem\":\"@$key\",\"password\":\"$pfxpw\",\"pfxEncryption\":\"$enc\"}"
+ info=$(openssl pkcs12 -info -noout -passin "pass:$pfxpw" -in "$dir/$cn.pfx" 2>&1) \
+ || e2e_fail "$set $enc: openssl liest die PFX nicht: $info"
+ if [ "$enc" = compat ]; then
+ printf '%s' "$info" | grep -q 'pbeWithSHA1And3-KeyTripleDES-CBC' || e2e_fail "$set: kompatible PFX ohne 3DES: $info"
+ printf '%s\n' "$info" > "$E2E_TMP/pfx-$set-compat.info"
+ else
+ printf '%s' "$info" | grep -q 'AES-256-CBC' || e2e_fail "$set: moderne PFX ohne AES-256-CBC: $info"
+ printf '%s\n' "$info" > "$E2E_TMP/pfx-$set-modern.info"
+ fi
+ # Inhalt: Serverzertifikat und Schluessel gehoeren zusammen
+ [ "$(openssl pkcs12 -passin "pass:$pfxpw" -in "$dir/$cn.pfx" -nocerts -nodes 2>/dev/null | openssl pkey -pubout)" \
+ = "$(openssl pkcs12 -passin "pass:$pfxpw" -in "$dir/$cn.pfx" -clcerts -nokeys 2>/dev/null | openssl x509 -pubkey -noout)" ] \
+ || e2e_fail "$set $enc: Schluessel und Zertifikat in der PFX passen nicht zusammen"
+ openssl pkcs12 -passin "pass:$pfxpw" -in "$dir/$cn.pfx" -clcerts -nokeys 2>/dev/null | openssl x509 -noout -subject | grep -q "$cn" \
+ || e2e_fail "$set $enc: Serverzertifikat fehlt in der PFX"
+ done
+
+ # Schluessel: PKCS#8 mit Passwort, klassisch (mit und ohne), DER mit Passwort
+ dir="$E2E_TMP/fmt-$set-key"
+ build_files "$dir" "{\"content\":\"key\",\"format\":\"pkcs8\",\"keyPem\":\"@$key\",\"password\":\"$keypw\",\"baseName\":\"k\"}"
+ openssl pkey -passin "pass:$keypw" -noout -in "$dir/k.key" || e2e_fail "$set: verschluesselter PKCS#8 nicht lesbar"
+ head -1 "$dir/k.key" | grep -q 'BEGIN ENCRYPTED PRIVATE KEY' || e2e_fail "$set: PKCS#8 mit Passwort nicht verschluesselt"
+ build_files "$dir-t" "{\"content\":\"key\",\"format\":\"traditional\",\"keyPem\":\"@$key\",\"baseName\":\"k\"}"
+ if [ "$set" = rsa ]; then
+ head -1 "$dir-t/k.rsa.key" | grep -q 'BEGIN RSA PRIVATE KEY' || e2e_fail "rsa: klassischer Schluessel ohne RSA-Kopf"
+ openssl pkey -noout -in "$dir-t/k.rsa.key" || e2e_fail "rsa: klassischer Schluessel nicht lesbar"
+ else
+ head -1 "$dir-t/k.ec.key" | grep -q 'BEGIN EC PRIVATE KEY' || e2e_fail "ec: klassischer Schluessel ohne EC-Kopf"
+ openssl pkey -noout -in "$dir-t/k.ec.key" || e2e_fail "ec: klassischer Schluessel nicht lesbar"
+ fi
+ build_files "$dir-d" "{\"content\":\"key\",\"format\":\"pkcs8-der\",\"keyPem\":\"@$key\",\"password\":\"$keypw\",\"baseName\":\"k\"}"
+ openssl pkey -inform DER -passin "pass:$keypw" -noout -in "$dir-d/k.key.der" || e2e_fail "$set: DER-Schluessel mit Passwort nicht lesbar"
+
+ # CSR als DER und als PEM
+ dir="$E2E_TMP/fmt-$set-csr"
+ build_files "$dir" "{\"content\":\"csr\",\"format\":\"der\",\"csrPem\":\"@$set-leaf.csr\",\"baseName\":\"anfrage\"}"
+ openssl req -inform DER -noout -subject -in "$dir/anfrage.csr.der" | grep -q "$cn" || e2e_fail "$set: CSR (DER) nicht lesbar"
+ build_files "$dir-p" "{\"content\":\"csr\",\"format\":\"pem\",\"csrPem\":\"@$set-leaf.csr\",\"baseName\":\"anfrage\"}"
+ openssl req -noout -subject -in "$dir-p/anfrage.csr" | grep -q "$cn" || e2e_fail "$set: CSR (PEM) nicht lesbar"
+ done
+
+ # Fehlerfaelle mit Code
+ build_fails 400 passwordRequired '{"content":"pfx","certPem":"@rsa-leaf.pem","keyPem":"@rsa-leaf-key.pem"}'
+ build_fails 400 keyMismatch '{"content":"leafKey","certPem":"@rsa-leaf.pem","keyPem":"@ec-leaf-key.pem"}'
+ build_fails 400 keyMissing '{"content":"leafKey","certPem":"@rsa-leaf.pem"}'
+ build_fails 400 invalidInput '{"content":"csr","format":"p7b","csrPem":"@rsa-leaf.csr"}'
+
+ # Kein Passwort und kein Schluessel im API-Log
+ local logs
+ logs=$(cd "$ROOT" && docker compose logs api --since 10m 2>&1)
+ local secret
+ for secret in 'Test-Pass-123' 'Neu-Pass-2026' 'PRIVATE KEY'; do
+ if printf '%s' "$logs" | grep -q "$secret"; then e2e_fail "$secret im API-Log"; fi
+ done
+
+ # Belege fuer die Zusammenfassung: PFX-Algorithmen und Reihenfolge der PKCS#7-Dateien
+ echo "--- openssl pkcs12 -info (rsa, kompatibel):"; grep -E 'MAC:|Shrouded' "$E2E_TMP/pfx-rsa-compat.info"
+ echo "--- openssl pkcs12 -info (rsa, modern):"; grep -E 'MAC:|Shrouded' "$E2E_TMP/pfx-rsa-modern.info"
+ echo "e2e cert formats ok"
+}
+
+# Abschnitt templates (Task 6): jede Vorlage fuer RSA und EC, von openssl gelesen
+section_templates() {
+ local pfxpw='Neu-Pass-2026'
+ local set cn leaf key dir pool_json
+ for set in rsa ec; do
+ if [ "$set" = rsa ]; then cn='www.example.test'; else cn='ec.example.test'; fi
+ leaf="$set-leaf.pem"; key="$set-leaf-key.pem"
+ pool_json="[\"@$set-root.pem\",\"@$set-inter.pem\"]"
+ local base="\"content\":\"template\",\"certPem\":\"@$leaf\",\"poolPems\":$pool_json,\"keyPem\":\"@$key\""
+ local leafpub; leafpub=$(openssl x509 -in "$FIX/$leaf" -pubkey -noout)
+
+ # Nginx und Apache: Fullchain besteht die Pruefung gegen die Wurzel, der Schluessel passt zum Zertifikat
+ local tpl
+ for tpl in nginx apache; do
+ dir="$E2E_TMP/tpl-$set-$tpl"
+ build_files "$dir" "{$base,\"template\":\"$tpl\"}"
+ openssl verify -CAfile "$FIX/$set-root.pem" -untrusted "$dir/fullchain.pem" "$FIX/$leaf" >/dev/null \
+ || e2e_fail "$set $tpl: openssl verify der Kette"
+ [ "$(grep -c 'BEGIN CERTIFICATE' "$dir/fullchain.pem")" = 2 ] || e2e_fail "$set $tpl: fullchain ohne Wurzel sollte zwei Zertifikate haben"
+ [ "$(openssl pkey -in "$dir/privkey.pem" -pubout)" = "$leafpub" ] || e2e_fail "$set $tpl: Schluessel passt nicht zum Zertifikat"
+ head -1 "$dir/privkey.pem" | grep -q 'BEGIN PRIVATE KEY' || e2e_fail "$set $tpl: privkey.pem nicht PKCS#8"
+ done
+ python3 -I -c "import json,sys; d=json.load(open(sys.argv[1])); assert 'ssl_certificate /etc/nginx/ssl/'+sys.argv[2]+'/fullchain.pem;' in d['snippet'], d['snippet']" "$E2E_TMP/tpl-$set-nginx/response.json" "$cn" \
+ || e2e_fail "$set nginx: Schnipsel falsch"
+
+ # Apache aelter als 2.4.8: Zertifikat, Kette und Schluessel getrennt
+ dir="$E2E_TMP/tpl-$set-apache-legacy"
+ build_files "$dir" "{$base,\"template\":\"apache-legacy\"}"
+ openssl verify -CAfile "$FIX/$set-root.pem" -untrusted "$dir/chain.pem" "$dir/cert.pem" >/dev/null \
+ || e2e_fail "$set apache-legacy: openssl verify cert.pem mit chain.pem"
+ [ "$(grep -c 'BEGIN CERTIFICATE' "$dir/cert.pem")" = 1 ] || e2e_fail "$set apache-legacy: cert.pem sollte nur das Serverzertifikat enthalten"
+ [ "$(grep -c 'BEGIN CERTIFICATE' "$dir/chain.pem")" = 1 ] || e2e_fail "$set apache-legacy: chain.pem sollte nur das Zwischenzertifikat enthalten"
+ [ "$(openssl pkey -in "$dir/privkey.pem" -pubout)" = "$leafpub" ] || e2e_fail "$set apache-legacy: Schluessel passt nicht"
+
+ # IIS: PFX mit 3DES (ohne pfxEncryption in der Anfrage), Schluessel und Zertifikat gehoeren zusammen
+ dir="$E2E_TMP/tpl-$set-iis"
+ build_files "$dir" "{$base,\"template\":\"iis\",\"password\":\"$pfxpw\"}"
+ openssl pkcs12 -info -noout -passin "pass:$pfxpw" -in "$dir/$cn.pfx" 2>&1 | grep -q 'pbeWithSHA1And3-KeyTripleDES-CBC' \
+ || e2e_fail "$set iis: PFX ohne 3DES-Schluesselbeutel"
+ [ "$(openssl pkcs12 -passin "pass:$pfxpw" -in "$dir/$cn.pfx" -nocerts -nodes 2>/dev/null | openssl pkey -pubout)" = "$leafpub" ] \
+ || e2e_fail "$set iis: Schluessel in der PFX passt nicht zum Zertifikat"
+ echo "--- $set iis (openssl pkcs12 -info): $(openssl pkcs12 -info -noout -passin "pass:$pfxpw" -in "$dir/$cn.pfx" 2>&1 | grep -E 'Shrouded' | tr -s ' ')"
+
+ # Nginx Proxy Manager: Schluessel im klassischen Format, Zertifikat und Zwischenzertifikat getrennt, kein Schnipsel
+ dir="$E2E_TMP/tpl-$set-npm"
+ build_files "$dir" "{$base,\"template\":\"npm\"}"
+ if [ "$set" = rsa ]; then want='BEGIN RSA PRIVATE KEY'; else want='BEGIN EC PRIVATE KEY'; fi
+ head -1 "$dir/privkey.pem" | grep -q "$want" || e2e_fail "$set npm: privkey.pem ohne $want"
+ openssl pkey -in "$dir/privkey.pem" -noout || e2e_fail "$set npm: privkey.pem nicht lesbar"
+ [ "$(grep -c 'BEGIN CERTIFICATE' "$dir/certificate.pem")" = 1 ] || e2e_fail "$set npm: certificate.pem sollte nur das Serverzertifikat enthalten"
+ [ "$(grep -c 'BEGIN CERTIFICATE' "$dir/intermediate.pem")" = 1 ] || e2e_fail "$set npm: intermediate.pem sollte nur das Zwischenzertifikat enthalten"
+ python3 -I -c "import json,sys; assert json.load(open(sys.argv[1]))['snippet'] is None" "$dir/response.json" \
+ || e2e_fail "$set npm: Schnipsel sollte leer sein"
+
+ # HAProxy: eine Datei, erstes Zertifikat ist der Server, Schluessel am Ende
+ dir="$E2E_TMP/tpl-$set-haproxy"
+ build_files "$dir" "{$base,\"template\":\"haproxy\"}"
+ openssl x509 -in "$dir/$cn.pem" -noout -subject | grep -q "$cn" || e2e_fail "$set haproxy: erstes Zertifikat ist nicht der Server"
+ [ "$(grep -c 'BEGIN CERTIFICATE' "$dir/$cn.pem")" = 2 ] || e2e_fail "$set haproxy: sollte zwei Zertifikate haben"
+ [ "$(openssl pkey -in "$dir/$cn.pem" -pubout)" = "$leafpub" ] || e2e_fail "$set haproxy: Schluessel passt nicht"
+ [ "$(grep -n 'BEGIN PRIVATE KEY' "$dir/$cn.pem" | cut -d: -f1)" -gt "$(grep -n 'END CERTIFICATE' "$dir/$cn.pem" | tail -1 | cut -d: -f1)" ] \
+ || e2e_fail "$set haproxy: Schluessel steht nicht hinter den Zertifikaten"
+
+ # Tomcat: PKCS#12 mit dem Passwort lesbar, Schnipsel mit IHR-PASSWORT und ohne das echte Passwort
+ dir="$E2E_TMP/tpl-$set-tomcat"
+ build_files "$dir" "{$base,\"template\":\"tomcat\",\"password\":\"$pfxpw\"}"
+ openssl pkcs12 -info -noout -passin "pass:$pfxpw" -in "$dir/$cn.p12" >/dev/null 2>&1 || e2e_fail "$set tomcat: p12 nicht lesbar"
+ python3 -I - "$dir/response.json" "$pfxpw" "$cn" <<'PY' || e2e_fail "$set tomcat: Schnipsel falsch"
+import json, sys
+d = json.load(open(sys.argv[1]))
+assert "IHR-PASSWORT" in d["snippet"] and sys.argv[2] not in d["snippet"], d["snippet"]
+assert f'certificateKeyAlias="{sys.argv[3]}"' in d["snippet"], d["snippet"]
+PY
+ done
+
+ # Wurzel nur auf Wunsch: nginx mit includeRoot hat drei Zertifikate
+ dir="$E2E_TMP/tpl-root"
+ build_files "$dir" "{\"content\":\"template\",\"template\":\"nginx\",\"includeRoot\":true,\"certPem\":\"@rsa-leaf.pem\",\"poolPems\":[\"@rsa-root.pem\",\"@rsa-inter.pem\"],\"keyPem\":\"@rsa-leaf-key.pem\"}"
+ [ "$(grep -c 'BEGIN CERTIFICATE' "$dir/fullchain.pem")" = 3 ] || e2e_fail "nginx mit Wurzel sollte drei Zertifikate haben"
+
+ # Fehlerfaelle
+ build_fails 400 templateNeedsKey '{"content":"template","template":"nginx","certPem":"@rsa-leaf.pem"}'
+ build_fails 400 keyMismatch '{"content":"template","template":"nginx","certPem":"@rsa-leaf.pem","keyPem":"@ec-leaf-key.pem"}'
+ build_fails 400 passwordRequired '{"content":"template","template":"iis","certPem":"@rsa-leaf.pem","keyPem":"@rsa-leaf-key.pem"}'
+ build_fails 400 passwordRequired '{"content":"template","template":"tomcat","certPem":"@rsa-leaf.pem","keyPem":"@rsa-leaf-key.pem"}'
+ mkbody "$E2E_TMP/fail.req" '{"content":"template","template":"weblogic","certPem":"@rsa-leaf.pem","keyPem":"@rsa-leaf-key.pem"}'
+ e2e_expect 400 "$(post_build "$E2E_TMP/fail.req" "$E2E_TMP/fail.out")" "unbekannte Vorlage"
+
+ # Kein Passwort und kein Schluessel im API-Log
+ local logs secret
+ logs=$(cd "$ROOT" && docker compose logs api --since 10m 2>&1)
+ for secret in 'Neu-Pass-2026' 'PRIVATE KEY'; do
+ if printf '%s' "$logs" | grep -q "$secret"; then e2e_fail "$secret im API-Log"; fi
+ done
+ echo "e2e cert templates ok"
+}
+
+# Abschnitt version (Task 6): Modul-Changelog und Katalog
+section_version() {
+ local out="$E2E_TMP/changelog.out" code
+ code=$(e2e_status "$JAR" GET "$API/modules/changelog/cert-manager" '' "$out")
+ e2e_expect 200 "$code" "Modul-Changelog"
+ python3 -I - "$out" <<'PY' || e2e_fail "Modul-Changelog stimmt nicht"
+import json, sys
+d = json.load(open(sys.argv[1]))
+first = d[0]
+assert first["version"] == "1.2.0", first["version"]
+assert first["date"] == "2026-10-09", first["date"]
+assert len(first["changes"]) >= 5, len(first["changes"])
+assert d[1]["version"] == "1.1.0", d[1]["version"]
+assert any("Fehlendes Zertifikat holen" in c["de"] for c in first["changes"]), "AIA-Eintrag fehlt"
+PY
+ code=$(e2e_status "$JAR" GET "$API/modules/catalog" '' "$out")
+ e2e_expect 200 "$code" "Katalog"
+ python3 -I - "$out" <<'PY' || e2e_fail "Katalog zeigt nicht Version 1.2.0"
+import json, sys
+d = json.load(open(sys.argv[1]))
+mod = [m for m in d if m.get("slug") == "cert-manager"]
+assert len(mod) == 1, "cert-manager fehlt im Katalog"
+assert mod[0]["version"] == "1.2.0", mod[0]["version"]
+PY
+ echo "e2e cert version ok"
+}
+
+# post_fetch — schickt fetch-issuer, gibt den HTTP-Status aus.
+post_fetch() {
+ curl -s -o "$1" -w '%{http_code}' -b "$JAR" -H 'Content-Type: application/json' --data-binary "@$2" \
+ "$API/modules/cert-manager/fetch-issuer"
+}
+
+# Abschnitt aia (Task 7): „Fehlendes Zertifikat holen“. Live gegen letsencrypt.org, ausser CERT_E2E_OFFLINE=1.
+section_aia() {
+ local out="$E2E_TMP/fetch.out" req="$E2E_TMP/fetch.req" code
+
+ # interne Adressen im Zertifikat (127.0.0.1, 169.254.169.254, ldap://): nie angefragt
+ mkbody "$req" '{"pem":"@aia-private-leaf.pem"}'
+ code=$(post_fetch "$out" "$req")
+ e2e_expect 422 "$code" "fetch-issuer mit internen Adressen"
+ e2e_contains "$out" '"code":"aiaInternal"' "fetch-issuer interne Adressen: Code"
+
+ # Zertifikat ohne Aussteller-Adresse
+ mkbody "$req" '{"pem":"@rsa-leaf-noaki.pem"}'
+ code=$(post_fetch "$out" "$req")
+ e2e_expect 422 "$code" "fetch-issuer ohne Adresse"
+ e2e_contains "$out" '"code":"aiaMissing"' "fetch-issuer ohne Adresse: Code"
+
+ # keine Zertifikatsdaten
+ mkbody "$req" '{"pem":"das ist kein Zertifikat"}'
+ code=$(post_fetch "$out" "$req")
+ e2e_expect 400 "$code" "fetch-issuer mit Text"
+ e2e_contains "$out" '"code":"notACertificate"' "fetch-issuer mit Text: Code"
+
+ # eine vom Browser mitgeschickte Adresse wird ignoriert: gleiches Ergebnis wie ohne
+ mkbody "$req" '{"pem":"@aia-private-leaf.pem","url":"http://127.0.0.1/"}'
+ code=$(post_fetch "$out" "$req")
+ e2e_expect 422 "$code" "fetch-issuer mit mitgeschickter Adresse"
+ e2e_contains "$out" '"code":"aiaInternal"' "fetch-issuer mit mitgeschickter Adresse: wie ohne"
+
+ # zu langer Text
+ python3 -I - "$req" <<'PY'
+import json, sys
+json.dump({"pem": "x" * 16385}, open(sys.argv[1], "w"))
+PY
+ code=$(post_fetch "$out" "$req")
+ e2e_expect 400 "$code" "fetch-issuer mit zu langem Text"
+
+ # ohne Anmeldung kein Zugriff
+ code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Content-Type: application/json' --data-binary "@$req" \
+ "$API/modules/cert-manager/fetch-issuer")
+ e2e_expect 401 "$code" "fetch-issuer ohne Anmeldung"
+
+ if [ "${CERT_E2E_OFFLINE:-}" = "1" ]; then
+ echo "e2e cert aia: Live-Teil uebersprungen (CERT_E2E_OFFLINE=1)"
+ else
+ local leaf="$E2E_TMP/le-leaf.pem" fetched="$E2E_TMP/le-issuer.pem"
+ openssl s_client -connect letsencrypt.org:443 -servername letsencrypt.org /dev/null \
+ | openssl x509 -outform PEM > "$leaf" || e2e_fail "Zertifikat von letsencrypt.org nicht lesbar (CERT_E2E_OFFLINE=1 setzen, wenn kein Netz)"
+ [ -s "$leaf" ] || e2e_fail "leeres Zertifikat von letsencrypt.org"
+
+ # 1. Das Blatt allein: Luecke direkt dahinter, mit http-Adresse
+ code=$(analyze "$out" "$leaf:le-leaf.pem")
+ e2e_expect 200 "$code" "analyze letsencrypt.org-Blatt"
+ python3 -I - "$out" <<'PY' || e2e_fail "Luecke hinter dem Blatt stimmt nicht"
+import json, sys
+d = json.load(open(sys.argv[1]))
+assert len(d["chains"]) == 1, d["chains"]
+gap = d["chains"][0]["gap"]
+assert gap and gap["kind"] == "afterLeaf", gap
+assert gap["aiaUrls"] and gap["aiaUrls"][0].startswith("http"), gap
+PY
+
+ # 2. Auf Knopfdruck holen
+ python3 -I - "$leaf" "$req" <<'PY'
+import json, sys
+json.dump({"pem": open(sys.argv[1]).read()}, open(sys.argv[2], "w"))
+PY
+ code=$(post_fetch "$out" "$req")
+ e2e_expect 200 "$code" "fetch-issuer live"
+ python3 -I - "$out" "$fetched" <<'PY' || e2e_fail "fetch-issuer-Antwort stimmt nicht"
+import json, sys
+d = json.load(open(sys.argv[1]))
+assert d["host"].endswith("lencr.org"), d["host"]
+assert d["pem"].count("BEGIN CERTIFICATE") == 1, d["pem"].count("BEGIN CERTIFICATE")
+assert d["cn"] and d["filename"].endswith(".crt"), (d["cn"], d["filename"])
+open(sys.argv[2], "w").write(d["pem"])
+print(f" live geholt: Server {d['host']}, Name {d['cn']}, Datei {d['filename']}")
+PY
+ openssl verify -partial_chain -trusted "$fetched" "$leaf" >/dev/null \
+ || e2e_fail "openssl verify: geholtes Zertifikat hat das Blatt nicht ausgestellt"
+
+ # 3. Blatt plus geholtes Zertifikat: Weg aus zwei Zertifikaten, naechste Luecke dahinter
+ code=$(analyze "$out" "$leaf:le-leaf.pem" "$fetched:le-issuer.pem")
+ e2e_expect 200 "$code" "analyze Blatt plus Geholtes"
+ python3 -I - "$out" <<'PY' || e2e_fail "Kette mit dem geholten Zertifikat stimmt nicht"
+import json, sys
+d = json.load(open(sys.argv[1]))
+assert len(d["chains"]) == 1, d["chains"]
+chain = d["chains"][0]
+assert len(chain["path"]) == 2, chain["path"]
+assert chain["gap"] and chain["gap"]["kind"] == "afterCa", chain["gap"]
+by_id = {i["id"]: i for i in d["items"]}
+print(f" naechste Stufe: Weg {[by_id[i]['cn'] for i in chain['path']]}, es fehlt \"{chain['gap']['missingIssuerCn']}\", Adressen {chain['gap']['aiaUrls']}")
+PY
+ fi
+
+ local logs
+ logs=$(cd "$ROOT" && docker compose logs api --since 10m 2>&1)
+ if printf '%s' "$logs" | grep -q 'BEGIN CERTIFICATE'; then
+ e2e_fail "API-Log enthaelt Zertifikatstext"
+ fi
+ echo "e2e cert aia ok"
+}
+
+# Abschnitt review (Review-Korrekturen): CR-01 ZIP-Bombe, CR-02 PEM-Scanner, CR-03 Umlaut-Passwoerter,
+# WR-01 CORS, WR-04 Rechenaufwand, WR-05 Anzahl, WR-06 Gesamtgroesse, WR-07 Wurzel ohne basicConstraints
+section_review() {
+ local out="$E2E_TMP/review.out" code secs dir
+
+ # CR-01: 200 MiB Nullbytes, Kopfdaten behaupten Groesse 0 -> tooLarge, schnell, API lebt weiter
+ python3 -I - "$E2E_TMP/bomb.zip" <<'PY'
+import struct, sys, zipfile
+path = sys.argv[1]
+with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as z:
+ z.writestr("bombe.pem", bytes(200 * 1024 * 1024))
+data = bytearray(open(path, "rb").read())
+sig = b"PK\x01\x02"
+at = data.find(sig)
+while at != -1:
+ struct.pack_into("/dev/null \
+ && e2e_fail "CR-03: openssl oeffnet $enc-PFX mit falschem Passwort"
+ done
+ done
+ for spec in "rsa-umlaut-modern.pfx:pässwörd" "rsa-umlaut-compat.pfx:pässwörd" "rsa-euro-modern.pfx:pw€" "rsa-euro-compat.pfx:pw€"; do
+ pw="${spec#*:}"; local f="${spec%%:*}"
+ code=$(analyze_pw "[\"$pw\"]" "$out" "$f:$f")
+ e2e_expect 200 "$code" "CR-03 analyze $f"
+ python3 -I - "$out" "$f" <<'PY' || e2e_fail "CR-03: $f mit richtigem Passwort nicht geoeffnet"
+import json, sys
+d = json.load(open(sys.argv[1]))
+assert d["locked"] == [], d["locked"]
+assert len([i for i in d["items"] if i["kind"] == "certificate"]) == 3, sys.argv[2]
+assert len([i for i in d["items"] if i["kind"] == "privateKey"]) == 1, sys.argv[2]
+PY
+ code=$(analyze_pw '["pasword"]' "$out" "$f:$f")
+ e2e_expect 200 "$code" "CR-03 analyze $f falsches Passwort"
+ e2e_contains "$out" '"passwordWrong"\|"passwordNeeded"' "CR-03 $f bleibt gesperrt"
+ done
+
+ # WR-04: zu aufwendiger Passwortschutz wird uebersprungen
+ secs=$(curl -s -o "$out" -w '%{time_total}' -b "$JAR" -F 'passwords=["Test-Pass-123"]' \
+ -F "files=@$FIX/rsa-expensive.pfx;filename=teuer.pfx" -F "files=@$FIX/rsa-leaf-key-enc-expensive.pem;filename=teuer.pem" \
+ "$API/modules/cert-manager/analyze")
+ python3 -I - "$out" "$secs" <<'PY' || e2e_fail "WR-04: teurer Passwortschutz nicht uebersprungen"
+import json, sys
+d = json.load(open(sys.argv[1]))
+assert d["items"] == [] and d["locked"] == [], d
+assert sorted(i["reason"] for i in d["ignored"]) == ["protectionTooExpensive"] * 2, d["ignored"]
+assert float(sys.argv[2]) < 2, sys.argv[2]
+PY
+
+ # WR-05: 201 verschiedene Zertifikate in einer Datei -> 413 tooManyItems
+ local many="$E2E_TMP/many.pem" i
+ : > "$many"
+ openssl ecparam -name prime256v1 -genkey -noout -out "$E2E_TMP/many.key"
+ for i in $(seq 1 201); do
+ openssl req -x509 -new -key "$E2E_TMP/many.key" -subj "/CN=massen-$i.example.test" -days 30 -set_serial "$i" >> "$many" 2>/dev/null
+ done
+ code=$(analyze "$out" "$many:many.pem")
+ e2e_expect 413 "$code" "WR-05 201 Zertifikate"
+ e2e_contains "$out" '"code":"tooManyItems"' "WR-05 Code"
+ head -n "$(( $(grep -c 'END CERTIFICATE' "$many") * 0 + $(grep -n 'END CERTIFICATE' "$many" | sed -n 200p | cut -d: -f1) ))" "$many" > "$E2E_TMP/many200.pem"
+ code=$(analyze "$out" "$E2E_TMP/many200.pem:many200.pem")
+ e2e_expect 200 "$code" "WR-05 200 Zertifikate"
+
+ # WR-06: fuenf Dateien zu je 4,9 MiB (zusammen ueber 20 MiB, je Datei unter der Einzelgrenze) werden beim Empfang abgewiesen
+ local j args=()
+ for j in 1 2 3 4; do
+ head -c $((49 * 1024 * 1024 / 10)) /dev/zero > "$E2E_TMP/gross-$j.bin"
+ args+=(-F "files=@$E2E_TMP/gross-$j.bin;filename=gross-$j.bin")
+ done
+ args+=(-F "files=@$E2E_TMP/gross-1.bin;filename=gross-5.bin")
+ secs=$(curl -s -o "$out" -w '%{http_code} %{time_total}' -b "$JAR" "${args[@]}" "$API/modules/cert-manager/analyze")
+ case "$secs" in 413\ *) ;; *) e2e_fail "WR-06: erwartet 413, war $secs" ;; esac
+ e2e_contains "$out" 'tooLarge' "WR-06 Code"
+
+ # WR-01: zu grosser build-Koerper traegt CORS-Kopfzeilen
+ python3 -I -c 'import json; print(json.dumps({"content": "x" * 600000}))' > "$E2E_TMP/bigbuild.json"
+ curl -s -D "$E2E_TMP/cors.headers" -o "$out" -b "$JAR" -H 'Origin: http://localhost:3000' -H 'Content-Type: application/json' \
+ --data-binary "@$E2E_TMP/bigbuild.json" "$API/modules/cert-manager/build"
+ head -1 "$E2E_TMP/cors.headers" | grep -q '413' || e2e_fail "WR-01: erwartet 413 ($(head -1 "$E2E_TMP/cors.headers"))"
+ grep -qi '^access-control-allow-origin: http://localhost:3000' "$E2E_TMP/cors.headers" \
+ || { cat "$E2E_TMP/cors.headers" >&2; e2e_fail "WR-01: 413 ohne CORS-Kopfzeile"; }
+ e2e_contains "$out" '"code":"tooLarge"' "WR-01 Code"
+
+ # WR-07: Wurzel ohne basicConstraints
+ code=$(analyze "$out" selfsigned-v1-root.pem:v1.pem selfsigned-nobc-ca.pem:nobc-ca.pem selfsigned-nobc-leaf.pem:nobc-leaf.pem)
+ e2e_expect 200 "$code" "WR-07 analyze"
+ python3 -I - "$out" <<'PY' || e2e_fail "WR-07: Rollen stimmen nicht"
+import json, sys
+d = json.load(open(sys.argv[1]))
+role = {i["cn"]: i["role"] for i in d["items"] if i["kind"] == "certificate"}
+assert role["Tessera Test V1 Root"] == "root", role
+assert role["Tessera Test NoBC CA"] == "root", role
+assert role["nobc.example.test"] == "end-entity", role
+PY
+ echo "e2e cert review ok"
+}
+
+run_section() {
+ case "$1" in
+ files) section_files ;;
+ fullchain) section_fullchain ;;
+ zip) section_zip ;;
+ inputs) section_inputs ;;
+ formats) section_formats ;;
+ templates) section_templates ;;
+ version) section_version ;;
+ aia) section_aia ;;
+ review) section_review ;;
+ *) e2e_fail "unbekannter Abschnitt: $1 (files|fullchain|zip|inputs|formats|templates|version|aia|review|all)" ;;
+ esac
+}
+
+main() {
+ local what=${1:-all} s
+ setup
+ if [ "$what" = "all" ]; then
+ for s in $BUILT; do run_section "$s"; done
+ else
+ run_section "$what"
+ fi
+}
+
+main "$@"
diff --git a/.planning/todos/pending/2026-10-09-cert-manager-mehrere-dateien-zip-fullchain.md b/.planning/todos/completed/2026-10-09-cert-manager-mehrere-dateien-zip-fullchain.md
similarity index 100%
rename from .planning/todos/pending/2026-10-09-cert-manager-mehrere-dateien-zip-fullchain.md
rename to .planning/todos/completed/2026-10-09-cert-manager-mehrere-dateien-zip-fullchain.md