' . esc_html__('Shieldlume Security account protection', 'shieldlume-security') . '

' . esc_html__('Manage authenticator and passkeys', 'shieldlume-security') . '

'; }); add_action('admin_post_antivirus_ai_account', [self::class, 'manage']); add_action('wp_ajax_antivirus_ai_passkey', [self::class, 'ajax']); add_action('wp_ajax_nopriv_antivirus_ai_passkey', [self::class, 'ajax']); add_action('deleted_user', [self::class, 'deleteAccount']); } private static function id(int $uid): string { return 'auth_user_' . $uid; } public static function account(int $uid): array { $row = (new Store())->get(self::id($uid)); if (null === $row) { return ['totp' => '', 'last_step' => 0, 'keys' => [], 'recovery' => [], 'generation' => 0, 'fails' => 0, 'until' => 0]; } // Absence is a new account. An existing unreadable record must never mean unenrolled. $invalid = static function (): never { throw new \RuntimeException('Saved account protection could not be read safely. Restore its record or use documented host recovery.'); }; if (($row['_kind'] ?? '') !== 'auth_account' || ($row['_status'] ?? '') !== 'active' || !is_string($row['totp'] ?? null) || strlen($row['totp']) > 4096 || !is_array($row['keys'] ?? null) || count($row['keys']) > 10 || !is_array($row['recovery'] ?? null) || !array_is_list($row['recovery']) || count($row['recovery']) > 10) { $invalid(); } foreach (['last_step', 'generation', 'fails', 'until'] as $field) { if (!is_int($row[$field] ?? null) || $row[$field] < 0) { $invalid(); } } foreach (['ceremonies', 'ceremonies_until'] as $field) { if (array_key_exists($field, $row) && (!is_int($row[$field]) || $row[$field] < 0)) { $invalid(); } } if (array_key_exists('handle', $row) && (!is_string($row['handle']) || !preg_match('/^[A-Za-z0-9_-]{43}$/D', $row['handle']))) { $invalid(); } foreach ($row['recovery'] as $hash) { if (!is_string($hash) || !preg_match('/^[a-f0-9]{64}$/D', $hash)) { $invalid(); } } if (count(array_unique($row['recovery'])) !== count($row['recovery'])) { $invalid(); } foreach ($row['keys'] as $keyId => $key) { if (!is_string($keyId) || !preg_match('/^[a-f0-9]{64}$/D', $keyId) || !is_array($key) || !is_string($key['id'] ?? null) || !preg_match('/^[A-Za-z0-9_-]{1,2048}$/D', $key['id']) || !hash_equals($keyId, hash('sha256', $key['id'])) || !is_string($key['public'] ?? null) || '' === $key['public'] || strlen($key['public']) > 16384 || !is_string($key['label'] ?? null) || strlen($key['label']) > 80) { $invalid(); } foreach (['counter', 'created', 'last_used'] as $field) { if (!is_int($key[$field] ?? null) || $key[$field] < 0) { $invalid(); } } } return $row; } public static function enrolled(int $uid): bool { $row = self::account($uid); return !empty($row['totp']) || !empty($row['keys']); } public static function status(int $uid): array { $row = self::account($uid); return ['enabled' => !empty($row['totp']) || !empty($row['keys']), 'authenticator' => !empty($row['totp']), 'passkeys' => count($row['keys']), 'recovery_remaining' => count($row['recovery']), 'host_recovery' => self::disabled()]; } private static function disabled(): bool { // Host-level recovery only. No URL, cookie or account setting can activate this switch. return defined('ANTIVIRUS_AI_DISABLE_ACCOUNT_SECURITY') && ANTIVIRUS_AI_DISABLE_ACCOUNT_SECURITY === true; } private static function save(int $uid, array $row): array { if (empty($row['handle'])) { $row['handle'] = self::b64(!empty($row['keys']) ? hash_hmac('sha256', 'webauthn-user-' . $uid, wp_salt('auth'), true) : random_bytes(32)); } return (new Store())->put('auth_account', self::id($uid), $row, 'active', $row['_revision'] ?? null); } private static function lock(int $uid, callable $operation): mixed { $store = new Store(); $name = 'auth_' . $uid; $token = $store->lock($name, 30); if (!$token) { throw new \RuntimeException('Account protection is busy. Please wait and try again.'); } try { return $operation(self::account($uid)); } finally { $store->unlock($name, $token); } } private static function attempt(int $uid, array $row): array { if ((int)$row['until'] <= time()) { $row['until'] = time() + self::WINDOW; $row['fails'] = 0; } if ((int)$row['fails'] >= self::LIMIT) { throw new \RuntimeException('Too many verification attempts. Wait 15 minutes before trying again.'); } $row['fails']++; return self::save($uid, $row); } private static function key(): string { return hash_hkdf('sha256', wp_salt('secure_auth'), 32, 'antivirus.ai-account-secret-v1'); } public static function seal(string $value): string { if (!function_exists('sodium_crypto_secretbox')) { throw new \RuntimeException('Account enrollment requires the PHP Sodium extension.'); } $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); return base64_encode($nonce . sodium_crypto_secretbox($value, $nonce, self::key())); } public static function open(string $value): string { $raw = base64_decode($value, true); if (!is_string($raw) || strlen($raw) < 40) { throw new \RuntimeException('The authenticator secret could not be read. Use a recovery code or documented host recovery.'); } $plain = sodium_crypto_secretbox_open(substr($raw, 24), substr($raw, 0, 24), self::key()); if (!is_string($plain)) { throw new \RuntimeException('The authenticator secret could not be unlocked. Use a recovery code or documented host recovery.'); } return $plain; } public static function totp(): \AntivirusAI\Vendor\Totp\TwoFactorAuth { require_once ANTIVIRUS_AI_DIR . '/vendor/auth/autoload.php'; // No third-party QR service ever receives the enrollment secret. $qr = new class implements \AntivirusAI\Vendor\Totp\Providers\Qr\IQRCodeProvider { public function getQRCodeImage(string $text, int $size): string { throw new \RuntimeException('External QR generation is disabled.'); } public function getMimeType(): string { return 'image/svg+xml'; } }; return new \AntivirusAI\Vendor\Totp\TwoFactorAuth($qr, 'Shieldlume Security'); } private static function codeHash(string $code): string { return hash('sha256', 'antivirus.ai-recovery-v1|' . strtoupper(str_replace(['-', ' '], '', trim($code)))); } private static function newCodes(array &$row): array { $codes = []; for ($i = 0; $i < 10; $i++) { $code = strtoupper(bin2hex(random_bytes(16))); $codes[] = implode('-', str_split($code, 8)); } $row['recovery'] = array_map([self::class, 'codeHash'], $codes); return $codes; } /** Caller holds the account lock. Consumption and replay state persist in the same CAS write. */ private static function factor(array &$row, string $input): bool { $hash = self::codeHash($input); foreach ($row['recovery'] as $index => $stored) { if (hash_equals($stored, $hash)) { unset($row['recovery'][$index]); $row['recovery'] = array_values($row['recovery']); return true; } } if (!empty($row['totp']) && preg_match('/^[0-9]{6}$/D', $input)) { $step = 0; if (self::totp()->verifyCode(self::open($row['totp']), $input, 1, null, $step) && $step > (int)$row['last_step']) { $row['last_step'] = $step; return true; } } return false; } public static function verifyFactor(int $uid, string $input): bool { return self::lock($uid, static function (array $row) use ($uid, $input): bool { $row = self::attempt($uid, $row); if (!self::factor($row, $input)) { return false; } $row['fails'] = 0; self::save($uid, $row); self::$factorGeneration[$uid] = (int)$row['generation']; return true; }); } public static function authenticate($user, $username, $password) { if (self::$passwordOnly || !$user instanceof \WP_User || self::disabled()) { return $user; } try { if (!self::enrolled((int)$user->ID)) { if (AccountPolicy::restricted((int)$user->ID) && defined('XMLRPC_REQUEST') && XMLRPC_REQUEST) { throw new \RuntimeException('Administrator second-factor enrollment is required before API sign-in. Use the WordPress Account protection page.'); } return $user; } $user = self::freshPasswordUser($user, is_string($password) ? $password : ''); if (defined('XMLRPC_REQUEST') && XMLRPC_REQUEST) { throw new \RuntimeException('XML-RPC password access is disabled for accounts protected by Shieldlume Security.'); } if (!is_ssl()) { throw new \RuntimeException('Use HTTPS to sign in to this protected account.'); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Native WordPress authenticate filter after core first-factor authentication; this additional exact credential check does not change account settings and cannot use an authenticated-user nonce before sign-in. $ticket = self::value($_POST, 'avai_ticket', 100); if ($ticket !== '') { $generation = self::consumeTicket((int)$user->ID, $ticket); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Native WordPress authenticate filter after core first-factor authentication; this additional exact credential check does not change account settings and cannot use an authenticated-user nonce before sign-in. elseif (!self::verifyFactor((int)$user->ID, self::value($_POST, 'avai_code', 64))) { throw new \RuntimeException('Enter your authenticator or unused recovery code, or choose Sign in with password + passkey.'); } $proof = self::recentProof((int)$user->ID); if ($proof['generation'] !== ($generation ?? self::$factorGeneration[(int)$user->ID] ?? -1) || !hash_equals($proof['password'], hash('sha256', $user->user_pass))) { throw new \RuntimeException('Your password or account protection changed. Sign in again.'); } self::$approvedUser = (int)$user->ID; self::$approvedProof = $proof; return $user; } catch (\Throwable $error) { return new \WP_Error('antivirus_ai_second_factor', $error->getMessage()); } } private static function freshPasswordUser(\WP_User $user, string $password): \WP_User { clean_user_cache((int)$user->ID); $fresh = get_userdata((int)$user->ID); if (!$fresh || (!hash_equals($user->user_pass, $fresh->user_pass) && !wp_check_password($password, $fresh->user_pass, (int)$user->ID))) { throw new \RuntimeException('Your password changed. Sign in again.'); } // Core may transparently upgrade an older password hash during this same login. return $fresh; } public static function applicationPasswords(bool $available, $user): bool { try { return $available && (self::disabled() || (!self::enrolled((int)$user->ID) && !AccountPolicy::restricted((int)$user->ID))); } catch (\Throwable $error) { return false; } } public static function applicationPasswordErrors($errors, $user, $item, $password): void { if (!self::applicationPasswords(true, $user)) { $errors->add('antivirus_ai_application_password', __('Application-password access is disabled for this protected account.', 'shieldlume-security')); } } public static function allowCookies(bool $send, int $expire, int $expiration, int $uid, string $scheme, string $token): bool { if ($uid === 0) { return $send; } // Cookie clearing must remain available during storage outages. try { return $send && (self::disabled() || !self::enrolled($uid) || (self::$approvedUser === $uid && self::$approvedProof === self::recentProof($uid))); } catch (\Throwable $error) { return false; } } public static function rememberAuthentication(string $cookie, int $expire, int $expiration, int $uid, string $scheme, string $token): void { try { if (self::$approvedUser === $uid && self::$approvedProof === self::recentProof($uid)) { set_transient('avai_recent_' . hash('sha256', $token), self::$approvedProof, 300); } } catch (\Throwable $error) { /* Do not grant recent proof when storage cannot be checked. */ } } private static function recent(int $uid): bool { $token = wp_get_session_token(); if ($token === '') { return false; } $stored = get_transient('avai_recent_' . hash('sha256', $token)); return is_array($stored) && $stored === self::recentProof($uid); } private static function recentProof(int $uid): array { $row = self::account($uid); clean_user_cache($uid); $user = get_userdata($uid); if (!$user) { throw new \RuntimeException('This account no longer exists.'); } return ['uid' => $uid, 'generation' => (int)$row['generation'], 'password' => hash('sha256', $user->user_pass)]; } private static function markRecent(int $uid, array $proof): void { $token = wp_get_session_token(); if ($token !== '' && $proof === self::recentProof($uid)) { set_transient('avai_recent_' . hash('sha256', $token), $proof, 300); } } private static function reauthenticate(): int { if (!is_user_logged_in() || !current_user_can('read') || !is_ssl()) { throw new \RuntimeException('Sign in over HTTPS to manage your account protection.'); } $uid = get_current_user_id(); $user = get_userdata($uid); self::lock($uid, static function (array $row) use ($uid, $user): void { $row = self::attempt($uid, $row); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Private credential verifier called only from nonce-verified account, browser or password-permission handlers; it also enforces the current actor, HTTPS origin, password and second factor. if (!wp_check_password(self::password($_POST), $user->user_pass, $uid)) { throw new \RuntimeException('Your current password is required.'); } $protected = !empty($row['totp']) || !empty($row['keys']); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Private credential verifier called only from nonce-verified account, browser or password-permission handlers; it also enforces the current actor, HTTPS origin, password and second factor. if ($protected && !self::disabled() && !self::recent($uid) && !self::factor($row, self::value($_POST, 'code', 64))) { throw new \RuntimeException('Verify with your authenticator, a recovery code, or Verify existing passkey before changing protection.'); } $row['fails'] = 0; self::save($uid, $row); self::$managementGrant[$uid] = ['generation' => (int)$row['generation'], 'password' => hash('sha256', $user->user_pass), 'session' => hash('sha256', wp_get_session_token())]; if ($protected) { self::markRecent($uid, ['uid' => $uid, 'generation' => (int)$row['generation'], 'password' => hash('sha256', $user->user_pass)]); } }); return $uid; } private static function assertManagement(int $uid, array $row): void { $grant = self::$managementGrant[$uid] ?? []; clean_user_cache($uid); $user = get_userdata($uid); if (!$user || (int)($grant['generation'] ?? -1) !== (int)$row['generation'] || !hash_equals($grant['password'] ?? '', hash('sha256', $user->user_pass)) || !hash_equals($grant['session'] ?? '', hash('sha256', wp_get_session_token()))) { throw new \RuntimeException('Your password, session or account protection changed. Reload and verify again.'); } } /** Shared explicit security-setting authority; callbacks run with a current account proof. */ public static function management(callable $operation): mixed { self::sameOrigin(); $uid = self::reauthenticate(); return self::lock($uid,static function (array $row) use ($uid,$operation): mixed { self::assertManagement($uid,$row); return $operation($uid,$row); }); } private static function reserveCeremony(int $uid, array $row): array { if ((int)($row['ceremonies_until'] ?? 0) <= time()) { $row['ceremonies_until'] = time()+900; $row['ceremonies'] = 0; } if ((int)($row['ceremonies'] ?? 0) >= 20) { throw new \RuntimeException('Too many passkey requests for this account. Wait 15 minutes and retry.'); } $row['ceremonies'] = (int)($row['ceremonies'] ?? 0)+1; return self::save($uid, $row); } /** Ordinary form fields; nonce verification stays explicit in each request handler. */ private static function value(array $values, string $key, int $max = 8192): string { if (!isset($values[$key]) || !is_string($values[$key]) || strlen($values[$key]) > $max * 2) { return ''; } $value = sanitize_text_field(wp_unslash($values[$key])); return strlen($value) <= $max && $value === wp_unslash($values[$key]) ? $value : ''; } /** Passwords are exact credentials: unslash once, bound, then verify with WordPress. */ private static function password(array $values): string { if (!isset($values['password']) || !is_string($values['password']) || strlen($values['password']) > 8192) { return ''; } $password = wp_unslash($values['password']); return strlen($password) <= 4096 ? $password : ''; } /** Signed WebAuthn data arrives as canonical base64url; never normalize signed bytes. */ private static function encoded(array $values, string $key, int $max): string { if (!isset($values[$key]) || $values[$key] === '') { return ''; } $value = self::value($values, $key, $max); if (!preg_match('/^[A-Za-z0-9_-]+$/D', $value)) { throw new \RuntimeException('Malformed passkey response.'); } return $value; } public static function origin(): string { $url = wp_parse_url(site_url()); if (($url['scheme'] ?? '') !== 'https' || empty($url['host'])) { throw new \RuntimeException('Account enrollment and passkeys require a canonical HTTPS WordPress address.'); } return 'https://' . strtolower($url['host']) . (isset($url['port']) ? ':' . (int)$url['port'] : ''); } private static function sameOrigin(): void { if (!is_ssl()) { throw new \RuntimeException('This action requires a same-origin HTTPS request. Reload this page and retry.'); } $method = isset($_SERVER['REQUEST_METHOD']) && is_string($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Compare the already sanitized method with original routing bytes only to reject transformation; no original value is stored, displayed or used as an action. if ($method !== 'POST' || $method !== ($_SERVER['REQUEST_METHOD'] ?? null)) { throw new \RuntimeException('This action requires a same-origin HTTPS request. Reload this page and retry.'); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only inspect type and byte length before explicit sanitation on the next line; no original value is retained or used as trusted content. if (!isset($_SERVER['HTTP_ORIGIN']) || !is_string($_SERVER['HTTP_ORIGIN']) || strlen($_SERVER['HTTP_ORIGIN']) > 2048) { throw new \RuntimeException('This action requires a same-origin HTTPS request. Reload this page and retry.'); } $origin = sanitize_url(wp_unslash($_SERVER['HTTP_ORIGIN']), ['https']); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only reject transformation by comparing the HTTPS-sanitized origin with its original unslashed bytes; only the sanitized value is checked against the canonical site origin. if ($origin !== wp_unslash($_SERVER['HTTP_ORIGIN'])) { throw new \RuntimeException('This action requires a same-origin HTTPS request. Reload this page and retry.'); } if (!hash_equals(self::origin(), $origin)) { throw new \RuntimeException('This action requires a same-origin HTTPS request. Reload this page and retry.'); } } private static function webauthn(): \AntivirusAI\Vendor\WebAuthn\WebAuthn { if (!extension_loaded('openssl') || !extension_loaded('mbstring')) { throw new \RuntimeException('Passkeys require the PHP OpenSSL and mbstring extensions.'); } return new \AntivirusAI\Vendor\WebAuthn\WebAuthn('Shieldlume Security · ' . wp_parse_url(site_url(), PHP_URL_HOST), wp_parse_url(self::origin(), PHP_URL_HOST), ['none']); } public static function b64(string $bytes): string { return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); } public static function unb64(string $input, int $max = 16384): string { if ($input === '' || strlen($input) > $max || !preg_match('/^[A-Za-z0-9_-]+$/D', $input)) { throw new \RuntimeException('Malformed passkey response.'); } $bytes = base64_decode(strtr($input, '-_', '+/'), true); if (!is_string($bytes) || !hash_equals(self::b64($bytes), $input)) { throw new \RuntimeException('Malformed passkey encoding.'); } return $bytes; } private static function handle(int $uid): string { $row = self::account($uid); return !empty($row['handle']) ? self::unb64($row['handle'], 100) : hash_hmac('sha256', 'webauthn-user-' . $uid, wp_salt('auth'), true); } private static function binding(): string { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Inspect credential type and byte length before explicit sanitation and exact hexadecimal validation immediately below. if (!isset($_COOKIE[self::COOKIE]) || !is_string($_COOKIE[self::COOKIE]) || strlen($_COOKIE[self::COOKIE]) !== 64) { throw new \RuntimeException('The passkey session expired. Start again.'); } $value = sanitize_text_field(wp_unslash($_COOKIE[self::COOKIE])); if ($value !== $_COOKIE[self::COOKIE] || !preg_match('/^[a-f0-9]{64}$/D', $value)) { throw new \RuntimeException('The passkey session expired. Start again.'); } return hash('sha256', $value); } private static function ceremony(string $purpose, int $uid, string $challenge, array $row, string $passwordProof = ''): string { $store = new Store(); foreach ($store->list('auth_challenge', null, 200, max(0, $store->count('auth_challenge') - 200)) as $old) { if ((int)$old['expires'] <= time()) { $store->delete($old['_id']); } } if ($store->count('auth_challenge') >= 2000) { throw new \RuntimeException('Passkey service is busy. Use an authenticator or recovery code and retry later.'); } $browser = bin2hex(random_bytes(32)); if (!setcookie(self::COOKIE, $browser, ['expires' => time()+300, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Strict'])) { throw new \RuntimeException('The browser challenge cookie could not be set.'); } $_COOKIE[self::COOKIE] = $browser; $token = bin2hex(random_bytes(32)); clean_user_cache($uid); $user = get_userdata($uid); if (!$user || ($passwordProof !== '' && !hash_equals($passwordProof, hash('sha256', $user->user_pass)))) { throw new \RuntimeException('Your password changed. Start again.'); } $store->put('auth_challenge', 'auth_c_' . substr(hash('sha256', $token), 0, 56), [ 'uid' => $uid, 'purpose' => $purpose, 'challenge' => self::b64($challenge), 'browser' => hash('sha256', $browser), 'session' => $purpose === 'login' ? '' : hash('sha256', wp_get_session_token()), 'expires' => time()+300, 'password' => hash('sha256', $user->user_pass), 'generation' => (int)$row['generation'], 'origin' => self::origin(), ], 'pending'); return $token; } private static function getCeremony(string $token): array { if (!preg_match('/^[a-f0-9]{64}$/D', $token)) { throw new \RuntimeException('The passkey challenge is invalid. Start again.'); } $row = (new Store())->get('auth_c_' . substr(hash('sha256', $token), 0, 56)); if (!$row || (int)$row['expires'] < time() || !hash_equals($row['browser'], self::binding()) || !hash_equals($row['origin'], self::origin())) { throw new \RuntimeException('The passkey challenge expired or belongs to another browser. Start again.'); } $user = get_userdata((int)$row['uid']); if (!$user || !hash_equals($row['password'], hash('sha256', $user->user_pass)) || (int)$row['generation'] !== (int)self::account((int)$row['uid'])['generation']) { throw new \RuntimeException('Account protection changed. Start again.'); } if ($row['purpose'] !== 'login' && ((int)$row['uid'] !== get_current_user_id() || !hash_equals($row['session'], hash('sha256', wp_get_session_token())))) { throw new \RuntimeException('This challenge belongs to another sign-in session.'); } return $row; } public static function checkClient(string $json, string $type, string $challenge): void { if (strlen($json) > 8192) { throw new \RuntimeException('The passkey response is too large.'); } $client = json_decode($json, true, 8, JSON_THROW_ON_ERROR); if (!is_array($client) || ($client['type'] ?? '') !== $type || ($client['origin'] ?? '') !== self::origin() || !empty($client['crossOrigin']) || isset($client['topOrigin']) || ($client['challenge'] ?? '') !== $challenge) { throw new \RuntimeException('The passkey origin, type or challenge did not match this site.'); } } private static function consumeTicket(int $uid, string $token): int { return self::lock($uid, static function (array $account) use ($uid, $token): int { $row = self::getCeremony($token); if ($row['purpose'] !== 'login' || $row['_status'] !== 'verified' || (int)$row['uid'] !== $uid || (int)$row['generation'] !== (int)$account['generation']) { throw new \RuntimeException('Passkey verification must be completed before signing in.'); } (new Store())->put('auth_challenge', $row['_id'], $row, 'used', (int)$row['_revision']); return (int)$row['generation']; }); } private static function optionsToJson(object $args): array { $key = $args->publicKey; $key->challenge = self::b64($key->challenge->getBinaryString()); if (isset($key->user)) { $key->user->id = self::b64($key->user->id->getBinaryString()); } foreach (['allowCredentials', 'excludeCredentials'] as $field) { foreach ($key->$field ?? [] as $credential) { $credential->id = self::b64($credential->id->getBinaryString()); } } return json_decode(wp_json_encode($key), true); } public static function ajax(): void { try { self::sameOrigin(); if (self::disabled()) { throw new \RuntimeException('Account security is disabled by the site owner recovery switch.'); } // Only select the nonce action here. No credential processing precedes verification. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- The exact operation selects login or account nonce immediately below. $op = isset($_POST['operation']) && is_string($_POST['operation']) ? sanitize_key(wp_unslash($_POST['operation'])) : ''; if (!in_array($op, ['login_begin', 'login_finish', 'register_begin', 'register_finish', 'reauth_begin', 'reauth_finish'], true)) { throw new \RuntimeException('Unknown account operation.'); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only reject normalization by comparing the already sanitized allowlisted selector with its original unslashed bytes. if ($op !== wp_unslash($_POST['operation'])) { throw new \RuntimeException('Unknown account operation.'); } // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only inspect nonce type and byte length before sanitation and the explicit verification immediately below; no action input or state is processed. if (!isset($_POST['nonce']) || !is_string($_POST['nonce']) || strlen($_POST['nonce']) > 40) { throw new \RuntimeException('Refresh this form and try again.'); } $nonce = sanitize_text_field(wp_unslash($_POST['nonce'])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Compare the already sanitized nonce with its original unslashed bytes only to reject transformation; only the sanitized value reaches wp_verify_nonce. if ($nonce === '' || strlen($nonce) > 20 || $nonce !== wp_unslash($_POST['nonce'])) { throw new \RuntimeException('Refresh this form and try again.'); } $nonceAction = str_starts_with($op, 'login_') ? 'antivirus_ai_login' : 'antivirus_ai_account'; if (!wp_verify_nonce($nonce, $nonceAction)) { throw new \RuntimeException('Refresh this form and try again.'); } if (!str_starts_with($op, 'login_') && (!is_user_logged_in() || !current_user_can('read'))) { throw new \RuntimeException('Sign in again.'); } $library = self::webauthn(); if (str_ends_with($op, '_begin')) { if ($op === 'login_begin') { $username = self::value($_POST, 'username', 254); // This credential-check endpoint always has local password throttling, independently of the optional general login setting. $guard = LoginLimiter::authenticate(null, $username, ''); if (is_wp_error($guard)) { throw new \RuntimeException('Too many sign-in attempts. Wait 15 minutes and retry.'); } self::$passwordOnly = true; try { $user = wp_authenticate($username, self::password($_POST)); } finally { self::$passwordOnly = false; } if (is_wp_error($user)) { if (!has_action('wp_login_failed', [LoginLimiter::class, 'failed'])) { LoginLimiter::failed($username); } throw new \RuntimeException('Sign-in details or account verification are unavailable. Check them and retry.'); } $user = self::freshPasswordUser($user, self::password($_POST)); $uid = (int)$user->ID; } elseif ($op === 'register_begin') { if (self::value($_POST, 'acknowledge', 2) !== '1') { throw new \RuntimeException('Confirm the sign-in changes before enrolling.'); } $uid = self::reauthenticate(); } else { $uid = get_current_user_id(); $user = get_userdata($uid); self::lock($uid, static function (array $row) use ($uid, $user): void { self::attempt($uid, $row); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This closure executes only after ajax() verifies its exact nonce and same HTTPS origin; the current ceremony and account proof are checked again under the account lock. if (!wp_check_password(self::password($_POST), $user->user_pass, $uid)) { throw new \RuntimeException('Your current password is required.'); } }); } $row = self::lock($uid, static function(array $row) use ($uid, $op): array { if ($op === 'register_begin') { self::assertManagement($uid, $row); } $row = self::reserveCeremony($uid, $row); return self::attempt($uid, $row); }); $ids = array_map(static fn(array $key): string => self::unb64($key['id'], 2048), array_values($row['keys'])); if ($op === 'register_begin') { if (count($ids) >= 10) { throw new \RuntimeException('Remove an unused passkey before adding another. The limit is 10.'); } $user = get_userdata($uid); $args = $library->getCreateArgs(self::handle($uid), $user->user_login, $user->display_name ?: $user->user_login, 60, 'preferred', 'required', null, $ids); $purpose = 'register'; } else { if (!$ids) { throw new \RuntimeException('No passkey is available for these sign-in details. Use your authenticator or recovery code.'); } $args = $library->getGetArgs($ids, 60, true, true, true, true, true, 'required'); $purpose = $op === 'login_begin' ? 'login' : 'reauth'; } $token = self::ceremony($purpose, $uid, $library->getChallenge()->getBinaryString(), $row, $op === 'register_begin' ? self::$managementGrant[$uid]['password'] : hash('sha256', $user->user_pass)); wp_send_json_success(['token' => $token, 'publicKey' => self::optionsToJson($args)]); } $challenge = self::getCeremony(self::value($_POST, 'token', 100)); $expectedPurpose = substr($op, 0, strpos($op, '_')); if ($challenge['_status'] !== 'pending' || $challenge['purpose'] !== $expectedPurpose) { throw new \RuntimeException('This passkey challenge has already been used or is for another action.'); } // Claim once before parsing/signature verification; failed responses require a fresh challenge. $claimed = (new Store())->put('auth_challenge', $challenge['_id'], $challenge, 'claimed', (int)$challenge['_revision']); $client = self::unb64(self::encoded($_POST, 'clientDataJSON', 12000)); self::checkClient($client, $expectedPurpose === 'register' ? 'webauthn.create' : 'webauthn.get', $challenge['challenge']); $uid = (int)$challenge['uid']; $verified = self::lock($uid, static function (array $row) use ($uid, $challenge, $library, $client, $expectedPurpose): array { if ((int)$row['generation'] !== (int)$challenge['generation']) { throw new \RuntimeException('Protection changed. Start again.'); } $row = self::attempt($uid, $row); if ($expectedPurpose === 'register') { if (count($row['keys']) >= 10) { throw new \RuntimeException('The passkey limit has been reached.'); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This closure executes only after ajax() verifies its exact nonce and same HTTPS origin; the current ceremony and account proof are checked again under the account lock. $result = $library->processCreate($client, self::unb64(self::encoded($_POST, 'attestationObject', 24000), 24000), self::unb64($challenge['challenge']), true, true); $id = self::b64($result->credentialId); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This closure executes only after ajax() verifies its exact nonce and same HTTPS origin; the current ceremony and account proof are checked again under the account lock. if (!hash_equals($id, self::encoded($_POST, 'id', 2048))) { throw new \RuntimeException('The credential identifier does not match its attestation.'); } $keyId = hash('sha256', $id); if (isset($row['keys'][$keyId])) { throw new \RuntimeException('That passkey is already registered.'); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This closure executes only after ajax() verifies its exact nonce and same HTTPS origin; the current ceremony and account proof are checked again under the account lock. $row['keys'][$keyId] = ['id' => $id, 'public' => $result->credentialPublicKey, 'counter' => (int)$result->signatureCounter, 'label' => sanitize_text_field(self::value($_POST, 'label', 80)) ?: 'Passkey', 'created' => time(), 'last_used' => 0]; $codes = empty($row['recovery']) ? self::newCodes($row) : []; $row['generation']++; } else { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This closure executes only after ajax() verifies its exact nonce and same HTTPS origin; the current ceremony and account proof are checked again under the account lock. $id = self::encoded($_POST, 'id', 2048); self::unb64($id, 2048); $keyId = hash('sha256', $id); $key = $row['keys'][$keyId] ?? null; if (!$key || !hash_equals($key['id'], $id)) { throw new \RuntimeException('That passkey is not registered to this account.'); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This closure executes only after ajax() verifies its exact nonce and same HTTPS origin; the current ceremony and account proof are checked again under the account lock. $handle = self::encoded($_POST, 'userHandle', 200); if ($handle !== '' && !hash_equals(self::handle($uid), self::unb64($handle, 200))) { throw new \RuntimeException('The passkey belongs to another account.'); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This closure executes only after ajax() verifies its exact nonce and same HTTPS origin; the current ceremony and account proof are checked again under the account lock. $library->processGet($client, self::unb64(self::encoded($_POST, 'authenticatorData', 4096), 4096), self::unb64(self::encoded($_POST, 'signature', 2048), 2048), $key['public'], self::unb64($challenge['challenge']), (int)$key['counter'], true, true); $row['keys'][$keyId]['counter'] = $library->getSignatureCounter() ?? 0; $row['keys'][$keyId]['last_used'] = time(); $codes = []; } clean_user_cache($uid); $currentUser = get_userdata($uid); if (!$currentUser || !hash_equals($challenge['password'], hash('sha256', $currentUser->user_pass))) { throw new \RuntimeException('Your password changed. Start again.'); } $row['fails'] = 0; self::save($uid, $row); AccountPolicy::invalidate(); return ['codes' => $codes, 'proof' => ['uid' => $uid, 'generation' => (int)$row['generation'], 'password' => $challenge['password']]]; }); if ($expectedPurpose === 'login') { $claimed['expires'] = time()+60; (new Store())->put('auth_challenge', $claimed['_id'], $claimed, 'verified', (int)$claimed['_revision']); } else { self::markRecent($uid, $verified['proof']); (new Store())->put('auth_challenge', $claimed['_id'], $claimed, 'used', (int)$claimed['_revision']); } wp_send_json_success(['codes' => $verified['codes'], 'message' => $expectedPurpose === 'register' ? 'Passkey added. Save any recovery codes shown below.' : 'Passkey verified.']); } catch (\Throwable $error) { wp_send_json_error(['message' => $error instanceof \AntivirusAI\Vendor\WebAuthn\WebAuthnException ? 'Passkey verification failed. Use a registered passkey with device verification and try again.' : $error->getMessage()], 400); } } public static function pageUrl(): string { return admin_url((current_user_can('edit_users') ? 'users.php' : 'profile.php') . '?page=antivirus-ai-account'); } public static function assets(): void { wp_enqueue_style('antivirus-ai-account', plugins_url('assets/account-security.css', ANTIVIRUS_AI_FILE), [], ANTIVIRUS_AI_VERSION); wp_enqueue_script('antivirus-ai-account', plugins_url('assets/account-security.js', ANTIVIRUS_AI_FILE), [], ANTIVIRUS_AI_VERSION, ['strategy'=>'defer','in_footer'=>true]); wp_localize_script('antivirus-ai-account', 'avaiAccount', ['url' => admin_url('admin-ajax.php'), 'loginNonce' => wp_create_nonce('antivirus_ai_login'), 'accountNonce' => wp_create_nonce('antivirus_ai_account')]); } public static function loginFields(): void { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- loginFieldsMarkup constructs this form fragment exclusively from literal HTML and individually escaped attributes/translated labels. echo self::loginFieldsMarkup(); } /** Return only the existing escaped form fields; never capture another component's output. */ public static function loginFieldsMarkup(): string { if (self::disabled()) { return ''; } $field = wp_unique_id('avai-code-'); return '

