mirror of
https://github.com/esphome/esphome.git
synced 2026-09-11 15:27:33 +00:00
Keep the api and ota noise keys in flash behind one context
This commit is contained in:
@@ -14,6 +14,7 @@ from esphome.components.noise import ( # noqa: F401
|
||||
ENCRYPTION_SCHEMA,
|
||||
decode_encryption_key,
|
||||
encryption_schema,
|
||||
new_psk_progmem,
|
||||
validate_encryption_key,
|
||||
)
|
||||
from esphome.config_helpers import filter_source_files_from_defines, get_logger_level
|
||||
@@ -589,8 +590,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None:
|
||||
if key := encryption_config.get(CONF_KEY):
|
||||
decoded = decode_encryption_key(key)
|
||||
cg.add(var.set_noise_psk(list(decoded)))
|
||||
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key)))
|
||||
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
|
||||
else:
|
||||
# No key provided, but encryption desired
|
||||
|
||||
@@ -548,7 +548,7 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
|
||||
* @return 0 on success, -1 on error (check errno)
|
||||
*/
|
||||
APIError APINoiseFrameHelper::init_handshake_() {
|
||||
int err = this->handshake_.init(this->ctx_.get_psk(), prologue_.data(), prologue_.size());
|
||||
int err = this->handshake_.init(this->ctx_, prologue_.data(), prologue_.size());
|
||||
APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshake_init"), APIError::HANDSHAKESTATE_SETUP_FAILED);
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
@@ -583,11 +583,14 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
|
||||
}
|
||||
|
||||
bool APIServer::load_and_apply_noise_psk_() {
|
||||
SavedNoisePsk saved{};
|
||||
if (!this->noise_pref_.load(&saved))
|
||||
#ifdef USE_API_NOISE_PSK_FROM_YAML
|
||||
return false;
|
||||
#else
|
||||
if (!this->noise_pref_.load(&this->saved_psk_))
|
||||
return false;
|
||||
this->set_noise_psk(saved.psk);
|
||||
this->noise_ctx_.set_psk(this->saved_psk_.psk.data());
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool APIServer::save_noise_psk(noise::psk_t psk, bool make_active) {
|
||||
@@ -597,8 +600,7 @@ bool APIServer::save_noise_psk(noise::psk_t psk, bool make_active) {
|
||||
ESP_LOGW(TAG, "Key set in YAML");
|
||||
return false;
|
||||
#else
|
||||
auto &old_psk = this->noise_ctx_.get_psk();
|
||||
if (std::equal(old_psk.begin(), old_psk.end(), psk.begin())) {
|
||||
if (this->saved_psk_.psk == psk) {
|
||||
ESP_LOGW(TAG, "New PSK matches old");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,8 @@ class APIServer final : public Component,
|
||||
#ifdef USE_API_NOISE
|
||||
bool save_noise_psk(noise::psk_t psk, bool make_active = true);
|
||||
bool clear_noise_psk(bool make_active = true);
|
||||
void set_noise_psk(const noise::psk_t &psk) { this->noise_ctx_.set_psk(psk); }
|
||||
/// psk points at 32 bytes that live in flash for the life of the program
|
||||
void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); }
|
||||
noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; }
|
||||
#endif // USE_API_NOISE
|
||||
|
||||
@@ -358,6 +359,9 @@ class APIServer final : public Component,
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
noise::NoiseContext noise_ctx_;
|
||||
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||
SavedNoisePsk saved_psk_{}; // backs noise_ctx_ for a runtime provisioned key
|
||||
#endif
|
||||
ESPPreferenceObject noise_pref_;
|
||||
#endif // USE_API_NOISE
|
||||
};
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.noise import (
|
||||
decode_encryption_key,
|
||||
encryption_schema,
|
||||
is_reserved_key,
|
||||
)
|
||||
from esphome.components.noise import encryption_schema, is_reserved_key, new_psk_progmem
|
||||
from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code
|
||||
from esphome.config_helpers import filter_source_files_from_defines, merge_config
|
||||
import esphome.config_validation as cv
|
||||
@@ -25,7 +21,7 @@ from esphome.const import (
|
||||
CONF_VERSION,
|
||||
CONF_WEB_SERVER,
|
||||
)
|
||||
from esphome.core import CORE, ID, coroutine_with_priority
|
||||
from esphome.core import CORE, coroutine_with_priority
|
||||
from esphome.coroutine import CoroPriority
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
@@ -41,7 +37,8 @@ DEPENDENCIES = ["network"]
|
||||
|
||||
|
||||
def AUTO_LOAD(config: ConfigType) -> list[str]:
|
||||
"""Auto-load noise only when encryption is configured."""
|
||||
"""Auto-load noise only when encryption is configured. The api key offer
|
||||
path inherits noise from the api component's own AUTO_LOAD."""
|
||||
base = ["sha256", "socket"]
|
||||
# A falsy config is a tooling probe for the maximal set (None from
|
||||
# dependency resolution, {} from the components-graph platform probe);
|
||||
@@ -309,13 +306,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
key = _api_static_key(CORE.config.get(CONF_API) or {})
|
||||
if key is not None:
|
||||
cg.add_define("USE_OTA_ENCRYPTION")
|
||||
# The key stays in flash; it is copied into the handshake only while
|
||||
# an encrypted session is open
|
||||
psk = cg.progmem_array(
|
||||
ID(f"{config[CONF_ID].id}_psk", is_declaration=True, type=cg.uint8),
|
||||
list(decode_encryption_key(key)),
|
||||
)
|
||||
cg.add(var.set_noise_psk(psk))
|
||||
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key)))
|
||||
|
||||
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
|
||||
cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME")
|
||||
|
||||
@@ -46,7 +46,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
/// psk points at 32 bytes that live in flash for the life of the program
|
||||
void set_noise_psk(const uint8_t *psk) { this->noise_psk_ = psk; }
|
||||
void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); }
|
||||
#endif
|
||||
|
||||
/// Manually set the port OTA should listen on
|
||||
@@ -146,7 +146,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
std::unique_ptr<uint8_t[]> auth_buf_;
|
||||
#endif // USE_OTA_PASSWORD
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
const uint8_t *noise_psk_{nullptr};
|
||||
noise::NoiseContext noise_ctx_;
|
||||
std::unique_ptr<NoiseSession> noise_;
|
||||
#endif // USE_OTA_ENCRYPTION
|
||||
|
||||
|
||||
@@ -65,15 +65,8 @@ bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) {
|
||||
*p++ = ota::OTA_RESPONSE_FEATURE_FLAGS;
|
||||
*p++ = server_feature_flags;
|
||||
|
||||
// noise-c keeps its own copy of the key, so the flash copy is only read here
|
||||
noise::psk_t psk;
|
||||
#ifdef USE_ESP8266
|
||||
memcpy_P(psk.data(), this->noise_psk_, psk.size());
|
||||
#else
|
||||
std::memcpy(psk.data(), this->noise_psk_, psk.size());
|
||||
#endif
|
||||
int err =
|
||||
this->noise_ == nullptr ? NOISE_ERROR_NO_MEMORY : this->noise_->handshake.init(psk, prologue, sizeof(prologue));
|
||||
int err = this->noise_ == nullptr ? NOISE_ERROR_NO_MEMORY
|
||||
: this->noise_->handshake.init(this->noise_ctx_, prologue, sizeof(prologue));
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Session init: %d", err);
|
||||
this->cleanup_connection_();
|
||||
|
||||
@@ -5,6 +5,8 @@ from typing import Any
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_KEY
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
@@ -61,6 +63,15 @@ ENCRYPTION_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
def new_psk_progmem(parent_id: ID, key: str) -> MockObj:
|
||||
"""Emit the decoded key as a PROGMEM array; the component keeps a pointer
|
||||
so the key never occupies RAM."""
|
||||
return cg.progmem_array(
|
||||
ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8),
|
||||
list(decode_encryption_key(key)),
|
||||
)
|
||||
|
||||
|
||||
def encryption_schema(config: ConfigType | None) -> ConfigType:
|
||||
# A bare `encryption:` block is valid; a missing key means the consumer
|
||||
# falls back to its keyless behavior (api provisioning, ota inheriting
|
||||
|
||||
@@ -15,9 +15,25 @@ namespace esphome::noise {
|
||||
|
||||
static const char *const TAG = "noise";
|
||||
|
||||
void NoiseContext::set_psk(const psk_t &psk) {
|
||||
void NoiseContext::set_psk(const uint8_t *psk) {
|
||||
this->psk_ = psk;
|
||||
this->has_psk_ = !is_all_zeros(psk);
|
||||
psk_t copy;
|
||||
this->load_psk(copy);
|
||||
if (is_all_zeros(copy)) {
|
||||
this->psk_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void NoiseContext::load_psk(psk_t &out) const {
|
||||
if (this->psk_ == nullptr) {
|
||||
out.fill(0);
|
||||
return;
|
||||
}
|
||||
#ifdef USE_ESP8266
|
||||
memcpy_P(out.data(), this->psk_, out.size());
|
||||
#else
|
||||
std::memcpy(out.data(), this->psk_, out.size());
|
||||
#endif
|
||||
}
|
||||
|
||||
const LogString *noise_err_to_logstr(int err) {
|
||||
|
||||
@@ -23,13 +23,16 @@ class NoiseContext {
|
||||
}
|
||||
return acc == 0;
|
||||
}
|
||||
void set_psk(const psk_t &psk);
|
||||
const psk_t &get_psk() const { return this->psk_; }
|
||||
bool has_psk() const { return this->has_psk_; }
|
||||
/// psk points at 32 bytes that outlive the context: a PROGMEM array from
|
||||
/// codegen, or RAM owned by the caller for a runtime provisioned key.
|
||||
/// The all-zeros key counts as no key.
|
||||
void set_psk(const uint8_t *psk);
|
||||
/// Copy the key out (flash-aware on ESP8266); all zeros when none is set.
|
||||
void load_psk(psk_t &out) const;
|
||||
bool has_psk() const { return this->psk_ != nullptr; }
|
||||
|
||||
protected:
|
||||
psk_t psk_{};
|
||||
bool has_psk_{false};
|
||||
const uint8_t *psk_{nullptr};
|
||||
};
|
||||
|
||||
/// Convert a noise error code to a readable error
|
||||
|
||||
@@ -20,7 +20,7 @@ NoiseResponderHandshake::~NoiseResponderHandshake() {
|
||||
}
|
||||
}
|
||||
|
||||
int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len) {
|
||||
int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len) {
|
||||
if (this->handshake_ != nullptr) {
|
||||
noise_handshakestate_free(this->handshake_);
|
||||
this->handshake_ = nullptr;
|
||||
@@ -44,6 +44,9 @@ int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, siz
|
||||
HANDSHAKE_STEP_LOG("noise_handshakestate_new_by_id", err);
|
||||
return err;
|
||||
}
|
||||
// noise-c keeps its own copy, so the key only passes through the stack here
|
||||
psk_t psk;
|
||||
ctx.load_psk(psk);
|
||||
err = noise_handshakestate_set_pre_shared_key(this->handshake_, psk.data(), psk.size());
|
||||
if (err != 0) {
|
||||
HANDSHAKE_STEP_LOG("noise_handshakestate_set_pre_shared_key", err);
|
||||
|
||||
@@ -36,9 +36,9 @@ class NoiseResponderHandshake {
|
||||
NoiseResponderHandshake(const NoiseResponderHandshake &) = delete;
|
||||
NoiseResponderHandshake &operator=(const NoiseResponderHandshake &) = delete;
|
||||
|
||||
/// Create and start the handshake with the given PSK and prologue. A
|
||||
/// repeated call frees the previous handshake state and starts over.
|
||||
[[nodiscard]] int init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len);
|
||||
/// Create and start the handshake with the context's PSK and the prologue.
|
||||
/// A repeated call frees the previous handshake state and starts over.
|
||||
[[nodiscard]] int init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len);
|
||||
/// ACTION_FAILED is the catch-all: returned before init(), after split()
|
||||
/// has released the state, and when noise-c reports a failed handshake.
|
||||
[[nodiscard]] Action action() const;
|
||||
|
||||
+5
-4
@@ -539,10 +539,11 @@ 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'. "
|
||||
"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."
|
||||
"If the config has an 'api: encryption: key', install once "
|
||||
"with the 'ota: encryption:' block removed (that firmware "
|
||||
"offers encryption), 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.
|
||||
|
||||
@@ -68,6 +68,14 @@ class Initiator {
|
||||
|
||||
static const uint8_t PROLOGUE[] = {'t', 'e', 's', 't', 'p', 'r', 'o', 'l', 'o', 'g', 'u', 'e'};
|
||||
|
||||
// The context only points at the key and init() copies it before returning,
|
||||
// so a temporary context over a temporary key is safe within one call
|
||||
static NoiseContext ctx_for(const psk_t &psk) {
|
||||
NoiseContext ctx;
|
||||
ctx.set_psk(psk.data());
|
||||
return ctx;
|
||||
}
|
||||
|
||||
static psk_t make_psk(uint8_t seed) {
|
||||
psk_t psk;
|
||||
for (size_t i = 0; i < psk.size(); i++) {
|
||||
@@ -102,7 +110,7 @@ TEST(NoiseResponderHandshakeTest, MessageMethodsErrorBeforeInit) {
|
||||
TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) {
|
||||
const psk_t psk = make_psk(7);
|
||||
NoiseResponderHandshake responder;
|
||||
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
EXPECT_EQ(responder.action(), Action::ACTION_READ);
|
||||
|
||||
Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE));
|
||||
@@ -155,8 +163,8 @@ TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) {
|
||||
// proves the restart took effect; the old state surviving would fail the
|
||||
// MAC here.
|
||||
NoiseResponderHandshake responder;
|
||||
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
ASSERT_EQ(responder.init(make_psk(9), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
ASSERT_EQ(responder.init(ctx_for(make_psk(7)), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
ASSERT_EQ(responder.init(ctx_for(make_psk(9)), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
EXPECT_EQ(responder.action(), Action::ACTION_READ);
|
||||
|
||||
Initiator initiator(make_psk(9), PROLOGUE, sizeof(PROLOGUE));
|
||||
@@ -168,7 +176,7 @@ TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) {
|
||||
|
||||
TEST(NoiseResponderHandshakeTest, WrongPskFailsWithMacFailure) {
|
||||
NoiseResponderHandshake responder;
|
||||
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
ASSERT_EQ(responder.init(ctx_for(make_psk(7)), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
|
||||
Initiator initiator(make_psk(200), PROLOGUE, sizeof(PROLOGUE));
|
||||
uint8_t msg[MAX_HANDSHAKE_SIZE];
|
||||
@@ -185,7 +193,7 @@ TEST(NoiseResponderHandshakeTest, MismatchedPrologueFailsWithMacFailure) {
|
||||
// tampered preamble must fail even with the right key.
|
||||
const psk_t psk = make_psk(7);
|
||||
NoiseResponderHandshake responder;
|
||||
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
|
||||
static const uint8_t TAMPERED[] = {'x'};
|
||||
Initiator initiator(psk, TAMPERED, sizeof(TAMPERED));
|
||||
|
||||
@@ -17,12 +17,16 @@ TEST(NoiseContextTest, AllZerosPskIsReserved) {
|
||||
EXPECT_FALSE(NoiseContext::is_all_zeros(psk));
|
||||
|
||||
NoiseContext ctx;
|
||||
psk_t loaded;
|
||||
EXPECT_FALSE(ctx.has_psk());
|
||||
ctx.set_psk(zeros);
|
||||
ctx.load_psk(loaded);
|
||||
EXPECT_EQ(loaded, zeros);
|
||||
ctx.set_psk(zeros.data());
|
||||
EXPECT_FALSE(ctx.has_psk());
|
||||
ctx.set_psk(psk);
|
||||
ctx.set_psk(psk.data());
|
||||
EXPECT_TRUE(ctx.has_psk());
|
||||
EXPECT_EQ(ctx.get_psk(), psk);
|
||||
ctx.load_psk(loaded);
|
||||
EXPECT_EQ(loaded, psk);
|
||||
}
|
||||
|
||||
TEST(WireFormatTest, FrameHeaderIsIndicatorPlusBigEndianLength) {
|
||||
|
||||
@@ -143,25 +143,13 @@ async def test_host_ota_encrypted(
|
||||
pid_before = proc.pid
|
||||
|
||||
# A plaintext upload must be refused with the device unharmed
|
||||
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 to an encrypted device must fail"
|
||||
await asyncio.sleep(0.5)
|
||||
assert proc.returncode is None, "process died on rejected plaintext OTA"
|
||||
|
||||
# The encrypted upload goes through and the device re-execs
|
||||
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 OTA reported failure"
|
||||
await asyncio.wait_for(rebooted, timeout=10.0)
|
||||
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
|
||||
|
||||
Reference in New Issue
Block a user