diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index c2020402283..78c5893c889 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -9,11 +9,14 @@ from typing import Any, Concatenate, ParamSpec, TypeVar import aiohttp import python_otbr_api +from python_otbr_api import tlv_parser +from python_otbr_api.pskc import compute_pskc from homeassistant.components.thread import async_add_dataset from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType @@ -23,6 +26,18 @@ from .const import DOMAIN _R = TypeVar("_R") _P = ParamSpec("_P") +INSECURE_NETWORK_KEYS = ( + # Thread web UI default + bytes.fromhex("00112233445566778899AABBCCDDEEFF"), +) + +INSECURE_PASSPHRASES = ( + # Thread web UI default + "j01Nme", + # Thread documentation default + "J01NME", +) + def _handle_otbr_error( func: Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]] @@ -70,21 +85,65 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +def _warn_on_default_network_settings( + hass: HomeAssistant, entry: ConfigEntry, dataset_tlvs: bytes +) -> None: + """Warn user if insecure default network settings are used.""" + dataset = tlv_parser.parse_tlv(dataset_tlvs.hex()) + insecure = False + + if ( + network_key := dataset.get(tlv_parser.MeshcopTLVType.NETWORKKEY) + ) is not None and bytes.fromhex(network_key) in INSECURE_NETWORK_KEYS: + insecure = True + if ( + not insecure + and tlv_parser.MeshcopTLVType.EXTPANID in dataset + and tlv_parser.MeshcopTLVType.NETWORKNAME in dataset + and tlv_parser.MeshcopTLVType.PSKC in dataset + ): + ext_pan_id = dataset[tlv_parser.MeshcopTLVType.EXTPANID] + network_name = dataset[tlv_parser.MeshcopTLVType.NETWORKNAME] + pskc = bytes.fromhex(dataset[tlv_parser.MeshcopTLVType.PSKC]) + for passphrase in INSECURE_PASSPHRASES: + if pskc == compute_pskc(ext_pan_id, network_name, passphrase): + insecure = True + break + + if insecure: + ir.async_create_issue( + hass, + DOMAIN, + f"insecure_thread_network_{entry.entry_id}", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="insecure_thread_network", + ) + else: + ir.async_delete_issue( + hass, + DOMAIN, + f"insecure_thread_network_{entry.entry_id}", + ) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an Open Thread Border Router config entry.""" api = python_otbr_api.OTBR(entry.data["url"], async_get_clientsession(hass), 10) otbrdata = OTBRData(entry.data["url"], api) try: - dataset = await otbrdata.get_active_dataset_tlvs() + dataset_tlvs = await otbrdata.get_active_dataset_tlvs() except ( HomeAssistantError, aiohttp.ClientError, asyncio.TimeoutError, ) as err: raise ConfigEntryNotReady("Unable to connect") from err - if dataset: - await async_add_dataset(hass, entry.title, dataset.hex()) + if dataset_tlvs: + _warn_on_default_network_settings(hass, entry, dataset_tlvs) + await async_add_dataset(hass, entry.title, dataset_tlvs.hex()) hass.data[DOMAIN] = otbrdata diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index 24fb89f2140..0a6482b040e 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==1.0.4"] + "requirements": ["python-otbr-api==1.0.5"] } diff --git a/homeassistant/components/otbr/strings.json b/homeassistant/components/otbr/strings.json index 58b32276ba8..a05c3f3e926 100644 --- a/homeassistant/components/otbr/strings.json +++ b/homeassistant/components/otbr/strings.json @@ -14,5 +14,11 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } + }, + "issues": { + "insecure_thread_network": { + "title": "Insecure Thread network settings detected", + "description": "Your Thread network is using a default network key or pass phrase.\n\nThis is a security risk, please create a new Thread network." + } } } diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 16fadd9b06e..547def83450 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==1.0.4", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==1.0.5", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 109d88605b6..f733c0fcaf3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2097,7 +2097,7 @@ python-nest==4.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==1.0.4 +python-otbr-api==1.0.5 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8e4c697c7c9..5248e53e3f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1490,7 +1490,7 @@ python-nest==4.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==1.0.4 +python-otbr-api==1.0.5 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/tests/components/otbr/__init__.py b/tests/components/otbr/__init__.py index 2180a091eb7..a133f6fda30 100644 --- a/tests/components/otbr/__init__.py +++ b/tests/components/otbr/__init__.py @@ -6,3 +6,15 @@ DATASET = bytes.fromhex( "0FE2AAF60510DE98B5BA1A528FEE049D4B4B01835375030D4F70656E5468726561642048410102" "25A40410F5DD18371BFD29E1A601EF6FFAD94C030C0402A0F7F8" ) + +DATASET_INSECURE_NW_KEY = bytes.fromhex( + "0E080000000000010000000300000F35060004001FFFE0020811111111222222220708FDD24657" + "0A336069051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + +DATASET_INSECURE_PASSPHRASE = bytes.fromhex( + "0E080000000000010000000300000F35060004001FFFE0020811111111222222220708FDD24657" + "0A336069051000112233445566778899AABBCCDDEEFA030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) diff --git a/tests/components/otbr/conftest.py b/tests/components/otbr/conftest.py index d02524cb615..ac120b3e164 100644 --- a/tests/components/otbr/conftest.py +++ b/tests/components/otbr/conftest.py @@ -20,7 +20,11 @@ async def otbr_config_entry_fixture(hass): title="Open Thread Border Router", ) config_entry.add_to_hass(hass) - with patch("python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET): + with patch( + "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET + ), patch( + "homeassistant.components.otbr.compute_pskc" + ): # Patch to speed up tests assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 7818d736e0e..9261004ec1c 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -10,8 +10,15 @@ import python_otbr_api from homeassistant.components import otbr from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir -from . import BASE_URL, CONFIG_ENTRY_DATA, DATASET +from . import ( + BASE_URL, + CONFIG_ENTRY_DATA, + DATASET, + DATASET_INSECURE_NW_KEY, + DATASET_INSECURE_PASSPHRASE, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -19,6 +26,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_import_dataset(hass: HomeAssistant) -> None: """Test the active dataset is imported at setup.""" + issue_registry = ir.async_get(hass) config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA, @@ -35,6 +43,39 @@ async def test_import_dataset(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) mock_add.assert_called_once_with(config_entry.title, DATASET.hex()) + assert not issue_registry.async_get_issue( + domain=otbr.DOMAIN, issue_id=f"insecure_thread_network_{config_entry.entry_id}" + ) + + +@pytest.mark.parametrize( + "dataset", [DATASET_INSECURE_NW_KEY, DATASET_INSECURE_PASSPHRASE] +) +async def test_import_insecure_dataset(hass: HomeAssistant, dataset: bytes) -> None: + """Test the active dataset is imported at setup. + + This imports a dataset with insecure settings. + """ + issue_registry = ir.async_get(hass) + + config_entry = MockConfigEntry( + data=CONFIG_ENTRY_DATA, + domain=otbr.DOMAIN, + options={}, + title="My OTBR", + ) + config_entry.add_to_hass(hass) + with patch( + "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=dataset + ), patch( + "homeassistant.components.thread.dataset_store.DatasetStore.async_add" + ) as mock_add: + assert await hass.config_entries.async_setup(config_entry.entry_id) + + mock_add.assert_called_once_with(config_entry.title, dataset.hex()) + assert issue_registry.async_get_issue( + domain=otbr.DOMAIN, issue_id=f"insecure_thread_network_{config_entry.entry_id}" + ) @pytest.mark.parametrize(