From d2113d9b2e0e349796e72d2a63cf9319100382d2 Mon Sep 17 00:00:00 2001 From: pwolanin Date: Wed, 23 Jun 2021 15:00:23 -0400 Subject: [PATCH] feat: add Ed25519 support to JWT (#343) --- .github/actions/entrypoint.sh | 4 +++- .github/workflows/tests.yml | 2 ++ README.md | 37 +++++++++++++++++++++++++++++ composer.json | 3 +++ src/JWT.php | 44 +++++++++++++++++++++++++++-------- tests/JWTTest.php | 29 +++++++++++++++++++++++ tests/ed25519-1.pub | 1 + tests/ed25519-1.sec | 1 + 8 files changed, 110 insertions(+), 11 deletions(-) create mode 100644 tests/ed25519-1.pub create mode 100644 tests/ed25519-1.sec diff --git a/.github/actions/entrypoint.sh b/.github/actions/entrypoint.sh index ce8379cb..8b6b9e1b 100755 --- a/.github/actions/entrypoint.sh +++ b/.github/actions/entrypoint.sh @@ -12,7 +12,9 @@ curl --silent --show-error https://getcomposer.org/installer | php php composer.phar self-update echo "---Installing dependencies ---" -php composer.phar update + +# Add compatiblity for libsodium with older versions of PHP +php composer.phar require --dev --with-dependencies paragonie/sodium_compat echo "---Running unit tests ---" vendor/bin/phpunit diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 343df75d..92b4e9e0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,6 +24,8 @@ jobs: timeout_minutes: 10 max_attempts: 3 command: composer install + - if: ${{ matrix.php == '5.6' }} + run: composer require --dev --with-dependencies paragonie/sodium_compat - name: Run Script run: vendor/bin/phpunit diff --git a/README.md b/README.md index 66d7d014..a8556aa5 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,13 @@ Use composer to manage your dependencies and download PHP-JWT: composer require firebase/php-jwt ``` +Optionally, install the `paragonie/sodium_compat` package from composer if your +php is < 7.2 or does not have libsodium installed: + +```bash +composer require paragonie/sodium_compat +``` + Example ------- ```php @@ -144,6 +151,36 @@ $decoded = JWT::decode($jwt, $publicKey, array('RS256')); echo "Decode:\n" . print_r((array) $decoded, true) . "\n"; ``` +Example with EdDSA (libsodium and Ed25519 signature) +---------------------------- +```php +use Firebase\JWT\JWT; + +// Public and private keys are expected to be Base64 encoded. The last +// non-empty line is used so that keys can be generated with +// sodium_crypto_sign_keypair(). The secret keys generated by other tools may +// need to be adjusted to match the input expected by libsodium. + +$keyPair = sodium_crypto_sign_keypair(); + +$privateKey = base64_encode(sodium_crypto_sign_secretkey($keyPair)); + +$publicKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); + +$payload = array( + "iss" => "example.org", + "aud" => "example.com", + "iat" => 1356999524, + "nbf" => 1357000000 +); + +$jwt = JWT::encode($payload, $privateKey, 'EdDSA'); +echo "Encode:\n" . print_r($jwt, true) . "\n"; + +$decoded = JWT::decode($jwt, $publicKey, array('EdDSA')); +echo "Decode:\n" . print_r((array) $decoded, true) . "\n"; +```` + Using JWKs ---------- diff --git a/composer.json b/composer.json index 25d1cfa9..6146e2dc 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,9 @@ "require": { "php": ">=5.3.0" }, + "suggest": { + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, "autoload": { "psr-4": { "Firebase\\JWT\\": "src" diff --git a/src/JWT.php b/src/JWT.php index 4b85699f..99d6dcd2 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -3,6 +3,7 @@ namespace Firebase\JWT; use DomainException; +use Exception; use InvalidArgumentException; use UnexpectedValueException; use DateTime; @@ -50,6 +51,7 @@ class JWT 'RS256' => array('openssl', 'SHA256'), 'RS384' => array('openssl', 'SHA384'), 'RS512' => array('openssl', 'SHA512'), + 'EdDSA' => array('sodium_crypto', 'EdDSA'), ); /** @@ -198,7 +200,7 @@ public static function encode($payload, $key, $alg = 'HS256', $keyId = null, $he * * @return string An encrypted message * - * @throws DomainException Unsupported algorithm was specified + * @throws DomainException Unsupported algorithm or bad key was specified */ public static function sign($msg, $key, $alg = 'HS256') { @@ -214,14 +216,24 @@ public static function sign($msg, $key, $alg = 'HS256') $success = \openssl_sign($msg, $signature, $key, $algorithm); if (!$success) { throw new DomainException("OpenSSL unable to sign data"); - } else { - if ($alg === 'ES256') { - $signature = self::signatureFromDER($signature, 256); - } - if ($alg === 'ES384') { - $signature = self::signatureFromDER($signature, 384); - } - return $signature; + } + if ($alg === 'ES256') { + $signature = self::signatureFromDER($signature, 256); + } elseif ($alg === 'ES384') { + $signature = self::signatureFromDER($signature, 384); + } + return $signature; + case 'sodium_crypto': + if (!function_exists('sodium_crypto_sign_detached')) { + throw new DomainException('libsodium is not available'); + } + try { + // The last non-empty line is used as the key. + $lines = array_filter(explode("\n", $key)); + $key = base64_decode(end($lines)); + return sodium_crypto_sign_detached($msg, $key); + } catch (Exception $e) { + throw new DomainException($e->getMessage(), 0, $e); } } } @@ -237,7 +249,7 @@ public static function sign($msg, $key, $alg = 'HS256') * * @return bool * - * @throws DomainException Invalid Algorithm or OpenSSL failure + * @throws DomainException Invalid Algorithm, bad key, or OpenSSL failure */ private static function verify($msg, $signature, $key, $alg) { @@ -258,6 +270,18 @@ private static function verify($msg, $signature, $key, $alg) throw new DomainException( 'OpenSSL error: ' . \openssl_error_string() ); + case 'sodium_crypto': + if (!function_exists('sodium_crypto_sign_verify_detached')) { + throw new DomainException('libsodium is not available'); + } + try { + // The last non-empty line is used as the key. + $lines = array_filter(explode("\n", $key)); + $key = base64_decode(end($lines)); + return sodium_crypto_sign_verify_detached($signature, $msg, $key); + } catch (Exception $e) { + throw new DomainException($e->getMessage(), 0, $e); + } case 'hash_hmac': default: $hash = \hash_hmac($algorithm, $msg, $key, true); diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 09dac142..3dee0450 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -285,6 +285,34 @@ public function testRSEncodeDecode() $this->assertEquals($decoded, 'abc'); } + public function testEdDsaEncodeDecode() + { + $keyPair = sodium_crypto_sign_keypair(); + $privKey = base64_encode(sodium_crypto_sign_secretkey($keyPair)); + + $payload = array('foo' => 'bar'); + $msg = JWT::encode($payload, $privKey, 'EdDSA'); + + $pubKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); + $decoded = JWT::decode($msg, $pubKey, array('EdDSA')); + $this->assertEquals('bar', $decoded->foo); + } + + public function testInvalidEdDsaEncodeDecode() + { + $keyPair = sodium_crypto_sign_keypair(); + $privKey = base64_encode(sodium_crypto_sign_secretkey($keyPair)); + + $payload = array('foo' => 'bar'); + $msg = JWT::encode($payload, $privKey, 'EdDSA'); + + // Generate a different key. + $keyPair = sodium_crypto_sign_keypair(); + $pubKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); + $this->setExpectedException('Firebase\JWT\SignatureInvalidException'); + JWT::decode($msg, $pubKey, array('EdDSA')); + } + public function testRSEncodeDecodeWithPassphrase() { $privateKey = openssl_pkey_get_private( @@ -322,6 +350,7 @@ public function provideEncodeDecode() array(__DIR__ . '/ecdsa-private.pem', __DIR__ . '/ecdsa-public.pem', 'ES256'), array(__DIR__ . '/ecdsa384-private.pem', __DIR__ . '/ecdsa384-public.pem', 'ES384'), array(__DIR__ . '/rsa1-private.pem', __DIR__ . '/rsa1-public.pub', 'RS512'), + array(__DIR__ . '/ed25519-1.sec', __DIR__ . '/ed25519-1.pub', 'EdDSA'), ); } } diff --git a/tests/ed25519-1.pub b/tests/ed25519-1.pub new file mode 100644 index 00000000..e4ae63ac --- /dev/null +++ b/tests/ed25519-1.pub @@ -0,0 +1 @@ +uOSJMhbKSG4V5xUHS7B9YHmVg/1yVd+G+Io6oBFhSfY= diff --git a/tests/ed25519-1.sec b/tests/ed25519-1.sec new file mode 100644 index 00000000..354ffa7a --- /dev/null +++ b/tests/ed25519-1.sec @@ -0,0 +1 @@ +i4eTKkWNIISKumdk3v90cPDrY/g8WRTJWy7DmGDsdzC45IkyFspIbhXnFQdLsH1geZWD/XJV34b4ijqgEWFJ9g==