From 2bd3f62110b21ef4d28c1e35cdf6c3355e3ba2fc Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Sun, 13 Sep 2026 13:58:52 +0200 Subject: [PATCH] feat(signature): Ed25519 and Ed448 fully-specified algorithms, X448 for ECDH-ES (RFC 9864, RFC 8037) RFC 9864 registers the fully-specified "Ed25519" and "Ed448" signature algorithms and deprecates the polymorphic "EdDSA": the name of the algorithm alone must say which curve is in use. The library only knew "EdDSA", on Ed25519 through ext-sodium, and refused the Ed448 and X448 keys its converters already read. AbstractEdDSA carries PureEdDSA (RFC 8037 section 3.1): backend choice, signature length check before any backend is called so that a malformed signature is false and never an exception, and the curve check. Ed25519 and Ed448 each accept their own curve and refuse the other with an InvalidKeyException. EdDSA stays, restricted to Ed25519 as it always was, throws the same UnsupportedCurveException as before on another curve, and is deprecated: signing with it raises the deprecation, verifying the tokens already in circulation does not, and its removal is a 5.0.0 candidate (#717). ext-sodium knows neither Ed448 nor X448. Both run on ext-openssl, which PHP exposes for these curves since 8.4 only: openssl_sign() refuses the null digest and openssl_pkey_get_details() returns no raw material before that version. Ed448 and X448 are therefore gated on the PHP version, not on the OpenSSL one: isSupported() tells, the constructor throws a MissingDependencyException, and the bundle registers the algorithm only when it can run. Ed25519 and X25519 keep sodium when it is loaded and fall back to OpenSSL on PHP 8.4 otherwise. OKPKey centralises the generation, the Diffie-Hellman and the OpenSSL signature primitives that JWKFactory and AbstractECDH duplicated; the OpenSSL variants are public so that the OpenSSL path of the 25519 curves is testable when sodium is loaded. AbstractECDH accepts X448 wherever X25519 was, in every ECDH-ES and ECDH-SS variant. A new OKPKeyAnalyzer checks the curve, the x and d sizes and the consistency of alg and crv; AlgorithmAnalyzer reports "alg": "EdDSA" as deprecated with the fully-specified replacement. Backward compatibility was verified: the reflection dump of the public API only shows additions, and the behavioural probes run against 4.3.x show no call that worked and now throws. Verified on PHP 8.2, 8.3 and 8.5. Closes #724 --- .ci-tools/phpstan-baseline.neon | 36 +- .../Source/Signature/SignatureSource.php | 8 +- .../config/Algorithms/signature_eddsa.php | 28 +- src/Bundle/Resources/config/analyzers.php | 2 + .../Console/OkpKeyGeneratorCommand.php | 2 +- .../Console/OkpKeysetGeneratorCommand.php | 2 +- src/Library/Core/Util/OKPKey.php | 236 +++++++++++- .../Algorithm/KeyEncryption/AbstractECDH.php | 68 +--- .../Analyzer/AlgorithmAnalyzer.php | 16 + .../KeyManagement/Analyzer/OKPKeyAnalyzer.php | 101 +++++ src/Library/KeyManagement/JWKFactory.php | 39 +- .../KeyManagement/JWKFactoryInterface.php | 4 + .../Signature/Algorithm/AbstractEdDSA.php | 167 ++++++++ src/Library/Signature/Algorithm/Ed25519.php | 30 ++ src/Library/Signature/Algorithm/Ed448.php | 29 ++ src/Library/Signature/Algorithm/EdDSA.php | 131 ++----- .../Functional/EdDSAAlgorithmsTest.php | 81 ++++ .../Console/KeyCreationCommandTest.php | 37 ++ tests/Component/Core/Util/OKPKeyTest.php | 252 +++++++++++++ .../ECDHESWithX448EncryptionTest.php | 177 +++++++++ .../KeyManagement/OKPKeyAnalyzerTest.php | 216 +++++++++++ .../EdDSA/FullySpecifiedEdDSATest.php | 356 ++++++++++++++++++ 22 files changed, 1792 insertions(+), 226 deletions(-) create mode 100644 src/Library/KeyManagement/Analyzer/OKPKeyAnalyzer.php create mode 100644 src/Library/Signature/Algorithm/AbstractEdDSA.php create mode 100644 src/Library/Signature/Algorithm/Ed25519.php create mode 100644 src/Library/Signature/Algorithm/Ed448.php create mode 100644 tests/Bundle/JoseFramework/Functional/EdDSAAlgorithmsTest.php create mode 100644 tests/Component/Core/Util/OKPKeyTest.php create mode 100644 tests/Component/Encryption/ECDHESWithX448EncryptionTest.php create mode 100644 tests/Component/KeyManagement/OKPKeyAnalyzerTest.php create mode 100644 tests/SignatureAlgorithm/EdDSA/FullySpecifiedEdDSATest.php diff --git a/.ci-tools/phpstan-baseline.neon b/.ci-tools/phpstan-baseline.neon index db8321813..f4e341440 100644 --- a/.ci-tools/phpstan-baseline.neon +++ b/.ci-tools/phpstan-baseline.neon @@ -3768,6 +3768,16 @@ parameters: count: 1 path: ../src/Bundle/Helper/ConfigurationHelper.php + - + rawMessage: ''' + Access to constant on deprecated class Jose\Component\Signature\Algorithm\EdDSA: + since 4.3.0, deprecated by RFC 9864. Use "Ed25519" (or "Ed448") instead; the keys are unchanged, only + the "alg" value differs. + ''' + identifier: classConstant.deprecatedClass + count: 1 + path: ../src/Bundle/Resources/config/Algorithms/signature_eddsa.php + - rawMessage: Access to constant on internal class Jose\Component\Core\Util\Ecc\NistCurve. identifier: classConstant.internalClass @@ -4674,12 +4684,6 @@ parameters: count: 1 path: ../src/Library/Encryption/Algorithm/KeyEncryption/AESGCMKW.php - - - rawMessage: 'Parameter #3 $length of function substr expects int|null, float|int given.' - identifier: argument.type - count: 1 - path: ../src/Library/Encryption/Algorithm/KeyEncryption/AbstractECDH.php - - rawMessage: Binary operation "." between mixed and "\000" results in an error. identifier: binaryOp.invalid @@ -5070,12 +5074,6 @@ parameters: count: 1 path: ../src/Library/KeyManagement/JWKFactory.php - - - rawMessage: 'Parameter #3 $length of function substr expects int|null, float|int given.' - identifier: argument.type - count: 1 - path: ../src/Library/KeyManagement/JWKFactory.php - - rawMessage: 'Method Jose\Component\KeyManagement\KeyConverter\ECKey::__construct() has parameter $data with no value type specified in iterable type array.' identifier: missingType.iterableValue @@ -5166,23 +5164,17 @@ parameters: count: 1 path: ../src/Library/KeyManagement/KeyConverter/RSAKey.php - - - rawMessage: 'Parameter #1 $signature of static method Jose\Component\Core\Util\ECSignature::fromAsn1() expects string, mixed given.' - identifier: argument.type - count: 1 - path: ../src/Library/Signature/Algorithm/ECDSA.php - - rawMessage: 'Call to internal static method ParagonIE_Sodium_Core_Ed25519::publickey_from_secretkey().' identifier: staticMethod.internal count: 1 - path: ../src/Library/Signature/Algorithm/EdDSA.php + path: ../src/Library/Signature/Algorithm/AbstractEdDSA.php - - rawMessage: 'Method Jose\Component\Signature\Algorithm\EdDSA::sign() should return non-empty-string but returns string.' - identifier: return.type + rawMessage: 'Parameter #1 $signature of static method Jose\Component\Core\Util\ECSignature::fromAsn1() expects string, mixed given.' + identifier: argument.type count: 1 - path: ../src/Library/Signature/Algorithm/EdDSA.php + path: ../src/Library/Signature/Algorithm/ECDSA.php - rawMessage: 'Method Jose\Component\Signature\Algorithm\RSAPKCS1::sign() should return string but returns mixed.' diff --git a/src/Bundle/DependencyInjection/Source/Signature/SignatureSource.php b/src/Bundle/DependencyInjection/Source/Signature/SignatureSource.php index 75bb51a0f..b3e9bd02b 100644 --- a/src/Bundle/DependencyInjection/Source/Signature/SignatureSource.php +++ b/src/Bundle/DependencyInjection/Source/Signature/SignatureSource.php @@ -8,7 +8,8 @@ use Jose\Bundle\JoseFramework\DependencyInjection\Source\Source; use Jose\Bundle\JoseFramework\DependencyInjection\Source\SourceWithCompilerPasses; use Jose\Component\Signature\Algorithm\ECDSA; -use Jose\Component\Signature\Algorithm\EdDSA; +use Jose\Component\Signature\Algorithm\Ed25519; +use Jose\Component\Signature\Algorithm\Ed448; use Jose\Component\Signature\Algorithm\HMAC; use Jose\Component\Signature\Algorithm\RSAPSS; use Jose\Experimental\Signature\HS1; @@ -21,7 +22,6 @@ use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use function array_key_exists; use function count; -use function extension_loaded; final readonly class SignatureSource implements SourceWithCompilerPasses { @@ -112,8 +112,8 @@ private function getAlgorithmsFiles(): array RSAPSS::class => 'signature_rsa.php', ]; - if (extension_loaded('sodium')) { - $algorithms[EdDSA::class] = 'signature_eddsa.php'; + if (Ed25519::isSupported() || Ed448::isSupported()) { + $algorithms[Ed25519::class] = 'signature_eddsa.php'; } return $algorithms; diff --git a/src/Bundle/Resources/config/Algorithms/signature_eddsa.php b/src/Bundle/Resources/config/Algorithms/signature_eddsa.php index a7a568ade..c711718b5 100644 --- a/src/Bundle/Resources/config/Algorithms/signature_eddsa.php +++ b/src/Bundle/Resources/config/Algorithms/signature_eddsa.php @@ -2,9 +2,16 @@ declare(strict_types=1); +use Jose\Component\Signature\Algorithm\Ed25519; +use Jose\Component\Signature\Algorithm\Ed448; use Jose\Component\Signature\Algorithm\EdDSA; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +/* + * Each algorithm is registered only when the platform can run it: the algorithm manager factory instantiates every + * tagged algorithm when the container is built, and an unsupported one would throw there. "Ed25519" and the + * deprecated "EdDSA" need sodium, or OpenSSL on PHP 8.4; "Ed448" OpenSSL on PHP 8.4. + */ return function (ContainerConfigurator $container): void { $container = $container->services() ->defaults() @@ -12,8 +19,21 @@ ->autoconfigure() ->autowire(); - $container->set(EdDSA::class) - ->tag('jose.algorithm', [ - 'alias' => 'EdDSA', - ]); + if (Ed25519::isSupported()) { + $container->set(EdDSA::class) + ->tag('jose.algorithm', [ + 'alias' => 'EdDSA', + ]); + $container->set(Ed25519::class) + ->tag('jose.algorithm', [ + 'alias' => 'Ed25519', + ]); + } + + if (Ed448::isSupported()) { + $container->set(Ed448::class) + ->tag('jose.algorithm', [ + 'alias' => 'Ed448', + ]); + } }; diff --git a/src/Bundle/Resources/config/analyzers.php b/src/Bundle/Resources/config/analyzers.php index 38f351967..de6194888 100644 --- a/src/Bundle/Resources/config/analyzers.php +++ b/src/Bundle/Resources/config/analyzers.php @@ -17,6 +17,7 @@ use Jose\Component\KeyManagement\Analyzer\MixedPublicAndPrivateKeys; use Jose\Component\KeyManagement\Analyzer\NoneAnalyzer; use Jose\Component\KeyManagement\Analyzer\OctAnalyzer; +use Jose\Component\KeyManagement\Analyzer\OKPKeyAnalyzer; use Jose\Component\KeyManagement\Analyzer\UsageAnalyzer; use Jose\Component\KeyManagement\Analyzer\ZxcvbnKeyAnalyzer; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; @@ -39,6 +40,7 @@ $container->set(KeyIdentifierAnalyzer::class); $container->set(NoneAnalyzer::class); $container->set(OctAnalyzer::class); + $container->set(OKPKeyAnalyzer::class); $container->set(MixedKeyTypes::class); $container->set(MixedPublicAndPrivateKeys::class); $container->set(HS256KeyAnalyzer::class); diff --git a/src/Library/Console/OkpKeyGeneratorCommand.php b/src/Library/Console/OkpKeyGeneratorCommand.php index 0a4647e3f..d4d4f0f19 100644 --- a/src/Library/Console/OkpKeyGeneratorCommand.php +++ b/src/Library/Console/OkpKeyGeneratorCommand.php @@ -19,7 +19,7 @@ final class OkpKeyGeneratorCommand extends GeneratorCommand protected function configure(): void { parent::configure(); - $this->addArgument('curve', InputArgument::REQUIRED, 'Curve of the key.'); + $this->addArgument('curve', InputArgument::REQUIRED, 'Curve of the key: Ed25519, Ed448, X25519 or X448.'); } #[Override] diff --git a/src/Library/Console/OkpKeysetGeneratorCommand.php b/src/Library/Console/OkpKeysetGeneratorCommand.php index ace5429c4..2512df62c 100644 --- a/src/Library/Console/OkpKeysetGeneratorCommand.php +++ b/src/Library/Console/OkpKeysetGeneratorCommand.php @@ -24,7 +24,7 @@ protected function configure(): void { parent::configure(); $this->addArgument('quantity', InputArgument::REQUIRED, 'Quantity of keys in the key set.') - ->addArgument('curve', InputArgument::REQUIRED, 'Curve of the keys.'); + ->addArgument('curve', InputArgument::REQUIRED, 'Curve of the keys: Ed25519, Ed448, X25519 or X448.'); } #[Override] diff --git a/src/Library/Core/Util/OKPKey.php b/src/Library/Core/Util/OKPKey.php index 0237507d4..702bff8fc 100644 --- a/src/Library/Core/Util/OKPKey.php +++ b/src/Library/Core/Util/OKPKey.php @@ -5,6 +5,8 @@ namespace Jose\Component\Core\Util; use Jose\Component\Core\Exception\InvalidKeyException; +use Jose\Component\Core\Exception\MissingDependencyException; +use Jose\Component\Core\Exception\RuntimeException; use Jose\Component\Core\Exception\UnsupportedCurveException; use Jose\Component\Core\JWK; use SpomkyLabs\Pki\CryptoTypes\Asymmetric\PrivateKey; @@ -18,16 +20,217 @@ use SpomkyLabs\Pki\CryptoTypes\Asymmetric\RFC8410\Curve448\Ed448PublicKey; use SpomkyLabs\Pki\CryptoTypes\Asymmetric\RFC8410\Curve448\X448PrivateKey; use SpomkyLabs\Pki\CryptoTypes\Asymmetric\RFC8410\Curve448\X448PublicKey; +use function extension_loaded; +use function in_array; +use function is_array; use function is_string; use function sprintf; +use function strlen; +use const PHP_VERSION_ID; /** - * Converts Octet Key Pair keys (RFC 8037) into the PEM structures defined by RFC 8410. + * Octet Key Pair keys (RFC 8037): conversion into the PEM structures defined by RFC 8410, key generation and + * Diffie-Hellman over the Montgomery curves. + * + * Two backends serve the four curves. ext-sodium knows Ed25519 and X25519 only and is preferred for them, as it was + * the only backend until 4.3. ext-openssl knows the four curves, but PHP exposes the raw key material and the + * digest-less signature of the Edwards curves since 8.4 only: on 8.2 and 8.3, openssl_pkey_get_details() returns no + * "ed448" member and openssl_sign() refuses a null digest. Ed448 and X448 are therefore available on PHP 8.4 and + * later, whatever the OpenSSL version, and Ed25519 / X25519 work without sodium from that version too. * * @internal */ final readonly class OKPKey { + public const CURVE_ED25519 = 'Ed25519'; + + public const CURVE_ED448 = 'Ed448'; + + public const CURVE_X25519 = 'X25519'; + + public const CURVE_X448 = 'X448'; + + /** + * The size in bytes of the "x" and "d" parameters of each curve (RFC 8032 section 5, RFC 7748 section 5). + */ + public const KEY_SIZES = [ + self::CURVE_ED25519 => 32, + self::CURVE_ED448 => 57, + self::CURVE_X25519 => 32, + self::CURVE_X448 => 56, + ]; + + /** + * The OpenSSL key type of each curve, as numbered by ext-openssl. The OPENSSL_KEYTYPE_* constants only exist + * since PHP 8.4, which is also the first version able to use them. + */ + private const OPENSSL_KEY_TYPES = [ + self::CURVE_X25519 => 4, + self::CURVE_ED25519 => 5, + self::CURVE_X448 => 6, + self::CURVE_ED448 => 7, + ]; + + /** + * The member of openssl_pkey_get_details() that carries the raw key material of each curve. + */ + private const OPENSSL_DETAILS_KEYS = [ + self::CURVE_X25519 => 'x25519', + self::CURVE_ED25519 => 'ed25519', + self::CURVE_X448 => 'x448', + self::CURVE_ED448 => 'ed448', + ]; + + /** + * Tells whether ext-openssl can generate, sign with, verify with and derive from OKP keys on this platform. + */ + public static function supportsOpenSSL(): bool + { + return PHP_VERSION_ID >= 80400 && extension_loaded('openssl'); + } + + /** + * Tells whether ext-sodium can serve the given curve: it only knows Ed25519 and X25519. + */ + public static function supportsSodium(string $curve): bool + { + return extension_loaded('sodium') && in_array($curve, [self::CURVE_ED25519, self::CURVE_X25519], true); + } + + /** + * Tells whether the given curve can be used on this platform, with either backend. + */ + public static function isCurveSupported(string $curve): bool + { + return self::supportsSodium($curve) || (self::supportsOpenSSL() && isset(self::KEY_SIZES[$curve])); + } + + /** + * Generates a private key on the given curve, with sodium when it knows the curve and OpenSSL otherwise. + */ + public static function generate(string $curve): JWK + { + if (! isset(self::KEY_SIZES[$curve])) { + throw new UnsupportedCurveException(sprintf('Unsupported "%s" curve', $curve)); + } + if (self::supportsSodium($curve)) { + return self::generateWithSodium($curve); + } + if (self::supportsOpenSSL()) { + return self::generateWithOpenSSL($curve); + } + + throw new MissingDependencyException(sprintf( + 'The curve "%s" needs the extension "sodium", or the extension "openssl" on PHP 8.4 or later.', + $curve + )); + } + + /** + * Generates a private key on the given curve with OpenSSL. Needs PHP 8.4, see supportsOpenSSL(). + */ + public static function generateWithOpenSSL(string $curve): JWK + { + if (! isset(self::OPENSSL_KEY_TYPES[$curve])) { + throw new UnsupportedCurveException(sprintf('Unsupported "%s" curve', $curve)); + } + $key = openssl_pkey_new([ + 'private_key_type' => self::OPENSSL_KEY_TYPES[$curve], + ]); + $details = $key === false ? false : openssl_pkey_get_details($key); + $material = is_array($details) ? ($details[self::OPENSSL_DETAILS_KEYS[$curve]] ?? null) : null; + if (! is_array($material) || ! is_string($material['pub_key'] ?? null) || ! is_string( + $material['priv_key'] ?? null + )) { + throw new RuntimeException(sprintf('Unable to generate a key on the "%s" curve.', $curve)); + } + + return self::createKey($curve, $material['pub_key'], $material['priv_key']); + } + + /** + * Signs the input with the private key, with OpenSSL (PureEdDSA, RFC 8032). Needs PHP 8.4, see + * supportsOpenSSL(): before that version openssl_sign() refuses the null digest the Edwards curves require. + * + * @return non-empty-string + */ + public static function signWithOpenSSL(JWK $privateKey, string $input): string + { + $key = openssl_pkey_get_private(self::convertPrivateKeyToPKCS8PEM($privateKey)); + if ($key === false) { + throw new InvalidKeyException('Unable to load the private key.'); + } + $signature = ''; + if (! openssl_sign($input, $signature, $key, 0) || ! is_string($signature) || $signature === '') { + throw new RuntimeException('Unable to sign the input.'); + } + + return $signature; + } + + /** + * Verifies the signature of the input with the public key, with OpenSSL (PureEdDSA, RFC 8032). Needs PHP 8.4, + * see supportsOpenSSL(). A malformed signature is false, not an exception. + */ + public static function verifyWithOpenSSL(JWK $publicKey, string $input, string $signature): bool + { + $key = openssl_pkey_get_public(self::convertPublicKeyToPEM($publicKey)); + if ($key === false) { + throw new InvalidKeyException('Unable to load the public key.'); + } + + return openssl_verify($input, $signature, $key, 0) === 1; + } + + /** + * Computes the Diffie-Hellman shared secret of a private and a public key on the same Montgomery curve + * (RFC 7748 section 6): sodium for X25519 when loaded, OpenSSL otherwise. + */ + public static function deriveSharedSecret(JWK $privateKey, JWK $publicKey): string + { + $curve = self::getParameter($publicKey, 'crv'); + if (! in_array($curve, [self::CURVE_X25519, self::CURVE_X448], true)) { + throw new UnsupportedCurveException(sprintf('The curve "%s" is not supported', $curve)); + } + if (self::getParameter($privateKey, 'crv') !== $curve) { + throw new InvalidKeyException('Curves are different'); + } + if (self::supportsSodium($curve)) { + return sodium_crypto_scalarmult( + Base64UrlSafe::decodeNoPadding(self::getParameter($privateKey, 'd')), + Base64UrlSafe::decodeNoPadding(self::getParameter($publicKey, 'x')) + ); + } + if (! self::supportsOpenSSL()) { + throw new MissingDependencyException(sprintf( + 'The curve "%s" needs the extension "sodium", or the extension "openssl" on PHP 8.4 or later.', + $curve + )); + } + + return self::deriveSharedSecretWithOpenSSL($privateKey, $publicKey); + } + + /** + * Computes the Diffie-Hellman shared secret with OpenSSL. Needs PHP 8.4, see supportsOpenSSL(). + */ + public static function deriveSharedSecretWithOpenSSL(JWK $privateKey, JWK $publicKey): string + { + $curve = self::getParameter($publicKey, 'crv'); + if (! isset(self::KEY_SIZES[$curve])) { + throw new UnsupportedCurveException(sprintf('The curve "%s" is not supported', $curve)); + } + $secret = openssl_pkey_derive( + self::convertPublicKeyToPEM($publicKey), + self::convertPrivateKeyToPKCS8PEM($privateKey) + ); + if (! is_string($secret) || strlen($secret) !== self::KEY_SIZES[$curve]) { + throw new RuntimeException('Unable to derive the key'); + } + + return $secret; + } + /** * Converts the key into a PKCS#8 PEM. As PKCS#8 only covers private keys, public keys are converted into a * SubjectPublicKeyInfo structure, which is the format expected by the tools consuming PKCS#8 private keys. @@ -94,6 +297,37 @@ private static function createPublicKey(JWK $jwk): PublicKey }; } + private static function generateWithSodium(string $curve): JWK + { + if ($curve === self::CURVE_X25519) { + $keyPair = sodium_crypto_box_keypair(); + + return self::createKey( + $curve, + sodium_crypto_box_publickey($keyPair), + sodium_crypto_box_secretkey($keyPair) + ); + } + $keyPair = sodium_crypto_sign_keypair(); + $secret = sodium_crypto_sign_secretkey($keyPair); + + return self::createKey( + $curve, + sodium_crypto_sign_publickey($keyPair), + substr($secret, 0, -(int) (strlen($secret) / 2)) + ); + } + + private static function createKey(string $curve, string $x, string $d): JWK + { + return new JWK([ + 'kty' => 'OKP', + 'crv' => $curve, + 'x' => Base64UrlSafe::encodeUnpadded($x), + 'd' => Base64UrlSafe::encodeUnpadded($d), + ]); + } + private static function getParameter(JWK $jwk, string $parameter): string { $value = $jwk->get($parameter); diff --git a/src/Library/Encryption/Algorithm/KeyEncryption/AbstractECDH.php b/src/Library/Encryption/Algorithm/KeyEncryption/AbstractECDH.php index ff5cc36d5..dff46b4ea 100644 --- a/src/Library/Encryption/Algorithm/KeyEncryption/AbstractECDH.php +++ b/src/Library/Encryption/Algorithm/KeyEncryption/AbstractECDH.php @@ -8,7 +8,6 @@ use Jose\Component\Core\Exception\InvalidArgumentException; use Jose\Component\Core\Exception\InvalidHeaderParameterException; use Jose\Component\Core\Exception\InvalidKeyException; -use Jose\Component\Core\Exception\MissingDependencyException; use Jose\Component\Core\Exception\RuntimeException; use Jose\Component\Core\Exception\UnsupportedCurveException; use Jose\Component\Core\JWK; @@ -19,11 +18,11 @@ use Jose\Component\Core\Util\Ecc\NistCurve; use Jose\Component\Core\Util\Ecc\PrivateKey; use Jose\Component\Core\Util\ECKey; +use Jose\Component\Core\Util\OKPKey; use Jose\Component\Encryption\Algorithm\KeyEncryption\Util\ConcatKDF; use Override; use Throwable; use function array_key_exists; -use function extension_loaded; use function function_exists; use function in_array; use function is_array; @@ -129,20 +128,9 @@ protected function calculateAgreementKey(JWK $private_key, JWK $public_key): str return $this->convertDecToBin(EcDH::computeSharedKey($curve, $pub_key, $priv_key)); - case 'X25519': - $this->checkSodiumExtensionIsAvailable(); - $x = $public_key->get('x'); - if (! is_string($x)) { - throw new InvalidKeyException('Invalid key parameter "x"'); - } - $d = $private_key->get('d'); - if (! is_string($d)) { - throw new InvalidKeyException('Invalid key parameter "d"'); - } - $sKey = Base64UrlSafe::decodeNoPadding($d); - $recipientPublickey = Base64UrlSafe::decodeNoPadding($x); - - return sodium_crypto_scalarmult($sKey, $recipientPublickey); + case OKPKey::CURVE_X25519: + case OKPKey::CURVE_X448: + return OKPKey::deriveSharedSecret($private_key, $public_key); default: throw new UnsupportedCurveException(sprintf('The curve "%s" is not supported', $crv)); @@ -167,7 +155,7 @@ protected function getKeysFromPublicKey( } $private_key = match ($crv) { 'P-256', 'P-384', 'P-521', 'BP-256', 'BP-384', 'BP-512' => $senderKey ?? ECKey::createECKey($crv), - 'X25519' => $senderKey ?? $this->createOKPKey('X25519'), + OKPKey::CURVE_X25519, OKPKey::CURVE_X448 => $senderKey ?? OKPKey::generate($crv), default => throw new UnsupportedCurveException(sprintf('The curve "%s" is not supported', $crv)), }; $epk = $private_key->toPublic() @@ -238,7 +226,8 @@ private function checkKey(JWK $key, bool $is_private): void break; - case 'X25519': + case OKPKey::CURVE_X25519: + case OKPKey::CURVE_X448: break; default: @@ -290,47 +279,4 @@ private function convertDecToBin(BigInteger $dec): string return $bin; } - - /** - * @param string $curve The curve - */ - private function createOKPKey(string $curve): JWK - { - $this->checkSodiumExtensionIsAvailable(); - - switch ($curve) { - case 'X25519': - $keyPair = sodium_crypto_box_keypair(); - $d = sodium_crypto_box_secretkey($keyPair); - $x = sodium_crypto_box_publickey($keyPair); - - break; - - case 'Ed25519': - $keyPair = sodium_crypto_sign_keypair(); - $secret = sodium_crypto_sign_secretkey($keyPair); - $secretLength = strlen($secret); - $d = substr($secret, 0, -$secretLength / 2); - $x = sodium_crypto_sign_publickey($keyPair); - - break; - - default: - throw new UnsupportedCurveException(sprintf('Unsupported "%s" curve', $curve)); - } - - return new JWK([ - 'kty' => 'OKP', - 'crv' => $curve, - 'x' => Base64UrlSafe::encodeUnpadded($x), - 'd' => Base64UrlSafe::encodeUnpadded($d), - ]); - } - - private function checkSodiumExtensionIsAvailable(): void - { - if (! extension_loaded('sodium')) { - throw new MissingDependencyException('The extension "sodium" is not available. Please install it to use this method'); - } - } } diff --git a/src/Library/KeyManagement/Analyzer/AlgorithmAnalyzer.php b/src/Library/KeyManagement/Analyzer/AlgorithmAnalyzer.php index 85dafaf2f..fea8255c9 100644 --- a/src/Library/KeyManagement/Analyzer/AlgorithmAnalyzer.php +++ b/src/Library/KeyManagement/Analyzer/AlgorithmAnalyzer.php @@ -5,8 +5,15 @@ namespace Jose\Component\KeyManagement\Analyzer; use Jose\Component\Core\JWK; +use Jose\Component\Core\Util\OKPKey; use Override; +use function sprintf; +/** + * Checks the "alg" parameter of a key: it should be present, and it should not name a deprecated algorithm. The + * polymorphic "EdDSA" is deprecated by RFC 9864 section 4.1.2 in favour of the fully-specified "Ed25519" and + * "Ed448". + */ final readonly class AlgorithmAnalyzer implements KeyAnalyzer { #[Override] @@ -14,6 +21,15 @@ public function analyze(JWK $jwk, MessageBag $bag): void { if (! $jwk->has('alg')) { $bag->add(Message::medium('The parameter "alg" should be added.')); + + return; + } + if ($jwk->find('alg') === 'EdDSA') { + $replacement = $jwk->find('crv') === OKPKey::CURVE_ED448 ? 'Ed448' : 'Ed25519'; + $bag->add(Message::medium(sprintf( + 'The algorithm "EdDSA" is deprecated (RFC 9864). Use the fully-specified "%s" algorithm instead.', + $replacement + ))); } } } diff --git a/src/Library/KeyManagement/Analyzer/OKPKeyAnalyzer.php b/src/Library/KeyManagement/Analyzer/OKPKeyAnalyzer.php new file mode 100644 index 000000000..4b0a43068 --- /dev/null +++ b/src/Library/KeyManagement/Analyzer/OKPKeyAnalyzer.php @@ -0,0 +1,101 @@ + ['Ed25519', 'EdDSA'], + OKPKey::CURVE_ED448 => ['Ed448'], + ]; + + #[Override] + public function analyze(JWK $jwk, MessageBag $bag): void + { + if ($jwk->get('kty') !== 'OKP') { + return; + } + $crv = $jwk->find('crv'); + if (! is_string($crv)) { + $bag->add(Message::high('Invalid key. The component "crv" is missing.')); + + return; + } + if (! isset(OKPKey::KEY_SIZES[$crv])) { + $bag->add(Message::high(sprintf('Invalid key. The curve "%s" is not supported.', $crv))); + + return; + } + $this->checkComponentSize($jwk, 'x', OKPKey::KEY_SIZES[$crv], $bag); + if ($jwk->has('d')) { + $this->checkComponentSize($jwk, 'd', OKPKey::KEY_SIZES[$crv], $bag); + } + $this->checkAlgorithm($jwk, $crv, $bag); + } + + private function checkComponentSize(JWK $jwk, string $component, int $size, MessageBag $bag): void + { + $value = $jwk->find($component); + if (! is_string($value)) { + $bag->add(Message::high(sprintf('Invalid key. The component "%s" shall be a string.', $component))); + + return; + } + if (strlen(Base64UrlSafe::decodeNoPadding($value)) !== $size) { + $bag->add(Message::high(sprintf( + 'Invalid key. The component "%s" size shall be %d bytes.', + $component, + $size + ))); + } + } + + private function checkAlgorithm(JWK $jwk, string $crv, MessageBag $bag): void + { + $alg = $jwk->find('alg'); + if (! is_string($alg)) { + return; + } + $expected = self::SIGNATURE_ALGORITHMS[$crv] ?? []; + if (in_array($alg, $expected, true)) { + return; + } + $isSignatureAlgorithm = in_array($alg, array_merge(...array_values(self::SIGNATURE_ALGORITHMS)), true); + if ($isSignatureAlgorithm && $expected !== []) { + $bag->add(Message::high(sprintf( + 'Invalid key. The algorithm "%s" cannot be used with the curve "%s"; use "%s".', + $alg, + $crv, + $expected[0] + ))); + + return; + } + if ($isSignatureAlgorithm || ($expected !== [] && str_starts_with($alg, 'ECDH-'))) { + $bag->add(Message::high(sprintf( + 'Invalid key. The algorithm "%s" cannot be used with the curve "%s".', + $alg, + $crv + ))); + } + } +} diff --git a/src/Library/KeyManagement/JWKFactory.php b/src/Library/KeyManagement/JWKFactory.php index 8db6d3d49..35904eb4b 100644 --- a/src/Library/KeyManagement/JWKFactory.php +++ b/src/Library/KeyManagement/JWKFactory.php @@ -7,12 +7,12 @@ use Jose\Component\Core\Exception\InvalidKeyException; use Jose\Component\Core\Exception\MissingDependencyException; use Jose\Component\Core\Exception\RuntimeException; -use Jose\Component\Core\Exception\UnsupportedCurveException; use Jose\Component\Core\JWK; use Jose\Component\Core\JWKSet; use Jose\Component\Core\Util\Base64UrlSafe; use Jose\Component\Core\Util\ECKey; use Jose\Component\Core\Util\InheritanceChecker; +use Jose\Component\Core\Util\OKPKey; use Jose\Component\KeyManagement\KeyConverter\KeyConverter; use Jose\Component\KeyManagement\KeyConverter\RSAKey; use OpenSSLCertificate; @@ -22,8 +22,6 @@ use function extension_loaded; use function is_array; use function is_string; -use function sprintf; -use function strlen; use function trigger_deprecation; use const JSON_THROW_ON_ERROR; use const OPENSSL_KEYTYPE_RSA; @@ -340,40 +338,15 @@ public function oct(int $size, array $values = []): JWK #[Override] public function okp(string $curve, array $values = []): JWK { - if (! extension_loaded('sodium')) { - throw new MissingDependencyException('The extension "sodium" is not available. Please install it to use this method'); - } - - switch ($curve) { - case 'X25519': - $keyPair = sodium_crypto_box_keypair(); - $d = sodium_crypto_box_secretkey($keyPair); - $x = sodium_crypto_box_publickey($keyPair); - - break; - - case 'Ed25519': - $keyPair = sodium_crypto_sign_keypair(); - $secret = sodium_crypto_sign_secretkey($keyPair); - $secretLength = strlen($secret); - $d = substr($secret, 0, -$secretLength / 2); - $x = sodium_crypto_sign_publickey($keyPair); - - break; - - default: - throw new UnsupportedCurveException(sprintf('Unsupported "%s" curve', $curve)); - } + $key = OKPKey::generate($curve); - $values = [ + return new JWK([ ...$values, 'kty' => 'OKP', 'crv' => $curve, - 'd' => Base64UrlSafe::encodeUnpadded($d), - 'x' => Base64UrlSafe::encodeUnpadded($x), - ]; - - return new JWK($values); + 'd' => $key->get('d'), + 'x' => $key->get('x'), + ]); } #[Override] diff --git a/src/Library/KeyManagement/JWKFactoryInterface.php b/src/Library/KeyManagement/JWKFactoryInterface.php index e5b213dc2..e32faf440 100644 --- a/src/Library/KeyManagement/JWKFactoryInterface.php +++ b/src/Library/KeyManagement/JWKFactoryInterface.php @@ -45,6 +45,10 @@ public function oct(int $size, array $values = []): JWK; /** * Creates an OKP key with the given curve and additional values. * + * The curves are "Ed25519", "Ed448", "X25519" and "X448" (RFC 8037). Ed25519 and X25519 are generated with sodium + * when the extension is loaded and with OpenSSL otherwise; Ed448 and X448 with OpenSSL only. The OpenSSL paths + * need PHP 8.4 or later. + * * @param string $curve The curve * @param array $values Values to configure the key */ diff --git a/src/Library/Signature/Algorithm/AbstractEdDSA.php b/src/Library/Signature/Algorithm/AbstractEdDSA.php new file mode 100644 index 000000000..1ed6cd97d --- /dev/null +++ b/src/Library/Signature/Algorithm/AbstractEdDSA.php @@ -0,0 +1,167 @@ + 64, + OKPKey::CURVE_ED448 => 114, + ]; + + public function __construct() + { + if (! static::isSupported()) { + throw new MissingDependencyException(sprintf( + static::curve() === OKPKey::CURVE_ED25519 + ? 'The algorithm "%s" needs the extension "sodium", or the extension "openssl" on PHP 8.4 or later.' + : 'The algorithm "%s" needs the extension "openssl" on PHP 8.4 or later.', + $this->name() + )); + } + } + + /** + * Tells whether the platform can run this algorithm. The constructor throws when it cannot, so that a service + * container or an algorithm manager can skip the algorithm instead of failing at the first signature. + */ + public static function isSupported(): bool + { + return OKPKey::isCurveSupported(static::curve()); + } + + #[Override] + public function allowedKeyTypes(): array + { + return ['OKP']; + } + + /** + * @return non-empty-string + */ + #[Override] + public function sign(JWK $key, string $input): string + { + $this->checkKey($key); + if (! $key->has('d')) { + throw new InvalidKeyException('The OKP key is not private'); + } + $d = $key->get('d'); + if (! is_string($d) || $d === '') { + throw new InvalidKeyException('Invalid "d" parameter.'); + } + if (OKPKey::supportsSodium(static::curve())) { + return $this->signWithSodium($key, $d, $input); + } + + return OKPKey::signWithOpenSSL($key, $input); + } + + #[Override] + public function verify(JWK $key, string $input, string $signature): bool + { + if ($signature === '') { + return false; + } + $this->checkKey($key); + if (strlen($signature) !== self::SIGNATURE_SIZES[static::curve()]) { + return false; + } + $x = $key->get('x'); + if (! is_string($x) || $x === '') { + throw new InvalidKeyException('Invalid "x" parameter.'); + } + if (OKPKey::supportsSodium(static::curve())) { + return sodium_crypto_sign_verify_detached($signature, $input, Base64UrlSafe::decodeNoPadding($x)); + } + + return OKPKey::verifyWithOpenSSL($key, $input, $signature); + } + + /** + * The only curve this algorithm accepts. + */ + abstract protected static function curve(): string; + + /** + * @param non-empty-string $d + * + * @return non-empty-string + */ + private function signWithSodium(JWK $key, string $d, string $input): string + { + $d = Base64UrlSafe::decodeNoPadding($d); + $x = $key->has('x') ? $key->get('x') : null; + if ($x === null) { + $x = SodiumEd25519::publickey_from_secretkey($d); + } else { + if (! is_string($x) || $x === '') { + throw new InvalidKeyException('Invalid "x" parameter.'); + } + $x = Base64UrlSafe::decodeNoPadding($x); + } + $signature = sodium_crypto_sign_detached($input, $d . $x); + if ($signature === '') { + throw new RuntimeException('Unable to sign the input.'); + } + + return $signature; + } + + private function checkKey(JWK $key): void + { + if (! in_array($key->get('kty'), $this->allowedKeyTypes(), true)) { + throw new InvalidKeyException('Wrong key type.'); + } + if (! $key->has('crv')) { + throw new InvalidKeyException('The key parameter "crv" is missing.'); + } + if ($key->get('crv') !== static::curve()) { + throw $this->curveMismatchException(); + } + } + + /** + * The exception thrown when the key is on another curve than the one the algorithm accepts. The fully-specified + * algorithms throw an InvalidKeyException, as the key is what does not fit; the polymorphic "EdDSA" keeps the + * UnsupportedCurveException it threw before 4.3. + */ + protected function curveMismatchException(): InvalidArgumentException + { + return new InvalidKeyException(sprintf( + 'The algorithm "%s" only accepts keys on the "%s" curve.', + $this->name(), + static::curve() + )); + } +} diff --git a/src/Library/Signature/Algorithm/Ed25519.php b/src/Library/Signature/Algorithm/Ed25519.php new file mode 100644 index 000000000..7d2c511e6 --- /dev/null +++ b/src/Library/Signature/Algorithm/Ed25519.php @@ -0,0 +1,30 @@ +checkKey($key); - if (! $key->has('d')) { - throw new InvalidKeyException('The EC key is not private'); - } - $d = $key->get('d'); - if (! is_string($d) || $d === '') { - throw new InvalidKeyException('Invalid "d" parameter.'); - } - if (! $key->has('x')) { - $x = self::getPublicKey($key); - } else { - $x = $key->get('x'); - } - if (! is_string($x) || $x === '') { - throw new InvalidKeyException('Invalid "x" parameter.'); - } - /** @var non-empty-string $x */ - $x = Base64UrlSafe::decodeNoPadding($x); - /** @var non-empty-string $d */ - $d = Base64UrlSafe::decodeNoPadding($d); - $secret = $d . $x; - - return match ($key->get('crv')) { - 'Ed25519' => sodium_crypto_sign_detached($input, $secret), - default => throw new UnsupportedCurveException('Unsupported curve'), - }; + trigger_deprecation( + 'web-token/jwt-framework', + '4.3.0', + 'Signing with the "EdDSA" algorithm is deprecated by RFC 9864. Use the fully-specified "Ed25519" algorithm (%s) instead: the key is unchanged, only the "alg" value differs.', + Ed25519::class + ); + + return parent::sign($key, $input); } #[Override] - public function verify(JWK $key, string $input, string $signature): bool + protected static function curve(): string { - if ($signature === '') { - return false; - } - $this->checkKey($key); - $x = $key->get('x'); - if (! is_string($x)) { - throw new InvalidKeyException('Invalid "x" parameter.'); - } - - /** @var non-empty-string $public */ - $public = Base64UrlSafe::decodeNoPadding($x); - - return match ($key->get('crv')) { - 'Ed25519' => sodium_crypto_sign_verify_detached($signature, $input, $public), - default => throw new UnsupportedCurveException('Unsupported curve'), - }; + return OKPKey::CURVE_ED25519; } #[Override] - public function name(): string - { - return 'EdDSA'; - } - - private static function getPublicKey(JWK $key): string - { - $d = $key->get('d'); - assert(is_string($d), 'Unsupported key type'); - - switch ($key->get('crv')) { - case 'Ed25519': - return Ed25519::publickey_from_secretkey($d); - case 'X25519': - if (extension_loaded('sodium')) { - return sodium_crypto_scalarmult_base($d); - } - // no break - default: - throw new InvalidKeyException('Unsupported key type'); - } - } - - private function checkKey(JWK $key): void + protected function curveMismatchException(): InvalidArgumentException { - if (! in_array($key->get('kty'), $this->allowedKeyTypes(), true)) { - throw new InvalidKeyException('Wrong key type.'); - } - foreach (['x', 'crv'] as $k) { - if (! $key->has($k)) { - throw new InvalidKeyException(sprintf('The key parameter "%s" is missing.', $k)); - } - } - if ($key->get('crv') !== 'Ed25519') { - throw new UnsupportedCurveException('Unsupported curve.'); - } + return new UnsupportedCurveException('Unsupported curve.'); } } diff --git a/tests/Bundle/JoseFramework/Functional/EdDSAAlgorithmsTest.php b/tests/Bundle/JoseFramework/Functional/EdDSAAlgorithmsTest.php new file mode 100644 index 000000000..4b2656af4 --- /dev/null +++ b/tests/Bundle/JoseFramework/Functional/EdDSAAlgorithmsTest.php @@ -0,0 +1,81 @@ +getAlgorithmManagerFactory(); + + static::assertSame(Ed25519::isSupported(), in_array('Ed25519', $factory->aliases(), true)); + static::assertSame(Ed25519::isSupported(), in_array('EdDSA', $factory->aliases(), true)); + if (Ed25519::isSupported()) { + static::assertInstanceOf(Ed25519::class, $factory->create(['Ed25519'])->get('Ed25519')); + static::assertInstanceOf(EdDSA::class, $factory->create(['EdDSA'])->get('EdDSA')); + } + } + + #[Test] + public function theEd448AlgorithmIsRegisteredWhenSupported(): void + { + $factory = $this->getAlgorithmManagerFactory(); + + static::assertSame(Ed448::isSupported(), in_array('Ed448', $factory->aliases(), true)); + if (Ed448::isSupported()) { + static::assertInstanceOf(Ed448::class, $factory->create(['Ed448'])->get('Ed448')); + } + } + + #[Test] + public function theKeyAnalyzerKnowsTheOctetKeyPairs(): void + { + static::ensureKernelShutdown(); + $container = static::createClient() + ->getContainer(); + $analyzer = $container->get(KeyAnalyzerManager::class); + static::assertInstanceOf(KeyAnalyzerManager::class, $analyzer); + $key = (new JWKFactory())->okp('Ed25519', [ + 'alg' => 'EdDSA', + 'use' => 'sig', + 'kid' => 'key-1', + ]); + + $messages = array_map(static fn ($message): string => $message->getMessage(), $analyzer->analyze($key)->all()); + + static::assertContains( + 'The algorithm "EdDSA" is deprecated (RFC 9864). Use the fully-specified "Ed25519" algorithm instead.', + $messages + ); + } + + private function getAlgorithmManagerFactory(): AlgorithmManagerFactory + { + static::ensureKernelShutdown(); + $container = static::createClient() + ->getContainer(); + $factory = $container->get(AlgorithmManagerFactory::class); + static::assertInstanceOf(AlgorithmManagerFactory::class, $factory); + + return $factory; + } +} diff --git a/tests/Component/Console/KeyCreationCommandTest.php b/tests/Component/Console/KeyCreationCommandTest.php index fb6ecfc1f..51c1c3fde 100644 --- a/tests/Component/Console/KeyCreationCommandTest.php +++ b/tests/Component/Console/KeyCreationCommandTest.php @@ -13,12 +13,16 @@ use Jose\Component\Console\SecretKeyGeneratorCommand; use Jose\Component\Core\JWK; use Jose\Component\Core\Util\Base64UrlSafe; +use Jose\Component\Core\Util\OKPKey; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use RuntimeException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; +use function sprintf; +use function strlen; /** * @internal @@ -170,6 +174,39 @@ public function iCanCreateAnOctetKeyPair(): void JWK::createFromJson($content); } + /** + * @return iterable + */ + public static function octetKeyPairCurves(): iterable + { + yield 'Ed25519' => ['Ed25519', 32]; + yield 'Ed448' => ['Ed448', 57]; + yield 'X25519' => ['X25519', 32]; + yield 'X448' => ['X448', 56]; + } + + #[Test] + #[DataProvider('octetKeyPairCurves')] + public function iCanCreateAnOctetKeyPairOnEveryCurve(string $curve, int $size): void + { + if (! OKPKey::isCurveSupported($curve)) { + static::markTestSkipped(sprintf('The curve "%s" is not supported on this platform.', $curve)); + } + $input = new ArrayInput([ + 'curve' => $curve, + ]); + $output = new BufferedOutput(); + $command = new OkpKeyGeneratorCommand(); + + $command->run($input, $output); + $jwk = JWK::createFromJson($output->fetch()); + + static::assertSame('OKP', $jwk->get('kty')); + static::assertSame($curve, $jwk->get('crv')); + static::assertSame($size, strlen(Base64UrlSafe::decodeNoPadding($jwk->getString('x')))); + static::assertSame($size, strlen(Base64UrlSafe::decodeNoPadding($jwk->getString('d')))); + } + #[DoesNotPerformAssertions] #[Test] public function iCanCreateANoneKey(): void diff --git a/tests/Component/Core/Util/OKPKeyTest.php b/tests/Component/Core/Util/OKPKeyTest.php new file mode 100644 index 000000000..514385031 --- /dev/null +++ b/tests/Component/Core/Util/OKPKeyTest.php @@ -0,0 +1,252 @@ += 80400 && extension_loaded('openssl'), OKPKey::supportsOpenSSL()); + } + + #[Test] + public function sodiumOnlyKnowsTheTwoCurve25519Curves(): void + { + $loaded = extension_loaded('sodium'); + + static::assertSame($loaded, OKPKey::supportsSodium('Ed25519')); + static::assertSame($loaded, OKPKey::supportsSodium('X25519')); + static::assertFalse(OKPKey::supportsSodium('Ed448')); + static::assertFalse(OKPKey::supportsSodium('X448')); + } + + #[Test] + public function theCurve448CurvesAreSupportedWithOpenSSLOnly(): void + { + static::assertSame(OKPKey::supportsOpenSSL(), OKPKey::isCurveSupported('Ed448')); + static::assertSame(OKPKey::supportsOpenSSL(), OKPKey::isCurveSupported('X448')); + static::assertFalse(OKPKey::isCurveSupported('P-256')); + } + + /** + * @return iterable + */ + public static function curves(): iterable + { + yield 'Ed25519' => ['Ed25519', 32]; + yield 'Ed448' => ['Ed448', 57]; + yield 'X25519' => ['X25519', 32]; + yield 'X448' => ['X448', 56]; + } + + #[Test] + #[DataProvider('curves')] + public function aKeyIsGeneratedWithTheSizeOfItsCurve(string $curve, int $size): void + { + if (! OKPKey::isCurveSupported($curve)) { + static::markTestSkipped(sprintf('The curve "%s" is not supported on this platform.', $curve)); + } + $key = OKPKey::generate($curve); + + static::assertSame('OKP', $key->get('kty')); + static::assertSame($curve, $key->get('crv')); + static::assertSame($size, strlen(Base64UrlSafe::decodeNoPadding($key->getString('x')))); + static::assertSame($size, strlen(Base64UrlSafe::decodeNoPadding($key->getString('d')))); + static::assertSame($size, OKPKey::KEY_SIZES[$curve]); + } + + #[Test] + #[DataProvider('curves')] + public function aKeyIsGeneratedWithOpenSSL(string $curve, int $size): void + { + $this->requireOpenSSL(); + $key = OKPKey::generateWithOpenSSL($curve); + + static::assertSame($curve, $key->get('crv')); + static::assertSame($size, strlen(Base64UrlSafe::decodeNoPadding($key->getString('x')))); + static::assertSame($size, strlen(Base64UrlSafe::decodeNoPadding($key->getString('d')))); + } + + #[Test] + public function anUnknownCurveCannotBeGenerated(): void + { + $this->expectException(UnsupportedCurveException::class); + $this->expectExceptionMessage('Unsupported "Ed455" curve'); + + OKPKey::generate('Ed455'); + } + + /** + * The OpenSSL path of Ed25519 interoperates with the sodium one: the RFC 8037 appendix A.4 signature is + * reproduced and verified, and a signature made with either backend verifies with the other. + */ + #[Test] + public function ed25519SignedWithOpenSSLMatchesTheRfc8037Vector(): void + { + $this->requireOpenSSL(); + $key = self::rfc8037Key(); + $input = 'eyJhbGciOiJFZERTQSJ9.RXhhbXBsZSBvZiBFZDI1NTE5IHNpZ25pbmc'; + $signature = Base64UrlSafe::decodeNoPadding( + 'hgyY0il_MGCjP0JzlnLWG1PPOt7-09PGcvMg3AIbQR6dWbhijcNR4ki4iylGjg5BhVsPt9g7sVvpAr_MuM0KAg' + ); + + static::assertSame($signature, OKPKey::signWithOpenSSL($key, $input)); + static::assertTrue(OKPKey::verifyWithOpenSSL($key->toPublic(), $input, $signature)); + static::assertFalse(OKPKey::verifyWithOpenSSL($key->toPublic(), $input . 'x', $signature)); + static::assertFalse(OKPKey::verifyWithOpenSSL($key->toPublic(), $input, substr($signature, 0, 63))); + } + + #[Test] + public function ed25519SignaturesMadeWithOpenSSLAndSodiumAreInterchangeable(): void + { + $this->requireOpenSSL(); + if (! extension_loaded('sodium')) { + static::markTestSkipped('The sodium extension is not loaded.'); + } + $key = self::rfc8037Key(); + $algorithm = new Ed25519(); + + $fromOpenSSL = OKPKey::signWithOpenSSL($key, 'payload'); + $fromSodium = $algorithm->sign($key, 'payload'); + + static::assertSame($fromSodium, $fromOpenSSL); + static::assertTrue($algorithm->verify($key->toPublic(), 'payload', $fromOpenSSL)); + static::assertTrue(OKPKey::verifyWithOpenSSL($key->toPublic(), 'payload', $fromSodium)); + } + + #[Test] + public function aPublicKeyCannotSignWithOpenSSL(): void + { + $this->requireOpenSSL(); + + $this->expectException(InvalidKeyException::class); + OKPKey::signWithOpenSSL(self::rfc8037Key()->toPublic(), 'payload'); + } + + /** + * RFC 7748 section 6.2: Alice's and Bob's X448 keys and the shared secret K. + */ + #[Test] + public function theRfc7748X448VectorIsReproduced(): void + { + $this->requireOpenSSL(); + $alice = self::x448Key( + '9b08f7cc31b7e3e67d22d5aea121074a273bd2b83de09c63faa73d2c22c5d9bbc836647241d953d40c5b12da88120d53177f80e532c41fa0', + '9a8f4925d1519f5775cf46b04b5800d4ee9ee8bae8bc5565d498c28dd9c9baf574a9419744897391006382a6f127ab1d9ac2d8c0a598726b' + ); + $bob = self::x448Key( + '3eb7a829b0cd20f5bcfc0b599b6feccf6da4627107bdb0d4f345b43027d8b972fc3e34fb4232a13ca706dcb57aec3dae07bdc1c67bf33609', + '1c306a7ac2a0e2e0990b294470cba339e6453772b075811d8fad0d1d6927c120bb5ee8972b0d3e21374c9c921b09d1b0366f10b65173992d' + ); + $expected = '07fff4181ac6cc95ec1c16a94a0f74d12da232ce40a77552281d282bb60c0b56fd2464c335543936521c24403085d59a449a5037514a879d'; + + static::assertSame($expected, bin2hex(OKPKey::deriveSharedSecret($alice, $bob->toPublic()))); + static::assertSame($expected, bin2hex(OKPKey::deriveSharedSecret($bob, $alice->toPublic()))); + static::assertSame($expected, bin2hex(OKPKey::deriveSharedSecretWithOpenSSL($alice, $bob->toPublic()))); + } + + /** + * RFC 7748 section 6.1: the X25519 vector, through the sodium path when loaded and the OpenSSL one otherwise, + * then explicitly through OpenSSL. + */ + #[Test] + public function theRfc7748X25519VectorIsReproduced(): void + { + if (! OKPKey::isCurveSupported('X25519')) { + static::markTestSkipped('X25519 is not supported on this platform.'); + } + $alice = self::x25519Key( + '8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a', + '77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a' + ); + $bob = self::x25519Key( + 'de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f', + '5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb' + ); + $expected = '4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742'; + + static::assertSame($expected, bin2hex(OKPKey::deriveSharedSecret($alice, $bob->toPublic()))); + static::assertSame($expected, bin2hex(OKPKey::deriveSharedSecret($bob, $alice->toPublic()))); + if (OKPKey::supportsOpenSSL()) { + static::assertSame($expected, bin2hex(OKPKey::deriveSharedSecretWithOpenSSL($alice, $bob->toPublic()))); + } + } + + #[Test] + public function theSharedSecretNeedsKeysOnTheSameCurve(): void + { + $this->requireOpenSSL(); + $x25519 = OKPKey::generate('X25519'); + $x448 = OKPKey::generate('X448'); + + $this->expectException(InvalidKeyException::class); + $this->expectExceptionMessage('Curves are different'); + OKPKey::deriveSharedSecret($x25519, $x448->toPublic()); + } + + #[Test] + public function theSharedSecretIsOnlyDefinedOnTheMontgomeryCurves(): void + { + $key = self::rfc8037Key(); + + $this->expectException(UnsupportedCurveException::class); + OKPKey::deriveSharedSecret($key, $key->toPublic()); + } + + private function requireOpenSSL(): void + { + if (! OKPKey::supportsOpenSSL()) { + static::markTestSkipped('OKP keys through OpenSSL need PHP 8.4 or later.'); + } + } + + private static function rfc8037Key(): JWK + { + return new JWK([ + 'kty' => 'OKP', + 'crv' => 'Ed25519', + 'd' => 'nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A', + 'x' => '11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo', + ]); + } + + private static function x448Key(string $x, string $d): JWK + { + return new JWK([ + 'kty' => 'OKP', + 'crv' => 'X448', + 'x' => Base64UrlSafe::encodeUnpadded(hex2bin($x)), + 'd' => Base64UrlSafe::encodeUnpadded(hex2bin($d)), + ]); + } + + private static function x25519Key(string $x, string $d): JWK + { + return new JWK([ + 'kty' => 'OKP', + 'crv' => 'X25519', + 'x' => Base64UrlSafe::encodeUnpadded(hex2bin($x)), + 'd' => Base64UrlSafe::encodeUnpadded(hex2bin($d)), + ]); + } +} diff --git a/tests/Component/Encryption/ECDHESWithX448EncryptionTest.php b/tests/Component/Encryption/ECDHESWithX448EncryptionTest.php new file mode 100644 index 000000000..f3fd5bc4c --- /dev/null +++ b/tests/Component/Encryption/ECDHESWithX448EncryptionTest.php @@ -0,0 +1,177 @@ + + */ + public static function keyAgreementAlgorithms(): iterable + { + yield 'ECDH-ES, A256GCM' => ['ECDH-ES', 'A256GCM']; + yield 'ECDH-ES+A128KW, A128GCM' => ['ECDH-ES+A128KW', 'A128GCM']; + yield 'ECDH-ES+A256KW, A256CBC-HS512' => ['ECDH-ES+A256KW', 'A256CBC-HS512']; + } + + #[Test] + #[DataProvider('keyAgreementAlgorithms')] + public function anEphemeralStaticAgreementRoundTripsWithX448(string $alg, string $enc): void + { + $this->requireX448(); + $receiverKey = self::receiverKey(); + $input = 'The quick brown fox jumps over the lazy dog.'; + + $jwe = $this->getJWEBuilderFactory() + ->create([$alg, $enc]) + ->withPayload($input) + ->withSharedProtectedHeader([ + 'alg' => $alg, + 'enc' => $enc, + ]) + ->addRecipient($receiverKey->toPublic()) + ->build(); + $serializerManager = $this->getJWESerializerManager(); + $loaded = $serializerManager->unserialize($serializerManager->serialize('jwe_compact', $jwe, 0)); + + $epk = $loaded->getSharedProtectedHeaderParameter('epk'); + static::assertIsArray($epk); + static::assertSame('OKP', $epk['kty']); + static::assertSame('X448', $epk['crv']); + static::assertArrayNotHasKey('d', $epk); + static::assertSame(56, strlen(Base64UrlSafe::decodeNoPadding($epk['x']))); + + $result = $this->getJWEDecrypterFactory() + ->create([$alg, $enc]) + ->decrypt($loaded, $receiverKey, 0); + static::assertTrue($result->isDecrypted()); + static::assertSame($input, $result->getJwe()->getPayload()); + } + + /** + * @return iterable + */ + public static function staticStaticAlgorithms(): iterable + { + yield 'ECDH-SS, A256GCM' => ['ECDH-SS', 'A256GCM']; + yield 'ECDH-SS+A256KW, A128CBC-HS256' => ['ECDH-SS+A256KW', 'A128CBC-HS256']; + } + + #[Test] + #[DataProvider('staticStaticAlgorithms')] + public function aStaticStaticAgreementRoundTripsWithX448(string $alg, string $enc): void + { + $this->requireX448(); + $receiverKey = self::receiverKey(); + $senderKey = OKPKey::generate('X448'); + $input = 'The quick brown fox jumps over the lazy dog.'; + + $jwe = $this->getJWEBuilderFactory() + ->create([$alg, $enc]) + ->withPayload($input) + ->withSharedProtectedHeader([ + 'alg' => $alg, + 'enc' => $enc, + ]) + ->withSenderKey($senderKey) + ->addRecipient($receiverKey->toPublic()) + ->build(); + $serializerManager = $this->getJWESerializerManager(); + $loaded = $serializerManager->unserialize($serializerManager->serialize('jwe_json_flattened', $jwe, 0)); + + static::assertFalse($loaded->hasSharedProtectedHeaderParameter('epk')); + $result = $this->getJWEDecrypterFactory() + ->create([$alg, $enc]) + ->decrypt($loaded, $senderKey->toPublic(), 0, $receiverKey); + static::assertTrue($result->isDecrypted()); + static::assertSame($input, $result->getJwe()->getPayload()); + } + + /** + * RFC 7748 section 6.2: with Alice's key as the ephemeral one and Bob's as the recipient's, the agreement key is + * the shared secret K of the RFC. + */ + #[Test] + public function theAgreementKeyIsTheRfc7748SharedSecret(): void + { + $this->requireX448(); + $alice = new JWK([ + 'kty' => 'OKP', + 'crv' => 'X448', + 'x' => Base64UrlSafe::encodeUnpadded(hex2bin( + '9b08f7cc31b7e3e67d22d5aea121074a273bd2b83de09c63faa73d2c22c5d9bbc836647241d953d40c5b12da88120d53177f80e532c41fa0' + )), + 'd' => Base64UrlSafe::encodeUnpadded(hex2bin( + '9a8f4925d1519f5775cf46b04b5800d4ee9ee8bae8bc5565d498c28dd9c9baf574a9419744897391006382a6f127ab1d9ac2d8c0a598726b' + )), + ]); + $bob = new JWK([ + 'kty' => 'OKP', + 'crv' => 'X448', + 'x' => Base64UrlSafe::encodeUnpadded(hex2bin( + '3eb7a829b0cd20f5bcfc0b599b6feccf6da4627107bdb0d4f345b43027d8b972fc3e34fb4232a13ca706dcb57aec3dae07bdc1c67bf33609' + )), + 'd' => Base64UrlSafe::encodeUnpadded(hex2bin( + '1c306a7ac2a0e2e0990b294470cba339e6453772b075811d8fad0d1d6927c120bb5ee8972b0d3e21374c9c921b09d1b0366f10b65173992d' + )), + ]); + $algorithm = new ECDHES(); + + $additionalHeader = []; + $fromSender = $algorithm->getAgreementKey(256, 'A256GCM', $bob->toPublic(), $alice, [], $additionalHeader); + static::assertSame($alice->toPublic()->all(), $additionalHeader['epk']); + $fromRecipient = $algorithm->getAgreementKey(256, 'A256GCM', $bob, null, [ + 'epk' => $additionalHeader['epk'], + ]); + + static::assertSame($fromSender, $fromRecipient); + static::assertSame( + hash('sha256', "\0\0\0\1" . hex2bin( + '07fff4181ac6cc95ec1c16a94a0f74d12da232ce40a77552281d282bb60c0b56fd2464c335543936521c24403085d59a449a5037514a879d' + ) . "\0\0\0\x07A256GCM\0\0\0\0\0\0\0\0\0\0\1\0", true), + $fromSender + ); + } + + #[Test] + public function anX448RecipientCannotUseAnX25519EphemeralKey(): void + { + $this->requireX448(); + $receiverKey = self::receiverKey(); + $epk = OKPKey::generate('X25519')->toPublic(); + + $this->expectException(InvalidKeyException::class); + $this->expectExceptionMessage('Curves are different'); + (new ECDHES())->getAgreementKey(256, 'A256GCM', $receiverKey, null, [ + 'epk' => $epk->all(), + ]); + } + + private function requireX448(): void + { + if (! OKPKey::isCurveSupported('X448')) { + static::markTestSkipped('X448 needs ext-openssl on PHP 8.4 or later.'); + } + } + + private static function receiverKey(): JWK + { + return OKPKey::generate('X448'); + } +} diff --git a/tests/Component/KeyManagement/OKPKeyAnalyzerTest.php b/tests/Component/KeyManagement/OKPKeyAnalyzerTest.php new file mode 100644 index 000000000..f0cd06201 --- /dev/null +++ b/tests/Component/KeyManagement/OKPKeyAnalyzerTest.php @@ -0,0 +1,216 @@ + + */ + public static function curves(): iterable + { + yield 'Ed25519' => ['Ed25519', 32]; + yield 'Ed448' => ['Ed448', 57]; + yield 'X25519' => ['X25519', 32]; + yield 'X448' => ['X448', 56]; + } + + #[Test] + #[DataProvider('curves')] + public function aWellFormedKeyRaisesNoMessage(string $curve, int $size): void + { + $key = self::key($curve, $size, $size); + + static::assertSame([], self::analyze(new OKPKeyAnalyzer(), $key)); + static::assertSame([], self::analyze(new OKPKeyAnalyzer(), $key->toPublic())); + } + + #[Test] + public function otherKeyTypesAreIgnored(): void + { + $key = new JWK([ + 'kty' => 'oct', + 'k' => 'AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow', + ]); + + static::assertSame([], self::analyze(new OKPKeyAnalyzer(), $key)); + } + + #[Test] + public function aMissingCurveIsReported(): void + { + $key = new JWK([ + 'kty' => 'OKP', + 'x' => Base64UrlSafe::encodeUnpadded(random_bytes(32)), + ]); + + static::assertSame( + ['high: Invalid key. The component "crv" is missing.'], + self::analyze(new OKPKeyAnalyzer(), $key) + ); + } + + #[Test] + public function anUnknownCurveIsReported(): void + { + $key = new JWK([ + 'kty' => 'OKP', + 'crv' => 'Ed512', + 'x' => Base64UrlSafe::encodeUnpadded(random_bytes(32)), + ]); + + static::assertSame( + ['high: Invalid key. The curve "Ed512" is not supported.'], + self::analyze(new OKPKeyAnalyzer(), $key) + ); + } + + #[Test] + #[DataProvider('curves')] + public function componentsOfTheWrongSizeAreReported(string $curve, int $size): void + { + $key = self::key($curve, $size + 1, $size - 1); + + static::assertSame([ + sprintf('high: Invalid key. The component "x" size shall be %d bytes.', $size), + sprintf('high: Invalid key. The component "d" size shall be %d bytes.', $size), + ], self::analyze(new OKPKeyAnalyzer(), $key)); + } + + #[Test] + public function aNonStringComponentIsReported(): void + { + $key = new JWK([ + 'kty' => 'OKP', + 'crv' => 'Ed25519', + 'x' => 42, + ]); + + static::assertSame( + ['high: Invalid key. The component "x" shall be a string.'], + self::analyze(new OKPKeyAnalyzer(), $key) + ); + } + + /** + * @return iterable}> + */ + public static function algorithmsAndCurves(): iterable + { + yield 'Ed25519 with Ed25519' => ['Ed25519', 'Ed25519', []]; + yield 'Ed25519 with EdDSA' => ['Ed25519', 'EdDSA', []]; + yield 'Ed448 with Ed448' => ['Ed448', 'Ed448', []]; + yield 'X25519 with ECDH-ES' => ['X25519', 'ECDH-ES', []]; + yield 'X448 with ECDH-ES+A256KW' => ['X448', 'ECDH-ES+A256KW', []]; + yield 'Ed448 with EdDSA' => [ + 'Ed448', + 'EdDSA', + ['high: Invalid key. The algorithm "EdDSA" cannot be used with the curve "Ed448"; use "Ed448".'], + ]; + yield 'Ed448 with Ed25519' => [ + 'Ed448', + 'Ed25519', + ['high: Invalid key. The algorithm "Ed25519" cannot be used with the curve "Ed448"; use "Ed448".'], + ]; + yield 'Ed25519 with Ed448' => [ + 'Ed25519', + 'Ed448', + ['high: Invalid key. The algorithm "Ed448" cannot be used with the curve "Ed25519"; use "Ed25519".'], + ]; + yield 'Ed25519 with ECDH-ES' => [ + 'Ed25519', + 'ECDH-ES', + ['high: Invalid key. The algorithm "ECDH-ES" cannot be used with the curve "Ed25519".'], + ]; + yield 'X448 with Ed448' => [ + 'X448', + 'Ed448', + ['high: Invalid key. The algorithm "Ed448" cannot be used with the curve "X448".'], + ]; + yield 'X25519 with EdDSA' => [ + 'X25519', + 'EdDSA', + ['high: Invalid key. The algorithm "EdDSA" cannot be used with the curve "X25519".'], + ]; + } + + #[Test] + #[DataProvider('algorithmsAndCurves')] + public function theAlgorithmMustMatchTheCurve(string $curve, string $algorithm, array $expected): void + { + $key = new JWK(self::key($curve, OKPKey::KEY_SIZES[$curve], OKPKey::KEY_SIZES[$curve])->all() + [ + 'alg' => $algorithm, + ]); + + static::assertSame($expected, self::analyze(new OKPKeyAnalyzer(), $key)); + } + + #[Test] + public function theDeprecatedEdDSAAlgorithmIsReportedWithItsReplacement(): void + { + $ed25519 = new JWK(self::key('Ed25519', 32, 32)->all() + [ + 'alg' => 'EdDSA', + ]); + $ed448 = new JWK(self::key('Ed448', 57, 57)->all() + [ + 'alg' => 'EdDSA', + ]); + + static::assertSame( + ['medium: The algorithm "EdDSA" is deprecated (RFC 9864). Use the fully-specified "Ed25519" algorithm instead.'], + self::analyze(new AlgorithmAnalyzer(), $ed25519) + ); + static::assertSame( + ['medium: The algorithm "EdDSA" is deprecated (RFC 9864). Use the fully-specified "Ed448" algorithm instead.'], + self::analyze(new AlgorithmAnalyzer(), $ed448) + ); + static::assertSame([], self::analyze(new AlgorithmAnalyzer(), new JWK(self::key('Ed25519', 32, 32)->all() + [ + 'alg' => 'Ed25519', + ]))); + static::assertSame( + ['medium: The parameter "alg" should be added.'], + self::analyze(new AlgorithmAnalyzer(), self::key('Ed25519', 32, 32)) + ); + } + + /** + * @return list + */ + private static function analyze(KeyAnalyzer $analyzer, JWK $key): array + { + $bag = new MessageBag(); + $analyzer->analyze($key, $bag); + $messages = []; + foreach ($bag as $message) { + $messages[] = sprintf('%s: %s', $message->getSeverity(), $message->getMessage()); + } + + return $messages; + } + + private static function key(string $curve, int $xSize, int $dSize): JWK + { + return new JWK([ + 'kty' => 'OKP', + 'crv' => $curve, + 'x' => Base64UrlSafe::encodeUnpadded(random_bytes($xSize)), + 'd' => Base64UrlSafe::encodeUnpadded(random_bytes($dSize)), + ]); + } +} diff --git a/tests/SignatureAlgorithm/EdDSA/FullySpecifiedEdDSATest.php b/tests/SignatureAlgorithm/EdDSA/FullySpecifiedEdDSATest.php new file mode 100644 index 000000000..eb0c7a2ff --- /dev/null +++ b/tests/SignatureAlgorithm/EdDSA/FullySpecifiedEdDSATest.php @@ -0,0 +1,356 @@ +name()); + static::assertSame(['OKP'], (new Ed25519())->allowedKeyTypes()); + static::assertSame('EdDSA', (new EdDSA())->name()); + if (Ed448::isSupported()) { + static::assertSame('Ed448', (new Ed448())->name()); + static::assertSame(['OKP'], (new Ed448())->allowedKeyTypes()); + } + } + + #[Test] + public function ed448IsGatedOnPhp84(): void + { + static::assertSame(PHP_VERSION_ID >= 80400 && extension_loaded('openssl'), Ed448::isSupported()); + static::assertSame(OKPKey::supportsOpenSSL(), Ed448::isSupported()); + if (Ed448::isSupported()) { + static::assertInstanceOf(SignatureAlgorithm::class, new Ed448()); + + return; + } + + $this->expectException(MissingDependencyException::class); + new Ed448(); + } + + /** + * RFC 8037 appendix A.4: the key, the signing input and the signature. + */ + #[Test] + #[DataProvider('ed25519Algorithms')] + public function theRfc8037VectorVerifiesUnderEdDSAAndEd25519(SignatureAlgorithm $algorithm): void + { + $key = self::rfc8037Key(); + $input = 'eyJhbGciOiJFZERTQSJ9.RXhhbXBsZSBvZiBFZDI1NTE5IHNpZ25pbmc'; + $signature = Base64UrlSafe::decodeNoPadding( + 'hgyY0il_MGCjP0JzlnLWG1PPOt7-09PGcvMg3AIbQR6dWbhijcNR4ki4iylGjg5BhVsPt9g7sVvpAr_MuM0KAg' + ); + + static::assertTrue($algorithm->verify($key, $input, $signature)); + static::assertSame($signature, $algorithm->sign($key, $input)); + } + + /** + * @return iterable + */ + public static function ed25519Algorithms(): iterable + { + yield 'EdDSA' => [new EdDSA()]; + yield 'Ed25519' => [new Ed25519()]; + } + + /** + * RFC 8032 section 7.4, the first two vectors: the secret key, the public key, the message and the signature. + * + * @return iterable + */ + public static function rfc8032Ed448Vectors(): iterable + { + yield 'blank message' => [ + '6c82a562cb808d10d632be89c8513ebf6c929f34ddfa8c9f63c9960ef6e348a3528c8a3fcc2f044e39a3fc5b94492f8f032e7549a20098f95b', + '5fd7449b59b461fd2ce787ec616ad46a1da1342485a70e1f8a0ea75d80e96778edf124769b46c7061bd6783df1e50f6cd1fa1abeafe8256180', + '', + '533a37f6bbe457251f023c0d88f976ae2dfb504a843e34d2074fd823d41a591f2b233f034f628281f2fd7a22ddd47d7828c59bd0a21bfd3980ff0d2028d4b18a9df63e006c5d1c2d345b925d8dc00b4104852db99ac5c7cdda8530a113a0f4dbb61149f05a7363268c71d95808ff2e652600', + ]; + yield '1 octet' => [ + 'c4eab05d357007c632f3dbb48489924d552b08fe0c353a0d4a1f00acda2c463afbea67c5e8d2877c5e3bc397a659949ef8021e954e0a12274e', + '43ba28f430cdff456ae531545f7ecd0ac834a55d9358c0372bfa0c6c6798c0866aea01eb00742802b8438ea4cb82169c235160627b4c3a9480', + '03', + '26b8f91727bd62897af15e41eb43c377efb9c610d48f2335cb0bd0087810f4352541b143c4b981b7e18f62de8ccdf633fc1bf037ab7cd779805e0dbcc0aae1cbcee1afb2e027df36bc04dcecbf154336c19f0af7e0a6472905e799f1953d2a0ff3348ab21aa4adafd1d234441cf807c03a00', + ]; + } + + #[Test] + #[DataProvider('rfc8032Ed448Vectors')] + public function theRfc8032Ed448VectorsAreReproduced(string $d, string $x, string $message, string $signature): void + { + $this->requireEd448(); + $key = new JWK([ + 'kty' => 'OKP', + 'crv' => 'Ed448', + 'd' => Base64UrlSafe::encodeUnpadded(hex2bin($d)), + 'x' => Base64UrlSafe::encodeUnpadded(hex2bin($x)), + ]); + $algorithm = new Ed448(); + + static::assertTrue($algorithm->verify($key, hex2bin($message), hex2bin($signature))); + static::assertTrue($algorithm->verify($key->toPublic(), hex2bin($message), hex2bin($signature))); + static::assertSame($signature, bin2hex($algorithm->sign($key, hex2bin($message)))); + } + + #[Test] + public function aTruncatedOrTamperedEd448SignatureIsFalse(): void + { + $this->requireEd448(); + $key = (new JWKFactory())->okp('Ed448'); + $algorithm = new Ed448(); + $signature = $algorithm->sign($key, 'payload'); + static::assertSame(114, strlen($signature)); + + $tampered = $signature; + $tampered[10] = chr(ord($tampered[10]) ^ 0x01); + + static::assertFalse($algorithm->verify($key, 'payload', substr($signature, 0, 113))); + static::assertFalse($algorithm->verify($key, 'payload', $signature . "\0")); + static::assertFalse($algorithm->verify($key, 'payload', '')); + static::assertFalse($algorithm->verify($key, 'payload', $tampered)); + static::assertFalse($algorithm->verify($key, 'other payload', $signature)); + } + + #[Test] + public function aTruncatedOrTamperedEd25519SignatureIsFalse(): void + { + $key = self::rfc8037Key(); + $algorithm = new Ed25519(); + $signature = $algorithm->sign($key, 'payload'); + static::assertSame(64, strlen($signature)); + + $tampered = $signature; + $tampered[10] = chr(ord($tampered[10]) ^ 0x01); + + static::assertFalse($algorithm->verify($key, 'payload', substr($signature, 0, 63))); + static::assertFalse($algorithm->verify($key, 'payload', '')); + static::assertFalse($algorithm->verify($key, 'payload', $tampered)); + } + + #[Test] + public function ed25519RefusesAnEd448Key(): void + { + $this->requireEd448(); + $key = (new JWKFactory())->okp('Ed448'); + + $this->expectException(InvalidKeyException::class); + $this->expectExceptionMessage('The algorithm "Ed25519" only accepts keys on the "Ed25519" curve.'); + (new Ed25519())->sign($key, 'payload'); + } + + #[Test] + public function ed448RefusesAnEd25519Key(): void + { + $this->requireEd448(); + + $this->expectException(InvalidKeyException::class); + $this->expectExceptionMessage('The algorithm "Ed448" only accepts keys on the "Ed448" curve.'); + (new Ed448())->verify(self::rfc8037Key(), 'payload', str_repeat("\0", 114)); + } + + /** + * The polymorphic algorithm keeps the exception it threw before 4.3 for a curve it does not handle. + */ + #[Test] + public function edDSAStillRefusesAnEd448Key(): void + { + $this->requireEd448(); + $key = (new JWKFactory())->okp('Ed448'); + + $this->expectException(UnsupportedCurveException::class); + $this->expectExceptionMessage('Unsupported curve.'); + (new EdDSA())->verify($key, 'payload', str_repeat("\0", 64)); + } + + #[Test] + public function anX25519KeyIsRefused(): void + { + $key = (new JWKFactory())->okp('X25519'); + + $this->expectException(InvalidKeyException::class); + (new Ed25519())->sign($key, 'payload'); + } + + #[Test] + public function aPublicKeyCannotSign(): void + { + $this->expectException(InvalidKeyException::class); + $this->expectExceptionMessage('The OKP key is not private'); + (new Ed25519())->sign(self::rfc8037Key()->toPublic(), 'payload'); + } + + #[Test] + public function aKeyRestrictedToEdDSAIsRefusedByEd25519AndAcceptedByEdDSA(): void + { + $key = new JWK(self::rfc8037Key()->all() + [ + 'alg' => 'EdDSA', + ]); + $edDSA = new AlgorithmManager([new EdDSA()]); + + $jws = (new JWSBuilder($edDSA)) + ->withPayload('payload') + ->addSignature($key, [ + 'alg' => 'EdDSA', + ]) + ->build(); + static::assertTrue((new JWSVerifier($edDSA))->verify($jws, $key, 0)->isVerified()); + + $this->expectException(InvalidKeyException::class); + $this->expectExceptionMessage('The algorithm "Ed25519" is not allowed with this key.'); + (new JWSBuilder(new AlgorithmManager([new Ed25519()]))) + ->withPayload('payload') + ->addSignature($key, [ + 'alg' => 'Ed25519', + ]); + } + + #[Test] + public function aKeyRestrictedToEd25519IsRefusedByEdDSAAndAcceptedByEd25519(): void + { + $key = new JWK(self::rfc8037Key()->all() + [ + 'alg' => 'Ed25519', + ]); + $ed25519 = new AlgorithmManager([new Ed25519()]); + + $jws = (new JWSBuilder($ed25519)) + ->withPayload('payload') + ->addSignature($key, [ + 'alg' => 'Ed25519', + ]) + ->build(); + static::assertTrue((new JWSVerifier($ed25519))->verify($jws, $key, 0)->isVerified()); + + $this->expectException(InvalidKeyException::class); + $this->expectExceptionMessage('The algorithm "EdDSA" is not allowed with this key.'); + (new JWSBuilder(new AlgorithmManager([new EdDSA()]))) + ->withPayload('payload') + ->addSignature($key, [ + 'alg' => 'EdDSA', + ]); + } + + #[Test] + public function aTokenSignedWithEdDSAIsNotVerifiedByAVerifierKnowingEd25519Only(): void + { + $key = self::rfc8037Key(); + $jws = (new JWSBuilder(new AlgorithmManager([new EdDSA()]))) + ->withPayload('payload') + ->addSignature($key, [ + 'alg' => 'EdDSA', + ]) + ->build(); + + $this->expectException(UnsupportedAlgorithmException::class); + $this->expectExceptionMessage('The algorithm "EdDSA" is not supported.'); + (new JWSVerifier(new AlgorithmManager([new Ed25519()])))->verify($jws, $key, 0); + } + + /** + * @return iterable + */ + public static function roundTrips(): iterable + { + $serializers = [ + 'compact' => new CompactSerializer(), + 'flattened' => new JSONFlattenedSerializer(), + 'general' => new JSONGeneralSerializer(), + ]; + foreach (['Ed25519', 'Ed448'] as $algorithm) { + foreach ($serializers as $name => $serializer) { + yield sprintf('%s, %s', $algorithm, $name) => [$algorithm, $name, $serializer]; + } + } + } + + #[Test] + #[DataProvider('roundTrips')] + public function aTokenIsSignedAndVerifiedThroughEverySerializer( + string $algorithmName, + string $serializerName, + JWSSerializer $serializer + ): void { + if ($algorithmName === 'Ed448') { + $this->requireEd448(); + } + $algorithm = $algorithmName === 'Ed448' ? new Ed448() : new Ed25519(); + $key = (new JWKFactory())->okp($algorithmName, [ + 'kid' => 'key-' . $serializerName, + ]); + $manager = new AlgorithmManager([$algorithm]); + + $jws = (new JWSBuilder($manager)) + ->withPayload('{"iss":"me"}') + ->addSignature($key, [ + 'alg' => $algorithmName, + 'kid' => $key->get('kid'), + ]) + ->build(); + $loaded = $serializer->unserialize($serializer->serialize($jws, 0)); + + $result = (new JWSVerifier($manager))->verify($loaded, $key->toPublic(), 0); + + static::assertTrue($result->isVerified()); + static::assertSame('{"iss":"me"}', $loaded->getPayload()); + static::assertSame($algorithmName, $loaded->getSignature(0)->getProtectedHeaderParameter('alg')); + } + + private function requireEd448(): void + { + if (! Ed448::isSupported()) { + static::markTestSkipped('Ed448 needs ext-openssl on PHP 8.4 or later.'); + } + } + + /** + * The Ed25519 key of RFC 8037 appendix A.1. + */ + private static function rfc8037Key(): JWK + { + return new JWK([ + 'kty' => 'OKP', + 'crv' => 'Ed25519', + 'd' => 'nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A', + 'x' => '11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo', + ]); + } +}