'; } private static function fields(string $operation): void { echo ''; wp_nonce_field('antivirus_ai_account'); } private static function passwordFields(): void { echo ''; } public static function page(): void { if (!current_user_can('read')) { wp_die(esc_html__('Access denied.', 'shieldlume-security')); } self::assets(); nocache_headers(); $uid = get_current_user_id(); try { $row = self::account($uid); } catch (\Throwable $error) { wp_die(esc_html__('Account protection storage is unavailable. Restore database access before changing protection.', 'shieldlume-security')); } $protected = !empty($row['totp']) || !empty($row['keys']); echo '

Shieldlume Security / ACCOUNT PROTECTION

One account. Stronger sign-in.

Add an authenticator or a passkey as a second factor after your WordPress password. Enrollment stays on this website. Your administrator may require it after a setup grace period.

'; if (self::disabled()) { echo '

Host recovery is active: account protection is bypassed. Remove the ANTIVIRUS_AI_DISABLE_ACCOUNT_SECURITY constant after recovery.

'; } if (!is_ssl() || !function_exists('sodium_crypto_secretbox')) { echo '

Enrollment requires HTTPS and the PHP Sodium extension. Ask your host to enable these before proceeding.

'; return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The bounded scalar reader unslashes passwords and authentication data once; callers verify nonce/origin and validate exact cryptographic values. Never text-sanitize passwords or signed bytes. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only result notice, with no stored state or permission change. $saved = isset($_GET['saved']) && is_string($_GET['saved']) ? sanitize_text_field(wp_unslash($_GET['saved'])) : ''; if ($saved === '1') { echo '

