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 on IOHomeControlComponent (hub_key_extraction.cpp), which calls into this header
17/// from the 0x28/0x2C/0x31/0x32 branches in process_received_packet_() (hub_status.cpp).
18
19#include "proto_device_model.h"
20#include "proto_sizes.h"
21
22#include <cstdint>
23
24namespace esphome {
25namespace home_io_control {
26namespace pairing_responder {
27
28/// @brief State machine for the device-role key-extraction responder.
29enum class ResponderState : uint8_t {
30 DISARMED, ///< Not armed; 0x28/0x2C/0x31/0x32 traffic is ignored.
31 ARMED_IDLE, ///< Armed, listening for a discovery request (0x28).
32 SENT_DISCOVER_RESP, ///< Replied to discovery (0x29); waiting for discovery-confirm (0x2C) or key-init (0x31).
33 SENT_CONFIRM_ACK, ///< Acknowledged discovery-confirm (0x2D); waiting for key-init (0x31).
34 SENT_CHALLENGE, ///< Replied to key-init with our challenge (0x3C); waiting for key-transfer (0x32).
35 EXTRACTED, ///< System key recovered from a valid 0x32; the hub disarms immediately after.
36};
37
38/// @brief Get a short, log/telemetry-friendly name for a responder state.
39/// @param state Responder state.
40/// @return Null-terminated lowercase string such as "sent_challenge".
41const char *responder_stage_name(ResponderState state);
42
43/// @brief Context for one key-extraction arm cycle.
44///
45/// Owned directly by the hub (like PairingTelemetry), not by PairingEngine/ExchangeEngine — this
46/// is the reverse (device) role and does not fit either of those collaborators.
49 uint8_t throwaway_id[NODE_ID_SIZE]{}; ///< Random per-arm-cycle node ID we advertise as ourselves.
50 DeviceType advertised_type{DeviceType::UNKNOWN}; ///< Device type advertised in our 0x29.
51 uint8_t advertised_subtype{0}; ///< Device subtype advertised in our 0x29.
52 uint8_t challenge[HMAC_SIZE]{}; ///< Our challenge, generated on the first 0x31 of an attempt.
53 uint8_t hub_node_id[NODE_ID_SIZE]{}; ///< Foreign hub's real node ID, captured from the 0x31's src.
54 uint8_t recovered_key[AES_KEY_SIZE]{}; ///< Recovered system key; valid once state == EXTRACTED.
55};
56
57/// @brief Decide how to react to an inbound CMD_DISCOVER_REQ (0x28) while armed.
58///
59/// Valid from ARMED_IDLE (first response — replies using the throwaway ID/advertised type
60/// already stored in @p ctx by the caller at arm time) and SENT_DISCOVER_RESP (a hub retry —
61/// resend the same values without regenerating anything). No-op from any later state: a discovery
62/// request must never re-arm or restart an exchange already past this phase. That silence is what
63/// a real device does too — in tests/corpus/captures/velux_kux100/pairing_full.yaml the hub
64/// re-broadcasts 0x28 right after the confirm-ack and the device pointedly does not answer,
65/// before the hub moves on to the key exchange. A second, independent hub does the same thing in
66/// tests/corpus/captures/issues/issue_45_velux_kig300_key_extraction_success.yaml (re-broadcast
67/// omitted from that capture's frame list as redundant, per its own notes).
68/// @param ctx Responder context (mutated: state only).
69/// @return true if the caller should build and send a CMD_DISCOVER_RESP (0x29) reply.
70bool on_discover_request(ResponderContext &ctx);
71
72/// @brief Decide how to react to an inbound CMD_DISCOVER_CONFIRM (0x2C) addressed to our
73/// throwaway ID.
74///
75/// A hub sends 0x2C directly to a device it just discovered and, in general, will not proceed to
76/// the key exchange until that device answers with CMD_DISCOVER_CONFIRM_ACK (0x2D) — the step
77/// between discovery and key-init in a real pairing
78/// (tests/corpus/captures/velux_kux100/pairing_full.yaml, and this project's own responder
79/// answering a real hub's 0x2C in
80/// tests/corpus/captures/issues/issue_45_velux_kig300_key_extraction_success.yaml — the fixed,
81/// successful retest of issue_45_velux_kig300_key_extraction_stall.yaml's stalled session, where
82/// that same hub's 0x2C previously went unanswered). Hub strictness varies: some retry 0x2C
83/// indefinitely without it, others eventually send 0x31 anyway, which is why on_key_init() also
84/// accepts a key-init straight from SENT_DISCOVER_RESP.
85///
86/// Valid from SENT_DISCOVER_RESP (first confirm) and SENT_CONFIRM_ACK (a hub retry after missing
87/// our 0x2D — resend the same bare ack, nothing is regenerated). No-op from any other state: like
88/// on_discover_request(), an early-phase frame must never pull an exchange already past this
89/// point back down.
90/// @param ctx Responder context (mutated: state only).
91/// @return true if the caller should build and send a CMD_DISCOVER_CONFIRM_ACK (0x2D) reply.
92bool on_discover_confirm(ResponderContext &ctx);
93
94/// @brief Decide how to react to an inbound CMD_KEY_INIT (0x31) addressed to our throwaway ID.
95///
96/// Valid from SENT_DISCOVER_RESP and SENT_CONFIRM_ACK (first key-init: stores @p challenge and
97/// @p hub_node_id, then advances — accepted from both because not every hub waits for our 0x2D,
98/// see on_discover_confirm()) and SENT_CHALLENGE (a hub retry after missing our 0x3C — @p challenge is discarded
99/// and the previously-stored one is reused instead, since the hub's eventual 0x32 must be
100/// decrypted with the exact challenge bytes it actually received; SX1262's slow TX→RX turnaround
101/// makes this retry path the expected common case, not an edge case). No-op from any other state.
102/// @param ctx Responder context (mutated: state, and on the first call challenge/hub_node_id).
103/// @param challenge Caller-generated 6 random bytes (crypto::generate_challenge()); only consumed
104/// on the first 0x31 of an attempt.
105/// @param hub_node_id The inbound frame's src (the foreign hub's real node ID).
106/// @return true if the caller should build and send a CMD_CHALLENGE_REQ (0x3C) reply using
107/// ctx.challenge (not @p challenge directly — they may differ on a retry).
108bool on_key_init(ResponderContext &ctx, const uint8_t challenge[HMAC_SIZE], const uint8_t hub_node_id[NODE_ID_SIZE]);
109
110/// @brief Decide how to react to an inbound CMD_KEY_TRANSFER (0x32) while armed.
111///
112/// Valid only from SENT_CHALLENGE. Recovers the system key from @p transfer_payload using
113/// ctx.challenge via recover_system_key_from_transfer() (proto_commands.h) and, on success,
114/// transitions to EXTRACTED. No-op (state unchanged) from any other state, or if decoding fails.
115/// @param ctx Responder context (mutated: state, and on success recovered_key).
116/// @param transfer_payload 16-byte CMD_KEY_TRANSFER payload (frame.data).
117/// @return true if the key was recovered and the caller should build and send a
118/// CMD_KEY_CONFIRM (0x33) reply and report ctx.recovered_key to the user.
119bool on_key_transfer(ResponderContext &ctx, const uint8_t transfer_payload[AES_KEY_SIZE]);
120
121} // namespace pairing_responder
122} // namespace home_io_control
123} // 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_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.
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_CHALLENGE
Replied to key-init with our challenge (0x3C); waiting for key-transfer (0x32).
@ EXTRACTED
System key recovered from a valid 0x32; the hub disarms immediately after.
bool on_discover_request(ResponderContext &ctx)
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.