Skip to content

Commit

Permalink
Replace PyOpenSSL with Cryptography (#260)
Browse files Browse the repository at this point in the history
Fixes #141
  • Loading branch information
kislyuk authored Aug 18, 2024
1 parent 9f06f43 commit c12e70c
Show file tree
Hide file tree
Showing 13 changed files with 277 additions and 246 deletions.
9 changes: 5 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,21 @@ jobs:
strategy:
max-parallel: 8
matrix:
os: [ubuntu-20.04, ubuntu-22.04, macos-12]
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
os: [ubuntu-20.04, ubuntu-24.04, macos-12]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
- uses: actions/setup-python@v5
with:
python-version: ${{matrix.python-version}}
- run: |
if [[ $(uname) == Linux ]]; then sudo apt-get install --no-install-recommends python3-openssl python3-lxml python3-certifi; fi
if [[ $(uname) == Linux ]]; then sudo apt-get install --no-install-recommends python3-lxml python3-certifi; fi
- run: make install
- if: ${{matrix.python-version == '3.12'}}
run: make lint
- run: make test
- uses: codecov/codecov-action@v3
if: ${{matrix.python-version == '3.12' && matrix.os == 'ubuntu-24.04'}}
black:
runs-on: ubuntu-22.04
steps:
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
SHELL=/bin/bash

lint:
ruff $$(dirname */__init__.py)
ruff check $$(dirname */__init__.py)
mypy --install-types --non-interactive --check-untyped-defs $$(dirname */__init__.py)

test:
Expand Down
26 changes: 6 additions & 20 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ standard, and most recommended ones. Its features are:
<http://www.w3.org/TR/xml-exc-c14n/#def-InclusiveNamespaces-PrefixList>`_, required to verify signatures generated by
some SAML implementations)
* Modern Python compatibility (3.7-3.11+ and PyPy)
* Well-supported, portable, reliable dependencies: `lxml <https://github.com/lxml/lxml>`_,
`cryptography <https://github.com/pyca/cryptography>`_, `pyOpenSSL <https://github.com/pyca/pyopenssl>`_
* Well-supported, portable, reliable dependencies: `lxml <https://github.com/lxml/lxml>`_ and
`cryptography <https://github.com/pyca/cryptography>`_
* Comprehensive testing (including the XMLDSig interoperability suite) and `continuous integration
<https://github.com/XML-Security/signxml/actions>`_
* Simple interface with useful, ergonomic, and secure defaults (no network calls, XSLT or XPath transforms)
Expand All @@ -30,22 +30,6 @@ Installation

pip install signxml

Note: SignXML depends on `lxml <https://github.com/lxml/lxml>`_ and `cryptography
<https://github.com/pyca/cryptography>`_, which in turn depend on `OpenSSL <https://www.openssl.org/>`_, `LibXML
<http://xmlsoft.org/>`_, and Python tools to interface with them. You can install those as follows:

+--------------+----------------------------------------------------------------------------------------------------------------------+
| OS | Command |
+==============+======================================================================================================================+
| Ubuntu | ``apt-get install --no-install-recommends python3-pip python3-wheel python3-setuptools python3-openssl python3-lxml``|
+--------------+----------------------------------------------------------------------------------------------------------------------+
| Red Hat, | ``yum install python3-pip python3-pyOpenSSL python3-lxml`` |
| Amazon Linux,| |
| CentOS | |
+--------------+----------------------------------------------------------------------------------------------------------------------+
| Mac OS | Install `Homebrew <https://brew.sh>`_, then run ``brew install python``. |
+--------------+----------------------------------------------------------------------------------------------------------------------+

Synopsis
--------
SignXML uses the `lxml ElementTree API <https://lxml.de/tutorial.html>`_ to work with XML data.
Expand All @@ -66,9 +50,11 @@ To make this example self-sufficient for test purposes:

- Generate a test certificate and key using
``openssl req -x509 -nodes -subj "/CN=test" -days 1 -newkey rsa -keyout privkey.pem -out cert.pem``
(run ``yum install openssl`` on Red Hat).
(run ``apt-get install openssl``, ``yum install openssl``, or ``brew install openssl`` if the ``openssl`` executable
is not found).
- Pass the ``x509_cert=cert`` keyword argument to ``XMLVerifier.verify()``. (In production, ensure this is replaced with
the correct configuration for the trusted CA or certificate - this determines which signatures your application trusts.)
the correct configuration for the trusted CA or certificate - this determines which signatures your application
trusts.)

