Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"}
Expand Down
49 changes: 33 additions & 16 deletions src/span_panel_api/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
51 changes: 50 additions & 1 deletion tests/test_plaintext_warning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading