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 {hasher.algorithm: hasher for hasher in get_hashers()}
98
99
100def get_hasher(algorithm: str | BasePasswordHasher = "default") -> BasePasswordHasher:
101 """
102 Return an instance of a loaded password hasher.
103
104 If algorithm is 'default', return the default hasher. Lazily import hashers
105 specified in the project's settings file if needed.
106 """
107 if isinstance(algorithm, BasePasswordHasher):
108 return algorithm
109
110 elif algorithm == "default":
111 return get_hashers()[0]
112
113 else:
114 hashers = get_hashers_by_algorithm()
115 try:
116 return hashers[algorithm]
117 except KeyError:
118 raise ValueError(
119 f"Unknown password hashing algorithm '{algorithm}'. "
120 "Did you specify it in the PASSWORD_HASHERS "
121 "setting?"
122 )
123
124
125def identify_hasher(encoded: str) -> BasePasswordHasher:
126 """
127 Return an instance of a loaded password hasher.
128
129 Identify hasher algorithm by examining encoded hash, and call
130 get_hasher() to return hasher. Raise ValueError if
131 algorithm cannot be identified, or if hasher is not loaded.
132 """
133 # Ancient versions of Plain created plain MD5 passwords and accepted
134 # MD5 passwords with an empty salt.
135 if (len(encoded) == 32 and "$" not in encoded) or (
136 len(encoded) == 37 and encoded.startswith("md5$$")
137 ):
138 algorithm = "unsalted_md5"
139 # Ancient versions of Plain accepted SHA1 passwords with an empty salt.
140 elif len(encoded) == 46 and encoded.startswith("sha1$$"):
141 algorithm = "unsalted_sha1"
142 else:
143 algorithm = encoded.split("$", 1)[0]
144 return get_hasher(algorithm)
145
146
147def mask_hash(hash: str, show: int = 6, char: str = "*") -> str:
148 """
149 Return the given hash, with only the first ``show`` number shown. The
150 rest are masked with ``char`` for security reasons.
151 """
152 masked = hash[:show]
153 masked += char * len(hash[show:])
154 return masked
155
156
157def must_update_salt(salt: str, expected_entropy: int) -> bool:
158 # Each character in the salt provides log_2(len(alphabet)) bits of entropy.
159 return len(salt) * math.log2(len(RANDOM_STRING_CHARS)) < expected_entropy
160
161
162class BasePasswordHasher(ABC):
163 """
164 Abstract base class for password hashers
165
166 When creating your own hasher, you need to override algorithm,
167 verify(), encode() and safe_summary().
168
169 PasswordHasher objects are immutable.
170 """
171
172 algorithm: str | None = None
173 salt_entropy: int = 128
174
175 def salt(self) -> str:
176 """
177 Generate a cryptographically secure nonce salt in ASCII with an entropy
178 of at least `salt_entropy` bits.
179 """
180 # Each character in the salt provides
181 # log_2(len(alphabet)) bits of entropy.
182 char_count = math.ceil(self.salt_entropy / math.log2(len(RANDOM_STRING_CHARS)))
183 return get_random_string(char_count, allowed_chars=RANDOM_STRING_CHARS)
184
185 @abstractmethod
186 def verify(self, password: str, encoded: str) -> bool:
187 """Check if the given password is correct."""
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 @abstractmethod
197 def encode(self, password: str, salt: str) -> str:
198 """
199 Create an encoded database value.
200
201 The result is normally formatted as "algorithm$salt$hash" and
202 must be fewer than 128 characters.
203 """
204 ...
205
206 @abstractmethod
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 ...
216
217 @abstractmethod
218 def safe_summary(self, encoded: str) -> dict[str, Any]:
219 """
220 Return a summary of safe values.
221
222 The result is a dictionary and will be used where the password field
223 must be displayed to construct a safe representation of the password.
224 """
225 ...
226
227 def must_update(self, encoded: str) -> bool:
228 return False
229
230 def harden_runtime(self, password: str, encoded: str) -> None:
231 """
232 Bridge the runtime gap between the work factor supplied in `encoded`
233 and the work factor suggested by this hasher.
234
235 Taking PBKDF2 as an example, if `encoded` contains 20000 iterations and
236 `self.iterations` is 30000, this method should run password through
237 another 10000 iterations of PBKDF2. Similar approaches should exist
238 for any hasher that has a work factor. If not, this method should be
239 defined as a no-op to silence the warning.
240 """
241 warnings.warn(
242 "subclasses of BasePasswordHasher should provide a harden_runtime() method"
243 )
244
245
246class PBKDF2PasswordHasher(BasePasswordHasher):
247 """
248 Secure password hashing using the PBKDF2 algorithm (recommended)
249
250 Configured to use PBKDF2 + HMAC + SHA256.
251 The result is a 64 byte binary string. Iterations may be changed
252 safely but you must rename the algorithm if you change SHA256.
253 """
254
255 algorithm = "pbkdf2_sha256"
256 iterations = 720000
257 digest = hashlib.sha256
258
259 def encode(self, password: str, salt: str, iterations: int | None = None) -> str:
260 self._check_encode_args(password, salt)
261 iterations = iterations or self.iterations
262 hash = pbkdf2(password, salt, iterations, digest=self.digest)
263 hash = base64.b64encode(hash).decode("ascii").strip()
264 return "%s$%d$%s$%s" % (self.algorithm, iterations, salt, hash) # noqa: UP031
265
266 def decode(self, encoded: str) -> dict[str, Any]:
267 algorithm, iterations, salt, hash = encoded.split("$", 3)
268 assert algorithm == self.algorithm
269 return {
270 "algorithm": algorithm,
271 "hash": hash,
272 "iterations": int(iterations),
273 "salt": salt,
274 }
275
276 def verify(self, password: str, encoded: str) -> bool:
277 decoded = self.decode(encoded)
278 encoded_2 = self.encode(password, decoded["salt"], decoded["iterations"])
279 return hmac.compare_digest(force_bytes(encoded), force_bytes(encoded_2))
280
281 def safe_summary(self, encoded: str) -> dict[str, Any]:
282 decoded = self.decode(encoded)
283 return {
284 "algorithm": decoded["algorithm"],
285 "iterations": decoded["iterations"],
286 "salt": mask_hash(decoded["salt"]),
287 "hash": mask_hash(decoded["hash"]),
288 }
289
290 def must_update(self, encoded: str) -> bool:
291 decoded = self.decode(encoded)
292 update_salt = must_update_salt(decoded["salt"], self.salt_entropy)
293 return (decoded["iterations"] != self.iterations) or update_salt
294
295 def harden_runtime(self, password: str, encoded: str) -> None:
296 decoded = self.decode(encoded)
297 extra_iterations = self.iterations - decoded["iterations"]
298 if extra_iterations > 0:
299 self.encode(password, decoded["salt"], extra_iterations)