diff --git a/.env.example b/.env.example index 78d7a11..efb0ac8 100644 --- a/.env.example +++ b/.env.example @@ -22,5 +22,10 @@ CAP_SECRET= CAP_HARD_SITE_KEY= CAP_HARD_SECRET= +# Trusted server-to-server API (JSON map of key IDs to SHA-256 key hashes) +# Example: {"platform-team":"0123456789abcdef..."} +FAUCET_API_KEY_HASHES= +FAUCET_API_KEY_DAILY_LIMIT=100 + # Promo codes as JSON (optional, merged with promo_codes.json; env wins on conflict) # PROMO_CODES={"TESTCODE": {"amount": 5.0}} diff --git a/README.md b/README.md index b1938cd..28c0d09 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ Environment variables: | `CORE_FAUCET_AMOUNT` | DASH amount for core faucet | `1.0` | | `CAP_SITE_KEY` | CAP captcha site key | - | | `CAP_SECRET` | CAP captcha secret | - | +| `FAUCET_API_KEY_HASHES` | JSON map of trusted API key IDs to SHA-256 hashes | - | +| `FAUCET_API_KEY_DAILY_LIMIT` | Successful payouts allowed per trusted key per rolling 24 hours | `100` | ## Architecture @@ -53,6 +55,23 @@ Environment variables: - `GET /api/status` - Faucet status, balance, and deposit address - `POST /api/identity-package` - Get an identity package with asset lock proof - `POST /api/core-faucet` - Request testnet DASH +- `POST /api/v1/core-faucet` - Trusted bearer-key tDASH payout without browser CAPTCHA or public IP limits + +### Trusted API + +The trusted endpoint requires `Authorization: Bearer ` and accepts only an +`address` field. API keys are configured as SHA-256 hashes so plaintext keys do +not need to be stored by the faucet. + +```bash +curl -X POST https://faucet.testnet.networks.dash.org/api/v1/core-faucet \ + -H "Authorization: Bearer $FAUCET_API_KEY" \ + -H "Content-Type: application/json" \ + --data '{"address":"YOUR_TESTNET_DASH_ADDRESS"}' +``` + +This endpoint bypasses the browser CAPTCHA and public IP rate limit, but retains +a per-key rolling daily limit. Promo codes are not accepted by the trusted API. ## Development diff --git a/app/config.py b/app/config.py index 9aa7d35..94b52eb 100644 --- a/app/config.py +++ b/app/config.py @@ -34,6 +34,11 @@ class Settings(BaseSettings): cap_hard_site_key: str = "" cap_hard_secret: str = "" + # Trusted server-to-server faucet API + # JSON mapping of key IDs to SHA-256 key hashes. + faucet_api_key_hashes: str = "" + faucet_api_key_daily_limit: int = 100 + # Promo codes config file path promo_codes_file: str = "promo_codes.json" diff --git a/app/routers/faucet.py b/app/routers/faucet.py index bb9f984..3a8cbf6 100644 --- a/app/routers/faucet.py +++ b/app/routers/faucet.py @@ -2,11 +2,27 @@ Provides the main endpoint for creating identity packages. """ +import logging +from typing import Annotated + import httpx -from fastapi import APIRouter, Request, Response, HTTPException +from fastapi import APIRouter, Header, Request, Response, HTTPException from pydantic import BaseModel from app.config import settings +from app.middleware.rate_limit import RateLimiter, rate_limiter +from app.models.schemas import FaucetResponse, ErrorResponse, RateLimitResponse, PublicKeyInfo +from app.services.api_keys import authenticate_api_key +from app.services.asset_lock import ( + create_asset_lock_transaction, + get_suitable_utxo, + COIN, +) +from app.services.core_client import dash_client +from app.services.instant_lock import wait_for_instant_lock, InstantLockTimeout +from app.services.keys import generate_key_pair, create_identity_public_key +from app.services.proof_builder import build_instant_asset_lock_proof +from app.services.promo import promo_service class FaucetRequest(BaseModel): @@ -37,6 +53,11 @@ class CoreFaucetRequest(BaseModel): promoCode: str | None = None +class ApiCoreFaucetRequest(BaseModel): + """Request body for trusted server-to-server tDASH payouts.""" + address: str + + class CoreFaucetResponse(BaseModel): """Response for core faucet endpoint.""" txid: str @@ -77,21 +98,14 @@ async def verify_cap_token(token: str, hard: bool = False) -> bool: return result.get("success", False) except Exception: return False -from app.middleware.rate_limit import rate_limiter -from app.models.schemas import FaucetResponse, ErrorResponse, RateLimitResponse, PublicKeyInfo -from app.services.core_client import dash_client -from app.services.keys import generate_key_pair, create_identity_public_key -from app.services.asset_lock import ( - create_asset_lock_transaction, - get_suitable_utxo, - COIN -) -from app.services.instant_lock import wait_for_instant_lock, InstantLockTimeout -from app.services.proof_builder import build_instant_asset_lock_proof -from app.services.promo import promo_service router = APIRouter(prefix="/api", tags=["faucet"]) +logger = logging.getLogger(__name__) +api_key_rate_limiter = RateLimiter( + max_requests=settings.faucet_api_key_daily_limit, + window_seconds=24 * 60 * 60, +) def get_client_ip(request: Request) -> str: @@ -584,14 +598,6 @@ async def core_faucet(request: Request, body: CoreFaucetRequest) -> CoreFaucetRe headers={"Retry-After": str(retry_after)} ) - # Validate address format (basic check for testnet address) - address = body.address.strip() - if not address or len(address) < 26: - raise HTTPException( - status_code=400, - detail={"error": "Invalid address format"} - ) - # Determine send amount (promo code may override) send_amount = settings.core_faucet_amount promo_code_used = None @@ -606,17 +612,29 @@ async def core_faucet(request: Request, body: CoreFaucetRequest) -> CoreFaucetRe send_amount = promo_amount promo_code_used = body.promoCode - try: - # Send DASH to the address - txid = dash_client.send_to_address(address, send_amount) + result = dispense_core_dash(body.address, send_amount) - # Record successful request for rate limiting - rate_limiter.record_request(client_ip) + # Record successful request for rate limiting + rate_limiter.record_request(client_ip) + + # Record promo code usage + if promo_code_used: + promo_service.record_usage(promo_code_used, client_ip) - # Record promo code usage - if promo_code_used: - promo_service.record_usage(promo_code_used, client_ip) + return result + +def dispense_core_dash(address_value: str, send_amount: float) -> CoreFaucetResponse: + """Validate an address and send a fixed amount of testnet DASH.""" + address = address_value.strip() + if not address or len(address) < 26: + raise HTTPException( + status_code=400, + detail={"error": "Invalid address format"} + ) + + try: + txid = dash_client.send_to_address(address, send_amount) return CoreFaucetResponse( txid=txid, amount=send_amount, @@ -645,3 +663,50 @@ async def core_faucet(request: Request, body: CoreFaucetRequest) -> CoreFaucetRe "detail": error_msg } ) + + +@router.post( + "/v1/core-faucet", + response_model=CoreFaucetResponse, + responses={ + 400: {"model": ErrorResponse, "description": "Invalid request"}, + 401: {"description": "Missing or invalid API key"}, + 429: {"model": RateLimitResponse, "description": "API key daily limit exceeded"}, + 500: {"model": ErrorResponse, "description": "Server error"}, + 503: {"model": ErrorResponse, "description": "Service unavailable"}, + }, +) +async def api_core_faucet( + body: ApiCoreFaucetRequest, + authorization: Annotated[str | None, Header()] = None, +) -> CoreFaucetResponse: + """Send tDASH for a trusted API client without browser CAPTCHA or IP limits.""" + key_id = authenticate_api_key(authorization) + if key_id is None: + raise HTTPException( + status_code=401, + detail={"error": "Missing or invalid API key"}, + headers={"WWW-Authenticate": "Bearer"}, + ) + + allowed, retry_after = api_key_rate_limiter.is_allowed(key_id) + if not allowed: + raise HTTPException( + status_code=429, + detail={ + "error": "API key daily limit exceeded", + "retryAfter": retry_after, + }, + headers={"Retry-After": str(retry_after)}, + ) + + result = dispense_core_dash(body.address, settings.core_faucet_amount) + api_key_rate_limiter.record_request(key_id) + logger.info( + "Trusted faucet API payout key_id=%s address=%s amount=%s txid=%s", + key_id, + result.address, + result.amount, + result.txid, + ) + return result diff --git a/app/services/api_keys.py b/app/services/api_keys.py new file mode 100644 index 0000000..9c9a06e --- /dev/null +++ b/app/services/api_keys.py @@ -0,0 +1,57 @@ +"""Authentication helpers for trusted faucet API clients.""" + +import hashlib +import hmac +import json + +from app.config import settings + + +def parse_api_key_hashes(value: str) -> dict[str, str]: + """Parse a JSON mapping of API key IDs to SHA-256 hashes.""" + if not value.strip(): + return {} + + parsed = json.loads(value) + if not isinstance(parsed, dict): + raise ValueError("FAUCET_API_KEY_HASHES must be a JSON object") + + result: dict[str, str] = {} + for key_id, key_hash in parsed.items(): + if not isinstance(key_id, str) or not key_id.strip(): + raise ValueError("Faucet API key IDs must be non-empty strings") + if ( + not isinstance(key_hash, str) + or len(key_hash) != 64 + or any(char not in "0123456789abcdefABCDEF" for char in key_hash) + ): + raise ValueError( + f"Faucet API key hash for {key_id!r} must be a SHA-256 hex digest" + ) + result[key_id] = key_hash.lower() + + return result + + +API_KEY_HASHES = parse_api_key_hashes(settings.faucet_api_key_hashes) + + +def authenticate_api_key(authorization: str | None) -> str | None: + """Return the matching key ID for a valid Bearer token.""" + if not authorization: + return None + + scheme, separator, token = authorization.partition(" ") + token = token.strip() + if not separator or scheme.casefold() != "bearer" or not token: + return None + + presented_hash = hashlib.sha256(token.encode("utf-8")).hexdigest() + matched_key_id = None + + # Compare against every configured hash to avoid revealing which key IDs exist. + for key_id, expected_hash in API_KEY_HASHES.items(): + if hmac.compare_digest(presented_hash, expected_hash): + matched_key_id = key_id + + return matched_key_id diff --git a/docker-compose.yml b/docker-compose.yml index 31386a3..592f5f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,6 +51,8 @@ services: - CAP_SECRET=${CAP_SECRET:-} - CAP_HARD_SITE_KEY=${CAP_HARD_SITE_KEY:-} - CAP_HARD_SECRET=${CAP_HARD_SECRET:-} + - FAUCET_API_KEY_HASHES=${FAUCET_API_KEY_HASHES:-} + - FAUCET_API_KEY_DAILY_LIMIT=${FAUCET_API_KEY_DAILY_LIMIT:-100} depends_on: dashcore: condition: service_healthy diff --git a/tests/test_api_keys.py b/tests/test_api_keys.py new file mode 100644 index 0000000..0916680 --- /dev/null +++ b/tests/test_api_keys.py @@ -0,0 +1,40 @@ +"""Tests for trusted faucet API key authentication.""" + +import hashlib +import unittest +from unittest.mock import patch + +from app.services import api_keys + + +class ApiKeyTests(unittest.TestCase): + def test_parse_api_key_hashes(self): + digest = hashlib.sha256(b"secret").hexdigest() + self.assertEqual( + api_keys.parse_api_key_hashes(f'{{"platform-team":"{digest}"}}'), + {"platform-team": digest}, + ) + + def test_parse_rejects_non_sha256_hash(self): + with self.assertRaises(ValueError): + api_keys.parse_api_key_hashes('{"platform-team":"short"}') + + def test_authenticate_valid_bearer_key(self): + token = "test-api-key" + digest = hashlib.sha256(token.encode()).hexdigest() + with patch.object(api_keys, "API_KEY_HASHES", {"platform-team": digest}): + self.assertEqual( + api_keys.authenticate_api_key(f"Bearer {token}"), + "platform-team", + ) + + def test_authenticate_rejects_invalid_or_missing_key(self): + digest = hashlib.sha256(b"valid-key").hexdigest() + with patch.object(api_keys, "API_KEY_HASHES", {"platform-team": digest}): + self.assertIsNone(api_keys.authenticate_api_key(None)) + self.assertIsNone(api_keys.authenticate_api_key("Basic abc")) + self.assertIsNone(api_keys.authenticate_api_key("Bearer wrong-key")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_trusted_faucet.py b/tests/test_trusted_faucet.py new file mode 100644 index 0000000..c4ed51a --- /dev/null +++ b/tests/test_trusted_faucet.py @@ -0,0 +1,63 @@ +"""Tests for the trusted server-to-server faucet endpoint.""" + +import unittest +from unittest.mock import Mock, patch + +from fastapi import HTTPException + +from app.routers import faucet + + +class TrustedFaucetTests(unittest.IsolatedAsyncioTestCase): + async def test_rejects_invalid_key_before_payout(self): + body = faucet.ApiCoreFaucetRequest(address="y" * 34) + with patch.object(faucet, "authenticate_api_key", return_value=None), patch.object( + faucet, "dispense_core_dash" + ) as dispense: + with self.assertRaises(HTTPException) as raised: + await faucet.api_core_faucet(body, "Bearer invalid") + + self.assertEqual(raised.exception.status_code, 401) + dispense.assert_not_called() + + async def test_valid_key_bypasses_public_protection_and_records_usage(self): + body = faucet.ApiCoreFaucetRequest(address="y" * 34) + expected = faucet.CoreFaucetResponse( + txid="ab" * 32, + amount=1.0, + address=body.address, + ) + limiter = Mock() + limiter.is_allowed.return_value = (True, 0) + + with patch.object( + faucet, "authenticate_api_key", return_value="platform-team" + ), patch.object(faucet, "api_key_rate_limiter", limiter), patch.object( + faucet, "dispense_core_dash", return_value=expected + ) as dispense: + result = await faucet.api_core_faucet(body, "Bearer valid") + + self.assertEqual(result, expected) + dispense.assert_called_once_with(body.address, faucet.settings.core_faucet_amount) + limiter.record_request.assert_called_once_with("platform-team") + + async def test_daily_limit_blocks_before_payout(self): + body = faucet.ApiCoreFaucetRequest(address="y" * 34) + limiter = Mock() + limiter.is_allowed.return_value = (False, 123) + + with patch.object( + faucet, "authenticate_api_key", return_value="platform-team" + ), patch.object(faucet, "api_key_rate_limiter", limiter), patch.object( + faucet, "dispense_core_dash" + ) as dispense: + with self.assertRaises(HTTPException) as raised: + await faucet.api_core_faucet(body, "Bearer valid") + + self.assertEqual(raised.exception.status_code, 429) + self.assertEqual(raised.exception.headers["Retry-After"], "123") + dispense.assert_not_called() + + +if __name__ == "__main__": + unittest.main()