# 📋 OpenCRM – Todo-Liste --- ## 🔜 Offen ### Manuelle Tests (vor Release durchklicken) Checklisten fĂŒr Security + Email-Log-System stehen in **[TESTING.md](./TESTING.md)**. Einmal komplett durchlaufen vor v1.0.0-Release. ### 🚀 SaaS-Ausbau: Instance-per-Customer + Admin-Portal + GoCardless **Vision:** OpenCRM als SaaS anbieten. Jeder Kunde bekommt seine eigene isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ĂŒber ein zentrales Admin-Portal. **Architektur-Entscheidung:** Weg C (Instance-per-Customer) - Pro Kunde eine eigene Docker-Instanz mit eigener DB - Keine `tenantId` im CRM-Code → keine Security-Risiken durch vergessene Filter - Komplette Datenisolation (DSGVO-freundlich) - Updates können gestaffelt ausgerollt werden (erst 10% testen) - Bei KĂŒndigung: Docker-Image + DB-Export als "Mitnehm-Paket" **Bewusst NICHT dabei:** eigener Mailserver. Stattdessen Plesk-Integration (die wir schon haben) – Kunde bekommt Mail-Zugang ĂŒber unseren Plesk bei Bedarf. --- **Admin-Portal (separate App, neben den CRM-Instanzen):** - Kundenverwaltung: wer hat welchen Plan, Status (Trial/Active/Suspended/Cancelled) - "Neuen Kunden anlegen" → Provisioning-Script - DB anlegen (Master-DB kennt die Mapping) - Docker-Container starten - Subdomain konfigurieren (`kundenname.deincrm.de` via Caddy/Traefik) - Initial-Admin-Account erstellen + Einladungs-Email senden - Optional: Factory-Defaults fĂŒr Stammdaten einspielen - GoCardless-Integration (Webhook + Dashboard) - Instanz-Management: Pause/Resume bei Zahlungsproblemen - Logs & Metriken pro Instanz (optional) - Support-Bereich (Tickets? oder einfach E-Mail) --- **Abrechnung mit GoCardless (gocardless.com):** - Zahlungsmethoden: SEPA-Lastschrift (Hauptfokus) + Kreditkarte (ĂŒber GoCardless Embedded/Success) - 30 Tage kostenlose Testphase ohne Zahlungsmittel - Nach Trial: Mandats-Erfassung → regelmĂ€ĂŸige Abbuchung - Mehrere PlĂ€ne (z.B. Basic / Pro / Enterprise) mit unterschiedlichen Features - Webhook-Endpoint im Admin-Portal: - `payment_confirmed` → Instanz aktiv lassen - `payment_failed` → Banner im CRM, nach X Tagen pausieren - `mandate_cancelled` → KĂŒndigungs-Flow - Rechnungsstellung: GoCardless liefert Zahlungsbelege, aber **echte Rechnungen** (mit USt-ID, Rechnungsnummer etc.) mĂŒssen wir selbst generieren (evtl. ĂŒber das existierende PDF-Template-System aus dem CRM nutzen) --- **Provisioning-Flow (grober Entwurf):** 1. Kunde registriert sich auf Landing Page (Name, Firma, E-Mail, Wunsch-Subdomain) 2. Admin-Portal: Trial-Instanz starten - DB erstellen, Docker-Container hochfahren, Caddy-Config fĂŒr Subdomain - Einladungs-Email mit Admin-Login + Passwort-Reset-Link 3. Tag 25: Erinnerungs-Email "Deine Trial lĂ€uft bald ab" 4. Tag 30: Banner im CRM "Jetzt bezahlen oder pausieren" 5. Kunde erfasst GoCardless-Mandat im Admin-Portal-Login 6. Bei erfolgreicher Zahlung: Instanz bleibt aktiv 7. Bei fehlender Zahlung nach 7 Tagen: Instanz pausiert (DB bleibt, UI zeigt Hinweis) --- **Technische Bausteine fĂŒr spĂ€ter:** - Master-DB mit Tenant-Tabelle (Name, Subdomain, DB-Name, Plan, Status, GoCardlessIDs) - Caddy oder Traefik als Reverse-Proxy mit Auto-SSL (Let's Encrypt) - Docker-Orchestrierung: einzelne `docker-compose.yml` pro Kunde oder Docker-Swarm/K8s - Backup-Strategie: pro Tenant separate Backups + zentrale Master-DB-Backups - Monitoring: ein Fail macht nicht alle down, aber wir mĂŒssen es mitbekommen - Logs zentral: z.B. Loki + Grafana fĂŒr aggregierte Logs aller Instanzen --- **Grobe ZeitschĂ€tzung:** - Admin-Portal (MVP): ~1 Woche - GoCardless-Integration + Webhooks: ~3-5 Tage - Provisioning-Automatisierung (Docker + Caddy): ~1 Woche - Landing Page + Checkout: ~3-5 Tage - Tests + Polishing: ~1 Woche - **Gesamt: ~3-4 Wochen** **Vorbereitung JETZT (einfach, macht spĂ€ter Arbeit leichter):** - ✅ Factory-Defaults System (schon erledigt, hilft beim Provisioning) - ✅ Domain/Label dynamisch per Provider (schon erledigt) - Docker-Compose aufrĂ€umen, Env-Variablen dokumentieren (klein, ein Tag) - Backup-Script robust + wiederherstellbar (haben wir schon weitgehend) --- ## ✅ Erledigt - [x] **🐳 Gegenbuch als Docker-Setup, lokales Buch auf eigener Maschine** (2026-08-22) - Betreiber-Entscheidung: Das Gegenbuch laeuft auf einer **eigenen Maschine** fuer Prod und Staging; ein externes Git-Repository entfaellt, das Buch liegt lokal auf dieser Maschine. Die Trennung, auf die es ankommt, ist damit gegeben – wer OpenCRM uebernimmt, kommt nicht ans Buch. - Richtung bewusst so herum: Das Gegenbuch **holt** ueber HTTPS mit einem Token, das nur `audit:read` kann. OpenCRM kennt weder Adresse noch Schluessel des Gegenbuchs. Kein SSH-Zugang zum CRM noetig – das Token kann ausschliesslich Pruefwerte lesen. - Unterverzeichnis `tools/audit-notary/`: `Dockerfile`, `entrypoint.sh`, `docker-compose.yml`, `.env.example`. Zwei Dienste (`prod`, `staging`) mit getrennten Verzeichnissen und Schluesseln, gesteuert ueber `COMPOSE_PROFILES` – dasselbe Muster wie beim Caddy-Profil im Hauptstack. Der Signaturschluessel wird beim ersten Start auf der Gegenbuch-Maschine erzeugt und verlaesst sie nie. - **Lokaler Betrieb ist jetzt ein vollwertiger Modus**, kein Testschalter mehr. Die Erfolgsmeldung benennt bei jedem Lauf, was abgedeckt ist („jemand veraendert Daten im CRM“) und was nicht („jemand uebernimmt diesen Rechner hier“) – statt der frueheren pauschalen Formulierung „kein Manipulationsschutz“, die im Einsatz auf eigener Maschine schlicht falsch war. - Jeder Durchlauf schreibt `status.txt` fuer die Ueberwachung; alles ausser `exit=0` gehoert angesehen. - Verifiziert mit echtem Docker-Build gegen eine CRM-Attrappe: Genesis ohne Bestaetigung → Code 4; mit Bestaetigung → Buch angelegt, Normalbetrieb exit 0; **Eintrag nachtraeglich veraendert → Alarm exit 2**; **Eintraege geloescht (maxId geschrumpft) → Alarm exit 2**; Siegel verschwunden → Alarm. Testartefakte und Image geloescht. - **Daten im Projektverzeichnis, keine Docker-Volumes** (Projektkonvention): Bind-Mount auf `tools/audit-notary/data//`. Der Inhalt ist per `.gitignore` ausgenommen – dort liegt der Signaturschluessel –, die Verzeichnisse selbst bestehen ueber `.gitkeep`. Ohne das haette Docker sie beim ersten Start als `root` angelegt und der Container (UID 1000) haette nicht schreiben koennen. - Behoben beim Bauen: `useradd -u 1000 || true` verschluckte, dass UID 1000 im Node-Image bereits vergeben ist – der Container startete gar nicht. Jetzt laeuft er als der vorhandene Benutzer `node`. - [x] **🧼 Gegenbuch: Pruefmodus schreibt nicht mehr, Widerspruch aufgeloest (Pentest R181)** (2026-08-18) - **R181-01 (LOW→MED)** – Der Pruefmodus sagte zu, nichts zu veraendern und kein Schreibrecht zu brauchen – und pushte trotzdem: `ankerNachziehen()` lief in JEDEM Modus. Ein „read-only“ Audit mutierte damit still das geteilte Substrat. Fix: Nachziehen nur im Schreiblauf. - **R181-02 (MEDIUM) – ein Widerspruch zwischen meinen EIGENEN Fixes.** R180-01 erhebt die Serversperre auf `refs/notary/*` zur tragenden Pflicht; R180-02 verlangt genau dort Schreibrecht zur Selbstheilung. Ergebnis: Sobald je ein Anker fehlte, bekam jeder read-only pruefende Auditor dauerhaft einen Fehler auf einer **voellig gueltigen** Kette – und konnte ihn nicht beheben. Fix: Reparieren ist Sache des Notar-Laufs; im Pruefmodus wird der fehlende Anker gemeldet, nicht repariert. - **Eigener Rueckgabecode 5** (seine Frage b): „Anker unvollstaendig“ ist NICHT „nicht feststellbar“. Die Kette ist geprueft und gueltig, nur das Substrat-Gedaechtnis ist unvollstaendig – ein benannter, reparierbarer Defekt mit klarer Handlungsanweisung. Ihn in Code 3 zu werfen hiesse, eine Anweisung als Ungewissheit zu melden; dieselbe Trennung wie bei Genesis/Adoption. - **Seine Frage (a) empirisch beantwortet:** Die Notar-Identitaet laesst sich eng auf das ANLEGEN von `refs/notary/*` beschraenken, ohne ihr Loeschen oder Ueberschreiben zu geben – serverseitig unterscheidbar an der Null-OID beim Anlegen. Mit `pre-receive`-Hook verifiziert: Backfill greift (exit 0), Loeschen und Force-Overwrite werden weiterhin abgewiesen. Der Hook steht als Beispiel in der README. - Verifiziert: read-only `--check` mit fehlendem Anker → **Code 5, nichts gepusht** (Anker-Anzahl vorher/nachher identisch); Notar-Schreiblauf unter derselben ACL → „Fehlende Anker nachgetragen: 1“, exit 0. - Seine Non-Findings uebernommen: Die Backfill-Zuordnung ist gegen das PIN-Modell robust – jeder Commit mit echter Ledger-Aenderung ist non-merge und muss keyA-signiert sein, sig-exempte Merges tragen keinen Inhalt in die Zuordnungsliste. - [x] **🛑 Gegenbuch: Anker belegen nichts mehr, Anker-Verlust wird laut (Pentest R180-01/-02)** (2026-08-18) - **R180-01 (MEDIUM)** – Genau die Unsicherheit, die ich selbst benannt hatte, bestaetigt: Der Code nahm den **hoechsten noch vorhandenen** `refs/notary/seq-*` als „hoechsten je“. Loescht ein Angreifer nur die OBEREN Anker und laesst einen niedrigeren stehen, senkt er den Vergleichswert selbst – ein frischer Auditoren-Klon meldete `OK`, exit 0 auf gewaschenem Stand. Perverser Gradient: Wer ALLE Refs loeschte, flog auf (Code 4); wer weniger loeschte, kam durch – weil `ankerBelegt` sowohl die Gewissheit begruendete als auch den Code-4-Diskriminator kurzschloss. Fix: **Anker begruenden keine Gewissheit mehr.** Sie koennen ein Zurueckspulen *widerlegen* (hoher Anker nicht mehr im Kopf → Alarm), aber nie Unversehrtheit *belegen*. Der Code-4-Diskriminator haengt nicht mehr an ihnen und greift nur noch im Schreiblauf (ein Auditor hatte nie ein Gedaechtnis, ihm Code 4 vorzuwerfen waere Laerm). - **R180-02 (LOW→MED)** – Der Anker-Push-Fehlschlag war nur eine Warnung mit exit 0, ausgerechnet bei der tragenden Eigenschaft: `main` stand auf dem neuen Checkpoint, der Anker fehlte, niemand holte es nach. Der **Normalbetrieb** senkte damit den Hoechststand still um eins und riss das R180-01-Fenster ohne Angriff auf. Fix: Anker-Push-Fehlschlag → **exit 3**; und jeder Lauf **zieht fehlende Anker nach**, bevor er irgendetwas als OK meldet (Zuordnung ueber die n-te Aenderung am Gegenbuch). - Verifiziert: Teil-Loeschung (main auf seq-2, Anker 3–5 weg, DB gekuerzt), frischer Auditoren-Klon → **exit 3 mit Vorbehalt statt exit 0** (das falsche Gruen ist weg; erkannt wird es in-repo weiterhin nicht, das geht ohne Ref-Schutz nicht); Anker-Push per Hook abgelehnt → **exit 3** statt Hinweis; Folgelauf → „Fehlende Anker nachgetragen: 1“, Anker wieder vollstaendig. - **README:** Der Schutz von `refs/notary/*` gegen Loeschen und Ueberschreiben ist von der Fussnote zur **tragenden Voraussetzung** erhoben – mit der Begruendung, warum Anker nichts belegen koennen, und dem ausdruecklichen Hinweis, dass ein `--check` aus frischem Klon eine Anker-Loeschung nicht erkennt. - [x] **🧠 Gegenbuch: Gedaechtnis ins Substrat verlegt, Verlust laut behandelt (Pentest R180)** (2026-08-18) - Sein Durchrechnen war zwingend: „erste Beobachtung → exit 3“ galt **genau einen Lauf**. Danach baselinete der Waechter auf den AKTUELLEN Kopf – nach einem Rewind also auf den gewaschenen Stand – und meldete dauerhaft gruen. Der Angreifer musste nur **ein einziges exit 3** ueberstehen, ausgerechnet den Code, den der Betreiber bei Remote-Ausfaellen ohnehin staendig sieht. Damit war der Waechter, der Rewind OHNE Notar-Host-Integritaet fangen sollte, wieder an genau diese Integritaet gekoppelt. - **Eigentlicher Fix (sein Vorschlag): Gedaechtnis ins Append-only-Substrat.** Jeder verankerte Checkpoint bekommt einen eigenen Ref `refs/notary/seq-N`. Der ueberlebt einen Force-Push auf den Zweig, weil er ein anderer Ref ist – die hoechste je existierende Nummer ist damit aus dem Server rekonstruierbar. Geprueft wird: ist der hoechste verankerte Checkpoint noch im aktuellen Kopf enthalten, und ist die Reihe mindestens so lang wie verankert. - **Laute Verlustbehandlung mit seinem Diskriminator** („traegt der Remote schon Checkpoints?“): keine Historie → echte Genesis, einmalig `NOTARY_GENESIS_ACK=true`; Historie vorhanden, aber kein Gedaechtnis → **Anomalie**, eigener **Code 4**, **keine stille Adoption**, erst nach `NOTARY_ADOPT_ACK=true`. Eine Baseline zu setzen heisst „ich verbuerge mich, DAS ist der wahre Kopf“ – dieser Akt wird nicht automatisch wiederholt. - Verifiziert: Genesis ohne Bestaetigung → **Code 4**; mit Bestaetigung → Kette aufgebaut, `refs/notary/seq-1..5` auf dem Server; Rewind auf Checkpoint 2 → Alarm **exit 2**, und zwar **auch nach Loeschen des lokalen Speichers** und bei jedem Folgelauf (vorher: ein exit 3, danach dauerhaft gruen); zusaetzlich die Anker-Refs geloescht → **Code 4** statt stiller Uebernahme, erst nach ausdruecklicher Bestaetigung weiter (dann exit 3 mit Vorbehalt, nicht gruen). Labor und Port geraeumt. - **Ehrlich dokumentiert:** Die Anker-Refs muessen serverseitig ebenfalls vor Loeschen/Ueberschreiben geschuetzt sein – sonst verschiebt sich das Problem nur eine Ebene weiter. Server-API-Abfrage der Protection bleibt bewusst weg (er hat sie selbst nur als Plausibilisierung eingeordnet). - [x] **👀 Gegenbuch: Rewind-Waechter statt Probe-Push, Tor vor dem Anhaengen (Pentest R179 a/b)** (2026-08-18) - **(a) Probe-Push verworfen – seine Analyse war zwingend.** Ein Testpush haette (1) nur den geprobten Ref beurteilt, nicht `main`; (2) die EIGENE Push-Identitaet gemessen statt die des Angreifers – Bypass-Rechte fuer Admins gehen genau dann auseinander, wenn es zaehlt; (3) nur einen Zeitpunkt abgedeckt und damit die Praesenz-erzeugt-Zuversicht-Falle aus R179-01 neu gebaut. Und: einen zerstoerungsfreien Force-Push gibt es nicht – die bestaetigende Beobachtung waere derselbe Vorgang wie der Schaden. - Stattdessen **Fast-Forward-Waechter**: Bei jedem Lauf wird der beobachtete Remote-Kopf **ausserhalb des Klons** festgehalten (`~/.opencrm-notary/beobachtungen.jsonl`, per `NOTARY_STATE_FILE` aenderbar). Beim naechsten Lauf muss der neue Kopf ein Nachfahre des alten sein – sonst Alarm. Das erkennt das **Ereignis** statt die Regel abzufragen und wirkt unabhaengig davon, ob serverseitiger Schutz existiert. Ein belegter Fast-Forward gilt als Nachweis und blendet den Rewind-Vorbehalt aus (im Gegensatz zur blossen Praesenz eines MIN_SEQ-Werts). - **(b) Code 3 als Tor VOR dem Anhaengen, nicht als Status danach.** Sein Argument: Der Schreiblauf signiert mit dem neuen Checkpoint zugleich ueber den Bestand darunter – ist die Basis ungeklaert, waere das Anhaengen selbst das Waschmittel. Jetzt: Grundlage nicht feststellbar → **nichts anhaengen, exit 3**. Erster Lauf ueberhaupt → Basislinie setzen, aber ehrlich als solche melden (exit 3, kein gruenes Licht). Anhaengen geklappt, Push gescheitert → **exit 3 mit „erstellt, aber NICHT verankert“** statt 0 oder 1. Nur Anhaengen + Push + belegte Verankerung → exit 0. - Verifiziert: Basislinie → exit 3; Folgelauf → exit 0 ohne Vorbehalt; **Rewind aus einem FRISCHEN Auditoren-Klon ohne MIN_SEQ und ohne Zusicherung → Alarm exit 2** (das war bisher stilles Gruen); Push-Ziel kaputt → „erstellt, aber NICHT verankert“ exit 3, Folgelauf haelt den ungepushten Commit fail-closed an (exit 2). Labor und Port geraeumt. - [x] **🚩 Gegenbuch: Unsicherheit erreicht jetzt den Rueckgabecode (Pentest R179)** (2026-08-18) - **R179-b – der wichtigere Punkt:** Mein Rewind-Vorbehalt stand in der Ausgabe, der Exit blieb **0**. Meine eigene README sagt „fuer Cron gilt: jeder Code ausser 0 gehoert gemeldet“ – der Zustand „ich bin an dieser Stelle blind“ erreichte die Ueberwachung also **nie**. Dieselbe Klasse wie R174 (vergrabener Vorbehalt), eine Ebene hoeher. Fix: Code **0 nur bei belegter Gewissheit** – entweder ist serverseitiger Rewind-Schutz zugesichert oder eine mitgegebene Mindesthoehe wurde erfuellt. Sonst **Code 3**, dieselbe Bedeutung wie beim unerreichbaren Remote und bewusst NICHT mit dem Manipulationsalarm (2) verschmolzen. - **R179-01 (MEDIUM)** – `NOTARY_MIN_SEQ` ist eine **Untergrenze**, kein Ist-Stand. Ein veralteter Wert liess einen Teil-Rewind darueber lautlos durch; schlimmer: die blosse **Praesenz** einer Zahl blendete den Vorbehalt aus, ein `MIN_SEQ=0` war damit ein Freibrief und ein Tippfehler (`NaN`) wurde still verschluckt. Ein veralteter Anker erzeugte so ein **selbstbewussteres Ergebnis als gar keiner**. Fix: Der Vorbehalt haengt jetzt allein an der ausdruecklichen Zusicherung `NOTARY_REWIND_PROTECTED=true`; bei gesetztem MIN_SEQ benennt er zusaetzlich die Grenze („deckt nur ab, was UNTERHALB dieser Nummer fehlt“). `MIN_SEQ` ≀ 0 oder unparsbar → **exit 1** mit Erklaerung statt stiller Annahme. - Verifiziert gegen seinen Aufbau (10 Checkpoints, Rewind auf 7, DB passend gekuerzt, frischer Auditoren-Klon): nicht gesetzt → **exit 3** (vorher 0) · 10 → Alarm 2 · 8 → Alarm 2 · 7 (veraltet) → 0, aber **mit Vorbehalt** (vorher ohne) · 0 / `xyz` → **exit 1** (vorher stilles 0) · Rewind-Schutz zugesichert → 0 ohne Vorbehalt. Labor und Port geraeumt. - Seine Positivkontrolle bestaetigt und uebernommen: „in der Mitte gewaschen“ ist dicht, weil der Kopf-Hash bei `atId=letzter.maxId` alles darunter abdeckt – die Reconciliation nur des letzten Checkpoints genuegt. - [x] **🔄 Gegenbuch: Rewind auf signierten Praefix benennbar gemacht (Pentest R178-01)** (2026-08-18) - Fund: Mein Signatur-Gate faengt Force-Push mit **fremder oder unsignierter** Historie – aber ein **Rewind auf einen aelteren, echt signierten Stand** ist signaturseitig einwandfrei. Angreifer spult `origin/main` auf einen frueheren Checkpoint zurueck und kuerzt die Datenbank passend: alle Signaturen `G`, Pin korrekt, Reihe lueckenlos. Ein Notar-Klon mit lokalem Vorlauf merkt es; ein **frischer Auditoren-Klon** meldete `OK`, exit 0 – also ausgerechnet im dokumentierten Pruef-Fall. - **Ehrliche Einordnung:** Das laesst sich im Skript nicht kryptographisch erkennen, die Historie ist ja echt. Also zwei Dinge statt eines Scheinfixes: 1. `NOTARY_MIN_SEQ` – die zuletzt bekannte Nummer als Bezugspunkt. Ist die Reihe kuerzer, ist das das Bild eines Rewinds → Alarm. Jeder Lauf nennt die Nummer am Ende, damit sie ueberhaupt bekannt sein kann. 2. Ohne diesen Bezugspunkt sagt die Erfolgsmeldung jetzt ausdruecklich, dass ein Zurueckspulen **nicht** erkennbar war – „OK“ soll nicht mehr Gewissheit behaupten als vorhanden ist. - README: serverseitiger **Rewind-Schutz (non-fast-forward verbieten)** ist jetzt als Pflicht formuliert, mit der Begruendung warum Signaturpruefung allein dagegen nichts ausrichtet – und mit dem ausdruecklichen Hinweis, dass ein frischer Klon den Rewind nicht sieht. - Kleinkram aus seinem Bericht: `NOTARY_SIGNER_FINGERPRINT` wird beim **Einlesen** getrimmt (ein Zeilenumbruch loeste sonst 4/4 Fehlalarme aus, die auf den *korrekten* Fingerabdruck zeigten); die „nie gepusht“-Meldung priorisiert jetzt **Untersuchen** statt Pushen und nennt den passenden `git log`-Befehl – „pushen“ haette einen falsch signierten Commit dauerhaft in die Kette gebracht. - Beim Testen selbst gefunden: der allererste git-Aufruf war ungeschuetzt und warf bei kaputtem Klon einen Node-Stacktrace – jetzt erklaerende Meldung. - Verifiziert: Rewind 3→1 bei passend gekuerzter DB → frischer Klon ohne Bezugspunkt `OK` **mit Vorbehalt**, mit `NOTARY_MIN_SEQ=3` → **Alarm exit 2**; Pin mit Zeilenumbruch → kein Fehlalarm mehr; regulaerer Lauf unveraendert. Testlabor und Port geraeumt. - Seine Bestaetigungen: Pin-Ableitung faellt bei Key-Literal/GPG-Key-ID korrekt fail-closed aus; der Rollback bei Pin-Mismatch ist vollstaendig, und selbst ein Absturz zwischen Commit und Reset wird vom „nie gepusht“-Check aufgefangen. - [x] **📌 Gegenbuch: Fingerabdruck-Pin verpflichtend und vollstaendig angewandt (Pentest R177)** (2026-08-18) - **R177-01 (MEDIUM)** – Der Pin war optional. Ohne ihn war der Vertrauensanker die gesamte `allowed_signers`-**Menge**, nicht der eine Notar-Schluessel: Ein zweiter dort gelisteter Schluessel (Kollege, Alt-Key, geteilte Datei) konnte das Gegenbuch waschen, force-pushen – und `%G?` war `G`. Live gezeigt, exit 0. R176-01 hatte „jeder selbst erzeugte Schluessel“ geschlossen, „jeder **erlaubte** Schluessel“ blieb offen. Fix: Pin wird aus `user.signingkey` automatisch abgeleitet (`ssh-keygen -lf` → `SHA256:
`); laesst er sich nicht bestimmen, wird **abgebrochen** statt die ganze Liste zu akzeptieren. - **R177-02 (MEDIUM)** – Der Schreib-Lauf prueft den frischen Commit nur auf `%G?`, nicht auf den Pin. Ein Notar-Host mit falsch konfiguriertem Schluessel meldete „beglaubigt“ und **pushte** – und ab da war die Kette bei jeder Folgepruefung dauerhaft rot, behebbar nur per Force-Push, den die Branch-Protection gerade verhindern soll. Selbst-DoS ohne Signal. Fix: Pin-Abgleich am frischen Commit **vor** dem Push, bei Abweichung Ruecknahme und Abbruch. - Kleinkram aus seinem Bericht: `NOTARY_ALLOW_LOCAL=true` faerbt jede Erfolgsmeldung jetzt ein („nur lokal, kein externer Anker“) und pusht nicht mehr ins Leere; CRM-Fehler (nicht erreichbar, 401/403, kein JSON) liefern eine erklaerende Zeile statt eines Node-Stacktrace. - Verifiziert mit drei SSH-Schluesseln gegen echten Remote: keyC-Angriff (in `allowed_signers`, aber nicht der Notar-Key) → **Alarm exit 2 auch ohne gesetzten Pin**; Schreiblauf mit falschem Schluessel → zurueckgerollt, **nichts gepusht**, keine Geisterzeile; CRM nicht erreichbar / 401 → saubere Meldung, exit 1; Lokalmodus → Erfolgsmeldung eingefaerbt; **saubere Historie: vier Laeufe plus Pruefmodus ohne Fehlalarm**. Alle Testartefakte geloescht, Ports frei. - Seine Antworten uebernommen: (a) `%GF` ist kryptographisch an die Signatur gebunden und nicht faelschbar – Non-Finding; (b) Code 3 bleibt getrennt von Code 2, „ich weiss es nicht“ darf nicht als „Manipulation“ gemeldet werden; (c) „Notar-Host **und** Key kompromittiert“ bleibt bewusst offen – dagegen hilft nur Branch-Protection am Remote, im Skript nicht erzwingbar. - [x] **🔑 Gegenbuch: nur wirklich vertrauenswuerdige Signaturen, fail-closed ohne Remote (Pentest R176)** (2026-08-18) - **R176-01 (HIGH)** – Mein Signatur-Gate akzeptierte `%G?` = `G` **oder** `U`. Bei SSH-Signaturen bedeutet `U` woertlich „gute Signatur, aber kein passender Principal“ – also: der Schluessel steht **nicht** in `allowed_signers`. Damit passierte **jeder selbst erzeugte Schluessel** das Gate, und der einzige In-System-Vertrauensanker war wirkungslos. Der Pentester hat es end-to-end gefahren: Gegenbuch mit fremdem Schluessel re-signiert und force-gepusht → „OK, Signaturen gĂŒltig“, exit 0. Vorbedingung war nur Remote-Schreibrecht, kein Host-Zugriff. Fix: **nur `G`** an beiden Stellen. Zusaetzlich optionales Pinnen des erwarteten Signierschluessels ueber `NOTARY_SIGNER_FINGERPRINT` (`%GF`). - **R176-02 (MEDIUM)** – War der Remote unerreichbar, fiel der Ablauf still auf `HEAD` zurueck und die „nie gepusht“-Pruefung wurde uebersprungen – ausgerechnet unter der Bedingung, die einen Push-Fehlschlag verursacht. `--check` meldete waehrend eines Remote-Ausfalls gruenes Licht auf nicht notarisiertem Zustand. Fix: **fail-closed**, Abbruch mit Code 3 statt dem lokalen Stand zu glauben; ebenso, wenn gar kein Remote konfiguriert ist (bewusster Testlauf nur mit `NOTARY_ALLOW_LOCAL=true`). - Nebenbei: git-eigene Fehlermeldungen standen VOR der eigenen Erklaerung – stderr wird jetzt abgefangen und gezielt weitergereicht. - Verifiziert mit zwei SSH-Schluesseln (keyA vertrauenswuerdig, keyB nicht) gegen echten Remote: Angriff mit keyB + Force-Push → **Alarm, exit 2** (vorher „OK“); Remote unerreichbar → **exit 3**; kein Remote → exit 3; falscher Fingerabdruck-Pin → Alarm; **saubere Historie: drei Laeufe und Pruefmodus ohne Fehlalarm**. Alle Testartefakte geloescht, Port frei. - Seine Antwort auf Frage (a) uebernommen: der Vergleich Arbeitsdatei ↔ signierter Baum ist nicht umgehbar; `.trim()` auf beiden Seiten schluckt nur Kosmetik. Notiert als Restrisiko: internes CRLF koennte bei `core.autocrlf` auf dem Notar-Host einen Fehlalarm ausloesen – Verfuegbarkeit, kein Integritaetsloch. - [x] **🔏 Gegenbuch: verifizierender Leser statt Absichtserklaerung (Pentest R175)** (2026-08-18) - **R175-01 (HIGH)** – Der Kernsatz des Pentesters: „Append-only allein reicht nicht, es braucht einen verifizierenden Leser.“ Meine erste Fassung signierte zwar, **prĂŒfte aber nie**: Sie las ihre Wahrheit per `readFileSync` aus der lokalen **Arbeitsdatei**, nirgends im Repo gab es ein `git verify-commit`. Das `-S` war write-only ohne Konsument. Live reproduziert: DB-Tail abgeschnitten UND die lokale Ledger-Zeile angepasst → „OK, Checkpoint beglaubigt“, exit 0, kein Alarm. Der gekuerzte Zustand wurde zur neuen Wahrheit. Fix: Wahrheitsquelle ist der **signierte Commit-Baum** (bevorzugt der Remote-Kopf); jeder Commit mit Gegenbuch-Aenderung muss eine gueltige Signatur tragen; weicht die Arbeitsdatei vom signierten Stand ab, wird abgebrochen; lokale, nie gepushte Commits gelten nicht als beglaubigt; die Signatur des frisch erzeugten Commits wird gegengeprueft. Dazu ein **Pruefmodus** `--check` fuer Auditoren (alle Kontrollen, kein Schreibrecht noetig). - **R175-02 (MEDIUM)** – `/checkpoint` fuhr je Aufruf ein volles `verifyIntegrity()` (O(n), gemessen 0,85 s bei 16k Zeilen) – authentifizierte DoS-Verstaerkung. Gebraucht wurde davon nur die Siegel-Wurzel. Jetzt: Kopf-Hash direkt aus der Kopfzeile, Wurzel aus dem juengsten gueltigen Marker (eine Abfrage + Signaturpruefung), plus `sealLeafCount` statt des teuren Status. - **R175-03 (LOW→MED)** – `writeFileSync` lief vor dem Commit: schlug der signierte Commit fehl, blieb eine verwaiste Zeile liegen, die der Folgelauf absorbierte und zementierte. Jetzt Ruecknahme bei Fehlschlag (`git checkout`), und `NOTARY_SIGN=false` verlangt zusaetzlich `NOTARY_INSECURE_ACK=
` – der Reflex „Cron hakt, Signatur abschalten“ soll nicht ausreichen. - Verifiziert mit **echtem SSH-Signaturschluessel** gegen eine CRM-Attrappe: stilles Waschen (DB gekuerzt + lokale Datei gebogen) → Alarm statt „OK“; frei erfundene, nie committete Zeile → Alarm; unsignierter Commit in der Historie → Alarm; Commit-Fehlschlag → Datei zurueckgerollt, keine Geisterzeile; `NOTARY_SIGN=false` ohne Bestaetigung → verweigert; Pruefmodus meldet sauber ohne anzuhaengen. Alle Testartefakte geloescht. - Seine Bestaetigungen: Key-Trennung im Deploy haelt (`printenv` im Container zeigt keinen Notar-Schluessel); `audit:read` kann am Checkpoint keine falschen Werte injizieren. - [x] **⚖ Gegenbuch: externe Notarisierung der Audit-Kette** (2026-08-18) - Abschluss der Anker-Kette. Alle bisherigen Schutzebenen liegen in derselben Datenbank, die sie absichern sollen – der Pentest hat das ueber mehrere Runden Schicht fuer Schicht gezeigt, zuletzt in R174-01 am Siegel- Marker selbst. Ein Gegenbuch an einem fremden Ort durchbricht das. - Aufteilung nach der Analyse des Pentesters („der Schutz kommt vom ORT, nicht von der Signatur“): Das CRM liefert nur einen **lesbaren Kontrollwert ohne Geheimnisse** (`GET /api/audit-logs/checkpoint`, `audit:read`). Signiert, zeitstempelt und angehaengt wird auf einem **anderen Rechner** – Schluessel und Push-Recht liegen NICHT in den Deploy-Secrets des CRM. Ohne diese Trennung waere es „D1 nochmal, nur schlimmer“: sieht nach doppeltem Boden aus, tut still nichts. - Der Kontrollwert enthaelt bewusst `maxId`. Ein blosser Kopf-Hash erkennt Umschreiben, aber kein **Abschneiden am Ende** – genau die R174-01-Klasse, eine Ebene hoeher. `atId` erlaubt der Gegenstelle, einen frueher beglaubigten Kopf erneut abzufragen und nachzurechnen. - Gegenstelle: `tools/audit-notary/notary.mjs` (Cron auf zweitem Rechner, privates Git-Repo als Append-only-Ablage, signierte Commits). Prueft VOR dem Anhaengen und bricht bei Widerspruch mit Exit-Code 2 ab, **ohne zu schreiben** – der manipulierte Zustand wird nicht als neue Wahrheit festgeschrieben. - Verifiziert gegen eine CRM-Attrappe mit echter DB: beglaubigte Zeile veraendert → Alarm; am Ende abgeschnitten (maxId 5→4) → Alarm; Gegenbuch selbst gekuerzt (seq-Luecke) → Alarm; in allen Faellen nichts angehaengt. Wegwerf-DB und Test-Gegenbuch danach geloescht. - **Ehrlich dokumentiert** (README): Restfenster zwischen zwei Laeufen bleibt und ist inhaerent; ein stiller Cron-Ausfall erzeugt im CRM KEINE Warnung und muss auf dem Gegenbuch-Rechner ueberwacht werden; Force-Push muss serverseitig gesperrt sein, sonst ist Append-only nur geliehen. - **Betrieb:** Einrichtung liegt beim Betreiber (privates Repo, Cron, Signaturschluessel auf dem zweiten Rechner). - [x] **🚹 Siegel-Entfernung wird erkannt (Pentest R174-01, HIGH)** (2026-08-18) - Fund: Der Siegelzustand hing **ausschliesslich** am Marker im Audit-Log – und den kann ein DB-Schreiber **ohne Schluessel** loeschen. Danach meldete die Pruefung `kein_siegel`, also **Entwarnung**, ununterscheidbar von „nie versiegelt“. Die 11 Blattwerte blieben verwaist liegen und wurden nie konsultiert; die zuvor erkannte V1-Faelschung war wieder unsichtbar. - Besonders bitter im **Tail-Fall**: Steht der Marker am Ketten-Ende – genau der Zustand direkt nach dem einmaligen `seal-backlog` beim Deploy – reisst beim Loeschen nicht einmal eine Luecke. Ergebnis war `valid: true`, „Alle EintrĂ€ge unverĂ€ndert und lĂŒckenlos verkettet“, **null Spur**. Meine Antwort auf seine Frage (b) war damit falsch: die Luecke reisst nur, solange der Marker NICHT am Ende steht. - Fix (sein Vorschlag): Gegen-Check „Blaetter vorhanden, aber kein gueltiger Marker“ → neuer Status **`entfernt`** statt `kein_siegel`, mit ausdruecklicher Meldung („Das ist KEIN Normalzustand“). Zusaetzlich kippt ein gebrochenes ODER entferntes Siegel jetzt `valid` auf `false`, auch wenn keine einzelne Zeile beanstandet ist – sonst bliebe der stille Anker-Verlust ohne Wirkung. Und der beruhigende Einstiegssatz entfaellt bei Siegelproblemen, damit er die Warnung nicht entwertet. - Verifiziert ueber den echten HTTP-Pfad, exakt sein Szenario 4: Marker war nachweislich das Ketten-Ende, Marker + Middleware-Decoy geloescht, **0 Ketten-Luecken** – Ergebnis jetzt `valid: false`, Status `entfernt`, Klartext-Warnung. Zusaetzlich der Nicht-Tail-Fall geprueft. Zwei Wegwerf-Datenbanken, beide danach geloescht; Dev unberuehrt. `tsc` + `vite build` gruen. - Seine Gegenchecks bestaetigt: Frage (a) Middleware kann keine Marker-/ Manifest-Signatur mit auswertbarem Inhalt nachbilden; Frage (c) Re-Insert mit derselben id umgeht nichts, weil das Blatt alle Spalten deckt. - [x] **🔧 Bestandssiegel betriebstauglich gemacht (Pentest R173-01 HIGH, R173-02/-03 MEDIUM)** (2026-08-18) - **R173-01 (HIGH) – Siegel war ueber HTTP tot.** Die generische `auditMiddleware` protokolliert den `POST /seal-backlog`-Request SELBST als `AuditLog / CREATE / endpoint=/api/audit-logs/seal-backlog` – exakt die Signatur, mit der ich den Marker suchte, nur mit hoeherer id und ohne `changesAfter`. Der Selektor griff diese Zeile, `root` war `undefined`, Ergebnis: **dauerhaft „gebrochen“ bei null manipulierten Zeilen.** Das komplette R171-02-Deliverable war damit im Betrieb wertlos. **Mein Testfehler:** Ich hatte `sealBacklog()`/`verifyIntegrity()` direkt aufgerufen, nie ueber HTTP – die Middleware lief nie mit. Dieselbe Fehlerklasse wie R165 (Code korrekt, im Betrieb nicht erreichbar). Fix: eigener Ressourcentyp `AuditBacklogSeal`, den die Middleware nie vergibt (sie leitet ihn aus dem Pfad ab → `AuditLog`), zusaetzlich muss der Marker auswertbares `{toId, root}` tragen. - **R173-02 (MEDIUM) – Siegel war blind fuers Loeschen.** `if (!zeile) continue` uebersprang fehlende Zeilen; das Loeschen eines gesiegelten Einbruchsbelegs erschien nur als „unerklaerte Luecke“, waehrend der dedizierte Indikator „intakt“ meldete – Entwarnung, waehrend Beweismaterial verschwindet. Fix: fehlende gesiegelte Zeilen sind ein Siegelbruch mit eigener Liste (`backlogMissing`) und werden namentlich gemeldet. Deckt zugleich seine Frage (a) ab: geprueft wird jetzt die gesiegelte Menge gegen die Live-Population, nicht nur der Inhalt vorhandener Zeilen. - **R173-03 (MEDIUM) – Re-Seal wusch mit weniger Spur als ein Rehash.** Die R170-01-Haertung (Vorbefund im Marker) war auf `seal-backlog` nie angewandt. Fix: Der Siegel-Marker haelt jetzt den Befund VOR dem Siegeln fest (manipulierte/fehlende Zeilen, Ketten-Luecken) samt Status und Wurzel des vorherigen Siegels; `/verify` weist die Anzahl gueltiger Siegel aus und weist bei >1 ausdruecklich darauf hin. - Verifiziert **ueber den echten HTTP-Pfad inkl. Middleware** (separate Wegwerf-DB, Mischbestand 6×V1/4×V2/4×V3): Middleware-Protokollzeile ist vorhanden, Status trotzdem `intakt`; Loeschen einer gesiegelten Zeile → `gebrochen`, `backlogMissing:[2]`, namentlich in der Meldung; erneutes Siegeln → Anzahl 2 gemeldet, Marker enthaelt „vorheriges Siegel: gebrochen, Befund fehlend [2]“. Wegwerf-DB geloescht, Dev unberuehrt. `tsc` + `vite build` gruen. - [x] **🧿 Bestandssiegel: Altbestand gegen stille Aenderung gesichert (Pentest R171-02, HIGH)** (2026-08-18) - Der Pentester hat den Fund live reproduziert: Bei Hash-Version 1 sind nur 7 von 24 Spalten gehasht. Ein DB-Schreibzugriff konnte eine `LOGIN_FAILED`-Zeile auf `success=1` setzen, das Label auf „
hat sich angemeldet“ aendern und `errorMessage` leeren – **alles Nicht-Hash-Felder, Hash unveraendert** → `/verify` meldete `valid=true`. Ein Einbruchsversuch war unsichtbar in einen Erfolg umschreibbar. - Rueckwirkend signieren geht nicht; ein Rehash waere die falsche Medizin. Stattdessen **Bestandssiegel**: einmalig, nicht destruktiv, schreibt nichts um. Je Altzeile ein Blattwert, die Wurzel darueber in einem HMAC-signierten Marker. - Umgesetzt exakt nach seinen vier Bedingungen: 1. **Blaetter ueber den vollen Zeileninhalt** (alle Spalten inkl. `id` und `hashVersion`), NICHT ueber den 7-Feld-V1-Hash – sonst lebte die Luecke im Siegel weiter. 2. **Wurzel signiert** – sie steht im Marker, der selbst Version 3 und HMAC-signiert ist. Ohne `AUDIT_HMAC_KEY` wird das Siegeln abgelehnt. 3. **Bereich fix auf `[1 
 v3FromId-1]`** gebunden statt per Live-Abfrage `hashVersion < 3`. Sein Umgehungsweg: ein Up-Flip der Grenzzeile (V1→V2, schluessellos nachrechenbar) haette sie sonst aus der geprueften Menge gedraengt – sie waere nur als harmlose Luecke erschienen. 4. **Pruefung je id**: vorhanden, weiterhin Altbestand, Inhalt == Blatt. Zusaetzlich Wurzelabgleich, damit auch ein veraenderter Blattbestand auffliegt. - Naht geschlossen: Zeilen ab `v3FromId` MUESSEN laut Versionsgrenze V3 sein, es bleibt also keine ungesiegelte Zone dazwischen. - Neuer Endpunkt `POST /audit-logs/seal-backlog` (`audit:admin`, `{"confirm":"SEAL"}`). `/verify` meldet den Siegelzustand im Klartext – auch wenn **kein** Siegel existiert („Aenderungen daran waeren nicht erkennbar“). - Verifiziert in einer separaten Wegwerf-Datenbank mit realistischem Mischbestand (6×V1, 5×V2, 5×V3): ohne Siegel ist der Angriff unsichtbar (`valid=true`) – mit Siegel wird er erkannt und die Zeile benannt (`[3]`); sein Up-Flip der Grenzzeile wird ebenfalls erkannt (`[6]`). Wegwerf-DB danach geloescht, Dev-Daten unberuehrt. `tsc` + `vite build` gruen. - [x] **🔒 Manifest-Kanal abgesichert (Pentest R171-01, HIGH; R171-03, LOW)** (2026-08-18) - **R171-01:** `verifyIntegrity` vertraute Loeschungs-Manifesten bedingungslos – ohne zu pruefen, ob die Traegerzeile ueberhaupt signiert und gueltig ist. Das Manifest steht in `changesAfter`, und dieses Feld ist erst ab Hash-Version 2 mitgehasht, auf **V1-Altzeilen also voellig unauthentifiziert**. Ein Angreifer konnte in eine BELIEBIGE V1-Zeile ein erfundenes Manifest schreiben, ohne deren Hash zu veraendern, und damit eigene Loeschungen als „erklaert“ ausweisen – womit zugleich die Eskalation an signierten Zeilen ausfiel. Damit waren **Anker UND Versionsgrenze** umgangen. Der Pentester hat es live gezeigt; es war genau die Stelle, die ich beim Uebergeben als meine schwaechste benannt hatte. - Fix: Ein Manifest zaehlt nur noch, wenn seine Traegerzeile (1) laut Versionsgrenze Stufe 3 sein MUSS und dies auch deklariert und (2) ihre HMAC-Signatur mit einem konfigurierten Schluessel aufgeht. Ohne Schluessel gibt es keine gueltigen Traeger – dann bleiben Luecken unerklaert, was die sichere Richtung ist. - **R171-03:** Eskalierte Luecken standen in `tamperedEntries` UND `chainGaps`; `invalidEntries` zaehlte sie doppelt. Jetzt entdoppelt. - Verifiziert: boeswillige Loeschung → Befund; erfundenes Manifest in herabgestufter Traegerzeile → **ignoriert**, Luecke bleibt Befund und die Traegerzeile wird selbst beanstandet; **legitimes signiertes Manifest erklaert die Luecke weiterhin** (Funktion nicht kaputt). `tsc` gruen. - Hinweis: Auf der Dev-DB existiert kein V1-Bestand mehr (der Rehash-Test aus R170-01 hat alles auf V3 gehoben), der Angriff wurde daher mit einer gezielt herabgestuften Traegerzeile nachgestellt. Auf Staging/Prod ist der V1-Altbestand real vorhanden. - [x] **đŸ§Ÿ rehash/cleanup: Vorzustand sichern + Bestaetigung verlangen (Pentest R170-01)** (2026-08-18) - Fund: `POST /audit-logs/rehash` (audit:admin) rechnet die Kette MIT dem HMAC-Schluessel neu und macht sie damit wieder stimmig – auch wenn sie vorher berechtigte Beanstandungen enthielt. Der Anker schuetzt also gegen einen DB-Schreiber ohne Schluessel, **nicht** gegen einen Admin mit `audit:admin`. Der bisherige Marker hielt nur fest, DASS rehasht wurde, nicht WAS dabei verschwand. - Fix 1 – **Vorzustand im Marker**: Vor dem Rehash wird `verifyIntegrity()` erhoben und samt Kettenkopf (id, hash, hashVersion) im Marker gesichert: Anzahl geprueft, Listen der manipulierten Zeilen, der Ketten-Luecken, der Luecken ohne dokumentierte Loeschung und der nicht pruefbaren. Dazu ausloesender Benutzer und IP (bisher stand dort pauschal „system“). Der Marker entsteht nach dem Rehash, ist Teil der neuen Kette und signiert – entfernen ginge nur unter Hinterlassung einer Luecke. - Fix 2 – **Bestaetigung verlangen**: `rehash` erfordert `{"confirm":"REHASH"}`, `cleanup` erfordert `{"confirm":"CLEANUP"}`. Der Pentester hatte beide bei blinder Methoden-Erkundung per POST unbeabsichtigt ausgeloest; ein tastender Aufruf laeuft jetzt in 400. - Verifiziert: blinder POST auf beide Endpunkte → 400 ohne Wirkung; mit Bestaetigung → Rehash laeuft, Marker enthaelt Ausloeser, Vorbefund (1 manipuliert, 7 Luecken mit exakten IDs) und Kettenkopf. - **Offengelegt:** Dieser Test hat auf der DEV-Datenbank real rehasht – die dortigen historischen Beanstandungen sind rechnerisch geglaettet (`valid = true`). Genau der beschriebene Effekt; der Vorzustand steht jetzt aber im Marker. Staging/Prod unberuehrt. - Offen (Betreiber-Entscheidung): Off-Site-Notarisierung des Kettenkopfes. Erst sie deckt den Fall „Admin mit audit:admin“ vollstaendig ab. - [x] **🔒 Refresh-Kulanz idempotent: stiller Session-Fork geschlossen (Pentest R168-01, HIGH)** (2026-08-18) - Der Pentester hat genau die Frage beantwortet, die ich beim Uebergeben gestellt hatte („laesst sich das Kulanzfenster ausnutzen?“) – und zwar nicht per Timing, sondern per **Linien-Fork**: Jede Kulanz-Wiedervorlage rotierte auf einen FRISCHEN Token mit eigenem, zurueckgesetztem Zaehler. Ein Angreifer mit gestohlenem Token konnte damit aus dem erkennbaren Replay-Zustand in eine eigene, sauber weiterrotierende Sitzung entkommen, die nie wieder mit der des Opfers kollidiert – **dauerhaft unsichtbar, kein einziges CRITICAL**. Damit war die Kern-Garantie von R164-02 (Diebstahl faellt bei der naechsten Nutzung auf) ausgehebelt. - Fix (sein Vorschlag): **Kulanz idempotent**. Die jti des Nachfolgers wird schon beim Einloesen im selben bedingten UPDATE reserviert (`replacedByJti`). Eine Wiedervorlage im Fenster gibt **denselben** bereits ausgestellten Nachfolger zurueck, statt neu zu rotieren – ohne neuen Datensatz. Parallele Tabs laufen dadurch auf EINE Linie zusammen; wer den Token spaeter (ausserhalb des Fensters) vorlegt, kollidiert zwangslaeufig und loest den Familien-Widerruf aus. Ist kein Nachfolger hinterlegt, wird bewusst NICHT ersatzweise rotiert (das waere wieder der Fork), sondern fail-closed als Replay gewertet. - Verifiziert – sein PoC nachgebaut: T0 legit → TA, T0 replayt → TC; **TA.jti == TC.jti**, also kein Fork mehr. Ueber HTTP: zwei parallele Tabs beide erfolgreich und auf derselben Linie; gestohlener Token nach Ablauf des Fensters → 401, Familie widerrufen, Angreifer-Linie tot, `SUSPICIOUS/CRITICAL` gemeldet. - Regression der in R168 bestaetigten Faelle: 40 parallel → 4 erfolgreich (1 + Kulanz 3) auf **einer** Linie, Rest abgewiesen; serielles Replay 1–4 ok, 5. → Replay; Token ohne jti → fail-closed; Logout widerruft. `tsc` gruen. - [x] **🔁 Refresh-Token: Replay-Schutz mit Familien-Widerruf (Pentest R164-02)** (2026-08-18) - Die Rotation war bisher wirkungslos: Der alte Refresh-Token blieb bis `exp` gueltig, ein gestohlener Token also **bis zu 7 Tage parallel** zum legitimen nutzbar (der Pentester trug mit EINEM Token 90 Parallel-Requests). - Umgesetzt nach OAuth-Sicherheits-BCP: Jeder Refresh-Token traegt eine `jti` und gehoert zu einer Sitzungs-`familyId` (neue Tabelle `RefreshTokenRecord`, Migration `20260818160000`). Beim Einloesen wird die `jti` verbraucht; taucht sie erneut auf, wird die **gesamte Familie** widerrufen – Angreifer und legitimer Nutzer fliegen raus, der Nutzer merkt es und der Vorfall wird als `SUSPICIOUS / CRITICAL` gemeldet. - Der Token selbst wird NICHT gespeichert (die Signatur authentifiziert ihn bereits); ein DB-Leck gibt damit keine nutzbaren Sitzungen preis. - **Kulanzfenster** fuer parallele Tabs: 15 s UND hoechstens 3 Wiederverwendungen. Ohne Toleranz wuerde der zweite legitime Tab die Sitzung sprengen; die enge Grenze laesst einen Missbrauchs-Burst trotzdem auflaufen. - **Einloesen ist atomar** (bedingtes UPDATE statt Lesen-dann-Schreiben) – derselbe Fehlertyp wie bei der Audit-Kette: im ersten Testlauf kamen 90 gleichzeitige Requests ausnahmslos durch, weil alle den Token als „noch unbenutzt“ lasen. Jetzt entscheidet die Datenbank, wer zuerst war. - Verifiziert: Rotation vergibt neue `jti` in derselben Familie; 90 parallele Requests → nur 4 erfolgreich (1 + Kulanz 3), 27 als Replay erkannt, Rest widerrufen, alle daraus entstandenen Tokens tot; 2 parallele Tabs weiterhin erfolgreich; gestohlener Token spaeter erneut → abgewiesen; Logout widerruft die Familie; ueber HTTP kommt `SUSPICIOUS/CRITICAL` an. Audit-Regression unveraendert (25/25, 0 Forks, alle V3). `tsc` + `vite build` gruen. - **Deploy-Hinweis:** Refresh-Tokens ohne `jti` (Bestand vor dem Deploy) werden bewusst **fail-closed** abgewiesen (`REFRESH_LEGACY`, als LOW gemeldet, kein Angriffsindiz). Alle angemeldeten Nutzer muessen sich nach dem Deploy **einmalig neu anmelden**. - [x] **⚓ Externer Anker: Audit-Kette HMAC-signiert (Hash-Version 3)** (2026-08-18) - Schliesst den nach R166/R167 verbliebenen Grenzfall: Bis Version 2 war die Kette selbsttragend – wer die DB schreiben kann, konnte jede Zeile aendern und alle Folgehashes konsistent nachziehen, die Pruefung meldete „gueltig“. - Version 3 signiert denselben Inhalt per **HMAC-SHA256** mit `AUDIT_HMAC_KEY` – einem Schluessel, der NICHT in der Datenbank liegt. Ohne ihn laesst sich keine gueltige Signatur erzeugen; reiner DB-Schreibzugriff genuegt nicht mehr. - **Fail-safe:** Ohne Schluessel schreibt das Audit-Log weiter Version 2, es faellt nichts aus. Signierte Zeilen gelten dann als **nicht pruefbar** (eigener Topf `unverifiableEntries`) und ausdruecklich NICHT als manipuliert – eine fehlende Konfiguration darf kein Fehlalarm ueber das gesamte Log sein. - **Schluesselwechsel ohne Rehash:** `AUDIT_HMAC_KEY_OLD` wird bei der Pruefung zusaetzlich akzeptiert. Verifiziert: mit KEY_OLD 0 Befunde, ohne KEY_OLD werden die alt signierten Zeilen erwartungsgemaess auffaellig. - **Restluecke der Versionsgrenze geschlossen (selbst gefunden):** Wird die FRUEHESTE Zeile einer Stufe herabgestuft, wandert `MIN(id)` mit – die Grenze allein haette den Downgrade durchgewunken (mein erster Testlauf fiel genau darauf durch). Der Nachfolger ist jedoch HMAC-signiert, sein `previousHash` ist ohne Schluessel nicht faelschbar. Eine unerklaerte Luecke vor einer signierten Zeile gilt deshalb jetzt als Befund, nicht als struktureller Zufall. - Verifiziert: Inhalt geaendert + Hash beliebig → erkannt; Downgrade 3→2 mit gueltigem V2-Hash auf der **fruehesten** V3-Zeile → erkannt; dasselbe auf der **letzten** V3-Zeile (kein Nachfolger) → erkannt; ohne Schluessel 0 manipuliert / 2 nicht pruefbar; 40 parallele Schreiber → 40/40, 0 Forks, alle V3. `tsc` + `vite build` gruen. - **Betrieb:** `AUDIT_HMAC_KEY` in `.env.example` dokumentiert (`openssl rand -hex 32`). Schluessel sichern – geht er verloren, sind die damit signierten Eintraege dauerhaft nicht mehr pruefbar (aber nicht als manipuliert gemeldet). Der Dev-Schluessel liegt nur lokal in `.env` (gitignored) und ist NICHT der Prod-Schluessel. - [x] **🔒 hashVersion-Downgrade geschlossen (Pentest R167-01, HIGH)** (2026-08-18) - Der Pentester hat den Angriffsweg, den ich beim Uebergeben von R166-02 selbst als naechsten benannt hatte, als **exploitbar bewiesen** (PoC gegen Live-Code + Live-Daten): `verifyIntegrity` waehlte die Pruefstaerke nach der von der Zeile SELBST deklarierten `hashVersion` – und die ist nicht gehasht. Angriff: `hashVersion` 2→1 setzen, die nur von V2 abgedeckten Felder aendern (`success` false→true, `errorMessage` leeren, `resourceLabel` umschreiben) und den schwachen V1-Hash ueber die 7 unveraenderten Felder nachziehen → Fehl-Login als Erfolg getarnt, Pruefung meldet „gueltig“. Besonders kritisch an der **letzten Zeile** der Kette: dort entsteht nicht einmal ein Gap → dauerhaft unsichtbar. - Fix: **Version-Floor.** Die erwartete Pruefstaerke leitet sich aus der Kette ab (`MIN(id) WHERE hashVersion >= 2`), nicht aus der Selbstauskunft. Ab dieser Grenze muss jede Zeile V2 sein; weicht die deklarierte Version von der erwarteten ab, gilt die Zeile selbst als manipuliert. Geprueft wird immer mit dem ERWARTETEN Verfahren. Die Grenze laesst sich durch das Herabstufen einzelner Zeilen nicht verschieben (Minimum bleibt) – ein Angreifer muesste alle V2-Zeilen ab der Grenze herabstufen und die gesamte Kette neu rechnen (= vollstaendiger Rehash, bekannter Grenzfall). - Zusaetzlich: Die Pruefung konsultiert jetzt das **Loeschungs-Manifest**. Neu `unexplainedGaps` – nur Luecken ohne protokollierte Loeschung sind erklaerungsbeduerftig. Vorher war das Manifest rein informativ, wodurch sich eine boeswillige Loeschung als „harmloser Gap“ tarnen konnte. - Verifiziert (PoC nachgebaut): Downgrade-Angriff auf Zeile mit Nachfolger → **erkannt**; auf die Tail-Zeile (erzeugt keinen Gap) → **erkannt**; Gegenrichtung (V1-Altzeile faelschlich als V2 deklariert) → **erkannt**; Ausgangslage und Zustand nach Wiederherstellung jeweils 0 manipuliert (keine Falschmeldungen auf Bestandsdaten). `tsc` + `vite build` gruen. - Betriebshinweis dokumentiert: Bei einem rollierenden Deploy mit kurzzeitig parallel schreibender Alt-Instanz koennen echte V1-Zeilen nach der Grenze entstehen und wuerden angezeigt. Beim hier ueblichen Deploy (pull + rebuild + restart) tritt das nicht auf. - [x] **đŸ›Ąïž Audit-Haerten: Fork, Feldabdeckung, Refresh-Rauschen, Route (Pentest R166)** (2026-08-18) - **R166-01 (HIGH) – Kette forkte weiter.** Mein GET_LOCK-Ansatz gab die Sperre im `finally` INNERHALB des Transaktions-Callbacks frei, also VOR dem COMMIT. Im Fenster Release↔Commit las der naechste Schreiber ein noch nicht sichtbares Kettenende → zwei Zeilen am selben Vorgaenger. Meine „100 parallel → 0 Brueche“-Messung war zu schwach: sie suchte Luecken zwischen Nachbarn, nicht Forks, und das Fenster ist lokal sehr schmal. Fix: einzeiliger Mutex `AuditChainLock` + `FOR UPDATE`; InnoDB-Zeilensperren fallen erst beim COMMIT. Dazu `isolationLevel: ReadCommitted`, damit der Lesevorgang den frisch festgeschriebenen Stand sieht. **Belegt** im Direktvergleich mit kuenstlich geweitetem Fenster: Release-vor-Commit → Fork, Zeilensperre → kein Fork. - **R166-02 (MEDIUM) – Hash deckte nur 7 Felder.** `changesBefore/After` (die eigentliche Nutzlast), `success`, `ipAddress`, `resourceLabel`, `dataSubjectId`, `userId`/`customerId` waren NICHT gehasht – ein Einzeledit dort blieb unsichtbar. Fix: `hashVersion` (Migration `20260818150000`) + `generateHashV2` ueber alle Inhaltsspalten. Bestandszeilen behalten Version 1 und bleiben ohne Rehash gueltig. `rehashAll` schreibt V2. Verifiziert: Manipulation an success/changesAfter/ipAddress/resourceLabel/ dataSubjectId wird jetzt **5/5 erkannt**, Altbestand weiter gueltig. - **R166-03 (LOW)** – „kein Cookie“ (normaler Erstbesuch) wurde als `HIGH / abgelehnt` gefuehrt. Jetzt eigener Ausgang: LOW + Label „ohne vorliegenden Token“. Nur echte Ablehnung bleibt HIGH. - **R166-04 (LOW, pre-existing)** – `GET /retention-policies` wurde von `GET /:id` verschluckt. Konkrete Routen jetzt konsequent vor der Parameter-Route, mit Warnhinweis im Code. - **Design-Empfehlungen umgesetzt:** `runRetentionCleanup` schreibt ein Loeschungs-Manifest (Bereich `fromId`–`toId`, Anzahl, Policy, Cutoff) als eigenen verketteten Eintrag – Luecken ausserhalb bleiben damit erklaerungsbeduerftig; `rehashAll` schreibt einen Marker, dass die Beweiskraft der Vergangenheit zurueckgesetzt wurde. - Verifiziert: 50 parallele Schreiber → 50/50, 0 Forks, alle V2; manipuliert 0, Luecken unveraendert 7. `tsc` + `vite build` gruen. - **Offen (bewusst nicht umgesetzt):** externer Anker (HMAC mit Schluessel ausserhalb der DB). Adressiert Full-DB-Compromise, erfordert aber Schluesselverwaltung/Rotation im Deployment → Entscheidung des Betreibers. - [x] **🔎 Audit-Pruefung: „manipuliert“ von „Luecke“ getrennt + Retention fuer Routine-Auth** (2026-08-18) - **Problem 1 (Deutbarkeit):** `verifyIntegrity` warf zwei voellig unterschiedliche Befunde in einen Topf und meldete beides als „N manipulierte Eintraege“. Eine harmlose Verkettungsluecke sah damit aus wie ein Angriff – die Meldung war im Alltag nicht deutbar und wurde dadurch wertlos (dasselbe Muster wie beim Refresh-Rauschen). - Fix: Rueckgabe um `tamperedEntries` (Inhalt einer Zeile nachtraeglich veraendert – **ernst**) und `chainGaps` (Verkettung unterbrochen durch parallele Schreibvorgaenge oder geloeschte Zeilen – **meist harmlos**) erweitert. `invalidEntries` bleibt als Summe erhalten (Abwaertskompatibilitaet). Controller formuliert die Meldung entsprechend eindeutig; Frontend-API-Typ nachgezogen. - **Problem 2 (Aufbewahrung):** Seit der Entrauschung landen Token-Refreshes als `Authentication / LOW`. Diese Kombination traf auf keine spezifische Regel und fiel in die Auffangregel `*` mit 3650 Tagen – das **Rauschen waere 10 Jahre** aufbewahrt worden, echte Logins nur 2 (730 Tage). - Fix: Regel `Authentication / LOW` → 90 Tage. Als Migration (`20260818130000`, idempotent per `ON DUPLICATE KEY`) **und** im Seed, damit sie sowohl bestehende Installationen als auch Neuinstallationen erreicht. - Hinweis zur Sensitivitaet: Sie steuert die Aufbewahrung, ist also **keine** Alarmstufe. Normale Logins/Logouts sowie Zugriffe auf Bankdaten/Ausweise bleiben bewusst CRITICAL. Ein Herabstufen „fuer eine ruhigere Liste“ wuerde still die Aufbewahrungsfrist verlaengern – daher unterlassen. - Verifiziert: Live-Test gegen Dev-DB – echte Manipulation einer Zeile (`UPDATE 
 SET userEmail`) wird als **manipuliert** erkannt und nicht mit Luecken verwechselt; Ketten-Luecken bleiben bei 7; Originalzustand exakt wiederhergestellt (0 manipuliert danach). `tsc` + `vite build` gruen. - [x] **🔗 Audit-Kette: Race beim Fortschreiben behoben (parallele Requests)** (2026-08-18) - `createAuditLog` las den Vorgaenger-Hash und schrieb den neuen Eintrag als zwei getrennte Schritte. Zwei parallele Requests lasen denselben letzten Hash und haengten sich beide daran → Kette zerrissen (echte Bruchstellen im Bestand: 05.05./07.05.2026). - Fix: Lesen + Schreiben in einer Transaktion, serialisiert ueber einen benannten MySQL-Lock (`GET_LOCK('opencrm_audit_chain')`). Liegt in der DB, wirkt daher auch ueber mehrere App-Instanzen hinweg. Release im `finally`, da benannte Locks nicht transaktional sind (sonst wandert die Sperre mit der Verbindung zurueck in den Pool und blockiert alle Schreiber). - **Verworfener erster Ansatz – wichtig:** `SELECT 
 FOR UPDATE` auf das Kettenende nimmt Gap-/Next-Key-Locks, die mit den gleichzeitigen INSERTs kollidieren. Gemessen: **38 von 40** parallelen Eintraegen gingen durch Deadlocks verloren (vom `catch` still verschluckt). Ein FEHLENDER Audit-Eintrag ist unsichtbar und damit gefaehrlicher als ein sichtbarer Kettenbruch – deshalb der Umbau auf den benannten Lock. - Verifiziert: 100 parallele Schreiber → **100/100 geschrieben, 0 neue Brueche** (444 ms); Folge-Schreiber danach in 6 ms, `IS_FREE_LOCK` = frei (kein Lock-Leak). Gesamtzahl ungueltiger Zeilen bleibt bei den 7 historischen. Rechenintensives (Serialisieren/Verschluesseln) liegt bewusst VOR der Transaktion, damit die Sperre kurz bleibt. `tsc` gruen. - [x] **đŸ›Ąïž Audit-Integritaet: Dauer-Fehlalarm ueber 67 % des Logs behoben** (2026-08-18) - Beim Nachpruefen aufgefallen: `verifyIntegrity` meldete **3107 von 4630** Zeilen als „manipuliert“. Davon waren **3100 Fehlalarme** – eingegrenzt auf exakt die Zeilen mit `resourceId = NULL` aus dem Zeitraum 08.02.–01.05.2026. - Ursache: Der R121-Fix nahm an, `resourceId` sei beim Schreiben immer `undefined` gewesen (Key faellt bei `JSON.stringify` weg) und daher wuerden **alle** Bestands-Hashes ohne Rehash matchen. Das gilt erst ab ~01.05.2026 – aeltere Zeilen wurden mit explizitem `null` serialisiert, der Key war DRIN. - Warum das sicherheitsrelevant ist: Ein Alarm, der staendig grundlos ausloest, wird ignoriert – **echte** Manipulation ginge im LĂ€rm unter (gleiches Muster wie beim Refresh-Rauschen, [R165]). - Fix: `generateHashLegacy()` reproduziert das alte Schreibverhalten; `verifyIntegrity` akzeptiert Altbestand ueber diesen Fallback (nur geprueft, wenn die aktuelle Variante nicht passt). **Kein Rehash** – der waere der naheliegende Schnellfix, wuerde die Manipulations-Beweiskraft der Vergangenheit aber unwiederbringlich zerstoeren. Gespeicherte Hashes bleiben unangetastet. - Verifiziert: ungueltige Zeilen **3107 → 7** (die 7 sind echte Ketten-Brueche, siehe naechster Punkt). Adversarial gegengetestet: Manipulation an `userEmail`/`action`/`endpoint`/`createdAt`/`resourceId` wird bei ALTEN wie NEUEN Zeilen weiterhin zu 100 % erkannt (10/10), unveraenderte Zeilen akzeptiert. `tsc` gruen. - [x] **🐛 Audit-Log: Pfad-Matching kaputt – Auth-Actions generisch (Pentest R165-01)** (2026-08-18) - Pentester meldete: Entrauschung (`de0d6bd`) live **nicht wirksam** – jeder `/refresh` weiter `CREATE / CRITICAL / „Anmeldung erstellt“`. Zusatzbefund: auch `/login` und `/logout` liefen als generisches `CREATE`. - **Kein Deploy-Miss** (Alerting aus `d599eb3` lief ja live), sondern **toter Code**: `auditMiddleware` liest `req.path` erst im `res.on('finish')`-Handler. Express strippt beim Router-Dispatch den Mount-Prefix aus `req.url` und stellt ihn nur beim `next()`-Durchlauf wieder her – ein terminaler Handler (`res.json()`) ruft nie `next()`, also bleibt `req.path` router-relativ (`/refresh` statt `/api/auth/refresh`). Alle `path.includes('/auth/...')`-Checks liefen ins Leere → Fallback POST→CREATE + Default-SensitivitĂ€t CRITICAL. Empirisch nachgestellt (Mini-Express: ENTRY `/api/auth/refresh` → FINISH `/refresh`). - Betraf **nicht nur** den neuen `TOKEN_REFRESH`: `LOGIN`/`LOGOUT`/`LOGIN_FAILED` waren im Audit-Stream **seit jeher** kaputt (pre-existing), ebenso das `endpoint`-Feld (router-relativ statt voll). Der SecurityEvent-Stream war nie betroffen (eigene `emit()`-Calls) – daher funktionierte das Alerting korrekt. - Fix: vollen Pfad **einmal synchron beim Eintritt** festhalten (`req.originalUrl.split('?')[0]`, wird von Express nie mutiert) und downstream ausschließlich diesen nutzen – in `determineAction`, `generateHumanLabel`, `extractDataSubjectId`, `manuallyLoggedPaths` und `endpoint`. `TOKEN_REFRESH` zusĂ€tzlich in die „immer loggen“-Ausnahme aufgenommen. - Verifiziert (E2E mit echter Middleware gegen Dev-DB, 5 Requests): `TOKEN_REFRESH/LOW` (Erfolg), `TOKEN_REFRESH/HIGH` (Fehlschlag), `LOGIN/CRITICAL`, `LOGIN_FAILED/CRITICAL`, `LOGOUT/CRITICAL`, alle mit vollem `endpoint`-Pfad und korrekten Labels. `tsc` grĂŒn. - [x] **đŸ›Ąïž Refresh-Fehlschlag: Detection-Gap geschlossen (Pentest R164-01)** (2026-08-18) - Folgefund zum Entrauschen: `determineAction` gab `/auth/refresh` bedingungslos `TOKEN_REFRESH`/LOW → ein **fehlgeschlagener** Refresh (Replay/Brute-Force auf geraubte/geratene Refresh-Tokens) rutschte als LOW durch und entging der Alarmierung (Angreifer weicht von `/login` auf `/refresh` aus, um unter CRITICAL zu bleiben). - **Wichtig:** Audit-Actions speisen die Alert-Engine NICHT (die zĂ€hlt `SecurityEvent`-Zeilen via `emit()`). Der Tester-Minimalvorschlag (Action → `LOGIN_FAILED`) hĂ€tte also keinen Alert ausgelöst. Echter Fix an 2 Ebenen: - **Detection:** `refresh()`-Catch emittiert jetzt `TOKEN_REJECTED` → greift die bestehende Schwelle `≄3 TOKEN_REJECTED/5min/IP → CRITICAL` (securityAlert.service, kein Severity-Filter). Severity wie Access-Token: abgelaufen/revoked → LOW (benigne, kein Sofort-Alert), ungĂŒltige Signatur/Manipulation → HIGH (Sofort-Alert). `auth.service` reicht dafĂŒr `err.code` REFRESH_EXPIRED/REFRESH_INVALID durch. „Kein Cookie" emittiert NICHT (normaler Erstbesuch). - **Audit-Triage:** fehlgeschlagener Refresh → SensitivitĂ€t HIGH statt LOW + Label „Token-Refresh abgelehnt (ungĂŒltig/abgelaufen)". Action bleibt bewusst `TOKEN_REFRESH` (semantisch ein Refresh, kein Login). - Verifiziert: tsx-Test — abgelaufen→LOW, manipuliert/garbage→HIGH; `tsc` grĂŒn. - Nebenbefund R164-02 (pre-existing, kein Commit von uns): Refresh-Rotation bietet keinen Replay-Schutz (alter Token bis exp gĂŒltig), aber fail-closed nach Logout. Ggf. spĂ€ter: Refresh-Token-Jti-Blacklist / One-Time-Use. - [x] **🔇 Audit-Log: stiller Token-Refresh entrauscht (`TOKEN_REFRESH`)** (2026-08-18) - Automatische `POST /auth/refresh`-Aufrufe (Interceptor bei 401 / nach Seiten-Reload, da Access-Token nur im Speicher) wurden als `CREATE` / „Anmeldung erstellt" / **CRITICAL** / User `anonymous` geloggt → sah aus wie anonyme Login-Flut, war aber die eigene Session. - Neuer `AuditAction`-Wert **`TOKEN_REFRESH`** (Enum-Migration `20260818120000_audit_token_refresh_action`, idempotentes `MODIFY COLUMN`). `determineAction()` erkennt `/auth/refresh` → eigene Action; Label „Sitzung verlĂ€ngert (Token erneuert)"; SensitivitĂ€t in der Middleware explizit auf **LOW** (statt Default `Authentication → CRITICAL`). - Frontend `AuditLogs.tsx`: Filter-Option „Sitzung verlĂ€ngert" + dezente Badge-Farbe (slate); Typ-Union ergĂ€nzt. `anonymous` bleibt (Endpoint lĂ€uft ohne `authenticate`-Middleware, authentifiziert per Cookie im Service) – bewusst nicht geĂ€ndert (Option 1). - Verifiziert: Migration auf Dev-DB aktiv, `tsc` + `vite build` grĂŒn. - [x] **🔒 Mass-Assignment-Schutz: Nested-Vertragsdetails (Pentest R162-01)** (2026-08-18) - `createContract`/`updateContract` spreadeten `energyDetails`/`tvDetails`/ `carInsuranceDetails`/`mobileDetails` (via `...mobileData`) roh an Prisma → injizierte `id`/`contractId` konnten ein Detail-Objekt **reparenten** (auf Fremdvertrag umhĂ€ngen) oder den **PK frei setzen** (stilles 200 statt 400). MEDIUM (IntegritĂ€t; kein Cross-Tenant, staff-only, Portal 403). - **Fix:** Feld-Whitelists (`pickEnergyScalars`/`pickMobileScalars`/`pickTvScalars`/ `pickCarInsuranceScalars`, analog R158) an allen Spread-Stellen. `internet` war bereits explizit (safe). Whitelists programmatisch gegen die DB-Spalten abgeglichen (minus id/contractId/verschlĂŒsselt) – alle Diffs leer. - Verifiziert: `energyDetails:{basePrice:99.99, id:999999, contractId:fremd}` → basePrice aktualisiert, ecd.id + contractId **unverĂ€ndert** (kein Reparenting). - [x] **📋 Aufgaben ohne Kunde/Vertrag anlegbar** (2026-08-18) - `ContractTask.contractId` nullable (Migration `20260818110000`). Neuer Endpoint `POST /tasks` (staff-only, `contracts:update`) fĂŒr allgemeine Aufgaben ohne Vertrag/Kunde. Ohne Vertrag → **kein Kunde → nie im Portal sichtbar** (`visibleInPortal` serverseitig erzwungen false; Portal-Reply-Endpoint 403 bei contractloser Aufgabe; getAllTasks-Portal-Filter schließt sie automatisch aus). - Task-Modal (Mitarbeiter): Checkbox „Ohne Kunde (allgemeine Aufgabe)" – blendet Kunden-/Vertragsauswahl **und** „Im Kundenportal sichtbar" aus. Task-Liste zeigt solche Aufgaben als „Allgemeine Aufgabe (ohne Vertrag)" (kein Vertrags-Link). - [x] **📧 Kunde: E-Mail Pflichtfeld + keine verwaltete Provider-Domain** (2026-08-18) - Private Kunden-E-Mail (`Customer.email`) darf nicht auf einer bei den E-Mail- Providern konfigurierten Domain (oder Subdomain) liegen → man trĂ€gt so keine verwaltete Weiterleitungs-/Mailbox-Adresse als private Adresse ein. E-Mail ist **nur beim Anlegen** Pflicht (Bestandskunden ohne E-Mail bleiben editierbar); die Domain-PrĂŒfung greift aber bei create UND update, falls eine gesetzt wird. - Helper `getConfiguredEmailDomains`/`emailUsesDomain` im emailProvider-Service. - [x] **⚡ Energievertrag: Ankreuzfeld „Keine Bonis erwĂŒnscht"** (2026-08-18) - `EnergyContractDetails.noBonusDesired` (Boolean, Migration `20260818100000`). Checkbox im Vertragsformular (Strom/Gas), Anzeige im Vertragsdetail. - [x] **🔌 MaLo-ID (Marktlokation) an die Lieferadresse verschoben (Strom/Gas)** (2026-08-14) - MaLo-ID gehört zur **(Liefer-)Adresse**, nicht zum Vertrag. Adresse bekommt **zwei Felder**: `maloIdElectricity` (Strom) + `maloIdGas` (Gas) – im AddressModal (nur Lieferadresse) pflegbar. - **Im Vertrag** ist die MaLo-ID jetzt ein **Lesefeld**, das je nach Sparte (ELECTRICITY→Strom, GAS→Gas) die MaLo der gewĂ€hlten Lieferadresse zeigt (mit Copy + „dort pflegen"-Link). ContractDetail/-Modal zeigen sie ebenso aus der Adresse. - **Schema + Migration** `20260814100000_address_malo_ids`: 2 Spalten (idempotent) **+ Daten-Migration** (bestehende `EnergyContractDetails.maloId` → jeweilige Lieferadresse, Strom→maloIdElectricity / Gas→maloIdGas). Verifiziert. - **Nebenbei einen selbst verursachten Regressions-Bug gefixt:** Beim R156- Mass-Assignment-Umbau waren die **10 `owner*`-Adressfelder** aus der Address- Whitelist gefallen → die EigentĂŒmer-Sektion speicherte seit `cb21a2c` nicht mehr. Address-Whitelist jetzt per Pick-Helper **programmatisch gegen alle DB-Spalten** abgeglichen (owner* + MaLo drin, id/customerId/Timestamps raus). - [x] **🔒 Mass-Assignment-Schutz: Contract-Create/Update (Pentest R158-Hygiene)** (2026-08-13) - Letzter Spread-Endpunkt (`createContract`/`updateContract` spreadeten rohen `...contractData` an Prisma) auf eine **Feld-Whitelist** umgestellt – konsistent zur R156-HĂ€rtung (BankCard/Address/Document). `id`/`contractNumber`/`createdAt`/ `updatedAt`/`portalPasswordEncrypted` und alle `cancellation*Path`-Felder sind damit **nicht** mehr per Form-Update setzbar (Pfade nur noch ĂŒber die Upload-Endpunkte). - **Whitelist autoritativ aus den DB-Spalten** abgeleitet (nicht aus dem unvollstĂ€ndigen `ContractCreateData`-Typ!) – dabei fielen 4 echte, vom Formular gesendete Felder auf, die NICHT im Typ standen und sonst still gebrochen wĂ€ren: `previousProviderId`, `previousContractNumber`, `previousCustomerNumber`, `nextReviewDate`. - Verifiziert: legit Felder (inkl. der 4) persistieren; injizierte `id`/`contractNumber`/`cancellationLetterPath` werden ignoriert. - [x] **đŸ—‚ïž Neuer Vertragsstatus „GekĂŒndigt / bestĂ€tigt" + KĂŒndigungs-Workflow** (2026-08-13) - Bisheriges **„GekĂŒndigt"** umbenannt in **„GekĂŒndigt / BestĂ€tigung abwarten"** (Status `CANCELLED`) – wird jetzt automatisch gesetzt, sobald ein **KĂŒndigungsschreiben** (`cancellationLetterPath`) hochgeladen wird (aus ACTIVE/PENDING/ONGOING/EXPIRED; nie DRAFT/DEACTIVATED/bereits bestĂ€tigt). - **Neuer Status „GekĂŒndigt / bestĂ€tigt"** (`CANCELLED_CONFIRMED`) – automatisch, sobald ein **KĂŒndigungsbestĂ€tigungsdatum** vorliegt (per BestĂ€tigungsdokument, das das Datum fĂŒllt, ODER manuell) + Vertragsende = KĂŒndigungsdatum. Hebt auch aus „BestĂ€tigung abwarten" hoch. - **Schema:** Enum-Wert `CANCELLED_CONFIRMED`, Migration `20260813200000_contract_status_cancelled_confirmed` (idempotentes `MODIFY COLUMN`). **Daten-Migration:** bestehende `CANCELLED` (unter alter Logik nur bei vorhandener BestĂ€tigung gesetzt) → `CANCELLED_CONFIRMED`. - **Cockpit-Semantik mitgewandert:** Fristen-Skip + „beendet" (Schlussrechnung) gelten jetzt fĂŒr `CANCELLED_CONFIRMED` (nicht mehr das reine „abwarten"); „KĂŒndigungsbestĂ€tigung fehlt"-Warnung greift dadurch weiter fĂŒr CANCELLED. `CANCELLED_CONFIRMED` in Ladeliste + KĂŒndigungsbestĂ€tigungs-Filter aufgenommen. - **Frontend:** Labels/Farben/Status-ErklĂ€rungen + Status-Dropdown in ContractList, ContractDetail, ContractForm, ContractDetailModal, CustomerDetail (CANCELLED = orange „abwarten", CANCELLED_CONFIRMED = rot). Verifiziert: Schreiben→CANCELLED, BestĂ€tigung→CANCELLED_CONFIRMED+Enddatum. - [x] **🔒 Mass-Assignment-Schutz: Bankkarte/Adresse/Ausweis (Pentest R155)** (2026-08-13) - Controller reichten rohen `req.body` an Prisma durch → `customerId` (Owner) und `id` (PK) waren per Update mutierbar (staff-only, kein Cross-Tenant, aber IntegritĂ€tsschwĂ€che – und mit `cardNumber` liegt Finanz-PII drauf). - **Fix:** explizite Feld-Whitelist im Service (create+update) fĂŒr **BankCard, Address, IdentityDocument** – nur benannte Felder gehen an Prisma, kein `...data`/`req.body`-Spread mehr. ZusĂ€tzlich Controller-`pickBankCardFields` fĂŒr saubere Audit-Logs (keine Phantom-EintrĂ€ge injizierter Keys). - Verifiziert: Update mit `{customerId:99999, id:88888, bogusField, cardNumber}` → id+customerId **unverĂ€ndert**, nur cardNumber gesetzt, Fremdfelder ignoriert. - [x] **đŸȘȘ Bankkarte-/Ausweis-Details in Vertrag (Ansicht + Bearbeiten) + Kartennummer** (2026-08-13) - **Schema:** neues Feld `BankCard.cardNumber` (String?, optional). Migration `20260813100000_bank_card_number` (`ADD COLUMN IF NOT EXISTS`), auf Dev angewandt + `prisma generate`. Prod zieht via `migrate deploy` im Entrypoint. Eingabefeld „Kartennummer" im Bankkarten-Modal (Kundenakte) ergĂ€nzt. - **Vertragsansicht (ContractDetail)** – Karten „Bankkarte"/„Ausweis" zeigen zusĂ€tzlich (jeweils mit Copy-Button, nur wenn gesetzt): - Bankkarte: BIC, Bank, Kartennummer, Ablaufdatum - Ausweis: Behörde, Ausstellung, Ablaufdatum + **Geburtsort/Geburtsdatum vom Kunden** - **Vertrag bearbeiten (ContractForm)** – unter den Bankkarte-/Ausweis- Dropdowns dieselben Detailfelder der aktuell gewĂ€hlten Karte/Ausweis (Copy-Buttons). Selects dafĂŒr je in eigenem `
` gewrappt (Grid-Alignment). - Datenquelle war bereits vorhanden: `getContractById` (bankCard/identityDocument/ customer) + `getCustomerById` (bankCards/identityDocuments) liefern alle Felder. - [x] **📄 PDF-Viewer-Modal fĂŒr Bankkarte-/Ausweis-Dokument** (2026-08-13) - Neue wiederverwendbare Komponente `PdfViewerModal` (Modal + `