Skip to content

Commit bc93723

Browse files
NinosNinos Ego
authored andcommitted
feat(alg): Add ES512 support
1 parent 43d70ae commit bc93723

14 files changed

Lines changed: 253 additions & 44 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
vendor
2+
node_modules
23
phpunit.phar
34
phpunit.phar.asc
45
composer.phar

Containerfile

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
ARG PHP_VERSION=8.5
2+
ARG COMPOSER_VERSION=latest
3+
ARG NODE_VERSION=current
4+
5+
FROM docker.io/composer:${COMPOSER_VERSION} AS composer
6+
7+
FROM docker.io/node:${NODE_VERSION}-alpine AS node
8+
9+
FROM docker.io/php:${PHP_VERSION}-fpm-alpine AS app
10+
11+
WORKDIR /srv/app
12+
13+
# Update base
14+
RUN apk update && apk upgrade
15+
16+
# persistent / runtime deps
17+
RUN apk add --no-cache \
18+
openssl \
19+
;
20+
21+
# TODO: Remove hardcoded imagick version after stable-release
22+
RUN set -eux; \
23+
apk add --no-cache --virtual .build-deps \
24+
$PHPIZE_DEPS \
25+
libsodium-dev \
26+
; \
27+
\
28+
docker-php-ext-install -j$(nproc) \
29+
sodium \
30+
; \
31+
\
32+
runDeps="$( \
33+
scanelf --needed --nobanner --format '%n#p' --recursive /usr/local/lib/php/extensions \
34+
| tr ',' '\n' \
35+
| sort -u \
36+
| awk 'system("[ -e /usr/local/lib/" $1 " ]") == 0 { next } { print "so:" $1 }' \
37+
)"; \
38+
apk add --no-cache --virtual .app-phpexts-rundeps $runDeps; \
39+
\
40+
apk del .build-deps
41+
42+
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
43+
44+
# Add dev-tools
45+
COPY --from=composer /usr/bin/composer /usr/bin/composer
46+
ENV PATH="${PATH}:/root/.composer/vendor/bin:/srv/app/vendor/bin"
47+
COPY --from=node /usr/lib /usr/lib
48+
COPY --from=node /usr/local/lib /usr/local/lib
49+
COPY --from=node /usr/local/include /usr/local/include
50+
COPY --from=node /usr/local/bin /usr/local/bin
51+
COPY --from=node /opt /opt
52+
53+
COPY . .
54+
RUN rm -f .env .env.*
55+
56+
RUN chown -R www-data:root /srv/app; \
57+
chmod -R g=u /srv/app

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,35 @@ $decoded = JWT::decode($jwt, $keys);
401401
$decoded = json_decode(json_encode($decoded), true);
402402
```
403403

404+
Development
405+
-----
406+
As fast-setup you can use a container environment, e.g. `podman` or `docker`. To build, run following:
407+
408+
```bash
409+
# podman
410+
$ podman build --tag php-jwt .
411+
# docker
412+
$ docker build --tag php-jwt .
413+
.....
414+
Successfully tagged localhost/php-jwt:latest
415+
```
416+
417+
After that run the container and use to its shell:
418+
419+
```bash
420+
# podman
421+
$ podman run -it -v .:/srv/app localhost/php-jwt:latest sh
422+
# docker
423+
$ docker run -it -v .:/srv/app localhost/php-jwt:latest sh
424+
```
425+
426+
Now you can install the dependencies and e.g. run tests (see below):
427+
428+
```bash
429+
$ composer install
430+
$ phpunit --configuration phpunit.xml.dist
431+
```
432+
404433
Tests
405434
-----
406435
Run the tests using phpunit:

src/JWK.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class JWK
2828
'P-256' => '1.2.840.10045.3.1.7', // Len: 64
2929
'secp256k1' => '1.3.132.0.10', // Len: 64
3030
'P-384' => '1.3.132.0.34', // Len: 96
31-
// 'P-521' => '1.3.132.0.35', // Len: 132 (not supported)
31+
'P-521' => '1.3.132.0.35', // Len: 132
3232
];
3333

3434
// For keys with "kty" equal to "OKP" (Octet Key Pair), the "crv" parameter must contain the key subtype.
@@ -188,14 +188,20 @@ public static function parseKey(array $jwk, ?string $defaultAlg = null): ?Key
188188
/**
189189
* Converts the EC JWK values to pem format.
190190
*
191-
* @param string $crv The EC curve (only P-256 & P-384 is supported)
191+
* @param string $crv The EC curve (only P-256, P-384 & P-521 is supported)
192192
* @param string $x The EC x-coordinate
193193
* @param string $y The EC y-coordinate
194194
*
195195
* @return string
196196
*/
197197
private static function createPemFromCrvAndXYCoordinates(string $crv, string $x, string $y): string
198198
{
199+
$coordinates = match ($crv) {
200+
'1-P-521' => \str_pad(JWT::urlsafeB64Decode($x), 66, "\x00", STR_PAD_LEFT) . \str_pad(JWT::urlsafeB64Decode($y), 66, "\x00", STR_PAD_LEFT),
201+
'0-P-521' => \str_pad(JWT::urlsafeB64Decode($x) . JWT::urlsafeB64Decode($y), 132, "\x00", STR_PAD_LEFT),
202+
default => JWT::urlsafeB64Decode($x) . JWT::urlsafeB64Decode($y),
203+
};
204+
199205
$pem =
200206
self::encodeDER(
201207
self::ASN1_SEQUENCE,
@@ -213,8 +219,7 @@ private static function createPemFromCrvAndXYCoordinates(string $crv, string $x,
213219
self::encodeDER(
214220
self::ASN1_BIT_STRING,
215221
\chr(0x00) . \chr(0x04)
216-
. JWT::urlsafeB64Decode($x)
217-
. JWT::urlsafeB64Decode($y)
222+
. $coordinates
218223
)
219224
);
220225

src/JWT.php

Lines changed: 39 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class JWT
3838
*
3939
* @var int
4040
*/
41-
public static $leeway = 0;
41+
public static int $leeway = 0;
4242

4343
/**
4444
* Allow the current timestamp to be specified.
@@ -47,15 +47,16 @@ class JWT
4747
*
4848
* @var ?int
4949
*/
50-
public static $timestamp = null;
50+
public static ?int $timestamp = null;
5151

5252
/**
5353
* @var array<string, string[]>
5454
*/
55-
public static $supported_algs = [
56-
'ES384' => ['openssl', 'SHA384'],
55+
public static array $supported_algs = [
5756
'ES256' => ['openssl', 'SHA256'],
5857
'ES256K' => ['openssl', 'SHA256'],
58+
'ES384' => ['openssl', 'SHA384'],
59+
'ES512' => ['openssl', 'SHA512'],
5960
'HS256' => ['hash_hmac', 'SHA256'],
6061
'HS384' => ['hash_hmac', 'SHA384'],
6162
'HS512' => ['hash_hmac', 'SHA512'],
@@ -75,10 +76,10 @@ class JWT
7576
* the public key.
7677
* Each Key object contains an algorithm and
7778
* matching key.
78-
* Supported algorithms are 'ES384','ES256',
79+
* Supported algorithms are 'ES256', 'ES256K', 'ES384', 'ES512',
7980
* 'HS256', 'HS384', 'HS512', 'RS256', 'RS384'
8081
* and 'RS512'.
81-
* @param stdClass $headers Optional. Populates stdClass with headers.
82+
* @param stdClass|null $headers Optional. Populates stdClass with headers.
8283
*
8384
* @return stdClass The JWT's payload as a PHP object
8485
*
@@ -95,7 +96,7 @@ class JWT
9596
*/
9697
public static function decode(
9798
string $jwt,
98-
$keyOrKeyArray,
99+
Key|ArrayAccess|array $keyOrKeyArray,
99100
?stdClass &$headers = null
100101
): stdClass {
101102
// Validate JWT
@@ -142,9 +143,9 @@ public static function decode(
142143
// See issue #351
143144
throw new UnexpectedValueException('Incorrect key for this algorithm');
144145
}
145-
if (\in_array($header->alg, ['ES256', 'ES256K', 'ES384'], true)) {
146-
// OpenSSL expects an ASN.1 DER sequence for ES256/ES256K/ES384 signatures
147-
$sig = self::signatureToDER($sig);
146+
if (\in_array($header->alg, ['ES256', 'ES256K', 'ES384', 'ES512'], true)) {
147+
// OpenSSL expects an ASN.1 DER sequence for ES256/ES256K/ES384/ES512 signatures
148+
$sig = self::signatureToDER($sig, $header->alg);
148149
}
149150
if (!self::verify("{$headb64}.{$bodyb64}", $sig, $key->getKeyMaterial(), $header->alg)) {
150151
throw new SignatureInvalidException('Signature verification failed');
@@ -184,12 +185,12 @@ public static function decode(
184185
/**
185186
* Converts and signs a PHP array into a JWT string.
186187
*
187-
* @param array<mixed> $payload PHP array
188+
* @param array<mixed> $payload PHP array
188189
* @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key.
189-
* @param string $alg Supported algorithms are 'ES384','ES256', 'ES256K', 'HS256',
190-
* 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512'
191-
* @param string $keyId
192-
* @param array<string, string> $head An array with header elements to attach
190+
* @param string $alg Supported algorithms are 'ES256', 'ES256K', 'ES384', 'ES512',
191+
* 'HS256', 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512'
192+
* @param string|null $keyId
193+
* @param array<string, string>|null $head An array with header elements to attach
193194
*
194195
* @return string A signed JWT
195196
*
@@ -198,7 +199,7 @@ public static function decode(
198199
*/
199200
public static function encode(
200201
array $payload,
201-
$key,
202+
mixed $key,
202203
string $alg,
203204
?string $keyId = null,
204205
?array $head = null
@@ -227,16 +228,16 @@ public static function encode(
227228
*
228229
* @param string $msg The message to sign
229230
* @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key.
230-
* @param string $alg Supported algorithms are 'EdDSA', 'ES384', 'ES256', 'ES256K', 'HS256',
231-
* 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512'
231+
* @param string $alg Supported algorithms are 'EdDSA', 'ES256', 'ES256K', 'ES384', 'ES512',
232+
* 'HS256', 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512'
232233
*
233234
* @return string An encrypted message
234235
*
235236
* @throws DomainException Unsupported algorithm or bad key was specified
236237
*/
237238
public static function sign(
238239
string $msg,
239-
$key,
240+
mixed $key,
240241
string $alg
241242
): string {
242243
if (empty(static::$supported_algs[$alg])) {
@@ -259,9 +260,11 @@ public static function sign(
259260
throw new DomainException('OpenSSL unable to sign data');
260261
}
261262
if ($alg === 'ES256' || $alg === 'ES256K') {
262-
$signature = self::signatureFromDER($signature, 256);
263+
$signature = self::signatureFromDER($signature, 256, $alg);
263264
} elseif ($alg === 'ES384') {
264-
$signature = self::signatureFromDER($signature, 384);
265+
$signature = self::signatureFromDER($signature, 384, $alg);
266+
} elseif ($alg === 'ES512') {
267+
$signature = self::signatureFromDER($signature, 521, $alg);
265268
}
266269
return $signature;
267270
case 'sodium_crypto':
@@ -303,7 +306,7 @@ public static function sign(
303306
private static function verify(
304307
string $msg,
305308
string $signature,
306-
$keyMaterial,
309+
mixed $keyMaterial,
307310
string $alg
308311
): bool {
309312
if (empty(static::$supported_algs[$alg])) {
@@ -364,7 +367,7 @@ private static function verify(
364367
*
365368
* @throws DomainException Provided string was invalid JSON
366369
*/
367-
public static function jsonDecode(string $input)
370+
public static function jsonDecode(string $input): mixed
368371
{
369372
$obj = \json_decode($input, false, 512, JSON_BIGINT_AS_STRING);
370373

@@ -457,7 +460,7 @@ public static function urlsafeB64Encode(string $input): string
457460
* @return Key
458461
*/
459462
private static function getKey(
460-
$keyOrKeyArray,
463+
Key|ArrayAccess|array $keyOrKeyArray,
461464
?string $kid
462465
): Key {
463466
if ($keyOrKeyArray instanceof Key) {
@@ -519,11 +522,7 @@ private static function handleJsonError(int $errno): void
519522
JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON',
520523
JSON_ERROR_UTF8 => 'Malformed UTF-8 characters' //PHP >= 5.3.3
521524
];
522-
throw new DomainException(
523-
isset($messages[$errno])
524-
? $messages[$errno]
525-
: 'Unknown JSON error: ' . $errno
526-
);
525+
throw new DomainException($messages[$errno] ?? 'Unknown JSON error: ' . $errno);
527526
}
528527

529528
/**
@@ -545,12 +544,16 @@ private static function safeStrlen(string $str): int
545544
* Convert an ECDSA signature to an ASN.1 DER sequence
546545
*
547546
* @param string $sig The ECDSA signature to convert
547+
* @param string $alg The algorithm
548548
* @return string The encoded DER object
549549
*/
550-
private static function signatureToDER(string $sig): string
550+
private static function signatureToDER(string $sig, string $alg): string
551551
{
552552
// Separate the signature into r-value and s-value
553-
$length = max(1, (int) (\strlen($sig) / 2));
553+
$length = match ($alg) {
554+
'ES512' => 66,
555+
default => max(1, (int) (\strlen($sig) / 2)),
556+
};
554557
list($r, $s) = \str_split($sig, $length);
555558

556559
// Trim leading zeros
@@ -602,10 +605,11 @@ private static function encodeDER(int $type, string $value): string
602605
*
603606
* @param string $der binary signature in DER format
604607
* @param int $keySize the number of bits in the key
608+
* @param string $alg The algorithm
605609
*
606610
* @return string the signature
607611
*/
608-
private static function signatureFromDER(string $der, int $keySize): string
612+
private static function signatureFromDER(string $der, int $keySize, string $alg): string
609613
{
610614
// OpenSSL returns the ECDSA signatures as a binary ASN.1 DER SEQUENCE
611615
list($offset, $_) = self::readDER($der);
@@ -618,8 +622,9 @@ private static function signatureFromDER(string $der, int $keySize): string
618622
$s = \ltrim($s, "\x00");
619623

620624
// Pad out r and s so that they are $keySize bits long
621-
$r = \str_pad($r, $keySize / 8, "\x00", STR_PAD_LEFT);
622-
$s = \str_pad($s, $keySize / 8, "\x00", STR_PAD_LEFT);
625+
$length = \ceil($keySize / 8);
626+
$r = \str_pad($r, $length, "\x00", STR_PAD_LEFT);
627+
$s = \str_pad($s, $length, "\x00", STR_PAD_LEFT);
623628

624629
return $r . $s;
625630
}

src/Key.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class Key
1414
* @param string $algorithm
1515
*/
1616
public function __construct(
17-
private $keyMaterial,
17+
private mixed $keyMaterial,
1818
private string $algorithm
1919
) {
2020
if (
@@ -48,7 +48,7 @@ public function getAlgorithm(): string
4848
/**
4949
* @return string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate
5050
*/
51-
public function getKeyMaterial()
51+
public function getKeyMaterial(): mixed
5252
{
5353
return $this->keyMaterial;
5454
}

tests/JWKTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ public function provideDecodeByJwkKeySet()
152152
['rsa1-private.pem', 'rsa-jwkset.json', 'RS256', 'jwk1'],
153153
['ecdsa256-private.pem', 'ec-jwkset.json', 'ES256', 'jwk1'],
154154
['ecdsa384-private.pem', 'ec-jwkset.json', 'ES384', 'jwk4'],
155+
['ecdsa512-private.pem', 'ec-jwkset.json', 'ES512', 'jwk5'],
155156
['ed25519-1.sec', 'ed25519-jwkset.json', 'EdDSA', 'jwk1'],
156157
];
157158
}

tests/JWTTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,7 @@ public function provideEncodeDecode()
489489
return [
490490
[__DIR__ . '/data/ecdsa-private.pem', __DIR__ . '/data/ecdsa-public.pem', 'ES256'],
491491
[__DIR__ . '/data/ecdsa384-private.pem', __DIR__ . '/data/ecdsa384-public.pem', 'ES384'],
492+
[__DIR__ . '/data/ecdsa512-private.pem', __DIR__ . '/data/ecdsa512-public.pem', 'ES512'],
492493
[__DIR__ . '/data/rsa1-private.pem', __DIR__ . '/data/rsa1-public.pub', 'RS512'],
493494
[__DIR__ . '/data/ed25519-1.sec', __DIR__ . '/data/ed25519-1.pub', 'EdDSA'],
494495
[__DIR__ . '/data/secp256k1-private.pem', __DIR__ . '/data/secp256k1-public.pem', 'ES256K'],

0 commit comments

Comments
 (0)