Account protection updated.

'; } echo '
' . ($protected ? 'Second factor enabled' : 'Ready to set up') . '' . count($row['keys']) . ' passkeys · ' . count($row['recovery']) . ' unused recovery codes
'; echo '
Before you enable protection

Save your recovery codes offline. Protected accounts cannot use XML-RPC password sign-in or WordPress application passwords; existing application passwords are retained but cannot authenticate while protection is enabled. Existing browser sessions remain valid. Use a separate, appropriately restricted integration account if needed. Standard WordPress sign-in, native wp_login_form forms and standard WooCommerce My Account/classic checkout login forms have second-factor controls. Theme overrides, Checkout Blocks, SSO and other custom forms need separate compatibility testing. Password and second-factor attempts are locally rate limited.

'; $postUrl = admin_url('admin-post.php'); try { self::policyPanel($row); } catch (\Throwable $error) { echo '

' . esc_html($error->getMessage()) . '

'; } echo '

Authenticator app

' . (!empty($row['totp']) ? 'Enabled. Use the six-digit code from your app when signing in.' : 'Use a local setup key with any compatible authenticator app. No secret is sent to a QR service.') . '

'; if (empty($row['totp'])) { echo '
'; self::fields('totp_begin'); self::passwordFields(); echo '
'; } else { echo '
'; self::fields('totp_remove'); self::passwordFields(); echo '
'; } echo '

