diff --git a/CHANGELOG.md b/CHANGELOG.md index eee383e..269655f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the **last public release**, never against the beta before it. What one beta corrected in an earlier beta does not appear at all: from the point of view of somebody upgrading between released versions, it never happened. +## [3.4.1] + +A panel that merely advertises itself on the network no longer produces the plaintext-transport warning when discovery probes it, closing the remaining way issue span#264's log line reached an operator who could do nothing about it. + +### Changed + +- **The status endpoint no longer emits the plaintext-transport warning**, for the CA download's reason from the other side: it is the detection probe made against devices nobody has configured, where no pin can exist and the only action is configuring the + panel — whose flow pins before any credential moves. It carries no credential in either direction, and it no longer spends the once-per-host warning slot, which it previously claimed first in every flow so that a genuinely credential-bearing call behind + it said nothing. Registration, passphrase rotation, and the schema fetch warn exactly as before. + ## [3.4.0] A consumer that pinned the panel's CA could not put its schema fetches behind that pin, because the one port `SpanMqttClient` took served two transports with opposite security properties — the schema fetch, which should ride the pinned HTTPS transport, and diff --git a/pyproject.toml b/pyproject.toml index dab4056..b9df534 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "3.4.0" +version = "3.4.1" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} diff --git a/src/span_panel_api/_http.py b/src/span_panel_api/_http.py index 20b90bb..97066fc 100644 --- a/src/span_panel_api/_http.py +++ b/src/span_panel_api/_http.py @@ -31,11 +31,14 @@ #: The one bootstrap path two modules request: the detector probes it to decide #: whether the panel speaks v2 at all, and `get_v2_status` reads the same answer #: for a caller that already knows it does. Named here rather than spelled out in -#: each, so the two cannot drift apart the way their parsers had. +#: each, so the two cannot drift apart the way their parsers had. Load-bearing +#: for `_warn_plaintext_transport`'s exemption: a request to this path is the +#: one the warning stays silent for, so a change here changes what warns. V2_STATUS_PATH = "/api/v2/status" -#: The one bootstrap path exempt from the plaintext warning, named here because -#: the transport is what grants the exemption. See `_warn_plaintext_transport`. +#: Exempt from the plaintext warning alongside `V2_STATUS_PATH`, named here +#: because the transport is what grants the exemption. See +#: `_warn_plaintext_transport`. CA_CERT_PATH = "/api/v2/certificate/ca" #: The verbs the bootstrap API uses. Spelled as a `Literal` rather than passed @@ -174,19 +177,33 @@ def _reset_plaintext_warnings() -> None: def _warn_plaintext_transport(host: str, path: str, ssl_context: ssl.SSLContext | None) -> None: """Say out loud, once per panel, that its bootstrap traffic is not encrypted. - **The CA download is exempt, and does not claim the once-per-host slot.** + **Two endpoints are exempt, and neither claims the once-per-host slot.** The warning exists so an operator can tell a security property is off when - it could be on, and for that endpoint there is no "on": verifying the fetch - of the anchor would require the anchor being fetched, an unverified-TLS - wrapping is readable and forgeable by the same active on-path attacker, and - the payload is a public certificate carrying no credential in either - direction — its authenticity control is the leaf check callers run *after* - the fetch. Each caller also states its own trust posture in its own voice: - the bridge's unpinned warning, a config flow's fingerprint confirmation, a - consumer's trust-on-first-use log. Warning here anyway named credentials the - call never carries, which is the line issue span#264 reported. Not marking - the host matters as much as not warning: a pinned consumer's diagnostic - re-read must not spend the slot a genuinely plaintext call needs later. + it could be on, and for both there is no "on". The CA download fetches the + very anchor verification would need — an unverified-TLS wrapping is + readable and forgeable by the same active on-path attacker, the payload is + a public certificate, and its authenticity control is the leaf check + callers run *after* the fetch. The status endpoint is the detection probe: + most prominently the request discovery makes against a device nobody has + configured, where no pin can exist because trust-on-first-use has not + happened and the only action available is configuring the panel — whose + flow pins before any credential moves, with a person confirming the + fingerprint. Neither call carries a credential in either direction, and + warning on them named credentials they never carry: the CA download's line + is what issue span#264 reported, and the status probe's is what a merely + *advertising* unconfigured panel produced at every boot. + + Two limits of the status exemption, stated rather than implied. The body + informs decisions — `proximityProven`, the serial an identity check reads — + and a plaintext answer is one anything on the path can write; the controls + for that are the panel's own registration gate and the pin the consumer's + flow acquires before a credential moves, not a log line. And a consumer + *can* probe a configured, pinned panel's status without its context; the + exemption means no warning will point that out, so a consumer owes every + probe of a configured host the entry's own transport. Not marking the host matters as + much as not warning — the probe runs first in every flow and a diagnostic + re-read runs on pinned entries, and neither may spend the slot a + credential-bearing call needs later. In the same voice as the MQTT bridge's unpinned-CA warning, and for the same reason: a security property that is off by default is only a decision if the @@ -214,7 +231,7 @@ def _warn_plaintext_transport(host: str, path: str, ssl_context: ssl.SSLContext """ if ssl_context is not None: return - if path == CA_CERT_PATH: + if path in (CA_CERT_PATH, V2_STATUS_PATH): return if host in _warned_plaintext_hosts: return diff --git a/tests/test_plaintext_warning.py b/tests/test_plaintext_warning.py index 5c33e92..56aa962 100644 --- a/tests/test_plaintext_warning.py +++ b/tests/test_plaintext_warning.py @@ -26,7 +26,8 @@ import httpx import pytest -from span_panel_api.auth import download_ca_cert, regenerate_passphrase, register_v2 +from span_panel_api.auth import download_ca_cert, get_v2_status, regenerate_passphrase, register_v2 +from span_panel_api.detection import detect_api_version from span_panel_api.mqtt.models import MqttClientConfig HOST = "panel.invalid" @@ -166,6 +167,54 @@ async def test_the_ca_download_never_warns(self, caplog: pytest.LogCaptureFixtur assert len(_warnings(caplog)) == 0 + @pytest.mark.asyncio + async def test_the_status_probe_never_warns(self, caplog: pytest.LogCaptureFixture) -> None: + """Exempted in 3.4.1, for the CA download's reason from the other side. + + The status endpoint is the detection probe — the request zeroconf + discovery makes against a device nobody has configured, once per boot, + where no pin can exist because trust-on-first-use has not happened and + there is nothing the operator can do but configure the panel. It + carries no credential in either direction, and the flows that probe it + on a configured-but-unpinned entry acquire the pin before any + credential moves, with a human confirming the fingerprint. The warning + here named credentials the call never carries and pointed at an action + nobody could take — the line that made an operator remove a healthy + emulator. The exemption's limits live in `_warn_plaintext_transport`'s + docstring: a consumer probing a *configured* host owes that probe the + entry's own transport, because no warning will say so anymore. + """ + answer = _json_response({"serialNumber": "SYN-0000-0001", "firmwareVersion": "f"}, method="GET") + with caplog.at_level(logging.WARNING): + await get_v2_status(HOST, httpx_client=_client("get", answer)) + assert len(_warnings(caplog)) == 0 + + @pytest.mark.asyncio + async def test_the_detection_probe_never_warns(self, caplog: pytest.LogCaptureFixture) -> None: + """Detection reads the same endpoint, and must be equally silent.""" + answer = _json_response({"serialNumber": "SYN-0000-0001", "firmwareVersion": "f"}, method="GET") + with caplog.at_level(logging.WARNING): + await detect_api_version(HOST, httpx_client=_client("get", answer)) + assert len(_warnings(caplog)) == 0 + + @pytest.mark.asyncio + async def test_the_status_probe_does_not_swallow_a_later_warning(self, caplog: pytest.LogCaptureFixture) -> None: + """The probe fires first in every flow, so it must not spend the slot. + + Today it does exactly that: a reauth's status probe claims the + once-per-host warning, and the credential-bearing call behind it says + nothing. The warning belongs to whichever call actually deserves it. + """ + status = _json_response({"serialNumber": "SYN-0000-0001", "firmwareVersion": "f"}, method="GET") + rotate = _json_response({"ebusBrokerPassword": "new-pass"}, method="PUT") + with caplog.at_level(logging.WARNING): + await get_v2_status(HOST, httpx_client=_client("get", status)) + # The midpoint is the assertion: the slot must still be unspent + # here, so the warning below demonstrably belongs to the rotation. + assert len(_warnings(caplog)) == 0 + await regenerate_passphrase(HOST, "jwt", httpx_client=_client("put", rotate)) + assert len(_warnings(caplog)) == 1 + @pytest.mark.asyncio async def test_the_ca_download_does_not_swallow_a_later_warning(self, caplog: pytest.LogCaptureFixture) -> None: """Skipping the warning must not mark the host as already warned. diff --git a/uv.lock b/uv.lock index 553b402..ec51795 100644 --- a/uv.lock +++ b/uv.lock @@ -975,7 +975,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "3.4.0" +version = "3.4.1" source = { editable = "." } dependencies = [ { name = "httpx" },