Skip to content

Refuse privileged HA commands on unencrypted connections (B2) - #162

Merged
bbangert merged 2 commits into
mainfrom
feat/refuse-privileged-commands-unencrypted
Aug 16, 2026
Merged

Refuse privileged HA commands on unencrypted connections (B2)#162
bbangert merged 2 commits into
mainfrom
feat/refuse-privileged-commands-unencrypted

Conversation

@bbangert

Copy link
Copy Markdown
Owner

DRAFT — blocked. Depends on espex#23 being merged and published to hex. mix.exs currently pins espex to that branch; that pin must go back to a hex constraint before merge. A git/path espex dep has caused problems in this project before, so it's called out loudly rather than left as a footnote.

Closes B2 from the /phx:review of #161.

The problem

Firmware Install, Factory Reset and Reboot are reachable by anything on the LAN while the device is keyless. Verified in the dependency source rather than inferred:

  • esphome/supervisor.ex:66-70psk = PskStore.load_psk() is nil until Home Assistant provisions, so a fresh device runs keyless.
  • deps/espex/lib/espex/dispatch.ex:105-107 — espex answers every AuthenticationRequest with invalid_password: false. There is no password check to fail.
  • deps/espex/lib/espex/dispatch.ex:425-431 — entity commands route on adapter presence alone. No auth, no encryption, no hello gate.

Once a PSK exists espex enforces encryption properly (a plaintext client gets a handshake rejection), so the exposure is confined to the keyless window — but that window is open indefinitely on a device nobody has adopted yet.

Scoped honestly: this is not arbitrary-firmware execution. :repo is only settable over SSH/IEx, so the reachable outcome is "force-install the legitimate release, then reboot" — availability, not RCE. Factory Reset and Reboot were already on this surface before #161; that PR added flash to it.

Why not just close the window

The keyless window is how HA adopts the device — it connects plaintext and provisions the PSK. Refusing all commands keyless would break onboarding, and refusing none leaves reboot/wipe/reflash exposed. The distinction is per-entity, which is app knowledge, so espex#23 passes the security context and this PR makes the policy decision:

@privileged_object_ids ~w(firmware_update factory_reset reboot)

Those three are refused unless the connection is encrypted; every diagnostic entity keeps working keyless. Refusals return :ok — the protocol has no "denied" reply for a command, so the warning is the audit trail.

What this does not fix

Only the app's own entities. Any other privileged adapter surface is out of scope, and the underlying "keyless means anonymous" property of the protocol is unchanged — this narrows the blast radius rather than removing it. The real close is HA provisioning a PSK, which the app already invites via accepts_key_provisioning.

Pairs with nerves_github_updater#3 (B3), which removes the replay loop independently — so even a caller that is allowed to install can't loop the device.

Verification

  • mix compile --warnings-as-errors — PASS
  • mise run test958 passed, 3 excluded (5 new)
  • mix dialyzer — PASS (7 errors, 7 skipped, 0 unnecessary skips)
  • MIX_TARGET=rpi3 mix compile — PASS

Mutation-tested: dropping firmware_update from the privileged list fails 2 tests.

Before merge

  1. Merge + publish espex#23
  2. Replace the git pin with the matching hex constraint
  3. Re-run mix deps.get and the suite

🤖 Generated with Claude Code

Firmware Install, Factory Reset and Reboot were reachable by anything on
the LAN while the device is keyless. The ESPHome protocol answers
AuthenticationRequest unconditionally, so a Noise session is the only
real credential, and espex only enforces encryption once a PSK exists.
That keyless window is how Home Assistant adopts the device, so it can't
simply be closed — but it shouldn't extend to commands that reboot, wipe
or reflash.

Uses espex's new handle_command/2 to see the connection's security
context and refuse just those three; diagnostic entities keep working
keyless. Refusals return :ok because the protocol has no denied reply for
a command — the warning is the audit trail.

NOTE: espex is temporarily pinned to a branch here. It must return to a
hex constraint before merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bbangert
bbangert force-pushed the feat/refuse-privileged-commands-unencrypted branch from f7c9a4e to e6a66f8 Compare August 15, 2026 22:18
@bbangert
bbangert marked this pull request as ready for review August 15, 2026 22:18
@bbangert
bbangert requested a balanced review from Copilot August 15, 2026 22:31

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds connection-aware authorization to protect destructive ESPHome commands from plaintext clients.

Changes:

  • Rejects firmware install, factory reset, and reboot commands on unencrypted connections.
  • Adds tests covering privileged-command identification and encrypted/plaintext behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
lib/universal_proxy/esphome/entity_provider.ex Adds security-context command filtering and audit logging.
test/universal_proxy/esphome/entity_provider_test.exs Tests privileged-command filtering.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/universal_proxy/esphome/entity_provider.ex
The gate matches on entity key, so the update entity's CHECK is refused
alongside UPDATE. That is deliberate — CHECK reaches
FirmwareUpdate.check/0, which hits GitHub with no debounce, so leaving it
open would let an unauthenticated client drive the API into its secondary
rate limits — but the docstring said "Factory Reset, Reboot or Firmware
Update", which reads as install-only and never said so.

Says it plainly now, with the reasoning, and adds a test pinning the
behaviour so it isn't quietly reversed later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bbangert
bbangert merged commit c8a7fca into main Aug 16, 2026
6 checks passed
@bbangert
bbangert deleted the feat/refuse-privileged-commands-unencrypted branch August 16, 2026 00:52
@bbangert bbangert mentioned this pull request Aug 16, 2026
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.

2 participants