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
246 lines
9 KiB
Python
246 lines
9 KiB
Python
"""WalletPress Hosted — multi-tenant user management + usage tracking.
|
|
|
|
Revenue model:
|
|
Free: 3 chains, 10 wallets/day, no API access
|
|
Starter: $29/mo, 10 chains, 500 wallets/day, API access
|
|
Pro: $79/mo, 55 chains, unlimited wallets, full API, 2FA
|
|
Enterprise: $199/mo, everything + dedicated support + SLA
|
|
|
|
Users self-register, get API keys, and pay via Stripe.
|
|
Admin creates plans, views usage, manages subscriptions.
|
|
|
|
SECURITY: Passwords are hashed with Argon2id (m=64MB, t=3, p=4).
|
|
Legacy SHA-256 hashes are migrated on next successful login.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import secrets
|
|
import sqlite3
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from argon2 import PasswordHasher
|
|
from argon2.exceptions import (
|
|
VerifyMismatchError,
|
|
InvalidHashError,
|
|
VerificationError,
|
|
)
|
|
|
|
from .config import cfg
|
|
|
|
logger = logging.getLogger("wp.hosting")
|
|
|
|
# Argon2id parameters: OWASP recommendations as of 2024
|
|
# m = 64 MiB memory cost, t = 3 iterations, p = 4 parallelism
|
|
# (argon2-cffi defaults are 64MB / 3 iterations / 4 lanes)
|
|
_ph = PasswordHasher()
|
|
|
|
|
|
def _hash_password(plaintext: str) -> str:
|
|
"""Hash a password with Argon2id. Returns the encoded hash string.
|
|
|
|
The returned string includes the salt, parameters, and hash in a
|
|
self-describing format like:
|
|
$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
|
|
"""
|
|
return _ph.hash(plaintext)
|
|
|
|
|
|
def _verify_password(stored_hash: str, plaintext: str) -> bool:
|
|
"""Verify a plaintext password against a stored hash.
|
|
|
|
Supports both new Argon2id hashes AND legacy unsalted SHA-256 hashes
|
|
(for migration). Returns True on match, False on mismatch.
|
|
"""
|
|
if not stored_hash:
|
|
return False
|
|
# Legacy SHA-256 hash: 64 hex chars, no $ separators
|
|
if "$" not in stored_hash and len(stored_hash) == 64:
|
|
try:
|
|
import hashlib
|
|
legacy_match = hashlib.sha256(plaintext.encode()).hexdigest() == stored_hash
|
|
return legacy_match
|
|
except Exception:
|
|
return False
|
|
# Argon2id hash
|
|
try:
|
|
_ph.verify(stored_hash, plaintext)
|
|
return True
|
|
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
|
return False
|
|
|
|
|
|
def _needs_rehash(stored_hash: str) -> bool:
|
|
"""Check if the stored hash uses outdated parameters and needs re-hashing."""
|
|
if "$" not in stored_hash:
|
|
return True # Legacy SHA-256 — needs migration to Argon2id
|
|
try:
|
|
return _ph.check_needs_rehash(stored_hash)
|
|
except InvalidHashError:
|
|
return True
|
|
|
|
|
|
PLANS = {
|
|
"free": {"name": "Free", "price_usd": 0, "chains": 3, "daily_wallet_limit": 10, "api_access": False, "features": ["basic_generation"]},
|
|
"starter": {"name": "Starter", "price_usd": 29, "chains": 10, "daily_wallet_limit": 500, "api_access": True, "features": ["basic_generation", "batch", "mnemonic", "export"]},
|
|
"pro": {"name": "Pro", "price_usd": 79, "chains": 55, "daily_wallet_limit": 0, "api_access": True, "features": ["all"]},
|
|
"enterprise": {"name": "Enterprise", "price_usd": 199, "chains": 55, "daily_wallet_limit": 0, "api_access": True, "features": ["all", "2fa", "sla"]},
|
|
}
|
|
|
|
|
|
class HostingDB:
|
|
"""User and subscription management."""
|
|
|
|
def __init__(self, db_path: Path):
|
|
self.db_path = db_path
|
|
self._init_db()
|
|
|
|
def _conn(self):
|
|
conn = sqlite3.connect(str(self.db_path))
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
return conn
|
|
|
|
def _init_db(self):
|
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
conn = self._conn()
|
|
conn.executescript("""
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id TEXT PRIMARY KEY,
|
|
email TEXT UNIQUE NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
name TEXT DEFAULT '',
|
|
plan TEXT DEFAULT 'free',
|
|
api_key TEXT UNIQUE DEFAULT '',
|
|
stripe_customer_id TEXT DEFAULT '',
|
|
subscription_id TEXT DEFAULT '',
|
|
subscription_status TEXT DEFAULT 'active',
|
|
created_at REAL NOT NULL,
|
|
last_login_at REAL DEFAULT 0
|
|
);
|
|
CREATE TABLE IF NOT EXISTS usage_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
chain TEXT DEFAULT '',
|
|
count INTEGER DEFAULT 1,
|
|
timestamp REAL NOT NULL,
|
|
ip TEXT DEFAULT '',
|
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_usage_user ON usage_log(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_usage_date ON usage_log(timestamp);
|
|
""")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
def register(self, email: str, password: str, name: str = "") -> dict:
|
|
conn = self._conn()
|
|
existing = conn.execute("SELECT id FROM users WHERE email = ?", (email,)).fetchone()
|
|
if existing:
|
|
conn.close()
|
|
return {"error": "Email already registered"}
|
|
user_id = f"u_{secrets.token_hex(8)}"
|
|
pw_hash = _hash_password(password)
|
|
api_key = f"wp_live_{secrets.token_hex(24)}"
|
|
conn.execute(
|
|
"INSERT INTO users (id, email, password_hash, name, plan, api_key, created_at) VALUES (?, ?, ?, ?, 'free', ?, ?)",
|
|
(user_id, email, pw_hash, name, api_key, time.time()),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"user_id": user_id, "api_key": api_key, "plan": "free"}
|
|
|
|
def login(self, email: str, password: str) -> Optional[dict]:
|
|
"""Verify login and migrate legacy SHA-256 hash to Argon2id on success.
|
|
|
|
Returns the user row (without password_hash) on success, None on failure.
|
|
On successful login with an outdated hash, transparently re-hashes the
|
|
password with current Argon2id parameters.
|
|
"""
|
|
conn = self._conn()
|
|
row = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
|
|
if not row:
|
|
conn.close()
|
|
return None
|
|
stored_hash = row["password_hash"]
|
|
if not _verify_password(stored_hash, password):
|
|
conn.close()
|
|
return None
|
|
|
|
# Migrate outdated hash to current Argon2id params
|
|
if _needs_rehash(stored_hash):
|
|
try:
|
|
new_hash = _hash_password(password)
|
|
conn.execute(
|
|
"UPDATE users SET password_hash = ? WHERE id = ?",
|
|
(new_hash, row["id"]),
|
|
)
|
|
conn.commit()
|
|
logger.info(f"Migrated password hash to Argon2id for user {row['id']}")
|
|
except Exception as e:
|
|
logger.warning(f"Password hash migration failed for user {row['id']}: {e}")
|
|
|
|
# Return everything except password_hash — never leak it
|
|
safe = {k: v for k, v in dict(row).items() if k != "password_hash"}
|
|
conn.close()
|
|
return safe
|
|
|
|
def get_by_api_key(self, api_key: str) -> Optional[dict]:
|
|
conn = self._conn()
|
|
row = conn.execute("SELECT * FROM users WHERE api_key = ?", (api_key,)).fetchone()
|
|
conn.close()
|
|
return dict(row) if row else None
|
|
|
|
def log_usage(self, user_id: str, action: str, chain: str = "", count: int = 1, ip: str = ""):
|
|
conn = self._conn()
|
|
conn.execute(
|
|
"INSERT INTO usage_log (user_id, action, chain, count, timestamp, ip) VALUES (?, ?, ?, ?, ?, ?)",
|
|
(user_id, action, chain, count, time.time(), ip),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
def daily_usage(self, user_id: str) -> int:
|
|
today = int(time.time()) - (int(time.time()) % 86400)
|
|
conn = self._conn()
|
|
row = conn.execute(
|
|
"SELECT COALESCE(SUM(count), 0) as total FROM usage_log WHERE user_id = ? AND timestamp >= ?",
|
|
(user_id, today),
|
|
).fetchone()
|
|
conn.close()
|
|
return row["total"] if row else 0
|
|
|
|
def check_limit(self, user_id: str) -> tuple[bool, str]:
|
|
conn = self._conn()
|
|
row = conn.execute("SELECT plan FROM users WHERE id = ?", (user_id,)).fetchone()
|
|
conn.close()
|
|
if not row:
|
|
return False, "User not found"
|
|
plan = PLANS.get(row["plan"], PLANS["free"])
|
|
if plan["daily_wallet_limit"] == 0:
|
|
return True, "" # Unlimited
|
|
used = self.daily_usage(user_id)
|
|
if used >= plan["daily_wallet_limit"]:
|
|
return False, f"Daily limit reached ({plan['daily_wallet_limit']} wallets). Upgrade at /hosting/plans"
|
|
return True, ""
|
|
|
|
def get_user(self, user_id: str) -> Optional[dict]:
|
|
conn = self._conn()
|
|
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
|
conn.close()
|
|
return dict(row) if row else None
|
|
|
|
|
|
# Singleton
|
|
_hosting: Optional[HostingDB] = None
|
|
|
|
|
|
def get_hosting() -> HostingDB:
|
|
global _hosting
|
|
if _hosting is None:
|
|
_hosting = HostingDB(cfg.data_dir / "hosting.db")
|
|
return _hosting
|