1<?php
2
3namespace Firebase\JWT;
4
5use ArrayAccess;
6use DomainException;
7use Exception;
8use InvalidArgumentException;
9use OpenSSLAsymmetricKey;
10use OpenSSLCertificate;
11use TypeError;
12use UnexpectedValueException;
13use DateTime;
14use stdClass;
15
16/**
17 * JSON Web Token implementation, based on this spec:
18 * https://tools.ietf.org/html/rfc7519
19 *
20 * PHP version 5
21 *
22 * @category Authentication
23 * @package  Authentication_JWT
24 * @author   Neuman Vong <neuman@twilio.com>
25 * @author   Anant Narayanan <anant@php.net>
26 * @license  http://opensource.org/licenses/BSD-3-Clause 3-clause BSD
27 * @link     https://github.com/firebase/php-jwt
28 */
29class JWT
30{
31    private const ASN1_INTEGER = 0x02;
32    private const ASN1_SEQUENCE = 0x10;
33    private const ASN1_BIT_STRING = 0x03;
34
35    /**
36     * When checking nbf, iat or expiration times,
37     * we want to provide some extra leeway time to
38     * account for clock skew.
39     *
40     * @var int
41     */
42    public static $leeway = 0;
43
44    /**
45     * Allow the current timestamp to be specified.
46     * Useful for fixing a value within unit testing.
47     * Will default to PHP time() value if null.
48     *
49     * @var ?int
50     */
51    public static $timestamp = null;
52
53    /**
54     * @var array<string, string[]>
55     */
56    public static $supported_algs = [
57        'ES384' => ['openssl', 'SHA384'],
58        'ES256' => ['openssl', 'SHA256'],
59        'HS256' => ['hash_hmac', 'SHA256'],
60        'HS384' => ['hash_hmac', 'SHA384'],
61        'HS512' => ['hash_hmac', 'SHA512'],
62        'RS256' => ['openssl', 'SHA256'],
63        'RS384' => ['openssl', 'SHA384'],
64        'RS512' => ['openssl', 'SHA512'],
65        'EdDSA' => ['sodium_crypto', 'EdDSA'],
66    ];
67
68    /**
69     * Decodes a JWT string into a PHP object.
70     *
71     * @param string                 $jwt            The JWT
72     * @param Key|array<string, Key> $keyOrKeyArray  The Key or associative array of key IDs (kid) to Key objects.
73     *                                               If the algorithm used is asymmetric, this is the public key
74     *                                               Each Key object contains an algorithm and matching key.
75     *                                               Supported algorithms are 'ES384','ES256', 'HS256', 'HS384',
76     *                                               'HS512', 'RS256', 'RS384', and 'RS512'
77     *
78     * @return stdClass The JWT's payload as a PHP object
79     *
80     * @throws InvalidArgumentException     Provided key/key-array was empty
81     * @throws DomainException              Provided JWT is malformed
82     * @throws UnexpectedValueException     Provided JWT was invalid
83     * @throws SignatureInvalidException    Provided JWT was invalid because the signature verification failed
84     * @throws BeforeValidException         Provided JWT is trying to be used before it's eligible as defined by 'nbf'
85     * @throws BeforeValidException         Provided JWT is trying to be used before it's been created as defined by 'iat'
86     * @throws ExpiredException             Provided JWT has since expired, as defined by the 'exp' claim
87     *
88     * @uses jsonDecode
89     * @uses urlsafeB64Decode
90     */
91    public static function decode(
92        string $jwt,
93        $keyOrKeyArray
94    ): stdClass {
95        // Validate JWT
96        $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp;
97
98        if (empty($keyOrKeyArray)) {
99            throw new InvalidArgumentException('Key may not be empty');
100        }
101        $tks = \explode('.', $jwt);
102        if (\count($tks) != 3) {
103            throw new UnexpectedValueException('Wrong number of segments');
104        }
105        list($headb64, $bodyb64, $cryptob64) = $tks;
106        $headerRaw = static::urlsafeB64Decode($headb64);
107        if (null === ($header = static::jsonDecode($headerRaw))) {
108            throw new UnexpectedValueException('Invalid header encoding');
109        }
110        $payloadRaw = static::urlsafeB64Decode($bodyb64);
111        if (null === ($payload = static::jsonDecode($payloadRaw))) {
112            throw new UnexpectedValueException('Invalid claims encoding');
113        }
114        if (is_array($payload)) {
115            // prevent PHP Fatal Error in edge-cases when payload is empty array
116            $payload = (object) $payload;
117        }
118        if (!$payload instanceof stdClass) {
119            throw new UnexpectedValueException('Payload must be a JSON object');
120        }
121        $sig = static::urlsafeB64Decode($cryptob64);
122        if (empty($header->alg)) {
123            throw new UnexpectedValueException('Empty algorithm');
124        }
125        if (empty(static::$supported_algs[$header->alg])) {
126            throw new UnexpectedValueException('Algorithm not supported');
127        }
128
129        $key = self::getKey($keyOrKeyArray, property_exists($header, 'kid') ? $header->kid : null);
130
131        // Check the algorithm
132        if (!self::constantTimeEquals($key->getAlgorithm(), $header->alg)) {
133            // See issue #351
134            throw new UnexpectedValueException('Incorrect key for this algorithm');
135        }
136        if ($header->alg === 'ES256' || $header->alg === 'ES384') {
137            // OpenSSL expects an ASN.1 DER sequence for ES256/ES384 signatures
138            $sig = self::signatureToDER($sig);
139        }
140        if (!self::verify("$headb64.$bodyb64", $sig, $key->getKeyMaterial(), $header->alg)) {
141            throw new SignatureInvalidException('Signature verification failed');
142        }
143
144        // Check the nbf if it is defined. This is the time that the
145        // token can actually be used. If it's not yet that time, abort.
146        if (isset($payload->nbf) && $payload->nbf > ($timestamp + static::$leeway)) {
147            throw new BeforeValidException(
148                'Cannot handle token prior to ' . \date(DateTime::ISO8601, $payload->nbf)
149            );
150        }
151
152        // Check that this token has been created before 'now'. This prevents
153        // using tokens that have been created for later use (and haven't
154        // correctly used the nbf claim).
155        if (isset($payload->iat) && $payload->iat > ($timestamp + static::$leeway)) {
156            throw new BeforeValidException(
157                'Cannot handle token prior to ' . \date(DateTime::ISO8601, $payload->iat)
158            );
159        }
160
161        // Check if this token has expired.
162        if (isset($payload->exp) && ($timestamp - static::$leeway) >= $payload->exp) {
163            throw new ExpiredException('Expired token');
164        }
165
166        return $payload;
167    }
168
169    /**
170     * Converts and signs a PHP object or array into a JWT string.
171     *
172     * @param array<mixed>          $payload PHP array
173     * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key.
174     * @param string                $alg     Supported algorithms are 'ES384','ES256', 'HS256', 'HS384',
175     *                                       'HS512', 'RS256', 'RS384', and 'RS512'
176     * @param string                $keyId
177     * @param array<string, string> $head    An array with header elements to attach
178     *
179     * @return string A signed JWT
180     *
181     * @uses jsonEncode
182     * @uses urlsafeB64Encode
183     */
184    public static function encode(
185        array $payload,
186        $key,
187        string $alg,
188        string $keyId = null,
189        array $head = null
190    ): string {
191        $header = ['typ' => 'JWT', 'alg' => $alg];
192        if ($keyId !== null) {
193            $header['kid'] = $keyId;
194        }
195        if (isset($head) && \is_array($head)) {
196            $header = \array_merge($head, $header);
197        }
198        $segments = [];
199        $segments[] = static::urlsafeB64Encode((string) static::jsonEncode($header));
200        $segments[] = static::urlsafeB64Encode((string) static::jsonEncode($payload));
201        $signing_input = \implode('.', $segments);
202
203        $signature = static::sign($signing_input, $key, $alg);
204        $segments[] = static::urlsafeB64Encode($signature);
205
206        return \implode('.', $segments);
207    }
208
209    /**
210     * Sign a string with a given key and algorithm.
211     *
212     * @param string $msg  The message to sign
213     * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate  $key  The secret key.
214     * @param string $alg  Supported algorithms are 'ES384','ES256', 'HS256', 'HS384',
215     *                    'HS512', 'RS256', 'RS384', and 'RS512'
216     *
217     * @return string An encrypted message
218     *
219     * @throws DomainException Unsupported algorithm or bad key was specified
220     */
221    public static function sign(
222        string $msg,
223        $key,
224        string $alg
225    ): string {
226        if (empty(static::$supported_algs[$alg])) {
227            throw new DomainException('Algorithm not supported');
228        }
229        list($function, $algorithm) = static::$supported_algs[$alg];
230        switch ($function) {
231            case 'hash_hmac':
232                if (!is_string($key)) {
233                    throw new InvalidArgumentException('key must be a string when using hmac');
234                }
235                return \hash_hmac($algorithm, $msg, $key, true);
236            case 'openssl':
237                $signature = '';
238                $success = \openssl_sign($msg, $signature, $key, $algorithm); // @phpstan-ignore-line
239                if (!$success) {
240                    throw new DomainException("OpenSSL unable to sign data");
241                }
242                if ($alg === 'ES256') {
243                    $signature = self::signatureFromDER($signature, 256);
244                } elseif ($alg === 'ES384') {
245                    $signature = self::signatureFromDER($signature, 384);
246                }
247                return $signature;
248            case 'sodium_crypto':
249                if (!function_exists('sodium_crypto_sign_detached')) {
250                    throw new DomainException('libsodium is not available');
251                }
252                if (!is_string($key)) {
253                    throw new InvalidArgumentException('key must be a string when using EdDSA');
254                }
255                try {
256                    // The last non-empty line is used as the key.
257                    $lines = array_filter(explode("\n", $key));
258                    $key = base64_decode((string) end($lines));
259                    return sodium_crypto_sign_detached($msg, $key);
260                } catch (Exception $e) {
261                    throw new DomainException($e->getMessage(), 0, $e);
262                }
263        }
264
265        throw new DomainException('Algorithm not supported');
266    }
267
268    /**
269     * Verify a signature with the message, key and method. Not all methods
270     * are symmetric, so we must have a separate verify and sign method.
271     *
272     * @param string $msg         The original message (header and body)
273     * @param string $signature   The original signature
274     * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate  $keyMaterial For HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey
275     * @param string $alg         The algorithm
276     *
277     * @return bool
278     *
279     * @throws DomainException Invalid Algorithm, bad key, or OpenSSL failure
280     */
281    private static function verify(
282        string $msg,
283        string $signature,
284        $keyMaterial,
285        string $alg
286    ): bool {
287        if (empty(static::$supported_algs[$alg])) {
288            throw new DomainException('Algorithm not supported');
289        }
290
291        list($function, $algorithm) = static::$supported_algs[$alg];
292        switch ($function) {
293            case 'openssl':
294                $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); // @phpstan-ignore-line
295                if ($success === 1) {
296                    return true;
297                } elseif ($success === 0) {
298                    return false;
299                }
300                // returns 1 on success, 0 on failure, -1 on error.
301                throw new DomainException(
302                    'OpenSSL error: ' . \openssl_error_string()
303                );
304            case 'sodium_crypto':
305              if (!function_exists('sodium_crypto_sign_verify_detached')) {
306                  throw new DomainException('libsodium is not available');
307              }
308              if (!is_string($keyMaterial)) {
309                  throw new InvalidArgumentException('key must be a string when using EdDSA');
310              }
311              try {
312                  // The last non-empty line is used as the key.
313                  $lines = array_filter(explode("\n", $keyMaterial));
314                  $key = base64_decode((string) end($lines));
315                  return sodium_crypto_sign_verify_detached($signature, $msg, $key);
316              } catch (Exception $e) {
317                  throw new DomainException($e->getMessage(), 0, $e);
318              }
319            case 'hash_hmac':
320            default:
321                if (!is_string($keyMaterial)) {
322                    throw new InvalidArgumentException('key must be a string when using hmac');
323                }
324                $hash = \hash_hmac($algorithm, $msg, $keyMaterial, true);
325                return self::constantTimeEquals($hash, $signature);
326        }
327    }
328
329    /**
330     * Decode a JSON string into a PHP object.
331     *
332     * @param string $input JSON string
333     *
334     * @return mixed The decoded JSON string
335     *
336     * @throws DomainException Provided string was invalid JSON
337     */
338    public static function jsonDecode(string $input)
339    {
340        $obj = \json_decode($input, false, 512, JSON_BIGINT_AS_STRING);
341
342        if ($errno = \json_last_error()) {
343            self::handleJsonError($errno);
344        } elseif ($obj === null && $input !== 'null') {
345            throw new DomainException('Null result with non-null input');
346        }
347        return $obj;
348    }
349
350    /**
351     * Encode a PHP array into a JSON string.
352     *
353     * @param array<mixed> $input A PHP array
354     *
355     * @return string JSON representation of the PHP array
356     *
357     * @throws DomainException Provided object could not be encoded to valid JSON
358     */
359    public static function jsonEncode(array $input): string
360    {
361        if (PHP_VERSION_ID >= 50400) {
362            $json = \json_encode($input, \JSON_UNESCAPED_SLASHES);
363        } else {
364            // PHP 5.3 only
365            $json = \json_encode($input);
366        }
367        if ($errno = \json_last_error()) {
368            self::handleJsonError($errno);
369        } elseif ($json === 'null' && $input !== null) {
370            throw new DomainException('Null result with non-null input');
371        }
372        if ($json === false) {
373            throw new DomainException('Provided object could not be encoded to valid JSON');
374        }
375        return $json;
376    }
377
378    /**
379     * Decode a string with URL-safe Base64.
380     *
381     * @param string $input A Base64 encoded string
382     *
383     * @return string A decoded string
384     *
385     * @throws InvalidArgumentException invalid base64 characters
386     */
387    public static function urlsafeB64Decode(string $input): string
388    {
389        $remainder = \strlen($input) % 4;
390        if ($remainder) {
391            $padlen = 4 - $remainder;
392            $input .= \str_repeat('=', $padlen);
393        }
394        return \base64_decode(\strtr($input, '-_', '+/'));
395    }
396
397    /**
398     * Encode a string with URL-safe Base64.
399     *
400     * @param string $input The string you want encoded
401     *
402     * @return string The base64 encode of what you passed in
403     */
404    public static function urlsafeB64Encode(string $input): string
405    {
406        return \str_replace('=', '', \strtr(\base64_encode($input), '+/', '-_'));
407    }
408
409
410    /**
411     * Determine if an algorithm has been provided for each Key
412     *
413     * @param Key|array<string, Key> $keyOrKeyArray
414     * @param string|null            $kid
415     *
416     * @throws UnexpectedValueException
417     *
418     * @return Key
419     */
420    private static function getKey(
421        $keyOrKeyArray,
422        ?string $kid
423    ): Key {
424        if ($keyOrKeyArray instanceof Key) {
425            return $keyOrKeyArray;
426        }
427
428        foreach ($keyOrKeyArray as $keyId => $key) {
429            if (!$key instanceof Key) {
430                throw new TypeError(
431                    '$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an '
432                    . 'array of Firebase\JWT\Key keys'
433                );
434            }
435        }
436        if (!isset($kid)) {
437            throw new UnexpectedValueException('"kid" empty, unable to lookup correct key');
438        }
439        if (!isset($keyOrKeyArray[$kid])) {
440            throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key');
441        }
442
443        return $keyOrKeyArray[$kid];
444    }
445
446    /**
447     * @param string $left  The string of known length to compare against
448     * @param string $right The user-supplied string
449     * @return bool
450     */
451    public static function constantTimeEquals(string $left, string $right): bool
452    {
453        if (\function_exists('hash_equals')) {
454            return \hash_equals($left, $right);
455        }
456        $len = \min(self::safeStrlen($left), self::safeStrlen($right));
457
458        $status = 0;
459        for ($i = 0; $i < $len; $i++) {
460            $status |= (\ord($left[$i]) ^ \ord($right[$i]));
461        }
462        $status |= (self::safeStrlen($left) ^ self::safeStrlen($right));
463
464        return ($status === 0);
465    }
466
467    /**
468     * Helper method to create a JSON error.
469     *
470     * @param int $errno An error number from json_last_error()
471     *
472     * @throws DomainException
473     *
474     * @return void
475     */
476    private static function handleJsonError(int $errno): void
477    {
478        $messages = [
479            JSON_ERROR_DEPTH => 'Maximum stack depth exceeded',
480            JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON',
481            JSON_ERROR_CTRL_CHAR => 'Unexpected control character found',
482            JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON',
483            JSON_ERROR_UTF8 => 'Malformed UTF-8 characters' //PHP >= 5.3.3
484        ];
485        throw new DomainException(
486            isset($messages[$errno])
487            ? $messages[$errno]
488            : 'Unknown JSON error: ' . $errno
489        );
490    }
491
492    /**
493     * Get the number of bytes in cryptographic strings.
494     *
495     * @param string $str
496     *
497     * @return int
498     */
499    private static function safeStrlen(string $str): int
500    {
501        if (\function_exists('mb_strlen')) {
502            return \mb_strlen($str, '8bit');
503        }
504        return \strlen($str);
505    }
506
507    /**
508     * Convert an ECDSA signature to an ASN.1 DER sequence
509     *
510     * @param   string $sig The ECDSA signature to convert
511     * @return  string The encoded DER object
512     */
513    private static function signatureToDER(string $sig): string
514    {
515        // Separate the signature into r-value and s-value
516        $length = max(1, (int) (\strlen($sig) / 2));
517        list($r, $s) = \str_split($sig, $length > 0 ? $length : 1);
518
519        // Trim leading zeros
520        $r = \ltrim($r, "\x00");
521        $s = \ltrim($s, "\x00");
522
523        // Convert r-value and s-value from unsigned big-endian integers to
524        // signed two's complement
525        if (\ord($r[0]) > 0x7f) {
526            $r = "\x00" . $r;
527        }
528        if (\ord($s[0]) > 0x7f) {
529            $s = "\x00" . $s;
530        }
531
532        return self::encodeDER(
533            self::ASN1_SEQUENCE,
534            self::encodeDER(self::ASN1_INTEGER, $r) .
535            self::encodeDER(self::ASN1_INTEGER, $s)
536        );
537    }
538
539    /**
540     * Encodes a value into a DER object.
541     *
542     * @param   int     $type DER tag
543     * @param   string  $value the value to encode
544     *
545     * @return  string  the encoded object
546     */
547    private static function encodeDER(int $type, string $value): string
548    {
549        $tag_header = 0;
550        if ($type === self::ASN1_SEQUENCE) {
551            $tag_header |= 0x20;
552        }
553
554        // Type
555        $der = \chr($tag_header | $type);
556
557        // Length
558        $der .= \chr(\strlen($value));
559
560        return $der . $value;
561    }
562
563    /**
564     * Encodes signature from a DER object.
565     *
566     * @param   string  $der binary signature in DER format
567     * @param   int     $keySize the number of bits in the key
568     *
569     * @return  string  the signature
570     */
571    private static function signatureFromDER(string $der, int $keySize): string
572    {
573        // OpenSSL returns the ECDSA signatures as a binary ASN.1 DER SEQUENCE
574        list($offset, $_) = self::readDER($der);
575        list($offset, $r) = self::readDER($der, $offset);
576        list($offset, $s) = self::readDER($der, $offset);
577
578        // Convert r-value and s-value from signed two's compliment to unsigned
579        // big-endian integers
580        $r = \ltrim($r, "\x00");
581        $s = \ltrim($s, "\x00");
582
583        // Pad out r and s so that they are $keySize bits long
584        $r = \str_pad($r, $keySize / 8, "\x00", STR_PAD_LEFT);
585        $s = \str_pad($s, $keySize / 8, "\x00", STR_PAD_LEFT);
586
587        return $r . $s;
588    }
589
590    /**
591     * Reads binary DER-encoded data and decodes into a single object
592     *
593     * @param string $der the binary data in DER format
594     * @param int $offset the offset of the data stream containing the object
595     * to decode
596     *
597     * @return array{int, string|null} the new offset and the decoded object
598     */
599    private static function readDER(string $der, int $offset = 0): array
600    {
601        $pos = $offset;
602        $size = \strlen($der);
603        $constructed = (\ord($der[$pos]) >> 5) & 0x01;
604        $type = \ord($der[$pos++]) & 0x1f;
605
606        // Length
607        $len = \ord($der[$pos++]);
608        if ($len & 0x80) {
609            $n = $len & 0x1f;
610            $len = 0;
611            while ($n-- && $pos < $size) {
612                $len = ($len << 8) | \ord($der[$pos++]);
613            }
614        }
615
616        // Value
617        if ($type == self::ASN1_BIT_STRING) {
618            $pos++; // Skip the first contents octet (padding indicator)
619            $data = \substr($der, $pos, $len - 1);
620            $pos += $len - 1;
621        } elseif (!$constructed) {
622            $data = \substr($der, $pos, $len);
623            $pos += $len;
624        } else {
625            $data = null;
626        }
627
628        return [$pos, $data];
629    }
630}
631