chore(security): pin TLSv1.2 floor on control-plane mTLS clients

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).
This commit is contained in:
2026-06-12 19:06:50 -04:00
parent efe4e49de6
commit 408810b3e2
4 changed files with 36 additions and 0 deletions

View File

@@ -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)
)

View File

@@ -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),
)

View File

@@ -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

View File

@@ -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()