TOTP-Zwei-Faktor-Authentifizierung für kleine PHP-Anwendungen
Ein Passwort allein schützt ein Konto nur, solange es geheim bleibt. Wiederverwendete Passwörter leaken bei einem Einbruch und werden anderswo ausprobiert. Ein zeitbasiertes Einmalpasswort (Time-Based One-Time Password, TOTP) ergänzt zum „Etwas, das du weißt" ein „Etwas, das du besitzt": einen Code, der sich alle dreißig Sekunden ändert und von einem gemeinsamen Geheimnis (Shared Secret) abhängt, das in der Authenticator-App des Nutzers gespeichert ist. Für eine kleine PHP-Anwendung ist der Reiz real – aber ebenso der Raum für subtile Fehler. Der Algorithmus sieht kurz aus, doch Clock Drift, Secret-Speicherung und Replay-Behandlung können entscheiden, ob die Funktion hilft oder zu einer neuen Angriffsfläche wird.
Dieser Artikel arbeitet durch, wie TOTP berechnet wird, wie eine kleine Anwendung einen Code verifizieren kann, ohne ihre eigene Sicherheit zu untergraben, und wo die ehrlichen Grenzen des Ansatzes liegen. Er setzt keinen Identity Provider auf Unternehmensebene voraus und ist kein Tutorial, das eine Produktionsbereitstellung behauptet. Der unten gezeigte Code wurde implementiert und gegen die Testvektoren geprüft, die im Standard selbst veröffentlicht sind.
Warum ein zweiter Faktor die Abhängigkeit vom Passwort verringert
Viele Konto-Kompromittierungen beginnen nicht mit einem exotischen Exploit, sondern mit einem Passwort, das schwach, wiederverwendet oder in einem Vorfall offengelegt wurde. Das OWASP Multifactor Authentication Cheat Sheet fasst das Problem aus Entwicklersicht zusammen: Nimm an, dass Passwörter irgendwann kompromittiert werden, und gestalte das System so, dass es dagegen verteidigt. Einen Code zu verlangen, der sich im Laufe der Zeit ändert, bedeutet, dass ein gestohlenes oder erratenes Passwort allein nicht mehr ausreicht.
Es hilft, präzise zu sein, was „Etwas, das du besitzt" bedeutet. TOTP ist ein besitzbasierter Faktor: Der Nutzer hält ein Gerät oder eine App, die den Code des Shared Secret reproduzieren kann. Zwei Dinge zu verlangen, die der Nutzer kennt, etwa ein Passwort und eine PIN, ist keine Multifaktor-Authentifizierung, denn beide Faktoren können durch dieselbe Art von Angriff kompromittiert werden. Die Faktoren sollten unabhängig sein.
Diese Unabhängigkeit ist nicht absolut. Ein TOTP-Code muss irgendwo eingetippt werden, daher bleibt Phishing möglich, wenn ein Nutzer dazu verleitet wird, einen aktiven Code in eine gefälschte Seite einzugeben. TOTP gilt weithin als deutliche Verbesserung gegenüber einem Passwort allein, ist aber nicht phishingsicher im Sinne eines Hardware-Challenge-Response-Tokens oder von WebAuthn.
Was TOTP tatsächlich berechnet
TOTP ist keine separate Erfindung. Es ist die zeitbasierte Form eines zählerbasierten Einmalpasswort-Algorithmus namens HOTP, der in RFC 4226 standardisiert ist. HOTP ist als ein getrunkierter HMAC-Wert definiert:
HOTP(K, C) = Truncate(HMAC-SHA-1(K, C))
Hier ist K das Shared Secret und C ein Zähler. RFC 6238 definiert TOTP, indem dieser Zähler durch einen Wert ersetzt wird, der aus der aktuellen Zeit abgeleitet ist. Konkret ist der bewegende Faktor
T = floor((current_unix_time - T0) / X)
wobei X der Zeitschritt (Time Step) und T0 die Zeit ist, zu der das Zählen beginnt. Die Standardwerte sind ein Zeitschritt von 30 Sekunden und ein T0 von null (Unix-Epoche). Das Ergebnis ist, dass sowohl die Authenticator-App als auch der Server denselben sechsstelligen Code für dasselbe Dreißig-Sekunden-Fenster ableiten, weil sie dasselbe Geheimnis und dasselbe Zeitverständnis teilen.
RFC 6238 stellt fest, dass Implementierungen zusätzlich zu dem in der ursprünglichen HOTP-Konstruktion verwendeten SHA-1 auch HMAC-SHA-256 oder HMAC-SHA-512 verwenden dürfen. Die Wahl betrifft vor allem, wie das Shared Secret dimensioniert wird und wie breit die Interoperabilität der App erwartet wird.
Das Shared Secret ist das Herzstück
Alles hängt von einem Geheimnis ab, das zwischen Authenticator-App und Server geteilt wird. Wenn dieses Geheimnis leakt, leakt der gesamte zweite Faktor mit, denn wer das Geheimnis besitzt, kann jeden zukünftigen Code reproduzieren. RFC 4226 legt eine Mindestlänge des Geheimnisses von 128 Bit fest und empfiehlt 160 Bit. RFC 6238 ergänzt, dass Schlüssel zufällig gewählt werden sollten, gemäß den Zufallsempfehlungen in RFC 4086, und sicher im Validierungssystem gespeichert werden sollten.
In PHP steht eine kryptographisch sichere Zufallsquelle über random_bytes() zur Verfügung, die laut Handbuch für langfristige Geheimnisse wie Verschlüsselungsschlüssel geeignet ist. Üblich ist, das Geheimnis einmalig bei der Registrierung zu erzeugen, es dem Nutzer anzuzeigen, damit es in eine Authenticator-App eingegeben werden kann, und die maßgebliche Kopie serverseitig aufzubewahren.
Hier sparen kleine Anwendungen am häufigsten an der falschen Stelle. Das rohe Geheimnis in einer Datenbankspalte zu speichern, die ein gedumptes Backup, ein Backup oder eine reine Leseabfrage erreichen kann, offenbart alle erzeugten Codes. Eine gängige Abschwächung ist, das Shared Secret zu verschlüsseln, sodass nur die Anwendung, die den Entschlüsselungsschlüssel besitzt, es lesen kann. Eine andere Möglichkeit ist, das Geheimnis an einem Ort mit begrenztem Zugriff zu halten, getrennt von dem Wert, der bei Kompromittierungsverdacht ersetzt wird. Der ehrliche Punkt ist, dass das Geheimnis in dem Moment, in dem die Anwendung es zur Verifizierung eines Codes verwendet, im Klartext vorliegen wird; das Ziel ist, unnötige Kopien und unbefugte Leser zu entfernen.
Einen Code in PHP verifizieren
Um einen übermittelten Code zu verifizieren, berechnet der Server das TOTP für den aktuellen Zeitschritt neu und vergleicht es mit dem vom Nutzer eingegebenen Wert. Die folgende Funktion implementiert den RFC-6238-Algorithmus mit hash_hmac(). Sie nimmt das Shared Secret als rohe Binärbytes, berechnet den Zeitschritt und wendet die in RFC 4226 beschriebene dynamische Trunkierung an:
<?php
declare(strict_types=1);
function currentTotp(string $secretBinary, int $timeStep = 30, int $digits = 6, string $algo = 'sha1'): string
{
$counter = intdiv($time(), $timeStep);
// Der bewegende Faktor ist eine 8-Byte-Ganzzahl im Big-Endian-Format.
$data = pack('NN', $counter >> 32, $counter & 0xFFFFFFFF);
$hash = hash_hmac($algo, $data, $secretBinary, true);
// Dynamische Trunkierung: Offset aus dem letzten Byte ableiten.
$offset = ord($hash[strlen($hash) - 1]) & 0x0f;
$binary =
((ord($hash[$offset]) & 0x7f) << 24) |
((ord($hash[$offset + 1]) & 0xff) << 16) |
((ord($hash[$offset + 2]) & 0xff) << 8) |
(ord($hash[$offset + 3]) & 0xff);
$otp = $binary % (10 ** $digits);
return str_pad((string) $otp, $digits, '0', STR_PAD_LEFT);
}
Eine Anmerkung zur Korrektheit: Der bewegende Faktor muss als acht Byte große Ganzzahl im Big-Endian-Format behandelt werden, und der HMAC-Schlüssel sowie die Nachricht müssen rohe Binärdaten sein, kein Hex-Text. Einer der beiden Fehler erzeugt Codes, die mit einer offiziellen Authenticator-App nicht übereinstimmen. Ich habe die obige Version als eigenständiges Skript geschrieben und gegen die Beispielwerte in RFC 6238 Appendix B geprüft; alle neun Testvektoren (SHA-1 und SHA-256) stimmten überein.
Bei der Verifizierung sollten Strings in konstanter Zeit verglichen werden, damit eine falsche Vermutung nicht verrät, wie nahe sie dran war. PHP stellt dafür hash_equals() bereit. Das Handbuch weist darauf hin, dass der nutzergelieferte String das zweite Argument sein und dass beide Strings dieselbe Länge haben müssen:
if (hash_equals($expected, $submitted)) {
// Code ist korrekt
}
Clock Drift braucht ein kleines Toleranzfenster
Eine geteilte Uhr ist eine versteckte Annahme. Driftet die Uhr des Nutzer-Telefons auch nur um eine Minute, sind sich App und Server über den aktuellen Zeitschritt nicht mehr einig, und gültige Codes werden abgelehnt. RFC 6238 empfiehlt dem Validator, ein kleines Fenster von Zeitschritten um den aktuellen herum zu berücksichtigen, um diese Drift zu tolerieren.
Der Standard empfiehlt, für Netzwerkverzögerung höchstens einen Zeitschritt zuzulassen, und empfiehlt 30 Sekunden als Standardzeitschritt, um Sicherheit und Benutzerfreundlichkeit auszubalancieren. Ein größeres Fenster macht die Anmeldung verzeihender, verlängert aber auch den Zeitraum, in dem ein erbeuteter Code verwendbar bleibt. Diese Balance ist eine Produktentscheidung und hängt davon ab, ob der Code über einen vertrauenswürdigen Kanal läuft.
Eine subtile Folge eines Fensters ist, dass die Resynchronisierung pro Konto gemerkt werden sollte. Ist die Uhr eines Nutzers konstant zwei Schritte voraus, kann der Server diesen Offset notieren und gegen den korrigierten Schritt validieren. Ohne diese Buchführung zahlt jeder Login den Preis, mehrere Schritte zu prüfen.
Jeder Code muss einmalig sein
RFC 6238 ist explizit: Ein Verifier MUSS dasselbe Einmalpasswort nicht zweimal akzeptieren. Innerhalb eines Zeitschritts wird derselbe Code wiederholt erzeugt, sodass ein erbeuteter und sofort wiedergegebener Code sonst dauerhaft durchginge. Der Server muss sich daher merken, welcher Schritt bereits verbraucht wurde, und einen wiederholten Code ablehnen, selbst wenn er technisch noch gültig ist.
In einem kleinen System gibt es mehrere Wege. Für TOTP ist es am einfachsten, pro Konto den zuletzt akzeptierten Zeitschritt zu verfolgen und jeden Code abzulehnen, dessen Schritt nicht neuer ist als der gespeicherte. Dies ähnelt der clientseitigen Einmal-Regel, die RFC 4226 für seine zählerbasierte Variante beschreibt.
Diese Einmal-Anforderung ist getrennt von der Wahl der Stellenzahl. RFC 4226 verlangt mindestens sechs Ziffern und empfiehlt für stärkere Sicherheit sieben oder mehr. OWASP empfiehlt ebenfalls, wo es die Benutzerfreundlichkeit erlaubt, Codes mit acht oder mehr Ziffern zu erwägen. Der Trade-off des Schlüsselraums ist einfache Arithmetik: Ein sechsstelliger Code hat etwa eine Million Möglichkeiten, sodass ein Server, der Versuche nicht drosselt, Brute-Force-Angriffe schlicht durch Volumen einlädt.
Versuche drosseln, weil Codes im Volumen erratbar sind
Selbst mit einem Vergleich in konstanter Zeit kann ein Angreifer, der viele Codes sendet, den Raum sechsstelliger Werte durcharbeiten. RFC 4226 spricht dies direkt an und empfiehlt einen Drosselungsparameter (Throttling), der die Anzahl fehlgeschlagener Verifizierungsversuche begrenzt, und stellt fest, dass die Drosselung über Anmeldesitzungen hinweg wirken muss, um paralleles Raten zu verhindern. OWASP bekräftigt dies mit der Anforderung, für Einmalcodes strenge Versuchslimits zu erzwingen.
Hier muss der Server aufpassen, keinen eigenen Denial of Service zu erzeugen. Rate Limiting pro Konto ist sinnvoll, aber eine globale Sperre eines Kontos nach wenigen Fehlversuchen kann jedem Besucher erlauben, einen legitimen Nutzer durch absichtliches Vertippen auszusperren. Wenn ein eingegebenes Passwort korrekt ist, der zweite Faktor aber wiederholt scheitert, kann das bedeuten, dass das Passwort bereits kompromittiert wurde – die Anwendung sollte also mehr tun als nur still ablehnen.
Einen neuen Authenticator registrieren
Die Provisionierung beginnt meist damit, dass der Server ein Geheimnis erzeugt und der Nutzer einen QR-Code scannt. Das weit verbreitete von Google Authenticator dokumentierte Key-URI-Format ist ein einfacher Text, der Typ, Kontolabel und Parameter kodiert:
otpauth://totp/Example:[email protected]?secret=BASE32SECRET&issuer=Example
Der secret-Wert ist gemäß RFC 3548 Base32-kodiert, Padding entfällt. Der Issuer wird dringend empfohlen, damit Konten verschiedener Dienste, die denselben Kontonamen teilen, in der App nicht kollidieren.
Ein sicherer Registrierungsablauf bestätigt die Einrichtung, bevor er ihr vertraut. Nach dem Scannen des Codes sollte der Nutzer einen frisch erzeugten Code eingeben, der verifiziert wird, bevor der Faktor als aktiv markiert wird. Das fängt den häufigen Fehler ab, in die falsche App zu scannen oder das Geheimnis zu vertippen. Erst nach einer erfolgreichen Herausforderung gilt der Zwei-Faktor-Faktor als aktiviert.
Recovery darf kein Umgehungsweg werden
Das größte operative Risiko der Zwei-Faktor-Authentifizierung ist, legitime Nutzer auszusperren, wenn sie das Gerät mit dem Geheimnis verlieren. Die Antwort ist nicht, den Sicherheitsfaktor auszuschalten, sondern Recovery sorgfältig zu planen – denn jeder Recovery-Pfad ist ein alternativer Weg zur Authentifizierung und darf nicht schwächer sein als der Faktor selbst.
OWASP schlägt mehrere Ansätze vor: Ausgabe einmaliger Recovery-Codes bei der Registrierung, die Anforderung mehrerer Faktoren oder einen strengen Identitätsprüfungsprozess. Recovery-Codes sind die praktischste Lösung für eine kleine Anwendung. Sie werden einmal erzeugt, genau einmal im Klartext angezeigt, vom Nutzer aufbewahrt und beim Gebrauch verbraucht. Ein einmaliger Recovery-Code ist selbst ein Besitzfaktor und muss daher so sorgfältig behandelt werden wie ein Passwort.
Ehrliche Grenzen und Alternativen
TOTP ist nicht der stärkste zweite Faktor, und es lohnt sich, das klar zu sagen, statt es zu übertreiben. Weil der Nutzer den Code in jede Seite eintippt, die danach fragt, kann eine überzeugende Phishing-Seite sowohl das Passwort als auch einen aktiven Code erfassen und den Code innerhalb seines restlichen Fensters wiedergeben. Deshalb greifen Produkte mit anderen Bedrohungsmodellen zu phishingsicherer Hardware oder Passkeys auf Basis von WebAuthn, bei denen der private Schlüssel das Gerät nie verlässt und die Transaktion an den Ursprung gebunden ist.
Für eine kleine Self-Hosted-Anwendung ist diese Lücke selten ein Grund, TOTP ganz wegzulassen. Die praktische Wahl ist meist TOTP jetzt, mit der Option eines phishingsicheren Faktors später, statt gar keinen zweiten Faktor zu haben. Die ehrliche Einordnung lautet: TOTP hebt die Messlatte gegen die meisten automatisierten Bedrohungen und Angriffe mit gestohlenen Passwörtern an, beseitigt aber nicht das menschliche Element des Phishings.
Es gibt auch Wartungspflichten. Ein Nutzer auf einem Gerät ist einfach. Eine wachsende Zahl von Konten bedeutet, dass sich die Shared Secrets vervielfachen, jedes davon sichere Speicherung, Rotation und einen Recovery-Plan braucht, und die Frage der Verschlüsselung im Ruhezustand kann nicht ignoriert werden, wenn der Server Rohgeheimnisse neben dem Code speichert, der sie nutzt.
Eine kompakte Prüfung für einen kleinen PHP-Dienst
- Das Shared Secret wird aus einem CSPRNG erzeugt, mindestens 128 Bit, und so gespeichert, dass gewöhnliche Leser es nicht erreichen können.
- Die maßgebliche Kopie des Geheimnisses ist getrennt von jedem Wert, der bei Kompromittierungsverdacht ersetzt werden könnte.
- Der Zähler wird als acht Byte große Big-Endian-Ganzzahl verpackt und als rohes Binär gehasht, nicht als Hex.
- Codes werden in konstanter Zeit mit
hash_equals()verglichen. - Ein kleines Resynchronisierungsfenster nimmt Clock Drift auf, ohne das Replay-Fenster mehr als nötig zu verbreitern.
- Derselbe Code wird nach Gebrauch abgelehnt, sodass ein erbeuteter Code in seinem Fenster nicht wiedergegeben werden kann.
- Fehlgeschlagene Versuche werden über Sitzungen hinweg gedrosselt, ohne es einem Außenstehenden zu erleichtern, einen legitimen Nutzer trivial auszusperren.
- Die Registrierung wird mit einem aktiven Code bestätigt, bevor der Faktor aktiviert wird, und einmalige Recovery-Codes existieren als getesteter Rückweg.
Fazit
TOTP wirkt täuschend einfach, weil der Kern nur aus ein paar Zeilen Hashing und Trunkierung besteht. Der schwierige Teil sind die Entscheidungen drumherum: wo das Shared Secret lebt, wie ein Toleranzfenster dimensioniert wird, wie Wiederverwendung verhindert wird und wie ein ausgesperrter Nutzer wieder hineinkommt. Jede Wahl tauscht ein wenig Sicherheit gegen ein wenig Benutzerfreundlichkeit, und die richtige Balance hängt davon ab, wen die Anwendung bedient.
Wenn du TOTP zu einer kleinen PHP-Anwendung hinzufügst, ist der oben gezeigte Code ein brauchbarer, verifizierter Ausgangspunkt, aber nicht die ganze Funktion. Die verbleibende Arbeit liegt nicht im Algorithmus; sie liegt in der Speicherung des Geheimnisses, der Replay- und Drosselungsbehandlung und einem Recovery-Ablauf, der stark genug ist, nicht zur leichten Tür zu werden. Ob TOTP ausreicht oder ob ein phishingsicherer Faktor die zusätzliche Komplexität wert ist, beantwortet am besten dein eigenes Bedrohungsmodell und die Menschen, die du schützen willst.
References
- M'Raihi, Machani, Pei, Rydell, "TOTP: Time-Based One-Time Password Algorithm", RFC 6238, May 2011.
- M'Raihi, Bellare, Hoornaert, Naccache, Ranen, "HOTP: An HMAC-Based One-Time Password Algorithm", RFC 4226, December 2005.
- Eastlake 3rd, Schiller, Crocker, "Randomness Requirements for Security", RFC 4086, June 2005.
- OWASP Cheat Sheet Series, "Multifactor Authentication Cheat Sheet".
- PHP Documentation Group, "random_bytes".
- PHP Documentation Group, "hash_hmac".
- PHP Documentation Group, "hash_equals".
- Google Authenticator, "Key Uri Format".
