diff --git a/esphome/components/noise/noise_resume.cpp b/esphome/components/noise/noise_resume.cpp index ffa95e159e..a601582775 100644 --- a/esphome/components/noise/noise_resume.cpp +++ b/esphome/components/noise/noise_resume.cpp @@ -12,12 +12,6 @@ namespace esphome::noise { const char RESUME_LABEL_OFFER[6] PROGMEM = "offer"; const char RESUME_LABEL_CONFIRM[8] PROGMEM = "confirm"; const char RESUME_LABEL_KEYS[5] PROGMEM = "keys"; -// Largest KDF input: "keys" || client_nonce || server_nonce || SHA256(prologue) -static constexpr size_t RESUME_KDF_MAX_DATA = - sizeof(RESUME_LABEL_KEYS) - 1 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE + 32; -static_assert(sizeof(RESUME_LABEL_CONFIRM) - 1 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE <= RESUME_KDF_MAX_DATA, - "MAC input must fit the KDF buffer"); - 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) { diff --git a/esphome/components/noise/noise_resume.h b/esphome/components/noise/noise_resume.h index 18e8d4f673..fa2ae15ef6 100644 --- a/esphome/components/noise/noise_resume.h +++ b/esphome/components/noise/noise_resume.h @@ -24,6 +24,11 @@ namespace esphome::noise { * 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)) + * + * A client that offers a ticket must hold handshake message 1 back until + * the ServerHello declines: an accept switches the responder straight to + * transport mode. A resumed session has no ephemeral DH; its keys come + * from the single-use ticket secret, which both sides wipe on redemption. */ static constexpr uint8_t RESUME_OFFER_VERSION = 0x01; @@ -63,12 +68,15 @@ class ResumeTicketCache { /// 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). + NoiseCipherState *&recv_cipher); + /// Forget every ticket (PSK change). void clear(); - // Realistically one or two controllers hold a ticket at a time; a third - // just evicts the oldest and that client does one full handshake. + // Realistically one or two controllers hold a ticket at a time. Issue is + // round robin, so three steady clients keep evicting each other and all + // fall back to full handshakes. static constexpr uint8_t SLOTS = 2; + static_assert(SLOTS <= 8, "used_mask_ is a uint8_t bitmask"); protected: ResumeTicket slots_[SLOTS]; @@ -81,8 +89,14 @@ extern const char RESUME_LABEL_OFFER[6]; extern const char RESUME_LABEL_CONFIRM[8]; extern const char RESUME_LABEL_KEYS[5]; +// Largest KDF input: "keys" || client_nonce || server_nonce || SHA256(prologue) +static constexpr size_t RESUME_KDF_MAX_DATA = + sizeof(RESUME_LABEL_KEYS) - 1 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE + 32; + /// Noise-construction HKDF-SHA256 keyed with the ticket secret over /// label || a || b [|| SHA256(hash_in)]. out2 == nullptr means MAC only. +/// label_len + a_len + b_len (+ 32 with hash_in) must not exceed +/// RESUME_KDF_MAX_DATA; the wrappers below static_assert their inputs. 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); @@ -91,6 +105,8 @@ bool resume_kdf(const uint8_t *secret, const char *label, size_t label_len, cons /// try_accept checks). inline bool resume_compute_offer_mac(const uint8_t *secret, const uint8_t *session_id, const uint8_t *client_nonce, uint8_t *out_mac) { + static_assert(sizeof(RESUME_LABEL_OFFER) - 1 + RESUME_SESSION_ID_SIZE + RESUME_NONCE_SIZE <= RESUME_KDF_MAX_DATA, + "offer MAC input must fit the KDF buffer"); return resume_kdf(secret, RESUME_LABEL_OFFER, sizeof(RESUME_LABEL_OFFER) - 1, session_id, RESUME_SESSION_ID_SIZE, client_nonce, RESUME_NONCE_SIZE, nullptr, 0, out_mac, RESUME_MAC_SIZE, nullptr); } @@ -98,6 +114,8 @@ inline bool resume_compute_offer_mac(const uint8_t *secret, const uint8_t *sessi /// confirm_mac for the ServerHello extension. inline bool resume_compute_confirm_mac(const uint8_t *secret, const uint8_t *client_nonce, const uint8_t *server_nonce, uint8_t *out_mac) { + static_assert(sizeof(RESUME_LABEL_CONFIRM) - 1 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE <= RESUME_KDF_MAX_DATA, + "confirm MAC input must fit the KDF buffer"); return resume_kdf(secret, RESUME_LABEL_CONFIRM, sizeof(RESUME_LABEL_CONFIRM) - 1, client_nonce, RESUME_NONCE_SIZE, server_nonce, RESUME_NONCE_SIZE, nullptr, 0, out_mac, RESUME_MAC_SIZE, nullptr); } @@ -106,6 +124,8 @@ inline bool resume_compute_confirm_mac(const uint8_t *secret, const uint8_t *cli /// k_d2c device-to-client. inline 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) { + static_assert(sizeof(RESUME_LABEL_KEYS) - 1 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE + 32 <= RESUME_KDF_MAX_DATA, + "key derivation input must fit the KDF buffer"); return resume_kdf(secret, RESUME_LABEL_KEYS, sizeof(RESUME_LABEL_KEYS) - 1, client_nonce, RESUME_NONCE_SIZE, server_nonce, RESUME_NONCE_SIZE, prologue, prologue_len, k_c2d, 32, k_d2c); } diff --git a/tests/components/noise/test_noise_resume.cpp b/tests/components/noise/test_noise_resume.cpp index b3392ae0bc..4bc6b5cb20 100644 --- a/tests/components/noise/test_noise_resume.cpp +++ b/tests/components/noise/test_noise_resume.cpp @@ -167,6 +167,28 @@ TEST(NoiseResumeCache, BadMacOrMalformedOfferLeavesTicketIntact) { noise_cipherstate_free(recv); } +TEST(NoiseResumeCache, SetPskForgetsTickets) { + NoiseContext ctx; + ResumeTicket ticket; + ASSERT_TRUE(ctx.resume_cache().issue(ticket)); + + psk_t psk{}; + psk[0] = 1; + ctx.set_psk(psk); + + uint8_t offer[RESUME_OFFER_SIZE]; + build_offer_for_ticket(offer, ticket, KAT_CLIENT_NONCE); + uint8_t prologue[KAT_PROLOGUE_SIZE]; + build_prologue(prologue, offer); + uint8_t ext[RESUME_ACCEPT_SIZE]; + NoiseCipherState *send = nullptr, *recv = nullptr; + EXPECT_EQ( + ctx.resume_cache().try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv), + 0u); + EXPECT_EQ(send, nullptr); + EXPECT_EQ(recv, nullptr); +} + TEST(NoiseResumeCache, IssueRotatesSlotsAndClearForgetsAll) { ResumeTicketCache cache; ResumeTicket tickets[ResumeTicketCache::SLOTS + 1]; diff --git a/tests/integration/test_api_noise_resume.py b/tests/integration/test_api_noise_resume.py index 149a9a535c..bd0aa1b2fe 100644 --- a/tests/integration/test_api_noise_resume.py +++ b/tests/integration/test_api_noise_resume.py @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio + import aioesphomeapi.core import pytest @@ -20,27 +22,37 @@ async def test_api_noise_resume( if not hasattr(aioesphomeapi.core, "ResumeAPIError"): pytest.skip("aioesphomeapi without noise session resume") - device_lines: list[str] = [] + resumed = asyncio.Event() + resumed_count = 0 + + def on_line(line: str) -> None: + nonlocal resumed_count + if "Session resumed" in line: + resumed_count += 1 + resumed.set() async with ( - run_compiled(yaml_config, line_callback=device_lines.append), + run_compiled(yaml_config, line_callback=on_line), api_client_connected(noise_psk=NOISE_KEY) as client, ): # First connection: full handshake, the device issues a ticket info = await client.device_info() assert info.name == "host-noise-resume" - assert not any("Session resumed" in line for line in device_lines) + assert resumed_count == 0 # Same client reconnects and offers the ticket await client.disconnect() await client.connect(login=True) info = await client.device_info() assert info.name == "host-noise-resume" - assert any("Session resumed" in line for line in device_lines) + await asyncio.wait_for(resumed.wait(), timeout=10.0) + assert resumed_count == 1 + resumed.clear() # The resumed session issued a fresh ticket, so it resumes again await client.disconnect() await client.connect(login=True) info = await client.device_info() assert info.name == "host-noise-resume" - assert sum("Session resumed" in line for line in device_lines) == 2 + await asyncio.wait_for(resumed.wait(), timeout=10.0) + assert resumed_count == 2