Security hardening: fix 10 vulnerabilities
CRITICAL: - Fix #1: Atomic nonce GETDEL to prevent race conditions in verify_auth() HIGH: - Fix #2: X-Forwarded-For only trusted from TRUSTED_PROXIES set - Fix #3: TRP tier refresh every 10min (was 60min) + 48h key expiry - Fix #4: SHA-256 hashed key storage in Redis (raw key never stored) MEDIUM: - Fix #5: Generic error messages - no internal detail leakage - Fix #6: Auth refresh is self-service only (documented) - Fix #7: CBOR validation before tx submit - Fix #8: Input validation regex for addresses, tx hashes, policy IDs LOW: - Fix #9: Correct tx hash calculation (blake2b of tx body, not full tx) - Fix #10: Enforce key expiry globally in get_api_key_info Version bumped to 2.1.0
This commit is contained in:
parent
d5fbec496f
commit
dac26ea1ba
1 changed files with 246 additions and 58 deletions
304
main.py
304
main.py
|
|
@ -21,9 +21,22 @@ Access Tiers (strictly enforced):
|
||||||
|
|
||||||
Node endpoints (/v1/address/{addr}/utxos, /v1/protocol-params, /v1/tx/submit)
|
Node endpoints (/v1/address/{addr}/utxos, /v1/protocol-params, /v1/tx/submit)
|
||||||
return HTTP 403 for insufficient tier.
|
return HTTP 403 for insufficient tier.
|
||||||
|
|
||||||
|
Security hardening applied 2026-03-21:
|
||||||
|
- Fix #1: Atomic nonce GETDEL to prevent race conditions
|
||||||
|
- Fix #2: X-Forwarded-For only trusted from known proxies
|
||||||
|
- Fix #3: TRP refresh every 10 min + 48h key expiry for TRP-gated keys
|
||||||
|
- Fix #4: SHA-256 hashed key storage in Redis
|
||||||
|
- Fix #5: Generic error messages (no internal detail leakage)
|
||||||
|
- Fix #6: Auth refresh is self-service only (key refreshes itself)
|
||||||
|
- Fix #7: CBOR validation before tx submit
|
||||||
|
- Fix #8: Input validation regex for addresses, tx hashes, policy IDs
|
||||||
|
- Fix #9: Correct tx hash calculation (blake2b of tx body, not full tx)
|
||||||
|
- Fix #10: Enforce key expiry globally in get_api_key_info
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
|
|
@ -102,14 +115,50 @@ CACHE_TTLS = {
|
||||||
"utxos": 10 # Short cache for UTxOs
|
"utxos": 10 # Short cache for UTxOs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# TRP-gated key expiry (48 hours)
|
||||||
|
TRP_KEY_EXPIRY_HOURS = 48
|
||||||
|
|
||||||
|
# Fix #2: Trusted proxies for X-Forwarded-For
|
||||||
|
TRUSTED_PROXIES = {"127.0.0.1", "::1", "172.22.0.1", "172.17.0.1"}
|
||||||
|
|
||||||
|
# Fix #8: Input validation regexes
|
||||||
|
ADDR_RE = re.compile(r'^addr1[a-z0-9]{50,120}$')
|
||||||
|
ADDR_TEST_RE = re.compile(r'^addr_test1[a-z0-9]{50,120}$')
|
||||||
|
HEX64_RE = re.compile(r'^[a-fA-F0-9]{64}$')
|
||||||
|
POLICY_RE = re.compile(r'^[a-fA-F0-9]{56}$')
|
||||||
|
|
||||||
# Global connections
|
# Global connections
|
||||||
db_pool: Optional[asyncpg.Pool] = None
|
db_pool: Optional[asyncpg.Pool] = None
|
||||||
redis_client: Optional[redis.Redis] = None
|
redis_client: Optional[redis.Redis] = None
|
||||||
protocol_params_cache: dict = {"data": None, "expires": 0}
|
protocol_params_cache: dict = {"data": None, "expires": 0}
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Input Validation (Fix #8) ============
|
||||||
|
|
||||||
|
def validate_address(address: str) -> bool:
|
||||||
|
"""Validate Cardano address format."""
|
||||||
|
return bool(ADDR_RE.match(address) or ADDR_TEST_RE.match(address))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_tx_hash(tx_hash: str) -> bool:
|
||||||
|
"""Validate transaction hash format (64 hex chars)."""
|
||||||
|
clean = tx_hash.lower().replace("0x", "")
|
||||||
|
return bool(HEX64_RE.match(clean))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_policy_id(policy_id: str) -> bool:
|
||||||
|
"""Validate policy ID format (56 hex chars)."""
|
||||||
|
clean = policy_id.lower().replace("0x", "")
|
||||||
|
return bool(POLICY_RE.match(clean))
|
||||||
|
|
||||||
|
|
||||||
# ============ Helper Functions ============
|
# ============ Helper Functions ============
|
||||||
|
|
||||||
|
def hash_api_key(key: str) -> str:
|
||||||
|
"""Hash an API key for storage/lookup. Fix #4."""
|
||||||
|
return hashlib.sha256(key.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def run_cardano_cli(args: list[str], timeout: int = 30) -> tuple[bool, str, str]:
|
def run_cardano_cli(args: list[str], timeout: int = 30) -> tuple[bool, str, str]:
|
||||||
"""Run cardano-cli command and return (success, stdout, stderr)."""
|
"""Run cardano-cli command and return (success, stdout, stderr)."""
|
||||||
cmd = ["cardano-cli"] + args
|
cmd = ["cardano-cli"] + args
|
||||||
|
|
@ -130,7 +179,8 @@ def run_cardano_cli(args: list[str], timeout: int = 30) -> tuple[bool, str, str]
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return False, "", "cardano-cli not found"
|
return False, "", "cardano-cli not found"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, "", str(e)
|
logger.error(f"cardano-cli error: {e}")
|
||||||
|
return False, "", "Node command failed"
|
||||||
|
|
||||||
|
|
||||||
def decode_hex_or_base64(data: str) -> bytes:
|
def decode_hex_or_base64(data: str) -> bytes:
|
||||||
|
|
@ -255,10 +305,10 @@ def verify_cip8_signature(address: str, nonce: str, signature_hex: str, key_hex:
|
||||||
# ============ Lifespan ============
|
# ============ Lifespan ============
|
||||||
|
|
||||||
async def refresh_trp_tiers_task():
|
async def refresh_trp_tiers_task():
|
||||||
"""Background task to refresh TRP tiers for all gated keys every hour."""
|
"""Background task to refresh TRP tiers for all gated keys every 10 minutes. Fix #3."""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(3600) # Run every hour
|
await asyncio.sleep(600) # Run every 10 minutes (was 3600)
|
||||||
await refresh_all_trp_tiers()
|
await refresh_all_trp_tiers()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
|
|
@ -367,7 +417,7 @@ async def lifespan(app: FastAPI):
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Cardano Chain Data API",
|
title="Cardano Chain Data API",
|
||||||
description="REST API for querying Cardano blockchain data via db-sync and cardano-node",
|
description="REST API for querying Cardano blockchain data via db-sync and cardano-node",
|
||||||
version="2.0.0",
|
version="2.1.0", # Bumped for security fixes
|
||||||
lifespan=lifespan
|
lifespan=lifespan
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -386,11 +436,11 @@ async def handle_undefined_table(request: Request, exc: UndefinedTableError):
|
||||||
|
|
||||||
@app.exception_handler(PostgresError)
|
@app.exception_handler(PostgresError)
|
||||||
async def handle_postgres_error(request: Request, exc: PostgresError):
|
async def handle_postgres_error(request: Request, exc: PostgresError):
|
||||||
"""Handle general postgres errors."""
|
"""Handle general postgres errors. Fix #5: Don't leak internal details."""
|
||||||
logger.error(f"Database error: {exc}")
|
logger.error(f"Database error: {exc}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
content={"error": "database_error", "message": str(exc)}
|
content={"error": "database_error", "message": "Internal database error"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -445,11 +495,34 @@ class TxSubmitResponse(BaseModel):
|
||||||
# ============ Helpers ============
|
# ============ Helpers ============
|
||||||
|
|
||||||
async def get_api_key_info(key: str) -> Optional[dict]:
|
async def get_api_key_info(key: str) -> Optional[dict]:
|
||||||
"""Get API key info from Redis."""
|
"""
|
||||||
|
Get API key info from Redis using hashed key lookup. Fix #4 and Fix #10.
|
||||||
|
Also enforces key expiry for TRP-gated keys.
|
||||||
|
"""
|
||||||
if not redis_client:
|
if not redis_client:
|
||||||
return None
|
return None
|
||||||
data = await redis_client.hgetall(f"apikey:{key}")
|
|
||||||
return data if data else None
|
# Hash the key for lookup (Fix #4)
|
||||||
|
key_hash = hash_api_key(key)
|
||||||
|
data = await redis_client.hgetall(f"apikey:{key_hash}")
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Fix #10: Check expiry for TRP-gated keys
|
||||||
|
expires_at = data.get("expires_at")
|
||||||
|
if expires_at:
|
||||||
|
try:
|
||||||
|
expiry_time = datetime.fromisoformat(expires_at.replace('Z', '+00:00'))
|
||||||
|
if datetime.now(timezone.utc) > expiry_time:
|
||||||
|
# Key expired, delete it and return None
|
||||||
|
logger.info(f"API key expired for owner {data.get('owner', 'unknown')}")
|
||||||
|
await redis_client.delete(f"apikey:{key_hash}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing expires_at: {e}")
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
async def check_rate_limit(identifier: str, tier: str, limit_type: str = "general") -> tuple[bool, int]:
|
async def check_rate_limit(identifier: str, tier: str, limit_type: str = "general") -> tuple[bool, int]:
|
||||||
|
|
@ -488,11 +561,18 @@ async def set_cached(cache_key: str, data: dict, ttl: int):
|
||||||
|
|
||||||
|
|
||||||
def get_client_ip(request: Request) -> str:
|
def get_client_ip(request: Request) -> str:
|
||||||
"""Extract client IP from request."""
|
"""
|
||||||
forwarded = request.headers.get("X-Forwarded-For")
|
Extract client IP from request. Fix #2: Only trust X-Forwarded-For from known proxies.
|
||||||
if forwarded:
|
"""
|
||||||
return forwarded.split(",")[0].strip()
|
client_host = request.client.host if request.client else "unknown"
|
||||||
return request.client.host if request.client else "unknown"
|
|
||||||
|
# Only trust X-Forwarded-For if connecting IP is a trusted proxy
|
||||||
|
if client_host in TRUSTED_PROXIES:
|
||||||
|
forwarded = request.headers.get("X-Forwarded-For")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
|
||||||
|
return client_host
|
||||||
|
|
||||||
|
|
||||||
# ============ Auth Dependency ============
|
# ============ Auth Dependency ============
|
||||||
|
|
@ -514,9 +594,10 @@ async def get_auth_context(
|
||||||
if key_info:
|
if key_info:
|
||||||
return {
|
return {
|
||||||
"tier": key_info.get("tier", "standard"),
|
"tier": key_info.get("tier", "standard"),
|
||||||
"identifier": key,
|
"identifier": hash_api_key(key), # Use hash as identifier
|
||||||
"label": key_info.get("label", "unknown"),
|
"label": key_info.get("label", "unknown"),
|
||||||
"owner": key_info.get("owner")
|
"owner": key_info.get("owner"),
|
||||||
|
"raw_key": key # Keep raw key for refresh endpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
return {"tier": "anonymous", "identifier": client_ip, "label": None}
|
return {"tier": "anonymous", "identifier": client_ip, "label": None}
|
||||||
|
|
@ -595,13 +676,13 @@ async def rate_limit_middleware(request: Request, call_next):
|
||||||
key_info = await get_api_key_info(key)
|
key_info = await get_api_key_info(key)
|
||||||
if key_info:
|
if key_info:
|
||||||
tier = key_info.get("tier", "standard")
|
tier = key_info.get("tier", "standard")
|
||||||
identifier = key
|
identifier = hash_api_key(key) # Use hash as identifier
|
||||||
label = key_info.get("label", "unknown")
|
label = key_info.get("label", "unknown")
|
||||||
|
|
||||||
# Check rate limit (general)
|
# Check rate limit (general)
|
||||||
allowed, retry_after = await check_rate_limit(identifier, tier)
|
allowed, retry_after = await check_rate_limit(identifier, tier)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
logger.warning(f"Rate limit exceeded: {identifier} ({tier})")
|
logger.warning(f"Rate limit exceeded: {identifier[:16]}... ({tier})")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=429,
|
status_code=429,
|
||||||
content={"error": "rate_limit_exceeded", "retry_after": retry_after}
|
content={"error": "rate_limit_exceeded", "retry_after": retry_after}
|
||||||
|
|
@ -610,9 +691,10 @@ async def rate_limit_middleware(request: Request, call_next):
|
||||||
# Process request
|
# Process request
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
|
|
||||||
# Log request
|
# Log request (don't log full key hash for security)
|
||||||
elapsed = int((time.time() - start_time) * 1000)
|
elapsed = int((time.time() - start_time) * 1000)
|
||||||
logger.info(f"{request.method} {request.url.path} | {label or client_ip} | {elapsed}ms | {response.status_code}")
|
log_id = label or client_ip
|
||||||
|
logger.info(f"{request.method} {request.url.path} | {log_id} | {elapsed}ms | {response.status_code}")
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
@ -688,6 +770,13 @@ async def get_address_utxos(address: str, auth: dict = Depends(require_standard_
|
||||||
Requires: standard tier (50+ TRP) or higher.
|
Requires: standard tier (50+ TRP) or higher.
|
||||||
Anonymous users should use /v1/address/{address}/balance (db-sync) instead.
|
Anonymous users should use /v1/address/{address}/balance (db-sync) instead.
|
||||||
"""
|
"""
|
||||||
|
# Fix #8: Validate address format
|
||||||
|
if not validate_address(address):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
|
||||||
|
)
|
||||||
|
|
||||||
cache_key = f"utxos_{address}"
|
cache_key = f"utxos_{address}"
|
||||||
cached = await get_cached(cache_key)
|
cached = await get_cached(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
|
|
@ -707,9 +796,11 @@ async def get_address_utxos(address: str, auth: dict = Depends(require_standard_
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail={"error": "node_unavailable", "message": "Cardano node not available"}
|
detail={"error": "node_unavailable", "message": "Cardano node not available"}
|
||||||
)
|
)
|
||||||
|
# Fix #5: Don't leak stderr details
|
||||||
|
logger.error(f"cardano-cli utxo query failed: {stderr}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail={"error": "node_error", "message": stderr}
|
detail={"error": "node_error", "message": "Node command failed"}
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -789,6 +880,27 @@ async def submit_transaction(
|
||||||
detail={"error": "invalid_encoding", "message": str(e)}
|
detail={"error": "invalid_encoding", "message": str(e)}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Fix #7: Validate CBOR before proceeding
|
||||||
|
try:
|
||||||
|
tx_cbor = cbor2.loads(tx_bytes)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={"error": "invalid_tx", "message": "Transaction is not valid CBOR"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fix #9: Calculate correct tx hash from tx body (index 0), not full tx
|
||||||
|
try:
|
||||||
|
if isinstance(tx_cbor, (list, tuple)) and len(tx_cbor) > 0:
|
||||||
|
tx_body_cbor = cbor2.dumps(tx_cbor[0])
|
||||||
|
tx_hash = hashlib.blake2b(tx_body_cbor, digest_size=32).hexdigest()
|
||||||
|
else:
|
||||||
|
# Fallback if structure is unexpected
|
||||||
|
tx_hash = hashlib.blake2b(tx_bytes, digest_size=32).hexdigest()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error calculating tx hash: {e}")
|
||||||
|
tx_hash = hashlib.blake2b(tx_bytes, digest_size=32).hexdigest()
|
||||||
|
|
||||||
# Write to temp file for cardano-cli
|
# Write to temp file for cardano-cli
|
||||||
with tempfile.NamedTemporaryFile(suffix=".signed", delete=False) as f:
|
with tempfile.NamedTemporaryFile(suffix=".signed", delete=False) as f:
|
||||||
f.write(tx_bytes)
|
f.write(tx_bytes)
|
||||||
|
|
@ -808,7 +920,7 @@ async def submit_transaction(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail={"error": "node_unavailable", "message": "Cardano node not available"}
|
detail={"error": "node_unavailable", "message": "Cardano node not available"}
|
||||||
)
|
)
|
||||||
# Parse common errors
|
# Parse common errors (these are safe to expose)
|
||||||
if "OutsideValidityIntervalUTxO" in stderr:
|
if "OutsideValidityIntervalUTxO" in stderr:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
|
@ -824,21 +936,14 @@ async def submit_transaction(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail={"error": "value_not_conserved", "message": "Input/output value mismatch"}
|
detail={"error": "value_not_conserved", "message": "Input/output value mismatch"}
|
||||||
)
|
)
|
||||||
|
# Fix #5: Don't leak full stderr for other errors
|
||||||
|
logger.error(f"tx submit failed: {stderr}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail={"error": "submit_failed", "message": stderr.strip()}
|
detail={"error": "submit_failed", "message": "Node command failed"}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calculate tx hash from the CBOR
|
logger.info(f"Transaction submitted: {tx_hash[:16]}... by {auth['identifier'][:16]}...")
|
||||||
# The hash is blake2b-256 of the transaction body
|
|
||||||
tx_hash = hashlib.blake2b(tx_bytes, digest_size=32).hexdigest()
|
|
||||||
|
|
||||||
# Try to get actual hash from output
|
|
||||||
if "Transaction successfully submitted" in stdout or success:
|
|
||||||
# cardano-cli doesn't output the hash, but we calculated it
|
|
||||||
pass
|
|
||||||
|
|
||||||
logger.info(f"Transaction submitted: {tx_hash[:16]}... by {auth['identifier']}")
|
|
||||||
|
|
||||||
return TxSubmitResponse(
|
return TxSubmitResponse(
|
||||||
tx_hash=tx_hash,
|
tx_hash=tx_hash,
|
||||||
|
|
@ -879,9 +984,11 @@ async def get_protocol_params(auth: dict = Depends(require_standard_tier)):
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail={"error": "node_unavailable", "message": "Cardano node not available"}
|
detail={"error": "node_unavailable", "message": "Cardano node not available"}
|
||||||
)
|
)
|
||||||
|
# Fix #5: Don't leak stderr
|
||||||
|
logger.error(f"protocol-params query failed: {stderr}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail={"error": "node_error", "message": stderr}
|
detail={"error": "node_error", "message": "Node command failed"}
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -911,8 +1018,8 @@ async def create_auth_challenge(request: AuthChallengeRequest):
|
||||||
"""
|
"""
|
||||||
address = request.address
|
address = request.address
|
||||||
|
|
||||||
# Validate address format
|
# Fix #8: Validate address format
|
||||||
if not address.startswith(("addr1", "addr_test1")):
|
if not validate_address(address):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
|
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
|
||||||
|
|
@ -944,22 +1051,32 @@ async def verify_auth(request: AuthVerifyRequest):
|
||||||
- 0 TRP: anonymous (no key issued)
|
- 0 TRP: anonymous (no key issued)
|
||||||
- 50+ TRP: standard (100 req/min)
|
- 50+ TRP: standard (100 req/min)
|
||||||
- 500+ TRP: elevated (1000 req/min)
|
- 500+ TRP: elevated (1000 req/min)
|
||||||
|
|
||||||
|
TRP-gated keys expire after 48 hours and must be re-authenticated.
|
||||||
"""
|
"""
|
||||||
address = request.address
|
address = request.address
|
||||||
nonce = request.nonce
|
nonce = request.nonce
|
||||||
|
|
||||||
# Check nonce exists and hasn't expired
|
# Fix #8: Validate address
|
||||||
challenge_key = f"auth_challenge:{address}:{nonce}"
|
if not validate_address(address):
|
||||||
challenge_data = await redis_client.get(challenge_key)
|
|
||||||
|
|
||||||
if not challenge_data:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail={"error": "invalid_nonce", "message": "Nonce expired or invalid"}
|
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Delete nonce (one-time use)
|
# Fix #1: Atomic nonce check-and-delete using pipeline
|
||||||
await redis_client.delete(challenge_key)
|
challenge_key = f"auth_challenge:{address}:{nonce}"
|
||||||
|
|
||||||
|
async with redis_client.pipeline(transaction=True) as pipe:
|
||||||
|
await pipe.get(challenge_key)
|
||||||
|
await pipe.delete(challenge_key)
|
||||||
|
challenge_data, deleted = await pipe.execute()
|
||||||
|
|
||||||
|
if not challenge_data or not deleted:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={"error": "invalid_nonce", "message": "Nonce expired or already used"}
|
||||||
|
)
|
||||||
|
|
||||||
# Verify CIP-8 signature
|
# Verify CIP-8 signature
|
||||||
if not verify_cip8_signature(address, nonce, request.signature, request.key):
|
if not verify_cip8_signature(address, nonce, request.signature, request.key):
|
||||||
|
|
@ -987,17 +1104,24 @@ async def verify_auth(request: AuthVerifyRequest):
|
||||||
# Generate API key
|
# Generate API key
|
||||||
new_key = f"capi_{secrets.token_hex(24)}"
|
new_key = f"capi_{secrets.token_hex(24)}"
|
||||||
|
|
||||||
# Store key info
|
# Fix #3: Set expiry 48 hours from now for TRP-gated keys
|
||||||
await redis_client.hset(f"apikey:{new_key}", mapping={
|
expires_at = datetime.now(timezone.utc) + timedelta(hours=TRP_KEY_EXPIRY_HOURS)
|
||||||
|
|
||||||
|
# Fix #4: Store using hashed key, not raw key
|
||||||
|
key_hash = hash_api_key(new_key)
|
||||||
|
|
||||||
|
await redis_client.hset(f"apikey:{key_hash}", mapping={
|
||||||
"label": f"TRP-gated:{address[:20]}...",
|
"label": f"TRP-gated:{address[:20]}...",
|
||||||
"tier": tier,
|
"tier": tier,
|
||||||
"owner": address,
|
"owner": address,
|
||||||
"trp_balance": str(trp_balance),
|
"trp_balance": str(trp_balance),
|
||||||
"created_at": datetime.now(timezone.utc).isoformat()
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"expires_at": expires_at.isoformat() # Fix #3: Add expiry
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.info(f"Issued {tier} API key for {address[:20]}... (TRP: {trp_balance})")
|
logger.info(f"Issued {tier} API key for {address[:20]}... (TRP: {trp_balance}, expires: {expires_at.isoformat()})")
|
||||||
|
|
||||||
|
# Return the raw key to the user (only time it's exposed)
|
||||||
return AuthVerifyResponse(
|
return AuthVerifyResponse(
|
||||||
api_key=new_key,
|
api_key=new_key,
|
||||||
tier=tier,
|
tier=tier,
|
||||||
|
|
@ -1012,6 +1136,10 @@ async def refresh_auth(
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Re-check TRP balance and upgrade/downgrade tier for an existing key.
|
Re-check TRP balance and upgrade/downgrade tier for an existing key.
|
||||||
|
|
||||||
|
Fix #6: This endpoint is self-service only. The API key in the header
|
||||||
|
is both the authentication AND the key being refreshed. You cannot
|
||||||
|
use key A to refresh key B.
|
||||||
"""
|
"""
|
||||||
if auth["tier"] == "anonymous":
|
if auth["tier"] == "anonymous":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -1037,10 +1165,17 @@ async def refresh_auth(
|
||||||
new_tier = get_tier_from_trp_balance(trp_balance)
|
new_tier = get_tier_from_trp_balance(trp_balance)
|
||||||
old_tier = auth["tier"]
|
old_tier = auth["tier"]
|
||||||
|
|
||||||
|
# Fix #3: Reset expiry on refresh
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(hours=TRP_KEY_EXPIRY_HOURS)
|
||||||
|
|
||||||
|
# Use hashed key for storage (Fix #4)
|
||||||
|
key_hash = hash_api_key(x_api_key)
|
||||||
|
|
||||||
# Update key info
|
# Update key info
|
||||||
await redis_client.hset(f"apikey:{x_api_key}", mapping={
|
await redis_client.hset(f"apikey:{key_hash}", mapping={
|
||||||
"tier": new_tier,
|
"tier": new_tier,
|
||||||
"trp_balance": str(trp_balance)
|
"trp_balance": str(trp_balance),
|
||||||
|
"expires_at": expires_at.isoformat() # Reset expiry
|
||||||
})
|
})
|
||||||
|
|
||||||
tier_changed = new_tier != old_tier
|
tier_changed = new_tier != old_tier
|
||||||
|
|
@ -1052,6 +1187,7 @@ async def refresh_auth(
|
||||||
"previous_tier": old_tier,
|
"previous_tier": old_tier,
|
||||||
"trp_balance": trp_balance,
|
"trp_balance": trp_balance,
|
||||||
"tier_changed": tier_changed,
|
"tier_changed": tier_changed,
|
||||||
|
"expires_at": expires_at.isoformat(),
|
||||||
"message": message
|
"message": message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1129,6 +1265,13 @@ async def get_block(block_no: int, auth: dict = Depends(get_auth_context)):
|
||||||
@app.get("/v1/address/{address}/balance")
|
@app.get("/v1/address/{address}/balance")
|
||||||
async def get_address_balance(address: str, auth: dict = Depends(get_auth_context)):
|
async def get_address_balance(address: str, auth: dict = Depends(get_auth_context)):
|
||||||
"""Get address balance including native tokens."""
|
"""Get address balance including native tokens."""
|
||||||
|
# Fix #8: Validate address
|
||||||
|
if not validate_address(address):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
|
||||||
|
)
|
||||||
|
|
||||||
cache_key = f"balance_{address}"
|
cache_key = f"balance_{address}"
|
||||||
cached = await get_cached(cache_key)
|
cached = await get_cached(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
|
|
@ -1186,6 +1329,13 @@ async def get_address_balance(address: str, auth: dict = Depends(get_auth_contex
|
||||||
@app.get("/v1/address/{address}/tokens")
|
@app.get("/v1/address/{address}/tokens")
|
||||||
async def get_address_tokens(address: str, auth: dict = Depends(get_auth_context)):
|
async def get_address_tokens(address: str, auth: dict = Depends(get_auth_context)):
|
||||||
"""Get native tokens held by an address."""
|
"""Get native tokens held by an address."""
|
||||||
|
# Fix #8: Validate address
|
||||||
|
if not validate_address(address):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
|
||||||
|
)
|
||||||
|
|
||||||
cache_key = f"tokens_{address}"
|
cache_key = f"tokens_{address}"
|
||||||
cached = await get_cached(cache_key)
|
cached = await get_cached(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
|
|
@ -1236,6 +1386,13 @@ async def get_address_transactions(
|
||||||
auth: dict = Depends(get_auth_context)
|
auth: dict = Depends(get_auth_context)
|
||||||
):
|
):
|
||||||
"""Get transactions for an address."""
|
"""Get transactions for an address."""
|
||||||
|
# Fix #8: Validate address
|
||||||
|
if not validate_address(address):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
|
||||||
|
)
|
||||||
|
|
||||||
cache_key = f"txs_{address}_{page}_{limit}_{order}"
|
cache_key = f"txs_{address}_{page}_{limit}_{order}"
|
||||||
cached = await get_cached(cache_key)
|
cached = await get_cached(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
|
|
@ -1304,6 +1461,13 @@ async def get_address_transactions(
|
||||||
@app.get("/v1/tx/{tx_hash}")
|
@app.get("/v1/tx/{tx_hash}")
|
||||||
async def get_transaction(tx_hash: str, auth: dict = Depends(get_auth_context)):
|
async def get_transaction(tx_hash: str, auth: dict = Depends(get_auth_context)):
|
||||||
"""Get transaction details by hash."""
|
"""Get transaction details by hash."""
|
||||||
|
# Fix #8: Validate tx hash
|
||||||
|
if not validate_tx_hash(tx_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={"error": "invalid_tx_hash", "message": "Invalid transaction hash format (expected 64 hex chars)"}
|
||||||
|
)
|
||||||
|
|
||||||
cache_key = f"tx_{tx_hash}"
|
cache_key = f"tx_{tx_hash}"
|
||||||
cached = await get_cached(cache_key)
|
cached = await get_cached(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
|
|
@ -1385,6 +1549,13 @@ async def get_transaction(tx_hash: str, auth: dict = Depends(get_auth_context)):
|
||||||
@app.get("/v1/asset/{policy_id}/info")
|
@app.get("/v1/asset/{policy_id}/info")
|
||||||
async def get_asset_info(policy_id: str, auth: dict = Depends(get_auth_context)):
|
async def get_asset_info(policy_id: str, auth: dict = Depends(get_auth_context)):
|
||||||
"""Get info about all assets under a policy ID."""
|
"""Get info about all assets under a policy ID."""
|
||||||
|
# Fix #8: Validate policy ID
|
||||||
|
if not validate_policy_id(policy_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={"error": "invalid_policy_id", "message": "Invalid policy ID format (expected 56 hex chars)"}
|
||||||
|
)
|
||||||
|
|
||||||
cache_key = f"asset_info_{policy_id}"
|
cache_key = f"asset_info_{policy_id}"
|
||||||
cached = await get_cached(cache_key)
|
cached = await get_cached(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
|
|
@ -1433,6 +1604,13 @@ async def get_asset_holders(
|
||||||
auth: dict = Depends(get_auth_context)
|
auth: dict = Depends(get_auth_context)
|
||||||
):
|
):
|
||||||
"""Get top holders of a specific asset."""
|
"""Get top holders of a specific asset."""
|
||||||
|
# Fix #8: Validate policy ID
|
||||||
|
if not validate_policy_id(policy_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={"error": "invalid_policy_id", "message": "Invalid policy ID format (expected 56 hex chars)"}
|
||||||
|
)
|
||||||
|
|
||||||
cache_key = f"holders_{policy_id}_{asset_name}_{limit}"
|
cache_key = f"holders_{policy_id}_{asset_name}_{limit}"
|
||||||
cached = await get_cached(cache_key)
|
cached = await get_cached(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
|
|
@ -1542,19 +1720,24 @@ async def get_pool_info(pool_id: str, auth: dict = Depends(get_auth_context)):
|
||||||
|
|
||||||
@app.post("/admin/keys")
|
@app.post("/admin/keys")
|
||||||
async def create_api_key(key_data: APIKeyCreate, auth: dict = Depends(require_master_key)):
|
async def create_api_key(key_data: APIKeyCreate, auth: dict = Depends(require_master_key)):
|
||||||
"""Create a new API key."""
|
"""Create a new API key (admin-created keys don't expire unless explicitly set)."""
|
||||||
new_key = f"capi_{secrets.token_hex(24)}"
|
new_key = f"capi_{secrets.token_hex(24)}"
|
||||||
|
|
||||||
await redis_client.hset(f"apikey:{new_key}", mapping={
|
# Fix #4: Store using hashed key
|
||||||
|
key_hash = hash_api_key(new_key)
|
||||||
|
|
||||||
|
# Admin-created keys don't expire by default (Fix #10)
|
||||||
|
await redis_client.hset(f"apikey:{key_hash}", mapping={
|
||||||
"label": key_data.label,
|
"label": key_data.label,
|
||||||
"tier": key_data.tier,
|
"tier": key_data.tier,
|
||||||
"owner": key_data.owner or "",
|
"owner": key_data.owner or "",
|
||||||
"trp_balance": str(key_data.trp_balance or 0),
|
"trp_balance": str(key_data.trp_balance or 0),
|
||||||
"created_at": datetime.now(timezone.utc).isoformat()
|
"created_at": datetime.now(timezone.utc).isoformat()
|
||||||
|
# No expires_at for admin-created keys
|
||||||
})
|
})
|
||||||
|
|
||||||
return APIKeyResponse(
|
return APIKeyResponse(
|
||||||
key=new_key,
|
key=new_key, # Return raw key only once
|
||||||
label=key_data.label,
|
label=key_data.label,
|
||||||
tier=key_data.tier,
|
tier=key_data.tier,
|
||||||
owner=key_data.owner,
|
owner=key_data.owner,
|
||||||
|
|
@ -1565,15 +1748,17 @@ async def create_api_key(key_data: APIKeyCreate, auth: dict = Depends(require_ma
|
||||||
@app.delete("/admin/keys/{key}")
|
@app.delete("/admin/keys/{key}")
|
||||||
async def revoke_api_key(key: str, auth: dict = Depends(require_master_key)):
|
async def revoke_api_key(key: str, auth: dict = Depends(require_master_key)):
|
||||||
"""Revoke an API key."""
|
"""Revoke an API key."""
|
||||||
deleted = await redis_client.delete(f"apikey:{key}")
|
# Fix #4: Hash the key for lookup
|
||||||
|
key_hash = hash_api_key(key)
|
||||||
|
deleted = await redis_client.delete(f"apikey:{key_hash}")
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail={"error": "not_found", "message": "API key not found"})
|
raise HTTPException(status_code=404, detail={"error": "not_found", "message": "API key not found"})
|
||||||
return {"status": "revoked", "key": key}
|
return {"status": "revoked", "key": key[:16] + "..."}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/keys")
|
@app.get("/admin/keys")
|
||||||
async def list_api_keys(auth: dict = Depends(require_master_key)):
|
async def list_api_keys(auth: dict = Depends(require_master_key)):
|
||||||
"""List all API keys."""
|
"""List all API keys (shows metadata, not raw keys since we only store hashes)."""
|
||||||
keys = []
|
keys = []
|
||||||
cursor = 0
|
cursor = 0
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -1581,13 +1766,16 @@ async def list_api_keys(auth: dict = Depends(require_master_key)):
|
||||||
for key in found_keys:
|
for key in found_keys:
|
||||||
data = await redis_client.hgetall(key)
|
data = await redis_client.hgetall(key)
|
||||||
if data:
|
if data:
|
||||||
|
# Key is stored as hash, so we show the hash (last 8 chars) for identification
|
||||||
|
key_hash = key.replace("apikey:", "")
|
||||||
keys.append({
|
keys.append({
|
||||||
"key": key.replace("apikey:", ""),
|
"key_hash_suffix": f"...{key_hash[-8:]}",
|
||||||
"label": data.get("label"),
|
"label": data.get("label"),
|
||||||
"tier": data.get("tier"),
|
"tier": data.get("tier"),
|
||||||
"owner": data.get("owner") or None,
|
"owner": data.get("owner") or None,
|
||||||
"trp_balance": int(data.get("trp_balance", 0)),
|
"trp_balance": int(data.get("trp_balance", 0)),
|
||||||
"created_at": data.get("created_at")
|
"created_at": data.get("created_at"),
|
||||||
|
"expires_at": data.get("expires_at")
|
||||||
})
|
})
|
||||||
if cursor == 0:
|
if cursor == 0:
|
||||||
break
|
break
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue