1
|
|
|
<?php |
2
|
|
|
/** |
3
|
|
|
* @author Todd Burry <[email protected]> |
4
|
|
|
* @copyright 2009-2014 Vanilla Forums Inc. |
5
|
|
|
* @license MIT |
6
|
|
|
*/ |
7
|
|
|
|
8
|
|
|
namespace Garden; |
9
|
|
|
|
10
|
|
|
|
11
|
|
|
/** |
12
|
|
|
* A class that provides functionality for encrypting and signing strings and then decoding those secure strings. |
13
|
|
|
*/ |
14
|
|
|
class SecureString { |
15
|
|
|
const SEP = '.'; |
16
|
|
|
const EOS = '-'; |
17
|
|
|
const STRICT = 'strict'; |
18
|
|
|
|
19
|
|
|
protected $timestampExpiry; |
20
|
|
|
|
21
|
|
|
/** |
22
|
|
|
* Initialize an instance of the {@link SecureString} class. |
23
|
|
|
*/ |
24
|
440 |
|
public function __construct() { |
25
|
440 |
|
$this->timestampExpiry = strtotime('1 day', 0); |
26
|
440 |
|
} |
27
|
|
|
|
28
|
|
|
/** |
29
|
|
|
* Get or set the timestamp expiry in seconds. |
30
|
|
|
* |
31
|
|
|
* @param int $value Set a new expiry value in seconds. |
32
|
|
|
* @return int Returns the current timestamp expiry. |
33
|
|
|
*/ |
34
|
1 |
|
public function timestampExpiry($value = 0) { |
35
|
1 |
|
if ($value) { |
36
|
1 |
|
$this->timestampExpiry = $value; |
37
|
|
|
} |
38
|
1 |
|
return $this->timestampExpiry; |
39
|
|
|
} |
40
|
|
|
|
41
|
|
|
/** |
42
|
|
|
* Encode a string using a secure specification. |
43
|
|
|
* |
44
|
|
|
* @param mixed $data The data to encode. This must be data that supports json serialization. |
45
|
|
|
* @param array $spec An array specifying how the string should be encoded. |
46
|
|
|
* The array should be in the form ['spec1' => 'password', 'spec2' => 'password']. |
47
|
|
|
* @param bool $throw Whether or not to throw an exception on error. |
48
|
|
|
* @return string|null Returns the encoded string or null if there is an error. |
49
|
|
|
*/ |
50
|
438 |
|
public function encode($data, array $spec, $throw = false) { |
51
|
438 |
|
$str = json_encode($data, JSON_UNESCAPED_SLASHES); |
52
|
|
|
|
53
|
438 |
|
$first = true; |
54
|
438 |
|
foreach ($spec as $name => $password) { |
55
|
438 |
|
if ($name === self::STRICT) { |
56
|
|
|
// Strict is just an option so continue. |
57
|
1 |
|
continue; |
58
|
|
|
} |
59
|
|
|
|
60
|
438 |
|
$supported = $this->supportedInfo($name, $throw); |
61
|
438 |
|
if ($supported === null) { |
62
|
1 |
|
return null; |
63
|
|
|
} |
64
|
|
|
|
65
|
437 |
|
list($encode,, $method, $encodeFirst) = $supported; |
66
|
|
|
|
67
|
437 |
|
if ($first) { |
68
|
437 |
|
if ($encodeFirst) { |
69
|
195 |
|
$str = static::base64urlEncode($str); |
70
|
|
|
} |
71
|
437 |
|
$this->pushString($str, self::EOS); |
72
|
|
|
} |
73
|
|
|
|
74
|
|
View Code Duplication |
switch ($encode) { |
|
|
|
|
75
|
437 |
|
case 'encrypt': |
76
|
290 |
|
$str = $this->encrypt($str, $method, $password, '', $throw); |
77
|
290 |
|
break; |
78
|
|
|
case 'hmac': |
79
|
292 |
|
$str = $this->hmac($str, $method, $password, 0, $throw); |
80
|
292 |
|
break; |
81
|
|
|
default: |
82
|
|
|
return $this->exception($throw, "Invalid method $encode.", 500); |
|
|
|
|
83
|
|
|
} |
84
|
|
|
// Return on error. |
85
|
437 |
|
if (!$str) { |
86
|
|
|
return null; |
87
|
|
|
} |
88
|
|
|
|
89
|
437 |
|
$this->pushString($str, $name); |
90
|
|
|
|
91
|
437 |
|
$first = false; |
92
|
|
|
} |
93
|
|
|
|
94
|
437 |
|
return $str; |
95
|
|
|
} |
96
|
|
|
|
97
|
|
|
/** |
98
|
|
|
* Decode a string that was encoded using {@link SecureString::encode()}. |
99
|
|
|
* |
100
|
|
|
* @param string $str The encoded string. |
101
|
|
|
* @param array $spec An array specifying how the string should be encoded. |
102
|
|
|
* @param bool $throw Whether or not to throw an exception on error. |
103
|
|
|
* @return string|null Returns the decoded string or null of there was a problem. |
104
|
|
|
*/ |
105
|
439 |
|
public function decode($str, array $spec, $throw = false) { |
106
|
439 |
|
$decodeFirst = false; |
107
|
439 |
|
$wstr = $str; // wstr means working string |
108
|
439 |
|
$used = []; |
109
|
|
|
|
110
|
439 |
|
$strict = true; |
111
|
439 |
|
if (array_key_exists(self::STRICT, $spec)) { |
112
|
2 |
|
$strict = (bool)$spec[self::STRICT]; |
113
|
2 |
|
unset($spec[self::STRICT]); |
114
|
|
|
} |
115
|
|
|
|
116
|
439 |
|
while ($token = $this->popString($wstr)) { |
117
|
439 |
|
if ($token === self::EOS) { |
118
|
147 |
|
if ($decodeFirst) { |
119
|
65 |
|
$wstr = static::base64urlDecode($wstr); |
120
|
|
|
} |
121
|
147 |
|
break; |
122
|
|
|
} |
123
|
|
|
|
124
|
|
|
try { |
125
|
438 |
|
$supported = $this->supportedInfo($token, true); |
126
|
3 |
|
} catch (\Exception $ex) { |
127
|
3 |
|
return $this->exception($throw, $ex->getMessage(), 403); |
|
|
|
|
128
|
|
|
} |
129
|
|
|
|
130
|
437 |
|
if (!isset($spec[$token])) { |
131
|
144 |
|
return $this->exception($throw, "You did not provide a password for $token.", 403); |
|
|
|
|
132
|
|
|
} |
133
|
293 |
|
$password = $spec[$token]; |
134
|
293 |
|
array_unshift($used, $token); |
135
|
|
|
|
136
|
293 |
|
list(, $decode, $method, $decodeFirst) = $supported; |
137
|
|
|
|
138
|
|
View Code Duplication |
switch ($decode) { |
|
|
|
|
139
|
293 |
|
case 'decrypt': |
140
|
162 |
|
$wstr = $this->decrypt($wstr, $method, $password, $throw); |
141
|
162 |
|
break; |
142
|
|
|
case 'verifyHmac': |
143
|
180 |
|
$wstr = $this->verifyHmac($wstr, $method, $password, $throw); |
144
|
180 |
|
break; |
145
|
|
|
default: |
146
|
|
|
return $this->exception($throw, "Invalid method $decode.", 500); |
|
|
|
|
147
|
|
|
} |
148
|
|
|
|
149
|
293 |
|
if ($wstr === null) { |
150
|
145 |
|
return null; |
151
|
|
|
} |
152
|
|
|
} |
153
|
|
|
|
154
|
|
|
// Make sure that the string was secured at all. |
155
|
147 |
|
if ($strict && array_keys($spec) != $used) { |
156
|
1 |
|
return $this->exception($throw, 'The string is not fully secure.', 403); |
|
|
|
|
157
|
|
|
} elseif (empty($used)) { |
158
|
1 |
|
return $this->exception($throw, 'The string is not secure.', 403); |
|
|
|
|
159
|
|
|
} |
160
|
|
|
|
161
|
146 |
|
$data = json_decode($wstr, true); |
162
|
146 |
|
if ($data === null) { |
163
|
|
|
return $this->exception($throw, 'The final string is not valid json.', 403); |
|
|
|
|
164
|
|
|
} |
165
|
|
|
|
166
|
146 |
|
return $data; |
167
|
|
|
} |
168
|
|
|
|
169
|
|
|
/** |
170
|
|
|
* Generate a random string suitable for use as an encryption or signature key. |
171
|
|
|
* |
172
|
|
|
* @param int $len The number of characters in the key. |
173
|
|
|
* @return string Returns a base64url encoded string representing the random key. |
174
|
|
|
*/ |
175
|
2 |
|
public static function generateRandomKey($len = 32) { |
176
|
2 |
|
$bytes = ceil($len * 3 / 4); |
177
|
|
|
|
178
|
2 |
|
return substr(self::base64urlEncode(openssl_random_pseudo_bytes($bytes)), 0, $len); |
179
|
|
|
} |
180
|
|
|
|
181
|
|
|
/** |
182
|
|
|
* Base64 Encode a string, but make it suitable to be passed in a url. |
183
|
|
|
* |
184
|
|
|
* @param string $str The string to encode. |
185
|
|
|
* @return string The encoded string. |
186
|
|
|
*/ |
187
|
437 |
|
protected static function base64urlEncode($str) { |
188
|
437 |
|
return trim(strtr(base64_encode($str), '+/', '-_'), '='); |
189
|
|
|
} |
190
|
|
|
|
191
|
|
|
/** |
192
|
|
|
* Decode a string that was encoded using base64UrlEncode(). |
193
|
|
|
* |
194
|
|
|
* @param string $str The encoded string. |
195
|
|
|
* @return string The decoded string. |
196
|
|
|
*/ |
197
|
293 |
|
protected static function base64urlDecode($str) { |
198
|
293 |
|
return base64_decode(strtr($str, '-_', '+/')); |
199
|
|
|
} |
200
|
|
|
|
201
|
|
|
/** |
202
|
|
|
* Encrypt a string with {@link openssl_encrypt()}. |
203
|
|
|
* |
204
|
|
|
* @param string $str The string to encrypt. |
205
|
|
|
* @param string $method The encryption cipher. |
206
|
|
|
* @param string $password The encryption password. |
207
|
|
|
* @param string $iv The input vector. |
208
|
|
|
* @param bool $throw Whether or not to throw an exception on error. |
209
|
|
|
* @return string Returns the encrypted string. |
210
|
|
|
*/ |
211
|
290 |
View Code Duplication |
protected function encrypt($str, $method, $password, $iv = '', $throw = false) { |
|
|
|
|
212
|
290 |
|
if ($iv === '') { |
213
|
290 |
|
$iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length($method)); |
214
|
|
|
} |
215
|
|
|
// Encrypt the string. |
216
|
290 |
|
$encrypted = openssl_encrypt($str, $method, $password, OPENSSL_RAW_DATA, $iv); |
217
|
|
|
|
218
|
290 |
|
if ($encrypted === false) { |
219
|
|
|
return $this->exception($throw, "Error encrypting the string.", 400); |
|
|
|
|
220
|
|
|
} |
221
|
|
|
|
222
|
290 |
|
$str = static::base64urlEncode($encrypted); |
223
|
290 |
|
$this->pushString($str, static::base64urlEncode($iv)); |
224
|
|
|
|
225
|
290 |
|
return $str; |
226
|
|
|
} |
227
|
|
|
|
228
|
|
|
/** |
229
|
|
|
* Decrypt a string with {@link openssl_decrypt()}. |
230
|
|
|
* |
231
|
|
|
* @param string $str The base64 url encoded encrypted string. |
232
|
|
|
* @param string $method The encryption cipher. |
233
|
|
|
* @param string $password The password to decyrpt the string. |
234
|
|
|
* @param bool $throw Whether or not to decode the string on exception. |
235
|
|
|
* @return string|null Returns the decrypted string or null on error. |
236
|
|
|
*/ |
237
|
162 |
|
protected function decrypt($str, $method, $password, $throw = false) { |
238
|
162 |
|
$iv = static::base64urlDecode($this->popString($str)); |
239
|
162 |
|
$encrypted = static::base64urlDecode($this->popString($str)); |
240
|
|
|
|
241
|
|
|
try { |
242
|
162 |
|
$decrypted = openssl_decrypt($encrypted, $method, $password, true, $iv); |
243
|
|
|
} catch (\Exception $ex) { |
244
|
|
|
return $this->exception($throw, "Error decrypting the string.", 403); |
245
|
|
|
} |
246
|
|
|
|
247
|
162 |
|
if ($decrypted === false) { |
248
|
63 |
|
return $this->exception($throw, "Error decrypting the string.", 403); |
249
|
|
|
} |
250
|
|
|
|
251
|
99 |
|
return $decrypted; |
252
|
|
|
} |
253
|
|
|
|
254
|
|
|
/** |
255
|
|
|
* Get information about a supported spec name. |
256
|
|
|
* |
257
|
|
|
* @param string $name The name of the spec. |
258
|
|
|
* @param bool $throw Whether or not throw an exception on error. |
259
|
|
|
* @return array|null Returns an array in the form [encode, decode, method, encodeFirst]. |
260
|
|
|
* Returns null on error. |
261
|
|
|
*/ |
262
|
439 |
|
protected function supportedInfo($name, $throw = false) { |
263
|
|
|
switch ($name) { |
264
|
439 |
|
case 'aes128': |
265
|
|
|
case 'aes256': |
266
|
291 |
|
$cipher = 'aes-'.substr($name, 3).'-cbc'; |
267
|
291 |
|
return ['encrypt', 'decrypt', $cipher, false]; |
268
|
|
|
case 'hsha1': |
269
|
4 |
|
case 'hsha256': |
270
|
292 |
|
$hash = substr($name, 1); |
271
|
292 |
|
return ['hmac', 'verifyHmac', $hash, true]; |
272
|
|
|
} |
273
|
4 |
|
return $this->exception($throw, "Spec $name not supported.", 400); |
274
|
|
|
} |
275
|
|
|
|
276
|
|
|
/** |
277
|
|
|
* Sign a string with hmac and a hash method. |
278
|
|
|
* |
279
|
|
|
* @param string $str The string to sign. |
280
|
|
|
* @param string $method The hash method used to sign the string. |
281
|
|
|
* @param string $password The password used to hmac hash the string with. |
282
|
|
|
* @param int $timestamp The timestamp used to sign the string with or 0 to use the current time. |
283
|
|
|
* @param bool $throw Whether or not the throw an exception on error. |
284
|
|
|
* @return string Returns the string with signing information or null on error. |
285
|
|
|
*/ |
286
|
292 |
View Code Duplication |
protected function hmac($str, $method, $password, $timestamp = 0, $throw = false) { |
|
|
|
|
287
|
292 |
|
if ($timestamp === 0) { |
288
|
292 |
|
$timestamp = time(); |
289
|
|
|
} |
290
|
|
|
// Add the timestamp to the string. |
291
|
292 |
|
static::pushString($str, $timestamp); |
292
|
|
|
|
293
|
|
|
// Sign the string. |
294
|
292 |
|
$signature = hash_hmac($method, $str, $password, true); |
295
|
292 |
|
if ($signature === false) { |
296
|
|
|
return $this->exception($throw, "Invalid hash method $method.", 400); |
|
|
|
|
297
|
|
|
} |
298
|
|
|
|
299
|
|
|
// Add the signature to the string. |
300
|
292 |
|
static::pushString($str, static::base64urlEncode($signature)); |
301
|
|
|
|
302
|
292 |
|
return $str; |
303
|
|
|
} |
304
|
|
|
|
305
|
|
|
/** |
306
|
|
|
* Verify the signature on a secure string. |
307
|
|
|
* |
308
|
|
|
* @param string $str The string to verify. |
309
|
|
|
* @param string $method The hashing algorithm that the string was signed with. |
310
|
|
|
* @param string $password The password used to sign the string. |
311
|
|
|
* @param bool $throw Whether or not to throw an exeptio on error. |
312
|
|
|
* @return bool Returns the string without the signing information or null on error. |
313
|
|
|
*/ |
314
|
180 |
|
protected function verifyHmac($str, $method, $password, $throw = false) { |
315
|
|
|
// Grab the signature from the string. |
316
|
180 |
|
$signature = $this->popString($str); |
317
|
180 |
|
if (!$signature) { |
|
|
|
|
318
|
|
|
return $this->exception($throw, "The signature is missing.", 403); |
319
|
|
|
} |
320
|
180 |
|
$signature = static::base64urlDecode($signature); |
321
|
|
|
|
322
|
|
|
// Recalculate the signature to compare. |
323
|
180 |
|
$calcSignature = hash_hmac($method, $str, $password, true); |
324
|
|
|
|
325
|
180 |
|
if (strlen($signature) !== strlen($calcSignature)) { |
326
|
|
|
return $this->exception($throw, "The signature is invalid.", 403); |
327
|
|
|
} |
328
|
|
|
|
329
|
|
|
// Do a double hmac comparison to prevent timing attacks. |
330
|
|
|
// https://www.isecpartners.com/blog/2011/february/double-hmac-verification.aspx |
331
|
180 |
|
$dblSignature = hash_hmac($method, $signature, $password, true); |
332
|
180 |
|
$dblCalcSignature = hash_hmac($method, $calcSignature, $password, true); |
333
|
|
|
|
334
|
180 |
|
if ($dblSignature !== $dblCalcSignature) { |
335
|
81 |
|
return $this->exception($throw, "The signature is invalid.", 403); |
336
|
|
|
} |
337
|
|
|
|
338
|
|
|
// Grab the timestamp and verify it. |
339
|
99 |
|
$timestamp = $this->popString($str); |
340
|
99 |
|
if (!$this->verifyTimestamp($timestamp, $throw)) { |
341
|
1 |
|
return null; |
342
|
|
|
} |
343
|
|
|
|
344
|
98 |
|
return $str; |
345
|
|
|
} |
346
|
|
|
|
347
|
|
|
/** |
348
|
|
|
* Verify that a timestamp hasn't expired. |
349
|
|
|
* |
350
|
|
|
* @param string $timestamp The unix timestamp to verify. |
351
|
|
|
* @param bool $throw Whether or not to throw an exception on error. |
352
|
|
|
* @return bool Returns true if the timestamp is valid or false otherwise. |
353
|
|
|
*/ |
354
|
99 |
|
protected function verifyTimestamp($timestamp, $throw = false) { |
355
|
99 |
|
if (!is_numeric($timestamp)) { |
356
|
|
|
return (bool)$this->exception($throw, "Invalid timestamp.", 403); |
357
|
|
|
} |
358
|
99 |
|
$intTimestamp = (int)$timestamp; |
359
|
99 |
|
$now = time(); |
360
|
99 |
|
if ($intTimestamp + $this->timestampExpiry <= $now) { |
361
|
1 |
|
return (bool)$this->exception($throw, "The timestamp has expired.", 403); |
362
|
|
|
} |
363
|
|
|
|
364
|
98 |
|
return true; |
365
|
|
|
} |
366
|
|
|
|
367
|
|
|
/** |
368
|
|
|
* Pop a string off of the end of an encoded secure string. |
369
|
|
|
* |
370
|
|
|
* @param string &$str The main string to pop. |
371
|
|
|
* @return string|null Returns the popped string or null if {@link $str} is empty. |
372
|
|
|
*/ |
373
|
439 |
|
protected function popString(&$str) { |
374
|
439 |
|
if ($str === '') { |
375
|
|
|
return null; |
376
|
|
|
} |
377
|
|
|
|
378
|
439 |
|
$pos = strrpos($str, '.'); |
379
|
439 |
|
if ($pos !== false) { |
380
|
439 |
|
$result = substr($str, $pos + 1); |
381
|
439 |
|
$str = substr($str, 0, $pos); |
382
|
|
|
} else { |
383
|
162 |
|
$result = $str; |
384
|
162 |
|
$str = ''; |
385
|
|
|
} |
386
|
439 |
|
return $result; |
387
|
|
|
} |
388
|
|
|
|
389
|
|
|
/** |
390
|
|
|
* Pushes a string on to the end of an encoded secure string. |
391
|
|
|
* |
392
|
|
|
* @param string &$str The main string to push to. |
393
|
|
|
* @param string|array $item The string or array of strings to push on to the end of {@link $str}. |
394
|
|
|
*/ |
395
|
437 |
|
protected function pushString(&$str, $item) { |
396
|
437 |
|
if ($str) { |
397
|
437 |
|
$str .= static::SEP; |
398
|
|
|
} |
399
|
437 |
|
$str .= implode(static::SEP, (array)$item); |
400
|
437 |
|
} |
401
|
|
|
|
402
|
|
|
/** |
403
|
|
|
* Throw an exception or return false. |
404
|
|
|
* |
405
|
|
|
* @param bool $throw Whether or not to throw an exception. |
406
|
|
|
* @param string $message The exception message. |
407
|
|
|
* @param int $code The exception code. |
408
|
|
|
* @return bool Returns false if {@link $throw} is false. |
409
|
|
|
* @throws \Exception Throws an exception with {@link $message} and {@link $code}. |
410
|
|
|
*/ |
411
|
295 |
|
protected function exception($throw, $message, $code) { |
412
|
295 |
|
if ($throw) { |
413
|
295 |
|
throw new \Exception($message, $code); |
414
|
|
|
} |
415
|
294 |
|
return null; |
416
|
|
|
} |
417
|
|
|
|
418
|
|
|
/** |
419
|
|
|
* Twiddle a value in an encoded secure string to another value. |
420
|
|
|
* |
421
|
|
|
* This method is mainly for testing so that an invalid string can be created. |
422
|
|
|
* |
423
|
|
|
* @param string $string A valid cookie to twiddle. |
424
|
|
|
* @param int $index The index of the new value. |
425
|
|
|
* @param string $value The new value. This will be base64url encoded. |
426
|
|
|
* @param bool $encode Whether or not to base64 url encode the value. |
427
|
|
|
* @return string Returns the new encoded cookie. |
428
|
|
|
*/ |
429
|
2 |
|
public function twiddle($string, $index, $value, $encode = false) { |
430
|
2 |
|
$parts = explode(static::SEP, $string); |
431
|
|
|
|
432
|
2 |
|
if ($encode) { |
433
|
|
|
$value = static::base64urlEncode($value); |
434
|
|
|
} |
435
|
2 |
|
$parts[$index] = $value; |
436
|
|
|
|
437
|
2 |
|
return implode(static::SEP, $parts); |
438
|
|
|
} |
439
|
|
|
} |
440
|
|
|
|
Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.
You can also find more detailed suggestions in the “Code” section of your repository.