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