Skip to content

Commit db5ee2c

Browse files
committed
feat(security): apply uniform verify delay for all outcomes
Use min_delay/max_delay for every verify_key response and deprecate rrd to timing signal leakage while keeping backward compatibility.
1 parent 3cc9dba commit db5ee2c

File tree

6 files changed

+129
-65
lines changed

6 files changed

+129
-65
lines changed

src/fastapi_api_key/services/base.py

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import asyncio
22
import os
3+
import warnings
34
from dataclasses import dataclass
45
from datetime import datetime
56
from random import SystemRandom
67
from abc import ABC, abstractmethod
7-
from typing import Optional, Tuple, List
8+
from typing import List, Optional, Tuple
89

910
from fastapi_api_key.domain.entities import ApiKey
1011
from fastapi_api_key.domain.errors import KeyNotProvided, KeyNotFound, InvalidKey, ConfigurationError
@@ -45,7 +46,9 @@ class AbstractApiKeyService(ABC):
4546
hasher: Hasher for hashing secrets. Defaults to Argon2ApiKeyHasher.
4647
separator: Separator in API key format. Defaults to "-".
4748
global_prefix: Prefix for API keys. Defaults to "ak".
48-
rrd: Random response delay for timing attack mitigation. Defaults to 1/3.
49+
rrd: Deprecated random response delay. Ignored if provided.
50+
min_delay: Minimum delay (seconds) applied to all verify responses.
51+
max_delay: Maximum delay (seconds) applied to all verify responses.
4952
5053
Notes:
5154
The global key_id is pure cosmetic, it is not used for anything else.
@@ -59,16 +62,33 @@ def __init__(
5962
hasher: ApiKeyHasher,
6063
separator: str = DEFAULT_SEPARATOR,
6164
global_prefix: str = DEFAULT_GLOBAL_PREFIX,
62-
rrd: float = 1 / 3,
65+
rrd: Optional[float] = None,
66+
min_delay: float = 0.1,
67+
max_delay: float = 0.3,
6368
) -> None:
6469
# Warning developer that separator is automatically added to the global key_id
6570
if separator in global_prefix:
6671
raise ValueError("Separator must not be in the global key_id")
6772

73+
if rrd is not None:
74+
warnings.warn(
75+
"rrd is deprecated and ignored. Use min_delay/max_delay instead.",
76+
DeprecationWarning,
77+
stacklevel=2,
78+
)
79+
80+
if min_delay < 0 or max_delay < 0:
81+
raise ValueError("min_delay and max_delay must be non-negative")
82+
83+
if max_delay < min_delay:
84+
raise ValueError("max_delay must be greater than or equal to min_delay")
85+
6886
self._repo = repo
6987
self._hasher = hasher
7088

7189
self.rrd = rrd
90+
self.min_delay = min_delay
91+
self.max_delay = max_delay
7292
self.separator = separator
7393
self.global_prefix = global_prefix
7494
self._system_random = SystemRandom()
@@ -216,14 +236,21 @@ async def verify_key(self, api_key: str, required_scopes: Optional[List[str]] =
216236
If the entity is inactive or expired, an exception is raised.
217237
If the check between the provided plain key and the stored hash fails,
218238
an InvalidKey exception is raised. Else, the entity is returned.
239+
A randomized delay is always applied to reduce timing signals.
219240
"""
220241
try:
221-
return await self._verify_key(api_key, required_scopes)
222-
except Exception as e:
223-
# Add a small jitter to make timing-based probing harder to profile.
224-
wait = self._system_random.uniform(self.rrd, self.rrd * 2)
225-
await asyncio.sleep(wait)
226-
raise e
242+
result = await self._verify_key(api_key, required_scopes)
243+
except Exception as exc:
244+
await self._apply_delay()
245+
raise exc
246+
247+
await self._apply_delay()
248+
return result
249+
250+
async def _apply_delay(self) -> None:
251+
"""Apply a randomized delay to reduce timing signals."""
252+
wait = self._system_random.uniform(self.min_delay, self.max_delay)
253+
await asyncio.sleep(wait)
227254

228255
@abstractmethod
229256
async def _verify_key(self, api_key: str, required_scopes: Optional[List[str]] = None) -> ApiKey:
@@ -249,6 +276,7 @@ async def _verify_key(self, api_key: str, required_scopes: Optional[List[str]] =
249276
If the entity is inactive or expired, an exception is raised.
250277
If the check between the provided plain key and the stored hash fails,
251278
an InvalidKey exception is raised. Else, the entity is returned.
279+
A randomized delay is always applied to reduce timing signals.
252280
"""
253281
...
254282

@@ -280,14 +308,18 @@ def __init__(
280308
hasher: ApiKeyHasher,
281309
separator: str = DEFAULT_SEPARATOR,
282310
global_prefix: str = DEFAULT_GLOBAL_PREFIX,
283-
rrd: float = 1 / 3,
311+
rrd: Optional[float] = None,
312+
min_delay: float = 0.1,
313+
max_delay: float = 0.3,
284314
) -> None:
285315
super().__init__(
286316
repo=repo,
287317
hasher=hasher,
288318
separator=separator,
289319
global_prefix=global_prefix,
290320
rrd=rrd,
321+
min_delay=min_delay,
322+
max_delay=max_delay,
291323
)
292324

293325
async def load_dotenv(self, envvar_prefix: str = "API_KEY_"):

src/fastapi_api_key/services/cached.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
) from e
77

