From 647bc7c112acadd20ffd82af73da428190e6180e Mon Sep 17 00:00:00 2001 From: Kijin Sung Date: Sun, 13 Mar 2016 22:08:56 +0900 Subject: [PATCH] Implement new Password class and related unit tests --- common/framework/password.php | 443 ++++++++++++++++++++++++++ common/libraries/vendorpass.php | 6 +- tests/unit/framework/PasswordTest.php | 127 ++++++++ tests/unit/framework/SecurityTest.php | 12 +- 4 files changed, 579 insertions(+), 9 deletions(-) create mode 100644 common/framework/password.php create mode 100644 tests/unit/framework/PasswordTest.php diff --git a/common/framework/password.php b/common/framework/password.php new file mode 100644 index 000000000..2e4e67017 --- /dev/null +++ b/common/framework/password.php @@ -0,0 +1,443 @@ + '/^\$2[a-z]\$[0-9]{2}\$/', + 'pbkdf2' => '/^[a-z0-9]+:[0-9]+:/', + 'md5' => '/^[0-9a-f]{32}$/', + 'md5,sha1,md5' => '/^[0-9a-f]{32}$/', + 'sha1' => '/^[0-9a-f]{40}$/', + 'sha256' => '/^[0-9a-f]{64}$/', + 'sha384' => '/^[0-9a-f]{96}$/', + 'sha512' => '/^[0-9a-f]{128}$/', + 'ripemd160' => '/^[0-9a-f]{40}$/', + 'whirlpool' => '/^[0-9a-f]{128}$/', + 'mssql_pwdencrypt' => '/^0x0100[0-9A-F]{48}$/', + 'mysql_old_password' => '/^[0-9a-f]{16}$/', + 'mysql_new_password' => '/^\*[0-9A-F]{40}$/', + 'portable' => '/^\$P\$/', + 'drupal' => '/^\$S\$/', + 'joomla' => '/^[0-9a-f]{32}:[0-9a-zA-Z\.\+\/\=]{32}$/', + 'kimsqrb' => '/\$[1-4]\$[0-9]{14}$/', + 'crypt' => '/^([0-9a-zA-Z\.\/]{13}$|_[0-9a-zA-Z\.\/]{19}$|\$[156]\$)/', + ); + + /** + * Add a custom algorithm. + * + * @param string $name + * @param string $signature + * @param callable $callback + * @return void + */ + public static function addAlgorithm($name, $signature, $callback) + { + self::$_algorithm_signatures[$name] = $signature; + self::$_algorithm_callbacks[$name] = $callback; + } + + /** + * Check if the given sequence of algorithms is valid. + * + * @param array|string $algos + * @return bool + */ + public static function isValidAlgorithm($algos) + { + $hash_algos = hash_algos(); + $algos = is_array($algos) ? $algos : explode(',', $algos); + foreach ($algos as $algo) + { + if (array_key_exists($algo, self::$_algorithm_signatures)) + { + continue; + } + if (in_array($algo, $hash_algos)) + { + continue; + } + return false; + } + return true; + } + + /** + * Get the list of hashing algorithms supported by this server. + * + * @return array + */ + public static function getSupportedAlgorithms() + { + $retval = array(); + if (version_compare(PHP_VERSION, '5.3.7', '>=') && defined('\CRYPT_BLOWFISH')) + { + $retval['bcrypt'] = 'bcrypt'; + } + if (function_exists('hash_hmac') && in_array('sha512', hash_algos())) + { + $retval['pbkdf2'] = 'pbkdf2'; + } + $retval['sha512'] = 'sha512'; + $retval['sha256'] = 'sha256'; + $retval['sha1'] = 'sha1'; + $retval['md5'] = 'md5'; + return $retval; + } + + /** + * Get the best hashing algorithm supported by this server. + * + * @return string + */ + public static function getBestSupportedAlgorithm() + { + $algos = self::getSupportedAlgorithms(); + return key($algos); + } + + /** + * Get the currently selected hashing algorithm. + * + * @return string + */ + public static function getSelectedAlgorithm() + { + if (function_exists('getModel')) + { + $config = getModel('member')->getMemberConfig(); + $algorithm = $config->password_hashing_algorithm; + if (strval($algorithm) === '') + { + $algorithm = 'md5'; + } + } + else + { + $algorithm = 'md5'; + } + return $algorithm; + } + + /** + * Get the currently configured work factor for bcrypt and other adjustable algorithms. + * + * @return int + */ + public static function getWorkFactor() + { + if (function_exists('getModel')) + { + $config = getModel('member')->getMemberConfig(); + $work_factor = $config->password_hashing_work_factor; + if (!$work_factor || $work_factor < 4 || $work_factor > 31) + { + $work_factor = 9; + } + } + else + { + $work_factor = 9; + } + + return $work_factor; + } + + /** + * Hash a password. + * + * To use multiple algorithms in series, provide them as an array. + * Salted algorithms such as bcrypt, pbkdf2, or portable must be used last. + * On error, false will be returned. + * + * @param string $password + * @param string|array $algos + * @param string $salt (optional) + * @return string|false + */ + public static function hashPassword($password, $algos, $salt = null) + { + // Initialize the chain of hashes. + $algos = array_map('strtolower', array_map('trim', is_array($algos) ? $algos : explode(',', $algos))); + $hashchain = preg_replace('/\\s+/', ' ', trim($password)); + + // Apply the given algorithms one by one. + foreach ($algos as $algo) + { + switch ($algo) + { + // bcrypt (must be used last) + case 'bcrypt': + $hashchain = self::bcrypt($hashchain, $salt, self::getWorkFactor()); + if ($hashchain[0] === '*') return false; + return $hashchain; + + // PBKDF2 (must be used last) + case 'pbkdf2': + if ($salt === null) + { + $salt = Security::getRandom(12, 'alnum'); + $hash_algorithm = 'sha512'; + $iterations = pow(2, self::getWorkFactor() + 5); + $key_length = 24; + } + else + { + $parts = explode(':', $salt); + $salt = $parts[2]; + $hash_algorithm = $parts[0]; + $iterations = $parts[1]; + $key_length = strlen(base64_decode($parts[3])); + } + return self::pbkdf2($hashchain, $salt, $hash_algorithm, $iterations, $key_length); + + // phpass portable algorithm (must be used last) + case 'portable': + $phpass = new \Hautelook\Phpass\PasswordHash(self::getWorkFactor(), true); + if ($salt === null) + { + $hashchain = $phpass->HashPassword($hashchain); + return $hashchain; + } + else + { + $match = $phpass->CheckPassword($hashchain, $salt); + return $match ? $salt : false; + } + + // Drupal's SHA-512 based algorithm (must be used last) + case 'drupal': + $hashchain = \VendorPass::drupal($password, $salt); + return $hashchain; + + // Joomla's MD5 based algorithm (must be used last) + case 'joomla': + $hashchain = \VendorPass::joomla($password, $salt); + return $hashchain; + + // KimsQ Rb algorithms (must be used last) + case 'kimsqrb': + $hashchain = \VendorPass::kimsqrb($password, $salt); + return $hashchain; + + // crypt() function (must be used last) + case 'crypt': + if ($salt === null) $salt = Security::getRandom(2, 'alnum'); + $hashchain = crypt($hashchain, $salt); + return $hashchain; + + // MS SQL's PWDENCRYPT() function (must be used last) + case 'mssql_pwdencrypt': + $hashchain = \VendorPass::mssql_pwdencrypt($hashchain, $salt); + return $hashchain; + + // MySQL's old PASSWORD() function. + case 'mysql_old_password': + $hashchain = \VendorPass::mysql_old_password($hashchain); + break; + + // MySQL's new PASSWORD() function. + case 'mysql_new_password': + $hashchain = \VendorPass::mysql_new_password($hashchain); + break; + + // A dummy algorithm that does nothing. + case 'null': + break; + + // All other algorithms will be passed to hash() or treated as a function name. + default: + if (isset(self::$_algorithm_callbacks[$algo])) + { + $callback = self::$_algorithm_callbacks[$algo]; + $hashchain = $callback($hashchain, $salt); + } + elseif (in_array($algo, hash_algos())) + { + $hashchain = hash($algo, $hashchain); + } + elseif (function_exists($algo)) + { + $hashchain = $algo($hashchain, $salt); + } + else + { + return false; + } + } + } + + return $hashchain; + } + + /** + * Check a password against a hash. + * + * This method returns true if the password is correct, and false otherwise. + * If the algorithm is not specified, it will be guessed from the format of the hash. + * + * @param string $password + * @param string $hash + * @param array|string $algos + * @return bool + */ + public static function checkPassword($password, $hash, $algos = null) + { + if (!$algos) + { + $algos = self::checkAlgorithm($hash); + } + if (!is_array($algos)) + { + $algos = explode(',', $algos); + } + + return Security::compareStrings($hash, self::hashPassword($password, $algos, $hash)); + } + + /** + * Guess which algorithm(s) were used to generate the given hash. + * + * If there are multiple possibilities, all of them will be returned in an array. + * + * @param string $hash + * @return array + */ + public static function checkAlgorithm($hash) + { + $candidates = array(); + foreach (self::$_algorithm_signatures as $name => $signature) + { + if (preg_match($signature, $hash)) $candidates[] = $name; + } + return $candidates; + } + + /** + * Check the work factor of a hash. + * + * @param string $hash + * @return int + */ + public static function checkWorkFactor($hash) + { + if(preg_match('/^\$2[axy]\$([0-9]{2})\$/', $hash, $matches)) + { + return intval($matches[1], 10); + } + elseif(preg_match('/^sha[0-9]+:([0-9]+):/', $hash, $matches)) + { + return max(0, round(log($matches[1], 2)) - 5); + } + else + { + return 0; + } + } + + /** + * Generate the bcrypt hash of a string. + * + * @param string $password + * @param string $salt (optional) + * @param int $work_factor (optional) + * @return string + */ + public static function bcrypt($password, $salt = null, $work_factor = 10) + { + if ($salt === null) + { + $salt = '$2y$' . sprintf('%02d', $work_factor) . '$' . Security::getRandom(22, 'alnum'); + } + + return crypt($password, $salt); + } + + /** + * Generate the PBKDF2 hash of a string. + * + * @param string $password + * @param string $salt (optional) + * @param string $algorithm (optional) + * @param int $iterations (optional) + * @param int $length (optional) + * @return string + */ + public static function pbkdf2($password, $salt = null, $algorithm = 'sha512', $iterations = 16384, $length = 24) + { + if ($salt === null) + { + $salt = Security::getRandom(12, 'alnum'); + } + + if (function_exists('hash_pbkdf2')) + { + $hash = hash_pbkdf2($algorithm, $password, $salt, $iterations, $length, true); + } + else + { + $output = ''; + $block_count = ceil($length / strlen(hash($algorithm, '', true))); // key length divided by the length of one hash + for ($i = 1; $i <= $block_count; $i++) + { + $last = $salt . pack('N', $i); // $i encoded as 4 bytes, big endian + $last = $xorsum = hash_hmac($algorithm, $last, $password, true); // first iteration + for ($j = 1; $j < $iterations; $j++) // The other $count - 1 iterations + { + $xorsum ^= ($last = hash_hmac($algorithm, $last, $password, true)); + } + $output .= $xorsum; + } + $hash = substr($output, 0, $length); + } + + return $algorithm . ':' . sprintf('%07d', $iterations) . ':' . $salt . ':' . base64_encode($hash); + } + + /** + * Count the amount of entropy that a password contains. + * + * @param string $password + * @return int + */ + public static function countEntropyBits($password) + { + // An empty string has no entropy. + + if ($password === '') return 0; + + // Common character sets and the number of possible mutations. + + static $entropy_per_char = array( + '/^[0-9]+$/' => 10, + '/^[a-z]+$/' => 26, + '/^[A-Z]+$/' => 26, + '/^[a-z0-9]+$/' => 36, + '/^[A-Z0-9]+$/' => 36, + '/^[a-zA-Z]+$/' => 52, + '/^[a-zA-Z0-9]+$/' => 62, + '/^[a-zA-Z0-9_-]+$/' => 64, + '/^[\\x20-\\x7e]+$/' => 95, + '/^[\\x00-\\x7f]+$/' => 128, + ); + + foreach ($entropy_per_char as $regex => $entropy) + { + if (preg_match($regex, $password)) + { + return log(pow($entropy, strlen($password)), 2); + } + } + + return strlen($password) * 8; + } +} diff --git a/common/libraries/vendorpass.php b/common/libraries/vendorpass.php index 46e5e73c3..c31bfe30b 100644 --- a/common/libraries/vendorpass.php +++ b/common/libraries/vendorpass.php @@ -61,7 +61,7 @@ class VendorPass else { $iterations = 15; - $salt = Password::createSecureSalt(8, 'hex'); + $salt = Rhymix\Framework\Security::getRandom(8, 'hex'); } $count = 1 << $iterations; $hash = hash('sha512', $salt . $password, true); @@ -104,7 +104,7 @@ class VendorPass } else { - $salt = Password::createSecureSalt(32, 'hex'); + $salt = Rhymix\Framework\Security::getRandom(32, 'hex'); } return md5($password . $salt) . ':' . $salt; } @@ -137,7 +137,7 @@ class VendorPass { if (!isset($options['salt']) || !preg_match('/^[0-9a-zA-Z\.\/]{22,}$/', $options['salt'])) { - $options['salt'] = Password::createSecureSalt(22, 'alnum'); + $options['salt'] = Rhymix\Framework\Security::getRandom(22, 'alnum'); } if (!isset($options['cost']) || $options['cost'] < 4 || $options['cost'] > 31) { diff --git a/tests/unit/framework/PasswordTest.php b/tests/unit/framework/PasswordTest.php new file mode 100644 index 000000000..1bf859bbf --- /dev/null +++ b/tests/unit/framework/PasswordTest.php @@ -0,0 +1,127 @@ +assertTrue(Rhymix\Framework\Password::isValidAlgorithm('bcrypt')); + $this->assertTrue(Rhymix\Framework\Password::isValidAlgorithm('whirlpool,pbkdf2')); + $this->assertTrue(Rhymix\Framework\Password::isValidAlgorithm(array('md5', 'sha1', 'md5'))); + + $this->assertFalse(Rhymix\Framework\Password::isValidAlgorithm('bunga_bunga')); + Rhymix\Framework\Password::addAlgorithm('bunga_bunga', '/bunga_bunga/', function($hash) { return 'bunga_bunga'; }); + $this->assertTrue(Rhymix\Framework\Password::isValidAlgorithm('bunga_bunga')); + } + + public function testGetSupportedAlgorithms() + { + $algos = Rhymix\Framework\Password::getSupportedAlgorithms(); + $this->assertTrue(in_array('bcrypt', $algos)); + $this->assertTrue(in_array('pbkdf2', $algos)); + $this->assertTrue(in_array('md5', $algos)); + } + + public function testGetBestSupportedAlgorithm() + { + $algo = Rhymix\Framework\Password::getBestSupportedAlgorithm(); + $this->assertTrue($algo === 'bcrypt' || $algo === 'pbkdf2'); + } + + public function testGetSelectedAlgorithm() + { + $algo = Rhymix\Framework\Password::getSelectedAlgorithm(); + $this->assertTrue($algo === 'bcrypt' || $algo === 'pbkdf2' || $algo === 'md5'); + } + + public function testGetWorkFactor() + { + $work_factor = $algo = Rhymix\Framework\Password::getWorkFactor(); + $this->assertTrue($work_factor >= 4); + $this->assertTrue($work_factor <= 31); + } + + public function testHashPassword() + { + $password = Rhymix\Framework\Security::getRandom(32); + $this->assertEquals(md5($password), Rhymix\Framework\Password::hashPassword($password, 'md5')); + $this->assertEquals(md5(sha1(md5($password))), Rhymix\Framework\Password::hashPassword($password, 'md5,sha1,md5')); + $this->assertEquals(hash('whirlpool', $password), Rhymix\Framework\Password::hashPassword($password, 'whirlpool')); + $this->assertEquals('5d2e19393cc5ef67', Rhymix\Framework\Password::hashPassword('password', 'mysql_old_password')); + } + + public function testCheckPassword() + { + $password = Rhymix\Framework\Security::getRandom(32); + + $algos = array('whirlpool', 'ripemd160', 'bcrypt'); + $hash = Rhymix\Framework\Password::hashPassword($password, $algos); + $this->assertRegExp('/^\$2y\$/', $hash); + $this->assertEquals(60, strlen($hash)); + $this->assertTrue(Rhymix\Framework\Password::checkPassword($password, $hash, $algos)); + + $algos = array('pbkdf2'); + $hash = Rhymix\Framework\Password::hashPassword($password, $algos); + $this->assertRegExp('/^(sha256|sha512):[0-9]+:/', $hash); + $this->assertEquals(60, strlen($hash)); + $this->assertTrue(Rhymix\Framework\Password::checkPassword($password, $hash, $algos)); + + foreach (array('drupal', 'joomla', 'kimsqrb', 'mysql_old_password', 'mysql_new_password', 'mssql_pwdencrypt') as $algo) + { + $hash = Rhymix\Framework\Password::hashPassword($password, $algo); + $this->assertTrue(Rhymix\Framework\Password::checkPassword($password, $hash, $algo)); + $this->assertFalse(Rhymix\Framework\Password::checkPassword($password, $hash . 'x', $algo)); + } + } + + public function testCheckAlgorithm() + { + $password = Rhymix\Framework\Security::getRandom(32, 'hex'); + + $this->assertEquals(array('md5', 'md5,sha1,md5'), Rhymix\Framework\Password::checkAlgorithm($password)); + $this->assertEquals(array('sha512', 'whirlpool'), Rhymix\Framework\Password::checkAlgorithm(hash('sha512', $password))); + + $hash = '$2y$10$VkxBdEBTZ1HyLluZPjXCjuFffw0a6alZlbb733CF/zA22HDpBNsMm'; + $this->assertEquals(array('bcrypt'), Rhymix\Framework\Password::checkAlgorithm($hash)); + + $hash = 'sha512:0008192:hoXcLXQzIiIJ:ElokybdRf+i512M4/4PIdEiSDgZ8f0uL'; + $this->assertEquals(array('pbkdf2'), Rhymix\Framework\Password::checkAlgorithm($hash)); + } + + public function testCheckWorkFactor() + { + $hash = '$2y$10$VkxBdEBTZ1HyLluZPjXCjuFffw0a6alZlbb733CF/zA22HDpBNsMm'; + $this->assertEquals(10, Rhymix\Framework\Password::checkWorkFactor($hash)); + + $hash = 'sha512:0008192:hoXcLXQzIiIJ:ElokybdRf+i512M4/4PIdEiSDgZ8f0uL'; + $this->assertEquals(8, Rhymix\Framework\Password::checkWorkFactor($hash)); + + $hash = '5f4dcc3b5aa765d61d8327deb882cf99'; + $this->assertEquals(0, Rhymix\Framework\Password::checkWorkFactor($hash)); + } + + public function testBcrypt() + { + $password = 'password'; + $hash = '$2y$10$VkxBdEBTZ1HyLluZPjXCjuFffw0a6alZlbb733CF/zA22HDpBNsMm'; + $this->assertEquals($hash, Rhymix\Framework\Password::bcrypt($password, $hash)); + } + + public function testPBKDF2() + { + $password = 'password'; + $salt = 'rtmIxdEUoWUk'; + $hash = 'sha512:0016384:rtmIxdEUoWUk:1hrwGP3ScWvxslnqNFqyhM6Ddn4iYrwf'; + $this->assertEquals($hash, Rhymix\Framework\Password::pbkdf2($password, $salt, 'sha512', 16384, 24)); + } + + public function testCountEntropyBits() + { + $this->assertEquals(0, Rhymix\Framework\Password::countEntropyBits('')); + $this->assertEquals(13, round(Rhymix\Framework\Password::countEntropyBits('1234'))); + $this->assertEquals(20, round(Rhymix\Framework\Password::countEntropyBits('123456'))); + $this->assertEquals(28, round(Rhymix\Framework\Password::countEntropyBits('rhymix'))); + $this->assertEquals(52, round(Rhymix\Framework\Password::countEntropyBits('rhymix1234'))); + $this->assertEquals(60, round(Rhymix\Framework\Password::countEntropyBits('RhymiX1234'))); + $this->assertEquals(125, round(Rhymix\Framework\Password::countEntropyBits('Rhymix_is*the%Best!'))); + } +} diff --git a/tests/unit/framework/SecurityTest.php b/tests/unit/framework/SecurityTest.php index 23d658a0f..8c600e2c6 100644 --- a/tests/unit/framework/SecurityTest.php +++ b/tests/unit/framework/SecurityTest.php @@ -57,11 +57,11 @@ class SecurityTest extends \Codeception\TestCase\Test public function testGetRandom() { - $this->assertEquals(1, preg_match('/^[0-9a-zA-Z]{32}$/', Rhymix\Framework\Security::getRandom())); - $this->assertEquals(1, preg_match('/^[0-9a-zA-Z]{256}$/', Rhymix\Framework\Security::getRandom(256))); - $this->assertEquals(1, preg_match('/^[0-9a-zA-Z]{16}$/', Rhymix\Framework\Security::getRandom(16, 'alnum'))); - $this->assertEquals(1, preg_match('/^[0-9a-f]{16}$/', Rhymix\Framework\Security::getRandom(16, 'hex'))); - $this->assertEquals(1, preg_match('/^[\x21-\x7e]{16}$/', Rhymix\Framework\Security::getRandom(16, 'printable'))); + $this->assertRegExp('/^[0-9a-zA-Z]{32}$/', Rhymix\Framework\Security::getRandom()); + $this->assertRegExp('/^[0-9a-zA-Z]{256}$/', Rhymix\Framework\Security::getRandom(256)); + $this->assertRegExp('/^[0-9a-zA-Z]{16}$/', Rhymix\Framework\Security::getRandom(16, 'alnum')); + $this->assertRegExp('/^[0-9a-f]{16}$/', Rhymix\Framework\Security::getRandom(16, 'hex')); + $this->assertRegExp('/^[\x21-\x7e]{16}$/', Rhymix\Framework\Security::getRandom(16, 'printable')); } public function testGetRandomNumber() @@ -80,7 +80,7 @@ class SecurityTest extends \Codeception\TestCase\Test $regex = '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/'; for ($i = 0; $i < 10; $i++) { - $this->assertEquals(1, preg_match($regex, Rhymix\Framework\Security::getRandomUUID())); + $this->assertRegExp($regex, Rhymix\Framework\Security::getRandomUUID()); } }