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 90f62ca9..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,20 +284,8 @@ 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 = base64_decode((string) end($lines)); - if (\strlen($key) === 0) { - throw new DomainException('Key cannot be empty string'); - } - return sodium_crypto_sign_detached($msg, $key); + return sodium_crypto_sign_detached($msg, self::validateEdDSAKey($key)); } catch (Exception $e) { throw new DomainException($e->getMessage(), 0, $e); } @@ -352,19 +340,8 @@ 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 = base64_decode((string) end($lines)); - if (\strlen($key) === 0) { - throw new DomainException('Key cannot be empty string'); - } + $key = self::validateEdDSAKey($keyMaterial); if (\strlen($signature) === 0) { throw new DomainException('Signature cannot be empty string'); } @@ -473,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 * @@ -745,4 +721,25 @@ private static function validateEcKeyLength( throw new DomainException('Provided key is too short'); } } + + /** + * @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'); + } + // 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 a1dd08a4..6832655f 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 {