88
import hashlib
9-
from typing import Optional, List
9+
from typing import List, Optional
1010

1111
import aiocache
1212
from aiocache import BaseCache
@@ -59,14 +59,18 @@ def __init__(
5959
cache_ttl: int = 300,
6060
separator: str = DEFAULT_SEPARATOR,
6161
global_prefix: str = "ak",
62-
rrd: float = 1 / 3,
62+
rrd: Optional[float] = None,
63+
min_delay: float = 0.1,
64+
max_delay: float = 0.3,
6365
):
6466
super().__init__(
6567
repo=repo,
6668
hasher=hasher,
6769
separator=separator,
6870
global_prefix=global_prefix,
6971
rrd=rrd,
72+
min_delay=min_delay,
73+
max_delay=max_delay,
7074
)
7175
self.cache_prefix = cache_prefix
7276
self.cache_ttl = cache_ttl
@@ -112,7 +116,7 @@ async def _verify_key(self, api_key: Optional[str] = None, required_scopes: Opti
112116

113117
# Compute cache key from the full API key (secure: requires complete key)
114118
cache_key = _compute_cache_key(parsed.raw)
115-
cached_entity: ApiKey = await self.cache.get(cache_key)
119+
cached_entity: Optional[ApiKey] = await self.cache.get(cache_key)
116120

117121
if cached_entity:
118122
# Cache hit: the full API key is correct (hash matched)

tests/unit/test_api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ def service(repo: InMemoryApiKeyRepository) -> ApiKeyService:
3636
return ApiKeyService(
3737
repo=repo,
3838
hasher=MockApiKeyHasher(pepper="test-pepper"),
39-
rrd=0, # No random delay for tests
39+
min_delay=0,
40+
max_delay=0,
4041
)
4142

4243

tests/unit/test_cached_service.py

Lines changed: 8 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ def service(mock_cache: AsyncMock) -> CachedApiKeyService:
3838
hasher=MockApiKeyHasher(pepper="test-pepper"),
3939
separator="-",
4040
global_prefix="ak",
41-
rrd=0,
41+
min_delay=0,
42+
max_delay=0,
4243
)
4344

4445

