1import base64
2import binascii
3import functools
4import hashlib
5import hmac
6import importlib
7import math
8import warnings
9
10from plain.exceptions import ImproperlyConfigured
11from plain.runtime import settings
12from plain.utils.crypto import (
13 RANDOM_STRING_CHARS,
14 get_random_string,
15 pbkdf2,
16)
17from plain.utils.encoding import force_bytes
18from plain.utils.module_loading import import_string
19
20
21def check_password(password, encoded, setter=None, preferred="default"):
22 """
23 Return a boolean of whether the raw password matches the three
24 part encoded digest.
25
26 If setter is specified, it'll be called when you need to
27 regenerate the password.
28 """
29 if not password:
30 return False
31
32 preferred = get_hasher(preferred)
33 try:
34 hasher = identify_hasher(encoded)
35 except ValueError:
36 # encoded is gibberish or uses a hasher that's no longer installed.
37 return False
38
39 hasher_changed = hasher.algorithm != preferred.algorithm
40 must_update = hasher_changed or preferred.must_update(encoded)
41 is_correct = hasher.verify(password, encoded)
42
43 # If the hasher didn't change (we don't protect against enumeration if it
44 # does) and the password should get updated, try to close the timing gap
45 # between the work factor of the current encoded password and the default
46 # work factor.
47 if not is_correct and not hasher_changed and must_update:
48 hasher.harden_runtime(password, encoded)
49
50 if setter and is_correct and must_update:
51 setter(password)
52 return is_correct
53
54
55def hash_password(password, salt=None, hasher="default"):
56 """
57 Turn a plain-text password into a hash for database storage
58
59 Same as encode() but generate a new random salt. If password is None then
60 return a concatenation of UNUSABLE_PASSWORD_PREFIX and a random string,
61 which disallows logins. Additional random string reduces chances of gaining
62 access to admin or superuser accounts. See ticket #20079 for more info.
63 """
64 if not isinstance(password, bytes | str):
65 raise TypeError(
66 f"Password must be a string or bytes, got {type(password).__qualname__}."
67 )
68 hasher = get_hasher(hasher)
69 salt = salt or hasher.salt()
70 return hasher.encode(password, salt)
71
72
73@functools.lru_cache
74def get_hashers():
75 hashers = []
76 for hasher_path in settings.PASSWORD_HASHERS:
77 hasher_cls = import_string(hasher_path)
78 hasher = hasher_cls()
79 if not getattr(hasher, "algorithm"):
80 raise ImproperlyConfigured(
81 f"hasher doesn't specify an algorithm name: {hasher_path}"
82 )
83 hashers.append(hasher)
84 return hashers
85
86
87@functools.lru_cache
88def get_hashers_by_algorithm():
89 return {hasher.algorithm: hasher for hasher in get_hashers()}
90
91
92def get_hasher(algorithm="default"):
93 """
94 Return an instance of a loaded password hasher.
95
96 If algorithm is 'default', return the default hasher. Lazily import hashers
97 specified in the project's settings file if needed.
98 """
99 if hasattr(algorithm, "algorithm"):
100 return algorithm
101
102 elif algorithm == "default":
103 return get_hashers()[0]
104
105 else:
106 hashers = get_hashers_by_algorithm()
107 try:
108 return hashers[algorithm]
109 except KeyError:
110 raise ValueError(
111 f"Unknown password hashing algorithm '{algorithm}'. "
112 "Did you specify it in the PASSWORD_HASHERS "
113 "setting?"
114 )
115
116
117def identify_hasher(encoded):
118 """
119 Return an instance of a loaded password hasher.
120
121 Identify hasher algorithm by examining encoded hash, and call
122 get_hasher() to return hasher. Raise ValueError if
123 algorithm cannot be identified, or if hasher is not loaded.
124 """
125 # Ancient versions of Plain created plain MD5 passwords and accepted
126 # MD5 passwords with an empty salt.
127 if (len(encoded) == 32 and "$" not in encoded) or (
128 len(encoded) == 37 and encoded.startswith("md5$$")
129 ):
130 algorithm = "unsalted_md5"
131 # Ancient versions of Plain accepted SHA1 passwords with an empty salt.
132 elif len(encoded) == 46 and encoded.startswith("sha1$$"):
133 algorithm = "unsalted_sha1"
134 else:
135 algorithm = encoded.split("$", 1)[0]
136 return get_hasher(algorithm)
137
138
139def mask_hash(hash, show=6, char="*"):
140 """
141 Return the given hash, with only the first ``show`` number shown. The
142 rest are masked with ``char`` for security reasons.
143 """
144 masked = hash[:show]
145 masked += char * len(hash[show:])
146 return masked
147
148
149def must_update_salt(salt, expected_entropy):
150 # Each character in the salt provides log_2(len(alphabet)) bits of entropy.
151 return len(salt) * math.log2(len(RANDOM_STRING_CHARS)) < expected_entropy
152
153
154class BasePasswordHasher:
155 """
156 Abstract base class for password hashers
157
158 When creating your own hasher, you need to override algorithm,
159 verify(), encode() and safe_summary().
160
161 PasswordHasher objects are immutable.
162 """
163
164 algorithm = None
165 library = None
166 salt_entropy = 128
167
168 def _load_library(self):
169 if self.library is not None:
170 if isinstance(self.library, tuple | list):
171 name, mod_path = self.library
172 else:
173 mod_path = self.library
174 try:
175 module = importlib.import_module(mod_path)
176 except ImportError as e:
177 raise ValueError(
178 f"Couldn't load {self.__class__.__name__!r} algorithm library: {e}"
179 )
180 return module
181 raise ValueError(
182 f"Hasher {self.__class__.__name__!r} doesn't specify a library attribute"
183 )
184
185 def salt(self):
186 """
187 Generate a cryptographically secure nonce salt in ASCII with an entropy
188 of at least `salt_entropy` bits.
189 """
190 # Each character in the salt provides
191 # log_2(len(alphabet)) bits of entropy.
192 char_count = math.ceil(self.salt_entropy / math.log2(len(RANDOM_STRING_CHARS)))
193 return get_random_string(char_count, allowed_chars=RANDOM_STRING_CHARS)
194
195 def verify(self, password, encoded):
196 """Check if the given password is correct."""
197 raise NotImplementedError(
198 "subclasses of BasePasswordHasher must provide a verify() method"
199 )
200
201 def _check_encode_args(self, password, salt):
202 if password is None:
203 raise TypeError("password must be provided.")
204 if not salt or "$" in salt:
205 raise ValueError("salt must be provided and cannot contain $.")
206
207 def encode(self, password, salt):
208 """
209 Create an encoded database value.
210
211 The result is normally formatted as "algorithm$salt$hash" and
212 must be fewer than 128 characters.
213 """
214 raise NotImplementedError(
215 "subclasses of BasePasswordHasher must provide an encode() method"
216 )
217
218 def decode(self, encoded):
219 """
220 Return a decoded database value.
221
222 The result is a dictionary and should contain `algorithm`, `hash`, and
223 `salt`. Extra keys can be algorithm specific like `iterations` or
224 `work_factor`.
225 """
226 raise NotImplementedError(
227 "subclasses of BasePasswordHasher must provide a decode() method."
228 )
229
230 def safe_summary(self, encoded):
231 """
232 Return a summary of safe values.
233
234 The result is a dictionary and will be used where the password field
235 must be displayed to construct a safe representation of the password.
236 """
237 raise NotImplementedError(
238 "subclasses of BasePasswordHasher must provide a safe_summary() method"
239 )
240
241 def must_update(self, encoded):
242 return False
243
244 def harden_runtime(self, password, encoded):
245 """
246 Bridge the runtime gap between the work factor supplied in `encoded`
247 and the work factor suggested by this hasher.
248
249 Taking PBKDF2 as an example, if `encoded` contains 20000 iterations and
250 `self.iterations` is 30000, this method should run password through
251 another 10000 iterations of PBKDF2. Similar approaches should exist
252 for any hasher that has a work factor. If not, this method should be
253 defined as a no-op to silence the warning.
254 """
255 warnings.warn(
256 "subclasses of BasePasswordHasher should provide a harden_runtime() method"
257 )
258
259
260class PBKDF2PasswordHasher(BasePasswordHasher):
261 """
262 Secure password hashing using the PBKDF2 algorithm (recommended)
263
264 Configured to use PBKDF2 + HMAC + SHA256.
265 The result is a 64 byte binary string. Iterations may be changed
266 safely but you must rename the algorithm if you change SHA256.
267 """
268
269 algorithm = "pbkdf2_sha256"
270 iterations = 720000
271 digest = hashlib.sha256
272
273 def encode(self, password, salt, iterations=None):
274 self._check_encode_args(password, salt)
275 iterations = iterations or self.iterations
276 hash = pbkdf2(password, salt, iterations, digest=self.digest)
277 hash = base64.b64encode(hash).decode("ascii").strip()
278 return "%s$%d$%s$%s" % (self.algorithm, iterations, salt, hash) # noqa: UP031
279
280 def decode(self, encoded):
281 algorithm, iterations, salt, hash = encoded.split("$", 3)
282 assert algorithm == self.algorithm
283 return {
284 "algorithm": algorithm,
285 "hash": hash,
286 "iterations": int(iterations),
287 "salt": salt,
288 }
289
290 def verify(self, password, encoded):
291 decoded = self.decode(encoded)
292 encoded_2 = self.encode(password, decoded["salt"], decoded["iterations"])
293 return hmac.compare_digest(force_bytes(encoded), force_bytes(encoded_2))
294
295 def safe_summary(self, encoded):
296 decoded = self.decode(encoded)
297 return {
298 "algorithm": decoded["algorithm"],
299 "iterations": decoded["iterations"],
300 "salt": mask_hash(decoded["salt"]),
301 "hash": mask_hash(decoded["hash"]),
302 }
303
304 def must_update(self, encoded):
305 decoded = self.decode(encoded)
306 update_salt = must_update_salt(decoded["salt"], self.salt_entropy)
307 return (decoded["iterations"] != self.iterations) or update_salt
308
309 def harden_runtime(self, password, encoded):
310 decoded = self.decode(encoded)
311 extra_iterations = self.iterations - decoded["iterations"]
312 if extra_iterations > 0:
313 self.encode(password, decoded["salt"], extra_iterations)
314
315
316class PBKDF2SHA1PasswordHasher(PBKDF2PasswordHasher):
317 """
318 Alternate PBKDF2 hasher which uses SHA1, the default PRF
319 recommended by PKCS #5. This is compatible with other
320 implementations of PBKDF2, such as openssl's
321 PKCS5_PBKDF2_HMAC_SHA1().
322 """
323
324 algorithm = "pbkdf2_sha1"
325 digest = hashlib.sha1
326
327
328class Argon2PasswordHasher(BasePasswordHasher):
329 """
330 Secure password hashing using the argon2 algorithm.
331
332 This is the winner of the Password Hashing Competition 2013-2015
333 (https://password-hashing.net). It requires the argon2-cffi library which
334 depends on native C code and might cause portability issues.
335 """
336
337 algorithm = "argon2"
338 library = "argon2"
339
340 time_cost = 2
341 memory_cost = 102400
342 parallelism = 8
343
344 def encode(self, password, salt):
345 argon2 = self._load_library()
346 params = self.params()
347 data = argon2.low_level.hash_secret(
348 password.encode(),
349 salt.encode(),
350 time_cost=params.time_cost,
351 memory_cost=params.memory_cost,
352 parallelism=params.parallelism,
353 hash_len=params.hash_len,
354 type=params.type,
355 )
356 return self.algorithm + data.decode("ascii")
357
358 def decode(self, encoded):
359 argon2 = self._load_library()
360 algorithm, rest = encoded.split("$", 1)
361 assert algorithm == self.algorithm
362 params = argon2.extract_parameters("$" + rest)
363 variety, *_, b64salt, hash = rest.split("$")
364 # Add padding.
365 b64salt += "=" * (-len(b64salt) % 4)
366 salt = base64.b64decode(b64salt).decode("latin1")
367 return {
368 "algorithm": algorithm,
369 "hash": hash,
370 "memory_cost": params.memory_cost,
371 "parallelism": params.parallelism,
372 "salt": salt,
373 "time_cost": params.time_cost,
374 "variety": variety,
375 "version": params.version,
376 "params": params,
377 }
378
379 def verify(self, password, encoded):
380 argon2 = self._load_library()
381 algorithm, rest = encoded.split("$", 1)
382 assert algorithm == self.algorithm
383 try:
384 return argon2.PasswordHasher().verify("$" + rest, password)
385 except argon2.exceptions.VerificationError:
386 return False
387
388 def safe_summary(self, encoded):
389 decoded = self.decode(encoded)
390 return {
391 "algorithm": decoded["algorithm"],
392 "variety": decoded["variety"],
393 "version": decoded["version"],
394 "memory cost": decoded["memory_cost"],
395 "time cost": decoded["time_cost"],
396 "parallelism": decoded["parallelism"],
397 "salt": mask_hash(decoded["salt"]),
398 "hash": mask_hash(decoded["hash"]),
399 }
400
401 def must_update(self, encoded):
402 decoded = self.decode(encoded)
403 current_params = decoded["params"]
404 new_params = self.params()
405 # Set salt_len to the salt_len of the current parameters because salt
406 # is explicitly passed to argon2.
407 new_params.salt_len = current_params.salt_len
408 update_salt = must_update_salt(decoded["salt"], self.salt_entropy)
409 return (current_params != new_params) or update_salt
410
411 def harden_runtime(self, password, encoded):
412 # The runtime for Argon2 is too complicated to implement a sensible
413 # hardening algorithm.
414 pass
415
416 def params(self):
417 argon2 = self._load_library()
418 # salt_len is a noop, because we provide our own salt.
419 return argon2.Parameters(
420 type=argon2.low_level.Type.ID,
421 version=argon2.low_level.ARGON2_VERSION,
422 salt_len=argon2.DEFAULT_RANDOM_SALT_LENGTH,
423 hash_len=argon2.DEFAULT_HASH_LENGTH,
424 time_cost=self.time_cost,
425 memory_cost=self.memory_cost,
426 parallelism=self.parallelism,
427 )
428
429
430class BCryptSHA256PasswordHasher(BasePasswordHasher):
431 """
432 Secure password hashing using the bcrypt algorithm (recommended)
433
434 This is considered by many to be the most secure algorithm but you
435 must first install the bcrypt library. Please be warned that
436 this library depends on native C code and might cause portability
437 issues.
438 """
439
440 algorithm = "bcrypt_sha256"
441 digest = hashlib.sha256
442 library = ("bcrypt", "bcrypt")
443 rounds = 12
444
445 def salt(self):
446 bcrypt = self._load_library()
447 return bcrypt.gensalt(self.rounds)
448
449 def encode(self, password, salt):
450 bcrypt = self._load_library()
451 password = password.encode()
452 # Hash the password prior to using bcrypt to prevent password
453 # truncation as described in #20138.
454 if self.digest is not None:
455 # Use binascii.hexlify() because a hex encoded bytestring is str.
456 password = binascii.hexlify(self.digest(password).digest())
457
458 data = bcrypt.hashpw(password, salt)
459 return "{}${}".format(self.algorithm, data.decode("ascii"))
460
461 def decode(self, encoded):
462 algorithm, empty, algostr, work_factor, data = encoded.split("$", 4)
463 assert algorithm == self.algorithm
464 return {
465 "algorithm": algorithm,
466 "algostr": algostr,
467 "checksum": data[22:],
468 "salt": data[:22],
469 "work_factor": int(work_factor),
470 }
471
472 def verify(self, password, encoded):
473 algorithm, data = encoded.split("$", 1)
474 assert algorithm == self.algorithm
475 encoded_2 = self.encode(password, data.encode("ascii"))
476 return hmac.compare_digest(force_bytes(encoded), force_bytes(encoded_2))
477
478 def safe_summary(self, encoded):
479 decoded = self.decode(encoded)
480 return {
481 "algorithm": decoded["algorithm"],
482 "work factor": decoded["work_factor"],
483 "salt": mask_hash(decoded["salt"]),
484 "checksum": mask_hash(decoded["checksum"]),
485 }
486
487 def must_update(self, encoded):
488 decoded = self.decode(encoded)
489 return decoded["work_factor"] != self.rounds
490
491 def harden_runtime(self, password, encoded):
492 _, data = encoded.split("$", 1)
493 salt = data[:29] # Length of the salt in bcrypt.
494 rounds = data.split("$")[2]
495 # work factor is logarithmic, adding one doubles the load.
496 diff = 2 ** (self.rounds - int(rounds)) - 1
497 while diff > 0:
498 self.encode(password, salt.encode("ascii"))
499 diff -= 1
500
501
502class BCryptPasswordHasher(BCryptSHA256PasswordHasher):
503 """
504 Secure password hashing using the bcrypt algorithm
505
506 This is considered by many to be the most secure algorithm but you
507 must first install the bcrypt library. Please be warned that
508 this library depends on native C code and might cause portability
509 issues.
510
511 This hasher does not first hash the password which means it is subject to
512 bcrypt's 72 bytes password truncation. Most use cases should prefer the
513 BCryptSHA256PasswordHasher.
514 """
515
516 algorithm = "bcrypt"
517 digest = None
518
519
520class ScryptPasswordHasher(BasePasswordHasher):
521 """
522 Secure password hashing using the Scrypt algorithm.
523 """
524
525 algorithm = "scrypt"
526 block_size = 8
527 maxmem = 0
528 parallelism = 1
529 work_factor = 2**14
530
531 def encode(self, password, salt, n=None, r=None, p=None):
532 self._check_encode_args(password, salt)
533 n = n or self.work_factor
534 r = r or self.block_size
535 p = p or self.parallelism
536 hash_ = hashlib.scrypt(
537 password.encode(),
538 salt=salt.encode(),
539 n=n,
540 r=r,
541 p=p,
542 maxmem=self.maxmem,
543 dklen=64,
544 )
545 hash_ = base64.b64encode(hash_).decode("ascii").strip()
546 return "%s$%d$%s$%d$%d$%s" % (self.algorithm, n, salt, r, p, hash_) # noqa: UP031
547
548 def decode(self, encoded):
549 algorithm, work_factor, salt, block_size, parallelism, hash_ = encoded.split(
550 "$", 6
551 )
552 assert algorithm == self.algorithm
553 return {
554 "algorithm": algorithm,
555 "work_factor": int(work_factor),
556 "salt": salt,
557 "block_size": int(block_size),
558 "parallelism": int(parallelism),
559 "hash": hash_,
560 }
561
562 def verify(self, password, encoded):
563 decoded = self.decode(encoded)
564 encoded_2 = self.encode(
565 password,
566 decoded["salt"],
567 decoded["work_factor"],
568 decoded["block_size"],
569 decoded["parallelism"],
570 )
571 return hmac.compare_digest(force_bytes(encoded), force_bytes(encoded_2))
572
573 def safe_summary(self, encoded):
574 decoded = self.decode(encoded)
575 return {
576 "algorithm": decoded["algorithm"],
577 "work factor": decoded["work_factor"],
578 "block size": decoded["block_size"],
579 "parallelism": decoded["parallelism"],
580 "salt": mask_hash(decoded["salt"]),
581 "hash": mask_hash(decoded["hash"]),
582 }
583
584 def must_update(self, encoded):
585 decoded = self.decode(encoded)
586 return (
587 decoded["work_factor"] != self.work_factor
588 or decoded["block_size"] != self.block_size
589 or decoded["parallelism"] != self.parallelism
590 )
591
592 def harden_runtime(self, password, encoded):
593 # The runtime for Scrypt is too complicated to implement a sensible
594 # hardening algorithm.
595 pass