diff --git a/jose/backends/__init__.py b/jose/backends/__init__.py index d1b9fa1a..a17814df 100644 --- a/jose/backends/__init__.py +++ b/jose/backends/__init__.py @@ -11,3 +11,18 @@ from jose.backends.cryptography_backend import CryptographyECKey as ECKey # noqa: F401 except ImportError: from jose.backends.ecdsa_backend import ECDSAECKey as ECKey # noqa: F401 + +try: + from jose.backends.nacl_backend import Ed25519Key # noqa: F401 +except ImportError: + pass +else: + # Since PyNaCl is an optional dependency, we do not add EdDSA to the set + # of supported algorithms in the jose.constants module. + # As a result, when we successfully import Ed25519Key, we need to manually + # register that algorithm, but we cannot do it in the jose.constants module + # because that would create a circular import. Instead, we do it here. + # TODO: Refactor to use __init_subclass__ hook on jose.backends.base.Key + import jose.constants as j_c + j_c.ALGORITHMS.SUPPORTED = j_c.ALGORITHMS.SUPPORTED.union(j_c.ALGORITHMS.ED) + j_c.ALGORITHMS.ALL = j_c.ALGORITHMS.SUPPORTED.union([j_c.ALGORITHMS.NONE]) diff --git a/jose/backends/nacl_backend.py b/jose/backends/nacl_backend.py new file mode 100644 index 00000000..572d8297 --- /dev/null +++ b/jose/backends/nacl_backend.py @@ -0,0 +1,106 @@ +import base64 + +import six + +from jose.backends.base import Key +from jose.constants import ALGORITHMS, USAGES +from jose.exceptions import JWKError + +from nacl.exceptions import BadSignatureError +from nacl.signing import SigningKey, VerifyKey + + +class Ed25519Key(Key): + def __init__(self, key, algorithm, use=None): + if algorithm not in ALGORITHMS.ED: + raise JWKError('hash_alg: %s is not a valid Ed25519 hash algorithm' % algorithm) + + # TODO: Validate Ed25519 hash algorithms + self._algorithm = algorithm + + if isinstance(key, dict): + self._prepared_key = self._process_jwk(key) + return + + if isinstance(key, (SigningKey, VerifyKey)): + self._prepared_key = key + return + + if isinstance(key, six.string_types): + key = key.encode('utf-8') + b'==' + + if isinstance(key, six.binary_type): + if use is None: + raise JWKError("The 'use' parameter is required when deserializing an Ed25519 key " + "from a string or bytes") + + if use == USAGES.PUBLIC: + decoded_key_bytes = base64.urlsafe_b64decode(key) + self._prepared_key = VerifyKey(decoded_key_bytes) + elif use == USAGES.PRIVATE: + decoded_key_bytes = base64.urlsafe_b64decode(key) + self._prepared_key = SigningKey(decoded_key_bytes) + else: + raise JWKError("The 'use' parameter must either be 'public' or 'private', not %s" % use) + return + + raise JWKError('Unable to parse an Ed25519_JWK from key: %s' % key) + + def _process_jwk(self, jwk_dict): + if not jwk_dict.get('kty') == 'OKP': + raise JWKError("Incorrect key type. Expected: 'OKP', Received: %s" % jwk_dict.get('kty')) + + if not jwk_dict.get('crv') == 'Ed25519': + raise JWKError("Incorrect key subtype. Expected 'Ed25519', Received %s" % jwk_dict.get('crv')) + + if 'd' in jwk_dict: + # d indicates private key + d = jwk_dict.get('d').encode('utf-8') + b'==' + decoded_d_bytes = base64.urlsafe_b64decode(d) + return SigningKey(decoded_d_bytes) + else: + # no d indicates public key + x = jwk_dict.get('x').encode('utf-8') + b'==' + decoded_x_bytes = base64.urlsafe_b64decode(x) + return VerifyKey(decoded_x_bytes) + + def sign(self, msg): + if isinstance(msg, six.string_types): + msg = msg.encode('utf-8') + return self._prepared_key.sign(msg) + + def verify(self, msg, sig=None): + try: + self._prepared_key.verify(msg, sig) + return True + except BadSignatureError: + return False + + def is_public(self): + return isinstance(self._prepared_key, VerifyKey) + + def public_key(self): + if isinstance(self._prepared_key, VerifyKey): + return self + return self.__class__(self._prepared_key.verify_key, self._algorithm) + + def to_pem(self, *args, **kwargs): + # Serializing Ed25519 keys is not yet supported anywhere in Python + # AFAICT, so instead of creating our own format, we simply prevent + # anybody from serializing to PEM. + raise NotImplementedError("Cannot serialize Ed25519 keys yet") + + def to_dict(self): + public_key = self.public_key() + + data = { + 'alg': self._algorithm, + 'kty': 'OKP', + 'crv': 'Ed25519', + 'x': base64.urlsafe_b64encode(bytes(public_key._prepared_key)).decode('utf-8'), + } + + if not self.is_public(): + data.update({'d': base64.urlsafe_b64encode(bytes(self._prepared_key)).decode('utf-8')}) + + return data diff --git a/jose/constants.py b/jose/constants.py index eb146549..3f202d6d 100644 --- a/jose/constants.py +++ b/jose/constants.py @@ -12,11 +12,15 @@ class Algorithms(object): ES256 = 'ES256' ES384 = 'ES384' ES512 = 'ES512' + # RFC8037 - https://tools.ietf.org/html/rfc8037 + EdDSA = 'EdDSA' HMAC = {HS256, HS384, HS512} RSA = {RS256, RS384, RS512} EC = {ES256, ES384, ES512} + ED = {EdDSA} + # This is modified in jose.backends.nacl_backend to register EdDSA SUPPORTED = HMAC.union(RSA).union(EC) ALL = SUPPORTED.union([NONE]) @@ -37,3 +41,15 @@ class Algorithms(object): ALGORITHMS = Algorithms() + + +class Usages(object): + PUBLIC = 'public' + PRIVATE = 'private' + + SUPPORTED = {PUBLIC, PRIVATE} + + ALL = SUPPORTED + + +USAGES = Usages() diff --git a/jose/jwk.py b/jose/jwk.py index 87f30b41..e4fb25ca 100644 --- a/jose/jwk.py +++ b/jose/jwk.py @@ -19,6 +19,11 @@ except ImportError: pass +try: + from jose.backends import Ed25519Key # noqa: F401 +except ImportError: + pass + def get_key(algorithm): if algorithm in ALGORITHMS.KEYS: @@ -31,6 +36,9 @@ def get_key(algorithm): elif algorithm in ALGORITHMS.EC: from jose.backends import ECKey # noqa: F811 return ECKey + elif algorithm in ALGORITHMS.ED: + from jose.backends import Ed25519Key # noqa: F811 + return Ed25519Key return None @@ -42,7 +50,7 @@ def register_key(algorithm, key_class): return True -def construct(key_data, algorithm=None): +def construct(key_data, algorithm=None, use=None): """ Construct a Key object for the given algorithm with the given key_data. @@ -58,7 +66,10 @@ def construct(key_data, algorithm=None): key_class = get_key(algorithm) if not key_class: raise JWKError('Unable to find a algorithm for key: %s' % key_data) - return key_class(key_data, algorithm) + if use is None: + return key_class(key_data, algorithm) + else: + return key_class(key_data, algorithm, use) def get_algorithm_object(algorithm): diff --git a/requirements.txt b/requirements.txt index 04dd5d1b..131e5877 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ six rsa ecdsa pyasn1 +pynacl diff --git a/setup.py b/setup.py index 01281158..3744b9b9 100644 --- a/setup.py +++ b/setup.py @@ -37,9 +37,10 @@ def _cryptography_version(): 'cryptography': [_cryptography_version()], 'pycrypto': ['pycrypto >=2.6.0, <2.7.0'] + pyasn1, 'pycryptodome': ['pycryptodome >=3.3.1, <4.0.0'] + pyasn1, + 'ed25519': ['pynacl'], } legacy_backend_requires = ['ecdsa <1.0', 'rsa'] + pyasn1 -install_requires = ['six <2.0'] +install_requires = ['six <2.0', 'pynacl'] # TODO: work this into the extras selection instead. install_requires += legacy_backend_requires diff --git a/tests/algorithms/test_Ed25519.py b/tests/algorithms/test_Ed25519.py new file mode 100644 index 00000000..2cdd0bf1 --- /dev/null +++ b/tests/algorithms/test_Ed25519.py @@ -0,0 +1,165 @@ + +import base64 + +from jose.backends.nacl_backend import Ed25519Key +from jose.constants import ALGORITHMS, USAGES +from jose.exceptions import JWKError + +from nacl.signing import SigningKey, VerifyKey + +import pytest + + +SIGNING_KEY = "npAVhmIfq2byvIzcmgS5cguKCv2Nw8Seqa1Fku00LoE=" +signing_key_bytes = base64.urlsafe_b64decode(SIGNING_KEY.encode('utf-8')) +nacl_signing_key = SigningKey(signing_key_bytes) +nacl_verify_key = nacl_signing_key.verify_key +VERIFY_KEY = base64.urlsafe_b64encode(bytes(nacl_verify_key)) + + +class TestEd25519Algorithm: + + @pytest.mark.parametrize("alg", ALGORITHMS.ED) + @pytest.mark.parametrize("use", USAGES.ALL) + def test_Ed25519_key(self, alg, use): + assert Ed25519Key(SIGNING_KEY, algorithm=alg, use=use)._prepared_key + assert Ed25519Key(SIGNING_KEY.encode('utf-8'), algorithm=alg, use=use)._prepared_key + # With Ed25519, there is no difference between seeds for private and public keys, and ALL + # 256-bit values are valid seeds, so since we have already tested with private key seeds, + # we do not need to also test for public key seeds + + @pytest.mark.parametrize("alg", ALGORITHMS.ED) + def test_Ed25519_signing_key(self, alg): + assert Ed25519Key(nacl_signing_key, algorithm=alg)._prepared_key + assert not Ed25519Key(nacl_signing_key, algorithm=alg).is_public() + + @pytest.mark.parametrize("alg", ALGORITHMS.ED) + def test_Ed25519_verify_key(self, alg): + assert Ed25519Key(nacl_verify_key, algorithm=alg)._prepared_key + assert Ed25519Key(nacl_verify_key, algorithm=alg).is_public() + + def test_Ed25519_key_unknown_object(self): + with pytest.raises(JWKError): + Ed25519Key(object(), algorithm=ALGORITHMS.EdDSA) + + @pytest.mark.parametrize("use", USAGES.ALL) + def test_Ed25519_key_bad_alg(self, use): + with pytest.raises(JWKError): + Ed25519Key(SIGNING_KEY, algorithm=ALGORITHMS.ES256, use=use) + + @pytest.mark.parametrize("alg", ALGORITHMS.ED) + def test_Ed25519_key_bad_use(self, alg): + with pytest.raises(JWKError): + Ed25519Key(SIGNING_KEY, algorithm=alg) + + with pytest.raises(JWKError): + Ed25519Key(SIGNING_KEY, algorithm=alg, use=None) + + with pytest.raises(JWKError): + Ed25519Key(SIGNING_KEY, algorithm=alg, use='bad_usage') + + def test_get_verify_key(self): + signing_key = Ed25519Key(SIGNING_KEY, algorithm=ALGORITHMS.EdDSA, use=USAGES.PRIVATE) + + assert not signing_key.is_public() + + verify_key = signing_key.public_key() # public_key is part of the Key API + verify_key2 = verify_key.public_key() + + assert verify_key.is_public() + assert verify_key is verify_key2 + + def test_to_pem(self): + signing_key = Ed25519Key(SIGNING_KEY, algorithm=ALGORITHMS.EdDSA, use=USAGES.PRIVATE) + + with pytest.raises(NotImplementedError): + signing_key.to_pem() + + def test_verify_key_to_pem(self): + signing_key = Ed25519Key(SIGNING_KEY, algorithm=ALGORITHMS.EdDSA, use=USAGES.PRIVATE) + verify_key = signing_key.public_key() + + with pytest.raises(NotImplementedError): + verify_key.to_pem() + + def assert_parameters(self, as_dict, private): + assert isinstance(as_dict, dict) + + # Public parameters should always be there + assert 'x' in as_dict + + if private: + # Private parameters as well + assert 'd' in as_dict + else: + # Private parameters should be absent + assert 'd' not in as_dict + + def assert_roundtrip(self, key, use): + assert Ed25519Key(key.to_dict(), ALGORITHMS.EdDSA, use=use).to_dict() == key.to_dict() + + def test_signing_key_to_dict(self): + key = Ed25519Key(SIGNING_KEY, algorithm=ALGORITHMS.EdDSA, use=USAGES.PRIVATE) + + self.assert_parameters(key.to_dict(), private=True) + self.assert_roundtrip(key, use=USAGES.PRIVATE) + + def test_verify_key_to_dict(self): + key = Ed25519Key(VERIFY_KEY, algorithm=ALGORITHMS.EdDSA, use=USAGES.PUBLIC) + + self.assert_parameters(key.to_dict(), private=False) + self.assert_roundtrip(key, use=USAGES.PUBLIC) + + def test_verify_key_from_bad_dict(self): + key = Ed25519Key(VERIFY_KEY, algorithm=ALGORITHMS.EdDSA, use=USAGES.PUBLIC) + key_dict = key.to_dict() + + bad_key_dict = key_dict.copy() + bad_key_dict['kty'] = "SOMETHING_ELSE" + + with pytest.raises(JWKError): + Ed25519Key(bad_key_dict, algorithm=ALGORITHMS.EdDSA) + + bad_key_dict = key_dict.copy() + bad_key_dict['crv'] = "SOMETHING_ELSE" + + with pytest.raises(JWKError): + Ed25519Key(bad_key_dict, algorithm=ALGORITHMS.EdDSA) + + def test_signing_bytes_parity(self): + signing_key = Ed25519Key(SIGNING_KEY, algorithm=ALGORITHMS.EdDSA, use=USAGES.PRIVATE) + verify_key = signing_key.public_key() + + msg = b'test' + smsg = signing_key.sign(msg) # -> signature + cleartext message + bad_smsg_message = bytes([(b + 1) if b < 255 else 0 for b in bytearray(smsg.message)]) + bad_smsg_signature = bytes([(b + 1) if b < 255 else 0 for b in bytearray(smsg.signature)]) + bad_smsg = bad_smsg_signature + bad_smsg_message + + assert verify_key.verify(smsg) + assert verify_key.verify(smsg.signature + smsg.message) + assert verify_key.verify(smsg.message, smsg.signature) + + assert not verify_key.verify(smsg.message, bad_smsg_signature) + assert not verify_key.verify(bad_smsg_message, smsg.signature) + assert not verify_key.verify(bad_smsg_message, bad_smsg_signature) + assert not verify_key.verify(bad_smsg) + + def test_signing_string_parity(self): + signing_key = Ed25519Key(SIGNING_KEY, algorithm=ALGORITHMS.EdDSA, use=USAGES.PRIVATE) + verify_key = signing_key.public_key() + + msg = 'test' + smsg = signing_key.sign(msg) # -> signature + cleartext message + bad_smsg_message = bytes([(b + 1) if b < 255 else 0 for b in bytearray(smsg.message)]) + bad_smsg_signature = bytes([(b + 1) if b < 255 else 0 for b in bytearray(smsg.signature)]) + bad_smsg = bad_smsg_signature + bad_smsg_message + + assert verify_key.verify(smsg) + assert verify_key.verify(smsg.signature + smsg.message) + assert verify_key.verify(smsg.message, smsg.signature) + + assert not verify_key.verify(smsg.message, bad_smsg_signature) + assert not verify_key.verify(bad_smsg_message, smsg.signature) + assert not verify_key.verify(bad_smsg_message, bad_smsg_signature) + assert not verify_key.verify(bad_smsg) diff --git a/tests/test_jwk.py b/tests/test_jwk.py index 6ea5a1b8..d062d4f5 100644 --- a/tests/test_jwk.py +++ b/tests/test_jwk.py @@ -30,6 +30,13 @@ "y": "AdymlHvOiLxXkEhayXQnNCvDX4h9htZaCJN34kfmC6pV5OhQHiraVySsUdaQkAgDPrwQrJmbnX9cwlGfP-HqHZR1" } +ed25519_key = { + "kty": "OKP", + "crv": "Ed25519", + "d": "npAVhmIfq2byvIzcmgS5cguKCv2Nw8Seqa1Fku00LoE", + "x": "th-Fe1Whyvy0vdexhMwSybtIyMh-WiYgUTogOKXfVnI", +} + class TestJWK: @@ -53,6 +60,9 @@ def test_invalid_hash_alg(self): with pytest.raises(JWKError): key = ECKey(ec_key, 'RS512') # noqa: F841 + with pytest.raises(JWKError): + key = Ed25519Key(ed25519_key, 'RS512') + def test_invalid_jwk(self): with pytest.raises(JWKError): @@ -64,6 +74,9 @@ def test_invalid_jwk(self): with pytest.raises(JWKError): key = ECKey(rsa_key, 'ES256') # noqa: F841 + with pytest.raises(JWKError): + key = Ed25519Key(rsa_key, 'EdDSA') + def test_RSAKey_errors(self): rsa_key = { @@ -101,6 +114,9 @@ def test_construct_from_jwk(self): key = jwk.construct(hmac_key) assert isinstance(key, jwk.Key) + key = jwk.construct(ed25519_key, algorithm='EdDSA', use='private') + assert isinstance(key, jwk.Key) + def test_construct_EC_from_jwk(self): key = ECKey(ec_key, algorithm='ES512') assert isinstance(key, jwk.Key) @@ -126,6 +142,7 @@ def test_get_key(self): assert issubclass(hs_key, Key) assert issubclass(jwk.get_key("RS256"), Key) assert issubclass(jwk.get_key("ES256"), Key) + assert issubclass(jwk.get_key("EdDSA"), Key) assert jwk.get_key("NONEXISTENT") is None