Passed
Push — master ( 9f0de3...23c042 )
by Richard
06:35 queued 10s
created

JWT::urlsafeB64Decode()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 5
dl 0
loc 8
rs 10
c 0
b 0
f 0
cc 2
nc 2
nop 1
1
<?php
2
3
namespace Firebase\JWT;
4
5
use \DomainException;
6
use \InvalidArgumentException;
7
use \UnexpectedValueException;
8
use \DateTime;
9
10
/**
11
 * JSON Web Token implementation, based on this spec:
12
 * https://tools.ietf.org/html/rfc7519
13
 *
14
 * PHP version 5
15
 *
16
 * @category Authentication
17
 * @package  Authentication_JWT
18
 * @author   Neuman Vong <[email protected]>
19
 * @author   Anant Narayanan <[email protected]>
20
 * @license  http://opensource.org/licenses/BSD-3-Clause 3-clause BSD
21
 * @link     https://github.com/firebase/php-jwt
22
 */
23
class JWT
24
{
25
    const ASN1_INTEGER = 0x02;
26
    const ASN1_SEQUENCE = 0x10;
27
    const ASN1_BIT_STRING = 0x03;
28
29
    /**
30
     * When checking nbf, iat or expiration times,
31
     * we want to provide some extra leeway time to
32
     * account for clock skew.
33
     */
34
    public static $leeway = 0;
35
36
    /**
37
     * Allow the current timestamp to be specified.
38
     * Useful for fixing a value within unit testing.
39
     *
40
     * Will default to PHP time() value if null.
41
     */
42
    public static $timestamp = null;
43
44
    public static $supported_algs = array(
45
        'ES256' => array('openssl', 'SHA256'),
46
        'HS256' => array('hash_hmac', 'SHA256'),
47
        'HS384' => array('hash_hmac', 'SHA384'),
48
        'HS512' => array('hash_hmac', 'SHA512'),
49
        'RS256' => array('openssl', 'SHA256'),
50
        'RS384' => array('openssl', 'SHA384'),
51
        'RS512' => array('openssl', 'SHA512'),
52
    );
53
54
    /**
55
     * Decodes a JWT string into a PHP object.
56
     *
57
     * @param string                    $jwt            The JWT
58
     * @param string|array|resource     $key            The key, or map of keys.
59
     *                                                  If the algorithm used is asymmetric, this is the public key
60
     * @param array                     $allowed_algs   List of supported verification algorithms
61
     *                                                  Supported algorithms are 'ES256', 'HS256', 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512'
62
     *
63
     * @return object The JWT's payload as a PHP object
64
     *
65
     * @throws UnexpectedValueException     Provided JWT was invalid
66
     * @throws SignatureInvalidException    Provided JWT was invalid because the signature verification failed
67
     * @throws BeforeValidException         Provided JWT is trying to be used before it's eligible as defined by 'nbf'
68
     * @throws BeforeValidException         Provided JWT is trying to be used before it's been created as defined by 'iat'
69
     * @throws ExpiredException             Provided JWT has since expired, as defined by the 'exp' claim
70
     *
71
     * @uses jsonDecode
72
     * @uses urlsafeB64Decode
73
     */
74
    public static function decode($jwt, $key, array $allowed_algs = array())
75
    {
76
        $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp;
77
78
        if (empty($key)) {
79
            throw new InvalidArgumentException('Key may not be empty');
80
        }
81
        $tks = \explode('.', $jwt);
82
        if (\count($tks) != 3) {
83
            throw new UnexpectedValueException('Wrong number of segments');
84
        }
85
        list($headb64, $bodyb64, $cryptob64) = $tks;
86
        if (null === ($header = static::jsonDecode(static::urlsafeB64Decode($headb64)))) {
87
            throw new UnexpectedValueException('Invalid header encoding');
88
        }
89
        if (null === $payload = static::jsonDecode(static::urlsafeB64Decode($bodyb64))) {
90
            throw new UnexpectedValueException('Invalid claims encoding');
91
        }
92
        if (false === ($sig = static::urlsafeB64Decode($cryptob64))) {
93
            throw new UnexpectedValueException('Invalid signature encoding');
94
        }
95
        if (empty($header->alg)) {
96
            throw new UnexpectedValueException('Empty algorithm');
97
        }
98
        if (empty(static::$supported_algs[$header->alg])) {
99
            throw new UnexpectedValueException('Algorithm not supported');
100
        }
101
        if (!\in_array($header->alg, $allowed_algs)) {
102
            throw new UnexpectedValueException('Algorithm not allowed');
103
        }
104
        if ($header->alg === 'ES256') {
105
            // OpenSSL expects an ASN.1 DER sequence for ES256 signatures
106
            $sig = self::signatureToDER($sig);
107
        }
108
109
        if (\is_array($key) || $key instanceof \ArrayAccess) {
110
            if (isset($header->kid)) {
111
                if (!isset($key[$header->kid])) {
112
                    throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key');
113
                }
114
                $key = $key[$header->kid];
115
            } else {
116
                throw new UnexpectedValueException('"kid" empty, unable to lookup correct key');
117
            }
118
        }
119
120
        // Check the signature
121
        if (!static::verify("$headb64.$bodyb64", $sig, $key, $header->alg)) {
122
            throw new SignatureInvalidException('Signature verification failed');
123
        }
124
125
        // Check the nbf if it is defined. This is the time that the
126
        // token can actually be used. If it's not yet that time, abort.
127
        if (isset($payload->nbf) && $payload->nbf > ($timestamp + static::$leeway)) {
128
            throw new BeforeValidException(
129
                'Cannot handle token prior to ' . \date(DateTime::ISO8601, $payload->nbf)
130
            );
131
        }
132
133
        // Check that this token has been created before 'now'. This prevents
134
        // using tokens that have been created for later use (and haven't
135
        // correctly used the nbf claim).
136
        if (isset($payload->iat) && $payload->iat > ($timestamp + static::$leeway)) {
137
            throw new BeforeValidException(
138
                'Cannot handle token prior to ' . \date(DateTime::ISO8601, $payload->iat)
139
            );
140
        }
141
142
        // Check if this token has expired.
143
        if (isset($payload->exp) && ($timestamp - static::$leeway) >= $payload->exp) {
144
            throw new ExpiredException('Expired token');
145
        }
146
147
        return $payload;
148
    }
149
150
    /**
151
     * Converts and signs a PHP object or array into a JWT string.
152
     *
153
     * @param object|array  $payload    PHP object or array
154
     * @param string        $key        The secret key.
155
     *                                  If the algorithm used is asymmetric, this is the private key
156
     * @param string        $alg        The signing algorithm.
157
     *                                  Supported algorithms are 'ES256', 'HS256', 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512'
158
     * @param mixed         $keyId
159
     * @param array         $head       An array with header elements to attach
160
     *
161
     * @return string A signed JWT
162
     *
163
     * @uses jsonEncode
164
     * @uses urlsafeB64Encode
165
     */
166
    public static function encode($payload, $key, $alg = 'HS256', $keyId = null, $head = null)
167
    {
168
        $header = array('typ' => 'JWT', 'alg' => $alg);
169
        if ($keyId !== null) {
170
            $header['kid'] = $keyId;
171
        }
172
        if (isset($head) && \is_array($head)) {
173
            $header = \array_merge($head, $header);
174
        }
175
        $segments = array();
176
        $segments[] = static::urlsafeB64Encode(static::jsonEncode($header));
177
        $segments[] = static::urlsafeB64Encode(static::jsonEncode($payload));
178
        $signing_input = \implode('.', $segments);
179
180
        $signature = static::sign($signing_input, $key, $alg);
181
        $segments[] = static::urlsafeB64Encode($signature);
182
183
        return \implode('.', $segments);
184
    }
185
186
    /**
187
     * Sign a string with a given key and algorithm.
188
     *
189
     * @param string            $msg    The message to sign
190
     * @param string|resource   $key    The secret key
191
     * @param string            $alg    The signing algorithm.
192
     *                                  Supported algorithms are 'ES256', 'HS256', 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512'
193
     *
194
     * @return string An encrypted message
195
     *
196
     * @throws DomainException Unsupported algorithm was specified
197
     */
198
    public static function sign($msg, $key, $alg = 'HS256')
199
    {
200
        if (empty(static::$supported_algs[$alg])) {
201
            throw new DomainException('Algorithm not supported');
202
        }
203
        list($function, $algorithm) = static::$supported_algs[$alg];
204
        switch ($function) {
205
            case 'hash_hmac':
206
                return \hash_hmac($algorithm, $msg, $key, true);
0 ignored issues
show
Bug introduced by
It seems like $key can also be of type resource; however, parameter $key of hash_hmac() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

206
                return \hash_hmac($algorithm, $msg, /** @scrutinizer ignore-type */ $key, true);
Loading history...
207
            case 'openssl':
208
                $signature = '';
209
                $success = \openssl_sign($msg, $signature, $key, $algorithm);
210
                if (!$success) {
211
                    throw new DomainException("OpenSSL unable to sign data");
212
                } else {
213
                    if ($alg === 'ES256') {
214
                        $signature = self::signatureFromDER($signature, 256);
215
                    }
216
                    return $signature;
217
                }
218
        }
219
    }
220
221
    /**
222
     * Verify a signature with the message, key and method. Not all methods
223
     * are symmetric, so we must have a separate verify and sign method.
224
     *
225
     * @param string            $msg        The original message (header and body)
226
     * @param string            $signature  The original signature
227
     * @param string|resource   $key        For HS*, a string key works. for RS*, must be a resource of an openssl public key
228
     * @param string            $alg        The algorithm
229
     *
230
     * @return bool
231
     *
232
     * @throws DomainException Invalid Algorithm or OpenSSL failure
233
     */
234
    private static function verify($msg, $signature, $key, $alg)
235
    {
236
        if (empty(static::$supported_algs[$alg])) {
237
            throw new DomainException('Algorithm not supported');
238
        }
239
240
        list($function, $algorithm) = static::$supported_algs[$alg];
241
        switch ($function) {
242
            case 'openssl':
243
                $success = \openssl_verify($msg, $signature, $key, $algorithm);
244
                if ($success === 1) {
245
                    return true;
246
                } elseif ($success === 0) {
247
                    return false;
248
                }
249
                // returns 1 on success, 0 on failure, -1 on error.
250
                throw new DomainException(
251
                    'OpenSSL error: ' . \openssl_error_string()
252
                );
253
            case 'hash_hmac':
254
            default:
255
                $hash = \hash_hmac($algorithm, $msg, $key, true);
0 ignored issues
show
Bug introduced by
It seems like $key can also be of type resource; however, parameter $key of hash_hmac() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

255
                $hash = \hash_hmac($algorithm, $msg, /** @scrutinizer ignore-type */ $key, true);
Loading history...
256
                if (\function_exists('hash_equals')) {
257
                    return \hash_equals($signature, $hash);
258
                }
259
                $len = \min(static::safeStrlen($signature), static::safeStrlen($hash));
260
261
                $status = 0;
262
                for ($i = 0; $i < $len; $i++) {
263
                    $status |= (\ord($signature[$i]) ^ \ord($hash[$i]));
264
                }
265
                $status |= (static::safeStrlen($signature) ^ static::safeStrlen($hash));
266
267
                return ($status === 0);
268
        }
269
    }
270
271
    /**
272
     * Decode a JSON string into a PHP object.
273
     *
274
     * @param string $input JSON string
275
     *
276
     * @return object Object representation of JSON string
277
     *
278
     * @throws DomainException Provided string was invalid JSON
279
     */
280
    public static function jsonDecode($input)
281
    {
282
        if (\version_compare(PHP_VERSION, '5.4.0', '>=') && !(\defined('JSON_C_VERSION') && PHP_INT_SIZE > 4)) {
283
            /** In PHP >=5.4.0, json_decode() accepts an options parameter, that allows you
284
             * to specify that large ints (like Steam Transaction IDs) should be treated as
285
             * strings, rather than the PHP default behaviour of converting them to floats.
286
             */
287
            $obj = \json_decode($input, false, 512, JSON_BIGINT_AS_STRING);
288
        } else {
289
            /** Not all servers will support that, however, so for older versions we must
290
             * manually detect large ints in the JSON string and quote them (thus converting
291
             *them to strings) before decoding, hence the preg_replace() call.
292
             */
293
            $max_int_length = \strlen((string) PHP_INT_MAX) - 1;
294
            $json_without_bigints = \preg_replace('/:\s*(-?\d{'.$max_int_length.',})/', ': "$1"', $input);
295
            $obj = \json_decode($json_without_bigints);
296
        }
297
298
        if ($errno = \json_last_error()) {
299
            static::handleJsonError($errno);
300
        } elseif ($obj === null && $input !== 'null') {
301
            throw new DomainException('Null result with non-null input');
302
        }
303
        return $obj;
304
    }
305
306
    /**
307
     * Encode a PHP object into a JSON string.
308
     *
309
     * @param object|array $input A PHP object or array
310
     *
311
     * @return string JSON representation of the PHP object or array
312
     *
313
     * @throws DomainException Provided object could not be encoded to valid JSON
314
     */
315
    public static function jsonEncode($input)
316
    {
317
        $json = \json_encode($input);
318
        if ($errno = \json_last_error()) {
319
            static::handleJsonError($errno);
320
        } elseif ($json === 'null' && $input !== null) {
321
            throw new DomainException('Null result with non-null input');
322
        }
323
        return $json;
324
    }
325
326
    /**
327
     * Decode a string with URL-safe Base64.
328
     *
329
     * @param string $input A Base64 encoded string
330
     *
331
     * @return string A decoded string
332
     */
333
    public static function urlsafeB64Decode($input)
334
    {
335
        $remainder = \strlen($input) % 4;
336
        if ($remainder) {
337
            $padlen = 4 - $remainder;
338
            $input .= \str_repeat('=', $padlen);
339
        }
340
        return \base64_decode(\strtr($input, '-_', '+/'));
341
    }
342
343
    /**
344
     * Encode a string with URL-safe Base64.
345
     *
346
     * @param string $input The string you want encoded
347
     *
348
     * @return string The base64 encode of what you passed in
349
     */
350
    public static function urlsafeB64Encode($input)
351
    {
352
        return \str_replace('=', '', \strtr(\base64_encode($input), '+/', '-_'));
353
    }
354
355
    /**
356
     * Helper method to create a JSON error.
357
     *
358
     * @param int $errno An error number from json_last_error()
359
     *
360
     * @return void
361
     */
362
    private static function handleJsonError($errno)
363
    {
364
        $messages = array(
365
            JSON_ERROR_DEPTH => 'Maximum stack depth exceeded',
366
            JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON',
367
            JSON_ERROR_CTRL_CHAR => 'Unexpected control character found',
368
            JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON',
369
            JSON_ERROR_UTF8 => 'Malformed UTF-8 characters' //PHP >= 5.3.3
370
        );
371
        throw new DomainException(
372
            isset($messages[$errno])
373
            ? $messages[$errno]
374
            : 'Unknown JSON error: ' . $errno
375
        );
376
    }
377
378
    /**
379
     * Get the number of bytes in cryptographic strings.
380
     *
381
     * @param string $str
382
     *
383
     * @return int
384
     */
385
    private static function safeStrlen($str)
386
    {
387
        if (\function_exists('mb_strlen')) {
388
            return \mb_strlen($str, '8bit');
389
        }
390
        return \strlen($str);
391
    }
392
393
    /**
394
     * Convert an ECDSA signature to an ASN.1 DER sequence
395
     *
396
     * @param   string $sig The ECDSA signature to convert
397
     * @return  string The encoded DER object
398
     */
399
    private static function signatureToDER($sig)
400
    {
401
        // Separate the signature into r-value and s-value
402
        list($r, $s) = \str_split($sig, (int) (\strlen($sig) / 2));
403
404
        // Trim leading zeros
405
        $r = \ltrim($r, "\x00");
406
        $s = \ltrim($s, "\x00");
407
408
        // Convert r-value and s-value from unsigned big-endian integers to
409
        // signed two's complement
410
        if (\ord($r[0]) > 0x7f) {
411
            $r = "\x00" . $r;
412
        }
413
        if (\ord($s[0]) > 0x7f) {
414
            $s = "\x00" . $s;
415
        }
416
417
        return self::encodeDER(
418
            self::ASN1_SEQUENCE,
419
            self::encodeDER(self::ASN1_INTEGER, $r) .
420
            self::encodeDER(self::ASN1_INTEGER, $s)
421
        );
422
    }
423
424
    /**
425
     * Encodes a value into a DER object.
426
     *
427
     * @param   int     $type DER tag
428
     * @param   string  $value the value to encode
429
     * @return  string  the encoded object
430
     */
431
    private static function encodeDER($type, $value)
432
    {
433
        $tag_header = 0;
434
        if ($type === self::ASN1_SEQUENCE) {
435
            $tag_header |= 0x20;
436
        }
437
438
        // Type
439
        $der = \chr($tag_header | $type);
440
441
        // Length
442
        $der .= \chr(\strlen($value));
443
444
        return $der . $value;
445
    }
446
447
    /**
448
     * Encodes signature from a DER object.
449
     *
450
     * @param   string  $der binary signature in DER format
451
     * @param   int     $keySize the number of bits in the key
452
     * @return  string  the signature
453
     */
454
    private static function signatureFromDER($der, $keySize)
455
    {
456
        // OpenSSL returns the ECDSA signatures as a binary ASN.1 DER SEQUENCE
457
        list($offset, $_) = self::readDER($der);
458
        list($offset, $r) = self::readDER($der, $offset);
459
        list($offset, $s) = self::readDER($der, $offset);
460
461
        // Convert r-value and s-value from signed two's compliment to unsigned
462
        // big-endian integers
463
        $r = \ltrim($r, "\x00");
464
        $s = \ltrim($s, "\x00");
465
466
        // Pad out r and s so that they are $keySize bits long
467
        $r = \str_pad($r, $keySize / 8, "\x00", STR_PAD_LEFT);
468
        $s = \str_pad($s, $keySize / 8, "\x00", STR_PAD_LEFT);
469
470
        return $r . $s;
471
    }
472
473
    /**
474
     * Reads binary DER-encoded data and decodes into a single object
475
     *
476
     * @param string $der the binary data in DER format
477
     * @param int $offset the offset of the data stream containing the object
478
     * to decode
479
     * @return array [$offset, $data] the new offset and the decoded object
480
     */
481
    private static function readDER($der, $offset = 0)
482
    {
483
        $pos = $offset;
484
        $size = \strlen($der);
485
        $constructed = (\ord($der[$pos]) >> 5) & 0x01;
486
        $type = \ord($der[$pos++]) & 0x1f;
487
488
        // Length
489
        $len = \ord($der[$pos++]);
490
        if ($len & 0x80) {
491
            $n = $len & 0x1f;
492
            $len = 0;
493
            while ($n-- && $pos < $size) {
494
                $len = ($len << 8) | \ord($der[$pos++]);
495
            }
496
        }
497
498
        // Value
499
        if ($type == self::ASN1_BIT_STRING) {
500
            $pos++; // Skip the first contents octet (padding indicator)
501
            $data = \substr($der, $pos, $len - 1);
502
            $pos += $len - 1;
503
        } elseif (!$constructed) {
504
            $data = \substr($der, $pos, $len);
505
            $pos += $len;
506
        } else {
507
            $data = null;
508
        }
509
510
        return array($pos, $data);
511
    }
512
}
513