diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index 4dd7fb00eb0e6..c5a566e486bbc 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -2618,6 +2618,10 @@ function wp_hash_password( $password ) { return $wp_hasher->HashPassword( trim( $password ) ); } + if ( strlen( $password ) > 4096 ) { + return '*'; + } + /** * Filters the options passed to the password_hash() and password_needs_rehash() functions. * @@ -2629,7 +2633,11 @@ function wp_hash_password( $password ) { */ $options = apply_filters( 'wp_hash_password_options', array() ); - return password_hash( trim( $password ), PASSWORD_BCRYPT, $options ); + // Use sha384 to retain entropy from a password that's longer than 72 bytes, and a wp-sha384 key for domain separation. + $password_to_hash = base64_encode( hash_hmac( 'sha384', trim( $password ), 'wp-sha384', true ) ); + + // Add a `wp-` prefix to facilitate distinguishing vanilla bcrypt hashes. + return 'wp-' . password_hash( $password_to_hash, PASSWORD_BCRYPT, $options ); } endif; @@ -2682,13 +2690,21 @@ function wp_check_password( $password, $hash, $user_id = '' ) { } if ( ! empty( $wp_hasher ) ) { + // Check the password using the overridden hasher. $check = $wp_hasher->CheckPassword( $password, $hash ); + } elseif ( strlen( $password ) > 4096 ) { + $check = false; + } elseif ( str_starts_with( $hash, 'wp-' ) ) { + // Check the password using the current `wp-` prefixed hash. + $password_to_verify = base64_encode( hash_hmac( 'sha384', $password, 'wp-sha384', true ) ); + $check = password_verify( $password_to_verify, substr( $hash, 3 ) ); } elseif ( str_starts_with( $hash, '$P$' ) ) { + // Check the password using phpass. require_once ABSPATH . WPINC . '/class-phpass.php'; - // Use the portable hash from phpass. $hasher = new PasswordHash( 8, true ); $check = $hasher->CheckPassword( $password, $hash ); } else { + // Check the password using compat support for any non-prefixed hash. $check = password_verify( $password, $hash ); } @@ -2720,10 +2736,14 @@ function wp_password_needs_rehash( $hash ) { return false; } + if ( ! str_starts_with( $hash, 'wp-' ) ) { + return true; + } + /** This filter is documented in wp-includes/pluggable.php */ $options = apply_filters( 'wp_hash_password_options', array() ); - return password_needs_rehash( $hash, PASSWORD_BCRYPT, $options ); + return password_needs_rehash( substr( $hash, 3 ), PASSWORD_BCRYPT, $options ); } endif; diff --git a/tests/phpunit/tests/auth.php b/tests/phpunit/tests/auth.php index 01b7ea989203b..66e6c75ee4579 100644 --- a/tests/phpunit/tests/auth.php +++ b/tests/phpunit/tests/auth.php @@ -34,6 +34,8 @@ class Tests_Auth extends WP_UnitTestCase { protected static $phpass_length_limit = 4096; + protected static $password_length_limit = 4096; + /** * Action hook. */ @@ -199,14 +201,15 @@ public function test_wp_check_password_supports_phpass_hash() { */ public function test_wp_check_password_supports_hash_with_increased_bcrypt_cost() { $password = 'password'; - $default = self::get_default_bcrypt_cost(); - $options = array( - // Reducing the cost mimics an increase to the default cost. - 'cost' => $default - 1, - ); - $hash = password_hash( trim( $password ), PASSWORD_BCRYPT, $options ); + + // Reducing the cost mimics an increase to the default cost. + add_filter( 'wp_hash_password_options', array( $this, 'reduce_hash_cost' ) ); + $hash = wp_hash_password( $password, PASSWORD_BCRYPT ); + remove_filter( 'wp_hash_password_options', array( $this, 'reduce_hash_cost' ) ); + $this->assertTrue( wp_check_password( $password, $hash ) ); $this->assertSame( 1, did_filter( 'check_password' ) ); + $this->assertTrue( wp_password_needs_rehash( $hash ) ); } /** @@ -222,29 +225,43 @@ public function test_wp_check_password_supports_hash_with_increased_bcrypt_cost( */ public function test_wp_check_password_supports_hash_with_reduced_bcrypt_cost() { $password = 'password'; - $default = self::get_default_bcrypt_cost(); - $options = array( - // Increasing the cost mimics a reduction of the default cost. - 'cost' => $default + 1, - ); - $hash = password_hash( trim( $password ), PASSWORD_BCRYPT, $options ); + + // Increasing the cost mimics a reduction of the default cost. + add_filter( 'wp_hash_password_options', array( $this, 'increase_hash_cost' ) ); + $hash = wp_hash_password( $password, PASSWORD_BCRYPT ); + remove_filter( 'wp_hash_password_options', array( $this, 'increase_hash_cost' ) ); + $this->assertTrue( wp_check_password( $password, $hash ) ); $this->assertSame( 1, did_filter( 'check_password' ) ); + $this->assertTrue( wp_password_needs_rehash( $hash ) ); } /** * @ticket 21022 * @ticket 50027 */ - public function test_wp_check_password_supports_hash_with_default_bcrypt_cost() { + public function test_wp_check_password_supports_wp_hash_with_default_bcrypt_cost() { $password = 'password'; - $default = self::get_default_bcrypt_cost(); - $options = array( - 'cost' => $default, - ); - $hash = password_hash( trim( $password ), PASSWORD_BCRYPT, $options ); + + $hash = wp_hash_password( $password, PASSWORD_BCRYPT ); + + $this->assertTrue( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + $this->assertFalse( wp_password_needs_rehash( $hash ) ); + } + + /** + * @ticket 21022 + * @ticket 50027 + */ + public function test_wp_check_password_supports_plain_bcrypt_hash_with_default_bcrypt_cost() { + $password = 'password'; + + $hash = password_hash( $password, PASSWORD_BCRYPT ); + $this->assertTrue( wp_check_password( $password, $hash ) ); $this->assertSame( 1, did_filter( 'check_password' ) ); + $this->assertTrue( wp_password_needs_rehash( $hash ) ); } /** @@ -437,7 +454,7 @@ public function test_password_is_hashed_with_bcrypt() { wp_set_password( $password, self::$user_id ); // Ensure the password is hashed with bcrypt. - $this->assertStringStartsWith( '$2y$', get_userdata( self::$user_id )->user_pass ); + $this->assertStringStartsWith( 'wp-$2y$', get_userdata( self::$user_id )->user_pass ); // Authenticate. $user = wp_authenticate( $this->user->user_login, $password ); @@ -518,6 +535,54 @@ public function test_valid_password_beyond_bcrypt_length_limit_is_accepted() { $this->assertSame( self::$user_id, $user->ID ); } + /** + * A password beyond 72 bytes will be truncated by bcrypt by default and still be accepted. + * + * This ensures that a truncated password is not accepted by WordPress. + * + * @ticket 21022 + * @ticket 50027 + */ + public function test_long_truncated_password_is_rejected() { + $at_limit = str_repeat( 'a', self::$bcrypt_length_limit ); + $beyond_limit = str_repeat( 'a', self::$bcrypt_length_limit + 1 ); + + // Set the user password beyond the bcrypt limit. + wp_set_password( $beyond_limit, self::$user_id ); + + // Authenticate using a truncated password. + $user = wp_authenticate( $this->user->user_login, $at_limit ); + + // Incorrect password. + $this->assertWPError( $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); + } + + /** + * @ticket 21022 + * @ticket 50027 + */ + public function test_setting_password_beyond_bcrypt_length_limit_is_rejected() { + $beyond_limit = str_repeat( 'a', self::$password_length_limit + 1 ); + + // Set the user password beyond the limit. + wp_set_password( $beyond_limit, self::$user_id ); + + // Password broken by setting it to be too long. + $user = get_user_by( 'id', self::$user_id ); + $this->assertSame( '*', $user->data->user_pass ); + + // Password is not accepted. + $user = wp_authenticate( $this->user->user_login, $beyond_limit ); + $this->assertInstanceOf( 'WP_Error', $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); + + // Placeholder is not accepted. + $user = wp_authenticate( $this->user->user_login, '*' ); + $this->assertInstanceOf( 'WP_Error', $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); + } + /** * @see https://core.trac.wordpress.org/changeset/30466 */ @@ -1004,7 +1069,7 @@ public function test_bcrypt_password_is_rehashed_with_new_cost_after_successful_ // Verify that the password has been rehashed with the increased cost. $hash = get_userdata( self::$user_id )->user_pass; $this->assertFalse( wp_password_needs_rehash( $hash ) ); - $this->assertSame( self::get_default_bcrypt_cost(), password_get_info( $hash )['options']['cost'] ); + $this->assertSame( self::get_default_bcrypt_cost(), password_get_info( substr( $hash, 3 ) )['options']['cost'] ); // Authenticate a second time to ensure the new hash is valid. $user = wp_authenticate( $username_or_email, $password ); @@ -1020,6 +1085,11 @@ public function reduce_hash_cost( array $options ): array { return $options; } + public function increase_hash_cost( array $options ): array { + $options['cost'] = self::get_default_bcrypt_cost() + 1; + return $options; + } + public function data_usernames() { return array( array( @@ -1051,7 +1121,7 @@ static function ( $options ) { $wp_hash = wp_hash_password( $password ); $valid = wp_check_password( $password, $wp_hash ); $needs_rehash = wp_password_needs_rehash( $wp_hash ); - $info = password_get_info( $wp_hash ); + $info = password_get_info( substr( $wp_hash, 3 ) ); $cost = $info['options']['cost']; $this->assertTrue( $valid );