diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index dd05f27040..806131a11b 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -267,9 +267,8 @@ APIError APINoiseFrameHelper::state_action_client_hello_() { if (aerr != APIError::OK) { return handle_handshake_frame_error_(aerr); } - // Contents are extension flags; today the only defined extension is the - // session resume offer. Everything is mixed into the prologue either way. - // Resize for: existing prologue + 2 size bytes + frame data + // Contents are extension flags (today: the resume offer); mixed into the + // prologue either way. Resize for: existing prologue + 2 size bytes + frame data size_t old_size = this->prologue_.size(); size_t rx_size = this->rx_buf_.size(); this->prologue_.resize(old_size + 2 + rx_size); @@ -283,12 +282,8 @@ APIError APINoiseFrameHelper::state_action_client_hello_() { return APIError::OK; } APIError APINoiseFrameHelper::state_action_server_hello_() { - // 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. + // A verified resume offer (still in rx_buf_ from the client hello step) + // replaces the whole handshake; any failure falls back to the full one. 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_); @@ -329,8 +324,6 @@ APIError APINoiseFrameHelper::state_action_server_hello_() { return aerr; if (resume) { - // 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; @@ -357,9 +350,8 @@ APIError APINoiseFrameHelper::state_action_handshake_() { HELPER_LOG("Bad action for handshake: %d", (int) action); return APIError::HANDSHAKESTATE_BAD_STATE; } -/// Resumed session: read and discard the client's full-handshake message 1, -/// which was already in flight when the resume offer was accepted, then -/// enter DATA. The same status byte rules apply as for a real handshake read. +/// Resumed session: discard the client's already-in-flight handshake +/// message 1, then enter DATA. APIError APINoiseFrameHelper::state_action_resume_discard_() { APIError aerr = this->try_read_frame_(); if (aerr != APIError::OK) { diff --git a/esphome/components/noise/noise_resume.h b/esphome/components/noise/noise_resume.h index 72d6b66ce8..6de53a3295 100644 --- a/esphome/components/noise/noise_resume.h +++ b/esphome/components/noise/noise_resume.h @@ -14,23 +14,16 @@ namespace esphome::noise { /** Session resume for the noise transports. * - * After a full NNpsk0 handshake the responder issues a single-use ticket - * (session id + secret) over the encrypted channel. A client holding a - * ticket places a resume offer in its ClientHello; the responder proves - * possession of the secret in its ServerHello and both sides derive the - * transport keys with HKDF-SHA256 alone, skipping the two curve25519 - * operations of a full handshake (~37 ms on ESP32, ~290 ms on ESP8266 at - * 80 MHz). Old peers ignore the extension bytes on both sides, so every - * mismatch degrades to a normal full handshake on the same connection. - * - * All HKDF calls use the Noise construction (noise_hashstate_hkdf): - * temp = HMAC-SHA256(key, data); out1 = HMAC(temp, 0x01); - * out2 = HMAC(temp, out1 || 0x02). + * After a full handshake the responder issues a single-use ticket over the + * encrypted channel. A client presents it in its next ClientHello and both + * sides derive the transport keys with HKDF-SHA256 alone, skipping the two + * curve25519 operations. Old peers ignore the extension bytes on both + * sides, so every mismatch degrades to a normal full handshake. * + * HKDF is the Noise construction (noise_hashstate_hkdf). Derivations: * offer_mac = HKDF(secret, "offer" || session_id || client_nonce).out1[:16] * confirm_mac = HKDF(secret, "confirm" || client_nonce || server_nonce).out1[:16] - * k_c2d, k_d2c = HKDF(secret, "keys" || client_nonce || server_nonce - * || SHA256(prologue)) (32 bytes each) + * k_c2d, k_d2c = HKDF(secret, "keys" || client_nonce || server_nonce || SHA256(prologue)) */ static constexpr uint8_t RESUME_OFFER_VERSION = 0x01; @@ -61,14 +54,10 @@ class ResumeTicketCache { /// 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); - /// 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. + /// Accept a resume offer: verify and consume the ticket (single use; a + /// forged MAC never burns one), build both transport ciphers, and fill + /// the RESUME_ACCEPT_SIZE ServerHello extension. Returns false with + /// nothing allocated on any miss or failure. 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).