Passkeys

Use your device PIN, fingerprint, face recognition or compatible security key after your password. HTTPS and a browser with WebAuthn support are required.

'; self::passwordFields(); echo '

'; foreach ($row['keys'] as $keyId => $key) { echo '
' . esc_html($key['label']) . ' · added ' . esc_html(wp_date('M j, Y', (int)$key['created'])) . '
'; self::fields('key_remove'); echo ''; self::passwordFields(); echo '
'; } echo '

Recovery codes

Each code works once in place of your second factor. Regenerating codes immediately invalidates the previous set. They cannot be displayed again after you leave the result page.

'; if ($protected) { echo '
'; self::fields('recovery'); self::passwordFields(); echo '
'; } else { echo '

Codes are created when you enable your first authenticator or passkey.

'; } echo '

Recovery without your devices

Use a saved recovery code. If all factors are lost, a trusted hosting administrator can temporarily set ANTIVIRUS_AI_DISABLE_ACCOUNT_SECURITY to true in wp-config.php, reset your protection from this screen, and immediately remove the switch. Changing WordPress security salts can make an authenticator secret unreadable; a saved recovery code or registered passkey remains the recovery route. Anyone with host or database control is outside this protection boundary. Password resets alone do not remove second-factor enrollment.

'; self::fields('disable'); self::passwordFields(); echo '
'; self::sessionPanel(); PasswordRisk::render(); try { DeviceTrust::render(); } catch (\Throwable $error) { echo '

