From 19512b2595cef521099a95b1ab8c24dbe51c5b2f Mon Sep 17 00:00:00 2001 From: Marc Alexander Date: Sat, 14 Sep 2013 14:04:48 +0200 Subject: [feature/passwords] Rename "crypto" files to "passwords" files PHPBB3-11610 --- phpBB/config/crypto.yml | 54 ------ phpBB/config/passwords.yml | 54 ++++++ phpBB/config/services.yml | 2 +- phpBB/phpbb/crypto/driver/base.php | 68 -------- phpBB/phpbb/crypto/driver/bcrypt.php | 108 ------------ phpBB/phpbb/crypto/driver/bcrypt_2y.php | 48 ------ phpBB/phpbb/crypto/driver/helper.php | 143 ---------------- phpBB/phpbb/crypto/driver/interface.php | 75 --------- phpBB/phpbb/crypto/driver/phpass.php | 40 ----- phpBB/phpbb/crypto/driver/salted_md5.php | 152 ----------------- phpBB/phpbb/crypto/helper.php | 194 ---------------------- phpBB/phpbb/crypto/manager.php | 210 ------------------------ phpBB/phpbb/passwords/driver/base.php | 68 ++++++++ phpBB/phpbb/passwords/driver/bcrypt.php | 108 ++++++++++++ phpBB/phpbb/passwords/driver/bcrypt_2y.php | 48 ++++++ phpBB/phpbb/passwords/driver/helper.php | 143 ++++++++++++++++ phpBB/phpbb/passwords/driver/interface.php | 75 +++++++++ phpBB/phpbb/passwords/driver/phpass.php | 40 +++++ phpBB/phpbb/passwords/driver/salted_md5.php | 152 +++++++++++++++++ phpBB/phpbb/passwords/helper.php | 194 ++++++++++++++++++++++ phpBB/phpbb/passwords/manager.php | 210 ++++++++++++++++++++++++ tests/crypto/manager_test.php | 244 ---------------------------- tests/passwords/manager_test.php | 244 ++++++++++++++++++++++++++++ 23 files changed, 1337 insertions(+), 1337 deletions(-) delete mode 100644 phpBB/config/crypto.yml create mode 100644 phpBB/config/passwords.yml delete mode 100644 phpBB/phpbb/crypto/driver/base.php delete mode 100644 phpBB/phpbb/crypto/driver/bcrypt.php delete mode 100644 phpBB/phpbb/crypto/driver/bcrypt_2y.php delete mode 100644 phpBB/phpbb/crypto/driver/helper.php delete mode 100644 phpBB/phpbb/crypto/driver/interface.php delete mode 100644 phpBB/phpbb/crypto/driver/phpass.php delete mode 100644 phpBB/phpbb/crypto/driver/salted_md5.php delete mode 100644 phpBB/phpbb/crypto/helper.php delete mode 100644 phpBB/phpbb/crypto/manager.php create mode 100644 phpBB/phpbb/passwords/driver/base.php create mode 100644 phpBB/phpbb/passwords/driver/bcrypt.php create mode 100644 phpBB/phpbb/passwords/driver/bcrypt_2y.php create mode 100644 phpBB/phpbb/passwords/driver/helper.php create mode 100644 phpBB/phpbb/passwords/driver/interface.php create mode 100644 phpBB/phpbb/passwords/driver/phpass.php create mode 100644 phpBB/phpbb/passwords/driver/salted_md5.php create mode 100644 phpBB/phpbb/passwords/helper.php create mode 100644 phpBB/phpbb/passwords/manager.php delete mode 100644 tests/crypto/manager_test.php create mode 100644 tests/passwords/manager_test.php diff --git a/phpBB/config/crypto.yml b/phpBB/config/crypto.yml deleted file mode 100644 index 2ef942df66..0000000000 --- a/phpBB/config/crypto.yml +++ /dev/null @@ -1,54 +0,0 @@ -parameters: - password_hashing.algorithm: crypto.driver.bcrypt_2y - -services: - crypto.driver.bcrypt: - class: phpbb_crypto_driver_bcrypt - arguments: - - @config - calls: - - [set_name, [crypto.driver.bcrypt]] - tags: - - { name: crypto.driver } - - crypto.driver.bcrypt_2y: - class: phpbb_crypto_driver_bcrypt_2y - arguments: - - @config - calls: - - [set_name, [crypto.driver.bcrypt_2y]] - tags: - - { name: crypto.driver } - - crypto.driver.salted_md5: - class: phpbb_crypto_driver_salted_md5 - arguments: - - @config - calls: - - [set_name, [crypto.driver.salted_md5]] - tags: - - { name: crypto.driver } - - crypto.driver.phpass: - class: phpbb_crypto_driver_phpass - arguments: - - @config - calls: - - [set_name, [crypto.driver.phpass]] - tags: - - { name: crypto.driver } - - crypto.driver_collection: - class: phpbb_di_service_collection - arguments: - - @service_container - tags: - - { name: service_collection, tag: crypto.driver } - - crypto.manager: - class: phpbb_crypto_manager - arguments: - - @config - - @service_container - - @crypto.driver_collection - - %password_hashing.algorithm% diff --git a/phpBB/config/passwords.yml b/phpBB/config/passwords.yml new file mode 100644 index 0000000000..c9f9e238e8 --- /dev/null +++ b/phpBB/config/passwords.yml @@ -0,0 +1,54 @@ +parameters: + passwords.algorithm: passwords.driver.bcrypt_2y + +services: + passwords.driver.bcrypt: + class: phpbb_passwords_driver_bcrypt + arguments: + - @config + calls: + - [set_name, [passwords.driver.bcrypt]] + tags: + - { name: passwords.driver } + + passwords.driver.bcrypt_2y: + class: phpbb_passwords_driver_bcrypt_2y + arguments: + - @config + calls: + - [set_name, [passwords.driver.bcrypt_2y]] + tags: + - { name: passwords.driver } + + passwords.driver.salted_md5: + class: phpbb_passwords_driver_salted_md5 + arguments: + - @config + calls: + - [set_name, [passwords.driver.salted_md5]] + tags: + - { name: passwords.driver } + + passwords.driver.phpass: + class: phpbb_passwords_driver_phpass + arguments: + - @config + calls: + - [set_name, [passwords.driver.phpass]] + tags: + - { name: passwords.driver } + + passwords.driver_collection: + class: phpbb_di_service_collection + arguments: + - @service_container + tags: + - { name: service_collection, tag: passwords.driver } + + passwords.manager: + class: phpbb_passwords_manager + arguments: + - @config + - @service_container + - @passwords.driver_collection + - %passwords.algorithm% diff --git a/phpBB/config/services.yml b/phpBB/config/services.yml index 486df3f556..576e16d5d4 100644 --- a/phpBB/config/services.yml +++ b/phpBB/config/services.yml @@ -6,7 +6,7 @@ imports: - { resource: avatars.yml } - { resource: feed.yml } - { resource: auth_providers.yml } - - { resource: crypto.yml } + - { resource: passwords.yml } services: acl.permissions: diff --git a/phpBB/phpbb/crypto/driver/base.php b/phpBB/phpbb/crypto/driver/base.php deleted file mode 100644 index f75031dabd..0000000000 --- a/phpBB/phpbb/crypto/driver/base.php +++ /dev/null @@ -1,68 +0,0 @@ -config = $config; - $this->helper = new phpbb_crypto_driver_helper($this); - } - - /** - * @inheritdoc - */ - public function is_supported() - { - return true; - } - - /** - * @inheritdoc - */ - public function get_name() - { - return $this->name; - } - - /** - * Set driver name - * - * @param string $name Driver name - */ - public function set_name($name) - { - $this->name = $name; - } -} diff --git a/phpBB/phpbb/crypto/driver/bcrypt.php b/phpBB/phpbb/crypto/driver/bcrypt.php deleted file mode 100644 index ad5a8036c3..0000000000 --- a/phpBB/phpbb/crypto/driver/bcrypt.php +++ /dev/null @@ -1,108 +0,0 @@ -is_supported()) ? '$2a$' : $this->get_prefix(); - - if ($salt == '') - { - $salt = $prefix . '10$' . $this->get_random_salt(); - } - - $hash = crypt($password, $salt); - if (strlen($hash) < 60) - { - return false; - } - return $hash; - } - - /** - * @inheritdoc - */ - public function check($password, $hash) - { - $salt = substr($hash, 0, 29); - if (strlen($salt) != 29) - { - return false; - } - - if ($hash == $this->hash($password, $salt)) - { - return true; - } - return false; - } - - /** - * Get a random salt value with a length of 22 characters - * - * @return string Salt for password hashing - */ - protected function get_random_salt() - { - return $this->helper->hash_encode64($this->helper->get_random_salt(22), 22); - } - - /** - * @inheritdoc - */ - public function get_settings_only($hash, $full = false) - { - if ($full) - { - $pos = stripos($hash, '$', 1) + 1; - $length = 22 + (strripos($hash, '$') + 1 - $pos); - } - else - { - $pos = strripos($hash, '$') + 1; - $length = 22; - } - return substr($hash, $pos, $length); - } -} diff --git a/phpBB/phpbb/crypto/driver/bcrypt_2y.php b/phpBB/phpbb/crypto/driver/bcrypt_2y.php deleted file mode 100644 index 8bce171a25..0000000000 --- a/phpBB/phpbb/crypto/driver/bcrypt_2y.php +++ /dev/null @@ -1,48 +0,0 @@ -driver = $driver; - } - - /** - * Base64 encode hash - * - * @param string $input Input string - * @param int $count Input string length - * - * @return string base64 encoded string - */ - public function hash_encode64($input, $count) - { - $output = ''; - $i = 0; - - do - { - $value = ord($input[$i++]); - $output .= $this->itoa64[$value & 0x3f]; - - if ($i < $count) - { - $value |= ord($input[$i]) << 8; - } - - $output .= $this->itoa64[($value >> 6) & 0x3f]; - - if ($i++ >= $count) - { - break; - } - - if ($i < $count) - { - $value |= ord($input[$i]) << 16; - } - - $output .= $this->itoa64[($value >> 12) & 0x3f]; - - if ($i++ >= $count) - { - break; - } - - $output .= $this->itoa64[($value >> 18) & 0x3f]; - } - while ($i < $count); - - return $output; - } - - /** - * Return unique id - * @param string $extra additional entropy - * - * @return string Unique id - */ - public function unique_id($extra = 'c') - { - static $dss_seeded = false; - global $config; - - $val = $config['rand_seed'] . microtime(); - $val = md5($val); - $config['rand_seed'] = md5($config['rand_seed'] . $val . $extra); - - if ($dss_seeded !== true && ($config['rand_seed_last_update'] < time() - rand(1,10))) - { - set_config('rand_seed_last_update', time(), true); - set_config('rand_seed', $config['rand_seed'], true); - $dss_seeded = true; - } - - return substr($val, 4, 16); - } - - /** - * Get random salt with specified length - * - * @param int $length Salt length - */ - public function get_random_salt($length) - { - $random = ''; - - if (($fh = @fopen('/dev/urandom', 'rb'))) - { - $random = fread($fh, $length); - fclose($fh); - } - - if (strlen($random) < $length) - { - $random = ''; - $random_state = $this->unique_id(); - - for ($i = 0; $i < $length; $i += 16) - { - $random_state = md5($this->unique_id() . $random_state); - $random .= pack('H*', md5($random_state)); - } - $random = substr($random, 0, $length); - } - return $random; - } -} diff --git a/phpBB/phpbb/crypto/driver/interface.php b/phpBB/phpbb/crypto/driver/interface.php deleted file mode 100644 index 68313fbedd..0000000000 --- a/phpBB/phpbb/crypto/driver/interface.php +++ /dev/null @@ -1,75 +0,0 @@ -get_hash_settings($setting)) === false) - { - return false; - } - } - else - { - if (($settings = $this->get_hash_settings($this->generate_salt())) === false) - { - return false; - } - } - - $hash = md5($settings['salt'] . $password, true); - do - { - $hash = md5($hash . $password, true); - } - while (--$settings['count']); - - $output = $settings['full']; - $output .= $this->helper->hash_encode64($hash, 16); - - if (strlen($output) == 34) - { - return $output; - } - - // Should we really just return the md5 of the password? O.o - return md5($password); - } - - /** - * @inheritdoc - */ - public function check($password, $hash) - { - if (strlen($hash) !== 34) - { - return (md5($password) === $hash) ? true : false; - } - // No need to check prefix, already did that in manage - - if ($hash === $this->hash($password, $hash)) - { - return true; - } - return false; - } - - /** - * Generate salt for hashing method - * - * @return string Salt for hashing method - */ - protected function generate_salt() - { - $salt = ''; - $random = ''; - $count = 6; - - $random = $this->helper->get_random_salt($count); - - $salt = $this->get_prefix(); - $salt .= $this->helper->itoa64[min($count + 5, 30)]; - $salt .= $this->helper->hash_encode64($random, $count); - - return $salt; - } - - /** - * Get hash settings - * - * @return array Array containing the count_log2, salt, and full hash - * settings string - */ - public function get_hash_settings($hash) - { - if (empty($hash)) - { - return false; - } - $count_log2 = strpos($this->helper->itoa64, $hash[3]); - $salt = substr($hash, 4, 8); - - if ($count_log2 < 7 || $count_log2 > 30 || strlen($salt) != 8) - { - return false; - } - - return array( - 'count' => 1 << $count_log2, - 'salt' => $salt, - 'full' => substr($hash, 0, 12), - ); - } - - /** - * @inheritdoc - */ - public function get_settings_only($hash, $full = false) - { - return substr($hash, 3, 9); - } -} diff --git a/phpBB/phpbb/crypto/helper.php b/phpBB/phpbb/crypto/helper.php deleted file mode 100644 index 9c802b3c01..0000000000 --- a/phpBB/phpbb/crypto/helper.php +++ /dev/null @@ -1,194 +0,0 @@ -manager = $manager; - $this->container = $container; - } - - /** - * Get hash settings from combined hash - * - * @param string $hash Password hash of combined hash - * - * @return array An array containing the hash settings for the hash - * types in successive order as described by the comined - * password hash - */ - protected function get_combined_hash_settings($hash) - { - preg_match('#^\$([a-zA-Z0-9\\\]*?)\$#', $hash, $match); - $hash_settings = substr($hash, strpos($hash, $match[1]) + strlen($match[1]) + 1); - $matches = explode('\\', $match[1]); - foreach ($matches as $cur_type) - { - $dollar_position = strpos($hash_settings, '$'); - $output[] = substr($hash_settings, 0, ($dollar_position != false) ? $dollar_position : strlen($hash_settings)); - $hash_settings = substr($hash_settings, $dollar_position + 1); - } - - return $output; - } - - /** - * Create combined hash from already hashed password - * - * @param string $password_hash Complete current password hash - * @param string $type Type of the hashing algorithm the password hash - * should be combined with - * @return string|bool Combined password hash if combined hashing was - * successful, else false - */ - public function combined_hash_password($password_hash, $type) - { - $data = array( - 'prefix' => '$', - 'settings' => '$', - ); - $hash_settings = $this->get_combined_hash_settings($password_hash); - $hash = $hash_settings[0]; - - // Put settings of current hash into data array - $stored_hash_type = $this->manager->get_hashing_algorithm($password_hash); - $this->combine_hash_output($data, 'prefix', $stored_hash_type->get_prefix()); - $this->combine_hash_output($data, 'settings', $stored_hash_type->get_settings_only($password_hash)); - - // Hash current hash with the defined types - foreach ($type as $cur_type) - { - $new_hash_type = $this->container->get($cur_type); - $new_hash = $new_hash_type->hash(str_replace($stored_hash_type->get_settings_only($password_hash), '', $hash)); - $this->combine_hash_output($data, 'prefix', $new_hash_type->get_prefix()); - $this->combine_hash_output($data, 'settings', substr(str_replace('$', '\\', $new_hash_type->get_settings_only($new_hash, true)), 0)); - $hash = str_replace($new_hash_type->get_settings_only($new_hash), '', $this->obtain_hash_only($new_hash)); - } - return $this->combine_hash_output($data, 'hash', $hash); - } - - /** - * Check combined password hash against the supplied password - * - * @param string $password Password entered by user - * @param array $stored_hash_type An array containing the hash types - * as described by stored password hash - * @param string $hash Stored password hash - * - * @return bool True if password is correct, false if not - */ - public function check_combined_hash($password, $stored_hash_type, $hash) - { - $cur_hash = ''; - $i = 0; - $data = array( - 'prefix' => '$', - 'settings' => '$', - ); - $hash_settings = $this->get_combined_hash_settings($hash); - foreach ($stored_hash_type as $key => $hash_type) - { - $rebuilt_hash = $this->rebuild_hash($hash_type->get_prefix(), $hash_settings[$i]); - $this->combine_hash_output($data, 'prefix', $key); - $this->combine_hash_output($data, 'settings', $hash_settings[$i]); - $cur_hash = $hash_type->hash($password, $rebuilt_hash); - $password = str_replace($rebuilt_hash, '', $cur_hash); - $i++; - } - return ($hash === $this->combine_hash_output($data, 'hash', $password)); - } - - /** - * Combine hash prefixes, settings, and actual hash - * - * @param array $data Array containing the keys 'prefix' and 'settings'. - * It will hold the prefixes and settings - * @param string $type Data type of the supplied value - * @param string $value Value that should be put into the data array - * - * @return string|none Return complete combined hash if type is neither - * 'prefix' nor 'settings', nothing if it is - */ - protected function combine_hash_output(&$data, $type, $value) - { - if ($type == 'prefix') - { - $data[$type] .= ($data[$type] !== '$') ? '\\' : ''; - $data[$type] .= str_replace('$', '', $value); - } - elseif ($type == 'settings') - { - $data[$type] .= ($data[$type] !== '$') ? '$' : ''; - $data[$type] .= $value; - } - else - { - // Return full hash - return $data['prefix'] . $data['settings'] . '$' . $value; - } - } - - /** - * Rebuild hash for hashing functions - * - * @param string $prefix Hash prefix - * @param string $settings Hash settings - * - * @return string Rebuilt hash for hashing functions - */ - protected function rebuild_hash($prefix, $settings) - { - $rebuilt_hash = $prefix; - if (strpos($settings, '\\') !== false) - { - $settings = str_replace('\\', '$', $settings); - } - $rebuilt_hash .= $settings; - return $rebuilt_hash; - } - - /** - * Obtain only the actual hash after the prefixes - * - * @param string $hash The full password hash - * @return string Actual hash (incl. settings) - */ - protected function obtain_hash_only($hash) - { - return substr($hash, strripos($hash, '$') + 1); - } -} diff --git a/phpBB/phpbb/crypto/manager.php b/phpBB/phpbb/crypto/manager.php deleted file mode 100644 index 885bf719bf..0000000000 --- a/phpBB/phpbb/crypto/manager.php +++ /dev/null @@ -1,210 +0,0 @@ -config = $config; - $this->container = $container; - $this->type = $default; - - $this->fill_type_map($hashing_algorithms); - $this->load_crypto_helper(); - } - - /** - * Fill algorithm type map - * - * @param phpbb_di_service_collection $hashing_algorithms - */ - protected function fill_type_map($hashing_algorithms) - { - foreach ($hashing_algorithms as $algorithm) - { - if (!isset($this->type_map[$algorithm->get_prefix()])) - { - $this->type_map[$algorithm->get_prefix()] = $algorithm; - } - } - } - - /** - * Load crypto helper class - */ - protected function load_crypto_helper() - { - if ($this->helper === null) - { - $this->helper = new phpbb_crypto_helper($this, $this->container); - } - } - - /** - * Get the hash type from the supplied hash - * - * @param string $hash Password hash that should be checked - * - * @return object The hash type object - */ - public function get_hashing_algorithm($hash) - { - /* - * preg_match() will also show hashing algos like $2a\H$, which - * is a combination of bcrypt and phpass. Legacy algorithms - * like md5 will not be matched by this and need to be treated - * differently. - */ - if (!preg_match('#^\$([a-zA-Z0-9\\\]*?)\$#', $hash, $match)) - { - return $this->type_map['$H$']; - } - - // Be on the lookout for multiple hashing algorithms - // 2 is correct: H\2a > 2, H\P > 2 - if (strlen($match[1]) > 2) - { - $hash_types = explode('\\', $match[1]); - $return_ary = array(); - foreach ($hash_types as $type) - { - if (isset($this->type_map["\${$type}\$"])) - { - // we do not support the same hashing - // algorithm more than once - if (isset($return_ary[$type])) - { - return false; - } - $return_ary[$type] = $this->type_map["\${$type}\$"]; - } - else - { - return false; - } - } - return $return_ary; - } - if (isset($this->type_map[$match[0]])) - { - return $this->type_map[$match[0]]; - } - else - { - return false; - } - } - - /** - * Hash supplied password - * - * @param string $password Password that should be hashed - * @param string $type Hash type. Will default to standard hash type if - * none is supplied - * @return string|bool Password hash of supplied password or false if - * if something went wrong during hashing - */ - public function hash_password($password, $type = '') - { - $type = ($type === '') ? $this->type : $type; - - if (is_array($type)) - { - return $this->helper->combined_hash_password($password, $type); - } - - $hashing_algorithm = $this->container->get($type); - // Do not support 8-bit characters with $2a$ bcrypt - if ($type === 'crypto.driver.bcrypt' || ($type === 'crypto.driver.bcrypt_2y' && !$hashing_algorithm->is_supported())) - { - if (ord($password[strlen($password)-1]) & 128) - { - return false; - } - } - - return $this->container->get($type)->hash($password); - } - - public function check_hash($password, $hash) - { - // First find out what kind of hash we're dealing with - $stored_hash_type = $this->get_hashing_algorithm($hash); - if ($stored_hash_type == false) - { - return false; - } - - // Multiple hash passes needed - if (is_array($stored_hash_type)) - { - return $this->helper->check_combined_hash($password, $stored_hash_type, $hash); - } - - if ($stored_hash_type->get_name() !== $this->type) - { - $this->convert_flag = true; - } - else - { - $this->convert_flag = false; - } - - return $stored_hash_type->check($password, $hash); - } -} diff --git a/phpBB/phpbb/passwords/driver/base.php b/phpBB/phpbb/passwords/driver/base.php new file mode 100644 index 0000000000..67cbe4decf --- /dev/null +++ b/phpBB/phpbb/passwords/driver/base.php @@ -0,0 +1,68 @@ +config = $config; + $this->helper = new phpbb_passwords_driver_helper($this); + } + + /** + * @inheritdoc + */ + public function is_supported() + { + return true; + } + + /** + * @inheritdoc + */ + public function get_name() + { + return $this->name; + } + + /** + * Set driver name + * + * @param string $name Driver name + */ + public function set_name($name) + { + $this->name = $name; + } +} diff --git a/phpBB/phpbb/passwords/driver/bcrypt.php b/phpBB/phpbb/passwords/driver/bcrypt.php new file mode 100644 index 0000000000..2c2ab8e7b7 --- /dev/null +++ b/phpBB/phpbb/passwords/driver/bcrypt.php @@ -0,0 +1,108 @@ +is_supported()) ? '$2a$' : $this->get_prefix(); + + if ($salt == '') + { + $salt = $prefix . '10$' . $this->get_random_salt(); + } + + $hash = crypt($password, $salt); + if (strlen($hash) < 60) + { + return false; + } + return $hash; + } + + /** + * @inheritdoc + */ + public function check($password, $hash) + { + $salt = substr($hash, 0, 29); + if (strlen($salt) != 29) + { + return false; + } + + if ($hash == $this->hash($password, $salt)) + { + return true; + } + return false; + } + + /** + * Get a random salt value with a length of 22 characters + * + * @return string Salt for password hashing + */ + protected function get_random_salt() + { + return $this->helper->hash_encode64($this->helper->get_random_salt(22), 22); + } + + /** + * @inheritdoc + */ + public function get_settings_only($hash, $full = false) + { + if ($full) + { + $pos = stripos($hash, '$', 1) + 1; + $length = 22 + (strripos($hash, '$') + 1 - $pos); + } + else + { + $pos = strripos($hash, '$') + 1; + $length = 22; + } + return substr($hash, $pos, $length); + } +} diff --git a/phpBB/phpbb/passwords/driver/bcrypt_2y.php b/phpBB/phpbb/passwords/driver/bcrypt_2y.php new file mode 100644 index 0000000000..9277414d13 --- /dev/null +++ b/phpBB/phpbb/passwords/driver/bcrypt_2y.php @@ -0,0 +1,48 @@ +driver = $driver; + } + + /** + * Base64 encode hash + * + * @param string $input Input string + * @param int $count Input string length + * + * @return string base64 encoded string + */ + public function hash_encode64($input, $count) + { + $output = ''; + $i = 0; + + do + { + $value = ord($input[$i++]); + $output .= $this->itoa64[$value & 0x3f]; + + if ($i < $count) + { + $value |= ord($input[$i]) << 8; + } + + $output .= $this->itoa64[($value >> 6) & 0x3f]; + + if ($i++ >= $count) + { + break; + } + + if ($i < $count) + { + $value |= ord($input[$i]) << 16; + } + + $output .= $this->itoa64[($value >> 12) & 0x3f]; + + if ($i++ >= $count) + { + break; + } + + $output .= $this->itoa64[($value >> 18) & 0x3f]; + } + while ($i < $count); + + return $output; + } + + /** + * Return unique id + * @param string $extra additional entropy + * + * @return string Unique id + */ + public function unique_id($extra = 'c') + { + static $dss_seeded = false; + global $config; + + $val = $config['rand_seed'] . microtime(); + $val = md5($val); + $config['rand_seed'] = md5($config['rand_seed'] . $val . $extra); + + if ($dss_seeded !== true && ($config['rand_seed_last_update'] < time() - rand(1,10))) + { + set_config('rand_seed_last_update', time(), true); + set_config('rand_seed', $config['rand_seed'], true); + $dss_seeded = true; + } + + return substr($val, 4, 16); + } + + /** + * Get random salt with specified length + * + * @param int $length Salt length + */ + public function get_random_salt($length) + { + $random = ''; + + if (($fh = @fopen('/dev/urandom', 'rb'))) + { + $random = fread($fh, $length); + fclose($fh); + } + + if (strlen($random) < $length) + { + $random = ''; + $random_state = $this->unique_id(); + + for ($i = 0; $i < $length; $i += 16) + { + $random_state = md5($this->unique_id() . $random_state); + $random .= pack('H*', md5($random_state)); + } + $random = substr($random, 0, $length); + } + return $random; + } +} diff --git a/phpBB/phpbb/passwords/driver/interface.php b/phpBB/phpbb/passwords/driver/interface.php new file mode 100644 index 0000000000..a019830cb4 --- /dev/null +++ b/phpBB/phpbb/passwords/driver/interface.php @@ -0,0 +1,75 @@ +get_hash_settings($setting)) === false) + { + return false; + } + } + else + { + if (($settings = $this->get_hash_settings($this->generate_salt())) === false) + { + return false; + } + } + + $hash = md5($settings['salt'] . $password, true); + do + { + $hash = md5($hash . $password, true); + } + while (--$settings['count']); + + $output = $settings['full']; + $output .= $this->helper->hash_encode64($hash, 16); + + if (strlen($output) == 34) + { + return $output; + } + + // Should we really just return the md5 of the password? O.o + return md5($password); + } + + /** + * @inheritdoc + */ + public function check($password, $hash) + { + if (strlen($hash) !== 34) + { + return (md5($password) === $hash) ? true : false; + } + // No need to check prefix, already did that in manage + + if ($hash === $this->hash($password, $hash)) + { + return true; + } + return false; + } + + /** + * Generate salt for hashing method + * + * @return string Salt for hashing method + */ + protected function generate_salt() + { + $salt = ''; + $random = ''; + $count = 6; + + $random = $this->helper->get_random_salt($count); + + $salt = $this->get_prefix(); + $salt .= $this->helper->itoa64[min($count + 5, 30)]; + $salt .= $this->helper->hash_encode64($random, $count); + + return $salt; + } + + /** + * Get hash settings + * + * @return array Array containing the count_log2, salt, and full hash + * settings string + */ + public function get_hash_settings($hash) + { + if (empty($hash)) + { + return false; + } + $count_log2 = strpos($this->helper->itoa64, $hash[3]); + $salt = substr($hash, 4, 8); + + if ($count_log2 < 7 || $count_log2 > 30 || strlen($salt) != 8) + { + return false; + } + + return array( + 'count' => 1 << $count_log2, + 'salt' => $salt, + 'full' => substr($hash, 0, 12), + ); + } + + /** + * @inheritdoc + */ + public function get_settings_only($hash, $full = false) + { + return substr($hash, 3, 9); + } +} diff --git a/phpBB/phpbb/passwords/helper.php b/phpBB/phpbb/passwords/helper.php new file mode 100644 index 0000000000..952f491669 --- /dev/null +++ b/phpBB/phpbb/passwords/helper.php @@ -0,0 +1,194 @@ +manager = $manager; + $this->container = $container; + } + + /** + * Get hash settings from combined hash + * + * @param string $hash Password hash of combined hash + * + * @return array An array containing the hash settings for the hash + * types in successive order as described by the comined + * password hash + */ + protected function get_combined_hash_settings($hash) + { + preg_match('#^\$([a-zA-Z0-9\\\]*?)\$#', $hash, $match); + $hash_settings = substr($hash, strpos($hash, $match[1]) + strlen($match[1]) + 1); + $matches = explode('\\', $match[1]); + foreach ($matches as $cur_type) + { + $dollar_position = strpos($hash_settings, '$'); + $output[] = substr($hash_settings, 0, ($dollar_position != false) ? $dollar_position : strlen($hash_settings)); + $hash_settings = substr($hash_settings, $dollar_position + 1); + } + + return $output; + } + + /** + * Create combined hash from already hashed password + * + * @param string $password_hash Complete current password hash + * @param string $type Type of the hashing algorithm the password hash + * should be combined with + * @return string|bool Combined password hash if combined hashing was + * successful, else false + */ + public function combined_hash_password($password_hash, $type) + { + $data = array( + 'prefix' => '$', + 'settings' => '$', + ); + $hash_settings = $this->get_combined_hash_settings($password_hash); + $hash = $hash_settings[0]; + + // Put settings of current hash into data array + $stored_hash_type = $this->manager->get_hashing_algorithm($password_hash); + $this->combine_hash_output($data, 'prefix', $stored_hash_type->get_prefix()); + $this->combine_hash_output($data, 'settings', $stored_hash_type->get_settings_only($password_hash)); + + // Hash current hash with the defined types + foreach ($type as $cur_type) + { + $new_hash_type = $this->container->get($cur_type); + $new_hash = $new_hash_type->hash(str_replace($stored_hash_type->get_settings_only($password_hash), '', $hash)); + $this->combine_hash_output($data, 'prefix', $new_hash_type->get_prefix()); + $this->combine_hash_output($data, 'settings', substr(str_replace('$', '\\', $new_hash_type->get_settings_only($new_hash, true)), 0)); + $hash = str_replace($new_hash_type->get_settings_only($new_hash), '', $this->obtain_hash_only($new_hash)); + } + return $this->combine_hash_output($data, 'hash', $hash); + } + + /** + * Check combined password hash against the supplied password + * + * @param string $password Password entered by user + * @param array $stored_hash_type An array containing the hash types + * as described by stored password hash + * @param string $hash Stored password hash + * + * @return bool True if password is correct, false if not + */ + public function check_combined_hash($password, $stored_hash_type, $hash) + { + $cur_hash = ''; + $i = 0; + $data = array( + 'prefix' => '$', + 'settings' => '$', + ); + $hash_settings = $this->get_combined_hash_settings($hash); + foreach ($stored_hash_type as $key => $hash_type) + { + $rebuilt_hash = $this->rebuild_hash($hash_type->get_prefix(), $hash_settings[$i]); + $this->combine_hash_output($data, 'prefix', $key); + $this->combine_hash_output($data, 'settings', $hash_settings[$i]); + $cur_hash = $hash_type->hash($password, $rebuilt_hash); + $password = str_replace($rebuilt_hash, '', $cur_hash); + $i++; + } + return ($hash === $this->combine_hash_output($data, 'hash', $password)); + } + + /** + * Combine hash prefixes, settings, and actual hash + * + * @param array $data Array containing the keys 'prefix' and 'settings'. + * It will hold the prefixes and settings + * @param string $type Data type of the supplied value + * @param string $value Value that should be put into the data array + * + * @return string|none Return complete combined hash if type is neither + * 'prefix' nor 'settings', nothing if it is + */ + protected function combine_hash_output(&$data, $type, $value) + { + if ($type == 'prefix') + { + $data[$type] .= ($data[$type] !== '$') ? '\\' : ''; + $data[$type] .= str_replace('$', '', $value); + } + elseif ($type == 'settings') + { + $data[$type] .= ($data[$type] !== '$') ? '$' : ''; + $data[$type] .= $value; + } + else + { + // Return full hash + return $data['prefix'] . $data['settings'] . '$' . $value; + } + } + + /** + * Rebuild hash for hashing functions + * + * @param string $prefix Hash prefix + * @param string $settings Hash settings + * + * @return string Rebuilt hash for hashing functions + */ + protected function rebuild_hash($prefix, $settings) + { + $rebuilt_hash = $prefix; + if (strpos($settings, '\\') !== false) + { + $settings = str_replace('\\', '$', $settings); + } + $rebuilt_hash .= $settings; + return $rebuilt_hash; + } + + /** + * Obtain only the actual hash after the prefixes + * + * @param string $hash The full password hash + * @return string Actual hash (incl. settings) + */ + protected function obtain_hash_only($hash) + { + return substr($hash, strripos($hash, '$') + 1); + } +} diff --git a/phpBB/phpbb/passwords/manager.php b/phpBB/phpbb/passwords/manager.php new file mode 100644 index 0000000000..9477ef5c2b --- /dev/null +++ b/phpBB/phpbb/passwords/manager.php @@ -0,0 +1,210 @@ +config = $config; + $this->container = $container; + $this->type = $default; + + $this->fill_type_map($hashing_algorithms); + $this->load_passwords_helper(); + } + + /** + * Fill algorithm type map + * + * @param phpbb_di_service_collection $hashing_algorithms + */ + protected function fill_type_map($hashing_algorithms) + { + foreach ($hashing_algorithms as $algorithm) + { + if (!isset($this->type_map[$algorithm->get_prefix()])) + { + $this->type_map[$algorithm->get_prefix()] = $algorithm; + } + } + } + + /** + * Load passwords helper class + */ + protected function load_passwords_helper() + { + if ($this->helper === null) + { + $this->helper = new phpbb_passwords_helper($this, $this->container); + } + } + + /** + * Get the hash type from the supplied hash + * + * @param string $hash Password hash that should be checked + * + * @return object The hash type object + */ + public function get_hashing_algorithm($hash) + { + /* + * preg_match() will also show hashing algos like $2a\H$, which + * is a combination of bcrypt and phpass. Legacy algorithms + * like md5 will not be matched by this and need to be treated + * differently. + */ + if (!preg_match('#^\$([a-zA-Z0-9\\\]*?)\$#', $hash, $match)) + { + return $this->type_map['$H$']; + } + + // Be on the lookout for multiple hashing algorithms + // 2 is correct: H\2a > 2, H\P > 2 + if (strlen($match[1]) > 2) + { + $hash_types = explode('\\', $match[1]); + $return_ary = array(); + foreach ($hash_types as $type) + { + if (isset($this->type_map["\${$type}\$"])) + { + // we do not support the same hashing + // algorithm more than once + if (isset($return_ary[$type])) + { + return false; + } + $return_ary[$type] = $this->type_map["\${$type}\$"]; + } + else + { + return false; + } + } + return $return_ary; + } + if (isset($this->type_map[$match[0]])) + { + return $this->type_map[$match[0]]; + } + else + { + return false; + } + } + + /** + * Hash supplied password + * + * @param string $password Password that should be hashed + * @param string $type Hash type. Will default to standard hash type if + * none is supplied + * @return string|bool Password hash of supplied password or false if + * if something went wrong during hashing + */ + public function hash_password($password, $type = '') + { + $type = ($type === '') ? $this->type : $type; + + if (is_array($type)) + { + return $this->helper->combined_hash_password($password, $type); + } + + $hashing_algorithm = $this->container->get($type); + // Do not support 8-bit characters with $2a$ bcrypt + if ($type === 'passwords.driver.bcrypt' || ($type === 'passwords.driver.bcrypt_2y' && !$hashing_algorithm->is_supported())) + { + if (ord($password[strlen($password)-1]) & 128) + { + return false; + } + } + + return $this->container->get($type)->hash($password); + } + + public function check_hash($password, $hash) + { + // First find out what kind of hash we're dealing with + $stored_hash_type = $this->get_hashing_algorithm($hash); + if ($stored_hash_type == false) + { + return false; + } + + // Multiple hash passes needed + if (is_array($stored_hash_type)) + { + return $this->helper->check_combined_hash($password, $stored_hash_type, $hash); + } + + if ($stored_hash_type->get_name() !== $this->type) + { + $this->convert_flag = true; + } + else + { + $this->convert_flag = false; + } + + return $stored_hash_type->check($password, $hash); + } +} diff --git a/tests/crypto/manager_test.php b/tests/crypto/manager_test.php deleted file mode 100644 index c396d092c1..0000000000 --- a/tests/crypto/manager_test.php +++ /dev/null @@ -1,244 +0,0 @@ -phpbb_container = new phpbb_mock_container_builder; - - // Prepare dependencies for manager and driver - $config = new phpbb_config(array()); - - $this->crypto_drivers = array( - 'crypto.driver.bcrypt' => new phpbb_crypto_driver_bcrypt($config), - 'crypto.driver.bcrypt_2y' => new phpbb_crypto_driver_bcrypt_2y($config), - 'crypto.driver.salted_md5' => new phpbb_crypto_driver_salted_md5($config), - 'crypto.driver.phpass' => new phpbb_crypto_driver_phpass($config), - ); - - foreach ($this->crypto_drivers as $key => $driver) - { - $driver->set_name($key); - $this->phpbb_container->set($key, $driver); - } - - // Set up avatar manager - $this->manager = new phpbb_crypto_manager($config, $this->phpbb_container, $this->crypto_drivers, 'crypto.driver.bcrypt_2y'); - } - - public function hash_password_data() - { - if (version_compare(PHP_VERSION, '5.3.7', '<')) - { - return array( - array('', '2a', 60), - array('crypto.driver.bcrypt_2y', '2a', 60), - array('crypto.driver.bcrypt', '2a', 60), - array('crypto.driver.salted_md5', 'H', 34), - ); - } - else - { - return array( - array('', '2y', 60), - array('crypto.driver.bcrypt_2y', '2y', 60), - array('crypto.driver.bcrypt', '2a', 60), - array('crypto.driver.salted_md5', 'H', 34), - ); - } - } - - /** - * @dataProvider hash_password_data - */ - public function test_hash_password($type, $prefix, $length) - { - $password = $this->default_pw; - $time = microtime(true); - - // Limit each test to 1 second - while ((microtime(true) - $time) < 1) - { - $hash = $this->manager->hash_password($password, $type); - preg_match('#^\$([a-zA-Z0-9\\\]*?)\$#', $hash, $match); - $this->assertEquals($prefix, $match[1]); - $this->assertEquals($length, strlen($hash)); - $password .= $this->pw_characters[mt_rand(0, 66)]; - } - } - - public function check_password_data() - { - if (version_compare(PHP_VERSION, '5.3.7', '<')) - { - return array( - array('crypto.driver.bcrypt'), - array('crypto.driver.salted_md5'), - array('crypto.driver.phpass'), - ); - } - else - { - return array( - array('crypto.driver.bcrypt_2y'), - array('crypto.driver.bcrypt'), - array('crypto.driver.salted_md5'), - array('crypto.driver.phpass'), - ); - } - } - - /** - * @dataProvider check_password_data - */ - public function test_check_password($hash_type) - { - $password = $this->default_pw; - $time = microtime(true); - // Limit each test to 1 second - while ((microtime(true) - $time) < 1) - { - $hash = $this->manager->hash_password($password, $hash_type); - $this->assertEquals(true, $this->manager->check_hash($password, $hash)); - $password .= $this->pw_characters[mt_rand(0, 66)]; - $this->assertEquals(false, $this->manager->check_hash($password, $hash)); - } - - // Check if convert_flag is correctly set - $this->assertEquals(($hash_type !== 'crypto.driver.bcrypt_2y'), $this->manager->convert_flag); - } - - - public function check_hash_exceptions_data() - { - return array( - array('foobar', '3858f62230ac3c915f300c664312c63f', true), - array('foobar', '$S$b57a939fa4f2c04413a4eea9734a0903647b7adb93181295', false), - array('foobar', '$2a\S$kkkkaakdkdiej39023903204j2k3490234jk234j02349', false), - array('foobar', '$H$kklk938d023k//k3023', false), - array('foobar', '$H$3PtYMgXb39lrIWkgoxYLWtRkZtY3AY/', false), - array('foobar', '$2a$kwiweorurlaeirw', false), - ); - } - - /** - * @dataProvider check_hash_exceptions_data - */ - public function test_check_hash_exceptions($password, $hash, $expected) - { - $this->assertEquals($expected, $this->manager->check_hash($password, $hash)); - } - - public function test_hash_password_length() - { - foreach ($this->crypto_drivers as $driver) - { - $this->assertEquals(false, $driver->hash('foobar', 'foobar')); - } - } - - public function test_hash_password_8bit_bcrypt() - { - $this->assertEquals(false, $this->manager->hash_password('foobar𝄞', 'crypto.driver.bcrypt')); - } - - public function test_combined_hash_data() - { - if (version_compare(PHP_VERSION, '5.3.7', '<')) - { - return array( - array( - 'crypto.driver.salted_md5', - array('crypto.driver.bcrypt'), - ), - array( - 'crypto.driver.phpass', - array('crypto.driver.salted_md5'), - ), - array( - 'crypto.driver.salted_md5', - array('crypto.driver.phpass', 'crypto.driver.bcrypt'), - ), - array( - 'crypto.driver.salted_md5', - array('crypto.driver.salted_md5'), - false, - ), - ); - } - else - { - return array( - array( - 'crypto.driver.salted_md5', - array('crypto.driver.bcrypt_2y'), - ), - array( - 'crypto.driver.salted_md5', - array('crypto.driver.bcrypt'), - ), - array( - 'crypto.driver.phpass', - array('crypto.driver.salted_md5'), - ), - array( - 'crypto.driver.salted_md5', - array('crypto.driver.bcrypt_2y', 'crypto.driver.bcrypt'), - ), - array( - 'crypto.driver.salted_md5', - array('crypto.driver.salted_md5'), - false, - ), - ); - } - } - - /** - * @dataProvider test_combined_hash_data - */ - public function test_combined_hash_password($first_type, $second_type, $expected = true) - { - $password = $this->default_pw; - $time = microtime(true); - // Limit each test to 1 second - while ((microtime(true) - $time) < 1) - { - $hash = $this->manager->hash_password($password, $first_type); - $combined_hash = $this->manager->hash_password($hash, $second_type); - $this->assertEquals($expected, $this->manager->check_hash($password, $combined_hash)); - $password .= $this->pw_characters[mt_rand(0, 66)]; - $this->assertEquals(false, $this->manager->check_hash($password, $combined_hash)); - - // If we are expecting the check to fail then there is - // no need to run this more than once - if (!$expected) - { - break; - } - } - } -} diff --git a/tests/passwords/manager_test.php b/tests/passwords/manager_test.php new file mode 100644 index 0000000000..a069c9692e --- /dev/null +++ b/tests/passwords/manager_test.php @@ -0,0 +1,244 @@ +phpbb_container = new phpbb_mock_container_builder; + + // Prepare dependencies for manager and driver + $config = new phpbb_config(array()); + + $this->passwords_drivers = array( + 'passwords.driver.bcrypt' => new phpbb_passwords_driver_bcrypt($config), + 'passwords.driver.bcrypt_2y' => new phpbb_passwords_driver_bcrypt_2y($config), + 'passwords.driver.salted_md5' => new phpbb_passwords_driver_salted_md5($config), + 'passwords.driver.phpass' => new phpbb_passwords_driver_phpass($config), + ); + + foreach ($this->passwords_drivers as $key => $driver) + { + $driver->set_name($key); + $this->phpbb_container->set($key, $driver); + } + + // Set up avatar manager + $this->manager = new phpbb_passwords_manager($config, $this->phpbb_container, $this->passwords_drivers, 'passwords.driver.bcrypt_2y'); + } + + public function hash_password_data() + { + if (version_compare(PHP_VERSION, '5.3.7', '<')) + { + return array( + array('', '2a', 60), + array('passwords.driver.bcrypt_2y', '2a', 60), + array('passwords.driver.bcrypt', '2a', 60), + array('passwords.driver.salted_md5', 'H', 34), + ); + } + else + { + return array( + array('', '2y', 60), + array('passwords.driver.bcrypt_2y', '2y', 60), + array('passwords.driver.bcrypt', '2a', 60), + array('passwords.driver.salted_md5', 'H', 34), + ); + } + } + + /** + * @dataProvider hash_password_data + */ + public function test_hash_password($type, $prefix, $length) + { + $password = $this->default_pw; + $time = microtime(true); + + // Limit each test to 1 second + while ((microtime(true) - $time) < 1) + { + $hash = $this->manager->hash_password($password, $type); + preg_match('#^\$([a-zA-Z0-9\\\]*?)\$#', $hash, $match); + $this->assertEquals($prefix, $match[1]); + $this->assertEquals($length, strlen($hash)); + $password .= $this->pw_characters[mt_rand(0, 66)]; + } + } + + public function check_password_data() + { + if (version_compare(PHP_VERSION, '5.3.7', '<')) + { + return array( + array('passwords.driver.bcrypt'), + array('passwords.driver.salted_md5'), + array('passwords.driver.phpass'), + ); + } + else + { + return array( + array('passwords.driver.bcrypt_2y'), + array('passwords.driver.bcrypt'), + array('passwords.driver.salted_md5'), + array('passwords.driver.phpass'), + ); + } + } + + /** + * @dataProvider check_password_data + */ + public function test_check_password($hash_type) + { + $password = $this->default_pw; + $time = microtime(true); + // Limit each test to 1 second + while ((microtime(true) - $time) < 1) + { + $hash = $this->manager->hash_password($password, $hash_type); + $this->assertEquals(true, $this->manager->check_hash($password, $hash)); + $password .= $this->pw_characters[mt_rand(0, 66)]; + $this->assertEquals(false, $this->manager->check_hash($password, $hash)); + } + + // Check if convert_flag is correctly set + $this->assertEquals(($hash_type !== 'passwords.driver.bcrypt_2y'), $this->manager->convert_flag); + } + + + public function check_hash_exceptions_data() + { + return array( + array('foobar', '3858f62230ac3c915f300c664312c63f', true), + array('foobar', '$S$b57a939fa4f2c04413a4eea9734a0903647b7adb93181295', false), + array('foobar', '$2a\S$kkkkaakdkdiej39023903204j2k3490234jk234j02349', false), + array('foobar', '$H$kklk938d023k//k3023', false), + array('foobar', '$H$3PtYMgXb39lrIWkgoxYLWtRkZtY3AY/', false), + array('foobar', '$2a$kwiweorurlaeirw', false), + ); + } + + /** + * @dataProvider check_hash_exceptions_data + */ + public function test_check_hash_exceptions($password, $hash, $expected) + { + $this->assertEquals($expected, $this->manager->check_hash($password, $hash)); + } + + public function test_hash_password_length() + { + foreach ($this->passwords_drivers as $driver) + { + $this->assertEquals(false, $driver->hash('foobar', 'foobar')); + } + } + + public function test_hash_password_8bit_bcrypt() + { + $this->assertEquals(false, $this->manager->hash_password('foobar𝄞', 'passwords.driver.bcrypt')); + } + + public function test_combined_hash_data() + { + if (version_compare(PHP_VERSION, '5.3.7', '<')) + { + return array( + array( + 'passwords.driver.salted_md5', + array('passwords.driver.bcrypt'), + ), + array( + 'passwords.driver.phpass', + array('passwords.driver.salted_md5'), + ), + array( + 'passwords.driver.salted_md5', + array('passwords.driver.phpass', 'passwords.driver.bcrypt'), + ), + array( + 'passwords.driver.salted_md5', + array('passwords.driver.salted_md5'), + false, + ), + ); + } + else + { + return array( + array( + 'passwords.driver.salted_md5', + array('passwords.driver.bcrypt_2y'), + ), + array( + 'passwords.driver.salted_md5', + array('passwords.driver.bcrypt'), + ), + array( + 'passwords.driver.phpass', + array('passwords.driver.salted_md5'), + ), + array( + 'passwords.driver.salted_md5', + array('passwords.driver.bcrypt_2y', 'passwords.driver.bcrypt'), + ), + array( + 'passwords.driver.salted_md5', + array('passwords.driver.salted_md5'), + false, + ), + ); + } + } + + /** + * @dataProvider test_combined_hash_data + */ + public function test_combined_hash_password($first_type, $second_type, $expected = true) + { + $password = $this->default_pw; + $time = microtime(true); + // Limit each test to 1 second + while ((microtime(true) - $time) < 1) + { + $hash = $this->manager->hash_password($password, $first_type); + $combined_hash = $this->manager->hash_password($hash, $second_type); + $this->assertEquals($expected, $this->manager->check_hash($password, $combined_hash)); + $password .= $this->pw_characters[mt_rand(0, 66)]; + $this->assertEquals(false, $this->manager->check_hash($password, $combined_hash)); + + // If we are expecting the check to fail then there is + // no need to run this more than once + if (!$expected) + { + break; + } + } + } +} -- cgit v1.2.1