Bau deinen eigenen Have-I-Been-Pwned Passwortchecker

Posted on October 26, 2017
hibp, linux, python, db, postgres

Bitte lest die Hinweise unter diesem Einführungstext bevor ihr versucht das nachzubauen.

Have I Been Pwned (HIBP) ist eine großartige Webseit von Troy Hunt auf der man prüfen kann, ob die eigene E-Mail-Adresse in “geleakten” Benutzerdatenbanken enthalten ist und das dazugehörende Passwort darum vielleicht nicht mehr sicher ist.

Im August hat Troy Hunt dann ein ganz neues Feature dazugebaut: Die Möglichkeit, direkt Passwörter gegen seine Datenbank mit 306 Millionen geknackten Passwörtern zu prüfen.

Die Datenbank kann direkt auf HIBP abgefragt werden und ist auch über die HIBP API verfügbar.

Wie Troy Hunt auch in seinem eigenen Blogpost erklärt, empfiehlt das NIST inzwischen ausdrücklich Benutzerpasswörter gegen bekannte Listen geknackter Passwörter zu prüfen.

Wenn ihr also Benutzer mit Logins habt, könnte so eine Datenbank für euch sehr nützlich sein. Auch auf mich trifft das zu.

Aber natürlich ist es eine schlechte Idee, (potenzielle) Benutzerpasswörter an eine Seite im Internet zu schicken, sogar wenn es Troy Hunt ist!

Ich habe mich also an die Arbeit gemacht und die Pwned Passwords-API nachgebaut damit ich sie in meinem eigenen (Kunden-) Netz betreiben kann.

Und hier erkläre ich wies geht!

Zutaten:

Die dazugehörende Web-App findet man hier: https://github.com/duk3luk3/pwndwords

Achtung

Und los gehts ~~

Datenbank

Ich bin kein Datenbankexperte, aber das ist mir eingefallen:

Zuerst muss pg9.6 installiert werden:

Es ist sinnvoll die Postgres-Konfiguration anzupassen um die Arbeitsspeicher-Allokation zu tunen (besonders shared_buffers und work_mem). Im PostgreSQL-Wiki gibt es eine Tuning PostgreSQL-Anleitung.

Einen Datenbank-User erstellen:

Datenbank erstellen:

Extension (pgcrypto Extension für digest Funktion), Tabelle und Index erstellen:

CREATE EXTENSION pgcrypto;
CREATE TABLE passwords ( id bigserial PRIMARY KEY, hash bytea);
CREATE INDEX ON passwords (substring(hash for 7));

Damit wird ein Index über die ersten 7 bytes des SHA1-Passwort-Hashes gebaut. Für unsere Liste mit 300 Millionen Passwörtern ist das eine gute Index-Größe, da es damit kaum Kollisionen geben sollte.

Daten laden und säubern

(In Zukunft sollte V2 der Passwort-Liste verfügbar sein, dann kann man sich diesen Schritt vielleicht sparen.)

$ time sort --parallel=4 -u pwned-passwords-1.0.txt pwned-passwords-update-1.txt pwned-passwords-update-2.txt > pwned_all_uniq.txt

real	4m35.591s
user	1m42.476s
sys	0m26.056s

(Ohne --parallel dauerte es 12 Minuten für nur die erste Datei - aber beim Aufruf mit --parallel war eventuell schon der Disk-Cache gefüllt)

In die Datenbank importieren

Wenn ihr schon mit der Datenbank rumgespielt habt und sie säubern wollt bevor ihr die volle Liste importiert, könnte ihr die Tabelle truncaten:

TRUNCATE passwords RESTART IDENTITY;

Dann den Import starten:

$ sed -e 's/^/\\\\x/' pwned_all_uniq.txt | time psql passwords -c "copy passwords (hash) from STDIN"

Das sed ist hier notwending um \\x vor alle Hashes einzufügen damit Postgres sie als Hexadezimal-Strings erkennt.

Query

Um den Index zu benutzen, muss eine WHERE-Condition die zum Index passt in der Query benutzt werden:

prepare pw_lookup (bytea) as select * from passwords WHERE substring(hash for 7) = substring($1 for 7) and hash = $1;
explain analyze execute pw_lookup(digest('sommernacht','sha1'));

Und ihr solltet eine Ausgabe wie hier erhalten:

                                                                QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on passwords  (cost=29579.61..2284020.12 rows=1 width=29) (actual time=16.376..16.376 rows=1 loops=1)
   Recheck Cond: ("substring"(hash, 1, 7) = '\x431ce891a0129c'::bytea)
   Filter: (hash = '\x431ce891a0129ceba34e9ef44638a6dbd065f28a'::bytea)
   Heap Blocks: exact=1
   ->  Bitmap Index Scan on passwords_substring_idx  (cost=0.00..29579.61 rows=1601472 width=0) (actual time=7.395..7.395 rows=1 loops=1)
         Index Cond: ("substring"(hash, 1, 7) = '\x431ce891a0129c'::bytea)
 Execution time: 16.396 ms
(7 rows)

Webserver

Ich habe eine Flask-App geschrieben die in Apache2 mit mod_wsgi läuft.

Apache2, mod_wsgi (für Python3!) and Python3 Infrastruktur installieren:

