Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
vendor
node_modules
phpunit.phar
phpunit.phar.asc
composer.phar
Expand Down
57 changes: 57 additions & 0 deletions Containerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
ARG PHP_VERSION=8.5
ARG COMPOSER_VERSION=latest
ARG NODE_VERSION=current

FROM docker.io/composer:${COMPOSER_VERSION} AS composer

FROM docker.io/node:${NODE_VERSION}-alpine AS node

FROM docker.io/php:${PHP_VERSION}-fpm-alpine AS app

WORKDIR /srv/app

# Update base
RUN apk update && apk upgrade

# persistent / runtime deps
RUN apk add --no-cache \
openssl \
;

# TODO: Remove hardcoded imagick version after stable-release
RUN set -eux; \
apk add --no-cache --virtual .build-deps \
$PHPIZE_DEPS \
libsodium-dev \
; \
\
docker-php-ext-install -j$(nproc) \
sodium \
; \
\
runDeps="$( \
scanelf --needed --nobanner --format '%n#p' --recursive /usr/local/lib/php/extensions \
| tr ',' '\n' \
| sort -u \
| awk 'system("[ -e /usr/local/lib/" $1 " ]") == 0 { next } { print "so:" $1 }' \
)"; \
apk add --no-cache --virtual .app-phpexts-rundeps $runDeps; \
\
apk del .build-deps

RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

# Add dev-tools
COPY --from=composer /usr/bin/composer /usr/bin/composer
ENV PATH="${PATH}:/root/.composer/vendor/bin:/srv/app/vendor/bin"
COPY --from=node /usr/lib /usr/lib
COPY --from=node /usr/local/lib /usr/local/lib
COPY --from=node /usr/local/include /usr/local/include
COPY --from=node /usr/local/bin /usr/local/bin
COPY --from=node /opt /opt

COPY . .
RUN rm -f .env .env.*

