diff --git a/src/wp-admin/includes/upgrade.php b/src/wp-admin/includes/upgrade.php index 94f40cda318a1..a96324f738fd9 100644 --- a/src/wp-admin/includes/upgrade.php +++ b/src/wp-admin/includes/upgrade.php @@ -980,6 +980,7 @@ function upgrade_101() { * * @ignore * @since 1.2.0 + * Since x.y.z User passwords are no longer hashed with md5. * * @global wpdb $wpdb WordPress database abstraction object. */ @@ -995,13 +996,6 @@ function upgrade_110() { } } - $users = $wpdb->get_results( "SELECT ID, user_pass from $wpdb->users" ); - foreach ( $users as $row ) { - if ( ! preg_match( '/^[A-Fa-f0-9]{32}$/', $row->user_pass ) ) { - $wpdb->update( $wpdb->users, array( 'user_pass' => md5( $row->user_pass ) ), array( 'ID' => $row->ID ) ); - } - } - // Get the GMT offset, we'll use that later on. $all_options = get_alloptions_110(); diff --git a/src/wp-includes/class-wp-application-passwords.php b/src/wp-includes/class-wp-application-passwords.php index 43965790594b1..7db169bc44897 100644 --- a/src/wp-includes/class-wp-application-passwords.php +++ b/src/wp-includes/class-wp-application-passwords.php @@ -60,6 +60,7 @@ public static function is_in_use() { * * @since 5.6.0 * @since 5.7.0 Returns WP_Error if application name already exists. + * @since x.y.z The hashed password value now uses wp_fast_hash() instead of phpass. * * @param int $user_id User ID. * @param array $args { @@ -95,7 +96,7 @@ public static function create_new_application_password( $user_id, $args = array( } $new_password = wp_generate_password( static::PW_LENGTH, false ); - $hashed_password = wp_hash_password( $new_password ); + $hashed_password = self::hash_password( $new_password ); $new_item = array( 'uuid' => wp_generate_uuid4(), @@ -124,6 +125,7 @@ public static function create_new_application_password( $user_id, $args = array( * Fires when an application password is created. * * @since 5.6.0 + * @since x.y.z @since x.y.z The hashed password value now uses wp_fast_hash() instead of phpass. * * @param int $user_id The user ID. * @param array $new_item { @@ -249,6 +251,7 @@ public static function application_name_exists_for_user( $user_id, $name ) { * Updates an application password. * * @since 5.6.0 + * @since x.y.z The actual password should now be hashed using wp_fast_hash(). * * @param int $user_id User ID. * @param string $uuid The password's UUID. @@ -296,6 +299,8 @@ public static function update_application_password( $user_id, $uuid, $update = a * Fires when an application password is updated. * * @since 5.6.0 + * @since x.y.z The password is now hashed using wp_fast_hash() instead of phpass. + * Existing passwords may still be hashed using phpass. * * @param int $user_id The user ID. * @param array $item { @@ -467,4 +472,52 @@ public static function chunk_password( return trim( chunk_split( $raw_password, 4, ' ' ) ); } + + /** + * Hashes a plaintext application password. + * + * @since x.y.z + * + * @param string $password Plaintext password. + * @return string Hashed password. + */ + public static function hash_password( + #[\SensitiveParameter] + string $password + ): string { + return wp_fast_hash( $password ); + } + + /** + * Checks a plaintext application password against a hashed password. + * + * @since x.y.z + * + * @param string $password Plaintext password. + * @param string $hash Hash of the password to check against. + * @return bool Whether the password matches the hashed password. + */ + public static function check_password( + #[\SensitiveParameter] + string $password, + string $hash + ): bool { + return wp_verify_fast_hash( $password, $hash ); + } + + /** + * Checks whether a password hash needs to be rehashed. + * + * A password hashed in a prior version of WordPress may still be hashed with phpass and will + * need to be rehashed. If the algorithm is changed in WordPress then a password hashed in a + * previous version will need to be rehashed. + * + * @since x.y.z + * + * @param string $hash Hash of a password to check. + * @return bool Whether the hash needs to be rehashed. + */ + public static function password_needs_rehash( string $hash ): bool { + return ! str_starts_with( $hash, '$generic$' ); + } } diff --git a/src/wp-includes/class-wp-recovery-mode-key-service.php b/src/wp-includes/class-wp-recovery-mode-key-service.php index 38d5730f85bf4..bbb949ada4a45 100644 --- a/src/wp-includes/class-wp-recovery-mode-key-service.php +++ b/src/wp-includes/class-wp-recovery-mode-key-service.php @@ -37,29 +37,18 @@ public function generate_recovery_mode_token() { * Creates a recovery mode key. * * @since 5.2.0 - * - * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance. + * @since x.y.z The stored key is now hashed using wp_fast_hash() instead of phpass. * * @param string $token A token generated by {@see generate_recovery_mode_token()}. * @return string Recovery mode key. */ public function generate_and_store_recovery_mode_key( $token ) { - - global $wp_hasher; - $key = wp_generate_password( 22, false ); - if ( empty( $wp_hasher ) ) { - require_once ABSPATH . WPINC . '/class-phpass.php'; - $wp_hasher = new PasswordHash( 8, true ); - } - - $hashed = $wp_hasher->HashPassword( $key ); - $records = $this->get_keys(); $records[ $token ] = array( - 'hashed_key' => $hashed, + 'hashed_key' => wp_fast_hash( $key ), 'created_at' => time(), ); @@ -85,16 +74,12 @@ public function generate_and_store_recovery_mode_key( $token ) { * * @since 5.2.0 * - * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance. - * * @param string $token The token used when generating the given key. - * @param string $key The unhashed key. + * @param string $key The plain text key. * @param int $ttl Time in seconds for the key to be valid for. * @return true|WP_Error True on success, error object on failure. */ public function validate_recovery_mode_key( $token, $key, $ttl ) { - global $wp_hasher; - $records = $this->get_keys(); if ( ! isset( $records[ $token ] ) ) { @@ -109,12 +94,7 @@ public function validate_recovery_mode_key( $token, $key, $ttl ) { return new WP_Error( 'invalid_recovery_key_format', __( 'Invalid recovery key format.' ) ); } - if ( empty( $wp_hasher ) ) { - require_once ABSPATH . WPINC . '/class-phpass.php'; - $wp_hasher = new PasswordHash( 8, true ); - } - - if ( ! $wp_hasher->CheckPassword( $key, $record['hashed_key'] ) ) { + if ( ! wp_verify_fast_hash( $key, $record['hashed_key'] ) ) { return new WP_Error( 'hash_mismatch', __( 'Invalid recovery key.' ) ); } @@ -169,9 +149,20 @@ private function remove_key( $token ) { * Gets the recovery key records. * * @since 5.2.0 + * @since x.y.z Each key is now hashed using wp_fast_hash() instead of phpass. + * Existing keys may still be hashed using phpass. + * + * @return array { + * Associative array of token => data pairs, where the data is an associative + * array of information about the key. * - * @return array Associative array of $token => $data pairs, where $data has keys 'hashed_key' - * and 'created_at'. + * @type array ...$0 { + * Information about the key. + * + * @type string $hashed_key The hashed value of the key. + * @type int $created_at The timestamp when the key was created. + * } + * } */ private function get_keys() { return (array) get_option( $this->option_name, array() ); @@ -181,9 +172,19 @@ private function get_keys() { * Updates the recovery key records. * * @since 5.2.0 + * @since x.y.z Each key should now be hashed using wp_fast_hash() instead of phpass. + * + * @param array $keys { + * Associative array of token => data pairs, where the data is an associative + * array of information about the key. + * + * @type array ...$0 { + * Information about the key. * - * @param array $keys Associative array of $token => $data pairs, where $data has keys 'hashed_key' - * and 'created_at'. + * @type string $hashed_key The hashed value of the key. + * @type int $created_at The timestamp when the key was created. + * } + * } * @return bool True on success, false on failure. */ private function update_keys( array $keys ) { diff --git a/src/wp-includes/class-wp-user-request.php b/src/wp-includes/class-wp-user-request.php index 8c66dcdf8189e..b52aefda9fb10 100644 --- a/src/wp-includes/class-wp-user-request.php +++ b/src/wp-includes/class-wp-user-request.php @@ -92,6 +92,8 @@ final class WP_User_Request { * Key used to confirm this request. * * @since 4.9.6 + * @since x.y.z The key is now hashed using wp_fast_hash() instead of phpass. + * * @var string */ public $confirm_key = ''; diff --git a/src/wp-includes/class-wp-user.php b/src/wp-includes/class-wp-user.php index 0be1b3ed02e86..051f583627298 100644 --- a/src/wp-includes/class-wp-user.php +++ b/src/wp-includes/class-wp-user.php @@ -11,6 +11,7 @@ * Core class used to implement the WP_User object. * * @since 2.0.0 + * @since x.y.z The `user_pass` property is now hashed using bcrypt instead of phpass. * * @property string $nickname * @property string $description diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index fbad1f721a94a..0dfff49599126 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -9114,3 +9114,62 @@ function wp_is_heic_image_mime_type( $mime_type ) { return in_array( $mime_type, $heic_mime_types, true ); } + +/** + * Returns a cryptographically secure hash of a message using a fast generic hash function. + * + * Use the wp_verify_fast_hash() function to verify the hash. + * + * This function does not salt the value prior to being hashed, therefore input to this function must originate from + * a random generator with sufficiently high entropy, preferably greater than 128 bits. This function is used internally + * in WordPress to hash security keys and application passwords which are generated with high entropy. + * + * Important: + * + * - This function must not be used for hashing user-generated passwords. Use wp_hash_password() for that. + * - This function must not be used for hashing other low-entropy input. Use wp_hash() for that. + * + * The BLAKE2b algorithm is used by Sodium to hash the message. + * + * @since x.y.z + * + * @throws TypeError Thrown by Sodium if the message is not a string. + * + * @param string $message The message to hash. + * @return string The hash of the message. + */ +function wp_fast_hash( + #[\SensitiveParameter] + string $message +): string { + return '$generic$' . sodium_bin2hex( sodium_crypto_generichash( $message ) ); +} + +/** + * Checks whether a plaintext message matches the hashed value. Used to verify values hashed via wp_fast_hash(). + * + * The function uses Sodium to hash the message and compare it to the hashed value. If the hash is not a generic hash, + * the hash is treated as a phpass portable hash in order to provide backward compatibility for application passwords + * which were hashed using phpass prior to WordPress x.y.z. + * + * @since x.y.z + * + * @throws TypeError Thrown by Sodium if the message is not a string. + * + * @param string $message The plaintext message. + * @param string $hash Hash of the message to check against. + * @return bool Whether the message matches the hashed message. + */ +function wp_verify_fast_hash( + #[\SensitiveParameter] + string $message, + string $hash +): bool { + if ( ! str_starts_with( $hash, '$generic$' ) ) { + // Back-compat for old phpass hashes. + require_once ABSPATH . WPINC . '/class-phpass.php'; + return ( new PasswordHash( 8, true ) )->CheckPassword( $message, $hash ); + } + + return hash_equals( $hash, wp_fast_hash( $message ) ); +} diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index bc61ba0bdfa3b..0b64b93fa8f53 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -693,6 +693,7 @@ function wp_logout() { * * @param string $cookie Optional. If used, will validate contents instead of cookie's. * @param string $scheme Optional. The cookie scheme to use: 'auth', 'secure_auth', or 'logged_in'. + * Note: This does *not* default to 'auth' like other cookie functions. * @return int|false User ID if valid cookie, false if invalid. */ function wp_validate_auth_cookie( $cookie = '', $scheme = '' ) { @@ -768,7 +769,13 @@ function wp_validate_auth_cookie( $cookie = '', $scheme = '' ) { return false; } - $pass_frag = substr( $user->user_pass, 8, 4 ); + if ( str_starts_with( $user->user_pass, '$P$' ) || str_starts_with( $user->user_pass, '$2y$' ) ) { + // Retain previous behaviour of phpass or vanilla bcrypt hashed passwords. + $pass_frag = substr( $user->user_pass, 8, 4 ); + } else { + // Otherwise, use a substring from the end of the hash to avoid dealing with potentially long hash prefixes. + $pass_frag = substr( $user->user_pass, -4 ); + } $key = wp_hash( $username . '|' . $pass_frag . '|' . $expiration . '|' . $token, $scheme ); @@ -869,7 +876,13 @@ function wp_generate_auth_cookie( $user_id, $expiration, $scheme = 'auth', $toke $token = $manager->create( $expiration ); } - $pass_frag = substr( $user->user_pass, 8, 4 ); + if ( str_starts_with( $user->user_pass, '$P$' ) || str_starts_with( $user->user_pass, '$2y$' ) ) { + // Retain previous behaviour of phpass or vanilla bcrypt hashed passwords. + $pass_frag = substr( $user->user_pass, 8, 4 ); + } else { + // Otherwise, use a substring from the end of the hash to avoid dealing with potentially long hash prefixes. + $pass_frag = substr( $user->user_pass, -4 ); + } $key = wp_hash( $user->user_login . '|' . $pass_frag . '|' . $expiration . '|' . $token, $scheme ); @@ -2625,8 +2638,9 @@ function wp_hash( $data, $scheme = 'auth', $algo = 'md5' ) { * instead use the other package password hashing algorithm. * * @since 2.5.0 + * @since x.y.z The password is now hashed using bcrypt by default instead of phpass. * - * @global PasswordHash $wp_hasher PHPass object. + * @global PasswordHash $wp_hasher phpass object. * * @param string $password Plain text user password to hash. * @return string The hash string of the password. @@ -2637,13 +2651,62 @@ function wp_hash_password( ) { global $wp_hasher; - if ( empty( $wp_hasher ) ) { - require_once ABSPATH . WPINC . '/class-phpass.php'; - // By default, use the portable hash from phpass. - $wp_hasher = new PasswordHash( 8, true ); + if ( ! empty( $wp_hasher ) ) { + return $wp_hasher->HashPassword( trim( $password ) ); + } + + if ( strlen( $password ) > 4096 ) { + return '*'; + } + + /** + * Filters the hashing algorithm to use in the password_hash() and password_needs_rehash() functions. + * + * The default is the value of the `PASSWORD_BCRYPT` constant which means bcrypt is used. + * + * **Important:** The only password hashing algorithm that is guaranteed to be available across PHP + * installations is bcrypt. If you use any other algorithm you must make sure that it is available on + * the server. The `password_algos()` function can be used to check which hashing algorithms are available. + * + * The hashing options can be controlled via the {@see 'wp_hash_password_options'} filter. + * + * Other available constants include: + * + * - `PASSWORD_ARGON2I` + * - `PASSWORD_ARGON2ID` + * - `PASSWORD_DEFAULT` + * + * @since x.y.z + * + * @param string $algorithm The hashing algorithm. Default is the value of the `PASSWORD_BCRYPT` constant. + */ + $algorithm = apply_filters( 'wp_hash_password_algorithm', PASSWORD_BCRYPT ); + + /** + * Filters the options passed to the password_hash() and password_needs_rehash() functions. + * + * The default hashing algorithm is bcrypt, but this can be changed via the {@see 'wp_hash_password_algorithm'} + * filter. You must ensure that the options are appropriate for the algorithm in use. + * + * @since x.y.z + * + * @param array $options Array of options to pass to the password hashing functions. + * By default this is an empty array which means the default + * options will be used. + * @param string $algorithm The hashing algorithm in use. + */ + $options = apply_filters( 'wp_hash_password_options', array(), $algorithm ); + + // Algorithms other than bcrypt don't need to use pre-hashing. + if ( PASSWORD_BCRYPT !== $algorithm ) { + return password_hash( $password, $algorithm, $options ); } - return $wp_hasher->HashPassword( trim( $password ) ); + // Use SHA-384 to retain entropy from a password that's longer than 72 bytes, and a `wp-sha384` key for domain separation. + $password_to_hash = base64_encode( hash_hmac( 'sha384', trim( $password ), 'wp-sha384', true ) ); + + // Add a prefix to facilitate distinguishing vanilla bcrypt hashes. + return '$wp' . password_hash( $password_to_hash, $algorithm, $options ); } endif; @@ -2651,23 +2714,24 @@ function wp_hash_password( /** * Checks a plaintext password against a hashed password. * - * Maintains compatibility between old version and the new cookie authentication - * protocol using PHPass library. The $hash parameter is the encrypted password - * and the function compares the plain text password when encrypted similarly - * against the already encrypted password to see if they match. + * Note that this function may be used to check a value that is not a user password. + * A plugin may use this function to check a password of a different type, and there + * may not always be a user ID associated with the password. * * For integration with other applications, this function can be overwritten to * instead use the other package password hashing algorithm. * * @since 2.5.0 + * @since x.y.z Passwords in WordPress are now hashed with bcrypt by default. A + * password that wasn't hashed with bcrypt will be checked with phpass. + * Passwords hashed with md5 are no longer supported. * - * @global PasswordHash $wp_hasher PHPass object used for checking the password - * against the $hash + $password. - * @uses PasswordHash::CheckPassword + * @global PasswordHash $wp_hasher phpass object. Used as a fallback for verifying + * passwords that were hashed with phpass. * - * @param string $password Plaintext user's password. - * @param string $hash Hash of the user's password to check against. - * @param string|int $user_id Optional. User ID. + * @param string $password Plaintext password. + * @param string $hash Hash of the password to check against. + * @param string|int $user_id Optional. ID of a user associated with the password. * @return bool False, if the $password does not match the hashed password. */ function wp_check_password( @@ -2678,45 +2742,107 @@ function wp_check_password( ) { global $wp_hasher; - // If the hash is still md5... - if ( strlen( $hash ) <= 32 ) { - $check = hash_equals( $hash, md5( $password ) ); - if ( $check && $user_id ) { - // Rehash using new hash. - wp_set_password( $password, $user_id ); - $hash = wp_hash_password( $password ); - } + $check = false; + // If the hash is still md5 or otherwise truncated then invalidate it. + if ( strlen( $hash ) <= 32 ) { /** - * Filters whether the plaintext password matches the encrypted password. + * Filters whether the plaintext password matches the hashed password. * * @since 2.5.0 + * @since x.y.z Passwords are now hashed with bcrypt by default. + * Old passwords may still be hashed with phpass. * * @param bool $check Whether the passwords match. * @param string $password The plaintext password. * @param string $hash The hashed password. - * @param string|int $user_id User ID. Can be empty. + * @param string|int $user_id Optional ID of a user associated with the password. + * Can be empty. */ return apply_filters( 'check_password', $check, $password, $hash, $user_id ); } - /* - * If the stored hash is longer than an MD5, - * presume the new style phpass portable hash. - */ - if ( empty( $wp_hasher ) ) { + if ( ! empty( $wp_hasher ) ) { + // Check the password using the overridden hasher. + $check = $wp_hasher->CheckPassword( $password, $hash ); + } elseif ( strlen( $password ) > 4096 ) { + $check = false; + } elseif ( str_starts_with( $hash, '$wp' ) ) { + // Check the password using the current prefixed hash. + $password_to_verify = base64_encode( hash_hmac( 'sha384', $password, 'wp-sha384', true ) ); + $check = password_verify( $password_to_verify, substr( $hash, 3 ) ); + } elseif ( str_starts_with( $hash, '$P$' ) ) { + // Check the password using phpass. require_once ABSPATH . WPINC . '/class-phpass.php'; - // By default, use the portable hash from phpass. - $wp_hasher = new PasswordHash( 8, true ); + $check = ( new PasswordHash( 8, true ) )->CheckPassword( $password, $hash ); + } else { + // Check the password using compat support for any non-prefixed hash. + $check = password_verify( $password, $hash ); } - $check = $wp_hasher->CheckPassword( $password, $hash ); - /** This filter is documented in wp-includes/pluggable.php */ return apply_filters( 'check_password', $check, $password, $hash, $user_id ); } endif; +if ( ! function_exists( 'wp_password_needs_rehash' ) ) : + /** + * Checks whether a password hash needs to be rehashed. + * + * Passwords are hashed with bcrypt using the default cost. A password hashed in a prior version + * of WordPress may still be hashed with phpass and will need to be rehashed. If the default cost + * or algorithm is changed in PHP or WordPress then a password hashed in a previous version will + * need to be rehashed. + * + * Note that, just like wp_check_password(), this function may be used to check a value that is + * not a user password. A plugin may use this function to check a password of a different type, + * and there may not always be a user ID associated with the password. + * + * @since x.y.z + * + * @global PasswordHash $wp_hasher phpass object. + * + * @param string $hash Hash of a password to check. + * @param string|int $user_id Optional. ID of a user associated with the password. + * @return bool Whether the hash needs to be rehashed. + */ + function wp_password_needs_rehash( $hash, $user_id = '' ) { + global $wp_hasher; + + if ( ! empty( $wp_hasher ) ) { + return false; + } + + /** This filter is documented in wp-includes/pluggable.php */ + $algorithm = apply_filters( 'wp_hash_password_algorithm', PASSWORD_BCRYPT ); + + /** This filter is documented in wp-includes/pluggable.php */ + $options = apply_filters( 'wp_hash_password_options', array(), $algorithm ); + + $prefixed = str_starts_with( $hash, '$wp' ); + + if ( ( PASSWORD_BCRYPT === $algorithm ) && ! $prefixed ) { + // If bcrypt is in use and the hash is not prefixed then it needs to be rehashed. + $needs_rehash = true; + } else { + // Otherwise check the hash minus its prefix if necessary. + $hash_to_check = $prefixed ? substr( $hash, 3 ) : $hash; + $needs_rehash = password_needs_rehash( $hash_to_check, $algorithm, $options ); + } + + /** + * Filters whether the password hash needs to be rehashed. + * + * @since x.y.z + * + * @param bool $needs_rehash Whether the password hash needs to be rehashed. + * @param string $hash The password hash. + * @param string|int $user_id Optional. ID of a user associated with the password. + */ + return apply_filters( 'password_needs_rehash', $needs_rehash, $hash, $user_id ); + } +endif; + if ( ! function_exists( 'wp_generate_password' ) ) : /** * Generates a random password drawn from the defined set of characters. @@ -2865,6 +2991,7 @@ function wp_rand( $min = null, $max = null ) { * of password resets if precautions are not taken to ensure it does not execute on every page load. * * @since 2.5.0 + * @since x.y.z The password is now hashed using bcrypt by default instead of phpass. * * @global wpdb $wpdb WordPress database abstraction object. * diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index ede1330251263..5e215cf9034ba 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -205,7 +205,13 @@ function wp_authenticate_username_password( return $user; } - if ( ! wp_check_password( $password, $user->user_pass, $user->ID ) ) { + $valid = wp_check_password( $password, $user->user_pass, $user->ID ); + + if ( $valid && wp_password_needs_rehash( $user->user_pass, $user->ID ) ) { + wp_set_password( $password, $user->ID ); + } + + if ( ! $valid ) { return new WP_Error( 'incorrect_password', sprintf( @@ -282,7 +288,13 @@ function wp_authenticate_email_password( return $user; } - if ( ! wp_check_password( $password, $user->user_pass, $user->ID ) ) { + $valid = wp_check_password( $password, $user->user_pass, $user->ID ); + + if ( $valid && wp_password_needs_rehash( $user->user_pass, $user->ID ) ) { + wp_set_password( $password, $user->ID ); + } + + if ( ! $valid ) { return new WP_Error( 'incorrect_password', sprintf( @@ -445,7 +457,7 @@ function wp_authenticate_application_password( $hashed_passwords = WP_Application_Passwords::get_user_application_passwords( $user->ID ); foreach ( $hashed_passwords as $key => $item ) { - if ( ! wp_check_password( $password, $item['password'], $user->ID ) ) { + if ( ! WP_Application_Passwords::check_password( $password, $item['password'] ) ) { continue; } @@ -2421,6 +2433,7 @@ function wp_insert_user( $userdata ) { * * @since 4.9.0 * @since 5.8.0 The `$userdata` parameter was added. + * @since x.y.z The user's password is now hashed using bcrypt instead of phpass. * * @param array $data { * Values and keys for the user. @@ -2968,14 +2981,10 @@ function wp_get_password_hint() { * * @since 4.4.0 * - * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance. - * * @param WP_User $user User to retrieve password reset key for. * @return string|WP_Error Password reset key on success. WP_Error on error. */ function get_password_reset_key( $user ) { - global $wp_hasher; - if ( ! ( $user instanceof WP_User ) ) { return new WP_Error( 'invalidcombo', __( 'Error: There is no account with that username or email address.' ) ); } @@ -3021,13 +3030,7 @@ function get_password_reset_key( $user ) { */ do_action( 'retrieve_password_key', $user->user_login, $key ); - // Now insert the key, hashed, into the DB. - if ( empty( $wp_hasher ) ) { - require_once ABSPATH . WPINC . '/class-phpass.php'; - $wp_hasher = new PasswordHash( 8, true ); - } - - $hashed = time() . ':' . $wp_hasher->HashPassword( $key ); + $hashed = time() . ':' . wp_fast_hash( $key ); $key_saved = wp_update_user( array( @@ -3053,9 +3056,7 @@ function get_password_reset_key( $user ) { * * @since 3.1.0 * - * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance. - * - * @param string $key Hash to validate sending user's password. + * @param string $key The password reset key. * @param string $login The user login. * @return WP_User|WP_Error WP_User object on success, WP_Error object for invalid or expired keys. */ @@ -3064,8 +3065,6 @@ function check_password_reset_key( $key, $login ) { - global $wp_hasher; - $key = preg_replace( '/[^a-z0-9]/i', '', $key ); if ( empty( $key ) || ! is_string( $key ) ) { @@ -3082,11 +3081,6 @@ function check_password_reset_key( return new WP_Error( 'invalid_key', __( 'Invalid key.' ) ); } - if ( empty( $wp_hasher ) ) { - require_once ABSPATH . WPINC . '/class-phpass.php'; - $wp_hasher = new PasswordHash( 8, true ); - } - /** * Filters the expiration time of password reset keys. * @@ -3108,7 +3102,7 @@ function check_password_reset_key( return new WP_Error( 'invalid_key', __( 'Invalid key.' ) ); } - $hash_is_correct = $wp_hasher->CheckPassword( $key, $pass_key ); + $hash_is_correct = wp_verify_fast_hash( $key, $pass_key ); if ( $hash_is_correct && $expiration_time && time() < $expiration_time ) { return $user; @@ -3123,7 +3117,7 @@ function check_password_reset_key( /** * Filters the return value of check_password_reset_key() when an - * old-style key is used. + * old-style key or an expired key is used. * * @since 3.7.0 Previously plain-text keys were stored in the database. * @since 4.3.0 Previously key hashes were stored without an expiration time. @@ -3144,8 +3138,7 @@ function check_password_reset_key( * @since 2.5.0 * @since 5.7.0 Added `$user_login` parameter. * - * @global wpdb $wpdb WordPress database abstraction object. - * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance. + * @global wpdb $wpdb WordPress database abstraction object. * * @param string $user_login Optional. Username to send a password retrieval email for. * Defaults to `$_POST['user_login']` if not set. @@ -4926,28 +4919,19 @@ function wp_send_user_request( $request_id ) { * * @since 4.9.6 * - * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance. - * * @param int $request_id Request ID. * @return string Confirmation key. */ function wp_generate_user_request_key( $request_id ) { - global $wp_hasher; - // Generate something random for a confirmation key. $key = wp_generate_password( 20, false ); - // Return the key, hashed. - if ( empty( $wp_hasher ) ) { - require_once ABSPATH . WPINC . '/class-phpass.php'; - $wp_hasher = new PasswordHash( 8, true ); - } - + // Save the key, hashed. wp_update_post( array( 'ID' => $request_id, 'post_status' => 'request-pending', - 'post_password' => $wp_hasher->HashPassword( $key ), + 'post_password' => wp_fast_hash( $key ), ) ); @@ -4959,8 +4943,6 @@ function wp_generate_user_request_key( $request_id ) { * * @since 4.9.6 * - * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance. - * * @param string $request_id ID of the request being confirmed. * @param string $key Provided key to validate. * @return true|WP_Error True on success, WP_Error on failure. @@ -4970,8 +4952,6 @@ function wp_validate_user_request_key( #[\SensitiveParameter] $key ) { - global $wp_hasher; - $request_id = absint( $request_id ); $request = wp_get_user_request( $request_id ); $saved_key = $request->confirm_key; @@ -4989,11 +4969,6 @@ function wp_validate_user_request_key( return new WP_Error( 'missing_key', __( 'The confirmation key is missing from this personal data request.' ) ); } - if ( empty( $wp_hasher ) ) { - require_once ABSPATH . WPINC . '/class-phpass.php'; - $wp_hasher = new PasswordHash( 8, true ); - } - /** * Filters the expiration time of confirm keys. * @@ -5004,7 +4979,7 @@ function wp_validate_user_request_key( $expiration_duration = (int) apply_filters( 'user_request_key_expiration', DAY_IN_SECONDS ); $expiration_time = $key_request_time + $expiration_duration; - if ( ! $wp_hasher->CheckPassword( $key, $saved_key ) ) { + if ( ! wp_verify_fast_hash( $key, $saved_key ) ) { return new WP_Error( 'invalid_key', __( 'The confirmation key is invalid for this personal data request.' ) ); } diff --git a/tests/phpunit/includes/bootstrap.php b/tests/phpunit/includes/bootstrap.php index d4dd978b379e2..e308dcb5a6e1c 100644 --- a/tests/phpunit/includes/bootstrap.php +++ b/tests/phpunit/includes/bootstrap.php @@ -329,6 +329,7 @@ function wp_tests_options( $value ) { require __DIR__ . '/class-wp-rest-test-search-handler.php'; require __DIR__ . '/class-wp-rest-test-configurable-controller.php'; require __DIR__ . '/class-wp-fake-block-type.php'; +require __DIR__ . '/class-wp-fake-hasher.php'; require __DIR__ . '/class-wp-sitemaps-test-provider.php'; require __DIR__ . '/class-wp-sitemaps-empty-test-provider.php'; require __DIR__ . '/class-wp-sitemaps-large-test-provider.php'; diff --git a/tests/phpunit/includes/class-wp-fake-hasher.php b/tests/phpunit/includes/class-wp-fake-hasher.php new file mode 100644 index 0000000000000..573362e375fb5 --- /dev/null +++ b/tests/phpunit/includes/class-wp-fake-hasher.php @@ -0,0 +1,41 @@ +hash = str_repeat( 'a', 36 ); + } + + /** + * Hashes a password. + * + * @param string $password Password to hash. + * @return string Hashed password. + */ + public function HashPassword( string $password ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return $this->hash; + } + + /** + * Checks the password hash. + * + * @param string $password Password to check. + * @param string $hash Hash to check against. + * @return bool Whether the password hash is valid. + */ + public function CheckPassword( string $password, string $hash ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return $hash === $this->hash; + } +} diff --git a/tests/phpunit/tests/auth.php b/tests/phpunit/tests/auth.php index f45071904e9c8..b500e54267bb1 100644 --- a/tests/phpunit/tests/auth.php +++ b/tests/phpunit/tests/auth.php @@ -10,15 +10,32 @@ class Tests_Auth extends WP_UnitTestCase { const USER_LOGIN = 'password-user'; const USER_PASS = 'password'; + /** + * @var WP_User + */ protected $user; /** * @var WP_User */ protected static $_user; + + /** + * @var int + */ protected static $user_id; + + /** + * @var PasswordHash + */ protected static $wp_hasher; + protected static $bcrypt_length_limit = 72; + + protected static $phpass_length_limit = 4096; + + protected static $password_length_limit = 4096; + /** * Action hook. */ @@ -91,6 +108,28 @@ public function test_auth_cookie_scheme() { $this->assertFalse( wp_validate_auth_cookie( $cookie, 'bar' ) ); } + /** + * @ticket 21022 + */ + public function test_auth_cookie_generated_with_phpass_hash_remains_valid() { + self::set_user_password_with_phpass( 'password', self::$user_id ); + + $auth_cookie = wp_generate_auth_cookie( self::$user_id, time() + 3600, 'auth' ); + + $this->assertSame( self::$user_id, wp_validate_auth_cookie( $auth_cookie, 'auth' ) ); + } + + /** + * @ticket 21022 + */ + public function test_auth_cookie_generated_with_plain_bcrypt_hash_remains_valid() { + self::set_user_password_with_plain_bcrypt( 'password', self::$user_id ); + + $auth_cookie = wp_generate_auth_cookie( self::$user_id, time() + 3600, 'auth' ); + + $this->assertSame( self::$user_id, wp_validate_auth_cookie( $auth_cookie, 'auth' ) ); + } + /** * @ticket 23494 */ @@ -106,6 +145,7 @@ public function test_password_trimming() { wp_set_password( $password_to_test, $this->user->ID ); $authed_user = wp_authenticate( $this->user->user_login, $password_to_test ); + $this->assertNotWPError( $authed_user ); $this->assertInstanceOf( 'WP_User', $authed_user ); $this->assertSame( $this->user->ID, $authed_user->ID ); } @@ -159,6 +199,185 @@ public function test_wp_hash_password_trimming() { $this->assertTrue( wp_check_password( 'pass with vertical tab o_O', wp_hash_password( $password ) ) ); } + /** + * @ticket 21022 + */ + public function test_wp_check_password_supports_phpass_hash() { + $password = 'password'; + $hash = self::$wp_hasher->HashPassword( $password ); + $this->assertTrue( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + } + + /** + * Ensure wp_check_password() remains compatible with an increase to the default bcrypt cost. + * + * The test verifies this by reducing the cost used to generate the hash, therefore mimicing a hash + * which was generated prior to the default cost being increased. + * + * Notably the bcrypt cost was increased in PHP 8.4: https://wiki.php.net/rfc/bcrypt_cost_2023 . + * + * @ticket 21022 + */ + public function test_wp_check_password_supports_hash_with_increased_bcrypt_cost() { + $password = 'password'; + + // Reducing the cost mimics an increase to the default cost. + add_filter( 'wp_hash_password_options', array( $this, 'reduce_hash_cost' ) ); + $hash = wp_hash_password( $password, PASSWORD_BCRYPT ); + remove_filter( 'wp_hash_password_options', array( $this, 'reduce_hash_cost' ) ); + + $this->assertTrue( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + $this->assertTrue( wp_password_needs_rehash( $hash ) ); + } + + /** + * Ensure wp_check_password() remains compatible with a reduction of the default bcrypt cost. + * + * The test verifies this by increasing the cost used to generate the hash, therefore mimicing a hash + * which was generated prior to the default cost being reduced. + * + * A reduction of the cost is unlikely to occur but is fully supported. + * + * @ticket 21022 + */ + public function test_wp_check_password_supports_hash_with_reduced_bcrypt_cost() { + $password = 'password'; + + // Increasing the cost mimics a reduction of the default cost. + add_filter( 'wp_hash_password_options', array( $this, 'increase_hash_cost' ) ); + $hash = wp_hash_password( $password, PASSWORD_BCRYPT ); + remove_filter( 'wp_hash_password_options', array( $this, 'increase_hash_cost' ) ); + + $this->assertTrue( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + $this->assertTrue( wp_password_needs_rehash( $hash ) ); + } + + /** + * @ticket 21022 + */ + public function test_wp_check_password_supports_wp_hash_with_default_bcrypt_cost() { + $password = 'password'; + + $hash = wp_hash_password( $password, PASSWORD_BCRYPT ); + + $this->assertTrue( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + $this->assertFalse( wp_password_needs_rehash( $hash ) ); + } + + /** + * @ticket 21022 + */ + public function test_wp_check_password_supports_plain_bcrypt_hash_with_default_bcrypt_cost() { + $password = 'password'; + + $hash = password_hash( $password, PASSWORD_BCRYPT ); + + $this->assertTrue( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + $this->assertTrue( wp_password_needs_rehash( $hash ) ); + } + + /** + * Ensure wp_check_password() is compatible with Argon2i hashes. + * + * @ticket 21022 + */ + public function test_wp_check_password_supports_argon2i_hash() { + if ( ! defined( 'PASSWORD_ARGON2I' ) ) { + $this->fail( 'Argon2i is not supported.' ); + } + + $password = 'password'; + $hash = password_hash( trim( $password ), PASSWORD_ARGON2I ); + $this->assertTrue( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + } + + /** + * Ensure wp_check_password() is compatible with Argon2id hashes. + * + * @requires PHP >= 7.3 + * + * @ticket 21022 + */ + public function test_wp_check_password_supports_argon2id_hash() { + if ( ! defined( 'PASSWORD_ARGON2ID' ) ) { + $this->fail( 'Argon2id is not supported.' ); + } + + $password = 'password'; + $hash = password_hash( trim( $password ), PASSWORD_ARGON2ID ); + $this->assertTrue( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + } + + /** + * @ticket 21022 + */ + public function test_wp_check_password_does_not_support_md5_hashes() { + $password = 'password'; + $hash = md5( $password ); + $this->assertFalse( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + } + + /** + * @ticket 21022 + */ + public function test_wp_check_password_does_not_support_plain_text() { + $password = 'password'; + $hash = $password; + $this->assertFalse( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + } + + /** + * @ticket 21022 + * + * @dataProvider data_empty_values + * @param mixed $value + */ + public function test_wp_check_password_does_not_support_empty_hash( $value ) { + $password = 'password'; + $hash = $value; + $this->assertFalse( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + } + + /** + * @ticket 21022 + * + * @dataProvider data_empty_values + * @param mixed $value + */ + public function test_wp_check_password_does_not_support_empty_password( $value ) { + $password = $value; + $hash = $value; + $this->assertFalse( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + } + + public function data_empty_values() { + return array( + // Integer zero: + array( 0 ), + // String zero: + array( '0' ), + // Zero-length string: + array( '' ), + // Null byte character: + array( "\0" ), + // Asterisk values: + array( '*' ), + array( '*0' ), + array( '*1' ), + ); + } + /** * @ticket 29217 */ @@ -235,51 +454,248 @@ public function test_check_ajax_referer_with_no_action_triggers_doing_it_wrong() unset( $_REQUEST['_wpnonce'] ); } - public function test_password_length_limit() { - $limit = str_repeat( 'a', 4096 ); + /** + * @ticket 21022 + */ + public function test_password_is_hashed_with_bcrypt() { + $password = 'password'; + + // Set the user password. + wp_set_password( $password, self::$user_id ); + + // Ensure the password is hashed with bcrypt. + $this->assertStringStartsWith( '$wp$2y$', get_userdata( self::$user_id )->user_pass ); + + // Authenticate. + $user = wp_authenticate( $this->user->user_login, $password ); + + // Verify correct password. + $this->assertNotWPError( $user ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertSame( self::$user_id, $user->ID ); + } + + /** + * @ticket 21022 + */ + public function test_invalid_password_at_bcrypt_length_limit_is_rejected() { + $limit = str_repeat( 'a', self::$bcrypt_length_limit ); + + // Set the user password to the bcrypt limit. + wp_set_password( $limit, self::$user_id ); + + $user = wp_authenticate( $this->user->user_login, 'aaaaaaaa' ); + // Wrong password. + $this->assertWPError( $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); + } + + /** + * @ticket 21022 + */ + public function test_invalid_password_beyond_bcrypt_length_limit_is_rejected() { + $limit = str_repeat( 'a', self::$bcrypt_length_limit + 1 ); + // Set the user password beyond the bcrypt limit. wp_set_password( $limit, self::$user_id ); - // phpass hashed password. - $this->assertStringStartsWith( '$P$', $this->user->data->user_pass ); $user = wp_authenticate( $this->user->user_login, 'aaaaaaaa' ); // Wrong password. + $this->assertWPError( $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); + } + + /** + * @ticket 21022 + */ + public function test_valid_password_at_bcrypt_length_limit_is_accepted() { + $limit = str_repeat( 'a', self::$bcrypt_length_limit ); + + // Set the user password to the bcrypt limit. + wp_set_password( $limit, self::$user_id ); + + // Authenticate. + $user = wp_authenticate( $this->user->user_login, $limit ); + + // Correct password. + $this->assertNotWPError( $user ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertSame( self::$user_id, $user->ID ); + } + + /** + * @ticket 21022 + */ + public function test_valid_password_beyond_bcrypt_length_limit_is_accepted() { + $limit = str_repeat( 'a', self::$bcrypt_length_limit + 1 ); + + // Set the user password beyond the bcrypt limit. + wp_set_password( $limit, self::$user_id ); + + // Authenticate. + $user = wp_authenticate( $this->user->user_login, $limit ); + + // Correct password depite its length. + $this->assertNotWPError( $user ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertSame( self::$user_id, $user->ID ); + } + + /** + * A password beyond 72 bytes will be truncated by bcrypt by default and still be accepted. + * + * This ensures that a truncated password is not accepted by WordPress. + * + * @ticket 21022 + */ + public function test_long_truncated_password_is_rejected() { + $at_limit = str_repeat( 'a', self::$bcrypt_length_limit ); + $beyond_limit = str_repeat( 'a', self::$bcrypt_length_limit + 1 ); + + // Set the user password beyond the bcrypt limit. + wp_set_password( $beyond_limit, self::$user_id ); + + // Authenticate using a truncated password. + $user = wp_authenticate( $this->user->user_login, $at_limit ); + + // Incorrect password. + $this->assertWPError( $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); + } + + /** + * @ticket 21022 + */ + public function test_setting_password_beyond_bcrypt_length_limit_is_rejected() { + $beyond_limit = str_repeat( 'a', self::$password_length_limit + 1 ); + + // Set the user password beyond the limit. + wp_set_password( $beyond_limit, self::$user_id ); + + // Password broken by setting it to be too long. + $user = get_user_by( 'id', self::$user_id ); + $this->assertSame( '*', $user->data->user_pass ); + + // Password is not accepted. + $user = wp_authenticate( $this->user->user_login, $beyond_limit ); + $this->assertInstanceOf( 'WP_Error', $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); + + // Placeholder is not accepted. + $user = wp_authenticate( $this->user->user_login, '*' ); $this->assertInstanceOf( 'WP_Error', $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); + } + /** + * @see https://core.trac.wordpress.org/changeset/30466 + */ + public function test_invalid_password_at_phpass_length_limit_is_rejected() { + $limit = str_repeat( 'a', self::$phpass_length_limit ); + + // Set the user password with the old phpass algorithm. + self::set_user_password_with_phpass( $limit, self::$user_id ); + + // Authenticate. + $user = wp_authenticate( $this->user->user_login, 'aaaaaaaa' ); + + // Wrong password. + $this->assertInstanceOf( 'WP_Error', $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); + } + + public function test_valid_password_at_phpass_length_limit_is_accepted() { + $limit = str_repeat( 'a', self::$phpass_length_limit ); + + // Set the user password with the old phpass algorithm. + self::set_user_password_with_phpass( $limit, self::$user_id ); + + // Authenticate. $user = wp_authenticate( $this->user->user_login, $limit ); + + // Correct password. + $this->assertNotWPError( $user ); $this->assertInstanceOf( 'WP_User', $user ); $this->assertSame( self::$user_id, $user->ID ); + } - // One char too many. + public function test_too_long_password_at_phpass_length_limit_is_rejected() { + $limit = str_repeat( 'a', self::$phpass_length_limit ); + + // Set the user password with the old phpass algorithm. + self::set_user_password_with_phpass( $limit, self::$user_id ); + + // Authenticate with a password that is one character too long. $user = wp_authenticate( $this->user->user_login, $limit . 'a' ); + // Wrong password. $this->assertInstanceOf( 'WP_Error', $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); + } + + public function test_too_long_password_beyond_phpass_length_limit_is_rejected() { + // One char too many. + $too_long = str_repeat( 'a', self::$phpass_length_limit + 1 ); + + // Set the user password with the old phpass algorithm. + self::set_user_password_with_phpass( $too_long, self::$user_id ); - wp_set_password( $limit . 'a', self::$user_id ); $user = get_user_by( 'id', self::$user_id ); // Password broken by setting it to be too long. $this->assertSame( '*', $user->data->user_pass ); + // Password is not accepted. $user = wp_authenticate( $this->user->user_login, '*' ); $this->assertInstanceOf( 'WP_Error', $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); + } + + /** + * @dataProvider data_empty_values + * @param mixed $value + */ + public function test_empty_password_is_rejected_by_bcrypt( $value ) { + // Set the user password. + wp_set_password( 'password', self::$user_id ); - $user = wp_authenticate( $this->user->user_login, '*0' ); + $user = wp_authenticate( $this->user->user_login, $value ); $this->assertInstanceOf( 'WP_Error', $user ); + } - $user = wp_authenticate( $this->user->user_login, '*1' ); + /** + * @dataProvider data_empty_values + * @param mixed $value + */ + public function test_empty_password_is_rejected_by_phpass( $value ) { + // Set the user password with the old phpass algorithm. + self::set_user_password_with_phpass( 'password', self::$user_id ); + + $user = wp_authenticate( $this->user->user_login, $value ); $this->assertInstanceOf( 'WP_Error', $user ); + } + + public function test_incorrect_password_is_rejected_by_phpass() { + // Set the user password with the old phpass algorithm. + self::set_user_password_with_phpass( 'password', self::$user_id ); $user = wp_authenticate( $this->user->user_login, 'aaaaaaaa' ); - // Wrong password. - $this->assertInstanceOf( 'WP_Error', $user ); - $user = wp_authenticate( $this->user->user_login, $limit ); // Wrong password. $this->assertInstanceOf( 'WP_Error', $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); + } + + public function test_too_long_password_is_rejected_by_phpass() { + $limit = str_repeat( 'a', self::$phpass_length_limit ); + + // Set the user password with the old phpass algorithm. + self::set_user_password_with_phpass( 'password', self::$user_id ); $user = wp_authenticate( $this->user->user_login, $limit . 'a' ); + // Password broken by setting it to be too long. $this->assertInstanceOf( 'WP_Error', $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); } /** @@ -306,7 +722,7 @@ public function test_user_activation_key_is_checked() { $wpdb->update( $wpdb->users, array( - 'user_activation_key' => strtotime( '-1 hour' ) . ':' . self::$wp_hasher->HashPassword( $key ), + 'user_activation_key' => strtotime( '-1 hour' ) . ':' . wp_fast_hash( $key ), ), array( 'ID' => $this->user->ID, @@ -344,7 +760,7 @@ public function test_expired_user_activation_key_is_rejected() { $wpdb->update( $wpdb->users, array( - 'user_activation_key' => strtotime( '-48 hours' ) . ':' . self::$wp_hasher->HashPassword( $key ), + 'user_activation_key' => strtotime( '-48 hours' ) . ':' . wp_fast_hash( $key ), ), array( 'ID' => $this->user->ID, @@ -355,6 +771,7 @@ public function test_expired_user_activation_key_is_rejected() { // An expired but otherwise valid key should be rejected. $check = check_password_reset_key( $key, $this->user->user_login ); $this->assertInstanceOf( 'WP_Error', $check ); + $this->assertSame( 'expired_key', $check->get_error_code() ); } /** @@ -393,10 +810,158 @@ public function test_legacy_user_activation_key_is_rejected() { // A legacy user_activation_key should not be accepted. $check = check_password_reset_key( $key, $this->user->user_login ); $this->assertInstanceOf( 'WP_Error', $check ); + $this->assertSame( 'expired_key', $check->get_error_code() ); // An empty key with a legacy user_activation_key should be rejected. $check = check_password_reset_key( '', $this->user->user_login ); $this->assertInstanceOf( 'WP_Error', $check ); + $this->assertSame( 'invalid_key', $check->get_error_code() ); + } + + /** + * @ticket 21022 + */ + public function test_phpass_user_activation_key_is_allowed() { + global $wpdb; + + // A legacy user_activation_key is one hashed using phpass between WordPress 4.3 and x.y.z. + + $key = wp_generate_password( 20, false ); + $wpdb->update( + $wpdb->users, + array( + 'user_activation_key' => strtotime( '-1 hour' ) . ':' . self::$wp_hasher->HashPassword( $key ), + ), + array( + 'ID' => $this->user->ID, + ) + ); + clean_user_cache( $this->user ); + + // A legacy phpass user_activation_key should remain valid. + $check = check_password_reset_key( $key, $this->user->user_login ); + $this->assertNotWPError( $check ); + $this->assertInstanceOf( 'WP_User', $check ); + $this->assertSame( $this->user->ID, $check->ID ); + + // An empty key with a legacy user_activation_key should be rejected. + $check = check_password_reset_key( '', $this->user->user_login ); + $this->assertWPError( $check ); + $this->assertSame( 'invalid_key', $check->get_error_code() ); + } + + /** + * @ticket 21022 + */ + public function test_expired_phpass_user_activation_key_is_rejected() { + global $wpdb; + + // A legacy user_activation_key is one hashed using phpass between WordPress 4.3 and x.y.z. + + $key = wp_generate_password( 20, false ); + $wpdb->update( + $wpdb->users, + array( + 'user_activation_key' => strtotime( '-48 hours' ) . ':' . self::$wp_hasher->HashPassword( $key ), + ), + array( + 'ID' => $this->user->ID, + ) + ); + clean_user_cache( $this->user ); + + // A legacy phpass user_activation_key should still be subject to an expiry check. + $check = check_password_reset_key( $key, $this->user->user_login ); + $this->assertWPError( $check ); + $this->assertSame( 'expired_key', $check->get_error_code() ); + + // An empty key with a legacy user_activation_key should be rejected. + $check = check_password_reset_key( '', $this->user->user_login ); + $this->assertWPError( $check ); + $this->assertSame( 'invalid_key', $check->get_error_code() ); + } + + /** + * @ticket 21022 + */ + public function test_user_request_key_handling() { + $request_id = wp_create_user_request( 'test@example.com', 'remove_personal_data' ); + $key = wp_generate_user_request_key( $request_id ); + + // A valid key should be accepted. + $check = wp_validate_user_request_key( $request_id, $key ); + $this->assertNotWPError( $check ); + $this->assertTrue( $check ); + + // An invalid key should rejected. + $check = wp_validate_user_request_key( $request_id, 'invalid' ); + $this->assertWPError( $check ); + $this->assertSame( 'invalid_key', $check->get_error_code() ); + + // An empty key should be rejected. + $check = wp_validate_user_request_key( $request_id, '' ); + $this->assertWPError( $check ); + $this->assertSame( 'missing_key', $check->get_error_code() ); + } + + /** + * @ticket 21022 + */ + public function test_phpass_user_request_key_is_allowed() { + // A legacy user request key is one hashed using phpass between WordPress 4.3 and x.y.z. + + $request_id = wp_create_user_request( 'test@example.com', 'remove_personal_data' ); + $key = wp_generate_password( 20, false ); + + wp_update_post( + array( + 'ID' => $request_id, + 'post_password' => self::$wp_hasher->HashPassword( $key ), + ) + ); + + // A legacy phpass key should remain valid. + $check = wp_validate_user_request_key( $request_id, $key ); + $this->assertNotWPError( $check ); + $this->assertTrue( $check ); + + // An empty key with a legacy key should be rejected. + $check = wp_validate_user_request_key( $request_id, '' ); + $this->assertWPError( $check ); + $this->assertSame( 'missing_key', $check->get_error_code() ); + } + + /** + * The `wp_password_needs_rehash()` function is just a wrapper around `password_needs_rehash()`, but this ensures + * that it works as expected. + * + * Notably the bcrypt cost was increased in PHP 8.4: https://wiki.php.net/rfc/bcrypt_cost_2023 . + * + * @ticket 21022 + */ + public function check_password_needs_rehashing() { + $password = 'password'; + + // Current password hashing algorithm. + $hash = wp_hash_password( $password ); + $this->assertFalse( wp_password_needs_rehash( $hash ) ); + + // A future upgrade from a previously lower cost. + $default = self::get_default_bcrypt_cost(); + $opts = array( + // Reducing the cost mimics an increase in the default cost. + 'cost' => $default - 1, + ); + $hash = password_hash( $password, PASSWORD_BCRYPT, $opts ); + $this->assertTrue( wp_password_needs_rehash( $hash ) ); + + // Previous phpass algorithm. + $hash = self::$wp_hasher->HashPassword( $password ); + $this->assertTrue( wp_password_needs_rehash( $hash ) ); + + // o_O md5. + $hash = md5( $password ); + $this->assertTrue( wp_password_needs_rehash( $hash ) ); } /** @@ -457,6 +1022,206 @@ public function test_user_activation_key_after_successful_login() { $this->assertEmpty( $activation_key_from_database, 'The `user_activation_key` was not empty in the database.' ); } + /** + * @ticket 21022 + */ + public function test_phpass_application_password_is_accepted() { + add_filter( 'application_password_is_api_request', '__return_true' ); + add_filter( 'wp_is_application_passwords_available', '__return_true' ); + + $password = 'password'; + + // Set an application password with the old phpass algorithm. + $uuid = self::set_application_password_with_phpass( $password, self::$user_id ); + + // Authenticate. + $user = wp_authenticate_application_password( null, self::USER_LOGIN, $password ); + + // Verify that the phpass hash for the application password was valid. + $this->assertNotWPError( $user ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertSame( self::$user_id, $user->ID ); + } + + /** + * @dataProvider data_usernames + * + * @ticket 21022 + */ + public function test_phpass_password_is_rehashed_after_successful_user_password_authentication( $username_or_email ) { + $password = 'password'; + + // Set the user password with the old phpass algorithm. + self::set_user_password_with_phpass( $password, self::$user_id ); + + // Verify that the password needs rehashing. + $hash = get_userdata( self::$user_id )->user_pass; + $this->assertTrue( wp_password_needs_rehash( $hash, self::$user_id ) ); + + // Authenticate. + $user = wp_authenticate( $username_or_email, $password ); + + // Verify that the phpass password hash was valid. + $this->assertNotWPError( $user ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertSame( self::$user_id, $user->ID ); + + // Verify that the password no longer needs rehashing. + $hash = get_userdata( self::$user_id )->user_pass; + $this->assertFalse( wp_password_needs_rehash( $hash, self::$user_id ) ); + + // Authenticate a second time to ensure the new hash is valid. + $user = wp_authenticate( $username_or_email, $password ); + + // Verify that the bcrypt password hash is valid. + $this->assertNotWPError( $user ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertSame( self::$user_id, $user->ID ); + } + + /** + * @dataProvider data_usernames + * + * @ticket 21022 + */ + public function test_bcrypt_password_is_rehashed_with_new_cost_after_successful_user_password_authentication( $username_or_email ) { + $password = 'password'; + + // Hash the user password with a lower cost than default to mimic a cost upgrade. + add_filter( 'wp_hash_password_options', array( $this, 'reduce_hash_cost' ) ); + wp_set_password( $password, self::$user_id ); + remove_filter( 'wp_hash_password_options', array( $this, 'reduce_hash_cost' ) ); + + // Verify that the password needs rehashing. + $hash = get_userdata( self::$user_id )->user_pass; + $this->assertTrue( wp_password_needs_rehash( $hash, self::$user_id ) ); + + // Authenticate. + $user = wp_authenticate( $username_or_email, $password ); + + // Verify that the reduced cost password hash was valid. + $this->assertNotWPError( $user ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertSame( self::$user_id, $user->ID ); + + // Verify that the password has been rehashed with the increased cost. + $hash = get_userdata( self::$user_id )->user_pass; + $this->assertFalse( wp_password_needs_rehash( $hash, self::$user_id ) ); + $this->assertSame( self::get_default_bcrypt_cost(), password_get_info( substr( $hash, 3 ) )['options']['cost'] ); + + // Authenticate a second time to ensure the new hash is valid. + $user = wp_authenticate( $username_or_email, $password ); + + // Verify that the password hash is valid. + $this->assertNotWPError( $user ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertSame( self::$user_id, $user->ID ); + } + + public function reduce_hash_cost( array $options ): array { + $options['cost'] = self::get_default_bcrypt_cost() - 1; + return $options; + } + + public function increase_hash_cost( array $options ): array { + $options['cost'] = self::get_default_bcrypt_cost() + 1; + return $options; + } + + public function data_usernames() { + return array( + array( + self::USER_LOGIN, + ), + array( + self::USER_EMAIL, + ), + ); + } + + /** + * @ticket 21022 + */ + public function test_password_rehashing_requirement_can_be_filtered() { + $filter_count_before = did_filter( 'password_needs_rehash' ); + + wp_password_needs_rehash( '$hash' ); + + $this->assertSame( $filter_count_before + 1, did_filter( 'password_needs_rehash' ) ); + } + + /** + * @ticket 21022 + */ + public function test_password_hashing_algorithm_can_be_filtered() { + $password = 'password'; + + $filter_count_before = did_filter( 'wp_hash_password_algorithm' ); + + $wp_hash = wp_hash_password( $password ); + + wp_check_password( $password, $wp_hash ); + wp_password_needs_rehash( $wp_hash ); + + $this->assertSame( $filter_count_before + 2, did_filter( 'wp_hash_password_algorithm' ) ); + } + + /** + * @ticket 21022 + */ + public function test_password_hashing_options_can_be_filtered() { + $password = 'password'; + + add_filter( + 'wp_hash_password_options', + static function ( $options ) { + $options['cost'] = 5; + return $options; + } + ); + + $filter_count_before = did_filter( 'wp_hash_password_options' ); + + $wp_hash = wp_hash_password( $password ); + $valid = wp_check_password( $password, $wp_hash ); + $needs_rehash = wp_password_needs_rehash( $wp_hash ); + $info = password_get_info( substr( $wp_hash, 3 ) ); + $cost = $info['options']['cost']; + + $this->assertTrue( $valid ); + $this->assertFalse( $needs_rehash ); + $this->assertSame( $filter_count_before + 2, did_filter( 'wp_hash_password_options' ) ); + $this->assertSame( 5, $cost ); + } + + /** + * @ticket 21022 + */ + public function test_password_checks_support_wp_hasher_fallback() { + global $wp_hasher; + + $filter_count_before = did_filter( 'wp_hash_password_options' ); + + $password = 'password'; + + // Ensure the global $wp_hasher is set. + $wp_hasher = new WP_Fake_Hasher(); + + $hasher_hash = $wp_hasher->HashPassword( $password ); + $wp_hash = wp_hash_password( $password ); + $valid = wp_check_password( $password, $wp_hash ); + $needs_rehash = wp_password_needs_rehash( $wp_hash ); + + // Reset the global $wp_hasher. + $wp_hasher = null; + + $this->assertSame( $hasher_hash, $wp_hash ); + $this->assertTrue( $valid ); + $this->assertFalse( $needs_rehash ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + $this->assertSame( $filter_count_before, did_filter( 'wp_hash_password_options' ) ); + } + /** * Ensure users can log in using both their username and their email address. * @@ -703,7 +1468,9 @@ public function test_application_password_authentication() { * @ticket 42790 */ public function test_authenticate_application_password_respects_existing_user() { - $this->assertSame( self::$_user, wp_authenticate_application_password( self::$_user, self::$_user->user_login, 'password' ) ); + $user = wp_authenticate_application_password( self::$_user, self::$_user->user_login, 'password' ); + $this->assertNotWPError( $user ); + $this->assertSame( self::$_user, $user ); } /** @@ -712,7 +1479,9 @@ public function test_authenticate_application_password_respects_existing_user() public function test_authenticate_application_password_is_rejected_if_not_api_request() { add_filter( 'application_password_is_api_request', '__return_false' ); - $this->assertNull( wp_authenticate_application_password( null, self::$_user->user_login, 'password' ) ); + $user = wp_authenticate_application_password( null, self::$_user->user_login, 'password' ); + $this->assertNotWPError( $user ); + $this->assertNull( $user ); } /** @@ -805,6 +1574,7 @@ public function test_authenticate_application_password_by_username() { list( $password ) = WP_Application_Passwords::create_new_application_password( self::$user_id, array( 'name' => 'phpunit' ) ); $user = wp_authenticate_application_password( null, self::$_user->user_login, $password ); + $this->assertNotWPError( $user ); $this->assertInstanceOf( WP_User::class, $user ); $this->assertSame( self::$user_id, $user->ID ); } @@ -819,6 +1589,7 @@ public function test_authenticate_application_password_by_email() { list( $password ) = WP_Application_Passwords::create_new_application_password( self::$user_id, array( 'name' => 'phpunit' ) ); $user = wp_authenticate_application_password( null, self::$_user->user_email, $password ); + $this->assertNotWPError( $user ); $this->assertInstanceOf( WP_User::class, $user ); $this->assertSame( self::$user_id, $user->ID ); } @@ -833,6 +1604,7 @@ public function test_authenticate_application_password_chunked() { list( $password ) = WP_Application_Passwords::create_new_application_password( self::$user_id, array( 'name' => 'phpunit' ) ); $user = wp_authenticate_application_password( null, self::$_user->user_email, WP_Application_Passwords::chunk_password( $password ) ); + $this->assertNotWPError( $user ); $this->assertInstanceOf( WP_User::class, $user ); $this->assertSame( self::$user_id, $user->ID ); } @@ -844,6 +1616,7 @@ public function test_authenticate_application_password_returns_null_if_not_in_us delete_site_option( 'using_application_passwords' ); $authenticated = wp_authenticate_application_password( null, 'idonotexist', 'password' ); + $this->assertNotWPError( $authenticated ); $this->assertNull( $authenticated ); } @@ -967,4 +1740,117 @@ public function tests_basic_http_authentication_with_colon_in_password() { $this->assertSame( $_SERVER['PHP_AUTH_USER'], 'username' ); $this->assertSame( $_SERVER['PHP_AUTH_PW'], 'pass:word' ); } + + /** + * Test the tests + * + * @covers Tests_Auth::set_user_password_with_phpass + * + * @ticket 21022 + */ + public function test_set_user_password_with_phpass() { + // Set the user password with the old phpass algorithm. + self::set_user_password_with_phpass( 'password', self::$user_id ); + + // Ensure the password is hashed with phpass. + $hash = get_userdata( self::$user_id )->user_pass; + $this->assertStringStartsWith( '$P$', $hash ); + } + + private static function set_user_password_with_phpass( string $password, int $user_id ) { + global $wpdb; + + $wpdb->update( + $wpdb->users, + array( + 'user_pass' => self::$wp_hasher->HashPassword( $password ), + ), + array( + 'ID' => $user_id, + ) + ); + clean_user_cache( $user_id ); + } + + + /** + * Test the tests + * + * @covers Tests_Auth::set_user_password_with_plain_bcrypt + * + * @ticket 21022 + */ + public function test_set_user_password_with_plain_bcrypt() { + // Set the user password with plain bcrypt. + self::set_user_password_with_plain_bcrypt( 'password', self::$user_id ); + + // Ensure the password is hashed with bcrypt. + $hash = get_userdata( self::$user_id )->user_pass; + $this->assertStringStartsWith( '$2y$', $hash ); + } + + private static function set_user_password_with_plain_bcrypt( string $password, int $user_id ) { + global $wpdb; + + $wpdb->update( + $wpdb->users, + array( + 'user_pass' => password_hash( 'password', PASSWORD_BCRYPT ), + ), + array( + 'ID' => $user_id, + ) + ); + clean_user_cache( $user_id ); + } + + /** + * Test the tests + * + * @covers Tests_Auth::set_application_password_with_phpass + * + * @ticket 21022 + */ + public function test_set_application_password_with_phpass() { + // Set an application password with the old phpass algorithm. + $uuid = self::set_application_password_with_phpass( 'password', self::$user_id ); + + // Ensure the password is hashed with phpass. + $hash = WP_Application_Passwords::get_user_application_password( self::$user_id, $uuid )['password']; + $this->assertStringStartsWith( '$P$', $hash ); + } + + private static function set_application_password_with_phpass( string $password, int $user_id ) { + $uuid = wp_generate_uuid4(); + $item = array( + 'uuid' => $uuid, + 'app_id' => '', + 'name' => 'Test', + 'password' => self::$wp_hasher->HashPassword( $password ), + 'created' => time(), + 'last_used' => null, + 'last_ip' => null, + ); + + $saved = update_user_meta( + $user_id, + WP_Application_Passwords::USERMETA_KEY_APPLICATION_PASSWORDS, + array( $item ) + ); + + if ( ! $saved ) { + throw new Exception( 'Could not save application password.' ); + } + + update_network_option( get_main_network_id(), WP_Application_Passwords::OPTION_KEY_IN_USE, true ); + + return $uuid; + } + + private static function get_default_bcrypt_cost(): int { + $hash = password_hash( 'password', PASSWORD_BCRYPT ); + $info = password_get_info( $hash ); + + return $info['options']['cost']; + } } diff --git a/tests/phpunit/tests/functions/wpVerifyFastHash.php b/tests/phpunit/tests/functions/wpVerifyFastHash.php new file mode 100644 index 0000000000000..56413873a466b --- /dev/null +++ b/tests/phpunit/tests/functions/wpVerifyFastHash.php @@ -0,0 +1,57 @@ +assertTrue( wp_verify_fast_hash( $password, $hash ) ); + } + + /** + * @ticket 21022 + */ + public function test_wp_verify_fast_hash_fails_unprefixed_hash() { + $password = 'password'; + + $hash = wp_fast_hash( $password ); + + $this->assertFalse( wp_verify_fast_hash( $password, substr( $hash, 9 ) ) ); + } + + /** + * @ticket 21022 + */ + public function test_wp_verify_fast_hash_fails_partial_hash() { + $password = 'password'; + + $hash = wp_fast_hash( $password ); + + $this->assertFalse( wp_verify_fast_hash( $password, substr( $hash, 0, -3 ) ) ); + } + + /** + * @ticket 21022 + */ + public function test_wp_verify_fast_hash_verifies_phpass_hash() { + require_once ABSPATH . WPINC . '/class-phpass.php'; + + $password = 'password'; + + $hash = ( new PasswordHash( 8, true ) )->HashPassword( $password ); + + $this->assertTrue( wp_verify_fast_hash( $password, $hash ) ); + } +} diff --git a/tests/phpunit/tests/pluggable/signatures.php b/tests/phpunit/tests/pluggable/signatures.php index 8ac1dfb6966b6..3919e31232979 100644 --- a/tests/phpunit/tests/pluggable/signatures.php +++ b/tests/phpunit/tests/pluggable/signatures.php @@ -217,6 +217,10 @@ public function get_pluggable_function_signatures() { 'hash', 'user_id' => '', ), + 'wp_password_needs_rehash' => array( + 'hash', + 'user_id' => '', + ), 'wp_generate_password' => array( 'length' => 12, 'special_chars' => true, diff --git a/tests/phpunit/tests/post/postPasswordRequired.php b/tests/phpunit/tests/post/postPasswordRequired.php new file mode 100644 index 0000000000000..8ff4e140eece6 --- /dev/null +++ b/tests/phpunit/tests/post/postPasswordRequired.php @@ -0,0 +1,54 @@ +post->create( + array( + 'post_password' => $password, + ) + ); + + // Password is required: + $this->assertTrue( post_password_required( $post_id ) ); + } + + public function test_post_password_not_required_with_valid_cookie() { + $password = 'password'; + + // Create a post with a password: + $post_id = self::factory()->post->create( + array( + 'post_password' => $password, + ) + ); + + // Set the cookie with the phpass hash: + $_COOKIE[ 'wp-postpass_' . COOKIEHASH ] = self::$wp_hasher->HashPassword( $password ); + + // Check if the password is required: + $required = post_password_required( $post_id ); + + // Clear the cookie: + unset( $_COOKIE[ 'wp-postpass_' . COOKIEHASH ] ); + + // Password is not required: + $this->assertFalse( $required ); + } +} diff --git a/tests/phpunit/tests/user/passwordHash.php b/tests/phpunit/tests/user/passwordHash.php index db34969c71bb3..c09fd91691efd 100644 --- a/tests/phpunit/tests/user/passwordHash.php +++ b/tests/phpunit/tests/user/passwordHash.php @@ -3,6 +3,10 @@ /** * Tests for the PasswordHash external library. * + * PasswordHash is no longer used to hash user passwords or security keys, but it is still used to + * hash post passwords and as a fallback to verify old passwords that were hashed by phpass. The + * library therefore needs to remain compatible with the latest versions of PHP. + * * @covers PasswordHash */ class Tests_User_PasswordHash extends WP_UnitTestCase {