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 )
@@ -337,6 +349,16 @@ def safe_summary(self, encoded):
337349 (_ ('checksum' ), mask_hash (checksum )),
338350 ])
339351
352+ def harden_runtime (self , password , encoded ):
353+ _ , data = encoded .split ('$' , 1 )
354+ salt = data [:29 ] # Length of the salt in bcrypt.
355+ rounds = data .split ('$' )[2 ]
356+ # work factor is logarithmic, adding one doubles the load.
357+ diff = 2 ** (self .rounds - int (rounds )) - 1
358+ while diff > 0 :
359+ self .encode (password , force_bytes (salt ))
360+ diff -= 1
361+
340362
341363class BCryptPasswordHasher (BCryptSHA256PasswordHasher ):
342364 """
@@ -384,6 +406,9 @@ def safe_summary(self, encoded):
384406 (_ ('hash' ), mask_hash (hash )),
385407 ])
386408
409+ def harden_runtime (self , password , encoded ):
410+ pass
411+
387412
388413class MD5PasswordHasher (BasePasswordHasher ):
389414 """
@@ -412,6 +437,9 @@ def safe_summary(self, encoded):
412437 (_ ('hash' ), mask_hash (hash )),
413438 ])
414439
440+ def harden_runtime (self , password , encoded ):
441+ pass
442+
415443
416444class UnsaltedSHA1PasswordHasher (BasePasswordHasher ):
417445 """
@@ -444,6 +472,9 @@ def safe_summary(self, encoded):
444472 (_ ('hash' ), mask_hash (hash )),
445473 ])
446474
475+ def harden_runtime (self , password , encoded ):
476+ pass
477+
447478
448479class UnsaltedMD5PasswordHasher (BasePasswordHasher ):
449480 """
@@ -477,6 +508,9 @@ def safe_summary(self, encoded):
477508 (_ ('hash' ), mask_hash (encoded , show = 3 )),
478509 ])
479510
511+ def harden_runtime (self , password , encoded ):
512+ pass
513+
480514
481515class CryptPasswordHasher (BasePasswordHasher ):
482516 """
@@ -511,3 +545,6 @@ def safe_summary(self, encoded):
511545 (_ ('salt' ), salt ),
512546 (_ ('hash' ), mask_hash (data , show = 3 )),
513547 ])
548+
549+ def harden_runtime (self , password , encoded ):
550+ pass
0 commit comments