WordPress.org

Make WordPress Core

Ticket #21022: 21022.3.diff

File 21022.3.diff, 19.6 KB (added by tomdxw, 3 years ago)

Use bcrypt where possible, upgrade portable hashes, show error message when downgrading to < PHP 5.3.7

  • new file wp-includes/class-wp-hasher.php

    commit e573532b959e7476cefa826a7b6ee2c929375d32
    Author: Tom Adams <tom@dxw.com>
    Date:   Tue Sep 27 11:29:48 2016 -0400
    
        Use bcrypt when available and fail gracefully
        
        - Move password hashing/verifying into new class
        - Use bcrypt if PHP version >= 5.3.7
        - Add compatibility library
        - Upgrade password hashes automatically
        - Show a message in the situation where a password is using bcrypt but
          PHP is not capable of verifying it
        
        Resolves: https://core.trac.wordpress.org/ticket/21022
    
    diff --git a/wp-includes/class-wp-hasher.php b/wp-includes/class-wp-hasher.php
    new file mode 100644
    index 0000000..9f6a9c5
    - +  
     1<?php
     2
     3class WP_Hasher {
     4        public function __construct() {
     5                // Load compatibility library to provide password_hash() and
     6                // password_verify() to pre-5.5.0 versions of PHP
     7                require_once ABSPATH . WPINC . '/compat-password.php';
     8        }
     9
     10        // Set up PasswordHash class
     11        private function InitializePasswordHash() {
     12                require_once ABSPATH . WPINC . '/class-phpass.php';
     13                $this->hasher = new PasswordHash( 8, true );
     14        }
     15
     16        // Test whether this PHP installation can use bcrypt
     17        public function UseBcrypt() {
     18                // Versions before 5.3.7 have bugs with their bcrypt implementations
     19                return version_compare( PHP_VERSION, '5.3.7', '>=' );
     20        }
     21
     22        // Test for a portable hash
     23        public function HashIsPortable( $hash  ) {
     24                return substr( $hash, 0, 3 ) === '$P$';
     25        }
     26
     27        public function HashPassword( $password ) {
     28                if ( $this->UseBcrypt() ) {
     29                        return password_hash( $password, PASSWORD_BCRYPT );
     30                } else {
     31                        $this->InitializePasswordHash();
     32                        return $this->hasher->HashPassword( $password );
     33                }
     34        }
     35
     36        public function CheckPassword( $password, $hash ) {
     37                if ( $this->UseBcrypt() && ! $this->HashIsPortable( $hash ) ) {
     38                        return password_verify( $password, $hash );
     39                } else {
     40                        $this->InitializePasswordHash();
     41                        return $this->hasher->CheckPassword( $password, $hash );
     42                }
     43        }
     44}
  • new file wp-includes/compat-password.php

    diff --git a/wp-includes/compat-password.php b/wp-includes/compat-password.php
    new file mode 100644
    index 0000000..cc6896c
    - +  
     1<?php
     2/**
     3 * A Compatibility library with PHP 5.5's simplified password hashing API.
     4 *
     5 * @author Anthony Ferrara <ircmaxell@php.net>
     6 * @license http://www.opensource.org/licenses/mit-license.html MIT License
     7 * @copyright 2012 The Authors
     8 */
     9
     10namespace {
     11
     12    if (!defined('PASSWORD_BCRYPT')) {
     13        /**
     14         * PHPUnit Process isolation caches constants, but not function declarations.
     15         * So we need to check if the constants are defined separately from
     16         * the functions to enable supporting process isolation in userland
     17         * code.
     18         */
     19        define('PASSWORD_BCRYPT', 1);
     20        define('PASSWORD_DEFAULT', PASSWORD_BCRYPT);
     21        define('PASSWORD_BCRYPT_DEFAULT_COST', 10);
     22    }
     23
     24    if (!function_exists('password_hash')) {
     25
     26        /**
     27         * Hash the password using the specified algorithm
     28         *
     29         * @param string $password The password to hash
     30         * @param int    $algo     The algorithm to use (Defined by PASSWORD_* constants)
     31         * @param array  $options  The options for the algorithm to use
     32         *
     33         * @return string|false The hashed password, or false on error.
     34         */
     35        function password_hash($password, $algo, array $options = array()) {
     36            if (!function_exists('crypt')) {
     37                trigger_error("Crypt must be loaded for password_hash to function", E_USER_WARNING);
     38                return null;
     39            }
     40            if (is_null($password) || is_int($password)) {
     41                $password = (string) $password;
     42            }
     43            if (!is_string($password)) {
     44                trigger_error("password_hash(): Password must be a string", E_USER_WARNING);
     45                return null;
     46            }
     47            if (!is_int($algo)) {
     48                trigger_error("password_hash() expects parameter 2 to be long, " . gettype($algo) . " given", E_USER_WARNING);
     49                return null;
     50            }
     51            $resultLength = 0;
     52            switch ($algo) {
     53                case PASSWORD_BCRYPT:
     54                    $cost = PASSWORD_BCRYPT_DEFAULT_COST;
     55                    if (isset($options['cost'])) {
     56                        $cost = $options['cost'];
     57                        if ($cost < 4 || $cost > 31) {
     58                            trigger_error(sprintf("password_hash(): Invalid bcrypt cost parameter specified: %d", $cost), E_USER_WARNING);
     59                            return null;
     60                        }
     61                    }
     62                    // The length of salt to generate
     63                    $raw_salt_len = 16;
     64                    // The length required in the final serialization
     65                    $required_salt_len = 22;
     66                    $hash_format = sprintf("$2y$%02d$", $cost);
     67                    // The expected length of the final crypt() output
     68                    $resultLength = 60;
     69                    break;
     70                default:
     71                    trigger_error(sprintf("password_hash(): Unknown password hashing algorithm: %s", $algo), E_USER_WARNING);
     72                    return null;
     73            }
     74            $salt_requires_encoding = false;
     75            if (isset($options['salt'])) {
     76                switch (gettype($options['salt'])) {
     77                    case 'NULL':
     78                    case 'boolean':
     79                    case 'integer':
     80                    case 'double':
     81                    case 'string':
     82                        $salt = (string) $options['salt'];
     83                        break;
     84                    case 'object':
     85                        if (method_exists($options['salt'], '__tostring')) {
     86                            $salt = (string) $options['salt'];
     87                            break;
     88                        }
     89                    case 'array':
     90                    case 'resource':
     91                    default:
     92                        trigger_error('password_hash(): Non-string salt parameter supplied', E_USER_WARNING);
     93                        return null;
     94                }
     95                if (PasswordCompat\binary\_strlen($salt) < $required_salt_len) {
     96                    trigger_error(sprintf("password_hash(): Provided salt is too short: %d expecting %d", PasswordCompat\binary\_strlen($salt), $required_salt_len), E_USER_WARNING);
     97                    return null;
     98                } elseif (0 == preg_match('#^[a-zA-Z0-9./]+$#D', $salt)) {
     99                    $salt_requires_encoding = true;
     100                }
     101            } else {
     102                $buffer = '';
     103                $buffer_valid = false;
     104                if (function_exists('mcrypt_create_iv') && !defined('PHALANGER')) {
     105                    $buffer = mcrypt_create_iv($raw_salt_len, MCRYPT_DEV_URANDOM);
     106                    if ($buffer) {
     107                        $buffer_valid = true;
     108                    }
     109                }
     110                if (!$buffer_valid && function_exists('openssl_random_pseudo_bytes')) {
     111                    $buffer = openssl_random_pseudo_bytes($raw_salt_len);
     112                    if ($buffer) {
     113                        $buffer_valid = true;
     114                    }
     115                }
     116                if (!$buffer_valid && @is_readable('/dev/urandom')) {
     117                    $f = fopen('/dev/urandom', 'r');
     118                    $read = PasswordCompat\binary\_strlen($buffer);
     119                    while ($read < $raw_salt_len) {
     120                        $buffer .= fread($f, $raw_salt_len - $read);
     121                        $read = PasswordCompat\binary\_strlen($buffer);
     122                    }
     123                    fclose($f);
     124                    if ($read >= $raw_salt_len) {
     125                        $buffer_valid = true;
     126                    }
     127                }
     128                if (!$buffer_valid || PasswordCompat\binary\_strlen($buffer) < $raw_salt_len) {
     129                    $bl = PasswordCompat\binary\_strlen($buffer);
     130                    for ($i = 0; $i < $raw_salt_len; $i++) {
     131                        if ($i < $bl) {
     132                            $buffer[$i] = $buffer[$i] ^ chr(mt_rand(0, 255));
     133                        } else {
     134                            $buffer .= chr(mt_rand(0, 255));
     135                        }
     136                    }
     137                }
     138                $salt = $buffer;
     139                $salt_requires_encoding = true;
     140            }
     141            if ($salt_requires_encoding) {
     142                // encode string with the Base64 variant used by crypt
     143                $base64_digits =
     144                    'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
     145                $bcrypt64_digits =
     146                    './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
     147
     148                $base64_string = base64_encode($salt);
     149                $salt = strtr(rtrim($base64_string, '='), $base64_digits, $bcrypt64_digits);
     150            }
     151            $salt = PasswordCompat\binary\_substr($salt, 0, $required_salt_len);
     152
     153            $hash = $hash_format . $salt;
     154
     155            $ret = crypt($password, $hash);
     156
     157            if (!is_string($ret) || PasswordCompat\binary\_strlen($ret) != $resultLength) {
     158                return false;
     159            }
     160
     161            return $ret;
     162        }
     163
     164        /**
     165         * Get information about the password hash. Returns an array of the information
     166         * that was used to generate the password hash.
     167         *
     168         * array(
     169         *    'algo' => 1,
     170         *    'algoName' => 'bcrypt',
     171         *    'options' => array(
     172         *        'cost' => PASSWORD_BCRYPT_DEFAULT_COST,
     173         *    ),
     174         * )
     175         *
     176         * @param string $hash The password hash to extract info from
     177         *
     178         * @return array The array of information about the hash.
     179         */
     180        function password_get_info($hash) {
     181            $return = array(
     182                'algo' => 0,
     183                'algoName' => 'unknown',
     184                'options' => array(),
     185            );
     186            if (PasswordCompat\binary\_substr($hash, 0, 4) == '$2y$' && PasswordCompat\binary\_strlen($hash) == 60) {
     187                $return['algo'] = PASSWORD_BCRYPT;
     188                $return['algoName'] = 'bcrypt';
     189                list($cost) = sscanf($hash, "$2y$%d$");
     190                $return['options']['cost'] = $cost;
     191            }
     192            return $return;
     193        }
     194
     195        /**
     196         * Determine if the password hash needs to be rehashed according to the options provided
     197         *
     198         * If the answer is true, after validating the password using password_verify, rehash it.
     199         *
     200         * @param string $hash    The hash to test
     201         * @param int    $algo    The algorithm used for new password hashes
     202         * @param array  $options The options array passed to password_hash
     203         *
     204         * @return boolean True if the password needs to be rehashed.
     205         */
     206        function password_needs_rehash($hash, $algo, array $options = array()) {
     207            $info = password_get_info($hash);
     208            if ($info['algo'] != $algo) {
     209                return true;
     210            }
     211            switch ($algo) {
     212                case PASSWORD_BCRYPT:
     213                    $cost = isset($options['cost']) ? $options['cost'] : PASSWORD_BCRYPT_DEFAULT_COST;
     214                    if ($cost != $info['options']['cost']) {
     215                        return true;
     216                    }
     217                    break;
     218            }
     219            return false;
     220        }
     221
     222        /**
     223         * Verify a password against a hash using a timing attack resistant approach
     224         *
     225         * @param string $password The password to verify
     226         * @param string $hash     The hash to verify against
     227         *
     228         * @return boolean If the password matches the hash
     229         */
     230        function password_verify($password, $hash) {
     231            if (!function_exists('crypt')) {
     232                trigger_error("Crypt must be loaded for password_verify to function", E_USER_WARNING);
     233                return false;
     234            }
     235            $ret = crypt($password, $hash);
     236            if (!is_string($ret) || PasswordCompat\binary\_strlen($ret) != PasswordCompat\binary\_strlen($hash) || PasswordCompat\binary\_strlen($ret) <= 13) {
     237                return false;
     238            }
     239
     240            $status = 0;
     241            for ($i = 0; $i < PasswordCompat\binary\_strlen($ret); $i++) {
     242                $status |= (ord($ret[$i]) ^ ord($hash[$i]));
     243            }
     244
     245            return $status === 0;
     246        }
     247    }
     248
     249}
     250
     251namespace PasswordCompat\binary {
     252
     253    if (!function_exists('PasswordCompat\\binary\\_strlen')) {
     254
     255        /**
     256         * Count the number of bytes in a string
     257         *
     258         * We cannot simply use strlen() for this, because it might be overwritten by the mbstring extension.
     259         * In this case, strlen() will count the number of *characters* based on the internal encoding. A
     260         * sequence of bytes might be regarded as a single multibyte character.
     261         *
     262         * @param string $binary_string The input string
     263         *
     264         * @internal
     265         * @return int The number of bytes
     266         */
     267        function _strlen($binary_string) {
     268            if (function_exists('mb_strlen')) {
     269                return mb_strlen($binary_string, '8bit');
     270            }
     271            return strlen($binary_string);
     272        }
     273
     274        /**
     275         * Get a substring based on byte limits
     276         *
     277         * @see _strlen()
     278         *
     279         * @param string $binary_string The input string
     280         * @param int    $start
     281         * @param int    $length
     282         *
     283         * @internal
     284         * @return string The substring
     285         */
     286        function _substr($binary_string, $start, $length) {
     287            if (function_exists('mb_substr')) {
     288                return mb_substr($binary_string, $start, $length, '8bit');
     289            }
     290            return substr($binary_string, $start, $length);
     291        }
     292
     293        /**
     294         * Check if current PHP version is compatible with the library
     295         *
     296         * @return boolean the check result
     297         */
     298        function check() {
     299            static $pass = NULL;
     300
     301            if (is_null($pass)) {
     302                if (function_exists('crypt')) {
     303                    $hash = '$2y$04$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG';
     304                    $test = crypt("password", $hash);
     305                    $pass = $test == $hash;
     306                } else {
     307                    $pass = false;
     308                }
     309            }
     310            return $pass;
     311        }
     312
     313    }
     314}
     315 No newline at end of file
  • wp-includes/default-filters.php

    diff --git a/wp-includes/default-filters.php b/wp-includes/default-filters.php
    index 3402e48..bc7843f 100644
    a b add_action( 'login_head', 'print_admin_styles', 9 ); 
    262262add_action( 'login_head',          'wp_site_icon',                  99    );
    263263add_action( 'login_footer',        'wp_print_footer_scripts',       20    );
    264264add_action( 'login_init',          'send_frame_options_header',     10, 0 );
     265add_filter( 'check_password',      'wp_upgrade_password',           10, 4 );
     266add_filter( 'wp_authenticate_user','wp_user_can_authenticate',      10, 1 );
    265267
    266268// Feed Generator Tags
    267269foreach ( array( 'rss2_head', 'commentsrss2_head', 'rss_head', 'rdf_header', 'atom_head', 'comments_atom_head', 'opml_head', 'app_head' ) as $action ) {
  • wp-includes/pluggable.php

    diff --git a/wp-includes/pluggable.php b/wp-includes/pluggable.php
    index 5c77477..2397560 100644
    a b function wp_new_user_notification( $user_id, $deprecated = null, $notify = '' ) 
    17391739
    17401740        // Now insert the key, hashed, into the DB.
    17411741        if ( empty( $wp_hasher ) ) {
    1742                 require_once ABSPATH . WPINC . '/class-phpass.php';
    1743                 $wp_hasher = new PasswordHash( 8, true );
     1742                require_once ABSPATH . WPINC . '/class-wp-hasher.php';
     1743                $wp_hasher = new WP_Hasher();
    17441744        }
    17451745        $hashed = time() . ':' . $wp_hasher->HashPassword( $key );
    17461746        $wpdb->update( $wpdb->users, array( 'user_activation_key' => $hashed ), array( 'user_login' => $user->user_login ) );
    function wp_hash_password($password) { 
    20142014        global $wp_hasher;
    20152015
    20162016        if ( empty($wp_hasher) ) {
    2017                 require_once( ABSPATH . WPINC . '/class-phpass.php');
     2017                require_once( ABSPATH . WPINC . '/class-wp-hasher.php');
    20182018                // By default, use the portable hash from phpass
    2019                 $wp_hasher = new PasswordHash(8, true);
     2019                $wp_hasher = new WP_Hasher();
    20202020        }
    20212021
    20222022        return $wp_hasher->HashPassword( trim( $password ) );
    function wp_check_password($password, $hash, $user_id = '') { 
    20742074        // If the stored hash is longer than an MD5, presume the
    20752075        // new style phpass portable hash.
    20762076        if ( empty($wp_hasher) ) {
    2077                 require_once( ABSPATH . WPINC . '/class-phpass.php');
     2077                require_once( ABSPATH . WPINC . '/class-wp-hasher.php');
    20782078                // By default, use the portable hash from phpass
    2079                 $wp_hasher = new PasswordHash(8, true);
     2079                $wp_hasher = new WP_Hasher();
    20802080        }
    20812081
    20822082        $check = $wp_hasher->CheckPassword($password, $hash);
  • wp-includes/user.php

    diff --git a/wp-includes/user.php b/wp-includes/user.php
    index 6d473ef..36cb303 100644
    a b function get_password_reset_key( $user ) { 
    20952095
    20962096        // Now insert the key, hashed, into the DB.
    20972097        if ( empty( $wp_hasher ) ) {
    2098                 require_once ABSPATH . WPINC . '/class-phpass.php';
    2099                 $wp_hasher = new PasswordHash( 8, true );
     2098                require_once ABSPATH . WPINC . '/class-wp-hasher.php';
     2099                $wp_hasher = new WP_Hasher();
    21002100        }
    21012101        $hashed = time() . ':' . $wp_hasher->HashPassword( $key );
    21022102        $key_saved = $wpdb->update( $wpdb->users, array( 'user_activation_key' => $hashed ), array( 'user_login' => $user->user_login ) );
    function check_password_reset_key($key, $login) { 
    21402140                return new WP_Error('invalid_key', __('Invalid key'));
    21412141
    21422142        if ( empty( $wp_hasher ) ) {
    2143                 require_once ABSPATH . WPINC . '/class-phpass.php';
    2144                 $wp_hasher = new PasswordHash( 8, true );
     2143                require_once ABSPATH . WPINC . '/class-wp-hasher.php';
     2144                $wp_hasher = new WP_Hasher();
    21452145        }
    21462146
    21472147        /**
    function _wp_get_current_user() { 
    25072507
    25082508        return $current_user;
    25092509}
     2510
     2511/**
     2512 * Upgrades user's password from "portable" hashes to bcrypt.
     2513 */
     2514 function wp_upgrade_password( $check, $password, $hash, $user_id ) {
     2515         if ( $check && substr( $hash, 0, 3 ) === '$P$' ) {
     2516                 wp_set_password( $password, $user_id );
     2517         }
     2518
     2519         return $check;
     2520 }
     2521
     2522 /**
     2523  * Tests to make sure the user's password hash can be used.
     2524  */
     2525 function wp_user_can_authenticate( $user ) {
     2526         global $wp_hasher;
     2527
     2528         if ( empty( $wp_hasher ) ) {
     2529                 require_once ABSPATH . WPINC . '/class-wp-hasher.php';
     2530                 $wp_hasher = new WP_Hasher();
     2531         }
     2532
     2533         if (!$wp_hasher->UseBcrypt() && !$wp_hasher->HashIsPortable( $user->user_pass )) {
     2534                 return new WP_Error(
     2535                         'unusable_password',
     2536                         __( '<strong>ERROR</strong>: Sorry, something has gone wrong and you must reset your password.' ) .
     2537                         '<br><a href="https://codex.wordpress.org/Foobar">' .
     2538                         __( 'More information' ) .
     2539                         '</a>' .
     2540                         '<br><a href="' . wp_lostpassword_url() . '">' .
     2541                         __( 'Reset your password' ) .
     2542                         '</a>'
     2543                 );
     2544         }
     2545
     2546         return $user;
     2547 }
  • wp-login.php

    diff --git a/wp-login.php b/wp-login.php
    index 01c754f..c622123 100644
    a b case 'postpass' : 
    444444                exit();
    445445        }
    446446
    447         require_once ABSPATH . WPINC . '/class-phpass.php';
    448         $hasher = new PasswordHash( 8, true );
     447        require_once ABSPATH . WPINC . '/class-wp-hasher.php';
     448        $hasher = new WP_Hasher();
    449449
    450450        /**
    451451         * Filters the life span of the post password cookie.