diff --git a/.gitattributes b/.gitattributes index c682f861..a791521e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,4 +5,3 @@ /.gitignore export-ignore /.travis.yml export-ignore /phpunit.xml.dist export-ignore -/run-tests.sh export-ignore diff --git a/.github/actions/entrypoint.sh b/.github/actions/entrypoint.sh new file mode 100755 index 00000000..ce8379cb --- /dev/null +++ b/.github/actions/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh -l + +apt-get update && \ +apt-get install -y --no-install-recommends \ + git \ + zip \ + curl \ + unzip \ + wget + +curl --silent --show-error https://getcomposer.org/installer | php +php composer.phar self-update + +echo "---Installing dependencies ---" +php composer.phar update + +echo "---Running unit tests ---" +vendor/bin/phpunit diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..09539931 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,66 @@ +name: Test Suite +on: + push: + branches: + - master + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + php: [ "5.6", "7.0", "7.1", "7.2", "7.3", "7.4", "8.0" ] + name: PHP ${{matrix.php }} Unit Test + steps: + - uses: actions/checkout@v2 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + - name: Install Dependencies + uses: nick-invision/retry@v1 + with: + timeout_minutes: 10 + max_attempts: 3 + command: composer install + - name: Run Script + run: vendor/bin/phpunit + + # use dockerfiles for old versions of php (setup-php times out for those). + test_php55: + name: "PHP 5.5 Unit Test" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Run Unit Tests + uses: docker://php:5.5-cli + with: + entrypoint: ./.github/actions/entrypoint.sh + + test_php54: + name: "PHP 5.4 Unit Test" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Run Unit Tests + uses: docker://php:5.4-cli + with: + entrypoint: ./.github/actions/entrypoint.sh + + style: + runs-on: ubuntu-latest + name: PHP Style Check + steps: + - uses: actions/checkout@v2 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "7.0" + - name: Run Script + run: | + composer require friendsofphp/php-cs-fixer + vendor/bin/php-cs-fixer fix --diff --dry-run . + vendor/bin/php-cs-fixer fix --rules=native_function_invocation --allow-risky=yes --diff src diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 26f0ff0f..00000000 --- a/.travis.yml +++ /dev/null @@ -1,19 +0,0 @@ -language: php - -php: - - 5.4 - - 5.5 - - 5.6 - - 7.0 - - 7.1 - - 7.2 - -matrix: - include: - - php: 5.3 - dist: precise - -sudo: false - -before_script: composer install -script: phpunit diff --git a/README.md b/README.md index b1a7a3a2..ba139079 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Example use \Firebase\JWT\JWT; $key = "example_key"; -$token = array( +$payload = array( "iss" => "http://example.org", "aud" => "http://example.com", "iat" => 1356999524, @@ -36,7 +36,7 @@ $token = array( * https://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-40 * for a list of spec-compliant algorithms. */ -$jwt = JWT::encode($token, $key); +$jwt = JWT::encode($payload, $key); $decoded = JWT::decode($jwt, $key, array('HS256')); print_r($decoded); @@ -93,14 +93,14 @@ ehde/zUxo6UvS7UrBQIDAQAB -----END PUBLIC KEY----- EOD; -$token = array( +$payload = array( "iss" => "example.org", "aud" => "example.com", "iat" => 1356999524, "nbf" => 1357000000 ); -$jwt = JWT::encode($token, $privateKey, 'RS256'); +$jwt = JWT::encode($payload, $privateKey, 'RS256'); echo "Encode:\n" . print_r($jwt, true) . "\n"; $decoded = JWT::decode($jwt, $publicKey, array('RS256')); @@ -115,6 +115,19 @@ echo "Decode:\n" . print_r($decoded_array, true) . "\n"; ?> ``` +Using JWKs +---------- + +```php +// Set of keys. The "keys" key is required. For example, the JSON response to +// this endpoint: https://www.gstatic.com/iap/verify/public_key-jwk +$jwks = ['keys' => []]; + +// JWK::parseKeySet($jwks) returns an associative array of **kid** to private +// key. Pass this as the second parameter to JWT::decode. +JWT::decode($payload, JWK::parseKeySet($jwks), $supportedAlgorithm); +``` + Changelog --------- diff --git a/composer.json b/composer.json index b76ffd19..25d1cfa9 100644 --- a/composer.json +++ b/composer.json @@ -2,6 +2,10 @@ "name": "firebase/php-jwt", "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "php", + "jwt" + ], "authors": [ { "name": "Neuman Vong", @@ -24,6 +28,6 @@ } }, "require-dev": { - "phpunit/phpunit": " 4.8.35" + "phpunit/phpunit": ">=4.8 <=9" } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9f85f5ba..092a662c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,7 +8,6 @@ convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false" - syntaxCheck="false" bootstrap="tests/bootstrap.php" > diff --git a/run-tests.sh b/run-tests.sh deleted file mode 100755 index c4bb9348..00000000 --- a/run-tests.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -gpg --fingerprint D8406D0D82947747293778314AA394086372C20A -if [ $? -ne 0 ]; then - echo -e "\033[33mDownloading PGP Public Key...\033[0m" - gpg --recv-keys D8406D0D82947747293778314AA394086372C20A - # Sebastian Bergmann - gpg --fingerprint D8406D0D82947747293778314AA394086372C20A - if [ $? -ne 0 ]; then - echo -e "\033[31mCould not download PGP public key for verification\033[0m" - exit - fi -fi - -# Let's grab the latest release and its signature -if [ ! -f phpunit.phar ]; then - wget https://phar.phpunit.de/phpunit.phar -fi -if [ ! -f phpunit.phar.asc ]; then - wget https://phar.phpunit.de/phpunit.phar.asc -fi - -# Verify before running -gpg --verify phpunit.phar.asc phpunit.phar -if [ $? -eq 0 ]; then - echo - echo -e "\033[33mBegin Unit Testing\033[0m" - # Run the testing suite - php --version - php phpunit.phar --configuration phpunit.xml.dist -else - echo - chmod -x phpunit.phar - mv phpunit.phar /tmp/bad-phpunit.phar - mv phpunit.phar.asc /tmp/bad-phpunit.phar.asc - echo -e "\033[31mSignature did not match! PHPUnit has been moved to /tmp/bad-phpunit.phar\033[0m" - exit 1 -fi diff --git a/src/BeforeValidException.php b/src/BeforeValidException.php index a6ee2f7c..c147852b 100644 --- a/src/BeforeValidException.php +++ b/src/BeforeValidException.php @@ -1,7 +1,7 @@ + * @license http://opensource.org/licenses/BSD-3-Clause 3-clause BSD + * @link https://github.com/firebase/php-jwt + */ +class JWK +{ + /** + * Parse a set of JWK keys + * + * @param array $jwks The JSON Web Key Set as an associative array + * + * @return array An associative array that represents the set of keys + * + * @throws InvalidArgumentException Provided JWK Set is empty + * @throws UnexpectedValueException Provided JWK Set was invalid + * @throws DomainException OpenSSL failure + * + * @uses parseKey + */ + public static function parseKeySet(array $jwks) + { + $keys = array(); + + if (!isset($jwks['keys'])) { + throw new UnexpectedValueException('"keys" member must exist in the JWK Set'); + } + if (empty($jwks['keys'])) { + throw new InvalidArgumentException('JWK Set did not contain any keys'); + } + + foreach ($jwks['keys'] as $k => $v) { + $kid = isset($v['kid']) ? $v['kid'] : $k; + if ($key = self::parseKey($v)) { + $keys[$kid] = $key; + } + } + + if (0 === \count($keys)) { + throw new UnexpectedValueException('No supported algorithms found in JWK Set'); + } + + return $keys; + } + + /** + * Parse a JWK key + * + * @param array $jwk An individual JWK + * + * @return resource|array An associative array that represents the key + * + * @throws InvalidArgumentException Provided JWK is empty + * @throws UnexpectedValueException Provided JWK was invalid + * @throws DomainException OpenSSL failure + * + * @uses createPemFromModulusAndExponent + */ + private static function parseKey(array $jwk) + { + if (empty($jwk)) { + throw new InvalidArgumentException('JWK must not be empty'); + } + if (!isset($jwk['kty'])) { + throw new UnexpectedValueException('JWK must contain a "kty" parameter'); + } + + switch ($jwk['kty']) { + case 'RSA': + if (!empty($jwk['d'])) { + throw new UnexpectedValueException('RSA private keys are not supported'); + } + if (!isset($jwk['n']) || !isset($jwk['e'])) { + throw new UnexpectedValueException('RSA keys must contain values for both "n" and "e"'); + } + + $pem = self::createPemFromModulusAndExponent($jwk['n'], $jwk['e']); + $publicKey = \openssl_pkey_get_public($pem); + if (false === $publicKey) { + throw new DomainException( + 'OpenSSL error: ' . \openssl_error_string() + ); + } + return $publicKey; + default: + // Currently only RSA is supported + break; + } + } + + /** + * Create a public key represented in PEM format from RSA modulus and exponent information + * + * @param string $n The RSA modulus encoded in Base64 + * @param string $e The RSA exponent encoded in Base64 + * + * @return string The RSA public key represented in PEM format + * + * @uses encodeLength + */ + private static function createPemFromModulusAndExponent($n, $e) + { + $modulus = JWT::urlsafeB64Decode($n); + $publicExponent = JWT::urlsafeB64Decode($e); + + $components = array( + 'modulus' => \pack('Ca*a*', 2, self::encodeLength(\strlen($modulus)), $modulus), + 'publicExponent' => \pack('Ca*a*', 2, self::encodeLength(\strlen($publicExponent)), $publicExponent) + ); + + $rsaPublicKey = \pack( + 'Ca*a*a*', + 48, + self::encodeLength(\strlen($components['modulus']) + \strlen($components['publicExponent'])), + $components['modulus'], + $components['publicExponent'] + ); + + // sequence(oid(1.2.840.113549.1.1.1), null)) = rsaEncryption. + $rsaOID = \pack('H*', '300d06092a864886f70d0101010500'); // hex version of MA0GCSqGSIb3DQEBAQUA + $rsaPublicKey = \chr(0) . $rsaPublicKey; + $rsaPublicKey = \chr(3) . self::encodeLength(\strlen($rsaPublicKey)) . $rsaPublicKey; + + $rsaPublicKey = \pack( + 'Ca*a*', + 48, + self::encodeLength(\strlen($rsaOID . $rsaPublicKey)), + $rsaOID . $rsaPublicKey + ); + + $rsaPublicKey = "-----BEGIN PUBLIC KEY-----\r\n" . + \chunk_split(\base64_encode($rsaPublicKey), 64) . + "\n-----END PUBLIC KEY-----"; + + return $rsaPublicKey; + } + + /** + * DER-encode the length + * + * DER supports lengths up to (2**8)**127, however, we'll only support lengths up to (2**8)**4. See + * {@link http://itu.int/ITU-T/studygroups/com17/languages/X.690-0207.pdf#p=13 X.690 paragraph 8.1.3} for more information. + * + * @param int $length + * @return string + */ + private static function encodeLength($length) + { + if ($length <= 0x7F) { + return \chr($length); + } + + $temp = \ltrim(\pack('N', $length), \chr(0)); + + return \pack('Ca*', 0x80 | \strlen($temp), $temp); + } +} diff --git a/src/JWT.php b/src/JWT.php index 22a67e32..b167abd7 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -1,10 +1,11 @@ array('openssl', 'SHA256'), 'HS256' => array('hash_hmac', 'SHA256'), - 'HS512' => array('hash_hmac', 'SHA512'), 'HS384' => array('hash_hmac', 'SHA384'), + 'HS512' => array('hash_hmac', 'SHA512'), 'RS256' => array('openssl', 'SHA256'), 'RS384' => array('openssl', 'SHA384'), 'RS512' => array('openssl', 'SHA512'), @@ -49,14 +54,15 @@ class JWT /** * Decodes a JWT string into a PHP object. * - * @param string $jwt The JWT - * @param string|array $key The key, or map of keys. - * If the algorithm used is asymmetric, this is the public key - * @param array $allowed_algs List of supported verification algorithms - * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' + * @param string $jwt The JWT + * @param string|array|resource $key The key, or map of keys. + * If the algorithm used is asymmetric, this is the public key + * @param array $allowed_algs List of supported verification algorithms + * Supported algorithms are 'ES256', 'HS256', 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' * * @return object The JWT's payload as a PHP object * + * @throws InvalidArgumentException Provided JWT was empty * @throws UnexpectedValueException Provided JWT was invalid * @throws SignatureInvalidException Provided JWT was invalid because the signature verification failed * @throws BeforeValidException Provided JWT is trying to be used before it's eligible as defined by 'nbf' @@ -68,13 +74,13 @@ class JWT */ public static function decode($jwt, $key, array $allowed_algs = array()) { - $timestamp = is_null(static::$timestamp) ? time() : static::$timestamp; + $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp; if (empty($key)) { throw new InvalidArgumentException('Key may not be empty'); } - $tks = explode('.', $jwt); - if (count($tks) != 3) { + $tks = \explode('.', $jwt); + if (\count($tks) != 3) { throw new UnexpectedValueException('Wrong number of segments'); } list($headb64, $bodyb64, $cryptob64) = $tks; @@ -93,10 +99,15 @@ public static function decode($jwt, $key, array $allowed_algs = array()) if (empty(static::$supported_algs[$header->alg])) { throw new UnexpectedValueException('Algorithm not supported'); } - if (!in_array($header->alg, $allowed_algs)) { + if (!\in_array($header->alg, $allowed_algs)) { throw new UnexpectedValueException('Algorithm not allowed'); } - if (is_array($key) || $key instanceof \ArrayAccess) { + if ($header->alg === 'ES256') { + // OpenSSL expects an ASN.1 DER sequence for ES256 signatures + $sig = self::signatureToDER($sig); + } + + if (\is_array($key) || $key instanceof \ArrayAccess) { if (isset($header->kid)) { if (!isset($key[$header->kid])) { throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key'); @@ -112,11 +123,11 @@ public static function decode($jwt, $key, array $allowed_algs = array()) throw new SignatureInvalidException('Signature verification failed'); } - // Check if the nbf if it is defined. This is the time that the + // Check the nbf if it is defined. This is the time that the // token can actually be used. If it's not yet that time, abort. if (isset($payload->nbf) && $payload->nbf > ($timestamp + static::$leeway)) { throw new BeforeValidException( - 'Cannot handle token prior to ' . date(DateTime::ISO8601, $payload->nbf) + 'Cannot handle token prior to ' . \date(DateTime::ISO8601, $payload->nbf) ); } @@ -125,7 +136,7 @@ public static function decode($jwt, $key, array $allowed_algs = array()) // correctly used the nbf claim). if (isset($payload->iat) && $payload->iat > ($timestamp + static::$leeway)) { throw new BeforeValidException( - 'Cannot handle token prior to ' . date(DateTime::ISO8601, $payload->iat) + 'Cannot handle token prior to ' . \date(DateTime::ISO8601, $payload->iat) ); } @@ -144,7 +155,7 @@ public static function decode($jwt, $key, array $allowed_algs = array()) * @param string $key The secret key. * If the algorithm used is asymmetric, this is the private key * @param string $alg The signing algorithm. - * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' + * Supported algorithms are 'ES256', 'HS256', 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' * @param mixed $keyId * @param array $head An array with header elements to attach * @@ -159,18 +170,18 @@ public static function encode($payload, $key, $alg = 'HS256', $keyId = null, $he if ($keyId !== null) { $header['kid'] = $keyId; } - if ( isset($head) && is_array($head) ) { - $header = array_merge($head, $header); + if (isset($head) && \is_array($head)) { + $header = \array_merge($head, $header); } $segments = array(); $segments[] = static::urlsafeB64Encode(static::jsonEncode($header)); $segments[] = static::urlsafeB64Encode(static::jsonEncode($payload)); - $signing_input = implode('.', $segments); + $signing_input = \implode('.', $segments); $signature = static::sign($signing_input, $key, $alg); $segments[] = static::urlsafeB64Encode($signature); - return implode('.', $segments); + return \implode('.', $segments); } /** @@ -179,7 +190,7 @@ public static function encode($payload, $key, $alg = 'HS256', $keyId = null, $he * @param string $msg The message to sign * @param string|resource $key The secret key * @param string $alg The signing algorithm. - * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' + * Supported algorithms are 'ES256', 'HS256', 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' * * @return string An encrypted message * @@ -191,15 +202,18 @@ public static function sign($msg, $key, $alg = 'HS256') throw new DomainException('Algorithm not supported'); } list($function, $algorithm) = static::$supported_algs[$alg]; - switch($function) { + switch ($function) { case 'hash_hmac': - return hash_hmac($algorithm, $msg, $key, true); + return \hash_hmac($algorithm, $msg, $key, true); case 'openssl': $signature = ''; - $success = openssl_sign($msg, $signature, $key, $algorithm); + $success = \openssl_sign($msg, $signature, $key, $algorithm); if (!$success) { throw new DomainException("OpenSSL unable to sign data"); } else { + if ($alg === 'ES256') { + $signature = self::signatureFromDER($signature, 256); + } return $signature; } } @@ -225,9 +239,9 @@ private static function verify($msg, $signature, $key, $alg) } list($function, $algorithm) = static::$supported_algs[$alg]; - switch($function) { + switch ($function) { case 'openssl': - $success = openssl_verify($msg, $signature, $key, $algorithm); + $success = \openssl_verify($msg, $signature, $key, $algorithm); if ($success === 1) { return true; } elseif ($success === 0) { @@ -235,19 +249,19 @@ private static function verify($msg, $signature, $key, $alg) } // returns 1 on success, 0 on failure, -1 on error. throw new DomainException( - 'OpenSSL error: ' . openssl_error_string() + 'OpenSSL error: ' . \openssl_error_string() ); case 'hash_hmac': default: - $hash = hash_hmac($algorithm, $msg, $key, true); - if (function_exists('hash_equals')) { - return hash_equals($signature, $hash); + $hash = \hash_hmac($algorithm, $msg, $key, true); + if (\function_exists('hash_equals')) { + return \hash_equals($signature, $hash); } - $len = min(static::safeStrlen($signature), static::safeStrlen($hash)); + $len = \min(static::safeStrlen($signature), static::safeStrlen($hash)); $status = 0; for ($i = 0; $i < $len; $i++) { - $status |= (ord($signature[$i]) ^ ord($hash[$i])); + $status |= (\ord($signature[$i]) ^ \ord($hash[$i])); } $status |= (static::safeStrlen($signature) ^ static::safeStrlen($hash)); @@ -266,23 +280,23 @@ private static function verify($msg, $signature, $key, $alg) */ public static function jsonDecode($input) { - if (version_compare(PHP_VERSION, '5.4.0', '>=') && !(defined('JSON_C_VERSION') && PHP_INT_SIZE > 4)) { + if (\version_compare(PHP_VERSION, '5.4.0', '>=') && !(\defined('JSON_C_VERSION') && PHP_INT_SIZE > 4)) { /** In PHP >=5.4.0, json_decode() accepts an options parameter, that allows you * to specify that large ints (like Steam Transaction IDs) should be treated as * strings, rather than the PHP default behaviour of converting them to floats. */ - $obj = json_decode($input, false, 512, JSON_BIGINT_AS_STRING); + $obj = \json_decode($input, false, 512, JSON_BIGINT_AS_STRING); } else { /** Not all servers will support that, however, so for older versions we must * manually detect large ints in the JSON string and quote them (thus converting *them to strings) before decoding, hence the preg_replace() call. */ - $max_int_length = strlen((string) PHP_INT_MAX) - 1; - $json_without_bigints = preg_replace('/:\s*(-?\d{'.$max_int_length.',})/', ': "$1"', $input); - $obj = json_decode($json_without_bigints); + $max_int_length = \strlen((string) PHP_INT_MAX) - 1; + $json_without_bigints = \preg_replace('/:\s*(-?\d{'.$max_int_length.',})/', ': "$1"', $input); + $obj = \json_decode($json_without_bigints); } - if (function_exists('json_last_error') && $errno = json_last_error()) { + if ($errno = \json_last_error()) { static::handleJsonError($errno); } elseif ($obj === null && $input !== 'null') { throw new DomainException('Null result with non-null input'); @@ -301,8 +315,8 @@ public static function jsonDecode($input) */ public static function jsonEncode($input) { - $json = json_encode($input); - if (function_exists('json_last_error') && $errno = json_last_error()) { + $json = \json_encode($input); + if ($errno = \json_last_error()) { static::handleJsonError($errno); } elseif ($json === 'null' && $input !== null) { throw new DomainException('Null result with non-null input'); @@ -319,12 +333,12 @@ public static function jsonEncode($input) */ public static function urlsafeB64Decode($input) { - $remainder = strlen($input) % 4; + $remainder = \strlen($input) % 4; if ($remainder) { $padlen = 4 - $remainder; - $input .= str_repeat('=', $padlen); + $input .= \str_repeat('=', $padlen); } - return base64_decode(strtr($input, '-_', '+/')); + return \base64_decode(\strtr($input, '-_', '+/')); } /** @@ -336,7 +350,7 @@ public static function urlsafeB64Decode($input) */ public static function urlsafeB64Encode($input) { - return str_replace('=', '', strtr(base64_encode($input), '+/', '-_')); + return \str_replace('=', '', \strtr(\base64_encode($input), '+/', '-_')); } /** @@ -365,15 +379,135 @@ private static function handleJsonError($errno) /** * Get the number of bytes in cryptographic strings. * - * @param string + * @param string $str * * @return int */ private static function safeStrlen($str) { - if (function_exists('mb_strlen')) { - return mb_strlen($str, '8bit'); + if (\function_exists('mb_strlen')) { + return \mb_strlen($str, '8bit'); } - return strlen($str); + return \strlen($str); + } + + /** + * Convert an ECDSA signature to an ASN.1 DER sequence + * + * @param string $sig The ECDSA signature to convert + * @return string The encoded DER object + */ + private static function signatureToDER($sig) + { + // Separate the signature into r-value and s-value + list($r, $s) = \str_split($sig, (int) (\strlen($sig) / 2)); + + // Trim leading zeros + $r = \ltrim($r, "\x00"); + $s = \ltrim($s, "\x00"); + + // Convert r-value and s-value from unsigned big-endian integers to + // signed two's complement + if (\ord($r[0]) > 0x7f) { + $r = "\x00" . $r; + } + if (\ord($s[0]) > 0x7f) { + $s = "\x00" . $s; + } + + return self::encodeDER( + self::ASN1_SEQUENCE, + self::encodeDER(self::ASN1_INTEGER, $r) . + self::encodeDER(self::ASN1_INTEGER, $s) + ); + } + + /** + * Encodes a value into a DER object. + * + * @param int $type DER tag + * @param string $value the value to encode + * @return string the encoded object + */ + private static function encodeDER($type, $value) + { + $tag_header = 0; + if ($type === self::ASN1_SEQUENCE) { + $tag_header |= 0x20; + } + + // Type + $der = \chr($tag_header | $type); + + // Length + $der .= \chr(\strlen($value)); + + return $der . $value; + } + + /** + * Encodes signature from a DER object. + * + * @param string $der binary signature in DER format + * @param int $keySize the number of bits in the key + * @return string the signature + */ + private static function signatureFromDER($der, $keySize) + { + // OpenSSL returns the ECDSA signatures as a binary ASN.1 DER SEQUENCE + list($offset, $_) = self::readDER($der); + list($offset, $r) = self::readDER($der, $offset); + list($offset, $s) = self::readDER($der, $offset); + + // Convert r-value and s-value from signed two's compliment to unsigned + // big-endian integers + $r = \ltrim($r, "\x00"); + $s = \ltrim($s, "\x00"); + + // Pad out r and s so that they are $keySize bits long + $r = \str_pad($r, $keySize / 8, "\x00", STR_PAD_LEFT); + $s = \str_pad($s, $keySize / 8, "\x00", STR_PAD_LEFT); + + return $r . $s; + } + + /** + * Reads binary DER-encoded data and decodes into a single object + * + * @param string $der the binary data in DER format + * @param int $offset the offset of the data stream containing the object + * to decode + * @return array [$offset, $data] the new offset and the decoded object + */ + private static function readDER($der, $offset = 0) + { + $pos = $offset; + $size = \strlen($der); + $constructed = (\ord($der[$pos]) >> 5) & 0x01; + $type = \ord($der[$pos++]) & 0x1f; + + // Length + $len = \ord($der[$pos++]); + if ($len & 0x80) { + $n = $len & 0x1f; + $len = 0; + while ($n-- && $pos < $size) { + $len = ($len << 8) | \ord($der[$pos++]); + } + } + + // Value + if ($type == self::ASN1_BIT_STRING) { + $pos++; // Skip the first contents octet (padding indicator) + $data = \substr($der, $pos, $len - 1); + $pos += $len - 1; + } elseif (!$constructed) { + $data = \substr($der, $pos, $len); + $pos += $len; + } else { + $data = null; + } + + return array($pos, $data); } } diff --git a/src/SignatureInvalidException.php b/src/SignatureInvalidException.php index 27332b21..d35dee9f 100644 --- a/src/SignatureInvalidException.php +++ b/src/SignatureInvalidException.php @@ -1,7 +1,7 @@ setExpectedException( + 'UnexpectedValueException', + 'JWK must contain a "kty" parameter' + ); + + $badJwk = array('kid' => 'foo'); + $keys = JWK::parseKeySet(array('keys' => array($badJwk))); + } + + public function testInvalidAlgorithm() + { + $this->setExpectedException( + 'UnexpectedValueException', + 'No supported algorithms found in JWK Set' + ); + + $badJwk = array('kty' => 'BADALG'); + $keys = JWK::parseKeySet(array('keys' => array($badJwk))); + } + + public function testParsePrivateKey() + { + $this->setExpectedException( + 'UnexpectedValueException', + 'RSA private keys are not supported' + ); + + $jwkSet = json_decode( + file_get_contents(__DIR__ . '/rsa-jwkset.json'), + true + ); + $jwkSet['keys'][0]['d'] = 'privatekeyvalue'; + + JWK::parseKeySet($jwkSet); + } + + public function testParseKeyWithEmptyDValue() + { + $jwkSet = json_decode( + file_get_contents(__DIR__ . '/rsa-jwkset.json'), + true + ); + + // empty or null values are ok + $jwkSet['keys'][0]['d'] = null; + + $keys = JWK::parseKeySet($jwkSet); + $this->assertTrue(is_array($keys)); + } + + public function testParseJwkKeySet() + { + $jwkSet = json_decode( + file_get_contents(__DIR__ . '/rsa-jwkset.json'), + true + ); + $keys = JWK::parseKeySet($jwkSet); + $this->assertTrue(is_array($keys)); + $this->assertArrayHasKey('jwk1', $keys); + self::$keys = $keys; + } + + public function testParseJwkKey_empty() + { + $this->setExpectedException('InvalidArgumentException', 'JWK must not be empty'); + + JWK::parseKeySet(array('keys' => array(array()))); + } + + public function testParseJwkKeySet_empty() + { + $this->setExpectedException('InvalidArgumentException', 'JWK Set did not contain any keys'); + + JWK::parseKeySet(array('keys' => array())); + } + + /** + * @depends testParseJwkKeySet + */ + public function testDecodeByJwkKeySetTokenExpired() + { + $privKey1 = file_get_contents(__DIR__ . '/rsa1-private.pem'); + $payload = array('exp' => strtotime('-1 hour')); + $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); + + $this->setExpectedException('Firebase\JWT\ExpiredException'); + + JWT::decode($msg, self::$keys, array('RS256')); + } + + /** + * @depends testParseJwkKeySet + */ + public function testDecodeByJwkKeySet() + { + $privKey1 = file_get_contents(__DIR__ . '/rsa1-private.pem'); + $payload = array('sub' => 'foo', 'exp' => strtotime('+10 seconds')); + $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); + + $result = JWT::decode($msg, self::$keys, array('RS256')); + + $this->assertEquals("foo", $result->sub); + } + + /** + * @depends testParseJwkKeySet + */ + public function testDecodeByMultiJwkKeySet() + { + $privKey2 = file_get_contents(__DIR__ . '/rsa2-private.pem'); + $payload = array('sub' => 'bar', 'exp' => strtotime('+10 seconds')); + $msg = JWT::encode($payload, $privKey2, 'RS256', 'jwk2'); + + $result = JWT::decode($msg, self::$keys, array('RS256')); + + $this->assertEquals("bar", $result->sub); + } + + /* + * For compatibility with PHPUnit 4.8 and PHP < 5.6 + */ + public function setExpectedException($exceptionName, $message = '', $code = null) + { + if (method_exists($this, 'expectException')) { + $this->expectException($exceptionName); + if ($message) { + $this->expectExceptionMessage($message); + } + } else { + parent::setExpectedException($exceptionName, $message, $code); + } + } +} diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 804a3769..bc9d7a8c 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -1,12 +1,23 @@ expectException($exceptionName); + } else { + parent::setExpectedException($exceptionName, $message, $code); + } + } public function testEncodeDecode() { @@ -148,7 +159,7 @@ public function testInvalidTokenWithNbfLeeway() "nbf" => time() + 65); // not before too far in future $encoded = JWT::encode($payload, 'my_key'); $this->setExpectedException('Firebase\JWT\BeforeValidException'); - $decoded = JWT::decode($encoded, 'my_key', array('HS256')); + JWT::decode($encoded, 'my_key', array('HS256')); JWT::$leeway = 0; } @@ -172,7 +183,7 @@ public function testInvalidTokenWithIatLeeway() "iat" => time() + 65); // issued too far in future $encoded = JWT::encode($payload, 'my_key'); $this->setExpectedException('Firebase\JWT\BeforeValidException'); - $decoded = JWT::decode($encoded, 'my_key', array('HS256')); + JWT::decode($encoded, 'my_key', array('HS256')); JWT::$leeway = 0; } @@ -183,7 +194,7 @@ public function testInvalidToken() "exp" => time() + 20); // time in the future $encoded = JWT::encode($payload, 'my_key'); $this->setExpectedException('Firebase\JWT\SignatureInvalidException'); - $decoded = JWT::decode($encoded, 'my_key2', array('HS256')); + JWT::decode($encoded, 'my_key2', array('HS256')); } public function testNullKeyFails() @@ -193,7 +204,7 @@ public function testNullKeyFails() "exp" => time() + JWT::$leeway + 20); // time in the future $encoded = JWT::encode($payload, 'my_key'); $this->setExpectedException('InvalidArgumentException'); - $decoded = JWT::decode($encoded, null, array('HS256')); + JWT::decode($encoded, null, array('HS256')); } public function testEmptyKeyFails() @@ -203,7 +214,7 @@ public function testEmptyKeyFails() "exp" => time() + JWT::$leeway + 20); // time in the future $encoded = JWT::encode($payload, 'my_key'); $this->setExpectedException('InvalidArgumentException'); - $decoded = JWT::decode($encoded, '', array('HS256')); + JWT::decode($encoded, '', array('HS256')); } public function testRSEncodeDecode() @@ -274,23 +285,19 @@ public function testInvalidSignatureEncoding() JWT::decode($msg, 'secret', array('HS256')); } - public function testVerifyError() + /** + * @runInSeparateProcess + */ + public function testEncodeAndDecodeEcdsaToken() { - $this->setExpectedException('DomainException'); - $pkey = openssl_pkey_new(); - $msg = JWT::encode('abc', $pkey, 'RS256'); - self::$opensslVerifyReturnValue = -1; - JWT::decode($msg, $pkey, array('RS256')); - } -} + $privateKey = file_get_contents(__DIR__ . '/ecdsa-private.pem'); + $payload = array('foo' => 'bar'); + $encoded = JWT::encode($payload, $privateKey, 'ES256'); -/* - * Allows the testing of openssl_verify with an error return value - */ -function openssl_verify($msg, $signature, $key, $algorithm) -{ - if (null !== JWTTest::$opensslVerifyReturnValue) { - return JWTTest::$opensslVerifyReturnValue; + // Verify decoding succeeds + $publicKey = file_get_contents(__DIR__ . '/ecdsa-public.pem'); + $decoded = JWT::decode($encoded, $publicKey, array('ES256')); + + $this->assertEquals('bar', $decoded->foo); } - return \openssl_verify($msg, $signature, $key, $algorithm); } diff --git a/tests/ecdsa-private.pem b/tests/ecdsa-private.pem new file mode 100644 index 00000000..5c77adaf --- /dev/null +++ b/tests/ecdsa-private.pem @@ -0,0 +1,18 @@ +-----BEGIN EC PARAMETERS----- +MIH3AgEBMCwGByqGSM49AQECIQD/////AAAAAQAAAAAAAAAAAAAAAP////////// +/////zBbBCD/////AAAAAQAAAAAAAAAAAAAAAP///////////////AQgWsY12Ko6 +k+ez671VdpiGvGUdBrDMU7D2O848PifSYEsDFQDEnTYIhucEk2pmeOETnSa3gZ9+ +kARBBGsX0fLhLEJH+Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWT+NC4v4af5uO5+tK +fA+eFivOM1drMV7Oy7ZAaDe/UfUCIQD/////AAAAAP//////////vOb6racXnoTz +ucrC/GMlUQIBAQ== +-----END EC PARAMETERS----- +-----BEGIN EC PRIVATE KEY----- +MIIBaAIBAQQgyP9e7yS1tjpXa0l6o+80dbSxuMcqx3lUg0n2OT9AmiuggfowgfcC +AQEwLAYHKoZIzj0BAQIhAP////8AAAABAAAAAAAAAAAAAAAA//////////////// +MFsEIP////8AAAABAAAAAAAAAAAAAAAA///////////////8BCBaxjXYqjqT57Pr +vVV2mIa8ZR0GsMxTsPY7zjw+J9JgSwMVAMSdNgiG5wSTamZ44ROdJreBn36QBEEE +axfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpZP40Li/hp/m47n60p8D54W +K84zV2sxXs7LtkBoN79R9QIhAP////8AAAAA//////////+85vqtpxeehPO5ysL8 +YyVRAgEBoUQDQgAE2klp6aX6y5kAir3EWQt0QAeapTW+db/9fD65KAoDzVajtThx +PVLEf1CufcfTxMQAQPM3wkZhu0NjlWFetcMdcQ== +-----END EC PRIVATE KEY----- diff --git a/tests/ecdsa-public.pem b/tests/ecdsa-public.pem new file mode 100644 index 00000000..31fa053d --- /dev/null +++ b/tests/ecdsa-public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBSzCCAQMGByqGSM49AgEwgfcCAQEwLAYHKoZIzj0BAQIhAP////8AAAABAAAA +AAAAAAAAAAAA////////////////MFsEIP////8AAAABAAAAAAAAAAAAAAAA//// +///////////8BCBaxjXYqjqT57PrvVV2mIa8ZR0GsMxTsPY7zjw+J9JgSwMVAMSd +NgiG5wSTamZ44ROdJreBn36QBEEEaxfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5 +RdiYwpZP40Li/hp/m47n60p8D54WK84zV2sxXs7LtkBoN79R9QIhAP////8AAAAA +//////////+85vqtpxeehPO5ysL8YyVRAgEBA0IABNpJaeml+suZAIq9xFkLdEAH +mqU1vnW//Xw+uSgKA81Wo7U4cT1SxH9Qrn3H08TEAEDzN8JGYbtDY5VhXrXDHXE= +-----END PUBLIC KEY----- diff --git a/tests/rsa-jwkset.json b/tests/rsa-jwkset.json new file mode 100644 index 00000000..0059f8cc --- /dev/null +++ b/tests/rsa-jwkset.json @@ -0,0 +1,17 @@ +{ + "keys": [ + { + "kty": "RSA", + "e": "AQAB", + "kid": "jwk1", + "n": "0Ttga33B1yX4w77NbpKyNYDNSVCo8j-RlZaZ9tI-KfkV1d-tfsvI9ZPAheP11FoN52ceBaY5ltelHW-IKwCfyT0orLdsxLgowaXki9woF1Azvcg2JVxQLv9aVjjAvy3CZFIG_EeN7J3nsyCXGnu1yMEbnvkWxA88__Q6HQ2K9wqfApkQ0LNlsK0YHz_sfjHNvRKxnbAJk7D5fUhZunPZXOPHXFgA5SvLvMaNIXduMKJh4OMfuoLdJowXJAR9j31Mqz_is4FMhm_9Mq7vZZ-uF09htRvIR8tRY28oJuW1gKWyg7cQQpnjHgFyG3XLXWAeXclWqyh_LfjyHQjrYhyeFw" + + }, + { + "kty": "RSA", + "e": "AQAB", + "kid": "jwk2", + "n": "pXi2o6AnNhwL30MaK_nuDHi2fxZHVen7Xwk0bjLGlHYpq3mSvXm2HBA-zR41vQCbHkYGsDpsyDhIXLBDTbSa7ue7D1ZqYdv5YLIS33zdX9GtUHfFHc6zYgXAU9ziWeyTzVn7icAbjxqcgT2xKNuGK7Zf2ZJ053rr-dxjAE-SjX4SG0WWUhwPjxlr1etF7mEurhHweuSdZYl36g39o9BtTBVfS87io2MwdIRsnL3w8ulgXRVRWjv-vvcuhMS_y6zGbzOC55Yr23sb4h2PSll32bgyglEIsGgHqjOdyjuUzl0t6jh86DHzbu9h-u1iihX8EI8t7CBbizbPPyHQygp-rQ" + } + ] +} \ No newline at end of file diff --git a/tests/rsa1-private.pem b/tests/rsa1-private.pem new file mode 100644 index 00000000..b194b5b4 --- /dev/null +++ b/tests/rsa1-private.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA0Ttga33B1yX4w77NbpKyNYDNSVCo8j+RlZaZ9tI+KfkV1d+t +fsvI9ZPAheP11FoN52ceBaY5ltelHW+IKwCfyT0orLdsxLgowaXki9woF1Azvcg2 +JVxQLv9aVjjAvy3CZFIG/EeN7J3nsyCXGnu1yMEbnvkWxA88//Q6HQ2K9wqfApkQ +0LNlsK0YHz/sfjHNvRKxnbAJk7D5fUhZunPZXOPHXFgA5SvLvMaNIXduMKJh4OMf +uoLdJowXJAR9j31Mqz/is4FMhm/9Mq7vZZ+uF09htRvIR8tRY28oJuW1gKWyg7cQ +QpnjHgFyG3XLXWAeXclWqyh/LfjyHQjrYhyeFwIDAQABAoIBAHMqdJsWAGEVNIVB ++792HYNXnydQr32PwemNmLeD59WglgU/9jZJoxaROjI4VLKK0wZg+uRvJ1nA3tCB ++Hh7Anh5Im9XExaAq2ZTkqXtC2AxtBktH6iW1EfaI/Y7jNRuMoaXo+Ku3A62p7cw +JBvepiOXL0Xko0RNguz7mBUvxCLPhYhzn7qCbM8uXLcjsXq/YhWQwQmtMqv0sd3W +Hy+8Jb2c18sqDeZIBne4dWD6qPClPEOsrq9gPTkl0DjbT27oVc2u1p4HMNm5BJIh +u3rMSxnZHUd7Axj1FgyLIOHl63UhaiaA1aPe/fLiVIGOA1jBZrpbnjgqDy9Uxyn6 +eydbiwECgYEA9mtRydz22idyUOlBCDXk+vdGBvFAucNYaNNUAXUJ2wfPmdGgFCA7 +g5eQG8JC6J/FU+2AfIuz6LGr7SxMBYcsWGjFAzGqs/sJib+zzN1dPUSRn4uJNFit +51yQzPgBqHS6S/XBi6YAODeZDl9jiPl3FxxucqLY5NstqZFXbE0SjIECgYEA2V3r +7xnRAK1krY1+zkPof4kcBmjqOXjnl/oRxlXP65lEXmyNJwm/ulOIko9mElWRs8CG +AxSWKaab9Gk6lc8MHjVRbuW52RGLGKq1mp6ENr4d3IBOfrNsTvD3gtNEN1JFLeF1 +jIbSsrbi2txr7VZ06Irac0C/ytro0QDOUoXkvpcCgYA8O0EzmToRWsD7e/g0XJAK +s/Q+8CtE/LWYccc/z+7HxeH9lBqPsM07Pgmwb0xRdfQSrqPQTYl9ICiJAWHXnBG/ +zmQRgstZ0MulCuGU+qq2thLuL3oq/F4NhjeykhA9r8J1nK1hSAMXuqdDtxcqPOfa +E03/4UQotFY181uuEiytgQKBgHQT+gjHqptH/XnJFCymiySAXdz2bg6fCF5aht95 +t/1C7gXWxlJQnHiuX0KVHZcw5wwtBePjPIWlmaceAtE5rmj7ZC9qsqK/AZ78mtql +SEnLoTq9si1rN624dRUCKW25m4Py4MlYvm/9xovGJkSqZOhCLoJZ05JK8QWb/pKH +Oi6lAoGBAOUN6ICpMQvzMGPgIbgS0H/gvRTnpAEs59vdgrkhlCII4tzfgvBQlVae +hRcdM6GTMq5pekBPKu45eanIzwVc88P6coT4qiWYKk2jYoLBa0UV3xEAuqBMymrj +X4nLcSbZtO0tcDGMfMpWF2JGYOEJQNetPozL/ICGVFyIO8yzXm8U +-----END RSA PRIVATE KEY----- diff --git a/tests/rsa2-private.pem b/tests/rsa2-private.pem new file mode 100644 index 00000000..74380869 --- /dev/null +++ b/tests/rsa2-private.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEApXi2o6AnNhwL30MaK/nuDHi2fxZHVen7Xwk0bjLGlHYpq3mS +vXm2HBA+zR41vQCbHkYGsDpsyDhIXLBDTbSa7ue7D1ZqYdv5YLIS33zdX9GtUHfF +Hc6zYgXAU9ziWeyTzVn7icAbjxqcgT2xKNuGK7Zf2ZJ053rr+dxjAE+SjX4SG0WW +UhwPjxlr1etF7mEurhHweuSdZYl36g39o9BtTBVfS87io2MwdIRsnL3w8ulgXRVR +Wjv+vvcuhMS/y6zGbzOC55Yr23sb4h2PSll32bgyglEIsGgHqjOdyjuUzl0t6jh8 +6DHzbu9h+u1iihX8EI8t7CBbizbPPyHQygp+rQIDAQABAoIBACF25kj1LLjutx/x +7CsUoqX3C8Fr+gVQCrxPmkDnF+4Sb570OU8EfGX0ix7kiy2sH7LhqpydVD6x00Cb +jSD785F5YAVcDqu31xlNKi/0irjEKO7rKfw7P2AFlb3gIA7bn5CaMBrNtUUdtqUU +mu2OZ/YTLhNMYUQnQe4IOiVn8lWW5D4Kje/RlLRRdGn8voXaD5BnOwZNXAxjdXqM +RxyXRG74tLKyfe3W8xTL8uhlKCNHjsdtUg9IZdnKT7I3DJPobpqgC3fUuC/IbfGf +MPK1aiu067/3DdgonC2ZWqFeKLJqtUa7z0pSQaZeDa1iiUuRivfqKYEBovFre6ni +1qHkp8ECgYEA089VnKc74NRGVbIs0VtQGprNhkl47eBq6jhTlG3hfaFF4VuDiZiu +wT8enlbhlbDb/gM0CDr9tkfDs7R4exNnhSVvn2PT8b1mhonOAeE466y/4YBA0d9x +gj0wF2vjH/bsVNBe6MBrIx12R2tBKTZ7tbCzgJRszSZqkrK7sljTlaUCgYEAx/54 +G3Yd3ULqGIG/JA7w/QEYitgjwAUSJ+eLU+iqlIjo/njAJwJ/kixqaI3Jzcl+kYmp +yNIXNNaJUz8c0M/QsuqvQjLnHkF0FOZUrdyVseU2mSbI6DhAGsPJEtAOep/61vyz +uJSu0z34gQ6bNrKdqfkA7XIQRNJ1r0qQXrVLRmkCgYB2/UYaIDTaREZTBCp7XnHs +0ERfiUz/TZCijgweGXCQ1BXe2TtXBEhAVcZMq4BFSLr9wyzq5sD7Muu1O9BnS+pe ++T3w6/L4Hi/HqwjpM253r2+ILjW78Wvh/5/RuJE6tsvjhb+bv+UwL+/vhUhw76Ol +2WOt+zP4N/ms+e3J7m7G5QKBgQCmasN65nC3WyT8u4pX8O7rOOw5LN2ivRV8ixnO ++r5m1v46MjSCwXtyIO9yjPmt+csOQ+U6LEgPOa4PzWanAyaAmvS3OzBCZui3M2qn +OfR+kWM7UaDAS35cRyqcMvC5bUIHf0P1hhNryBdvHL5fZ4X2mDMDYnTTL+WptXwo +sucucQKBgAGHzi5+ZRwffhpZiYVR/lA6zvqyekAncJZwGe2UVDL0axTumX1NPdin +2mOnVuvKVvJkisyKTIQzFk6ClQEyiArO4+t7zhUbg5Crh8q6nObRo2R2NcP8o0Iq +BRIwPgaG/WlEvZ6zqlHQ0qH7WoL4HnRG5uyLOuzRIkjasYmZdfR8 +-----END RSA PRIVATE KEY-----