Skip to content

Give entity providers the connection's security context - #23

Merged
bbangert merged 2 commits into
mainfrom
feat/entity-command-security-context
Aug 15, 2026
Merged

Give entity providers the connection's security context#23
bbangert merged 2 commits into
mainfrom
feat/entity-command-security-context

Conversation

@bbangert

Copy link
Copy Markdown
Owner

Adds an optional handle_command/2 to Espex.EntityProvider, carrying %{encrypted?: boolean()}. Found while security-reviewing a downstream consumer (universal_proxy#161).

Why

The ESPHome protocol gives a server no authentication it can act on. AuthenticationRequest carries a password field, but espex answers every one with invalid_password: false (dispatch.ex:105-107) — there is no password check to fail. And entity commands are routed on adapter presence alone (dispatch.ex:425-431), with no auth, encryption or hello gate.

A Noise session is therefore the only real credential. Once a PSK is set espex already enforces it properly — a plaintext client gets a handshake rejection so aioesphomeapi raises RequiresEncryptionAPIError. But while the server is keyless, every connection is anonymous by construction, and any host on the LAN can issue entity commands.

That window is usually benign: it exists so Home Assistant can adopt the device and provision a PSK, and most entity commands are harmless. It stops being benign when a provider exposes commands that reboot, factory-reset or reflash the device — which is exactly what prompted this (universal_proxy added firmware Install as an HA update entity).

Why not fix it in espex

espex can't make this call. Whether an entity is dangerous is the provider's knowledge, not the protocol's — a ButtonCommandRequest for "factory reset" and one for "toggle a lamp" are the same message type. Any policy espex hardcoded would be either too blunt (refuse all commands keyless, breaking benign control) or wrong for someone.

So espex supplies the context and the provider decides:

@impl Espex.EntityProvider
def handle_command(command, %{encrypted?: false}) do
  if privileged?(command) do
    Logger.warning("refusing privileged command on an unencrypted connection")
    :ok
  else
    handle_command(command)
  end
end

def handle_command(command, _context), do: handle_command(command)

Compatibility

Fully backward compatible. handle_command/2 is in @optional_callbacks; espex calls it only when function_exported?/3 says so, otherwise handle_command/1 as before. Existing providers need no change.

encrypted? is true only for {:active, _, _} — an established Noise session. Mid-handshake states are false, which is the safe direction.

Verification

  • mix compile --warnings-as-errors — PASS
  • mix test318 passed (4 doctests), up from 315
  • mix format --check-formatted — clean

Three new tests: a provider without /2 still receives /1; /2 is preferred and reports encrypted?: false on a plaintext connection; and encrypted?: true after a real Noise handshake (using the existing encrypted-integration harness, which I extended to accept an adapters tag). Mutation-tested — forcing the /1 path fails both /2 assertions.

🤖 Generated with Claude Code

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 encryption context to entity-command providers, enabling provider-specific authorization decisions.

Changes:

  • Adds optional handle_command/2 with %{encrypted?: boolean()}.
  • Preserves handle_command/1 compatibility and adds plaintext/Noise tests.
  • Includes unrelated serial-proxy review artifacts that should be removed.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
lib/espex/entity_provider.ex Defines and documents the optional callback.
lib/espex/connection.ex Dispatches commands with encryption context.
test/support/fake_adapters.ex Adds context-aware test providers and probe.
test/espex/integration_test.exs Tests plaintext context and fallback behavior.
test/espex/encrypted_integration_test.exs Tests established Noise-session context.
.claude/.../testing.md Unrelated serial-proxy review artifact.
.claude/.../serial-proxy-lazy-open-review.md Unrelated serial-proxy review summary.
.claude/.../security.md Unrelated serial-proxy security audit.
.claude/.../requirements.md Unrelated serial-proxy requirements report.
.claude/.../elixir.md Unrelated serial-proxy code review.
.claude/.../.requirements-input.md Unrelated serial-proxy requirements input.
.claude/.../.diff.txt Unrelated generated serial-proxy diff.

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

Comment thread lib/espex/entity_provider.ex
Comment thread .claude/plans/serial-proxy-lazy-open/reviews/testing.md Outdated
Comment thread .claude/plans/serial-proxy-lazy-open/reviews/serial-proxy-lazy-open-review.md Outdated
Comment thread .claude/plans/serial-proxy-lazy-open/reviews/security.md Outdated
Comment thread .claude/plans/serial-proxy-lazy-open/reviews/requirements.md Outdated
Comment thread .claude/plans/serial-proxy-lazy-open/reviews/elixir.md Outdated
Comment thread .claude/plans/serial-proxy-lazy-open/reviews/.requirements-input.md Outdated
Comment thread .claude/plans/serial-proxy-lazy-open/reviews/.diff.txt Outdated
bbangert and others added 2 commits August 15, 2026 15:07
The ESPHome protocol offers no authentication a server can rely on:
AuthenticationRequest carries a password field, but espex answers every
one with invalid_password: false. A Noise session is the only real
credential, so on a keyless server any host that can open a TCP socket
can issue entity commands.

That is usually fine — the keyless window exists so Home Assistant can
adopt the device and provision a PSK, and most entity commands are
harmless. It is not fine for commands that reboot, wipe or reflash. Espex
can't tell those apart, because which entity is dangerous is the
provider's knowledge, not the protocol's.

So espex now supplies the context and lets the provider decide: an
optional handle_command/2 receives %{encrypted?: boolean()} and is
preferred when exported. Providers that don't export it are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Callbacks table and return-value summary still described commands as
arriving only through handle_command/1, so anyone scanning the behaviour
docs would miss the security hook entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bbangert
bbangert force-pushed the feat/entity-command-security-context branch from 58c3b55 to 95b2536 Compare August 15, 2026 15:08
@bbangert
bbangert requested a balanced review from Copilot August 15, 2026 15:12

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

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

@bbangert
bbangert merged commit 5f84af4 into main Aug 15, 2026
4 checks passed
@bbangert
bbangert deleted the feat/entity-command-security-context branch August 15, 2026 15:14
@bbangert bbangert mentioned this pull request Aug 15, 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