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" },