Skip to content

Commit 1105a8f

Browse files
committed
replaced in memory nonce in redis
1 parent 73a384a commit 1105a8f

3 files changed

Lines changed: 92 additions & 99 deletions

File tree

quantara/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ structlog = ">=24.1.0"
4141
black = "24.8.0"
4242
isort = "5.13.2"
4343
pre-commit = "4.0.1"
44+
fakeredis = "^2.26.1"
4445

4546
[build-system]
4647
requires = ["poetry-core"]

quantara/web_app/api/wallet_auth.py

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,60 @@
1-
"""Wallet authentication: Ed25519 challenge-response signature verification."""
1+
"""Wallet authentication: Ed25519 challenge-response signature verification.
2+
3+
Nonces are stored in Redis (shared across uvicorn workers and surviving a
4+
deploy) rather than in per-process memory. Redis TTL handles expiry, so no
5+
manual pruning is required. The nonce itself is never logged -- it is a seed
6+
for replay attacks if leaked.
7+
"""
28
import secrets
3-
import time
4-
from typing import Dict, Tuple
59

10+
import redis.asyncio as redis
611
from fastapi import APIRouter, Header, HTTPException, Query
712

813
from stellar_sdk import Keypair
914

15+
from web_app.contract_tools.cache import get_redis_pool
16+
1017
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
1118

12-
_nonce_store: Dict[str, Tuple[str, float]] = {}
1319
NONCE_TTL: int = 300 # seconds
20+
NONCE_KEY_PREFIX: str = "quantara:nonce:"
1421

1522

16-
def _clean_expired_nonces() -> None:
17-
"""Remove nonces past their TTL."""
18-
cutoff = time.monotonic() - NONCE_TTL
19-
expired = [n for n, (_, ts) in _nonce_store.items() if ts < cutoff]
20-
for n in expired:
21-
_nonce_store.pop(n, None)
23+
async def _redis() -> redis.Redis:
24+
"""Return an async Redis client backed by the shared connection pool."""
25+
return redis.Redis(connection_pool=await get_redis_pool())
2226

2327

24-
def _generate_nonce(wallet_id: str) -> str:
25-
"""Generate a cryptographically secure nonce bound to wallet_id."""
26-
_clean_expired_nonces()
27-
nonce = secrets.token_hex(32)
28-
_nonce_store[nonce] = (wallet_id, time.monotonic())
29-
return nonce
28+
def _nonce_key(nonce: str) -> str:
29+
return f"{NONCE_KEY_PREFIX}{nonce}"
3030

3131

32-
def _consume_nonce(nonce: str, wallet_id: str) -> bool:
32+
async def _generate_nonce(wallet_id: str) -> str:
33+
"""Generate a cryptographically secure nonce bound to wallet_id.
34+
35+
Issued with SET ... EX NONCE_TTL NX so no two workers can ever return the
36+
same nonce; on the astronomically-unlikely collision we retry.
37+
"""
38+
client = await _redis()
39+
while True:
40+
nonce = secrets.token_hex(32)
41+
if await client.set(_nonce_key(nonce), wallet_id, ex=NONCE_TTL, nx=True):
42+
return nonce
43+
44+
45+
async def _consume_nonce(nonce: str, wallet_id: str) -> bool:
3346
"""
3447
Validate and consume a nonce atomically.
3548
Returns True only when the nonce exists, has not expired, and belongs to wallet_id.
36-
The nonce is always removed to prevent replay even on a wallet mismatch.
49+
50+
GETDEL reads and deletes in a single atomic step, so two concurrent
51+
consumers of the same nonce get exactly one value and one miss -- and the
52+
nonce is always removed to prevent replay even on a wallet mismatch.
3753
"""
38-
_clean_expired_nonces()
39-
entry = _nonce_store.pop(nonce, None)
40-
if entry is None:
54+
client = await _redis()
55+
stored_wallet_id = await client.getdel(_nonce_key(nonce))
56+
if stored_wallet_id is None:
4157
return False
42-
stored_wallet_id, _ = entry
4358
return stored_wallet_id == wallet_id
4459

