From 7c09154b76d1baa1b6a6538259b3406bebec4f63 Mon Sep 17 00:00:00 2001 From: Malcolm Roberts Date: Fri, 2 Oct 2026 14:13:40 -0500 Subject: [PATCH] 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 --- backend/auth.py | 79 +++++++++++++++++++++++++++++++++ backend/tests/test_passwords.py | 58 ++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 backend/tests/test_passwords.py diff --git a/backend/auth.py b/backend/auth.py index b5dce6c..b70839a 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -1,8 +1,87 @@ """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() diff --git a/backend/tests/test_passwords.py b/backend/tests/test_passwords.py new file mode 100644 index 0000000..f386613 --- /dev/null +++ b/backend/tests/test_passwords.py @@ -0,0 +1,58 @@ +import os +import stat +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from auth import ITERATIONS, hash_password, load_pepper, needs_rehash, verify_password + +PEPPER = b"p" * 32 +# Independently computed: HMAC-SHA256(PEPPER, password) -> PBKDF2-SHA256, 1000 iterations, 16 zero-byte salt. +KNOWN_ANSWER = "pbkdf2_sha256$1000$AAAAAAAAAAAAAAAAAAAAAA==$9ZLLEusnEPU3Km8h+vnd4ue9fw7rHdm4QwuBX5ozynE=" + + +class PasswordHashTests(unittest.TestCase): + def test_known_answer(self): + stored = hash_password("correct horse battery staple", PEPPER, salt=bytes(16), iterations=1000) + self.assertEqual(stored, KNOWN_ANSWER) + + def test_round_trip_uses_current_iterations_and_random_salt(self): + first = hash_password("correct horse battery staple", PEPPER) + second = hash_password("correct horse battery staple", PEPPER) + self.assertTrue(first.startswith(f"pbkdf2_sha256${ITERATIONS}$")) + self.assertNotEqual(first, second) + self.assertTrue(verify_password("correct horse battery staple", first, PEPPER)) + + def test_wrong_password_fails(self): + self.assertFalse(verify_password("wrong password!!", KNOWN_ANSWER, PEPPER)) + + def test_wrong_pepper_fails(self): + self.assertFalse(verify_password("correct horse battery staple", KNOWN_ANSWER, b"q" * 32)) + + def test_needs_rehash_below_current_iterations(self): + self.assertTrue(needs_rehash(KNOWN_ANSWER)) + self.assertFalse(needs_rehash(hash_password("x" * 12, PEPPER))) + + +class PepperTests(unittest.TestCase): + def test_env_pepper_must_be_long_enough(self): + with mock.patch.dict(os.environ, {"PASSWORD_PEPPER": "short"}): + with self.assertRaisesRegex(RuntimeError, "at least 32 characters"): + load_pepper(Path("/nonexistent")) + + def test_env_pepper_is_used_when_set(self): + with mock.patch.dict(os.environ, {"PASSWORD_PEPPER": "e" * 40}): + self.assertEqual(load_pepper(Path("/nonexistent")), b"e" * 40) + + def test_file_fallback_creates_private_stable_pepper(self): + with tempfile.TemporaryDirectory() as tmp, mock.patch.dict(os.environ): + os.environ.pop("PASSWORD_PEPPER", None) + pepper_file = Path(tmp) / ".pepper" + with self.assertLogs("auth", level="WARNING"): + first = load_pepper(pepper_file) + with self.assertLogs("auth", level="WARNING"): + second = load_pepper(pepper_file) + self.assertEqual(first, second) + self.assertEqual(len(first), 64) + self.assertEqual(stat.S_IMODE(pepper_file.stat().st_mode), 0o600)