.. _verifying-saml-assertions:

Expand Down
1 change: 0 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
"python": ("https://docs.python.org/3", None),
"lxml": ("https://lxml.de/apidoc", "https://lxml.de/apidoc/objects.inv"),
"Cryptography": ("https://cryptography.io/en/latest", "https://cryptography.io/en/latest/objects.inv"),
"pyOpenSSL": ("https://www.pyopenssl.org/en/stable", "https://www.pyopenssl.org/en/stable/objects.inv"),
}
templates_path = [""]
ogp_site_url = "https://xml-security.github.io/" + project
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ skip_gitignore = true

[tool.ruff]
line-length = 120

[tool.ruff.lint]
per-file-ignores = {"signxml/__init__.py" = ["F401"], "signxml/xades/__init__.py" = ["F401"], "signxml/verifier.py" = ["E721"]}
9 changes: 3 additions & 6 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,9 @@
long_description=open("README.rst").read(),
python_requires=">=3.7",
install_requires=[
# Dependencies are restricted by major version range according to semver.
# By default, version minimums are set to be compatible with the oldest supported Ubuntu LTS (currently 20.04).
"lxml >= 4.5.0, < 6",
"cryptography >= 3.4.8", # Set to the version in Ubuntu 22.04 due to features we need from cryptography 3.1
"pyOpenSSL >= 19.0.0",
"certifi >= 2019.11.28",
"lxml >= 5.2.1, < 6", # Ubuntu 24.04 LTS
"cryptography >= 43", # Required to support client certificate validation
"certifi >= 2023.11.17", # Ubuntu 24.04 LTS
# "tsp-client >= 0.1.3",
],
extras_require={
Expand Down
4 changes: 2 additions & 2 deletions signxml/algorithms.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class SignatureConstructionMethod(Enum):
class FragmentLookupMixin:
@classmethod
def from_fragment(cls, fragment):
for i in cls: # type: ignore
for i in cls: # type: ignore[attr-defined]
if i.value.endswith("#" + fragment):
return i
else:
Expand All @@ -50,7 +50,7 @@ def _missing_(cls, value):
raise InvalidInput(f"Unrecognized {cls.__name__}: {value}")

def __repr__(self):
return f"{self.__class__.__name__}.{self.name}" # type: ignore
return f"{self.__class__.__name__}.{self.name}" # type: ignore[attr-defined]


class DigestAlgorithm(FragmentLookupMixin, InvalidInputErrorMixin, Enum):
Expand Down
2 changes: 1 addition & 1 deletion signxml/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class XMLSignatureProcessor(XMLProcessor):
"urn:oid:1.3.132.0.37": ec.SECT409R1,
"urn:oid:1.3.132.0.38": ec.SECT571K1,
}
known_ecdsa_curve_oids = {ec().name: oid for oid, ec in known_ecdsa_curves.items()} # type: ignore
known_ecdsa_curve_oids = {ec().name: oid for oid, ec in known_ecdsa_curves.items()} # type: ignore[abstract]

excise_empty_xmlns_declarations = False

Expand Down
23 changes: 13 additions & 10 deletions signxml/signer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
from dataclasses import dataclass, replace
from typing import List, Optional, Union

from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import dsa, ec, rsa, utils
from cryptography.hazmat.primitives.asymmetric.padding import MGF1, PSS, PKCS1v15
from cryptography.hazmat.primitives.hmac import HMAC
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.primitives.serialization import Encoding, load_pem_private_key
from lxml.etree import Element, SubElement, _Element
from OpenSSL.crypto import FILETYPE_PEM, X509, dump_certificate

