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

WIP Add support for Ed25519, Ed448, X25519 and X448. RFC 8037 #98

Draft
wants to merge 37 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
5d40d44
Ed25519 keys
atombrella Apr 24, 2021
a7066e1
Preliminary work on integrating Ed448 and Ed25519 keys.
atombrella Apr 27, 2021
6b78592
more work
atombrella May 6, 2021
851bfb4
Start writing tests
atombrella May 6, 2021
2578ed9
Tests
atombrella May 7, 2021
7f061de
More tests
atombrella May 7, 2021
240be4b
more work
atombrella May 8, 2021
818a40f
Add typing to ComparableKey and JWKOKP.
atombrella May 8, 2021
78961a9
fields_to_partial_json
atombrella May 9, 2021
c87f0e1
More testing
atombrella May 9, 2021
06b6019
generate with x25519
atombrella May 11, 2021
038833b
Better way to load keys
atombrella May 12, 2021
8c24dd8
At least loading from json
atombrella May 12, 2021
31c2b9d
Tests and other stuff
atombrella May 12, 2021
e506a17
better fields_from_json
atombrella May 13, 2021
573e61e
RFC 8032 work
atombrella Jun 22, 2021
cf94319
More work
atombrella Jun 25, 2021
6c2a6d7
More work
atombrella Jul 10, 2021
590fc50
ComparableOKPKey work
atombrella Jul 26, 2021
3a73a35
Merge branch 'master' into rfc_8037
adferrand Sep 13, 2021
a5a9f93
Merge remote-tracking branch 'upstream/master' into rfc_8037
atombrella Jan 13, 2022
cae9030
Clean up
atombrella Feb 4, 2022
571e0c4
Comment out sections with X25519 and X448. Proper return types.
atombrella Feb 4, 2022
2256c10
Fix test
atombrella Feb 4, 2022
4cc26b6
Fix some mypy warnings, introduce more.
atombrella Feb 6, 2022
a9d5b51
pleasing mypy
atombrella Feb 6, 2022
c6d5079
undo stuff
atombrella Feb 6, 2022
00bd690
Tests and more
atombrella Feb 11, 2022
844fe6a
Merge branch 'master' of github.com:certbot/josepy into rfc_8037
atombrella Feb 11, 2022
d650cfa
Fix isort
atombrella Feb 11, 2022
5144134
Better tests
atombrella Feb 11, 2022
f9fc969
More testing
atombrella Feb 12, 2022
3b8ba32
Merge branch 'master' of github.com:certbot/josepy into rfc_8037
atombrella Feb 12, 2022
3ef6f85
Make tests pass
atombrella Feb 27, 2022
85bf5f7
Better tests
atombrella Feb 27, 2022
3ad6181
X448 and X25519 do not work locally for me.
atombrella May 7, 2022
eaef5f7
Merge branch 'master' of github.com:certbot/josepy into rfc_8037
atombrella May 7, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ Changelog

1.13.0 (master)
---------------
* Added support for Ed25519, Ed448, X25519 and X448 keys (see `RFC 8037 <https://tools.ietf.org/html/rfc8037>`_).
These are also known as Bernstein curves.
* Added support for signing with Ed25519, Ed448, X25519 and X448 keys
(see `RFC 8032 <https://datatracker.ietf.org/doc/html/rfc8032>`_). See JWA.
* Minimum requirement of ``cryptography`` is now 2.6+.

