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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "span-panel-api"
version = "3.2.0"
version = "3.3.0"
description = "A client library for SPAN Panel API"
authors = [
{name = "SpanPanel"}
Expand Down
8 changes: 7 additions & 1 deletion src/span_panel_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
171 changes: 156 additions & 15 deletions src/span_panel_api/_ssl.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,54 @@
"""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

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

_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.
Expand Down Expand Up @@ -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()
Expand All @@ -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:
Expand Down
14 changes: 11 additions & 3 deletions src/span_panel_api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,24 @@ 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
certificate whose fingerprint differs from the pinned one -- so
``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
Expand Down
Loading
Loading