diff --git a/docs/HARDWAREKEY.md b/docs/HARDWAREKEY.md index 6bbadf2..f7573b4 100644 --- a/docs/HARDWAREKEY.md +++ b/docs/HARDWAREKEY.md @@ -5,8 +5,18 @@ Dieses Dokument beschreibt die Hardwarekey-Anmeldung so, wie sie im Deploymentcenter läuft, und zwar mit dem Ziel, sie in einem anderen Projekt **gleichwertig** nachzubauen. Es ist bewusst konzeptlastig: Der PHP-Code ist -die Referenz, nicht der Kern. Der Kern sind die Entscheidungen — und -besonders die drei Dinge, die *wie* eine Prüfung aussehen und keine sind. +die Referenz, nicht der Kern. Der Kern sind die Entscheidungen. + +Es deckt zwei Ebenen ab, und die zweite ist die, die man zuerst übersieht: + +1. **Die Zeremonie** (§2–§7): die Dinge, die *wie* eine Prüfung aussehen und + keine sind, und was stattdessen trägt. +2. **Die Wege daneben** (§12): ein zweiter Anmeldepfad, der den Faktor nicht + kannte; eine Prüfung, die bei Datenbankfehlern stillschweigend abschaltete; + eine übernommene Sitzung, die die Anmeldemittel ändern durfte. + +Dazu §13 (welche Entscheidung welche Zertifizierungsanforderung erfüllt) und +§14 (wie man Eigenschaften belegt, die sich nicht unit-testen lassen). --- @@ -481,6 +491,12 @@ einer offenen: geprüft - [ ] Anmeldefehler sind ununterscheidbar (falscher Name, falsches Passwort, gesperrt, deaktiviert) +- [ ] Eine Diagnose meldet **vor** dem Anmelden, ob der Anker gesetzt ist + *und ob er zur tatsächlichen Adresse passt* (§12.6) +- [ ] Sitzungen aus der Zeit vor der Generations-Migration gelten weiter + (§12.4) — sonst wirft die Einführung jeden Angemeldeten hinaus +- [ ] Die negativen Tests prüfen den **Ablehnungsgrund**, nicht nur die + Ablehnung (§14.1) Der letzte Punkt steht nicht aus Höflichkeit dort: Genau hier hatte diese Umsetzung einen Fehler. Die API verpackt Fehler als @@ -502,9 +518,12 @@ Begründung der Ablehnung stehen sollte. | `src/Core/Webauthn/CredentialRepo.php` | Schlüssel und Notfalltoken | | `src/Core/Webauthn/roots/yubico.pem` | Die drei Wurzeln | | `public/api/auth/v1/index.php` | Die vier Aktionen | -| `sql/migrations/019_*.sql`, `021_*.sql` | Datenmodell | -| `tests/e2e/suite_hardware.py` | Fünf Fälle, ohne Krypto-Bibliothek | -| `docs/UPGRADE.md` §17, §18 | Einführung und Verschärfung im Betrieb | +| `public/login.php` | Zweite Stufe, Notfalltoken **und** Sperrbildschirm (§12.8) | +| `public/api/health.php` | Ankerdiagnose `checks.webauthn_anchor` (§12.6) | +| `sql/migrations/019_*.sql`, `021_*.sql` | Datenmodell des Schlüssels | +| `sql/migrations/022_*.sql` | `session_epoch` (§12.4) | +| `tests/e2e/suite_hardware.py` | Gruppen `hardware` und `haertung`, ohne Krypto-Bibliothek | +| `docs/UPGRADE.md` §17–§19 | Einführung, Verschärfung, Härtung im Betrieb | --- @@ -593,6 +612,20 @@ Wechsel erhöht. Die eigene Sitzung übernimmt den neuen Stand — wer sein Passwort ändert, soll sich nicht selbst hinauswerfen. Ein **Zähler**, keine Uhr: kein Zeitzonen- und kein Gleichstandsproblem. +**Die Rollout-Falle:** Sitzungen, die vor der Migration entstanden sind, +tragen den Wert gar nicht. Wer sie als ungültig behandelt, wirft im Moment +der Einführung jeden Angemeldeten hinaus — und eine Sicherheitsverbesserung, +die das tut, wird beim nächsten Mal abgeschaltet. Fehlt der Wert, gilt die +Sitzung weiter: + +```php +if (!isset($_SESSION['dc_session_epoch'])) { + return true; // aus der Zeit vor der Migration +} +``` + +Die Lücke schließt sich von selbst: Jede neue Anmeldung trägt den Stand ein. + ### 12.5 Leerlauf: sperren, nicht beenden AAL2 verlangt erneute Authentifizierung nach 30 Minuten Untätigkeit und lässt @@ -608,9 +641,37 @@ hält ein offener Tab mit Hintergrundabrufen die Sperre ewig auf. `rpId` und `expectedOrigin` aus dem Host-Header abzuleiten heißt, den Maßstab der Prüfung vom Geprüften setzen zu lassen. Fest konfigurieren, und -bei fehlender Konfiguration **werfen** statt raten. Dazu eine Anzeige, die -das sichtbar macht, bevor jemand sich anmeldet — sonst tauschen Sie eine -stille Schwäche gegen eine stille Aussperrung. +bei fehlender Konfiguration **werfen** statt raten. + +Damit tauschen Sie aber eine stille Schwäche gegen eine stille Aussperrung: +Ohne den Wert nimmt die Anlage keinen Hardwarekey mehr an, und gemerkt wird +das beim nächsten Anmeldeversuch. Es braucht also eine Diagnose, die das +**vorher** sichtbar macht — im Deploymentcenter unter +`/api/health` → `checks.webauthn_anchor`. + +**Und diese Diagnose muss vergleichen, nicht nur nachsehen.** Die erste +Fassung meldete, *ob* ein Wert gesetzt ist. Das beantwortet die Frage nicht: +Ein Tippfehler in der Adresse weist den Schlüssel genauso ab wie ein +fehlender Wert, weil der Browser den echten Ursprung in `clientDataJSON` +einträgt und der zu `expectedOrigin` passen muss. + +```php +$erwarteterHost = parse_url($appUrl, PHP_URL_HOST); +$tatsaechlicherHost = explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]; + +$checks['webauthn_anchor'] = [ + 'ok' => $gesetzt && $passt, + 'configured' => $gesetzt, + 'matches_request' => $passt, +]; +``` + +Dass hier der Host-Header herangezogen wird, ist unbedenklich: Es ist eine +**Diagnose, keine Entscheidung**. Wer ihn fälscht, erzeugt eine falsche +Warnung und sonst nichts — während die Zeremonie ihn unverändert ignoriert. +Die Unterscheidung lohnt sich zu merken: Derselbe Wert darf an der einen +Stelle verboten und an der anderen nützlich sein, solange klar ist, welche +von beiden etwas entscheidet. ### 12.7 Kleinigkeiten mit Hebelwirkung @@ -630,3 +691,169 @@ stille Schwäche gegen eine stille Aussperrung. - **Sperren auch auf der zweiten Stufe.** Sonst ist der Zwischenzustand zwischen Passwort und Faktor die Stelle, an der unbegrenzt geraten werden darf — also die schwächste, obwohl sie die stärkere sein soll. + +### 12.8 Ein Bildschirm für Leerlauf und Bestätigung + +§12.3 und §12.5 verlangen dasselbe: „bitte noch einmal das Passwort". Es +liegt nahe, dafür zwei Dinge zu bauen — einen Sperrbildschirm für den +Leerlauf und ein Bestätigungsformular für heikle Aktionen. Tun Sie es nicht. +Zwei Stellen, die beide Passwörter prüfen, laufen mit der Zeit auseinander, +und beim Prüfen von Passwörtern ist das die teuerste Art von Duplikat. + +Der Trick ist, den Leerlauf-Zustand *willentlich* herbeizuführen: + +```php +// Bei Leerlauf: sperren statt vernichten +if (($now - $lastSeen) > self::IDLE_TIMEOUT) { + $_SESSION['dc_locked'] = true; + return; +} + +// Vor einer heiklen Aktion: denselben Zustand herstellen +public static function lockForReauth(): void +{ + $_SESSION['dc_locked'] = true; + unset($_SESSION['dc_reauth_at']); +} +``` + +Die ganze Wirkung hängt dann an einer Zeile in `isLoggedIn()`: + +```php +if (!empty($_SESSION['dc_locked'])) { + return false; // eine gesperrte Sitzung ist keine Anmeldung +} +``` + +Jede Stelle, die bisher `isLoggedIn()` gefragt hat — WebUI, API, Endpunkte — +bekommt damit ohne eigenes Zutun die richtige Antwort. Sie müssen nichts +aufzählen, und deshalb können Sie auch nichts vergessen. + +Zwei Feinheiten, die sonst beißen: + +- **Die Weiterleitung muss unterscheiden.** Eine gesperrte Sitzung gehört auf + den Sperrbildschirm (Passwort genügt), eine fehlende auf die Anmeldung + (Passwort *und* Faktor). Derselbe Ort für beides wäre entweder zu streng + oder zu lasch. +- **Die Formulardaten gehen verloren.** Nach dem Entsperren landet man wieder + auf der Seite, nicht in der abgebrochenen Aktion. Sagen Sie das in der + Meldung, sonst sucht man nach einer Wirkung, die nie eingetreten ist. + + +--- + +## 13. Was welche Zertifizierungsanforderung erfüllt + +Nützlich, wenn das Zielprojekt ein Sicherheitsprotokoll hat: Die Zahlen in +diesem Dokument sind nicht gegriffen, sie stammen aus Anforderungen. Wer sie +ändert, sollte wissen, welche davon er verlässt. + +| Entscheidung | Wert hier | Woher | +|---|---|---| +| Zweiter Faktor kryptographisch, phishing-resistent | WebAuthn | NIST SP 800-63B, AAL2 (Phishing-Resistenz verlangt erst AAL3 — WebAuthn liefert sie ohnehin) | +| Erneute Authentifizierung bei Untätigkeit | 30 Min | AAL2; **ein** Faktor genügt zur Reaktivierung | +| Absolute Sitzungsgrenze | 12 Std | AAL2 | +| Mindestlänge Passwort | 12 Zeichen | PCI DSS 4.0, 8.3.6 | +| Kontosperre nach N Fehlversuchen | 5 | PCI DSS 4.0, 8.3.4 verlangt höchstens 10 | +| MFA auf **allen** Zugangswegen | §12.1 | PCI DSS 4.0, 8.4.2 — der Befund, der die Setup-API betraf | +| Erneute Authentifizierung vor Änderung der Anmeldemittel | 10 Min | OWASP ASVS, Kapitel Authentifizierung | +| Sitzungen bei Passwortwechsel beenden | §12.4 | OWASP ASVS, Kapitel Sitzungsverwaltung | +| HSTS, TLS-Zwang, Sicherheits-Header | §12.7 | OWASP ASVS, Kapitel Konfiguration | +| Protokollierung sicherheitsrelevanter Ereignisse | SecurityLogger | ISO 27001:2022 A.8.15 | +| Authentisierungsinformation, sichere Authentisierung | §12.1–§12.4 | ISO 27001:2022 A.5.17, A.8.5 | +| Identitäts- und Berechtigungsmanagement | Rollentrennung §12.1 | BSI IT-Grundschutz ORP.4, APP.3.1 | + +**Zwei Ehrlichkeiten dazu:** + +- Eine Begutachtung, die nur die Authentifizierungskapitel betrachtet, kann + auch nur über diese eine Aussage treffen. „Die genannten Blocker sind + geschlossen" ist belegbar; „ASVS L2 bestanden" wäre es nicht. +- ISO 27001 zertifiziert das Managementsystem, nicht den Code. Was hier steht, + ist Nachweismaterial für einen Auditor — kein Zertifikat. + +### 13.1 Wo die Anforderungen sich widersprechen + +Das ist der interessante Teil. AAL2 verlangt Reauthentifizierung alle 30 +Minuten. Nimmt man das als „volle Anmeldung mit Hardwarekey", entsteht ein +Verhalten, das der Standard gerade nicht will: Der Schlüssel bleibt stecken, +weil ihn jemand achtmal am Tag sucht. Derselbe Standard lässt deshalb **einen** +Faktor zur Reaktivierung genügen. + +> Eine Regel, die zur Umgehung erzieht, ist schlechter als eine mildere, die +> eingehalten wird. + +Wer im Zielprojekt eine Zahl verschärft, sollte diese Frage mitstellen: +Welches Verhalten erzeugt sie bei dem Menschen, der sie täglich einhalten +muss? + +--- + +## 14. Wie man belegt, was sich nicht unit-testen lässt + +Drei der wichtigsten Zusicherungen in diesem Dokument lassen sich nicht mit +einem gewöhnlichen Testfall prüfen. Sie sind trotzdem belegbar — nur anders. + +### 14.1 Der Test muss aus dem richtigen Grund scheitern + +Die Probe aus §2.3 (selbst ausgestelltes Zertifikat) ist wertlos, wenn die +Ablehnung wegen der **Signatur** kommt: Dann hat der Test sich selbst falsch +gebaut und über die Kette nichts gezeigt. Prüfen Sie also nicht nur *dass* +abgelehnt wurde, sondern **womit**: + +```python +if 'signatur' in meldung.lower(): + return 'UNKLAR: an der Signatur gescheitert - beweist nichts ueber die Kette' +if 'wurzel' in meldung.lower() or 'kette' in meldung.lower(): + return 'BESTANDEN' +``` + +Das gilt allgemein: Ein negativer Sicherheitstest, der den Ablehnungsgrund +nicht prüft, bestätigt irgendwann nur noch, dass irgendetwas kaputt ist. + +### 14.2 Manche Eigenschaften verlangen echtes Warten + +Ob eine **veraltete** Sitzung die Anmeldemittel noch ändern kann, hängt am +Zehn-Minuten-Fenster. Es für den Test zu verkleinern liegt nahe — und prüft +dann eine andere Anlage als die, die später läuft. Im Deploymentcenter +wartet ein Werkzeug außerhalb der Suite die vollen elf Minuten ab und prüft +danach: + +``` +A) Formularweg -> Sperrbildschirm +B) Zustand danach -> unverändert (die Aktion hatte keine Wirkung) +C) JSON-Weg -> 403 reauth_required +D) Sperrbildschirm -> wird angezeigt +E) Passwort -> öffnet wieder +``` + +Punkt B ist der eigentliche Beweis. „Wurde umgeleitet" heißt noch nicht +„hatte keine Wirkung" — eine Umleitung nach getaner Arbeit sieht genauso aus. + +**Eine Falle aus eigener Erfahrung:** Beim ersten Anlauf schickte die Probe +beide Wege durch dieselbe Sitzung. Schritt A hatte sie bereits gesperrt, also +meldete der JSON-Weg folgerichtig „nicht angemeldet" statt +„reauth_required" — ein Fehler der Probe, nicht des Codes. Zwei Zustände, die +sich gegenseitig ausschließen, brauchen zwei Sitzungen. + +### 14.3 Was sich gar nicht nachstellen lässt + +Der Gegenfall zu §2.3 — ein echter Yubikey, der eine gültige Kette vorlegt — +ist von einem Testrechner aus nicht erzeugbar; dafür bräuchte man den privaten +Wurzelschlüssel des Herstellers, und genau darauf beruht die ganze Aussage. +Diese Hälfte belegt nur echte Hardware. Planen Sie den Handgriff ein, statt +ihn wegzuautomatisieren. + +### 14.4 Was in die automatische Reihe gehört + +Alles, was ohne Kryptographie und ohne Warten auskommt — und das ist mehr, +als es zunächst scheint. Bei einer Registrierung mit `fmt: "none"` prüft der +Server **keine Signatur**; es genügt ein fest eingebauter, gültiger +Kurvenpunkt. Damit lässt sich der entscheidende Vergleich ohne jede +Abhängigkeit fahren: + +> Dieselben Bytes eines Authenticators ohne Herkunftsnachweis — einmal mit +> und einmal ohne Regel. + +Ebenso automatisierbar: dass die Anmeldung die Existenz eines Kontos nicht +verrät, dass HSTS und CSP ankommen, dass die Diagnose aus §12.6 grün meldet, +und dass ein Konto der falschen Rolle am zweiten Anmeldepfad abgewiesen wird.