# DBCERT-2668 — IG Metall ABN CSR-Signing Failure (Postmortem)

**Status:** Resolved (backend-side) / Follow-up pending (Pages Worker)
**Incident window:** 2026-04-07 09:15 UTC → 2026-04-13 12:20 UTC (partner-side blocking)
**Root-cause analysis:** 2026-04-23
**Fix deployment:** 2026-04-23 (Commits `e5bd737` private, `b7b835b` public)

---

## 1. Summary (TL;DR)

Partner IG Metall versuchte zwischen dem 07.04. und 13.04.2026 insgesamt **18 Mal** einen CSR auf der ABN-Stage signieren zu lassen. Alle 18 Versuche scheiterten — die Mehrheit mit `HTTP 400: "Invalid CSR: CN must match client certificate UUID."`. Der Partner sah aber beim Endkunden nur eine leere `HTTP 500 Content-Length 0`, weil der zwischengeschaltete Cloudflare Pages Worker (`/api/data/ccc/create`) alle Backend-4xx-Antworten auf 500 mappte und den strukturierten JSON-Body verwarf.

**Root Cause:** Klientseitig falsch erzeugter CSR (CN stimmte nicht mit `cc`-POST-Parameter überein) + Backend-Fehlermeldung war ohne `expected`/`actual`-Werte nicht selbsterklärend genug + Pages-Worker-seitiges Response-Swallowing versteckte die Backend-Message komplett.

**Fix (backend-side, deployed 2026-04-23):**
- Option A — Alle 6 CSR-Subject-Validierungen (`C`, `ST`, `L`, `O`, `OU`, `CN`) liefern jetzt `expected` + `actual` im Error-Body
- Option B — `.csr`-Datei wird ab sofort direkt nach der PEM-Format-Validierung persistiert (statt erst nach allen Subject-Checks), sodass auch bei Subject-Fehlern eine forensische Analyse möglich ist

**Follow-up (Pages-Worker-side, ticket vorbereitet):** Pages Worker soll Backend-Responses transparent durchreichen statt als 500 zu verschleiern. Ticket-Body siehe Abschnitt 9.

---

## 2. Identifiers

### ABN (betroffen)
| Feld | Wert |
|---|---|
| CC.ID (UUID) | `180a37ea-2107-4a5e-8ace-59dae57010e2` |
| OrgID (CAS) | `9a344f9a-0e9e-4c1e-a2e8-3b8e46d4c8d2` |
| BMIS | `9266131` |
| Endpoint | `https://gen.abn.bahn.business/abn/sign-csr-abn.php` |
| Partner-Slug (CAS lowercase) | `ig_metall` |

### PRD (Vergleichs-Referenz, funktioniert)
| Feld | Wert |
|---|---|
| CC.ID (UUID) | `bf7dc87b-147d-4ead-a41e-d7477c2eb74e` |
| OrgID (CAS) | `8e347e6a-9c61-473b-9e00-db221a943230` |
| BMIS | `5000162` |
| Endpoint | `https://gen.prd.bahn.business/prd/sign-csr-prd.php` |
| Status zum Incident | Wöchentliche Renews, seit Monaten produktiv (letzte 3 Signings: 07.04., 14.04., 21.04.) |

---

## 3. Timeline (MESZ / UTC)

| Datum/Zeit | Event | Quelle |
|---|---|---|
| 2026-04-07 11:15:50 MESZ | Erster ABN-Versuch — 400 `State (ST) must be "abn"` | `abn/csr/180a37ea-...1775553350.res` |
| 2026-04-07 11:17:04–11:17:08 MESZ | 3 weitere ST-Fehler (schneller Retry) | .res Files 1775553424–1775553428 |
| 2026-04-07 11:18:59 MESZ | Erster CN-Fehler — ST korrigiert, CN bleibt falsch | 1775553539.res |
| 2026-04-07 11:19:10 MESZ | 500 `unexpected parameters detected` (+85 B im Body) — Partner testete Extra-Parameter | 1775553550.res |
| 2026-04-07 11:19:18–11:33:13 MESZ | 8 weitere CN-Fehler, Partner gibt auf | — |
| 2026-04-13 10:39:19 MESZ | Retry #15 — 400 CN | 1776069559.res |
| **2026-04-13 11:13:37 MESZ (09:13:37 UTC)** | **Ticket-Referenz-Zeitpunkt** — 400 CN | **1776071617.res** |
| 2026-04-13 14:17:45 MESZ | 400 CN | 1776082665.res |
| 2026-04-13 14:18:32 MESZ | 400 `Invalid CSR format. Must be PEM-encoded.` (CSR war 0 Bytes) | 1776082712.res |
| 2026-04-13 14:20:02 MESZ | 400 CN — **letzter Versuch** | 1776082802.res |
| 2026-04-13 bis 2026-04-23 | **10 Tage Stille** — Partner kontaktiert Support | — |
| 2026-04-23 | Root-Cause-Analyse + Backend-Fix + Commits + Push | Commit `b7b835b` |