1.12.0 (2022-01-11)
-------------------
Expand Down
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
install_requires = [
# load_pem_private/public_key (>=0.6)
# rsa_recover_prime_factors (>=0.8)
# ed25519, ed448, x25519 and x448 support (>= 2.6)
# add sign() and verify() to asymetric keys (RSA >=1.4, ECDSA >=1.5)
'cryptography>=1.5',
'cryptography>=2.6',
# Connection.set_tlsext_host_name (>=0.13)
'PyOpenSSL>=0.13',
# For pkg_resources. >=1.0 so pip resolves it to a version cryptography
Expand Down
2 changes: 1 addition & 1 deletion src/josepy/json_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ def register(cls, type_cls: Type[GenericTypedJSONObjectWithFields],
def get_type_cls(cls, jobj: Mapping[str, Any]) -> Type["TypedJSONObjectWithFields"]:
"""Get the registered class for ``jobj``."""
if cls in cls.TYPES.values():
if cls.type_field_name not in jobj:
if cls.type_field_name not in jobj: # noqa
raise errors.DeserializationError(
"Missing type field ({0})".format(cls.type_field_name))
# cls is already registered type_cls, force to use it
Expand Down
120 changes: 119 additions & 1 deletion src/josepy/jwk.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""JSON Web Key."""
import abc
import collections
import json
import logging
import math
Expand All @@ -18,7 +19,15 @@
import cryptography.exceptions
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
# TODO import with try/except as some curves may not be available
from cryptography.hazmat.primitives.asymmetric import (
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if there are coding guidelines about grouping imports.

ec,
ed448,
ed25519,
rsa,
x448,
x25519,
)

import josepy.util
from josepy import errors, json_util, util
Expand Down Expand Up @@ -391,3 +400,112 @@ def public_key(self) -> 'JWKEC':
else:
key = self.key.public_numbers().public_key(default_backend())
return type(self)(key=key)


@JWK.register
class JWKOKP(JWK):
"""
Performs signing and verification operations using either
Ed25519, Ed448, X25519 or X448. See RFC 8037 and RFC 8032 for details about
the algorithms, and signing, respectively.

:ivar: :key :class:`~cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey`
or :class:`~cryptography.hazmat.primitives.asymmetric.ed448.Ed448PublicKey`
or :class:`~cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey`
or :class:`~cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PublicKey`
or :class:`~cryptography.hazmat.primitives.asymmetric.x448.X448PrivateKey`
or :class:`~cryptography.hazmat.primitives.asymmetric.x448.X448PublicKey`
or :class:`~cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey`
or :class:`~cryptography.hazmat.primitives.asymmetric.x25519.X25519PublicKey`
wrapped in :class:`~josepy.util.ComparableOKPKey`

This class requires ``cryptography>=2.6`` to be installed.
"""
typ = 'OKP'
__slots__ = ('key',)
cryptography_key_types = (
ed25519.Ed25519PrivateKey, ed25519.Ed25519PrivateKey,
ed448.Ed448PublicKey, ed448.Ed448PrivateKey,
x25519.X25519PrivateKey, x25519.X25519PublicKey,
x448.X448PrivateKey, x448.X448PublicKey,
)
required = ('crv', JWK.type_field_name, 'x')
okp_curve = collections.namedtuple('okp_curve', 'pubkey privkey')
crv_to_pub_priv = {
"Ed25519": okp_curve(pubkey=ed25519.Ed25519PublicKey, privkey=ed25519.Ed25519PrivateKey),
"Ed448": okp_curve(pubkey=ed448.Ed448PublicKey, privkey=ed448.Ed448PrivateKey),
"X25519": okp_curve(pubkey=x25519.X25519PublicKey, privkey=x25519.X25519PrivateKey),
"X448": okp_curve(pubkey=x448.X448PublicKey, privkey=x448.X448PrivateKey),
}

def __init__(self, *args: Any, **kwargs: Any) -> None:
if 'key' in kwargs and not isinstance(kwargs['key'], util.ComparableOKPKey):
kwargs['key'] = util.ComparableOKPKey(kwargs['key'])
super().__init__(*args, **kwargs)

def public_key(self) -> "JWKOKP":
return self.key._wrapped.__class__.public_key()

def _key_to_crv(self) -> str:
if isinstance(self.key._wrapped, (ed25519.Ed25519PublicKey, ed25519.Ed25519PrivateKey)):
return "Ed25519"
elif isinstance(self.key._wrapped, (ed448.Ed448PublicKey, ed448.Ed448PrivateKey)):
return "Ed448"
elif isinstance(self.key._wrapped, (x25519.X25519PublicKey, x25519.X25519PrivateKey)):
return "X25519"
elif isinstance(self.key._wrapped, (x448.X448PublicKey, x448.X448PrivateKey)):
return "X448"
return NotImplemented

def fields_to_partial_json(self) -> Dict[str, Any]:
params = {
"crv": self._key_to_crv(),
"kty": "OKP",
}
if hasattr(self.key._wrapped, "private_bytes"):
params['d'] = json_util.encode_b64jose(self.key._wrapped.private_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption()
))
params['x'] = self.key._wrapped.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
else:
params['x'] = json_util.encode_b64jose(self.key._wrapped.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
))
return params

@classmethod
def fields_from_json(cls, jobj: Mapping[str, Any]) -> "JWKOKP":
curve = jobj["crv"]
if curve not in cls.crv_to_pub_priv:
raise errors.DeserializationError(f"Invalid curve: {curve}")

if "x" not in jobj:
raise errors.DeserializationError('OKP should have "x" parameter')
x = json_util.decode_b64jose(jobj["x"])

try:
if "d" not in jobj: # public key
pub_class: Type[Union[
ed25519.Ed25519PublicKey,
ed448.Ed448PublicKey,
x25519.X25519PublicKey,
x448.X448PublicKey,
]] = cls.crv_to_pub_priv[curve].pubkey
return cls(key=pub_class.from_public_bytes(x))
else: # private key
d = json_util.decode_b64jose(jobj["d"])
priv_key_class: Type[Union[
ed25519.Ed25519PrivateKey,
ed448.Ed448PrivateKey,
x25519.X25519PrivateKey,
x448.X448PrivateKey,
]] = cls.crv_to_pub_priv[curve].privkey
return cls(key=priv_key_class.from_private_bytes(d))
except ValueError as err:
raise errors.DeserializationError("Invalid key parameter") from err
76 changes: 68 additions & 8 deletions src/josepy/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
from types import ModuleType
from typing import Any, Callable, Iterator, List, Tuple, TypeVar, Union, cast

from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec, ed448, ed25519, rsa
from OpenSSL import crypto


Expand Down Expand Up @@ -70,17 +71,22 @@ class ComparableKey: # pylint: disable=too-few-public-methods
"""
__hash__: Callable[[], int] = NotImplemented

def __getattr__(self, name: str) -> Any:
return getattr(self._wrapped, name)

def __init__(self,
wrapped: Union[
rsa.RSAPrivateKeyWithSerialization,
rsa.RSAPublicKeyWithSerialization,
ec.EllipticCurvePrivateKeyWithSerialization,
ec.EllipticCurvePublicKeyWithSerialization]):
ec.EllipticCurvePublicKeyWithSerialization,
ed25519.Ed25519PrivateKey,
ed25519.Ed25519PublicKey,
ed448.Ed448PrivateKey,
ed448.Ed448PublicKey,
]):
self._wrapped = wrapped

