Skip to content

Commit 7605377

Browse files
committed
feat(security): Phase 7c Step 2 — key generation + bcrypt verify + LRU cache
- Add generate_api_key(): generates lh_<256-bit-urlsafe> key with bcrypt hash (work factor 12) and 12-char prefix for UI display - Add verify_api_key(): bcrypt.checkpw with TTLCache(maxsize=512, ttl=300s) to avoid re-running ~200ms bcrypt on every request after initial verify - Add invalidate_cache(): explicit cache eviction on key revocation - 16 new unit tests covering generation entropy, prefix format, bcrypt verification, cache hit/miss, and cache invalidation Refs #22
1 parent 39e19a0 commit 7605377

6 files changed

Lines changed: 275 additions & 0 deletions

File tree

backend/app/auth/__init__.py

Whitespace-only changes.

backend/app/auth/key_generator.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""API key generation for Phase 7c — generates bcrypt-hashed keys with prefix.
2+
3+
Key format: ``lh_<43-char-urlsafe-base64>``
4+
- ``lh_`` — Label Hub prefix, distinguishes from other token formats
5+
- 43-char body — secrets.token_urlsafe(32) produces ~43 URL-safe chars
6+
from 256 bits of entropy (no padding)
7+
8+
The plaintext is returned only at generation time and must be shown to the
9+
user ONCE. Only the bcrypt hash and the 12-char prefix are persisted.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import secrets
15+
16+
import bcrypt
17+
18+
# bcrypt work factor: 12 rounds is the 2024-2026 industry default (~100-200ms on
19+
# modern hardware). Deliberately slow to resist offline brute-force attacks.
20+
_BCRYPT_ROUNDS = 12
21+
22+
23+
def generate_api_key() -> tuple[str, str, str]:
24+
"""Generate a new API key.
25+
26+
Returns:
27+
(plaintext, prefix, bcrypt_hash) where:
28+
- plaintext — the full key, shown to the user ONCE, never persisted
29+
- prefix — first 12 chars (e.g. "lh_ab12cd34X"), stored for UI display
30+
- bcrypt_hash — stored in the DB, used for verify_api_key()
31+
"""
32+
body = secrets.token_urlsafe(32) # 256 bits of entropy, URL-safe charset
33+
plaintext = f"lh_{body}"
34+
prefix = plaintext[:12]
35+
hashed = bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt(rounds=_BCRYPT_ROUNDS)).decode()
36+
return plaintext, prefix, hashed

backend/app/auth/verifier.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""bcrypt verifier with LRU cache to avoid slow re-verification on every request.
2+
3+
bcrypt.checkpw takes ~100-200ms per call (by design — work factor 12). For a
4+
HomeLab with a handful of keys and hundreds of requests per day this is fine,
5+
but for interactive use (frontend page loads doing multiple API calls) it would
6+
be noticeable.
7+
8+
The LRU cache keyed on (plaintext, hash) avoids repeated bcrypt rounds for the
9+
same key within the TTL window. The cache is invalidated explicitly when a key
10+
is revoked or updated.
11+
12+
Cache design:
13+
- Key: (plaintext, hashed) — using both avoids cache-poisoning if two keys
14+
happen to share a prefix
15+
- Value: bool (True=valid, False=invalid)
16+
- Size: maxsize=512 (sufficient for HomeLab, tiny memory footprint)
17+
- TTL: 300 seconds (5 minutes) — after expiry the next call re-verifies
18+
19+
Thread-safety: cachetools.TTLCache is NOT thread-safe, so we use an explicit
20+
asyncio-compatible pattern (single event loop = single thread for FastAPI).
21+
For multi-process deployments an external cache would be needed (out of scope
22+
for HomeLab single-instance design per spec Section 5).
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import bcrypt
28+
from cachetools import TTLCache
29+
30+
# _cache is module-level so test code can inspect/clear it
31+
_cache: TTLCache[tuple[str, str], bool] = TTLCache(maxsize=512, ttl=300)
32+
33+
34+
def verify_api_key(plaintext: str, hashed: str) -> bool:
35+
"""Return True if ``plaintext`` matches the bcrypt ``hashed`` value.
36+
37+
Results are cached for ``ttl`` seconds (default 300s / 5 minutes) to avoid
38+
repeated expensive bcrypt verifications.
39+
40+
Args:
41+
plaintext: The full API key as provided in the ``X-Label-Hub-Key`` header.
42+
hashed: The bcrypt hash stored in the DB.
43+
44+
Returns:
45+
True if the key is valid, False otherwise.
46+
"""
47+
cache_key = (plaintext, hashed)
48+
if cache_key in _cache:
49+
return _cache[cache_key]
50+
51+
result = bcrypt.checkpw(plaintext.encode(), hashed.encode())
52+
_cache[cache_key] = result
53+
return result
54+
55+
56+
def invalidate_cache(hashed: str) -> None:
57+
"""Remove all cache entries for a given hash (e.g. after key revocation).
58+
59+
Called when a key is revoked or the hash changes so that subsequent
60+
requests re-verify against the DB rather than getting a stale cache hit.
61+
"""
62+
keys_to_remove = [k for k in list(_cache.keys()) if k[1] == hashed]
63+
for k in keys_to_remove:
64+
_cache.pop(k, None)

