From 871803e2aa94916a7743dfde79e13dbb5f59f8af Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 23 Mar 2026 16:33:10 -0700 Subject: [PATCH 1/4] fix: use urlsafeB64Decode everywhere --- src/JWT.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index 90f62ca9..e36040b3 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -293,7 +293,7 @@ public static function sign( try { // The last non-empty line is used as the key. $lines = array_filter(explode("\n", $key)); - $key = base64_decode((string) end($lines)); + $key = self::urlsafeB64Decode((string) end($lines)); if (\strlen($key) === 0) { throw new DomainException('Key cannot be empty string'); } @@ -361,7 +361,7 @@ private static function verify( try { // The last non-empty line is used as the key. $lines = array_filter(explode("\n", $keyMaterial)); - $key = base64_decode((string) end($lines)); + $key = self::urlsafeB64Decode((string) end($lines)); if (\strlen($key) === 0) { throw new DomainException('Key cannot be empty string'); } From be69a1ab60103cd15c07008a89b870ca014d319e Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 24 Mar 2026 12:46:17 -0700 Subject: [PATCH 2/4] follow gemini's advice --- src/JWT.php | 68 ++++++++++++++++++++++++----------------------------- 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index e36040b3..be55b24e 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -284,23 +284,7 @@ public static function sign( } return $signature; case 'sodium_crypto': - if (!\function_exists('sodium_crypto_sign_detached')) { - throw new DomainException('libsodium is not available'); - } - if (!\is_string($key)) { - throw new InvalidArgumentException('key must be a string when using EdDSA'); - } - try { - // The last non-empty line is used as the key. - $lines = array_filter(explode("\n", $key)); - $key = self::urlsafeB64Decode((string) end($lines)); - if (\strlen($key) === 0) { - throw new DomainException('Key cannot be empty string'); - } - return sodium_crypto_sign_detached($msg, $key); - } catch (Exception $e) { - throw new DomainException($e->getMessage(), 0, $e); - } + return self::parseEdDSA($msg, $key); } throw new DomainException('Algorithm not supported'); @@ -352,26 +336,7 @@ private static function verify( 'OpenSSL error: ' . \openssl_error_string() ); case 'sodium_crypto': - if (!\function_exists('sodium_crypto_sign_verify_detached')) { - throw new DomainException('libsodium is not available'); - } - if (!\is_string($keyMaterial)) { - throw new InvalidArgumentException('key must be a string when using EdDSA'); - } - try { - // The last non-empty line is used as the key. - $lines = array_filter(explode("\n", $keyMaterial)); - $key = self::urlsafeB64Decode((string) end($lines)); - if (\strlen($key) === 0) { - throw new DomainException('Key cannot be empty string'); - } - if (\strlen($signature) === 0) { - throw new DomainException('Signature cannot be empty string'); - } - return sodium_crypto_sign_verify_detached($signature, $msg, $key); - } catch (Exception $e) { - throw new DomainException($e->getMessage(), 0, $e); - } + return self::parseEdDSA($msg, $keyMaterial, $signature); case 'hash_hmac': default: if (!\is_string($keyMaterial)) { @@ -745,4 +710,33 @@ private static function validateEcKeyLength( throw new DomainException('Provided key is too short'); } } + + private static function parseEdDSA( + string $msg, + #[\SensitiveParameter] $keyMaterial, + ?string $signature = null + ) { + if (!\function_exists('sodium_crypto_sign_verify_detached')) { + throw new DomainException('libsodium is not available'); + } + if (!\is_string($keyMaterial)) { + throw new InvalidArgumentException('key must be a string when using EdDSA'); + } + try { + // The last non-empty line is used as the key. + $lines = array_filter(explode("\n", $keyMaterial)); + $key = self::urlsafeB64Decode((string) end($lines)); + if (\strlen($key) === 0) { + throw new DomainException('Key cannot be empty string'); + } + if (!is_null($signature) && \strlen($signature) === 0) { + throw new DomainException('Signature cannot be empty string'); + } + return $signature + ? sodium_crypto_sign_verify_detached($signature, $msg, $key) + : sodium_crypto_sign_detached($msg, $key); + } catch (Exception $e) { + throw new DomainException($e->getMessage(), 0, $e); + } + } } From 6edddd47b6c33de2fcd6cbfffcb8e8db2eb5e655 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 24 Mar 2026 13:01:47 -0700 Subject: [PATCH 3/4] add test --- tests/JWTTest.php | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/JWTTest.php b/tests/JWTTest.php index a1dd08a4..e6ea568f 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -678,6 +678,40 @@ public function provideHmac() ]; } + public function testEdDsaHandlesBase64UrlKeys() + { + if (!extension_loaded('sodium')) { + $this->markTestSkipped('libsodium is not available'); + } + + // Generate a deterministic Ed25519 keypair using a specific seed. The byte "\xfb" + // translates to '+' and '/' in standard base64, which become '-' and '_' in Base64URL. + // This guarantees our keys will contain the URL-safe characters that get incorrectly + // stripped by base64_decode(). + $seed = str_repeat("\xfb", 32); + $keyPair = sodium_crypto_sign_seed_keypair($seed); + + $secretKey = sodium_crypto_sign_secretkey($keyPair); + $publicKey = sodium_crypto_sign_publickey($keyPair); + + // Convert the raw keys to Base64URL encoded strings + $secretKeyB64u = JWT::urlsafeB64Encode($secretKey); + $publicKeyB64u = JWT::urlsafeB64Encode($publicKey); + + // Ensure our test keys actually contain the characters that get + // incorrectly stripped by a standard base64_decode(). + $this->assertTrue(strpos($secretKeyB64u, '-') !== false || strpos($secretKeyB64u, '_') !== false); + $this->assertTrue(strpos($publicKeyB64u, '-') !== false || strpos($publicKeyB64u, '_') !== false); + + // Test Encoding + $token = JWT::encode(['issue' => 596], $secretKeyB64u, 'EdDSA'); + $this->assertIsString($token); + + // Test Decoding + $decoded = JWT::decode($token, new Key($publicKeyB64u, 'EdDSA')); + $this->assertSame(596, $decoded->issue); + } + /** @dataProvider provideEcKeyInvalidLength */ public function testEcKeyLengthValidationThrowsExceptionEncode(string $keyFile, string $alg): void { From a99df6f9206ede2ce3c07e50f80c69c48ff02483 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 24 Mar 2026 13:15:17 -0700 Subject: [PATCH 4/4] cs fix --- src/CachedKeySet.php | 3 ++- src/JWT.php | 51 +++++++++++++++++++++++--------------------- tests/JWTTest.php | 2 +- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/src/CachedKeySet.php b/src/CachedKeySet.php index 8e8e8d68..37c3f94d 100644 --- a/src/CachedKeySet.php +++ b/src/CachedKeySet.php @@ -180,7 +180,8 @@ private function keyIdExists(string $keyId): bool $jwksResponse = $this->httpClient->sendRequest($request); if ($jwksResponse->getStatusCode() !== 200) { throw new UnexpectedValueException( - \sprintf('HTTP Error: %d %s for URI "%s"', + \sprintf( + 'HTTP Error: %d %s for URI "%s"', $jwksResponse->getStatusCode(), $jwksResponse->getReasonPhrase(), $this->jwksUri, diff --git a/src/JWT.php b/src/JWT.php index be55b24e..0d2e47c9 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -31,7 +31,7 @@ class JWT private const ASN1_SEQUENCE = 0x10; private const ASN1_BIT_STRING = 0x03; - private const RSA_KEY_MIN_LENGTH=2048; + private const RSA_KEY_MIN_LENGTH = 2048; /** * When checking nbf, iat or expiration times, @@ -284,7 +284,11 @@ public static function sign( } return $signature; case 'sodium_crypto': - return self::parseEdDSA($msg, $key); + try { + return sodium_crypto_sign_detached($msg, self::validateEdDSAKey($key)); + } catch (Exception $e) { + throw new DomainException($e->getMessage(), 0, $e); + } } throw new DomainException('Algorithm not supported'); @@ -336,7 +340,15 @@ private static function verify( 'OpenSSL error: ' . \openssl_error_string() ); case 'sodium_crypto': - return self::parseEdDSA($msg, $keyMaterial, $signature); + try { + $key = self::validateEdDSAKey($keyMaterial); + if (\strlen($signature) === 0) { + throw new DomainException('Signature cannot be empty string'); + } + return sodium_crypto_sign_verify_detached($signature, $msg, $key); + } catch (Exception $e) { + throw new DomainException($e->getMessage(), 0, $e); + } case 'hash_hmac': default: if (!\is_string($keyMaterial)) { @@ -438,7 +450,6 @@ public static function urlsafeB64Encode(string $input): string return \str_replace('=', '', \strtr(\base64_encode($input), '+/', '-_')); } - /** * Determine if an algorithm has been provided for each Key * @@ -711,32 +722,24 @@ private static function validateEcKeyLength( } } - private static function parseEdDSA( - string $msg, - #[\SensitiveParameter] $keyMaterial, - ?string $signature = null - ) { + /** + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial + * @return non-empty-string + */ + private static function validateEdDSAKey(#[\SensitiveParameter] $keyMaterial): string + { if (!\function_exists('sodium_crypto_sign_verify_detached')) { throw new DomainException('libsodium is not available'); } if (!\is_string($keyMaterial)) { throw new InvalidArgumentException('key must be a string when using EdDSA'); } - try { - // The last non-empty line is used as the key. - $lines = array_filter(explode("\n", $keyMaterial)); - $key = self::urlsafeB64Decode((string) end($lines)); - if (\strlen($key) === 0) { - throw new DomainException('Key cannot be empty string'); - } - if (!is_null($signature) && \strlen($signature) === 0) { - throw new DomainException('Signature cannot be empty string'); - } - return $signature - ? sodium_crypto_sign_verify_detached($signature, $msg, $key) - : sodium_crypto_sign_detached($msg, $key); - } catch (Exception $e) { - throw new DomainException($e->getMessage(), 0, $e); + // The last non-empty line is used as the key. + $lines = array_filter(explode("\n", $keyMaterial)); + $key = self::urlsafeB64Decode((string) end($lines)); + if (\strlen($key) === 0) { + throw new DomainException('Key cannot be empty string'); } + return $key; } } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index e6ea568f..6832655f 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -680,7 +680,7 @@ public function provideHmac() public function testEdDsaHandlesBase64UrlKeys() { - if (!extension_loaded('sodium')) { + if (!\extension_loaded('sodium')) { $this->markTestSkipped('libsodium is not available'); }