diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index ca062abb984..ce356d7cdc4 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -17,6 +17,7 @@ #include "main.h" #include "mesh-pb-constants.h" #include "meshUtils.h" +#include "modules/AdminModule.h" #include "modules/NodeInfoModule.h" #include "modules/PositionModule.h" #include "modules/RoutingModule.h" @@ -259,6 +260,14 @@ void MeshService::handleToRadio(meshtastic_MeshPacket &p) const StoredMessage &sm = messageStore.addFromPacket(p); graphics::MessageRenderer::handleNewMessage(nullptr, sm, p); // notify UI }) +#if !MESHTASTIC_EXCLUDE_ADMIN + // Note admin requests on their way out: AdminModule only accepts a response from a remote we + // actually asked. Runs before encryption, while the payload is still readable. + if (adminModule && p.which_payload_variant == meshtastic_MeshPacket_decoded_tag && + p.decoded.portnum == meshtastic_PortNum_ADMIN_APP) + adminModule->noteOutgoingAdminRequest(p); +#endif + // Send the packet into the mesh DEBUG_HEAP_BEFORE; auto a = packetPool.allocCopy(p); diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index b98df39cdc8..36a7d6fe97e 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -10,6 +10,7 @@ #include "input/InputBroker.h" #include "meshUtils.h" #include +#include #include // for better whitespace handling #if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WIFI #include "MeshtasticOTA.h" @@ -125,9 +126,16 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta } #endif meshtastic_Channel *ch = &channels.getByIndex(mp.channel); - // Could tighten this up further by tracking the last public_key we went an AdminMessage request to - // and only allowing responses from that remote. if (messageIsResponse(r)) { + // Only accept a response from a remote we sent the matching request to. from == 0 is a + // local client, which PhoneAPI has already gated. + const pb_size_t moduleConfigTag = r->which_payload_variant == meshtastic_AdminMessage_get_module_config_response_tag + ? r->get_module_config_response.which_payload_variant + : 0; + if (mp.from != 0 && !responseIsSolicited(mp, r->which_payload_variant, moduleConfigTag)) { + LOG_INFO("Ignore admin response from 0x%08x, no outstanding request", mp.from); + return handled; + } LOG_DEBUG("Allow admin response message"); } else if (mp.from == 0) { // Local admin from a BLE/USB/TCP client. from == 0 cannot arrive from the @@ -472,8 +480,9 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta } case meshtastic_AdminMessage_get_module_config_response_tag: { LOG_INFO("Client received a get_module_config response"); - if (fromOthers && r->get_module_config_response.which_payload_variant == - meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG) { + // which_payload_variant is the ModuleConfig oneof tag, so compare against that tag, not the + // AdminMessage ModuleConfigType enum (whose REMOTEHARDWARE value is a different number). + if (fromOthers && r->get_module_config_response.which_payload_variant == meshtastic_ModuleConfig_remote_hardware_tag) { handleGetModuleConfigResponse(mp, r); } break; @@ -1821,6 +1830,107 @@ bool AdminModule::messageIsResponse(const meshtastic_AdminMessage *r) return false; } +// The response variant that answers a getter request, or 0 if the request has none. +static pb_size_t adminResponseForRequest(pb_size_t requestVariant) +{ + switch (requestVariant) { + case meshtastic_AdminMessage_get_channel_request_tag: + return meshtastic_AdminMessage_get_channel_response_tag; + case meshtastic_AdminMessage_get_owner_request_tag: + return meshtastic_AdminMessage_get_owner_response_tag; + case meshtastic_AdminMessage_get_config_request_tag: + return meshtastic_AdminMessage_get_config_response_tag; + case meshtastic_AdminMessage_get_module_config_request_tag: + return meshtastic_AdminMessage_get_module_config_response_tag; + case meshtastic_AdminMessage_get_canned_message_module_messages_request_tag: + return meshtastic_AdminMessage_get_canned_message_module_messages_response_tag; + case meshtastic_AdminMessage_get_device_metadata_request_tag: + return meshtastic_AdminMessage_get_device_metadata_response_tag; + case meshtastic_AdminMessage_get_ringtone_request_tag: + return meshtastic_AdminMessage_get_ringtone_response_tag; + case meshtastic_AdminMessage_get_device_connection_status_request_tag: + return meshtastic_AdminMessage_get_device_connection_status_response_tag; + case meshtastic_AdminMessage_get_node_remote_hardware_pins_request_tag: + return meshtastic_AdminMessage_get_node_remote_hardware_pins_response_tag; + case meshtastic_AdminMessage_get_ui_config_request_tag: + return meshtastic_AdminMessage_get_ui_config_response_tag; + default: + return 0; + } +} + +void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p) +{ + if (p.which_payload_variant != meshtastic_MeshPacket_decoded_tag || p.decoded.portnum != meshtastic_PortNum_ADMIN_APP) + return; + // Local admin is answered in-process, and a broadcast is never a request to one remote. + if (p.to == 0 || isBroadcast(p.to) || p.to == nodeDB->getNodeNum()) + return; + + meshtastic_AdminMessage admin = meshtastic_AdminMessage_init_zero; + if (!pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_AdminMessage_msg, &admin)) + return; + const pb_size_t responseVariant = adminResponseForRequest(admin.which_payload_variant); + if (!responseVariant) + return; // not a getter whose response we can pair + + const bool keyValid = p.pki_encrypted && p.public_key.size == 32; + + // One entry per request (a client sends N indexed get_channel requests, each answered once, so + // entries must not merge). Free slot, else evict the oldest by rollover-safe elapsed time. + OutstandingAdminRequest *slot = nullptr; + for (auto &o : outstandingAdminRequests) + if (o.to == 0) { + slot = &o; + break; + } + if (!slot) { + const uint32_t now = millis(); + slot = &outstandingAdminRequests[0]; + for (auto &o : outstandingAdminRequests) + if ((uint32_t)(now - o.sentAtMs) > (uint32_t)(now - slot->sentAtMs)) + slot = &o; + } + + slot->to = p.to; + slot->sentAtMs = millis(); + slot->expectedResponse = responseVariant; + slot->moduleConfigType = admin.which_payload_variant == meshtastic_AdminMessage_get_module_config_request_tag + ? (uint8_t)admin.get_module_config_request + : 0; + slot->keyValid = keyValid; + if (keyValid) + memcpy(slot->key, p.public_key.bytes, 32); + else + memset(slot->key, 0, 32); + LOG_DEBUG("Admin request sent to 0x%08x, expecting its response", p.to); +} + +bool AdminModule::responseIsSolicited(const meshtastic_MeshPacket &mp, pb_size_t responseVariant, pb_size_t moduleConfigTag) +{ + // Scan every entry: several requests for the same variant may differ only in pinning, and an + // unpinned one still authorizes the response even if a pinned one does not. + for (auto &o : outstandingAdminRequests) { + if (o.to != mp.from || o.expectedResponse != responseVariant) + continue; + if (!Throttle::isWithinTimespanMs(o.sentAtMs, kOutstandingAdminRequestMs)) { + o.to = 0; // lapsed; free the slot and keep looking for another live match + continue; + } + // A request pinned to a PKC key must be answered over PKC by that same key. + if (o.keyValid && (!mp.pki_encrypted || mp.public_key.size != 32 || memcmp(mp.public_key.bytes, o.key, 32) != 0)) + continue; + // remote_hardware is the only module config that mutates state (the pin table), so require + // it to answer a request for that exact subtype, not just any get_module_config_request. + if (moduleConfigTag == meshtastic_ModuleConfig_remote_hardware_tag && + o.moduleConfigType != meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG) + continue; + o.to = 0; // consume: one request authorizes one response, no replay within the window + return true; + } + return false; +} + bool AdminModule::messageIsRequest(const meshtastic_AdminMessage *r) { if (r->which_payload_variant == meshtastic_AdminMessage_get_channel_request_tag || diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index 15923988bc9..bfc3035b511 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -77,7 +77,29 @@ class AdminModule : public ProtobufModule, public Obser public: void handleSetHamMode(const meshtastic_HamParameters &req); + /// Note an admin request leaving this node for a remote, so that remote's response is + /// accepted. Called from the client-to-mesh path (MeshService::handleToRadio). + void noteOutgoingAdminRequest(const meshtastic_MeshPacket &p); + private: + // An admin response has no session passkey and its sender need not hold an admin key, so a + // request we sent is the only thing vouching for it. Track each request independently (its own + // expiry and pinned key) so a later one can't extend or relax an earlier one. + static constexpr size_t kOutstandingAdminRequests = 8; + static constexpr uint32_t kOutstandingAdminRequestMs = 300 * 1000; // same window as the session passkey + struct OutstandingAdminRequest { + NodeNum to; // 0 = free slot + uint32_t sentAtMs; // millis() when this request went out + pb_size_t expectedResponse; // the one response variant this request authorizes + uint8_t moduleConfigType; // for get_module_config_request: which ModuleConfigType we asked + uint8_t key[32]; // pinned destination key when the request went out over PKC + bool keyValid; + }; + OutstandingAdminRequest outstandingAdminRequests[kOutstandingAdminRequests] = {}; + + /// Whether a response (variant responseVariant, module-config subtype moduleConfigTag or 0) + /// from mp.from answers a request we sent; consumes the matched request so it can't be replayed. + bool responseIsSolicited(const meshtastic_MeshPacket &mp, pb_size_t responseVariant, pb_size_t moduleConfigTag); void handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uicfg); void handleSendInputEvent(const meshtastic_AdminMessage_InputEvent &inputEvent); void reboot(int32_t seconds); diff --git a/test/support/AdminModuleTestShim.h b/test/support/AdminModuleTestShim.h index 6d9c3f1a8ec..c189bee84cc 100644 --- a/test/support/AdminModuleTestShim.h +++ b/test/support/AdminModuleTestShim.h @@ -12,6 +12,7 @@ class AdminModuleTestShim : public AdminModule using AdminModule::handleReceivedProtobuf; using AdminModule::handleSetConfig; using AdminModule::handleSetModuleConfig; + using AdminModule::responseIsSolicited; // request/response pairing gate using AdminModule::setPassKey; // Peek at the reply a handler queued, before drainReply() releases it. diff --git a/test/test_admin_session_repro/test_main.cpp b/test/test_admin_session_repro/test_main.cpp index 6502ab3c4b4..2a4cdda8d3d 100644 --- a/test/test_admin_session_repro/test_main.cpp +++ b/test/test_admin_session_repro/test_main.cpp @@ -1,6 +1,7 @@ // Deterministic reproduction of the admin session-key behavior discussed on PR #10669 // (ndoo's "Admin message without session_key!" report), plus the remote-vs-local redaction of -// secret material in admin GET responses. +// secret material in admin GET responses, plus the request/response pairing that decides which +// admin responses are accepted at all. // // Drives the REAL incoming-admin path (AdminModule::handleReceivedProtobuf) with a remote // (from != 0) PKC-authorized set_owner, exercising the exact checkPassKey/setPassKey gate. @@ -21,7 +22,9 @@ #include static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; -static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; // authorized admin, sends remote admin to us +static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; // authorized admin, sends remote admin to us +static constexpr NodeNum QUERIED_NODE = 0x0C0C0C0C; // a remote we send admin requests to +static constexpr NodeNum STRANGER_NODE = 0x0D0D0D0D; static const uint8_t ADMIN_KEY[32] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x20}; @@ -51,6 +54,59 @@ static meshtastic_MeshPacket makeRemoteSetOwner(const char *newLongName, const u return mp; } +// A get_module_config_response carrying a remote_hardware pin list, as a remote would answer. +// This is the class of message that short-circuited auth: no session passkey, sender need not +// hold an admin key. handleGetModuleConfigResponse() stamps mp.from into the pin table. +static meshtastic_MeshPacket makeModuleConfigResponse(NodeNum from, meshtastic_AdminMessage &out) +{ + out = meshtastic_AdminMessage_init_zero; + out.which_payload_variant = meshtastic_AdminMessage_get_module_config_response_tag; + out.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_remote_hardware_tag; + out.get_module_config_response.payload_variant.remote_hardware.enabled = true; + out.get_module_config_response.payload_variant.remote_hardware.available_pins_count = 1; + out.get_module_config_response.payload_variant.remote_hardware.available_pins[0].gpio_pin = 17; + + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = from; + mp.to = LOCAL_NODE; + mp.channel = 0; + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + return mp; +} + +// One known pin entry, so a response that reaches handleGetModuleConfigResponse rewrites its owner. +static void seedRemoteHardwarePin(NodeNum owner) +{ + devicestate.node_remote_hardware_pins_count = 1; + devicestate.node_remote_hardware_pins[0] = meshtastic_NodeRemoteHardwarePin_init_zero; + devicestate.node_remote_hardware_pins[0].node_num = owner; + devicestate.node_remote_hardware_pins[0].has_pin = true; + devicestate.node_remote_hardware_pins[0].pin.gpio_pin = 4; +} + +// The outgoing request `req` a local client would send to `to`, as MeshService sees it (from == 0, +// ADMIN_APP, payload still plaintext). +static meshtastic_MeshPacket makeOutgoingRequest(NodeNum to, const meshtastic_AdminMessage &req) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = 0; + p.to = to; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_ADMIN_APP; + p.decoded.payload.size = + pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_AdminMessage_msg, &req); + return p; +} + +static meshtastic_MeshPacket makeOutgoingModuleConfigRequest( + NodeNum to, meshtastic_AdminMessage_ModuleConfigType type = meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG) +{ + meshtastic_AdminMessage req = meshtastic_AdminMessage_init_zero; + req.which_payload_variant = meshtastic_AdminMessage_get_module_config_request_tag; + req.get_module_config_request = type; + return makeOutgoingRequest(to, req); +} + void setUp(void) { mockService = new MockMeshService(); @@ -194,6 +250,174 @@ void test_local_security_config_keeps_private_key(void) admin->drainReply(); } +// An admin response carries no session passkey and its sender is not an admin-key holder, so a +// request we sent is the only thing vouching for it. A get_module_config_response from a node we +// never queried is not. +static constexpr pb_size_t MODULE_CONFIG_RESPONSE = meshtastic_AdminMessage_get_module_config_response_tag; +static constexpr pb_size_t REMOTE_HW_TAG = meshtastic_ModuleConfig_remote_hardware_tag; // makeModuleConfigResponse subtype + +void test_unsolicited_response_is_not_solicited(void) +{ + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + + TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "a response nobody asked for must not be accepted"); +} + +// Control: once the client has sent that node a request, its response is accepted. Without this, +// the test above would also pass if responseIsSolicited() simply always said no. +void test_response_after_our_request_is_solicited(void) +{ + admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(STRANGER_NODE)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + + TEST_ASSERT_TRUE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "the answer to our own request must be accepted"); +} + +// A request to one remote does not vouch for a different remote's response. +void test_request_to_one_node_does_not_admit_another(void) +{ + admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(QUERIED_NODE)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); // answered by someone else + + TEST_ASSERT_FALSE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG)); +} + +// A response only answers its own request type: a get_owner_request does not admit a +// get_module_config_response from the same node. +void test_response_variant_must_match_request(void) +{ + meshtastic_AdminMessage owner_req = meshtastic_AdminMessage_init_zero; + owner_req.which_payload_variant = meshtastic_AdminMessage_get_owner_request_tag; + admin->noteOutgoingAdminRequest(makeOutgoingRequest(STRANGER_NODE, owner_req)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); // wrong type for the request + + TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "a get_owner request must not admit a get_module_config response"); + // ...but the response it actually asked for is still accepted from the same slot. + TEST_ASSERT_TRUE(admin->responseIsSolicited(mp, meshtastic_AdminMessage_get_owner_response_tag, 0)); +} + +// Regression: each request keeps its own pinned key. A later unpinned request to the same node +// must not relax the PKC pin of an earlier one (the old shared-slot model cleared it). +void test_pinned_request_keeps_its_key_after_an_unpinned_request(void) +{ + uint8_t key[32]; + memset(key, 0xC1, 32); + + // A PKC-pinned get_config request to STRANGER (pins `key`). + meshtastic_AdminMessage cfg = meshtastic_AdminMessage_init_zero; + cfg.which_payload_variant = meshtastic_AdminMessage_get_config_request_tag; + meshtastic_MeshPacket pinned = makeOutgoingRequest(STRANGER_NODE, cfg); + pinned.pki_encrypted = true; + pinned.public_key.size = 32; + memcpy(pinned.public_key.bytes, key, 32); + admin->noteOutgoingAdminRequest(pinned); + + // A later, unpinned get_owner request to the same node. + meshtastic_AdminMessage own = meshtastic_AdminMessage_init_zero; + own.which_payload_variant = meshtastic_AdminMessage_get_owner_request_tag; + admin->noteOutgoingAdminRequest(makeOutgoingRequest(STRANGER_NODE, own)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket resp = makeModuleConfigResponse(STRANGER_NODE, m); // from set; pki off by default + + // A plaintext get_config_response is still rejected: the config request was pinned. + TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_config_response_tag, 0), + "an unpinned request must not relax an earlier request's key pin"); + // Over PKC with the pinned key it is accepted. + resp.pki_encrypted = true; + resp.public_key.size = 32; + memcpy(resp.public_key.bytes, key, 32); + TEST_ASSERT_TRUE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_config_response_tag, 0)); + // The unpinned get_owner_response is accepted in plaintext (its request carried no pin). + resp.pki_encrypted = false; + resp.public_key.size = 0; + TEST_ASSERT_TRUE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_owner_response_tag, 0)); +} + +// A remote_hardware response must answer a request for that exact subtype, not just any module +// config - else an MQTT-config request could authorize a pin-table update. +void test_module_config_subtype_must_match(void) +{ + admin->noteOutgoingAdminRequest( + makeOutgoingModuleConfigRequest(STRANGER_NODE, meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); // remote_hardware subtype + TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "a non-remote-hardware request must not admit a remote_hardware response"); + + // Control: a request for the matching subtype does admit it. + admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(STRANGER_NODE)); + TEST_ASSERT_TRUE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG)); +} + +// A matched request is consumed, so a node cannot replay a state-mutating response within the window. +void test_response_is_consumed_no_replay(void) +{ + admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(STRANGER_NODE)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + TEST_ASSERT_TRUE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG)); + TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "a second copy of an already-answered response must be rejected"); +} + +// Only requests arm the gate: sending a setter to a node must not make it a trusted responder. +void test_outgoing_setter_does_not_admit_responses(void) +{ + meshtastic_AdminMessage setter = meshtastic_AdminMessage_init_zero; + setter.which_payload_variant = meshtastic_AdminMessage_set_owner_tag; + admin->noteOutgoingAdminRequest(makeOutgoingRequest(STRANGER_NODE, setter)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + + TEST_ASSERT_FALSE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG)); +} + +// End to end through the real handler: an unsolicited get_module_config_response must not reach +// handleGetModuleConfigResponse, so the remote_hardware pin table keeps its owner. +void test_unsolicited_response_does_not_poison_pins(void) +{ + seedRemoteHardwarePin(QUERIED_NODE); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + admin->handleReceivedProtobuf(mp, &m); + admin->drainReply(); + + TEST_ASSERT_EQUAL_UINT32_MESSAGE(QUERIED_NODE, devicestate.node_remote_hardware_pins[0].node_num, + "unsolicited response must not rewrite the pin owner"); +} + +// Control: once we have requested it, the same response is handled and updates the pin table. This +// proves the assertion above is the auth gate firing, not the handler being dead. (It also exercises +// the dispatch tag fixed in this change - without it, the handler never runs for either node.) +void test_solicited_response_updates_pins(void) +{ + seedRemoteHardwarePin(QUERIED_NODE); + admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(STRANGER_NODE)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + admin->handleReceivedProtobuf(mp, &m); + admin->drainReply(); + + TEST_ASSERT_EQUAL_UINT32_MESSAGE(STRANGER_NODE, devicestate.node_remote_hardware_pins[0].node_num, + "a response we asked for must reach the handler"); +} + #endif // !(MESHTASTIC_EXCLUDE_PKI) void setup() @@ -207,6 +431,16 @@ void setup() RUN_TEST(test_session_gate_accepts_key_from_a_get_response); RUN_TEST(test_remote_security_config_omits_private_key); RUN_TEST(test_local_security_config_keeps_private_key); + RUN_TEST(test_unsolicited_response_is_not_solicited); + RUN_TEST(test_response_after_our_request_is_solicited); + RUN_TEST(test_request_to_one_node_does_not_admit_another); + RUN_TEST(test_response_variant_must_match_request); + RUN_TEST(test_pinned_request_keeps_its_key_after_an_unpinned_request); + RUN_TEST(test_module_config_subtype_must_match); + RUN_TEST(test_response_is_consumed_no_replay); + RUN_TEST(test_outgoing_setter_does_not_admit_responses); + RUN_TEST(test_unsolicited_response_does_not_poison_pins); + RUN_TEST(test_solicited_response_updates_pins); #endif exit(UNITY_END()); }