Trim the noise glue and drive the source filter from the define

This commit is contained in:
J. Nick Koston
2026-09-05 11:28:00 +02:00
parent 029f6d4bc3
commit 8d60f03ff3
9 changed files with 119 additions and 258 deletions
+34 -80
View File
@@ -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
+29 -80
View File
@@ -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
+14 -23
View File
@@ -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