mirror of
https://github.com/esphome/esphome.git
synced 2026-09-14 00:28:39 +00:00
Trim the noise glue and drive the source filter from the define
This commit is contained in:
@@ -372,44 +372,6 @@ 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_filter_source_files_keeps_noise_for_static_api_key() -> None:
|
||||
"""A static api key makes the device offer encryption, so the transport
|
||||
compiles even without an ota encryption block."""
|
||||
old_config = CORE.config
|
||||
ota = [_make_ota_config(port=3232)]
|
||||
try:
|
||||
CORE.config = {CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}}, CONF_OTA: ota}
|
||||
assert FILTER_SOURCE_FILES() == []
|
||||
# A runtime provisioned or all-zeros api key has nothing to offer
|
||||
CORE.config = {CONF_API: {CONF_ENCRYPTION: {}}, CONF_OTA: ota}
|
||||
assert FILTER_SOURCE_FILES() == ["ota_esphome_noise.cpp"]
|
||||
CORE.config = {
|
||||
CONF_API: {CONF_ENCRYPTION: {CONF_KEY: ZEROS_KEY}},
|
||||
CONF_OTA: ota,
|
||||
}
|
||||
assert FILTER_SOURCE_FILES() == ["ota_esphome_noise.cpp"]
|
||||
CORE.config = {CONF_API: {}, CONF_OTA: ota}
|
||||
assert FILTER_SOURCE_FILES() == ["ota_esphome_noise.cpp"]
|
||||
finally:
|
||||
CORE.config = old_config
|
||||
|
||||
|
||||
def test_api_static_key() -> None:
|
||||
"""Only a real build-time api key can seed the encryption offer."""
|
||||
assert _api_static_key({}) is None
|
||||
@@ -418,52 +380,44 @@ def test_api_static_key() -> None:
|
||||
assert _api_static_key({CONF_ENCRYPTION: {CONF_KEY: API_KEY}}) == API_KEY
|
||||
|
||||
|
||||
def _defines() -> set[str]:
|
||||
return {define.name for define in CORE.defines}
|
||||
|
||||
|
||||
def test_api_key_offers_encryption_without_requiring_it(
|
||||
@pytest.mark.parametrize(
|
||||
("yaml_name", "defines_present", "defines_absent"),
|
||||
[
|
||||
# An api key alone compiles the transport in without requiring it
|
||||
("api_key_offer", {"USE_OTA_ENCRYPTION"}, {"USE_OTA_ENCRYPTION_REQUIRED"}),
|
||||
# A password still guards plaintext uploads on an offering device
|
||||
(
|
||||
"api_key_offer_password",
|
||||
{"USE_OTA_ENCRYPTION", "USE_OTA_PASSWORD"},
|
||||
{"USE_OTA_ENCRYPTION_REQUIRED"},
|
||||
),
|
||||
# The ota encryption block is what makes the device refuse plaintext
|
||||
(
|
||||
"encryption_required",
|
||||
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"},
|
||||
set(),
|
||||
),
|
||||
# A key provisioned at runtime is unknown at build time, so no offer
|
||||
("runtime_api_key", set(), {"USE_OTA_ENCRYPTION"}),
|
||||
],
|
||||
)
|
||||
def test_encryption_offer_codegen(
|
||||
generate_main: Callable[[str], str],
|
||||
yaml_name: str,
|
||||
defines_present: set[str],
|
||||
defines_absent: set[str],
|
||||
) -> None:
|
||||
"""An api key alone compiles the transport in and sets the psk, but the
|
||||
device keeps accepting plaintext uploads."""
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/ota/test_esphome_ota_api_key_offer.yaml"
|
||||
f"tests/component_tests/ota/test_esphome_ota_{yaml_name}.yaml"
|
||||
)
|
||||
assert "USE_OTA_ENCRYPTION" in _defines()
|
||||
assert "USE_OTA_ENCRYPTION_REQUIRED" not in _defines()
|
||||
assert "set_noise_psk(" in main_cpp
|
||||
|
||||
|
||||
def test_api_key_offer_keeps_password(generate_main: Callable[[str], str]) -> None:
|
||||
"""A password still guards plaintext uploads on an offering device."""
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/ota/test_esphome_ota_api_key_offer_password.yaml"
|
||||
)
|
||||
assert {"USE_OTA_ENCRYPTION", "USE_OTA_PASSWORD"} <= _defines()
|
||||
assert "USE_OTA_ENCRYPTION_REQUIRED" not in _defines()
|
||||
assert "set_noise_psk(" in main_cpp
|
||||
assert "set_auth_password(" in main_cpp
|
||||
|
||||
|
||||
def test_encryption_block_requires_encryption(
|
||||
generate_main: Callable[[str], str],
|
||||
) -> None:
|
||||
"""The ota encryption block is what makes the device refuse plaintext."""
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/ota/test_esphome_ota_encryption_required.yaml"
|
||||
)
|
||||
assert {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"} <= _defines()
|
||||
assert "set_noise_psk(" in main_cpp
|
||||
|
||||
|
||||
def test_runtime_api_key_offers_nothing(generate_main: Callable[[str], str]) -> None:
|
||||
"""A key provisioned at runtime is unknown at build time, so no offer."""
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/ota/test_esphome_ota_runtime_api_key.yaml"
|
||||
)
|
||||
assert "USE_OTA_ENCRYPTION" not in _defines()
|
||||
assert "set_noise_psk(" not in main_cpp
|
||||
defines = {define.name for define in CORE.defines}
|
||||
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 ("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:
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
esphome:
|
||||
name: host-ota-test
|
||||
host:
|
||||
api:
|
||||
encryption:
|
||||
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
ota:
|
||||
- platform: esphome
|
||||
port: __OTA_PORT__
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -11,6 +11,7 @@ import asyncio
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
import functools
|
||||
from pathlib import Path
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
@@ -22,6 +23,7 @@ from .const import LOCALHOST, PORT_POLL_INTERVAL, PORT_WAIT_TIMEOUT
|
||||
from .types import CompileFunction, ConfigWriter
|
||||
|
||||
DEVICE_NAME = "host-ota-test"
|
||||
API_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -121,7 +123,6 @@ async def test_host_ota_encrypted(
|
||||
) -> None:
|
||||
"""Encrypted self-OTA succeeds; a plaintext upload to the same device fails."""
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
noise_psk = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
api_port, api_socket = reserved_tcp_port
|
||||
with _reserve_port() as (ota_port, ota_socket):
|
||||
yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port))
|
||||
@@ -158,7 +159,7 @@ async def test_host_ota_encrypted(
|
||||
ota_port,
|
||||
None,
|
||||
binary_path,
|
||||
noise_psk=noise_psk,
|
||||
noise_psk=API_KEY,
|
||||
),
|
||||
)
|
||||
assert rc == 0, "encrypted OTA reported failure"
|
||||
@@ -187,61 +188,22 @@ class _RebootCounter:
|
||||
await self._seen.wait()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_ota_api_key_offers_encryption(
|
||||
yaml_config: str,
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
) -> None:
|
||||
"""With only an api key the device takes both a plaintext upload and an
|
||||
encrypted one using that key, which is the enablement path for
|
||||
`ota: encryption:`."""
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
api_key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
api_port, api_socket = reserved_tcp_port
|
||||
with _reserve_port() as (ota_port, ota_socket):
|
||||
yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port))
|
||||
config_path = await write_yaml_config(yaml_config)
|
||||
binary_path = await compile_esphome(config_path)
|
||||
api_socket.close()
|
||||
ota_socket.close()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
reboots = _RebootCounter()
|
||||
|
||||
async with run_binary(binary_path, line_callback=reboots.on_log) as (
|
||||
proc,
|
||||
lines,
|
||||
):
|
||||
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
|
||||
pid_before = proc.pid
|
||||
|
||||
rc, _ = await loop.run_in_executor(
|
||||
None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path
|
||||
)
|
||||
assert rc == 0, "plaintext upload to an offering device must succeed"
|
||||
await reboots.wait(1)
|
||||
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
|
||||
assert proc.pid == pid_before
|
||||
|
||||
rc, _ = await loop.run_in_executor(
|
||||
None,
|
||||
functools.partial(
|
||||
espota2.run_ota,
|
||||
LOCALHOST,
|
||||
ota_port,
|
||||
None,
|
||||
binary_path,
|
||||
noise_psk=api_key,
|
||||
),
|
||||
)
|
||||
assert rc == 0, "encrypted upload with the api key must succeed"
|
||||
await reboots.wait(2)
|
||||
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
|
||||
assert proc.returncode is None, "process exited instead of execing"
|
||||
assert proc.pid == pid_before
|
||||
assert any("Encryption: offered" in line for line in lines)
|
||||
async def _run_ota(
|
||||
ota_port: int, password: str | None, binary_path: Path, noise_psk: str | None
|
||||
) -> int:
|
||||
"""espota2 is blocking; run it in the executor and return its exit code."""
|
||||
rc, _ = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
functools.partial(
|
||||
espota2.run_ota,
|
||||
LOCALHOST,
|
||||
ota_port,
|
||||
password,
|
||||
binary_path,
|
||||
noise_psk=noise_psk,
|
||||
),
|
||||
)
|
||||
return rc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -251,10 +213,11 @@ async def test_host_ota_api_key_offer_with_password(
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
) -> None:
|
||||
"""The OTA password still guards plaintext uploads on an offering device
|
||||
while the api key alone authenticates an encrypted one."""
|
||||
"""With only an api key the device offers encryption without requiring
|
||||
it: the password still guards plaintext uploads, and the key alone
|
||||
authenticates an encrypted one, which is the enablement path for
|
||||
`ota: encryption:`."""
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
api_key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
api_port, api_socket = reserved_tcp_port
|
||||
with _reserve_port() as (ota_port, ota_socket):
|
||||
yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port))
|
||||
@@ -263,46 +226,32 @@ async def test_host_ota_api_key_offer_with_password(
|
||||
api_socket.close()
|
||||
ota_socket.close()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
reboots = _RebootCounter()
|
||||
|
||||
async with run_binary(binary_path, line_callback=reboots.on_log) as (
|
||||
proc,
|
||||
_lines,
|
||||
lines,
|
||||
):
|
||||
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
|
||||
pid_before = proc.pid
|
||||
|
||||
rc, _ = await loop.run_in_executor(
|
||||
None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path
|
||||
)
|
||||
rc = await _run_ota(ota_port, None, binary_path, None)
|
||||
assert rc == 1, "plaintext upload without the password must fail"
|
||||
await asyncio.sleep(0.5)
|
||||
assert proc.returncode is None, "process died on rejected upload"
|
||||
|
||||
rc, _ = await loop.run_in_executor(
|
||||
None, espota2.run_ota, LOCALHOST, ota_port, "hunter2", binary_path
|
||||
)
|
||||
rc = await _run_ota(ota_port, "hunter2", binary_path, None)
|
||||
assert rc == 0, "plaintext upload with the password must succeed"
|
||||
await reboots.wait(1)
|
||||
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
|
||||
assert proc.pid == pid_before
|
||||
|
||||
rc, _ = await loop.run_in_executor(
|
||||
None,
|
||||
functools.partial(
|
||||
espota2.run_ota,
|
||||
LOCALHOST,
|
||||
ota_port,
|
||||
None,
|
||||
binary_path,
|
||||
noise_psk=api_key,
|
||||
),
|
||||
)
|
||||
rc = await _run_ota(ota_port, None, binary_path, API_KEY)
|
||||
assert rc == 0, "encrypted upload with the api key must succeed"
|
||||
await reboots.wait(2)
|
||||
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
|
||||
assert proc.returncode is None, "process exited instead of execing"
|
||||
assert proc.pid == pid_before
|
||||
assert any("Encryption: offered" in line for line in lines)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -110,17 +110,19 @@ class FakeEncryptedDevice(threading.Thread):
|
||||
return
|
||||
server_flags = espota2.SERVER_FEATURE_SUPPORTS_NOISE if self.offer_noise else 0
|
||||
sock.sendall(bytes([espota2.RESPONSE_FEATURE_FLAGS, server_flags]))
|
||||
if noise_negotiated and not self.offer_noise:
|
||||
return # the client fails closed; nothing further arrives
|
||||
if not noise_negotiated:
|
||||
# A device that does not require encryption lets a plaintext
|
||||
# client through
|
||||
self._transfer(
|
||||
lambda byte: sock.sendall(bytes([byte])),
|
||||
lambda length: _recv_exact(sock, length),
|
||||
lambda remaining: sock.recv(min(remaining, 4096)),
|
||||
lambda remaining: _recv_exact(
|
||||
sock, min(remaining, espota2.UPLOAD_BLOCK_SIZE)
|
||||
),
|
||||
)
|
||||
return
|
||||
if not self.offer_noise:
|
||||
return # the client fails closed; nothing further arrives
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from noise.connection import NoiseConnection
|
||||
@@ -159,7 +161,7 @@ class FakeEncryptedDevice(threading.Thread):
|
||||
assert len(plaintext) == length, "control units must be one per frame"
|
||||
return plaintext
|
||||
|
||||
def recv_data(remaining: int) -> bytes:
|
||||
def recv_data(_remaining: int) -> bytes:
|
||||
plaintext = proto.decrypt(_recv_frame(sock))
|
||||
assert 0 < len(plaintext) <= espota2.NOISE_MAX_PLAINTEXT
|
||||
return plaintext
|
||||
@@ -183,9 +185,7 @@ class FakeEncryptedDevice(threading.Thread):
|
||||
received = b""
|
||||
acked = 0
|
||||
while len(received) < size:
|
||||
chunk = recv_data(size - len(received))
|
||||
assert chunk, "client closed mid-transfer"
|
||||
received += chunk
|
||||
received += recv_data(size - len(received))
|
||||
if self.version >= espota2.OTA_VERSION_2_0:
|
||||
while acked + espota2.UPLOAD_BLOCK_SIZE <= len(received) or (
|
||||
len(received) == size and acked < size
|
||||
@@ -264,25 +264,16 @@ def test_client_fails_closed_when_device_lacks_encryption() -> None:
|
||||
device.join_and_check()
|
||||
|
||||
|
||||
def test_plaintext_client_accepted_by_offering_device() -> None:
|
||||
"""A device that offers but does not require encryption still takes a
|
||||
plaintext upload from a client with no key configured."""
|
||||
@pytest.mark.parametrize("noise_psk", [None, PSK], ids=["plaintext", "encrypted"])
|
||||
def test_offering_device_accepts_either_transport(noise_psk: str | None) -> None:
|
||||
"""A device that offers but does not require encryption takes a plaintext
|
||||
upload from a keyless client and an encrypted one from a keyed client."""
|
||||
if noise_psk:
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
firmware = bytes(range(256)) * 40
|
||||
device = FakeEncryptedDevice(offer_noise=True, require_noise=False)
|
||||
with patch("time.sleep"):
|
||||
_upload(device, firmware, None)
|
||||
device.join_and_check()
|
||||
assert device.received == firmware
|
||||
|
||||
|
||||
def test_keyed_client_encrypts_with_offering_device() -> None:
|
||||
"""The upload that turns on `ota: encryption:` is already encrypted when
|
||||
the running firmware offers it."""
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
firmware = bytes(range(256)) * 40
|
||||
device = FakeEncryptedDevice(offer_noise=True, require_noise=False)
|
||||
with patch("time.sleep"):
|
||||
_upload(device, firmware, PSK)
|
||||
_upload(device, firmware, noise_psk)
|
||||
device.join_and_check()
|
||||
assert device.received == firmware
|
||||
|
||||
|
||||
Reference in New Issue
Block a user