mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 02:03:27 +01:00
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:
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
65
homeassistant/components/bthome/repairs.py
Normal file
65
homeassistant/components/bthome/repairs.py
Normal 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)
|
||||
@@ -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
2
requirements_all.txt
generated
@@ -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
|
||||
|
||||
2
requirements_test_all.txt
generated
2
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
213
tests/components/bthome/test_repairs.py
Normal file
213
tests/components/bthome/test_repairs.py
Normal 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"
|
||||
Reference in New Issue
Block a user