diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3924165d..4a1a2060 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,29 +4,23 @@ on: [push, pull_request] jobs: unit_tests: - runs-on: ${{ matrix.os }} + runs-on: ${{matrix.os}} strategy: max-parallel: 8 matrix: os: [ubuntu-18.04, ubuntu-22.04, macos-12] python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 with: - python-version: ${{ matrix.python-version }} - - name: Install - run: | - [[ $(uname) == Linux ]] && sudo apt-get install --no-install-recommends python3-openssl python3-lxml - pip install coverage wheel - make install - - name: Test - run: | - make test - - name: Upload coverage data - run: | - bash <(curl -s https://codecov.io/bash) + python-version: ${{matrix.python-version}} + - run: | + if [[ $(uname) == Linux ]]; then sudo apt-get install --no-install-recommends python3-openssl python3-lxml; fi + - run: make install + - run: make lint + - run: make test + - uses: codecov/codecov-action@v3 black: runs-on: ubuntu-22.04 steps: diff --git a/Makefile b/Makefile index a3078872..12935361 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,11 @@ -test_deps: - pip install coverage flake8 wheel mypy types-certifi types-pyOpenSSL lxml-stubs +SHELL=/bin/bash -lint: test_deps - flake8 $$(python setup.py --name) test - mypy $$(python setup.py --name) --check-untyped-defs +lint: + flake8 + mypy --install-types --non-interactive --check-untyped-defs $$(dirname */__init__.py) -test: test_deps lint - coverage run --source=$$(python setup.py --name) ./test/test.py +test: + python ./test/test.py -v init_docs: cd docs; sphinx-quickstart @@ -14,15 +13,12 @@ init_docs: docs: sphinx-build docs docs/html -install: clean - pip install wheel - python setup.py bdist_wheel - pip install --upgrade dist/*.whl +install: + -rm -rf dist + python -m pip install build + python -m build + python -m pip install --upgrade $$(echo dist/*.whl)[tests] -clean: - -rm -rf build dist - -rm -rf *.egg-info - -.PHONY: lint test test_deps docs install clean +.PHONY: test lint release docs include common.mk diff --git a/README.rst b/README.rst index 0a550c47..ad601817 100644 --- a/README.rst +++ b/README.rst @@ -65,7 +65,7 @@ SignXML uses the `lxml ElementTree API `_ to work 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:2048 > cert.pem`` + ``openssl req -x509 -nodes -subj "/CN=test" -days 1 -newkey rsa -keyout privkey.pem -out cert.pem`` (run ``yum install openssl`` on Red Hat). - 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.) @@ -244,7 +244,7 @@ Please report bugs, issues, feature requests, etc. on `GitHub `_. Distribution of the LICENSE and NOTICE files with source copies of this package and derivative works is **REQUIRED** as specified by the Apache License. diff --git a/setup.cfg b/setup.cfg index 4e79e204..5853e9a7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,3 @@ -[bdist_wheel] -universal=1 [flake8] max-line-length=120 extend-ignore=E203 diff --git a/setup.py b/setup.py index ab635253..09e62e13 100755 --- a/setup.py +++ b/setup.py @@ -19,7 +19,18 @@ "cryptography >= 3.4.8", # Set to the version in Ubuntu 22.04 due to features we need from cryptography 3.1 "pyOpenSSL >= 17.5.0", "certifi >= 2018.1.18", + "tsp-client >= 0.1.3", ], + extras_require={ + "tests": [ + "flake8", + "coverage", + "build", + "wheel", + "mypy", + "lxml-stubs", + ] + }, packages=find_packages(exclude=["test"]), platforms=["MacOS X", "Posix"], package_data={"signxml": ["schemas/*.xsd", "py.typed"]}, diff --git a/signxml/util/__init__.py b/signxml/util/__init__.py index 31933efe..4c9d441a 100644 --- a/signxml/util/__init__.py +++ b/signxml/util/__init__.py @@ -12,8 +12,11 @@ from dataclasses import dataclass from typing import Any, List, Optional +import certifi from cryptography.hazmat.primitives import hashes, hmac from lxml.etree import QName +from OpenSSL.crypto import Error as OpenSSLCryptoError +from OpenSSL.crypto import X509Store, X509StoreContext, X509StoreContextError from ..exceptions import InvalidCertificate, RedundantCert, SignXMLException @@ -207,9 +210,6 @@ def p_sha1(client_b64_bytes, server_b64_bytes): 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: @@ -233,22 +233,21 @@ def verify_x509_cert_chain(cert_chain, ca_pem_file=None, ca_path=None): No ordering is implied by the above constraints" """ # TODO: migrate to Cryptography (pending cert validation support) or https://github.com/wbond/certvalidator - from OpenSSL import SSL - - context = SSL.Context(SSL.TLSv1_METHOD) + x509_store = X509Store() 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() + x509_store.load_locations(cafile=ca_pem_file, capath=ca_path) + + # FIXME: use X509StoreContext(store=x509_store, certificate=cert, chain=cert_chain).get_verified_chain() + # This requires identifying the signing cert out-of-band + certs = list(reversed(cert_chain)) end_of_chain = None last_error: Exception = SignXMLException("Invalid certificate chain") while len(certs) > 0: for cert in certs: try: - end_of_chain = _add_cert_to_store(store, cert) + end_of_chain = _add_cert_to_store(x509_store, cert) certs.remove(cert) break except RedundantCert: diff --git a/signxml/verifier.py b/signxml/verifier.py index 841ecd54..99df0960 100644 --- a/signxml/verifier.py +++ b/signxml/verifier.py @@ -375,6 +375,7 @@ def verify( raise InvalidInput(msg) else: cert_chain = [load_certificate(FILETYPE_PEM, add_pem_header(cert)) for cert in certs] + # FIXME: switch to wbondcrypto cert chain verify signing_cert = verify_x509_cert_chain(cert_chain, ca_pem_file=ca_pem_file, ca_path=ca_path) elif isinstance(self.x509_cert, X509): signing_cert = self.x509_cert @@ -389,6 +390,8 @@ def verify( try: digest_alg_name = str(digest_algorithm_implementations[signature_alg].name) + # FIXME: confirm the specified signature algorithm matches the certificate's public key + # FIXME: switch to cryptography verify openssl_verify(signing_cert, raw_signature, signed_info_c14n, digest_alg_name) except OpenSSLCryptoError as e: try: diff --git a/signxml/xades/xades.py b/signxml/xades/xades.py index 9111ba3f..3dc0b0b7 100644 --- a/signxml/xades/xades.py +++ b/signxml/xades/xades.py @@ -29,6 +29,7 @@ from lxml.etree import SubElement, _Element from OpenSSL.crypto import FILETYPE_ASN1, FILETYPE_PEM, X509, dump_certificate, load_certificate +from tsp_client import TSPVerifier from .. import SignatureConfiguration, VerifyResult, XMLSignatureProcessor, XMLSigner, XMLVerifier from ..algorithms import DigestAlgorithm @@ -264,9 +265,31 @@ class XAdESVerifier(XAdESProcessor, XMLVerifier): """ # TODO: document/support SignatureTimeStamp / timestamp attestation + # TODO: allow setting required attributes, including timestamp # SignatureTimeStamp is required by certain profiles but is an unsigned property - def _verify_signing_time(self, verify_result: VerifyResult): - pass + + def _verify_signing_time(self, verify_result: VerifyResult, all_verify_results: List[VerifyResult]): + """ + The Implicit mechanism (see clause 5.1.4.4.1) shall be used for generating this qualifying property. + The input to the computation of the message imprint shall be the result of processing all the ds:Reference + elements within the ds:SignedInfo except the one referencing the SignedProperties element, in their order of + appearance, as follows: + 1) process the retrieved ds:Reference element according to the reference-processing model of XMLDSIG [1] + clause 4.4.3.2; + 2) if the result is a XML node set, canonicalize it as specified in clause 4.5; and + 3) concatenate the resulting octets to those resulting from previously processed ds:Reference elements in + ds:SignedInfo. + """ + ts_path = "xades:SignedDataObjectProperties/xades:AllDataObjectsTimeStamp/xades:EncapsulatedTimeStamp" + if verify_result.signed_xml is None: + return + all_data_objs_ts = verify_result.signed_xml.find(ts_path, namespaces=namespaces) + if all_data_objs_ts is None: + return + print("Will verify", all_data_objs_ts.text) + ts = b64decode(all_data_objs_ts.text) # type: ignore + tsp_message = b"".join(r.signed_data for r in all_verify_results if r != verify_result) + TSPVerifier().verify(ts, message=tsp_message) def _verify_cert_digest(self, signing_cert_node, expect_cert): for cert in self._findall(signing_cert_node, "xades:Cert"): @@ -320,8 +343,8 @@ def _verify_signature_policy(self, verify_result: VerifyResult, expect_signature if b64decode(digest_value.text) != b64decode(expect_signature_policy.DigestValue): raise InvalidInput("Digest mismatch for signature policy hash") - def _verify_signed_properties(self, verify_result): - self._verify_signing_time(verify_result) + def _verify_signed_properties(self, verify_result, *, all_verify_results): + self._verify_signing_time(verify_result, all_verify_results=all_verify_results) self._verify_cert_digests(verify_result) if self.expect_signature_policy: self._verify_signature_policy( @@ -364,7 +387,8 @@ def verify( # type: ignore continue if verify_result.signed_xml.tag == xades_tag("SignedProperties"): verify_results[i] = XAdESVerifyResult( # type: ignore - *astuple(verify_result), signed_properties=self._verify_signed_properties(verify_result) + *astuple(verify_result), + signed_properties=self._verify_signed_properties(verify_result, all_verify_results=verify_results), ) break else: diff --git a/test/openssl_tests.py b/test/openssl_tests.py new file mode 100644 index 00000000..d3f67226 --- /dev/null +++ b/test/openssl_tests.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python +from functools import partial +from subprocess import call + +import OpenSSL.crypto +from asn1crypto.x509 import TbsCertificate +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding, rsa + +run = partial(call, shell=True, executable="/bin/bash") +message = b"abc" +signatures = {} + +pss_padding = padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH) +pkcs_padding = padding.PKCS1v15() + +run("openssl req -x509 -nodes -subj '/CN=test' -days 1 -newkey rsa -keyout rsa-key.pem -out rsa-cert.pem") +with open("rsa-cert.pem", "rb") as fh: + rsa_cert = fh.read() +with open("rsa-key.pem", "rb") as fh: + rsa_key = serialization.load_pem_private_key(fh.read(), password=None) +signatures[rsa_cert] = rsa_key.sign(message, pkcs_padding, hashes.SHA512()) + +# Does not work on LibreSSL +run("openssl req -x509 -nodes -subj '/CN=test' -days 1 -newkey rsa-pss -keyout rsapss-key.pem -out rsapss-cert.pem") +with open("rsapss-cert.pem", "rb") as fh: + rsapss_cert = fh.read() +with open("rsapss-key.pem", "rb") as fh: + rsapss_key = serialization.load_pem_private_key(fh.read(), password=None) +signatures[rsapss_cert] = rsapss_key.sign(message, pss_padding, hashes.SHA512()) + +run( + "openssl req -x509 -nodes -subj '/CN=test' -days 1 -newkey ec:<(openssl ecparam -name secp384r1) -keyout ec-key.pem -out ec-cert.pem" +) +with open("ec-cert.pem", "rb") as fh: + ec_cert = fh.read() +with open("ec-key.pem", "rb") as fh: + ec_key = serialization.load_pem_private_key(fh.read(), password=None) +signatures[ec_cert] = ec_key.sign(message, ec.ECDSA(hashes.SHA512())) + +run( + "openssl req -x509 -nodes -subj '/CN=test' -days 1 -newkey dsa:<(openssl dsaparam 2048) -keyout dsa-key.pem -out dsa-cert.pem" +) +with open("dsa-cert.pem", "rb") as fh: + dsa_cert = fh.read() +with open("dsa-key.pem", "rb") as fh: + dsa_key = serialization.load_pem_private_key(fh.read(), password=None) +signatures[dsa_cert] = dsa_key.sign(message, hashes.SHA512()) + +# Does not work on LibreSSL +run("openssl req -x509 -nodes -subj '/CN=test' -days 1 -newkey ed25519 -keyout ed25519-key.pem -out ed25519-cert.pem") +with open("ed25519-cert.pem", "rb") as fh: + ed25519_cert = fh.read() +with open("ed25519-key.pem", "rb") as fh: + ed25519_key = serialization.load_pem_private_key(fh.read(), password=None) +signatures[ed25519_cert] = ed25519_key.sign(message) + +for cert_pem_bytes, signature in signatures.items(): + cert = x509.load_pem_x509_certificate(cert_pem_bytes) + pubkey = cert.public_key() + alg = TbsCertificate.load(cert.tbs_certificate_bytes)["subject_public_key_info"]["algorithm"] + if alg["algorithm"].native == "rsassa_pss": + verify_args = [pss_padding, hashes.SHA512()] + elif alg["algorithm"].native == "rsa": + verify_args = [pkcs_padding, hashes.SHA512()] + elif alg["algorithm"].native == "ec": + verify_args = [ec.ECDSA(hashes.SHA512())] + elif alg["algorithm"].native == "ed25519": + verify_args = [] + elif alg["algorithm"].native == "dsa": + verify_args = [hashes.SHA512()] + pubkey.verify(signature, message, *verify_args) + + try: + cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, cert_pem_bytes) + OpenSSL.crypto.verify(cert, signature, message, "sha512") + except Exception as e: + print(f"Error in OpenSSL.crypto.verify with {type(pubkey)}: {e}")