Plain is headed towards 1.0! Subscribe for development updates →

  1import base64
  2import binascii
  3import functools
  4import hashlib
  5import hmac
  6import importlib
  7import math
  8import warnings
  9
 10from plain.exceptions import ImproperlyConfigured
 11from plain.runtime import settings
 12from plain.utils.crypto import (
 13    RANDOM_STRING_CHARS,
 14    get_random_string,
 15    pbkdf2,
 16)
 17from plain.utils.encoding import force_bytes
 18from plain.utils.module_loading import import_string
 19
 20
 21def check_password(password, encoded, setter=None, preferred="default"):
 22    """
 23    Return a boolean of whether the raw password matches the three
 24    part encoded digest.
 25
 26    If setter is specified, it'll be called when you need to
 27    regenerate the password.
 28    """
 29    if not password:
 30        return False
 31
 32    preferred = get_hasher(preferred)
 33    try:
 34        hasher = identify_hasher(encoded)
 35    except ValueError:
 36        # encoded is gibberish or uses a hasher that's no longer installed.
 37        return False
 38
 39    hasher_changed = hasher.algorithm != preferred.algorithm
 40    must_update = hasher_changed or preferred.must_update(encoded)
 41    is_correct = hasher.verify(password, encoded)
 42
 43    # If the hasher didn't change (we don't protect against enumeration if it
 44    # does) and the password should get updated, try to close the timing gap
 45    # between the work factor of the current encoded password and the default
 46    # work factor.
 47    if not is_correct and not hasher_changed and must_update:
 48        hasher.harden_runtime(password, encoded)
 49
 50    if setter and is_correct and must_update:
 51        setter(password)
 52    return is_correct
 53
 54
 55def hash_password(password, salt=None, hasher="default"):
 56    """
 57    Turn a plain-text password into a hash for database storage
 58
 59    Same as encode() but generate a new random salt. If password is None then
 60    return a concatenation of UNUSABLE_PASSWORD_PREFIX and a random string,
 61    which disallows logins. Additional random string reduces chances of gaining
 62    access to admin or superuser accounts. See ticket #20079 for more info.
 63    """
 64    if not isinstance(password, bytes | str):
 65        raise TypeError(
 66            f"Password must be a string or bytes, got {type(password).__qualname__}."
 67        )
 68    hasher = get_hasher(hasher)
 69    salt = salt or hasher.salt()
 70    return hasher.encode(password, salt)
 71
 72
 73@functools.lru_cache
 74def get_hashers():
 75    hashers = []
 76    for hasher_path in settings.PASSWORD_HASHERS:
 77        hasher_cls = import_string(hasher_path)
 78        hasher = hasher_cls()
 79        if not getattr(hasher, "algorithm"):
 80            raise ImproperlyConfigured(
 81                f"hasher doesn't specify an algorithm name: {hasher_path}"
 82            )
 83        hashers.append(hasher)
 84    return hashers
 85
 86
 87@functools.lru_cache
 88def get_hashers_by_algorithm():
 89    return {hasher.algorithm: hasher for hasher in get_hashers()}
 90
 91
 92def get_hasher(algorithm="default"):
 93    """
 94    Return an instance of a loaded password hasher.
 95
 96    If algorithm is 'default', return the default hasher. Lazily import hashers
 97    specified in the project's settings file if needed.
 98    """
 99    if hasattr(algorithm, "algorithm"):
