From 27179e1d244298d83d9b300b47763cba1e87cee4 Mon Sep 17 00:00:00 2001 From: Syahrul Safarila Date: Thu, 10 Apr 2025 02:12:25 +0700 Subject: [PATCH 01/14] docs: fix examples in README.md (#569) --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 04252693..e45ccb80 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ $jwks = ['keys' => []]; // JWK::parseKeySet($jwks) returns an associative array of **kid** to Firebase\JWT\Key // objects. Pass this as the second parameter to JWT::decode. -JWT::decode($payload, JWK::parseKeySet($jwks)); +JWT::decode($jwt, JWK::parseKeySet($jwks)); ``` Using Cached Key Sets @@ -350,7 +350,7 @@ use InvalidArgumentException; use UnexpectedValueException; try { - $decoded = JWT::decode($payload, $keys); + $decoded = JWT::decode($jwt, $keys); } catch (InvalidArgumentException $e) { // provided key/key-array is empty or malformed. } catch (DomainException $e) { @@ -380,7 +380,7 @@ like this: use Firebase\JWT\JWT; use UnexpectedValueException; try { - $decoded = JWT::decode($payload, $keys); + $decoded = JWT::decode($jwt, $keys); } catch (LogicException $e) { // errors having to do with environmental setup or malformed JWT Keys } catch (UnexpectedValueException $e) { @@ -395,7 +395,7 @@ instead, you can do the following: ```php // return type is stdClass -$decoded = JWT::decode($payload, $keys); +$decoded = JWT::decode($jwt, $keys); // cast to array $decoded = json_decode(json_encode($decoded), true); From c11113afa13265e016a669e75494b9203b8a7775 Mon Sep 17 00:00:00 2001 From: Dan Wallis Date: Wed, 9 Apr 2025 21:29:08 +0100 Subject: [PATCH 02/14] fix: update error text for consistency (#528) --- src/JWT.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index dd9292a4..833a415e 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -154,7 +154,7 @@ public static function decode( // token can actually be used. If it's not yet that time, abort. if (isset($payload->nbf) && floor($payload->nbf) > ($timestamp + static::$leeway)) { $ex = new BeforeValidException( - 'Cannot handle token with nbf prior to ' . \date(DateTime::ISO8601, (int) $payload->nbf) + 'Cannot handle token with nbf prior to ' . \date(DateTime::ISO8601, (int) floor($payload->nbf)) ); $ex->setPayload($payload); throw $ex; @@ -165,7 +165,7 @@ public static function decode( // correctly used the nbf claim). if (!isset($payload->nbf) && isset($payload->iat) && floor($payload->iat) > ($timestamp + static::$leeway)) { $ex = new BeforeValidException( - 'Cannot handle token with iat prior to ' . \date(DateTime::ISO8601, (int) $payload->iat) + 'Cannot handle token with iat prior to ' . \date(DateTime::ISO8601, (int) floor($payload->iat)) ); $ex->setPayload($payload); throw $ex; From d1e91ecf8c598d073d0995afa8cd5c75c6e19e66 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 9 Apr 2025 13:32:01 -0700 Subject: [PATCH 03/14] chore(main): release 6.11.1 (#597) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01fcc077..7b5f6ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [6.11.1](https://github.com/firebase/php-jwt/compare/v6.11.0...v6.11.1) (2025-04-09) + + +### Bug Fixes + +* update error text for consistency ([#528](https://github.com/firebase/php-jwt/issues/528)) ([c11113a](https://github.com/firebase/php-jwt/commit/c11113afa13265e016a669e75494b9203b8a7775)) + ## [6.11.0](https://github.com/firebase/php-jwt/compare/v6.10.2...v6.11.0) (2025-01-23) From 43d70ae8d9b0dd8adbe189adc03324369f2161e6 Mon Sep 17 00:00:00 2001 From: Enno Rehling Date: Wed, 9 Apr 2025 22:42:27 +0200 Subject: [PATCH 04/14] fix: use DateTime::ATOM instead of ISO8601 in exception message --- src/JWT.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index 833a415e..37a9e0e6 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -154,7 +154,7 @@ public static function decode( // token can actually be used. If it's not yet that time, abort. if (isset($payload->nbf) && floor($payload->nbf) > ($timestamp + static::$leeway)) { $ex = new BeforeValidException( - 'Cannot handle token with nbf prior to ' . \date(DateTime::ISO8601, (int) floor($payload->nbf)) + 'Cannot handle token with nbf prior to ' . \date(DateTime::ATOM, (int) floor($payload->nbf)) ); $ex->setPayload($payload); throw $ex; @@ -165,7 +165,7 @@ public static function decode( // correctly used the nbf claim). if (!isset($payload->nbf) && isset($payload->iat) && floor($payload->iat) > ($timestamp + static::$leeway)) { $ex = new BeforeValidException( - 'Cannot handle token with iat prior to ' . \date(DateTime::ISO8601, (int) floor($payload->iat)) + 'Cannot handle token with iat prior to ' . \date(DateTime::ATOM, (int) floor($payload->iat)) ); $ex->setPayload($payload); throw $ex; From 953b2c88bb445b7e3bb82a5141928f13d7343afd Mon Sep 17 00:00:00 2001 From: christiandavilakoobin <46561103+christiandavilakoobin@users.noreply.github.com> Date: Wed, 16 Apr 2025 21:45:58 +0200 Subject: [PATCH 05/14] fix: validate iat and nbf on payload (#568) --- src/JWT.php | 10 ++++++++++ tests/JWTTest.php | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/JWT.php b/src/JWT.php index 37a9e0e6..5386b601 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -127,6 +127,16 @@ public static function decode( if (!$payload instanceof stdClass) { throw new UnexpectedValueException('Payload must be a JSON object'); } + if (isset($payload->iat) && !\is_numeric($payload->iat)) { + throw new UnexpectedValueException('Payload iat must be a number'); + } + if (isset($payload->nbf) && !\is_numeric($payload->nbf)) { + throw new UnexpectedValueException('Payload nbf must be a number'); + } + if (isset($payload->exp) && !\is_numeric($payload->exp)) { + throw new UnexpectedValueException('Payload exp must be a number'); + } + $sig = static::urlsafeB64Decode($cryptob64); if (empty($header->alg)) { throw new UnexpectedValueException('Empty algorithm'); diff --git a/tests/JWTTest.php b/tests/JWTTest.php index d09d43e3..805b867a 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -546,4 +546,31 @@ public function testAdditionalHeaderOverrides() $this->assertEquals('my_key_id', $headers->kid, 'key param not overridden'); $this->assertEquals('HS256', $headers->alg, 'alg param not overridden'); } + + public function testDecodeExpectsIntegerIat() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Payload iat must be a number'); + + $payload = JWT::encode(['iat' => 'not-an-int'], 'secret', 'HS256'); + JWT::decode($payload, new Key('secret', 'HS256')); + } + + public function testDecodeExpectsIntegerNbf() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Payload nbf must be a number'); + + $payload = JWT::encode(['nbf' => 'not-an-int'], 'secret', 'HS256'); + JWT::decode($payload, new Key('secret', 'HS256')); + } + + public function testDecodeExpectsIntegerExp() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Payload exp must be a number'); + + $payload = JWT::encode(['exp' => 'not-an-int'], 'secret', 'HS256'); + JWT::decode($payload, new Key('secret', 'HS256')); + } } From 223d1b39dee28f2eb47c623b27a8cfe3a9945a5f Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 7 Aug 2025 13:19:49 -0700 Subject: [PATCH 06/14] chore: move release please from app to github action (#606) --- .github/release-please.yml | 3 --- .github/workflows/release-please.yml | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) delete mode 100644 .github/release-please.yml create mode 100644 .github/workflows/release-please.yml diff --git a/.github/release-please.yml b/.github/release-please.yml deleted file mode 100644 index 0a6e0cc2..00000000 --- a/.github/release-please.yml +++ /dev/null @@ -1,3 +0,0 @@ -releaseType: simple -handleGHRelease: true -primaryBranch: main diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 00000000..f2d3ca98 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,19 @@ +name: release-please +on: + push: + branches: + - main +permissions: + # Needed for Release Please to create and update files + contents: write + # Needed for Release Please to create Release PRs + pull-requests: write +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + id: release + with: + token: ${{ secrets.GITHUB_TOKEN }} + release-type: simple From 4dbfac0260eeb0e9e643063c99998e3219cc539b Mon Sep 17 00:00:00 2001 From: Yohei Ema <24579635+meihei3@users.noreply.github.com> Date: Fri, 8 Aug 2025 05:20:40 +0900 Subject: [PATCH 07/14] feat: add SensitiveParameter attribute to security-critical parameters (#603) --- src/JWK.php | 4 ++-- src/JWT.php | 10 +++++----- src/Key.php | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/JWK.php b/src/JWK.php index 405dcc49..d5175b21 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -52,7 +52,7 @@ class JWK * * @uses parseKey */ - public static function parseKeySet(array $jwks, ?string $defaultAlg = null): array + public static function parseKeySet(#[\SensitiveParameter] array $jwks, ?string $defaultAlg = null): array { $keys = []; @@ -93,7 +93,7 @@ public static function parseKeySet(array $jwks, ?string $defaultAlg = null): arr * * @uses createPemFromModulusAndExponent */ - public static function parseKey(array $jwk, ?string $defaultAlg = null): ?Key + public static function parseKey(#[\SensitiveParameter] array $jwk, ?string $defaultAlg = null): ?Key { if (empty($jwk)) { throw new InvalidArgumentException('JWK must not be empty'); diff --git a/src/JWT.php b/src/JWT.php index 5386b601..a2e4438a 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -95,7 +95,7 @@ class JWT */ public static function decode( string $jwt, - $keyOrKeyArray, + #[\SensitiveParameter] $keyOrKeyArray, ?stdClass &$headers = null ): stdClass { // Validate JWT @@ -208,7 +208,7 @@ public static function decode( */ public static function encode( array $payload, - $key, + #[\SensitiveParameter] $key, string $alg, ?string $keyId = null, ?array $head = null @@ -246,7 +246,7 @@ public static function encode( */ public static function sign( string $msg, - $key, + #[\SensitiveParameter] $key, string $alg ): string { if (empty(static::$supported_algs[$alg])) { @@ -313,7 +313,7 @@ public static function sign( private static function verify( string $msg, string $signature, - $keyMaterial, + #[\SensitiveParameter] $keyMaterial, string $alg ): bool { if (empty(static::$supported_algs[$alg])) { @@ -467,7 +467,7 @@ public static function urlsafeB64Encode(string $input): string * @return Key */ private static function getKey( - $keyOrKeyArray, + #[\SensitiveParameter] $keyOrKeyArray, ?string $kid ): Key { if ($keyOrKeyArray instanceof Key) { diff --git a/src/Key.php b/src/Key.php index b34eae25..4db94851 100644 --- a/src/Key.php +++ b/src/Key.php @@ -14,7 +14,7 @@ class Key * @param string $algorithm */ public function __construct( - private $keyMaterial, + #[\SensitiveParameter] private $keyMaterial, private string $algorithm ) { if ( From f1748260d218a856b6a0c23715ac7fae1d7ca95b Mon Sep 17 00:00:00 2001 From: Luke Kuzmish <42181698+cosmastech@users.noreply.github.com> Date: Tue, 19 Aug 2025 11:14:18 -0400 Subject: [PATCH 08/14] feat: store timestamp in `ExpiredException` (#604) --- src/ExpiredException.php | 12 ++++++++++++ src/JWT.php | 1 + tests/JWTTest.php | 23 +++++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/src/ExpiredException.php b/src/ExpiredException.php index 12fef094..25f44513 100644 --- a/src/ExpiredException.php +++ b/src/ExpiredException.php @@ -6,6 +6,8 @@ class ExpiredException extends \UnexpectedValueException implements JWTException { private object $payload; + private ?int $timestamp = null; + public function setPayload(object $payload): void { $this->payload = $payload; @@ -15,4 +17,14 @@ public function getPayload(): object { return $this->payload; } + + public function setTimestamp(int $timestamp): void + { + $this->timestamp = $timestamp; + } + + public function getTimestamp(): ?int + { + return $this->timestamp; + } } diff --git a/src/JWT.php b/src/JWT.php index a2e4438a..7e08f491 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -185,6 +185,7 @@ public static function decode( if (isset($payload->exp) && ($timestamp - static::$leeway) >= $payload->exp) { $ex = new ExpiredException('Expired token'); $ex->setPayload($payload); + $ex->setTimestamp($timestamp); throw $ex; } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 805b867a..de744311 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -130,6 +130,29 @@ public function testExpiredExceptionPayload() } } + /** + * @runInSeparateProcess + */ + public function testExpiredExceptionTimestamp() + { + $this->expectException(ExpiredException::class); + + JWT::$timestamp = 98765; + $payload = [ + 'message' => 'abc', + 'exp' => 1234, + ]; + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + + try { + JWT::decode($encoded, new Key('my_key', 'HS256')); + } catch (ExpiredException $e) { + $exTimestamp = $e->getTimestamp(); + $this->assertSame(98765, $exTimestamp); + throw $e; + } + } + public function testBeforeValidExceptionPayload() { $this->expectException(BeforeValidException::class); From a3edb392bd5570e1db9ae316737c27bcaab7f78e Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 5 Sep 2025 14:25:31 -0700 Subject: [PATCH 09/14] chore: update release-please secret (#608) --- .github/workflows/release-please.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index f2d3ca98..409df545 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -15,5 +15,5 @@ jobs: - uses: googleapis/release-please-action@v4 id: release with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ secrets.YOSHI_CODE_BOT_TOKEN }} release-type: simple From 6b80341bf57838ea2d011487917337901cd71576 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 15 Dec 2025 11:45:09 -0700 Subject: [PATCH 10/14] feat: add key size validation (#613) --- src/JWT.php | 53 +++++++++- tests/JWTTest.php | 247 +++++++++++++++++++++++++++++++--------------- 2 files changed, 218 insertions(+), 82 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index 7e08f491..dc564efc 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -31,6 +31,8 @@ class JWT private const ASN1_SEQUENCE = 0x10; private const ASN1_BIT_STRING = 0x03; + private const RSA_KEY_MIN_LENGTH=2048; + /** * When checking nbf, iat or expiration times, * we want to provide some extra leeway time to @@ -259,11 +261,19 @@ public static function sign( if (!\is_string($key)) { throw new InvalidArgumentException('key must be a string when using hmac'); } + self::validateHmacKeyLength($key, $algorithm); return \hash_hmac($algorithm, $msg, $key, true); case 'openssl': $signature = ''; - if (!\is_resource($key) && !openssl_pkey_get_private($key)) { - throw new DomainException('OpenSSL unable to validate key'); + if (!\is_resource($key)) { + /** @var OpenSSLAsymmetricKey|OpenSSLCertificate|string $key */ + $key = $key; + if (!$key = openssl_pkey_get_private($key)) { + throw new DomainException('OpenSSL unable to validate key'); + } + if (str_starts_with($alg, 'RS')) { + self::validateRsaKeyLength($key); + } } $success = \openssl_sign($msg, $signature, $key, $algorithm); // @phpstan-ignore-line if (!$success) { @@ -324,6 +334,13 @@ private static function verify( list($function, $algorithm) = static::$supported_algs[$alg]; switch ($function) { case 'openssl': + if (!\is_resource($keyMaterial) && str_starts_with($algorithm, 'RS')) { + /** @var OpenSSLAsymmetricKey|OpenSSLCertificate|string $keyMaterial */ + $keyMaterial = $keyMaterial; + if ($key = openssl_pkey_get_private($keyMaterial)) { + self::validateRsaKeyLength($key); + } + } $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); // @phpstan-ignore-line if ($success === 1) { return true; @@ -361,6 +378,7 @@ private static function verify( if (!\is_string($keyMaterial)) { throw new InvalidArgumentException('key must be a string when using hmac'); } + self::validateHmacKeyLength($keyMaterial, $algorithm); $hash = \hash_hmac($algorithm, $msg, $keyMaterial, true); return self::constantTimeEquals($hash, $signature); } @@ -675,4 +693,35 @@ private static function readDER(string $der, int $offset = 0): array return [$pos, $data]; } + + /** + * Validate HMAC key length + * + * @param string $key HMAC key material + * @param string $algorithm The algorithm + * @throws DomainException Provided key is too short + */ + private static function validateHmacKeyLength(string $key, string $algorithm): void + { + $keyLength = \strlen($key) * 8; + $minKeyLength = (int) \str_replace('SHA', '', $algorithm); + if ($keyLength < $minKeyLength) { + throw new DomainException('Provided key is too short'); + } + } + + /** + * Validate RSA key length + * + * @param OpenSSLAsymmetricKey $key RSA key material + * @throws DomainException Provided key is too short + */ + private static function validateRsaKeyLength(OpenSSLAsymmetricKey $key): void + { + if ($keyDetails = \openssl_pkey_get_details($key)) { + if ($keyDetails['bits'] < self::RSA_KEY_MIN_LENGTH) { + throw new DomainException('Provided key is too short'); + } + } + } } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index de744311..d64d37be 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -2,7 +2,6 @@ namespace Firebase\JWT; -use ArrayObject; use DomainException; use InvalidArgumentException; use PHPUnit\Framework\TestCase; @@ -12,18 +11,26 @@ class JWTTest extends TestCase { + private Key $hmacKey; + + public function setUp(): void + { + $this->hmacKey = $this->generateHmac256(); + } + public function testUrlSafeCharacters() { - $encoded = JWT::encode(['message' => 'f?'], 'a', 'HS256'); + $encoded = JWT::encode(['message' => 'f?'], $this->hmacKey->getKeyMaterial(), 'HS256'); $expected = new stdClass(); $expected->message = 'f?'; - $this->assertEquals($expected, JWT::decode($encoded, new Key('a', 'HS256'))); + $this->assertEquals($expected, JWT::decode($encoded, $this->hmacKey)); } public function testMalformedUtf8StringsFail() { + $this->expectException(DomainException::class); - JWT::encode(['message' => pack('c', 128)], 'a', 'HS256'); + JWT::encode(['message' => pack('c', 128)], $this->hmacKey->getKeyMaterial(), 'HS256'); } public function testInvalidKeyOpensslSignFail() @@ -45,8 +52,9 @@ public function testExpiredToken() 'message' => 'abc', 'exp' => time() - 20, // time in the past ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - JWT::decode($encoded, new Key('my_key', 'HS256')); + + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey); } public function testBeforeValidTokenWithNbf() @@ -56,8 +64,8 @@ public function testBeforeValidTokenWithNbf() 'message' => 'abc', 'nbf' => time() + 20, // time in the future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey); } public function testBeforeValidTokenWithIat() @@ -67,8 +75,8 @@ public function testBeforeValidTokenWithIat() 'message' => 'abc', 'iat' => time() + 20, // time in the future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey); } public function testValidToken() @@ -77,8 +85,8 @@ public function testValidToken() 'message' => 'abc', 'exp' => time() + JWT::$leeway + 20, // time in the future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); $this->assertSame($decoded->message, 'abc'); } @@ -92,8 +100,8 @@ public function testValidTokenWithLeeway() 'message' => 'abc', 'exp' => time() - 20, // time in the past ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); $this->assertSame($decoded->message, 'abc'); } @@ -102,14 +110,14 @@ public function testValidTokenWithLeeway() */ public function testExpiredTokenWithLeeway() { + $this->expectException(ExpiredException::class); JWT::$leeway = 60; $payload = [ 'message' => 'abc', 'exp' => time() - 70, // time far in the past ]; - $this->expectException(ExpiredException::class); - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); $this->assertSame($decoded->message, 'abc'); } @@ -120,9 +128,9 @@ public function testExpiredExceptionPayload() 'message' => 'abc', 'exp' => time() - 100, // time in the past ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); try { - JWT::decode($encoded, new Key('my_key', 'HS256')); + JWT::decode($encoded, $this->hmacKey); } catch (ExpiredException $e) { $exceptionPayload = (array) $e->getPayload(); $this->assertEquals($exceptionPayload, $payload); @@ -142,10 +150,10 @@ public function testExpiredExceptionTimestamp() 'message' => 'abc', 'exp' => 1234, ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); try { - JWT::decode($encoded, new Key('my_key', 'HS256')); + JWT::decode($encoded, $this->hmacKey); } catch (ExpiredException $e) { $exTimestamp = $e->getTimestamp(); $this->assertSame(98765, $exTimestamp); @@ -160,9 +168,9 @@ public function testBeforeValidExceptionPayload() 'message' => 'abc', 'iat' => time() + 100, // time in the future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); try { - JWT::decode($encoded, new Key('my_key', 'HS256')); + JWT::decode($encoded, $this->hmacKey); } catch (BeforeValidException $e) { $exceptionPayload = (array) $e->getPayload(); $this->assertEquals($exceptionPayload, $payload); @@ -178,8 +186,8 @@ public function testValidTokenWithNbf() 'exp' => time() + 20, // time in the future 'nbf' => time() - 20 ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); $this->assertSame($decoded->message, 'abc'); } @@ -193,8 +201,8 @@ public function testValidTokenWithNbfLeeway() 'message' => 'abc', 'nbf' => time() + 20, // not before in near (leeway) future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); $this->assertSame($decoded->message, 'abc'); } @@ -208,10 +216,10 @@ public function testInvalidTokenWithNbfLeeway() 'message' => 'abc', 'nbf' => time() + 65, // not before too far in future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); $this->expectException(BeforeValidException::class); $this->expectExceptionMessage('Cannot handle token with nbf prior to'); - JWT::decode($encoded, new Key('my_key', 'HS256')); + JWT::decode($encoded, $this->hmacKey); } public function testValidTokenWithNbfIgnoresIat() @@ -221,8 +229,8 @@ public function testValidTokenWithNbfIgnoresIat() 'nbf' => time() - 20, // time in the future 'iat' => time() + 20, // time in the past ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); $this->assertEquals('abc', $decoded->message); } @@ -232,8 +240,8 @@ public function testValidTokenWithNbfMicrotime() 'message' => 'abc', 'nbf' => microtime(true), // use microtime ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); $this->assertEquals('abc', $decoded->message); } @@ -245,8 +253,8 @@ public function testInvalidTokenWithNbfMicrotime() 'message' => 'abc', 'nbf' => microtime(true) + 20, // use microtime in the future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey); } /** @@ -259,8 +267,8 @@ public function testValidTokenWithIatLeeway() 'message' => 'abc', 'iat' => time() + 20, // issued in near (leeway) future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); $this->assertSame($decoded->message, 'abc'); } @@ -274,10 +282,10 @@ public function testInvalidTokenWithIatLeeway() 'message' => 'abc', 'iat' => time() + 65, // issued too far in future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); $this->expectException(BeforeValidException::class); $this->expectExceptionMessage('Cannot handle token with iat prior to'); - JWT::decode($encoded, new Key('my_key', 'HS256')); + JWT::decode($encoded, $this->hmacKey); } public function testValidTokenWithIatMicrotime() @@ -286,8 +294,8 @@ public function testValidTokenWithIatMicrotime() 'message' => 'abc', 'iat' => microtime(true), // use microtime ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); $this->assertEquals('abc', $decoded->message); } @@ -299,19 +307,21 @@ public function testInvalidTokenWithIatMicrotime() 'message' => 'abc', 'iat' => microtime(true) + 20, // use microtime in the future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey); } public function testInvalidToken() { + $encodeKey = $this->generateHmac256(); + $decodeKey = $this->generateHmac256(); $payload = [ 'message' => 'abc', 'exp' => time() + 20, // time in the future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $encoded = JWT::encode($payload, $encodeKey->getKeyMaterial(), $encodeKey->getAlgorithm()); $this->expectException(SignatureInvalidException::class); - JWT::decode($encoded, new Key('my_key2', 'HS256')); + JWT::decode($encoded, $decodeKey); } public function testNullKeyFails() @@ -320,7 +330,7 @@ public function testNullKeyFails() 'message' => 'abc', 'exp' => time() + JWT::$leeway + 20, // time in the future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); $this->expectException(TypeError::class); JWT::decode($encoded, new Key(null, 'HS256')); } @@ -331,7 +341,7 @@ public function testEmptyKeyFails() 'message' => 'abc', 'exp' => time() + JWT::$leeway + 20, // time in the future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); $this->expectException(InvalidArgumentException::class); JWT::decode($encoded, new Key('', 'HS256')); } @@ -339,9 +349,9 @@ public function testEmptyKeyFails() public function testKIDChooser() { $keys = [ - '0' => new Key('my_key0', 'HS256'), - '1' => new Key('my_key1', 'HS256'), - '2' => new Key('my_key2', 'HS256') + '0' => $this->generateHmac256(), + '1' => $this->generateHmac256(), + '2' => $this->generateHmac256() ]; $msg = JWT::encode(['message' => 'abc'], $keys['0']->getKeyMaterial(), 'HS256', '0'); $decoded = JWT::decode($msg, $keys); @@ -352,11 +362,11 @@ public function testKIDChooser() public function testArrayAccessKIDChooser() { - $keys = new ArrayObject([ - '0' => new Key('my_key0', 'HS256'), - '1' => new Key('my_key1', 'HS256'), - '2' => new Key('my_key2', 'HS256'), - ]); + $keys = [ + '0' => $this->generateHmac256(), + '1' => $this->generateHmac256(), + '2' => $this->generateHmac256() + ]; $msg = JWT::encode(['message' => 'abc'], $keys['0']->getKeyMaterial(), 'HS256', '0'); $decoded = JWT::decode($msg, $keys); $expected = new stdClass(); @@ -366,59 +376,62 @@ public function testArrayAccessKIDChooser() public function testNoneAlgorithm() { - $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); + $msg = JWT::encode(['message' => 'abc'], $this->hmacKey->getKeyMaterial(), 'HS256'); $this->expectException(UnexpectedValueException::class); - JWT::decode($msg, new Key('my_key', 'none')); + JWT::decode($msg, new Key($this->hmacKey->getKeyMaterial(), 'none')); } public function testIncorrectAlgorithm() { - $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); + $msg = JWT::encode(['message' => 'abc'], $this->hmacKey->getKeyMaterial(), 'HS256'); $this->expectException(UnexpectedValueException::class); - JWT::decode($msg, new Key('my_key', 'RS256')); + // TODO: Generate proper RS256 key + JWT::decode($msg, new Key($this->hmacKey->getKeyMaterial(), 'RS256')); } public function testEmptyAlgorithm() { - $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); + $msg = JWT::encode(['message' => 'abc'], $this->hmacKey->getKeyMaterial(), 'HS256'); $this->expectException(InvalidArgumentException::class); - JWT::decode($msg, new Key('my_key', '')); + JWT::decode($msg, new Key($this->hmacKey->getKeyMaterial(), '')); } public function testAdditionalHeaders() { - $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256', null, ['cty' => 'test-eit;v=1']); + $msg = JWT::encode(['message' => 'abc'], $this->hmacKey->getKeyMaterial(), 'HS256', null, ['cty' => 'test-eit;v=1']); $expected = new stdClass(); $expected->message = 'abc'; - $this->assertEquals(JWT::decode($msg, new Key('my_key', 'HS256')), $expected); + $this->assertEquals(JWT::decode($msg, $this->hmacKey), $expected); } public function testInvalidSegmentCount() { $this->expectException(UnexpectedValueException::class); - JWT::decode('brokenheader.brokenbody', new Key('my_key', 'HS256')); + JWT::decode('brokenheader.brokenbody', $this->hmacKey); } public function testInvalidSignatureEncoding() { $msg = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwibmFtZSI6ImZvbyJ9.Q4Kee9E8o0Xfo4ADXvYA8t7dN_X_bU9K5w6tXuiSjlUxx'; $this->expectException(UnexpectedValueException::class); - JWT::decode($msg, new Key('secret', 'HS256')); + JWT::decode($msg, $this->hmacKey); } public function testHSEncodeDecode() { - $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); + $msg = JWT::encode(['message' => 'abc'], $this->hmacKey->getKeyMaterial(), 'HS256'); $expected = new stdClass(); $expected->message = 'abc'; - $this->assertEquals(JWT::decode($msg, new Key('my_key', 'HS256')), $expected); + $this->assertEquals(JWT::decode($msg, $this->hmacKey), $expected); } public function testRSEncodeDecode() { - $privKey = openssl_pkey_new(['digest_alg' => 'sha256', - 'private_key_bits' => 1024, - 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + $privKey = openssl_pkey_new([ + 'digest_alg' => 'sha256', + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA + ]); $msg = JWT::encode(['message' => 'abc'], $privKey, 'RS256'); $pubKey = openssl_pkey_get_details($privKey); $pubKey = $pubKey['key']; @@ -541,8 +554,8 @@ public function testGetHeaders() ]; $headers = new stdClass(); - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - JWT::decode($encoded, new Key('my_key', 'HS256'), $headers); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey, $headers); $this->assertEquals($headers->typ, 'JWT'); $this->assertEquals($headers->alg, 'HS256'); @@ -552,7 +565,7 @@ public function testAdditionalHeaderOverrides() { $msg = JWT::encode( ['message' => 'abc'], - 'my_key', + $this->hmacKey->getKeyMaterial(), 'HS256', 'my_key_id', [ @@ -563,7 +576,7 @@ public function testAdditionalHeaderOverrides() ] ); $headers = new stdClass(); - JWT::decode($msg, new Key('my_key', 'HS256'), $headers); + JWT::decode($msg, $this->hmacKey, $headers); $this->assertEquals('test-eit;v=1', $headers->cty, 'additional field works'); $this->assertEquals('JOSE', $headers->typ, 'typ override works'); $this->assertEquals('my_key_id', $headers->kid, 'key param not overridden'); @@ -575,8 +588,8 @@ public function testDecodeExpectsIntegerIat() $this->expectException(UnexpectedValueException::class); $this->expectExceptionMessage('Payload iat must be a number'); - $payload = JWT::encode(['iat' => 'not-an-int'], 'secret', 'HS256'); - JWT::decode($payload, new Key('secret', 'HS256')); + $payload = JWT::encode(['iat' => 'not-an-int'], $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($payload, $this->hmacKey); } public function testDecodeExpectsIntegerNbf() @@ -584,8 +597,8 @@ public function testDecodeExpectsIntegerNbf() $this->expectException(UnexpectedValueException::class); $this->expectExceptionMessage('Payload nbf must be a number'); - $payload = JWT::encode(['nbf' => 'not-an-int'], 'secret', 'HS256'); - JWT::decode($payload, new Key('secret', 'HS256')); + $payload = JWT::encode(['nbf' => 'not-an-int'], $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($payload, $this->hmacKey); } public function testDecodeExpectsIntegerExp() @@ -593,7 +606,81 @@ public function testDecodeExpectsIntegerExp() $this->expectException(UnexpectedValueException::class); $this->expectExceptionMessage('Payload exp must be a number'); - $payload = JWT::encode(['exp' => 'not-an-int'], 'secret', 'HS256'); - JWT::decode($payload, new Key('secret', 'HS256')); + $payload = JWT::encode(['exp' => 'not-an-int'], $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($payload, $this->hmacKey); + } + + public function testRsaKeyLengthValidationThrowsException(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Provided key is too short'); + + // Generate an RSA key that is smaller than the 2048-bit minimum + $shortRsaKey = openssl_pkey_new([ + 'private_key_bits' => 1024, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + self::assertNotFalse($shortRsaKey, 'Failed to generate a short RSA key for testing.'); + $payload = ['message' => 'abc']; + JWT::encode($payload, $shortRsaKey, 'RS256'); + } + + /** @dataProvider provideHmac */ + public function testHmacKeyLengthValidationThrowsExceptionEncode(string $alg, int $minLength): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Provided key is too short'); + + $tooShortKeyBytes = str_repeat('b', $minLength - 1); + $payload = ['message' => 'abc']; + + JWT::encode($payload, $tooShortKeyBytes, $alg); + } + + /** @dataProvider provideHmac */ + public function testHmacKeyLengthValidationThrowsExceptionDecode(string $alg, int $minLength): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Provided key is too short'); + + $tooShortKeyBytes = str_repeat('b', $minLength - 1); + $payload = ['message' => 'abc']; + + $validKeyBytes = str_repeat('b', $minLength); + $encoded = JWT::encode($payload, $validKeyBytes, $alg); + + JWT::decode($encoded, new Key($tooShortKeyBytes, $alg)); + } + + /** @dataProvider provideHmac */ + public function testHmacKeyLengthValidationPassesWithCorrectLength(string $alg, int $minLength): void + { + $payload = ['message' => 'test hmac length']; + + // Test with a key that is exactly the required length + $minKeyBytes = str_repeat('b', $minLength); + $encoded48 = JWT::encode($payload, $minKeyBytes, $alg); + $decoded48 = JWT::decode($encoded48, new Key($minKeyBytes, $alg)); + $this->assertEquals($payload['message'], $decoded48->message); + + // Test with a key that is longer than the required length + $largeKeyBytes = str_repeat('c', $minLength * 2); // Longer than min bytes + $encoded64 = JWT::encode($payload, $largeKeyBytes, $alg); + $decoded64 = JWT::decode($encoded64, new Key($largeKeyBytes, $alg)); + $this->assertEquals($payload['message'], $decoded64->message); + } + + public function provideHmac() + { + return [ + ['HS384', 48], + ['HS256', 32], + ]; + } + + private function generateHmac256(): Key + { + return new Key(random_bytes(32), 'HS256'); } } From c03036fd5dbd530a95406ca3b5f6d7b24eaa3910 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 15 Dec 2025 12:26:43 -0700 Subject: [PATCH 11/14] chore(main): release 7.0.0 (#614) --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b5f6ce4..d08ecff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [7.0.0](https://github.com/firebase/php-jwt/compare/v6.11.1...v7.0.0) (2025-12-15) + + +### ⚠️ ⚠️ ⚠️ Security Fixes ⚠️ ⚠️ ⚠️ + * add key size validation ([#613](https://github.com/firebase/php-jwt/issues/613)) ([6b80341](https://github.com/firebase/php-jwt/commit/6b80341bf57838ea2d011487917337901cd71576)) + **NOTE**: This fix will cause keys with a size below the minimally allowed size to break. + +### Features + +* add SensitiveParameter attribute to security-critical parameters ([#603](https://github.com/firebase/php-jwt/issues/603)) ([4dbfac0](https://github.com/firebase/php-jwt/commit/4dbfac0260eeb0e9e643063c99998e3219cc539b)) +* store timestamp in `ExpiredException` ([#604](https://github.com/firebase/php-jwt/issues/604)) ([f174826](https://github.com/firebase/php-jwt/commit/f1748260d218a856b6a0c23715ac7fae1d7ca95b)) + + +### Bug Fixes + +* validate iat and nbf on payload ([#568](https://github.com/firebase/php-jwt/issues/568)) ([953b2c8](https://github.com/firebase/php-jwt/commit/953b2c88bb445b7e3bb82a5141928f13d7343afd)) + ## [6.11.1](https://github.com/firebase/php-jwt/compare/v6.11.0...v6.11.1) (2025-04-09) From 81ed59ef42f33a522b5891180df4c743222b8b92 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 15 Dec 2025 16:11:17 -0700 Subject: [PATCH 12/14] Add key size validation (#612) Co-authored-by: Marius Klocke --- README.md | 2 +- src/JWT.php | 45 +++++++++++++++++++++------------------------ src/Key.php | 7 +++---- tests/JWTTest.php | 7 +++---- 4 files changed, 28 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index e45ccb80..65b6c860 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ $passphrase = '[YOUR_PASSPHRASE]'; // Can be generated with "ssh-keygen -t rsa -m pem" $privateKeyFile = '/path/to/key-with-passphrase.pem'; -// Create a private key of type "resource" +/** @var OpenSSLAsymmetricKey $privateKey */ $privateKey = openssl_pkey_get_private( file_get_contents($privateKeyFile), $passphrase diff --git a/src/JWT.php b/src/JWT.php index dc564efc..3ead151b 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -198,7 +198,7 @@ public static function decode( * Converts and signs a PHP array into a JWT string. * * @param array $payload PHP array - * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. * @param string $alg Supported algorithms are 'ES384','ES256', 'ES256K', 'HS256', * 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' * @param string $keyId @@ -239,7 +239,7 @@ public static function encode( * Sign a string with a given key and algorithm. * * @param string $msg The message to sign - * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. * @param string $alg Supported algorithms are 'EdDSA', 'ES384', 'ES256', 'ES256K', 'HS256', * 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' * @@ -265,17 +265,13 @@ public static function sign( return \hash_hmac($algorithm, $msg, $key, true); case 'openssl': $signature = ''; - if (!\is_resource($key)) { - /** @var OpenSSLAsymmetricKey|OpenSSLCertificate|string $key */ - $key = $key; - if (!$key = openssl_pkey_get_private($key)) { - throw new DomainException('OpenSSL unable to validate key'); - } - if (str_starts_with($alg, 'RS')) { - self::validateRsaKeyLength($key); - } + if (!$key = openssl_pkey_get_private($key)) { + throw new DomainException('OpenSSL unable to validate key'); } - $success = \openssl_sign($msg, $signature, $key, $algorithm); // @phpstan-ignore-line + if (str_starts_with($alg, 'RS')) { + self::validateRsaKeyLength($key); + } + $success = \openssl_sign($msg, $signature, $key, $algorithm); if (!$success) { throw new DomainException('OpenSSL unable to sign data'); } @@ -314,7 +310,7 @@ public static function sign( * * @param string $msg The original message (header and body) * @param string $signature The original signature - * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial For Ed*, ES*, HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial For Ed*, ES*, HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey * @param string $alg The algorithm * * @return bool @@ -334,14 +330,13 @@ private static function verify( list($function, $algorithm) = static::$supported_algs[$alg]; switch ($function) { case 'openssl': - if (!\is_resource($keyMaterial) && str_starts_with($algorithm, 'RS')) { - /** @var OpenSSLAsymmetricKey|OpenSSLCertificate|string $keyMaterial */ - $keyMaterial = $keyMaterial; - if ($key = openssl_pkey_get_private($keyMaterial)) { - self::validateRsaKeyLength($key); + if (str_starts_with($algorithm, 'RS')) { + if (!$key = openssl_pkey_get_private($keyMaterial)) { + throw new DomainException('OpenSSL unable to validate key'); } + self::validateRsaKeyLength($key); } - $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); // @phpstan-ignore-line + $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); if ($success === 1) { return true; } @@ -699,6 +694,7 @@ private static function readDER(string $der, int $offset = 0): array * * @param string $key HMAC key material * @param string $algorithm The algorithm + * * @throws DomainException Provided key is too short */ private static function validateHmacKeyLength(string $key, string $algorithm): void @@ -716,12 +712,13 @@ private static function validateHmacKeyLength(string $key, string $algorithm): v * @param OpenSSLAsymmetricKey $key RSA key material * @throws DomainException Provided key is too short */ - private static function validateRsaKeyLength(OpenSSLAsymmetricKey $key): void + private static function validateRsaKeyLength(#[\SensitiveParameter] OpenSSLAsymmetricKey $key): void { - if ($keyDetails = \openssl_pkey_get_details($key)) { - if ($keyDetails['bits'] < self::RSA_KEY_MIN_LENGTH) { - throw new DomainException('Provided key is too short'); - } + if (!$keyDetails = openssl_pkey_get_details($key)) { + throw new DomainException('Unable to validate key'); + } + if ($keyDetails['bits'] < self::RSA_KEY_MIN_LENGTH) { + throw new DomainException('Provided key is too short'); } } } diff --git a/src/Key.php b/src/Key.php index 4db94851..694d3b13 100644 --- a/src/Key.php +++ b/src/Key.php @@ -10,7 +10,7 @@ class Key { /** - * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial * @param string $algorithm */ public function __construct( @@ -21,9 +21,8 @@ public function __construct( !\is_string($keyMaterial) && !$keyMaterial instanceof OpenSSLAsymmetricKey && !$keyMaterial instanceof OpenSSLCertificate - && !\is_resource($keyMaterial) ) { - throw new TypeError('Key material must be a string, resource, or OpenSSLAsymmetricKey'); + throw new TypeError('Key material must be a string, OpenSSLCertificate, or OpenSSLAsymmetricKey'); } if (empty($keyMaterial)) { @@ -46,7 +45,7 @@ public function getAlgorithm(): string } /** - * @return string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate + * @return string|OpenSSLAsymmetricKey|OpenSSLCertificate */ public function getKeyMaterial() { diff --git a/tests/JWTTest.php b/tests/JWTTest.php index d64d37be..d920a851 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -28,7 +28,6 @@ public function testUrlSafeCharacters() public function testMalformedUtf8StringsFail() { - $this->expectException(DomainException::class); JWT::encode(['message' => pack('c', 128)], $this->hmacKey->getKeyMaterial(), 'HS256'); } @@ -531,17 +530,17 @@ public function provideEncodeDecode() ]; } - public function testEncodeDecodeWithResource() + public function testEncodeDecodeWithOpenSSLAsymmetricKey() { $pem = file_get_contents(__DIR__ . '/data/rsa1-public.pub'); - $resource = openssl_pkey_get_public($pem); + $keyMaterial = openssl_pkey_get_public($pem); $privateKey = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); $payload = ['foo' => 'bar']; $encoded = JWT::encode($payload, $privateKey, 'RS512'); // Verify decoding succeeds - $decoded = JWT::decode($encoded, new Key($resource, 'RS512')); + $decoded = JWT::decode($encoded, new Key($keyMaterial, 'RS512')); $this->assertSame('bar', $decoded->foo); } From 7044f9ae7e7d175d28cca71714feb236f1c0e252 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 16 Dec 2025 12:59:01 -0800 Subject: [PATCH 13/14] fix: add key length validation for ec keys (#615) --- src/JWT.php | 32 +++++++++++++++--- tests/JWTTest.php | 59 +++++++++++++++++++++++++++++++++ tests/data/ecdsa192-private.pem | 5 +++ tests/data/ecdsa192-public.pem | 4 +++ 4 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 tests/data/ecdsa192-private.pem create mode 100644 tests/data/ecdsa192-public.pem diff --git a/src/JWT.php b/src/JWT.php index 3ead151b..c18e4cc0 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -270,6 +270,8 @@ public static function sign( } if (str_starts_with($alg, 'RS')) { self::validateRsaKeyLength($key); + } elseif (str_starts_with($alg, 'ES')) { + self::validateEcKeyLength($key, $alg); } $success = \openssl_sign($msg, $signature, $key, $algorithm); if (!$success) { @@ -330,11 +332,13 @@ private static function verify( list($function, $algorithm) = static::$supported_algs[$alg]; switch ($function) { case 'openssl': - if (str_starts_with($algorithm, 'RS')) { - if (!$key = openssl_pkey_get_private($keyMaterial)) { - throw new DomainException('OpenSSL unable to validate key'); - } + if (!$key = openssl_pkey_get_public($keyMaterial)) { + throw new DomainException('OpenSSL unable to validate key'); + } + if (str_starts_with($alg, 'RS')) { self::validateRsaKeyLength($key); + } elseif (str_starts_with($alg, 'ES')) { + self::validateEcKeyLength($key, $alg); } $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); if ($success === 1) { @@ -721,4 +725,24 @@ private static function validateRsaKeyLength(#[\SensitiveParameter] OpenSSLAsymm throw new DomainException('Provided key is too short'); } } + + /** + * Validate RSA key length + * + * @param OpenSSLAsymmetricKey $key RSA key material + * @param string $algorithm The algorithm + * @throws DomainException Provided key is too short + */ + private static function validateEcKeyLength( + #[\SensitiveParameter] OpenSSLAsymmetricKey $key, + string $algorithm + ): void { + if (!$keyDetails = openssl_pkey_get_details($key)) { + throw new DomainException('Unable to validate key'); + } + $minKeyLength = (int) \str_replace('ES', '', $algorithm); + if ($keyDetails['bits'] < $minKeyLength) { + throw new DomainException('Provided key is too short'); + } + } } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index d920a851..a1dd08a4 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -678,6 +678,65 @@ public function provideHmac() ]; } + /** @dataProvider provideEcKeyInvalidLength */ + public function testEcKeyLengthValidationThrowsExceptionEncode(string $keyFile, string $alg): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Provided key is too short'); + + $tooShortEcKey = file_get_contents(__DIR__ . '/data/' . $keyFile); + $payload = ['message' => 'abc']; + + JWT::encode($payload, $tooShortEcKey, $alg); + } + + public function testEcKeyLengthValidationThrowsExceptionDecode(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Provided key is too short'); + + $payload = ['message' => 'abc']; + + $validEcKeyBytes = file_get_contents(__DIR__ . '/data/ecdsa384-private.pem'); + $encoded = JWT::encode($payload, $validEcKeyBytes, 'ES256'); + + $tooShortEcKey = file_get_contents(__DIR__ . '/data/ecdsa192-public.pem'); + JWT::decode($encoded, new Key($tooShortEcKey, 'ES256')); + } + + /** @dataProvider provideEcKey */ + public function testEcKeyLengthValidationPassesWithCorrectLength( + string $privateKeyFile, + string $publicKeyFile, + string $alg + ): void { + $payload = ['message' => 'test hmac length']; + + // Test with a key that is the required length + $privateKeyBytes = file_get_contents(__DIR__ . '/data/' . $privateKeyFile); + $encoded48 = JWT::encode($payload, $privateKeyBytes, $alg); + + $publicKeyBytes = file_get_contents(__DIR__ . '/data/' . $publicKeyFile); + $decoded48 = JWT::decode($encoded48, new Key($publicKeyBytes, $alg)); + $this->assertEquals($payload['message'], $decoded48->message); + } + + public function provideEcKeyInvalidLength() + { + return [ + ['ecdsa192-private.pem', 'ES256'], + ['ecdsa-private.pem', 'ES384'], + ]; + } + + public function provideEcKey() + { + return [ + ['ecdsa-private.pem', 'ecdsa-public.pem', 'ES256'], + ['ecdsa384-private.pem', 'ecdsa384-public.pem', 'ES384'], + ]; + } + private function generateHmac256(): Key { return new Key(random_bytes(32), 'HS256'); diff --git a/tests/data/ecdsa192-private.pem b/tests/data/ecdsa192-private.pem new file mode 100644 index 00000000..4d9bc0b1 --- /dev/null +++ b/tests/data/ecdsa192-private.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MF8CAQEEGPRkK7lK/9FuZ3BE8ZX+dlHavL22Q9CN2KAKBggqhkjOPQMBAaE0AzIA +BL4pM50YcLq/I9Y8T+C+fwoOtwRW8zdV6yQmG9fD8zWaAs28+UxHeK8VD7THatbp +wg== +-----END EC PRIVATE KEY----- \ No newline at end of file diff --git a/tests/data/ecdsa192-public.pem b/tests/data/ecdsa192-public.pem new file mode 100644 index 00000000..d4aeee1d --- /dev/null +++ b/tests/data/ecdsa192-public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MEkwEwYHKoZIzj0CAQYIKoZIzj0DAQEDMgAEvikznRhwur8j1jxP4L5/Cg63BFbz +N1XrJCYb18PzNZoCzbz5TEd4rxUPtMdq1unC +-----END PUBLIC KEY----- From 5645b43af647b6947daac1d0f659dd1fbe8d3b65 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 16 Dec 2025 14:17:28 -0800 Subject: [PATCH 14/14] chore(main): release 7.0.2 (#616) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d08ecff8..26376076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [7.0.2](https://github.com/firebase/php-jwt/compare/v7.0.1...v7.0.2) (2025-12-16) + + +### Bug Fixes + +* add key length validation for ec keys ([#615](https://github.com/firebase/php-jwt/issues/615)) ([7044f9a](https://github.com/firebase/php-jwt/commit/7044f9ae7e7d175d28cca71714feb236f1c0e252)) + ## [7.0.0](https://github.com/firebase/php-jwt/compare/v6.11.1...v7.0.0) (2025-12-15)