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:
parent
090ef1b4ea
commit
ac36eb97e0
3 changed files with 182 additions and 4 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue