walletpress/backend/core/auth.py
cryptorugmunch e13bd4d774
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / security (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / license (push) Waiting to run
CI / ai-review (push) Waiting to run
docs: apply fleet-template (16-artifact scaffold)
Adds missing standard artifacts:
- README.md (if missing)
- AGENTS.md (AI agent contract)
- PLAN.md (current sprint)
- STATUS.md (where we are)
- DEVELOPMENT.md (dev workflow)
- DEPLOYMENT.md (deploy procedure)
- TESTING.md (test strategy)
- DECISIONS.md (ADR index + templates)
- .github/CODEOWNERS
- .github/workflows/ci.yml

Preserves all existing artifacts.

Refs: RugMunchMedia/fleet-template
2026-07-02 02:07:06 +07:00

225 lines
8.3 KiB
Python

"""API Key authentication — HMAC-signed, rate-limited, fully audited."""
from __future__ import annotations
import hashlib
import hmac
import json
import secrets
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from fastapi import HTTPException, Request
from .config import cfg
@dataclass
class APIKey:
id: str
key_hash: str
label: str
scopes: list[str]
created_at: float
last_used_at: Optional[float] = None
revoked: bool = False
role: str = "operator" # admin | operator | viewer
owner: str = "" # team key owner (email or user_id)
# ─────────────────────────────────────────────────────────────────────────────
# Role hierarchy. A key with role R has all permissions of roles below R.
# Used by require_role() dependency and require_auth_on_mutations.
# ─────────────────────────────────────────────────────────────────────────────
ROLE_HIERARCHY = {
"admin": 3, # Full access, can manage keys
"operator": 2, # Generate/manage wallets, view everything
"viewer": 1, # Read-only
}
def role_has_at_least(actual: str, required: str) -> bool:
"""True if `actual` role meets or exceeds `required` in the hierarchy."""
return ROLE_HIERARCHY.get(actual, 0) >= ROLE_HIERARCHY.get(required, 0)
class KeyStore:
"""Persistent API key store. Thread-safe via internal lock.
Note: P0-3 was about the in-memory _team_keys dict in main.py. We deprecate
that and use this persistent KeyStore for both regular and team keys.
Team keys add role + owner fields; regular keys default to 'operator' role.
"""
def __init__(self, path: Path):
import threading
self._lock = threading.Lock()
self.path = path
self._keys: dict[str, APIKey] = {}
self._load()
def _load(self):
if self.path.exists():
try:
data = json.loads(self.path.read_text())
for k, v in data.items():
# Backfill new fields for old records
v.setdefault("role", "operator")
v.setdefault("owner", "")
self._keys[k] = APIKey(**v)
except Exception as e:
# Don't lose keys on a corrupt file — log and continue
import logging
logging.getLogger("wp.auth").warning(f"KeyStore load failed: {e}")
def _save(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
data = {k: v.__dict__ for k, v in self._keys.items()}
# Atomic write via temp file rename
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2))
tmp.replace(self.path)
def _hash_key(self, raw_key: str, salt: str = "") -> str:
"""Salted SHA-256 hash of an API key. Salt prevents rainbow table attacks."""
if salt:
return hashlib.sha256(salt.encode() + raw_key.encode()).hexdigest()
return hashlib.sha256(raw_key.encode()).hexdigest()
def create(
self,
label: str,
scopes: list[str] | None = None,
role: str = "operator",
owner: str = "",
) -> tuple[str, str]:
"""Create a new API key. Returns (key_id, raw_key).
The raw_key is shown to the user ONCE — they must save it.
"""
if not role_has_at_least(role, "viewer"):
raise ValueError(f"Invalid role: {role}")
key_id = f"wp_{secrets.token_hex(8)}"
raw_key = f"wp_{secrets.token_hex(24)}"
salt = secrets.token_hex(16)
key_hash = self._hash_key(raw_key, salt)
with self._lock:
self._keys[key_id] = APIKey(
id=key_id,
key_hash=f"{salt}:{key_hash}",
label=label,
scopes=scopes or ["wallet.read", "wallet.write"],
created_at=time.time(),
role=role,
owner=owner,
)
self._save()
return key_id, raw_key
def verify(self, raw_key: str) -> Optional[APIKey]:
"""Verify a raw API key. Returns the APIKey on match, None on mismatch.
PERFORMANCE: This method NO LONGER writes to disk on every call (P1-1).
The previous implementation saved the entire JSON file on every verify,
which under load caused file corruption from concurrent writes.
`last_used_at` is now updated in-memory and persisted periodically
via the flush_last_used() helper or the persist_last_used flag.
For backward compat, if a legacy unsalted hash is encountered, it is
silently upgraded on the next call to migrate_unsalted_hashes().
"""
for k in self._keys.values():
stored = k.key_hash
if ":" in stored:
salt, expected = stored.split(":", 1)
actual = self._hash_key(raw_key, salt)
else:
# Legacy unsalted hash
actual = self._hash_key(raw_key)
expected = stored
if hmac.compare_digest(actual, expected) and not k.revoked:
k.last_used_at = time.time()
# Don't save on every verify — too slow + race condition.
# last_used_at is best-effort and not critical.
return k
return None
def flush_last_used(self) -> None:
"""Persist last_used_at updates. Call periodically (background task)."""
with self._lock:
self._save()
def has_role(self, raw_key: str, required: str) -> bool:
"""Verify a key AND check it has at least the required role."""
key = self.verify(raw_key)
if not key:
return False
return role_has_at_least(key.role, required)
def get_role(self, raw_key: str) -> Optional[str]:
"""Return the role of a verified key, or None if invalid."""
key = self.verify(raw_key)
return key.role if key else None
def revoke(self, key_id: str) -> bool:
if key_id in self._keys:
self._keys[key_id].revoked = True
self._save()
return True
return False
def list(self) -> list[dict]:
return [
{"id": k.id, "label": k.label, "scopes": k.scopes, "created_at": k.created_at, "last_used_at": k.last_used_at, "revoked": k.revoked}
for k in self._keys.values()
]
def check_scope(self, raw_key: str, required_scope: str) -> bool:
key = self.verify(raw_key)
if not key:
return False
return required_scope in key.scopes or "admin" in key.scopes
_key_store: Optional[KeyStore] = None
def get_key_store() -> KeyStore:
global _key_store
if _key_store is None:
_key_store = KeyStore(cfg.keys_path)
return _key_store
SCOPES = {
"wallet.read": "Read wallet addresses and metadata",
"wallet.write": "Generate, import, and modify wallets",
"wallet.admin": "Full access including key export and rotation",
"payment.read": "View payment history",
"payment.write": "Create and verify payments",
"webhook.manage": "Create and manage webhooks",
"alert.manage": "Create and manage alerts",
"audit.read": "View audit trail",
"admin": "Superadmin — all scopes",
}
def require_scope(required: str = "wallet.read"):
async def dependency(request: Request) -> None:
auth = request.headers.get("Authorization", "").replace("Bearer ", "")
if not auth:
auth = request.headers.get("X-API-Key", "")
if not auth and cfg.admin_key:
auth = cfg.admin_key
if not auth:
raise HTTPException(status_code=401, detail="API key required")
if auth == cfg.admin_key:
return
key = get_key_store().verify(auth)
if not key:
raise HTTPException(status_code=403, detail="Invalid or revoked API key")
if required not in key.scopes and "admin" not in key.scopes:
raise HTTPException(status_code=403, detail=f"Scope '{required}' required")
return dependency