diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index f2c37155ae..89bbd5ebac 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -129,6 +129,20 @@ def ota_esphome_final_validate(config: ConfigType) -> None: _validate_no_password_with_encryption(ota_conf) if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None: _resolve_encryption_key(encryption_conf, api_conf) + elif CONF_PASSWORD in ota_conf and _api_static_key(api_conf) is not None: + _LOGGER.warning( + "The '%s' %s %s also authenticates OTA uploads and encrypts them, " + "so '%s' %s only guards plaintext uploads; remove '%s' and add " + "'%s' under '%s' to require encryption", + CONF_API, + CONF_ENCRYPTION, + CONF_KEY, + CONF_OTA, + CONF_PASSWORD, + CONF_PASSWORD, + CONF_ENCRYPTION, + CONF_OTA, + ) if any( conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf ) and any( diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index b3c01636c6..d4a0911ec7 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -346,6 +346,44 @@ def test_encryption_with_captive_portal_web_server_ota_warns( fv.full_config.reset(token) +def test_password_with_api_key_warns(caplog: pytest.LogCaptureFixture) -> None: + """An api key makes the device offer encryption, which authenticates an + uploader without the password; the config validates with a warning.""" + full_conf = { + CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}}, + CONF_OTA: [_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"})], + } + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + ota_esphome_final_validate({}) + assert any("only guards plaintext" in r.message for r in caplog.records) + finally: + fv.full_config.reset(token) + + +@pytest.mark.parametrize( + "api_conf", + [{}, {CONF_ENCRYPTION: {}}, {CONF_ENCRYPTION: {CONF_KEY: ZEROS_KEY}}], + ids=["no_api", "runtime_key", "zeros_key"], +) +def test_password_without_static_api_key_no_warning( + caplog: pytest.LogCaptureFixture, api_conf: dict[str, Any] +) -> None: + """Without a build-time api key there is no offer, so nothing to warn about.""" + full_conf = { + CONF_API: api_conf, + CONF_OTA: [_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"})], + } + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + ota_esphome_final_validate({}) + assert not any("only guards plaintext" in r.message for r in caplog.records) + finally: + fv.full_config.reset(token) + + def test_web_server_ota_without_encryption_unaffected() -> None: """web_server ota stays valid alongside an unencrypted esphome entry.""" full_conf = {