Auth (V2.1.1/V3.1.2, V2.1.3, V3.1.1): - Pin JWT iss/aud/typ at mint and require+verify them at decode; revocation (jti denylist + tokens_valid_from) still enforced. - Change-password now requires min_length=12. - SSE auth moves off JWT-in-URL to a single-use 60s opaque ticket (POST /auth/sse-ticket); raw JWT in query no longer authenticates a stream. Removed dead fail-open get_stream_user helper. Egress (V5.1.1, V9.1.1/V14.1.3): - Webhook delivery + CRUD reject SSRF destinations (private/loopback/link-local/ metadata, IPv4-mapped, multi-A-record) via resolved-IP validation, pin to the vetted IP, and never auto-follow redirects. Opt-out via DECNET_WEBHOOK_ALLOW_PRIVATE. - UpdaterClient pins the worker leaf cert SHA-256 against the stored per-host fingerprint (fail closed on missing/mismatch); DECNET_VERIFY_HOSTNAME now defaults True. Hardening (V13.1.3, V4.1.4, V13.1.2): - Rate-limit change-password (5/min), enroll-bundle (10/min), webhook-create (20/min), host-delete (20/min) via the existing slowapi limiter. - Correct false 'global auth middleware' comment; document enroll-bundle proxy trust. Correctness (BUG-7..11): - BUG-7 unbound bus in finally; BUG-8 apply_ceiling clamps to min(base,ceiling); BUG-9 commit before emit; BUG-10 multi-actor rearm for sub-threshold identities; BUG-11 normalize naive timestamps to UTC. Already-closed (no change): V14.1.1, V2.1.2/V3.1.3, V5.1.2. Tests added for every fix; unanimous adversarial review.
195 lines
6.3 KiB
Python
195 lines
6.3 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
"""UpdaterClient SHA-256 leaf-cert pinning (master->worker updater channel).
|
|
|
|
The updater channel pip-installs code as root, so it pins the worker's
|
|
updater leaf cert against ``SwarmHost.updater_cert_fingerprint`` and fails
|
|
closed on mismatch OR a missing recorded fingerprint.
|
|
|
|
We don't need the real updater ASGI app: ``UpdaterClient.__aenter__`` runs
|
|
``_verify_pin`` which opens its own throwaway TLS connection to extract the
|
|
peer leaf cert before any RPC. A minimal threaded mTLS socket server that
|
|
simply completes the handshake is enough to exercise the pin.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pathlib
|
|
import socket
|
|
import ssl
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from decnet.swarm import client as swarm_client
|
|
from decnet.swarm import pki
|
|
from decnet.swarm.updater_client import UpdaterClient
|
|
|
|
|
|
def _free_port() -> int:
|
|
s = socket.socket()
|
|
s.bind(("127.0.0.1", 0))
|
|
port = s.getsockname()[1]
|
|
s.close()
|
|
return port
|
|
|
|
|
|
class _MiniTLSServer:
|
|
"""Threaded mTLS server that accepts a connection, completes the
|
|
handshake (presenting the worker leaf cert), then closes."""
|
|
|
|
def __init__(self, worker_dir: pathlib.Path, port: int) -> None:
|
|
self._ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
self._ctx.load_cert_chain(
|
|
str(worker_dir / "worker.crt"), str(worker_dir / "worker.key")
|
|
)
|
|
self._ctx.load_verify_locations(cafile=str(worker_dir / "ca.crt"))
|
|
self._ctx.verify_mode = ssl.CERT_REQUIRED
|
|
self._sock = socket.socket()
|
|
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
self._sock.bind(("127.0.0.1", port))
|
|
self._sock.listen(8)
|
|
self._sock.settimeout(0.5)
|
|
self._stop = threading.Event()
|
|
self._thread = threading.Thread(target=self._serve, daemon=True)
|
|
|
|
def start(self) -> None:
|
|
self._thread.start()
|
|
|
|
def _serve(self) -> None:
|
|
while not self._stop.is_set():
|
|
try:
|
|
conn, _ = self._sock.accept()
|
|
except socket.timeout:
|
|
continue
|
|
except OSError:
|
|
break
|
|
try:
|
|
tls = self._ctx.wrap_socket(conn, server_side=True)
|
|
try:
|
|
tls.recv(64)
|
|
except OSError:
|
|
pass
|
|
tls.close()
|
|
except OSError:
|
|
try:
|
|
conn.close()
|
|
except OSError:
|
|
pass
|
|
|
|
def stop(self) -> None:
|
|
self._stop.set()
|
|
try:
|
|
self._sock.close()
|
|
except OSError:
|
|
pass
|
|
self._thread.join(timeout=5)
|
|
|
|
|
|
@pytest.fixture
|
|
def updater_env(tmp_path: pathlib.Path):
|
|
ca_dir = tmp_path / "ca"
|
|
pki.ensure_ca(ca_dir)
|
|
worker_dir = tmp_path / "updater"
|
|
pki.write_worker_bundle(
|
|
pki.issue_worker_cert(pki.load_ca(ca_dir), "updater-test", ["127.0.0.1"]),
|
|
worker_dir,
|
|
)
|
|
master_id = swarm_client.ensure_master_identity(ca_dir)
|
|
|
|
port = _free_port()
|
|
server = _MiniTLSServer(worker_dir, port)
|
|
server.start()
|
|
# Give the listener a moment.
|
|
time.sleep(0.1)
|
|
try:
|
|
yield worker_dir, port, master_id
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pin_accepts_matching_fingerprint(updater_env) -> None:
|
|
worker_dir, port, master_id = updater_env
|
|
expected = pki.fingerprint((worker_dir / "worker.crt").read_bytes())
|
|
host = {
|
|
"uuid": "h1",
|
|
"name": "updater-test",
|
|
"address": "127.0.0.1",
|
|
"updater_cert_fingerprint": expected,
|
|
}
|
|
async with UpdaterClient(
|
|
host=host, updater_port=port, identity=master_id
|
|
) as u:
|
|
# Entering the context already ran _verify_pin successfully.
|
|
assert u._expected_fingerprint == expected.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pin_rejects_mismatch(updater_env) -> None:
|
|
_worker_dir, port, master_id = updater_env
|
|
host = {
|
|
"uuid": "h1",
|
|
"name": "updater-test",
|
|
"address": "127.0.0.1",
|
|
"updater_cert_fingerprint": "0" * 64,
|
|
}
|
|
with pytest.raises(swarm_client.FingerprintMismatchError):
|
|
async with UpdaterClient(host=host, updater_port=port, identity=master_id):
|
|
pass
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pin_rejects_missing_fingerprint(updater_env) -> None:
|
|
"""Fail closed: a host with no recorded updater fingerprint is refused
|
|
(unlike AgentClient, the updater channel never falls through to CA-only)."""
|
|
_worker_dir, port, master_id = updater_env
|
|
host = {
|
|
"uuid": "h1",
|
|
"name": "updater-test",
|
|
"address": "127.0.0.1",
|
|
"updater_cert_fingerprint": None,
|
|
}
|
|
with pytest.raises(swarm_client.FingerprintMismatchError):
|
|
async with UpdaterClient(host=host, updater_port=port, identity=master_id):
|
|
pass
|
|
|
|
|
|
def test_verify_hostname_defaults_to_env_flag(monkeypatch) -> None:
|
|
"""The verify_hostname kwarg defaults to DECNET_VERIFY_HOSTNAME, which
|
|
now defaults to True (operators opt OUT explicitly)."""
|
|
import decnet.env as env
|
|
|
|
monkeypatch.setattr(env, "DECNET_VERIFY_HOSTNAME", True)
|
|
c_default = UpdaterClient(address="127.0.0.1", updater_port=9)
|
|
assert c_default._verify_hostname is True
|
|
|
|
monkeypatch.setattr(env, "DECNET_VERIFY_HOSTNAME", False)
|
|
c_off = UpdaterClient(address="127.0.0.1", updater_port=9)
|
|
assert c_off._verify_hostname is False
|
|
|
|
# Explicit kwarg overrides the env default.
|
|
c_explicit = UpdaterClient(
|
|
address="127.0.0.1", updater_port=9, verify_hostname=True
|
|
)
|
|
assert c_explicit._verify_hostname is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_client_constructs_with_flag(updater_env) -> None:
|
|
"""_build_client must construct a client for both flag values without
|
|
error; check_hostname is wired from self._verify_hostname (verified via
|
|
the live handshake in the pin tests above, which use verify_hostname
|
|
from the env default)."""
|
|
import httpx
|
|
|
|
_worker_dir, port, master_id = updater_env
|
|
for flag in (True, False):
|
|
c = UpdaterClient(
|
|
address="127.0.0.1", updater_port=port, identity=master_id,
|
|
verify_hostname=flag,
|
|
)
|
|
built = c._build_client(httpx.Timeout(5.0))
|
|
assert isinstance(built, httpx.AsyncClient)
|
|
assert c._verify_hostname is flag
|
|
await built.aclose()
|