diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 913d1619..250958ff 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,6 +3,18 @@ Changelog 2.0.0 (main) ------------------- +* Breaking Change: PyOpenSSL has been fully removed. + - Dropped objects: + `josepy.util.ComparableX509` + - Functions now expect `cryptography.x509` objects: + `josepy.json_util.encode_cert` + `josepy.json_util.encode_csr` + `josepy.jws.Header.x5c.encoder` + - Functions now return `cryptography.x509` objects: + `josepy.json_util.decode_cert` + `josepy.json_util.decode_csr` + `josepy.jws.Header.x5c.decoder` + 1.15.0 (2025-01-22) ------------------- diff --git a/docs/conf.py b/docs/conf.py index e9aeabf3..ec8f6f29 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -63,9 +63,9 @@ # built documents. # # The short X.Y version. -version = "1.16" +version = "2.0" # The full version, including alpha/beta/rc tags. -release = "1.16.0.dev0" +release = "2.0.0.dev0" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/poetry.lock b/poetry.lock index d0780f32..01586e98 100644 --- a/poetry.lock +++ b/poetry.lock @@ -632,7 +632,7 @@ trio = ["async_generator", "trio"] name = "jinja2" version = "3.1.5" description = "A very fast and expressive template engine." -optional = false +optional = true python-versions = ">=3.7" files = [ {file = "jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"}, @@ -703,7 +703,7 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] name = "markupsafe" version = "2.1.5" description = "Safely add untrusted strings to HTML/XML markup." -optional = false +optional = true python-versions = ">=3.7" files = [ {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, @@ -995,25 +995,6 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] -[[package]] -name = "pyopenssl" -version = "25.0.0" -description = "Python wrapper module around the OpenSSL library" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pyOpenSSL-25.0.0-py3-none-any.whl", hash = "sha256:424c247065e46e76a37411b9ab1782541c23bb658bf003772c3405fbaa128e90"}, - {file = "pyopenssl-25.0.0.tar.gz", hash = "sha256:cd2cef799efa3936bb08e8ccb9433a575722b9dd986023f1cabc4ae64e9dac16"}, -] - -[package.dependencies] -cryptography = ">=41.0.5,<45" -typing-extensions = {version = ">=4.9", markers = "python_version < \"3.13\" and python_version >= \"3.8\""} - -[package.extras] -docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"] -test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"] - [[package]] name = "pyproject-api" version = "1.8.0" @@ -1491,35 +1472,6 @@ urllib3 = ">=1.26.0" [package.extras] keyring = ["keyring (>=15.1)"] -[[package]] -name = "types-cffi" -version = "1.16.0.20240331" -description = "Typing stubs for cffi" -optional = false -python-versions = ">=3.8" -files = [ - {file = "types-cffi-1.16.0.20240331.tar.gz", hash = "sha256:b8b20d23a2b89cfed5f8c5bc53b0cb8677c3aac6d970dbc771e28b9c698f5dee"}, - {file = "types_cffi-1.16.0.20240331-py3-none-any.whl", hash = "sha256:a363e5ea54a4eb6a4a105d800685fde596bc318089b025b27dee09849fe41ff0"}, -] - -[package.dependencies] -types-setuptools = "*" - -[[package]] -name = "types-pyopenssl" -version = "24.1.0.20240722" -description = "Typing stubs for pyOpenSSL" -optional = false -python-versions = ">=3.8" -files = [ - {file = "types-pyOpenSSL-24.1.0.20240722.tar.gz", hash = "sha256:47913b4678a01d879f503a12044468221ed8576263c1540dcb0484ca21b08c39"}, - {file = "types_pyOpenSSL-24.1.0.20240722-py3-none-any.whl", hash = "sha256:6a7a5d2ec042537934cfb4c9d4deb0e16c4c6250b09358df1f083682fe6fda54"}, -] - -[package.dependencies] -cryptography = ">=35.0.0" -types-cffi = "*" - [[package]] name = "types-pyrfc3339" version = "2.0.1.20241107" @@ -1629,4 +1581,4 @@ docs = ["sphinx", "sphinx-rtd-theme"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "7fa22898e575497d4261ebca5256b36bd6576e1cca2d01bd7e5d050a0d43fd3e" +content-hash = "21b0c02b4dbd10829153f0ec665e974fdc3811ece6f13a922c52acc730d35339" diff --git a/pyproject.toml b/pyproject.toml index 3e1fbd95..a30e76f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "josepy" -version = "1.16.0.dev0" +version = "2.0.0.dev0" description = "JOSE protocol implementation in Python" license = "Apache License 2.0" classifiers = [ @@ -43,8 +43,6 @@ python = "^3.8" # rsa_recover_prime_factors (>=0.8) # add sign() and verify() to asymetric keys (RSA >=1.4, ECDSA >=1.5) cryptography = ">=1.5" -# Connection.set_tlsext_host_name (>=0.13) -pyopenssl = ">=0.13" # >=4.3.0 is needed for Python 3.10 support sphinx = {version = ">=4.3.0", optional = true} sphinx-rtd-theme = {version = ">=1.0", optional = true} @@ -57,7 +55,6 @@ coverage = {version = ">=4.0", extras = ["toml"]} # https://github.com/python/importlib_resources/tree/7f4fbb5ee026d7610636d5ece18b09c64aa0c893#compatibility. importlib_resources = {version = ">=1.3", python = "<3.9"} mypy = "*" -types-pyOpenSSL = "*" types-pyRFC3339 = "*" types-requests = "*" types-setuptools = "*" @@ -97,11 +94,8 @@ disallow_untyped_defs = true [tool.pytest.ini_options] filterwarnings = [ "error", - "ignore:CSR support in pyOpenSSL is deprecated:DeprecationWarning", # We ignore our own warning about dropping Python 3.8 support. "ignore:Python 3.8 support will be dropped:DeprecationWarning", - # We ignore our own warning about ComparableX509 - "ignore:.*josepy will remove josepy.util.ComparableX509", ] norecursedirs = "*.egg .eggs dist build docs .tox" diff --git a/src/josepy/__init__.py b/src/josepy/__init__.py index dde2fb6e..1e74445c 100644 --- a/src/josepy/__init__.py +++ b/src/josepy/__init__.py @@ -73,7 +73,6 @@ ComparableECKey, ComparableKey, ComparableRSAKey, - ComparableX509, ImmutableMap, ) diff --git a/src/josepy/json_util.py b/src/josepy/json_util.py index ed4e4811..e692a9d2 100644 --- a/src/josepy/json_util.py +++ b/src/josepy/json_util.py @@ -22,7 +22,8 @@ TypeVar, ) -from OpenSSL import crypto +from cryptography import x509 +from cryptography.hazmat.primitives.serialization import Encoding from josepy import b64, errors, interfaces, util @@ -426,59 +427,65 @@ def decode_hex16(value: str, size: Optional[int] = None, minimum: bool = False) raise errors.DeserializationError(error) -def encode_cert(cert: util.ComparableX509) -> str: +def encode_cert(cert: x509.Certificate) -> str: """Encode certificate as JOSE Base-64 DER. - :type cert: `OpenSSL.crypto.X509` wrapped in `.ComparableX509` + :type cert: `cryptography.x509.Certificate` :rtype: unicode + .. versionchanged:: 2.0.0 + The `cert` parameter is now `cryptography.x509.Certificate`. + Previously this was an `josepy.util.ComparableX509` object, which wrapped + an `OpenSSL.crypto.X509` object. """ - if isinstance(cert.wrapped, crypto.X509Req): - raise ValueError("Error input is actually a certificate request.") + return encode_b64jose(cert.public_bytes(Encoding.DER)) - return encode_b64jose(crypto.dump_certificate(crypto.FILETYPE_ASN1, cert.wrapped)) - -def decode_cert(b64der: str) -> util.ComparableX509: +def decode_cert(b64der: str) -> x509.Certificate: """Decode JOSE Base-64 DER-encoded certificate. :param unicode b64der: - :rtype: `OpenSSL.crypto.X509` wrapped in `.ComparableX509` + :rtype: `cryptography.x509.Certificate` + .. versionchanged:: 2.0.0 + The returned object is now a `cryptography.x509.Certificate`. + Previously this was an `josepy.util.ComparableX509` object, which wrapped + an `OpenSSL.crypto.X509` object. """ try: - return util.ComparableX509( - crypto.load_certificate(crypto.FILETYPE_ASN1, decode_b64jose(b64der)) - ) - except crypto.Error as error: + return x509.load_der_x509_certificate(decode_b64jose(b64der)) + except ValueError as error: raise errors.DeserializationError(error) -def encode_csr(csr: util.ComparableX509) -> str: +def encode_csr(csr: x509.CertificateSigningRequest) -> str: """Encode CSR as JOSE Base-64 DER. - :type csr: `OpenSSL.crypto.X509Req` wrapped in `.ComparableX509` + :type csr: `cryptography.x509.CertificateSigningRequest` :rtype: unicode + .. versionchanged:: 2.0.0 + The `cert` parameter is now `cryptography.x509.CertificateSigningRequest`. + Previously this was an `josepy.util.ComparableX509` object, which wrapped + an `OpenSSL.crypto.X509Req` object. """ - if isinstance(csr.wrapped, crypto.X509): - raise ValueError("Error input is actually a certificate.") - - return encode_b64jose(crypto.dump_certificate_request(crypto.FILETYPE_ASN1, csr.wrapped)) + return encode_b64jose(csr.public_bytes(Encoding.DER)) -def decode_csr(b64der: str) -> util.ComparableX509: +def decode_csr(b64der: str) -> x509.CertificateSigningRequest: """Decode JOSE Base-64 DER-encoded CSR. :param unicode b64der: - :rtype: `OpenSSL.crypto.X509Req` wrapped in `.ComparableX509` + :rtype: `cryptography.x509.CertificateSigningRequest` + .. versionchanged:: 2.0.0 + The returned object is now a `cryptography.x509.CertificateSigningRequest`. + Previously this was an `josepy.util.ComparableX509` object, which wrapped + an `OpenSSL.crypto.X509Req` object. """ try: - return util.ComparableX509( - crypto.load_certificate_request(crypto.FILETYPE_ASN1, decode_b64jose(b64der)) - ) - except crypto.Error as error: + return x509.load_der_x509_csr(decode_b64jose(b64der)) + except ValueError as error: raise errors.DeserializationError(error) diff --git a/src/josepy/jws.py b/src/josepy/jws.py index 6bfe9ce9..a282e380 100644 --- a/src/josepy/jws.py +++ b/src/josepy/jws.py @@ -15,12 +15,12 @@ cast, ) -from OpenSSL import crypto +from cryptography import x509 +from cryptography.hazmat.primitives.serialization import Encoding import josepy from josepy import b64, errors, json_util, jwa from josepy import jwk as jwk_mod -from josepy import util class MediaType: @@ -80,7 +80,7 @@ class Header(json_util.JSONObjectWithFields): ) kid: Optional[str] = json_util.field("kid", omitempty=True) x5u: Optional[bytes] = json_util.field("x5u", omitempty=True) - x5c: Tuple[util.ComparableX509, ...] = json_util.field("x5c", omitempty=True, default=()) + x5c: Tuple[x509.Certificate, ...] = json_util.field("x5c", omitempty=True, default=()) x5t: Optional[bytes] = json_util.field("x5t", decoder=json_util.decode_b64jose, omitempty=True) x5tS256: Optional[bytes] = json_util.field( "x5t#S256", decoder=json_util.decode_b64jose, omitempty=True @@ -138,21 +138,25 @@ def crit(unused_value: Any) -> Any: @x5c.encoder # type: ignore def x5c(value): - return [ - base64.b64encode(crypto.dump_certificate(crypto.FILETYPE_ASN1, cert.wrapped)) - for cert in value - ] + """ + .. versionchanged:: 2.0.0 + The values are now `cryptography.x509.Certificate` objects. + Previously these were `josepy.util.ComparableX509` objects, which wrapped + `OpenSSL.crypto.X509` objects. + """ + return [base64.b64encode(cert.public_bytes(Encoding.DER)) for cert in value] @x5c.decoder # type: ignore def x5c(value): + """ + .. versionchanged:: 2.0.0 + The values are now `cryptography.x509.Certificate` objects. + Previously these were `josepy.util.ComparableX509` objects, which wrapped + `OpenSSL.crypto.X509` objects. + """ try: - return tuple( - util.ComparableX509( - crypto.load_certificate(crypto.FILETYPE_ASN1, base64.b64decode(cert)) - ) - for cert in value - ) - except crypto.Error as error: + return tuple(x509.load_der_x509_certificate(base64.b64decode(cert)) for cert in value) + except ValueError as error: raise errors.DeserializationError(error) diff --git a/src/josepy/util.py b/src/josepy/util.py index 50a35f97..59c869ee 100644 --- a/src/josepy/util.py +++ b/src/josepy/util.py @@ -8,7 +8,6 @@ from typing import Any, Callable, Iterator, List, Tuple, TypeVar, Union, cast from cryptography.hazmat.primitives.asymmetric import ec, rsa -from OpenSSL import crypto # Deprecated. Please use built-in decorators @classmethod and abc.abstractmethod together instead. @@ -16,61 +15,6 @@ def abstractclassmethod(func: Callable) -> classmethod: return classmethod(abc.abstractmethod(func)) -class ComparableX509: - """Wrapper for OpenSSL.crypto.X509** objects that supports __eq__. - - :ivar wrapped: Wrapped certificate or certificate request. - :type wrapped: `OpenSSL.crypto.X509` or `OpenSSL.crypto.X509Req`. - - .. deprecated:: 1.15.0 - """ - - def __init__(self, wrapped: Union[crypto.X509, crypto.X509Req]) -> None: - warnings.warn( - "The next major version of josepy will remove josepy.util.ComparableX509 and all " - "uses of it as part of removing our dependency on PyOpenSSL. This includes " - "modifying any functions with ComparableX509 parameters or return values. This " - "will be a breaking change. To avoid breakage, we recommend pinning josepy < 2.0.0 " - "until josepy 2.0.0 is out and you've had time to update your code.", - DeprecationWarning, - stacklevel=2, - ) - assert isinstance(wrapped, crypto.X509) or isinstance(wrapped, crypto.X509Req) - self.wrapped = wrapped - - def __getattr__(self, name: str) -> Any: - return getattr(self.wrapped, name) - - def _dump(self, filetype: int = crypto.FILETYPE_ASN1) -> bytes: - """Dumps the object into a buffer with the specified encoding. - - :param int filetype: The desired encoding. Should be one of - `OpenSSL.crypto.FILETYPE_ASN1`, - `OpenSSL.crypto.FILETYPE_PEM`, or - `OpenSSL.crypto.FILETYPE_TEXT`. - - :returns: Encoded X509 object. - :rtype: bytes - - """ - if isinstance(self.wrapped, crypto.X509): - return crypto.dump_certificate(filetype, self.wrapped) - - # assert in __init__ makes sure this is X509Req - return crypto.dump_certificate_request(filetype, self.wrapped) - - def __eq__(self, other: Any) -> bool: - if not isinstance(other, self.__class__): - return NotImplemented - return self._dump() == other._dump() - - def __hash__(self) -> int: - return hash((self.__class__, self._dump())) - - def __repr__(self) -> str: - return "<{0}({1!r})>".format(self.__class__.__name__, self.wrapped) - - class ComparableKey: """Comparable wrapper for ``cryptography`` keys. diff --git a/tests/json_util_test.py b/tests/json_util_test.py index 9e53876c..24fe43fa 100644 --- a/tests/json_util_test.py +++ b/tests/json_util_test.py @@ -8,11 +8,12 @@ import pytest import test_util +from cryptography import x509 from josepy import errors, interfaces, util -CERT = test_util.load_comparable_cert("cert.pem") -CSR = test_util.load_comparable_csr("csr.pem") +CERT = test_util.load_cert("cert.pem") +CSR = test_util.load_csr("csr.pem") class FieldTest(unittest.TestCase): @@ -327,7 +328,7 @@ def test_decode_cert(self) -> None: from josepy.json_util import decode_cert cert = decode_cert(self.b64_cert) - assert isinstance(cert, util.ComparableX509) + assert isinstance(cert, x509.Certificate) assert cert == CERT with pytest.raises(errors.DeserializationError): decode_cert("") @@ -341,7 +342,7 @@ def test_decode_csr(self) -> None: from josepy.json_util import decode_csr csr = decode_csr(self.b64_csr) - assert isinstance(csr, util.ComparableX509) + assert isinstance(csr, x509.CertificateSigningRequest) assert csr == CSR with pytest.raises(errors.DeserializationError): decode_csr("") diff --git a/tests/jws_test.py b/tests/jws_test.py index c6608cd2..b98f882f 100644 --- a/tests/jws_test.py +++ b/tests/jws_test.py @@ -5,13 +5,14 @@ import unittest from unittest import mock -import OpenSSL import pytest import test_util +from cryptography import x509 +from cryptography.hazmat.primitives.serialization import Encoding from josepy import errors, json_util, jwa, jwk -CERT = test_util.load_comparable_cert("cert.pem") +CERT = test_util.load_cert("cert.pem") KEY = jwk.JWKRSA.load(test_util.load_vector("rsa512_key.pem")) @@ -72,8 +73,8 @@ def test_x5c_decoding(self) -> None: header = Header(x5c=(CERT, CERT)) jobj = header.to_partial_json() - assert isinstance(CERT.wrapped, OpenSSL.crypto.X509) - cert_asn1 = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_ASN1, CERT.wrapped) + assert isinstance(CERT, x509.Certificate) + cert_asn1 = CERT.public_bytes(Encoding.DER) cert_b64 = base64.b64encode(cert_asn1) assert jobj == {"x5c": [cert_b64, cert_b64]} assert header == Header.from_json(jobj) diff --git a/tests/test_util.py b/tests/test_util.py index 1bd60974..d639105e 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -6,12 +6,12 @@ import sys from typing import Any +from cryptography import x509 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization -from OpenSSL import crypto import josepy.util -from josepy import ComparableRSAKey, ComparableX509 +from josepy import ComparableRSAKey from josepy.util import ComparableECKey # This approach is based on the recommendation at @@ -51,26 +51,18 @@ def _guess_loader(filename: str, loader_pem: Any, loader_der: Any) -> Any: raise ValueError("Loader could not be recognized based on extension") -def load_cert(*names: str) -> crypto.X509: +def load_cert(*names: str) -> x509.Certificate: """Load certificate.""" - loader = _guess_loader(names[-1], crypto.FILETYPE_PEM, crypto.FILETYPE_ASN1) - return crypto.load_certificate(loader, load_vector(*names)) - - -def load_comparable_cert(*names: str) -> josepy.util.ComparableX509: - """Load ComparableX509 cert.""" - return ComparableX509(load_cert(*names)) + loader = _guess_loader( + names[-1], x509.load_pem_x509_certificate, x509.load_der_x509_certificate + ) + return loader(load_vector(*names)) -def load_csr(*names: str) -> crypto.X509Req: +def load_csr(*names: str) -> x509.CertificateSigningRequest: """Load certificate request.""" - loader = _guess_loader(names[-1], crypto.FILETYPE_PEM, crypto.FILETYPE_ASN1) - return crypto.load_certificate_request(loader, load_vector(*names)) - - -def load_comparable_csr(*names: str) -> josepy.util.ComparableX509: - """Load ComparableX509 certificate request.""" - return ComparableX509(load_csr(*names)) + loader = _guess_loader(names[-1], x509.load_pem_x509_csr, x509.load_der_x509_csr) + return loader(load_vector(*names)) def load_rsa_private_key(*names: str) -> josepy.util.ComparableRSAKey: @@ -87,9 +79,3 @@ def load_ec_private_key(*names: str) -> josepy.util.ComparableECKey: names[-1], serialization.load_pem_private_key, serialization.load_der_private_key ) return ComparableECKey(loader(load_vector(*names), password=None, backend=default_backend())) - - -def load_pyopenssl_private_key(*names: str) -> crypto.PKey: - """Load pyOpenSSL private key.""" - loader = _guess_loader(names[-1], crypto.FILETYPE_PEM, crypto.FILETYPE_ASN1) - return crypto.load_privatekey(loader, load_vector(*names)) diff --git a/tests/util_test.py b/tests/util_test.py index 09edab03..2cf20f83 100644 --- a/tests/util_test.py +++ b/tests/util_test.py @@ -3,58 +3,11 @@ import functools import sys import unittest -import warnings import pytest import test_util -class ComparableX509Test(unittest.TestCase): - """Tests for josepy.util.ComparableX509.""" - - def setUp(self) -> None: - # test_util.load_comparable_{csr,cert} return ComparableX509 - self.req1 = test_util.load_comparable_csr("csr.pem") - self.req2 = test_util.load_comparable_csr("csr.pem") - self.req_other = test_util.load_comparable_csr("csr-san.pem") - - self.cert1 = test_util.load_comparable_cert("cert.pem") - self.cert2 = test_util.load_comparable_cert("cert.pem") - self.cert_other = test_util.load_comparable_cert("cert-san.pem") - - def test_getattr_proxy(self) -> None: - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", - category=DeprecationWarning, - message=".*Use timezone-aware objects to represent datetimes", - ) - assert self.cert1.has_expired() is True - - def test_eq(self) -> None: - assert self.req1 == self.req2 - assert self.cert1 == self.cert2 - - def test_ne(self) -> None: - assert self.req1 != self.req_other - assert self.cert1 != self.cert_other - - def test_ne_wrong_types(self) -> None: - assert self.req1 != 5 - assert self.cert1 != 5 - - def test_hash(self) -> None: - assert hash(self.req1) == hash(self.req2) - assert hash(self.req1) != hash(self.req_other) - - assert hash(self.cert1) == hash(self.cert2) - assert hash(self.cert1) != hash(self.cert_other) - - def test_repr(self) -> None: - for x509 in self.req1, self.cert1: - assert repr(x509) == "".format(x509.wrapped) - - class ComparableRSAKeyTest(unittest.TestCase): """Tests for josepy.util.ComparableRSAKey."""