From dc01592991c9873d622201b4f18f776e4e38830b Mon Sep 17 00:00:00 2001 From: dafal Date: Sun, 1 Feb 2026 08:40:47 +0100 Subject: [PATCH] Bthome encryption downgrade (#159646) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- homeassistant/components/bthome/__init__.py | 65 +++++- .../components/bthome/coordinator.py | 2 + homeassistant/components/bthome/manifest.json | 2 +- homeassistant/components/bthome/repairs.py | 65 ++++++ homeassistant/components/bthome/strings.json | 16 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bthome/test_repairs.py | 213 ++++++++++++++++++ 8 files changed, 363 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/bthome/repairs.py create mode 100644 tests/components/bthome/test_repairs.py diff --git a/homeassistant/components/bthome/__init__.py b/homeassistant/components/bthome/__init__.py index f5e634b774d..5464d6ccf98 100644 --- a/homeassistant/components/bthome/__init__.py +++ b/homeassistant/components/bthome/__init__.py @@ -15,7 +15,7 @@ from homeassistant.components.bluetooth import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceRegistry from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util.signal_type import SignalType @@ -36,6 +36,45 @@ PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.EVENT, Platform.SE _LOGGER = logging.getLogger(__name__) +def get_encryption_issue_id(entry_id: str) -> str: + """Return the repair issue id for encryption removal.""" + return f"encryption_removed_{entry_id}" + + +def _async_create_encryption_downgrade_issue( + hass: HomeAssistant, entry: BTHomeConfigEntry, issue_id: str +) -> None: + """Create a repair issue for encryption downgrade.""" + _LOGGER.warning( + "BTHome device %s was previously encrypted but is now sending " + "unencrypted data. This could be a spoofing attempt. " + "Data will be ignored until resolved", + entry.title, + ) + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="encryption_removed", + translation_placeholders={"name": entry.title}, + data={"entry_id": entry.entry_id}, + ) + + +def _async_clear_encryption_downgrade_issue( + hass: HomeAssistant, entry: BTHomeConfigEntry, issue_id: str +) -> None: + """Clear the encryption downgrade repair issue.""" + ir.async_delete_issue(hass, DOMAIN, issue_id) + _LOGGER.info( + "BTHome device %s is now sending encrypted data again. Resuming normal operation", + entry.title, + ) + + def process_service_info( hass: HomeAssistant, entry: BTHomeConfigEntry, @@ -45,7 +84,26 @@ def process_service_info( """Process a BluetoothServiceInfoBleak, running side effects and returning sensor data.""" coordinator = entry.runtime_data data = coordinator.device_data + issue_registry = ir.async_get(hass) + issue_id = get_encryption_issue_id(entry.entry_id) update = data.update(service_info) + + # Block unencrypted payloads for devices that were previously verified as encrypted. + if entry.data.get(CONF_BINDKEY) and data.downgrade_detected: + if not coordinator.encryption_downgrade_logged: + coordinator.encryption_downgrade_logged = True + if not issue_registry.async_get_issue(DOMAIN, issue_id): + _async_create_encryption_downgrade_issue(hass, entry, issue_id) + return SensorUpdate(title=None, devices={}) + + if data.bindkey_verified and ( + (existing_issue := issue_registry.async_get_issue(DOMAIN, issue_id)) + or coordinator.encryption_downgrade_logged + ): + coordinator.encryption_downgrade_logged = False + if existing_issue: + _async_clear_encryption_downgrade_issue(hass, entry, issue_id) + discovered_event_classes = coordinator.discovered_event_classes if entry.data.get(CONF_SLEEPY_DEVICE, False) != data.sleepy_device: hass.config_entries.async_update_entry( @@ -150,3 +208,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: BTHomeConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: BTHomeConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_entry(hass: HomeAssistant, entry: BTHomeConfigEntry) -> None: + """Remove a config entry.""" + ir.async_delete_issue(hass, DOMAIN, get_encryption_issue_id(entry.entry_id)) diff --git a/homeassistant/components/bthome/coordinator.py b/homeassistant/components/bthome/coordinator.py index 6ab88c48c46..437a18107c8 100644 --- a/homeassistant/components/bthome/coordinator.py +++ b/homeassistant/components/bthome/coordinator.py @@ -41,6 +41,8 @@ class BTHomePassiveBluetoothProcessorCoordinator( self.discovered_event_classes = discovered_event_classes self.device_data = device_data self.entry = entry + # Track whether we've already logged the encryption downgrade this session. + self.encryption_downgrade_logged = False @property def sleepy_device(self) -> bool: diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index ca9744d5b63..ae91ff19239 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.16.0"] + "requirements": ["bthome-ble==3.17.0"] } diff --git a/homeassistant/components/bthome/repairs.py b/homeassistant/components/bthome/repairs.py new file mode 100644 index 00000000000..4985bcd4e51 --- /dev/null +++ b/homeassistant/components/bthome/repairs.py @@ -0,0 +1,65 @@ +"""Repairs for the BTHome integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from . import get_encryption_issue_id +from .const import CONF_BINDKEY, DOMAIN + + +class EncryptionRemovedRepairFlow(RepairsFlow): + """Handle the repair flow when encryption is disabled.""" + + def __init__(self, entry_id: str, entry_title: str) -> None: + """Initialize the repair flow.""" + self._entry_id = entry_id + self._entry_title = entry_title + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the initial step of the repair flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: + """Handle confirmation, remove the bindkey, and reload the entry.""" + if user_input is not None: + entry = self.hass.config_entries.async_get_entry(self._entry_id) + if not entry: + return self.async_abort(reason="entry_removed") + + new_data = {k: v for k, v in entry.data.items() if k != CONF_BINDKEY} + self.hass.config_entries.async_update_entry(entry, data=new_data) + + ir.async_delete_issue( + self.hass, DOMAIN, get_encryption_issue_id(self._entry_id) + ) + + await self.hass.config_entries.async_reload(self._entry_id) + + return self.async_create_entry(data={}) + + return self.async_show_form( + step_id="confirm", + description_placeholders={"name": self._entry_title}, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, issue_id: str, data: dict[str, Any] | None +) -> RepairsFlow: + """Create the repair flow for removing the encryption key.""" + if not data or "entry_id" not in data: + raise ValueError("Missing data for repair flow") + entry_id = data["entry_id"] + entry = hass.config_entries.async_get_entry(entry_id) + entry_title = entry.title if entry else "Unknown device" + return EncryptionRemovedRepairFlow(entry_id, entry_title) diff --git a/homeassistant/components/bthome/strings.json b/homeassistant/components/bthome/strings.json index 35937341d84..de8476cada8 100644 --- a/homeassistant/components/bthome/strings.json +++ b/homeassistant/components/bthome/strings.json @@ -117,5 +117,21 @@ "name": "UV Index" } } + }, + "issues": { + "encryption_removed": { + "fix_flow": { + "abort": { + "entry_removed": "The device has been removed" + }, + "step": { + "confirm": { + "description": "The BTHome device **{name}** was configured with encryption but is now broadcasting unencrypted data. Data from this device is being ignored until this is resolved.\n\nIf you disabled encryption on the device, select **Submit** to remove the encryption key and resume receiving data.\n\nIf you did not disable encryption, someone may be attempting to spoof your device. Do not submit this form and the unencrypted data will continue to be ignored.", + "title": "Remove encryption key for {name}" + } + } + }, + "title": "Encryption disabled on {name}" + } } } diff --git a/requirements_all.txt b/requirements_all.txt index 7511967e758..f6d26e1651c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -704,7 +704,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.16.0 +bthome-ble==3.17.0 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 78415326cd1..7a13c2b6f1c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -634,7 +634,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.16.0 +bthome-ble==3.17.0 # homeassistant.components.buienradar buienradar==1.0.6 diff --git a/tests/components/bthome/test_repairs.py b/tests/components/bthome/test_repairs.py new file mode 100644 index 00000000000..c4f7b427944 --- /dev/null +++ b/tests/components/bthome/test_repairs.py @@ -0,0 +1,213 @@ +"""Tests for BTHome repair handling.""" + +from __future__ import annotations + +from collections.abc import Callable +import logging +from unittest.mock import patch + +import pytest + +from homeassistant.components.bluetooth import BluetoothChange +from homeassistant.components.bthome import get_encryption_issue_id +from homeassistant.components.bthome.const import CONF_BINDKEY, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from . import PRST_SERVICE_INFO, TEMP_HUMI_ENCRYPTED_SERVICE_INFO + +from tests.common import MockConfigEntry +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.typing import ClientSessionGenerator + +BINDKEY = "231d39c1d7cc1ab1aee224cd096db932" + + +async def _setup_entry( + hass: HomeAssistant, +) -> tuple[MockConfigEntry, Callable[[object, BluetoothChange], None]]: + """Set up a BTHome config entry and capture the Bluetooth callback.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="54:48:E6:8F:80:A5", + title="Test Device", + data={CONF_BINDKEY: BINDKEY}, + ) + entry.add_to_hass(hass) + + saved_callback: Callable[[object, BluetoothChange], None] | None = None + + def _async_register_callback(_hass, _callback, _matcher, _mode): + nonlocal saved_callback + saved_callback = _callback + return lambda: None + + with patch( + "homeassistant.components.bluetooth.update_coordinator.async_register_callback", + _async_register_callback, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert saved_callback is not None + return entry, saved_callback + + +async def test_encryption_downgrade_creates_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test unencrypted payloads create a repair issue.""" + entry, callback = await _setup_entry(hass) + issue_id = get_encryption_issue_id(entry.entry_id) + + # Send encrypted data first to establish the device + callback(TEMP_HUMI_ENCRYPTED_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None + + # Send unencrypted data - should create issue + callback(PRST_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.data is not None + assert issue.data["entry_id"] == entry.entry_id + assert issue.is_fixable is True + + +async def test_encryption_downgrade_warning_only_logged_once( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test warning is only logged once per session.""" + _, callback = await _setup_entry(hass) + + # Send encrypted data first + callback(TEMP_HUMI_ENCRYPTED_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + await hass.async_block_till_done() + + caplog.clear() + # First unencrypted - should warn + callback(PRST_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + await hass.async_block_till_done() + + assert ( + sum( + record.levelno == logging.WARNING and "unencrypted" in record.message + for record in caplog.records + ) + == 1 + ) + + caplog.clear() + # Second unencrypted - should not warn again + callback(PRST_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + await hass.async_block_till_done() + + assert not any( + record.levelno == logging.WARNING and "unencrypted" in record.message + for record in caplog.records + ) + + +async def test_issue_cleared_when_encryption_resumes( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test issue is cleared when encrypted data resumes.""" + entry, callback = await _setup_entry(hass) + issue_id = get_encryption_issue_id(entry.entry_id) + + # Send encrypted, then unencrypted to create the issue + callback(TEMP_HUMI_ENCRYPTED_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + await hass.async_block_till_done() + callback(PRST_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None + + # Send encrypted data again - should clear issue + callback(TEMP_HUMI_ENCRYPTED_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None + + +async def test_repair_flow_removes_bindkey_and_reloads_entry( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the repair flow clears the bindkey and reloads the entry.""" + entry, callback = await _setup_entry(hass) + issue_id = get_encryption_issue_id(entry.entry_id) + + # Send encrypted, then unencrypted to create the issue + callback(TEMP_HUMI_ENCRYPTED_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + await hass.async_block_till_done() + callback(PRST_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None + + assert await async_setup_component(hass, "repairs", {}) + await async_process_repairs_platforms(hass) + http_client = await hass_client() + + # Start the repair flow + data = await start_repair_fix_flow(http_client, DOMAIN, issue_id) + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + + # Confirm the repair + data = await process_repair_fix_flow(http_client, flow_id, {}) + assert data["type"] == "create_entry" + + # Verify bindkey was removed and issue cleared + assert CONF_BINDKEY not in entry.data + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None + + +async def test_repair_flow_aborts_when_entry_removed( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the repair flow aborts gracefully when entry is removed.""" + entry, callback = await _setup_entry(hass) + issue_id = get_encryption_issue_id(entry.entry_id) + + # Send encrypted, then unencrypted to create the issue + callback(TEMP_HUMI_ENCRYPTED_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + await hass.async_block_till_done() + callback(PRST_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None + + assert await async_setup_component(hass, "repairs", {}) + await async_process_repairs_platforms(hass) + http_client = await hass_client() + + # Start the repair flow + data = await start_repair_fix_flow(http_client, DOMAIN, issue_id) + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + + # Remove entry before confirming + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + # Confirm the repair - should abort + data = await process_repair_fix_flow(http_client, flow_id, {}) + assert data["type"] == "abort" + assert data["reason"] == "entry_removed"