fix(audit): Wave 3 — WP plugin AES-GCM + email verification

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
This commit is contained in:
Crypto Rug Munch 2026-06-30 21:10:34 +07:00
parent 090ef1b4ea
commit ac36eb97e0
No known key found for this signature in database
GPG key ID: 58D4300721937626
3 changed files with 182 additions and 4 deletions

View file

@ -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.

View file

@ -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."""

View file

@ -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'],
];
}