Passwörter sicher(er) speichern
Update
Als ich diesen Artikel schrieb war mir die Existenz von zeitintensiven Hashing-Verfahren wie
bcryptnoch nicht bekannt. Obwohl das meiste Geschriebene dadurch seine Richtigkeit nicht verliert, würde ich doch dazu raten, anstatt auf SHA-256 auf bcrypt zu setzen. :-)
Einleitung
Seit Ende April wurde in unzählige Webseiten eingebrochen, die im Namen "Sony" tragen. Unter anderem das Sony Playstation Network, Sony Online Entertainment, Sony Music, Sony BMG, Sony Erricson, Sony Pictures und diverse Webseiten von Landesniederlassungen von Sony.
In mehreren Fällen wurden Datenbanken entwendet, die Passwörter in Klartext beinhalten. Für jeden Webentwickler eigentlich ein absolutes No-Go. Falls ihr irgendwo eine solche Datenbank habt, setzt alle Passwörter zurück, optimiert die Speicherung dieser und lasst eure Benutzer ein neues Passwort setzen. Damit wäre dieses Szenario aus dem Raum und wir können zum eigentlich Thema fortschreiten.
Da ihr gute Entwickler seid, habt ihr die Passwörter eurer Benutzer bestimmt als Hash abgespeichert. Eventuell habt ihr sie sogar gesalzen.
Lange Zeit galt MD5 dabei als das Hashing-Verfahren der Wahl. Obwohl MD5 etwas angeschlagen ist, ist die Verwendung zum Hashen von Passwörtern weiterhin ungefährlich. Denn obwohl sich bei MD5 Kollisionen finden lassen (= zwei frei wählbare, zufällige Datenblöcke finden, die den gleichen Hash besitzen) ist für die Generierung von Prüfsummen egal. Denn dort zählt die Resistenz gegen Preimage-Angriffe, da ein Datenblock ja gegeben ist (der Passwort-Hash).
Aber wir denken weiter, wir wollen möglichst sichere Passwörter. Deshalb wollen wir diese zukunftsicher mit SHA-256 (256 Bits, 64 Zeichen) anstatt MD5 (128 Bits, 32 Zeichen) hashen, dabei salzen und das ganze Prozedere sogar mehrmals durchführen. Los geht's!
Ausgangslage
Schauen wir uns folgende, vereinfachte Tabelle users an.
CREATE TABLE `users` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `username` VARCHAR(80) NOT NULL, `password` VARCHAR(32) NOT NULL, `salt` VARCHAR(32) NOT NULL ) ENGINE = InnoDB;
Schieben wir hier ein paar Einträge rein.
INSERT INTO `users`
(`username`, `password`, `salt`)
VALUES
(
'dhoward',
MD5(CONCAT('superman12', '23f7e74d0d9b54da662d5c555ea4107f')),
'23f7e74d0d9b54da662d5c555ea4107f'
),
(
'lebronjames',
MD5(CONCAT('the_king', '7725eb219d8692ca7bb8f64077014a6b')),
'7725eb219d8692ca7bb8f64077014a6b'
);
So eine Tabelle existiert vermutlich in vielen oder sogar den meisten Webanwendungen und soll Ausgangspunkt unserer Optimierungen sein. Da bereits ein gutes Mass an Sicherheit gegeben ist, müssen wir auch nicht alle Passwörter zurücksetzen und unsere Benutzer informieren. Die gezeigt Lösung soll unsere aktiven Benutzer automatisch in den Genuss von mehr Sicherheit bringen.
Der Vollständigkeit halber hier noch der aktuelle Inhalt:
| id | username | password | salt |
|---|---|---|---|
| 1 | dhoward | 9eb9917ac6d651fb5259b1f7f7facc55 | 23f7e74d0d9b54da662d5c555ea4107f |
| 2 | lebronjames | 735ea0f75bc028cb48558ba2cfb047f4 | 7725eb219d8692ca7bb8f64077014a6b |
Datenbank updaten
Jetzt wollen wir die Tabelle so umbauen, dass die Passwörter sicherer gespeichert werden. Dazu hashen wir sie zwischen 40'000 und 50'000 Mal mit SHA-256 und fügen nebst dem persönlichen Salz noch den Secret Key der Webanwendung hinzu.
- Durch das Salzen werden Rainbow Tables (vorgerechnete Klartext-Passwörter und deren Hash) ausgehebelt.
- Da jeder Benutzer ein eigenes, persönliches Salz erhält, muss auch jeder Datenbank-Eintrag beim Knacken einzeln bearbeitet werden. Wenn das Salz für alle Benutzer gleich wäre, könnte der Angreifer Hashes mit diesem Salz generieren und alle Einträge auf einmal überprüfen. Das wollen wir nicht.
- Nebst dem persönlichen Salz werden wir aber zusätzlich noch ein systemweites Salz hinzufügen (den Secret Key). Dieser ist nur in der Webapplikation gespeichert, nicht aber in der Datenbank. Der Angreifer müsste also auch Zugriff auf unseren Code erlangen (Gute Nacht, dann!).
- Um den Angreifer noch etwas länger zu beschäftigen, werden wir die Passwörter mehrmals hashen. Für uns ist dieser zusätzliche Zeitaufwand irrelevant, für den Angreifer aber zeitintensiv.
Schlussendlich soll dem Angreifer nur noch eine Möglichkeit bleiben: Brute-Force-Attacke, bzw. Dictionary-Attacke. Doch selbst mit geklauter Datenbank würden ihm dazu folgende Hinweise fehlen: Wie wurde das Passwort gesalzen, in welcher Reihenfolge, gabe es zusätzliche Salze und wie lauten diese?
Fangen wir an und bauen die users Tabelle um.
ALTER TABLE `users`
CHANGE `password` `password` VARCHAR(64) NOT NULL,
ADD `hashing_algo` ENUM('md5', 'sha256') NOT NULL DEFAULT 'md5',
ADD `hashing_rounds` SMALLINT(5) UNSIGNED NOT NULL DEFAULT 1;
Wir haben nun die Passwortspalte soweit angepasst, dass ein 64 Zeichen langer SHA-256 Hash gespeichert werden kann. Ausserdem haben wir neue Spalten für den Hashing Algorithmus und die Anzahl Durchgänge, die gehasht wurden, hinzugefügt.
Der Tabelleninhalt sieht nun so aus:
| id | username | password | salt | hashing_algo | hashing_rounds |
|---|---|---|---|---|---|
| 1 | dhoward | 9eb9917ac6d651fb5259b1f7f7facc55 | 23f7e74d0d9b54da662d5c555ea4107f | md5 | 1 |
| 2 | lebronjames | 735ea0f75bc028cb48558ba2cfb047f4 | 7725eb219d8692ca7bb8f64077014a6b | md5 | 1 |
Code updaten
Die Datenbank steht. Jetzt müssen wir noch die Codebase anpassen. Ich zeige dies anhand von PHP auf. Gehen wir von einem existierenden Model User aus, dass wir mit folgenden Funktionen erweitern/ausstatten.
<?php
class User
{
const HASHING_ALGO_MD5 = 'md5';
const HASHING_ALGO_SHA256 = 'sha256';
public $username;
public $password;
public $salt;
public $hashingAlgo;
public $hashingRounds;
/**
* Erstellt einen Hash für ein Klartext-Passwort.
*
* @param string $password
* @param string $salt
* @param string $hashingAlgo
* @param int $hashingRounds
* @return string
*/
static public function hashPassword($password, $salt, $hashingAlgo, $hashingRounds)
{
switch ($hashingAlgo)
{
case self::HASHING_ALGO_MD5:
$hash = $password . $salt;
break;
case self::HASHING_ALGO_SHA256:
$secretKey = Config::get('security.secret-key');
$hash = $secretKey . $password . $salt;
break;
default:
throw new InvalidArgumentException('Hashing Algo wird nicht unterstützt.');
}
for ($i = 0; $i < (int)$hashingRounds; $i ) {
$hash = hash($hashingAlgo, $hash);
}
return $hash;
}
// ...
/**
* Überprüft, ob ein Klartext-Passwort für diesen Benutzer gültig ist.
*
* @param string $password
* @return bool
*/
public function checkPassword($password)
{
return $this->password == self::hashPassword($password, $this->salt, $this->hashingAlgo, $this->hashingRounds);
}
/**
* Hasht und speichert ein Klartext-Passwort nach dem
* dem aktuell sichersten, implementierten Prinzip.
*
* @param string $password
* @return User
*/
public function setPassword($password)
{
$this->salt = md5(uniqid());
$this->hashingAlgo = self::HASHING_ALGO_SHA256;
$this->hashingRounds = mt_rand(40000, 50000);
$this->password = self::hashPassword($password, $this->salt, $this->hashingAlgo, $this->hashingRounds);
return $this;
}
// ...
}
In unserer Anwendungslogik gibt es jetzt noch irgendwo die Stelle, wo ein Anmeldeversuch überprüft werden muss. Das könnte etwa so aussehen.
<?php
// ...
$user = User::findByUsername($username);
if (!$user) {
throw new Exception('Ungültiger Benutzername.');
}
if (!$user->checkPassword($password)) {
throw new Exception('Ungültiges Passwort.');
}
if ($user->hashingAlgo != User::HASHING_ALGO_SHA256) {
$user->setPassword($password);
$user->save();
}
// ...
Bei einem erfolgreichen Versuch wird überprüft, ob der Benutzer noch einen MD5-Hash verwendet. Ist dies der Fall, wird der Hash aufgerüstet. Der Benutzer kriegt davon nichts mit.
Nehmen wir also an, dass sich User dhoward erfolgreich mit superman12 eingeloggt hat. Die Tabelle könnte danach so aussehen (als Secret Key wurde foobar benutzt):
| id | username | password | salt | hashing_algo | hashing_rounds |
|---|---|---|---|---|---|
| 1 | dhoward | 4029a6d114c4889c3087ae8f0ef25ce3 59dac54f7631e2f740c6d39f340dd9a0 | b007f1de0b2243a1f149b9f7102155ac | sha256 | 43452 |
| 2 | lebronjames | 735ea0f75bc028cb48558ba2cfb047f4 | 7725eb219d8692ca7bb8f64077014a6b | md5 | 1 |
Benchmark
Zum Abschluss noch eine kleine Benchmark, die aufzeigt, wie lange mein PC zur Berechnung der Hashes bräuchte. Ich denke, die Resultate sprechen für sich. :-)
| Methode | 1 Hash | 10 Hashes | 100 Hashes | 500 Hashes | 1000 Hashes | 5000 Hashes |
|---|---|---|---|---|---|---|
| MD5, gesalzen, 1-mal gehasht | <1 ms | <1 ms | <1 ms | 1 ms | 3 ms | 13 ms |
| SHA-256, gesalzen, 43452-mal gehasht | 156 ms | 1 s 619 ms | 15 s | 1 min 17 s | 2 min 34 s | 12 min 48 s |
Ähnliche Artikel
- Statische Methoden sind einfach nur Funktionen - also Vorsicht!
- Properties: Neue Get-/Set-Syntax für PHP?
- PHP zvals und Referenzen erklärt




