Keep the api and ota noise keys in flash behind one context

This commit is contained in:
J. Nick Koston
2026-09-05 11:45:59 +02:00
parent 5c047ba485
commit fad229a7cb
16 changed files with 95 additions and 71 deletions
+2 -2
View File
@@ -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;
+7 -5
View File
@@ -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;
}
+5 -1
View File
@@ -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
};
+5 -14
View File
@@ -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")
+2 -2
View File
@@ -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_();
+11
View File
@@ -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
+18 -2
View File
@@ -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) {
+8 -5
View File
@@ -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
+4 -1
View File
@@ -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);
+3 -3
View File
@@ -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
View File
@@ -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) {
+2 -14
View File
@@ -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)