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:
co-authored by
Claude Opus 5
parent
0c20698fd5
commit
91d8bf353e
+158
-2
@@ -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.
|
||||
|
||||
@@ -785,3 +785,100 @@ aushebelbar zu machen.
|
||||
Schlüssel am eigenen Konto hängt
|
||||
- [ ] Nach `strict`: Anmeldung mit dem Yubikey funktioniert weiter
|
||||
- [ ] `python tests/e2e/run.py --gruppe hardware`: alles grün
|
||||
|
||||
---
|
||||
|
||||
## 19. Umstellung auf 2.9.3 — Ergebnis der Sicherheitsbegutachtung
|
||||
|
||||
### 19.1 Was gefunden wurde
|
||||
|
||||
Die WebAuthn-Zeremonie selbst war in Ordnung. Der zweite Faktor ließ sich
|
||||
trotzdem umgehen — **nicht in der Zeremonie, sondern daneben**:
|
||||
|
||||
| Weg | Was möglich war |
|
||||
|---|---|
|
||||
| `/api/setup/v1/login` | Passwort allein genügte für ein `setup:install`-Token — der Endpunkt prüfte `totp_secret`, eine Spalte, die kein Anmeldeweg auswertet |
|
||||
| Datenbankfehler | `secondFactorRequired()` fing jedes `Throwable` und antwortete „kein zweiter Faktor" — ein Lock-Timeout schaltete die Anlage still auf Passwort-allein |
|
||||
| Übernommene Sitzung | Konnte einen eigenen Hardwarekey registrieren, Notfalltokens ausstellen und die Regel abschalten — ohne eine einzige erneute Bestätigung |
|
||||
|
||||
Der rote Faden: Ein zweiter Faktor ist nur so stark wie der **schwächste Weg
|
||||
zum Konto**. Zwei der drei Befunde waren Stellen, an denen ein Kommentar eine
|
||||
Zusicherung behauptete, die der Code nicht einlöste.
|
||||
|
||||
### 19.2 Vor dem Deployment prüfen
|
||||
|
||||
> **`app.url` muss in der `config.php` gesetzt sein.**
|
||||
|
||||
`rpId()` und `expectedOrigin()` leiten sich nicht mehr aus dem Host-Header ab —
|
||||
aus diesem Wert entstehen die beiden Anker, gegen die die ganze Zeremonie
|
||||
geprüft wird. Fehlt `app.url` **und** `security.webauthn_rp_id`, wird der
|
||||
Hardwarekey abgewiesen.
|
||||
|
||||
```bash
|
||||
curl -s https://dc.mhdf.de/api/health | grep -o '"webauthn_anchor":{[^}]*}'
|
||||
```
|
||||
|
||||
`"ok":true` — alles in Ordnung. `"ok":false` — vor dem Deployment eintragen:
|
||||
|
||||
```php
|
||||
'app' => ['url' => 'https://dc.mhdf.de'],
|
||||
```
|
||||
|
||||
### 19.3 Migration
|
||||
|
||||
```bash
|
||||
php cli/migrate.php # oder System → 🗄️ DB Migration
|
||||
```
|
||||
|
||||
Migration 022 ergänzt `dc_users.session_epoch`. Bestehende Anmeldungen bleiben
|
||||
gültig — eine Sicherheitsverbesserung, die im Moment ihrer Einführung alle
|
||||
Angemeldeten hinauswirft, wird beim nächsten Mal abgeschaltet.
|
||||
|
||||
### 19.4 Was sich im Arbeitsablauf ändert
|
||||
|
||||
- **Nach 30 Minuten Untätigkeit wird die Sitzung gesperrt**, nicht beendet.
|
||||
Der Sperrbildschirm öffnet mit dem **Passwort allein** — der Hardwarekey
|
||||
wird erst bei einer neuen Anmeldung wieder verlangt.
|
||||
- **Fünf Aktionen verlangen eine Bestätigung**, wenn die letzte
|
||||
Authentifizierung über zehn Minuten zurückliegt: Passwort setzen,
|
||||
Notfalltoken ausstellen, Hardwarekey aufnehmen oder entfernen, die
|
||||
Hardwarekey-Regel umstellen. Es erscheint derselbe Sperrbildschirm.
|
||||
- **Ein Passwortwechsel beendet alle anderen Sitzungen** des Kontos. Die
|
||||
eigene bleibt bestehen.
|
||||
- **Die Anmeldung nennt keine Gründe mehr.** Falscher Name, falsches Passwort,
|
||||
deaktiviertes und gesperrtes Konto ergeben dieselbe Meldung. Die Wartezeit
|
||||
steht darin, der Grund nicht — gesperrt wird nur, was existiert, und eine
|
||||
eigene Meldung dafür bestätigte die Existenz des Namens.
|
||||
|
||||
### 19.5 Was Sie selbst erledigen müssen
|
||||
|
||||
- [ ] **Rocket.Chat-Passwort rotieren.** In `config/config.example.php` stand
|
||||
ein echt aussehendes Passwort — die Beispieldatei liegt im Repository,
|
||||
und damit lag es dort auch. Ersetzt durch `''`; der Wert kommt jetzt aus
|
||||
`DC_RC_PASS`.
|
||||
- [ ] **`verify_ssl` prüfen.** Die Vorgabe der Beispieldatei stand auf
|
||||
`false` und steht jetzt auf `true`. Wer den alten Wert übernommen hat,
|
||||
prüfte Zertifikate nicht.
|
||||
- [ ] **Installationskonto anlegen**, falls Sie die Setup-API mit einem
|
||||
Administratorkonto benutzt haben. Rolle `installer`, unter System →
|
||||
Benutzerkonten.
|
||||
- [ ] **Notfalltoken erneuern**, wenn er älter als 180 Tage ist. Er läuft
|
||||
jetzt ab; die Oberfläche zeigt die Restlaufzeit.
|
||||
|
||||
### 19.6 Was bewusst offen bleibt
|
||||
|
||||
| Punkt | Warum nicht jetzt |
|
||||
|---|---|
|
||||
| CSP ohne `'unsafe-inline'` | Verlangt das Herauslösen von 93 Inline-Handlern und mehreren tausend Zeilen Inline-Skript aus `index.php`. Ein eigenes Vorhaben, kein Header. **Bis dahin schützt die CSP nicht gegen XSS** — sie schützt gegen fremde Skriptquellen, Einbettung und umgebogene Formularziele. |
|
||||
| Rollenmodell im WebUI | `ApiAuth` gibt einer Session jeden Scope ohne Prüfung; jeder WebUI-Benutzer ist Volladministrator. Ein echtes Berechtigungsmodell ist ein Vorhaben für sich. |
|
||||
| Document Root | Der gesamte Quellbaum inklusive `config/` hängt am Web und wird von `mod_rewrite` plus Verzeichnis-`.htaccess` gehalten. Richtig wäre `public/` als Root — das ist eine Änderung an der Hosting-Konfiguration, nicht am Code. |
|
||||
|
||||
### 19.7 Prüfen, ob alles läuft
|
||||
|
||||
- [ ] `/api/health` → `checks.webauthn_anchor.ok` ist `true`
|
||||
- [ ] Anmeldung mit Passwort und Hardwarekey funktioniert
|
||||
- [ ] `curl -I https://dc.mhdf.de/login.php` zeigt
|
||||
`Strict-Transport-Security` und `Content-Security-Policy`
|
||||
- [ ] `curl -I http://dc.mhdf.de/login.php` antwortet mit `301` auf `https://`
|
||||
- [ ] Setup-API mit einem Administratorkonto → `403 installer_account_required`
|
||||
- [ ] `python tests/e2e/run.py` gegen die Testinstanz: alles grün
|
||||
|
||||
Reference in New Issue
Block a user