---

## 4. Investigation Approach

### 4.1 Scope-Hypothesen beim Start

Aus dem Tickettext war unklar, ob es ein systemischer Fehler (CA-Datei fehlt, Permissions, OpenSSL-Defekt) oder ein klientseitiger Fehler war. Geprüfte Ursachen-Kategorien:

1. Fehlende/nicht-lesbare CA-Datei (2026.0)
2. Berechtigungs-Fehler auf Private-Key
3. OpenSSL-Signing-Fehler
4. Temp-Dir voll/unschreibbar
5. Cloudflare Access Reject (Request kommt nicht durch)
6. Whitelist-/Partner-spezifische Ablehnung
7. Sub-CA-Konfig-Abweichung PRD↔ABN
8. PHP Fatal Error (Syntax/Undefined)

### 4.2 Log-Quellen (gefunden auf CA-Gen-Server `db-cert-generate`)

- `/var/log/apache2/access.log{,.N}` — Apache Combined-Log, Response-Size enthält Fehler-Body-Länge
- `/var/www/html/{stage}/csr/{UUID}.{unix-timestamp}.req` — vollständiger HTTP-Request (Header + POST-Params, CSR-Body redigiert)
- `/var/www/html/{stage}/csr/{UUID}.{unix-timestamp}.res` — vollständige HTTP-Response (Status + Body)
- `/var/www/html/{stage}/csr/{UUID}.{unix-timestamp}.csr` — NUR bei erfolgreich validiertem CSR (pre-patch)
- `/var/www/html/{stage}/csr/{UUID}.{unix-timestamp}.success.log` / `.error.log` — 0-Byte-Marker, nur bei OpenSSL-Stufe angelegt

### 4.3 Wichtige Fund-Sequenz

1. Im Access-Log am 13.04. um 11:13:37 MESZ: `POST /abn/sign-csr-abn.php HTTP/1.1 400 231` — Response-Code ist **400**, nicht 500. Der 500 entstand ausschließlich im Pages Worker vorne dran.
2. In `180a37ea-...1776071617.res`: Body war `{"error":"Invalid CSR: CN must match client certificate UUID."}`.
3. Aggregation über alle 18 .res-Dateien: 14× CN-Fehler, 4× ST-Fehler, 1× Format-Fehler, 1× Parameter-Security-Fehler. **Null erfolgreiche Signings.**
4. Cross-Check PRD für denselben Partner (`bf7dc87b-...`): Letzte 3 wöchentliche Signings alle HTTP 200 mit Subject `C=cc, ST=prd, L=cert, O=bahn, OU=business, CN=bf7dc87b-147d-4ead-a41e-d7477c2eb74e`.
5. Cross-Check anderer ABN-Partner am 13.04.: `09199ef1-...` 8× HTTP 200, `400eb687-...` 3× 400 → 1× 200 (eigenständig debuggt), weitere mit 200 — **ABN-Signing systemisch OK**.
6. Filesystem: `/var/www/html/abn/ca/server-cert.abn.bahn.business-2026.0_cert.pem` + `_key.pem` existieren, lesbar für `www-data`, identische Größe zu PRD. Kein systemischer Defekt.
7. Diagnose: Klientseitige Fehlkonfiguration im CSR-Generator des Partners. `$_POST['csr']` wird aber aus Security-Gründen im `.req` redigiert (Zeile 109–115 im PHP-Skript), d.h. der tatsächliche falsche CN-Wert ist **nicht rekonstruierbar** aus den Logs.

---

## 5. Root Cause

