-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Gracefully Prevent Keys from Ever Being Used for the Wrong Header-Specified Algorithm #352
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,8 +2,11 @@ | |
|
||
namespace Firebase\JWT; | ||
|
||
use ArrayAccess; | ||
use DomainException; | ||
use Exception; | ||
use Firebase\JWT\Keys\JWTKey; | ||
use Firebase\JWT\Keys\Keyring; | ||
use InvalidArgumentException; | ||
use UnexpectedValueException; | ||
use DateTime; | ||
|
@@ -111,7 +114,9 @@ public static function decode($jwt, $key, array $allowed_algs = array()) | |
$sig = self::signatureToDER($sig); | ||
} | ||
|
||
if (\is_array($key) || $key instanceof \ArrayAccess) { | ||
/** @var Keyring|JWTKey $key */ | ||
$key = self::getKeyType($key, $allowed_algs); | ||
if ($key instanceof Keyring) { | ||
if (isset($header->kid)) { | ||
if (!isset($key[$header->kid])) { | ||
throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key'); | ||
|
@@ -121,9 +126,16 @@ public static function decode($jwt, $key, array $allowed_algs = array()) | |
throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); | ||
} | ||
} | ||
if (!($key instanceof JWTKey)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For BC, why not construct the object here if the key is a string and guess the alg? |
||
throw new UnexpectedValueException('$key should be an instance of JWTKey'); | ||
} | ||
|
||
// Check the signature | ||
if (!static::verify("$headb64.$bodyb64", $sig, $key, $header->alg)) { | ||
if (!$key->isValidForAlg($header->alg)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems like the alg guessing code should allow multiple? e.g. if it's an RSA key type all the signature lengths should pass here. The way it looks now, if RS512 and RS256 are supported by the endpoint, and a bare string key is passed in, a request with a header alg RS256 fails? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don't feel particularly strongly about the safety and correctness of this guessing code. We'd feel much safer if a major version bump with a small risk of BC breaks was elected instead. |
||
// See issue #351 | ||
throw new UnexpectedValueException('Incorrect key for this algorithm'); | ||
} | ||
if (!static::verify("$headb64.$bodyb64", $sig, $key->getKeyMaterial(), $header->alg)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For better BC, just cast the key to string? |
||
throw new SignatureInvalidException('Signature verification failed'); | ||
} | ||
|
||
|
@@ -285,18 +297,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); | ||
} | ||
} | ||
|
||
|
@@ -384,6 +385,50 @@ public static function urlsafeB64Encode($input) | |
return \str_replace('=', '', \strtr(\base64_encode($input), '+/', '-_')); | ||
} | ||
|
||
/** | ||
* @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); | ||
} | ||
|
||
/** | ||
* @param string|array|ArrayAccess $oldType | ||
* @param string[] $algs | ||
* @return KeyInterface | ||
*/ | ||
public static function getKeyType($oldType, $algs) | ||
{ | ||
if ($oldType instanceof KeyInterface) { | ||
return $oldType; | ||
} | ||
if (is_string($oldType)) { | ||
return new JWTKey($oldType, $algs); | ||
} | ||
if (is_array($oldType) || $oldType instanceof ArrayAccess) { | ||
$keyring = new Keyring(array()); | ||
foreach ($oldType as $kid => $key) { | ||
$keyring[$kid] = new JWTKey($key, $algs); | ||
} | ||
return $keyring; | ||
} | ||
throw new InvalidArgumentException('Invalid type: Must be string or array'); | ||
} | ||
|
||
/** | ||
* Helper method to create a JSON error. | ||
* | ||
|
@@ -414,7 +459,7 @@ private static function handleJsonError($errno) | |
* | ||
* @return int | ||
*/ | ||
private static function safeStrlen($str) | ||
public static function safeStrlen($str) | ||
{ | ||
if (\function_exists('mb_strlen')) { | ||
return \mb_strlen($str, '8bit'); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
<?php | ||
namespace Firebase\JWT\Keys; | ||
|
||
use Firebase\JWT\JWT; | ||
|
||
class JWTKey | ||
{ | ||
/** @var string $alg */ | ||
private $alg; | ||
|
||
/** @var string $keyMaterial */ | ||
private $keyMaterial; | ||
|
||
/** | ||
* @param string $keyMaterial | ||
* @param string|array|null $alg | ||
*/ | ||
public function __construct($keyMaterial, $alg = null) | ||
{ | ||
if (is_array($alg)) { | ||
$alg = self::guessAlgFromKeyMaterial($keyMaterial, $alg); | ||
} elseif (is_null($alg)) { | ||
$alg = self::guessAlgFromKeyMaterial($keyMaterial); | ||
} | ||
$this->keyMaterial = $keyMaterial; | ||
$this->alg = $alg; | ||
} | ||
|
||
/** | ||
* Is the header algorithm valid for this key? | ||
* | ||
* @param string $headerAlg | ||
* @return bool | ||
*/ | ||
public function isValidForAlg($headerAlg) | ||
{ | ||
return JWT::constantTimeEquals($this->alg, $headerAlg); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do you need const time comparison here? The alg is generally known or picked from a short list. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consistency. |
||
} | ||
|
||
/** | ||
* @return string | ||
*/ | ||
public function getKeyMaterial() | ||
bshaffer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
return $this->keyMaterial; | ||
} | ||
|
||
/** | ||
* This is a best-effort attempt to guess the algorithm for a given key | ||
* based on its contents. | ||
* | ||
* It will probably be wrong in a lot of corner cases. | ||
* | ||
* If it is, construct a JWTKey object and/or Keyring of JWTKey objects | ||
* with the correct algorithms. | ||
* | ||
* @param string $keyMaterial | ||
* @param array $candidates | ||
* @return string | ||
*/ | ||
public static function guessAlgFromKeyMaterial($keyMaterial, array $candidates = array()) | ||
{ | ||
$length = JWT::safeStrlen($keyMaterial); | ||
if ($length >= 720) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For the tests you've provided, this does not pass with the generated RSA public key. The public key is of length 272. Is this a bug or is the guessing expected to be that brittle? it could be because the tests are generating a key with only There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was a canned guess at key size. 1024-bit RSA is a bit dodgy; 3072-bit and higher is recommended. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The other thing we could do is parse the base64-encoded string and inspect the ASN.1 header. That's probably a bit more reliable. I'll send a superseding pull request that does that instead. |
||
// RSA keys | ||
if (preg_match('#^-+BEGIN.+(PRIVATE|PUBLIC) KEY-+#', $keyMaterial)) { | ||
if (in_array('RS512', $candidates)) { | ||
return 'RS512'; | ||
} | ||
if (in_array('RS384', $candidates)) { | ||
return 'RS384'; | ||
} | ||
return 'RS256'; | ||
} | ||
} elseif ($length >= 220) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same issue here - the EC key generates with length There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What curve are you using? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The test you provided! |
||
// ECDSA private keys | ||
if (preg_match('#^-+BEGIN EC PRIVATE KEY-+#', $keyMaterial)) { | ||
if (in_array('ES512', $candidates)) { | ||
return 'ES512'; | ||
} | ||
if (in_array('ES384', $candidates)) { | ||
return 'ES384'; | ||
} | ||
return 'ES256'; | ||
} | ||
} elseif ($length >= 170) { | ||
// ECDSA public keys | ||
if (preg_match('#^-+BEGIN EC PUBLICY-+#', $keyMaterial)) { | ||
if (in_array('ES512', $candidates)) { | ||
return 'ES512'; | ||
} | ||
if (in_array('ES384', $candidates)) { | ||
return 'ES384'; | ||
} | ||
return 'ES256'; | ||
} | ||
} elseif ($length >= 40 && $length <= 88) { | ||
// Likely base64-encoded EdDSA key | ||
if (in_array('EdDSA', $candidates)) { | ||
return 'EdDSA'; | ||
} | ||
} | ||
|
||
// Last resort: HMAC | ||
if (in_array('HS512', $candidates)) { | ||
return 'HS512'; | ||
} | ||
if (in_array('HS384', $candidates)) { | ||
return 'HS384'; | ||
} | ||
return 'HS256'; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
<?php | ||
namespace Firebase\JWT\Keys; | ||
|
||
use ArrayAccess; | ||
use RuntimeException; | ||
|
||
final class Keyring implements ArrayAccess | ||
{ | ||
/** @var array<string, JWTKey> $mapping */ | ||
private $mapping; | ||
|
||
/** | ||
* @param array<string, JWTKey> $mapping | ||
*/ | ||
public function __construct(array $mapping = array()) | ||
{ | ||
$this->mapping = $mapping; | ||
} | ||
|
||
/** | ||
* @param string $keyId | ||
* @param JWTKey $key | ||
* @return $this | ||
*/ | ||
public function mapKeyId($keyId, JWTKey $key) | ||
{ | ||
$this->mapping[$keyId] = $key; | ||
return $this; | ||
} | ||
|
||
/** | ||
* @param mixed $offset | ||
* @return bool | ||
*/ | ||
public function offsetExists($offset) | ||
{ | ||
if (!is_string($offset)) { | ||
throw new RuntimeException('Type error: argument 1 must be a string'); | ||
} | ||
return array_key_exists($offset, $this->mapping); | ||
} | ||
|
||
/** | ||
* @param mixed $offset | ||
* @return JWTKey | ||
*/ | ||
public function offsetGet($offset) | ||
{ | ||
$value = $this->mapping[$offset]; | ||
if (!($value instanceof JWTKey)) { | ||
throw new RuntimeException('Type error: return value not an instance of JWTKey'); | ||
} | ||
return $value; | ||
} | ||
|
||
/** | ||
* @param string $offset | ||
* @param JWTKey $value | ||
*/ | ||
public function offsetSet($offset, $value) | ||
{ | ||
if (!is_string($offset)) { | ||
throw new RuntimeException('Type error: argument 1 must be a string'); | ||
} | ||
if (!($value instanceof JWTKey)) { | ||
throw new RuntimeException('Type error: argument 2 must be an instance of JWT'); | ||
} | ||
$this->mapKeyId($offset, $value); | ||
} | ||
|
||
/** | ||
* @param string $offset | ||
*/ | ||
public function offsetUnset($offset) | ||
{ | ||
if (!is_string($offset)) { | ||
throw new RuntimeException('Type error: argument 1 must be a string'); | ||
} | ||
unset($this->mapping[$offset]); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why hard-code this class? It means I can't implement my own key database or just use an array of key objects. There is verification below that the key type is as expected.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Feel free to swap it out for an interface in your own implementation. This is just a proof-of-concept fix.