Skip to content

security(rest): ride the pinned CA at runtime, and fail closed with repairs - #265

Merged
cayossarian merged 11 commits into
mainfrom
fix/runtime-rest-pinned-ca
Sep 1, 2026
Merged

security(rest): ride the pinned CA at runtime, and fail closed with repairs#265
cayossarian merged 11 commits into
mainfrom
fix/runtime-rest-pinned-ca

Conversation

@cayossarian

Copy link
Copy Markdown
Member

Fixes #264.

What

A pinned entry's schema read — the one REST call that runs unattended at every startup — ran over plaintext HTTP with the pin sitting unused in entry.data, which is exactly what the library's transport warning reported. The runtime client now rides the pinned CA on the HTTPS port (span-panel-api 3.4.0, SpanPanel/span-panel-api#175), so a healthy pinned entry makes zero plaintext bootstrap calls in steady state and the warning is resolved rather than silenced.

Failing closed, without convicting transients

The escalation mirrors the library's own rule: a matching fingerprint never escalates.

  • Rotated CA (fingerprint differs on the diagnostic re-read): the existing guided re-pin repair, terminal — the one outcome that needs a person before anything reconnects.
  • Moved panel (unchanged CA, leaf names somewhere else): the existing address repair, retrying as it promises.
  • Untrusted leaf (unchanged CA, leaf the pin does not validate): a new non-persistent repair naming both readings — a middlebox terminating TLS or a wrong HTTPS port, or a certificate a clock reset pushed outside its validity window — retried so the clock case heals itself. Never a plaintext fallback.
  • Missing evidence (CA unreadable, port unreachable): plain retry, no verdict — a panel mid-reboot must not be convicted of anything.

The two same-fingerprint repairs supersede each other, since a panel cannot be both moved and intercepted.

The population that was never asked

Entries pinned by the deferred migration path can have TLS living somewhere 443 is not — behind NAT, a port forward, a proxy — and the CA step's gating never asked them for a port. Two remedies: Reconfigure now offers the HTTPS port on any pinned entry (probed against and persisted), and the retry message for an unreachable pinned panel names the expected port and the remedy instead of blaming the panel.

Also

  • A stored anchor that cannot be read stops setup with a fixable repair reusing the CA-change flow, instead of quietly downgrading to plaintext.
  • A stored HTTPS port of 80 — refused by the library under a context — ends in a clear error naming Reconfigure, and both port forms refuse it at input.
  • Entry removal clears the whole CA repair family, including two pre-existing persistent leakers.
  • New strings translated in all five locales; README and changelog updated.

Verification

1674 passed / 1 skipped, ruff and mypy clean, sync_translations.py green — run against the library branch. Every behavioral change was written test-first (tests/test_runtime_rest_transport.py plus reconfigure flow tests). Reviewed adversarially twice; all findings resolved and re-verified.

CI note: cannot pass until span-panel-api 3.4.0 is on PyPI (CI re-locks from PyPI); merge SpanPanel/span-panel-api#175 and release first. The commit was made from a linked worktree, where the repo's uv-based hooks cannot resolve the editable library path (the pyproject documents this); every hook's check was run directly instead.

…epairs

Fixes #264. Every REST call site adopted `panel_rest_transport` when the pin
landed — config flow, reauth, repairs, rotate_credentials — except the one
that runs on every startup: the schema fetch inside connect() stayed on
plaintext HTTP with the pin sitting unused in entry.data, which is the
condition the library's transport warning named. The runtime client now
takes the pinned context and the HTTPS port (span-panel-api 3.4.0), so a
pinned entry makes zero plaintext bootstrap calls in steady state.

Failing closed follows the library's own escalation rule: a matching
fingerprint never convicts. A rotated CA — diagnosed by re-reading the
advertised CA, plaintext deliberately — is the one terminal outcome, taking
the existing guided re-pin repair. Under an unchanged CA the leaf probe
splits the rest: a moved panel raises the existing address repair and keeps
its promise to retry; a leaf the pin does not validate — a middlebox, or a
certificate a clock reset pushed outside its window — raises its own
non-persistent repair and retries, so the clock case heals itself and
plaintext is never fallen back to; missing evidence just retries. The two
same-fingerprint repairs supersede each other, since a panel cannot be both
moved and intercepted.

A stored anchor that cannot be read stops setup with a fixable repair that
reuses the CA-change flow, instead of quietly downgrading the one call that
runs unattended on every boot. A stored HTTPS port of 80 — which the library
refuses under a context — ends in a clear error naming Reconfigure, and both
port forms now refuse it at input.

For the population the deferred pin serves — TLS behind NAT, a port forward,
a proxy, never asked for a port by the gated CA step — Reconfigure now
offers the HTTPS port on any pinned entry, probing against and persisting
the submitted value, and the retry message for an unreachable pinned panel
names the expected port and the remedy. Entry removal clears the whole CA
repair family, including two pre-existing leakers.
… Home Assistant

Each release of the test harness pins one exact Home Assistant, so its pin
and the homeassistant pin are a single choice — the pyproject says so. The
bump to 0.13.358 paired with 2026.9.0b0 against the pinned 2026.8.3, an
unresolvable pair that CI's re-lock from PyPI reports as a sqlalchemy split
(2.0.52 vs 2.0.51) while a stale local venv resolves nothing and notices
nothing. 0.13.357 is the release that pins 2026.8.3.
The Supervisor-discovery host check reads the serial that decides whether
the entry moves, and it read it over plaintext with the entry's anchor
sitting unused — the one probe of a configured host that did not ride the
pin. Since span-panel-api 3.4.1 exempts the status endpoint from the
plaintext warning, nothing in the log would say so either; the probe now
takes the entry's own transport, on the add-on's newly published TLS port
when it published one, because a reallocated port is the very case this
probe exists for. Unpinned entries keep the plaintext probe they always
had. (Direct checks run in lieu of the worktree-incompatible uv hooks:
pytest 1676 passed, ruff, mypy all clean.)
The release that stops a merely advertising, unconfigured panel from
producing the plaintext-transport warning at every boot when discovery
probes it. Pairs with the Supervisor-discovery probe moving onto the pin
in the previous commit, which that release's warning exemption motivated.
@cayossarian
cayossarian merged commit d82244f into main Sep 1, 2026
7 checks passed
@cayossarian
cayossarian deleted the fix/runtime-rest-pinned-ca branch September 1, 2026 01:26
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.

Startup warning

1 participant