Make WordPress Core

Changeset 59828


Ignore:
Timestamp:
02/17/2025 11:22:33 AM (2 months ago)
Author:
johnbillion
Message:

Security: Switch to using bcrypt for hashing user passwords and BLAKE2b for hashing application passwords and security keys.

Passwords and security keys that were saved in prior versions of WordPress will continue to work. Each user's password will be opportunistically rehashed and resaved when they next subsequently log in using a valid password.

The following new functions have been introduced:

  • wp_password_needs_rehash()
  • wp_fast_hash()
  • wp_verify_fast_hash()

The following new filters have been introduced:

  • password_needs_rehash
  • wp_hash_password_algorithm
  • wp_hash_password_options

Props ayeshrajans, bgermann, dd32, deadduck169, desrosj, haozi, harrym, iandunn, jammycakes, joehoyle, johnbillion, mbijon, mojorob, mslavco, my1xt, nacin, otto42, paragoninitiativeenterprises, paulkevan, rmccue, ryanhellyer, scribu, swalkinshaw, synchro, th23, timothyblynjacobs, tomdxw, westi, xknown.

Additional thanks go to the Roots team, Soatok, Calvin Alkan, and Raphael Ahrens.

Fixes #21022, #44628

Location:
trunk
Files:
1 added
12 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-admin/includes/upgrade.php

    r59754 r59828  
    981981 * @ignore
    982982 * @since 1.2.0
     983 * @since 6.8.0 User passwords are no longer hashed with md5.
    983984 *
    984985 * @global wpdb $wpdb WordPress database abstraction object.
     
    993994            $newname = sanitize_title( $user->user_nickname );
    994995            $wpdb->update( $wpdb->users, array( 'user_nicename' => $newname ), array( 'ID' => $user->ID ) );
    995         }
    996     }
    997 
    998     $users = $wpdb->get_results( "SELECT ID, user_pass from $wpdb->users" );
    999     foreach ( $users as $row ) {
    1000         if ( ! preg_match( '/^[A-Fa-f0-9]{32}$/', $row->user_pass ) ) {
    1001             $wpdb->update( $wpdb->users, array( 'user_pass' => md5( $row->user_pass ) ), array( 'ID' => $row->ID ) );
    1002996        }
    1003997    }
  • trunk/src/wp-includes/class-wp-application-passwords.php

    r59754 r59828  
    6161     * @since 5.6.0
    6262     * @since 5.7.0 Returns WP_Error if application name already exists.
     63     * @since 6.8.0 The hashed password value now uses wp_fast_hash() instead of phpass.
    6364     *
    6465     * @param int   $user_id  User ID.
     
    9697
    9798        $new_password    = wp_generate_password( static::PW_LENGTH, false );
    98         $hashed_password = wp_hash_password( $new_password );
     99        $hashed_password = self::hash_password( $new_password );
    99100
    100101        $new_item = array(
     
    125126         *
    126127         * @since 5.6.0
     128         * @since 6.8.0 The hashed password value now uses wp_fast_hash() instead of phpass.
    127129         *
    128130         * @param int    $user_id      The user ID.
     
    250252     *
    251253     * @since 5.6.0
     254     * @since 6.8.0 The actual password should now be hashed using wp_fast_hash().
    252255     *
    253256     * @param int    $user_id User ID.
     
    297300             *
    298301             * @since 5.6.0
     302             * @since 6.8.0 The password is now hashed using wp_fast_hash() instead of phpass.
     303             *              Existing passwords may still be hashed using phpass.
    299304             *
    300305             * @param int   $user_id The user ID.
     
    468473        return trim( chunk_split( $raw_password, 4, ' ' ) );
    469474    }
     475
     476    /**
     477     * Hashes a plaintext application password.
     478     *
     479     * @since 6.8.0
     480     *
     481     * @param string $password Plaintext password.
     482     * @return string Hashed password.
     483     */
     484    public static function hash_password(
     485        #[\SensitiveParameter]
     486        string $password
     487    ): string {
     488        return wp_fast_hash( $password );
     489    }
     490
     491    /**
     492     * Checks a plaintext application password against a hashed password.
     493     *
     494     * @since 6.8.0
     495     *
     496     * @param string $password Plaintext password.
     497     * @param string $hash     Hash of the password to check against.
     498     * @return bool Whether the password matches the hashed password.
     499     */
     500    public static function check_password(
     501        #[\SensitiveParameter]
     502        string $password,
     503        string $hash
     504    ): bool {
     505        return wp_verify_fast_hash( $password, $hash );
     506    }
    470507}
  • trunk/src/wp-includes/class-wp-recovery-mode-key-service.php

    r58975 r59828  
    3838     *
    3939     * @since 5.2.0
    40      *
    41      * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance.
     40     * @since 6.8.0 The stored key is now hashed using wp_fast_hash() instead of phpass.
    4241     *
    4342     * @param string $token A token generated by {@see generate_recovery_mode_token()}.
     
    4544     */
    4645    public function generate_and_store_recovery_mode_key( $token ) {
    47 
    48         global $wp_hasher;
    49 
    5046        $key = wp_generate_password( 22, false );
    51 
    52         if ( empty( $wp_hasher ) ) {
    53             require_once ABSPATH . WPINC . '/class-phpass.php';
    54             $wp_hasher = new PasswordHash( 8, true );
    55         }
    56 
    57         $hashed = $wp_hasher->HashPassword( $key );
    5847
    5948        $records = $this->get_keys();
    6049
    6150        $records[ $token ] = array(
    62             'hashed_key' => $hashed,
     51            'hashed_key' => wp_fast_hash( $key ),
    6352            'created_at' => time(),
    6453        );
     
    8675     * @since 5.2.0
    8776     *
    88      * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance.
    89      *
    9077     * @param string $token The token used when generating the given key.
    91      * @param string $key   The unhashed key.
     78     * @param string $key   The plain text key.
    9279     * @param int    $ttl   Time in seconds for the key to be valid for.
    9380     * @return true|WP_Error True on success, error object on failure.
    9481     */
    9582    public function validate_recovery_mode_key( $token, $key, $ttl ) {
    96         global $wp_hasher;
    97 
    9883        $records = $this->get_keys();
    9984
     
    11095        }
    11196
    112         if ( empty( $wp_hasher ) ) {
    113             require_once ABSPATH . WPINC . '/class-phpass.php';
    114             $wp_hasher = new PasswordHash( 8, true );
    115         }
    116 
    117         if ( ! $wp_hasher->CheckPassword( $key, $record['hashed_key'] ) ) {
     97        if ( ! wp_verify_fast_hash( $key, $record['hashed_key'] ) ) {
    11898            return new WP_Error( 'hash_mismatch', __( 'Invalid recovery key.' ) );
    11999        }
     
    170150     *
    171151     * @since 5.2.0
     152     * @since 6.8.0 Each key is now hashed using wp_fast_hash() instead of phpass.
     153     *              Existing keys may still be hashed using phpass.
    172154     *
    173      * @return array Associative array of $token => $data pairs, where $data has keys 'hashed_key'
    174      *               and 'created_at'.
     155     * @return array {
     156     *     Associative array of token => data pairs, where the data is an associative
     157     *     array of information about the key.
     158     *
     159     *     @type array ...$0 {
     160     *         Information about the key.
     161     *
     162     *         @type string $hashed_key The hashed value of the key.
     163     *         @type int    $created_at The timestamp when the key was created.
     164     *     }
     165     * }
    175166     */
    176167    private function get_keys() {
     
    182173     *
    183174     * @since 5.2.0
     175     * @since 6.8.0 Each key should now be hashed using wp_fast_hash() instead of phpass.
    184176     *
    185      * @param array $keys Associative array of $token => $data pairs, where $data has keys 'hashed_key'
    186      *                    and 'created_at'.
     177     * @param array $keys {
     178     *     Associative array of token => data pairs, where the data is an associative
     179     *     array of information about the key.
     180     *
     181     *     @type array ...$0 {
     182     *         Information about the key.
     183     *
     184     *         @type string $hashed_key The hashed value of the key.
     185     *         @type int    $created_at The timestamp when the key was created.
     186     *     }
     187     * }
    187188     * @return bool True on success, false on failure.
    188189     */
  • trunk/src/wp-includes/class-wp-user-request.php

    r54133 r59828  
    9393     *
    9494     * @since 4.9.6
     95     * @since 6.8.0 The key is now hashed using wp_fast_hash() instead of phpass.
     96     *
    9597     * @var string
    9698     */
  • trunk/src/wp-includes/class-wp-user.php

    r57644 r59828  
    1212 *
    1313 * @since 2.0.0
     14 * @since 6.8.0 The `user_pass` property is now hashed using bcrypt instead of phpass.
    1415 *
    1516 * @property string $nickname
  • trunk/src/wp-includes/functions.php

    r59790 r59828  
    91159115    return in_array( $mime_type, $heic_mime_types, true );
    91169116}
     9117
     9118/**
     9119 * Returns a cryptographically secure hash of a message using a fast generic hash function.
     9120 *
     9121 * Use the wp_verify_fast_hash() function to verify the hash.
     9122 *
     9123 * This function does not salt the value prior to being hashed, therefore input to this function must originate from
     9124 * a random generator with sufficiently high entropy, preferably greater than 128 bits. This function is used internally
     9125 * in WordPress to hash security keys and application passwords which are generated with high entropy.
     9126 *
     9127 * Important:
     9128 *
     9129 *  - This function must not be used for hashing user-generated passwords. Use wp_hash_password() for that.
     9130 *  - This function must not be used for hashing other low-entropy input. Use wp_hash() for that.
     9131 *
     9132 * The BLAKE2b algorithm is used by Sodium to hash the message.
     9133 *
     9134 * @since 6.8.0
     9135 *
     9136 * @throws TypeError Thrown by Sodium if the message is not a string.
     9137 *
     9138 * @param string $message The message to hash.
     9139 * @return string The hash of the message.
     9140 */
     9141function wp_fast_hash(
     9142    #[\SensitiveParameter]
     9143    string $message
     9144): string {
     9145    return '$generic$' . sodium_bin2hex( sodium_crypto_generichash( $message ) );
     9146}
     9147
     9148/**
     9149 * Checks whether a plaintext message matches the hashed value. Used to verify values hashed via wp_fast_hash().
     9150 *
     9151 * The function uses Sodium to hash the message and compare it to the hashed value. If the hash is not a generic hash,
     9152 * the hash is treated as a phpass portable hash in order to provide backward compatibility for application passwords
     9153 * which were hashed using phpass prior to WordPress 6.8.0.
     9154 *
     9155 * @since 6.8.0
     9156 *
     9157 * @throws TypeError Thrown by Sodium if the message is not a string.
     9158 *
     9159 * @param string $message The plaintext message.
     9160 * @param string $hash    Hash of the message to check against.
     9161 * @return bool Whether the message matches the hashed message.
     9162 */
     9163function wp_verify_fast_hash(
     9164    #[\SensitiveParameter]
     9165    string $message,
     9166    string $hash
     9167): bool {
     9168    if ( ! str_starts_with( $hash, '$generic$' ) ) {
     9169        // Back-compat for old phpass hashes.
     9170        require_once ABSPATH . WPINC . '/class-phpass.php';
     9171        return ( new PasswordHash( 8, true ) )->CheckPassword( $message, $hash );
     9172    }
     9173
     9174    return hash_equals( $hash, wp_fast_hash( $message ) );
     9175}
  • trunk/src/wp-includes/pluggable.php

    r59803 r59828  
    694694     * @param string $cookie Optional. If used, will validate contents instead of cookie's.
    695695     * @param string $scheme Optional. The cookie scheme to use: 'auth', 'secure_auth', or 'logged_in'.
     696     *                       Note: This does *not* default to 'auth' like other cookie functions.
    696697     * @return int|false User ID if valid cookie, false if invalid.
    697698     */
     
    769770        }
    770771
    771         $pass_frag = substr( $user->user_pass, 8, 4 );
     772        if ( str_starts_with( $user->user_pass, '$P$' ) || str_starts_with( $user->user_pass, '$2y$' ) ) {
     773            // Retain previous behaviour of phpass or vanilla bcrypt hashed passwords.
     774            $pass_frag = substr( $user->user_pass, 8, 4 );
     775        } else {
     776            // Otherwise, use a substring from the end of the hash to avoid dealing with potentially long hash prefixes.
     777            $pass_frag = substr( $user->user_pass, -4 );
     778        }
    772779
    773780        $key = wp_hash( $username . '|' . $pass_frag . '|' . $expiration . '|' . $token, $scheme );
     
    870877        }
    871878
    872         $pass_frag = substr( $user->user_pass, 8, 4 );
     879        if ( str_starts_with( $user->user_pass, '$P$' ) || str_starts_with( $user->user_pass, '$2y$' ) ) {
     880            // Retain previous behaviour of phpass or vanilla bcrypt hashed passwords.
     881            $pass_frag = substr( $user->user_pass, 8, 4 );
     882        } else {
     883            // Otherwise, use a substring from the end of the hash to avoid dealing with potentially long hash prefixes.
     884            $pass_frag = substr( $user->user_pass, -4 );
     885        }
    873886
    874887        $key = wp_hash( $user->user_login . '|' . $pass_frag . '|' . $expiration . '|' . $token, $scheme );
     
    26262639     *
    26272640     * @since 2.5.0
    2628      *
    2629      * @global PasswordHash $wp_hasher PHPass object.
     2641     * @since 6.8.0 The password is now hashed using bcrypt by default instead of phpass.
     2642     *
     2643     * @global PasswordHash $wp_hasher phpass object.
    26302644     *
    26312645     * @param string $password Plain text user password to hash.
     
    26382652        global $wp_hasher;
    26392653
    2640         if ( empty( $wp_hasher ) ) {
    2641             require_once ABSPATH . WPINC . '/class-phpass.php';
    2642             // By default, use the portable hash from phpass.
    2643             $wp_hasher = new PasswordHash( 8, true );
    2644         }
    2645 
    2646         return $wp_hasher->HashPassword( trim( $password ) );
     2654        if ( ! empty( $wp_hasher ) ) {
     2655            return $wp_hasher->HashPassword( trim( $password ) );
     2656        }
     2657
     2658        if ( strlen( $password ) > 4096 ) {
     2659            return '*';
     2660        }
     2661
     2662        /**
     2663         * Filters the hashing algorithm to use in the password_hash() and password_needs_rehash() functions.
     2664         *
     2665         * The default is the value of the `PASSWORD_BCRYPT` constant which means bcrypt is used.
     2666         *
     2667         * **Important:** The only password hashing algorithm that is guaranteed to be available across PHP
     2668         * installations is bcrypt. If you use any other algorithm you must make sure that it is available on
     2669         * the server. The `password_algos()` function can be used to check which hashing algorithms are available.
     2670         *
     2671         * The hashing options can be controlled via the {@see 'wp_hash_password_options'} filter.
     2672         *
     2673         * Other available constants include:
     2674         *
     2675         * - `PASSWORD_ARGON2I`
     2676         * - `PASSWORD_ARGON2ID`
     2677         * - `PASSWORD_DEFAULT`
     2678         *
     2679         * @since 6.8.0
     2680         *
     2681         * @param string $algorithm The hashing algorithm. Default is the value of the `PASSWORD_BCRYPT` constant.
     2682         */
     2683        $algorithm = apply_filters( 'wp_hash_password_algorithm', PASSWORD_BCRYPT );
     2684
     2685        /**
     2686         * Filters the options passed to the password_hash() and password_needs_rehash() functions.
     2687         *
     2688         * The default hashing algorithm is bcrypt, but this can be changed via the {@see 'wp_hash_password_algorithm'}
     2689         * filter. You must ensure that the options are appropriate for the algorithm in use.
     2690         *
     2691         * @since 6.8.0
     2692         *
     2693         * @param array $options    Array of options to pass to the password hashing functions.
     2694         *                          By default this is an empty array which means the default
     2695         *                          options will be used.
     2696         * @param string $algorithm The hashing algorithm in use.
     2697         */
     2698        $options = apply_filters( 'wp_hash_password_options', array(), $algorithm );
     2699
     2700        // Algorithms other than bcrypt don't need to use pre-hashing.
     2701        if ( PASSWORD_BCRYPT !== $algorithm ) {
     2702            return password_hash( $password, $algorithm, $options );
     2703        }
     2704
     2705        // Use SHA-384 to retain entropy from a password that's longer than 72 bytes, and a `wp-sha384` key for domain separation.
     2706        $password_to_hash = base64_encode( hash_hmac( 'sha384', trim( $password ), 'wp-sha384', true ) );
     2707
     2708        // Add a prefix to facilitate distinguishing vanilla bcrypt hashes.
     2709        return '$wp' . password_hash( $password_to_hash, $algorithm, $options );
    26472710    }
    26482711endif;
     
    26522715     * Checks a plaintext password against a hashed password.
    26532716     *
    2654      * Maintains compatibility between old version and the new cookie authentication
    2655      * protocol using PHPass library. The $hash parameter is the encrypted password
    2656      * and the function compares the plain text password when encrypted similarly
    2657      * against the already encrypted password to see if they match.
     2717     * Note that this function may be used to check a value that is not a user password.
     2718     * A plugin may use this function to check a password of a different type, and there
     2719     * may not always be a user ID associated with the password.
    26582720     *
    26592721     * For integration with other applications, this function can be overwritten to
     
    26612723     *
    26622724     * @since 2.5.0
    2663      *
    2664      * @global PasswordHash $wp_hasher PHPass object used for checking the password
    2665      *                                 against the $hash + $password.
    2666      * @uses PasswordHash::CheckPassword
    2667      *
    2668      * @param string     $password Plaintext user's password.
    2669      * @param string     $hash     Hash of the user's password to check against.
    2670      * @param string|int $user_id  Optional. User ID.
     2725     * @since 6.8.0 Passwords in WordPress are now hashed with bcrypt by default. A
     2726     *              password that wasn't hashed with bcrypt will be checked with phpass.
     2727     *              Passwords hashed with md5 are no longer supported.
     2728     *
     2729     * @global PasswordHash $wp_hasher phpass object. Used as a fallback for verifying
     2730     *                                 passwords that were hashed with phpass.
     2731     *
     2732     * @param string     $password Plaintext password.
     2733     * @param string     $hash     Hash of the password to check against.
     2734     * @param string|int $user_id  Optional. ID of a user associated with the password.
    26712735     * @return bool False, if the $password does not match the hashed password.
    26722736     */
     
    26792743        global $wp_hasher;
    26802744
    2681         // If the hash is still md5...
     2745        $check = false;
     2746
     2747        // If the hash is still md5 or otherwise truncated then invalidate it.
    26822748        if ( strlen( $hash ) <= 32 ) {
    2683             $check = hash_equals( $hash, md5( $password ) );
    2684             if ( $check && $user_id ) {
    2685                 // Rehash using new hash.
    2686                 wp_set_password( $password, $user_id );
    2687                 $hash = wp_hash_password( $password );
    2688             }
    2689 
    26902749            /**
    2691              * Filters whether the plaintext password matches the encrypted password.
     2750             * Filters whether the plaintext password matches the hashed password.
    26922751             *
    26932752             * @since 2.5.0
     2753             * @since 6.8.0 Passwords are now hashed with bcrypt by default.
     2754             *              Old passwords may still be hashed with phpass.
    26942755             *
    26952756             * @param bool       $check    Whether the passwords match.
    26962757             * @param string     $password The plaintext password.
    26972758             * @param string     $hash     The hashed password.
    2698              * @param string|int $user_id  User ID. Can be empty.
     2759             * @param string|int $user_id  Optional ID of a user associated with the password.
     2760             *                             Can be empty.
    26992761             */
    27002762            return apply_filters( 'check_password', $check, $password, $hash, $user_id );
    27012763        }
    27022764
    2703         /*
    2704          * If the stored hash is longer than an MD5,
    2705          * presume the new style phpass portable hash.
    2706          */
    2707         if ( empty( $wp_hasher ) ) {
     2765        if ( ! empty( $wp_hasher ) ) {
     2766            // Check the password using the overridden hasher.
     2767            $check = $wp_hasher->CheckPassword( $password, $hash );
     2768        } elseif ( strlen( $password ) > 4096 ) {
     2769            $check = false;
     2770        } elseif ( str_starts_with( $hash, '$wp' ) ) {
     2771            // Check the password using the current prefixed hash.
     2772            $password_to_verify = base64_encode( hash_hmac( 'sha384', $password, 'wp-sha384', true ) );
     2773            $check              = password_verify( $password_to_verify, substr( $hash, 3 ) );
     2774        } elseif ( str_starts_with( $hash, '$P$' ) ) {
     2775            // Check the password using phpass.
    27082776            require_once ABSPATH . WPINC . '/class-phpass.php';
    2709             // By default, use the portable hash from phpass.
    2710             $wp_hasher = new PasswordHash( 8, true );
    2711         }
    2712 
    2713         $check = $wp_hasher->CheckPassword( $password, $hash );
     2777            $check = ( new PasswordHash( 8, true ) )->CheckPassword( $password, $hash );
     2778        } else {
     2779            // Check the password using compat support for any non-prefixed hash.
     2780            $check = password_verify( $password, $hash );
     2781        }
    27142782
    27152783        /** This filter is documented in wp-includes/pluggable.php */
    27162784        return apply_filters( 'check_password', $check, $password, $hash, $user_id );
     2785    }
     2786endif;
     2787
     2788if ( ! function_exists( 'wp_password_needs_rehash' ) ) :
     2789    /**
     2790     * Checks whether a password hash needs to be rehashed.
     2791     *
     2792     * Passwords are hashed with bcrypt using the default cost. A password hashed in a prior version
     2793     * of WordPress may still be hashed with phpass and will need to be rehashed. If the default cost
     2794     * or algorithm is changed in PHP or WordPress then a password hashed in a previous version will
     2795     * need to be rehashed.
     2796     *
     2797     * Note that, just like wp_check_password(), this function may be used to check a value that is
     2798     * not a user password. A plugin may use this function to check a password of a different type,
     2799     * and there may not always be a user ID associated with the password.
     2800     *
     2801     * @since 6.8.0
     2802     *
     2803     * @global PasswordHash $wp_hasher phpass object.
     2804     *
     2805     * @param string     $hash    Hash of a password to check.
     2806     * @param string|int $user_id Optional. ID of a user associated with the password.
     2807     * @return bool Whether the hash needs to be rehashed.
     2808     */
     2809    function wp_password_needs_rehash( $hash, $user_id = '' ) {
     2810        global $wp_hasher;
     2811
     2812        if ( ! empty( $wp_hasher ) ) {
     2813            return false;
     2814        }
     2815
     2816        /** This filter is documented in wp-includes/pluggable.php */
     2817        $algorithm = apply_filters( 'wp_hash_password_algorithm', PASSWORD_BCRYPT );
     2818
     2819        /** This filter is documented in wp-includes/pluggable.php */
     2820        $options = apply_filters( 'wp_hash_password_options', array(), $algorithm );
     2821
     2822        $prefixed = str_starts_with( $hash, '$wp' );
     2823
     2824        if ( ( PASSWORD_BCRYPT === $algorithm ) && ! $prefixed ) {
     2825            // If bcrypt is in use and the hash is not prefixed then it needs to be rehashed.
     2826            $needs_rehash = true;
     2827        } else {
     2828            // Otherwise check the hash minus its prefix if necessary.
     2829            $hash_to_check = $prefixed ? substr( $hash, 3 ) : $hash;
     2830            $needs_rehash  = password_needs_rehash( $hash_to_check, $algorithm, $options );
     2831        }
     2832
     2833        /**
     2834         * Filters whether the password hash needs to be rehashed.
     2835         *
     2836         * @since 6.8.0
     2837         *
     2838         * @param bool       $needs_rehash Whether the password hash needs to be rehashed.
     2839         * @param string     $hash         The password hash.
     2840         * @param string|int $user_id      Optional. ID of a user associated with the password.
     2841         */
     2842        return apply_filters( 'password_needs_rehash', $needs_rehash, $hash, $user_id );
    27172843    }
    27182844endif;
     
    28662992     *
    28672993     * @since 2.5.0
     2994     * @since 6.8.0 The password is now hashed using bcrypt by default instead of phpass.
    28682995     *
    28692996     * @global wpdb $wpdb WordPress database abstraction object.
  • trunk/src/wp-includes/user.php

    r59817 r59828  
    206206    }
    207207
    208     if ( ! wp_check_password( $password, $user->user_pass, $user->ID ) ) {
     208    $valid = wp_check_password( $password, $user->user_pass, $user->ID );
     209
     210    if ( ! $valid ) {
    209211        return new WP_Error(
    210212            'incorrect_password',
     
    220222    }
    221223
     224    if ( wp_password_needs_rehash( $user->user_pass, $user->ID ) ) {
     225        wp_set_password( $password, $user->ID );
     226    }
     227
    222228    return $user;
    223229}
     
    283289    }
    284290
    285     if ( ! wp_check_password( $password, $user->user_pass, $user->ID ) ) {
     291    $valid = wp_check_password( $password, $user->user_pass, $user->ID );
     292
     293    if ( ! $valid ) {
    286294        return new WP_Error(
    287295            'incorrect_password',
     
    295303            '</a>'
    296304        );
     305    }
     306
     307    if ( wp_password_needs_rehash( $user->user_pass, $user->ID ) ) {
     308        wp_set_password( $password, $user->ID );
    297309    }
    298310
     
    446458
    447459    foreach ( $hashed_passwords as $key => $item ) {
    448         if ( ! wp_check_password( $password, $item['password'], $user->ID ) ) {
     460        if ( ! WP_Application_Passwords::check_password( $password, $item['password'] ) ) {
    449461            continue;
    450462        }
     
    24322444     * @since 4.9.0
    24332445     * @since 5.8.0 The `$userdata` parameter was added.
     2446     * @since 6.8.0 The user's password is now hashed using bcrypt instead of phpass.
    24342447     *
    24352448     * @param array    $data {
     
    29792992 * @since 4.4.0
    29802993 *
    2981  * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance.
    2982  *
    29832994 * @param WP_User $user User to retrieve password reset key for.
    29842995 * @return string|WP_Error Password reset key on success. WP_Error on error.
    29852996 */
    29862997function get_password_reset_key( $user ) {
    2987     global $wp_hasher;
    2988 
    29892998    if ( ! ( $user instanceof WP_User ) ) {
    29902999        return new WP_Error( 'invalidcombo', __( '<strong>Error:</strong> There is no account with that username or email address.' ) );
     
    30323041    do_action( 'retrieve_password_key', $user->user_login, $key );
    30333042
    3034     // Now insert the key, hashed, into the DB.
    3035     if ( empty( $wp_hasher ) ) {
    3036         require_once ABSPATH . WPINC . '/class-phpass.php';
    3037         $wp_hasher = new PasswordHash( 8, true );
    3038     }
    3039 
    3040     $hashed = time() . ':' . $wp_hasher->HashPassword( $key );
     3043    $hashed = time() . ':' . wp_fast_hash( $key );
    30413044
    30423045    $key_saved = wp_update_user(
     
    30643067 * @since 3.1.0
    30653068 *
    3066  * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance.
    3067  *
    3068  * @param string $key       Hash to validate sending user's password.
     3069 * @param string $key       The password reset key.
    30693070 * @param string $login     The user login.
    30703071 * @return WP_User|WP_Error WP_User object on success, WP_Error object for invalid or expired keys.
     
    30753076    $login
    30763077) {
    3077     global $wp_hasher;
    3078 
    30793078    $key = preg_replace( '/[^a-z0-9]/i', '', $key );
    30803079
     
    30913090    if ( ! $user ) {
    30923091        return new WP_Error( 'invalid_key', __( 'Invalid key.' ) );
    3093     }
    3094 
    3095     if ( empty( $wp_hasher ) ) {
    3096         require_once ABSPATH . WPINC . '/class-phpass.php';
    3097         $wp_hasher = new PasswordHash( 8, true );
    30983092    }
    30993093
     
    31193113    }
    31203114
    3121     $hash_is_correct = $wp_hasher->CheckPassword( $key, $pass_key );
     3115    $hash_is_correct = wp_verify_fast_hash( $key, $pass_key );
    31223116
    31233117    if ( $hash_is_correct && $expiration_time && time() < $expiration_time ) {
     
    31343128        /**
    31353129         * Filters the return value of check_password_reset_key() when an
    3136          * old-style key is used.
     3130         * old-style key or an expired key is used.
    31373131         *
    31383132         * @since 3.7.0 Previously plain-text keys were stored in the database.
     
    31553149 * @since 5.7.0 Added `$user_login` parameter.
    31563150 *
    3157  * @global wpdb         $wpdb      WordPress database abstraction object.
    3158  * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance.
     3151 * @global wpdb $wpdb WordPress database abstraction object.
    31593152 *
    31603153 * @param string $user_login Optional. Username to send a password retrieval email for.
     
    49374930 * @since 4.9.6
    49384931 *
    4939  * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance.
    4940  *
    49414932 * @param int $request_id Request ID.
    49424933 * @return string Confirmation key.
    49434934 */
    49444935function wp_generate_user_request_key( $request_id ) {
    4945     global $wp_hasher;
    4946 
    49474936    // Generate something random for a confirmation key.
    49484937    $key = wp_generate_password( 20, false );
    49494938
    4950     // Return the key, hashed.
    4951     if ( empty( $wp_hasher ) ) {
    4952         require_once ABSPATH . WPINC . '/class-phpass.php';
    4953         $wp_hasher = new PasswordHash( 8, true );
    4954     }
    4955 
     4939    // Save the key, hashed.
    49564940    wp_update_post(
    49574941        array(
    49584942            'ID'            => $request_id,
    49594943            'post_status'   => 'request-pending',
    4960             'post_password' => $wp_hasher->HashPassword( $key ),
     4944            'post_password' => wp_fast_hash( $key ),
    49614945        )
    49624946    );
     
    49694953 *
    49704954 * @since 4.9.6
    4971  *
    4972  * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance.
    49734955 *
    49744956 * @param string $request_id ID of the request being confirmed.
     
    49814963    $key
    49824964) {
    4983     global $wp_hasher;
    4984 
    49854965    $request_id       = absint( $request_id );
    49864966    $request          = wp_get_user_request( $request_id );
     
    50004980    }
    50014981
    5002     if ( empty( $wp_hasher ) ) {
    5003         require_once ABSPATH . WPINC . '/class-phpass.php';
    5004         $wp_hasher = new PasswordHash( 8, true );
    5005     }
    5006 
    50074982    /**
    50084983     * Filters the expiration time of confirm keys.
     
    50154990    $expiration_time     = $key_request_time + $expiration_duration;
    50164991
    5017     if ( ! $wp_hasher->CheckPassword( $key, $saved_key ) ) {
     4992    if ( ! wp_verify_fast_hash( $key, $saved_key ) ) {
    50184993        return new WP_Error( 'invalid_key', __( 'The confirmation key is invalid for this personal data request.' ) );
    50194994    }
  • trunk/tests/phpunit/includes/bootstrap.php

    r59769 r59828  
    330330require __DIR__ . '/class-wp-rest-test-configurable-controller.php';
    331331require __DIR__ . '/class-wp-fake-block-type.php';
     332require __DIR__ . '/class-wp-fake-hasher.php';
    332333require __DIR__ . '/class-wp-sitemaps-test-provider.php';
    333334require __DIR__ . '/class-wp-sitemaps-empty-test-provider.php';
  • trunk/tests/phpunit/tests/auth.php

    r59595 r59828  
    1111    const USER_PASS  = 'password';
    1212
     13    /**
     14     * @var WP_User
     15     */
    1316    protected $user;
    1417
     
    1720     */
    1821    protected static $_user;
     22
     23    /**
     24     * @var int
     25     */
    1926    protected static $user_id;
     27
     28    /**
     29     * @var PasswordHash
     30     */
    2031    protected static $wp_hasher;
     32
     33    protected static $bcrypt_length_limit = 72;
     34
     35    protected static $phpass_length_limit = 4096;
     36
     37    protected static $password_length_limit = 4096;
    2138
    2239    /**
     
    90107        $cookie = wp_generate_auth_cookie( self::$user_id, time() + 3600, 'foo' );
    91108        $this->assertFalse( wp_validate_auth_cookie( $cookie, 'bar' ) );
     109    }
     110
     111    /**
     112     * @ticket 21022
     113     */
     114    public function test_auth_cookie_generated_with_phpass_hash_remains_valid() {
     115        self::set_user_password_with_phpass( 'password', self::$user_id );
     116
     117        $auth_cookie = wp_generate_auth_cookie( self::$user_id, time() + 3600, 'auth' );
     118
     119        $this->assertSame( self::$user_id, wp_validate_auth_cookie( $auth_cookie, 'auth' ) );
     120    }
     121
     122    /**
     123     * @ticket 21022
     124     */
     125    public function test_auth_cookie_generated_with_plain_bcrypt_hash_remains_valid() {
     126        self::set_user_password_with_plain_bcrypt( 'password', self::$user_id );
     127
     128        $auth_cookie = wp_generate_auth_cookie( self::$user_id, time() + 3600, 'auth' );
     129
     130        $this->assertSame( self::$user_id, wp_validate_auth_cookie( $auth_cookie, 'auth' ) );
    92131    }
    93132
     
    107146            $authed_user = wp_authenticate( $this->user->user_login, $password_to_test );
    108147
     148            $this->assertNotWPError( $authed_user );
    109149            $this->assertInstanceOf( 'WP_User', $authed_user );
    110150            $this->assertSame( $this->user->ID, $authed_user->ID );
     
    158198        $password = "pass with vertical tab o_O\x0B";
    159199        $this->assertTrue( wp_check_password( 'pass with vertical tab o_O', wp_hash_password( $password ) ) );
     200    }
     201
     202    /**
     203     * @ticket 21022
     204     */
     205    public function test_wp_check_password_supports_phpass_hash() {
     206        $password = 'password';
     207        $hash     = self::$wp_hasher->HashPassword( $password );
     208        $this->assertTrue( wp_check_password( $password, $hash ) );
     209        $this->assertSame( 1, did_filter( 'check_password' ) );
     210    }
     211
     212    /**
     213     * Ensure wp_check_password() remains compatible with an increase to the default bcrypt cost.
     214     *
     215     * The test verifies this by reducing the cost used to generate the hash, therefore mimicing a hash
     216     * which was generated prior to the default cost being increased.
     217     *
     218     * Notably the bcrypt cost was increased in PHP 8.4: https://wiki.php.net/rfc/bcrypt_cost_2023 .
     219     *
     220     * @ticket 21022
     221     */
     222    public function test_wp_check_password_supports_hash_with_increased_bcrypt_cost() {
     223        $password = 'password';
     224
     225        // Reducing the cost mimics an increase to the default cost.
     226        add_filter( 'wp_hash_password_options', array( $this, 'reduce_hash_cost' ) );
     227        $hash = wp_hash_password( $password, PASSWORD_BCRYPT );
     228        remove_filter( 'wp_hash_password_options', array( $this, 'reduce_hash_cost' ) );
     229
     230        $this->assertTrue( wp_check_password( $password, $hash ) );
     231        $this->assertSame( 1, did_filter( 'check_password' ) );
     232        $this->assertTrue( wp_password_needs_rehash( $hash ) );
     233    }
     234
     235    /**
     236     * Ensure wp_check_password() remains compatible with a reduction of the default bcrypt cost.
     237     *
     238     * The test verifies this by increasing the cost used to generate the hash, therefore mimicing a hash
     239     * which was generated prior to the default cost being reduced.
     240     *
     241     * A reduction of the cost is unlikely to occur but is fully supported.
     242     *
     243     * @ticket 21022
     244     */
     245    public function test_wp_check_password_supports_hash_with_reduced_bcrypt_cost() {
     246        $password = 'password';
     247
     248        // Increasing the cost mimics a reduction of the default cost.
     249        add_filter( 'wp_hash_password_options', array( $this, 'increase_hash_cost' ) );
     250        $hash = wp_hash_password( $password, PASSWORD_BCRYPT );
     251        remove_filter( 'wp_hash_password_options', array( $this, 'increase_hash_cost' ) );
     252
     253        $this->assertTrue( wp_check_password( $password, $hash ) );
     254        $this->assertSame( 1, did_filter( 'check_password' ) );
     255        $this->assertTrue( wp_password_needs_rehash( $hash ) );
     256    }
     257
     258    /**
     259     * @ticket 21022
     260     */
     261    public function test_wp_check_password_supports_wp_hash_with_default_bcrypt_cost() {
     262        $password = 'password';
     263
     264        $hash = wp_hash_password( $password, PASSWORD_BCRYPT );
     265
     266        $this->assertTrue( wp_check_password( $password, $hash ) );
     267        $this->assertSame( 1, did_filter( 'check_password' ) );
     268        $this->assertFalse( wp_password_needs_rehash( $hash ) );
     269    }
     270
     271    /**
     272     * @ticket 21022
     273     */
     274    public function test_wp_check_password_supports_plain_bcrypt_hash_with_default_bcrypt_cost() {
     275        $password = 'password';
     276
     277        $hash = password_hash( $password, PASSWORD_BCRYPT );
     278
     279        $this->assertTrue( wp_check_password( $password, $hash ) );
     280        $this->assertSame( 1, did_filter( 'check_password' ) );
     281        $this->assertTrue( wp_password_needs_rehash( $hash ) );
     282    }
     283
     284    /**
     285     * Ensure wp_check_password() is compatible with Argon2i hashes.
     286     *
     287     * @ticket 21022
     288     */
     289    public function test_wp_check_password_supports_argon2i_hash() {
     290        if ( ! defined( 'PASSWORD_ARGON2I' ) ) {
     291            $this->fail( 'Argon2i is not supported.' );
     292        }
     293
     294        $password = 'password';
     295        $hash     = password_hash( trim( $password ), PASSWORD_ARGON2I );
     296        $this->assertTrue( wp_check_password( $password, $hash ) );
     297        $this->assertSame( 1, did_filter( 'check_password' ) );
     298    }
     299
     300    /**
     301     * Ensure wp_check_password() is compatible with Argon2id hashes.
     302     *
     303     * @requires PHP >= 7.3
     304     *
     305     * @ticket 21022
     306     */
     307    public function test_wp_check_password_supports_argon2id_hash() {
     308        if ( ! defined( 'PASSWORD_ARGON2ID' ) ) {
     309            $this->fail( 'Argon2id is not supported.' );
     310        }
     311
     312        $password = 'password';
     313        $hash     = password_hash( trim( $password ), PASSWORD_ARGON2ID );
     314        $this->assertTrue( wp_check_password( $password, $hash ) );
     315        $this->assertSame( 1, did_filter( 'check_password' ) );
     316    }
     317
     318    /**
     319     * @ticket 21022
     320     */
     321    public function test_wp_check_password_does_not_support_md5_hashes() {
     322        $password = 'password';
     323        $hash     = md5( $password );
     324        $this->assertFalse( wp_check_password( $password, $hash ) );
     325        $this->assertSame( 1, did_filter( 'check_password' ) );
     326    }
     327
     328    /**
     329     * @ticket 21022
     330     */
     331    public function test_wp_check_password_does_not_support_plain_text() {
     332        $password = 'password';
     333        $hash     = $password;
     334        $this->assertFalse( wp_check_password( $password, $hash ) );
     335        $this->assertSame( 1, did_filter( 'check_password' ) );
     336    }
     337
     338    /**
     339     * @ticket 21022
     340     *
     341     * @dataProvider data_empty_values
     342     * @param mixed $value
     343     */
     344    public function test_wp_check_password_does_not_support_empty_hash( $value ) {
     345        $password = 'password';
     346        $hash     = $value;
     347        $this->assertFalse( wp_check_password( $password, $hash ) );
     348        $this->assertSame( 1, did_filter( 'check_password' ) );
     349    }
     350
     351    /**
     352     * @ticket 21022
     353     *
     354     * @dataProvider data_empty_values
     355     * @param mixed $value
     356     */
     357    public function test_wp_check_password_does_not_support_empty_password( $value ) {
     358        $password = $value;
     359        $hash     = $value;
     360        $this->assertFalse( wp_check_password( $password, $hash ) );
     361        $this->assertSame( 1, did_filter( 'check_password' ) );
     362    }
     363
     364    public function data_empty_values() {
     365        return array(
     366            // Integer zero:
     367            array( 0 ),
     368            // String zero:
     369            array( '0' ),
     370            // Zero-length string:
     371            array( '' ),
     372            // Null byte character:
     373            array( "\0" ),
     374            // Asterisk values:
     375            array( '*' ),
     376            array( '*0' ),
     377            array( '*1' ),
     378        );
    160379    }
    161380
     
    236455    }
    237456
    238     public function test_password_length_limit() {
    239         $limit = str_repeat( 'a', 4096 );
    240 
     457    /**
     458     * @ticket 21022
     459     */
     460    public function test_password_is_hashed_with_bcrypt() {
     461        $password = 'password';
     462
     463        // Set the user password.
     464        wp_set_password( $password, self::$user_id );
     465
     466        // Ensure the password is hashed with bcrypt.
     467        $this->assertStringStartsWith( '$wp$2y$', get_userdata( self::$user_id )->user_pass );
     468
     469        // Authenticate.
     470        $user = wp_authenticate( $this->user->user_login, $password );
     471
     472        // Verify correct password.
     473        $this->assertNotWPError( $user );
     474        $this->assertInstanceOf( 'WP_User', $user );
     475        $this->assertSame( self::$user_id, $user->ID );
     476    }
     477
     478    /**
     479     * @ticket 21022
     480     */
     481    public function test_invalid_password_at_bcrypt_length_limit_is_rejected() {
     482        $limit = str_repeat( 'a', self::$bcrypt_length_limit );
     483
     484        // Set the user password to the bcrypt limit.
    241485        wp_set_password( $limit, self::$user_id );
    242         // phpass hashed password.
    243         $this->assertStringStartsWith( '$P$', $this->user->data->user_pass );
    244486
    245487        $user = wp_authenticate( $this->user->user_login, 'aaaaaaaa' );
    246488        // Wrong password.
    247         $this->assertInstanceOf( 'WP_Error', $user );
    248 
     489        $this->assertWPError( $user );
     490        $this->assertSame( 'incorrect_password', $user->get_error_code() );
     491    }
     492
     493    /**
     494     * @ticket 21022
     495     */
     496    public function test_invalid_password_beyond_bcrypt_length_limit_is_rejected() {
     497        $limit = str_repeat( 'a', self::$bcrypt_length_limit + 1 );
     498
     499        // Set the user password beyond the bcrypt limit.
     500        wp_set_password( $limit, self::$user_id );
     501
     502        $user = wp_authenticate( $this->user->user_login, 'aaaaaaaa' );
     503        // Wrong password.
     504        $this->assertWPError( $user );
     505        $this->assertSame( 'incorrect_password', $user->get_error_code() );
     506    }
     507
     508    /**
     509     * @ticket 21022
     510     */
     511    public function test_valid_password_at_bcrypt_length_limit_is_accepted() {
     512        $limit = str_repeat( 'a', self::$bcrypt_length_limit );
     513
     514        // Set the user password to the bcrypt limit.
     515        wp_set_password( $limit, self::$user_id );
     516
     517        // Authenticate.
    249518        $user = wp_authenticate( $this->user->user_login, $limit );
     519
     520        // Correct password.
     521        $this->assertNotWPError( $user );
    250522        $this->assertInstanceOf( 'WP_User', $user );
    251523        $this->assertSame( self::$user_id, $user->ID );
    252 
    253         // One char too many.
    254         $user = wp_authenticate( $this->user->user_login, $limit . 'a' );
     524    }
     525
     526    /**
     527     * @ticket 21022
     528     */
     529    public function test_valid_password_beyond_bcrypt_length_limit_is_accepted() {
     530        $limit = str_repeat( 'a', self::$bcrypt_length_limit + 1 );
     531
     532        // Set the user password beyond the bcrypt limit.
     533        wp_set_password( $limit, self::$user_id );
     534
     535        // Authenticate.
     536        $user = wp_authenticate( $this->user->user_login, $limit );
     537
     538        // Correct password depite its length.
     539        $this->assertNotWPError( $user );
     540        $this->assertInstanceOf( 'WP_User', $user );
     541        $this->assertSame( self::$user_id, $user->ID );
     542    }
     543
     544    /**
     545     * A password beyond 72 bytes will be truncated by bcrypt by default and still be accepted.
     546     *
     547     * This ensures that a truncated password is not accepted by WordPress.
     548     *
     549     * @ticket 21022
     550     */
     551    public function test_long_truncated_password_is_rejected() {
     552        $at_limit     = str_repeat( 'a', self::$bcrypt_length_limit );
     553        $beyond_limit = str_repeat( 'a', self::$bcrypt_length_limit + 1 );
     554
     555        // Set the user password beyond the bcrypt limit.
     556        wp_set_password( $beyond_limit, self::$user_id );
     557
     558        // Authenticate using a truncated password.
     559        $user = wp_authenticate( $this->user->user_login, $at_limit );
     560
     561        // Incorrect password.
     562        $this->assertWPError( $user );
     563        $this->assertSame( 'incorrect_password', $user->get_error_code() );
     564    }
     565
     566    /**
     567     * @ticket 21022
     568     */
     569    public function test_setting_password_beyond_bcrypt_length_limit_is_rejected() {
     570        $beyond_limit = str_repeat( 'a', self::$password_length_limit + 1 );
     571
     572        // Set the user password beyond the limit.
     573        wp_set_password( $beyond_limit, self::$user_id );
     574
     575        // Password broken by setting it to be too long.
     576        $user = get_user_by( 'id', self::$user_id );
     577        $this->assertSame( '*', $user->data->user_pass );
     578
     579        // Password is not accepted.
     580        $user = wp_authenticate( $this->user->user_login, $beyond_limit );
     581        $this->assertInstanceOf( 'WP_Error', $user );
     582        $this->assertSame( 'incorrect_password', $user->get_error_code() );
     583
     584        // Placeholder is not accepted.
     585        $user = wp_authenticate( $this->user->user_login, '*' );
     586        $this->assertInstanceOf( 'WP_Error', $user );
     587        $this->assertSame( 'incorrect_password', $user->get_error_code() );
     588    }
     589
     590    /**
     591     * @see https://core.trac.wordpress.org/changeset/30466
     592     */
     593    public function test_invalid_password_at_phpass_length_limit_is_rejected() {
     594        $limit = str_repeat( 'a', self::$phpass_length_limit );
     595
     596        // Set the user password with the old phpass algorithm.
     597        self::set_user_password_with_phpass( $limit, self::$user_id );
     598
     599        // Authenticate.
     600        $user = wp_authenticate( $this->user->user_login, 'aaaaaaaa' );
     601
    255602        // Wrong password.
    256603        $this->assertInstanceOf( 'WP_Error', $user );
    257 
    258         wp_set_password( $limit . 'a', self::$user_id );
     604        $this->assertSame( 'incorrect_password', $user->get_error_code() );
     605    }
     606
     607    public function test_valid_password_at_phpass_length_limit_is_accepted() {
     608        $limit = str_repeat( 'a', self::$phpass_length_limit );
     609
     610        // Set the user password with the old phpass algorithm.
     611        self::set_user_password_with_phpass( $limit, self::$user_id );
     612
     613        // Authenticate.
     614        $user = wp_authenticate( $this->user->user_login, $limit );
     615
     616        // Correct password.
     617        $this->assertNotWPError( $user );
     618        $this->assertInstanceOf( 'WP_User', $user );
     619        $this->assertSame( self::$user_id, $user->ID );
     620    }
     621
     622    public function test_too_long_password_at_phpass_length_limit_is_rejected() {
     623        $limit = str_repeat( 'a', self::$phpass_length_limit );
     624
     625        // Set the user password with the old phpass algorithm.
     626        self::set_user_password_with_phpass( $limit, self::$user_id );
     627
     628        // Authenticate with a password that is one character too long.
     629        $user = wp_authenticate( $this->user->user_login, $limit . 'a' );
     630
     631        // Wrong password.
     632        $this->assertInstanceOf( 'WP_Error', $user );
     633        $this->assertSame( 'incorrect_password', $user->get_error_code() );
     634    }
     635
     636    public function test_too_long_password_beyond_phpass_length_limit_is_rejected() {
     637        // One char too many.
     638        $too_long = str_repeat( 'a', self::$phpass_length_limit + 1 );
     639
     640        // Set the user password with the old phpass algorithm.
     641        self::set_user_password_with_phpass( $too_long, self::$user_id );
     642
    259643        $user = get_user_by( 'id', self::$user_id );
    260644        // Password broken by setting it to be too long.
    261645        $this->assertSame( '*', $user->data->user_pass );
    262646
     647        // Password is not accepted.
    263648        $user = wp_authenticate( $this->user->user_login, '*' );
    264649        $this->assertInstanceOf( 'WP_Error', $user );
    265 
    266         $user = wp_authenticate( $this->user->user_login, '*0' );
     650        $this->assertSame( 'incorrect_password', $user->get_error_code() );
     651    }
     652
     653    /**
     654     * @dataProvider data_empty_values
     655     * @param mixed $value
     656     */
     657    public function test_empty_password_is_rejected_by_bcrypt( $value ) {
     658        // Set the user password.
     659        wp_set_password( 'password', self::$user_id );
     660
     661        $user = wp_authenticate( $this->user->user_login, $value );
    267662        $this->assertInstanceOf( 'WP_Error', $user );
    268 
    269         $user = wp_authenticate( $this->user->user_login, '*1' );
     663    }
     664
     665    /**
     666     * @dataProvider data_empty_values
     667     * @param mixed $value
     668     */
     669    public function test_empty_password_is_rejected_by_phpass( $value ) {
     670        // Set the user password with the old phpass algorithm.
     671        self::set_user_password_with_phpass( 'password', self::$user_id );
     672
     673        $user = wp_authenticate( $this->user->user_login, $value );
    270674        $this->assertInstanceOf( 'WP_Error', $user );
     675    }
     676
     677    public function test_incorrect_password_is_rejected_by_phpass() {
     678        // Set the user password with the old phpass algorithm.
     679        self::set_user_password_with_phpass( 'password', self::$user_id );
    271680
    272681        $user = wp_authenticate( $this->user->user_login, 'aaaaaaaa' );
     682
    273683        // Wrong password.
    274684        $this->assertInstanceOf( 'WP_Error', $user );
    275 
    276         $user = wp_authenticate( $this->user->user_login, $limit );
    277         // Wrong password.
    278         $this->assertInstanceOf( 'WP_Error', $user );
     685        $this->assertSame( 'incorrect_password', $user->get_error_code() );
     686    }
     687
     688    public function test_too_long_password_is_rejected_by_phpass() {
     689        $limit = str_repeat( 'a', self::$phpass_length_limit );
     690
     691        // Set the user password with the old phpass algorithm.
     692        self::set_user_password_with_phpass( 'password', self::$user_id );
    279693
    280694        $user = wp_authenticate( $this->user->user_login, $limit . 'a' );
     695
    281696        // Password broken by setting it to be too long.
    282697        $this->assertInstanceOf( 'WP_Error', $user );
     698        $this->assertSame( 'incorrect_password', $user->get_error_code() );
    283699    }
    284700
     
    307723            $wpdb->users,
    308724            array(
    309                 'user_activation_key' => strtotime( '-1 hour' ) . ':' . self::$wp_hasher->HashPassword( $key ),
     725                'user_activation_key' => strtotime( '-1 hour' ) . ':' . wp_fast_hash( $key ),
    310726            ),
    311727            array(
     
    345761            $wpdb->users,
    346762            array(
    347                 'user_activation_key' => strtotime( '-48 hours' ) . ':' . self::$wp_hasher->HashPassword( $key ),
     763                'user_activation_key' => strtotime( '-48 hours' ) . ':' . wp_fast_hash( $key ),
    348764            ),
    349765            array(
     
    356772        $check = check_password_reset_key( $key, $this->user->user_login );
    357773        $this->assertInstanceOf( 'WP_Error', $check );
     774        $this->assertSame( 'expired_key', $check->get_error_code() );
    358775    }
    359776
     
    394811        $check = check_password_reset_key( $key, $this->user->user_login );
    395812        $this->assertInstanceOf( 'WP_Error', $check );
     813        $this->assertSame( 'expired_key', $check->get_error_code() );
    396814
    397815        // An empty key with a legacy user_activation_key should be rejected.
    398816        $check = check_password_reset_key( '', $this->user->user_login );
    399817        $this->assertInstanceOf( 'WP_Error', $check );
     818        $this->assertSame( 'invalid_key', $check->get_error_code() );
     819    }
     820
     821    /**
     822     * @ticket 21022
     823     */
     824    public function test_phpass_user_activation_key_is_allowed() {
     825        global $wpdb;
     826
     827        // A legacy user_activation_key is one hashed using phpass between WordPress 4.3 and 6.8.0.
     828
     829        $key = wp_generate_password( 20, false );
     830        $wpdb->update(
     831            $wpdb->users,
     832            array(
     833                'user_activation_key' => strtotime( '-1 hour' ) . ':' . self::$wp_hasher->HashPassword( $key ),
     834            ),
     835            array(
     836                'ID' => $this->user->ID,
     837            )
     838        );
     839        clean_user_cache( $this->user );
     840
     841        // A legacy phpass user_activation_key should remain valid.
     842        $check = check_password_reset_key( $key, $this->user->user_login );
     843        $this->assertNotWPError( $check );
     844        $this->assertInstanceOf( 'WP_User', $check );
     845        $this->assertSame( $this->user->ID, $check->ID );
     846
     847        // An empty key with a legacy user_activation_key should be rejected.
     848        $check = check_password_reset_key( '', $this->user->user_login );
     849        $this->assertWPError( $check );
     850        $this->assertSame( 'invalid_key', $check->get_error_code() );
     851    }
     852
     853    /**
     854     * @ticket 21022
     855     */
     856    public function test_expired_phpass_user_activation_key_is_rejected() {
     857        global $wpdb;
     858
     859        // A legacy user_activation_key is one hashed using phpass between WordPress 4.3 and 6.8.0.
     860
     861        $key = wp_generate_password( 20, false );
     862        $wpdb->update(
     863            $wpdb->users,
     864            array(
     865                'user_activation_key' => strtotime( '-48 hours' ) . ':' . self::$wp_hasher->HashPassword( $key ),
     866            ),
     867            array(
     868                'ID' => $this->user->ID,
     869            )
     870        );
     871        clean_user_cache( $this->user );
     872
     873        // A legacy phpass user_activation_key should still be subject to an expiry check.
     874        $check = check_password_reset_key( $key, $this->user->user_login );
     875        $this->assertWPError( $check );
     876        $this->assertSame( 'expired_key', $check->get_error_code() );
     877
     878        // An empty key with a legacy user_activation_key should be rejected.
     879        $check = check_password_reset_key( '', $this->user->user_login );
     880        $this->assertWPError( $check );
     881        $this->assertSame( 'invalid_key', $check->get_error_code() );
     882    }
     883
     884    /**
     885     * @ticket 21022
     886     */
     887    public function test_user_request_key_handling() {
     888        $request_id = wp_create_user_request( 'test@example.com', 'remove_personal_data' );
     889        $key        = wp_generate_user_request_key( $request_id );
     890
     891        // A valid key should be accepted.
     892        $check = wp_validate_user_request_key( $request_id, $key );
     893        $this->assertNotWPError( $check );
     894        $this->assertTrue( $check );
     895
     896        // An invalid key should rejected.
     897        $check = wp_validate_user_request_key( $request_id, 'invalid' );
     898        $this->assertWPError( $check );
     899        $this->assertSame( 'invalid_key', $check->get_error_code() );
     900
     901        // An empty key should be rejected.
     902        $check = wp_validate_user_request_key( $request_id, '' );
     903        $this->assertWPError( $check );
     904        $this->assertSame( 'missing_key', $check->get_error_code() );
     905    }
     906
     907    /**
     908     * @ticket 21022
     909     */
     910    public function test_phpass_user_request_key_is_allowed() {
     911        // A legacy user request key is one hashed using phpass between WordPress 4.3 and 6.8.0.
     912
     913        $request_id = wp_create_user_request( 'test@example.com', 'remove_personal_data' );
     914        $key        = wp_generate_password( 20, false );
     915
     916        wp_update_post(
     917            array(
     918                'ID'            => $request_id,
     919                'post_password' => self::$wp_hasher->HashPassword( $key ),
     920            )
     921        );
     922
     923        // A legacy phpass key should remain valid.
     924        $check = wp_validate_user_request_key( $request_id, $key );
     925        $this->assertNotWPError( $check );
     926        $this->assertTrue( $check );
     927
     928        // An empty key with a legacy key should be rejected.
     929        $check = wp_validate_user_request_key( $request_id, '' );
     930        $this->assertWPError( $check );
     931        $this->assertSame( 'missing_key', $check->get_error_code() );
     932    }
     933
     934    /**
     935     * The `wp_password_needs_rehash()` function is just a wrapper around `password_needs_rehash()`, but this ensures
     936     * that it works as expected.
     937     *
     938     * Notably the bcrypt cost was increased in PHP 8.4: https://wiki.php.net/rfc/bcrypt_cost_2023 .
     939     *
     940     * @ticket 21022
     941     */
     942    public function check_password_needs_rehashing() {
     943        $password = 'password';
     944
     945        // Current password hashing algorithm.
     946        $hash = wp_hash_password( $password );
     947        $this->assertFalse( wp_password_needs_rehash( $hash ) );
     948
     949        // A future upgrade from a previously lower cost.
     950        $default = self::get_default_bcrypt_cost();
     951        $opts    = array(
     952            // Reducing the cost mimics an increase in the default cost.
     953            'cost' => $default - 1,
     954        );
     955        $hash    = password_hash( $password, PASSWORD_BCRYPT, $opts );
     956        $this->assertTrue( wp_password_needs_rehash( $hash ) );
     957
     958        // Previous phpass algorithm.
     959        $hash = self::$wp_hasher->HashPassword( $password );
     960        $this->assertTrue( wp_password_needs_rehash( $hash ) );
     961
     962        // o_O md5.
     963        $hash = md5( $password );
     964        $this->assertTrue( wp_password_needs_rehash( $hash ) );
    400965    }
    401966
     
    4561021        $this->assertEmpty( $user->user_activation_key, 'The `user_activation_key` was not empty on the user object returned by `wp_signon()` function.' );
    4571022        $this->assertEmpty( $activation_key_from_database, 'The `user_activation_key` was not empty in the database.' );
     1023    }
     1024
     1025    /**
     1026     * @ticket 21022
     1027     */
     1028    public function test_phpass_application_password_is_accepted() {
     1029        add_filter( 'application_password_is_api_request', '__return_true' );
     1030        add_filter( 'wp_is_application_passwords_available', '__return_true' );
     1031
     1032        $password = 'password';
     1033
     1034        // Set an application password with the old phpass algorithm.
     1035        $uuid = self::set_application_password_with_phpass( $password, self::$user_id );
     1036
     1037        // Authenticate.
     1038        $user = wp_authenticate_application_password( null, self::USER_LOGIN, $password );
     1039
     1040        // Verify that the phpass hash for the application password was valid.
     1041        $this->assertNotWPError( $user );
     1042        $this->assertInstanceOf( 'WP_User', $user );
     1043        $this->assertSame( self::$user_id, $user->ID );
     1044    }
     1045
     1046    /**
     1047     * @dataProvider data_usernames
     1048     *
     1049     * @ticket 21022
     1050     */
     1051    public function test_phpass_password_is_rehashed_after_successful_user_password_authentication( $username_or_email ) {
     1052        $password = 'password';
     1053
     1054        // Set the user password with the old phpass algorithm.
     1055        self::set_user_password_with_phpass( $password, self::$user_id );
     1056
     1057        // Verify that the password needs rehashing.
     1058        $hash = get_userdata( self::$user_id )->user_pass;
     1059        $this->assertTrue( wp_password_needs_rehash( $hash, self::$user_id ) );
     1060
     1061        // Authenticate.
     1062        $user = wp_authenticate( $username_or_email, $password );
     1063
     1064        // Verify that the phpass password hash was valid.
     1065        $this->assertNotWPError( $user );
     1066        $this->assertInstanceOf( 'WP_User', $user );
     1067        $this->assertSame( self::$user_id, $user->ID );
     1068
     1069        // Verify that the password no longer needs rehashing.
     1070        $hash = get_userdata( self::$user_id )->user_pass;
     1071        $this->assertFalse( wp_password_needs_rehash( $hash, self::$user_id ) );
     1072
     1073        // Authenticate a second time to ensure the new hash is valid.
     1074        $user = wp_authenticate( $username_or_email, $password );
     1075
     1076        // Verify that the bcrypt password hash is valid.
     1077        $this->assertNotWPError( $user );
     1078        $this->assertInstanceOf( 'WP_User', $user );
     1079        $this->assertSame( self::$user_id, $user->ID );
     1080    }
     1081
     1082    /**
     1083     * @dataProvider data_usernames
     1084     *
     1085     * @ticket 21022
     1086     */
     1087    public function test_bcrypt_password_is_rehashed_with_new_cost_after_successful_user_password_authentication( $username_or_email ) {
     1088        $password = 'password';
     1089
     1090        // Hash the user password with a lower cost than default to mimic a cost upgrade.
     1091        add_filter( 'wp_hash_password_options', array( $this, 'reduce_hash_cost' ) );
     1092        wp_set_password( $password, self::$user_id );
     1093        remove_filter( 'wp_hash_password_options', array( $this, 'reduce_hash_cost' ) );
     1094
     1095        // Verify that the password needs rehashing.
     1096        $hash = get_userdata( self::$user_id )->user_pass;
     1097        $this->assertTrue( wp_password_needs_rehash( $hash, self::$user_id ) );
     1098
     1099        // Authenticate.
     1100        $user = wp_authenticate( $username_or_email, $password );
     1101
     1102        // Verify that the reduced cost password hash was valid.
     1103        $this->assertNotWPError( $user );
     1104        $this->assertInstanceOf( 'WP_User', $user );
     1105        $this->assertSame( self::$user_id, $user->ID );
     1106
     1107        // Verify that the password has been rehashed with the increased cost.
     1108        $hash = get_userdata( self::$user_id )->user_pass;
     1109        $this->assertFalse( wp_password_needs_rehash( $hash, self::$user_id ) );
     1110        $this->assertSame( self::get_default_bcrypt_cost(), password_get_info( substr( $hash, 3 ) )['options']['cost'] );
     1111
     1112        // Authenticate a second time to ensure the new hash is valid.
     1113        $user = wp_authenticate( $username_or_email, $password );
     1114
     1115        // Verify that the password hash is valid.
     1116        $this->assertNotWPError( $user );
     1117        $this->assertInstanceOf( 'WP_User', $user );
     1118        $this->assertSame( self::$user_id, $user->ID );
     1119    }
     1120
     1121    public function reduce_hash_cost( array $options ): array {
     1122        $options['cost'] = self::get_default_bcrypt_cost() - 1;
     1123        return $options;
     1124    }
     1125
     1126    public function increase_hash_cost( array $options ): array {
     1127        $options['cost'] = self::get_default_bcrypt_cost() + 1;
     1128        return $options;
     1129    }
     1130
     1131    public function data_usernames() {
     1132        return array(
     1133            array(
     1134                self::USER_LOGIN,
     1135            ),
     1136            array(
     1137                self::USER_EMAIL,
     1138            ),
     1139        );
     1140    }
     1141
     1142    /**
     1143     * @ticket 21022
     1144     */
     1145    public function test_password_rehashing_requirement_can_be_filtered() {
     1146        $filter_count_before = did_filter( 'password_needs_rehash' );
     1147
     1148        wp_password_needs_rehash( '$hash' );
     1149
     1150        $this->assertSame( $filter_count_before + 1, did_filter( 'password_needs_rehash' ) );
     1151    }
     1152
     1153    /**
     1154     * @ticket 21022
     1155     */
     1156    public function test_password_hashing_algorithm_can_be_filtered() {
     1157        $password = 'password';
     1158
     1159        $filter_count_before = did_filter( 'wp_hash_password_algorithm' );
     1160
     1161        $wp_hash = wp_hash_password( $password );
     1162
     1163        wp_check_password( $password, $wp_hash );
     1164        wp_password_needs_rehash( $wp_hash );
     1165
     1166        $this->assertSame( $filter_count_before + 2, did_filter( 'wp_hash_password_algorithm' ) );
     1167    }
     1168
     1169    /**
     1170     * @ticket 21022
     1171     */
     1172    public function test_password_hashing_options_can_be_filtered() {
     1173        $password = 'password';
     1174
     1175        add_filter(
     1176            'wp_hash_password_options',
     1177            static function ( $options ) {
     1178                $options['cost'] = 5;
     1179                return $options;
     1180            }
     1181        );
     1182
     1183        $filter_count_before = did_filter( 'wp_hash_password_options' );
     1184
     1185        $wp_hash      = wp_hash_password( $password );
     1186        $valid        = wp_check_password( $password, $wp_hash );
     1187        $needs_rehash = wp_password_needs_rehash( $wp_hash );
     1188        $info         = password_get_info( substr( $wp_hash, 3 ) );
     1189        $cost         = $info['options']['cost'];
     1190
     1191        $this->assertTrue( $valid );
     1192        $this->assertFalse( $needs_rehash );
     1193        $this->assertSame( $filter_count_before + 2, did_filter( 'wp_hash_password_options' ) );
     1194        $this->assertSame( 5, $cost );
     1195    }
     1196
     1197    /**
     1198     * @ticket 21022
     1199     */
     1200    public function test_password_checks_support_wp_hasher_fallback() {
     1201        global $wp_hasher;
     1202
     1203        $filter_count_before = did_filter( 'wp_hash_password_options' );
     1204
     1205        $password = 'password';
     1206
     1207        // Ensure the global $wp_hasher is set.
     1208        $wp_hasher = new WP_Fake_Hasher();
     1209
     1210        $hasher_hash  = $wp_hasher->HashPassword( $password );
     1211        $wp_hash      = wp_hash_password( $password );
     1212        $valid        = wp_check_password( $password, $wp_hash );
     1213        $needs_rehash = wp_password_needs_rehash( $wp_hash );
     1214
     1215        // Reset the global $wp_hasher.
     1216        $wp_hasher = null;
     1217
     1218        $this->assertSame( $hasher_hash, $wp_hash );
     1219        $this->assertTrue( $valid );
     1220        $this->assertFalse( $needs_rehash );
     1221        $this->assertSame( 1, did_filter( 'check_password' ) );
     1222        $this->assertSame( $filter_count_before, did_filter( 'wp_hash_password_options' ) );
    4581223    }
    4591224
     
    7041469     */
    7051470    public function test_authenticate_application_password_respects_existing_user() {
    706         $this->assertSame( self::$_user, wp_authenticate_application_password( self::$_user, self::$_user->user_login, 'password' ) );
     1471        $user = wp_authenticate_application_password( self::$_user, self::$_user->user_login, 'password' );
     1472        $this->assertNotWPError( $user );
     1473        $this->assertSame( self::$_user, $user );
    7071474    }
    7081475
     
    7131480        add_filter( 'application_password_is_api_request', '__return_false' );
    7141481
    715         $this->assertNull( wp_authenticate_application_password( null, self::$_user->user_login, 'password' ) );
     1482        $user = wp_authenticate_application_password( null, self::$_user->user_login, 'password' );
     1483        $this->assertNotWPError( $user );
     1484        $this->assertNull( $user );
    7161485    }
    7171486
     
    8061575
    8071576        $user = wp_authenticate_application_password( null, self::$_user->user_login, $password );
     1577        $this->assertNotWPError( $user );
    8081578        $this->assertInstanceOf( WP_User::class, $user );
    8091579        $this->assertSame( self::$user_id, $user->ID );
     
    8201590
    8211591        $user = wp_authenticate_application_password( null, self::$_user->user_email, $password );
     1592        $this->assertNotWPError( $user );
    8221593        $this->assertInstanceOf( WP_User::class, $user );
    8231594        $this->assertSame( self::$user_id, $user->ID );
     
    8341605
    8351606        $user = wp_authenticate_application_password( null, self::$_user->user_email, WP_Application_Passwords::chunk_password( $password ) );
     1607        $this->assertNotWPError( $user );
    8361608        $this->assertInstanceOf( WP_User::class, $user );
    8371609        $this->assertSame( self::$user_id, $user->ID );
     
    8451617
    8461618        $authenticated = wp_authenticate_application_password( null, 'idonotexist', 'password' );
     1619        $this->assertNotWPError( $authenticated );
    8471620        $this->assertNull( $authenticated );
    8481621    }
     
    9681741        $this->assertSame( $_SERVER['PHP_AUTH_PW'], 'pass:word' );
    9691742    }
     1743
     1744    /**
     1745     * Test the tests
     1746     *
     1747     * @covers Tests_Auth::set_user_password_with_phpass
     1748     *
     1749     * @ticket 21022
     1750     */
     1751    public function test_set_user_password_with_phpass() {
     1752        // Set the user password with the old phpass algorithm.
     1753        self::set_user_password_with_phpass( 'password', self::$user_id );
     1754
     1755        // Ensure the password is hashed with phpass.
     1756        $hash = get_userdata( self::$user_id )->user_pass;
     1757        $this->assertStringStartsWith( '$P$', $hash );
     1758    }
     1759
     1760    private static function set_user_password_with_phpass( string $password, int $user_id ) {
     1761        global $wpdb;
     1762
     1763        $wpdb->update(
     1764            $wpdb->users,
     1765            array(
     1766                'user_pass' => self::$wp_hasher->HashPassword( $password ),
     1767            ),
     1768            array(
     1769                'ID' => $user_id,
     1770            )
     1771        );
     1772        clean_user_cache( $user_id );
     1773    }
     1774
     1775
     1776    /**
     1777     * Test the tests
     1778     *
     1779     * @covers Tests_Auth::set_user_password_with_plain_bcrypt
     1780     *
     1781     * @ticket 21022
     1782     */
     1783    public function test_set_user_password_with_plain_bcrypt() {
     1784        // Set the user password with plain bcrypt.
     1785        self::set_user_password_with_plain_bcrypt( 'password', self::$user_id );
     1786
     1787        // Ensure the password is hashed with bcrypt.
     1788        $hash = get_userdata( self::$user_id )->user_pass;
     1789        $this->assertStringStartsWith( '$2y$', $hash );
     1790    }
     1791
     1792    private static function set_user_password_with_plain_bcrypt( string $password, int $user_id ) {
     1793        global $wpdb;
     1794
     1795        $wpdb->update(
     1796            $wpdb->users,
     1797            array(
     1798                'user_pass' => password_hash( 'password', PASSWORD_BCRYPT ),
     1799            ),
     1800            array(
     1801                'ID' => $user_id,
     1802            )
     1803        );
     1804        clean_user_cache( $user_id );
     1805    }
     1806
     1807    /**
     1808     * Test the tests
     1809     *
     1810     * @covers Tests_Auth::set_application_password_with_phpass
     1811     *
     1812     * @ticket 21022
     1813     */
     1814    public function test_set_application_password_with_phpass() {
     1815        // Set an application password with the old phpass algorithm.
     1816        $uuid = self::set_application_password_with_phpass( 'password', self::$user_id );
     1817
     1818        // Ensure the password is hashed with phpass.
     1819        $hash = WP_Application_Passwords::get_user_application_password( self::$user_id, $uuid )['password'];
     1820        $this->assertStringStartsWith( '$P$', $hash );
     1821    }
     1822
     1823    private static function set_application_password_with_phpass( string $password, int $user_id ) {
     1824        $uuid = wp_generate_uuid4();
     1825        $item = array(
     1826            'uuid'      => $uuid,
     1827            'app_id'    => '',
     1828            'name'      => 'Test',
     1829            'password'  => self::$wp_hasher->HashPassword( $password ),
     1830            'created'   => time(),
     1831            'last_used' => null,
     1832            'last_ip'   => null,
     1833        );
     1834
     1835        $saved = update_user_meta(
     1836            $user_id,
     1837            WP_Application_Passwords::USERMETA_KEY_APPLICATION_PASSWORDS,
     1838            array( $item )
     1839        );
     1840
     1841        if ( ! $saved ) {
     1842            throw new Exception( 'Could not save application password.' );
     1843        }
     1844
     1845        update_network_option( get_main_network_id(), WP_Application_Passwords::OPTION_KEY_IN_USE, true );
     1846
     1847        return $uuid;
     1848    }
     1849
     1850    private static function get_default_bcrypt_cost(): int {
     1851        $hash = password_hash( 'password', PASSWORD_BCRYPT );
     1852        $info = password_get_info( $hash );
     1853
     1854        return $info['options']['cost'];
     1855    }
    9701856}
  • trunk/tests/phpunit/tests/pluggable/signatures.php

    r59578 r59828  
    215215            'wp_check_password'               => array(
    216216                'password',
     217                'hash',
     218                'user_id' => '',
     219            ),
     220            'wp_password_needs_rehash'        => array(
    217221                'hash',
    218222                'user_id' => '',
  • trunk/tests/phpunit/tests/user/passwordHash.php

    r56547 r59828  
    33/**
    44 * Tests for the PasswordHash external library.
     5 *
     6 * PasswordHash is no longer used to hash user passwords or security keys, but it is still used to
     7 * hash post passwords and as a fallback to verify old passwords that were hashed by phpass. The
     8 * library therefore needs to remain compatible with the latest versions of PHP.
    59 *
    610 * @covers PasswordHash
Note: See TracChangeset for help on using the changeset viewer.