roles ) ) { return false; } return (bool) array_intersect( (array) $user->roles, $required ); } // ── The breached-password check ────────────────────────────────────── /** * The SHA-1 of a password split as the range API wants it. * * @param string $password Plain password. * @return array{0:string,1:string} [ 5-char prefix, 35-char suffix ], upper case. */ public static function hash_parts( string $password ): array { $hash = strtoupper( sha1( $password ) ); return array( substr( $hash, 0, 5 ), substr( $hash, 5 ) ); } /** * How many times a suffix appears in a range body, 0 when it does not. * * A padding line (Add-Padding: true) carries a count of 0 and is a suffix nobody has ever * used, so a match on one is reported as 0, not breached. * * @param string $body Range response, "<35 hex>:" per line. * @param string $suffix The 35-character suffix to find. * @return int */ public static function range_count( string $body, string $suffix ): int { $suffix = strtoupper( $suffix ); foreach ( preg_split( '/\r?\n/', $body ) as $line ) { $parts = explode( ':', trim( $line ), 2 ); if ( 2 === count( $parts ) && strtoupper( $parts[0] ) === $suffix ) { return max( 0, (int) $parts[1] ); } } return 0; } /** * Times this password appears in known breaches: a count, 0 for none, null when the * check could not be completed (fail open). * * @param string $password Plain password. Never sent anywhere; only five hash characters are. * @return int|null */ public static function breach_count( string $password ): ?int { if ( '' === $password ) { return 0; } list( $prefix, $suffix ) = self::hash_parts( $password ); $resp = wp_remote_get( self::ENDPOINT . $prefix, array( 'timeout' => self::TIMEOUT, 'redirection' => 0, 'user-agent' => defined( 'CSDT_DevTools::USER_AGENT' ) ? CSDT_DevTools::USER_AGENT : 'CloudScale-Cyber-DevTools', 'headers' => array( 'Accept' => 'text/plain' ), ) ); $r = CSDT_Http::json( $resp ); if ( 200 !== $r['code'] || '' === trim( $r['body'] ) ) { self::log( 'Breached-password check skipped, the lookup failed (' . CSDT_Http::describe( $r ) . '). The password was accepted.' ); return null; } // A range is lines of "<35 hex>:". Anything else (an error page from something in // front of the proxy) is not evidence either way. if ( 1 !== preg_match( '/^[0-9A-Fa-f]{35}:\d+/', ltrim( $r['body'] ) ) ) { self::log( 'Breached-password check skipped, the answer was not a hash range. The password was accepted.' ); return null; } return self::range_count( $r['body'], $suffix ); } // ── Evaluation ─────────────────────────────────────────────────────── /** * Every policy problem with a proposed password. * * @param string $password Plain password. * @return array */ public static function problems( string $password ): array { $out = array(); $min = self::min_length(); if ( $min > 0 && self::length( $password ) < $min ) { $out[] = array( 'code' => 'csdt_password_too_short', /* translators: %d: minimum number of characters */ 'message' => sprintf( __( 'Passwords on this site must be at least %d characters long.', 'cloudscale-cyber-devtools' ), $min ), 'block' => true, ); } $mode = self::pwned_mode(); if ( 'off' !== $mode ) { $count = self::breach_count( $password ); if ( null !== $count && $count > 0 ) { $out[] = array( 'code' => 'csdt_pwned_password', 'message' => __( 'This password has appeared in a known data breach, so attackers try it first. Choose a different one.', 'cloudscale-cyber-devtools' ), 'block' => 'block' === $mode, ); } } return $out; } /** * Applies the policy to a password being set, adding blocking problems to $errors. * * @param WP_Error $errors Errors to add to. * @param string $password Proposed password. * @param int $user_id Account the password is for, 0 when not yet created. * @return void */ public static function apply( $errors, string $password, int $user_id ): void { try { $breached = false; foreach ( self::problems( $password ) as $p ) { if ( 'csdt_pwned_password' === $p['code'] ) { $breached = true; } if ( $p['block'] ) { $errors->add( $p['code'], '' . esc_html__( 'Error:', 'cloudscale-cyber-devtools' ) . ' ' . esc_html( $p['message'] ) ); } } if ( $breached ) { self::note_breached( $user_id, 'block' === self::pwned_mode() ); } elseif ( $user_id > 0 ) { // A clean password replaces whatever was flagged before. delete_user_meta( $user_id, self::META_WARN ); } } catch ( \Throwable $e ) { // Fail open: a fault in this check must never stop a password change. if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } /** * Records a breached password: an event always, and in warn mode a flag the next admin * page shows to that user. * * @param int $user_id Account, 0 when not yet created. * @param bool $blocked Whether the password was refused. * @return void */ private static function note_breached( int $user_id, bool $blocked ): void { if ( ! $blocked && $user_id > 0 ) { update_user_meta( $user_id, self::META_WARN, (string) time() ); } if ( is_callable( array( 'CSDT_Login', 'record_security_event' ) ) ) { $who = $user_id > 0 ? 'user #' . $user_id : 'a new account'; CSDT_Login::record_security_event( 'pwned_password', $blocked ? sprintf( 'Refused a breached password for %s', $who ) : sprintf( 'A breached password was set for %s (warn mode)', $who ), 'Matched the breached-password range. The password itself is never stored or sent.' ); } } // ── Hooks ──────────────────────────────────────────────────────────── /** * user_profile_update_errors: profile.php, user-edit.php and Add New User. * * @param WP_Error $errors Errors. * @param bool $update Whether an existing user is being updated. * @param object $user User data; user_pass is set only when a password was entered. * @return void */ public static function on_profile_errors( $errors, $update = false, $user = null ): void { if ( ! is_object( $user ) || empty( $user->user_pass ) || ! is_string( $user->user_pass ) ) { return; } if ( is_object( $errors ) && method_exists( $errors, 'has_errors' ) && $errors->has_errors() ) { return; } self::apply( $errors, $user->user_pass, (int) ( $user->ID ?? 0 ) ); } /** * validate_password_reset: the wp-login.php reset form. * * Also fires when the form is first SHOWN, with nothing posted, so it acts only on a POST * carrying the new password. * * @param WP_Error $errors Errors. * @param WP_User|object $user The account being reset. * @return void */ public static function on_reset_errors( $errors, $user = null ): void { $password = self::posted_password(); if ( null === $password ) { self::refuse_unproven( $errors ); return; } if ( '' === $password || ! is_object( $errors ) ) { return; } if ( method_exists( $errors, 'has_errors' ) && $errors->has_errors() ) { return; } self::apply( $errors, $password, is_object( $user ) && isset( $user->ID ) ? (int) $user->ID : 0 ); } /** * registration_errors: core's form has no password field, but a form that adds one * (pass1) is checked. * * @param WP_Error $errors Errors. * @param string $login Sanitised login. * @param string $email Email. * @return WP_Error */ public static function on_registration_errors( $errors, $login = '', $email = '' ) { $password = self::posted_password(); if ( null === $password ) { self::refuse_unproven( $errors ); return $errors; } if ( '' !== $password && is_object( $errors ) && ! ( method_exists( $errors, 'has_errors' ) && $errors->has_errors() ) ) { self::apply( $errors, $password, 0 ); } return $errors; } /** * Prints this class's nonce into core's password reset and registration forms. * * Core puts no nonce on either form, so posted_password() has nothing of core's to * verify. Neither page is cached (wp-login.php sends no-cache headers), so the nonce * does not go stale the way one on a public page would. */ public static function render_form_nonce(): void { wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD, false ); } /** * Refuses a new password that arrived without this class's form nonce. * * Without this, leaving the nonce out of the request would skip the policy. * * @param WP_Error|mixed $errors Errors. */ private static function refuse_unproven( $errors ): void { if ( is_object( $errors ) && method_exists( $errors, 'add' ) ) { $errors->add( 'csdt_pw_form_expired', __( 'This form has expired. Reload the page and enter your new password again.', 'cloudscale-cyber-devtools' ) ); } } /** * The posted new password, byte-exact; '' when none was posted; null when one was * posted without the nonce render_form_nonce() prints into the form. * * Validated rather than stripped: sanitize_text_field() would trim and remove characters * from a password, which then fails to match what the user typed. * * @return string|null */ private static function posted_password(): ?string { $nonce = isset( $_POST[ self::NONCE_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::NONCE_FIELD ] ) ) : ''; if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) { return isset( $_POST['pass1'] ) ? null : ''; } // A password posted as an array was not typed into the form. // wporg-risk-accepted: WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- A password must reach the breach check exactly as typed: sanitize_text_field() strips tags, octets and whitespace and would test a different password. WordPress core's own login and password-setting code takes it unsanitised for the same reason. The nonce is verified first, the value is type checked and passed through wp_check_invalid_utf8(), and only a 5-character prefix of its SHA-1 hash leaves the site. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- a password must stay byte for byte what was typed: it is type checked and passed through wp_check_invalid_utf8(), never stripped return isset( $_POST['pass1'] ) && ! is_array( $_POST['pass1'] ) ? (string) wp_check_invalid_utf8( wp_unslash( $_POST['pass1'] ) ) : ''; } /** Shows the warn-mode flag to the user it is about, on any admin page. */ public static function breached_notice(): void { $uid = get_current_user_id(); if ( ! $uid || '' === (string) get_user_meta( $uid, self::META_WARN, true ) ) { return; } echo '

