Passwörter sicher(er) speichern

Tags:

Update

Als ich diesen Artikel schrieb war mir die Existenz von zeitintensiven Hashing-Verfahren wie bcrypt noch 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.

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

Kommentare