100        return algorithm
101
102    elif algorithm == "default":
103        return get_hashers()[0]
104
105    else:
106        hashers = get_hashers_by_algorithm()
107        try:
108            return hashers[algorithm]
109        except KeyError:
110            raise ValueError(
111                f"Unknown password hashing algorithm '{algorithm}'. "
112                "Did you specify it in the PASSWORD_HASHERS "
113                "setting?"
114            )
115
116
117def identify_hasher(encoded):
118    """
119    Return an instance of a loaded password hasher.
120
121    Identify hasher algorithm by examining encoded hash, and call
122    get_hasher() to return hasher. Raise ValueError if
123    algorithm cannot be identified, or if hasher is not loaded.
124    """
125    # Ancient versions of Plain created plain MD5 passwords and accepted
126    # MD5 passwords with an empty salt.
127    if (len(encoded) == 32 and "$" not in encoded) or (
128        len(encoded) == 37 and encoded.startswith("md5$$")
129    ):
130        algorithm = "unsalted_md5"
131    # Ancient versions of Plain accepted SHA1 passwords with an empty salt.
132    elif len(encoded) == 46 and encoded.startswith("sha1$$"):
133        algorithm = "unsalted_sha1"
134    else:
135        algorithm = encoded.split("$", 1)[0]
136    return get_hasher(algorithm)
137
138
139def mask_hash(hash, show=6, char="*"):
140    """
141    Return the given hash, with only the first ``show`` number shown. The
142    rest are masked with ``char`` for security reasons.
143    """
144    masked = hash[:show]
145    masked += char * len(hash[show:])
146    return masked
147
148
149def must_update_salt(salt, expected_entropy):
150    # Each character in the salt provides log_2(len(alphabet)) bits of entropy.
151    return len(salt) * math.log2(len(RANDOM_STRING_CHARS)) < expected_entropy
152
153
154class BasePasswordHasher:
155    """
156    Abstract base class for password hashers
157
158    When creating your own hasher, you need to override algorithm,
159    verify(), encode() and safe_summary().
160
161    PasswordHasher objects are immutable.
162    """
163
164    algorithm = None
165    library = None
166    salt_entropy = 128
167
168    def _load_library(self):
169        if self.library is not None:
170            if isinstance(self.library, tuple | list):
171                name, mod_path = self.library
172            else:
173                mod_path = self.library
174            try:
175                module = importlib.import_module(mod_path)
176            except ImportError as e:
177                raise ValueError(
178                    f"Couldn't load {self.__class__.__name__!r} algorithm library: {e}"
179                )
180            return module
181        raise ValueError(
182            f"Hasher {self.__class__.__name__!r} doesn't specify a library attribute"
183        )
184
185    def salt(self):
186        """
187        Generate a cryptographically secure nonce salt in ASCII with an entropy
188        of at least `salt_entropy` bits.
189        """
190        # Each character in the salt provides
191        # log_2(len(alphabet)) bits of entropy.
192        char_count = math.ceil(self.salt_entropy / math.log2(len(RANDOM_STRING_CHARS)))
193        return get_random_string(char_count, allowed_chars=RANDOM_STRING_CHARS)
194
195    def verify(self, password, encoded):
196        """Check if the given password is correct."""
197        raise NotImplementedError(
198            "subclasses of BasePasswordHasher must provide a verify() method"
199        )
200
201    def _check_encode_args(self, password, salt):
202        if password is None:
203            raise TypeError("password must be provided.")
204        if not salt or "$" in salt:
205            raise ValueError("salt must be provided and cannot contain $.")
206
207    def encode(self, password, salt):
208        """
209        Create an encoded database value.
210
211        The result is normally formatted as "algorithm$salt$hash" and
212        must be fewer than 128 characters.
213        """
214        raise NotImplementedError(
215            "subclasses of BasePasswordHasher must provide an encode() method"
216        )
217
218    def decode(self, encoded):
219        """
220        Return a decoded database value.
221
222        The result is a dictionary and should contain `algorithm`, `hash`, and
223        `salt`. Extra keys can be algorithm specific like `iterations` or
224        `work_factor`.
225        """
226        raise NotImplementedError(
227            "subclasses of BasePasswordHasher must provide a decode() method."
228        )
229
230    def safe_summary(self, encoded):
231        """
232        Return a summary of safe values.
233
234        The result is a dictionary and will be used where the password field
235        must be displayed to construct a safe representation of the password.
236        """
237        raise NotImplementedError(
238            "subclasses of BasePasswordHasher must provide a safe_summary() method"
239        )
240
241    def must_update(self, encoded):
242        return False
243
244    def harden_runtime(self, password, encoded):
245        """
246        Bridge the runtime gap between the work factor supplied in `encoded`
247        and the work factor suggested by this hasher.
248
249        Taking PBKDF2 as an example, if `encoded` contains 20000 iterations and
250        `self.iterations` is 30000, this method should run password through
251        another 10000 iterations of PBKDF2. Similar approaches should exist
252        for any hasher that has a work factor. If not, this method should be
253        defined as a no-op to silence the warning.
254        """
255        warnings.warn(
256            "subclasses of BasePasswordHasher should provide a harden_runtime() method"
257        )
258
259
260class PBKDF2PasswordHasher(BasePasswordHasher):
261    """
262    Secure password hashing using the PBKDF2 algorithm (recommended)
263
264    Configured to use PBKDF2 + HMAC + SHA256.
265    The result is a 64 byte binary string.  Iterations may be changed
266    safely but you must rename the algorithm if you change SHA256.
267    """
268
269    algorithm = "pbkdf2_sha256"
270    iterations = 720000
271    digest = hashlib.sha256
272
273    def encode(self, password, salt, iterations=None):
274        self._check_encode_args(password, salt)
275        iterations = iterations or self.iterations
276        hash = pbkdf2(password, salt, iterations, digest=self.digest)
277        hash = base64.b64encode(hash).decode("ascii").strip()
278        return "%s$%d$%s$%s" % (self.algorithm, iterations, salt, hash)  # noqa: UP031
279
280    def decode(self, encoded):
281        algorithm, iterations, salt, hash = encoded.split("$", 3)
282        assert algorithm == self.algorithm
283        return {
284            "algorithm": algorithm,
285            "hash": hash,
286            "iterations": int(iterations),
287            "salt": salt,
288        }
289
290    def verify(self, password, encoded):
291        decoded = self.decode(encoded)
292        encoded_2 = self.encode(password, decoded["salt"], decoded["iterations"])
293        return hmac.compare_digest(force_bytes(encoded), force_bytes(encoded_2))
294
295    def safe_summary(self, encoded):
296        decoded = self.decode(encoded)
297        return {
298            "algorithm": decoded["algorithm"],
299            "iterations": decoded["iterations"],
300            "salt": mask_hash(decoded["salt"]),
301            "hash": mask_hash(decoded["hash"]),
302        }
303
304    def must_update(self, encoded):
305        decoded = self.decode(encoded)
306        update_salt = must_update_salt(decoded["salt"], self.salt_entropy)
307        return (decoded["iterations"] != self.iterations) or update_salt
308
309    def harden_runtime(self, password, encoded):
310        decoded = self.decode(encoded)
311        extra_iterations = self.iterations - decoded["iterations"]
312        if extra_iterations > 0:
313            self.encode(password, decoded["salt"], extra_iterations)
314
315
316class PBKDF2SHA1PasswordHasher(PBKDF2PasswordHasher):
317    """
318    Alternate PBKDF2 hasher which uses SHA1, the default PRF
319    recommended by PKCS #5. This is compatible with other
320    implementations of PBKDF2, such as openssl's
321    PKCS5_PBKDF2_HMAC_SHA1().
322    """
323
324    algorithm = "pbkdf2_sha1"
325    digest = hashlib.sha1
326
327
328class Argon2PasswordHasher(BasePasswordHasher):
329    """
330    Secure password hashing using the argon2 algorithm.
331
332    This is the winner of the Password Hashing Competition 2013-2015
333    (https://password-hashing.net). It requires the argon2-cffi library which
334    depends on native C code and might cause portability issues.
335    """
336
337    algorithm = "argon2"
338    library = "argon2"
339
340    time_cost = 2
341    memory_cost = 102400
342    parallelism = 8
343
344    def encode(self, password, salt):
345        argon2 = self._load_library()
346        params = self.params()
347        data = argon2.low_level.hash_secret(
348            password.encode(),
349            salt.encode(),
350            time_cost=params.time_cost,
351            memory_cost=params.memory_cost,
352            parallelism=params.parallelism,
353            hash_len=params.hash_len,
354            type=params.type,
355        )
356        return self.algorithm + data.decode("ascii")
357
358    def decode(self, encoded):
359        argon2 = self._load_library()
360        algorithm, rest = encoded.split("$", 1)
361        assert algorithm == self.algorithm
362        params = argon2.extract_parameters("$" + rest)
363        variety, *_, b64salt, hash = rest.split("$")
364        # Add padding.
365        b64salt += "=" * (-len(b64salt) % 4)
366        salt = base64.b64decode(b64salt).decode("latin1")
367        return {
368            "algorithm": algorithm,
369            "hash": hash,
370            "memory_cost": params.memory_cost,
371            "parallelism": params.parallelism,
372            "salt": salt,
373            "time_cost": params.time_cost,
374            "variety": variety,
375            "version": params.version,
376            "params": params,
377        }
378
379    def verify(self, password, encoded):
380        argon2 = self._load_library()
381        algorithm, rest = encoded.split("$", 1)
382        assert algorithm == self.algorithm
383        try:
384            return argon2.PasswordHasher().verify("$" + rest, password)
385        except argon2.exceptions.VerificationError:
386            return False
387
388    def safe_summary(self, encoded):
389        decoded = self.decode(encoded)
390        return {
391            "algorithm": decoded["algorithm"],
392            "variety": decoded["variety"],
393            "version": decoded["version"],
394            "memory cost": decoded["memory_cost"],
395            "time cost": decoded["time_cost"],
396            "parallelism": decoded["parallelism"],
397            "salt": mask_hash(decoded["salt"]),
398            "hash": mask_hash(decoded["hash"]),
399        }
400
401    def must_update(self, encoded):
402        decoded = self.decode(encoded)
403        current_params = decoded["params"]
404        new_params = self.params()
405        # Set salt_len to the salt_len of the current parameters because salt
406        # is explicitly passed to argon2.
407        new_params.salt_len = current_params.salt_len
408        update_salt = must_update_salt(decoded["salt"], self.salt_entropy)
409        return (current_params != new_params) or update_salt
410
411    def harden_runtime(self, password, encoded):
412        # The runtime for Argon2 is too complicated to implement a sensible
413        # hardening algorithm.
414        pass
415
416    def params(self):
417        argon2 = self._load_library()
418        # salt_len is a noop, because we provide our own salt.
419        return argon2.Parameters(
420            type=argon2.low_level.Type.ID,
421            version=argon2.low_level.ARGON2_VERSION,
422            salt_len=argon2.DEFAULT_RANDOM_SALT_LENGTH,
423            hash_len=argon2.DEFAULT_HASH_LENGTH,
424            time_cost=self.time_cost,
425            memory_cost=self.memory_cost,
426            parallelism=self.parallelism,
427        )
428
429
430class BCryptSHA256PasswordHasher(BasePasswordHasher):
431    """
432    Secure password hashing using the bcrypt algorithm (recommended)
433
434    This is considered by many to be the most secure algorithm but you
435    must first install the bcrypt library.  Please be warned that
436    this library depends on native C code and might cause portability
437    issues.
438    """
439
440    algorithm = "bcrypt_sha256"
441    digest = hashlib.sha256
442    library = ("bcrypt", "bcrypt")
443    rounds = 12
444
445    def salt(self):
446        bcrypt = self._load_library()
447        return bcrypt.gensalt(self.rounds)
448
449    def encode(self, password, salt):
450        bcrypt = self._load_library()
451        password = password.encode()
452        # Hash the password prior to using bcrypt to prevent password
453        # truncation as described in #20138.
454        if self.digest is not None:
455            # Use binascii.hexlify() because a hex encoded bytestring is str.
456            password = binascii.hexlify(self.digest(password).digest())
457
458        data = bcrypt.hashpw(password, salt)
459        return "{}${}".format(self.algorithm, data.decode("ascii"))
460
461    def decode(self, encoded):
462        algorithm, empty, algostr, work_factor, data = encoded.split("$", 4)
463        assert algorithm == self.algorithm
464        return {
465            "algorithm": algorithm,
466            "algostr": algostr,
467            "checksum": data[22:],
468            "salt": data[:22],
469            "work_factor": int(work_factor),
470        }
471
472    def verify(self, password, encoded):
473        algorithm, data = encoded.split("$", 1)
474        assert algorithm == self.algorithm
475        encoded_2 = self.encode(password, data.encode("ascii"))
476        return hmac.compare_digest(force_bytes(encoded), force_bytes(encoded_2))
477
478    def safe_summary(self, encoded):
479        decoded = self.decode(encoded)
480        return {
481            "algorithm": decoded["algorithm"],
482            "work factor": decoded["work_factor"],
483            "salt": mask_hash(decoded["salt"]),
484            "checksum": mask_hash(decoded["checksum"]),
485        }
486
487    def must_update(self, encoded):
488        decoded = self.decode(encoded)
489        return decoded["work_factor"] != self.rounds
490
491    def harden_runtime(self, password, encoded):
492        _, data = encoded.split("$", 1)
493        salt = data[:29]  # Length of the salt in bcrypt.
494        rounds = data.split("$")[2]
495        # work factor is logarithmic, adding one doubles the load.
496        diff = 2 ** (self.rounds - int(rounds)) - 1
497        while diff > 0:
498            self.encode(password, salt.encode("ascii"))
499            diff -= 1
500
501
502class BCryptPasswordHasher(BCryptSHA256PasswordHasher):
503    """
504    Secure password hashing using the bcrypt algorithm
505
506    This is considered by many to be the most secure algorithm but you
507    must first install the bcrypt library.  Please be warned that
508    this library depends on native C code and might cause portability
509    issues.
510
511    This hasher does not first hash the password which means it is subject to
512    bcrypt's 72 bytes password truncation. Most use cases should prefer the
513    BCryptSHA256PasswordHasher.
514    """
515
516    algorithm = "bcrypt"
517    digest = None
518
519
520class ScryptPasswordHasher(BasePasswordHasher):
521    """
522    Secure password hashing using the Scrypt algorithm.
523    """
524
525    algorithm = "scrypt"
526    block_size = 8
527    maxmem = 0
528    parallelism = 1
529    work_factor = 2**14
530
531    def encode(self, password, salt, n=None, r=None, p=None):
532        self._check_encode_args(password, salt)
533        n = n or self.work_factor
534        r = r or self.block_size
535        p = p or self.parallelism
536        hash_ = hashlib.scrypt(
537            password.encode(),
538            salt=salt.encode(),
539            n=n,
540            r=r,
541            p=p,
542            maxmem=self.maxmem,
543            dklen=64,
544        )
545        hash_ = base64.b64encode(hash_).decode("ascii").strip()
546        return "%s$%d$%s$%d$%d$%s" % (self.algorithm, n, salt, r, p, hash_)  # noqa: UP031
547
548    def decode(self, encoded):
549        algorithm, work_factor, salt, block_size, parallelism, hash_ = encoded.split(
550            "$", 6
551        )
552        assert algorithm == self.algorithm
553        return {
554            "algorithm": algorithm,
555            "work_factor": int(work_factor),
556            "salt": salt,
557            "block_size": int(block_size),
558            "parallelism": int(parallelism),
559            "hash": hash_,
560        }
561
562    def verify(self, password, encoded):
563        decoded = self.decode(encoded)
564        encoded_2 = self.encode(
565            password,
566            decoded["salt"],
567            decoded["work_factor"],
568            decoded["block_size"],
569            decoded["parallelism"],
570        )
571        return hmac.compare_digest(force_bytes(encoded), force_bytes(encoded_2))
572
573    def safe_summary(self, encoded):
574        decoded = self.decode(encoded)
575        return {
576            "algorithm": decoded["algorithm"],
577            "work factor": decoded["work_factor"],
578            "block size": decoded["block_size"],
579            "parallelism": decoded["parallelism"],
580            "salt": mask_hash(decoded["salt"]),
581            "hash": mask_hash(decoded["hash"]),
582        }
583
584    def must_update(self, encoded):
585        decoded = self.decode(encoded)
586        return (
587            decoded["work_factor"] != self.work_factor
588            or decoded["block_size"] != self.block_size
589            or decoded["parallelism"] != self.parallelism
590        )
591
592    def harden_runtime(self, password, encoded):
593        # The runtime for Scrypt is too complicated to implement a sensible
594        # hardening algorithm.
595        pass