|
|
|
@@ -2,6 +2,7 @@
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from collections.abc import Callable
|
|
|
|
|
import logging
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
@@ -14,6 +15,7 @@ from esphome.components.esphome.ota import (
|
|
|
|
|
_validate_no_password_with_encryption,
|
|
|
|
|
ota_esphome_final_validate,
|
|
|
|
|
)
|
|
|
|
|
from esphome.components.noise import static_encryption_key
|
|
|
|
|
from esphome.const import (
|
|
|
|
|
CONF_API,
|
|
|
|
|
CONF_ENCRYPTION,
|
|
|
|
@@ -115,7 +117,6 @@ def test_non_esphome_ota_unaffected() -> None:
|
|
|
|
|
|
|
|
|
|
API_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
|
|
|
|
OTHER_KEY = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="
|
|
|
|
|
ZEROS_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_encryption_key_inherited_from_api() -> None:
|
|
|
|
@@ -197,36 +198,6 @@ def test_encryption_without_any_key_rejected() -> None:
|
|
|
|
|
fv.full_config.reset(token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_encryption_explicit_all_zeros_key_rejected() -> None:
|
|
|
|
|
"""The all-zeros key is the provisioning sentinel; the device would treat
|
|
|
|
|
it as no PSK and accept plaintext, so it must fail validation."""
|
|
|
|
|
full_conf = {
|
|
|
|
|
CONF_OTA: [
|
|
|
|
|
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: ZEROS_KEY}})
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
token = fv.full_config.set(full_conf)
|
|
|
|
|
try:
|
|
|
|
|
with pytest.raises(cv.Invalid, match="all-zeros key is reserved"):
|
|
|
|
|
ota_esphome_final_validate({})
|
|
|
|
|
finally:
|
|
|
|
|
fv.full_config.reset(token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_encryption_inherited_all_zeros_key_rejected() -> None:
|
|
|
|
|
"""An all-zeros api key must not silently disable ota encryption either."""
|
|
|
|
|
full_conf = {
|
|
|
|
|
CONF_API: {CONF_ENCRYPTION: {CONF_KEY: ZEROS_KEY}},
|
|
|
|
|
CONF_OTA: [_make_ota_config(port=3232, **{CONF_ENCRYPTION: {}})],
|
|
|
|
|
}
|
|
|
|
|
token = fv.full_config.set(full_conf)
|
|
|
|
|
try:
|
|
|
|
|
with pytest.raises(cv.Invalid, match="all-zeros key is reserved"):
|
|
|
|
|
ota_esphome_final_validate({})
|
|
|
|
|
finally:
|
|
|
|
|
fv.full_config.reset(token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_encryption_key_mismatch_between_merged_configs_rejected() -> None:
|
|
|
|
|
"""Same-port configs with different encryption keys raise."""
|
|
|
|
|
full_conf = {
|
|
|
|
@@ -295,13 +266,14 @@ def test_encryption_explicit_key_with_runtime_provisioned_api_accepted() -> None
|
|
|
|
|
fv.full_config.reset(token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("component", ["web_server", "prometheus"])
|
|
|
|
|
def test_encryption_with_web_server_ota_warns(
|
|
|
|
|
caplog: pytest.LogCaptureFixture,
|
|
|
|
|
caplog: pytest.LogCaptureFixture, component: str
|
|
|
|
|
) -> None:
|
|
|
|
|
"""With the web_server component the plaintext /update endpoint is always
|
|
|
|
|
on; the combination validates with a warning."""
|
|
|
|
|
"""web_server and prometheus keep the shared listener up, so the
|
|
|
|
|
plaintext /update endpoint is always on and the combination warns."""
|
|
|
|
|
full_conf = {
|
|
|
|
|
"web_server": {},
|
|
|
|
|
component: {},
|
|
|
|
|
CONF_OTA: [
|
|
|
|
|
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}),
|
|
|
|
|
{CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)},
|
|
|
|
@@ -316,12 +288,12 @@ def test_encryption_with_web_server_ota_warns(
|
|
|
|
|
fv.full_config.reset(token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_encryption_with_captive_portal_web_server_ota_warns(
|
|
|
|
|
def test_encryption_with_captive_portal_does_not_warn(
|
|
|
|
|
caplog: pytest.LogCaptureFixture,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""captive_portal auto-loads the web_server ota platform without the
|
|
|
|
|
web_server component; encryption stays usable and only warns, so the
|
|
|
|
|
fallback AP recovery path is not lost."""
|
|
|
|
|
web_server component; its endpoint only exists while the fallback AP is
|
|
|
|
|
active and is the intended recovery path, so there is no warning."""
|
|
|
|
|
full_conf = {
|
|
|
|
|
"captive_portal": {},
|
|
|
|
|
CONF_OTA: [
|
|
|
|
@@ -333,7 +305,10 @@ def test_encryption_with_captive_portal_web_server_ota_warns(
|
|
|
|
|
try:
|
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
|
|
|
ota_esphome_final_validate({})
|
|
|
|
|
assert any("captive_portal" in record.message for record in caplog.records)
|
|
|
|
|
assert not any(
|
|
|
|
|
"OTA encryption does not cover" in record.message
|
|
|
|
|
for record in caplog.records
|
|
|
|
|
)
|
|
|
|
|
esphome_conf = next(
|
|
|
|
|
conf
|
|
|
|
|
for conf in fv.full_config.get()[CONF_OTA]
|
|
|
|
@@ -344,6 +319,100 @@ def test_encryption_with_captive_portal_web_server_ota_warns(
|
|
|
|
|
fv.full_config.reset(token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_password_with_api_key_warns(caplog: pytest.LogCaptureFixture) -> None:
|
|
|
|
|
"""A static api key makes the device offer encryption and the CLI take
|
|
|
|
|
it, so the password is dead weight; the config validates with a warning."""
|
|
|
|
|
full_conf = {
|
|
|
|
|
CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}},
|
|
|
|
|
CONF_OTA: [_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"})],
|
|
|
|
|
}
|
|
|
|
|
token = fv.full_config.set(full_conf)
|
|
|
|
|
try:
|
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
|
|
|
ota_esphome_final_validate({})
|
|
|
|
|
assert any("wastes significant flash" in r.message for r in caplog.records)
|
|
|
|
|
finally:
|
|
|
|
|
fv.full_config.reset(token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_password_with_runtime_api_key_warns_differently(
|
|
|
|
|
caplog: pytest.LogCaptureFixture,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""The CLI still needs the password, but the provisioned key also
|
|
|
|
|
authenticates uploads; the warning says so without the flash advice."""
|
|
|
|
|
full_conf = {
|
|
|
|
|
CONF_API: {CONF_ENCRYPTION: {}},
|
|
|
|
|
CONF_OTA: [_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"})],
|
|
|
|
|
}
|
|
|
|
|
token = fv.full_config.set(full_conf)
|
|
|
|
|
try:
|
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
|
|
|
ota_esphome_final_validate({})
|
|
|
|
|
messages = [r.message for r in caplog.records]
|
|
|
|
|
assert any("provisioned at runtime also authenticates" in m for m in messages)
|
|
|
|
|
assert not any("wastes significant flash" in m for m in messages)
|
|
|
|
|
finally:
|
|
|
|
|
fv.full_config.reset(token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_password_without_api_key_no_warning(
|
|
|
|
|
caplog: pytest.LogCaptureFixture,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Without an api key there is no offer, so nothing to warn about."""
|
|
|
|
|
full_conf = {
|
|
|
|
|
CONF_API: {},
|
|
|
|
|
CONF_OTA: [_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"})],
|
|
|
|
|
}
|
|
|
|
|
token = fv.full_config.set(full_conf)
|
|
|
|
|
try:
|
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
|
|
|
ota_esphome_final_validate({})
|
|
|
|
|
assert not any("authenticates" in r.message for r in caplog.records)
|
|
|
|
|
finally:
|
|
|
|
|
fv.full_config.reset(token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_web_server_component_without_ota_platform_does_not_warn(
|
|
|
|
|
caplog: pytest.LogCaptureFixture,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""The web_server component alone has no /update endpoint."""
|
|
|
|
|
full_conf = {
|
|
|
|
|
"web_server": {},
|
|
|
|
|
CONF_OTA: [
|
|
|
|
|
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}})
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
token = fv.full_config.set(full_conf)
|
|
|
|
|
try:
|
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
|
|
|
ota_esphome_final_validate({})
|
|
|
|
|
assert not any(
|
|
|
|
|
"OTA encryption does not cover" in r.message for r in caplog.records
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
fv.full_config.reset(token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_web_server_ota_platform_alone_does_not_warn(
|
|
|
|
|
caplog: pytest.LogCaptureFixture,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Only the web_server component starts the shared listener, so the ota
|
|
|
|
|
platform on its own never exposes /update."""
|
|
|
|
|
full_conf = {
|
|
|
|
|
CONF_OTA: [
|
|
|
|
|
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}),
|
|
|
|
|
{CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
token = fv.full_config.set(full_conf)
|
|
|
|
|
try:
|
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
|
|
|
ota_esphome_final_validate({})
|
|
|
|
|
assert not any("plaintext /update" in r.message for r in caplog.records)
|
|
|
|
|
finally:
|
|
|
|
|
fv.full_config.reset(token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_web_server_ota_without_encryption_unaffected() -> None:
|
|
|
|
|
"""web_server ota stays valid alongside an unencrypted esphome entry."""
|
|
|
|
|
full_conf = {
|
|
|
|
@@ -370,20 +439,87 @@ def test_auto_load_pulls_noise_only_for_encryption() -> None:
|
|
|
|
|
assert "noise" in AUTO_LOAD({})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_filter_source_files_excludes_noise_without_encryption() -> None:
|
|
|
|
|
"""The noise transport source compiles only for encrypted builds."""
|
|
|
|
|
old_config = CORE.config
|
|
|
|
|
try:
|
|
|
|
|
CORE.config = {CONF_OTA: [_make_ota_config(port=3232)]}
|
|
|
|
|
assert FILTER_SOURCE_FILES() == ["ota_esphome_noise.cpp"]
|
|
|
|
|
CORE.config = {
|
|
|
|
|
CONF_OTA: [
|
|
|
|
|
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: API_KEY}})
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
assert FILTER_SOURCE_FILES() == []
|
|
|
|
|
finally:
|
|
|
|
|
CORE.config = old_config
|
|
|
|
|
def test_static_encryption_key() -> None:
|
|
|
|
|
"""Only a build-time key counts; a runtime provisioned one does not."""
|
|
|
|
|
assert static_encryption_key({}) is None
|
|
|
|
|
assert static_encryption_key({CONF_ENCRYPTION: {}}) is None
|
|
|
|
|
assert static_encryption_key({CONF_ENCRYPTION: {CONF_KEY: API_KEY}}) == API_KEY
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
("yaml_name", "defines_present", "defines_absent"),
|
|
|
|
|
[
|
|
|
|
|
# An api key alone compiles the transport in without requiring it;
|
|
|
|
|
# the device uses the api server's key, not a copy
|
|
|
|
|
(
|
|
|
|
|
"api_key_offer",
|
|
|
|
|
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API"},
|
|
|
|
|
{"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"},
|
|
|
|
|
),
|
|
|
|
|
# A password still guards plaintext uploads on an offering device
|
|
|
|
|
(
|
|
|
|
|
"api_key_offer_password",
|
|
|
|
|
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_PASSWORD"},
|
|
|
|
|
{"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"},
|
|
|
|
|
),
|
|
|
|
|
# The ota encryption block is what makes the device refuse plaintext
|
|
|
|
|
(
|
|
|
|
|
"encryption_required",
|
|
|
|
|
{
|
|
|
|
|
"USE_OTA_ENCRYPTION",
|
|
|
|
|
"USE_OTA_ENCRYPTION_REQUIRED",
|
|
|
|
|
"USE_OTA_ENCRYPTION_FROM_API",
|
|
|
|
|
},
|
|
|
|
|
{"USE_OTA_ENCRYPTION_PROVISIONED"},
|
|
|
|
|
),
|
|
|
|
|
# Without api encryption the ota key is the device's own
|
|
|
|
|
(
|
|
|
|
|
"own_key",
|
|
|
|
|
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"},
|
|
|
|
|
{"USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_ENCRYPTION_PROVISIONED"},
|
|
|
|
|
),
|
|
|
|
|
# A key provisioned at runtime lives in the api server; the device
|
|
|
|
|
# offers with it once provisioned and never requires it
|
|
|
|
|
(
|
|
|
|
|
"runtime_api_key",
|
|
|
|
|
{
|
|
|
|
|
"USE_OTA_ENCRYPTION",
|
|
|
|
|
"USE_OTA_ENCRYPTION_FROM_API",
|
|
|
|
|
"USE_OTA_ENCRYPTION_PROVISIONED",
|
|
|
|
|
},
|
|
|
|
|
{"USE_OTA_ENCRYPTION_REQUIRED"},
|
|
|
|
|
),
|
|
|
|
|
# No api encryption at all keeps the noise glue out of the build
|
|
|
|
|
(
|
|
|
|
|
"plain",
|
|
|
|
|
set(),
|
|
|
|
|
{
|
|
|
|
|
"USE_OTA_ENCRYPTION",
|
|
|
|
|
"USE_OTA_ENCRYPTION_REQUIRED",
|
|
|
|
|
"USE_OTA_ENCRYPTION_FROM_API",
|
|
|
|
|
"USE_OTA_ENCRYPTION_PROVISIONED",
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
def test_encryption_offer_codegen(
|
|
|
|
|
generate_main: Callable[[str], str],
|
|
|
|
|
yaml_name: str,
|
|
|
|
|
defines_present: set[str],
|
|
|
|
|
defines_absent: set[str],
|
|
|
|
|
) -> None:
|
|
|
|
|
main_cpp = generate_main(
|
|
|
|
|
f"tests/component_tests/ota/test_esphome_ota_{yaml_name}.yaml"
|
|
|
|
|
)
|
|
|
|
|
defines = {define.name for define in CORE.defines}
|
|
|
|
|
assert defines_present <= defines
|
|
|
|
|
assert not (defines_absent & defines)
|
|
|
|
|
encrypted = "USE_OTA_ENCRYPTION" in defines_present
|
|
|
|
|
own_key = encrypted and "USE_OTA_ENCRYPTION_FROM_API" not in defines_present
|
|
|
|
|
assert ("esphome_esphomeotacomponent_id->set_noise_psk(" in main_cpp) is own_key
|
|
|
|
|
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"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_password_with_encryption_rejected() -> None:
|
|
|
|
|