false,'generation'=>'']; } private static function locked(callable $operation): mixed { $store = new Store(); $token = $store->lock('password_risk_permission',20); if (!$token) { throw new \RuntimeException('Password-check settings are busy. Reload and retry.'); } try { return $operation(); } finally { $store->unlock('password_risk_permission',$token); } } /** Must be called from the verified administrator management callback. No network request. */ public static function choose(bool $enabled): void { if (!current_user_can('manage_options')) { throw new \RuntimeException('Administrator access is required.'); } self::locked(static function () use ($enabled): void { Store::saveOption(self::OPTION,['enabled'=>$enabled,'generation'=>bin2hex(random_bytes(16)),'changed'=>time()]); self::$request = []; }); if (!$enabled) { self::cleanup(); } AccountPolicy::audit('password_risk_permission_changed',['enabled'=>$enabled]); } /** Expired fingerprints are removed in bounded batches; paused cron cannot extend reuse. */ public static function cleanup(): void { try { self::locked(static function (): void { global $wpdb; $enabled = self::permission()['enabled']; if ($enabled) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Bounded prepared cleanup of expired private fingerprints. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- This is bounded deletion of expired or consent-withdrawn fingerprint records, not a cacheable read. $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}avai_objects WHERE kind='password_risk_cache' AND (JSON_EXTRACT(body,'$.expires') IS NULL OR CAST(JSON_UNQUOTE(JSON_EXTRACT(body,'$.expires')) AS UNSIGNED)<=%d) LIMIT 200",time())); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Consent withdrawal removes a bounded batch of private fingerprints. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- This is bounded deletion of expired or consent-withdrawn fingerprint records, not a cacheable read. $wpdb->query("DELETE FROM {$wpdb->prefix}avai_objects WHERE kind='password_risk_cache' LIMIT 200"); } }); } catch (\Throwable $error) { /* Cached proofs expire even if cleanup cannot run. */ } } public static function stop(): void { wp_clear_scheduled_hook('antivirus_ai_password_risk_cleanup'); } public static function parse(array $response,string $suffix): string { if (!preg_match('/^[A-F0-9]{35}$/D',$suffix) || ($response['status'] ?? 0) !== 200 || !empty($response['limited']) || !is_string($response['body'] ?? null)) { return 'unavailable'; } $body = $response['body']; if (strlen($body) > 1048576 || strlen($body) < 38) { return 'unavailable'; } $lines = explode("\n",rtrim($body,"\r\n")); if (count($lines) > 15000) { return 'unavailable'; } $seen = []; $matched = false; foreach ($lines as $line) { $line = rtrim($line,"\r"); if (!preg_match('/^([A-F0-9]{35}):([0-9]{1,15})$/D',$line,$m) || isset($seen[$m[1]])) { return 'unavailable'; } $seen[$m[1]] = true; if (hash_equals($suffix,$m[1]) && (int)$m[2] > 0) { $matched = true; } } return $matched ? 'breached' : 'not_listed'; } public static function assess(int $uid,string $password): string { if ($uid < 1 || $password === '' || strlen($password) > 4096 || AccountPolicy::recovery()) { return 'disabled'; } try { $permission = self::permission(); if (!$permission['enabled']) { return 'disabled'; } $fingerprint = hash_hmac('sha256',$uid . "\0" . $password,wp_salt('secure_auth')); $requestKey = $permission['generation'] . $fingerprint; if (isset(self::$request[$requestKey])) { return self::$request[$requestKey]; } $store = new Store(); $id = 'password_risk_user_' . $uid; $old = $store->get($id); if ($old && ($old['generation'] ?? '') === $permission['generation'] && hash_equals($old['fingerprint'] ?? '',$fingerprint) && (int)($old['expires'] ?? 0) > time() && in_array($old['result'] ?? '',['breached','not_listed','unavailable'],true)) { return self::$request[$requestKey] = $old['result']; } $reserved = self::locked(static function () use ($store,$id,$permission): bool { $fresh = self::permission(); if (!$fresh['enabled'] || $fresh['generation'] !== $permission['generation']) { return false; } $rate = $store->get('password_risk_budget') ?: ['start'=>time(),'count'=>0,'users'=>[]]; if (time() - (int)$rate['start'] >= 300) { $rate['start'] = time(); $rate['count'] = 0; $rate['users'] = []; } if ((int)$rate['count'] >= 20 || (int)($rate['users'][$id] ?? 0) > time()-30) { return false; } $rate['count']++; $rate['users'][$id] = time(); $store->put('password_risk','password_risk_budget',$rate,'active',$rate['_revision'] ?? null); return true; }); if (!$reserved) { return self::$request[$requestKey] = 'unavailable'; } // The only password-derived outbound value is this five-character range prefix. $hash = strtoupper(sha1($password)); try { $response = PinnedHttp::get('https://api.pwnedpasswords.com/range/' . substr($hash,0,5),1048576,['Add-Padding'=>'true','Accept'=>'text/plain']); $result = self::parse($response,substr($hash,5)); } catch (\Throwable $error) { $result = 'unavailable'; } unset($hash,$response); return self::locked(static function () use ($store,$id,$permission,$fingerprint,$result,$requestKey): string { $fresh = self::permission(); if (!$fresh['enabled'] || $fresh['generation'] !== $permission['generation']) { return 'disabled'; } $old = $store->get($id); $store->put('password_risk_cache',$id,['generation'=>$fresh['generation'],'fingerprint'=>$fingerprint,'result'=>$result,'checked'=>time(),'expires'=>time()+($result === 'unavailable' ? 60 : 86400)],'active',$old['_revision'] ?? null); Store::saveOption('antivirus_ai_password_risk_status',['result'=>$result,'checked'=>time()]); return self::$request[$requestKey] = $result; }); } catch (\Throwable $error) { return 'unavailable'; } } public static function authenticate($user,$username,$password) { if (!$user instanceof \WP_User || !is_string($password) || $password === '' || !AccountPolicy::targeted((int)$user->ID) || !wp_check_password($password,$user->user_pass,(int)$user->ID)) { return $user; } if (self::assess((int)$user->ID,$password) !== 'breached') { return $user; } return new \WP_Error('antivirus_ai_breached_password',__('This password appears in known breached-password data. Reset your password using the sign-in page before continuing. Your second factor remains enabled.','shieldlume-security')); } public static function reset($errors,$user): void { if (!$user instanceof \WP_User || !AccountPolicy::targeted((int)$user->ID)) { return; } // Match the exact already-trimmed value wp-login.php passes to reset_password. // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- WordPress validates the password reset key before this validation hook and has already trimmed pass1. Inspect the exact password core will install; do not sanitize or unslash it again. $password = isset($_POST['pass1']) && is_string($_POST['pass1']) ? $_POST['pass1'] : ''; if (self::assess((int)$user->ID,$password) === 'breached') { $errors->add('antivirus_ai_breached_password',__('Choose a different password: this one appears in known breached-password data.','shieldlume-security')); } } public static function profile($errors,bool $update,$user): void { $uid = (int)($user->ID ?? 0); if (!$update || $uid < 1 || !AccountPolicy::targeted($uid) || !is_string($user->user_pass ?? null)) { return; } if (self::assess($uid,$user->user_pass) === 'breached') { $errors->add('antivirus_ai_breached_password',__('Choose a different password: this one appears in known breached-password data.','shieldlume-security')); } } public static function deleted(int $uid): void { try { (new Store())->delete('password_risk_user_' . $uid); } catch (\Throwable $error) {} } public static function handle(): void { if (!current_user_can('manage_options')) { wp_die('Administrator access required.','',['response'=>403]); } $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)) { wp_die('Administrator POST access required.','',['response'=>403]); } // 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) { wp_die('Refresh this form and try again.', '', ['response' => 403]); } $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'])) { wp_die('Refresh this form and try again.', '', ['response' => 403]); } if (!wp_verify_nonce($nonce, 'antivirus_ai_password_risk')) { wp_die('Refresh this form and try again.', '', ['response' => 403]); } try { $operation = isset($_POST['operation']) && is_string($_POST['operation']) ? sanitize_key(wp_unslash($_POST['operation'])) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only reject normalization by comparing the already sanitized allowlisted selector with its original unslashed bytes. if (!in_array($operation,['enable','disable'],true) || $operation !== wp_unslash($_POST['operation'])) { throw new \RuntimeException('Choose a password-check action.'); } $consent = isset($_POST['consent']) && is_string($_POST['consent']) ? sanitize_text_field(wp_unslash($_POST['consent'])) : ''; $complete = isset($_POST['complete']) && is_string($_POST['complete']) ? sanitize_text_field(wp_unslash($_POST['complete'])) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only compare already sanitized consent flags with original unslashed bytes to reject manufactured permission; the exact nonce and administrator capability were verified above. if ($operation === 'enable' && ($consent !== '1' || $complete !== '1' || $consent !== wp_unslash($_POST['consent']) || $complete !== wp_unslash($_POST['complete']))) { throw new \RuntimeException('Review the provider disclosure and confirm permission.'); } AccountSecurity::management(static function () use ($operation): void { self::choose($operation === 'enable'); }); wp_safe_redirect(AccountSecurity::pageUrl() . '&saved=1#avai-password-risk'); exit; } catch (\Throwable $error) { wp_die(esc_html($error->getMessage()),'',['response'=>400,'back_link'=>true]); } } public static function render(): void { if (!current_user_can('manage_options')) { return; } $state = self::permission(); $status = get_option('antivirus_ai_password_risk_status',[]); echo '

Known breached passwords

Optional checks for administrator accounts when a correct password is used to sign in, reset or update a password. A known breached password is rejected; reset it using WordPress. Existing sessions and second factors are retained.

' . ($state['enabled'] ? 'Enabled' : 'Off') . ' · ' . esc_html(isset($status['checked']) ? 'Last check: ' . str_replace('_',' ',(string)$status['result']) . ' · ' . wp_date('M j H:i T',(int)$status['checked']) : 'No check recorded') . '

This uses Have I Been Pwned Pwned Passwords. The service at api.pwnedpasswords.com receives five characters of a SHA-1 password hash, your server IP and request timing. It never receives the password, complete hash, username or email from this plugin. Matching happens locally; padded responses are discarded. A keyed local fingerprint and result are cached for up to 24 hours. Provider privacy.

Provider failures and rate limits do not block sign-in or password changes. “Not listed” is not a guarantee that a password is safe. Application passwords, SSO and direct database changes are outside this check. Host recovery bypasses it.

'; wp_nonce_field('antivirus_ai_password_risk'); if (!$state['enabled']) { echo ''; } echo '

Stopping takes effect for future checks and rejects in-flight results. Expired fingerprints are deleted in hourly batches, and stopping requests immediate batch cleanup. Missed WordPress cron or database failures can delay deletion; expired results are never reused. Ordinary uninstall retains remaining records. A separate authorized retention review is needed for permanent removal.

'; } }