From 26b1d4dd9b6ab0cc91d191df37d4c3a72474cde2 Mon Sep 17 00:00:00 2001 From: Deimos Date: Tue, 11 Aug 2020 18:00:10 -0600 Subject: [PATCH] Use pts_lbsearch to check for breached passwords This replaces the current method of using a Bloom filter in Redis to check for breached passwords with searching the text file directly using pts_lbsearch (https://github.com/pts/pts-line-bisect/). I'm not removing the Redis-based method yet because I want to test the performance of this first, but this is *far* simpler and doesn't have the possibility for false positives like the Bloom filter does. --- salt/salt/pts-lbsearch.sls | 12 ++++++++++++ salt/salt/top.sls | 1 + tildes/production.ini.example | 9 +++++++++ tildes/tildes/__init__.py | 1 + tildes/tildes/lib/password.py | 34 +++++++++++++++++++++++----------- tildes/tildes/settings.py | 30 ++++++++++++++++++++++++++++++ 6 files changed, 76 insertions(+), 11 deletions(-) create mode 100644 salt/salt/pts-lbsearch.sls create mode 100644 tildes/tildes/settings.py diff --git a/salt/salt/pts-lbsearch.sls b/salt/salt/pts-lbsearch.sls new file mode 100644 index 0000000..d09686a --- /dev/null +++ b/salt/salt/pts-lbsearch.sls @@ -0,0 +1,12 @@ +compile-pts-lbsearch: + file.managed: + - name: /tmp/pts_lbsearch.c + - source: + - https://raw.githubusercontent.com/pts/pts-line-bisect/2ecd9f59246cfa28cb1aeac7cd8d98a8eea2914f/pts_lbsearch.c + - source_hash: sha256=ef79efc2f1ecde504b6074f9c89bdc71259a833fa2a2dda4538ed5ea3e04aea1 + - creates: /usr/local/bin/pts_lbsearch + cmd.run: + - cwd: /tmp/ + # compilation command taken from the top of the source file + - name: gcc -ansi -W -Wall -Wextra -Werror=missing-declarations -s -O2 -DNDEBUG -o /usr/local/bin/pts_lbsearch pts_lbsearch.c + - creates: /usr/local/bin/pts_lbsearch diff --git a/salt/salt/top.sls b/salt/salt/top.sls index 5c10676..75cc6ac 100644 --- a/salt/salt/top.sls +++ b/salt/salt/top.sls @@ -22,6 +22,7 @@ base: - tildes-wiki - boussole - webassets + - pts-lbsearch - cronjobs - final-setup # keep this state file last 'dev': diff --git a/tildes/production.ini.example b/tildes/production.ini.example index 3ff2137..3c7ad68 100644 --- a/tildes/production.ini.example +++ b/tildes/production.ini.example @@ -27,6 +27,15 @@ stripe.recurring_donation_product_id = prod_ProductID tildes.default_user_comment_label_weight = 1.0 +# Path to the file to use to check for passwords that have been in data breaches, which +# users will be prevented from using as their password. It's recommended to use the +# "Pwned Passwords" list downloaded from https://haveibeenpwned.com/passwords (must be +# the SHA-1 format, "ordered by hash" one), but you can use any file with a compatible +# format: each line starting with a single uppercase SHA-1 hash of a password to block, +# with the entire file sorted in lexographical order. +# Leave this line commented out to allow all passwords. +# tildes.breached_passwords_hash_file_path = /opt/tildes/pwned-passwords-sha1-ordered-by-hash-v6.txt + webassets.auto_build = false webassets.base_dir = %(here)s/static webassets.base_url = / diff --git a/tildes/tildes/__init__.py b/tildes/tildes/__init__.py index d740729..1997d97 100644 --- a/tildes/tildes/__init__.py +++ b/tildes/tildes/__init__.py @@ -28,6 +28,7 @@ def main(global_config: Dict[str, str], **settings: str) -> PrefixMiddleware: config.include("tildes.json") config.include("tildes.request_methods") config.include("tildes.routes") + config.include("tildes.settings") config.include("tildes.tweens") config.add_webasset("javascript", Bundle(output="js/tildes.js")) diff --git a/tildes/tildes/lib/password.py b/tildes/tildes/lib/password.py index 967d23c..7d9206f 100644 --- a/tildes/tildes/lib/password.py +++ b/tildes/tildes/lib/password.py @@ -3,10 +3,10 @@ """Functions/constants related to user passwords.""" +import subprocess from hashlib import sha1 -from redis import ConnectionError, Redis, ResponseError # noqa - +from tildes import settings from tildes.metrics import summary_timer @@ -19,16 +19,28 @@ BREACHED_PASSWORDS_BF_KEY = "breached_passwords_bloom" @summary_timer("breached_password_check") def is_breached_password(password: str) -> bool: - """Return whether the password is in the breached-passwords list.""" - redis = Redis(unix_socket_path=BREACHED_PASSWORDS_REDIS_SOCKET) + """Return whether the password is in the breached-passwords list. + + Note: this function uses a binary-search utility on the breached-passwords file, so + the file's format is not flexible. Each line of the file must begin with a single + uppercase SHA-1 hash corresponding to a password that should be blocked, and the + lines must be sorted in lexographical order. + + This is specifically intended for use with a "Pwned Passwords" list downloaded from + https://haveibeenpwned.com/passwords (SHA-1 format, "ordered by hash"), but any + other file with a compatible format will also work. + """ + try: + hash_list_path = settings.INI_FILE_SETTINGS["breached_passwords_hash_file_path"] + except KeyError: + return False - hashed = sha1(password.encode("utf-8")).hexdigest() + hashed = sha1(password.encode("utf-8")).hexdigest().upper() + # call pts_lbsearch in "prefix search" mode - exit code 0 means it found a match try: - return bool( - redis.execute_command("BF.EXISTS", BREACHED_PASSWORDS_BF_KEY, hashed) - ) - except (ConnectionError, ResponseError): - # server isn't running, bloom filter doesn't exist or the key is a different - # data type + subprocess.run(["pts_lbsearch", "-p", hash_list_path, hashed], check=True) + except subprocess.CalledProcessError: return False + + return True diff --git a/tildes/tildes/settings.py b/tildes/tildes/settings.py new file mode 100644 index 0000000..146394c --- /dev/null +++ b/tildes/tildes/settings.py @@ -0,0 +1,30 @@ +# Copyright (c) 2020 Tildes contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Global-like settings for the application. + +This module should always be imported as a whole ("from tildes import settings"), not +importing individual names, since that will cause re-initialization. + +Currently, this module only contains a dict with some of the settings defined in the +INI file, specifically ones with the "tildes." prefix. The values in this dict are +initialized during app startup. + +Important note: this module may be a terrible idea and I may regret this. +""" + +from pyramid.config import Configurator + +INI_FILE_SETTINGS = {} + + +def includeme(config: Configurator) -> None: + """Initialize ini_file_settings with all prefixed settings from the INI file.""" + global INI_FILE_SETTINGS # pylint: disable=global-statement + setting_prefix = "tildes." + + INI_FILE_SETTINGS = { + setting[len(setting_prefix) :]: value + for setting, value in config.get_settings().items() + if setting.startswith(setting_prefix) + }