Plain is headed towards 1.0! Subscribe for development updates →

  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)