docs(quick-261009-ikt): Cert-Manager-Umbau
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+3
-2
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
|
|||||||
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
|
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
|
||||||
Plan: 6 of 6
|
Plan: 6 of 6
|
||||||
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
|
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
|
||||||
Last activity: 2026-10-09 - Completed quick task 261009-dkv: Dateien Etappe 2a Teilen (lokal, nicht gepusht)
|
Last activity: 2026-10-09 - Completed quick task 261009-ikt: Cert-Manager-Umbau
|
||||||
|
|
||||||
Progress: [██████████] 99%
|
Progress: [██████████] 99%
|
||||||
|
|
||||||
@@ -504,6 +504,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
|||||||
| 261008-w5w | Modul-Changelog je Modul (Version = neuester Eintrag, Wächter-Test), Marktplatz-Abschnitt „Änderungen“, Historie aller 10 Module nachgetragen | 2026-10-08 | 4c14ec8 | [261008-w5w-modul-changelog-und-modulversionen-nacht](.planning/quick/261008-w5w-modul-changelog-und-modulversionen-nacht/) |
|
| 261008-w5w | Modul-Changelog je Modul (Version = neuester Eintrag, Wächter-Test), Marktplatz-Abschnitt „Änderungen“, Historie aller 10 Module nachgetragen | 2026-10-08 | 4c14ec8 | [261008-w5w-modul-changelog-und-modulversionen-nacht](.planning/quick/261008-w5w-modul-changelog-und-modulversionen-nacht/) |
|
||||||
| 261008-who | Eigene Module beim Start vorladen (je Benutzer, Schalter in Modulansicht + Einstellungen), Keep-alive-Grenze 5→8, vorgeladene nie verworfen | 2026-10-09 | 86495ae | [261008-who-eigene-module-beim-start-vorladen](.planning/quick/261008-who-eigene-module-beim-start-vorladen/) |
|
| 261008-who | Eigene Module beim Start vorladen (je Benutzer, Schalter in Modulansicht + Einstellungen), Keep-alive-Grenze 5→8, vorgeladene nie verworfen | 2026-10-09 | 86495ae | [261008-who-eigene-module-beim-start-vorladen](.planning/quick/261008-who-eigene-module-beim-start-vorladen/) |
|
||||||
| 261009-dkv | Modul Dateien Etappe 2a: Teilen (Personen, Gruppen, Links nach Nextcloud-Regeln, Von mir/Mit mir geteilt), Review-Fixes, Nur-Ansehen-Ordner ohne Hochladen; Verifikation: Needs Review (Firmen-Nextcloud) | 2026-10-09 | d487a00 | [261009-dkv-modul-dateien-etappe-2a-teilen-von-datei](.planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/) |
|
| 261009-dkv | Modul Dateien Etappe 2a: Teilen (Personen, Gruppen, Links nach Nextcloud-Regeln, Von mir/Mit mir geteilt), Review-Fixes, Nur-Ansehen-Ordner ohne Hochladen; Verifikation: Needs Review (Firmen-Nextcloud) | 2026-10-09 | d487a00 | [261009-dkv-modul-dateien-etappe-2a-teilen-von-datei](.planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/) |
|
||||||
|
| 261009-ikt | Cert Manager Umbau: gemeinsamer Reiter Dateien (mehrere Dateien, ZIP, Text), Kette/Fullchain mit Signaturpruefung, alle Formate inkl. EC, PFX kompatibel/modern, Vorlagen fuer 6 Zielsysteme, Fehlendes Zertifikat holen (AIA), Adressschutz gehaertet, Review-Fixes (ZIP-Bombe, PEM-Scanner, Umlaut-PFX); Modul 1.2.0; Verifikation: Verified | 2026-10-09 | 4b87249 | [261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f](.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/) |
|
||||||
|
|
||||||
## Deferred Items
|
## Deferred Items
|
||||||
|
|
||||||
@@ -547,6 +548,6 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
|
|||||||
|
|
||||||
Last session: 2026-10-09T09:40:00Z
|
Last session: 2026-10-09T09:40:00Z
|
||||||
Resumed: 2026-10-09 ueber /gsd-resume-work (HANDOFF eingelesen und entfernt; CI 41a5754 gruen).
|
Resumed: 2026-10-09 ueber /gsd-resume-work (HANDOFF eingelesen und entfernt; CI 41a5754 gruen).
|
||||||
Stopped at: Teilen fertig (261009-dkv, lokal). Naechstes laut User 09.10.: 2) Cert-Manager-Umbau, 3) Anleitungen pruefen/nacharbeiten; danach Sicherheitsprotokoll/CI-Scanner, Dateien-Suche.
|
Stopped at: Teilen (261009-dkv) und Cert Manager (261009-ikt) fertig. Naechstes laut User 09.10.: Anleitungen pruefen/nacharbeiten; danach Sicherheitsprotokoll/CI-Scanner, Dateien-Suche.
|
||||||
Resume file: None
|
Resume file: None
|
||||||
Last activity: 2026-09-29 - Quick 260929-if2 Erinnerungen-Widget (lokal, nicht gepusht); v1.7.0 auf alpha+live
|
Last activity: 2026-09-29 - Quick 260929-if2 Erinnerungen-Widget (lokal, nicht gepusht); v1.7.0 auf alpha+live
|
||||||
|
|||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
# Quick Task 261009-ikt: Cert Manager Umbau: mehrere Dateien, ZIP, Fullchain, alle Formate - Context
|
||||||
|
|
||||||
|
**Gathered:** 2026-10-09
|
||||||
|
**Status:** Ready for planning
|
||||||
|
|
||||||
|
<domain>
|
||||||
|
## Task Boundary
|
||||||
|
|
||||||
|
Rework module "Zertifikat-Manager" (slug cert-manager; api `apps/api/src/cert-manager/`, web
|
||||||
|
`apps/web/src/app/(portal)/modules/cert-manager/`). Source of the request:
|
||||||
|
`.planning/todos/pending/2026-10-09-cert-manager-mehrere-dateien-zip-fullchain.md` (user test 09.10.):
|
||||||
|
1. Bug: merging accepts only ONE file — selecting a second overwrites the first.
|
||||||
|
2. Upload of a ZIP (as delivered by a certificate vendor) — Tessera unpacks and analyses contained
|
||||||
|
certificates/keys.
|
||||||
|
3. Choose what you want as output, e.g. "Fullchain" — Tessera orders server cert → intermediates
|
||||||
|
(→ root optional) itself and offers the result for download.
|
||||||
|
All common formats as input AND output (user decision 09.10., locked): PEM/CRT/CER (Base64), DER,
|
||||||
|
PKCS#7 (.p7b/.p7c), PKCS#12 (.pfx/.p12 with password), private keys (PKCS#1, PKCS#8, encrypted/
|
||||||
|
unencrypted, RSA + EC), CSR; outputs: Fullchain, chain only (intermediates), single certificate,
|
||||||
|
certificate + key (PEM bundle), PFX with chosen password. Module version + module changelog +
|
||||||
|
guides are part of the task.
|
||||||
|
|
||||||
|
</domain>
|
||||||
|
|
||||||
|
<decisions>
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
### Flow / structure
|
||||||
|
- ONE upload tab (first tab) where the user drops/selects multiple files and/or ZIPs (and may paste
|
||||||
|
PEM text). Everything uploaded forms a shared working set (list of files with remove buttons and
|
||||||
|
what was recognised in each). The other tabs (Analysieren, Aufteilen, Zusammenführen/Fullchain,
|
||||||
|
Konvertieren, Vorlagen) work on that shared set instead of having their own upload fields.
|
||||||
|
Users' words: "in einem Reiter die ZIP bzw. die Einzelzertifikate hochladen und die einzelnen
|
||||||
|
Reiter verarbeiten diese dann".
|
||||||
|
|
||||||
|
### Root certificate in Fullchain
|
||||||
|
- Selectable, default WITHOUT root ("Root-Zertifikat mitnehmen" checkbox).
|
||||||
|
|
||||||
|
### Missing intermediate
|
||||||
|
- Tessera reports the gap clearly ("Zwischenzertifikat fehlt") and offers a button
|
||||||
|
"Fehlendes Zertifikat holen" which fetches it from the certificate's AIA caIssuers URL — ONLY on
|
||||||
|
button press, never automatically. Fetch must be SSRF-safe (http/https only, public addresses only
|
||||||
|
— reuse the project's existing `isPublicHttpUrl`/SSRF guard pattern, size and time limits, no
|
||||||
|
redirects to private targets) and the result marked as "nachgeladen".
|
||||||
|
|
||||||
|
### Templates for target systems
|
||||||
|
- Yes: one-click templates, e.g. Nginx, Apache, Windows/IIS (PFX), Nginx Proxy Manager (and other
|
||||||
|
common ones at Claude's discretion, e.g. HAProxy combined PEM, Java keystore only if feasible
|
||||||
|
without native tools — otherwise skip). Free selection of content + format remains available.
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Where the working set lives (prefer browser memory only, never persisted server-side; private keys
|
||||||
|
and passwords never stored or logged); ZIP limits (size, file count, nesting, zip-bomb ratio);
|
||||||
|
key-to-certificate matching display; how CSRs are shown; file naming of downloads; whether the
|
||||||
|
server or browser does the crypto (follow the existing module architecture); exact tab names.
|
||||||
|
|
||||||
|
</decisions>
|
||||||
|
|
||||||
|
<specifics>
|
||||||
|
## Specific Ideas
|
||||||
|
|
||||||
|
- Chain building via Issuer/Subject + Authority/Subject Key Identifier; clear gap messages;
|
||||||
|
verify the private key matches the certificate.
|
||||||
|
- Existing module already analyses correctly (user: "Die Analyse funktioniert bereits richtig") —
|
||||||
|
keep that behaviour, check existing conversion functions and close gaps.
|
||||||
|
- Current module version 1.1.0 — check the module-changelog rule in docs/anleitung-entwicklung.md
|
||||||
|
("Höchstens ein Sprung je Modul zwischen zwei Tessera-Freigaben"; last release v1.10.1 on
|
||||||
|
2026-10-06) to decide bump vs. extending an unreleased entry.
|
||||||
|
|
||||||
|
</specifics>
|
||||||
|
|
||||||
|
<canonical_refs>
|
||||||
|
## Canonical References
|
||||||
|
|
||||||
|
- .planning/todos/pending/2026-10-09-cert-manager-mehrere-dateien-zip-fullchain.md
|
||||||
|
- docs/anleitung-entwicklung.md (module changelog rules)
|
||||||
|
- docs/anleitung-anwender.md section "Zertifikat-Manager"
|
||||||
|
|
||||||
|
</canonical_refs>
|
||||||
+625
@@ -0,0 +1,625 @@
|
|||||||
|
---
|
||||||
|
phase: quick-261009-ikt
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
quick_id: 261009-ikt
|
||||||
|
description: "Zertifikat-Manager (cert-manager) Umbau: ein Reiter „Dateien“ als gemeinsamer Arbeitsbereich (mehrere Dateien, ZIPs, eingefuegter PEM-Text), alle anderen Reiter arbeiten darauf; Fehler „zweite Datei ueberschreibt die erste“ behoben; Kettenbildung und Fullchain (Root waehlbar, Vorgabe ohne); alle gaengigen Formate rein und raus inkl. EC; Vorlagen fuer Zielsysteme; „Fehlendes Zertifikat holen“ (AIA, nur auf Knopfdruck, SSRF-sicher, gehaerteter gemeinsamer Adressschutz); Modulversion 1.2.0, Modul-Changelog, CHANGELOG und Anleitungen"
|
||||||
|
date: 2026-10-09
|
||||||
|
files_modified:
|
||||||
|
# Every path once; the comment names the tasks (T1–T8) that change it. Each task commits only its own files.
|
||||||
|
- apps/api/src/cert-manager/__fixtures__/ # T1: make-fixtures.sh, README.md and the generated PEM/DER/P7B/P7C/PFX/CSR/ZIP files
|
||||||
|
- apps/api/src/cert-manager/cert-types.ts # T1, T4, T6
|
||||||
|
- apps/api/src/cert-manager/cert-model.ts # T1, T3, T4
|
||||||
|
- apps/api/src/cert-manager/cert-model.spec.ts # T1, T3, T4
|
||||||
|
- apps/api/src/cert-manager/cert-output.ts # T1, T2, T5, T6
|
||||||
|
- apps/api/src/cert-manager/cert-output.spec.ts # T1, T2, T5, T6
|
||||||
|
- apps/api/src/cert-manager/cert-analyze.ts # T1, T2, T3, T4
|
||||||
|
- apps/api/src/cert-manager/cert-analyze.spec.ts # T1, T2, T3, T4
|
||||||
|
- apps/api/src/cert-manager/cert-manager.controller.ts # T1, T2, T4, T7
|
||||||
|
- apps/api/src/cert-manager/cert-manager.controller.spec.ts # T1, T2, T4, T5, T7
|
||||||
|
- apps/api/src/cert-manager/cert-manager.module.ts # T1
|
||||||
|
- apps/api/src/cert-manager/cert-bundle.ts # T1 deleted
|
||||||
|
- apps/api/src/cert-manager/cert-bundle.spec.ts # T1 deleted
|
||||||
|
- apps/api/src/cert-manager/cert-manager.service.ts # T1 deleted
|
||||||
|
- apps/api/src/cert-manager/cert-manager.service.spec.ts # T1 deleted
|
||||||
|
- apps/api/src/cert-manager/dto/convert-cert.dto.ts # T1 deleted
|
||||||
|
- apps/api/src/cert-manager/dto/merge-certs.dto.ts # T1 deleted
|
||||||
|
- apps/api/src/cert-manager/dto/parse-cert.dto.ts # T1 deleted
|
||||||
|
- apps/api/src/cert-manager/cert-chain.ts # T2, T4
|
||||||
|
- apps/api/src/cert-manager/cert-chain.spec.ts # T2, T4
|
||||||
|
- apps/api/src/cert-manager/dto/cert-build.dto.ts # T2, T5, T6
|
||||||
|
- apps/api/src/cert-manager/cert-json-body.ts # T2 (D-26)
|
||||||
|
- apps/api/src/cert-manager/cert-json-body.spec.ts # T2 (D-26)
|
||||||
|
- apps/api/src/main.ts # T2 (D-26 registration)
|
||||||
|
- apps/api/src/cert-manager/zip-expand.ts # T3
|
||||||
|
- apps/api/src/cert-manager/zip-expand.spec.ts # T3
|
||||||
|
- apps/api/src/cert-manager/cert-keys.ts # T4, T5
|
||||||
|
- apps/api/src/cert-manager/cert-keys.spec.ts # T4, T5
|
||||||
|
- apps/api/src/cert-manager/cert-pkcs12.ts # T4, T5
|
||||||
|
- apps/api/src/cert-manager/cert-pkcs12.spec.ts # T4, T5
|
||||||
|
- apps/api/src/cert-manager/cert-csr.ts # T4
|
||||||
|
- apps/api/src/cert-manager/cert-csr.spec.ts # T4
|
||||||
|
- apps/api/src/cert-manager/cert-templates.ts # T6
|
||||||
|
- apps/api/src/cert-manager/cert-templates.spec.ts # T6
|
||||||
|
- apps/api/src/cert-manager/cert-manager.changelog.ts # T6, T7
|
||||||
|
- apps/api/src/common/public-url-guard.ts # T7
|
||||||
|
- apps/api/src/common/public-url-guard.spec.ts # T7
|
||||||
|
- apps/api/src/cert-manager/cert-aia.ts # T7
|
||||||
|
- apps/api/src/cert-manager/cert-aia.spec.ts # T7
|
||||||
|
- apps/api/src/cert-manager/dto/cert-fetch-issuer.dto.ts # T7
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/actions.ts # T1, T2, T5, T6, T7
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/working-set.ts # T1, T3, T4, T7
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts # T1, T3, T4, T7
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts # T1, T3, T4, T7
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/page.tsx # T1, T2, T3, T5, T6
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx # T1, T2, T3, T5, T6
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx # T1, T3, T4, T7
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx # T1, T3, T4, T7
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx # T1 deleted
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx # T1 deleted
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.tsx # T1 deleted
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.test.tsx # T1 deleted
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/PasswordField.tsx # T1 deleted
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx # T1 deleted (old), T2 new, T5, T7
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx # T1 deleted (old), T2 new, T5
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.tsx # T2, T7
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.test.tsx # T7
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/EmptyWorkspace.tsx # T2
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx # T1 deleted (old), T3 new
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.test.tsx # T3
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/ItemCard.tsx # T3, T4, T7
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx # T3, T4, T7
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx # T3, T4
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/PasswordInput.tsx # T4
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx # T1 deleted (old), T5 new
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.test.tsx # T5
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/PfxOptions.tsx # T5
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/TemplatesTab.tsx # T6
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/TemplatesTab.test.tsx # T6
|
||||||
|
- apps/web/src/messages/de.json # T1–T7 (T8 only for review fixes)
|
||||||
|
- apps/web/src/messages/en.json # T1–T7 (T8 only for review fixes)
|
||||||
|
- apps/web/src/messages/umlaut-dictionary.ts # T1–T7 as the guard asks
|
||||||
|
- .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh # T1–T7 (one section each)
|
||||||
|
- .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs # T1
|
||||||
|
- CHANGELOG.md # T6, T7
|
||||||
|
- docs/anleitung-anwender.md # T6, T7 (T8 only label corrections)
|
||||||
|
- docs/anleitung-betrieb.md # T6, T7
|
||||||
|
- docs/anleitung-entwicklung.md # T6, T7
|
||||||
|
- .planning/todos/pending/2026-10-09-cert-manager-mehrere-dateien-zip-fullchain.md # T8 moved to .planning/todos/completed/
|
||||||
|
autonomous: true
|
||||||
|
requirements: [QUICK-261009-ikt]
|
||||||
|
|
||||||
|
estimate: # whole plan; each task runs in its own executor at about 60 000–80 000 tokens incl. the plan itself (Task 8 mostly browser work)
|
||||||
|
tokens: 600000
|
||||||
|
raw_tokens: 600000
|
||||||
|
tasks: 8
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "In the first tab „Dateien“ a user selects or drops several files one after another and a vendor ZIP; every file stays in the list (a second file never replaces the first), each row shows what Tessera recognised in it (for a ZIP per contained path, junk such as __MACOSX skipped, nested or password-protected ZIP entries named with the reason), single files are removed with their own button, PEM text can be pasted as an extra entry, and the tab says that the list exists only in this browser window and is gone after a reload"
|
||||||
|
- "Analysieren, Aufteilen, Zusammenführen, Konvertieren and Vorlagen have no upload field of their own; they all work on the same working set, and with an empty set they point to the „Dateien“ tab"
|
||||||
|
- "„Zusammenführen“ orders server certificate, intermediates and root by itself (issuer check plus real signature check, so a same-name CA with another key is never taken, cross-signed and expired variants are resolved deterministically) and downloads Fullchain or Nur Kette; the root goes in only when „Root-Zertifikat mitnehmen“ is ticked (off by default); a missing issuer is reported as „Zwischenzertifikat fehlt“ (after the server certificate) or as a calm note that the certificate above, usually the root, is missing; the API re-builds the order itself and never trusts the order sent by the browser"
|
||||||
|
- "RSA AND EC are recognised in every common input format: certificates as PEM/CRT/CER/CA-bundle and DER, PKCS#7 as PEM and DER, PKCS#12 (OpenSSL-3 default, compatible 3DES and legacy RC2, password per file, also inside a ZIP, also without the .pfx extension), private keys PKCS#1/PKCS#8/SEC1 in PEM and DER, unencrypted, encrypted PKCS#8 and traditionally encrypted, CSR in PEM and DER; keys and CSRs are matched to their certificates and a locked file asks for its password"
|
||||||
|
- "Outputs: single certificate (PEM, DER, PKCS#7 PEM .p7b, PKCS#7 DER .p7c), Fullchain and Nur Kette (PEM, .p7b, .p7c), Zertifikat und Schlüssel in one PEM file, PFX with a chosen password as „Kompatibel (auch ältere Windows-Server)“ = 3DES/SHA-1 (default) or „Modern (AES-256)“, key as PKCS#8, traditional (PKCS#1 for RSA, SEC1 for EC) or DER with optional password, CSR as PEM or DER; openssl reads every output in the e2e (verify, pkcs12 -info with the expected algorithm, pkcs7 -print_certs, pkey match)"
|
||||||
|
- "„Vorlagen“ delivers with one click the files and a configuration snippet for Nginx, Apache 2.4.8 and newer, Apache older than 2.4.8, Windows/IIS (PFX, compatible encryption preselected), Nginx Proxy Manager (certificate, intermediate, key), HAProxy (one combined PEM) and Tomcat/Java (PKCS#12); without the matching private key the templates explain why they are unavailable"
|
||||||
|
- "„Fehlendes Zertifikat holen“ appears only where an issuer is missing and the certificate names an http(s) caIssuers address; only the click makes the API fetch it, the address comes from the certificate on the server (never from the browser), only public addresses on ports 80/443 are contacted (shared guard hardened for hex IPv4-mapped, NAT64, 6to4 and further IPv6 forms, re-checked on every redirect and at connect time), 8 s and 256 KiB caps, and only a certificate that really issued the incomplete one is accepted and added as entry „nachgeladen von {host}“ — proven live with the letsencrypt.org certificate"
|
||||||
|
- "Private keys and passwords are never stored or logged; the old routes parse, split, merge, convert and export, the old service and forge's RSA-only certificate parsing are gone; module version 1.2.0 (new entry 2026-10-09) with module changelog, CHANGELOG (incl. the guard security fix), Anwender-, Betriebs- and Entwicklungsanleitung updated; screenshots in dark mode (and some in light mode) prove the flow"
|
||||||
|
- "Every `build` request within the DTO caps is parsed and answered with a result or an error that carries a code; a body over 512 KiB gets 413 with code tooLarge; every other route keeps the 100 kB JSON limit and Nest's global JSON parser stays active (D-26)"
|
||||||
|
- "After each of the eight tasks the module is usable on its own: only the tabs built so far are shown, no web code calls a missing route, and every live e2e section built so far passes on the rebuilt stack"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/api/src/cert-manager/cert-types.ts"
|
||||||
|
provides: "the analyze and build contract shared by all tasks (items, chains, locked, ignored, error codes)"
|
||||||
|
exports: ["CertItem", "KeyItem", "CsrItem", "AnalysisResult", "ChainInfo", "BuildInput", "BuildResult", "CertErrorCode"]
|
||||||
|
- path: "apps/api/src/cert-manager/zip-expand.ts"
|
||||||
|
provides: "ZIP detection by magic bytes and expansion with entry, size, ratio, total, nesting and encryption limits"
|
||||||
|
exports: ["expandZip", "isZip", "ZIP_LIMITS"]
|
||||||
|
- path: "apps/api/src/cert-manager/cert-model.ts"
|
||||||
|
provides: "the one parser: blob → certificates with role and selfSigned (node:crypto, Task 1), ZIP and PKCS#7 via ASN.1 walk (Task 3), keys, PKCS#12 and CSR detectors (Task 4), locked and ignored reports"
|
||||||
|
exports: ["detectBlob", "certItemFromDer"]
|
||||||
|
- path: "apps/api/src/cert-manager/cert-chain.ts"
|
||||||
|
provides: "chain building with checkIssued plus verify, ranking and gaps (Task 2), key and CSR matching (Task 4)"
|
||||||
|
exports: ["buildChains", "matchKeys"]
|
||||||
|
- path: "apps/api/src/cert-manager/cert-output.ts"
|
||||||
|
provides: "build(): every content × format, file names, re-validated order"
|
||||||
|
exports: ["buildOutput", "safeBaseName"]
|
||||||
|
- path: "apps/api/src/cert-manager/cert-pkcs12.ts"
|
||||||
|
provides: "PFX read (RSA and EC bags) and write (compat 3DES / modern AES-256) via a scoped, restored forge patch"
|
||||||
|
exports: ["readPkcs12", "writePkcs12"]
|
||||||
|
- path: "apps/api/src/cert-manager/cert-aia.ts"
|
||||||
|
provides: "button-only issuer fetch with SSRF guard per hop, guarded connect lookup, caps, issuer verification"
|
||||||
|
exports: ["fetchIssuer", "createGuardedLookup"]
|
||||||
|
- path: "apps/api/src/cert-manager/cert-templates.ts"
|
||||||
|
provides: "template id → files + config snippet"
|
||||||
|
exports: ["buildTemplate", "TEMPLATE_IDS"]
|
||||||
|
- path: "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts"
|
||||||
|
provides: "browser-only working set (entries, passwords, fetched entries) and re-analysis of the whole set with stale-response guard"
|
||||||
|
exports: ["useCertWorkspace"]
|
||||||
|
- path: "apps/api/src/cert-manager/cert-manager.changelog.ts"
|
||||||
|
provides: "module changelog with the new top entry 1.2.0 dated 2026-10-09"
|
||||||
|
contains: "version: '1.2.0'"
|
||||||
|
- path: ".planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh"
|
||||||
|
provides: "live e2e against the local API with openssl round trips: sections files, fullchain, zip, inputs, formats, templates, version, aia and all (every section built so far)"
|
||||||
|
- path: ".planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs"
|
||||||
|
provides: "message gate used by every task: de/en key parity, minimum key count, title present, no tenant/licence words, no arrow or middle-dot characters"
|
||||||
|
- path: "apps/api/src/cert-manager/cert-json-body.ts"
|
||||||
|
provides: "own JSON parser for POST build (512 KiB) and the coded 413/400 mapper (D-26)"
|
||||||
|
exports: ["CERT_BUILD_ROUTE", "CERT_BUILD_JSON_LIMIT", "certBuildJsonBody", "certBuildBodyErrors"]
|
||||||
|
key_links:
|
||||||
|
- from: "apps/api/src/main.ts"
|
||||||
|
to: "apps/api/src/cert-manager/cert-json-body.ts certBuildJsonBody + certBuildBodyErrors"
|
||||||
|
via: "app.use(CERT_BUILD_ROUTE, …) before app.listen, i.e. before Nest registers its global parsers in init()"
|
||||||
|
pattern: "certBuildJsonBody"
|
||||||
|
- from: "apps/api/src/cert-manager/cert-manager.controller.ts analyze"
|
||||||
|
to: "apps/api/src/cert-manager/cert-analyze.ts analyzeWorkingSet"
|
||||||
|
via: "multipart files (≤ 30 × 5 MiB, total ≤ 20 MiB) plus optional passwords array"
|
||||||
|
pattern: "analyzeWorkingSet\\("
|
||||||
|
- from: "apps/api/src/cert-manager/cert-model.ts detectBlob (ZIP slot)"
|
||||||
|
to: "apps/api/src/cert-manager/zip-expand.ts expandZip"
|
||||||
|
via: "every blob whose first bytes are a ZIP signature, limits checked before any entry is inflated"
|
||||||
|
pattern: "expandZip\\("
|
||||||
|
- from: "apps/api/src/cert-manager/cert-chain.ts buildChains"
|
||||||
|
to: "node:crypto X509Certificate checkIssued + verify"
|
||||||
|
via: "issuer candidates must pass both, self-signed = both against itself"
|
||||||
|
pattern: "checkIssued\\("
|
||||||
|
- from: "apps/api/src/cert-manager/cert-output.ts buildOutput"
|
||||||
|
to: "apps/api/src/cert-manager/cert-chain.ts buildChains"
|
||||||
|
via: "order of Fullchain, chain, bundle, PFX and templates is rebuilt from the sent certificates on every build"
|
||||||
|
pattern: "buildChains\\("
|
||||||
|
- from: "apps/api/src/cert-manager/cert-aia.ts fetchIssuer"
|
||||||
|
to: "apps/api/src/common/public-url-guard.ts isPublicHttpUrl + isPrivateIpAddress"
|
||||||
|
via: "guard before the first request and every redirect hop, guarded lookup in the undici dispatcher"
|
||||||
|
pattern: "isPublicHttpUrl"
|
||||||
|
- from: "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts"
|
||||||
|
to: "analyzeWorkingSet action → POST /modules/cert-manager/analyze"
|
||||||
|
via: "the whole entry list in order after every add, remove, password change or fetch; older answers dropped"
|
||||||
|
pattern: "analyzeWorkingSet\\("
|
||||||
|
- from: "apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.tsx gap row"
|
||||||
|
to: "fetchIssuer action → POST /modules/cert-manager/fetch-issuer"
|
||||||
|
via: "only on click of „Fehlendes Zertifikat holen“, body contains only the PEM of the incomplete certificate"
|
||||||
|
pattern: "fetchIssuer\\("
|
||||||
|
- from: "apps/api/src/cert-manager/cert-manager.seed.ts"
|
||||||
|
to: "CERT_MANAGER_CHANGELOG"
|
||||||
|
via: "latestVersion yields 1.2.0 (no version string in the seed)"
|
||||||
|
pattern: "latestVersion\\(CERT_MANAGER_CHANGELOG\\)"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Module „Zertifikat-Manager“ (slug `cert-manager`, api `apps/api/src/cert-manager/`, web `apps/web/src/app/(portal)/modules/cert-manager/`) is rebuilt around ONE shared working set: the first tab „Dateien“ takes several files, ZIPs from a certificate vendor and pasted PEM text; every other tab works on that set. Tessera builds the chain itself and delivers Fullchain, chain only, single certificate, certificate plus key, PFX, every key and CSR format and one-click templates for common target systems — for RSA and EC. A missing intermediate can be fetched on button press from the certificate's AIA address, SSRF-safe.
|
||||||
|
|
||||||
|
Purpose: user test 09.10. (`.planning/todos/pending/2026-10-09-cert-manager-mehrere-dateien-zip-fullchain.md`): merging accepted only one file (a second selection replaced the first), ZIPs had to be handled by hand, and there was no „give me the Fullchain“ choice. Research found the bigger gap: node-forge cannot read any EC certificate, so every ECDSA certificate (Let's Encrypt default, most modern CAs) failed or was silently dropped.
|
||||||
|
|
||||||
|
Eight tasks, strictly in order, each run by its own fresh executor, each ending with a green verify chain and exactly one commit, and each leaving a module that is usable on its own (only the tabs built so far are shown; no web code calls a missing route). Task 1 is the tracer: several files into one working set, one node:crypto parser for RSA and EC certificates, every certificate shown with its role per file — through every layer, proven live. Each further task adds one capability on top: Task 2 Zusammenführen (chain building, Fullchain and Nur Kette, root checkbox, own body limit for `build`), Task 3 vendor ZIP, PKCS#7, pasted text, Analysieren and Aufteilen, Task 4 keys, PFX and CSR with a password per file, Task 5 every output format with Konvertieren and the complete Zusammenführen, Task 6 templates plus version 1.2.0, changelogs and guides, Task 7 „Fehlendes Zertifikat holen“ with the hardened guard, Task 8 the full gates and the browser proof.
|
||||||
|
|
||||||
|
Locked decisions — from CONTEXT.md (user, NON-NEGOTIABLE):
|
||||||
|
- D-01 ONE upload tab, the first one („Dateien“): select or drop several files and/or ZIPs, paste PEM text. Everything forms a shared working set (list with remove buttons and what was recognised in each). Analysieren, Aufteilen, Zusammenführen, Konvertieren and Vorlagen work on that set and have no upload field of their own.
|
||||||
|
- D-02 Root certificate in Fullchain: selectable, default WITHOUT root, checkbox „Root-Zertifikat mitnehmen“.
|
||||||
|
- D-03 Missing intermediate: Tessera reports the gap clearly („Zwischenzertifikat fehlt“) and offers „Fehlendes Zertifikat holen“, which fetches the issuer from the certificate's AIA caIssuers URL — ONLY on button press, never automatically. SSRF-safe: http/https only, public addresses only via the project's `isPublicHttpUrl` guard pattern, size and time limits, no redirects to private targets; the result is marked „nachgeladen“.
|
||||||
|
- D-04 One-click templates for target systems: Nginx, Apache, Windows/IIS (PFX), Nginx Proxy Manager, plus (Claude's choice) HAProxy combined PEM and Tomcat/Java as PKCS#12. Java keystore (JKS) is skipped because it needs native tools. Free choice of content and format stays available (Konvertieren, Zusammenführen).
|
||||||
|
- D-05 All common formats as input AND output: PEM/CRT/CER (Base64), DER, PKCS#7 (.p7b/.p7c), PKCS#12 (.pfx/.p12 with password), private keys (PKCS#1, PKCS#8, encrypted/unencrypted, RSA and EC), CSR; outputs Fullchain, chain only (intermediates), single certificate, certificate plus key (PEM bundle), PFX with a chosen password. Chain building via issuer/subject plus key identifiers (here: OpenSSL's `checkIssued`, which compares names, AKI/SKI and key usage, plus the real signature check), clear gap messages, verify that a private key matches its certificate.
|
||||||
|
- D-06 The bug „second file overwrites the first“ disappears structurally (one append-only working set). The existing analysis behaviour (what each part is, validity, what belongs together, calm note for an unneeded locked PFX) is kept and its gaps (EC, keys, CSR, PFX bags) are closed.
|
||||||
|
|
||||||
|
Locked decisions — orchestrator answers to the research's open questions (NON-NEGOTIABLE):
|
||||||
|
- D-07 PFX encryption is user-selectable: default „Kompatibel (auch ältere Windows-Server)“ = forge `algorithm: '3des'` (pbeWithSHA1And3-KeyTripleDES-CBC, HMAC-SHA1 MAC); option „Modern (AES-256)“ = forge `algorithm: 'aes256'` (PBES2/PBKDF2/AES-256-CBC key bag; forge keeps the SHA-1 MAC). The IIS template (and the Tomcat template) preselect the compatible profile.
|
||||||
|
- D-08 The old routes parse, split, merge, convert are removed together with `cert-manager.service.ts`, its spec and the three old DTOs; the old export route is replaced by the new `build`, `analyze` keeps its name with the new contract. ONE parser only: node:crypto (`X509Certificate`, `createPrivateKey`, `createPublicKey`, `KeyObject.export`) decides everything about certificates and keys; node-forge is used only for PKCS#12 and as a generic ASN.1 reader/writer (PKCS#7 walk and build, CSR walk). No production code calls forge's certificate, CSR or PKCS#7-message parsers (they are RSA-only).
|
||||||
|
- D-09 Module version 1.2.0: a NEW top entry dated 2026-10-09 in `cert-manager.changelog.ts` (1.1.0 from 2026-10-02 is released — latest Tessera release 1.10.1 on 2026-10-06). Root `CHANGELOG.md` bullets go under „## Unveröffentlicht“.
|
||||||
|
- D-10 Harden the shared SSRF guard `apps/api/src/common/public-url-guard.ts` (`isPrivateIpv6`): IPv4-mapped addresses in hex form (`::ffff:7f00:1`), NAT64 `64:ff9b::/96`, 6to4 `2002::/16` embedding a private IPv4 — with a new spec; mention it in CHANGELOG as a security fix.
|
||||||
|
- D-11 Private keys and passwords are never persisted and never logged; the working set lives in browser memory only and is lost on reload — the „Dateien“ tab and the user guide say so.
|
||||||
|
- D-12 Docs are mandatory: Anwenderanleitung section rewritten for the new flow incl. templates and the AIA button; Betriebsanleitung notes (upload limits through Nginx Proxy Manager, outbound HTTP for AIA); Entwicklungsanleitung module paragraph. App texts German with „Sie“, de AND en messages.
|
||||||
|
- D-13 Proof: spec fixtures for RSA and EC (certificate, chains incl. cross-signed and same-name CA, encrypted PKCS#8, traditionally encrypted key, DER key, CSR, PFX in both output encryptions and as input in OpenSSL-3/compat/legacy form, P7B, vendor-like ZIP), openssl round trips in the e2e script, and a Playwright browser check (dark mode first, a few light) of the upload tab with ZIP and several files, Fullchain output, a template download and the missing-intermediate button.
|
||||||
|
|
||||||
|
Claude's discretion (decided here, apply as written):
|
||||||
|
- D-14 API surface: `@Controller('modules/cert-manager')` with class-level `@UseModule('cert-manager')` (Benutzen level, global JwtAuthGuard and TenantGuard as before), exactly three POST routes, each `@HttpCode(200)`: `analyze` (multipart, Task 1, passwords from Task 4), `build` (JSON, Task 2, extended in Tasks 5 and 6), `fetch-issuer` (JSON, Task 7). All stateless, nothing stored. No new npm package (node:crypto of Node 24, node-forge 1.4.0, adm-zip 0.6.0, undici 7 and fflate are already installed; no install step, no package checkpoint).
|
||||||
|
- D-15 Analyze contract (in `cert-types.ts`, mirrored 1:1 in web `actions.ts`): `AnalysisResult { items: AnyItem[]; chains: ChainInfo[]; locked: LockedEntry[]; ignored: IgnoredEntry[] }`. `ItemSource { file: number (index of the uploaded file in request order); path: string (file name, or "zipname/entry/path" inside a ZIP; control characters removed, max 255) }`. `CertItem { id ('c-' + first 16 lowercase hex chars of sha256(DER)); kind 'certificate'; role 'end-entity' | 'intermediate' | 'root'; sources; pem (canonical PEM of the DER); baseName; cn; organization; issuerCn; issuerOrganization; notBefore; notAfter (ISO); isExpired; daysLeft; san: string[] (DNS names plain, others with prefix like 'IP:'); keyType ('RSA' | 'EC' | 'ED25519' | other upper-case name); keyBits: number | null; curve ('P-256' | 'P-384' | 'P-521' | raw name | null); serialNumber (upper-case hex); sha256 and sha1 (colon-separated upper-case hex, as Node prints); isCa; selfSigned; aiaIssuerUrls: string[] (http/https only, max 5); keyId: string | null; csrIds: string[] }`. `KeyItem { id ('k-' + 16 hex of sha256(SPKI DER)); kind 'privateKey'; sources; pem (unencrypted PKCS#8 PEM); baseName; keyType; keyBits; curve; wasEncrypted; certIds: string[] }`. `CsrItem { id ('r-' + 16 hex of sha256(DER)); kind 'csr'; sources; pem; baseName; cn; organization; san; keyType; keyBits; curve; keyId: string | null; certIds: string[] }`. `ChainInfo { headId; path: string[] (head first, then each issuer, root last when present); rootId: string | null; complete: boolean; gap: { certId; kind: 'afterLeaf' | 'afterCa'; missingIssuerCn; aiaUrls: string[] } | null; alternatives: number }`. `LockedEntry { file; path; container: 'pkcs12' | 'privateKey'; reason: 'passwordNeeded' | 'passwordWrong' }`. `IgnoredEntry { file; path; reason: 'unknown' | 'nestedZip' | 'encryptedZip' | 'brokenZip' | 'tooLarge' | 'suspicious' | 'zipTooLarge' | 'tooManyEntries' | 'unsupportedKey' }`. Items are deduplicated by id (sources merged), ordered certificates (end-entity, intermediate, root; then by cn, then notAfter descending), keys, CSRs. Chain heads: every end-entity certificate; when the set has none, every certificate that issued no other certificate of the set. Fallback base names as today: 'zertifikat' (end-entity), 'ca', 'schluessel', 'anfrage'; `safeBaseName` keeps its current rules (`*.` → `wildcard.`, other characters → `_`, max 80).
|
||||||
|
- D-16 Detection (one pipeline in `cert-model.ts`, every detector wrapped in try/catch, a bad blob never fails the request): ZIP by magic bytes `PK\x03\x04` / `PK\x05\x06` (not by extension) → `zip-expand.ts`; text with `-----BEGIN` (BOM and CRLF tolerated, text around blocks ignored) → each block by label: CERTIFICATE / X509 CERTIFICATE, TRUSTED CERTIFICATE (leading certificate SEQUENCE only), PKCS7 / CMS, PRIVATE KEY / RSA PRIVATE KEY / EC PRIVATE KEY / ENCRYPTED PRIVATE KEY (incl. `Proc-Type: 4,ENCRYPTED`), CERTIFICATE REQUEST / NEW CERTIFICATE REQUEST; otherwise DER in this order: X.509 certificate → PKCS#12 (top-level SEQUENCE starting with INTEGER 3, tried with the passwords) → PKCS#7 signedData (OID 1.2.840.113549.1.7.2) → private key (pkcs8, pkcs1, sec1, then encrypted pkcs8 with the passwords) → CSR → `ignored: unknown`. PKCS#7 certificates are read by walking the ASN.1 with `forge.asn1.fromDer` (ContentInfo → [0] SignedData → certificates [0] SET) and handing each certificate's DER to `X509Certificate`. Task 1 implements the certificate stages, Task 3 ZIP and PKCS#7, Task 4 keys, PKCS#12 and CSR — all into the same pipeline.
|
||||||
|
- D-17 Limits: multer `FilesInterceptor('files', 30, { limits: { fileSize: 5 MiB } })`; the controller rejects a request whose files together exceed 20 MiB with 413 `tooLarge`; the web refuses before upload more than 30 entries, a file over 5 MiB, a set over 10 MiB in total, pasted text over 256 000 characters, and an identical file (same name, size and lastModified) a second time. ZIP (`ZIP_LIMITS`, injectable for specs): one level only (an entry that is itself a ZIP → `nestedZip`), at most 100 non-junk entries (else the whole ZIP → `tooManyEntries`), junk skipped silently (directories, `__MACOSX/`, names starting with `.`, `Thumbs.db`, `desktop.ini`), declared size per entry ≤ 1 MiB (else `tooLarge`), declared size / max(compressed, 1) ≤ 100 (else `suspicious`), encrypted entry (general-purpose flag bit 0) → `encryptedZip`, sum of declared sizes of the kept entries ≤ 20 MiB checked BEFORE any entry is inflated (else the whole ZIP → `zipTooLarge`), unreadable archive → `brokenZip`; entry names are used for display only and never written to disk; adm-zip inflates at most the declared size and checks the CRC.
|
||||||
|
- D-18 Chains (`cert-chain.ts`, pure functions; `buildChains` in Task 2, `matchKeys` in Task 4; selfSigned and role below are per-certificate facts that `certItemFromDer` computes from Task 1 on): issuers of C = every other certificate I with `C.checkIssued(I) && C.verify(I.publicKey)`; selfSigned(C) = both against C itself; role = not CA → 'end-entity', CA and selfSigned → 'root', otherwise 'intermediate' (a self-signed non-CA stays end-entity with path [itself], complete true, rootId null, gap null). Path search: depth-first over verified issuers, depth ≤ 10, visited set; ranking of the found paths, in this order: ends at a self-signed certificate of the set first, fewer certificates that are expired or not yet valid, shorter, later notAfter of the first issuer, then sha256 ascending (deterministic); `alternatives` = number of other paths found (cap 10). An incomplete path ends at a certificate whose issuer is absent: `gap.kind` 'afterLeaf' when that certificate is the head end-entity, else 'afterCa', with the issuer CN from the certificate and its caIssuers URLs. Key match: `cert.checkPrivateKey(keyObject)`; CSR match: SPKI DER of the CSR equals the certificate's or the key's SPKI DER. Never match by name or modulus string.
|
||||||
|
- D-19 Build contract (`POST build`, JSON, own body limit 512 KiB per D-26, `BuildOutputDto` with class-validator): `{ content, format?, certPem? (≤ 16 384 characters), poolPems? (≤ 20, each ≤ 16 384 characters; an entry that is not a certificate → 400 notACertificate), keyPem? (≤ 16 384), csrPem? (≤ 16 384), includeRoot? (default false), includeChain? (default true, bundle only), password? (≤ 256), pfxEncryption? 'compat' | 'modern' (default compat), template? (Task 6), baseName? (≤ 120) }` → `{ files: [{ filename, content (base64), mimeType }], chainComplete: boolean, missingIssuerCn: string | null, snippet?: string | null }`. The API always rebuilds the order with `buildChains` over certPem plus poolPems and uses the primary chain of certPem (certificates in the pool that do not belong to it are dropped). Matrix and file names (keep today's conventions): leaf — pem `<base>.crt`, der `<base>.cer`, p7b `<base>.p7b` (PEM PKCS#7), p7c `<base>.p7c` (DER PKCS#7); fullchain — pem `<base>-fullchain.pem`, p7b, p7c; chain — pem `<base>-chain.pem`, p7b, p7c (no intermediate and no included root → 400 `noChain`); leafKey — pem `<base>-bundle.pem` (leaf, intermediates, root only with includeRoot, key last as unencrypted PKCS#8; includeChain false → leaf and key only); pfx — `<base>.pfx` (leaf, intermediates, root only with includeRoot, key when given; password required); key — pkcs8 `<base>.key` (PRIVATE KEY or, with password, ENCRYPTED PRIVATE KEY AES-256-CBC), traditional `<base>.rsa.key` / `<base>.ec.key` (PKCS#1 / SEC1, with password AES-256-CBC Proc-Type), pkcs8-der `<base>.key.der` (with password encrypted PKCS#8 DER); traditional for other key types → 400 `formatNotPossible`; csr — pem `<base>.csr`, der `<base>.csr.der`. PKCS#7 output is assembled with `forge.asn1` by hand (ContentInfo signedData, version 1, empty digestAlgorithms and signerInfos, encapContentInfo data, certificates [0] IMPLICIT with each certificate's own ASN.1), never through forge certificate objects. PEM outputs use Node's `X509Certificate#toString()` blocks joined in path order with a trailing newline.
|
||||||
|
- D-20 PKCS#12 (`cert-pkcs12.ts`): reading via forge `pkcs12FromAsn1` — try the file's own password, then '' (empty password), then the other passwords of the request (distinct, max 10); certificate bags: DER = `bag.asn1` when present (EC certificates: forge leaves `bag.cert` null) else forge's encoding of `bag.cert`; key bags (pkcs8ShroudedKeyBag and keyBag): `bag.asn1` → `createPrivateKey({ format: 'der', type: 'pkcs8' })` (EC keys: forge leaves `bag.key` false). Writing: the research's Pattern 3 — inside ONE synchronous function temporarily replace `pki.privateKeyToAsn1`, `pki.wrapRsaPrivateKey` and `pki.certificateToAsn1` with pass-through functions, call `forge.pkcs12.toPkcs12Asn1(keyAsn1OrNull, certs, password, { algorithm, friendlyName: baseName, generateLocalKeyId: true })`, restore the three originals in `finally` (the module is single-threaded and the call is synchronous, so no other caller can observe the patch); key DER comes from `KeyObject.export({ type: 'pkcs8', format: 'der' })`, certificates leaf first. A header comment explains why. No hand-rolled PBE or MAC.
|
||||||
|
- D-21 Templates (`cert-templates.ts`, Task 6; all need leaf plus matching key, else 400 `templateNeedsKey`; root only with includeRoot; key unencrypted): nginx → ZIP `<base>-nginx.zip` with `fullchain.pem`, `privkey.pem` (PKCS#8), snippet `ssl_certificate /etc/nginx/ssl/<base>/fullchain.pem;` and `ssl_certificate_key /etc/nginx/ssl/<base>/privkey.pem;`; apache (2.4.8 and newer) → `fullchain.pem`, `privkey.pem`, snippet `SSLCertificateFile …/fullchain.pem`, `SSLCertificateKeyFile …/privkey.pem`; apache-legacy (older than 2.4.8) → `cert.pem`, `chain.pem`, `privkey.pem`, snippet with `SSLCertificateFile`, `SSLCertificateKeyFile`, `SSLCertificateChainFile`; iis → single `<base>.pfx` (password required, pfxEncryption default compat), snippet `Import-PfxCertificate -FilePath .\<base>.pfx -CertStoreLocation Cert:\LocalMachine\My -Password (Read-Host -AsSecureString)`; npm (Nginx Proxy Manager, „Custom“ certificate) → `certificate.pem` (leaf only), `intermediate.pem` (intermediates, root only with includeRoot), `privkey.pem` (RSA as PKCS#1 „RSA PRIVATE KEY“, EC as SEC1 „EC PRIVATE KEY“, research A2), snippet null, steps from the web messages; haproxy → single `<base>.pem` = leaf, intermediates, key (PKCS#8), snippet `bind :443 ssl crt /etc/haproxy/certs/<base>.pem`; tomcat → single `<base>.p12` (password required, compat default, friendlyName = base name), snippet `<Certificate certificateKeystoreFile="conf/<base>.p12" certificateKeystorePassword="IHR-PASSWORT" certificateKeystoreType="PKCS12" certificateKeyAlias="<base>" />` (never the real password). The API returns the files and the snippet; the web zips multi-file templates with fflate and adds `ANLEITUNG.txt` (de) / `INSTRUCTIONS.txt` (en) made of the localized steps and the snippet; single-file templates download directly; the snippet is always shown with „Kopieren“.
|
||||||
|
- D-22 AIA fetch (`cert-aia.ts`, `POST fetch-issuer` with `FetchIssuerDto { pem: string ≤ 16 384 }` — whitelist strips any other field, a URL is never accepted from the browser): parse the PEM (`notACertificate` 400 otherwise); URLs from `toLegacyObject().infoAccess['CA Issuers - URI']` (Node's `infoAccess` getter is a plain string, verified), keep http/https, no username/password, length ≤ 2048, default port only (empty `url.port`), max 3 URLs tried in order; none → 422 `aiaMissing`. Per URL: the loop of `nextcloud-logo-fetch.ts` — `isPublicHttpUrl` before the first request and before every hop (refused → 422 `aiaInternal`), `redirect: 'manual'`, max 3 redirects with the same checks, one AbortController for 8 s plus `Promise.race` on abort, content-length pre-check and streamed cap 256 KiB (`aiaTooLarge` 502), no cookies, no credentials, no custom User-Agent, `discard()` non-final bodies; the undici fetch runs with an own `Agent({ connect: { lookup } })` whose lookup (`createGuardedLookup`) resolves the name and fails when any address is private (closes the DNS-rebinding window for this feature). Response parse: DER certificate, else PKCS#7 DER/PEM (the D-16 walker), else PEM text; accept only certificates where `target.checkIssued(c) && target.verify(c.publicKey)` (none → 422 `aiaNotIssuer`); other failures → 502 `aiaUnreachable`. Result `{ filename (`<safeBaseName(cn)>.crt`), pem (the accepted certificates), host, cn }`. One hop per click. Log one warn line with host and code on failure only — never the PEM or the URL path.
|
||||||
|
- D-23 Web structure: `page.tsx` holds `useCertWorkspace()` and renders `PageHeader` plus `TabBar` from `@/components/accounting/tab-bar`; tab ids and labels in this order: files „Dateien“ (with the count, e.g. „Dateien (3)“), analyze „Analysieren“, split „Aufteilen“, merge „Zusammenführen“, convert „Konvertieren“, templates „Vorlagen“ — Task 1 shows files, Task 2 adds merge, Task 3 analyze and split, Task 5 convert, Task 6 templates, always in the order above; start tab files; switching tabs keeps the set. Non-files tabs with an empty set render `EmptyWorkspace` („Noch keine Dateien. Laden Sie Ihre Zertifikate im Reiter „Dateien“ hoch.“ plus a button to that tab); with a set, a calm line „Grundlage: {count} Dateien aus dem Reiter „Dateien“.“. `working-set.ts` (pure) keeps `WorkingEntry { id, file: File, label, origin: 'upload' | 'paste' | 'fetched', host: string | null, password: string }`; pasted text becomes `pasted-<n>.pem` with the label „Eingefügter Text <n>“; fetched certificates become entries with origin fetched and their host. `use-cert-workspace.ts` re-sends the whole set after every change and drops answers of older requests (request counter). Design: Mosaik tokens, calm dense list, existing role badge colours of the old Übersicht, visible focus, no ALL-CAPS labels, no arrow characters or arrow buttons, no middle-dot meta strings, texts formal „Sie“ with real umlauts, no tenant or licence words; every text through `t()` (namespace `certManager`, `certManager.title` stays because `nav-store.ts` uses it).
|
||||||
|
- D-24 Errors: the API throws Nest exceptions with an object body `{ code, message }` (English message for developers): `invalidInput` 400, `notACertificate` 400, `noChain` 400, `keyMissing` 400, `keyMismatch` 400, `passwordRequired` 400, `formatNotPossible` 400, `templateNeedsKey` 400, `tooLarge` 413, `aiaMissing` 422, `aiaInternal` 422, `aiaNotIssuer` 422, `aiaUnreachable` 502, `aiaTooLarge` 502. The web maps `code` (and status 413 without code) to `certManager.errors.<code>` with German texts that say what to do, unknown → a generic text. Validation errors of the DTO arrive as Nest's default 400 → `invalidInput`.
|
||||||
|
- D-25 Tests: committed fixtures under `apps/api/src/cert-manager/__fixtures__/` (biome ignores this folder; specs read them with `readFileSync(join(__dirname, '__fixtures__', …))` like the tenders specs) generated once by `make-fixtures.sh` with the host openssl 3.5.7; CA keys stay in a temporary directory and are deleted, only leaf keys are committed; file names never end in `.key` (`.gitignore` line 48 ignores `*.key` for the updater signing key — fixtures use `-key.pem` / `-key-….der`). Specs never call openssl (CI has none) — they round-trip through Node and forge; the e2e script uses openssl. One e2e script `e2e-cert.sh [files|fullchain|zip|inputs|formats|templates|version|aia|all]` against the local API (each task adds its section; `all` runs every section built so far); the browser proof in Task 8.
|
||||||
|
- D-26 Request body of `POST build` (Claude's discretion; resolves the plan-checker finding that the DTO allowed far more than Express's default 100 kB JSON limit, so a valid request could fail with a 413 without code). `build` gets its own JSON parser with `CERT_BUILD_JSON_LIMIT = 512 * 1024` bytes in `cert-json-body.ts`, registered in `main.ts` as `app.use(CERT_BUILD_ROUTE, certBuildJsonBody, certBuildBodyErrors)` with `CERT_BUILD_ROUTE = '/modules/cert-manager/build'` (the API has no global prefix) after `cookieParser()` and before `app.listen` — `app.use` registers at once, Nest registers its own parsers later in `init()`. Size arithmetic: certPem 16 384 + 20 × 16 384 poolPems + keyPem 16 384 + csrPem 16 384 characters, password 256, baseName 120 and JSON-escaped PEM newlines (about +1.6 %) ≈ 384 kB < 512 KiB, so every body within the DTO caps is parsed and reaches validation; a bigger body gets 413 `{ code: 'tooLarge', message }` and malformed JSON 400 `{ code: 'invalidInput', message }` from `certBuildBodyErrors`; every other error goes to `next`. All other routes keep Nest's default 100 kB; `analyze` is multipart and `fetch-issuer` carries at most 16 384 characters. The parser is Express's own `json()` from the Express copy that Nest's adapter loads — `createRequire(createRequire(__filename).resolve('@nestjs/platform-express'))('express')`, typed via `typeof import('express')`; `express` itself is not resolvable from `apps/api` and no package is added — wrapped in a function named `certBuildJsonBody`, because Nest's `ExpressAdapter.registerParserMiddleware` skips its global JSON parser for EVERY route when a router layer whose function is named `jsonParser` already exists. body-parser 2.3.0 returns early for a request whose body was already read, so the global parser does not read the build body again. `cert-json-body.spec.ts` builds the maximal DTO body from the exported caps of `dto/cert-build.dto.ts` and asserts it stays below the limit, so a later cap change cannot reopen the gap. The Entwicklungs- and Betriebsanleitung mention the limit (Task 6).
|
||||||
|
|
||||||
|
Output: new parser, chain, ZIP, output, PKCS#12, CSR, key, template and AIA modules with specs, the build body parser, hardened shared guard with spec, three routes, rebuilt web module (working set, six tabs), messages de/en, fixtures, e2e script and message gate, module version 1.2.0 with changelog, CHANGELOG, three guides, screenshots. Eight commits on main (one per task), NOT pushed.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@./CLAUDE.md
|
||||||
|
@.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/261009-ikt-CONTEXT.md
|
||||||
|
RESEARCH.md: `.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/261009-ikt-RESEARCH.md` — not loaded by default; read only the sections your task's `<read_first>` names.
|
||||||
|
|
||||||
|
Discovered facts the executor can rely on (verified during planning on 2026-10-09):
|
||||||
|
- Current API module: `cert-manager.controller.ts` (routes parse, split, merge, convert, analyze, export; class `@UseModule('cert-manager')`), `cert-manager.service.ts` (793 lines, forge), `cert-bundle.ts` (655 lines, forge; `analyzeBundle`, `exportBundleItem`, `safeBaseName`, ZIP expansion via adm-zip by `.zip` extension, 100 entries, 5 MiB per entry header size), `cert-manager.module.ts` (providers `[CertManagerService]`, seeds on init and logs „Cert-Manager module seeded in registry“), `cert-manager.seed.ts` (`version: latestVersion(CERT_MANAGER_CHANGELOG)`), `cert-manager.changelog.ts` (top entry 1.1.0 dated 2026-10-02), `dto/{convert-cert,merge-certs,parse-cert}.dto.ts` (plain classes). `CertManagerController` appears in no other spec (not in `module-manage-handlers.spec.ts`); `app.module.ts` imports `CertManagerModule` and `module-changelog.registry.ts` maps 'cert-manager' — both stay.
|
||||||
|
- Current web module: `page.tsx` (own tab nav, shared single-file `DropZone` + paste + `PasswordField` for every tab except Übersicht — root cause of the bug), components `OverviewTab`, `InspectTab`, `SplitTab` (fflate `zipSync` with filename dedupe), `MergeTab`, `ConvertTab`, `DropZone`, `PasswordField` (hard-coded German aria-labels), `actions.ts` (`API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'` — `/api-proxy` in production, `downloadBase64(filename, base64, mime)`, `postForm`, fetch with `credentials: 'include'`), `zip-filename.ts` (`sanitizeZipFilename`, `ZIP_FILENAME_FALLBACK = 'certificates.zip'`; keep it), `layout.tsx` (ModuleAccessGate; untouched). The old `OverviewTab.tsx` (card layout, `ROLE_STYLES`, `formatDate` in UTC, explanations) is the visual reference for Task 3's Analysieren tab — after Task 1 deleted it, read it with `T1=$(git log --format=%H -1 --grep='Parser für RSA und EC'); git show "$T1^:apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.tsx"` (the same way for the old `SplitTab.tsx` and `cert-bundle.ts`).
|
||||||
|
- Node 24 facts (probed on this host): `new X509Certificate(pemOrDer)`; `x.infoAccess` is a STRING (`"CA Issuers - URI:http://ye2.i.lencr.org/"`), the parsed form is `x.toLegacyObject().infoAccess` → `{ 'CA Issuers - URI': ['http://ye2.i.lencr.org/'] }`; `toLegacyObject().subject` → `{ CN: 'letsencrypt.org' }` (values can be arrays for multi-valued attributes); `x.publicKey.asymmetricKeyType` 'ec' / 'rsa', `asymmetricKeyDetails` `{ namedCurve: 'prime256v1' }` or `{ modulusLength }` (map prime256v1 → P-256, secp384r1 → P-384, secp521r1 → P-521); `x.subjectAltName` is a string like `DNS:a, DNS:b`; `x.ca`, `x.fingerprint256`, `x.fingerprint`, `x.serialNumber`, `x.validFromDate`/`x.validToDate`, `x.checkIssued(other)`, `x.verify(publicKey)`, `x.checkPrivateKey(key)`, `x.raw` (DER), `x.toString()` (PEM). Live chain: the letsencrypt.org leaf (EC P-256) names `http://ye2.i.lencr.org/`; that URL answers 200 `application/pkix-cert` with 656 bytes DER of „CN=YE2“ (EC); `leaf.checkIssued(ye2) && leaf.verify(ye2.publicKey)` is true; YE2 names `http://ye.i.lencr.org/` (issuer „Root YE“). The api container reaches it (`docker compose exec -T api node -e "fetch('http://ye2.i.lencr.org/')…"` → 200).
|
||||||
|
- Host tools: OpenSSL 3.5.7 (`pkcs12 -export -legacy` works, `req -x509 -not_before 20200101000000Z -not_after 20210101000000Z` works), `zip`, `unzip`, `python3`.
|
||||||
|
- Shared guard `apps/api/src/common/public-url-guard.ts`: `isPrivateIpv4`, `isPrivateIpv6` (only `::`, `::1`, prefixes fc/fd/`fe80:`/ff and dotted `::ffff:a.b.c.d`), `isPrivateIpAddress` (NOT exported), `isBlockedHostname`, `export async function isPublicHttpUrl(url: URL)` (http/https, blocked names, literal IPs, `dns/promises` lookup all). Users: `favorites/icon-discovery.service.ts`, `nextcloud-status/nextcloud-logo-fetch.ts` (and their specs); no guard spec exists. `nextcloud-logo-fetch.ts` is the loop to copy (options `fetchImpl`, `isPublic`, `timeoutMs`; `discard(response)`; abort race; streamed byte cap; one warn line with host and code).
|
||||||
|
- Body parser (probed 2026-10-09, basis of D-26): a JSON body over 100 kB is answered today with 413 `{"statusCode":413,"message":"request entity too large"}` (no code); `require.resolve('express')` from `apps/api` → MODULE_NOT_FOUND, while `createRequire(require.resolve('@nestjs/platform-express'))('express').json` works and returns a function named `jsonParser`; Nest 11.1.27 `NestApplication.init()` calls `ExpressAdapter.registerParserMiddleware`, which skips json/urlencoded when `isMiddlewareApplied(name)` finds a router layer whose `handle.name` equals `jsonParser`/`urlencodedParser`; body-parser 2.3.0 `read()` starts with `if (onFinished.isFinished(req)) next()`; in Vitest 3 of `apps/api` both `__filename` and `require` exist, and feeding a 5 000-byte body with `content-length` through `json({ limit: 1000 })` on a `PassThrough` with `headers` and `method` calls back with `status 413`, `type 'entity.too.large'`; the api image copies the full pnpm `node_modules` (`apps/api/Dockerfile` lines 40–42), so the same resolution works in the container; `biome check apps/api/src/main.ts` passes today.
|
||||||
|
- NestJS: global `ValidationPipe({ whitelist: true, transform: true })` in `main.ts`; no body-parser options → Express JSON limit 100 kB for every route except `build` from Task 2 on (D-26); `common/request-log.ts` logs method, path (query cut), status, ms and user only. Metadata keys: `MODULE_SLUG_KEY = 'moduleSlug'` in `module-registry/module.guard.ts`; route metadata via `Reflect.getMetadata('path', …)`, `'method'` (RequestMethod POST = 1) and `'__httpCode__'` (see `nextcloud-files.controller.spec.ts` for the pattern). An object passed to `new BadRequestException({ code, message })` becomes the response body.
|
||||||
|
- Web: shared `TabBar` at `apps/web/src/components/accounting/tab-bar.tsx` (`{ tabs: { id, label }[], active, onChange }`, buttons with `aria-current="page"`); `PageHeader` from `@/components/layout/page-header` (`moduleSlug`, `title`, `description`); tests use Vitest 4 + Testing Library; prefer rendering with `NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin"` (as `nextcloud-files-page.test.tsx`) so missing keys fail; mock `./actions` with `vi.mock` keeping `downloadBase64` spied. `messages/umlaut-guard.spec.ts` fails on new tokens with ae/oe/ue/ss that are not in `UMLAUT_ALLOWLIST` (`umlaut-dictionary.ts`) — add correct German words there; no message key for the module exists in any parity spec, so the verify's node check enforces de/en parity.
|
||||||
|
- Biome: `biome.json` excludes `**/__fixtures__`; baseline `biome check` already fails on the untouched `layout.tsx`, `zip-filename.test.ts` and `module-registry/module-changelog.spec.ts` (import order/format) — do not reformat them; `biome lint` on both module folders passes; `de.json`, `en.json`, `public-url-guard.ts`, `cert-manager.changelog.ts`, `cert-manager.seed.ts` pass `biome check` today and must keep passing.
|
||||||
|
- Module changelog rules (`docs/anleitung-entwicklung.md` „### Modulversion und Modul-Changelog pflegen“, ~line 299): new-item entry = minor bump; one jump per module between Tessera releases; items 1–2 sentences, `de` + `en`, kinds `new | changed | fixed`, real umlauts, „Sie“, no replacement spellings like „fuer“/„Aenderung“, no tenant/licence words, plain text. Guard: `apps/api/src/module-registry/module-changelog.spec.ts` (seed version = top entry, strictly descending versions, real dates descending). Marktplatz reads `GET /modules/changelog/:slug`. `CHANGELOG.md` starts with „## Unveröffentlicht“ → „### Neu“ / „### Geändert“ / „### Behoben“; module bumps are mentioned like „Modulversion 1.1.0.“ (see the DKV bullet).
|
||||||
|
- Guides: `docs/anleitung-anwender.md` „### Zertifikat-Manager“ at line 150 (intro plus four bullets plus a formats paragraph, ends before „### Domaincheck“ at line 161); `docs/anleitung-betrieb.md` chapter „## 3. Konfiguration“ with „### Dateien (Nextcloud)“ at line 186 (bullet with `client_max_body_size` ≥ `10m` at line 192) and the table „### Fehlerbilder“ (line ~755); `docs/anleitung-entwicklung.md` „## Konventionen und Fallstricke“ (line 693, paragraphs „**Titel (quick-id):** …“, last one „**Dateien (Nextcloud), Teilen (quick-261009-dkv):**“ around line 800). `docs/anleitung-administration.md` has no cert-manager text and the module has no settings — it stays unchanged.
|
||||||
|
- e2e harness `.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh` (source it; no side effects): `API` (http://localhost:3001), `E2E_TMP`, `e2e_fail`, `e2e_login <jar>` (admin/admin123), `e2e_status <jar> <method> <url> [json] [out]`, `e2e_expect`, `e2e_contains`, `e2e_activate <jar> cert-manager`, `e2e_wait_health`. Multipart upload with `curl -s -b <jar> -F "files=@<path>;filename=<name>" … $API/modules/cert-manager/analyze`; JSON handling with `python3 -I`.
|
||||||
|
- Stack: db, api :3001, web :3000 (production build through `/api-proxy`) and mailhog run; rebuild with `docker compose up -d --build api web` (plain `up` does not rebuild). Login admin/admin123. Playwright MCP is configured (`.mcp.json`, chromium); screenshots go to `.playwright-mcp/cert-manager/` (gitignored). Dark mode via the theme button in the header (never by adding the class by script). Never judge the UI by calling fetch from inside the page — navigate and read the rendered page.
|
||||||
|
- Git: commit ONLY the task's files: `git add <new files>`, `git rm <deleted files>`, then `git commit -m "…" -- <every file of the task>`. German subject with prefix `feat(cert-manager):`, body ends with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`. Never push (the user bundles pushes), never deploy to the test server, never read `.env` files.
|
||||||
|
|
||||||
|
Execution protocol for the eight tasks (read before starting your task):
|
||||||
|
- Tasks run strictly in order 1 → 8, each by its own fresh executor. Read the frontmatter, the objective with D-01 … D-26, this context and YOUR `<task>` only; open the files your `<read_first>` names; read RESEARCH.md only in the sections your task names.
|
||||||
|
- TDD: write or extend the specs and tests of your `<behavior>` first, see them fail, then implement.
|
||||||
|
- Before the verify chain: `docker compose up -d --build api web` (plain `up` does not rebuild); the e2e setup waits for /health. Every chain ends with `e2e-cert.sh all`, i.e. every section built so far.
|
||||||
|
- End with exactly one commit of exactly your task's files (new, changed, removed), message as your task names it. Never push (the user bundles pushes after Task 8), never deploy.
|
||||||
|
- After the commit append „## Task N“ to `.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/261009-ikt-SUMMARY.md` (create it if missing; never committed by an executor) with the measured gate table, deviations, threat status and the output items the `<output>` section assigns to your task.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tracer" tdd="true">
|
||||||
|
<name>Task 1: Tracer — several files into one working set, one node:crypto parser for RSA and EC certificates, every certificate recognised with its role in the tab „Dateien“, end to end and proven live</name>
|
||||||
|
<files>apps/api/src/cert-manager/__fixtures__/make-fixtures.sh, apps/api/src/cert-manager/__fixtures__/README.md, apps/api/src/cert-manager/__fixtures__/ (generated fixture files), apps/api/src/cert-manager/cert-types.ts, apps/api/src/cert-manager/cert-model.ts, apps/api/src/cert-manager/cert-model.spec.ts, apps/api/src/cert-manager/cert-output.ts, apps/api/src/cert-manager/cert-output.spec.ts, apps/api/src/cert-manager/cert-analyze.ts, apps/api/src/cert-manager/cert-analyze.spec.ts, apps/api/src/cert-manager/cert-manager.controller.ts, apps/api/src/cert-manager/cert-manager.controller.spec.ts, apps/api/src/cert-manager/cert-manager.module.ts, removed: apps/api/src/cert-manager/{cert-bundle.ts, cert-bundle.spec.ts, cert-manager.service.ts, cert-manager.service.spec.ts, dto/convert-cert.dto.ts, dto/merge-certs.dto.ts, dto/parse-cert.dto.ts}, apps/web/src/app/(portal)/modules/cert-manager/actions.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts, apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts, apps/web/src/app/(portal)/modules/cert-manager/page.tsx, apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx, removed: apps/web/src/app/(portal)/modules/cert-manager/components/{DropZone.tsx, InspectTab.tsx, OverviewTab.tsx, OverviewTab.test.tsx, SplitTab.tsx, ConvertTab.tsx, PasswordField.tsx, MergeTab.tsx, MergeTab.test.tsx}, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh, .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs</files>
|
||||||
|
<read_first>apps/api/src/cert-manager/cert-manager.controller.ts, apps/api/src/cert-manager/cert-manager.module.ts, apps/api/src/cert-manager/cert-bundle.ts (only `safeBaseName` and the role wording — read it before you remove it), apps/web/src/app/(portal)/modules/cert-manager/page.tsx, apps/web/src/app/(portal)/modules/cert-manager/actions.ts, apps/web/src/components/accounting/tab-bar.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx (render pattern with NextIntlClientProvider), the e2e harness e2e-lib.sh named in the context, RESEARCH.md sections on node:crypto and the EC gap of node-forge</read_first>
|
||||||
|
<precondition>The local stack runs (`docker compose ps --status running --services` lists api, web and db) and `openssl version` reports 3.4 or newer (the fixture script needs `-legacy` and `-not_before`).</precondition>
|
||||||
|
<behavior>
|
||||||
|
- Fixtures: `make-fixtures.sh` regenerates every file of the D-25 list into its own folder; afterwards `git status --porcelain --ignored apps/api/src/cert-manager/__fixtures__` shows no ignored („!!“) entry and the folder contains no private key of a CA.
|
||||||
|
- cert-model: `rsa-leaf.pem` → one CertItem with cn `www.example.test`, san [`www.example.test`, `example.test`], issuerCn `Tessera Test Inter RSA`, role end-entity, keyType RSA, keyBits 2048, curve null, isCa false, selfSigned false, aiaIssuerUrls [`http://pki.example.test/rsa-inter.cer`], sha256 equal to Node's `fingerprint256` of the fixture and id `c-` plus the first 16 lowercase hex characters of that hash; `rsa-inter.pem` → role intermediate; `ec-leaf.pem` → keyType EC, curve P-256, keyBits 256; `ec-root.pem` → curve P-384, isCa true, selfSigned true, role root; `selfsigned-leaf.pem` → selfSigned true but role end-entity (not a CA); `ec-leaf.cer` (DER) → the same id as `ec-leaf.pem`; `ec-fullchain.pem` saved with a UTF-8 BOM, CRLF line endings and text before and after the blocks → three certificates; `rsa-trusted.pem` (TRUSTED CERTIFICATE) → the certificate; random bytes, an empty file, a truncated DER and a PEM block with broken Base64 → ignored unknown without throwing.
|
||||||
|
- cert-output: `safeBaseName('*.example.de', 'x')` = `wildcard.example.de` and the other cases of the old spec (moved, rules unchanged).
|
||||||
|
- cert-analyze: files [rsa-leaf.pem, rsa-inter.pem, ec-leaf.cer, ec-inter.pem, ec-root.pem, rsa-leaf.cer, readme.txt] → rsa-leaf is ONE item with two sources (file 0 path `rsa-leaf.pem`, file 5 path `rsa-leaf.cer`); items ordered end-entity, intermediate, root, then cn, then notAfter descending; ignored has `unknown` for readme.txt with file 6; `chains`, `locked` are empty arrays in this task; JSON.stringify of the result contains neither the text undefined nor NaN.
|
||||||
|
- Controller: class path `modules/cert-manager` with MODULE_SLUG_KEY `cert-manager`; the prototype has exactly the handler `analyze` (POST `analyze`, http code 200); no files → 400 with code invalidInput; files summing over 20 MiB → 413 with code tooLarge and no analysis; the files reach `analyzeWorkingSet` in request order.
|
||||||
|
- working-set (web, pure): two `addFiles` calls with one file each → two entries in call order; the same file again (name, size, lastModified) → rejected duplicate; the 31st file → rejected tooMany; a 6 MiB file → rejected tooLarge; a set over 10 MiB → rejected totalTooLarge; `removeEntry` keeps the order of the rest; `toFormData` appends `files` in entry order.
|
||||||
|
- FilesTab (real de messages, mocked actions): selecting one file and then another in a second selection lists both and the last `analyzeWorkingSet` call receives both (regression of the user's bug); one selection with two files appends both; dropping files appends; removing an entry re-analyses the rest and removing the last one clears the analysis without a call; each entry lists the certificates whose sources point to it with role label and CN; an unrecognised file shows the reason text for unknown; rejected files are listed with their reason; the note that the list exists only in this browser window is visible; while analysing a status text is shown; an analysis error shows the error text and „Erneut versuchen“ repeats the call.
|
||||||
|
- Page: PageHeader plus TabBar with the single tab „Dateien“ in this task, start tab files, the label shows the count („Dateien (2)“ after two files).
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**Fixtures (per D-05, D-13, D-25).** Write `apps/api/src/cert-manager/__fixtures__/make-fixtures.sh` with the Write tool (bash, `set -euo pipefail`, header comment: test-only PKI for quick 261009-ikt, needs OpenSSL ≥ 3.4, CA keys live only in a `mktemp -d` folder removed by a trap, fixture password `Test-Pass-123`) and run it once. It produces, in its own folder: RSA 2048 PKI — `rsa-root.pem` (CN „Tessera Test Root RSA“, CA, SKI), `rsa-root2.pem` („Tessera Test Root RSA 2“), `rsa-inter.pem` („Tessera Test Inter RSA“, signed by rsa-root, AKI/SKI, caIssuers `http://pki.example.test/rsa-root.cer`), `rsa-inter-cross.pem` (same subject and key as rsa-inter, signed by rsa-root2), `rsa-inter-expired.pem` (same subject and key, signed by rsa-root, valid 2020-01-01 to 2021-01-01), `rsa-inter-decoy.pem` (same subject DN, different key, signed by rsa-root), `rsa-leaf.pem` (CN `www.example.test`, SAN `www.example.test` and `example.test`, caIssuers `http://pki.example.test/rsa-inter.cer`, signed by rsa-inter, 100 years validity like all others), `rsa-leaf.cer` (DER), `rsa-leaf-noaki.pem` (CN `noaki.example.test`, signed by rsa-inter with authorityKeyIdentifier and subjectKeyIdentifier set to none and no AIA), `rsa-fullchain.pem`, `rsa-chain.p7b` (PEM via `openssl crl2pkcs7 -nocrl`), `rsa-chain.p7c` (DER), `rsa-trusted.pem` (`openssl x509 -trustout`); EC PKI — `ec-root.pem` (P-384, „Tessera Test Root EC“), `ec-inter.pem` (P-384, „Tessera Test Inter EC“), `ec-leaf.pem` (P-256, CN `ec.example.test`, SAN, caIssuers `http://pki.example.test/ec-inter.cer`), `ec-leaf.cer`, `ec-fullchain.pem`, `ec-chain.p7b`; `selfsigned-leaf.pem` (RSA, not CA); `aia-private-leaf.pem` (signed by rsa-inter, caIssuers `http://127.0.0.1/inter.cer`, `http://169.254.169.254/latest` and an `ldap://` URL — used in Task 7); keys for both leaves — `rsa-leaf-key.pem` (PKCS#8), `rsa-leaf-key-pkcs1.pem`, `rsa-leaf-key-enc-pkcs8.pem` (AES-256), `rsa-leaf-key-enc-trad.pem` (`openssl rsa -traditional -aes256`), `rsa-leaf-key-pkcs8.der`, `rsa-leaf-key-pkcs1.der`, `ec-leaf-key.pem`, `ec-leaf-key-sec1.pem`, `ec-leaf-key-enc-pkcs8.pem` (`-v1 PBE-SHA1-3DES`), `ec-leaf-key-enc-trad.pem` (`openssl ec -aes256`), `ec-leaf-key-sec1.der`, `ec-leaf-key-enc-pkcs8.der`; CSRs — `rsa-leaf.csr`, `rsa-leaf.csr.der`, `ec-leaf.csr`, `ec-leaf.csr.der` (with SAN via `-addext`); PFX (leaf, key and chain) — `rsa-modern.pfx` and `ec-modern.pfx` (OpenSSL-3 default), `rsa-compat.pfx` and `ec-compat.pfx` (`-certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES -macalg sha1`), `rsa-legacy.pfx` (`-legacy`), `rsa-nopass.pfx` (empty password), `rsa-modern.bin` (a copy of rsa-modern.pfx without the extension); `encrypted-entry.zip` (`zip -P Test-Pass-123` with rsa-leaf.pem inside). Never use the `.key` extension (`.gitignore` line 48). Write `__fixtures__/README.md`: test-only data, no real certificates, the password, how to regenerate, that specs read the files and that a future secret scanner must allow-list this folder. Tasks 2–7 use the chain, PKCS#7, key, CSR, PFX, ZIP and AIA fixtures — generate everything now, never regenerate later (new CA keys would change every certificate).
|
||||||
|
|
||||||
|
**Contract (per D-15, D-19, D-24).** New `cert-types.ts` with every type of D-15 (KeyItem, CsrItem, ChainInfo and LockedEntry are filled from Tasks 2 and 4 on), `BuildInput`/`BuildResult` of D-19 and the `CertErrorCode` union of D-24 plus a small `certError(code, status, message)` helper that throws the matching Nest exception with body `{ code, message }`.
|
||||||
|
|
||||||
|
**One parser, certificates first (per D-08, D-16, D-18).** New `cert-model.ts`: `detectBlob(blob, ctx)` runs the D-16 pipeline with every stage as a named function; in this task the certificate stages are real (PEM labels CERTIFICATE, X509 CERTIFICATE, TRUSTED CERTIFICATE with the leading certificate SEQUENCE only; DER X.509) and the ZIP, PKCS#7, key, PKCS#12 and CSR stages are named slots that recognise nothing yet, so a blob no stage recognises ends as ignored unknown (Task 3 fills ZIP and PKCS#7, Task 4 keys, PKCS#12 and CSR). `certItemFromDer(der, source)` builds a CertItem from node:crypto only (fields of D-15, subject and issuer via `toLegacyObject()`, caIssuers via `toLegacyObject().infoAccess`, curve mapping as in the context facts, `safeBaseName`, and the per-certificate facts selfSigned and role of D-18). Do not call forge's certificate, CSR or PKCS#7-message parsers anywhere outside specs — they are RSA-only; the verify gate greps for the names of those forge functions, so keep them out of comments too. Write `cert-model.spec.ts` first.
|
||||||
|
|
||||||
|
**Base name (per D-15).** New `cert-output.ts` with `safeBaseName` only (moved from cert-bundle.ts, rules unchanged) and `cert-output.spec.ts` with the moved cases; Task 2 adds `buildOutput` to the same file.
|
||||||
|
|
||||||
|
**Facade, route, module (per D-08, D-14, D-17, D-24).** New `cert-analyze.ts`: `analyzeWorkingSet(files, passwords = [])` — detect every blob, dedupe by id with merged sources, order per D-15, return `AnalysisResult` (chains from Task 2, keys, CSRs and locked from Task 4; empty arrays until then). Rewrite `cert-manager.controller.ts`: header comment (stateless, nothing stored, passwords and keys never logged, the D-14 route list with the task that adds each route), constructor without services, `@Post('analyze')` with `@HttpCode(200)`, `FilesInterceptor('files', 30, { limits: { fileSize: 5 * 1024 * 1024 } })`, no files → invalidInput 400, files together over 20 MiB → tooLarge 413. Rewrite `cert-manager.module.ts` without providers (keep the seeding and its log line). Remove with `git rm`: `cert-bundle.ts`, `cert-bundle.spec.ts`, `cert-manager.service.ts`, `cert-manager.service.spec.ts` and the three old DTO files — the old routes and the old analyze contract go away in the same commit as the web code that called them. Write `cert-analyze.spec.ts` and `cert-manager.controller.spec.ts` (metadata per behavior; the 400 and 413 cases by calling the handler with fake files) first.
|
||||||
|
|
||||||
|
**Web (per D-01, D-06, D-11, D-23, D-24).** Rewrite `actions.ts`: keep `API_URL` and `downloadBase64`; mirror the D-15 and D-19 types; `CertManagerRequestError(status, code)`; `analyzeWorkingSet(entries)` (multipart via `toFormData`, credentials include); no password ever in a URL or log. New `working-set.ts` (pure, D-17 web limits, the full D-23 entry shape incl. `origin`, `host` and `password` so Tasks 3, 4 and 7 only add functions) with `working-set.test.ts`. New `use-cert-workspace.ts`: entries state, `addFiles(files) → rejected[]`, `remove(id)`, `clear()`, `retry()`, analysis state (`idle | analyzing | error`), re-analysis of the whole set after each change with a request counter that drops older answers, empty set → analysis null without a call. Rewrite `page.tsx` per D-23 with the tab files only. New `components/FilesTab.tsx`: one large drop button (real button for click AND drop, hidden `<input type="file" multiple>` with accept `.zip,.pem,.crt,.cer,.cert,.der,.ca-bundle,.chain,.p7b,.p7c,.pfx,.p12,.csr,.req,.txt` plus key extensions, drops accept any file because detection is by content), hint text with the limits, rejected files with their reason, the entry list (`<ul>`, per entry: label, size, the recognised certificates from `analysis.items` whose sources point to this entry with role label and CN, its ignored lines with reason texts, remove button with aria-label „„{name}“ entfernen“), „Alle entfernen“, the always visible note per D-11 („Die Dateien bleiben nur in diesem Browserfenster. Tessera speichert nichts davon; nach dem Neuladen oder Schließen der Seite ist die Liste leer.“), analysing status and error with „Erneut versuchen“. Remove with `git rm`: `DropZone.tsx`, `InspectTab.tsx`, `OverviewTab.tsx`, `OverviewTab.test.tsx`, `SplitTab.tsx`, `ConvertTab.tsx`, `PasswordField.tsx`, `MergeTab.tsx`, `MergeTab.test.tsx` (Tasks 2, 3 and 5 write the new tabs). Rewrite `cert-manager.test.tsx` (page) and write `FilesTab.test.tsx` per behavior first. Messages: restructure namespace `certManager` in de AND en (keep `title` and `description`; keys for tabs, files, roles, ignored reasons and `errors.<code>` for every D-24 code — all codes now, later tasks add only their own texts), German with „Sie“ and real umlauts, no arrow or middle-dot characters, no tenant or licence words; extend `UMLAUT_ALLOWLIST` only with correct German words the guard asks for.
|
||||||
|
|
||||||
|
**Message gate.** Write `.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs` (node, no dependencies, resolves the two message files relative to the repo root found via `git rev-parse --show-toplevel`): flattens `certManager` of de.json and en.json, exits 1 with `key mismatch` when the key sets differ, `too few keys` when de has fewer keys than the first argument, `title missing` without `certManager.title`, `bad text: <value>` for any value matching `/mandant|tenant|lizenz|licens|→|·/i` (write the arrow and the middle dot as escapes in the regex); otherwise prints `messages ok <count>`.
|
||||||
|
|
||||||
|
**Live e2e (per D-13, D-25).** Write `.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh` with the Write tool (bash, `set -euo pipefail`, sources `../../261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh` relative to its own directory, `FIX` = the fixtures folder relative to the repo root, header comment: test values only, reads no .env). Argument: one of files, fullchain, zip, inputs, formats, templates, version, aia or all; `all` runs every section implemented so far in exactly this order; an argument naming a section not yet implemented fails with „Abschnitt noch nicht gebaut“. This task implements setup and files; Tasks 2–7 each add their section and append it to `all`. Setup: wait for health, admin login, `e2e_activate <jar> cert-manager`, probe `POST $API/modules/cert-manager/parse` — anything but 404 fails with the hint „API-Container neu bauen: docker compose up -d --build api“. Section files: POST analyze (`curl -s -b <jar> -F "files=@<path>;filename=<name>" …`) with rsa-leaf.pem, rsa-inter.pem, ec-leaf.cer, ec-inter.pem, ec-root.pem and rsa-leaf.cer → 200, exactly five distinct certificates, rsa-leaf with two sources, the EC leaf with keyType EC and curve P-256, roles end-entity/intermediate/root as expected; POST analyze without files → 400 with code invalidInput; POST parse, split, merge, convert and export → 404 each. Print `e2e cert files ok`.
|
||||||
|
|
||||||
|
**Rebuild, run, commit.** `docker compose up -d --build api web`, wait for /health, run the verify chain. Commit `feat(cert-manager): Reiter „Dateien“ mit mehreren Dateien und ein Parser für RSA und EC – Durchstich` with exactly the files of this task (new, rewritten and removed). Do not push. Append „## Task 1“ to the SUMMARY with output item 1 (fixture list actually generated, openssl version).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f apps/api/src/cert-manager/cert-model.spec.ts && test -f apps/api/src/cert-manager/cert-output.spec.ts && test -f apps/api/src/cert-manager/cert-analyze.spec.ts && test -f apps/api/src/cert-manager/cert-manager.controller.spec.ts && test -f "apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts" && test -f "apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx" && pnpm --filter @tessera/api exec vitest run src/cert-manager src/module-registry && pnpm --filter @tessera/web exec vitest run modules/cert-manager src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager" && pnpm exec biome check apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager/components" "apps/web/src/app/(portal)/modules/cert-manager/page.tsx" "apps/web/src/app/(portal)/modules/cert-manager/actions.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts" "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts" "apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx" apps/web/src/messages/de.json apps/web/src/messages/en.json && node .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs 15 && test ! -e apps/api/src/cert-manager/cert-manager.service.ts && test ! -e apps/api/src/cert-manager/cert-bundle.ts && test ! -e apps/api/src/cert-manager/dto/parse-cert.dto.ts && test ! -e "apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.tsx" && test ! -e "apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx" && test ! -e "apps/web/src/app/(portal)/modules/cert-manager/components/PasswordField.tsx" && ! grep -nE "@(Post|Get)\('(parse|split|merge|convert|export)'\)" apps/api/src/cert-manager/cert-manager.controller.ts && test -z "$(grep -rlE 'certificateFromPem|certificateFromAsn1|certificationRequestFromAsn1|messageFromPem|messageFromAsn1' apps/api/src/cert-manager --include=*.ts | grep -v '\.spec\.ts$')" && FIXSTAT=$(git status --porcelain --ignored apps/api/src/cert-manager/__fixtures__) && ! printf '%s\n' "$FIXSTAT" | grep -q '^!!' && test -s apps/api/src/cert-manager/__fixtures__/ec-chain.p7b && test -s apps/api/src/cert-manager/__fixtures__/rsa-legacy.pfx && test -s apps/api/src/cert-manager/__fixtures__/aia-private-leaf.pem && docker compose ps --status running --services | grep -qx api && bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all && echo "task1 ok"</automated>
|
||||||
|
<fails_when>non-zero exit of the chain and the last line `task1 ok` missing; the visible signal is one of: a `test -f`/`test ! -e`/`test -s`/`grep` gate stopping the chain without tool output (the api vitest config has passWithNoTests, so the `test -f` gates in front are what catch a filter matching no spec), vitest printing `FAIL` or a `failed` count in its `Test Files` line, tsc printing `error TS`, biome printing `Found` with errors, check-cert-messages printing `key mismatch`, `too few keys`, `title missing` or `bad text`, or e2e-cert.sh printing `E2E FAIL:`</fails_when>
|
||||||
|
</verify>
|
||||||
|
<done>Several files land in one append-only working set (a second file never replaces the first), RSA and EC certificates in PEM, DER and TRUSTED form are recognised by one node:crypto parser and shown with role and CN per file; the old routes, the old service, the old DTOs and the old web components are gone; specs, tsc, biome, the message gate and the live files section are green on the rebuilt stack; one commit on main, not pushed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Zusammenführen — chain building with issuer and signature check, Fullchain and Nur Kette as PEM with the root checkbox, and a per-route body limit with a coded 413</name>
|
||||||
|
<files>apps/api/src/cert-manager/cert-chain.ts, apps/api/src/cert-manager/cert-chain.spec.ts, apps/api/src/cert-manager/cert-analyze.ts, apps/api/src/cert-manager/cert-analyze.spec.ts, apps/api/src/cert-manager/cert-output.ts, apps/api/src/cert-manager/cert-output.spec.ts, apps/api/src/cert-manager/dto/cert-build.dto.ts, apps/api/src/cert-manager/cert-json-body.ts, apps/api/src/cert-manager/cert-json-body.spec.ts, apps/api/src/main.ts, apps/api/src/cert-manager/cert-manager.controller.ts, apps/api/src/cert-manager/cert-manager.controller.spec.ts, apps/web/src/app/(portal)/modules/cert-manager/actions.ts, apps/web/src/app/(portal)/modules/cert-manager/page.tsx, apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/EmptyWorkspace.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh</files>
|
||||||
|
<read_first>apps/api/src/cert-manager/cert-types.ts, cert-model.ts, cert-analyze.ts, cert-output.ts and cert-manager.controller.ts (Task 1), apps/api/src/main.ts, apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts, page.tsx and components/FilesTab.tsx (Task 1 patterns), the context facts on the body parser, RESEARCH.md section on chain building (name-only walk is wrong, checkIssued plus verify)</read_first>
|
||||||
|
<precondition>Task 1 is committed (`git log --oneline --grep='Parser für RSA und EC' | grep -q .`) and `bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all` passes on the running stack.</precondition>
|
||||||
|
<behavior>
|
||||||
|
- cert-chain (sets from fixtures): {rsa-leaf, rsa-inter} → path [leaf, inter], complete false, gap { kind 'afterCa', missingIssuerCn 'Tessera Test Root RSA' }; adding rsa-root → complete true, rootId = root, gap null; {rsa-leaf} → gap kind 'afterLeaf' with aiaUrls [`http://pki.example.test/rsa-inter.cer`]; {rsa-leaf-noaki, rsa-inter-decoy, rsa-inter, rsa-root} → path uses rsa-inter, never the decoy; {rsa-leaf-noaki, rsa-inter-decoy, rsa-root} → gap afterLeaf (the same-name decoy fails the signature check); {rsa-leaf, rsa-inter-cross, rsa-root2} → complete via root2; {rsa-leaf, rsa-inter, rsa-inter-cross, rsa-root} → primary via rsa-inter and rsa-root, alternatives ≥ 1; {rsa-leaf, rsa-inter-expired, rsa-inter, rsa-root} → primary uses the valid rsa-inter; the same set in reversed input order → the same primary path; {rsa-inter, rsa-root} → one chain with head rsa-inter; `selfsigned-leaf.pem` → path [itself], complete true, rootId null, gap null.
|
||||||
|
- cert-analyze: the Task-1 set now yields two chains (RSA incomplete afterCa, EC complete with rootId = ec-root).
|
||||||
|
- cert-output (buildOutput, fullchain and chain as PEM): fullchain for ec-leaf with pool [ec-root, ec-inter] in that (wrong) order plus rsa-inter → exactly two blocks, ec-leaf first then ec-inter, filename `ec.example.test-fullchain.pem`, chainComplete true; includeRoot true → three blocks ending with ec-root; chain → only ec-inter (with includeRoot ec-inter and ec-root), filename `ec.example.test-chain.pem`; chain for {rsa-leaf} alone → 400 noChain; certPem that is not a certificate → 400 notACertificate; a poolPems entry that is not a certificate → 400 notACertificate; {rsa-leaf, rsa-inter} fullchain → chainComplete false, missingIssuerCn 'Tessera Test Root RSA'.
|
||||||
|
- cert-json-body (D-26): the exported middleware's function name is `certBuildJsonBody` (never `jsonParser` or `urlencodedParser`); the maximal DTO body built from the exported caps (certPem, 20 poolPems, keyPem and csrPem each at `CERT_PEM_MAX` characters with a newline every 64 characters, password at `CERT_PASSWORD_MAX`, baseName at `CERT_BASENAME_MAX`) serialised with JSON.stringify is smaller than `CERT_BUILD_JSON_LIMIT` and, fed through `certBuildJsonBody` as a fake request stream, ends up in `req.body`; a 600 KiB body → `certBuildBodyErrors` answers 413 with body `{ code: 'tooLarge', message }`; malformed JSON → 400 `{ code: 'invalidInput', message }`; any other error is passed to `next` unchanged.
|
||||||
|
- Controller: the prototype has exactly the handlers `analyze` and `build` (POST `build`, http code 200).
|
||||||
|
- MergeTab (real de messages, mocked actions): one head → its chain listed in order with role labels; two heads → a radio group to choose; „Root-Zertifikat mitnehmen“ unchecked by default and disabled with the note that no root is available when the path has no root; „Fullchain herunterladen“ calls `buildOutput({ content: 'fullchain', format: 'pem', certPem: <head pem>, poolPems: <the other certificates of the path>, includeRoot: false, baseName })` once and then `downloadBase64` with the returned file; ticking the root → includeRoot true; „Nur Kette herunterladen“ sends content chain; a gap afterLeaf shows „Zwischenzertifikat fehlt“ with the missing name; a gap afterCa shows the calm note; an error code shows its German text; a 413 without code shows the tooLarge text.
|
||||||
|
- Page: tabs „Dateien“ and „Zusammenführen“ (D-23 order); on Zusammenführen with an empty set the EmptyWorkspace text and its button switch to Dateien; with a set the line „Grundlage: …“ is shown; entries survive switching tabs.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**Chains (per D-05, D-18).** New `cert-chain.ts`: `buildChains(certs)` → `{ chains }` per D-18 — heads per D-15, issuers of C = every other certificate passing `C.checkIssued(I) && C.verify(I.publicKey)`, DFS with visited set and depth cap 10, the full ranking (self-signed end first, fewer certificates expired or not yet valid, shorter, later notAfter of the first issuer, sha256 ascending), `alternatives`, gap kinds, `rootId`; it uses the role and selfSigned facts that `certItemFromDer` already computes. `analyzeWorkingSet` fills `chains`. Write `cert-chain.spec.ts` and the analyze case first.
|
||||||
|
|
||||||
|
**Output (per D-02, D-19).** Add `buildOutput(input)` to `cert-output.ts` for content fullchain and chain in format pem (Task 5 adds every other content and format to the same function): parse certPem and poolPems with `X509Certificate` (anything that is not a certificate → notACertificate), run `buildChains`, take the primary chain of certPem, drop the root unless includeRoot, return D-19 file names, base64 content, mime `application/x-pem-file`, chainComplete and missingIssuerCn. Extend `cert-output.spec.ts` first.
|
||||||
|
|
||||||
|
**DTO and body limit (per D-19, D-26).** New `dto/cert-build.dto.ts`: exported caps `CERT_PEM_MAX = 16384`, `CERT_POOL_MAX = 20`, `CERT_PASSWORD_MAX = 256`, `CERT_BASENAME_MAX = 120`; `BuildOutputDto` with class-validator — content IsIn fullchain and chain (Task 5 widens it), format IsIn pem, certPem IsString MaxLength, poolPems IsArray ArrayMaxSize with each IsString MaxLength, includeRoot IsBoolean, baseName IsString MaxLength, all optional except content; header comment with the size arithmetic of D-26. New `cert-json-body.ts` per D-26 (exports `CERT_BUILD_ROUTE`, `CERT_BUILD_JSON_LIMIT`, `certBuildJsonBody`, `certBuildBodyErrors`; header comment explaining the 512 KiB, why the function must not be called `jsonParser`, and that `express` is reached through `@nestjs/platform-express`). Write `cert-json-body.spec.ts` first (fake request = a `PassThrough` with `headers` and `method`, as in the context facts). In `apps/api/src/main.ts` register `app.use(CERT_BUILD_ROUTE, certBuildJsonBody, certBuildBodyErrors)` directly after `cookieParser()` and before `app.listen`, with a one-line comment pointing to quick-261009-ikt D-26.
|
||||||
|
|
||||||
|
**Route.** `@Post('build')` with `@HttpCode(200)` and `@Body() dto: BuildOutputDto` → `buildOutput`; header route list updated. Extend the controller spec first.
|
||||||
|
|
||||||
|
**Web (per D-01, D-02, D-23, D-24).** `actions.ts`: `buildOutput(input)` (JSON, credentials include), error mapping incl. 413 without code → tooLarge. New `components/ChainView.tsx` (ordered path with role badge, CN, issuer and validity per step, a thin connecting line instead of arrow characters, gap row per D-18: afterLeaf „Zwischenzertifikat fehlt: „{name}““ as a warning, afterCa as a calm note that the certificate above, usually the root, is missing and is not needed for a Fullchain without root; Task 7 adds the fetch button to this row). New `components/MergeTab.tsx` per behavior (head choice, ChainView, root checkbox per D-02, buttons „Fullchain herunterladen“ and „Nur Kette herunterladen“, busy state, error texts). New `components/EmptyWorkspace.tsx` per D-23. `page.tsx`: tab merge. Write `MergeTab.test.tsx` and extend the page test first. Messages de AND en for every new text (rules as in Task 1).
|
||||||
|
|
||||||
|
**Live e2e (per D-13, D-26).** Add section fullchain (after files in `all`): POST build with `{}` → 400 (the probe that the api container carries this task; else fail with the rebuild hint); fullchain for ec-leaf with ec-root and ec-inter in the wrong order plus rsa-inter → 200, decode the file with `python3 -I`, exactly two certificates, the first subject is ec.example.test, `openssl verify -CAfile ec-root.pem -untrusted <ec-inter> <first certificate>` OK; includeRoot → three; chain → only ec-inter; RSA fullchain without root → chainComplete false and missingIssuerCn „Tessera Test Root RSA“; body limit: a DTO-valid body of about 380 kB (certPem ec-leaf plus 20 poolPems of 16 000 characters that are not certificates) → 400 with code notACertificate (never 413); a body over 600 KiB → 413 with code tooLarge; `POST $API/auth/login` with a 150 kB JSON body → 413 (the global 100 kB limit of every other route is unchanged), while the JSON login of the setup still works (Nest's global JSON parser is still active). Every business error body seen carries a `code`; DTO validation errors are Nest's default 400 (D-24). Print `e2e cert fullchain ok`.
|
||||||
|
|
||||||
|
**Rebuild, run, commit.** `docker compose up -d --build api web`, wait for /health, run the verify chain. Commit `feat(cert-manager): Zusammenführen mit Fullchain und Nur Kette` with exactly the files of this task. Do not push. Append „## Task 2“ to the SUMMARY.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f apps/api/src/cert-manager/cert-chain.spec.ts && test -f apps/api/src/cert-manager/cert-json-body.spec.ts && test -f "apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx" && pnpm --filter @tessera/api exec vitest run src/cert-manager src/module-registry && pnpm --filter @tessera/web exec vitest run modules/cert-manager src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager" && pnpm exec biome check apps/api/src/cert-manager apps/api/src/main.ts "apps/web/src/app/(portal)/modules/cert-manager/components" "apps/web/src/app/(portal)/modules/cert-manager/page.tsx" "apps/web/src/app/(portal)/modules/cert-manager/actions.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts" "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts" "apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx" apps/web/src/messages/de.json apps/web/src/messages/en.json && node .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs 25 && grep -q "certBuildJsonBody" apps/api/src/main.ts && test -z "$(grep -rlE 'certificateFromPem|certificateFromAsn1|certificationRequestFromAsn1|messageFromPem|messageFromAsn1' apps/api/src/cert-manager --include=*.ts | grep -v '\.spec\.ts$')" && docker compose ps --status running --services | grep -qx api && bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all && echo "task2 ok"</automated>
|
||||||
|
<fails_when>non-zero exit of the chain and the last line `task2 ok` missing; the visible signal is one of: a `test -f`/`grep` gate stopping the chain without tool output, vitest printing `FAIL` or a `failed` count in its `Test Files` line, tsc printing `error TS`, biome printing `Found` with errors, check-cert-messages printing `key mismatch`, `too few keys`, `title missing` or `bad text`, or e2e-cert.sh printing `E2E FAIL:` (among others when the login of the setup fails because the global JSON parser was disabled, or when a body within the DTO caps is answered with 413)</fails_when>
|
||||||
|
</verify>
|
||||||
|
<done>Zusammenführen builds the chain itself (issuer and signature check, decoy refused, cross-signed and expired variants resolved deterministically) and downloads Fullchain or Nur Kette as PEM, root only when ticked; gaps are named; every DTO-valid build body is parsed, a bigger one gets 413 with code tooLarge, every other route keeps 100 kB; specs, tsc, biome and the live files and fullchain sections are green on the rebuilt stack; one commit on main, not pushed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 3: Vendor ZIP, PKCS#7 and pasted PEM text into the working set, plus the tabs Analysieren and Aufteilen</name>
|
||||||
|
<files>apps/api/src/cert-manager/zip-expand.ts, apps/api/src/cert-manager/zip-expand.spec.ts, apps/api/src/cert-manager/cert-model.ts, apps/api/src/cert-manager/cert-model.spec.ts, apps/api/src/cert-manager/cert-analyze.ts, apps/api/src/cert-manager/cert-analyze.spec.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts, apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts, apps/web/src/app/(portal)/modules/cert-manager/page.tsx, apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/ItemCard.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh</files>
|
||||||
|
<read_first>apps/api/src/cert-manager/cert-model.ts, cert-analyze.ts, cert-types.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.ts, use-cert-workspace.ts, components/FilesTab.tsx, components/ChainView.tsx, zip-filename.ts; from history (the version before the tracer, `T1=$(git log --format=%H -1 --grep='Parser für RSA und EC')`): `git show "$T1^:apps/api/src/cert-manager/cert-bundle.ts"` (ZIP part), `git show "$T1^:apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.tsx"` (card layout, `ROLE_STYLES`, UTC `formatDate`, explanations) and `git show "$T1^:apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx"` (fflate pattern, filename dedupe); RESEARCH.md sections on ZIP limits and PKCS#7</read_first>
|
||||||
|
<precondition>Task 2 is committed (`git log --oneline --grep='Fullchain und Nur Kette' | grep -q .`) and `e2e-cert.sh all` passes on the running stack.</precondition>
|
||||||
|
<behavior>
|
||||||
|
- zip-expand (vendor ZIP built in the spec with adm-zip from fixture contents: `ServerCertificate.crt`, `Intermediate/CA.crt`, `__MACOSX/._ServerCertificate.crt`, `.DS_Store`, `Thumbs.db`, `readme.txt`, `inner.zip`): returns blobs for the two certificates and readme.txt with paths `vendor.zip/ServerCertificate.crt` and `vendor.zip/Intermediate/CA.crt`; the junk entries produce nothing; `inner.zip` → ignored nestedZip; the same ZIP named `bundle.dat` is still expanded (magic bytes); random bytes starting with `PK\x03\x04` → ignored brokenZip; the committed `encrypted-entry.zip` → its entry ignored encryptedZip; with injected limits: more entries than allowed → one ignored tooManyEntries for the whole ZIP and no blob; an entry declared larger than allowed → ignored tooLarge; 600 kB of zero bytes (ratio over 100) → ignored suspicious; kept entries summing over the total → one ignored zipTooLarge and no blob.
|
||||||
|
- cert-model: `rsa-chain.p7b`, `rsa-chain.p7c` and `ec-chain.p7b` (the EC case forge could not read) → three certificates each; a ZIP blob is expanded through zip-expand and its entries run through the same pipeline with source path `<zip name>/<entry path>`.
|
||||||
|
- cert-analyze: files [rsa-leaf.pem, rsa-inter.pem, vendor ZIP containing ec-leaf, ec-inter, ec-root, a copy of rsa-leaf, `inner.zip` and readme.txt] → rsa-leaf is ONE item with two sources (file 0 path `rsa-leaf.pem` and file 2 path `vendor.zip/…`); items ordered end-entity, intermediate, root; two chains (RSA incomplete afterCa, EC complete with root); ignored contains nestedZip and unknown for readme.txt with file 2.
|
||||||
|
- working-set: `addText` trims, ignores empty text, rejects more than 256 000 characters and creates `pasted-1.pem` labelled „Eingefügter Text 1“ with origin paste, the next one `pasted-2.pem`; pasted entries count toward the 30 entries.
|
||||||
|
- FilesTab: a ZIP entry shows its contained parts grouped by path with role and CN from the analysis; ignored lines nestedZip, encryptedZip, tooLarge, suspicious, zipTooLarge, tooManyEntries and brokenZip show their reason texts; the collapsible „PEM-Text einfügen“ area with „Hinzufügen“ adds „Eingefügter Text 1“ marked as pasted text.
|
||||||
|
- AnalyzeTab: chains section first (one ChainView per head), then one ItemCard per certificate — role badge in the colours of the old Übersicht, CN, issuer, validity state (valid, expires within 30 days, expired; dates in UTC), SAN, key type with size or curve, serial, SHA-256, SHA-1, sources with file and ZIP path — then the list of ignored entries with reasons; empty set → EmptyWorkspace.
|
||||||
|
- SplitTab: every item as a row with a download in its natural format straight from `item.pem` (certificate `<baseName>.crt`; the rows are generic over kind, so keys `.key` and CSRs `.csr` appear from Task 4 on without changes) via `downloadBase64`, and „Alle als ZIP herunterladen“ (fflate `zipSync`, duplicate names numbered, ZIP name via `sanitizeZipFilename`); no API call.
|
||||||
|
- Page: tabs Dateien, Analysieren, Aufteilen, Zusammenführen in this order.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**ZIP (per D-17).** New `zip-expand.ts`: `isZip(buffer)` by magic bytes; `expandZip(buffer, zipName, file, limits = ZIP_LIMITS)` → `{ blobs: { path, buffer }[], ignored: IgnoredEntry[] }` following D-17 exactly — all per-entry and total checks on the adm-zip headers before the first `getData()`; header comment with the rules and the note that adm-zip bounds inflation to the declared size. Write `zip-expand.spec.ts` first.
|
||||||
|
|
||||||
|
**ZIP and PKCS#7 slots (per D-08, D-16).** Fill the two slots of `cert-model.ts`: ZIP by magic bytes (not by extension) → `expandZip`, a ZIP inside a ZIP → nestedZip, the entries' blobs go through the same pipeline; PKCS#7 PEM (labels PKCS7 and CMS) and DER (signedData OID 1.2.840.113549.1.7.2) via the forge ASN.1 walk of D-16, each certificate's DER handed to `certItemFromDer`. Extend `cert-analyze.ts` for ZIP sources. Extend the specs first.
|
||||||
|
|
||||||
|
**Web (per D-01, D-06, D-11, D-23).** `working-set.ts` + `use-cert-workspace.ts`: `addText(text)`. `FilesTab.tsx` per behavior (ZIP grouping by contained path, reason texts, paste area). New `components/ItemCard.tsx` and `components/AnalyzeTab.tsx` (layout, badge colours, UTC date formatting and explanation texts of the old OverviewTab from history, adapted to the new items and to EC; Task 4 adds key and CSR cards). New `components/SplitTab.tsx` per behavior (fflate pattern of the old SplitTab from history). `page.tsx`: tabs analyze and split. Write `AnalyzeTab.test.tsx`, `SplitTab.test.tsx` and the extended FilesTab, working-set and page tests first. Messages de AND en (rules as in Task 1).
|
||||||
|
|
||||||
|
**Live e2e (per D-13, D-17).** Add section zip (after fullchain in `all`): build a vendor ZIP in `$E2E_TMP` with `python3 -I` (ec-leaf as `ServerCertificate.crt`, ec-inter, ec-root, `__MACOSX/._x`, an inner ZIP, `readme.txt`); POST analyze with rsa-leaf.pem, rsa-inter.pem and the ZIP → 200, five distinct certificates, two chains, ignored contains nestedZip and unknown for readme.txt, no `__MACOSX` path anywhere; the same ZIP uploaded as `bundle.dat` → the same certificates; `rsa-chain.p7c` and `ec-chain.p7b` → three certificates each; fullchain built from the EC certificates taken out of the ZIP analysis → two blocks. Print `e2e cert zip ok`.
|
||||||
|
|
||||||
|
**Rebuild, run, commit.** `docker compose up -d --build api web`, wait for /health, run the verify chain. Commit `feat(cert-manager): Hersteller-ZIP, PKCS#7, eingefügter Text, Analysieren und Aufteilen` with exactly the files of this task. Do not push. Append „## Task 3“ to the SUMMARY.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f apps/api/src/cert-manager/zip-expand.spec.ts && test -f "apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx" && test -f "apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.test.tsx" && pnpm --filter @tessera/api exec vitest run src/cert-manager src/module-registry && pnpm --filter @tessera/web exec vitest run modules/cert-manager src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager" && pnpm exec biome check apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager/components" "apps/web/src/app/(portal)/modules/cert-manager/page.tsx" "apps/web/src/app/(portal)/modules/cert-manager/actions.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts" "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts" "apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx" apps/web/src/messages/de.json apps/web/src/messages/en.json && node .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs 40 && test -z "$(grep -rlE 'certificateFromPem|certificateFromAsn1|certificationRequestFromAsn1|messageFromPem|messageFromAsn1' apps/api/src/cert-manager --include=*.ts | grep -v '\.spec\.ts$')" && docker compose ps --status running --services | grep -qx api && bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all && echo "task3 ok"</automated>
|
||||||
|
<fails_when>non-zero exit of the chain and the last line `task3 ok` missing; the visible signal is one of: a `test -f`/`grep` gate stopping the chain without tool output, vitest printing `FAIL` or a `failed` count in its `Test Files` line, tsc printing `error TS`, biome printing `Found` with errors, check-cert-messages printing `key mismatch`, `too few keys`, `title missing` or `bad text`, or e2e-cert.sh printing `E2E FAIL:`</fails_when>
|
||||||
|
</verify>
|
||||||
|
<done>A vendor ZIP (also without the .zip name), PKCS#7 in PEM and DER for RSA and EC and pasted PEM text join the working set; junk is skipped silently, nested, encrypted, oversized and suspicious entries are named with their reason; Analysieren shows chains and every certificate in detail, Aufteilen downloads every part or all as ZIP; specs, tsc, biome and the live files, fullchain and zip sections are green on the rebuilt stack; one commit on main, not pushed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 4: Private keys, PFX and CSR recognised in every common form, a password per file, keys and CSRs matched to their certificates</name>
|
||||||
|
<files>apps/api/src/cert-manager/cert-keys.ts, apps/api/src/cert-manager/cert-keys.spec.ts, apps/api/src/cert-manager/cert-pkcs12.ts, apps/api/src/cert-manager/cert-pkcs12.spec.ts, apps/api/src/cert-manager/cert-csr.ts, apps/api/src/cert-manager/cert-csr.spec.ts, apps/api/src/cert-manager/cert-types.ts, apps/api/src/cert-manager/cert-model.ts, apps/api/src/cert-manager/cert-model.spec.ts, apps/api/src/cert-manager/cert-chain.ts, apps/api/src/cert-manager/cert-chain.spec.ts, apps/api/src/cert-manager/cert-analyze.ts, apps/api/src/cert-manager/cert-analyze.spec.ts, apps/api/src/cert-manager/cert-manager.controller.ts, apps/api/src/cert-manager/cert-manager.controller.spec.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts, apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts, apps/web/src/app/(portal)/modules/cert-manager/components/PasswordInput.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/ItemCard.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh</files>
|
||||||
|
<read_first>apps/api/src/cert-manager/cert-types.ts, cert-model.ts, cert-chain.ts, cert-analyze.ts, cert-manager.controller.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.ts, use-cert-workspace.ts, components/FilesTab.tsx, components/ItemCard.tsx, components/AnalyzeTab.tsx; RESEARCH.md sections on PKCS#12 EC bags (bag.cert null, bag.key false), key lock detection, traditional encryption and per-file passwords</read_first>
|
||||||
|
<precondition>Task 3 is committed (`git log --oneline --grep='Hersteller-ZIP' | grep -q .`) and `e2e-cert.sh all` passes on the running stack.</precondition>
|
||||||
|
<behavior>
|
||||||
|
- Keys (cert-keys): `rsa-leaf-key.pem`, `rsa-leaf-key-pkcs1.pem`, `rsa-leaf-key-pkcs8.der`, `rsa-leaf-key-pkcs1.der`, `ec-leaf-key.pem`, `ec-leaf-key-sec1.pem` and `ec-leaf-key-sec1.der` → one KeyItem each with the same id per key pair (RSA 2048 / EC P-256), wasEncrypted false, pem starting with `-----BEGIN PRIVATE KEY-----`; `rsa-leaf-key-enc-pkcs8.pem`, `rsa-leaf-key-enc-trad.pem`, `ec-leaf-key-enc-pkcs8.pem` (PBE-SHA1-3DES), `ec-leaf-key-enc-trad.pem` and `ec-leaf-key-enc-pkcs8.der` with password `Test-Pass-123` → the same ids with wasEncrypted true; without a password → locked { container 'privateKey', reason 'passwordNeeded' }; with `falsch` → reason 'passwordWrong'; the analysis JSON never contains the password.
|
||||||
|
- PKCS#12 reading (cert-pkcs12 `readPkcs12`): `rsa-modern.pfx`, `rsa-compat.pfx`, `rsa-legacy.pfx`, `ec-modern.pfx`, `ec-compat.pfx` with the password → leaf, inter and root certificates (the EC leaf included although forge leaves its bag.cert null) and the key (EC included although forge leaves bag.key false); `rsa-nopass.pfx` opens without password; `rsa-modern.bin` is recognised by content; no password → locked { container 'pkcs12', reason 'passwordNeeded' }, wrong password → reason 'passwordWrong'; a PFX inside a ZIP is locked with its ZIP path and unlocked by the password of the ZIP's entry; a password typed for file 1 also unlocks file 2 when it is the same; at most 10 distinct passwords are tried.
|
||||||
|
- CSR (cert-csr): `rsa-leaf.csr`, `rsa-leaf.csr.der`, `ec-leaf.csr`, `ec-leaf.csr.der` → CsrItem with cn, organization, san from the extensionRequest, keyType and size/curve; PEM and DER of the same request share one id.
|
||||||
|
- Matching and analyze: the full set (rsa-leaf, rsa-inter, rsa-root, rsa-leaf-key-enc-trad.pem with password, rsa-leaf.csr, ec-compat.pfx with password, ec-leaf.csr.der) → rsa-leaf.keyId = the RSA key, the key's certIds [rsa-leaf], rsa-leaf.csrIds [the RSA CSR], the CSR's keyId and certIds set; the EC leaf from the PFX carries its key; the `passwords` multipart field (JSON array aligned with the files) reaches the parser; a `passwords` value that is not a JSON array of strings, longer than 30 or with an entry over 1024 characters → 400 invalidInput.
|
||||||
|
- Web: FilesTab shows a locked entry with „„{path}“ ist mit einem Passwort geschützt.“, a PasswordInput (show/hide button with translated aria-labels) and „Entsperren“ (also Enter) → setPassword and re-analysis with the password in the `passwords` field; passwordWrong shows „Das Passwort passt nicht.“; a locked PFX while the set already holds an end-entity certificate with a matching key appears as a calm muted note with „Trotzdem entsperren“ (behaviour of 1.1.0 kept); keys and CSRs are listed per entry like certificates. ItemCard: certificate cards show „Passender Schlüssel vorhanden“ when keyId is set; key cards with type, size or curve, „war verschlüsselt“ and the certificate they belong to; CSR cards with subject, SAN, key and matches. AnalyzeTab adds the locked summary. No rendered text contains a password.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**Keys (per D-05, D-11, D-16).** New `cert-keys.ts`: `detectKeyPem(block, passwords)` and `detectKeyDer(buffer, passwords)` on `createPrivateKey` only (PEM labels of D-16; DER types pkcs8, pkcs1, sec1, then encrypted pkcs8), trying the file's own password first and then the other distinct passwords (max 10); returns a KeyItem (pem = `export({ type: 'pkcs8', format: 'pem' })`, details from `asymmetricKeyType`/`asymmetricKeyDetails`), a LockedEntry (passwordNeeded when no own password was given, passwordWrong otherwise) or null; RSA-PSS, Ed25519 and other types readable by Node become KeyItems, unreadable key structures → ignored unsupportedKey. Never log a password or key. `exportKey` follows in Task 5. Write `cert-keys.spec.ts` first.
|
||||||
|
|
||||||
|
**PKCS#12 reading (per D-20).** New `cert-pkcs12.ts` with `readPkcs12(der, passwords)` → `{ certDers, keyObjects }` or locked per D-20 (detection only for a top-level SEQUENCE whose first element is INTEGER 3; own password, then '', then the other passwords). `writePkcs12` follows in Task 5. Write `cert-pkcs12.spec.ts` first.
|
||||||
|
|
||||||
|
**CSR (per D-05, D-16).** New `cert-csr.ts`: `csrItemFromDer(der, source)` walking CertificationRequestInfo with `forge.asn1.fromDer` — subject attributes via `forge.pki.RDNAttributesAsArray` (CN, O), SPKI DER → `createPublicKey({ format: 'der', type: 'spki' })` for type and size, SAN dNSName and iPAddress from the extensionRequest attribute (OID 1.2.840.113549.1.9.14, extension 2.5.29.17); no signature check. Write `cert-csr.spec.ts` first.
|
||||||
|
|
||||||
|
**Slots, matching, analyze, route (per D-15, D-16, D-18, D-24).** Fill the key, PKCS#12 and CSR slots of `cert-model.ts` (PEM labels and the DER order of D-16; PKCS#12 certificates and keys become ordinary items with the PFX as source). Add `matchKeys(certs, keys, csrs)` to `cert-chain.ts` per D-18 (`checkPrivateKey`; SPKI DER equality for CSRs; never names or modulus strings). `analyzeWorkingSet` runs `matchKeys`, fills keyId/certIds/csrIds and reports locked entries. The controller reads the optional multipart field `passwords` (validated per behavior, never logged) and hands it over. Extend the specs first.
|
||||||
|
|
||||||
|
**Web (per D-01, D-05, D-06, D-11, D-24).** `working-set.ts` + `use-cert-workspace.ts`: `setPassword(id, password)` and the `passwords` array in `toFormData`, aligned with the files; passwords exist only in this state and the multipart body. New `components/PasswordInput.tsx` (label, value, show/hide with translated aria-labels, `autoComplete="off"`). FilesTab, ItemCard and AnalyzeTab per behavior. Tests first (FilesTab, working-set, AnalyzeTab). Messages de AND en (rules as in Task 1).
|
||||||
|
|
||||||
|
**Live e2e (per D-11, D-13).** Add section inputs (after zip in `all`): analyze the full fixture set of the behavior with a `passwords` array → 200 with the expected kinds, the RSA leaf with keyId, the EC leaf from ec-compat.pfx with keyId, the CSRs matched; `rsa-modern.pfx` without password → locked passwordNeeded, with `falsch` → passwordWrong; `rsa-legacy.pfx` and `rsa-modern.bin` with the password → certificates plus key; `passwords` set to `nope` → 400 invalidInput; `docker compose logs api --since 10m` contains neither `Test-Pass-123` nor `PRIVATE KEY`. Print `e2e cert inputs ok`.
|
||||||
|
|
||||||
|
**Rebuild, run, commit.** `docker compose up -d --build api web`, wait for /health, run the verify chain. Commit `feat(cert-manager): Schlüssel, PFX und CSR erkennen, Passwort je Datei` with exactly the files of this task. Do not push. Append „## Task 4“ to the SUMMARY.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f apps/api/src/cert-manager/cert-keys.spec.ts && test -f apps/api/src/cert-manager/cert-pkcs12.spec.ts && test -f apps/api/src/cert-manager/cert-csr.spec.ts && pnpm --filter @tessera/api exec vitest run src/cert-manager src/module-registry && pnpm --filter @tessera/web exec vitest run modules/cert-manager src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager" && pnpm exec biome check apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager/components" "apps/web/src/app/(portal)/modules/cert-manager/page.tsx" "apps/web/src/app/(portal)/modules/cert-manager/actions.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts" "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts" "apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx" apps/web/src/messages/de.json apps/web/src/messages/en.json && node .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs 50 && test -z "$(grep -rlE 'certificateFromPem|certificateFromAsn1|certificationRequestFromAsn1|messageFromPem|messageFromAsn1' apps/api/src/cert-manager --include=*.ts | grep -v '\.spec\.ts$')" && docker compose ps --status running --services | grep -qx api && bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all && echo "task4 ok"</automated>
|
||||||
|
<fails_when>non-zero exit of the chain and the last line `task4 ok` missing; the visible signal is one of: a `test -f`/`grep` gate stopping the chain without tool output, vitest printing `FAIL` or a `failed` count in its `Test Files` line, tsc printing `error TS`, biome printing `Found` with errors, check-cert-messages printing `key mismatch`, `too few keys`, `title missing` or `bad text`, or e2e-cert.sh printing `E2E FAIL:` (among others when the api log contains a fixture password or a private key)</fails_when>
|
||||||
|
</verify>
|
||||||
|
<done>Private keys (PKCS#1, PKCS#8, SEC1; PEM and DER; plain and encrypted), PFX files (OpenSSL-3, compatible and legacy; also in a ZIP and without extension) and CSRs of RSA and EC are recognised with a password per file and matched to their certificates; locked files ask for their password; no password or key reaches the api log; specs, tsc, biome and every live section so far are green on the rebuilt stack; one commit on main, not pushed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 5: Every output format — single certificate, PKCS#7, Fullchain and chain in every format, certificate plus key, PFX (Kompatibel / Modern), key and CSR exports; tab Konvertieren and the complete Zusammenführen</name>
|
||||||
|
<files>apps/api/src/cert-manager/cert-keys.ts, apps/api/src/cert-manager/cert-keys.spec.ts, apps/api/src/cert-manager/cert-pkcs12.ts, apps/api/src/cert-manager/cert-pkcs12.spec.ts, apps/api/src/cert-manager/cert-output.ts, apps/api/src/cert-manager/cert-output.spec.ts, apps/api/src/cert-manager/dto/cert-build.dto.ts, apps/api/src/cert-manager/cert-manager.controller.spec.ts, apps/web/src/app/(portal)/modules/cert-manager/actions.ts, apps/web/src/app/(portal)/modules/cert-manager/page.tsx, apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/PfxOptions.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.test.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh</files>
|
||||||
|
<read_first>apps/api/src/cert-manager/cert-output.ts, cert-keys.ts, cert-pkcs12.ts, cert-model.ts (the PKCS#7 walker), dto/cert-build.dto.ts, cert-json-body.ts (caps arithmetic), apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx, components/PasswordInput.tsx; RESEARCH.md Pattern 3 (scoped forge patch for EC PFX)</read_first>
|
||||||
|
<precondition>Task 4 is committed (`git log --oneline --grep='Passwort je Datei' | grep -q .`) and `e2e-cert.sh all` passes on the running stack.</precondition>
|
||||||
|
<behavior>
|
||||||
|
- PKCS#12 writing: `writePkcs12` for RSA and EC, profile compat and modern, re-read by `readPkcs12` gives leaf first, the chain and a key that `checkPrivateKey` accepts for the leaf; walking the output ASN.1 shows the key bag algorithm OID 1.2.840.113549.1.12.1.3 for compat and 1.2.840.113549.1.5.13 with AES-256-CBC 2.16.840.1.101.3.4.1.42 for modern; the key bag and the leaf certificate bag carry the same localKeyId; after a successful call AND after a call that throws inside, `forge.pki.certificateToAsn1`, `privateKeyToAsn1` and `wrapRsaPrivateKey` are the original functions again.
|
||||||
|
- Keys: `exportKey` — pkcs8 without password → PRIVATE KEY, with password → ENCRYPTED PRIVATE KEY that `createPrivateKey` opens with it; traditional RSA → RSA PRIVATE KEY, EC → EC PRIVATE KEY, with password the PEM carries `Proc-Type: 4,ENCRYPTED`; pkcs8-der with password → encrypted DER that opens with it; traditional for an Ed25519 key → formatNotPossible.
|
||||||
|
- Outputs (cert-output): leaf pem `.crt` / der `.cer` (re-read by X509Certificate equal to the input) / p7b / p7c (re-read by the Task-3 PKCS#7 walker: exactly the leaf); fullchain and chain as p7b and p7c contain the path in order (root only with includeRoot) for RSA AND EC; leafKey → `<base>-bundle.pem` with leaf, intermediates and key last, includeChain false → leaf and key only, a key of another certificate → 400 keyMismatch, no key → 400 keyMissing; pfx without password → 400 passwordRequired, with password `Neu-Pass-2026` and pfxEncryption modern → `<base>.pfx` re-read by readPkcs12 with that password, without key → certificates only; key outputs `<base>.key`, `<base>.rsa.key` / `<base>.ec.key`, `<base>.key.der`; csr pem `<base>.csr` and der `<base>.csr.der`; an unknown content/format pair → 400 invalidInput.
|
||||||
|
- DTO: content IsIn every D-19 content, keyPem and csrPem MaxLength `CERT_PEM_MAX`, password MaxLength `CERT_PASSWORD_MAX`, pfxEncryption IsIn compat and modern, includeChain IsBoolean; the maximal-body test of cert-json-body.spec still passes unchanged (Task 2 already counted these fields).
|
||||||
|
- Web: ConvertTab — an item select grouped by kind and per kind the formats of D-19 (certificate: PEM, DER, PKCS#7, PKCS#7 binär; key: PKCS#8, traditionell, DER, optional „Schlüssel mit Passwort schützen“ with PasswordInput; CSR: PEM, DER), „Herunterladen“ calls buildOutput with exactly content, format, the item's pem in the right field and the password only when chosen. MergeTab — format select for Fullchain and Nur Kette (PEM, PKCS#7 .p7b, PKCS#7 binär .p7c); „Zertifikat und Schlüssel (eine PEM-Datei)“ enabled only when the head has keyId, otherwise disabled with the reason; „PFX-Datei“ with PfxOptions (password and repeat, mismatch blocks the download, encryption select default „Kompatibel (auch ältere Windows-Server)“, option „Modern (AES-256)“ with a hint that older Windows servers such as Windows Server 2016 often cannot open it and „Kompatibel“ is the safe choice) → buildOutput with content pfx, keyPem of the matching key, password and pfxEncryption. Page: tabs Dateien, Analysieren, Aufteilen, Zusammenführen, Konvertieren.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**PKCS#12 writing (per D-07, D-20).** Add `writePkcs12({ keyObject | null, certDers, password, profile: 'compat' | 'modern', friendlyName })` to `cert-pkcs12.ts` with the scoped patch of D-20 and a header comment explaining why it is safe and where the original functions are restored. Extend `cert-pkcs12.spec.ts` first (round trips, OIDs, localKeyId, restoration also after an injected throw).
|
||||||
|
|
||||||
|
**Key export (per D-19).** Add `exportKey(keyObject, format, password?)` to `cert-keys.ts` (cipher `aes-256-cbc`; traditional = pkcs1 for RSA, sec1 for EC, else formatNotPossible). Extend the spec first.
|
||||||
|
|
||||||
|
**Outputs (per D-02, D-07, D-19).** Extend `buildOutput` with every remaining content × format of D-19 in one switch with exhaustive checking: the hand-built PKCS#7 (forge.asn1, no forge certificate objects), the bundle, PFX via `writePkcs12` (keyPem parsed with `createPrivateKey`, `checkPrivateKey` against the leaf → keyMismatch otherwise), key and CSR exports; mime types: pem `application/x-pem-file`, der `application/pkix-cert`, p7b/p7c `application/x-pkcs7-certificates`, pfx `application/x-pkcs12`, key `application/x-pem-file` or `application/octet-stream`, csr `application/pkcs10`. Widen `BuildOutputDto` per behavior. Extend `cert-output.spec.ts` and the controller spec first.
|
||||||
|
|
||||||
|
**Web (per D-02, D-05, D-07, D-23, D-24).** `actions.ts`: the full BuildInput. New `components/PfxOptions.tsx`, new `components/ConvertTab.tsx`, MergeTab extended, `page.tsx` tab convert — all per behavior. Write `ConvertTab.test.tsx` and extend `MergeTab.test.tsx` and the page test first. Messages de AND en (rules as in Task 1).
|
||||||
|
|
||||||
|
**Live e2e (per D-07, D-11, D-13).** Add section formats (after inputs in `all`): for RSA and EC build and check with openssl — leaf der (`openssl x509 -inform DER`), fullchain p7b and p7c (`openssl pkcs7 [-inform DER] -print_certs` lists the path in order), bundle (first certificate is the leaf, `openssl pkey -pubout` of the key equals `openssl x509 -pubkey -noout` of the leaf), pfx compat (`openssl pkcs12 -info -noout -passin pass:Neu-Pass-2026` output contains `pbeWithSHA1And3-KeyTripleDES-CBC`) and pfx modern (contains `AES-256-CBC`), encrypted pkcs8 key (`openssl pkey -passin pass:… -noout` OK), traditional key (`BEGIN RSA PRIVATE KEY` / `BEGIN EC PRIVATE KEY`), csr der (`openssl req -inform DER -noout -subject`); `docker compose logs api --since 10m` contains neither `Test-Pass-123`, `Neu-Pass-2026` nor `PRIVATE KEY`. Print `e2e cert formats ok`.
|
||||||
|
|
||||||
|
**Rebuild, run, commit.** `docker compose up -d --build api web`, wait for /health, run the verify chain. Commit `feat(cert-manager): alle Ausgabeformate, Konvertieren, Bundle und PFX` with exactly the files of this task. Do not push. Append „## Task 5“ to the SUMMARY with output item 2 (the openssl outputs that prove the PFX algorithms and the P7B/P7C order).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f "apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.test.tsx" && grep -q "writePkcs12" apps/api/src/cert-manager/cert-pkcs12.spec.ts && grep -q "exportKey" apps/api/src/cert-manager/cert-keys.spec.ts && pnpm --filter @tessera/api exec vitest run src/cert-manager src/module-registry && pnpm --filter @tessera/web exec vitest run modules/cert-manager src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager" && pnpm exec biome check apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager/components" "apps/web/src/app/(portal)/modules/cert-manager/page.tsx" "apps/web/src/app/(portal)/modules/cert-manager/actions.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts" "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts" "apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx" apps/web/src/messages/de.json apps/web/src/messages/en.json && node .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs 60 && test -z "$(grep -rlE 'certificateFromPem|certificateFromAsn1|certificationRequestFromAsn1|messageFromPem|messageFromAsn1' apps/api/src/cert-manager --include=*.ts | grep -v '\.spec\.ts$')" && docker compose ps --status running --services | grep -qx api && bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all && echo "task5 ok"</automated>
|
||||||
|
<fails_when>non-zero exit of the chain and the last line `task5 ok` missing; the visible signal is one of: a `test -f`/`grep` gate stopping the chain without tool output, vitest printing `FAIL` or a `failed` count in its `Test Files` line, tsc printing `error TS`, biome printing `Found` with errors, check-cert-messages printing `key mismatch`, `too few keys`, `title missing` or `bad text`, or e2e-cert.sh printing `E2E FAIL:` (among others when openssl cannot read an output or the PFX info lacks the expected algorithm)</fails_when>
|
||||||
|
</verify>
|
||||||
|
<done>Every output of D-19 is produced for RSA and EC and read back by openssl in the e2e (both PFX profiles with the expected algorithm, P7B/P7C in order); Konvertieren converts any single item, Zusammenführen offers every chain format, certificate plus key and PFX; no password or key appears in the api log; specs, tsc, biome and every live section so far are green on the rebuilt stack; one commit on main, not pushed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 6: Templates for target systems with one click and a configuration snippet; module version 1.2.0 with changelogs and guides for everything built so far</name>
|
||||||
|
<files>apps/api/src/cert-manager/cert-templates.ts, apps/api/src/cert-manager/cert-templates.spec.ts, apps/api/src/cert-manager/cert-types.ts, apps/api/src/cert-manager/cert-output.ts, apps/api/src/cert-manager/cert-output.spec.ts, apps/api/src/cert-manager/dto/cert-build.dto.ts, apps/api/src/cert-manager/cert-manager.changelog.ts, apps/web/src/app/(portal)/modules/cert-manager/actions.ts, apps/web/src/app/(portal)/modules/cert-manager/page.tsx, apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/TemplatesTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/TemplatesTab.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-betrieb.md, docs/anleitung-entwicklung.md, .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh</files>
|
||||||
|
<read_first>apps/api/src/cert-manager/cert-output.ts, cert-keys.ts, cert-pkcs12.ts, dto/cert-build.dto.ts, cert-manager.changelog.ts, apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx and PfxOptions.tsx, docs/anleitung-entwicklung.md „### Modulversion und Modul-Changelog pflegen“ (~line 299), the head of CHANGELOG.md, docs/anleitung-anwender.md lines 150–161, docs/anleitung-betrieb.md „### Dateien (Nextcloud)“ (line 186) and „### Fehlerbilder“ (~line 755), docs/anleitung-entwicklung.md „## Konventionen und Fallstricke“ (line 693, last paragraph ~line 800); RESEARCH.md templates table and A2–A4</read_first>
|
||||||
|
<precondition>Task 5 is committed (`git log --oneline --grep='alle Ausgabeformate' | grep -q .`) and `e2e-cert.sh all` passes on the running stack.</precondition>
|
||||||
|
<behavior>
|
||||||
|
- Templates (cert-templates via buildOutput content template): nginx and apache for RSA and EC → files `fullchain.pem` (leaf and intermediates in order) and `privkey.pem` (PKCS#8 matching the leaf) plus the D-21 snippet with the base name; apache-legacy → `cert.pem`, `chain.pem`, `privkey.pem`; iis → one `<base>.pfx` that readPkcs12 opens with the password and whose key bag uses the compat OID (no pfxEncryption sent); npm → `certificate.pem` (only the leaf), `intermediate.pem` (only intermediates, root with includeRoot), `privkey.pem` starting with `-----BEGIN RSA PRIVATE KEY-----` for RSA and `-----BEGIN EC PRIVATE KEY-----` for EC, snippet null; haproxy → one `<base>.pem` with leaf, intermediates and key in this order; tomcat → one `<base>.p12` with friendlyName = base name and a snippet that contains `IHR-PASSWORT` and never the password sent; without key → 400 templateNeedsKey; iis/tomcat without password → 400 passwordRequired; an unknown template id → 400 invalidInput.
|
||||||
|
- TemplatesTab: head choice, „Root-Zertifikat mitnehmen“ (off), seven template cards (Nginx, Apache 2.4.8 und neuer, Apache älter als 2.4.8, Windows / IIS, Nginx Proxy Manager, HAProxy, Tomcat / Java) with what each delivers; without matching key all cards disabled with the reason; IIS and Tomcat show PfxOptions with „Kompatibel“ preselected; a multi-file template downloads one ZIP `<base>-<id>.zip` (fflate) containing the files and `ANLEITUNG.txt` with the steps and the snippet; single-file templates download the file directly; afterwards the snippet is shown with „Kopieren“ (navigator.clipboard.writeText). Page: all six tabs in the D-23 order.
|
||||||
|
- module-changelog.spec: cert-manager's top entry is 1.2.0 dated 2026-10-09 and the seed version equals it.
|
||||||
|
- Guides and CHANGELOG describe everything of Tasks 1–6; the missing-intermediate fetch is added by Task 7.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**Templates (per D-04, D-07, D-21).** New `cert-templates.ts` with `TEMPLATE_IDS` and `buildTemplate(id, ctx)` returning files and snippet per D-21, reusing the chain, `exportKey` and `writePkcs12`; wire content template plus `template` (IsIn TEMPLATE_IDS) into `buildOutput` and `BuildOutputDto`, `snippet` in BuildResult. Write `cert-templates.spec.ts` and the output case first.
|
||||||
|
|
||||||
|
**Web (per D-04, D-23).** `actions.ts`: the template fields. New `components/TemplatesTab.tsx` per behavior (per template: title, what you get, steps from messages `templates.<id>.steps`; for Nginx Proxy Manager the steps name SSL Certificates, „Add SSL Certificate“, „Custom“ and which file goes into Certificate Key, Certificate and Intermediate Certificate; English export `INSTRUCTIONS.txt`). `page.tsx`: tab templates. Write `TemplatesTab.test.tsx` and extend the page test first. Messages de AND en (rules as in Task 1).
|
||||||
|
|
||||||
|
**Version and changelogs (per D-09).** In `cert-manager.changelog.ts` add above 1.1.0 the entry version 1.2.0, date 2026-10-09 (1.1.0 unchanged) with these items (de / en, adjust wording only if the guard spec demands): new „Ein gemeinsamer Reiter „Dateien“: Laden Sie mehrere Dateien und ZIP-Dateien auf einmal hoch oder fügen Sie PEM-Text ein. Alle anderen Reiter arbeiten mit dieser Liste.“ / „One shared “Files” tab: upload several files and ZIP files at once or paste PEM text. All other tabs work with this list.“; new „„Zusammenführen“ ordnet die Kette selbst und liefert Fullchain, nur die Kette, Zertifikat mit Schlüssel oder eine PFX-Datei; das Root-Zertifikat nehmen Sie nur auf Wunsch mit.“ / „“Merge” orders the chain by itself and delivers a full chain, the chain only, certificate with key or a PFX file; the root certificate is only included on request.“; new „Alle gängigen Formate rein und raus, auch Zertifikate und Schlüssel mit elliptischen Kurven (EC) und verschlüsselte Schlüssel. PFX-Dateien wahlweise kompatibel oder modern verschlüsselt.“ / „All common formats in and out, including elliptic-curve (EC) certificates and keys and encrypted keys. PFX files with compatible or modern encryption.“; new „Vorlagen für Nginx, Apache, Windows/IIS, Nginx Proxy Manager, HAProxy und Tomcat liefern die passenden Dateien mit einem Klick.“ / „Templates for Nginx, Apache, Windows/IIS, Nginx Proxy Manager, HAProxy and Tomcat deliver the right files with one click.“; fixed „Beim Zusammenführen ersetzt eine zweite Datei nicht mehr die erste.“ / „When merging, a second file no longer replaces the first one.“ (Task 7 adds the item for the missing-intermediate fetch). In `CHANGELOG.md` under „## Unveröffentlicht“: in „### Neu“ two bullets „Zertifikat-Manager: …“ (the shared Dateien tab with ZIP and pasted text and the browser-only list; Zusammenführen with Fullchain, Nur Kette, bundle and PFX with the root checkbox, the Kompatibel/Modern choice, Vorlagen; „Modulversion 1.2.0.“), in „### Behoben“ one bullet for the replaced second file and the EC certificates and keys that were not recognised. Plain words, „Sie“, no file names.
|
||||||
|
|
||||||
|
**Guides (per D-11, D-12; everyday language, „Sie“, detailed, no tenant or licence wording).** `docs/anleitung-anwender.md` „### Zertifikat-Manager“: replace the whole section body including its old introduction sentence — the six tabs and the working-set idea, Dateien (several files, ZIPs, pasted text, limits 30 files and 10 MB, what the list shows, passwords per file, „Die Liste bleibt nur in diesem Browserfenster …“), Analysieren, Aufteilen, Zusammenführen (Fullchain, Nur Kette, Zertifikat und Schlüssel, PFX with password and Kompatibel/Modern advice, „Root-Zertifikat mitnehmen“ off by default and when you need it, what „Zwischenzertifikat fehlt“ means), Konvertieren, Vorlagen (one sentence per template: what you get and where it goes), supported formats; Task 7 adds the paragraph on „Fehlendes Zertifikat holen“. `docs/anleitung-betrieb.md`: new „### Zertifikat-Manager“ after „### Dateien (Nextcloud)“ in chapter 3 (uploads go through `/api-proxy`, one analysis sends at most 10 MB, so the `client_max_body_size` of at least `10m` from the Dateien section covers it; a single download request is at most 512 KiB; nothing is stored, no setting) and one row in „### Fehlerbilder“ (upload aborted with 413). `docs/anleitung-entwicklung.md` „## Konventionen und Fallstricke“: paragraph „**Zertifikat-Manager, Arbeitsbereich und Ketten (quick-261009-ikt):**“ (node:crypto decides, forge only for PKCS#12 and as ASN.1 tool and why; the stateless routes; chain rule checkIssued plus verify; the scoped PFX patch; ZIP limits; the per-route body limit of `build` from D-26 including the `jsonParser` name trap; fixtures in `__fixtures__` with `make-fixtures.sh`, never `.key` names). `docs/anleitung-administration.md` stays unchanged (no settings).
|
||||||
|
|
||||||
|
**Live e2e (per D-13).** Add sections templates and version (after formats in `all`). templates: for the RSA set and the EC set build every template, decode the files with `python3 -I` and check with openssl per behavior (nginx `openssl verify` of fullchain, key matches leaf; iis `openssl pkcs12 -info` contains `pbeWithSHA1And3-KeyTripleDES-CBC`; npm key headers; haproxy first block is the leaf and a key is present; tomcat readable). version: `GET $API/modules/changelog/cert-manager` → 200, first release 1.2.0 dated 2026-10-09; the catalog lists cert-manager with version 1.2.0. Print one ok line per section.
|
||||||
|
|
||||||
|
**Rebuild, run, commit.** `docker compose up -d --build api web`, wait for /health, check the api log for „Cert-Manager module seeded in registry“, run the verify chain. Commit `feat(cert-manager): Vorlagen für Zielsysteme, Modulversion 1.2.0 und Anleitungen` with exactly the files of this task. Do not push. Append „## Task 6“ to the SUMMARY.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f apps/api/src/cert-manager/cert-templates.spec.ts && test -f "apps/web/src/app/(portal)/modules/cert-manager/components/TemplatesTab.test.tsx" && pnpm --filter @tessera/api exec vitest run src/cert-manager src/module-registry && pnpm --filter @tessera/web exec vitest run modules/cert-manager src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager" && pnpm exec biome check apps/api/src/cert-manager "apps/web/src/app/(portal)/modules/cert-manager/components" "apps/web/src/app/(portal)/modules/cert-manager/page.tsx" "apps/web/src/app/(portal)/modules/cert-manager/actions.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts" "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts" "apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx" apps/web/src/messages/de.json apps/web/src/messages/en.json && node .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs 80 && grep -q "version: '1.2.0'" apps/api/src/cert-manager/cert-manager.changelog.ts && grep -q "date: '2026-10-09'" apps/api/src/cert-manager/cert-manager.changelog.ts && awk '/^## Unveröffentlicht/{f=1;next} /^## /{f=0} f' CHANGELOG.md | grep -q "Zertifikat-Manager" && grep -q "Root-Zertifikat mitnehmen" docs/anleitung-anwender.md && ! grep -q "Ein Werkzeug rund um SSL/TLS-Zertifikate mit vier Reitern" docs/anleitung-anwender.md && grep -q "^### Zertifikat-Manager" docs/anleitung-betrieb.md && grep -q "quick-261009-ikt" docs/anleitung-entwicklung.md && test -z "$(grep -rlE 'certificateFromPem|certificateFromAsn1|certificationRequestFromAsn1|messageFromPem|messageFromAsn1' apps/api/src/cert-manager --include=*.ts | grep -v '\.spec\.ts$')" && docker compose ps --status running --services | grep -qx api && docker compose logs api 2>&1 | grep -q "Cert-Manager module seeded in registry" && bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all && echo "task6 ok"</automated>
|
||||||
|
<fails_when>non-zero exit of the chain and the last line `task6 ok` missing; the visible signal is one of: a `test -f`/`grep`/`awk` gate stopping the chain without tool output (version, date, CHANGELOG bullet or guide section missing, or the old introduction sentence still present), vitest printing `FAIL` or a `failed` count in its `Test Files` line (module-changelog.spec included), tsc printing `error TS`, biome printing `Found` with errors, check-cert-messages printing `key mismatch`, `too few keys`, `title missing` or `bad text`, or e2e-cert.sh printing `E2E FAIL:`</fails_when>
|
||||||
|
</verify>
|
||||||
|
<done>Seven target-system templates download with one click (with snippet, IIS and Tomcat compatible by default); module version 1.2.0 with its changelog, the CHANGELOG bullets and the three guides describe everything built so far; specs, tsc, biome and every live section so far incl. version are green on the rebuilt stack; one commit on main, not pushed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 7: „Fehlendes Zertifikat holen“ — button-only AIA fetch through the hardened shared address guard, fetched entries marked „nachgeladen“, proven live</name>
|
||||||
|
<files>apps/api/src/common/public-url-guard.ts, apps/api/src/common/public-url-guard.spec.ts, apps/api/src/cert-manager/cert-aia.ts, apps/api/src/cert-manager/cert-aia.spec.ts, apps/api/src/cert-manager/dto/cert-fetch-issuer.dto.ts, apps/api/src/cert-manager/cert-manager.controller.ts, apps/api/src/cert-manager/cert-manager.controller.spec.ts, apps/api/src/cert-manager/cert-manager.changelog.ts, apps/web/src/app/(portal)/modules/cert-manager/actions.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.ts, apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts, apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts, apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.test.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/ItemCard.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-betrieb.md, docs/anleitung-entwicklung.md, .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh</files>
|
||||||
|
<read_first>apps/api/src/common/public-url-guard.ts, apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts (the loop to copy: `fetchImpl`, `isPublic`, `timeoutMs`, `discard`, abort race, streamed byte cap, one warn line), apps/api/src/cert-manager/cert-model.ts (PKCS#7 walker), cert-manager.controller.ts, apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.tsx, working-set.ts, use-cert-workspace.ts; RESEARCH.md sections on the AIA pattern, ports 80/443 (A8), the guarded lookup and the IPv6 weaknesses of the shared guard</read_first>
|
||||||
|
<precondition>Task 6 is committed (`git log --oneline --grep='Vorlagen für Zielsysteme' | grep -q .`), `e2e-cert.sh all` passes on the running stack, and the api container reaches the internet: `docker compose exec -T api node -e "fetch('http://ye2.i.lencr.org/').then(r=>console.log(r.status))"` prints 200.</precondition>
|
||||||
|
<behavior>
|
||||||
|
- Guard (new `public-url-guard.spec.ts`, exported `isPrivateIpAddress`): private/blocked → `127.0.0.1`, `10.1.2.3`, `100.64.0.1`, `169.254.169.254`, `::`, `::1`, `::ffff:127.0.0.1`, `::ffff:7f00:1`, `0:0:0:0:0:ffff:7f00:1`, `::ffff:a9fe:a9fe`, `::7f00:1` (IPv4-compatible), `64:ff9b::7f00:1`, `64:ff9b::10.0.0.1`, `64:ff9b:1::1`, `2002:7f00:1::1`, `2002:c0a8:101::1`, `fc00::1`, `fd12::1`, `fe80::1`, `fe80::1%eth0`, `febf::1`, `fec0::1`, `ff02::1`, `2001:db8::1`, `100::1`, `2001::1` (Teredo), a malformed address; public → `8.8.8.8`, `2606:4700:4700::1111`, `64:ff9b::808:808`, `2002:808:808::1`, `2a00:1450:4001:82a::200e`; `isPublicHttpUrl` with a mocked `node:dns/promises` lookup returning `{ address: '::ffff:7f00:1', family: 6 }` → false, returning 8.8.8.8 → true; ftp → false; the existing favorites and nextcloud-status specs stay green.
|
||||||
|
- AIA (cert-aia, injected `fetchImpl`, `isPublic`, `timeoutMs`): rsa-leaf with a fake answer of rsa-inter's DER → `{ host: 'pki.example.test', cn: 'Tessera Test Inter RSA', filename: 'Tessera_Test_Inter_RSA.crt' }` and the PEM verifies as issuer; an answer of `rsa-chain.p7c` → only rsa-inter is returned; a PEM text answer works; ec-inter's DER for rsa-leaf → 422 aiaNotIssuer; `aia-private-leaf.pem` with the real guard → 422 aiaInternal and fetchImpl never called; `rsa-leaf-noaki.pem` → 422 aiaMissing; a URL with port 8080 → skipped like a non-public address; a 302 to `http://10.0.0.5/x` → refused before the second request; four redirects → aiaUnreachable; content-length 300 000 → aiaTooLarge without reading; a streamed body over 256 KiB → aiaTooLarge; a hanging server → aiaUnreachable after the injected timeout; HTTP 404 → aiaUnreachable; `createGuardedLookup` with a fake resolver returning 10.0.0.1 errors, with 93.184.215.14 calls back with that address; the request headers contain no cookie and no authorization; a failure writes exactly one warn line containing host and code and no PEM text.
|
||||||
|
- Controller: handlers exactly analyze, build and fetchIssuer (`POST fetch-issuer`, http code 200); FetchIssuerDto rejects a pem over 16 384 characters and drops an extra `url` field.
|
||||||
|
- Web: ChainView gap row with aiaUrls shows „Fehlendes Zertifikat holen“ and the hint with the host of the first URL; without aiaUrls only the instruction to download it from the vendor; click → `fetchIssuer(<pem of the gap certificate>)` once, button busy and disabled meanwhile, success → `addFetched` adds an entry labelled with the returned filename, origin fetched, host, and the set is re-analysed; the same certificate twice is not added again; error codes show their texts; nothing is fetched on render or on re-analysis. FilesTab and ItemCard show „nachgeladen von {host}“ for fetched entries and their items.
|
||||||
|
- module-changelog.spec stays green with the extra 1.2.0 item.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**Guard hardening (per D-10).** In `apps/api/src/common/public-url-guard.ts` export `isPrivateIpAddress` and rewrite `isPrivateIpv6` on a small `expandIpv6(address)` helper (zone id stripped, `::` expanded, embedded dotted IPv4 converted, eight 16-bit groups): blocked = unspecified and loopback, all of `::/96` (IPv4-compatible) and `::ffff:0:0/96` judged by the embedded IPv4 through `isPrivateIpv4`, `64:ff9b::/96` judged by the embedded IPv4, `64:ff9b:1::/48` always, `2002::/16` judged by the IPv4 in groups 2–3, `2001::/32` (Teredo) always, `2001:db8::/32`, `100::/64`, `fc00::/7`, `fe80::/10`, `fec0::/10`, `ff00::/8`; anything unparsable stays blocked. Update the header comment (quick 261009-ikt, list of ranges). Write `public-url-guard.spec.ts` first per behavior (vi.mock of `node:dns/promises` for the lookup cases). Run the favorites and nextcloud-status specs too.
|
||||||
|
|
||||||
|
**AIA fetch (per D-03, D-22, D-24).** New `cert-aia.ts` with `fetchIssuer(pem, opts = {})` and `createGuardedLookup(resolve = dns.lookup)` exactly per D-22 (header comment listing every protection and the accepted remainder: any authenticated module user can make the API send one GET to a public address named in a certificate they upload; the answer is only returned when it is a verified issuer certificate). The real path passes `dispatcher: new Agent({ connect: { lookup: createGuardedLookup() } })` from undici. New `dto/cert-fetch-issuer.dto.ts` and `@Post('fetch-issuer')` with `@HttpCode(200)` in the controller (header route list updated). Write `cert-aia.spec.ts` first per behavior and extend the controller spec.
|
||||||
|
|
||||||
|
**Web (per D-01, D-03, D-11).** `actions.ts`: `fetchIssuer(pem)`. `working-set.ts`/`use-cert-workspace.ts`: `addFetched({ filename, pem, host })` (origin fetched; the same certificate twice is not added again). `ChainView.tsx`: gap row with the button per behavior (props `onFetched`, busy per gap, error text); MergeTab and AnalyzeTab pass the workspace's `addFetched`. `FilesTab.tsx` and `ItemCard.tsx`: the „nachgeladen von {host}“ marker. Tests first: `ChainView.test.tsx`, extended FilesTab and working-set tests. Messages de AND en (rules as in Task 1).
|
||||||
|
|
||||||
|
**Changelogs and guides for this feature (per D-03, D-09, D-10, D-12).** `cert-manager.changelog.ts`: add to the 1.2.0 entry the item new „Fehlt ein Zwischenzertifikat, holt „Fehlendes Zertifikat holen“ es auf Knopfdruck beim Aussteller.“ / „If an intermediate certificate is missing, “Fetch missing certificate” gets it from the issuer at the click of a button.“. `CHANGELOG.md` under „## Unveröffentlicht“: extend the Zusammenführen bullet with „Fehlendes Zertifikat holen“ only on click, and add one bullet „Sicherheit: …“ in plain words that the protection against fetching internal addresses (favourite icons, Nextcloud-Status logos and the new certificate fetch) now also recognises hidden spellings of internal IPv6 addresses. Anwenderanleitung: paragraph „Fehlendes Zertifikat holen“ (only on click, Tessera asks the issuer on the internet, the entry is marked nachgeladen, a second click may be needed for the next level). Betriebsanleitung „### Zertifikat-Manager“: the api container needs outbound http/https on ports 80 and 443 to the certificate issuers' addresses, otherwise the button reports „nicht erreichbar“; one row in „### Fehlerbilder“ (fetch reports nicht erreichbar). Entwicklungsanleitung paragraph of Task 6: the AIA guard with guarded lookup and the hardened shared guard.
|
||||||
|
|
||||||
|
**Live e2e (per D-03, D-13).** Add section aia (last in `all`): `aia-private-leaf.pem` → 422 aiaInternal; `rsa-leaf-noaki.pem` → 422 aiaMissing; a body `{ "pem": …, "url": "http://127.0.0.1/" }` behaves like without url; live — unless `CERT_E2E_OFFLINE=1` is set — fetch the letsencrypt.org leaf with `openssl s_client -connect letsencrypt.org:443 -servername letsencrypt.org` into `$E2E_TMP`, analyze it → gap afterLeaf with an http aia URL, POST fetch-issuer → 200, host ends with `lencr.org`, `openssl verify -partial_chain -trusted <fetched> <leaf>` OK, analyze leaf plus fetched → path of two with gap afterCa; `docker compose logs api --since 10m` contains no `BEGIN CERTIFICATE`. Print `e2e cert aia ok`.
|
||||||
|
|
||||||
|
**Rebuild, run, commit.** `docker compose up -d --build api web`, wait for /health, run the verify chain. Commit `feat(cert-manager): Fehlendes Zertifikat holen, gehärteter Adressschutz` with exactly the files of this task. Do not push. Append „## Task 7“ to the SUMMARY with output item 3 (live AIA result: host, fetched CN, what the next level showed — or why `CERT_E2E_OFFLINE=1` had to be used).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f apps/api/src/common/public-url-guard.spec.ts && test -f apps/api/src/cert-manager/cert-aia.spec.ts && test -f "apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.test.tsx" && pnpm --filter @tessera/api exec vitest run src/cert-manager src/module-registry src/common src/favorites src/nextcloud-status && pnpm --filter @tessera/web exec vitest run modules/cert-manager src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/api/src/cert-manager apps/api/src/common "apps/web/src/app/(portal)/modules/cert-manager" && pnpm exec biome check apps/api/src/cert-manager apps/api/src/common/public-url-guard.ts apps/api/src/common/public-url-guard.spec.ts "apps/web/src/app/(portal)/modules/cert-manager/components" "apps/web/src/app/(portal)/modules/cert-manager/page.tsx" "apps/web/src/app/(portal)/modules/cert-manager/actions.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts" "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts" "apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx" apps/web/src/messages/de.json apps/web/src/messages/en.json && node .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs 85 && awk '/^## Unveröffentlicht/{f=1;next} /^## /{f=0} f' CHANGELOG.md | grep -q "IPv6" && grep -q "Fehlendes Zertifikat holen" docs/anleitung-anwender.md && grep -q "Fehlendes Zertifikat holen" apps/api/src/cert-manager/cert-manager.changelog.ts && test -z "$(grep -rlE 'certificateFromPem|certificateFromAsn1|certificationRequestFromAsn1|messageFromPem|messageFromAsn1' apps/api/src/cert-manager --include=*.ts | grep -v '\.spec\.ts$')" && docker compose ps --status running --services | grep -qx api && bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all && echo "task7 ok"</automated>
|
||||||
|
<fails_when>non-zero exit of the chain and the last line `task7 ok` missing; the visible signal is one of: a `test -f`/`grep`/`awk` gate stopping the chain without tool output (security bullet, guide paragraph or changelog item missing), vitest printing `FAIL` or a `failed` count in its `Test Files` line (favorites and nextcloud-status included), tsc printing `error TS`, biome printing `Found` with errors, check-cert-messages printing `key mismatch`, `too few keys`, `title missing` or `bad text`, or e2e-cert.sh printing `E2E FAIL:` (among others when a private address is contacted, the live letsencrypt fetch fails or the api log contains a certificate)</fails_when>
|
||||||
|
</verify>
|
||||||
|
<done>„Fehlendes Zertifikat holen“ fetches the verified issuer only on click through the hardened guard (proven live with letsencrypt.org) and marks it nachgeladen; the shared guard blocks every hidden IPv6 spelling of internal addresses with the old users' specs green; changelog item, CHANGELOG security bullet and guide paragraphs are in place; specs, tsc, biome and every live section are green on the rebuilt stack; one commit on main, not pushed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="false">
|
||||||
|
<name>Task 8: Final gates on the rebuilt stack, browser proof in dark and light mode with design review, todo closed</name>
|
||||||
|
<files>.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh (only if a check needs fixing), .planning/todos/pending/2026-10-09-cert-manager-mehrere-dateien-zip-fullchain.md (moved to .planning/todos/completed/), module web files under apps/web/src/app/(portal)/modules/cert-manager/ and apps/web/src/messages/{de,en}.json only where the design review finds something (each behaviour change with a test), docs/anleitung-anwender.md only where a label in the guide differs from the real page</files>
|
||||||
|
<read_first>apps/web/src/app/(portal)/modules/cert-manager/page.tsx and the components it renders (to know the labels), docs/anleitung-anwender.md section „### Zertifikat-Manager“, the context facts on Playwright (dark mode via the theme button, never judge by fetch from inside the page)</read_first>
|
||||||
|
<precondition>Task 7 is committed (`git log --oneline --grep='Fehlendes Zertifikat holen' | grep -q .`) and `e2e-cert.sh all` passes on the running stack including the live aia part.</precondition>
|
||||||
|
<behavior>
|
||||||
|
- Full api and web suites, both tsc runs, biome and `e2e-cert.sh all` are green on a freshly rebuilt stack; the api log shows the seed line.
|
||||||
|
- Browser: two separate selections both stay listed (the user's bug), ZIP and PFX join the list, the PFX unlocks, Fullchain downloads with two certificates when the root is unticked, a template downloads, the gap shows „Zwischenzertifikat fehlt“ with the button, the click adds the YE2 entry „nachgeladen von …“, after a reload the list is empty and the note is visible; eight dark and four light screenshots exist and were reviewed against D-23.
|
||||||
|
- Every label quoted in the Anwenderanleitung matches the page.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**Final gates.** `docker compose up -d --build api web`, wait for /health, check the seed line „Cert-Manager module seeded in registry“ in the api log, run the full `pnpm --filter @tessera/api test` and `pnpm --filter @tessera/web test`, both tsc, biome per the verify and `e2e-cert.sh all`.
|
||||||
|
|
||||||
|
**Browser proof (per D-13, D-23).** With Playwright MCP at http://localhost:3000 as admin/admin123. To keep this run small, take screenshots with a file name and request a page snapshot only when you need element references. Prepare in the session scratchpad a folder with copies of the fixtures: `server.crt` (rsa-leaf), `intermediate.crt` (rsa-inter), a vendor ZIP `zertifikat-paket.zip` (ec-leaf as ServerCertificate.crt, ec-inter, ec-root, ec-leaf-key.pem as `server.key`, a `__MACOSX` entry), `rsa-compat.pfx`, and `letsencrypt-leaf.pem` from `openssl s_client`. Switch to dark mode with the theme button and capture under `.playwright-mcp/cert-manager/`: select `server.crt`, then in a second selection `intermediate.crt` — both stay listed (the user's bug) — then the ZIP and the PFX, unlock the PFX with its password: `ikt-dark-files.png`; Analysieren: `ikt-dark-analyze.png`; Zusammenführen for the EC leaf with the root unticked, download the Fullchain and check the downloaded file holds two certificates (openssl on the saved file), open the PFX options: `ikt-dark-merge.png`; Konvertieren with the key to traditional with password: `ikt-dark-convert.png`; Vorlagen, download Nginx and Windows / IIS, snippet visible: `ikt-dark-templates.png`; „Alle entfernen“, add `letsencrypt-leaf.pem`, Zusammenführen shows „Zwischenzertifikat fehlt“ with the button: `ikt-dark-gap.png`; click it, the YE2 entry appears „nachgeladen von ye2.i.lencr.org“ (or the host the certificate names) and the next level shows its own calm note and button: `ikt-dark-fetched.png`; Dateien at 390×844: `ikt-dark-mobile.png`; reload the page and confirm the list is empty and the note visible. Then light mode: `ikt-light-files.png`, `ikt-light-merge.png`, `ikt-light-templates.png`, `ikt-light-gap.png`. Review every screenshot against D-23 (calm dense list, readable badges and warnings in both modes, visible focus, no ALL-CAPS labels, no arrow characters, no middle dots); fix findings in the module's web files with a test where the behaviour changes, rebuild web and re-shoot. Compare the labels quoted in the Anwenderanleitung with the page and correct the guide where they differ.
|
||||||
|
|
||||||
|
**Todo and commit.** `git mv` `.planning/todos/pending/2026-10-09-cert-manager-mehrere-dateien-zip-fullchain.md` to `.planning/todos/completed/` if it is still pending. Commit `feat(cert-manager): Browser-Nachweis und Abschluss` with exactly the files of this task (the todo move plus any review fixes). Do not push, do not deploy. Append „## Task 8“ to the SUMMARY with output items 4 to 6.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/api/src/cert-manager apps/api/src/common "apps/web/src/app/(portal)/modules/cert-manager" && pnpm exec biome check apps/api/src/cert-manager apps/api/src/main.ts apps/api/src/common/public-url-guard.ts apps/api/src/common/public-url-guard.spec.ts "apps/web/src/app/(portal)/modules/cert-manager/components" "apps/web/src/app/(portal)/modules/cert-manager/page.tsx" "apps/web/src/app/(portal)/modules/cert-manager/actions.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.ts" "apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts" "apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts" "apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx" apps/web/src/messages/de.json apps/web/src/messages/en.json && node .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/check-cert-messages.cjs 85 && test ! -e .planning/todos/pending/2026-10-09-cert-manager-mehrere-dateien-zip-fullchain.md && test -e .planning/todos/completed/2026-10-09-cert-manager-mehrere-dateien-zip-fullchain.md && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web && docker compose logs api 2>&1 | grep -q "Cert-Manager module seeded in registry" && bash .planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/e2e/e2e-cert.sh all && test "$(ls .playwright-mcp/cert-manager/ikt-dark-*.png 2>/dev/null | wc -l)" -ge 8 && test "$(ls .playwright-mcp/cert-manager/ikt-light-*.png 2>/dev/null | wc -l)" -ge 4 && echo "task8 ok"</automated>
|
||||||
|
<fails_when>non-zero exit of the chain and the last line `task8 ok` missing; the visible signal is one of: vitest printing `FAIL` or a `failed` count in either full suite, tsc printing `error TS`, biome printing `Found` with errors, check-cert-messages printing `key mismatch`, `too few keys`, `title missing` or `bad text`, the todo still under pending, the seed line absent from the api log, e2e-cert.sh printing `E2E FAIL:`, or fewer than eight dark or four light screenshots</fails_when>
|
||||||
|
<human-check>After the user's own pull on alpha (the user deploys, not Claude): upload a real vendor ZIP in „Dateien“ and download the Fullchain; add a certificate in Nginx Proxy Manager with the „Nginx Proxy Manager“ template (research A2: field names and the RSA PRIVATE KEY header are assumptions to confirm there); import the „Windows / IIS“ PFX on a Windows server (compatible profile) and, if wanted, the „Modern“ PFX on a current Windows to see which systems accept it (research A1); check that „Fehlendes Zertifikat holen“ reaches the issuer from the alpha server (outbound http allowed).</human-check>
|
||||||
|
</verify>
|
||||||
|
<done>Full api and web suites, both tsc, biome and every e2e section are green on the rebuilt stack; eight dark and four light screenshots prove the flow (two selections kept, ZIP and PFX, Fullchain without root, template, gap and fetch, empty after reload) and were reviewed; guide labels match the page; todo moved; one commit on main, not pushed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| browser → API (`/modules/cert-manager/analyze`, `build`, `fetch-issuer`) | untrusted caller with a valid session; files, ZIPs, PEM text, passwords, chosen formats and every PEM sent back are untrusted |
|
||||||
|
| uploaded containers → parsers | ZIP, ASN.1, PKCS#7, PKCS#12, keys and CSRs from unknown vendors or attackers; parsers run in the API process |
|
||||||
|
| API → internet (AIA caIssuers fetch) | outbound GET to an address named inside an uploaded certificate; answer untrusted |
|
||||||
|
| API → browser | analysis answers carry unencrypted private keys of the user's own upload (needed for stateless build) |
|
||||||
|
| repository fixtures | committed test-only private keys |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-ikt-01 | Denial of Service | ZIP expansion (zip bomb, many entries, nesting) | high | mitigate | D-17: magic-byte detection, ≤ 100 entries, ≤ 1 MiB declared per entry, ratio ≤ 100, total ≤ 20 MiB checked before any inflate, one level only, encrypted entries skipped; adm-zip bounds inflation to the declared size; specs with injected limits |
|
||||||
|
| T-ikt-02 | Denial of Service | multipart upload size and build body | medium | mitigate | 30 files × 5 MiB (multer), total ≤ 20 MiB → 413 `tooLarge`, web refuses over 10 MiB before upload; `build` has its own JSON limit of 512 KiB just above the DTO maximum (≈ 384 kB) with a coded 413 beyond (D-26), every other route keeps 100 kB; e2e checks 380 kB → 400 with code, over 600 KiB → 413 tooLarge, login with 150 kB → 413 |
|
||||||
|
| T-ikt-17 | Denial of Service | build body parser registration in `main.ts` | medium | mitigate | the wrapper is named `certBuildJsonBody`, never `jsonParser`, because Nest would otherwise skip its global JSON parser for every route (D-26); the spec asserts the name, the JSON login in every e2e setup proves the global parser still runs |
|
||||||
|
| T-ikt-03 | Denial of Service | malformed ASN.1 / PEM / PKCS#12 | medium | mitigate | every detector in try/catch, bad blobs become `ignored`, never a 500; specs with random, truncated and broken input; PKCS#12 only for a SEQUENCE starting with INTEGER 3; at most 10 distinct passwords tried |
|
||||||
|
| T-ikt-04 | Tampering / SSRF | AIA fetch | high | mitigate | D-22: URL derived on the server from the uploaded certificate (DTO accepts only `pem`), http/https, default ports, no credentials, `isPublicHttpUrl` before the first request and every redirect (max 3), guarded connect lookup against DNS rebinding, 8 s, 256 KiB, no cookies or auth headers; specs per case; e2e proves 127.0.0.1 and extra `url` field are refused |
|
||||||
|
| T-ikt-05 | Tampering / SSRF | shared guard bypass via IPv6 spellings | high | mitigate | D-10 hardening with full IPv6 expansion (hex IPv4-mapped, IPv4-compatible, NAT64 incl. local-use, 6to4, Teredo, link/site-local, documentation, discard, zone ids) and a new spec; favorites and nextcloud-status specs re-run |
|
||||||
|
| T-ikt-06 | Spoofing | fetched certificate injected as issuer | medium | mitigate | only certificates with `checkIssued` AND `verify` against the incomplete certificate are returned; entry marked „nachgeladen von {host}“ |
|
||||||
|
| T-ikt-07 | Spoofing | wrong chain order or decoy CA from the browser | medium | mitigate | `build` re-runs `buildChains` on every call; a same-name CA with another key fails the signature check (spec with the decoy fixture); certificates outside the primary chain are dropped |
|
||||||
|
| T-ikt-08 | Information Disclosure | private keys and passwords | high | mitigate | D-11: nothing persisted, working set only in browser memory, passwords only in multipart/JSON bodies (never URLs), no logger call with bodies (request-log logs path and status only), errors carry codes only; e2e greps the api log for passwords, `PRIVATE KEY` and `BEGIN CERTIFICATE` |
|
||||||
|
| T-ikt-09 | Information Disclosure | AIA failure logging | low | mitigate | one warn line with host and code only, never PEM or URL path; spec asserts it |
|
||||||
|
| T-ikt-10 | Tampering | scoped forge patch in PFX writing | medium | mitigate | synchronous single call, originals restored in `finally`; spec asserts restoration after success and after an injected throw |
|
||||||
|
| T-ikt-11 | Elevation of Privilege | route access | medium | mitigate | class `@UseModule('cert-manager')` plus global JwtAuthGuard/TenantGuard as before; controller spec checks class metadata and the exact handler list; old routes removed (e2e: parse → 404) |
|
||||||
|
| T-ikt-12 | Tampering | file and friendly names from uploads | low | mitigate | names used for display only, never written to disk, control characters removed, max 255; download names and PFX friendlyName through `safeBaseName` |
|
||||||
|
| T-ikt-13 | Tampering (XSS) | subject strings, SANs, snippets rendered in the browser | low | mitigate | React text only, no `dangerouslySetInnerHTML`; snippet in a `<pre>` as text; clipboard gets plain text |
|
||||||
|
| T-ikt-14 | Denial of Service / Repudiation | AIA as a blind request trigger | low | accept | authenticated module users only, one GET per click to a public address on 80/443, answer only returned when it is a verified issuer certificate; noted in the header comment |
|
||||||
|
| T-ikt-15 | Information Disclosure | weak PFX encryption (3DES default) | low | accept | needed for older Windows servers (user decision D-07), „Modern (AES-256)“ selectable, guide explains the choice |
|
||||||
|
| T-ikt-16 | Information Disclosure | committed test private keys | low | accept | test-only PKI generated for this repo, CA keys never committed, README marks the folder for a future secret-scanner allow-list |
|
||||||
|
| T-ikt-SC | Tampering | npm/pip/cargo installs | low | accept | no new packages (research audit: all libraries already installed; adm-zip flagged SUS only for a recent release date and stays unchanged, no install) |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- Each task's `<automated>` chain passes and its commit exists. Every chain ends with `e2e-cert.sh all`, so each task re-proves every earlier live section on the rebuilt stack: Task 1 the working set, Task 2 Fullchain and the body limit, Task 3 ZIP and PKCS#7, Task 4 keys, PFX and CSR inputs, Task 5 every output with openssl round trips, Task 6 templates and the version, Task 7 the live AIA fetch; Task 8 reruns the full suites and adds the browser proof.
|
||||||
|
- After every task the committed state is usable on its own: the page shows only the tabs built so far and no web code calls a route that does not exist (the old routes and the old web components leave together in Task 1).
|
||||||
|
- Multi-source coverage audit:
|
||||||
|
|
||||||
|
| Source item | Covered by |
|
||||||
|
|-------------|------------|
|
||||||
|
| GOAL / todo 1: merging accepts only one file, second overwrites first | Task 1 (append-only working set, FilesTab regression test), Task 8 (browser proof with two selections) |
|
||||||
|
| GOAL / todo 2: vendor ZIP unpacked and analysed | Task 3 (zip-expand, analyze, e2e), Task 4 (PFX and keys inside ZIPs) |
|
||||||
|
| GOAL / todo 3: choose output such as Fullchain, Tessera orders leaf, intermediates, root optional | Task 2 (chain + fullchain/chain), Task 5 (bundle, PFX, formats) |
|
||||||
|
| D-01 one upload tab, shared set, remove buttons, recognised content, other tabs without upload | Task 1 (FilesTab, page), Task 2 (EmptyWorkspace), Tasks 3–6 (remaining tabs on the same set) |
|
||||||
|
| D-02 root selectable, default without | Task 2 (MergeTab, build includeRoot), Task 5 (bundle/PFX), Task 6 (templates) |
|
||||||
|
| D-03 gap message + button-only SSRF-safe AIA fetch, marked nachgeladen | Task 2 (gap kinds and texts), Task 7 (cert-aia, ChainView button, marker, e2e live) |
|
||||||
|
| D-04 templates Nginx, Apache, IIS, NPM + HAProxy, Tomcat; no JKS; free choice stays | Task 6 (cert-templates, TemplatesTab), Task 5 (Konvertieren, Zusammenführen) |
|
||||||
|
| D-05 all formats in and out, RSA + EC, key match, chain via issuer + key ids | Task 1 (certificates PEM/DER), Task 2 (chain), Task 3 (PKCS#7), Task 4 (keys, PKCS#12, CSR, matching), Task 5 (outputs) |
|
||||||
|
| D-06 bug fixed structurally, analysis behaviour kept | Task 1 (working set), Task 3 (Analysieren from the old Übersicht), Task 4 (calm locked-PFX note) |
|
||||||
|
| D-07 PFX compat default / modern option, IIS compat | Task 5 (writePkcs12, PfxOptions, e2e algorithms), Task 6 (IIS/Tomcat templates) |
|
||||||
|
| D-08 old routes, service, specs, DTOs removed; export replaced by build; one parser | Task 1 (deletions, controller spec, grep gates), Tasks 2–7 (forge grep gate in every chain) |
|
||||||
|
| D-09 version 1.2.0 new entry 2026-10-09, CHANGELOG Unveröffentlicht | Task 6 (entry, bullets, version e2e), Task 7 (AIA item) |
|
||||||
|
| D-10 guard hardening with tests + CHANGELOG security line | Task 7 |
|
||||||
|
| D-11 keys/passwords never stored or logged, browser memory only, said in UI and guide | Tasks 1 and 4 (state, note, log greps), Task 5 (log grep), Task 6 (guide), Task 8 (reload proof) |
|
||||||
|
| D-12 docs Anwender, Betrieb, Entwicklung; de + en Sie texts | Tasks 6–7 (guides), Tasks 1–7 (messages, check-cert-messages gate) |
|
||||||
|
| D-13 fixtures, openssl round trips, browser proof | Task 1 (fixtures), Tasks 1–7 (e2e sections), Task 5 (openssl round trips), Task 8 (screenshots) |
|
||||||
|
| D-26 build body limit (checker info item: DTO caps vs. Express 100 kB) | Task 2 (cert-json-body + spec incl. maximal-body arithmetic, main.ts, e2e 380 kB / 600 KiB / login 150 kB), Task 6 (Betriebs- and Entwicklungsanleitung) |
|
||||||
|
| CONTEXT discretion: working-set location, ZIP limits, key-match display, CSR display, file names, server crypto, tab names | D-14, D-15, D-17, D-19, D-23, D-26 in Tasks 1–5 |
|
||||||
|
| CONTEXT specifics: chain via issuer/subject + AKI/SKI, gap messages, key check, keep analysis, version rule | D-18 (Tasks 2 and 4), D-09 (Task 6) |
|
||||||
|
| RESEARCH: forge cannot read EC certs/keys/CSR, PKCS#7 with EC fails | D-08, D-16 (node:crypto, ASN.1 walk), Tasks 1, 3, 4 |
|
||||||
|
| RESEARCH: bug root cause (shared single-file DropZone) | Task 1 (DropZone removed, page rebuilt) |
|
||||||
|
| RESEARCH: name-only chain walk wrong → checkIssued + verify, decoy and cross-signed | Task 2 (cert-chain spec) |
|
||||||
|
| RESEARCH: PFX EC bags (bag.cert null, bag.key false), OpenSSL-3 PFX readable, sniff by content | Task 4 (readPkcs12) |
|
||||||
|
| RESEARCH Pattern 3: scoped forge patch for EC PFX | D-20, Task 5 (spec incl. restoration) |
|
||||||
|
| RESEARCH: key lock detection and traditional encryption | Task 4 (cert-keys), Task 5 (exportKey) |
|
||||||
|
| RESEARCH: ZIP limits, magic bytes, junk, nested, encrypted | D-17, Task 3 |
|
||||||
|
| RESEARCH: AIA pattern from nextcloud-logo-fetch, server-derived URL, issuer verification, ports 80/443 (A8), guarded lookup | D-22, Task 7 |
|
||||||
|
| RESEARCH: shared guard IPv6 weaknesses | D-10, Task 7 |
|
||||||
|
| RESEARCH: templates table incl. NPM PKCS#1/SEC1 key (A2), HAProxy order (A3), Tomcat PKCS#12 (A4), Apache 2.4.8 split | D-21, Task 6, human check |
|
||||||
|
| RESEARCH: JSON 100 kB limit, NPM upload limit, outbound egress | D-26 (Task 2), Task 6 (Betriebsanleitung upload), Task 7 (Betriebsanleitung egress) |
|
||||||
|
| RESEARCH: per-file passwords pitfall | D-20, Task 4 |
|
||||||
|
| RESEARCH: biome array keys and nested buttons, umlaut guard, de/en parity, hard-coded German strings | Tasks 1–7 (messages via t(), check-cert-messages gate) |
|
||||||
|
| RESEARCH open questions 1–3 (AES option, old endpoints, version) | decided by orchestrator: D-07, D-08, D-09 |
|
||||||
|
| RESEARCH A1 (forge AES PFX on Windows), A2 (NPM fields) | human check after the user's pull (Task 8) |
|
||||||
|
| Deferred / out of scope: JKS (native tools), licensing and multi-tenancy topics | not planned |
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- A user collects all files of a certificate delivery — several single files, a vendor ZIP, pasted text — in „Dateien“ without ever losing an earlier file, sees what each contains, and every other tab works on that list.
|
||||||
|
- Fullchain, Nur Kette, single certificate, certificate plus key, PFX (Kompatibel default, Modern optional), every key and CSR format and seven templates come out in the right order for RSA and EC, root only when ticked; openssl accepts every output in the e2e.
|
||||||
|
- A missing issuer is named clearly; „Fehlendes Zertifikat holen“ fetches it only on click through the hardened guard, accepts only the real issuer and marks it nachgeladen — proven live.
|
||||||
|
- No private key or password is stored or logged; the old routes, the old service and forge certificate parsing are gone; one parser remains.
|
||||||
|
- Module version 1.2.0 with module changelog, CHANGELOG (incl. the security fix), Anwender-, Betriebs- and Entwicklungsanleitung updated.
|
||||||
|
- A build request within the DTO caps is never cut off by the body limit; a larger one gets 413 with code tooLarge; every other route keeps 100 kB.
|
||||||
|
- After each of the eight tasks the gates of that task are green and the module is usable; at the end full api and web suites, both tsc runs, biome, all e2e sections green on the rebuilt stack; eight dark and four light screenshots reviewed; eight commits on main, nothing pushed, nothing deployed.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
`.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/261009-ikt-SUMMARY.md` is written piece by piece (never committed by an executor): Task 1 creates it, every task appends „## Task N“ with its measured gate table, deviations and threat status. Assigned items: (1) Task 1 — the fixture list actually generated and the openssl version used; (2) Task 5 — the openssl outputs of the formats e2e that prove the PFX algorithms (compat 3DES, modern AES-256) and the P7B/P7C order; (3) Task 7 — the live AIA result (host, fetched CN, what the next level showed) or, if `CERT_E2E_OFFLINE=1` had to be used, why; (4) Task 8 — confirmation that the old routes return 404 and no forge certificate parser remains; (5) Task 8 — the screenshot list with paths and the findings of the design review; (6) Task 8 — a checklist for the user's real environment: real vendor ZIP, NPM custom certificate with the template, IIS import of the compatible PFX, optional test of the modern PFX on a current Windows, outbound http from alpha for the fetch button — deployment and pull stay with the user.
|
||||||
|
</output>
|
||||||
+340
@@ -0,0 +1,340 @@
|
|||||||
|
# Quick 261009-ikt: Cert Manager Umbau - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-10-09
|
||||||
|
**Domain:** X.509 / PKCS container handling in NestJS 11 (Node 24) + Next.js 15 working-set UI
|
||||||
|
**Confidence:** HIGH for code findings and library capabilities (all probed on this machine, Node v24.16.0, OpenSSL 3.5.7); MEDIUM for target-system templates (docs/forum based)
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
### Locked Decisions
|
||||||
|
- ONE upload tab (first tab): drop/select multiple files and/or ZIPs (and may paste PEM text). Everything uploaded forms a shared working set (list of files with remove buttons and what was recognised in each). The other tabs (Analysieren, Aufteilen, Zusammenführen/Fullchain, Konvertieren, Vorlagen) work on that shared set instead of having their own upload fields.
|
||||||
|
- Root certificate in Fullchain: selectable, default WITHOUT root ("Root-Zertifikat mitnehmen" checkbox).
|
||||||
|
- Missing intermediate: Tessera reports the gap clearly ("Zwischenzertifikat fehlt") and offers a button "Fehlendes Zertifikat holen" which fetches it from the certificate's AIA caIssuers URL - ONLY on button press, never automatically. Fetch must be SSRF-safe (http/https only, public addresses only - reuse the project's existing `isPublicHttpUrl`/SSRF guard pattern, size and time limits, no redirects to private targets) and the result marked as "nachgeladen".
|
||||||
|
- Templates for target systems: one-click templates, e.g. Nginx, Apache, Windows/IIS (PFX), Nginx Proxy Manager (and other common ones at Claude's discretion, e.g. HAProxy combined PEM, Java keystore only if feasible without native tools - otherwise skip). Free selection of content + format remains available.
|
||||||
|
- All common formats as input AND output: PEM/CRT/CER, DER, PKCS#7 (.p7b/.p7c), PKCS#12 (.pfx/.p12 with password), private keys (PKCS#1, PKCS#8, encrypted/unencrypted, RSA + EC), CSR; outputs: Fullchain, chain only (intermediates), single certificate, certificate + key (PEM bundle), PFX with chosen password. Module version + module changelog + guides are part of the task.
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Where the working set lives (prefer browser memory only, never persisted server-side; private keys and passwords never stored or logged); ZIP limits (size, file count, nesting, zip-bomb ratio); key-to-certificate matching display; how CSRs are shown; file naming of downloads; whether the server or browser does the crypto (follow the existing module architecture); exact tab names.
|
||||||
|
|
||||||
|
### Deferred Ideas (OUT OF SCOPE)
|
||||||
|
- None listed. (Memory rules apply: no licensing/multi-tenant talk, no password-leak warnings, AD read-only, user-facing texts formal "Sie", answers to user in German with "du".)
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
## Project Constraints (from CLAUDE.md)
|
||||||
|
- Stack is fixed: NestJS 11 / Express 5 / Prisma 6, Next.js 15.5 / React 19, Vitest (api 3.2.6, web 4.1.9), Biome 2.5 (`biome lint .`). No Redis, no TanStack Query, no shadcn - do not introduce them.
|
||||||
|
- Work only through a GSD command (this is /gsd-quick). Everything built by Claude -> keep code maintainable and plain.
|
||||||
|
- Memory rules: module change = bump module version in changelog + entry (feedback-modulversion-changelog); user-facing app text uses "Sie"; real umlauts in de.json; no customer-specific defaults; no password-leak warnings.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The module is **node-forge 1.4.0 server-side** (`apps/api/src/cert-manager/`), stateless (upload -> process -> base64 JSON back). `cert-bundle.ts` (analyze/export, added 2026-10-02) already does multi-file + ZIP (adm-zip) and is what the "Übersicht" tab uses; the four older tabs (Analysieren/Aufteilen/Zusammenführen/Konvertieren) use the older single-file endpoints in `cert-manager.service.ts`. **The biggest finding is not the UI bug but that node-forge cannot read ANY EC certificate** (`Cannot read public key. OID is not RSA.` - probed). So today every ECDSA certificate (Let's Encrypt default, most modern CAs) fails to parse in `parse/split/merge/convert`, is silently dropped from `analyze`, and EC leaf certs inside a PFX are silently dropped (bag.cert is `null`). "Analyse funktioniert richtig" is true for RSA only.
|
||||||
|
|
||||||
|
**Bug root cause (2nd file overwrites 1st):** `page.tsx:124` renders the shared single-file `DropZone` card for every tab except `overview` - including `merge`. `DropZone.tsx:23/43` reads only `files?.[0]` and `page.tsx:54-55` does `setFile(selected)` (replace). `MergeTab` ignores that shared `file` completely and has its own native `<input multiple>` (which does append, `MergeTab.tsx` `setFiles(prev => [...prev, ...selected])`). The user used the prominent drop zone -> each drop replaced the previous one and nothing reached the merge. The redesign (one shared multi-file set) removes the whole class of bug.
|
||||||
|
|
||||||
|
**Primary recommendation:** Replace forge for X.509/keys with Node's built-in `node:crypto` (`X509Certificate`, `createPrivateKey`, `KeyObject.export`, `checkIssued`, `verify`, `checkPrivateKey`) - handles RSA+EC, PEM+DER, encrypted keys, chain-verification natively. Keep node-forge only for what Node cannot do: PKCS#12 read/write, PKCS#7 read/write, and (via forge's generic `asn1`) CSR/AKI/SKI walking. Add NO new npm package. One stateless `analyze` over the whole working set, one `build` for outputs/templates, one `fetch-issuer` for AIA. Working set = `File[]` in React state in the browser; server re-receives it per analyze call (this is how `OverviewTab` already works).
|
||||||
|
|
||||||
|
## Architectural Responsibility Map
|
||||||
|
|
||||||
|
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||||
|
|------------|-------------|----------------|-----------|
|
||||||
|
| Working set (files list, passwords, selection) | Browser / Client (React state) | - | Never persisted; private keys/passwords must not be stored (CONTEXT) |
|
||||||
|
| Parse/identify/chain/key-match | API / Backend (stateless) | - | Existing architecture; Node crypto + forge exist only server-side (web has no crypto lib except fflate) |
|
||||||
|
| ZIP expansion (input) | API / Backend | - | Existing; limits enforced server-side (adm-zip) |
|
||||||
|
| Output building (fullchain, PFX, P7B, key re-encoding) | API / Backend | - | Needs forge PKCS#12 / node KeyObject export |
|
||||||
|
| Template bundles as ZIP + config snippet text | Browser (fflate `zipSync`, already used in `SplitTab.tsx`) | API builds each file | Keeps API returning single files; ZIP is cheap client-side |
|
||||||
|
| AIA caIssuers fetch | API / Backend | - | SSRF-guarded; URL is read from the cert **on the server**, never taken from the client |
|
||||||
|
| Downloads | Browser (`downloadBase64`) | - | Existing helper |
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core (all already installed - no new packages)
|
||||||
|
| Library | Version | Purpose | Why |
|
||||||
|
|---------|---------|---------|-----|
|
||||||
|
| `node:crypto` (Node 24 `node:24-alpine`) | 24.16.0 here | X509Certificate parse/DER/PEM, `checkIssued`, `verify`, `checkPrivateKey`, `createPrivateKey` (PKCS#1/PKCS#8/SEC1, enc/unenc, PEM/DER, RSA+EC), `KeyObject.export` (pkcs1/pkcs8/sec1, optional cipher+passphrase), `infoAccess` (AIA) | [VERIFIED: probe scripts this session, see "Probe results"] |
|
||||||
|
| `node-forge` | 1.4.0 (`apps/api/package.json:41` `"node-forge": "^1.4.0"`) | PKCS#12 read (incl. OpenSSL-3 AES/SHA-256 PFX) and write, PKCS#7 read/write, generic `asn1` for CSR/extension walking | Only lockfile option for PKCS#12/#7 [VERIFIED: pnpm-lock `node-forge@1.4.0`] |
|
||||||
|
| `adm-zip` | 0.6.0 (`apps/api/package.json:28`) | ZIP read | Already used; its inflater passes `maxOutputLength: expectedLength` (header size) so a lying header cannot out-inflate its declared size, and it CRC-checks [VERIFIED: `node_modules/.pnpm/adm-zip@0.6.0/.../methods/inflater.js:4-5`, `zipEntry.js:31-48`] |
|
||||||
|
| `undici` | 7.28.0 (`apps/api/package.json:52`) | AIA HTTP fetch (same as favorites / nextcloud-status) | Existing SSRF fetch pattern |
|
||||||
|
| `fflate` | ^0.8.3 (web) | ZIP creation for template download in browser | Already used (`SplitTab.tsx:5`) |
|
||||||
|
| multer 2.1.1 (via `@nestjs/platform-express`) | 2.1.1 | multipart limits (`fileSize`, `files`) | Existing |
|
||||||
|
|
||||||
|
### Alternatives Considered
|
||||||
|
| Instead of | Could Use | Tradeoff |
|
||||||
|
|------------|-----------|----------|
|
||||||
|
| node:crypto + forge asn1 helpers | `@peculiar/x509` (npm legitimacy: OK, 13.8M wk, repo PeculiarVentures/x509) | Gives AKI/SKI/CSR classes and a chain builder, but is NOT in the lockfile (needs install + reflect-metadata/tsyringe/pvtsutils chain) and still has no PKCS#12. Not worth it: Node covers cert+key+chain; only CSR display + AKI/SKI need ~60 lines of forge-asn1 walking. Use only if CSR parsing proves painful (then gate behind `checkpoint:human-verify`). |
|
||||||
|
| Monkeypatch forge to write EC PFX | write own PFX assembler | See Pattern 3; patch approach probed working, assembler is 100+ lines |
|
||||||
|
|
||||||
|
**Installation:** none.
|
||||||
|
|
||||||
|
## Package Legitimacy Audit
|
||||||
|
|
||||||
|
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|
||||||
|
|---------|----------|-----|-----------|-------------|---------|-------------|
|
||||||
|
| node-forge | npm | yrs | ~39.7M/wk | github.com/digitalbazaar/forge | OK | Already installed (1.4.0) |
|
||||||
|
| adm-zip | npm | yrs | ~23M/wk | github.com/cthackers/adm-zip | SUS (`too-new`: latest version published 2026-09-11) | Already installed (0.6.0) and in production use; no change, no install - no checkpoint needed |
|
||||||
|
| @peculiar/x509 | npm | yrs | ~13.8M/wk | github.com/PeculiarVentures/x509 | OK | NOT recommended / not installed (alternative only) |
|
||||||
|
|
||||||
|
**Packages removed (SLOP):** none. **Flagged SUS:** adm-zip (already present; not a new install).
|
||||||
|
|
||||||
|
## Current Architecture (facts)
|
||||||
|
|
||||||
|
| Item | Finding |
|
||||||
|
|------|---------|
|
||||||
|
| Crypto lib | node-forge 1.4.0, server only. `cert-bundle.ts` (655 lines) + `cert-manager.service.ts` (793 lines). Web has no crypto lib. |
|
||||||
|
| Endpoints (`cert-manager.controller.ts`) | `parse` (1 file/pemText), `split` (1 file), `merge` (`FilesInterceptor('files',20)`, >=2 files, out pem/pfx), `convert` (1 file/pemText -> pem/der/p7b/pfx, first cert only), `analyze` (20 files x 5 MB, optional ZIP, one password for all), `export` (JSON: kind, pem, format, chain[], keyPem, password) |
|
||||||
|
| Web | `page.tsx` tab shell (overview, inspect, split, merge, convert); shared single-file `DropZone` + paste textarea + `PasswordField` for every tab except overview; `OverviewTab.tsx` is the only multi-file UI (re-uploads the whole `File[]` to `analyze` on every add/remove). Calls go to `API_URL` = `/api-proxy` in prod (`apps/web/Dockerfile:28`, rewrite in `next.config.ts:40`). |
|
||||||
|
| Tests | api: `cert-bundle.spec.ts` (268 lines), `cert-manager.service.spec.ts` (778); web: `cert-manager.test.tsx`, `MergeTab.test.tsx`, `OverviewTab.test.tsx`, `zip-filename.test.ts`. **No EC test anywhere** (grep). |
|
||||||
|
|
||||||
|
### Format coverage vs required matrix (today)
|
||||||
|
|
||||||
|
| Input format | Today | Gap (probed unless noted) |
|
||||||
|
|---|---|---|
|
||||||
|
| Cert PEM/CRT/CER (RSA) | yes | - |
|
||||||
|
| Cert PEM/DER (EC) | **NO** | forge throws "OID is not RSA"; `parse/split/merge/convert` 400, `analyze` ignores file |
|
||||||
|
| Cert DER (`.der`/`.cer`) RSA | yes (content sniff) | - |
|
||||||
|
| PKCS#7 PEM/DER | yes (RSA only) | one EC cert inside -> whole file fails |
|
||||||
|
| PKCS#12 | RSA key + RSA certs; ext-sniff only (`.pfx/.p12`) | EC key: `bag.key` false -> **silently skipped**; EC cert: `bag.cert === null` -> **silently skipped** (`collectPfx` `if (bag.cert)`); OpenSSL-3 AES/SHA-256 PFX **is readable** by forge (probed) |
|
||||||
|
| Key PKCS#8 RSA, PKCS#1 RSA | yes | - |
|
||||||
|
| Key PKCS#8 EC / SEC1 EC | kept as raw PEM, `modulus:''` | never matched to a certificate; no key details |
|
||||||
|
| Key encrypted PKCS#8 | RSA only (`decryptRsaPrivateKey`) | EC encrypted: reported "locked" even with right password |
|
||||||
|
| Key traditional-encrypted (`Proc-Type: 4,ENCRYPTED`) | **dropped** to "ignored" | forge throws; catch only handles `PRIVATE KEY` |
|
||||||
|
| Key DER (pkcs1/pkcs8/sec1) | **not detected** | `collectDer` tries cert/p7/csr only |
|
||||||
|
| CSR PEM RSA | yes | - |
|
||||||
|
| CSR EC (PEM/DER) | PEM: item without details; DER: ignored | forge `certificationRequestFromAsn1` needs RSA |
|
||||||
|
| ZIP | yes (adm-zip, 100 entries, 5 MB/entry header size) | no total budget, no ratio check, no nested/encrypted handling, `.zip` ext only |
|
||||||
|
|
||||||
|
| Output format | Today | Gap |
|
||||||
|
|---|---|---|
|
||||||
|
| Cert PEM (.crt), DER (.cer) | yes (RSA) | EC fails |
|
||||||
|
| Fullchain | yes, always **includes root** | no root toggle; chain order = name-hash walk |
|
||||||
|
| Chain only (intermediates) | **no** | add |
|
||||||
|
| P7B | PEM-wrapped only (forge `createSignedData`, probed readable by `openssl pkcs7`) | add DER `.p7b`/`.p7c`; with/without root |
|
||||||
|
| PFX | RSA key + 3DES only; cert-only PFX allowed | EC key; AES profile; password check |
|
||||||
|
| Cert + key PEM bundle | **no** | add (also HAProxy/NPM variants) |
|
||||||
|
| Key out | RSA: PKCS#8, PKCS#1, DER; EC: PEM as-is only | EC SEC1/PKCS#8/DER; encrypted PKCS#8 (AES-256) + traditional-encrypted |
|
||||||
|
| CSR out | PEM, DER | fine (no re-encode needed) |
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### System flow
|
||||||
|
```
|
||||||
|
Browser: working set File[] (+ pasted PEM as File, + fetched certs as items)
|
||||||
|
| POST /analyze (multipart, all files + passwords) POST /fetch-issuer (JSON {pem})
|
||||||
|
v |
|
||||||
|
API analyze: expandZips(limits) -> detect each blob -> items v server reads AIA from cert, SSRF-guarded GET,
|
||||||
|
(cert via X509Certificate | key via createPrivateKey | csr via parse DER/P7C/PEM, verify it really issued the cert
|
||||||
|
asn1 | pfx/p7 via forge) -> dedupe by sha256 -> buildChains -> -> returns items marked source "nachgeladen"
|
||||||
|
matchKeys -> {items, chains, gaps, locked, ignored}
|
||||||
|
v
|
||||||
|
Browser: tabs render the same result (Analysieren=list, Aufteilen=per-item downloads,
|
||||||
|
Fullchain/Zusammenführen=choose leaf + root toggle + format, Konvertieren=any item -> any format,
|
||||||
|
Vorlagen=one-click bundle) --POST /build (JSON, only needed PEMs)--> API builds file(s) -> base64 -> downloadBase64 / fflate ZIP
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended structure (api)
|
||||||
|
```
|
||||||
|
apps/api/src/cert-manager/
|
||||||
|
cert-model.ts # parse blobs -> RawCert/RawKey/RawCsr (node:crypto + forge containers)
|
||||||
|
cert-chain.ts # buildChains(), matchKeys() (pure functions, easy to unit-test)
|
||||||
|
zip-expand.ts # limits + expansion
|
||||||
|
cert-aia.ts # fetchIssuer() with SSRF guard
|
||||||
|
cert-templates.ts # template id -> file list builder
|
||||||
|
cert-bundle.ts # becomes thin analyze/build facade (keep exported names for existing specs)
|
||||||
|
```
|
||||||
|
Old single-file endpoints/service (parse/split/merge/convert) become unused once the tabs run on the working set; remove with their specs in the same plan (planner decision) - do not leave two parallel parsers.
|
||||||
|
|
||||||
|
### Pattern 1: Detect blob type (replaces `collect()`)
|
||||||
|
Order: ZIP magic `PK\x03\x04` (not extension) -> text with `-----BEGIN` -> PEM blocks by type -> otherwise DER: try `new X509Certificate(buf)`; try `createPrivateKey({key, format:'der', type})` for `pkcs8`, `pkcs1`, `sec1`; try forge `pkcs12FromAsn1` (needs password path); try forge `pkcs7.messageFromAsn1`; try CSR walk. PEM types: `CERTIFICATE`, `TRUSTED CERTIFICATE`, `PKCS7`, `CMS`, `PRIVATE KEY`, `RSA PRIVATE KEY`, `EC PRIVATE KEY`, `ENCRYPTED PRIVATE KEY`, `CERTIFICATE REQUEST`, `NEW CERTIFICATE REQUEST`. Also treat `Proc-Type: 4,ENCRYPTED` PEM as locked. [VERIFIED by reading `cert-bundle.ts:131` `PEM_BLOCK` and `collectPemText`]
|
||||||
|
|
||||||
|
Key lock detection (probed): missing passphrase on PEM -> `err.code === 'ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED'`; wrong passphrase -> `'ERR_OSSL_BAD_DECRYPT'`; DER without passphrase -> `'ERR_MISSING_PASSPHRASE'`. Report as locked (existing UI `LockedNotice` pattern). `PBE-SHA1-3DES` PKCS#8 and AES-256 PKCS#8 decrypt fine in Node 24; RC2-based legacy PBE was not probed (assume unsupported without OpenSSL legacy provider -> report as "locked/unreadable").
|
||||||
|
|
||||||
|
PFX (probed): forge reads OpenSSL-3 default (AES-256 + SHA-256 MAC) and `-legacy` PFX. For EC items inside: cert DER = `bag.asn1 ? forge.asn1.toDer(bag.asn1) : certificateToAsn1(bag.cert)` (EC cert bag: `bag.cert===null` but `bag.asn1` holds the cert; RSA cert bag: `bag.asn1` undefined); key = `createPrivateKey({key: toDer(bag.asn1), format:'der', type:'pkcs8'})` for shrouded EC key bag (probed OK). Sniff PFX by trying `pkcs12FromAsn1` on any DER that is a top-level SEQUENCE with INTEGER 3, not only by extension.
|
||||||
|
|
||||||
|
### Pattern 2: Chain building + key matching (pure, no network)
|
||||||
|
- Candidate issuers of cert C: every other cert I in the set with `C.checkIssued(I)` (OpenSSL X509_check_issued: subject==issuer DN **and** AKI/SKI/serial match when present, keyUsage) **AND** `C.verify(I.publicKey)` (signature). Both are needed: without AKI on the cert, a same-name different-key CA passes `checkIssued` but fails `verify` (probed with two "Test Inter" CAs: `checkIssued false` when AKI present; keep `verify` as the authority).
|
||||||
|
- Self-signed/root: `C.checkIssued(C) && C.verify(C.publicKey)` (probed true for root). Role: leaf = not CA; root = CA + self-signed; else intermediate. A self-signed non-CA stays "end-entity" (existing `certRole` behaviour).
|
||||||
|
- Path for a leaf: DFS over verified issuer candidates, depth <= 10, visited-set (cross-signing/cycles), prefer candidates that lead to a self-signed root present in the set, then currently valid, then latest `notAfter`. If several full paths exist (cross-signed roots) expose the primary and list alternatives; default output uses the primary.
|
||||||
|
- Gap: path ends at a non-self-signed cert whose issuer is absent -> `gap: { certId, missingIssuerCn, aiaUrls }` -> UI "Zwischenzertifikat fehlt" + "Fehlendes Zertifikat holen" (only if AIA exists).
|
||||||
|
- Existing `cert-bundle.ts` chain walk matches by `subject.hash === issuer.hash` only (name, no AKI, no signature) - wrong with re-issued/cross-signed same-name CAs.
|
||||||
|
- Key match: `cert.checkPrivateKey(keyObj)` (probed true, RSA+EC). For key/CSR -> cert: compare SPKI DER (`createPublicKey(key).export({type:'spki',format:'der'})` equals `cert.publicKey.export(...)`; probed true). Replaces modulus-string match that cannot work for EC.
|
||||||
|
- Display data from Node: `x.toLegacyObject().subject` ({CN,O,...}), `.infoAccess`, `x.subjectAltName`, `x.validFromDate/validToDate`, `x.publicKey.asymmetricKeyType/asymmetricKeyDetails` (`modulusLength` | `namedCurve`), `x.fingerprint256`, `x.ca`. Node 24 does **not** expose AKI/SKI (probe printed `ski=undefined`) - only needed for display; get via forge `asn1.fromDer(x.raw)` extension walk if wanted, not for logic.
|
||||||
|
- CSR (no lib supports EC CSR): `forge.asn1.fromDer` the DER, walk `CertificationRequestInfo` -> subject (`forge.pki.RDNAttributesAsArray`), SPKI -> `createPublicKey({key: spkiDer, format:'der', type:'spki'})`, optional SAN from the `extensionRequest` attribute, optional self-signature check with `crypto.verify`. forge's own CSR parser works for RSA only (probed).
|
||||||
|
|
||||||
|
### Pattern 3: PFX writing with EC keys/certs (forge cannot natively)
|
||||||
|
`forge.pkcs12.toPkcs12Asn1(key, certs, pw, opts)` internally calls `pki.privateKeyToAsn1`, `pki.wrapRsaPrivateKey`, `pki.certificateToAsn1` (RSA-only objects). Probed workaround (scoped, synchronous, restored in `finally`) that produced PFX files `openssl pkcs12 -info` accepts with RSA and EC:
|
||||||
|
```ts
|
||||||
|
// Source: probe in this session (forge 1.4.0, openssl 3.5.7 read both outputs)
|
||||||
|
import * as forge from 'node-forge';
|
||||||
|
const pki = forge.pki;
|
||||||
|
export function buildPfx(keyPkcs8Der: Buffer | null, certDers: Buffer[], password: string,
|
||||||
|
algorithm: '3des' | 'aes256', friendlyName?: string): Buffer {
|
||||||
|
const keyAsn1 = keyPkcs8Der && forge.asn1.fromDer(keyPkcs8Der.toString('binary'));
|
||||||
|
const certs = certDers.map((d) => ({ asn1: forge.asn1.fromDer(d.toString('binary')) }));
|
||||||
|
const o = { k: pki.privateKeyToAsn1, w: pki.wrapRsaPrivateKey, c: pki.certificateToAsn1 };
|
||||||
|
const p = pki as any;
|
||||||
|
p.privateKeyToAsn1 = (x: any) => x.asn1; p.wrapRsaPrivateKey = (a: any) => a; p.certificateToAsn1 = (c: any) => c.asn1;
|
||||||
|
try {
|
||||||
|
const p12 = forge.pkcs12.toPkcs12Asn1(keyAsn1 ? ({ asn1: keyAsn1 } as any) : (null as any),
|
||||||
|
certs as any, password, { algorithm, friendlyName });
|
||||||
|
return Buffer.from(forge.asn1.toDer(p12).getBytes(), 'binary');
|
||||||
|
} finally { p.privateKeyToAsn1 = o.k; p.wrapRsaPrivateKey = o.w; p.certificateToAsn1 = o.c; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Key DER comes from `createPrivateKey(...).export({type:'pkcs8', format:'der'})` (works for any key type). Cover with a spec that round-trips the output through forge `pkcs12FromAsn1` and checks cert count + key presence for RSA **and** EC. If the planner dislikes patching a library, the alternative is a self-written PFX assembler; do not hand-roll PBE/MAC.
|
||||||
|
|
||||||
|
**PFX encryption profile (user choice, default compatible):**
|
||||||
|
- `3des` = `pbeWithSHA1And3-KeyTripleDES-CBC` + HMAC-SHA1 MAC (current behaviour; probed: `openssl pkcs12 -info` reads it). Readable by Windows Server 2012R2/2016/IIS, Java, macOS, old appliances.
|
||||||
|
- `aes256` = PBES2/PBKDF2(HMAC-SHA1)/AES-256-CBC key bag, MAC still SHA-1 (forge always uses SHA-1 MAC; `pkcs12.js:827,1007`). **Not** the same as OpenSSL-3's default (AES-256 + SHA-256 MAC). Which Windows versions accept forge's AES variant is NOT tested here [ASSUMED]. Microsoft Q&A states Windows Server 2012R2/2016 will never support AES256-SHA256 PFX ("The password you entered is incorrect") and the workaround is TripleDES-SHA1 [CITED: learn.microsoft.com/en-us/answers/questions/1054881]. Therefore: default **3DES (kompatibel)**, label the AES option "nur für neuere Systeme (Windows Server 2019 und neuer, nicht Windows Server 2016)" and mark that wording `[ASSUMED]` -> planner: accept as-is or drop the AES option (smallest, safest: ship 3DES only + AES as secondary).
|
||||||
|
|
||||||
|
### Pattern 4: AIA caIssuers fetch (button only)
|
||||||
|
- Endpoint takes `{ pem }` (the cert lacking its issuer), server reads URLs from `new X509Certificate(pem).infoAccess['CA Issuers - URI']` (probed: `{"CA Issuers - URI":["http://example.test/inter.cer"]}`), keeps only `http:`/`https:`, ignores `ldap:`. **Never accept a URL from the client.**
|
||||||
|
- Reuse `isPublicHttpUrl(url: URL)` from `apps/api/src/common/public-url-guard.ts:80` and copy the loop of `fetchLogoImage` in `nextcloud-status/nextcloud-logo-fetch.ts` (guard before first request and before every hop, `redirect:'manual'`, max 3 redirects, `http/https` only on each hop, single `AbortController` timeout 8 s + `Promise.race` on abort, `content-length` pre-check then streamed byte cap, `discard(response)` on non-final). Differences: byte cap 256 KiB (certs are ~1-3 KB, P7C a few KB), no content-type trust, no User-Agent spoofing, no cookies, `maxResponseSize`/cap in the reader loop.
|
||||||
|
- Response parse: try X509 DER; else forge PKCS#7 DER (`.p7c`, RFC 5280 "certs-only"); else PEM text. Accept ONLY certs where `target.checkIssued(c) && target.verify(c.publicKey)`; otherwise reject (prevents injecting unrelated certs). One hop per click; after adding, re-run chain building client-side/server-side so the new gap (if any) shows its own button. Return items with `sources: ['nachgeladen: <host>']`.
|
||||||
|
- Allow default ports only (80/443) - cheap extra SSRF/port-scan hardening [ASSUMED nice-to-have].
|
||||||
|
- Hardening candidate for the shared guard (read, not changed): `isPrivateIpv6` only recognises IPv4-mapped in dotted form (`/^::ffff:(\d+\.\d+\.\d+\.\d+)$/`, `public-url-guard.ts:60`); a DNS name resolving to AAAA `::ffff:7f00:1` (hex form), NAT64 `64:ff9b::/96` or `2002::/16` would be classified public. Literal bracketed IPv6 URLs fail closed (hostname keeps brackets -> `isIP` 0 -> lookup fails). Also DNS rebinding window is documented as accepted (`nextcloud-logo-fetch.ts` header). Recommend (optional, low-cost): add those ranges to the guard + an undici `connect.lookup` that re-checks the connected IP. Note any change to the shared guard affects favorites/nextcloud-status specs.
|
||||||
|
|
||||||
|
### Pattern 5: Templates (`cert-templates.ts`, id -> list of `{filename, content}` + `readme` snippet)
|
||||||
|
All content comes from the same chain/key; "without root" is default, root only when the checkbox is on (never for IIS/NPM where chain needs are explicit - see table).
|
||||||
|
|
||||||
|
| Template | Files produced | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Nginx | `fullchain.pem` (leaf+intermediates) + `privkey.pem` (unencrypted PKCS#8 PEM) | `ssl_certificate fullchain.pem; ssl_certificate_key privkey.pem;` [ASSUMED: standard nginx docs, not re-fetched] |
|
||||||
|
| Apache >= 2.4.8 | `fullchain.pem` + `privkey.pem` | `SSLCertificateFile` = fullchain, `SSLCertificateKeyFile` = key; `SSLCertificateChainFile` deprecated since 2.4.8 [CITED: community.letsencrypt.org/t/apache-directives/5879] |
|
||||||
|
| Apache < 2.4.8 | `cert.pem` + `chain.pem` + `privkey.pem` | `SSLCertificateFile`=cert, `SSLCertificateChainFile`=chain [CITED: same] |
|
||||||
|
| IIS / Windows | `<name>.pfx` incl. chain + key, 3DES default | import into Local Computer\Personal; friendly name = base name |
|
||||||
|
| Nginx Proxy Manager (custom cert) | `certificate.pem` (leaf), `privkey.pem`, `intermediate.pem` (intermediates only) | NPM "Add certificate -> Custom" has fields Certificate Key / Certificate / Intermediate Certificate [ASSUMED: from training + community hints; could not fetch an authoritative NPM doc this session]. Community reports NPM rejecting `BEGIN PRIVATE KEY` for RSA and wanting `BEGIN RSA PRIVATE KEY` [CITED via WebSearch summary, unverified] -> offer the NPM key as PKCS#1 for RSA (`type:'pkcs1'`) and SEC1 for EC; make it a selectable detail, test on the real NPM host. |
|
||||||
|
| HAProxy | single `<name>.pem` = leaf + intermediates + unencrypted key (one concatenated file) | HAProxy `crt` file may contain cert, intermediates, key; blank-line/newline between blocks [CITED: discourse.haproxy.org threads via WebSearch; order variations reported, cert-first works] |
|
||||||
|
| Tomcat / Java | `<name>.p12` (PKCS#12 with chain; `friendlyName` = alias, 3DES) | PKCS#12 keystore type works in Java 9+/Tomcat `certificateKeystoreType="PKCS12"` [ASSUMED]. **Skip JKS** (needs keytool/native). |
|
||||||
|
| Frei | any item x any format | "Konvertieren" tab |
|
||||||
|
|
||||||
|
### Anti-patterns
|
||||||
|
- Do not keep two parsers (forge cert parser + node) in parallel; forge parse must not decide anything about certs again.
|
||||||
|
- Do not trust the client for chain order or AIA URLs; `build` re-validates order with the same `buildChains`.
|
||||||
|
- Do not put the ZIP expansion of nested archives or encrypted entries on the happy path.
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| X.509 parse (RSA+EC) | ASN.1 certificate decoder | `crypto.X509Certificate` | handles all curves/algs, DER+PEM |
|
||||||
|
| Issuer relationship | DN/AKI string matching | `checkIssued()` + `verify(pubkey)` | OpenSSL semantics (AKI/SKI/keyUsage) + real signature check |
|
||||||
|
| Key <-> cert match | modulus compare | `cert.checkPrivateKey(key)` / SPKI DER compare | works for EC |
|
||||||
|
| Key decrypt/encrypt/re-encode | PBES/PEM crypt | `createPrivateKey` / `KeyObject.export` | probed: pkcs1/pkcs8/sec1, PEM+DER, AES-256 encrypted pkcs8 and traditional |
|
||||||
|
| PKCS#12 PBE/MAC | own PBKDF/MAC | forge `pkcs12` | only lockfile option |
|
||||||
|
| ZIP | manual inflate | adm-zip + limits | CRC + bounded inflate built in |
|
||||||
|
| SSRF check | new regex | `isPublicHttpUrl` (+ optional hardening above) | one shared guard |
|
||||||
|
| ZIP creation in browser | own writer | `fflate.zipSync` | already used |
|
||||||
|
|
||||||
|
## Limits and sizes (Claude's discretion, concrete recommendation)
|
||||||
|
Current: multipart `FilesInterceptor('files', 20, {limits:{fileSize:5 MiB}})` (`cert-manager.controller.ts`), memory storage (multer default) -> theoretical 100 MiB RAM per request; ZIP: 100 entries, `entry.header.size <= 5 MiB` each, **no total cap, no ratio, no nested/encrypted handling** [VERIFIED: `cert-bundle.ts:104-105,184-205`]. Nest JSON body limit is Express default **100 kB** (`main.ts` has no body-parser options - read) - the `export`/`build` JSON must stay small: send only needed PEMs (typ. 5-20 kB).
|
||||||
|
Recommend: `files: 30`, `fileSize: 5 MiB`, client-side total <= 10 MiB (matches Betriebsanleitung "client_max_body_size mindestens 10m", `docs/anleitung-betrieb.md:192`; NPM default 1 MiB would 413 larger uploads - cert uploads are normally <1 MiB so fine, but state it in the guide). ZIP: single level only (a nested `.zip` -> listed as ignored with hint), sum of `header.size` of all kept entries <= 20 MiB checked BEFORE any `getData()`, per-entry uncompressed <= 5 MiB but treat >1 MiB as suspicious/ignored for non-PFX, ratio `header.size / max(header.compressedSize,1) <= 100`, entry count <= 100, skip `__MACOSX/`, dotfiles, `Thumbs.db`, directories; encrypted entry (general-purpose flag bit 0, `entry.header.encripted` in adm-zip) -> reported as locked/ignored; use basename only (never written to disk); detect ZIP by magic bytes, not only `.zip`. Dedupe identical blobs (existing sha256 dedupe stays).
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
1. **Forge silently drops EC** (cert, key, CSR): any "no items found" message hides this. Add EC fixtures to specs (generate with `openssl ecparam`; commit PEM fixtures or generate with `crypto.generateKeyPairSync` + `X509`? Node cannot create certs - commit small fixtures).
|
||||||
|
2. **Name-only chain walk** picks wrong CA for re-issued/cross-signed roots - use verify.
|
||||||
|
3. **Binary through utf-8**: DER must go via `Buffer`/`'binary'` (existing Pitfall 1); base64 results via Buffer.
|
||||||
|
4. **Shared password**: `analyze` takes ONE password for all files; with several PFX/keys, try per-file entry (`passwords` map by filename) or retry each locked file with the one entered password; UI keeps `LockedNotice` per file. Never log passwords; existing pattern: catch -> generic message.
|
||||||
|
5. **Unencrypted keys leave the server in responses** (`analyze` returns key PEM in JSON, `OverviewTab` holds it in state). Keep; do not log request bodies (check `common/request-log.ts` logs path/status only - confirm during planning), and don't put keys in URLs.
|
||||||
|
6. **JSON 100 kB limit** on `build`; **NPM 1 MiB default** upload limit; mention in guide.
|
||||||
|
7. **Route order** (`project_nest_route_order`): static routes before `@Get(':id')` - all new routes are POST with static names, fine; keep them declared before any param route.
|
||||||
|
8. **Biome**: array-index keys need a `biome-ignore` with reason (see `OverviewTab`); a11y rule on nested buttons (see `DropZone` comment).
|
||||||
|
9. **PFX friendlyName** from user input -> sanitize (`safeBaseName`).
|
||||||
|
10. **Fullchain order** must be leaf -> issuer -> ... ; root only on checkbox; with no key never produce a "cert+key" output (disable the button, say why).
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Source: probes this session (Node 24.16)
|
||||||
|
import { X509Certificate, createPrivateKey, createPublicKey } from 'node:crypto';
|
||||||
|
const leaf = new X509Certificate(pemOrDer);
|
||||||
|
leaf.checkIssued(issuer) && leaf.verify(issuer.publicKey); // issued by
|
||||||
|
leaf.checkPrivateKey(createPrivateKey({ key, passphrase })); // key matches (RSA+EC)
|
||||||
|
leaf.infoAccess; // { 'CA Issuers - URI': ['http://...'] }
|
||||||
|
key.export({ type: 'pkcs8', format: 'pem', cipher: 'aes-256-cbc', passphrase: 'pw' }); // -----BEGIN ENCRYPTED PRIVATE KEY-----
|
||||||
|
key.export({ type: 'sec1', format: 'pem' }); // -----BEGIN EC PRIVATE KEY-----
|
||||||
|
key.export({ type: 'pkcs1', format: 'pem' }); // -----BEGIN RSA PRIVATE KEY-----
|
||||||
|
```
|
||||||
|
Use `ec.export({type:'pkcs1'})` only for RSA keys (EC -> `sec1`).
|
||||||
|
|
||||||
|
## Probe results (this session)
|
||||||
|
Forge: EC cert/CSR parse FAIL "Cannot read public key. OID is not RSA."; RSA CSR OK; PKCS#7 PEM+DER (openssl-made) OK, forge-made `.p7b` PEM and DER read by `openssl pkcs7 -print_certs` OK; PFX modern+legacy RSA/EC read OK (EC leaf `bag.cert===null`, EC key `bag.key===false` with `bag.asn1` set). Node: createPrivateKey OK for RSA PKCS#1/PKCS#8, traditional AES-256 encrypted RSA+EC, PKCS#8 AES-256 and PBE-SHA1-3DES (EC), DER pkcs1/pkcs8/sec1; X509Certificate from DER, CRLF PEM; `checkIssued`/`verify`/`checkPrivateKey` true for matching, false for wrong issuer. EC PFX written with the Pattern-3 patch: `openssl pkcs12 -info` lists 2 cert bags + shrouded key bag (3DES: `pbeWithSHA1And3-KeyTripleDES-CBC`; AES: `PBES2, PBKDF2, AES-256-CBC, PRF hmacWithSHA1`; MAC sha1).
|
||||||
|
|
||||||
|
## Integration points
|
||||||
|
- **Module version rule** (`docs/anleitung-entwicklung.md:299-340`): an entry is "unveröffentlicht" only while its date is after the date of the latest Tessera release in `CHANGELOG.md`. Latest release `## 1.10.1 – 2026-10-06` (`CHANGELOG.md:34`); top cert-manager entry `1.1.0` dated `2026-10-02` (`cert-manager.changelog.ts`) is **already released** -> add a **new** top entry dated 2026-10-09 (not extending 1.1.0). It has "new" items -> at least minor: **1.2.0**; the redesign could justify **2.0.0** ("grundlegender Umbau") - planner/user call, recommend 1.2.0 to follow the written rule conservatively. Items need `de` + `en`, real umlauts, "Sie", no "fuer/Aenderung", no Mandanten/Lizenz words, 1-2 sentences each, kinds `new|changed|fixed`. Seed version comes only from `latestVersion(CERT_MANAGER_CHANGELOG)`; guard spec `module-registry/module-changelog.spec.ts` fails otherwise. Optional follow-up the guide mentions: Tessera-level `CHANGELOG.md` "Unveröffentlicht" section also gets a user-visible line (project practice, e.g. Domains/Dateien entries there).
|
||||||
|
- **Guides:** `docs/anleitung-anwender.md:150-161` "Zertifikat-Manager" still says "vier Reitern" and does not mention the Übersicht tab - rewrite for the new tab list, ZIP/multi-file, fullchain/root toggle, "Fehlendes Zertifikat holen", templates, PFX compatibility choice. `docs/anleitung-betrieb.md:192` already documents the NPM `client_max_body_size >= 10m` requirement (link from the cert section; also note the API container makes outbound http requests for AIA - egress must be allowed). `docs/anleitung-administration.md`: no cert-manager text found (`grep -n cert` only anwender).
|
||||||
|
- **i18n:** `apps/web/src/messages/de.json` / `en.json`, namespace `certManager` (keys: title, description, tabs, dropZone, paste, password, or, actions, certRole, emptyState, error, overview{...}). `umlaut-guard.spec.ts` fails on substitute spellings (ae/oe/ue/ss tokens not allow-listed in `umlaut-dictionary.ts`) -> use real umlauts, extend `UMLAUT_ALLOWLIST` for legit words. No cert-manager parity spec exists (only tenderRadar), keep de/en keys identical by hand. `MergeTab`/`ConvertTab` contain hard-coded German strings (e.g. "Ausgabeformat", "PEM-Kette") - new UI must use `t()`.
|
||||||
|
- **Web tests to rewrite:** `cert-manager.test.tsx`, `MergeTab.test.tsx`, `OverviewTab.test.tsx` (MergeTab tests assert the old per-tab list). `module-layouts.test.tsx` references cert-manager layout (unchanged).
|
||||||
|
- **Seed/Marketplace:** `cert-manager.seed.ts` description unchanged; version flows from changelog.
|
||||||
|
|
||||||
|
## Environment Availability
|
||||||
|
|
||||||
|
| Dependency | Required By | Available | Version | Fallback |
|
||||||
|
|------------|------------|-----------|---------|----------|
|
||||||
|
| Node (host) / `node:24-alpine` (prod images) | node:crypto features | yes | 24.16.0 host; images `node:24-alpine` (CLAUDE.md) | - |
|
||||||
|
| openssl CLI | generating test fixtures only (not at runtime) | yes (host 3.5.7) | - | commit fixtures to repo; runtime must not call openssl |
|
||||||
|
| keytool | JKS | n/a | - | skip JKS (per CONTEXT) |
|
||||||
|
| Outbound HTTP from api container | AIA fetch | unknown (env-specific) | - | feature degrades to "nicht erreichbar" message; document in Betriebsanleitung |
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework | Vitest 3.2.6 (api, `apps/api/vitest.config.ts`, `src/**/*.spec.ts`), Vitest 4.1.9 + Testing Library (web) |
|
||||||
|
| Quick run | `cd apps/api && pnpm vitest run src/cert-manager` ; `cd apps/web && pnpm vitest run "src/app/(portal)/modules/cert-manager"` |
|
||||||
|
| Full suite | `pnpm test` at root (turbo) / `pnpm --filter api test`, `--filter web test` |
|
||||||
|
| Lint | `biome lint .` (api/web) |
|
||||||
|
|
||||||
|
| Behavior | Test | File |
|
||||||
|
|----------|------|------|
|
||||||
|
| EC + RSA cert/key/CSR/PFX/P7B/DER analyzed, EC no longer ignored | unit | new `cert-model.spec.ts` (fixtures: RSA+EC chain, enc keys, PFX modern+legacy) |
|
||||||
|
| Chain: leaf->inter->root, root toggle, cross-signed/same-name decoy, gap reported | unit | new `cert-chain.spec.ts` |
|
||||||
|
| Key match RSA+EC; CSR match | unit | same |
|
||||||
|
| ZIP limits (count, total, ratio, nested, encrypted, lying header) | unit | new `zip-expand.spec.ts` |
|
||||||
|
| PFX round-trip RSA+EC, 3des+aes256, wrong pw | unit | `cert-bundle.spec.ts` (extend) |
|
||||||
|
| AIA: private/redirect-to-private/oversize/timeout/non-issuer rejected | unit with injected `fetchImpl`/`isPublic` like `nextcloud-logo-fetch` spec | new `cert-aia.spec.ts` |
|
||||||
|
| Templates produce expected filenames/contents | unit | new `cert-templates.spec.ts` |
|
||||||
|
| Multi-drop appends (2 files stay 2), remove, ZIP in list, tabs read shared set | web | rewritten `cert-manager.test.tsx` |
|
||||||
|
| Module changelog guard | existing | `module-registry/module-changelog.spec.ts` |
|
||||||
|
Wave 0: commit fixture files (generated with openssl commands used in this research: RSA root/inter/leaf, EC leaf, enc keys, PFX, P7B, CSR).
|
||||||
|
|
||||||
|
## Security Domain
|
||||||
|
ASVS: V5 input validation (ZIP/ASN.1 untrusted - size/ratio limits, try/catch -> generic 400); V6 cryptography (use node:crypto/forge, no custom crypto; PFX password required, not logged; encrypted-key export uses AES-256); V12 files/resources (ZIP bomb, nested, path ignored); V13/SSRF (AIA: server-derived URL, `isPublicHttpUrl` per hop, no redirects to private, size/time caps, only 80/443, issuer-verification of the response); V4 access (existing global JwtAuthGuard + `@UseModule('cert-manager')` on all routes - new routes inherit by being in the same controller); V8 data protection (working set browser-only; private keys never persisted server-side; keep response bodies out of logs).
|
||||||
|
| Threat | STRIDE | Mitigation |
|
||||||
|
|--------|--------|------------|
|
||||||
|
| Zip bomb / many entries | DoS | caps above, check sizes before `getData()` |
|
||||||
|
| SSRF via AIA URL in malicious cert | Tampering/InfoDisc | server-side URL derivation + guard + hardening |
|
||||||
|
| Malicious ASN.1 crashing parser | DoS | try/catch per blob, size caps |
|
||||||
|
| Key/password leakage via logs | InfoDisc | never log bodies; generic errors (existing T-09-02 pattern) |
|
||||||
|
| Fetched cert injected as "issuer" | Spoofing | accept only if `checkIssued` + `verify` pass |
|
||||||
|
|
||||||
|
## Assumptions Log
|
||||||
|
|
||||||
|
| # | Claim | Section | Risk if Wrong |
|
||||||
|
|---|-------|---------|---------------|
|
||||||
|
| A1 | forge-written AES-256 PFX (PBES2 + SHA-1 MAC) is accepted by newer Windows; which versions is untested | PFX profile | AES option unusable on Windows; keep 3DES default |
|
||||||
|
| A2 | NPM custom cert has fields Certificate Key / Certificate / Intermediate Certificate and may want `BEGIN RSA PRIVATE KEY` | Templates | NPM template wrong; verify on the real NPM host (user does deploys; do not deploy to test server) |
|
||||||
|
| A3 | HAProxy single PEM cert->intermediates->key order works (forum-sourced) | Templates | wrong order only if HAProxy rejects; low risk |
|
||||||
|
| A4 | Java 9+/Tomcat reads 3DES PKCS#12 | Templates | Tomcat template needs different profile |
|
||||||
|
| A5 | Nginx directive names as listed (standard) | Templates | negligible |
|
||||||
|
| A6 | Legacy RC2-based encrypted PKCS#8 not decryptable by Node w/o legacy provider (not probed) | Pattern 1 | such keys show as locked; acceptable |
|
||||||
|
| A7 | Version bump 1.2.0 vs 2.0.0 | Integration | purely naming; user/planner choose |
|
||||||
|
| A8 | Restricting AIA to ports 80/443 will not break real-world AIA URLs | Pattern 4 | rare non-standard ports blocked; acceptable |
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
1. **PFX AES option:** ship only 3DES (zero risk) or both profiles? Recommendation: both, default 3DES, AES labelled "nur für neuere Systeme".
|
||||||
|
2. **Remove old endpoints** (`parse/split/merge/convert`) in this task? Recommendation: yes once tabs migrate, with their specs, to avoid two parsers.
|
||||||
|
3. **Version 1.2.0 vs 2.0.0** (A7).
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
### Primary (HIGH)
|
||||||
|
- Code read this session: `apps/api/src/cert-manager/*`, `apps/api/src/common/public-url-guard.ts`, `apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts`, `apps/api/src/favorites/icon-discovery.service.ts`, `apps/api/src/main.ts`, web cert-manager `page.tsx`, `DropZone.tsx`, `MergeTab.tsx`, `ConvertTab.tsx`, `OverviewTab.tsx`, `actions.ts`, `docs/anleitung-entwicklung.md`, `CHANGELOG.md`, `pnpm-lock.yaml`.
|
||||||
|
- Local probes (Node 24.16.0, OpenSSL 3.5.7, node-forge 1.4.0, adm-zip 0.6.0 source) - scripts in session scratchpad.
|
||||||
|
### Secondary (MEDIUM)
|
||||||
|
- [Microsoft Q&A: Windows Server 2016/2012R2 AES256-SHA256 PFX](https://learn.microsoft.com/en-us/answers/questions/1054881/windows-server-2016-2012r2-how-to-add-support-for)
|
||||||
|
- [Let's Encrypt community: Apache directives / SSLCertificateChainFile deprecated 2.4.8](https://community.letsencrypt.org/t/apache-directives/5879)
|
||||||
|
### Tertiary (LOW)
|
||||||
|
- HAProxy discourse threads on PEM composition (https://discourse.haproxy.org/t/strange-cert-chain-with-all-certs-in-one-file/7478); NPM custom-certificate field names from training + community snippets (no authoritative doc fetched).
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
**Confidence:** stack/capabilities HIGH (probed); architecture HIGH; templates MEDIUM/LOW for NPM and HAProxy.
|
||||||
|
**Research date:** 2026-10-09 | **Valid until:** ~2026-11-08
|
||||||
+215
@@ -0,0 +1,215 @@
|
|||||||
|
---
|
||||||
|
phase: quick-261009-ikt
|
||||||
|
reviewed: 2026-10-09T16:45:00Z
|
||||||
|
depth: standard
|
||||||
|
files_reviewed: 62
|
||||||
|
files_reviewed_list:
|
||||||
|
- apps/api/src/cert-manager/zip-expand.ts
|
||||||
|
- apps/api/src/cert-manager/cert-json-body.ts
|
||||||
|
- apps/api/src/main.ts
|
||||||
|
- apps/api/src/cert-manager/cert-manager.controller.ts
|
||||||
|
- apps/api/src/cert-manager/cert-manager.module.ts
|
||||||
|
- apps/api/src/cert-manager/cert-analyze.ts
|
||||||
|
- apps/api/src/cert-manager/cert-model.ts
|
||||||
|
- apps/api/src/cert-manager/cert-chain.ts
|
||||||
|
- apps/api/src/cert-manager/cert-pkcs12.ts
|
||||||
|
- apps/api/src/cert-manager/cert-keys.ts
|
||||||
|
- apps/api/src/cert-manager/cert-csr.ts
|
||||||
|
- apps/api/src/cert-manager/cert-names.ts
|
||||||
|
- apps/api/src/cert-manager/cert-output.ts
|
||||||
|
- apps/api/src/cert-manager/cert-templates.ts
|
||||||
|
- apps/api/src/cert-manager/cert-aia.ts
|
||||||
|
- apps/api/src/cert-manager/cert-types.ts
|
||||||
|
- apps/api/src/cert-manager/cert-manager.changelog.ts
|
||||||
|
- apps/api/src/cert-manager/dto/cert-build.dto.ts
|
||||||
|
- apps/api/src/cert-manager/dto/cert-fetch-issuer.dto.ts
|
||||||
|
- apps/api/src/common/public-url-guard.ts
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/actions.ts
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/working-set.ts
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/TemplatesTab.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/PfxOptions.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/PasswordInput.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/cert-manager/components/ItemCard.tsx
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
findings:
|
||||||
|
critical: 3
|
||||||
|
warning: 7
|
||||||
|
info: 5
|
||||||
|
total: 15
|
||||||
|
status: issues_found
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 261009-ikt: Code Review Report (Zertifikat-Manager Umbau)
|
||||||
|
|
||||||
|
**Reviewed:** 2026-10-09
|
||||||
|
**Depth:** standard (plus targeted reproductions in the scratchpad; no source files modified)
|
||||||
|
**Status:** issues_found
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Test suites are green (API cert-manager and common: 331 tests; web cert-manager: 142 tests; `tsc` clean; message gate "messages ok 237"; Biome only reports two pre-existing, untouched files). That does not cover the findings below, which I reproduced against the real code and libraries.
|
||||||
|
|
||||||
|
Verified as sound: the SSRF design in `cert-aia.ts` (the connect-time lookup really blocks a hostname that resolves to loopback, reproduced with a local server; the address is read from the certificate on the server; redirects are re-checked; the `isPublicHttpUrl` signature is unchanged, so favorites icon discovery and nextcloud-status are not broken, the hardening only blocks more); `cert-json-body.ts` is named so that Nest keeps its global JSON parser (`jsonParser` name check in `registerParserMiddleware`) and mounted without a global prefix; passwords and keys are never logged (the request log is path/status only; error texts are fixed strings); chain building requires `checkIssued` and `verify`, with cycle protection; template snippets never contain a real password (IIS uses `Read-Host`, Tomcat uses the placeholder `IHR-PASSWORT`); the `forge.pki` patch is synchronous and restored in `finally`.
|
||||||
|
|
||||||
|
Three problems are blockers: a decompression bomb that bypasses every ZIP limit, a quadratic PEM scanner that freezes the API process, and a PKCS#12 password-encoding bug that produces unusable "Modern" PFX files for passwords with umlauts and rejects correct umlaut passwords on read.
|
||||||
|
|
||||||
|
## Critical Issues
|
||||||
|
|
||||||
|
### CR-01: ZIP bomb bypasses all limits when the central directory declares size 0
|
||||||
|
|
||||||
|
**File:** `apps/api/src/cert-manager/zip-expand.ts:102-122` (root cause in adm-zip `methods/inflater.js`)
|
||||||
|
**Issue:** All limit checks run on the declared `entry.header.size` from the central directory. adm-zip 0.6.0 only passes `maxOutputLength` to zlib when the declared size is `> 0` (`expectedLength > 0 ? { maxOutputLength: expectedLength } : {}`). An entry that declares `size = 0` but carries a deflate stream of, say, 300 KB therefore:
|
||||||
|
- passes the ratio check (`0 / compressedSize = 0`),
|
||||||
|
- adds 0 to `total`,
|
||||||
|
- is inflated without any bound by `entry.getData()` (line 122). Only afterwards `data.length > maxEntryBytes` is checked, and the CRC check fails after the allocation has happened.
|
||||||
|
|
||||||
|
Reproduced: a 305,870-byte ZIP with the size field patched to 0 inflated to 314,572,800 bytes in 1.3 s. The upload limit is 5 MiB per file, and deflate reaches about 1000:1, so one request can allocate around 5 GB and crash or freeze the whole API container (an authenticated module user is enough). The header comment in the file ("adm-zip entpackt hoechstens die deklarierte Groesse") is wrong for this case. The comment's claim that "all checks run before the first unpacking" holds only for honest headers.
|
||||||
|
**Fix:** Do not rely on the header size for the bound. Reject the inconsistent case and inflate with your own cap:
|
||||||
|
```ts
|
||||||
|
if (size === 0 && entry.header.compressedSize > 0) {
|
||||||
|
ignored.push({ file, path, reason: 'suspicious' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// and for the actual extraction use a hard cap instead of entry.getData():
|
||||||
|
// zlib.inflateRawSync(rawCompressedBytes, { maxOutputLength: limits.maxEntryBytes + 1 })
|
||||||
|
// (STORED entries: compare compressed length with maxEntryBytes first)
|
||||||
|
```
|
||||||
|
Add a spec that patches the size field to 0 (the repro is a five-line script with `buf.writeUInt32LE(0, centralDirOffset + 24)`).
|
||||||
|
|
||||||
|
### CR-02: Quadratic PEM block regex lets one small upload freeze the API for minutes
|
||||||
|
|
||||||
|
**File:** `apps/api/src/cert-manager/cert-model.ts:61` and `:271` (same regex in `cert-aia.ts:162,185`)
|
||||||
|
**Issue:** `/-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/g` is applied to the whole file text. For every `-----BEGIN X-----` that has no matching END, the lazy `[\s\S]*?` scans to the end of the input, so a file made of repeated BEGIN lines costs O(n^2). Measured with `detectBlob` on `'-----BEGIN A-----\n'` repeated: 100 KiB takes 0.38 s, 200 KiB takes 1.3 s, 400 KiB takes 5.0 s. The upload limit is 5 MiB per file (the client limit is 10 MiB total), which extrapolates to roughly 15 minutes of blocked event loop on a single request. Node is single threaded, so the whole Tessera API (login, dashboard, all modules) stops. The AIA copy is capped at 256 KiB (about 2 s) but is fed by an attacker-chosen public server, three URLs per click.
|
||||||
|
**Fix:** Replace the regex with a linear scanner (`indexOf('-----BEGIN ')`, read the label, `indexOf('-----END label-----', start)`, and abort or skip when no END exists so that a missing END is not rescanned for every later BEGIN), and add a block-count cap (for example 200 blocks per blob). Add a timing-style spec with 400 KiB of BEGIN lines.
|
||||||
|
|
||||||
|
### CR-03: "Modern (AES-256)" PFX is unreadable for non-ASCII passwords; correct umlaut passwords are rejected on read
|
||||||
|
|
||||||
|
**File:** `apps/api/src/cert-manager/cert-pkcs12.ts:113` (read) and `:185` (write)
|
||||||
|
**Issue:** forge derives the MAC and the PKCS#12 3DES keys from the UTF-16 password (correct), but the PBES2 key derivation (`algorithm: 'aes256'`, and every OpenSSL-3-default PFX on read) uses forge's PBKDF2 on the raw JS string, i.e. one byte per UTF-16 unit, instead of the UTF-8 bytes OpenSSL, Windows and Java use. Reproduced with `openssl 3`:
|
||||||
|
- Write: `writePkcs12({profile:'modern', password:'pässwörd'})` and `'pw€'` round-trip inside Tessera, but `openssl pkcs12 -in file -passin pass:pässwörd` fails with `bad decrypt`. The "compat" files open fine. The user downloads a PFX that no other tool can open, with the password they typed, and gets no error.
|
||||||
|
- Read: an OpenSSL-3 PFX created with `-passout 'pass:pässwörd€'` returns `{ ok:false, reason:'passwordWrong' }` for the correct password ("Das Passwort passt nicht"), because the MAC passes and the shrouded key bag then throws a decrypt error that the broad regex at `:120` classifies as a password problem.
|
||||||
|
|
||||||
|
Passwords with umlauts are realistic for this (German) user base. The same module's key export/import (`cert-keys.ts`) goes through node:crypto and is not affected.
|
||||||
|
**Fix:** Do not let forge decrypt or encrypt the PBES2 bag. For writing: build the shrouded key bag from `key.export({type:'pkcs8',format:'der',cipher:'aes-256-cbc',passphrase})` (node uses UTF-8) and keep forge only for the MAC and the cert bags. For reading: lift each `pkcs8ShroudedKeyBag` and decrypt it with `createPrivateKey({key: der, format:'der', type:'pkcs8', passphrase})`. As a minimum, reject non-ASCII passwords for `modern` with a clear error and, on read, try `forge.util.encodeUtf8(password)` as a second candidate. Add a round-trip spec against `openssl pkcs12` with a non-ASCII password for both profiles.
|
||||||
|
|
||||||
|
## Warnings
|
||||||
|
|
||||||
|
### WR-01: 413/400 answers of the build parser carry no CORS headers
|
||||||
|
|
||||||
|
**File:** `apps/api/src/main.ts:24-26` (cors is enabled later at `:36`)
|
||||||
|
**Issue:** `app.use(CERT_BUILD_ROUTE, certBuildJsonBody, certBuildBodyErrors)` is registered before `app.enableCors(...)`. In the Express adapter `enableCors` is just another `use()`, so middleware order is: request log, cookies, build parser (and its error handler), cors, Nest parsers. For an oversized or malformed build body, `certBuildBodyErrors` answers 413/400 before the cors middleware ran, so in the cross-origin setup (`NEXT_PUBLIC_API_URL=http://localhost:3001`) the browser blocks the response and `fetch` throws a `TypeError`. The web code then shows `errors.generic` instead of the intended `tooLarge`/`invalidInput` messages. Same-origin production is unaffected.
|
||||||
|
**Fix:** Register the build parser after `enableCors` (still before `listen`):
|
||||||
|
```ts
|
||||||
|
app.enableCors({ origin: corsOrigin, credentials: true });
|
||||||
|
app.use(CERT_BUILD_ROUTE, certBuildJsonBody, certBuildBodyErrors);
|
||||||
|
```
|
||||||
|
Parsers are only installed at `init()` inside `listen`, so the order relative to Nest's global parser stays correct.
|
||||||
|
|
||||||
|
### WR-02: Output tabs keep working on a stale analysis (removed files, failed re-analysis)
|
||||||
|
|
||||||
|
**File:** `apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts:67-81`, consumers `MergeTab.tsx:42-56`, `TemplatesTab.tsx:71-85`, `ConvertTab.tsx:59-70`, `SplitTab.tsx:71-80`
|
||||||
|
**Issue:** `remove()`, `clear` of single entries and password changes update `entries` immediately but leave the previous `analysis` in place until the new response arrives. If the re-analysis fails (network, 413, 500) the old `analysis` stays indefinitely with `status:'error'`. The tabs only look at `status === 'analyzing' && !analysis`, so they continue to offer downloads of certificates and private keys from a file the user just removed (for example a key file removed for safety). The "N Dateien" line then no longer matches what is offered.
|
||||||
|
**Fix:** Treat the analysis as valid only when it matches the current entries: expose `fresh = status === 'idle' && analysisIds.length === entries.length && analysisIds.every((id, i) => id === entries[i].id)` from the hook, show the "analysing" or error state otherwise, and disable the download buttons while `!fresh`.
|
||||||
|
|
||||||
|
### WR-03: "Aufteilen" silently writes password-protected keys in clear text
|
||||||
|
|
||||||
|
**File:** `apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx:36-47,95-105`, `apps/api/src/cert-manager/cert-keys.ts:111-124`
|
||||||
|
**Issue:** `keyItemFromObject` always exports an unencrypted PKCS#8 PEM, even when the source key was encrypted (`wasEncrypted: true`). The Split tab downloads that PEM as `schluessel.key` and puts every key unencrypted into the "alle als ZIP" archive. A user who splits a protected bundle gets plaintext key files with no hint (the intro text `split.intro` says nothing), and the unlocked key also travels in every analyze response. This is a security-relevant surprise for a tool whose purpose is handling secrets.
|
||||||
|
**Fix:** In Split, show a visible note on keys with `wasEncrypted`, or offer the same password option as the Convert tab (`exportKey(key,'pkcs8',password)`), or exclude keys from the ZIP unless the user ticks them. At minimum extend `split.intro` (de/en).
|
||||||
|
|
||||||
|
### WR-04: Attacker-chosen KDF iteration counts run synchronously, up to 11 times per PFX
|
||||||
|
|
||||||
|
**File:** `apps/api/src/cert-manager/cert-pkcs12.ts:105-122`, `apps/api/src/cert-manager/cert-keys.ts:155-163`
|
||||||
|
**Issue:** `readPkcs12` tries own password, empty password and up to 10 more, each with a full pure-JS forge PBKDF/MAC computation; the iteration count comes from the uploaded file and is never bounded. `openWithPasswords` does the same with node:crypto for encrypted PKCS#8 (synchronous OpenSSL PBKDF2). A crafted PFX or encrypted key with a huge iteration count blocks the event loop for as long as the author wants, per attempt, per file (30 files).
|
||||||
|
**Fix:** Parse the MAC iteration count (`macData.iterations`) and the PBES2/PBE iteration counts before trying passwords and treat anything above a sane cap (for example 1,000,000) as unsupported (`ignored: unsupportedKey` or a new reason). Keep the attempts per container low.
|
||||||
|
|
||||||
|
### WR-05: No cap on the number of parsed items; chain building is O(n^2) on the event loop
|
||||||
|
|
||||||
|
**File:** `apps/api/src/cert-manager/cert-chain.ts:27-52`, `apps/api/src/cert-manager/cert-analyze.ts:51-77`
|
||||||
|
**Issue:** The ZIP path limits entries (100), but a single PEM bundle may contain thousands of distinct certificates (5 MiB is about 10k certificates). `toNodes` runs `checkIssued` over every pair; `matchKeys` multiplies certificates by keys; `isSelfSigned` verifies a signature per certificate. Measured: 600 distinct CA certificates in one file take 480 ms; the cost grows quadratically, so a 10 MiB request blocks the API for minutes.
|
||||||
|
**Fix:** Cap items per request (for example 200 certificates and 50 keys; return `tooLarge` or an `ignored` reason beyond that), and precompute subject/issuer buckets so only certificates with a matching name are compared.
|
||||||
|
|
||||||
|
### WR-06: Multer buffers up to 150 MiB before the 20 MiB total check
|
||||||
|
|
||||||
|
**File:** `apps/api/src/cert-manager/cert-manager.controller.ts:62-76`
|
||||||
|
**Issue:** `FilesInterceptor('files', 30, { limits: { fileSize: 5 MiB } })` uses memory storage; the total limit (`CERT_MAX_TOTAL_BYTES`) is only checked after all 30 files are fully buffered, so a request can hold 150 MiB (plus multer parts overhead) before it is rejected. There are also no `fields`/`parts` limits.
|
||||||
|
**Fix:** Add `limits: { fileSize, files: 30, fields: 5, parts: 40 }` and check the running total early with a small custom multer storage or a `Content-Length` pre-check (reject above about 21 MiB) in a guard/middleware.
|
||||||
|
|
||||||
|
### WR-07: Certificates without basicConstraints are classified as server certificates, even self-signed CAs
|
||||||
|
|
||||||
|
**File:** `apps/api/src/cert-manager/cert-model.ts:115-131`
|
||||||
|
**Issue:** `role` is derived from `x.ca` (basicConstraints cA flag only). A self-signed certificate that lacks basicConstraints, including legacy v1 roots and some private CAs, gets `isCa=false` and therefore role `end-entity`, although `selfSigned` is true. Reproduced: a self-signed certificate without the extension gives `ca false, selfverify true, checkIssued true`. Consequences: such a root shows as "Serverzertifikat", appears as an extra chain head in `defaultHeads`, is never reported as `rootId`, and cannot be excluded by "Root-Zertifikat mitnehmen" (`cert-output.ts` filters by `role === 'root'`).
|
||||||
|
**Fix:** Treat `selfSigned && (x.ca || no basicConstraints but keyCertSign usage or v1)` as root, or at least `selfSigned && x.subject === x.issuer && !hasServerAuthSan` heuristics. Simplest consistent rule: if the certificate is self-signed and has no basicConstraints extension, show it as role `root` only when `keyUsage` contains keyCertSign or the version is 1; otherwise keep `end-entity` but do not offer it as a chain member.
|
||||||
|
|
||||||
|
## Info
|
||||||
|
|
||||||
|
### IN-01: IPv6 literal URLs are always rejected, so the literal branch of the IPv6 hardening is dead
|
||||||
|
|
||||||
|
**File:** `apps/api/src/common/public-url-guard.ts:162-166`
|
||||||
|
**Issue:** `URL.hostname` keeps the brackets (`[2606:4700::1111]`), so `isIP(url.hostname)` is 0, the code falls into `lookup('[...]')`, which fails and returns `false`. Safe (fail closed), but the changelog and header comment suggest literal IPv6 addresses are now parsed and judged; in practice only DNS answers reach `isPrivateIpv6`. Public IPv6 AIA/favicon hosts given as literals can never work.
|
||||||
|
**Fix:** Strip the brackets before `isIP` (`url.hostname.replace(/^\[|\]$/g, '')`) and add a spec for `new URL('http://[::ffff:7f00:1]/')` and a public literal.
|
||||||
|
|
||||||
|
### IN-02: `aiaInternal` text is used for port, credential and length refusals
|
||||||
|
|
||||||
|
**File:** `apps/api/src/cert-manager/cert-aia.ts:297-299,324-326`; message `errors.aiaInternal` in `de.json`
|
||||||
|
**Issue:** A public host with a non-default port (`http://pki.example.com:8080/ca.crt`) is refused with "verweist nicht auf einen öffentlichen Server", which is wrong. Also, the UI button is shown for such URLs because `cert-model.ts:aiaUrls` (line 90-104) does not apply the same filter as `issuerUrls` (credentials, length, port).
|
||||||
|
**Fix:** Add a separate code (for example `aiaPortNotAllowed`) or reword the message ("Adresse nicht zulässig"), and share one URL filter between `cert-model.ts` and `cert-aia.ts` so the button only appears when a fetch can actually happen.
|
||||||
|
|
||||||
|
### IN-03: `errors.generic` says "Dateien konnten nicht geprüft werden" for download failures
|
||||||
|
|
||||||
|
**File:** `apps/web/src/messages/de.json` (`certManager.errors.generic`), used by `MergeTab.tsx`, `ConvertTab.tsx`, `TemplatesTab.tsx` via `certErrorKey`
|
||||||
|
**Issue:** A 500 from `build` shows the analysis error text, which is misleading.
|
||||||
|
**Fix:** Add `errors.buildFailed` and map `generic` per call site, or phrase the generic message neutrally ("Die Anfrage konnte nicht ausgeführt werden.").
|
||||||
|
|
||||||
|
### IN-04: Object URL is revoked synchronously after `click()`
|
||||||
|
|
||||||
|
**File:** `apps/web/src/app/(portal)/modules/cert-manager/actions.ts:282-285`, `components/SplitTab.tsx:56-60`
|
||||||
|
**Issue:** `URL.revokeObjectURL(url)` directly after `anchor.click()` can cancel the download in some browsers (older Safari/Firefox).
|
||||||
|
**Fix:** `setTimeout(() => URL.revokeObjectURL(url), 1000)`.
|
||||||
|
|
||||||
|
### IN-05: Smaller UI accuracy and key issues in the Dateien tab and Split tab
|
||||||
|
|
||||||
|
**File:** `apps/web/src/messages/de.json` (`files.locked.note`), `FilesTab.tsx:268-273`, `SplitTab.tsx:95-105`
|
||||||
|
**Issue:** (a) The note says the password is "nur zum Öffnen der Datei übertragen", but every add/remove/unlock re-uploads all files with all passwords, and the server also tries each file's password on every other file (`candidatePasswords`). (b) The rejected list uses `key={`${r.name}-${r.reason}`}`, which duplicates when two files with the same name are rejected for the same reason. (c) Several keys in the Split tab get the identical file name `schluessel.key` for single downloads (only the ZIP is de-duplicated).
|
||||||
|
**Fix:** Reword the note ("wird mit jeder Prüfung mitgesendet, aber nie gespeichert"), use the index in the key, and name keys after their certificate (`certIds[0]`) or number them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Reviewed: 2026-10-09_
|
||||||
|
_Reviewer: Claude (gsd-code-reviewer)_
|
||||||
|
_Depth: standard_
|
||||||
|
|
||||||
|
## Fix status
|
||||||
|
|
||||||
|
Behoben in den lokalen Commits bfcf6d4 (API, Fixtures) und c5bffe9 (Web, Anleitungen). Jeder Blocker hat einen Regressionstest, der gegen den alten Code rot war: CR-01 vier Tests rot gegen die alte `zip-expand.ts`; CR-02 der Test mit 20 000 unvollständigen BEGIN-Zeilen brauchte mit der alten Regex 4,6 s; CR-03 vier Tests rot gegen das alte `cert-pkcs12.ts` (modern lesen und schreiben, mit `pässwörd` und `pw€`).
|
||||||
|
|
||||||
|
| ID | Status | Wie |
|
||||||
|
|----|--------|-----|
|
||||||
|
| CR-01 | fixed | `zip-expand.ts` entpackt selbst: `inflateRawSync` mit `maxOutputLength` (Einzelgrenze, höchstens Restgrenze der Summe), STORED vorab gegen den Deckel; Größe, Verhältnis und CRC am echten Ergebnis, abweichende Kopfgröße = `suspicious`, Summe der echten Größen > 20 MiB = ganzes ZIP `zipTooLarge`. Spec: ZIP mit Kopfgröße 0 (200 MiB Nullbytes) wird mit `tooLarge` abgewiesen, Arbeitsspeicher < 50 MiB, < 2 s; e2e live (200 MiB, Antwort in unter 5 s, API gesund). |
|
||||||
|
| CR-02 | fixed | Neu `pem-scan.ts` (END-Stellen je Etikett in einem Durchlauf, Zeiger je Etikett, 1000 Blöcke Deckel), benutzt von `cert-model.ts` und `cert-aia.ts`. Specs: 5 MiB BEGIN-Zeilen (gleiche und verschiedene Etiketten) weit unter 100 ms (bestes von drei Läufen), Wiederholungen ohne Etikett; `detectBlob` mit 5 MiB BEGIN-Zeilen; e2e live (< 2 s). |
|
||||||
|
| CR-03 | fixed | `withForgeKdf` in `cert-pkcs12.ts` ersetzt für die Dauer des synchronen Lesens und Schreibens `forge.pkcs5.pbkdf2` durch `node:crypto` mit UTF-8-Bytes (3DES/RC2/MAC-Ableitung bleibt BMPString wie bei OpenSSL; der Reviewer-Vorschlag mit Schlüsselbeutel über node:crypto war nicht nötig). Neue Fixtures `rsa-umlaut-*.pfx`, `rsa-euro-*.pfx` (OpenSSL 3.5, modern und compat). Specs: lesen aller vier mit richtigem Passwort, falsches bleibt `passwordWrong`, Schreiben: der Schlüsselbeutel öffnet sich unabhängig von forge mit `createPrivateKey` und UTF-8-Passwort. e2e live in beide Richtungen mit `openssl pkcs12 -passin pass:pässwörd` und `pw€`, compat und modern. Kein Passwort wird abgelehnt. |
|
||||||
|
| WR-01 | fixed | Neu `http-setup.ts` (`configureHttp`), `main.ts` ruft es auf; der build-Leser wird nach `enableCors` eingehängt. Spec prüft die Reihenfolge; e2e live: 413 mit `access-control-allow-origin` bei gesetztem Origin. |
|
||||||
|
| WR-02 | fixed | `useCertWorkspace` liefert `fresh` (keine Analyse läuft, letzte erfolgreich, gleiche Eintragskennungen in gleicher Reihenfolge). Die fünf Ausgabe-Reiter lesen nur noch `freshAnalysis(workspace)`; sonst `AnalysisPending` (Prüfhinweis oder Fehler mit „Erneut versuchen“). Dateien-Reiter zeigt weiter die letzte bekannte Analyse je Eintrag. Tests: Hook (`use-cert-workspace.test.tsx`) und alle fünf Reiter (`stale-analysis.test.tsx`); Browser dunkel geprüft (ikt-fix-*.png). |
|
||||||
|
| WR-03 | fixed | Aufteilen: war ein Schlüssel geschützt, ist „Schlüssel mit einem Passwort schützen“ vorgewählt, die Schlüsselknöpfe und die ZIP warten auf ein Passwort, die Ausgabe kommt von `build` (`content: key`, `pkcs8`) verschlüsselt, auch in der ZIP. Ohne Schutz sichtbarer Hinweis. Browser: heruntergeladene Datei ist `ENCRYPTED PRIVATE KEY`, mit dem neuen Passwort lesbar, mit dem alten nicht. Die Analyse-Antwort enthält den Schlüssel weiterhin entschlüsselt (wie von Task 4/5 für die Ausgabe vorgesehen, nirgends protokolliert). |
|
||||||
|
| WR-04 | fixed | `cert-budget.ts`: eine Ableitung höchstens 1 000 000 Runden, alle zusammen 6 000 000 je Anfrage, gemeldet vor dem Rechnen (PKCS#12 über die drei forge-Ableitungsfunktionen, PKCS#8 über `pkcs8Iterations` aus den Parametern); Folge: `protectionTooExpensive` für die Datei. Fixtures mit 2 000 000 Runden (PFX und PKCS#8); Specs und e2e. |
|
||||||
|
| WR-05 | fixed | `RequestBudget`: 200 Zertifikate, 50 Schlüssel, 50 Anfragen je Anfrage, über alle Dateien und ZIP-Einträge, geprüft beim Erzeugen (Fehler verschluckende Stellen reichen sie mit `rethrowRequestError` weiter); 413 `tooManyItems`, Meldung de/en. Specs (200 ok, 201 413, über Dateien und ZIP, 50/51 Schlüssel, Müll zählt nicht), e2e live mit 200/201 Zertifikaten. Die Zuordnung `matchKeys` und der Kettenbau bleiben damit begrenzt; eine eigene Vorsortierung nach Namen war dafür nicht mehr nötig. |
|
||||||
|
| WR-06 | fixed | Neu `cert-upload.ts`: eigener multer-Speicher zählt die Summe beim Empfang und bricht bei 20 MiB mit 413 `tooLarge` ab; dazu `limits` `files` 30, `fields` 5, `parts` 35, `fieldSize` 256 KiB. Specs mit Datenströmen; e2e live (5 Dateien zu 4,9 MiB). |
|
||||||
|
| WR-07 | fixed | `cert-model.ts` (`isCaCertificate`, wie OpenSSLs `X509_check_ca`): ohne basicConstraints zählt ein selbstsigniertes v1-Zertifikat oder eines mit keyCertSign als CA/Wurzel; mit basicConstraints entscheidet nur cA; ein selbstsigniertes Serverzertifikat ohne diese Erweiterung (nur digitalSignature/keyEncipherment) bleibt Serverzertifikat. Neue Fixtures (echte v1-Wurzel mit Blatt, v3 nur keyCertSign, v3 nur Server). Specs für Rolle, Kette (vollständig, Wurzel erkannt, kein zweiter Kopf), e2e. |
|
||||||
|
| IN-01 | fixed | `public-url-guard.ts` entfernt die Klammern vor `isIP`; Spec mit internen Schreibweisen in Klammern (gesperrt) und öffentlichen Literalen (erlaubt). |
|
||||||
|
| IN-02 | fixed | Neu `cert-aia-url.ts` (ein Filter für Anzeige in `cert-model.ts` und Abruf in `cert-aia.ts`: Protokoll, Zugangsdaten, Länge, Standardport); neuer Code `aiaNotAllowed` (Port, Zugangsdaten, Länge, Protokoll nach Weiterleitung, nur nicht abrufbare Adressen); `aiaInternal` bleibt für nicht öffentliche Server. Live-AIA (Let's Encrypt, YE2) unverändert grün. |
|
||||||
|
| IN-03 | fixed | `certErrorKey(error, 'buildFailed')` für Zusammenführen, Konvertieren, Vorlagen und Aufteilen; neue Meldung `errors.buildFailed`. |
|
||||||
|
| IN-04 | fixed | `downloadBase64` und der ZIP-Download widerrufen die Objektadresse nach 1 s. |
|
||||||
|
| IN-05 | fixed | (a) Passwort-Hinweis neu formuliert (wird bei jeder Prüfung mitgesendet, nie gespeichert), (b) Schlüssel der Liste abgewiesener Dateien mit laufender Nummer, (c) Schlüssel heißen nach ihrem Zertifikat, sonst `schluessel`, `schluessel-2` … |
|
||||||
|
|
||||||
|
Nicht geändert (Vorgabe): „Cert Manager“ in der Seitenleiste gegenüber „Zertifikat-Manager“ auf der Seite.
|
||||||
|
|
||||||
|
Messwerte der Gates: api vitest gesamt 169 Dateien / 3573 Tests grün; web vitest gesamt 160 Dateien / 1945 Tests grün; tsc api und web ohne Fehler; Biome auf allen geänderten Dateien ohne Befund; `check-cert-messages.cjs 237` meldet `messages ok 250`; `e2e-cert.sh all` (jetzt mit Abschnitt `review`) grün inkl. Live-Abruf bei ye2.i.lencr.org.
|
||||||
+489
@@ -0,0 +1,489 @@
|
|||||||
|
---
|
||||||
|
phase: quick-261009-ikt
|
||||||
|
plan: 01
|
||||||
|
status: complete
|
||||||
|
quick_id: 261009-ikt
|
||||||
|
tasks_done: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 261009-ikt: Zertifikat-Manager Umbau - Summary
|
||||||
|
|
||||||
|
## Task 1
|
||||||
|
|
||||||
|
**Commit:** 75ea83f `feat(cert-manager): Reiter „Dateien“ mit mehreren Dateien und ein Parser für RSA und EC – Durchstich` (84 Dateien, 16 Löschungen alle beabsichtigt: 7 API, 9 Web). Nicht gepusht.
|
||||||
|
|
||||||
|
### Gates (gemessen, Stack neu gebaut mit `docker compose up -d --build api web`)
|
||||||
|
|
||||||
|
| Gate | Ergebnis |
|
||||||
|
|------|----------|
|
||||||
|
| api vitest `src/cert-manager src/module-registry` | 10 Dateien, 255 Tests grün |
|
||||||
|
| web vitest `modules/cert-manager src/messages` | 7 Dateien, 41 Tests grün (inkl. umlaut-guard) |
|
||||||
|
| tsc api / web | ohne Fehler |
|
||||||
|
| biome lint beide Modulordner | sauber |
|
||||||
|
| biome check eigene Dateien (api cert-manager, web components, page, actions, working-set(+test), use-cert-workspace, Seitentest, de/en.json) | sauber (7 eigene Dateien mit `--write` formatiert, nie ganze Ordner außerhalb des Moduls) |
|
||||||
|
| check-cert-messages.cjs 15 | `messages ok 51` |
|
||||||
|
| Löschungs- und grep-Gates (alte Dateien weg, keine forge-Zertifikatparser, keine alten Routen im Controller) | grün |
|
||||||
|
| Fixtures: keine ignorierten Dateien, Pflichtdateien vorhanden | grün |
|
||||||
|
| `e2e-cert.sh all` (Abschnitt files) | `e2e cert files ok` |
|
||||||
|
| gesamte `<automated>`-Kette | letzte Zeile `task1 ok` |
|
||||||
|
|
||||||
|
### Fixtures (Output-Punkt 1)
|
||||||
|
|
||||||
|
OpenSSL 3.5.7 (9 Jun 2026). Erzeugt mit `__fixtures__/make-fixtures.sh` (47 Dateien im Ordner inkl. Skript und README):
|
||||||
|
|
||||||
|
- RSA-PKI: rsa-root, rsa-root2, rsa-inter, rsa-inter-cross, rsa-inter-expired, rsa-inter-decoy (gleicher Name UND gleiche SubjectKeyIdentifier wie rsa-inter, anderer Schlüssel: checkIssued true, verify false), rsa-leaf (.pem/.cer), rsa-leaf-noaki, rsa-fullchain, rsa-chain.p7b/.p7c, rsa-trusted
|
||||||
|
- EC-PKI: ec-root (P-384), ec-inter (P-384), ec-leaf (P-256, .pem/.cer), ec-fullchain, ec-chain.p7b
|
||||||
|
- Sonstige Zertifikate: selfsigned-leaf, aia-private-leaf (127.0.0.1, 169.254.169.254, ldap://)
|
||||||
|
- Schlüssel RSA: rsa-leaf-key (PKCS#8), -pkcs1, -enc-pkcs8, -enc-trad, -pkcs8.der, -pkcs1.der
|
||||||
|
- Schlüssel EC: ec-leaf-key (PKCS#8), -sec1, -enc-pkcs8 (.pem/.der, 3DES), -enc-trad, -sec1.der
|
||||||
|
- CSR: rsa-leaf.csr/.csr.der, ec-leaf.csr/.csr.der
|
||||||
|
- PFX: rsa-modern, rsa-compat, rsa-legacy, rsa-nopass (leeres Passwort), rsa-modern.bin, ec-modern, ec-compat
|
||||||
|
- encrypted-entry.zip (ZipCrypto, Passwort Test-Pass-123)
|
||||||
|
- Passwort aller geschützten Dateien: `Test-Pass-123`. CA-Schlüssel werden nach der Erzeugung gelöscht.
|
||||||
|
|
||||||
|
### Abweichungen
|
||||||
|
|
||||||
|
1. **[Rule 1 - Bug] selfSigned-Berechnung.** D-18 verlangt `checkIssued(self) && verify(self)`. OpenSSLs checkIssued lehnt ein Zertifikat ab, dessen Schlüsselverwendung kein keyCertSign enthält; ein selbstsigniertes Serverzertifikat mit `digitalSignature,keyEncipherment` wäre dann nicht „selbstsigniert“ (die Kette würde fälschlich „Zwischenzertifikat fehlt“ melden). Umsetzung: `verify(eigener Schlüssel)` UND (`checkIssued(self)` ODER `subject === issuer`). Erfüllt weiterhin die Spec-Anforderung (selfsigned-leaf: selfSigned true, Rolle end-entity). Datei: cert-model.ts, Funktion `isSelfSigned`.
|
||||||
|
2. **Dateinamen aus multer.** multer liest Dateinamen als Latin-1; `repairFileName` im Controller gewinnt UTF-8-Umlaute zurück (mit Spec). Nicht im Plan, rein für die Anzeige.
|
||||||
|
3. Der Plan nennt `certError(code, status, message)`; so umgesetzt. Keine `CERT_ERROR_STATUS`-Tabelle.
|
||||||
|
4. `cert-manager.module.ts`: Providerliste entfernt, Kommentar angepasst (Seeding und Logzeile unverändert).
|
||||||
|
5. Keine Stubs: `detectZip`, `detectPkcs12`, `detectPkcs7`, `detectPrivateKey`, `detectCsr` sind laut Plan benannte leere Stufen (Tasks 3 und 4), `analyzeWorkingSet` liefert `chains`/`locked` leer bis Task 2/4. Nichts davon erreicht die Oberfläche als Platzhaltertext.
|
||||||
|
|
||||||
|
### Bedrohungsstatus
|
||||||
|
|
||||||
|
- T-ikt-02 (Upload-Größe): multer 30 x 5 MiB, Gesamtsumme 20 MiB -> 413 tooLarge (Controller-Spec); Web lehnt vorab ab (30 Einträge, 5 MiB, 10 MiB gesamt).
|
||||||
|
- T-ikt-03 (kaputte Eingaben): jede Stufe in try/catch, Spec mit Zufallsbytes, leerer Datei, abgeschnittenem DER, kaputtem Base64: nie ein Fehler, nur `unknown`.
|
||||||
|
- T-ikt-08 (Schlüssel/Passwörter): Passwort-Feld im Zustand vorhanden, aber noch nirgends befüllt oder gesendet; kein Logger-Aufruf mit Inhalten.
|
||||||
|
- T-ikt-11 (Zugriff): Controller-Spec prüft Klassenpfad, Modul-Slug und die exakte Handlerliste; alte Routen liefern live 404.
|
||||||
|
- T-ikt-12/13: Namen nur zur Anzeige (Steuerzeichen entfernt, max. 255), React-Text ohne `dangerouslySetInnerHTML`.
|
||||||
|
|
||||||
|
### Hinweise für Task 2
|
||||||
|
|
||||||
|
- API: `cert-types.ts` enthält schon alle Typen (BuildInput, ChainInfo, CertErrorCode, `certError(code, status, message)`). `cert-output.ts` enthält nur `safeBaseName`; `buildOutput` kommt dazu. `analyzeWorkingSet` in `cert-analyze.ts` setzt `chains: []` - dort `buildChains` einhängen.
|
||||||
|
- `CertItem.keyId` ist die Schlüsselkennung `k-<16 Hex von sha256(SPKI-DER)>` (gleiche Form wie KeyItem.id/CsrItem.keyId); `selfSigned` und `role` kommen schon aus `certItemFromDer`. Hilfsfunktionen in cert-model.ts exportiert: `describeKey`, `keyIdOf`, `sha256Hex`, `roleOf`, `leadingDerSequence`.
|
||||||
|
- Fixtures für Task 2: rsa-inter-cross (andere Wurzel rsa-root2), rsa-inter-expired, rsa-inter-decoy (gleicher Name und gleiche SKI, anderer Schlüssel: Pflichttest für checkIssued + verify), rsa-leaf-noaki, ec-*, selfsigned-leaf. Fixture-Zertifikate sind ab "jetzt" (2026-10-09) 100 Jahre gültig, rsa-inter-expired 2020 bis 2021.
|
||||||
|
- Web: `useCertWorkspace()` liefert `{ entries, analysis, analysisIds, status, errorKey, addFiles, remove, clear, retry }`; `analysisIds[source.file]` ist die Eintrags-Kennung zum Analysezeitpunkt (Antworten mit alter Reihenfolge bleiben korrekt zuordenbar). `actions.ts` hat `certErrorKey(error)` (Code, 413 ohne Code -> tooLarge, sonst generic) für `certManager.errors.<key>`. `ROLE_STYLES` ist aus `components/FilesTab.tsx` exportiert. `page.tsx` kennt `TabId = 'files'`; neue Reiter dort in Reihenfolge einfügen und `EmptyWorkspace` bei leerer Liste.
|
||||||
|
- Messages: Namespace `certManager` ist neu strukturiert (`tabs`, `roles`, `files`, `errors` mit allen D-24-Codes); `check-cert-messages.cjs <min>` zählt jetzt 51 Schlüssel, Mindestanzahl in späteren Tasks erhöhen. Neue deutsche Wörter mit ae/oe/ue/ss in `umlaut-dictionary.ts` (UMLAUT_ALLOWLIST) eintragen, wenn der Guard sie meldet.
|
||||||
|
- e2e-cert.sh: `BUILT="files"`; neue Abschnitte als Funktion `section_<name>` anlegen, in `run_section` freischalten und an `BUILT` hängen. Setup prüft, dass `POST parse` 404 liefert (Hinweis zum Neubau). Hilfsfunktion `analyze <out> <fixture>:<anzeigename>...`.
|
||||||
|
- Live-Befund: API-Image-Neubau dauert über 2 Minuten; `docker compose up -d --build api web` im Hintergrund starten.
|
||||||
|
|
||||||
|
## Task 2
|
||||||
|
|
||||||
|
**Commit:** 30118a2 `feat(cert-manager): Zusammenführen mit Fullchain und Nur Kette` (24 Dateien, keine Löschungen, 10 neu). 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` | 12 Dateien, 284 Tests grün |
|
||||||
|
| web vitest `modules/cert-manager src/messages` | 8 Dateien, 55 Tests grün (inkl. umlaut-guard) |
|
||||||
|
| tsc api / web | ohne Fehler |
|
||||||
|
| biome lint beide Modulordner, biome check eigene Dateien (api cert-manager + main.ts, web components, page, actions, working-set, use-cert-workspace, Seitentest, de/en.json) | sauber |
|
||||||
|
| check-cert-messages.cjs 25 | `messages ok 70` |
|
||||||
|
| `certBuildJsonBody` in main.ts, keine forge-Zertifikatparser | grün |
|
||||||
|
| `e2e-cert.sh all` | `e2e cert files ok`, `e2e cert fullchain ok` |
|
||||||
|
| gesamte `<automated>`-Kette | letzte Zeile `task2 ok` |
|
||||||
|
|
||||||
|
Live bewiesen (Abschnitt fullchain): openssl verify der Fullchain OK, Pool in falscher Reihenfolge plus fremdes RSA-Zwischenzertifikat ergibt genau 2 Blöcke (3 mit Wurzel, Wurzel zuletzt), Nur Kette = nur Zwischenzertifikat, RSA ohne Wurzel meldet `chainComplete false` und „Tessera Test Root RSA“, Körper von rund 330 kB innerhalb der DTO-Grenzen -> 400 notACertificate (nie 413), 600 KiB -> 413 `tooLarge`, kaputtes JSON -> 400 `invalidInput`, Anmeldung mit 150 kB -> 413 (100-kB-Grenze der anderen Routen unverändert), danach Anmeldung weiter möglich (Nests globaler JSON-Leser aktiv).
|
||||||
|
|
||||||
|
### Abweichungen
|
||||||
|
|
||||||
|
1. **[Rule 3 - Blocking] Neue Datei `cert-names.ts`.** `safeBaseName` liegt jetzt dort (cert-output.ts exportiert es weiterhin). Grund: `cert-output.ts` braucht jetzt `certItemFromDer` aus cert-model.ts, und cert-model.ts importierte `safeBaseName` aus cert-output.ts; das wäre eine Importschleife. `cert-model.ts` importiert jetzt aus `cert-names`. Verhalten unverändert, die bestehenden safeBaseName-Specs laufen unverändert gegen cert-output.
|
||||||
|
2. `buildChains(certs, headIds?)`: zusätzlicher optionaler Parameter für vorgegebene Kettenköpfe (`buildOutput` baut genau die Kette des gesendeten Serverzertifikats, auch wenn der Kopf ein Zwischenzertifikat ist). Ohne Parameter exakt das D-18-Verhalten.
|
||||||
|
3. Nur-Kette-Knopf in der Oberfläche ist deaktiviert (mit Hinweis), wenn es keinen Aussteller zum Mitnehmen gibt; die API antwortet in diesem Fall weiterhin mit 400 `noChain`.
|
||||||
|
4. Knöpfe benutzen die Design-Klassen `btn btn-primary` / `btn btn-secondary` aus globals.css.
|
||||||
|
|
||||||
|
### Bedrohungsstatus
|
||||||
|
|
||||||
|
- Body-Grenze (D-26): eigener Leser `certBuildJsonBody` (Funktionsname nie `jsonParser`), Express aus der Kopie von `@nestjs/platform-express`, `main.ts` registriert vor `app.listen`; Spec rechnet die größte DTO-Anfrage aus den exportierten Konstanten (unter 512 KiB) und prüft 413/400-Abbildung.
|
||||||
|
- Reihenfolge nie vom Browser: `buildOutput` baut immer neu über `buildChains`, Fremdzertifikate im Pool werden verworfen, Nicht-Zertifikate -> 400 notACertificate.
|
||||||
|
- Keine Passwörter oder Schlüssel in diesem Task im Spiel; keine Logger-Aufrufe mit Anfrageinhalt.
|
||||||
|
|
||||||
|
### Hinweise für Task 3
|
||||||
|
|
||||||
|
- API: `cert-model.ts` Stufen `detectZip` und `detectPkcs7` sind weiter leere Slots; PEM-Schleife in `detectPem` hat den Kommentar für PKCS7/CMS-Etiketten. `buildChains` und `analyzeWorkingSet` brauchen für ZIP/P7B keine Änderung (Ketten entstehen aus allen erkannten Zertifikaten). `cert-names.ts` ist der Ort von `safeBaseName`.
|
||||||
|
- Web: `page.tsx` hat `TabId = 'files' | 'merge'`; neue Reiter dort in der D-23-Reihenfolge einfügen (analyze, split zwischen files und merge). Für alle Nicht-Dateien-Reiter ist `EmptyWorkspace` und die Zeile „Grundlage: …“ schon in `page.tsx` verdrahtet (`workspace.basis`/`workspace.empty`/`workspace.goToFiles`); nur noch die Reiterinhalte ergänzen. `ChainView` und `ROLE_STYLES` (aus FilesTab) sind wiederverwendbar.
|
||||||
|
- Messages: Namespace hat jetzt `tabs.merge`, `workspace.*`, `merge.*`, `chain.*`; `check-cert-messages.cjs` zählt 70 Schlüssel (Mindestzahl dort erhöhen). Umlaut-Allowlist: `vertrauen` ergänzt.
|
||||||
|
- e2e-cert.sh: `BUILT="files fullchain"`; Hilfsfunktionen `build_json <out> <content> <cert> <includeRoot> <pool...>`, `post_build <req> <out>`, `pem_subjects <antwort>` (schreibt `cert-N.pem` nach `$E2E_TMP`). Der Abschnitt files prüft jetzt zwei Ketten statt leerer `chains`.
|
||||||
|
- Rebuild dauerte rund 3 Minuten; im Hintergrund starten und mit `until grep -qx done <log>` warten (Monitor ist nicht verfügbar).
|
||||||
|
|
||||||
|
## Task 3
|
||||||
|
|
||||||
|
**Commit:** 1554ae8 `feat(cert-manager): Hersteller-ZIP, PKCS#7, eingefügter Text, Analysieren und Aufteilen` (22 Dateien, 7 neu, keine Löschungen). Nicht gepusht.
|
||||||
|
|
||||||
|
### Gates (gemessen, Stack neu gebaut mit `docker compose up -d --build api web`)
|
||||||
|
|
||||||
|
| Gate | Ergebnis |
|
||||||
|
|------|----------|
|
||||||
|
| api vitest `src/cert-manager src/module-registry` | 13 Dateien, 313 Tests grün (zip-expand 15, cert-model 21, cert-analyze 12) |
|
||||||
|
| web vitest `modules/cert-manager src/messages` | 10 Dateien, 89 Tests grün (inkl. umlaut-guard) |
|
||||||
|
| tsc api / web | ohne Fehler |
|
||||||
|
| biome lint beide Modulordner, biome check eigene Dateien | sauber |
|
||||||
|
| check-cert-messages.cjs 40 | `messages ok 107` |
|
||||||
|
| keine forge-Zertifikatparser | grün |
|
||||||
|
| `e2e-cert.sh all` | `e2e cert files ok`, `e2e cert fullchain ok`, `e2e cert zip ok` |
|
||||||
|
| gesamte `<automated>`-Kette | letzte Zeile `task3 ok` |
|
||||||
|
|
||||||
|
Live bewiesen (Abschnitt zip): Hersteller-ZIP (EC-Server, Zwischen, Wurzel, `__MACOSX`, Inner-ZIP, readme.txt) plus rsa-leaf und rsa-inter ergibt fünf Zertifikate, zwei Ketten, ignored nestedZip und unknown (readme.txt), `MACOSX` kommt in der Antwort nirgends vor; dasselbe ZIP als `bundle.dat` wird geöffnet; `rsa-chain.p7c` (DER) und `ec-chain.p7b` (PEM, EC) liefern je drei Zertifikate; Fullchain aus den aus dem ZIP erkannten EC-Zertifikaten hat zwei Blöcke und besteht `openssl verify`.
|
||||||
|
|
||||||
|
### Abweichungen
|
||||||
|
|
||||||
|
1. **[Rule 3 - Blocking] `MergeTab.test.tsx` angefasst (nicht in der Dateiliste des Plans).** `CertWorkspace` hat jetzt `addText`; die Workspace-Attrappe dieses Tests brauchte eine Zeile `addText: () => null`, sonst bricht `tsc`. Sonst unverändert.
|
||||||
|
2. **`cleanSourcePath` nach `cert-names.ts` verschoben** (gleiche Importschleifen-Vermeidung wie `safeBaseName` in Task 2): `zip-expand.ts` braucht es, `cert-analyze.ts` importierte es bisher selbst. `cert-analyze.ts` exportiert es weiter (`export { cleanSourcePath }`), die Specs laufen unverändert.
|
||||||
|
3. **Typ-Umgehung bei forge:** `forge.asn1.fromDer(bytes, options)` nimmt zur Laufzeit ein Optionsobjekt, die Typen kennen nur `boolean`. `{ decodeBitStrings: false } as unknown as boolean` mit Erklärung im Code. Ohne `decodeBitStrings: false` stimmten die Zertifikatsbytes nach dem erneuten Schreiben nicht mehr mit dem Original überein (anderer Fingerabdruck); die Spec prüft die Kennungen gegen die Einzelzertifikate.
|
||||||
|
4. Das Verschachtelte-ZIP-Erkennen geschieht zuerst am Namen (`.zip`, ohne Entpacken) und danach an den Anfangsbytes des entpackten Eintrags (ohne die Endung). Beides meldet `nestedZip`.
|
||||||
|
5. Verschlüsseltes ZIP: `encrypted-entry.zip` meldet den Eintrag mit Pfad `<zip>/rsa-leaf.pem` und `encryptedZip`. Ein ZIP, aus dem gar nichts Lesbares und nichts Ausgelassenes entsteht (leeres Archiv), ergibt `unknown` für das ZIP selbst, damit die Oberfläche nie schweigt.
|
||||||
|
6. Keine Stubs. `detectPkcs12`, `detectPrivateKey`, `detectCsr` sind weiter die benannten leeren Stufen für Task 4.
|
||||||
|
|
||||||
|
### Bedrohungsstatus
|
||||||
|
|
||||||
|
- Zip-Bombe / Speicher (T-ikt-02): alle Grenzen auf den Kopfdaten vor dem ersten `getData()` (Spec zählt Entpackungen: 0 bei zipTooLarge); Einzelgröße 1 MiB, Verhältnis 100, Summe 20 MiB, 100 Einträge, eine Ebene; Ergebnis des Entpackens wird nochmals gegen die Grenze geprüft.
|
||||||
|
- Pfade (T-ikt-12/13): Eintragsnamen nur zur Anzeige, Steuerzeichen entfernt, höchstens 255 Zeichen, nie ein Dateipfad. Web zeigt sie als React-Text.
|
||||||
|
- Kaputte Eingaben (T-ikt-03): ZIP-, PKCS#7- und Texterkennung stehen in try/catch (Zufallsbytes mit ZIP-Anfang: brokenZip, abgeschnittenes PKCS#7: unknown).
|
||||||
|
- Keine Passwörter oder Schlüssel in diesem Task im Spiel; keine Logger-Aufrufe mit Inhalten. Eingefügter Text lebt nur im Browserarbeitsbereich (D-11).
|
||||||
|
|
||||||
|
### Hinweise für Task 4
|
||||||
|
|
||||||
|
- API `cert-model.ts`: Stufen `detectPkcs12`, `detectPrivateKey`, `detectCsr` sind weiter leere Slots (Reihenfolge in `STAGES`: ZIP, PEM, DER-Zertifikat, PKCS#12, PKCS#7, Schlüssel, CSR). Die PEM-Schleife in `detectPem` hat den Kommentar für PRIVATE KEY / ENCRYPTED / CERTIFICATE REQUEST. Rekursion: `detectZip` ruft `detectBlob` je ZIP-Eintrag mit `ctx.path = "zip/eintrag"` und gleichem `file` und `passwords`; Passwörter pro Datei und Container (auch im ZIP) funktionieren damit ohne weitere Änderung der ZIP-Stufe. Ein ZIP-Eintrag, der zu einer gesperrten PFX wird, liefert `locked` mit dem Pfad des Eintrags.
|
||||||
|
- `forge` ist in `cert-model.ts` als `import * as forge from 'node-forge'` eingebunden (nur ASN.1 und util). Hilfsfunktion `pkcs7Certificates(der)` zeigt den ASN.1-Lauf; für den CSR-Lauf dieselbe Optionen-Umgehung (`decodeBitStrings: false`) verwenden, wenn Bytes unverändert bleiben sollen.
|
||||||
|
- Web: `CertWorkspace` hat `addText(text, label?)`; `WorkingEntry.origin` kennt `paste`, Dateien heißen `pasted-<n>.pem` (Nummer zählt weiter nach Entfernen). `ItemCard` nimmt nur `CertItem`; Task 4 ergänzt Karten für Schlüssel und CSR (Rollenfarben in `ROLE_STYLES` sind schon da) und die Zuordnung (`certIds`, `csrIds`, `keyId`). `AnalyzeTab` filtert aktuell `kind === 'certificate'`; Schlüssel/CSR/Gesperrte kommen dort dazu (`files.ignored.unsupportedKey` existiert). `SplitTab` ist bereits über alle Arten generisch (`.crt`, `.key`, `.csr`; Schlüssel-PEM aus `item.pem`).
|
||||||
|
- Messages: `tabs.analyze`, `tabs.split`, `analyze.*`, `split.*`, `files.paste*`, `files.pastedLabel`, `files.originPaste`, `files.rejected.pasteTooLarge` neu; `check-cert-messages.cjs` zählt jetzt 107 Schlüssel (Mindestzahl 50 im Task-4-Gate ist damit schon erfüllt, ruhig höher ansetzen). Die Umlaut-Allowlist blieb unverändert.
|
||||||
|
- e2e-cert.sh: `BUILT="files fullchain zip"`; `analyze` nimmt jetzt auch absolute Pfade (z. B. ZIP in `$E2E_TMP`) in der Form `/pfad/datei:anzeigename`. Der Abschnitt zip baut sein ZIP mit `python3 -I` nach `$E2E_TMP/vendor.zip`.
|
||||||
|
- Rebuild dauerte wieder rund 3 Minuten (im Hintergrund starten, `until grep -qx done <log>`).
|
||||||
|
|
||||||
|
## Task 4
|
||||||
|
|
||||||
|
**Commit:** fcac0a3 `feat(cert-manager): Schlüssel, PFX und CSR erkennen, Passwort je Datei` (28 Dateien, 7 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` | 16 Dateien, 389 Tests grün (cert-keys 32, cert-pkcs12 14, cert-csr 7, cert-analyze 21, cert-chain 18, controller 18) |
|
||||||
|
| web vitest `modules/cert-manager src/messages` | 10 Dateien, 106 Tests grün (inkl. umlaut-guard) |
|
||||||
|
| tsc api / web | ohne Fehler |
|
||||||
|
| biome lint beide Modulordner, biome check eigene Dateien | sauber (10 eigene Dateien mit `--write` formatiert, nur eigene Dateien) |
|
||||||
|
| check-cert-messages.cjs 50 | `messages ok 135` |
|
||||||
|
| keine forge-Zertifikat-/CSR-/PKCS#7-Leser im Produktivcode | grün |
|
||||||
|
| `e2e-cert.sh all` | `e2e cert files ok`, `fullchain ok`, `zip ok`, `inputs ok` |
|
||||||
|
| gesamte `<automated>`-Kette | letzte Zeile `task4 ok` |
|
||||||
|
|
||||||
|
Live bewiesen (Abschnitt inputs): Satz aus RSA-Server/Zwischen/Wurzel, klassisch verschlüsseltem RSA-Schlüssel, RSA-CSR, `ec-compat.pfx` und EC-CSR (DER) mit `passwords` im Takt der Dateien: sechs Zertifikate, zwei Schlüssel, zwei Anfragen, nichts gesperrt; RSA-Server trägt `keyId`, der EC-Server aus der PFX ebenfalls, beide CSRs sind Zertifikat und Schlüssel zugeordnet; `rsa-modern.pfx` ohne Passwort `passwordNeeded` (Container pkcs12), mit `falsch` `passwordWrong`; `rsa-legacy.pfx` (RC2) und `rsa-modern.bin` (ohne Endung) liefern je drei Zertifikate plus Schlüssel; verschlüsselter EC-Schlüssel (DER) gesperrt bzw. `passwordWrong`; `passwords=nope` -> 400 `invalidInput`; `docker compose logs api --since 10m` enthält weder `Test-Pass-123` noch `PRIVATE KEY`.
|
||||||
|
|
||||||
|
### Abweichungen
|
||||||
|
|
||||||
|
1. **TDD-Reihenfolge.** Die Specs für Schlüssel, PFX und CSR wurden zusammen mit der Umsetzung geschrieben, nicht vorher rot gesehen; alle Fälle der `<behavior>` sind abgedeckt und liefen beim ersten Lauf grün.
|
||||||
|
2. **`describeKey`, `keyIdOf`, `sha256Hex` nach `cert-keys.ts` verschoben** (Importschleife wie bei `cert-names` in Task 2/3: cert-keys/cert-csr/cert-pkcs12 brauchen sie, cert-model bindet sie ein). `cert-model.ts` exportiert sie weiter, alle bisherigen Importe laufen unverändert.
|
||||||
|
3. **`CertItem.keyId` und `CsrItem.keyId` sind nach dem Parser `null`** (vorher Schlüsselkennung des Zertifikats). `matchKeys` setzt sie erst, wenn der passende Schlüssel in der Menge liegt; so bedeutet „keyId gesetzt“ wirklich „passender Schlüssel vorhanden“. Die eine Prüfung in `cert-model.spec.ts` wurde angepasst.
|
||||||
|
4. **`readPkcs12(der, passwords, ownPassword = '')`:** zusätzlicher dritter Parameter, damit „Passwort falsch“ (eigenes Passwort eingegeben) von „Passwort nötig“ unterschieden wird. `DetectContext` hat dafür das optionale Feld `ownPassword`; `passwords` ist die Kandidatenliste (eigenes zuerst, höchstens 10, `candidatePasswords` in cert-keys.ts). PKCS#12 probiert zusätzlich das leere Passwort (nicht mitgezählt).
|
||||||
|
5. **Rule 3 - Blocking: `MergeTab.test.tsx` und `SplitTab.test.tsx` angefasst** (nicht in der Dateiliste): `CertWorkspace` hat jetzt `setPassword`, die Attrappen brauchten eine Zeile `setPassword: () => {}`, sonst bricht `tsc` (gleiche Lage wie `addText` in Task 3).
|
||||||
|
6. **`ItemCard` nimmt jetzt `item` (Zertifikat, Schlüssel oder Anfrage) und `items`** statt `cert`; innen gibt es `CertCard`, `KeyCard`, `CsrCard`. Einziger Aufrufer ist AnalyzeTab.
|
||||||
|
7. `forge.pki.RDNAttributesAsArray` fehlt in den Typdefinitionen: mit einer schmalen Typumgehung an einer Stelle in cert-csr.ts verwendet (liest nur den Namen; ist kein Zertifikat-/CSR-Parser).
|
||||||
|
8. Keine Stubs. `cert-types.ts` blieb unverändert (die Typen standen seit Task 1).
|
||||||
|
|
||||||
|
### Bedrohungsstatus
|
||||||
|
|
||||||
|
- T-ikt-08 (Schlüssel/Passwörter): Passwörter nur im Browserzustand und im multipart-Körper (`passwords`), nie in Adresse, Dateiname, Fehlertext, Antwort oder Log; Specs prüfen die Antwort (API) und den dargestellten Text (Web), das e2e prüft das API-Log. Fehlertexte aus OpenSSL/forge werden nicht weitergereicht.
|
||||||
|
- T-ikt-03 (kaputte Eingaben): jede Schlüssel-, PKCS#12- und CSR-Stufe in try/catch; kaputter Schlüsselblock -> `unsupportedKey`, kaputtes DER -> `unknown`; Specs mit abgeschnittenem CSR, Zufallsbytes, kaputtem Base64.
|
||||||
|
- Eingabegrenzen `passwords`: JSON-Liste aus höchstens 30 Zeichenketten zu höchstens 1024 Zeichen, sonst 400 `invalidInput` (Controller-Spec, Grenzfälle 30 x 1024 erlaubt, 31 und 1025 abgewiesen); je Datei höchstens 10 verschiedene Kandidaten (CPU-Schutz bei PBKDF).
|
||||||
|
- Der Schlüssel (unverschlüsseltes PKCS#8) steht in der `analyze`-Antwort für die Oberfläche (Task 5 braucht ihn für die Ausgabe); er wird nicht geloggt (Request-Log kennt nur Pfad und Status).
|
||||||
|
|
||||||
|
### Hinweise für Task 5
|
||||||
|
|
||||||
|
- API: `cert-keys.ts` enthält `keyItemFromObject`, `spkiDerOf`, `candidatePasswords`, `MAX_PASSWORDS`; `exportKey` ist noch nicht da (laut Plan Task 5), ebenso `writePkcs12` in `cert-pkcs12.ts` (dort `readPkcs12`, `isPkcs12Der`). `KeyItem.pem` ist immer unverschlüsseltes PKCS#8; `createPrivateKey(item.pem)` liefert das KeyObject für Export und PFX. `cert-output.ts` unverändert (Teilung/Fullchain von Task 2).
|
||||||
|
- `matchKeys(certs, keys, csrs)` in cert-chain.ts mutiert die Einträge (setzt `keyId`, `certIds`, `csrIds`) und ist wiederholbar; `analyzeWorkingSet(files, passwords)` ruft es nach dem Zusammenfassen auf. `csrPublicKeyOf(pem)` (cert-csr.ts) liefert Kennung und SPKI-DER einer Anfrage.
|
||||||
|
- Controller: `parsePasswords(raw)` (exportiert) liest das Feld `passwords`; `analyze(files, rawPasswords?)`.
|
||||||
|
- Web: `WorkingEntry.password` wird jetzt befüllt (`setPassword(id, password)` im Hook, `setEntryPassword` in working-set.ts); `toFormData` sendet `passwords` nur, wenn mindestens eins gesetzt ist. `PasswordInput` (Label, Wert, Anzeigen/Verbergen) ist wiederverwendbar, z. B. für das PFX-Passwort im Konvertieren-Reiter. `ItemCard` ist über `item`/`items` generisch; `FilesTab` zeigt gesperrte Dateien mit `UnlockPanel` (ruhiger Hinweis + „Trotzdem entsperren“, wenn schon ein Serverzertifikat mit `keyId` vorliegt und nur eine PFX ohne Passwort gesperrt ist).
|
||||||
|
- Messages: neu `files.locked.*`, `files.keyWasEncrypted`, `passwordInput.*`, `analyze.matchingKey/keysTitle/csrsTitle/lockedTitle/lockedNeeded/lockedWrong/lockedHint/wasEncrypted/belongsTo/noCertificateYet/subject/matchedKey/matchedCertificates/matchedCsr/present/absent/explainKey/explainCsr`; `check-cert-messages.cjs` zählt jetzt 135 Schlüssel (Mindestzahl in Task 5 erhöhen). Umlaut-Allowlist: `Passender`, `Passendes`, `Passende` ergänzt.
|
||||||
|
- e2e-cert.sh: `BUILT="files fullchain zip inputs"`; `analyze_pw <passwörter-json> <out> <datei[:name]>...` schickt das Feld `passwords` (nur für diesen Aufruf, lokale Variable `ANALYZE_PASSWORDS`). Abschnitt inputs prüft am Ende das API-Log der letzten 10 Minuten auf Passwort und Schlüssel.
|
||||||
|
- Rebuild dauerte wieder rund 3 Minuten (im Hintergrund starten, `until grep -qx done <log>`).
|
||||||
|
|
||||||
|
## Task 5
|
||||||
|
|
||||||
|
**Commit:** 65a1dca `feat(cert-manager): alle Ausgabeformate, Konvertieren, Bundle und PFX` (18 Dateien, 3 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` | 16 Dateien, 449 Tests grün (cert-output 45, cert-pkcs12 22, cert-keys 39, controller 28) |
|
||||||
|
| web vitest `modules/cert-manager src/messages` | 11 Dateien, 121 Tests grün (inkl. umlaut-guard; ConvertTab 7, MergeTab 17) |
|
||||||
|
| tsc api / web | ohne Fehler |
|
||||||
|
| biome lint beide Modulordner, biome check eigene Dateien (api cert-manager, web components, page, actions, working-set(+test), use-cert-workspace, Seitentest, de/en.json, umlaut-dictionary) | sauber |
|
||||||
|
| check-cert-messages.cjs 60 | `messages ok 176` |
|
||||||
|
| keine forge-Zertifikat-/CSR-/PKCS#7-Leser im Produktivcode | grün |
|
||||||
|
| `e2e-cert.sh all` | `files`, `fullchain`, `zip`, `inputs`, `formats` ok |
|
||||||
|
| gesamte `<automated>`-Kette | letzte Zeile `task5 ok` |
|
||||||
|
|
||||||
|
### Output-Punkt 2 (openssl liest die Ausgaben, live gegen die API)
|
||||||
|
|
||||||
|
`openssl pkcs12 -info -noout -passin pass:Neu-Pass-2026` (RSA; EC verhält sich gleich und ist im Abschnitt ebenfalls geprüft):
|
||||||
|
|
||||||
|
```
|
||||||
|
kompatibel: MAC: sha1, Iteration 2048
|
||||||
|
Shrouded Keybag: pbeWithSHA1And3-KeyTripleDES-CBC, Iteration 2048
|
||||||
|
modern: MAC: sha1, Iteration 2048
|
||||||
|
Shrouded Keybag: PBES2, PBKDF2, AES-256-CBC, Iteration 2048, PRF hmacWithSHA1
|
||||||
|
```
|
||||||
|
|
||||||
|
`openssl pkcs7 [-inform DER] -print_certs` der Fullchain mit Wurzel, p7b | p7c, gleiche Reihenfolge Server, Zwischen, Wurzel:
|
||||||
|
|
||||||
|
```
|
||||||
|
rsa: www.example.test,Tessera Test Inter RSA,Tessera Test Root RSA | www.example.test,Tessera Test Inter RSA,Tessera Test Root RSA
|
||||||
|
ec: ec.example.test,Tessera Test Inter EC,Tessera Test Root EC | ec.example.test,Tessera Test Inter EC,Tessera Test Root EC
|
||||||
|
```
|
||||||
|
|
||||||
|
Außerdem live (RSA und EC): leaf DER (`openssl x509 -inform DER`), leaf p7b mit genau einem Zertifikat, Fullchain ohne Wurzel p7b mit zwei, Nur Kette p7c nur mit dem Zwischenzertifikat, Bundle (erstes Zertifikat der Server, zwei Zertifikate, `openssl pkey -pubout` gleich `openssl x509 -pubkey`), in beiden PFX-Profilen Schlüssel und Serverzertifikat passen zusammen, PKCS#8 mit Passwort (ENCRYPTED PRIVATE KEY, `openssl pkey -passin` ok), klassisch `BEGIN RSA PRIVATE KEY` / `BEGIN EC PRIVATE KEY`, DER mit Passwort, CSR als DER und PEM (`openssl req -subject`). Fehlercodes live: `passwordRequired`, `keyMismatch`, `keyMissing`, `invalidInput` (csr mit p7b). `docker compose logs api --since 10m` enthält weder `Test-Pass-123` noch `Neu-Pass-2026` noch `PRIVATE KEY`.
|
||||||
|
|
||||||
|
### Abweichungen
|
||||||
|
|
||||||
|
1. **TDD-Reihenfolge.** Specs für `exportKey`, `writePkcs12`, `buildOutput`, DTO und die Web-Reiter wurden vor der Umsetzung geschrieben, aber nicht einzeln rot gesehen (gleiche Lage wie Task 4); alle Fälle der `<behavior>` laufen grün.
|
||||||
|
2. **`actions.ts` unverändert.** Die Typen und `buildOutput` mit dem vollen `BuildInput` standen seit Task 1 dort; es gab nichts anzupassen, die Datei ist nicht im Commit. Aus demselben Grund fehlt `cert-json-body.spec.ts` im Commit: die Größtanfrage (Task 2) deckt keyPem, csrPem und password schon ab und läuft unverändert grün.
|
||||||
|
3. **Neue Eingabegrenze/Fehlerabbildung in `buildOutput`:** unbekannter Inhalt oder Format-Inhalt-Paar ergibt jetzt `invalidInput` (vorher `formatNotPossible`); `formatNotPossible` bleibt für „klassisch“ bei Schlüsseln, die weder RSA noch EC sind. Ein `keyPem`, den Node nicht öffnet (etwa verschlüsselt), ergibt `invalidInput`, ohne Schlüsseltext in der Meldung.
|
||||||
|
4. **Schlüssel- und Anfrage-Ausgabe ohne `certPem`:** `key` und `csr` brauchen kein Zertifikat (Konvertieren-Reiter); der Dateiname kommt aus `baseName`, sonst `schluessel` bzw. der Name der Anfrage. Nur „Zertifikat“-Inhalte verlangen `certPem`.
|
||||||
|
5. **PFX-Anzeigename als BMPString:** `friendlyName` geht UTF-16BE-kodiert an forge (forge schreibt den Wert sonst roh in ein BMPString-Feld und openssl zeigte Zeichensalat).
|
||||||
|
6. **Konvertieren sendet bei Schlüsseln `baseName` des zugehörigen Zertifikats** (aus `certIds`), damit die Datei nicht „schluessel.key“ heißt; bei Zertifikaten kein `baseName` (API nimmt den eigenen).
|
||||||
|
7. **Rule 3 - Blocking:** `umlaut-dictionary.ts` um `passwortgeschützte` und `AES` ergänzt (Guard).
|
||||||
|
8. **PFX ohne passenden Schlüssel bleibt erlaubt** (nur Zertifikate, mit ruhigem Hinweis in der Oberfläche); die API verlangt den Schlüssel dafür nicht (laut Plan: „without key → certificates only“).
|
||||||
|
9. Keine Stubs.
|
||||||
|
|
||||||
|
### Bedrohungsstatus
|
||||||
|
|
||||||
|
- T-ikt-08 (Schlüssel/Passwörter): Passwörter und Schlüssel nur im Anfragekörper; nirgends in Antwort (Spec: PFX- und Schlüssel-Antwort enthalten das Passwort nicht), Fehlertext, Dateiname oder Log (e2e prüft das API-Log). Passwortfelder der Oberfläche bleiben im Reiterzustand, Specs prüfen, dass sie nicht im dargestellten Text stehen. Fehlertexte aus node:crypto/forge werden nicht weitergereicht.
|
||||||
|
- Anfragegrenzen: DTO-Whitelist (ValidationPipe) entfernt Fremdfelder; neue Felder mit `MaxLength` (`keyPem`, `csrPem` 16 384, `password` 256), `IsIn` für Inhalt, Format und `pfxEncryption`; die Größtanfrage bleibt unter der 512-KiB-Grenze von `build` (Spec aus Task 2 läuft unverändert).
|
||||||
|
- Forge-Austausch beim PFX-Schreiben (D-20): ein einziger synchroner Aufruf, die drei Funktionen stehen im `finally` wieder an ihrem Platz; Spec prüft das nach Erfolg und nach einem Fehler mitten in `toPkcs12Asn1` (der Durchreicher war währenddessen wirklich eingesetzt).
|
||||||
|
- Zuordnung: PFX und Bundle prüfen den Schlüssel gegen das Serverzertifikat mit `checkPrivateKey` (Falsch: 400 `keyMismatch`), nie nach Namen.
|
||||||
|
|
||||||
|
### Hinweise für Task 6
|
||||||
|
|
||||||
|
- API: `buildOutput` in `cert-output.ts` ist die eine Funktion; Hilfsfunktionen darin sind `parseCertificate`, `parseKey`, `matchingKey(head, keyPem)`, `pkcs7Der`, `certListFile`, `joinPem`, `derOf`, `file(filename, data, mime)`. Die Kette (`shown` = Pfad ohne Wurzel, mit Wurzel bei `includeRoot`) wird dort einmal gebaut; Vorlagen können `path`/`shown`/`head` ebenso nutzen (einen eigenen `case` oder eine eigene Funktion `buildTemplate` in `cert-templates.ts`, die `buildOutput` für `template` aufruft). `writePkcs12({ keyObject, certDers, password, profile, friendlyName })` und `exportKey(key, 'pkcs8' | 'traditional' | 'pkcs8-der', password?)` sind fertig; für NPM: `exportKey(key, 'traditional')` liefert PKCS#1 (RSA) bzw. SEC1 (EC) unverschlüsselt. Fehler: `certError('templateNeedsKey', 400, ...)` ist im Typ schon da.
|
||||||
|
- DTO `dto/cert-build.dto.ts`: `BUILD_CONTENTS`, `BUILD_FORMATS`, `BUILD_PFX_ENCRYPTIONS`; `template` (IsIn der Vorlagenkennungen) und gegebenenfalls Inhalt `template` fehlen noch. Das `BuildInput`-Feld `template?: string` und `BuildResult.snippet` stehen schon in `cert-types.ts` und `actions.ts`. Die Formattabelle `FORMATS` in `cert-output.ts` legt je Inhalt die erlaubten Formate fest; ein neuer Inhalt `template` braucht dort einen Eintrag (oder die Vorlage läuft vor der Formatprüfung).
|
||||||
|
- Web: `PfxOptions` (`password`, `repeat`, `encryption`, Callbacks) und `pfxPasswordReady(password, repeat)` sind für den IIS- und Tomcat-Vorgabewert wiederverwendbar (Kompatibel ist Vorgabe, `PfxEncryption` exportiert). `PasswordInput` bleibt das Passwortfeld. In `page.tsx` ist `TabId` jetzt `files | analyze | split | merge | convert`; `templates` kommt hinter `convert` (D-23).
|
||||||
|
- Messages: neu `tabs.convert`, `merge.formatLabel/formats.*/bundle*/pfx*/downloadBundle/downloadPfx`, `pfx.*`, `convert.*`; `check-cert-messages.cjs` zählt jetzt 176 Schlüssel (Mindestzahl in Task 6 höher ansetzen). Umlaut-Allowlist: `passwortgeschützte`, `AES` ergänzt.
|
||||||
|
- e2e-cert.sh: `BUILT="files fullchain zip inputs formats"`; neue Hilfen `mkbody <out> '<json mit @fixture>'` (Werte, die mit `@` beginnen, werden durch den Inhalt der Fixture ersetzt), `build_files <ordner> <json>` (200 erwartet, legt alle Antwortdateien im Ordner ab) und `build_fails <status> <code> <json>`. 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 `<automated>`-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 `<behavior>` 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 `<automated>`-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 <geholt> <blatt>` 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 `<behavior>` 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 `<automated>`-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 <noreply@anthropic.com>` (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) |
|
||||||
+77
@@ -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)_
|
||||||
+53
@@ -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 <Mindestanzahl Schluessel>
|
||||||
|
// 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}`);
|
||||||
+922
@@ -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 <ausgabe> <datei[:name]>... — schickt Fixtures (oder absolute Pfade) als multipart, gibt den HTTP-Status aus.
|
||||||
|
# analyze_pw <passwords-json> <ausgabe> <datei[:name]>... — 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 <ausgabe-json> <content> <cert> <include-root> <pool-datei>... — 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 <anfrage-json-datei> <ausgabe> — 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 <antwort-json> — 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 <ausgabe-json> <json> — 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 <ordner> <json> — 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 <erwarteter-status> <erwarteter-code> <json> — 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 <ausgabe> <json-body> — 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 2>/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("<I", data, at + 24, 0) # deklarierte Groesse im Zentralverzeichnis
|
||||||
|
at = data.find(sig, at + 4)
|
||||||
|
open(path, "wb").write(data)
|
||||||
|
PY
|
||||||
|
[ "$(stat -c %s "$E2E_TMP/bomb.zip")" -lt 1000000 ] || e2e_fail "CR-01: Bomben-ZIP ist nicht klein"
|
||||||
|
secs=$(curl -s -o "$out" -w '%{time_total}' -b "$JAR" -F "files=@$E2E_TMP/bomb.zip;filename=bomb.zip" "$API/modules/cert-manager/analyze")
|
||||||
|
python3 -I - "$out" "$secs" <<'PY' || e2e_fail "CR-01: ZIP-Bombe nicht abgewiesen"
|
||||||
|
import json, sys
|
||||||
|
d = json.load(open(sys.argv[1]))
|
||||||
|
assert d["items"] == [], d
|
||||||
|
assert [(i["path"], i["reason"]) for i in d["ignored"]] == [("bomb.zip/bombe.pem", "tooLarge")], d["ignored"]
|
||||||
|
assert float(sys.argv[2]) < 5, f"zu langsam: {sys.argv[2]}"
|
||||||
|
PY
|
||||||
|
curl -s -o /dev/null -w '%{http_code}' "$API/health" | grep -q 200 || e2e_fail "CR-01: API nach der ZIP-Bombe nicht gesund"
|
||||||
|
|
||||||
|
# CR-02: 5 MiB lauter BEGIN-Zeilen ohne END
|
||||||
|
python3 -I -c 'import sys; open(sys.argv[1],"w").write("-----BEGIN CERTIFICATE-----\n"*185000)' "$E2E_TMP/begins.pem"
|
||||||
|
secs=$(curl -s -o "$out" -w '%{time_total}' -b "$JAR" -F "files=@$E2E_TMP/begins.pem;filename=begins.pem" "$API/modules/cert-manager/analyze")
|
||||||
|
python3 -I - "$out" "$secs" <<'PY' || e2e_fail "CR-02: BEGIN-Zeilen nicht schnell verarbeitet"
|
||||||
|
import json, sys
|
||||||
|
d = json.load(open(sys.argv[1]))
|
||||||
|
assert d["items"] == [], d
|
||||||
|
assert float(sys.argv[2]) < 2, f"zu langsam: {sys.argv[2]}"
|
||||||
|
PY
|
||||||
|
|
||||||
|
# CR-03: Umlaut- und Euro-Passwoerter, Hin- und Rueckweg mit openssl
|
||||||
|
local pw cert key enc
|
||||||
|
for pw in 'pässwörd' 'pw€'; do
|
||||||
|
for enc in compat modern; do
|
||||||
|
dir="$E2E_TMP/umlaut-$enc-${#pw}"
|
||||||
|
build_files "$dir" "{\"content\":\"pfx\",\"certPem\":\"@rsa-leaf.pem\",\"poolPems\":[\"@rsa-inter.pem\"],\"keyPem\":\"@rsa-leaf-key.pem\",\"password\":\"$pw\",\"pfxEncryption\":\"$enc\"}"
|
||||||
|
openssl pkcs12 -in "$dir/www.example.test.pfx" -passin "pass:$pw" -noout \
|
||||||
|
|| e2e_fail "CR-03: openssl liest $enc-PFX mit Passwort $pw nicht"
|
||||||
|
openssl pkcs12 -in "$dir/www.example.test.pfx" -passin "pass:falsch" -noout 2>/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 "$@"
|
||||||
Reference in New Issue
Block a user