Guard the session start against a missing key, reword the warning, and let the wizard use the key for OTA

This commit is contained in:
J. Nick Koston
2026-09-05 12:56:06 +02:00
parent e3e755850f
commit 1b99443a97
6 changed files with 42 additions and 15 deletions
+5 -4
View File
@@ -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,
@@ -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) {
+2 -2
View File
@@ -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 "
+8 -4
View File
@@ -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 = "", ""
@@ -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"])
+19
View File
@@ -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],
):