def __getattr__(self, name: str) -> Any:
return getattr(self._wrapped, name)

def __eq__(self, other: Any) -> bool:
# pylint: disable=protected-access
if (not isinstance(other, self.__class__) or
Expand All @@ -90,6 +96,26 @@ def __eq__(self, other: Any) -> bool:
return self.private_numbers() == other.private_numbers()
elif hasattr(self._wrapped, 'public_numbers'):
return self.public_numbers() == other.public_numbers()
elif (isinstance(self._wrapped, (ed25519.Ed25519PrivateKey, ed448.Ed448PrivateKey)) and
isinstance(other._wrapped, (ed25519.Ed25519PrivateKey, ed448.Ed448PrivateKey))):
return self._wrapped.private_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption(),
) == other._wrapped.private_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption(),
)
elif (isinstance(self._wrapped, (ed25519.Ed25519PublicKey, ed448.Ed448PublicKey)) and
isinstance(other._wrapped, (ed25519.Ed25519PublicKey, ed448.Ed448PublicKey))):
return self._wrapped.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
) == other._wrapped.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
else:
return NotImplemented

Expand All @@ -98,8 +124,12 @@ def __repr__(self) -> str:

def public_key(self) -> 'ComparableKey':
"""Get wrapped public key."""
if isinstance(self._wrapped, (rsa.RSAPublicKeyWithSerialization,
ec.EllipticCurvePublicKeyWithSerialization)):
if isinstance(self._wrapped, (
rsa.RSAPublicKeyWithSerialization,
ec.EllipticCurvePublicKeyWithSerialization,
ed25519.Ed25519PublicKey,
ed448.Ed448PublicKey,
)):
return self

return self.__class__(self._wrapped.public_key())
Expand Down Expand Up @@ -131,7 +161,7 @@ def __hash__(self) -> int:


class ComparableECKey(ComparableKey): # pylint: disable=too-few-public-methods
"""Wrapper for ``cryptography`` RSA keys.
"""Wrapper for ``cryptography`` EC keys.
atombrella marked this conversation as resolved.
Show resolved Hide resolved
Wraps around:
- :class:`~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey`
- :class:`~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey`
Expand All @@ -154,6 +184,36 @@ def __hash__(self) -> int:
GenericImmutableMap = TypeVar('GenericImmutableMap', bound='ImmutableMap')


class ComparableOKPKey(ComparableKey):
"""Wrapper for ``cryptography`` OKP keys.

Wraps around any of these available with the compilation
- :class:`~cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PublicKey`
- :class:`~cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey`
- :class:`~cryptography.hazmat.primitives.asymmetric.ed448.Ed448PublicKey`
- :class:`~cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey`

These are not yet supported
- :class:`~cryptography.hazmat.primitives.asymmetric.x25519.X25519PublicKey`
- :class:`~cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey`
- :class:`~cryptography.hazmat.primitives.asymmetric.x448.X448PublicKey`
- :class:`~cryptography.hazmat.primitives.asymmetric.x448.X448PrivateKey`
"""

def __hash__(self) -> int:
if isinstance(self._wrapped, (ed25519.Ed25519PublicKey, ed448.Ed448PublicKey)):
return hash(self._wrapped.public_bytes(
format=serialization.PublicFormat.Raw,
encoding=serialization.Encoding.Raw,
)[:32])
elif isinstance(self._wrapped, (ed25519.Ed25519PrivateKey, ed448.Ed448PrivateKey)):
return hash(self._wrapped.public_key().public_bytes(
format=serialization.PublicFormat.Raw,
encoding=serialization.Encoding.Raw,
)[:32])
return 0


class ImmutableMap(Mapping, Hashable):
# pylint: disable=too-few-public-methods
"""Immutable key to value mapping with attribute access."""
Expand Down