from .algorithms import (
CanonicalizationMethod,
Expand Down Expand Up @@ -128,7 +128,7 @@ def sign(
*,
key: Optional[Union[str, bytes, rsa.RSAPrivateKey, dsa.DSAPrivateKey, ec.EllipticCurvePrivateKey]] = None,
passphrase: Optional[bytes] = None,
cert: Optional[Union[str, List[str], List[X509]]] = None,
cert: Optional[Union[str, List[str], List[x509.Certificate]]] = None,
reference_uri: Optional[Union[str, List[str], List[SignatureReference]]] = None,
key_name: Optional[str] = None,
key_info: Optional[_Element] = None,
Expand All @@ -151,8 +151,8 @@ def sign(
:param passphrase: Passphrase to use to decrypt the key, if any.
:param cert:
X.509 certificate to use for signing. This should be a string containing a PEM-formatted certificate, or an
array of strings or :class:`OpenSSL.crypto.X509` objects containing the certificate and a chain of
intermediate certificates.
array of strings or :class:`cryptography.x509.Certificate` objects containing the certificate and a chain
of intermediate certificates.
:param reference_uri:
Custom reference URI or list of reference URIs to incorporate into the signature. When ``method`` is set to
``detached`` or ``enveloped``, reference URIs are set to this value and only the referenced elements are
Expand Down Expand Up @@ -201,7 +201,7 @@ def sign(
if len(cert_chain) == 0:
raise InvalidInput("No PEM-encoded certificates found in string cert input data")
else:
cert_chain = cert # type: ignore
cert_chain = cert # type:ignore[assignment]

input_references = self._preprocess_reference_uri(reference_uri)

Expand Down Expand Up @@ -244,7 +244,7 @@ def sign(
signed_info_node, algorithm=self.c14n_alg, inclusive_ns_prefixes=inclusive_ns_prefixes
)
if self.sign_alg.name.startswith("HMAC_"):
signer = HMAC(key=key, algorithm=digest_algorithm_implementations[self.sign_alg]()) # type: ignore
signer = HMAC(key=key, algorithm=digest_algorithm_implementations[self.sign_alg]()) # type:ignore[arg-type]
signer.update(signed_info_c14n)
signature_value_node.text = b64encode(signer.finalize()).decode()
sig_root.append(signature_value_node)
Expand Down Expand Up @@ -313,7 +313,7 @@ def _add_key_info(self, sig_root, signing_settings: SigningSettings):
if isinstance(cert, (str, bytes)):
x509_certificate.text = strip_pem_header(cert)
else:
x509_certificate.text = strip_pem_header(dump_certificate(FILETYPE_PEM, cert))
x509_certificate.text = strip_pem_header(cert.public_bytes(Encoding.PEM))
else:
sig_root.append(signing_settings.key_info)

Expand Down Expand Up @@ -378,12 +378,15 @@ def _unpack(self, data, references: List[SignatureReference]):
return sig_root, doc_root, c14n_inputs, references

def _build_transforms_for_reference(self, *, transforms_node: _Element, reference: SignatureReference):
assert reference.c14n_method is not None
if self.construction_method == SignatureConstructionMethod.enveloped:
SubElement(transforms_node, ds_tag("Transform"), Algorithm=SignatureConstructionMethod.enveloped.value)
SubElement(transforms_node, ds_tag("Transform"), Algorithm=reference.c14n_method.value) # type: ignore
SubElement(transforms_node, ds_tag("Transform"), Algorithm=reference.c14n_method.value)
else:
c14n_xform = SubElement(
transforms_node, ds_tag("Transform"), Algorithm=reference.c14n_method.value # type: ignore
transforms_node,
ds_tag("Transform"),
Algorithm=reference.c14n_method.value,
)
if reference.inclusive_ns_prefixes:
SubElement(
Expand Down
111 changes: 58 additions & 53 deletions signxml/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
from dataclasses import dataclass
from typing import Any, List, Optional

import certifi
from cryptography import x509
from cryptography.hazmat.primitives import hashes, hmac
from lxml.etree import QName

from ..exceptions import InvalidCertificate, RedundantCert, SignXMLException
from ..exceptions import InvalidCertificate

PEM_HEADER = "-----BEGIN CERTIFICATE-----"
PEM_FOOTER = "-----END CERTIFICATE-----"
Expand Down Expand Up @@ -150,17 +152,18 @@ def bits_to_bytes_unit(num_of_bits):


def strip_pem_header(cert):
try:
return re.search(pem_regexp, ensure_str(cert)).group(1).replace("\r", "") # type: ignore
except Exception:
return ensure_str(cert).replace("\r", "")
search_res = re.search(pem_regexp, ensure_str(cert))
if search_res:
return search_res.group(1).replace("\r", "")
return ensure_str(cert).replace("\r", "")


def add_pem_header(bare_base64_cert):
bare_base64_cert = ensure_str(bare_base64_cert)
if bare_base64_cert.startswith(PEM_HEADER):
return bare_base64_cert
return PEM_HEADER + "\n" + textwrap.fill(bare_base64_cert, 64) + "\n" + PEM_FOOTER
return bare_base64_cert.encode()
cert_with_header = PEM_HEADER + "\n" + textwrap.fill(bare_base64_cert, 64) + "\n" + PEM_FOOTER
return cert_with_header.encode()


def iterate_pem(certs):
Expand Down Expand Up @@ -206,61 +209,63 @@ def p_sha1(client_b64_bytes, server_b64_bytes):
return b64encode(raw_p_sha1(client_bytes, server_bytes, (len(client_bytes), len(server_bytes)))[0]).decode()


def _add_cert_to_store(store, cert):
from OpenSSL.crypto import Error as OpenSSLCryptoError
from OpenSSL.crypto import X509StoreContext, X509StoreContextError

try:
X509StoreContext(store, cert).verify_certificate()
except X509StoreContextError as e:
raise InvalidCertificate(e)
try:
store.add_cert(cert)
return cert
except OpenSSLCryptoError as e:
if e.args == ([("x509 certificate routines", "X509_STORE_add_cert", "cert already in hash table")],):
raise RedundantCert(e)
raise


def verify_x509_cert_chain(cert_chain, ca_pem_file=None, ca_path=None):
class X509CertChainVerifier:
"""
Look at certs in the cert chain and add them to the store one by one.
Return the cert at the end of the chain. That is the cert to be used by the caller for verifying.
From https://www.w3.org/TR/xmldsig-core2/#sec-X509Data:
"All certificates appearing in an X509Data element must relate to the validation key by either containing it
or being part of a certification chain that terminates in a certificate containing the validation key.
No ordering is implied by the above constraints"
Note: SignXML no longer uses OpenSSL for certificate chain verificaiton. The CApath parameter supported by OpenSSL
is not supported by cryptography. The CApath parameter is used to specify a directory containing CA certificates in
PEM format. The files each contain one CA certificate. The files are looked up by the CA subject name hash value.
See https://docs.openssl.org/master/man3/SSL_CTX_load_verify_locations/#notes. If you need CApath support, please
contact SignXML maintainers.
"""
# TODO: migrate to Cryptography (pending cert validation support) or https://github.com/wbond/certvalidator
from OpenSSL import SSL

context = SSL.Context(SSL.TLSv1_METHOD)
if ca_pem_file is None and ca_path is None:
import certifi

ca_pem_file = certifi.where()
context.load_verify_locations(ensure_bytes(ca_pem_file, none_ok=True), capath=ca_path)
store = context.get_cert_store()
certs = list(reversed(cert_chain))
end_of_chain = None
last_error: Exception = SignXMLException("Invalid certificate chain")
while len(certs) > 0:
for cert in certs:

def __init__(self, ca_pem_file=None, ca_path=None, verification_time=None):
if ca_pem_file is None:
ca_pem_file = certifi.where()
self.ca_pem_file = ca_pem_file
if ca_path is not None:
msg = "CApath is not supported. If you need this feature, please contact SignXML maintainers."
raise NotImplementedError(msg)

self.verification_time = verification_time

@property
def store(self):
with open(self.ca_pem_file, "rb") as pems:
certs = x509.load_pem_x509_certificates(pems.read())
return x509.verification.Store(certs)

@property
def builder(self):
builder = x509.verification.PolicyBuilder()
builder = builder.store(self.store)
if self.verification_time is not None:
builder = builder.time(self.verification_time)
return builder

@property
def verifier(self):
return self.builder.build_client_verifier()

def _do_verify(self, cert_chain):
leaf, intermediates = cert_chain[0], cert_chain[1:]
result = self.verifier.verify(leaf=leaf, intermediates=intermediates)
return result.chain[0]

def verify(self, cert_chain):
try:
return self._do_verify(cert_chain)
except x509.verification.VerificationError:
try:
end_of_chain = _add_cert_to_store(store, cert)
certs.remove(cert)
break
except RedundantCert:
certs.remove(cert)
if end_of_chain is None:
end_of_chain = cert
break
except Exception as e:
last_error = e
else:
raise last_error
return end_of_chain
return self._do_verify(list(reversed(cert_chain)))
except x509.verification.VerificationError as e:
raise InvalidCertificate(e)


def _remove_sig(signature, idempotent=False):
Expand Down
Loading

0 comments on commit c12e70c

Please sign in to comment.