SecureString::verifyHmac()   B
last analyzed

Complexity

Conditions 5
Paths 5

Size

Total Lines 32
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 5.0488

Importance

Changes 0
Metric Value
cc 5
eloc 16
nc 5
nop 4
dl 0
loc 32
ccs 14
cts 16
cp 0.875
crap 5.0488
rs 8.439
c 0
b 0
f 0
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 1
        }
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 195
                }
71 437
                $this->pushString($str, self::EOS);
72 437
            }
73
74 View Code Duplication
            switch ($encode) {
75 437
                case 'encrypt':
76 290
                    $str = $this->encrypt($str, $method, $password, '', $throw);
77 290
                    break;
78 292
                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);
0 ignored issues
show
Bug Compatibility introduced by
The expression $this->exception($throw,...thod {$encode}.", 500); of type boolean|null adds the type boolean to the return on line 82 which is incompatible with the return type documented by Garden\SecureString::encode of type string|null.
Loading history...
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 437
        }
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 2
        }
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 65
                }
121 147
                break;
122
            }
123
124
            try {
125 438
                $supported = $this->supportedInfo($token, true);
126 438
            } catch (\Exception $ex) {
127 2
                return $this->exception($throw, $ex->getMessage(), 403);
0 ignored issues
show
Bug Compatibility introduced by
The expression $this->exception($throw, $ex->getMessage(), 403); of type boolean|null adds the type boolean to the return on line 127 which is incompatible with the return type documented by Garden\SecureString::decode of type string|null.
Loading history...
128
            }
129
130 437
            if (!isset($spec[$token])) {
131 144
                return $this->exception($throw, "You did not provide a password for $token.", 403);
0 ignored issues
show
Bug Compatibility introduced by
The expression $this->exception($throw,...d for {$token}.", 403); of type boolean|null adds the type boolean to the return on line 131 which is incompatible with the return type documented by Garden\SecureString::decode of type string|null.
Loading history...
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 180
                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);
0 ignored issues
show
Bug Compatibility introduced by
The expression $this->exception($throw,...thod {$decode}.", 500); of type boolean|null adds the type boolean to the return on line 146 which is incompatible with the return type documented by Garden\SecureString::decode of type string|null.
Loading history...
147
            }
148
149 293
            if ($wstr === null) {
150 146
                return null;
151
            }
152 147
        }
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);
0 ignored issues
show
Bug Compatibility introduced by
The expression $this->exception($throw,...t fully secure.', 403); of type boolean|null adds the type boolean to the return on line 156 which is incompatible with the return type documented by Garden\SecureString::decode of type string|null.
Loading history...
157 147
        } elseif (empty($used)) {
158 1
            return $this->exception($throw, 'The string is not secure.', 403);
0 ignored issues
show
Bug Compatibility introduced by
The expression $this->exception($throw,... is not secure.', 403); of type boolean|null adds the type boolean to the return on line 158 which is incompatible with the return type documented by Garden\SecureString::decode of type string|null.
Loading history...
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);
0 ignored issues
show
Bug Compatibility introduced by
The expression $this->exception($throw,...not valid json.', 403); of type boolean|null adds the type boolean to the return on line 163 which is incompatible with the return type documented by Garden\SecureString::decode of type string|null.
Loading history...
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 290
        }
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);
0 ignored issues
show
Bug Compatibility introduced by
The expression $this->exception($throw,...ing the string.', 400); of type boolean|null adds the type boolean to the return on line 219 which is incompatible with the return type documented by Garden\SecureString::encrypt of type string.
Loading history...
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 162
        } catch (\Exception $ex) {
244
            return $this->exception($throw, "Error decrypting the string.", 403);
245
        }
246
247 162
        if ($decrypted === false) {
248 64
            return $this->exception($throw, "Error decrypting the string.", 403);
249
        }
250
251 98
        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 439
            case 'aes256':
266 291
                $cipher = 'aes-'.substr($name, 3).'-cbc';
267 291
                return ['encrypt', 'decrypt', $cipher, false];
268 295
            case 'hsha1':
269 295
            case 'hsha256':
270 292
                $hash = substr($name, 1);
271 292
                return ['hmac', 'verifyHmac', $hash, true];
272
        }
273 3
        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 292
        }
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);
0 ignored issues
show
Bug Compatibility introduced by
The expression $this->exception($throw,...thod {$method}.", 400); of type boolean|null adds the type boolean to the return on line 296 which is incompatible with the return type documented by Garden\SecureString::hmac of type string.
Loading history...
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) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $signature of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
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 439
        } 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 437
        }
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