4560

@@ -60,7 +75,7 @@ async def get_nonce(
6075
) -> dict:
6176
"""Issue a one-time nonce for wallet_id. Sign the nonce with your Stellar private key
6277
and pass it as X-Signature on the next authenticated request."""
63-
nonce = _generate_nonce(wallet_id)
78+
nonce = await _generate_nonce(wallet_id)
6479
return {"nonce": nonce, "expires_in": NONCE_TTL}
6580

6681

@@ -70,7 +85,7 @@ async def verify_wallet_signature(
7085
x_signature: str = Header(..., description="Hex-encoded Ed25519 signature of the nonce"),
7186
) -> str:
7287
"""FastAPI dependency -- verifies a Stellar wallet signature and returns the wallet_id."""
73-
if not _consume_nonce(x_nonce, x_wallet_id):
88+
if not await _consume_nonce(x_nonce, x_wallet_id):
7489
raise HTTPException(
7590
status_code=401,
7691
detail="Invalid or expired nonce. Request a fresh nonce from /api/auth/nonce.",

quantara/web_app/tests/test_wallet_auth.py

Lines changed: 52 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,26 @@
11
"""
2-
Tests for wallet signature authentication (Issue #41).
2+
Tests for wallet signature authentication (Issue #41, #192).
33
44
Covers:
55
- Nonce generation: uniqueness, storage, binding to wallet_id.
66
- Nonce consumption: valid path, replay prevention, wrong wallet, unknown nonce.
7-
- Nonce expiry: expired nonces are pruned by _clean_expired_nonces.
87
- Signature verification: valid key, wrong key, tampered message, bad hex, bad public key.
98
- verify_wallet_signature dependency: 401 on bad nonce, 401 on bad sig, wallet_id on success.
109
- GET /api/auth/nonce endpoint: returns nonce + expires_in.
1110
"""
1211

13-
import time
14-
1512
import pytest
13+
import fakeredis.aioredis
1614
from fastapi import FastAPI, HTTPException
1715
from fastapi.testclient import TestClient
16+
from unittest.mock import patch
1817

1918
from web_app.api.wallet_auth import (
2019
NONCE_TTL,
21-
_clean_expired_nonces,
20+
NONCE_KEY_PREFIX,
2221
_consume_nonce,
2322
_generate_nonce,
24-
_nonce_store,
23+
_nonce_key,
2524
_verify_stellar_signature,
2625
router,
2726
verify_wallet_signature,
@@ -33,97 +32,74 @@
3332
# ---------------------------------------------------------------------------
3433

3534
@pytest.fixture(autouse=True)
36-
def _clear_nonce_store():
37-
"""Ensure an empty nonce store before and after every test."""
38-
_nonce_store.clear()
39-
yield
40-
_nonce_store.clear()
35+
async def _mock_redis():
36+
"""Mock Redis with fakeredis for all tests."""
37+
fake_redis = fakeredis.aioredis.FakeRedis()
38+
with patch("web_app.api.wallet_auth._redis") as mock_redis:
39+
mock_redis.return_value = fake_redis
40+
# Clear all nonce keys before and after each test
41+
async def clear_keys():
42+
keys = await fake_redis.keys(f"{NONCE_KEY_PREFIX}*")
43+
if keys:
44+
await fake_redis.delete(*keys)
45+
await clear_keys()
46+
yield fake_redis
47+
await clear_keys()
4148

4249

4350
# ---------------------------------------------------------------------------
4451
# Nonce generation
4552
# ---------------------------------------------------------------------------
4653

54+
@pytest.mark.asyncio
4755
class TestGenerateNonce:
48-
def test_returns_64_char_hex_string(self):
49-
nonce = _generate_nonce("GABCDEF")
56+
async def test_returns_64_char_hex_string(self):
57+
nonce = await _generate_nonce("GABCDEF")
5058
assert isinstance(nonce, str)
5159
assert len(nonce) == 64
5260

53-
def test_each_call_produces_unique_nonce(self):
54-
n1 = _generate_nonce("GABCDEF")
55-
n2 = _generate_nonce("GABCDEF")
61+
async def test_each_call_produces_unique_nonce(self):
62+
n1 = await _generate_nonce("GABCDEF")
63+
n2 = await _generate_nonce("GABCDEF")
5664
assert n1 != n2
5765

58-
def test_nonce_stored_with_correct_wallet_id(self):
66+
async def test_nonce_stored_with_correct_wallet_id(self, _mock_redis):
5967
wallet_id = "GABCDEF123"
60-
nonce = _generate_nonce(wallet_id)
61-
assert nonce in _nonce_store
62-
stored_wallet, _ = _nonce_store[nonce]
68+
nonce = await _generate_nonce(wallet_id)
69+
stored_wallet = await _mock_redis.get(_nonce_key(nonce))
6370
assert stored_wallet == wallet_id
6471

65-
def test_nonce_stored_with_recent_timestamp(self):
66-
before = time.monotonic()
67-
nonce = _generate_nonce("GTEST")
68-
after = time.monotonic()
69-
_, ts = _nonce_store[nonce]
70-
assert before <= ts <= after
71-
7272

7373
# ---------------------------------------------------------------------------
7474
# Nonce consumption
7575
# ---------------------------------------------------------------------------
7676

77+
@pytest.mark.asyncio
7778
class TestConsumeNonce:
78-
def test_valid_nonce_and_wallet_returns_true(self):
79+
async def test_valid_nonce_and_wallet_returns_true(self):
7980
wallet_id = "GABCDEF"
80-
nonce = _generate_nonce(wallet_id)
81-
assert _consume_nonce(nonce, wallet_id) is True
81+
nonce = await _generate_nonce(wallet_id)
82+
assert await _consume_nonce(nonce, wallet_id) is True
8283

83-
def test_nonce_removed_after_consumption(self):
84+
async def test_nonce_removed_after_consumption(self, _mock_redis):
8485
wallet_id = "GABCDEF"
85-
nonce = _generate_nonce(wallet_id)
86-
_consume_nonce(nonce, wallet_id)
87-
assert nonce not in _nonce_store
86+
nonce = await _generate_nonce(wallet_id)
87+
await _consume_nonce(nonce, wallet_id)
88+
stored_wallet = await _mock_redis.get(_nonce_key(nonce))
89+
assert stored_wallet is None
8890

89-
def test_replay_attack_fails(self):
91+
async def test_replay_attack_fails(self):
9092
wallet_id = "GABCDEF"
91-
nonce = _generate_nonce(wallet_id)
92-
assert _consume_nonce(nonce, wallet_id) is True
93-
assert _consume_nonce(nonce, wallet_id) is False
93+
nonce = await _generate_nonce(wallet_id)
94+
assert await _consume_nonce(nonce, wallet_id) is True
95+
assert await _consume_nonce(nonce, wallet_id) is False
9496

95-
def test_wrong_wallet_id_returns_false(self):
96-
nonce = _generate_nonce("GOWNER")
97-
assert _consume_nonce(nonce, "GATTACKER") is False
97+
async def test_wrong_wallet_id_returns_false(self):
98+
nonce = await _generate_nonce("GOWNER")
99+
assert await _consume_nonce(nonce, "GATTACKER") is False
98100

99-
def test_unknown_nonce_returns_false(self):
100-
assert _consume_nonce("deadbeef" * 8, "GABCDEF") is False
101-
102-
103-
# ---------------------------------------------------------------------------
104-
# Nonce expiry
105-
# ---------------------------------------------------------------------------
106-
107-
class TestCleanExpiredNonces:
108-
def test_removes_expired_nonce(self):
109-
wallet_id = "GEXPIRED"
110-
nonce = _generate_nonce(wallet_id)
111-
_nonce_store[nonce] = (wallet_id, time.monotonic() - NONCE_TTL - 1)
112-
_clean_expired_nonces()
113-
assert nonce not in _nonce_store
114-
115-
def test_retains_fresh_nonce(self):
116-
wallet_id = "GFRESH"
117-
nonce = _generate_nonce(wallet_id)
118-
_clean_expired_nonces()
119-
assert nonce in _nonce_store
120-
121-
def test_generate_nonce_prunes_expired_entries(self):
122-
wallet_id = "GSTALE"
123-
stale_nonce = _generate_nonce(wallet_id)
124-
_nonce_store[stale_nonce] = (wallet_id, time.monotonic() - NONCE_TTL - 1)
125-
_generate_nonce("GNEW")
126-
assert stale_nonce not in _nonce_store
101+
async def test_unknown_nonce_returns_false(self):
102+
assert await _consume_nonce("deadbeef" * 8, "GABCDEF") is False
127103

128104

129105
# ---------------------------------------------------------------------------
@@ -181,7 +157,7 @@ async def test_dependency_raises_401_on_invalid_nonce():
181157
async def test_dependency_raises_401_on_bad_signature():
182158
from stellar_sdk import Keypair
183159
kp = Keypair.random()
184-
nonce = _generate_nonce(kp.public_key)
160+
nonce = await _generate_nonce(kp.public_key)
185161
with pytest.raises(HTTPException) as exc_info:
186162
await verify_wallet_signature(
187163
x_wallet_id=kp.public_key,
@@ -195,7 +171,7 @@ async def test_dependency_raises_401_on_bad_signature():
195171
async def test_dependency_returns_wallet_id_on_valid_signature():
196172
from stellar_sdk import Keypair
197173
kp = Keypair.random()
198-
nonce = _generate_nonce(kp.public_key)
174+
nonce = await _generate_nonce(kp.public_key)
199175
sig_hex = kp.sign(nonce.encode()).hex()
200176
result = await verify_wallet_signature(
201177
x_wallet_id=kp.public_key,
@@ -209,7 +185,8 @@ async def test_dependency_returns_wallet_id_on_valid_signature():
209185
# GET /api/auth/nonce endpoint
210186
# ---------------------------------------------------------------------------
211187

212-
def test_get_nonce_endpoint_returns_nonce_and_ttl():
188+
@pytest.mark.asyncio
189+
async def test_get_nonce_endpoint_returns_nonce_and_ttl(_mock_redis):
213190
mini_app = FastAPI()
214191
mini_app.include_router(router)
215192
test_client = TestClient(mini_app)
@@ -234,7 +211,8 @@ def test_get_nonce_endpoint_missing_wallet_id_returns_422():
234211
assert response.status_code == 422
235212

236213

237-
def test_get_nonce_endpoint_stores_nonce_bound_to_wallet():
214+
@pytest.mark.asyncio
215+
async def test_get_nonce_endpoint_stores_nonce_bound_to_wallet(_mock_redis):
238216
mini_app = FastAPI()
239217
mini_app.include_router(router)
240218
test_client = TestClient(mini_app)
@@ -243,6 +221,5 @@ def test_get_nonce_endpoint_stores_nonce_bound_to_wallet():
243221
response = test_client.get("/api/auth/nonce", params={"wallet_id": wallet_id})
244222
nonce = response.json()["nonce"]
245223

246-
assert nonce in _nonce_store
247-
stored_wallet, _ = _nonce_store[nonce]
224+
stored_wallet = await _mock_redis.get(_nonce_key(nonce))
248225
assert stored_wallet == wallet_id

0 commit comments

Comments
 (0)