Abschnitt 15 · Lektion 4
Passwörter
In Lektion 12.5 hast du gelernt, Passwörter zu hashen statt sie im Klartext zu speichern, mit
password_hash() und password_verify(), und dass zu jedem Hash ein zufälliger Salt gehört. Damals
blieb dreierlei offen: warum der Hash langsam sein soll, was ihn mit den Jahren altern lässt, und was
du tust, wenn ein alter Benutzer sich anmeldet. Der Reihe nach.
Langsam ist hier eine Tugend
<?php
// Warum ein schneller Hash das Problem ist und nicht die Loesung.
// Gemessen mit hrtime, der monotonen Uhr aus Lektion 7.
function dauer(callable $f): float
{
$start = hrtime(true);
$f();
return (hrtime(true) - $start) / 1e6; // Millisekunden
}
$passwort = "geheim123";
$md5 = dauer(fn() => md5($passwort));
$bcrypt = dauer(fn() => password_hash($passwort, PASSWORD_DEFAULT));
echo "Ein einzelner Hash von \"$passwort\":\n";
printf(" md5: %.4f ms\n", $md5);
printf(" bcrypt: %.4f ms\n", $bcrypt);
echo "\nWie viele Versuche ein Angreifer je Sekunde schafft, grob:\n";
$n = 20000;
$proMd5 = dauer(function () use ($n, $passwort) {
for ($i = 0; $i < $n; $i++) { md5($passwort . $i); }
});
printf(" md5: rund %s pro Sekunde\n", number_format($n / ($proMd5 / 1000), 0, ",", "."));
printf(" bcrypt: rund %d pro Sekunde\n", (int) round(1000 / $bcrypt));
echo "\nGenau diese Langsamkeit ist bei einem Passwort-Hash die Absicht.\n"; md5 und sha1 hört man oft als „Hash-Funktionen”, und für einen Prüfwert einer Datei sind sie das
auch. Für ein Passwort sind sie ein Fehler, und zwar genau wegen ihrer größten Stärke: Sie sind
rasend schnell. Das erste Beispiel misst es. Ein md5 ist in einem winzigen Bruchteil einer
Millisekunde fertig, password_hash braucht viele Millisekunden.
Warum ist die schnelle Funktion die schlechtere? Weil Geschwindigkeit hier die Waffe des Angreifers
ist. Hat jemand deine Hash-Tabelle gestohlen, probiert er Passwörter durch: Er hasht Millionen
Kandidaten und vergleicht. Mit md5 schafft er auf einer Grafikkarte Milliarden pro Sekunde, das
Beispiel misst auf einem Kern schon rund neun Millionen. Mit bcrypt sind es im Beispiel sechs.
Dieselbe Passwortliste, die bei md5 in einer Stunde durch ist, dauert bei bcrypt länger, als der
Angreifer lebt. Die Langsamkeit, die dich als Server nichts kostet (ein Hash pro Anmeldung), kostet
ihn alles.
Und der Salt aus 12.5 kommt hier von der anderen Seite ins Bild: Ohne Salt könnte der Angreifer einmal eine riesige Tabelle „Passwort zu Hash” vorberechnen, eine Regenbogentabelle, und danach jeden Hash darin nachschlagen. Der Salt macht jeden Hash einzigartig und diese Vorberechnung wertlos, denn er müsste sie für jeden Salt neu machen.
Der Kostenfaktor, und warum er steigt
<?php
// Was in einem Hash steht, und woran man sein Alter erkennt. Die
// Hashs sind fest, damit die Ausgabe bei jedem Lauf gleich ist.
$alt = '$2y$04$DsmXledROeNrafT4DfFlVewaAInRbva4H2ulqKuqvqDKYlfry8ZAa';
$neu = '$2y$12$sDUX0zfH753099eELZE/FOGXhVslT4KackUvmMbWI8lML7JTpfCMm';
foreach (["alt" => $alt, "neu" => $neu] as $label => $hash) {
$info = password_get_info($hash);
echo "$label: Verfahren ", $info["algo"], " (", $info["algoName"], "), Kosten ", $info["options"]["cost"], "\n";
}
echo "\npassword_needs_rehash mit dem heutigen Standard:\n";
var_dump(password_needs_rehash($alt, PASSWORD_DEFAULT));
var_dump(password_needs_rehash($neu, PASSWORD_DEFAULT));
echo "\nDas Passwort verifiziert weiterhin, auch beim alten Hash:\n";
var_dump(password_verify("geheim123", $alt)); „Langsam” ist keine feste Zahl, sondern eine Einstellung, der Kostenfaktor. Bei bcrypt steckt er
im Hash selbst, gleich hinter dem Verfahren: $2y$12$... bedeutet Verfahren 2y, Kosten 12. Jede
Stufe verdoppelt die Arbeit. password_get_info() liest beides aus, ohne den Hash zu knacken, und das
zweite Beispiel zeigt es an einem alten Hash mit Kosten 4 und einem aktuellen mit Kosten 12.
Warum steigt die Zahl mit den Jahren? Weil Rechner schneller werden. Ein Kostenfaktor, der heute einen
Angreifer bremst, bremst ihn in fünf Jahren weniger. PASSWORD_DEFAULT in PHP ist deshalb kein
fester Wert, sondern der, den die PHP-Entwickler heute für angemessen halten, und er wird von Version
zu Version angehoben. Genau nachgemessen für diesen Kurs: Heute wählt PASSWORD_DEFAULT bcrypt mit
Kosten 12. Woran merkst du, dass ein gespeicherter Hash zu alt geworden ist? An
password_needs_rehash($hash, PASSWORD_DEFAULT): Es sagt true, sobald der Hash nicht mehr dem
aktuellen Standard entspricht.
Der einzige Moment, in dem du anheben kannst
Ein alter Hash ist ein Problem, aber ein heikles: Du kannst ihn nicht einfach neu berechnen, denn
dafür bräuchtest du das Klartextpasswort, und das hast du nicht. Es steht nirgends, genau das ist der
Sinn. Es gibt aber einen Moment, in dem es doch kurz vorliegt: beim Anmelden. Da tippt der
Benutzer es ein, du prüfst es mit password_verify, und genau dann, solange es noch in der Variable
steht, kannst du einen frischen Hash erzeugen und den alten ersetzen.
Das sind drei Zeilen, und sie sind der Kern der Aufgabe zu dieser Lektion: Wenn password_verify
passt und password_needs_rehash true sagt, dann password_hash mit dem eingetippten Passwort,
und der neue Hash geht per UPDATE in die Tabelle. Der Benutzer merkt nichts, seine Anmeldung
funktioniert wie immer, und beim nächsten Mal ist sein Hash auf dem aktuellen Stand.
hash_equals gehört nicht zu den Passwörtern
<?php
// Zwei Werkzeuge, zwei Aufgaben. password_verify fuer Passwoerter,
// hash_equals fuer alles andere Geheime: das CSRF-Token aus 15.3,
// einen Bestaetigungscode, einen API-Schluessel.
echo "Ein Passwort vergleicht man nie direkt, sondern mit password_verify:\n";
$hash = password_hash("geheim123", PASSWORD_DEFAULT);
var_dump(password_verify("geheim123", $hash));
echo "\nEin Token ist kein Passwort. Es wird direkt verglichen, aber\n";
echo "mit hash_equals, damit die gemessene Zeit nichts verraet:\n";
$echt = "6278c6f1387d8a3c55c8e851cade0e58";
var_dump(hash_equals($echt, "6278c6f1387d8a3c55c8e851cade0e58"));
var_dump(hash_equals($echt, "6278c6f1387d8a3c55c8e851cade0e59")); Zum Schluss eine Abgrenzung, die man leicht verwechselt. Ein Passwort vergleichst du nie direkt,
auch nicht mit hash_equals, sondern immer mit password_verify, weil nur das mit Salt und
Kostenfaktor umgeht. hash_equals ist für das andere Geheime, das kein Passwort-Hash ist und trotzdem
verglichen wird: das CSRF-Token aus 15.3, ein Bestätigungscode, ein API-Schlüssel. Dort schützt es vor
demselben Zeitangriff wie beim Token. Zwei Werkzeuge, zwei Aufgaben.
Und die alte Streitfrage Länge gegen Sonderzeichen? Länge gewinnt, und zwar deutlich. Ein langes
Passwort aus einfachen Wörtern hat mehr Möglichkeiten als ein kurzes mit $ und !, und man kann es
sich merken. Was du als Server dafür tust, ist bescheiden: eine Mindestlänge fordern, eine sinnvolle
Obergrenze zulassen, und ansonsten nicht mit Regeln nerven, die die Leute zu Passwort1! treiben.
Zum Mitnehmen
password_hash und password_verify kennst du aus Lektion 12.5. Dort blieb offen, warum ein Hash langsam sein soll, was ihn altern lässt und wie man einen alten Benutzer im Vorbeigehen auf den neuen Stand hebt. Genau diese drei Fragen.
Jetzt du
Basis Konto, kostenlosZu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.
Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.
In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.
Was in dieser Lektion steckt
-
Artikel mit 3 Beispielen zum Ausprobieren
Steht hier, ohne Konto lesbar.
-
Aufgabe, dein Code läuft auf einem Server
Öffnet sich mit dem Basis Konto.