fix(2.9.3): Der zweite Faktor war neben der Zeremonie umgehbar

Ergebnis einer beauftragten Sicherheitsbegutachtung. Die WebAuthn-
Zeremonie selbst ist nicht angefasst worden - sie war in Ordnung. Alle
Befunde lagen DANEBEN:

  Ein zweiter Faktor ist nur so stark wie der schwaechste Weg zum Konto,
  und Sitzungen, die aus ihm entstehen, erben seinen Schutz nicht.

KRITISCH
/api/setup/v1/login prueste auf totp_secret - eine Spalte, die kein
Anmeldeweg auswertet - und nicht auf dc_user_credentials. Ein
Administratorpasswort genuegte dort fuer ein setup:install-Token ohne
Hardwarekey. Neu: Konten mit zweitem Faktor abgewiesen, und die
Rollentrennung gilt symmetrisch. Der Docblock behauptete sie seit jeher;
erzwungen hat sie nichts.

HOCH
secondFactorRequired(), accountLockoutSeconds() und lockoutSeconds()
fingen jedes Throwable und fielen auf "kein Faktor" bzw. "nicht
gesperrt" zurueck. Gefangen wurden damit auch Verbindungsabbrueche und
Lock-Timeouts - jeder davon schaltete still fuer alle Konten auf
Passwort-allein. Toleriert werden jetzt nur noch 42S02 und 42S22.

HOCH
Fuenf Aktionen aenderten die Anmeldemittel ohne erneute Bestaetigung.
Eine uebernommene Sitzung konnte sich damit dauerhaft einnisten. Neu:
Authentifizierung aus den letzten zehn Minuten noetig, geprueft an
derselben Stelle, an der auch der Leerlauf sperrt.

WEITER
- Leerlauf 30 statt 120 Minuten, und GESPERRT statt beendet: AAL2 laesst
  zur Reaktivierung einen Faktor genuegen. Wer alle 30 Minuten den
  Hardwarekey vorzeigen muss, laesst ihn stecken.
- Migration 022: session_epoch. Ein Passwortwechsel beendet die uebrigen
  Sitzungen des Kontos, die eigene bleibt.
- Kontosperre wirkt auf der zweiten Stufe; Zaehler gegen unbegrenztes
  Raten im Zwischenzustand.
- Drosselung auf login.php (10/60s) und /api/auth/v1/ (30/60s).
- Anmeldefehler ununterscheidbar; die IP-Sperre darf benannt bleiben,
  sie sagt nichts ueber ein Konto.
- HSTS, TLS-Zwang VOR allen Routen, CSP. Die CSP traegt wegen 93
  Inline-Handlern nicht gegen XSS - das steht so im Kommentar.
- session.use_strict_mode, CSRF-Token bei der Anmeldung verworfen,
  Secure-Flag aus app.url statt aus $_SERVER['HTTPS'].
- rpId/expectedOrigin nicht mehr aus dem Host-Header. /api/health meldet
  unter checks.webauthn_anchor, ob der Anker steht.
- Notfalltoken laeuft nach 180 Tagen ab, geprueft nach dem Hashvergleich.
- config.example.php ohne echtes Passwort, verify_ssl auf true.
- Serverdaten.txt.bak mitgesperrt, dc_login_attempts raeumt auf.

