Skip to content

fix(config_flow): tell a moved panel apart from an impersonated one (2.1.0b22) - #261

Merged
cayossarian merged 7 commits into
mainfrom
fix/reconfigure-leaf-name-mismatch
Aug 29, 2026
Merged

fix(config_flow): tell a moved panel apart from an impersonated one (2.1.0b22)#261
cayossarian merged 7 commits into
mainfrom
fix/reconfigure-leaf-name-mismatch

Conversation

@cayossarian

Copy link
Copy Markdown
Member

The problem

A pinned entry whose panel changed DHCP lease had no way back. The panel serves a perfectly good certificate that no longer names where it answers, but async_leaf_chains_to_ca returned one boolean for "does not chain to my anchor" and "does not name this host" alike. So:

  • Reconfigure refused with "the certificate this panel serves is not signed by the authority it published" — which was false; it was signed, by exactly that authority.
  • The mDNS move-guard refused on the same check.
  • Reauth reads the stored host and offers no field to change it, so it dialled the old address and aborted.
  • The CA-changed repair never fired, because the CA had not changed.

Deleting and re-adding the entry was the only route left, for a panel that had done nothing but take a new address.

The fix

async_leaf_verdict answers the questions separately — TRUSTED, NAME_MISMATCH, UNTRUSTED — plus UNREACHABLE, which had been folded into the same value, so an unplugged cable was reported to users as an unsigned certificate. async_leaf_chains_to_ca stays as a wrapper for TRUSTED, so every existing caller keeps the meaning it had.

Classification alone would have fixed nothing. detect_api_version and register_fqdn run over the entry's pinned transport and would both fail the same hostname check before registration could repair anything. So on NAME_MISMATCH the reconfigure flow rebuilds its own transport with the pin kept and only the name binding dropped. It probes, registers the FQDN, and the panel regenerates its certificate around the new name. That transport never leaves the flow and is never stored.

A bare IP the certificate does not name is still refused — this flow can relax the name binding for its own probe; the coordinator and broker cannot. Storing it would point the entry at an address every later connection rejects. The new ca_name_mismatch error says so and names both ways through: an FQDN, or the panel's .local name.

Security

Relaxing check_hostname does not relax trust: the chain, signature and expiry are still verified against the pinned anchor, so a peer without a key that anchor signed fails exactly as before. The relaxed context exists at one call site, reachable only inside NAME_MISMATCH after UNREACHABLE and UNTRUSTED have both returned.

Reviewed adversarially. The strongest attack available is a TCP relay to the real panel, where TLS stays end-to-end under the pin, the registered name is user-chosen, and the entry is stored only after strict verification — net capability is traffic redirection the attacker's position already granted. Worth noting the old code's only remedy was delete-and-re-add, whose fresh setup fetches the CA over plaintext through that same attacker.

Review also caught a real hole in the first draft: on the moved-panel path there is no verified address to fall back to, so "continue anyway" after a failed FQDN registration would have stored the unserved name behind a reconfigure_successful banner. Fixed in a2674c9 with a test that fails against the pre-fix code.

Library

The relaxed context and the SAN matcher come from span-panel-api 3.2.0 (already on PyPI) rather than being written here. ssl.match_hostname was removed in 3.12, so the matcher is hand-written, and a hand-written security primitive with two implementations is the drift _ssl.py's own docstring warns about.

Docs

The Security section is rewritten as guidance rather than narrative — 68 lines to 30, with the four situation-and-remedy paragraphs collapsed into a table. The reasoning moves to developer.md under "Why the CA pinning behaves as it does". Also corrects a firewall bullet that named tcp/80 for the REST bootstrap: a pinned entry uses tcp/443, and following the old text literally would have blocked every REST call the moment pinning succeeded.

Testing

1640 pass. Four new flow tests cover the moved panel recovering via FQDN, a bare address still refused, an impostor still refused as a signing failure, and an unreachable host no longer accused of interception. The headline test was verified to fail against the pre-change code, so it captures the fix rather than passing anyway.

Version bumped to 2.1.0b22.

A pinned entry whose panel changed DHCP lease had no way back. The panel
serves a perfectly good certificate that no longer names where it answers,
and `async_leaf_chains_to_ca` returned one boolean for "does not chain" and
"does not name this host" alike, so:

- reconfigure refused with "not signed by the authority it published",
  which was false -- it was signed, by exactly that authority;
- the mDNS move-guard refused on the same check;
- reauth reads the stored host and offers no field to change it;
- the CA-changed repair never fired, because the CA had not changed.

Deleting and re-adding the entry was the only route left, for a panel that
had done nothing but take a new address.

`async_leaf_verdict` answers the three questions separately -- TRUSTED,
NAME_MISMATCH, UNTRUSTED -- plus UNREACHABLE, which had been folded into
the same value, so an unplugged cable was reported to users as an unsigned
certificate. `async_leaf_chains_to_ca` stays as a wrapper for TRUSTED, so
every existing caller keeps the meaning it had.

