mirror of
https://github.com/meshtastic/firmware.git
synced 2026-11-05 05:44:21 -05:00
* Rework XEdDSA signing policy: Compatible stops signing, AES-CCM overrides Signing costs 66 bytes per packet. On a crowded mesh that matters, and packets on an AES-CCM channel were paying it on top of the CCM tag. Sending (perhapsEncode): - Compatible, the default, no longer signs. Balanced and Strict sign broadcasts as before; licensed (ham) nodes also sign unicasts, but now only under Balanced or Strict. - Packets on an AES-CCM (use_aead) channel are never signed. This also removes the case where a signed CCM packet passed signedDataFits() but then failed the CCM size check with TOO_LARGE. Receiving (checkXeddsaReceivePolicy): - Only Strict drops a packet for lacking a signature. Balanced no longer drops unsigned broadcasts from known signers; that downgrade rule and its canonical-sizing helper are removed. - An AES-CCM channel overrides Strict: its unsigned or unverifiable packets are accepted, since the CCM tag authenticates them. - A malformed or failing signature still drops the packet in every mode. Identity (new NodeDB::refusesUnsignedIdentity, used by updateUser, NodeInfoModule and TrafficManagementModule): - Balanced and Strict still refuse an unsigned NodeInfo that would change a known signer's identity, except on an AES-CCM channel. - Compatible accepts it, since firmware before 2.8 strips signatures when relaying. Channels::isAEADEnabled() now returns false for an out-of-range index instead of logging, since receive paths pass a packet's index unchecked. XEDDSA_SIGNATURE_FIELD_BYTES lost its only user and is removed. Tests: test_packet_signing drops the cases that pinned the removed downgrade sizing (A7-A9, A18-A19, E5, E10-E13, D1), flips A4 and E1 to acceptance, and adds AES-CCM, Compatible and ham cases (A18-A20, B18-B20, N12-N13). test_mqtt and test_traffic_management pin the same rules on the plaintext-MQTT and NodeInfo-cache paths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * Sign NodeInfo unicasts, forward signed requests, check signed NodeInfo keys A NodeInfo request or reply carries the key a peer will use for PKI direct messages, but off ham mode only broadcasts were signed. A Strict node therefore dropped every NodeInfo reply and could only learn a new peer's key from a signed broadcast, hours apart. - perhapsEncode: under Balanced and Strict, NodeInfo is signed even when unicast (still never on an AES-CCM channel, never in Compatible). - TrafficManagementModule: a NodeInfo request that carries a signature is forwarded instead of answered from the cache. Its sender may need a signed reply, and a reply sent on the target's behalf can never be signed. - checkXeddsaReceivePolicy: a signed NodeInfo verified against a stored key must advertise that same key, and the key must hash to the sender's node number, as first contact already requires. Otherwise it is dropped. This stops a valid signature vouching for another key, and stops a key planted on trust under the wrong node number proving itself with fresh signatures. Tests: B7 now expects NodeInfo unicasts signed; B21 pins signing per policy; A21-A23 pin the key-consistency check; a traffic-management case pins that a signed request is forwarded while the same request unsigned is answered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * Strict: accept unsigned acks and traceroutes; never sign traceroutes Strict dropped every unsigned packet that was not PKI-encrypted. Acks and traceroutes are never signed, so a Strict node saw no acks, retransmitted every reliable packet to exhaustion, and could not take part in traceroutes. - checkXeddsaReceivePolicy: Strict no longer drops an unsigned ROUTING_APP packet; an ack carries its own proof (AckProof.h). Traceroutes are accepted in every mode, and any signature on one is ignored rather than verified. - perhapsEncode: a traceroute is never signed, including in ham mode, which signs other unicasts. Every relay rewrites the route, so a signature fails verification from the second hop on, and a bad signature drops the packet in every mode. - TraceRouteModule::alterReceivedProtobuf: strip any signature when rewriting the route, so a traceroute signed by older firmware is not dropped by older receivers further along. Tests: A24-A26 (Strict accepts unsigned acks and traceroutes; a traceroute signature is ignored), B22 (traceroutes never signed), B7 updated, and test_traceroute_nexthop pins the signature strip on rewrite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * Drop the traceroute signature strip Only 2.8.0 and 2.8.1 pre-release nodes ever signed a traceroute: ham mode, which signs every unicast, and client-sent broadcast traceroutes. Stripping the signature on rewrite only helped those pre-releases interoperate, which does not justify the code. Receivers still ignore a traceroute signature, and traceroutes are still never signed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * Sign unicast positions under Balanced and Strict A position request or reply is PKI-excluded, so a signature is its only authentication, but off ham mode only broadcasts were signed. Strict dropped every position request and reply, and under Balanced any channel member could fake another node's location with an unsigned reply. perhapsEncode now signs POSITION_APP unicasts under Balanced and Strict, as it does NodeInfo. Nothing rewrites a position in flight - precision is applied by the originator before signing - so the signature survives relaying, and it binds request_id so a reply cannot be retargeted. Compatible and AES-CCM channels still sign nothing. Tests: B23 pins position unicast signing per policy and Strict acceptance; B7 expects position unicasts signed; B2 and B5 use PRIVATE_APP as their unsigned unicast now that position is signed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * Strict: accept unsigned key verification messages Key verification's bootstrap messages (M1, M2) go out channel-encrypted because the peer's key is not known yet, so they cannot carry a signature the receiver could verify. Strict dropped them, so two Strict nodes that did not already hold each other's keys could never run key verification. The handshake does not rely on the transport: hash1 binds both public keys to the security number the users exchange, and a key is only stored after the numbers match and the user accepts. A forged request can at most raise a verification prompt, bounded by the single session slot and the 60 s remote cooldown - as it already can under Balanced. Test: A27 pins that Strict accepts an unsigned key verification message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * test_nodedb_identity_hygiene: pin the identity refusal to Balanced Two cases expected updateUser() to refuse an unsigned update from a known signer under a zeroed config. Zeroed is Compatible, which now accepts it, so they failed in CI. Run them under Balanced, and add the Compatible counterpart so both halves of the rule are pinned at the updateUser level. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * Ham mode: sign in place of PKI, authenticate acks with the ack proof (#12200) Ham mode signed every unicast under Balanced and Strict, acks included. A signed ack from a node whose key a Strict receiver lacks is dropped, so the sender retransmits to exhaustion, and the signature costs 66 bytes where the ack proof costs about 10. Ham mode now signs the unicasts PKI would cover in regular mode, plus broadcasts, NodeInfo and position as before. Acks are no longer signed; they carry the ack proof, which needs only the two nodes' keys, not PKI. Strict already accepts unsigned acks. Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK Co-authored-by: Claude <noreply@anthropic.com> * Notify the client when an unsigned identity update is refused Balanced and Strict refuse an unsigned NodeInfo that would change a known signer's name or role, but the refusal only reached the device log, so the user saw a stale name with no reason. Send a ClientNotification naming the node (by the name already held, never the refused claim) and how to accept its updates again: remove the node. Rate limited to once per node per boot, over the last 8 nodes, and to one notice per minute overall, so a signer relayed through pre-2.8 firmware or a burst of them cannot flood the client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * Refused identity notice: only for a name or role change, drop the warnings Notify only when the refused NodeInfo would have changed the stored long name, short name or role, compared through CopyUserToNodeInfoLite so truncation and sanitizing cannot read as a change. A signer held only in the warm tier has no stored name to compare and is not reported. Remove the two LOG_WARNs at the refusal sites; the notice replaces them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * Refused identity notice: name the security setting that refused it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK * Refused identity notice: one global hourly throttle, no per-node tracking Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK --------- Co-authored-by: Claude <noreply@anthropic.com>
580 lines
23 KiB
C++
580 lines
23 KiB
C++
// Identity hygiene for the remote-identity commit paths in NodeDB: updateUser() key pinning and
|
|
// addFromContact() guards (a keyless contact must never erase a stored key - the #11432 regression).
|
|
#include "MeshTypes.h" // Include BEFORE TestUtil.h
|
|
#include "TestUtil.h"
|
|
#include <unity.h>
|
|
|
|
#if defined(ARCH_PORTDUINO)
|
|
#define IH_TEST_ENTRY extern "C"
|
|
#else
|
|
#define IH_TEST_ENTRY
|
|
#endif
|
|
|
|
#include "FSCommon.h"
|
|
#include "SPILock.h"
|
|
#include "mesh/NodeDB.h"
|
|
#include "support/MockMeshService.h"
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
|
|
// Subclass shim: the friend declaration in NodeDB.h grants access to the
|
|
// private state these tests must seed/reset (duplicateWarned latch, warm-tier
|
|
// demotion). Declared at global scope so it matches `friend class NodeDBTestShim`.
|
|
class NodeDBTestShim : public NodeDB
|
|
{
|
|
public:
|
|
void clearHot()
|
|
{
|
|
meshNodes->clear();
|
|
numMeshNodes = 0;
|
|
}
|
|
|
|
// keySeed == 0 means "no stored key"; otherwise a deterministic 32-byte pattern.
|
|
void push(NodeNum num, uint32_t lastHeard, uint8_t keySeed = 0, bool xeddsaSigned = false)
|
|
{
|
|
meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero;
|
|
n.num = num;
|
|
n.last_heard = lastHeard;
|
|
nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_HAS_USER_MASK, true);
|
|
if (keySeed) {
|
|
n.public_key.size = 32;
|
|
memset(n.public_key.bytes, keySeed, 32);
|
|
n.public_key.bytes[0] = 0x01; // never all-zero (all-zero == "no key")
|
|
}
|
|
if (xeddsaSigned)
|
|
nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
|
|
meshNodes->push_back(n);
|
|
numMeshNodes = meshNodes->size();
|
|
}
|
|
|
|
// Index 0 is our own node; eviction scans treat it as self.
|
|
void seedSelf() { push(0x0BADF00D, 0xFFFFFFFFu); }
|
|
|
|
void resetDuplicateWarned() { duplicateWarned = false; }
|
|
|
|
void resetRefusedIdentityNotices() { lastRefusedIdentityNoticeMs = 0; }
|
|
|
|
#if WARM_NODE_COUNT > 0
|
|
void runDemote() { demoteOldestHotNodesToWarm(); }
|
|
#endif
|
|
};
|
|
|
|
namespace
|
|
{
|
|
|
|
NodeDBTestShim *db = nullptr;
|
|
MockMeshService *mockService = nullptr;
|
|
|
|
meshtastic_User savedOwner;
|
|
meshtastic_LocalConfig savedConfig;
|
|
|
|
constexpr NodeNum kPeer = 0xE1000001;
|
|
|
|
// Same pattern as NodeDBTestShim::push so a "matching" user key really matches.
|
|
template <typename KeyT> void fillKey(KeyT &k, uint8_t seed)
|
|
{
|
|
k.size = 32;
|
|
memset(k.bytes, seed, 32);
|
|
k.bytes[0] = 0x01;
|
|
}
|
|
|
|
meshtastic_User makeUser(const char *longName, const char *shortName, uint8_t keySeed = 0)
|
|
{
|
|
meshtastic_User u = meshtastic_User_init_zero;
|
|
strncpy(u.long_name, longName, sizeof(u.long_name) - 1);
|
|
strncpy(u.short_name, shortName, sizeof(u.short_name) - 1);
|
|
if (keySeed)
|
|
fillKey(u.public_key, keySeed);
|
|
return u;
|
|
}
|
|
|
|
meshtastic_SharedContact makeContact(NodeNum num, const char *longName, const char *shortName, uint8_t keySeed = 0)
|
|
{
|
|
meshtastic_SharedContact c = meshtastic_SharedContact_init_zero;
|
|
c.node_num = num;
|
|
c.has_user = true;
|
|
c.user = makeUser(longName, shortName, keySeed);
|
|
return c;
|
|
}
|
|
|
|
void assertStoredKeyEquals(NodeNum num, uint8_t seed)
|
|
{
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(num);
|
|
TEST_ASSERT_NOT_NULL(info);
|
|
TEST_ASSERT_EQUAL(32, info->public_key.size);
|
|
uint8_t expected[32];
|
|
memset(expected, seed, 32);
|
|
expected[0] = 0x01;
|
|
TEST_ASSERT_EQUAL_MEMORY(expected, info->public_key.bytes, 32);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// --- addFromContact ---
|
|
|
|
// The #11432 regression: a stored 32-byte key plus a contact with has_user=true
|
|
// but no key must keep the stored key bit-for-bit while still merging the user
|
|
// fields (clients send add_contact before every DM, usually keyless).
|
|
static void test_contact_keyless_preserves_stored_key(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42);
|
|
|
|
db->addFromContact(makeContact(kPeer, "Alice", "AL"));
|
|
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer);
|
|
TEST_ASSERT_EQUAL_STRING("Alice", info->long_name); // merge still applied
|
|
TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(info)); // anti-eviction stamp for normal roles
|
|
}
|
|
|
|
// The guard blocks erasure, not update: a contact carrying a different valid
|
|
// 32-byte key replaces the stored one (the QR contact-sharing flow).
|
|
static void test_contact_new_key_updates_stored_key(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42);
|
|
|
|
db->addFromContact(makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x77));
|
|
|
|
assertStoredKeyEquals(kPeer, 0x77);
|
|
}
|
|
|
|
// A manually-verified pin refuses the ENTIRE update from a non-verified contact
|
|
// whose key mismatches - name and key both stay untouched.
|
|
static void test_contact_verified_pin_blocks_mismatched_key(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42);
|
|
nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true);
|
|
|
|
db->addFromContact(makeContact(kPeer, "Mallory", "MA", /*keySeed=*/0x77));
|
|
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer);
|
|
TEST_ASSERT_EQUAL_STRING("", info->long_name); // refused wholesale, not just the key
|
|
TEST_ASSERT_FALSE(nodeInfoLiteIsFavorite(info)); // returned before the favorite stamp
|
|
TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info));
|
|
}
|
|
|
|
// The verified pin also refuses a KEYLESS non-verified contact wholesale (a
|
|
// size mismatch is a key mismatch) - unlike the plain erasure guard below,
|
|
// which merges the user fields and only restores the key.
|
|
static void test_contact_verified_pin_blocks_keyless_unverified(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42);
|
|
nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true);
|
|
|
|
db->addFromContact(makeContact(kPeer, "Alice", "AL")); // keyless, not verified
|
|
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name);
|
|
}
|
|
|
|
// A non-verified contact whose key MATCHES the verified pin may still update
|
|
// the user fields; the verified bit survives the merge.
|
|
static void test_contact_verified_pin_allows_matching_key(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42);
|
|
nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true);
|
|
|
|
db->addFromContact(makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x42));
|
|
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer);
|
|
TEST_ASSERT_EQUAL_STRING("Alice", info->long_name);
|
|
TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info));
|
|
}
|
|
|
|
// contact.manually_verified sets the bit, and a later plain update (here via
|
|
// updateUser with the pinned key) must not clear it - CopyUserToNodeInfoLite
|
|
// only touches the user-derived bits.
|
|
static void test_contact_manually_verified_bit_survives_updates(void)
|
|
{
|
|
meshtastic_SharedContact c = makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x42);
|
|
c.manually_verified = true;
|
|
db->addFromContact(c);
|
|
TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(db->getMeshNode(kPeer)));
|
|
|
|
meshtastic_User u = makeUser("Alice2", "A2", /*keySeed=*/0x42);
|
|
TEST_ASSERT_TRUE(db->updateUser(kPeer, u));
|
|
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer);
|
|
TEST_ASSERT_EQUAL_STRING("Alice2", info->long_name);
|
|
TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info));
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
}
|
|
|
|
// should_ignore blocks the contact and drops its satellite data but keeps the
|
|
// stored public key: an ignored peer stays a verifiable identity.
|
|
static void test_contact_should_ignore_blocks_but_keeps_key(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42);
|
|
nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_FAVORITE_MASK, true);
|
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
|
meshtastic_PositionLite pos = meshtastic_PositionLite_init_zero;
|
|
pos.latitude_i = 123456789;
|
|
db->nodePositions[kPeer] = pos;
|
|
TEST_ASSERT_TRUE(db->hasNodePosition(kPeer));
|
|
#endif
|
|
|
|
meshtastic_SharedContact c = makeContact(kPeer, "Blocked", "BL"); // keyless on purpose
|
|
c.should_ignore = true;
|
|
db->addFromContact(c);
|
|
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer);
|
|
TEST_ASSERT_NOT_NULL(info);
|
|
TEST_ASSERT_TRUE(nodeInfoLiteIsIgnored(info));
|
|
TEST_ASSERT_FALSE(nodeInfoLiteIsFavorite(info));
|
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
|
TEST_ASSERT_FALSE(db->hasNodePosition(kPeer));
|
|
#endif
|
|
assertStoredKeyEquals(kPeer, 0x42); // key retained through the keyless ignore contact
|
|
}
|
|
|
|
// CLIENT_BASE must not auto-favorite (is_favorite has special meaning there);
|
|
// the anti-eviction protection is a heard-now stamp instead.
|
|
static void test_contact_client_base_stamps_heard_not_favorite(void)
|
|
{
|
|
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT_BASE;
|
|
|
|
db->addFromContact(makeContact(kPeer, "Alice", "AL"));
|
|
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer);
|
|
TEST_ASSERT_NOT_NULL(info);
|
|
TEST_ASSERT_FALSE(nodeInfoLiteIsFavorite(info));
|
|
// initializeTestEnvironment() set an NTP-quality RTC, so the stamp lands in last_heard.
|
|
TEST_ASSERT_NOT_EQUAL(0, info->last_heard);
|
|
}
|
|
|
|
// A contact without a user payload must not merge fields or apply should_ignore to an
|
|
// existing node. (getOrCreateMeshNode still runs first, so an unknown num would be
|
|
// admitted as a blank row - that path is not covered here.)
|
|
static void test_contact_without_user_is_noop(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42);
|
|
const size_t countBefore = db->getNumMeshNodes();
|
|
|
|
meshtastic_SharedContact c = meshtastic_SharedContact_init_zero;
|
|
c.node_num = kPeer;
|
|
c.has_user = false;
|
|
c.should_ignore = true;
|
|
db->addFromContact(c);
|
|
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer);
|
|
TEST_ASSERT_FALSE(nodeInfoLiteIsIgnored(info));
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
TEST_ASSERT_EQUAL_UINT(countBefore, db->getNumMeshNodes()); // existing node: no new row admitted
|
|
}
|
|
|
|
// --- updateUser ---
|
|
|
|
#if !(MESHTASTIC_EXCLUDE_PKI)
|
|
|
|
// A pinned 32-byte key is immutable against a NodeInfo carrying a different key.
|
|
static void test_updateuser_pinned_key_blocks_mismatch(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42);
|
|
|
|
meshtastic_User u = makeUser("Mallory", "MA", /*keySeed=*/0x77);
|
|
TEST_ASSERT_FALSE(db->updateUser(kPeer, u));
|
|
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name); // dropped wholesale
|
|
}
|
|
|
|
// ...and against a NodeInfo carrying NO key: unlike addFromContact, updateUser
|
|
// drops a keyless update for a pinned node entirely.
|
|
static void test_updateuser_keyless_nodeinfo_dropped_wholesale(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42);
|
|
|
|
meshtastic_User u = makeUser("Alice", "AL");
|
|
TEST_ASSERT_FALSE(db->updateUser(kPeer, u));
|
|
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name);
|
|
}
|
|
|
|
// First key for a node is accepted (TOFU) and the reach-channel is stamped.
|
|
static void test_updateuser_first_key_accepted(void)
|
|
{
|
|
db->push(kPeer, 1000);
|
|
|
|
meshtastic_User u = makeUser("Alice", "AL", /*keySeed=*/0x42);
|
|
TEST_ASSERT_TRUE(db->updateUser(kPeer, u, /*channelIndex=*/3));
|
|
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer);
|
|
TEST_ASSERT_EQUAL_STRING("Alice", info->long_name);
|
|
TEST_ASSERT_EQUAL(3, info->channel);
|
|
}
|
|
|
|
// A remote node advertising OUR public key is refused with exactly one
|
|
// ClientNotification; the duplicateWarned latch silences the second attempt.
|
|
static void test_updateuser_own_key_advert_notifies_once(void)
|
|
{
|
|
fillKey(owner.public_key, 0x5A);
|
|
meshtastic_User u = makeUser("Evil twin", "ET", /*keySeed=*/0x5A);
|
|
|
|
TEST_ASSERT_FALSE(db->updateUser(kPeer, u));
|
|
TEST_ASSERT_EQUAL(1, mockService->notificationCount);
|
|
|
|
TEST_ASSERT_FALSE(db->updateUser(kPeer, u));
|
|
TEST_ASSERT_EQUAL(1, mockService->notificationCount); // latched
|
|
}
|
|
|
|
// user.id is always re-derived from the node number, whatever the payload claims.
|
|
static void test_updateuser_id_derived_from_nodenum(void)
|
|
{
|
|
meshtastic_User u = makeUser("Alice", "AL", /*keySeed=*/0x42);
|
|
strncpy(u.id, "!deadbeef", sizeof(u.id) - 1);
|
|
|
|
TEST_ASSERT_TRUE(db->updateUser(kPeer, u));
|
|
|
|
char expected[16];
|
|
snprintf(expected, sizeof(expected), "!%08x", (unsigned)kPeer);
|
|
TEST_ASSERT_EQUAL_STRING(expected, u.id);
|
|
}
|
|
|
|
// Under Balanced (and Strict) a known XEdDSA signer's identity only changes via a
|
|
// signed update - even a same-key name change arriving unsigned is refused.
|
|
static void test_updateuser_unsigned_update_refused_for_hot_signer(void)
|
|
{
|
|
config.security.packet_signature_policy =
|
|
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED;
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42, /*xeddsaSigned=*/true);
|
|
meshtastic_User u = makeUser("New name", "NN", /*keySeed=*/0x42);
|
|
|
|
TEST_ASSERT_FALSE(db->updateUser(kPeer, u, 0, /*xeddsaSigned=*/false));
|
|
TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name);
|
|
|
|
TEST_ASSERT_TRUE(db->updateUser(kPeer, u, 0, /*xeddsaSigned=*/true)); // signed control
|
|
TEST_ASSERT_EQUAL_STRING("New name", db->getMeshNode(kPeer)->long_name);
|
|
}
|
|
|
|
// Without a notice the user sees a stale name or role and no reason for it. Pins one
|
|
// ClientNotification for a refused identity change, and none for further refusals within the hour,
|
|
// from the same node or another: signers whose NodeInfo keeps arriving unsigned (every relay through
|
|
// pre-2.8 firmware) must not flood the client.
|
|
static void test_updateuser_refused_identity_notifies_rate_limited(void)
|
|
{
|
|
config.security.packet_signature_policy =
|
|
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT;
|
|
const NodeNum other = kPeer + 1;
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42, /*xeddsaSigned=*/true);
|
|
db->push(other, 1000, /*keySeed=*/0x43, /*xeddsaSigned=*/true);
|
|
meshtastic_User a = makeUser("A", "A", /*keySeed=*/0x42);
|
|
meshtastic_User b = makeUser("B", "B", /*keySeed=*/0x43);
|
|
|
|
TEST_ASSERT_FALSE(db->updateUser(kPeer, a, 0, /*xeddsaSigned=*/false));
|
|
TEST_ASSERT_EQUAL_MESSAGE(1, mockService->notificationCount, "a refused identity change must notify the client");
|
|
TEST_ASSERT_FALSE(db->updateUser(kPeer, a, 0, /*xeddsaSigned=*/false));
|
|
TEST_ASSERT_FALSE(db->updateUser(other, b, 0, /*xeddsaSigned=*/false));
|
|
TEST_ASSERT_EQUAL_MESSAGE(1, mockService->notificationCount, "notices must be spaced, not one per refusal");
|
|
}
|
|
|
|
// A refused NodeInfo that repeats the stored name, short name and role loses the user nothing, so it
|
|
// must not notify: an unchanged signer relayed through pre-2.8 firmware would otherwise report itself
|
|
// on every NodeInfo. A role change alone is a change the user would miss, and does notify.
|
|
static void test_updateuser_refused_unchanged_identity_does_not_notify(void)
|
|
{
|
|
config.security.packet_signature_policy =
|
|
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED;
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42, /*xeddsaSigned=*/true);
|
|
meshtastic_NodeInfoLite *stored = db->getMeshNode(kPeer);
|
|
strncpy(stored->long_name, "Same name", sizeof(stored->long_name) - 1);
|
|
strncpy(stored->short_name, "SN", sizeof(stored->short_name) - 1);
|
|
meshtastic_User u = makeUser("Same name", "SN", /*keySeed=*/0x42);
|
|
|
|
TEST_ASSERT_FALSE(db->updateUser(kPeer, u, 0, /*xeddsaSigned=*/false));
|
|
TEST_ASSERT_EQUAL_MESSAGE(0, mockService->notificationCount, "an unchanged identity must not notify");
|
|
|
|
u.role = meshtastic_Config_DeviceConfig_Role_ROUTER;
|
|
TEST_ASSERT_FALSE(db->updateUser(kPeer, u, 0, /*xeddsaSigned=*/false));
|
|
TEST_ASSERT_EQUAL_MESSAGE(1, mockService->notificationCount, "a refused role change must notify");
|
|
}
|
|
|
|
// Compatible accepts the same unsigned update: firmware before 2.8 strips signatures when
|
|
// relaying, so refusing there would freeze a signer's name behind every older relay. The key
|
|
// pin still holds (test_updateuser_pinned_key_blocks_mismatch); only name and role can change.
|
|
static void test_updateuser_unsigned_update_accepted_for_signer_in_compatible(void)
|
|
{
|
|
config.security.packet_signature_policy =
|
|
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE;
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42, /*xeddsaSigned=*/true);
|
|
meshtastic_User u = makeUser("New name", "NN", /*keySeed=*/0x42);
|
|
|
|
TEST_ASSERT_TRUE(db->updateUser(kPeer, u, 0, /*xeddsaSigned=*/false));
|
|
TEST_ASSERT_EQUAL_STRING("New name", db->getMeshNode(kPeer)->long_name);
|
|
TEST_ASSERT_EQUAL_MESSAGE(0, mockService->notificationCount, "an accepted update must not notify");
|
|
}
|
|
|
|
// The key pin outranks the signature: a signed update still cannot rotate a
|
|
// pinned key (rotation goes through commitRemoteKey's proven paths instead).
|
|
static void test_updateuser_signed_update_cannot_rotate_pinned_key(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42, /*xeddsaSigned=*/true);
|
|
|
|
meshtastic_User u = makeUser("Rotated", "RO", /*keySeed=*/0x77);
|
|
TEST_ASSERT_FALSE(db->updateUser(kPeer, u, 0, /*xeddsaSigned=*/true));
|
|
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
}
|
|
|
|
#if WARM_NODE_COUNT > 0
|
|
// The signer gate runs BEFORE getOrCreateMeshNode, so refusing an unsigned
|
|
// update for a warm-tier signer must not evict a hot node, must not re-admit
|
|
// the signer, and must not consume its warm record.
|
|
static void test_updateuser_warm_signer_refusal_does_not_evict(void)
|
|
{
|
|
config.security.packet_signature_policy =
|
|
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED; // refusal applies
|
|
const NodeNum signerNum = 0xE2000000 + 3;
|
|
const int extra = MAX_NUM_NODES + 30; // overflow so the oldest non-protected demote to warm
|
|
for (int i = 1; i <= extra; i++)
|
|
db->push(0xE2000000 + i, /*lastHeard=*/i, /*keySeed=*/0x42);
|
|
nodeInfoLiteSetBit(db->getMeshNode(signerNum), NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
|
|
|
|
db->runDemote();
|
|
|
|
TEST_ASSERT_NULL(db->getMeshNode(signerNum)); // demoted out of hot
|
|
TEST_ASSERT_TRUE(db->isKnownXeddsaSigner(signerNum));
|
|
TEST_ASSERT_TRUE(db->isFull());
|
|
const int hotBefore = (int)db->getNumMeshNodes();
|
|
|
|
meshtastic_User u = makeUser("New name", "NN", /*keySeed=*/0x42);
|
|
TEST_ASSERT_FALSE(db->updateUser(signerNum, u, 0, /*xeddsaSigned=*/false));
|
|
|
|
TEST_ASSERT_EQUAL_INT(hotBefore, (int)db->getNumMeshNodes());
|
|
TEST_ASSERT_NULL(db->getMeshNode(signerNum)); // not re-admitted
|
|
TEST_ASSERT_TRUE(db->isKnownXeddsaSigner(signerNum)); // warm record intact (take() never ran)
|
|
|
|
// Signed control: the same update signed is accepted and re-admits the
|
|
// signer from warm with its key and signer bit restored.
|
|
TEST_ASSERT_TRUE(db->updateUser(signerNum, u, 0, /*xeddsaSigned=*/true));
|
|
const meshtastic_NodeInfoLite *back = db->getMeshNode(signerNum);
|
|
TEST_ASSERT_NOT_NULL(back);
|
|
TEST_ASSERT_TRUE(nodeInfoLiteHasXeddsaSigned(back));
|
|
TEST_ASSERT_EQUAL_STRING("New name", back->long_name);
|
|
assertStoredKeyEquals(signerNum, 0x42);
|
|
}
|
|
#endif // WARM_NODE_COUNT > 0
|
|
|
|
#endif // !(MESHTASTIC_EXCLUDE_PKI)
|
|
|
|
// --- persistence ---
|
|
|
|
// The erasure guard's outcome must survive the disk round trip: after a keyless
|
|
// add_contact against a pinned key, a rebooted NodeDB still holds the full key
|
|
// (pre-#11432 the zeroed key was persisted, breaking DMs until re-exchange).
|
|
static void test_contact_key_guard_survives_reboot(void)
|
|
{
|
|
// saveNodeDatabaseToDisk() skips keyless devices, so give ourselves a key.
|
|
fillKey(owner.public_key, 0x5A);
|
|
|
|
meshtastic_SharedContact keyed = makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x42);
|
|
keyed.manually_verified = true;
|
|
db->addFromContact(keyed); // persists
|
|
// The keyless pre-DM contact for a verified node also carries manually_verified
|
|
// (a non-verified keyless contact would be refused by the verified pin instead).
|
|
meshtastic_SharedContact keyless = makeContact(kPeer, "Al2", "A2");
|
|
keyless.manually_verified = true;
|
|
db->addFromContact(keyless); // keyless merge; persists the guard result
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
|
|
// A real cold boot starts with a zeroed nodeDatabase global; in-process the decode
|
|
// callback appends on top of the previous boot's rows, so without this reset the
|
|
// lookups below would find the pre-reboot RAM row and the persistence claim is vacuous.
|
|
delete db;
|
|
db = nullptr;
|
|
nodeDB = nullptr;
|
|
nodeDatabase.version = 0;
|
|
nodeDatabase.nodes.clear();
|
|
nodeDatabase.positions.clear();
|
|
nodeDatabase.telemetry.clear();
|
|
nodeDatabase.environment.clear();
|
|
nodeDatabase.status.clear();
|
|
db = new NodeDBTestShim();
|
|
nodeDB = db;
|
|
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer);
|
|
TEST_ASSERT_NOT_NULL_MESSAGE(info, "contact must survive the reload");
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
TEST_ASSERT_EQUAL_STRING("Al2", info->long_name);
|
|
TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info)); // pin survives the reboot too
|
|
}
|
|
|
|
// --- Unity lifecycle ---
|
|
|
|
void setUp(void)
|
|
{
|
|
savedOwner = owner;
|
|
savedConfig = config;
|
|
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
|
owner.public_key.size = 0;
|
|
|
|
mockService = new MockMeshService();
|
|
service = mockService;
|
|
|
|
db->clearHot();
|
|
db->seedSelf();
|
|
db->resetDuplicateWarned();
|
|
db->resetRefusedIdentityNotices();
|
|
}
|
|
|
|
void tearDown(void)
|
|
{
|
|
owner = savedOwner;
|
|
config = savedConfig;
|
|
service = nullptr;
|
|
delete mockService;
|
|
mockService = nullptr;
|
|
}
|
|
|
|
IH_TEST_ENTRY void setup()
|
|
{
|
|
initializeTestEnvironment();
|
|
#ifdef FSCom
|
|
// NodeDB and MessageStore bracket their FS writes with spiLock; nothing in the
|
|
// test environment creates it, so do it here (initSPI asserts it only runs once).
|
|
if (!spiLock)
|
|
initSPI();
|
|
#endif
|
|
db = new NodeDBTestShim();
|
|
nodeDB = db;
|
|
|
|
UNITY_BEGIN();
|
|
|
|
printf("\n=== addFromContact guards ===\n");
|
|
RUN_TEST(test_contact_keyless_preserves_stored_key);
|
|
RUN_TEST(test_contact_new_key_updates_stored_key);
|
|
RUN_TEST(test_contact_verified_pin_blocks_mismatched_key);
|
|
RUN_TEST(test_contact_verified_pin_blocks_keyless_unverified);
|
|
RUN_TEST(test_contact_verified_pin_allows_matching_key);
|
|
RUN_TEST(test_contact_manually_verified_bit_survives_updates);
|
|
RUN_TEST(test_contact_should_ignore_blocks_but_keeps_key);
|
|
RUN_TEST(test_contact_client_base_stamps_heard_not_favorite);
|
|
RUN_TEST(test_contact_without_user_is_noop);
|
|
|
|
#if !(MESHTASTIC_EXCLUDE_PKI)
|
|
printf("\n=== updateUser key pinning ===\n");
|
|
RUN_TEST(test_updateuser_pinned_key_blocks_mismatch);
|
|
RUN_TEST(test_updateuser_keyless_nodeinfo_dropped_wholesale);
|
|
RUN_TEST(test_updateuser_first_key_accepted);
|
|
RUN_TEST(test_updateuser_own_key_advert_notifies_once);
|
|
RUN_TEST(test_updateuser_id_derived_from_nodenum);
|
|
RUN_TEST(test_updateuser_unsigned_update_refused_for_hot_signer);
|
|
RUN_TEST(test_updateuser_refused_identity_notifies_rate_limited);
|
|
RUN_TEST(test_updateuser_refused_unchanged_identity_does_not_notify);
|
|
RUN_TEST(test_updateuser_unsigned_update_accepted_for_signer_in_compatible);
|
|
RUN_TEST(test_updateuser_signed_update_cannot_rotate_pinned_key);
|
|
#if WARM_NODE_COUNT > 0
|
|
RUN_TEST(test_updateuser_warm_signer_refusal_does_not_evict);
|
|
#endif
|
|
#endif
|
|
|
|
printf("\n=== persistence ===\n");
|
|
RUN_TEST(test_contact_key_guard_survives_reboot);
|
|
|
|
exit(UNITY_END());
|
|
}
|
|
IH_TEST_ENTRY void loop() {}
|