"""Password hashing (ADR-0001): PBKDF2-SHA256 over an HMAC pepper, plus loading that pepper.""" import base64 import functools import hashlib import hmac import logging import os import secrets from pathlib import Path log = logging.getLogger(__name__) ITERATIONS = 600_000 SALT_BYTES = 16 MIN_PEPPER_CHARS = 32 def hash_password( password: str, pepper: bytes, *, salt: bytes | None = None, iterations: int = ITERATIONS, ) -> str: salt = secrets.token_bytes(SALT_BYTES) if salt is None else salt derived = _derive(password, pepper, salt, iterations) return f"pbkdf2_sha256${iterations}${_b64(salt)}${_b64(derived)}" def verify_password(password: str, stored: str, pepper: bytes) -> bool: _, iterations, salt, expected = stored.split("$") derived = _derive(password, pepper, base64.b64decode(salt), int(iterations)) return hmac.compare_digest(derived, base64.b64decode(expected)) def needs_rehash(stored: str) -> bool: return int(stored.split("$")[1]) < ITERATIONS def _derive(password: str, pepper: bytes, salt: bytes, iterations: int) -> bytes: peppered = hmac.new(pepper, password.encode("utf-8"), hashlib.sha256).digest() return hashlib.pbkdf2_hmac("sha256", peppered, salt, iterations) def _b64(raw: bytes) -> str: return base64.b64encode(raw).decode() @functools.cache def dummy_hash() -> str: # Verified against for unknown usernames so their response time matches a real account. return hash_password(secrets.token_urlsafe(16), b"\0" * MIN_PEPPER_CHARS) def load_pepper(pepper_file: Path) -> bytes: from_env = os.environ.get("PASSWORD_PEPPER") if from_env is not None: if len(from_env) < MIN_PEPPER_CHARS: raise RuntimeError( f"PASSWORD_PEPPER must be at least {MIN_PEPPER_CHARS} characters. " 'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"' ) return from_env.encode() try: fd = os.open(pepper_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) except FileExistsError: pass else: with os.fdopen(fd, "w") as f: f.write(secrets.token_hex(32)) log.warning( "PASSWORD_PEPPER is not set; using %s. Development only: production must supply the pepper " "from a secrets manager.", pepper_file, ) pepper = pepper_file.read_text().strip() if len(pepper) < MIN_PEPPER_CHARS: raise RuntimeError( f"{pepper_file} holds fewer than {MIN_PEPPER_CHARS} characters. Set PASSWORD_PEPPER, or delete " "the file to regenerate it (existing passwords will stop verifying)." ) return pepper.encode()