diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8630a271..87bafcb6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -23,6 +23,8 @@ Fixed Added ~~~~~ +- Add support for Ed448/EdDSA. `#675 `__ + `v2.1.0 `__ -------------------------------------------------------------------- diff --git a/docs/algorithms.rst b/docs/algorithms.rst index 71922523..0fe0761f 100644 --- a/docs/algorithms.rst +++ b/docs/algorithms.rst @@ -17,7 +17,7 @@ This library currently supports: * PS256 - RSASSA-PSS signature using SHA-256 and MGF1 padding with SHA-256 * PS384 - RSASSA-PSS signature using SHA-384 and MGF1 padding with SHA-384 * PS512 - RSASSA-PSS signature using SHA-512 and MGF1 padding with SHA-512 -* EdDSA - Ed25519 signature using SHA-512. Provides 128-bit security +* EdDSA - Both Ed25519 signature using SHA-512 and Ed448 signature using SHA-3 are supported. Ed25519 and Ed448 provide 128-bit and 224-bit security respectively. Asymmetric (Public-key) Algorithms ---------------------------------- diff --git a/jwt/algorithms.py b/jwt/algorithms.py index cee66a47..2f738355 100644 --- a/jwt/algorithms.py +++ b/jwt/algorithms.py @@ -22,6 +22,10 @@ EllipticCurvePrivateKey, EllipticCurvePublicKey, ) + from cryptography.hazmat.primitives.asymmetric.ed448 import ( + Ed448PrivateKey, + Ed448PublicKey, + ) from cryptography.hazmat.primitives.asymmetric.ed25519 import ( Ed25519PrivateKey, Ed25519PublicKey, @@ -93,7 +97,7 @@ def get_default_algorithms(): "PS256": RSAPSSAlgorithm(RSAPSSAlgorithm.SHA256), "PS384": RSAPSSAlgorithm(RSAPSSAlgorithm.SHA384), "PS512": RSAPSSAlgorithm(RSAPSSAlgorithm.SHA512), - "EdDSA": Ed25519Algorithm(), + "EdDSA": OKPAlgorithm(), } ) @@ -534,9 +538,9 @@ def verify(self, msg, key, sig): except InvalidSignature: return False - class Ed25519Algorithm(Algorithm): + class OKPAlgorithm(Algorithm): """ - Performs signing and verification operations using Ed25519 + Performs signing and verification operations using EdDSA This class requires ``cryptography>=2.6`` to be installed. """ @@ -546,7 +550,10 @@ def __init__(self, **kwargs): def prepare_key(self, key): - if isinstance(key, (Ed25519PrivateKey, Ed25519PublicKey)): + if isinstance( + key, + (Ed25519PrivateKey, Ed25519PublicKey, Ed448PrivateKey, Ed448PublicKey), + ): return key if isinstance(key, (bytes, str)): @@ -565,9 +572,10 @@ def prepare_key(self, key): def sign(self, msg, key): """ - Sign a message ``msg`` using the Ed25519 private key ``key`` + Sign a message ``msg`` using the EdDSA private key ``key`` :param str|bytes msg: Message to sign - :param Ed25519PrivateKey key: A :class:`.Ed25519PrivateKey` instance + :param Ed25519PrivateKey}Ed448PrivateKey key: A :class:`.Ed25519PrivateKey` + or :class:`.Ed448PrivateKey` iinstance :return bytes signature: The signature, as bytes """ msg = bytes(msg, "utf-8") if type(msg) is not bytes else msg @@ -575,18 +583,19 @@ def sign(self, msg, key): def verify(self, msg, key, sig): """ - Verify a given ``msg`` against a signature ``sig`` using the Ed25519 key ``key`` + Verify a given ``msg`` against a signature ``sig`` using the EdDSA key ``key`` - :param str|bytes sig: Ed25519 signature to check ``msg`` against + :param str|bytes sig: EdDSA signature to check ``msg`` against :param str|bytes msg: Message to sign - :param Ed25519PrivateKey|Ed25519PublicKey key: A private or public Ed25519 key instance + :param Ed25519PrivateKey|Ed25519PublicKey|Ed448PrivateKey|Ed448PublicKey key: + A private or public EdDSA key instance :return bool verified: True if signature is valid, False if not. """ try: msg = bytes(msg, "utf-8") if type(msg) is not bytes else msg sig = bytes(sig, "utf-8") if type(sig) is not bytes else sig - if isinstance(key, Ed25519PrivateKey): + if isinstance(key, (Ed25519PrivateKey, Ed448PrivateKey)): key = key.public_key() key.verify(sig, msg) return True # If no exception was raised, the signature is valid. @@ -595,21 +604,21 @@ def verify(self, msg, key, sig): @staticmethod def to_jwk(key): - if isinstance(key, Ed25519PublicKey): + if isinstance(key, (Ed25519PublicKey, Ed448PublicKey)): x = key.public_bytes( encoding=Encoding.Raw, format=PublicFormat.Raw, ) - + crv = "Ed25519" if isinstance(key, Ed25519PublicKey) else "Ed448" return json.dumps( { "x": base64url_encode(force_bytes(x)).decode(), "kty": "OKP", - "crv": "Ed25519", + "crv": crv, } ) - if isinstance(key, Ed25519PrivateKey): + if isinstance(key, (Ed25519PrivateKey, Ed448PrivateKey)): d = key.private_bytes( encoding=Encoding.Raw, format=PrivateFormat.Raw, @@ -621,12 +630,13 @@ def to_jwk(key): format=PublicFormat.Raw, ) + crv = "Ed25519" if isinstance(key, Ed25519PrivateKey) else "Ed448" return json.dumps( { "x": base64url_encode(force_bytes(x)).decode(), "d": base64url_encode(force_bytes(d)).decode(), "kty": "OKP", - "crv": "Ed25519", + "crv": crv, } ) @@ -648,7 +658,7 @@ def from_jwk(jwk): raise InvalidKeyError("Not an Octet Key Pair") curve = obj.get("crv") - if curve != "Ed25519": + if curve != "Ed25519" and curve != "Ed448": raise InvalidKeyError(f"Invalid curve: {curve}") if "x" not in obj: @@ -657,8 +667,12 @@ def from_jwk(jwk): try: if "d" not in obj: - return Ed25519PublicKey.from_public_bytes(x) + if curve == "Ed25519": + return Ed25519PublicKey.from_public_bytes(x) + return Ed448PublicKey.from_public_bytes(x) d = base64url_decode(obj.get("d")) - return Ed25519PrivateKey.from_private_bytes(d) + if curve == "Ed25519": + return Ed25519PrivateKey.from_private_bytes(d) + return Ed448PrivateKey.from_private_bytes(d) except ValueError as err: raise InvalidKeyError("Invalid key parameter") from err diff --git a/tests/keys/jwk_okp_key_Ed448.json b/tests/keys/jwk_okp_key_Ed448.json new file mode 100644 index 00000000..02c44d06 --- /dev/null +++ b/tests/keys/jwk_okp_key_Ed448.json @@ -0,0 +1,9 @@ +{ + "kty": "OKP", + "kid": "sig_ed448_01", + "crv": "Ed448", + "use": "sig", + "x": "kvqP7TzMosCQCpNcW8qY2HmVmpPYUEIGn-sQWQgoWlAZbWpnXpXqAT6yMoYA08pkJm7P_HKZoHwA", + "d": "Zh5xx0r_0tq39xj-8jGuCwAA6wsDim2ME7cX_iXzqDRgPN8lsZZHu60AO7m31Fa4NtHO07eU63q8", + "alg": "EdDSA" +} diff --git a/tests/keys/jwk_okp_pub_Ed448.json b/tests/keys/jwk_okp_pub_Ed448.json new file mode 100644 index 00000000..9ce0a101 --- /dev/null +++ b/tests/keys/jwk_okp_pub_Ed448.json @@ -0,0 +1,8 @@ +{ + "kty": "OKP", + "kid": "sig_ed448_01", + "crv": "Ed448", + "use": "sig", + "x": "kvqP7TzMosCQCpNcW8qY2HmVmpPYUEIGn-sQWQgoWlAZbWpnXpXqAT6yMoYA08pkJm7P_HKZoHwA", + "alg": "EdDSA" +} diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py index 417f91d8..b6a73fc4 100644 --- a/tests/test_algorithms.py +++ b/tests/test_algorithms.py @@ -11,12 +11,7 @@ from .utils import crypto_required, key_path if has_crypto: - from jwt.algorithms import ( - ECAlgorithm, - Ed25519Algorithm, - RSAAlgorithm, - RSAPSSAlgorithm, - ) + from jwt.algorithms import ECAlgorithm, OKPAlgorithm, RSAAlgorithm, RSAPSSAlgorithm class TestAlgorithms: @@ -667,12 +662,12 @@ def test_ec_verify_should_return_true_for_test_vector(self): @crypto_required -class TestEd25519Algorithms: +class TestOKPAlgorithms: hello_world_sig = b"Qxa47mk/azzUgmY2StAOguAd4P7YBLpyCfU3JdbaiWnXM4o4WibXwmIHvNYgN3frtE2fcyd8OYEaOiD/KiwkCg==" hello_world = b"Hello World!" - def test_ed25519_should_reject_non_string_key(self): - algo = Ed25519Algorithm() + def test_okp_ed25519_should_reject_non_string_key(self): + algo = OKPAlgorithm() with pytest.raises(TypeError): algo.prepare_key(None) @@ -683,14 +678,14 @@ def test_ed25519_should_reject_non_string_key(self): with open(key_path("testkey_ed25519.pub")) as keyfile: algo.prepare_key(keyfile.read()) - def test_ed25519_should_accept_unicode_key(self): - algo = Ed25519Algorithm() + def test_okp_ed25519_should_accept_unicode_key(self): + algo = OKPAlgorithm() with open(key_path("testkey_ed25519")) as ec_key: algo.prepare_key(ec_key.read()) - def test_ed25519_sign_should_generate_correct_signature_value(self): - algo = Ed25519Algorithm() + def test_okp_ed25519_sign_should_generate_correct_signature_value(self): + algo = OKPAlgorithm() jwt_message = self.hello_world @@ -706,8 +701,8 @@ def test_ed25519_sign_should_generate_correct_signature_value(self): result = algo.verify(jwt_message, jwt_pub_key, expected_sig) assert result - def test_ed25519_verify_should_return_false_if_signature_invalid(self): - algo = Ed25519Algorithm() + def test_okp_ed25519_verify_should_return_false_if_signature_invalid(self): + algo = OKPAlgorithm() jwt_message = self.hello_world jwt_sig = base64.b64decode(self.hello_world_sig) @@ -720,8 +715,8 @@ def test_ed25519_verify_should_return_false_if_signature_invalid(self): result = algo.verify(jwt_message, jwt_pub_key, jwt_sig) assert not result - def test_ed25519_verify_should_return_true_if_signature_valid(self): - algo = Ed25519Algorithm() + def test_okp_ed25519_verify_should_return_true_if_signature_valid(self): + algo = OKPAlgorithm() jwt_message = self.hello_world jwt_sig = base64.b64decode(self.hello_world_sig) @@ -732,8 +727,8 @@ def test_ed25519_verify_should_return_true_if_signature_valid(self): result = algo.verify(jwt_message, jwt_pub_key, jwt_sig) assert result - def test_ed25519_prepare_key_should_be_idempotent(self): - algo = Ed25519Algorithm() + def test_okp_ed25519_prepare_key_should_be_idempotent(self): + algo = OKPAlgorithm() with open(key_path("testkey_ed25519.pub")) as keyfile: jwt_pub_key_first = algo.prepare_key(keyfile.read()) @@ -741,8 +736,8 @@ def test_ed25519_prepare_key_should_be_idempotent(self): assert jwt_pub_key_first == jwt_pub_key_second - def test_ed25519_jwk_private_key_should_parse_and_verify(self): - algo = Ed25519Algorithm() + def test_okp_ed25519_jwk_private_key_should_parse_and_verify(self): + algo = OKPAlgorithm() with open(key_path("jwk_okp_key_Ed25519.json")) as keyfile: key = algo.from_jwk(keyfile.read()) @@ -750,8 +745,19 @@ def test_ed25519_jwk_private_key_should_parse_and_verify(self): signature = algo.sign(b"Hello World!", key) assert algo.verify(b"Hello World!", key.public_key(), signature) - def test_ed25519_jwk_public_key_should_parse_and_verify(self): - algo = Ed25519Algorithm() + def test_okp_ed25519_jwk_private_key_should_parse_and_verify_with_private_key_as_is( + self, + ): + algo = OKPAlgorithm() + + with open(key_path("jwk_okp_key_Ed25519.json")) as keyfile: + key = algo.from_jwk(keyfile.read()) + + signature = algo.sign(b"Hello World!", key) + assert algo.verify(b"Hello World!", key, signature) + + def test_okp_ed25519_jwk_public_key_should_parse_and_verify(self): + algo = OKPAlgorithm() with open(key_path("jwk_okp_key_Ed25519.json")) as keyfile: priv_key = algo.from_jwk(keyfile.read()) @@ -762,8 +768,8 @@ def test_ed25519_jwk_public_key_should_parse_and_verify(self): signature = algo.sign(b"Hello World!", priv_key) assert algo.verify(b"Hello World!", pub_key, signature) - def test_ed25519_jwk_fails_on_invalid_json(self): - algo = Ed25519Algorithm() + def test_okp_ed25519_jwk_fails_on_invalid_json(self): + algo = OKPAlgorithm() with open(key_path("jwk_okp_pub_Ed25519.json")) as keyfile: valid_pub = json.loads(keyfile.read()) @@ -790,6 +796,12 @@ def test_ed25519_jwk_fails_on_invalid_json(self): with pytest.raises(InvalidKeyError): algo.from_jwk(v) + # Invalid crv, "Ed448" + v = valid_pub.copy() + v["crv"] = "Ed448" + with pytest.raises(InvalidKeyError): + algo.from_jwk(v) + # Missing x v = valid_pub.copy() del v["x"] @@ -808,8 +820,8 @@ def test_ed25519_jwk_fails_on_invalid_json(self): with pytest.raises(InvalidKeyError): algo.from_jwk(v) - def test_ed25519_to_jwk_works_with_from_jwk(self): - algo = Ed25519Algorithm() + def test_okp_ed25519_to_jwk_works_with_from_jwk(self): + algo = OKPAlgorithm() with open(key_path("jwk_okp_key_Ed25519.json")) as keyfile: priv_key_1 = algo.from_jwk(keyfile.read()) @@ -827,8 +839,111 @@ def test_ed25519_to_jwk_works_with_from_jwk(self): assert algo.verify(b"Hello World!", pub_key_2, signature_1) assert algo.verify(b"Hello World!", pub_key_2, signature_2) - def test_ed25519_to_jwk_raises_exception_on_invalid_key(self): - algo = Ed25519Algorithm() + def test_okp_to_jwk_raises_exception_on_invalid_key(self): + algo = OKPAlgorithm() with pytest.raises(InvalidKeyError): algo.to_jwk({"not": "a valid key"}) + + def test_okp_ed448_jwk_private_key_should_parse_and_verify(self): + algo = OKPAlgorithm() + + with open(key_path("jwk_okp_key_Ed448.json")) as keyfile: + key = algo.from_jwk(keyfile.read()) + + signature = algo.sign(b"Hello World!", key) + assert algo.verify(b"Hello World!", key.public_key(), signature) + + def test_okp_ed448_jwk_private_key_should_parse_and_verify_with_private_key_as_is( + self, + ): + algo = OKPAlgorithm() + + with open(key_path("jwk_okp_key_Ed448.json")) as keyfile: + key = algo.from_jwk(keyfile.read()) + + signature = algo.sign(b"Hello World!", key) + assert algo.verify(b"Hello World!", key, signature) + + def test_okp_ed448_jwk_public_key_should_parse_and_verify(self): + algo = OKPAlgorithm() + + with open(key_path("jwk_okp_key_Ed448.json")) as keyfile: + priv_key = algo.from_jwk(keyfile.read()) + + with open(key_path("jwk_okp_pub_Ed448.json")) as keyfile: + pub_key = algo.from_jwk(keyfile.read()) + + signature = algo.sign(b"Hello World!", priv_key) + assert algo.verify(b"Hello World!", pub_key, signature) + + def test_okp_ed448_jwk_fails_on_invalid_json(self): + algo = OKPAlgorithm() + + with open(key_path("jwk_okp_pub_Ed448.json")) as keyfile: + valid_pub = json.loads(keyfile.read()) + with open(key_path("jwk_okp_key_Ed448.json")) as keyfile: + valid_key = json.loads(keyfile.read()) + + # Invalid instance type + with pytest.raises(InvalidKeyError): + algo.from_jwk(123) + + # Invalid JSON + with pytest.raises(InvalidKeyError): + algo.from_jwk("") + + # Invalid kty, not "OKP" + v = valid_pub.copy() + v["kty"] = "oct" + with pytest.raises(InvalidKeyError): + algo.from_jwk(v) + + # Invalid crv, not "Ed448" + v = valid_pub.copy() + v["crv"] = "P-256" + with pytest.raises(InvalidKeyError): + algo.from_jwk(v) + + # Invalid crv, "Ed25519" + v = valid_pub.copy() + v["crv"] = "Ed25519" + with pytest.raises(InvalidKeyError): + algo.from_jwk(v) + + # Missing x + v = valid_pub.copy() + del v["x"] + with pytest.raises(InvalidKeyError): + algo.from_jwk(v) + + # Invalid x + v = valid_pub.copy() + v["x"] = "123" + with pytest.raises(InvalidKeyError): + algo.from_jwk(v) + + # Invalid d + v = valid_key.copy() + v["d"] = "123" + with pytest.raises(InvalidKeyError): + algo.from_jwk(v) + + def test_okp_ed448_to_jwk_works_with_from_jwk(self): + algo = OKPAlgorithm() + + with open(key_path("jwk_okp_key_Ed448.json")) as keyfile: + priv_key_1 = algo.from_jwk(keyfile.read()) + + with open(key_path("jwk_okp_pub_Ed448.json")) as keyfile: + pub_key_1 = algo.from_jwk(keyfile.read()) + + pub = algo.to_jwk(pub_key_1) + pub_key_2 = algo.from_jwk(pub) + pri = algo.to_jwk(priv_key_1) + priv_key_2 = algo.from_jwk(pri) + + signature_1 = algo.sign(b"Hello World!", priv_key_1) + signature_2 = algo.sign(b"Hello World!", priv_key_2) + assert algo.verify(b"Hello World!", pub_key_2, signature_1) + assert algo.verify(b"Hello World!", pub_key_2, signature_2) diff --git a/tests/test_api_jwk.py b/tests/test_api_jwk.py index e0787f46..53c05473 100644 --- a/tests/test_api_jwk.py +++ b/tests/test_api_jwk.py @@ -9,12 +9,7 @@ from .utils import crypto_required, key_path if has_crypto: - from jwt.algorithms import ( - ECAlgorithm, - Ed25519Algorithm, - HMACAlgorithm, - RSAAlgorithm, - ) + from jwt.algorithms import ECAlgorithm, HMACAlgorithm, OKPAlgorithm, RSAAlgorithm class TestPyJWK: @@ -166,7 +161,7 @@ def test_should_load_key_okp_without_alg_from_dict(self): jwk = PyJWK.from_dict(key_data) assert jwk.key_type == "OKP" - assert isinstance(jwk.Algorithm, Ed25519Algorithm) + assert isinstance(jwk.Algorithm, OKPAlgorithm) @crypto_required def test_from_dict_should_throw_exception_if_arg_is_invalid(self):