@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Make password hashing modular

Summary:
Ref T4443. Make hashing algorithms pluggable and extensible so we can deal with the attendant complexities more easily.

This moves "Iterated MD5" to a modular implementation, and adds a tiny bit of hack-glue so we don't need to migrate the DB in this patch. I'll migrate in the next patch, then add bcrypt.

Test Plan:
- Verified that the same stuff gets stored in the DB (i.e., no functional changes):
- Logged into an old password account.
- Changed password.
- Registered a new account.
- Changed password.
- Switched back to master.
- Logged in / out, changed password.
- Switched back, logged in.
- Ran unit tests (they aren't super extensive, but cover some of the basics).

Reviewers: btrahan

Reviewed By: btrahan

CC: aran, kofalt

Maniphest Tasks: T4443

Differential Revision: https://secure.phabricator.com/D8268

+560 -15
+8
src/__phutil_library_map__.php
··· 1595 1595 'PhabricatorInlineSummaryView' => 'infrastructure/diff/view/PhabricatorInlineSummaryView.php', 1596 1596 'PhabricatorInternationalizationManagementExtractWorkflow' => 'infrastructure/internationalization/management/PhabricatorInternationalizationManagementExtractWorkflow.php', 1597 1597 'PhabricatorInternationalizationManagementWorkflow' => 'infrastructure/internationalization/management/PhabricatorInternationalizationManagementWorkflow.php', 1598 + 'PhabricatorIteratedMD5PasswordHasher' => 'infrastructure/util/password/PhabricatorIteratedMD5PasswordHasher.php', 1598 1599 'PhabricatorJavelinLinter' => 'infrastructure/lint/linter/PhabricatorJavelinLinter.php', 1599 1600 'PhabricatorJumpNavHandler' => 'applications/search/engine/PhabricatorJumpNavHandler.php', 1600 1601 'PhabricatorKeyValueDatabaseCache' => 'applications/cache/PhabricatorKeyValueDatabaseCache.php', ··· 1764 1765 'PhabricatorPHIDType' => 'applications/phid/type/PhabricatorPHIDType.php', 1765 1766 'PhabricatorPHPMailerConfigOptions' => 'applications/config/option/PhabricatorPHPMailerConfigOptions.php', 1766 1767 'PhabricatorPagedFormExample' => 'applications/uiexample/examples/PhabricatorPagedFormExample.php', 1768 + 'PhabricatorPasswordHasher' => 'infrastructure/util/password/PhabricatorPasswordHasher.php', 1769 + 'PhabricatorPasswordHasherTestCase' => 'infrastructure/util/password/__tests__/PhabricatorPasswordHasherTestCase.php', 1770 + 'PhabricatorPasswordHasherUnavailableException' => 'infrastructure/util/password/PhabricatorPasswordHasherUnavailableException.php', 1767 1771 'PhabricatorPaste' => 'applications/paste/storage/PhabricatorPaste.php', 1768 1772 'PhabricatorPasteCommentController' => 'applications/paste/controller/PhabricatorPasteCommentController.php', 1769 1773 'PhabricatorPasteConfigOptions' => 'applications/paste/config/PhabricatorPasteConfigOptions.php', ··· 4327 4331 'PhabricatorInlineSummaryView' => 'AphrontView', 4328 4332 'PhabricatorInternationalizationManagementExtractWorkflow' => 'PhabricatorInternationalizationManagementWorkflow', 4329 4333 'PhabricatorInternationalizationManagementWorkflow' => 'PhabricatorManagementWorkflow', 4334 + 'PhabricatorIteratedMD5PasswordHasher' => 'PhabricatorPasswordHasher', 4330 4335 'PhabricatorJavelinLinter' => 'ArcanistLinter', 4331 4336 'PhabricatorKeyValueDatabaseCache' => 'PhutilKeyValueCache', 4332 4337 'PhabricatorLegalpadConfigOptions' => 'PhabricatorApplicationConfigOptions', ··· 4491 4496 'PhabricatorPHDConfigOptions' => 'PhabricatorApplicationConfigOptions', 4492 4497 'PhabricatorPHPMailerConfigOptions' => 'PhabricatorApplicationConfigOptions', 4493 4498 'PhabricatorPagedFormExample' => 'PhabricatorUIExample', 4499 + 'PhabricatorPasswordHasher' => 'Phobject', 4500 + 'PhabricatorPasswordHasherTestCase' => 'PhabricatorTestCase', 4501 + 'PhabricatorPasswordHasherUnavailableException' => 'Exception', 4494 4502 'PhabricatorPaste' => 4495 4503 array( 4496 4504 0 => 'PhabricatorPasteDAO',
+3
src/applications/auth/controller/config/PhabricatorAuthEditController.php
··· 276 276 $form->appendRemarkupInstructions($help); 277 277 } 278 278 279 + $footer = $provider->renderConfigurationFooter(); 280 + 279 281 $crumbs = $this->buildApplicationCrumbs(); 280 282 $crumbs->addTextCrumb($crumb); 281 283 ··· 305 307 array( 306 308 $crumbs, 307 309 $form_box, 310 + $footer, 308 311 $xaction_view, 309 312 ), 310 313 array(
+4
src/applications/auth/provider/PhabricatorAuthProvider.php
··· 437 437 $content); 438 438 } 439 439 440 + public function renderConfigurationFooter() { 441 + return null; 442 + } 443 + 440 444 }
+85 -2
src/applications/auth/provider/PhabricatorAuthProviderPassword.php
··· 11 11 12 12 public function getConfigurationHelp() { 13 13 return pht( 14 - 'You can select a minimum password length by setting '. 15 - '`account.minimum-password-length` in configuration.'); 14 + "(WARNING) Examine the table below for information on how password ". 15 + "hashes will be stored in the database.\n\n". 16 + "(NOTE) You can select a minimum password length by setting ". 17 + "`account.minimum-password-length` in configuration."); 18 + } 19 + 20 + public function renderConfigurationFooter() { 21 + $hashers = PhabricatorPasswordHasher::getAllHashers(); 22 + $hashers = msort($hashers, 'getStrength'); 23 + $hashers = array_reverse($hashers); 24 + 25 + $yes = phutil_tag( 26 + 'strong', 27 + array( 28 + 'style' => 'color: #009900', 29 + ), 30 + pht('Yes')); 31 + 32 + $no = phutil_tag( 33 + 'strong', 34 + array( 35 + 'style' => 'color: #990000', 36 + ), 37 + pht('Not Installed')); 38 + 39 + $best_hasher_name = null; 40 + try { 41 + $best_hasher = PhabricatorPasswordHasher::getBestHasher(); 42 + $best_hasher_name = $best_hasher->getHashName(); 43 + } catch (PhabricatorPasswordHasherUnavailableException $ex) { 44 + // There are no suitable hashers. The user might be able to enable some, 45 + // so we don't want to fatal here. We'll fatal when users try to actually 46 + // use this stuff if it isn't fixed before then. Until then, we just 47 + // don't highlight a row. In practice, at least one hasher should always 48 + // be available. 49 + } 50 + 51 + $rows = array(); 52 + $rowc = array(); 53 + foreach ($hashers as $hasher) { 54 + $is_installed = $hasher->canHashPasswords(); 55 + 56 + $rows[] = array( 57 + $hasher->getHumanReadableName(), 58 + $hasher->getHashName(), 59 + $hasher->getHumanReadableStrength(), 60 + ($is_installed ? $yes : $no), 61 + ($is_installed ? null : $hasher->getInstallInstructions()), 62 + ); 63 + $rowc[] = ($best_hasher_name == $hasher->getHashName()) 64 + ? 'highlighted' 65 + : null; 66 + } 67 + 68 + $table = new AphrontTableView($rows); 69 + $table->setRowClasses($rowc); 70 + $table->setHeaders( 71 + array( 72 + pht('Algorithm'), 73 + pht('Name'), 74 + pht('Strength'), 75 + pht('Installed'), 76 + pht('Install Instructions'), 77 + )); 78 + 79 + $table->setColumnClasses( 80 + array( 81 + '', 82 + '', 83 + '', 84 + '', 85 + 'wide', 86 + )); 87 + 88 + $header = id(new PHUIHeaderView()) 89 + ->setHeader(pht('Password Hash Algorithms')) 90 + ->setSubheader( 91 + pht( 92 + 'Stronger algorithms are listed first. The highlighted algorithm '. 93 + 'will be used when storing new hashes. Older hashes will be '. 94 + 'upgraded to the best algorithm over time.')); 95 + 96 + return id(new PHUIObjectBoxView()) 97 + ->setHeader($header) 98 + ->appendChild($table); 16 99 } 17 100 18 101 public function getDescriptionForCreate() {
+31 -13
src/applications/people/storage/PhabricatorUser.php
··· 112 112 if (!strlen($envelope->openEnvelope())) { 113 113 $this->setPasswordHash(''); 114 114 } else { 115 - $this->setPasswordSalt(md5(mt_rand())); 115 + $this->setPasswordSalt(md5(Filesystem::readRandomBytes(32))); 116 116 $hash = $this->hashPassword($envelope); 117 - $this->setPasswordHash($hash); 117 + $this->setPasswordHash($hash->openEnvelope()); 118 118 } 119 119 return $this; 120 120 } ··· 170 170 if (!strlen($this->getPasswordHash())) { 171 171 return false; 172 172 } 173 - $password_hash = $this->hashPassword($envelope); 174 - return ($password_hash === $this->getPasswordHash()); 173 + 174 + return PhabricatorPasswordHasher::comparePassword( 175 + $this->getPasswordHashInput($envelope), 176 + // TODO: For now, we need to add a prefix. 177 + new PhutilOpaqueEnvelope('md5:'.$this->getPasswordHash())); 175 178 } 176 179 177 - private function hashPassword(PhutilOpaqueEnvelope $envelope) { 178 - $hash = $this->getUsername(). 179 - $envelope->openEnvelope(). 180 - $this->getPHID(). 181 - $this->getPasswordSalt(); 182 - for ($ii = 0; $ii < 1000; $ii++) { 183 - $hash = md5($hash); 184 - } 185 - return $hash; 180 + private function getPasswordHashInput(PhutilOpaqueEnvelope $password) { 181 + $input = 182 + $this->getUsername(). 183 + $password->openEnvelope(). 184 + $this->getPHID(). 185 + $this->getPasswordSalt(); 186 + 187 + return new PhutilOpaqueEnvelope($input); 188 + } 189 + 190 + private function hashPassword(PhutilOpaqueEnvelope $password) { 191 + 192 + $hasher = PhabricatorPasswordHasher::getBestHasher(); 193 + 194 + $input_envelope = $this->getPasswordHashInput($password); 195 + $output_envelope = $hasher->getPasswordHashForStorage($input_envelope); 196 + 197 + // TODO: For now, we need to strip the type prefix until we can upgrade 198 + // the storage. 199 + 200 + $raw_output = $output_envelope->openEnvelope(); 201 + $raw_output = substr($raw_output, strlen('md5:')); 202 + 203 + return new PhutilOpaqueEnvelope($raw_output); 186 204 } 187 205 188 206 const CSRF_CYCLE_FREQUENCY = 3600;
+46
src/infrastructure/util/password/PhabricatorIteratedMD5PasswordHasher.php
··· 1 + <?php 2 + 3 + final class PhabricatorIteratedMD5PasswordHasher 4 + extends PhabricatorPasswordHasher { 5 + 6 + public function getHumanReadableName() { 7 + return pht('Iterated MD5'); 8 + } 9 + 10 + public function getHashName() { 11 + return 'md5'; 12 + } 13 + 14 + public function getHashLength() { 15 + return 40; 16 + } 17 + 18 + public function canHashPasswords() { 19 + return function_exists('md5'); 20 + } 21 + 22 + public function getInstallInstructions() { 23 + // This should always be available, but do something useful anyway. 24 + return pht('To use iterated MD5, make the md5() function available.'); 25 + } 26 + 27 + public function getStrength() { 28 + return 1.0; 29 + } 30 + 31 + public function getHumanReadableStrength() { 32 + return pht("Okay"); 33 + } 34 + 35 + protected function getPasswordHash(PhutilOpaqueEnvelope $envelope) { 36 + $raw_input = $envelope->openEnvelope(); 37 + 38 + $hash = $raw_input; 39 + for ($ii = 0; $ii < 1000; $ii++) { 40 + $hash = md5($hash); 41 + } 42 + 43 + return new PhutilOpaqueEnvelope($hash); 44 + } 45 + 46 + }
+336
src/infrastructure/util/password/PhabricatorPasswordHasher.php
··· 1 + <?php 2 + 3 + /** 4 + * Provides a mechanism for hashing passwords, like "iterated md5", "bcrypt", 5 + * "scrypt", etc. 6 + * 7 + * Hashers define suitability and strength, and the system automatically 8 + * chooses the strongest available hasher and can prompt users to upgrade as 9 + * soon as a stronger hasher is available. 10 + * 11 + * @task hasher Implementing a Hasher 12 + * @task hashing Using Hashers 13 + */ 14 + abstract class PhabricatorPasswordHasher extends Phobject { 15 + 16 + const MAXIMUM_STORAGE_SIZE = 128; 17 + 18 + 19 + /* -( Implementing a Hasher )---------------------------------------------- */ 20 + 21 + 22 + /** 23 + * Return a human-readable description of this hasher, like "Iterated MD5". 24 + * 25 + * @return string Human readable hash name. 26 + * @task hasher 27 + */ 28 + abstract public function getHumanReadableName(); 29 + 30 + 31 + /** 32 + * Return a short, unique, key identifying this hasher, like "md5" or 33 + * "bcrypt". This identifier should not be translated. 34 + * 35 + * @return string Short, unique hash name. 36 + * @task hasher 37 + */ 38 + abstract public function getHashName(); 39 + 40 + 41 + /** 42 + * Return the maximum byte length of hashes produced by this hasher. This is 43 + * used to prevent storage overflows. 44 + * 45 + * @return int Maximum number of bytes in hashes this class produces. 46 + * @task hasher 47 + */ 48 + abstract public function getHashLength(); 49 + 50 + 51 + /** 52 + * Return `true` to indicate that any required extensions or dependencies 53 + * are available, and this hasher is able to perform hashing. 54 + * 55 + * @return bool True if this hasher can execute. 56 + * @task hasher 57 + */ 58 + abstract public function canHashPasswords(); 59 + 60 + 61 + /** 62 + * Return a human-readable string describing why this hasher is unable 63 + * to operate. For example, "To use bcrypt, upgrade to PHP 5.5.0 or newer.". 64 + * 65 + * @return string Human-readable description of how to enable this hasher. 66 + * @task hasher 67 + */ 68 + abstract public function getInstallInstructions(); 69 + 70 + 71 + /** 72 + * Return an indicator of this hasher's strength. When choosing to hash 73 + * new passwords, the strongest available hasher which is usuable for new 74 + * passwords will be used, and the presence of a stronger hasher will 75 + * prompt users to update their hashes. 76 + * 77 + * Generally, this method should return a larger number than hashers it is 78 + * preferable to, but a smaller number than hashers which are better than it 79 + * is. This number does not need to correspond directly with the actual hash 80 + * strength. 81 + * 82 + * @return float Strength of this hasher. 83 + * @task hasher 84 + */ 85 + abstract public function getStrength(); 86 + 87 + 88 + /** 89 + * Return a short human-readable indicator of this hasher's strength, like 90 + * "Weak", "Okay", or "Good". 91 + * 92 + * This is only used to help administrators make decisions about 93 + * configuration. 94 + * 95 + * @return string Short human-readable description of hash strength. 96 + * @task hasher 97 + */ 98 + abstract public function getHumanReadableStrength(); 99 + 100 + 101 + /** 102 + * Produce a password hash. 103 + * 104 + * @param PhutilOpaqueEnvelope Text to be hashed. 105 + * @return PhutilOpaqueEnvelope Hashed text. 106 + * @task hasher 107 + */ 108 + abstract protected function getPasswordHash(PhutilOpaqueEnvelope $envelope); 109 + 110 + 111 + /* -( Using Hashers )------------------------------------------------------ */ 112 + 113 + 114 + /** 115 + * Get the hash of a password for storage. 116 + * 117 + * @param PhutilOpaqueEnvelope Password text. 118 + * @return PhutilOpaqueEnvelope Hashed text. 119 + * @task hashing 120 + */ 121 + final public function getPasswordHashForStorage( 122 + PhutilOpaqueEnvelope $envelope) { 123 + 124 + $name = $this->getHashName(); 125 + $hash = $this->getPasswordHash($envelope); 126 + 127 + $actual_len = strlen($hash->openEnvelope()); 128 + $expect_len = $this->getHashLength(); 129 + if ($actual_len > $expect_len) { 130 + throw new Exception( 131 + pht( 132 + "Password hash '%s' produced a hash of length %d, but a ". 133 + "maximum length of %d was expected.", 134 + $name, 135 + new PhutilNumber($actual_len), 136 + new PhutilNumber($expect_len))); 137 + } 138 + 139 + return new PhutilOpaqueEnvelope($name.':'.$hash->openEnvelope()); 140 + } 141 + 142 + 143 + /** 144 + * Parse a storage hash into its components, like the hash type and hash 145 + * data. 146 + * 147 + * @return map Dictionary of information about the hash. 148 + * @task hashing 149 + */ 150 + private static function parseHashFromStorage(PhutilOpaqueEnvelope $hash) { 151 + $raw_hash = $hash->openEnvelope(); 152 + if (strpos($raw_hash, ':') === false) { 153 + throw new Exception( 154 + pht( 155 + 'Malformed password hash, expected "name:hash".')); 156 + } 157 + 158 + list($name, $hash) = explode(':', $raw_hash); 159 + 160 + return array( 161 + 'name' => $name, 162 + 'hash' => new PhutilOpaqueEnvelope($hash), 163 + ); 164 + } 165 + 166 + 167 + /** 168 + * Get all available password hashers. This may include hashers which can not 169 + * actually be used (for example, a required extension is missing). 170 + * 171 + * @return list<PhabicatorPasswordHasher> Hasher objects. 172 + * @task hashing 173 + */ 174 + public static function getAllHashers() { 175 + $objects = id(new PhutilSymbolLoader()) 176 + ->setAncestorClass('PhabricatorPasswordHasher') 177 + ->loadObjects(); 178 + 179 + $map = array(); 180 + foreach ($objects as $object) { 181 + $name = $object->getHashName(); 182 + 183 + $potential_length = strlen($name) + $object->getHashLength() + 1; 184 + $maximum_length = self::MAXIMUM_STORAGE_SIZE; 185 + 186 + if ($potential_length > $maximum_length) { 187 + throw new Exception( 188 + pht( 189 + 'Hasher "%s" may produce hashes which are too long to fit in '. 190 + 'storage. %d characters are available, but its hashes may be '. 191 + 'up to %d characters in length.', 192 + $name, 193 + $maximum_length, 194 + $potential_length)); 195 + } 196 + 197 + if (isset($map[$name])) { 198 + throw new Exception( 199 + pht( 200 + 'Two hashers use the same hash name ("%s"), "%s" and "%s". Each '. 201 + 'hasher must have a unique name.', 202 + $name, 203 + get_class($object), 204 + get_class($map[$name]))); 205 + } 206 + $map[$name] = $object; 207 + } 208 + 209 + return $map; 210 + } 211 + 212 + 213 + /** 214 + * Get all usable password hashers. This may include hashers which are 215 + * not desirable or advisable. 216 + * 217 + * @return list<PhabicatorPasswordHasher> Hasher objects. 218 + * @task hashing 219 + */ 220 + public static function getAllUsableHashers() { 221 + $hashers = self::getAllHashers(); 222 + foreach ($hashers as $key => $hasher) { 223 + if (!$hasher->canHashPasswords()) { 224 + unset($hashers[$key]); 225 + } 226 + } 227 + return $hashers; 228 + } 229 + 230 + 231 + /** 232 + * Get the best (strongest) available hasher. 233 + * 234 + * @return PhabicatorPasswordHasher Best hasher. 235 + * @task hashing 236 + */ 237 + public static function getBestHasher() { 238 + $hashers = self::getAllUsableHashers(); 239 + msort($hashers, 'getStrength'); 240 + 241 + $hasher = last($hashers); 242 + if (!$hasher) { 243 + throw new PhabricatorPasswordHasherUnavailableException( 244 + pht( 245 + 'There are no password hashers available which are usable for '. 246 + 'new passwords.')); 247 + } 248 + 249 + return $hasher; 250 + } 251 + 252 + 253 + /** 254 + * Get the hashser for a given stored hash. 255 + * 256 + * @return PhabicatorPasswordHasher Corresponding hasher. 257 + * @task hashing 258 + */ 259 + public static function getHasherForHash(PhutilOpaqueEnvelope $hash) { 260 + $info = self::parseHashFromStorage($hash); 261 + $name = $info['name']; 262 + 263 + $usable = self::getAllUsableHashers(); 264 + if (isset($usable[$name])) { 265 + return $usable[$name]; 266 + } 267 + 268 + $all = self::getAllHashers(); 269 + if (isset($all[$name])) { 270 + throw new PhabricatorPasswordHasherUnavailableException( 271 + pht( 272 + 'Attempting to compare a password saved with the "%s" hash. The '. 273 + 'hasher exists, but is not currently usable. %s', 274 + $name, 275 + $all[$name]->getInstallInstructions())); 276 + } 277 + 278 + throw new PhabricatorPasswordHasherUnavailableException( 279 + pht( 280 + 'Attempting to compare a password saved with the "%s" hash. No such '. 281 + 'hasher is known to Phabricator.', 282 + $name)); 283 + } 284 + 285 + 286 + /** 287 + * Test if a password is using an weaker hash than the strongest available 288 + * hash. This can be used to prompt users to upgrade, or automatically upgrade 289 + * on login. 290 + * 291 + * @return bool True to indicate that rehashing this password will improve 292 + * the hash strength. 293 + * @task hashing 294 + */ 295 + public static function canHashBeUpgraded(PhutilOpaqueEnvelope $hash) { 296 + $current_hasher = self::getHasherForHash($hash); 297 + $best_hasher = self::getBestHasher(); 298 + 299 + return ($current_hasher->getHashName() != $best_hasher->getHashName()); 300 + } 301 + 302 + 303 + /** 304 + * Generate a new hash for a password, using the best available hasher. 305 + * 306 + * @param PhutilOpaqueEnvelope Password to hash. 307 + * @return PhutilOpaqueEnvelope Hashed password, using best available 308 + * hasher. 309 + * @task hashing 310 + */ 311 + public static function generateNewPasswordHash( 312 + PhutilOpaqueEnvelope $password) { 313 + $hasher = self::getBestHasher(); 314 + return $hasher->getPasswordHashForStorage($password); 315 + } 316 + 317 + 318 + /** 319 + * Compare a password to a stored hash. 320 + * 321 + * @param PhutilOpaqueEnvelope Password to compare. 322 + * @param PhutilOpaqueEnvelope Stored password hash. 323 + * @return bool True if the passwords match. 324 + * @task hashing 325 + */ 326 + public static function comparePassword( 327 + PhutilOpaqueEnvelope $password, 328 + PhutilOpaqueEnvelope $hash) { 329 + 330 + $hasher = self::getHasherForHash($hash); 331 + $password_hash = $hasher->getPasswordHashForStorage($password); 332 + 333 + return ($password_hash->openEnvelope() == $hash->openEnvelope()); 334 + } 335 + 336 + }
+5
src/infrastructure/util/password/PhabricatorPasswordHasherUnavailableException.php
··· 1 + <?php 2 + 3 + final class PhabricatorPasswordHasherUnavailableException extends Exception { 4 + 5 + }
+42
src/infrastructure/util/password/__tests__/PhabricatorPasswordHasherTestCase.php
··· 1 + <?php 2 + 3 + final class PhabricatorPasswordHasherTestCase extends PhabricatorTestCase { 4 + 5 + public function testHasherSyntax() { 6 + $caught = null; 7 + try { 8 + PhabricatorPasswordHasher::getHasherForHash( 9 + new PhutilOpaqueEnvelope('xxx')); 10 + } catch (Exception $ex) { 11 + $caught = $ex; 12 + } 13 + 14 + $this->assertEqual( 15 + true, 16 + ($caught instanceof Exception), 17 + pht('Exception on unparseable hash format.')); 18 + 19 + $caught = null; 20 + try { 21 + PhabricatorPasswordHasher::getHasherForHash( 22 + new PhutilOpaqueEnvelope('__test__:yyy')); 23 + } catch (Exception $ex) { 24 + $caught = $ex; 25 + } 26 + 27 + $this->assertEqual( 28 + true, 29 + ($caught instanceof PhabricatorPasswordHasherUnavailableException), 30 + pht('Fictional hasher unavailable.')); 31 + } 32 + 33 + public function testMD5Hasher() { 34 + $hasher = new PhabricatorIteratedMD5PasswordHasher(); 35 + 36 + $this->assertEqual( 37 + 'md5:4824a35493d8b5dceab36f017d68425f', 38 + $hasher->getPasswordHashForStorage( 39 + new PhutilOpaqueEnvelope('quack'))->openEnvelope()); 40 + } 41 + 42 + }