diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index f36c596..5171779 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -44,6 +44,19 @@ jobs: allow-prereleases: true - name: Setup dependencies run: pip install --upgrade pip pytest + - name: Install mkcert (Linux) + if: matrix.os == 'ubuntu-latest' + run: apt-get install mkcert + - name: Install mkcert (MacOS) + if: matrix.os == 'macos-12' + run: brew install mkcert + - name: Install mkcert (Windows) + if: matrix.os == 'windows-latest' + run: choco install mkcert + - name: Inject fake CA in TrustStore + run: mkcert -install + - name: Generate a valid certificate + run: mkcert example.test - name: Build wheels (Unix, Linux) if: matrix.os != 'windows-latest' uses: PyO3/maturin-action@v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index f637d7f..cd5b534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to wassima will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## 1.1.3 (2024-09-30) + +### Changed +- Bumped `rustls-native-certs` to version 0.7.3 + +### Added +- Automatic (fallback) installation of `certifi` if native trust store access isn't supported on your platform. + ## 1.1.2 (2024-08-17) ### Changed diff --git a/Cargo.lock b/Cargo.lock index 062bd0e..bdc75fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "autocfg" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "base64" @@ -56,9 +56,9 @@ checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" [[package]] name = "libc" -version = "0.2.156" +version = "0.2.159" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5f43f184355eefb8d17fc948dbecf6c13be3c141f20d834ae842193a448c72a" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "lock_api" @@ -116,9 +116,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.7.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da544ee218f0d287a911e9c99a39a8c9bc8fcad3cb8db5959940044ecfc67265" +checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" [[package]] name = "proc-macro2" @@ -194,27 +194,27 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.36" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] [[package]] name = "redox_syscall" -version = "0.5.3" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" +checksum = "355ae415ccd3a04315d3f8246e86d67689ea74d88d915576e1589a351062a13b" dependencies = [ "bitflags", ] [[package]] name = "rustls-native-certs" -version = "0.7.1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88d6d420651b496bdd98684116959239430022a115c1240e6c3993be0b15fba" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" dependencies = [ "openssl-probe", "rustls-pemfile", @@ -235,15 +235,15 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" +checksum = "0e696e35370c65c9c541198af4543ccd580cf17fc25d8e05c5a242b202488c55" [[package]] name = "schannel" -version = "0.1.23" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +checksum = "e9aaafd5a2b6e3d657ff009d82fbd630b6bd54dd4eb06f21693925cdf80f9b8b" dependencies = [ "windows-sys", ] @@ -269,9 +269,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.11.1" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75da29fe9b9b08fe9d6b22b5b4bcbc75d8db3aa31e639aa56bb62e9d46bfceaf" +checksum = "ea4a292869320c0272d7bc55a5a6aafaff59b4f63404a003887b679a2e05b4b6" dependencies = [ "core-foundation-sys", "libc", @@ -285,9 +285,9 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "syn" -version = "2.0.74" +version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", @@ -302,9 +302,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" [[package]] name = "unindent" @@ -322,9 +322,9 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.52.0" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets", ] diff --git a/Cargo.toml b/Cargo.toml index ebc5301..66ae093 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wassima" -version = "1.1.2" +version = "1.1.3" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -10,7 +10,7 @@ crate-type = ["cdylib"] [dependencies] pyo3 = { version = "0.20.3", features = ["abi3-py37", "extension-module"] } -rustls-native-certs = "0.7.1" +rustls-native-certs = "0.7.3" [package.metadata.maturin] python-source = "wassima" diff --git a/README.md b/README.md index 53b9c00..acafe85 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ around MIT licensed **rustls-native-certs**. This project allows you to access your original operating system trust store, thus helping you to verify the remote peer certificates. -It works as-is out-of-the-box for MacOS, Windows, and Linux. +It works as-is out-of-the-box for MacOS, Windows, and Linux. Automatically fallback on Certifi otherwise. Available on PyPy and Python 3.7+ If your particular operating system is not supported, we will make this happen! Open diff --git a/pyproject.fb.toml b/pyproject.fb.toml index ac26e9d..cc2bc28 100644 --- a/pyproject.fb.toml +++ b/pyproject.fb.toml @@ -37,6 +37,9 @@ classifiers = [ "Development Status :: 5 - Production/Stable" ] dynamic = ["version"] +dependencies = [ + "certifi; (platform_python_implementation != 'CPython' or python_full_version < '3.7.10') or (platform_system != 'Darwin' and platform_system != 'Windows' and platform_system != 'Linux') or (platform_machine != 'x86_64' and platform_machine != 's390x' and platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'ppc64le' and platform_machine != 'ppc64' and platform_machine != 'AMD64' and platform_machine != 'arm64' and platform_machine != 'ARM64' and platform_machine != 'i686') or (platform_python_implementation == 'PyPy' and python_version >= '3.11')", +] [tool.hatch.version] path = "wassima/_version.py" diff --git a/tests/test_ctx.py b/tests/test_ctx.py index a3506c5..c8ab861 100644 --- a/tests/test_ctx.py +++ b/tests/test_ctx.py @@ -1,7 +1,11 @@ from __future__ import annotations +import http.server +import threading +from os.path import exists from socket import AF_INET, SOCK_STREAM, socket -from ssl import SSLError +from ssl import PROTOCOL_TLS_SERVER, SSLContext, SSLError +from time import sleep import pytest @@ -59,3 +63,45 @@ def test_ctx_use_system_store(host: str, port: int, expect_failure: bool) -> Non assert s.getpeercert() is not None s.close() + + +def serve(): + context = SSLContext(PROTOCOL_TLS_SERVER) + context.load_cert_chain( + certfile="./example.test.pem", keyfile="./example.test-key.pem" + ) + server_address = ("127.0.0.1", 47476) + httpd = http.server.HTTPServer(server_address, http.server.SimpleHTTPRequestHandler) + httpd.socket = context.wrap_socket(httpd.socket, server_side=True) + httpd.serve_forever() + + +@pytest.mark.skipif(not exists("./example.test.pem"), reason="test requires mkcert") +def test_ctx_access_local_trusted_root() -> None: + ctx = create_default_ssl_context() + + t = threading.Thread(target=serve) + t.daemon = True + t.start() + + s = socket(AF_INET, SOCK_STREAM) + s = ctx.wrap_socket(s, server_hostname="example.test") + + i = 0 + + while True: + sleep(1) + + if i >= 5: + break + + try: + s.connect(("127.0.0.1", 47476)) + except ConnectionError: + i += 1 + except SSLError: + assert False + else: + break + + assert s.getpeercert() is not None diff --git a/wassima/__init__.py b/wassima/__init__.py index 080dc7e..3d7b1cd 100644 --- a/wassima/__init__.py +++ b/wassima/__init__.py @@ -7,10 +7,14 @@ import contextlib import os import ssl +import tempfile import typing from functools import lru_cache from threading import RLock +if typing.TYPE_CHECKING: + from io import BufferedWriter + from ._version import VERSION, __version__ #: Determine if we could load correctly the non-native rust module. @@ -24,6 +28,60 @@ _USER_APPEND_CA_LOCK = RLock() +@contextlib.contextmanager +def _atomic_open(filename: str) -> typing.Generator[BufferedWriter, None, None]: + """Write a file to the disk in an atomic fashion""" + tmp_descriptor, tmp_name = tempfile.mkstemp(dir=os.path.dirname(filename)) + try: + with os.fdopen(tmp_descriptor, "wb") as tmp_handler: + yield tmp_handler + os.replace(tmp_name, filename) + except BaseException: + os.remove(tmp_name) + raise + + +def _extract_zipped_paths(path: str) -> str: + """Replace nonexistent paths that look like they refer to a member of a zip + archive with the location of an extracted copy of the target, or else + just return the provided path unchanged. + """ + if os.path.exists(path): + # this is already a valid path, no need to do anything further + return path + + import zipfile + + # find the first valid part of the provided path and treat that as a zip archive + # assume the rest of the path is the name of a member in the archive + archive, member = os.path.split(path) + while archive and not os.path.exists(archive): + archive, prefix = os.path.split(archive) + if not prefix: + # If we don't check for an empty prefix after the split (in other words, archive remains unchanged after the split), + # we _can_ end up in an infinite loop on a rare corner case affecting a small number of users + break + member = "/".join([prefix, member]) + + if not zipfile.is_zipfile(archive): + return path + + zip_file = zipfile.ZipFile(archive) + if member not in zip_file.namelist(): + return path + + # we have a valid zip archive and a valid member of that archive + tmp = tempfile.gettempdir() + extracted_path = os.path.join(tmp, member.split("/")[-1]) + + if not os.path.exists(extracted_path): + # use read + write to avoid the creating nested folders, we only want the file, avoids mkdir racing condition + with _atomic_open(extracted_path) as file_handler: + file_handler.write(zip_file.read(member)) + + return extracted_path + + def _split_certifi_bundle(data: bytes) -> list[str]: line_ending = b"\n" if b"-----\r\n" not in data else b"\r\n" boundary = b"-----END CERTIFICATE-----" + line_ending @@ -66,7 +124,7 @@ def _certifi_fallback() -> list[bytes]: certs: list[bytes] = [] try: - with open(certifi.where(), "rb") as fp: + with open(_extract_zipped_paths(certifi.where()), "rb") as fp: for pem_cert in _split_certifi_bundle(fp.read()): certs.append(ssl.PEM_cert_to_DER_cert(pem_cert)) except (OSError, PermissionError) as e: diff --git a/wassima/_version.py b/wassima/_version.py index 8da9c16..efd579c 100644 --- a/wassima/_version.py +++ b/wassima/_version.py @@ -1,4 +1,4 @@ from __future__ import annotations -__version__ = "1.1.2" +__version__ = "1.1.3" VERSION = __version__.split(".")