diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f80340e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto + +/test export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.travis.yml export-ignore +/phpunit.xml.dist export-ignore +/README.md export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81b9258 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +composer.lock +phpunit.xml +vendor diff --git a/.travis.yml b/.travis.yml index a960061..6dd21b2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,19 @@ language: php php: - - 5.5 - - 5.4 - 5.3 + - 5.4 + - 5.5 + - 5.6 + - hhvm + +install: + - travis_retry composer install --no-interaction --prefer-source + +script: + - vendor/bin/phpunit -script: phpunit --configuration phpunit.xml.dist \ No newline at end of file +matrix: + allow_failures: + - php: hhvm + fast_finish: true diff --git a/README.md b/README.md index e158f7d..6bfd6cc 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ password_compat [![Build Status](https://travis-ci.org/ircmaxell/password_compat.png?branch=master)](https://travis-ci.org/ircmaxell/password_compat) -This library is intended to provide forward compatibility with the password_* functions being worked on for PHP 5.5. +This library is intended to provide forward compatibility with the [password_*](http://php.net/password) functions being worked on for PHP 5.5. See [the RFC](https://wiki.php.net/rfc/password_hash) for more detailed information. @@ -11,13 +11,13 @@ See [the RFC](https://wiki.php.net/rfc/password_hash) for more detailed informat Requirements ============ -This library requires `PHP >= 5.3.7` OR a version that has the `$2y` fix backported into it (such as Debian provides). +This library requires `PHP >= 5.3.7` OR a version that has the `$2y` fix backported into it (such as RedHat provides). Note that Debian's 5.3.3 version is **NOT** supported. The runtime checks have been removed due to this version issue. To see if password_compat is available for your system, run the included `version-test.php`. If it outputs "Pass", you can safely use the library. If not, you cannot. If you attempt to use password-compat on an unsupported version, attempts to create or verify hashes will return `false`. You have been warned! -The reason for this is that PHP prior to 5.3.7 contains a security issue with its BCRYPT implementation. Therefore, it's highly recommended that you upgrade to a newer version of PHP prior to using this layer. +The reason for this is that PHP prior to 5.3.7 contains a [security issue with its BCRYPT implementation](http://php.net/security/crypt_blowfish.php). Therefore, it's highly recommended that you upgrade to a newer version of PHP prior to using this layer. Installation ============ @@ -32,21 +32,21 @@ Usage **Creating Password Hashes** To create a password hash from a password, simply use the `password_hash` function. - +````PHP $hash = password_hash($password, PASSWORD_BCRYPT); - +```` Note that the algorithm that we chose is `PASSWORD_BCRYPT`. That's the current strongest algorithm supported. This is the `BCRYPT` crypt algorithm. It produces a 60 character hash as the result. `BCRYPT` also allows for you to define a `cost` parameter in the options array. This allows for you to change the CPU cost of the algorithm: - - $hash = password_hash($password, PASSWORD_BCRYPT, ["cost" => 10]); - +````PHP + $hash = password_hash($password, PASSWORD_BCRYPT, array("cost" => 10)); +```` That's the same as the default. The cost can range from `4` to `31`. I would suggest that you use the highest cost that you can, while keeping response time reasonable (I target between 0.1 and 0.5 seconds for a hash, depending on use-case). Another algorithm name is supported: - +````PHP PASSWORD_DEFAULT - +```` This will use the strongest algorithm available to PHP at the current time. Presently, this is the same as specifying `PASSWORD_BCRYPT`. But in future versions of PHP, it may be updated to use a stronger algorithm if one is introduced. It can also be changed if a problem is identified with the BCRYPT algorithm. Note that if you use this option, you are **strongly** encouraged to store it in a `VARCHAR(255)` column to avoid truncation issues if a future algorithm increases the length of the generated hash. It is very important that you should check the return value of `password_hash` prior to storing it, because a `false` may be returned if it encountered an error. @@ -54,22 +54,23 @@ It is very important that you should check the return value of `password_hash` p **Verifying Password Hashes** To verify a hash created by `password_hash`, simply call: - +````PHP if (password_verify($password, $hash)) { /* Valid */ } else { /* Invalid */ } - +```` That's all there is to it. **Rehashing Passwords** From time to time you may update your hashing parameters (algorithm, cost, etc). So a function to determine if rehashing is necessary is available: - +````PHP if (password_verify($password, $hash)) { if (password_needs_rehash($hash, $algorithm, $options)) { $hash = password_hash($password, $algorithm, $options); /* Store new hash in db */ } } +```` diff --git a/composer.json b/composer.json index e0d4f14..822fd1f 100644 --- a/composer.json +++ b/composer.json @@ -1,18 +1,20 @@ { - "name": "ircmaxell/password-compat", - "description": "A compatibility library for the proposed simplified password hashing algorithm: https://wiki.php.net/rfc/password_hash", - "version": "1.0.3", - "keywords": ["password", "hashing"], - "homepage": "https://github.com/ircmaxell/password_compat", - "license": "MIT", - "authors": [ - { - "name": "Anthony Ferrara", - "email": "ircmaxell@php.net", - "homepage": "http://blog.ircmaxell.com" - } - ], - "autoload": { - "files": ["lib/password.php"] - } -} \ No newline at end of file + "name": "ircmaxell/password-compat", + "description": "A compatibility library for the proposed simplified password hashing algorithm: https://wiki.php.net/rfc/password_hash", + "keywords": ["password", "hashing"], + "homepage": "https://github.com/ircmaxell/password_compat", + "license": "MIT", + "authors": [ + { + "name": "Anthony Ferrara", + "email": "ircmaxell@php.net", + "homepage": "http://blog.ircmaxell.com" + } + ], + "require-dev": { + "phpunit/phpunit": "4.*" + }, + "autoload": { + "files": ["lib/password.php"] + } +} diff --git a/lib/password.php b/lib/password.php index 4d0e8b7..cc6896c 100644 --- a/lib/password.php +++ b/lib/password.php @@ -7,216 +7,308 @@ * @copyright 2012 The Authors */ -if (!defined('PASSWORD_BCRYPT')) { - - define('PASSWORD_BCRYPT', 1); - define('PASSWORD_DEFAULT', PASSWORD_BCRYPT); - - /** - * Hash the password using the specified algorithm - * - * @param string $password The password to hash - * @param int $algo The algorithm to use (Defined by PASSWORD_* constants) - * @param array $options The options for the algorithm to use - * - * @return string|false The hashed password, or false on error. - */ - function password_hash($password, $algo, array $options = array()) { - if (!function_exists('crypt')) { - trigger_error("Crypt must be loaded for password_hash to function", E_USER_WARNING); - return null; - } - if (!is_string($password)) { - trigger_error("password_hash(): Password must be a string", E_USER_WARNING); - return null; - } - if (!is_int($algo)) { - trigger_error("password_hash() expects parameter 2 to be long, " . gettype($algo) . " given", E_USER_WARNING); - return null; - } - switch ($algo) { - case PASSWORD_BCRYPT: - // Note that this is a C constant, but not exposed to PHP, so we don't define it here. - $cost = 10; - if (isset($options['cost'])) { - $cost = $options['cost']; - if ($cost < 4 || $cost > 31) { - trigger_error(sprintf("password_hash(): Invalid bcrypt cost parameter specified: %d", $cost), E_USER_WARNING); - return null; - } - } - // The length of salt to generate - $raw_salt_len = 16; - // The length required in the final serialization - $required_salt_len = 22; - $hash_format = sprintf("$2y$%02d$", $cost); - break; - default: - trigger_error(sprintf("password_hash(): Unknown password hashing algorithm: %s", $algo), E_USER_WARNING); +namespace { + + if (!defined('PASSWORD_BCRYPT')) { + /** + * PHPUnit Process isolation caches constants, but not function declarations. + * So we need to check if the constants are defined separately from + * the functions to enable supporting process isolation in userland + * code. + */ + define('PASSWORD_BCRYPT', 1); + define('PASSWORD_DEFAULT', PASSWORD_BCRYPT); + define('PASSWORD_BCRYPT_DEFAULT_COST', 10); + } + + if (!function_exists('password_hash')) { + + /** + * Hash the password using the specified algorithm + * + * @param string $password The password to hash + * @param int $algo The algorithm to use (Defined by PASSWORD_* constants) + * @param array $options The options for the algorithm to use + * + * @return string|false The hashed password, or false on error. + */ + function password_hash($password, $algo, array $options = array()) { + if (!function_exists('crypt')) { + trigger_error("Crypt must be loaded for password_hash to function", E_USER_WARNING); return null; - } - if (isset($options['salt'])) { - switch (gettype($options['salt'])) { - case 'NULL': - case 'boolean': - case 'integer': - case 'double': - case 'string': - $salt = (string) $options['salt']; - break; - case 'object': - if (method_exists($options['salt'], '__tostring')) { - $salt = (string) $options['salt']; - break; + } + if (is_null($password) || is_int($password)) { + $password = (string) $password; + } + if (!is_string($password)) { + trigger_error("password_hash(): Password must be a string", E_USER_WARNING); + return null; + } + if (!is_int($algo)) { + trigger_error("password_hash() expects parameter 2 to be long, " . gettype($algo) . " given", E_USER_WARNING); + return null; + } + $resultLength = 0; + switch ($algo) { + case PASSWORD_BCRYPT: + $cost = PASSWORD_BCRYPT_DEFAULT_COST; + if (isset($options['cost'])) { + $cost = $options['cost']; + if ($cost < 4 || $cost > 31) { + trigger_error(sprintf("password_hash(): Invalid bcrypt cost parameter specified: %d", $cost), E_USER_WARNING); + return null; + } } - case 'array': - case 'resource': + // The length of salt to generate + $raw_salt_len = 16; + // The length required in the final serialization + $required_salt_len = 22; + $hash_format = sprintf("$2y$%02d$", $cost); + // The expected length of the final crypt() output + $resultLength = 60; + break; default: - trigger_error('password_hash(): Non-string salt parameter supplied', E_USER_WARNING); + trigger_error(sprintf("password_hash(): Unknown password hashing algorithm: %s", $algo), E_USER_WARNING); return null; } - if (strlen($salt) < $required_salt_len) { - trigger_error(sprintf("password_hash(): Provided salt is too short: %d expecting %d", strlen($salt), $required_salt_len), E_USER_WARNING); - return null; - } elseif (0 == preg_match('#^[a-zA-Z0-9./]+$#D', $salt)) { - $salt = str_replace('+', '.', base64_encode($salt)); - } - } else { - $buffer = ''; - $buffer_valid = false; - if (function_exists('mcrypt_create_iv') && !defined('PHALANGER')) { - $buffer = mcrypt_create_iv($raw_salt_len, MCRYPT_DEV_URANDOM); - if ($buffer) { - $buffer_valid = true; + $salt_requires_encoding = false; + if (isset($options['salt'])) { + switch (gettype($options['salt'])) { + case 'NULL': + case 'boolean': + case 'integer': + case 'double': + case 'string': + $salt = (string) $options['salt']; + break; + case 'object': + if (method_exists($options['salt'], '__tostring')) { + $salt = (string) $options['salt']; + break; + } + case 'array': + case 'resource': + default: + trigger_error('password_hash(): Non-string salt parameter supplied', E_USER_WARNING); + return null; } - } - if (!$buffer_valid && function_exists('openssl_random_pseudo_bytes')) { - $buffer = openssl_random_pseudo_bytes($raw_salt_len); - if ($buffer) { - $buffer_valid = true; + if (PasswordCompat\binary\_strlen($salt) < $required_salt_len) { + trigger_error(sprintf("password_hash(): Provided salt is too short: %d expecting %d", PasswordCompat\binary\_strlen($salt), $required_salt_len), E_USER_WARNING); + return null; + } elseif (0 == preg_match('#^[a-zA-Z0-9./]+$#D', $salt)) { + $salt_requires_encoding = true; } - } - if (!$buffer_valid && is_readable('/dev/urandom')) { - $f = fopen('/dev/urandom', 'r'); - $read = strlen($buffer); - while ($read < $raw_salt_len) { - $buffer .= fread($f, $raw_salt_len - $read); - $read = strlen($buffer); + } else { + $buffer = ''; + $buffer_valid = false; + if (function_exists('mcrypt_create_iv') && !defined('PHALANGER')) { + $buffer = mcrypt_create_iv($raw_salt_len, MCRYPT_DEV_URANDOM); + if ($buffer) { + $buffer_valid = true; + } } - fclose($f); - if ($read >= $raw_salt_len) { - $buffer_valid = true; + if (!$buffer_valid && function_exists('openssl_random_pseudo_bytes')) { + $buffer = openssl_random_pseudo_bytes($raw_salt_len); + if ($buffer) { + $buffer_valid = true; + } } - } - if (!$buffer_valid || strlen($buffer) < $raw_salt_len) { - $bl = strlen($buffer); - for ($i = 0; $i < $raw_salt_len; $i++) { - if ($i < $bl) { - $buffer[$i] = $buffer[$i] ^ chr(mt_rand(0, 255)); - } else { - $buffer .= chr(mt_rand(0, 255)); + if (!$buffer_valid && @is_readable('/dev/urandom')) { + $f = fopen('/dev/urandom', 'r'); + $read = PasswordCompat\binary\_strlen($buffer); + while ($read < $raw_salt_len) { + $buffer .= fread($f, $raw_salt_len - $read); + $read = PasswordCompat\binary\_strlen($buffer); + } + fclose($f); + if ($read >= $raw_salt_len) { + $buffer_valid = true; + } + } + if (!$buffer_valid || PasswordCompat\binary\_strlen($buffer) < $raw_salt_len) { + $bl = PasswordCompat\binary\_strlen($buffer); + for ($i = 0; $i < $raw_salt_len; $i++) { + if ($i < $bl) { + $buffer[$i] = $buffer[$i] ^ chr(mt_rand(0, 255)); + } else { + $buffer .= chr(mt_rand(0, 255)); + } } } + $salt = $buffer; + $salt_requires_encoding = true; } - $salt = str_replace('+', '.', base64_encode($buffer)); - } - $salt = substr($salt, 0, $required_salt_len); + if ($salt_requires_encoding) { + // encode string with the Base64 variant used by crypt + $base64_digits = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + $bcrypt64_digits = + './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - $hash = $hash_format . $salt; + $base64_string = base64_encode($salt); + $salt = strtr(rtrim($base64_string, '='), $base64_digits, $bcrypt64_digits); + } + $salt = PasswordCompat\binary\_substr($salt, 0, $required_salt_len); - $ret = crypt($password, $hash); + $hash = $hash_format . $salt; - if (!is_string($ret) || strlen($ret) <= 13) { - return false; - } + $ret = crypt($password, $hash); - return $ret; - } + if (!is_string($ret) || PasswordCompat\binary\_strlen($ret) != $resultLength) { + return false; + } - /** - * Get information about the password hash. Returns an array of the information - * that was used to generate the password hash. - * - * array( - * 'algo' => 1, - * 'algoName' => 'bcrypt', - * 'options' => array( - * 'cost' => 10, - * ), - * ) - * - * @param string $hash The password hash to extract info from - * - * @return array The array of information about the hash. - */ - function password_get_info($hash) { - $return = array( - 'algo' => 0, - 'algoName' => 'unknown', - 'options' => array(), - ); - if (substr($hash, 0, 4) == '$2y$' && strlen($hash) == 60) { - $return['algo'] = PASSWORD_BCRYPT; - $return['algoName'] = 'bcrypt'; - list($cost) = sscanf($hash, "$2y$%d$"); - $return['options']['cost'] = $cost; + return $ret; } - return $return; - } - /** - * Determine if the password hash needs to be rehashed according to the options provided - * - * If the answer is true, after validating the password using password_verify, rehash it. - * - * @param string $hash The hash to test - * @param int $algo The algorithm used for new password hashes - * @param array $options The options array passed to password_hash - * - * @return boolean True if the password needs to be rehashed. - */ - function password_needs_rehash($hash, $algo, array $options = array()) { - $info = password_get_info($hash); - if ($info['algo'] != $algo) { - return true; - } - switch ($algo) { - case PASSWORD_BCRYPT: - $cost = isset($options['cost']) ? $options['cost'] : 10; - if ($cost != $info['options']['cost']) { - return true; - } - break; + /** + * Get information about the password hash. Returns an array of the information + * that was used to generate the password hash. + * + * array( + * 'algo' => 1, + * 'algoName' => 'bcrypt', + * 'options' => array( + * 'cost' => PASSWORD_BCRYPT_DEFAULT_COST, + * ), + * ) + * + * @param string $hash The password hash to extract info from + * + * @return array The array of information about the hash. + */ + function password_get_info($hash) { + $return = array( + 'algo' => 0, + 'algoName' => 'unknown', + 'options' => array(), + ); + if (PasswordCompat\binary\_substr($hash, 0, 4) == '$2y$' && PasswordCompat\binary\_strlen($hash) == 60) { + $return['algo'] = PASSWORD_BCRYPT; + $return['algoName'] = 'bcrypt'; + list($cost) = sscanf($hash, "$2y$%d$"); + $return['options']['cost'] = $cost; + } + return $return; } - return false; - } - /** - * Verify a password against a hash using a timing attack resistant approach - * - * @param string $password The password to verify - * @param string $hash The hash to verify against - * - * @return boolean If the password matches the hash - */ - function password_verify($password, $hash) { - if (!function_exists('crypt')) { - trigger_error("Crypt must be loaded for password_verify to function", E_USER_WARNING); - return false; - } - $ret = crypt($password, $hash); - if (!is_string($ret) || strlen($ret) != strlen($hash) || strlen($ret) <= 13) { + /** + * Determine if the password hash needs to be rehashed according to the options provided + * + * If the answer is true, after validating the password using password_verify, rehash it. + * + * @param string $hash The hash to test + * @param int $algo The algorithm used for new password hashes + * @param array $options The options array passed to password_hash + * + * @return boolean True if the password needs to be rehashed. + */ + function password_needs_rehash($hash, $algo, array $options = array()) { + $info = password_get_info($hash); + if ($info['algo'] != $algo) { + return true; + } + switch ($algo) { + case PASSWORD_BCRYPT: + $cost = isset($options['cost']) ? $options['cost'] : PASSWORD_BCRYPT_DEFAULT_COST; + if ($cost != $info['options']['cost']) { + return true; + } + break; + } return false; } - $status = 0; - for ($i = 0; $i < strlen($ret); $i++) { - $status |= (ord($ret[$i]) ^ ord($hash[$i])); - } + /** + * Verify a password against a hash using a timing attack resistant approach + * + * @param string $password The password to verify + * @param string $hash The hash to verify against + * + * @return boolean If the password matches the hash + */ + function password_verify($password, $hash) { + if (!function_exists('crypt')) { + trigger_error("Crypt must be loaded for password_verify to function", E_USER_WARNING); + return false; + } + $ret = crypt($password, $hash); + if (!is_string($ret) || PasswordCompat\binary\_strlen($ret) != PasswordCompat\binary\_strlen($hash) || PasswordCompat\binary\_strlen($ret) <= 13) { + return false; + } + + $status = 0; + for ($i = 0; $i < PasswordCompat\binary\_strlen($ret); $i++) { + $status |= (ord($ret[$i]) ^ ord($hash[$i])); + } - return $status === 0; + return $status === 0; + } } + } +namespace PasswordCompat\binary { + if (!function_exists('PasswordCompat\\binary\\_strlen')) { + /** + * Count the number of bytes in a string + * + * We cannot simply use strlen() for this, because it might be overwritten by the mbstring extension. + * In this case, strlen() will count the number of *characters* based on the internal encoding. A + * sequence of bytes might be regarded as a single multibyte character. + * + * @param string $binary_string The input string + * + * @internal + * @return int The number of bytes + */ + function _strlen($binary_string) { + if (function_exists('mb_strlen')) { + return mb_strlen($binary_string, '8bit'); + } + return strlen($binary_string); + } + + /** + * Get a substring based on byte limits + * + * @see _strlen() + * + * @param string $binary_string The input string + * @param int $start + * @param int $length + * + * @internal + * @return string The substring + */ + function _substr($binary_string, $start, $length) { + if (function_exists('mb_substr')) { + return mb_substr($binary_string, $start, $length, '8bit'); + } + return substr($binary_string, $start, $length); + } + + /** + * Check if current PHP version is compatible with the library + * + * @return boolean the check result + */ + function check() { + static $pass = NULL; + + if (is_null($pass)) { + if (function_exists('crypt')) { + $hash = '$2y$04$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG'; + $test = crypt("password", $hash); + $pass = $test == $hash; + } else { + $pass = false; + } + } + return $pass; + } + + } +} \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist index b2b3afb..33f37a1 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,28 +2,30 @@ + syntaxCheck="true" + strict="true" + verbose="true" +> test/Unit + + + ./test + + - - lib/ + + ./lib diff --git a/test/Unit/PasswordHashTest.php b/test/Unit/PasswordHashTest.php index 9e5e9ec..abe9109 100644 --- a/test/Unit/PasswordHashTest.php +++ b/test/Unit/PasswordHashTest.php @@ -22,9 +22,23 @@ public function testKnownSalt() { public function testRawSalt() { $hash = password_hash("test", PASSWORD_BCRYPT, array("salt" => "123456789012345678901" . chr(0))); - $this->assertEquals('$2y$10$MTIzNDU2Nzg5MDEyMzQ1Nej0NmcAWSLR.oP7XOR9HD/vjUuOj100y', $hash); + if (version_compare(PHP_VERSION, '5.5.0', '<')) { + $this->assertEquals('$2y$10$KRGxLBS0Lxe3KBCwKxOzLexLDeu0ZfqJAKTubOfy7O/yL2hjimw3u', $hash); + } else { + $this->assertEquals('$2y$10$MTIzNDU2Nzg5MDEyMzQ1Nej0NmcAWSLR.oP7XOR9HD/vjUuOj100y', $hash); + } } + public function testNullBehavior() { + $hash = password_hash(null, PASSWORD_BCRYPT, array("salt" => "1234567890123456789012345678901234567890")); + $this->assertEquals('$2y$10$123456789012345678901uhihPb9QpE2n03zMu9TDdvO34jDn6mO.', $hash); + } + + public function testIntegerBehavior() { + $hash = password_hash(12345, PASSWORD_BCRYPT, array("salt" => "1234567890123456789012345678901234567890")); + $this->assertEquals('$2y$10$123456789012345678901ujczD5TiARVFtc68bZCAlbEg1fCIexfO', $hash); + } + /** * @expectedException PHPUnit_Framework_Error */ @@ -81,4 +95,4 @@ public function testInvalidBcryptSaltShort() { password_hash('foo', PASSWORD_BCRYPT, array('salt' => 'abc')); } -} \ No newline at end of file +} diff --git a/version-test.php b/version-test.php index f527e30..96f60ca 100644 --- a/version-test.php +++ b/version-test.php @@ -1,8 +1,6 @@