From fe90e79ec58f9bafced216e1b95f6c2f4156d01d Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Mon, 27 Jul 2026 10:43:46 +0000 Subject: [PATCH 01/11] Add Thread credential sharing to OTBR Add an otbr/create_ephemeral_key websocket command which activates ephemeral key (ePSKc) mode on the border router and returns the Thread Administration Passcode it generated, so another border router can be given temporary access to the Thread network. The REST endpoints are not in python-otbr-api yet, so they are called directly for now. --- homeassistant/components/otbr/const.py | 4 + homeassistant/components/otbr/util.py | 64 ++++++ .../components/otbr/websocket_api.py | 48 ++++- tests/components/otbr/test_websocket_api.py | 195 +++++++++++++++++- 4 files changed, 309 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/otbr/const.py b/homeassistant/components/otbr/const.py index cc3e4a9e6c3a48..6086c789d265dd 100644 --- a/homeassistant/components/otbr/const.py +++ b/homeassistant/components/otbr/const.py @@ -3,3 +3,7 @@ DOMAIN = "otbr" DEFAULT_CHANNEL = 15 + +# Milliseconds, matching the units of the OpenThread border agent API. +# OT_BORDER_AGENT_DEFAULT_EPHEMERAL_KEY_TIMEOUT; the API caps it at 10 minutes. +EPHEMERAL_KEY_LIFETIME = 2 * 60 * 1000 diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index bdd66a9d3625c2..7202c044c30a73 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -3,6 +3,7 @@ from collections.abc import Callable, Coroutine import dataclasses from functools import wraps +from http import HTTPStatus import logging import random from typing import TYPE_CHECKING, Any, Concatenate, cast @@ -22,6 +23,7 @@ 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 .const import DOMAIN @@ -48,6 +50,18 @@ class GetBorderAgentIdNotSupported(HomeAssistantError): """Raised from python_otbr_api.GetBorderAgentIdNotSupportedError.""" +class EphemeralKeyNotSupported(HomeAssistantError): + """Raised when the router does not expose ephemeral key mode.""" + + +# A router without the ephemeral key routes answers with 404, or with 405 when +# it rejects the method before matching the path. +EPHEMERAL_KEY_UNSUPPORTED_STATUS = ( + HTTPStatus.NOT_FOUND, + HTTPStatus.METHOD_NOT_ALLOWED, +) + + def compose_default_network_name(pan_id: int) -> str: """Generate a default network name.""" return f"ha-thread-{pan_id:04x}" @@ -160,6 +174,56 @@ async def get_coprocessor_version(self) -> str: """Get coprocessor firmware version.""" return await self.api.get_coprocessor_version() + @_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. These endpoints are not in python-otbr-api yet, they are + called directly to follow home-assistant-libs/python-otbr-api#267; + move this to the library once that is released. + """ + 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 drop it and ask for a replacement. + await session.delete(f"{self.url}/node/ba-epskc/key", timeout=timeout) + 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() + return activation["tap"], activation["port"] + except (ValueError, KeyError) as exc: + raise python_otbr_api.OTBRError("unexpected API response") from exc + 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 2bcd0da8f16c50..4cee6428a588b3 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -17,8 +17,9 @@ 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 from .util import ( + EphemeralKeyNotSupported, OTBRData, compose_default_network_name, generate_random_pan_id, @@ -35,6 +36,7 @@ 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_set_channel) websocket_api.async_register_command(hass, websocket_set_network) @@ -199,6 +201,50 @@ 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.""" + # Seconds on the wire, as otbr/set_channel does with its delay + lifetime: float = EPHEMERAL_KEY_LIFETIME / 1000 + + try: + ephemeral_key, port = await data.activate_ephemeral_key( + hass, EPHEMERAL_KEY_LIFETIME + ) + 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"], "create_ephemeral_key_failed", str(exc)) + return + + connection.send_result( + msg["id"], + { + "ephemeral_key": ephemeral_key, + "lifetime": lifetime, + "port": port, + }, + ) + + @websocket_api.websocket_command( { "type": "otbr/set_network", diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 0b7d2bc8e2a92d..b805b1ba07b48c 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -1,9 +1,12 @@ """Test OTBR Websocket API.""" +from http import HTTPStatus +from typing import Any from unittest.mock import AsyncMock, patch import pytest import python_otbr_api +from yarl import URL from homeassistant.components import otbr, thread from homeassistant.components.otbr import DOMAIN @@ -18,7 +21,7 @@ TEST_BORDER_AGENT_ID, ) -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse from tests.typing import MockHAClientWebSocket, WebSocketGenerator @@ -853,3 +856,193 @@ async def test_set_channel_fails_3( assert not msg["success"] assert msg["error"]["code"] == "unknown_router" + + +async def test_create_ephemeral_key( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry_multipan: str, + 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": 120.0, + "port": 49154, + } + # The border agent API takes the lifetime in milliseconds + assert aioclient_mock.mock_calls[-1][2] == {"lifetime": 120000} + + +@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" + ), + ], +) +async def test_create_ephemeral_key_not_supported( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry_multipan: str, + 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" + + +async def test_create_ephemeral_key_replaces_active_key( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry_multipan: str, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test an already active key is dropped so a new one can be created.""" + aioclient_mock.put(f"{BASE_URL}/node/ba-epskc/state") + 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 any(call[0] == "DELETE" for call in aioclient_mock.mock_calls) + + +async def test_create_ephemeral_key_fails( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry_multipan: str, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test create ephemeral key when the border router returns an error.""" + aioclient_mock.put( + f"{BASE_URL}/node/ba-epskc/state", status=HTTPStatus.INTERNAL_SERVER_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" + + +async def test_create_ephemeral_key_no_entry( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test create ephemeral key.""" + await async_setup_component(hass, DOMAIN, {}) + websocket_client = await hass_ws_client(hass) + 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"] == "not_loaded" + + +async def test_create_ephemeral_key_unknown_router( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry_multipan, + websocket_client, +) -> None: + """Test create ephemeral 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": "blah", + } + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "unknown_router" From 72efa9d86fc491f433400d3379ef00b0dd444bec Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Mon, 27 Jul 2026 11:00:21 +0000 Subject: [PATCH 02/11] Annotate test fixture parameters --- tests/components/otbr/test_websocket_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index b805b1ba07b48c..327a8d5ad2990d 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -1028,8 +1028,8 @@ async def test_create_ephemeral_key_no_entry( async def test_create_ephemeral_key_unknown_router( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry_multipan, - websocket_client, + otbr_config_entry_multipan: str, + websocket_client: MockHAClientWebSocket, ) -> None: """Test create ephemeral key.""" with patch( From 0690cfe81a1780cb7b9bb3ea90829437efe76719 Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Fri, 21 Aug 2026 03:15:38 +0000 Subject: [PATCH 03/11] Add ephemeral key support probe and delete command, address review --- homeassistant/components/otbr/__init__.py | 3 + homeassistant/components/otbr/util.py | 48 ++++- .../components/otbr/websocket_api.py | 34 ++++ tests/components/otbr/conftest.py | 15 ++ tests/components/otbr/test_websocket_api.py | 168 +++++++++++++++--- 5 files changed, 243 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 38c0bcc4aaee25..655b3c611539c4 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -49,6 +49,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: OTBRConfigEntry) -> bool border_agent_id = await otbrdata.get_border_agent_id() dataset_tlvs = await otbrdata.get_active_dataset_tlvs() extended_address = await otbrdata.get_extended_address() + otbrdata.ephemeral_key_supported = await otbrdata.get_ephemeral_key_supported( + hass + ) except GetBorderAgentIdNotSupported: ir.async_create_issue( hass, diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 7202c044c30a73..3bb73db26d55cd 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -54,8 +54,8 @@ class EphemeralKeyNotSupported(HomeAssistantError): """Raised when the router does not expose ephemeral key mode.""" -# A router without the ephemeral key routes answers with 404, or with 405 when -# it rejects the method before matching the path. +# A router without the ephemeral key routes answers with 404, but ot-br-posix +# builds between #2733 and #3524 turn every PUT error into 405 (ot-br-posix#3522). EPHEMERAL_KEY_UNSUPPORTED_STATUS = ( HTTPStatus.NOT_FOUND, HTTPStatus.METHOD_NOT_ALLOWED, @@ -95,6 +95,7 @@ class OTBRData: url: str api: python_otbr_api.OTBR entry_id: str + ephemeral_key_supported: bool = False @_handle_otbr_error async def factory_reset(self, hass: HomeAssistant) -> None: @@ -174,6 +175,21 @@ 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. + + Like activate_ephemeral_key, this calls the REST endpoint directly; + move this to the library once python-otbr-api#267 is released. + """ + session = async_get_clientsession(hass) + response = await session.get( + f"{self.url}/node/ba-epskc/state", + timeout=aiohttp.ClientTimeout(total=10), + ) + # Only 200 proves support; never fail setup over an optional feature + return response.status == HTTPStatus.OK + @_handle_otbr_error async def activate_ephemeral_key( self, hass: HomeAssistant, lifetime: int @@ -210,7 +226,14 @@ async def activate() -> aiohttp.ClientResponse: if response.status == HTTPStatus.CONFLICT: # A key is already active, and one can only be started from the # stopped state, so drop it and ask for a replacement. - await session.delete(f"{self.url}/node/ba-epskc/key", timeout=timeout) + 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: @@ -221,9 +244,26 @@ async def activate() -> aiohttp.ClientResponse: try: activation = await response.json() return activation["tap"], activation["port"] - except (ValueError, KeyError) as exc: + except (ValueError, KeyError, TypeError) as exc: raise python_otbr_api.OTBRError("unexpected API response") from exc + @_handle_otbr_error + async def deactivate_ephemeral_key(self, hass: HomeAssistant) -> None: + """Deactivate the active ephemeral key, if any. + + Like activate_ephemeral_key, this calls the REST endpoint directly; + move this to the library once python-otbr-api#267 is released. + """ + 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}") + 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 4cee6428a588b3..48730ec87c8dfb 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -37,6 +37,7 @@ def async_setup(hass: HomeAssistant) -> None: 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) @@ -87,6 +88,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": data.ephemeral_key_supported, "url": data.url, } @@ -245,6 +247,38 @@ async def websocket_create_ephemeral_key( ) +@websocket_api.websocket_command( + { + "type": "otbr/delete_ephemeral_key", + vol.Required("extended_address"): 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: + await data.deactivate_ephemeral_key(hass) + 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 + + 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 09df4d846af21d..4d7afd3074196c 100644 --- a/tests/components/otbr/conftest.py +++ b/tests/components/otbr/conftest.py @@ -69,6 +69,21 @@ def mock_api_actions( aioclient_mock.get(re.compile(r".*/api/actions$"), status=status) +@pytest.fixture(name="ephemeral_key_supported") +def ephemeral_key_supported_fixture() -> bool: + """Override to control the ephemeral key support probe outcome.""" + return True + + +@pytest.fixture(autouse=True) +def mock_ephemeral_key_state( + aioclient_mock: AiohttpClientMocker, ephemeral_key_supported: bool +) -> None: + """Mock the /node/ba-epskc/state probe used to detect ephemeral key support.""" + status = HTTPStatus.OK if ephemeral_key_supported else HTTPStatus.NOT_FOUND + aioclient_mock.get(re.compile(r".*/node/ba-epskc/state$"), status=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_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 327a8d5ad2990d..853f0a3bcd3772 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -38,11 +38,13 @@ def mock_supervisor_client(supervisor_client: AsyncMock) -> None: """Mock supervisor client.""" +@pytest.mark.parametrize("ephemeral_key_supported", [True, False]) 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" @@ -79,6 +81,7 @@ 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, } } @@ -858,10 +861,15 @@ async def test_set_channel_fails_3( 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( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry_multipan: str, websocket_client: MockHAClientWebSocket, ) -> None: """Test create ephemeral key activates the key on the border router.""" @@ -906,10 +914,9 @@ async def test_create_ephemeral_key( ), ], ) +@pytest.mark.usefixtures("otbr_config_entry_multipan") async def test_create_ephemeral_key_not_supported( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry_multipan: str, websocket_client: MockHAClientWebSocket, state_status: HTTPStatus, key_status: HTTPStatus, @@ -934,10 +941,9 @@ async def test_create_ephemeral_key_not_supported( assert msg["error"]["code"] == "ephemeral_key_not_supported" +@pytest.mark.usefixtures("otbr_config_entry_multipan") async def test_create_ephemeral_key_replaces_active_key( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry_multipan: str, websocket_client: MockHAClientWebSocket, ) -> None: """Test an already active key is dropped so a new one can be created.""" @@ -978,16 +984,63 @@ async def activate(method: str, url: URL, data: Any) -> AiohttpClientMockRespons assert any(call[0] == "DELETE" for call in aioclient_mock.mock_calls) +@pytest.mark.parametrize( + ("state_status", "key_responses", "delete_status"), + [ + pytest.param( + HTTPStatus.INTERNAL_SERVER_ERROR, [], HTTPStatus.OK, id="enable_fails" + ), + pytest.param( + HTTPStatus.OK, + [(HTTPStatus.INTERNAL_SERVER_ERROR, None)], + HTTPStatus.OK, + id="activate_fails", + ), + pytest.param( + HTTPStatus.OK, + [(HTTPStatus.OK, {"tap": "700855744"})], + HTTPStatus.OK, + id="missing_port", + ), + pytest.param( + HTTPStatus.OK, [(HTTPStatus.OK, [])], HTTPStatus.OK, id="not_a_dict" + ), + pytest.param( + HTTPStatus.OK, + [(HTTPStatus.CONFLICT, None), (HTTPStatus.CONFLICT, None)], + HTTPStatus.OK, + id="conflict_after_replacement", + ), + pytest.param( + HTTPStatus.OK, + [(HTTPStatus.CONFLICT, None)], + HTTPStatus.INTERNAL_SERVER_ERROR, + id="delete_fails", + ), + ], +) +@pytest.mark.usefixtures("otbr_config_entry_multipan") async def test_create_ephemeral_key_fails( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry_multipan: str, websocket_client: MockHAClientWebSocket, + state_status: HTTPStatus, + key_responses: list[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=HTTPStatus.INTERNAL_SERVER_ERROR - ) + aioclient_mock.put(f"{BASE_URL}/node/ba-epskc/state", status=state_status) + 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", @@ -1003,19 +1056,22 @@ async def test_create_ephemeral_key_fails( 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 -async def test_create_ephemeral_key_no_entry( +@pytest.mark.parametrize("command", EPHEMERAL_KEY_COMMANDS) +async def test_ephemeral_key_no_entry( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + command: str, ) -> None: - """Test create ephemeral key.""" + """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": "otbr/create_ephemeral_key", + "type": command, "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), } ) @@ -1025,20 +1081,20 @@ async def test_create_ephemeral_key_no_entry( assert msg["error"]["code"] == "not_loaded" -async def test_create_ephemeral_key_unknown_router( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - otbr_config_entry_multipan: str, +@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 create ephemeral key.""" + """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": "otbr/create_ephemeral_key", + "type": command, "extended_address": "blah", } ) @@ -1046,3 +1102,73 @@ async def test_create_ephemeral_key_unknown_router( 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( + ("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 From d88cb2b074341ea277f4faac7ba458471c0c5ddf Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Sat, 22 Aug 2026 12:35:47 +0000 Subject: [PATCH 04/11] Make ephemeral key deletion key-aware, refuse to replace a key in use, extend lifetime --- homeassistant/components/otbr/const.py | 5 +- homeassistant/components/otbr/util.py | 57 +++-- .../components/otbr/websocket_api.py | 26 ++- tests/components/otbr/conftest.py | 13 +- tests/components/otbr/test_init.py | 57 ++++- tests/components/otbr/test_websocket_api.py | 220 +++++++++++++++++- 6 files changed, 336 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/otbr/const.py b/homeassistant/components/otbr/const.py index 6086c789d265dd..f0b9c6a7b69681 100644 --- a/homeassistant/components/otbr/const.py +++ b/homeassistant/components/otbr/const.py @@ -4,6 +4,5 @@ DEFAULT_CHANNEL = 15 -# Milliseconds, matching the units of the OpenThread border agent API. -# OT_BORDER_AGENT_DEFAULT_EPHEMERAL_KEY_TIMEOUT; the API caps it at 10 minutes. -EPHEMERAL_KEY_LIFETIME = 2 * 60 * 1000 +# 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 3bb73db26d55cd..c2853dc83f4df2 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -54,13 +54,20 @@ 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 turn every PUT error into 405 (ot-br-posix#3522). +# 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.""" @@ -96,6 +103,7 @@ class OTBRData: api: python_otbr_api.OTBR entry_id: str ephemeral_key_supported: bool = False + active_ephemeral_key: str | None = None @_handle_otbr_error async def factory_reset(self, hass: HomeAssistant) -> None: @@ -175,19 +183,18 @@ async def get_coprocessor_version(self) -> str: """Get coprocessor firmware version.""" return await self.api.get_coprocessor_version() + # The ephemeral key endpoints are called directly until a python-otbr-api + # release includes them (home-assistant-libs/python-otbr-api#267) @_handle_otbr_error async def get_ephemeral_key_supported(self, hass: HomeAssistant) -> bool: - """Return whether the router supports ephemeral key mode. - - Like activate_ephemeral_key, this calls the REST endpoint directly; - move this to the library once python-otbr-api#267 is released. - """ + """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), ) - # Only 200 proves support; never fail setup over an optional feature + # Only 200 proves support; any other status hides the optional feature, + # while connection errors fail setup like the other startup calls return response.status == HTTPStatus.OK @_handle_otbr_error @@ -197,9 +204,7 @@ async def activate_ephemeral_key( """Activate ephemeral key mode, returning the passcode and its UDP port. The lifetime is in milliseconds, as the OpenThread border agent API - takes it. These endpoints are not in python-otbr-api yet, they are - called directly to follow home-assistant-libs/python-otbr-api#267; - move this to the library once that is released. + takes it. """ session = async_get_clientsession(hass) timeout = aiohttp.ClientTimeout(total=10) @@ -225,7 +230,20 @@ async def activate() -> aiohttp.ClientResponse: response = await activate() if response.status == HTTPStatus.CONFLICT: # A key is already active, and one can only be started from the - # stopped state, so drop it and ask for a replacement. + # 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 ) @@ -243,17 +261,24 @@ async def activate() -> aiohttp.ClientResponse: try: activation = await response.json() - return activation["tap"], activation["port"] + 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 + return ephemeral_key, port @_handle_otbr_error - async def deactivate_ephemeral_key(self, hass: HomeAssistant) -> None: - """Deactivate the active ephemeral key, if any. + async def deactivate_ephemeral_key( + self, hass: HomeAssistant, ephemeral_key: str | None = None + ) -> None: + """Deactivate the active ephemeral key. - Like activate_ephemeral_key, this calls the REST endpoint directly; - move this to the library once python-otbr-api#267 is released. + With a key given, only that key is deactivated, so a stale request + cannot revoke a key handed out after it. """ + if ephemeral_key is not None and ephemeral_key != self.active_ephemeral_key: + return + self.active_ephemeral_key = None session = async_get_clientsession(hass) response = await session.delete( f"{self.url}/node/ba-epskc/key", diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 48730ec87c8dfb..d84e8b94332f01 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,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from .const import DEFAULT_CHANNEL, DOMAIN, EPHEMERAL_KEY_LIFETIME +from .const import DEFAULT_CHANNEL, DOMAIN, EPHEMERAL_KEY_LIFETIME_MS from .util import ( + EphemeralKeyInUse, EphemeralKeyNotSupported, OTBRData, compose_default_network_name, @@ -30,6 +32,8 @@ if TYPE_CHECKING: from . import OTBRConfigEntry +_LOGGER = logging.getLogger(__name__) + @callback def async_setup(hass: HomeAssistant) -> None: @@ -219,12 +223,9 @@ async def websocket_create_ephemeral_key( data: OTBRData, ) -> None: """Create an ephemeral key for sharing the Thread network credentials.""" - # Seconds on the wire, as otbr/set_channel does with its delay - lifetime: float = EPHEMERAL_KEY_LIFETIME / 1000 - try: ephemeral_key, port = await data.activate_ephemeral_key( - hass, EPHEMERAL_KEY_LIFETIME + hass, EPHEMERAL_KEY_LIFETIME_MS ) except EphemeralKeyNotSupported: connection.send_error( @@ -233,15 +234,24 @@ async def websocket_create_ephemeral_key( "The border router does not support credential sharing", ) return + except EphemeralKeyInUse: + connection.send_error( + msg["id"], + "ephemeral_key_in_use", + "A device is currently joining through the active ephemeral key", + ) + 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": lifetime, + "lifetime": EPHEMERAL_KEY_LIFETIME_MS // 1000, "port": port, }, ) @@ -251,6 +261,7 @@ async def websocket_create_ephemeral_key( { "type": "otbr/delete_ephemeral_key", vol.Required("extended_address"): str, + vol.Optional("ephemeral_key"): str, } ) @websocket_api.require_admin @@ -264,7 +275,7 @@ async def websocket_delete_ephemeral_key( ) -> None: """Deactivate the active ephemeral key, revoking the shared credentials.""" try: - await data.deactivate_ephemeral_key(hass) + await data.deactivate_ephemeral_key(hass, msg.get("ephemeral_key")) except EphemeralKeyNotSupported: connection.send_error( msg["id"], @@ -276,6 +287,7 @@ async def websocket_delete_ephemeral_key( connection.send_error(msg["id"], "delete_ephemeral_key_failed", str(exc)) return + _LOGGER.info("Ephemeral key for %s deleted by %s", data.url, connection.user.name) connection.send_result(msg["id"]) diff --git a/tests/components/otbr/conftest.py b/tests/components/otbr/conftest.py index 4d7afd3074196c..41ae0b5a753cb6 100644 --- a/tests/components/otbr/conftest.py +++ b/tests/components/otbr/conftest.py @@ -69,19 +69,20 @@ def mock_api_actions( aioclient_mock.get(re.compile(r".*/api/actions$"), status=status) -@pytest.fixture(name="ephemeral_key_supported") -def ephemeral_key_supported_fixture() -> bool: +@pytest.fixture(name="ephemeral_key_probe_status") +def ephemeral_key_probe_status_fixture() -> HTTPStatus: """Override to control the ephemeral key support probe outcome.""" - return True + return HTTPStatus.OK @pytest.fixture(autouse=True) def mock_ephemeral_key_state( - aioclient_mock: AiohttpClientMocker, ephemeral_key_supported: bool + aioclient_mock: AiohttpClientMocker, ephemeral_key_probe_status: HTTPStatus ) -> None: """Mock the /node/ba-epskc/state probe used to detect ephemeral key support.""" - status = HTTPStatus.OK if ephemeral_key_supported else HTTPStatus.NOT_FOUND - aioclient_mock.get(re.compile(r".*/node/ba-epskc/state$"), status=status) + aioclient_mock.get( + re.compile(r".*/node/ba-epskc/state$"), status=ephemeral_key_probe_status + ) @pytest.fixture(name="get_active_dataset_tlvs") diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index b14527165e6e89..fbe4626bae113f 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -1,6 +1,8 @@ """Test the Open Thread Border Router integration.""" import asyncio +from http import HTTPStatus +import re from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock, patch @@ -11,7 +13,7 @@ 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 @@ -267,6 +269,59 @@ 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" +) +async def test_config_entry_not_ready_ephemeral_key_probe( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, error: type[Exception] +) -> None: + """Test a connection error while probing ephemeral key support retries setup.""" + 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 not await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@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.INTERNAL_SERVER_ERROR, False, 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: + """Test only a 200 from the probe marks ephemeral key mode as supported.""" + 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 853f0a3bcd3772..349f53671e0780 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -4,6 +4,7 @@ from typing import Any from unittest.mock import AsyncMock, patch +import aiohttp import pytest import python_otbr_api from yarl import URL @@ -21,6 +22,7 @@ TEST_BORDER_AGENT_ID, ) +from tests.common import MockUser from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse from tests.typing import MockHAClientWebSocket, WebSocketGenerator @@ -38,7 +40,13 @@ def mock_supervisor_client(supervisor_client: AsyncMock) -> None: """Mock supervisor client.""" -@pytest.mark.parametrize("ephemeral_key_supported", [True, False]) +@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, @@ -893,11 +901,12 @@ async def test_create_ephemeral_key( assert msg["success"] assert msg["result"] == { "ephemeral_key": "700855744", - "lifetime": 120.0, + "lifetime": 300, "port": 49154, } - # The border agent API takes the lifetime in milliseconds - assert aioclient_mock.mock_calls[-1][2] == {"lifetime": 120000} + # 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( @@ -946,8 +955,11 @@ async def test_create_ephemeral_key_replaces_active_key( aioclient_mock: AiohttpClientMocker, websocket_client: MockHAClientWebSocket, ) -> None: - """Test an already active key is dropped so a new one can be created.""" + """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 @@ -981,39 +993,105 @@ async def activate(method: str, url: URL, data: Any) -> AiohttpClientMockRespons assert msg["success"] assert msg["result"]["ephemeral_key"] == "700855744" - assert any(call[0] == "DELETE" for call in aioclient_mock.mock_calls) + assert [call[0] for call in aioclient_mock.mock_calls[-5:]] == [ + "PUT", + "POST", + "GET", + "DELETE", + "POST", + ] + + +@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", "delete_status"), + ("state_status", "key_responses", "key_status", "delete_status"), [ pytest.param( - HTTPStatus.INTERNAL_SERVER_ERROR, [], HTTPStatus.OK, id="enable_fails" + 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, [])], HTTPStatus.OK, id="not_a_dict" + 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", ), @@ -1025,10 +1103,14 @@ async def test_create_ephemeral_key_fails( 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( @@ -1060,6 +1142,55 @@ async def activate(method: str, url: URL, data: Any) -> AiohttpClientMockRespons 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, @@ -1129,6 +1260,77 @@ async def test_delete_ephemeral_key( assert aioclient_mock.mock_calls[-1][0] == "DELETE" +@pytest.mark.parametrize( + ("ephemeral_key", "deleted"), + [ + pytest.param("700855744", True, id="active_key"), + pytest.param("123456789", False, id="replaced_key"), + ], +) +@pytest.mark.usefixtures("otbr_config_entry_multipan") +async def test_delete_ephemeral_key_by_key( + aioclient_mock: AiohttpClientMocker, + websocket_client: MockHAClientWebSocket, + ephemeral_key: str, + 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"] + 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.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"), [ From baec2791ea8fc9de3e6bade70fc37b94526b6e10 Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Sat, 22 Aug 2026 12:44:45 +0000 Subject: [PATCH 05/11] Address Copilot review --- homeassistant/components/otbr/util.py | 60 +++++++--- .../components/otbr/websocket_api.py | 9 +- tests/components/otbr/test_websocket_api.py | 104 ++++++++++++++++++ 3 files changed, 156 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index c2853dc83f4df2..0c9e159b06f90f 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -1,7 +1,9 @@ """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 @@ -24,6 +26,7 @@ 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 @@ -104,6 +107,10 @@ class OTBRData: entry_id: str ephemeral_key_supported: bool = False active_ephemeral_key: str | None = None + active_ephemeral_key_expires: datetime | None = None + ephemeral_key_lock: asyncio.Lock = dataclasses.field( + default_factory=asyncio.Lock, repr=False + ) @_handle_otbr_error async def factory_reset(self, hass: HomeAssistant) -> None: @@ -206,6 +213,22 @@ async def activate_ephemeral_key( 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.""" + # 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) @@ -265,29 +288,38 @@ async def activate() -> aiohttp.ClientResponse: 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 - ) -> None: - """Deactivate the active ephemeral key. + ) -> 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. """ - if ephemeral_key is not None and ephemeral_key != self.active_ephemeral_key: - return - self.active_ephemeral_key = None - 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}") + async with self.ephemeral_key_lock: + 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: diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index d84e8b94332f01..6c6c62aaf024c5 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -238,7 +238,7 @@ async def websocket_create_ephemeral_key( connection.send_error( msg["id"], "ephemeral_key_in_use", - "A device is currently joining through the active ephemeral key", + "The active ephemeral key is still in use", ) return except HomeAssistantError as exc: @@ -275,7 +275,7 @@ async def websocket_delete_ephemeral_key( ) -> None: """Deactivate the active ephemeral key, revoking the shared credentials.""" try: - await data.deactivate_ephemeral_key(hass, msg.get("ephemeral_key")) + deleted = await data.deactivate_ephemeral_key(hass, msg.get("ephemeral_key")) except EphemeralKeyNotSupported: connection.send_error( msg["id"], @@ -287,7 +287,10 @@ async def websocket_delete_ephemeral_key( connection.send_error(msg["id"], "delete_ephemeral_key_failed", str(exc)) return - _LOGGER.info("Ephemeral key for %s deleted by %s", data.url, connection.user.name) + if deleted: + _LOGGER.info( + "Ephemeral key for %s deleted by %s", data.url, connection.user.name + ) connection.send_result(msg["id"]) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 349f53671e0780..fa64b4087ab34f 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -1,10 +1,12 @@ """Test OTBR Websocket API.""" +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 @@ -1002,6 +1004,56 @@ async def activate(method: str, url: URL, data: Any) -> AiohttpClientMockRespons ] +@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.parametrize("state", ["connected", "accepted"]) @pytest.mark.usefixtures("otbr_config_entry_multipan") async def test_create_ephemeral_key_in_use( @@ -1305,6 +1357,58 @@ async def test_delete_ephemeral_key_by_key( 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( From 1ff092aa2d12649b7692ea9c1f9f5b5e9d04d793 Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Sat, 22 Aug 2026 12:52:26 +0000 Subject: [PATCH 06/11] Test concurrent ephemeral key creation --- tests/components/otbr/test_websocket_api.py | 53 +++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index fa64b4087ab34f..26aed259a584ac 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -1,5 +1,6 @@ """Test OTBR Websocket API.""" +import asyncio from datetime import timedelta from http import HTTPStatus from typing import Any @@ -1054,6 +1055,58 @@ async def test_create_ephemeral_key_twice( 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( From ffe0f7505e7c03667ee17b787de68fbe50a7575b Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Sat, 22 Aug 2026 13:06:17 +0000 Subject: [PATCH 07/11] Address Copilot review --- homeassistant/components/otbr/__init__.py | 20 +++++-- homeassistant/components/otbr/util.py | 3 +- .../components/otbr/websocket_api.py | 10 +++- tests/components/otbr/test_init.py | 54 +++++++++++++++++-- tests/components/otbr/test_websocket_api.py | 51 ++++++++++++++++++ 5 files changed, 128 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 655b3c611539c4..1d78d81661cada 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -49,9 +49,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OTBRConfigEntry) -> bool border_agent_id = await otbrdata.get_border_agent_id() dataset_tlvs = await otbrdata.get_active_dataset_tlvs() extended_address = await otbrdata.get_extended_address() - otbrdata.ephemeral_key_supported = await otbrdata.get_ephemeral_key_supported( - hass - ) except GetBorderAgentIdNotSupported: ir.async_create_issue( hass, @@ -69,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) @@ -91,6 +95,16 @@ 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 + # The key outlives this entry's memory of it, so revoke it rather than + # leaving the credential active until it expires + if otbrdata.active_ephemeral_key is not None: + try: + await otbrdata.deactivate_ephemeral_key(hass) + except HomeAssistantError: + _LOGGER.warning( + "Could not deactivate the ephemeral key on %s", otbrdata.url + ) return True diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 0c9e159b06f90f..978f38a40f354f 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -105,7 +105,8 @@ class OTBRData: url: str api: python_otbr_api.OTBR entry_id: str - ephemeral_key_supported: bool = False + # 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 ephemeral_key_lock: asyncio.Lock = dataclasses.field( diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 6c6c62aaf024c5..cf602d6600c70e 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -77,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 @@ -92,7 +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": data.ephemeral_key_supported, + "ephemeral_key_supported": bool(data.ephemeral_key_supported), "url": data.url, } diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index fbe4626bae113f..534727d42abc15 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -271,12 +271,15 @@ async def test_config_entry_not_ready( @pytest.mark.parametrize("error", [TimeoutError, aiohttp.ClientError]) @pytest.mark.usefixtures( - "get_active_dataset_tlvs", "get_border_agent_id", "get_extended_address" + "get_active_dataset_tlvs", + "get_border_agent_id", + "get_extended_address", + "multiprotocol_addon_manager_mock", ) -async def test_config_entry_not_ready_ephemeral_key_probe( +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 retries setup.""" + """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) @@ -288,8 +291,49 @@ async def test_config_entry_not_ready_ephemeral_key_probe( unique_id=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), ) config_entry.add_to_hass(hass) - assert not await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.state is ConfigEntryState.SETUP_RETRY + 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", "deleted"), + [ + pytest.param(HTTPStatus.OK, True, id="deleted"), + pytest.param(HTTPStatus.INTERNAL_SERVER_ERROR, False, id="delete_fails"), + ], +) +@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, + deleted: 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" + + 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" + assert (otbrdata.active_ephemeral_key is None) is deleted @pytest.mark.parametrize( diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 26aed259a584ac..8e4d490114cfb7 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -97,6 +97,57 @@ async def test_get_info( } +@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, From cbef9ffc2bff34483750a155440d73900913d471 Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Sat, 22 Aug 2026 13:25:54 +0000 Subject: [PATCH 08/11] remove redundent text (from before home-assistant-libs/python-otbr-api#267 was mereged) --- homeassistant/components/otbr/util.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 978f38a40f354f..f699bd59a1be5c 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -191,8 +191,6 @@ async def get_coprocessor_version(self) -> str: """Get coprocessor firmware version.""" return await self.api.get_coprocessor_version() - # The ephemeral key endpoints are called directly until a python-otbr-api - # release includes them (home-assistant-libs/python-otbr-api#267) @_handle_otbr_error async def get_ephemeral_key_supported(self, hass: HomeAssistant) -> bool: """Return whether the router supports ephemeral key mode.""" From 53b8470f8d6abf597af21781253fb375a926179f Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Sat, 22 Aug 2026 13:51:06 +0000 Subject: [PATCH 09/11] Fix OTBR ephemeral key unload race --- homeassistant/components/otbr/__init__.py | 12 +++++------- homeassistant/components/otbr/util.py | 10 +++++++++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 1d78d81661cada..ebfb6edf643392 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -96,15 +96,13 @@ 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 - if otbrdata.active_ephemeral_key is not None: - try: - await otbrdata.deactivate_ephemeral_key(hass) - except HomeAssistantError: - _LOGGER.warning( - "Could not deactivate the ephemeral key on %s", otbrdata.url - ) + 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/util.py b/homeassistant/components/otbr/util.py index f699bd59a1be5c..3dc1669e81c864 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -109,6 +109,7 @@ class OTBRData: 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 ) @@ -219,6 +220,8 @@ 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 ( @@ -294,7 +297,10 @@ async def activate() -> aiohttp.ClientResponse: @_handle_otbr_error async def deactivate_ephemeral_key( - self, hass: HomeAssistant, ephemeral_key: str | None = None + self, + hass: HomeAssistant, + ephemeral_key: str | None = None, + only_if_active: bool = False, ) -> bool: """Deactivate the active ephemeral key, returning whether one was deleted. @@ -302,6 +308,8 @@ async def deactivate_ephemeral_key( cannot revoke a key handed out after it. """ async with self.ephemeral_key_lock: + 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) From 2668be31335103fd316a91700c69934c90b64a41 Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Sat, 22 Aug 2026 13:52:26 +0000 Subject: [PATCH 10/11] Only treat 404 and 405 as unsupported; allow other HTTP failures to remain retryable --- homeassistant/components/otbr/util.py | 8 +++++--- tests/components/otbr/test_init.py | 5 +++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 3dc1669e81c864..233ae03960f9b7 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -200,9 +200,11 @@ async def get_ephemeral_key_supported(self, hass: HomeAssistant) -> bool: f"{self.url}/node/ba-epskc/state", timeout=aiohttp.ClientTimeout(total=10), ) - # Only 200 proves support; any other status hides the optional feature, - # while connection errors fail setup like the other startup calls - return response.status == HTTPStatus.OK + 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( diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 534727d42abc15..03c606441729aa 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -341,7 +341,8 @@ async def test_unload_entry_revokes_ephemeral_key( [ pytest.param(HTTPStatus.OK, True, id="supported"), pytest.param(HTTPStatus.NOT_FOUND, False, id="not_supported"), - pytest.param(HTTPStatus.INTERNAL_SERVER_ERROR, False, id="probe_error"), + pytest.param(HTTPStatus.METHOD_NOT_ALLOWED, False, id="not_supported_method"), + pytest.param(HTTPStatus.INTERNAL_SERVER_ERROR, None, id="probe_error"), ], ) @pytest.mark.usefixtures( @@ -353,7 +354,7 @@ async def test_unload_entry_revokes_ephemeral_key( async def test_ephemeral_key_support_probe( hass: HomeAssistant, ephemeral_key_supported: bool ) -> None: - """Test only a 200 from the probe marks ephemeral key mode as supported.""" + """Test the ephemeral key support probe handles router response statuses.""" config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA_MULTIPAN, domain=otbr.DOMAIN, From 7a5b49f90905b03c282d25d55f5d3f4b230c3eeb Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Wed, 2 Sep 2026 13:39:03 +0000 Subject: [PATCH 11/11] Forget an expired ephemeral key before deleting --- homeassistant/components/otbr/util.py | 8 +++++++ tests/components/otbr/test_init.py | 25 +++++++++++++++------ tests/components/otbr/test_websocket_api.py | 10 ++++++--- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 233ae03960f9b7..56965b52eae58b 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -310,6 +310,14 @@ async def deactivate_ephemeral_key( 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: diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 03c606441729aa..5aa347ea9f76bc 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -1,6 +1,7 @@ """Test the Open Thread Border Router integration.""" import asyncio +from datetime import timedelta from http import HTTPStatus import re from typing import Any @@ -17,6 +18,7 @@ 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, @@ -298,10 +300,14 @@ async def test_ephemeral_key_probe_connection_error( @pytest.mark.parametrize( - ("delete_status", "deleted"), + ("delete_status", "expired", "delete_sent", "forgotten"), [ - pytest.param(HTTPStatus.OK, True, id="deleted"), - pytest.param(HTTPStatus.INTERNAL_SERVER_ERROR, False, id="delete_fails"), + 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( @@ -314,7 +320,9 @@ async def test_unload_entry_revokes_ephemeral_key( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, delete_status: HTTPStatus, - deleted: bool, + 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) @@ -329,11 +337,14 @@ async def test_unload_entry_revokes_ephemeral_key( 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" - assert (otbrdata.active_ephemeral_key is None) is deleted + assert (aioclient_mock.mock_calls[-1][0] == "DELETE") is delete_sent + assert (otbrdata.active_ephemeral_key is None) is forgotten @pytest.mark.parametrize( @@ -352,7 +363,7 @@ async def test_unload_entry_revokes_ephemeral_key( "multiprotocol_addon_manager_mock", ) async def test_ephemeral_key_support_probe( - hass: HomeAssistant, ephemeral_key_supported: bool + hass: HomeAssistant, ephemeral_key_supported: bool | None ) -> None: """Test the ephemeral key support probe handles router response statuses.""" config_entry = MockConfigEntry( diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 8e4d490114cfb7..90a3bba407215c 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -1417,17 +1417,20 @@ async def test_delete_ephemeral_key( @pytest.mark.parametrize( - ("ephemeral_key", "deleted"), + ("ephemeral_key", "elapsed", "deleted"), [ - pytest.param("700855744", True, id="active_key"), - pytest.param("123456789", False, id="replaced_key"), + 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.""" @@ -1448,6 +1451,7 @@ async def test_delete_ephemeral_key_by_key( } ) assert (await websocket_client.receive_json())["success"] + freezer.tick(elapsed) await websocket_client.send_json_auto_id( { "type": "otbr/delete_ephemeral_key",