Skip to content

Commit 44930e0

Browse files
author
Saurabh Kumar Bajpai
committed
security: add bcrypt password hashing helpers
1 parent 993cd82 commit 44930e0

3 files changed

Lines changed: 147 additions & 0 deletions

File tree

backend/password_hashing.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""
2+
Password hashing helpers for any repository-managed credential flow.
3+
4+
The public app currently delegates user authentication to Supabase Auth. If a
5+
backend/admin flow ever stores local passwords, it must use these helpers
6+
rather than fast hashes such as MD5 or SHA variants.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import re
12+
13+
import bcrypt
14+
15+
BCRYPT_COST_FACTOR = 12
16+
_BCRYPT_HASH_PATTERN = re.compile(r"^\$2[aby]\$(\d{2})\$[./A-Za-z0-9]{53}$")
17+
18+
19+
def _to_password_bytes(password: str) -> bytes:
20+
if not isinstance(password, str) or not password:
21+
raise ValueError("Password must be a non-empty string.")
22+
return password.encode("utf-8")
23+
24+
25+
def hash_password(
26+
password: str,
27+
*,
28+
cost_factor: int = BCRYPT_COST_FACTOR,
29+
) -> str:
30+
"""
31+
Hash a plaintext password with bcrypt.
32+
33+
bcrypt cost 12 is the project baseline. Higher values may be passed by
34+
callers after performance testing, but lower values are rejected.
35+
"""
36+
37+
if cost_factor < BCRYPT_COST_FACTOR:
38+
raise ValueError(
39+
f"bcrypt cost factor must be at least {BCRYPT_COST_FACTOR}."
40+
)
41+
42+
password_bytes = _to_password_bytes(password)
43+
return bcrypt.hashpw(
44+
password_bytes,
45+
bcrypt.gensalt(rounds=cost_factor),
46+
).decode("ascii")
47+
48+
49+
def verify_password(password: str, password_hash: str) -> bool:
50+
"""
51+
Verify a plaintext password against a bcrypt hash.
52+
53+
Malformed hashes, legacy MD5/SHA digests, and empty inputs fail closed.
54+
"""
55+
56+
if not isinstance(password_hash, str) or not is_bcrypt_hash(password_hash):
57+
return False
58+
59+
try:
60+
return bcrypt.checkpw(
61+
_to_password_bytes(password),
62+
password_hash.encode("ascii"),
63+
)
64+
except (TypeError, ValueError):
65+
return False
66+
67+
68+
def is_bcrypt_hash(password_hash: str) -> bool:
69+
"""Return whether a value has bcrypt's expected modular crypt format."""
70+
71+
return isinstance(password_hash, str) and bool(
72+
_BCRYPT_HASH_PATTERN.fullmatch(password_hash)
73+
)
74+
75+
76+
def password_hash_needs_rehash(
77+
password_hash: str,
78+
*,
79+
cost_factor: int = BCRYPT_COST_FACTOR,
80+
) -> bool:
81+
"""Return true when a bcrypt hash is missing or uses a weaker cost."""
82+
83+
match = _BCRYPT_HASH_PATTERN.fullmatch(password_hash or "")
84+
if not match:
85+
return True
86+
87+
return int(match.group(1)) < cost_factor

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ redis>=5.0.1
1414
python-dotenv>=1.0.1
1515
pytest-env>=1.1.3
1616
bleach
17+
bcrypt>=4.1.0
1718
faiss-cpu>=1.8.0
1819

1920
# Pre-commit hooks

tests/test_password_hashing.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import hashlib
2+
3+
import pytest
4+
5+
from backend.password_hashing import (
6+
BCRYPT_COST_FACTOR,
7+
hash_password,
8+
is_bcrypt_hash,
9+
password_hash_needs_rehash,
10+
verify_password,
11+
)
12+
13+
14+
def test_hash_password_uses_bcrypt_cost_factor_12():
15+
password_hash = hash_password("Correct Horse Battery Staple")
16+
17+
assert is_bcrypt_hash(password_hash)
18+
assert password_hash.startswith(f"$2b${BCRYPT_COST_FACTOR}$")
19+
assert "Correct Horse Battery Staple" not in password_hash
20+
21+
22+
def test_verify_password_accepts_valid_password_and_rejects_invalid_password():
23+
password_hash = hash_password("s3cure-pa55word")
24+
25+
assert verify_password("s3cure-pa55word", password_hash) is True
26+
assert verify_password("wrong-password", password_hash) is False
27+
28+
29+
def test_verify_password_fails_closed_for_md5_and_malformed_hashes():
30+
legacy_md5_hash = hashlib.md5(b"s3cure-pa55word").hexdigest()
31+
32+
assert verify_password("s3cure-pa55word", legacy_md5_hash) is False
33+
assert verify_password("s3cure-pa55word", "not-a-bcrypt-hash") is False
34+
assert verify_password("", legacy_md5_hash) is False
35+
36+
37+
def test_hash_password_rejects_weak_cost_factors_and_empty_passwords():
38+
with pytest.raises(ValueError):
39+
hash_password("s3cure-pa55word", cost_factor=BCRYPT_COST_FACTOR - 1)
40+
41+
with pytest.raises(ValueError):
42+
hash_password("")
43+
44+
45+
def test_password_hash_needs_rehash_detects_missing_or_weak_hashes():
46+
current_hash = hash_password("s3cure-pa55word")
47+
weak_hash = hash_password(
48+
"s3cure-pa55word",
49+
cost_factor=BCRYPT_COST_FACTOR,
50+
)
51+
52+
assert password_hash_needs_rehash(current_hash) is False
53+
assert password_hash_needs_rehash(
54+
weak_hash,
55+
cost_factor=BCRYPT_COST_FACTOR + 1,
56+
) is True
57+
assert password_hash_needs_rehash(
58+
"5f4dcc3b5aa765d61d8327deb882cf99"
59+
) is True

0 commit comments

Comments
 (0)