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 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)