@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
3final class PhabricatorHash extends Phobject {
4
5 const INDEX_DIGEST_LENGTH = 12;
6 const ANCHOR_DIGEST_LENGTH = 12;
7
8 /**
9 * Digest a string using HMAC+SHA1.
10 *
11 * Because a SHA1 collision is now known, this method should be considered
12 * weak. Callers should prefer @{method:digestWithNamedKey}.
13 *
14 * @param string $string Input string.
15 * @param string $key (optional)
16 * @return string 32-byte hexadecimal SHA1+HMAC hash.
17 */
18 public static function weakDigest($string, $key = null) {
19 if ($key === null) {
20 $key = PhabricatorEnv::getEnvConfig('security.hmac-key');
21 }
22
23 if (!$key) {
24 throw new Exception(
25 pht(
26 "Set a '%s' in your configuration!",
27 'security.hmac-key'));
28 }
29
30 return hash_hmac('sha1', $string, $key);
31 }
32
33
34 /**
35 * Digest a string for use in, e.g., a MySQL index. This produces a short
36 * (12-byte), case-sensitive alphanumeric string with 72 bits of entropy,
37 * which is generally safe in most contexts (notably, URLs).
38 *
39 * This method emphasizes compactness, and should not be used for security
40 * related hashing (for general purpose hashing, see @{method:digest}).
41 *
42 * @param string|null $string Input string.
43 * @return string 12-byte, case-sensitive, mostly-alphanumeric hash of
44 * the string.
45 */
46 public static function digestForIndex($string) {
47 if ($string === null) {
48 $string = '';
49 }
50 $hash = sha1($string, $raw_output = true);
51
52 static $map;
53 if ($map === null) {
54 $map = '0123456789'.
55 'abcdefghij'.
56 'klmnopqrst'.
57 'uvwxyzABCD'.
58 'EFGHIJKLMN'.
59 'OPQRSTUVWX'.
60 'YZ._';
61 }
62
63 $result = '';
64 for ($ii = 0; $ii < self::INDEX_DIGEST_LENGTH; $ii++) {
65 $result .= $map[(ord($hash[$ii]) & 0x3F)];
66 }
67
68 return $result;
69 }
70
71 /**
72 * Digest a string for use in HTML page anchors. This is similar to
73 * @{method:digestForIndex} but produces purely alphanumeric output.
74 *
75 * This tries to be mostly compatible with the index digest to limit how
76 * much stuff we're breaking by switching to it. For additional discussion,
77 * see T13045.
78 *
79 * @param string $string Input string.
80 * @return string 12-byte, case-sensitive, purely-alphanumeric hash of
81 * the string.
82 */
83 public static function digestForAnchor($string) {
84 $hash = sha1($string, $raw_output = true);
85
86 static $map;
87 if ($map === null) {
88 $map = '0123456789'.
89 'abcdefghij'.
90 'klmnopqrst'.
91 'uvwxyzABCD'.
92 'EFGHIJKLMN'.
93 'OPQRSTUVWX'.
94 'YZ';
95 }
96
97 $result = '';
98 $accum = 0;
99 $map_size = strlen($map);
100 for ($ii = 0; $ii < self::ANCHOR_DIGEST_LENGTH; $ii++) {
101 $byte = ord($hash[$ii]);
102 $low_bits = ($byte & 0x3F);
103 $accum = ($accum + $byte) % $map_size;
104
105 if ($low_bits < $map_size) {
106 // If an index digest would produce any alphanumeric character, just
107 // use that character. This means that these digests are the same as
108 // digests created with "digestForIndex()" in all positions where the
109 // output character is some character other than "." or "_".
110 $result .= $map[$low_bits];
111 } else {
112 // If an index digest would produce a non-alphumeric character ("." or
113 // "_"), pick an alphanumeric character instead. We accumulate an
114 // index into the alphanumeric character list to try to preserve
115 // entropy here. We could use this strategy for all bytes instead,
116 // but then these digests would differ from digests created with
117 // "digestForIndex()" in all positions, instead of just a small number
118 // of positions.
119 $result .= $map[$accum];
120 }
121 }
122
123 return $result;
124 }
125
126
127 public static function digestToRange($string, $min, $max) {
128 if ($min > $max) {
129 throw new Exception(pht('Maximum must be larger than minimum.'));
130 }
131
132 if ($min == $max) {
133 return $min;
134 }
135
136 $hash = sha1($string, $raw_output = true);
137 // Make sure this ends up positive, even on 32-bit machines.
138 $value = head(unpack('L', $hash)) & 0x7FFFFFFF;
139
140 return $min + ($value % (1 + $max - $min));
141 }
142
143
144 /**
145 * Shorten a string to a maximum byte length in a collision-resistant way
146 * while retaining some degree of human-readability.
147 *
148 * This function converts an input string into a prefix plus a hash. For
149 * example, a very long string beginning with "crabapplepie..." might be
150 * digested to something like "crabapp-N1wM1Nz3U84k".
151 *
152 * This allows the maximum length of identifiers to be fixed while
153 * maintaining a high degree of collision resistance and a moderate degree
154 * of human readability.
155 *
156 * @param string $string The string to shorten.
157 * @param int $length Maximum length of the result.
158 * @return string String shortened in a collision-resistant way.
159 */
160 public static function digestToLength($string, $length) {
161 // We need at least two more characters than the hash length to fit in a
162 // a 1-character prefix and a separator.
163 $min_length = self::INDEX_DIGEST_LENGTH + 2;
164 if ($length < $min_length) {
165 throw new Exception(
166 pht(
167 'Length parameter in %s must be at least %s, '.
168 'but %s was provided.',
169 'digestToLength()',
170 new PhutilNumber($min_length),
171 new PhutilNumber($length)));
172 }
173
174 // We could conceivably return the string unmodified if it's shorter than
175 // the specified length. Instead, always hash it. This makes the output of
176 // the method more recognizable and consistent (no surprising new behavior
177 // once you hit a string longer than `$length`) and prevents an attacker
178 // who can control the inputs from intentionally using the hashed form
179 // of a string to cause a collision.
180
181 $hash = self::digestForIndex($string);
182
183 $prefix = substr($string, 0, ($length - ($min_length - 1)));
184
185 return $prefix.'-'.$hash;
186 }
187
188 public static function digestWithNamedKey($message, $key_name) {
189 $key_bytes = self::getNamedHMACKey($key_name);
190 return self::digestHMACSHA256($message, $key_bytes);
191 }
192
193 public static function digestHMACSHA256($message, $key) {
194 if (!is_string($message)) {
195 throw new Exception(
196 pht('HMAC-SHA256 can only digest strings.'));
197 }
198
199 if (!is_string($key)) {
200 throw new Exception(
201 pht('HMAC-SHA256 keys must be strings.'));
202 }
203
204 if (!strlen($key)) {
205 throw new Exception(
206 pht('HMAC-SHA256 requires a nonempty key.'));
207 }
208
209 $result = hash_hmac('sha256', $message, $key, $raw_output = false);
210
211 // Although "hash_hmac()" is documented as returning `false` when it fails,
212 // it can also return `null` if you pass an object as the "$message".
213 if ($result === false || $result === null) {
214 throw new Exception(
215 pht('Unable to compute HMAC-SHA256 digest of message.'));
216 }
217
218 return $result;
219 }
220
221
222/* -( HMAC Key Management )------------------------------------------------ */
223
224
225 private static function getNamedHMACKey($hmac_name) {
226 $cache = PhabricatorCaches::getImmutableCache();
227
228 $cache_key = "hmac.key({$hmac_name})";
229
230 $hmac_key = $cache->getKey($cache_key);
231 if (($hmac_key === null) || !strlen($hmac_key)) {
232 $hmac_key = self::readHMACKey($hmac_name);
233
234 if ($hmac_key === null) {
235 $hmac_key = self::newHMACKey($hmac_name);
236 self::writeHMACKey($hmac_name, $hmac_key);
237 }
238
239 $cache->setKey($cache_key, $hmac_key);
240 }
241
242 // The "hex2bin()" function doesn't exist until PHP 5.4.0 so just
243 // implement it inline.
244 $result = '';
245 for ($ii = 0; $ii < strlen($hmac_key); $ii += 2) {
246 $result .= pack('H*', substr($hmac_key, $ii, 2));
247 }
248
249 return $result;
250 }
251
252 private static function newHMACKey($hmac_name) {
253 $hmac_key = Filesystem::readRandomBytes(64);
254 return bin2hex($hmac_key);
255 }
256
257 private static function writeHMACKey($hmac_name, $hmac_key) {
258 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
259
260 id(new PhabricatorAuthHMACKey())
261 ->setKeyName($hmac_name)
262 ->setKeyValue($hmac_key)
263 ->save();
264
265 unset($unguarded);
266 }
267
268 private static function readHMACKey($hmac_name) {
269 $table = new PhabricatorAuthHMACKey();
270 $conn = $table->establishConnection('r');
271
272 $row = queryfx_one(
273 $conn,
274 'SELECT keyValue FROM %T WHERE keyName = %s',
275 $table->getTableName(),
276 $hmac_name);
277 if (!$row) {
278 return null;
279 }
280
281 return $row['keyValue'];
282 }
283
284
285}