From d393269c5de414074632166ec24f152c0ac83340 Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 19 Jun 2026 11:09:47 +0100 Subject: [PATCH 1/6] feat(auth): add Ed25519 wallet signature authentication module --- quantara/web_app/api/wallet_auth.py | 83 +++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 quantara/web_app/api/wallet_auth.py diff --git a/quantara/web_app/api/wallet_auth.py b/quantara/web_app/api/wallet_auth.py new file mode 100644 index 00000000..237646ee --- /dev/null +++ b/quantara/web_app/api/wallet_auth.py @@ -0,0 +1,83 @@ +"""Wallet authentication: Ed25519 challenge-response signature verification.""" +import secrets +import time +from typing import Dict, Tuple + +from fastapi import APIRouter, Header, HTTPException, Query + +from stellar_sdk import Keypair + +router = APIRouter(prefix="/api/auth", tags=["Authentication"]) + +_nonce_store: Dict[str, Tuple[str, float]] = {} +NONCE_TTL: int = 300 # seconds + + +def _clean_expired_nonces() -> None: + """Remove nonces past their TTL.""" + cutoff = time.monotonic() - NONCE_TTL + expired = [n for n, (_, ts) in _nonce_store.items() if ts < cutoff] + for n in expired: + _nonce_store.pop(n, None) + + +def _generate_nonce(wallet_id: str) -> str: + """Generate a cryptographically secure nonce bound to wallet_id.""" + _clean_expired_nonces() + nonce = secrets.token_hex(32) + _nonce_store[nonce] = (wallet_id, time.monotonic()) + return nonce + + +def _consume_nonce(nonce: str, wallet_id: str) -> bool: + """ + Validate and consume a nonce atomically. + Returns True only when the nonce exists, has not expired, and belongs to wallet_id. + The nonce is always removed to prevent replay even on a wallet mismatch. + """ + _clean_expired_nonces() + entry = _nonce_store.pop(nonce, None) + if entry is None: + return False + stored_wallet_id, _ = entry + return stored_wallet_id == wallet_id + + +def _verify_stellar_signature(public_key: str, message: str, signature_hex: str) -> bool: + """Verify an Ed25519 signature produced by a Stellar keypair.""" + try: + keypair = Keypair.from_public_key(public_key) + sig_bytes = bytes.fromhex(signature_hex) + keypair.verify(message.encode(), sig_bytes) + return True + except Exception: + return False + + +@router.get("/nonce", summary="Request a one-time authentication nonce") +async def get_nonce( + wallet_id: str = Query(..., description="Stellar public key (G...) of the authenticating wallet"), +) -> dict: + """Issue a one-time nonce for wallet_id. Sign the nonce with your Stellar private key + and pass it as X-Signature on the next authenticated request.""" + nonce = _generate_nonce(wallet_id) + return {"nonce": nonce, "expires_in": NONCE_TTL} + + +async def verify_wallet_signature( + x_wallet_id: str = Header(..., description="Stellar public key of the signer"), + x_nonce: str = Header(..., description="Nonce obtained from GET /api/auth/nonce"), + x_signature: str = Header(..., description="Hex-encoded Ed25519 signature of the nonce"), +) -> str: + """FastAPI dependency -- verifies a Stellar wallet signature and returns the wallet_id.""" + if not _consume_nonce(x_nonce, x_wallet_id): + raise HTTPException( + status_code=401, + detail="Invalid or expired nonce. Request a fresh nonce from /api/auth/nonce.", + ) + if not _verify_stellar_signature(x_wallet_id, x_nonce, x_signature): + raise HTTPException( + status_code=401, + detail="Signature verification failed. Ensure the nonce was signed with the correct key.", + ) + return x_wallet_id From 42949afa286b599c607d573762f2fb5e3d9c448b Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 19 Jun 2026 11:09:48 +0100 Subject: [PATCH 2/6] feat(auth): register wallet auth router and expose auth CORS headers --- quantara/web_app/api/main.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/quantara/web_app/api/main.py b/quantara/web_app/api/main.py index e8ad0ac2..36ef9558 100644 --- a/quantara/web_app/api/main.py +++ b/quantara/web_app/api/main.py @@ -2,8 +2,8 @@ Main FastAPI application module for the QUANTARA API (Stellar/Soroban). Sets up FastAPI with session and CORS middleware, registers routers for -dashboard, position, user, vault, leaderboard, referal, and telegram -endpoints, and exposes a /health endpoint for CI orchestration. +dashboard, position, user, vault, leaderboard, referal, telegram, and +authentication endpoints, and exposes a /health endpoint for CI orchestration. """ import logging @@ -30,6 +30,7 @@ from web_app.api.vault import router as vault_router from web_app.api.leaderboard import router as leaderboard_router from web_app.api.referal import router as referal_router +from web_app.api.wallet_auth import router as auth_router from web_app.config_validator import assert_valid_config from web_app.db.database import init_db from web_app.db.database import init_db, get_database @@ -37,7 +38,7 @@ logger = logging.getLogger(__name__) DEFAULT_CORS_ORIGINS = ["http://localhost:3000"] CORS_ALLOW_METHODS = ["GET", "POST"] -CORS_ALLOW_HEADERS = ["Content-Type", "Authorization"] +CORS_ALLOW_HEADERS = ["Content-Type", "Authorization", "X-Wallet-Id", "X-Nonce", "X-Signature"] def get_cors_origins() -> list[str]: @@ -135,7 +136,7 @@ async def global_exception_handler(request: Request, exc: Exception): allow_headers=CORS_ALLOW_HEADERS, allow_methods=CORS_ALLOW_METHODS, ) -# Rate limiting middleware — must be added after CORS/session so it wraps the +# Rate limiting middleware -- must be added after CORS/session so it wraps the # full middleware stack and can reject requests before they reach routers. app.add_middleware(SlowAPIMiddleware) @@ -175,7 +176,7 @@ async def health_check(response: Response, db: Session = Depends(get_database)): return health_status -# No startup-time blockchain contract init needed – the frontend +# No startup-time blockchain contract init needed -- the frontend # invokes Soroban contracts directly via Freighter + stellar-sdk. # Include the routers @@ -186,3 +187,4 @@ async def health_check(response: Response, db: Session = Depends(get_database)): app.include_router(vault_router) app.include_router(leaderboard_router) app.include_router(referal_router) +app.include_router(auth_router) From 0040aec8d36ed9310e252b2b730d68d219b3d93c Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 19 Jun 2026 11:13:30 +0100 Subject: [PATCH 3/6] feat(auth): protect vault write endpoints with wallet signature auth --- quantara/web_app/api/vault.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/quantara/web_app/api/vault.py b/quantara/web_app/api/vault.py index d3ebe733..d7460664 100644 --- a/quantara/web_app/api/vault.py +++ b/quantara/web_app/api/vault.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request +from web_app.api.wallet_auth import verify_wallet_signature from web_app.db.crud import DepositDBConnector, UserDBConnector from web_app.api.serializers.vault import ( UpdateVaultBalanceRequest, @@ -26,9 +27,12 @@ async def deposit_to_vault( request: Request, body: VaultDepositRequest, deposit_connector: DepositDBConnector = Depends(DepositDBConnector), + wallet: str = Depends(verify_wallet_signature), ) -> VaultDepositResponse: """ Process a vault deposit request. + + Requires wallet signature authentication via X-Wallet-Id, X-Nonce, and X-Signature headers. """ logger.info(f"Processing deposit request for wallet {body.wallet_id}") @@ -78,9 +82,12 @@ async def add_vault_balance( request: Request, body: UpdateVaultBalanceRequest, deposit_connector: DepositDBConnector = Depends(DepositDBConnector), + wallet: str = Depends(verify_wallet_signature), ) -> UpdateVaultBalanceResponse: """ Add balance to a user's vault for a specific token. + + Requires wallet signature authentication via X-Wallet-Id, X-Nonce, and X-Signature headers. """ try: updated_vault = deposit_connector.add_vault_balance( From a04acbf1ce9d578a36d51dbf66941cbff2f1578a Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 19 Jun 2026 11:13:34 +0100 Subject: [PATCH 4/6] feat(auth): protect position write endpoints with wallet signature auth --- quantara/web_app/api/position.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/quantara/web_app/api/position.py b/quantara/web_app/api/position.py index c0b3efbb..3748b64b 100644 --- a/quantara/web_app/api/position.py +++ b/quantara/web_app/api/position.py @@ -19,6 +19,7 @@ RepayTransactionDataResponse, WithdrawAllData, ) +from web_app.api.wallet_auth import verify_wallet_signature from web_app.contract_tools.constants import TokenMultipliers, TokenParams from web_app.contract_tools.mixins import DashboardMixin, DepositMixin, PositionMixin from web_app.db.crud import PositionDBConnector, TransactionDBConnector @@ -66,13 +67,16 @@ async def create_position_with_transaction_data( request: Request, form_data: PositionFormData, client: StellarClient = Depends(get_stellar_client), + wallet: str = Depends(verify_wallet_signature), ) -> LoopLiquidityData: """ Create a new user position and return the data needed by the frontend to call the Soroban loop_liquidity contract. + Requires wallet signature authentication via X-Wallet-Id, X-Nonce, and X-Signature headers. + Parameters: - - **wallet_id**: The wallet ID of the user (Stellar public key G…). + - **wallet_id**: The wallet ID of the user (Stellar public key G...). - **token_symbol**: The symbol of the token used for the position. - **amount**: The amount of the token being deposited. - **multiplier**: The multiplier applied to the user's position. @@ -122,7 +126,7 @@ async def get_repay_data( """ Obtain data for position closing. - :param wallet_id: Wallet ID (Stellar public key G…) + :param wallet_id: Wallet ID (Stellar public key G...) :return: Dict containing the repay transaction data """ if not wallet_id: @@ -286,10 +290,17 @@ async def get_add_deposit_data(request: Request, position_id: UUID, amount: str, @router.post("/api/add-extra-deposit/{position_id}") @limiter.limit(WRITE_LIMIT) -async def add_extra_deposit(request: Request, position_id: UUID, data: AddPositionDepositData): +async def add_extra_deposit( + request: Request, + position_id: UUID, + data: AddPositionDepositData, + wallet: str = Depends(verify_wallet_signature), +): """ Add extra deposit to a user position. + Requires wallet signature authentication via X-Wallet-Id, X-Nonce, and X-Signature headers. + :param position_id: UUID of the position :param data: Deposit data to create extra deposit :return: Dict containing detail From 3eedfeff7721e68bc7ea8bd499c91134ddaf1884 Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 19 Jun 2026 11:13:39 +0100 Subject: [PATCH 5/6] test(auth): add bypass_wallet_auth autouse fixture alongside rate-limit bypass --- quantara/web_app/tests/conftest.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/quantara/web_app/tests/conftest.py b/quantara/web_app/tests/conftest.py index 1f634361..af399e56 100644 --- a/quantara/web_app/tests/conftest.py +++ b/quantara/web_app/tests/conftest.py @@ -12,6 +12,7 @@ from web_app.api.main import app from web_app.api.rate_limiter import limiter as _ORIGINAL_LIMITER +from web_app.api.wallet_auth import verify_wallet_signature from web_app.db.crud import DBConnector, PositionDBConnector, UserDBConnector from web_app.db.database import get_database from web_app.db.models import ExtraDeposit @@ -22,11 +23,11 @@ def disable_rate_limiting(): """Disable rate limiting in all tests to avoid Redis dependency. Three separate Limiter instances can exist during a test run: - 1. _ORIGINAL_LIMITER – created when rate_limiter.py was first loaded; + 1. _ORIGINAL_LIMITER -- created when rate_limiter.py was first loaded; all @limiter.limit() wrappers in user.py, vault.py, etc. close over it. - 2. A reloaded limiter – TestRateLimiterConfig calls importlib.reload(), + 2. A reloaded limiter -- TestRateLimiterConfig calls importlib.reload(), which creates a fresh instance and updates the module-level name. - 3. A memory_limiter – TestRateLimitEnforcement swaps app.state.limiter for + 3. A memory_limiter -- TestRateLimitEnforcement swaps app.state.limiter for an in-memory instance so tests don't need Redis. We collect every unique instance we can find and disable them all so that the middleware, the decorator wrappers, and direct function calls all skip @@ -46,6 +47,21 @@ def disable_rate_limiting(): lim.enabled = True +@pytest.fixture(autouse=True) +def bypass_wallet_auth(): + """Bypass wallet signature verification for all tests. + + Adds a dependency override so every endpoint that Depends(verify_wallet_signature) + receives a fixed wallet_id of "test_wallet" without any real signature exchange. + This keeps tests decoupled from the nonce store and stellar-sdk crypto. + + The override is removed (not cleared, to preserve other overrides) after each test. + """ + app.dependency_overrides[verify_wallet_signature] = lambda: "test_wallet" + yield + app.dependency_overrides.pop(verify_wallet_signature, None) + + def dict_to_object(data: dict, **kwargs) -> object: """ Convert a dictionary to an attribute object From 01388aa9d9a83de80eedeecc23e8f5d25d90706f Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 19 Jun 2026 11:13:43 +0100 Subject: [PATCH 6/6] test(auth): add comprehensive tests for wallet signature authentication --- quantara/web_app/tests/test_wallet_auth.py | 248 +++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 quantara/web_app/tests/test_wallet_auth.py diff --git a/quantara/web_app/tests/test_wallet_auth.py b/quantara/web_app/tests/test_wallet_auth.py new file mode 100644 index 00000000..8fea03fc --- /dev/null +++ b/quantara/web_app/tests/test_wallet_auth.py @@ -0,0 +1,248 @@ +""" +Tests for wallet signature authentication (Issue #41). + +Covers: +- Nonce generation: uniqueness, storage, binding to wallet_id. +- Nonce consumption: valid path, replay prevention, wrong wallet, unknown nonce. +- Nonce expiry: expired nonces are pruned by _clean_expired_nonces. +- Signature verification: valid key, wrong key, tampered message, bad hex, bad public key. +- verify_wallet_signature dependency: 401 on bad nonce, 401 on bad sig, wallet_id on success. +- GET /api/auth/nonce endpoint: returns nonce + expires_in. +""" + +import time + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from web_app.api.wallet_auth import ( + NONCE_TTL, + _clean_expired_nonces, + _consume_nonce, + _generate_nonce, + _nonce_store, + _verify_stellar_signature, + router, + verify_wallet_signature, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _clear_nonce_store(): + """Ensure an empty nonce store before and after every test.""" + _nonce_store.clear() + yield + _nonce_store.clear() + + +# --------------------------------------------------------------------------- +# Nonce generation +# --------------------------------------------------------------------------- + +class TestGenerateNonce: + def test_returns_64_char_hex_string(self): + nonce = _generate_nonce("GABCDEF") + assert isinstance(nonce, str) + assert len(nonce) == 64 + + def test_each_call_produces_unique_nonce(self): + n1 = _generate_nonce("GABCDEF") + n2 = _generate_nonce("GABCDEF") + assert n1 != n2 + + def test_nonce_stored_with_correct_wallet_id(self): + wallet_id = "GABCDEF123" + nonce = _generate_nonce(wallet_id) + assert nonce in _nonce_store + stored_wallet, _ = _nonce_store[nonce] + assert stored_wallet == wallet_id + + def test_nonce_stored_with_recent_timestamp(self): + before = time.monotonic() + nonce = _generate_nonce("GTEST") + after = time.monotonic() + _, ts = _nonce_store[nonce] + assert before <= ts <= after + + +# --------------------------------------------------------------------------- +# Nonce consumption +# --------------------------------------------------------------------------- + +class TestConsumeNonce: + def test_valid_nonce_and_wallet_returns_true(self): + wallet_id = "GABCDEF" + nonce = _generate_nonce(wallet_id) + assert _consume_nonce(nonce, wallet_id) is True + + def test_nonce_removed_after_consumption(self): + wallet_id = "GABCDEF" + nonce = _generate_nonce(wallet_id) + _consume_nonce(nonce, wallet_id) + assert nonce not in _nonce_store + + def test_replay_attack_fails(self): + wallet_id = "GABCDEF" + nonce = _generate_nonce(wallet_id) + assert _consume_nonce(nonce, wallet_id) is True + assert _consume_nonce(nonce, wallet_id) is False + + def test_wrong_wallet_id_returns_false(self): + nonce = _generate_nonce("GOWNER") + assert _consume_nonce(nonce, "GATTACKER") is False + + def test_unknown_nonce_returns_false(self): + assert _consume_nonce("deadbeef" * 8, "GABCDEF") is False + + +# --------------------------------------------------------------------------- +# Nonce expiry +# --------------------------------------------------------------------------- + +class TestCleanExpiredNonces: + def test_removes_expired_nonce(self): + wallet_id = "GEXPIRED" + nonce = _generate_nonce(wallet_id) + _nonce_store[nonce] = (wallet_id, time.monotonic() - NONCE_TTL - 1) + _clean_expired_nonces() + assert nonce not in _nonce_store + + def test_retains_fresh_nonce(self): + wallet_id = "GFRESH" + nonce = _generate_nonce(wallet_id) + _clean_expired_nonces() + assert nonce in _nonce_store + + def test_generate_nonce_prunes_expired_entries(self): + wallet_id = "GSTALE" + stale_nonce = _generate_nonce(wallet_id) + _nonce_store[stale_nonce] = (wallet_id, time.monotonic() - NONCE_TTL - 1) + _generate_nonce("GNEW") + assert stale_nonce not in _nonce_store + + +# --------------------------------------------------------------------------- +# Signature verification +# --------------------------------------------------------------------------- + +class TestVerifyStellarSignature: + def test_valid_signature_returns_true(self): + from stellar_sdk import Keypair + kp = Keypair.random() + message = "test_nonce_abcdef0123456789" + sig_hex = kp.sign(message.encode()).hex() + assert _verify_stellar_signature(kp.public_key, message, sig_hex) is True + + def test_wrong_keypair_returns_false(self): + from stellar_sdk import Keypair + signer = Keypair.random() + verifier = Keypair.random() + message = "some_nonce" + sig_hex = signer.sign(message.encode()).hex() + assert _verify_stellar_signature(verifier.public_key, message, sig_hex) is False + + def test_tampered_message_returns_false(self): + from stellar_sdk import Keypair + kp = Keypair.random() + message = "original_nonce" + sig_hex = kp.sign(message.encode()).hex() + assert _verify_stellar_signature(kp.public_key, "tampered_nonce", sig_hex) is False + + def test_non_hex_signature_returns_false(self): + from stellar_sdk import Keypair + kp = Keypair.random() + assert _verify_stellar_signature(kp.public_key, "nonce", "not_hex!!") is False + + def test_malformed_public_key_returns_false(self): + assert _verify_stellar_signature("INVALID_KEY", "nonce", "ab" * 32) is False + + +# --------------------------------------------------------------------------- +# verify_wallet_signature FastAPI dependency +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_dependency_raises_401_on_invalid_nonce(): + with pytest.raises(HTTPException) as exc_info: + await verify_wallet_signature( + x_wallet_id="GABCDEF", + x_nonce="does_not_exist_at_all", + x_signature="ab" * 32, + ) + assert exc_info.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_dependency_raises_401_on_bad_signature(): + from stellar_sdk import Keypair + kp = Keypair.random() + nonce = _generate_nonce(kp.public_key) + with pytest.raises(HTTPException) as exc_info: + await verify_wallet_signature( + x_wallet_id=kp.public_key, + x_nonce=nonce, + x_signature="aa" * 32, # wrong signature + ) + assert exc_info.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_dependency_returns_wallet_id_on_valid_signature(): + from stellar_sdk import Keypair + kp = Keypair.random() + nonce = _generate_nonce(kp.public_key) + sig_hex = kp.sign(nonce.encode()).hex() + result = await verify_wallet_signature( + x_wallet_id=kp.public_key, + x_nonce=nonce, + x_signature=sig_hex, + ) + assert result == kp.public_key + + +# --------------------------------------------------------------------------- +# GET /api/auth/nonce endpoint +# --------------------------------------------------------------------------- + +def test_get_nonce_endpoint_returns_nonce_and_ttl(): + mini_app = FastAPI() + mini_app.include_router(router) + test_client = TestClient(mini_app) + + wallet_id = "GABCDEF123TEST" + response = test_client.get("/api/auth/nonce", params={"wallet_id": wallet_id}) + + assert response.status_code == 200 + data = response.json() + assert "nonce" in data + assert "expires_in" in data + assert data["expires_in"] == NONCE_TTL + assert len(data["nonce"]) == 64 + + +def test_get_nonce_endpoint_missing_wallet_id_returns_422(): + mini_app = FastAPI() + mini_app.include_router(router) + test_client = TestClient(mini_app) + + response = test_client.get("/api/auth/nonce") + assert response.status_code == 422 + + +def test_get_nonce_endpoint_stores_nonce_bound_to_wallet(): + mini_app = FastAPI() + mini_app.include_router(router) + test_client = TestClient(mini_app) + + wallet_id = "GBOUND_TEST_WALLET" + response = test_client.get("/api/auth/nonce", params={"wallet_id": wallet_id}) + nonce = response.json()["nonce"] + + assert nonce in _nonce_store + stored_wallet, _ = _nonce_store[nonce] + assert stored_wallet == wallet_id