P2-12 — Hosted email verification (opt-in)
Added verify_email() method + /hosting/verify-email endpoint.
When WP_REQUIRE_EMAIL_VERIFY=1:
- Registration returns a verification_token in the response
- User calls POST /hosting/verify-email with the token to activate
- Token expires in 24 hours
Email sending is documented but not automated (caller must wire
core/email_notify.py). For community/self-hosted, verification is
auto-skipped (email_verified=1 by default).
P2-18 — WP plugin AES-CBC → AES-GCM
class-walletpress.php encrypt()/decrypt() was using AES-256-CBC
with no authentication, making stored values vulnerable to
padding-oracle and bit-flipping attacks.
New format: base64(iv[12] || ciphertext || tag[16]) using AES-256-GCM.
decrypt() auto-detects format and falls back to legacy CBC for
backwards compat with existing encrypted values (e.g. the API
key option in wp_options).
P2-10 — register() api_key plaintext note
api_key in response body is intentional (the user MUST save it).
But added a clearer 'warning' note and documented why.
Test results: 80 passed, 5 skipped (no regressions).
Refs: AUDIT.md P2-12, P2-18, P2-10
319 lines
12 KiB
Python
319 lines
12 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,
|
|
email_verified INTEGER DEFAULT 0,
|
|
email_verify_token TEXT DEFAULT '',
|
|
email_verify_expires 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:
|
|
"""Register a new user with email verification (P2-12 fix).
|
|
|
|
Email verification is OPT-IN via WP_REQUIRE_EMAIL_VERIFY=1. By default
|
|
(community / self-hosted dev), users can use the API key immediately.
|
|
For hosted deployments with SMTP configured, set that flag to require
|
|
email confirmation before the API key becomes active.
|
|
|
|
Returns:
|
|
dict with user_id, api_key (provisional), plan, and optionally
|
|
a verification_token the caller should email to the user.
|
|
"""
|
|
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)}"
|
|
require_verify = os.getenv("WP_REQUIRE_EMAIL_VERIFY", "0") == "1"
|
|
verify_token = secrets.token_urlsafe(32) if require_verify else ""
|
|
verify_expires = time.time() + 86400 if require_verify else 0 # 24h
|
|
conn.execute(
|
|
"""INSERT INTO users
|
|
(id, email, password_hash, name, plan, api_key, created_at,
|
|
email_verified, email_verify_token, email_verify_expires)
|
|
VALUES (?, ?, ?, ?, 'free', ?, ?, ?, ?, ?)""",
|
|
(
|
|
user_id, email, pw_hash, name, api_key, time.time(),
|
|
0 if require_verify else 1, # auto-verify if not required
|
|
verify_token, verify_expires,
|
|
),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
result = {
|
|
"user_id": user_id,
|
|
"api_key": api_key,
|
|
"plan": "free",
|
|
"email_verification_required": require_verify,
|
|
}
|
|
if require_verify:
|
|
result["verification_token"] = verify_token
|
|
result["note"] = (
|
|
"Send POST /hosting/verify-email with {token} to activate. "
|
|
"Token expires in 24h. Configure WP_SMTP_HOST etc. to send "
|
|
"the email automatically (see core/email_notify.py)."
|
|
)
|
|
return result
|
|
|
|
def verify_email(self, token: str) -> bool:
|
|
"""Mark the email as verified using a token from registration."""
|
|
if not token:
|
|
return False
|
|
conn = self._conn()
|
|
row = conn.execute(
|
|
"SELECT id, email_verify_expires FROM users WHERE email_verify_token = ?",
|
|
(token,),
|
|
).fetchone()
|
|
if not row:
|
|
conn.close()
|
|
return False
|
|
if row["email_verify_expires"] and time.time() > row["email_verify_expires"]:
|
|
conn.close()
|
|
return False
|
|
conn.execute(
|
|
"""UPDATE users
|
|
SET email_verified = 1, email_verify_token = '', email_verify_expires = 0
|
|
WHERE id = ?""",
|
|
(row["id"],),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return True
|
|
|
|
def is_email_verified(self, user_id: str) -> bool:
|
|
"""True if the user has verified their email (or verification is not required)."""
|
|
conn = self._conn()
|
|
row = conn.execute(
|
|
"SELECT email_verified FROM users WHERE id = ?", (user_id,)
|
|
).fetchone()
|
|
conn.close()
|
|
if not row:
|
|
return False
|
|
return bool(row["email_verified"])
|
|
|
|
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
|