Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 12 additions & 34 deletions homeassistant/components/esphome/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import logging

from aioesphomeapi import APIClient, APIConnectionError
from aioesphomeapi import APIConnectionError

from homeassistant.components import zeroconf
from homeassistant.components.bluetooth import async_remove_scanner
Expand All @@ -11,13 +11,7 @@
USBDevice,
async_register_serial_port_scanner,
)
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
EVENT_HOMEASSISTANT_STOP,
__version__ as ha_version,
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import async_delete_issue
Expand All @@ -29,15 +23,18 @@
from .domain_data import DomainData
from .encryption_key_storage import async_get_encryption_key_storage
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
from .manager import DEVICE_CONFLICT_ISSUE_FORMAT, ESPHomeManager, cleanup_instance
from .manager import (
DEVICE_CONFLICT_ISSUE_FORMAT,
ESPHomeManager,
async_create_api_client,
cleanup_instance,
)
from .websocket_api import async_setup as async_setup_websocket_api

_LOGGER = logging.getLogger(__name__)

CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)

CLIENT_INFO = f"Home Assistant {ha_version}"


@callback
def _async_scan_serial_ports(
Expand Down Expand Up @@ -90,20 +87,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> bool:
"""Set up the esphome component."""
host: str = entry.data[CONF_HOST]
port: int = entry.data[CONF_PORT]
password: str | None = entry.data[CONF_PASSWORD]
noise_psk: str | None = entry.data.get(CONF_NOISE_PSK)

zeroconf_instance = await zeroconf.async_get_instance(hass)

cli = APIClient(
host,
port,
password,
client_info=CLIENT_INFO,
zeroconf_instance=zeroconf_instance,
noise_psk=noise_psk,
timezone=hass.config.time_zone,
cli = async_create_api_client(
hass, entry, zeroconf_instance, noise_psk=entry.data.get(CONF_NOISE_PSK)
)

domain_data = DomainData.get(hass)
Expand Down Expand Up @@ -159,21 +148,10 @@ async def _async_clear_dynamic_encryption_key(
if await storage.async_get_key(entry.unique_id) is None:
return

host: str = entry.data[CONF_HOST]
port: int = entry.data[CONF_PORT]
password: str | None = entry.data[CONF_PASSWORD]
noise_psk: str | None = entry.data.get(CONF_NOISE_PSK)

zeroconf_instance = await zeroconf.async_get_instance(hass)

cli = APIClient(
host,
port,
password,
client_info=CLIENT_INFO,
zeroconf_instance=zeroconf_instance,
noise_psk=noise_psk,
timezone=hass.config.time_zone,
cli = async_create_api_client(
hass, entry, zeroconf_instance, noise_psk=entry.data.get(CONF_NOISE_PSK)
)

try:
Expand Down
8 changes: 6 additions & 2 deletions homeassistant/components/esphome/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@
ERROR_INVALID_PASSWORD_AUTH = "invalid_auth"
_LOGGER = logging.getLogger(__name__)

ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
# A deliberately wrong key (base64 of thirty two ASCII zero characters, not
# zero bytes) used only to elicit the server hello so the device name can be
# read. Not to be confused with aioesphomeapi.ZERO_NOISE_PSK, the well known
# all zeros provisioning key.
PROBE_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
DEFAULT_NAME = "ESPHome"

_BLUETOOTH_SCANNING_MODE_SELECTOR = SelectSelector(
Expand Down Expand Up @@ -271,7 +275,7 @@ async def _async_try_fetch_device_info(self) -> ConfigFlowResult:
# to get the device name which will allow us to populate
# the device name and hopefully get the encryption key
# from the dashboard.
self._noise_psk = ZERO_NOISE_PSK
self._noise_psk = PROBE_NOISE_PSK
response = await self.fetch_device_info()
self._noise_psk = None

Expand Down
3 changes: 3 additions & 0 deletions homeassistant/components/esphome/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@
from awesomeversion import AwesomeVersion

from homeassistant.components.bluetooth import BluetoothScanningMode
from homeassistant.const import __version__ as ha_version
from homeassistant.util.hass_dict import HassKey

if TYPE_CHECKING:
from .domain_data import DomainData

DOMAIN = "esphome"

CLIENT_INFO = f"Home Assistant {ha_version}"

ESPHOME_DATA: HassKey[DomainData] = HassKey(DOMAIN)

CONF_ALLOW_SERVICE_CALLS = "allow_service_calls"
Expand Down
98 changes: 87 additions & 11 deletions homeassistant/components/esphome/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import TYPE_CHECKING, Any, Final, NamedTuple

from aioesphomeapi import (
ZERO_NOISE_PSK,
APIClient,
APIConnectionError,
APIVersion,
Expand All @@ -34,7 +35,10 @@
from homeassistant.components import bluetooth, tag, zeroconf
from homeassistant.const import (
ATTR_DEVICE_ID,
CONF_HOST,
CONF_MODE,
CONF_PASSWORD,
CONF_PORT,
EVENT_HOMEASSISTANT_CLOSE,
EVENT_LOGGING_CHANGED,
Platform,
Expand Down Expand Up @@ -77,6 +81,7 @@

from .bluetooth import async_connect_scanner
from .const import (
CLIENT_INFO,
CONF_ALLOW_SERVICE_CALLS,
CONF_BLUETOOTH_MAC_ADDRESS,
CONF_DEVICE_NAME,
Expand All @@ -101,6 +106,26 @@
UNPACK_UINT32_BE = struct.Struct(">I").unpack_from


@callback
def async_create_api_client(
hass: HomeAssistant,
entry: ESPHomeConfigEntry,
zeroconf_instance: zeroconf.HaZeroconf,
*,
noise_psk: str | None,
) -> APIClient:
"""Create an APIClient for a config entry."""
return APIClient(
entry.data[CONF_HOST],
entry.data[CONF_PORT],
entry.data[CONF_PASSWORD],
client_info=CLIENT_INFO,
zeroconf_instance=zeroconf_instance,
noise_psk=noise_psk,
timezone=hass.config.time_zone,
)


if TYPE_CHECKING:
from aioesphomeapi.api_pb2 import SubscribeLogsResponse # type: ignore[attr-defined] # noqa: I001

Expand Down Expand Up @@ -812,6 +837,51 @@ async def _start_reauth_and_disconnect(self) -> None:
if self.reconnect_logic:
await self.reconnect_logic.stop()

async def _async_provision_key_over_noise(self, new_key: bytes) -> bool:
"""Send the encryption key over a short lived zero PSK Noise connection.

The well known all zeros PSK still runs a fresh ephemeral X25519
exchange, so the key cannot be read by a passive listener on the
network. This protects against sniffing only; it does not
authenticate either side against an active man in the middle.

Returns True if the device accepted the key. On failure the caller
simply returns; provisioning runs again on the next connect cycle.
"""
unique_id = self.entry.unique_id
cli = async_create_api_client(
self.hass, self.entry, self.zeroconf_instance, noise_psk=ZERO_NOISE_PSK
)
device_name = self.entry.data.get(CONF_DEVICE_NAME, self.host)
try:
await cli.connect()
if await cli.noise_encryption_set_key(new_key):
return True
_LOGGER.error(
"Device %s (%s) rejected the encryption key",
device_name,
unique_id,
)
except InvalidEncryptionKeyAPIError:
_LOGGER.error(
"Device %s (%s) rejected the zero PSK handshake; it appears "
"to already have an encryption key set",
device_name,
unique_id,
)
except APIConnectionError as ex:
# Whatever went wrong, we never downgrade to a plaintext push;
# provisioning simply runs again on the next connect cycle
_LOGGER.error(
"Error provisioning encryption key for device %s (%s): %s",
device_name,
unique_id,
ex,
)
finally:
await cli.disconnect(force=True)
return False

async def _handle_dynamic_encryption_key(
self, device_info: EsphomeDeviceInfo
) -> None:
Expand Down Expand Up @@ -853,18 +923,24 @@ async def _handle_dynamic_encryption_key(
new_key = base64.b64encode(secrets.token_bytes(32))
new_key_str = new_key.decode()

try:
# Store the key on the device using the existing connection
result = await self.cli.noise_encryption_set_key(new_key)
except APIConnectionError as ex:
_LOGGER.error(
"Connection error while storing encryption key for device %s (%s): %s",
self.entry.data.get(CONF_DEVICE_NAME, self.host),
self.entry.unique_id,
ex,
)
return
if device_info.api_encryption_provisionable:
# New firmware: send the key over an encrypted zero PSK Noise
# connection so it cannot be sniffed off the network
if not await self._async_provision_key_over_noise(new_key):
return
else:
# Old firmware only accepts the key over the existing plaintext
# connection. Deprecated; will be removed after the usual window.
try:
result = await self.cli.noise_encryption_set_key(new_key)
except APIConnectionError as ex:
_LOGGER.error(
"Connection error while storing encryption key for device %s (%s): %s",
self.entry.data.get(CONF_DEVICE_NAME, self.host),
self.entry.unique_id,
ex,
)
return
if not result:
_LOGGER.error(
"Failed to set dynamic encryption key on device %s (%s)",
Expand Down
2 changes: 1 addition & 1 deletion tests/components/esphome/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ def mock_constructor(
"homeassistant.components.esphome.manager.ReconnectLogic",
BaseMockReconnectLogic,
),
patch("homeassistant.components.esphome.APIClient", mock_client),
patch("homeassistant.components.esphome.manager.APIClient", mock_client),
patch("homeassistant.components.esphome.config_flow.APIClient", mock_client),
):
yield mock_client
Expand Down
Loading
Loading