from __future__ import annotations import hashlib import hmac import os import posixpath import secrets SALT_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" DEFAULT_PBKDF2_ITERATIONS = 600000 _os_alt_seps: list[str] = list( sep for sep in [os.sep, os.path.altsep] if sep is not None and sep != "/" ) def gen_salt(length: int) -> str: """Generate a random string of SALT_CHARS with specified ``length``.""" if length <= 0: raise ValueError("Salt length must be at least 1.") return "".join(secrets.choice(SALT_CHARS) for _ in range(length)) def _hash_internal(method: str, salt: str, password: str) -> tuple[str, str]: method, *args = method.split(":") salt = salt.encode() password = password.encode() if method == "scrypt": if not args: n = 2**15 r = 8 p = 1 else: try: n, r, p = map(int, args) except ValueError: raise ValueError("'scrypt' takes 3 arguments.") from None maxmem = 132 * n * r * p # ideally 128, but some extra seems needed return ( hashlib.scrypt(password, salt=salt, n=n, r=r, p=p, maxmem=maxmem).hex(), f"scrypt:{n}:{r}:{p}", ) elif method == "pbkdf2": len_args = len(args) if len_args == 0: hash_name = "sha256" iterations = DEFAULT_PBKDF2_ITERATIONS elif len_args == 1: hash_name = args[0] iterations = DEFAULT_PBKDF2_ITERATIONS elif len_args == 2: hash_name = args[0] iterations = int(args[1]) else: raise ValueError("'pbkdf2' takes 2 arguments.") return ( hashlib.pbkdf2_hmac(hash_name, password, salt, iterations).hex(), f"pbkdf2:{hash_name}:{iterations}", ) else: raise ValueError(f"Invalid hash method '{method}'.") def generate_password_hash( password: str, method: str = "scrypt", salt_length: int = 16 ) -> str: """Securely hash a password for storage. A password can be compared to a stored hash using :func:`check_password_hash`. The following methods are supported: - ``scrypt``, the default. The parameters are ``n``, ``r``, and ``p``, the default is ``scrypt:32768:8:1``. See :func:`hashlib.scrypt`. - ``pbkdf2``, less secure. The parameters are ``hash_method`` and ``iterations``, the default is ``pbkdf2:sha256:600000``. See :func:`hashlib.pbkdf2_hmac`. Default parameters may be updated to reflect current guidelines, and methods may be deprecated and removed if they are no longer considered secure. To migrate old hashes, you may generate a new hash when checking an old hash, or you may contact users with a link to reset their password. :param password: The plaintext password. :param method: The key derivation function and parameters. :param salt_length: The number of characters to generate for the salt. .. versionchanged:: 2.3 Scrypt support was added. .. versionchanged:: 2.3 The default iterations for pbkdf2 was increased to 600,000. .. versionchanged:: 2.3 All plain hashes are deprecated and will not be supported in Werkzeug 3.0. """ salt = gen_salt(salt_length) h, actual_method = _hash_internal(method, salt, password) return f"{actual_method}${salt}${h}" def check_password_hash(pwhash: str, password: str) -> bool: """Securely check that the given stored password hash, previously generated using :func:`generate_password_hash`, matches the given password. Methods may be deprecated and removed if they are no longer considered secure. To migrate old hashes, you may generate a new hash when checking an old hash, or you may contact users with a link to reset their password. :param pwhash: The hashed password. :param password: The plaintext password. .. versionchanged:: 2.3 All plain hashes are deprecated and will not be supported in Werkzeug 3.0. """ try: method, salt, hashval = pwhash.split("$", 2) except ValueError: return False return hmac.compare_digest(_hash_internal(method, salt, password)[0], hashval) def safe_join(directory: str, *pathnames: str) -> str | None: """Safely join zero or more untrusted path components to a base directory to avoid escaping the base directory. :param directory: The trusted base directory. :param pathnames: The untrusted path components relative to the base directory. :return: A safe path, otherwise ``None``. """ if not directory: # Ensure we end up with ./path if directory="" is given, # otherwise the first untrusted part could become trusted. directory = "." parts = [directory] for filename in pathnames: if filename != "": filename = posixpath.normpath(filename) if ( any(sep in filename for sep in _os_alt_seps) or os.path.isabs(filename) or filename == ".." or filename.startswith("../") ): return None parts.append(filename) return posixpath.join(*parts)