From 62840e07c5903e33d8947b08339d82a6791eec92 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:25:03 -0700 Subject: [PATCH] fix(warning): exempt the status probe from the plaintext warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status endpoint is the detection probe — most prominently 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 the only action available is configuring the panel, whose flow pins before any credential moves. Warning on it named credentials the call never carries, for an action nobody could take; it is the line that made an operator remove a healthy emulator (SpanPanel/span#264's residue). The probe also no longer spends the once-per-host slot. It fires first in every flow, so it previously claimed the warning that a genuinely credential-bearing plaintext call behind it deserved — the same slot-squatting the CA download's 3.4.0 exemption fixed. Registration, passphrase rotation, the FQDN calls and the schema fetch warn exactly as before. The exemption's limits are stated rather than implied: the status body informs decisions — proximityProven, the serial an identity check reads — whose controls are the panel's own registration gate and the pin a consumer's flow acquires, not a log line; and a consumer probing a configured host owes that probe the entry's own transport, because no warning will say so anymore. --- CHANGELOG.md | 10 +++++++ pyproject.toml | 2 +- src/span_panel_api/_http.py | 49 ++++++++++++++++++++----------- tests/test_plaintext_warning.py | 51 ++++++++++++++++++++++++++++++++- uv.lock | 2 +- 5 files changed, 95 insertions(+), 19 deletions(-) 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" },