Compare commits

...
5 Commits
Author SHA1 Message Date
Deploymentcenter BotandClaude Opus 5 12fb06fb48 docs: Die Lehren aus 2.9.3 in die Uebernahme-Anleitung
Was die Sicherheitsbegutachtung an Erkenntnis gebracht hat, galt bisher
nur zur Haelfte im Dokument: Abschnitt 12 nannte die Befunde, aber drei
Dinge fehlten, die im Zielprojekt genauso gelten.

NEU §13 - was welche Zertifizierungsanforderung erfuellt
Die Zahlen in diesem Dokument sind nicht gegriffen, sie stammen aus
Anforderungen: 30 Minuten und 12 Stunden aus AAL2, zwoelf Zeichen und
hoechstens zehn Fehlversuche aus PCI DSS 4.0, die Wiederanmeldung vor
Aenderungen an den Anmeldemitteln aus ASVS. Wer eine Zahl aendert, soll
wissen, welche Anforderung er dabei verlaesst. Mit zwei Ehrlichkeiten:
Eine Begutachtung der Authentifizierungskapitel kann auch nur ueber
diese eine Aussage treffen, und ISO zertifiziert das Managementsystem,
nicht den Code.

§13.1 nennt den Fall, in dem die Anforderungen sich widersprechen - 30
Minuten als VOLLE Neuanmeldung erzeugen genau das Verhalten, das der
Standard nicht will: Der Schluessel bleibt stecken.

NEU §14 - wie man belegt, was sich nicht unit-testen laesst
Drei der wichtigsten Zusicherungen sind mit einem gewoehnlichen
Testfall nicht pruefbar. Der negative Test muss den ABLEHNUNGSGRUND
pruefen, sonst bestaetigt er irgendwann nur noch, dass irgendetwas
kaputt ist. Das Zehn-Minuten-Fenster verlangt echtes Warten; es fuer
den Test zu verkleinern prueft eine andere Anlage. Und der Gegenfall
mit echter Hardware laesst sich gar nicht automatisieren - das ist ein
einzuplanender Handgriff, keine Luecke.

Samt der Falle, in die die eigene Probe zuerst gelaufen ist: zwei
Zustaende, die sich gegenseitig ausschliessen, durch dieselbe Sitzung
geschickt.

NEU §12.8 - ein Bildschirm fuer Leerlauf und Bestaetigung
Beide verlangen dasselbe. Zwei Stellen, die Passwoerter pruefen, laufen
auseinander. Der Leerlaufzustand laesst sich willentlich herbeifuehren,
und die ganze Wirkung haengt dann an einer Zeile in isLoggedIn() - jede
Stelle, die sie fragt, bekommt ohne eigenes Zutun die richtige Antwort.

GESCHAERFT §12.6
Die Ankerdiagnose muss VERGLEICHEN, nicht nur nachsehen, ob ein Wert
gesetzt ist: Ein Tippfehler weist den Schluessel genauso ab wie ein
fehlender Wert. Dass dafuer der Host-Header dient, ist unbedenklich -
es ist eine Diagnose, keine Entscheidung.

ERGAENZT §12.4
Die Rollout-Falle der Sitzungsgeneration: Sitzungen aus der Zeit vor der
Migration muessen weitergelten, sonst wirft die Einfuehrung jeden
Angemeldeten hinaus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 17:24:06 +02:00
Deploymentcenter BotandClaude Opus 5 df67932019 fix(2.9.3): Ankerpruefung vergleicht, statt nur auf "gesetzt" zu sehen
checks.webauthn_anchor meldete bisher nur, OB app.url oder
security.webauthn_rp_id einen Wert tragen. Das beantwortet die Frage
nicht, die dahintersteht: Ein Tippfehler in app.url weist den
Hardwarekey genauso ab wie ein fehlender Wert - der Browser traegt den
echten Ursprung in clientDataJSON ein, und der muss zu expectedOrigin
passen.

Die Pruefung vergleicht jetzt zusaetzlich mit dem Namen, unter dem die
Anfrage tatsaechlich hereinkam, und meldet configured und
matches_request getrennt. Dass dafuer der Host-Header herangezogen wird,
ist unbedenklich: Es ist eine Diagnose, keine Entscheidung. Wer ihn
faelscht, erzeugt eine falsche Warnung und sonst nichts - waehrend
WebauthnServer ihn weiterhin unveraendert ignoriert.

Damit ist die Auskunft vor einem Deployment belastbar, statt nur die
haelfte der Frage zu beantworten.

188 von 188 Testfaellen gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 15:52:51 +02:00
Deploymentcenter BotandClaude Opus 5 91d8bf353e 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>
2026-09-21 14:58:31 +02:00
Deploymentcenter BotandClaude Opus 5 0c20698fd5 fix(2.9.2): Fehlermeldung lesbar machen, Modellliste ins Formularraster
Zwei Befunde aus der Abnahme auf der Testinstanz.

1. Die Ablehnung eines Softwarekeys erschien als "[object Object]".
   Die API verpackt Fehler als { error: { code, message } }; das
   JavaScript las "data.message || data.error" und griff damit auf das
   OBJEKT zu - ausgerechnet an der Stelle, an der die Begruendung stehen
   sollte, warum ein Schluessel abgelehnt wurde. Eine Regel, die ihren
   Grund nicht nennt, ist in der Bedienung dasselbe wie ein Defekt.

   Derselbe Fehler steckte in login.php. Dort waere er schwerer wiegend:
   An der zweiten Stufe ist diese Meldung der einzige Hinweis, warum es
   nicht weitergeht. Beide lesen jetzt beide Formen - die verschachtelte
   und die flache, die weiterhin vorkommt.

2. Die Textbox fuer die Modellliste stand neben statt unter ihrer
   Beschriftung und behielt ihre Eigengroesse. Der umschliessende
   form-group fehlte; genau der setzt im Stylesheet display:flex mit
   column und laesst das Feld die Breite fuellen. Alle anderen
   Textboxen der Oberflaeche haben ihn.

Zusaetzlich docs/HARDWAREKEY.md: Konzept, Beispielcode und Prueflíste
zur gleichwertigen Uebernahme in ein anderes Projekt. Der Schwerpunkt
liegt auf den drei Dingen, die wie eine Pruefung aussehen und keine
sind - cross-platform, response.authenticatorAttachment und ein blosses
x5c -, samt dem Gegentest, ohne den nichts belegt ist.

Kein Eingriff in src/, sql/ oder public/api/ - der Kern ist unveraendert
gegenueber dem Lauf mit 184 von 184 gruenen Testfaellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:37:49 +02:00
Deploymentcenter BotandClaude Opus 5 073d52ab7d feat(2.9.2): Nur geprueft in Hardware entstandene Schluessel als zweiter Faktor
Befund aus dem Betrieb: Ein Passkey aus 1Password liess sich als
Hardwarekey registrieren. Fuer das Deploymentcenter hinnehmbar, fuer ein
Protokoll, das ausdruecklich Hardware fordert, nicht.

Zwei Dinge, die wie eine Pruefung aussahen und keine waren - beide seit
2.9.0 gesetzt: authenticatorAttachment "cross-platform" steuert, was der
Browser anbietet, und ein Passwortverwalter meldet sich ebenso. Und
response.authenticatorAttachment setzt der Client, also genau die Instanz,
gegen die sich nichts beweisen laesst.

Was traegt, ist Basic Attestation. Neu geprueft wird die Kette bis zu einer
hinterlegten Herstellerwurzel; die drei FIDO-Wurzeln von Yubico liegen bei.
Ein x5c allein genuegt ausdruecklich nicht: Der Fall "eigenes Zertifikat,
Name Yubico, AAGUID eines echten YubiKey 5, gueltige Unterschrift" wurde
nachgebaut und wird abgewiesen - an der Kette, nicht an der Signatur.

Drei Stufen statt eines Schalters, weil alle bisher registrierten
Schluessel als nicht nachgewiesen gelten - auch echte Yubikeys. Bei
attestation "none" ersetzt der Browser die Geraetekennung durch Nullbytes,
bevor der Server sie sieht; es gab also nie eine Herkunft. Ein einzelner
Schalter haette beim Umlegen den eigenen Zugang gesperrt. Der Wechsel nach
"strict" wird zusaetzlich abgelehnt, solange das eigene Konto keinen
nachgewiesenen Schluessel hat.

- Attestation.php: packed und fido-u2f, Kettenpruefung gegen eigene
  Wurzeln statt gegen den Speicher des Betriebssystems, Gueltigkeit,
  Gegenprobe der AAGUID aus der Zertifikatserweiterung
- HardwarePolicy.php: off / register / strict, optionale Modellliste,
  Wurzeln aus der Konfiguration erweiterbar
- Migration 021: attested und drei Anzeigespalten, Vorgabe 0
- WebUI: Herkunft je Schluessel, Regel vor der Beruehrung angesagt
- testctl clear_credentials, damit ein Testlauf den naechsten nicht
  aussperrt
- Testreihe "hardware", 5 Faelle, ohne Krypto-Bibliothek

184 von 184 Testfaellen gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 10:44:19 +02:00
24 changed files with 4315 additions and 124 deletions
+59 -2
View File
@@ -10,6 +10,24 @@ Options -Indexes
<IfModule mod_rewrite.c>
RewriteEngine On
# ------------------------------------------------------------------
# 0. TLS erzwingen - und zwar VOR jeder Route
# ------------------------------------------------------------------
# Die Stelle ist der Punkt. Alle Routen darunter tragen [L]; stuende die
# Umleitung dahinter, wuerde sie fuer genau die Adressen nie erreicht,
# die es betrifft - login.php, index.php, die gesamte API.
#
# Ergaenzt HSTS, ersetzt es nicht: HSTS wirkt erst, nachdem der Browser
# die Anlage einmal ueber TLS gesehen hat. Die erste Anfrage ist genau
# die, die abgefangen wuerde, und die faengt diese Regel ab.
#
# Die zweite Bedingung gilt TLS-terminierenden Zwischenstellen: Dort ist
# %{HTTPS} leer, obwohl der Besucher verschluesselt spricht. Ohne sie
# liefe die Umleitung im Kreis.
RewriteCond %{HTTPS} !=on
RewriteCond %{HTTP:X-Forwarded-Proto} !=https
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# Bereits umgeschriebene Anfragen nicht erneut anfassen.
RewriteRule ^public/ - [L]
@@ -74,7 +92,11 @@ Options -Indexes
# 5. Alles Uebrige sperren: Anwendungscode, Konfiguration, Skripte
# ------------------------------------------------------------------
RewriteRule ^(config|src|sql|scripts|cli|var|client-dotnet|client-php|mcp)(/|$) - [F,L]
RewriteRule ^Serverdaten\.txt$ - [F,L]
# Auch die Sicherungskopie. deploy.py schliesst beide aus, die Datei
# sollte also nie hier liegen - aber die zweite Verteidigungslinie muss
# zur ersten passen, sonst deckt sie genau den Fall nicht ab, fuer den es
# sie gibt.
RewriteRule ^Serverdaten\.txt(\..*)?$ - [F,L]
</IfModule>
# ----------------------------------------------------------------------
@@ -85,12 +107,47 @@ Options -Indexes
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
# HSTS: Nach dem ersten Besuch spricht der Browser diese Adresse nur noch
# ueber TLS an - auch dann, wenn jemand einen http-Link unterschiebt. Die
# Umleitung weiter unten schuetzt erst ab der zweiten Anfrage; die erste
# ist genau die, die abgefangen wird.
#
# Ohne preload und ohne includeSubDomains waere es die halbe Wirkung; mit
# includeSubDomains gilt es fuer jede Unteradresse von mhdf.de. Wer hier
# eine Unteradresse ohne TLS betreibt, macht sie damit unerreichbar - das
# ist der Preis und der Grund, warum die Zeile bewusst hier steht und
# nicht in einer Vorlage.
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
# ------------------------------------------------------------------
# Content-Security-Policy - und was sie hier NICHT leistet
# ------------------------------------------------------------------
# 'unsafe-inline' steht in script-src, und das ist keine Nachlaessigkeit,
# sondern eine Tatsache ueber den Bestand: index.php enthaelt 93 Inline-
# Handler (onclick, onsubmit, onchange) und mehrere tausend Zeilen
# Inline-Skript. Ohne 'unsafe-inline' funktioniert die Oberflaeche nicht -
# mit ihr schuetzt die Richtlinie nicht gegen eingeschleustes Skript.
#
# Das ehrlich hinzuschreiben ist wichtiger, als es zu verschweigen: Eine
# CSP im Header laedt sonst dazu ein, XSS fuer erledigt zu halten. Was
# sie hier WIRKLICH leistet:
# - kein Skript von fremden Adressen (Exfiltration ueber <script src>)
# - keine Einbettung in fremde Seiten (frame-ancestors)
# - kein umgebogenes <base> (base-uri)
# - keine Formularziele ausserhalb der Anlage (form-action)
# - keine Plugins (object-src)
#
# Eine Richtlinie mit Nonce, die auch gegen XSS traegt, verlangt das
# Herausloesen aller Inline-Handler. Das ist ein eigenes Vorhaben und
# kein Header.
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
</IfModule>
# ----------------------------------------------------------------------
# Dateischutz auch ohne mod_rewrite
# ----------------------------------------------------------------------
<FilesMatch "^(Serverdaten\.txt|\.env.*|composer\.(json|lock)|deploy_config\.json)$">
<FilesMatch "^(Serverdaten\.txt(\..*)?|\.env.*|composer\.(json|lock)|deploy_config\.json)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
+8 -2
View File
@@ -84,10 +84,16 @@ return [
'enabled' => (bool)dc_env('DC_RC_ENABLED', true),
'url' => dc_env('DC_RC_URL', 'https://chat.wh1.mhdf.de'),
'username' => dc_env('DC_RC_USER', 'deploymentcenter'),
'password' => dc_env('DC_RC_PASS', 'cNt.m.KcWHrb8_X9Tv8-'),
// Leer lassen und ueber die Umgebungsvariable setzen. In dieser
// Datei stand bis 2.9.3 ein echtes Passwort - eine Beispieldatei
// liegt im Repository, und damit lag es dort auch.
'password' => dc_env('DC_RC_PASS', ''),
'status_channel' => dc_env('DC_RC_STATUS_CHANNEL', '#DC-Systemstatus'),
'alert_channel' => dc_env('DC_RC_ALERT_CHANNEL', '#DC-Alerts'),
'verify_ssl' => (bool)dc_env('DC_RC_VERIFY_SSL', false),
// Vorgabe true. Eine abgeschaltete Zertifikatspruefung ist in einer
// Beispieldatei die denkbar schlechteste Vorgabe: Sie wird
// uebernommen, ohne dass jemand die Entscheidung trifft.
'verify_ssl' => (bool)dc_env('DC_RC_VERIFY_SSL', true),
'report_interval' => (int)dc_env('DC_RC_REPORT_INTERVAL', 43200),
],
];
+859
View File
@@ -0,0 +1,859 @@
# Hardwarekey als zweiter Faktor — Konzept und Übernahme
**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
**gleichwertig** nachzubauen. Es ist bewusst konzeptlastig: Der PHP-Code ist
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).
---
## 1. Was das Verfahren leistet — und was nicht
**Leistet:** Nach dem Passwort verlangt die Anmeldung einen Gegenstand, der
physisch vorliegen muss. Es ist beweisbar, dass der private Schlüssel in
einem Sicherheitselement entstanden ist und es nie verlassen hat. Die
Anmeldung ist an die Adresse gebunden (RP-ID), also phishing-resistent: Eine
nachgebaute Seite bekommt keine gültige Signatur, weil der Browser die echte
Herkunft in die signierten Daten schreibt.
**Leistet nicht:**
- **Keine Prüfung bei der Anmeldung.** Attestation gibt es im Standard nur
bei der *Registrierung*. Die Aussage lautet: „Dieser Schlüssel ist
nachweislich in Hardware entstanden" — und sie trägt danach, weil der
private Teil diese Hardware nicht verlassen kann. Bei jeder späteren
Anmeldung wird nur noch die Signatur gegen den gespeicherten öffentlichen
Schlüssel geprüft.
- **Kein Schutz gegen einen kompromittierten Client.** Wer den Browser
kontrolliert, kann eine echte Zeremonie gegen einen echten Schlüssel
auslösen. Dagegen hilft Attestation nicht und soll es nicht.
- **Keine Aussage über den Menschen.** `userVerification` steht hier auf
`discouraged`, es wird also keine PIN und kein Fingerabdruck verlangt. Wer
den Schlüssel in der Hand hat, kommt hinein. Siehe §8.
---
## 2. Die drei Fallen
Das ist der wichtigste Abschnitt. Alle drei waren in der ersten Fassung
(2.9.0) vorhanden und wurden erst im Betrieb als wirkungslos erkannt.
### 2.1 `authenticatorAttachment: "cross-platform"` ist keine Prüfung
```js
authenticatorSelection: { authenticatorAttachment: 'cross-platform' }
```
Liest sich wie „nur angesteckte Hardware". Steuert aber nur, was der Browser
dem Benutzer **anbietet**. Ein Passwortverwalter meldet sich ebenfalls als
`cross-platform` und kommt anstandslos durch. Die Angabe darf drinbleiben —
sie verbessert die Auswahl —, aber sie sichert nichts zu.
### 2.2 `response.authenticatorAttachment` ist wertlos
Das Feld setzt der **Client**. Der Client ist genau die Instanz, gegen die
serverseitig nichts zu beweisen ist. Wer darauf eine Regel stützt, hat eine
Regel, die der Angreifer selbst ausfüllt.
### 2.3 Ein `x5c` allein beweist nichts
Der verführerischste Fehler. „Es ist ein Zertifikat dabei, also ist es
Hardware" — nein. Jeder Authenticator kann sich selbst eines ausstellen.
Dieser Fall wurde gegen den fertigen Server gefahren, mit:
- eigenem EC-Schlüsselpaar,
- selbst ausgestelltem Zertifikat, Betreff `CN=Yubico U2F EE Serial
99999999, O=Yubico AB, OU=Authenticator Attestation`,
- der AAGUID eines echten YubiKey 5 NFC — in `authData` **und** in der
Zertifikatserweiterung `1.3.6.1.4.1.45724.1.1.4`,
- einer kryptografisch einwandfreien ES256-Signatur über
`authData || SHA256(clientDataJSON)`.
Antwort: *„Die Zertifikatskette endet nicht bei einer hinterlegten
Herstellerwurzel."* Abgelehnt an der **Kette**, nicht an der Signatur. Genau
das ist die Probe, die eine Umsetzung bestehen muss. Kommt die Ablehnung
wegen der Signatur, hat der Test sich selbst falsch gebaut und beweist
nichts.
> **Die einzige Eigenschaft, die Software nicht herstellen kann, ist eine
> Kette bis zu einer Wurzel, deren privaten Schlüssel der Hersteller hat.**
> Alles andere ist abschreibbar.
### 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
AAGUID aus `authData` gegen die im Zertifikat gegengeprüft werden, sonst
filtert man wieder nach einer Behauptung.
**Empfehlung: Modellliste leer lassen.** Hersteller vergeben neuen
Firmwareständen neue AAGUIDs. Eine gepflegte Liste ist eine Liste, die
irgendwann einen echten Schlüssel abweist.
---
## 3. Datenmodell
```sql
CREATE TABLE user_credentials (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
credential_id VARBINARY(255) NOT NULL, -- roh vom Authenticator
public_key TEXT NOT NULL, -- PEM, nicht COSE (siehe unten)
alg INT NOT NULL DEFAULT -7, -- -7 = ES256, -257 = RS256
sign_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
aaguid VARBINARY(16) NULL,
attested TINYINT(1) NOT NULL DEFAULT 0, -- <- einzige Entscheidungsspalte
attestation_fmt VARCHAR(32) NULL,
attestation_ca VARCHAR(190) NULL,
attestation_root VARCHAR(190) NULL,
transports VARCHAR(128) NULL,
label VARCHAR(64) NOT NULL DEFAULT 'Hardwarekey',
created_at DATETIME NOT NULL,
last_used_at DATETIME NULL,
UNIQUE KEY ux_credential_id (credential_id),
KEY ix_credential_user (user_id)
);
```
Vier Entscheidungen, die man sonst zweimal trifft:
| Feld | Warum so |
|---|---|
| `public_key` als **PEM** | Geprüft wird mit `openssl_verify()`, das ein PEM will. Die Wandlung COSE→PEM einmal bei der Aufnahme zu bezahlen ist billiger als bei jeder Anmeldung. |
| `alg` **gespeichert** | Ließe sich aus dem PEM herleiten. Die Prüfung soll aber nicht davon abhängen, dass das Herleiten richtig geraten hat. |
| `credential_id` **UNIQUE global** | Die Kennung ist geräteweit eindeutig. Ohne UNIQUE landet derselbe Schlüssel an zwei Konten. |
| `attested` als **einzige** Entscheidungsspalte | Die drei Textspalten kommen aus einem Zertifikat, also von außen. Sie taugen zur Anzeige, nicht als Vergleichsgrundlage im Code. Das Bit hat der Server selbst gesetzt. |
Dazu am Benutzer: `recovery_hash`, `recovery_created_at`, `recovery_used_at`
für den Notfalltoken (§7).
---
## 4. Ablauf
### Registrierung (verlangt bestehende Anmeldung + CSRF)
```
Browser Server
|-- POST ?action=register_options ------------>|
| erzeugt 32 Byte challenge,
| legt sie in die Sitzung
|<-- publicKey{ challenge, rp, user, |
| pubKeyCredParams, attestation, |
| excludeCredentials } |
| |
| navigator.credentials.create() |
| |
|-- POST ?action=register_verify ------------->|
| { clientDataJSON, attestationObject } |
| 1. clientData prüfen
| 2. authData zerlegen
| 3. Attestation prüfen <- §5
| 4. Regel anwenden <- §6
|<-- { attested, root, aaguid } |
```
### Anmeldung (zweite Stufe, noch niemand angemeldet)
Der Zwischenzustand ist wichtig: Nach richtigem Passwort steht in der
Sitzung nur `pending_user_id` — `isLoggedIn()` bleibt **false**. Erst der
zweite Faktor macht daraus eine Anmeldung. Wer hier eine „halbe" Anmeldung
baut, hat die Sicherheitslücke schon eingebaut.
```php
const PENDING_TIMEOUT = 300; // Zwischenzustand verfällt nach 5 Minuten
```
---
## 5. Der Kern: Attestationsprüfung
Reihenfolge ist nicht beliebig. **Erst die Signatur, dann die Kette** — nur
so lässt sich später unterscheiden, ob ein Schlüssel kaputt oder bloß nicht
zugelassen ist.
```php
public static function inspect(
array $attestationObject, // CBOR-dekodiert
string $authDataRaw,
string $clientDataJson,
?string $aaguidFromAuthData,
array $trustedRoots // PEM je Wurzel
): array {
$format = $attestationObject['fmt'] ?? 'unbekannt';
$stmt = $attestationObject['attStmt'] ?? [];
// "none": Passwortverwalter, eingebaute Passkeys. Keine Behauptung.
if ($format === 'none' || $stmt === []) {
return ['attested' => false,
'reason' => 'Der Authenticator hat keine Attestation mitgeschickt.'];
}
$chain = $stmt['x5c'] ?? null;
// "self attestation": beweist, dass der Schlüssel zu sich selbst passt.
// Über Hardware sagt das nichts.
if (!is_array($chain) || $chain === []) {
return ['attested' => false,
'reason' => 'Die Attestation ist selbst unterschrieben.'];
}
$leafPem = self::derToPem($chain[0]);
$parsed = openssl_x509_parse($leafPem);
self::checkValidity($parsed); // abgelaufen?
$clientDataHash = hash('sha256', $clientDataJson, true);
if ($format === 'packed') { // CTAP2, Normalfall
self::verifyPacked($leafPem, $stmt['sig'],
$authDataRaw . $clientDataHash, $stmt);
self::checkAaguidExtension($parsed, $aaguidFromAuthData);
} elseif ($format === 'fido-u2f') { // CTAP1, ältere Keys
self::verifyU2f($leafPem, $stmt['sig'], $authDataRaw, $clientDataHash);
} else {
// tpm, android-key, apple: nicht umgesetzt. Ablehnen ist ehrlicher
// als durchwinken — ein ungeprüftes Format als "geprüft" zu führen
// wäre genau der Fehler, den diese Klasse behebt.
throw new \RuntimeException("Format \"$format\" wird hier nicht geprüft.");
}
$root = self::chainVerifies($chain, $trustedRoots); // <- die Aussage
if ($root === null) {
throw new \RuntimeException(
'Die Zertifikatskette endet nicht bei einer hinterlegten Herstellerwurzel.');
}
return ['attested' => true, 'root' => $root, 'format' => $format];
}
```
### 5.1 Kettenprüfung — gegen *eigene* Wurzeln
```php
private static function chainVerifies(array $derChain, array $trustedRoots): ?string
{
if ($trustedRoots === []) {
throw new \RuntimeException('Keine Herstellerwurzeln hinterlegt.');
}
$intermediates = array_map([self::class, 'derToPem'], array_slice($derChain, 1));
$current = self::derToPem($derChain[0]);
for ($step = 0; $step < 6; $step++) {
foreach ($trustedRoots as $rootPem) {
if (self::signedBy($current, $rootPem)) {
return self::describe(openssl_x509_parse($rootPem));
}
}
$next = null;
foreach ($intermediates as $i => $kandidat) {
if (self::signedBy($current, $kandidat)) {
$next = $kandidat; unset($intermediates[$i]); break;
}
}
if ($next === null) { return null; }
self::checkValidity(openssl_x509_parse($next));
$current = $next;
}
return null;
}
private static function signedBy(string $certPem, string $issuerPem): bool
{
$key = openssl_pkey_get_public($issuerPem);
return $key !== false && openssl_x509_verify($certPem, $key) === 1;
}
```
> **Nicht `openssl_x509_checkpurpose()` benutzen.** Dessen Wurzelspeicher ist
> der des Betriebssystems, und dort steht die Web-PKI — also genau die
> falsche Menge. Eine Attestationskette soll bei *einem* Hersteller enden
> und nirgends sonst.
### 5.2 `fido-u2f` — anderes signiertes Material
Ältere Schlüssel über CTAP1 signieren nicht `authData`, sondern:
```
0x00 | rpIdHash | clientDataHash | credentialId | 0x04 | x | y
```
Erbe aus der Zeit vor WebAuthn. Wer das Format weglässt, weist ältere
Hardware ab. U2F kennt nur ES256, und die AAGUID ist dort immer Null — eine
Modellliste greift für diese Geräte also grundsätzlich nicht.
### 5.3 Klonerkennung
```php
if (!($storedSignCount === 0 && $neuerZaehler === 0)
&& $neuerZaehler <= $storedSignCount) {
throw new \RuntimeException('Zähler nicht gestiegen — möglicherweise ein Duplikat.');
}
```
Die Ausnahme ist notwendig: Ein Teil der Hardware führt den Zähler gar nicht
und meldet konstant 0. „Zählt nicht" darf nicht wie „geklont" aussehen.
---
## 6. Die Regel — drei Stufen, nicht ein Schalter
| Stufe | Aufnahme | Anmeldung | `attestation` in den Vorgaben |
|---|---|---|---|
| `off` | jeder | alle | `none` |
| `register` | nur nachgewiesen | alle | `direct` |
| `strict` | nur nachgewiesen | nur nachgewiesene | `direct` |
**Warum die Mittelstufe zwingend ist:** Bei `attestation: "none"` ersetzt der
**Browser** die AAGUID durch 16 Nullbytes, bevor der Server sie sieht. Alle
vor der Umstellung registrierten Schlüssel haben daher *keine* Herkunft —
auch echte Yubikeys. Ein einzelner Schalter sperrt in dem Moment, in dem man
ihn umlegt, den eigenen Zugang aus.
**Der Übergang nach `strict` muss bewacht sein:**
```php
public function blockingReason(string $desiredLevel, int $actingUserId): ?string
{
if ($desiredLevel !== self::STRICT) { return null; }
$stmt = $this->db->prepare(
'SELECT COUNT(*) FROM user_credentials WHERE user_id = :u AND attested = 1');
$stmt->execute([':u' => $actingUserId]);
if ((int)$stmt->fetchColumn() > 0) { return null; }
return 'Kein nachgewiesener Schlüssel am eigenen Konto. '
. '"strict" würde die eigene Anmeldung sperren.';
}
```
> Eine Einstellung, die sich selbst aussperrt, ist keine Einstellung,
> sondern eine Falle.
**Und die Regel zweimal prüfen.** `allowCredentials` ist eine Vorgabe an den
Browser, keine Zusage des Clients:
```php
// login_options: filtert die Auswahl
$ids = $repo->rawIdsForUser($userId, $policy->appliesToLogin());
// login_verify: entscheidet wirklich
if ($policy->appliesToLogin() && (int)$credential['attested'] !== 1) {
Auth::recordSecondFactorFailure($db, 'not_attested');
Http::fail(403, 'not_attested', '...');
}
```
---
## 7. Ersatzweg: der Notfalltoken
Ohne ihn ist ein verlorener Schlüssel ein verlorenes Konto.
- Nur der **Hash** in der Datenbank, Klartext genau einmal bei der Erzeugung
sichtbar.
- **Einmalgebrauch**, Hash wird beim Verbrauch geleert.
- `recovery_used_at` bleibt stehen: Ohne diese Spur sieht „keiner
hinterlegt" genauso aus wie „es gab nie einen", während es in Wahrheit
heißt: *jemand hat ihn benutzt*.
- Alphabet ohne `0/O` und `1/I`, Gruppen zu vier Zeichen — er wird
abgeschrieben und vorgelesen.
- **Läuft als gewöhnliches Formular, nicht über JavaScript.** Ein Ersatzweg
mit denselben Voraussetzungen wie der Hauptweg ist keiner.
Der Notfalltoken gilt **unabhängig von der Regel**. Das ist Absicht: Er ist
der Weg zurück aus einer Fehlkonfiguration.
---
## 8. Bewusst offene Punkte
| Punkt | Stand | Wenn das Zielprojekt mehr braucht |
|---|---|---|
| `userVerification` | `discouraged` — keine PIN | Auf `required` stellen; verlangt dann PIN/Biometrie am Schlüssel und prüft das UV-Bit in `authData`. |
| FIDO Metadata Service | nicht angebunden | Kanonischer Weg zu „jede zertifizierte Hardware". Signierter JWT-Abruf mit Auffrischung — eine laufende Außenabhängigkeit in der Anmeldestrecke. |
| `tpm` / `apple` | abgelehnt | Windows Hello und Secure Enclave sind hardwaregestützt, sitzen aber im selben Gerät wie der erste Faktor. |
| Enterprise Attestation | nein | Liefert eine geräteindividuelle Kennung. Braucht Browser-Richtlinien auf verwalteten Rechnern. |
---
## 9. Portierung in ein anderes Projekt
### 9.1 Was sprachunabhängig ist
Alles in §2 bis §7. Die Fallen, die Reihenfolge der Prüfungen, das
Datenmodell, die drei Stufen, der Notfalltoken.
### 9.2 Was zu ersetzen ist
| PHP | Entsprechung .NET | Entsprechung Node |
|---|---|---|
| `openssl_x509_parse` | `X509Certificate2` | `crypto.X509Certificate` |
| `openssl_x509_verify($c,$k)` | `X509Certificate2.Verify()` bzw. Signaturprüfung über `X509Chain` mit eigenem `CustomTrustStore` | `cert.verify(publicKey)` |
| `openssl_verify` | `ECDsa.VerifyData` / `RSA.VerifyData` | `crypto.verify` |
| eigener CBOR-Leser | `PeterO.Cbor` | `cbor` |
> **.NET-Hinweis:** Für die Kettenprüfung `X509ChainPolicy.TrustMode =
> CustomRootTrust` setzen und die Herstellerwurzeln in
> `CustomTrustStore` legen. Ohne das prüft .NET gegen den
> Betriebssystemspeicher — derselbe Fehler wie
> `openssl_x509_checkpurpose()`.
### 9.3 Wurzelzertifikate
Yubico veröffentlicht sie unter
<https://developers.yubico.com/PKI/yubico-ca-certs.txt>. Für WebAuthn sind
**drei** relevant:
- Yubico U2F Root CA Serial 457200631 (2014) — YubiKey 4 und 5
- Yubico FIDO Root CA Serial 450203556 (2024)
- Yubico Attestation Root 1 (2024-12)
Die dort ebenfalls veröffentlichten Wurzeln für **PIV** und **OpenPGP**
gehören *nicht* dazu — sie signieren anderes und vergrößerten nur die Menge
des Akzeptierten.
Andere Hersteller (SoloKeys, Nitrokey, Feitian, Token2) brauchen ihre
eigenen Wurzeln. Fehlen sie, wird echte Hardware abgewiesen — das ist eine
Konfigurationslücke, kein Urteil über das Gerät.
### 9.4 RP-ID
Ein registrierter Schlüssel gilt **nur** für die Kennung, unter der er
aufgenommen wurde. Test- und Produktivinstanz brauchen je eine eigene
Registrierung. Das ist kein Mangel, sondern der Kern des Verfahrens — genau
deshalb läuft ein Anmeldeversuch auf einer nachgebauten Seite ins Leere.
---
## 10. Prüfliste für die Übernahme
Eine Umsetzung gilt erst als fertig, wenn **alle** Punkte belegt sind:
- [ ] Ein echter Hardwarekey wird aufgenommen und zeigt die erreichte Wurzel
- [ ] Ein Passwortverwalter-Passkey (`fmt: none`) wird abgewiesen
- [ ] **Ein selbst ausgestelltes Zertifikat mit gültiger Signatur und
abgeschriebener AAGUID wird abgewiesen — an der KETTE, nicht an der
Signatur** *(§2.3; ohne diesen Nachweis ist nichts belegt)*
- [ ] Zwischenzustand: `isLoggedIn()` bleibt nach richtigem Passwort false
- [ ] Zwischenzustand verfällt nach 5 Minuten
- [ ] Zähler-Rückschritt wird erkannt, beide-Null bleibt erlaubt
- [ ] Notfalltoken funktioniert ohne JavaScript und ist danach verbraucht
- [ ] `strict` lässt sich nicht einschalten, solange kein nachgewiesener
Schlüssel am eigenen Konto hängt
- [ ] Ein Konto zu löschen entfernt seine Credential-Zeilen *(sonst ist der
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)
- [ ] 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
`{ error: { code, message } }`, das JavaScript las `data.message ||
data.error` — und zeigte `[object Object]` an, ausgerechnet dort, wo die
Begründung der Ablehnung stehen sollte.
---
## 11. Referenzdateien im Deploymentcenter
| Datei | Inhalt |
|---|---|
| `src/Core/Webauthn/Attestation.php` | Attestationsprüfung, Kette, AAGUID-Gegenprobe |
| `src/Core/Webauthn/HardwarePolicy.php` | Die drei Stufen, Modellliste, Bewachung |
| `src/Core/Webauthn/WebauthnServer.php` | Die beiden Zeremonien |
| `src/Core/Webauthn/CoseKey.php` | COSE → DER/PEM, von Hand |
| `src/Core/Webauthn/Cbor.php` | CBOR-Leser, nur so groß wie nötig |
| `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 |
| `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 |
---
## 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.
**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
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.
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
- **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.
### 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.
+208
View File
@@ -674,3 +674,211 @@ alten Namens werden dabei aufgeräumt.
- [ ] Roadmap → 🧭 Tafel zeigt Spalten, Spuren und Kärtchen
- [ ] Ein Klick auf ein Kärtchen öffnet das Panel, Kommentieren funktioniert
- [ ] `python tests/e2e/run.py` gegen die Testinstanz: alles grün
---
## 18. Umstellung auf 2.9.2 — nur echte Hardware als zweiter Faktor
### 18.1 Worum es geht
Seit 2.9.0 verlangt die Anmeldung einen Hardwarekey. Was sie *nicht* verlangte,
war, dass es wirklich Hardware ist: Ein Passkey aus einem Passwortverwalter
(1Password, Bitwarden, iCloud, Google) ließ sich ebenso registrieren wie ein
Yubikey. Für das Deploymentcenter selbst ist das hinnehmbar. Wo ein
Sicherheitsprotokoll ausdrücklich Hardware fordert, ist es der Unterschied
zwischen einer Regel und einem Wunsch.
Zwei Dinge, die *wie* eine Prüfung aussehen und keine sind — beide waren in
2.9.0 bereits gesetzt:
- `authenticatorAttachment: "cross-platform"` steuert, was der Browser
**anbietet**. Ein Passwortverwalter meldet sich ebenfalls als
cross-platform.
- `response.authenticatorAttachment` setzt der **Client**, und der Client ist
genau die Instanz, gegen die sich hier nichts beweisen lässt.
Was trägt, ist die Attestation: Der Authenticator unterschreibt bei der
Aufnahme mit einem Schlüssel, der ab Werk in seinem Sicherheitselement liegt
und es nie verlässt. Das zugehörige Zertifikat muss auf eine hinterlegte
Herstellerwurzel ketten.
**Ein Zertifikat allein genügt nicht.** Jeder kann sich eines ausstellen, es
„Yubico U2F EE“ nennen, die AAGUID eines echten YubiKey 5 eintragen und sauber
unterschreiben. Genau dieser Fall wurde nachgebaut und gegen den Server
gefahren: Er wird abgewiesen — an der **Kette**, nicht an der Signatur. Was
Software nicht herstellen kann, ist eine Kette, die bei Yubicos Wurzel endet.
### 18.2 Migration
```bash
php cli/migrate.php # oder System → 🗄️ DB Migration im WebUI
```
Migration 021 ergänzt `dc_user_credentials` um `attested`, `attestation_fmt`,
`attestation_ca` und `attestation_root`. Sie setzt **keine** Regel — ohne
Umstellen bleibt das Verhalten von 2.9.1.
### 18.3 Die drei Stufen
| Stufe | Aufnahme neuer Schlüssel | Anmeldung mit vorhandenen |
|---|---|---|
| `off` | jeder Authenticator | alle |
| `register` | nur mit Herstellernachweis | alle |
| `strict` | nur mit Herstellernachweis | nur nachgewiesene |
Einzustellen unter **System → 👤 Benutzer → 🛡️ Welche Schlüssel zugelassen
sind**.
### 18.4 Der Weg nach `strict` — in dieser Reihenfolge
> **Alle vor 2.9.2 registrierten Schlüssel gelten als nicht nachgewiesen —
> auch echte Yubikeys.** Das ist keine Vorsicht, sondern eine Tatsache des
> Standards: Bei `attestation: "none"` ersetzt der **Browser** die
> Gerätekennung durch Nullbytes, bevor der Server sie zu sehen bekommt. Es gab
> also nie eine Herkunft, nicht einmal eine ungeprüfte.
1. Stufe auf **`register`** stellen.
2. Den Hardwarekey **einmal neu aufnehmen**. In der Liste muss danach
*🔒 Hardware* stehen, mit der erreichten Wurzel.
3. Den alten, nicht nachgewiesenen Eintrag entfernen.
4. Erst jetzt auf **`strict`**.
Schritt 4 wird abgelehnt, solange das eigene Konto keinen nachgewiesenen
Schlüssel hat — eine Einstellung, die sich selbst aussperrt, ist keine
Einstellung, sondern eine Falle.
### 18.5 Wenn doch etwas klemmt
- **Der Browser fragt nach Marke und Modell.** Das ist der sichtbare Preis von
`attestation: "direct"` und kein Fehler. Wer ablehnt, liefert keine
Attestation und wird abgewiesen.
- **Ausgesperrt.** Der Notfalltoken funktioniert unabhängig von der Regel.
Danach unter System → Benutzer die Stufe zurückstellen.
- **Ganz ohne WebUI:**
```sql
DELETE FROM dc_settings WHERE skey = 'webauthn_hardware_policy';
```
Fehlt der Eintrag, gilt wieder `off`.
### 18.6 Andere Hersteller als Yubico
Mitgeliefert sind die drei FIDO-Wurzeln von Yubico
(`src/Core/Webauthn/roots/yubico.pem`). Weitere Wurzeln in der `config.php`:
```php
'security' => [
'webauthn_root_files' => ['/etc/dc/solokeys.pem'],
],
```
Bewusst dort und nicht im WebUI: Eine Vertrauenswurzel über dieselbe
Oberfläche austauschbar zu machen, die sie schützt, hieße die Prüfung dort
aushebelbar zu machen.
### 18.7 Prüfen, ob alles läuft
- [ ] Migration 021 ist durch (Spalte `attested` in `dc_user_credentials`)
- [ ] Stufe `register`: Der Yubikey wird aufgenommen und zeigt *🔒 Hardware*
- [ ] Stufe `register`: Ein Passwortverwalter wird mit einer Meldung abgewiesen,
die den Grund nennt
- [ ] `strict` lässt sich **nicht** einschalten, solange kein nachgewiesener
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
+125 -4
View File
@@ -34,8 +34,11 @@ use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Core\Logger;
use Deploymentcenter\Core\SecurityLogger;
use Deploymentcenter\Core\Webauthn\Attestation;
use Deploymentcenter\Core\Webauthn\CredentialRepo;
use Deploymentcenter\Core\Webauthn\HardwarePolicy;
use Deploymentcenter\Core\Webauthn\WebauthnServer;
use Deploymentcenter\Modules\License\RateLimiter;
// Kein publicCors: Diese Endpunkte arbeiten mit dem Sitzungscookie. Ein
// "Access-Control-Allow-Origin: *" waere hier das Gegenteil dessen, was
@@ -52,6 +55,17 @@ if (Http::method() !== 'POST') {
$db = Db::init();
$repo = new CredentialRepo($db);
$policy = new HardwarePolicy($db);
// Drosselung. login_options liess sich bisher unbegrenzt aufrufen; der
// einzige Schutz war die Kontosperre, und die greift erst ab dem fuenften
// Fehlversuch. Das Fenster ist weiter gefasst als am Anmeldeformular (30
// statt 10), weil eine einzelne Anmeldung hier mehrere Aufrufe braucht -
// Vorgaben holen, Antwort pruefen, bei einem Abbruch von vorn.
$drossel = new RateLimiter($db, 30, 60, 'webauthn');
if (!$drossel->check(Http::clientIp())) {
Http::fail(429, 'rate_limited', 'Zu viele Anfragen. Bitte kurz warten.');
}
switch ($action) {
@@ -64,8 +78,25 @@ switch ($action) {
Http::fail(401, 'no_pending_login', 'Es steht keine Anmeldung offen. Bitte von vorn beginnen.');
}
$ids = $repo->rawIdsForUser($userId);
// Bei "strict" kommen nur geprueft in Hardware entstandene Schluessel
// in die Auswahl. Ein danach leeres Ergebnis ist kein Fehlerfall,
// sondern der erwartete Zustand direkt nach dem Umschalten - und die
// Meldung muss deshalb sagen, was zu tun ist, statt bloss "geht
// nicht".
$strict = $policy->appliesToLogin();
$ids = $repo->rawIdsForUser($userId, $strict);
if ($ids === []) {
if ($strict && $repo->countForUser($userId) > 0) {
Http::fail(
409,
'no_attested_credentials',
'Fuer dieses Konto ist zwar ein Schluessel hinterlegt, aber keiner davon hat '
. 'sich als Hardware ausgewiesen. Die Anmeldung laeuft ueber den Notfalltoken; '
. 'danach den Hardwarekey neu aufnehmen.'
);
}
Http::fail(409, 'no_credentials', 'Fuer dieses Konto ist kein Hardwarekey hinterlegt.');
}
@@ -105,6 +136,20 @@ switch ($action) {
Http::fail(403, 'unknown_credential', 'Dieser Hardwarekey ist fuer das Konto nicht hinterlegt.');
}
// allowCredentials ist eine Vorgabe an den Browser, keine Zusage des
// Clients. Wer die Anfrage von Hand stellt, kann jede Kennung
// einsetzen - also wird die Regel hier ein zweites Mal geprueft, an
// der Stelle, an der sie wirklich entscheidet.
if ($policy->appliesToLogin() && (int)($credential['attested'] ?? 0) !== 1) {
Auth::recordSecondFactorFailure($db, 'not_attested');
Http::fail(
403,
'not_attested',
'Dieser Schluessel hat sich bei der Aufnahme nicht als Hardware ausgewiesen '
. 'und ist unter der geltenden Regel nicht zugelassen.'
);
}
try {
$result = WebauthnServer::verifyAssertion(
$clientDataJson,
@@ -143,6 +188,25 @@ switch ($action) {
}
Csrf::requireValid();
// Einen zweiten Faktor aufzunehmen ist die wirksamste Art, sich in
// einem Konto einzunisten: Der eigene Schluessel gilt danach
// dauerhaft und faellt niemandem auf. Deshalb genuegt hier eine
// lebende Sitzung nicht - es braucht eine Authentifizierung aus den
// letzten Minuten. Der Browser bekommt einen eigenen Code dafuer,
// damit die Oberflaeche zum Sperrbildschirm schicken kann, statt
// "verboten" zu melden.
if (!Auth::reauthFresh()) {
Http::fail(
403,
'reauth_required',
'Diese Aenderung betrifft die Anmeldung selbst. Bitte bestaetigen Sie sie '
. 'zuerst mit Ihrem Passwort.',
null,
['reauth_url' => '/login.php?step=unlock&weiter=' . rawurlencode('/index.php#sub-system-users')]
);
}
$userId = Auth::userId();
$challenge = WebauthnServer::challenge();
@@ -153,8 +217,14 @@ switch ($action) {
$challenge,
$userId,
Auth::username(),
$repo->rawIdsForUser($userId)
$repo->rawIdsForUser($userId),
$policy->conveyance()
),
// Die Oberflaeche sagt vorher, was gefordert ist. Ohne diesen
// Hinweis erfuehre man die Regel erst nach dem Beruehren des
// Schluessels - und wuesste nicht, ob der Schluessel oder die
// Regel das Problem war.
'policy' => $policy->level(),
]);
break;
}
@@ -165,6 +235,25 @@ switch ($action) {
}
Csrf::requireValid();
// Einen zweiten Faktor aufzunehmen ist die wirksamste Art, sich in
// einem Konto einzunisten: Der eigene Schluessel gilt danach
// dauerhaft und faellt niemandem auf. Deshalb genuegt hier eine
// lebende Sitzung nicht - es braucht eine Authentifizierung aus den
// letzten Minuten. Der Browser bekommt einen eigenen Code dafuer,
// damit die Oberflaeche zum Sperrbildschirm schicken kann, statt
// "verboten" zu melden.
if (!Auth::reauthFresh()) {
Http::fail(
403,
'reauth_required',
'Diese Aenderung betrifft die Anmeldung selbst. Bitte bestaetigen Sie sie '
. 'zuerst mit Ihrem Passwort.',
null,
['reauth_url' => '/login.php?step=unlock&weiter=' . rawurlencode('/index.php#sub-system-users')]
);
}
$userId = Auth::userId();
$challenge = Auth::takeChallenge();
@@ -191,7 +280,12 @@ switch ($action) {
: null;
try {
$registration = WebauthnServer::verifyRegistration($clientDataJson, $attestationObject, $challenge);
$registration = WebauthnServer::verifyRegistration(
$clientDataJson,
$attestationObject,
$challenge,
$policy->roots()
);
} catch (\Throwable $e) {
Logger::warning('Registrierung eines Hardwarekeys abgelehnt', [
'user_id' => $userId,
@@ -204,6 +298,26 @@ switch ($action) {
Http::fail(409, 'already_registered', 'Dieser Hardwarekey ist bereits hinterlegt.');
}
// Erst jetzt entscheidet die Regel. Die Zeremonie oben war fehlerfrei
// - der Schluessel ist echt und gehoert zu diesem Konto. Abgelehnt
// wird er, weil er nicht das ist, was hier gefordert ist. Diese
// Reihenfolge ist Absicht: Sie trennt "kaputt" von "nicht zugelassen",
// und nur so laesst sich die zweite Meldung ueberhaupt verstehen.
$verdict = $policy->acceptsRegistration($registration['attestation']);
if (!$verdict['ok']) {
SecurityLogger::log('credential_rejected', Http::clientIp(), [
'username' => Auth::username(),
'format' => (string)($registration['attestation']['format'] ?? '?'),
'reason' => (string)$verdict['reason'],
]);
Http::fail(403, 'policy_rejected', (string)$verdict['reason'], null, [
'format' => $registration['attestation']['format'] ?? null,
'attested' => false,
]);
}
try {
$repo->add(
$userId,
@@ -213,7 +327,8 @@ switch ($action) {
$registration['sign_count'],
$registration['aaguid'],
$label,
$transports
$transports,
$registration['attestation']
);
} catch (\PDOException $e) {
// Eindeutiger Schluessel verletzt: derselbe Key an einem anderen
@@ -231,11 +346,17 @@ switch ($action) {
SecurityLogger::log('credential_registered', Http::clientIp(), [
'username' => Auth::username(),
'label' => $label,
'attested' => $registration['attestation']['attested'] ?? false,
'root' => $registration['attestation']['root'] ?? null,
]);
Http::ok([
'label' => $label,
'total' => $repo->countForUser($userId),
'attested' => $registration['attestation']['attested'] ?? false,
'format' => $registration['attestation']['format'] ?? null,
'root' => $registration['attestation']['root'] ?? null,
'aaguid' => Attestation::formatAaguid($registration['aaguid']),
'message' => 'Hardwarekey registriert.',
]);
break;
+56
View File
@@ -47,6 +47,62 @@ try {
$logDir = DC_VAR . '/log';
$checks['log_writable'] = ['ok' => is_dir($logDir) ? is_writable($logDir) : is_writable(DC_ROOT)];
// --- Ist die eigene Adresse fest hinterlegt? ---
// Seit 2.9.3 weigert sich WebauthnServer, die Kennung aus dem Host-Header
// abzuleiten: Aus diesem Wert entstehen rpId und expectedOrigin, also die
// beiden Anker, gegen die die Anmeldezeremonie geprueft wird. Wer den Header
// setzt, bestimmte sonst selbst, wogegen seine Anmeldung geprueft wird.
//
// Die Folge ist aber, dass eine Anlage ohne app.url den Hardwarekey nicht
// mehr annimmt. Das hier ist die Stelle, an der man das VORHER sieht statt
// beim naechsten Anmeldeversuch. Gemeldet wird nur, OB ein Wert gesetzt ist -
// nicht welcher; eine Gesundheitsauskunft ohne Anmeldung soll keine
// Konfiguration ausplaudern.
$appUrl = trim((string)Config::get('app.url', ''));
$rpIdConf = trim((string)Config::get('security.webauthn_rp_id', ''));
$gesetzt = $appUrl !== '' || $rpIdConf !== '';
// "Gesetzt" allein genuegt als Auskunft nicht. Ein Tippfehler in app.url
// weist den Hardwarekey genauso ab wie ein fehlender Wert - der Browser
// traegt den echten Ursprung in clientDataJSON ein, und der muss zu
// expectedOrigin passen. Deshalb wird hier zusaetzlich verglichen, unter
// welchem Namen diese Anfrage tatsaechlich hereingekommen ist.
//
// Dass dafuer der Host-Header herangezogen wird, ist hier unbedenklich: Es
// ist eine Diagnose, keine Entscheidung. Wer ihn faelscht, erzeugt eine
// falsche Warnung und sonst nichts - waehrend WebauthnServer ihn
// unveraendert ignoriert.
$erwarteterHost = $rpIdConf !== ''
? $rpIdConf
: (string)(parse_url($appUrl, PHP_URL_HOST) ?: '');
$tatsaechlicherHost = strtolower((string)(explode(':', (string)($_SERVER['HTTP_HOST'] ?? ''))[0]));
$passt = $erwarteterHost !== ''
&& strtolower($erwarteterHost) === $tatsaechlicherHost;
$hinweis = null;
if (!$gesetzt) {
$hinweis = 'app.url oder security.webauthn_rp_id fehlt - der Hardwarekey wird abgewiesen.';
} elseif (!$passt) {
$hinweis = sprintf(
'Die hinterlegte Adresse ("%s") ist eine andere als die, unter der diese Anfrage '
. 'hereinkam. Ein Hardwarekey, der unter der einen registriert wurde, wird unter der '
. 'anderen abgewiesen.',
$erwarteterHost
);
}
$checks['webauthn_anchor'] = [
'ok' => $gesetzt && $passt,
'configured' => $gesetzt,
'matches_request' => $passt,
'hint' => $hinweis,
];
// Bewusst KEIN $healthy = false: Eine Anlage ohne Hardwarekeys laeuft damit
// einwandfrei. Erst wer einen registriert, braucht den Anker - und dann sagt
// es ihm diese Zeile.
// --- Schema vorhanden? ---
// Dass die Verbindung steht, heisst noch nicht, dass die Migrationen gelaufen
// sind: SELECT 1 gelingt auch auf einer leeren Datenbank. Vor der ersten
+30 -8
View File
@@ -766,15 +766,24 @@ $spec = [
'summary' => 'Anmelden und ein kurzlebiges Setup-Token holen',
'description' =>
"Ohne Token erreichbar - hier entsteht das Token erst.\n\n"
. "Gedacht fuer Konten der Rolle `installer`, die sich ihrerseits **nicht** am "
. "WebUI anmelden koennen. Die Zugangsdaten werden auf jedem Zielsystem "
. "eingegeben; mit einem Administratorkonto verteilte man damit den Zugang zur "
. "gesamten Verwaltung.\n\n"
. "**Nur fuer Konten der Rolle `installer`.** Sie koennen sich ihrerseits "
. "nicht am WebUI anmelden; die Trennung gilt seit 2.9.3 in beide Richtungen. "
. "Ein Administratorkonto wird hier mit `403 installer_account_required` "
. "abgewiesen. Die Zugangsdaten werden auf jedem Zielsystem eingegeben; mit "
. "einem Administratorkonto verteilte man damit den Zugang zur gesamten "
. "Verwaltung.\n\n"
. "Das Token laeuft nach 30 Minuten ab, traegt nur `setup:install` und wird "
. "nicht mitgeschrieben - es ist nach der Ausgabe nirgends mehr abrufbar.\n\n"
. "Konten mit hinterlegtem TOTP-Geheimnis werden mit 501 abgewiesen: eine "
. "TOTP-Pruefung gibt es noch nicht, und sie stillschweigend zu uebergehen "
. "waere ein Rueckschritt.",
. "**Zweiter Faktor:** Konten mit hinterlegtem Hardwarekey oder "
. "TOTP-Geheimnis werden mit `403 second_factor_required` abgewiesen. "
. "Dieser Weg kennt die WebAuthn-Zeremonie nicht, und ein Anmeldeweg, der "
. "den zweiten Faktor uebergeht, macht ihn ueberall wertlos. Bis 2.9.2 "
. "wurde hier nur auf `totp_secret` geprueft - eine Spalte, die kein "
. "Anmeldeweg auswertet.\n\n"
. "**Fehlschlaege sind ununterscheidbar:** Falscher Name, falsches "
. "Passwort, deaktiviertes und gesperrtes Konto ergeben alle "
. "`401 invalid_credentials`. Eine eigene Antwort fuer die Kontosperre "
. "verriete, dass es den Namen gibt - gesperrt wird nur, was existiert.",
'security' => [],
'requestBody' => [
'required' => true,
@@ -829,7 +838,20 @@ $spec = [
. "wertlos und umgekehrt - das ist der Kern des Verfahrens und kein Mangel.\n\n"
. "Der Ersatzweg fuer einen fehlenden Schluessel ist der Notfalltoken; der "
. "laeuft als gewoehnliches Formular ueber `/login.php` und braucht kein "
. "JavaScript.",
. "JavaScript.\n\n"
. "**Seit 2.9.2: Attestation.** Welche Authenticatoren zugelassen sind, "
. "entscheidet eine Regel mit drei Stufen (`off`, `register`, `strict`), "
. "einstellbar unter *System -> Benutzer*. Steht sie nicht auf `off`, fordert "
. "`register_options` die Attestation als `direct` an, und `register_verify` "
. "nimmt nur Schluessel auf, deren Zertifikatskette bei einer hinterlegten "
. "Herstellerwurzel endet - ein Passwortverwalter wird mit `403 "
. "policy_rejected` abgewiesen. Bei `strict` bietet `login_options` nur noch "
. "geprueft in Hardware entstandene Schluessel an; ein Konto ohne solchen "
. "Schluessel erhaelt `409 no_attested_credentials` und muss den Notfalltoken "
. "benutzen.\n\n"
. "`register_options` liefert die geltende Stufe als Feld `policy` mit, damit "
. "die Oberflaeche die Anforderung nennen kann, BEVOR jemand seinen Schluessel "
. "beruehrt.",
'security' => [],
'parameters' => [
['name' => 'action', 'in' => 'query', 'required' => true,
+90 -31
View File
@@ -18,10 +18,17 @@
* dort falsch aufgehoben. Was nach der Installation zurueckbleibt, ist das
* Sub-Token der Anwendung selbst mit genau den Rechten, die sie braucht.
*
* ROLLENTRENNUNG: Anmelden darf sich hier jedes Konto, aber gedacht ist der
* Weg fuer Konten der Rolle "installer" - die sich ihrerseits nicht am WebUI
* anmelden koennen. Wer auf jedem Zielsystem sein Admin-Passwort eingibt,
* verteilt den Zugang zur gesamten Verwaltung.
* ROLLENTRENNUNG: Seit 2.9.3 duerfen sich hier NUR Konten der Rolle
* "installer" anmelden - die sich ihrerseits nicht am WebUI anmelden koennen.
* Bis dahin stand an dieser Stelle "gedacht ist der Weg fuer Konten der Rolle
* installer", und erzwungen war nichts; der Satz beschrieb eine Absicht, die
* der Code nicht teilte. Wer auf jedem Zielsystem sein Admin-Passwort
* eingibt, verteilt den Zugang zur gesamten Verwaltung.
*
* ZWEITER FAKTOR: Konten mit hinterlegtem Hardwarekey werden hier ebenfalls
* abgewiesen. Dieser Weg kennt die WebAuthn-Zeremonie nicht, und ein
* Anmeldeweg, der den zweiten Faktor einfach uebergeht, macht ihn ueberall
* wertlos.
*/
declare(strict_types=1);
@@ -33,6 +40,7 @@ use Deploymentcenter\Core\Auth;
use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Core\Logger;
use Deploymentcenter\Core\SecurityLogger;
use Deploymentcenter\Core\TokenManager;
use Deploymentcenter\Modules\License\RateLimiter;
use Deploymentcenter\Modules\Setup\SetupCatalog;
@@ -77,44 +85,95 @@ switch (resolveSetupAction()) {
), null, ['retry_after_seconds' => $ipLockout]);
}
// Die Kontosperre wird geprueft, aber NICHT als solche gemeldet.
// Eine eigene Antwort "account_locked" verriete, dass es den Namen
// gibt - gesperrt wird nur, was existiert. Die sorgfaeltige
// Laufzeitangleichung ueber dummyHash() in verifyCredentials() waere
// damit wieder aufgehoben: Fuenf Versuche pro Name genuegten, um ihn
// zu bestaetigen.
$accLockout = Auth::accountLockoutSeconds($db, $username);
if ($accLockout > 0) {
Http::fail(429, 'account_locked', sprintf(
'Dieses Benutzerkonto ist aufgrund zu vieler Fehlversuche vorübergehend gesperrt. Bitte in %d Minute(n) erneut versuchen.',
(int)ceil($accLockout / 60)
), null, ['retry_after_seconds' => $accLockout]);
}
$user = Auth::verifyCredentials($db, $username, $password);
$user = $accLockout > 0 ? null : Auth::verifyCredentials($db, $username, $password);
if ($user === null) {
$postAccLockout = Auth::accountLockoutSeconds($db, $username);
if ($postAccLockout > 0) {
Http::fail(429, 'account_locked', sprintf(
'Zu viele Fehlversuche. Dieses Benutzerkonto wurde für %d Minute(n) gesperrt.',
(int)ceil($postAccLockout / 60)
), null, ['retry_after_seconds' => $postAccLockout]);
// Eine Antwort fuer alle Faelle: falscher Name, falsches Passwort,
// deaktiviertes Konto, gesperrtes Konto. Die Wartezeit darf mit -
// sie ist ein Hinweis fuer den Berechtigten und sagt einem
// Ratenden nichts, was er nicht ohnehin ausprobieren wuerde.
$warten = max($accLockout, Auth::accountLockoutSeconds($db, $username));
Http::fail(
401,
'invalid_credentials',
$warten > 0
? sprintf(
'Anmeldung nicht moeglich. Bitte in %d Minute(n) erneut versuchen.',
(int)ceil($warten / 60)
)
: 'Anmeldung nicht moeglich.',
null,
$warten > 0 ? ['retry_after_seconds' => $warten] : []
);
}
// Bewusst dieselbe Meldung fuer falschen Benutzer, falsches
// Passwort und deaktiviertes Konto.
Http::fail(401, 'invalid_credentials', 'Ungueltige Anmeldedaten.');
}
// TOTP ist in dc_users vorgesehen, wird aber noch von keinem
// Anmeldeweg geprueft. Ein Konto mit hinterlegtem Geheimnis hier
// durchzulassen, ohne den zweiten Faktor zu verlangen, waere ein
// stiller Rueckschritt - deshalb wird es ausdruecklich abgelehnt.
if (!empty($user['totp_secret'])) {
Logger::warning('Setup-Anmeldung mit TOTP-Konto abgelehnt', [
// ------------------------------------------------------------------
// Zweiter Faktor
// ------------------------------------------------------------------
// Bis 2.9.2 stand hier nur die Pruefung auf totp_secret - eine Spalte,
// die von KEINEM Anmeldeweg ausgewertet wird. Der tatsaechlich
// umgesetzte zweite Faktor liegt in dc_user_credentials, und der
// wurde hier nicht geprueft.
//
// Die Folge war eine offene Tuer neben der verschlossenen: Wer ein
// Administratorpasswort hatte, bekam hier ohne Hardwarekey ein
// gueltiges setup:install-Token und konnte damit dauerhafte Tokens
// fuer jedes Projekt ausstellen. Die Aussage "dieses Konto ist durch
// einen zweiten Faktor geschuetzt" stimmte damit nicht mehr - und
// eine Aussage ueber Sicherheit, die nur fuer einen von zwei Wegen
// gilt, ist keine.
if (!empty($user['totp_secret']) || Auth::secondFactorRequired($db, (int)$user['id'])) {
Logger::warning('Setup-Anmeldung eines Kontos mit zweitem Faktor abgelehnt', [
'username' => $username,
'ip' => Http::clientIp(),
]);
SecurityLogger::log('setup_login_second_factor', Http::clientIp(), [
'username' => $username,
]);
Http::fail(
501,
'totp_not_supported',
403,
'second_factor_required',
'Fuer dieses Konto ist ein zweiter Faktor hinterlegt. Die Setup-Anmeldung '
. 'unterstuetzt ihn noch nicht - bitte ein eigenes Installationskonto verwenden.'
. 'unterstuetzt ihn nicht - bitte ein eigenes Installationskonto verwenden.'
);
}
// ------------------------------------------------------------------
// Rollentrennung, und zwar symmetrisch
// ------------------------------------------------------------------
// Der Docblock dieses Endpunkts sagte seit jeher, gedacht sei er fuer
// Konten der Rolle "installer" - erzwungen hat das nichts: "Anmelden
// darf sich hier jedes Konto."
//
// startLogin() haelt Installer umgekehrt schon immer vom WebUI fern.
// Die Trennung galt also nur in eine Richtung, und die
// unbeaufsichtigte Richtung war die gefaehrlichere: Ein Zielsystem,
// auf dem Installationsdaten eingegeben werden, ist der Ort, an dem
// sie am ehesten abhandenkommen.
if (Auth::roleOf($user) !== 'installer') {
Logger::warning('Setup-Anmeldung eines Nicht-Installer-Kontos abgelehnt', [
'username' => $username,
'role' => Auth::roleOf($user),
'ip' => Http::clientIp(),
]);
SecurityLogger::log('setup_login_wrong_role', Http::clientIp(), [
'username' => $username,
'role' => Auth::roleOf($user),
]);
Http::fail(
403,
'installer_account_required',
'Die Setup-Anmeldung steht nur Konten der Rolle "installer" offen. '
. 'Ein solches Konto legen Sie im WebUI unter System -> Benutzerkonten an.'
);
}
+150 -1
View File
@@ -1,8 +1,157 @@
{
"_comment": "Einzige Quelle der Wahrheit fuer den Changelog. Wird von /docs/changelog.php gerendert und von GET /api/updateservice/v1/changelog?since=X ausgeliefert. Neue Eintraege oben einfuegen.",
"schema": 1,
"current": "2.9.1",
"current": "2.9.3",
"versions": [
{
"version": "2.9.3",
"date": "2026-09-21",
"summary": "Ergebnis einer Sicherheitsbegutachtung. Die WebAuthn-Zeremonie selbst war in Ordnung - der zweite Faktor liess sich aber DANEBEN umgehen: ueber einen Anmeldeweg, der ihn nicht kannte, ueber eine Pruefung, die bei Datenbankfehlern stillschweigend auf Passwort-allein zurueckfiel, und ueber eine uebernommene Sitzung, die die Anmeldemittel ohne erneute Bestaetigung aendern durfte.",
"actionRequired": true,
"changes": [
{
"area": "security",
"breaking": true,
"title": "Die Setup-API kannte den zweiten Faktor nicht",
"text": "POST /api/setup/v1/login pruefte bisher auf totp_secret - eine Spalte, die KEIN Anmeldeweg auswertet. Auf dc_user_credentials, also den tatsaechlich umgesetzten zweiten Faktor, wurde nicht geprueft. Wer ein Administratorpasswort hatte, bekam dort ohne Hardwarekey ein gueltiges setup:install-Token und konnte damit dauerhafte Tokens fuer jedes Projekt ausstellen und Monitore ueberschreiben. Neu werden Konten mit hinterlegtem Hardwarekey abgewiesen, und die Rollentrennung gilt symmetrisch: Installer kommen nicht ins WebUI (das galt schon), Administratoren nicht mehr in die Setup-API. Der Docblock behauptete diese Trennung seit jeher - erzwungen hat sie nichts.",
"action": "Wer die Setup-API mit einem Administratorkonto benutzt hat, legt vorher ein Konto der Rolle \"installer\" an (System -> Benutzerkonten)."
},
{
"area": "security",
"breaking": false,
"title": "Authentifizierung scheitert jetzt geschlossen",
"text": "secondFactorRequired(), accountLockoutSeconds() und lockoutSeconds() fingen jedes Throwable und antworteten mit \"kein zweiter Faktor\" bzw. \"nicht gesperrt\". Begruendet war das mit einer fehlenden Tabelle vor Migration 019 - gefangen wurden aber auch Verbindungsabbrueche, Lock-Timeouts, \"too many connections\" und entzogene Tabellenrechte. Jeder dieser Faelle schaltete im Stillen fuer ALLE Konten auf Passwort-allein zurueck und hob gleichzeitig den Brute-Force-Schutz auf. Toleriert werden jetzt nur noch die SQL-Zustaende 42S02 und 42S22 (Tabelle bzw. Spalte unbekannt); alles andere bricht ab. Lieber keine Anmeldung als eine ohne zweiten Faktor.",
"action": null
},
{
"area": "security",
"breaking": false,
"title": "Aenderungen an den Anmeldemitteln verlangen das Passwort",
"text": "Passwort setzen, Notfalltoken ausstellen, Hardwarekey aufnehmen oder entfernen, die Hardwarekey-Regel umstellen: Fuer all das genuegte eine lebende Sitzung samt CSRF-Token. Eine einzige uebernommene Sitzung reichte damit, um sich dauerhaft einzunisten - einen eigenen Schluessel zu registrieren, einen Notfalltoken fuer jedes Konto auszustellen und den zweiten Faktor anlagenweit abzuschalten. Der Hardwarekey haette gegen genau den Fall nicht geholfen, gegen den er gedacht ist: Er schuetzt die Anmeldung, nicht die Sitzung, die daraus entsteht. Diese fuenf Aktionen verlangen jetzt eine Authentifizierung aus den letzten zehn Minuten.",
"action": null
},
{
"area": "security",
"breaking": true,
"title": "Leerlauf 30 Minuten - gesperrt statt beendet",
"text": "Bisher zwei Stunden, danach wurde die Sitzung vernichtet. NIST SP 800-63B verlangt auf Stufe AAL2 eine erneute Authentifizierung nach 30 Minuten Untaetigkeit; der ganze Aufwand mit dem Hardwarekey hing damit an einer Zahl, die niemand fuer eine Sicherheitsentscheidung hielt. Statt zu vernichten wird jetzt gesperrt: Der Sperrbildschirm oeffnet mit dem PASSWORT allein, derselbe Standard laesst zur Reaktivierung einen Faktor genuegen. Wer alle 30 Minuten Passwort UND Hardwarekey vorzeigen muss, legt den Schluessel irgendwann steckend liegen - eine Regel, die zur Umgehung erzieht, ist schlechter als eine mildere, die eingehalten wird. Das absolute Maximum von 12 Stunden beendet die Sitzung weiterhin wirklich.",
"action": null
},
{
"area": "security",
"breaking": false,
"title": "Ein Passwortwechsel beendet die uebrigen Sitzungen",
"text": "Bisher beendete er keine einzige: Wer das Passwort aendert, weil er einen fremden Zugriff vermutet, liess damit genau den drin, dessentwegen er es aendert. PHP legt Sitzungen als Dateien ab, es gibt keinen Index \"alle Sitzungen des Kontos X\". Migration 022 loest das mit einer Spalte session_epoch: Jede Sitzung merkt sich beim Anmelden den Stand, ein Passwortwechsel erhoeht ihn, alle uebrigen passen nicht mehr. Die eigene Sitzung uebernimmt den neuen Stand - wer sein Passwort aendert, soll sich nicht selbst hinauswerfen.",
"action": "Migration 022 ausfuehren."
},
{
"area": "security",
"breaking": false,
"title": "Sperren greifen jetzt auch auf der zweiten Stufe",
"text": "pendingUserId() pruefte nur das 5-Minuten-Fenster, completeSecondFactor() nur disabled. Ein waehrend des Ratens gesperrtes Konto konnte die Anmeldung trotzdem abschliessen, und innerhalb des offenen Zwischenzustands durfte unbegrenzt geraten werden - obwohl der Kommentar an recordSecondFactorFailure genau das zu verhindern versprach. Neu: Kontosperre wird beim Abschluss geprueft, und nach fuenf Fehlversuchen faellt der Zwischenzustand. Praktisch war das folgenlos (der Notfalltoken hat rund 100 Bit und ist bcrypt-gehasht), die Kontrolle fehlte aber.",
"action": null
},
{
"area": "security",
"breaking": false,
"title": "Drosselung und keine Namens-Enumeration am Login",
"text": "Der RateLimiter war im Haus und auf acht Endpunkten im Einsatz - ausgerechnet login.php und /api/auth/v1/ waren ohne. Neu ein Eimer webui_login mit 10 Versuchen pro Minute. Ausserdem meldete die Anmeldung \"Dieses Benutzerkonto ist gesperrt\" gegen \"Ungueltige Anmeldedaten\" - gesperrt wird aber nur, was existiert, also verrieten fuenf Versuche pro Name, ob es ihn gibt. Die sorgfaeltige Laufzeitangleichung ueber dummyHash() war damit wieder aufgehoben. Jetzt eine Meldung fuer alle Faelle; die Wartezeit darf darin stehen, der Grund nicht. Die IP-Sperre wird weiterhin benannt - sie sagt etwas ueber das Netzwerk aus, nicht ueber ein Konto.",
"action": null
},
{
"area": "security",
"breaking": false,
"title": "HSTS, TLS-Zwang und eine ehrliche CSP",
"text": "Neu: Strict-Transport-Security, eine Umleitung von http auf https VOR allen Routen (dahinter waere sie fuer login.php und die gesamte API nie erreicht worden, weil alle Routen [L] tragen), und eine Content-Security-Policy. Die CSP enthaelt 'unsafe-inline' fuer script-src, und das ist keine Nachlaessigkeit, sondern eine Tatsache ueber den Bestand: index.php hat 93 Inline-Handler und mehrere tausend Zeilen Inline-Skript. Sie schuetzt deshalb NICHT gegen XSS, sondern gegen Skript von fremden Adressen, Einbettung, umgebogenes <base>, fremde Formularziele und Plugins. Das hinzuschreiben ist wichtiger, als es zu verschweigen - ein CSP-Header laedt sonst dazu ein, XSS fuer erledigt zu halten.",
"action": null
},
{
"area": "security",
"breaking": false,
"title": "Sitzungshaertung und der WebAuthn-Anker",
"text": "session.use_strict_mode ist gesetzt (PHP-Vorgabe ist aus - der Server uebernahm jede vom Client erfundene Sitzungskennung). Das CSRF-Token wird bei der Anmeldung verworfen, statt die Regeneration zu ueberleben. Das Secure-Flag des Cookies haengt nicht mehr an $_SERVER['HTTPS'], das hinter einem TLS-terminierenden Proxy leer ist, sondern an app.url. Und rpId()/expectedOrigin() leiten sich nicht mehr aus dem Host-Header ab: Aus diesem Wert entstehen die beiden Anker, gegen die die ganze Zeremonie geprueft wird - wer den Header setzt, bestimmte sonst selbst, wogegen seine Anmeldung geprueft wird.",
"action": "app.url MUSS in der config.php gesetzt sein, sonst wird der Hardwarekey abgewiesen. /api/health meldet das unter checks.webauthn_anchor."
},
{
"area": "security",
"breaking": false,
"title": "Notfalltoken laeuft nach 180 Tagen ab",
"text": "recovery_created_at wurde seit 2.9.0 geschrieben und nie ausgewertet. Ein vor zwei Jahren auf einen Zettel geschriebener Token war weiterhin ein vollwertiger Ersatz fuer den Hardwarekey - ein zweiter Faktor, der nicht altert, waehrend der Zettel es tut. Geprueft wird NACH dem Hashvergleich, damit die Antwortzeit nicht verraet, ob ein Token existiert. Die Oberflaeche zeigt jetzt die Restlaufzeit und warnt 30 Tage vorher.",
"action": "Notfalltoken, die aelter als 180 Tage sind, neu erzeugen."
},
{
"area": "security",
"breaking": false,
"title": "Kleinkram mit Folgen",
"text": "config/config.example.php enthielt ein echt aussehendes Rocket.Chat-Passwort und verify_ssl => false als Vorgabe - eine Beispieldatei liegt im Repository, und damit lag das Passwort dort auch. Beides bereinigt. Die .htaccess sperrte Serverdaten.txt, aber nicht Serverdaten.txt.bak; deploy.py schliesst beide aus, die zweite Verteidigungslinie muss aber zur ersten passen. dc_login_attempts wurde nie aufgeraeumt - eine Tabelle, die nur waechst und bei JEDER Anmeldung gelesen wird, wird irgendwann zu dem Problem, das die Anmeldung langsam macht; jetzt raeumt ein Hundertstel der Anmeldungen beilaeufig auf.",
"action": "Das Rocket.Chat-Passwort rotieren - es stand im Repository."
},
{
"area": "tests",
"breaking": false,
"title": "Die Setup-Testfaelle pruefen jetzt die Trennung",
"text": "Sie meldeten sich bisher mit dem Administratorkonto an - sie bildeten also gerade den Weg ab, den es nicht geben sollte. testctl legt beim Zuruecksetzen ein Installationskonto an, die Faelle benutzen es, und ein neuer Fall prueft ausdruecklich, dass ein Administratorkonto dort abgewiesen wird. 185 von 185 gruen.",
"action": null
}
]
},
{
"version": "2.9.2",
"date": "2026-09-21",
"summary": "Das WebUI kann jetzt verlangen, dass ein zweiter Faktor wirklich in Hardware entstanden ist. Bisher liess sich auch ein Passwortverwalter als Hardwarekey registrieren - fuer das Deploymentcenter hinnehmbar, fuer ein Protokoll, das ausdruecklich Hardware fordert, nicht.",
"actionRequired": true,
"changes": [
{
"area": "security",
"breaking": false,
"title": "Attestation wird geprueft, wenn die Regel es verlangt",
"text": "Bis 2.9.1 forderte die Registrierung attestation \"none\" an und pruefte nichts. Der Kommentar im Code begruendete das damit, dass der Betreiber seinen eigenen Schluessel an seinem eigenen Server registriert - das stimmte und uebersah, was daraus im Betrieb wird: Ein 1Password-Passkey liess sich ebenso anstandslos aufnehmen wie ein Yubikey. Neu prueft der Server auf Wunsch die Basic Attestation: Der Authenticator unterschreibt bei der Aufnahme mit einem Schluessel, der ab Werk in seinem Sicherheitselement liegt, und das zugehoerige Zertifikat muss auf eine hinterlegte Herstellerwurzel ketten. Mitgeliefert werden die drei FIDO-Wurzeln von Yubico.",
"action": "Migration 021 ausfuehren. Danach unter System -> Benutzer die gewuenschte Stufe waehlen; ohne Umstellen bleibt das Verhalten von vorher."
},
{
"area": "security",
"breaking": false,
"title": "Warum die Kette geprueft wird und nicht nur das Vorhandensein eines Zertifikats",
"text": "Ein x5c allein beweist nichts: Jeder Authenticator kann sich selbst ein Zertifikat ausstellen, es \"Yubico U2F EE\" nennen, die AAGUID eines echten YubiKey 5 eintragen und kryptografisch einwandfrei unterschreiben. Genau dieser Fall wurde nachgebaut und gegen den Server gefahren - er wird abgewiesen, und zwar an der Kette, nicht an der Signatur. Was Software nicht herstellen kann, ist eine Kette, die bei Yubicos Wurzel endet; dafuer braeuchte es deren privaten Schluessel. Deshalb ist die Wurzeldatei kein Beiwerk, sondern der Kern der Pruefung.",
"action": null
},
{
"area": "security",
"breaking": false,
"title": "Drei Stufen, damit sich niemand aussperrt",
"text": "off laesst jeden Authenticator zu (das Verhalten bis 2.9.1). register verlangt den Nachweis nur bei NEUEN Aufnahmen - bestehende Schluessel melden sich weiter an. strict laesst auch zur Anmeldung nur noch geprueft in Hardware entstandene Schluessel zu. Die Zwischenstufe ist nicht Bequemlichkeit, sondern notwendig: Alle vor dieser Fassung registrierten Schluessel gelten als nicht nachgewiesen, auch echte Yubikeys, weil bei attestation \"none\" der Browser die Geraetekennung durch Nullbytes ersetzt, bevor der Server sie sieht. Ein einzelner Schalter haette in dem Moment, in dem man ihn umlegt, den eigenen Zugang gesperrt. Der Wechsel nach strict wird zusaetzlich abgelehnt, solange das eigene Konto keinen nachgewiesenen Schluessel hat.",
"action": "Wer strict nutzen will: erst auf register stellen, den Hardwarekey einmal neu aufnehmen, dann auf strict."
},
{
"area": "security",
"breaking": false,
"title": "cross-platform war nie eine Pruefung",
"text": "In den Registrierungsvorgaben stand seit 2.9.0 authenticatorAttachment = \"cross-platform\". Das sieht aus wie eine Beschraenkung auf angesteckte Hardware und ist keine: Die Angabe steuert, was der Browser ANBIETET, und ein Passwortverwalter meldet sich ebenfalls als cross-platform. Auch response.authenticatorAttachment traegt nicht - das setzt der Client, und der Client ist genau die Instanz, gegen die sich hier nichts beweisen laesst. Der Kommentar an dieser Stelle sagt das jetzt ausdruecklich, damit die Angabe nicht ein zweites Mal fuer eine Zusicherung gehalten wird.",
"action": null
},
{
"area": "webui",
"breaking": false,
"title": "Die Herkunft steht in der Schluesselliste",
"text": "Jeder registrierte Schluessel zeigt, ob er sich als Hardware ausgewiesen hat, bei welcher Herstellerwurzel die Kette endete und welche Geraetekennung er nennt. Die Kennung steht dort in derselben Schreibweise, in der die Modellliste sie erwartet - sie laesst sich also uebernehmen, statt sie irgendwo nachschlagen zu muessen. Die Regel wird ausserdem beim Registrieren angesagt, BEVOR jemand seinen Schluessel beruehrt: Wer erst danach erfaehrt, dass nur Hardware zaehlt, sucht den Fehler beim Schluessel statt bei der Einstellung.",
"action": null
},
{
"area": "security",
"breaking": false,
"title": "Modellliste optional - und mit Warnhinweis",
"text": "Zusaetzlich zur Wurzelpruefung lassen sich einzelne AAGUIDs zulassen. Die Liste ist leer voreingestellt und soll es meist bleiben: Yubico weist neuen Firmwarestaenden neue Kennungen zu, eine ungepflegte Liste weist also irgendwann einen echten Schluessel ab. Wo sie benutzt wird, wird die Kennung aus den Authenticator-Daten gegen die im Zertifikat gegengeprueft - sonst filterte man nach einer Behauptung statt nach einer Eigenschaft. Weitere Hersteller lassen sich ueber security.webauthn_root_files in der config.php ergaenzen, bewusst dort und nicht im WebUI: Eine Vertrauenswurzel ueber dieselbe Oberflaeche austauschbar zu machen, die sie schuetzt, hiesse die Pruefung dort aushebelbar zu machen.",
"action": null
},
{
"area": "tests",
"breaking": false,
"title": "Neue Testreihe \"hardware\" und testctl clear_credentials",
"text": "Fuenf Faelle pruefen die Gegenueberstellung, auf die es ankommt: dieselben Bytes eines Authenticators ohne Herkunftsnachweis, einmal mit und einmal ohne Regel. Sie kommen ohne Krypto-Bibliothek aus, weil bei fmt \"none\" keine Signatur geprueft wird. Der Fall mit vorgetaeuschtem Zertifikat laeuft in einem eigenen Werkzeug ausserhalb der Suite. testctl bekommt clear_credentials: Ein Testlauf, der einen Schluessel registriert, sperrt sonst den naechsten aus, und dafuer den ganzen Datenbestand zu loeschen waere ein hoher Preis fuer eine Tabelle.",
"action": null
}
]
},
{
"version": "2.9.1",
"date": "2026-09-20",
+326 -9
View File
@@ -27,8 +27,10 @@ use Deploymentcenter\Core\Csrf;
use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Core\Logger;
use Deploymentcenter\Core\SecurityLogger;
use Deploymentcenter\Core\TokenManager as CoreTokenManager;
use Deploymentcenter\Core\Webauthn\CredentialRepo;
use Deploymentcenter\Core\Webauthn\HardwarePolicy;
use Deploymentcenter\Modules\Bugtracker\BugRepo;
use Deploymentcenter\Modules\Bugtracker\IgnoreRules;
use Deploymentcenter\Modules\Bugtracker\Roadmap;
@@ -228,6 +230,49 @@ function dc_flash(string $message, string $type = 'success'): void
$_SESSION['dc_flash'] = ['message' => $message, 'type' => $type];
}
/**
* Verlangt eine frische Authentifizierung fuer Aenderungen an den
* Anmeldemitteln selbst.
*
* WARUM DAS NOETIG IST
*
* Bis 2.9.2 genuegte fuer jede dieser Aktionen eine lebende Sitzung samt
* CSRF-Token. Eine einzige uebernommene Sitzung reichte damit, um sich
* dauerhaft einzunisten: einen eigenen Hardwarekey registrieren, einen
* Notfalltoken fuer jedes Konto ausstellen, den zweiten Faktor anlagenweit
* abschalten. Der Hardwarekey haette gegen genau den Fall nicht geholfen,
* gegen den er gedacht ist - er schuetzt die Anmeldung, nicht die Sitzung,
* die daraus entsteht.
*
* Der Aufrufer bricht ab; diese Funktion kehrt bei fehlender frischer
* Authentifizierung nicht zurueck.
*/
function dc_require_reauth(string $rueckkehr = 'sub-system-users'): void
{
if (Auth::reauthFresh()) {
return;
}
// Der Hinweis auf die Wiederholung ist kein Zierrat: Die Formulardaten
// gehen bei der Umleitung verloren, und ohne diesen Satz sucht man nach
// dem Entsperren vergeblich nach der Wirkung.
dc_flash(
'Diese Aenderung betrifft die Anmeldung selbst. Bitte bestaetigen Sie sie mit '
. 'Ihrem Passwort und wiederholen Sie die Aktion danach.',
'danger'
);
// Der Sperrbildschirm erledigt genau das - er prueft ein Passwort und
// setzt den Zeitstempel. Statt eines zweiten Formulars derselben Art
// wird die Sitzung dafuer kurz gesperrt.
Auth::lockForReauth();
if (!headers_sent()) {
header('Location: /login.php?step=unlock&weiter=' . rawurlencode('/index.php#' . $rueckkehr));
}
exit;
}
/**
* Wie viele handlungsfaehige Administratorkonten gibt es noch?
*
@@ -1520,6 +1565,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
case 'set_user_password': {
dc_require_reauth();
$userId = (int)($_POST['user_id'] ?? 0);
$newPass = (string)($_POST['password'] ?? '');
@@ -1531,13 +1578,33 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$upd = $pdo->prepare('UPDATE dc_users SET password_hash = :h WHERE id = :id');
$upd->execute([':h' => password_hash($newPass, PASSWORD_DEFAULT), ':id' => $userId]);
$geaendert = $upd->rowCount() > 0;
if ($geaendert) {
// Alle uebrigen Sitzungen dieses Kontos beenden. Ein
// Passwort zu aendern, ohne die Sitzungen zu beenden, die
// mit dem alten eroeffnet wurden, laesst genau den drin,
// dessentwegen man es aendert. Die eigene Sitzung
// uebernimmt den neuen Stand und bleibt bestehen.
Auth::bumpSessionEpoch($pdo, $userId);
SecurityLogger::log('password_changed', Http::clientIp(), [
'username' => $actor,
'user_id' => $userId,
]);
}
Logger::info('Passwort geaendert', ['user_id' => $userId, 'actor' => $actor]);
// Der Hash steht auch in den .htpasswd-Dateien.
dc_guard_releases($pdo);
dc_flash($upd->rowCount() > 0 ? 'Passwort geaendert.' : 'Konto nicht gefunden.',
$upd->rowCount() > 0 ? 'success' : 'danger');
dc_flash(
$geaendert
? 'Passwort geaendert. Andere Sitzungen dieses Kontos sind damit beendet.'
: 'Konto nicht gefunden.',
$geaendert ? 'success' : 'danger'
);
dc_redirect('#sub-system-users');
}
@@ -1612,6 +1679,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
case 'issue_recovery_token': {
dc_require_reauth();
$userId = (int)($_POST['user_id'] ?? 0);
$row = $pdo->prepare('SELECT id, username FROM dc_users WHERE id = :id LIMIT 1');
@@ -1641,7 +1710,55 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
dc_redirect('#sub-system-users');
}
case 'save_webauthn_policy': {
dc_require_reauth();
$policy = new HardwarePolicy($pdo);
$wanted = (string)($_POST['policy_level'] ?? HardwarePolicy::OFF);
$before = $policy->level();
if (!in_array($wanted, HardwarePolicy::LEVELS, true)) {
dc_flash('Unbekannte Stufe.', 'danger');
dc_redirect('#sub-system-users');
}
// Die Modellliste zuerst: Sie kann unbrauchbar sein, und dann
// soll die Stufe nicht schon umgestellt sein. Andersherum
// haette man eine halb angewandte Regel - die Stufe neu, die
// Liste alt -, und niemand wuesste, welcher Teil gilt.
try {
$policy->setAllowlist((string)($_POST['policy_aaguids'] ?? ''));
} catch (InvalidArgumentException $e) {
dc_flash($e->getMessage(), 'danger');
dc_redirect('#sub-system-users');
}
$blocked = $policy->blockingReason($wanted, Auth::userId());
if ($blocked !== null) {
dc_flash($blocked, 'danger');
dc_redirect('#sub-system-users');
}
$policy->setLevel($wanted);
// Eine geaenderte Anmelderegel gehoert ins
// Sicherheitsprotokoll, nicht nur ins Anwendungslog: Sie
// beantwortet spaeter die Frage, seit wann ein Konto mit
// welchem Anspruch geschuetzt war.
SecurityLogger::log('webauthn_policy_changed', Http::clientIp(), [
'username' => $actor,
'from' => $before,
'to' => $wanted,
]);
dc_flash(sprintf('Hardwarekey-Regel steht jetzt auf "%s".', $wanted));
dc_redirect('#sub-system-users');
}
case 'delete_credential': {
dc_require_reauth();
$userId = (int)($_POST['user_id'] ?? 0);
$credentialId = (int)($_POST['credential_id'] ?? 0);
@@ -2086,6 +2203,29 @@ if ($twoFactorReady) {
}
}
// Die Hardwarekey-Regel. Steht die Migration 021 noch aus, bleibt alles auf
// den Vorgaben - "off" ist dasselbe Verhalten wie vor dieser Funktion, und
// eine Oberflaeche, die eine Regel zum Einschalten anbietet, die die
// Datenbank nicht tragen kann, waere ein Versprechen ohne Deckung.
$hardwarePolicy = new HardwarePolicy($pdo);
$policyLevel = HardwarePolicy::OFF;
$policyAllowlist = '';
$policyRootCount = 0;
$myAttestedCount = 0;
$attestationReady = false;
if ($twoFactorReady) {
try {
$policyLevel = $hardwarePolicy->level();
$policyAllowlist = $hardwarePolicy->allowlistText();
$policyRootCount = count($hardwarePolicy->roots());
$myAttestedCount = $credentialRepo->countAttestedForUser(Auth::userId());
$attestationReady = true;
} catch (Throwable $e) {
$attestationReady = false;
}
}
// Einmalige Anzeigen, die die Sitzung von der vorigen Anfrage mitbringt.
$freshRecoveryToken = $_SESSION['dc_fresh_recovery_token'] ?? null;
unset($_SESSION['dc_fresh_recovery_token']);
@@ -6713,6 +6853,7 @@ Antwort:
<thead>
<tr>
<th>Bezeichnung</th>
<th>Herkunft</th>
<th>Registriert</th>
<th>Zuletzt benutzt</th>
<th>Aktion</th>
@@ -6722,6 +6863,31 @@ Antwort:
<?php foreach ($myCredentials as $cred): ?>
<tr>
<td><strong><?= e((string)$cred['label']) ?></strong></td>
<td style="font-size:0.8rem;">
<?php if (!empty($cred['attested'])): ?>
<span style="color:var(--success); font-weight:600;">🔒 Hardware</span>
<?php if (!empty($cred['attestation_root'])): ?>
<div style="color:var(--text-muted); font-size:0.75rem;">
<?= e((string)$cred['attestation_root']) ?>
</div>
<?php endif; ?>
<?php if (!empty($cred['aaguid_text'])): ?>
<div style="color:var(--text-muted); font-size:0.7rem; font-family:monospace;"
title="Gerätekennung — lässt sich in die Modellliste übernehmen.">
<?= e((string)$cred['aaguid_text']) ?>
</div>
<?php endif; ?>
<?php else: ?>
<span style="color:var(--text-muted);">nicht nachgewiesen</span>
<div style="color:var(--text-muted); font-size:0.75rem;">
<?php if (!empty($cred['attestation_fmt'])): ?>
Format <?= e((string)$cred['attestation_fmt']) ?>
<?php else: ?>
vor der Prüfung aufgenommen
<?php endif; ?>
</div>
<?php endif; ?>
</td>
<td style="font-size:0.8rem; color:var(--text-muted);"><?= e((string)$cred['created_at']) ?></td>
<td style="font-size:0.8rem; color:var(--text-muted);">
<?= !empty($cred['last_used_at']) ? e((string)$cred['last_used_at']) : 'nie' ?>
@@ -6752,8 +6918,20 @@ Antwort:
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
Notfalltoken für Ihr eigenes Konto:
<?php if ($myRecovery['present']): ?>
hinterlegt seit <?= e((string)$myRecovery['created_at']) ?>.
<?php if (!empty($myRecovery['expired'])): ?>
<span style="color:var(--danger);">abgelaufen</span> —
hinterlegt am <?= e((string)$myRecovery['created_at']) ?>,
also vor <?= (int)$myRecovery['age_days'] ?> Tagen. Er öffnet nichts mehr;
bitte einen neuen erzeugen.
<?php elseif ($myRecovery['present']): ?>
hinterlegt seit <?= e((string)$myRecovery['created_at']) ?><?php
if ($myRecovery['age_days'] !== null) {
$rest = (int)(CredentialRepo::RECOVERY_MAX_AGE / 86400) - (int)$myRecovery['age_days'];
echo $rest <= 30
? ', <span style="color:var(--warning);">läuft in ' . $rest . ' Tag(en) ab</span>'
: ' (' . $rest . ' Tage gültig)';
}
?>.
<?php elseif (!empty($myRecovery['used_at'])): ?>
<span style="color:var(--warning);">verbraucht am <?= e((string)$myRecovery['used_at']) ?>.</span>
<?php else: ?>
@@ -6764,6 +6942,106 @@ Antwort:
<?php endif; ?>
</div>
<?php if ($twoFactorReady): ?>
<div class="card">
<div class="card-header"><h2 class="card-title">🛡️ Welche Schlüssel zugelassen sind</h2></div>
<?php if (!$attestationReady): ?>
<div class="alert" style="background:rgba(255,180,0,0.12); border:1px solid rgba(255,180,0,0.3); color:#fde68a; font-weight:500;">
<strong>Migration 021 fehlt.</strong> Die Herkunft eines Schlüssels lässt sich
erst danach festhalten. Bis dahin gilt „jeder Schlüssel“ — also das Verhalten
vor dieser Funktion. Die Migration läuft unter <em>System → 🗄️ DB Migration</em>.
</div>
<?php else: ?>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
WebAuthn allein unterscheidet nicht zwischen einem Yubikey und einem
Passwortverwalter — beide sind für den Browser gültige Authenticatoren, und
<code>cross-platform</code> in den Vorgaben ist nur ein Hinweis an die
Oberfläche, keine Prüfung. Wer <strong>ausschließlich Hardware</strong>
zulassen will, braucht die Attestation: ein Zertifikat, das der Schlüssel ab
Werk trägt und das auf eine Herstellerwurzel führt. Software kann sie nicht
herleiten, weil der zugehörige private Schlüssel das Sicherheitselement nie
verlässt.
</p>
<form method="POST" action="index.php">
<?= $csrfField ?>
<input type="hidden" name="action" value="save_webauthn_policy">
<input type="hidden" name="_return" value="sub-system-users">
<?php
$stufen = [
HardwarePolicy::OFF => [
'Jeder Authenticator',
'Es wird keine Attestation angefordert und nichts geprüft. '
. 'Passwortverwalter und eingebaute Passkeys sind zugelassen. '
. 'Das Verhalten bis Fassung 2.9.1.',
],
HardwarePolicy::REGISTER => [
'Neue Schlüssel nur als Hardware',
'Eine neue Registrierung muss eine geprüfte Herstellerattestation '
. 'vorlegen. Bereits hinterlegte Schlüssel melden sich weiter an — '
. 'das ist die Stufe, auf der Sie Ihren Yubikey neu aufnehmen, '
. 'ohne sich auszusperren.',
],
HardwarePolicy::STRICT => [
'Auch die Anmeldung nur mit Hardware',
'Zusätzlich werden bei der Anmeldung nur noch Schlüssel angeboten und '
. 'akzeptiert, deren Herkunft geprüft ist. Erst diese Stufe erfüllt '
. 'ein Protokoll, das „nur Hardware“ verlangt.',
],
];
?>
<?php foreach ($stufen as $wert => $beschreibung): ?>
<label style="display:block; margin-bottom:0.9rem; cursor:pointer;">
<input type="radio" name="policy_level" value="<?= e($wert) ?>"
<?= $policyLevel === $wert ? 'checked' : '' ?>>
<strong style="margin-left:0.35rem;"><?= e($beschreibung[0]) ?></strong>
<span style="display:block; margin-left:1.6rem; color:var(--text-muted); font-size:0.82rem;">
<?= e($beschreibung[1]) ?>
</span>
</label>
<?php endforeach; ?>
<?php if ($myAttestedCount === 0): ?>
<div class="alert" style="background:rgba(255,180,0,0.12); border:1px solid rgba(255,180,0,0.3); color:#fde68a; font-size:0.85rem; margin:1rem 0;">
Für Ihr Konto ist noch kein geprüfter Hardwarekey hinterlegt. Die Stufe
<em>Auch die Anmeldung nur mit Hardware</em> lässt sich deshalb nicht
einschalten — sie würde Ihren eigenen Zugang sperren. Stellen Sie erst auf
<em>Neue Schlüssel nur als Hardware</em> und nehmen Sie den Schlüssel neu auf.
</div>
<?php endif; ?>
<div class="form-group" style="margin-top:1rem;">
<label class="form-label">Zugelassene Modelle (optional)</label>
<textarea name="policy_aaguids" class="form-input" rows="3"
style="font-family:monospace; font-size:0.8rem;"
placeholder="leer = jedes Gerät des Herstellers&#10;d7781e5d-e353-46aa-afe2-3ca49f13332a"><?= e($policyAllowlist) ?></textarea>
</div>
<p style="color:var(--text-muted); font-size:0.78rem; margin:0.4rem 0 1rem;">
Eine Gerätekennung je Zeile. <strong>Leer lassen ist der Normalfall:</strong>
Die Wurzelprüfung beantwortet bereits „stammt von diesem Hersteller“. Die Liste
verengt auf einzelne Modelle und hat einen Unterhaltspreis — neue
Firmwarestände bekommen neue Kennungen, und eine ungepflegte Liste weist
irgendwann einen echten Schlüssel ab. Die Kennung Ihrer Schlüssel steht in
der Tabelle oben.
</p>
<p style="color:var(--text-muted); font-size:0.78rem; margin-bottom:1rem;">
Hinterlegte Herstellerwurzeln: <strong><?= (int)$policyRootCount ?></strong>
(mitgeliefert: Yubico). Weitere Hersteller lassen sich über
<code>security.webauthn_root_files</code> in der <code>config.php</code>
ergänzen — bewusst dort und nicht hier, damit sich die Prüfung nicht über
dieselbe Oberfläche aushebeln lässt, die sie schützt.
</p>
<button type="submit" class="btn">Regel speichern</button>
</form>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="card">
<div class="card-header"><h2 class="card-title">👤 Benutzerkonten</h2></div>
@@ -9015,6 +9293,24 @@ document.addEventListener('DOMContentLoaded', () => {
: (tone === 'ok' ? 'var(--success)' : 'var(--text-muted)');
};
/**
* Holt den lesbaren Text aus einer Fehlerantwort.
*
* Die API verpackt Fehler als { error: { code, message } }. Ein
* "data.message || data.error" griff deshalb auf das OBJEKT zu, und der
* Browser machte daraus "[object Object]" - ausgerechnet an der Stelle,
* an der die Begruendung stehen sollte, warum ein Schluessel abgelehnt
* wurde. Beide Formen zu lesen ist kein Zierrat: Die aeltere, flache
* Form kommt weiterhin vor.
*/
const fehlertext = data => {
const huelle = data && data.error;
if (huelle && typeof huelle === 'object' && huelle.message) { return huelle.message; }
if (data && typeof data.message === 'string') { return data.message; }
if (typeof huelle === 'string') { return huelle; }
return 'Unerwartete Antwort des Servers.';
};
const post = async (action, payload) => {
const response = await fetch('/api/auth/v1/?action=' + action, {
method: 'POST',
@@ -9024,7 +9320,17 @@ document.addEventListener('DOMContentLoaded', () => {
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.message || data.error || 'Unerwartete Antwort des Servers.');
// Eine fehlende Passwortbestätigung ist kein Fehler, sondern ein
// Zwischenschritt. Sie als roten Text stehen zu lassen, hieße dem
// Benutzer eine Sackgasse zu zeigen, aus der er den Ausweg selbst
// suchen müsste.
const huelle = data && data.error;
if (huelle && huelle.code === 'reauth_required' && huelle.reauth_url) {
setStatus('Bitte kurz mit dem Passwort bestätigen …');
window.location.href = huelle.reauth_url;
return new Promise(() => {});
}
throw new Error(fehlertext(data));
}
return data;
};
@@ -9038,7 +9344,12 @@ document.addEventListener('DOMContentLoaded', () => {
button.disabled = true;
try {
setStatus('Anfrage wird vorbereitet …');
const options = (await post('register_options')).publicKey;
const vorgabe = await post('register_options');
const options = vorgabe.publicKey;
// Die Regel steht vor der Berührung, nicht danach. Wer erst nach
// dem Antippen erfährt, dass nur Hardware zählt, sucht den Fehler
// beim Schlüssel statt bei der Einstellung.
const nurHardware = vorgabe.policy && vorgabe.policy !== 'off';
options.challenge = fromB64u(options.challenge);
options.user.id = fromB64u(options.user.id);
@@ -9046,7 +9357,10 @@ document.addEventListener('DOMContentLoaded', () => {
...c, id: fromB64u(c.id)
}));
setStatus('Bitte den Hardwarekey jetzt anstecken und berühren.');
setStatus(nurHardware
? 'Bitte den Hardwarekey jetzt anstecken und berühren. Zugelassen sind nur Geräte '
+ 'mit Herstellernachweis — ein Passwortverwalter wird abgelehnt.'
: 'Bitte den Hardwarekey jetzt anstecken und berühren.');
const credential = await navigator.credentials.create({ publicKey: options });
setStatus('Schlüssel wird hinterlegt …');
@@ -9054,14 +9368,17 @@ document.addEventListener('DOMContentLoaded', () => {
? credential.response.getTransports()
: null;
await post('register_verify', {
const ergebnis = await post('register_verify', {
clientDataJSON: toB64u(credential.response.clientDataJSON),
attestationObject: toB64u(credential.response.attestationObject),
label: (labelField.value || '').trim(),
transports: transports
});
setStatus('Registriert. Die Seite wird neu geladen …', 'ok');
setStatus(ergebnis.attested
? 'Registriert und als Hardware nachgewiesen (' + (ergebnis.root || 'Herstellerwurzel')
+ '). Die Seite wird neu geladen …'
: 'Registriert. Ein Hardwarenachweis liegt nicht vor. Die Seite wird neu geladen …', 'ok');
window.location.reload();
} catch (err) {
const message = err && err.name === 'NotAllowedError'
+132 -6
View File
@@ -10,6 +10,7 @@ use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Core\Logger;
use Deploymentcenter\Core\Webauthn\CredentialRepo;
use Deploymentcenter\Modules\License\RateLimiter;
Auth::startSession();
@@ -21,6 +22,61 @@ if (Auth::isLoggedIn()) {
$error = null;
$notice = null;
// -----------------------------------------------------------------------
// Sperrbildschirm
//
// Nach 30 Minuten Leerlauf wird die Sitzung gesperrt, nicht vernichtet
// (Auth::IDLE_TIMEOUT). Geoeffnet wird sie mit dem Passwort allein - NIST
// SP 800-63B laesst auf Stufe AAL2 zur Reaktivierung einen Faktor genuegen,
// und der Hardwarekey fuegte hier nichts hinzu, was der Sitzungscookie nicht
// schon behauptet: Wer den Cookie hat, hat das Geraet. Das Passwort hat er
// damit nicht.
// -----------------------------------------------------------------------
$gesperrt = Auth::isLocked();
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['step'] ?? '') === 'unlock') {
if (!Csrf::isValid(is_string($_POST['csrf_token'] ?? null) ? $_POST['csrf_token'] : null)) {
$error = 'Sitzung abgelaufen. Bitte erneut versuchen.';
} elseif (!$gesperrt) {
header('Location: /login.php');
exit;
} else {
try {
$pdo = Db::init();
// Die IP-Sperre gilt auch hier. Der Sperrbildschirm waere sonst
// die Stelle, an der ein Passwort ungezaehlt geraten werden darf.
$ipSperre = Auth::lockoutSeconds($pdo, Http::clientIp());
if ($ipSperre > 0) {
$error = sprintf(
'Zu viele fehlgeschlagene Versuche von diesem Netzwerk. Bitte in %d Minute(n) erneut versuchen.',
(int)ceil($ipSperre / 60)
);
} elseif (Auth::unlock($pdo, (string)($_POST['password'] ?? ''))) {
$ziel = (string)($_POST['return_to'] ?? '');
header('Location: ' . (preg_match('#^/[A-Za-z0-9_./#?=-]*$#', $ziel) ? $ziel : '/index.php'));
exit;
} else {
Auth::recordFailedAttempt($pdo, Http::clientIp(), Auth::lockedUsername());
$error = 'Das Passwort stimmt nicht.';
}
} catch (\Throwable $e) {
Logger::error('Entsperren fehlgeschlagen', ['error' => $e->getMessage()]);
$error = 'Entsperren derzeit nicht moeglich. Bitte spaeter erneut versuchen.';
}
}
$gesperrt = Auth::isLocked();
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['step'] ?? '') === 'abandon') {
// Bewusst abmelden statt entsperren.
Auth::logout();
header('Location: /login.php');
exit;
}
// -----------------------------------------------------------------------
// Zweite Stufe: Notfalltoken und Abbruch
//
@@ -85,17 +141,35 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === '') {
} else {
try {
$pdo = Db::init();
// Drosselung vor allem anderen. Die Kontosperre greift erst ab
// dem fuenften, die IP-Sperre ab dem zehnten Fehlversuch - bis
// dahin durfte bisher beliebig schnell geraten werden. Der
// RateLimiter ist im Haus und auf acht anderen Endpunkten im
// Einsatz; ausgerechnet die Anmeldung war ohne.
$drossel = new RateLimiter($pdo, 10, 60, 'webui_login');
$ipLockout = Auth::lockoutSeconds($pdo, Http::clientIp());
$accLockout = Auth::accountLockoutSeconds($pdo, $username);
if ($ipLockout > 0) {
if (!$drossel->check(Http::clientIp())) {
$error = 'Zu viele Anmeldeversuche. Bitte eine Minute warten.';
} elseif ($ipLockout > 0) {
// Die IP-Sperre darf beim Namen genannt werden: Sie sagt
// etwas ueber das Netzwerk aus, nicht darueber, ob es ein
// bestimmtes Konto gibt.
$error = sprintf(
'Zu viele fehlgeschlagene Anmeldeversuche von diesem Netzwerk. Bitte in %d Minute(n) erneut versuchen.',
(int)ceil($ipLockout / 60)
);
} elseif ($accLockout > 0) {
// Die KONTOsperre dagegen nicht. Gesperrt wird nur, was
// existiert - eine eigene Meldung dafuer verriete also die
// Existenz des Namens und haette die Laufzeitangleichung
// ueber dummyHash() in verifyCredentials() wieder aufgehoben.
// Die Wartezeit darf stehen, der Grund nicht.
$error = sprintf(
'Dieses Benutzerkonto ist aufgrund zu vieler Fehlversuche vorübergehend gesperrt. Bitte in %d Minute(n) erneut versuchen.',
'Anmeldung nicht moeglich. Bitte in %d Minute(n) erneut versuchen.',
(int)ceil($accLockout / 60)
);
} else {
@@ -121,8 +195,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === '') {
$newIpLockout = Auth::lockoutSeconds($pdo, Http::clientIp());
if ($newAccLockout > 0) {
// Wieder ohne den Grund: "dieses Benutzerkonto wurde
// gesperrt" ist eine Bestaetigung, dass es das Konto
// gibt.
$error = sprintf(
'Zu viele Fehlversuche. Dieses Benutzerkonto wurde für %d Minute(n) gesperrt.',
'Anmeldung nicht moeglich. Bitte in %d Minute(n) erneut versuchen.',
(int)ceil($newAccLockout / 60)
);
} elseif ($newIpLockout > 0) {
@@ -131,7 +208,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $step === '') {
(int)ceil($newIpLockout / 60)
);
} else {
$error = 'Ungueltige Anmeldedaten.';
$error = 'Anmeldung nicht moeglich.';
}
}
} catch (\Throwable $e) {
@@ -419,7 +496,11 @@ $pendingUsername = Auth::pendingUsername();
</div>
<h1 class="brand-title">Deploymentcenter</h1>
<p class="brand-subtitle">
<?php if ($gesperrt): ?>
Sitzung gesperrt
<?php else: ?>
<?= $pendingUsername !== '' ? 'Zweiter Schritt: Hardwarekey' : 'Bitte melden Sie sich an, um fortzufahren' ?>
<?php endif; ?>
</p>
</div>
@@ -427,7 +508,36 @@ $pendingUsername = Auth::pendingUsername();
<div class="alert-danger"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<?php if ($pendingUsername !== ''): ?>
<?php if ($gesperrt): ?>
<div class="step-note">
Die Sitzung von <strong><?= htmlspecialchars(Auth::lockedUsername()) ?></strong> wurde
nach 30 Minuten ohne Aktivität gesperrt. Zum Weiterarbeiten genügt das Passwort —
der Hardwarekey wird erst bei einer neuen Anmeldung wieder verlangt.
</div>
<form method="POST" action="login.php">
<?= Csrf::field() ?>
<input type="hidden" name="step" value="unlock">
<input type="hidden" name="return_to"
value="<?= htmlspecialchars((string)($_GET['weiter'] ?? '/index.php'), ENT_QUOTES) ?>">
<div class="form-group">
<label for="unlockPassword">Passwort</label>
<input type="password" id="unlockPassword" name="password"
class="form-control" required autofocus placeholder="••••••••">
</div>
<button type="submit" class="btn-login">Weiterarbeiten</button>
</form>
<form method="POST" action="login.php" class="cancel-form">
<?= Csrf::field() ?>
<input type="hidden" name="step" value="abandon">
<button type="submit" class="btn-cancel">Stattdessen abmelden</button>
</form>
<?php elseif ($pendingUsername !== ''): ?>
<div class="step-note">
Passwort erkannt für <strong><?= htmlspecialchars($pendingUsername) ?></strong>.
@@ -513,6 +623,22 @@ $pendingUsername = Auth::pendingUsername();
status.classList.toggle('is-error', !!isError);
};
/**
* Der lesbare Text aus einer Fehlerantwort.
*
* Die API verpackt Fehler als { error: { code, message } }. Wer nur
* data.error liest, zeigt "[object Object]" an - hier waere das
* besonders misslich, denn an dieser Stelle steht der einzige Hinweis,
* warum die Anmeldung nicht weitergeht.
*/
const fehlertext = data => {
const huelle = data && data.error;
if (huelle && typeof huelle === 'object' && huelle.message) { return huelle.message; }
if (data && typeof data.message === 'string') { return data.message; }
if (typeof huelle === 'string') { return huelle; }
return 'Unerwartete Antwort des Servers.';
};
if (!window.PublicKeyCredential) {
setStatus('Dieser Browser kennt keine Hardwarekeys. Bitte den Notfalltoken benutzen.', true);
button.disabled = true;
@@ -528,7 +654,7 @@ $pendingUsername = Auth::pendingUsername();
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.message || data.error || 'Unerwartete Antwort des Servers.');
throw new Error(fehlertext(data));
}
return data;
};
@@ -0,0 +1,68 @@
-- Migration 021: Herkunft eines Hardwarekeys festhalten
--
-- Additive Migration. Der Migrator toleriert 1050/1060/1061/1062/1091.
--
-- ---------------------------------------------------------------------------
-- Warum das nicht schon in 019 stand
-- ---------------------------------------------------------------------------
-- 019 hat die Attestation bewusst nicht geprueft und deshalb auch nichts
-- darueber gespeichert: Wer seinen eigenen Schluessel an seinem eigenen Server
-- registriert, muss sich nicht ausweisen lassen. Die Spalte aaguid war dabei
-- schon angelegt - und blieb in der Praxis immer leer. Das ist kein Fehler
-- der Umsetzung, sondern Absicht des Standards: Fordert eine Seite
-- attestation "none" an, ersetzt der BROWSER die Geraetekennung durch
-- sechzehn Nullbytes, bevor der Server sie zu sehen bekommt. Ohne
-- angeforderte Attestation gibt es also grundsaetzlich keine Herkunft, nicht
-- einmal eine ungepruefte.
--
-- Gebraucht wird sie, seit die Frage anders lautet: Ein Passwortverwalter
-- liess sich als zweiter Faktor registrieren. Fuer das Deploymentcenter
-- selbst ist das hinnehmbar, fuer ein Protokoll, das ausdruecklich Hardware
-- verlangt, nicht.
--
-- ---------------------------------------------------------------------------
-- attested ist die einzige Spalte, an der eine Entscheidung haengt
-- ---------------------------------------------------------------------------
-- Die drei uebrigen sind Anzeige und Nachvollziehbarkeit. Die Trennung ist
-- beabsichtigt: attestation_ca und attestation_root kommen aus einem
-- Zertifikat, das der Authenticator geschickt hat - also von aussen. Sie
-- eignen sich, um einem Menschen zu zeigen, was da angekommen ist, aber nicht
-- als Grundlage eines Vergleichs im Code. Was die Pruefung ergeben hat, steht
-- in genau einem Bit, und das hat der Server selbst gesetzt.
--
-- Vorgabe 0: Alles, was vor dieser Migration registriert wurde, ist NICHT
-- attestiert - und zwar unabhaengig davon, ob es ein Yubikey war. Das ist die
-- ehrliche Vorgabe. Ein "war bestimmt Hardware" waere eine Behauptung ueber
-- etwas, das nie geprueft wurde, und genau solche Behauptungen soll diese
-- Migration abschaffen. Wer die Stufe "strict" nutzen will, nimmt seinen
-- Schluessel einmal neu auf; das ist der Preis dafuer, dass die Spalte etwas
-- bedeutet.
ALTER TABLE dc_user_credentials
ADD COLUMN attested TINYINT(1) NOT NULL DEFAULT 0 AFTER aaguid;
-- Das Attestationsformat, so wie der Authenticator es genannt hat:
-- "packed" (CTAP2, der Normalfall beim YubiKey 5), "fido-u2f" (CTAP1),
-- "none" (Passwortverwalter und eingebaute Passkeys).
ALTER TABLE dc_user_credentials
ADD COLUMN attestation_fmt VARCHAR(32) NULL AFTER attested;
-- Der Name aus dem Blattzertifikat - bei Yubico etwa
-- "Yubico U2F EE Serial 1234567890".
ALTER TABLE dc_user_credentials
ADD COLUMN attestation_ca VARCHAR(190) NULL AFTER attestation_fmt;
-- Die Wurzel, bei der die Kette geendet hat. Sie steht hier und nicht nur im
-- Protokoll, weil sie die Frage "warum gilt dieser Schluessel als Hardware"
-- ohne Blick in die Logdatei beantwortet.
ALTER TABLE dc_user_credentials
ADD COLUMN attestation_root VARCHAR(190) NULL AFTER attestation_ca;
-- ---------------------------------------------------------------------------
-- Die Regel selbst steht in dc_settings, nicht hier
-- ---------------------------------------------------------------------------
-- Eingetragen wird sie erst beim ersten Speichern in der Oberflaeche. Ein
-- INSERT waere hier moeglich, wuerde aber eine Entscheidung vorwegnehmen:
-- Eine Migration, die eine Sicherheitsstufe setzt, aendert im Vorbeigehen das
-- Verhalten einer laufenden Anlage. Fehlt der Eintrag, liest
-- HardwarePolicy::level() die Vorgabe "off" - also genau das Verhalten von
-- vorher. Umgestellt wird, wenn jemand umstellt.
+36
View File
@@ -0,0 +1,36 @@
-- Migration 022: Sitzungsgeneration je Konto
--
-- Additive Migration. Der Migrator toleriert 1050/1060/1061/1062/1091.
--
-- ---------------------------------------------------------------------------
-- Wozu
-- ---------------------------------------------------------------------------
-- Ein Passwortwechsel beendete bisher keine einzige Sitzung. Wer das Passwort
-- aendert, weil er einen fremden Zugriff vermutet, liess damit genau den
-- drin, dessentwegen er es aendert: Dessen Cookie galt unveraendert weiter.
--
-- PHP legt Sitzungen als Dateien ab; es gibt keinen Index "alle Sitzungen des
-- Kontos X", ohne einen eigenen Sitzungsspeicher zu bauen. Eine Generation
-- loest dasselbe Problem mit einer Spalte: Jede Sitzung merkt sich beim
-- Anmelden den Stand, und bei jeder Anfrage wird verglichen. Wird der Stand
-- erhoeht, passen alle uebrigen Sitzungen nicht mehr - ohne dass man sie
-- einzeln finden muesste.
--
-- Die eigene Sitzung uebernimmt den neuen Stand sofort. Wer sein Passwort
-- aendert, soll nicht sich selbst hinauswerfen; das waere die Art von
-- Nebenwirkung, wegen der Leute Passwoerter nicht aendern.
--
-- ---------------------------------------------------------------------------
-- Warum Vorgabe 0 und nicht ein Zeitstempel
-- ---------------------------------------------------------------------------
-- Ein Zaehler ist gegenueber einer Uhr im Vorteil: Er braucht keine
-- Zeitzonen, keine Sekundengenauigkeit und kein Nachdenken darueber, was
-- passiert, wenn zwei Aenderungen in dieselbe Sekunde fallen. Verglichen wird
-- auf Gleichheit, nicht auf "juenger als".
--
-- Sitzungen, die vor dieser Migration eroeffnet wurden, tragen den Wert gar
-- nicht. Sie gelten weiter (siehe Auth::sessionEpochValid): Eine
-- Sicherheitsverbesserung, die im Moment ihrer Einfuehrung alle Angemeldeten
-- hinauswirft, wird beim naechsten Mal abgeschaltet.
ALTER TABLE dc_users
ADD COLUMN session_epoch INT NOT NULL DEFAULT 0 AFTER recovery_used_at;
+442 -21
View File
@@ -21,12 +21,45 @@ use PDO;
*/
final class Auth
{
/** Leerlauf-Timeout in Sekunden (2 Stunden). */
private const IDLE_TIMEOUT = 7200;
/**
* Leerlauf bis zur Sperre in Sekunden (30 Minuten).
*
* Bis 2.9.2 standen hier 2 Stunden, und die Sitzung wurde danach
* vernichtet. Beides ist geaendert, und zwar aus zwei verschiedenen
* Gruenden:
*
* Die 30 Minuten kommen aus NIST SP 800-63B: Auf Stufe AAL2 muss nach
* dieser Zeit erneut authentifiziert werden. Zwei Stunden erfuellen das
* nicht, und damit war der ganze Aufwand mit dem Hardwarekey an einer
* Zahl aufgehaengt, die niemand fuer eine Sicherheitsentscheidung hielt.
*
* Vernichtet wird die Sitzung dabei aber nicht mehr, sondern GESPERRT -
* siehe lock(). Derselbe Standard laesst zur Reaktivierung einen Faktor
* genuegen. Wer alle 30 Minuten Passwort UND Hardwarekey vorzeigen muss,
* legt den Schluessel irgendwann steckend liegen, und dann ist der
* zweite Faktor keiner mehr. Eine Regel, die zur Umgehung erzieht, ist
* schlechter als eine mildere, die eingehalten wird.
*/
private const IDLE_TIMEOUT = 1800;
/** Absolutes Session-Maximum in Sekunden (12 Stunden). */
private const ABSOLUTE_TIMEOUT = 43200;
/**
* Wie lange eine frische Authentifizierung fuer heikle Aenderungen
* zaehlt (10 Minuten).
*
* Betrifft die Handvoll Aktionen, die die Authentifizierung selbst
* veraendern: Passwort setzen, Notfalltoken ausstellen, Hardwarekey
* aufnehmen oder entfernen, die Hardwarekey-Regel umstellen. Ohne diese
* Huerde genuegt eine einzige uebernommene Sitzung, um sich dauerhaft
* einzunisten - einen eigenen Schluessel zu registrieren, einen
* Notfalltoken fuer jedes Konto auszustellen und den zweiten Faktor
* anlagenweit abzuschalten. Der zweite Faktor waere damit gegen genau
* den Fall wirkungslos, gegen den er gedacht ist.
*/
public const REAUTH_WINDOW = 600;
/** Fehlversuche pro IP, bevor IP-Sperre greift. */
public const DEFAULT_MAX_IP_ATTEMPTS = 10;
@@ -48,6 +81,15 @@ final class Auth
*/
public const PENDING_TIMEOUT = 300;
/**
* Fehlversuche auf der zweiten Stufe, bevor der Zwischenzustand faellt.
*
* Die Kontosperre wirkt erst beim naechsten Anmeldeversuch. Dieser
* Zaehler wirkt innerhalb des offenen Zwischenzustands - dort, wo sonst
* unbegrenzt geraten werden duerfte.
*/
public const MAX_PENDING_FAILURES = 5;
/** Rueckgabewerte von startLogin(). */
public const LOGIN_FAILED = 'failed';
public const LOGIN_COMPLETE = 'complete';
@@ -59,7 +101,23 @@ final class Auth
return;
}
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
// Ohne strict_mode uebernimmt PHP jede Sitzungskennung, die der
// Client sich ausdenkt - die Grundlage jeder Session-Fixation. Das
// session_regenerate_id(true) in establishSession() entschaerft das
// bereits, aber es entschaerft nur den Fall, den wir bedacht haben.
// Die Zeile hier schliesst die Gattung.
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
// Das Secure-Flag haengt sonst allein an $_SERVER['HTTPS']. Hinter
// einem TLS-terminierenden Proxy ist das leer, und der Cookie ginge
// ohne Secure hinaus - also genau dort, wo man es am wenigsten
// bemerkt. Die konfigurierte app.url ist die verlaessliche Quelle:
// Sie sagt, wie die Anlage erreichbar sein SOLL.
$configuredUrl = (string)Config::get('app.url', '');
$secure = $configuredUrl !== ''
? str_starts_with(strtolower($configuredUrl), 'https://')
: (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
session_set_cookie_params([
'lifetime' => 0,
@@ -76,10 +134,29 @@ final class Auth
self::enforceTimeouts();
}
/** Ergebnis der Generationspruefung je Anfrage, damit sie einmal kostet. */
private static ?bool $epochGeprueft = null;
public static function isLoggedIn(): bool
{
self::startSession();
return !empty($_SESSION['dc_user_id']) && !empty($_SESSION['dc_username']);
if (empty($_SESSION['dc_user_id']) || empty($_SESSION['dc_username'])) {
return false;
}
// Eine gesperrte Sitzung ist keine Anmeldung. Das ist die ganze
// Wirkung der Sperre: Jede Stelle, die bisher isLoggedIn() gefragt
// hat, bekommt ohne eigenes Zutun die richtige Antwort.
if (!empty($_SESSION['dc_locked'])) {
return false;
}
if (self::$epochGeprueft === null) {
self::$epochGeprueft = self::sessionEpochValid();
}
return self::$epochGeprueft;
}
public static function requireLogin(): void
@@ -89,7 +166,11 @@ final class Auth
}
if (!headers_sent()) {
header('Location: /login.php');
// Eine gesperrte Sitzung fuehrt auf den Sperrbildschirm, nicht
// auf die Anmeldung. Der Unterschied ist keine Kosmetik: Am
// Sperrbildschirm genuegt das Passwort, an der Anmeldung
// braeuchte es wieder den Hardwarekey.
header('Location: ' . (self::isLocked() ? '/login.php?step=unlock' : '/login.php'));
}
exit;
}
@@ -133,10 +214,19 @@ final class Auth
$diff = $ts - time();
return $diff > 0 ? $diff : 0;
} catch (\Throwable $e) {
// Spalte fehlt eventuell vor Migration 018
} catch (\PDOException $e) {
// Vor Migration 018 gibt es die Spalte nicht - dann gibt es auch
// keine Sperren, und 0 ist die richtige Antwort.
if (self::isMissingSchema($e)) {
return 0;
}
// Sonst nicht durchwinken: "return 0" heisst hier "Sperre
// aufgehoben". Bei einer gestoerten Datenbank fiele damit
// ausgerechnet der Brute-Force-Schutz als Erstes weg.
Logger::error('Kontosperr-Pruefung fehlgeschlagen', ['error' => $e->getMessage()]);
throw $e;
}
}
/**
@@ -169,10 +259,14 @@ final class Auth
$remaining = $window - (time() - $last);
return $remaining > 0 ? $remaining : 0;
} catch (\Throwable $e) {
Logger::warning('IP-Lockout-Pruefung nicht moeglich', ['error' => $e->getMessage()]);
} catch (\PDOException $e) {
if (self::isMissingSchema($e)) {
return 0;
}
Logger::error('IP-Sperr-Pruefung fehlgeschlagen', ['error' => $e->getMessage()]);
throw $e;
}
}
/**
@@ -357,13 +451,23 @@ final class Auth
try {
return (new CredentialRepo($db))->countForUser($userId) > 0;
} catch (\Throwable $e) {
// Tabelle fehlt eventuell vor Migration 019. Dann gibt es keine
// Schluessel, also auch keine Pflicht - und die Anmeldung laeuft
// wie zuvor, statt an einer fehlenden Tabelle zu scheitern.
Logger::warning('Pruefung auf Hardwarekey nicht moeglich', ['error' => $e->getMessage()]);
} catch (\PDOException $e) {
// Nur der eine tolerierbare Fall: Vor Migration 019 gibt es die
// Tabelle nicht, also auch keine Schluessel und keine Pflicht.
if (self::isMissingSchema($e)) {
Logger::warning('dc_user_credentials fehlt - Migration 019 ausstehend');
return false;
}
// Alles andere - Verbindungsabbruch, Lock-Timeout, "too many
// connections", entzogene Rechte - hat vorher ebenfalls zu
// "false" gefuehrt und damit im Stillen fuer ALLE Konten auf
// Passwort-allein zurueckgeschaltet. Eine Authentifizierung muss
// geschlossen scheitern: Lieber keine Anmeldung als eine ohne
// zweiten Faktor.
Logger::error('Pruefung auf Hardwarekey fehlgeschlagen', ['error' => $e->getMessage()]);
throw $e;
}
}
/** Das Konto, das gerade zwischen erster und zweiter Stufe steht (0 = keines). */
@@ -415,6 +519,20 @@ final class Auth
return false;
}
// Eine Sperre, die zwischen erster und zweiter Stufe verhaengt wurde,
// muss hier greifen. Vorher konnte ein Konto, das waehrend des
// Ratens gesperrt wurde, die Anmeldung trotzdem abschliessen - die
// Sperre galt nur fuer den Weg, den der Angreifer gerade nicht ging.
if (self::accountLockoutSeconds($db, (string)$user['username']) > 0) {
SecurityLogger::accountLocked(
Http::clientIp(),
(string)$user['username'],
self::accountLockoutSeconds($db, (string)$user['username'])
);
self::clearPending();
return false;
}
self::clearPending();
self::establishSession($user);
@@ -457,8 +575,28 @@ final class Auth
if (is_array($user)) {
self::handleAccountFailedAttempt($db, $user, $ip, $username);
}
} catch (\Throwable $e) {
Logger::warning('Fehlversuch der zweiten Stufe nicht gezaehlt', ['error' => $e->getMessage()]);
} catch (\PDOException $e) {
if (!self::isMissingSchema($e)) {
throw $e;
}
Logger::warning('Fehlversuch der zweiten Stufe nicht gezaehlt - Migration ausstehend');
}
// Zusaetzlich ein Zaehler in der Sitzung. Die Kontosperre oben
// greift erst beim naechsten Anmeldeversuch; INNERHALB des offenen
// Zwischenzustands duerfte sonst beliebig oft geraten werden, und
// genau das versprach der Kommentar ueber dieser Methode zu
// verhindern. Nach der fuenften Fehlmeldung ist der Zwischenzustand
// weg und es geht wieder beim Passwort los.
$fehl = (int)($_SESSION['dc_pending_failures'] ?? 0) + 1;
$_SESSION['dc_pending_failures'] = $fehl;
if ($fehl >= self::MAX_PENDING_FAILURES) {
SecurityLogger::log('second_factor_abandoned', $ip, [
'username' => $username,
'failures' => $fehl,
]);
self::clearPending();
}
}
@@ -471,7 +609,8 @@ final class Auth
$_SESSION['dc_pending_username'],
$_SESSION['dc_pending_at'],
$_SESSION['dc_webauthn_challenge'],
$_SESSION['dc_webauthn_challenge_at']
$_SESSION['dc_webauthn_challenge_at'],
$_SESSION['dc_pending_failures']
);
}
@@ -515,6 +654,12 @@ final class Auth
/** Session-Variablen einer erfolgreichen Anmeldung setzen. */
private static function establishSession(array $user): void
{
// Das CSRF-Token vor der Regeneration verwerfen. Es liegt in
// $_SESSION und ueberlebt den Kennungswechsel sonst - ein vor der
// Anmeldung gelerntes Token gaelte danach weiter, und damit haette
// die Regeneration die halbe Wirkung, die sie haben soll.
unset($_SESSION['dc_csrf_token']);
session_regenerate_id(true);
$_SESSION['dc_user_id'] = (int)$user['id'];
@@ -522,6 +667,15 @@ final class Auth
$_SESSION['dc_role'] = self::roleOf($user);
$_SESSION['dc_login_at'] = time();
$_SESSION['dc_last_seen'] = time();
$_SESSION['dc_reauth_at'] = time();
unset($_SESSION['dc_locked']);
// Die Generation, unter der diese Sitzung gilt. Ein Passwortwechsel
// erhoeht sie und beendet damit alle uebrigen Sitzungen des Kontos.
if (array_key_exists('session_epoch', $user)) {
$_SESSION['dc_session_epoch'] = (int)$user['session_epoch'];
}
self::$epochGeprueft = null;
Logger::info('Anmeldung erfolgreich', ['username' => $user['username'], 'ip' => Http::clientIp()]);
}
@@ -641,8 +795,49 @@ final class Auth
session_destroy();
}
/**
* Ein Fehlversuch, der nicht aus verifyCredentials() kommt.
*
* Gebraucht vom Sperrbildschirm: Auch dort wird ein Passwort geprueft,
* und ein Weg, auf dem ungezaehlt geraten werden darf, waere der
* bequemste - er umgeht die Anmeldeseite vollstaendig.
*/
public static function recordFailedAttempt(PDO $db, string $ip, string $username): void
{
self::recordAttempt($db, $ip, $username, false);
SecurityLogger::authFail($ip, $username, 'unlock_failed');
}
private static function recordAttempt(PDO $db, string $ip, string $username, bool $success): void
{
// Gelegentlich aufraeumen. Die Tabelle wuchs bisher unbegrenzt, und
// sie wird bei JEDER Anmeldung gelesen - eine Tabelle, die nur
// waechst und im heissesten Pfad liegt, wird irgendwann zu dem
// Problem, das die Anmeldung langsam macht.
//
// Ein eigener Cron-Eintrag waere sauberer, braeuchte aber eine
// laufende Zeitsteuerung, damit die Sperre funktioniert. Faellt sie
// aus, faellt mit ihr die Aufraeumung, und niemand merkt es. Ein
// Hundertstel der Anmeldungen erledigt es beilaeufig und haengt an
// nichts.
if (random_int(1, 100) === 1) {
try {
// Das Fenster der IP-Sperre ist die Untergrenze; ein Tag
// Puffer, damit eine laufende Auswertung nichts verliert.
$db->exec(
'DELETE FROM dc_login_attempts '
. 'WHERE attempted_at < (UTC_TIMESTAMP() - INTERVAL 7 DAY)'
);
} catch (\Throwable $e) {
// Aufraeumen ist Beiwerk. Es darf eine Anmeldung nicht
// scheitern lassen - anders als die Pruefungen, die es
// beschleunigen soll.
Logger::warning('Aufraeumen von dc_login_attempts fehlgeschlagen', [
'error' => $e->getMessage(),
]);
}
}
try {
$stmt = $db->prepare('
INSERT INTO dc_login_attempts (ip, username, success, attempted_at)
@@ -690,16 +885,242 @@ final class Auth
$loginAt = (int)($_SESSION['dc_login_at'] ?? $now);
$lastSeen = (int)($_SESSION['dc_last_seen'] ?? $now);
$expired = ($now - $lastSeen) > self::IDLE_TIMEOUT
|| ($now - $loginAt) > self::ABSOLUTE_TIMEOUT;
if ($expired) {
// Das absolute Maximum beendet die Sitzung wirklich. Zwoelf Stunden
// sind keine Pause, sondern ein anderer Tag.
if (($now - $loginAt) > self::ABSOLUTE_TIMEOUT) {
$_SESSION = [];
session_destroy();
session_start();
// Nach dem Neustart eine frische Kennung. Ohne diese Zeile liefe
// der Benutzer mit derselben Kennung weiter, mit der er
// hereingekommen ist - und die kannte moeglicherweise jemand.
session_regenerate_id(true);
return;
}
// Leerlauf sperrt nur. Die Sitzung bleibt bestehen, aber
// isLoggedIn() meldet false, bis das Passwort sie wieder oeffnet.
if (($now - $lastSeen) > self::IDLE_TIMEOUT) {
$_SESSION['dc_locked'] = true;
return;
}
// Eine gesperrte Sitzung verlaengert sich nicht von selbst. Sonst
// hielte ein Hintergrundabruf - ein offener Tab, der alle zwei
// Minuten etwas nachlaedt - die Sperre ewig auf.
if (empty($_SESSION['dc_locked'])) {
$_SESSION['dc_last_seen'] = $now;
}
}
// -----------------------------------------------------------------
// Sperre und erneute Authentifizierung
// -----------------------------------------------------------------
/** Steht eine Sitzung, die nur noch das Passwort braucht? */
public static function isLocked(): bool
{
self::startSession();
return !empty($_SESSION['dc_user_id']) && !empty($_SESSION['dc_locked']);
}
/**
* Sperrt die Sitzung absichtlich, um eine Passwortbestaetigung zu
* erzwingen.
*
* Derselbe Zustand wie nach 30 Minuten Leerlauf, nur willentlich
* herbeigefuehrt. Damit gibt es fuer "bitte nochmal das Passwort" genau
* einen Weg und einen Bildschirm - zwei Formulare, die dasselbe tun,
* laufen mit der Zeit auseinander, und beim Pruefen von Passwoertern ist
* das die teuerste Art von Duplikat.
*/
public static function lockForReauth(): void
{
self::startSession();
if (!empty($_SESSION['dc_user_id'])) {
$_SESSION['dc_locked'] = true;
unset($_SESSION['dc_reauth_at']);
self::$epochGeprueft = null;
}
}
/** Der Name des gesperrten Kontos - fuer die Anzeige am Sperrbildschirm. */
public static function lockedUsername(): string
{
self::startSession();
$name = $_SESSION['dc_username'] ?? null;
return is_string($name) ? $name : '';
}
/**
* Oeffnet eine gesperrte Sitzung wieder - nur mit dem Passwort.
*
* Bewusst ohne zweiten Faktor: AAL2 laesst zur Reaktivierung einen
* Faktor genuegen, und der Hardwarekey wuerde hier nichts hinzufuegen,
* was der Sitzungscookie nicht schon behauptet. Wer den Cookie hat, hat
* das Geraet - nicht aber das Passwort.
*/
public static function unlock(PDO $db, string $password): bool
{
self::startSession();
$userId = (int)($_SESSION['dc_user_id'] ?? 0);
if ($userId <= 0 || empty($_SESSION['dc_locked'])) {
return false;
}
if (!self::verifyPasswordFor($db, $userId, $password)) {
SecurityLogger::log('session_unlock_failed', Http::clientIp(), [
'username' => self::lockedUsername(),
]);
return false;
}
unset($_SESSION['dc_locked']);
$_SESSION['dc_last_seen'] = time();
$_SESSION['dc_reauth_at'] = time();
// Auch das Entsperren ist eine Authentifizierung. Wer sie besteht,
// bekommt eine neue Sitzungskennung.
session_regenerate_id(true);
return true;
}
/**
* Liegt eine Authentifizierung innerhalb des Fensters zurueck?
*
* Das ist die Frage vor jeder Aenderung an den Anmeldemitteln selbst.
*/
public static function reauthFresh(): bool
{
self::startSession();
if (!self::isLoggedIn()) {
return false;
}
$at = (int)($_SESSION['dc_reauth_at'] ?? 0);
return $at > 0 && (time() - $at) <= self::REAUTH_WINDOW;
}
/** Merkt sich, dass gerade authentifiziert wurde. */
public static function touchReauth(): void
{
self::startSession();
$_SESSION['dc_reauth_at'] = time();
}
/** Prueft ein Passwort gegen ein bestimmtes Konto, ohne Sperrlogik. */
public static function verifyPasswordFor(PDO $db, int $userId, string $password): bool
{
if ($userId <= 0 || $password === '') {
return false;
}
$stmt = $db->prepare('SELECT password_hash FROM dc_users WHERE id = :id LIMIT 1');
$stmt->execute([':id' => $userId]);
$hash = $stmt->fetchColumn();
// Auch hier gegen einen Platzhalter rechnen, wenn es das Konto nicht
// gibt: Sonst verriete die Laufzeit den Unterschied.
return password_verify($password, is_string($hash) && $hash !== '' ? $hash : self::dummyHash());
}
// -----------------------------------------------------------------
// Sitzungsgeneration - beendet fremde Sitzungen eines Kontos
// -----------------------------------------------------------------
/**
* Erhoeht die Generation eines Kontos und uebernimmt sie in die eigene
* Sitzung.
*
* Damit enden alle ANDEREN Sitzungen desselben Kontos. Gebraucht wird
* das beim Passwortwechsel: Ein Passwort zu aendern, ohne die Sitzungen
* zu beenden, die mit dem alten Passwort eroeffnet wurden, laesst genau
* den drin, dessentwegen man es aendert.
*/
public static function bumpSessionEpoch(PDO $db, int $userId): void
{
try {
$db->prepare('UPDATE dc_users SET session_epoch = session_epoch + 1 WHERE id = :id')
->execute([':id' => $userId]);
$stmt = $db->prepare('SELECT session_epoch FROM dc_users WHERE id = :id LIMIT 1');
$stmt->execute([':id' => $userId]);
$neu = (int)$stmt->fetchColumn();
if (self::userId() === $userId) {
$_SESSION['dc_session_epoch'] = $neu;
}
} catch (\PDOException $e) {
if (!self::isMissingSchema($e)) {
throw $e;
}
Logger::warning('session_epoch fehlt - Migration 022 ausstehend');
}
}
/**
* Gilt die Sitzung noch, gemessen an der Generation des Kontos?
*
* Wird von isLoggedIn() bei jeder Anfrage mitgeprueft.
*/
private static function sessionEpochValid(): bool
{
$userId = (int)($_SESSION['dc_user_id'] ?? 0);
if ($userId <= 0) {
return false;
}
// Sitzungen aus der Zeit vor Migration 022 tragen den Wert nicht.
// Sie gelten weiter - ein Sicherheitsgewinn, der bestehende
// Anmeldungen wahllos abraeumt, wird beim naechsten Mal abgeschaltet.
if (!isset($_SESSION['dc_session_epoch'])) {
return true;
}
try {
$stmt = Db::init()->prepare('SELECT session_epoch FROM dc_users WHERE id = :id LIMIT 1');
$stmt->execute([':id' => $userId]);
$aktuell = $stmt->fetchColumn();
if ($aktuell === false) {
// Konto geloescht - die Sitzung hat keine Grundlage mehr.
return false;
}
return (int)$aktuell === (int)$_SESSION['dc_session_epoch'];
} catch (\PDOException $e) {
if (self::isMissingSchema($e)) {
return true;
}
// Jeder andere Datenbankfehler: nicht stillschweigend
// durchwinken. Siehe den Kommentar in secondFactorRequired().
throw $e;
}
}
/**
* Unterscheidet "Tabelle/Spalte gibt es nicht" von jedem anderen
* Datenbankfehler.
*
* Diese Unterscheidung ist der Kern der Umstellung auf fail-closed:
* Eine ausstehende Migration ist ein bekannter, harmloser Zustand.
* Ein Verbindungsabbruch, ein Lock-Timeout, "too many connections" oder
* entzogene Tabellenrechte sind es nicht - und alle vier sahen vorher
* genauso aus.
*/
private static function isMissingSchema(\PDOException $e): bool
{
// 42S02 = Tabelle unbekannt, 42S22 = Spalte unbekannt.
$sqlState = (string)$e->getCode();
return $sqlState === '42S02' || $sqlState === '42S22';
}
}
+501
View File
@@ -0,0 +1,501 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core\Webauthn;
/**
* Prueft die Attestation eines Authenticators - also die Frage, ob ein
* Schluessel wirklich in einem Stueck Hardware entstanden ist.
*
* WARUM ES DIESE KLASSE GIBT
*
* Die erste Fassung des Hardwarekeys (2.9.0) hat die Attestation bewusst
* nicht geprueft; der Kommentar in WebauthnServer begruendete das damit, dass
* hier der Betreiber seinen eigenen Schluessel an seinem eigenen Server
* registriert. Fuer das Deploymentcenter stimmt das weiterhin. Im Betrieb kam
* aber heraus, was diese Entscheidung sonst noch bedeutet: Ein Passwort-
* verwalter (1Password) liess sich genauso anstandslos registrieren wie ein
* Yubikey. Fuer ein Sicherheitsprotokoll, das "nur Hardware" verlangt, ist
* das der Unterschied zwischen Regel und Wunsch.
*
* WAS NICHT GEREICHT HAETTE
*
* In den Registrierungsvorgaben stand von Anfang an
* authenticatorAttachment = "cross-platform". Das sieht aus wie eine
* Beschraenkung auf angesteckte Hardware, ist aber keine: Die Angabe steuert,
* was der Browser dem Benutzer anbietet, und ein Passwortverwalter meldet
* sich ebenfalls als "cross-platform" an. Sie ist ein Hinweis an die
* Oberflaeche, keine Aussage, die der Server nachpruefen koennte.
*
* Ebenso wenig traegt response.authenticatorAttachment aus dem Browser: Das
* ist ein Feld, das der Client setzt, und der Client ist genau die Instanz,
* gegen die wir hier nichts beweisen koennen.
*
* WAS TRAEGT
*
* Basic Attestation. Der Authenticator unterschreibt bei der Registrierung
* mit einem Schluessel, der ab Werk in seinem Sicherheitselement liegt und
* ihn nicht verlaesst. Das zugehoerige Zertifikat kettet auf eine Wurzel des
* Herstellers. Wer diese Kette faelschen will, braucht den privaten
* Wurzelschluessel von Yubico - keine Software kann sie herleiten.
*
* Und deshalb reicht die Kette allein nicht, und x5c allein erst recht nicht:
* Ein beliebiger Authenticator kann sich selbst ein Zertifikat ausstellen und
* damit sauber unterschreiben. Ohne die Pruefung gegen eine Wurzel, der wir
* vorher vertraut haben, beweist ein x5c nichts. Genau das ist der Grund,
* warum diese Klasse eine Datei mit Wurzelzertifikaten braucht und nicht mit
* "ist ein Zertifikat dabei?" auskommt.
*
* WO DIE GRENZE LIEGT
*
* Geprueft wird bei der REGISTRIERUNG. Bei einer spaeteren Anmeldung liefert
* der Authenticator keine Attestation mehr - der Standard sieht das nicht
* vor. Die Aussage lautet deshalb: "Dieser Schluessel ist nachweislich in
* Hardware entstanden", und sie traegt danach, weil der private Teil diese
* Hardware nicht verlassen kann. Wer das Ergebnis spaeter verwenden will,
* liest die gespeicherte Spalte attested - nicht ein neues Zertifikat.
*/
final class Attestation
{
/** OID der FIDO-Erweiterung, die die AAGUID im Zertifikat wiederholt. */
private const OID_AAGUID = '1.3.6.1.4.1.45724.1.1.4';
/** Mehr Zwischenzertifikate hat keine reale Kette. Schutz vor Schleifen. */
private const MAX_CHAIN_DEPTH = 6;
/**
* Das Ergebnis einer Attestationspruefung.
*
* "attested" ist die einzige Angabe, an der eine Entscheidung haengen
* darf. Alles andere ist Anzeige und Protokoll.
*
* @return array{attested:bool,format:string,aaguid:?string,subject:?string,root:?string,reason:?string}
*/
public static function inspect(
array $attestationObject,
string $authDataRaw,
string $clientDataJson,
?string $aaguidFromAuthData,
array $trustedRoots
): array {
$format = is_string($attestationObject['fmt'] ?? null) ? $attestationObject['fmt'] : 'unbekannt';
$stmt = is_array($attestationObject['attStmt'] ?? null) ? $attestationObject['attStmt'] : [];
$result = [
'attested' => false,
'format' => $format,
'aaguid' => $aaguidFromAuthData,
'subject' => null,
'root' => null,
'reason' => null,
];
// "none" ist kein Fehler, sondern eine Auskunft: Der Authenticator hat
// nichts ueber sich behauptet. Passwortverwalter und die in Betriebs-
// systeme eingebauten Passkeys landen hier - sie haben kein
// Herstellerzertifikat, weil es kein Geraet gibt, das sie ausweisen
// koennte.
if ($format === 'none' || $stmt === []) {
$result['reason'] = 'Der Authenticator hat keine Attestation mitgeschickt.';
return $result;
}
$chain = $stmt['x5c'] ?? null;
if (!is_array($chain) || $chain === [] || !is_string($chain[0])) {
// "self attestation": mit dem eigenen Schluessel unterschrieben.
// Beweist, dass der Schluessel zu sich selbst passt - und sonst
// nichts. Insbesondere nichts ueber Hardware.
$result['reason'] = 'Die Attestation ist selbst unterschrieben und nennt keinen Hersteller.';
return $result;
}
try {
$leafPem = self::derToPem((string)$chain[0]);
$parsed = openssl_x509_parse($leafPem);
if (!is_array($parsed)) {
throw new \RuntimeException('Das Attestationszertifikat ist unlesbar.');
}
$result['subject'] = self::describe($parsed);
self::checkValidity($parsed);
// Die Signatur selbst. Unterschrieben wird ueber die
// Authenticator-Daten und den Hash der Clientdaten - bei "packed"
// aneinandergehaengt, bei "fido-u2f" in einer eigenen Form.
$signature = is_string($stmt['sig'] ?? null) ? $stmt['sig'] : '';
if ($signature === '') {
throw new \RuntimeException('Die Attestation enthaelt keine Signatur.');
}
$clientDataHash = hash('sha256', $clientDataJson, true);
if ($format === 'packed') {
self::verifyPacked($leafPem, $signature, $authDataRaw . $clientDataHash, $stmt);
self::checkAaguidExtension($parsed, $aaguidFromAuthData);
} elseif ($format === 'fido-u2f') {
self::verifyU2f($leafPem, $signature, $authDataRaw, $clientDataHash);
} else {
// "tpm", "android-key", "apple" und die uebrigen Formate sind
// nicht umgesetzt. Sie abzulehnen ist ehrlicher, als sie
// durchzuwinken: Ein nicht geprueftes Format als "geprueft" zu
// fuehren waere genau der Fehler, den diese Klasse behebt.
throw new \RuntimeException(sprintf(
'Das Attestationsformat "%s" wird hier nicht geprueft.',
$format
));
}
$root = self::chainVerifies($chain, $trustedRoots);
if ($root === null) {
throw new \RuntimeException(
'Die Zertifikatskette endet nicht bei einer hinterlegten Herstellerwurzel.'
);
}
$result['attested'] = true;
$result['root'] = $root;
return $result;
} catch (\Throwable $e) {
$result['reason'] = $e->getMessage();
return $result;
}
}
// -----------------------------------------------------------------
// Die beiden geprueften Formate
// -----------------------------------------------------------------
/**
* "packed" - das Format, das ein YubiKey 5 ueber CTAP2 liefert.
*/
private static function verifyPacked(string $leafPem, string $signature, string $signed, array $stmt): void
{
$alg = isset($stmt['alg']) ? (int)$stmt['alg'] : CoseKey::ES256;
$key = openssl_pkey_get_public($leafPem);
if ($key === false) {
throw new \RuntimeException('Der oeffentliche Schluessel des Zertifikats ist unlesbar.');
}
if (openssl_verify($signed, $signature, $key, CoseKey::digestFor($alg)) !== 1) {
throw new \RuntimeException('Die Attestationssignatur stimmt nicht.');
}
}
/**
* "fido-u2f" - der aeltere Weg, den ein Schluessel ueber CTAP1 geht.
*
* Hier wird nicht ueber authData unterschrieben, sondern ueber eine eigens
* zusammengesetzte Folge. Das ist kein Schoenheitsfehler des Standards,
* sondern Erbe: Das Format stammt aus der Zeit vor WebAuthn, und die
* Signatur musste zu dem passen, was U2F-Geraete schon unterschrieben.
*
* 0x00 | rpIdHash | clientDataHash | credentialId | 0x04 | x | y
*/
private static function verifyU2f(
string $leafPem,
string $signature,
string $authDataRaw,
string $clientDataHash
): void {
if (strlen($authDataRaw) < 55) {
throw new \RuntimeException('Die Authenticator-Daten sind fuer fido-u2f zu kurz.');
}
$rpIdHash = substr($authDataRaw, 0, 32);
$lengthBytes = unpack('nlen', substr($authDataRaw, 53, 2));
$credentialLength = is_array($lengthBytes) ? (int)$lengthBytes['len'] : 0;
if ($credentialLength < 1 || strlen($authDataRaw) < 55 + $credentialLength) {
throw new \RuntimeException('Die Laengenangabe der Schluesselkennung ist unbrauchbar.');
}
$credentialId = substr($authDataRaw, 55, $credentialLength);
$offset = 55 + $credentialLength;
$cose = Cbor::decode($authDataRaw, $offset);
if (!is_array($cose) || !is_string($cose[-2] ?? null) || !is_string($cose[-3] ?? null)) {
throw new \RuntimeException('fido-u2f erwartet einen EC-Schluessel mit x und y.');
}
$x = str_pad($cose[-2], 32, "\x00", STR_PAD_LEFT);
$y = str_pad($cose[-3], 32, "\x00", STR_PAD_LEFT);
$signed = "\x00" . $rpIdHash . $clientDataHash . $credentialId . "\x04" . $x . $y;
$key = openssl_pkey_get_public($leafPem);
if ($key === false) {
throw new \RuntimeException('Der oeffentliche Schluessel des Zertifikats ist unlesbar.');
}
// U2F kennt nur ES256; ein anderes Verfahren gibt es in diesem Format
// nicht, deshalb steht der Digest hier fest.
if (openssl_verify($signed, $signature, $key, OPENSSL_ALGO_SHA256) !== 1) {
throw new \RuntimeException('Die U2F-Attestationssignatur stimmt nicht.');
}
}
// -----------------------------------------------------------------
// Die Kette
// -----------------------------------------------------------------
/**
* Laeuft vom Blatt ueber die mitgelieferten Zwischenzertifikate zu einer
* der hinterlegten Wurzeln.
*
* Bewusst von Hand und nicht ueber openssl_x509_checkpurpose(): Dessen
* Wurzelspeicher ist der des Betriebssystems, und dort stehen die
* Zertifizierungsstellen des Web-PKI - also genau die falsche Menge.
* Eine Attestationskette soll bei Yubico enden und nirgends sonst.
*
* @param list<mixed> $derChain x5c aus dem Attestationsobjekt
* @param list<string> $trustedRoots PEM der akzeptierten Wurzeln
* @return string|null Name der erreichten Wurzel, oder null
*/
private static function chainVerifies(array $derChain, array $trustedRoots): ?string
{
if ($trustedRoots === []) {
throw new \RuntimeException(
'Es sind keine Herstellerwurzeln hinterlegt - eine Kette laesst sich nicht pruefen.'
);
}
$intermediates = [];
foreach (array_slice($derChain, 1) as $der) {
if (is_string($der)) {
$intermediates[] = self::derToPem($der);
}
}
$current = self::derToPem((string)$derChain[0]);
for ($step = 0; $step < self::MAX_CHAIN_DEPTH; $step++) {
// Zuerst die Wurzeln: Endet die Kette hier, sind wir fertig. Das
// Blatt kann direkt von der Wurzel unterschrieben sein - bei
// Yubico ist genau das der Normalfall.
foreach ($trustedRoots as $rootPem) {
if (self::signedBy($current, $rootPem)) {
$parsed = openssl_x509_parse($rootPem);
return is_array($parsed) ? self::describe($parsed) : 'unbenannte Wurzel';
}
}
$next = null;
foreach ($intermediates as $index => $candidate) {
if (self::signedBy($current, $candidate)) {
$next = $candidate;
unset($intermediates[$index]);
break;
}
}
if ($next === null) {
return null;
}
$parsedNext = openssl_x509_parse($next);
if (is_array($parsedNext)) {
self::checkValidity($parsedNext);
}
$current = $next;
}
return null;
}
private static function signedBy(string $certPem, string $issuerPem): bool
{
$issuerKey = openssl_pkey_get_public($issuerPem);
if ($issuerKey === false) {
return false;
}
return openssl_x509_verify($certPem, $issuerKey) === 1;
}
// -----------------------------------------------------------------
// Pruefungen am einzelnen Zertifikat
// -----------------------------------------------------------------
/**
* Ein abgelaufenes Zertifikat wird abgelehnt - mit eigener Meldung.
*
* Die eigene Meldung ist der Punkt: "abgelaufen" und "falsche Wurzel"
* fuehren beide zur Ablehnung, verlangen aber voellig verschiedene
* Reaktionen. Eine gemeinsame Meldung waere bei der Fehlersuche wertlos.
*/
private static function checkValidity(array $parsed): void
{
$now = time();
$from = isset($parsed['validFrom_time_t']) ? (int)$parsed['validFrom_time_t'] : 0;
$to = isset($parsed['validTo_time_t']) ? (int)$parsed['validTo_time_t'] : 0;
if ($from > 0 && $now < $from) {
throw new \RuntimeException(sprintf(
'Das Attestationszertifikat gilt erst ab %s.',
gmdate('Y-m-d', $from)
));
}
if ($to > 0 && $now > $to) {
throw new \RuntimeException(sprintf(
'Das Attestationszertifikat ist am %s abgelaufen.',
gmdate('Y-m-d', $to)
));
}
}
/**
* Die AAGUID im Zertifikat muss zu der in den Authenticator-Daten passen.
*
* Ohne diese Pruefung waere eine Wurzelpruefung zwar bestanden, die
* Modellangabe aber frei erfunden: Ein Geraet koennte mit seinem echten
* Zertifikat unterschreiben und daneben die AAGUID eines anderen Modells
* behaupten. Wer spaeter nach AAGUID filtert, filterte dann nach einer
* Behauptung statt nach einer Eigenschaft.
*
* Die Erweiterung ist optional - fehlt sie, wird nichts behauptet und
* also auch nichts widerlegt.
*/
private static function checkAaguidExtension(array $parsed, ?string $expected): void
{
$extensions = is_array($parsed['extensions'] ?? null) ? $parsed['extensions'] : [];
$raw = null;
foreach ($extensions as $name => $value) {
if ((string)$name === self::OID_AAGUID && is_string($value)) {
$raw = $value;
break;
}
}
if ($raw === null || $expected === null) {
return;
}
// Die Erweiterung ist ein OCTET STRING in einem OCTET STRING. Statt
// den Rahmen zu zerlegen, wird darin nach den 16 Byte gesucht - das
// Ergebnis ist dasselbe und haengt nicht davon ab, wie viele Huellen
// eine Fassung des Standards vorsieht.
if (strpos($raw, $expected) === false) {
throw new \RuntimeException(
'Die Geraetekennung im Zertifikat weicht von der in den Authenticator-Daten ab.'
);
}
}
// -----------------------------------------------------------------
// Wurzeln laden
// -----------------------------------------------------------------
/**
* Liest die Wurzeln aus den mitgelieferten und den zusaetzlich
* konfigurierten Dateien.
*
* @param list<string> $paths
* @return list<string> PEM je Zertifikat
*/
public static function loadRoots(array $paths): array
{
$roots = [];
foreach ($paths as $path) {
if (!is_file($path) || !is_readable($path)) {
continue;
}
$content = (string)file_get_contents($path);
if (preg_match_all(
'/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/s',
$content,
$matches
)) {
foreach ($matches[0] as $pem) {
$roots[] = $pem;
}
}
}
return $roots;
}
/** Die mitgelieferte Wurzeldatei. */
public static function bundledRootFile(): string
{
return __DIR__ . '/roots/yubico.pem';
}
// -----------------------------------------------------------------
// Kleinkram
// -----------------------------------------------------------------
private static function derToPem(string $der): string
{
if ($der === '') {
throw new \RuntimeException('Ein Zertifikat der Kette ist leer.');
}
return "-----BEGIN CERTIFICATE-----\n"
. chunk_split(base64_encode($der), 64, "\n")
. "-----END CERTIFICATE-----\n";
}
/** Ein lesbarer Name fuer die Anzeige - der CN, sonst der ganze Betreff. */
private static function describe(array $parsed): string
{
$subject = is_array($parsed['subject'] ?? null) ? $parsed['subject'] : [];
if (is_string($subject['CN'] ?? null) && $subject['CN'] !== '') {
return mb_substr($subject['CN'], 0, 190);
}
if (is_string($parsed['name'] ?? null)) {
return mb_substr($parsed['name'], 0, 190);
}
return 'ohne Namen';
}
/** Die AAGUID als lesbare Kennung, so wie Hersteller sie angeben. */
public static function formatAaguid(?string $raw): ?string
{
if ($raw === null || strlen($raw) !== 16) {
return null;
}
$hex = bin2hex($raw);
return sprintf(
'%s-%s-%s-%s-%s',
substr($hex, 0, 8),
substr($hex, 8, 4),
substr($hex, 12, 4),
substr($hex, 16, 4),
substr($hex, 20, 12)
);
}
/** Die Umkehrung - aus der lesbaren Kennung die 16 Byte. */
public static function parseAaguid(string $formatted): ?string
{
$hex = preg_replace('/[^0-9a-f]/i', '', $formatted);
if (!is_string($hex) || strlen($hex) !== 32) {
return null;
}
$raw = hex2bin(strtolower($hex));
return $raw === false ? null : $raw;
}
}
+151 -14
View File
@@ -22,6 +22,22 @@ final class CredentialRepo
/** Gruppen zu je vier Zeichen, damit man ihn vorlesen kann. */
private const TOKEN_GROUPS = 5;
/**
* Hoechstalter eines Notfalltokens in Sekunden (180 Tage).
*
* Ein Notfalltoken liegt ausgedruckt in einem Schrank oder in einem
* Passwortverwalter. Beides altert: Der Schrank wechselt den Besitzer,
* der Zettel wird abfotografiert, der Verwalter wird geteilt. Ein
* Ersatzweg fuer den zweiten Faktor, der zeitlich unbegrenzt gilt, ist
* auf Dauer der schwaechste Punkt des Verfahrens - und zwar unbemerkt,
* weil niemand ihn benutzt und deshalb niemand nachsieht.
*
* 180 Tage sind lang genug, dass der Token seinen Zweck erfuellt (ein
* verlorener Schluessel faellt binnen Tagen auf, nicht binnen Monaten),
* und kurz genug, dass halbjaehrlich jemand hinsieht.
*/
public const RECOVERY_MAX_AGE = 180 * 86400;
public function __construct(private PDO $db)
{
}
@@ -37,9 +53,13 @@ final class CredentialRepo
*/
public function forUser(int $userId): array
{
// SELECT * und nicht die Spalten einzeln: Vor Migration 021 gibt es
// die Attestationsspalten noch nicht, und eine Abfrage, die sie
// namentlich verlangt, bricht dann ab. Die Oberflaeche meldete
// daraufhin "Migration 019 fehlt" - eine falsche Diagnose, die beim
// Suchen mehr kostet als die paar ungenutzten Spalten hier.
$stmt = $this->db->prepare('
SELECT id, credential_id, label, sign_count, transports, created_at, last_used_at
FROM dc_user_credentials
SELECT * FROM dc_user_credentials
WHERE user_id = :u
ORDER BY created_at ASC
');
@@ -48,6 +68,18 @@ final class CredentialRepo
$out = [];
foreach ($stmt->fetchAll() as $row) {
$row['credential_b64'] = WebauthnServer::b64uEncode((string)$row['credential_id']);
// Die Geraetekennung wird hier lesbar gemacht und nicht erst in
// der Ausgabe: Sie soll sich aus der Oberflaeche in die
// Modellliste kopieren lassen, und dafuer muss sie ueberall
// gleich geschrieben sein.
$row['aaguid_text'] = Attestation::formatAaguid(
($row['aaguid'] ?? null) === null ? null : (string)$row['aaguid']
);
$row['attested'] = (int)($row['attested'] ?? 0) === 1;
$row['attestation_fmt'] = $row['attestation_fmt'] ?? null;
$row['attestation_ca'] = $row['attestation_ca'] ?? null;
$row['attestation_root'] = $row['attestation_root'] ?? null;
unset($row['aaguid'], $row['public_key']);
// Die rohe Kennung hat in der Oberflaeche nichts zu suchen - sie
// ist binaer und wuerde jede HTML-Ausgabe verunstalten.
unset($row['credential_id']);
@@ -60,11 +92,23 @@ final class CredentialRepo
/**
* Die rohen Kennungen eines Kontos, fuer allowCredentials.
*
* $attestedOnly ist der Hebel, mit dem die Stufe "strict" wirkt - und sie
* wirkt hier und nicht erst beim Pruefen der Signatur. Der Unterschied ist
* nicht bloss technisch: Ein Schluessel, der gar nicht erst in
* allowCredentials steht, wird vom Browser nicht angeboten. Der Benutzer
* sieht "dieser Schluessel passt hier nicht" statt erst nach dem
* Beruehren eine Ablehnung zu kassieren.
*
* @return list<string>
*/
public function rawIdsForUser(int $userId): array
public function rawIdsForUser(int $userId, bool $attestedOnly = false): array
{
$stmt = $this->db->prepare('SELECT credential_id FROM dc_user_credentials WHERE user_id = :u');
$sql = 'SELECT credential_id FROM dc_user_credentials WHERE user_id = :u';
if ($attestedOnly) {
$sql .= ' AND attested = 1';
}
$stmt = $this->db->prepare($sql);
$stmt->execute([':u' => $userId]);
return array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN));
@@ -78,6 +122,17 @@ final class CredentialRepo
return (int)$stmt->fetchColumn();
}
/** Wie viele davon sich als Hardware ausgewiesen haben. */
public function countAttestedForUser(int $userId): int
{
$stmt = $this->db->prepare(
'SELECT COUNT(*) FROM dc_user_credentials WHERE user_id = :u AND attested = 1'
);
$stmt->execute([':u' => $userId]);
return (int)$stmt->fetchColumn();
}
/**
* Sucht einen Schluessel - und zwar am Konto, nicht nur an der Kennung.
*
@@ -112,13 +167,18 @@ final class CredentialRepo
int $signCount,
?string $aaguid,
string $label,
?string $transports
?string $transports,
array $attestation = []
): void {
$attested = ($attestation['attested'] ?? false) === true;
$stmt = $this->db->prepare('
INSERT INTO dc_user_credentials
(user_id, credential_id, public_key, alg, sign_count, aaguid, transports, label, created_at)
(user_id, credential_id, public_key, alg, sign_count, aaguid, attested,
attestation_fmt, attestation_ca, attestation_root, transports, label, created_at)
VALUES
(:u, :c, :pk, :alg, :sc, :aaguid, :transports, :label, UTC_TIMESTAMP())
(:u, :c, :pk, :alg, :sc, :aaguid, :attested,
:fmt, :ca, :root, :transports, :label, UTC_TIMESTAMP())
');
$stmt->bindValue(':u', $userId, PDO::PARAM_INT);
$stmt->bindValue(':c', $credentialId, PDO::PARAM_LOB);
@@ -126,11 +186,28 @@ final class CredentialRepo
$stmt->bindValue(':alg', $alg, PDO::PARAM_INT);
$stmt->bindValue(':sc', $signCount, PDO::PARAM_INT);
$stmt->bindValue(':aaguid', $aaguid, $aaguid === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':attested', $attested ? 1 : 0, PDO::PARAM_INT);
// Die drei Textspalten stammen aus einem Zertifikat, das von aussen
// kam. Sie werden deshalb gekuerzt gespeichert: Die Spaltenbreite ist
// die Grenze, und was darueber hinausgeht, gehoert nicht in die
// Datenbank, sondern hat dort nie etwas verloren gehabt.
foreach ([':fmt' => 'format', ':ca' => 'subject', ':root' => 'root'] as $bind => $field) {
$value = $attestation[$field] ?? null;
$value = is_string($value) && $value !== '' ? mb_substr($value, 0, 180) : null;
$stmt->bindValue($bind, $value, $value === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
}
$stmt->bindValue(':transports', $transports, $transports === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
$stmt->bindValue(':label', $label);
$stmt->execute();
Logger::info('Hardwarekey registriert', ['user_id' => $userId, 'label' => $label]);
Logger::info('Hardwarekey registriert', [
'user_id' => $userId,
'label' => $label,
'attested' => $attested,
'root' => $attestation['root'] ?? null,
]);
}
public function touch(int $credentialRowId, int $signCount): void
@@ -204,9 +281,13 @@ final class CredentialRepo
*/
public function consumeRecoveryToken(int $userId, string $presented): bool
{
$stmt = $this->db->prepare('SELECT recovery_hash FROM dc_users WHERE id = :id LIMIT 1');
$stmt = $this->db->prepare(
'SELECT recovery_hash, recovery_created_at FROM dc_users WHERE id = :id LIMIT 1'
);
$stmt->execute([':id' => $userId]);
$hash = $stmt->fetchColumn();
$row = $stmt->fetch();
$hash = is_array($row) ? ($row['recovery_hash'] ?? null) : null;
if (!is_string($hash) || $hash === '') {
// Trotzdem einmal rechnen: Ob ein Notfalltoken hinterlegt ist,
@@ -219,6 +300,37 @@ final class CredentialRepo
return false;
}
// Alter pruefen - NACH dem Vergleich, damit die Antwortzeit nicht
// verraet, ob ein Token existiert.
//
// recovery_created_at wurde seit 2.9.0 geschrieben und nie
// ausgewertet. Ein vor zwei Jahren auf einen Zettel geschriebener
// Token war damit weiterhin ein vollwertiger Ersatz fuer den
// Hardwarekey - also ein zweiter Faktor, der nicht altert, waehrend
// der Zettel es tut.
$erzeugt = is_array($row) ? ($row['recovery_created_at'] ?? null) : null;
if (is_string($erzeugt) && $erzeugt !== '') {
$ts = strtotime($erzeugt . ' UTC');
if ($ts !== false && (time() - $ts) > self::RECOVERY_MAX_AGE) {
Logger::warning('Abgelaufener Notfalltoken vorgezeigt', [
'user_id' => $userId,
'alter_tage' => (int)floor((time() - $ts) / 86400),
]);
// Der abgelaufene Token wird bei dieser Gelegenheit
// ausgetragen. Ihn stehen zu lassen hiesse, dass er beim
// naechsten Versuch wieder dieselbe Pruefung durchlaeuft -
// und in der Oberflaeche weiterhin als "hinterlegt" steht,
// obwohl er nichts mehr oeffnet.
$this->db->prepare('UPDATE dc_users SET recovery_hash = NULL WHERE id = :id')
->execute([':id' => $userId]);
return false;
}
}
$clear = $this->db->prepare('
UPDATE dc_users
SET recovery_hash = NULL, recovery_used_at = UTC_TIMESTAMP()
@@ -234,7 +346,7 @@ final class CredentialRepo
/**
* Zustand des Notfalltokens fuer die Anzeige.
*
* @return array{present:bool,created_at:?string,used_at:?string}
* @return array{present:bool,created_at:?string,used_at:?string,age_days:?int,expired:bool}
*/
public function recoveryState(int $userId): array
{
@@ -245,14 +357,39 @@ final class CredentialRepo
$stmt->execute([':id' => $userId]);
$row = $stmt->fetch();
$leer = [
'present' => false,
'created_at' => null,
'used_at' => null,
'age_days' => null,
'expired' => false,
];
if (!is_array($row)) {
return ['present' => false, 'created_at' => null, 'used_at' => null];
return $leer;
}
$present = is_string($row['recovery_hash'] ?? null) && $row['recovery_hash'] !== '';
$erzeugt = $row['recovery_created_at'] ?? null;
// Das Alter gehoert in die Anzeige, nicht nur in die Pruefung. Ein
// Token, der in drei Wochen verfaellt, soll das sagen, solange man
// noch in Ruhe einen neuen erzeugen kann - nicht erst in dem Moment,
// in dem man ihn braucht.
$alter = null;
if (is_string($erzeugt) && $erzeugt !== '') {
$ts = strtotime($erzeugt . ' UTC');
if ($ts !== false) {
$alter = (int)floor((time() - $ts) / 86400);
}
}
return [
'present' => is_string($row['recovery_hash'] ?? null) && $row['recovery_hash'] !== '',
'created_at' => $row['recovery_created_at'] ?? null,
'present' => $present,
'created_at' => $erzeugt,
'used_at' => $row['recovery_used_at'] ?? null,
'age_days' => $alter,
'expired' => $present && $alter !== null && $alter > (self::RECOVERY_MAX_AGE / 86400),
];
}
+312
View File
@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core\Webauthn;
use Deploymentcenter\Core\Config;
use PDO;
/**
* Die Regel, welche Authenticatoren ueberhaupt zugelassen sind.
*
* DREI STUFEN, UND WARUM NICHT ZWEI
*
* Ein einzelner Schalter "nur Hardware" waere die naheliegende Loesung und
* haette eine Falle: Alle bereits registrierten Schluessel sind ohne
* Attestation aufgenommen worden - auch ein echter Yubikey. Ein Schalter, der
* sofort auch die Anmeldung einschraenkt, sperrt in dem Moment, in dem man ihn
* umlegt, den eigenen Zugang aus. Zurueck ginge es nur ueber den
* Notfalltoken.
*
* Deshalb drei Stufen, und die mittlere ist die wichtige:
*
* off Wie vor 2.9.2. Es wird keine Attestation angefordert und
* nichts geprueft. Jeder Passkey darf sich registrieren.
*
* register Neue Registrierungen muessen eine gepruefte Hardware-
* Attestation vorlegen. Bereits vorhandene Schluessel melden
* sich weiter an. Das ist der Zustand, in dem man den Yubikey
* neu aufnimmt, ohne sich auszusperren.
*
* strict Zusaetzlich duerfen sich nur noch geprueft in Hardware
* entstandene Schluessel anmelden. Erst diese Stufe erfuellt ein
* Protokoll, das "nur Hardware" verlangt.
*
* Der Uebergang nach "strict" ist bewusst bewacht (siehe blockingReason()):
* Wer keinen attestierten Schluessel hat, kann die Stufe nicht einschalten.
* Eine Einstellung, die sich selbst aussperrt, ist keine Einstellung, sondern
* eine Falle.
*
* WAS DIE AAGUID-LISTE SOLL - UND WAS NICHT
*
* Die Wurzelpruefung beantwortet "stammt das von Yubico". Die AAGUID-Liste
* verengt auf einzelne Modelle. Sie ist optional und leer voreingestellt,
* denn sie hat einen Unterhaltspreis: Yubico weist neuen Firmwarestaenden
* neue AAGUIDs zu, und eine gepflegte Liste ist damit eine Liste, die
* irgendwann einen echten Schluessel abweist. Wer sie fuellt, sollte wissen,
* warum ihm das Modell und nicht der Hersteller wichtig ist.
*/
final class HardwarePolicy
{
public const OFF = 'off';
public const REGISTER = 'register';
public const STRICT = 'strict';
public const LEVELS = [self::OFF, self::REGISTER, self::STRICT];
/** Schluessel in dc_settings. */
private const KEY_LEVEL = 'webauthn_hardware_policy';
private const KEY_ALLOWLIST = 'webauthn_aaguid_allowlist';
public function __construct(private PDO $db)
{
}
// -----------------------------------------------------------------
// Zustand
// -----------------------------------------------------------------
public function level(): string
{
$stored = $this->setting(self::KEY_LEVEL);
return in_array($stored, self::LEVELS, true) ? $stored : self::OFF;
}
public function isOff(): bool
{
return $this->level() === self::OFF;
}
/** Gilt die Regel auch fuer die Anmeldung, nicht nur fuer die Aufnahme? */
public function appliesToLogin(): bool
{
return $this->level() === self::STRICT;
}
/**
* Was in den Registrierungsvorgaben als attestation steht.
*
* "direct" laesst den Browser das Herstellerzertifikat durchreichen.
* Manche Browser fragen den Benutzer dabei einmal, ob die Seite Marke und
* Modell des Schluessels erfahren darf - das ist der sichtbare Preis
* dieser Funktion und kein Fehler.
*/
public function conveyance(): string
{
return $this->isOff() ? 'none' : 'direct';
}
/**
* Die zugelassenen Geraetekennungen, roh zu je 16 Byte.
*
* @return list<string>
*/
public function allowedAaguids(): array
{
$raw = $this->setting(self::KEY_ALLOWLIST) ?? '';
$out = [];
foreach (preg_split('/[\s,;]+/', $raw) ?: [] as $entry) {
if (trim($entry) === '') {
continue;
}
$parsed = Attestation::parseAaguid($entry);
if ($parsed !== null) {
$out[] = $parsed;
}
}
return $out;
}
/**
* Die Wurzeln, gegen die geprueft wird.
*
* Mitgeliefert wird Yubico. Wer andere Hardware zulassen will, nennt in
* der Konfiguration weitere Dateien - security.webauthn_root_files. Das
* steht bewusst in der Konfiguration und nicht in der Datenbank: Eine
* Vertrauenswurzel ueber die Weboberflaeche austauschbar zu machen hiesse,
* die Pruefung ueber dieselbe Oberflaeche aushebelbar zu machen, die sie
* schuetzen soll.
*
* @return list<string>
*/
public function roots(): array
{
$paths = [Attestation::bundledRootFile()];
$extra = Config::get('security.webauthn_root_files', []);
if (is_string($extra) && $extra !== '') {
$extra = [$extra];
}
if (is_array($extra)) {
foreach ($extra as $path) {
if (is_string($path) && $path !== '') {
$paths[] = $path;
}
}
}
return Attestation::loadRoots($paths);
}
// -----------------------------------------------------------------
// Entscheiden
// -----------------------------------------------------------------
/**
* Darf ein Schluessel mit diesem Pruefergebnis aufgenommen werden?
*
* @param array{attested:bool,format:string,aaguid:?string,subject:?string,root:?string,reason:?string} $inspection
* @return array{ok:bool,reason:?string}
*/
public function acceptsRegistration(array $inspection): array
{
if ($this->isOff()) {
return ['ok' => true, 'reason' => null];
}
if ($inspection['attested'] !== true) {
return [
'ok' => false,
'reason' => sprintf(
'Dieser Authenticator weist sich nicht als Hardware aus (%s). '
. 'Zugelassen sind nur Schluessel mit einer geprueften Herstellerattestation.',
$inspection['reason'] ?? 'kein Grund angegeben'
),
];
}
$allowed = $this->allowedAaguids();
if ($allowed === []) {
return ['ok' => true, 'reason' => null];
}
$aaguid = $inspection['aaguid'];
if ($aaguid === null) {
return [
'ok' => false,
'reason' => 'Der Authenticator nennt keine Geraetekennung, '
. 'es ist aber eine Modellliste hinterlegt.',
];
}
foreach ($allowed as $candidate) {
if (hash_equals($candidate, $aaguid)) {
return ['ok' => true, 'reason' => null];
}
}
return [
'ok' => false,
'reason' => sprintf(
'Das Modell %s steht nicht auf der Liste der zugelassenen Geraete.',
Attestation::formatAaguid($aaguid) ?? 'unbekannt'
),
];
}
// -----------------------------------------------------------------
// Umschalten
// -----------------------------------------------------------------
/**
* Warum die gewuenschte Stufe gerade nicht eingeschaltet werden kann.
*
* Nur "strict" ist bewacht, und nur gegen den einen Fall, der wirklich
* aussperrt: kein einziger attestierter Schluessel am eigenen Konto. Dass
* andere Konten darunter fallen koennten, ist Absicht und keine
* Nachlaessigkeit - wer die Regel setzt, setzt sie fuer die Anlage, und
* ein Konto ohne passenden Schluessel hat weiterhin den Notfalltoken.
*/
public function blockingReason(string $desiredLevel, int $actingUserId): ?string
{
if ($desiredLevel !== self::STRICT) {
return null;
}
$stmt = $this->db->prepare('
SELECT COUNT(*) FROM dc_user_credentials
WHERE user_id = :u AND attested = 1
');
$stmt->execute([':u' => $actingUserId]);
if ((int)$stmt->fetchColumn() > 0) {
return null;
}
return 'Fuer das eigene Konto ist kein geprueft in Hardware entstandener Schluessel '
. 'hinterlegt. Die Stufe "strict" wuerde die eigene Anmeldung sperren. '
. 'Zuerst auf "register" stellen und den Hardwarekey neu aufnehmen.';
}
public function setLevel(string $level): void
{
if (!in_array($level, self::LEVELS, true)) {
throw new \InvalidArgumentException(sprintf('Unbekannte Stufe "%s".', $level));
}
$this->store(self::KEY_LEVEL, $level);
}
/** Nimmt die Liste so entgegen, wie sie im Formular steht. */
public function setAllowlist(string $raw): void
{
$clean = [];
foreach (preg_split('/[\s,;]+/', $raw) ?: [] as $entry) {
if (trim($entry) === '') {
continue;
}
$parsed = Attestation::parseAaguid($entry);
if ($parsed === null) {
throw new \InvalidArgumentException(sprintf(
'"%s" ist keine Geraetekennung. Erwartet werden 32 Hexziffern, '
. 'ueblicherweise als 8-4-4-4-12 geschrieben.',
mb_substr(trim($entry), 0, 60)
));
}
$formatted = Attestation::formatAaguid($parsed);
if ($formatted !== null && !in_array($formatted, $clean, true)) {
$clean[] = $formatted;
}
}
$this->store(self::KEY_ALLOWLIST, implode("\n", $clean));
}
/** Die Liste, wie sie im Formular stehen soll. */
public function allowlistText(): string
{
return $this->setting(self::KEY_ALLOWLIST) ?? '';
}
// -----------------------------------------------------------------
// dc_settings
// -----------------------------------------------------------------
private function setting(string $key): ?string
{
$stmt = $this->db->prepare('SELECT svalue FROM dc_settings WHERE skey = :k LIMIT 1');
$stmt->execute([':k' => $key]);
$value = $stmt->fetchColumn();
return is_string($value) ? $value : null;
}
private function store(string $key, string $value): void
{
$stmt = $this->db->prepare('
INSERT INTO dc_settings (skey, svalue) VALUES (:k, :v)
ON DUPLICATE KEY UPDATE svalue = VALUES(svalue)
');
$stmt->execute([':k' => $key, ':v' => $value]);
}
}
+79 -16
View File
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Deploymentcenter\Core\Webauthn;
use Deploymentcenter\Core\Config;
use Deploymentcenter\Core\Http;
/**
* Die beiden WebAuthn-Zeremonien: Registrieren und Anmelden.
@@ -16,14 +15,17 @@ use Deploymentcenter\Core\Http;
* global (und damit fuer parallele Anmeldungen falsch) oder sie braeuchte
* eine eigene Tabelle fuer etwas, das ohnehin nur zwei Minuten lebt.
*
* Zur Attestation: Geprueft wird sie nicht ("none"). Attestation beantwortet
* die Frage "stammt dieser Schluessel wirklich von Hersteller X" - das ist
* die Frage eines Unternehmens, das Geraete vorschreibt. Hier registriert der
* Betreiber seinen eigenen Schluessel an seinem eigenen Server; die Frage
* stellt sich nicht, und ihre Beantwortung braeuchte eine gepflegte Liste von
* Herstellerzertifikaten. Was zaehlt - dass spaetere Anmeldungen mit
* demselben privaten Schluessel unterschrieben sind - haengt an der
* Attestation nicht.
* Zur Attestation: Ob sie geprueft wird, entscheidet seit 2.9.2 die
* HardwarePolicy, nicht mehr diese Klasse. Sie liefert hier die Vorgabe
* ("none" oder "direct") und bekommt das Pruefergebnis zurueck.
*
* Bis 2.9.1 stand an dieser Stelle die Begruendung, warum nicht geprueft
* wird: Der Betreiber registriere seinen eigenen Schluessel an seinem eigenen
* Server, und die Frage nach dem Hersteller stelle sich nicht. Das galt fuer
* das Deploymentcenter und gilt dort weiter - es uebersah nur, dass aus
* "wird nicht geprueft" in der Praxis "jeder Passwortverwalter darf" wird.
* Wer diese Unterscheidung braucht, schaltet die Regel ein; wer nicht,
* bleibt bei "off" und bekommt das Verhalten von vorher.
*/
final class WebauthnServer
{
@@ -55,15 +57,51 @@ final class WebauthnServer
return $configured;
}
$host = parse_url(Http::baseUrl(), PHP_URL_HOST);
$host = parse_url(self::configuredBaseUrl(), PHP_URL_HOST);
return is_string($host) && $host !== '' ? $host : 'localhost';
if (!is_string($host) || $host === '') {
throw new \RuntimeException(
'Die Kennung fuer WebAuthn laesst sich nicht bestimmen. '
. 'Bitte app.url oder security.webauthn_rp_id in der config.php setzen.'
);
}
return $host;
}
/** Der Ursprung, den der Browser in clientDataJSON eintragen muss. */
public static function expectedOrigin(): string
{
return rtrim(Http::baseUrl(), '/');
return rtrim(self::configuredBaseUrl(), '/');
}
/**
* Die Basisadresse - ohne den Rueckfall auf den Host-Header.
*
* Http::baseUrl() faellt auf $_SERVER['HTTP_HOST'] zurueck, wenn app.url
* leer ist. Fuer die meisten Zwecke ist das bequem und harmlos. Hier
* nicht: Aus diesem Wert entstehen rpId() und expectedOrigin(), also die
* beiden Anker, gegen die die ganze Zeremonie geprueft wird. Ein
* Angreifer, der den Host-Header setzt, bestimmte damit selbst, wogegen
* seine Anmeldung geprueft wird - und die Pruefung ginge auf.
*
* Deshalb hier lieber abbrechen als dem Header glauben. Eine
* Authentifizierung, die sich ihren eigenen Massstab vom Aufrufer geben
* laesst, ist keine.
*/
private static function configuredBaseUrl(): string
{
$configured = trim((string)Config::get('app.url', ''));
if ($configured === '') {
throw new \RuntimeException(
'app.url ist nicht gesetzt. Fuer WebAuthn ist das keine Kleinigkeit: '
. 'Ohne festen Wert wuerde der Host-Header des Aufrufers bestimmen, '
. 'gegen welche Adresse geprueft wird.'
);
}
return rtrim($configured, '/');
}
public static function challenge(): string
@@ -85,7 +123,8 @@ final class WebauthnServer
string $challenge,
int $userId,
string $username,
array $existingCredentialIds
array $existingCredentialIds,
string $attestationConveyance = 'none'
): array {
$exclude = [];
foreach ($existingCredentialIds as $id) {
@@ -114,7 +153,7 @@ final class WebauthnServer
],
'pubKeyCredParams' => $params,
'timeout' => self::CHALLENGE_TTL * 1000,
'attestation' => 'none',
'attestation' => $attestationConveyance,
'excludeCredentials' => $exclude,
'authenticatorSelection' => [
// "cross-platform": gemeint ist ein Stueck Hardware zum
@@ -122,6 +161,12 @@ final class WebauthnServer
// dem man gerade sitzt. Wer Windows Hello registriert, hat
// einen zweiten Faktor, der mit dem ersten im selben Geraet
// sitzt.
//
// Diese Angabe steuert, was der Browser ANBIETET. Sie ist
// keine Pruefung: Ein Passwortverwalter meldet sich ebenfalls
// als "cross-platform" und kommt damit durch. Wer Hardware
// erzwingen will, braucht die Attestation - siehe
// HardwarePolicy.
'authenticatorAttachment' => 'cross-platform',
'userVerification' => 'discouraged',
'residentKey' => 'discouraged',
@@ -132,12 +177,21 @@ final class WebauthnServer
/**
* Prueft die Antwort des Browsers auf create() und liefert, was gespeichert wird.
*
* @return array{credential_id:string,public_key:string,sign_count:int,aaguid:?string,alg:int}
* Die Attestation wird immer UNTERSUCHT, aber nie hier entschieden. Diese
* Trennung ist der Grund, warum das Ergebnis als eigenes Feld zurueckkommt
* und nicht als Ausnahme: Ob ein nicht attestierter Schluessel abgelehnt
* gehoert, ist eine Frage der Betriebsregel und nicht der Zeremonie. Steht
* die Regel auf "off", ist dasselbe Ergebnis eine blosse Auskunft, die in
* der Oberflaeche angezeigt wird.
*
* @param list<string> $trustedRoots PEM der akzeptierten Herstellerwurzeln
* @return array{credential_id:string,public_key:string,sign_count:int,aaguid:?string,alg:int,attestation:array<string,mixed>}
*/
public static function verifyRegistration(
string $clientDataJson,
string $attestationObject,
string $expectedChallenge
string $expectedChallenge,
array $trustedRoots = []
): array {
self::checkClientData($clientDataJson, 'webauthn.create', $expectedChallenge);
@@ -154,12 +208,21 @@ final class WebauthnServer
$key = CoseKey::toPem($authData['cose']);
$inspection = Attestation::inspect(
$attestation,
(string)$attestation['authData'],
$clientDataJson,
$authData['aaguid'],
$trustedRoots
);
return [
'credential_id' => $authData['credential_id'],
'public_key' => $key['pem'],
'sign_count' => $authData['sign_count'],
'aaguid' => $authData['aaguid'],
'alg' => $key['alg'],
'attestation' => $inspection,
];
}
+82
View File
@@ -0,0 +1,82 @@
# Wurzelzertifikate der Yubico-Attestation (FIDO/WebAuthn).
#
# Herkunft: https://developers.yubico.com/PKI/yubico-ca-certs.txt
# Abgerufen am 2026-09-21.
#
# Warum diese drei und nicht alle fuenf: Yubico veroeffentlicht unter derselben
# Adresse auch die Wurzeln fuer PIV und OpenPGP. Die signieren andere Dinge -
# Smartcard-Schluessel und OpenPGP-Schluessel - und haben in einer
# WebAuthn-Attestationskette nichts zu suchen. Wer sie hier mit aufnimmt,
# vergroessert die Menge des Akzeptierten, ohne die Frage zu beantworten, um
# die es geht: Hat ein FIDO-Authenticator diesen Schluessel erzeugt?
#
# Die drei stehen nebeneinander, weil Yubico die Wurzel im Laufe der Zeit
# gewechselt hat. Ein YubiKey 5 aus 2019 kettet auf die U2F-Wurzel von 2014,
# ein Geraet aus 2025 auf eine der neueren. Keine davon darf wegfallen,
# solange noch Geraete im Umlauf sind, die darauf zeigen.
# --- Yubico U2F Root CA Serial 457200631 (2014) -----------------------------
# Die Wurzel der YubiKey-4- und -5-Serie.
-----BEGIN CERTIFICATE-----
MIIDHjCCAgagAwIBAgIEG0BT9zANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZ
dWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAw
MDBaGA8yMDUwMDkwNDAwMDAwMFowLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290
IENBIFNlcmlhbCA0NTcyMDA2MzEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
AoIBAQC/jwYuhBVlqaiYWEMsrWFisgJ+PtM91eSrpI4TK7U53mwCIawSDHy8vUmk
5N2KAj9abvT9NP5SMS1hQi3usxoYGonXQgfO6ZXyUA9a+KAkqdFnBnlyugSeCOep
8EdZFfsaRFtMjkwz5Gcz2Py4vIYvCdMHPtwaz0bVuzneueIEz6TnQjE63Rdt2zbw
nebwTG5ZybeWSwbzy+BJ34ZHcUhPAY89yJQXuE0IzMZFcEBbPNRbWECRKgjq//qT
9nmDOFVlSRCt2wiqPSzluwn+v+suQEBsUjTGMEd25tKXXTkNW21wIWbxeSyUoTXw
LvGS6xlwQSgNpk2qXYwf8iXg7VWZAgMBAAGjQjBAMB0GA1UdDgQWBBQgIvz0bNGJ
hjgpToksyKpP9xv9oDAPBgNVHRMECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAN
BgkqhkiG9w0BAQsFAAOCAQEAjvjuOMDSa+JXFCLyBKsycXtBVZsJ4Ue3LbaEsPY4
MYN/hIQ5ZM5p7EjfcnMG4CtYkNsfNHc0AhBLdq45rnT87q/6O3vUEtNMafbhU6kt
hX7Y+9XFN9NpmYxr+ekVY5xOxi8h9JDIgoMP4VB1uS0aunL1IGqrNooL9mmFnL2k
LVVee6/VR6C5+KSTCMCWppMuJIZII2v9o4dkoZ8Y7QRjQlLfYzd3qGtKbw7xaF1U
sG/5xUb/Btwb2X2g4InpiB/yt/3CpQXpiWX/K4mBvUKiGn05ZsqeY1gx4g0xLBqc
U9psmyPzK+Vsgw2jeRQ5JlKDyqE0hebfC1tvFu0CCrJFcw==
-----END CERTIFICATE-----
# --- Yubico FIDO Root CA Serial 450203556 (2024) ----------------------------
-----BEGIN CERTIFICATE-----
MIIDMzCCAhugAwIBAgIUSOEjTf//yqRfPW7Qq8qtIyCrAg8wDQYJKoZIhvcNAQEL
BQAwLzEtMCsGA1UEAwwkWXViaWNvIEZJRE8gUm9vdCBDQSBTZXJpYWwgNDUwMjAz
NTU2MCAXDTI0MDUwMTAwMDAwMFoYDzIwNjAwNDMwMDAwMDAwWjAvMS0wKwYDVQQD
DCRZdWJpY28gRklETyBSb290IENBIFNlcmlhbCA0NTAyMDM1NTYwggEiMA0GCSqG
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCdvl27w2gu1fPXeEFbIdqx0BalvVDVWrQP
J7HqviuEtZHlxSLxSFtcXpTolvLvof8f4tMerQTkVGzcmYzm1EBT4IJuMmoEqfkE
EhWpsADMFrjZkqlZY9EqxQzLoVEEonE5oGxSdVCxCcLIackpyR/CCXvj1Bt/hTgE
9hTlF4pRqxMkx3plF7y8dDZlRHWs7vbnhmBCGeI0ZPEQ6nl2mCg2r74adF2u6K9r
rLfhBC3QLE8EPrgqUsI+hkuq2tK4M2SMOp8uUVVkqUeu3h0kr3WVI0W02pkgrOgi
FKLFNkSrbYhdjMBDj5izmqfc9xJRKoDX612qd8ZGVHpT5AYFX+1hAgMBAAGjRTBD
MB0GA1UdDgQWBBTZyU5DiQ/a2UEgE7qBK0zhIsRNRjASBgNVHRMBAf8ECDAGAQH/
AgEAMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAQEAXvnB4SLuUJfY
MSVGAhssL/SmWli3FSccgxydvKlACcidIIWKQqa3q/QSUEQzC9DgEfMgr7iC1BkT
ZbILboV6UZ5knNsvjEZWuMeogJ8tgZs1hVvKwZizwJ+mEcmsjhIrBYuoL1T6yrOJ
vKFg1jv+Cy4ZwA9Bpk/V3UOir1VyK8dCtyHu6vfosotAdYx8FAuR243gRTMV6Jx8
Jdig2JDIAQMlzVeDpSUHX/K2HXRHxHwfgjbgUjjBu/72r8OfehyhzHXI3K8CFFdf
lO+8nEOJK3y8F1ivgS5uN/8SmcYw/STQYwhrxPuwz3nP8baMum4BB2nnYmpB60sX
3bl5k8QUSw==
-----END CERTIFICATE-----
# --- Yubico Attestation Root 1 (2024-12) ------------------------------------
-----BEGIN CERTIFICATE-----
MIIDPjCCAiagAwIBAgIUXzeiEDJEOTt14F5n0o6Zf/bBwiUwDQYJKoZIhvcNAQEN
BQAwJDEiMCAGA1UEAwwZWXViaWNvIEF0dGVzdGF0aW9uIFJvb3QgMTAgFw0yNDEy
MDEwMDAwMDBaGA85OTk5MTIzMTIzNTk1OVowJDEiMCAGA1UEAwwZWXViaWNvIEF0
dGVzdGF0aW9uIFJvb3QgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
AMZ6/TxM8rIT+EaoPvG81ontMOo/2mQ2RBwJHS0QZcxVaNXvl12LUhBZ5LmiBScI
Zd1Rnx1od585h+/dhK7hEm7JAALkKKts1fO53KGNLZujz5h3wGncr4hyKF0G74b/
U3K9hE5mGND6zqYchCRAHfrYMYRDF4YL0X4D5nGdxvppAy6nkEmtWmMnwO3i0TAu
csrbE485HvGM4r0VpgVdJpvgQjiTJCTIq+D35hwtT8QDIv+nGvpcyi5wcIfCkzyC
imJukhYy6KoqNMKQEdpNiSOvWyDMTMt1bwCvEzpw91u+msUt4rj0efnO9s0ZOwdw
MRDnH4xgUl5ZLwrrPkfC1/0CAwEAAaNmMGQwHQYDVR0OBBYEFNLu71oijTptXCOX
PfKF1SbxJXuSMB8GA1UdIwQYMBaAFNLu71oijTptXCOXPfKF1SbxJXuSMBIGA1Ud
EwEB/wQIMAYBAf8CAQMwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBDQUAA4IB
AQC3IW/sgB9pZ8apJNjxuGoX+FkILks0wMNrdXL/coUvsrhzsvl6mePMrbGJByJ1
XnquB5sgcRENFxdQFma3mio8Upf1owM1ZreXrJ0mADG2BplqbJnxiyYa+R11reIF
TWeIhMNcZKsDZrFAyPuFjCWSQvJmNWe9mFRYFgNhXJKkXIb5H1XgEDlwiedYRM7V
olBNlld6pRFKlX8ust6OTMOeADl2xNF0m1LThSdeuXvDyC1g9+ILfz3S6OIYgc3i
roRcFD354g7rKfu67qFAw9gC4yi0xBTPrY95rh4/HqaUYCA/L8ldRk6H7Xk35D+W
Vpmq2Sh/xT5HiFuhf4wJb0bK
-----END CERTIFICATE-----
+3
View File
@@ -466,6 +466,9 @@ def run(cfg, gruppen=None, muster=None, reset=True, bericht_datei=None, stoppen=
return 2
ctx['master_token'] = r.feld('master', 'raw_token')
ctx['admin'] = r.feld('admin')
# Das Installationskonto. Seit 2.9.3 nimmt /api/setup/v1/login nur
# noch die Rolle "installer" an; die Setup-Faelle brauchen es.
ctx['installer'] = r.feld('installer')
client.token = ctx['master_token']
print(" %d Migration(en), %d Tabellen, Master-Token bereit." % (
len(r.feld('migration', 'applied', default=[])),
+3
View File
@@ -29,6 +29,8 @@ Gruppen:
update Releases veroeffentlichen und finden
setup Erstinstallation
supervisor Befunde
hardware Welche Authenticatoren zugelassen sind
haertung Haertung der Anmeldewege
"""
import argparse
@@ -45,6 +47,7 @@ import suite_kern # noqa: E402,F401
import suite_bugtracker # noqa: E402,F401
import suite_dienste # noqa: E402,F401
import suite_tafel # noqa: E402,F401
import suite_hardware # noqa: E402,F401
def main():
+37 -3
View File
@@ -418,11 +418,25 @@ def _(ctx):
# Erstinstallation
# ---------------------------------------------------------------------------
def _installer(ctx):
"""Zugangsdaten des Installationskontos, oder ueberspringen."""
konto = ctx.get('installer')
if not konto or not konto.get('username'):
ueberspringen('Kein Installationskonto - Lauf ohne --reset oder vor Migration 012.')
return konto
@test('setup', 'Anmeldung liefert ein kurzlebiges Token')
def _(ctx):
"""
Bewusst mit dem INSTALLATIONSKONTO, nicht mit dem Administrator. Bis
2.9.3 nahm dieser Endpunkt jedes Konto an, und dieser Testfall bildete
das ab - er pruefte damit einen Weg, den es nicht geben sollte.
"""
konto = _installer(ctx)
r = ctx.client.post('/api/setup/v1/login', token=None, body={
'username': ctx.cfg['admin']['user'],
'password': ctx.cfg['admin']['pass'],
'username': konto['username'],
'password': konto['password'],
'hostname': MASCHINE,
})
erwarte_erfolg(r)
@@ -445,10 +459,30 @@ def _(ctx):
"Unerwartete Rechte am Setup-Token: %s" % scopes)
@test('setup', 'Ein Administratorkonto kommt hier nicht herein')
def _(ctx):
"""
Die Rollentrennung gilt seit 2.9.3 in beide Richtungen: Installer nicht
ins WebUI, Administratoren nicht in die Setup-API. Wer auf jedem
Zielsystem sein Administratorpasswort eingibt, verteilt den Zugang zur
gesamten Verwaltung - und bis 2.9.3 bekam er dafuer auch noch ein Token,
ohne den hinterlegten Hardwarekey vorzeigen zu muessen.
"""
r = ctx.client.post('/api/setup/v1/login', token=None, body={
'username': ctx.cfg['admin']['user'],
'password': ctx.cfg['admin']['pass'],
'hostname': MASCHINE,
})
erwarte_status(r, 403)
erwarte(r.fehlercode in ('installer_account_required', 'second_factor_required'),
'Erwartet wurde eine Ablehnung wegen Rolle oder zweitem Faktor, kam: %s' % r.kurz(300))
@test('setup', 'Anmeldung mit falschem Passwort abgewiesen')
def _(ctx):
konto = _installer(ctx)
r = ctx.client.post('/api/setup/v1/login', token=None, body={
'username': ctx.cfg['admin']['user'], 'password': 'garantiert-falsch',
'username': konto['username'], 'password': 'garantiert-falsch',
})
erwarte_fehler(r, http=(401, 429), code=['invalid_credentials', 'locked_out'])
+477
View File
@@ -0,0 +1,477 @@
#!/usr/bin/env python3
"""
Die Regel, welche Authenticatoren zugelassen sind.
WAS HIER GEPRUEFT WIRD - UND WARUM GERADE DAS
Der Anlass war ein Befund aus dem Betrieb: Ein Passwortverwalter liess sich
als zweiter Faktor registrieren, obwohl in den Registrierungsvorgaben
authenticatorAttachment = "cross-platform" stand. Die Angabe steuert nur, was
der Browser anbietet; sie ist keine Pruefung. Was traegt, ist die Attestation.
Der entscheidende Fall laesst sich ohne Hardware nachstellen: Ein
Authenticator, der "fmt": "none" schickt - also genau das, was ein
Passwortverwalter oder ein in das Betriebssystem eingebauter Passkey liefert -
muss unter der Regel abgewiesen werden und ohne sie durchkommen. Dazu braucht
es keine Kryptografie: Bei einer Registrierung mit "none" prueft der Server
keine Signatur, er liest den oeffentlichen Schluessel und legt ihn ab. Deshalb
kommt diese Reihe mit der Standardbibliothek aus.
WAS HIER NICHT GEPRUEFT WIRD
Der Gegenfall - ein echter Yubikey, der eine gueltige Kette vorlegt - laesst
sich von hier aus nicht erzeugen; dafuer braeuchte man Yubicos privaten
Wurzelschluessel, und genau darauf beruht die ganze Aussage. Ebenso fehlt der
Fall "packed mit selbst ausgestelltem Zertifikat", der belegt, dass ein x5c
allein nicht genuegt: Er verlangt eine echte Unterschrift ueber eine
Herausforderung, die erst zur Laufzeit feststeht, und damit eine
Krypto-Bibliothek. Beides laeuft in einem eigenen Werkzeug ausserhalb dieser
Suite.
Die Reihe raeumt am Ende auf und stellt die Regel auf "off" zurueck. Ohne das
liefe die naechste Reihe gegen eine Anlage, die Hardware verlangt - und
scheiterte an etwas, das sie gar nicht prueft.
"""
import base64
import http.cookiejar
import json
import re
import ssl
import urllib.error
import urllib.parse
import urllib.request
from harness import (test, erwarte, erwarte_status, ueberspringen, Fehlschlag)
# ---------------------------------------------------------------------------
# Ein oeffentlicher P-256-Punkt, fest eingebaut
# ---------------------------------------------------------------------------
# Warum fest und nicht erzeugt: Ein Punkt muss auf der Kurve liegen, sonst
# lehnt der Server schon beim Umwandeln nach PEM ab - und der Testfall
# scheiterte dann aus dem falschen Grund. Einen gueltigen Punkt zu WUERFELN
# verlangt dieselbe Bibliothek, die diese Suite nicht haben will. Also steht
# hier einer, der nachweislich auf P-256 liegt.
#
# Der zugehoerige private Schluessel ist bewusst nirgends abgelegt. Er wird
# nicht gebraucht: Bei einer Registrierung mit "none" unterschreibt niemand.
# Ein Schluessel, den man nicht braucht, ist ein Schluessel, der nicht
# verloren gehen kann.
PUBKEY_X = bytes.fromhex('6ee18ffb6fb32a97ac63039c118a9d507fb4624761df549a57b0f97457f85ca8')
PUBKEY_Y = bytes.fromhex('6291e3318ceadce692fd39d1822ffef9255f5eeb916b634694548c5d452b03cd')
# ---------------------------------------------------------------------------
# CBOR-Schreiber, gerade gross genug fuer ein Attestationsobjekt
# ---------------------------------------------------------------------------
def _kopf(major, wert):
if wert < 24:
return bytes([(major << 5) | wert])
if wert < 0x100:
return bytes([(major << 5) | 24, wert])
if wert < 0x10000:
return bytes([(major << 5) | 25]) + wert.to_bytes(2, 'big')
return bytes([(major << 5) | 26]) + wert.to_bytes(4, 'big')
def _cbor(wert):
if isinstance(wert, bytes):
return _kopf(2, len(wert)) + wert
if isinstance(wert, str):
roh = wert.encode('utf-8')
return _kopf(3, len(roh)) + roh
if isinstance(wert, bool):
return bytes([0xF5 if wert else 0xF4])
if isinstance(wert, int):
if wert >= 0:
return _kopf(0, wert)
return _kopf(1, -wert - 1)
if isinstance(wert, list):
return _kopf(4, len(wert)) + b''.join(_cbor(x) for x in wert)
if isinstance(wert, dict):
# Die Reihenfolge bleibt, wie sie eingetragen wurde. Der Server liest
# die Abbildung als Ganzes; eine kanonische Sortierung braucht er
# nicht, und sie nachzubilden waere Aufwand ohne Wirkung.
out = _kopf(5, len(wert))
for k, v in wert.items():
out += _cbor(k) + _cbor(v)
return out
raise TypeError('CBOR: %r' % type(wert))
def _b64u(roh):
return base64.urlsafe_b64encode(roh).decode('ascii').rstrip('=')
# ---------------------------------------------------------------------------
# Ein Sitzungsclient - die Suite selbst arbeitet mit Token, hier braucht es
# ein Cookie
# ---------------------------------------------------------------------------
class Sitzung:
def __init__(self, cfg):
self.basis = cfg['base_url'].rstrip('/')
self.basic = cfg.get('basic_auth') or {}
jar = http.cookiejar.CookieJar()
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar),
urllib.request.HTTPSHandler(context=ssl.create_default_context()),
)
self.csrf = None
def _kopf(self, extra=None):
k = {'User-Agent': 'dc-e2e/1.0', 'Accept': 'text/html,application/json'}
if self.basic.get('user'):
roh = ('%s:%s' % (self.basic['user'], self.basic['pass'])).encode()
k['Authorization'] = 'Basic ' + base64.b64encode(roh).decode('ascii')
k.update(extra or {})
return k
def hole(self, pfad):
req = urllib.request.Request(self.basis + pfad, headers=self._kopf())
try:
with self.opener.open(req, timeout=45) as r:
text = r.read().decode('utf-8', 'replace')
except urllib.error.HTTPError as e:
text = e.read().decode('utf-8', 'replace')
treffer = re.search(r'name="csrf_token"\s+value="([^"]+)"', text)
if treffer:
self.csrf = treffer.group(1)
return text
def formular(self, pfad, felder):
daten = dict(felder)
if self.csrf:
daten['csrf_token'] = self.csrf
req = urllib.request.Request(
self.basis + pfad,
data=urllib.parse.urlencode(daten).encode(),
headers=self._kopf({'Content-Type': 'application/x-www-form-urlencoded'}),
method='POST')
try:
with self.opener.open(req, timeout=45) as r:
return r.status, r.read().decode('utf-8', 'replace')
except urllib.error.HTTPError as e:
return e.code, e.read().decode('utf-8', 'replace')
def json_post(self, pfad, rumpf):
req = urllib.request.Request(
self.basis + pfad,
data=json.dumps(rumpf).encode(),
headers=self._kopf({'Content-Type': 'application/json',
'X-CSRF-Token': self.csrf or ''}),
method='POST')
try:
with self.opener.open(req, timeout=45) as r:
return r.status, json.loads(r.read().decode('utf-8', 'replace'))
except urllib.error.HTTPError as e:
roh = e.read().decode('utf-8', 'replace')
try:
return e.code, json.loads(roh)
except ValueError:
return e.code, {'roh': roh[:400]}
def kopfzeilen(self, pfad):
"""Antwortkopf einer Anfrage, Schluessel kleingeschrieben."""
req = urllib.request.Request(self.basis + pfad, headers=self._kopf())
try:
with self.opener.open(req, timeout=45) as r:
roh = r.headers
except urllib.error.HTTPError as e:
roh = e.headers
return {k.lower(): v for k, v in roh.items()}
def anmelden(self, benutzer, passwort):
self.hole('/login.php')
status, _ = self.formular('/login.php', {
'username': benutzer,
'password': passwort,
})
# Nach der Anmeldung steht das Token der Sitzung in index.php - das
# aus dem Anmeldeformular gilt fuer die alte Sitzungskennung nicht
# mehr.
self.hole('/index.php')
return status
def _schluessel_loeschen(ctx):
"""
Raeumt die Hardwarekeys des Testkontos ab.
Notwendig, weil diese Reihe selbst einen registriert - und ein Konto mit
zweitem Faktor laesst sich mit dem Passwort allein nicht mehr anmelden.
Ohne dieses Aufraeumen liefe der zweite Lauf gegen den Zwischenzustand
und meldete "Anmeldung erforderlich", was nach einem Fehler der Anwendung
aussieht und keiner ist.
"""
benutzer = (ctx.cfg.get('admin') or {}).get('user') or 'admin'
try:
ctx.testctl('clear_credentials', username=benutzer)
except Exception:
# Aeltere Testinstanz ohne diese Aktion. Dann bleibt der Lauf auf das
# Zuruecksetzen angewiesen - gemeldet wird es beim Anmelden.
pass
def _sitzung(ctx):
cfg = ctx.cfg
if not (cfg.get('admin') or {}).get('user'):
ueberspringen('In der Konfiguration steht kein Administratorkonto.')
_schluessel_loeschen(ctx)
s = Sitzung(cfg)
s.anmelden(cfg['admin']['user'], cfg['admin']['pass'])
if s.csrf is None:
ueberspringen('Anmeldung am WebUI nicht moeglich - kein CSRF-Token erhalten.')
return s
def _regel_setzen(s, stufe, aaguids=''):
return s.formular('/index.php', {
'action': 'save_webauthn_policy',
'_return': 'sub-system-users',
'policy_level': stufe,
'policy_aaguids': aaguids,
})
def _registrieren(s, bezeichnung):
"""Eine Registrierung mit fmt "none" - wie ein Passwortverwalter sie liefert."""
status, vorgabe = s.json_post('/api/auth/v1/?action=register_options', {})
if status != 200:
raise Fehlschlag('register_options antwortete %d: %s' % (status, vorgabe))
pk = vorgabe['publicKey']
herausforderung = pk['challenge']
rp_id = pk['rp']['id']
import hashlib
import os
kennung = os.urandom(32)
cose = {1: 2, 3: -7, -1: 1, -2: PUBKEY_X, -3: PUBKEY_Y}
auth = hashlib.sha256(rp_id.encode()).digest()
auth += bytes([0x41]) # UP | AT
auth += (0).to_bytes(4, 'big') # Zaehler
auth += b'\x00' * 16 # aaguid: bei "none" immer null
auth += len(kennung).to_bytes(2, 'big')
auth += kennung
auth += _cbor(cose)
client = json.dumps({
'type': 'webauthn.create',
'challenge': herausforderung,
'origin': s.basis,
'crossOrigin': False,
}).encode()
attestation = _cbor({'fmt': 'none', 'attStmt': {}, 'authData': auth})
return s.json_post('/api/auth/v1/?action=register_verify', {
'clientDataJSON': _b64u(client),
'attestationObject': _b64u(attestation),
'label': bezeichnung,
})
# ---------------------------------------------------------------------------
# Die Faelle
# ---------------------------------------------------------------------------
@test('hardware', 'Ohne Regel wird ein Software-Authenticator aufgenommen')
def t_ohne_regel(ctx):
"""
Der Ausgangszustand. Er ist zugleich der Befund, um den es geht: Ein
Authenticator ohne jeden Herkunftsnachweis kommt durch.
"""
s = _sitzung(ctx)
ctx['hw_sitzung'] = s
status, _ = _regel_setzen(s, 'off')
erwarte(status in (200, 302, 303), 'Regel liess sich nicht auf "off" stellen (%s).' % status)
status, antwort = _registrieren(s, 'Testschluessel ohne Nachweis')
erwarte(status == 200,
'Ohne Regel muss die Aufnahme gelingen, war %d: %s' % (status, antwort))
erwarte(antwort.get('attested') is False,
'Ein Schluessel ohne Attestation darf nicht als Hardware gelten: %s' % antwort)
@test('hardware', 'Mit Regel wird derselbe Authenticator abgewiesen')
def t_mit_regel(ctx):
"""
Derselbe Vorgang, dieselben Bytes - nur die Regel steht anders. Genau
diese Gegenueberstellung ist die Aussage der Funktion.
"""
s = ctx.get('hw_sitzung') or _sitzung(ctx)
status, _ = _regel_setzen(s, 'register')
erwarte(status in (200, 302, 303), 'Regel liess sich nicht auf "register" stellen (%s).' % status)
status, antwort = _registrieren(s, 'Testschluessel unter Regel')
erwarte(status == 403,
'Unter der Regel muss die Aufnahme scheitern, war %d: %s' % (status, antwort))
# Die Fehlerhuelle der API traegt den Code eine Ebene tiefer. Hier beide
# Formen zu lesen ist keine Nachlaessigkeit: Der Testfall soll den
# Zustand der Anwendung melden und nicht die Form ihrer Antwort.
huelle = antwort.get('error')
code = huelle.get('code') if isinstance(huelle, dict) else huelle
erwarte(code == 'policy_rejected',
'Erwartet wurde policy_rejected, kam: %s' % antwort)
# Die Meldung muss den Grund nennen. Ein blosses "abgelehnt" liesse
# offen, ob der Schluessel kaputt ist oder die Regel greift - und das ist
# der Unterschied zwischen "anderen Schluessel holen" und "Einstellung
# pruefen".
text = (huelle.get('message') if isinstance(huelle, dict) else '') or ''
erwarte('attestation' in text.lower() or 'hardware' in text.lower(),
'Die Ablehnung nennt den Grund nicht: %s' % text)
@test('hardware', 'Die strenge Stufe sperrt sich nicht selbst aus')
def t_strict_bewacht(ctx):
"""
Nach den beiden Faellen oben hat das Konto keinen attestierten Schluessel.
"strict" wuerde die eigene Anmeldung sperren - und muss deshalb abgelehnt
werden, statt sie zu sperren.
"""
s = ctx.get('hw_sitzung') or _sitzung(ctx)
status, _ = _regel_setzen(s, 'strict')
erwarte(status in (200, 302, 303), 'Unerwarteter Status beim Umschalten (%s).' % status)
# Die Ablehnung zeigt sich daran, dass die Stufe NICHT uebernommen wurde.
# Das ist die Pruefung, auf die es ankommt: Eine Meldung ohne Wirkung
# waere schlimmer als gar keine.
seite = s.hole('/index.php')
treffer = re.search(
r'name="policy_level"\s+value="strict"[^>]*\schecked', seite)
erwarte(treffer is None,
'Die Stufe "strict" wurde uebernommen, obwohl kein attestierter '
'Schluessel vorliegt - das sperrt den eigenen Zugang aus.')
@test('hardware', 'Eine unbrauchbare Modellliste wird abgewiesen')
def t_allowlist_pruefung(ctx):
"""
Eine Geraetekennung, die keine ist, darf nicht stillschweigend als leere
Liste durchgehen - das hiesse "alles erlaubt", wo jemand "nur dieses
Modell" gemeint hat.
"""
s = ctx.get('hw_sitzung') or _sitzung(ctx)
status, _ = _regel_setzen(s, 'register', aaguids='kein-gueltiges-geraet')
erwarte(status in (200, 302, 303), 'Unerwarteter Status (%s).' % status)
seite = s.hole('/index.php')
erwarte('kein-gueltiges-geraet' not in seite,
'Die unbrauchbare Angabe wurde gespeichert.')
@test('hardware', 'Aufraeumen: Regel steht wieder auf "off"')
def t_aufraeumen(ctx):
"""
Kein Test im eigentlichen Sinn, sondern die Rueckgabe eines geliehenen
Zustands. Bliebe die Regel stehen, liefe die naechste Reihe gegen eine
Anlage, die Hardware verlangt.
"""
s = ctx.get('hw_sitzung') or _sitzung(ctx)
status, _ = _regel_setzen(s, 'off')
erwarte(status in (200, 302, 303), 'Regel liess sich nicht zuruecksetzen (%s).' % status)
seite = s.hole('/index.php')
treffer = re.search(r'name="policy_level"\s+value="off"[^>]*\schecked', seite)
erwarte(treffer is not None, 'Die Regel steht nicht wieder auf "off".')
# Der in dieser Reihe angelegte Schluessel muss ebenfalls weg. Bliebe er
# stehen, verlangte das Testkonto beim naechsten Lauf einen zweiten
# Faktor, den niemand vorzeigen kann.
_schluessel_loeschen(ctx)
# ---------------------------------------------------------------------------
# Haertung der Anmeldewege
#
# Ergebnis einer Sicherheitsbegutachtung. Der rote Faden aller Befunde: Die
# WebAuthn-Zeremonie war in Ordnung, der zweite Faktor trotzdem umgehbar -
# ueber Wege DANEBEN. Was hier geprueft wird, sind genau diese Wege.
# ---------------------------------------------------------------------------
@test('haertung', 'Die Anmeldung verraet nicht, ob es ein Konto gibt')
def t_keine_enumeration(ctx):
"""
Gesperrt und gezaehlt wird nur, was existiert. Unterscheidet die Seite
sichtbar zwischen "Konto gesperrt" und "ungueltige Daten", genuegen
fuenf Versuche pro Name, um seine Existenz zu bestaetigen - und die
sorgfaeltige Laufzeitangleichung ueber dummyHash() waere umsonst.
Ein Fehlversuch gegen das echte Konto ist dabei unvermeidlich; die
Kontosperre greift erst beim fuenften.
"""
s = Sitzung(ctx.cfg)
s.hole('/login.php')
_, unbekannt = s.formular('/login.php', {
'username': 'gibt-es-garantiert-nicht-4711', 'password': 'beliebig'})
s2 = Sitzung(ctx.cfg)
s2.hole('/login.php')
_, vorhanden = s2.formular('/login.php', {
'username': (ctx.cfg.get('admin') or {}).get('user') or 'admin',
'password': 'garantiert-falsch'})
def meldung(seite):
treffer = re.search(r'<div class="alert-danger">(.*?)</div>', seite, re.S)
return re.sub(r'\s+', ' ', treffer.group(1)).strip() if treffer else ''
a, b = meldung(unbekannt), meldung(vorhanden)
erwarte(a != '', 'Kein Fehlertext fuer den unbekannten Namen gefunden.')
erwarte(a == b,
'Die Meldungen unterscheiden sich und verraten damit die Existenz '
'des Kontos:\n unbekannt: %r\n vorhanden: %r' % (a, b))
@test('haertung', 'Die Sicherheits-Header sind gesetzt')
def t_header(ctx):
"""
HSTS und CSP. Zur CSP gehoert die Einschraenkung, dass sie wegen der
Inline-Skripte in index.php 'unsafe-inline' enthaelt und deshalb NICHT
gegen XSS traegt - geprueft wird hier, dass die Richtlinien ueberhaupt
ankommen, nicht dass sie alles loesen.
"""
s = Sitzung(ctx.cfg)
kopf = s.kopfzeilen('/login.php')
hsts = kopf.get('strict-transport-security', '')
erwarte('max-age=' in hsts, 'Kein Strict-Transport-Security: %r' % hsts)
csp = kopf.get('content-security-policy', '')
for pflicht in ("frame-ancestors 'none'", "base-uri 'none'", "form-action 'self'"):
erwarte(pflicht in csp, 'In der CSP fehlt %r: %r' % (pflicht, csp))
@test('haertung', 'Die Gesundheitsauskunft nennt den WebAuthn-Anker')
def t_anker(ctx):
"""
rpId und expectedOrigin stammen nicht mehr aus dem Host-Header. Die
Folge ist, dass eine Anlage ohne app.url den Hardwarekey abweist - und
genau das soll man sehen koennen, BEVOR jemand sich anzumelden versucht.
"""
r = ctx.client.get('/api/health', token=None)
erwarte_status(r, 200)
anker = r.feld('checks', 'webauthn_anchor')
erwarte(isinstance(anker, dict),
'Die Gesundheitsauskunft nennt keinen webauthn_anchor: %s' % r.kurz(300))
erwarte(anker.get('ok') is True,
'Der WebAuthn-Anker ist nicht gesetzt - app.url fehlt: %s' % anker)
+75 -1
View File
@@ -176,6 +176,17 @@ switch ($action) {
// Vor Migration 019 gibt es die Tabelle nicht.
}
// Die Hardwarekey-Regel gehoert ebenfalls zurueckgesetzt. Sie steht
// in dc_settings und ueberlebt das Loeschen der Konten sonst - ein
// Lauf, der auf "strict" endet, liesse den naechsten an einer Regel
// scheitern, die er gar nicht prueft. Ausgetragen statt auf "off"
// gesetzt: Fehlt der Eintrag, liest HardwarePolicy die Vorgabe, und
// das ist genau derselbe Zustand wie bei einer frischen Anlage.
$db->prepare("
DELETE FROM dc_settings
WHERE skey IN ('webauthn_hardware_policy', 'webauthn_aaguid_allowlist')
")->execute();
$db->prepare('DELETE FROM dc_users WHERE username = :u')->execute([':u' => $adminUser]);
$db->prepare('
INSERT INTO dc_users (username, password_hash, created_at)
@@ -189,6 +200,33 @@ switch ($action) {
// Ohne Rollenspalte ist jeder Benutzer Administrator - nichts zu tun.
}
// ------------------------------------------------------------------
// Installationskonto
// ------------------------------------------------------------------
// Seit 2.9.3 nimmt /api/setup/v1/login nur noch Konten der Rolle
// "installer" an. Die Setup-Testfaelle benutzten bis dahin das
// Administratorkonto - und pruefen damit gerade den Weg, der jetzt
// verschlossen ist. Ein eigenes Konto hier anzulegen ist nicht bloss
// Testkosmetik: Es bildet ab, wie der Weg in der Produktion gedacht
// ist, und die Testfaelle liefen bisher auf einer Abkuerzung.
$installerUser = $adminUser . '-installer';
$installerPass = bin2hex(random_bytes(12));
$db->prepare('DELETE FROM dc_users WHERE username = :u')->execute([':u' => $installerUser]);
$db->prepare('
INSERT INTO dc_users (username, password_hash, created_at)
VALUES (:u, :h, UTC_TIMESTAMP())
')->execute([':u' => $installerUser, ':h' => password_hash($installerPass, PASSWORD_DEFAULT)]);
$installerOk = false;
try {
$db->prepare('UPDATE dc_users SET role = "installer" WHERE username = :u')
->execute([':u' => $installerUser]);
$installerOk = true;
} catch (Throwable $e) {
// Ohne Rollenspalte (vor Migration 012) gibt es keine Installer.
}
$manager = new TokenManager($db);
$master = $manager->createMasterToken(
'Testlauf (automatisch)',
@@ -204,6 +242,9 @@ switch ($action) {
'dropped' => $dropped,
'migration' => $result,
'admin' => ['username' => $adminUser, 'password' => $adminPass],
'installer' => $installerOk
? ['username' => $installerUser, 'password' => $installerPass]
: null,
'master' => ['token_id' => $master['token_id'], 'raw_token' => $master['raw_token']],
'state' => $status(),
]);
@@ -381,10 +422,43 @@ switch ($action) {
Http::ok(['unlocked' => $username, 'affected' => $stmt->rowCount()]);
break;
// ------------------------------------------------------------------
// Hardwarekeys eines Kontos entfernen, ohne die Datenbank zu leeren.
//
// Warum eigens und nicht ueber "reset": Ein registrierter zweiter Faktor
// macht das Konto fuer einen automatisierten Lauf unbenutzbar - Passwort
// allein fuehrt nur noch in den Zwischenzustand. Ein Testlauf, der einen
// Schluessel aufnimmt, sperrt damit den naechsten aus. Der ganze
// Datenbestand muss dafuer aber nicht weichen; "reset" wuerde Roadmap,
// Meldungen und Lizenzen gleich mit loeschen, und das ist ein hoher Preis
// fuer das Aufraeumen einer einzigen Tabelle.
//
// Nebenbei ist es der Weg zurueck, wenn man sich auf der Testinstanz mit
// einer schaerferen Regel selbst ausgesperrt hat. Auf der Produktion gibt
// es diese Datei nicht - dort ist der Notfalltoken der Weg.
case 'clear_credentials': {
$username = (string)(Http::str('username') ?? 'admin');
try {
$stmt = $db->prepare('
DELETE FROM dc_user_credentials
WHERE user_id IN (SELECT id FROM dc_users WHERE username = :u)
');
$stmt->execute([':u' => $username]);
$entfernt = $stmt->rowCount();
} catch (Throwable $e) {
Http::fail(500, 'no_table', 'dc_user_credentials fehlt - Migration 019 steht aus.');
}
Http::ok(['username' => $username, 'removed' => $entfernt]);
break;
}
// ------------------------------------------------------------------
default:
Http::fail(400, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [
'actions' => ['status', 'wipe', 'migrate', 'reset', 'workbench_token',
'update_master_token', 'genkey', 'log', 'clearlog', 'unlock_user'],
'update_master_token', 'genkey', 'log', 'clearlog', 'unlock_user',
'clear_credentials'],
]);
}