mitmario.dev

Passwörter

PHP Sandbox 4 Min Lesezeit 3 BeispieleLektion 4 von 7

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

Warum ein schneller Hash das Problem ist
<?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

Woran man das Alter eines Hashs erkennt
<?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

hash_equals für alles, was kein Passwort ist
<?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, kostenlos

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