@@ -268,7 +269,8 @@ async def test_cache_set_uses_default_ttl(
268269
repo=InMemoryApiKeyRepository(),
269270
cache=mock_cache,
270271
hasher=MockApiKeyHasher(pepper="test"),
271-
rrd=0,
272+
min_delay=0,
273+
max_delay=0,
272274
)
273275
entity, api_key = await service.create(name="test")
274276
mock_cache.get.return_value = None # Cache miss
@@ -290,7 +292,8 @@ async def test_cache_set_uses_custom_ttl(
290292
cache=mock_cache,
291293
cache_ttl=60,
292294
hasher=MockApiKeyHasher(pepper="test"),
293-
rrd=0,
295+
min_delay=0,
296+
max_delay=0,
294297
)
295298
entity, api_key = await service.create(name="test")
296299
mock_cache.get.return_value = None
@@ -305,42 +308,7 @@ def test_default_ttl_is_300(self):
305308
service = CachedApiKeyService(
306309
repo=InMemoryApiKeyRepository(),
307310
hasher=MockApiKeyHasher(pepper="test"),
308-
rrd=0,
311+
min_delay=0,
312+
max_delay=0,
309313
)
310314
assert service.cache_ttl == 300
311-
312-
313-
class TestDefaultCache:
314-
"""Tests for default cache behavior."""
315-
316-
@pytest.mark.asyncio
317-
async def test_default_cache_is_simple_memory(self):
318-
"""Service uses SimpleMemoryCache by default."""
319-
import aiocache
320-
321-
service = CachedApiKeyService(
322-
repo=InMemoryApiKeyRepository(),
323-
hasher=MockApiKeyHasher(pepper="test"),
324-
rrd=0,
325-
)
326-
327-
assert isinstance(service.cache, aiocache.SimpleMemoryCache)
328-
329-
@pytest.mark.asyncio
330-
async def test_service_works_without_explicit_cache(self):
331-
"""Service works end-to-end with default cache."""
332-
service = CachedApiKeyService(
333-
repo=InMemoryApiKeyRepository(),
334-
hasher=MockApiKeyHasher(pepper="test"),
335-
rrd=0,
336-
)
337-
338-
entity, api_key = await service.create(name="test")
339-
340-
# First verify (cache miss)
341-
result = await service.verify_key(api_key)
342-
assert result.id_ == entity.id_
343-
344-
# Second verify (cache hit)
345-
result = await service.verify_key(api_key)
346-
assert result.id_ == entity.id_

tests/unit/test_cli.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ def service(repo: InMemoryApiKeyRepository) -> ApiKeyService:
3636
return ApiKeyService(
3737
repo=repo,
3838
hasher=MockApiKeyHasher(pepper="test-pepper"),
39-
rrd=0, # No random delay for tests
39+
min_delay=0,
40+
max_delay=0,
4041
)
4142

4243

tests/unit/test_service.py

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ def service() -> ApiKeyService:
3535
hasher=MockApiKeyHasher(pepper="test-pepper"),
3636
separator=".",
3737
global_prefix="ak",
38-
rrd=0, # No delay for tests
38+
min_delay=0,
39+
max_delay=0,
3940
)
4041

4142

@@ -310,15 +311,16 @@ async def test_verify_no_required_scopes(self, service: ApiKeyService):
310311

311312

312313
class TestServiceTimingAttackMitigation:
313-
"""Tests for RRD (random response delay) timing attack mitigation."""
314+
"""Tests for response delay timing attack mitigation."""
314315

315316
@pytest.mark.asyncio
316-
async def test_rrd_adds_delay_on_error(self):
317+
async def test_delay_applies_on_error(self):
317318
"""verify_key() adds random delay on verification failure."""
318319
service = ApiKeyService(
319320
repo=InMemoryApiKeyRepository(),
320321
hasher=MockApiKeyHasher(pepper="test"),
321-
rrd=0.1, # 100ms base delay
322+
min_delay=0.1,
323+
max_delay=0.3,
322324
)
323325

324326
with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
@@ -328,7 +330,35 @@ async def test_rrd_adds_delay_on_error(self):
328330
mock_sleep.assert_awaited_once()
329331
assert mock_sleep.await_args, "Expected sleep to be called"
330332
delay = mock_sleep.await_args.args[0]
331-
assert 0.1 <= delay <= 0.2 # Between rrd and rrd*2
333+
assert 0.1 <= delay <= 0.3
334+
335+
@pytest.mark.asyncio
336+
async def test_delay_applies_on_success(self):
337+
"""verify_key() adds random delay on successful verification."""
338+
service = ApiKeyService(
339+
repo=InMemoryApiKeyRepository(),
340+
hasher=MockApiKeyHasher(pepper="test"),
341+
min_delay=0.1,
342+
max_delay=0.3,
343+
)
344+
_, full_key = await service.create(name="ok")
345+
346+
with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
347+
await service.verify_key(full_key)
348+
349+
mock_sleep.assert_awaited_once()
350+
assert mock_sleep.await_args, "Expected sleep to be called"
351+
delay = mock_sleep.await_args.args[0]
352+
assert 0.1 <= delay <= 0.3
353+
354+
def test_rrd_warns_and_is_ignored(self):
355+
"""rrd emits a deprecation warning when provided."""
356+
with pytest.warns(DeprecationWarning, match="rrd is deprecated"):
357+
ApiKeyService(
358+
repo=InMemoryApiKeyRepository(),
359+
hasher=MockApiKeyHasher(pepper="test"),
360+
rrd=0.1,
361+
)
332362