### Primäres Root Cause (klientseitig)
Der CSR, den IG Metalls Tooling auf ABN erzeugt, hat einen CN-Wert, der **nicht** mit der ABN-CC.ID `180a37ea-2107-4a5e-8ace-59dae57010e2` übereinstimmt. Plausibelste Kandidaten (nach Wahrscheinlichkeit):

1. **PRD-CC.ID `bf7dc87b-...` als CN** — Partner hat PRD-CSR-Template für ABN kopiert und nur einzelne Felder geändert. Starkes Indiz: erste 4 Versuche am 07.04. hatten auch noch `ST=prd` (aus PRD-Kopie), wurde dann korrigiert — CN blieb vermutlich unverändert.
2. **ABN-OrgID `9a344f9a-...`** — Verwechslung der beiden UUID-Typen.
3. **BMIS `9266131`** — unwahrscheinlich, da nicht UUID-Format und Validierung dann anderen Fehler werfen würde.

### Sekundäre Root Causes (unsere Systeme)
- **Backend-Fehlermeldung war unterinformativ:** `"CN must match client certificate UUID"` ohne `expected`/`actual`-Werte — Partner konnte aus der Message allein nicht ableiten, was zu ändern ist.
- **Pages Worker verschluckt 4xx-Antworten:** Alle Nicht-200 vom PHP-Backend kommen beim Endkunden als `HTTP 500 Content-Length 0` an. Selbst wenn die Message informativ wäre — der Partner sähe sie nie.
- **Backend persistiert den CSR nicht bei Validierungsfehlern:** `.csr`-Datei wurde erst nach allen Subject-Checks geschrieben (alte Zeile 234). Bei 400-CN-Fehler gab es also keine Spur des konkreten CSR-Inhalts. Forensik nur über klientseitige Neueinreichung möglich.

---

## 6. Solution — Implemented Backend Changes

Alle 7 sign-csr-Skripte (`abn`, `prd`, `pres`, `lup`, `int`, `tst`, `pen`) sind strukturgleich (byte-identische Validierungsblöcke, nur `$stage` unterscheidet sich). Änderungen wurden **einheitlich auf alle 7 Stages** angewendet.

### Option A — Erweiterte Fehlermeldungen

Alle 6 CSR-Subject-Validierungen erhalten `expected`/`actual` im Error-Body.

**Betroffene Checks:**
| Field | Expected | Actual |
|---|---|---|
| `C` (countryName) | `"cc"` | Tatsächlicher CSR-Wert oder `null` |
| `ST` (stateOrProvinceName) | `$stage` (z.B. `"abn"`) | Tatsächlicher CSR-Wert oder `null` |
| `L` (localityName) | `"cert"` | Tatsächlicher CSR-Wert oder `null` |
| `O` (organizationName) | `"bahn"` | Tatsächlicher CSR-Wert oder `null` |
| `OU` (organizationalUnitName) | POST-Parameter `$ou` (`"business"` oder `"services"`) | Tatsächlicher CSR-Wert oder `null` |
| `CN` (commonName) | POST-Parameter `$cc` (UUID) | Tatsächlicher CSR-Wert oder `null` |

**Beispiel-Response vorher/nachher:**
```json
// Vorher
{"error":"Invalid CSR: CN must match client certificate UUID."}

// Nachher
{"error":"Invalid CSR: CN must match client certificate UUID.","expected":"180a37ea-2107-4a5e-8ace-59dae57010e2","actual":"bf7dc87b-147d-4ead-a41e-d7477c2eb74e"}
```

**Security-Abwägung:** Alle Werte in `expected`/`actual` sind öffentliche Identifier, die der Partner selbst im POST-Request mitschickt oder im CSR-Subject definiert. Kein Geheimnis, keine Privileg-Eskalation.

### Option B — Frühe CSR-Persistierung

Die `.csr`-Datei wird ab sofort direkt nach dem PEM-Format-Check (Zeile 188) geschrieben, **bevor** die Subject-Validierungen laufen.

**Vorher:** `.csr` wurde nur geschrieben, wenn alle Validierungen durchliefen (alte Zeile 234, nach CN-Check).
**Nachher:** `.csr` wird geschrieben, sobald der CSR syntaktisch valides PEM ist — unabhängig vom Subject-Inhalt.

**Konsequenz:** Bei jedem zukünftigen 400-Fehler nach dem PEM-Check liegt der komplette originale CSR auf der Platte für forensische Analyse. Kein Raten mehr über den falschen CN-Wert.

