Add shared noise component and move noise-c primitives out of api

This commit is contained in:
J. Nick Koston
2026-08-19 23:42:10 -05:00
parent b7d0b676fc
commit 98e7c56e53
18 changed files with 296 additions and 89 deletions
+1
View File
@@ -381,6 +381,7 @@ esphome/components/nextion/switch/* @senexcrenshaw
esphome/components/nextion/text_sensor/* @senexcrenshaw
esphome/components/nfc/* @jesserockz @kbx81
esphome/components/noblex/* @AGalfra
esphome/components/noise/* @bdraco
esphome/components/npi19/* @bakerkj
esphome/components/nrf52/* @tomaszduda23
esphome/components/number/* @esphome/core
+6 -5
View File
@@ -45,9 +45,14 @@ CODEOWNERS = ["@esphome/core"]
def AUTO_LOAD(config: ConfigType) -> list[str]:
"""Conditionally auto-load json only when capture_response is used."""
"""Conditionally auto-load noise (encryption) and json (capture_response)."""
base = ["socket"]
# config is None when the dependency-resolution tooling asks for the
# maximal set; a validated config always carries defaults, never empty
if config is None or CONF_ENCRYPTION in config:
base = base + ["noise"]
# Check if any homeassistant.action/homeassistant.service has capture_response: true
# This flag is set during config validation in _validate_response_config
if not config or CORE.data.get(DOMAIN, {}).get(CONF_CAPTURE_RESPONSE, False):
@@ -497,10 +502,6 @@ 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")
cg.add_library("esphome/noise-c", "0.1.21")
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
else:
cg.add_define("USE_API_PLAINTEXT")
@@ -2,6 +2,7 @@
#ifdef USE_API
#ifdef USE_API_NOISE
#include "api_connection.h" // For ClientInfo struct
#include "esphome/components/noise/noise.h"
#include "esphome/core/application.h"
#include "esphome/core/entity_base.h"
#include "esphome/core/hal.h"
@@ -17,6 +18,8 @@
namespace esphome::api {
using noise::noise_err_to_logstr;
static const char *const TAG = "api.noise";
#ifdef USE_ESP8266
static constexpr char PROLOGUE_INIT[] PROGMEM = "NoiseAPIInit";
@@ -51,45 +54,6 @@ static constexpr size_t API_MAX_LOG_BYTES = 168;
#define LOG_PACKET_RECEIVED(buffer) ((void) 0)
#endif
/// Convert a noise error code to a readable error
const LogString *noise_err_to_logstr(int err) {
if (err == NOISE_ERROR_NO_MEMORY)
return LOG_STR("NO_MEMORY");
if (err == NOISE_ERROR_UNKNOWN_ID)
return LOG_STR("UNKNOWN_ID");
if (err == NOISE_ERROR_UNKNOWN_NAME)
return LOG_STR("UNKNOWN_NAME");
if (err == NOISE_ERROR_MAC_FAILURE)
return LOG_STR("MAC_FAILURE");
if (err == NOISE_ERROR_NOT_APPLICABLE)
return LOG_STR("NOT_APPLICABLE");
if (err == NOISE_ERROR_SYSTEM)
return LOG_STR("SYSTEM");
if (err == NOISE_ERROR_REMOTE_KEY_REQUIRED)
return LOG_STR("REMOTE_KEY_REQUIRED");
if (err == NOISE_ERROR_LOCAL_KEY_REQUIRED)
return LOG_STR("LOCAL_KEY_REQUIRED");
if (err == NOISE_ERROR_PSK_REQUIRED)
return LOG_STR("PSK_REQUIRED");
if (err == NOISE_ERROR_INVALID_LENGTH)
return LOG_STR("INVALID_LENGTH");
if (err == NOISE_ERROR_INVALID_PARAM)
return LOG_STR("INVALID_PARAM");
if (err == NOISE_ERROR_INVALID_STATE)
return LOG_STR("INVALID_STATE");
if (err == NOISE_ERROR_INVALID_NONCE)
return LOG_STR("INVALID_NONCE");
if (err == NOISE_ERROR_INVALID_PRIVATE_KEY)
return LOG_STR("INVALID_PRIVATE_KEY");
if (err == NOISE_ERROR_INVALID_PUBLIC_KEY)
return LOG_STR("INVALID_PUBLIC_KEY");
if (err == NOISE_ERROR_INVALID_FORMAT)
return LOG_STR("INVALID_FORMAT");
if (err == NOISE_ERROR_INVALID_SIGNATURE)
return LOG_STR("INVALID_SIGNATURE");
return LOG_STR("UNKNOWN");
}
/// Initialize the frame helper, returns OK if successful.
APIError APINoiseFrameHelper::init() {
APIError err = init_common_();
@@ -675,16 +639,6 @@ APINoiseFrameHelper::~APINoiseFrameHelper() {
}
}
extern "C" {
// declare how noise generates random bytes (here with a good HWRNG based on the RF system)
void noise_rand_bytes(void *output, size_t len) {
if (!esphome::random_bytes(reinterpret_cast<uint8_t *>(output), len)) {
ESP_LOGE(TAG, "Acquiring random bytes failed; rebooting");
arch_restart();
}
}
}
} // namespace esphome::api
#endif // USE_API_NOISE
#endif // USE_API
+7 -30
View File
@@ -1,37 +1,14 @@
#pragma once
#include <array>
#include <cstdint>
#include "esphome/core/defines.h"
#ifdef USE_API_NOISE
#include "esphome/components/noise/noise.h"
namespace esphome::api {
#ifdef USE_API_NOISE
using psk_t = std::array<uint8_t, 32>;
class APINoiseContext {
public:
// The all-zeros PSK is reserved: it marks the device as unprovisioned and
// doubles as the well-known provisioning PSK that unprovisioned devices
// accept for Noise handshakes (passive-sniffing protection only, no
// authentication). It is never a valid real key.
static bool is_all_zeros(const psk_t &psk) {
uint8_t acc = 0;
for (uint8_t b : psk) {
acc |= b;
}
return acc == 0;
}
void set_psk(psk_t psk) {
this->psk_ = psk;
this->has_psk_ = !is_all_zeros(psk);
}
const psk_t &get_psk() const { return this->psk_; }
bool has_psk() const { return this->has_psk_; }
protected:
psk_t psk_{};
bool has_psk_{false};
};
#endif // USE_API_NOISE
// Kept as aliases for external components that use the api names
using psk_t = noise::psk_t;
using APINoiseContext = noise::NoiseContext;
} // namespace esphome::api
#endif // USE_API_NOISE
+26
View File
@@ -0,0 +1,26 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.core import CORE
from esphome.types import ConfigType
CODEOWNERS = ["@bdraco"]
noise_ns = cg.esphome_ns.namespace("noise")
CONFIG_SCHEMA = cv.Schema({})
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_NOISE")
cg.add_library("esphome/noise-c", "0.1.21")
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
# noise-c pulls noise_rand_bytes (our HWRNG binding, defined in noise.cpp)
# from the static archive. A consumer such as api may reference no other
# symbol from noise.cpp at normal log levels, so on the ESP-IDF link (which
# resolves archives in a group) the member is dropped and noise-c fails to
# link. Force the linker to keep it. The host toolchain links it without
# help, and its ld syntax differs (leading underscore), so skip it there.
if not CORE.is_host:
cg.add_build_flag("-Wl,-u,noise_rand_bytes")
+62
View File
@@ -0,0 +1,62 @@
#include "noise.h"
#ifdef USE_NOISE
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <noise/protocol.h>
namespace esphome::noise {
static const char *const TAG = "noise";
const LogString *noise_err_to_logstr(int err) {
if (err == NOISE_ERROR_NO_MEMORY)
return LOG_STR("NO_MEMORY");
if (err == NOISE_ERROR_UNKNOWN_ID)
return LOG_STR("UNKNOWN_ID");
if (err == NOISE_ERROR_UNKNOWN_NAME)
return LOG_STR("UNKNOWN_NAME");
if (err == NOISE_ERROR_MAC_FAILURE)
return LOG_STR("MAC_FAILURE");
if (err == NOISE_ERROR_NOT_APPLICABLE)
return LOG_STR("NOT_APPLICABLE");
if (err == NOISE_ERROR_SYSTEM)
return LOG_STR("SYSTEM");
if (err == NOISE_ERROR_REMOTE_KEY_REQUIRED)
return LOG_STR("REMOTE_KEY_REQUIRED");
if (err == NOISE_ERROR_LOCAL_KEY_REQUIRED)
return LOG_STR("LOCAL_KEY_REQUIRED");
if (err == NOISE_ERROR_PSK_REQUIRED)
return LOG_STR("PSK_REQUIRED");
if (err == NOISE_ERROR_INVALID_LENGTH)
return LOG_STR("INVALID_LENGTH");
if (err == NOISE_ERROR_INVALID_PARAM)
return LOG_STR("INVALID_PARAM");
if (err == NOISE_ERROR_INVALID_STATE)
return LOG_STR("INVALID_STATE");
if (err == NOISE_ERROR_INVALID_NONCE)
return LOG_STR("INVALID_NONCE");
if (err == NOISE_ERROR_INVALID_PRIVATE_KEY)
return LOG_STR("INVALID_PRIVATE_KEY");
if (err == NOISE_ERROR_INVALID_PUBLIC_KEY)
return LOG_STR("INVALID_PUBLIC_KEY");
if (err == NOISE_ERROR_INVALID_FORMAT)
return LOG_STR("INVALID_FORMAT");
if (err == NOISE_ERROR_INVALID_SIGNATURE)
return LOG_STR("INVALID_SIGNATURE");
return LOG_STR("UNKNOWN");
}
extern "C" {
// declare how noise generates random bytes (here with a good HWRNG based on the RF system)
void noise_rand_bytes(void *output, size_t len) {
if (!esphome::random_bytes(reinterpret_cast<uint8_t *>(output), len)) {
ESP_LOGE(TAG, "Acquiring random bytes failed; rebooting");
arch_restart();
}
}
}
} // namespace esphome::noise
#endif // USE_NOISE
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_NOISE
#include <array>
#include <cstdint>
#include "esphome/core/log.h"
namespace esphome::noise {
using psk_t = std::array<uint8_t, 32>;
class NoiseContext {
public:
// The all-zeros PSK is reserved: it marks the device as unprovisioned and
// doubles as the well-known provisioning PSK that unprovisioned devices
// accept for Noise handshakes (passive-sniffing protection only, no
// authentication). It is never a valid real key.
static bool is_all_zeros(const psk_t &psk) {
uint8_t acc = 0;
for (uint8_t b : psk) {
acc |= b;
}
return acc == 0;
}
void set_psk(psk_t psk) {
this->psk_ = psk;
this->has_psk_ = !is_all_zeros(psk);
}
const psk_t &get_psk() const { return this->psk_; }
bool has_psk() const { return this->has_psk_; }
protected:
psk_t psk_{};
bool has_psk_{false};
};
/// Convert a noise error code to a readable error
const LogString *noise_err_to_logstr(int err);
} // namespace esphome::noise
#endif // USE_NOISE
@@ -0,0 +1,80 @@
#include "noise_handshake.h"
#ifdef USE_NOISE
namespace esphome::noise {
NoiseResponderHandshake::~NoiseResponderHandshake() {
if (this->handshake_ != nullptr) {
noise_handshakestate_free(this->handshake_);
this->handshake_ = nullptr;
}
}
int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len) {
// Noise_NNpsk0_25519_ChaChaPoly_SHA256, built on the stack:
// noise_handshakestate_new_by_id copies it, so a member would waste
// 104 bytes per connection, and a static const would sit in RAM on
// ESP8266 (.rodata is DRAM there).
const NoiseProtocolId nid = {
.prefix_id = NOISE_PREFIX_STANDARD,
.pattern_id = NOISE_PATTERN_NN,
.modifier_ids = {NOISE_MODIFIER_PSK0},
.dh_id = NOISE_DH_CURVE25519,
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
.hash_id = NOISE_HASH_SHA256,
.hybrid_id = NOISE_DH_NONE,
};
int err = noise_handshakestate_new_by_id(&this->handshake_, &nid, NOISE_ROLE_RESPONDER);
if (err != 0)
return err;
err = noise_handshakestate_set_pre_shared_key(this->handshake_, psk.data(), psk.size());
if (err != 0)
return err;
err = noise_handshakestate_set_prologue(this->handshake_, prologue, prologue_len);
if (err != 0)
return err;
return noise_handshakestate_start(this->handshake_);
}
NoiseResponderHandshake::Action NoiseResponderHandshake::action() const {
switch (noise_handshakestate_get_action(this->handshake_)) {
case NOISE_ACTION_READ_MESSAGE:
return Action::READ;
case NOISE_ACTION_WRITE_MESSAGE:
return Action::WRITE;
case NOISE_ACTION_SPLIT:
return Action::SPLIT;
default:
return Action::FAILED;
}
}
int NoiseResponderHandshake::read_message(const uint8_t *data, size_t len) {
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_input(mbuf, const_cast<uint8_t *>(data), len);
return noise_handshakestate_read_message(this->handshake_, &mbuf, nullptr);
}
int NoiseResponderHandshake::write_message(uint8_t *out, size_t capacity, size_t &out_len) {
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_output(mbuf, out, capacity);
int err = noise_handshakestate_write_message(this->handshake_, &mbuf, nullptr);
if (err == 0)
out_len = mbuf.size;
return err;
}
int NoiseResponderHandshake::split(NoiseCipherState *&send_cipher, NoiseCipherState *&recv_cipher) {
int err = noise_handshakestate_split(this->handshake_, &send_cipher, &recv_cipher);
if (err != 0)
return err;
noise_handshakestate_free(this->handshake_);
this->handshake_ = nullptr;
return 0;
}
} // namespace esphome::noise
#endif // USE_NOISE
@@ -0,0 +1,47 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_NOISE
#include <cstddef>
#include <cstdint>
#include <noise/protocol.h>
#include "noise.h"
namespace esphome::noise {
/** Sans-IO responder side of a Noise_NNpsk0_25519_ChaChaPoly_SHA256 handshake.
*
* Owns only the noise-c handshake state; the caller moves the raw handshake
* messages (no framing) over its own transport, driven by action():
* read_message() while READ, write_message() while WRITE, then split() to
* take ownership of the transport ciphers. All methods return a noise-c
* error code, 0 on success.
*
* Methods are deliberately small separate functions so callers on tight
* stacks (RP2040 core0 scratch bank) never pay for more than one branch;
* the curve25519 step alone needs ~2KB of stack.
*/
class NoiseResponderHandshake {
public:
enum class Action : uint8_t { READ, WRITE, SPLIT, FAILED };
~NoiseResponderHandshake();
/// Create and start the handshake with the given PSK and prologue.
int init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len);
Action action() const;
/// Process one received handshake message.
int read_message(const uint8_t *data, size_t len);
/// Produce the next handshake message into out (out_len receives its size).
int write_message(uint8_t *out, size_t capacity, size_t &out_len);
/// Hand out the transport ciphers and free the handshake state. The caller
/// owns both cipher states and must free them with noise_cipherstate_free().
int split(NoiseCipherState *&send_cipher, NoiseCipherState *&recv_cipher);
protected:
NoiseHandshakeState *handshake_{nullptr};
};
} // namespace esphome::noise
#endif // USE_NOISE
+1
View File
@@ -220,6 +220,7 @@
#define API_MAX_SEND_QUEUE 8
#define MAX_API_CONNECTIONS 6
#define USE_MD5
#define USE_NOISE
#define USE_SHA256
#ifndef USE_RP2 // no MQTT backend or esp_wireguard library on RP2
#define USE_MQTT
+3 -3
View File
@@ -45,7 +45,7 @@ lib_deps_base =
lib_deps =
${common.lib_deps_base}
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
esphome/noise-c@0.1.21 ; api
esphome/noise-c@0.1.21 ; noise (api, ota)
improv/Improv@1.2.6 ; improv_serial / esp32_improv
kikuchan98/pngle@1.1.0 ; online_image
; Using the repository directly, otherwise ESP-IDF can't use the library
@@ -244,7 +244,7 @@ lib_deps =
${common:idf-component-libs.lib_deps}
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
droscy/esp_wireguard@0.4.5 ; wireguard
esphome/noise-c@0.1.21 ; api
esphome/noise-c@0.1.21 ; noise (api, ota)
ESP32Async/AsyncTCP@3.4.5 ; async_tcp
DNSServer ; captive_portal
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
@@ -641,7 +641,7 @@ build_unflags =
extends = common
platform = platformio/native
lib_deps =
esphome/noise-c@0.1.21 ; used by api
esphome/noise-c@0.1.21 ; used by noise (api, ota)
lvgl/lvgl@9.5.0 ; lvgl
build_flags =
${common.build_flags}
+3 -2
View File
@@ -3,8 +3,9 @@ from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# api must run its to_code to define USE_API, USE_API_PLAINTEXT,
# and add the noise-c library dependency.
# api must run its to_code to define USE_API and USE_API_NOISE. The
# AUTO_LOADed noise component runs its own to_code via the override in
# tests/benchmarks/components/noise/__init__.py.
manifest.enable_codegen()
original_to_code = manifest.to_code
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# to_code must run: it defines USE_NOISE and adds the noise-c library
# the api benchmark sources need.
manifest.enable_codegen()
+1
View File
@@ -0,0 +1 @@
noise:
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
+2
View File
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml