Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: Require Key object when decoding JWT objects #364

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/actions/entrypoint.sh
Expand Up @@ -5,6 +5,7 @@ apt-get install -y --no-install-recommends \
git \
zip \
curl \
ca-certificates \
unzip \
wget

Expand Down
18 changes: 12 additions & 6 deletions README.md
Expand Up @@ -27,6 +27,7 @@ Example
-------
```php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;

$key = "example_key";
$payload = array(
Expand All @@ -43,7 +44,7 @@ $payload = array(
* for a list of spec-compliant algorithms.
*/
$jwt = JWT::encode($payload, $key);
$decoded = JWT::decode($jwt, $key, array('HS256'));
$decoded = JWT::decode($jwt, new Key($key, 'HS256'));

print_r($decoded);

Expand All @@ -62,12 +63,13 @@ $decoded_array = (array) $decoded;
* Source: http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html#nbfDef
*/
JWT::$leeway = 60; // $leeway in seconds
$decoded = JWT::decode($jwt, $key, array('HS256'));
$decoded = JWT::decode($jwt, new Key($key, 'HS256'));
```
Example with RS256 (openssl)
----------------------------
```php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;

$privateKey = <<<EOD
-----BEGIN RSA PRIVATE KEY-----
Expand Down Expand Up @@ -106,7 +108,7 @@ $payload = array(
$jwt = JWT::encode($payload, $privateKey, 'RS256');
echo "Encode:\n" . print_r($jwt, true) . "\n";

$decoded = JWT::decode($jwt, $publicKey, array('RS256'));
$decoded = JWT::decode($jwt, new Key($publicKey, 'RS256'));

/*
NOTE: This will now be an object instead of an associative array. To get
Expand All @@ -121,6 +123,9 @@ Example with a passphrase
-------------------------

```php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;

// Your passphrase
$passphrase = '[YOUR_PASSPHRASE]';

Expand All @@ -147,14 +152,15 @@ echo "Encode:\n" . print_r($jwt, true) . "\n";
// Get public key from the private key, or pull from from a file.
$publicKey = openssl_pkey_get_details($privateKey)['key'];

$decoded = JWT::decode($jwt, $publicKey, array('RS256'));
$decoded = JWT::decode($jwt, new Key($publicKey, 'RS256'));
echo "Decode:\n" . print_r((array) $decoded, true) . "\n";
```

Example with EdDSA (libsodium and Ed25519 signature)
----------------------------
```php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;

