Skip to content

Commit d587f08

Browse files
authored
Pin admin responses to the stored peer key and request id (#11092)
* Pin admin responses to the stored peer key and request id noteOutgoingAdminRequest derived its PKC pin from p.public_key, which nothing populates on the outgoing path, so keyValid was false for every client request and the pin never engaged. The accepted-response predicate reduced to an unauthenticated from plus variant and subtype. Resolve the destination key from NodeDB the way perhapsEncode does, and pin it only when the request would actually be PKC-encrypted. Extract that condition from perhapsEncode as wouldEncryptWithPKC so both use one predicate. Also record the request's packet id and require the response to echo it. * Treat a zero request id as no pairing token
1 parent 1872e5b commit d587f08

5 files changed

Lines changed: 190 additions & 44 deletions

File tree

src/mesh/Router.cpp

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,34 @@ static bool signedDataFits(meshtastic_Data *d)
822822
}
823823
#endif
824824

825+
#if !(MESHTASTIC_EXCLUDE_PKI)
826+
bool wouldEncryptWithPKC(const meshtastic_MeshPacket *p, ChannelIndex chIndex, bool haveDestKey)
827+
{
828+
// First, only PKC encrypt packets we are originating
829+
return isFromUs(p) &&
830+
#if ARCH_PORTDUINO
831+
// Sim radio via the cli flag skips PKC
832+
!portduino_config.force_simradio &&
833+
#endif
834+
// Don't use PKC with Ham mode
835+
!owner.is_licensed &&
836+
// Don't use PKC on 'serial' or 'gpio' channels unless explicitly requested
837+
!(p->pki_encrypted != true && (strcasecmp(channels.getName(chIndex), Channels::serialChannel) == 0 ||
838+
strcasecmp(channels.getName(chIndex), Channels::gpioChannel) == 0)) &&
839+
// Check for valid keys and single node destination
840+
config.security.private_key.size == 32 && !isBroadcast(p->to) &&
841+
// Some portnums either make no sense to send with PKC
842+
p->decoded.portnum != meshtastic_PortNum_TRACEROUTE_APP && p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP &&
843+
p->decoded.portnum != meshtastic_PortNum_ROUTING_APP && p->decoded.portnum != meshtastic_PortNum_POSITION_APP &&
844+
// We allow Key Verification messages to be sent without a known destination key, since the point of those messages is
845+
// to exchange keys. The first exchange (no usable key yet) falls through to channel encryption; the follow-on packet
846+
// uses the pending key resolved into haveDestKey/destKey above.
847+
// Though possible the first packet each direction should go non-pkc
848+
// to handle the case where the remote node has our key, but we don't have theirs.
849+
!(p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP && !haveDestKey);
850+
}
851+
#endif
852+
825853
/** Return 0 for success or a Routing_Error code for failure
826854
*/
827855
meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
@@ -915,28 +943,7 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
915943
}
916944
// We may want to retool things so we can send a PKC packet when the client specifies a key and nodenum, even if the node
917945
// is not in the local nodedb
918-
// First, only PKC encrypt packets we are originating
919-
if (isFromUs(p) &&
920-
#if ARCH_PORTDUINO
921-
// Sim radio via the cli flag skips PKC
922-
!portduino_config.force_simradio &&
923-
#endif
924-
// Don't use PKC with Ham mode
925-
!owner.is_licensed &&
926-
// Don't use PKC on 'serial' or 'gpio' channels unless explicitly requested
927-
!(p->pki_encrypted != true && (strcasecmp(channels.getName(chIndex), Channels::serialChannel) == 0 ||
928-
strcasecmp(channels.getName(chIndex), Channels::gpioChannel) == 0)) &&
929-
// Check for valid keys and single node destination
930-
config.security.private_key.size == 32 && !isBroadcast(p->to) &&
931-
// Some portnums either make no sense to send with PKC
932-
p->decoded.portnum != meshtastic_PortNum_TRACEROUTE_APP && p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP &&
933-
p->decoded.portnum != meshtastic_PortNum_ROUTING_APP && p->decoded.portnum != meshtastic_PortNum_POSITION_APP &&
934-
// We allow Key Verification messages to be sent without a known destination key, since the point of those messages is
935-
// to exchange keys. The first exchange (no usable key yet) falls through to channel encryption; the follow-on packet
936-
// uses the pending key resolved into haveDestKey/destKey above.
937-
// Though possible the first packet each direction should go non-pkc
938-
// to handle the case where the remote node has our key, but we don't have theirs.
939-
!(p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP && !haveDestKey)) {
946+
if (wouldEncryptWithPKC(p, chIndex, haveDestKey)) {
940947
LOG_DEBUG("Use PKI!");
941948
if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN)
942949
return meshtastic_Routing_Error_TOO_LARGE;

src/mesh/Router.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,18 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p);
193193
bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p);
194194
#endif
195195

