diff --git a/protobufs b/protobufs index 1ae3be3d541..19ab2d27110 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 1ae3be3d5413d2190c1eb0d3ced094237de68b81 +Subproject commit 19ab2d2711050cb462f0f57507f75b47bfa6832c diff --git a/src/main.cpp b/src/main.cpp index 63cbf35ab07..2a73557dd80 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1062,6 +1062,7 @@ void setup() nodeDB->hasWarned = true; } #endif + nodeDB->notifyPendingLicensedIdentityMigration(); #if !MESHTASTIC_EXCLUDE_INPUTBROKER if (inputBroker) inputBroker->Init(); @@ -1206,7 +1207,7 @@ bool runASAP; // TODO find better home than main.cpp extern meshtastic_DeviceMetadata getDeviceMetadata() { - meshtastic_DeviceMetadata deviceMetadata; + meshtastic_DeviceMetadata deviceMetadata = meshtastic_DeviceMetadata_init_zero; strncpy(deviceMetadata.firmware_version, optstr(APP_VERSION), sizeof(deviceMetadata.firmware_version)); deviceMetadata.device_state_version = DEVICESTATE_CUR_VER; deviceMetadata.canShutdown = pmu_found || HAS_CPU_SHUTDOWN; @@ -1262,6 +1263,9 @@ extern meshtastic_DeviceMetadata getDeviceMetadata() #if !(MESHTASTIC_EXCLUDE_PKI) deviceMetadata.hasPKC = true; +#endif +#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) + deviceMetadata.has_xeddsa = true; #endif return deviceMetadata; } diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index b086f5e6b3e..1cebcce1732 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -128,11 +128,13 @@ bool Channels::ensureLicensedOperation() } auto &channelSettings = channel.settings; if (strcasecmp(channelSettings.name, Channels::adminChannel) == 0) { - channel.role = meshtastic_Channel_Role_DISABLED; - channelSettings.psk.bytes[0] = 0; - channelSettings.psk.size = 0; - hasEncryptionOrAdmin = true; - channels.setChannel(channel); + if (channel.role != meshtastic_Channel_Role_DISABLED || channelSettings.psk.size > 0) { + channel.role = meshtastic_Channel_Role_DISABLED; + channelSettings.psk.bytes[0] = 0; + channelSettings.psk.size = 0; + hasEncryptionOrAdmin = true; + channels.setChannel(channel); + } } else if (channelSettings.psk.size > 0) { channelSettings.psk.bytes[0] = 0; @@ -563,4 +565,4 @@ bool Channels::setDefaultPresetCryptoForHash(ChannelHash channelHash) int16_t Channels::setActiveByIndex(ChannelIndex channelIndex) { return setCrypto(channelIndex); -} \ No newline at end of file +} diff --git a/src/mesh/FloodingRouter.cpp b/src/mesh/FloodingRouter.cpp index 13f98299f93..1b2f05c978e 100644 --- a/src/mesh/FloodingRouter.cpp +++ b/src/mesh/FloodingRouter.cpp @@ -51,8 +51,8 @@ bool FloodingRouter::shouldFilterReceived(const meshtastic_MeshPacket *p) LOG_DEBUG("Repeated reliable tx"); // Check if it's still in the Tx queue, if not, we have to relay it again if (!findInTxQueue(p->from, p->id)) { - reprocessPacket(p); - perhapsRebroadcast(p); + if (reprocessPacket(p)) + perhapsRebroadcast(p); } } else { perhapsCancelDupe(p); @@ -68,6 +68,10 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p) { // isRebroadcaster() is duplicated in perhapsRebroadcast(), but this avoids confusing log messages if (isRebroadcaster() && iface && p->hop_limit > 0) { + // Re-authenticate before replacing the queued lower-hop copy so future callers remain safe. + if (passesRoutingAuthGate(p) != RoutingAuthVerdict::ACCEPT) + return true; + // If we overhear a duplicate copy of the packet with more hops left than the one we are waiting to // rebroadcast, then remove the packet currently sitting in the TX queue and use this one instead. uint8_t dropThreshold = p->hop_limit; // remove queued packets that have fewer hops remaining @@ -75,7 +79,8 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p) LOG_DEBUG("Processing upgraded packet 0x%08x for rebroadcast with hop limit %d (dropping queued < %d)", p->id, p->hop_limit, dropThreshold); - reprocessPacket(p); + if (!reprocessPacket(p)) + return true; perhapsRebroadcast(p); rxDupe++; @@ -87,32 +92,24 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p) return false; } -void FloodingRouter::reprocessPacket(const meshtastic_MeshPacket *p) +bool FloodingRouter::reprocessPacket(const meshtastic_MeshPacket *p) { + if (p->which_payload_variant != meshtastic_MeshPacket_decoded_tag) { + auto decodedState = perhapsDecode(const_cast(p)); + if (decodedState != DecodeState::DECODE_SUCCESS && decodedState != DecodeState::DECODE_OPAQUE) + return false; + } + if (nodeDB) nodeDB->updateFrom(*p); #if !MESHTASTIC_EXCLUDE_TRACEROUTE - if (traceRouteModule && p->which_payload_variant != meshtastic_MeshPacket_decoded_tag) { - // If we got a packet that is not decoded, try to decode it so we can check for traceroute. - auto decodedState = perhapsDecode(const_cast(p)); - if (decodedState == DecodeState::DECODE_SUCCESS) { - // parsing was successful, print for debugging - printPacket("reprocessPacket(DUP)", p); - } else { - // Fatal decoding error, we can't do anything with this packet - LOG_WARN( - "FloodingRouter::reprocessPacket: Fatal decode error (state=%d, id=0x%08x, from=%u), can't check for traceroute", - static_cast(decodedState), p->id, getFrom(p)); - return; - } - } - if (traceRouteModule && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP) { traceRouteModule->processUpgradedPacket(*p); } #endif + return true; } bool FloodingRouter::roleAllowsCancelingDupe(const meshtastic_MeshPacket *p) diff --git a/src/mesh/FloodingRouter.h b/src/mesh/FloodingRouter.h index e8a2e9685fa..6bd5fca4166 100644 --- a/src/mesh/FloodingRouter.h +++ b/src/mesh/FloodingRouter.h @@ -64,7 +64,7 @@ class FloodingRouter : public Router bool perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p); /* Call when we receive a packet that needs some reprocessing, but afterwards should be filtered */ - void reprocessPacket(const meshtastic_MeshPacket *p); + bool reprocessPacket(const meshtastic_MeshPacket *p); // Return false for roles like ROUTER which should always rebroadcast even when we've heard another rebroadcast of // the same packet @@ -75,4 +75,4 @@ class FloodingRouter : public Router // Return true if we are a rebroadcaster bool isRebroadcaster(); -}; \ No newline at end of file +}; diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index f33de779cc0..3e7361f191e 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -11,6 +11,25 @@ NextHopRouter::NextHopRouter() {} +bool NextHopRouter::relayOpaquePacket(const meshtastic_MeshPacket *p) +{ + // Opaque traffic is never admitted to PacketHistory, NodeDB, modules, phone, MQTT, or ACK + // handling. Relay only from the immutable outer routing header and let hop exhaustion bound it. + const auto mode = config.device.rebroadcast_mode; + if (!iface || isToUs(p) || isFromUs(p) || p->id == 0 || p->hop_limit == 0 || !isRebroadcaster() || owner.is_licensed || + !IS_ONE_OF(mode, meshtastic_Config_DeviceConfig_RebroadcastMode_ALL, + meshtastic_Config_DeviceConfig_RebroadcastMode_ALL_SKIP_DECODING) || + (p->next_hop != NO_NEXT_HOP_PREFERENCE && p->next_hop != nodeDB->getLastByteOfNodeNum(getNodeNum()))) + return false; + + meshtastic_MeshPacket *relay = packetPool.allocCopy(*p); + if (!relay) + return false; + relay->hop_limit--; + relay->relay_node = nodeDB->getLastByteOfNodeNum(getNodeNum()); + return Router::send(relay) == ERRNO_OK; +} + PendingPacket::PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions) { packet = p; @@ -65,16 +84,15 @@ bool NextHopRouter::shouldFilterReceived(const meshtastic_MeshPacket *p) LOG_INFO("Fallback to flooding from relay_node=0x%x", p->relay_node); // Check if it's still in the Tx queue, if not, we have to relay it again if (!findInTxQueue(p->from, p->id)) { - reprocessPacket(p); - perhapsRebroadcast(p); + if (reprocessPacket(p)) + perhapsRebroadcast(p); } } else { bool isRepeated = getHopsAway(*p) == 0; // If repeated and not in Tx queue anymore, try relaying again, or if we are the destination, send the ACK again if (isRepeated) { if (!findInTxQueue(p->from, p->id)) { - reprocessPacket(p); - if (!perhapsRebroadcast(p) && isToUs(p) && p->want_ack) { + if (reprocessPacket(p) && !perhapsRebroadcast(p) && isToUs(p) && p->want_ack) { sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel, 0); } } diff --git a/src/mesh/NextHopRouter.h b/src/mesh/NextHopRouter.h index 467d9ca68bc..d5e2a9621f9 100644 --- a/src/mesh/NextHopRouter.h +++ b/src/mesh/NextHopRouter.h @@ -137,6 +137,7 @@ class NextHopRouter : public FloodingRouter * @return true to abandon the packet */ virtual bool shouldFilterReceived(const meshtastic_MeshPacket *p) override; + bool relayOpaquePacket(const meshtastic_MeshPacket *p) override; /** * Look for packets we need to relay @@ -209,4 +210,4 @@ class NextHopRouter : public FloodingRouter /** Check if we should be rebroadcasting this packet if so, do so. * @return true if we did rebroadcast */ bool perhapsRebroadcast(const meshtastic_MeshPacket *p) override; -}; \ No newline at end of file +}; diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index e05f8b8f0a2..6560cdb49af 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -860,7 +860,7 @@ void NodeDB::installDefaultNodeDatabase() void NodeDB::installDefaultConfig(bool preserveKey = false) { uint8_t private_key_temp[32]; - bool shouldPreserveKey = preserveKey && config.has_security && config.security.private_key.size > 0; + bool shouldPreserveKey = preserveKey && config.has_security && config.security.private_key.size == 32; if (shouldPreserveKey) { memcpy(private_key_temp, config.security.private_key.bytes, config.security.private_key.size); } @@ -1855,6 +1855,8 @@ bool NodeDB::enforceSatelliteCaps() // them if they do); otherwise tracker/sensor/tak_tracker are role-protected. static uint8_t warmProtectedCategory(const meshtastic_NodeInfoLite &n) { + if (nodeInfoLiteHasXeddsaSigned(&n)) + return static_cast(WarmProtected::XeddsaSigner); if (n.bitfield & (NODEINFO_BITFIELD_IS_FAVORITE_MASK | NODEINFO_BITFIELD_IS_IGNORED_MASK | NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK)) return static_cast(WarmProtected::Flag); @@ -2599,6 +2601,11 @@ void NodeDB::loadFromDisk() moduleConfig.version = POSITION_TELEMETRY_OPTIN_VER; saveToDisk(SEGMENT_MODULECONFIG); } + + if (channels.ensureLicensedOperation()) { + LOG_WARN("Licensed operation removed persisted channel encryption/admin access"); + saveToDisk(SEGMENT_CHANNELS); + } #if ARCH_PORTDUINO // set any config overrides if (portduino_config.has_configDisplayMode) { @@ -3719,6 +3726,18 @@ bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out) return false; } +bool NodeDB::hasSeenXeddsaSigner(NodeNum n) +{ + if (nodeInfoLiteHasXeddsaSigned(getMeshNode(n))) + return true; +#if WARM_NODE_COUNT > 0 + uint8_t role = 0, prot = 0; + return warmStore.lookupMeta(n, role, prot) && prot == static_cast(WarmProtected::XeddsaSigner); +#else + return false; +#endif +} + meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n) { const meshtastic_NodeInfoLite *info = getMeshNode(n); @@ -3811,6 +3830,8 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) lite->public_key.size = 32; memcpy(lite->public_key.bytes, warm.public_key, 32); } + if (warmProtOf(warm) == static_cast(WarmProtected::XeddsaSigner)) + nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); LOG_MIGRATION("Rehydrated node 0x%08x from warm tier (key=%d)", n, lite->public_key.size == 32); } #endif @@ -3863,15 +3884,14 @@ bool NodeDB::checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_pub bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey) { #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - // Only generate keys for non-licensed users and if the LoRa region is set. The native simulator - // boots region-UNSET but still needs a keypair so PKI-encrypted DMs work between sim nodes, so - // allow keygen there regardless of region. + // Generate identity keys once a LoRa region is set. Licensed operation still needs the identity + // key for plaintext signatures, even though the key is never used for PKI encryption. bool regionBlocksKeygen = config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET; #if ARCH_PORTDUINO if (portduino_config.lora_module == use_simradio) regionBlocksKeygen = false; #endif - if (owner.is_licensed || regionBlocksKeygen) { + if (regionBlocksKeygen) { return false; } @@ -3921,8 +3941,8 @@ bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey) LOG_DEBUG("Set DH private key for crypto operations"); crypto->setDHPrivateKey(config.security.private_key.bytes); - // Conditionally create new identity based on parameter - createNewIdentity(); + if (createNewIdentity() && owner.is_licensed) + licensedIdentityMigrationPending = true; } return keygenSuccess; #else @@ -3930,6 +3950,21 @@ bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey) #endif } +bool NodeDB::notifyPendingLicensedIdentityMigration() +{ + if (!licensedIdentityMigrationPending || !service) + return false; + meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed(); + if (!notification) + return false; + notification->level = meshtastic_LogRecord_Level_WARNING; + notification->time = getValidTime(RTCQualityFromNet); + snprintf(notification->message, sizeof(notification->message), "%s", LICENSED_IDENTITY_MIGRATION_WARNING); + service->sendClientNotification(notification); + licensedIdentityMigrationPending = false; + return true; +} + bool NodeDB::createNewIdentity() { uint32_t oldNodeNum = getNodeNum(); @@ -4033,6 +4068,13 @@ bool NodeDB::restorePreferences(meshtastic_AdminMessage_BackupLocation location, LOG_DEBUG("Restored channels"); } + if (owner.is_licensed && channels.ensureLicensedOperation()) { + restoreWhat |= SEGMENT_CHANNELS; + LOG_WARN("Licensed operation sanitized restored channel encryption/admin access"); + } + if (restoreWhat & SEGMENT_CHANNELS) + channels.onConfigChanged(); + success = saveToDisk(restoreWhat); if (success) { LOG_INFO("Restored preferences from backup"); diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 8aa32dcc75a..39655ae1584 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -74,6 +74,8 @@ static const uint8_t LOW_ENTROPY_HASHES[][32] = { 0x37, 0x82, 0x8d, 0xb2, 0xcc, 0xd8, 0x97, 0x40, 0x9a, 0x5c, 0x8f, 0x40, 0x55, 0xcb, 0x4c, 0x3e}}; static const char LOW_ENTROPY_WARNING[] = "Compromised keys were detected and regenerated."; #endif +static const char LICENSED_IDENTITY_MIGRATION_WARNING[] = + "Licensed signing generated a new identity key; this node identity changed."; /* DeviceState versions used to be defined in the .proto file but really only this function cares. So changed to a #define here. @@ -218,6 +220,7 @@ class NodeDB bool keyIsLowEntropy = false; bool hasWarned = false; + bool licensedIdentityMigrationPending = false; /// don't do mesh based algorithm for node id assignment (initially) /// instead just store in flash - possibly even in the initial alpha release do this hack @@ -357,6 +360,10 @@ class NodeDB /// tier. Returns false if we don't know a key for n. bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out); + /// Whether this node has produced a verified XEdDSA signature, including while its + /// identity is resident only in the warm tier. + bool hasSeenXeddsaSigner(NodeNum n); + /// Resolve a node's device role - hot store (with user) first, then the role /// cached in the warm tier, else CLIENT. Lets role-aware policy keep firing for /// nodes that have aged out of the hot store. @@ -471,6 +478,8 @@ class NodeDB /// @param privateKey Optional 32-byte private key to use. If nullptr, generates new random keys. bool generateCryptoKeyPair(const uint8_t *privateKey = nullptr); + bool notifyPendingLicensedIdentityMigration(); + bool createNewIdentity(); bool backupPreferences(meshtastic_AdminMessage_BackupLocation location); @@ -545,6 +554,7 @@ class NodeDB // Grant the unit-test shim access to the private maintenance paths below // (migration / cleanup / eviction) without relaxing production access. friend class NodeDBTestShim; + friend class MockNodeDB; #endif /// purge db entries without user info diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 67d724433f2..c26d2a9e914 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -12,6 +12,8 @@ #include "mesh-pb-constants.h" #include "meshUtils.h" #include "modules/RoutingModule.h" +#include +#include #include #if HAS_TRAFFIC_MANAGEMENT #include "modules/TrafficManagementModule.h" @@ -67,6 +69,81 @@ Allocator &packetPool = staticPool; static uint8_t bytes[MAX_LORA_PAYLOAD_LEN + 1] __attribute__((__aligned__)); +struct RoutingAuthCache { + bool valid = false; + meshtastic_Config_SecurityConfig_PacketSignaturePolicy policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED; + meshtastic_MeshPacket wire = meshtastic_MeshPacket_init_zero; + meshtastic_MeshPacket authenticated = meshtastic_MeshPacket_init_zero; +}; +static RoutingAuthCache routingAuthCache; +static concurrency::Lock *routingAuthCacheLock; +static uint32_t routingAuthEvaluations; + +static bool routingAuthCacheMatches(const meshtastic_MeshPacket &packet) +{ + if (!routingAuthCacheLock) + return false; + concurrency::LockGuard guard(routingAuthCacheLock); + if (!routingAuthCache.valid) + return false; + if (routingAuthCache.policy != config.security.packet_signature_policy || + memcmp(&routingAuthCache.wire, &packet, sizeof(packet)) != 0) { + routingAuthCache.valid = false; + return false; + } + return true; +} + +static void storeRoutingAuthCache(const meshtastic_MeshPacket &wire, const meshtastic_MeshPacket &authenticated) +{ + if (!routingAuthCacheLock) + return; + concurrency::LockGuard guard(routingAuthCacheLock); + routingAuthCache.wire = wire; + routingAuthCache.authenticated = authenticated; + routingAuthCache.policy = config.security.packet_signature_policy; + routingAuthCache.valid = true; +} + +static bool applyRoutingAuthCache(meshtastic_MeshPacket *packet) +{ + if (!routingAuthCacheLock) + return false; + concurrency::LockGuard guard(routingAuthCacheLock); + if (!routingAuthCache.valid || routingAuthCache.policy != config.security.packet_signature_policy || + memcmp(&routingAuthCache.wire, packet, sizeof(*packet)) != 0) { + routingAuthCache.valid = false; + return false; + } + *packet = routingAuthCache.authenticated; + routingAuthCache.valid = false; + return true; +} + +static void clearRoutingAuthCache() +{ + if (!routingAuthCacheLock) + return; + concurrency::LockGuard guard(routingAuthCacheLock); + routingAuthCache.valid = false; +} + +#ifdef PIO_UNIT_TESTING +uint32_t routingAuthEvaluationCount() +{ + return routingAuthEvaluations; +} +void resetRoutingAuthEvaluationCount() +{ + routingAuthEvaluations = 0; + if (routingAuthCacheLock) { + concurrency::LockGuard guard(routingAuthCacheLock); + routingAuthCache.valid = false; + } +} +#endif + /** * Constructor * @@ -85,6 +162,8 @@ Router::Router() : concurrency::OSThread("Router"), fromRadioQueue(MAX_RX_FROMRA // init Lockguard for crypt operations assert(!cryptLock); cryptLock = new concurrency::Lock(); + if (!routingAuthCacheLock) + routingAuthCacheLock = new concurrency::Lock(); } bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p) @@ -247,8 +326,10 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src) // No need to deliver externally if the destination is the local node if (isToUs(p)) { printPacket("Enqueued local", p); - enqueueReceivedMessage(p); - return ERRNO_OK; + // Preserve the trusted origin explicitly. Queueing used to erase src and make a local + // phone/module packet indistinguishable from remote already-decoded ingress. + handleReceived(p, src); + return ERRNO_SHOULD_RELEASE; } else if (!iface) { // We must be sending to remote nodes also, fail if no interface found abortSendAndNak(meshtastic_Routing_Error_NO_INTERFACE, p); @@ -452,26 +533,78 @@ void Router::sniffReceived(const meshtastic_MeshPacket *p, const meshtastic_Rout } #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) +enum class NodeInfoBootstrapResult { NOT_APPLICABLE, VERIFIED, INVALID }; + +static NodeInfoBootstrapResult verifyFirstContactNodeInfo(meshtastic_MeshPacket *p) +{ + if (p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP) + return NodeInfoBootstrapResult::NOT_APPLICABLE; + + meshtastic_User user = meshtastic_User_init_zero; + if (!pb_decode_from_bytes(p->decoded.payload.bytes, p->decoded.payload.size, &meshtastic_User_msg, &user) || + user.public_key.size != 32 || crc32Buffer(user.public_key.bytes, user.public_key.size) != p->from || + !crypto->xeddsa_verify(user.public_key.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, + p->decoded.payload.size, p->decoded.xeddsa_signature.bytes)) { + return NodeInfoBootstrapResult::INVALID; + } + + meshtastic_NodeInfoLite *node = nodeDB->getOrCreateMeshNode(p->from); + if (!node) + return NodeInfoBootstrapResult::INVALID; + node->public_key.size = user.public_key.size; + memcpy(node->public_key.bytes, user.public_key.bytes, user.public_key.size); + nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); + p->xeddsa_signed = true; + p->public_key.size = user.public_key.size; + memcpy(p->public_key.bytes, user.public_key.bytes, user.public_key.size); + LOG_DEBUG("Verified first-contact XEdDSA NodeInfo from 0x%08x", p->from); + return NodeInfoBootstrapResult::VERIFIED; +} + bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize) { + const auto policy = config.security.packet_signature_policy; + const bool strict = policy == meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT; + const bool compatible = policy == meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE; + // Only a signature we verify below may mark this packet signed; never trust an inbound flag. p->xeddsa_signed = false; + if (!p->pki_encrypted) + p->public_key.size = 0; if (p->decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE) { + meshtastic_NodeInfoLite_public_key_t senderKey = {0, {0}}; meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from); - if (node && node->public_key.size == 32) { + if (nodeDB->copyPublicKey(p->from, senderKey)) { p->xeddsa_signed = - crypto->xeddsa_verify(node->public_key.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, + crypto->xeddsa_verify(senderKey.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size, p->decoded.xeddsa_signature.bytes); if (p->xeddsa_signed) { // Learn this node as a signer, so a later unsigned signable broadcast from it is dropped + // A warm-tier key must be re-admitted before setting the signer bit; otherwise Balanced + // forgets downgrade protection as soon as the node is evicted from the hot store. + if (!node) + node = nodeDB->getOrCreateMeshNode(p->from); + if (!node) + return false; nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); + p->public_key.size = senderKey.size; + memcpy(p->public_key.bytes, senderKey.bytes, senderKey.size); LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from); } else { LOG_WARN("XEdDSA signature verification failed from 0x%08x, dropping", p->from); return false; } } else { + const auto bootstrap = verifyFirstContactNodeInfo(p); + if (bootstrap == NodeInfoBootstrapResult::INVALID) { + LOG_WARN("Invalid first-contact XEdDSA NodeInfo from 0x%08x, dropping", p->from); + return false; + } + if (bootstrap == NodeInfoBootstrapResult::VERIFIED) + return true; LOG_DEBUG("No public key for 0x%08x, cannot verify XEdDSA signature", p->from); + if (strict) + return false; } } else if (p->decoded.xeddsa_signature.size != 0) { // A signature field that is neither empty nor a full 64 bytes is malformed - honest @@ -482,15 +615,22 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize) p->from); return false; } else { - // Truly unsigned (signature size 0) - only reject the class a signing node always signs: a - // non-PKI broadcast whose signed encoding would still fit the LoRa frame. encodedDataSize is + if (p->pki_encrypted) + return true; + if (strict) { + LOG_WARN("Dropping unsigned packet from 0x%08x in Strict signature mode", p->from); + return false; + } + if (compatible) + return true; + + // In Balanced, preserve legacy unsigned-unicast compatibility and only reject a signable + // unsigned broadcast from a known signer. encodedDataSize is // the size of the encoded Data exactly as the sender built it (or 0 to size p->decoded // canonically); with no signature field present it is the unsigned base, and adding // XEDDSA_SIGNATURE_FIELD_BYTES mirrors the sender-side signedDataFits() gate per packet, - // whatever fields the Data carried. Unicast/PKI packets and broadcasts too big to carry a - // signature are never signed, so they must not be hard-failed here even for a known signer. - const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from); - if (node && nodeInfoLiteHasXeddsaSigned(node) && !p->pki_encrypted && isBroadcast(p->to)) { + // whatever fields the Data carried. Oversized broadcasts remain compatible. + if (nodeDB->hasSeenXeddsaSigner(p->from) && isBroadcast(p->to)) { if (encodedDataSize == 0 && !pb_get_encoded_size(&encodedDataSize, &meshtastic_Data_msg, &p->decoded)) return true; // can't size it; never drop on a sizing failure if (encodedDataSize + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN) { @@ -503,6 +643,60 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize) } #endif +static RoutingAuthVerdict evaluateRoutingAuth(const meshtastic_MeshPacket &wire, meshtastic_MeshPacket &authenticated) +{ + authenticated = wire; + routingAuthEvaluations++; + if (authenticated.which_payload_variant == meshtastic_MeshPacket_decoded_tag) { + // Already-decoded remote ingress (notably Portduino SimRadio) did not pass through a + // decryptor. Never trust serialized local authentication metadata on that boundary. + authenticated.pki_encrypted = false; + authenticated.public_key.size = 0; + authenticated.xeddsa_signed = false; +#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) + concurrency::LockGuard g(cryptLock); + if (!checkXeddsaReceivePolicy(&authenticated)) { + LOG_WARN("Already-decoded packet rejected by routing signature policy"); + return RoutingAuthVerdict::REJECT; + } +#endif + return RoutingAuthVerdict::ACCEPT; + } + const DecodeState state = perhapsDecode(&authenticated); + if (state == DecodeState::DECODE_POLICY_REJECT) { + LOG_WARN("Packet rejected by routing signature policy"); + return RoutingAuthVerdict::REJECT; + } + if (state == DecodeState::DECODE_FATAL) { + LOG_WARN("Fatal decode error during routing authentication"); + return RoutingAuthVerdict::REJECT; + } + if (state == DecodeState::DECODE_FAILURE) { + LOG_WARN("Decryptable packet failed routing authentication"); + return RoutingAuthVerdict::REJECT; + } + + // Only an explicit unknown-channel result remains eligible for opaque relay. + if (state == DecodeState::DECODE_OPAQUE) + return RoutingAuthVerdict::OPAQUE_RELAY_ONLY; + return RoutingAuthVerdict::ACCEPT; +} + +RoutingAuthVerdict passesRoutingAuthGate(const meshtastic_MeshPacket *p) +{ + // Routing still needs the original encrypted representation for byte-for-byte relay and for + // MQTT uplink. Authenticate a copy here; handleReceived() performs the normal in-place decode + // only after stateful routing filters have completed. + if (routingAuthCacheMatches(*p)) + return RoutingAuthVerdict::ACCEPT; + + meshtastic_MeshPacket authenticated = meshtastic_MeshPacket_init_zero; + const RoutingAuthVerdict verdict = evaluateRoutingAuth(*p, authenticated); + if (verdict == RoutingAuthVerdict::ACCEPT) + storeRoutingAuthCache(*p, authenticated); + return verdict; +} + DecodeState perhapsDecode(meshtastic_MeshPacket *p) { concurrency::LockGuard g(cryptLock); @@ -516,21 +710,29 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) return DecodeState::DECODE_SUCCESS; // If packet was already decoded just return + // Authentication metadata is local-only. Re-establish it below only after successful PKI decryption. + p->pki_encrypted = false; + p->public_key.size = 0; + p->xeddsa_signed = false; + size_t rawSize = p->encrypted.size; if (rawSize > sizeof(bytes)) { LOG_ERROR("Packet too large to attempt decryption! (rawSize=%d > 256)", rawSize); return DecodeState::DECODE_FATAL; } bool decrypted = false; + bool pkiAttempted = false; + bool matchedChannel = false; ChannelIndex chIndex = 0; #if !(MESHTASTIC_EXCLUDE_PKI) // Attempt PKI decryption first. The sender's key may come from the hot // store or the warm tier (nodes evicted from the hot store keep their key // there), so DMs from long-tail nodes still decrypt. meshtastic_NodeInfoLite_public_key_t fromKey = {0, {0}}; - if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && nodeDB->copyPublicKey(p->from, fromKey) && - nodeDB->getMeshNode(p->to) != nullptr && nodeDB->getMeshNode(p->to)->public_key.size > 0 && - rawSize > MESHTASTIC_PKC_OVERHEAD) { + if (!owner.is_licensed && p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && + nodeDB->copyPublicKey(p->from, fromKey) && nodeDB->getMeshNode(p->to) != nullptr && + nodeDB->getMeshNode(p->to)->public_key.size > 0 && rawSize > MESHTASTIC_PKC_OVERHEAD) { + pkiAttempted = true; LOG_DEBUG("Attempt PKI decryption"); if (crypto->decryptCurve25519(p->from, fromKey, p->id, rawSize, p->encrypted.bytes, bytes)) { @@ -564,6 +766,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) for (chIndex = 0; chIndex < channels.getNumChannels(); chIndex++) { // Try to use this hash/channel pair if (channels.decryptForHash(chIndex, p->channel)) { + matchedChannel = true; // we have to copy into a scratch buffer, because these bytes are a union with the decoded protobuf. Create a // fresh copy for each decrypt attempt. memcpy(bytes, p->encrypted.bytes, rawSize); @@ -605,7 +808,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) // MESHTASTIC_PKC_OVERHEAD subtraction preserves that, and PKI packets are unicast so the // downgrade predicate ignores them anyway). if (!checkXeddsaReceivePolicy(p, rawSize)) - return DecodeState::DECODE_FAILURE; + return DecodeState::DECODE_POLICY_REJECT; #endif /* Not actually ever used. @@ -660,7 +863,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) return DecodeState::DECODE_SUCCESS; } else { LOG_WARN("No suitable channel found for decoding, hash was 0x%x!", p->channel); - return DecodeState::DECODE_FAILURE; + return (matchedChannel || pkiAttempted) ? DecodeState::DECODE_FAILURE : DecodeState::DECODE_OPAQUE; } } @@ -698,12 +901,15 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) // verification at every XEdDSA-enabled receiver that knows our key. p->decoded.xeddsa_signature.size = 0; #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) - // Sign broadcast packets when the Data still fits a LoRa frame with the signature - // attached. This must be the exact encoded-size criterion, not a payload-size - // heuristic: a heuristic band where we sign-then-fail-TOO_LARGE breaks packets that + // Licensed packets stay plaintext, so sign both broadcasts and unicasts. Normal mode + // continues to sign broadcasts only. Use the exact encoded size: a payload-size heuristic + // where we sign-then-fail-TOO_LARGE breaks packets that // were deliverable unsigned, and perhapsDecode() applies the mirror-image rule when // deciding whether an unsigned broadcast from a known signer is a downgrade. - if (!p->pki_encrypted && isBroadcast(p->to) && signedDataFits(&p->decoded)) { + const bool signatureFits = signedDataFits(&p->decoded); + if (owner.is_licensed && p->decoded.portnum == meshtastic_PortNum_ADMIN_APP && !signatureFits) + return meshtastic_Routing_Error_TOO_LARGE; + if (!p->pki_encrypted && (owner.is_licensed || isBroadcast(p->to)) && signatureFits) { if (crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size, p->decoded.xeddsa_signature.bytes)) { p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE; @@ -848,11 +1054,9 @@ NodeNum Router::getNodeNum() * Handle any packet that is received by an interface on this node. * Note: some packets may merely being passed through this node and will be forwarded elsewhere. */ -void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) +void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src, bool routingAuthRequired) { bool skipHandle = false; - // Also, we should set the time from the ISR and it should have msec level resolution - p->rx_time = getValidTime(RTCQualityFromNet); // store the arrival timestamp for the phone // Store a copy of the encrypted packet for MQTT. // Local, not a class member: handleReceived re-enters itself when a module @@ -863,12 +1067,38 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) meshtastic_MeshPacket *p_encrypted = packetPool.allocCopy(*p); DEBUG_HEAP_AFTER("Router::handleReceived", p_encrypted); + // Consume the decoded/authenticated handoff after preserving the exact encrypted packet and + // before mutating any packet fields that participate in the exact cache match. + if (routingAuthRequired && !applyRoutingAuthCache(p)) { + meshtastic_MeshPacket authenticated = meshtastic_MeshPacket_init_zero; + if (evaluateRoutingAuth(*p, authenticated) != RoutingAuthVerdict::ACCEPT) { + LOG_WARN("Routing authentication handoff was lost and packet re-verification failed"); + packetPool.release(p_encrypted); + return; + } + *p = authenticated; + } + + // Also, we should set the time from the ISR and it should have msec level resolution. + // Keep the decoded working packet and encrypted MQTT copy on the same local arrival timestamp. + const uint32_t rxTime = getValidTime(RTCQualityFromNet); + p->rx_time = rxTime; + if (p_encrypted) + p_encrypted->rx_time = rxTime; + // Take those raw bytes and convert them back into a well structured protobuf we can understand auto decodedState = perhapsDecode(p); - if (decodedState == DecodeState::DECODE_FATAL) { + if (decodedState == DecodeState::DECODE_FATAL || decodedState == DecodeState::DECODE_POLICY_REJECT || + decodedState == DecodeState::DECODE_FAILURE) { // Fatal decoding error, we can't do anything with this packet - LOG_WARN("Fatal decode error, dropping packet"); - cancelSending(p->from, p->id); + LOG_WARN(decodedState == DecodeState::DECODE_POLICY_REJECT + ? "Packet rejected by signature policy" + : (decodedState == DecodeState::DECODE_FATAL ? "Fatal decode error, dropping packet" + : "Decryptable packet failed decoding, dropping packet")); + // A policy rejection is attacker-controlled input and must not cancel a valid pending + // transmission with the same (from, id). Preserve the pre-existing fatal-decode behavior. + if (decodedState == DecodeState::DECODE_FATAL) + cancelSending(p->from, p->id); skipHandle = true; } else if (decodedState == DecodeState::DECODE_SUCCESS) { // parsing was successful, queue for our recipient @@ -932,7 +1162,7 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) } else { // Mark as pki_encrypted if it is not yet decoded and MQTT encryption is also enabled, hash matches and it's a DM not // to us (because we would be able to decrypt it) - if (decodedState == DecodeState::DECODE_FAILURE && moduleConfig.mqtt.encryption_enabled && p->channel == 0x00 && + if (decodedState == DecodeState::DECODE_OPAQUE && moduleConfig.mqtt.encryption_enabled && p->channel == 0x00 && !isBroadcast(p->to) && !isToUs(p)) p_encrypted->pki_encrypted = true; // After potentially altering it, publish received message to MQTT if we're not the original transmitter of the packet @@ -981,6 +1211,7 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) #endif // assert(radioConfig.has_preferences); if (is_in_repeated(config.lora.ignore_incoming, p->from)) { + clearRoutingAuthCache(); LOG_DEBUG("Ignore msg, 0x%08x is in our ignore list", p->from); packetPool.release(p); return; @@ -988,30 +1219,49 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) meshtastic_NodeInfoLite const *node = nodeDB->getMeshNode(p->from); if (nodeInfoLiteIsIgnored(node)) { + clearRoutingAuthCache(); LOG_DEBUG("Ignore msg, 0x%08x is ignored", p->from); packetPool.release(p); return; } if (p->from == NODENUM_BROADCAST) { + clearRoutingAuthCache(); LOG_DEBUG("Ignore msg from broadcast address"); packetPool.release(p); return; } if (config.lora.ignore_mqtt && p->via_mqtt) { + clearRoutingAuthCache(); LOG_DEBUG("Msg came in via MQTT from 0x%08x", p->from); packetPool.release(p); return; } if (shouldDropPacketForPreHop(*p)) { + clearRoutingAuthCache(); logHopStartDrop(*p, "pre-hop drop"); packetPool.release(p); return; } + // Decrypt and authenticate before Reliable/Flooding/NextHop filters can update retry + // timers, packet history, implicit ACK state, cancellation, or relay queues. A packet for + // an unknown channel passes as opaque traffic and retains the existing relay behavior. + const auto authVerdict = passesRoutingAuthGate(p); + if (authVerdict == RoutingAuthVerdict::REJECT) { + packetPool.release(p); + return; + } + if (authVerdict == RoutingAuthVerdict::OPAQUE_RELAY_ONLY) { + relayOpaquePacket(p); + packetPool.release(p); + return; + } + if (shouldFilterReceived(p)) { + clearRoutingAuthCache(); LOG_DEBUG("Incoming msg was filtered from 0x%08x", p->from); packetPool.release(p); return; @@ -1019,6 +1269,6 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) // Note: we avoid calling shouldFilterReceived if we are supposed to ignore certain nodes - because some overrides might // cache/learn of the existence of nodes (i.e. FloodRouter) that they should not - handleReceived(p); + handleReceived(p, RX_SRC_RADIO, true); packetPool.release(p); } diff --git a/src/mesh/Router.h b/src/mesh/Router.h index 954b88f2410..cd82dd5eacf 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -100,6 +100,10 @@ class Router : protected concurrency::OSThread, protected PacketHistory before us */ uint32_t rxDupe = 0, txRelayCanceled = 0; +#ifdef PIO_UNIT_TESTING + void handleReceivedAfterRoutingGateForTest(meshtastic_MeshPacket *p) { handleReceived(p, RX_SRC_RADIO, true); } +#endif + protected: friend class RoutingModule; @@ -113,6 +117,9 @@ class Router : protected concurrency::OSThread, protected PacketHistory */ virtual bool shouldFilterReceived(const meshtastic_MeshPacket *p) { return false; } + /** Relay an opaque packet without admitting it to local routing/history state. */ + virtual bool relayOpaquePacket(const meshtastic_MeshPacket *) { return false; } + /** * Determine if hop_limit should be decremented for a relay operation. * Returns false (preserve hop_limit) only if all conditions are met: @@ -156,13 +163,14 @@ class Router : protected concurrency::OSThread, protected PacketHistory * Note: this packet will never be called for messages sent/generated by this node. * Note: this method will free the provided packet. */ - void handleReceived(meshtastic_MeshPacket *p, RxSource src = RX_SRC_RADIO); + void handleReceived(meshtastic_MeshPacket *p, RxSource src = RX_SRC_RADIO, bool routingAuthRequired = false); /** Frees the provided packet, and generates a NAK indicating the specifed error while sending */ void abortSendAndNak(meshtastic_Routing_Error err, meshtastic_MeshPacket *p); }; -enum DecodeState { DECODE_SUCCESS, DECODE_FAILURE, DECODE_FATAL }; +enum DecodeState { DECODE_SUCCESS, DECODE_FAILURE, DECODE_OPAQUE, DECODE_FATAL, DECODE_POLICY_REJECT }; +enum class RoutingAuthVerdict { ACCEPT, OPAQUE_RELAY_ONLY, REJECT }; /** FIXME - move this into a mesh packet class * Remove any encryption and decode the protobufs inside this packet (if necessary). @@ -171,27 +179,21 @@ enum DecodeState { DECODE_SUCCESS, DECODE_FAILURE, DECODE_FATAL }; */ DecodeState perhapsDecode(meshtastic_MeshPacket *p); +/** Apply receive authentication before routing state mutation; unknown-channel packets may remain opaque relay-only. */ +RoutingAuthVerdict passesRoutingAuthGate(const meshtastic_MeshPacket *p); + +#ifdef PIO_UNIT_TESTING +uint32_t routingAuthEvaluationCount(); +void resetRoutingAuthEvaluationCount(); +#endif + /** Return 0 for success or a Routing_Error code for failure */ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p); #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) -/** XEdDSA receive-side signature policy. When the packet carries a 64-byte signature *and* the - * sender's public key is known, verify it: on success learn the sender's signer bit, on failure - * drop. If the key is unknown the signature is left unverified and the packet passes. A signature - * of any other non-zero length is treated as malformed and dropped. For unsigned packets, enforce - * downgrade protection: drop a non-PKI broadcast from a known signer whose signed encoding would - * still fit a LoRa frame (unicast, PKI, and oversized broadcasts always pass). - * - * encodedDataSize is the wire size of the encoded Data as the sender built it; pass 0 to size - * p->decoded canonically instead (for already-decoded ingress such as plaintext-MQTT downlink, - * which bypasses perhapsDecode's crypto path). - * - * The caller MUST hold cryptLock: verification runs through the shared CryptoEngine key cache. - * (perhapsDecode already holds it; other call sites must take it themselves.) - * - * @return false if the packet must be dropped. - */ +/** Enforce the configured XEdDSA receive policy; zero encodedDataSize derives it canonically. + * The caller must hold cryptLock. Returns false when the packet must be dropped. */ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize = 0); #endif diff --git a/src/mesh/WarmNodeStore.h b/src/mesh/WarmNodeStore.h index 4cacbbc0ca3..eda6e777a55 100644 --- a/src/mesh/WarmNodeStore.h +++ b/src/mesh/WarmNodeStore.h @@ -58,7 +58,7 @@ static constexpr uint32_t WARM_PROT_SHIFT = 4; // bits [ static constexpr uint32_t WARM_PROT_MASK = 0x03u; // Protected category cached alongside role so consumers needn't re-derive the mapping. -enum class WarmProtected : uint8_t { None = 0, Role = 1, Flag = 2 }; +enum class WarmProtected : uint8_t { None = 0, Role = 1, Flag = 2, XeddsaSigner = 3 }; inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot) { diff --git a/src/mesh/generated/meshtastic/config.pb.cpp b/src/mesh/generated/meshtastic/config.pb.cpp index c554ca43ce9..52a591f3368 100644 --- a/src/mesh/generated/meshtastic/config.pb.cpp +++ b/src/mesh/generated/meshtastic/config.pb.cpp @@ -67,8 +67,6 @@ PB_BIND(meshtastic_Config_SessionkeyConfig, meshtastic_Config_SessionkeyConfig, - - diff --git a/src/mesh/generated/meshtastic/config.pb.h b/src/mesh/generated/meshtastic/config.pb.h index 1f49ef9f373..d6d149b253c 100644 --- a/src/mesh/generated/meshtastic/config.pb.h +++ b/src/mesh/generated/meshtastic/config.pb.h @@ -395,6 +395,18 @@ typedef enum _meshtastic_Config_BluetoothConfig_PairingMode { meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN = 2 } meshtastic_Config_BluetoothConfig_PairingMode; +/* Controls how the device authenticates remotely received mesh packets. */ +typedef enum _meshtastic_Config_SecurityConfig_PacketSignaturePolicy { + /* Prefer authenticated packets while retaining compatibility with unsigned packets from nodes not known to sign. + This is the default and rejects unsigned, signable broadcasts from nodes that have previously signed. */ + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED = 0, + /* Accept unsigned packets for maximum compatibility while still rejecting malformed or invalid signatures. */ + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE = 1, + /* Accept only packets authenticated by a verified XEdDSA signature or successful PKI decryption. + Unsigned, malformed, invalid, or unverifiable packets are ignored. */ + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT = 2 +} meshtastic_Config_SecurityConfig_PacketSignaturePolicy; + /* Struct definitions */ /* Configuration */ typedef struct _meshtastic_Config_DeviceConfig { @@ -685,6 +697,8 @@ typedef struct _meshtastic_Config_SecurityConfig { bool debug_log_api_enabled; /* Allow incoming device control over the insecure legacy admin channel. */ bool admin_channel_enabled; + /* Determines the packet signature policy applied to remotely received mesh packets. */ + meshtastic_Config_SecurityConfig_PacketSignaturePolicy packet_signature_policy; } meshtastic_Config_SecurityConfig; /* Blank config request, strictly for getting the session key */ @@ -778,6 +792,10 @@ extern "C" { #define _meshtastic_Config_BluetoothConfig_PairingMode_MAX meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN #define _meshtastic_Config_BluetoothConfig_PairingMode_ARRAYSIZE ((meshtastic_Config_BluetoothConfig_PairingMode)(meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN+1)) +#define _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_MIN meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED +#define _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_MAX meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT +#define _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_ARRAYSIZE ((meshtastic_Config_SecurityConfig_PacketSignaturePolicy)(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT+1)) + #define meshtastic_Config_DeviceConfig_role_ENUMTYPE meshtastic_Config_DeviceConfig_Role #define meshtastic_Config_DeviceConfig_rebroadcast_mode_ENUMTYPE meshtastic_Config_DeviceConfig_RebroadcastMode @@ -801,6 +819,7 @@ extern "C" { #define meshtastic_Config_BluetoothConfig_mode_ENUMTYPE meshtastic_Config_BluetoothConfig_PairingMode +#define meshtastic_Config_SecurityConfig_packet_signature_policy_ENUMTYPE meshtastic_Config_SecurityConfig_PacketSignaturePolicy @@ -814,7 +833,7 @@ extern "C" { #define meshtastic_Config_DisplayConfig_init_default {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0} #define meshtastic_Config_LoRaConfig_init_default {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0} #define meshtastic_Config_BluetoothConfig_init_default {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0} -#define meshtastic_Config_SecurityConfig_init_default {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0} +#define meshtastic_Config_SecurityConfig_init_default {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0, _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_MIN} #define meshtastic_Config_SessionkeyConfig_init_default {0} #define meshtastic_Config_init_zero {0, {meshtastic_Config_DeviceConfig_init_zero}} #define meshtastic_Config_DeviceConfig_init_zero {_meshtastic_Config_DeviceConfig_Role_MIN, 0, 0, 0, _meshtastic_Config_DeviceConfig_RebroadcastMode_MIN, 0, 0, 0, 0, "", 0, _meshtastic_Config_DeviceConfig_BuzzerMode_MIN} @@ -825,7 +844,7 @@ extern "C" { #define meshtastic_Config_DisplayConfig_init_zero {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0} #define meshtastic_Config_LoRaConfig_init_zero {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0} #define meshtastic_Config_BluetoothConfig_init_zero {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0} -#define meshtastic_Config_SecurityConfig_init_zero {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0} +#define meshtastic_Config_SecurityConfig_init_zero {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0, _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_MIN} #define meshtastic_Config_SessionkeyConfig_init_zero {0} /* Field tags (for use in manual encoding/decoding) */ @@ -921,6 +940,7 @@ extern "C" { #define meshtastic_Config_SecurityConfig_serial_enabled_tag 5 #define meshtastic_Config_SecurityConfig_debug_log_api_enabled_tag 6 #define meshtastic_Config_SecurityConfig_admin_channel_enabled_tag 8 +#define meshtastic_Config_SecurityConfig_packet_signature_policy_tag 9 #define meshtastic_Config_device_tag 1 #define meshtastic_Config_position_tag 2 #define meshtastic_Config_power_tag 3 @@ -1082,7 +1102,8 @@ X(a, STATIC, REPEATED, BYTES, admin_key, 3) \ X(a, STATIC, SINGULAR, BOOL, is_managed, 4) \ X(a, STATIC, SINGULAR, BOOL, serial_enabled, 5) \ X(a, STATIC, SINGULAR, BOOL, debug_log_api_enabled, 6) \ -X(a, STATIC, SINGULAR, BOOL, admin_channel_enabled, 8) +X(a, STATIC, SINGULAR, BOOL, admin_channel_enabled, 8) \ +X(a, STATIC, SINGULAR, UENUM, packet_signature_policy, 9) #define meshtastic_Config_SecurityConfig_CALLBACK NULL #define meshtastic_Config_SecurityConfig_DEFAULT NULL @@ -1126,7 +1147,7 @@ extern const pb_msgdesc_t meshtastic_Config_SessionkeyConfig_msg; #define meshtastic_Config_NetworkConfig_size 204 #define meshtastic_Config_PositionConfig_size 62 #define meshtastic_Config_PowerConfig_size 52 -#define meshtastic_Config_SecurityConfig_size 178 +#define meshtastic_Config_SecurityConfig_size 180 #define meshtastic_Config_SessionkeyConfig_size 0 #define meshtastic_Config_size 207 diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index c838dc21d85..059398cf057 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -452,7 +452,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; /* Maximum encoded size of messages (where known) */ /* meshtastic_NodeDatabase_size depends on runtime parameters */ #define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size -#define meshtastic_BackupPreferences_size 2738 +#define meshtastic_BackupPreferences_size 2740 #define meshtastic_ChannelFile_size 718 #define meshtastic_DeviceState_size 1944 #define meshtastic_NodeEnvironmentEntry_size 170 diff --git a/src/mesh/generated/meshtastic/localonly.pb.h b/src/mesh/generated/meshtastic/localonly.pb.h index 6fcaed30617..c560d5447ed 100644 --- a/src/mesh/generated/meshtastic/localonly.pb.h +++ b/src/mesh/generated/meshtastic/localonly.pb.h @@ -211,7 +211,7 @@ extern const pb_msgdesc_t meshtastic_LocalModuleConfig_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_MAX_SIZE meshtastic_LocalModuleConfig_size -#define meshtastic_LocalConfig_size 757 +#define meshtastic_LocalConfig_size 759 #define meshtastic_LocalModuleConfig_size 1126 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 98798173c85..23a9abdffd9 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -1395,6 +1395,9 @@ typedef struct _meshtastic_DeviceMetadata { /* Bit field of boolean for excluded modules (bitwise OR of ExcludedModules) */ uint32_t excluded_modules; + /* Indicates whether this firmware build includes XEdDSA packet signature verification. + This is a read-only capability and must be false when XEdDSA is not compiled in. */ + bool has_xeddsa; } meshtastic_DeviceMetadata; /* A distinct set of legal modem presets shared by one or more LoRa regions. @@ -1743,7 +1746,7 @@ extern "C" { #define meshtastic_Compressed_init_default {_meshtastic_PortNum_MIN, {0, {0}}} #define meshtastic_NeighborInfo_init_default {0, 0, 0, 0, {meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default}} #define meshtastic_Neighbor_init_default {0, 0, 0, 0} -#define meshtastic_DeviceMetadata_init_default {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0} +#define meshtastic_DeviceMetadata_init_default {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0, 0} #define meshtastic_LoRaPresetGroup_init_default {0, {_meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN}, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0} #define meshtastic_LoRaRegionPresets_init_default {_meshtastic_Config_LoRaConfig_RegionCode_MIN, 0} #define meshtastic_LoRaRegionPresetMap_init_default {0, {meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default}, 0, {meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default}} @@ -1782,7 +1785,7 @@ extern "C" { #define meshtastic_Compressed_init_zero {_meshtastic_PortNum_MIN, {0, {0}}} #define meshtastic_NeighborInfo_init_zero {0, 0, 0, 0, {meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero}} #define meshtastic_Neighbor_init_zero {0, 0, 0, 0} -#define meshtastic_DeviceMetadata_init_zero {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0} +#define meshtastic_DeviceMetadata_init_zero {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0, 0} #define meshtastic_LoRaPresetGroup_init_zero {0, {_meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN}, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0} #define meshtastic_LoRaRegionPresets_init_zero {_meshtastic_Config_LoRaConfig_RegionCode_MIN, 0} #define meshtastic_LoRaRegionPresetMap_init_zero {0, {meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero}, 0, {meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero}} @@ -1985,6 +1988,7 @@ extern "C" { #define meshtastic_DeviceMetadata_hasRemoteHardware_tag 10 #define meshtastic_DeviceMetadata_hasPKC_tag 11 #define meshtastic_DeviceMetadata_excluded_modules_tag 12 +#define meshtastic_DeviceMetadata_has_xeddsa_tag 14 #define meshtastic_LoRaPresetGroup_presets_tag 1 #define meshtastic_LoRaPresetGroup_default_preset_tag 2 #define meshtastic_LoRaPresetGroup_licensed_only_tag 3 @@ -2403,7 +2407,8 @@ X(a, STATIC, SINGULAR, UINT32, position_flags, 8) \ X(a, STATIC, SINGULAR, UENUM, hw_model, 9) \ X(a, STATIC, SINGULAR, BOOL, hasRemoteHardware, 10) \ X(a, STATIC, SINGULAR, BOOL, hasPKC, 11) \ -X(a, STATIC, SINGULAR, UINT32, excluded_modules, 12) +X(a, STATIC, SINGULAR, UINT32, excluded_modules, 12) \ +X(a, STATIC, SINGULAR, BOOL, has_xeddsa, 14) #define meshtastic_DeviceMetadata_CALLBACK NULL #define meshtastic_DeviceMetadata_DEFAULT NULL @@ -2552,7 +2557,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_ClientNotification_size 482 #define meshtastic_Compressed_size 239 #define meshtastic_Data_size 335 -#define meshtastic_DeviceMetadata_size 54 +#define meshtastic_DeviceMetadata_size 56 #define meshtastic_DuplicatedPublicKey_size 0 #define meshtastic_FileInfo_size 236 #define meshtastic_FromRadio_size 510 diff --git a/src/mesh/udp/UdpMulticastHandler.h b/src/mesh/udp/UdpMulticastHandler.h index 625dc008ac7..93f5dd6ac78 100644 --- a/src/mesh/udp/UdpMulticastHandler.h +++ b/src/mesh/udp/UdpMulticastHandler.h @@ -80,9 +80,9 @@ class UdpMulticastHandler final return; } mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP; - // Preserve the whole MeshPacket as received: while payload_variant is encrypted, `channel` is a hash (and is 0 for - // PKI DMs), so it must be copied verbatim for the router to attempt PKI/channel decryption. Keep - // pki_encrypted/public_key too so downstream auth/metadata can reflect PKI usage correctly. + // Authentication metadata is local-only; Router re-establishes it after successful PKI decryption. + mp.pki_encrypted = false; + mp.public_key.size = 0; UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp); // Unset received SNR/RSSI p->rx_snr = 0; diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index a3ee10b121a..5bf97839811 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -8,6 +8,7 @@ #include "SPILock.h" #include "input/InputBroker.h" #include "meshUtils.h" +#include #include #include // for better whitespace handling #if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WIFI @@ -62,6 +63,17 @@ AdminModule *adminModule; bool hasOpenEditTransaction; +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) +static bool licensedIdentityWillMigrate() +{ + if (config.security.private_key.size != 32 || config.security.public_key.size != 32) + return true; + if (nodeDB->checkLowEntropyPublicKey(config.security.public_key)) + return true; + return crc32Buffer(config.security.public_key.bytes, config.security.public_key.size) != nodeDB->getNodeNum(); +} +#endif + /// A special reserved string to indicate strings we can not share with external nodes. We will use this 'reserved' word instead. /// Also, to make setting work correctly, if someone tries to set a string to this reserved value we assume they don't really want /// a change. @@ -124,9 +136,31 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta } #endif meshtastic_Channel *ch = &channels.getByIndex(mp.channel); - // Could tighten this up further by tracking the last public_key we went an AdminMessage request to - // and only allowing responses from that remote. - if (messageIsResponse(r)) { + const bool licensedRemote = owner.is_licensed && mp.from != 0; + bool authorizedLicensedSigner = false; + // Could tighten responses further by tracking the last public key queried. + if (licensedRemote) { + const bool directedAdmin = mp.to == nodeDB->getNodeNum() && !isBroadcast(mp.to) && + mp.decoded.portnum == meshtastic_PortNum_ADMIN_APP && !mp.pki_encrypted; + if (!directedAdmin || !mp.xeddsa_signed || mp.public_key.size != 32) { + LOG_INFO("Ignore licensed admin payload without a directed Router-verified signature"); + myReply = allocErrorResponse(meshtastic_Routing_Error_NOT_AUTHORIZED, &mp); + return handled; + } + for (const auto &adminKey : config.security.admin_key) { + if (adminKey.size == 32 && memcmp(mp.public_key.bytes, adminKey.bytes, 32) == 0) { + authorizedLicensedSigner = true; + break; + } + } + if (!messageIsResponse(r) && !authorizedLicensedSigner) { + LOG_INFO("Received signed licensed admin payload from a non-allowlisted key"); + myReply = allocErrorResponse(meshtastic_Routing_Error_ADMIN_PUBLIC_KEY_UNAUTHORIZED, &mp); + return handled; + } + LOG_INFO("Signed licensed admin payload with Router-verified sender"); + LOG_DEBUG("Allow authenticated licensed admin payload"); + } else if (messageIsResponse(r)) { LOG_DEBUG("Allow admin response message"); } else if (mp.from == 0) { // Local admin from a BLE/USB/TCP client. from == 0 cannot arrive from the @@ -177,7 +211,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta // that pointer is unrelated, so the path was unsafe.) // Automatically favorite the node that is using the admin key - auto remoteNode = nodeDB->getMeshNode(mp.from); + auto remoteNode = nodeDB ? nodeDB->getMeshNode(mp.from) : nullptr; if (remoteNode && !nodeInfoLiteIsFavorite(remoteNode)) { if (config.device.role == meshtastic_Config_DeviceConfig_Role_CLIENT_BASE) { // Special case for CLIENT_BASE: is_favorite has special meaning, and we don't want to automatically set it @@ -695,7 +729,10 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta if (mp.decoded.want_response && !myReply) { myReply = allocErrorResponse(meshtastic_Routing_Error_NONE, &mp); } - if (mp.pki_encrypted && myReply) { + if (licensedRemote && myReply) { + myReply->to = mp.from; + myReply->pki_encrypted = false; + } else if (mp.pki_encrypted && myReply) { myReply->pki_encrypted = true; } return handled; @@ -731,6 +768,8 @@ void AdminModule::handleGetModuleConfigResponse(const meshtastic_MeshPacket &mp, void AdminModule::handleSetOwner(const meshtastic_User &o) { int changed = 0; + bool identityUpdated = false; + bool channelsSanitized = false; if (*o.long_name) { // Apps built against the older 39-byte limit may send longer names; clamp @@ -749,15 +788,28 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) owner.short_name[sizeof(owner.short_name) - 1] = '\0'; sanitizeUtf8(owner.short_name, sizeof(owner.short_name)); } - snprintf(owner.id, sizeof(owner.id), "!%08x", nodeDB->getNodeNum()); - if (owner.is_licensed != o.is_licensed) { changed = 1; +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + const bool identityWillMigrate = + o.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && licensedIdentityWillMigrate(); + if (identityWillMigrate) + sendWarning(licensedIdentityMigrationMessage); +#endif owner.is_licensed = o.is_licensed; if (channels.ensureLicensedOperation()) { sendWarning(licensedModeMessage); + channelsSanitized = true; + } +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + if (owner.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + identityUpdated = nodeDB->generateCryptoKeyPair(); + if (identityWillMigrate) + nodeDB->licensedIdentityMigrationPending = false; } +#endif } + snprintf(owner.id, sizeof(owner.id), "!%08x", nodeDB->getNodeNum()); if (owner.has_is_unmessagable != o.has_is_unmessagable || (o.has_is_unmessagable && owner.is_unmessagable != o.is_unmessagable)) { changed = 1; @@ -767,7 +819,8 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) if (changed) { // If nothing really changed, don't broadcast on the network or write to flash service->reloadOwner(!hasOpenEditTransaction); - saveChanges(SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE); + saveChanges(SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE | (identityUpdated ? SEGMENT_CONFIG : 0) | + (channelsSanitized ? SEGMENT_CHANNELS : 0)); } } @@ -934,7 +987,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) // If we're setting region for the first time, init the region and regenerate the keys if (isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (crypto) { + if (crypto && !owner.is_licensed) { crypto->ensurePkiKeys(config.security, owner); } #endif @@ -947,6 +1000,17 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) } // Ensure initRegion() uses the newly validated region config.lora.region = validatedLora.region; +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + if (owner.is_licensed && isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + const bool identityWillMigrate = licensedIdentityWillMigrate(); + if (identityWillMigrate) + sendWarning(licensedIdentityMigrationMessage); + nodeDB->generateCryptoKeyPair(); + changes |= SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; + if (identityWillMigrate) + nodeDB->licensedIdentityMigrationPending = false; + } +#endif initRegion(); if (getEffectiveDutyCycle() < 100) { validatedLora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit @@ -955,7 +1019,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) // Default root is in use, so subscribe to the appropriate MQTT topic for this region snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name); } - changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG; + changes |= SEGMENT_CONFIG | SEGMENT_MODULECONFIG; } else { // Region validation has failed, so just copy all of the old config over the new config validatedLora = oldLoraConfig; @@ -1061,6 +1125,16 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) incoming.private_key = config.security.private_key; incoming.public_key = config.security.public_key; } +#if MESHTASTIC_EXCLUDE_PKI || MESHTASTIC_EXCLUDE_XEDDSA + if (incoming.packet_signature_policy != + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED) { + incoming.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED; + const char *warning = "Packet authenticity policy is unavailable on this firmware build"; + LOG_WARN(warning); + sendWarning(warning); + } +#endif config.security = incoming; #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) && !(MESHTASTIC_EXCLUDE_PKI) // First provisioning (no key) generates one; a private key supplied without its public key derives it. @@ -1682,6 +1756,9 @@ void AdminModule::reboot(int32_t seconds) void AdminModule::saveChanges(int saveWhat, bool shouldReboot) { +#ifdef PIO_UNIT_TESTING + lastSaveWhatForTest = saveWhat; +#endif if (!hasOpenEditTransaction) { LOG_INFO("Save changes to disk"); service->reloadConfig(saveWhat); // Calls saveToDisk among other things @@ -1735,6 +1812,17 @@ void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY; // Remove PSK of primary channel for plaintext amateur usage +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + if (config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + const bool identityWillMigrate = licensedIdentityWillMigrate(); + if (identityWillMigrate) + sendWarning(licensedIdentityMigrationMessage); + nodeDB->generateCryptoKeyPair(); + if (identityWillMigrate) + nodeDB->licensedIdentityMigrationPending = false; + } +#endif + if (channels.ensureLicensedOperation()) { sendWarning(licensedModeMessage); } @@ -1756,11 +1844,13 @@ AdminModule::AdminModule() : ProtobufModule("Admin", meshtastic_PortNum_ADMIN_AP void AdminModule::setPassKey(meshtastic_AdminMessage *res) { - if (session_time == 0 || millis() / 1000 > session_time + 150) { + const uint now = millis() / 1000; + if (!sessionInitialized || now - session_time > 150) { for (int i = 0; i < 8; i++) { session_passkey[i] = random(); } - session_time = millis() / 1000; + session_time = now; + sessionInitialized = true; } memcpy(res->session_passkey.bytes, session_passkey, 8); res->session_passkey.size = 8; @@ -1773,7 +1863,7 @@ bool AdminModule::checkPassKey(meshtastic_AdminMessage *res) { // check that the key in the packet is still valid printBytes("Incoming session key: ", res->session_passkey.bytes, 8); printBytes("Expected session key: ", session_passkey, 8); - return (session_time + 300 > millis() / 1000 && res->session_passkey.size == 8 && + return (sessionInitialized && millis() / 1000 - session_time < 300 && res->session_passkey.size == 8 && memcmp(res->session_passkey.bytes, session_passkey, 8) == 0); } diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index 468e020eaf0..6e5cf6c56a3 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -39,9 +39,13 @@ class AdminModule : public ProtobufModule, public Obser private: bool hasOpenEditTransaction = false; +#ifdef PIO_UNIT_TESTING + int lastSaveWhatForTest = 0; +#endif uint8_t session_passkey[8] = {0}; uint session_time = 0; + bool sessionInitialized = false; void saveChanges(int saveWhat, bool shouldReboot = true); @@ -94,9 +98,12 @@ class AdminModule : public ProtobufModule, public Obser static constexpr const char *licensedModeMessage = "Licensed mode activated, removing admin channel and encryption from all channels"; +static constexpr const char *licensedIdentityMigrationMessage = + "Licensed signing requires an identity key; this node identity will change after key generation"; + static constexpr const char *publicChannelPrecisionMessage = "Precise position is not allowed on a public (open / known-key) channel; reduced to coarse precision"; extern AdminModule *adminModule; -void disableBluetooth(); \ No newline at end of file +void disableBluetooth(); diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index c86c54aff34..f32db558b92 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -49,15 +49,6 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes LOG_WARN("Invalid nodeInfo detected, is_licensed mismatch!"); return true; } - NodeNum sourceNum = getFrom(&mp); - const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(sourceNum); - // Broadcasts only: senders never sign unicast NodeInfo, so dropping it would break exchanges - // with signer nodes. Backstops ingress that skips Router's downgrade drop (e.g. decoded MQTT). - if (node && nodeInfoLiteHasXeddsaSigned(node) && !mp.xeddsa_signed && isBroadcast(mp.to)) { - LOG_WARN("Dropping unsigned NodeInfo broadcast from node 0x%08x that previously signed", sourceNum); - return true; - } - // Coerce user.id to be derived from the node number snprintf(p.id, sizeof(p.id), "!%08x", getFrom(&mp)); @@ -167,14 +158,8 @@ meshtastic_MeshPacket *NodeInfoModule::allocReply() ignoreRequest = true; return NULL; } else { - ignoreRequest = false; // Don't ignore requests anymore - meshtastic_User u = owner; // deliberate copy: the licensed strip below must not clobber the global owner state - - // Strip the public key if the user is licensed - if (u.is_licensed && u.public_key.size > 0) { - memset(u.public_key.bytes, 0, sizeof(u.public_key.bytes)); - u.public_key.size = 0; - } + ignoreRequest = false; // Don't ignore requests anymore + meshtastic_User u = owner; // FIXME: Clear the user.id field since it should be derived from node number on the receiving end // u.id[0] = '\0'; @@ -232,4 +217,4 @@ int32_t NodeInfoModule::runOnce() sendOurNodeInfo(NODENUM_BROADCAST, requestReplies); // Send our info (don't request replies) } return Default::getConfiguredOrDefaultMs(config.device.node_info_broadcast_secs, default_node_info_broadcast_secs); -} \ No newline at end of file +} diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index b6f0ce24d5b..8892b5bf6ea 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -92,7 +92,11 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) if (isFromUs(e.packet)) { auto pAck = routingModule->allocAckNak(meshtastic_Routing_Error_NONE, getFrom(e.packet), e.packet->id, ch.index); pAck->transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; - router->sendLocal(pAck); + // sendLocal consumes packets sent to a live interface, but returns SHOULD_RELEASE + // when it handled a local delivery synchronously. Match MeshService::sendToMesh's + // ownership contract so the MQTT acknowledgement cannot leak on the local path. + if (router->sendLocal(pAck) == ERRNO_SHOULD_RELEASE) + packetPool.release(pAck); } else { LOG_INFO("Ignore downlink message we originally sent"); } @@ -117,11 +121,18 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) p->hop_limit = e.packet->hop_limit; p->hop_start = e.packet->hop_start; p->want_ack = e.packet->want_ack; - p->via_mqtt = true; // Mark that the packet was received via MQTT + p->via_mqtt = true; // Mark that the packet was received via MQTT + p->pki_encrypted = false; // Only local AES-CCM decryption may establish PKI authentication. p->transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; p->which_payload_variant = e.packet->which_payload_variant; memcpy(&p->decoded, &e.packet->decoded, std::max(sizeof(p->decoded), sizeof(p->encrypted))); + // A decoded MQTT packet has already crossed the broker boundary, so authenticate it in place + // before handing it to the router. The routing-auth cache intentionally keeps an authenticated + // copy separate for encrypted packets, but a cache hit alone does not update this packet's + // xeddsa_signed marker (which is part of the downstream message metadata). + bool decodedAuthChecked = false; + if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) { if (moduleConfig.mqtt.encryption_enabled) { LOG_INFO("Ignore decoded message on MQTT, encryption is enabled"); @@ -138,14 +149,14 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) // protection for known signers. Without this, a peer on a plaintext broker could // impersonate a signing node with unsigned broadcasts. Hold cryptLock like the RF path // (perhapsDecode) does - checkXeddsaReceivePolicy -> xeddsa_verify mutates shared - // CryptoEngine cache state, and MQTT ingress can run on a different task. - { - concurrency::LockGuard g(cryptLock); - if (!checkXeddsaReceivePolicy(p.get())) { - LOG_INFO("Ignore decoded message failing XEdDSA policy"); - return; - } + // CryptoEngine cache state, and MQTT ingress can run on a different task. The in-place + // call preserves the verified xeddsa_signed marker for downstream routing/UI consumers. + concurrency::LockGuard g(cryptLock); + if (!checkXeddsaReceivePolicy(p.get())) { + LOG_INFO("Ignore decoded message failing XEdDSA policy"); + return; } + decodedAuthChecked = true; #endif } @@ -157,8 +168,7 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) // likely they discovered each other via a channel we have downlink enabled for if (isToUs(p.get()) || (nodeInfoLiteHasUser(tx) && nodeInfoLiteHasUser(rx))) router->enqueueReceivedMessage(p.release()); - } else if (router && - perhapsDecode(p.get()) == DecodeState::DECODE_SUCCESS) // ignore messages if we don't have the channel key + } else if (router && (decodedAuthChecked || passesRoutingAuthGate(p.get()) == RoutingAuthVerdict::ACCEPT)) router->enqueueReceivedMessage(p.release()); } diff --git a/test/support/AdminModuleTestShim.h b/test/support/AdminModuleTestShim.h index 7dba0677e9b..887af03ff14 100644 --- a/test/support/AdminModuleTestShim.h +++ b/test/support/AdminModuleTestShim.h @@ -10,9 +10,21 @@ class AdminModuleTestShim : public AdminModule using AdminModule::handleReceivedProtobuf; using AdminModule::handleSetConfig; using AdminModule::handleSetModuleConfig; + using AdminModule::handleSetOwner; // With an "open edit transaction" saveChanges() is a pure no-op: no reloadConfig/saveToDisk/reboot. void deferSaves() { hasOpenEditTransaction = true; } + int savedSegments() const { return lastSaveWhatForTest; } + meshtastic_MeshPacket *reply() const { return myReply; } + void generateSession(meshtastic_AdminMessage *message) { setPassKey(message); } + bool sessionValid(meshtastic_AdminMessage *message) { return checkPassKey(message); } + void clearSession() + { + memset(session_passkey, 0, sizeof(session_passkey)); + session_time = 0; + sessionInitialized = false; + } + void expireSession() { session_time = millis() / 1000 - 301; } // Setters may allocate an error reply from packetPool; drain it each iteration or the pool leaks. void drainReply() diff --git a/test/support/MockMeshService.h b/test/support/MockMeshService.h index 6bfeed07792..1f863ef23de 100644 --- a/test/support/MockMeshService.h +++ b/test/support/MockMeshService.h @@ -6,5 +6,11 @@ class MockMeshService : public MeshService { public: - void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); } + void sendClientNotification(meshtastic_ClientNotification *n) override + { + notificationCount++; + releaseClientNotificationToPool(n); + } + + uint32_t notificationCount = 0; }; diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 8a839f9345c..7c93976885f 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -11,13 +11,18 @@ * 6. Channel spacing calculation (placeholder for future protobuf changes) */ +#include "Channels.h" #include "DisplayFormatters.h" +#include "FSCommon.h" #include "MeshRadio.h" #include "MeshService.h" #include "NodeDB.h" #include "RadioInterface.h" #include "TestUtil.h" #include "modules/AdminModule.h" +#include "modules/NodeInfoModule.h" +#include +#include #include #include "meshtastic/config.pb.h" @@ -927,6 +932,134 @@ static void test_channelSpacingCalculation_placeholder() // AdminModuleTestShim comes from test/support - the friend seam AdminModule.h declares. static AdminModuleTestShim *testAdmin; +static bool adminRadioGlobalsActive; +static NodeDB *savedNodeDB; +static NodeDB *replacementNodeDB; +static NodeInfoModule *savedNodeInfoModule; +static meshtastic_DeviceState savedDeviceState; +static meshtastic_User savedOwner; +static meshtastic_LocalConfig savedConfig; +static meshtastic_ChannelFile savedChannelFile; + +static void replaceAdminRadioGlobals() +{ + savedNodeDB = nodeDB; + savedNodeInfoModule = nodeInfoModule; + savedDeviceState = devicestate; + savedOwner = owner; + savedConfig = config; + savedChannelFile = channelFile; + replacementNodeDB = new NodeDB(); + nodeDB = replacementNodeDB; + adminRadioGlobalsActive = true; +} + +static void restoreAdminRadioGlobals() +{ + if (!adminRadioGlobalsActive) + return; + nodeInfoModule = savedNodeInfoModule; + nodeDB = savedNodeDB; + delete replacementNodeDB; + replacementNodeDB = nullptr; + devicestate = savedDeviceState; + owner = savedOwner; + config = savedConfig; + channelFile = savedChannelFile; + initRegion(); + adminRadioGlobalsActive = false; +} + +static void installEncryptedAndAdminChannels() +{ + channels.initDefaults(); + meshtastic_Channel admin = meshtastic_Channel_init_zero; + admin.index = 1; + admin.role = meshtastic_Channel_Role_SECONDARY; + admin.has_settings = true; + strncpy(admin.settings.name, Channels::adminChannel, sizeof(admin.settings.name)); + admin.settings.psk.size = 16; + memset(admin.settings.psk.bytes, 0xA5, admin.settings.psk.size); + channels.setChannel(admin); + + meshtastic_Channel secondary = meshtastic_Channel_init_zero; + secondary.index = 2; + secondary.role = meshtastic_Channel_Role_SECONDARY; + secondary.has_settings = true; + strncpy(secondary.settings.name, "private", sizeof(secondary.settings.name)); + secondary.settings.psk.size = 32; + memset(secondary.settings.psk.bytes, 0x5A, secondary.settings.psk.size); + channels.setChannel(secondary); +} + +static void assertLicensedChannelsSanitized() +{ + TEST_ASSERT_EQUAL(0, channels.getByIndex(0).settings.psk.size); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_DISABLED, channels.getByIndex(1).role); + TEST_ASSERT_EQUAL(0, channels.getByIndex(1).settings.psk.size); + TEST_ASSERT_EQUAL(0, channels.getByIndex(2).settings.psk.size); +} + +static void test_handleSetOwner_persistsLicensedChannelSanitation() +{ + replaceAdminRadioGlobals(); + owner = meshtastic_User_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + installEncryptedAndAdminChannels(); + + meshtastic_User licensed = meshtastic_User_init_zero; + licensed.is_licensed = true; + testAdmin->deferSaves(); + nodeInfoModule = reinterpret_cast(1); // reloadOwner(false) only checks presence + testAdmin->handleSetOwner(licensed); + + TEST_ASSERT_TRUE(testAdmin->savedSegments() & SEGMENT_CHANNELS); + assertLicensedChannelsSanitized(); + + uint8_t encoded[meshtastic_ChannelFile_size]; + const size_t encodedSize = pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_ChannelFile_msg, &channelFile); + TEST_ASSERT_GREATER_THAN(0, encodedSize); + meshtastic_ChannelFile reloaded = meshtastic_ChannelFile_init_zero; + TEST_ASSERT_TRUE(pb_decode_from_bytes(encoded, encodedSize, &meshtastic_ChannelFile_msg, &reloaded)); + channelFile = reloaded; + assertLicensedChannelsSanitized(); + TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "sanitized reload must not trigger another persistence write"); +} + +static void test_bootDefense_sanitizesStaleLicensedChannelsOnce() +{ + replaceAdminRadioGlobals(); + owner = meshtastic_User_init_zero; + owner.is_licensed = true; + installEncryptedAndAdminChannels(); + + TEST_ASSERT_TRUE(channels.ensureLicensedOperation()); + assertLicensedChannelsSanitized(); + TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "boot sanitation must be idempotent"); +} + +static void test_restorePreferences_sanitizesLicensedBackupBeforeReturn() +{ + replaceAdminRadioGlobals(); + + owner = meshtastic_User_init_zero; + owner.is_licensed = true; + installEncryptedAndAdminChannels(); + TEST_ASSERT_TRUE(nodeDB->backupPreferences(meshtastic_AdminMessage_BackupLocation_FLASH)); + + owner.is_licensed = false; + channels.initDefaults(); + TEST_ASSERT_TRUE( + nodeDB->restorePreferences(meshtastic_AdminMessage_BackupLocation_FLASH, SEGMENT_DEVICESTATE | SEGMENT_CHANNELS)); + TEST_ASSERT_TRUE(owner.is_licensed); + assertLicensedChannelsSanitized(); + TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "restored licensed channels must remain sanitized"); + + devicestate = savedDeviceState; + channelFile = savedChannelFile; + nodeDB->saveToDisk(SEGMENT_DEVICESTATE | SEGMENT_CHANNELS); + FSCom.remove(backupFileName); +} static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode region, bool usePreset, meshtastic_Config_LoRaConfig_ModemPreset preset) @@ -939,6 +1072,27 @@ static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCo return c; } +static void test_handleSetConfig_persistsLicensedFirstRegionIdentity() +{ + replaceAdminRadioGlobals(); + owner = meshtastic_User_init_zero; + owner.is_licensed = true; + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + initRegion(); + + testAdmin->deferSaves(); + const meshtastic_Config c = + makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + testAdmin->handleSetConfig(c, false); + + const int expectedSegments = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; + TEST_ASSERT_EQUAL_INT(expectedSegments, testAdmin->savedSegments()); + TEST_ASSERT_EQUAL(32, config.security.private_key.size); + TEST_ASSERT_EQUAL(32, owner.public_key.size); +} + static void test_handleSetConfig_fromOthers_invalidPresetRejected() { // Set up a known-good baseline in the global config @@ -1240,6 +1394,188 @@ static void test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejecte TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset); } +static meshtastic_MeshPacket makeRemoteAdminPacket(NodeNum destination) +{ + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + packet.from = 0x11223344; + packet.to = destination; + packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + packet.decoded.portnum = meshtastic_PortNum_ADMIN_APP; + return packet; +} + +static meshtastic_AdminMessage makeOwnerRequest() +{ + meshtastic_AdminMessage message = meshtastic_AdminMessage_init_zero; + message.which_payload_variant = meshtastic_AdminMessage_get_owner_request_tag; + message.get_owner_request = true; + return message; +} + +static void setVerifiedPacketSigner(meshtastic_MeshPacket &packet, const uint8_t key[32]) +{ + packet.xeddsa_signed = true; + packet.public_key.size = 32; + memcpy(packet.public_key.bytes, key, 32); +} + +static void test_adminSession_zeroBeforeGenerationIsRejected() +{ + testAdmin->clearSession(); + meshtastic_AdminMessage message = meshtastic_AdminMessage_init_zero; + message.session_passkey.size = 8; + memset(message.session_passkey.bytes, 0, 8); + TEST_ASSERT_FALSE(testAdmin->sessionValid(&message)); +} + +static void test_adminSession_generatedKeyIsValidThenExpires() +{ + testAdmin->clearSession(); + meshtastic_AdminMessage message = meshtastic_AdminMessage_init_zero; + testAdmin->generateSession(&message); + TEST_ASSERT_TRUE(testAdmin->sessionValid(&message)); + testAdmin->expireSession(); + TEST_ASSERT_FALSE(testAdmin->sessionValid(&message)); +} + +static void test_licensedSignedAdminGetterUsesTransientAllowlistedKey() +{ + owner.is_licensed = true; + uint8_t key[32]; + memset(key, 0x51, sizeof(key)); + config.security.admin_key[0].size = 32; + memcpy(config.security.admin_key[0].bytes, key, sizeof(key)); + meshtastic_MeshPacket packet = makeRemoteAdminPacket(nodeDB->getNodeNum()); + packet.decoded.want_response = true; + packet.xeddsa_signed = true; + packet.public_key.size = 32; + memcpy(packet.public_key.bytes, key, sizeof(key)); + meshtastic_AdminMessage request = makeOwnerRequest(); + testAdmin->handleReceivedProtobuf(packet, &request); + TEST_ASSERT_NOT_NULL(testAdmin->reply()); + TEST_ASSERT_EQUAL(meshtastic_PortNum_ADMIN_APP, testAdmin->reply()->decoded.portnum); + TEST_ASSERT_EQUAL(packet.from, testAdmin->reply()->to); + TEST_ASSERT_FALSE(testAdmin->reply()->pki_encrypted); +} + +static void test_licensedAdminRejectsKeyWithoutVerifiedSignature() +{ + owner.is_licensed = true; + uint8_t key[32]; + memset(key, 0x52, sizeof(key)); + config.security.admin_key[0].size = 32; + memcpy(config.security.admin_key[0].bytes, key, sizeof(key)); + meshtastic_MeshPacket packet = makeRemoteAdminPacket(nodeDB->getNodeNum()); + packet.xeddsa_signed = false; + packet.public_key.size = 32; + memcpy(packet.public_key.bytes, key, sizeof(key)); + meshtastic_AdminMessage request = makeOwnerRequest(); + testAdmin->handleReceivedProtobuf(packet, &request); + TEST_ASSERT_NOT_NULL(testAdmin->reply()); + TEST_ASSERT_EQUAL(meshtastic_PortNum_ROUTING_APP, testAdmin->reply()->decoded.portnum); +} + +static void test_normalModePkiAdminRemainsAuthorized() +{ + owner.is_licensed = false; + uint8_t key[32]; + memset(key, 0x53, sizeof(key)); + config.security.admin_key[0].size = 32; + memcpy(config.security.admin_key[0].bytes, key, sizeof(key)); + meshtastic_MeshPacket packet = makeRemoteAdminPacket(nodeDB->getNodeNum()); + packet.decoded.want_response = true; + packet.pki_encrypted = true; + packet.public_key.size = 32; + memcpy(packet.public_key.bytes, key, sizeof(key)); + meshtastic_AdminMessage request = makeOwnerRequest(); + testAdmin->handleReceivedProtobuf(packet, &request); + TEST_ASSERT_NOT_NULL(testAdmin->reply()); + TEST_ASSERT_EQUAL(meshtastic_PortNum_ADMIN_APP, testAdmin->reply()->decoded.portnum); + TEST_ASSERT_TRUE(testAdmin->reply()->pki_encrypted); +} + +static void test_licensedAdminRejectsVerifiedNonAllowlistedAndEmptySlots() +{ + owner.is_licensed = true; + uint8_t key[32]; + memset(key, 0x61, sizeof(key)); + config.security = meshtastic_Config_SecurityConfig_init_zero; + meshtastic_MeshPacket packet = makeRemoteAdminPacket(nodeDB->getNodeNum()); + setVerifiedPacketSigner(packet, key); + meshtastic_AdminMessage request = makeOwnerRequest(); + testAdmin->handleReceivedProtobuf(packet, &request); + TEST_ASSERT_EQUAL(meshtastic_PortNum_ROUTING_APP, testAdmin->reply()->decoded.portnum); +} + +static void test_licensedAdminRejectsBroadcastAndWrongDestination() +{ + owner.is_licensed = true; + uint8_t key[32]; + memset(key, 0x62, sizeof(key)); + config.security.admin_key[0].size = 32; + memcpy(config.security.admin_key[0].bytes, key, 32); + meshtastic_AdminMessage request = makeOwnerRequest(); + for (NodeNum destination : {NODENUM_BROADCAST, static_cast(nodeDB->getNodeNum() + 1)}) { + meshtastic_MeshPacket packet = makeRemoteAdminPacket(destination); + setVerifiedPacketSigner(packet, key); + testAdmin->handleReceivedProtobuf(packet, &request); + TEST_ASSERT_EQUAL(meshtastic_PortNum_ROUTING_APP, testAdmin->reply()->decoded.portnum); + testAdmin->drainReply(); + } +} + +static void test_licensedSignedResponseDoesNotRequireAdminAllowlist() +{ + owner.is_licensed = true; + uint8_t key[32]; + memset(key, 0x63, sizeof(key)); + config.security = meshtastic_Config_SecurityConfig_init_zero; + meshtastic_MeshPacket packet = makeRemoteAdminPacket(nodeDB->getNodeNum()); + setVerifiedPacketSigner(packet, key); + meshtastic_AdminMessage response = meshtastic_AdminMessage_init_zero; + response.which_payload_variant = meshtastic_AdminMessage_get_owner_response_tag; + testAdmin->handleReceivedProtobuf(packet, &response); + TEST_ASSERT_NULL(testAdmin->reply()); +} + +static meshtastic_AdminMessage makeOwnerMutation() +{ + meshtastic_AdminMessage mutation = meshtastic_AdminMessage_init_zero; + mutation.which_payload_variant = meshtastic_AdminMessage_set_owner_tag; + mutation.set_owner.is_licensed = true; + return mutation; +} + +static void test_licensedMutationRequiresCurrentSessionAndNewModuleInvalidatesIt() +{ + owner.is_licensed = true; + uint8_t key[32]; + memset(key, 0x64, sizeof(key)); + config.security.admin_key[0].size = 32; + memcpy(config.security.admin_key[0].bytes, key, 32); + meshtastic_MeshPacket packet = makeRemoteAdminPacket(nodeDB->getNodeNum()); + setVerifiedPacketSigner(packet, key); + + meshtastic_AdminMessage missing = makeOwnerMutation(); + testAdmin->handleReceivedProtobuf(packet, &missing); + TEST_ASSERT_EQUAL(meshtastic_PortNum_ROUTING_APP, testAdmin->reply()->decoded.portnum); + testAdmin->drainReply(); + + meshtastic_AdminMessage valid = makeOwnerMutation(); + testAdmin->generateSession(&valid); + testAdmin->deferSaves(); + testAdmin->handleReceivedProtobuf(packet, &valid); + TEST_ASSERT_NULL(testAdmin->reply()); + + testAdmin->expireSession(); + testAdmin->handleReceivedProtobuf(packet, &valid); + TEST_ASSERT_EQUAL(meshtastic_PortNum_ROUTING_APP, testAdmin->reply()->decoded.portnum); + testAdmin->drainReply(); + + AdminModuleTestShim rebooted; + TEST_ASSERT_FALSE(rebooted.sessionValid(&valid)); +} + // ----------------------------------------------------------------------- // Test runner // ----------------------------------------------------------------------- @@ -1252,6 +1588,8 @@ void setUp(void) } void tearDown(void) { + testAdmin->drainReply(); + restoreAdminRadioGlobals(); service = nullptr; delete mockMeshService; mockMeshService = nullptr; @@ -1269,6 +1607,10 @@ void setup() UNITY_BEGIN(); // getRegion() + RUN_TEST(test_handleSetOwner_persistsLicensedChannelSanitation); + RUN_TEST(test_handleSetConfig_persistsLicensedFirstRegionIdentity); + RUN_TEST(test_bootDefense_sanitizesStaleLicensedChannelsOnce); + RUN_TEST(test_restorePreferences_sanitizesLicensedBackupBeforeReturn); RUN_TEST(test_getRegion_returnsCorrectRegion_US); RUN_TEST(test_getRegion_returnsCorrectRegion_EU868); RUN_TEST(test_getRegion_returnsCorrectRegion_LORA24); @@ -1355,8 +1697,18 @@ void setup() RUN_TEST(test_checkConfigRegion_quietCheckReportsReason); RUN_TEST(test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion); RUN_TEST(test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected); - - exit(UNITY_END()); + RUN_TEST(test_adminSession_zeroBeforeGenerationIsRejected); + RUN_TEST(test_adminSession_generatedKeyIsValidThenExpires); + RUN_TEST(test_licensedSignedAdminGetterUsesTransientAllowlistedKey); + RUN_TEST(test_licensedAdminRejectsKeyWithoutVerifiedSignature); + RUN_TEST(test_normalModePkiAdminRemainsAuthorized); + RUN_TEST(test_licensedAdminRejectsVerifiedNonAllowlistedAndEmptySlots); + RUN_TEST(test_licensedAdminRejectsBroadcastAndWrongDestination); + RUN_TEST(test_licensedSignedResponseDoesNotRequireAdminAllowlist); + RUN_TEST(test_licensedMutationRequiresCurrentSessionAndNewModuleInvalidatesIt); + + const int result = UNITY_END(); + exit(result); } void loop() {} diff --git a/test/test_fuzz_packets/test_main.cpp b/test/test_fuzz_packets/test_main.cpp index 196962aeada..0cd2b77da6e 100644 --- a/test/test_fuzz_packets/test_main.cpp +++ b/test/test_fuzz_packets/test_main.cpp @@ -158,8 +158,9 @@ void test_E1_perhaps_decode_fuzz(void) rngFill(p.encrypted.bytes, p.encrypted.size); DecodeState st = perhapsDecode(&p); - // Any verdict is fine; the contract is that arbitrary ciphertext never crashes the pipeline. - TEST_ASSERT_TRUE(st == DECODE_SUCCESS || st == DECODE_FAILURE || st == DECODE_FATAL); + // Unknown-channel ciphertext is intentionally opaque relay-only traffic; all four verdicts + // are valid here. The contract is that arbitrary ciphertext never crashes the pipeline. + TEST_ASSERT_TRUE(st == DECODE_SUCCESS || st == DECODE_FAILURE || st == DECODE_FATAL || st == DECODE_OPAQUE); } } diff --git a/test/test_mqtt/MQTT.cpp b/test/test_mqtt/MQTT.cpp index 3716e0a206f..f241b1ca319 100644 --- a/test/test_mqtt/MQTT.cpp +++ b/test/test_mqtt/MQTT.cpp @@ -53,6 +53,13 @@ class MockRouter : public Router class MockMeshService : public MeshService { public: + ~MockMeshService() + { + // handleFromRadio queues packet copies for the phone. There is no phone poller in this + // unit test, so release any queued copies before LeakSanitizer checks process shutdown. + while (auto *p = getForPhone()) + releaseToPool(p); + } void sendMqttMessageToClientProxy(meshtastic_MqttClientProxyMessage *m) override { messages_.emplace_back(*m); @@ -334,6 +341,7 @@ const meshtastic_MeshPacket encrypted = { // Initialize mocks and configuration before running each test. void setUp(void) { + config = meshtastic_LocalConfig_init_zero; moduleConfig.mqtt = meshtastic_ModuleConfig_MQTTConfig{.enabled = true, .map_reporting_enabled = true, .has_map_report_settings = true}; moduleConfig.mqtt.map_report_settings = meshtastic_ModuleConfig_MapReportSettings{ @@ -596,6 +604,8 @@ void test_receiveWithoutChannelDownlink(void) // Test receiving an encrypted MeshPacket on the PKI topic. void test_receiveEncryptedPKITopicToUs(void) { + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT; meshtastic_MeshPacket e = encrypted; e.to = myNodeInfo.my_node_num; @@ -687,6 +697,8 @@ static meshtastic_MeshPacket makeDecodedBroadcast() // signing node (audit F3). void test_receiveDropsUnsignedBroadcastFromSigner(void) { + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED; mockNodeDB->emptyNode.bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; const meshtastic_MeshPacket p = makeDecodedBroadcast(); @@ -698,6 +710,8 @@ void test_receiveDropsUnsignedBroadcastFromSigner(void) // The same unsigned broadcast from a node never seen signing is accepted. void test_receiveAcceptsUnsignedBroadcastFromNonSigner(void) { + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED; const meshtastic_MeshPacket p = makeDecodedBroadcast(); unitTest->publish(&p); @@ -729,6 +743,8 @@ void test_receiveVerifiesSignedDecodedDownlink(void) // A decoded downlink carrying a signature that fails verification is dropped. void test_receiveDropsBadSignatureOnDecodedDownlink(void) { + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE; uint8_t pub[32], priv[32]; crypto->generateKeyPair(pub, priv); mockNodeDB->emptyNode.public_key.size = 32; @@ -744,6 +760,53 @@ void test_receiveDropsBadSignatureOnDecodedDownlink(void) TEST_ASSERT_TRUE(mockRouter->packets_.empty()); } + +void test_receiveCompatibleAcceptsUnsignedBroadcastFromSigner(void) +{ + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE; + mockNodeDB->emptyNode.bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; + + const meshtastic_MeshPacket p = makeDecodedBroadcast(); + unitTest->publish(&p); + + TEST_ASSERT_EQUAL(1, mockRouter->packets_.size()); +} + +void test_receiveStrictDropsUnsignedPortnumsAndUnicast(void) +{ + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT; + const meshtastic_PortNum ports[] = { + meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_PortNum_POSITION_APP, meshtastic_PortNum_TELEMETRY_APP, + meshtastic_PortNum_NODEINFO_APP, meshtastic_PortNum_WAYPOINT_APP, + }; + for (const auto port : ports) { + meshtastic_MeshPacket p = makeDecodedBroadcast(); + p.decoded.portnum = port; + unitTest->publish(&p); + } + + meshtastic_MeshPacket unicast = makeDecodedBroadcast(); + unicast.to = myNodeInfo.my_node_num; + unicast.decoded.portnum = meshtastic_PortNum_POSITION_APP; + unitTest->publish(&unicast); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// A plaintext broker assertion is not evidence that AES-CCM authentication succeeded locally. +void test_receiveStrictDoesNotTrustDecodedPkiFlag(void) +{ + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT; + meshtastic_MeshPacket p = makeDecodedBroadcast(); + p.to = myNodeInfo.my_node_num; + p.pki_encrypted = true; + unitTest->publish(&p); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} #endif // !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) // Only the same fields that are transmitted over LoRa should be set in MQTT messages. @@ -1063,6 +1126,9 @@ void setup() RUN_TEST(test_receiveAcceptsUnsignedBroadcastFromNonSigner); RUN_TEST(test_receiveVerifiesSignedDecodedDownlink); RUN_TEST(test_receiveDropsBadSignatureOnDecodedDownlink); + RUN_TEST(test_receiveCompatibleAcceptsUnsignedBroadcastFromSigner); + RUN_TEST(test_receiveStrictDropsUnsignedPortnumsAndUnicast); + RUN_TEST(test_receiveStrictDoesNotTrustDecodedPkiFlag); #endif RUN_TEST(test_receiveIgnoresUnexpectedFields); RUN_TEST(test_receiveIgnoresInvalidHopLimit); diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index 707d716195b..17a2cc40ef2 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -3,16 +3,19 @@ // // The decision logic under test lives in Router.cpp free functions. Groups A/B drive a real // encode -> decode round-trip through the default channel (perhapsEncode/perhapsDecode, black-box, -// no production changes); Groups C-E exercise the policy helpers directly. +// no production changes); later groups exercise routing order and policy helpers directly. // // Group A receive-side accept/reject matrix (verify, downgrade protection, signer-bit learning) // Group B send-side signing policy (which outgoing packets perhapsEncode signs) -// Group C NodeInfoModule's broadcast-only "drop unsigned NodeInfo from a known signer" rule +// Group C routing pipeline ordering (authenticate before duplicate/retry/relay state) // Group D encoding invariants the routing gates depend on // Group E decoded-ingress policy (checkXeddsaReceivePolicy, the plaintext-MQTT trust boundary) #include "MeshTypes.h" // include BEFORE TestUtil.h +#include "NodeStatus.h" #include "TestUtil.h" +#include "airtime.h" +#include "support/MockMeshService.h" #include // The whole suite exercises XEdDSA sign/verify and checkXeddsaReceivePolicy, all of which are @@ -21,12 +24,20 @@ #include "mesh/Channels.h" #include "mesh/CryptoEngine.h" +#include "mesh/MeshRadio.h" +#include "mesh/MeshService.h" #include "mesh/NodeDB.h" +#include "mesh/ReliableRouter.h" #include "mesh/Router.h" +#include "mesh/SinglePortModule.h" #include "modules/NodeInfoModule.h" +#include "modules/RoutingModule.h" +#include "mqtt/MQTT.h" +#include #include #include #include +#include #include #include @@ -35,6 +46,7 @@ // --------------------------------------------------------------------------- static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; static constexpr NodeNum REMOTE_NODE = 0x0B0B0B0B; +static constexpr NodeNum OTHER_NODE = 0x0C0C0C0C; // A "small" broadcast payload whose signed encoding easily fits a LoRa frame, and an "oversized" // one whose signed encoding does not, yet still encodes within a LoRa frame unsigned. @@ -48,6 +60,8 @@ static constexpr size_t OVERSIZED_PAYLOAD = 180; class MockNodeDB : public NodeDB { public: + void installDefaultsPreservingIdentity() { installDefaultConfig(true); } + void clearTestNodes() { testNodes.clear(); @@ -86,6 +100,112 @@ class MockNodeDB : public NodeDB static MockNodeDB *mockNodeDB = nullptr; +#ifdef ARCH_PORTDUINO +static bool savedForceSimRadio; +#endif + +class AuthPipelineRadio : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *p) override + { + sendCalls++; + packetPool.release(p); + return ERRNO_OK; + } + bool cancelSending(NodeNum, PacketId) override + { + cancelCalls++; + return true; + } + bool findInTxQueue(NodeNum, PacketId) override + { + findCalls++; + return false; + } + bool removePendingTXPacket(NodeNum, PacketId, uint32_t) override + { + removeCalls++; + return true; + } + uint32_t getPacketTime(uint32_t, bool = false) override { return 7; } + void reset() { sendCalls = cancelCalls = findCalls = removeCalls = 0; } + + uint32_t sendCalls = 0; + uint32_t cancelCalls = 0; + uint32_t findCalls = 0; + uint32_t removeCalls = 0; +}; + +class AuthPipelineRouter : public ReliableRouter +{ + public: + bool filter(meshtastic_MeshPacket *p) { return ReliableRouter::shouldFilterReceived(p); } + bool historyContains(const meshtastic_MeshPacket *p) { return wasSeenRecently(p, false); } + void remember(const meshtastic_MeshPacket *p) { wasSeenRecently(p, true); } + void forgetRelayer(uint8_t relay, PacketId id, NodeNum from) { removeRelayer(relay, id, from); } + bool handleUpgrade(meshtastic_MeshPacket *p) { return perhapsHandleUpgradedPacket(p); } + void addPending(const meshtastic_MeshPacket &p, uint32_t nextTx) + { + auto *copy = packetPool.allocCopy(p); + TEST_ASSERT_NOT_NULL(copy); + const GlobalPacketId key(copy); + pending.emplace(key, PendingPacket(copy, NUM_INTERMEDIATE_RETX)); + pending.at(key).nextTxMsec = nextTx; + } + uint32_t pendingNextTx(NodeNum from, PacketId id) + { + PendingPacket *entry = findPendingPacket(from, id); + return entry ? entry->nextTxMsec : 0; + } + size_t pendingCount() const { return pending.size(); } + void clearPending() + { + for (auto &entry : pending) + packetPool.release(entry.second.packet); + pending.clear(); + } +}; + +class AuthPipelineRoutingModule : public RoutingModule +{ + public: + void sendAckNak(meshtastic_Routing_Error, NodeNum, PacketId, ChannelIndex, uint8_t = 0, bool = false) override { ackCalls++; } + uint32_t ackCalls = 0; +}; + +class AuthPipelineModule : public SinglePortModule +{ + public: + AuthPipelineModule() : SinglePortModule("authPipeline", meshtastic_PortNum_POSITION_APP) {} + ProcessMessage handleReceived(const meshtastic_MeshPacket &packet) override + { + calls++; + lastPacket = packet; + return ProcessMessage::CONTINUE; + } + uint32_t calls = 0; + meshtastic_MeshPacket lastPacket = meshtastic_MeshPacket_init_zero; +}; + +class AuthPipelineMqtt : public MQTT +{ + public: + int queueSize() { return mqttQueue.numUsed(); } + void clearQueue() + { + while (QueueEntry *entry = mqttQueue.dequeuePtr(0)) + delete entry; + } +}; + +static AuthPipelineRouter *pipelineRouter = nullptr; +static AuthPipelineRadio *pipelineRadio = nullptr; +static AuthPipelineRoutingModule *pipelineRouting = nullptr; +static AuthPipelineModule *pipelineModule = nullptr; +static AuthPipelineMqtt *pipelineMqtt = nullptr; +static MeshService *pipelineService = nullptr; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -126,11 +246,48 @@ static DecodeState roundTrip(meshtastic_MeshPacket *p) return perhapsDecode(p); } +static meshtastic_MeshPacket channelEncode(meshtastic_MeshPacket p) +{ + uint8_t encoded[MAX_LORA_PAYLOAD_LEN + 1] = {}; + const size_t encodedSize = pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_Data_msg, &p.decoded); + TEST_ASSERT_GREATER_THAN(0, encodedSize); + const int16_t hash = channels.setActiveByIndex(p.channel); + TEST_ASSERT_GREATER_OR_EQUAL(0, hash); + crypto->encryptPacket(p.from, p.id, encodedSize, encoded); + memcpy(p.encrypted.bytes, encoded, encodedSize); + p.encrypted.size = encodedSize; + p.channel = hash; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + return p; +} + +static meshtastic_MeshPacket makeSignedWirePacket(NodeNum from, NodeNum to, PacketId id, uint8_t hopLimit = 1, + uint8_t hopStart = 2, uint8_t nextHop = NO_NEXT_HOP_PREFERENCE, + uint8_t relayNode = 0x33, bool valid = true) +{ + meshtastic_MeshPacket p = makeDecoded(from, to, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + p.id = id; + p.hop_limit = hopLimit; + p.hop_start = hopStart; + p.next_hop = nextHop; + p.relay_node = relayNode; + p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + signWithCurrentKey(&p); + if (!valid) + p.decoded.xeddsa_signature.bytes[0] ^= 0x80; + return channelEncode(p); +} + static bool remoteSignerBit() { return nodeInfoLiteHasXeddsaSigned(mockNodeDB->getMeshNode(REMOTE_NODE)); } +static void setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy policy) +{ + config.security.packet_signature_policy = policy; +} + // Size a Data message exactly as the wire encoder would. static size_t encodedDataSize(const meshtastic_Data *d) { @@ -153,22 +310,42 @@ static bool signedEncodingFits(const meshtastic_Data *d) // --------------------------------------------------------------------------- void setUp(void) { +#ifdef ARCH_PORTDUINO + savedForceSimRadio = portduino_config.force_simradio; + portduino_config.force_simradio = false; +#endif // Construct the mock FIRST: the NodeDB constructor can reload persisted state from the // host filesystem (portduino VFS) and repopulate the globals - a saved private key // re-enables the PKI encrypt path and fails the unicast tests on hosts with leftover prefs. mockNodeDB = new MockNodeDB(); mockNodeDB->clearTestNodes(); +#if WARM_NODE_COUNT > 0 + mockNodeDB->warmStore.clear(); +#endif nodeDB = mockNodeDB; // Clean global config/owner AFTER the ctor; zeroed config => rebroadcast ALL (no KNOWN_ONLY // drop) and security.private_key.size == 0 (PKI encrypt path skipped => simple channel crypto). config = meshtastic_LocalConfig_init_zero; + moduleConfig = meshtastic_LocalModuleConfig_init_zero; owner = meshtastic_User_init_zero; myNodeInfo.my_node_num = LOCAL_NODE; // drives isFromUs()/getFrom()/isToUs() // Working primary channel with the default PSK so encrypt/decrypt round-trips. channels.initDefaults(); channels.onConfigChanged(); + + pipelineRouter->clearPending(); + pipelineRouter->rxDupe = 0; + pipelineRouter->txRelayCanceled = 0; + pipelineRadio->reset(); + pipelineRouting->ackCalls = 0; + pipelineModule->calls = 0; + pipelineModule->lastPacket = meshtastic_MeshPacket_init_zero; + pipelineMqtt->clearQueue(); + while (meshtastic_MeshPacket *queued = pipelineService->getForPhone()) + packetPool.release(queued); + resetRoutingAuthEvaluationCount(); } void tearDown(void) @@ -176,6 +353,9 @@ void tearDown(void) delete mockNodeDB; mockNodeDB = nullptr; nodeDB = nullptr; +#ifdef ARCH_PORTDUINO + portduino_config.force_simradio = savedForceSimRadio; +#endif } // =========================================================================== @@ -185,6 +365,7 @@ void tearDown(void) // A1: valid signature from a node whose key we know -> accepted, marked signed, signer bit learned. void test_A1_valid_signature_accepted_and_learns_signer(void) { + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); uint8_t pub[32], priv[32]; crypto->generateKeyPair(pub, priv); // engine now holds REMOTE's key mockNodeDB->addNode(REMOTE_NODE); @@ -203,6 +384,7 @@ void test_A1_valid_signature_accepted_and_learns_signer(void) // A2: a tampered signature from a known key -> dropped. void test_A2_bad_signature_dropped(void) { + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE); uint8_t pub[32], priv[32]; crypto->generateKeyPair(pub, priv); mockNodeDB->addNode(REMOTE_NODE); @@ -212,12 +394,13 @@ void test_A2_bad_signature_dropped(void) signWithCurrentKey(&p); p.decoded.xeddsa_signature.bytes[0] ^= 0xFF; // corrupt the signature - TEST_ASSERT_EQUAL(DECODE_FAILURE, roundTrip(&p)); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); } // A3: signed packet but we have no key for the sender -> accepted unverified, signer bit NOT set. void test_A3_signed_no_pubkey_accepted_unverified(void) { + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED); uint8_t pub[32], priv[32]; crypto->generateKeyPair(pub, priv); mockNodeDB->addNode(REMOTE_NODE); // node exists, but no public key stored @@ -239,7 +422,7 @@ void test_A4_downgrade_unsigned_broadcast_from_signer_dropped(void) meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); // from != us, so perhapsEncode leaves it unsigned. - TEST_ASSERT_EQUAL(DECODE_FAILURE, roundTrip(&p)); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); } // A5: no prior knowledge - unsigned small broadcast from a non-signer -> accepted. @@ -321,7 +504,214 @@ void test_A9_unsigned_boundary_broadcast_from_signer_still_dropped(void) TEST_ASSERT_EQUAL_MESSAGE(MAX_LORA_PAYLOAD_LEN, encodedDataSize(&signedCopy) + MESHTASTIC_HEADER_LENGTH, "payload no longer sits exactly on the fit boundary - retune it"); - TEST_ASSERT_EQUAL(DECODE_FAILURE, roundTrip(&p)); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); +} + +void test_A10_compatible_accepts_unsigned_broadcast_from_signer(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setSignerBit(REMOTE_NODE, true); + + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); +} + +void test_A11_strict_rejects_unsigned_all_portnums_destinations_and_sizes(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + const meshtastic_PortNum ports[] = { + meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_PortNum_POSITION_APP, meshtastic_PortNum_TELEMETRY_APP, + meshtastic_PortNum_NODEINFO_APP, meshtastic_PortNum_WAYPOINT_APP, + }; + for (const auto port : ports) { + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, port, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); + } + + meshtastic_MeshPacket unicast = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&unicast)); + + meshtastic_MeshPacket oversized = + makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, OVERSIZED_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&oversized)); +} + +void test_A12_strict_rejects_signed_packet_without_key(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + signWithCurrentKey(&p); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); +} + +void test_A13_strict_accepts_locally_authenticated_pki_packet(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32]; + crypto->generateKeyPair(localPub, localPriv); + crypto->generateKeyPair(remotePub, remotePriv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, localPub); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setPublicKey(REMOTE_NODE, remotePub); + + meshtastic_Data data = meshtastic_Data_init_zero; + data.portnum = meshtastic_PortNum_PRIVATE_APP; + data.payload.size = SMALL_PAYLOAD; + memset(data.payload.bytes, 0x5A, data.payload.size); + uint8_t plaintext[MAX_LORA_PAYLOAD_LEN + 1] = {}; + const size_t plaintextSize = pb_encode_to_bytes(plaintext, sizeof(plaintext), &meshtastic_Data_msg, &data); + TEST_ASSERT_GREATER_THAN(0, plaintextSize); + + meshtastic_NodeInfoLite_public_key_t localKey = {32, {0}}; + memcpy(localKey.bytes, localPub, sizeof(localPub)); + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = REMOTE_NODE; + p.to = LOCAL_NODE; + p.id = 0x0CC01234; + p.channel = 0; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + crypto->setDHPrivateKey(remotePriv); + TEST_ASSERT_TRUE(crypto->encryptCurve25519(p.to, p.from, localKey, p.id, plaintextSize, plaintext, p.encrypted.bytes)); + p.encrypted.size = plaintextSize + MESHTASTIC_PKC_OVERHEAD; + + // Only the receiver's private key can establish the local pki_encrypted authentication marker. + crypto->setDHPrivateKey(localPriv); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p)); + TEST_ASSERT_TRUE(p.pki_encrypted); + TEST_ASSERT_EQUAL(meshtastic_PortNum_PRIVATE_APP, p.decoded.portnum); +} + +void test_A13b_strict_rejects_spoofed_pki_flag_on_encrypted_ingress(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&p)); + p.pki_encrypted = true; + p.public_key.size = 32; + memset(p.public_key.bytes, 0xAB, p.public_key.size); + + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, perhapsDecode(&p)); + TEST_ASSERT_FALSE(p.pki_encrypted); + TEST_ASSERT_EQUAL(0, p.public_key.size); +} + +void test_A14_strict_bootstraps_identity_bound_signed_nodeinfo(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + const NodeNum signer = crc32Buffer(pub, sizeof(pub)); + + meshtastic_User user = meshtastic_User_init_zero; + user.public_key.size = sizeof(pub); + memcpy(user.public_key.bytes, pub, sizeof(pub)); + meshtastic_MeshPacket p = makeDecoded(signer, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, 0); + p.decoded.payload.size = + pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_User_msg, &user); + signWithCurrentKey(&p); + + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); + const meshtastic_NodeInfoLite *node = mockNodeDB->getMeshNode(signer); + TEST_ASSERT_NOT_NULL(node); + TEST_ASSERT_EQUAL_UINT8_ARRAY(pub, node->public_key.bytes, sizeof(pub)); + TEST_ASSERT_TRUE(p.xeddsa_signed); +} + +void test_A15_strict_rejects_nodeinfo_key_without_identity_binding(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + + meshtastic_User user = meshtastic_User_init_zero; + user.public_key.size = sizeof(pub); + memcpy(user.public_key.bytes, pub, sizeof(pub)); + meshtastic_MeshPacket p = + makeDecoded(crc32Buffer(pub, sizeof(pub)) ^ 1, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, 0); + p.decoded.payload.size = + pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_User_msg, &user); + signWithCurrentKey(&p); + + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); + TEST_ASSERT_NULL(mockNodeDB->getMeshNode(p.from)); +} + +void test_A16_compatible_rejects_invalid_first_contact_nodeinfo(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE); + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + + meshtastic_User user = meshtastic_User_init_zero; + user.public_key.size = sizeof(pub); + memcpy(user.public_key.bytes, pub, sizeof(pub)); + meshtastic_MeshPacket p = + makeDecoded(crc32Buffer(pub, sizeof(pub)) ^ 1, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, 0); + p.decoded.payload.size = + pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_User_msg, &user); + signWithCurrentKey(&p); + + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); +} + +#if WARM_NODE_COUNT > 0 +void test_A17_strict_verifies_signer_from_warm_key_store(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + TEST_ASSERT_TRUE(mockNodeDB->warmStore.absorb(REMOTE_NODE, 1, pub)); + TEST_ASSERT_NULL(mockNodeDB->getMeshNode(REMOTE_NODE)); + + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + signWithCurrentKey(&p); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); + TEST_ASSERT_TRUE(p.xeddsa_signed); + const meshtastic_NodeInfoLite *rehydrated = mockNodeDB->getMeshNode(REMOTE_NODE); + TEST_ASSERT_NOT_NULL_MESSAGE(rehydrated, "verified warm signer must be re-admitted to the hot store"); + TEST_ASSERT_EQUAL_UINT8_ARRAY(pub, rehydrated->public_key.bytes, sizeof(pub)); + TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasXeddsaSigned(rehydrated), "re-admitted signer must retain Balanced downgrade memory"); + + // Model its next hot-store eviction and prove Balanced still remembers the signer without + // allocating a hot node merely to evaluate an unsigned packet. + TEST_ASSERT_TRUE(mockNodeDB->warmStore.absorb(REMOTE_NODE, 2, pub, meshtastic_Config_DeviceConfig_Role_CLIENT, + static_cast(WarmProtected::XeddsaSigner))); + mockNodeDB->clearTestNodes(); + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED); + meshtastic_MeshPacket unsignedPacket = + makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&unsignedPacket), + "Balanced downgrade memory must survive repeated hot-store eviction"); +} +#endif + +void test_A18_verifiedSignerKeyIsPacketLocalAndClearedOnNextIngress(void) +{ + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setPublicKey(REMOTE_NODE, pub); + + meshtastic_MeshPacket verified = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_ADMIN_APP, SMALL_PAYLOAD); + signWithCurrentKey(&verified); + TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&verified)); + TEST_ASSERT_TRUE(verified.xeddsa_signed); + TEST_ASSERT_EQUAL(32, verified.public_key.size); + TEST_ASSERT_EQUAL_UINT8_ARRAY(pub, verified.public_key.bytes, sizeof(pub)); + + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE); + meshtastic_MeshPacket next = makeDecoded(OTHER_NODE, LOCAL_NODE, meshtastic_PortNum_ADMIN_APP, SMALL_PAYLOAD); + next.xeddsa_signed = true; + next.public_key.size = 32; + memcpy(next.public_key.bytes, pub, sizeof(pub)); + TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&next)); + TEST_ASSERT_FALSE(next.xeddsa_signed); + TEST_ASSERT_EQUAL(0, next.public_key.size); } // =========================================================================== @@ -343,15 +733,15 @@ void test_B1_local_broadcast_is_signed(void) TEST_ASSERT_TRUE(p.xeddsa_signed); } -// B2: our own unicast is NOT signed. +// B2: our own normal-mode unicast is NOT signed. void test_B2_local_unicast_not_signed(void) { mockNodeDB->addNode(REMOTE_NODE); - meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_PRIVATE_APP, SMALL_PAYLOAD); + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); - TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "unicast must not be signed"); + TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "normal unicast must not be signed"); } // B3: our own oversized broadcast is NOT signed (signature wouldn't fit). @@ -400,14 +790,12 @@ void test_B4_all_broadcast_sizes_deliverable_no_deadband(void) TEST_ASSERT_TRUE_MESSAGE(sawUnsigned, "sweep never crossed the fit boundary"); } -// B5: a client-preset signature on a packet we originate is discarded, not transmitted. -// perhapsEncode owns signing for our packets; a stale/garbage signature from a phone app on a -// packet we don't sign (here: unicast) would otherwise fail verification at every receiver. +// B5: a client-preset signature on a packet outside the existing broadcast sign class is discarded. void test_B5_preset_signature_on_local_packet_cleared(void) { mockNodeDB->addNode(REMOTE_NODE); - meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_PRIVATE_APP, SMALL_PAYLOAD); + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); p.decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE; memset(p.decoded.xeddsa_signature.bytes, 0xAB, XEDDSA_SIGNATURE_SIZE); @@ -454,81 +842,602 @@ void test_B6_rich_shape_sweep_no_deadband(void) TEST_ASSERT_TRUE_MESSAGE(sawUnsigned, "rich sweep never crossed the fit boundary"); } -// =========================================================================== -// Group C - NodeInfoModule downgrade drop (broadcast-only backstop for ingress paths that skip -// Router's check; unicast NodeInfo is never signed by senders, so it is exempt - see C4) -// =========================================================================== +// B7: licensed operation signs both plaintext broadcasts and direct messages. +void test_B7_licensed_broadcast_and_unicast_are_signed(void) +{ + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, pub); + owner.is_licensed = true; + channels.ensureLicensedOperation(); + + meshtastic_MeshPacket broadcast = + makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&broadcast)); + TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, broadcast.decoded.xeddsa_signature.size); + TEST_ASSERT_TRUE(broadcast.xeddsa_signed); + + meshtastic_MeshPacket direct = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&direct)); + TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, direct.decoded.xeddsa_signature.size); + TEST_ASSERT_TRUE(direct.xeddsa_signed); +} + +// B8: even with both identity keys present, licensed direct messages use the plaintext channel path. +void test_B8_licensed_unicast_never_uses_pki_encryption(void) +{ + uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32]; + crypto->generateKeyPair(localPub, localPriv); + memcpy(config.security.private_key.bytes, localPriv, sizeof(localPriv)); + config.security.private_key.size = sizeof(localPriv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, localPub); + mockNodeDB->addNode(REMOTE_NODE); + crypto->generateKeyPair(remotePub, remotePriv); + mockNodeDB->setPublicKey(REMOTE_NODE, remotePub); + crypto->setDHPrivateKey(localPriv); + + owner.is_licensed = true; + channels.ensureLicensedOperation(); + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&p)); + TEST_ASSERT_FALSE(p.pki_encrypted); + meshtastic_Data plaintext = meshtastic_Data_init_zero; + TEST_ASSERT_TRUE_MESSAGE(pb_decode_from_bytes(p.encrypted.bytes, p.encrypted.size, &meshtastic_Data_msg, &plaintext), + "licensed channel payload must remain plaintext"); + TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, plaintext.xeddsa_signature.size); +} + +// B9: licensed direct messages remain deliverable unsigned when the existing signature will not fit. +void test_B9_licensed_oversized_unicast_remains_unsigned(void) +{ + owner.is_licensed = true; + channels.ensureLicensedOperation(); + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, OVERSIZED_PAYLOAD); + + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); + TEST_ASSERT_EQUAL(0, p.decoded.xeddsa_signature.size); +} + +void test_B9a_licensed_oversized_admin_is_not_sent_unsigned(void) +{ + owner.is_licensed = true; + channels.ensureLicensedOperation(); + meshtastic_MeshPacket packet = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ADMIN_APP, OVERSIZED_PAYLOAD); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_TOO_LARGE, perhapsEncode(&packet)); + TEST_ASSERT_EQUAL(0, packet.decoded.xeddsa_signature.size); +} + +// B10: normal-mode direct messages retain their existing PKI encryption behavior. +void test_B10_normal_unicast_still_uses_pki(void) +{ + uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32]; + crypto->generateKeyPair(localPub, localPriv); + crypto->generateKeyPair(remotePub, remotePriv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, localPub); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setPublicKey(REMOTE_NODE, remotePub); + memcpy(config.security.private_key.bytes, localPriv, sizeof(localPriv)); + config.security.private_key.size = sizeof(localPriv); + crypto->setDHPrivateKey(localPriv); + + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&p)); + TEST_ASSERT_TRUE(p.pki_encrypted); + + myNodeInfo.my_node_num = REMOTE_NODE; + crypto->setDHPrivateKey(remotePriv); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p)); + TEST_ASSERT_TRUE(p.pki_encrypted); + TEST_ASSERT_EQUAL(0, p.decoded.xeddsa_signature.size); +} + +// B11: publishing a licensed node's key must not make inbound PKI decryption reachable. +void test_B11_licensed_receiver_does_not_decrypt_pki(void) +{ + uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32]; + crypto->generateKeyPair(localPub, localPriv); + crypto->generateKeyPair(remotePub, remotePriv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, localPub); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setPublicKey(REMOTE_NODE, remotePub); + memcpy(config.security.private_key.bytes, localPriv, sizeof(localPriv)); + config.security.private_key.size = sizeof(localPriv); + crypto->setDHPrivateKey(localPriv); + + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&p)); + TEST_ASSERT_TRUE(p.pki_encrypted); + + owner.is_licensed = true; + channels.ensureLicensedOperation(); + myNodeInfo.my_node_num = REMOTE_NODE; + crypto->setDHPrivateKey(remotePriv); + const DecodeState state = perhapsDecode(&p); + TEST_ASSERT_NOT_EQUAL(DECODE_SUCCESS, state); + TEST_ASSERT_FALSE(p.pki_encrypted); +} + +void test_B12_licensed_port_and_destination_signing_matrix(void) +{ + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, pub); + owner.is_licensed = true; + channels.ensureLicensedOperation(); + + const meshtastic_PortNum ports[] = { + meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_PortNum_POSITION_APP, meshtastic_PortNum_TELEMETRY_APP, + meshtastic_PortNum_ROUTING_APP, meshtastic_PortNum_NODEINFO_APP, + }; + const NodeNum destinations[] = {NODENUM_BROADCAST, REMOTE_NODE}; + for (const auto port : ports) { + for (const auto destination : destinations) { + meshtastic_MeshPacket packet = makeDecoded(LOCAL_NODE, destination, port, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&packet)); + TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, packet.decoded.xeddsa_signature.size); + TEST_ASSERT_TRUE(packet.xeddsa_signed); + TEST_ASSERT_FALSE(packet.pki_encrypted); + } + } +} + +// Group C - licensed NodeInfo publishes the signing identity key. class NodeInfoTestShim : public NodeInfoModule { public: - using NodeInfoModule::handleReceivedProtobuf; // protected virtual -> exposed for direct call + using NodeInfoModule::allocReply; }; -static meshtastic_MeshPacket makeNodeInfoPacket(bool signed_) +// C0: licensed NodeInfo publishes the same public identity key used to verify plaintext signatures. +void test_C0_licensed_nodeinfo_publishes_public_key(void) { - // Broadcast so the module's phone-forward path (which needs `service`) is skipped. - meshtastic_MeshPacket mp = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD); - mp.xeddsa_signed = signed_; - return mp; + owner.is_licensed = true; + owner.public_key.size = 32; + memset(owner.public_key.bytes, 0x5A, owner.public_key.size); + + NodeInfoTestShim shim; + meshtastic_MeshPacket *reply = shim.allocReply(); + TEST_ASSERT_NOT_NULL(reply); + meshtastic_User published = meshtastic_User_init_zero; + TEST_ASSERT_TRUE( + pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_User_msg, &published)); + TEST_ASSERT_TRUE(published.is_licensed); + TEST_ASSERT_EQUAL(32, published.public_key.size); + TEST_ASSERT_EQUAL_UINT8_ARRAY(owner.public_key.bytes, published.public_key.bytes, 32); + packetPool.release(reply); } -// C1: unsigned NodeInfo from a node that previously signed -> dropped. -void test_C1_unsigned_nodeinfo_from_signer_dropped(void) +// C00: a pre-signing licensed install creates one identity and derives the same public key after reload. +void test_C00_licensed_identity_key_is_generated_and_preserved(void) { - mockNodeDB->addNode(REMOTE_NODE); - mockNodeDB->setSignerBit(REMOTE_NODE, true); + owner.is_licensed = true; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.security = meshtastic_Config_SecurityConfig_init_zero; + + TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair()); + uint8_t privateKey[32], publicKey[32]; + memcpy(privateKey, config.security.private_key.bytes, sizeof(privateKey)); + memcpy(publicKey, config.security.public_key.bytes, sizeof(publicKey)); + const NodeNum migratedNodeNum = myNodeInfo.my_node_num; + TEST_ASSERT_NOT_EQUAL(LOCAL_NODE, migratedNodeNum); + TEST_ASSERT_TRUE(mockNodeDB->licensedIdentityMigrationPending); + + MockMeshService mockService; + MeshService *const savedService = service; + service = &mockService; + const bool firstNotification = mockNodeDB->notifyPendingLicensedIdentityMigration(); + const bool migrationPending = mockNodeDB->licensedIdentityMigrationPending; + const bool secondNotification = mockNodeDB->notifyPendingLicensedIdentityMigration(); + const int notificationCount = mockService.notificationCount; + service = savedService; + + TEST_ASSERT_TRUE(firstNotification); + TEST_ASSERT_EQUAL(1, notificationCount); + TEST_ASSERT_FALSE(migrationPending); + TEST_ASSERT_FALSE(secondNotification); + + config.security.public_key.size = 0; + owner.public_key.size = 0; + TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair()); + TEST_ASSERT_EQUAL(migratedNodeNum, myNodeInfo.my_node_num); + TEST_ASSERT_EQUAL_UINT8_ARRAY(privateKey, config.security.private_key.bytes, sizeof(privateKey)); + TEST_ASSERT_EQUAL_UINT8_ARRAY(publicKey, config.security.public_key.bytes, sizeof(publicKey)); + TEST_ASSERT_EQUAL_UINT8_ARRAY(publicKey, owner.public_key.bytes, sizeof(publicKey)); +} - NodeInfoTestShim shim; - meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/false); - meshtastic_User user = meshtastic_User_init_zero; - user.is_licensed = owner.is_licensed; +void test_C01_factory_config_reset_preserves_valid_identity_private_key(void) +{ + uint8_t publicKey[32], privateKey[32]; + crypto->generateKeyPair(publicKey, privateKey); + config.has_security = true; + config.security.private_key.size = 32; + memcpy(config.security.private_key.bytes, privateKey, sizeof(privateKey)); + + mockNodeDB->installDefaultsPreservingIdentity(); + TEST_ASSERT_EQUAL(32, config.security.private_key.size); + TEST_ASSERT_EQUAL_UINT8_ARRAY(privateKey, config.security.private_key.bytes, sizeof(privateKey)); + TEST_ASSERT_EQUAL(0, config.security.public_key.size); + + owner.is_licensed = true; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair()); + TEST_ASSERT_EQUAL_UINT8_ARRAY(privateKey, config.security.private_key.bytes, sizeof(privateKey)); + TEST_ASSERT_EQUAL_UINT8_ARRAY(publicKey, config.security.public_key.bytes, sizeof(publicKey)); +} - TEST_ASSERT_TRUE_MESSAGE(shim.handleReceivedProtobuf(mp, &user), "unsigned NodeInfo from signer must be dropped"); +void test_C02_licensed_low_entropy_identity_is_regenerated(void) +{ + static const uint8_t compromisedPublicKey[32] = { + 0xac, 0xaf, 0x8c, 0x1c, 0x3c, 0x1c, 0x37, 0xac, 0x4f, 0x03, 0xa1, 0xe9, 0xfc, 0x37, 0x23, 0x29, + 0xc8, 0xa3, 0x5d, 0x7f, 0x05, 0x26, 0xeb, 0x00, 0xbd, 0x26, 0xb8, 0x2e, 0xb1, 0x94, 0x7d, 0x24, + }; + owner.is_licensed = true; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.security.private_key.size = 32; + memset(config.security.private_key.bytes, 0xA5, 32); + config.security.public_key.size = 32; + memcpy(config.security.public_key.bytes, compromisedPublicKey, sizeof(compromisedPublicKey)); + TEST_ASSERT_TRUE(mockNodeDB->checkLowEntropyPublicKey(config.security.public_key)); + + uint8_t oldPrivateKey[32]; + memcpy(oldPrivateKey, config.security.private_key.bytes, sizeof(oldPrivateKey)); + TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair()); + TEST_ASSERT_TRUE(mockNodeDB->keyIsLowEntropy); + TEST_ASSERT_FALSE(mockNodeDB->checkLowEntropyPublicKey(config.security.public_key)); + TEST_ASSERT_FALSE(memcmp(oldPrivateKey, config.security.private_key.bytes, sizeof(oldPrivateKey)) == 0); } -// C2: signed NodeInfo from a known signer -> not dropped by this rule. -void test_C2_signed_nodeinfo_from_signer_not_dropped(void) +// =========================================================================== +// Group P - routing pipeline authentication ordering +// =========================================================================== + +static void preparePipelineSigner(NodeNum sender) { - mockNodeDB->addNode(REMOTE_NODE); - mockNodeDB->setSignerBit(REMOTE_NODE, true); + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + mockNodeDB->addNode(sender); + mockNodeDB->setPublicKey(sender, pub); +} - NodeInfoTestShim shim; - meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/true); - meshtastic_User user = meshtastic_User_init_zero; - user.is_licensed = owner.is_licensed; +static void runPipelineIngress(const meshtastic_MeshPacket &p) +{ + meshtastic_MeshPacket *copy = packetPool.allocCopy(p); + TEST_ASSERT_NOT_NULL(copy); + pipelineRouter->enqueueReceivedMessage(copy); + pipelineRouter->runOnce(); +} - TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user)); +static void assertNoRejectedPipelineEffects(NodeNum sender, uint32_t lastHeardBefore) +{ + TEST_ASSERT_EQUAL(0, pipelineRadio->sendCalls); + TEST_ASSERT_EQUAL(0, pipelineRadio->cancelCalls); + TEST_ASSERT_EQUAL(0, pipelineRadio->findCalls); + TEST_ASSERT_EQUAL(0, pipelineRadio->removeCalls); + TEST_ASSERT_EQUAL(0, pipelineRouting->ackCalls); + TEST_ASSERT_EQUAL(0, pipelineRouter->rxDupe); + TEST_ASSERT_EQUAL(0, pipelineRouter->txRelayCanceled); + TEST_ASSERT_EQUAL(0, pipelineModule->calls); + TEST_ASSERT_EQUAL(0, pipelineMqtt->queueSize()); + TEST_ASSERT_NULL(pipelineService->getForPhone()); + const meshtastic_NodeInfoLite *node = mockNodeDB->getMeshNode(sender); + TEST_ASSERT_NOT_NULL(node); + TEST_ASSERT_EQUAL_UINT32(lastHeardBefore, node->last_heard); } -// C3: unsigned NodeInfo from a node we've never seen sign -> not dropped. -void test_C3_unsigned_nodeinfo_from_nonsigner_not_dropped(void) +void test_P1_invalid_first_copy_does_not_poison_valid_same_id(void) { - mockNodeDB->addNode(REMOTE_NODE); // signer bit clear + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + preparePipelineSigner(REMOTE_NODE); + const PacketId id = 0xC1000001; + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + + meshtastic_MeshPacket invalid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 1, 2, 0, 0x31, false); + moduleConfig.mqtt.enabled = true; + runPipelineIngress(invalid); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&invalid)); + + meshtastic_MeshPacket valid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id); + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::ACCEPT), static_cast(passesRoutingAuthGate(&valid))); + TEST_ASSERT_EQUAL_MESSAGE(meshtastic_MeshPacket_encrypted_tag, valid.which_payload_variant, + "routing auth gate must preserve encrypted relay/MQTT bytes"); + TEST_ASSERT_FALSE_MESSAGE(pipelineRouter->filter(&valid), "valid same-ID packet was poisoned by rejected first copy"); + TEST_ASSERT_TRUE(pipelineRouter->historyContains(&valid)); +} - NodeInfoTestShim shim; - meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/false); - meshtastic_User user = meshtastic_User_init_zero; - user.is_licensed = owner.is_licensed; +void test_P2_invalid_ordinary_duplicate_has_no_cancel_or_delivery_effects(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + preparePipelineSigner(REMOTE_NODE); + const PacketId id = 0xC2000002; + meshtastic_MeshPacket prior = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + prior.id = id; + prior.hop_limit = 1; + prior.hop_start = 2; + prior.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + pipelineRouter->remember(&prior); + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + + meshtastic_MeshPacket invalid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 1, 2, 0, 0x32, false); + runPipelineIngress(invalid); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + TEST_ASSERT_TRUE(pipelineRouter->historyContains(&prior)); +} + +void test_P3_invalid_repeated_packet_cannot_ack_or_change_retry_state(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + preparePipelineSigner(LOCAL_NODE); + const PacketId id = 0xC3000003; + meshtastic_MeshPacket prior = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + prior.id = id; + prior.hop_limit = 2; + prior.hop_start = 2; + prior.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + pipelineRouter->remember(&prior); + pipelineRouter->addPending(prior, UINT32_MAX); + const uint32_t lastHeard = mockNodeDB->getMeshNode(LOCAL_NODE)->last_heard; + + meshtastic_MeshPacket invalid = makeSignedWirePacket(LOCAL_NODE, NODENUM_BROADCAST, id, 2, 2, 0, 0x34, false); + runPipelineIngress(invalid); + assertNoRejectedPipelineEffects(LOCAL_NODE, lastHeard); + TEST_ASSERT_EQUAL(1, pipelineRouter->pendingCount()); + TEST_ASSERT_EQUAL_UINT32(UINT32_MAX, pipelineRouter->pendingNextTx(LOCAL_NODE, id)); +} + +void test_P4_invalid_fallback_packet_cannot_relay(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + preparePipelineSigner(REMOTE_NODE); + const PacketId id = 0xC4000004; + const uint8_t ourRelay = mockNodeDB->getLastByteOfNodeNum(LOCAL_NODE); + meshtastic_MeshPacket prior = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + prior.id = id; + prior.next_hop = 0x22; + prior.relay_node = ourRelay; + prior.hop_limit = 1; + prior.hop_start = 2; + pipelineRouter->remember(&prior); + meshtastic_MeshPacket relayed = prior; + relayed.relay_node = 0x35; + pipelineRouter->remember(&relayed); + pipelineRouter->forgetRelayer(ourRelay, id, REMOTE_NODE); + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + + meshtastic_MeshPacket invalid = makeSignedWirePacket(REMOTE_NODE, LOCAL_NODE, id, 1, 2, 0, 0x35, false); + runPipelineIngress(invalid); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); +} + +void test_P5_invalid_upgrade_cannot_remove_pending_valid_send(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + preparePipelineSigner(REMOTE_NODE); + const PacketId id = 0xC5000005; + meshtastic_MeshPacket prior = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + prior.id = id; + prior.hop_limit = 1; + prior.hop_start = 2; + pipelineRouter->remember(&prior); + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + + meshtastic_MeshPacket directInvalid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 2, 2, 0, 0x36, false); + TEST_ASSERT_TRUE(pipelineRouter->handleUpgrade(&directInvalid)); + TEST_ASSERT_EQUAL(0, pipelineRadio->removeCalls); + + meshtastic_MeshPacket invalid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 2, 2, 0, 0x36, false); + runPipelineIngress(invalid); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + + // The rejected upgrade did not raise the history watermark or remove the queued valid copy; + // a later authenticated replacement still performs the intended upgrade. + meshtastic_MeshPacket valid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 2, 2, 0, 0x36, true); + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::ACCEPT), static_cast(passesRoutingAuthGate(&valid))); + TEST_ASSERT_TRUE(pipelineRouter->filter(&valid)); + TEST_ASSERT_EQUAL(1, pipelineRadio->removeCalls); +} - TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user)); +void test_P6_opaque_unknown_channel_is_relay_only(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + meshtastic_MeshPacket opaque = meshtastic_MeshPacket_init_zero; + opaque.from = REMOTE_NODE; + opaque.to = NODENUM_BROADCAST; + opaque.id = 0xC6000006; + opaque.channel = 0xFE; + opaque.hop_limit = 1; + opaque.hop_start = 2; + opaque.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + opaque.encrypted.size = 16; + memset(opaque.encrypted.bytes, 0xA5, opaque.encrypted.size); + + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::OPAQUE_RELAY_ONLY), static_cast(passesRoutingAuthGate(&opaque))); + moduleConfig.mqtt.enabled = true; + runPipelineIngress(opaque); + TEST_ASSERT_EQUAL_MESSAGE(1, pipelineRadio->sendCalls, "opaque broadcast should take only the safety-controlled relay path"); + TEST_ASSERT_EQUAL(0, pipelineRouting->ackCalls); + TEST_ASSERT_EQUAL(0, pipelineModule->calls); + TEST_ASSERT_EQUAL(0, pipelineMqtt->queueSize()); + TEST_ASSERT_NULL(pipelineService->getForPhone()); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&opaque)); + TEST_ASSERT_NULL(mockNodeDB->getMeshNode(REMOTE_NODE)); + + pipelineRadio->reset(); + meshtastic_MeshPacket addressed = opaque; + addressed.to = LOCAL_NODE; + addressed.id++; + runPipelineIngress(addressed); + TEST_ASSERT_EQUAL_MESSAGE(0, pipelineRadio->sendCalls, "opaque packet addressed to us must not be relayed"); + TEST_ASSERT_EQUAL(0, pipelineRouting->ackCalls); + TEST_ASSERT_EQUAL(0, pipelineModule->calls); + TEST_ASSERT_EQUAL(0, pipelineMqtt->queueSize()); + TEST_ASSERT_NULL(pipelineService->getForPhone()); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&addressed)); + + const meshtastic_Config_DeviceConfig_RebroadcastMode blockedModes[] = { + meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY, + meshtastic_Config_DeviceConfig_RebroadcastMode_CORE_PORTNUMS_ONLY, + meshtastic_Config_DeviceConfig_RebroadcastMode_NONE, + }; + for (const auto mode : blockedModes) { + pipelineRadio->reset(); + config.device.rebroadcast_mode = mode; + meshtastic_MeshPacket blocked = opaque; + blocked.id++; + blocked.id += static_cast(mode); + blocked.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP; + runPipelineIngress(blocked); + TEST_ASSERT_EQUAL_MESSAGE(0, pipelineRadio->sendCalls, "restricted rebroadcast mode must suppress opaque relay"); + TEST_ASSERT_EQUAL(0, pipelineRouting->ackCalls); + TEST_ASSERT_EQUAL(0, pipelineModule->calls); + TEST_ASSERT_EQUAL(0, pipelineMqtt->queueSize()); + TEST_ASSERT_NULL(pipelineService->getForPhone()); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&blocked)); + } } -// C4: F1 regression - unsigned UNICAST NodeInfo from a known signer -> NOT dropped. Unicast -// NodeInfo (want_response replies, phone-initiated exchanges) is never signed by the sender, -// so treating it as a downgrade broke NodeInfo exchange with signer nodes. -void test_C4_unsigned_unicast_nodeinfo_from_signer_accepted(void) +void test_P7_strict_rejects_unsigned_decoded_simradio_ingress(void) { + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); mockNodeDB->addNode(REMOTE_NODE); - mockNodeDB->setSignerBit(REMOTE_NODE, true); + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + meshtastic_MeshPacket injected = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + injected.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + runPipelineIngress(injected); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&injected)); +} - NodeInfoTestShim shim; - meshtastic_MeshPacket mp = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD); - mp.xeddsa_signed = false; - meshtastic_User user = meshtastic_User_init_zero; - user.is_licensed = owner.is_licensed; +void test_P8_trusted_local_decoded_delivery_is_not_filtered(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + meshtastic_MeshPacket *local = + packetPool.allocCopy(makeDecoded(0, LOCAL_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD)); + TEST_ASSERT_NOT_NULL(local); + TEST_ASSERT_EQUAL(ERRNO_SHOULD_RELEASE, pipelineRouter->sendLocal(local, RX_SRC_USER)); + TEST_ASSERT_EQUAL_MESSAGE(1, pipelineModule->calls, "trusted phone-origin packet must reach local modules"); + packetPool.release(local); +} - TEST_ASSERT_FALSE_MESSAGE(shim.handleReceivedProtobuf(mp, &user), - "unsigned unicast NodeInfo from a signer must not be dropped"); +void test_P9_known_channel_malformed_plaintext_is_not_relayed_as_opaque(void) +{ + meshtastic_MeshPacket malformed = meshtastic_MeshPacket_init_zero; + malformed.from = REMOTE_NODE; + malformed.to = NODENUM_BROADCAST; + malformed.id = 0xC9000009; + malformed.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + malformed.encrypted.size = 3; + malformed.encrypted.bytes[0] = 0xFF; + malformed.encrypted.bytes[1] = 0xFF; + malformed.encrypted.bytes[2] = 0xFF; + malformed.channel = channels.setActiveByIndex(0); + crypto->encryptPacket(malformed.from, malformed.id, malformed.encrypted.size, malformed.encrypted.bytes); + mockNodeDB->addNode(REMOTE_NODE); + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + runPipelineIngress(malformed); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&malformed)); +} + +void test_P10_legacy_channel_dm_failure_has_no_pipeline_effects(void) +{ + meshtastic_MeshPacket legacyDm = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + legacyDm = channelEncode(legacyDm); + mockNodeDB->addNode(REMOTE_NODE); + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + moduleConfig.mqtt.enabled = true; + runPipelineIngress(legacyDm); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&legacyDm)); +} + +void test_P11_malformed_pki_plaintext_has_no_pipeline_effects(void) +{ + uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32]; + crypto->generateKeyPair(localPub, localPriv); + crypto->generateKeyPair(remotePub, remotePriv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, localPub); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setPublicKey(REMOTE_NODE, remotePub); + + const uint8_t malformedPlaintext[] = {0xFF, 0xFF, 0xFF}; + meshtastic_NodeInfoLite_public_key_t localKey = {32, {0}}; + memcpy(localKey.bytes, localPub, sizeof(localPub)); + meshtastic_MeshPacket malformed = meshtastic_MeshPacket_init_zero; + malformed.from = REMOTE_NODE; + malformed.to = LOCAL_NODE; + malformed.id = 0xCB00000B; + malformed.channel = 0; + malformed.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + crypto->setDHPrivateKey(remotePriv); + TEST_ASSERT_TRUE(crypto->encryptCurve25519(malformed.to, malformed.from, localKey, malformed.id, sizeof(malformedPlaintext), + malformedPlaintext, malformed.encrypted.bytes)); + malformed.encrypted.size = sizeof(malformedPlaintext) + MESHTASTIC_PKC_OVERHEAD; + crypto->setDHPrivateKey(localPriv); + + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + moduleConfig.mqtt.enabled = true; + runPipelineIngress(malformed); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&malformed)); +} + +void test_P12_exact_authenticated_replay_reuses_verdict_without_collision_bypass(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + preparePipelineSigner(REMOTE_NODE); + meshtastic_MeshPacket valid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, 0xCC00000C); + // Full ingress replaces this nonzero wire timestamp with the local arrival time. The exact + // authentication handoff must be consumed before that mutation, avoiding a second evaluation. + valid.rx_time = 0x12345678; + runPipelineIngress(valid); + TEST_ASSERT_EQUAL_MESSAGE(1, routingAuthEvaluationCount(), "full ingress must consume the primed verdict exactly once"); + runPipelineIngress(valid); + TEST_ASSERT_EQUAL_MESSAGE(2, routingAuthEvaluationCount(), "consumed verdict must not authenticate a later replay"); + + meshtastic_MeshPacket collision = valid; + collision.encrypted.bytes[0] ^= 0x80; + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::REJECT), static_cast(passesRoutingAuthGate(&collision))); + TEST_ASSERT_EQUAL_MESSAGE(3, routingAuthEvaluationCount(), "same packet ID with different bytes must be reevaluated"); +} + +void test_P13_lost_decoded_handoff_reverifies_packet_local_signer(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + uint8_t signerPublic[32], signerPrivate[32]; + crypto->generateKeyPair(signerPublic, signerPrivate); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setPublicKey(REMOTE_NODE, signerPublic); + + meshtastic_MeshPacket packet = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + packet.id = 0xCD00000D; + signWithCurrentKey(&packet); + + uint8_t forgedKey[32]; + memset(forgedKey, 0xA5, sizeof(forgedKey)); + packet.xeddsa_signed = true; + packet.public_key.size = sizeof(forgedKey); + memcpy(packet.public_key.bytes, forgedKey, sizeof(forgedKey)); + + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::ACCEPT), static_cast(passesRoutingAuthGate(&packet))); + resetRoutingAuthEvaluationCount(); // Simulate another ingress replacing the one-entry handoff. + + pipelineRouter->handleReceivedAfterRoutingGateForTest(&packet); + + TEST_ASSERT_EQUAL(1, pipelineModule->calls); + TEST_ASSERT_EQUAL(1, routingAuthEvaluationCount()); + TEST_ASSERT_TRUE(pipelineModule->lastPacket.xeddsa_signed); + TEST_ASSERT_EQUAL_UINT(32, pipelineModule->lastPacket.public_key.size); + TEST_ASSERT_EQUAL_MEMORY(signerPublic, pipelineModule->lastPacket.public_key.bytes, sizeof(signerPublic)); + TEST_ASSERT_FALSE_MESSAGE(memcmp(forgedKey, pipelineModule->lastPacket.public_key.bytes, sizeof(forgedKey)) == 0, + "serialized signer metadata survived a lost routing-auth handoff"); } // =========================================================================== @@ -628,7 +1537,7 @@ void test_E5_decoded_unsigned_oversized_broadcast_from_signer_accepted(void) TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&p)); } -// E6: unsigned unicast from a signer -> accepted (unicast is never signed). +// E6: Balanced accepts unsigned unicast from a signer for legacy compatibility. void test_E6_decoded_unsigned_unicast_from_signer_accepted(void) { mockNodeDB->addNode(REMOTE_NODE); @@ -639,20 +1548,6 @@ void test_E6_decoded_unsigned_unicast_from_signer_accepted(void) TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&p)); } -// E7: unsigned PKI-flagged packet from a signer -> accepted. Senders never sign PKI traffic, -// so the predicate's !pki_encrypted guard must exempt it (pins the assumption that the -// downgrade drop can never fire on PKI packets, whatever their addressing). -void test_E7_decoded_unsigned_pki_from_signer_accepted(void) -{ - mockNodeDB->addNode(REMOTE_NODE); - mockNodeDB->setSignerBit(REMOTE_NODE, true); - - meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); - p.pki_encrypted = true; - - TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&p)); -} - // E8: a crafted partial (non-0, non-64) signature must not let a forged broadcast dodge the // downgrade drop. A 63-byte junk signature inflates the encoded size past the fit threshold, so // a size-only predicate would treat the packet as "too big to sign" and accept it as an @@ -688,6 +1583,25 @@ void test_E9_decoded_partial_signature_from_nonsigner_dropped(void) void setup() { initializeTestEnvironment(); + AirTime *savedAirTime = airTime; + meshtastic::NodeStatus *savedNodeStatus = nodeStatus; + AirTime testAirTime; + meshtastic::NodeStatus testNodeStatus; + airTime = &testAirTime; + nodeStatus = &testNodeStatus; + + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + pipelineRouter = new AuthPipelineRouter(); + auto pipelineRadioOwner = std::make_unique(); + pipelineRadio = pipelineRadioOwner.get(); + pipelineRouter->addInterface(std::move(pipelineRadioOwner)); + router = pipelineRouter; + routingModule = pipelineRouting = new AuthPipelineRoutingModule(); + pipelineModule = new AuthPipelineModule(); + service = pipelineService = new MeshService(); + mqtt = pipelineMqtt = new AuthPipelineMqtt(); + UNITY_BEGIN(); printf("\n=== Group A: receive-side accept/reject ===\n"); @@ -700,6 +1614,18 @@ void setup() RUN_TEST(test_A7_unsigned_oversized_broadcast_from_signer_accepted); RUN_TEST(test_A8_unsigned_deadband_broadcast_from_signer_accepted); RUN_TEST(test_A9_unsigned_boundary_broadcast_from_signer_still_dropped); + RUN_TEST(test_A10_compatible_accepts_unsigned_broadcast_from_signer); + RUN_TEST(test_A11_strict_rejects_unsigned_all_portnums_destinations_and_sizes); + RUN_TEST(test_A12_strict_rejects_signed_packet_without_key); + RUN_TEST(test_A13_strict_accepts_locally_authenticated_pki_packet); + RUN_TEST(test_A13b_strict_rejects_spoofed_pki_flag_on_encrypted_ingress); + RUN_TEST(test_A14_strict_bootstraps_identity_bound_signed_nodeinfo); + RUN_TEST(test_A15_strict_rejects_nodeinfo_key_without_identity_binding); + RUN_TEST(test_A16_compatible_rejects_invalid_first_contact_nodeinfo); +#if WARM_NODE_COUNT > 0 + RUN_TEST(test_A17_strict_verifies_signer_from_warm_key_store); +#endif + RUN_TEST(test_A18_verifiedSignerKeyIsPacketLocalAndClearedOnNextIngress); printf("\n=== Group B: send-side signing policy ===\n"); RUN_TEST(test_B1_local_broadcast_is_signed); @@ -708,12 +1634,34 @@ void setup() RUN_TEST(test_B4_all_broadcast_sizes_deliverable_no_deadband); RUN_TEST(test_B5_preset_signature_on_local_packet_cleared); RUN_TEST(test_B6_rich_shape_sweep_no_deadband); - - printf("\n=== Group C: NodeInfoModule downgrade drop ===\n"); - RUN_TEST(test_C1_unsigned_nodeinfo_from_signer_dropped); - RUN_TEST(test_C2_signed_nodeinfo_from_signer_not_dropped); - RUN_TEST(test_C3_unsigned_nodeinfo_from_nonsigner_not_dropped); - RUN_TEST(test_C4_unsigned_unicast_nodeinfo_from_signer_accepted); + RUN_TEST(test_B7_licensed_broadcast_and_unicast_are_signed); + RUN_TEST(test_B8_licensed_unicast_never_uses_pki_encryption); + RUN_TEST(test_B9_licensed_oversized_unicast_remains_unsigned); + RUN_TEST(test_B9a_licensed_oversized_admin_is_not_sent_unsigned); + RUN_TEST(test_B10_normal_unicast_still_uses_pki); + RUN_TEST(test_B11_licensed_receiver_does_not_decrypt_pki); + RUN_TEST(test_B12_licensed_port_and_destination_signing_matrix); + + printf("\n=== Group C: NodeInfo signing identity ===\n"); + RUN_TEST(test_C0_licensed_nodeinfo_publishes_public_key); + RUN_TEST(test_C00_licensed_identity_key_is_generated_and_preserved); + RUN_TEST(test_C01_factory_config_reset_preserves_valid_identity_private_key); + RUN_TEST(test_C02_licensed_low_entropy_identity_is_regenerated); + + printf("\n=== Group P: routing pipeline authentication ordering ===\n"); + RUN_TEST(test_P1_invalid_first_copy_does_not_poison_valid_same_id); + RUN_TEST(test_P2_invalid_ordinary_duplicate_has_no_cancel_or_delivery_effects); + RUN_TEST(test_P3_invalid_repeated_packet_cannot_ack_or_change_retry_state); + RUN_TEST(test_P4_invalid_fallback_packet_cannot_relay); + RUN_TEST(test_P5_invalid_upgrade_cannot_remove_pending_valid_send); + RUN_TEST(test_P6_opaque_unknown_channel_is_relay_only); + RUN_TEST(test_P7_strict_rejects_unsigned_decoded_simradio_ingress); + RUN_TEST(test_P8_trusted_local_decoded_delivery_is_not_filtered); + RUN_TEST(test_P9_known_channel_malformed_plaintext_is_not_relayed_as_opaque); + RUN_TEST(test_P10_legacy_channel_dm_failure_has_no_pipeline_effects); + RUN_TEST(test_P11_malformed_pki_plaintext_has_no_pipeline_effects); + RUN_TEST(test_P12_exact_authenticated_replay_reuses_verdict_without_collision_bypass); + RUN_TEST(test_P13_lost_decoded_handoff_reverifies_packet_local_signer); printf("\n=== Group D: encoding invariants ===\n"); RUN_TEST(test_D1_signature_field_overhead_exact); @@ -725,11 +1673,26 @@ void setup() RUN_TEST(test_E4_decoded_bad_signature_dropped); RUN_TEST(test_E5_decoded_unsigned_oversized_broadcast_from_signer_accepted); RUN_TEST(test_E6_decoded_unsigned_unicast_from_signer_accepted); - RUN_TEST(test_E7_decoded_unsigned_pki_from_signer_accepted); RUN_TEST(test_E8_decoded_partial_signature_from_signer_dropped); RUN_TEST(test_E9_decoded_partial_signature_from_nonsigner_dropped); - exit(UNITY_END()); + const int result = UNITY_END(); + pipelineRouter->clearPending(); + delete pipelineMqtt; + mqtt = pipelineMqtt = nullptr; + delete pipelineService; + service = pipelineService = nullptr; + delete pipelineModule; + pipelineModule = nullptr; + delete pipelineRouting; + routingModule = pipelineRouting = nullptr; + router = nullptr; + delete pipelineRouter; + pipelineRouter = nullptr; + pipelineRadio = nullptr; + airTime = savedAirTime; + nodeStatus = savedNodeStatus; + exit(result); } void loop() {} diff --git a/variants/stm32/CDEBYTE_E77-MBL/platformio.ini b/variants/stm32/CDEBYTE_E77-MBL/platformio.ini index cb980db10f0..f0f5d7cdc69 100644 --- a/variants/stm32/CDEBYTE_E77-MBL/platformio.ini +++ b/variants/stm32/CDEBYTE_E77-MBL/platformio.ini @@ -16,5 +16,8 @@ build_flags = -DMESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR=1 -DMESHTASTIC_EXCLUDE_I2C=1 -DMESHTASTIC_EXCLUDE_GPS=1 +build_unflags = + ${arduino_base.build_unflags} + -DMESHTASTIC_EXCLUDE_XEDDSA=1 -upload_port = stlink \ No newline at end of file +upload_port = stlink diff --git a/variants/stm32/rak3172/platformio.ini b/variants/stm32/rak3172/platformio.ini index de8f2b74b41..1b63eaadae9 100644 --- a/variants/stm32/rak3172/platformio.ini +++ b/variants/stm32/rak3172/platformio.ini @@ -15,5 +15,8 @@ build_flags = -DMESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR=1 -DMESHTASTIC_EXCLUDE_I2C=1 -DMESHTASTIC_EXCLUDE_GPS=1 +build_unflags = + ${arduino_base.build_unflags} + -DMESHTASTIC_EXCLUDE_XEDDSA=1 upload_port = stlink diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index 5726fd369a2..be4638e6852 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -25,7 +25,7 @@ build_flags = -DMESHTASTIC_EXCLUDE_BLUETOOTH=1 -DMESHTASTIC_EXCLUDE_WIFI=1 -DMESHTASTIC_EXCLUDE_TZ=1 ; Exclude TZ to save some flash space. - -DMESHTASTIC_EXCLUDE_XEDDSA=1 ; The Ed25519 signing code does not fit in the 256KB flash. Packets are sent unsigned, like pre-XEdDSA firmware. + -DMESHTASTIC_EXCLUDE_XEDDSA=1 ; Individual STM32WL variants opt in after size validation. -DSERIAL_RX_BUFFER_SIZE=256 ; For GPS - the default of 64 is too small. -DHAS_SCREEN=0 ; Always disable screen for STM32, it is not supported. ;-DPIO_FRAMEWORK_ARDUINO_NANOLIB_FLOAT_PRINTF ; Enable this if enabling debugg logging. It is REQUIRED for at least traceroute debug prints - without it the length returned by printf ends up uninitialized. @@ -61,4 +61,4 @@ lib_ignore = OneButton ; Set a custom linker script with a higher MinStackSize value, to match NRF52. -board_build.ldscript = $PROJECT_DIR/variants/stm32/stm32wle5xx.ld \ No newline at end of file +board_build.ldscript = $PROJECT_DIR/variants/stm32/stm32wle5xx.ld