Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/CachedKeySet.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question:
Why do we use a \ here? Shouldn't this be available without it?

Copy link
Copy Markdown

@Krisell Krisell Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a common convention (and micro-optimization) in php. It tells the parser to not look for a sprintf function defined in the Firebase\JWT namespace but uses the global function directly.

'HTTP Error: %d %s for URI "%s"',
$jwksResponse->getStatusCode(),
$jwksResponse->getReasonPhrase(),
$this->jwksUri,
Expand Down
51 changes: 24 additions & 27 deletions src/JWT.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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;
}
}
34 changes: 34 additions & 0 deletions tests/JWTTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Loading