From 1b99443a972b6a8ebbf0dc21a5fc035268d5bb2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Sep 2026 12:56:06 +0200 Subject: [PATCH] Guard the session start against a missing key, reword the warning, and let the wizard use the key for OTA --- esphome/components/esphome/ota/__init__.py | 9 +++++---- .../esphome/ota/ota_esphome_noise.cpp | 11 +++++++---- esphome/espota2.py | 4 ++-- esphome/wizard.py | 12 ++++++++---- tests/component_tests/ota/test_esphome_ota.py | 2 +- tests/unit_tests/test_wizard.py | 19 +++++++++++++++++++ 6 files changed, 42 insertions(+), 15 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 4ccf86383c..dbbaa6ef48 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -132,10 +132,11 @@ def ota_esphome_final_validate(config: ConfigType) -> None: elif CONF_PASSWORD in ota_conf and _api_static_key(api_conf) is not None: _LOGGER.warning( "'%s' %s wastes significant flash and RAM (about 3.5 KB and 60 " - "bytes plus the password on the heap): the '%s' %s %s already " - "authenticates and encrypts OTA uploads, the password only serves " - "older clients that do not support encryption; remove '%s' and " - "add '%s' under '%s' to require encryption", + "bytes plus the password on the heap): the device already offers " + "encryption with the '%s' %s %s, which authenticates any uploader " + "that takes it, so the password only serves older clients that do " + "not support encryption; remove '%s' and add '%s' under '%s' to " + "require encryption", CONF_OTA, CONF_PASSWORD, CONF_API, diff --git a/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp index fffe866e98..dd4767406a 100644 --- a/esphome/components/esphome/ota/ota_esphome_noise.cpp +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -64,8 +64,11 @@ bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) { *p++ = ota::OTA_RESPONSE_FEATURE_FLAGS; *p++ = server_feature_flags; - int err = this->noise_ == nullptr ? NOISE_ERROR_NO_MEMORY - : this->noise_->handshake.init(this->noise_ctx_, prologue, sizeof(prologue)); + // Codegen always pairs USE_OTA_ENCRYPTION with a real key; never fall back + // to the all-zeros provisioning key here + int err = this->noise_ == nullptr ? NOISE_ERROR_NO_MEMORY + : !this->noise_ctx_.has_psk() ? NOISE_ERROR_PSK_REQUIRED + : this->noise_->handshake.init(this->noise_ctx_, prologue, sizeof(prologue)); if (err != 0) { ESP_LOGW(TAG, "Session init: %d", err); this->cleanup_connection_(); @@ -142,7 +145,7 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() { return true; } default: { - ESP_LOGV(TAG, "Bad handshake state"); + ESP_LOGW(TAG, "Bad handshake state"); this->cleanup_connection_(); return false; } @@ -151,7 +154,7 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() { } /// Payload length from a frame header, or 0 (logged) when the indicator or -/// the length is out of range. +/// the length is out of range. Callers pass min_len >= 1 so 0 is never valid. size_t ESPHomeOTAComponent::noise_frame_payload_len_(const uint8_t *header, size_t min_len, size_t max_len) { const size_t payload_len = encode_uint16(header[1], header[2]); if (header[0] != noise::FRAME_INDICATOR || payload_len < min_len || payload_len > max_len) { diff --git a/esphome/espota2.py b/esphome/espota2.py index 2d490caf99..e18359c32a 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -537,8 +537,8 @@ def perform_ota( raise OTAError( "An OTA encryption key is configured but the device did not " "offer encryption; refusing to send the image in plaintext. " - "The running firmware was built before OTA encryption " - "(ESPHome 2026.9.0) or without an 'api: encryption: key'. " + "The running firmware does not offer encryption (built before " + "ESPHome 2026.9.0 or without an 'api: encryption: key'). " "If the config has an 'api: encryption: key', install once " "with the 'ota: encryption:' block removed (that firmware " "offers encryption), then restore the block and install " diff --git a/esphome/wizard.py b/esphome/wizard.py index f7706928e9..515d091f49 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -148,11 +148,14 @@ def wizard_file(**kwargs: Unpack[WizardFileKwargs]) -> str: if "api_encryption_key" in kwargs: config += f' encryption:\n key: "{kwargs["api_encryption_key"]}"\n' - # Configure OTA + # Configure OTA: the api key also secures OTA updates, a password is only + # for uploaders that do not support encryption config += "\nota:\n" config += " - platform: esphome\n" if "ota_password" in kwargs: config += f' password: "{kwargs["ota_password"]}"' + elif "api_encryption_key" in kwargs: + config += " encryption:" # Configuring wifi config += "\n\nwifi:\n" @@ -532,12 +535,13 @@ def wizard(path: Path) -> int: safe_print() safe_print( - f"Do you want to set a {color(AnsiFore.GREEN, 'password')} for OTA updates? " - "This can be insecure if you do not trust the WiFi network." + "The API encryption key also secures OTA updates. Do you want to set a " + f"{color(AnsiFore.GREEN, 'password')} for OTA updates instead? Only " + "older uploaders that do not support encryption need one." ) safe_print() sleep(0.25) - safe_print("Press ENTER for no password") + safe_print("Press ENTER to use the encryption key") ota_password = safe_input(color(AnsiFore.BOLD_WHITE, "(password): ")) else: ssid, psk = "", "" diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index 7beec3e971..e88d4efaca 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -452,7 +452,7 @@ def test_encryption_offer_codegen( assert defines_present <= defines assert not (defines_absent & defines) encrypted = "USE_OTA_ENCRYPTION" in defines_present - assert ("set_noise_psk(" in main_cpp) is encrypted + assert ("esphome_esphomeotacomponent_id->set_noise_psk(" in main_cpp) is encrypted assert ("set_auth_password(" in main_cpp) is ("USE_OTA_PASSWORD" in defines_present) # The noise transport source compiles only when the define is set assert FILTER_SOURCE_FILES() == ([] if encrypted else ["ota_esphome_noise.cpp"]) diff --git a/tests/unit_tests/test_wizard.py b/tests/unit_tests/test_wizard.py index 244e4eb5a1..218ea8b649 100644 --- a/tests/unit_tests/test_wizard.py +++ b/tests/unit_tests/test_wizard.py @@ -101,6 +101,25 @@ def test_config_file_should_include_ota(default_config: dict[str, Any]): assert "ota:" in config +def test_config_file_should_use_encryption_when_api_key_set( + default_config: dict[str, Any], +): + """ + With an API encryption key and no OTA password the OTA block reuses the key + """ + # Given + default_config["api_encryption_key"] = ( + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + ) + + # When + config = wz.wizard_file(**default_config) + + # Then + assert "ota:\n - platform: esphome\n encryption:" in config + assert "password" not in config.split("ota:")[1].split("wifi:")[0] + + def test_config_file_should_include_ota_when_password_set( default_config: dict[str, Any], ):