[noise] Document the resume constraints, bound the KDF inputs, and test PSK rotation

This commit is contained in:
J. Nick Koston
2026-08-24 19:39:03 -05:00
parent b7a5056f3e
commit a9b1fc361b
4 changed files with 62 additions and 14 deletions
@@ -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) {
+23 -3
View File
@@ -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);
}
@@ -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];
+17 -5
View File
@@ -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