@@ -29,8 +29,43 @@ static const uint8_t ADMIN_KEY[32] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
2929 0xcc , 0xdd , 0xee , 0xff , 0x01 , 0x02 , 0x03 , 0x04 , 0x05 , 0x06 , 0x07 ,
3030 0x08 , 0x09 , 0x0a , 0x0b , 0x0c , 0x0d , 0x0e , 0x0f , 0x10 , 0x20 };
3131
32+ // MeshService assigns every outgoing packet an id before noteOutgoingAdminRequest sees it, and
33+ // setReplyTo echoes it back as decoded.request_id, so the pairing is keyed on it.
34+ static constexpr uint32_t REQUEST_ID = 0x5EED0001 ;
35+ static const uint8_t QUERIED_KEY [32 ] = {0xC1 , 0xC2 , 0xC3 , 0xC4 , 0xC5 , 0xC6 , 0xC7 , 0xC8 , 0xC9 , 0xCa , 0xCb ,
36+ 0xCc , 0xCd , 0xCe , 0xCf , 0xD0 , 0xD1 , 0xD2 , 0xD3 , 0xD4 , 0xD5 , 0xD6 ,
37+ 0xD7 , 0xD8 , 0xD9 , 0xDa , 0xDb , 0xDc , 0xDd , 0xDe , 0xDf , 0xE0 };
38+
39+ // NodeDB with injectable nodes, so a destination can have a stored public key to pin.
40+ class MockNodeDB : public NodeDB
41+ {
42+ public:
43+ void clearTestNodes ()
44+ {
45+ testNodes.clear ();
46+ meshNodes = &testNodes;
47+ numMeshNodes = 0 ;
48+ }
49+
50+ void addNodeWithKey (NodeNum num, const uint8_t *key)
51+ {
52+ meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero;
53+ n.num = num;
54+ if (key) {
55+ n.public_key .size = 32 ;
56+ memcpy (n.public_key .bytes , key, 32 );
57+ }
58+ testNodes.push_back (n);
59+ meshNodes = &testNodes;
60+ numMeshNodes = testNodes.size ();
61+ }
62+
63+ std::vector<meshtastic_NodeInfoLite> testNodes;
64+ };
65+
3266static MockMeshService *mockService = nullptr ;
3367static AdminModuleTestShim *admin = nullptr ;
68+ static MockNodeDB *mockNodeDB = nullptr ;
3469
3570// A remote, PKC-authorized set_owner. `session` (if non-empty) is the session_passkey the client presents.
3671static meshtastic_MeshPacket makeRemoteSetOwner (const char *newLongName, const uint8_t *session, size_t sessionLen,
@@ -71,6 +106,7 @@ static meshtastic_MeshPacket makeModuleConfigResponse(NodeNum from, meshtastic_A
71106 mp.to = LOCAL_NODE ;
72107 mp.channel = 0 ;
73108 mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
109+ mp.decoded .request_id = REQUEST_ID ; // setReplyTo echoes the request's packet id
74110 return mp;
75111}
76112
@@ -93,6 +129,7 @@ static meshtastic_MeshPacket makeOutgoingRequest(NodeNum to, const meshtastic_Ad
93129 p.to = to;
94130 p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
95131 p.decoded .portnum = meshtastic_PortNum_ADMIN_APP;
132+ p.id = REQUEST_ID ;
96133 p.decoded .payload .size =
97134 pb_encode_to_bytes (p.decoded .payload .bytes , sizeof (p.decoded .payload .bytes ), &meshtastic_AdminMessage_msg, &req);
98135 return p;
@@ -114,11 +151,16 @@ void setUp(void)
114151 admin = new AdminModuleTestShim ();
115152 admin->deferSaves (); // no disk/reboot side effects when a setter is accepted
116153
117- if (!nodeDB)
118- nodeDB = new NodeDB ();
154+ if (!mockNodeDB)
155+ mockNodeDB = new MockNodeDB ();
156+ mockNodeDB->clearTestNodes ();
157+ nodeDB = mockNodeDB;
119158 myNodeInfo.my_node_num = LOCAL_NODE ;
120159
121160 config = meshtastic_LocalConfig_init_zero;
161+ // A real device always holds a private key; without one perhapsEncode never picks PKC.
162+ config.security .private_key .size = 32 ;
163+ memset (config.security .private_key .bytes , 0xA5 , 32 );
122164 // Authorize ADMIN_NODE's key as an admin key so the PKC path accepts it and we reach the session gate.
123165 config.security .admin_key [0 ].size = 32 ;
124166 memcpy (config.security .admin_key [0 ].bytes , ADMIN_KEY , 32 );
@@ -411,38 +453,104 @@ void test_response_variant_must_match_request(void)
411453// must not relax the PKC pin of an earlier one (the old shared-slot model cleared it).
412454void test_pinned_request_keeps_its_key_after_an_unpinned_request (void )
413455{
414- uint8_t key[32 ];
415- memset (key, 0xC1 , 32 );
456+ // QUERIED has a stored key, so a request to it will be PKC-encrypted and pins that key.
457+ // STRANGER has none, so a request to it cannot be pinned.
458+ mockNodeDB->addNodeWithKey (QUERIED_NODE , QUERIED_KEY );
459+ mockNodeDB->addNodeWithKey (STRANGER_NODE , nullptr );
416460
417- // A PKC-pinned get_config request to STRANGER (pins `key`).
418461 meshtastic_AdminMessage cfg = meshtastic_AdminMessage_init_zero;
419462 cfg.which_payload_variant = meshtastic_AdminMessage_get_config_request_tag;
420- meshtastic_MeshPacket pinned = makeOutgoingRequest (STRANGER_NODE , cfg);
421- pinned.pki_encrypted = true ;
422- pinned.public_key .size = 32 ;
423- memcpy (pinned.public_key .bytes , key, 32 );
424- admin->noteOutgoingAdminRequest (pinned);
463+ admin->noteOutgoingAdminRequest (makeOutgoingRequest (QUERIED_NODE , cfg));
425464
426- // A later, unpinned get_owner request to the same node.
427465 meshtastic_AdminMessage own = meshtastic_AdminMessage_init_zero;
428466 own.which_payload_variant = meshtastic_AdminMessage_get_owner_request_tag;
429467 admin->noteOutgoingAdminRequest (makeOutgoingRequest (STRANGER_NODE , own));
430468
431469 meshtastic_AdminMessage m;
432- meshtastic_MeshPacket resp = makeModuleConfigResponse (STRANGER_NODE , m); // from set; pki off by default
470+ meshtastic_MeshPacket resp = makeModuleConfigResponse (QUERIED_NODE , m); // pki off by default
433471
434- // A plaintext get_config_response is still rejected: the config request was pinned.
435472 TEST_ASSERT_FALSE_MESSAGE (admin->responseIsSolicited (resp, meshtastic_AdminMessage_get_config_response_tag, 0 ),
436473 " an unpinned request must not relax an earlier request's key pin" );
437- // Over PKC with the pinned key it is accepted.
474+
438475 resp.pki_encrypted = true ;
439476 resp.public_key .size = 32 ;
440- memcpy (resp.public_key .bytes , key , 32 );
477+ memcpy (resp.public_key .bytes , QUERIED_KEY , 32 );
441478 TEST_ASSERT_TRUE (admin->responseIsSolicited (resp, meshtastic_AdminMessage_get_config_response_tag, 0 ));
442- // The unpinned get_owner_response is accepted in plaintext (its request carried no pin).
443- resp.pki_encrypted = false ;
444- resp.public_key .size = 0 ;
445- TEST_ASSERT_TRUE (admin->responseIsSolicited (resp, meshtastic_AdminMessage_get_owner_response_tag, 0 ));
479+ }
480+
481+ // The pin is taken from the destination's stored NodeDB key - the key perhapsEncode will encrypt
482+ // to. Reading the outgoing packet's public_key instead pinned nothing: nothing populates that
483+ // field before encryption, so every real request was unpinned and `from` alone admitted responses.
484+ void test_request_to_keyed_node_pins_the_stored_key (void )
485+ {
486+ mockNodeDB->addNodeWithKey (QUERIED_NODE , QUERIED_KEY );
487+ admin->noteOutgoingAdminRequest (makeOutgoingModuleConfigRequest (QUERIED_NODE ));
488+
489+ meshtastic_AdminMessage m;
490+ meshtastic_MeshPacket plain = makeModuleConfigResponse (QUERIED_NODE , m);
491+ TEST_ASSERT_FALSE_MESSAGE (admin->responseIsSolicited (plain, MODULE_CONFIG_RESPONSE , REMOTE_HW_TAG ),
492+ " a plaintext response must not answer a PKC-pinned request" );
493+
494+ meshtastic_MeshPacket wrongKey = makeModuleConfigResponse (QUERIED_NODE , m);
495+ wrongKey.pki_encrypted = true ;
496+ wrongKey.public_key .size = 32 ;
497+ memset (wrongKey.public_key .bytes , 0x77 , 32 );
498+ TEST_ASSERT_FALSE_MESSAGE (admin->responseIsSolicited (wrongKey, MODULE_CONFIG_RESPONSE , REMOTE_HW_TAG ),
499+ " a response under a different key must not be accepted" );
500+
501+ meshtastic_MeshPacket good = makeModuleConfigResponse (QUERIED_NODE , m);
502+ good.pki_encrypted = true ;
503+ good.public_key .size = 32 ;
504+ memcpy (good.public_key .bytes , QUERIED_KEY , 32 );
505+ TEST_ASSERT_TRUE_MESSAGE (admin->responseIsSolicited (good, MODULE_CONFIG_RESPONSE , REMOTE_HW_TAG ),
506+ " the pinned key must still admit the genuine response" );
507+ }
508+
509+ // Ham mode never uses PKC, so pinning a key there would reject the legitimate plaintext response.
510+ void test_ham_mode_request_is_not_pinned (void )
511+ {
512+ owner.is_licensed = true ;
513+ mockNodeDB->addNodeWithKey (QUERIED_NODE , QUERIED_KEY );
514+ admin->noteOutgoingAdminRequest (makeOutgoingModuleConfigRequest (QUERIED_NODE ));
515+
516+ meshtastic_AdminMessage m;
517+ meshtastic_MeshPacket mp = makeModuleConfigResponse (QUERIED_NODE , m);
518+ TEST_ASSERT_TRUE_MESSAGE (admin->responseIsSolicited (mp, MODULE_CONFIG_RESPONSE , REMOTE_HW_TAG ),
519+ " a request that could not have gone out over PKC must not be pinned" );
520+ }
521+
522+ // The response must echo our request's packet id, so an injector cannot answer a request it did
523+ // not see just by naming the right node and variant.
524+ void test_response_with_wrong_request_id_is_rejected (void )
525+ {
526+ admin->noteOutgoingAdminRequest (makeOutgoingModuleConfigRequest (STRANGER_NODE ));
527+
528+ meshtastic_AdminMessage m;
529+ meshtastic_MeshPacket mp = makeModuleConfigResponse (STRANGER_NODE , m);
530+ mp.decoded .request_id = REQUEST_ID ^ 0xFFFF ; // answers some other request
531+
532+ TEST_ASSERT_FALSE_MESSAGE (admin->responseIsSolicited (mp, MODULE_CONFIG_RESPONSE , REMOTE_HW_TAG ),
533+ " a response that does not echo our request id must be rejected" );
534+
535+ mp.decoded .request_id = REQUEST_ID ;
536+ TEST_ASSERT_TRUE_MESSAGE (admin->responseIsSolicited (mp, MODULE_CONFIG_RESPONSE , REMOTE_HW_TAG ),
537+ " the matching request id must still be accepted" );
538+ }
539+
540+ // A request we could not bind to an id must not be answerable by a response that simply omits
541+ // request_id, which decodes to 0.
542+ void test_request_without_an_id_admits_nothing (void )
543+ {
544+ meshtastic_MeshPacket req = makeOutgoingModuleConfigRequest (STRANGER_NODE );
545+ req.id = 0 ;
546+ admin->noteOutgoingAdminRequest (req);
547+
548+ meshtastic_AdminMessage m;
549+ meshtastic_MeshPacket mp = makeModuleConfigResponse (STRANGER_NODE , m);
550+ mp.decoded .request_id = 0 ;
551+
552+ TEST_ASSERT_FALSE_MESSAGE (admin->responseIsSolicited (mp, MODULE_CONFIG_RESPONSE , REMOTE_HW_TAG ),
553+ " a zero request id must not act as a matching token" );
446554}
447555
448556// A remote_hardware response must answer a request for that exact subtype, not just any module
@@ -542,6 +650,10 @@ void setup()
542650 RUN_TEST (test_request_to_one_node_does_not_admit_another);
543651 RUN_TEST (test_response_variant_must_match_request);
544652 RUN_TEST (test_pinned_request_keeps_its_key_after_an_unpinned_request);
653+ RUN_TEST (test_request_to_keyed_node_pins_the_stored_key);
654+ RUN_TEST (test_ham_mode_request_is_not_pinned);
655+ RUN_TEST (test_response_with_wrong_request_id_is_rejected);
656+ RUN_TEST (test_request_without_an_id_admits_nothing);
545657 RUN_TEST (test_module_config_subtype_must_match);
546658 RUN_TEST (test_response_is_consumed_no_replay);
547659 RUN_TEST (test_outgoing_setter_does_not_admit_responses);
0 commit comments