diff --git a/esphome/__main__.py b/esphome/__main__.py index 30e97f55eb..d68319e63a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1343,8 +1343,12 @@ def _upload_via_native_api( # fall back to a plaintext upload noise_psk = None plaintext_fallback = False + allow_plaintext_upload = False if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None: noise_psk = encryption_conf.get(CONF_KEY) + allow_plaintext_upload = bool( + encryption_conf.get(espota2.CONF_ALLOW_PLAINTEXT_UPLOAD) + ) if not noise_psk: raise EsphomeError( "OTA encryption is configured but no key was resolved; " @@ -1395,6 +1399,7 @@ def _upload_via_native_api( ota_type, noise_psk, plaintext_fallback=plaintext_fallback, + allow_plaintext_upload=allow_plaintext_upload, ) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index bace13c17f..e14f5e7e86 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -2,7 +2,7 @@ import logging import esphome.codegen as cg from esphome.components.noise import ( - encryption_schema, + ENCRYPTION_SCHEMA, new_psk_progmem, static_encryption_key, ) @@ -27,6 +27,7 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD import esphome.final_validate as fv from esphome.types import ConfigType @@ -231,15 +232,38 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) - encryption_conf[CONF_KEY] = api_key +# Uploader side options live only on the ota block; the api block keeps the +# shared schema +_ENCRYPTION_SCHEMA = ENCRYPTION_SCHEMA.extend( + { + cv.Optional(CONF_ALLOW_PLAINTEXT_UPLOAD): cv.boolean, + } +) + + +def _encryption_schema(config: ConfigType | None) -> ConfigType: + # Only a bare `encryption:` block is keyless; `false` or a list must fail + return _ENCRYPTION_SCHEMA({} if config is None else config) + + # Also called on merged same-port configs in final validate, where schemas # do not run def _validate_no_password_with_encryption(config: ConfigType) -> ConfigType: - if CONF_PASSWORD in config and CONF_ENCRYPTION in config: - raise cv.Invalid( - f"'{CONF_PASSWORD}' cannot be combined with '{CONF_ENCRYPTION}'; the " - f"encryption key already authenticates the uploader, remove '{CONF_PASSWORD}'" - ) - return config + if ( + CONF_PASSWORD not in config + or (encryption := config.get(CONF_ENCRYPTION)) is None + ): + return config + # The migration install may still have to answer the old firmware's + # password prompt on the plaintext leg; the password is not built in + if encryption.get(CONF_ALLOW_PLAINTEXT_UPLOAD): + return config + raise cv.Invalid( + f"'{CONF_PASSWORD}' cannot be combined with '{CONF_ENCRYPTION}'; the " + f"encryption key already authenticates the uploader, remove '{CONF_PASSWORD}' " + f"(or set '{CONF_ALLOW_PLAINTEXT_UPLOAD}: true' for the one install that " + f"migrates a device still asking for it)" + ) def _consume_ota_sockets(config: ConfigType) -> ConfigType: @@ -269,7 +293,7 @@ CONFIG_SCHEMA = cv.All( ): cv.port, cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, cv.Optional(CONF_PASSWORD): cv.sensitive(), - cv.Optional(CONF_ENCRYPTION): encryption_schema, + cv.Optional(CONF_ENCRYPTION): _encryption_schema, cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid( f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode" ), @@ -304,7 +328,10 @@ async def to_code(config: ConfigType) -> None: # An empty password opts in to the auth code path so set_auth_password() can be # called at runtime (e.g. to rotate the password from a lambda). When `password:` # is omitted entirely, the auth path is excluded to save flash on small devices. - if CONF_PASSWORD in config: + # A password is never built in next to encryption: validation only lets + # the two coexist for the migration install, where the password answers + # the running firmware and the build is authenticated by the key + if CONF_PASSWORD in config and CONF_ENCRYPTION not in config: cg.add_define("USE_OTA_PASSWORD") if config[CONF_PASSWORD]: cg.add(var.set_auth_password(config[CONF_PASSWORD])) diff --git a/esphome/espota2.py b/esphome/espota2.py index c683ffa323..952f88fc4b 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -211,6 +211,27 @@ class OTAEncryptionFallback(OTAError): """The encrypted attempt failed and the caller may retry in plaintext.""" +# Uploader side option under `ota: encryption:`; the ota component imports the +# name so the upload path never loads the component module +CONF_ALLOW_PLAINTEXT_UPLOAD = "allow_plaintext_upload" +ALLOW_PLAINTEXT_UPLOAD_NOTICE = ( + f"'{CONF_ALLOW_PLAINTEXT_UPLOAD}' is set; expected once, on the install that " + "migrates a device which never encrypted. If this device encrypted before, " + "something on the network stripped the offer: remove the option and check " + "the network." +) +# Logged only once the device is seen encrypting, so the migration install +# itself is never nagged and the user learns exactly when removal is safe +ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING = f""" +****************************************************************** +* This device offers OTA encryption and accepted the key, so +* '{CONF_ALLOW_PLAINTEXT_UPLOAD}' under 'ota: encryption:' has done +* its job. Remove it from the configuration now, together with +* any 'password:' on that block: leaving the option in place lets +* an attacker on the network strip the encryption offer and +* downgrade a future upload to plaintext. +******************************************************************""" + # Remove before 2027.3.0 PLAINTEXT_FALLBACK_NOTICE = ( "A device with an api encryption key offers encryption after this " @@ -512,6 +533,7 @@ def perform_ota( ota_type: int = OTA_TYPE_UPDATE_APP, noise_psk: str | None = None, plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, ) -> None: # Validate up front; an out-of-range value would only surface as a # ValueError deep inside send_check, bypassing OTAError handling @@ -577,25 +599,32 @@ def perform_ota( features = 0 if noise_psk and not (extended_proto and features & SERVER_FEATURE_SUPPORTS_NOISE): - if plaintext_fallback: - # Remove before 2027.3.0: older firmware that cannot encrypt still - # gets its update on this connection + # Remove before 2027.3.0: drop `or plaintext_fallback` and + # PLAINTEXT_FALLBACK_NOTICE here; allow_plaintext_upload stays + if allow_plaintext_upload or plaintext_fallback: + # The running firmware cannot encrypt; it still gets this update, + # and the build being sent offers encryption for the next one _LOGGER.warning( "The device did not offer OTA encryption; continuing in plaintext. %s", - PLAINTEXT_FALLBACK_NOTICE, + ALLOW_PLAINTEXT_UPLOAD_NOTICE + if allow_plaintext_upload + else PLAINTEXT_FALLBACK_NOTICE, ) noise_psk = None else: # Fail closed: an attacker could otherwise strip the offer and # capture the image (wifi credentials, api key) + # Remove before 2027.3.0: installing without the block no longer + # falls back then; advise 'allow_plaintext_upload: true' instead 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 predates ESPHome 2026.9.0 or has no " "'api: encryption: key'. With an api key, install once " "without the 'ota: encryption:' block (that build offers " - "encryption), then restore it; otherwise flash by serial or " - "the web_server OTA platform." + f"encryption), then restore it; otherwise set '{CONF_ALLOW_PLAINTEXT_UPLOAD}: " + "true' under 'ota: encryption:' for this one install, or flash by " + "serial or the web_server OTA platform." ) if noise_psk: # The prologue binds every negotiation byte both sides saw, so any @@ -619,6 +648,8 @@ def perform_ota( raise OTAEncryptionFallback(str(err)) from err raise _LOGGER.info("Encrypted connection established") + if allow_plaintext_upload: + _LOGGER.warning(ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING) if ota_type != OTA_TYPE_UPDATE_APP: # Any non-app OTA type requires the extended protocol and the @@ -824,6 +855,7 @@ def run_ota_impl_( ota_type: int = OTA_TYPE_UPDATE_APP, noise_psk: str | None = None, plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, ) -> tuple[int, str | None]: from esphome.core import CORE @@ -899,6 +931,7 @@ def run_ota_impl_( ota_type, encryption.noise_psk, encryption.plaintext_fallback, + allow_plaintext_upload=allow_plaintext_upload, ) except OTAEncryptionFallback as err: # Same address and attempt budget: not a network retry @@ -940,6 +973,7 @@ def run_ota( ota_type: int = OTA_TYPE_UPDATE_APP, noise_psk: str | None = None, plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, ) -> tuple[int, str | None]: try: return run_ota_impl_( @@ -950,6 +984,7 @@ def run_ota( ota_type, noise_psk, plaintext_fallback, + allow_plaintext_upload, ) except OTAError as err: _LOGGER.error(err) diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index d8bcd7e275..cd66503c1b 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -12,10 +12,11 @@ from esphome import config_validation as cv from esphome.components.esphome.ota import ( AUTO_LOAD, FILTER_SOURCE_FILES, + _encryption_schema, _validate_no_password_with_encryption, ota_esphome_final_validate, ) -from esphome.components.noise import static_encryption_key +from esphome.components.noise import encryption_schema, static_encryption_key from esphome.const import ( CONF_API, CONF_ENCRYPTION, @@ -29,6 +30,7 @@ from esphome.const import ( CONF_VERSION, ) from esphome.core import CORE, ID +from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD import esphome.final_validate as fv @@ -198,6 +200,21 @@ def test_encryption_without_any_key_rejected() -> None: fv.full_config.reset(token) +def test_encryption_schema_allow_plaintext_upload() -> None: + """The opt in is an ota only option with no default, so a merged block + that does not mention it cannot clear it; the shared api schema does not + know it.""" + assert _encryption_schema(None) == {} + assert _encryption_schema({CONF_ALLOW_PLAINTEXT_UPLOAD: True}) == { + CONF_ALLOW_PLAINTEXT_UPLOAD: True + } + assert _encryption_schema({CONF_KEY: API_KEY}) == {CONF_KEY: API_KEY} + with pytest.raises(cv.Invalid): + _encryption_schema(False) + with pytest.raises(cv.Invalid): + encryption_schema({CONF_ALLOW_PLAINTEXT_UPLOAD: True}) + + def test_encryption_key_mismatch_between_merged_configs_rejected() -> None: """Same-port configs with different encryption keys raise.""" full_conf = { @@ -489,6 +506,13 @@ def test_static_encryption_key() -> None: {"USE_OTA_ENCRYPTION", "USE_OTA_PASSWORD"}, {"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"}, ), + # The migration install keeps the password for the old firmware's + # prompt but the build it sends is authenticated by the key alone + ( + "migration_password", + {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"}, + {"USE_OTA_PASSWORD", "USE_OTA_ENCRYPTION_PROVISIONED"}, + ), # The ota encryption block is what makes the device refuse plaintext ( "encryption_required", @@ -549,6 +573,16 @@ def test_password_with_encryption_rejected() -> None: _validate_no_password_with_encryption(config) +def test_password_with_migration_install_accepted() -> None: + """The old firmware may still ask for the password on the plaintext + leg of the migration install.""" + config = { + CONF_PASSWORD: "pw", + CONF_ENCRYPTION: {CONF_KEY: API_KEY, CONF_ALLOW_PLAINTEXT_UPLOAD: True}, + } + assert _validate_no_password_with_encryption(config) is config + + def test_password_alone_accepted() -> None: """A password without encryption still validates.""" config = {CONF_PASSWORD: "pw"} diff --git a/tests/component_tests/ota/test_esphome_ota_migration_password.yaml b/tests/component_tests/ota/test_esphome_ota_migration_password.yaml new file mode 100644 index 0000000000..6a44f75751 --- /dev/null +++ b/tests/component_tests/ota/test_esphome_ota_migration_password.yaml @@ -0,0 +1,14 @@ +esphome: + name: ota-migration-password + +host: + +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + +ota: + - platform: esphome + password: "superlongpasswordthatnoonewillknow" + encryption: + allow_plaintext_upload: true diff --git a/tests/components/ota/allow_plaintext_upload.yaml b/tests/components/ota/allow_plaintext_upload.yaml new file mode 100644 index 0000000000..be222ec433 --- /dev/null +++ b/tests/components/ota/allow_plaintext_upload.yaml @@ -0,0 +1,13 @@ +wifi: + ssid: MySSID + password: password1 + +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + +ota: + - platform: esphome + password: "superlongpasswordthatnoonewillknow" + encryption: + allow_plaintext_upload: true diff --git a/tests/components/ota/validate-allow_plaintext_upload.esp8266-ard.yaml b/tests/components/ota/validate-allow_plaintext_upload.esp8266-ard.yaml new file mode 100644 index 0000000000..834e6197ac --- /dev/null +++ b/tests/components/ota/validate-allow_plaintext_upload.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + ota: !include allow_plaintext_upload.yaml diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index 439220f09c..60f6b28e07 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -218,6 +218,7 @@ def _upload( firmware: bytes, noise_psk: str | None, plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, ) -> None: device.start() sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -231,13 +232,18 @@ def _upload( Path("firmware.bin"), noise_psk=noise_psk, plaintext_fallback=plaintext_fallback, + allow_plaintext_upload=allow_plaintext_upload, ) finally: sock.close() def _run_ota( - device: FakeEncryptedDevice, firmware: bytes, tmp_path: Path, noise_psk: str + device: FakeEncryptedDevice, + firmware: bytes, + tmp_path: Path, + noise_psk: str, + plaintext_fallback: bool = True, ) -> int: """Drive the retry loop, which is where the plaintext fallback reconnects.""" path = tmp_path / "firmware.bin" @@ -249,7 +255,7 @@ def _run_ota( None, path, noise_psk=noise_psk, - plaintext_fallback=True, + plaintext_fallback=plaintext_fallback, ) return rc @@ -299,11 +305,69 @@ def test_tampered_negotiation_breaks_handshake() -> None: def test_client_fails_closed_when_device_lacks_encryption() -> None: """With a key configured, a device not offering noise aborts the upload.""" device = FakeEncryptedDevice(offer_noise=False, require_noise=False) - with pytest.raises(espota2.OTAError, match="refusing to send the image"): + with pytest.raises( + espota2.OTAError, match="refusing to send the image.*allow_plaintext_upload" + ): _upload(device, b"firmware", PSK) device.join_and_check() +def test_allow_plaintext_upload_when_device_does_not_offer( + caplog: pytest.LogCaptureFixture, +) -> None: + """The explicit opt in sends the image in plaintext to a device that + cannot encrypt, naming the option in the warning.""" + firmware = b"firmware" + device = FakeEncryptedDevice(offer_noise=False, require_noise=False) + with patch("time.sleep"), caplog.at_level(logging.WARNING): + _upload(device, firmware, PSK, allow_plaintext_upload=True) + device.join_and_check() + assert device.received == firmware + assert any("'allow_plaintext_upload' is set" in r.message for r in caplog.records) + assert not any("2027.3.0" in r.message for r in caplog.records) + assert not any( + "Remove it from the configuration" in r.message for r in caplog.records + ) + + +def test_allow_plaintext_upload_warns_once_device_encrypts( + caplog: pytest.LogCaptureFixture, +) -> None: + """The removal warning appears exactly when it is safe to act on: the + device offered encryption and accepted the key with the option still set.""" + pytest.importorskip("aioesphomeapi.noise") + firmware = b"firmware" + device = FakeEncryptedDevice() + with caplog.at_level(logging.WARNING): + _upload(device, firmware, PSK, allow_plaintext_upload=True) + device.join_and_check() + assert device.received == firmware + assert any( + "Remove it from the configuration now" in r.message for r in caplog.records + ) + with caplog.at_level(logging.WARNING): + caplog.clear() + _upload(FakeEncryptedDevice(), firmware, PSK) + assert not caplog.records + + +def test_allow_plaintext_upload_keeps_wrong_key_failing( + caplog: pytest.LogCaptureFixture, +) -> None: + """The opt in only covers a device that does not offer; a rejected key + never turns into a plaintext upload.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(psk=OTHER_PSK, require_noise=False) + with ( + caplog.at_level(logging.WARNING), + pytest.raises(espota2.OTAError, match="encryption key correct"), + ): + _upload(device, b"firmware", PSK, allow_plaintext_upload=True) + device.join_and_check() + assert device.received != b"firmware" + assert not any("plaintext" in r.message for r in caplog.records) + + # Remove before 2027.3.0 def test_fallback_when_device_does_not_offer(caplog: pytest.LogCaptureFixture) -> None: """The api key is tried opportunistically; an older device that cannot @@ -519,3 +583,18 @@ def test_recv_serves_buffered_plaintext_without_new_frame() -> None: assert wrapper.recv(1) == b"A" # reads and decrypts one frame assert wrapper.recv(1) == b"B" # served from the buffer, no new frame wrapper._decrypt.decrypt.assert_called_once() + + +def test_bare_block_refuses_a_device_that_cannot_encrypt( + caplog: pytest.LogCaptureFixture, tmp_path: Path +) -> None: + """What the CLI sends for a bare `encryption:` block: a key with neither + fallback. The retry loop never reconnects in plaintext.""" + device = FakeEncryptedDevice(offer_noise=False, require_noise=False) + with patch("time.sleep"), caplog.at_level(logging.WARNING): + rc = _run_ota(device, b"firmware", tmp_path, PSK, plaintext_fallback=False) + device.join_and_check() + assert rc == 1 + assert device.received != b"firmware" + assert any("refusing to send the image" in r.message for r in caplog.records) + assert not any("Retrying in plaintext" in r.message for r in caplog.records) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 8fb9b7376e..956ba86502 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -2115,6 +2115,7 @@ def test_upload_program_ota_success( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -2153,9 +2154,75 @@ def test_upload_program_ota_encryption_key( OTA_TYPE_UPDATE_APP, key, plaintext_fallback=False, + allow_plaintext_upload=False, ) +def test_upload_program_bare_encryption_block_never_falls_back( + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """A bare `ota: encryption:` (the api key inherited by final validate, no + option) fails closed against a device that does not offer encryption.""" + from esphome.components.esphome.ota import ota_esphome_final_validate + import esphome.final_validate as fv + + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + config = { + CONF_API: {CONF_ENCRYPTION: {CONF_KEY: key}}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232, CONF_ENCRYPTION: {}}], + } + token = fv.full_config.set(config) + try: + ota_esphome_final_validate({}) + config = fv.full_config.get() + finally: + fv.full_config.reset(token) + assert config[CONF_OTA][0][CONF_ENCRYPTION] == {CONF_KEY: key} + + with patch("esphome.espota2.run_ota", return_value=(0, "192.168.1.100")) as run_ota: + upload_program(config, MockArgs(), ["192.168.1.100"]) + assert run_ota.call_args.args[5] == key + assert run_ota.call_args.kwargs == { + "plaintext_fallback": False, + "allow_plaintext_upload": False, + } + + +def test_upload_program_ota_allow_plaintext_upload( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """The uploader side opt in reaches run_ota without the removed fallback.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + CONF_PASSWORD: "pw", + CONF_ENCRYPTION: {CONF_KEY: key, "allow_plaintext_upload": True}, + } + ] + } + exit_code, _ = upload_program(config, MockArgs(), ["192.168.1.100"]) + + assert exit_code == 0 + assert mock_run_ota.call_args.args[2] == "pw" + assert mock_run_ota.call_args.args[5] == key + assert mock_run_ota.call_args.kwargs == { + "plaintext_fallback": False, + "allow_plaintext_upload": True, + } + + def test_upload_program_ota_api_key_opportunistic( mock_run_ota: Mock, mock_get_port_type: Mock, @@ -2186,6 +2253,7 @@ def test_upload_program_ota_api_key_opportunistic( OTA_TYPE_UPDATE_APP, key, plaintext_fallback=True, + allow_plaintext_upload=False, ) @@ -2214,7 +2282,10 @@ def test_upload_program_ota_no_usable_api_key_stays_plaintext( assert exit_code == 0 assert mock_run_ota.call_args.args[5] is None - assert mock_run_ota.call_args.kwargs == {"plaintext_fallback": False} + assert mock_run_ota.call_args.kwargs == { + "plaintext_fallback": False, + "allow_plaintext_upload": False, + } def test_upload_program_ota_encryption_without_key_fails_closed( @@ -2274,6 +2345,7 @@ def test_upload_program_ota_with_file_arg( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -2330,6 +2402,7 @@ def test_upload_program_ota_partition_table_with_file_arg( OTA_TYPE_UPDATE_PARTITION_TABLE, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -2393,6 +2466,7 @@ def test_upload_program_ota_partition_table_mqttip( OTA_TYPE_UPDATE_PARTITION_TABLE, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -2582,6 +2656,7 @@ def test_upload_program_ota_bootloader_with_file_arg( OTA_TYPE_UPDATE_BOOTLOADER, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -3077,6 +3152,7 @@ def test_upload_program_ota_with_mqtt_resolution( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -3133,6 +3209,7 @@ def test_upload_program_ota_with_mqtt_empty_broker( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) # Verify warning was logged assert "MQTT IP discovery failed" in caplog.text @@ -5306,6 +5383,7 @@ def test_upload_program_ota_static_ip_with_mqttip( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -5357,6 +5435,7 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -5541,6 +5620,7 @@ def test_upload_program_ota_mqtt_timeout_fallback( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, )