' . esc_html($error->getMessage()) . '

'; } echo ''; } public static function manage(): void { try { if (!is_user_logged_in() || !current_user_can('read')) { throw new \RuntimeException('Sign in to manage your account protection.'); } self::sameOrigin(); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only inspect nonce type and byte length before sanitation and the explicit verification immediately below; no action input or state is processed. if (!isset($_POST['_wpnonce']) || !is_string($_POST['_wpnonce']) || strlen($_POST['_wpnonce']) > 40) { throw new \RuntimeException('Refresh this form and try again.'); } $nonce = sanitize_text_field(wp_unslash($_POST['_wpnonce'])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Compare the already sanitized nonce with its original unslashed bytes only to reject transformation; only the sanitized value reaches wp_verify_nonce. if ($nonce === '' || strlen($nonce) > 20 || $nonce !== wp_unslash($_POST['_wpnonce'])) { throw new \RuntimeException('Refresh this form and try again.'); } if (!wp_verify_nonce($nonce, 'antivirus_ai_account')) { throw new \RuntimeException('Refresh this form and try again.'); } $uid = self::reauthenticate(); $op = self::value($_POST, 'operation', 30); $codes = []; $completedProof = []; if ($op === 'policy' || $op === 'sessions_revoke') { self::lock($uid, static function (array $row) use ($uid, $op): void { self::assertManagement($uid, $row); if ($op === 'policy') { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This closure executes only after manage() verifies its fixed POST nonce and reauthenticates the current actor; assertManagement rechecks the password, session and generation under the account lock. $mode = self::value($_POST, 'policy_mode', 10); $days = self::value($_POST, 'grace_days', 3); $revision = self::value($_POST, 'policy_revision', 12); if (!in_array($mode, ['off', 'required'], true) || !preg_match('/^[0-9]{1,2}$/D', $days) || !preg_match('/^[0-9]{1,10}$/D', $revision)) { throw new \RuntimeException('The policy form is incomplete. Reload before saving.'); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This closure executes only after manage() verifies its fixed POST nonce and reauthenticates the current actor; assertManagement rechecks the password, session and generation under the account lock. AccountPolicy::save($uid, $mode === 'required', (int)$days, (int)$revision, self::value($_POST, 'acknowledge', 2) === '1', $row); } else { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This closure executes only after manage() verifies its fixed POST nonce and reauthenticates the current actor; assertManagement rechecks the password, session and generation under the account lock. if (self::value($_POST, 'acknowledge', 2) !== '1') { throw new \RuntimeException('Confirm that you want to sign out your other sessions.'); } self::revokeOtherSessions($uid); } }); wp_safe_redirect(self::pageUrl() . '&saved=1'); exit; } if ($op === 'totp_begin') { if (self::value($_POST, 'acknowledge', 2) !== '1') { throw new \RuntimeException('Confirm the sign-in changes before enrolling.'); } $secret = self::totp()->createSecret(160); $pending = ['secret' => self::seal($secret), 'expires' => time()+600, 'session' => hash('sha256', wp_get_session_token()), 'generation' => self::account($uid)['generation']]; set_transient('avai_totp_pending_' . $uid, $pending, 600); wp_enqueue_script('antivirus-ai-qr', plugins_url('assets/auth-qrcode.js', ANTIVIRUS_AI_FILE), [], ANTIVIRUS_AI_VERSION, true); self::resultHeader('Connect your authenticator'); $uri = self::totp()->getQRText(wp_get_current_user()->user_login . '@' . wp_parse_url(site_url(), PHP_URL_HOST), $secret); echo '
'; echo '

