diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b41b38 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/requests-pkcs12-1.7.tar.gz +/requests-pkcs12-1.25.tar.gz +/requests-pkcs12-1.27.tar.gz diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd189a3 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# python-requests-pkcs12 + +The python-requests-pkcs12 package \ No newline at end of file diff --git a/dead.package b/dead.package deleted file mode 100644 index 5204a84..0000000 --- a/dead.package +++ /dev/null @@ -1 +0,0 @@ -Orphaned for 6+ weeks diff --git a/python-requests-pkcs12.spec b/python-requests-pkcs12.spec new file mode 100644 index 0000000..f39902b --- /dev/null +++ b/python-requests-pkcs12.spec @@ -0,0 +1,137 @@ +%global pypi_name requests-pkcs12 + +Name: python-%{pypi_name} +Version: 1.27 +Release: 1%{?dist} +Summary: Add PKCS12 support to the requests library + +License: ISC +URL: https://github.com/m-click/requests_pkcs12 +Source0: %{url}/archive/%{version}/%{pypi_name}-%{version}.tar.gz +Source1: test_integration.py +BuildArch: noarch + +%description +This library adds PKCS12 support to the Python requests library. It is +integrated into requests as recommended by its authors: creating a custom +TransportAdapter, which provides a custom SSLContext. + +%package -n python3-%{pypi_name} +Summary: %{summary} + +BuildRequires: python3-devel + +# For tests +BuildRequires: python3-requests +BuildRequires: python3-pytest +BuildRequires: openssl + +%description -n python3-%{pypi_name} +This library adds PKCS12 support to the Python requests library. It is +integrated into requests as recommended by its authors: creating a custom +TransportAdapter, which provides a custom SSLContext. + +%prep +%autosetup -n requests_pkcs12-%{version} +cp %{SOURCE1} . + +%generate_buildrequires +%pyproject_buildrequires + +%build +%pyproject_wheel + +%install +%pyproject_install +%pyproject_save_files -l requests_pkcs12 + +%check +%pyproject_check_import +%{pytest} -v + +# embeded test with connection to example.com +# skip it with unavailable network (in koji) +if getent hosts example.com; then + PYTHONDONTWRITEBYTECODE=1 \ + PATH="%{buildroot}%{_bindir}:$PATH" \ + PYTHONPATH="${PYTHONPATH:-%{buildroot}%{python3_sitearch}:%{buildroot}%{python3_sitelib}}" \ + %{__python3} -c 'import requests_pkcs12; requests_pkcs12.test()' +fi + +%files -n python3-%{pypi_name} -f %{pyproject_files} +%doc README.rst + +%changelog +* Mon Sep 22 2025 Lukas Slebodnik - 1.27-1 +- New upstream version 1.27 +- Fix serialisation on FIPS enabled system + +* Fri Sep 19 2025 Python Maint - 1.25-7 +- Rebuilt for Python 3.14.0rc3 bytecode + +* Wed Aug 27 2025 Lukas Slebodnik - 1.25-6 +- rhbz#2378169 Migrating to pyproject macros +- https://fedoraproject.org/wiki/Changes/DeprecateSetuppyMacros + +* Fri Aug 15 2025 Python Maint - 1.25-5 +- Rebuilt for Python 3.14.0rc2 bytecode + +* Fri Jul 25 2025 Fedora Release Engineering - 1.25-4 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_43_Mass_Rebuild + +* Mon Jun 02 2025 Python Maint - 1.25-3 +- Rebuilt for Python 3.14 + +* Sat Jan 18 2025 Fedora Release Engineering - 1.25-2 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_42_Mass_Rebuild + +* Mon Jul 22 2024 Lukas Slebodnik - 1.25-1 +- New upstream version 1.25 + +* Fri Jul 19 2024 Fedora Release Engineering - 1.7-16 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_41_Mass_Rebuild + +* Fri Jun 07 2024 Python Maint - 1.7-15 +- Rebuilt for Python 3.13 + +* Fri Jan 26 2024 Fedora Release Engineering - 1.7-14 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_40_Mass_Rebuild + +* Mon Jan 22 2024 Fedora Release Engineering - 1.7-13 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_40_Mass_Rebuild + +* Fri Jul 21 2023 Fedora Release Engineering - 1.7-12 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_39_Mass_Rebuild + +* Tue Jun 13 2023 Python Maint - 1.7-11 +- Rebuilt for Python 3.12 + +* Fri Jan 20 2023 Fedora Release Engineering - 1.7-10 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_38_Mass_Rebuild + +* Fri Jul 22 2022 Fedora Release Engineering - 1.7-9 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_37_Mass_Rebuild + +* Mon Jun 13 2022 Python Maint - 1.7-8 +- Rebuilt for Python 3.11 + +* Fri Jan 21 2022 Fedora Release Engineering - 1.7-7 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_36_Mass_Rebuild + +* Fri Jul 23 2021 Fedora Release Engineering - 1.7-6 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_35_Mass_Rebuild + +* Fri Jun 04 2021 Python Maint - 1.7-5 +- Rebuilt for Python 3.10 + +* Wed Jan 27 2021 Fedora Release Engineering - 1.7-4 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_34_Mass_Rebuild + +* Wed Jul 29 2020 Fedora Release Engineering - 1.7-3 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_33_Mass_Rebuild + +* Tue May 26 2020 Miro HronĨok - 1.7-2 +- Rebuilt for Python 3.9 + +* Thu Mar 19 2020 Fabian Affolter - 1.7-1 +- Initial package for Fedora diff --git a/sources b/sources new file mode 100644 index 0000000..074ca0a --- /dev/null +++ b/sources @@ -0,0 +1 @@ +SHA512 (requests-pkcs12-1.27.tar.gz) = f8c2a8eb0a03ebb10f07ddf8e5470f63d7eb038938ead31053145a3eca37e77135e9d961230378d157ef616d575c1b39f9fa5fb84cb26f0d16738e1fff607ce5 diff --git a/test_integration.py b/test_integration.py new file mode 100644 index 0000000..a51911b --- /dev/null +++ b/test_integration.py @@ -0,0 +1,426 @@ +# SPDX-License-Identifier: MIT +import http.server +import os +import ssl +import subprocess +import threading +import unittest + +import requests +import requests_pkcs12 +import urllib3.util + +# --- Configuration --- +HOST = "localhost" +IP_ADDRESS = "127.0.0.1" +PORT = 9443 + +# --- File Names --- +ROOT_CA_KEY = "test_rootCA.key" +ROOT_CA_CSR = "test_rootCA.csr" +ROOT_CA_PEM = "test_rootCA.pem" +SERVER_KEY = "test_server.key" +SERVER_CSR = "test_server.csr" +SERVER_CERT = "test_server.crt" +CLIENT_KEY = "test_client.key" +CLIENT_CSR = "test_client.csr" +CLIENT_CERT = "test_client.crt" +CLIENT_P12_NO_PWD = "test_client_no_pwd.p12" +CLIENT_P12_WITH_PWD = "test_client_with_pwd.p12" +CA_V3_EXT_FILE = "ca_v3.ext" +SERVER_V3_EXT_FILE = "server_v3.ext" +P12_PASSWORD = "testpassword" + +GENERATED_FILES = [ + ROOT_CA_KEY, + ROOT_CA_CSR, + ROOT_CA_PEM, + SERVER_KEY, + SERVER_CSR, + SERVER_CERT, + CLIENT_KEY, + CLIENT_CSR, + CLIENT_CERT, + CLIENT_P12_NO_PWD, + CLIENT_P12_WITH_PWD, + CA_V3_EXT_FILE, + SERVER_V3_EXT_FILE, + "test_rootCA.srl", +] + + +def run_command(args): + """Helper function to run a shell command as a list of arguments.""" + subprocess.run(args, check=True) + + +class TestMTLSClient(unittest.TestCase): + """Test suite for mTLS client connections with an embedded server.""" + + httpd = None + server_thread = None + + @staticmethod + def _start_embedded_server(): + """Creates and returns a configured HTTPServer instance.""" + server_address = (IP_ADDRESS, PORT) + + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(certfile=SERVER_CERT, keyfile=SERVER_KEY) + context.load_verify_locations(cafile=ROOT_CA_PEM) + context.verify_mode = ssl.CERT_REQUIRED + + httpd = http.server.HTTPServer(server_address, http.server.SimpleHTTPRequestHandler) + httpd.socket = context.wrap_socket(httpd.socket, server_side=True) + + return httpd + + @classmethod + def setUpClass(cls): + """Set up the test environment: generate certs and start the server.""" + print("--- Setting up test environment ---") + + # 1. Create the v3.ext files and generate all certificates + print("Generating certificates...") + + with open(CA_V3_EXT_FILE, "w") as f: + f.write("subjectKeyIdentifier=hash\n") + f.write("authorityKeyIdentifier=keyid:always,issuer\n") + f.write("basicConstraints = critical,CA:TRUE\n") + f.write("keyUsage = critical,digitalSignature,cRLSign,keyCertSign\n") + + with open(SERVER_V3_EXT_FILE, "w") as f: + f.write("authorityKeyIdentifier=keyid,issuer\n") + f.write("basicConstraints=CA:FALSE\n") + f.write("subjectAltName = @alt_names\n\n[alt_names]\n") + f.write(f"DNS.1 = {HOST}\nIP.1 = {IP_ADDRESS}\n") + + run_command(['openssl', 'genrsa', '-out', ROOT_CA_KEY, '4096']) + run_command( + [ + 'openssl', + 'req', + '-new', + '-key', + ROOT_CA_KEY, + '-out', + ROOT_CA_CSR, + '-subj', + '/C=ZZ/O=Test/CN=Test Root CA', + ] + ) + run_command( + [ + 'openssl', + 'x509', + '-req', + '-in', + ROOT_CA_CSR, + '-signkey', + ROOT_CA_KEY, + '-out', + ROOT_CA_PEM, + '-days', + '31', + '-sha512', + '-extfile', + CA_V3_EXT_FILE, + ] + ) + + run_command(['openssl', 'genrsa', '-out', SERVER_KEY, '2048']) + run_command( + [ + 'openssl', + 'req', + '-new', + '-key', + SERVER_KEY, + '-out', + SERVER_CSR, + '-subj', + f'/C=ZZ/O=Test/CN={HOST}', + ] + ) + run_command( + [ + 'openssl', + 'x509', + '-req', + '-in', + SERVER_CSR, + '-CA', + ROOT_CA_PEM, + '-CAkey', + ROOT_CA_KEY, + '-CAcreateserial', + '-out', + SERVER_CERT, + '-days', + '7', + '-sha512', + '-extfile', + SERVER_V3_EXT_FILE, + ] + ) + run_command(['openssl', 'genrsa', '-out', CLIENT_KEY, '2048']) + run_command( + [ + 'openssl', + 'req', + '-new', + '-key', + CLIENT_KEY, + '-out', + CLIENT_CSR, + '-subj', + '/C=ZZ/O=Test/CN=Test Client', + ] + ) + run_command( + [ + 'openssl', + 'x509', + '-req', + '-in', + CLIENT_CSR, + '-CA', + ROOT_CA_PEM, + '-CAkey', + ROOT_CA_KEY, + '-CAcreateserial', + '-out', + CLIENT_CERT, + '-days', + '7', + '-sha512', + ] + ) + run_command( + [ + 'openssl', + 'pkcs12', + '-export', + '-out', + CLIENT_P12_NO_PWD, + '-inkey', + CLIENT_KEY, + '-in', + CLIENT_CERT, + '-passout', + 'pass:', + ] + ) + run_command( + [ + 'openssl', + 'pkcs12', + '-export', + '-out', + CLIENT_P12_WITH_PWD, + '-inkey', + CLIENT_KEY, + '-in', + CLIENT_CERT, + '-passout', + f'pass:{P12_PASSWORD}', + ] + ) + print("Certificates generated successfully.") + + # 2. Start the embedded mTLS server in a background thread + print(f"Starting embedded server on https://{IP_ADDRESS}:{PORT}") + cls.httpd = cls._start_embedded_server() + + cls.server_thread = threading.Thread(target=cls.httpd.serve_forever) + cls.server_thread.daemon = ( + True # Allows main thread to exit even if server thread is running + ) + cls.server_thread.start() + + print("Server is running in a background thread.") + + if hasattr(urllib3.util, 'IS_SECURETRANSPORT'): + print(f"urllib3 version {urllib3.__version__} has IS_SECURETRANSPORT.") + print("Forcing to True to pass IP as server_hostname.") + urllib3.util.ssl_.IS_SECURETRANSPORT = True + + @classmethod + def tearDownClass(cls): + """Clean up the environment: stop the server and delete files.""" + print("\n--- Tearing down test environment ---") + if cls.httpd: + print("Shutting down embedded server...") + cls.httpd.shutdown() + cls.server_thread.join() + print("Server stopped.") + + print("Cleaning up generated files...") + for f in GENERATED_FILES: + try: + os.remove(f) + except FileNotFoundError: + pass + print("Cleanup complete.") + + def test_requests_pem_cert_with_hostname(self): + """Tests connection to localhost using PEM certificate and key.""" + url = f"https://{HOST}:{PORT}" + + response = requests.get(url, cert=(CLIENT_CERT, CLIENT_KEY), verify=ROOT_CA_PEM, timeout=10) + self.assertEqual(response.status_code, 200) + + def test_requests_pem_cert_with_ip(self): + """Tests connection to 127.0.0.1 using PEM certificate and key.""" + url = f"https://{IP_ADDRESS}:{PORT}" + + response = requests.get(url, cert=(CLIENT_CERT, CLIENT_KEY), verify=ROOT_CA_PEM, timeout=10) + self.assertEqual(response.status_code, 200) + + def test_requests_nocert_with_hostname(self): + """Tests connection to localhost without client certificate.""" + url = f"https://{HOST}:{PORT}" + + with self.assertRaises(requests.exceptions.SSLError) as cm: + requests.get(url, verify=ROOT_CA_PEM, timeout=10) + + exc = cm.exception + self.assertIn("alert certificate required", str(exc)) + + def test_requests_nocert_with_ip(self): + """Tests connection to localhost without client certificate.""" + url = f"https://{IP_ADDRESS}:{PORT}" + + with self.assertRaises(requests.exceptions.SSLError) as cm: + requests.get(url, verify=ROOT_CA_PEM, timeout=10) + + exc = cm.exception + self.assertIn("alert certificate required", str(exc)) + + def test_requests_pkcs12_with_password_and_hostname(self): + """Tests connection using a password-protected PKCS12 file.""" + url = f"https://{HOST}:{PORT}" + + response = requests_pkcs12.get( + url, + pkcs12_filename=CLIENT_P12_WITH_PWD, + pkcs12_password=P12_PASSWORD, + verify=ROOT_CA_PEM, + timeout=10, + ) + self.assertEqual(response.status_code, 200) + + def test_requests_pkcs12_with_password_and_ip(self): + """Tests connection using a password-protected PKCS12 file.""" + url = f"https://{IP_ADDRESS}:{PORT}" + + response = requests_pkcs12.get( + url, + pkcs12_filename=CLIENT_P12_WITH_PWD, + pkcs12_password=P12_PASSWORD, + verify=ROOT_CA_PEM, + timeout=10, + ) + self.assertEqual(response.status_code, 200) + + def test_requests_pkcs12_without_password_and_hostname(self): + """Tests connection using a PKCS12 file with an empty password.""" + url = f"https://{HOST}:{PORT}" + + response = requests_pkcs12.get( + url, + pkcs12_filename=CLIENT_P12_NO_PWD, + pkcs12_password="", + verify=ROOT_CA_PEM, + timeout=10, + ) + self.assertEqual(response.status_code, 200) + + def test_requests_pkcs12_without_password_and_ip(self): + """Tests connection using a PKCS12 file with an empty password.""" + url = f"https://{IP_ADDRESS}:{PORT}" + + response = requests_pkcs12.get( + url, + pkcs12_filename=CLIENT_P12_NO_PWD, + pkcs12_password="", + verify=ROOT_CA_PEM, + timeout=10, + ) + self.assertEqual(response.status_code, 200) + + def test_requests_pkcs12_with_password_none_and_hostname(self): + """Tests connection using a PKCS12 file with None as password.""" + url = f"https://{HOST}:{PORT}" + + response = requests_pkcs12.get( + url, + pkcs12_filename=CLIENT_P12_NO_PWD, + pkcs12_password=None, + verify=ROOT_CA_PEM, + timeout=10, + ) + self.assertEqual(response.status_code, 200) + + def test_requests_pkcs12_with_password_none_and_ip(self): + """Tests connection using a PKCS12 file with None as password.""" + url = f"https://{IP_ADDRESS}:{PORT}" + + response = requests_pkcs12.get( + url, + pkcs12_filename=CLIENT_P12_NO_PWD, + pkcs12_password=None, + verify=ROOT_CA_PEM, + timeout=10, + ) + self.assertEqual(response.status_code, 200) + + def test_requests_pkcs12_without_cert_parameters_and_hostname(self): + """Tests requests_pkcs12 connection without PKCS12 file.""" + url = f"https://{HOST}:{PORT}" + + with self.assertRaises(requests.exceptions.SSLError) as cm: + requests_pkcs12.get(url, verify=ROOT_CA_PEM, timeout=10) + + exc = cm.exception + self.assertIn("alert certificate required", str(exc)) + + def test_requests_pkcs12_without_cert_parameters_and_ip(self): + """Tests requests_pkcs12 connection without PKCS12 file.""" + url = f"https://{IP_ADDRESS}:{PORT}" + + with self.assertRaises(requests.exceptions.SSLError) as cm: + requests_pkcs12.get(url, verify=ROOT_CA_PEM, timeout=10) + + exc = cm.exception + self.assertIn("alert certificate required", str(exc)) + + def test_pkcs12_adapter_hostname(self): + """Tests connection using Pkcs12Adapter with PKCS12 file and password.""" + url = f"https://{HOST}:{PORT}" + client = requests.Session() + client.mount( + url, + requests_pkcs12.Pkcs12Adapter( + pkcs12_filename=CLIENT_P12_WITH_PWD, pkcs12_password=P12_PASSWORD + ), + ) + response = client.get(url, verify=ROOT_CA_PEM, timeout=10) + self.assertEqual(response.status_code, 200) + + def test_pkcs12_adapter_ip(self): + """Tests connection using Pkcs12Adapter with PKCS12 file and password.""" + url = f"https://{IP_ADDRESS}:{PORT}" + client = requests.Session() + client.mount( + url, + requests_pkcs12.Pkcs12Adapter( + pkcs12_filename=CLIENT_P12_WITH_PWD, pkcs12_password=P12_PASSWORD + ), + ) + response = client.get(url, verify=ROOT_CA_PEM, timeout=10) + self.assertEqual(response.status_code, 200) + + +if __name__ == '__main__': + unittest.main()