diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 38c0bcc4aaee2..ebfb6edf64339 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -66,6 +66,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: OTBRConfigEntry) -> bool TimeoutError, ) as err: raise ConfigEntryNotReady("Unable to connect") from err + try: + otbrdata.ephemeral_key_supported = await otbrdata.get_ephemeral_key_supported( + hass + ) + except HomeAssistantError: + # Optional feature, it is probed again when the Thread panel asks for it + _LOGGER.debug("Could not probe %s for ephemeral key support", otbrdata.url) await update_unique_id(hass, entry, border_agent_id) if dataset_tlvs: await update_issues(hass, otbrdata, dataset_tlvs) @@ -88,6 +95,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: OTBRConfigEntry) -> bool async def async_unload_entry(hass: HomeAssistant, entry: OTBRConfigEntry) -> bool: """Unload a config entry.""" + otbrdata = entry.runtime_data + otbrdata.unloading = True + # The key outlives this entry's memory of it, so revoke it rather than + # leaving the credential active until it expires + try: + await otbrdata.deactivate_ephemeral_key(hass, only_if_active=True) + except HomeAssistantError: + _LOGGER.warning("Could not deactivate the ephemeral key on %s", otbrdata.url) return True diff --git a/homeassistant/components/otbr/const.py b/homeassistant/components/otbr/const.py index cc3e4a9e6c3a4..f0b9c6a7b6968 100644 --- a/homeassistant/components/otbr/const.py +++ b/homeassistant/components/otbr/const.py @@ -3,3 +3,6 @@ DOMAIN = "otbr" DEFAULT_CHANNEL = 15 + +# The OpenThread border agent API caps the lifetime at 10 minutes +EPHEMERAL_KEY_LIFETIME_MS = 5 * 60 * 1000 diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index bdd66a9d3625c..56965b52eae58 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -1,8 +1,11 @@ """Utility functions for the Open Thread Border Router integration.""" +import asyncio from collections.abc import Callable, Coroutine import dataclasses +from datetime import datetime, timedelta from functools import wraps +from http import HTTPStatus import logging import random from typing import TYPE_CHECKING, Any, Concatenate, cast @@ -22,6 +25,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -48,6 +53,25 @@ class GetBorderAgentIdNotSupported(HomeAssistantError): """Raised from python_otbr_api.GetBorderAgentIdNotSupportedError.""" +class EphemeralKeyNotSupported(HomeAssistantError): + """Raised when the router does not expose ephemeral key mode.""" + + +class EphemeralKeyInUse(HomeAssistantError): + """Raised when a device is connected through the active ephemeral key.""" + + +# A router without the ephemeral key routes answers with 404, but ot-br-posix +# builds between #2733 and #3524 report failed PUT requests as 405 (ot-br-posix#3522) +EPHEMERAL_KEY_UNSUPPORTED_STATUS = ( + HTTPStatus.NOT_FOUND, + HTTPStatus.METHOD_NOT_ALLOWED, +) + +# Deactivating the key in these states drops a commissioner mid-session +EPHEMERAL_KEY_IN_USE_STATES = ("connected", "accepted") + + def compose_default_network_name(pan_id: int) -> str: """Generate a default network name.""" return f"ha-thread-{pan_id:04x}" @@ -81,6 +105,14 @@ class OTBRData: url: str api: python_otbr_api.OTBR entry_id: str + # None until the router has been probed successfully + ephemeral_key_supported: bool | None = None + active_ephemeral_key: str | None = None + active_ephemeral_key_expires: datetime | None = None + unloading: bool = False + ephemeral_key_lock: asyncio.Lock = dataclasses.field( + default_factory=asyncio.Lock, repr=False + ) @_handle_otbr_error async def factory_reset(self, hass: HomeAssistant) -> None: @@ -160,6 +192,152 @@ async def get_coprocessor_version(self) -> str: """Get coprocessor firmware version.""" return await self.api.get_coprocessor_version() + @_handle_otbr_error + async def get_ephemeral_key_supported(self, hass: HomeAssistant) -> bool: + """Return whether the router supports ephemeral key mode.""" + session = async_get_clientsession(hass) + response = await session.get( + f"{self.url}/node/ba-epskc/state", + timeout=aiohttp.ClientTimeout(total=10), + ) + if response.status == HTTPStatus.OK: + return True + if response.status in EPHEMERAL_KEY_UNSUPPORTED_STATUS: + return False + raise python_otbr_api.OTBRError(f"unexpected http status {response.status}") + + @_handle_otbr_error + async def activate_ephemeral_key( + self, hass: HomeAssistant, lifetime: int + ) -> tuple[str, int]: + """Activate ephemeral key mode, returning the passcode and its UDP port. + + The lifetime is in milliseconds, as the OpenThread border agent API + takes it. + """ + async with self.ephemeral_key_lock: + return await self._activate_ephemeral_key(hass, lifetime) + + async def _activate_ephemeral_key( + self, hass: HomeAssistant, lifetime: int + ) -> tuple[str, int]: + """Activate ephemeral key mode while holding the lock.""" + if self.unloading: + raise HomeAssistantError("OTBR entry is unloading") + # A key handed out by this instance stays valid until its dialog is + # closed, so don't silently replace it for a second caller + if ( + self.active_ephemeral_key is not None + and self.active_ephemeral_key_expires is not None + and self.active_ephemeral_key_expires > dt_util.utcnow() + ): + raise EphemeralKeyInUse + + session = async_get_clientsession(hass) + timeout = aiohttp.ClientTimeout(total=10) + + # The feature has to be enabled before a key can be activated + response = await session.put( + f"{self.url}/node/ba-epskc/state", + json="enable", + timeout=timeout, + ) + if response.status in EPHEMERAL_KEY_UNSUPPORTED_STATUS: + raise EphemeralKeyNotSupported + if response.status != HTTPStatus.OK: + raise python_otbr_api.OTBRError(f"unexpected http status {response.status}") + + async def activate() -> aiohttp.ClientResponse: + return await session.post( + f"{self.url}/node/ba-epskc/key", + json={"lifetime": lifetime}, + timeout=timeout, + ) + + response = await activate() + if response.status == HTTPStatus.CONFLICT: + # A key is already active, and one can only be started from the + # stopped state, so replace it unless a device is using it right now + status_response = await session.get( + f"{self.url}/node/ba-epskc/key", timeout=timeout + ) + if status_response.status != HTTPStatus.OK: + raise python_otbr_api.OTBRError( + f"unexpected http status {status_response.status}" + ) + try: + state = (await status_response.json())["state"] + except (ValueError, KeyError, TypeError) as exc: + raise python_otbr_api.OTBRError("unexpected API response") from exc + if state in EPHEMERAL_KEY_IN_USE_STATES: + raise EphemeralKeyInUse + delete_response = await session.delete( + f"{self.url}/node/ba-epskc/key", timeout=timeout + ) + if delete_response.status != HTTPStatus.OK: + raise python_otbr_api.OTBRError( + "failed to replace the active ephemeral key: " + f"unexpected http status {delete_response.status}" + ) + response = await activate() + + if response.status in EPHEMERAL_KEY_UNSUPPORTED_STATUS: + raise EphemeralKeyNotSupported + if response.status != HTTPStatus.OK: + raise python_otbr_api.OTBRError(f"unexpected http status {response.status}") + + try: + activation = await response.json() + ephemeral_key, port = activation["tap"], activation["port"] + except (ValueError, KeyError, TypeError) as exc: + raise python_otbr_api.OTBRError("unexpected API response") from exc + self.active_ephemeral_key = ephemeral_key + self.active_ephemeral_key_expires = dt_util.utcnow() + timedelta( + milliseconds=lifetime + ) + return ephemeral_key, port + + @_handle_otbr_error + async def deactivate_ephemeral_key( + self, + hass: HomeAssistant, + ephemeral_key: str | None = None, + only_if_active: bool = False, + ) -> bool: + """Deactivate the active ephemeral key, returning whether one was deleted. + + With a key given, only that key is deactivated, so a stale request + cannot revoke a key handed out after it. + """ + async with self.ephemeral_key_lock: + # The router dropped an expired key on its own; deleting now could + # revoke a key another controller activated since + if ( + self.active_ephemeral_key_expires is not None + and self.active_ephemeral_key_expires <= dt_util.utcnow() + ): + self.active_ephemeral_key = None + self.active_ephemeral_key_expires = None + if only_if_active and self.active_ephemeral_key is None: + return False + if ephemeral_key is not None and ephemeral_key != self.active_ephemeral_key: + return False + session = async_get_clientsession(hass) + response = await session.delete( + f"{self.url}/node/ba-epskc/key", + timeout=aiohttp.ClientTimeout(total=10), + ) + if response.status in EPHEMERAL_KEY_UNSUPPORTED_STATUS: + raise EphemeralKeyNotSupported + if response.status != HTTPStatus.OK: + raise python_otbr_api.OTBRError( + f"unexpected http status {response.status}" + ) + # Only forget the key once the router confirmed it is gone + self.active_ephemeral_key = None + self.active_ephemeral_key_expires = None + return True + async def get_allowed_channel(hass: HomeAssistant, otbr_url: str) -> int | None: """Return the allowed channel, or None if there's no restriction.""" diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 2bcd0da8f16c5..cf602d6600c70 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -2,6 +2,7 @@ from collections.abc import Callable, Coroutine from functools import wraps +import logging from typing import TYPE_CHECKING, Any, cast import python_otbr_api @@ -17,8 +18,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from .const import DEFAULT_CHANNEL, DOMAIN +from .const import DEFAULT_CHANNEL, DOMAIN, EPHEMERAL_KEY_LIFETIME_MS from .util import ( + EphemeralKeyInUse, + EphemeralKeyNotSupported, OTBRData, compose_default_network_name, generate_random_pan_id, @@ -29,12 +32,16 @@ if TYPE_CHECKING: from . import OTBRConfigEntry +_LOGGER = logging.getLogger(__name__) + @callback def async_setup(hass: HomeAssistant) -> None: """Set up the OTBR Websocket API.""" websocket_api.async_register_command(hass, websocket_info) websocket_api.async_register_command(hass, websocket_create_network) + websocket_api.async_register_command(hass, websocket_create_ephemeral_key) + websocket_api.async_register_command(hass, websocket_delete_ephemeral_key) websocket_api.async_register_command(hass, websocket_set_channel) websocket_api.async_register_command(hass, websocket_set_network) @@ -70,6 +77,14 @@ async def websocket_info( connection.send_error(msg["id"], "otbr_info_failed", str(exc)) return + if data.ephemeral_key_supported is None: + try: + data.ephemeral_key_supported = await data.get_ephemeral_key_supported( + hass + ) + except HomeAssistantError: + _LOGGER.debug("Could not probe %s for ephemeral key support", data.url) + # The border agent ID is checked when the OTBR config entry is setup, # we can assert it's not None assert border_agent_id is not None @@ -85,6 +100,7 @@ async def websocket_info( "channel": dataset.channel if dataset else None, "extended_address": extended_address, "extended_pan_id": extended_pan_id, + "ephemeral_key_supported": bool(data.ephemeral_key_supported), "url": data.url, } @@ -199,6 +215,93 @@ async def websocket_create_network( connection.send_result(msg["id"]) +@websocket_api.websocket_command( + { + "type": "otbr/create_ephemeral_key", + vol.Required("extended_address"): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +@async_get_otbr_data +async def websocket_create_ephemeral_key( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, + data: OTBRData, +) -> None: + """Create an ephemeral key for sharing the Thread network credentials.""" + try: + ephemeral_key, port = await data.activate_ephemeral_key( + hass, EPHEMERAL_KEY_LIFETIME_MS + ) + except EphemeralKeyNotSupported: + connection.send_error( + msg["id"], + "ephemeral_key_not_supported", + "The border router does not support credential sharing", + ) + return + except EphemeralKeyInUse: + connection.send_error( + msg["id"], + "ephemeral_key_in_use", + "The active ephemeral key is still in use", + ) + return + except HomeAssistantError as exc: + connection.send_error(msg["id"], "create_ephemeral_key_failed", str(exc)) + return + + # The key grants Thread administration to whoever enters it, so leave a trace + _LOGGER.info("Ephemeral key for %s created by %s", data.url, connection.user.name) + connection.send_result( + msg["id"], + { + "ephemeral_key": ephemeral_key, + "lifetime": EPHEMERAL_KEY_LIFETIME_MS // 1000, + "port": port, + }, + ) + + +@websocket_api.websocket_command( + { + "type": "otbr/delete_ephemeral_key", + vol.Required("extended_address"): str, + vol.Optional("ephemeral_key"): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +@async_get_otbr_data +async def websocket_delete_ephemeral_key( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, + data: OTBRData, +) -> None: + """Deactivate the active ephemeral key, revoking the shared credentials.""" + try: + deleted = await data.deactivate_ephemeral_key(hass, msg.get("ephemeral_key")) + except EphemeralKeyNotSupported: + connection.send_error( + msg["id"], + "ephemeral_key_not_supported", + "The border router does not support credential sharing", + ) + return + except HomeAssistantError as exc: + connection.send_error(msg["id"], "delete_ephemeral_key_failed", str(exc)) + return + + if deleted: + _LOGGER.info( + "Ephemeral key for %s deleted by %s", data.url, connection.user.name + ) + connection.send_result(msg["id"]) + + @websocket_api.websocket_command( { "type": "otbr/set_network", diff --git a/tests/components/otbr/conftest.py b/tests/components/otbr/conftest.py index 09df4d846af21..41ae0b5a753cb 100644 --- a/tests/components/otbr/conftest.py +++ b/tests/components/otbr/conftest.py @@ -69,6 +69,22 @@ def mock_api_actions( aioclient_mock.get(re.compile(r".*/api/actions$"), status=status) +@pytest.fixture(name="ephemeral_key_probe_status") +def ephemeral_key_probe_status_fixture() -> HTTPStatus: + """Override to control the ephemeral key support probe outcome.""" + return HTTPStatus.OK + + +@pytest.fixture(autouse=True) +def mock_ephemeral_key_state( + aioclient_mock: AiohttpClientMocker, ephemeral_key_probe_status: HTTPStatus +) -> None: + """Mock the /node/ba-epskc/state probe used to detect ephemeral key support.""" + aioclient_mock.get( + re.compile(r".*/node/ba-epskc/state$"), status=ephemeral_key_probe_status + ) + + @pytest.fixture(name="get_active_dataset_tlvs") def get_active_dataset_tlvs_fixture(dataset: Any) -> Generator[AsyncMock]: """Mock get_active_dataset_tlvs.""" diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index b14527165e6e8..5aa347ea9f76b 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -1,6 +1,9 @@ """Test the Open Thread Border Router integration.""" import asyncio +from datetime import timedelta +from http import HTTPStatus +import re from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock, patch @@ -11,10 +14,11 @@ from homeassistant.components import otbr, thread from homeassistant.components.thread import discovery -from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_USER +from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_USER, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from . import ( BASE_URL, @@ -267,6 +271,113 @@ async def test_config_entry_not_ready( assert not await hass.config_entries.async_setup(config_entry.entry_id) +@pytest.mark.parametrize("error", [TimeoutError, aiohttp.ClientError]) +@pytest.mark.usefixtures( + "get_active_dataset_tlvs", + "get_border_agent_id", + "get_extended_address", + "multiprotocol_addon_manager_mock", +) +async def test_ephemeral_key_probe_connection_error( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, error: type[Exception] +) -> None: + """Test a connection error while probing ephemeral key support is not fatal.""" + aioclient_mock.clear_requests() + aioclient_mock.get(re.compile(r".*/node/ba-epskc/state$"), exc=error) + + config_entry = MockConfigEntry( + data=CONFIG_ENTRY_DATA_MULTIPAN, + domain=otbr.DOMAIN, + options={}, + title="My OTBR", + unique_id=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + # Support stays unknown, to be probed again later + assert config_entry.runtime_data.ephemeral_key_supported is None + + +@pytest.mark.parametrize( + ("delete_status", "expired", "delete_sent", "forgotten"), + [ + pytest.param(HTTPStatus.OK, False, True, True, id="deleted"), + pytest.param( + HTTPStatus.INTERNAL_SERVER_ERROR, False, True, False, id="delete_fails" + ), + # The router already dropped it, and another controller may own a new one + pytest.param(HTTPStatus.OK, True, False, True, id="expired"), + ], +) +@pytest.mark.usefixtures( + "get_active_dataset_tlvs", + "get_border_agent_id", + "get_extended_address", + "multiprotocol_addon_manager_mock", +) +async def test_unload_entry_revokes_ephemeral_key( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + delete_status: HTTPStatus, + expired: bool, + delete_sent: bool, + forgotten: bool, +) -> None: + """Test an active ephemeral key is revoked when the entry unloads.""" + aioclient_mock.delete(f"{BASE_URL}/node/ba-epskc/key", status=delete_status) + config_entry = MockConfigEntry( + data=CONFIG_ENTRY_DATA_MULTIPAN, + domain=otbr.DOMAIN, + options={}, + title="My OTBR", + unique_id=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + otbrdata = config_entry.runtime_data + otbrdata.active_ephemeral_key = "700855744" + otbrdata.active_ephemeral_key_expires = dt_util.utcnow() + timedelta( + minutes=-1 if expired else 1 + ) + + assert await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert (aioclient_mock.mock_calls[-1][0] == "DELETE") is delete_sent + assert (otbrdata.active_ephemeral_key is None) is forgotten + + +@pytest.mark.parametrize( + ("ephemeral_key_probe_status", "ephemeral_key_supported"), + [ + pytest.param(HTTPStatus.OK, True, id="supported"), + pytest.param(HTTPStatus.NOT_FOUND, False, id="not_supported"), + pytest.param(HTTPStatus.METHOD_NOT_ALLOWED, False, id="not_supported_method"), + pytest.param(HTTPStatus.INTERNAL_SERVER_ERROR, None, id="probe_error"), + ], +) +@pytest.mark.usefixtures( + "get_active_dataset_tlvs", + "get_border_agent_id", + "get_extended_address", + "multiprotocol_addon_manager_mock", +) +async def test_ephemeral_key_support_probe( + hass: HomeAssistant, ephemeral_key_supported: bool | None +) -> None: + """Test the ephemeral key support probe handles router response statuses.""" + config_entry = MockConfigEntry( + data=CONFIG_ENTRY_DATA_MULTIPAN, + domain=otbr.DOMAIN, + options={}, + title="My OTBR", + unique_id=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.runtime_data.ephemeral_key_supported is ephemeral_key_supported + + async def test_border_agent_id_not_supported( hass: HomeAssistant, get_border_agent_id: AsyncMock ) -> None: diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 0b7d2bc8e2a92..90a3bba407215 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -1,9 +1,16 @@ """Test OTBR Websocket API.""" +import asyncio +from datetime import timedelta +from http import HTTPStatus +from typing import Any from unittest.mock import AsyncMock, patch +import aiohttp +from freezegun.api import FrozenDateTimeFactory import pytest import python_otbr_api +from yarl import URL from homeassistant.components import otbr, thread from homeassistant.components.otbr import DOMAIN @@ -18,7 +25,8 @@ TEST_BORDER_AGENT_ID, ) -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockUser +from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse from tests.typing import MockHAClientWebSocket, WebSocketGenerator @@ -35,11 +43,19 @@ def mock_supervisor_client(supervisor_client: AsyncMock) -> None: """Mock supervisor client.""" +@pytest.mark.parametrize( + ("ephemeral_key_probe_status", "ephemeral_key_supported"), + [ + pytest.param(HTTPStatus.OK, True, id="supported"), + pytest.param(HTTPStatus.NOT_FOUND, False, id="not_supported"), + ], +) async def test_get_info( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry_multipan, websocket_client, + ephemeral_key_supported: bool, ) -> None: """Test async_get_info.""" extended_pan_id = "ABCD1234" @@ -76,10 +92,62 @@ async def test_get_info( "border_agent_id": TEST_BORDER_AGENT_ID.hex(), "extended_address": extended_address, "extended_pan_id": extended_pan_id.lower(), + "ephemeral_key_supported": ephemeral_key_supported, } } +@pytest.mark.parametrize( + ("ephemeral_key_probe_status", "ephemeral_key_supported"), + [ + pytest.param(HTTPStatus.OK, True, id="supported"), + pytest.param(HTTPStatus.NOT_FOUND, False, id="not_supported"), + ], +) +async def test_get_info_probes_ephemeral_key_support( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry_multipan: str, + websocket_client: MockHAClientWebSocket, + ephemeral_key_supported: bool, +) -> None: + """Test otbr/info probes for ephemeral key support while it is unknown.""" + entry = hass.config_entries.async_get_entry(otbr_config_entry_multipan) + assert entry is not None + entry.runtime_data.ephemeral_key_supported = None + probes = len([call for call in aioclient_mock.mock_calls if call[0] == "GET"]) + + with ( + patch( + "python_otbr_api.OTBR.get_active_dataset", + return_value=python_otbr_api.ActiveDataSet(channel=16), + ), + patch( + "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 + ), + patch( + "python_otbr_api.OTBR.get_border_agent_id", + return_value=TEST_BORDER_AGENT_ID, + ), + patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ), + ): + await websocket_client.send_json_auto_id({"type": "otbr/info"}) + msg = await websocket_client.receive_json() + await websocket_client.send_json_auto_id({"type": "otbr/info"}) + msg2 = await websocket_client.receive_json() + + result = msg["result"][TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex()] + assert result["ephemeral_key_supported"] is ephemeral_key_supported + assert msg2["result"] == msg["result"] + # Probed once, then remembered + assert len([call for call in aioclient_mock.mock_calls if call[0] == "GET"]) == ( + probes + 1 + ) + + async def test_get_info_no_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -853,3 +921,668 @@ async def test_set_channel_fails_3( assert not msg["success"] assert msg["error"]["code"] == "unknown_router" + + +EPHEMERAL_KEY_COMMANDS = [ + pytest.param("otbr/create_ephemeral_key", id="create"), + pytest.param("otbr/delete_ephemeral_key", id="delete"), +] + + +@pytest.mark.usefixtures("otbr_config_entry_multipan") +async def test_create_ephemeral_key( + aioclient_mock: AiohttpClientMocker, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test create ephemeral key activates the key on the border router.""" + aioclient_mock.put(f"{BASE_URL}/node/ba-epskc/state") + aioclient_mock.post( + f"{BASE_URL}/node/ba-epskc/key", json={"tap": "700855744", "port": 49154} + ) + + with patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/create_ephemeral_key", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + msg = await websocket_client.receive_json() + + assert msg["success"] + assert msg["result"] == { + "ephemeral_key": "700855744", + "lifetime": 300, + "port": 49154, + } + # The feature is enabled first; the border agent API takes the lifetime in ms + assert aioclient_mock.mock_calls[-2][2] == "enable" + assert aioclient_mock.mock_calls[-1][2] == {"lifetime": 300000} + + +@pytest.mark.parametrize( + ("state_status", "key_status"), + [ + pytest.param(HTTPStatus.NOT_FOUND, HTTPStatus.OK, id="state_not_found"), + pytest.param(HTTPStatus.OK, HTTPStatus.NOT_FOUND, id="key_not_found"), + # Routers which reject the method before matching the path answer 405 + pytest.param( + HTTPStatus.METHOD_NOT_ALLOWED, HTTPStatus.OK, id="state_not_allowed" + ), + pytest.param( + HTTPStatus.OK, HTTPStatus.METHOD_NOT_ALLOWED, id="key_not_allowed" + ), + ], +) +@pytest.mark.usefixtures("otbr_config_entry_multipan") +async def test_create_ephemeral_key_not_supported( + aioclient_mock: AiohttpClientMocker, + websocket_client: MockHAClientWebSocket, + state_status: HTTPStatus, + key_status: HTTPStatus, +) -> None: + """Test a router without ephemeral key support is reported as unsupported.""" + aioclient_mock.put(f"{BASE_URL}/node/ba-epskc/state", status=state_status) + aioclient_mock.post(f"{BASE_URL}/node/ba-epskc/key", status=key_status, json={}) + + with patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/create_ephemeral_key", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "ephemeral_key_not_supported" + + +@pytest.mark.usefixtures("otbr_config_entry_multipan") +async def test_create_ephemeral_key_replaces_active_key( + aioclient_mock: AiohttpClientMocker, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test an unused active key is dropped so a new one can be created.""" + aioclient_mock.put(f"{BASE_URL}/node/ba-epskc/state") + aioclient_mock.get( + f"{BASE_URL}/node/ba-epskc/key", json={"state": "started", "port": 49154} + ) + aioclient_mock.delete(f"{BASE_URL}/node/ba-epskc/key") + # The border router only accepts a new key from the stopped state, so the + # first activation conflicts and only the one after the delete succeeds + responses = [ + AiohttpClientMockResponse( + "POST", URL(f"{BASE_URL}/node/ba-epskc/key"), status=HTTPStatus.CONFLICT + ), + AiohttpClientMockResponse( + "POST", + URL(f"{BASE_URL}/node/ba-epskc/key"), + json={"tap": "700855744", "port": 49154}, + ), + ] + + async def activate(method: str, url: URL, data: Any) -> AiohttpClientMockResponse: + return responses.pop(0) + + aioclient_mock.post(f"{BASE_URL}/node/ba-epskc/key", side_effect=activate) + + with patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/create_ephemeral_key", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + msg = await websocket_client.receive_json() + + assert msg["success"] + assert msg["result"]["ephemeral_key"] == "700855744" + assert [call[0] for call in aioclient_mock.mock_calls[-5:]] == [ + "PUT", + "POST", + "GET", + "DELETE", + "POST", + ] + + +@pytest.mark.parametrize( + ("elapsed", "success"), + [ + pytest.param(timedelta(minutes=1), False, id="key_still_valid"), + pytest.param(timedelta(minutes=6), True, id="key_expired"), + ], +) +@pytest.mark.usefixtures("otbr_config_entry_multipan") +async def test_create_ephemeral_key_twice( + aioclient_mock: AiohttpClientMocker, + websocket_client: MockHAClientWebSocket, + freezer: FrozenDateTimeFactory, + elapsed: timedelta, + success: bool, +) -> None: + """Test a key handed out by this instance is not replaced until it expires.""" + aioclient_mock.put(f"{BASE_URL}/node/ba-epskc/state") + aioclient_mock.post( + f"{BASE_URL}/node/ba-epskc/key", json={"tap": "700855744", "port": 49154} + ) + + with patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/create_ephemeral_key", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + assert (await websocket_client.receive_json())["success"] + posts = aioclient_mock.call_count + freezer.tick(elapsed) + await websocket_client.send_json_auto_id( + { + "type": "otbr/create_ephemeral_key", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + msg = await websocket_client.receive_json() + + assert msg["success"] is success + assert msg.get("error", {}).get("code") == ( + None if success else "ephemeral_key_in_use" + ) + # The router is only asked for another key once the first one has expired + assert (aioclient_mock.call_count > posts) is success + + +@pytest.mark.usefixtures("otbr_config_entry_multipan") +async def test_create_ephemeral_key_concurrently( + aioclient_mock: AiohttpClientMocker, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test only one of two concurrent requests gets a key.""" + aioclient_mock.put(f"{BASE_URL}/node/ba-epskc/state") + release = asyncio.Event() + requests: asyncio.Queue[None] = asyncio.Queue() + + async def activate(method: str, url: URL, data: Any) -> AiohttpClientMockResponse: + await release.wait() + return AiohttpClientMockResponse( + "POST", + URL(f"{BASE_URL}/node/ba-epskc/key"), + json={"tap": "700855744", "port": 49154}, + ) + + async def get_extended_address() -> bytes: + # Called by each request right before the ephemeral key lock + await requests.put(None) + return TEST_BORDER_AGENT_EXTENDED_ADDRESS + + aioclient_mock.post(f"{BASE_URL}/node/ba-epskc/key", side_effect=activate) + create_msg = { + "type": "otbr/create_ephemeral_key", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + + with patch( + "python_otbr_api.OTBR.get_extended_address", side_effect=get_extended_address + ): + await websocket_client.send_json_auto_id(create_msg) + await requests.get() + await websocket_client.send_json_auto_id(create_msg) + await requests.get() + # Both requests are in flight: the first waits on the router, the + # second must be waiting for the first to finish + release.set() + msgs = [ + await websocket_client.receive_json(), + await websocket_client.receive_json(), + ] + + assert sorted(msg.get("error", {}).get("code", "success") for msg in msgs) == [ + "ephemeral_key_in_use", + "success", + ] + # The router was only asked for one key + assert len([call for call in aioclient_mock.mock_calls if call[0] == "POST"]) == 1 + + +@pytest.mark.parametrize("state", ["connected", "accepted"]) +@pytest.mark.usefixtures("otbr_config_entry_multipan") +async def test_create_ephemeral_key_in_use( + aioclient_mock: AiohttpClientMocker, + websocket_client: MockHAClientWebSocket, + state: str, +) -> None: + """Test a key a device is joining through is not replaced.""" + aioclient_mock.put(f"{BASE_URL}/node/ba-epskc/state") + aioclient_mock.post(f"{BASE_URL}/node/ba-epskc/key", status=HTTPStatus.CONFLICT) + aioclient_mock.get( + f"{BASE_URL}/node/ba-epskc/key", json={"state": state, "port": 49154} + ) + + with patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/create_ephemeral_key", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "ephemeral_key_in_use" + assert not any(call[0] == "DELETE" for call in aioclient_mock.mock_calls) + + +KEY_STARTED = (HTTPStatus.OK, {"state": "started", "port": 49154}) + + +@pytest.mark.parametrize( + ("state_status", "key_responses", "key_status", "delete_status"), + [ + pytest.param( + HTTPStatus.INTERNAL_SERVER_ERROR, + [], + KEY_STARTED, + HTTPStatus.OK, + id="enable_fails", + ), + pytest.param( + HTTPStatus.OK, + [(HTTPStatus.INTERNAL_SERVER_ERROR, None)], + KEY_STARTED, + HTTPStatus.OK, + id="activate_fails", + ), + pytest.param( + HTTPStatus.OK, + [(HTTPStatus.OK, {"tap": "700855744"})], + KEY_STARTED, + HTTPStatus.OK, + id="missing_port", + ), + pytest.param( + HTTPStatus.OK, + [(HTTPStatus.OK, [])], + KEY_STARTED, + HTTPStatus.OK, + id="not_a_dict", + ), + pytest.param( + HTTPStatus.OK, + [(HTTPStatus.CONFLICT, None), (HTTPStatus.CONFLICT, None)], + KEY_STARTED, + HTTPStatus.OK, + id="conflict_after_replacement", + ), + pytest.param( + HTTPStatus.OK, + [(HTTPStatus.CONFLICT, None)], + (HTTPStatus.INTERNAL_SERVER_ERROR, None), + HTTPStatus.OK, + id="key_status_fails", + ), + pytest.param( + HTTPStatus.OK, + [(HTTPStatus.CONFLICT, None)], + (HTTPStatus.OK, {"port": 49154}), + HTTPStatus.OK, + id="key_status_missing_state", + ), + pytest.param( + HTTPStatus.OK, + [(HTTPStatus.CONFLICT, None)], + KEY_STARTED, + HTTPStatus.INTERNAL_SERVER_ERROR, + id="delete_fails", + ), + ], +) +@pytest.mark.usefixtures("otbr_config_entry_multipan") +async def test_create_ephemeral_key_fails( + aioclient_mock: AiohttpClientMocker, + websocket_client: MockHAClientWebSocket, + state_status: HTTPStatus, + key_responses: list[tuple[HTTPStatus, Any]], + key_status: tuple[HTTPStatus, Any], + delete_status: HTTPStatus, +) -> None: + """Test create ephemeral key when the border router returns an error.""" + aioclient_mock.put(f"{BASE_URL}/node/ba-epskc/state", status=state_status) + aioclient_mock.get( + f"{BASE_URL}/node/ba-epskc/key", status=key_status[0], json=key_status[1] + ) + aioclient_mock.delete(f"{BASE_URL}/node/ba-epskc/key", status=delete_status) + responses = [ + AiohttpClientMockResponse( + "POST", URL(f"{BASE_URL}/node/ba-epskc/key"), status=status, json=json + ) + for status, json in key_responses + ] + + async def activate(method: str, url: URL, data: Any) -> AiohttpClientMockResponse: + return responses.pop(0) + + aioclient_mock.post(f"{BASE_URL}/node/ba-epskc/key", side_effect=activate) + + with patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/create_ephemeral_key", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "create_ephemeral_key_failed" + # Every scripted activation response was consumed and no extra one requested + assert not responses + + +@pytest.mark.parametrize("error", [aiohttp.ClientError, TimeoutError]) +@pytest.mark.usefixtures("otbr_config_entry_multipan") +async def test_create_ephemeral_key_connection_error( + aioclient_mock: AiohttpClientMocker, + websocket_client: MockHAClientWebSocket, + error: type[Exception], +) -> None: + """Test create ephemeral key when the border router cannot be reached.""" + aioclient_mock.put(f"{BASE_URL}/node/ba-epskc/state", exc=error) + + with patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/create_ephemeral_key", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "create_ephemeral_key_failed" + + +@pytest.mark.parametrize("command", EPHEMERAL_KEY_COMMANDS) +@pytest.mark.usefixtures("otbr_config_entry_multipan") +async def test_ephemeral_key_not_admin( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_admin_user: MockUser, + command: str, +) -> None: + """Test ephemeral key commands require an admin user.""" + hass_admin_user.groups = [] + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json_auto_id( + { + "type": command, + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + + msg = await websocket_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "unauthorized" + + +@pytest.mark.parametrize("command", EPHEMERAL_KEY_COMMANDS) +async def test_ephemeral_key_no_entry( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + command: str, +) -> None: + """Test ephemeral key commands without a loaded config entry.""" + await async_setup_component(hass, DOMAIN, {}) + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json_auto_id( + { + "type": command, + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + + msg = await websocket_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "not_loaded" + + +@pytest.mark.parametrize("command", EPHEMERAL_KEY_COMMANDS) +@pytest.mark.usefixtures("otbr_config_entry_multipan") +async def test_ephemeral_key_unknown_router( + websocket_client: MockHAClientWebSocket, + command: str, +) -> None: + """Test ephemeral key commands for an unknown router.""" + with patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ): + await websocket_client.send_json_auto_id( + { + "type": command, + "extended_address": "blah", + } + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "unknown_router" + + +@pytest.mark.usefixtures("otbr_config_entry_multipan") +async def test_delete_ephemeral_key( + aioclient_mock: AiohttpClientMocker, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test delete ephemeral key deactivates the key on the border router.""" + aioclient_mock.delete(f"{BASE_URL}/node/ba-epskc/key") + + with patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/delete_ephemeral_key", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + msg = await websocket_client.receive_json() + + assert msg["success"] + assert msg["result"] is None + assert aioclient_mock.mock_calls[-1][0] == "DELETE" + + +@pytest.mark.parametrize( + ("ephemeral_key", "elapsed", "deleted"), + [ + pytest.param("700855744", timedelta(minutes=1), True, id="active_key"), + pytest.param("123456789", timedelta(minutes=1), False, id="replaced_key"), + pytest.param("700855744", timedelta(minutes=6), False, id="expired_key"), + ], +) +@pytest.mark.usefixtures("otbr_config_entry_multipan") +async def test_delete_ephemeral_key_by_key( + aioclient_mock: AiohttpClientMocker, + websocket_client: MockHAClientWebSocket, + freezer: FrozenDateTimeFactory, + ephemeral_key: str, + elapsed: timedelta, + deleted: bool, +) -> None: + """Test deleting a specific key only deactivates it if it is still active.""" + aioclient_mock.put(f"{BASE_URL}/node/ba-epskc/state") + aioclient_mock.post( + f"{BASE_URL}/node/ba-epskc/key", json={"tap": "700855744", "port": 49154} + ) + aioclient_mock.delete(f"{BASE_URL}/node/ba-epskc/key") + + with patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/create_ephemeral_key", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + assert (await websocket_client.receive_json())["success"] + freezer.tick(elapsed) + await websocket_client.send_json_auto_id( + { + "type": "otbr/delete_ephemeral_key", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + "ephemeral_key": ephemeral_key, + } + ) + msg = await websocket_client.receive_json() + + assert msg["success"] + assert any(call[0] == "DELETE" for call in aioclient_mock.mock_calls) is deleted + + +@pytest.mark.usefixtures("otbr_config_entry_multipan") +async def test_delete_ephemeral_key_retry_after_failure( + aioclient_mock: AiohttpClientMocker, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test a key is still known after a failed delete, so a retry deletes it.""" + aioclient_mock.put(f"{BASE_URL}/node/ba-epskc/state") + aioclient_mock.post( + f"{BASE_URL}/node/ba-epskc/key", json={"tap": "700855744", "port": 49154} + ) + responses = [ + AiohttpClientMockResponse( + "DELETE", + URL(f"{BASE_URL}/node/ba-epskc/key"), + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ), + AiohttpClientMockResponse("DELETE", URL(f"{BASE_URL}/node/ba-epskc/key")), + ] + + async def delete(method: str, url: URL, data: Any) -> AiohttpClientMockResponse: + return responses.pop(0) + + aioclient_mock.delete(f"{BASE_URL}/node/ba-epskc/key", side_effect=delete) + delete_msg = { + "type": "otbr/delete_ephemeral_key", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + "ephemeral_key": "700855744", + } + + with patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/create_ephemeral_key", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + assert (await websocket_client.receive_json())["success"] + await websocket_client.send_json_auto_id(delete_msg) + failed = await websocket_client.receive_json() + await websocket_client.send_json_auto_id(delete_msg) + retried = await websocket_client.receive_json() + + assert not failed["success"] + assert failed["error"]["code"] == "delete_ephemeral_key_failed" + assert retried["success"] + # Both attempts reached the router + assert not responses + + +@pytest.mark.parametrize("error", [aiohttp.ClientError, TimeoutError]) +@pytest.mark.usefixtures("otbr_config_entry_multipan") +async def test_delete_ephemeral_key_connection_error( + aioclient_mock: AiohttpClientMocker, + websocket_client: MockHAClientWebSocket, + error: type[Exception], +) -> None: + """Test delete ephemeral key when the border router cannot be reached.""" + aioclient_mock.delete(f"{BASE_URL}/node/ba-epskc/key", exc=error) + + with patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/delete_ephemeral_key", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "delete_ephemeral_key_failed" + + +@pytest.mark.parametrize( + ("delete_status", "error_code"), + [ + pytest.param( + HTTPStatus.NOT_FOUND, "ephemeral_key_not_supported", id="not_found" + ), + # Routers which reject the method before matching the path answer 405 + pytest.param( + HTTPStatus.METHOD_NOT_ALLOWED, + "ephemeral_key_not_supported", + id="not_allowed", + ), + pytest.param( + HTTPStatus.INTERNAL_SERVER_ERROR, + "delete_ephemeral_key_failed", + id="error", + ), + ], +) +@pytest.mark.usefixtures("otbr_config_entry_multipan") +async def test_delete_ephemeral_key_fails( + aioclient_mock: AiohttpClientMocker, + websocket_client: MockHAClientWebSocket, + delete_status: HTTPStatus, + error_code: str, +) -> None: + """Test delete ephemeral key when the border router returns an error.""" + aioclient_mock.delete(f"{BASE_URL}/node/ba-epskc/key", status=delete_status) + + with patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/delete_ephemeral_key", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == error_code