Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Ed25519 support to JWT #343

Merged
merged 15 commits into from
Jun 23, 2021
Merged
4 changes: 3 additions & 1 deletion .github/actions/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
bshaffer marked this conversation as resolved.
Show resolved Hide resolved

```bash
composer require paragonie/sodium_compat
```

Example
-------
```php
Expand Down Expand Up @@ -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
----------

Expand Down
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
44 changes: 34 additions & 10 deletions src/JWT.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Firebase\JWT;

use DomainException;
use Exception;
use InvalidArgumentException;
use UnexpectedValueException;
use DateTime;
Expand Down Expand Up @@ -50,6 +51,7 @@ class JWT
'RS256' => array('openssl', 'SHA256'),
'RS384' => array('openssl', 'SHA384'),
'RS512' => array('openssl', 'SHA512'),
'EdDSA' => array('sodium_crypto', 'EdDSA'),
);

/**
Expand Down Expand Up @@ -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')
{
Expand All @@ -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);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason (other than staying consistent with the other exceptions thrown from this method) that we are wrapping the libsodium exceptions in DomainException?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for consistency and so we are not adding new exception types for BC. Also the other exception types may not be defined in PHP without the library or shim present, so anyone trying to write generic try/catch code will have a harder time.

}
}
}
Expand All @@ -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)
{
Expand All @@ -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);
Expand Down
29 changes: 29 additions & 0 deletions tests/JWTTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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'),
);
}
}
1 change: 1 addition & 0 deletions tests/ed25519-1.pub
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uOSJMhbKSG4V5xUHS7B9YHmVg/1yVd+G+Io6oBFhSfY=
1 change: 1 addition & 0 deletions tests/ed25519-1.sec
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
i4eTKkWNIISKumdk3v90cPDrY/g8WRTJWy7DmGDsdzC45IkyFspIbhXnFQdLsH1geZWD/XJV34b4ijqgEWFJ9g==