44import binascii
55import hashlib
66import importlib
7+ import warnings
78from collections import OrderedDict
89
910from django .conf import settings
@@ -46,10 +47,17 @@ def check_password(password, encoded, setter=None, preferred='default'):
4647 preferred = get_hasher (preferred )
4748 hasher = identify_hasher (encoded )
4849
49- must_update = hasher .algorithm != preferred .algorithm
50- if not must_update :
51- must_update = preferred .must_update (encoded )
50+ hasher_changed = hasher .algorithm != preferred .algorithm
51+ must_update = hasher_changed or preferred .must_update (encoded )
5252 is_correct = hasher .verify (password , encoded )
53+
54+ # If the hasher didn't change (we don't protect against enumeration if it
55+ # does) and the password should get updated, try to close the timing gap
56+ # between the work factor of the current encoded password and the default
57+ # work factor.
58+ if not is_correct and not hasher_changed and must_update :
59+ hasher .harden_runtime (password , encoded )
60+
5361 if setter and is_correct and must_update :
5462 setter (password )
5563 return is_correct
@@ -216,6 +224,19 @@ def safe_summary(self, encoded):
216224 def must_update (self , encoded ):
217225 return False
218226
227+ def harden_runtime (self , password , encoded ):
228+ """
229+ Bridge the runtime gap between the work factor supplied in `encoded`
230+ and the work factor suggested by this hasher.
231+
232+ Taking PBKDF2 as an example, if `encoded` contains 20000 iterations and
233+ `self.iterations` is 30000, this method should run password through
234+ another 10000 iterations of PBKDF2. Similar approaches should exist
235+ for any hasher that has a work factor. If not, this method should be
236+ defined as a no-op to silence the warning.
237+ """
238+ warnings .warn ('subclasses of BasePasswordHasher should provide a harden_runtime() method' )
239+
219240
220241class PBKDF2PasswordHasher (BasePasswordHasher ):
221242 """
@@ -258,6 +279,12 @@ def must_update(self, encoded):
258279 algorithm , iterations , salt , hash = encoded .split ('$' , 3 )
259280 return int (iterations ) != self .iterations
260281
282+ def harden_runtime (self , password , encoded ):
283+ algorithm , iterations , salt , hash = encoded .split ('$' , 3 )
284+ extra_iterations = self .iterations - int (iterations )
285+ if extra_iterations > 0 :
286+ self .encode (password , salt , extra_iterations )
287+
261288
262289class PBKDF2SHA1PasswordHasher (PBKDF2PasswordHasher ):
263290 """
@@ -308,23 +335,8 @@ def encode(self, password, salt):
308335 def verify (self , password , encoded ):
309336 algorithm , data = encoded .split ('$' , 1 )
310337 assert algorithm == self .algorithm
311- bcrypt = self ._load_library ()
312-
313- # Hash the password prior to using bcrypt to prevent password truncation
314- # See: https://code.djangoproject.com/ticket/20138
315- if self .digest is not None :
316- # We use binascii.hexlify here because Python3 decided that a hex encoded
317- # bytestring is somehow a unicode.
318- password = binascii .hexlify (self .digest (force_bytes (password )).digest ())
319- else :
320- password = force_bytes (password )
321-
322- # Ensure that our data is a bytestring
323- data = force_bytes (data )
324- # force_bytes() necessary for py-bcrypt compatibility
325- hashpw = force_bytes (bcrypt .hashpw (password , data ))
326-
327- return constant_time_compare (data , hashpw )
338+ encoded_2 = self .encode (password , force_bytes (data ))
339+ return constant_time_compare (encoded , encoded_2 )
328340
329341 def safe_summary (self , encoded ):
330342 algorithm , empty , algostr , work_factor , data = encoded .split ('$' , 4 )
@@ -341,6 +353,16 @@ def must_update(self, encoded):
341353 algorithm , empty , algostr , rounds , data = encoded .split ('$' , 4 )
342354 return int (rounds ) != self .rounds
343355
356+ def harden_runtime (self , password , encoded ):
357+ _ , data = encoded .split ('$' , 1 )
358+ salt = data [:29 ] # Length of the salt in bcrypt.
359+ rounds = data .split ('$' )[2 ]
360+ # work factor is logarithmic, adding one doubles the load.
361+ diff = 2 ** (self .rounds - int (rounds )) - 1
362+ while diff > 0 :
363+ self .encode (password , force_bytes (salt ))
364+ diff -= 1
365+
344366
345367class BCryptPasswordHasher (BCryptSHA256PasswordHasher ):
346368 """
@@ -388,6 +410,9 @@ def safe_summary(self, encoded):
388410 (_ ('hash' ), mask_hash (hash )),
389411 ])
390412
413+ def harden_runtime (self , password , encoded ):
414+ pass
415+
391416
392417class MD5PasswordHasher (BasePasswordHasher ):
393418 """
@@ -416,6 +441,9 @@ def safe_summary(self, encoded):
416441 (_ ('hash' ), mask_hash (hash )),
417442 ])
418443
444+ def harden_runtime (self , password , encoded ):
445+ pass
446+
419447
420448class UnsaltedSHA1PasswordHasher (BasePasswordHasher ):
421449 """
@@ -448,6 +476,9 @@ def safe_summary(self, encoded):
448476 (_ ('hash' ), mask_hash (hash )),
449477 ])
450478
479+ def harden_runtime (self , password , encoded ):
480+ pass
481+
451482
452483class UnsaltedMD5PasswordHasher (BasePasswordHasher ):
453484 """
@@ -481,6 +512,9 @@ def safe_summary(self, encoded):
481512 (_ ('hash' ), mask_hash (encoded , show = 3 )),
482513 ])
483514
515+ def harden_runtime (self , password , encoded ):
516+ pass
517+
484518
485519class CryptPasswordHasher (BasePasswordHasher ):
486520 """
@@ -515,3 +549,6 @@ def safe_summary(self, encoded):
515549 (_ ('salt' ), salt ),
516550 (_ ('hash' ), mask_hash (data , show = 3 )),
517551 ])
552+
553+ def harden_runtime (self , password , encoded ):
554+ pass
0 commit comments