fix(config_flow): tell a moved panel apart from an impersonated one (2.1.0b22) - #261
Conversation
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.
|
Adds the integration half of the leaf-name-mismatch Repair (design: 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 The subscription is registered before CI will fail until span-panel-api 3.3.0 is published. The manifest pin (and the synced |
`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.
|
Second change on this branch: 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 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 Also bumps the integration to Correction to my earlier note: span-panel-api 3.3.0 is now on PyPI, so the |
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_careturned one boolean for "does not chain to my anchor" and "does not name this host" alike. So: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_verdictanswers the questions separately —TRUSTED,NAME_MISMATCH,UNTRUSTED— plusUNREACHABLE, which had been folded into the same value, so an unplugged cable was reported to users as an unsigned certificate.async_leaf_chains_to_castays as a wrapper forTRUSTED, so every existing caller keeps the meaning it had.Classification alone would have fixed nothing.
detect_api_versionandregister_fqdnrun over the entry's pinned transport and would both fail the same hostname check before registration could repair anything. So onNAME_MISMATCHthe 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_mismatcherror says so and names both ways through: an FQDN, or the panel's.localname.Security
Relaxing
check_hostnamedoes 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 insideNAME_MISMATCHafterUNREACHABLEandUNTRUSTEDhave 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_successfulbanner. 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_hostnamewas 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.mdunder "Why the CA pinning behaves as it does". Also corrects a firewall bullet that namedtcp/80for the REST bootstrap: a pinned entry usestcp/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.