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
This commit is contained in:
2026-10-02 14:13:40 -05:00
parent b15448f109
commit 7c09154b76
2 changed files with 137 additions and 0 deletions
+79
View File
@@ -1,8 +1,87 @@
"""Auth slice: password hashing (ADR-0001), server-side sessions (ADR-0002), and auth routes.""" """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 from flask import Blueprint, Flask
log = logging.getLogger(__name__)
bp = Blueprint("auth", __name__, url_prefix="/api/auth") bp = Blueprint("auth", __name__, url_prefix="/api/auth")
ITERATIONS = 600_000
SALT_BYTES = 16
MIN_PEPPER_CHARS = 32
def init_app(app: Flask) -> None: 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) 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()
+58
View File
@@ -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)