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:
@@ -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