Skip to content

Commit dc01592

Browse files
dafalbdraco
andauthored
Bthome encryption downgrade (home-assistant#159646)
Co-authored-by: J. Nick Koston <nick@koston.org> Co-authored-by: J. Nick Koston <nick@home-assistant.io>
1 parent c5fb2bd commit dc01592

8 files changed

Lines changed: 363 additions & 4 deletions

File tree

homeassistant/components/bthome/__init__.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
)
1616
from homeassistant.const import Platform
1717
from homeassistant.core import HomeAssistant
18-
from homeassistant.helpers import device_registry as dr
18+
from homeassistant.helpers import device_registry as dr, issue_registry as ir
1919
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceRegistry
2020
from homeassistant.helpers.dispatcher import async_dispatcher_send
2121
from homeassistant.util.signal_type import SignalType
@@ -36,6 +36,45 @@
3636
_LOGGER = logging.getLogger(__name__)
3737

3838

39+
def get_encryption_issue_id(entry_id: str) -> str:
40+
"""Return the repair issue id for encryption removal."""
41+
return f"encryption_removed_{entry_id}"
42+
43+
44+
def _async_create_encryption_downgrade_issue(
45+
hass: HomeAssistant, entry: BTHomeConfigEntry, issue_id: str
46+
) -> None:
47+
"""Create a repair issue for encryption downgrade."""
48+
_LOGGER.warning(
49+
"BTHome device %s was previously encrypted but is now sending "
50+
"unencrypted data. This could be a spoofing attempt. "
51+
"Data will be ignored until resolved",
52+
entry.title,
53+
)
54+
ir.async_create_issue(
55+
hass,
56+
DOMAIN,
57+
issue_id,
58+
is_fixable=True,
59+
is_persistent=True,
60+
severity=ir.IssueSeverity.WARNING,
61+
translation_key="encryption_removed",
62+
translation_placeholders={"name": entry.title},
63+
data={"entry_id": entry.entry_id},
64+
)
65+
66+
67+
def _async_clear_encryption_downgrade_issue(
68+
hass: HomeAssistant, entry: BTHomeConfigEntry, issue_id: str
69+
) -> None:
70+
"""Clear the encryption downgrade repair issue."""
71+
ir.async_delete_issue(hass, DOMAIN, issue_id)
72+
_LOGGER.info(
73+
"BTHome device %s is now sending encrypted data again. Resuming normal operation",
74+
entry.title,
75+
)
76+
77+
3978
def process_service_info(
4079
hass: HomeAssistant,
4180
entry: BTHomeConfigEntry,
@@ -45,7 +84,26 @@ def process_service_info(
4584
"""Process a BluetoothServiceInfoBleak, running side effects and returning sensor data."""
4685
coordinator = entry.runtime_data
4786
data = coordinator.device_data
87+
issue_registry = ir.async_get(hass)
88+
issue_id = get_encryption_issue_id(entry.entry_id)
4889
update = data.update(service_info)
90+
91+
# Block unencrypted payloads for devices that were previously verified as encrypted.
92+
if entry.data.get(CONF_BINDKEY) and data.downgrade_detected:
93+
if not coordinator.encryption_downgrade_logged:
94+
coordinator.encryption_downgrade_logged = True
95+
if not issue_registry.async_get_issue(DOMAIN, issue_id):
96+
_async_create_encryption_downgrade_issue(hass, entry, issue_id)
97+
return SensorUpdate(title=None, devices={})
98+
99+
if data.bindkey_verified and (
100+
(existing_issue := issue_registry.async_get_issue(DOMAIN, issue_id))
101+
or coordinator.encryption_downgrade_logged
102+
):
103+
coordinator.encryption_downgrade_logged = False
104+
if existing_issue:
105+
_async_clear_encryption_downgrade_issue(hass, entry, issue_id)
106+
49107
discovered_event_classes = coordinator.discovered_event_classes
50108
if entry.data.get(CONF_SLEEPY_DEVICE, False) != data.sleepy_device:
51109
hass.config_entries.async_update_entry(
@@ -150,3 +208,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: BTHomeConfigEntry) -> bo
150208
async def async_unload_entry(hass: HomeAssistant, entry: BTHomeConfigEntry) -> bool:
151209
"""Unload a config entry."""
152210
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
211+
212+
213+
async def async_remove_entry(hass: HomeAssistant, entry: BTHomeConfigEntry) -> None:
214+
"""Remove a config entry."""
215+
ir.async_delete_issue(hass, DOMAIN, get_encryption_issue_id(entry.entry_id))

homeassistant/components/bthome/coordinator.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ def __init__(
4141
self.discovered_event_classes = discovered_event_classes
4242
self.device_data = device_data
4343
self.entry = entry
44+
# Track whether we've already logged the encryption downgrade this session.
45+
self.encryption_downgrade_logged = False
4446

4547
@property
4648
def sleepy_device(self) -> bool:

homeassistant/components/bthome/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,5 @@
2020
"dependencies": ["bluetooth_adapters"],
2121
"documentation": "https://www.home-assistant.io/integrations/bthome",
2222
"iot_class": "local_push",
23-
"requirements": ["bthome-ble==3.16.0"]
23+
"requirements": ["bthome-ble==3.17.0"]
2424
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Repairs for the BTHome integration."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from homeassistant import data_entry_flow
8+
from homeassistant.components.repairs import RepairsFlow
9+
from homeassistant.core import HomeAssistant
10+
from homeassistant.helpers import issue_registry as ir
11+
12+
from . import get_encryption_issue_id
13+
from .const import CONF_BINDKEY, DOMAIN
14+
15+
16+
class EncryptionRemovedRepairFlow(RepairsFlow):
17+
"""Handle the repair flow when encryption is disabled."""
18+
19+
def __init__(self, entry_id: str, entry_title: str) -> None:
20+
"""Initialize the repair flow."""
21+
self._entry_id = entry_id
22+
self._entry_title = entry_title
23+
24+
async def async_step_init(
25+
self, user_input: dict[str, Any] | None = None
26+
) -> data_entry_flow.FlowResult:
27+
"""Handle the initial step of the repair flow."""
28+
return await self.async_step_confirm()
29+
30+
async def async_step_confirm(
31+
self, user_input: dict[str, Any] | None = None
32+
) -> data_entry_flow.FlowResult:
33+
"""Handle confirmation, remove the bindkey, and reload the entry."""
34+
if user_input is not None:
35+
entry = self.hass.config_entries.async_get_entry(self._entry_id)
36+
if not entry:
37+
return self.async_abort(reason="entry_removed")
38+
39+
new_data = {k: v for k, v in entry.data.items() if k != CONF_BINDKEY}
40+
self.hass.config_entries.async_update_entry(entry, data=new_data)
41+
42+
ir.async_delete_issue(
43+
self.hass, DOMAIN, get_encryption_issue_id(self._entry_id)
44+
)
45+
46+
await self.hass.config_entries.async_reload(self._entry_id)
47+
48+
return self.async_create_entry(data={})
49+
50+
return self.async_show_form(
51+
step_id="confirm",
52+
description_placeholders={"name": self._entry_title},
53+
)
54+
55+
56+
async def async_create_fix_flow(
57+
hass: HomeAssistant, issue_id: str, data: dict[str, Any] | None
58+
) -> RepairsFlow:
59+
"""Create the repair flow for removing the encryption key."""
60+
if not data or "entry_id" not in data:
61+
raise ValueError("Missing data for repair flow")
62+
entry_id = data["entry_id"]
63+
entry = hass.config_entries.async_get_entry(entry_id)
64+
entry_title = entry.title if entry else "Unknown device"
65+
return EncryptionRemovedRepairFlow(entry_id, entry_title)

homeassistant/components/bthome/strings.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,5 +117,21 @@
117117
"name": "UV Index"
118118
}
119119
}
120+
},
121+
"issues": {
122+
"encryption_removed": {
123+
"fix_flow": {
124+
"abort": {
125+
"entry_removed": "The device has been removed"
126+
},
127+
"step": {
128+
"confirm": {
129+
"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.",
130+
"title": "Remove encryption key for {name}"
131+
}
132+
}
133+
},
134+
"title": "Encryption disabled on {name}"
135+
}
120136
}
121137
}

requirements_all.txt

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

requirements_test_all.txt

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)