diff --git a/.ci-tools/phpstan-baseline.neon b/.ci-tools/phpstan-baseline.neon index db832181..f4e34144 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 75bb51a0..b3e9bd02 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 a7a568ad..c711718b 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 38f35196..de619488 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 0a4647e3..d4d4f0f1 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 ace5429c..2512df62 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 0237507d..702bff8f 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 ff5cc36d..dff46b4e 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 85dafaf2..fea8255c 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 00000000..4b0a4306 --- /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 8db6d3d4..35904eb4 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 e5b213dc..e32faf44 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 00000000..1ed6cd97 --- /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 00000000..7d2c511e --- /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 00000000..4b2656af --- /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 fb6ecfc1..51c1c3fd 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 00000000..51438503 --- /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 00000000..f3fd5bc4 --- /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 00000000..f0cd0620 --- /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 00000000..eb0c7a2f --- /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', + ]); + } +}