Files
Jonathan BennettandClaude Opus 5.5 34cb8d5828 Rework XEdDSA signing policy: Compatible stops signing, AES-CCM overrides, Strict exemptions (#12191)
* 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>
2026-10-22 01:08:36 +00:00

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() {}