' . esc_html__( 'Your password has appeared in a known data breach.', 'cloudscale-cyber-devtools' ) . ' ' . esc_html__( 'Attackers try these first. Choose a new one on your profile page.', 'cloudscale-cyber-devtools' ) . ' ' . esc_html__( 'Change password', 'cloudscale-cyber-devtools' ) . '

'; } /** * Length in characters, not bytes. * * @param string $s String. * @return int */ private static function length( string $s ): int { return function_exists( 'mb_strlen' ) ? mb_strlen( $s, 'UTF-8' ) : strlen( $s ); } /** * One line to the plugin log. * * @param string $line Message. Never carries the password or its hash. * @return void */ private static function log( string $line ): void { if ( is_callable( array( 'CSDT_DevTools', 'log' ) ) ) { CSDT_DevTools::log( $line ); } } // ── Admin settings ─────────────────────────────────────────────────── /** Saves the three settings. Registered with refuse_test_session. */ public static function ajax_save(): void { check_ajax_referer( CSDT_DevTools::LOGIN_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } // Same order as CSDT_Login::ajax_login_save(): ask for the passkey before writing, // so the page can run the ceremony and retry. The pre_update_option gate still vetoes. $blocker = CSDT_Login::stepup_blocker( get_current_user_id() ); if ( 'stepup_required' === $blocker || 'non_browser' === $blocker ) { wp_send_json_error( array( 'code' => $blocker, 'message' => 'stepup_required' === $blocker ? __( 'Verify with your passkey to change login security settings.', 'cloudscale-cyber-devtools' ) : __( 'Login security settings can only be changed from a browser.', 'cloudscale-cyber-devtools' ), ), 403 ); } $mode = sanitize_key( wp_unslash( $_POST['pwned_mode'] ?? '' ) ); if ( ! in_array( $mode, array( 'off', 'warn', 'block' ), true ) ) { wp_send_json_error( __( 'Unknown breached-password mode.', 'cloudscale-cyber-devtools' ) ); } $min = absint( sanitize_text_field( wp_unslash( $_POST['min_length'] ?? '0' ) ) ); if ( $min > self::MAX_MIN_LENGTH ) { /* translators: %d: largest accepted value */ wp_send_json_error( sprintf( __( 'Minimum length must be between 0 and %d.', 'cloudscale-cyber-devtools' ), self::MAX_MIN_LENGTH ) ); } $raw_roles = isset( $_POST['roles'] ) ? sanitize_text_field( wp_unslash( $_POST['roles'] ) ) : ''; $roles = self::sanitize_roles( '' === $raw_roles ? array() : explode( ',', $raw_roles ) ); update_option( self::OPTION_PWNED, $mode ); update_option( self::OPTION_MIN_LENGTH, (string) $min ); update_option( self::OPTION_REQUIRED_ROLES, $roles ); // Read back, so a write the step-up gate vetoed is reported, not claimed. $saved = self::pwned_mode() === $mode && self::min_length() === $min && self::required_roles() === $roles; if ( ! $saved ) { wp_send_json_error( __( 'Not saved: the change was refused by the login security gate. Reload and try again.', 'cloudscale-cyber-devtools' ) ); } wp_send_json_success( array( 'msg' => __( 'Saved.', 'cloudscale-cyber-devtools' ) ) ); } /** * Role slugs from a request, kept only when the role exists. * * @param array $raw Candidate slugs. * @return array */ public static function sanitize_roles( array $raw ): array { $known = function_exists( 'wp_roles' ) ? array_keys( wp_roles()->get_names() ) : array(); $out = array(); foreach ( $raw as $slug ) { $slug = sanitize_key( (string) $slug ); if ( '' !== $slug && in_array( $slug, $known, true ) && ! in_array( $slug, $out, true ) ) { $out[] = $slug; } } return $out; } /** Renders the settings panel on the Login Security tab. */ public static function render_panel(): void { $mode = self::pwned_mode(); $min = self::min_length(); $required = self::required_roles(); $roles = function_exists( 'wp_roles' ) ? wp_roles()->get_names() : array(); ?>
🔑 'Breached-password check: what is sent', 'rec' => 'Read this first', 'html' => 'When a password is set (profile page, password reset, Add New User, or a registration form with a password field), this site computes its SHA-1 hash and sends only the first 5 hexadecimal characters of that hash to ' . esc_html( self::ENDPOINT ) . '. The password is never sent, and neither is the full hash.