Scan the QR code in your authenticator app, or add a time-based account with this setup key. Choose six digits and a 30-second period. Keep this key private.

Account: ' . esc_html(wp_get_current_user()->user_login . '@' . wp_parse_url(site_url(), PHP_URL_HOST)) . '

' . esc_html($secret) . '

Enter the code from your app to finish. Until then, your current protection is unchanged.

'; self::fields('totp_finish'); echo ''; self::passwordFields(); echo '
'; self::resultFooter(); } self::lock($uid, static function (array $row) use ($uid, $op, &$codes, &$completedProof): void { self::assertManagement($uid, $row); if ($op === 'totp_finish') { if (!empty($row['totp'])) { throw new \RuntimeException('Remove the existing authenticator before setting up another.'); } $pending = get_transient('avai_totp_pending_' . $uid); if (!$pending || (int)$pending['expires'] < time() || !hash_equals($pending['session'], hash('sha256', wp_get_session_token())) || (int)$pending['generation'] !== (int)$row['generation']) { throw new \RuntimeException('Authenticator setup expired. Start again.'); } $step = 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This closure executes only after manage() verifies its fixed POST nonce and reauthenticates the current actor; assertManagement rechecks the password, session and generation under the account lock. if (!self::totp()->verifyCode(self::open($pending['secret']), self::value($_POST, 'new_code', 6), 1, null, $step)) { throw new \RuntimeException('The new authenticator code did not match. Retry with the current code.'); } $row['totp'] = $pending['secret']; $row['last_step'] = $step; if (empty($row['recovery'])) { $codes = self::newCodes($row); } delete_transient('avai_totp_pending_' . $uid); } elseif ($op === 'totp_remove') { $row['totp'] = ''; $row['last_step'] = 0; } elseif ($op === 'key_remove') { unset($row['keys'][self::value($_POST, 'key', 64)]); } elseif ($op === 'recovery') { if (empty($row['totp']) && empty($row['keys'])) { throw new \RuntimeException('Enable a second factor before generating recovery codes.'); } $codes = self::newCodes($row); } elseif ($op === 'disable') { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This closure executes only after manage() verifies its fixed POST nonce and reauthenticates the current actor; assertManagement rechecks the password, session and generation under the account lock. if (self::value($_POST, 'acknowledge', 2) !== '1') { throw new \RuntimeException('Confirm that you want to remove protection.'); } $row['totp'] = ''; $row['last_step'] = 0; $row['keys'] = []; $row['recovery'] = []; } else { throw new \RuntimeException('Unknown account action.'); } if (empty($row['totp']) && empty($row['keys'])) { $row['recovery'] = []; } $row['generation']++; AccountPolicy::saveFactors($uid, $row, static fn() => self::save($uid, $row)); $completedProof = ['uid' => $uid, 'generation' => (int)$row['generation'], 'password' => self::$managementGrant[$uid]['password']]; }); self::markRecent($uid, $completedProof); if ($codes) { self::resultHeader('Save your recovery codes'); echo '

