mirror of
https://github.com/esphome/esphome.git
synced 2026-09-28 15:30:21 +00:00
[ota] Add allow_plaintext_upload for devices that never migrated to encryption (#19396)
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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]))
|
||||
|
||||
+41
-6
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user