Skip to content

Commit b889495

Browse files
authored
fix: use urlsafeB64Decode everywhere (#627)
1 parent 510a00c commit b889495

3 files changed

Lines changed: 60 additions & 28 deletions

File tree

src/CachedKeySet.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,8 @@ private function keyIdExists(string $keyId): bool
180180
$jwksResponse = $this->httpClient->sendRequest($request);
181181
if ($jwksResponse->getStatusCode() !== 200) {
182182
throw new UnexpectedValueException(
183-
\sprintf('HTTP Error: %d %s for URI "%s"',
183+
\sprintf(
184+
'HTTP Error: %d %s for URI "%s"',
184185
$jwksResponse->getStatusCode(),
185186
$jwksResponse->getReasonPhrase(),
186187
$this->jwksUri,

src/JWT.php

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class JWT
3131
private const ASN1_SEQUENCE = 0x10;
3232
private const ASN1_BIT_STRING = 0x03;
3333

34-
private const RSA_KEY_MIN_LENGTH=2048;
34+
private const RSA_KEY_MIN_LENGTH = 2048;
3535

3636
/**
3737
* When checking nbf, iat or expiration times,
@@ -284,20 +284,8 @@ public static function sign(
284284
}
285285
return $signature;
286286
case 'sodium_crypto':
287-
if (!\function_exists('sodium_crypto_sign_detached')) {
288-
throw new DomainException('libsodium is not available');
289-
}
290-
if (!\is_string($key)) {
291-
throw new InvalidArgumentException('key must be a string when using EdDSA');
292-
}
293287
try {
294-
// The last non-empty line is used as the key.
295-
$lines = array_filter(explode("\n", $key));
296-
$key = base64_decode((string) end($lines));
297-
if (\strlen($key) === 0) {
298-
throw new DomainException('Key cannot be empty string');
299-
}
300-
return sodium_crypto_sign_detached($msg, $key);
288+
return sodium_crypto_sign_detached($msg, self::validateEdDSAKey($key));
301289
} catch (Exception $e) {
302290
throw new DomainException($e->getMessage(), 0, $e);
303291
}
@@ -352,19 +340,8 @@ private static function verify(
352340
'OpenSSL error: ' . \openssl_error_string()
353341
);
354342
case 'sodium_crypto':
355-
if (!\function_exists('sodium_crypto_sign_verify_detached')) {
356-
throw new DomainException('libsodium is not available');
357-
}
358-
if (!\is_string($keyMaterial)) {
359-
throw new InvalidArgumentException('key must be a string when using EdDSA');
360-
}
361343
try {
362-
// The last non-empty line is used as the key.
363-
$lines = array_filter(explode("\n", $keyMaterial));
364-
$key = base64_decode((string) end($lines));
365-
if (\strlen($key) === 0) {
366-
throw new DomainException('Key cannot be empty string');
367-
}
344+
$key = self::validateEdDSAKey($keyMaterial);
368345
if (\strlen($signature) === 0) {
369346
throw new DomainException('Signature cannot be empty string');
370347
}
@@ -473,7 +450,6 @@ public static function urlsafeB64Encode(string $input): string
473450
return \str_replace('=', '', \strtr(\base64_encode($input), '+/', '-_'));
474451
}
475452

476-
477453
/**
478454
* Determine if an algorithm has been provided for each Key
479455
*
@@ -745,4 +721,25 @@ private static function validateEcKeyLength(
745721
throw new DomainException('Provided key is too short');
746722
}
747723
}
724+
725+
/**
726+
* @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial
727+
* @return non-empty-string
728+
*/
729+
private static function validateEdDSAKey(#[\SensitiveParameter] $keyMaterial): string
730+
{
731+
if (!\function_exists('sodium_crypto_sign_verify_detached')) {
732+
throw new DomainException('libsodium is not available');
733+
}
734+
if (!\is_string($keyMaterial)) {
735+
throw new InvalidArgumentException('key must be a string when using EdDSA');
736+
}
737+
// The last non-empty line is used as the key.
738+
$lines = array_filter(explode("\n", $keyMaterial));
739+
$key = self::urlsafeB64Decode((string) end($lines));
740+
if (\strlen($key) === 0) {
741+
throw new DomainException('Key cannot be empty string');
742+
}
743+
return $key;
744+
}
748745
}

tests/JWTTest.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,40 @@ public function provideHmac()
678678
];
679679
}
680680

681+
public function testEdDsaHandlesBase64UrlKeys()
682+
{
683+
if (!\extension_loaded('sodium')) {
684+
$this->markTestSkipped('libsodium is not available');
685+
}
686+
687+
// Generate a deterministic Ed25519 keypair using a specific seed. The byte "\xfb"
688+
// translates to '+' and '/' in standard base64, which become '-' and '_' in Base64URL.
689+
// This guarantees our keys will contain the URL-safe characters that get incorrectly
690+
// stripped by base64_decode().
691+
$seed = str_repeat("\xfb", 32);
692+
$keyPair = sodium_crypto_sign_seed_keypair($seed);
693+
694+
$secretKey = sodium_crypto_sign_secretkey($keyPair);
695+
$publicKey = sodium_crypto_sign_publickey($keyPair);
696+
697+
// Convert the raw keys to Base64URL encoded strings
698+
$secretKeyB64u = JWT::urlsafeB64Encode($secretKey);
699+
$publicKeyB64u = JWT::urlsafeB64Encode($publicKey);
700+
701+
// Ensure our test keys actually contain the characters that get
702+
// incorrectly stripped by a standard base64_decode().
703+
$this->assertTrue(strpos($secretKeyB64u, '-') !== false || strpos($secretKeyB64u, '_') !== false);
704+
$this->assertTrue(strpos($publicKeyB64u, '-') !== false || strpos($publicKeyB64u, '_') !== false);
705+
706+
// Test Encoding
707+
$token = JWT::encode(['issue' => 596], $secretKeyB64u, 'EdDSA');
708+
$this->assertIsString($token);
709+
710+
// Test Decoding
711+
$decoded = JWT::decode($token, new Key($publicKeyB64u, 'EdDSA'));
712+
$this->assertSame(596, $decoded->issue);
713+
}
714+
681715
/** @dataProvider provideEcKeyInvalidLength */
682716
public function testEcKeyLengthValidationThrowsExceptionEncode(string $keyFile, string $alg): void
683717
{

0 commit comments

Comments
 (0)