From e4ab903b681cc6787c87125eba49a84a16e1398b Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Sun, 13 Sep 2026 14:39:55 +0200 Subject: [PATCH] feat(signature): move ES256K from the experimental package to the library (RFC 8812) ES256K - ECDSA over secp256k1 with SHA-256 - is registered by RFC 8812 since 2020, yet the library shipped it in the experimental package next to the WebCrypto-only identifiers that have no RFC. The "Experimental" label tells users the algorithm may change or go away; the RFC says the opposite. Jose\Component\Signature\Algorithm\ES256K now lives next to ES256, and the bundle registers it under the same "ES256K" alias. The experimental class survives until 5.0.0 as a deprecated subclass of the new one, its constructor raising the deprecation, so that instantiating it or type-hinting it keeps working; the library class is therefore not final, as Jose\Unsecured\Signature\None is not for the same reason. In the bundle the experimental service is kept, untagged and deprecated, for the applications that inject the old class: an alias to the new service would have handed them an object that is not an instance of the class they ask for. KoblitzCurve carries the secp256k1 parameters, which no Curve object described so far - only OpenSSL knew the curve, through its OID - so that ES256KKeyAnalyzer can check the keys the way the ES256 analyzer does. The 5.0.0 step, recorded in #717, is the deletion of the experimental class and its service. Closes #727 --- .ci-tools/phpstan-baseline.neon | 10 ++ .../config/Algorithms/signature_ecdsa.php | 6 + .../Algorithms/signature_experimental.php | 9 +- src/Bundle/Resources/config/analyzers.php | 2 + src/Experimental/Signature/ES256K.php | 34 ++--- src/Library/Core/Util/Ecc/KoblitzCurve.php | 29 ++++ .../Analyzer/ES256KKeyAnalyzer.php | 39 +++++ src/Library/Signature/Algorithm/ES256K.php | 34 +++++ .../Functional/ExperimentalAlgorithmsTest.php | 28 +++- .../TestBundle/Resources/config/services.php | 5 + .../Service/DeprecatedES256KConsumer.php | 18 +++ .../ECDSA/ES256KSignatureTest.php | 144 ++++++++++++++++++ .../Experimental/P256KSignatureTest.php | 60 -------- 13 files changed, 335 insertions(+), 83 deletions(-) create mode 100644 src/Library/Core/Util/Ecc/KoblitzCurve.php create mode 100644 src/Library/KeyManagement/Analyzer/ES256KKeyAnalyzer.php create mode 100644 src/Library/Signature/Algorithm/ES256K.php create mode 100644 tests/Bundle/JoseFramework/TestBundle/Service/DeprecatedES256KConsumer.php create mode 100644 tests/SignatureAlgorithm/ECDSA/ES256KSignatureTest.php delete mode 100644 tests/SignatureAlgorithm/Experimental/P256KSignatureTest.php diff --git a/.ci-tools/phpstan-baseline.neon b/.ci-tools/phpstan-baseline.neon index f4e341440..d1c856899 100644 --- a/.ci-tools/phpstan-baseline.neon +++ b/.ci-tools/phpstan-baseline.neon @@ -3778,6 +3778,16 @@ parameters: count: 1 path: ../src/Bundle/Resources/config/Algorithms/signature_eddsa.php + - + rawMessage: ''' + Access to constant on deprecated class Jose\Experimental\Signature\ES256K: + since 4.3.0, will be removed in 5.0.0. "ES256K" is a standard algorithm (RFC 8812) and moved to the + library: use Jose\Component\Signature\Algorithm\ES256K instead. + ''' + identifier: classConstant.deprecatedClass + count: 1 + path: ../src/Bundle/Resources/config/Algorithms/signature_experimental.php + - rawMessage: Access to constant on internal class Jose\Component\Core\Util\Ecc\NistCurve. identifier: classConstant.internalClass diff --git a/src/Bundle/Resources/config/Algorithms/signature_ecdsa.php b/src/Bundle/Resources/config/Algorithms/signature_ecdsa.php index fdbac9008..c28d3c12e 100644 --- a/src/Bundle/Resources/config/Algorithms/signature_ecdsa.php +++ b/src/Bundle/Resources/config/Algorithms/signature_ecdsa.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Jose\Component\Signature\Algorithm\ES256; +use Jose\Component\Signature\Algorithm\ES256K; use Jose\Component\Signature\Algorithm\ES384; use Jose\Component\Signature\Algorithm\ES512; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; @@ -28,4 +29,9 @@ ->tag('jose.algorithm', [ 'alias' => 'ES512', ]); + + $container->set(ES256K::class) + ->tag('jose.algorithm', [ + 'alias' => 'ES256K', + ]); }; diff --git a/src/Bundle/Resources/config/Algorithms/signature_experimental.php b/src/Bundle/Resources/config/Algorithms/signature_experimental.php index e29575caa..58c5d6e5a 100644 --- a/src/Bundle/Resources/config/Algorithms/signature_experimental.php +++ b/src/Bundle/Resources/config/Algorithms/signature_experimental.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Jose\Component\Signature\Algorithm\ES256K as StandardES256K; use Jose\Experimental\Signature\Blake2b; use Jose\Experimental\Signature\ES256K; use Jose\Experimental\Signature\HS1; @@ -40,9 +41,11 @@ ]); $container->set(ES256K::class) - ->tag('jose.algorithm', [ - 'alias' => 'ES256K', - ]); + ->deprecate( + 'web-token/jwt-framework', + '4.3.0', + 'The "%service_id%" service is deprecated: the "ES256K" algorithm moved to the library and is registered as "' . StandardES256K::class . '", under the same "ES256K" alias.' + ); $container->set(Blake2b::class) ->tag('jose.algorithm', [ diff --git a/src/Bundle/Resources/config/analyzers.php b/src/Bundle/Resources/config/analyzers.php index af927c0ee..867ccec55 100644 --- a/src/Bundle/Resources/config/analyzers.php +++ b/src/Bundle/Resources/config/analyzers.php @@ -5,6 +5,7 @@ use Jose\Component\Core\Util\Ecc\NistCurve; use Jose\Component\KeyManagement\Analyzer\AlgorithmAnalyzer; use Jose\Component\KeyManagement\Analyzer\ES256KeyAnalyzer; +use Jose\Component\KeyManagement\Analyzer\ES256KKeyAnalyzer; use Jose\Component\KeyManagement\Analyzer\ES384KeyAnalyzer; use Jose\Component\KeyManagement\Analyzer\ES512KeyAnalyzer; use Jose\Component\KeyManagement\Analyzer\HS256KeyAnalyzer; @@ -51,6 +52,7 @@ if (class_exists(NistCurve::class)) { $container->set(ES256KeyAnalyzer::class); + $container->set(ES256KKeyAnalyzer::class); $container->set(ES384KeyAnalyzer::class); $container->set(ES512KeyAnalyzer::class); } diff --git a/src/Experimental/Signature/ES256K.php b/src/Experimental/Signature/ES256K.php index f15cf8660..062ba42e6 100644 --- a/src/Experimental/Signature/ES256K.php +++ b/src/Experimental/Signature/ES256K.php @@ -4,26 +4,24 @@ namespace Jose\Experimental\Signature; -use Jose\Component\Signature\Algorithm\ECDSA; -use Override; +use Jose\Component\Signature\Algorithm\ES256K as StandardES256K; +use function trigger_deprecation; -final readonly class ES256K extends ECDSA +/** + * @deprecated since 4.3.0, will be removed in 5.0.0. "ES256K" is a standard algorithm (RFC 8812) and moved to the + * library: use Jose\Component\Signature\Algorithm\ES256K instead. + */ +final readonly class ES256K extends StandardES256K { - #[Override] - public function name(): string + public function __construct() { - return 'ES256K'; - } - - #[Override] - protected function getHashAlgorithm(): string - { - return 'sha256'; - } - - #[Override] - protected function getSignaturePartLength(): int - { - return 64; + parent::__construct(); + trigger_deprecation( + 'web-token/jwt-framework', + '4.3.0', + 'The class "%s" is deprecated and will be removed in 5.0.0. The "ES256K" algorithm is a standard one and moved to the library: use "%s" instead.', + self::class, + StandardES256K::class + ); } } diff --git a/src/Library/Core/Util/Ecc/KoblitzCurve.php b/src/Library/Core/Util/Ecc/KoblitzCurve.php new file mode 100644 index 000000000..3ce9da98f --- /dev/null +++ b/src/Library/Core/Util/Ecc/KoblitzCurve.php @@ -0,0 +1,29 @@ +getContainer(); + + /** @var AlgorithmManagerFactory $factory */ + $factory = $container->get(AlgorithmManagerFactory::class); + $algorithm = $factory->create(['ES256K'])->get('ES256K'); + static::assertSame(StandardES256K::class, $algorithm::class); + + $consumer = $container->get(DeprecatedES256KConsumer::class); + static::assertInstanceOf(DeprecatedES256KConsumer::class, $consumer); + static::assertInstanceOf(ES256K::class, $consumer->algorithm); + static::assertSame('ES256K', $consumer->algorithm->name()); + } + /** * Two of them reported the name of another one, so they replaced it in the manager and were unreachable. */ @@ -87,7 +112,6 @@ public static function aliases(): iterable yield 'RS1' => ['RS1']; yield 'HS1' => ['HS1']; yield 'HS256/64' => ['HS256/64']; - yield 'ES256K' => ['ES256K']; yield 'BLAKE2B' => ['BLAKE2B']; yield 'A128CTR' => ['A128CTR']; yield 'A192CTR' => ['A192CTR']; diff --git a/tests/Bundle/JoseFramework/TestBundle/Resources/config/services.php b/tests/Bundle/JoseFramework/TestBundle/Resources/config/services.php index 54dfe7231..7e04aa59a 100644 --- a/tests/Bundle/JoseFramework/TestBundle/Resources/config/services.php +++ b/tests/Bundle/JoseFramework/TestBundle/Resources/config/services.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Jose\Tests\Bundle\JoseFramework\TestBundle\Checker\CustomChecker; +use Jose\Tests\Bundle\JoseFramework\TestBundle\Service\DeprecatedES256KConsumer; use Jose\Tests\Bundle\JoseFramework\TestBundle\Service\NestedTokenServiceConsumer; use Psr\Clock\ClockInterface; use Symfony\Component\Clock\NativeClock; @@ -30,4 +31,8 @@ $container->set(NestedTokenServiceConsumer::class) ->public() ; + + $container->set(DeprecatedES256KConsumer::class) + ->public() + ; }; diff --git a/tests/Bundle/JoseFramework/TestBundle/Service/DeprecatedES256KConsumer.php b/tests/Bundle/JoseFramework/TestBundle/Service/DeprecatedES256KConsumer.php new file mode 100644 index 000000000..23ee6affb --- /dev/null +++ b/tests/Bundle/JoseFramework/TestBundle/Service/DeprecatedES256KConsumer.php @@ -0,0 +1,18 @@ +getKey(); + $algorithm = new ES256K(); + $data = 'Hello'; + + static::assertTrue($algorithm->verify($key, $data, hex2bin( + '9c75b9d171d9690a37f2474d4bfab5c234911cb150950ea5cbfc9aedda5ec360725cc47978de95b4efb2a3ed617c7b36b1cd0a26b536662a79d0f3ae873a7924' + ))); + } + + #[Test] + public function es256KSignAndVerify(): void + { + $key = $this->getKey(); + $algorithm = new ES256K(); + $data = 'Hello'; + + static::assertSame('ES256K', $algorithm->name()); + + $signature = $algorithm->sign($key, $data); + + static::assertTrue($algorithm->verify($key, $data, $signature)); + } + + /** + * @return iterable + */ + public static function serializers(): iterable + { + yield 'compact' => [new CompactSerializer()]; + yield 'flattened' => [new JSONFlattenedSerializer()]; + yield 'general' => [new JSONGeneralSerializer()]; + } + + #[Test] + #[DataProvider('serializers')] + public function aTokenIsSignedAndVerifiedThroughEverySerializer(JWSSerializer $serializer): void + { + $key = (new JWKFactory())->ec('secp256k1', [ + 'alg' => 'ES256K', + ]); + $manager = new AlgorithmManager([new ES256K()]); + + $jws = (new JWSBuilder($manager)) + ->withPayload('{"iss":"me"}') + ->addSignature($key, [ + 'alg' => 'ES256K', + ]) + ->build(); + $loaded = $serializer->unserialize($serializer->serialize($jws, 0)); + + static::assertTrue((new JWSVerifier($manager))->verify($loaded, $key->toPublic(), 0)->isVerified()); + static::assertSame('ES256K', $loaded->getSignature(0)->getProtectedHeaderParameter('alg')); + } + + #[Test] + #[IgnoreDeprecations] + public function theExperimentalClassIsADeprecatedSubclassOfTheLibraryOne(): void + { + $algorithm = new ExperimentalES256K(); + + static::assertInstanceOf(ES256K::class, $algorithm); + static::assertSame('ES256K', $algorithm->name()); + static::assertTrue($algorithm->verify($this->getKey(), 'Hello', (new ES256K())->sign($this->getKey(), 'Hello'))); + } + + #[Test] + public function theSecp256k1GeneratorIsOnTheCurve(): void + { + $curve = KoblitzCurve::secp256k1(); + $generator = $curve->getGenerator(); + + static::assertTrue($curve->contains($generator->getX(), $generator->getY())); + static::assertSame(256, $curve->getSize()); + } + + #[Test] + public function theKeyAnalyzerAcceptsTheKeyAndRejectsAPointOffTheCurve(): void + { + $bag = new MessageBag(); + (new ES256KKeyAnalyzer())->analyze($this->getKey(), $bag); + static::assertCount(0, $bag); + + $values = $this->getKey() + ->all(); + $values['y'] = $values['x']; + $bag = new MessageBag(); + (new ES256KKeyAnalyzer())->analyze(new JWK($values), $bag); + static::assertCount(1, $bag); + static::assertSame('Invalid key. The point is not on the curve.', $bag->all()[0]->getMessage()); + } + + private function getKey(): JWK + { + return new JWK([ + 'kty' => 'EC', + 'crv' => 'secp256k1', + 'd' => Base64UrlSafe::encodeUnpadded( + hex2bin('D1592A94BBB9B5D94CDC425FC7DA80B6A47863AE973A9D581FD9D8F29690B659') + ), + 'x' => Base64UrlSafe::encodeUnpadded( + hex2bin('4B4DF318DE05BB8F3A115BF337F9BCBC55CA14B917B46BCB557D3C9A158D4BE0') + ), + 'y' => Base64UrlSafe::encodeUnpadded( + hex2bin('627EB75731A8BBEBC7D9A3C57EC4D7DA2CBA6D2A28E7F45134921861FE1CF5D9') + ), + ]); + } +} diff --git a/tests/SignatureAlgorithm/Experimental/P256KSignatureTest.php b/tests/SignatureAlgorithm/Experimental/P256KSignatureTest.php deleted file mode 100644 index 4964d81e7..000000000 --- a/tests/SignatureAlgorithm/Experimental/P256KSignatureTest.php +++ /dev/null @@ -1,60 +0,0 @@ -getKey(); - $algorithm = new ES256K(); - $data = 'Hello'; - - static::assertTrue($algorithm->verify($key, $data, hex2bin( - '9c75b9d171d9690a37f2474d4bfab5c234911cb150950ea5cbfc9aedda5ec360725cc47978de95b4efb2a3ed617c7b36b1cd0a26b536662a79d0f3ae873a7924' - ))); - } - - #[Test] - public function es256KSignAndVerify(): void - { - $key = $this->getKey(); - $algorithm = new ES256K(); - $data = 'Hello'; - - static::assertSame('ES256K', $algorithm->name()); - - $signature = $algorithm->sign($key, $data); - - static::assertTrue($algorithm->verify($key, $data, $signature)); - } - - private function getKey(): JWK - { - return new JWK([ - 'kty' => 'EC', - 'crv' => 'secp256k1', - 'd' => Base64UrlSafe::encodeUnpadded( - hex2bin('D1592A94BBB9B5D94CDC425FC7DA80B6A47863AE973A9D581FD9D8F29690B659') - ), - 'x' => Base64UrlSafe::encodeUnpadded( - hex2bin('4B4DF318DE05BB8F3A115BF337F9BCBC55CA14B917B46BCB557D3C9A158D4BE0') - ), - 'y' => Base64UrlSafe::encodeUnpadded( - hex2bin('627EB75731A8BBEBC7D9A3C57EC4D7DA2CBA6D2A28E7F45134921861FE1CF5D9') - ), - ]); - } -}