Store these codes somewhere private outside this website. Each works once after your WordPress password. These codes will not be shown again.

' . esc_html(implode("\n", $codes)) . '
'; self::resultFooter(); } wp_safe_redirect(self::pageUrl() . '&saved=1'); exit; } catch (\Throwable $error) { wp_die(esc_html($error->getMessage()), esc_html__('Account protection', 'shieldlume-security'), ['response' => 400, 'back_link' => true]); } } private static function policyPanel(array $account): void { $policy = AccountPolicy::settings(); $state = AccountPolicy::status(get_current_user_id()); echo '

Administrator protection policy

'; if ($state['host_recovery']) { echo '

Host recovery is active. Enrollment enforcement is temporarily bypassed.

'; } elseif (!$policy['enabled']) { echo '

Optional enrollment. Site-wide administrator enforcement is off.

'; } elseif ($state['enrolled']) { echo '

Your account meets the policy. Your existing sessions remain valid.

'; } elseif ($state['targeted']) { echo '

' . ($state['restricted'] ? 'Finish setup to restore administrator actions.' : 'Set up your second factor by ' . esc_html(wp_date('M j, Y H:i T', $state['deadline'])) . '.') . ' Your account can still open this page and complete enrollment.

'; } else { echo '

This policy applies to administrators and roles with site-settings access. Your account is outside its scope.