backend/tests/unit/auth/__init__.py

Whitespace-only changes.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Unit tests for API key generation — Phase 7c Step 2.
2+
3+
RED phase: these tests must fail before key_generator.py exists.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import bcrypt
9+
import pytest
10+
11+
12+
def test_generate_api_key_importable():
13+
"""generate_api_key is importable from app.auth.key_generator."""
14+
from app.auth.key_generator import generate_api_key # noqa: F401
15+
assert generate_api_key is not None
16+
17+
18+
def test_generate_api_key_returns_three_tuple():
19+
from app.auth.key_generator import generate_api_key
20+
result = generate_api_key()
21+
assert len(result) == 3
22+
23+
24+
def test_plaintext_starts_with_lh_prefix():
25+
from app.auth.key_generator import generate_api_key
26+
plaintext, _, _ = generate_api_key()
27+
assert plaintext.startswith("lh_"), f"Expected lh_ prefix, got: {plaintext[:5]}"
28+
29+
30+
def test_prefix_is_first_12_chars_of_plaintext():
31+
from app.auth.key_generator import generate_api_key
32+
plaintext, prefix, _ = generate_api_key()
33+
assert prefix == plaintext[:12], f"prefix={prefix!r}, plaintext[:12]={plaintext[:12]!r}"
34+
35+
36+
def test_prefix_is_exactly_12_chars():
37+
from app.auth.key_generator import generate_api_key
38+
_, prefix, _ = generate_api_key()
39+
assert len(prefix) == 12, f"Expected 12 chars, got {len(prefix)}"
40+
41+
42+
def test_bcrypt_hash_verifies_against_plaintext():
43+
from app.auth.key_generator import generate_api_key
44+
plaintext, _, hashed = generate_api_key()
45+
assert bcrypt.checkpw(plaintext.encode(), hashed.encode()), (
46+
"bcrypt.checkpw failed — hash does not match plaintext"
47+
)
48+
49+
50+
def test_bcrypt_hash_rejects_wrong_plaintext():
51+
from app.auth.key_generator import generate_api_key
52+
_, _, hashed = generate_api_key()
53+
assert not bcrypt.checkpw(b"wrong_key", hashed.encode())
54+
55+
56+
def test_generate_produces_unique_keys():
57+
"""10 consecutive calls produce unique plaintexts (collision probability negligible)."""
58+
from app.auth.key_generator import generate_api_key
59+
plaintexts = [generate_api_key()[0] for _ in range(10)]
60+
assert len(set(plaintexts)) == 10, "Duplicate keys detected in 10 generations"
61+
62+
63+
def test_plaintext_body_is_urlsafe():
64+
"""Characters after lh_ prefix should be URL-safe (no +, /, =)."""
65+
from app.auth.key_generator import generate_api_key
66+
for _ in range(5):
67+
plaintext, _, _ = generate_api_key()
68+
body = plaintext[3:] # strip "lh_"
69+
assert "+" not in body and "/" not in body and "=" not in body, (
70+
f"Non-URL-safe chars in plaintext body: {body}"
71+
)
72+
73+
74+
def test_plaintext_has_sufficient_entropy():
75+
"""Plaintext body should be at least 43 chars (32 bytes base64url ≈ 43 chars)."""
76+
from app.auth.key_generator import generate_api_key
77+
plaintext, _, _ = generate_api_key()
78+
body = plaintext[3:]
79+
assert len(body) >= 43, f"Body too short for 256-bit entropy: {len(body)} chars"
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Unit tests for bcrypt verifier + LRU cache — Phase 7c Step 2."""
2+
3+
from __future__ import annotations
4+
5+
from unittest.mock import patch
6+
7+
import bcrypt
8+
import pytest
9+
10+
11+
def _make_hash(plaintext: str) -> str:
12+
return bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt(rounds=4)).decode()
13+
14+
15+
def test_verify_api_key_importable():
16+
from app.auth.verifier import verify_api_key
17+
assert verify_api_key is not None
18+
19+
20+
def test_verify_returns_true_for_correct_key():
21+
from app.auth.verifier import verify_api_key
22+
plaintext = "lh_testkey_correct_12345"
23+
hashed = _make_hash(plaintext)
24+
assert verify_api_key(plaintext, hashed) is True
25+
26+
27+
def test_verify_returns_false_for_wrong_key():
28+
from app.auth.verifier import verify_api_key
29+
hashed = _make_hash("lh_correct_key_abc123")
30+
assert verify_api_key("lh_wrong_key_xyz999", hashed) is False
31+
32+
33+
def test_verify_caches_result_on_second_call():
34+
"""After the first verify, subsequent calls with same inputs skip bcrypt."""
35+
from app.auth import verifier as verifier_module
36+
verifier_module._cache.clear()
37+
38+
plaintext = "lh_cache_test_key_001"
39+
hashed = _make_hash(plaintext)
40+
41+
bcrypt_call_count = [0]
42+
original_checkpw = bcrypt.checkpw
43+
44+
def counting_checkpw(pw, hsh):
45+
bcrypt_call_count[0] += 1
46+
return original_checkpw(pw, hsh)
47+
48+
with patch.object(bcrypt, "checkpw", side_effect=counting_checkpw):
49+
# First call — should invoke bcrypt
50+
result1 = verifier_module.verify_api_key(plaintext, hashed)
51+
# Second call — should use cache
52+
result2 = verifier_module.verify_api_key(plaintext, hashed)
53+
54+
assert result1 is True
55+
assert result2 is True
56+
assert bcrypt_call_count[0] == 1, (
57+
f"Expected 1 bcrypt call (cache hit on 2nd), got {bcrypt_call_count[0]}"
58+
)
59+
60+
61+
def test_verify_different_keys_call_bcrypt_each():
62+
"""Different plaintext/hash pairs are each verified separately."""
63+
from app.auth import verifier as verifier_module
64+
verifier_module._cache.clear()
65+
66+
p1, h1 = "lh_key_alpha_001", _make_hash("lh_key_alpha_001")
67+
p2, h2 = "lh_key_beta_002", _make_hash("lh_key_beta_002")
68+
69+
bcrypt_call_count = [0]
70+
original_checkpw = bcrypt.checkpw
71+
72+
def counting_checkpw(pw, hsh):
73+
bcrypt_call_count[0] += 1
74+
return original_checkpw(pw, hsh)
75+
76+
with patch.object(bcrypt, "checkpw", side_effect=counting_checkpw):
77+
verifier_module.verify_api_key(p1, h1)
78+
verifier_module.verify_api_key(p2, h2)
79+
80+
assert bcrypt_call_count[0] == 2
81+
82+
83+
def test_invalidate_cache_removes_entry():
84+
"""invalidate_cache removes a cached entry by hash."""
85+
from app.auth import verifier as verifier_module
86+
verifier_module._cache.clear()
87+
88+
plaintext = "lh_invalidate_test_001"
89+
hashed = _make_hash(plaintext)
90+
91+
# Prime cache
92+
verifier_module.verify_api_key(plaintext, hashed)
93+
assert (plaintext, hashed) in verifier_module._cache
94+
95+
verifier_module.invalidate_cache(hashed)
96+
assert (plaintext, hashed) not in verifier_module._cache

0 commit comments

Comments
 (0)