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
+4 -5
View File
@@ -147,11 +147,10 @@ The following are **not** vulnerabilities, by design:
that offers encryption because it has a static api key. That is the
documented default, authenticated (if at all) by the OTA password. An
uploader that takes the offer skips the password; the key authenticates it.
- The enablement window: requiring encryption needs a running firmware that
offers it, which every build with a static api key does. Older firmware
takes one last upload of the offering firmware over the existing plaintext
channel, with the pre-existing plaintext exposure; the upload that turns on
`ota: encryption:` is then already encrypted.
- The enablement window: firmware built with a static api key already offers
encryption, so turning on `ota: encryption:` is itself an encrypted upload.
Older firmware needs one last plaintext upload of an offering build, with
the pre-existing plaintext exposure.
- The web OTA `/update` endpoint alongside encryption. The `web_server`
component keeps it always reachable, and `captive_portal:` auto-loads it
for the fallback AP window; validation warns about both combinations, and
+8 -22
View File
@@ -7,7 +7,7 @@ from esphome.components.noise import (
is_reserved_key,
)
from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code
from esphome.config_helpers import merge_config
from esphome.config_helpers import filter_source_files_from_defines, merge_config
import esphome.config_validation as cv
from esphome.const import (
CONF_API,
@@ -174,12 +174,8 @@ def _warn_web_server_ota(full_conf: ConfigType) -> None:
def _api_static_key(api_conf: ConfigType) -> str | None:
"""The api encryption key when it is fixed at build time.
A keyless api block provisions its key at runtime and the all-zeros key is
the unprovisioned sentinel (the device treats it as no key); neither can
seed the OTA encryption offer.
"""
"""The api key when fixed at build time; None for a runtime provisioned
or all-zeros key, neither can seed the encryption offer."""
key = api_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY)
if not key or is_reserved_key(key):
return None
@@ -280,18 +276,9 @@ CONFIG_SCHEMA = cv.All(
FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate
def FILTER_SOURCE_FILES() -> list[str]:
"""Filter out the noise transport unless an ota entry configures
encryption or the api has a static key to offer."""
if _api_static_key(CORE.config.get(CONF_API) or {}) is not None:
return []
for ota_conf in CORE.config.get(CONF_OTA, []):
if (
ota_conf.get(CONF_PLATFORM) == CONF_ESPHOME
and ota_conf.get(CONF_ENCRYPTION) is not None
):
return []
return ["ota_esphome_noise.cpp"]
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{"ota_esphome_noise.cpp": "USE_OTA_ENCRYPTION"}
)
@coroutine_with_priority(CoroPriority.OTA_UPDATES)
@@ -317,9 +304,8 @@ async def to_code(config: ConfigType) -> None:
key = encryption_conf[CONF_KEY]
cg.add_define("USE_OTA_ENCRYPTION_REQUIRED")
else:
# With only an api key the device offers encryption but still accepts
# plaintext, so a firmware that predates the ota block can be replaced
# over OTA and the next upload with the block is encrypted.
# An api key alone makes the device offer encryption while still
# accepting plaintext, so the upload that adds the block is encrypted
key = _api_static_key(CORE.config.get(CONF_API) or {})
if key is not None:
cg.add_define("USE_OTA_ENCRYPTION")
+14 -22
View File
@@ -97,22 +97,19 @@ void ESPHomeOTAComponent::dump_config() {
ESP_LOGCONFIG(TAG,
"Over-The-Air updates:\n"
" Address: %s:%u\n"
" Version: %d",
" Version: %d"
#ifdef USE_OTA_ENCRYPTION_REQUIRED
"\n Encryption: required"
#elif defined(USE_OTA_ENCRYPTION)
"\n Encryption: offered, plaintext accepted"
#endif
,
network::get_use_address_to(addr_buf), this->port_, USE_OTA_VERSION);
#ifdef USE_OTA_PASSWORD
if (!this->password_.empty()) {
ESP_LOGCONFIG(TAG, " Password configured");
}
#endif
#ifdef USE_OTA_ENCRYPTION
if (this->noise_ctx_.has_psk()) {
#ifdef USE_OTA_ENCRYPTION_REQUIRED
ESP_LOGCONFIG(TAG, " Encryption: required");
#else
ESP_LOGCONFIG(TAG, " Encryption: offered (api key), plaintext accepted");
#endif
}
#endif
#ifdef USE_OTA_PARTITIONS
ESP_LOGCONFIG(TAG,
" Partition access allowed\n"
@@ -252,9 +249,8 @@ void ESPHomeOTAComponent::handle_handshake_() {
#ifdef USE_OTA_ENCRYPTION_REQUIRED
// Fail closed: an explicit `ota: encryption:` block means the client must
// negotiate encryption; refuse plaintext uploads. A build with only an api
// key offers encryption but lets plaintext through.
if (this->noise_ctx_.has_psk() && (this->ota_features_ & CLIENT_NOISE_FEATURES) != CLIENT_NOISE_FEATURES) {
// negotiate encryption; refuse plaintext uploads
if ((this->ota_features_ & CLIENT_NOISE_FEATURES) != CLIENT_NOISE_FEATURES) {
ESP_LOGW(TAG, "Client does not support encryption");
this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED);
return;
@@ -278,9 +274,7 @@ void ESPHomeOTAComponent::handle_handshake_() {
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS;
#endif
#ifdef USE_OTA_ENCRYPTION
if (this->noise_ctx_.has_psk()) {
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE;
}
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE;
#endif
} else {
this->handshake_buf_[0] =
@@ -297,12 +291,10 @@ void ESPHomeOTAComponent::handle_handshake_() {
return;
}
#ifdef USE_OTA_ENCRYPTION
// When the client took the encryption offer the rest of the session runs
// inside the noise transport, which also authenticates it; the client
// sends the first handshake frame next, so there is nothing to do until
// data arrives. A client without both noise bits continues in plaintext
// (already rejected above when encryption is required).
if (this->noise_ctx_.has_psk() && (this->ota_features_ & CLIENT_NOISE_FEATURES) == CLIENT_NOISE_FEATURES) {
// The client took the encryption offer: the rest of the session runs
// inside the noise transport, which also authenticates it. Nothing to
// do until its first handshake frame arrives.
if ((this->ota_features_ & CLIENT_NOISE_FEATURES) == CLIENT_NOISE_FEATURES) {
// handshake_buf_ still holds the feature ack composed above; a
// would-block re-entry lands here without rebuilding it
if (!this->noise_start_session_(this->handshake_buf_[1])) {
@@ -73,7 +73,7 @@ bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) {
int err = this->noise_->handshake.init(this->noise_ctx_.get_psk(), prologue, sizeof(prologue));
if (err != 0) {
ESP_LOGW(TAG, "Handshake init: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
ESP_LOGW(TAG, "Handshake init: %d", err);
this->cleanup_connection_();
return false;
}
@@ -105,14 +105,16 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() {
s.frame_pos = 0;
s.frame_len = 0;
if (s.frame_buf[noise::FRAME_HEADER_SIZE] != noise::HANDSHAKE_STATUS_OK) {
ESP_LOGW(TAG, "Bad handshake error byte: %u", s.frame_buf[noise::FRAME_HEADER_SIZE]);
ESP_LOGV(TAG, "Bad handshake error byte: %u", s.frame_buf[noise::FRAME_HEADER_SIZE]);
this->cleanup_connection_();
return false;
}
int err = s.handshake.read_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, payload_len - 1);
if (err != 0) {
ESP_LOGW(TAG, "Handshake read: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
this->noise_send_reject_(noise::reject_reason_for(err));
// A MAC failure here almost always means the uploader has a different key
const LogString *reason = noise::reject_reason_for(err);
ESP_LOGW(TAG, "Handshake read: %s (%d)", LOG_STR_ARG(reason), err);
this->noise_send_reject_(reason);
this->cleanup_connection_();
return false;
}
@@ -123,7 +125,7 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() {
int err =
s.handshake.write_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, noise::MAX_HANDSHAKE_SIZE, msg_len);
if (err != 0) {
ESP_LOGW(TAG, "Handshake write: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
ESP_LOGW(TAG, "Handshake write: %d", err);
this->cleanup_connection_();
return false;
}
@@ -138,7 +140,7 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() {
case noise::NoiseResponderHandshake::Action::ACTION_SPLIT: {
int err = s.handshake.split(s.send_cipher, s.recv_cipher);
if (err != 0) {
ESP_LOGW(TAG, "Handshake split: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
ESP_LOGW(TAG, "Handshake split: %d", err);
this->cleanup_connection_();
return false;
}
@@ -146,7 +148,7 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() {
return true;
}
default: {
ESP_LOGW(TAG, "Bad handshake state");
ESP_LOGV(TAG, "Bad handshake state");
this->cleanup_connection_();
return false;
}
@@ -159,7 +161,7 @@ bool ESPHomeOTAComponent::noise_try_read_frame_() {
NoiseSession &s = *this->noise_;
while (s.frame_pos < noise::FRAME_HEADER_SIZE) {
ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, noise::FRAME_HEADER_SIZE - s.frame_pos);
if (!this->handle_read_error_(read, LOG_STR("read noise header"))) {
if (!this->handle_read_error_(read, LOG_STR("read noise frame"))) {
return false;
}
s.frame_pos += read;
@@ -214,7 +216,7 @@ ssize_t ESPHomeOTAComponent::noise_decrypt_(uint8_t *buf, size_t len) {
noise_buffer_set_inout(mbuf, buf, len, len);
int err = noise_cipherstate_decrypt(this->noise_->recv_cipher, &mbuf);
if (err != 0) {
ESP_LOGW(TAG, "Decrypt: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
ESP_LOGW(TAG, "Decrypt: %d", err);
return -1;
}
return mbuf.size;
@@ -267,7 +269,7 @@ bool ESPHomeOTAComponent::noise_write_byte_(uint8_t byte) {
noise_buffer_set_inout(mbuf, frame + noise::FRAME_HEADER_SIZE, 1, 1 + noise::MAC_SIZE);
int err = noise_cipherstate_encrypt(this->noise_->send_cipher, &mbuf);
if (err != 0) {
ESP_LOGW(TAG, "Encrypt: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
ESP_LOGW(TAG, "Encrypt: %d", err);
return false;
}
noise::write_frame_header(frame, mbuf.size);
+4 -5
View File
@@ -539,11 +539,10 @@ def perform_ota(
"offer encryption; refusing to send the image in plaintext. "
"The running firmware was built before OTA encryption "
"(ESPHome 2026.9.0) or without an 'api: encryption: key'. "
"Install once with the 'ota: encryption:' block removed and "
"the api key kept, that firmware offers encryption, then "
"restore the block and install again; every install after "
"that is encrypted. Use the web_server OTA platform or a "
"serial flash if the device cannot be reached that way."
"Firmware built with an api key offers encryption: install "
"once with the 'ota: encryption:' block removed, then restore "
"the block and install again. Otherwise flash by serial or "
"the web_server OTA platform."
)
# The prologue binds every negotiation byte both sides saw, so any
# tampering with the plaintext preamble breaks the handshake.
+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