mirror of
https://github.com/esphome/esphome.git
synced 2026-09-07 13:36:03 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edec6aaf5e | ||
|
|
55804e6e16 | ||
|
|
228f8894a9 | ||
|
|
866574ca14 | ||
|
|
5747c736c2 | ||
|
|
0f6c266cd7 | ||
|
|
05dbc5ee59 | ||
|
|
29f7439154 | ||
|
|
89cd183a9f | ||
|
|
370cfb8898 |
@@ -13,6 +13,7 @@ from esphome.components.logger import request_log_listener
|
||||
from esphome.components.noise import ( # noqa: F401
|
||||
ENCRYPTION_SCHEMA,
|
||||
decode_encryption_key,
|
||||
enable_spare_ephemeral,
|
||||
encryption_schema,
|
||||
new_psk_progmem,
|
||||
validate_encryption_key,
|
||||
@@ -603,6 +604,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
# and plaintext disabled. Only a factory reset can remove it.
|
||||
cg.add_define("USE_API_PLAINTEXT")
|
||||
cg.add_define("USE_API_NOISE")
|
||||
enable_spare_ephemeral()
|
||||
else:
|
||||
cg.add_define("USE_API_PLAINTEXT")
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS *
|
||||
// WiFi (-70 dBm+), TCP retransmissions push real-world handshake times to
|
||||
// 28-30s. See https://github.com/esphome/esphome/issues/14999
|
||||
static constexpr uint32_t HANDSHAKE_TIMEOUT_MS = 60000;
|
||||
// How long a new connection holds off the spare ephemeral refill
|
||||
static constexpr uint32_t CONNECT_GRACE_MS = 1000;
|
||||
|
||||
static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION);
|
||||
|
||||
@@ -250,6 +252,10 @@ void APIConnection::begin_iterator_(ActiveIterator type) {
|
||||
}
|
||||
}
|
||||
|
||||
bool APIConnection::is_still_connecting(uint32_t now) {
|
||||
return !this->is_authenticated() && now - this->last_traffic_ < CONNECT_GRACE_MS;
|
||||
}
|
||||
|
||||
void APIConnection::loop() {
|
||||
if (this->flags_.next_close) {
|
||||
// requested a disconnect - don't close socket here, let APIServer::loop() do it
|
||||
|
||||
@@ -319,6 +319,9 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
bool is_authenticated() {
|
||||
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::AUTHENTICATED;
|
||||
}
|
||||
// Unauthenticated and within its grace period; an older unauthenticated
|
||||
// connection is a stale half open client and no longer counts
|
||||
bool is_still_connecting(uint32_t now);
|
||||
bool is_connection_setup() {
|
||||
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::CONNECTED ||
|
||||
this->is_authenticated();
|
||||
|
||||
@@ -41,6 +41,11 @@ void APIServer::setup() {
|
||||
ControllerRegistry::register_controller(this);
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
#ifdef USE_ESP8266
|
||||
// The spare ephemeral refill blocks ~60 ms here and shares the pass with
|
||||
// the client loops; keep the whole pass under the blocking warning
|
||||
this->warn_if_blocking_over_ = 10; // centiseconds
|
||||
#endif
|
||||
// Always reserve the slot: flash preferences are positional on esp8266, so
|
||||
// a yaml key build must keep the layout of a runtime key build
|
||||
uint32_t hash = 88491486UL;
|
||||
@@ -138,6 +143,12 @@ void APIServer::setup() {
|
||||
}
|
||||
|
||||
void APIServer::loop() {
|
||||
#ifdef USE_API_NOISE
|
||||
// Only the flag test is inline; refilling is the rare path
|
||||
if (!noise::has_spare_ephemeral()) {
|
||||
this->refill_spare_ephemeral_();
|
||||
}
|
||||
#endif
|
||||
// Accept new clients only if the socket exists and has incoming connections
|
||||
if (this->socket_ && this->socket_->ready()) {
|
||||
this->accept_new_connections_();
|
||||
@@ -188,6 +199,23 @@ void APIServer::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
// Refill only while no api client is still connecting; an OTA handshake is
|
||||
// not visible here and just pays the refill it triggered.
|
||||
void APIServer::refill_spare_ephemeral_() {
|
||||
if (!network::is_connected()) {
|
||||
return;
|
||||
}
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
for (auto &client : this->active_clients()) {
|
||||
if (client->is_still_connecting(now)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
noise::prepare_spare_ephemeral();
|
||||
}
|
||||
#endif
|
||||
|
||||
void APIServer::remove_client_(uint8_t client_index) {
|
||||
auto &client = this->clients_[client_index];
|
||||
|
||||
|
||||
@@ -364,6 +364,7 @@ class APIServer final : public Component,
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
void refill_spare_ephemeral_();
|
||||
noise::NoiseContext noise_ctx_;
|
||||
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||
SavedNoisePsk saved_psk_{}; // backs noise_ctx_ for a runtime provisioned key
|
||||
|
||||
@@ -86,6 +86,11 @@ def encryption_schema(config: ConfigType | None) -> ConfigType:
|
||||
return ENCRYPTION_SCHEMA(config)
|
||||
|
||||
|
||||
def enable_spare_ephemeral() -> None:
|
||||
"""Compile the spare ephemeral key slot; the component that refills it calls this."""
|
||||
cg.add_define("USE_NOISE_SPARE_EPHEMERAL")
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("USE_NOISE")
|
||||
cg.add_library("esphome/noise-c", "0.1.24")
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
#include "noise.h"
|
||||
#ifdef USE_NOISE
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include <noise/protocol.h>
|
||||
#include <sodium.h>
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
#include <pgmspace.h>
|
||||
@@ -24,6 +26,40 @@ void NoiseContext::load_psk(psk_t &out) const {
|
||||
progmem_memcpy(out.data(), this->psk_, out.size());
|
||||
}
|
||||
|
||||
#ifdef USE_NOISE_SPARE_EPHEMERAL
|
||||
static constexpr size_t PRIVATE_KEY_SIZE = 32;
|
||||
static constexpr size_t PUBLIC_KEY_SIZE = 32;
|
||||
static_assert(PRIVATE_KEY_SIZE + PUBLIC_KEY_SIZE == SPARE_EPHEMERAL_SIZE);
|
||||
uint8_t spare_ephemeral[SPARE_EPHEMERAL_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
void prepare_spare_ephemeral() {
|
||||
uint8_t *private_key = spare_ephemeral;
|
||||
uint8_t *public_key = spare_ephemeral + PRIVATE_KEY_SIZE;
|
||||
// Same steps as noise-c's curve25519 keygen; the clamp sets the ready bit,
|
||||
// a failure wipes the slot so the handshake generates its own key
|
||||
if (!random_bytes(private_key, PRIVATE_KEY_SIZE)) {
|
||||
sodium_memzero(spare_ephemeral, sizeof(spare_ephemeral));
|
||||
return;
|
||||
}
|
||||
private_key[0] &= 0xF8;
|
||||
private_key[PRIVATE_KEY_SIZE - 1] = (private_key[PRIVATE_KEY_SIZE - 1] & 0x7F) | 0x40;
|
||||
if (crypto_scalarmult_curve25519_base(public_key, private_key) != 0) {
|
||||
sodium_memzero(spare_ephemeral, sizeof(spare_ephemeral));
|
||||
}
|
||||
}
|
||||
|
||||
int consume_spare_ephemeral(NoiseHandshakeState *state) {
|
||||
if (!has_spare_ephemeral()) {
|
||||
return 0;
|
||||
}
|
||||
// noise-c keeps its own copy, so the slot is wiped either way
|
||||
int err = noise_handshakestate_set_local_ephemeral(state, spare_ephemeral, PRIVATE_KEY_SIZE,
|
||||
spare_ephemeral + PRIVATE_KEY_SIZE, PUBLIC_KEY_SIZE);
|
||||
sodium_memzero(spare_ephemeral, sizeof(spare_ephemeral));
|
||||
return err;
|
||||
}
|
||||
#endif // USE_NOISE_SPARE_EPHEMERAL
|
||||
|
||||
const LogString *noise_err_to_logstr(int err) {
|
||||
if (err == NOISE_ERROR_NO_MEMORY)
|
||||
return LOG_STR("NO_MEMORY");
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
#include <cstdint>
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
// noise-c handshake state; the full definition lives in <noise/protocol.h>
|
||||
using NoiseHandshakeState = struct NoiseHandshakeState_s;
|
||||
|
||||
namespace esphome::noise {
|
||||
|
||||
using psk_t = std::array<uint8_t, 32>;
|
||||
@@ -38,6 +41,24 @@ class NoiseContext {
|
||||
/// Convert a noise error code to a readable error
|
||||
const LogString *noise_err_to_logstr(int err);
|
||||
|
||||
#ifdef USE_NOISE_SPARE_EPHEMERAL
|
||||
// One responder ephemeral key pair generated ahead of time (about 60 ms on
|
||||
// ESP8266), refilled by the api server while idle and consumed by the next
|
||||
// handshake of any noise transport; an empty slot means the handshake
|
||||
// generates its own key.
|
||||
// Private key then public key; zero when empty
|
||||
static constexpr size_t SPARE_EPHEMERAL_SIZE = 64;
|
||||
extern uint8_t spare_ephemeral[SPARE_EPHEMERAL_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
// Polled every api loop tick, so it must inline. A clamped X25519 private key
|
||||
// always has bit 254 set, so that byte doubles as the ready flag.
|
||||
inline bool has_spare_ephemeral() { return (spare_ephemeral[31] & 0x40) != 0; }
|
||||
/// Fill the slot; blocks for the base point multiply
|
||||
void prepare_spare_ephemeral();
|
||||
/// Hand the slot's key pair to a handshake that has not started and wipe the
|
||||
/// slot; 0 when the slot was empty or the key was taken, else the noise-c error
|
||||
int consume_spare_ephemeral(NoiseHandshakeState *state);
|
||||
#endif
|
||||
|
||||
// Shared wire format for the noise transports (api and ota): every frame is
|
||||
// FRAME_INDICATOR, a 16-bit big-endian payload length, then the payload.
|
||||
// Handshake payloads start with a status byte; transport payloads end with
|
||||
|
||||
@@ -57,6 +57,13 @@ int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prolog
|
||||
HANDSHAKE_STEP_LOG("noise_handshakestate_set_prologue", err);
|
||||
return this->fail_init_(err);
|
||||
}
|
||||
#ifdef USE_NOISE_SPARE_EPHEMERAL
|
||||
err = consume_spare_ephemeral(this->handshake_);
|
||||
// Not fatal: the handshake generates its own key instead
|
||||
if (err != 0) {
|
||||
HANDSHAKE_STEP_LOG("noise_handshakestate_set_local_ephemeral", err);
|
||||
}
|
||||
#endif
|
||||
err = noise_handshakestate_start(this->handshake_);
|
||||
if (err != 0) {
|
||||
HANDSHAKE_STEP_LOG("noise_handshakestate_start", err);
|
||||
|
||||
@@ -37,7 +37,8 @@ class NoiseResponderHandshake {
|
||||
NoiseResponderHandshake &operator=(const NoiseResponderHandshake &) = delete;
|
||||
|
||||
/// Create and start the handshake with the context's PSK and the prologue.
|
||||
/// A repeated call frees the previous handshake state and starts over.
|
||||
/// A repeated call frees the previous handshake state and starts over. A
|
||||
/// spare ephemeral key, when one is ready, is used instead of generating.
|
||||
[[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.
|
||||
|
||||
@@ -230,6 +230,7 @@
|
||||
#define USE_IMPROV_SERIAL_NEXT_URL
|
||||
#define USE_MD5
|
||||
#define USE_NOISE
|
||||
#define USE_NOISE_SPARE_EPHEMERAL
|
||||
#define USE_SHA256
|
||||
#ifndef USE_RP2 // no MQTT backend or esp_wireguard library on RP2
|
||||
#define USE_MQTT
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import esphome.codegen as cg
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
@@ -5,3 +6,10 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# to_code must run: it defines USE_NOISE and adds the noise-c library
|
||||
# the component sources under test need.
|
||||
manifest.enable_codegen()
|
||||
real_to_code = manifest.to_code
|
||||
|
||||
async def to_code_testing(config):
|
||||
await real_to_code(config)
|
||||
cg.add_define("USE_NOISE_SPARE_EPHEMERAL")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
|
||||
@@ -157,6 +157,38 @@ TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) {
|
||||
noise_cipherstate_free(recv_cipher);
|
||||
}
|
||||
|
||||
// Drive one full NNpsk0 handshake between a fresh initiator and responder
|
||||
static void run_handshake(NoiseResponderHandshake &responder) {
|
||||
const psk_t psk = make_psk(7);
|
||||
ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE));
|
||||
uint8_t msg[MAX_HANDSHAKE_SIZE];
|
||||
size_t msg_len = initiator.write_message(msg, sizeof(msg));
|
||||
ASSERT_EQ(responder.read_message(msg, msg_len), 0);
|
||||
size_t reply_len = 0;
|
||||
ASSERT_EQ(responder.write_message(msg, sizeof(msg), reply_len), 0);
|
||||
ASSERT_EQ(initiator.read_message(msg, reply_len), 0);
|
||||
ASSERT_EQ(responder.action(), Action::ACTION_SPLIT);
|
||||
}
|
||||
|
||||
TEST(SpareEphemeralTest, EmptySlotLeavesHandshakeToGenerate) {
|
||||
NoiseResponderHandshake responder;
|
||||
run_handshake(responder);
|
||||
EXPECT_FALSE(has_spare_ephemeral());
|
||||
}
|
||||
|
||||
TEST(SpareEphemeralTest, SlotIsConsumedByExactlyOneHandshake) {
|
||||
prepare_spare_ephemeral();
|
||||
ASSERT_TRUE(has_spare_ephemeral());
|
||||
NoiseResponderHandshake first;
|
||||
run_handshake(first);
|
||||
// Consumed: the next handshake finds no spare and still completes
|
||||
EXPECT_FALSE(has_spare_ephemeral());
|
||||
NoiseResponderHandshake second;
|
||||
run_handshake(second);
|
||||
EXPECT_FALSE(has_spare_ephemeral());
|
||||
}
|
||||
|
||||
TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) {
|
||||
// The documented retry shape: a repeated init() frees the previous state
|
||||
// and starts over. The first message under the new key authenticating
|
||||
|
||||
Reference in New Issue
Block a user