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:
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user