Skip to content

Commit bc0df64

Browse files
authored
feat: add Key object to prevent key/algorithm type confusion (#365)
1 parent 804585f commit bc0df64

File tree

4 files changed

+190
-36
lines changed

4 files changed

+190
-36
lines changed

README.md

+12-5
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Example
2727
-------
2828
```php
2929
use Firebase\JWT\JWT;
30+
use Firebase\JWT\Key;
3031

3132
$key = "example_key";
3233
$payload = array(
@@ -43,7 +44,7 @@ $payload = array(
4344
* for a list of spec-compliant algorithms.
4445
*/
4546
$jwt = JWT::encode($payload, $key);
46-
$decoded = JWT::decode($jwt, $key, array('HS256'));
47+
$decoded = JWT::decode($jwt, new Key($key, 'HS256'));
4748

4849
print_r($decoded);
4950

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

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

109-
$decoded = JWT::decode($jwt, $publicKey, array('RS256'));
111+
$decoded = JWT::decode($jwt, new Key($publicKey, 'RS256'));
110112

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

123125
```php
126+
use Firebase\JWT\JWT;
127+
use Firebase\JWT\Key;
128+
124129
// Your passphrase
125130
$passphrase = '[YOUR_PASSPHRASE]';
126131

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

150-
$decoded = JWT::decode($jwt, $publicKey, array('RS256'));
155+
$decoded = JWT::decode($jwt, new Key($publicKey, 'RS256'));
151156
echo "Decode:\n" . print_r((array) $decoded, true) . "\n";
152157
```
153158

154159
Example with EdDSA (libsodium and Ed25519 signature)
155160
----------------------------
156161
```php
157162
use Firebase\JWT\JWT;
163+
use Firebase\JWT\Key;
158164

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

180-
$decoded = JWT::decode($jwt, $publicKey, array('EdDSA'));
186+
$decoded = JWT::decode($jwt, new Key($publicKey, 'EdDSA'));
181187
echo "Decode:\n" . print_r((array) $decoded, true) . "\n";
182188
````
183189

@@ -194,6 +200,7 @@ $jwks = ['keys' => []];
194200

195201
// JWK::parseKeySet($jwks) returns an associative array of **kid** to private
196202
// key. Pass this as the second parameter to JWT::decode.
203+
// NOTE: The deprecated $supportedAlgorithm must be supplied when parsing from JWK.
197204
JWT::decode($payload, JWK::parseKeySet($jwks), $supportedAlgorithm);
198205
```
199206

src/JWT.php

+91-31
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Firebase\JWT;
44

5+
use ArrayAccess;
56
use DomainException;
67
use Exception;
78
use InvalidArgumentException;
@@ -58,11 +59,13 @@ class JWT
5859
* Decodes a JWT string into a PHP object.
5960
*
6061
* @param string $jwt The JWT
61-
* @param string|array|resource $key The key, or map of keys.
62+
* @param Key|array<Key> $keyOrKeyArray The Key or array of Key objects.
6263
* If the algorithm used is asymmetric, this is the public key
63-
* @param array $allowed_algs List of supported verification algorithms
64+
* Each Key object contains an algorithm and matching key.
6465
* Supported algorithms are 'ES384','ES256', 'HS256', 'HS384',
6566
* 'HS512', 'RS256', 'RS384', and 'RS512'
67+
* @param array $allowed_algs [DEPRECATED] List of supported verification algorithms. Only
68+
* should be used for backwards compatibility.
6669
*
6770
* @return object The JWT's payload as a PHP object
6871
*
@@ -76,11 +79,11 @@ class JWT
7679
* @uses jsonDecode
7780
* @uses urlsafeB64Decode
7881
*/
79-
public static function decode($jwt, $key, array $allowed_algs = array())
82+
public static function decode($jwt, $keyOrKeyArray, array $allowed_algs = array())
8083
{
8184
$timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp;
8285

83-
if (empty($key)) {
86+
if (empty($keyOrKeyArray)) {
8487
throw new InvalidArgumentException('Key may not be empty');
8588
}
8689
$tks = \explode('.', $jwt);
@@ -103,27 +106,32 @@ public static function decode($jwt, $key, array $allowed_algs = array())
103106
if (empty(static::$supported_algs[$header->alg])) {
104107
throw new UnexpectedValueException('Algorithm not supported');
105108
}
106-
if (!\in_array($header->alg, $allowed_algs)) {
107-
throw new UnexpectedValueException('Algorithm not allowed');
109+
110+
list($keyMaterial, $algorithm) = self::getKeyMaterialAndAlgorithm(
111+
$keyOrKeyArray,
112+
empty($header->kid) ? null : $header->kid
113+
);
114+
115+
if (empty($algorithm)) {
116+
// Use deprecated "allowed_algs" to determine if the algorithm is supported.
117+
// This opens up the possibility of an attack in some implementations.
118+
// @see https://github.com/firebase/php-jwt/issues/351
119+
if (!\in_array($header->alg, $allowed_algs)) {
120+
throw new UnexpectedValueException('Algorithm not allowed');
121+
}
122+
} else {
123+
// Check the algorithm
124+
if (!self::constantTimeEquals($algorithm, $header->alg)) {
125+
// See issue #351
126+
throw new UnexpectedValueException('Incorrect key for this algorithm');
127+
}
108128
}
109129
if ($header->alg === 'ES256' || $header->alg === 'ES384') {
110130
// OpenSSL expects an ASN.1 DER sequence for ES256/ES384 signatures
111131
$sig = self::signatureToDER($sig);
112132
}
113133

114-
if (\is_array($key) || $key instanceof \ArrayAccess) {
115-
if (isset($header->kid)) {
116-
if (!isset($key[$header->kid])) {
117-
throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key');
118-
}
119-
$key = $key[$header->kid];
120-
} else {
121-
throw new UnexpectedValueException('"kid" empty, unable to lookup correct key');
122-
}
123-
}
124-
125-
// Check the signature
126-
if (!static::verify("$headb64.$bodyb64", $sig, $key, $header->alg)) {
134+
if (!static::verify("$headb64.$bodyb64", $sig, $keyMaterial, $header->alg)) {
127135
throw new SignatureInvalidException('Signature verification failed');
128136
}
129137

@@ -285,18 +293,7 @@ private static function verify($msg, $signature, $key, $alg)
285293
case 'hash_hmac':
286294
default:
287295
$hash = \hash_hmac($algorithm, $msg, $key, true);
288-
if (\function_exists('hash_equals')) {
289-
return \hash_equals($signature, $hash);
290-
}
291-
$len = \min(static::safeStrlen($signature), static::safeStrlen($hash));
292-
293-
$status = 0;
294-
for ($i = 0; $i < $len; $i++) {
295-
$status |= (\ord($signature[$i]) ^ \ord($hash[$i]));
296-
}
297-
$status |= (static::safeStrlen($signature) ^ static::safeStrlen($hash));
298-
299-
return ($status === 0);
296+
return self::constantTimeEquals($signature, $hash);
300297
}
301298
}
302299

@@ -384,6 +381,69 @@ public static function urlsafeB64Encode($input)
384381
return \str_replace('=', '', \strtr(\base64_encode($input), '+/', '-_'));
385382
}
386383

384+
385+
/**
386+
* Determine if an algorithm has been provided for each Key
387+
*
388+
* @param string|array $keyOrKeyArray
389+
* @param string|null $kid
390+
*
391+
* @return an array containing the keyMaterial and algorithm
392+
*/
393+
private static function getKeyMaterialAndAlgorithm($keyOrKeyArray, $kid = null)
394+
{
395+
if (is_string($keyOrKeyArray)) {
396+
return array($keyOrKeyArray, null);
397+
}
398+
399+
if ($keyOrKeyArray instanceof Key) {
400+
return array($keyOrKeyArray->getKeyMaterial(), $keyOrKeyArray->getAlgorithm());
401+
}
402+
403+
if (is_array($keyOrKeyArray) || $keyOrKeyArray instanceof ArrayAccess) {
404+
if (!isset($kid)) {
405+
throw new UnexpectedValueException('"kid" empty, unable to lookup correct key');
406+
}
407+
if (!isset($keyOrKeyArray[$kid])) {
408+
throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key');
409+
}
410+
411+
$key = $keyOrKeyArray[$kid];
412+
413+
if ($key instanceof Key) {
414+
return array($key->getKeyMaterial(), $key->getAlgorithm());
415+
}
416+
417+
return array($key, null);
418+
}
419+
420+
throw new UnexpectedValueException(
421+
'$keyOrKeyArray must be a string key, an array of string keys, '
422+
. 'an instance of Firebase\JWT\Key key or an array of Firebase\JWT\Key keys'
423+
);
424+
}
425+
426+
/**
427+
* @param string $left
428+
* @param string $right
429+
* @return bool
430+
*/
431+
public static function constantTimeEquals($left, $right)
432+
{
433+
if (\function_exists('hash_equals')) {
434+
return \hash_equals($left, $right);
435+
}
436+
$len = \min(static::safeStrlen($left), static::safeStrlen($right));
437+
438+
$status = 0;
439+
for ($i = 0; $i < $len; $i++) {
440+
$status |= (\ord($left[$i]) ^ \ord($right[$i]));
441+
}
442+
$status |= (static::safeStrlen($left) ^ static::safeStrlen($right));
443+
444+
return ($status === 0);
445+
}
446+
387447
/**
388448
* Helper method to create a JSON error.
389449
*

src/Key.php

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
namespace Firebase\JWT;
4+
5+
use InvalidArgumentException;
6+
use OpenSSLAsymmetricKey;
7+
8+
class Key
9+
{
10+
/** @var string $algorithm */
11+
private $algorithm;
12+
13+
/** @var string|resource|OpenSSLAsymmetricKey $keyMaterial */
14+
private $keyMaterial;
15+
16+
/**
17+
* @param string|resource|OpenSSLAsymmetricKey $keyMaterial
18+
* @param string $algorithm
19+
*/
20+
public function __construct($keyMaterial, $algorithm)
21+
{
22+
if (
23+
!is_string($keyMaterial)
24+
&& !is_resource($keyMaterial)
25+
&& !$keyMaterial instanceof OpenSSLAsymmetricKey
26+
) {
27+
throw new InvalidArgumentException('Type error: $keyMaterial must be a string, resource, or OpenSSLAsymmetricKey');
28+
}
29+
30+
if (empty($keyMaterial)) {
31+
throw new InvalidArgumentException('Type error: $keyMaterial must not be empty');
32+
}
33+
34+
if (!is_string($algorithm)|| empty($keyMaterial)) {
35+
throw new InvalidArgumentException('Type error: $algorithm must be a string');
36+
}
37+
38+
$this->keyMaterial = $keyMaterial;
39+
$this->algorithm = $algorithm;
40+
}
41+
42+
/**
43+
* Return the algorithm valid for this key
44+
*
45+
* @return string
46+
*/
47+
public function getAlgorithm()
48+
{
49+
return $this->algorithm;
50+
}
51+
52+
/**
53+
* @return string|resource|OpenSSLAsymmetricKey
54+
*/
55+
public function getKeyMaterial()
56+
{
57+
return $this->keyMaterial;
58+
}
59+
}

tests/JWTTest.php

+28
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,34 @@ public function testEncodeDecode($privateKeyFile, $publicKeyFile, $alg)
344344
$this->assertEquals('bar', $decoded->foo);
345345
}
346346

347+
/**
348+
* @runInSeparateProcess
349+
* @dataProvider provideEncodeDecode
350+
*/
351+
public function testEncodeDecodeWithKeyObject($privateKeyFile, $publicKeyFile, $alg)
352+
{
353+
$privateKey = file_get_contents($privateKeyFile);
354+
$payload = array('foo' => 'bar');
355+
$encoded = JWT::encode($payload, $privateKey, $alg);
356+
357+
// Verify decoding succeeds
358+
$publicKey = file_get_contents($publicKeyFile);
359+
$decoded = JWT::decode($encoded, new Key($publicKey, $alg));
360+
361+
$this->assertEquals('bar', $decoded->foo);
362+
}
363+
364+
public function testArrayAccessKIDChooserWithKeyObject()
365+
{
366+
$keys = new ArrayObject(array(
367+
'1' => new Key('my_key', 'HS256'),
368+
'2' => new Key('my_key2', 'HS256'),
369+
));
370+
$msg = JWT::encode('abc', $keys['1']->getKeyMaterial(), 'HS256', '1');
371+
$decoded = JWT::decode($msg, $keys);
372+
$this->assertEquals($decoded, 'abc');
373+
}
374+
347375
public function provideEncodeDecode()
348376
{
349377
return array(

0 commit comments

Comments
 (0)