Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
pairing_responder.h
Go to the documentation of this file.
1#pragma once
2
3/// @file pairing_responder.h
4/// @brief Pure decision logic for the device-role "Accept Foreign Pairing" (system-key
5/// extraction) responder.
6/// @ingroup hioc_hub
7///
8/// Mirrors hub_pairing.h's PairingState enum but for the reverse role: here the hub emulates an
9/// *unpaired device* so a user's existing (foreign) hub can pair to it and hand over its
10/// node_id/system_key. This lets a user recover credentials from an installation they already
11/// own without a separate sniffing tool or a device reset.
12///
13/// This header holds only the pure state-transition decisions (no radio I/O, no timers), matching
14/// hub_decisions.h's split, so it is directly host-testable. The impure orchestration — arming,
15/// throwaway node-ID generation, transmitting replies, the auto-off timer, and the security log
16/// block — lives in the `KeyExtractionResponder` collaborator (`key_extraction_responder.h`/`.cpp`),
17/// which calls into this header from the 0x28/0x2C/0x31/0x32 branches in process_received_packet_()
18/// (hub_status.cpp).
19
20#include "proto_device_model.h"
21#include "proto_sizes.h"
22
23#include <cstdint>
24
25namespace esphome {
26namespace home_io_control {
27namespace pairing_responder {
28
29/// @brief State machine for the device-role key-extraction responder.
30enum class ResponderState : uint8_t {
31 DISARMED, ///< Not armed; 0x28/0x2C/0x31/0x32 traffic is ignored.
32 ARMED_IDLE, ///< Armed, listening for a discovery request (0x28).
33 SENT_DISCOVER_RESP, ///< Replied to discovery (0x29); waiting for discovery-confirm (0x2C) or key-init (0x31).
34 SENT_CONFIRM_ACK, ///< Acknowledged discovery-confirm (0x2D); waiting for key-init (0x31).
35 SENT_CHALLENGE, ///< Replied to key-init with our challenge (0x3C); waiting for key-transfer (0x32).
36 EXTRACTED, ///< System key recovered from a valid 0x32. Some hubs (KIG300) need nothing more and
37 ///< the responder disarms after a grace window; others (KLR200) follow up with an
38 ///< address request (0x36) — see SENT_ADDRESS_RESP.
39 SENT_ADDRESS_RESP, ///< Answered a hub's CMD_ADDRESS_REQ (0x36) with our CMD_ADDRESS_RESP (0x37);
40 ///< waiting for the hub's own CMD_CHALLENGE_REQ (0x3C) challenging it, or a
41 ///< retry of 0x36 itself. Appended at the end of the enum rather than in
42 ///< sequence order after EXTRACTED: nothing serializes or compares this
43 ///< uint8_t-backed enum ordinally (every guard below is an explicit == set),
44 ///< so ordering is cosmetic, and appending keeps the diff minimal.
45};
46
47/// @brief Get a short, log/telemetry-friendly name for a responder state.
48/// @param state Responder state.
49/// @return Null-terminated lowercase string such as "sent_challenge".
50const char *responder_stage_name(ResponderState state);
51
52/// @brief Context for one key-extraction arm cycle.
53///
54/// Owned directly by the hub (like PairingTelemetry), not by PairingEngine/ExchangeEngine — this
55/// is the reverse (device) role and does not fit either of those collaborators.
58 uint8_t throwaway_id[NODE_ID_SIZE]{}; ///< Random per-arm-cycle node ID we advertise as ourselves.
59 DeviceType advertised_type{DeviceType::UNKNOWN}; ///< Device type advertised in our 0x29.
60 uint8_t advertised_subtype{0}; ///< Device subtype advertised in our 0x29.
61 uint8_t challenge[HMAC_SIZE]{}; ///< Our challenge, generated on the first 0x31 of an attempt.
62 uint8_t hub_node_id[NODE_ID_SIZE]{}; ///< Foreign hub's real node ID, captured from the 0x31's src.
63 uint8_t recovered_key[AES_KEY_SIZE]{}; ///< Recovered system key; valid once state == EXTRACTED.
64};
65
66/// @brief Decide how to react to an inbound CMD_DISCOVER_REQ (0x28) while armed.
67///
68/// Valid from ARMED_IDLE (first response — replies using the throwaway ID/advertised type
69/// already stored in @p ctx by the caller at arm time) and SENT_DISCOVER_RESP (a hub retry —
70/// resend the same values without regenerating anything). No-op from SENT_CONFIRM_ACK or
71/// SENT_CHALLENGE: a discovery request must never pull an in-flight exchange back a phase. That
72/// silence is what a real device does too — in
73/// tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml the hub re-broadcasts 0x28 right after the
74/// confirm-ack (landing in SENT_CONFIRM_ACK) and the device pointedly does not answer, before the
75/// hub moves on to the key exchange. A second, independent hub does the same thing in
76/// tests/corpus/captures/pairing/velux_kig300_pairing_key_extraction_success.yaml (re-broadcast
77/// omitted from that capture's frame list as redundant, per its own notes). Neither of those
78/// mid-exchange states depends on @p hub_node_id — ARMED_IDLE/SENT_DISCOVER_RESP accept
79/// unconditionally too, since ctx.hub_node_id isn't meaningfully populated this early (it is first
80/// captured by on_key_init(), below) and the original design never restricted this case.
81///
82/// Also valid from EXTRACTED and SENT_ADDRESS_RESP — but, unlike every other case above, *only*
83/// when @p hub_node_id matches ctx.hub_node_id (the hub this responder actually extracted a key
84/// from, captured by on_key_init()). These two states are a *completed* attempt, so a fresh 0x28
85/// from the *same* hub here cannot be the mid-exchange rebroadcast the corpus evidence above
86/// documents — every corpus capture with one shows it landing between the confirm-ack and the key
87/// transfer, never after extraction — and is therefore a genuinely new attempt by that hub trying
88/// again. Without accepting this case, one successful extraction would deafen the responder to
89/// every later attempt by the same hub for the rest of the 10-minute arm window, recoverable only
90/// by manually disarming and re-arming the switch.
91///
92/// The hub-identity check is what @p hub_node_id is for: CMD_DISCOVER_REQ is a broadcast handled
93/// before the throwaway-ID destination filter (KeyExtractionResponder::try_handle_frame(),
94/// key_extraction_responder.cpp), so without it *any* hub in radio range — not necessarily the one this
95/// responder is actually doing business with — could knock a live post-extraction
96/// address-verification round (0x36→0x37→0x3C→0x3D with the real hub) back to
97/// SENT_DISCOVER_RESP merely by broadcasting its own unrelated 0x28. Restricting the restart to the
98/// same hub closes that hole while still fixing the "second attempt permanently dead" bug above for
99/// its actual repro case (the same hub, or test rig, starting a fresh attempt without a switch
100/// toggle).
101/// @param ctx Responder context (mutated: state only).
102/// @param hub_node_id The inbound frame's src (the broadcasting hub's real node ID) — only
103/// consulted from EXTRACTED/SENT_ADDRESS_RESP; ignored otherwise.
104/// @return true if the caller should build and send a CMD_DISCOVER_RESP (0x29) reply.
105bool on_discover_request(ResponderContext &ctx, const uint8_t hub_node_id[NODE_ID_SIZE]);
106
107/// @brief Decide how to react to an inbound CMD_DISCOVER_CONFIRM (0x2C) addressed to our
108/// throwaway ID.
109///
110/// A hub sends 0x2C directly to a device it just discovered and, in general, will not proceed to
111/// the key exchange until that device answers with CMD_DISCOVER_CONFIRM_ACK (0x2D) — the step
112/// between discovery and key-init in a real pairing
113/// (tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml, and this project's own responder
114/// answering a real hub's 0x2C in
115/// tests/corpus/captures/pairing/velux_kig300_pairing_key_extraction_success.yaml — the fixed,
116/// successful retest of velux_kig300_pairing_key_extraction_stall.yaml's stalled session, where
117/// that same hub's 0x2C previously went unanswered). Hub strictness varies: some retry 0x2C
118/// indefinitely without it, others eventually send 0x31 anyway, which is why on_key_init() also
119/// accepts a key-init straight from SENT_DISCOVER_RESP.
120///
121/// Valid from SENT_DISCOVER_RESP (first confirm) and SENT_CONFIRM_ACK (a hub retry after missing
122/// our 0x2D — resend the same bare ack, nothing is regenerated). No-op from any other state: like
123/// on_discover_request(), an early-phase frame must never pull an exchange already past this
124/// point back down.
125/// @param ctx Responder context (mutated: state only).
126/// @return true if the caller should build and send a CMD_DISCOVER_CONFIRM_ACK (0x2D) reply.
127bool on_discover_confirm(ResponderContext &ctx);
128
129/// @brief Decide how to react to an inbound CMD_KEY_INIT (0x31) addressed to our throwaway ID.
130///
131/// Valid from SENT_DISCOVER_RESP and SENT_CONFIRM_ACK (first key-init: stores @p challenge and
132/// @p hub_node_id, then advances — accepted from both because not every hub waits for our 0x2D,
133/// see on_discover_confirm()) and SENT_CHALLENGE (a hub retry after missing our 0x3C — @p challenge is discarded
134/// and the previously-stored one is reused instead, since the hub's eventual 0x32 must be
135/// decrypted with the exact challenge bytes it actually received; SX1262's slow TX→RX turnaround
136/// makes this retry path the expected common case, not an edge case). No-op from any other state.
137/// @param ctx Responder context (mutated: state, and on the first call challenge/hub_node_id).
138/// @param challenge Caller-generated 6 random bytes (crypto::generate_challenge()); only consumed
139/// on the first 0x31 of an attempt.
140/// @param hub_node_id The inbound frame's src (the foreign hub's real node ID).
141/// @return true if the caller should build and send a CMD_CHALLENGE_REQ (0x3C) reply using
142/// ctx.challenge (not @p challenge directly — they may differ on a retry).
143bool on_key_init(ResponderContext &ctx, const uint8_t challenge[HMAC_SIZE], const uint8_t hub_node_id[NODE_ID_SIZE]);
144
145/// @brief Decide how to react to an inbound CMD_KEY_TRANSFER (0x32) while armed.
146///
147/// Valid only from SENT_CHALLENGE. Recovers the system key from @p transfer_payload using
148/// ctx.challenge via recover_system_key_from_transfer() (proto_commands.h) and, on success,
149/// transitions to EXTRACTED. No-op (state unchanged) from any other state, or if decoding fails.
150/// @param ctx Responder context (mutated: state, and on success recovered_key).
151/// @param transfer_payload 16-byte CMD_KEY_TRANSFER payload (frame.data).
152/// @return true if the key was recovered and the caller should build and send a
153/// CMD_KEY_CONFIRM (0x33) reply and report ctx.recovered_key to the user.
154bool on_key_transfer(ResponderContext &ctx, const uint8_t transfer_payload[AES_KEY_SIZE]);
155
156/// @brief Decide how to react to an inbound CMD_ADDRESS_REQ (0x36) addressed to our throwaway ID.
157///
158/// Valid from EXTRACTED (first reply) and SENT_ADDRESS_RESP (a hub retry — resend the same 0x37,
159/// nothing regenerated, matching on_discover_request()'s established retry idiom). No-op from any
160/// earlier state: a hub that hasn't completed the key exchange yet has no business asking for our
161/// address. No-op from DISARMED/ARMED_IDLE etc. too, same as every other on_*() here.
162/// @param ctx Responder context (mutated: state only).
163/// @return true if the caller should build and send a CMD_ADDRESS_RESP (0x37) reply.
164bool on_address_req(ResponderContext &ctx);
165
166/// @brief Decide how to react to an inbound CMD_CHALLENGE_REQ (0x3C) — issued by the hub this
167/// time, challenging our own CMD_ADDRESS_RESP (0x37) rather than us challenging the hub.
168///
169/// Valid only from SENT_ADDRESS_RESP: a hub-issued 0x3C only makes sense after we've told it our
170/// address. No new fields to store — the caller has everything it needs (ctx.recovered_key,
171/// ctx.throwaway_id, the inbound frame's own challenge bytes) to build 0x3D on the spot.
172///
173/// Unlike every other on_*() in this file, this function does NOT clear or advance state on
174/// success — it stays in SENT_ADDRESS_RESP precisely so a retried 0x3C is answered the same way a
175/// retried 0x31 is by on_key_init(). That makes it the only on_*() here that returns true without
176/// ever changing @p ctx; that is intentional, not an oversight.
177/// @param ctx Responder context (read-only; not mutated).
178/// @return true if the caller should build and send a CMD_CHALLENGE_RESP (0x3D) reply.
179bool on_address_challenge(const ResponderContext &ctx);
180
181} // namespace pairing_responder
182} // namespace home_io_control
183} // namespace esphome
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_address_challenge(const ResponderContext &ctx)
Decide how to react to an inbound CMD_CHALLENGE_REQ (0x3C) — issued by the hub this time,...
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_address_req(ResponderContext &ctx)
Decide how to react to an inbound CMD_ADDRESS_REQ (0x36) addressed to our throwaway ID.
ResponderState
State machine for the device-role key-extraction responder.
@ 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).
@ SENT_CONFIRM_ACK
Acknowledged discovery-confirm (0x2D); waiting for key-init (0x31).
@ DISARMED
Not armed; 0x28/0x2C/0x31/0x32 traffic is ignored.
@ SENT_ADDRESS_RESP
Answered a hub's CMD_ADDRESS_REQ (0x36) with our CMD_ADDRESS_RESP (0x37); waiting for the hub's own C...
@ SENT_CHALLENGE
Replied to key-init with our challenge (0x3C); waiting for key-transfer (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.
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_sizes.h:20
DeviceType
Device type identifiers reported by IO‑Homecontrol products.
@ UNKNOWN
Unknown/unspecified device.
static constexpr uint8_t HMAC_SIZE
Authentication HMAC is 6 bytes (truncated AES output).
Definition proto_sizes.h:22
static constexpr uint8_t AES_KEY_SIZE
AES-128 key size.
Definition proto_sizes.h:23
IO-Homecontrol device-type model, capabilities and runtime device state.
Fundamental IO-Homecontrol frame and crypto size constants.
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.
uint8_t challenge[HMAC_SIZE]
Our challenge, generated on the first 0x31 of an attempt.
uint8_t recovered_key[AES_KEY_SIZE]
Recovered system key; valid once state == EXTRACTED.