'; } if (current_user_can('manage_options')) { echo '

Require an authenticator or password-plus-passkey for administrator accounts. After the grace period, an account without a second factor can sign in to finish setup, but cannot administer content, settings, users or plugins, use the REST API or other administrative AJAX actions. Existing sessions are retained. Enrolled accounts already require their second factor at new sign-in.

Enroll and verify your own account first, save recovery codes outside this site and check host recovery access. New administrator accounts and role-based promotions receive the selected grace period; changing its length updates existing deadlines without restarting the clock. Direct custom-capability changes use the existing policy deadline. Custom sign-in and integration plugins need compatibility testing.

'; self::fields('policy'); echo ''; self::passwordFields(); echo '
'; } echo '
'; } /** Reads only this account's existing WordPress session records. Never returns tokens or verifiers. */ public static function sessions(int $uid): array { if ($uid !== get_current_user_id() || !is_user_logged_in()) { throw new \RuntimeException('Only your own sessions are available here.'); } $manager = \WP_Session_Tokens::get_instance($uid); $token = wp_get_session_token(); $current = $token !== '' ? $manager->get($token) : false; $items = []; $matched = false; foreach ($manager->get_all() as $entry) { if (!is_array($entry)) { continue; } // Exact record equality is only a display hint: records can have equal timestamps/device data. $isCurrent = !$matched && $current && $entry === $current; if ($isCurrent) { $matched = true; } $items[] = ['login' => max(0, (int)($entry['login'] ?? 0)), 'expiration' => max(0, (int)($entry['expiration'] ?? 0)), 'device' => wp_html_excerpt(sanitize_text_field((string)($entry['ua'] ?? 'Browser details unavailable')), 240), 'current' => (bool)$isCurrent]; } usort($items, static fn(array $a, array $b): int => $b['login'] <=> $a['login']); return ['items' => array_slice($items, 0, 50), 'total' => count($items), 'current_valid' => $token !== '' && $manager->verify($token)]; } /** Caller holds a current account-management grant. Revokes only this user's other sessions. */ private static function revokeOtherSessions(int $uid): void { self::assertManagement($uid, self::account($uid)); $manager = \WP_Session_Tokens::get_instance($uid); $token = wp_get_session_token(); if ($token === '' || !$manager->verify($token)) { throw new \RuntimeException('Your current browser session expired. Sign in again before revoking other sessions.'); } $manager->destroy_others($token); if (!$manager->verify($token) || count($manager->get_all()) !== 1) { throw new \RuntimeException('Session revocation could not be confirmed. Reload and review your sessions.'); } AccountPolicy::audit('other_sessions_revoked', ['account_id' => $uid]); } private static function sessionPanel(): void { $sessions = self::sessions(get_current_user_id()); echo '

Your signed-in sessions

' . (int)$sessions['total'] . ' active WordPress browser sessions. This view uses the session records WordPress already stores; it does not contact a device or location service. Device descriptions can be inaccurate. Matching browser details are only a display hint.

'; if ($sessions['total'] > 50) { echo '

The newest 50 sessions are shown. Signing out others applies to all of your other browser sessions.

'; } if ($sessions['current_valid'] && $sessions['total'] > 1) { echo '
'; self::fields('sessions_revoke'); self::passwordFields(); echo '
'; } echo '

This action does not reset your password, remove factors, revoke another user, or change application passwords. A person who still has valid sign-in credentials can sign in again.

'; } private static function resultHeader(string $title): void { nocache_headers(); header('Referrer-Policy: same-origin'); header('X-Frame-Options: DENY'); self::assets(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- WordPress core get_language_attributes returns an HTML attribute fragment, not a value to escape as text. echo '' . esc_html($title) . ''; wp_print_styles('antivirus-ai-account'); // Match native admin navigation on WordPress versions that opt into // cross-document transitions, including its reduced-motion preference. if (wp_style_is('wp-view-transitions-admin', 'registered')) { wp_print_styles('wp-view-transitions-admin'); } echo '

' . esc_html($title) . '

'; } private static function resultFooter(): never { echo '

Return to account protection

'; wp_print_scripts(['antivirus-ai-qr', 'antivirus-ai-account']); echo ''; exit; } public static function deleteAccount(int $uid): void { try { (new Store())->delete(self::id($uid)); delete_transient('avai_totp_pending_' . $uid); } catch (\Throwable $error) { /* Account deletion never makes a removed user authenticatable. */ } } }