From a311805c8598232b14a40a561bb4dc9528e707ee Mon Sep 17 00:00:00 2001 From: Neil Williams Date: Thu, 20 Oct 2011 11:31:01 -0700 Subject: [PATCH] Switch to bcrypt for password hashing. Transparently upgrades passwords on next login. --- r2/example.ini | 3 +++ r2/r2/lib/app_globals.py | 1 + r2/r2/models/account.py | 54 +++++++++++++++++++++++++++------------- r2/setup.py | 1 + 4 files changed, 42 insertions(+), 17 deletions(-) diff --git a/r2/example.ini b/r2/example.ini index 464e577c3..7661ecf48 100755 --- a/r2/example.ini +++ b/r2/example.ini @@ -91,6 +91,9 @@ monitored_servers = reddit, localhost https_endpoint = # name of the cookie to drop with login information login_cookie = reddit_session +# the work factor for bcrypt, increment this every time computers double in +# speed. don't worry, changing this won't break old passwords +bcrypt_work_factor = 12 # fraction of requests to pass into the queue-based usage sampler usage_sampling = 0. diff --git a/r2/r2/lib/app_globals.py b/r2/r2/lib/app_globals.py index 34f9a7da6..0ebde4812 100755 --- a/r2/r2/lib/app_globals.py +++ b/r2/r2/lib/app_globals.py @@ -66,6 +66,7 @@ class Globals(object): 'sr_dropdown_threshold', 'comment_visits_period', 'min_membership_create_community', + 'bcrypt_work_factor', ] float_props = ['min_promote_bid', diff --git a/r2/r2/models/account.py b/r2/r2/models/account.py index 214e25008..31003be5b 100644 --- a/r2/r2/models/account.py +++ b/r2/r2/models/account.py @@ -27,12 +27,14 @@ from r2.lib.utils import modhash, valid_hash, randstr, timefromnow from r2.lib.utils import UrlParser, set_last_visit, last_visit from r2.lib.utils import constant_time_compare from r2.lib.cache import sgm +from r2.lib import filters from r2.lib.log import log_text from pylons import g import time, sha from copy import copy from datetime import datetime, timedelta +import bcrypt class AccountExists(Exception): pass @@ -601,22 +603,40 @@ def valid_login(name, password): return valid_password(a, password) def valid_password(a, password): - try: - # A constant_time_compare isn't strictly required here - # but it is doesn't hurt - if constant_time_compare(a.password, passhash(a.name, password, '')): - #add a salt - a.password = passhash(a.name, password, True) - a._commit() - return a - else: - salt = a.password[:3] - if constant_time_compare(a.password, passhash(a.name, password, salt)): - return a - except AttributeError, UnicodeEncodeError: + # bail out early if the account or password's invalid + if not hasattr(a, 'name') or not hasattr(a, 'password') or not password: return False - # Python defaults to returning None - return False + + # standardize on utf-8 encoding + password = filters._force_utf8(password) + + # this is really easy if it's a sexy bcrypt password + if a.password.startswith('$2a$'): + expected_hash = bcrypt.hashpw(password, a.password) + if constant_time_compare(a.password, expected_hash): + return a + return False + + # alright, so it's not bcrypt. how old is it? + # if the length of the stored hash is 43 bytes, the sha-1 hash has a salt + # otherwise it's sha-1 with no salt. + salt = '' + if len(a.password) == 43: + salt = a.password[:3] + expected_hash = passhash(a.name, password, salt) + + if not constant_time_compare(a.password, expected_hash): + return False + + # since we got this far, it's a valid password but in an old format + # let's upgrade it + a.password = bcrypt_password(password) + a._commit() + return a + +def bcrypt_password(password): + salt = bcrypt.gensalt(log_rounds=g.bcrypt_work_factor) + return bcrypt.hashpw(password, salt) def passhash(username, password, salt = ''): if salt is True: @@ -625,7 +645,7 @@ def passhash(username, password, salt = ''): return salt + sha.new(tohash).hexdigest() def change_password(user, newpassword): - user.password = passhash(user.name, newpassword, True) + user.password = bcrypt_password(newpassword) user._commit() return True @@ -636,7 +656,7 @@ def register(name, password): raise AccountExists except NotFound: a = Account(name = name, - password = passhash(name, password, True)) + password = bcrypt_password(password)) # new accounts keep the profanity filter settings until opting out a.pref_no_profanity = True a._commit() diff --git a/r2/setup.py b/r2/setup.py index 4d382a4b3..08b1fe143 100644 --- a/r2/setup.py +++ b/r2/setup.py @@ -80,6 +80,7 @@ setup( "pycaptcha", "amqplib", "pylibmc==1.2.1-dev", + "py-bcrypt", ], dependency_links=[ "https://github.com/downloads/reddit/pylibmc/pylibmc-1.2.1-dev.tar.gz#egg=pylibmc-1.2.1-dev",