Classification alone would have fixed nothing: `detect_api_version` and
`register_fqdn` run over the entry's pinned transport, and both would fail
the same hostname check before registration could repair anything. So on
NAME_MISMATCH the reconfigure flow rebuilds its own transport with the pin
kept and only the name binding dropped. It probes, registers the FQDN, and
the panel regenerates its certificate around the new name. That transport
never leaves the flow and is never stored.

A bare IP the certificate does not name is still refused, because this flow
can relax the name binding for its own probe and the coordinator and broker
cannot -- storing it would point the entry at an address every later
connection rejects. The new `ca_name_mismatch` error says so and names both
ways through: an FQDN, or the panel's .local name.

The relaxed context and the SAN matcher come from span-panel-api 3.2.0
rather than being written here. `ssl.match_hostname` was removed in 3.12,
so the matcher is hand-written, and a hand-written security primitive with
two implementations is the drift `_ssl`'s docstring warns about.
…fused

"Continue anyway" after a failed FQDN registration stored the name and
reported success. On every path but one that is the right trade: the flow
falls back to the address it reached the panel by, which the anchor
verified, so the entry lands somewhere usable.

The moved-panel path has no such address. Its certificate names neither
the FQDN nor anything reachable -- that is why the flow relaxed the name
binding for its own probe -- so `_fall_back_to_the_bootstrap_address` had
nothing to adopt and returned having changed nothing, and the flow then
wrote the unserved name behind a `reconfigure_successful` banner. Every
runtime connection would fail the hostname check the coordinator and the
broker apply and cannot relax: the stranded entry this branch refuses for
bare IP addresses, delivered as a success.

It now re-shows the form with `ca_name_mismatch` and leaves the entry
untouched, and the fallback's docstring no longer claims an invariant it
cannot restore without an address.

Also: an empty `getpeercert()` after a completed handshake now classifies
as UNTRUSTED rather than NAME_MISMATCH. `CERT_REQUIRED` should make it
unreachable, but NAME_MISMATCH is the verdict that unlocks the relaxed
transport, and "no validated certificate in hand" is not evidence that
anything holds a key the anchor signed.
The Finding A fix returns a `reconfigure` form from inside
`async_step_reconfigure_fqdn_failed`, which is only correct if submitting
it re-enters `async_step_reconfigure` and gets judged again. Asserted
rather than assumed: the test now submits the form it lands on and
confirms the flow is still live and the entry still untouched.
The section had grown to explain its own reasoning inline, so a reader
looking for what to do worked through why each choice was made to find it.
The four situation-and-remedy paragraphs -- hostname setup, the mDNS move
guard, Reconfigure, and an entry with no anchor -- are a table now, which
leaves nowhere to put a justifying clause. 68 lines to 30.

The reasoning is kept, in developer.md under "Why the CA pinning behaves as
it does": why no fingerprint is offered at first contact, why diagnostics
carry the fingerprint and not the certificate, why an unreachable panel
does not stop setup, why reauth keeps the anchor it acquires, the
difference between pinning on screen and pinning during a reload, and why
a registered domain is checked when the panel reports it.

Corrections along the way:

- The deployment bullet named `tcp/80` for the REST bootstrap. A pinned
  entry uses `tcp/443`; `tcp/80` is the plaintext CA fetch, and REST only
  until the entry pins. Following it literally would have blocked every
  REST call the moment pinning succeeded.
- "The changeover is designed to be seamless.**" had a closing bold marker
  with no opener, so it rendered literally.
- The `r202633` sentence was never closed and "after the firmware hits"
  dangled.
- "differ in two:" introduced four bullets, one of which is not a
  difference.
- The prerequisite read as a floor; 2.0.8 is a required stepping stone.
- Install step 8 said "IP address or .local address".
- One `behavior` against the document's British spelling.

The adopted-device and adopted-vendor-reading sections move below the
entity tables. They had been sitting between the Microgrid Interconnect
Device and Power Sensor Attributes, so a reader working down the tables hit
fifty lines of vendor-extensibility prose and landed back in tables.

developer.md's Supervisor section pointed at a README paragraph that no
longer exists; the reference is dropped.
…ddress

A pinned CA that still validates the panel's certificate, on a handshake
that fails anyway, means one of two things the library used to conflate: an
expired leaf, which fixes itself, and a certificate that does not name the
configured host, which never will. 3.3.0 tells the two apart and reports
the second on a callback of its own; this subscribes to it and puts the
remedy in front of a person.

`leaf_repairs.py` follows `ca_repairs` in shape and the `schema_repairs`
reconciliation model in substance: not persistent, because the transport is
alive and re-derives the condition on every attempt; WARNING, because
nothing was intercepted and nothing refused; not fixable, because
Reconfigure is the remedy, already refuses a host the leaf does not name,
and a fix flow cannot hand off to a config flow.