CloudScale\'s service returns the matching range of breached-password hashes. The range lists every breached hash sharing those 5 characters, several hundred of them, padded with random entries so its size reveals nothing. This site compares the remaining 35 characters itself. This is the k-anonymity model: CloudScale cannot tell which password, or even which hash, was checked.

Nothing is sent when the check is Off.', ), array( 'name' => 'Off, Warn, Block', 'rec' => 'Default: Off, opt-in', 'html' => 'Warn accepts the password, records a security event, and shows that user a notice on every admin page until they choose another.
Block refuses the password with a message asking for a different one.
Off sends nothing.

If the lookup fails or takes more than ' . (int) self::TIMEOUT . ' seconds, the password is accepted and the failure is written to the plugin log. A breach corpus being unreachable must never stop someone resetting their password.', ), array( 'name' => 'Minimum length', 'rec' => 'Optional', 'html' => 'Refuses a new password shorter than this many characters, on the same forms. 0 leaves it to WordPress, which only warns about weak passwords. Length is the strongest single factor in a password\'s resistance to guessing; 12 or more is a sensible floor. Existing passwords are not affected until they are next changed.', ), array( 'name' => 'Require 2FA for roles', 'rec' => 'Important', 'html' => 'A user holding any ticked role must pass a second factor at login, even when the site-wide 2FA method is Off: their authenticator app or passkey if they have enrolled one, otherwise a code emailed to them. Every user can enrol from Users, Profile, Two-factor authentication, which needs no access to this screen.

The Force 2FA for administrators setting above is separate and unchanged by this list.

Check outgoing email works before ticking a role: until those users enrol an app or passkey, the emailed code is the only way in.', ), ) ); ?>
$name ) : ?>
admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( CSDT_DevTools::LOGIN_NONCE ), ) ); wp_enqueue_script( 'csdt-passkey', plugins_url( 'assets/cs-passkey.js', CSDT_PLUGIN_FILE ), array( 'csdt-profile-2fa' ), $ver, true ); } /** * The "Two-factor authentication" section on the user's OWN profile (show_user_profile). * * @param WP_User $user The profile being shown, always the current user on this hook. * @return void */ public static function render_profile_section( $user ): void { $uid = get_current_user_id(); // Belt and braces: the endpoints act on the current user, so this must never render // against anyone else's profile, whatever hook calls it. if ( ! is_object( $user ) || (int) $user->ID !== $uid || ! $uid ) { return; } $totp = '1' === get_user_meta( $uid, 'csdt_devtools_totp_enabled', true ); $email = '1' === get_user_meta( $uid, 'csdt_devtools_2fa_email_enabled', true ); $pending = (bool) get_user_meta( $uid, 'csdt_devtools_email_verify_pending', true ); $required = self::role_requires_2fa( $user ); // Display-only flag, read only on the signed redirect email_2fa_confirm_check() issues. $activated = false; if ( isset( $_GET['_wpnonce'], $_GET['email_2fa_activated'] ) ) { if ( wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'csdt_email_2fa_notice' ) ) { $activated = '1' === sanitize_text_field( wp_unslash( $_GET['email_2fa_activated'] ) ); } } ?>

'Authenticator app', 'rec' => 'Recommended', 'html' => 'Scan a QR code once with Google Authenticator, Authy, 1Password, Apple Passwords or any TOTP app. At each sign-in, type the 6-digit code it shows. Works offline.', ), array( 'name' => 'Passkey', 'rec' => 'Recommended', 'html' => 'Face ID, Touch ID, Windows Hello or a hardware key. Nothing to type, and it cannot be phished. Register one per device you sign in from.', ), array( 'name' => 'Email code', 'rec' => 'Optional', 'html' => 'A 6-digit code is emailed to you at each sign-in. Simple, but only as safe as your mailbox, and only as reliable as this site\'s email.', ), array( 'name' => 'What changes when you enrol', 'rec' => 'Note', 'html' => 'From your next sign-in you are asked for the second step. Enrolling or removing a method signs you out everywhere else. These buttons only ever change your own account.', ), ), '', true ); ?>