Skip to content

Commit 89bd43f

Browse files
committed
fixes
1 parent 7f1a35a commit 89bd43f

4 files changed

Lines changed: 80 additions & 21 deletions

File tree

.github/workflows/type-checking.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
SECRET_KEY: test-secret-key-for-testing-only
2727

2828
- name: Mypy (cli)
29-
run: cd cli && uv run --no-sync mypy src/cli
29+
run: cd cli && uv run --no-sync mypy -p cli
3030
env:
3131
ENVIRONMENT: local
3232
SECRET_KEY: test-secret-key-for-testing-only

backend/src/modules/api_keys/service.py

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
"""API key management service for developer-facing products."""
22

3+
import base64
4+
import binascii
35
import hashlib
46
import hmac
57
import secrets
68
from datetime import UTC, datetime, timedelta
79
from typing import Any
810

911
from fastcrud.types import GetMultiResponseDict
12+
from sqlalchemy import select
1013
from sqlalchemy.ext.asyncio import AsyncSession
1114

12-
from ...infrastructure.config.settings import get_settings
1315
from ...infrastructure.logging import get_logger
1416
from ..common.exceptions import PermissionDeniedError, ResourceNotFoundError
1517
from .crud import crud_api_keys, crud_key_permissions, crud_key_usage
1618
from .enums import KeyPermissionAction, KeyPermissionResource
19+
from .models import APIKey
1720
from .schemas import (
1821
APIKeyCreate,
1922
APIKeyCreateInternal,
@@ -33,7 +36,11 @@
3336
)
3437

3538
logger = get_logger()
36-
settings = get_settings()
39+
40+
_SCRYPT_N = 2**14
41+
_SCRYPT_R = 8
42+
_SCRYPT_P = 1
43+
_SCRYPT_DKLEN = 32
3744

3845

3946
class APIKeyService:
@@ -62,17 +69,49 @@ def _generate_api_key(self) -> tuple[str, str, str]:
6269
return api_key, prefix, key_hash
6370

6471
def _hash_api_key(self, api_key: str) -> str:
65-
"""Hash an API key for storage or comparison.
72+
"""Hash an API key for storage using scrypt with a per-row salt.
6673
67-
HMAC-SHA256 with SECRET_KEY as the pepper. Deterministic so DB lookup
68-
by `key_hash` stays O(1); the server-side secret means a stolen
69-
`key_hash` column alone cannot be used to forge or verify keys offline.
74+
Stored format: ``scrypt$N$r$p$salt_b64$derived_b64``. Non-deterministic;
75+
DB lookup uses ``key_prefix`` (already indexed) instead of ``key_hash``.
7076
"""
71-
return hmac.new(
72-
settings.SECRET_KEY.encode("utf-8"),
77+
salt = secrets.token_bytes(16)
78+
derived = hashlib.scrypt(
7379
api_key.encode("utf-8"),
74-
hashlib.sha256,
75-
).hexdigest()
80+
salt=salt,
81+
n=_SCRYPT_N,
82+
r=_SCRYPT_R,
83+
p=_SCRYPT_P,
84+
dklen=_SCRYPT_DKLEN,
85+
)
86+
salt_b64 = base64.b64encode(salt).decode("ascii")
87+
derived_b64 = base64.b64encode(derived).decode("ascii")
88+
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt_b64}${derived_b64}"
89+
90+
def _verify_api_key(self, api_key: str, stored_hash: str) -> bool:
91+
"""Verify a candidate ``api_key`` against a stored scrypt hash."""
92+
try:
93+
scheme, n_str, r_str, p_str, salt_b64, derived_b64 = stored_hash.split("$", 5)
94+
except ValueError:
95+
return False
96+
if scheme != "scrypt":
97+
return False
98+
try:
99+
n = int(n_str)
100+
r = int(r_str)
101+
p = int(p_str)
102+
salt = base64.b64decode(salt_b64)
103+
expected = base64.b64decode(derived_b64)
104+
except (ValueError, binascii.Error):
105+
return False
106+
actual = hashlib.scrypt(
107+
api_key.encode("utf-8"),
108+
salt=salt,
109+
n=n,
110+
r=r,
111+
p=p,
112+
dklen=len(expected),
113+
)
114+
return hmac.compare_digest(actual, expected)
76115

77116
async def create_api_key(
78117
self,
@@ -263,15 +302,31 @@ async def validate_api_key(
263302
Returns:
264303
Validation response with key details and permissions
265304
"""
266-
key_hash = self._hash_api_key(api_key)
267-
key = await crud_api_keys.get(db=db, key_hash=key_hash, schema_to_select=APIKeyRead)
305+
parts = api_key.split("_", 2)
306+
if len(parts) != 3 or parts[0] != "fai":
307+
return APIKeyValidationResponse(
308+
is_valid=False,
309+
error_message="Invalid API key",
310+
)
311+
prefix = parts[1]
268312

