Make WordPress Core


Ignore:
Timestamp:
02/17/2025 11:22:33 AM (3 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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • 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}
Note: See TracChangeset for help on using the changeset viewer.