Plain is headed towards 1.0! Subscribe for development updates →

plain.password

Password authentication for Plain.

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