@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<?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 */
14abstract 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 usable 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 $envelope Text to be hashed.
105 * @return PhutilOpaqueEnvelope Hashed text.
106 * @task hasher
107 */
108 abstract protected function getPasswordHash(PhutilOpaqueEnvelope $envelope);
109
110
111 /**
112 * Verify that a password matches a hash.
113 *
114 * The default implementation checks for equality; if a hasher embeds salt in
115 * hashes it should override this method and perform a salt-aware comparison.
116 *
117 * @param PhutilOpaqueEnvelope $password Password to compare.
118 * @param PhutilOpaqueEnvelope $hash Bare password hash.
119 * @return bool True if the passwords match.
120 * @task hasher
121 */
122 protected function verifyPassword(
123 PhutilOpaqueEnvelope $password,
124 PhutilOpaqueEnvelope $hash) {
125
126 $actual_hash = $this->getPasswordHash($password)->openEnvelope();
127 $expect_hash = $hash->openEnvelope();
128
129 return phutil_hashes_are_identical($actual_hash, $expect_hash);
130 }
131
132
133 /**
134 * Check if an existing hash created by this algorithm is upgradeable.
135 *
136 * The default implementation returns `false`. However, hash algorithms which
137 * have (for example) an internal cost function may be able to upgrade an
138 * existing hash to a stronger one with a higher cost.
139 *
140 * @param PhutilOpaqueEnvelope $hash Bare hash.
141 * @return bool True if the hash can be upgraded without
142 * changing the algorithm (for example, to a
143 * higher cost).
144 * @task hasher
145 */
146 protected function canUpgradeInternalHash(PhutilOpaqueEnvelope $hash) {
147 return false;
148 }
149
150
151/* -( Using Hashers )------------------------------------------------------ */
152
153
154 /**
155 * Get the hash of a password for storage.
156 *
157 * @param PhutilOpaqueEnvelope $envelope Password text.
158 * @return PhutilOpaqueEnvelope Hashed text.
159 * @task hashing
160 */
161 final public function getPasswordHashForStorage(
162 PhutilOpaqueEnvelope $envelope) {
163
164 $name = $this->getHashName();
165 $hash = $this->getPasswordHash($envelope);
166
167 $actual_len = strlen($hash->openEnvelope());
168 $expect_len = $this->getHashLength();
169 if ($actual_len > $expect_len) {
170 throw new Exception(
171 pht(
172 "Password hash '%s' produced a hash of length %s, but a ".
173 "maximum length of %s was expected.",
174 $name,
175 new PhutilNumber($actual_len),
176 new PhutilNumber($expect_len)));
177 }
178
179 return new PhutilOpaqueEnvelope($name.':'.$hash->openEnvelope());
180 }
181
182
183 /**
184 * Parse a storage hash into its components, like the hash type and hash
185 * data.
186 *
187 * @return map Dictionary of information about the hash.
188 * @task hashing
189 */
190 private static function parseHashFromStorage(PhutilOpaqueEnvelope $hash) {
191 $raw_hash = $hash->openEnvelope();
192 if (strpos($raw_hash, ':') === false) {
193 throw new Exception(
194 pht(
195 'Malformed password hash, expected "name:hash".'));
196 }
197
198 list($name, $hash) = explode(':', $raw_hash);
199
200 return array(
201 'name' => $name,
202 'hash' => new PhutilOpaqueEnvelope($hash),
203 );
204 }
205
206
207 /**
208 * Get all available password hashers. This may include hashers which can not
209 * actually be used (for example, a required extension is missing).
210 *
211 * @return list<PhabricatorPasswordHasher> Hasher objects.
212 * @task hashing
213 */
214 public static function getAllHashers() {
215 $objects = id(new PhutilClassMapQuery())
216 ->setAncestorClass(self::class)
217 ->setUniqueMethod('getHashName')
218 ->execute();
219
220 foreach ($objects as $object) {
221 $name = $object->getHashName();
222
223 $potential_length = strlen($name) + $object->getHashLength() + 1;
224 $maximum_length = self::MAXIMUM_STORAGE_SIZE;
225
226 if ($potential_length > $maximum_length) {
227 throw new Exception(
228 pht(
229 'Hasher "%s" may produce hashes which are too long to fit in '.
230 'storage. %d characters are available, but its hashes may be '.
231 'up to %d characters in length.',
232 $name,
233 $maximum_length,
234 $potential_length));
235 }
236 }
237
238 return $objects;
239 }
240
241
242 /**
243 * Get all usable password hashers. This may include hashers which are
244 * not desirable or advisable.
245 *
246 * @return list<PhabricatorPasswordHasher> Hasher objects.
247 * @task hashing
248 */
249 public static function getAllUsableHashers() {
250 $hashers = self::getAllHashers();
251 foreach ($hashers as $key => $hasher) {
252 if (!$hasher->canHashPasswords()) {
253 unset($hashers[$key]);
254 }
255 }
256 return $hashers;
257 }
258
259
260 /**
261 * Get the best (strongest) available hasher.
262 *
263 * @return PhabricatorPasswordHasher Best hasher.
264 * @task hashing
265 */
266 public static function getBestHasher() {
267 $hashers = self::getAllUsableHashers();
268 $hashers = msort($hashers, 'getStrength');
269
270 $hasher = last($hashers);
271 if (!$hasher) {
272 throw new PhabricatorPasswordHasherUnavailableException(
273 pht(
274 'There are no password hashers available which are usable for '.
275 'new passwords.'));
276 }
277
278 return $hasher;
279 }
280
281
282 /**
283 * Get the hasher for a given stored hash.
284 *
285 * @return PhabricatorPasswordHasher Corresponding hasher.
286 * @task hashing
287 */
288 public static function getHasherForHash(PhutilOpaqueEnvelope $hash) {
289 $info = self::parseHashFromStorage($hash);
290 $name = $info['name'];
291
292 $usable = self::getAllUsableHashers();
293 if (isset($usable[$name])) {
294 return $usable[$name];
295 }
296
297 $all = self::getAllHashers();
298 if (isset($all[$name])) {
299 throw new PhabricatorPasswordHasherUnavailableException(
300 pht(
301 'Attempting to compare a password saved with the "%s" hash. The '.
302 'hasher exists, but is not currently usable. %s',
303 $name,
304 $all[$name]->getInstallInstructions()));
305 }
306
307 throw new PhabricatorPasswordHasherUnavailableException(
308 pht(
309 'Attempting to compare a password saved with the "%s" hash. No such '.
310 'hasher is known.',
311 $name));
312 }
313
314
315 /**
316 * Test if a password is using an weaker hash than the strongest available
317 * hash. This can be used to prompt users to upgrade, or automatically upgrade
318 * on login.
319 *
320 * @return bool True to indicate that rehashing this password will improve
321 * the hash strength.
322 * @task hashing
323 */
324 public static function canUpgradeHash(PhutilOpaqueEnvelope $hash) {
325 if (!strlen($hash->openEnvelope())) {
326 throw new Exception(
327 pht('Expected a password hash, received nothing!'));
328 }
329
330 $current_hasher = self::getHasherForHash($hash);
331 $best_hasher = self::getBestHasher();
332
333 if ($current_hasher->getHashName() != $best_hasher->getHashName()) {
334 // If the algorithm isn't the best one, we can upgrade.
335 return true;
336 }
337
338 $info = self::parseHashFromStorage($hash);
339 if ($current_hasher->canUpgradeInternalHash($info['hash'])) {
340 // If the algorithm provides an internal upgrade, we can also upgrade.
341 return true;
342 }
343
344 // Already on the best algorithm with the best settings.
345 return false;
346 }
347
348
349 /**
350 * Generate a new hash for a password, using the best available hasher.
351 *
352 * @param PhutilOpaqueEnvelope $password Password to hash.
353 * @return PhutilOpaqueEnvelope Hashed password, using best available
354 * hasher.
355 * @task hashing
356 */
357 public static function generateNewPasswordHash(
358 PhutilOpaqueEnvelope $password) {
359 $hasher = self::getBestHasher();
360 return $hasher->getPasswordHashForStorage($password);
361 }
362
363
364 /**
365 * Compare a password to a stored hash.
366 *
367 * @param PhutilOpaqueEnvelope $password Password to compare.
368 * @param PhutilOpaqueEnvelope $hash Stored password hash.
369 * @return bool True if the passwords match.
370 * @task hashing
371 */
372 public static function comparePassword(
373 PhutilOpaqueEnvelope $password,
374 PhutilOpaqueEnvelope $hash) {
375
376 $hasher = self::getHasherForHash($hash);
377 $parts = self::parseHashFromStorage($hash);
378
379 return $hasher->verifyPassword($password, $parts['hash']);
380 }
381
382
383 /**
384 * Get the human-readable algorithm name for a given hash.
385 *
386 * @param PhutilOpaqueEnvelope $hash Storage hash.
387 * @return string Human-readable algorithm name.
388 */
389 public static function getCurrentAlgorithmName(PhutilOpaqueEnvelope $hash) {
390 $raw_hash = $hash->openEnvelope();
391 if (!strlen($raw_hash)) {
392 return pht('None');
393 }
394
395 try {
396 $current_hasher = self::getHasherForHash($hash);
397 return $current_hasher->getHumanReadableName();
398 } catch (Exception $ex) {
399 $info = self::parseHashFromStorage($hash);
400 $name = $info['name'];
401 return pht('Unknown ("%s")', $name);
402 }
403 }
404
405
406 /**
407 * Get the human-readable algorithm name for the best available hash.
408 *
409 * @return string Human-readable name for best hash.
410 */
411 public static function getBestAlgorithmName() {
412 try {
413 $best_hasher = self::getBestHasher();
414 return $best_hasher->getHumanReadableName();
415 } catch (Exception $ex) {
416 return pht('Unknown');
417 }
418 }
419
420}