From 4c0174356ce5971dbd0876c41e720346fb045bef Mon Sep 17 00:00:00 2001 From: Daniel Alley Date: Tue, 11 Aug 2026 15:26:30 -0400 Subject: [PATCH 1/3] Add PQC (ML-DSA) certificate test coverage Test scenarios added: Certguard (parameterized over ML-DSA-65 and ML-DSA-87): - Download with a valid PQC client cert returns 200 - Download with an untrusted PQC client cert returns 403 - Download with no client cert returns 403 Remote sync (ML-DSA-65): - on_demand sync over HTTPS with PQC server certificate (TLS validation) - on_demand sync over HTTPS with PQC mutual TLS (client cert required) Static PQC cert/key assets and a generation script are included for both ML-DSA-65 and ML-DSA-87. Certs are generated using pycryptography's x509 builder API. Assisted-By: Claude Opus 4.6 --- functest_requirements.txt | 1 + .../functional/api/test_x509_certguard.py | 78 +++++++ pulp_certguard/tests/functional/constants.py | 1 - pulp_file/pytest_plugin.py | 58 ++++++ .../functional/api/test_remote_settings.py | 46 +++++ pulpcore/pytest_plugin.py | 194 ++++++++++++++++++ template_config.yml | 3 + 7 files changed, 380 insertions(+), 1 deletion(-) diff --git a/functest_requirements.txt b/functest_requirements.txt index c8bb7e387ce..1a919656eab 100644 --- a/functest_requirements.txt +++ b/functest_requirements.txt @@ -4,6 +4,7 @@ pytest-xdist python-gnupg proxy.py~=2.4.10 trustme~=1.2.1 +cryptography>=49.0 # pulp_file tests beautifulsoup4 diff --git a/pulp_certguard/tests/functional/api/test_x509_certguard.py b/pulp_certguard/tests/functional/api/test_x509_certguard.py index bff81b03f67..3fad8a75109 100644 --- a/pulp_certguard/tests/functional/api/test_x509_certguard.py +++ b/pulp_certguard/tests/functional/api/test_x509_certguard.py @@ -1,4 +1,5 @@ import uuid +from collections import namedtuple from urllib.parse import quote, urljoin import pytest @@ -12,6 +13,11 @@ X509_UNTRUSTED_CLIENT_CERT_FILE_PATH, ) +PQCGuardedDistribution = namedtuple( + "PQCGuardedDistribution", + ["distribution", "algorithm", "client_cert_pem", "untrusted_client_cert_pem"], +) + @pytest.fixture(scope="class") def x509_certguard_factory(x509_content_guards_api_client, gen_object_with_cleanup): @@ -42,6 +48,31 @@ def x509_guarded_distribution( return distribution +@pytest.fixture(scope="class") +def pqc_guarded_distribution( + pqc_certificate_authority, + x509_content_guards_api_client, + gen_object_with_cleanup, + file_distribution_factory, + repository_test_file, +): + pca = pqc_certificate_authority + content_guard = gen_object_with_cleanup( + x509_content_guards_api_client, + { + "name": str(uuid.uuid4()), + "ca_certificate": pca.ca_cert_pem, + }, + ) + distribution = file_distribution_factory( + repository=repository_test_file.pulp_href, + content_guard=content_guard.pulp_href, + ) + return PQCGuardedDistribution( + distribution, pca.algorithm, pca.client_cert_pem, pca.untrusted_client_cert_pem + ) + + @pytest.fixture( scope="module", params=[ @@ -92,3 +123,50 @@ def test_download( headers=cert_data and {"X-CLIENT-CERT": cert_data}, ) assert response.status_code == status_code + + +class TestPQCX509CertGuard: + """Test X.509 content guard with PQC (ML-DSA) certificates. + + Parameterized over ML-DSA-65 (~7.5KB, under the default 8190-byte header + limit) and ML-DSA-87 (~10KB, over the limit). + """ + + def test_download_with_valid_cert( + self, + pqc_guarded_distribution, + distribution_base_url, + ): + distribution = pqc_guarded_distribution.distribution + url = distribution_base_url(distribution.base_url) + cert_pem = quote(pqc_guarded_distribution.client_cert_pem) + response = requests.get( + urljoin(url, "test_file"), + headers={"X-CLIENT-CERT": cert_pem}, + allow_redirects=False, + ) + assert response.status_code in (200, 302) + + def test_download_with_untrusted_cert( + self, + pqc_guarded_distribution, + distribution_base_url, + ): + distribution = pqc_guarded_distribution.distribution + url = distribution_base_url(distribution.base_url) + cert_pem = quote(pqc_guarded_distribution.untrusted_client_cert_pem) + response = requests.get( + urljoin(url, "test_file"), + headers={"X-CLIENT-CERT": cert_pem}, + ) + assert response.status_code == 403 + + def test_download_with_no_cert( + self, + pqc_guarded_distribution, + distribution_base_url, + ): + distribution = pqc_guarded_distribution.distribution + url = distribution_base_url(distribution.base_url) + response = requests.get(urljoin(url, "test_file")) + assert response.status_code == 403 diff --git a/pulp_certguard/tests/functional/constants.py b/pulp_certguard/tests/functional/constants.py index f481b1adb87..f0d04b2658a 100644 --- a/pulp_certguard/tests/functional/constants.py +++ b/pulp_certguard/tests/functional/constants.py @@ -14,7 +14,6 @@ X509_CERTS_BASE_PATH, "un_urlencoded_cert.txt" ) - RHSM_CA_CERT_FILE_PATH = os.path.join(_CURRENT_DIR, "artifacts", "rhsm", "katello-default-ca.crt") RHSM_CLIENT_CERT_FROM_UNTRUSTED_CA = os.path.join( diff --git a/pulp_file/pytest_plugin.py b/pulp_file/pytest_plugin.py index 62c83df846b..baa9f850830 100644 --- a/pulp_file/pytest_plugin.py +++ b/pulp_file/pytest_plugin.py @@ -255,6 +255,64 @@ def _file_remote_client_cert_req_factory(*, manifest_path, policy, **kwargs): return _file_remote_client_cert_req_factory +@pytest.fixture(scope="class") +def file_fixture_server_pqc_ssl(pqc_ssl_ctx, file_fixtures_root, gen_fixture_server): + return gen_fixture_server(file_fixtures_root, pqc_ssl_ctx) + + +@pytest.fixture(scope="class") +def file_fixture_server_pqc_ssl_client_cert_req( + pqc_ssl_ctx_req_client_auth, file_fixtures_root, gen_fixture_server +): + return gen_fixture_server(file_fixtures_root, pqc_ssl_ctx_req_client_auth) + + +@pytest.fixture(scope="class") +def file_remote_pqc_ssl_factory( + file_fixture_server_pqc_ssl, + file_bindings, + pqc_certificate_authority, + gen_object_with_cleanup, +): + def _file_remote_pqc_ssl_factory(*, manifest_path, policy, **kwargs): + url = file_fixture_server_pqc_ssl.make_url(manifest_path) + kwargs.update( + { + "url": str(url), + "policy": policy, + "name": str(uuid.uuid4()), + "ca_cert": pqc_certificate_authority.ca_cert_pem, + } + ) + return gen_object_with_cleanup(file_bindings.RemotesFileApi, kwargs) + + return _file_remote_pqc_ssl_factory + + +@pytest.fixture(scope="class") +def file_remote_pqc_client_cert_req_factory( + file_fixture_server_pqc_ssl_client_cert_req, + file_bindings, + pqc_certificate_authority, + gen_object_with_cleanup, +): + def _file_remote_pqc_client_cert_req_factory(*, manifest_path, policy, **kwargs): + url = file_fixture_server_pqc_ssl_client_cert_req.make_url(manifest_path) + kwargs.update( + { + "url": str(url), + "policy": policy, + "name": str(uuid.uuid4()), + "ca_cert": pqc_certificate_authority.ca_cert_pem, + "client_cert": pqc_certificate_authority.client_cert_pem, + "client_key": pqc_certificate_authority.client_key_pem, + } + ) + return gen_object_with_cleanup(file_bindings.RemotesFileApi, kwargs) + + return _file_remote_pqc_client_cert_req_factory + + @pytest.fixture(scope="class") def file_repository_factory(file_bindings, gen_object_with_cleanup): """A factory to generate a File Repository with auto-deletion after the test run.""" diff --git a/pulp_file/tests/functional/api/test_remote_settings.py b/pulp_file/tests/functional/api/test_remote_settings.py index 28fd169301d..e2c9893c57b 100644 --- a/pulp_file/tests/functional/api/test_remote_settings.py +++ b/pulp_file/tests/functional/api/test_remote_settings.py @@ -217,6 +217,52 @@ def test_http_sync_ssl_with_client_cert_req( ) +@pytest.mark.parallel +def test_http_sync_pqc_ssl_tls_validation_on( + file_bindings, + file_remote_pqc_ssl_factory, + file_repo, + basic_manifest_path, + monitor_task, +): + """ + Test file on_demand sync with https:// using PQC (ML-DSA-65) server certificate. + """ + remote_on_demand = file_remote_pqc_ssl_factory( + manifest_path=basic_manifest_path, policy="on_demand", tls_validation=True + ) + + _run_basic_sync_and_assert( + file_bindings, + remote_on_demand, + file_repo, + monitor_task, + ) + + +@pytest.mark.parallel +def test_http_sync_pqc_ssl_with_client_cert_req( + file_bindings, + file_remote_pqc_client_cert_req_factory, + file_repo, + basic_manifest_path, + monitor_task, +): + """ + Test file on_demand sync with https:// using PQC (ML-DSA-65) mutual TLS authentication. + """ + remote_on_demand = file_remote_pqc_client_cert_req_factory( + manifest_path=basic_manifest_path, policy="on_demand" + ) + + _run_basic_sync_and_assert( + file_bindings, + remote_on_demand, + file_repo, + monitor_task, + ) + + @pytest.mark.parallel def test_ondemand_to_immediate_sync( file_bindings, diff --git a/pulpcore/pytest_plugin.py b/pulpcore/pytest_plugin.py index cdb9b619fd4..f5eff645105 100644 --- a/pulpcore/pytest_plugin.py +++ b/pulpcore/pytest_plugin.py @@ -475,6 +475,200 @@ def ssl_ctx_req_client_auth( return ssl_ctx +# PQC (Post-Quantum Cryptography) TLS Fixtures + +# TODO pretty much all of this can be deleted when "trustme" gets ML-DSA support + + +@dataclass +class PQCCertificateAuthority: + algorithm: str + ca_cert_pem: str + server_cert_path: str + server_key_path: str + client_cert_pem: str + client_key_pem: str + untrusted_client_cert_pem: str + + +_PQC_KEY_CLASSES = { + "ML-DSA-44": "MLDSA44PrivateKey", + "ML-DSA-65": "MLDSA65PrivateKey", + "ML-DSA-87": "MLDSA87PrivateKey", +} + + +def _generate_pqc_certs(td, host, algorithm="ML-DSA-65"): + """Generate a PQC CA and issue server + client certs signed by it.""" + import datetime + import ipaddress + + from cryptography import x509 + from cryptography.hazmat.primitives.asymmetric import mldsa + from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat + from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + + key_cls = getattr(mldsa, _PQC_KEY_CLASSES[algorithm]) + now = datetime.datetime.now(datetime.timezone.utc) + not_before = now - datetime.timedelta(minutes=1) + not_after = now + datetime.timedelta(days=1) + + ca_key = key_cls.generate() + ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "PQC Test CA")]) + ca_cert = ( + x509.CertificateBuilder() + .subject_name(ca_name) + .issuer_name(ca_name) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_before) + .not_valid_after(not_after) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=True, + crl_sign=True, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .sign(private_key=ca_key, algorithm=None) + ) + + san = x509.SubjectAlternativeName( + [x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address(host))] + ) + + def _issue_cert(name, cn, eku_oid): + key = key_cls.generate() + cert = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])) + .issuer_name(ca_name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_before) + .not_valid_after(not_after) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension(x509.ExtendedKeyUsage([eku_oid]), critical=False) + .add_extension(san, critical=False) + .sign(private_key=ca_key, algorithm=None) + ) + cert_pem = cert.public_bytes(Encoding.PEM).decode() + key_pem = key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()).decode() + cert_path = str(td / f"{name}.crt") + key_path = str(td / f"{name}.key") + with open(cert_path, "w") as f: + f.write(cert_pem) + with open(key_path, "w") as f: + f.write(key_pem) + return cert_path, key_path, cert_pem, key_pem + + ca_cert_pem = ca_cert.public_bytes(Encoding.PEM).decode() + + server_cert_path, server_key_path, _, _ = _issue_cert( + "server", host, ExtendedKeyUsageOID.SERVER_AUTH + ) + _, _, client_cert_pem, client_key_pem = _issue_cert( + "client", host, ExtendedKeyUsageOID.CLIENT_AUTH + ) + + # Untrusted client: signed by a different CA so cert-chain verification rejects it + untrusted_ca_key = key_cls.generate() + untrusted_ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "PQC Untrusted CA")]) + untrusted_key = key_cls.generate() + untrusted_cert = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "PQC Untrusted Client")])) + .issuer_name(untrusted_ca_name) + .public_key(untrusted_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_before) + .not_valid_after(not_after) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .sign(private_key=untrusted_ca_key, algorithm=None) + ) + untrusted_client_cert_pem = untrusted_cert.public_bytes(Encoding.PEM).decode() + + return PQCCertificateAuthority( + algorithm=algorithm, + ca_cert_pem=ca_cert_pem, + server_cert_path=server_cert_path, + server_key_path=server_key_path, + client_cert_pem=client_cert_pem, + client_key_pem=client_key_pem, + untrusted_client_cert_pem=untrusted_client_cert_pem, + ) + + +@pytest.fixture(scope="session", params=["ML-DSA-65", "ML-DSA-87"]) +def pqc_certificate_authority(request, tmp_path_factory, fixtures_cfg): + """Generate a PQC Certificate Authority and related certs for each ML-DSA variant.""" + algorithm = request.param + td = tmp_path_factory.mktemp(f"pqc_certs_{algorithm}") + return _generate_pqc_certs(td, fixtures_cfg.aiohttp_fixtures_origin, algorithm) + + +@pytest.fixture(scope="session") +def pqc_ssl_ctx(pqc_certificate_authority): + """Server SSL context using PQC certificates (no client auth required).""" + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_ctx.load_cert_chain( + pqc_certificate_authority.server_cert_path, + pqc_certificate_authority.server_key_path, + ) + return ssl_ctx + + +@pytest.fixture(scope="session") +def pqc_ssl_ctx_req_client_auth(pqc_certificate_authority): + """Server SSL context using PQC certificates, requiring PQC client auth.""" + ssl_ctx = ssl.create_default_context( + purpose=ssl.Purpose.CLIENT_AUTH, + cadata=pqc_certificate_authority.ca_cert_pem, + ) + ssl_ctx.verify_mode = ssl.CERT_REQUIRED + ssl_ctx.load_cert_chain( + pqc_certificate_authority.server_cert_path, + pqc_certificate_authority.server_key_path, + ) + return ssl_ctx + + # Object factories diff --git a/template_config.yml b/template_config.yml index 9065361f022..ae85df2bdea 100644 --- a/template_config.yml +++ b/template_config.yml @@ -6,6 +6,9 @@ # After editing this file please always reapply the plugin template before committing any changes. --- +# NOTE: the S3 runner has redis disabled, domains enabled, and hide_guarded_distributions = true +# "weird" test failures unique to that runner may be due to those differences. + check_commit_message: true check_manifest: true check_stray_pulpcore_imports: false From 5897f9375e235086c4f132bd34f58d27a714a1e0 Mon Sep 17 00:00:00 2001 From: Daniel Alley Date: Tue, 11 Aug 2026 16:05:59 -0400 Subject: [PATCH 2/3] Increase content app max HTTP header field size for PQC certificates Post-quantum (ML-DSA) X.509 certificates are significantly larger than traditional RSA/ECDSA certificates. When a reverse proxy forwards a PQC client certificate via the X-CLIENT-CERT header, it can exceed aiohttp's default 8190-byte max_field_size, causing a 400 LineTooLong error. Increase the limit to 16KB. Assisted-By: Claude Opus 4.6 --- CHANGES/+header-too-large.bugfix | 1 + pulpcore/content/__init__.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 CHANGES/+header-too-large.bugfix diff --git a/CHANGES/+header-too-large.bugfix b/CHANGES/+header-too-large.bugfix new file mode 100644 index 00000000000..a5106eec27e --- /dev/null +++ b/CHANGES/+header-too-large.bugfix @@ -0,0 +1 @@ +Increased the content app's maximum HTTP header field size from 8190 to 16384 bytes to support PQC (post-quantum) X.509 certificates forwarded via the `X-CLIENT-CERT` header. diff --git a/pulpcore/content/__init__.py b/pulpcore/content/__init__.py index decf0957b0d..26d5200de45 100644 --- a/pulpcore/content/__init__.py +++ b/pulpcore/content/__init__.py @@ -29,12 +29,18 @@ log = logging.getLogger(__name__) +# PQC (post-quantum) X.509 certificates can exceed aiohttp's default 8190-byte header +# limit when forwarded via X-CLIENT-CERT by a reverse proxy. +_HANDLER_ARGS = {"max_field_size": 16 * 1024} + if settings.OTEL_ENABLED: from .instrumentation import instrumentation # noqa: E402 - app = web.Application(middlewares=[guid, authenticate, instrumentation()]) + app = web.Application( + middlewares=[guid, authenticate, instrumentation()], handler_args=_HANDLER_ARGS + ) else: - app = web.Application(middlewares=[guid, authenticate]) + app = web.Application(middlewares=[guid, authenticate], handler_args=_HANDLER_ARGS) if settings.UVLOOP_ENABLED: From 29e79a8d29d0fe8fb6bd75cb659ef4f11d5a64d1 Mon Sep 17 00:00:00 2001 From: Daniel Alley Date: Wed, 12 Aug 2026 00:45:42 -0400 Subject: [PATCH 3/3] Bump pycryptography, pyOpenSSL versions We want the lower bound to have PQC support. --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 44a8008a74b..631c6cb0752 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "asyncio-throttle>=1.0,<=1.0.2", # Unsure about versioning, but not released often anyway. "backoff>=2.1.2,<2.3", # Looks like only bugfixes in z-Stream. "click>=8.1.0,<8.5", # Uses milestone.feature.fix https://palletsprojects.com/versions . - "cryptography>=44.0.3,<51.0", # SemVer compatible https://cryptography.io/en/latest/api-stability/#versioning . + "cryptography>=49.0.0,<51.0", # SemVer compatible https://cryptography.io/en/latest/api-stability/#versioning . "Django>=5.2.0,<5.3", # LTS version, we aim at supporting one or two at a time. "django-filter>=24.3,<=26.1", # Uses CalVer, not released often https://github.com/carltongibson/django-filter "django-guid>=3.4.0,<3.7", # Looks like only bugfixes in z-Stream. @@ -47,12 +47,12 @@ dependencies = [ "jinja2>=3.1,<=3.1.6", "json_stream>=2.3.2,<2.6", "jq>=1.6.0,<1.13.0", - "PyOpenSSL<27.0", "opentelemetry-api>=1.27.0,<1.45", "opentelemetry-sdk>=1.27.0,<1.45", "opentelemetry-exporter-otlp-proto-http>=1.27.0,<1.45", "protobuf>=4.21.1,<8.0", "pulp-glue>=0.35.0,<0.41", + "PyOpenSSL>=26.3.0,<27.0", "pygtrie>=2.5,<=2.5.0", "psycopg[binary]>=3.3.4,<3.4", # SemVer, not explicitely stated, but mentioned on multiple changes. "pyparsing>=3.1.0,<3.4", # Looks like only bugfixes in z-Stream.