mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-13 06:41:45 -04:00
* test: make every suite run its own binary, and fail the run when it does not PlatformIO links every native test program to the one $BUILD_DIR/$PROGNAME path and attributes Unity output by text alone, never checking that the source file a case came from belongs to the suite it thinks it ran. Both harnesses had been split into a build pass (--without-testing) and a run pass (--without-building), and for a non-embedded platform the run pass never relinks - so all 57 suites executed whichever suite was linked last, each reporting PASSED under its own name. Introduced for CI in4906f8a6and for bin/run-tests.sh in de6b2319; both ran fused, and correctly, before that. Drop --without-building from both run passes. The --without-testing pass stays as a warm-up so no single suite absorbs the whole src compile in its reported duration; with the objects already cached the per-suite step is one test_main.cpp plus a link. Add bin/check-test-attribution.py, which grades the JUnit reports both harnesses already produce. It fails on a test case whose source file lies outside the suite that reported it, and on a suite that was asked to run and produced no cases at all. Wired in three places: bin/run-tests.sh as a RED verdict ahead of the softer ones, per area in CI so a mismatch names its area, and once over the merged report so an area that never executed cannot hide. Suite ownership is matched on whole path segments, so test_mesh does not claim test_mesh_module, and the -f pattern is resolved against the canonical set rather than taken as a literal suite name. * fix(test): pin simradio off for the packet-signing PKI cases [env:coverage] passes -s to the test binary (74e6723ad, #8251), which sets portduino_config.force_simradio. wouldEncryptWithPKC() lists !force_simradio among its preconditions, so perhapsEncode() takes the channel-crypto branch, returns NONE and leaves pki_encrypted false - failing test_B11_normal_unicast_still_uses_pki and test_B12_licensed_receiver_does_not_decrypt_pki, both of which assert the production PKI path. [env:native] passes no such flag, which is the whole of the long-standing "passes under native, fails under coverage" split; it was never gcov, ASan or a host. Save and clear the flag in setUp, restore it in tearDown, so the suite asserts the encode path it is named for under either env's invocation. Same binary, pristine $HOME: 77 tests 0 failures with -s and without, where before -s gave 2 failures. Whether the unit-test binary should run with -s at all is a separate question - it means CI exercises the simradio configuration for every suite - and is left alone here. * fix(router): drive the admin-key fallback budget from the injectable clock The budget is 8 tokens refilling one per 250ms of wall clock, and test_admin_key_fallback_is_rate_limited drains it with eight PKI decodes before asserting the ninth is refused. That gives the drain loop 31ms per iteration, each of which generates a keypair and does three X25519 operations under gcov and ASan. This box runs them in ~4ms; a GitHub runner takes ~38ms, so a token refills mid-drain and the packet the test expects to be blocked decodes. Measured from both runs' own log timestamps, 9.5x apart. Read the bucket through Time::getMillis() instead of millis(), and have the test set and advance the virtual clock rather than sleeping. The subtraction was already wrap-correct, so the deadline guard is unaffected. Restores the clock in tearDown so the rest of the suite is untouched, and drops ~3s of real sleeping from the run. * test: declare the event-channel suites' shared state Both construct a NodeDB, whose constructor persists a default set into an empty prefs directory, so each writes the five prefs protos. Neither was declared, because until suites started running their own binaries nothing had ever observed them writing anything. * test: add a repeat runner for order-independent flakes A single green run says nothing about a real-time race or a slow-host margin: the rate-limit budget above passes here with 7x headroom and still fails on a CI runner. Run one suite N times against a fresh scratch $HOME each time, optionally against CPU contention, and print a flake rate. Failing runs keep their log and their sandbox; passing runs leave nothing. Simradio is taken from the env's own test_testing_command, so a stress run reproduces the real invocation rather than inventing a third one. * fix(test): keep a native test run off the host's radio bin/pio-test-isolate.sh sandboxes $HOME, but portduinoSetup() looks for config in ./config.yaml and /etc/meshtasticd/config.yaml - the second absolute, so no $HOME sandbox can hide it. On a machine running meshtasticd that config selects the real LoRa module and the run continues into GPIO and SPI setup, so ./bin/run-tests.sh -e native would drive the developer's own radio without saying so. -e native is also the faster of the two, and the one reached for when iterating. [env:coverage] already passes -s, which short-circuits ahead of the config search and returns before hardware init. Pass it for [env:native] too. That closes the hazard and, incidentally, makes the two envs invoke the binary identically - they did not, which is the whole of the long-standing "green locally, red in CI" split. * test: run every suite with PKC on, and assert it stays that way force_simradio does two unrelated jobs. It keeps portduinoSetup() off the host's hardware, which every test run wants, and it makes wouldEncryptWithPKC() return false, which no test run wants: the encode path under test then falls back to channel crypto and any case asserting PKI fails, or worse, passes while asserting the wrong thing. Three suites had each worked this out separately and cleared the flag themselves - test_admin_session_repro's comment describes the mechanism exactly. Clear it once in initializeTestEnvironment() instead. By then portduinoSetup() has already skipped the config search and chosen the simulated radio, and it never reconsults the flag, so clearing it cannot bring hardware back; the only remaining readers are the PKC gate and an exit_simulator intercept no test can reach. The per-suite copy added to test_packet_signing for B11/B12 goes away with it. Two asserts, because both invariants were true only by inspection: - No listening sockets. main.cpp's setup()/loop() are compiled out under PIO_UNIT_TESTING, so the phone API, MQTT and the web server never start - but nothing checked. A suite that pulled in a service binding a port would open one on the developer's machine for the length of the run. - force_simradio still clear, before every test rather than once per suite, since a case that restores a struct it snapshotted earlier puts it back and silently disables PKC for everything after it. Named per test, so the report points at the case after the culprit. Both exit rather than TEST_FAIL: they run outside a Unity test frame, and silently repairing either one would leave the suite that broke it passing. Verified by disabling the clear and watching the guard fire on the first case instead of reporting two quiet failures. * test: let the repeat runner vary suite order too Repeating one binary finds races and slow-host margins; it cannot find state that leaks from one suite into the next, because only one suite runs. --shuffle drives run-tests.sh --seed with a fresh seed each iteration and reports which seeds went red, so the shuffle already in the harness yields a flake rate rather than a single sample. Seeds are printed and replayable. * fix(test): baseline the environment from whichever runs first Clearing force_simradio in initializeTestEnvironment() missed the suites that never call it. test_atak is one, and it also pulls in TestUtil.h, so it got the per-test assert without ever getting the baseline and aborted on its first case - caught by CI, which is what the assert is for. test_geocoord_distance, test_meshpacket_serializer and test_utf8 skip the init too, but include no TestUtil.h at all, so nothing reached them either way. Move the clear and the socket check into baselineEnvironment(), called from initializeTestEnvironment() or from the first RUN_TEST, whichever comes first. Suites that initialise are still asserted from their first case; the rest are baselined at case one and asserted from case two. Print the violation on stdout as well as stderr: bin/run-tests.sh filters the program's stderr, so locally the message vanished and the run reported "exit-time abort (likely sanitizer)" - the exit code read as a signal number again, with no sign of the real reason. * test: drop the per-suite simradio exceptions Three suites had each found that force_simradio disables PKC and cleared it themselves. initializeTestEnvironment() now clears it once for every suite, so all six sites are dead code - along with the PortduinoGlue.h include each pulled in for it. test_event_channel_router's is the one worth removing rather than leaving: it snapshotted the flag into SavedGlobals and restored it at teardown, which is exactly the shape the per-test assert exists to catch. Harmless while the snapshot reads false, and a silent PKC-off for every later case if that ever changed. The three suites pass unchanged: 54 cases, attribution clean. * test: tell a deliberate harness abort from a sanitizer fault A guard in TestUtil.cpp that aborts on purpose - a listening socket, or force_simradio put back - exits non-zero with no sanitizer report, so it fell through to the exit-time-abort heuristic and was announced as "RED exit-time abort (tests passed; likely sanitizer)". That is the same trap as the phantom SIGILL two checks above: a verdict line naming a cause it has not established, sending the reader after a memory bug that does not exist. It cost hours in the original investigation and it cost the first read of a test_atak failure today. Match the FATAL line the guards print on stdout for exactly this purpose, and report the reason they gave instead of guessing. * test: say why three suites omit TestUtil.h They are pure-function - no NodeDB, no router, no sockets, no PKC - so the harness-wide guards in TestUtil.h would assert conditions they cannot reach, and initializeTestEnvironment()'s RTC and OSThread setup would pull in portduino globals they otherwise never touch. Suite-level state cleanliness still applies: bin/pio-test-isolate.sh fingerprints the sandbox from outside and wraps every suite regardless. Recorded at the top of each so the omission reads as a decision rather than an oversight - it looked like the latter when the socket and simradio asserts landed. * test(traffic): give every case a primary channel resetTrafficConfig() zeroed channelFile and left channels_count at 0, so the 66 cases that do not install a channel themselves ran against a device with none. Every router lookup then hit Channels::getByIndex()'s out-of-range branch and logged, which is 12106 of the suite's 20088 ERROR lines and tests nothing - a real device always has a primary channel, and no case here asserts channels-unset behaviour. Install the well-known primary the suite already builds for its precision cases. All 85 pass unchanged, and the suite's ERROR output drops to 7985, the remainder being decode failures from test_tm_fuzz_nodenum_blitz's malformed payloads. * test: budget each suite's LOG_ERROR output A suite can pass while emitting six figures of ERROR, which buries a real failure and trains everyone to skim. Count them per suite and grade the count as a second axis, alongside the CLEAN/DIRTY verdict already computed from the same captured log. Declared in the same manifest, as a RANGE rather than a ceiling, because for a fuzz suite the floor is the half that matters: test_fuzz_decode logging ~100k rejections is the suite working, and the same suite logging none means it stopped feeding malformed input while every case still passes. Bounds are wide on purpose - they catch a path that has stopped running, not a drift of a few hundred lines. Undeclared suites get 100, which 50 of 57 already meet. AMBER, not RED. Three log sites - mesh-pb-constants.cpp:28, Channels.cpp:356, MQTT.cpp:92 - account for nearly all the remaining volume, and landing this red before they are demoted would buy exemptions rather than fixes. * test: canary the attribution check, and run the state self-test in CI check-test-attribution.py guards against the false green, and nothing guarded the guard. A checker that has quietly stopped matching looks exactly like a codebase with no problem, which is how the original went unnoticed for three weeks of green runs. The canary reproduces the failure deliberately - two suites run with --without-building, so PlatformIO does not relink and both execute the same leftover binary - and requires the checker to catch it. It also fails if the reproduction stops reproducing: if PlatformIO ever relinks per suite under that flag, the reason both harnesses stopped passing it no longer holds, and the harness should be revisited rather than left on a stale assumption. bin/test-state-check.sh already existed with fixtures asserting CLEAN/CLEAN/DIRTY/MISSING and had never run in CI. Wire it in too - the shared-state checker had the same blind spot, and somebody had already written the test for it. * fix(ci): run the attribution canary where it cannot clobber the daemon The canary relinks $BUILD_DIR/$PROGNAME, and in simulator-tests that replaced the daemon binary with a test suite. The integration test then started it and waited for a listening socket, which a test binary never opens - by assertion, since initializeTestEnvironment() now fails a suite that holds one - so the step sat until its 20s timeout and the job exited 124. The canary itself had already passed. Move it to platformio-tests, where the binary is per-suite already and nothing downstream needs the daemon, and place it after the coverage capture so its extra runs stay out of the numbers. The shared-state self-test stays in simulator-tests; it touches no binary. Fitting failure mode for this branch: one shared program path, two consumers, and the second one silently getting the first one's build. * fix(ci): silence the XXE rule on the attribution checker semgrep blocks xml.etree.ElementTree.parse as XXE-prone. The input here is the JUnit report PlatformIO wrote moments earlier in the same run, and anything able to plant a hostile report is already executing its own code in that job, so parsing it defused changes nothing it could do. defusedxml is in the tree but only under bin/bump_metainfo with its own requirements, and pulling it onto this path would add an install step to every native test job for no reachable threat. Suppressed with a reason at the call site, the same shape as the subprocess-shell-true suppression in extra_scripts/nrf54l15_linker.py. * fix(test): address the review findings on the harness guards Two were real defects rather than style: - state_count_errors() returned "0\n0" for a log with no ERROR lines, because grep -c prints 0 and *then* exits 1, so the `|| printf 0` fallback appended a second one. The classifier threw a syntax error on it. Dormant only because every suite currently emits at least one ERROR line; the planned log-level demotions would have driven most suites to zero and tripped it everywhere, looking like the demotions broke the harness. - check-test-attribution.py returned OK for a report whose cases carry no `file` attribute. It cannot prove ownership in that state, so a changed JUnit format would have restored the exact false green it exists to catch. Now its own finding, listed and fatal. The rest: keep the sandbox when an error budget is breached, since that is the one outcome whose evidence was being deleted; reject a missing or non-numeric option value in stress-suite.sh instead of running an empty loop and reporting 0/0 as a pass; exit on INT/TERM rather than cleaning up and carrying on; drive repetitions through pio-test-isolate.sh so a stress run exercises the real invocation; require the canary to see MISATTRIBUTED rather than any non-zero exit, so an unreadable report cannot read as a caught mismatch; and check for listening sockets before every test, since a listener would be opened by the code under test. resetAdminKeyFallbackBudget() is a new PIO_UNIT_TESTING hook, shaped like the neighbouring resetRoutingAuthEvaluationCount(). The refill stamp is only meaningful against the clock that produced it, so a suite switching timebases leaves a stamp from the other one and the next unsigned subtraction reads as a near-infinite gap - silently refilling the bucket. Also move the semgrep marker onto its own line: buried mid-sentence in a comment it was ignored, and the XXE finding stayed blocking.
693 lines
32 KiB
C++
693 lines
32 KiB
C++
// 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, 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.
|
|
// A local (from == 0) admin bypasses that gate, so the bug only reproduces from != 0.
|
|
|
|
#include "MeshTypes.h" // include BEFORE TestUtil.h
|
|
#include "TestUtil.h"
|
|
#include <unity.h>
|
|
|
|
#if !(MESHTASTIC_EXCLUDE_PKI)
|
|
|
|
#include "mesh/Channels.h"
|
|
#include "mesh/NodeDB.h"
|
|
#include "mesh/mesh-pb-constants.h"
|
|
#include "modules/AdminModule.h"
|
|
#include "support/AdminModuleTestShim.h"
|
|
#include "support/MockMeshService.h"
|
|
#include <cstring>
|
|
|
|
static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A;
|
|
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};
|
|
|
|
// MeshService assigns every outgoing packet an id before noteOutgoingAdminRequest sees it, and
|
|
// setReplyTo echoes it back as decoded.request_id, so the pairing is keyed on it.
|
|
static constexpr uint32_t REQUEST_ID = 0x5EED0001;
|
|
static const uint8_t QUERIED_KEY[32] = {0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCa, 0xCb,
|
|
0xCc, 0xCd, 0xCe, 0xCf, 0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6,
|
|
0xD7, 0xD8, 0xD9, 0xDa, 0xDb, 0xDc, 0xDd, 0xDe, 0xDf, 0xE0};
|
|
|
|
// NodeDB with injectable nodes, so a destination can have a stored public key to pin.
|
|
class MockNodeDB : public NodeDB
|
|
{
|
|
public:
|
|
void clearTestNodes()
|
|
{
|
|
testNodes.clear();
|
|
meshNodes = &testNodes;
|
|
numMeshNodes = 0;
|
|
}
|
|
|
|
void addNodeWithKey(NodeNum num, const uint8_t *key)
|
|
{
|
|
meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero;
|
|
n.num = num;
|
|
if (key) {
|
|
n.public_key.size = 32;
|
|
memcpy(n.public_key.bytes, key, 32);
|
|
}
|
|
testNodes.push_back(n);
|
|
meshNodes = &testNodes;
|
|
numMeshNodes = testNodes.size();
|
|
}
|
|
|
|
std::vector<meshtastic_NodeInfoLite> testNodes;
|
|
};
|
|
|
|
static MockMeshService *mockService = nullptr;
|
|
static AdminModuleTestShim *admin = nullptr;
|
|
static MockNodeDB *mockNodeDB = nullptr;
|
|
|
|
// A remote, PKC-authorized set_owner. `session` (if non-empty) is the session_passkey the client presents.
|
|
static meshtastic_MeshPacket makeRemoteSetOwner(const char *newLongName, const uint8_t *session, size_t sessionLen,
|
|
meshtastic_AdminMessage &out)
|
|
{
|
|
out = meshtastic_AdminMessage_init_zero;
|
|
out.which_payload_variant = meshtastic_AdminMessage_set_owner_tag;
|
|
strncpy(out.set_owner.long_name, newLongName, sizeof(out.set_owner.long_name) - 1);
|
|
if (session) {
|
|
out.session_passkey.size = sessionLen;
|
|
memcpy(out.session_passkey.bytes, session, sessionLen);
|
|
}
|
|
|
|
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
|
|
mp.from = ADMIN_NODE; // REMOTE: this is what makes the session gate apply
|
|
mp.channel = 0;
|
|
mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
|
mp.pki_encrypted = true; // arrived over PKC
|
|
mp.public_key.size = 32;
|
|
memcpy(mp.public_key.bytes, ADMIN_KEY, 32); // matches config.security.admin_key[0] -> authorized
|
|
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;
|
|
mp.decoded.request_id = REQUEST_ID; // setReplyTo echoes the request's packet id
|
|
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.id = REQUEST_ID;
|
|
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();
|
|
service = mockService;
|
|
admin = new AdminModuleTestShim();
|
|
admin->deferSaves(); // no disk/reboot side effects when a setter is accepted
|
|
|
|
if (!mockNodeDB)
|
|
mockNodeDB = new MockNodeDB();
|
|
mockNodeDB->clearTestNodes();
|
|
nodeDB = mockNodeDB;
|
|
myNodeInfo.my_node_num = LOCAL_NODE;
|
|
|
|
config = meshtastic_LocalConfig_init_zero;
|
|
// A real device always holds a private key; without one perhapsEncode never picks PKC.
|
|
config.security.private_key.size = 32;
|
|
memset(config.security.private_key.bytes, 0xA5, 32);
|
|
// Authorize ADMIN_NODE's key as an admin key so the PKC path accepts it and we reach the session gate.
|
|
config.security.admin_key[0].size = 32;
|
|
memcpy(config.security.admin_key[0].bytes, ADMIN_KEY, 32);
|
|
|
|
owner = meshtastic_User_init_zero;
|
|
strncpy(owner.long_name, "Original", sizeof(owner.long_name) - 1);
|
|
|
|
channels.initDefaults();
|
|
channels.onConfigChanged();
|
|
}
|
|
|
|
void tearDown(void)
|
|
{
|
|
service = nullptr;
|
|
delete mockService;
|
|
mockService = nullptr;
|
|
delete admin;
|
|
admin = nullptr;
|
|
}
|
|
|
|
// ndoo's report: a setter from a remote node with NO valid session is rejected, and the node's
|
|
// expected session key is all-zero because it has minted none since boot.
|
|
void test_remote_setter_without_session_is_rejected(void)
|
|
{
|
|
meshtastic_AdminMessage m;
|
|
meshtastic_MeshPacket mp = makeRemoteSetOwner("Hijacked", nullptr, 0, m);
|
|
|
|
admin->handleReceivedProtobuf(mp, &m);
|
|
admin->drainReply();
|
|
|
|
// Rejected at the session gate -> owner unchanged (this is ndoo's "Admin message without session_key!").
|
|
TEST_ASSERT_EQUAL_STRING("Original", owner.long_name);
|
|
}
|
|
|
|
// The node's session key is minted only by setPassKey (which runs when it answers an admin GET),
|
|
// so before any GET the expected key is all-zero and any presented key mismatches.
|
|
void test_expected_session_key_is_zero_before_any_get(void)
|
|
{
|
|
uint8_t zero[8] = {0};
|
|
meshtastic_AdminMessage probe = meshtastic_AdminMessage_init_zero;
|
|
probe.session_passkey.size = 8;
|
|
memcpy(probe.session_passkey.bytes, zero, 8); // even all-zeros must not authorize a state change
|
|
// A fresh module has minted no session; a stale/guessed key does not match.
|
|
// (checkPassKey also requires size==8 AND session_time freshness.)
|
|
uint8_t stale[8] = {0x29, 0x04, 0xb4, 0x78, 0xd8, 0x68, 0xa7, 0xff}; // ndoo's presented key
|
|
meshtastic_AdminMessage staleMsg = meshtastic_AdminMessage_init_zero;
|
|
staleMsg.session_passkey.size = 8;
|
|
memcpy(staleMsg.session_passkey.bytes, stale, 8);
|
|
TEST_ASSERT_FALSE(admin->checkPassKey(&staleMsg)); // Expected: 00..00 vs Incoming: 29 04 b4 78.. -> reject
|
|
}
|
|
|
|
// The fix path: once the node answers a GET (setPassKey mints/returns the key), the session gate
|
|
// accepts a setter carrying that key. Asserting the gate (checkPassKey) directly is the mechanism;
|
|
// driving the full handleSetOwner would need the NodeInfoModule scaffolding, out of scope here.
|
|
void test_session_gate_accepts_key_from_a_get_response(void)
|
|
{
|
|
// Simulate the node answering an admin GET: setPassKey mints the session and writes it into the response.
|
|
meshtastic_AdminMessage getResponse = meshtastic_AdminMessage_init_zero;
|
|
admin->setPassKey(&getResponse);
|
|
TEST_ASSERT_EQUAL(8, getResponse.session_passkey.size); // node handed the client a session key
|
|
|
|
// A setter carrying that exact key passes the gate (would be accepted).
|
|
meshtastic_AdminMessage good = meshtastic_AdminMessage_init_zero;
|
|
good.session_passkey = getResponse.session_passkey;
|
|
TEST_ASSERT_TRUE(admin->checkPassKey(&good));
|
|
|
|
// A setter carrying a stale/guessed key still fails (no session replay).
|
|
meshtastic_AdminMessage bad = meshtastic_AdminMessage_init_zero;
|
|
bad.session_passkey.size = 8;
|
|
uint8_t stale[8] = {0x29, 0x04, 0xb4, 0x78, 0xd8, 0x68, 0xa7, 0xff};
|
|
memcpy(bad.session_passkey.bytes, stale, 8);
|
|
TEST_ASSERT_FALSE(admin->checkPassKey(&bad));
|
|
}
|
|
|
|
// Decode the SecurityConfig out of the get_config response a handler queued in myReply.
|
|
static bool decodeSecurityFromReply(meshtastic_MeshPacket *reply, meshtastic_Config_SecurityConfig &out)
|
|
{
|
|
meshtastic_AdminMessage am = meshtastic_AdminMessage_init_zero;
|
|
if (!reply || reply->which_payload_variant != meshtastic_MeshPacket_decoded_tag)
|
|
return false;
|
|
if (!pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_AdminMessage_msg, &am))
|
|
return false;
|
|
if (am.which_payload_variant != meshtastic_AdminMessage_get_config_response_tag ||
|
|
am.get_config_response.which_payload_variant != meshtastic_Config_security_tag)
|
|
return false;
|
|
out = am.get_config_response.payload_variant.security;
|
|
return true;
|
|
}
|
|
|
|
static meshtastic_MeshPacket makeGetConfigRequest(NodeNum from)
|
|
{
|
|
meshtastic_MeshPacket req = meshtastic_MeshPacket_init_zero;
|
|
req.from = from;
|
|
req.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
|
req.decoded.want_response = true;
|
|
return req;
|
|
}
|
|
|
|
// The device identity private key must never leave over the air: a SECURITY_CONFIG response to a
|
|
// remote request (from != 0, even an authorized admin) carries an empty private_key.
|
|
void test_remote_security_config_omits_private_key(void)
|
|
{
|
|
config.security.private_key.size = 32;
|
|
memset(config.security.private_key.bytes, 0xAB, 32);
|
|
|
|
meshtastic_MeshPacket req = makeGetConfigRequest(ADMIN_NODE);
|
|
admin->handleGetConfig(req, meshtastic_AdminMessage_ConfigType_SECURITY_CONFIG);
|
|
|
|
meshtastic_Config_SecurityConfig sec;
|
|
TEST_ASSERT_TRUE(decodeSecurityFromReply(admin->reply(), sec));
|
|
TEST_ASSERT_EQUAL_MESSAGE(0, sec.private_key.size, "remote security config must not carry the private key");
|
|
admin->drainReply();
|
|
}
|
|
|
|
// Control: the local backup path (from == 0, BLE/USB/TCP) still receives the private key, so the
|
|
// redaction above is remote-specific rather than a blanket wipe.
|
|
void test_local_security_config_keeps_private_key(void)
|
|
{
|
|
config.security.private_key.size = 32;
|
|
memset(config.security.private_key.bytes, 0xAB, 32);
|
|
|
|
meshtastic_MeshPacket req = makeGetConfigRequest(0);
|
|
admin->handleGetConfig(req, meshtastic_AdminMessage_ConfigType_SECURITY_CONFIG);
|
|
|
|
meshtastic_Config_SecurityConfig sec;
|
|
TEST_ASSERT_TRUE(decodeSecurityFromReply(admin->reply(), sec));
|
|
TEST_ASSERT_EQUAL_MESSAGE(32, sec.private_key.size, "local backup must still receive the private key");
|
|
TEST_ASSERT_EACH_EQUAL_HEX8(0xAB, sec.private_key.bytes, 32);
|
|
admin->drainReply();
|
|
}
|
|
|
|
// A local client reads the effective policy after a set. Builds without packet-signature support coerce it to Compatible.
|
|
void test_local_security_config_applies_packet_signature_policy(void)
|
|
{
|
|
meshtastic_Config set = meshtastic_Config_init_zero;
|
|
set.which_payload_variant = meshtastic_Config_security_tag;
|
|
set.payload_variant.security = config.security;
|
|
set.payload_variant.security.packet_signature_policy =
|
|
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT;
|
|
admin->handleSetConfig(set, false);
|
|
|
|
meshtastic_MeshPacket req = makeGetConfigRequest(0);
|
|
admin->handleGetConfig(req, meshtastic_AdminMessage_ConfigType_SECURITY_CONFIG);
|
|
|
|
meshtastic_Config_SecurityConfig sec;
|
|
TEST_ASSERT_TRUE(decodeSecurityFromReply(admin->reply(), sec));
|
|
#if MESHTASTIC_EXCLUDE_PKI || MESHTASTIC_EXCLUDE_XEDDSA
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE,
|
|
sec.packet_signature_policy);
|
|
#else
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT,
|
|
sec.packet_signature_policy);
|
|
#endif
|
|
admin->drainReply();
|
|
}
|
|
|
|
// Decode the NetworkConfig / MqttConfig out of the response a handler queued in myReply.
|
|
static bool decodeNetworkFromReply(meshtastic_MeshPacket *reply, meshtastic_Config_NetworkConfig &out)
|
|
{
|
|
meshtastic_AdminMessage am = meshtastic_AdminMessage_init_zero;
|
|
if (!reply || reply->which_payload_variant != meshtastic_MeshPacket_decoded_tag)
|
|
return false;
|
|
if (!pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_AdminMessage_msg, &am))
|
|
return false;
|
|
if (am.which_payload_variant != meshtastic_AdminMessage_get_config_response_tag ||
|
|
am.get_config_response.which_payload_variant != meshtastic_Config_network_tag)
|
|
return false;
|
|
out = am.get_config_response.payload_variant.network;
|
|
return true;
|
|
}
|
|
|
|
static bool decodeMqttFromReply(meshtastic_MeshPacket *reply, meshtastic_ModuleConfig_MQTTConfig &out)
|
|
{
|
|
meshtastic_AdminMessage am = meshtastic_AdminMessage_init_zero;
|
|
if (!reply || reply->which_payload_variant != meshtastic_MeshPacket_decoded_tag)
|
|
return false;
|
|
if (!pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_AdminMessage_msg, &am))
|
|
return false;
|
|
if (am.which_payload_variant != meshtastic_AdminMessage_get_module_config_response_tag ||
|
|
am.get_module_config_response.which_payload_variant != meshtastic_ModuleConfig_mqtt_tag)
|
|
return false;
|
|
out = am.get_module_config_response.payload_variant.mqtt;
|
|
return true;
|
|
}
|
|
|
|
// A remote requester gets the sentinel; the set path swaps the stored value back.
|
|
void test_remote_network_config_omits_wifi_psk(void)
|
|
{
|
|
strcpy(config.network.wifi_psk, "hunter2hunter2");
|
|
|
|
meshtastic_MeshPacket req = makeGetConfigRequest(ADMIN_NODE);
|
|
admin->handleGetConfig(req, meshtastic_AdminMessage_ConfigType_NETWORK_CONFIG);
|
|
|
|
meshtastic_Config_NetworkConfig net;
|
|
TEST_ASSERT_TRUE(decodeNetworkFromReply(admin->reply(), net));
|
|
TEST_ASSERT_EQUAL_STRING_MESSAGE("sekrit", net.wifi_psk, "remote network config must not carry the real psk");
|
|
admin->drainReply();
|
|
}
|
|
|
|
// Control: the local path still receives the stored psk.
|
|
void test_local_network_config_keeps_wifi_psk(void)
|
|
{
|
|
strcpy(config.network.wifi_psk, "hunter2hunter2");
|
|
|
|
meshtastic_MeshPacket req = makeGetConfigRequest(0);
|
|
admin->handleGetConfig(req, meshtastic_AdminMessage_ConfigType_NETWORK_CONFIG);
|
|
|
|
meshtastic_Config_NetworkConfig net;
|
|
TEST_ASSERT_TRUE(decodeNetworkFromReply(admin->reply(), net));
|
|
TEST_ASSERT_EQUAL_STRING_MESSAGE("hunter2hunter2", net.wifi_psk, "local client must still receive the psk");
|
|
admin->drainReply();
|
|
}
|
|
|
|
void test_remote_mqtt_config_omits_password(void)
|
|
{
|
|
strcpy(moduleConfig.mqtt.password, "brokerpass");
|
|
|
|
meshtastic_MeshPacket req = makeGetConfigRequest(ADMIN_NODE);
|
|
admin->handleGetModuleConfig(req, meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG);
|
|
|
|
meshtastic_ModuleConfig_MQTTConfig mqtt;
|
|
TEST_ASSERT_TRUE(decodeMqttFromReply(admin->reply(), mqtt));
|
|
TEST_ASSERT_EQUAL_STRING_MESSAGE("sekrit", mqtt.password, "remote mqtt config must not carry the broker password");
|
|
admin->drainReply();
|
|
}
|
|
|
|
void test_local_mqtt_config_keeps_password(void)
|
|
{
|
|
strcpy(moduleConfig.mqtt.password, "brokerpass");
|
|
|
|
meshtastic_MeshPacket req = makeGetConfigRequest(0);
|
|
admin->handleGetModuleConfig(req, meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG);
|
|
|
|
meshtastic_ModuleConfig_MQTTConfig mqtt;
|
|
TEST_ASSERT_TRUE(decodeMqttFromReply(admin->reply(), mqtt));
|
|
TEST_ASSERT_EQUAL_STRING_MESSAGE("brokerpass", mqtt.password, "local client must still receive the password");
|
|
admin->drainReply();
|
|
}
|
|
|
|
// A client that GETs and writes the config straight back must not wipe the stored value.
|
|
void test_set_config_sentinel_psk_preserves_stored_value(void)
|
|
{
|
|
strcpy(config.network.wifi_psk, "hunter2hunter2");
|
|
|
|
meshtastic_Config c = meshtastic_Config_init_zero;
|
|
c.which_payload_variant = meshtastic_Config_network_tag;
|
|
c.payload_variant.network = config.network;
|
|
strcpy(c.payload_variant.network.wifi_psk, "sekrit");
|
|
|
|
admin->deferSaves();
|
|
admin->handleSetConfig(c, true);
|
|
|
|
TEST_ASSERT_EQUAL_STRING_MESSAGE("hunter2hunter2", config.network.wifi_psk,
|
|
"a read-modify-write round trip must not wipe the psk");
|
|
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)
|
|
{
|
|
// QUERIED has a stored key, so a request to it will be PKC-encrypted and pins that key.
|
|
// STRANGER has none, so a request to it cannot be pinned.
|
|
mockNodeDB->addNodeWithKey(QUERIED_NODE, QUERIED_KEY);
|
|
mockNodeDB->addNodeWithKey(STRANGER_NODE, nullptr);
|
|
|
|
meshtastic_AdminMessage cfg = meshtastic_AdminMessage_init_zero;
|
|
cfg.which_payload_variant = meshtastic_AdminMessage_get_config_request_tag;
|
|
admin->noteOutgoingAdminRequest(makeOutgoingRequest(QUERIED_NODE, cfg));
|
|
|
|
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(QUERIED_NODE, m); // pki off by default
|
|
|
|
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");
|
|
|
|
resp.pki_encrypted = true;
|
|
resp.public_key.size = 32;
|
|
memcpy(resp.public_key.bytes, QUERIED_KEY, 32);
|
|
TEST_ASSERT_TRUE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_config_response_tag, 0));
|
|
}
|
|
|
|
// The pin is taken from the destination's stored NodeDB key - the key perhapsEncode will encrypt
|
|
// to. Reading the outgoing packet's public_key instead pinned nothing: nothing populates that
|
|
// field before encryption, so every real request was unpinned and `from` alone admitted responses.
|
|
void test_request_to_keyed_node_pins_the_stored_key(void)
|
|
{
|
|
mockNodeDB->addNodeWithKey(QUERIED_NODE, QUERIED_KEY);
|
|
admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(QUERIED_NODE));
|
|
|
|
meshtastic_AdminMessage m;
|
|
meshtastic_MeshPacket plain = makeModuleConfigResponse(QUERIED_NODE, m);
|
|
TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(plain, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG),
|
|
"a plaintext response must not answer a PKC-pinned request");
|
|
|
|
meshtastic_MeshPacket wrongKey = makeModuleConfigResponse(QUERIED_NODE, m);
|
|
wrongKey.pki_encrypted = true;
|
|
wrongKey.public_key.size = 32;
|
|
memset(wrongKey.public_key.bytes, 0x77, 32);
|
|
TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(wrongKey, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG),
|
|
"a response under a different key must not be accepted");
|
|
|
|
meshtastic_MeshPacket good = makeModuleConfigResponse(QUERIED_NODE, m);
|
|
good.pki_encrypted = true;
|
|
good.public_key.size = 32;
|
|
memcpy(good.public_key.bytes, QUERIED_KEY, 32);
|
|
TEST_ASSERT_TRUE_MESSAGE(admin->responseIsSolicited(good, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG),
|
|
"the pinned key must still admit the genuine response");
|
|
}
|
|
|
|
// Ham mode never uses PKC, so pinning a key there would reject the legitimate plaintext response.
|
|
void test_ham_mode_request_is_not_pinned(void)
|
|
{
|
|
owner.is_licensed = true;
|
|
mockNodeDB->addNodeWithKey(QUERIED_NODE, QUERIED_KEY);
|
|
admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(QUERIED_NODE));
|
|
|
|
meshtastic_AdminMessage m;
|
|
meshtastic_MeshPacket mp = makeModuleConfigResponse(QUERIED_NODE, m);
|
|
TEST_ASSERT_TRUE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG),
|
|
"a request that could not have gone out over PKC must not be pinned");
|
|
}
|
|
|
|
// The response must echo our request's packet id, so an injector cannot answer a request it did
|
|
// not see just by naming the right node and variant.
|
|
void test_response_with_wrong_request_id_is_rejected(void)
|
|
{
|
|
admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(STRANGER_NODE));
|
|
|
|
meshtastic_AdminMessage m;
|
|
meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m);
|
|
mp.decoded.request_id = REQUEST_ID ^ 0xFFFF; // answers some other request
|
|
|
|
TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG),
|
|
"a response that does not echo our request id must be rejected");
|
|
|
|
mp.decoded.request_id = REQUEST_ID;
|
|
TEST_ASSERT_TRUE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG),
|
|
"the matching request id must still be accepted");
|
|
}
|
|
|
|
// A request we could not bind to an id must not be answerable by a response that simply omits
|
|
// request_id, which decodes to 0.
|
|
void test_request_without_an_id_admits_nothing(void)
|
|
{
|
|
meshtastic_MeshPacket req = makeOutgoingModuleConfigRequest(STRANGER_NODE);
|
|
req.id = 0;
|
|
admin->noteOutgoingAdminRequest(req);
|
|
|
|
meshtastic_AdminMessage m;
|
|
meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m);
|
|
mp.decoded.request_id = 0;
|
|
|
|
TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG),
|
|
"a zero request id must not act as a matching token");
|
|
}
|
|
|
|
// 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()
|
|
{
|
|
delay(10);
|
|
initializeTestEnvironment();
|
|
UNITY_BEGIN();
|
|
#if !(MESHTASTIC_EXCLUDE_PKI)
|
|
RUN_TEST(test_remote_setter_without_session_is_rejected);
|
|
RUN_TEST(test_expected_session_key_is_zero_before_any_get);
|
|
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_local_security_config_applies_packet_signature_policy);
|
|
RUN_TEST(test_remote_network_config_omits_wifi_psk);
|
|
RUN_TEST(test_local_network_config_keeps_wifi_psk);
|
|
RUN_TEST(test_remote_mqtt_config_omits_password);
|
|
RUN_TEST(test_local_mqtt_config_keeps_password);
|
|
RUN_TEST(test_set_config_sentinel_psk_preserves_stored_value);
|
|
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_request_to_keyed_node_pins_the_stored_key);
|
|
RUN_TEST(test_ham_mode_request_is_not_pinned);
|
|
RUN_TEST(test_response_with_wrong_request_id_is_rejected);
|
|
RUN_TEST(test_request_without_an_id_admits_nothing);
|
|
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());
|
|
}
|
|
|
|
void loop() {}
|