Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Ed25519 / EdDSA, with unit tests #455

Merged
merged 1 commit into from
May 24, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions jwt/algorithms.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
has_crypto = True
except ImportError:
has_crypto = False
has_ed25519 = False

requires_cryptography = set(
[
Expand All @@ -56,6 +57,7 @@
"PS256",
"PS384",
"PS512",
"EdDSA",
]
)

Expand Down Expand Up @@ -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

Expand Down
71 changes: 71 additions & 0 deletions jwt/contrib/algorithms/py_ed25519.py
Original file line number Diff line number Diff line change
@@ -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
86 changes: 86 additions & 0 deletions tests/contrib/test_algorithms.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import base64
import warnings

import pytest

Expand All @@ -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"
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions tests/keys/testkey_ed25519
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIBy9N4xfv/9qOiKrxwRKeGfO5ab6lSukKHbuC5vaJ1Mg
-----END PRIVATE KEY-----
1 change: 1 addition & 0 deletions tests/keys/testkey_ed25519.pub
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC4pK2dePGgctIAsh0H/tmUrLzx2Vc4Ltc8TN9nfuChG