BELEGT
188 von 188 Testfaellen gruen, darunter drei neue in der Gruppe
"haertung". Die Blockade einer veralteten Sitzung liess sich nicht in
der Suite pruefen - sie verlangt, das Zehn-Minuten-Fenster wirklich
abzuwarten. Dafuer gibt es ein Werkzeug ausserhalb; es belegt, dass der
Formularweg auf den Sperrbildschirm fuehrt, die Regel dabei unveraendert
bleibt, der JSON-Weg reauth_required meldet und das Passwort allein
wieder oeffnet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-09-21 14:58:31 +02:00
co-authored by Claude Opus 5
parent 0c20698fd5
commit 91d8bf353e
20 changed files with 1571 additions and 93 deletions
+158 -2
View File
@@ -1,6 +1,6 @@
# Hardwarekey als zweiter Faktor — Konzept und Übernahme
**Stand: Deploymentcenter 2.9.2, 21. September 2026**
**Stand: Deploymentcenter 2.9.3, 21. September 2026**
Dieses Dokument beschreibt die Hardwarekey-Anmeldung so, wie sie im
Deploymentcenter läuft, und zwar mit dem Ziel, sie in einem anderen Projekt
@@ -83,7 +83,20 @@ nichts.
> Kette bis zu einer Wurzel, deren privaten Schlüssel der Hersteller hat.**
> Alles andere ist abschreibbar.
### 2.4 Und die AAGUID?
### 2.4 Und die Wege NEBEN der Zeremonie
Die drei Fallen oben sind Fallen *innerhalb* der Zeremonie. Eine
Sicherheitsbegutachtung am 21.09.2026 fand die schwerere Klasse: Die Zeremonie
war korrekt, der zweite Faktor trotzdem umgehbar — **daneben**. Ausführlich in
§12; in einem Satz:
> Ein zweiter Faktor ist nur so stark wie der schwächste Weg zum Konto,
> und Sitzungen, die aus ihm entstehen, erben seinen Schutz nicht.
Wer §5 umsetzt und §12 überspringt, hat eine einwandfreie Zeremonie mit einer
Tür daneben.
### 2.5 Und die AAGUID?
Eine AAGUID ist eine **Behauptung im Datenstrom**. Als alleiniges Kriterium
wertlos. Sinnvoll erst *zusätzlich* zur Wurzelprüfung — und dann muss die
@@ -451,6 +464,24 @@ Eine Umsetzung gilt erst als fertig, wenn **alle** Punkte belegt sind:
Schlüssel wegen UNIQUE dauerhaft verbrannt)*
- [ ] Fehlermeldungen kommen beim Benutzer **lesbar** an
Und die Wege daneben (§12) — ohne diese ist die Zeremonie eine Tür neben
einer offenen:
- [ ] **Jeder** Anmeldepfad prüft den zweiten Faktor. Zählen Sie sie auf; was
Sie nicht vollständig aufschreiben können, ist der Befund
- [ ] Kein `catch (\Throwable)` in einem Authentifizierungspfad, der auf
„kein Faktor" oder „nicht gesperrt" zurückfällt
- [ ] Änderungen an den Anmeldemitteln verlangen eine frische
Authentifizierung
- [ ] Ein Passwortwechsel beendet die übrigen Sitzungen des Kontos
- [ ] Leerlauf sperrt (ein Faktor genügt zum Öffnen), Absolutgrenze beendet
- [ ] `rpId`/`origin` stammen aus der Konfiguration, nicht aus dem
Host-Header — und eine Anzeige sagt vorher, ob sie gesetzt sind
- [ ] Der Notfalltoken altert; das Alter wird **nach** dem Hashvergleich
geprüft
- [ ] Anmeldefehler sind ununterscheidbar (falscher Name, falsches Passwort,
gesperrt, deaktiviert)
Der letzte Punkt steht nicht aus Höflichkeit dort: Genau hier hatte diese
Umsetzung einen Fehler. Die API verpackt Fehler als
`{ error: { code, message } }`, das JavaScript las `data.message ||
@@ -474,3 +505,128 @@ Begründung der Ablehnung stehen sollte.
| `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 |
---
## 12. Die Wege neben der Zeremonie
Alles hier stammt aus einer Begutachtung des fertigen Verfahrens. Die
Kryptographie musste dafür nicht angefasst werden — sie war in Ordnung. Jeder
Punkt ist unabhängig von der Sprache und gilt im Zielprojekt genauso.
### 12.1 Jeder Anmeldeweg muss den Faktor kennen
Das Deploymentcenter hat neben dem WebUI eine zweite Anmeldung für
Erstinstallationen. Sie prüfte ein TOTP-Feld, das **kein** Anmeldeweg
auswertet — und nicht die Tabelle, in der der tatsächlich umgesetzte zweite
Faktor liegt. Wer ein Administratorpasswort hatte, bekam dort ohne
Hardwarekey ein gültiges Token.
```php
if (!empty($user['totp_secret']) || Auth::secondFactorRequired($db, (int)$user['id'])) {
Http::fail(403, 'second_factor_required', '...');
}
if (Auth::roleOf($user) !== 'installer') {
Http::fail(403, 'installer_account_required', '...');
}
```
**Prüffrage fürs Zielprojekt:** Zählen Sie *alle* Stellen auf, an denen ein
Passwort gegen ein Konto geprüft wird. Jede einzelne muss den zweiten Faktor
kennen oder das Konto ablehnen. Eine Liste, die Sie nicht vollständig
aufschreiben können, ist der Befund.
**Und die Trennung symmetrisch:** Der Docblock behauptete seit jeher, dieser
Weg sei „für Installationskonten gedacht" — erzwungen hat das nichts. Der
umgekehrte Fall war dagegen geprüft. Eine Trennung, die nur in eine Richtung
wirkt, ist eine Notiz, keine Trennung.
### 12.2 Authentifizierung muss geschlossen scheitern
```php
} catch (\PDOException $e) {
if ($e->getCode() === '42S02' || $e->getCode() === '42S22') {
return false; // Migration steht aus - bekannt, harmlos
}
throw $e; // alles andere: abbrechen
}
```
Vorher stand dort `catch (\Throwable)` mit `return false`. Begründet war das
mit einer fehlenden Tabelle vor der Migration — gefangen wurden aber auch
Verbindungsabbrüche, Lock-Timeouts, „too many connections" und entzogene
Rechte. Jeder davon schaltete still für **alle** Konten auf Passwort-allein
zurück, und dieselbe Bauart hob bei `lockoutSeconds()` gleichzeitig den
Brute-Force-Schutz auf.
**Die Regel:** In einem Authentifizierungspfad darf ein `catch` nur die
Fehler schlucken, die Sie *benennen* können. Lieber keine Anmeldung als eine
ohne zweiten Faktor.
### 12.3 Die Sitzung erbt den Schutz nicht
Ein Hardwarekey schützt die **Anmeldung**. Er schützt nicht die Sitzung, die
daraus entsteht. Solange eine übernommene Sitzung die Anmeldemittel ändern
darf, ist er gegen genau den Fall wirkungslos, gegen den er gedacht ist:
- eigenen Schlüssel registrieren → dauerhafter Zugang, fällt niemandem auf
- Notfalltoken ausstellen → Ersatzweg in fremder Hand
- Regel abschalten → zweiter Faktor anlagenweit weg
Diese Aktionen brauchen eine Authentifizierung aus den letzten Minuten
(„sudo mode"). Im Deploymentcenter sind das zehn Minuten und **ein**
Bildschirm — derselbe, den auch der Leerlauf benutzt. Zwei Formulare, die
beide ein Passwort prüfen, laufen mit der Zeit auseinander.
### 12.4 Ein Passwortwechsel muss fremde Sitzungen beenden
Sonst lässt er genau den drin, dessentwegen man ihn vornimmt. PHP führt
keinen Index „alle Sitzungen des Kontos X"; eine Zählergeneration löst es mit
einer Spalte:
```sql
ALTER TABLE users ADD COLUMN session_epoch INT NOT NULL DEFAULT 0;
```
Die Sitzung merkt sich den Stand beim Anmelden, jede Anfrage vergleicht, der
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.
### 12.5 Leerlauf: sperren, nicht beenden
AAL2 verlangt erneute Authentifizierung nach 30 Minuten Untätigkeit und lässt
zur Reaktivierung **einen** Faktor genügen. Wer stattdessen die volle
Anmeldung erzwingt, erzieht dazu, den Schlüssel steckend liegen zu lassen —
und dann ist der zweite Faktor keiner mehr. Eine Regel, die zur Umgehung
erzieht, ist schlechter als eine mildere, die eingehalten wird.
Eine gesperrte Sitzung darf sich außerdem **nicht selbst verlängern**, sonst
hält ein offener Tab mit Hintergrundabrufen die Sperre ewig auf.
### 12.6 Der Anker darf nicht vom Aufrufer kommen
`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.
### 12.7 Kleinigkeiten mit Hebelwirkung
- **Fehlermeldungen am Login vereinheitlichen.** Gesperrt wird nur, was
existiert; eine eigene Meldung „Konto gesperrt" hebt jede sorgfältige
Laufzeitangleichung wieder auf. Die Wartezeit darf darin stehen, der Grund
nicht. Die IP-Sperre darf benannt werden — sie sagt nichts über ein Konto.
- **Drosselung auf den Anmeldepfaden.** Kontosperren greifen erst nach N
Versuchen; bis dahin darf sonst beliebig schnell geraten werden.
- **`session.use_strict_mode`.** PHP-Vorgabe ist *aus* — der Server übernimmt
jede vom Client erfundene Sitzungskennung.
- **CSRF-Token bei der Anmeldung verwerfen.** Es liegt in der Session und
überlebt `session_regenerate_id()` sonst.
- **Notfalltoken altern lassen.** Ein Zettel im Schrank altert; ein Token
ohne Ablauf nicht. Alter **nach** dem Hashvergleich prüfen, sonst verrät die
Antwortzeit, ob einer existiert.
- **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.