**Security-Abwägung:** CSRs enthalten nur Public-Key + Subject + Signature. Kein Private-Key-Material. Das existierende `.gitignore`-Pattern `{stage}/csr/*` verhindert weiterhin, dass diese Dateien nach GitHub gelangen.

---

## 7. Deployment

### Geänderte Dateien (8 insgesamt, beide Repos)
```
abn/sign-csr-abn.php
prd/sign-csr-prd.php
pres/sign-csr-pres.php
lup/sign-csr-lup.php
int/sign-csr-int.php
tst/sign-csr-tst.php
pen/sign-csr-pen.php
CHANGELOG.md
```

### Commits
| Repo | Commit-SHA | Branch | Remote |
|---|---|---|---|
| `/var/www/html` (privat) | `e5bd737` | `master` | — (kein Remote) |
| `/srv/pki-public` (public) | `b7b835b` (nach Rebase) | `pki-public` | `REDITS-GmbH/cert-pki` |

### Verifikationen vor Deployment
- `php -l` auf allen 7 Skripten: 0 Syntax-Fehler
- `'expected'`-Vorkommen pro Skript: exakt 6 (je Subject-Check ein Eintrag)
- CSR-Write-Positionen: exakt 1 pro Skript an Zeile 188 (kein Duplikat)
- Cross-file `diff` abn↔andere Stages: unverändert zu pre-Patch (nur `$stage` + `$caCertPath` + `2025.0`/`2025.db`-Default differieren)
- `diff /var/www/html/CLAUDE.md /srv/pki-public/CLAUDE.md`: 0 Zeilen
- Security Check: `find /srv/pki-public -name "*_key.pem"` leer, keine Secrets/Tokens in den 8 geänderten Dateien

### Commit-Konflikt-Auflösung beim Push
Beim `git push` des Public-Repos rejected GitHub wegen 33 Commits Vorsprung (parallele KI-Session zu DNS-Maintenance, Cluster-Resilience, Slack-Alerting). `git pull --rebase` führte zu Konflikt in CHANGELOG.md (Remote hatte bereits ein `### Fixed`-Block für dns-maintenance-v1.1-Bugs, lokal lag `### Changed` für DBCERT-2668 an). Manuell gemerged — beide Blöcke koexistieren unter `[Unreleased]`.

---

## 8. Verification Steps

### End-to-End-Test nach Partner-Re-Submission
1. Partner sendet CSR mit korrigiertem `CN = 180a37ea-2107-4a5e-8ace-59dae57010e2`.
2. Erwartete Response: `HTTP 200` + JSON-Body mit `cc.public.pem` (~9 kB).
3. Filesystem-Check auf CA-Gen-Server:
   ```bash
   ls /var/www/html/abn/csr/180a37ea-2107-4a5e-8ace-59dae57010e2.*.crt
   ls /var/www/html/abn/csr/180a37ea-2107-4a5e-8ace-59dae57010e2.*.success.log
   grep "Status: 200" /var/www/html/abn/csr/180a37ea-2107-4a5e-8ace-59dae57010e2.*.res | tail -1
   ```
4. Zertifikat-Validität:
   ```bash
   openssl x509 -in <neueste>.crt -noout -subject -issuer -dates
   ```
   Erwartet:
   - `subject: CN = 180a37ea-2107-4a5e-8ace-59dae57010e2`
   - `issuer: CN = server-cert.abn.bahn.business-2026.0`
   - `notAfter ≈ notBefore + 30 days`

### Regression-Check auf anderen Partnern
- 09199ef1-... (ABN), bf7dc87b-... (PRD): Wöchentliche Renew-Zyklen weiter erfolgreich, kein 200→400-Switch durch Patch.

### Forensik-Check (Option B)
- Bei jedem zukünftigen 400-Fehler nach dem PEM-Check: `.csr`-Datei muss existieren mit Content = Original-CSR.
- Verify: `openssl req -in <failed>.csr -noout -subject` zeigt den tatsächlichen (falschen) Subject.

---

## 9. Follow-Up — Pages Worker Ticket (ausstehend)

Für die andere KI-Session vorbereitet: vollständiger Ticket-Body mit Response-Matrix für das Cloudflare Pages Worker Endpoint `/api/data/ccc/create`. Ziel: Backend-4xx-Responses (inklusive `expected`/`actual`) transparent an Endkunden durchreichen statt als leere 500 zu verschlucken.

