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

testing idea to migrate from pyopenssl onto cryptography (#1) #196

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
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
5 changes: 4 additions & 1 deletion .github/workflows/check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ on:

jobs:
build:
name: test-${{ matrix.python }}-${{ matrix.build-type }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
build-type: ["noopenssl", "legacy"]
steps:
- uses: actions/checkout@v3
- name: Setup Python
Expand All @@ -34,7 +36,8 @@ jobs:
- name: Install Poetry & Tox
run: pip install poetry>1.0.0 tox>3.3.0
- name: Run tox
run: tox
run: tox -e github-${{ matrix.build-type }}

# This job runs our tests like external parties such as packagers.
external:
runs-on: ubuntu-latest
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ Changelog
---------------
* Added support for Python 3.13.
* Dropped support for Python 3.7.
* Deprecated pyOpenSSL in favor of Cryptography and removed the required
dependency. The underlying storage format of the `josepy.util.ComparableX509`
has been switched to `cryptography.x509` objects, and the
`josepy.util.ComparableX509.wrapped` attribute will now be either a
`cryptography.x509.Certificate` or `cryptography.x509.CertificateSigningRequest`
- objects from the `opensssl.crypto` package will be automatically transcoded
to their Cryptography counterparts on initialization. A new convenience
attribute, `josepy.util.ComparableX509.wrapped_legacy` will return an
`opensssl.crypto` object for affected projects that are unable to immediately
migrate code to the Cryptography objects. This is offered as a minimally
breaking change to aid in migration to Cryptography. Affected projects should
either pin to `1.14.0` or utilize the new attribute in a "hotfix" release.
Please note, due to the removal of `X509_V_FLAG_NOTIFY_POLICY` in pyOpenSSL
`23.2.0`, projects migrating to the new backend may experience a version
conflict during the code transition.


1.14.0 (2023-11-01)
-------------------
Expand Down
7 changes: 4 additions & 3 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 7 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,13 @@ python = "^3.8"
# load_pem_private/public_key (>=0.6)
# 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"
# add not_valid_after_utc()
cryptography = ">=42.0.0"
# >=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}
# poetry add "pyopenssl[legacy]>=23.2.0"
pyopenssl = {version = ">=23.2.0", optional=true}

[tool.poetry.dev-dependencies]
# coverage[toml] extra is required to read the coverage config from pyproject.toml
Expand All @@ -73,6 +74,9 @@ docs = [
"sphinx",
"sphinx-rtd-theme",
]
legacy = [
"pyopenssl",
]

[tool.poetry.scripts]
jws = "josepy.jws:CLI.run"
Expand Down
31 changes: 14 additions & 17 deletions src/josepy/json_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -429,56 +430,52 @@ def decode_hex16(value: str, size: Optional[int] = None, minimum: bool = False)
def encode_cert(cert: util.ComparableX509) -> str:
"""Encode certificate as JOSE Base-64 DER.

:type cert: `OpenSSL.crypto.X509` wrapped in `.ComparableX509`
:type cert: `x509.Certificate` wrapped in `.ComparableX509`
:rtype: unicode

"""
if isinstance(cert.wrapped, crypto.X509Req):
if isinstance(cert.wrapped, x509.CertificateSigningRequest):
raise ValueError("Error input is actually a certificate request.")

return encode_b64jose(crypto.dump_certificate(crypto.FILETYPE_ASN1, cert.wrapped))
return encode_b64jose(cert.wrapped.public_bytes(Encoding.DER))


def decode_cert(b64der: str) -> util.ComparableX509:
"""Decode JOSE Base-64 DER-encoded certificate.

:param unicode b64der:
:rtype: `OpenSSL.crypto.X509` wrapped in `.ComparableX509`
:rtype: `x509.Certificate` wrapped in `.ComparableX509`

"""
try:
return util.ComparableX509(
crypto.load_certificate(crypto.FILETYPE_ASN1, decode_b64jose(b64der))
)
except crypto.Error as error:
return util.ComparableX509(x509.load_der_x509_certificate(decode_b64jose(b64der)))
except Exception as error:
raise errors.DeserializationError(error)


def encode_csr(csr: util.ComparableX509) -> str:
"""Encode CSR as JOSE Base-64 DER.

:type csr: `OpenSSL.crypto.X509Req` wrapped in `.ComparableX509`
:type csr: `x509.CertificateSigningRequest` wrapped in `.ComparableX509`
:rtype: unicode

"""
if isinstance(csr.wrapped, crypto.X509):
if isinstance(csr.wrapped, x509.Certificate):
raise ValueError("Error input is actually a certificate.")

return encode_b64jose(crypto.dump_certificate_request(crypto.FILETYPE_ASN1, csr.wrapped))
return encode_b64jose(csr.wrapped.public_bytes(Encoding.DER))


def decode_csr(b64der: str) -> util.ComparableX509:
"""Decode JOSE Base-64 DER-encoded CSR.

:param unicode b64der:
:rtype: `OpenSSL.crypto.X509Req` wrapped in `.ComparableX509`
:rtype: `cryptography.x509.CertificateSigningRequest` wrapped in `.ComparableX509`

"""
try:
return util.ComparableX509(
crypto.load_certificate_request(crypto.FILETYPE_ASN1, decode_b64jose(b64der))
)
except crypto.Error as error:
return util.ComparableX509(x509.load_der_x509_csr(decode_b64jose(b64der)))
except Exception as error:
raise errors.DeserializationError(error)


Expand Down
14 changes: 5 additions & 9 deletions src/josepy/jws.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
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
Expand Down Expand Up @@ -138,21 +139,16 @@ 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
]
return [base64.b64encode(cert.wrapped.public_bytes(Encoding.DER)) for cert in value]

@x5c.decoder # type: ignore
def x5c(value):
try:
return tuple(
util.ComparableX509(
crypto.load_certificate(crypto.FILETYPE_ASN1, base64.b64decode(cert))
)
util.ComparableX509(x509.load_der_x509_certificate(base64.b64decode(cert)))
for cert in value
)
except crypto.Error as error:
except Exception as error:
raise errors.DeserializationError(error)


Expand Down
144 changes: 131 additions & 13 deletions src/josepy/util.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,49 @@
"""JOSE utilities."""

import abc
import datetime
import sys
import warnings
from collections.abc import Hashable, Mapping
from types import ModuleType
from typing import Any, Callable, Iterator, List, Tuple, TypeVar, Union, cast

from typing import (
TYPE_CHECKING,
Any,
Callable,
Iterator,
List,
Optional,
Tuple,
TypeVar,
Union,
cast,
)

from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from OpenSSL import crypto
from cryptography.hazmat.primitives.serialization import Encoding

# support this as an optional import
# use an alternate name, as the dev environment will always need typing
crypto: Optional[ModuleType] = None
try:
from OpenSSL import crypto
except (ImportError, ModuleNotFoundError):
pass

if TYPE_CHECKING:
# use the full path for typing
import OpenSSL.crypto


def warn_deprecated(message: str) -> None:
# used to warn for deprecation
warnings.warn(message, DeprecationWarning, stacklevel=2)


# compatability
FILETYPE_ASN1 = 2
FILETYPE_PEM = 1


# Deprecated. Please use built-in decorators @classmethod and abc.abstractmethod together instead.
Expand All @@ -17,21 +52,104 @@ def abstractclassmethod(func: Callable) -> classmethod:


class ComparableX509:
"""Wrapper for OpenSSL.crypto.X509** objects that supports __eq__.
"""Originally a wrapper for OpenSSL.crypto.X509** objects that supports __eq__.

This still accepts crypto.X509, but uses cryptography.x509 objects

:ivar wrapped: Wrapped certificate or certificate request.
:type wrapped: `OpenSSL.crypto.X509` or `OpenSSL.crypto.X509Req`.
:type wrapped: `Cryptography.x509.Certificate` or
`Cryptography.x509.CertificateSigningRequest`


:ivar wrapped_legacy: Legacy Wrapped certificate or certificate request.
This attribute will be removed when `OpenSSL.crypto` support is fully
dropped. This attribute is only meant to aid in migration to the
new Cryptography backend.
:type wrapped_legacy: `OpenSSL.crypto.X509` or `OpenSSL.crypto.X509Req`
"""

def __init__(self, wrapped: Union[crypto.X509, crypto.X509Req]) -> None:
assert isinstance(wrapped, crypto.X509) or isinstance(wrapped, crypto.X509Req)
#
wrapped: Union[x509.Certificate, x509.CertificateSigningRequest]
_wrapped_legacy: Union["OpenSSL.crypto.X509", "OpenSSL.crypto.X509Req", None] = None

def __init__(
self,
wrapped: Union[
"OpenSSL.crypto.X509",
"OpenSSL.crypto.X509Req",
x509.Certificate,
x509.CertificateSigningRequest,
],
) -> None:
# conditional runtime inputs
if crypto:
assert isinstance(
wrapped,
(x509.Certificate, x509.CertificateSigningRequest, crypto.X509, crypto.X509Req),
)
else:
assert isinstance(wrapped, (x509.Certificate, x509.CertificateSigningRequest))
# conditional compatibility layer
if crypto:
if isinstance(wrapped, (crypto.X509, crypto.X509Req)):
warn_deprecated(
"`OpenSSL.crypto` objects are deprecated and support will be "
"removed in a future verison of josepy. The `wrapped` attribute "
"now contains a `Cryptography.x509` object."
)
# stash for legacy operations
self._wrapped_legacy = wrapped
# convert to Cryptography.x509
der: bytes
if isinstance(wrapped, crypto.X509):
der = crypto.dump_certificate(crypto.FILETYPE_ASN1, wrapped)
wrapped = x509.load_der_x509_certificate(der)

elif isinstance(wrapped, crypto.X509Req):
der = crypto.dump_certificate_request(crypto.FILETYPE_ASN1, wrapped)
wrapped = x509.load_der_x509_csr(der)

self.wrapped = wrapped

@property
def wrapped_legacy(self) -> Union["OpenSSL.crypto.X509", "OpenSSL.crypto.X509Req", None]:
# migration layer to the new Cryptography backend
# this function is deprecated and will be removed asap
if crypto is None:
raise ValueError("OpenSSL.crypto must be install for compatability")
if self._wrapped_legacy is not None:
if isinstance(self.wrapped, x509.Certificate):
self._wrapped_legacy = crypto.load_certificate(
crypto.FILETYPE_ASN1, self.wrapped.public_bytes(Encoding.DER)
)
elif isinstance(self.wrapped, x509.CertificateSigningRequest):
self._wrapped_legacy = crypto.load_certificate_request(
crypto.FILETYPE_ASN1, self.wrapped.public_bytes(Encoding.DER)
)
else:
raise ValueError("no compatible legacy object")
if TYPE_CHECKING:
# mypy is detecting an `object` from the `x509.CertificateSigningRequest` block
assert (
isinstance(self._wrapped_legacy, (crypto.X509, crypto.X509Req))
or self._wrapped_legacy is None
)
return self._wrapped_legacy

def __getattr__(self, name: str) -> Any:
if name == "has_expired":
# a unittest addresses this attribute
# x509.CertificateSigningRequest does not have this attribute
# ideally this function would be deprecated and users should
# address the `wrapped` item directly.
if isinstance(self.wrapped, x509.Certificate):
return (
lambda: datetime.datetime.now(datetime.timezone.utc)
> self.wrapped.not_valid_after_utc
)
return getattr(self.wrapped, name)

def _dump(self, filetype: int = crypto.FILETYPE_ASN1) -> bytes:
def _dump(self, filetype: int = FILETYPE_ASN1) -> bytes:
"""Dumps the object into a buffer with the specified encoding.

:param int filetype: The desired encoding. Should be one of
Expand All @@ -43,11 +161,11 @@ def _dump(self, filetype: int = crypto.FILETYPE_ASN1) -> bytes:
: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)
if filetype not in (FILETYPE_ASN1, FILETYPE_PEM):
raise ValueError("filetype `%s` is deprecated")
if filetype == FILETYPE_ASN1:
return self.wrapped.public_bytes(Encoding.DER)
return self.wrapped.public_bytes(Encoding.PEM)

def __eq__(self, other: Any) -> bool:
if not isinstance(other, self.__class__):
Expand Down
Loading
Loading