Files
Deploymentcenter/docs/HARDWAREKEY.md
T
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

26 KiB

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

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

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.

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.

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

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

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:

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:

// 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.

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

} 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:

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.