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)