RUN chown -R www-data:root /srv/app; \
chmod -R g=u /srv/app
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,35 @@ $decoded = JWT::decode($jwt, $keys);
$decoded = json_decode(json_encode($decoded), true);
```

Development
-----
As fast-setup you can use a container environment, e.g. `podman` or `docker`. To build, run following:

```bash
# podman
$ podman build --tag php-jwt .
# docker
$ docker build --tag php-jwt .
.....
Successfully tagged localhost/php-jwt:latest
```

After that run the container and use to its shell:

```bash
# podman
$ podman run -it -v .:/srv/app localhost/php-jwt:latest sh
# docker
$ docker run -it -v .:/srv/app localhost/php-jwt:latest sh
```

Now you can install the dependencies and e.g. run tests (see below):

```bash
$ composer install
$ phpunit --configuration phpunit.xml.dist
```

Tests
-----
Run the tests using phpunit:
Expand Down
13 changes: 9 additions & 4 deletions src/JWK.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class JWK
'P-256' => '1.2.840.10045.3.1.7', // Len: 64
'secp256k1' => '1.3.132.0.10', // Len: 64
'P-384' => '1.3.132.0.34', // Len: 96
// 'P-521' => '1.3.132.0.35', // Len: 132 (not supported)
'P-521' => '1.3.132.0.35', // Len: 132
];

// For keys with "kty" equal to "OKP" (Octet Key Pair), the "crv" parameter must contain the key subtype.
Expand Down Expand Up @@ -188,14 +188,20 @@ public static function parseKey(#[\SensitiveParameter] array $jwk, ?string $defa
/**
* Converts the EC JWK values to pem format.
*
* @param string $crv The EC curve (only P-256 & P-384 is supported)
* @param string $crv The EC curve (only P-256, P-384 & P-521 is supported)
* @param string $x The EC x-coordinate
* @param string $y The EC y-coordinate
*
* @return string
*/
private static function createPemFromCrvAndXYCoordinates(string $crv, string $x, string $y): string
{
$coordinates = match ($crv) {
'1-P-521' => \str_pad(JWT::urlsafeB64Decode($x), 66, "\x00", STR_PAD_LEFT) . \str_pad(JWT::urlsafeB64Decode($y), 66, "\x00", STR_PAD_LEFT),
'0-P-521' => \str_pad(JWT::urlsafeB64Decode($x) . JWT::urlsafeB64Decode($y), 132, "\x00", STR_PAD_LEFT),
default => JWT::urlsafeB64Decode($x) . JWT::urlsafeB64Decode($y),
};

$pem =
self::encodeDER(
self::ASN1_SEQUENCE,
Expand All @@ -213,8 +219,7 @@ private static function createPemFromCrvAndXYCoordinates(string $crv, string $x,
self::encodeDER(
self::ASN1_BIT_STRING,
\chr(0x00) . \chr(0x04)
. JWT::urlsafeB64Decode($x)
. JWT::urlsafeB64Decode($y)
. $coordinates
)
);

Expand Down
77 changes: 41 additions & 36 deletions src/JWT.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
*
* @var int
*/
public static $leeway = 0;
public static int $leeway = 0;

/**
* Allow the current timestamp to be specified.
Expand All @@ -49,15 +49,16 @@
*
* @var ?int
*/
public static $timestamp = null;
public static ?int $timestamp = null;

/**
* @var array<string, string[]>
*/
public static $supported_algs = [
'ES384' => ['openssl', 'SHA384'],
public static array $supported_algs = [
'ES256' => ['openssl', 'SHA256'],
'ES256K' => ['openssl', 'SHA256'],
'ES384' => ['openssl', 'SHA384'],
'ES512' => ['openssl', 'SHA512'],
'HS256' => ['hash_hmac', 'SHA256'],
'HS384' => ['hash_hmac', 'SHA384'],
'HS512' => ['hash_hmac', 'SHA512'],
Expand All @@ -77,10 +78,10 @@
* the public key.
* Each Key object contains an algorithm and
* matching key.
* Supported algorithms are 'ES384','ES256',
* Supported algorithms are 'ES256', 'ES256K', 'ES384', 'ES512',
* 'HS256', 'HS384', 'HS512', 'RS256', 'RS384'
* and 'RS512'.
* @param stdClass $headers Optional. Populates stdClass with headers.
* @param stdClass|null $headers Optional. Populates stdClass with headers.
*
* @return stdClass The JWT's payload as a PHP object
*
Expand All @@ -97,7 +98,7 @@
*/
public static function decode(
string $jwt,
#[\SensitiveParameter] $keyOrKeyArray,
#[\SensitiveParameter] Key|ArrayAccess|array $keyOrKeyArray,
?stdClass &$headers = null
): stdClass {
// Validate JWT
Expand Down Expand Up @@ -154,9 +155,9 @@
// See issue #351
throw new UnexpectedValueException('Incorrect key for this algorithm');
}
if (\in_array($header->alg, ['ES256', 'ES256K', 'ES384'], true)) {
// OpenSSL expects an ASN.1 DER sequence for ES256/ES256K/ES384 signatures
$sig = self::signatureToDER($sig);
if (\in_array($header->alg, ['ES256', 'ES256K', 'ES384', 'ES512'], true)) {
// OpenSSL expects an ASN.1 DER sequence for ES256/ES256K/ES384/ES512 signatures
$sig = self::signatureToDER($sig, $header->alg);
}
if (!self::verify("{$headb64}.{$bodyb64}", $sig, $key->getKeyMaterial(), $header->alg)) {
throw new SignatureInvalidException('Signature verification failed');
Expand Down Expand Up @@ -197,12 +198,12 @@
/**
* Converts and signs a PHP array into a JWT string.
*
* @param array<mixed> $payload PHP array
* @param array<mixed> $payload PHP array
* @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key.
* @param string $alg Supported algorithms are 'ES384','ES256', 'ES256K', 'HS256',
* 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512'
* @param string $keyId
* @param array<string, string|string[]> $head An array with header elements to attach
* @param string $alg Supported algorithms are 'ES256', 'ES256K', 'ES384', 'ES512',
* 'HS256', 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512'
* @param string|null $keyId
* @param array<string, string>|null $head An array with header elements to attach
Comment thread
bshaffer marked this conversation as resolved.
Outdated
*
* @return string A signed JWT
*
Expand All @@ -211,7 +212,7 @@
*/
public static function encode(
array $payload,
#[\SensitiveParameter] $key,
#[\SensitiveParameter] string|OpenSSLAsymmetricKey|OpenSSLCertificate $key,
string $alg,
?string $keyId = null,
?array $head = null
Expand Down Expand Up @@ -239,17 +240,17 @@
* Sign a string with a given key and algorithm.
*
* @param string $msg The message to sign
* @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key.
* @param string $alg Supported algorithms are 'EdDSA', 'ES384', 'ES256', 'ES256K', 'HS256',
* 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512'
* @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key.
* @param string $alg Supported algorithms are 'EdDSA', 'ES256', 'ES256K', 'ES384', 'ES512',
* 'HS256', 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512'
*
* @return string An encrypted message
*
* @throws DomainException Unsupported algorithm or bad key was specified
*/
public static function sign(
string $msg,
#[\SensitiveParameter] $key,
#[\SensitiveParameter] string|OpenSSLAsymmetricKey|OpenSSLCertificate $key,
string $alg
): string {
if (empty(static::$supported_algs[$alg])) {
Expand Down Expand Up @@ -278,9 +279,11 @@
throw new DomainException('OpenSSL unable to sign data');
}
if ($alg === 'ES256' || $alg === 'ES256K') {
$signature = self::signatureFromDER($signature, 256);
$signature = self::signatureFromDER($signature, 256, $alg);
} elseif ($alg === 'ES384') {
$signature = self::signatureFromDER($signature, 384);
$signature = self::signatureFromDER($signature, 384, $alg);
} elseif ($alg === 'ES512') {
$signature = self::signatureFromDER($signature, 521, $alg);
}
return $signature;
case 'sodium_crypto':
Expand Down Expand Up @@ -312,7 +315,7 @@
*
* @param string $msg The original message (header and body)
* @param string $signature The original signature
* @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial For Ed*, ES*, HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey
* @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial For Ed*, ES*, HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey
* @param string $alg The algorithm
*
* @return bool
Expand All @@ -322,7 +325,7 @@
private static function verify(
string $msg,
string $signature,
#[\SensitiveParameter] $keyMaterial,
#[\SensitiveParameter] string|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial,
string $alg
): bool {
if (empty(static::$supported_algs[$alg])) {
Expand Down Expand Up @@ -392,7 +395,7 @@
*
* @throws DomainException Provided string was invalid JSON
*/
public static function jsonDecode(string $input)
public static function jsonDecode(string $input): mixed
{
$obj = \json_decode($input, false, 512, JSON_BIGINT_AS_STRING);

Expand Down Expand Up @@ -485,7 +488,7 @@
* @return Key
*/
private static function getKey(
#[\SensitiveParameter] $keyOrKeyArray,
#[\SensitiveParameter] Key|ArrayAccess|array $keyOrKeyArray,
?string $kid
): Key {
if ($keyOrKeyArray instanceof Key) {
Expand Down Expand Up @@ -547,11 +550,7 @@
JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON',
JSON_ERROR_UTF8 => 'Malformed UTF-8 characters' //PHP >= 5.3.3
];
throw new DomainException(
isset($messages[$errno])
? $messages[$errno]
: 'Unknown JSON error: ' . $errno
);
throw new DomainException($messages[$errno] ?? 'Unknown JSON error: ' . $errno);
}

/**
Expand All @@ -573,12 +572,16 @@
* Convert an ECDSA signature to an ASN.1 DER sequence
*
* @param string $sig The ECDSA signature to convert
* @param string $alg The algorithm
* @return string The encoded DER object
*/
private static function signatureToDER(string $sig): string
private static function signatureToDER(string $sig, string $alg): string
{
// Separate the signature into r-value and s-value
$length = max(1, (int) (\strlen($sig) / 2));
$length = match ($alg) {
'ES512' => 66,
default => max(1, (int) (\strlen($sig) / 2)),

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.

Suggested change
default => max(1, (int) (\strlen($sig) / 2)),
'ES256', 'ES256K', 'ES384' => max(1, (int) (\strlen($sig) / 2)),
default => throw new InvalidArgumentException('unknown ECDNA alg: ' . $alg),

};
list($r, $s) = \str_split($sig, $length);

// Trim leading zeros
Expand Down Expand Up @@ -630,10 +633,11 @@
*
* @param string $der binary signature in DER format
* @param int $keySize the number of bits in the key
* @param string $alg The algorithm

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.

$alg parameter here is never used

Suggested change
* @param string $alg The algorithm

*
* @return string the signature
*/
private static function signatureFromDER(string $der, int $keySize): string
private static function signatureFromDER(string $der, int $keySize, string $alg): string

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.

$alg parameter here is never used

Suggested change
private static function signatureFromDER(string $der, int $keySize, string $alg): string
private static function signatureFromDER(string $der, int $keySize): string

{
// OpenSSL returns the ECDSA signatures as a binary ASN.1 DER SEQUENCE
list($offset, $_) = self::readDER($der);
Expand All @@ -646,8 +650,9 @@
$s = \ltrim($s, "\x00");

// Pad out r and s so that they are $keySize bits long
$r = \str_pad($r, $keySize / 8, "\x00", STR_PAD_LEFT);
$s = \str_pad($s, $keySize / 8, "\x00", STR_PAD_LEFT);
$length = \ceil($keySize / 8);
$r = \str_pad($r, $length, "\x00", STR_PAD_LEFT);

Check failure on line 654 in src/JWT.php

View workflow job for this annotation

GitHub Actions / PHPStan Static Analysis

Parameter #2 $length of function str_pad expects int, float given.
$s = \str_pad($s, $length, "\x00", STR_PAD_LEFT);

Check failure on line 655 in src/JWT.php

View workflow job for this annotation

GitHub Actions / PHPStan Static Analysis

Parameter #2 $length of function str_pad expects int, float given.

return $r . $s;
}
Expand Down
4 changes: 2 additions & 2 deletions src/Key.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@
* @param string $algorithm
*/
public function __construct(
#[\SensitiveParameter] private $keyMaterial,
#[\SensitiveParameter] private string|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial,
private string $algorithm
) {
if (
!\is_string($keyMaterial)

Check failure on line 21 in src/Key.php

View workflow job for this annotation

GitHub Actions / PHPStan Static Analysis

Result of && is always false.
&& !$keyMaterial instanceof OpenSSLAsymmetricKey
&& !$keyMaterial instanceof OpenSSLCertificate
) {
Expand Down Expand Up @@ -47,7 +47,7 @@
/**
* @return string|OpenSSLAsymmetricKey|OpenSSLCertificate
*/
public function getKeyMaterial()
public function getKeyMaterial(): mixed
{
return $this->keyMaterial;
}
Expand Down
1 change: 1 addition & 0 deletions tests/JWKTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ public function provideDecodeByJwkKeySet()
['rsa1-private.pem', 'rsa-jwkset.json', 'RS256', 'jwk1'],
['ecdsa256-private.pem', 'ec-jwkset.json', 'ES256', 'jwk1'],
['ecdsa384-private.pem', 'ec-jwkset.json', 'ES384', 'jwk4'],
['ecdsa512-private.pem', 'ec-jwkset.json', 'ES512', 'jwk5'],
['ed25519-1.sec', 'ed25519-jwkset.json', 'EdDSA', 'jwk1'],
];
}
Expand Down
1 change: 1 addition & 0 deletions tests/JWTTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@ public function provideEncodeDecode()
return [
[__DIR__ . '/data/ecdsa-private.pem', __DIR__ . '/data/ecdsa-public.pem', 'ES256'],
[__DIR__ . '/data/ecdsa384-private.pem', __DIR__ . '/data/ecdsa384-public.pem', 'ES384'],
[__DIR__ . '/data/ecdsa512-private.pem', __DIR__ . '/data/ecdsa512-public.pem', 'ES512'],
[__DIR__ . '/data/rsa1-private.pem', __DIR__ . '/data/rsa1-public.pub', 'RS512'],
[__DIR__ . '/data/ed25519-1.sec', __DIR__ . '/data/ed25519-1.pub', 'EdDSA'],
[__DIR__ . '/data/secp256k1-private.pem', __DIR__ . '/data/secp256k1-public.pem', 'ES256K'],
Expand Down
Loading
Loading