From 408810b3e230d0c5123c27889f573191ac308cc8 Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 12 Jun 2026 19:06:50 -0400 Subject: [PATCH] chore(security): pin TLSv1.2 floor on control-plane mTLS clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to V9.1.4 (which covered only the syslog forwarder/listener): set ctx.minimum_version = TLSVersion.TLSv1_2 on the remaining DECNET-owned mTLS client contexts — AgentClient (_build_client + _fetch_peer_fingerprint), UpdaterClient (_build_client + _fetch_peer_fingerprint), and the updater executor's worker context. Pure hardening, no behavior change for TLS1.2+ peers (confirmed by the existing mTLS round-trip suites). Deliberately EXCLUDED — hardening these would be counterproductive: - templates/https/server.py, templates/rdp/server.py: honeypot listeners, where looking weak/old is part of the deception. - prober/tlscert.py: outbound TLS fingerprinting prober, which must speak whatever the attacker's target offers. Added a floor-assertion test (spies httpx.AsyncClient to capture the real verify= context). --- decnet/swarm/client.py | 2 ++ decnet/swarm/updater_client.py | 2 ++ decnet/updater/executor.py | 1 + tests/updater/test_updater_client_pin.py | 31 ++++++++++++++++++++++++ 4 files changed, 36 insertions(+) diff --git a/decnet/swarm/client.py b/decnet/swarm/client.py index b4351af7..952a79f5 100644 --- a/decnet/swarm/client.py +++ b/decnet/swarm/client.py @@ -150,6 +150,7 @@ class AgentClient: # purpose/ALPN/default-CA logic that doesn't compose with private-CA # mTLS in all combinations. A bare SSLContext is predictable. ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 ctx.load_cert_chain( str(self._identity.cert_path), str(self._identity.key_path) ) @@ -168,6 +169,7 @@ class AgentClient: def _fetch_peer_fingerprint(self) -> str: ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 ctx.load_cert_chain( str(self._identity.cert_path), str(self._identity.key_path) ) diff --git a/decnet/swarm/updater_client.py b/decnet/swarm/updater_client.py index 47b3f8bc..977364a1 100644 --- a/decnet/swarm/updater_client.py +++ b/decnet/swarm/updater_client.py @@ -72,6 +72,7 @@ class UpdaterClient: def _build_client(self, timeout: httpx.Timeout) -> httpx.AsyncClient: ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 ctx.load_cert_chain( str(self._identity.cert_path), str(self._identity.key_path), ) @@ -89,6 +90,7 @@ class UpdaterClient: SHA-256 hex of the leaf cert it presents. Mirrors ``AgentClient._fetch_peer_fingerprint`` exactly.""" ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 ctx.load_cert_chain( str(self._identity.cert_path), str(self._identity.key_path), ) diff --git a/decnet/updater/executor.py b/decnet/updater/executor.py index 0576b658..b10da5c3 100644 --- a/decnet/updater/executor.py +++ b/decnet/updater/executor.py @@ -519,6 +519,7 @@ def _probe_agent( if not (worker_key.is_file() and worker_crt.is_file() and ca.is_file()): return False, f"no mTLS bundle at {agent_dir}" ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 ctx.load_cert_chain(certfile=str(worker_crt), keyfile=str(worker_key)) ctx.load_verify_locations(cafile=str(ca)) ctx.verify_mode = ssl.CERT_REQUIRED diff --git a/tests/updater/test_updater_client_pin.py b/tests/updater/test_updater_client_pin.py index 24a60240..70a1701d 100644 --- a/tests/updater/test_updater_client_pin.py +++ b/tests/updater/test_updater_client_pin.py @@ -192,3 +192,34 @@ async def test_build_client_constructs_with_flag(updater_env) -> None: assert isinstance(built, httpx.AsyncClient) assert c._verify_hostname is flag await built.aclose() + + +@pytest.mark.asyncio +async def test_build_client_pins_tls12_floor(updater_env, monkeypatch) -> None: + """V9.1.4 sweep: the updater mTLS client context pins a TLS 1.2 floor. + + The context is embedded in _build_client (passed to httpx as verify=), so + spy on httpx.AsyncClient to capture the real context and assert the floor. + Spying on httpx (not ssl) leaves the genuine SSLContext setter intact. + """ + import ssl as _ssl + import httpx + from decnet.swarm import updater_client as uc + + captured: dict[str, object] = {} + real_client = httpx.AsyncClient + + def _spy(*args, **kwargs): # type: ignore[no-untyped-def] + captured["verify"] = kwargs.get("verify") + return real_client(*args, **kwargs) + + monkeypatch.setattr(uc.httpx, "AsyncClient", _spy) + _worker_dir, port, master_id = updater_env + c = UpdaterClient(address="127.0.0.1", updater_port=port, identity=master_id) + built = c._build_client(httpx.Timeout(5.0)) + try: + ctx = captured.get("verify") + assert isinstance(ctx, _ssl.SSLContext), "context not passed to httpx" + assert ctx.minimum_version == _ssl.TLSVersion.TLSv1_2 + finally: + await built.aclose()