Skip to content

Commit f9322a3

Browse files
authored
Merge branch 'main' into add-ec-jwk-support
2 parents 10e135a + 92aa12d commit f9322a3

File tree

5 files changed

+112
-72
lines changed

5 files changed

+112
-72
lines changed

.github/workflows/tests.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,17 @@ jobs:
4141
composer require friendsofphp/php-cs-fixer
4242
vendor/bin/php-cs-fixer fix --diff --dry-run .
4343
vendor/bin/php-cs-fixer fix --rules=native_function_invocation --allow-risky=yes --diff src
44+
45+
staticanalysis:
46+
runs-on: ubuntu-latest
47+
name: PHPStan Static Analysis
48+
steps:
49+
- uses: actions/checkout@v2
50+
- name: Install PHP
51+
uses: shivammathur/setup-php@v2
52+
with:
53+
php-version: '8.0'
54+
- name: Run Script
55+
run: |
56+
composer global require phpstan/phpstan
57+
~/.composer/vendor/bin/phpstan analyse

phpstan.neon.dist

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
parameters:
2+
level: 7
3+
paths:
4+
- src
5+
treatPhpDocTypesAsCertain: false

src/JWK.php

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class JWK
3434
/**
3535
* Parse a set of JWK keys
3636
*
37-
* @param array $jwks The JSON Web Key Set as an associative array
37+
* @param array<mixed> $jwks The JSON Web Key Set as an associative array
3838
*
3939
* @return array<string, Key> An associative array of key IDs (kid) to Key objects
4040
*
@@ -59,7 +59,7 @@ public static function parseKeySet(array $jwks): array
5959
foreach ($jwks['keys'] as $k => $v) {
6060
$kid = isset($v['kid']) ? $v['kid'] : $k;
6161
if ($key = self::parseKey($v)) {
62-
$keys[$kid] = $key;
62+
$keys[(string) $kid] = $key;
6363
}
6464
}
6565

@@ -73,7 +73,7 @@ public static function parseKeySet(array $jwks): array
7373
/**
7474
* Parse a JWK key
7575
*
76-
* @param array $jwk An individual JWK
76+
* @param array<mixed> $jwk An individual JWK
7777
*
7878
* @return Key The key object for the JWK
7979
*
@@ -194,10 +194,16 @@ private static function createPemFromCrvAndXYCoordinates(string $crv, string $x,
194194
*
195195
* @uses encodeLength
196196
*/
197-
private static function createPemFromModulusAndExponent(string $n, string $e): string
198-
{
199-
$modulus = JWT::urlsafeB64Decode($n);
200-
$publicExponent = JWT::urlsafeB64Decode($e);
197+
private static function createPemFromModulusAndExponent(
198+
string $n,
199+
string $e
200+
): string {
201+
if (false === ($modulus = JWT::urlsafeB64Decode($n))) {
202+
throw new UnexpectedValueException('Invalid JWK encoding');
203+
}
204+
if (false === ($publicExponent = JWT::urlsafeB64Decode($e))) {
205+
throw new UnexpectedValueException('Invalid header encoding');
206+
}
201207

202208
$components = [
203209
'modulus' => \pack('Ca*a*', 2, self::encodeLength(\strlen($modulus)), $modulus),

src/JWT.php

Lines changed: 75 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
use Exception;
88
use InvalidArgumentException;
99
use OpenSSLAsymmetricKey;
10+
use OpenSSLCertificate;
11+
use TypeError;
1012
use UnexpectedValueException;
1113
use DateTime;
1214
use stdClass;
@@ -37,6 +39,9 @@ class JWT
3739
*/
3840
public static int $leeway = 0;
3941

42+
/**
43+
* @var array<string, string[]>
44+
*/
4045
public static array $supported_algs = [
4146
'ES384' => ['openssl', 'SHA384'],
4247
'ES256' => ['openssl', 'SHA256'],
@@ -85,10 +90,16 @@ public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray)
8590
throw new UnexpectedValueException('Wrong number of segments');
8691
}
8792
list($headb64, $bodyb64, $cryptob64) = $tks;
88-
if (null === ($header = static::jsonDecode(static::urlsafeB64Decode($headb64)))) {
93+
if (false === ($headerRaw = static::urlsafeB64Decode($headb64))) {
94+
throw new UnexpectedValueException('Invalid header encoding');
95+
}
96+
if (null === ($header = static::jsonDecode($headerRaw))) {
8997
throw new UnexpectedValueException('Invalid header encoding');
9098
}
91-
if (null === $payload = static::jsonDecode(static::urlsafeB64Decode($bodyb64))) {
99+
if (false === ($payloadRaw = static::urlsafeB64Decode($bodyb64))) {
100+
throw new UnexpectedValueException('Invalid claims encoding');
101+
}
102+
if (null === ($payload = static::jsonDecode($payloadRaw))) {
92103
throw new UnexpectedValueException('Invalid claims encoding');
93104
}
94105
if (!$payload instanceof stdClass) {
@@ -115,7 +126,7 @@ public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray)
115126
// OpenSSL expects an ASN.1 DER sequence for ES256/ES384 signatures
116127
$sig = self::signatureToDER($sig);
117128
}
118-
if (!static::verify("$headb64.$bodyb64", $sig, $key->getKeyMaterial(), $header->alg)) {
129+
if (!self::verify("$headb64.$bodyb64", $sig, $key->getKeyMaterial(), $header->alg)) {
119130
throw new SignatureInvalidException('Signature verification failed');
120131
}
121132

@@ -147,11 +158,10 @@ public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray)
147158
/**
148159
* Converts and signs a PHP object or array into a JWT string.
149160
*
150-
* @param array $payload PHP array
151-
* @param string|OpenSSLAsymmetricKey $key The secret key.
152-
* If the algorithm used is asymmetric, this is the private key
153-
* @param string $keyId
154-
* @param array $head An array with header elements to attach
161+
* @param array<mixed> $payload PHP array
162+
* @param string|OpenSSLAsymmetricKey|OpenSSLCertificate|array<mixed> $key The secret key.
163+
* @param string $keyId
164+
* @param array<string, string> $head An array with header elements to attach
155165
*
156166
* @return string A signed JWT
157167
*
@@ -160,7 +170,7 @@ public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray)
160170
*/
161171
public static function encode(
162172
array $payload,
163-
string|OpenSSLAsymmetricKey $key,
173+
string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $key,
164174
string $alg,
165175
string $keyId = null,
166176
array $head = null
@@ -173,8 +183,8 @@ public static function encode(
173183
$header = \array_merge($head, $header);
174184
}
175185
$segments = [];
176-
$segments[] = static::urlsafeB64Encode(static::jsonEncode($header));
177-
$segments[] = static::urlsafeB64Encode(static::jsonEncode($payload));
186+
$segments[] = static::urlsafeB64Encode((string) static::jsonEncode($header));
187+
$segments[] = static::urlsafeB64Encode((string) static::jsonEncode($payload));
178188
$signing_input = \implode('.', $segments);
179189

180190
$signature = static::sign($signing_input, $key, $alg);
@@ -186,23 +196,29 @@ public static function encode(
186196
/**
187197
* Sign a string with a given key and algorithm.
188198
*
189-
* @param string $msg The message to sign
190-
* @param string|OpenSSLAsymmetricKey $key The secret key.
191-
* @param string $alg Supported algorithms are 'ES384','ES256', 'HS256', 'HS384',
192-
* 'HS512', 'RS256', 'RS384', and 'RS512'
199+
* @param string $msg The message to sign
200+
* @param string|OpenSSLAsymmetricKey|OpenSSLCertificate|array<mixed> $key The secret key.
201+
* @param string $alg Supported algorithms are 'ES384','ES256', 'HS256', 'HS384',
202+
* 'HS512', 'RS256', 'RS384', and 'RS512'
193203
*
194204
* @return string An encrypted message
195205
*
196206
* @throws DomainException Unsupported algorithm or bad key was specified
197207
*/
198-
public static function sign(string $msg, string|OpenSSLAsymmetricKey $key, string $alg): string
199-
{
208+
public static function sign(
209+
string $msg,
210+
string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $key,
211+
string $alg
212+
): string {
200213
if (empty(static::$supported_algs[$alg])) {
201214
throw new DomainException('Algorithm not supported');
202215
}
203216
list($function, $algorithm) = static::$supported_algs[$alg];
204217
switch ($function) {
205218
case 'hash_hmac':
219+
if (!is_string($key)) {
220+
throw new InvalidArgumentException('key must be a string when using hmac');
221+
}
206222
return \hash_hmac($algorithm, $msg, $key, true);
207223
case 'openssl':
208224
$signature = '';
@@ -220,25 +236,30 @@ public static function sign(string $msg, string|OpenSSLAsymmetricKey $key, strin
220236
if (!function_exists('sodium_crypto_sign_detached')) {
221237
throw new DomainException('libsodium is not available');
222238
}
239+
if (!is_string($key)) {
240+
throw new InvalidArgumentException('key must be a string when using EdDSA');
241+
}
223242
try {
224243
// The last non-empty line is used as the key.
225244
$lines = array_filter(explode("\n", $key));
226-
$key = base64_decode(end($lines));
245+
$key = base64_decode((string) end($lines));
227246
return sodium_crypto_sign_detached($msg, $key);
228247
} catch (Exception $e) {
229248
throw new DomainException($e->getMessage(), 0, $e);
230249
}
231250
}
251+
252+
throw new DomainException('Algorithm not supported');
232253
}
233254

234255
/**
235256
* Verify a signature with the message, key and method. Not all methods
236257
* are symmetric, so we must have a separate verify and sign method.
237258
*
238-
* @param string $msg The original message (header and body)
239-
* @param string $signature The original signature
240-
* @param string|OpenSSLAsymmetricKey $key For HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey
241-
* @param string $alg The algorithm
259+
* @param string $msg The original message (header and body)
260+
* @param string $signature The original signature
261+
* @param string|OpenSSLAsymmetricKey|OpenSSLCertificate|array<mixed> $keyMaterial For HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey
262+
* @param string $alg The algorithm
242263
*
243264
* @return bool
244265
*
@@ -247,7 +268,7 @@ public static function sign(string $msg, string|OpenSSLAsymmetricKey $key, strin
247268
private static function verify(
248269
string $msg,
249270
string $signature,
250-
string|OpenSSLAsymmetricKey $keyMaterial,
271+
string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $keyMaterial,
251272
string $alg
252273
): bool {
253274
if (empty(static::$supported_algs[$alg])) {
@@ -271,16 +292,22 @@ private static function verify(
271292
if (!function_exists('sodium_crypto_sign_verify_detached')) {
272293
throw new DomainException('libsodium is not available');
273294
}
295+
if (!is_string($keyMaterial)) {
296+
throw new InvalidArgumentException('key must be a string when using EdDSA');
297+
}
274298
try {
275299
// The last non-empty line is used as the key.
276300
$lines = array_filter(explode("\n", $keyMaterial));
277-
$key = base64_decode(end($lines));
301+
$key = base64_decode((string) end($lines));
278302
return sodium_crypto_sign_verify_detached($signature, $msg, $key);
279303
} catch (Exception $e) {
280304
throw new DomainException($e->getMessage(), 0, $e);
281305
}
282306
case 'hash_hmac':
283307
default:
308+
if (!is_string($keyMaterial)) {
309+
throw new InvalidArgumentException('key must be a string when using hmac');
310+
}
284311
$hash = \hash_hmac($algorithm, $msg, $keyMaterial, true);
285312
return self::constantTimeEquals($hash, $signature);
286313
}
@@ -300,7 +327,7 @@ public static function jsonDecode(string $input): mixed
300327
$obj = \json_decode($input, false, 512, JSON_BIGINT_AS_STRING);
301328

302329
if ($errno = \json_last_error()) {
303-
static::handleJsonError($errno);
330+
self::handleJsonError($errno);
304331
} elseif ($obj === null && $input !== 'null') {
305332
throw new DomainException('Null result with non-null input');
306333
}
@@ -310,13 +337,13 @@ public static function jsonDecode(string $input): mixed
310337
/**
311338
* Encode a PHP array into a JSON string.
312339
*
313-
* @param array $input A PHP array
340+
* @param array<mixed> $input A PHP array
314341
*
315-
* @return string JSON representation of the PHP array
342+
* @return string|false JSON representation of the PHP array
316343
*
317344
* @throws DomainException Provided object could not be encoded to valid JSON
318345
*/
319-
public static function jsonEncode(array $input): string
346+
public static function jsonEncode(array $input): string|false
320347
{
321348
if (PHP_VERSION_ID >= 50400) {
322349
$json = \json_encode($input, \JSON_UNESCAPED_SLASHES);
@@ -325,7 +352,7 @@ public static function jsonEncode(array $input): string
325352
$json = \json_encode($input);
326353
}
327354
if ($errno = \json_last_error()) {
328-
static::handleJsonError($errno);
355+
self::handleJsonError($errno);
329356
} elseif ($json === 'null' && $input !== null) {
330357
throw new DomainException('Null result with non-null input');
331358
}
@@ -339,7 +366,7 @@ public static function jsonEncode(array $input): string
339366
*
340367
* @return string A decoded string
341368
*/
342-
public static function urlsafeB64Decode(string $input): string
369+
public static function urlsafeB64Decode(string $input): string|false
343370
{
344371
$remainder = \strlen($input) % 4;
345372
if ($remainder) {
@@ -378,29 +405,22 @@ private static function getKey(Key|array|ArrayAccess $keyOrKeyArray, ?string $ki
378405
return $keyOrKeyArray;
379406
}
380407

381-
if (is_array($keyOrKeyArray) || $keyOrKeyArray instanceof ArrayAccess) {
382-
foreach ($keyOrKeyArray as $keyId => $key) {
383-
if (!$key instanceof Key) {
384-
throw new TypeError(
385-
'$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an '
386-
. 'array of Firebase\JWT\Key keys'
387-
);
388-
}
389-
}
390-
if (!isset($kid)) {
391-
throw new UnexpectedValueException('"kid" empty, unable to lookup correct key');
392-
}
393-
if (!isset($keyOrKeyArray[$kid])) {
394-
throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key');
408+
foreach ($keyOrKeyArray as $keyId => $key) {
409+
if (!$key instanceof Key) {
410+
throw new TypeError(
411+
'$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an '
412+
. 'array of Firebase\JWT\Key keys'
413+
);
395414
}
396-
397-
return $keyOrKeyArray[$kid];
415+
}
416+
if (!isset($kid)) {
417+
throw new UnexpectedValueException('"kid" empty, unable to lookup correct key');
418+
}
419+
if (!isset($keyOrKeyArray[$kid])) {
420+
throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key');
398421
}
399422

400-
throw new UnexpectedValueException(
401-
'$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an '
402-
. 'array of Firebase\JWT\Key keys'
403-
);
423+
return $keyOrKeyArray[$kid];
404424
}
405425

406426
/**
@@ -413,13 +433,13 @@ public static function constantTimeEquals(string $left, string $right): bool
413433
if (\function_exists('hash_equals')) {
414434
return \hash_equals($left, $right);
415435
}
416-
$len = \min(static::safeStrlen($left), static::safeStrlen($right));
436+
$len = \min(self::safeStrlen($left), self::safeStrlen($right));
417437

418438
$status = 0;
419439
for ($i = 0; $i < $len; $i++) {
420440
$status |= (\ord($left[$i]) ^ \ord($right[$i]));
421441
}
422-
$status |= (static::safeStrlen($left) ^ static::safeStrlen($right));
442+
$status |= (self::safeStrlen($left) ^ self::safeStrlen($right));
423443

424444
return ($status === 0);
425445
}
@@ -473,7 +493,8 @@ private static function safeStrlen(string $str): int
473493
private static function signatureToDER(string $sig): string
474494
{
475495
// Separate the signature into r-value and s-value
476-
list($r, $s) = \str_split($sig, (int) (\strlen($sig) / 2));
496+
$length = max(1, (int) (\strlen($sig) / 2));
497+
list($r, $s) = \str_split($sig, $length > 0 ? $length : 1);
477498

478499
// Trim leading zeros
479500
$r = \ltrim($r, "\x00");
@@ -553,7 +574,7 @@ private static function signatureFromDER(string $der, int $keySize): string
553574
* @param int $offset the offset of the data stream containing the object
554575
* to decode
555576
*
556-
* @return array [$offset, $data] the new offset and the decoded object
577+
* @return array{int, string|null} the new offset and the decoded object
557578
*/
558579
private static function readDER(string $der, int $offset = 0): array
559580
{

0 commit comments

Comments
 (0)