docs: tidy inline comments and rewrite README

This commit is contained in:
Sulkta 2026-05-27 11:15:02 -07:00
parent 5b3b9010e0
commit 6b994a6944
2 changed files with 230 additions and 284 deletions

268
main.py
View file

@ -1,54 +1,21 @@
"""
Cardano Chain Data REST API
Sits in front of cardano-db-sync PostgreSQL database.
Includes node integration for UTxO queries, tx submission, and protocol params.
TRP-gated permissionless API keys with CIP-8 wallet signature verification.
cardano-api REST API over cardano-db-sync + cardano-node.
Reads hit the db-sync Postgres directly. UTxO queries, protocol params, and
tx submit shell out to cardano-cli against a local node socket. Auth is
TRP-token-gated via CIP-8 wallet signatures; keys are stored as sha256(key).
Tiers:
anonymous 0 TRP 20 req/min db-sync only, no node access
standard >=50 TRP 100 req/min + node reads (utxos, protocol-params)
elevated >=500 TRP 1000 req/min + POST /v1/tx/submit
master env key unlimited everything
Node endpoints return 403 for insufficient tier.
Known policy IDs:
- TRP: 9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05
- MAP: 24bd9e7b9ae3a61df79eca72fd8355d0f7767e4c55a04a0d919c019c
Access Tiers (strictly enforced):
- Anonymous (0 TRP, 20 req/min):
db-sync read-only ONLY. Balance, transactions, tokens, blocks, assets, pools, sync status.
NO node access whatsoever.
- Standard (50 TRP, 100 req/min):
db-sync read + node read-only. UTxO queries, protocol params.
NO transaction submission.
- Elevated (500 TRP, 1000 req/min):
Everything above + POST /v1/tx/submit (transaction broadcasting).
- Master key: Unrestricted access.
Node endpoints (/v1/address/{addr}/utxos, /v1/protocol-params, /v1/tx/submit)
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
Security hardening pass 2 (2026-03-21):
- Fix #11: Request body size limit (64KB) on /v1/tx/submit
- Fix #12: CIP-8 empty payload bypass fixed
- Fix #13: Pagination on /v1/address/{addr}/tokens and /v1/asset/{policy_id}/info
- Fix #14: cbor2 bumped to >=5.6.5 (CVE-2024-26134)
- Fix #15: Fixed holder count query (was using GROUP BY + COUNT DISTINCT incorrectly)
- Fix #16: Async lock for protocol params cache to prevent stampede
Security hardening pass 3 (2026-03-21):
- Fix #17: Body size middleware now reads actual body stream (catches chunked/missing Content-Length)
- Fix #17: Challenge flood prevention - per-address rate limit (5/min) + outstanding limit (10 max)
- Fix #18: COSE algorithm validation (must be EdDSA/-8)
- Fix #18: COSE element type validation (protected, sig must be bytes; key must be 32 bytes)
- Fix #19: TRP refresh uses Redis Set + batched Postgres query (no more N+1)
- Fix #20: Pagination page parameter capped at 10000 across all endpoints
TRP 9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05
MAP 24bd9e7b9ae3a61df79eca72fd8355d0f7767e4c55a04a0d919c019c
"""
import os
@ -135,10 +102,11 @@ CACHE_TTLS = {
# TRP-gated key expiry (48 hours)
TRP_KEY_EXPIRY_HOURS = 48
# Fix #2: Trusted proxies for X-Forwarded-For
# Proxies whose X-Forwarded-For we trust. Loopback + docker default bridges
# cover the standard compose deploy; override for any other front-end.
TRUSTED_PROXIES = {"127.0.0.1", "::1", "172.22.0.1", "172.17.0.1"}
# Fix #8: Input validation regexes
# Input validation regexes — gate all path params before any DB query.
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}$')
@ -149,11 +117,11 @@ db_pool: Optional[asyncpg.Pool] = None
redis_client: Optional[redis.Redis] = None
protocol_params_cache: dict = {"data": None, "expires": 0}
# Fix #16: Async lock for protocol params cache to prevent stampede
# Async lock for protocol params cache to prevent stampede.
_params_lock = asyncio.Lock()
# ============ Input Validation (Fix #8) ============
# ============ Input Validation ============
def validate_address(address: str) -> bool:
"""Validate Cardano address format."""
@ -175,7 +143,7 @@ def validate_policy_id(policy_id: str) -> bool:
# ============ Helper Functions ============
def hash_api_key(key: str) -> str:
"""Hash an API key for storage/lookup. Fix #4."""
"""Hash an API key for storage/lookup. Raw keys are never persisted."""
return hashlib.sha256(key.encode()).hexdigest()
@ -248,8 +216,7 @@ def verify_cip8_signature(address: str, nonce: str, signature_hex: str, key_hex:
# Decode the key (should be a 32-byte Ed25519 public key)
key_bytes = bytes.fromhex(key_hex)
# Fix #18: Validate key_bytes type and length
if not isinstance(key_bytes, bytes) or len(key_bytes) != 32:
logger.warning(f"CIP-8 verification failed: invalid key length {len(key_bytes) if isinstance(key_bytes, bytes) else 'non-bytes'}")
return False
@ -289,13 +256,12 @@ def verify_cip8_signature(address: str, nonce: str, signature_hex: str, key_hex:
return False
protected, unprotected, payload, sig = cose_sign1
# Fix #18: Validate COSE element types
if not isinstance(protected, bytes) or not isinstance(sig, bytes):
logger.warning("CIP-8 verification failed: invalid COSE_Sign1 element types")
return False
# Fix #18: Decode and validate protected header - must be EdDSA (-8)
# Protected header must declare EdDSA (alg = -8).
try:
protected_decoded = cbor2.loads(protected)
except Exception:
@ -310,8 +276,8 @@ def verify_cip8_signature(address: str, nonce: str, signature_hex: str, key_hex:
if alg != -8: # -8 = EdDSA per COSE spec
logger.warning(f"CIP-8 verification failed: invalid algorithm {alg}, expected -8 (EdDSA)")
return False
# Fix #12: Reject empty payloads - nonce verification must happen
# Empty payloads bypass the nonce check; reject.
if not payload:
logger.warning("CIP-8 verification rejected: empty payload")
return False
@ -356,7 +322,7 @@ def verify_cip8_signature(address: str, nonce: str, signature_hex: str, key_hex:
# ============ Lifespan ============
async def refresh_trp_tiers_task():
"""Background task to refresh TRP tiers for all gated keys every 10 minutes. Fix #3."""
"""Background task: refresh TRP tiers for all gated keys every 10 minutes."""
while True:
try:
await asyncio.sleep(600) # Run every 10 minutes (was 3600)
@ -370,12 +336,11 @@ async def refresh_trp_tiers_task():
async def refresh_all_trp_tiers():
"""
Check all TRP-gated keys and update tiers if balance changed.
Fix #19: Uses Redis Set + batched Postgres query to avoid N+1.
Uses a dedicated Redis set + a single batched Postgres query to avoid N+1.
"""
if not redis_client or not db_pool:
return
# Fix #19: Get TRP-gated keys from dedicated set instead of SCAN
key_hashes = await redis_client.smembers("trp_gated_keys")
if not key_hashes:
return
@ -397,8 +362,8 @@ async def refresh_all_trp_tiers():
if not addresses:
return
# Fix #19: Batch query all TRP balances in one Postgres call
# Batch all TRP balances in one Postgres call.
try:
async with db_pool.acquire() as conn:
results = await conn.fetch("""
@ -508,19 +473,20 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="Cardano Chain Data API",
description="REST API for querying Cardano blockchain data via db-sync and cardano-node",
version="2.3.0", # Bumped for security hardening pass 3
version="2.3.0",
lifespan=lifespan
)
# ============ Fix #11: Request Body Size Limit Middleware ============
# ============ Request Body Size Limit ============
class LimitBodySizeMiddleware(BaseHTTPMiddleware):
"""
Limit request body size on tx submit to prevent DoS. Cardano max tx is ~16KB.
Fix #17: Actually reads body stream to catch chunked encoding / missing Content-Length.
Cap /v1/tx/submit body size. Cardano max tx is ~16 KB; cap at 64 KB.
Reads the actual body stream so chunked transfer / missing Content-Length
can't bypass the limit.
"""
MAX_TX_SIZE = 65536 # 64KB - generous limit
MAX_TX_SIZE = 65536
async def dispatch(self, request: Request, call_next):
if request.url.path == "/v1/tx/submit":
@ -565,7 +531,7 @@ async def handle_undefined_table(request: Request, exc: UndefinedTableError):
@app.exception_handler(PostgresError)
async def handle_postgres_error(request: Request, exc: PostgresError):
"""Handle general postgres errors. Fix #5: Don't leak internal details."""
"""Handle general postgres errors. Don't leak internal detail to the caller."""
logger.error(f"Database error: {exc}")
return JSONResponse(
status_code=503,
@ -625,29 +591,25 @@ class TxSubmitResponse(BaseModel):
async def get_api_key_info(key: str) -> Optional[dict]:
"""
Get API key info from Redis using hashed key lookup. Fix #4 and Fix #10.
Also enforces key expiry for TRP-gated keys.
Look up an API key by sha256 hash. Enforces TRP-gated key expiry
expired keys are deleted on read and treated as missing.
"""
if not redis_client:
return 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}")
# Fix #19: Also remove from TRP-gated keys set
await redis_client.srem("trp_gated_keys", key_hash)
return None
except Exception as e:
@ -692,9 +654,7 @@ async def set_cached(cache_key: str, data: dict, ttl: int):
def get_client_ip(request: Request) -> str:
"""
Extract client IP from request. Fix #2: Only trust X-Forwarded-For from known proxies.
"""
"""Extract client IP. X-Forwarded-For is only honoured from TRUSTED_PROXIES."""
client_host = request.client.host if request.client else "unknown"
# Only trust X-Forwarded-For if connecting IP is a trusted proxy
@ -725,10 +685,10 @@ async def get_auth_context(
if key_info:
return {
"tier": key_info.get("tier", "standard"),
"identifier": hash_api_key(key), # Use hash as identifier
"identifier": hash_api_key(key),
"label": key_info.get("label", "unknown"),
"owner": key_info.get("owner"),
"raw_key": key # Keep raw key for refresh endpoint
"raw_key": key # needed by the refresh endpoint
}
return {"tier": "anonymous", "identifier": client_ip, "label": None}
@ -742,10 +702,7 @@ async def require_master_key(auth: dict = Depends(get_auth_context)):
async def require_standard_tier(auth: dict = Depends(get_auth_context)):
"""
Require standard tier or higher for node read endpoints.
Anonymous users get db-sync only - no direct node access.
"""
"""Standard tier or higher — node read endpoints. Anonymous = db-sync only."""
if auth["tier"] == "anonymous":
raise HTTPException(
status_code=403,
@ -760,10 +717,7 @@ async def require_standard_tier(auth: dict = Depends(get_auth_context)):
async def require_elevated_tier(auth: dict = Depends(get_auth_context)):
"""
Require elevated tier for transaction submission.
Standard tier gets read-only node access, elevated gets write.
"""
"""Elevated tier — tx submission. Standard tier is read-only on the node."""
if auth["tier"] not in ("elevated", "master"):
raise HTTPException(
status_code=403,
@ -807,7 +761,7 @@ async def rate_limit_middleware(request: Request, call_next):
key_info = await get_api_key_info(key)
if key_info:
tier = key_info.get("tier", "standard")
identifier = hash_api_key(key) # Use hash as identifier
identifier = hash_api_key(key)
label = key_info.get("label", "unknown")
# Check rate limit (general)
@ -897,11 +851,10 @@ async def get_address_utxos(address: str, auth: dict = Depends(require_standard_
"""
Query UTxOs for an address directly from the node.
Faster than db-sync for current unspent outputs.
Requires: standard tier (50+ TRP) or higher.
Anonymous users should use /v1/address/{address}/balance (db-sync) instead.
Standard tier (50+ TRP) or higher. Anonymous tier should use
/v1/address/{address}/balance (db-sync) instead.
"""
# Fix #8: Validate address format
if not validate_address(address):
raise HTTPException(
status_code=400,
@ -927,7 +880,6 @@ async def get_address_utxos(address: str, auth: dict = Depends(require_standard_
status_code=503,
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(
status_code=500,
@ -990,9 +942,8 @@ async def submit_transaction(
"""
Submit a signed transaction to the network.
Accepts hex or base64 encoded CBOR transaction.
Requires: elevated tier (500+ TRP) or master key.
Standard tier gets read-only node access.
Elevated tier (500+ TRP) or master key. Standard tier is node-read-only.
"""
# Check tx submission rate limit (elevated tier still has limits)
allowed, retry_after = await check_rate_limit(auth["identifier"], auth["tier"], "tx_submit")
@ -1011,7 +962,7 @@ async def submit_transaction(
detail={"error": "invalid_encoding", "message": str(e)}
)
# Fix #7: Validate CBOR before proceeding
# Validate CBOR before shelling out.
try:
tx_cbor = cbor2.loads(tx_bytes)
except Exception:
@ -1019,8 +970,8 @@ async def submit_transaction(
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
# Cardano tx hash is blake2b of the tx body (index 0), not the full tx.
try:
if isinstance(tx_cbor, (list, tuple)) and len(tx_cbor) > 0:
tx_body_cbor = cbor2.dumps(tx_cbor[0])
@ -1067,7 +1018,6 @@ async def submit_transaction(
status_code=400,
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(
status_code=400,
@ -1092,14 +1042,12 @@ async def submit_transaction(
@app.get("/v1/protocol-params")
async def get_protocol_params(auth: dict = Depends(require_standard_tier)):
"""
Get current epoch protocol parameters from the node.
Cached for 5 minutes.
Requires: standard tier (50+ TRP) or higher.
Get current epoch protocol parameters from the node. Cached 5 min.
Standard tier (50+ TRP) or higher.
"""
global protocol_params_cache
# Fix #16: Use async lock to prevent cache stampede
# Lock to prevent cache stampede on first-load.
async with _params_lock:
# Check cache inside the lock
if protocol_params_cache["data"] and protocol_params_cache["expires"] > time.time():
@ -1117,7 +1065,6 @@ async def get_protocol_params(auth: dict = Depends(require_standard_tier)):
status_code=503,
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(
status_code=500,
@ -1148,19 +1095,18 @@ async def create_auth_challenge(request: AuthChallengeRequest, req: Request):
"""
Create a challenge nonce for wallet signature verification.
The nonce must be signed with CIP-8 and submitted to /v1/auth/verify.
Fix #17: Per-address rate limit to prevent challenge flood / Redis memory exhaustion.
Per-address rate-limited to prevent challenge flood / Redis memory blow-up.
"""
address = request.address.strip()
# Fix #8: Validate address format
if not validate_address(address):
raise HTTPException(
status_code=400,
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
)
# Fix #17: Per-address challenge rate limit: 5 per minute
# 5 challenges/min per address.
challenge_rate_key = f"challenge_rate:{address}:{int(time.time() // 60)}"
current_count = await redis_client.incr(challenge_rate_key)
if current_count == 1:
@ -1170,8 +1116,8 @@ async def create_auth_challenge(request: AuthChallengeRequest, req: Request):
status_code=429,
detail={"error": "too_many_challenges", "message": "Too many challenge requests. Try again in a minute."}
)
# Fix #17: Enforce max outstanding challenges per address (prevent accumulation across minutes)
# And cap outstanding (unverified) challenges per address — prevents accumulation across minutes.
outstanding_key = f"challenge_count:{address}"
outstanding = await redis_client.incr(outstanding_key)
if outstanding == 1:
@ -1215,15 +1161,14 @@ async def verify_auth(request: AuthVerifyRequest):
"""
address = request.address
nonce = request.nonce
# Fix #8: Validate address
if not validate_address(address):
raise HTTPException(
status_code=400,
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
)
# Fix #1: Atomic nonce check-and-delete using pipeline
# Atomic GET+DEL the nonce — closes the verify-twice race window.
challenge_key = f"auth_challenge:{address}:{nonce}"
async with redis_client.pipeline(transaction=True) as pipe:
@ -1237,7 +1182,7 @@ async def verify_auth(request: AuthVerifyRequest):
detail={"error": "invalid_nonce", "message": "Nonce expired or already used"}
)
# Fix #17: Decrement outstanding challenge counter since nonce is now consumed
# Nonce consumed — drop one from the outstanding counter.
outstanding_key = f"challenge_count:{address}"
await redis_client.decr(outstanding_key)
@ -1266,11 +1211,10 @@ async def verify_auth(request: AuthVerifyRequest):
# Generate API key
new_key = f"capi_{secrets.token_hex(24)}"
# Fix #3: Set expiry 48 hours from now for TRP-gated keys
# TRP-gated keys expire 48h after issue — re-auth via /v1/auth/refresh.
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={
@ -1279,10 +1223,11 @@ async def verify_auth(request: AuthVerifyRequest):
"owner": address,
"trp_balance": str(trp_balance),
"created_at": datetime.now(timezone.utc).isoformat(),
"expires_at": expires_at.isoformat() # Fix #3: Add expiry
"expires_at": expires_at.isoformat()
})
# Fix #19: Track TRP-gated keys in a set for efficient refresh
# Track TRP-gated keys in a set — the refresh task batches a single
# Postgres query over the owner addresses instead of N+1.
await redis_client.sadd("trp_gated_keys", key_hash)
logger.info(f"Issued {tier} API key for {address[:20]}... (TRP: {trp_balance}, expires: {expires_at.isoformat()})")
@ -1302,10 +1247,9 @@ async def refresh_auth(
):
"""
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.
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":
raise HTTPException(
@ -1330,18 +1274,16 @@ async def refresh_auth(
trp_balance = await get_trp_balance(owner)
new_tier = get_tier_from_trp_balance(trp_balance)
old_tier = auth["tier"]
# Fix #3: Reset expiry on refresh
# Refresh resets the 48h expiry too.
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
await redis_client.hset(f"apikey:{key_hash}", mapping={
"tier": new_tier,
"trp_balance": str(trp_balance),
"expires_at": expires_at.isoformat() # Reset expiry
"expires_at": expires_at.isoformat()
})
tier_changed = new_tier != old_tier
@ -1431,7 +1373,7 @@ async def get_block(block_no: int, auth: dict = Depends(get_auth_context)):
@app.get("/v1/address/{address}/balance")
async def get_address_balance(address: str, auth: dict = Depends(get_auth_context)):
"""Get address balance including native tokens."""
# Fix #8: Validate address
# Validate address
if not validate_address(address):
raise HTTPException(
status_code=400,
@ -1499,8 +1441,8 @@ async def get_address_tokens(
limit: int = Query(100, ge=1, le=1000, description="Results per page (max 1000)"),
auth: dict = Depends(get_auth_context)
):
"""Get native tokens held by an address. Fix #13: Now paginated."""
# Fix #8: Validate address
"""Get native tokens held by an address. Paginated."""
# Validate address
if not validate_address(address):
raise HTTPException(
status_code=400,
@ -1526,7 +1468,7 @@ async def get_address_tokens(
total_count = count_result["total"] if count_result else 0
# Fix #13: Add LIMIT and OFFSET for pagination
# LIMIT/OFFSET for pagination
tokens = await conn.fetch("""
SELECT
encode(ma.policy, 'hex') as policy_id,
@ -1576,7 +1518,7 @@ async def get_address_transactions(
auth: dict = Depends(get_auth_context)
):
"""Get transactions for an address."""
# Fix #8: Validate address
# Validate address
if not validate_address(address):
raise HTTPException(
status_code=400,
@ -1651,7 +1593,7 @@ async def get_address_transactions(
@app.get("/v1/tx/{tx_hash}")
async def get_transaction(tx_hash: str, auth: dict = Depends(get_auth_context)):
"""Get transaction details by hash."""
# Fix #8: Validate tx hash
# Validate tx hash
if not validate_tx_hash(tx_hash):
raise HTTPException(
status_code=400,
@ -1743,8 +1685,8 @@ async def get_asset_info(
limit: int = Query(100, ge=1, le=500, description="Results per page (max 500)"),
auth: dict = Depends(get_auth_context)
):
"""Get info about all assets under a policy ID. Fix #13: Now paginated."""
# Fix #8: Validate policy ID
"""Get info about all assets under a policy ID. Paginated."""
# Validate policy ID
if not validate_policy_id(policy_id):
raise HTTPException(
status_code=400,
@ -1769,7 +1711,7 @@ async def get_asset_info(
total_count = count_result["total"] if count_result else 0
# Fix #13: Add LIMIT and OFFSET for pagination
# LIMIT/OFFSET for pagination
assets = await conn.fetch("""
SELECT
encode(ma.name, 'hex') as asset_name_hex,
@ -1816,7 +1758,7 @@ async def get_asset_holders(
auth: dict = Depends(get_auth_context)
):
"""Get top holders of a specific asset."""
# Fix #8: Validate policy ID
# Validate policy ID
if not validate_policy_id(policy_id):
raise HTTPException(
status_code=400,
@ -1853,7 +1795,8 @@ async def get_asset_holders(
LIMIT $2
""", asset["id"], limit)
# Fix #15: Correct holder count query using subquery
# Holder count via subquery — naive COUNT DISTINCT + GROUP BY misses
# the HAVING SUM > 0 filter and over-counts addresses that net to zero.
holder_count = await conn.fetchrow("""
SELECT COUNT(*) as count FROM (
SELECT txo.address
@ -1936,11 +1879,10 @@ async def get_pool_info(pool_id: str, auth: dict = Depends(get_auth_context)):
async def create_api_key(key_data: APIKeyCreate, auth: dict = Depends(require_master_key)):
"""Create a new API key (admin-created keys don't expire unless explicitly set)."""
new_key = f"capi_{secrets.token_hex(24)}"
# Fix #4: Store using hashed key
key_hash = hash_api_key(new_key)
# Admin-created keys don't expire by default (Fix #10)
# Admin-created keys don't expire by default — only TRP-gated keys do.
await redis_client.hset(f"apikey:{key_hash}", mapping={
"label": key_data.label,
"tier": key_data.tier,
@ -1962,13 +1904,11 @@ async def create_api_key(key_data: APIKeyCreate, auth: dict = Depends(require_ma
@app.delete("/admin/keys/{key}")
async def revoke_api_key(key: str, auth: dict = Depends(require_master_key)):
"""Revoke an API 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:
raise HTTPException(status_code=404, detail={"error": "not_found", "message": "API key not found"})
# Fix #19: Remove from TRP-gated keys set
await redis_client.srem("trp_gated_keys", key_hash)
return {"status": "revoked", "key": key[:16] + "..."}