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>
633 lines
26 KiB
Markdown
633 lines
26 KiB
Markdown
# 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 — und
|
|
besonders die drei Dinge, die *wie* eine Prüfung aussehen und keine sind.
|
|
|
|
---
|
|
|
|
## 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)
|
|
|
|
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 |
|
|
| `sql/migrations/019_*.sql`, `021_*.sql` | Datenmodell |
|
|
| `tests/e2e/suite_hardware.py` | Fünf Fälle, ohne Krypto-Bibliothek |
|
|
| `docs/UPGRADE.md` §17, §18 | Einführung und Verschärfung im Betrieb |
|
|
|
|
---
|
|
|
|
## 12. Die Wege neben der Zeremonie
|
|
|
|
Alles hier stammt aus einer Begutachtung des fertigen Verfahrens. Die
|
|
Kryptographie musste dafür nicht angefasst werden — sie war in Ordnung. Jeder
|
|
Punkt ist unabhängig von der Sprache und gilt im Zielprojekt genauso.
|
|
|
|
### 12.1 Jeder Anmeldeweg muss den Faktor kennen
|
|
|
|
Das Deploymentcenter hat neben dem WebUI eine zweite Anmeldung für
|
|
Erstinstallationen. Sie prüfte ein TOTP-Feld, das **kein** Anmeldeweg
|
|
auswertet — und nicht die Tabelle, in der der tatsächlich umgesetzte zweite
|
|
Faktor liegt. Wer ein Administratorpasswort hatte, bekam dort ohne
|
|
Hardwarekey ein gültiges Token.
|
|
|
|
```php
|
|
if (!empty($user['totp_secret']) || Auth::secondFactorRequired($db, (int)$user['id'])) {
|
|
Http::fail(403, 'second_factor_required', '...');
|
|
}
|
|
if (Auth::roleOf($user) !== 'installer') {
|
|
Http::fail(403, 'installer_account_required', '...');
|
|
}
|
|
```
|
|
|
|
**Prüffrage fürs Zielprojekt:** Zählen Sie *alle* Stellen auf, an denen ein
|
|
Passwort gegen ein Konto geprüft wird. Jede einzelne muss den zweiten Faktor
|
|
kennen oder das Konto ablehnen. Eine Liste, die Sie nicht vollständig
|
|
aufschreiben können, ist der Befund.
|
|
|
|
**Und die Trennung symmetrisch:** Der Docblock behauptete seit jeher, dieser
|
|
Weg sei „für Installationskonten gedacht" — erzwungen hat das nichts. Der
|
|
umgekehrte Fall war dagegen geprüft. Eine Trennung, die nur in eine Richtung
|
|
wirkt, ist eine Notiz, keine Trennung.
|
|
|
|
### 12.2 Authentifizierung muss geschlossen scheitern
|
|
|
|
```php
|
|
} catch (\PDOException $e) {
|
|
if ($e->getCode() === '42S02' || $e->getCode() === '42S22') {
|
|
return false; // Migration steht aus - bekannt, harmlos
|
|
}
|
|
throw $e; // alles andere: abbrechen
|
|
}
|
|
```
|
|
|
|
Vorher stand dort `catch (\Throwable)` mit `return false`. Begründet war das
|
|
mit einer fehlenden Tabelle vor der Migration — gefangen wurden aber auch
|
|
Verbindungsabbrüche, Lock-Timeouts, „too many connections" und entzogene
|
|
Rechte. Jeder davon schaltete still für **alle** Konten auf Passwort-allein
|
|
zurück, und dieselbe Bauart hob bei `lockoutSeconds()` gleichzeitig den
|
|
Brute-Force-Schutz auf.
|
|
|
|
**Die Regel:** In einem Authentifizierungspfad darf ein `catch` nur die
|
|
Fehler schlucken, die Sie *benennen* können. Lieber keine Anmeldung als eine
|
|
ohne zweiten Faktor.
|
|
|
|
### 12.3 Die Sitzung erbt den Schutz nicht
|
|
|
|
Ein Hardwarekey schützt die **Anmeldung**. Er schützt nicht die Sitzung, die
|
|
daraus entsteht. Solange eine übernommene Sitzung die Anmeldemittel ändern
|
|
darf, ist er gegen genau den Fall wirkungslos, gegen den er gedacht ist:
|
|
|
|
- eigenen Schlüssel registrieren → dauerhafter Zugang, fällt niemandem auf
|
|
- Notfalltoken ausstellen → Ersatzweg in fremder Hand
|
|
- Regel abschalten → zweiter Faktor anlagenweit weg
|
|
|
|
Diese Aktionen brauchen eine Authentifizierung aus den letzten Minuten
|
|
(„sudo mode"). Im Deploymentcenter sind das zehn Minuten und **ein**
|
|
Bildschirm — derselbe, den auch der Leerlauf benutzt. Zwei Formulare, die
|
|
beide ein Passwort prüfen, laufen mit der Zeit auseinander.
|
|
|
|
### 12.4 Ein Passwortwechsel muss fremde Sitzungen beenden
|
|
|
|
Sonst lässt er genau den drin, dessentwegen man ihn vornimmt. PHP führt
|
|
keinen Index „alle Sitzungen des Kontos X"; eine Zählergeneration löst es mit
|
|
einer Spalte:
|
|
|
|
```sql
|
|
ALTER TABLE users ADD COLUMN session_epoch INT NOT NULL DEFAULT 0;
|
|
```
|
|
|
|
Die Sitzung merkt sich den Stand beim Anmelden, jede Anfrage vergleicht, der
|
|
Wechsel erhöht. Die eigene Sitzung übernimmt den neuen Stand — wer sein
|
|
Passwort ändert, soll sich nicht selbst hinauswerfen. Ein **Zähler**, keine
|
|
Uhr: kein Zeitzonen- und kein Gleichstandsproblem.
|
|
|
|
### 12.5 Leerlauf: sperren, nicht beenden
|
|
|
|
AAL2 verlangt erneute Authentifizierung nach 30 Minuten Untätigkeit und lässt
|
|
zur Reaktivierung **einen** Faktor genügen. Wer stattdessen die volle
|
|
Anmeldung erzwingt, erzieht dazu, den Schlüssel steckend liegen zu lassen —
|
|
und dann ist der zweite Faktor keiner mehr. Eine Regel, die zur Umgehung
|
|
erzieht, ist schlechter als eine mildere, die eingehalten wird.
|
|
|
|
Eine gesperrte Sitzung darf sich außerdem **nicht selbst verlängern**, sonst
|
|
hält ein offener Tab mit Hintergrundabrufen die Sperre ewig auf.
|
|
|
|
### 12.6 Der Anker darf nicht vom Aufrufer kommen
|
|
|
|
`rpId` und `expectedOrigin` aus dem Host-Header abzuleiten heißt, den
|
|
Maßstab der Prüfung vom Geprüften setzen zu lassen. Fest konfigurieren, und
|
|
bei fehlender Konfiguration **werfen** statt raten. Dazu eine Anzeige, die
|
|
das sichtbar macht, bevor jemand sich anmeldet — sonst tauschen Sie eine
|
|
stille Schwäche gegen eine stille Aussperrung.
|
|
|
|
### 12.7 Kleinigkeiten mit Hebelwirkung
|
|
|
|
- **Fehlermeldungen am Login vereinheitlichen.** Gesperrt wird nur, was
|
|
existiert; eine eigene Meldung „Konto gesperrt" hebt jede sorgfältige
|
|
Laufzeitangleichung wieder auf. Die Wartezeit darf darin stehen, der Grund
|
|
nicht. Die IP-Sperre darf benannt werden — sie sagt nichts über ein Konto.
|
|
- **Drosselung auf den Anmeldepfaden.** Kontosperren greifen erst nach N
|
|
Versuchen; bis dahin darf sonst beliebig schnell geraten werden.
|
|
- **`session.use_strict_mode`.** PHP-Vorgabe ist *aus* — der Server übernimmt
|
|
jede vom Client erfundene Sitzungskennung.
|
|
- **CSRF-Token bei der Anmeldung verwerfen.** Es liegt in der Session und
|
|
überlebt `session_regenerate_id()` sonst.
|
|
- **Notfalltoken altern lassen.** Ein Zettel im Schrank altert; ein Token
|
|
ohne Ablauf nicht. Alter **nach** dem Hashvergleich prüfen, sonst verrät die
|
|
Antwortzeit, ob einer existiert.
|
|
- **Sperren auch auf der zweiten Stufe.** Sonst ist der Zwischenzustand
|
|
zwischen Passwort und Faktor die Stelle, an der unbegrenzt geraten werden
|
|
darf — also die schwächste, obwohl sie die stärkere sein soll.
|