196+
#if !(MESHTASTIC_EXCLUDE_PKI)
197+
/**
198+
* Would perhapsEncode() PKC-encrypt this outgoing packet? Callers that must know the encryption a
199+
* packet will get before it is encoded (e.g. pinning a peer key at request time) have to ask this
200+
* rather than inspect p, whose pki_encrypted/public_key fields are only populated on the RX path.
201+
*
202+
* @param chIndex the channel index p carries before encoding rewrites it to a hash.
203+
* @param haveDestKey whether a public key for p->to was resolvable.
204+
*/
205+
bool wouldEncryptWithPKC(const meshtastic_MeshPacket *p, ChannelIndex chIndex, bool haveDestKey);
206+
#endif
207+
196208
extern Router *router;
197209

198210
/// Generate a unique packet id

src/modules/AdminModule.cpp

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1975,7 +1975,15 @@ void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p)
19751975
if (!responseVariant)
19761976
return; // not a getter whose response we can pair
19771977

1978-
const bool keyValid = p.pki_encrypted && p.public_key.size == 32;
1978+
// Pin the key perhapsEncode will actually encrypt to, resolved the same way it resolves it.
1979+
// p.public_key is NOT it: nothing populates that field on the outgoing path (only perhapsDecode
1980+
// sets it, on RX), so reading it here pinned nothing and left `from` as the sole check.
1981+
bool keyValid = false;
1982+
meshtastic_NodeInfoLite_public_key_t destKey = {0, {0}};
1983+
#if !(MESHTASTIC_EXCLUDE_PKI)
1984+
const bool haveDestKey = nodeDB->copyPublicKey(p.to, destKey);
1985+
keyValid = haveDestKey && wouldEncryptWithPKC(&p, p.channel, haveDestKey);
1986+
#endif
19791987

19801988
// One entry per request (a client sends N indexed get_channel requests, each answered once, so
19811989
// entries must not merge). Free slot, else evict the oldest by rollover-safe elapsed time.
@@ -1994,14 +2002,15 @@ void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p)
19942002
}
19952003

19962004
slot->to = p.to;
2005+
slot->requestId = p.id;
19972006
slot->sentAtMs = millis();
19982007
slot->expectedResponse = responseVariant;
19992008
slot->moduleConfigType = admin.which_payload_variant == meshtastic_AdminMessage_get_module_config_request_tag
20002009
? (uint8_t)admin.get_module_config_request
20012010
: 0;
20022011
slot->keyValid = keyValid;
20032012
if (keyValid)
2004-
memcpy(slot->key, p.public_key.bytes, 32);
2013+
memcpy(slot->key, destKey.bytes, 32);
20052014
else
20062015
memset(slot->key, 0, 32);
20072016
LOG_DEBUG("Admin request sent to 0x%08x, expecting its response", p.to);
@@ -2014,6 +2023,11 @@ bool AdminModule::responseIsSolicited(const meshtastic_MeshPacket &mp, pb_size_t
20142023
for (auto &o : outstandingAdminRequests) {
20152024
if (o.to != mp.from || o.expectedResponse != responseVariant)
20162025
continue;
2026+
// mp.from is unauthenticated, so also require the response to echo our request's packet id
2027+
// (setReplyTo puts it in decoded.request_id). A blind injector must now guess it. Id 0 is
2028+
// no token at all - an omitted request_id decodes to 0 - so such a slot never matches.
2029+
if (o.requestId == 0 || mp.decoded.request_id != o.requestId)
2030+
continue;
20172031
if (!Throttle::isWithinTimespanMs(o.sentAtMs, kOutstandingAdminRequestMs)) {
20182032
o.to = 0; // lapsed; free the slot and keep looking for another live match
20192033
continue;

src/modules/AdminModule.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,11 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
9090
static constexpr uint32_t kOutstandingAdminRequestMs = 300 * 1000; // same window as the session passkey
9191
struct OutstandingAdminRequest {
9292
NodeNum to; // 0 = free slot
93+
uint32_t requestId; // our request's packet id; the response must echo it as request_id
9394
uint32_t sentAtMs; // millis() when this request went out
9495
pb_size_t expectedResponse; // the one response variant this request authorizes
9596
uint8_t moduleConfigType; // for get_module_config_request: which ModuleConfigType we asked
96-
uint8_t key[32]; // pinned destination key when the request went out over PKC
97+
uint8_t key[32]; // pinned destination key when the request goes out over PKC
9798
bool keyValid;
9899
};
99100
OutstandingAdminRequest outstandingAdminRequests[kOutstandingAdminRequests] = {};

test/test_admin_session_repro/test_main.cpp

Lines changed: 131 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
3266
static MockMeshService *mockService = nullptr;
3367
static 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.
3671
static 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).
412454
void 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

Comments
 (0)