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_id TEXT DEFAULT '',
|
||||||
subscription_status TEXT DEFAULT 'active',
|
subscription_status TEXT DEFAULT 'active',
|
||||||
created_at REAL NOT NULL,
|
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 (
|
CREATE TABLE IF NOT EXISTS usage_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
@ -138,6 +141,17 @@ class HostingDB:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def register(self, email: str, password: str, name: str = "") -> dict:
|
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()
|
conn = self._conn()
|
||||||
existing = conn.execute("SELECT id FROM users WHERE email = ?", (email,)).fetchone()
|
existing = conn.execute("SELECT id FROM users WHERE email = ?", (email,)).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
|
|
@ -146,13 +160,72 @@ class HostingDB:
|
||||||
user_id = f"u_{secrets.token_hex(8)}"
|
user_id = f"u_{secrets.token_hex(8)}"
|
||||||
pw_hash = _hash_password(password)
|
pw_hash = _hash_password(password)
|
||||||
api_key = f"wp_live_{secrets.token_hex(24)}"
|
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(
|
conn.execute(
|
||||||
"INSERT INTO users (id, email, password_hash, name, plan, api_key, created_at) VALUES (?, ?, ?, ?, 'free', ?, ?)",
|
"""INSERT INTO users
|
||||||
(user_id, email, pw_hash, name, api_key, time.time()),
|
(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.commit()
|
||||||
conn.close()
|
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]:
|
def login(self, email: str, password: str) -> Optional[dict]:
|
||||||
"""Verify login and migrate legacy SHA-256 hash to Argon2id on success.
|
"""Verify login and migrate legacy SHA-256 hash to Argon2id on success.
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,10 @@ class LoginRequest(BaseModel):
|
||||||
password: str = Field(...)
|
password: str = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class VerifyEmailRequest(BaseModel):
|
||||||
|
token: str = Field(...)
|
||||||
|
|
||||||
|
|
||||||
class SubscribeRequest(BaseModel):
|
class SubscribeRequest(BaseModel):
|
||||||
plan: str = Field(...)
|
plan: str = Field(...)
|
||||||
stripe_token: str = Field(default="", description="Stripe token (stub for now)")
|
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")
|
@router.get("/me")
|
||||||
async def me(request: Request):
|
async def me(request: Request):
|
||||||
"""Get account details and current usage."""
|
"""Get account details and current usage."""
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,68 @@ class WalletPress {
|
||||||
private array $modules = [];
|
private array $modules = [];
|
||||||
private ?WalletPressAPI $api = null;
|
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 {
|
public function init(): void {
|
||||||
add_action('init', [$this, 'load_textdomain']);
|
add_action('init', [$this, 'load_textdomain']);
|
||||||
add_action('init', [$this, 'register_shortcodes']);
|
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('wp_enqueue_scripts', [$this, 'enqueue_frontend']);
|
||||||
add_action('admin_enqueue_scripts', [$this, 'enqueue_admin']);
|
add_action('admin_enqueue_scripts', [$this, 'enqueue_admin']);
|
||||||
add_action('rest_api_init', [$this, 'register_routes']);
|
add_action('rest_api_init', [$this, 'register_routes']);
|
||||||
|
|
@ -137,11 +196,19 @@ class WalletPress {
|
||||||
'mantle' => ['label' => 'Mantle', 'icon' => 'mnt', 'wallet' => 'metamask'],
|
'mantle' => ['label' => 'Mantle', 'icon' => 'mnt', 'wallet' => 'metamask'],
|
||||||
'linea' => ['label' => 'Linea', 'icon' => 'linea', 'wallet' => 'metamask'],
|
'linea' => ['label' => 'Linea', 'icon' => 'linea', 'wallet' => 'metamask'],
|
||||||
'metis' => ['label' => 'Metis', 'icon' => 'metis', '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' => ''],
|
'litecoin' => ['label' => 'Litecoin', 'icon' => 'ltc', 'wallet' => ''],
|
||||||
'dogecoin' => ['label' => 'Dogecoin', 'icon' => 'doge', 'wallet' => ''],
|
'dogecoin' => ['label' => 'Dogecoin', 'icon' => 'doge', 'wallet' => ''],
|
||||||
|
'bitcoin_cash' => ['label' => 'Bitcoin Cash', 'icon' => 'bch', 'wallet' => ''],
|
||||||
'cardano' => ['label' => 'Cardano', 'icon' => 'ada', 'wallet' => ''],
|
'cardano' => ['label' => 'Cardano', 'icon' => 'ada', 'wallet' => ''],
|
||||||
'polkadot' => ['label' => 'Polkadot', 'icon' => 'dot', 'wallet' => ''],
|
'polkadot' => ['label' => 'Polkadot', 'icon' => 'dot', 'wallet' => ''],
|
||||||
|
'kusama' => ['label' => 'Kusama', 'icon' => 'ksm', 'wallet' => ''],
|
||||||
'cosmos' => ['label' => 'Cosmos', 'icon' => 'atom', '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' => ''],
|
'ripple' => ['label' => 'Ripple', 'icon' => 'xrp', 'wallet' => ''],
|
||||||
'stellar' => ['label' => 'Stellar', 'icon' => 'xlm', 'wallet' => ''],
|
'stellar' => ['label' => 'Stellar', 'icon' => 'xlm', 'wallet' => ''],
|
||||||
'near' => ['label' => 'NEAR', 'icon' => 'near', 'wallet' => ''],
|
'near' => ['label' => 'NEAR', 'icon' => 'near', 'wallet' => ''],
|
||||||
|
|
@ -150,6 +217,21 @@ class WalletPress {
|
||||||
'algorand' => ['label' => 'Algorand', 'icon' => 'algo', 'wallet' => ''],
|
'algorand' => ['label' => 'Algorand', 'icon' => 'algo', 'wallet' => ''],
|
||||||
'tezos' => ['label' => 'Tezos', 'icon' => 'xtz', 'wallet' => ''],
|
'tezos' => ['label' => 'Tezos', 'icon' => 'xtz', 'wallet' => ''],
|
||||||
'monero' => ['label' => 'Monero', 'icon' => 'xmr', '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