From cd1a649f7341e7723174b1a026a26f4d7a206946 Mon Sep 17 00:00:00 2001 From: Milad Rahimi Date: Fri, 31 Jul 2026 12:38:12 +0200 Subject: [PATCH] feat: add Ed25519 and Ed448 algorithms (RFC 9864) RFC 9864 deprecates the polymorphic EdDSA identifier in favor of the fully-specified Ed25519 and Ed448 names, which jwt.io now lists. - Ed25519Signer/Ed25519Verifier subclass the EdDsa classes, changing only the JWS alg name; same sodium keys and signatures. - Ed448Signer/Ed448Verifier run on OpenSSL (openssl_sign/openssl_verify with no digest), available since PHP 8.4. The Ed448PrivateKey and Ed448PublicKey classes (PEM path or inline, like RSA/ECDSA keys) gate the algorithm at construction by requiring OPENSSL_KEYTYPE_ED448, so PHP 7.4-8.3 users get a clear InvalidKeyException and everything else keeps working unchanged. - Interop vectors signed with the OpenSSL CLI: sodium verifies an independent Ed25519 implementation (RFC 8037 key), and the Ed448 vector pins the raw 114-byte signature format. - New test keys: ed448-*.pem plus x448-private.pem (loads but cannot sign) for the signing-failure path. --- CLAUDE.md | 9 +- CONTRIBUTING.md | 3 +- README.md | 65 +++++- assets/keys/ed448-private.pem | 4 + assets/keys/ed448-public.pem | 4 + assets/keys/x448-private.pem | 4 + composer.json | 2 +- docs/ADDING_AN_ALGORITHM.md | 4 +- docs/ARCHITECTURE.md | 15 +- docs/TESTING.md | 8 +- examples/ed25519.php | 38 ++++ examples/ed448.php | 41 ++++ infection.json5 | 2 + .../Algorithms/Eddsa/Ed25519Signer.php | 14 ++ .../Algorithms/Eddsa/Ed25519Verifier.php | 14 ++ .../Algorithms/Eddsa/Ed448Signer.php | 64 ++++++ .../Algorithms/Eddsa/Ed448Verifier.php | 57 ++++++ src/Cryptography/Keys/Ed448PrivateKey.php | 59 ++++++ src/Cryptography/Keys/Ed448PublicKey.php | 58 ++++++ .../Algorithms/Eddsa/Ed25519Test.php | 133 ++++++++++++ .../Algorithms/Eddsa/Ed448Test.php | 192 ++++++++++++++++++ .../Cryptography/Keys/Ed448PrivateKeyTest.php | 108 ++++++++++ .../Cryptography/Keys/Ed448PublicKeyTest.php | 108 ++++++++++ tests/ExamplesScriptsTest.php | 8 +- tests/ExamplesTest.php | 54 +++++ tests/InteropTest.php | 55 +++++ 26 files changed, 1109 insertions(+), 14 deletions(-) create mode 100644 assets/keys/ed448-private.pem create mode 100644 assets/keys/ed448-public.pem create mode 100644 assets/keys/x448-private.pem create mode 100644 examples/ed25519.php create mode 100644 examples/ed448.php create mode 100644 src/Cryptography/Algorithms/Eddsa/Ed25519Signer.php create mode 100644 src/Cryptography/Algorithms/Eddsa/Ed25519Verifier.php create mode 100644 src/Cryptography/Algorithms/Eddsa/Ed448Signer.php create mode 100644 src/Cryptography/Algorithms/Eddsa/Ed448Verifier.php create mode 100644 src/Cryptography/Keys/Ed448PrivateKey.php create mode 100644 src/Cryptography/Keys/Ed448PublicKey.php create mode 100644 tests/Cryptography/Algorithms/Eddsa/Ed25519Test.php create mode 100644 tests/Cryptography/Algorithms/Eddsa/Ed448Test.php create mode 100644 tests/Cryptography/Keys/Ed448PrivateKeyTest.php create mode 100644 tests/Cryptography/Keys/Ed448PublicKeyTest.php diff --git a/CLAUDE.md b/CLAUDE.md index cd2993b..992d5ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec0ce2d..0ace29a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index eac95d9..2dc8605 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/assets/keys/ed448-private.pem b/assets/keys/ed448-private.pem new file mode 100644 index 0000000..5a6bfc4 --- /dev/null +++ b/assets/keys/ed448-private.pem @@ -0,0 +1,4 @@ +-----BEGIN PRIVATE KEY----- +MEcCAQAwBQYDK2VxBDsEOa8E12pbH7bnEdRWDpnB9y3dpvYSQMjcA0m189X6qZSC +jgtXjYtmLDDOSP6jVoM+cMc9NmgdGBzJ5Q== +-----END PRIVATE KEY----- diff --git a/assets/keys/ed448-public.pem b/assets/keys/ed448-public.pem new file mode 100644 index 0000000..05937d3 --- /dev/null +++ b/assets/keys/ed448-public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MEMwBQYDK2VxAzoA4Va7EU52kvnGwPX4Nc7538FHxEzVMSs5lVg0sis7BqOvZTdd +ZZOjEt71Gk3UPBm2rY3AbksDeQCA +-----END PUBLIC KEY----- diff --git a/assets/keys/x448-private.pem b/assets/keys/x448-private.pem new file mode 100644 index 0000000..20b8e09 --- /dev/null +++ b/assets/keys/x448-private.pem @@ -0,0 +1,4 @@ +-----BEGIN PRIVATE KEY----- +MEYCAQAwBQYDK2VvBDoEOMiff8/ZaUP9fRji45VmVGzzLgXN4r1jTouY/gwuu+l8 +y/kN1AoU5vVTsf2VECRxAALYcB6e26qd +-----END PRIVATE KEY----- diff --git a/composer.json b/composer.json index 70367d5..4940ce3 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/docs/ADDING_AN_ALGORITHM.md b/docs/ADDING_AN_ALGORITHM.md index b98708c..fabfc61 100644 --- a/docs/ADDING_AN_ALGORITHM.md +++ b/docs/ADDING_AN_ALGORITHM.md @@ -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`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9ba4e71..4e87807 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. @@ -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` diff --git a/docs/TESTING.md b/docs/TESTING.md index d365b25..d6845a8 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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 @@ -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 diff --git a/examples/ed25519.php b/examples/ed25519.php new file mode 100644 index 0000000..4f8eef8 --- /dev/null +++ b/examples/ed25519.php @@ -0,0 +1,38 @@ +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); diff --git a/examples/ed448.php b/examples/ed448.php new file mode 100644 index 0000000..c8c791e --- /dev/null +++ b/examples/ed448.php @@ -0,0 +1,41 @@ +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); diff --git a/infection.json5 b/infection.json5 index d1920e3..c619376 100644 --- a/infection.json5 +++ b/infection.json5 @@ -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 diff --git a/src/Cryptography/Algorithms/Eddsa/Ed25519Signer.php b/src/Cryptography/Algorithms/Eddsa/Ed25519Signer.php new file mode 100644 index 0000000..44ac110 --- /dev/null +++ b/src/Cryptography/Algorithms/Eddsa/Ed25519Signer.php @@ -0,0 +1,14 @@ +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; + } +} diff --git a/src/Cryptography/Algorithms/Eddsa/Ed448Verifier.php b/src/Cryptography/Algorithms/Eddsa/Ed448Verifier.php new file mode 100644 index 0000000..d5326d3 --- /dev/null +++ b/src/Cryptography/Algorithms/Eddsa/Ed448Verifier.php @@ -0,0 +1,57 @@ +publicKey = $publicKey; + } + + /** + * {@inheritDoc} + */ + public function verify(string $plain, string $signature): void + { + // EdDSA hashes internally, so no digest algorithm (`0`) is passed to OpenSSL. + if (openssl_verify($plain, $signature, $this->publicKey->getResource(), 0) !== 1) { + throw new InvalidSignatureException(openssl_error_string() ?: 'The signature is not valid.'); + } + } + + /** + * {@inheritDoc} + */ + public function name(): string + { + return static::$name; + } + + /** + * {@inheritDoc} + */ + public function kid(): ?string + { + return $this->publicKey->getId(); + } + + public function getPublicKey(): Ed448PublicKey + { + return $this->publicKey; + } +} diff --git a/src/Cryptography/Keys/Ed448PrivateKey.php b/src/Cryptography/Keys/Ed448PrivateKey.php new file mode 100644 index 0000000..ae94515 --- /dev/null +++ b/src/Cryptography/Keys/Ed448PrivateKey.php @@ -0,0 +1,59 @@ +resource = $resource; + + $this->id = $id; + } + + /** + * @return resource|\OpenSSLAsymmetricKey The OpenSSL key handle. + * @phpstan-return resource + */ + public function getResource() + { + return $this->resource; + } + + public function getId(): ?string + { + return $this->id; + } +} diff --git a/src/Cryptography/Keys/Ed448PublicKey.php b/src/Cryptography/Keys/Ed448PublicKey.php new file mode 100644 index 0000000..6e616bd --- /dev/null +++ b/src/Cryptography/Keys/Ed448PublicKey.php @@ -0,0 +1,58 @@ +resource = $resource; + + $this->id = $id; + } + + /** + * @return resource|\OpenSSLAsymmetricKey The OpenSSL key handle. + * @phpstan-return resource + */ + public function getResource() + { + return $this->resource; + } + + public function getId(): ?string + { + return $this->id; + } +} diff --git a/tests/Cryptography/Algorithms/Eddsa/Ed25519Test.php b/tests/Cryptography/Algorithms/Eddsa/Ed25519Test.php new file mode 100644 index 0000000..8f701ce --- /dev/null +++ b/tests/Cryptography/Algorithms/Eddsa/Ed25519Test.php @@ -0,0 +1,133 @@ +privateKey = new EdDsaPrivateKey( + base64_decode(file_get_contents(__DIR__ . '/../../../../assets/keys/ed25519.sec')), + 'id-1' + ); + $this->publicKey = new EdDsaPublicKey( + base64_decode(file_get_contents(__DIR__ . '/../../../../assets/keys/ed25519.pub')), + 'id-1' + ); + } + + /** + * @throws Throwable + */ + public function test_signer_and_verifier_they_should_sign_and_verify_with_the_pair_key() + { + $plain = 'Text'; + + $signer = new Ed25519Signer($this->privateKey); + $signature = $signer->sign($plain); + + $verifier = new Ed25519Verifier($this->publicKey); + $verifier->verify($plain, $signature); + + $this->assertTrue(true); + } + + /** + * @throws Throwable + */ + public function test_signer_and_verifier_they_should_fail_with_different_plains() + { + $signer = new Ed25519Signer($this->privateKey); + $signature = $signer->sign('Header Payload'); + + $verifier = new Ed25519Verifier($this->publicKey); + + $this->expectException(InvalidSignatureException::class); + $verifier->verify('Different!', $signature); + } + + /** + * Ed25519 is the RFC 9864 fully-specified name for EdDSA; both signers must produce identical signatures. + * + * @throws Throwable + */ + public function test_sign_it_should_produce_the_same_signature_as_eddsa() + { + $ed25519Signature = (new Ed25519Signer($this->privateKey))->sign('Text'); + $edDsaSignature = (new EdDsaSigner($this->privateKey))->sign('Text'); + + $this->assertSame($edDsaSignature, $ed25519Signature); + } + + /** + * @throws Throwable + */ + public function test_generate_and_parse_the_token_header_should_declare_ed25519() + { + $generator = new Generator(new Ed25519Signer($this->privateKey)); + $jwt = $generator->generate(['id' => 666]); + + $header = json_decode(base64_decode(explode('.', $jwt)[0]), true); + $this->assertSame('Ed25519', $header['alg']); + + $parser = new Parser(new Ed25519Verifier($this->publicKey)); + $this->assertEquals(['id' => 666], $parser->parse($jwt)); + } + + /** + * @throws Throwable + */ + public function test_set_and_get_private_key() + { + $signer = new Ed25519Signer($this->privateKey); + + $this->assertSame($this->privateKey, $signer->getPrivateKey()); + $this->assertSame('Ed25519', $signer->name()); + } + + /** + * @throws Throwable + */ + public function test_set_and_get_public_key() + { + $verifier = new Ed25519Verifier($this->publicKey); + $this->assertSame($this->publicKey, $verifier->getPublicKey()); + } + + /** + * @throws Throwable + */ + public function test_name() + { + $verifier = new Ed25519Verifier($this->publicKey); + $this->assertSame('Ed25519', $verifier->name()); + } + + /** + * @throws Throwable + */ + public function test_kid() + { + $verifier = new Ed25519Verifier($this->publicKey); + $this->assertSame($this->publicKey->getId(), $verifier->kid()); + } +} diff --git a/tests/Cryptography/Algorithms/Eddsa/Ed448Test.php b/tests/Cryptography/Algorithms/Eddsa/Ed448Test.php new file mode 100644 index 0000000..c85d174 --- /dev/null +++ b/tests/Cryptography/Algorithms/Eddsa/Ed448Test.php @@ -0,0 +1,192 @@ +markTestSkipped('The `Ed448` algorithm requires PHP 8.4 or later with OpenSSL Ed448 support.'); + } + + $this->privateKey = new Ed448PrivateKey(__DIR__ . '/../../../../assets/keys/ed448-private.pem', '', 'id-1'); + $this->publicKey = new Ed448PublicKey(__DIR__ . '/../../../../assets/keys/ed448-public.pem', 'id-1'); + } + + /** + * @throws Throwable + */ + public function test_signer_and_verifier_they_should_sign_and_verify_with_the_pair_key() + { + $plain = 'Text'; + + $signer = new Ed448Signer($this->privateKey); + $signature = $signer->sign($plain); + + $verifier = new Ed448Verifier($this->publicKey); + $verifier->verify($plain, $signature); + + $this->assertTrue(true); + } + + /** + * @throws Throwable + */ + public function test_signer_and_verifier_they_should_fail_with_different_plains() + { + $signer = new Ed448Signer($this->privateKey); + $signature = $signer->sign('Header Payload'); + + $verifier = new Ed448Verifier($this->publicKey); + + $this->expectException(InvalidSignatureException::class); + $verifier->verify('Different!', $signature); + } + + /** + * @throws Throwable + */ + public function test_generate_and_parse_the_token_header_should_declare_ed448() + { + $generator = new Generator(new Ed448Signer($this->privateKey)); + $jwt = $generator->generate(['id' => 666]); + + $header = json_decode(base64_decode(explode('.', $jwt)[0]), true); + $this->assertSame('Ed448', $header['alg']); + + $parser = new Parser(new Ed448Verifier($this->publicKey)); + $this->assertEquals(['id' => 666], $parser->parse($jwt)); + } + + /** + * @throws Throwable + */ + public function test_verify_with_a_different_key_it_should_fail() + { + $signature = (new Ed448Signer($this->privateKey))->sign('Text'); + + $resource = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_ED448]); + $otherPublicKey = new Ed448PublicKey(openssl_pkey_get_details($resource)['key']); + + $verifier = new Ed448Verifier($otherPublicKey); + + $this->expectException(InvalidSignatureException::class); + $verifier->verify('Text', $signature); + } + + /** + * An X448 key loads fine but cannot sign, so OpenSSL fails and the exception carries its error message. + * + * @throws Throwable + */ + public function test_sign_with_a_non_signing_key_it_should_carry_the_openssl_error() + { + while (openssl_error_string() !== false) { + continue; + } + + $signer = new Ed448Signer(new Ed448PrivateKey(__DIR__ . '/../../../../assets/keys/x448-private.pem')); + + try { + $signer->sign('Text'); + $this->fail('A SigningException was expected.'); + } catch (SigningException $e) { + $this->assertStringStartsWith('error:', $e->getMessage()); + } + } + + /** + * The exception surfaces the pending OpenSSL error instead of the generic fallback message. The queue is + * drained and re-seeded with a known error to make its state deterministic across platforms. + * + * @throws Throwable + */ + public function test_verify_with_a_wrong_signature_it_should_carry_the_openssl_error() + { + while (openssl_error_string() !== false) { + continue; + } + openssl_pkey_get_private('not-a-valid-key'); + + $verifier = new Ed448Verifier($this->publicKey); + + try { + $verifier->verify('Plain', str_repeat("\x01", 114)); + $this->fail('An InvalidSignatureException was expected.'); + } catch (InvalidSignatureException $e) { + $this->assertStringStartsWith('error:', $e->getMessage()); + } + } + + /** + * @throws Throwable + */ + public function test_verify_with_an_empty_signature_it_should_fail() + { + $verifier = new Ed448Verifier($this->publicKey); + + $this->expectException(InvalidSignatureException::class); + $verifier->verify('Header Payload', ''); + } + + /** + * @throws Throwable + */ + public function test_set_and_get_private_key() + { + $signer = new Ed448Signer($this->privateKey); + + $this->assertSame($this->privateKey, $signer->getPrivateKey()); + $this->assertSame('Ed448', $signer->name()); + } + + /** + * @throws Throwable + */ + public function test_set_and_get_public_key() + { + $verifier = new Ed448Verifier($this->publicKey); + $this->assertSame($this->publicKey, $verifier->getPublicKey()); + } + + /** + * @throws Throwable + */ + public function test_name() + { + $verifier = new Ed448Verifier($this->publicKey); + $this->assertSame('Ed448', $verifier->name()); + } + + /** + * @throws Throwable + */ + public function test_kid() + { + $signer = new Ed448Signer($this->privateKey); + $verifier = new Ed448Verifier($this->publicKey); + + $this->assertSame('id-1', $signer->kid()); + $this->assertSame('id-1', $verifier->kid()); + } +} diff --git a/tests/Cryptography/Keys/Ed448PrivateKeyTest.php b/tests/Cryptography/Keys/Ed448PrivateKeyTest.php new file mode 100644 index 0000000..959e810 --- /dev/null +++ b/tests/Cryptography/Keys/Ed448PrivateKeyTest.php @@ -0,0 +1,108 @@ +markTestSkipped('Ed448 keys require PHP 8.4 or later with OpenSSL Ed448 support.'); + } + } + + /** + * @throws Throwable + */ + public function test_with_valid_key_file_it_should_pass() + { + $this->requireEd448Support(); + + $key = new Ed448PrivateKey(__DIR__ . '/../../../assets/keys/ed448-private.pem'); + $this->assertNotNull($key->getResource()); + } + + /** + * @throws Throwable + */ + public function test_with_valid_key_string_it_should_pass() + { + $this->requireEd448Support(); + + $key = new Ed448PrivateKey(file_get_contents(__DIR__ . '/../../../assets/keys/ed448-private.pem')); + $this->assertNotNull($key->getResource()); + } + + /** + * @throws Throwable + */ + public function test_id() + { + $this->requireEd448Support(); + + $key = new Ed448PrivateKey(__DIR__ . '/../../../assets/keys/ed448-private.pem', '', 'id-1'); + $this->assertEquals('id-1', $key->getId()); + } + + /** + * @throws Throwable + */ + public function test_with_invalid_key_path_it_should_fail() + { + $this->requireEd448Support(); + + $this->expectException(InvalidKeyException::class); + new Ed448PrivateKey('Invalid Key!'); + } + + /** + * @throws Throwable + */ + public function test_with_invalid_key_file_it_should_fail() + { + $this->requireEd448Support(); + + $this->expectException(InvalidKeyException::class); + new Ed448PrivateKey(__DIR__ . '/../../../assets/file.empty'); + } + + /** + * The exception carries the underlying OpenSSL error explaining why the key was rejected. + * + * @throws Throwable + */ + public function test_with_invalid_key_it_should_carry_the_openssl_error() + { + $this->requireEd448Support(); + + try { + new Ed448PrivateKey('Invalid Key!'); + $this->fail('An InvalidKeyException was expected.'); + } catch (InvalidKeyException $e) { + $this->assertStringStartsWith('error:', $e->getMessage()); + } + } + + /** + * On runtimes without Ed448 support (PHP below 8.4), construction fails fast with a clear message. + * + * @throws Throwable + */ + public function test_without_ed448_support_it_should_fail() + { + if (defined('OPENSSL_KEYTYPE_ED448')) { + $this->markTestSkipped('This environment supports Ed448 keys.'); + } + + $this->expectException(InvalidKeyException::class); + $this->expectExceptionMessage('Ed448 keys require PHP 8.4 or later with OpenSSL Ed448 support.'); + new Ed448PrivateKey(__DIR__ . '/../../../assets/keys/ed448-private.pem'); + } +} diff --git a/tests/Cryptography/Keys/Ed448PublicKeyTest.php b/tests/Cryptography/Keys/Ed448PublicKeyTest.php new file mode 100644 index 0000000..272b2db --- /dev/null +++ b/tests/Cryptography/Keys/Ed448PublicKeyTest.php @@ -0,0 +1,108 @@ +markTestSkipped('Ed448 keys require PHP 8.4 or later with OpenSSL Ed448 support.'); + } + } + + /** + * @throws Throwable + */ + public function test_with_valid_key_file_it_should_pass() + { + $this->requireEd448Support(); + + $key = new Ed448PublicKey(__DIR__ . '/../../../assets/keys/ed448-public.pem'); + $this->assertNotNull($key->getResource()); + } + + /** + * @throws Throwable + */ + public function test_with_valid_key_string_it_should_pass() + { + $this->requireEd448Support(); + + $key = new Ed448PublicKey(file_get_contents(__DIR__ . '/../../../assets/keys/ed448-public.pem')); + $this->assertNotNull($key->getResource()); + } + + /** + * @throws Throwable + */ + public function test_id() + { + $this->requireEd448Support(); + + $key = new Ed448PublicKey(__DIR__ . '/../../../assets/keys/ed448-public.pem', 'id-1'); + $this->assertEquals('id-1', $key->getId()); + } + + /** + * @throws Throwable + */ + public function test_with_invalid_key_path_it_should_fail() + { + $this->requireEd448Support(); + + $this->expectException(InvalidKeyException::class); + new Ed448PublicKey('Invalid Key!'); + } + + /** + * @throws Throwable + */ + public function test_with_invalid_key_file_it_should_fail() + { + $this->requireEd448Support(); + + $this->expectException(InvalidKeyException::class); + new Ed448PublicKey(__DIR__ . '/../../../assets/file.empty'); + } + + /** + * The exception carries the underlying OpenSSL error explaining why the key was rejected. + * + * @throws Throwable + */ + public function test_with_invalid_key_it_should_carry_the_openssl_error() + { + $this->requireEd448Support(); + + try { + new Ed448PublicKey('Invalid Key!'); + $this->fail('An InvalidKeyException was expected.'); + } catch (InvalidKeyException $e) { + $this->assertStringStartsWith('error:', $e->getMessage()); + } + } + + /** + * On runtimes without Ed448 support (PHP below 8.4), construction fails fast with a clear message. + * + * @throws Throwable + */ + public function test_without_ed448_support_it_should_fail() + { + if (defined('OPENSSL_KEYTYPE_ED448')) { + $this->markTestSkipped('This environment supports Ed448 keys.'); + } + + $this->expectException(InvalidKeyException::class); + $this->expectExceptionMessage('Ed448 keys require PHP 8.4 or later with OpenSSL Ed448 support.'); + new Ed448PublicKey(__DIR__ . '/../../../assets/keys/ed448-public.pem'); + } +} diff --git a/tests/ExamplesScriptsTest.php b/tests/ExamplesScriptsTest.php index ff48eea..1c67fc1 100644 --- a/tests/ExamplesScriptsTest.php +++ b/tests/ExamplesScriptsTest.php @@ -18,8 +18,12 @@ public function test_every_example_script_it_should_run_successfully(): void foreach ($scripts as $script) { $name = basename($script); - if ($name === 'eddsa.php' && !extension_loaded('sodium')) { - continue; // EdDSA needs ext-sodium + if (in_array($name, ['eddsa.php', 'ed25519.php'], true) && !extension_loaded('sodium')) { + continue; // EdDSA/Ed25519 need ext-sodium + } + + if ($name === 'ed448.php' && !defined('OPENSSL_KEYTYPE_ED448')) { + continue; // Ed448 needs PHP 8.4+ with OpenSSL Ed448 support } $output = []; diff --git a/tests/ExamplesTest.php b/tests/ExamplesTest.php index 560c78a..182fb62 100644 --- a/tests/ExamplesTest.php +++ b/tests/ExamplesTest.php @@ -8,6 +8,10 @@ use MiladRahimi\Jwt\Cryptography\Algorithms\Ecdsa\ES384Verifier; use MiladRahimi\Jwt\Cryptography\Algorithms\Ecdsa\ES512Signer; use MiladRahimi\Jwt\Cryptography\Algorithms\Ecdsa\ES512Verifier; +use MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa\Ed25519Signer; +use MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa\Ed25519Verifier; +use MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa\Ed448Signer; +use MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa\Ed448Verifier; use MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa\EdDsaSigner; use MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa\EdDsaVerifier; use MiladRahimi\Jwt\Cryptography\Algorithms\Hmac\HS256; @@ -17,6 +21,8 @@ use MiladRahimi\Jwt\Cryptography\Algorithms\RsaPss\PS256Verifier; use MiladRahimi\Jwt\Cryptography\Keys\EcdsaPrivateKey; use MiladRahimi\Jwt\Cryptography\Keys\EcdsaPublicKey; +use MiladRahimi\Jwt\Cryptography\Keys\Ed448PrivateKey; +use MiladRahimi\Jwt\Cryptography\Keys\Ed448PublicKey; use MiladRahimi\Jwt\Cryptography\Keys\EdDsaPrivateKey; use MiladRahimi\Jwt\Cryptography\Keys\EdDsaPublicKey; use MiladRahimi\Jwt\Cryptography\Keys\HmacKey; @@ -186,6 +192,54 @@ public function test_eddsa_algorithms() $this->assertEquals(['id' => 666, 'is-admin' => true], $claims); } + /** + * @throws Throwable + */ + public function test_ed25519_algorithm() + { + // Generate a token + $privateKey = new EdDsaPrivateKey( + base64_decode(file_get_contents(__DIR__ . '/../assets/keys/ed25519.sec')) + ); + $signer = new Ed25519Signer($privateKey); + $generator = new Generator($signer); + $jwt = $generator->generate(['id' => 666, 'is-admin' => true]); + + // Parse the token + $publicKey = new EdDsaPublicKey( + base64_decode(file_get_contents(__DIR__ . '/../assets/keys/ed25519.pub')) + ); + $verifier = new Ed25519Verifier($publicKey); + $parser = new Parser($verifier); + $claims = $parser->parse($jwt); + + $this->assertEquals(['id' => 666, 'is-admin' => true], $claims); + } + + /** + * @throws Throwable + */ + public function test_ed448_algorithm() + { + if (!defined('OPENSSL_KEYTYPE_ED448')) { + $this->markTestSkipped('The `Ed448` algorithm requires PHP 8.4 or later with OpenSSL Ed448 support.'); + } + + // Generate a token + $privateKey = new Ed448PrivateKey(__DIR__ . '/../assets/keys/ed448-private.pem'); + $signer = new Ed448Signer($privateKey); + $generator = new Generator($signer); + $jwt = $generator->generate(['id' => 666, 'is-admin' => true]); + + // Parse the token + $publicKey = new Ed448PublicKey(__DIR__ . '/../assets/keys/ed448-public.pem'); + $verifier = new Ed448Verifier($publicKey); + $parser = new Parser($verifier); + $claims = $parser->parse($jwt); + + $this->assertEquals(['id' => 666, 'is-admin' => true], $claims); + } + /** * @throws Throwable */ diff --git a/tests/InteropTest.php b/tests/InteropTest.php index 3de889a..a9b1a4d 100644 --- a/tests/InteropTest.php +++ b/tests/InteropTest.php @@ -6,9 +6,12 @@ use MiladRahimi\Jwt\Base64\SafeBase64Parser; use MiladRahimi\Jwt\Cryptography\Algorithms\Ecdsa\ES512Verifier; +use MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa\Ed25519Verifier; +use MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa\Ed448Verifier; use MiladRahimi\Jwt\Cryptography\Algorithms\Eddsa\EdDsaVerifier; use MiladRahimi\Jwt\Cryptography\Algorithms\Hmac\HS256; use MiladRahimi\Jwt\Cryptography\Keys\EcdsaPublicKey; +use MiladRahimi\Jwt\Cryptography\Keys\Ed448PublicKey; use MiladRahimi\Jwt\Cryptography\Keys\EdDsaPublicKey; use MiladRahimi\Jwt\Cryptography\Keys\HmacKey; use MiladRahimi\Jwt\Parser; @@ -96,4 +99,56 @@ public function test_verify_the_rfc8037_ed25519_example_signature() $this->assertTrue(true); } + + /** + * Verifies an `Ed25519` (RFC 9864) signature produced by the OpenSSL CLI, so libsodium checks the output + * of an independent implementation. The key is the RFC 8037 Appendix A.1 pair and the payload matches the + * RFC 8037 Appendix A.4 example; only the header differs (`{"alg":"Ed25519"}`). Regenerate with: + * `openssl pkeyutl -sign -rawin -inkey -in `. + * + * @throws Throwable + */ + public function test_verify_the_openssl_ed25519_example_signature() + { + $publicKey = new EdDsaPublicKey( + hex2bin('d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a') + ); + + $signingInput = 'eyJhbGciOiJFZDI1NTE5In0.RXhhbXBsZSBvZiBFZDI1NTE5IHNpZ25pbmc'; + $signature = (new SafeBase64Parser())->decode( + 'UxhIYLHGg39NVCLpQAVD_UcfOmnGSCzLFZoXYkLiIbFccmOb_qObsgjzLKsfJw-4NlccUgvYrEHrRbNV0HcZAQ' + ); + + $verifier = new Ed25519Verifier($publicKey); + $verifier->verify($signingInput, $signature); + + $this->assertTrue(true); + } + + /** + * Verifies an `Ed448` (RFC 9864) signature produced by the OpenSSL CLI with the test key pair, pinning the + * signature format (raw 114 bytes) and the header (`{"alg":"Ed448"}`) against drift. Regenerate with: + * `openssl pkeyutl -sign -rawin -inkey assets/keys/ed448-private.pem -in `. + * + * @throws Throwable + */ + public function test_verify_the_openssl_ed448_example_signature() + { + if (!defined('OPENSSL_KEYTYPE_ED448')) { + $this->markTestSkipped('The `Ed448` algorithm requires PHP 8.4 or later with OpenSSL Ed448 support.'); + } + + $publicKey = new Ed448PublicKey(__DIR__ . '/../assets/keys/ed448-public.pem'); + + $signingInput = 'eyJhbGciOiJFZDQ0OCJ9.RXhhbXBsZSBvZiBFZDQ0OCBzaWduaW5n'; + $signature = (new SafeBase64Parser())->decode( + 'wtleX23Jt23w5vgNjQC3jihdYbhnUXHHP8VMRJuxWMS9SqJxXIXE1AlGh8JX7LUfQwksHEmhIQWAAAd9TLIrob05r4' + . 'VKc0hgkGMA88ljBjvYy4W_dYI4xrQRSZfQ0TBcZMQ9o4X0JKE7sE-wGvM_Sz8A' + ); + + $verifier = new Ed448Verifier($publicKey); + $verifier->verify($signingInput, $signature); + + $this->assertTrue(true); + } }