sudo apt-get install apache2 libapache2-mod-wsgi-py3 python3-pip python3-venv
sudo a2enmod ssl wsgi

Jetzt müsst ihr eine App bauen - oder einfach meine benutzen:

sudo mkdir /opt/password-lookup
sudo chown erlacher:tumuser /opt/password-lookup/
git clone git@github.com:duk3luk3/pwndwords.git /opt/password-lookup

Die Requirements für die App kann man global installieren, oder ihr könnt ein Virtualenv benutzen. Ich mag Virtualenvs:

cd /opt/password-lookup
python3 -m venv .venv
cp activate_this.py .venv/bin/
. ./.venv/bin/activate
pip install -r requirements.txt

Da die Python3-Distribution in Ubuntu ein bisschen speziell ist, muss man activate_this.py kopieren.

Jetzt könnt ihr in /etc/apache2/sites-available/default-ssl.conf einen Wsgi-Service einbauen:

<IfModule mod_ssl.c>
	<VirtualHost _default_:443>
		ServerAdmin ADMIN@EXAMPLE.COM

		DocumentRoot /opt/password-lookup

		WSGIDaemonProcess passwords user=USER group=GROUP threads=5
		WSGIScriptAlias /passwords /opt/password-lookup/venv.wsgi

		<Directory /opt/password-lookup>
			WSGIProcessGroup passwords
			WSGIApplicationGroup %{GLOBAL}
			Order deny,allow
			Allow from all
			Require all granted
		</Directory>

		SSLCertificateFile    /etc/ssl/certs/CERTIFICATE.pem
		SSLCertificateKeyFile /etc/ssl/private/KEY.key
		SSLCACertificateFile  /etc/ssl/certs/ca-certificates.crt

Die in GROSSBUCHSTABEN eingetragenen Platzhalter müsst ihr entsprechend einsetzen.

Integration

So, dieser letzte Teil ist keine Meisterleistung der Software-Kunst und besteht größtenteils aus Copy-Paste von StackOverflow, und sollte deshalb nur als Proof-of-Concept gesehen werden.

Die API selbst solltet ihr auch nicht öffentlich zugänglich machen außer ihr baut zuerst Rate-limiting und Caching ein.

Um sich noch ein bisschen sicherer zu fühlen, sollte man auch die Passwörter hashen bevor sie an die API geschickt werden…

jQuery AJAX code:

<script src="/jquery-1.10.2.js"></script>
<script>
$(function() {
//setup before functions
var typingTimer;                //timer identifier
var doneTypingInterval = 800;  //time in msa
var jqxhr;
   
//on keyup, start the countdown
$('#pwedit').keyup(function(){
    clearTimeout(typingTimer);
    $('#pwhint_pwnd').css('display','none');
    $('#pwhint_ok').css('display','none');
    $('#pwhint_error').css('display','none');
    $('#pwedit').css('background-color','');
    if ($('#pwedit').val()) {
        typingTimer = setTimeout(doneTyping, doneTypingInterval);
    }
});

//user is "finished typing," do something
function doneTyping () {
//alert('done typing');
var inputval = $('#pwedit').val();
$('#pwhint_working').css('display','');
jqxhr = $.ajax({
  url: 'https://pwndwords.in.tum.de/passwords/?password=' + encodeURIComponent(inputval),
  timeout: 5000,
  statusCode: {
    200: function(data, textStatus, xhr) {
        if (xhr == jqxhr) {
                $('#pwedit').css('background-color','#FF9999');
                $('#pwhint_working').css('display','none');
                $('#pwhint_pwnd').css('display','');
        }
    },         
    404: function(xhr, textStatus, errorThrown) {
        if (xhr == jqxhr) {
                $('#pwedit').css('background-color','#99FF99');
                $('#pwhint_pwnd').css('display','none');
                $('#pwhint_working').css('display','none');
                $('#pwhint_ok').css('display','');
        }              
    }          
  },           
  error: function(xhr, textStatus, errorThrown) {
        if (xhr == jqxhr && xhr.status != 404) {
                console.log('Error trying to reach pwndwords.in.tum.de: ' + textStatus + ' (' + errorThrown + ')');
                $('#pwhint_working').css('display','none');
                $('#pwhint_error').css('display','');
        }      
  }
});    
}
});            
</script>

HTML Form mit den Inputs:

                <table>
                        <tr>
                                <td>Old password:</td>
                                <td><input type=password name=password_old size=60 /></td>
                        </tr>
                        <tr>
                                <td>New Password:</td>
                                <td><input id='pwedit' type=password name=password_new size=60 /></td>
                                <td>
                                        <span id="pwhint_working" style="display: none;"><img src="/24px-spinner-black.gif"> Checking password, please wait.</span>
                                        <span id="pwhint_pwnd" style="background-color: #ff9999; display: none;">This password has been pwned and is not allowed.</span>
                                        <span id="pwhint_ok" style="display: none;">This password has not previously been pwned. (But that does not mean it is a good password)</span>
                                        <span id="pwhint_error" style="display: none;">There was an error checking your password :-(</span>
                                </td>
                        </tr>
                        <tr>
                                <td>Repeat New Password:</td>
                                <td><input type=password name=password_repeat size=60 /></td>
                        </tr>
                </table>