From ac36eb97e0e0e81a836184602c6933fd4d940eae Mon Sep 17 00:00:00 2001 From: Rug Munch Media LLC Date: Tue, 30 Jun 2026 21:10:34 +0700 Subject: [PATCH] =?UTF-8?q?fix(audit):=20Wave=203=20=E2=80=94=20WP=20plugi?= =?UTF-8?q?n=20AES-GCM=20+=20email=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/core/hosting.py | 81 +++++++++++++++++++++-- backend/routers/hosting.py | 23 +++++++ wp-plugin/includes/class-walletpress.php | 82 ++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 4 deletions(-) diff --git a/backend/core/hosting.py b/backend/core/hosting.py index d7d7c31..2ce8549 100644 --- a/backend/core/hosting.py +++ b/backend/core/hosting.py @@ -119,7 +119,10 @@ class HostingDB: subscription_id TEXT DEFAULT '', subscription_status TEXT DEFAULT 'active', created_at REAL NOT NULL, - last_login_at REAL DEFAULT 0 + 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, @@ -138,6 +141,17 @@ class HostingDB: 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: @@ -146,13 +160,72 @@ class HostingDB: 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) VALUES (?, ?, ?, ?, 'free', ?, ?)", - (user_id, email, pw_hash, name, api_key, time.time()), + """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() - return {"user_id": user_id, "api_key": api_key, "plan": "free"} + 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. diff --git a/backend/routers/hosting.py b/backend/routers/hosting.py index 72c4e0a..83ec935 100644 --- a/backend/routers/hosting.py +++ b/backend/routers/hosting.py @@ -83,6 +83,10 @@ class LoginRequest(BaseModel): password: str = Field(...) +class VerifyEmailRequest(BaseModel): + token: str = Field(...) + + class SubscribeRequest(BaseModel): plan: str = Field(...) stripe_token: str = Field(default="", description="Stripe token (stub for now)") @@ -140,6 +144,25 @@ async def login(req: LoginRequest, request: Request): } +@router.post("/verify-email") +async def verify_email(req: VerifyEmailRequest): + """Verify the email address associated with a registration. + + Required when WP_REQUIRE_EMAIL_VERIFY=1 is set. The token is generated + by /hosting/register and should be sent via email (use core/email_notify.py + to send automatically when SMTP is configured). + + Returns 200 on success, 400 on invalid/expired token. + """ + host = get_hosting() + if host.verify_email(req.token): + return {"verified": True} + raise HTTPException( + status_code=400, + detail="Invalid or expired verification token. Register again to get a new one.", + ) + + @router.get("/me") async def me(request: Request): """Get account details and current usage.""" diff --git a/wp-plugin/includes/class-walletpress.php b/wp-plugin/includes/class-walletpress.php index d8eca5e..14b1324 100644 --- a/wp-plugin/includes/class-walletpress.php +++ b/wp-plugin/includes/class-walletpress.php @@ -5,9 +5,68 @@ class WalletPress { private array $modules = []; private ?WalletPressAPI $api = null; + /** + * Encrypt a value using WordPress salt as the key. + * + * P2-18 fix: Was AES-256-CBC without authentication (malleable, vulnerable + * to padding-oracle / bit-flipping attacks). Now AES-256-GCM with the + * 16-byte auth tag appended to the IV field. Format: + * base64( iv (12B) || ciphertext || tag (16B) ) + * On legacy decrypt() (CBC), auto-detects and decodes the old format + * so existing encrypted values still work after an update. + */ + public static function encrypt(string $plaintext): string { + if ($plaintext === '') return ''; + $key = defined('LOGGED_IN_KEY') ? LOGGED_IN_KEY : wp_salt('logged_in'); + // Derive a 32-byte key from the salt (it may be shorter than 32 chars) + $key = hash('sha256', $key, true); + $iv = openssl_random_pseudo_bytes(12); + $tag = ''; + $cipher = openssl_encrypt( + $plaintext, + 'aes-256-gcm', + $key, + OPENSSL_RAW_DATA, + $iv, + $tag, + '', + 16 + ); + // Format: base64(iv (12) || ciphertext || tag (16)) + return base64_encode($iv . $cipher . $tag); + } + + /** Decrypt a value encrypted by encrypt(). Supports legacy CBC format. */ + public static function decrypt(string $ciphertext): string { + if ($ciphertext === '') return ''; + $key = defined('LOGGED_IN_KEY') ? LOGGED_IN_KEY : wp_salt('logged_in'); + $data = base64_decode($ciphertext, true); + if ($data === false || strlen($data) < 16) return ''; + + // Auto-detect format. GCM: 12B IV + N ciphertext + 16B tag = at least 28B. + // CBC legacy: 16B IV + N ciphertext. Heuristic: try GCM first if + // (len - 12 - 16) >= 0; otherwise legacy CBC. + $key = hash('sha256', $key, true); + if (strlen($data) >= 28) { + // Try GCM first + $iv = substr($data, 0, 12); + $tag = substr($data, -16); + $cipher = substr($data, 12, -16); + $plain = openssl_decrypt($cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, ''); + if ($plain !== false) return $plain; + // Fall through to CBC attempt + } + // Legacy CBC: 16B IV + ciphertext + $iv = substr($data, 0, 16); + $cipher = substr($data, 16); + return openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv) ?: ''; + } + public function init(): void { add_action('init', [$this, 'load_textdomain']); add_action('init', [$this, 'register_shortcodes']); + add_filter('pre_update_option_walletpress_api_key', [__CLASS__, 'encrypt'], 10, 1); + add_filter('option_walletpress_api_key', [__CLASS__, 'decrypt'], 10, 1); add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend']); add_action('admin_enqueue_scripts', [$this, 'enqueue_admin']); add_action('rest_api_init', [$this, 'register_routes']); @@ -137,11 +196,19 @@ class WalletPress { 'mantle' => ['label' => 'Mantle', 'icon' => 'mnt', 'wallet' => 'metamask'], 'linea' => ['label' => 'Linea', 'icon' => 'linea', 'wallet' => 'metamask'], 'metis' => ['label' => 'Metis', 'icon' => 'metis', 'wallet' => 'metamask'], + 'opbnb' => ['label' => 'opBNB', 'icon' => 'bnb', 'wallet' => 'metamask'], + 'manta' => ['label' => 'Manta', 'icon' => 'manta', 'wallet' => 'metamask'], + 'starknet' => ['label' => 'StarkNet', 'icon' => 'strk', 'wallet' => 'metamask'], + 'polygon_zkevm' => ['label' => 'Polygon zkEVM', 'icon' => 'poly', 'wallet' => 'metamask'], 'litecoin' => ['label' => 'Litecoin', 'icon' => 'ltc', 'wallet' => ''], 'dogecoin' => ['label' => 'Dogecoin', 'icon' => 'doge', 'wallet' => ''], + 'bitcoin_cash' => ['label' => 'Bitcoin Cash', 'icon' => 'bch', 'wallet' => ''], 'cardano' => ['label' => 'Cardano', 'icon' => 'ada', 'wallet' => ''], 'polkadot' => ['label' => 'Polkadot', 'icon' => 'dot', 'wallet' => ''], + 'kusama' => ['label' => 'Kusama', 'icon' => 'ksm', 'wallet' => ''], 'cosmos' => ['label' => 'Cosmos', 'icon' => 'atom', 'wallet' => ''], + 'osmosis' => ['label' => 'Osmosis', 'icon' => 'osmo', 'wallet' => ''], + 'injective' => ['label' => 'Injective', 'icon' => 'inj', 'wallet' => ''], 'ripple' => ['label' => 'Ripple', 'icon' => 'xrp', 'wallet' => ''], 'stellar' => ['label' => 'Stellar', 'icon' => 'xlm', 'wallet' => ''], 'near' => ['label' => 'NEAR', 'icon' => 'near', 'wallet' => ''], @@ -150,6 +217,21 @@ class WalletPress { 'algorand' => ['label' => 'Algorand', 'icon' => 'algo', 'wallet' => ''], 'tezos' => ['label' => 'Tezos', 'icon' => 'xtz', 'wallet' => ''], 'monero' => ['label' => 'Monero', 'icon' => 'xmr', 'wallet' => ''], + 'filecoin' => ['label' => 'Filecoin', 'icon' => 'fil', 'wallet' => ''], + 'internet_computer' => ['label' => 'Internet Computer', 'icon' => 'icp', 'wallet' => ''], + 'hedera' => ['label' => 'Hedera', 'icon' => 'hbar', 'wallet' => ''], + 'ton' => ['label' => 'TON', 'icon' => 'ton', 'wallet' => ''], + 'nano' => ['label' => 'Nano', 'icon' => 'nano', 'wallet' => ''], + 'elrond' => ['label' => 'Elrond', 'icon' => 'egld', 'wallet' => ''], + 'casper' => ['label' => 'Casper', 'icon' => 'cspr', 'wallet' => ''], + 'zilliqa' => ['label' => 'Zilliqa', 'icon' => 'zil', 'wallet' => ''], + 'sei' => ['label' => 'Sei', 'icon' => 'sei', 'wallet' => ''], + 'juno' => ['label' => 'Juno', 'icon' => 'juno', 'wallet' => ''], + 'kava' => ['label' => 'Kava', 'icon' => 'kava', 'wallet' => 'metamask'], + 'moonbeam' => ['label' => 'Moonbeam', 'icon' => 'glmr', 'wallet' => 'metamask'], + 'cronos' => ['label' => 'Cronos', 'icon' => 'cro', 'wallet' => 'metamask'], + 'harmony' => ['label' => 'Harmony', 'icon' => 'one', 'wallet' => 'metamask'], + 'aurora' => ['label' => 'Aurora', 'icon' => 'aurora', 'wallet' => 'metamask'], ]; }