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
225 lines
8.3 KiB
Python
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
|