mirror of
https://github.com/esphome/esphome.git
synced 2026-08-30 01:26:43 +00:00
[noise] Simplify the resume implementation
This commit is contained in:
@@ -1754,11 +1754,13 @@ void APIConnection::complete_authentication_() {
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
void APIConnection::send_resume_ticket_() {
|
||||
#ifdef USE_API_PLAINTEXT
|
||||
// Only encrypted transports get a ticket: on dual-mode builds a plaintext
|
||||
// connection has no frame footer
|
||||
if (this->helper_->frame_footer_size() == 0) {
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
noise::ResumeTicket ticket;
|
||||
if (!this->parent_->get_noise_ctx().resume_cache().issue(ticket)) {
|
||||
return;
|
||||
@@ -1767,7 +1769,7 @@ void APIConnection::send_resume_ticket_() {
|
||||
msg.set_session_id(ticket.session_id, noise::RESUME_SESSION_ID_SIZE);
|
||||
msg.set_secret(ticket.secret, noise::RESUME_SECRET_SIZE);
|
||||
this->send_message(msg);
|
||||
noise::resume_wipe(&ticket, sizeof(ticket));
|
||||
noise_clean(&ticket, sizeof(ticket));
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -283,6 +283,7 @@ class APIFrameHelper {
|
||||
CLOSED = 6,
|
||||
FAILED = 7,
|
||||
EXPLICIT_REJECT = 8, // Noise only
|
||||
RESUME_DISCARD = 9, // Noise only: session resumed, discard the in-flight handshake msg 1
|
||||
};
|
||||
|
||||
// Fast inline state check for read_packet/write_protobuf_messages hot path.
|
||||
|
||||
@@ -252,6 +252,8 @@ APIError APINoiseFrameHelper::state_action_() {
|
||||
return this->state_action_server_hello_();
|
||||
case State::HANDSHAKE:
|
||||
return this->state_action_handshake_();
|
||||
case State::RESUME_DISCARD:
|
||||
return this->state_action_resume_discard_();
|
||||
case State::CLOSED:
|
||||
case State::FAILED:
|
||||
return APIError::BAD_STATE;
|
||||
@@ -277,29 +279,19 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
|
||||
std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size);
|
||||
}
|
||||
|
||||
// A resume offer is decided on in the server hello step, which reads it
|
||||
// from rx_buf_ (no frame is read in between, so the buffer stays intact).
|
||||
this->resume_offer_pending_ = rx_size == noise::RESUME_OFFER_SIZE && this->rx_buf_[0] == noise::RESUME_OFFER_VERSION;
|
||||
|
||||
state_ = State::SERVER_HELLO;
|
||||
return APIError::OK;
|
||||
}
|
||||
APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
// A verified resume offer replaces the whole handshake: consume the ticket,
|
||||
// prove possession of its secret in a trailing ServerHello extension (old
|
||||
// clients ignore trailing bytes), and derive the transport keys via HKDF.
|
||||
// Every failure on this path silently falls back to the full handshake.
|
||||
uint8_t resume_secret[noise::RESUME_SECRET_SIZE];
|
||||
uint8_t server_nonce[noise::RESUME_NONCE_SIZE];
|
||||
uint8_t confirm_mac[noise::RESUME_MAC_SIZE];
|
||||
bool resume =
|
||||
this->resume_offer_pending_ && this->ctx_.resume_cache().take_verified(this->rx_buf_.data(), resume_secret);
|
||||
this->resume_offer_pending_ = false;
|
||||
if (resume) {
|
||||
resume = random_bytes(server_nonce, sizeof(server_nonce)) &&
|
||||
noise::resume_compute_confirm_mac(resume_secret, this->rx_buf_.data() + noise::RESUME_OFFER_NONCE_OFFSET,
|
||||
server_nonce, confirm_mac);
|
||||
}
|
||||
// A verified resume offer (still in rx_buf_ from the client hello step; no
|
||||
// frame is read in between) replaces the whole handshake: the cache
|
||||
// consumes the ticket, proves possession of its secret in a trailing
|
||||
// ServerHello extension (old clients ignore trailing bytes), and hands
|
||||
// back ready transport ciphers. Every failure silently falls back to the
|
||||
// full handshake.
|
||||
uint8_t resume_ext[noise::RESUME_ACCEPT_SIZE];
|
||||
bool resume = this->ctx_.resume_cache().try_accept(this->rx_buf_.data(), this->rx_buf_.size(), this->prologue_.data(),
|
||||
this->prologue_.size(), resume_ext, send_cipher_, recv_cipher_);
|
||||
|
||||
// send server hello
|
||||
const auto &name = App.get_name();
|
||||
@@ -328,26 +320,22 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE);
|
||||
|
||||
if (resume) {
|
||||
// version | server_nonce | confirm_mac
|
||||
uint8_t *ext = msg + total_size;
|
||||
ext[0] = noise::RESUME_ACCEPT_VERSION;
|
||||
std::memcpy(ext + 1, server_nonce, sizeof(server_nonce));
|
||||
std::memcpy(ext + 1 + sizeof(server_nonce), confirm_mac, sizeof(confirm_mac));
|
||||
std::memcpy(msg + total_size, resume_ext, noise::RESUME_ACCEPT_SIZE);
|
||||
total_size += noise::RESUME_ACCEPT_SIZE;
|
||||
}
|
||||
|
||||
APIError aerr = write_frame_(msg, total_size);
|
||||
if (aerr != APIError::OK) {
|
||||
noise::resume_wipe(resume_secret, sizeof(resume_secret));
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
}
|
||||
|
||||
if (resume) {
|
||||
aerr = this->setup_resumed_session_(resume_secret, server_nonce);
|
||||
noise::resume_wipe(resume_secret, sizeof(resume_secret));
|
||||
return aerr;
|
||||
// The client's full-handshake message 1 is already in flight; discard
|
||||
// it before entering DATA.
|
||||
this->prologue_.release();
|
||||
this->frame_footer_size_ = noise_cipherstate_get_mac_length(this->send_cipher_);
|
||||
state_ = State::RESUME_DISCARD;
|
||||
return APIError::OK;
|
||||
}
|
||||
noise::resume_wipe(resume_secret, sizeof(resume_secret));
|
||||
|
||||
// start handshake
|
||||
aerr = init_handshake_();
|
||||
@@ -357,39 +345,7 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
state_ = State::HANDSHAKE;
|
||||
return APIError::OK;
|
||||
}
|
||||
|
||||
/// Derive the resumed session's transport ciphers. The client's full
|
||||
/// handshake message 1 is already in flight, so the connection stays in
|
||||
/// HANDSHAKE state to read and discard it before switching to DATA.
|
||||
APIError APINoiseFrameHelper::setup_resumed_session_(const uint8_t *resume_secret, const uint8_t *server_nonce) {
|
||||
uint8_t k_c2d[32];
|
||||
uint8_t k_d2c[32];
|
||||
bool ok = noise::resume_derive_keys(resume_secret, this->rx_buf_.data() + noise::RESUME_OFFER_NONCE_OFFSET,
|
||||
server_nonce, this->prologue_.data(), this->prologue_.size(), k_c2d, k_d2c);
|
||||
if (ok) {
|
||||
this->recv_cipher_ = noise::resume_make_cipher(k_c2d);
|
||||
this->send_cipher_ = noise::resume_make_cipher(k_d2c);
|
||||
ok = this->recv_cipher_ != nullptr && this->send_cipher_ != nullptr;
|
||||
}
|
||||
noise::resume_wipe(k_c2d, sizeof(k_c2d));
|
||||
noise::resume_wipe(k_d2c, sizeof(k_d2c));
|
||||
if (!ok) {
|
||||
// The accept extension is already on the wire; the connection cannot
|
||||
// fall back to a full handshake any more. Fail it; the client retries.
|
||||
state_ = State::FAILED;
|
||||
HELPER_LOG("Resume key derivation failed");
|
||||
return APIError::HANDSHAKESTATE_SETUP_FAILED;
|
||||
}
|
||||
this->prologue_.release();
|
||||
this->frame_footer_size_ = noise_cipherstate_get_mac_length(this->send_cipher_);
|
||||
this->resume_discard_msg1_ = true;
|
||||
state_ = State::HANDSHAKE;
|
||||
return APIError::OK;
|
||||
}
|
||||
APIError APINoiseFrameHelper::state_action_handshake_() {
|
||||
if (this->resume_discard_msg1_) {
|
||||
return this->state_action_resume_discard_();
|
||||
}
|
||||
noise::NoiseResponderHandshake::Action action = this->handshake_.action();
|
||||
if (action == noise::NoiseResponderHandshake::Action::ACTION_READ) {
|
||||
return this->state_action_handshake_read_();
|
||||
@@ -414,7 +370,6 @@ APIError APINoiseFrameHelper::state_action_resume_discard_() {
|
||||
HELPER_LOG("Bad discarded handshake message");
|
||||
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
|
||||
}
|
||||
this->resume_discard_msg1_ = false;
|
||||
HELPER_LOG("Session resumed!");
|
||||
state_ = State::DATA;
|
||||
return APIError::OK;
|
||||
|
||||
@@ -43,7 +43,6 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
APIError state_action_handshake_read_();
|
||||
APIError state_action_handshake_write_();
|
||||
APIError state_action_resume_discard_();
|
||||
APIError setup_resumed_session_(const uint8_t *resume_secret, const uint8_t *server_nonce);
|
||||
APIError try_read_frame_();
|
||||
APIError write_frame_(const uint8_t *data, uint16_t len);
|
||||
APIError encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type,
|
||||
@@ -71,14 +70,7 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
// Note: Maximum message size is UINT16_MAX (65535), with a limit of 128 bytes during handshake phase
|
||||
uint8_t rx_header_buf_[noise::FRAME_HEADER_SIZE];
|
||||
uint8_t rx_header_buf_len_ = 0;
|
||||
// The ClientHello body carried a well-formed resume offer; decided on in
|
||||
// state_action_server_hello_, which reads the offer from the still-intact
|
||||
// rx_buf_.
|
||||
bool resume_offer_pending_ = false;
|
||||
// Resume accepted: the client's already-in-flight full-handshake message 1
|
||||
// must be read and discarded before the connection enters DATA.
|
||||
bool resume_discard_msg1_ = false;
|
||||
// 6 bytes total, 2 padding
|
||||
// 4 bytes total, no padding
|
||||
};
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
@@ -2,26 +2,13 @@
|
||||
#ifdef USE_NOISE
|
||||
#include <cstring>
|
||||
|
||||
#include <noise/protocol.h>
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::noise {
|
||||
|
||||
void resume_wipe(void *p, size_t len) {
|
||||
volatile uint8_t *b = reinterpret_cast<volatile uint8_t *>(p);
|
||||
while (len--) {
|
||||
*b++ = 0;
|
||||
}
|
||||
}
|
||||
|
||||
static bool resume_ct_equal_(const uint8_t *a, const uint8_t *b, size_t len) {
|
||||
uint8_t acc = 0;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
acc |= a[i] ^ b[i];
|
||||
}
|
||||
return acc == 0;
|
||||
}
|
||||
|
||||
/// Noise-construction HKDF-SHA256; out2 may alias scratch the caller wipes.
|
||||
/// Noise-construction HKDF-SHA256; out2 may be scratch the caller wipes.
|
||||
static bool resume_hkdf_(const uint8_t *key, size_t key_len, const uint8_t *data, size_t data_len, uint8_t *out1,
|
||||
size_t out1_len, uint8_t *out2, size_t out2_len) {
|
||||
NoiseHashState *hash = nullptr;
|
||||
@@ -33,8 +20,10 @@ static bool resume_hkdf_(const uint8_t *key, size_t key_len, const uint8_t *data
|
||||
return err == NOISE_ERROR_NONE;
|
||||
}
|
||||
|
||||
static bool resume_mac_(const uint8_t *secret, const char *label, size_t label_len, const uint8_t *a, size_t a_len,
|
||||
const uint8_t *b, size_t b_len, uint8_t *out_mac) {
|
||||
template<size_t N>
|
||||
static bool resume_mac_(const uint8_t *secret, const char (&label)[N], const uint8_t *a, size_t a_len, const uint8_t *b,
|
||||
size_t b_len, uint8_t *out_mac) {
|
||||
constexpr size_t label_len = N - 1; // drop the terminating NUL
|
||||
// label || a || b, largest use is "confirm"(7) + 16 + 16 = 39
|
||||
uint8_t data[7 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE];
|
||||
uint8_t scratch[32];
|
||||
@@ -43,63 +32,96 @@ static bool resume_mac_(const uint8_t *secret, const char *label, size_t label_l
|
||||
std::memcpy(data + label_len + a_len, b, b_len);
|
||||
bool ok = resume_hkdf_(secret, RESUME_SECRET_SIZE, data, label_len + a_len + b_len, out_mac, RESUME_MAC_SIZE, scratch,
|
||||
sizeof(scratch));
|
||||
resume_wipe(scratch, sizeof(scratch));
|
||||
noise_clean(scratch, sizeof(scratch));
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool ResumeTicketCache::issue(ResumeTicket &out) {
|
||||
ResumeTicket ticket;
|
||||
if (!random_bytes(ticket.session_id, RESUME_SESSION_ID_SIZE) || !random_bytes(ticket.secret, RESUME_SECRET_SIZE)) {
|
||||
if (!random_bytes(out.session_id, RESUME_SESSION_ID_SIZE) || !random_bytes(out.secret, RESUME_SECRET_SIZE)) {
|
||||
return false;
|
||||
}
|
||||
ticket.valid = true;
|
||||
ResumeTicket &slot = this->slots_[this->next_];
|
||||
this->next_ = static_cast<uint8_t>((this->next_ + 1) % SLOTS);
|
||||
slot = ticket;
|
||||
out = ticket;
|
||||
resume_wipe(&ticket, sizeof(ticket));
|
||||
uint8_t slot = this->next_;
|
||||
this->next_ = static_cast<uint8_t>((slot + 1) % SLOTS);
|
||||
this->slots_[slot] = out;
|
||||
this->used_[slot] = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ResumeTicketCache::take_verified(const uint8_t *offer, uint8_t *secret_out) {
|
||||
bool ResumeTicketCache::take_verified_(const uint8_t *offer, uint8_t *secret_out) {
|
||||
const uint8_t *session_id = offer + RESUME_OFFER_SESSION_ID_OFFSET;
|
||||
const uint8_t *client_nonce = offer + RESUME_OFFER_NONCE_OFFSET;
|
||||
const uint8_t *offer_mac = offer + RESUME_OFFER_MAC_OFFSET;
|
||||
for (ResumeTicket &slot : this->slots_) {
|
||||
if (!slot.valid || std::memcmp(slot.session_id, session_id, RESUME_SESSION_ID_SIZE) != 0) {
|
||||
for (uint8_t i = 0; i < SLOTS; i++) {
|
||||
ResumeTicket &slot = this->slots_[i];
|
||||
if (!this->used_[i] || std::memcmp(slot.session_id, session_id, RESUME_SESSION_ID_SIZE) != 0) {
|
||||
continue;
|
||||
}
|
||||
uint8_t expected[RESUME_MAC_SIZE];
|
||||
bool ok = resume_compute_offer_mac(slot.secret, session_id, client_nonce, expected) &&
|
||||
resume_ct_equal_(expected, offer_mac, RESUME_MAC_SIZE);
|
||||
resume_wipe(expected, sizeof(expected));
|
||||
noise_is_equal(expected, offer_mac, RESUME_MAC_SIZE);
|
||||
noise_clean(expected, sizeof(expected));
|
||||
if (!ok) {
|
||||
// Bad MAC: leave the ticket so a forger cannot burn it
|
||||
return false;
|
||||
}
|
||||
std::memcpy(secret_out, slot.secret, RESUME_SECRET_SIZE);
|
||||
resume_wipe(&slot, sizeof(slot));
|
||||
slot.valid = false;
|
||||
noise_clean(&slot, sizeof(slot));
|
||||
this->used_[i] = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void ResumeTicketCache::clear() {
|
||||
resume_wipe(this->slots_, sizeof(this->slots_));
|
||||
for (ResumeTicket &slot : this->slots_) {
|
||||
slot.valid = false;
|
||||
bool ResumeTicketCache::try_accept(const uint8_t *offer, size_t offer_len, const uint8_t *prologue, size_t prologue_len,
|
||||
uint8_t *out_ext, NoiseCipherState *&send_cipher, NoiseCipherState *&recv_cipher) {
|
||||
if (offer_len != RESUME_OFFER_SIZE || offer[0] != RESUME_OFFER_VERSION) {
|
||||
return false;
|
||||
}
|
||||
uint8_t secret[RESUME_SECRET_SIZE];
|
||||
if (!this->take_verified_(offer, secret)) {
|
||||
return false;
|
||||
}
|
||||
// From here every failure still falls back to the full handshake; the
|
||||
// ticket is spent, which is harmless (the client gets a fresh one).
|
||||
const uint8_t *client_nonce = offer + RESUME_OFFER_NONCE_OFFSET;
|
||||
uint8_t *server_nonce = out_ext + 1;
|
||||
uint8_t *confirm_mac = out_ext + 1 + RESUME_NONCE_SIZE;
|
||||
uint8_t k_c2d[32];
|
||||
uint8_t k_d2c[32];
|
||||
out_ext[0] = RESUME_ACCEPT_VERSION;
|
||||
bool ok = random_bytes(server_nonce, RESUME_NONCE_SIZE) &&
|
||||
resume_compute_confirm_mac(secret, client_nonce, server_nonce, confirm_mac) &&
|
||||
resume_derive_keys(secret, client_nonce, server_nonce, prologue, prologue_len, k_c2d, k_d2c);
|
||||
noise_clean(secret, sizeof(secret));
|
||||
if (ok) {
|
||||
recv_cipher = resume_make_cipher(k_c2d);
|
||||
send_cipher = resume_make_cipher(k_d2c);
|
||||
ok = recv_cipher != nullptr && send_cipher != nullptr;
|
||||
if (!ok) {
|
||||
noise_cipherstate_free(recv_cipher);
|
||||
noise_cipherstate_free(send_cipher);
|
||||
recv_cipher = nullptr;
|
||||
send_cipher = nullptr;
|
||||
}
|
||||
}
|
||||
noise_clean(k_c2d, sizeof(k_c2d));
|
||||
noise_clean(k_d2c, sizeof(k_d2c));
|
||||
return ok;
|
||||
}
|
||||
|
||||
void ResumeTicketCache::clear() {
|
||||
noise_clean(this->slots_, sizeof(this->slots_));
|
||||
std::memset(this->used_, 0, sizeof(this->used_));
|
||||
}
|
||||
|
||||
bool resume_compute_offer_mac(const uint8_t *secret, const uint8_t *session_id, const uint8_t *client_nonce,
|
||||
uint8_t *out_mac) {
|
||||
return resume_mac_(secret, "offer", 5, session_id, RESUME_SESSION_ID_SIZE, client_nonce, RESUME_NONCE_SIZE, out_mac);
|
||||
return resume_mac_(secret, "offer", session_id, RESUME_SESSION_ID_SIZE, client_nonce, RESUME_NONCE_SIZE, out_mac);
|
||||
}
|
||||
|
||||
bool resume_compute_confirm_mac(const uint8_t *secret, const uint8_t *client_nonce, const uint8_t *server_nonce,
|
||||
uint8_t *out_mac) {
|
||||
return resume_mac_(secret, "confirm", 7, client_nonce, RESUME_NONCE_SIZE, server_nonce, RESUME_NONCE_SIZE, out_mac);
|
||||
return resume_mac_(secret, "confirm", client_nonce, RESUME_NONCE_SIZE, server_nonce, RESUME_NONCE_SIZE, out_mac);
|
||||
}
|
||||
|
||||
bool resume_derive_keys(const uint8_t *secret, const uint8_t *client_nonce, const uint8_t *server_nonce,
|
||||
@@ -118,7 +140,7 @@ bool resume_derive_keys(const uint8_t *secret, const uint8_t *client_nonce, cons
|
||||
err = noise_hashstate_hkdf(hash, secret, RESUME_SECRET_SIZE, data, sizeof(data), k_c2d, 32, k_d2c, 32);
|
||||
}
|
||||
noise_hashstate_free(hash);
|
||||
resume_wipe(data, sizeof(data));
|
||||
noise_clean(data, sizeof(data));
|
||||
return err == NOISE_ERROR_NONE;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include <noise/protocol.h>
|
||||
// Forward declaration matching <noise/protocol/cipherstate.h>; keeps noise-c
|
||||
// headers out of everything that includes noise.h.
|
||||
extern "C" {
|
||||
typedef struct NoiseCipherState_s NoiseCipherState; // NOLINT(modernize-use-using)
|
||||
}
|
||||
|
||||
namespace esphome::noise {
|
||||
|
||||
@@ -48,35 +52,39 @@ static constexpr size_t RESUME_ACCEPT_SIZE = 1 + RESUME_NONCE_SIZE + RESUME_MAC_
|
||||
struct ResumeTicket {
|
||||
uint8_t session_id[RESUME_SESSION_ID_SIZE];
|
||||
uint8_t secret[RESUME_SECRET_SIZE];
|
||||
bool valid{false};
|
||||
};
|
||||
|
||||
/// Fixed-slot RAM cache of single-use resume tickets. Lost on reboot by
|
||||
/// design: clients fall back to a full handshake.
|
||||
class ResumeTicketCache {
|
||||
public:
|
||||
/// Generate and store a fresh ticket, evicting the oldest slot.
|
||||
/// Returns false (and stores nothing) if the RNG fails.
|
||||
/// Generate a fresh ticket into out and store it, evicting the oldest
|
||||
/// slot. Returns false (and stores nothing) if the RNG fails.
|
||||
bool issue(ResumeTicket &out);
|
||||
/// Verify a wire offer (RESUME_OFFER_SIZE bytes, version already checked).
|
||||
/// On a valid MAC the ticket is consumed (single use) and its secret is
|
||||
/// copied to secret_out. A miss or a bad MAC leaves the cache unchanged so
|
||||
/// an attacker cannot burn tickets.
|
||||
bool take_verified(const uint8_t *offer, uint8_t *secret_out);
|
||||
/// Try to accept a resume offer: recognizes the offer format, verifies
|
||||
/// its MAC against a cached ticket (consuming it, single use), derives
|
||||
/// the transport keys bound to the prologue, builds both ChaChaPoly
|
||||
/// cipher states, and fills the RESUME_ACCEPT_SIZE ServerHello extension
|
||||
/// proving possession of the secret. A miss, a bad MAC, or any internal
|
||||
/// failure returns false with nothing allocated, and a forged offer never
|
||||
/// burns a ticket. k_c2d decrypts client-to-device traffic (recv), k_d2c
|
||||
/// encrypts device-to-client (send). All secrets are wiped internally.
|
||||
bool try_accept(const uint8_t *offer, size_t offer_len, const uint8_t *prologue, size_t prologue_len,
|
||||
uint8_t *out_ext, NoiseCipherState *&send_cipher, NoiseCipherState *&recv_cipher);
|
||||
/// Forget every ticket (PSK change).
|
||||
void clear();
|
||||
|
||||
protected:
|
||||
bool take_verified_(const uint8_t *offer, uint8_t *secret_out);
|
||||
|
||||
static constexpr uint8_t SLOTS = 4;
|
||||
ResumeTicket slots_[SLOTS];
|
||||
bool used_[SLOTS]{};
|
||||
uint8_t next_{0};
|
||||
};
|
||||
|
||||
/// Best-effort secure wipe (not optimized away).
|
||||
void resume_wipe(void *p, size_t len);
|
||||
|
||||
/// offer_mac for the ClientHello resume offer (what a client computes and
|
||||
/// take_verified checks).
|
||||
/// try_accept checks).
|
||||
bool resume_compute_offer_mac(const uint8_t *secret, const uint8_t *session_id, const uint8_t *client_nonce,
|
||||
uint8_t *out_mac);
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include <noise/protocol.h>
|
||||
|
||||
#include "esphome/components/noise/noise.h"
|
||||
#include "esphome/components/noise/noise_resume.h"
|
||||
|
||||
@@ -13,21 +15,10 @@ namespace esphome::noise::testing {
|
||||
static const uint8_t KAT_SECRET[RESUME_SECRET_SIZE] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32};
|
||||
static const uint8_t KAT_SESSION_ID[RESUME_SESSION_ID_SIZE] = {0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7};
|
||||
|
||||
static void fill_nonces(uint8_t *client_nonce, uint8_t *server_nonce) {
|
||||
for (int i = 0; i < 16; i++) {
|
||||
client_nonce[i] = 0x10 + i;
|
||||
server_nonce[i] = 0x30 + i;
|
||||
}
|
||||
}
|
||||
|
||||
static void build_kat_offer(uint8_t *offer, const uint8_t *client_nonce, const uint8_t *offer_mac) {
|
||||
offer[0] = RESUME_OFFER_VERSION;
|
||||
std::memcpy(offer + RESUME_OFFER_SESSION_ID_OFFSET, KAT_SESSION_ID, RESUME_SESSION_ID_SIZE);
|
||||
std::memcpy(offer + RESUME_OFFER_NONCE_OFFSET, client_nonce, RESUME_NONCE_SIZE);
|
||||
std::memcpy(offer + RESUME_OFFER_MAC_OFFSET, offer_mac, RESUME_MAC_SIZE);
|
||||
}
|
||||
|
||||
static const uint8_t KAT_CLIENT_NONCE[RESUME_NONCE_SIZE] = {0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
|
||||
0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f};
|
||||
static const uint8_t KAT_SERVER_NONCE[RESUME_NONCE_SIZE] = {0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37,
|
||||
0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f};
|
||||
static const uint8_t KAT_OFFER_MAC[RESUME_MAC_SIZE] = {0xa8, 0x08, 0xea, 0xdb, 0xec, 0x81, 0xa7, 0xcb,
|
||||
0xf4, 0xca, 0xaa, 0xb8, 0x0d, 0x7f, 0x9d, 0x01};
|
||||
static const uint8_t KAT_CONFIRM_MAC[RESUME_MAC_SIZE] = {0x09, 0xa3, 0x70, 0x3e, 0xc8, 0x34, 0x77, 0xe9,
|
||||
@@ -39,88 +30,132 @@ static const uint8_t KAT_K_D2C[32] = {0x7f, 0x8d, 0x57, 0x7e, 0x9f, 0xb4, 0xbb,
|
||||
0xf4, 0x9b, 0x42, 0xe7, 0x24, 0xc8, 0x49, 0xce, 0x89, 0xd8, 0x96,
|
||||
0x3f, 0x3c, 0x4b, 0x3f, 0x8f, 0x80, 0xc2, 0x56, 0xab, 0x65};
|
||||
|
||||
/// The one place in this file that spells the offer wire layout
|
||||
static void build_offer(uint8_t *offer, const uint8_t *session_id, const uint8_t *client_nonce, const uint8_t *mac) {
|
||||
offer[0] = RESUME_OFFER_VERSION;
|
||||
std::memcpy(offer + RESUME_OFFER_SESSION_ID_OFFSET, session_id, RESUME_SESSION_ID_SIZE);
|
||||
std::memcpy(offer + RESUME_OFFER_NONCE_OFFSET, client_nonce, RESUME_NONCE_SIZE);
|
||||
std::memcpy(offer + RESUME_OFFER_MAC_OFFSET, mac, RESUME_MAC_SIZE);
|
||||
}
|
||||
|
||||
static void build_offer_for_ticket(uint8_t *offer, const ResumeTicket &ticket, const uint8_t *client_nonce) {
|
||||
uint8_t mac[RESUME_MAC_SIZE];
|
||||
ASSERT_TRUE(resume_compute_offer_mac(ticket.secret, ticket.session_id, client_nonce, mac));
|
||||
build_offer(offer, ticket.session_id, client_nonce, mac);
|
||||
}
|
||||
|
||||
/// Test access to the protected slots so a test can plant the KAT ticket
|
||||
struct TestCache : ResumeTicketCache {
|
||||
void plant(const uint8_t *session_id, const uint8_t *secret) {
|
||||
std::memcpy(this->slots_[0].session_id, session_id, RESUME_SESSION_ID_SIZE);
|
||||
std::memcpy(this->slots_[0].secret, secret, RESUME_SECRET_SIZE);
|
||||
this->used_[0] = true;
|
||||
}
|
||||
};
|
||||
|
||||
TEST(NoiseResumeKat, ConfirmMacMatchesClientImplementation) {
|
||||
uint8_t client_nonce[16], server_nonce[16], mac[RESUME_MAC_SIZE];
|
||||
fill_nonces(client_nonce, server_nonce);
|
||||
ASSERT_TRUE(resume_compute_confirm_mac(KAT_SECRET, client_nonce, server_nonce, mac));
|
||||
uint8_t mac[RESUME_MAC_SIZE];
|
||||
ASSERT_TRUE(resume_compute_confirm_mac(KAT_SECRET, KAT_CLIENT_NONCE, KAT_SERVER_NONCE, mac));
|
||||
EXPECT_EQ(std::memcmp(mac, KAT_CONFIRM_MAC, RESUME_MAC_SIZE), 0);
|
||||
}
|
||||
|
||||
TEST(NoiseResumeKat, OfferMacMatchesClientImplementation) {
|
||||
uint8_t mac[RESUME_MAC_SIZE];
|
||||
ASSERT_TRUE(resume_compute_offer_mac(KAT_SECRET, KAT_SESSION_ID, KAT_CLIENT_NONCE, mac));
|
||||
EXPECT_EQ(std::memcmp(mac, KAT_OFFER_MAC, RESUME_MAC_SIZE), 0);
|
||||
}
|
||||
|
||||
TEST(NoiseResumeKat, KeyDerivationMatchesClientImplementation) {
|
||||
uint8_t client_nonce[16], server_nonce[16];
|
||||
fill_nonces(client_nonce, server_nonce);
|
||||
// Prologue used by the shared vectors: "NoiseAPIInit" + be16(41) + a
|
||||
// 41-byte offer whose MAC field is 16 bytes of 0xEE
|
||||
uint8_t prologue[55];
|
||||
uint8_t prologue[14 + RESUME_OFFER_SIZE];
|
||||
std::memcpy(prologue, "NoiseAPIInit", 12);
|
||||
prologue[12] = 0x00;
|
||||
prologue[13] = 0x29;
|
||||
prologue[13] = RESUME_OFFER_SIZE;
|
||||
uint8_t mac_filler[RESUME_MAC_SIZE];
|
||||
std::memset(mac_filler, 0xEE, sizeof(mac_filler));
|
||||
build_kat_offer(prologue + 14, client_nonce, mac_filler);
|
||||
build_offer(prologue + 14, KAT_SESSION_ID, KAT_CLIENT_NONCE, mac_filler);
|
||||
|
||||
uint8_t k_c2d[32], k_d2c[32];
|
||||
ASSERT_TRUE(resume_derive_keys(KAT_SECRET, client_nonce, server_nonce, prologue, sizeof(prologue), k_c2d, k_d2c));
|
||||
ASSERT_TRUE(
|
||||
resume_derive_keys(KAT_SECRET, KAT_CLIENT_NONCE, KAT_SERVER_NONCE, prologue, sizeof(prologue), k_c2d, k_d2c));
|
||||
EXPECT_EQ(std::memcmp(k_c2d, KAT_K_C2D, 32), 0);
|
||||
EXPECT_EQ(std::memcmp(k_d2c, KAT_K_D2C, 32), 0);
|
||||
}
|
||||
|
||||
TEST(NoiseResumeCache, TakeVerifiedConsumesTicketOnce) {
|
||||
ResumeTicketCache cache;
|
||||
// Plant the KAT ticket directly through the protected members via a
|
||||
// subclass so the test controls the session id and secret.
|
||||
struct TestCache : ResumeTicketCache {
|
||||
void plant(const uint8_t *session_id, const uint8_t *secret) {
|
||||
std::memcpy(this->slots_[0].session_id, session_id, RESUME_SESSION_ID_SIZE);
|
||||
std::memcpy(this->slots_[0].secret, secret, RESUME_SECRET_SIZE);
|
||||
this->slots_[0].valid = true;
|
||||
}
|
||||
} test_cache;
|
||||
test_cache.plant(KAT_SESSION_ID, KAT_SECRET);
|
||||
TEST(NoiseResumeCache, TryAcceptConsumesTicketOnceAndProvesPossession) {
|
||||
TestCache cache;
|
||||
cache.plant(KAT_SESSION_ID, KAT_SECRET);
|
||||
|
||||
uint8_t client_nonce[16], server_nonce[16];
|
||||
fill_nonces(client_nonce, server_nonce);
|
||||
uint8_t offer[RESUME_OFFER_SIZE];
|
||||
build_kat_offer(offer, client_nonce, KAT_OFFER_MAC);
|
||||
build_offer(offer, KAT_SESSION_ID, KAT_CLIENT_NONCE, KAT_OFFER_MAC);
|
||||
uint8_t prologue[14 + RESUME_OFFER_SIZE];
|
||||
std::memcpy(prologue, "NoiseAPIInit", 12);
|
||||
prologue[12] = 0x00;
|
||||
prologue[13] = RESUME_OFFER_SIZE;
|
||||
std::memcpy(prologue + 14, offer, RESUME_OFFER_SIZE);
|
||||
|
||||
uint8_t ext[RESUME_ACCEPT_SIZE];
|
||||
NoiseCipherState *send = nullptr, *recv = nullptr;
|
||||
ASSERT_TRUE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv));
|
||||
ASSERT_NE(send, nullptr);
|
||||
ASSERT_NE(recv, nullptr);
|
||||
|
||||
// The extension proves possession: verify like the client does
|
||||
EXPECT_EQ(ext[0], RESUME_ACCEPT_VERSION);
|
||||
const uint8_t *server_nonce = ext + 1;
|
||||
uint8_t expected_confirm[RESUME_MAC_SIZE];
|
||||
ASSERT_TRUE(resume_compute_confirm_mac(KAT_SECRET, KAT_CLIENT_NONCE, server_nonce, expected_confirm));
|
||||
EXPECT_EQ(std::memcmp(ext + 1 + RESUME_NONCE_SIZE, expected_confirm, RESUME_MAC_SIZE), 0);
|
||||
|
||||
// The ciphers must interoperate with the documented key derivation
|
||||
uint8_t k_c2d[32], k_d2c[32];
|
||||
ASSERT_TRUE(resume_derive_keys(KAT_SECRET, KAT_CLIENT_NONCE, server_nonce, prologue, sizeof(prologue), k_c2d, k_d2c));
|
||||
NoiseCipherState *client_send = resume_make_cipher(k_c2d);
|
||||
ASSERT_NE(client_send, nullptr);
|
||||
uint8_t buf[64] = "resumed";
|
||||
NoiseBuffer nb;
|
||||
noise_buffer_init(nb);
|
||||
noise_buffer_set_inout(nb, buf, 7, sizeof(buf));
|
||||
ASSERT_EQ(noise_cipherstate_encrypt(client_send, &nb), NOISE_ERROR_NONE);
|
||||
ASSERT_EQ(noise_cipherstate_decrypt(recv, &nb), NOISE_ERROR_NONE);
|
||||
EXPECT_EQ(std::memcmp(buf, "resumed", 7), 0);
|
||||
noise_cipherstate_free(client_send);
|
||||
noise_cipherstate_free(send);
|
||||
noise_cipherstate_free(recv);
|
||||
|
||||
uint8_t secret[RESUME_SECRET_SIZE];
|
||||
ASSERT_TRUE(test_cache.take_verified(offer, secret));
|
||||
EXPECT_EQ(std::memcmp(secret, KAT_SECRET, RESUME_SECRET_SIZE), 0);
|
||||
// Single use: the same offer must miss the second time
|
||||
EXPECT_FALSE(test_cache.take_verified(offer, secret));
|
||||
NoiseCipherState *send2 = nullptr, *recv2 = nullptr;
|
||||
EXPECT_FALSE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send2, recv2));
|
||||
EXPECT_EQ(send2, nullptr);
|
||||
EXPECT_EQ(recv2, nullptr);
|
||||
}
|
||||
|
||||
TEST(NoiseResumeCache, BadMacLeavesTicketIntact) {
|
||||
struct TestCache : ResumeTicketCache {
|
||||
void plant(const uint8_t *session_id, const uint8_t *secret) {
|
||||
std::memcpy(this->slots_[0].session_id, session_id, RESUME_SESSION_ID_SIZE);
|
||||
std::memcpy(this->slots_[0].secret, secret, RESUME_SECRET_SIZE);
|
||||
this->slots_[0].valid = true;
|
||||
}
|
||||
} test_cache;
|
||||
test_cache.plant(KAT_SESSION_ID, KAT_SECRET);
|
||||
TEST(NoiseResumeCache, BadMacOrMalformedOfferLeavesTicketIntact) {
|
||||
TestCache cache;
|
||||
cache.plant(KAT_SESSION_ID, KAT_SECRET);
|
||||
|
||||
uint8_t client_nonce[16], server_nonce[16];
|
||||
fill_nonces(client_nonce, server_nonce);
|
||||
uint8_t offer[RESUME_OFFER_SIZE];
|
||||
uint8_t bad_mac[RESUME_MAC_SIZE];
|
||||
std::memcpy(bad_mac, KAT_OFFER_MAC, RESUME_MAC_SIZE);
|
||||
bad_mac[0] ^= 0x01;
|
||||
build_kat_offer(offer, client_nonce, bad_mac);
|
||||
build_offer(offer, KAT_SESSION_ID, KAT_CLIENT_NONCE, bad_mac);
|
||||
|
||||
uint8_t secret[RESUME_SECRET_SIZE];
|
||||
uint8_t prologue[1] = {0};
|
||||
uint8_t ext[RESUME_ACCEPT_SIZE];
|
||||
NoiseCipherState *send = nullptr, *recv = nullptr;
|
||||
// A forged offer must not burn the ticket
|
||||
EXPECT_FALSE(test_cache.take_verified(offer, secret));
|
||||
build_kat_offer(offer, client_nonce, KAT_OFFER_MAC);
|
||||
EXPECT_TRUE(test_cache.take_verified(offer, secret));
|
||||
}
|
||||
|
||||
static void build_offer_for_ticket(uint8_t *offer, const ResumeTicket &ticket, const uint8_t *client_nonce) {
|
||||
EXPECT_FALSE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv));
|
||||
// Wrong size or version must be recognized as "no offer"
|
||||
build_offer(offer, KAT_SESSION_ID, KAT_CLIENT_NONCE, KAT_OFFER_MAC);
|
||||
EXPECT_FALSE(cache.try_accept(offer, sizeof(offer) - 1, prologue, sizeof(prologue), ext, send, recv));
|
||||
offer[0] = 0x7f;
|
||||
EXPECT_FALSE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv));
|
||||
offer[0] = RESUME_OFFER_VERSION;
|
||||
std::memcpy(offer + RESUME_OFFER_SESSION_ID_OFFSET, ticket.session_id, RESUME_SESSION_ID_SIZE);
|
||||
std::memcpy(offer + RESUME_OFFER_NONCE_OFFSET, client_nonce, RESUME_NONCE_SIZE);
|
||||
ASSERT_TRUE(
|
||||
resume_compute_offer_mac(ticket.secret, ticket.session_id, client_nonce, offer + RESUME_OFFER_MAC_OFFSET));
|
||||
// The genuine offer still redeems
|
||||
EXPECT_TRUE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv));
|
||||
noise_cipherstate_free(send);
|
||||
noise_cipherstate_free(recv);
|
||||
}
|
||||
|
||||
TEST(NoiseResumeCache, IssueRotatesSlotsAndClearForgetsAll) {
|
||||
@@ -128,29 +163,30 @@ TEST(NoiseResumeCache, IssueRotatesSlotsAndClearForgetsAll) {
|
||||
ResumeTicket tickets[5];
|
||||
for (auto &ticket : tickets) {
|
||||
ASSERT_TRUE(cache.issue(ticket));
|
||||
ASSERT_TRUE(ticket.valid);
|
||||
}
|
||||
uint8_t client_nonce[16], server_nonce[16];
|
||||
fill_nonces(client_nonce, server_nonce);
|
||||
uint8_t offer[RESUME_OFFER_SIZE];
|
||||
uint8_t secret[RESUME_SECRET_SIZE];
|
||||
uint8_t prologue[1] = {0};
|
||||
uint8_t ext[RESUME_ACCEPT_SIZE];
|
||||
|
||||
// Slot 0 was evicted by the fifth issue
|
||||
build_offer_for_ticket(offer, tickets[0], client_nonce);
|
||||
EXPECT_FALSE(cache.take_verified(offer, secret));
|
||||
build_offer_for_ticket(offer, tickets[0], KAT_CLIENT_NONCE);
|
||||
NoiseCipherState *send = nullptr, *recv = nullptr;
|
||||
EXPECT_FALSE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv));
|
||||
// Tickets 1..4 remain redeemable
|
||||
for (int i = 1; i < 5; i++) {
|
||||
build_offer_for_ticket(offer, tickets[i], client_nonce);
|
||||
EXPECT_TRUE(cache.take_verified(offer, secret));
|
||||
EXPECT_EQ(std::memcmp(secret, tickets[i].secret, RESUME_SECRET_SIZE), 0);
|
||||
build_offer_for_ticket(offer, tickets[i], KAT_CLIENT_NONCE);
|
||||
EXPECT_TRUE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv));
|
||||
noise_cipherstate_free(send);
|
||||
noise_cipherstate_free(recv);
|
||||
send = recv = nullptr;
|
||||
}
|
||||
|
||||
// clear() forgets everything
|
||||
ResumeTicket ticket;
|
||||
ASSERT_TRUE(cache.issue(ticket));
|
||||
cache.clear();
|
||||
build_offer_for_ticket(offer, ticket, client_nonce);
|
||||
EXPECT_FALSE(cache.take_verified(offer, secret));
|
||||
build_offer_for_ticket(offer, ticket, KAT_CLIENT_NONCE);
|
||||
EXPECT_FALSE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv));
|
||||
}
|
||||
|
||||
} // namespace esphome::noise::testing
|
||||
|
||||
Reference in New Issue
Block a user