Subscribed before `connect()` rather than after it, unlike every other
callback here. The library runs the diagnosis inside the first connect and
a mismatch makes it raise, so setup raises ConfigEntryNotReady and the
retry builds a new client -- a subscription after a successful connect
would never see the case it exists for.

Cleared on the coordinator's connection edge, which is the exact event the
library re-arms its own once-per-outage signal on, and at setup beside the
CA one. A CA change on the same entry supersedes it: only that finding
carries a security decision, and pointing somebody at an address served by
a certificate just refused is the wrong instruction.

Two translation keys rather than one. A placeholder is substituted
verbatim, so a certificate naming no address at all gets a description of
its own instead of an English phrase passed through {leaf_names}.

Requires span-panel-api 3.3.0, which is not yet on PyPI.
@cayossarian

Copy link
Copy Markdown
Member Author

Adds the integration half of the leaf-name-mismatch Repair (design: 2026-08-28-leaf-name-mismatch-repair-design.md).

A pinned CA that still validates the panel's certificate, on a handshake that fails anyway, used to conflate an expired leaf (which fixes itself) with a certificate that does not name the configured host (which never will). span-panel-api 3.3.0 tells the two apart and reports the second on register_leaf_mismatch_callback; leaf_repairs.py turns that into a non-fixable WARNING Repair naming the addresses the panel does answer to, with Reconfigure as the remedy.

The subscription is registered before client.connect(), unlike every other callback in async_setup_entry: the library runs the diagnosis inside the first connect and a mismatch makes it raise, so a subscription placed after a successful connect would never see the case it exists for.

CI will fail until span-panel-api 3.3.0 is published. The manifest pin (and the synced pyproject.toml / requirements_test.txt / uv.lock) now names span-panel-api==3.3.0, which currently exists only on the library branch feat/leaf-name-mismatch-signal (SpanPanel/span-panel-api#174). Locally the full suite is green against that checkout: 1652 passed, mypy/ruff/pylint/vulture/bandit clean.

`async_step_hassio` put the add-on's published host straight into the
already-configured update on every add-on start. Observed on ha-test: an
entry configured at an address that reached the panel and that the panel's
certificate names was overwritten with the add-on's container hostname, a
name the panel does not serve, 56 seconds after the entry was created.
Every connection afterwards failed verification against the entry's own
pin. The update also moved `host` without `ebus_broker_host`, leaving the
entry naming two different machines.

The docstring's argument for being unguarded -- add-ons legitimately
reallocate their own ports -- covers the ports and does not reach the host.
An add-on that moved a port has not moved the panel.

So the ports still go straight in, and `_async_hassio_host_update` decides
the host on the evidence the entry already has: probe the *configured* host
on the *newly discovered* port, which is precisely the reallocated-port
case, and keep the stored host when a v2 answer there carries this serial.
Only a configured host that has stopped answering for this panel is
replaced, and then `CONF_HOST` and `CONF_EBUS_BROKER_HOST` move together.

Deliberately not `_async_host_update`'s pinned-address check. That one
decides whether an unauthenticated claim may move an entry; this one
decides whether there is anything to move at all. The Supervisor route's
documented trade is unchanged for a host that has genuinely gone away.

Bumps the integration to 2.1.0b23 and re-mirrors pyproject's version onto
the manifest's, which had drifted at 2.1.0.
@cayossarian

Copy link
Copy Markdown
Member Author

Second change on this branch: async_step_hassio no longer takes the add-on's host on its word.

Observed on ha-test: the Supervisor discovery put the add-on's container hostname straight into the already-configured update on every add-on start, overwriting a user-configured address that reached the panel and that the panel's certificate names — 56 seconds after the entry was created. Every connection afterwards then failed verification against the entry's own pin. It also moved host without ebus_broker_host, leaving the entry naming two different machines.

The docstring's argument for the route being unguarded — add-ons legitimately reallocate their own ports — covers the ports and does not reach the host. So the ports still go straight in, and _async_hassio_host_update probes the configured host on the newly discovered port (precisely the reallocated-port case), keeping the stored host whenever a v2 answer there carries this serial. Only a host that has stopped answering for this panel is replaced, and then CONF_HOST and CONF_EBUS_BROKER_HOST move together. developer.md and the README Security table are updated to describe ports-only as the unguarded part.

Also bumps the integration to 2.1.0b23 and re-mirrors pyproject.toml's version onto the manifest's, which had drifted at 2.1.0.

Correction to my earlier note: span-panel-api 3.3.0 is now on PyPI, so the span-panel-api==3.3.0 pin should resolve in CI after all. Locally: 1656 passed, ruff/mypy/pylint/vulture/bandit/markdownlint clean.

@cayossarian
cayossarian merged commit ded8806 into main Aug 29, 2026
7 checks passed
@cayossarian
cayossarian deleted the fix/reconfigure-leaf-name-mismatch branch August 29, 2026 02:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant