diff --git a/src/infrastructure/util/password/PhabricatorBcryptPasswordHasher.php b/src/infrastructure/util/password/PhabricatorBcryptPasswordHasher.php index 11e5a9389d..fc2bf7a523 100644 --- a/src/infrastructure/util/password/PhabricatorBcryptPasswordHasher.php +++ b/src/infrastructure/util/password/PhabricatorBcryptPasswordHasher.php @@ -1,56 +1,75 @@ openEnvelope(); - // NOTE: The default cost is "10", but my laptop can do a hash of cost - // "12" in about 300ms. Since server hardware is often virtualized or old, - // just split the difference. - $options = array( - 'cost' => 11, + 'cost' => $this->getBcryptCost(), ); $raw_hash = password_hash($raw_input, CRYPT_BLOWFISH, $options); return new PhutilOpaqueEnvelope($raw_hash); } protected function verifyPassword( PhutilOpaqueEnvelope $password, PhutilOpaqueEnvelope $hash) { return password_verify($password->openEnvelope(), $hash->openEnvelope()); } + protected function canUpgradeInternalHash(PhutilOpaqueEnvelope $hash) { + $info = password_get_info($hash->openEnvelope()); + + // NOTE: If the costs don't match -- even if the new cost is lower than + // the old cost -- count this as an upgrade. This allows costs to be + // adjusted down and hashing to be migrated toward the new cost if costs + // are ever configured too high for some reason. + + $cost = idx($info['options'], 'cost'); + if ($cost != $this->getBcryptCost()) { + return true; + } + + return false; + } + + private function getBcryptCost() { + // NOTE: The default cost is "10", but my laptop can do a hash of cost + // "12" in about 300ms. Since server hardware is often virtualized or old, + // just split the difference. + return 11; + } + } diff --git a/src/infrastructure/util/password/PhabricatorPasswordHasher.php b/src/infrastructure/util/password/PhabricatorPasswordHasher.php index 12a341289f..0dcdb0e7c8 100644 --- a/src/infrastructure/util/password/PhabricatorPasswordHasher.php +++ b/src/infrastructure/util/password/PhabricatorPasswordHasher.php @@ -1,358 +1,388 @@ getPasswordHash($password)->openEnvelope(); $expect_hash = $hash->openEnvelope(); return ($actual_hash === $expect_hash); } + /** + * Check if an existing hash created by this algorithm is upgradeable. + * + * The default implementation returns `false`. However, hash algorithms which + * have (for example) an internal cost function may be able to upgrade an + * existing hash to a stronger one with a higher cost. + * + * @param PhutilOpaqueEnvelope Bare hash. + * @return bool True if the hash can be upgraded without + * changing the algorithm (for example, to a + * higher cost). + * @task hasher + */ + protected function canUpgradeInternalHash(PhutilOpaqueEnvelope $hash) { + return false; + } + + /* -( Using Hashers )------------------------------------------------------ */ /** * Get the hash of a password for storage. * * @param PhutilOpaqueEnvelope Password text. * @return PhutilOpaqueEnvelope Hashed text. * @task hashing */ final public function getPasswordHashForStorage( PhutilOpaqueEnvelope $envelope) { $name = $this->getHashName(); $hash = $this->getPasswordHash($envelope); $actual_len = strlen($hash->openEnvelope()); $expect_len = $this->getHashLength(); if ($actual_len > $expect_len) { throw new Exception( pht( "Password hash '%s' produced a hash of length %d, but a ". "maximum length of %d was expected.", $name, new PhutilNumber($actual_len), new PhutilNumber($expect_len))); } return new PhutilOpaqueEnvelope($name.':'.$hash->openEnvelope()); } /** * Parse a storage hash into its components, like the hash type and hash * data. * * @return map Dictionary of information about the hash. * @task hashing */ private static function parseHashFromStorage(PhutilOpaqueEnvelope $hash) { $raw_hash = $hash->openEnvelope(); if (strpos($raw_hash, ':') === false) { throw new Exception( pht( 'Malformed password hash, expected "name:hash".')); } list($name, $hash) = explode(':', $raw_hash); return array( 'name' => $name, 'hash' => new PhutilOpaqueEnvelope($hash), ); } /** * Get all available password hashers. This may include hashers which can not * actually be used (for example, a required extension is missing). * * @return list Hasher objects. * @task hashing */ public static function getAllHashers() { $objects = id(new PhutilSymbolLoader()) ->setAncestorClass('PhabricatorPasswordHasher') ->loadObjects(); $map = array(); foreach ($objects as $object) { $name = $object->getHashName(); $potential_length = strlen($name) + $object->getHashLength() + 1; $maximum_length = self::MAXIMUM_STORAGE_SIZE; if ($potential_length > $maximum_length) { throw new Exception( pht( 'Hasher "%s" may produce hashes which are too long to fit in '. 'storage. %d characters are available, but its hashes may be '. 'up to %d characters in length.', $name, $maximum_length, $potential_length)); } if (isset($map[$name])) { throw new Exception( pht( 'Two hashers use the same hash name ("%s"), "%s" and "%s". Each '. 'hasher must have a unique name.', $name, get_class($object), get_class($map[$name]))); } $map[$name] = $object; } return $map; } /** * Get all usable password hashers. This may include hashers which are * not desirable or advisable. * * @return list Hasher objects. * @task hashing */ public static function getAllUsableHashers() { $hashers = self::getAllHashers(); foreach ($hashers as $key => $hasher) { if (!$hasher->canHashPasswords()) { unset($hashers[$key]); } } return $hashers; } /** * Get the best (strongest) available hasher. * * @return PhabicatorPasswordHasher Best hasher. * @task hashing */ public static function getBestHasher() { $hashers = self::getAllUsableHashers(); $hashers = msort($hashers, 'getStrength'); $hasher = last($hashers); if (!$hasher) { throw new PhabricatorPasswordHasherUnavailableException( pht( 'There are no password hashers available which are usable for '. 'new passwords.')); } return $hasher; } /** * Get the hashser for a given stored hash. * * @return PhabicatorPasswordHasher Corresponding hasher. * @task hashing */ public static function getHasherForHash(PhutilOpaqueEnvelope $hash) { $info = self::parseHashFromStorage($hash); $name = $info['name']; $usable = self::getAllUsableHashers(); if (isset($usable[$name])) { return $usable[$name]; } $all = self::getAllHashers(); if (isset($all[$name])) { throw new PhabricatorPasswordHasherUnavailableException( pht( 'Attempting to compare a password saved with the "%s" hash. The '. 'hasher exists, but is not currently usable. %s', $name, $all[$name]->getInstallInstructions())); } throw new PhabricatorPasswordHasherUnavailableException( pht( 'Attempting to compare a password saved with the "%s" hash. No such '. 'hasher is known to Phabricator.', $name)); } /** * Test if a password is using an weaker hash than the strongest available * hash. This can be used to prompt users to upgrade, or automatically upgrade * on login. * * @return bool True to indicate that rehashing this password will improve * the hash strength. * @task hashing */ public static function canUpgradeHash(PhutilOpaqueEnvelope $hash) { $current_hasher = self::getHasherForHash($hash); $best_hasher = self::getBestHasher(); - return ($current_hasher->getHashName() != $best_hasher->getHashName()); + if ($current_hasher->getHashName() != $best_hasher->getHashName()) { + // If the algorithm isn't the best one, we can upgrade. + return true; + } + + $info = self::parseHashFromStorage($hash); + if ($current_hasher->canUpgradeInternalHash($info['hash'])) { + // If the algorithm provides an internal upgrade, we can also upgrade. + return true; + } + + // Already on the best algorithm with the best settings. + return false; } /** * Generate a new hash for a password, using the best available hasher. * * @param PhutilOpaqueEnvelope Password to hash. * @return PhutilOpaqueEnvelope Hashed password, using best available * hasher. * @task hashing */ public static function generateNewPasswordHash( PhutilOpaqueEnvelope $password) { $hasher = self::getBestHasher(); return $hasher->getPasswordHashForStorage($password); } /** * Compare a password to a stored hash. * * @param PhutilOpaqueEnvelope Password to compare. * @param PhutilOpaqueEnvelope Stored password hash. * @return bool True if the passwords match. * @task hashing */ public static function comparePassword( PhutilOpaqueEnvelope $password, PhutilOpaqueEnvelope $hash) { $hasher = self::getHasherForHash($hash); $parts = self::parseHashFromStorage($hash); return $hasher->verifyPassword($password, $parts['hash']); } }