walletpress/backend/core/auth.py
Rug Munch Media LLC 8b8e85a24d
fix(security): WP-002..WP-006 — remaining P0 fixes
Five P0 security/correctness bugs fixed in this commit:

WP-002 (P0-1) — Free x402 credits
  x402_service.buy_credits() previously accepted any non-empty payment_tx
  and minted credits for free. Now calls verify_payment() (the same
  on-chain USDC verifier used by /generate) before crediting.
  Direct theft vector closed.

WP-003 (P0-2) — Unsalted SHA-256 hosted passwords
  hosting.py used hashlib.sha256(password.encode()).hexdigest() with
  no salt — rainbow-table crackable. Replaced with argon2-cffi
  PasswordHasher (OWASP 2024 params: m=64MB, t=3, p=4).
  Legacy SHA-256 hashes are verified (for backwards compat) and
  transparently migrated to Argon2id on next successful login.
  login() now also strips password_hash from the response (P2-11).

WP-004 (P0-3) — In-memory _team_keys + no role enforcement
  main.py's _team_keys dict was module-level, lost on restart, and
  the middleware didn't enforce roles. Replaced with persistent
  KeyStore (survives restarts), added role field (admin/operator/viewer)
  to APIKey dataclass, ROLE_HIERARCHY constant, and require_role()
  helper. Middleware now:
    - attaches verified APIKey to request.state.api_key_obj
    - rejects mutation requests with viewer role (was the bypass)
    - verifies + attaches for /api/v1/team/* GET endpoints too
  KeyStore._save() now uses atomic temp-file rename (no more partial
  writes) and uses a threading.Lock for safety. P1-1 (verify saves
  on every read) also fixed: last_used_at is now in-memory only,
  flushed via flush_last_used().

WP-005 (P0-5) — Env-only vault KEK
  config.py now resolves the vault KEK in priority order:
    1. WP_VAULT_PASSWORD env var (legacy, dev only)
    2. ~/.walletpress/vault.key file (mode 0600)
    3. {data_dir}/vault.key file (mode 0600)
    4. Auto-generate on first run, persisted to vault.key
  Sources 2-4 are preferred — env vars leak into logs and process
  listings. Added WP_REQUIRE_KEY_FILE=1 to refuse startup without a
  KEK in production. _check_key_file_perms() warns on loose perms
  but doesn't fail (operators should chmod 600).

WP-006 (P0-6) — wallet_sweep doesn't sweep
  The MCP tool claimed to sweep funds but only returned an intent.
  Now actually builds, signs, and broadcasts EVM transactions:
    - Decrypts private key from vault via get_generator
    - Connects to chain RPC via Web3
    - Builds tx: balance - gas_cost to destination
    - Signs with eth_account
    - Broadcasts via existing _evm_broadcast helper
    - Records spending + audit log entry
  Non-EVM chains (Solana, TRON, BTC family) still return intent +
  clear 'not implemented' notice — to be added as separate PRs.

  Same fix applied to DCA scheduler (_exec_dca): when both
  from_wallet_id and to_address are set, actually broadcasts
  via _evm_sweep instead of just logging.

Test results:
  pytest tests/ tests/test_address_vectors.py
  # 80 + 22 = 102 passed, 5 skipped, no regressions

Refs: AUDIT.md, SECURITY.md, BUILDER.md
Closes: WP-002, WP-003, WP-004, WP-005, WP-006
2026-06-30 20:20:39 +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