1from __future__ import annotations
2
3import base64
4import functools
5import hashlib
6import hmac
7import math
8import warnings
9from collections.abc import Callable
10from typing import Any
11
12from plain.exceptions import ImproperlyConfigured
13from plain.runtime import settings
14from plain.utils.crypto import (
15 RANDOM_STRING_CHARS,
16 get_random_string,
17 pbkdf2,
18)
19from plain.utils.encoding import force_bytes
20from plain.utils.module_loading import import_string
21
22
23def check_password(
24 password: str,
25 encoded: str,
26 setter: Callable[[str], None] | None = None,
27 preferred: str | BasePasswordHasher = "default",
28) -> bool:
29 """
30 Return a boolean of whether the raw password matches the three
31 part encoded digest.
32
33 If setter is specified, it'll be called when you need to
34 regenerate the password.
35 """
36 if not password:
37 return False
38
39 preferred = get_hasher(preferred)
40 try:
41 hasher = identify_hasher(encoded)
42 except ValueError:
43 # encoded is gibberish or uses a hasher that's no longer installed.
44 return False
45
46 hasher_changed = hasher.algorithm != preferred.algorithm
47 must_update = hasher_changed or preferred.must_update(encoded)
48 is_correct = hasher.verify(password, encoded)
49
50 # If the hasher didn't change (we don't protect against enumeration if it
51 # does) and the password should get updated, try to close the timing gap
52 # between the work factor of the current encoded password and the default
53 # work factor.
54 if not is_correct and not hasher_changed and must_update:
55 hasher.harden_runtime(password, encoded)
56
57 if setter and is_correct and must_update:
58 setter(password)
59 return is_correct
60
61
62def hash_password(
63 password: str,
64 salt: str | None = None,
65 hasher: str | BasePasswordHasher = "default",
66) -> str:
67 """
68 Turn a plain-text password into a hash for database storage
69
70 Same as encode() but generate a new random salt. If password is None then
71 return a concatenation of UNUSABLE_PASSWORD_PREFIX and a random string,
72 which disallows logins. Additional random string reduces chances of gaining
73 access to admin or superuser accounts. See ticket #20079 for more info.
74 """
75 hasher = get_hasher(hasher)
76 salt = salt or hasher.salt()
77 return hasher.encode(password, salt)
78
79
80@functools.lru_cache
81def get_hashers() -> list[BasePasswordHasher]:
82 hashers = []
83 for hasher_path in settings.PASSWORD_HASHERS:
84 hasher_cls = import_string(hasher_path)
85 hasher = hasher_cls()
86 if not getattr(hasher, "algorithm"):
87 raise ImproperlyConfigured(
88 f"hasher doesn't specify an algorithm name: {hasher_path}"
89 )
90 hashers.append(hasher)
91 return hashers
92
93
94@functools.lru_cache
95def get_hashers_by_algorithm() -> dict[str, BasePasswordHasher]:
96 return {hasher.algorithm: hasher for hasher in get_hashers()}
97
98
99def get_hasher(algorithm: str | BasePasswordHasher = "default") -> BasePasswordHasher:
100 """
101 Return an instance of a loaded password hasher.
102
103 If algorithm is 'default', return the default hasher. Lazily import hashers
104 specified in the project's settings file if needed.
105 """
106 if isinstance(algorithm, BasePasswordHasher):
107 return algorithm
108
109 elif algorithm == "default":
110 return get_hashers()[0]
111
112 else:
113 hashers = get_hashers_by_algorithm()
114 try:
115 return hashers[algorithm]
116 except KeyError:
117 raise ValueError(
118 f"Unknown password hashing algorithm '{algorithm}'. "
119 "Did you specify it in the PASSWORD_HASHERS "
120 "setting?"
121 )
122
123
124def identify_hasher(encoded: str) -> BasePasswordHasher:
125 """
126 Return an instance of a loaded password hasher.
127
128 Identify hasher algorithm by examining encoded hash, and call
129 get_hasher() to return hasher. Raise ValueError if
130 algorithm cannot be identified, or if hasher is not loaded.
131 """
132 # Ancient versions of Plain created plain MD5 passwords and accepted
133 # MD5 passwords with an empty salt.
134 if (len(encoded) == 32 and "$" not in encoded) or (
135 len(encoded) == 37 and encoded.startswith("md5$$")
136 ):
137 algorithm = "unsalted_md5"
138 # Ancient versions of Plain accepted SHA1 passwords with an empty salt.
139 elif len(encoded) == 46 and encoded.startswith("sha1$$"):
140 algorithm = "unsalted_sha1"
141 else:
142 algorithm = encoded.split("$", 1)[0]
143 return get_hasher(algorithm)
144
145
146def mask_hash(hash: str, show: int = 6, char: str = "*") -> str:
147 """
148 Return the given hash, with only the first ``show`` number shown. The
149 rest are masked with ``char`` for security reasons.
150 """
151 masked = hash[:show]
152 masked += char * len(hash[show:])
153 return masked
154
155
156def must_update_salt(salt: str, expected_entropy: int) -> bool:
157 # Each character in the salt provides log_2(len(alphabet)) bits of entropy.
158 return len(salt) * math.log2(len(RANDOM_STRING_CHARS)) < expected_entropy
159
160
161class BasePasswordHasher:
162 """
163 Abstract base class for password hashers
164
165 When creating your own hasher, you need to override algorithm,
166 verify(), encode() and safe_summary().
167
168 PasswordHasher objects are immutable.
169 """
170
171 algorithm: str | None = None
172 salt_entropy: int = 128
173
174 def salt(self) -> str:
175 """
176 Generate a cryptographically secure nonce salt in ASCII with an entropy
177 of at least `salt_entropy` bits.
178 """
179 # Each character in the salt provides
180 # log_2(len(alphabet)) bits of entropy.
181 char_count = math.ceil(self.salt_entropy / math.log2(len(RANDOM_STRING_CHARS)))
182 return get_random_string(char_count, allowed_chars=RANDOM_STRING_CHARS)
183
184 def verify(self, password: str, encoded: str) -> bool:
185 """Check if the given password is correct."""
186 raise NotImplementedError(
187 "subclasses of BasePasswordHasher must provide a verify() method"
188 )
189
190 def _check_encode_args(self, password: str, salt: str) -> None:
191 if password is None:
192 raise TypeError("password must be provided.")
193 if not salt or "$" in salt:
194 raise ValueError("salt must be provided and cannot contain $.")
195
196 def encode(self, password: str, salt: str) -> str:
197 """
198 Create an encoded database value.
199
200 The result is normally formatted as "algorithm$salt$hash" and
201 must be fewer than 128 characters.
202 """
203 raise NotImplementedError(
204 "subclasses of BasePasswordHasher must provide an encode() method"
205 )
206
207 def decode(self, encoded: str) -> dict[str, Any]:
208 """
209 Return a decoded database value.
210
211 The result is a dictionary and should contain `algorithm`, `hash`, and
212 `salt`. Extra keys can be algorithm specific like `iterations` or
213 `work_factor`.
214 """
215 raise NotImplementedError(
216 "subclasses of BasePasswordHasher must provide a decode() method."
217 )
218
219 def safe_summary(self, encoded: str) -> dict[str, Any]:
220 """
221 Return a summary of safe values.
222
223 The result is a dictionary and will be used where the password field
224 must be displayed to construct a safe representation of the password.
225 """
226 raise NotImplementedError(
227 "subclasses of BasePasswordHasher must provide a safe_summary() method"
228 )
229
230 def must_update(self, encoded: str) -> bool:
231 return False
232
233 def harden_runtime(self, password: str, encoded: str) -> None:
234 """
235 Bridge the runtime gap between the work factor supplied in `encoded`
236 and the work factor suggested by this hasher.
237
238 Taking PBKDF2 as an example, if `encoded` contains 20000 iterations and
239 `self.iterations` is 30000, this method should run password through
240 another 10000 iterations of PBKDF2. Similar approaches should exist
241 for any hasher that has a work factor. If not, this method should be
242 defined as a no-op to silence the warning.
243 """
244 warnings.warn(
245 "subclasses of BasePasswordHasher should provide a harden_runtime() method"
246 )
247
248
249class PBKDF2PasswordHasher(BasePasswordHasher):
250 """
251 Secure password hashing using the PBKDF2 algorithm (recommended)
252
253 Configured to use PBKDF2 + HMAC + SHA256.
254 The result is a 64 byte binary string. Iterations may be changed
255 safely but you must rename the algorithm if you change SHA256.
256 """
257
258 algorithm = "pbkdf2_sha256"
259 iterations = 720000
260 digest = hashlib.sha256
261
262 def encode(self, password: str, salt: str, iterations: int | None = None) -> str:
263 self._check_encode_args(password, salt)
264 iterations = iterations or self.iterations
265 hash = pbkdf2(password, salt, iterations, digest=self.digest)
266 hash = base64.b64encode(hash).decode("ascii").strip()
267 return "%s$%d$%s$%s" % (self.algorithm, iterations, salt, hash) # noqa: UP031
268
269 def decode(self, encoded: str) -> dict[str, Any]:
270 algorithm, iterations, salt, hash = encoded.split("$", 3)
271 assert algorithm == self.algorithm
272 return {
273 "algorithm": algorithm,
274 "hash": hash,
275 "iterations": int(iterations),
276 "salt": salt,
277 }
278
279 def verify(self, password: str, encoded: str) -> bool:
280 decoded = self.decode(encoded)
281 encoded_2 = self.encode(password, decoded["salt"], decoded["iterations"])
282 return hmac.compare_digest(force_bytes(encoded), force_bytes(encoded_2))
283
284 def safe_summary(self, encoded: str) -> dict[str, Any]:
285 decoded = self.decode(encoded)
286 return {
287 "algorithm": decoded["algorithm"],
288 "iterations": decoded["iterations"],
289 "salt": mask_hash(decoded["salt"]),
290 "hash": mask_hash(decoded["hash"]),
291 }
292
293 def must_update(self, encoded: str) -> bool:
294 decoded = self.decode(encoded)
295 update_salt = must_update_salt(decoded["salt"], self.salt_entropy)
296 return (decoded["iterations"] != self.iterations) or update_salt
297
298 def harden_runtime(self, password: str, encoded: str) -> None:
299 decoded = self.decode(encoded)
300 extra_iterations = self.iterations - decoded["iterations"]
301 if extra_iterations > 0:
302 self.encode(password, decoded["salt"], extra_iterations)