diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 654bd56c755..5fbd3f3b4cb 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -37,7 +37,7 @@ PIN_FORMAT = re.compile(r"^(\d{3})-{0,1}(\d{2})-{0,1}(\d{3})$") _LOGGER = logging.getLogger(__name__) -DISALLOWED_CODES = { +INSECURE_CODES = { "00000000", "11111111", "22222222", @@ -66,7 +66,7 @@ def find_existing_host(hass, serial): return entry -def ensure_pin_format(pin): +def ensure_pin_format(pin, allow_insecure_setup_codes=None): """ Ensure a pin code is correctly formatted. @@ -78,8 +78,8 @@ def ensure_pin_format(pin): if not match: raise aiohomekit.exceptions.MalformedPinError(f"Invalid PIN code f{pin}") pin_without_dashes = "".join(match.groups()) - if pin_without_dashes in DISALLOWED_CODES: - raise aiohomekit.exceptions.MalformedPinError(f"Invalid PIN code f{pin}") + if not allow_insecure_setup_codes and pin_without_dashes in INSECURE_CODES: + raise InsecureSetupCode(f"Invalid PIN code f{pin}") return "-".join(match.groups()) @@ -310,7 +310,12 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if pair_info and self.finish_pairing: code = pair_info["pairing_code"] try: - code = ensure_pin_format(code) + code = ensure_pin_format( + code, + allow_insecure_setup_codes=pair_info.get( + "allow_insecure_setup_codes" + ), + ) pairing = await self.finish_pairing(code) return await self._entry_from_accessory(pairing) except aiohomekit.exceptions.MalformedPinError: @@ -336,6 +341,8 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except aiohomekit.AccessoryNotFoundError: # Can no longer find the device on the network return self.async_abort(reason="accessory_not_found_error") + except InsecureSetupCode: + errors["pairing_code"] = "insecure_setup_code" except Exception: # pylint: disable=broad-except _LOGGER.exception("Pairing attempt failed with an unhandled exception") self.finish_pairing = None @@ -399,13 +406,15 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): placeholders = {"name": self.name} self.context["title_placeholders"] = {"name": self.name} + schema = {vol.Required("pairing_code"): vol.All(str, vol.Strip)} + if errors and errors.get("pairing_code") == "insecure_setup_code": + schema[vol.Optional("allow_insecure_setup_codes")] = bool + return self.async_show_form( step_id="pair", errors=errors or {}, description_placeholders=placeholders, - data_schema=vol.Schema( - {vol.Required("pairing_code"): vol.All(str, vol.Strip)} - ), + data_schema=vol.Schema(schema), ) async def _entry_from_accessory(self, pairing): @@ -428,3 +437,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): name = get_accessory_name(bridge_info) return self.async_create_entry(title=name, data=pairing_data) + + +class InsecureSetupCode(Exception): + """An exception for insecure trivial setup codes.""" diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index d170693bb6f..7ad868db3fc 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -14,7 +14,8 @@ "title": "Pair with a device via HomeKit Accessory Protocol", "description": "HomeKit Controller communicates with {name} over the local area network using a secure encrypted connection without a separate HomeKit controller or iCloud. Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging.", "data": { - "pairing_code": "Pairing Code" + "pairing_code": "Pairing Code", + "allow_insecure_setup_codes": "Allow pairing with insecure setup codes." } }, "protocol_error": { @@ -31,6 +32,7 @@ } }, "error": { + "insecure_setup_code": "The requested setup code is insecure because of its trivial nature. This accessory fails to meet basic security requirements.", "unable_to_pair": "Unable to pair, please try again.", "unknown_error": "Device reported an unknown error. Pairing failed.", "authentication_error": "Incorrect HomeKit code. Please check it and try again.", diff --git a/homeassistant/components/homekit_controller/translations/en.json b/homeassistant/components/homekit_controller/translations/en.json index 67409ed02cf..5de3a6c5334 100644 --- a/homeassistant/components/homekit_controller/translations/en.json +++ b/homeassistant/components/homekit_controller/translations/en.json @@ -12,6 +12,7 @@ }, "error": { "authentication_error": "Incorrect HomeKit code. Please check it and try again.", + "insecure_setup_code": "The requested setup code is insecure because of its trivial nature. This accessory fails to meet basic security requirements.", "max_peers_error": "Device refused to add pairing as it has no free pairing storage.", "pairing_failed": "An unhandled error occurred while attempting to pair with this device. This may be a temporary failure or your device may not be supported currently.", "unable_to_pair": "Unable to pair, please try again.", @@ -29,6 +30,7 @@ }, "pair": { "data": { + "allow_insecure_setup_codes": "Allow pairing with insecure setup codes.", "pairing_code": "Pairing Code" }, "description": "HomeKit Controller communicates with {name} over the local area network using a secure encrypted connection without a separate HomeKit controller or iCloud. Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging.", diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index 12381614a83..99c6966e827 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -42,6 +42,16 @@ PAIRING_FINISH_ABORT_ERRORS = [ (aiohomekit.AccessoryNotFoundError, "accessory_not_found_error") ] + +INSECURE_PAIRING_CODES = [ + "111-11-111", + "123-45-678", + "22222222", + "111-11-111 ", + " 111-11-111", +] + + INVALID_PAIRING_CODES = [ "aaa-aa-aaa", "aaa-11-aaa", @@ -49,11 +59,8 @@ INVALID_PAIRING_CODES = [ "aaa-aa-111", "1111-1-111", "a111-11-111", - " 111-11-111", - "111-11-111 ", "111-11-111a", "1111111", - "22222222", ] @@ -94,6 +101,15 @@ def test_invalid_pairing_codes(pairing_code): config_flow.ensure_pin_format(pairing_code) +@pytest.mark.parametrize("pairing_code", INSECURE_PAIRING_CODES) +def test_insecure_pairing_codes(pairing_code): + """Test ensure_pin_format raises for an invalid setup code.""" + with pytest.raises(config_flow.InsecureSetupCode): + config_flow.ensure_pin_format(pairing_code) + + config_flow.ensure_pin_format(pairing_code, allow_insecure_setup_codes=True) + + @pytest.mark.parametrize("pairing_code", VALID_PAIRING_CODES) def test_valid_pairing_codes(pairing_code): """Test ensure_pin_format corrects format for a valid pin in an alternative format.""" @@ -624,6 +640,49 @@ async def test_user_works(hass, controller): assert result["title"] == "Koogeek-LS1-20833F" +async def test_user_pairing_with_insecure_setup_code(hass, controller): + """Test user initiated disovers devices.""" + device = setup_mock_accessory(controller) + device.pairing_code = "123-45-678" + + # Device is discovered + result = await hass.config_entries.flow.async_init( + "homekit_controller", context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert get_flow_context(hass, result) == { + "source": config_entries.SOURCE_USER, + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"device": "TestDevice"} + ) + assert result["type"] == "form" + assert result["step_id"] == "pair" + + assert get_flow_context(hass, result) == { + "source": config_entries.SOURCE_USER, + "unique_id": "00:00:00:00:00:00", + "title_placeholders": {"name": "TestDevice"}, + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"pairing_code": "123-45-678"} + ) + assert result["type"] == "form" + assert result["step_id"] == "pair" + assert result["errors"] == {"pairing_code": "insecure_setup_code"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"pairing_code": "123-45-678", "allow_insecure_setup_codes": True}, + ) + assert result["type"] == "create_entry" + assert result["title"] == "Koogeek-LS1-20833F" + + async def test_user_no_devices(hass, controller): """Test user initiated pairing where no devices discovered.""" result = await hass.config_entries.flow.async_init(