333363

334364
class TestServiceConstructor:
@@ -351,10 +381,33 @@ def test_custom_prefix_and_separator(self):
351381
hasher=MockApiKeyHasher(pepper="test"),
352382
separator=":",
353383
global_prefix="KEY",
384+
min_delay=0,
385+
max_delay=0,
354386
)
387+
355388
assert service.separator == ":"
356389
assert service.global_prefix == "KEY"
357390

391+
def test_negative_delay_raises(self):
392+
"""Constructor rejects negative delay values."""
393+
with pytest.raises(ValueError, match="non-negative"):
394+
ApiKeyService(
395+
repo=InMemoryApiKeyRepository(),
396+
hasher=MockApiKeyHasher(pepper="test"),
397+
min_delay=-0.1,
398+
max_delay=0.1,
399+
)
400+
401+
def test_max_delay_less_than_min_raises(self):
402+
"""Constructor rejects max_delay below min_delay."""
403+
with pytest.raises(ValueError, match="greater than or equal"):
404+
ApiKeyService(
405+
repo=InMemoryApiKeyRepository(),
406+
hasher=MockApiKeyHasher(pepper="test"),
407+
min_delay=0.2,
408+
max_delay=0.1,
409+
)
410+
358411

359412
class TestServiceListFindCount:
360413
"""Tests for list(), find(), count() methods."""
@@ -364,7 +417,8 @@ def service(self) -> ApiKeyService:
364417
return ApiKeyService(
365418
repo=InMemoryApiKeyRepository(),
366419
hasher=MockApiKeyHasher(pepper="test"),
367-
rrd=0,
420+
min_delay=0,
421+
max_delay=0,
368422
)
369423

370424
@pytest.mark.asyncio
@@ -419,7 +473,8 @@ async def test_load_dotenv_creates_keys(self, monkeypatch: pytest.MonkeyPatch):
419473
hasher=MockApiKeyHasher(pepper="test"),
420474
separator="-",
421475
global_prefix="ak",
422-
rrd=0,
476+
min_delay=0,
477+
max_delay=0,
423478
)
424479

425480
# Set environment variables
@@ -442,7 +497,8 @@ async def test_load_dotenv_no_keys_raises(self, monkeypatch: pytest.MonkeyPatch)
442497
service = ApiKeyService(
443498
repo=InMemoryApiKeyRepository(),
444499
hasher=MockApiKeyHasher(pepper="test"),
445-
rrd=0,
500+
min_delay=0,
501+
max_delay=0,
446502
)
447503

448504
# Clear any existing API_KEY_ vars
@@ -461,7 +517,8 @@ async def test_load_dotenv_custom_prefix(self, monkeypatch: pytest.MonkeyPatch):
461517
hasher=MockApiKeyHasher(pepper="test"),
462518
separator="-",
463519
global_prefix="ak",
464-
rrd=0,
520+
min_delay=0,
521+
max_delay=0,
465522
)
466523

467524
monkeypatch.setenv("MYAPP_KEY_TEST", "ak-abc123def456ghij-secretsecretsecretsecretsecretsecretsecretsecret12")
@@ -483,7 +540,8 @@ def service(self) -> ApiKeyService:
483540
hasher=MockApiKeyHasher(pepper="test"),
484541
separator=".",
485542
global_prefix="ak",
486-
rrd=0,
543+
min_delay=0,
544+
max_delay=0,
487545
)
488546

489547
@pytest.mark.asyncio

0 commit comments

Comments
 (0)