### Kern-Response-Matrix (Kurzfassung)

| Status | Anzahl Ursachen | Kategorie | Pages-Worker-Empfehlung |
|---|---|---|---|
| 200 | 1 | Success | durchreichen (heute schon OK) |
| 400 | 13 | Client-fixable | **durchreichen** (heute: verschluckt → 500) |
| 404 | 1 | Nicht-POST | defensiv mappen auf 405 |
| 500 | 6 | Server-Issues | 500.1 auf 400 mappen (`unexpected parameters`), Rest als 500 durchreichen |

Vollständige Matrix, Akzeptanzkriterien und Pseudocode siehe Session-Handover-Text (gespeichert im Konversationsverlauf der KI-Session am 2026-04-23).

### Weitere bekannte Issues (separate Tickets)

- **500.6 — OpenSSL-Signing-Fallthrough:** Wenn `openssl x509 -req` fehlschlägt, setzt das Skript `$errored = true`, gibt aber trotzdem HTTP 200 mit `cc.public.pem = null` zurück (Zeile 289–295). Das sollte als 500 beim Kunden ankommen. Backend-seitiger Fix nötig: `sendResponse($errored ? 500 : 200, ...)`.
- **.req/.res-Backlog:** Public-Repo-Filesystem hat ~5.670 `.req`/`.res`-Dateien weniger als private. Kein GitHub-Risiko (gitignored via `{stage}/csr/*`), aber interne rsync-Drift. Full-Sync mit `sync-to-public.sh` ohne `--delete` würde aufräumen.

---

## 10. Lessons Learned

1. **Log-Redaktion hat einen Preis.** Das PHP-Skript redigiert den CSR-Inhalt aus `$_POST['csr']` im `.req`-File (Security-by-Design) und `php://input` ist bei `multipart/form-data` leer. Das verhindert CSR-Leaks in Debug-Logs, kostet uns aber die Möglichkeit zur post-hoc-Analyse wenn **Validierung** (nicht Signing) fehlschlägt. Option B (früher `.csr`-Write) schließt diese Lücke.

2. **Fehlermeldungen ohne `expected`/`actual` sind Black-Box-Diagnostik.** 18 Fehlversuche über 6 Tage zeigen, dass der Partner die generische Message nicht zur korrekten Aktion führen konnte. Strukturierte Error-Bodies mit diagnostischem Kontext sind **keine** Privacy-/Security-Kostenstelle — die Werte gehören dem Partner ohnehin.

3. **Response-Swallowing in Zwischenschichten ist ein Multiplikator.** Der Pages Worker alleine hätte nicht so lange Blockade verursacht; Backend-Messages allein auch nicht. Erst die Kombination (lossy Wrapper + lossy Message) führte zu 6 Tagen Ausfall. Beide Schichten müssen diagnostisch sein.

4. **Cross-Stage-Uniformität schützt.** Weil alle 7 PHP-Skripte byte-identische Validierungsblöcke haben, konnte ein Patch deterministisch auf alle 7 übertragen werden. Divergenz zwischen Stages würde solche Roll-Outs zum Minenfeld machen.

5. **Dual-Repo-Drift-Gotcha:** Beim Commit-Vorgang war `CHANGELOG.md` in privat nicht synchron mit public — parallel laufende Sessions hatten nur die public-Seite weitergeschrieben. Der Session Checklist-Punkt "Consistency Check" ist genau dafür da; hier war es nur CHANGELOG, kein CLAUDE.md, daher kein harter Blocker, aber beim nächsten Mal früher mergen statt erst beim Push.

---

## 11. Referenzen

- **Backend-Code (post-patch):** `https://github.com/REDITS-GmbH/cert-pki/blob/pki-public/abn/sign-csr-abn.php` (alle 7 Stages identisch modulo Stage-Name)
- **CHANGELOG-Entry:** `CHANGELOG.md` → `[Unreleased]` → `### Changed` → "CSR Signing: Improved Error Responses & Forensic Logging" (2026-04-23)
- **Infrastruktur-Doku:** `docs/CA-GEN-SERVER.md` (CA-Gen-Server `db-cert-generate`)
- **Ursprünglicher Ticket-ID:** DBCERT-2668 (extern getracked, Partner-Kontakt über Standard-Ticket-Prozess)
