|
1 | 1 | """API key management service for developer-facing products.""" |
2 | 2 |
|
| 3 | +import base64 |
| 4 | +import binascii |
3 | 5 | import hashlib |
4 | 6 | import hmac |
5 | 7 | import secrets |
6 | 8 | from datetime import UTC, datetime, timedelta |
7 | 9 | from typing import Any |
8 | 10 |
|
9 | 11 | from fastcrud.types import GetMultiResponseDict |
| 12 | +from sqlalchemy import select |
10 | 13 | from sqlalchemy.ext.asyncio import AsyncSession |
11 | 14 |
|
12 | | -from ...infrastructure.config.settings import get_settings |
13 | 15 | from ...infrastructure.logging import get_logger |
14 | 16 | from ..common.exceptions import PermissionDeniedError, ResourceNotFoundError |
15 | 17 | from .crud import crud_api_keys, crud_key_permissions, crud_key_usage |
16 | 18 | from .enums import KeyPermissionAction, KeyPermissionResource |
| 19 | +from .models import APIKey |
17 | 20 | from .schemas import ( |
18 | 21 | APIKeyCreate, |
19 | 22 | APIKeyCreateInternal, |
|
33 | 36 | ) |
34 | 37 |
|
35 | 38 | 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 |
37 | 44 |
|
38 | 45 |
|
39 | 46 | class APIKeyService: |
@@ -62,17 +69,49 @@ def _generate_api_key(self) -> tuple[str, str, str]: |
62 | 69 | return api_key, prefix, key_hash |
63 | 70 |
|
64 | 71 | 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. |
66 | 73 |
|
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``. |
70 | 76 | """ |
71 | | - return hmac.new( |
72 | | - settings.SECRET_KEY.encode("utf-8"), |
| 77 | + salt = secrets.token_bytes(16) |
| 78 | + derived = hashlib.scrypt( |
73 | 79 | 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) |
76 | 115 |
|
77 | 116 | async def create_api_key( |
78 | 117 | self, |
@@ -263,15 +302,31 @@ async def validate_api_key( |
263 | 302 | Returns: |
264 | 303 | Validation response with key details and permissions |
265 | 304 | """ |
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] |
268 | 312 |
|
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: |
270 | 323 | return APIKeyValidationResponse( |
271 | 324 | is_valid=False, |
272 | 325 | error_message="Invalid API key", |
273 | 326 | ) |
274 | 327 |
|
| 328 | + key = APIKeyRead.model_validate(matched).model_dump() |
| 329 | + |
275 | 330 | if not key["is_active"]: |
276 | 331 | return APIKeyValidationResponse( |
277 | 332 | is_valid=False, |
|
0 commit comments