12#include <esp_random.h>
66constexpr uint32_t KEY_EXTRACTION_AUTO_OFF_MS = 10 * 60 * 1000;
70constexpr uint8_t KEY_EXTRACTION_MANUFACTURER_ID = MANUFACTURER_SOMFY;
71constexpr uint8_t KEY_EXTRACTION_ID_GEN_MAX_ATTEMPTS = 16;
72constexpr size_t IO_CHANNEL_COUNT = 3;
73constexpr const char *KEY_EXTRACTION_DISCOVER_REPLY_TIMER_NAME =
"key_extraction_discover_reply";
74constexpr const char *KEY_EXTRACTION_TIMEOUT_NAME =
"key_extraction_auto_off";
75constexpr uint32_t RANDOM_LOW_BYTE_MASK = 0xFF;
77constexpr uint32_t KEY_EXTRACTION_MID_ATTEMPT_TIMEOUT_MS = 5000;
101constexpr uint32_t KEY_EXTRACTION_POST_EXTRACT_GRACE_MS = 60000;
112constexpr const char *KEY_EXTRACTION_GRACE_TIMER_NAME =
"key_extraction_post_extract_grace";
119 return "SYSTEM KEY EXTRACTED -- DO NOT SHARE YOUR SYSTEM KEY\n"
120 "Anyone with this key and node_id can control every device on this installation.\n"
121 "This exchange has not been independently confirmed against your specific hub -- test\n"
122 "this key (e.g. by controlling a device with it) before relying on it.\n"
123 "Copy the block below into a new hub's YAML.\n"
138 transmit_(std::move(transmit)),
139 schedule_auto_off_(std::move(schedule_auto_off)) {}
142 for (uint8_t attempt = 0; attempt < KEY_EXTRACTION_ID_GEN_MAX_ATTEMPTS; attempt++) {
143 for (uint8_t i = 0; i < NODE_ID_SIZE; i++)
144 out[i] =
static_cast<uint8_t
>(esp_random() & RANDOM_LOW_BYTE_MASK);
147 if (memcmp(out, this->node_id_, NODE_ID_SIZE) == 0)
149 if (memcmp(out, BROADCAST_DISCOVER, NODE_ID_SIZE) == 0 || memcmp(out, BROADCAST_DISCOVER_ALT, NODE_ID_SIZE) == 0)
167 if (this->armed_callback_)
168 this->armed_callback_(
false);
174 this->set_configured_type_();
178 "Key extraction: ARMED for 10 minutes, throwaway ID %s. Put your existing hub into pairing/add-device "
187 this->schedule_auto_off_(KEY_EXTRACTION_TIMEOUT_NAME, KEY_EXTRACTION_AUTO_OFF_MS, [
this]() {
194 ESP_LOGW(
detail::TAG,
"Key extraction: window expired, no pairing attempt seen. Disarming.");
196 ESP_LOGW(
detail::TAG,
"Key extraction: window expired while in progress (reached stage=%s). Disarming.",
202 if (this->armed_callback_)
203 this->armed_callback_(
true);
211 this->rx_freq_hz_ = rx_freq_hz != 0 ? rx_freq_hz : FREQ_CH2;
212 this->rx_ms_ = millis();
214 if (frame.
cmd == CMD_DISCOVER_REQ) {
215 this->handle_discover_(frame);
218 if (memcmp(frame.
dst, this->key_extraction_ctx_.throwaway_id, NODE_ID_SIZE) != 0)
220 if (frame.
cmd == CMD_DISCOVER_CONFIRM) {
221 this->handle_discover_confirm_(frame);
224 if (frame.
cmd == CMD_KEY_INIT) {
225 this->handle_key_init_(frame);
228 if (frame.
cmd == CMD_KEY_TRANSFER) {
229 this->handle_key_transfer_(frame);
232 if (frame.
cmd == CMD_NODE_VERIFY_REQ) {
233 this->handle_node_verify_req_(frame);
236 if (frame.
cmd == CMD_CHALLENGE_REQ) {
237 this->handle_node_verify_challenge_(frame);
243void KeyExtractionResponder::send_reply_(
const IoFrame &frame) {
246 this->session_freq_ = this->rx_freq_hz_;
247 this->session_rx_ms_ = this->rx_ms_;
257 const uint16_t preamble = (*this->radio_)->response_preamble();
263 this->transmit_(frame, this->session_freq_, preamble);
266void KeyExtractionResponder::schedule_discover_reply_(
const IoFrame &search,
const IoFrame &resp) {
271 const uint32_t slot = 1 + (esp_random() % slots);
272 const uint32_t delay_ms = slot * DISCOVERY_SLOT_MS;
276 static constexpr uint32_t CHANNELS[IO_CHANNEL_COUNT] = {FREQ_CH1, FREQ_CH2, FREQ_CH3};
277 size_t search_index = 1;
278 for (
size_t i = 0; i < IO_CHANNEL_COUNT; i++) {
279 if (CHANNELS[i] == this->rx_freq_hz_)
283 const size_t step = 1 + (esp_random() % (IO_CHANNEL_COUNT - 1));
284 const uint32_t freq = CHANNELS[(search_index + step) % IO_CHANNEL_COUNT];
288 this->session_freq_ = this->rx_freq_hz_;
289 this->session_rx_ms_ = this->rx_ms_ - this->session_window_ms_;
291 this->pending_discover_resp_ = resp;
292 this->discover_reply_pending_ =
true;
293 this->discover_reply_due_ms_ = millis() + delay_ms;
295 this->schedule_auto_off_(KEY_EXTRACTION_DISCOVER_REPLY_TIMER_NAME, delay_ms, [
this, freq]() {
296 this->discover_reply_pending_ =
false;
303 this->transmit_(this->pending_discover_resp_, freq, this->tuning_->cold_broadcast_reply_preamble);
308 "Key extraction: discovery answer in slot %" PRIu32
" of %" PRIu32
" (%" PRIu32
" ms) on %" PRIu32
" Hz",
309 slot, slots, delay_ms, freq);
312void KeyExtractionResponder::set_configured_type_() {
315 const uint16_t word = this->tuning_->key_extraction_type_word;
316 const auto msb =
static_cast<uint8_t
>(word >> BITS_PER_BYTE);
317 const auto lsb =
static_cast<uint8_t
>(word);
322void KeyExtractionResponder::handle_discover_(
const IoFrame &frame) {
336 this->set_configured_type_();
342 const uint8_t flags = this->tuning_->key_extraction_multi_info;
343 this->session_window_ms_ = key_extraction_session_window_ms(flags);
345 this->key_extraction_ctx_.advertised_type, this->key_extraction_ctx_.advertised_subtype,
346 KEY_EXTRACTION_MANUFACTURER_ID, flags)) {
347 ESP_LOGW(
detail::TAG,
"Key extraction: failed to build discovery response");
356 this->schedule_discover_reply_(frame, resp);
358 "Key extraction: answering discovery from hub %s as node type %u subtype %u (type word 0x%02X%02X, "
359 "multi info 0x%02X) with throwaway ID %s",
360 node_id_to_string(frame.src).c_str(),
static_cast<unsigned>(this->key_extraction_ctx_.advertised_type),
361 this->key_extraction_ctx_.advertised_subtype, resp.data[0], resp.data[1], flags,
365void KeyExtractionResponder::handle_discover_confirm_(
const IoFrame &frame) {
371 ESP_LOGW(
detail::TAG,
"Key extraction: failed to build discovery-confirm ack");
376 this->send_reply_(resp);
378 ESP_LOGI(
detail::TAG,
"Key extraction: acknowledged discovery confirm from hub %s",
382void KeyExtractionResponder::handle_key_init_(
const IoFrame &frame) {
383 uint8_t candidate_challenge[HMAC_SIZE];
390 this->key_extraction_ctx_.challenge)) {
391 ESP_LOGW(
detail::TAG,
"Key extraction: failed to build challenge request");
396 this->send_reply_(resp);
401void KeyExtractionResponder::handle_key_transfer_(
const IoFrame &frame) {
402 if (frame.data_len < AES_KEY_SIZE) {
403 ESP_LOGW(
detail::TAG,
"Key extraction: key-transfer payload too short (%u bytes)", frame.data_len);
411 this->send_reply_(resp);
413 ESP_LOGW(
detail::TAG,
"Key extraction: failed to build key confirm");
420 "Key extraction: still listening for up to %" PRIu32
421 " more seconds in case the hub verifies this device (CMD_NODE_VERIFY_REQ/0x36) — leave the "
422 "switch on until it turns off on its own.",
423 KEY_EXTRACTION_POST_EXTRACT_GRACE_MS / 1000);
436void KeyExtractionResponder::handle_node_verify_req_(
const IoFrame &frame) {
443 if (memcmp(frame.src, this->key_extraction_ctx_.hub_node_id, NODE_ID_SIZE) != 0)
449 ESP_LOGW(
detail::TAG,
"Key extraction: failed to build address response");
452 this->send_reply_(resp);
454 ESP_LOGI(
detail::TAG,
"Key extraction: answered node verification request from hub %s",
458void KeyExtractionResponder::handle_node_verify_challenge_(
const IoFrame &frame) {
461 if (memcmp(frame.src, this->key_extraction_ctx_.hub_node_id, NODE_ID_SIZE) != 0)
466 if (frame.data_len < HMAC_SIZE) {
467 ESP_LOGW(
detail::TAG,
"Key extraction: address challenge payload too short (%u bytes)", frame.data_len);
476 IoFrame our_node_verify_resp;
482 frame.data, our_node_verify_resp, this->key_extraction_ctx_.recovered_key)) {
483 ESP_LOGW(
detail::TAG,
"Key extraction: failed to build address challenge response");
486 this->send_reply_(resp);
509 this->schedule_auto_off_(KEY_EXTRACTION_GRACE_TIMER_NAME, KEY_EXTRACTION_POST_EXTRACT_GRACE_MS, [
this]() {
525 ESP_LOGI(
detail::TAG,
"Key extraction: post-extraction grace window elapsed (stage=%s). Disarming.",
541void KeyExtractionResponder::log_result_() {
551 ESP_LOGW(
detail::TAG,
"========================================");
554 this->key_extraction_ctx_.recovered_key));
555 ESP_LOGW(
detail::TAG,
"========================================");
Owns the per-hub device table, update callbacks, and linked-remote associations.
Abstract radio driver for IO-Homecontrol.
Per-hub device table, update-callback fan-out, and linked-remote map.
Hub-layer log tag and log/format helpers shared by the hub and its collaborators.
void generate_challenge(uint8_t out[HMAC_SIZE])
Generate 6 random bytes for a challenge using the ESP32 hardware RNG.
constexpr const char * TAG
Shared log tag for hub-level messages.
void log_multiline_result(const char *tag, bool is_warning, const std::string &prefix, const std::string &message)
Log prefix followed by message, one line per log call rather than one call for the whole (possibly mu...
std::string build_key_extraction_report(const uint8_t node_id[NODE_ID_SIZE], const uint8_t key[AES_KEY_SIZE])
Build the ready-to-paste 2W system-key-extraction report: node_id:/system_key: as a home_io_control: ...
std::string format_key_hex(const uint8_t key[AES_KEY_SIZE])
Format a 16-byte key as an uppercase, unseparated hex string for display.
bool on_key_transfer(ResponderContext &ctx, const uint8_t transfer_payload[AES_KEY_SIZE])
Decide how to react to an inbound CMD_KEY_TRANSFER (0x32) while armed.
const char * responder_stage_name(ResponderState state)
Get a short, log/telemetry-friendly name for a responder state.
bool on_discover_confirm(ResponderContext &ctx)
Decide how to react to an inbound CMD_DISCOVER_CONFIRM (0x2C) addressed to our throwaway ID.
bool on_node_verify_req(ResponderContext &ctx)
Decide how to react to an inbound CMD_NODE_VERIFY_REQ (0x36) addressed to our throwaway ID.
bool on_key_init(ResponderContext &ctx, const uint8_t challenge[HMAC_SIZE], const uint8_t hub_node_id[NODE_ID_SIZE])
Decide how to react to an inbound CMD_KEY_INIT (0x31) addressed to our throwaway ID.
bool on_node_verify_challenge(const ResponderContext &ctx)
Decide how to react to an inbound CMD_CHALLENGE_REQ (0x3C) — issued by the hub this time,...
@ ARMED_IDLE
Armed, listening for a discovery request (0x28).
@ SENT_DISCOVER_RESP
Replied to discovery (0x29); waiting for discovery-confirm (0x2C) or key-init (0x31).
@ DISARMED
Not armed; 0x28/0x2C/0x31/0x32 traffic is ignored.
@ SENT_NODE_VERIFY_RESP
Answered a hub's CMD_NODE_VERIFY_REQ (0x36) with our CMD_NODE_VERIFY_RESP (0x37); waiting for the hub...
@ EXTRACTED
System key recovered from a valid 0x32.
bool on_discover_request(ResponderContext &ctx, const uint8_t hub_node_id[NODE_ID_SIZE])
Decide how to react to an inbound CMD_DISCOVER_REQ (0x28) while armed.
uint8_t discovery_slot_count(const uint8_t addr[NODE_ID_SIZE])
Number of answer slots a discovery request opens.
bool create_discover_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst, DeviceType type, uint8_t subtype, uint8_t manufacturer_id, uint8_t flags)
Build a discovery response (0x29) — device side, used only by the key-extraction responder.
bool create_node_verify_resp_device_role(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build an address response (0x37) — device side, used only by the key-extraction responder.
DeviceType
IO-Homecontrol node types.
@ UNKNOWN
Unknown/unspecified device.
bool create_key_confirm(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a key-confirm frame (0x33) — device side, used only by the key-extraction responder.
DeviceType decode_packed_device_type(uint8_t type_msb, uint8_t type_subtype)
Decode the 10-bit node type from two packed metadata bytes.
std::function< bool(const IoFrame &frame, uint32_t freq_hz, uint16_t preamble)> TransmitFrameFn
Puts a frame on air on a given channel via the hub's protected transmit_frame_().
bool stored_node_id_is_valid(const uint8_t id[NODE_ID_SIZE])
Check whether a node ID is usable as an address: not all-zero and not all-0xFF, the two patterns blan...
bool create_discover_confirm_ack(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a discovery-confirm acknowledgement (0x2D) — device side, used only by the key-extraction respo...
std::string node_id_to_string(const uint8_t id[NODE_ID_SIZE])
Format a 3‑byte node ID as a 6‑character uppercase hex string.
bool create_challenge_req_device_role(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE])
Build a device-role challenge request (0x3C) — device side, used only by the key-extraction responder...
std::function< void(const char *name, uint32_t delay_ms, std::function< void()> callback)> NamedTimeoutFn
Schedules a named, replace-on-same-name timeout on the hub's ESPHome scheduler.
uint8_t decode_packed_device_subtype(uint8_t type_subtype)
Decode a protocol-packed device subtype from the second metadata byte.
bool create_challenge_resp_device_role(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE], const IoFrame &origin, const uint8_t *key)
Build a device-role challenge response (0x3D) — device side, used only by the key-extraction responde...
DeviceType discovery_target_type(const uint8_t addr[NODE_ID_SIZE])
Extract the device type a discovery request is restricted to.
Pure decision logic for the device-role "Accept Foreign Pairing" (system-key extraction) responder.
Command builders for the IO‑Homecontrol protocol.
Cryptographic helpers for the IO‑Homecontrol protocol.
Radio abstraction layer for IO-Homecontrol.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
All runtime tunable parameters for pairing and radio diagnostics.
Context for one key-extraction arm cycle.
ResponderState state
Current state.
DeviceType advertised_type
Device type advertised in our 0x29.
uint8_t throwaway_id[NODE_ID_SIZE]
Random per-arm-cycle node ID we advertise as ourselves.
uint8_t advertised_subtype
Device subtype advertised in our 0x29.
uint8_t hub_node_id[NODE_ID_SIZE]
Foreign hub's real node ID, captured from the 0x31's src.
Runtime tuning configuration for pairing and radio diagnostics.