From 6e678f244faf82c1fcc40e71a0894c3530e32c62 Mon Sep 17 00:00:00 2001 From: "Chris (Someguy123)" Date: Sat, 26 Oct 2019 03:58:40 +0100 Subject: [PATCH] Add support for Ed25519 / EdDSA, with unit tests --- jwt/algorithms.py | 12 ++++ jwt/contrib/algorithms/py_ed25519.py | 71 +++++++++++++++++++++++ tests/contrib/test_algorithms.py | 86 ++++++++++++++++++++++++++++ tests/keys/testkey_ed25519 | 3 + tests/keys/testkey_ed25519.pub | 1 + 5 files changed, 173 insertions(+) create mode 100644 jwt/contrib/algorithms/py_ed25519.py create mode 100644 tests/keys/testkey_ed25519 create mode 100644 tests/keys/testkey_ed25519.pub diff --git a/jwt/algorithms.py b/jwt/algorithms.py index 293a4707..5e65d9ff 100644 --- a/jwt/algorithms.py +++ b/jwt/algorithms.py @@ -43,6 +43,7 @@ has_crypto = True except ImportError: has_crypto = False + has_ed25519 = False requires_cryptography = set( [ @@ -56,6 +57,7 @@ "PS256", "PS384", "PS512", + "EdDSA", ] ) @@ -86,8 +88,18 @@ def get_default_algorithms(): "PS256": RSAPSSAlgorithm(RSAPSSAlgorithm.SHA256), "PS384": RSAPSSAlgorithm(RSAPSSAlgorithm.SHA384), "PS512": RSAPSSAlgorithm(RSAPSSAlgorithm.SHA512), + } ) + # Older versions of the `cryptography` libraries may not have Ed25519 available. + # Needs a minimum of version 2.6 + try: + from jwt.contrib.algorithms.py_ed25519 import Ed25519Algorithm + default_algorithms.update({ + "EdDSA": Ed25519Algorithm(), + }) + except ImportError: + pass return default_algorithms diff --git a/jwt/contrib/algorithms/py_ed25519.py b/jwt/contrib/algorithms/py_ed25519.py new file mode 100644 index 00000000..6e761d55 --- /dev/null +++ b/jwt/contrib/algorithms/py_ed25519.py @@ -0,0 +1,71 @@ +""" +Implementation of Ed25519 using ``cryptography`` (as of Version 2.6 released in February 2019) +""" + +import cryptography.exceptions +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey +from cryptography.hazmat.primitives.serialization import load_pem_public_key, load_pem_private_key, load_ssh_public_key + +from jwt.algorithms import Algorithm +from jwt.compat import string_types, text_type + + +class Ed25519Algorithm(Algorithm): + """ + Performs signing and verification operations using Ed25519 + + This class requires ``cryptography>=2.6`` to be installed. + """ + + def __init__(self, **kwargs): + pass + + def prepare_key(self, key): + + if isinstance(key, (Ed25519PrivateKey, Ed25519PublicKey)): + return key + + if isinstance(key, string_types): + if isinstance(key, text_type): + key = key.encode("utf-8") + str_key = key.decode('utf-8') + + if '-----BEGIN PUBLIC' in str_key: + return load_pem_public_key(key, backend=default_backend()) + if '-----BEGIN PRIVATE' in str_key: + return load_pem_private_key(key, password=None, backend=default_backend()) + if str_key[0:4] == 'ssh-': + return load_ssh_public_key(key, backend=default_backend()) + + raise TypeError("Expecting a PEM-formatted or OpenSSH key.") + + def sign(self, msg, key): + """ + Sign a message ``msg`` using the Ed25519 private key ``key`` + :param str|bytes msg: Message to sign + :param Ed25519PrivateKey key: A :class:`.Ed25519PrivateKey` instance + :return bytes signature: The signature, as bytes + """ + msg = bytes(msg, 'utf-8') if type(msg) is not bytes else msg + return key.sign(msg) + + def verify(self, msg, key, sig): + """ + Verify a given ``msg`` against a signature ``sig`` using the Ed25519 key ``key`` + + :param str|bytes sig: Ed25519 signature to check ``msg`` against + :param str|bytes msg: Message to sign + :param Ed25519PrivateKey|Ed25519PublicKey key: A private or public Ed25519 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): + key = key.public_key() + key.verify(sig, msg) + return True # If no exception was raised, the signature is valid. + except cryptography.exceptions.InvalidSignature: + return False diff --git a/tests/contrib/test_algorithms.py b/tests/contrib/test_algorithms.py index 4a1550b1..35825c04 100644 --- a/tests/contrib/test_algorithms.py +++ b/tests/contrib/test_algorithms.py @@ -1,4 +1,5 @@ import base64 +import warnings import pytest @@ -20,6 +21,13 @@ except ImportError: has_ecdsa = False +try: + from jwt.contrib.algorithms.py_ed25519 import Ed25519Algorithm + + has_ed25519 = True +except ImportError as e: + has_ed25519 = False + @pytest.mark.skipif( not has_pycrypto, reason="Not supported without PyCrypto library" @@ -212,3 +220,81 @@ def test_ec_prepare_key_should_be_idempotent(self): jwt_pub_key_second = algo.prepare_key(jwt_pub_key_first) assert jwt_pub_key_first == jwt_pub_key_second + + +@pytest.mark.skipif( + not has_ed25519, reason="Not supported without cryptography>=2.6 library" +) +class TestEd25519Algorithms: + hello_world_sig = 'Qxa47mk/azzUgmY2StAOguAd4P7YBLpyCfU3JdbaiWnXM4o4WibXwmIHvNYgN3frtE2fcyd8OYEaOiD/KiwkCg==' + hello_world = force_bytes('Hello World!') + + def test_ed25519_should_reject_non_string_key(self): + algo = Ed25519Algorithm() + + with pytest.raises(TypeError): + algo.prepare_key(None) + + with open(key_path("testkey_ed25519"), "r") as keyfile: + jwt_key = algo.prepare_key(keyfile.read()) + + with open(key_path("testkey_ed25519.pub"), "r") as keyfile: + jwt_pub_key = algo.prepare_key(keyfile.read()) + + def test_ed25519_should_accept_unicode_key(self): + algo = Ed25519Algorithm() + + with open(key_path("testkey_ed25519"), "r") as ec_key: + algo.prepare_key(force_unicode(ec_key.read())) + + def test_ed25519_sign_should_generate_correct_signature_value(self): + algo = Ed25519Algorithm() + + jwt_message = self.hello_world + + expected_sig = base64.b64decode(force_bytes(self.hello_world_sig)) + + with open(key_path("testkey_ed25519"), "r") as keyfile: + jwt_key = algo.prepare_key(keyfile.read()) + + with open(key_path("testkey_ed25519.pub"), "r") as keyfile: + jwt_pub_key = algo.prepare_key(keyfile.read()) + + algo.sign(jwt_message, jwt_key) + 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() + + jwt_message = self.hello_world + jwt_sig = base64.b64decode(force_bytes(self.hello_world_sig)) + + jwt_sig += force_bytes("123") # Signature is now invalid + + with open(key_path("testkey_ed25519.pub"), "r") as keyfile: + jwt_pub_key = algo.prepare_key(keyfile.read()) + + 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() + + jwt_message = self.hello_world + jwt_sig = base64.b64decode(force_bytes(self.hello_world_sig)) + + with open(key_path("testkey_ed25519.pub"), "r") as keyfile: + jwt_pub_key = algo.prepare_key(keyfile.read()) + + result = algo.verify(jwt_message, jwt_pub_key, jwt_sig) + assert result + + def test_ed25519_prepare_key_should_be_idempotent(self): + algo = Ed25519Algorithm() + + with open(key_path("testkey_ed25519.pub"), "r") as keyfile: + jwt_pub_key_first = algo.prepare_key(keyfile.read()) + jwt_pub_key_second = algo.prepare_key(jwt_pub_key_first) + + assert jwt_pub_key_first == jwt_pub_key_second diff --git a/tests/keys/testkey_ed25519 b/tests/keys/testkey_ed25519 new file mode 100644 index 00000000..6863f178 --- /dev/null +++ b/tests/keys/testkey_ed25519 @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIBy9N4xfv/9qOiKrxwRKeGfO5ab6lSukKHbuC5vaJ1Mg +-----END PRIVATE KEY----- diff --git a/tests/keys/testkey_ed25519.pub b/tests/keys/testkey_ed25519.pub new file mode 100644 index 00000000..13c80c7c --- /dev/null +++ b/tests/keys/testkey_ed25519.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC4pK2dePGgctIAsh0H/tmUrLzx2Vc4Ltc8TN9nfuChG \ No newline at end of file