Skip to content

Commit

Permalink
feat: add Ed25519 support to JWT (#343)
Browse files Browse the repository at this point in the history
  • Loading branch information
pwolanin committed Jun 23, 2021
1 parent 44d0a5a commit d2113d9
Show file tree
Hide file tree
Showing 8 changed files with 110 additions and 11 deletions.
4 changes: 3 additions & 1 deletion .github/actions/entrypoint.sh
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
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
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:

```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
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
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);
}
}
}
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
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
@@ -0,0 +1 @@
uOSJMhbKSG4V5xUHS7B9YHmVg/1yVd+G+Io6oBFhSfY=
1 change: 1 addition & 0 deletions tests/ed25519-1.sec
@@ -0,0 +1 @@
i4eTKkWNIISKumdk3v90cPDrY/g8WRTJWy7DmGDsdzC45IkyFspIbhXnFQdLsH1geZWD/XJV34b4ijqgEWFJ9g==

0 comments on commit d2113d9

Please sign in to comment.