// Public and private keys are expected to be Base64 encoded. The last
// non-empty line is used so that keys can be generated with
Expand All @@ -177,7 +183,7 @@ $payload = array(
$jwt = JWT::encode($payload, $privateKey, 'EdDSA');
echo "Encode:\n" . print_r($jwt, true) . "\n";

$decoded = JWT::decode($jwt, $publicKey, array('EdDSA'));
$decoded = JWT::decode($jwt, new Key($publicKey, 'EdDSA'));
echo "Decode:\n" . print_r((array) $decoded, true) . "\n";
````

Expand All @@ -194,7 +200,7 @@ $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);
JWT::decode($payload, JWK::parseKeySet($jwks));
```

Changelog
Expand Down
10 changes: 9 additions & 1 deletion src/JWK.php
Expand Up @@ -47,7 +47,15 @@ public static function parseKeySet(array $jwks)
foreach ($jwks['keys'] as $k => $v) {
$kid = isset($v['kid']) ? $v['kid'] : $k;
if ($key = self::parseKey($v)) {
$keys[$kid] = $key;
if (isset($v['alg'])) {
$keys[$kid] = new Key($key, $v['alg']);
} else {
// The "alg" parameter is optional in a KTY, but is required
// for parsing in this library. Add it manually to your JWK
// array if it doesn't already exist.
// @see https://datatracker.ietf.org/doc/html/rfc7517#section-4.4
throw new InvalidArgumentException('JWK key is missing "alg"');
Copy link
Collaborator Author

@bshaffer bshaffer Nov 3, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@paragonie-security if the "alg" parameter is missing from a JWK, is there a reliable way to detect it? Or is it best to just throw the exception and require the users to specify it manually?

We can use the "kty" field to find out of the key is of type RSA or EC. I imagine there is a way to detect more algorithms from there as well, but I don't want to add magic unless I know it's reliable.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's possible, but it's going to require a bit of additional complexity.

First, you have to know if you're dealing with a DER- and PEM-encoded key.

  1. If it's not base64-encoded, you aren't. (Thus, not PEM.) At this point, you might want to assume symmetric (AES or HMAC).
    • Without a breadcrumb to distinguish them, you're safe assuming HMAC. However, in other libraries (i.e. ones with JWE support), our recommendation would be to instead throw an exception. Failure to do so can reintroduce alg confusion.
  2. If you can base64-decode the string and get a valid byte string, you can then verify that the first few bytes corresponds to a valid ASN.1 object identifier. This will map uniquely to a specific key algorithm.

This table below maps some hex-encoded ASN.1 prefixes to the respective algorithms, based on this encoder and the OIDs specified in IETF RFCs.

ASN.1 Prefix Key Type
06 09 2A 86 48 86 F7 0D 01 01 01 RSA
06 07 2A 86 48 CE 3D 02 01 ECDSA
30 2E 02 01 00 30 05 06 03 2B 65 70 04 22 04 20 EdDSA (Curve25519)

So that's what you would need to do.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(This is on top of the -----BEGIN PUBLIC KEY----- and -----END PUBLIC KEY-----, of course.)

}
}
}

Expand Down
109 changes: 77 additions & 32 deletions src/JWT.php
Expand Up @@ -2,6 +2,7 @@

namespace Firebase\JWT;

use ArrayAccess;
use DomainException;
use Exception;
use InvalidArgumentException;
Expand Down Expand Up @@ -58,11 +59,13 @@ class JWT
* Decodes a JWT string into a PHP object.
*
* @param string $jwt The JWT
* @param string|array|resource $key The key, or map of keys.
* @param Key|array<Key> $keyOrKeyArray The Key or array of Key objects.
* If the algorithm used is asymmetric, this is the public key
* @param array $allowed_algs List of supported verification algorithms
* Each Key object contains an algorithm and matching key.
* Supported algorithms are 'ES384','ES256', 'HS256', 'HS384',
* 'HS512', 'RS256', 'RS384', and 'RS512'
* @param array $allowed_algs [DEPRECATED] List of supported verification algorithms. Only
* should be used for BC.
*
* @return object The JWT's payload as a PHP object
*
Expand All @@ -76,11 +79,12 @@ class JWT
* @uses jsonDecode
* @uses urlsafeB64Decode
*/
public static function decode($jwt, $key, array $allowed_algs = array())
public static function decode($jwt, $keyOrKeyArray)
{
// Validate JWT
$timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp;

if (empty($key)) {
if (empty($keyOrKeyArray)) {
throw new InvalidArgumentException('Key may not be empty');
}
$tks = \explode('.', $jwt);
Expand All @@ -103,27 +107,19 @@ 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)) {
throw new UnexpectedValueException('Algorithm not allowed');

$key = self::getKey($keyOrKeyArray, empty($header->kid) ? null : $header->kid);

// Check the algorithm
if (!self::constantTimeEquals($key->getAlgorithm(), $header->alg)) {
// See issue #351
throw new UnexpectedValueException('Incorrect key for this algorithm');
}
if ($header->alg === 'ES256' || $header->alg === 'ES384') {
// OpenSSL expects an ASN.1 DER sequence for ES256/ES384 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');
}
$key = $key[$header->kid];
} else {
throw new UnexpectedValueException('"kid" empty, unable to lookup correct key');
}
}

// Check the signature
if (!static::verify("$headb64.$bodyb64", $sig, $key, $header->alg)) {
if (!static::verify("$headb64.$bodyb64", $sig, $key->getKeyMaterial(), $header->alg)) {
throw new SignatureInvalidException('Signature verification failed');
}

Expand Down Expand Up @@ -285,18 +281,7 @@ private static function verify($msg, $signature, $key, $alg)
case 'hash_hmac':
default:
$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));

$status = 0;
for ($i = 0; $i < $len; $i++) {
$status |= (\ord($signature[$i]) ^ \ord($hash[$i]));
}
$status |= (static::safeStrlen($signature) ^ static::safeStrlen($hash));

return ($status === 0);
return self::constantTimeEquals($signature, $hash);
}
}

Expand Down Expand Up @@ -384,6 +369,66 @@ public static function urlsafeB64Encode($input)
return \str_replace('=', '', \strtr(\base64_encode($input), '+/', '-_'));
}


/**
* Determine if an algorithm has been provided for each Key
*
* @param string|array $keyOrKeyArray
*
* @return an array containing the keyMaterial and algorithm
*/
private static function getKey($keyOrKeyArray, $kid = null)
{
if ($keyOrKeyArray instanceof Key) {
return $keyOrKeyArray;
}

if (is_array($keyOrKeyArray) || $keyOrKeyArray instanceof ArrayAccess) {
foreach ($keyOrKeyArray as $keyId => $key) {
if (!$key instanceof Key) {
throw new UnexpectedValueException(
'$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an '
. 'array of Firebase\JWT\Key keys'
);
}
}
if (!isset($kid)) {
throw new UnexpectedValueException('"kid" empty, unable to lookup correct key');
}
if (!isset($keyOrKeyArray[$kid])) {
throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key');
}

return $keyOrKeyArray[$kid];
}

throw new UnexpectedValueException(
'$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an '
. 'array of Firebase\JWT\Key keys'
);
}

/**
* @param string $left
* @param string $right
* @return bool
*/
public static function constantTimeEquals($left, $right)
{
if (\function_exists('hash_equals')) {
return \hash_equals($left, $right);
}
$len = \min(static::safeStrlen($left), static::safeStrlen($right));

$status = 0;
for ($i = 0; $i < $len; $i++) {
$status |= (\ord($left[$i]) ^ \ord($right[$i]));
}
$status |= (static::safeStrlen($left) ^ static::safeStrlen($right));

return ($status === 0);
}

/**
* Helper method to create a JSON error.
*
Expand Down
58 changes: 58 additions & 0 deletions src/Key.php
@@ -0,0 +1,58 @@
<?php

namespace Firebase\JWT;

use InvalidArgumentException;
use OpenSSLAsymmetricKey;

class Key
{
/** @var string $algorithm */
private $algorithm;

/** @var string $keyMaterial */
private $keyMaterial;

/**
* @param string|resource $keyMaterial
* @param string $algorithm
*/
public function __construct($keyMaterial, $algorithm)
{
if (
!is_string($keyMaterial)
&& !is_resource($keyMaterial)
&& !$keyMaterial instanceof OpenSSLAsymmetricKey
) {
throw new InvalidArgumentException('Type error: $keyMaterial must be a string, resource, or OpenSSLAsymmetricKey');
}

if (empty($keyMaterial)) {
throw new InvalidArgumentException('Type error: $keyMaterial must not be empty');
}

if (!is_string($algorithm)|| empty($keyMaterial)) {
throw new InvalidArgumentException('Type error: $algorithm must be a string');
}
$this->keyMaterial = $keyMaterial;
$this->algorithm = $algorithm;
}

/**
* Return the algorithm valid for this key
*
* @return string
*/
public function getAlgorithm()
{
return $this->algorithm;
}

/**
* @return string|resource|OpenSSLAsymmetricKey
*/
public function getKeyMaterial()
{
return $this->keyMaterial;
}
}