diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 5d329b61974f3..b7c2eb8352e83 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -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 @@ -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 @@ -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( @@ -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) @@ -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: diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 71b99ced5feae..1d3488d3cd008 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -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( @@ -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 diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index b10995ac27ccc..508065b091c81 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -5,6 +5,7 @@ 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: @@ -12,6 +13,8 @@ DOMAIN = "esphome" +CLIENT_INFO = f"Home Assistant {ha_version}" + ESPHOME_DATA: HassKey[DomainData] = HassKey(DOMAIN) CONF_ALLOW_SERVICE_CALLS = "allow_service_calls" diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index a1428ddc702e9..90e0760da46bb 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Final, NamedTuple from aioesphomeapi import ( + ZERO_NOISE_PSK, APIClient, APIConnectionError, APIVersion, @@ -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, @@ -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, @@ -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 @@ -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: @@ -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)", diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index bfb6aa97446f9..8060f6aafe31d 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -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 diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index dfde80addd55a..70cdd63c5fa6d 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -2,11 +2,13 @@ import asyncio import base64 +from collections.abc import Generator import logging from typing import Any from unittest.mock import AsyncMock, Mock, call, patch from aioesphomeapi import ( + ZERO_NOISE_PSK, APIClient, APIConnectionError, APIVersion, @@ -32,6 +34,7 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.esphome.config_flow import PROBE_NOISE_PSK from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, CONF_BLUETOOTH_MAC_ADDRESS, @@ -2724,6 +2727,208 @@ async def test_manager_handle_dynamic_encryption_key_connection_error( assert mac_address not in hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"] +@pytest.fixture +def mock_provisioning_client(mock_client: APIClient) -> Generator[Mock]: + """Mock the APIClient built for the zero PSK provisioning connection.""" + client = Mock(spec=APIClient) + client.connect = AsyncMock() + client.disconnect = AsyncMock() + client.noise_encryption_set_key = AsyncMock(return_value=True) + + def _api_client(*args: Any, **kwargs: Any) -> Mock: + if kwargs.get("noise_psk") == ZERO_NOISE_PSK: + return client + return mock_client(*args, **kwargs) + + with patch( + "homeassistant.components.esphome.manager.APIClient", side_effect=_api_client + ): + yield client + + +def _make_provisionable_entry(hass: HomeAssistant, mac_address: str) -> MockConfigEntry: + """Create a config entry without a noise PSK.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + return entry + + +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_dynamic_encryption_key_provisioned_over_zero_psk( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_provisioning_client: Mock, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test provisionable firmware gets the key over a zero PSK connection.""" + mac_address = "11:22:33:44:55:aa" + test_key_bytes = b"test_key_32_bytes_long_exactly!" + mock_token_bytes.return_value = test_key_bytes + expected_key = base64.b64encode(test_key_bytes).decode() + + entry = _make_provisionable_entry(hass, mac_address) + + # The main (plaintext) client must never be used to push the key + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2026.8.0", + "api_encryption_supported": True, + "api_encryption_provisionable": True, + }, + ) + + await device.mock_disconnect(True) + await device.mock_connect() + + # The key went over the zero PSK client (the fixture only hands it out + # for constructions using ZERO_NOISE_PSK), not the plaintext connection + mock_provisioning_client.noise_encryption_set_key.assert_called_once_with( + base64.b64encode(test_key_bytes) + ) + mock_client.noise_encryption_set_key.assert_not_called() + mock_provisioning_client.disconnect.assert_called_with(force=True) + + # Entry and storage were updated + assert entry.data[CONF_NOISE_PSK] == expected_key + assert ( + hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"][mac_address] + == expected_key + ) + + +async def test_dynamic_encryption_key_provisioned_over_zero_psk_from_storage( + hass: HomeAssistant, + mock_client: APIClient, + mock_provisioning_client: Mock, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test a stored key is re-provisioned over the zero PSK connection.""" + mac_address = "11:22:33:44:55:aa" + test_key = base64.b64encode(b"existing_key_32_bytes_long!!!").decode() + + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": {"keys": {mac_address: test_key}}, + } + + entry = _make_provisionable_entry(hass, mac_address) + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2026.8.0", + "api_encryption_supported": True, + "api_encryption_provisionable": True, + }, + ) + + await device.mock_disconnect(True) + await device.mock_connect() + + mock_provisioning_client.noise_encryption_set_key.assert_called_once_with( + test_key.encode() + ) + mock_client.noise_encryption_set_key.assert_not_called() + assert entry.data[CONF_NOISE_PSK] == test_key + + +@pytest.mark.parametrize( + ("connect_error", "set_key_result"), + [ + # Device already has a key (distinct log branch) + (InvalidEncryptionKeyAPIError("already keyed"), True), + # Old firmware answering plaintext to the noise hello (generic branch; + # all connection errors are APIConnectionError subclasses) + (EncryptionPlaintextAPIError("plaintext"), True), + # Device accepted the connection but rejected the key + (None, False), + ], +) +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_dynamic_encryption_key_zero_psk_failures_never_use_plaintext( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_provisioning_client: Mock, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], + connect_error: Exception | None, + set_key_result: bool, +) -> None: + """Test zero PSK provisioning failures do not fall back to plaintext.""" + mac_address = "11:22:33:44:55:aa" + mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!" + + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": {"keys": {}}, + } + + entry = _make_provisionable_entry(hass, mac_address) + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + # A None side_effect leaves connect behaving normally + mock_provisioning_client.connect.side_effect = connect_error + mock_provisioning_client.noise_encryption_set_key.return_value = set_key_result + + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2026.8.0", + "api_encryption_supported": True, + "api_encryption_provisionable": True, + }, + ) + + await device.mock_disconnect(True) + await device.mock_connect() + + # The plaintext connection was never used to push the key, the entry was + # not updated, and no generated key was stored + mock_client.noise_encryption_set_key.assert_not_called() + assert CONF_NOISE_PSK not in entry.data + assert mac_address not in hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"] + mock_provisioning_client.disconnect.assert_called_with(force=True) + + +def test_zero_noise_psk_is_not_the_probe_key() -> None: + """Test the provisioning PSK is 32 zero bytes and differs from the probe.""" + assert base64.b64decode(ZERO_NOISE_PSK) == bytes(32) + assert ZERO_NOISE_PSK != PROBE_NOISE_PSK + + async def test_zwave_proxy_request_home_id_change( hass: HomeAssistant, mock_client: APIClient,