mirror of
https://github.com/esphome/esphome.git
synced 2026-09-11 23:37:34 +00:00
[noise] Generate the responder ephemeral key ahead of the handshake
The responder's ephemeral key pair was generated inside the handshake write step, a base point multiply of about 60 ms on ESP8266 that every connecting client waited for. The noise component now keeps one spare key pair (64 bytes of static storage, only in builds with an encrypted api since the api server is the only refiller), the api server refills it from loop() once the network is up and no api client is mid handshake, and both the api and ota handshakes take it through noise-c's noise_handshakestate_set_local_ephemeral(). A handshake that finds the slot empty, or whose spare noise-c refuses, generates its own key as before. The host gtest suite covers the slot's single use, the key pair's consistency, and a full handshake whose message carries the supplied key.
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
#include "proto.h"
|
||||
#include <cstring>
|
||||
#include <cinttypes>
|
||||
#include <sodium.h>
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
#include <pgmspace.h>
|
||||
@@ -548,7 +549,10 @@ 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_, prologue_.data(), prologue_.size());
|
||||
noise::ephemeral_keypair_t spare;
|
||||
const uint8_t *ephemeral = noise::take_spare_ephemeral(spare) ? spare.data() : nullptr;
|
||||
int err = this->handshake_.init(this->ctx_, prologue_.data(), prologue_.size(), ephemeral);
|
||||
sodium_memzero(spare.data(), spare.size());
|
||||
APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshake_init"), APIError::HANDSHAKESTATE_SETUP_FAILED);
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
@@ -138,6 +138,9 @@ void APIServer::setup() {
|
||||
}
|
||||
|
||||
void APIServer::loop() {
|
||||
#ifdef USE_API_NOISE
|
||||
this->prepare_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 +191,28 @@ void APIServer::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
// Refill the spare ephemeral key while nobody is waiting for it: not before
|
||||
// the network is up, and not while an api client is still in its handshake
|
||||
// (an OTA handshake is not visible here; it took the previous spare and
|
||||
// pays the refill instead, no worse than generating its own key). The
|
||||
// generation blocks the loop for about 60 ms on ESP8266, over the 50 ms
|
||||
// blocking warning, so the api component's warning threshold ratchets up
|
||||
// on the first refill; the same generation used to run inside the
|
||||
// handshake, where it tripped the warning for longer.
|
||||
void APIServer::prepare_spare_ephemeral_() {
|
||||
if (noise::has_spare_ephemeral() || !network::is_connected()) {
|
||||
return;
|
||||
}
|
||||
for (auto &client : this->active_clients()) {
|
||||
if (!client->is_connection_setup()) {
|
||||
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 prepare_spare_ephemeral_();
|
||||
noise::NoiseContext noise_ctx_;
|
||||
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||
SavedNoisePsk saved_psk_{}; // backs noise_ctx_ for a runtime provisioned key
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
#include <sodium.h>
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
#include <pgmspace.h>
|
||||
@@ -65,9 +66,21 @@ bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) {
|
||||
*p++ = ota::OTA_RESPONSE_FEATURE_FLAGS;
|
||||
*p++ = server_feature_flags;
|
||||
|
||||
// The api server keeps the spare; without an encrypted api there is none
|
||||
const uint8_t *ephemeral = nullptr;
|
||||
#ifdef USE_API_NOISE
|
||||
noise::ephemeral_keypair_t spare;
|
||||
if (this->noise_ != nullptr && noise::take_spare_ephemeral(spare)) {
|
||||
ephemeral = spare.data();
|
||||
}
|
||||
#endif
|
||||
// The caller only starts a session when the context holds a key
|
||||
int err = this->noise_ == nullptr ? NOISE_ERROR_NO_MEMORY
|
||||
: this->noise_->handshake.init(this->noise_context_(), prologue, sizeof(prologue));
|
||||
int err = this->noise_ == nullptr
|
||||
? NOISE_ERROR_NO_MEMORY
|
||||
: this->noise_->handshake.init(this->noise_context_(), prologue, sizeof(prologue), ephemeral);
|
||||
#ifdef USE_API_NOISE
|
||||
sodium_memzero(spare.data(), spare.size());
|
||||
#endif
|
||||
if (err != 0) {
|
||||
// Raw noise codes throughout: the name table would cost flash in builds
|
||||
// where only the OTA uses noise
|
||||
|
||||
@@ -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,38 @@ void NoiseContext::load_psk(psk_t &out) const {
|
||||
progmem_memcpy(out.data(), this->psk_, out.size());
|
||||
}
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
static uint8_t spare_ephemeral[EPHEMERAL_KEYPAIR_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static bool spare_ephemeral_ready = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
bool has_spare_ephemeral() { return spare_ephemeral_ready; }
|
||||
|
||||
void prepare_spare_ephemeral() {
|
||||
uint8_t *private_key = spare_ephemeral;
|
||||
uint8_t *public_key = spare_ephemeral + EPHEMERAL_PRIVATE_KEY_SIZE;
|
||||
// Same generation as noise-c's curve25519 backend: random bytes, X25519
|
||||
// clamping, then the public key. A random source failure leaves the slot
|
||||
// empty; the handshake then generates its own key.
|
||||
if (!random_bytes(private_key, EPHEMERAL_PRIVATE_KEY_SIZE)) {
|
||||
return;
|
||||
}
|
||||
private_key[0] &= 0xF8;
|
||||
private_key[EPHEMERAL_PRIVATE_KEY_SIZE - 1] = (private_key[EPHEMERAL_PRIVATE_KEY_SIZE - 1] & 0x7F) | 0x40;
|
||||
crypto_scalarmult_curve25519_base(public_key, private_key);
|
||||
spare_ephemeral_ready = true;
|
||||
}
|
||||
|
||||
bool take_spare_ephemeral(ephemeral_keypair_t &out) {
|
||||
if (!spare_ephemeral_ready) {
|
||||
return false;
|
||||
}
|
||||
std::memcpy(out.data(), spare_ephemeral, EPHEMERAL_KEYPAIR_SIZE);
|
||||
sodium_memzero(spare_ephemeral, EPHEMERAL_KEYPAIR_SIZE);
|
||||
spare_ephemeral_ready = false;
|
||||
return true;
|
||||
}
|
||||
#endif // USE_API_NOISE
|
||||
|
||||
const LogString *noise_err_to_logstr(int err) {
|
||||
if (err == NOISE_ERROR_NO_MEMORY)
|
||||
return LOG_STR("NO_MEMORY");
|
||||
|
||||
@@ -38,6 +38,27 @@ class NoiseContext {
|
||||
/// Convert a noise error code to a readable error
|
||||
const LogString *noise_err_to_logstr(int err);
|
||||
|
||||
// An X25519 key pair as the spare ephemeral hands it out: private key first
|
||||
static constexpr size_t EPHEMERAL_PRIVATE_KEY_SIZE = 32;
|
||||
static constexpr size_t EPHEMERAL_PUBLIC_KEY_SIZE = 32;
|
||||
static constexpr size_t EPHEMERAL_KEYPAIR_SIZE = EPHEMERAL_PRIVATE_KEY_SIZE + EPHEMERAL_PUBLIC_KEY_SIZE;
|
||||
using ephemeral_keypair_t = std::array<uint8_t, EPHEMERAL_KEYPAIR_SIZE>;
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
// A responder ephemeral key pair generated ahead of time. The base point
|
||||
// multiply behind one costs about 60 ms on ESP8266, so the api server fills
|
||||
// the slot while idle and a connecting client does not wait for it. One slot
|
||||
// serves every noise transport; a handshake that finds it empty generates
|
||||
// its own key as before. The api server is the only refiller, so the slot
|
||||
// only exists in builds with an encrypted api.
|
||||
bool has_spare_ephemeral();
|
||||
/// Generate a key pair into the slot; blocks for the base point multiply.
|
||||
void prepare_spare_ephemeral();
|
||||
/// Move the slot's key pair into out and empty the slot. Returns false,
|
||||
/// leaving out untouched, when the slot is empty.
|
||||
bool take_spare_ephemeral(ephemeral_keypair_t &out);
|
||||
#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
|
||||
|
||||
@@ -20,7 +20,8 @@ NoiseResponderHandshake::~NoiseResponderHandshake() {
|
||||
}
|
||||
}
|
||||
|
||||
int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len) {
|
||||
int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len,
|
||||
const uint8_t *ephemeral_keypair) {
|
||||
if (this->handshake_ != nullptr) {
|
||||
noise_handshakestate_free(this->handshake_);
|
||||
this->handshake_ = nullptr;
|
||||
@@ -57,6 +58,15 @@ int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prolog
|
||||
HANDSHAKE_STEP_LOG("noise_handshakestate_set_prologue", err);
|
||||
return this->fail_init_(err);
|
||||
}
|
||||
if (ephemeral_keypair != nullptr) {
|
||||
err = noise_handshakestate_set_local_ephemeral(this->handshake_, ephemeral_keypair, EPHEMERAL_PRIVATE_KEY_SIZE,
|
||||
ephemeral_keypair + EPHEMERAL_PRIVATE_KEY_SIZE,
|
||||
EPHEMERAL_PUBLIC_KEY_SIZE);
|
||||
// Not fatal: the handshake generates its own key when the spare is refused
|
||||
if (err != 0) {
|
||||
HANDSHAKE_STEP_LOG("noise_handshakestate_set_local_ephemeral", err);
|
||||
}
|
||||
}
|
||||
err = noise_handshakestate_start(this->handshake_);
|
||||
if (err != 0) {
|
||||
HANDSHAKE_STEP_LOG("noise_handshakestate_start", err);
|
||||
|
||||
@@ -38,7 +38,12 @@ class NoiseResponderHandshake {
|
||||
|
||||
/// 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);
|
||||
/// ephemeral_keypair, when not null, is a key pair from
|
||||
/// take_spare_ephemeral() that the handshake uses as its ephemeral key
|
||||
/// instead of generating one; if noise-c refuses it the handshake
|
||||
/// generates its own key and init() still succeeds.
|
||||
[[nodiscard]] int init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len,
|
||||
const uint8_t *ephemeral_keypair = nullptr);
|
||||
/// 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;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import esphome.codegen as cg
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
@@ -5,3 +6,11 @@ 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)
|
||||
# The spare ephemeral slot only exists in builds with an encrypted api
|
||||
cg.add_define("USE_API_NOISE")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <cstring>
|
||||
|
||||
#include <noise/protocol.h>
|
||||
#include <sodium.h>
|
||||
|
||||
#include "esphome/components/noise/noise.h"
|
||||
#include "esphome/components/noise/noise_handshake.h"
|
||||
@@ -157,6 +158,61 @@ TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) {
|
||||
noise_cipherstate_free(recv_cipher);
|
||||
}
|
||||
|
||||
TEST(SpareEphemeralTest, EmptySlotHandsOutNothing) {
|
||||
ephemeral_keypair_t out;
|
||||
// Drain whatever an earlier test left behind, then the slot must stay empty
|
||||
take_spare_ephemeral(out);
|
||||
EXPECT_FALSE(has_spare_ephemeral());
|
||||
EXPECT_FALSE(take_spare_ephemeral(out));
|
||||
}
|
||||
|
||||
TEST(SpareEphemeralTest, KeyPairIsHandedOutExactlyOnce) {
|
||||
ephemeral_keypair_t out;
|
||||
take_spare_ephemeral(out);
|
||||
prepare_spare_ephemeral();
|
||||
ASSERT_TRUE(has_spare_ephemeral());
|
||||
ASSERT_TRUE(take_spare_ephemeral(out));
|
||||
// Taken once: the slot is empty and a second take gets nothing
|
||||
EXPECT_FALSE(has_spare_ephemeral());
|
||||
EXPECT_FALSE(take_spare_ephemeral(out));
|
||||
|
||||
// The pair is consistent: the public half is the base point multiple of the private half
|
||||
uint8_t check[EPHEMERAL_PUBLIC_KEY_SIZE];
|
||||
ASSERT_EQ(crypto_scalarmult_curve25519_base(check, out.data()), 0);
|
||||
EXPECT_EQ(std::memcmp(check, out.data() + EPHEMERAL_PRIVATE_KEY_SIZE, EPHEMERAL_PUBLIC_KEY_SIZE), 0);
|
||||
}
|
||||
|
||||
TEST(SpareEphemeralTest, SuppliedKeyPairCompletesHandshakeAndIsTheKeyOnTheWire) {
|
||||
ephemeral_keypair_t spare;
|
||||
take_spare_ephemeral(spare);
|
||||
prepare_spare_ephemeral();
|
||||
ASSERT_TRUE(take_spare_ephemeral(spare));
|
||||
|
||||
const psk_t psk = make_psk(7);
|
||||
NoiseResponderHandshake responder;
|
||||
ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE), spare.data()), 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);
|
||||
// The responder's message starts with its ephemeral public key
|
||||
ASSERT_GE(reply_len, static_cast<size_t>(EPHEMERAL_PUBLIC_KEY_SIZE));
|
||||
EXPECT_EQ(std::memcmp(msg, spare.data() + EPHEMERAL_PRIVATE_KEY_SIZE, EPHEMERAL_PUBLIC_KEY_SIZE), 0);
|
||||
|
||||
ASSERT_EQ(initiator.read_message(msg, reply_len), 0);
|
||||
initiator.split();
|
||||
NoiseCipherState *send_cipher = nullptr;
|
||||
NoiseCipherState *recv_cipher = nullptr;
|
||||
ASSERT_EQ(responder.split(send_cipher, recv_cipher), 0);
|
||||
ASSERT_NE(send_cipher, nullptr);
|
||||
noise_cipherstate_free(send_cipher);
|
||||
noise_cipherstate_free(recv_cipher);
|
||||
}
|
||||
|
||||
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