[noise] Trim resume flash: one KDF, no discard state, packed ticket

This commit is contained in:
J. Nick Koston
2026-08-24 16:31:46 -05:00
parent d1f7e71efb
commit 1cb2136c38
10 changed files with 60 additions and 105 deletions
+1 -2
View File
@@ -902,8 +902,7 @@ message NoiseResumeTicket {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_API_NOISE";
bytes session_id = 1; // 8 bytes
bytes secret = 2; // 32 bytes
bytes ticket = 1; // session_id(8) || secret(32)
}
// ==================== HOMEASSISTANT.SERVICE ====================
+3 -6
View File
@@ -1763,12 +1763,9 @@ void APIConnection::send_resume_ticket_() {
return;
}
NoiseResumeTicket msg;
msg.set_session_id(ticket.session_id, noise::RESUME_SESSION_ID_SIZE);
msg.set_secret(ticket.secret, noise::RESUME_SECRET_SIZE);
if (!this->send_message(msg)) {
// Harmless: the client simply does a full handshake next time
API_LOG_MSG_DROPPED(TAG, "Resume ticket");
}
msg.set_ticket(reinterpret_cast<const uint8_t *>(&ticket), sizeof(ticket));
// A dropped ticket is harmless: the client does a full handshake next time
static_cast<void>(this->send_message(msg));
noise_clean(&ticket, sizeof(ticket));
}
#endif
@@ -283,7 +283,6 @@ 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,8 +252,6 @@ 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;
@@ -323,18 +321,20 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
return aerr;
if (resume) {
this->prologue_.release();
// A resuming client waits for this hello instead of pipelining
// handshake message 1, so the transport is ready now
this->frame_footer_size_ = noise_cipherstate_get_mac_length(this->send_cipher_);
state_ = State::RESUME_DISCARD;
return APIError::OK;
HELPER_LOG("Session resumed!");
state_ = State::DATA;
} else {
aerr = init_handshake_();
if (aerr != APIError::OK)
return aerr;
state_ = State::HANDSHAKE;
}
// start handshake
aerr = init_handshake_();
if (aerr != APIError::OK)
return aerr;
state_ = State::HANDSHAKE;
// init_handshake_ copied the prologue into the handshake state; the resume
// path is done with it too
this->prologue_.release();
return APIError::OK;
}
APIError APINoiseFrameHelper::state_action_handshake_() {
@@ -349,41 +349,20 @@ APIError APINoiseFrameHelper::state_action_handshake_() {
HELPER_LOG("Bad action for handshake: %d", (int) action);
return APIError::HANDSHAKESTATE_BAD_STATE;
}
/// Read one handshake frame into rx_buf_ and validate its status byte.
APIError APINoiseFrameHelper::read_handshake_frame_() {
APIError APINoiseFrameHelper::state_action_handshake_read_() {
APIError aerr = this->try_read_frame_();
if (aerr != APIError::OK) {
return this->handle_handshake_frame_error_(aerr);
}
if (this->rx_buf_.empty()) {
this->send_explicit_handshake_reject_(LOG_STR("Empty handshake message"));
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
}
if (this->rx_buf_[0] != noise::HANDSHAKE_STATUS_OK) {
} else if (this->rx_buf_[0] != noise::HANDSHAKE_STATUS_OK) {
HELPER_LOG("Bad handshake error byte: %u", this->rx_buf_[0]);
this->send_explicit_handshake_reject_(LOG_STR("Bad handshake error byte"));
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
}
return APIError::OK;
}
/// Resumed session: discard the client's already-in-flight handshake
/// message 1, then enter DATA.
APIError APINoiseFrameHelper::state_action_resume_discard_() {
APIError aerr = this->read_handshake_frame_();
if (aerr != APIError::OK) {
return aerr;
}
HELPER_LOG("Session resumed!");
state_ = State::DATA;
return APIError::OK;
}
APIError APINoiseFrameHelper::state_action_handshake_read_() {
APIError aerr = this->read_handshake_frame_();
if (aerr != APIError::OK) {
return aerr;
}
int err = this->handshake_.read_message(this->rx_buf_.data() + 1, this->rx_buf_.size() - 1);
if (err != 0) {
@@ -583,8 +562,6 @@ APIError APINoiseFrameHelper::init_handshake_() {
APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshake_init"), APIError::HANDSHAKESTATE_SETUP_FAILED);
if (aerr != APIError::OK)
return aerr;
// init copies the prologue into the handshakestate, so we can get rid of it now
prologue_.release();
return APIError::OK;
}
@@ -42,8 +42,6 @@ class APINoiseFrameHelper final : public APIFrameHelper {
APIError state_action_handshake_();
APIError state_action_handshake_read_();
APIError state_action_handshake_write_();
APIError state_action_resume_discard_();
APIError read_handshake_frame_();
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,
+2 -4
View File
@@ -1061,14 +1061,12 @@ uint32_t NoiseEncryptionSetKeyResponse::calculate_size() const {
}
uint8_t *NoiseResumeTicket::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->session_id_ptr_, this->session_id_len_);
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->secret_ptr_, this->secret_len_);
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->ticket_ptr_, this->ticket_len_);
return pos;
}
uint32_t NoiseResumeTicket::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_length(1, this->session_id_len_);
size += ProtoSize::calc_length(1, this->secret_len_);
size += ProtoSize::calc_length(1, this->ticket_len_);
return size;
}
#endif
+6 -12
View File
@@ -1136,21 +1136,15 @@ class NoiseEncryptionSetKeyResponse final : public ProtoMessage {
class NoiseResumeTicket final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 152;
static constexpr uint8_t ESTIMATED_SIZE = 38;
static constexpr uint8_t ESTIMATED_SIZE = 19;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("noise_resume_ticket"); }
#endif
const uint8_t *session_id_ptr_{nullptr};
size_t session_id_len_{0};
void set_session_id(const uint8_t *data, size_t len) {
this->session_id_ptr_ = data;
this->session_id_len_ = len;
}
const uint8_t *secret_ptr_{nullptr};
size_t secret_len_{0};
void set_secret(const uint8_t *data, size_t len) {
this->secret_ptr_ = data;
this->secret_len_ = len;
const uint8_t *ticket_ptr_{nullptr};
size_t ticket_len_{0};
void set_ticket(const uint8_t *data, size_t len) {
this->ticket_ptr_ = data;
this->ticket_len_ = len;
}
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
+29 -38
View File
@@ -8,49 +8,45 @@
namespace esphome::noise {
/// Noise-construction HKDF-SHA256 keyed with the ticket secret. When
/// hash_in is given, SHA256(hash_in) is written to hash_out first with the
/// same hash state (hash_out is expected to point inside data).
static bool resume_hkdf(const uint8_t *secret, const uint8_t *data, size_t data_len, uint8_t *out1, size_t out1_len,
uint8_t *out2, size_t out2_len, const uint8_t *hash_in = nullptr, size_t hash_in_len = 0,
uint8_t *hash_out = nullptr) {
/// Noise-construction HKDF-SHA256 keyed with the ticket secret over
/// label || a || b [|| SHA256(hash_in)]. out2 == nullptr means MAC only.
static bool resume_kdf(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, const uint8_t *hash_in, size_t hash_in_len, uint8_t *out1,
size_t out1_len, uint8_t *out2) {
// largest use is "confirm"(7) + 16 + 16, or "keys"(4) + 16 + 16 + 32
uint8_t data[7 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE + 32];
uint8_t scratch[32];
size_t len = label_len + a_len + b_len;
std::memcpy(data, label, label_len); // NOLINT(bugprone-not-null-terminated-result)
std::memcpy(data + label_len, a, a_len);
std::memcpy(data + label_len + a_len, b, b_len);
NoiseHashState *hash = nullptr;
if (noise_hashstate_new_by_id(&hash, NOISE_HASH_SHA256) != NOISE_ERROR_NONE) {
return false;
}
int err = NOISE_ERROR_NONE;
if (hash_in != nullptr) {
err = noise_hashstate_hash_one(hash, hash_in, hash_in_len, hash_out, 32);
err = noise_hashstate_hash_one(hash, hash_in, hash_in_len, data + len, 32);
len += 32;
}
if (err == NOISE_ERROR_NONE) {
err = noise_hashstate_hkdf(hash, secret, RESUME_SECRET_SIZE, data, data_len, out1, out1_len, out2, out2_len);
err = noise_hashstate_hkdf(hash, secret, RESUME_SECRET_SIZE, data, len, out1, out1_len,
out2 != nullptr ? out2 : scratch, 32);
}
noise_hashstate_free(hash);
noise_clean(data, sizeof(data));
noise_clean(scratch, sizeof(scratch));
return err == NOISE_ERROR_NONE;
}
/// MAC = HKDF(secret, label || a || b).out1[:16]
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) {
// largest use is "confirm"(7) + 16 + 16 = 39
uint8_t data[7 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE];
uint8_t scratch[32];
std::memcpy(data, label, label_len); // NOLINT(bugprone-not-null-terminated-result)
std::memcpy(data + label_len, a, a_len);
std::memcpy(data + label_len + a_len, b, b_len);
bool ok = resume_hkdf(secret, data, label_len + a_len + b_len, out_mac, RESUME_MAC_SIZE, scratch, sizeof(scratch));
noise_clean(scratch, sizeof(scratch));
return ok;
}
bool ResumeTicketCache::issue(ResumeTicket &out) {
if (!random_bytes(out.session_id, RESUME_SESSION_ID_SIZE) || !random_bytes(out.secret, RESUME_SECRET_SIZE)) {
if (!random_bytes(reinterpret_cast<uint8_t *>(&out), sizeof(out))) {
return false;
}
uint8_t slot = this->next_;
this->next_ = static_cast<uint8_t>((slot + 1) % SLOTS);
this->slots_[slot] = out;
this->used_[slot] = true;
this->used_mask_ |= static_cast<uint8_t>(1u << slot);
return true;
}
@@ -65,7 +61,7 @@ bool ResumeTicketCache::try_accept(const uint8_t *offer, size_t offer_len, const
bool ok = false;
for (uint8_t i = 0; i < SLOTS && !ok; i++) {
ResumeTicket &slot = this->slots_[i];
if (!this->used_[i] || std::memcmp(slot.session_id, session_id, RESUME_SESSION_ID_SIZE) != 0) {
if (!(this->used_mask_ & (1u << i)) || std::memcmp(slot.session_id, session_id, RESUME_SESSION_ID_SIZE) != 0) {
continue;
}
uint8_t expected[RESUME_MAC_SIZE];
@@ -78,7 +74,7 @@ bool ResumeTicketCache::try_accept(const uint8_t *offer, size_t offer_len, const
}
std::memcpy(secret, slot.secret, RESUME_SECRET_SIZE);
noise_clean(&slot, sizeof(slot));
this->used_[i] = false;
this->used_mask_ &= static_cast<uint8_t>(~(1u << i));
}
if (!ok) {
return false;
@@ -111,30 +107,25 @@ bool ResumeTicketCache::try_accept(const uint8_t *offer, size_t offer_len, const
void ResumeTicketCache::clear() {
noise_clean(this->slots_, sizeof(this->slots_));
std::memset(this->used_, 0, sizeof(this->used_));
this->used_mask_ = 0;
}
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_kdf(secret, "offer", 5, session_id, RESUME_SESSION_ID_SIZE, client_nonce, RESUME_NONCE_SIZE, nullptr, 0,
out_mac, RESUME_MAC_SIZE, nullptr);
}
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_kdf(secret, "confirm", 7, client_nonce, RESUME_NONCE_SIZE, server_nonce, RESUME_NONCE_SIZE, nullptr, 0,
out_mac, RESUME_MAC_SIZE, nullptr);
}
bool resume_derive_keys(const uint8_t *secret, const uint8_t *client_nonce, const uint8_t *server_nonce,
const uint8_t *prologue, size_t prologue_len, uint8_t *k_c2d, uint8_t *k_d2c) {
// "keys"(4) || client_nonce(16) || server_nonce(16) || SHA256(prologue)(32)
uint8_t data[4 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE + 32];
std::memcpy(data, "keys", 4); // NOLINT(bugprone-not-null-terminated-result)
std::memcpy(data + 4, client_nonce, RESUME_NONCE_SIZE);
std::memcpy(data + 4 + RESUME_NONCE_SIZE, server_nonce, RESUME_NONCE_SIZE);
bool ok = resume_hkdf(secret, data, sizeof(data), k_c2d, 32, k_d2c, 32, prologue, prologue_len,
data + 4 + 2 * RESUME_NONCE_SIZE);
noise_clean(data, sizeof(data));
return ok;
return resume_kdf(secret, "keys", 4, client_nonce, RESUME_NONCE_SIZE, server_nonce, RESUME_NONCE_SIZE, prologue,
prologue_len, k_c2d, 32, k_d2c);
}
NoiseCipherState *resume_make_cipher(const uint8_t *key) {
+3 -1
View File
@@ -46,6 +46,8 @@ struct ResumeTicket {
uint8_t session_id[RESUME_SESSION_ID_SIZE];
uint8_t secret[RESUME_SECRET_SIZE];
};
// Sent on the wire as one blob: session_id || secret
static_assert(sizeof(ResumeTicket) == RESUME_SESSION_ID_SIZE + RESUME_SECRET_SIZE, "ticket must be packed");
/// Fixed-slot RAM cache of single-use resume tickets. Lost on reboot by
/// design: clients fall back to a full handshake.
@@ -69,7 +71,7 @@ class ResumeTicketCache {
protected:
ResumeTicket slots_[SLOTS];
bool used_[SLOTS]{};
uint8_t used_mask_{0};
uint8_t next_{0};
};
+1 -1
View File
@@ -49,7 +49,7 @@ 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;
this->used_mask_ |= 1u;
}
};