[ota] Add allow_plaintext_upload for devices that never migrated to encryption (#19396)

This commit is contained in:
J. Nick Koston
2026-09-25 15:42:52 +01:00
committed by GitHub
parent 370d76d013
commit 2d8e43ef78
9 changed files with 309 additions and 20 deletions
+35 -1
View File
@@ -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"}
@@ -0,0 +1,14 @@
esphome:
name: ota-migration-password
host:
api:
encryption:
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
ota:
- platform: esphome
password: "superlongpasswordthatnoonewillknow"
encryption:
allow_plaintext_upload: true
@@ -0,0 +1,13 @@
wifi:
ssid: MySSID
password: password1
api:
encryption:
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
ota:
- platform: esphome
password: "superlongpasswordthatnoonewillknow"
encryption:
allow_plaintext_upload: true
@@ -0,0 +1,2 @@
packages:
ota: !include allow_plaintext_upload.yaml
+82 -3
View File
@@ -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)
+81 -1
View File
@@ -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,
)