269-
if not key:
313+
result = await db.execute(select(APIKey).where(APIKey.key_prefix == prefix).execution_options(populate_existing=True))
314+
candidates = result.scalars().all()
315+
316+
matched: APIKey | None = None
317+
for candidate in candidates:
318+
if self._verify_api_key(api_key, candidate.key_hash):
319+
matched = candidate
320+
break
321+
322+
if matched is None:
270323
return APIKeyValidationResponse(
271324
is_valid=False,
272325
error_message="Invalid API key",
273326
)
274327

328+
key = APIKeyRead.model_validate(matched).model_dump()
329+
275330
if not key["is_active"]:
276331
return APIKeyValidationResponse(
277332
is_valid=False,

backend/tests/conftest.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from src.infrastructure.auth.session.backends.memory import MemorySessionStorage
2121
from src.infrastructure.auth.session.dependencies import get_current_superuser, get_current_user
22-
from src.infrastructure.auth.session.schemas import CSRFToken
22+
from src.infrastructure.auth.session.schemas import CSRFToken, SessionData
2323
from src.infrastructure.auth.utils import get_password_hash
2424
from src.infrastructure.config.settings import Settings, get_settings
2525
from src.infrastructure.database.session import Base, async_session
@@ -258,8 +258,8 @@ async def override_get_current_superuser():
258258
@pytest.fixture(autouse=True)
259259
def mock_session_backend(monkeypatch):
260260
"""Use in-memory session backend instead of Redis during tests."""
261-
memory_storage = MemorySessionStorage(prefix="session:", expiration=1800)
262-
memory_csrf_storage = MemorySessionStorage(prefix="csrf:", expiration=1800)
261+
memory_storage: MemorySessionStorage[SessionData] = MemorySessionStorage(prefix="session:", expiration=1800)
262+
memory_csrf_storage: MemorySessionStorage[CSRFToken] = MemorySessionStorage(prefix="csrf:", expiration=1800)
263263

264264
def override_session_dependency(backend, model_type, **kwargs):
265265
if model_type == CSRFToken:

backend/tests/unit/modules/api_keys/test_service.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -370,15 +370,19 @@ async def test_get_user_summary(api_key_service, db_session: AsyncSession, test_
370370

371371

372372
@pytest.mark.asyncio
373-
async def test_api_key_hash_consistency(api_key_service):
374-
"""Test that API key hashing is consistent."""
373+
async def test_api_key_hash_roundtrip(api_key_service):
374+
"""Hashing produces a fresh salt each call; verifying must still succeed."""
375375
test_key = "fai_test_key_12345"
376376

377377
hash1 = api_key_service._hash_api_key(test_key)
378378
hash2 = api_key_service._hash_api_key(test_key)
379379

380-
assert hash1 == hash2
381-
assert len(hash1) == 64 # SHA256 hex digest length
380+
assert hash1 != hash2
381+
assert hash1.startswith("scrypt$")
382+
assert hash2.startswith("scrypt$")
383+
assert api_key_service._verify_api_key(test_key, hash1)
384+
assert api_key_service._verify_api_key(test_key, hash2)
385+
assert not api_key_service._verify_api_key("fai_wrong_key", hash1)
382386

383387

384388
@pytest.mark.asyncio

0 commit comments

Comments
 (0)