span-panel-api 3.2.0__tar.gz → 3.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/CHANGELOG.md +15 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/PKG-INFO +20 -4
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/README.md +19 -3
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/pyproject.toml +1 -1
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/__init__.py +7 -1
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/_ssl.py +156 -15
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/exceptions.py +11 -3
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/client.py +47 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/connection.py +153 -34
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/const.py +8 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/protocol.py +19 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_ca_pinning.py +35 -6
- span_panel_api-3.3.0/tests/test_leaf_name_mismatch.py +465 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_public_api_unchanged.py +7 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_ssl_context.py +151 -177
- span_panel_api-3.3.0/tests/tls_fixtures.py +246 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/.gitignore +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/LICENSE +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/auth.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/models.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/control.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/conftest.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/fixtures/panelbench_wire.json +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/reference_payloads/README.md +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/reference_payloads/bootstrap.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/reference_payloads/schema_one.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_absent_readings_are_not_zero.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_adoption.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_auth_redaction.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_catalog_divergence.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_control_interceptor.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_detection_auth.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_https_transport.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_mqtt_homie.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_packaging.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_plaintext_warning.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_publish_outcome.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_redispatch_on_reconnect.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_reference_tree_values.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_rest_transport_contract.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_migration_delta.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_adapter.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_charge_limit.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_circuits.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_conformance.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_connection_health.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_control_refusal.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_devices.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_discovery.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_extension.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_panel.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_snapshot.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_shared_http_client.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_v2_status_parser.py +0 -0
|
@@ -7,6 +7,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
|
7
7
|
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
|
|
8
8
|
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.
|
|
9
9
|
|
|
10
|
+
## [3.3.0]
|
|
11
|
+
|
|
12
|
+
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.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`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.
|
|
17
|
+
- **`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.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **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
|
|
22
|
+
either.
|
|
23
|
+
- **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.
|
|
24
|
+
|
|
10
25
|
## [3.2.0]
|
|
11
26
|
|
|
12
27
|
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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.3.0
|
|
4
4
|
Summary: A client library for SPAN Panel API
|
|
5
5
|
Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
|
|
6
6
|
Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
|
|
@@ -420,9 +420,25 @@ context = build_panel_ssl_context(stored_pem)
|
|
|
420
420
|
fingerprint = ca_fingerprint(stored_pem)
|
|
421
421
|
```
|
|
422
422
|
|
|
423
|
-
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
|
|
424
|
-
|
|
425
|
-
|
|
423
|
+
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
|
|
424
|
+
expired leaf and a panel that has moved all raise the identical error, and the failed handshake carries no evidence about which.
|
|
425
|
+
|
|
426
|
+
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
|
|
427
|
+
nothing still cannot mistake a dead bridge for a healthy one, because `ping()` and `get_snapshot()` re-raise.
|
|
428
|
+
|
|
429
|
+
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
|
|
430
|
+
the certificate names the address being dialled.
|
|
431
|
+
|
|
432
|
+
```python
|
|
433
|
+
def moved(mismatch: LeafNameMismatch) -> None:
|
|
434
|
+
print(f"configured as {mismatch.host}, certificate names {', '.join(mismatch.leaf_names)}")
|
|
435
|
+
|
|
436
|
+
unregister = client.register_leaf_mismatch_callback(moved)
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
**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
|
|
440
|
+
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
|
|
441
|
+
handshake can re-anchor anything: both are diagnostic, and the pin is the pin whatever the panel served.
|
|
426
442
|
|
|
427
443
|
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
|
|
428
444
|
be fingerprint-confirmed out of band before it is trusted.
|
|
@@ -393,9 +393,25 @@ context = build_panel_ssl_context(stored_pem)
|
|
|
393
393
|
fingerprint = ca_fingerprint(stored_pem)
|
|
394
394
|
```
|
|
395
395
|
|
|
396
|
-
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
|
|
397
|
-
|
|
398
|
-
|
|
396
|
+
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
|
|
397
|
+
expired leaf and a panel that has moved all raise the identical error, and the failed handshake carries no evidence about which.
|
|
398
|
+
|
|
399
|
+
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
|
|
400
|
+
nothing still cannot mistake a dead bridge for a healthy one, because `ping()` and `get_snapshot()` re-raise.
|
|
401
|
+
|
|
402
|
+
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
|
|
403
|
+
the certificate names the address being dialled.
|
|
404
|
+
|
|
405
|
+
```python
|
|
406
|
+
def moved(mismatch: LeafNameMismatch) -> None:
|
|
407
|
+
print(f"configured as {mismatch.host}, certificate names {', '.join(mismatch.leaf_names)}")
|
|
408
|
+
|
|
409
|
+
unregister = client.register_leaf_mismatch_callback(moved)
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
**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
|
|
413
|
+
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
|
|
414
|
+
handshake can re-anchor anything: both are diagnostic, and the pin is the pin whatever the panel served.
|
|
399
415
|
|
|
400
416
|
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
|
|
401
417
|
be fingerprint-confirmed out of band before it is trusted.
|
|
@@ -6,7 +6,7 @@ supporting MQTT/Homie (v2) transport.
|
|
|
6
6
|
|
|
7
7
|
from importlib.metadata import version as _pkg_version
|
|
8
8
|
|
|
9
|
-
from ._ssl import build_panel_ssl_context, ca_fingerprint, leaf_names_host
|
|
9
|
+
from ._ssl import LeafNameMismatch, build_panel_ssl_context, ca_fingerprint, leaf_names_host
|
|
10
10
|
from .auth import (
|
|
11
11
|
delete_fqdn,
|
|
12
12
|
download_ca_cert,
|
|
@@ -157,6 +157,12 @@ __all__ = [ # noqa: RUF022
|
|
|
157
157
|
# Added 2026-08-28: the hostname half of verification, split out so a
|
|
158
158
|
# caller using a relaxed context can still establish the name binding.
|
|
159
159
|
"leaf_names_host",
|
|
160
|
+
# Added 2026-08-28 (3.3.0): what the transport reports when the pinned CA
|
|
161
|
+
# validates the broker's certificate and that certificate names somewhere
|
|
162
|
+
# else. Purely additive -- a consumer that registers no leaf-mismatch
|
|
163
|
+
# callback never receives one, and the reconnect behaviour it accompanies is
|
|
164
|
+
# unchanged.
|
|
165
|
+
"LeafNameMismatch",
|
|
160
166
|
"delete_fqdn",
|
|
161
167
|
"download_ca_cert",
|
|
162
168
|
"get_fqdn",
|
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
"""The panel's trust anchor: building a context from it, and naming it.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
the
|
|
6
|
-
lives in ``auth.download_ca_cert``,
|
|
7
|
-
trusted lives with the caller.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
3
|
+
``build_panel_ssl_context``, ``leaf_names_host`` and ``ca_fingerprint`` take a CA
|
|
4
|
+
in PEM form and nothing else. They make no network call and hold no state, which
|
|
5
|
+
is the point -- a trust anchor that is fetched at the moment it is used is not an
|
|
6
|
+
anchor, it is whatever answered. The fetching lives in ``auth.download_ca_cert``,
|
|
7
|
+
and deciding whether a fetched PEM may be trusted lives with the caller.
|
|
8
|
+
|
|
9
|
+
Those three are public (``_ssl`` is a module-name convention here, and all three
|
|
10
|
+
are re-exported from the package root) because the consumer needs them: it builds
|
|
11
|
+
the same context for its own HTTPS calls, it prints and compares the same
|
|
12
|
+
fingerprint string, and it applies the same hostname rules when it has to judge a
|
|
13
|
+
name binding for itself. Two implementations of a fingerprint that must agree
|
|
14
|
+
byte-for-byte is a defect waiting for a firmware upgrade to find it, and the same
|
|
15
|
+
is true of a hand-written hostname matcher -- more so, since that one is
|
|
16
|
+
security-relevant and has no standard-library implementation left to defer to
|
|
17
|
+
since ``ssl.match_hostname`` was removed in Python 3.12.
|
|
18
|
+
|
|
19
|
+
``probe_leaf_name`` is the one thing here that does open a socket, and it is the
|
|
20
|
+
same argument carried one step further. A failed pinned handshake carries no
|
|
21
|
+
evidence about *why* it failed, so somebody has to ask the peer a second, narrower
|
|
22
|
+
question -- and that question is a composition of the anchor, the relaxed context
|
|
23
|
+
and the SAN matcher, all of which live in this module. Written once here rather
|
|
24
|
+
than at each caller for exactly the reason the matcher is: a second implementation
|
|
25
|
+
of "does this certificate name this host" is the drift the module exists to
|
|
26
|
+
prevent. It anchors on the CA it is handed and returns a verdict, never a
|
|
27
|
+
certificate to trust -- nothing it sees can become an anchor.
|
|
18
28
|
"""
|
|
19
29
|
|
|
20
30
|
from __future__ import annotations
|
|
@@ -22,8 +32,10 @@ from __future__ import annotations
|
|
|
22
32
|
import base64
|
|
23
33
|
import binascii
|
|
24
34
|
from collections.abc import Iterator, Mapping
|
|
35
|
+
from dataclasses import dataclass
|
|
25
36
|
import hashlib
|
|
26
37
|
import ipaddress
|
|
38
|
+
import socket
|
|
27
39
|
import ssl
|
|
28
40
|
|
|
29
41
|
from .exceptions import SpanPanelValidationError
|
|
@@ -31,6 +43,12 @@ from .exceptions import SpanPanelValidationError
|
|
|
31
43
|
_PEM_HEADER = "-----BEGIN CERTIFICATE-----"
|
|
32
44
|
_PEM_FOOTER = "-----END CERTIFICATE-----"
|
|
33
45
|
|
|
46
|
+
#: The SAN entry kinds this library reads. A panel names literal addresses, so
|
|
47
|
+
#: these are the two that can carry one; anything else in a SAN (``email``, a
|
|
48
|
+
#: ``URI``) names something that is not a host and would only mislead a user
|
|
49
|
+
#: reading the list back.
|
|
50
|
+
_ADDRESSING_SAN_KINDS = ("DNS", "IP Address")
|
|
51
|
+
|
|
34
52
|
|
|
35
53
|
def build_panel_ssl_context(ca_pem: str, *, check_hostname: bool = True) -> ssl.SSLContext:
|
|
36
54
|
"""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:
|
|
|
127
145
|
return _names_address(entries, wanted)
|
|
128
146
|
|
|
129
147
|
|
|
148
|
+
@dataclass(frozen=True, slots=True)
|
|
149
|
+
class LeafNameMismatch:
|
|
150
|
+
"""A peer whose certificate the pinned CA validates, and which does not name ``host``.
|
|
151
|
+
|
|
152
|
+
The one thing that can be established about a failed pinned handshake beyond
|
|
153
|
+
"something is wrong": the panel is who it says it is, and it is not where the
|
|
154
|
+
configuration says it is. Not an exception, because it is not fatal and
|
|
155
|
+
nothing is being refused -- the transport keeps retrying, and a DHCP lease
|
|
156
|
+
that comes back or a panel that finishes registering its name fixes this with
|
|
157
|
+
nobody's help. It is a fact reported to whoever asked to be told, so that a
|
|
158
|
+
consumer can put the remedy in front of a person instead of leaving them to
|
|
159
|
+
read a log.
|
|
160
|
+
|
|
161
|
+
``leaf_names`` is what the certificate actually carries -- its SAN ``DNS`` and
|
|
162
|
+
``IP Address`` entries, in certificate order -- because the remedy is to
|
|
163
|
+
re-point the configuration at one of them, and a message that says only "the
|
|
164
|
+
name is wrong" does not tell anyone what the right one is. Empty is possible
|
|
165
|
+
and means the certificate names no address at all, which is a panel problem
|
|
166
|
+
rather than an addressing one.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
host: str
|
|
170
|
+
leaf_names: tuple[str, ...]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True, slots=True)
|
|
174
|
+
class LeafProbe:
|
|
175
|
+
"""The result of one relaxed diagnostic handshake.
|
|
176
|
+
|
|
177
|
+
``mismatch`` is set for the single outcome that is actionable and is ``None``
|
|
178
|
+
for every other, because every other one is transient and the caller's
|
|
179
|
+
response to all of them is the same: keep retrying. ``detail`` says which,
|
|
180
|
+
as a phrase fit to drop into a log line, so that a caller can be specific
|
|
181
|
+
about a verdict it must not act on differently.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
mismatch: LeafNameMismatch | None
|
|
185
|
+
detail: str
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def probe_leaf_name(ca_pem: str, host: str, port: int, *, timeout: float) -> LeafProbe:
|
|
189
|
+
"""Ask ``host`` directly whether the certificate it serves names ``host``.
|
|
190
|
+
|
|
191
|
+
**Diagnostic only.** One handshake, under the CA it is handed, with hostname
|
|
192
|
+
checking relaxed. Nothing it observes is stored, no context is built from it
|
|
193
|
+
for any other use, and the anchor it verifies against is the caller's pin
|
|
194
|
+
unchanged -- a peer cannot become trusted by answering this call. The chain,
|
|
195
|
+
the signature and the expiry are all still verified, which is what makes the
|
|
196
|
+
remaining question meaningful: a peer that gets as far as being *named
|
|
197
|
+
wrongly* has already proved it holds a key the pin signed.
|
|
198
|
+
|
|
199
|
+
Blocking, and deliberately so -- ``ssl`` offers no non-blocking handshake
|
|
200
|
+
worth the machinery here, and the one caller has an executor. It is not
|
|
201
|
+
exported from the package root for that reason: a blocking call on an async
|
|
202
|
+
library's public surface is a footgun, and the consumer's own decisions about
|
|
203
|
+
which host to talk to are made in a config flow that already composes
|
|
204
|
+
:func:`build_panel_ssl_context` and :func:`leaf_names_host` for itself.
|
|
205
|
+
|
|
206
|
+
Four outcomes, and only the last is not a shrug:
|
|
207
|
+
|
|
208
|
+
- the peer rejects under the pin -- an expired leaf, most often a panel whose
|
|
209
|
+
clock reset after a power cut, and nothing anyone can act on;
|
|
210
|
+
- nothing answers -- a panel mid-reboot;
|
|
211
|
+
- the certificate names ``host`` -- which cannot follow a strict handshake
|
|
212
|
+
that failed, and is reported as transient rather than reasoned about,
|
|
213
|
+
because a contradiction is not evidence of anything;
|
|
214
|
+
- the certificate does not name ``host`` -- the mismatch.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
ca_pem: The pinned CA, verified against and never replaced.
|
|
218
|
+
host: The name to dial and the name to look for. Both, deliberately:
|
|
219
|
+
the question is whether the peer reached *by this name* carries it.
|
|
220
|
+
port: The port to dial.
|
|
221
|
+
timeout: Seconds allowed for the connection and the handshake together.
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
ssl.SSLError: ``ca_pem`` is not a certificate the ssl module accepts.
|
|
225
|
+
ValueError: ``ca_pem`` is malformed in a way ``ssl`` reports as such.
|
|
226
|
+
"""
|
|
227
|
+
context = build_panel_ssl_context(ca_pem, check_hostname=False)
|
|
228
|
+
try:
|
|
229
|
+
with (
|
|
230
|
+
socket.create_connection((host, port), timeout=timeout) as raw,
|
|
231
|
+
context.wrap_socket(raw, server_hostname=host) as tls,
|
|
232
|
+
):
|
|
233
|
+
peer = tls.getpeercert()
|
|
234
|
+
except ssl.SSLCertVerificationError as exc:
|
|
235
|
+
# Ahead of OSError because it is one: SSLCertVerificationError derives
|
|
236
|
+
# from SSLError derives from OSError, and this is the branch that means
|
|
237
|
+
# "the peer answered and the pin rejected it" rather than "nothing
|
|
238
|
+
# answered".
|
|
239
|
+
return LeafProbe(None, f"a second look with the hostname check relaxed was rejected too ({exc.verify_message})")
|
|
240
|
+
except (OSError, ValueError) as exc:
|
|
241
|
+
# Every remaining transport failure, including the non-verification TLS
|
|
242
|
+
# errors: refused, unresolvable, timed out, a handshake that went wrong
|
|
243
|
+
# for a reason the pin has no opinion about. ValueError because an empty
|
|
244
|
+
# `host` is one, and an unusable configuration is still not evidence.
|
|
245
|
+
return LeafProbe(None, f"a second look with the hostname check relaxed could not reach it ({exc})")
|
|
246
|
+
if peer is None:
|
|
247
|
+
# Only reachable with verification off, which this context never has.
|
|
248
|
+
# Kept because the alternative is reading a mismatch out of an empty
|
|
249
|
+
# certificate and naming no addresses in the report.
|
|
250
|
+
return LeafProbe(None, "a second look with the hostname check relaxed produced no certificate to read")
|
|
251
|
+
if leaf_names_host(peer, host):
|
|
252
|
+
return LeafProbe(None, f"the certificate it serves does name {host}, so the failure was something else")
|
|
253
|
+
return LeafProbe(
|
|
254
|
+
LeafNameMismatch(host=host, leaf_names=_san_names(peer)),
|
|
255
|
+
f"the certificate it serves does not name {host}",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
130
259
|
def _without_root_dot(name: str) -> str:
|
|
131
260
|
"""Strip surrounding space and a single root dot, which is not significant."""
|
|
132
261
|
stripped = name.strip()
|
|
@@ -150,6 +279,18 @@ def _san_entries(peer_cert: Mapping[str, object]) -> Iterator[tuple[str, str]]:
|
|
|
150
279
|
yield kind, value
|
|
151
280
|
|
|
152
281
|
|
|
282
|
+
def _san_names(peer_cert: Mapping[str, object]) -> tuple[str, ...]:
|
|
283
|
+
"""The addresses a certificate names, in certificate order.
|
|
284
|
+
|
|
285
|
+
Verbatim, without normalisation: a user is going to read these back and type
|
|
286
|
+
one of them into a configuration field, so what is reported has to be what
|
|
287
|
+
the certificate says rather than a casefolded or dot-stripped rendering of
|
|
288
|
+
it. Order is the certificate's because the first entry is conventionally the
|
|
289
|
+
primary name, and re-sorting would lose that for nothing.
|
|
290
|
+
"""
|
|
291
|
+
return tuple(value for kind, value in _san_entries(peer_cert) if kind in _ADDRESSING_SAN_KINDS)
|
|
292
|
+
|
|
293
|
+
|
|
153
294
|
def _names_address(entries: list[tuple[str, str]], wanted: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
|
154
295
|
"""Whether an ``IP Address`` entry denotes ``wanted``, compared as addresses."""
|
|
155
296
|
for kind, value in entries:
|
|
@@ -49,9 +49,9 @@ class SpanPanelCAChangedError(SpanPanelError):
|
|
|
49
49
|
a client waiting to succeed against whatever is answering, which is the
|
|
50
50
|
outcome pinning exists to prevent.
|
|
51
51
|
|
|
52
|
-
It is also not a conclusion drawn from
|
|
53
|
-
|
|
54
|
-
a power outage) and a hostname mismatch (a panel whose address moved)
|
|
52
|
+
It is also not a conclusion drawn from the failed handshake, because that
|
|
53
|
+
handshake cannot support one: an expired leaf (a panel whose clock reset
|
|
54
|
+
after a power outage) and a hostname mismatch (a panel whose address moved)
|
|
55
55
|
raise the same verification error against a perfectly valid pinned CA, and
|
|
56
56
|
the ``ssl`` module exposes no peer chain when verification fails. This is
|
|
57
57
|
raised only after a separate fetch of the panel's advertised CA returned a
|
|
@@ -59,6 +59,14 @@ class SpanPanelCAChangedError(SpanPanelError):
|
|
|
59
59
|
``observed_fingerprint`` is what the panel says its anchor is now, not what
|
|
60
60
|
it presented on the connection that failed.
|
|
61
61
|
|
|
62
|
+
The other two are told apart afterwards and elsewhere, by a *second*
|
|
63
|
+
handshake with hostname checking relaxed (``_ssl.probe_leaf_name``), which
|
|
64
|
+
reaches the point of holding a validated certificate and can therefore read
|
|
65
|
+
its names. That path never produces this error: a leaf that chains to the pin
|
|
66
|
+
has proved the panel is the panel, so the worst it can report is
|
|
67
|
+
``LeafNameMismatch``, which is not fatal and is retried like any other
|
|
68
|
+
address problem.
|
|
69
|
+
|
|
62
70
|
The two remedies are opposite and only the user can choose between them, so
|
|
63
71
|
both fingerprints are carried: re-pin, if the panel's CA was legitimately
|
|
64
72
|
rotated by a firmware upgrade or a factory reset, or investigate, if it was
|
|
@@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, NoReturn
|
|
|
20
20
|
|
|
21
21
|
from span_panel_api.schema_drift import log_schema_drift
|
|
22
22
|
|
|
23
|
+
from .._ssl import LeafNameMismatch
|
|
23
24
|
from ..adapters import installed_adapter_keys, resolve_adapter
|
|
24
25
|
from ..auth import get_homie_schema
|
|
25
26
|
from ..dispatch import select_adapter_key
|
|
@@ -167,6 +168,7 @@ class SpanMqttClient:
|
|
|
167
168
|
self._snapshot_callbacks: list[Callable[[SpanPanelSnapshot], Awaitable[None]]] = []
|
|
168
169
|
self._connection_callbacks: list[Callable[[bool], None]] = []
|
|
169
170
|
self._fatal_error_callbacks: list[Callable[[SpanPanelError], None]] = []
|
|
171
|
+
self._leaf_mismatch_callbacks: list[Callable[[LeafNameMismatch], None]] = []
|
|
170
172
|
self._schema_change_callbacks: list[Callable[[str | None, str | None], None]] = []
|
|
171
173
|
self._live = False
|
|
172
174
|
self._ready_event: asyncio.Event | None = None
|
|
@@ -456,6 +458,7 @@ class SpanMqttClient:
|
|
|
456
458
|
self._bridge.set_message_callback(self._on_message)
|
|
457
459
|
self._bridge.set_connection_callback(self._on_connection_change)
|
|
458
460
|
self._bridge.set_fatal_error_callback(self._on_fatal_error)
|
|
461
|
+
self._bridge.set_leaf_mismatch_callback(self._on_leaf_mismatch)
|
|
459
462
|
# Pre-rebuild hook: reset Homie accumulator before the bridge swaps
|
|
460
463
|
# paho clients, so retained messages on the new subscription start
|
|
461
464
|
# from a clean slate (no stale `$state=disconnected` cached from
|
|
@@ -631,6 +634,50 @@ class SpanMqttClient:
|
|
|
631
634
|
except Exception: # pylint: disable=broad-exception-caught
|
|
632
635
|
_LOGGER.warning("Fatal-error callback raised", exc_info=True)
|
|
633
636
|
|
|
637
|
+
def register_leaf_mismatch_callback(self, callback: Callable[[LeafNameMismatch], None]) -> Callable[[], None]:
|
|
638
|
+
"""Subscribe to the broker's certificate naming somewhere other than here.
|
|
639
|
+
|
|
640
|
+
Fires with the address this client dials and the addresses the broker's
|
|
641
|
+
certificate actually carries, once the pinned CA has been confirmed as
|
|
642
|
+
still the panel's own. So it says something quite narrow and quite
|
|
643
|
+
useful: this *is* the panel, and it is not where the configuration says
|
|
644
|
+
it is -- most often a panel that took a new DHCP lease.
|
|
645
|
+
|
|
646
|
+
Not a fatal error and deliberately not on that channel. The transport
|
|
647
|
+
keeps retrying and recovers by itself if the panel comes back to the
|
|
648
|
+
configured address, so a consumer should surface the remedy -- re-point
|
|
649
|
+
the configuration at one of the names reported -- rather than tear
|
|
650
|
+
anything down. Nothing else re-raises it, because there is nothing to
|
|
651
|
+
raise: `ping()` and `get_snapshot()` go on reporting an ordinary outage,
|
|
652
|
+
which is what this is until somebody decides otherwise.
|
|
653
|
+
|
|
654
|
+
Fires at most once per outage: the next successful connect re-arms it, so
|
|
655
|
+
a mismatch that lasts a week is one notification and a mismatch that
|
|
656
|
+
recurs after a recovery is a second one.
|
|
657
|
+
|
|
658
|
+
Returns an unregister function. Calling it twice is safe.
|
|
659
|
+
"""
|
|
660
|
+
self._leaf_mismatch_callbacks.append(callback)
|
|
661
|
+
|
|
662
|
+
def unregister() -> None:
|
|
663
|
+
with contextlib.suppress(ValueError):
|
|
664
|
+
self._leaf_mismatch_callbacks.remove(callback)
|
|
665
|
+
|
|
666
|
+
return unregister
|
|
667
|
+
|
|
668
|
+
def _on_leaf_mismatch(self, mismatch: LeafNameMismatch) -> None:
|
|
669
|
+
"""Fan the bridge's name-mismatch report out to subscribers.
|
|
670
|
+
|
|
671
|
+
Iterates a copy for the same reason the other two fan-outs do: a
|
|
672
|
+
subscriber unregistering from inside its own callback must not mutate
|
|
673
|
+
the list being walked.
|
|
674
|
+
"""
|
|
675
|
+
for cb in list(self._leaf_mismatch_callbacks):
|
|
676
|
+
try:
|
|
677
|
+
cb(mismatch)
|
|
678
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
679
|
+
_LOGGER.warning("Leaf-mismatch callback raised", exc_info=True)
|
|
680
|
+
|
|
634
681
|
def register_schema_change_callback(self, callback: Callable[[str | None, str | None], None]) -> Callable[[], None]:
|
|
635
682
|
"""Subscribe to the panel changing schema generation mid-session.
|
|
636
683
|
|