diff --git a/.gitignore b/.gitignore index f720fb76..30361c89 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ vendor +node_modules phpunit.phar phpunit.phar.asc composer.phar diff --git a/Containerfile b/Containerfile new file mode 100644 index 00000000..a6214971 --- /dev/null +++ b/Containerfile @@ -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 diff --git a/README.md b/README.md index 65b6c860..784de005 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/JWK.php b/src/JWK.php index d5175b21..5028b039 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -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. @@ -188,7 +188,7 @@ 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 * @@ -196,6 +196,12 @@ public static function parseKey(#[\SensitiveParameter] array $jwk, ?string $defa */ 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, @@ -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 ) ); diff --git a/src/JWT.php b/src/JWT.php index 90f62ca9..bdc28e58 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -40,7 +40,7 @@ class JWT * * @var int */ - public static $leeway = 0; + public static int $leeway = 0; /** * Allow the current timestamp to be specified. @@ -49,15 +49,16 @@ class JWT * * @var ?int */ - public static $timestamp = null; + public static ?int $timestamp = null; /** * @var array */ - 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'], @@ -77,10 +78,10 @@ class JWT * 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 * @@ -97,7 +98,7 @@ class JWT */ public static function decode( string $jwt, - #[\SensitiveParameter] $keyOrKeyArray, + #[\SensitiveParameter] Key|ArrayAccess|array $keyOrKeyArray, ?stdClass &$headers = null ): stdClass { // Validate JWT @@ -154,9 +155,9 @@ public static function decode( // 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'); @@ -197,12 +198,12 @@ public static function decode( /** * Converts and signs a PHP array into a JWT string. * - * @param array $payload PHP array + * @param array $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 $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|null $head An array with header elements to attach * * @return string A signed JWT * @@ -211,7 +212,7 @@ public static function decode( */ public static function encode( array $payload, - #[\SensitiveParameter] $key, + #[\SensitiveParameter] string|OpenSSLAsymmetricKey|OpenSSLCertificate $key, string $alg, ?string $keyId = null, ?array $head = null @@ -239,9 +240,9 @@ public static function encode( * 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 * @@ -249,7 +250,7 @@ public static function encode( */ public static function sign( string $msg, - #[\SensitiveParameter] $key, + #[\SensitiveParameter] string|OpenSSLAsymmetricKey|OpenSSLCertificate $key, string $alg ): string { if (empty(static::$supported_algs[$alg])) { @@ -278,9 +279,11 @@ public static function sign( 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': @@ -312,7 +315,7 @@ public static function sign( * * @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 @@ -322,7 +325,7 @@ public static function sign( private static function verify( string $msg, string $signature, - #[\SensitiveParameter] $keyMaterial, + #[\SensitiveParameter] string|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial, string $alg ): bool { if (empty(static::$supported_algs[$alg])) { @@ -392,7 +395,7 @@ private static function verify( * * @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); @@ -485,7 +488,7 @@ public static function urlsafeB64Encode(string $input): string * @return Key */ private static function getKey( - #[\SensitiveParameter] $keyOrKeyArray, + #[\SensitiveParameter] Key|ArrayAccess|array $keyOrKeyArray, ?string $kid ): Key { if ($keyOrKeyArray instanceof Key) { @@ -547,11 +550,7 @@ private static function handleJsonError(int $errno): void 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); } /** @@ -573,12 +572,16 @@ private static function safeStrlen(string $str): int * 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)), + }; list($r, $s) = \str_split($sig, $length); // Trim leading zeros @@ -630,10 +633,11 @@ private static function encodeDER(int $type, string $value): string * * @param string $der binary signature in DER format * @param int $keySize the number of bits in the key + * @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 { // OpenSSL returns the ECDSA signatures as a binary ASN.1 DER SEQUENCE list($offset, $_) = self::readDER($der); @@ -646,8 +650,9 @@ private static function signatureFromDER(string $der, int $keySize): string $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); + $s = \str_pad($s, $length, "\x00", STR_PAD_LEFT); return $r . $s; } diff --git a/src/Key.php b/src/Key.php index 694d3b13..4f31cad7 100644 --- a/src/Key.php +++ b/src/Key.php @@ -14,7 +14,7 @@ class Key * @param string $algorithm */ public function __construct( - #[\SensitiveParameter] private $keyMaterial, + #[\SensitiveParameter] private string|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial, private string $algorithm ) { if ( @@ -47,7 +47,7 @@ public function getAlgorithm(): string /** * @return string|OpenSSLAsymmetricKey|OpenSSLCertificate */ - public function getKeyMaterial() + public function getKeyMaterial(): mixed { return $this->keyMaterial; } diff --git a/tests/JWKTest.php b/tests/JWKTest.php index db385c87..b2fad451 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -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'], ]; } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index a1dd08a4..14973996 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -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'], diff --git a/tests/data/ec-jwkset.json b/tests/data/ec-jwkset.json index 50c5b24e..b9cc0041 100644 --- a/tests/data/ec-jwkset.json +++ b/tests/data/ec-jwkset.json @@ -7,7 +7,11 @@ "kid": "jwk1", "x": "ALXnvdCvbBx35J2bozBkIFHPT747KiYioLK4JquMhZU", "y": "fAt_rGPqS95Ytwdluh4TNWTmj9xkcAbKGBRpP5kuGBk", - "alg": "ES256" + "alg": "ES256", + "files": { + "private": "ecdsa-private.pem", + "public": "ecdsa-public.pem" + } }, { "kty": "EC", @@ -16,7 +20,11 @@ "kid": "jwk2", "x": "mQa0q5FvxPRujxzFazQT1Mo2YJJzuKiXU3svOJ41jhw", "y": "jAz7UwIl2oOFk06kj42ZFMOXmGMFUGjKASvyYtibCH0", - "alg": "ES256" + "alg": "ES256", + "files": { + "private": "ecdsa256-private.pem", + "public": "ecdsa256-public.pem" + } }, { "kty": "EC", @@ -25,7 +33,11 @@ "kid": "jwk3", "x": "EFpwNuP322bU3WP1DtJgx67L0CUV1MxNixqPVMH2L9Q", "y": "_fSTbijIJjpsqL16cIEvxxf3MaYMY8MbqEq066yV9ls", - "alg": "ES256K" + "alg": "ES256K", + "files": { + "private": "secp256k1-private.pem", + "public": "secp256k1-public.pem" + } }, { "kty": "EC", @@ -34,7 +46,24 @@ "kid": "jwk4", "x": "FhXXcyKmWkTkdVbWYYU3dtJqpJ0JmLGftEdNzUEFEKSU5MlnLr_FjcneszvXAqEB", "y": "M4veJF_dO_zhFk44bh_ELXbp0_nn9QaViVtQpuTvpu29eefx6PfUMqX0K--IS4NQ", - "alg": "ES384" + "alg": "ES384", + "files": { + "private": "ecdsa384-private.pem", + "public": "ecdsa384-public.pem" + } + }, + { + "kty": "EC", + "use": "sig", + "crv": "P-521", + "kid": "jwk5", + "x": "AJ1sHaYL3ylcmvH_v2-m7JgdsAqKHDgFgvZa5VdFSGVJ0grIpFVsDLzvlkDQS4O7pYlf7LvlSSkiUERowd-iLM6r", + "y": "AOCilHrpEwo4r2ugX7HQHf5liR8BQM68CNk53jFgQTBgaRv_WO0lfBAyrIN7j_3l7vr8zNcOh6MydtIW78M6Jhnp", + "alg": "ES512", + "files": { + "private": "ecdsa512-private.pem", + "public": "ecdsa512-public.pem" + } } ] } \ No newline at end of file diff --git a/tests/data/ecdsa256-public.pem b/tests/data/ecdsa256-public.pem new file mode 100644 index 00000000..13b7a995 --- /dev/null +++ b/tests/data/ecdsa256-public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEALXnvdCvbBx35J2bozBkIFHPT747 +KiYioLK4JquMhZV8C3+sY+pL3li3B2W6HhM1ZOaP3GRwBsoYFGk/mS4YGQ== +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/tests/data/ecdsa512-private.pem b/tests/data/ecdsa512-private.pem new file mode 100644 index 00000000..44530c92 --- /dev/null +++ b/tests/data/ecdsa512-private.pem @@ -0,0 +1,8 @@ +-----BEGIN PRIVATE KEY----- +MIHuAgEAMBAGByqGSM49AgEGBSuBBAAjBIHWMIHTAgEBBEIBLk8bLHx/RH0Xf+b8 +pfEeX3lq0F3+CDmQ6wC2bVCHVjyONlGB7lCCe1dZ98K/4BZSgaE1DZppQapAuhnr +Za8qoH2hgYkDgYYABADe6Ue6+qQGYQxEyNGDXo3fUBAQRJfozKdsrwlJwk2DZWvJ +ZccIVvXVzMGBhtKnofCXTHDt7sZB9NQTd+1EB2QslgGgnG3WtX9Bj0ldeGr/Dysv +p7SLATalhvnCKFfdTANJbn95DZ+aal1U02TQO5YXAmj9gG8nYh8c/YD1EicyuCUI +1Q== +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/tests/data/ecdsa512-public.pem b/tests/data/ecdsa512-public.pem new file mode 100644 index 00000000..8502daf5 --- /dev/null +++ b/tests/data/ecdsa512-public.pem @@ -0,0 +1,6 @@ +-----BEGIN PUBLIC KEY----- +MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQAEzNkuFjjIsHJxcTHoJbhoCr6L6eK +7X0v4UUV4RVQLQyxaHeJgI9Jm4i5f4zdcTYmvvQ4m+RMSpy71uwqzVJI+aABAUnG +DmVz2VkeFC4OrN0FpMqzTaHqd17fcFZHXN+2i4ooJKljy1lu2gJ1WmdEK/YrOM3K +LdsO1hjsVZmf1VFgMfQ= +-----END PUBLIC KEY----- diff --git a/tests/examples/jwk2pem.php b/tests/examples/jwk2pem.php new file mode 100755 index 00000000..75dd40c5 --- /dev/null +++ b/tests/examples/jwk2pem.php @@ -0,0 +1,24 @@ +getKeyMaterial(); diff --git a/tests/examples/jwtTest.php b/tests/examples/jwtTest.php new file mode 100755 index 00000000..9137f23c --- /dev/null +++ b/tests/examples/jwtTest.php @@ -0,0 +1,39 @@ + 'bar']; + +$jwt = JWT::encode($payload, $privateContent, $alg); +echo 'Encode:' . PHP_EOL . print_r($jwt, true) . PHP_EOL . PHP_EOL; + +$decoded = JWT::decode($jwt, new Key($publicContent, $alg)); +$decoded_array = (array)$decoded; +echo 'Decode:' . PHP_EOL . print_r($decoded_array, true) . PHP_EOL . PHP_EOL;