Files
Clarium/backend/auth.py
T
mroberts 7c09154b76 feat: add peppered PBKDF2 password hashing
Implements password hashing with PBKDF2-SHA256 and pepper-based additional security.
Includes password verification, rehash detection, and pepper loading from environment
or file.

Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X
2026-10-02 14:13:40 -05:00

88 lines
3.0 KiB
Python

"""Auth slice: password hashing (ADR-0001), server-side sessions (ADR-0002), and auth routes."""
import base64
import functools
import hashlib
import hmac
import logging
import os
import secrets
from pathlib import Path
from flask import Blueprint, Flask
log = logging.getLogger(__name__)
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
ITERATIONS = 600_000
SALT_BYTES = 16
MIN_PEPPER_CHARS = 32
def init_app(app: Flask) -> None:
app.extensions["password_pepper"] = load_pepper(Path(app.root_path) / ".pepper")
_dummy_hash() # pay the one-time cost now so the first unknown-user login isn't measurably slower
app.register_blueprint(bp)
# --- Passwords -----------------------------------------------------------------
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()