diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 38d9180c5a..ef6b0bcabe 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -897,6 +897,7 @@ message NoiseEncryptionSetKeyResponse { // its next connection to skip the curve25519 handshake; the device then // issues a fresh ticket on that connection. Never sent on plaintext // connections. Clients that do not understand it drop it silently. +// Contents are secret; the device generator redacts this message from dump_to message NoiseResumeTicket { option (id) = 152; option (source) = SOURCE_SERVER; diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 9f0d1743fa..c3ddbdc646 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -308,13 +308,12 @@ APIError APINoiseFrameHelper::state_action_server_hello_() { // node mac, terminated by null byte std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE); - // The accept extension is written straight after the mac - bool resume = - this->ctx_.resume_cache().try_accept(this->rx_buf_.data(), this->rx_buf_.size(), this->prologue_.data(), - this->prologue_.size(), msg + total_size, send_cipher_, recv_cipher_); - if (resume) { - total_size += noise::RESUME_ACCEPT_SIZE; - } + // The accept extension, if any, is written straight after the mac + size_t ext_len = this->ctx_.resume_cache().try_accept( + this->rx_buf_.data(), this->rx_buf_.size(), this->prologue_.data(), this->prologue_.size(), msg + total_size, + sizeof(msg) - total_size, send_cipher_, recv_cipher_); + bool resume = ext_len != 0; + total_size += ext_len; APIError aerr = write_frame_(msg, total_size); if (aerr != APIError::OK) diff --git a/esphome/components/noise/noise_resume.cpp b/esphome/components/noise/noise_resume.cpp index a78d6437dd..349702e8a0 100644 --- a/esphome/components/noise/noise_resume.cpp +++ b/esphome/components/noise/noise_resume.cpp @@ -8,13 +8,20 @@ namespace esphome::noise { +static constexpr char LABEL_OFFER[] = "offer"; +static constexpr char LABEL_CONFIRM[] = "confirm"; +static constexpr char LABEL_KEYS[] = "keys"; +// Largest KDF input: "keys" || client_nonce || server_nonce || SHA256(prologue) +static constexpr size_t RESUME_KDF_MAX_DATA = sizeof(LABEL_KEYS) - 1 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE + 32; +static_assert(sizeof(LABEL_CONFIRM) - 1 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE <= RESUME_KDF_MAX_DATA, + "MAC input must fit the KDF buffer"); + /// 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 data[RESUME_KDF_MAX_DATA]; 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) @@ -50,45 +57,45 @@ bool ResumeTicketCache::issue(ResumeTicket &out) { return true; } -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; +size_t ResumeTicketCache::try_accept(const uint8_t *offer, size_t offer_len, const uint8_t *prologue, + size_t prologue_len, uint8_t *out_ext, size_t out_capacity, + NoiseCipherState *&send_cipher, NoiseCipherState *&recv_cipher) { + if (offer_len != RESUME_OFFER_SIZE || offer[0] != RESUME_OFFER_VERSION || out_capacity < RESUME_ACCEPT_SIZE) { + return 0; } const uint8_t *session_id = offer + RESUME_OFFER_SESSION_ID_OFFSET; const uint8_t *client_nonce = offer + RESUME_OFFER_NONCE_OFFSET; - uint8_t secret[RESUME_SECRET_SIZE]; - bool ok = false; - for (uint8_t i = 0; i < SLOTS && !ok; i++) { - ResumeTicket &slot = this->slots_[i]; - if (!(this->used_mask_ & (1u << i)) || std::memcmp(slot.session_id, session_id, RESUME_SESSION_ID_SIZE) != 0) { - continue; + ResumeTicket *ticket = nullptr; + for (uint8_t i = 0; i < SLOTS; i++) { + if ((this->used_mask_ & (1u << i)) && + std::memcmp(this->slots_[i].session_id, session_id, RESUME_SESSION_ID_SIZE) == 0) { + ticket = &this->slots_[i]; + this->used_mask_ &= static_cast(~(1u << i)); + break; } - uint8_t expected[RESUME_MAC_SIZE]; - ok = resume_compute_offer_mac(slot.secret, session_id, client_nonce, expected) && - noise_is_equal(expected, offer + RESUME_OFFER_MAC_OFFSET, 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, slot.secret, RESUME_SECRET_SIZE); - noise_clean(&slot, sizeof(slot)); - this->used_mask_ &= static_cast(~(1u << i)); } + if (ticket == nullptr) { + return 0; + } + uint8_t expected[RESUME_MAC_SIZE]; + bool ok = resume_compute_offer_mac(ticket->secret, session_id, client_nonce, expected) && + noise_is_equal(expected, offer + RESUME_OFFER_MAC_OFFSET, RESUME_MAC_SIZE); + noise_clean(expected, sizeof(expected)); if (!ok) { - return false; + // Bad MAC: keep the ticket so a forger cannot burn it + this->used_mask_ |= static_cast(1u << static_cast(ticket - this->slots_)); + return 0; } - // From here every failure still falls back to the full handshake; the - // ticket is spent, which is harmless (the client gets a fresh one). + // The ticket is spent from here; any later failure falls back to the full + // handshake and the client gets a fresh one. uint8_t *server_nonce = out_ext + 1; uint8_t k_c2d[32]; uint8_t k_d2c[32]; out_ext[0] = RESUME_ACCEPT_VERSION; ok = random_bytes(server_nonce, RESUME_NONCE_SIZE) && - resume_compute_confirm_mac(secret, client_nonce, server_nonce, out_ext + 1 + RESUME_NONCE_SIZE) && - resume_derive_keys(secret, client_nonce, server_nonce, prologue, prologue_len, k_c2d, k_d2c); - noise_clean(secret, sizeof(secret)); + resume_compute_confirm_mac(ticket->secret, client_nonce, server_nonce, out_ext + 1 + RESUME_NONCE_SIZE) && + resume_derive_keys(ticket->secret, client_nonce, server_nonce, prologue, prologue_len, k_c2d, k_d2c); + noise_clean(ticket, sizeof(*ticket)); if (ok) { recv_cipher = resume_make_cipher(k_c2d); send_cipher = resume_make_cipher(k_d2c); @@ -102,7 +109,7 @@ bool ResumeTicketCache::try_accept(const uint8_t *offer, size_t offer_len, const } noise_clean(k_c2d, sizeof(k_c2d)); noise_clean(k_d2c, sizeof(k_d2c)); - return ok; + return ok ? RESUME_ACCEPT_SIZE : 0; } void ResumeTicketCache::clear() { @@ -112,20 +119,20 @@ void ResumeTicketCache::clear() { 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_kdf(secret, "offer", 5, session_id, RESUME_SESSION_ID_SIZE, client_nonce, RESUME_NONCE_SIZE, nullptr, 0, - out_mac, RESUME_MAC_SIZE, nullptr); + return resume_kdf(secret, LABEL_OFFER, sizeof(LABEL_OFFER) - 1, 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_kdf(secret, "confirm", 7, client_nonce, RESUME_NONCE_SIZE, server_nonce, RESUME_NONCE_SIZE, nullptr, 0, - out_mac, RESUME_MAC_SIZE, nullptr); + return resume_kdf(secret, LABEL_CONFIRM, sizeof(LABEL_CONFIRM) - 1, 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) { - return resume_kdf(secret, "keys", 4, client_nonce, RESUME_NONCE_SIZE, server_nonce, RESUME_NONCE_SIZE, prologue, - prologue_len, k_c2d, 32, k_d2c); + return resume_kdf(secret, LABEL_KEYS, sizeof(LABEL_KEYS) - 1, 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) { diff --git a/esphome/components/noise/noise_resume.h b/esphome/components/noise/noise_resume.h index dc9371097f..1df58bd550 100644 --- a/esphome/components/noise/noise_resume.h +++ b/esphome/components/noise/noise_resume.h @@ -57,12 +57,13 @@ class ResumeTicketCache { /// slot. Returns false (and stores nothing) if the RNG fails. bool issue(ResumeTicket &out); /// 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). + /// forged MAC never burns one), build both transport ciphers, and write + /// the ServerHello accept extension into out_ext. Returns the extension + /// length, or 0 (nothing allocated) on any miss, failure, or when + /// out_capacity is too small. Secrets are wiped internally. + size_t try_accept(const uint8_t *offer, size_t offer_len, const uint8_t *prologue, size_t prologue_len, + uint8_t *out_ext, size_t out_capacity, NoiseCipherState *&send_cipher, + NoiseCipherState *&recv_cipher); /// Forget every ticket (PSK change). void clear(); // Realistically one or two controllers hold a ticket at a time; a third diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 5d909da81c..24805952c5 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2518,6 +2518,11 @@ def calculate_message_max_size(desc: descriptor.DescriptorProto) -> int | None: return total_size +# Contents must never reach the log: dump_to prints only the name +SENSITIVE_MESSAGES = {"NoiseResumeTicket"} +SENSITIVE_MESSAGES_SEEN: set[str] = set() + + def build_message_type( desc: descriptor.DescriptorProto, base_class_fields: dict[str, list[descriptor.FieldDescriptorProto]], @@ -2799,9 +2804,9 @@ def build_message_type( public_content.append(prot) # If no fields to calculate size for or message doesn't need encoding, the default implementation in ProtoMessage will be used - # Sensitive messages (keys, tickets) dump only their name if desc.name in SENSITIVE_MESSAGES: dump = [] + SENSITIVE_MESSAGES_SEEN.add(desc.name) # dump_to method declaration in header prot = "#ifdef HAS_PROTO_MESSAGE_DUMP\n" @@ -2849,9 +2854,6 @@ def build_message_type( return out, cpp, dump_cpp -# Messages whose contents are secret; dump_to prints only the name -SENSITIVE_MESSAGES = {"NoiseResumeTicket"} - SOURCE_BOTH = 0 SOURCE_SERVER = 1 SOURCE_CLIENT = 2 @@ -3709,6 +3711,11 @@ static const char *const TAG = "api.service"; except ImportError: pass + # A renamed message must fail the build, not silently start dumping secrets + missing = SENSITIVE_MESSAGES - SENSITIVE_MESSAGES_SEEN + if missing: + raise RuntimeError(f"SENSITIVE_MESSAGES not found in api.proto: {missing}") + if __name__ == "__main__": sys.exit(main()) diff --git a/tests/components/noise/test_noise_resume.cpp b/tests/components/noise/test_noise_resume.cpp index 64aa2b3529..b3392ae0bc 100644 --- a/tests/components/noise/test_noise_resume.cpp +++ b/tests/components/noise/test_noise_resume.cpp @@ -38,6 +38,15 @@ static void build_offer(uint8_t *offer, const uint8_t *session_id, const uint8_t std::memcpy(offer + RESUME_OFFER_MAC_OFFSET, mac, RESUME_MAC_SIZE); } +/// "NoiseAPIInit" || be16(len) || offer, exactly as the api frame helper mixes it +static constexpr size_t KAT_PROLOGUE_SIZE = 12 + 2 + RESUME_OFFER_SIZE; +static void build_prologue(uint8_t *out, const uint8_t *offer) { + std::memcpy(out, "NoiseAPIInit", 12); // NOLINT(bugprone-not-null-terminated-result) + out[12] = 0x00; + out[13] = RESUME_OFFER_SIZE; + std::memcpy(out + 14, offer, RESUME_OFFER_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)); @@ -68,13 +77,12 @@ TEST(NoiseResumeKat, OfferMacMatchesClientImplementation) { TEST(NoiseResumeKat, KeyDerivationMatchesClientImplementation) { // Prologue used by the shared vectors: "NoiseAPIInit" + be16(41) + a // 41-byte offer whose MAC field is 16 bytes of 0xEE - uint8_t prologue[14 + RESUME_OFFER_SIZE]; - std::memcpy(prologue, "NoiseAPIInit", 12); - prologue[12] = 0x00; - prologue[13] = RESUME_OFFER_SIZE; uint8_t mac_filler[RESUME_MAC_SIZE]; std::memset(mac_filler, 0xEE, sizeof(mac_filler)); - build_offer(prologue + 14, KAT_SESSION_ID, KAT_CLIENT_NONCE, mac_filler); + uint8_t offer[RESUME_OFFER_SIZE]; + build_offer(offer, KAT_SESSION_ID, KAT_CLIENT_NONCE, mac_filler); + uint8_t prologue[KAT_PROLOGUE_SIZE]; + build_prologue(prologue, offer); uint8_t k_c2d[32], k_d2c[32]; ASSERT_TRUE( @@ -89,15 +97,13 @@ TEST(NoiseResumeCache, TryAcceptConsumesTicketOnceAndProvesPossession) { uint8_t offer[RESUME_OFFER_SIZE]; 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 prologue[KAT_PROLOGUE_SIZE]; + build_prologue(prologue, offer); 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_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv), + RESUME_ACCEPT_SIZE); ASSERT_NE(send, nullptr); ASSERT_NE(recv, nullptr); @@ -126,7 +132,7 @@ TEST(NoiseResumeCache, TryAcceptConsumesTicketOnceAndProvesPossession) { // Single use: the same offer must miss the second time NoiseCipherState *send2 = nullptr, *recv2 = nullptr; - EXPECT_FALSE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send2, recv2)); + EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send2, recv2), 0u); EXPECT_EQ(send2, nullptr); EXPECT_EQ(recv2, nullptr); } @@ -145,15 +151,18 @@ TEST(NoiseResumeCache, BadMacOrMalformedOfferLeavesTicketIntact) { uint8_t ext[RESUME_ACCEPT_SIZE]; NoiseCipherState *send = nullptr, *recv = nullptr; // A forged offer must not burn the ticket - EXPECT_FALSE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv)); + EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv), 0u); // 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)); + EXPECT_EQ(cache.try_accept(offer, sizeof(offer) - 1, prologue, sizeof(prologue), ext, sizeof(ext), send, recv), 0u); offer[0] = 0x7f; - EXPECT_FALSE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv)); + EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv), 0u); offer[0] = RESUME_OFFER_VERSION; + // No room for the extension must also decline without burning it + EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext) - 1, send, recv), 0u); // The genuine offer still redeems - EXPECT_TRUE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv)); + EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv), + RESUME_ACCEPT_SIZE); noise_cipherstate_free(send); noise_cipherstate_free(recv); } @@ -171,11 +180,12 @@ TEST(NoiseResumeCache, IssueRotatesSlotsAndClearForgetsAll) { // The oldest ticket was evicted by the one-past-capacity issue 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)); + EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv), 0u); // The rest remain redeemable for (int i = 1; i <= ResumeTicketCache::SLOTS; i++) { build_offer_for_ticket(offer, tickets[i], KAT_CLIENT_NONCE); - EXPECT_TRUE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv)); + EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv), + RESUME_ACCEPT_SIZE); noise_cipherstate_free(send); noise_cipherstate_free(recv); send = recv = nullptr; @@ -186,7 +196,7 @@ TEST(NoiseResumeCache, IssueRotatesSlotsAndClearForgetsAll) { ASSERT_TRUE(cache.issue(ticket)); cache.clear(); build_offer_for_ticket(offer, ticket, KAT_CLIENT_NONCE); - EXPECT_FALSE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv)); + EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv), 0u); } } // namespace esphome::noise::testing