diff --git a/.black.cfg.toml b/.black.cfg.toml index 5afec7e0..aca538bf 100644 --- a/.black.cfg.toml +++ b/.black.cfg.toml @@ -9,4 +9,4 @@ include = '\.pyi?$' line-length = 100 skip-string-normalization = true -target-version = ['py38', 'py39', 'py310'] +target-version = ['py39', 'py310'] diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 84eb193e..f19895a0 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.42.0 +current_version = 0.43.0 commit = True tag = False message = chore: Bump version from {current_version} to {new_version} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6c651cc9..8fe430d7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -28,7 +28,6 @@ jobs: strategy: matrix: python_version: - - "3.8" - "3.9" - "3.10" @@ -38,7 +37,7 @@ jobs: - name: Set Up Python ${{ matrix.python_version }} id: set_up_python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: "${{ matrix.python_version }}" check-latest: true @@ -47,7 +46,7 @@ jobs: run: make python-virtualenv PYTHON_VIRTUALENV_DIR="venv" - name: Restoring/Saving Cache - uses: actions/cache@v4.2.0 + uses: actions/cache@v4.2.2 with: path: | .tox @@ -73,7 +72,6 @@ jobs: strategy: matrix: python_version: - - "3.8" - "3.9" - "3.10" @@ -83,13 +81,13 @@ jobs: - name: Set Up Python ${{ matrix.python_version }} id: set_up_python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: "${{ matrix.python_version }}" check-latest: true - name: Restoring/Saving Cache - uses: actions/cache@v4.2.0 + uses: actions/cache@v4.2.2 with: path: | .tox @@ -130,7 +128,7 @@ jobs: make test-coverage-report - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v5.1.2 + uses: codecov/codecov-action@v5.4.0 with: token: ${{ secrets.CODECOV_TOKEN }} directory: ./test-reports/coverage/ @@ -138,14 +136,14 @@ jobs: - name: Check that compiled Python dependency manifests are up-to-date with their sources # FIXME: There are issues related to testing with multiple Python versions. - if: ${{ startsWith(matrix.python_version, '3.8.') }} + if: ${{ startsWith(steps.set_up_python.outputs.python-version, '3.9.') }} run: | source "$PYTHON_VIRTUALENV_ACTIVATE" make python-deps-sync-check - name: Store Artifacts if: ${{ always() }} - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.1 with: name: test_reports_${{ matrix.python_version }} path: test-reports/ diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 5e59f736..3de1e927 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -42,18 +42,18 @@ jobs: - name: Set Up Python id: set_up_python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: "3.10" - name: Restoring/Saving Cache - uses: actions/cache@v4.2.0 + uses: actions/cache@v4.2.2 with: path: "venv" key: py-v1-deps-${{ runner.os }}-${{ steps.set_up_python.outputs.python-version }}-${{ hashFiles('pyproject.toml', 'requirements.txt', 'requirements-dev.txt', 'Makefile', 'make/**.mk') }} - name: Restore Artifacts (Release) - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: release path: ${{ inputs.artifacts_path }}/ diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index cf3d2501..14f7cf16 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -39,7 +39,7 @@ jobs: - name: Set Up Python id: set_up_python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: "3.10" @@ -47,7 +47,7 @@ jobs: run: make python-virtualenv PYTHON_VIRTUALENV_DIR="venv" - name: Restoring/Saving Cache - uses: actions/cache@v4.2.0 + uses: actions/cache@v4.2.2 with: path: "venv" key: py-v1-deps-${{ runner.os }}-${{ steps.set_up_python.outputs.python-version }}-${{ hashFiles('pyproject.toml', 'requirements.txt', 'requirements-dev.txt', 'Makefile', 'make/**.mk') }} @@ -68,7 +68,7 @@ jobs: make dist - name: Store Artifacts - uses: actions/upload-artifact@v4.5.0 + uses: actions/upload-artifact@v4.6.1 with: name: release path: ${{ env.ARTIFACTS_PATH }}/ diff --git a/HISTORY.md b/HISTORY.md index dc7bab8d..2fea1c3c 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,21 @@ # History +## 0.43.0 (2025-03-12) + +- (PR #764, 2025-01-28) Python dependency sync check is never executed in CI/CD workflow +- (PR #765, 2025-02-03) chore: Bump the production-dependencies group with 3 updates +- (PR #766, 2025-02-03) chore(deps-dev): Bump twine from 6.0.1 to 6.1.0 in the + development-dependencies group +- (PR #767, 2025-02-07) chore(deps): Bump pydantic from 2.10.4 to 2.10.6 +- (PR #769, 2025-02-18) chore(deps): Bump pyopenssl from 24.3.0 to 25.0.0 +- (PR #768, 2025-02-18) chore(deps): Bump pytz from 2024.2 to 2025.1 +- (PR #770, 2025-02-18) chore(deps): Bump cryptography from 44.0.0 to 44.0.1 in the pip group +- (PR #777, 2025-03-06) chore: Bump the production-dependencies group with 4 updates +- (PR #779, 2025-03-07) libs: Add utility to get X.509 certificate from PKCS12 (PFX) file +- (PR #778, 2025-03-07) chore(deps): Bump django from 4.2.18 to 4.2.20 +- (PR #775, 2025-03-07) chore(deps): Bump lxml from 5.3.0 to 5.3.1 +- (PR #771, 2025-03-11) Drop support for Python 3.8 + ## 0.42.0 (2025-01-28) - (PR #758, 2025-01-15) Enable `warn_unused_ignores` in Mypy configuration diff --git a/README.md b/README.md index 8774b291..d44ee39f 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,8 @@ The full documentation is at . ## Supported Python versions -Only Python 3.8, 3.9 and 3.10. Python 3.7 and below will not work because we use some features -introduced in Python 3.8. +Only Python 3.9 and 3.10. Python 3.8 and below will not work because we use some features +introduced in Python 3.9. ## Quickstart diff --git a/mypy.ini b/mypy.ini index e9b6d932..3c553708 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version = 3.8 +python_version = 3.9 platform = linux mypy_path = src diff --git a/pyproject.toml b/pyproject.toml index 6938fcb7..0e4af765 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,6 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Natural Language :: English", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", ] diff --git a/requirements-dev.in b/requirements-dev.in index af06660a..5e3cdbea 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -13,7 +13,7 @@ isort==5.13.2 mypy==1.14.1 pip-tools==7.4.1 tox==4.23.2 -twine==6.0.1 +twine==6.1.0 types-jsonschema==4.23.0.20241208 types-lxml==2024.12.13 types-pyOpenSSL==24.1.0.20240722 diff --git a/requirements-dev.txt b/requirements-dev.txt index ab8618d3..5bf91a91 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.8 +# This file is autogenerated by pip-compile with Python 3.9 # by the following command: # # pip-compile --allow-unsafe --strip-extras requirements-dev.in @@ -40,7 +40,7 @@ colorama==0.4.6 # via tox coverage==7.6.1 # via -r requirements-dev.in -cryptography==44.0.0 +cryptography==44.0.1 # via # -c requirements.txt # secretstorage @@ -57,6 +57,8 @@ filelock==3.16.1 # virtualenv flake8==7.1.1 # via -r requirements-dev.in +id==1.5.0 + # via twine idna==3.7 # via requests importlib-metadata==8.5.0 @@ -96,8 +98,6 @@ pathspec==0.9.0 # via black pip-tools==7.4.1 # via -r requirements-dev.in -pkginfo==1.8.3 - # via twine platformdirs==4.3.6 # via # black @@ -131,6 +131,7 @@ referencing==0.35.1 # types-jsonschema requests==2.32.2 # via + # id # requests-toolbelt # twine requests-toolbelt==0.9.1 @@ -158,7 +159,7 @@ tomli==2.0.1 # tox tox==4.23.2 # via -r requirements-dev.in -twine==6.0.1 +twine==6.1.0 # via -r requirements-dev.in types-beautifulsoup4==4.12.0.20240907 # via types-lxml @@ -181,7 +182,6 @@ typing-extensions==4.12.2 # -c requirements.txt # black # mypy - # rich # tox # types-lxml urllib3==1.26.19 diff --git a/requirements.in b/requirements.in index a3aa5c4c..4045e05a 100644 --- a/requirements.in +++ b/requirements.in @@ -6,17 +6,17 @@ # git+https://github.com/example/example.git@example-vcs-ref#egg=example-pkg[foo,bar]==1.42.3 backports-zoneinfo==0.2.1 ; python_version < "3.9" # Used by `djangorestframework`. -cryptography==44.0.0 +cryptography==44.0.1 defusedxml==0.7.1 django-filter>=24.2 Django>=4.2 djangorestframework>=3.10.3,<3.16 importlib-metadata==8.5.0 jsonschema==4.23.0 -lxml==5.3.0 +lxml==5.3.1 marshmallow==3.22.0 -pydantic==2.10.4 -pyOpenSSL==24.3.0 -pytz==2024.2 +pydantic==2.10.6 +pyOpenSSL==25.0.0 +pytz==2025.1 signxml==4.0.3 typing-extensions==4.12.2 diff --git a/requirements.txt b/requirements.txt index 4d7c201a..ade735bb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.8 +# This file is autogenerated by pip-compile with Python 3.9 # by the following command: # # pip-compile --allow-unsafe --strip-extras requirements.in @@ -12,23 +12,18 @@ attrs==23.2.0 # via # jsonschema # referencing -backports-zoneinfo==0.2.1 ; python_version < "3.9" - # via - # -r requirements.in - # django - # djangorestframework certifi==2024.7.4 # via signxml cffi==1.16.0 # via cryptography -cryptography==44.0.0 +cryptography==44.0.1 # via # -r requirements.in # pyopenssl # signxml defusedxml==0.7.1 # via -r requirements.in -django==4.2.18 +django==4.2.20 # via # -r requirements.in # django-filter @@ -39,15 +34,11 @@ djangorestframework==3.15.2 # via -r requirements.in importlib-metadata==8.5.0 # via -r requirements.in -importlib-resources==6.4.0 - # via - # jsonschema - # jsonschema-specifications jsonschema==4.23.0 # via -r requirements.in jsonschema-specifications==2023.12.1 # via jsonschema -lxml==5.3.0 +lxml==5.3.1 # via # -r requirements.in # signxml @@ -55,17 +46,15 @@ marshmallow==3.22.0 # via -r requirements.in packaging==24.1 # via marshmallow -pkgutil-resolve-name==1.3.10 - # via jsonschema pycparser==2.22 # via cffi -pydantic==2.10.4 +pydantic==2.10.6 # via -r requirements.in pydantic-core==2.27.2 # via pydantic -pyopenssl==24.3.0 +pyopenssl==25.0.0 # via -r requirements.in -pytz==2024.2 +pytz==2025.1 # via -r requirements.in referencing==0.35.1 # via @@ -82,11 +71,9 @@ sqlparse==0.5.0 typing-extensions==4.12.2 # via # -r requirements.in - # annotated-types # asgiref # pydantic # pydantic-core + # pyopenssl zipp==3.20.2 - # via - # importlib-metadata - # importlib-resources + # via importlib-metadata diff --git a/src/cl_sii/__init__.py b/src/cl_sii/__init__.py index 32c7673e..419e274d 100644 --- a/src/cl_sii/__init__.py +++ b/src/cl_sii/__init__.py @@ -4,4 +4,4 @@ """ -__version__ = '0.42.0' +__version__ = '0.43.0' diff --git a/src/cl_sii/libs/crypto_utils.py b/src/cl_sii/libs/crypto_utils.py index cd20233d..0b69cb49 100644 --- a/src/cl_sii/libs/crypto_utils.py +++ b/src/cl_sii/libs/crypto_utils.py @@ -36,6 +36,13 @@ > In the case that it encodes a certificate it would simply contain the > base64 encoding of the DER certificate [plus the header and footer]. +PKCS12 +-------- + +PKCS12 stands for "Public Key Cryptography Standard #12". + +> […] a binary format described in RFC 7292. It can contain certificates, keys, and more. +> PKCS12 files commonly have a `pfx` or `p12` file suffix. """ __all__ = [ @@ -44,9 +51,10 @@ ] import base64 -from typing import Union +from typing import Optional, Union import cryptography.hazmat.backends.openssl.backend as _crypto_x509_backend +import cryptography.hazmat.primitives.serialization.pkcs12 import cryptography.x509 import signxml.util from cryptography.x509 import Certificate as X509Cert @@ -170,3 +178,38 @@ def remove_pem_cert_header_footer(pem_cert: bytes) -> bytes: mod_pem_value_str = signxml.util.strip_pem_header(pem_value_str) mod_pem_value: bytes = mod_pem_value_str.encode('ascii').strip() return mod_pem_value + + +def load_pfx_x509_cert(pfx_value: bytes, password: Union[bytes, str, None]) -> Optional[X509Cert]: + """ + Load an X.509 certificate from PKCS12-encoded data. + + :raises TypeError: + :raises ValueError: + """ + if isinstance(password, str): + password = password.encode() + + try: + p12 = cryptography.hazmat.primitives.serialization.pkcs12.load_pkcs12( + data=pfx_value, password=password + ) + except TypeError: + # Examples: + # - "TypeError: argument 'data': a bytes-like object is required, not 'NoneType'" + # - "argument 'password': from_buffer() cannot return the address of a unicode object" + raise + except ValueError: + # Examples: + # - "Invalid password or PKCS12 data" + # - "Could not deserialize PKCS12 data" + raise + + pkcs12_cert = p12.cert + x509_cert = pkcs12_cert.certificate if pkcs12_cert is not None else None + return x509_cert + + +# Aliases for `load_pfx_x509_cert()`: +load_pkcs12_x509_cert = load_pfx_x509_cert +load_p12_x509_cert = load_pfx_x509_cert diff --git a/src/tests/test_libs_crypto_utils.py b/src/tests/test_libs_crypto_utils.py index d0c18304..0850a53b 100644 --- a/src/tests/test_libs_crypto_utils.py +++ b/src/tests/test_libs_crypto_utils.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import unittest from binascii import a2b_hex from datetime import datetime @@ -11,6 +13,7 @@ add_pem_cert_header_footer, load_der_x509_cert, load_pem_x509_cert, + load_pfx_x509_cert, remove_pem_cert_header_footer, x509_cert_der_to_pem, x509_cert_pem_to_der, @@ -1098,3 +1101,9 @@ def test_add_pem_cert_header_footer(self) -> None: def test_remove_pem_cert_header_footer(self) -> None: # TODO: implement for 'remove_pem_cert_header_footer' pass + + +class LoadPfxX509CertTest(unittest.TestCase): + @unittest.skip("TODO: Implement for 'load_pfx_x509_cert'") + def test_load_pfx_x509_cert_ok(self) -> None: + assert bool(load_pfx_x509_cert) diff --git a/src/tests/test_libs_xml_utils.py b/src/tests/test_libs_xml_utils.py index a503f2d5..e2aa359f 100644 --- a/src/tests/test_libs_xml_utils.py +++ b/src/tests/test_libs_xml_utils.py @@ -306,11 +306,14 @@ def _get_cert_chain_verifier( ) # Without cert: fails because the issuer of the cert in the signature is not a known CA. - with self.assertRaises(XmlSignatureInvalidCertificate) as cm, mock.patch.object( - signxml.verifier.XMLVerifier, - 'get_cert_chain_verifier', - side_effect=_get_cert_chain_verifier, - ) as mock_get_cert_chain_verifier: + with ( + self.assertRaises(XmlSignatureInvalidCertificate) as cm, + mock.patch.object( + signxml.verifier.XMLVerifier, + 'get_cert_chain_verifier', + side_effect=_get_cert_chain_verifier, + ) as mock_get_cert_chain_verifier, + ): verify_xml_signature(xml_doc, trusted_x509_cert=None) self.assertEqual( cm.exception.args, diff --git a/src/tests/test_rut_crypto_utils.py b/src/tests/test_rut_crypto_utils.py index e97287dd..511117ba 100644 --- a/src/tests/test_rut_crypto_utils.py +++ b/src/tests/test_rut_crypto_utils.py @@ -120,14 +120,17 @@ def test_get_subject_rut_from_certificate_pfx_does_not_throw_attribute_error_if_ value=certificate_extension_value, ) - with patch.object( - cryptography.hazmat.primitives.serialization.pkcs12, - 'load_key_and_certificates', - Mock(return_value=(None, x509_cert, None)), - ), patch.object( - x509_cert.extensions, - 'get_extension_for_class', - Mock(return_value=certificate_extension), + with ( + patch.object( + cryptography.hazmat.primitives.serialization.pkcs12, + 'load_key_and_certificates', + Mock(return_value=(None, x509_cert, None)), + ), + patch.object( + x509_cert.extensions, + 'get_extension_for_class', + Mock(return_value=certificate_extension), + ), ): pfx_file_bytes = b'hello' password = 'fake_password' diff --git a/tox.ini b/tox.ini index 779afe35..c62778c7 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,5 @@ [tox] envlist = - py38, py39, py310, @@ -12,6 +11,5 @@ deps = -r{toxinidir}/requirements.txt -r{toxinidir}/requirements-dev.txt basepython = - py38: python3.8 py39: python3.9 py310: python3.10