Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,13 @@ Full detail: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — read it before t
(`EmsaPss` trait) and OpenSSL only does the raw RSA operation (`OPENSSL_NO_PADDING`).
- **ECDSA** (`ES256/ES256K/ES384/ES512`) — split; OpenSSL **plus** DER↔raw signature conversion (JWS needs raw
`R||S`).
- **EdDSA** — standalone signer/verifier via libsodium; needs `ext-sodium`.
- **EdDSA / Ed25519** — standalone signer/verifier via libsodium; needs `ext-sodium`. `Ed25519*` subclass the
`EdDsa*` classes, changing only the `alg` name to the RFC 9864 fully-specified `Ed25519`.
- **Ed448** — RFC 9864, Curve448 via OpenSSL (`openssl_sign`/`openssl_verify` with digest `0`); needs PHP 8.4+
— the `Ed448*` key classes enforce that by requiring `OPENSSL_KEYTYPE_ED448` at construction.

Keys: string-content (`HmacKey`, `EdDsa*` — `getContent()`) or OpenSSL (`Rsa*`, `Ecdsa*` — `getResource()`,
accept a file path **or** inline PEM).
Keys: string-content (`HmacKey`, `EdDsa*` — `getContent()`) or OpenSSL (`Rsa*`, `Ecdsa*`, `Ed448*` —
`getResource()`, accept a file path **or** inline PEM).

## Conventions

Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ composer install
./vendor/bin/phpunit
```

Requirements: PHP `>=7.4`, `ext-openssl`, `ext-json`, and `ext-sodium` (for EdDSA and its tests).
Requirements: PHP `>=7.4`, `ext-openssl`, `ext-json`, and `ext-sodium` (for EdDSA/Ed25519 and their tests).
The Ed448 algorithm and its tests additionally need PHP 8.4+ with OpenSSL Ed448 support.

## Ground rules

Expand Down
65 changes: 64 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Supported algorithms:
* **RSA**: `RS256`, `RS384`, and `RS512`
* **RSA-PSS**: `PS256`, `PS384`, and `PS512`
* **ECDSA**: `ES256`, `ES256K`, `ES384`, and `ES512`
* **EdDSA** (requires the `sodium` PHP extension)
* **EdDSA**: `EdDSA` and `Ed25519` (require the `sodium` PHP extension), and `Ed448` (requires PHP 8.4+)

Supported features:
* Built-in and custom validations
Expand Down Expand Up @@ -208,6 +208,69 @@ print_r($claims); // ['id' => 13, 'is-admin' => true]

Please note that EdDSA keys must be in string format. If they are already base64 encoded, decoding them is necessary before use.

### Ed25519 Algorithm

[RFC 9864](https://datatracker.ietf.org/doc/rfc9864/) replaces the `EdDSA` algorithm name with the fully-specified names `Ed25519` and `Ed448`.
`Ed25519` uses the exact same keys and signatures as `EdDSA` above; only the token's `alg` header differs.
It also requires the `sodium` PHP extension.

```php
use MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa\Ed25519Signer;
use MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa\Ed25519Verifier;
use MiladRahimi\Jwt\Cryptography\Keys\EdDsaPrivateKey;
use MiladRahimi\Jwt\Cryptography\Keys\EdDsaPublicKey;
use MiladRahimi\Jwt\Generator;
use MiladRahimi\Jwt\Parser;

// Generate a token
$privateKey = new EdDsaPrivateKey(base64_decode(file_get_contents('/path/to/ed25519.sec')));
$signer = new Ed25519Signer($privateKey);
$generator = new Generator($signer);
$jwt = $generator->generate(['id' => 13, 'is-admin' => true]);

// Parse the token
$publicKey = new EdDsaPublicKey(base64_decode(file_get_contents('/path/to/ed25519.pub')));
$verifier = new Ed25519Verifier($publicKey);
$parser = new Parser($verifier);
$claims = $parser->parse($jwt);

print_r($claims); // ['id' => 13, 'is-admin' => true]
```

### Ed448 Algorithm

`Ed448` (RFC 9864) is EdDSA over Curve448.
It runs on OpenSSL instead of Sodium and requires PHP 8.4 or later; on older PHP versions, creating the keys throws an exception.
The keys are PEM files (or inline PEM strings), which you can generate with the OpenSSL CLI:

```shell
openssl genpkey -algorithm ED448 -out ed448-private.pem
openssl pkey -in ed448-private.pem -pubout -out ed448-public.pem
```

```php
use MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa\Ed448Signer;
use MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa\Ed448Verifier;
use MiladRahimi\Jwt\Cryptography\Keys\Ed448PrivateKey;
use MiladRahimi\Jwt\Cryptography\Keys\Ed448PublicKey;
use MiladRahimi\Jwt\Generator;
use MiladRahimi\Jwt\Parser;

// Generate a token
$privateKey = new Ed448PrivateKey('/path/to/ed448-private.pem');
$signer = new Ed448Signer($privateKey);
$generator = new Generator($signer);
$jwt = $generator->generate(['id' => 13, 'is-admin' => true]);

// Parse the token
$publicKey = new Ed448PublicKey('/path/to/ed448-public.pem');
$verifier = new Ed448Verifier($publicKey);
$parser = new Parser($verifier);
$claims = $parser->parse($jwt);

print_r($claims); // ['id' => 13, 'is-admin' => true]
```

### Validation

By default, the package validates certain public claims if present (using `DefaultValidator`), and parses the claims.
Expand Down
4 changes: 4 additions & 0 deletions assets/keys/ed448-private.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-----BEGIN PRIVATE KEY-----
MEcCAQAwBQYDK2VxBDsEOa8E12pbH7bnEdRWDpnB9y3dpvYSQMjcA0m189X6qZSC
jgtXjYtmLDDOSP6jVoM+cMc9NmgdGBzJ5Q==
-----END PRIVATE KEY-----
4 changes: 4 additions & 0 deletions assets/keys/ed448-public.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MEMwBQYDK2VxAzoA4Va7EU52kvnGwPX4Nc7538FHxEzVMSs5lVg0sis7BqOvZTdd
ZZOjEt71Gk3UPBm2rY3AbksDeQCA
-----END PUBLIC KEY-----
4 changes: 4 additions & 0 deletions assets/keys/x448-private.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-----BEGIN PRIVATE KEY-----
MEYCAQAwBQYDK2VvBDoEOMiff8/ZaUP9fRji45VmVGzzLgXN4r1jTouY/gwuu+l8
y/kN1AoU5vVTsf2VECRxAALYcB6e26qd
-----END PRIVATE KEY-----
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"phpunit/phpunit": "^9.6"
},
"suggest": {
"ext-sodium": "Sodium extension is required for EdDSA algorithms"
"ext-sodium": "Sodium extension is required for the EdDSA and Ed25519 algorithms"
},
"autoload": {
"psr-4": {
Expand Down
4 changes: 3 additions & 1 deletion docs/ADDING_AN_ALGORITHM.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ header `alg` contradicts `name()`.
3. **Signature format**: convert on the boundary if your backend's encoding isn't the JWS form — see
`AbstractEcdsaSigner::derToSignature` / `AbstractEcdsaVerifier::signatureToDer` (DER↔raw).
4. **Optional extensions**: guard with `function_exists()` and add to `suggest` in `composer.json` (as EdDSA
does for `ext-sodium`).
does for `ext-sodium`). For a PHP-version floor, guard at key construction instead, as the `Ed448*` keys do
with `defined('OPENSSL_KEYTYPE_ED448')` — a capability check keeps mutants killable where a
`PHP_VERSION_ID` comparison would not.
5. **Tests** under `tests/Cryptography/...` following [`TESTING.md`](TESTING.md); test keys go in `assets/keys/`.
6. **Docs**: add to the README's algorithm list with an example and a round-trip in `tests/ExamplesTest.php`.

Expand Down
15 changes: 12 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ As defense in depth, a present `alg` that contradicts the verifier's `name()` is
## Design principles

- **Small interfaces, constructor injection** — every concern is swappable without subclassing.
- **No runtime dependencies** — PHP + `ext-openssl` + `ext-json` (+ `ext-sodium` for EdDSA).
- **No runtime dependencies** — PHP + `ext-openssl` + `ext-json` (+ `ext-sodium` for EdDSA/Ed25519).
- **PHP 7.4 floor** — typed properties yes; enums/`match`/promotion no.
- **Typed exceptions** — every failure is a `JwtException` subclass, so callers catch the base type broadly or
a specific subclass narrowly.
Expand Down Expand Up @@ -92,18 +92,27 @@ with `name()` (all built-in verifiers implement it).
- **EdDSA** (`Algorithms/Eddsa/`) — standalone signer/verifier via `sodium_crypto_sign_detached` /
`..._verify_detached`, guarded by `function_exists()`.
Keys are raw Ed25519 bytes (README base64-decodes them).
`Ed25519Signer`/`Ed25519Verifier` subclass them, changing only the JWS `alg` name to the RFC 9864
fully-specified `Ed25519` (RFC 9864 deprecates the polymorphic `EdDSA` identifier).
- **Ed448** (`Algorithms/Eddsa/`) — the other RFC 9864 EdDSA name, over Curve448. Sodium has no Ed448, so it
runs on OpenSSL: `openssl_sign`/`openssl_verify` with `0` as the digest (EdDSA hashes internally), which PHP
accepts since 8.4. `Ed448PrivateKey`/`Ed448PublicKey` gate the whole algorithm at construction by requiring
the `OPENSSL_KEYTYPE_ED448` constant (defined exactly when PHP 8.4+ is built against an OpenSSL with Ed448),
so the signer/verifier themselves stay guard-free.

### Keys (`Cryptography/Keys/`)

- **String-content** — `HmacKey`, `EdDsaPrivateKey`, `EdDsaPublicKey`: `__construct(string $key, ?string $id)`,
`getContent()`, no file I/O.
- **OpenSSL** — `Rsa*`, `Ecdsa*`: `getResource()` (`OpenSSLAsymmetricKey`/resource, typed `mixed`).
All four load identically — `is_file($key) ? file_get_contents(...) : $key` — so a **file path or inline
- **OpenSSL** — `Rsa*`, `Ecdsa*`, `Ed448*`: `getResource()` (`OpenSSLAsymmetricKey`/resource, typed `mixed`).
All load identically — `is_file($key) ? file_get_contents(...) : $key` — so a **file path or inline
PEM** both work.
Private keys add a passphrase (`(string $key, string $passphrase = '', ?string $id)`,
`openssl_pkey_get_private`); public keys `(string $key, ?string $id)`, `openssl_pkey_get_public`.
Failures throw `InvalidKeyException`.
RSA and ECDSA key classes are identical; the curve comes from the key material.
The `Ed448*` pair additionally throws `InvalidKeyException` when `OPENSSL_KEYTYPE_ED448` is undefined
(PHP below 8.4, or an OpenSSL without Ed448).

### `VerifierFactory`

Expand Down
8 changes: 6 additions & 2 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
`phpunit.xml` defines one testsuite `main` → `./tests`, coverage over `./src`.
There is no `composer test` script — call the binary directly.
Local coverage without pcov/xdebug: `phpdbg -qrr vendor/bin/phpunit --coverage-text`.
EdDSA tests need `ext-sodium`.
EdDSA/Ed25519 tests need `ext-sodium`; Ed448 tests skip themselves unless `OPENSSL_KEYTYPE_ED448` is defined
(PHP 8.4+), and the below-8.4 guard tests skip themselves everywhere else.
CI runs on PHP 7.4–8.5; new tests must pass on 7.4.

## Mutation testing
Expand Down Expand Up @@ -52,7 +53,10 @@ Override `setUp()` only by calling `parent::setUp()` first.
## Key assets (`assets/keys/`)

Test-only keys: `rsa-*.pem`, `ecdsa256/256k/384/512` pairs, `ed25519.sec`/`.pub` (raw base64 — decode before
use), and `assets/file.empty` for invalid-key cases.
use), `ed448-*.pem`, `x448-private.pem` (loads but cannot sign — Ed448's signing-failure case), and
`assets/file.empty` for invalid-key cases.
The Ed448 interop vector in `tests/InteropTest.php` is signed with `ed448-private.pem` via the OpenSSL CLI —
regenerate the keys and the vector together or not at all.
Reference PEM keys by `__DIR__`-relative path (depth varies by nesting).
The RSA-PSS tests additionally hold fixed odd-size RSA keys (2047/2041/2042 bits) as constants in
`tests/Cryptography/Algorithms/RsaPss/KeyFixtures.php`, paired with OpenSSL CLI signature vectors in the test
Expand Down
38 changes: 38 additions & 0 deletions examples/ed25519.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

/**
* Ed25519 — the RFC 9864 fully-specified name for EdDSA over Curve25519 (asymmetric: private signs, public
* verifies). Requires ext-sodium.
*
* Run: php examples/ed25519.php
*/

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa\Ed25519Signer;
use MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa\Ed25519Verifier;
use MiladRahimi\Jwt\Cryptography\Keys\EdDsaPrivateKey;
use MiladRahimi\Jwt\Cryptography\Keys\EdDsaPublicKey;
use MiladRahimi\Jwt\Generator;
use MiladRahimi\Jwt\Parser;

// 1) Keys — swap these for your own (raw Ed25519 key bytes, same keys as EdDSA).
// The sample key files store the bytes base64-encoded, so they are decoded here.
$privateKey = new EdDsaPrivateKey(base64_decode(file_get_contents(__DIR__ . '/../assets/keys/ed25519.sec')));
$publicKey = new EdDsaPublicKey(base64_decode(file_get_contents(__DIR__ . '/../assets/keys/ed25519.pub')));

// 2) Sign with the private key.
$signer = new Ed25519Signer($privateKey);
$jwt = (new Generator($signer))->generate([
'sub' => '42',
'name' => 'Pink Floyd',
]);
echo "Token:\n{$jwt}\n\n";

// 3) Verify with the public key.
$verifier = new Ed25519Verifier($publicKey);
$claims = (new Parser($verifier))->parse($jwt);
echo "Verified claims:\n";
print_r($claims);
41 changes: 41 additions & 0 deletions examples/ed448.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

/**
* Ed448 — EdDSA over Curve448, RFC 9864 (asymmetric: private signs, public verifies).
* Requires PHP 8.4+ with OpenSSL Ed448 support.
*
* Generate keys:
* openssl genpkey -algorithm ED448 -out ed448-private.pem
* openssl pkey -in ed448-private.pem -pubout -out ed448-public.pem
*
* Run: php examples/ed448.php
*/

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa\Ed448Signer;
use MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa\Ed448Verifier;
use MiladRahimi\Jwt\Cryptography\Keys\Ed448PrivateKey;
use MiladRahimi\Jwt\Cryptography\Keys\Ed448PublicKey;
use MiladRahimi\Jwt\Generator;
use MiladRahimi\Jwt\Parser;

// 1) Keys — swap these for your own (PEM file path or inline PEM content).
$privateKey = new Ed448PrivateKey(__DIR__ . '/../assets/keys/ed448-private.pem');
$publicKey = new Ed448PublicKey(__DIR__ . '/../assets/keys/ed448-public.pem');

// 2) Sign with the private key.
$signer = new Ed448Signer($privateKey);
$jwt = (new Generator($signer))->generate([
'sub' => '42',
'name' => 'Pink Floyd',
]);
echo "Token:\n{$jwt}\n\n";

// 3) Verify with the public key.
$verifier = new Ed448Verifier($publicKey);
$claims = (new Parser($verifier))->parse($jwt);
echo "Verified claims:\n";
print_r($claims);
2 changes: 2 additions & 0 deletions infection.json5
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@
// files); the call is guarded by is_file(), so the cast is unobservable.
"MiladRahimi\\Jwt\\Cryptography\\Keys\\EcdsaPrivateKey::__construct",
"MiladRahimi\\Jwt\\Cryptography\\Keys\\EcdsaPublicKey::__construct",
"MiladRahimi\\Jwt\\Cryptography\\Keys\\Ed448PrivateKey::__construct",
"MiladRahimi\\Jwt\\Cryptography\\Keys\\Ed448PublicKey::__construct",
"MiladRahimi\\Jwt\\Cryptography\\Keys\\RsaPrivateKey::__construct",
"MiladRahimi\\Jwt\\Cryptography\\Keys\\RsaPublicKey::__construct",
// `(string)$this->value` under an is_scalar() guard: string interpolation performs the exact
Expand Down
14 changes: 14 additions & 0 deletions src/Cryptography/Algorithms/Eddsa/Ed25519Signer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa;

/**
* Signs tokens with `Ed25519`, the RFC 9864 fully-specified name for EdDSA over Curve25519. It produces the
* same signatures as `EdDsaSigner`; only the JWS `alg` header value differs.
*/
class Ed25519Signer extends EdDsaSigner
{
protected static string $name = 'Ed25519';
}
14 changes: 14 additions & 0 deletions src/Cryptography/Algorithms/Eddsa/Ed25519Verifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa;

/**
* Verifies tokens signed with `Ed25519`, the RFC 9864 fully-specified name for EdDSA over Curve25519. It
* accepts the same signatures as `EdDsaVerifier`; only the JWS `alg` header value differs.
*/
class Ed25519Verifier extends EdDsaVerifier
{
protected static string $name = 'Ed25519';
}
64 changes: 64 additions & 0 deletions src/Cryptography/Algorithms/Eddsa/Ed448Signer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa;

use MiladRahimi\Jwt\Cryptography\Keys\Ed448PrivateKey;
use MiladRahimi\Jwt\Cryptography\Signer;
use MiladRahimi\Jwt\Exceptions\SigningException;

/**
* Signs tokens with `Ed448` (RFC 9864), EdDSA over Curve448, via OpenSSL. Requires PHP 8.4 or later;
* `Ed448PrivateKey` enforces that at construction.
*/
class Ed448Signer implements Signer
{
protected static string $name = 'Ed448';

protected Ed448PrivateKey $privateKey;

public function __construct(Ed448PrivateKey $privateKey)
{
$this->privateKey = $privateKey;
}

/**
* {@inheritDoc}
*/
public function sign(string $message): string
{
$signature = '';

// EdDSA hashes internally, so no digest algorithm (`0`) is passed to OpenSSL.
if (
openssl_sign($message, $signature, $this->privateKey->getResource(), 0) === true
&& is_string($signature)
) {
return $signature;
}

throw new SigningException(openssl_error_string() ?: 'OpenSSL cannot sign the token.');
}

/**
* {@inheritDoc}
*/
public function name(): string
{
return static::$name;
}

/**
* {@inheritDoc}
*/
public function kid(): ?string
{
return $this->privateKey->getId();
}

public function getPrivateKey(): Ed448PrivateKey
{
return $this->privateKey;
}
}
Loading
Loading