From eca550a41fd4a8fa3ae197234a6c7098cf1a9df2 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:29:51 -0700 Subject: [PATCH] feat(mqtt): report a pinned broker whose certificate names somewhere else A pinned handshake that fails carries no evidence about why. When the panel still advertises the pinned CA, the transport now asks one further question -- a second handshake to the broker with hostname checking relaxed, chain, signature and expiry still verified against the pin -- and can therefore say which of the two remaining failures it is. An expired leaf and an unreachable broker keep their existing shape and their existing silence; a certificate that chains to the pin and does not name the address being dialled becomes a typed report. Deliberately not terminal. A DHCP lease that comes back, or a panel that finishes registering its name, fixes this without anyone's help, so the reconnect loop is unchanged and the report exists to put the other remedy in front of a user who would otherwise see nothing but an outage. It fires at most once per outage and is re-armed by the next successful connect, so a mismatch lasting a week is one notification rather than one per backoff tick. Neither fetch can re-anchor anything. `probe_leaf_name` takes the pin as an argument and returns a verdict, so there is no path by which a certificate it observed can become a trust anchor, and the transport's own context builder is never reached from this path. Anything the probe raises is caught before it can escape into the reconnect loop's exception handler, where it would kill the task silently and leave a bridge that looks merely disconnected forever. The per-retry warning stays and its text changes from saying it could be either to naming which. `_diagnose_ca_change` becomes `_diagnose_verification_failure`, because it now diagnoses more than the CA. Public API: LeafNameMismatch(host: str, leaf_names: tuple[str, ...]) SpanMqttClient.register_leaf_mismatch_callback(cb) -> Callable[[], None] The register method is declared on `SpanPanelClientProtocol` beside the fatal one, for the reason that one is: the consumer depends on it and codes against protocols. Additive for callers, breaking for implementers. --- CHANGELOG.md | 15 + README.md | 22 +- pyproject.toml | 2 +- src/span_panel_api/__init__.py | 8 +- src/span_panel_api/_ssl.py | 171 +++++++++- src/span_panel_api/exceptions.py | 14 +- src/span_panel_api/mqtt/client.py | 47 +++ src/span_panel_api/mqtt/connection.py | 187 +++++++++-- src/span_panel_api/mqtt/const.py | 8 + src/span_panel_api/protocol.py | 19 ++ tests/test_ca_pinning.py | 41 ++- tests/test_leaf_name_mismatch.py | 465 ++++++++++++++++++++++++++ tests/test_public_api_unchanged.py | 7 + tests/test_ssl_context.py | 328 +++++++++--------- tests/tls_fixtures.py | 246 ++++++++++++++ uv.lock | 2 +- 16 files changed, 1341 insertions(+), 241 deletions(-) create mode 100644 tests/test_leaf_name_mismatch.py create mode 100644 tests/tls_fixtures.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c5847d..5e2201b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ 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.3.0] + +A pinned panel that has moved is no longer reported the same way as a panel whose clock reset, so a consumer can put the remedy in front of a user instead of retrying in silence. + +### Added + +- **`LeafNameMismatch` reports a broker whose certificate the pinned CA validates and which names somewhere other than the configured address**, carrying that address and the names the certificate does carry. +- **`register_leaf_mismatch_callback` delivers that report**, at most once per outage and re-armed by the next successful connect, returning an unregister function like the other callback channels. + +### Changed + +- **The warning logged when a pinned handshake fails against an unchanged CA now names which failure it is** — an expired or otherwise rejected certificate, an unreachable broker, or a certificate that names somewhere else — instead of saying it could be + either. +- **A moved panel is still retried and never terminal**, because the address can come back on its own and the report exists to make the alternative remedy visible rather than to stop the transport. + ## [3.2.0] A consumer pinned to a panel's CA cannot currently tell a panel that has moved from something impersonating one, because the two produce the same verification failure. This release splits the question. diff --git a/README.md b/README.md index 34eaed5..40cfb26 100644 --- a/README.md +++ b/README.md @@ -393,9 +393,25 @@ context = build_panel_ssl_context(stored_pem) fingerprint = ca_fingerprint(stored_pem) ``` -Leaving `ca_pem` unset keeps the previous behaviour, with one `WARNING` per bridge recording that the anchor was obtained unauthenticated. With it set, a certificate-verification failure is diagnosed rather than assumed: an expired leaf after a panel's -clock reset and a hostname mismatch after the panel moved both produce the identical error, so the library refetches the advertised CA for comparison only and keeps retrying unless the fingerprint has actually changed — at which point it raises -`SpanPanelCAChangedError` carrying both fingerprints and stops. Register `register_fatal_error_callback` to be told; a consumer that registers nothing still cannot mistake a dead bridge for a healthy one, because `ping()` and `get_snapshot()` re-raise. +Leaving `ca_pem` unset keeps the previous behaviour, with one `WARNING` per bridge recording that the anchor was obtained unauthenticated. With it set, a certificate-verification failure is diagnosed rather than assumed, in two steps — a rotated CA, an +expired leaf and a panel that has moved all raise the identical error, and the failed handshake carries no evidence about which. + +First the library refetches the advertised CA, for comparison only. If the fingerprint has changed it raises `SpanPanelCAChangedError` carrying both fingerprints and stops. Register `register_fatal_error_callback` to be told; a consumer that registers +nothing still cannot mistake a dead bridge for a healthy one, because `ping()` and `get_snapshot()` re-raise. + +If the fingerprint matches, the panel is still the panel and the library asks one further question: a second handshake to the broker with hostname checking relaxed — the chain, the signature and the expiry still verified against the pin — to see whether +the certificate names the address being dialled. + +```python +def moved(mismatch: LeafNameMismatch) -> None: + print(f"configured as {mismatch.host}, certificate names {', '.join(mismatch.leaf_names)}") + +unregister = client.register_leaf_mismatch_callback(moved) +``` + +**This is not fatal and the transport keeps retrying**, because a returning DHCP lease fixes it without anyone's help; what the callback is for is putting the other remedy — re-point the configuration at one of the names reported — in front of a user who +would otherwise see only an outage. It fires at most once per outage and is re-armed by the next successful connect. An expired leaf reports nothing, because nothing anyone does helps and the panel recovers on its own once it has the time again. Neither +handshake can re-anchor anything: both are diagnostic, and the pin is the pin whatever the panel served. The bootstrap REST calls take an `ssl_context` for the same purpose. `download_ca_cert` is the one exception and stays on plain HTTP — it fetches the anchor everything else is checked against, so it has nothing to check itself against, and its result must be fingerprint-confirmed out of band before it is trusted. diff --git a/pyproject.toml b/pyproject.toml index abba0ee..128c33a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "3.2.0" +version = "3.3.0" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} diff --git a/src/span_panel_api/__init__.py b/src/span_panel_api/__init__.py index 2d798e4..9670691 100644 --- a/src/span_panel_api/__init__.py +++ b/src/span_panel_api/__init__.py @@ -6,7 +6,7 @@ from importlib.metadata import version as _pkg_version -from ._ssl import build_panel_ssl_context, ca_fingerprint, leaf_names_host +from ._ssl import LeafNameMismatch, build_panel_ssl_context, ca_fingerprint, leaf_names_host from .auth import ( delete_fqdn, download_ca_cert, @@ -157,6 +157,12 @@ # Added 2026-08-28: the hostname half of verification, split out so a # caller using a relaxed context can still establish the name binding. "leaf_names_host", + # Added 2026-08-28 (3.3.0): what the transport reports when the pinned CA + # validates the broker's certificate and that certificate names somewhere + # else. Purely additive -- a consumer that registers no leaf-mismatch + # callback never receives one, and the reconnect behaviour it accompanies is + # unchanged. + "LeafNameMismatch", "delete_fqdn", "download_ca_cert", "get_fqdn", diff --git a/src/span_panel_api/_ssl.py b/src/span_panel_api/_ssl.py index e070e65..565d86b 100644 --- a/src/span_panel_api/_ssl.py +++ b/src/span_panel_api/_ssl.py @@ -1,20 +1,30 @@ """The panel's trust anchor: building a context from it, and naming it. -Both functions here take a CA in PEM form and nothing else. They make no network -call and hold no state, which is the point -- a trust anchor that is fetched at -the moment it is used is not an anchor, it is whatever answered. The fetching -lives in ``auth.download_ca_cert``, and deciding whether a fetched PEM may be -trusted lives with the caller. - -Public rather than private (``_ssl`` is a module-name convention here, and every -name is re-exported from the package root) because the consumer needs all three: -it builds the same context for its own HTTPS calls, it prints and compares the -same fingerprint string, and it applies the same hostname rules when it has to -judge a name binding for itself. Two implementations of a fingerprint that must -agree byte-for-byte is a defect waiting for a firmware upgrade to find it, and -the same is true of a hand-written hostname matcher -- more so, since that one -is security-relevant and has no standard-library implementation left to defer -to since ``ssl.match_hostname`` was removed in Python 3.12. +``build_panel_ssl_context``, ``leaf_names_host`` and ``ca_fingerprint`` take a CA +in PEM form and nothing else. They make no network call and hold no state, which +is the point -- a trust anchor that is fetched at the moment it is used is not an +anchor, it is whatever answered. The fetching lives in ``auth.download_ca_cert``, +and deciding whether a fetched PEM may be trusted lives with the caller. + +Those three are public (``_ssl`` is a module-name convention here, and all three +are re-exported from the package root) because the consumer needs them: it builds +the same context for its own HTTPS calls, it prints and compares the same +fingerprint string, and it applies the same hostname rules when it has to judge a +name binding for itself. Two implementations of a fingerprint that must agree +byte-for-byte is a defect waiting for a firmware upgrade to find it, and the same +is true of a hand-written hostname matcher -- more so, since that one is +security-relevant and has no standard-library implementation left to defer to +since ``ssl.match_hostname`` was removed in Python 3.12. + +``probe_leaf_name`` is the one thing here that does open a socket, and it is the +same argument carried one step further. A failed pinned handshake carries no +evidence about *why* it failed, so somebody has to ask the peer a second, narrower +question -- and that question is a composition of the anchor, the relaxed context +and the SAN matcher, all of which live in this module. Written once here rather +than at each caller for exactly the reason the matcher is: a second implementation +of "does this certificate name this host" is the drift the module exists to +prevent. It anchors on the CA it is handed and returns a verdict, never a +certificate to trust -- nothing it sees can become an anchor. """ from __future__ import annotations @@ -22,8 +32,10 @@ import base64 import binascii from collections.abc import Iterator, Mapping +from dataclasses import dataclass import hashlib import ipaddress +import socket import ssl from .exceptions import SpanPanelValidationError @@ -31,6 +43,12 @@ _PEM_HEADER = "-----BEGIN CERTIFICATE-----" _PEM_FOOTER = "-----END CERTIFICATE-----" +#: The SAN entry kinds this library reads. A panel names literal addresses, so +#: these are the two that can carry one; anything else in a SAN (``email``, a +#: ``URI``) names something that is not a host and would only mislead a user +#: reading the list back. +_ADDRESSING_SAN_KINDS = ("DNS", "IP Address") + def build_panel_ssl_context(ca_pem: str, *, check_hostname: bool = True) -> ssl.SSLContext: """Build an SSLContext that trusts only the provided panel CA. @@ -127,6 +145,117 @@ def leaf_names_host(peer_cert: Mapping[str, object], host: str) -> bool: return _names_address(entries, wanted) +@dataclass(frozen=True, slots=True) +class LeafNameMismatch: + """A peer whose certificate the pinned CA validates, and which does not name ``host``. + + The one thing that can be established about a failed pinned handshake beyond + "something is wrong": the panel is who it says it is, and it is not where the + configuration says it is. Not an exception, because it is not fatal and + nothing is being refused -- the transport keeps retrying, and a DHCP lease + that comes back or a panel that finishes registering its name fixes this with + nobody's help. It is a fact reported to whoever asked to be told, so that a + consumer can put the remedy in front of a person instead of leaving them to + read a log. + + ``leaf_names`` is what the certificate actually carries -- its SAN ``DNS`` and + ``IP Address`` entries, in certificate order -- because the remedy is to + re-point the configuration at one of them, and a message that says only "the + name is wrong" does not tell anyone what the right one is. Empty is possible + and means the certificate names no address at all, which is a panel problem + rather than an addressing one. + """ + + host: str + leaf_names: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class LeafProbe: + """The result of one relaxed diagnostic handshake. + + ``mismatch`` is set for the single outcome that is actionable and is ``None`` + for every other, because every other one is transient and the caller's + response to all of them is the same: keep retrying. ``detail`` says which, + as a phrase fit to drop into a log line, so that a caller can be specific + about a verdict it must not act on differently. + """ + + mismatch: LeafNameMismatch | None + detail: str + + +def probe_leaf_name(ca_pem: str, host: str, port: int, *, timeout: float) -> LeafProbe: + """Ask ``host`` directly whether the certificate it serves names ``host``. + + **Diagnostic only.** One handshake, under the CA it is handed, with hostname + checking relaxed. Nothing it observes is stored, no context is built from it + for any other use, and the anchor it verifies against is the caller's pin + unchanged -- a peer cannot become trusted by answering this call. The chain, + the signature and the expiry are all still verified, which is what makes the + remaining question meaningful: a peer that gets as far as being *named + wrongly* has already proved it holds a key the pin signed. + + Blocking, and deliberately so -- ``ssl`` offers no non-blocking handshake + worth the machinery here, and the one caller has an executor. It is not + exported from the package root for that reason: a blocking call on an async + library's public surface is a footgun, and the consumer's own decisions about + which host to talk to are made in a config flow that already composes + :func:`build_panel_ssl_context` and :func:`leaf_names_host` for itself. + + Four outcomes, and only the last is not a shrug: + + - the peer rejects under the pin -- an expired leaf, most often a panel whose + clock reset after a power cut, and nothing anyone can act on; + - nothing answers -- a panel mid-reboot; + - the certificate names ``host`` -- which cannot follow a strict handshake + that failed, and is reported as transient rather than reasoned about, + because a contradiction is not evidence of anything; + - the certificate does not name ``host`` -- the mismatch. + + Args: + ca_pem: The pinned CA, verified against and never replaced. + host: The name to dial and the name to look for. Both, deliberately: + the question is whether the peer reached *by this name* carries it. + port: The port to dial. + timeout: Seconds allowed for the connection and the handshake together. + + Raises: + ssl.SSLError: ``ca_pem`` is not a certificate the ssl module accepts. + ValueError: ``ca_pem`` is malformed in a way ``ssl`` reports as such. + """ + context = build_panel_ssl_context(ca_pem, check_hostname=False) + try: + with ( + socket.create_connection((host, port), timeout=timeout) as raw, + context.wrap_socket(raw, server_hostname=host) as tls, + ): + peer = tls.getpeercert() + except ssl.SSLCertVerificationError as exc: + # Ahead of OSError because it is one: SSLCertVerificationError derives + # from SSLError derives from OSError, and this is the branch that means + # "the peer answered and the pin rejected it" rather than "nothing + # answered". + return LeafProbe(None, f"a second look with the hostname check relaxed was rejected too ({exc.verify_message})") + except (OSError, ValueError) as exc: + # Every remaining transport failure, including the non-verification TLS + # errors: refused, unresolvable, timed out, a handshake that went wrong + # for a reason the pin has no opinion about. ValueError because an empty + # `host` is one, and an unusable configuration is still not evidence. + return LeafProbe(None, f"a second look with the hostname check relaxed could not reach it ({exc})") + if peer is None: + # Only reachable with verification off, which this context never has. + # Kept because the alternative is reading a mismatch out of an empty + # certificate and naming no addresses in the report. + return LeafProbe(None, "a second look with the hostname check relaxed produced no certificate to read") + if leaf_names_host(peer, host): + return LeafProbe(None, f"the certificate it serves does name {host}, so the failure was something else") + return LeafProbe( + LeafNameMismatch(host=host, leaf_names=_san_names(peer)), + f"the certificate it serves does not name {host}", + ) + + def _without_root_dot(name: str) -> str: """Strip surrounding space and a single root dot, which is not significant.""" stripped = name.strip() @@ -150,6 +279,18 @@ def _san_entries(peer_cert: Mapping[str, object]) -> Iterator[tuple[str, str]]: yield kind, value +def _san_names(peer_cert: Mapping[str, object]) -> tuple[str, ...]: + """The addresses a certificate names, in certificate order. + + Verbatim, without normalisation: a user is going to read these back and type + one of them into a configuration field, so what is reported has to be what + the certificate says rather than a casefolded or dot-stripped rendering of + it. Order is the certificate's because the first entry is conventionally the + primary name, and re-sorting would lose that for nothing. + """ + return tuple(value for kind, value in _san_entries(peer_cert) if kind in _ADDRESSING_SAN_KINDS) + + def _names_address(entries: list[tuple[str, str]], wanted: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: """Whether an ``IP Address`` entry denotes ``wanted``, compared as addresses.""" for kind, value in entries: diff --git a/src/span_panel_api/exceptions.py b/src/span_panel_api/exceptions.py index b038bc6..9e63049 100644 --- a/src/span_panel_api/exceptions.py +++ b/src/span_panel_api/exceptions.py @@ -49,9 +49,9 @@ class SpanPanelCAChangedError(SpanPanelError): a client waiting to succeed against whatever is answering, which is the outcome pinning exists to prevent. - It is also not a conclusion drawn from a handshake failure, because that - conclusion cannot be drawn: an expired leaf (a panel whose clock reset after - a power outage) and a hostname mismatch (a panel whose address moved) both + It is also not a conclusion drawn from the failed handshake, because that + handshake cannot support one: an expired leaf (a panel whose clock reset + after a power outage) and a hostname mismatch (a panel whose address moved) raise the same verification error against a perfectly valid pinned CA, and the ``ssl`` module exposes no peer chain when verification fails. This is raised only after a separate fetch of the panel's advertised CA returned a @@ -59,6 +59,14 @@ class SpanPanelCAChangedError(SpanPanelError): ``observed_fingerprint`` is what the panel says its anchor is now, not what it presented on the connection that failed. + The other two are told apart afterwards and elsewhere, by a *second* + handshake with hostname checking relaxed (``_ssl.probe_leaf_name``), which + reaches the point of holding a validated certificate and can therefore read + its names. That path never produces this error: a leaf that chains to the pin + has proved the panel is the panel, so the worst it can report is + ``LeafNameMismatch``, which is not fatal and is retried like any other + address problem. + The two remedies are opposite and only the user can choose between them, so both fingerprints are carried: re-pin, if the panel's CA was legitimately rotated by a firmware upgrade or a factory reset, or investigate, if it was diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index 1e870ba..b2f4e86 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -20,6 +20,7 @@ from span_panel_api.schema_drift import log_schema_drift +from .._ssl import LeafNameMismatch from ..adapters import installed_adapter_keys, resolve_adapter from ..auth import get_homie_schema from ..dispatch import select_adapter_key @@ -167,6 +168,7 @@ def __init__( self._snapshot_callbacks: list[Callable[[SpanPanelSnapshot], Awaitable[None]]] = [] self._connection_callbacks: list[Callable[[bool], None]] = [] self._fatal_error_callbacks: list[Callable[[SpanPanelError], None]] = [] + self._leaf_mismatch_callbacks: list[Callable[[LeafNameMismatch], None]] = [] self._schema_change_callbacks: list[Callable[[str | None, str | None], None]] = [] self._live = False self._ready_event: asyncio.Event | None = None @@ -456,6 +458,7 @@ async def connect(self) -> None: self._bridge.set_message_callback(self._on_message) self._bridge.set_connection_callback(self._on_connection_change) self._bridge.set_fatal_error_callback(self._on_fatal_error) + self._bridge.set_leaf_mismatch_callback(self._on_leaf_mismatch) # Pre-rebuild hook: reset Homie accumulator before the bridge swaps # paho clients, so retained messages on the new subscription start # from a clean slate (no stale `$state=disconnected` cached from @@ -631,6 +634,50 @@ def _on_fatal_error(self, error: SpanPanelError) -> None: except Exception: # pylint: disable=broad-exception-caught _LOGGER.warning("Fatal-error callback raised", exc_info=True) + def register_leaf_mismatch_callback(self, callback: Callable[[LeafNameMismatch], None]) -> Callable[[], None]: + """Subscribe to the broker's certificate naming somewhere other than here. + + Fires with the address this client dials and the addresses the broker's + certificate actually carries, once the pinned CA has been confirmed as + still the panel's own. So it says something quite narrow and quite + useful: this *is* the panel, and it is not where the configuration says + it is -- most often a panel that took a new DHCP lease. + + Not a fatal error and deliberately not on that channel. The transport + keeps retrying and recovers by itself if the panel comes back to the + configured address, so a consumer should surface the remedy -- re-point + the configuration at one of the names reported -- rather than tear + anything down. Nothing else re-raises it, because there is nothing to + raise: `ping()` and `get_snapshot()` go on reporting an ordinary outage, + which is what this is until somebody decides otherwise. + + Fires at most once per outage: the next successful connect re-arms it, so + a mismatch that lasts a week is one notification and a mismatch that + recurs after a recovery is a second one. + + Returns an unregister function. Calling it twice is safe. + """ + self._leaf_mismatch_callbacks.append(callback) + + def unregister() -> None: + with contextlib.suppress(ValueError): + self._leaf_mismatch_callbacks.remove(callback) + + return unregister + + def _on_leaf_mismatch(self, mismatch: LeafNameMismatch) -> None: + """Fan the bridge's name-mismatch report out to subscribers. + + Iterates a copy for the same reason the other two fan-outs do: a + subscriber unregistering from inside its own callback must not mutate + the list being walked. + """ + for cb in list(self._leaf_mismatch_callbacks): + try: + cb(mismatch) + except Exception: # pylint: disable=broad-exception-caught + _LOGGER.warning("Leaf-mismatch callback raised", exc_info=True) + def register_schema_change_callback(self, callback: Callable[[str | None, str | None], None]) -> Callable[[], None]: """Subscribe to the panel changing schema generation mid-session. diff --git a/src/span_panel_api/mqtt/connection.py b/src/span_panel_api/mqtt/connection.py index 20c431e..a0bd857 100644 --- a/src/span_panel_api/mqtt/connection.py +++ b/src/span_panel_api/mqtt/connection.py @@ -22,7 +22,7 @@ from paho.mqtt.properties import Properties from paho.mqtt.reasoncodes import ReasonCode -from .._ssl import build_panel_ssl_context, ca_fingerprint +from .._ssl import LeafNameMismatch, LeafProbe, build_panel_ssl_context, ca_fingerprint, probe_leaf_name from ..auth import download_ca_cert from ..exceptions import ( SpanPanelAPIError, @@ -37,6 +37,7 @@ MQTT_CONNECT_TIMEOUT_S, MQTT_FULL_REBUILD_AFTER_FAILURES, MQTT_KEEPALIVE_S, + MQTT_LEAF_PROBE_TIMEOUT_S, MQTT_RECONNECT_BACKOFF_MULTIPLIER, MQTT_RECONNECT_MAX_DELAY_S, MQTT_RECONNECT_MIN_DELAY_S, @@ -93,6 +94,15 @@ def __init__( self._fatal_error: SpanPanelError | None = None self._fatal_error_callback: Callable[[SpanPanelError], None] | None = None + # The non-terminal counterpart: a broker whose certificate the pin + # validates and which does not name the address this bridge dials. Not + # fatal -- the loop keeps retrying, because a returning DHCP lease fixes + # it -- so it is announced at most once and then latched, and the latch + # is released by the next successful connect. Without the latch a + # week-long mismatch would announce itself on every backoff tick. + self._leaf_mismatch_callback: Callable[[LeafNameMismatch], None] | None = None + self._leaf_mismatch_reported = False + # QoS-1 publishes awaiting a PUBACK, keyed by paho message id. Emptied # by `_on_publish` one at a time, and wholesale by # `_resolve_pending_publishes` when the outbound queue ceases to exist. @@ -163,6 +173,38 @@ def _enter_terminal_state(self, error: SpanPanelError) -> None: except Exception: # pylint: disable=broad-exception-caught _LOGGER.warning("Fatal-error callback raised", exc_info=True) + def set_leaf_mismatch_callback(self, callback: Callable[[LeafNameMismatch], None]) -> None: + """Set the callback fired when the broker's certificate names somewhere else. + + The counterpart to `set_fatal_error_callback`, and non-fatal on purpose. + This bridge goes on retrying afterwards, because the condition genuinely + can clear on its own, so a consumer's correct response is to make the + remedy visible rather than to tear anything down. + + It still needs a channel of its own for the same reason the fatal one + does: the reconnect loop is fire-and-forget, and the connection callback + can only say "disconnected" -- which is what an ordinary outage looks + like, and which a consumer is right to wait through. Waiting through this + one is waiting for something that will not happen. + + Fires at most once between successful connects, from the event loop. A + subscriber that raises is logged and otherwise ignored, so a broken one + cannot suppress the notification for a second subscriber added later. + """ + self._leaf_mismatch_callback = callback + + def _report_leaf_mismatch(self, mismatch: LeafNameMismatch) -> None: + """Announce a name mismatch once, until the next successful connect clears it.""" + if self._leaf_mismatch_reported: + return + self._leaf_mismatch_reported = True + if self._leaf_mismatch_callback is None: + return + try: + self._leaf_mismatch_callback(mismatch) + except Exception: # pylint: disable=broad-exception-caught + _LOGGER.warning("Leaf-mismatch callback raised", exc_info=True) + def set_pre_rebuild_callback(self, callback: Callable[[], None]) -> None: """Set callback invoked just before the bridge rebuilds its paho client. @@ -211,29 +253,37 @@ async def _trust_anchor_pem(self) -> str: ) return await download_ca_cert(self._panel_host, port=self._panel_http_port) - async def _diagnose_ca_change(self) -> SpanPanelCAChangedError | None: - """Decide whether a certificate-verification failure means the CA rotated. - - It usually does not, and the failure carries no evidence either way. A - valid pinned CA still produces `SSLCertVerificationError` when the leaf - has expired — a panel whose clock reset after a power outage, which for - an electrical panel is not a corner case — or when the hostname no longer - matches after the panel's address changed. And `ssl` exposes no peer - chain on a verification failure, so the certificate that was actually - offered cannot be read from the exception at all. - - So the observed fingerprint has to come from somewhere else: a separate, - unauthenticated fetch of the panel's advertised CA. That fetch is - **diagnostic only and is never used to re-anchor** — it is exactly the - request an attacker would answer, and treating its result as a new trust - anchor is the re-anchoring this class exists to stop. + async def _diagnose_verification_failure(self) -> SpanPanelCAChangedError | None: + """Say what a certificate-verification failure actually was. + + The failure itself carries no evidence. A valid pinned CA still produces + `SSLCertVerificationError` when the leaf has expired — a panel whose clock + reset after a power outage, which for an electrical panel is not a corner + case — or when the hostname no longer matches after the panel's address + changed. And `ssl` exposes no peer chain on a verification failure, so the + certificate that was actually offered cannot be read from the exception at + all. + + So the evidence has to come from somewhere else, and this asks two + questions in turn. First, what CA does the panel advertise now? — a + separate, unauthenticated fetch. If that has rotated, nothing else + matters and the connection is over. If it has not, the panel is still the + panel and the failure is about the certificate underneath: second + question, does the broker's leaf name the address this bridge dials? + + Both fetches are **diagnostic only and are never used to re-anchor**. The + CA fetch is exactly the request an attacker would answer; the relaxed + handshake in `_diagnose_leaf_name` verifies against the pin and returns a + verdict rather than a certificate. Neither can change what is trusted. Three outcomes, and only one of them escalates: - - Fingerprint matches the pin — the CA did not change. Some other TLS - problem; the caller keeps retrying. - Fingerprint differs — the panel is advertising a different anchor. - Returns the error to raise. + Returns the error to raise, and asks nothing further. + - Fingerprint matches the pin — the CA did not change. Returns None; the + caller keeps retrying, and `_diagnose_leaf_name` has meanwhile said + which of the remaining failures this is and announced a name mismatch + if that is what it found. - The fetch failed, or returned something that is not a certificate — returns None. **Never escalate on missing evidence.** A panel that is reachable on 8883 and not on its HTTP port is a panel mid-reboot, and @@ -266,15 +316,75 @@ async def _diagnose_ca_change(self) -> SpanPanelCAChangedError | None: _LOGGER.warning("Could not fingerprint a CA certificate while diagnosing a TLS failure: %s", exc) return None if expected == observed: + await self._diagnose_leaf_name(expected) + return None + return SpanPanelCAChangedError(expected, observed) + + async def _diagnose_leaf_name(self, pinned_fingerprint: str) -> None: + """Tell an expired leaf from a moved panel, and announce a moved panel. + + Reached only once the advertised CA has been confirmed to still be the + pinned one, which is what makes the remaining question worth asking: the + panel is the panel, so either its certificate has expired — nothing anyone + can act on, and it fixes itself once the panel has the time again — or the + certificate is fine and does not name the address this bridge dials, which + only a person can fix. + + Answered by one further handshake to the broker with hostname checking + relaxed, run in an executor because it blocks. **Nothing it observes is + kept.** No context is rebuilt from it, no certificate is recorded, and the + anchor it verifies against is `self._ca_pem` unchanged — the pin is the pin + before this runs and after it, whatever the broker served. + + Warns either way, on every attempt, because the log is where a failure + that is nobody's fault belongs. The callback is what marks the one that + is somebody's, and it fires once until the next successful connect — + a mismatch that lasts a week is one condition, not ten thousand. + """ + pinned = self._ca_pem + if pinned is None: + return + loop = self._loop if self._loop is not None else asyncio.get_running_loop() + try: + probe = await loop.run_in_executor( + None, + partial(probe_leaf_name, pinned, self._host, self._port, timeout=MQTT_LEAF_PROBE_TIMEOUT_S), + ) + except Exception as exc: # pylint: disable=broad-exception-caught + # A pin that cannot be made into a context is the documented way + # `probe_leaf_name` raises, and it should be unreachable here: the + # same PEM was turned into a context by `connect` before any + # handshake could fail. The catch is broad and here anyway because of + # *where* this runs. `_reconnect_loop` awaits it from inside an + # exception handler, so anything escaping kills that task with no + # traceback and no terminal state -- a bridge that looks merely + # disconnected and will never try again, which is the one outcome the + # whole fatal-error channel exists to prevent. A diagnostic must not + # be able to cause it. + probe = LeafProbe(None, f"the pinned CA could not be used for a second look ({exc})") + mismatch = probe.mismatch + if mismatch is None: _LOGGER.warning( "TLS verification failed for %s, but the panel still advertises the pinned CA " - "(SHA-256 %s). An expired certificate or a changed hostname would both look like " - "this. Continuing to retry.", + "(SHA-256 %s), and %s. Continuing to retry.", self._panel_host, - expected, + pinned_fingerprint, + probe.detail, ) - return None - return SpanPanelCAChangedError(expected, observed) + return + _LOGGER.warning( + "TLS verification failed for %s: the certificate served by %s:%s is signed by the pinned " + "CA (SHA-256 %s) but names %s, while this connection is configured as %s. The panel has " + "most likely moved. Continuing to retry, which recovers on its own if it moves back; " + "otherwise re-point the configuration at one of the names the certificate carries.", + self._panel_host, + self._host, + self._port, + pinned_fingerprint, + ", ".join(mismatch.leaf_names) if mismatch.leaf_names else "no address at all", + mismatch.host, + ) + self._report_leaf_mismatch(mismatch) def _make_paho_client(self, ssl_context: ssl.SSLContext | None) -> AsyncMQTTClient: """Build and wire a fresh paho client. @@ -365,11 +475,12 @@ def _blocking_connect() -> None: # The pinned handshake failed on the very first attempt, which is # what a CA rotated while the consumer was shut down looks like. # Wrapped as a connection error this became a setup-retry loop - # with nothing for the user to act on, forever. `_diagnose_ca_change` - # is what distinguishes it from an expired leaf or a moved host, - # and returns None for both of those so they keep their old - # retryable shape. - fatal = await self._diagnose_ca_change() + # with nothing for the user to act on, forever. + # `_diagnose_verification_failure` is what distinguishes it from an + # expired leaf or a moved host, and returns None for both of those + # so they keep their old retryable shape — announcing the moved + # host on its own channel on the way past. + fatal = await self._diagnose_verification_failure() if fatal is not None: raise fatal from exc raise SpanPanelConnectionError(f"Cannot connect to MQTT broker at {self._host}:{self._port}: {exc}") from exc @@ -642,6 +753,11 @@ def _on_connect( if connected: _LOGGER.debug("MQTT connected to %s:%s", self._host, self._port) + # A completed handshake is the proof that any name mismatch reported + # earlier is over -- the address moved back, or the panel registered + # the name it is dialled by. Releasing the latch here is what makes a + # second mismatch, later, a second notification rather than silence. + self._leaf_mismatch_reported = False # Cancel reconnect loop on successful connection if self._reconnect_task is not None: self._reconnect_task.cancel() @@ -868,15 +984,18 @@ async def _reconnect_loop(self) -> None: await self._rebuild_client() failures_since_rebuild_attempt = 0 else: - fatal = await self._diagnose_ca_change() + fatal = await self._diagnose_verification_failure() if fatal is not None: self._enter_terminal_state(fatal) return # Not the CA. An expired leaf or a moved host, both of # which the panel or the network can still fix, so this - # is an ordinary failure. No immediate rebuild: with a - # pin, a rebuild changes nothing about trust and only - # discards whatever paho was still holding. + # is an ordinary failure -- including the moved host, + # which the diagnostic has by now named on its own + # channel without making it terminal. No immediate + # rebuild: with a pin, a rebuild changes nothing about + # trust and only discards whatever paho was still + # holding. failures_since_rebuild_attempt += 1 _LOGGER.warning("Reconnect TLS verification failed (%s), retrying in %ss", exc, delay) except ssl.SSLError as exc: diff --git a/src/span_panel_api/mqtt/const.py b/src/span_panel_api/mqtt/const.py index ac49f40..9f77b97 100644 --- a/src/span_panel_api/mqtt/const.py +++ b/src/span_panel_api/mqtt/const.py @@ -28,3 +28,11 @@ # going through HA's config_entry teardown. Resets after every rebuild attempt so the cadence holds # throughout extended outages. MQTT_FULL_REBUILD_AFTER_FAILURES = 3 + +# How long the relaxed diagnostic handshake in `_diagnose_leaf_name` may take, +# connection and TLS together. Short because it runs on a reconnect attempt that +# has already failed and is about to be retried anyway: the answer is worth +# having, and worth nothing if it arrives after the next attempt. A panel that +# cannot answer inside this is a panel mid-reboot, which the caller already +# treats as transient. +MQTT_LEAF_PROBE_TIMEOUT_S = 5.0 diff --git a/src/span_panel_api/protocol.py b/src/span_panel_api/protocol.py index 65ef293..9182aa8 100644 --- a/src/span_panel_api/protocol.py +++ b/src/span_panel_api/protocol.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable if TYPE_CHECKING: + from ._ssl import LeafNameMismatch from .exceptions import SpanPanelError from .models import ControlTarget, FieldMetadata, SpanPanelSnapshot, V2HomieSchema from .mqtt.control import ControlInterceptor, PublishOutcome @@ -64,6 +65,24 @@ def register_fatal_error_callback(self, callback: Callable[[SpanPanelError], Non and the consumer is expected to surface it to a person. """ + def register_leaf_mismatch_callback(self, callback: Callable[[LeafNameMismatch], None]) -> Callable[[], None]: + """Subscribe to the broker's certificate naming somewhere other than here. + + The third of a set, and it sits between the other two rather than beside + either. `register_connection_callback` reports a state a consumer waits + through; `register_fatal_error_callback` reports one no waiting fixes. + This reports one that waiting *might* fix -- a panel on a new DHCP lease + comes back on its own -- and might not, and only a person can tell which. + The transport keeps retrying either way, so a consumer's job here is to + make the remedy visible rather than to stop. + + Declared here for the same reason as the fatal channel: the consumer + depends on it, and this module's rule is that the consumer codes against + protocols. Additive for callers and breaking for implementers -- a class + type-checked against this protocol has to grow the method, which test + fakes and simulators are exactly what that means in practice. + """ + @runtime_checkable class CircuitControlProtocol(Protocol): diff --git a/tests/test_ca_pinning.py b/tests/test_ca_pinning.py index bd9c86a..ef2a9fb 100644 --- a/tests/test_ca_pinning.py +++ b/tests/test_ca_pinning.py @@ -191,12 +191,21 @@ async def test_unpinned_fetches_and_warns_once_per_bridge( # --------------------------------------------------------------------------- -class TestDiagnoseCaChange: +class TestDiagnoseVerificationFailure: + """The CA question, which is asked first and settles the matter when it differs. + + The name question that follows a matching fingerprint has its own suite in + `test_leaf_name_mismatch.py`, against a real broker. Here the pinned PEM is a + marker rather than a certificate, so the second handshake cannot be attempted + at all -- which is itself worth asserting, because that failure arrives inside + the reconnect loop's exception handler and must not escape it. + """ + @pytest.mark.asyncio async def test_unpinned_never_escalates(self) -> None: bridge = _bridge(ca_pem=None) with patch("span_panel_api.mqtt.connection.download_ca_cert") as fetch: - assert await bridge._diagnose_ca_change() is None + assert await bridge._diagnose_verification_failure() is None fetch.assert_not_called() @pytest.mark.asyncio @@ -207,14 +216,34 @@ async def test_same_fingerprint_is_not_a_ca_change(self, caplog: pytest.LogCaptu patch("span_panel_api.mqtt.connection.download_ca_cert", return_value=PINNED_PEM), caplog.at_level(logging.WARNING, logger="span_panel_api.mqtt.connection"), ): - assert await bridge._diagnose_ca_change() is None + assert await bridge._diagnose_verification_failure() is None assert "still advertises the pinned CA" in caplog.text + @pytest.mark.asyncio + async def test_a_pin_the_second_handshake_cannot_use_is_still_transient(self, caplog: pytest.LogCaptureFixture) -> None: + """The diagnostic must not be able to kill the loop that awaits it. + + `PINNED_PEM` fingerprints perfectly well and is not a certificate, so it + gets as far as the name question and then cannot build a context. That + exception is raised inside the reconnect loop's own exception handler, + where anything escaping leaves a task dead with no traceback and a bridge + that looks merely disconnected forever. + """ + bridge = _bridge(ca_pem=PINNED_PEM) + with ( + patch("span_panel_api.mqtt.connection.download_ca_cert", return_value=PINNED_PEM), + caplog.at_level(logging.WARNING, logger="span_panel_api.mqtt.connection"), + ): + assert await bridge._diagnose_verification_failure() is None + + assert "could not be used for a second look" in caplog.text + assert bridge.fatal_error is None + @pytest.mark.asyncio async def test_different_fingerprint_carries_both(self) -> None: bridge = _bridge(ca_pem=PINNED_PEM) with patch("span_panel_api.mqtt.connection.download_ca_cert", return_value=ROTATED_PEM): - error = await bridge._diagnose_ca_change() + error = await bridge._diagnose_verification_failure() assert isinstance(error, SpanPanelCAChangedError) assert error.expected_fingerprint == PINNED_FP assert error.observed_fingerprint == ROTATED_FP @@ -235,14 +264,14 @@ async def test_fetch_failure_never_escalates(self, failure: Exception) -> None: """Missing evidence is not evidence. A panel mid-reboot looks exactly like this.""" bridge = _bridge(ca_pem=PINNED_PEM) with patch("span_panel_api.mqtt.connection.download_ca_cert", side_effect=failure): - assert await bridge._diagnose_ca_change() is None + assert await bridge._diagnose_verification_failure() is None @pytest.mark.asyncio async def test_unfingerprintable_answer_never_escalates(self) -> None: """A proxy's error page in place of a PEM says nothing about the CA.""" bridge = _bridge(ca_pem=PINNED_PEM) with patch("span_panel_api.mqtt.connection.download_ca_cert", return_value="404"): - assert await bridge._diagnose_ca_change() is None + assert await bridge._diagnose_verification_failure() is None # --------------------------------------------------------------------------- diff --git a/tests/test_leaf_name_mismatch.py b/tests/test_leaf_name_mismatch.py new file mode 100644 index 0000000..9a14f87 --- /dev/null +++ b/tests/test_leaf_name_mismatch.py @@ -0,0 +1,465 @@ +"""Telling a panel that moved from a panel whose clock reset. + +Both produce `SSLCertVerificationError` against a perfectly valid pinned CA, and +until now the library said so and stopped there: a warning per retry saying it +could be either, and a consumer left waiting through one of them forever. One is +transient and the panel fixes it; the other is an address that will never come +back on its own, and only a person can act on it. + +What distinguishes them is a second handshake with hostname checking relaxed -- +which still verifies the chain, the signature and the expiry against the pin, and +therefore reaches the point of holding a certificate whose names can be read. The +tests here are about the conclusion the transport draws from that and what it does +with it, so every one of them runs a real handshake against a real server; the +certificate-level outcomes are in `test_ssl_context.py`. + +Two properties matter more than the classification itself and are asserted +separately below: the reconnect loop is **not** stopped by a mismatch, because a +returning DHCP lease still fixes this without anyone's help; and nothing the +diagnostic sees can become the pin. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import ssl +from unittest.mock import AsyncMock, MagicMock, patch + +from paho.mqtt.client import ConnectFlags, DisconnectFlags +from paho.mqtt.reasoncodes import ReasonCode +import pytest + +from span_panel_api._ssl import LeafNameMismatch +from span_panel_api.exceptions import SpanPanelCAChangedError +from span_panel_api.mqtt.client import SpanMqttClient +from span_panel_api.mqtt.connection import AsyncMqttBridge +from span_panel_api.mqtt.models import MqttClientConfig + +from conftest import MOCK_SCHEMA, SERIAL +from tls_fixtures import Chain, closed_port, mint_chain, tls_server + +_LOG = "span_panel_api.mqtt.connection" + + +def _bridge(chain: Chain | None, host: str, port: int) -> AsyncMqttBridge: + """A pinned TLS bridge dialling ``host:port``, or an unpinned one for ``None``. + + ``panel_host`` is deliberately not ``host``: the CA re-read goes to the panel's + HTTP endpoint and the diagnostic handshake goes to the broker, and conflating + the two would hide a diagnostic that dialled the wrong one. + """ + return AsyncMqttBridge( + host=host, + port=port, + username="user", + password="pass", + panel_host="panel.invalid", + serial_number=SERIAL, + use_tls=True, + ca_pem=chain.ca_pem if chain is not None else None, + ) + + +def _succeed_connect(bridge: AsyncMqttBridge) -> None: + """Drive the CONNACK the bridge would see on a successful (re)connect.""" + bridge._on_connect( + MagicMock(), + None, + ConnectFlags(session_present=0), + ReasonCode(packetType=2, aName="Success"), + None, + ) + + +def _drive_reconnect_loop(bridge: AsyncMqttBridge, client_mock: MagicMock) -> None: + """Push the bridge into its reconnect loop through a disconnect edge.""" + bridge._on_disconnect( + client_mock, + None, + DisconnectFlags(is_disconnect_packet_from_server=True), + ReasonCode(packetType=2, aName="Success"), + None, + ) + assert bridge._reconnect_task is not None + + +class TestTheDiagnosis: + """`_diagnose_verification_failure`, driven directly, against a live broker.""" + + @pytest.mark.asyncio + async def test_a_moved_panel_is_reported_and_is_not_terminal(self) -> None: + """The whole point: a typed signal, and a transport that keeps trying.""" + chain = mint_chain(names=("panel.local", "10.0.0.5")) + seen: list[LeafNameMismatch] = [] + + with tls_server(chain) as (host, port): + bridge = _bridge(chain, host, port) + bridge.set_leaf_mismatch_callback(seen.append) + with patch("span_panel_api.mqtt.connection.download_ca_cert", return_value=chain.ca_pem): + assert await bridge._diagnose_verification_failure() is None + + assert seen == [LeafNameMismatch(host="127.0.0.1", leaf_names=("panel.local", "10.0.0.5"))] + assert bridge.fatal_error is None + + @pytest.mark.asyncio + async def test_the_warning_names_the_certificate_and_the_configuration(self, caplog: pytest.LogCaptureFixture) -> None: + """Replaces "it could be either" with the answer, for the log a user reads. + + The per-retry warning stays -- it is the only place a mismatch that + nothing subscribed to shows up -- so what changes is that it is now + specific, and specific in the direction that names the remedy. + """ + chain = mint_chain(names=("panel.local",)) + + with tls_server(chain) as (host, port): + bridge = _bridge(chain, host, port) + with ( + patch("span_panel_api.mqtt.connection.download_ca_cert", return_value=chain.ca_pem), + caplog.at_level(logging.WARNING, logger=_LOG), + ): + await bridge._diagnose_verification_failure() + + assert "names panel.local" in caplog.text + assert "configured as 127.0.0.1" in caplog.text + assert "would both look like this" not in caplog.text + + @pytest.mark.asyncio + async def test_reported_once_until_a_successful_connect(self) -> None: + """A mismatch that lasts a week is one condition, not one per backoff tick. + + And a mismatch that comes back after a recovery is a second condition, + which is why the latch is released by the connect rather than never. + """ + chain = mint_chain(names=("panel.local",)) + seen: list[LeafNameMismatch] = [] + + with tls_server(chain) as (host, port): + bridge = _bridge(chain, host, port) + bridge.set_leaf_mismatch_callback(seen.append) + with patch("span_panel_api.mqtt.connection.download_ca_cert", return_value=chain.ca_pem): + await bridge._diagnose_verification_failure() + await bridge._diagnose_verification_failure() + assert len(seen) == 1 + + _succeed_connect(bridge) + await bridge._diagnose_verification_failure() + + assert len(seen) == 2 + assert seen[0] == seen[1] + + @pytest.mark.asyncio + async def test_an_expired_leaf_reports_nothing(self, caplog: pytest.LogCaptureFixture) -> None: + """A panel whose clock reset fixes itself, and a notice saying "wait" is noise.""" + chain = mint_chain(names=("127.0.0.1",), expired=True) + seen: list[LeafNameMismatch] = [] + + with tls_server(chain) as (host, port): + bridge = _bridge(chain, host, port) + bridge.set_leaf_mismatch_callback(seen.append) + with ( + patch("span_panel_api.mqtt.connection.download_ca_cert", return_value=chain.ca_pem), + caplog.at_level(logging.WARNING, logger=_LOG), + ): + assert await bridge._diagnose_verification_failure() is None + + assert seen == [] + assert "still advertises the pinned CA" in caplog.text + assert "rejected too" in caplog.text + + @pytest.mark.asyncio + async def test_an_unreachable_broker_reports_nothing(self, caplog: pytest.LogCaptureFixture) -> None: + """A panel mid-reboot answers neither question, and that is not an answer.""" + chain = mint_chain(names=("panel.local",)) + seen: list[LeafNameMismatch] = [] + + with closed_port() as (host, port): + bridge = _bridge(chain, host, port) + bridge.set_leaf_mismatch_callback(seen.append) + with ( + patch("span_panel_api.mqtt.connection.download_ca_cert", return_value=chain.ca_pem), + caplog.at_level(logging.WARNING, logger=_LOG), + ): + assert await bridge._diagnose_verification_failure() is None + + assert seen == [] + assert "could not reach it" in caplog.text + + @pytest.mark.asyncio + async def test_a_leaf_that_names_the_host_reports_nothing(self, caplog: pytest.LogCaptureFixture) -> None: + """Cannot follow a failed strict handshake, so it is logged and not acted on.""" + chain = mint_chain(names=("127.0.0.1",)) + seen: list[LeafNameMismatch] = [] + + with tls_server(chain) as (host, port): + bridge = _bridge(chain, host, port) + bridge.set_leaf_mismatch_callback(seen.append) + with ( + patch("span_panel_api.mqtt.connection.download_ca_cert", return_value=chain.ca_pem), + caplog.at_level(logging.WARNING, logger=_LOG), + ): + assert await bridge._diagnose_verification_failure() is None + + assert seen == [] + assert "does name 127.0.0.1" in caplog.text + + @pytest.mark.asyncio + async def test_a_rotated_ca_short_circuits_the_name_question(self) -> None: + """The order is load-bearing: a different anchor settles it, names or no names. + + The broker here serves a chain that would read as a name mismatch if + anybody asked. Nobody asks, because the panel is advertising an anchor + that is not the pin -- which is terminal, and which a "you have probably + moved" notice alongside it would only soften. + """ + chain = mint_chain(names=("panel.local",)) + rotated = mint_chain(names=("panel.local",)) + seen: list[LeafNameMismatch] = [] + + with tls_server(chain) as (host, port): + bridge = _bridge(chain, host, port) + bridge.set_leaf_mismatch_callback(seen.append) + with patch("span_panel_api.mqtt.connection.download_ca_cert", return_value=rotated.ca_pem): + verdict = await bridge._diagnose_verification_failure() + + assert isinstance(verdict, SpanPanelCAChangedError) + assert seen == [] + + @pytest.mark.asyncio + async def test_an_unpinned_bridge_never_probes(self) -> None: + """Nothing to verify against, so there is no question to ask.""" + chain = mint_chain(names=("panel.local",)) + seen: list[LeafNameMismatch] = [] + + with tls_server(chain) as (host, port): + bridge = _bridge(None, host, port) + bridge.set_leaf_mismatch_callback(seen.append) + with patch("span_panel_api.mqtt.connection.download_ca_cert") as fetch: + assert await bridge._diagnose_verification_failure() is None + + fetch.assert_not_called() + assert seen == [] + + @pytest.mark.asyncio + async def test_a_raising_subscriber_is_swallowed_and_still_latches(self, caplog: pytest.LogCaptureFixture) -> None: + """The diagnostic runs on the reconnect path; a broken subscriber cannot kill it.""" + chain = mint_chain(names=("panel.local",)) + + def _explode(_mismatch: LeafNameMismatch) -> None: + raise RuntimeError("subscriber is broken") + + with tls_server(chain) as (host, port): + bridge = _bridge(chain, host, port) + bridge.set_leaf_mismatch_callback(_explode) + with ( + patch("span_panel_api.mqtt.connection.download_ca_cert", return_value=chain.ca_pem), + caplog.at_level(logging.WARNING, logger=_LOG), + ): + await bridge._diagnose_verification_failure() + + assert "Leaf-mismatch callback raised" in caplog.text + assert bridge._leaf_mismatch_reported is True + + +class TestThePinIsUntouched: + """The discipline the CA re-read is built on, extended to the second fetch. + + A diagnostic that could re-anchor would be worse than no diagnostic: the + failing handshake is exactly the moment an attacker is answering, and this + path now opens a second connection at that moment. + """ + + @pytest.mark.asyncio + async def test_the_anchor_survives_a_mismatch(self) -> None: + chain = mint_chain(names=("panel.local",)) + + with tls_server(chain) as (host, port): + bridge = _bridge(chain, host, port) + with patch("span_panel_api.mqtt.connection.download_ca_cert", return_value=chain.ca_pem): + await bridge._diagnose_verification_failure() + + assert bridge._ca_pem == chain.ca_pem + + @pytest.mark.asyncio + async def test_no_context_is_rebuilt_from_what_the_diagnostic_saw(self) -> None: + """The transport's own context builder is never reached from this path. + + `probe_leaf_name` builds one of its own, from the pin, and throws it away. + Watching the transport's import of the builder is what separates the two: + a call here would mean a context had been made for the connection out of + something the diagnostic observed. + """ + chain = mint_chain(names=("panel.local",)) + + with tls_server(chain) as (host, port): + bridge = _bridge(chain, host, port) + with ( + patch("span_panel_api.mqtt.connection.download_ca_cert", return_value=chain.ca_pem), + patch("span_panel_api.mqtt.connection.build_panel_ssl_context") as build, + ): + await bridge._diagnose_verification_failure() + + build.assert_not_called() + + @pytest.mark.asyncio + async def test_a_rebuild_after_a_mismatch_still_anchors_on_the_pin(self, mqtt_client_mock: MagicMock) -> None: + """The recovery path a mismatch leaves running must not have moved. + + `mqtt_client_mock` patches the transport's context builder, so what is + asserted is the argument: whatever the broker served during the + diagnostic, the rebuild is handed the pinned PEM and nothing else. + """ + chain = mint_chain(names=("panel.local",)) + + with tls_server(chain) as (host, port): + bridge = _bridge(chain, host, port) + await bridge.connect() + with patch("span_panel_api.mqtt.connection.download_ca_cert", return_value=chain.ca_pem): + await bridge._diagnose_verification_failure() + assert await bridge._rebuild_client() is True + + from span_panel_api.mqtt import connection as conn_mod + + conn_mod.build_panel_ssl_context.assert_called_with(chain.ca_pem) + await bridge.disconnect() + + +class TestTheReconnectLoop: + @pytest.mark.asyncio + async def test_a_moved_panel_keeps_the_loop_running_and_reports_once( + self, mqtt_client_mock: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The behaviour the design turns on: report it, and go on retrying. + + A terminal state here would convert a DHCP lease that comes back in an + hour into an outage that lasts until somebody reloads the integration. + The loop runs several times over the sleep below and the notification + still arrives exactly once. + """ + monkeypatch.setattr("span_panel_api.mqtt.connection.MQTT_RECONNECT_MIN_DELAY_S", 0.01) + chain = mint_chain(names=("panel.local",)) + seen: list[LeafNameMismatch] = [] + + with tls_server(chain) as (host, port): + bridge = _bridge(chain, host, port) + await bridge.connect() + bridge.set_leaf_mismatch_callback(seen.append) + mqtt_client_mock.reconnect.side_effect = ssl.SSLCertVerificationError("hostname mismatch") + + with patch("span_panel_api.mqtt.connection.download_ca_cert", return_value=chain.ca_pem): + _drive_reconnect_loop(bridge, mqtt_client_mock) + await asyncio.sleep(0.3) + + assert mqtt_client_mock.reconnect.call_count > 1 + assert bridge.fatal_error is None + assert bridge._should_reconnect is True + assert seen == [LeafNameMismatch(host="127.0.0.1", leaf_names=("panel.local",))] + + await bridge.disconnect() + + @pytest.mark.asyncio + async def test_a_recovered_connection_re_arms_the_report( + self, mqtt_client_mock: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A second outage after a recovery is a second thing to tell somebody about.""" + monkeypatch.setattr("span_panel_api.mqtt.connection.MQTT_RECONNECT_MIN_DELAY_S", 0.01) + chain = mint_chain(names=("panel.local",)) + seen: list[LeafNameMismatch] = [] + + with tls_server(chain) as (host, port): + bridge = _bridge(chain, host, port) + await bridge.connect() + bridge.set_leaf_mismatch_callback(seen.append) + mqtt_client_mock.reconnect.side_effect = ssl.SSLCertVerificationError("hostname mismatch") + + with patch("span_panel_api.mqtt.connection.download_ca_cert", return_value=chain.ca_pem): + _drive_reconnect_loop(bridge, mqtt_client_mock) + await asyncio.sleep(0.2) + assert len(seen) == 1 + + # The broker comes back on the configured address, then moves again. + _succeed_connect(bridge) + _drive_reconnect_loop(bridge, mqtt_client_mock) + await asyncio.sleep(0.2) + + assert len(seen) == 2 + await bridge.disconnect() + + +class TestTheClientSurface: + """`register_leaf_mismatch_callback`, and the wiring that makes it fire.""" + + @staticmethod + def _client() -> SpanMqttClient: + return SpanMqttClient( + host="panel.invalid", + serial_number=SERIAL, + broker_config=MqttClientConfig(broker_host="broker.local", username="u", password="p"), + ) + + @pytest.mark.asyncio + async def test_the_bridge_is_wired_to_the_client_fan_out(self) -> None: + """Proves the subscription exists, rather than a test making it exist. + + `connect()` is run against a stub bridge and allowed to fail afterwards: + what is under test is the wiring done at construction time, and driving a + full Homie handshake to reach it would be testing the handshake. + """ + client = self._client() + bridge = MagicMock() + bridge.connect = AsyncMock() + + with ( + patch("span_panel_api.mqtt.client.AsyncMqttBridge", return_value=bridge), + patch("span_panel_api.mqtt.client.get_homie_schema", AsyncMock(return_value=MOCK_SCHEMA)), + patch.object(client, "_preload_adapter", AsyncMock()), + patch.object(client, "_build_adapter", MagicMock()), + contextlib.suppress(Exception), + ): + await client.connect() + + bridge.set_leaf_mismatch_callback.assert_called_once_with(client._on_leaf_mismatch) + + @pytest.mark.asyncio + async def test_registered_callback_fires_and_unregisters(self) -> None: + client = self._client() + seen: list[LeafNameMismatch] = [] + unregister = client.register_leaf_mismatch_callback(seen.append) + mismatch = LeafNameMismatch(host="10.0.0.9", leaf_names=("panel.local",)) + + client._on_leaf_mismatch(mismatch) + assert seen == [mismatch] + + unregister() + unregister() # idempotent + client._on_leaf_mismatch(mismatch) + assert seen == [mismatch] + + @pytest.mark.asyncio + async def test_a_raising_subscriber_does_not_swallow_the_rest(self) -> None: + client = self._client() + seen: list[LeafNameMismatch] = [] + + def _explode(_mismatch: LeafNameMismatch) -> None: + raise RuntimeError("subscriber is broken") + + client.register_leaf_mismatch_callback(_explode) + client.register_leaf_mismatch_callback(seen.append) + client._on_leaf_mismatch(LeafNameMismatch(host="10.0.0.9", leaf_names=())) + + assert len(seen) == 1 + + @pytest.mark.asyncio + async def test_a_mismatch_is_not_a_fatal_error(self) -> None: + """The two channels stay separate, because the responses to them differ.""" + client = self._client() + fatal: list[object] = [] + mismatches: list[LeafNameMismatch] = [] + client.register_fatal_error_callback(fatal.append) + client.register_leaf_mismatch_callback(mismatches.append) + + client._on_leaf_mismatch(LeafNameMismatch(host="10.0.0.9", leaf_names=("panel.local",))) + + assert fatal == [] + assert len(mismatches) == 1 diff --git a/tests/test_public_api_unchanged.py b/tests/test_public_api_unchanged.py index 1009ad6..9b11b4c 100644 --- a/tests/test_public_api_unchanged.py +++ b/tests/test_public_api_unchanged.py @@ -101,6 +101,13 @@ # above -- a hand-written SAN matcher reimplemented on the far side of the # pin is the drift this module exists to prevent. "leaf_names_host", + # Added 2026-08-28 (3.3.0): the transport's report that the pinned CA + # validates the broker's certificate and that certificate names somewhere + # other than the address configured. Here rather than in the transport + # section because it is produced by the same module as the two above and + # carries the names that module read. Additive -- a consumer that registers + # no callback never receives one. + "LeafNameMismatch", "delete_fqdn", "download_ca_cert", "get_fqdn", diff --git a/tests/test_ssl_context.py b/tests/test_ssl_context.py index bf0680d..c3a35c1 100644 --- a/tests/test_ssl_context.py +++ b/tests/test_ssl_context.py @@ -6,177 +6,32 @@ with "Missing Authority Key Identifier" — which broke MQTTS against perfectly healthy panels. These tests pin the behaviour so the regression cannot come back silently. + +They also cover the two halves that a caller has to compose when a strict +handshake has already failed: a relaxed context, and the SAN matcher that then +decides the name on its own. ``probe_leaf_name`` is that composition, so its +outcomes are asserted here too, against a real server, rather than against a +mocked socket that could agree with a wrong implementation. """ from __future__ import annotations -import contextlib -import datetime import socket import ssl -import tempfile -import threading -from pathlib import Path import pytest -from span_panel_api._ssl import build_panel_ssl_context, leaf_names_host - -cryptography = pytest.importorskip("cryptography", reason="cryptography needed to mint a test CA") - -from cryptography import x509 # noqa: E402 -from cryptography.hazmat.primitives import hashes, serialization # noqa: E402 -from cryptography.hazmat.primitives.asymmetric import ec # noqa: E402 -from cryptography.x509.oid import NameOID # noqa: E402 +from span_panel_api._ssl import LeafNameMismatch, build_panel_ssl_context, leaf_names_host, probe_leaf_name +from tls_fixtures import closed_port, mint_ca, mint_chain, tls_server -def _self_signed_ca(*, with_aki: bool) -> str: - """Mint a self-signed CA, mirroring what the panel serves. - - With ``with_aki=False`` the certificate omits the Authority Key - Identifier extension, exactly like the SPAN panel's CA. - """ - key = ec.generate_private_key(ec.SECP256R1()) - subject = x509.Name( - [ - x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "SPAN.io"), - x509.NameAttribute(NameOID.COMMON_NAME, "test-panel CA"), - ] - ) - now = datetime.datetime.now(datetime.timezone.utc) - builder = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(subject) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - datetime.timedelta(days=1)) - .not_valid_after(now + datetime.timedelta(days=365)) - .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) - .add_extension( - x509.KeyUsage( - digital_signature=True, - content_commitment=False, - key_encipherment=False, - data_encipherment=False, - key_agreement=False, - key_cert_sign=True, - crl_sign=True, - encipher_only=False, - decipher_only=False, - ), - critical=True, - ) - ) - if with_aki: - builder = builder.add_extension( - x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), - critical=False, - ) - - cert = builder.sign(key, hashes.SHA256()) - return cert.public_bytes(serialization.Encoding.PEM).decode() - - -def _ca_and_leaf(*, with_aki: bool) -> tuple[str, str, str]: - """Mint a CA plus a localhost leaf signed by it. - - Returns ``(ca_pem, leaf_pem, leaf_key_pem)``. When ``with_aki`` is False - neither certificate carries an Authority Key Identifier, reproducing the - chain a SPAN panel actually presents. - """ - ca_key = ec.generate_private_key(ec.SECP256R1()) - ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test-panel CA")]) - now = datetime.datetime.now(datetime.timezone.utc) - - ca_builder = ( - x509.CertificateBuilder() - .subject_name(ca_name) - .issuer_name(ca_name) - .public_key(ca_key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - datetime.timedelta(days=1)) - .not_valid_after(now + datetime.timedelta(days=365)) - .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) - ) - if with_aki: - ca_builder = ca_builder.add_extension(x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), critical=False) - ca_cert = ca_builder.sign(ca_key, hashes.SHA256()) - - leaf_key = ec.generate_private_key(ec.SECP256R1()) - leaf_builder = ( - x509.CertificateBuilder() - .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])) - .issuer_name(ca_name) - .public_key(leaf_key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - datetime.timedelta(days=1)) - .not_valid_after(now + datetime.timedelta(days=365)) - .add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False) - .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) - ) - if with_aki: - leaf_builder = leaf_builder.add_extension( - x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False - ) - leaf_cert = leaf_builder.sign(ca_key, hashes.SHA256()) - - return ( - ca_cert.public_bytes(serialization.Encoding.PEM).decode(), - leaf_cert.public_bytes(serialization.Encoding.PEM).decode(), - leaf_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ).decode(), - ) - - -@contextlib.contextmanager -def _tls_server(leaf_pem: str, leaf_key_pem: str): - """Run a throwaway TLS server on localhost, yielding ``(host, port)``.""" - with tempfile.TemporaryDirectory() as tmp: - cert_path = Path(tmp) / "leaf.pem" - key_path = Path(tmp) / "leaf.key" - cert_path.write_text(leaf_pem) - key_path.write_text(leaf_key_pem) - - server_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - server_ctx.load_cert_chain(certfile=str(cert_path), keyfile=str(key_path)) - - listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - listener.bind(("127.0.0.1", 0)) - listener.listen(1) - listener.settimeout(5) - - def _serve() -> None: - try: - raw, _ = listener.accept() - except OSError: - return - try: - with server_ctx.wrap_socket(raw, server_side=True): - pass - except OSError: - pass - finally: - raw.close() - - thread = threading.Thread(target=_serve, daemon=True) - thread.start() - try: - yield listener.getsockname() - finally: - listener.close() - thread.join(timeout=5) +PROBE_TIMEOUT_S = 5.0 class TestBuildSslContext: def test_loads_ca_without_authority_key_identifier(self) -> None: """The panel's AKI-less CA must load — this is the actual regression.""" - ctx = build_panel_ssl_context(_self_signed_ca(with_aki=False)) + ctx = build_panel_ssl_context(mint_ca(with_aki=False)) assert ctx.verify_mode is ssl.CERT_REQUIRED assert ctx.check_hostname is True @@ -184,10 +39,10 @@ def test_loads_ca_without_authority_key_identifier(self) -> None: def test_handshake_succeeds_against_panel_style_cert(self) -> None: """End-to-end proof: a TLS handshake completes against an AKI-less chain.""" - ca_pem, leaf_pem, leaf_key_pem = _ca_and_leaf(with_aki=False) - ctx = build_panel_ssl_context(ca_pem) + chain = mint_chain(with_aki=False) + ctx = build_panel_ssl_context(chain.ca_pem) - with _tls_server(leaf_pem, leaf_key_pem) as (host, port): + with tls_server(chain) as (host, port): with socket.create_connection((host, port), timeout=5) as raw: with ctx.wrap_socket(raw, server_hostname="localhost") as tls: assert tls.getpeercert() is not None @@ -200,33 +55,33 @@ def test_strict_x509_would_reject_the_panel_chain(self) -> None: AKI-less chains this test fails loudly, telling us the workaround is no longer needed. """ - ca_pem, leaf_pem, leaf_key_pem = _ca_and_leaf(with_aki=False) - strict = build_panel_ssl_context(ca_pem) + chain = mint_chain(with_aki=False) + strict = build_panel_ssl_context(chain.ca_pem) strict.verify_flags |= ssl.VERIFY_X509_STRICT - with _tls_server(leaf_pem, leaf_key_pem) as (host, port): + with tls_server(chain) as (host, port): with socket.create_connection((host, port), timeout=5) as raw: with pytest.raises(ssl.SSLCertVerificationError, match="Authority Key Identifier"): strict.wrap_socket(raw, server_hostname="localhost") def test_strict_flag_is_cleared(self) -> None: - ctx = build_panel_ssl_context(_self_signed_ca(with_aki=False)) + ctx = build_panel_ssl_context(mint_ca(with_aki=False)) assert not (ctx.verify_flags & ssl.VERIFY_X509_STRICT) def test_hostname_and_peer_verification_stay_enabled(self) -> None: """Clearing the strict flag must not weaken the checks that matter.""" - ctx = build_panel_ssl_context(_self_signed_ca(with_aki=True)) + ctx = build_panel_ssl_context(mint_ca(with_aki=True)) assert ctx.check_hostname is True assert ctx.verify_mode is ssl.CERT_REQUIRED def test_system_ca_bundle_is_not_trusted(self) -> None: """Only the panel CA is a trust anchor — no system roots.""" - ctx = build_panel_ssl_context(_self_signed_ca(with_aki=False)) + ctx = build_panel_ssl_context(mint_ca(with_aki=False)) assert len(ctx.get_ca_certs()) == 1 def test_conventional_ca_still_loads(self) -> None: - ctx = build_panel_ssl_context(_self_signed_ca(with_aki=True)) + ctx = build_panel_ssl_context(mint_ca(with_aki=True)) assert ctx.get_ca_certs() def test_malformed_pem_raises(self) -> None: @@ -245,20 +100,20 @@ class TestRelaxedHostnameContext: def test_default_context_refuses_a_host_the_leaf_does_not_name(self) -> None: """The premise. With hostname checking on, the two failures look alike.""" - ca_pem, leaf_pem, leaf_key_pem = _ca_and_leaf(with_aki=False) - ctx = build_panel_ssl_context(ca_pem) + chain = mint_chain(with_aki=False) + ctx = build_panel_ssl_context(chain.ca_pem) - with _tls_server(leaf_pem, leaf_key_pem) as (host, port): + with tls_server(chain) as (host, port): with socket.create_connection((host, port), timeout=5) as raw: with pytest.raises(ssl.SSLCertVerificationError): ctx.wrap_socket(raw, server_hostname="127.0.0.1") def test_relaxed_context_completes_and_yields_the_certificate(self) -> None: """The same connection succeeds, and hands back the leaf to judge.""" - ca_pem, leaf_pem, leaf_key_pem = _ca_and_leaf(with_aki=False) - ctx = build_panel_ssl_context(ca_pem, check_hostname=False) + chain = mint_chain(with_aki=False) + ctx = build_panel_ssl_context(chain.ca_pem, check_hostname=False) - with _tls_server(leaf_pem, leaf_key_pem) as (host, port): + with tls_server(chain) as (host, port): with socket.create_connection((host, port), timeout=5) as raw: with ctx.wrap_socket(raw, server_hostname="127.0.0.1") as tls: peer = tls.getpeercert() @@ -275,17 +130,17 @@ def test_relaxed_context_still_rejects_an_untrusted_chain(self) -> None: An attacker without a key the pinned CA signed must still fail, or the tri-state would be a hole rather than a classification. """ - ca_pem, _, _ = _ca_and_leaf(with_aki=False) - _, other_leaf, other_key = _ca_and_leaf(with_aki=False) - ctx = build_panel_ssl_context(ca_pem, check_hostname=False) + chain = mint_chain(with_aki=False) + impostor = mint_chain(with_aki=False) + ctx = build_panel_ssl_context(chain.ca_pem, check_hostname=False) - with _tls_server(other_leaf, other_key) as (host, port): + with tls_server(impostor) as (host, port): with socket.create_connection((host, port), timeout=5) as raw: with pytest.raises(ssl.SSLCertVerificationError): ctx.wrap_socket(raw, server_hostname="localhost") def test_relaxed_context_keeps_peer_verification_required(self) -> None: - ctx = build_panel_ssl_context(_self_signed_ca(with_aki=False), check_hostname=False) + ctx = build_panel_ssl_context(mint_ca(with_aki=False), check_hostname=False) assert ctx.check_hostname is False assert ctx.verify_mode is ssl.CERT_REQUIRED @@ -293,7 +148,7 @@ def test_relaxed_context_keeps_peer_verification_required(self) -> None: def test_default_is_unchanged(self) -> None: """Every existing caller keeps hostname checking without asking for it.""" - ctx = build_panel_ssl_context(_self_signed_ca(with_aki=False)) + ctx = build_panel_ssl_context(mint_ca(with_aki=False)) assert ctx.check_hostname is True @@ -350,3 +205,122 @@ def test_unparseable_ip_entry_does_not_match(self) -> None: def test_empty_host_matches_nothing(self) -> None: assert not leaf_names_host({"subjectAltName": (("DNS", "panel.local"),)}, "") + + +class TestProbeLeafName: + """The composition: dial a peer under the pin, and judge the name separately. + + Every case here runs a real handshake against a real socket. A mocked one + would happily agree with a wrong implementation, and the whole value of this + function is that it reaches the point of holding a *validated* certificate -- + which is precisely the state a mock cannot reproduce honestly. + """ + + def test_a_leaf_that_omits_the_dialled_address_is_a_mismatch(self) -> None: + """The DHCP move: a good certificate, reached at an address it never names.""" + chain = mint_chain(names=("panel.local", "10.0.0.5")) + + with tls_server(chain) as (host, port): + probe = probe_leaf_name(chain.ca_pem, host, port, timeout=PROBE_TIMEOUT_S) + + assert probe.mismatch == LeafNameMismatch(host="127.0.0.1", leaf_names=("panel.local", "10.0.0.5")) + assert "does not name 127.0.0.1" in probe.detail + + def test_reported_names_keep_certificate_order_and_both_kinds(self) -> None: + """A user reads these back and types one in, so they are verbatim and in order.""" + chain = mint_chain(names=("10.0.0.5", "panel.local", "panel.example.test")) + + with tls_server(chain) as (host, port): + probe = probe_leaf_name(chain.ca_pem, host, port, timeout=PROBE_TIMEOUT_S) + + assert probe.mismatch is not None + assert probe.mismatch.leaf_names == ("10.0.0.5", "panel.local", "panel.example.test") + + def test_a_leaf_that_names_the_dialled_address_is_not_a_mismatch(self) -> None: + """Cannot follow a failed strict handshake, and is a shrug rather than a verdict.""" + chain = mint_chain(names=("127.0.0.1",)) + + with tls_server(chain) as (host, port): + probe = probe_leaf_name(chain.ca_pem, host, port, timeout=PROBE_TIMEOUT_S) + + assert probe.mismatch is None + assert "does name 127.0.0.1" in probe.detail + + def test_an_expired_leaf_is_not_a_mismatch(self) -> None: + """The other half of what a pinned handshake conflates, and the panel fixes it. + + The certificate here names the dialled address perfectly well. Only its + validity window has passed, so the pin rejects it before there is any + name to read -- and reporting a mismatch on that basis would send a user + to change an address that is correct. + """ + chain = mint_chain(names=("127.0.0.1",), expired=True) + + with tls_server(chain) as (host, port): + probe = probe_leaf_name(chain.ca_pem, host, port, timeout=PROBE_TIMEOUT_S) + + assert probe.mismatch is None + assert "rejected too" in probe.detail + assert "expired" in probe.detail + + def test_an_untrusted_chain_is_not_a_mismatch(self) -> None: + """Relaxing the name does not relax trust, so an impostor is still rejected. + + Load-bearing: if this reported a name mismatch, the mismatch signal would + be a way for something the pin does not trust to publish its own names to + a user as addresses to move to. + """ + chain = mint_chain() + impostor = mint_chain(names=("panel.local",)) + + with tls_server(impostor) as (host, port): + probe = probe_leaf_name(chain.ca_pem, host, port, timeout=PROBE_TIMEOUT_S) + + assert probe.mismatch is None + assert "rejected too" in probe.detail + + def test_nothing_listening_is_not_a_mismatch(self) -> None: + """A panel mid-reboot. Missing evidence is not evidence.""" + chain = mint_chain() + + with closed_port() as (host, port): + probe = probe_leaf_name(chain.ca_pem, host, port, timeout=PROBE_TIMEOUT_S) + + assert probe.mismatch is None + assert "could not reach it" in probe.detail + + def test_an_unusable_host_is_not_a_mismatch(self) -> None: + """An empty host is a configuration fault, and still not evidence of one.""" + chain = mint_chain() + + probe = probe_leaf_name(chain.ca_pem, "", 8883, timeout=PROBE_TIMEOUT_S) + + assert probe.mismatch is None + assert "could not reach it" in probe.detail + + def test_a_leaf_naming_nothing_reports_an_empty_tuple(self) -> None: + """A certificate with no SAN names no address, which is a panel problem.""" + chain = mint_chain(names=()) + + with tls_server(chain) as (host, port): + probe = probe_leaf_name(chain.ca_pem, host, port, timeout=PROBE_TIMEOUT_S) + + assert probe.mismatch is not None + assert probe.mismatch.leaf_names == () + + def test_the_anchor_is_never_replaced_by_what_the_peer_served(self) -> None: + """The discipline the whole path is built on, asserted at its lowest level. + + A probe that saw a perfectly valid certificate from some other CA must + leave the caller's anchor exactly as it found it -- the function returns + a verdict, and there is no way for a certificate to travel back out of it. + """ + chain = mint_chain() + impostor = mint_chain(names=("127.0.0.1",)) + pinned_before = chain.ca_pem + + with tls_server(impostor) as (host, port): + probe_leaf_name(chain.ca_pem, host, port, timeout=PROBE_TIMEOUT_S) + + assert chain.ca_pem == pinned_before + assert impostor.ca_pem not in chain.ca_pem diff --git a/tests/tls_fixtures.py b/tests/tls_fixtures.py new file mode 100644 index 0000000..2730916 --- /dev/null +++ b/tests/tls_fixtures.py @@ -0,0 +1,246 @@ +"""Minting a panel-shaped certificate chain, and serving one over TLS. + +Shared rather than per-module because more than one suite now needs a *real* +handshake to say anything: `test_ssl_context.py` proves the context and the SAN +matcher behave, and `test_leaf_name_mismatch.py` proves the transport draws the +right conclusion from what a live peer serves. Two implementations of "mint a CA +and stand a server up on it" would drift, and the certificates are the premise of +both -- a second, subtly different chain would leave one suite testing something +the other does not. + +Everything here is a throwaway: keys are generated per call, the server binds an +ephemeral port on the loopback, and nothing is written outside a temporary +directory that goes away with the context manager. +""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +import contextlib +from dataclasses import dataclass +import datetime +import ipaddress +from pathlib import Path +import socket +import ssl +import tempfile +import threading + +import pytest + +pytest.importorskip("cryptography", reason="cryptography needed to mint a test CA") + +from cryptography import x509 # noqa: E402 +from cryptography.hazmat.primitives import hashes, serialization # noqa: E402 +from cryptography.hazmat.primitives.asymmetric import ec # noqa: E402 +from cryptography.x509.oid import NameOID # noqa: E402 + +_CA_COMMON_NAME = "test-panel CA" + + +@dataclass(frozen=True) +class Chain: + """A CA and one leaf signed by it, all in PEM form.""" + + ca_pem: str + leaf_pem: str + leaf_key_pem: str + + +def mint_ca(*, with_aki: bool) -> str: + """Mint a self-signed CA, mirroring what the panel serves. + + With ``with_aki=False`` the certificate omits the Authority Key Identifier + extension, exactly like the SPAN panel's CA. + """ + key = ec.generate_private_key(ec.SECP256R1()) + subject = x509.Name( + [ + x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "SPAN.io"), + x509.NameAttribute(NameOID.COMMON_NAME, _CA_COMMON_NAME), + ] + ) + now = datetime.datetime.now(datetime.timezone.utc) + builder = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=365)) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=True, + crl_sign=True, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + ) + if with_aki: + builder = builder.add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), + critical=False, + ) + + cert = builder.sign(key, hashes.SHA256()) + return cert.public_bytes(serialization.Encoding.PEM).decode() + + +def _san(names: Sequence[str]) -> x509.SubjectAlternativeName: + """Build a SAN from plain strings, in the order given. + + An entry that parses as an address becomes an ``IP Address`` and everything + else becomes a ``DNS`` name, which is the split the panel's own template + makes and the one `leaf_names_host` refuses to blur. Order is preserved + because a caller asserting on what a certificate reports back is asserting on + certificate order. + """ + entries: list[x509.GeneralName] = [] + for name in names: + try: + entries.append(x509.IPAddress(ipaddress.ip_address(name))) + except ValueError: + entries.append(x509.DNSName(name)) + return x509.SubjectAlternativeName(entries) + + +def mint_chain(*, names: Sequence[str] = ("localhost",), with_aki: bool = False, expired: bool = False) -> Chain: + """Mint a CA plus a leaf signed by it that names ``names``. + + ``with_aki=False`` reproduces the chain a SPAN panel actually presents: + neither certificate carries an Authority Key Identifier. + + ``expired=True`` back-dates the leaf's validity window so the chain is + rejected under its own CA -- the panel whose clock reset after a power cut, + which is one of the two failures a pinned handshake cannot tell apart on its + own. The CA stays valid, so what fails is the leaf and only the leaf. + + An empty ``names`` omits the SAN extension entirely rather than adding an + empty one, because "a certificate that names nothing" is the case worth + reproducing and that is the shape it takes in the field. + """ + ca_key = ec.generate_private_key(ec.SECP256R1()) + ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, _CA_COMMON_NAME)]) + now = datetime.datetime.now(datetime.timezone.utc) + + ca_builder = ( + x509.CertificateBuilder() + .subject_name(ca_name) + .issuer_name(ca_name) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=365)) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + ) + if with_aki: + ca_builder = ca_builder.add_extension(x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), critical=False) + ca_cert = ca_builder.sign(ca_key, hashes.SHA256()) + + leaf_from = now - datetime.timedelta(days=30) if expired else now - datetime.timedelta(days=1) + leaf_until = now - datetime.timedelta(days=1) if expired else now + datetime.timedelta(days=365) + + leaf_key = ec.generate_private_key(ec.SECP256R1()) + leaf_builder = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, names[0] if names else "unnamed")])) + .issuer_name(ca_name) + .public_key(leaf_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(leaf_from) + .not_valid_after(leaf_until) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + ) + if names: + leaf_builder = leaf_builder.add_extension(_san(names), critical=False) + if with_aki: + leaf_builder = leaf_builder.add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False + ) + leaf_cert = leaf_builder.sign(ca_key, hashes.SHA256()) + + return Chain( + ca_pem=ca_cert.public_bytes(serialization.Encoding.PEM).decode(), + leaf_pem=leaf_cert.public_bytes(serialization.Encoding.PEM).decode(), + leaf_key_pem=leaf_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode(), + ) + + +@contextlib.contextmanager +def tls_server(chain: Chain) -> Iterator[tuple[str, int]]: + """Serve ``chain``'s leaf on the loopback until the block exits. + + Yields ``(host, port)``. Accepts repeatedly rather than once, because a + caller under test may handshake several times -- a reconnect loop that + diagnoses on every backoff tick does exactly that -- and a server that served + a single connection would make the second attempt look like a panel that had + gone away, which is a different verdict entirely. + """ + with tempfile.TemporaryDirectory() as tmp: + cert_path = Path(tmp) / "leaf.pem" + key_path = Path(tmp) / "leaf.key" + cert_path.write_text(chain.leaf_pem) + key_path.write_text(chain.leaf_key_pem) + + server_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_ctx.load_cert_chain(certfile=str(cert_path), keyfile=str(key_path)) + + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", 0)) + listener.listen(5) + # Short, and looped: the accept has to wake often enough to notice the + # stop flag, without putting a ceiling on how long the block may run. + listener.settimeout(0.25) + stop = threading.Event() + + def _serve() -> None: + while not stop.is_set(): + try: + raw, _ = listener.accept() + except TimeoutError: + continue + except OSError: + return + try: + with server_ctx.wrap_socket(raw, server_side=True): + pass + except OSError: + # A client that rejects the certificate aborts the + # handshake, which is the point of several of these tests. + pass + finally: + raw.close() + + thread = threading.Thread(target=_serve, daemon=True) + thread.start() + try: + yield listener.getsockname() + finally: + stop.set() + listener.close() + thread.join(timeout=5) + + +@contextlib.contextmanager +def closed_port() -> Iterator[tuple[str, int]]: + """Yield a loopback ``(host, port)`` that nothing is listening on.""" + probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + probe.bind(("127.0.0.1", 0)) + address = probe.getsockname() + probe.close() + yield address diff --git a/uv.lock b/uv.lock index 7416132..46355a3 100644 --- a/uv.lock +++ b/uv.lock @@ -975,7 +975,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "3.2.0" +version = "3.3.0" source = { editable = "." } dependencies = [ { name = "httpx" },