Bthome encryption downgrade (#159646)

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
dafal
2026-02-01 08:40:47 +01:00
committed by GitHub
parent c5fb2bd566
commit dc01592991
8 changed files with 363 additions and 4 deletions

View File

@@ -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))

View File

@@ -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:

View File

@@ -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"]
}

View File

@@ -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)

View File

@@ -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}"
}
}
}

2
requirements_all.txt generated
View File

@@ -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

View File

@@ -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

View File

@@ -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"