diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index 59c6701c9..fa3bcd331 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -12,10 +12,18 @@ #include #include #include -#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) -#if !defined(ARCH_STM32WL) -#define CryptRNG RNG + +#if !(MESHTASTIC_EXCLUDE_XEDDSA) +#include "XEdDSA.h" +#include + +#ifndef NUM_LIMBS_256BIT +#define NUM_LIMBS_BITS(n) (((n) + sizeof(limb_t) * 8 - 1) / (8 * sizeof(limb_t))) +#define NUM_LIMBS_256BIT NUM_LIMBS_BITS(256) #endif +#endif + +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) /** * Create a public/private key pair with Curve25519. @@ -46,6 +54,9 @@ void CryptoEngine::generateKeyPair(uint8_t *pubKey, uint8_t *privKey) Curve25519::dh1(public_key, private_key); memcpy(pubKey, public_key, sizeof(public_key)); memcpy(privKey, private_key, sizeof(private_key)); +#if !(MESHTASTIC_EXCLUDE_XEDDSA) + XEdDSA::priv_curve_to_ed_keys(private_key, xeddsa_private_key, xeddsa_public_key); +#endif } /** @@ -65,6 +76,9 @@ bool CryptoEngine::regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey) } memcpy(private_key, privKey, sizeof(private_key)); memcpy(public_key, pubKey, sizeof(public_key)); +#if !(MESHTASTIC_EXCLUDE_XEDDSA) + XEdDSA::priv_curve_to_ed_keys(private_key, xeddsa_private_key, xeddsa_public_key); +#endif } else { LOG_WARN("X25519 key generation failed due to blank private key"); return false; @@ -72,6 +86,97 @@ bool CryptoEngine::regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey) return true; } +#if !(MESHTASTIC_EXCLUDE_XEDDSA) +/** + * Build a signing buffer that covers packet metadata and payload: + * [fromNode(4) | packetId(4) | portnum(4) | payload(N)] + * This prevents replay, reattribution, and portnum redirection attacks. + */ +static size_t buildSigningBuffer(uint8_t *buf, size_t bufSize, uint32_t fromNode, uint32_t packetId, uint32_t portnum, + const uint8_t *payload, size_t payloadLen) +{ + const size_t headerLen = sizeof(uint32_t) * 3; + size_t totalLen = headerLen + payloadLen; + if (totalLen > bufSize) + return 0; + // May need endian conversion for oddball platforms. + memcpy(buf, &fromNode, sizeof(uint32_t)); + memcpy(buf + sizeof(uint32_t), &packetId, sizeof(uint32_t)); + memcpy(buf + sizeof(uint32_t) * 2, &portnum, sizeof(uint32_t)); + memcpy(buf + headerLen, payload, payloadLen); + return totalLen; +} + +bool CryptoEngine::xeddsa_sign(uint32_t fromNode, uint32_t packetId, uint32_t portnum, const uint8_t *payload, size_t payloadLen, + uint8_t *signature) +{ + if (memfll(xeddsa_private_key, 0, sizeof(xeddsa_private_key))) + return false; + uint8_t sigBuf[MAX_BLOCKSIZE]; + size_t sigLen = buildSigningBuffer(sigBuf, sizeof(sigBuf), fromNode, packetId, portnum, payload, payloadLen); + if (sigLen == 0) + return false; + // the XEdDSA::sign function requires at least the first 32 bytes of signature to be pre-filled with randomness + HardwareRNG::fill(signature, 32); + XEdDSA::sign(signature, xeddsa_private_key, xeddsa_public_key, sigBuf, sigLen); + return true; +} + +bool CryptoEngine::xeddsa_verify(const uint8_t *pubKey, uint32_t fromNode, uint32_t packetId, uint32_t portnum, + const uint8_t *payload, size_t payloadLen, const uint8_t *signature) +{ + // Use cached Ed25519 key if the Curve25519 key matches, avoiding expensive field inversion + if (memcmp(pubKey, cached_curve_pubkey, 32) != 0) { + curve_to_ed_pub(pubKey, cached_ed_pubkey); + memcpy(cached_curve_pubkey, pubKey, 32); + } + uint8_t sigBuf[MAX_BLOCKSIZE]; + size_t sigLen = buildSigningBuffer(sigBuf, sizeof(sigBuf), fromNode, packetId, portnum, payload, payloadLen); + if (sigLen == 0) + return false; + return XEdDSA::verify(signature, cached_ed_pubkey, sigBuf, sigLen); +} + +void CryptoEngine::curve_to_ed_pub(const uint8_t *curve_pubkey, uint8_t *ed_pubkey) +{ + + // Apply the birational map defined in RFC 7748, section 4.1 "Curve25519" to calculate an Ed25519 public + // key from a Curve25519 public key. Because the serialization format of Curve25519 public keys only + // contains the u coordinate, the x coordinate of the corresponding Ed25519 public key can't be uniquely + // calculated as defined by the birational map. The x coordinate is represented in the serialization + // format of Ed25519 public keys only in a single sign bit. XEdDSA always normalizes the Ed25519 public + // key to a sign bit of zero (the signer negates its key pair when needed), so this function clears the + // sign bit unconditionally below instead of taking it as an input. + fe u, y; + fe one; + fe u_minus_one, u_plus_one, u_plus_one_inv; + + // Parse the Curve25519 public key input as a field element containing the u coordinate. RFC 7748, + // section 5 "The X25519 and X448 Functions", mandates that the most significant bit of the Curve25519 + // public key has to be zeroized. This is handled by fe_frombytes internally. + fe_frombytes(u, curve_pubkey); + + // Calculate the parameters (u - 1) and (u + 1) + fe_1(one); + fe_sub(u_minus_one, u, one); + fe_add(u_plus_one, u, one); + + // Invert u + 1 + fe_invert(u_plus_one_inv, u_plus_one); + + // Calculate y = (u - 1) * inv(u + 1) (mod p) + fe_mul(y, u_minus_one, u_plus_one_inv); + + // Serialize the field element containing the y coordinate to the Ed25519 public key output + fe_tobytes(ed_pubkey, y); + + // Set the sign bit to zero + ed_pubkey[31] &= 0x7f; + + // need to convert the pubkey y = ( u - 1) * inv( u + 1) (mod p). +} +#endif + bool CryptoEngine::ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user) { if (user.is_licensed) { diff --git a/src/mesh/CryptoEngine.h b/src/mesh/CryptoEngine.h index 9410b63e7..96e1909b3 100644 --- a/src/mesh/CryptoEngine.h +++ b/src/mesh/CryptoEngine.h @@ -23,6 +23,7 @@ struct CryptoKey { #define MAX_BLOCKSIZE 256 #define TEST_CURVE25519_FIELD_OPS // Exposes Curve25519::isWeakPoint() for testing keys +#define XEDDSA_SIGNATURE_SIZE 64 class CryptoEngine { @@ -37,7 +38,12 @@ class CryptoEngine virtual void generateKeyPair(uint8_t *pubKey, uint8_t *privKey); virtual bool regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey); virtual bool ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user); - +#endif +#if !(MESHTASTIC_EXCLUDE_XEDDSA) + bool xeddsa_sign(uint32_t fromNode, uint32_t packetId, uint32_t portnum, const uint8_t *payload, size_t payloadLen, + uint8_t *signature); + bool xeddsa_verify(const uint8_t *pubKey, uint32_t fromNode, uint32_t packetId, uint32_t portnum, const uint8_t *payload, + size_t payloadLen, const uint8_t *signature); #endif void setDHPrivateKey(uint8_t *_private_key); // The remotePublic key parameter takes the public_key bytes container from @@ -85,6 +91,14 @@ class CryptoEngine #if !(MESHTASTIC_EXCLUDE_PKI) uint8_t shared_key[32] = {0}; uint8_t private_key[32] = {0}; +#if !(MESHTASTIC_EXCLUDE_XEDDSA) + uint8_t xeddsa_public_key[32] = {0}; + uint8_t xeddsa_private_key[32] = {0}; + void curve_to_ed_pub(const uint8_t *curve_pubkey, uint8_t *ed_pubkey); + // Single-entry cache for curve_to_ed_pub conversion (avoids expensive field inversion per packet) + uint8_t cached_curve_pubkey[32] = {0}; + uint8_t cached_ed_pubkey[32] = {0}; +#endif #endif /** * Init our 128 bit nonce for a new packet diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 32d1c75ab..2481eb8ca 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -448,8 +448,6 @@ NodeDB::NodeDB() // likewise - we always want the app requirements to come from the running appload myNodeInfo.min_app_version = 30200; // format is Mmmss (where M is 1+the numeric major number. i.e. 30200 means 2.2.00 - // Note! We do this after loading saved settings, so that if somehow an invalid nodenum was stored in preferences we won't - // keep using that nodenum forever. Crummy guess at our nodenum (but we will check against the nodedb to avoid conflicts) pickNewNodeNum(); // Set our board type so we can share it with others @@ -469,31 +467,18 @@ NodeDB::NodeDB() } #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - - if (!owner.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { - bool keygenSuccess = false; - keyIsLowEntropy = checkLowEntropyPublicKey(config.security.public_key); - if (config.security.private_key.size == 32 && !keyIsLowEntropy) { - if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { - keygenSuccess = true; - } - } else { - crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); - keygenSuccess = true; - } - if (keygenSuccess) { - config.security.public_key.size = 32; - config.security.private_key.size = 32; - owner.public_key.size = 32; - memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32); - } - } + // Generate crypto keys if needed using consolidated function + // Set my node num uint32 value to bytes from the public key (if we have one) + // Generate identity and crypto keys if needed; this will create a new identity if one does not exist + generateCryptoKeyPair(nullptr); #elif !(MESHTASTIC_EXCLUDE_PKI) // Calculate Curve25519 public and private keys if (config.security.private_key.size == 32 && config.security.public_key.size == 32) { owner.public_key.size = config.security.public_key.size; memcpy(owner.public_key.bytes, config.security.public_key.bytes, config.security.public_key.size); crypto->setDHPrivateKey(config.security.private_key.bytes); + // Set my node num uint32 value to bytes from the new public key + myNodeInfo.my_node_num = crc32Buffer(config.security.public_key.bytes, config.security.public_key.size); } #endif // Include our owner in the node db under our nodenum @@ -3059,6 +3044,99 @@ bool NodeDB::checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_pub } #endif +bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey) +{ +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + // Only generate keys for non-licensed users and if LoRa region is set + if (owner.is_licensed || config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + return false; + } + + bool keygenSuccess = false; + // Record whether the stored key is a known compromised/low-entropy key so main.cpp can warn the + // user. A detected low-entropy key is regenerated below, but the flag stays set so the + // "Compromised keys were detected and regenerated" notification still fires. + keyIsLowEntropy = checkLowEntropyPublicKey(config.security.public_key); + + // If a specific private key was provided, use it + if (privateKey != nullptr) { + LOG_INFO("Using provided private key for PKI"); + memcpy(config.security.private_key.bytes, privateKey, 32); + config.security.private_key.size = 32; + config.security.public_key.size = 32; + + // Generate public key from the provided private key + if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { + keygenSuccess = true; + } else { + LOG_ERROR("Failed to generate public key from provided private key"); + return false; + } + } + // Try to regenerate public key from existing private key if it's valid and not low entropy + else if (config.security.private_key.size == 32 && !keyIsLowEntropy) { + config.security.public_key.size = 32; + LOG_DEBUG("Regenerate PKI public key from existing private key"); + if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { + keygenSuccess = true; + } + } else { + // Generate a new key pair + LOG_INFO("Generate new PKI keys"); + config.security.public_key.size = 32; + config.security.private_key.size = 32; + crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); + keygenSuccess = true; + } + + // Update sizes and copy to owner if successful + if (keygenSuccess) { + owner.public_key.size = 32; + memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32); + + // Set the DH private key for crypto operations + LOG_DEBUG("Set DH private key for crypto operations"); + crypto->setDHPrivateKey(config.security.private_key.bytes); + + // Conditionally create new identity based on parameter + createNewIdentity(); + } + return keygenSuccess; +#else + return false; +#endif +} + +bool NodeDB::createNewIdentity() +{ + uint32_t oldNodeNum = getNodeNum(); + uint32_t newNodeNum = crc32Buffer(config.security.public_key.bytes, config.security.public_key.size); + + // If the key hasn't changed, nothing to do + if (newNodeNum == oldNodeNum) + return false; + + // Retire the old node entry + meshtastic_NodeInfoLite *node = getMeshNode(oldNodeNum); + if (node != NULL) { + LOG_DEBUG("Old node num %u is now %u", oldNodeNum, newNodeNum); + nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_IGNORED_MASK, true); + node->public_key.size = 0; + memset(node->public_key.bytes, 0, sizeof(node->public_key.bytes)); + } + + // Drop satellite-store entries (position/telemetry/environment/status) keyed by the retired + // node number so stale data isn't left attached to the old identity. + eraseNodeSatellites(oldNodeNum); + + myNodeInfo.my_node_num = newNodeNum; + + meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getNodeNum()); + TypeConversions::CopyUserToNodeInfoLite(info, owner); + + return true; +} + bool NodeDB::backupPreferences(meshtastic_AdminMessage_BackupLocation location) { bool success = false; diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index cbd2cfa8c..3fe244294 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -376,6 +376,12 @@ class NodeDB bool checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_public_key_t &keyToTest); #endif + /// Consolidate crypto key generation logic used across multiple modules + /// @param privateKey Optional 32-byte private key to use. If nullptr, generates new random keys. + bool generateCryptoKeyPair(const uint8_t *privateKey = nullptr); + + bool createNewIdentity(); + bool backupPreferences(meshtastic_AdminMessage_BackupLocation location); bool restorePreferences(meshtastic_AdminMessage_BackupLocation location, int restoreWhat = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS); @@ -519,7 +525,9 @@ extern uint32_t error_address; #define NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK (1u << NODEINFO_BITFIELD_IS_UNMESSAGABLE_SHIFT) #define NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_SHIFT 8 #define NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK (1u << NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_SHIFT) -// Bits 9..31 reserved for future single-bit flags. +#define NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_SHIFT 9 +#define NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK (1u << NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_SHIFT) +// Bits 10..31 reserved for future single-bit flags. // Convenience accessors so call sites read like the old struct fields. inline bool nodeInfoLiteHasUser(const meshtastic_NodeInfoLite *n) @@ -558,6 +566,10 @@ inline bool nodeInfoLiteIsKeyManuallyVerified(const meshtastic_NodeInfoLite *n) { return n && (n->bitfield & NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK); } +inline bool nodeInfoLiteHasXeddsaSigned(const meshtastic_NodeInfoLite *n) +{ + return n && (n->bitfield & NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK); +} inline void nodeInfoLiteSetBit(meshtastic_NodeInfoLite *n, uint32_t mask, bool value) { diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 300dcb0f6..f176f60e6 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -559,6 +559,38 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) if (p->decoded.has_bitfield) p->decoded.want_response |= p->decoded.bitfield & BITFIELD_WANT_RESPONSE_MASK; +#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) + if (p->decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE) { + meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from); + if (node && node->public_key.size == 32) { + p->xeddsa_signed = + crypto->xeddsa_verify(node->public_key.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) { + // Mark this node as a signer so future unsigned packets from it are rejected + nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); + LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from); + } else { + LOG_WARN("XEdDSA signature verification failed from 0x%08x, dropping", p->from); + return DecodeState::DECODE_FAILURE; + } + } else { + LOG_DEBUG("No public key for 0x%08x, cannot verify XEdDSA signature", p->from); + } + } else { + // Unsigned packet — only reject the class of packet a signing node always signs: + // an unencrypted broadcast small enough to also carry a signature (see perhapsEncode()). + // Unicast packets and oversized broadcasts are never signed, so they must not be + // hard-failed here even if this node has signed before. + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from); + if (node && nodeInfoLiteHasXeddsaSigned(node) && isBroadcast(p->to) && + p->decoded.payload.size + XEDDSA_SIGNATURE_SIZE < meshtastic_Constants_DATA_PAYLOAD_LEN) { + LOG_WARN("Dropping unsigned broadcast from 0x%08x that previously signed", p->from); + return DecodeState::DECODE_FAILURE; + } + } +#endif + /* Not actually ever used. // Decompress if needed. jm if (p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP) { @@ -629,6 +661,18 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) p->decoded.has_bitfield = true; p->decoded.bitfield |= (config.lora.config_ok_to_mqtt << BITFIELD_OK_TO_MQTT_SHIFT); p->decoded.bitfield |= (p->decoded.want_response << BITFIELD_WANT_RESPONSE_SHIFT); +#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) + // Sign broadcast packets if payload + signature fits within the max Data payload. + // The actual encoded size is checked after pb_encode (TOO_LARGE). + if (!p->pki_encrypted && isBroadcast(p->to) && + p->decoded.payload.size + XEDDSA_SIGNATURE_SIZE < meshtastic_Constants_DATA_PAYLOAD_LEN) { + 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; + LOG_DEBUG("XEdDSA signed packet 0x%08x", p->id); + } + } +#endif } size_t numbytes = pb_encode_to_bytes(bytes, sizeof(bytes), &meshtastic_Data_msg, &p->decoded); diff --git a/src/mesh/TypeConversions.cpp b/src/mesh/TypeConversions.cpp index cdcf0b328..9a56845bd 100644 --- a/src/mesh/TypeConversions.cpp +++ b/src/mesh/TypeConversions.cpp @@ -18,6 +18,7 @@ meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfo info.is_ignored = nodeInfoLiteIsIgnored(lite); info.is_key_manually_verified = nodeInfoLiteIsKeyManuallyVerified(lite); info.is_muted = nodeInfoLiteIsMuted(lite); + info.has_xeddsa_signed = nodeInfoLiteHasXeddsaSigned(lite); if (lite->has_hops_away) { info.has_hops_away = true; diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index d5ccbaf12..8473930e7 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -111,8 +111,12 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta // and only allowing responses from that remote. if (messageIsResponse(r)) { LOG_DEBUG("Allow admin response message"); - } else if (mp.from == 0 && !mp.pki_encrypted) { - // Plain (non-PKC) local admin from BLE/USB client. + } else if (mp.from == 0) { + // Local admin from a BLE/USB/TCP client. from == 0 cannot arrive from the + // mesh: RF drops packets without a sender (RadioLibInterface) and MQTT treats + // from == 0 as our own downlink and ignores it. Clients may set pki_encrypted + // on self-addressed admin (the python CLI does), so don't use it to reroute + // local packets into the remote-PKC key check. // // Under MESHTASTIC_PHONEAPI_ACCESS_CONTROL, the per-connection auth // gate lives in PhoneAPI::handleToRadioPacket — any local admin @@ -987,22 +991,14 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) LOG_INFO("Set config: Security"); config.security = c.payload_variant.security; #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) && !(MESHTASTIC_EXCLUDE_PKI) - // If the client set the key to blank, go ahead and regenerate so long as we're not in ham mode - if (!owner.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { - if (config.security.private_key.size != 32) { - crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); - - } else { - if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { - config.security.public_key.size = 32; - } - } + // Only regenerate keys if the private key is not 32 bytes + if (config.security.private_key.size != 32) { + nodeDB->generateCryptoKeyPair(); + } + // If user provided a private key of correct size but no public key, generate the public key from private key + else if (config.security.private_key.size == 32 && config.security.public_key.size == 0) { + nodeDB->generateCryptoKeyPair(config.security.private_key.bytes); } -#endif - owner.public_key.size = config.security.public_key.size; - memcpy(owner.public_key.bytes, config.security.public_key.bytes, config.security.public_key.size); -#if !MESHTASTIC_EXCLUDE_PKI - crypto->setDHPrivateKey(config.security.private_key.bytes); #endif if (config.security.is_managed && !(config.security.admin_key[0].size == 32 || config.security.admin_key[1].size == 32 || config.security.admin_key[2].size == 32)) { @@ -1012,9 +1008,9 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) sendWarning(warning); } - if (config.security.debug_log_api_enabled == c.payload_variant.security.debug_log_api_enabled && - config.security.serial_enabled == c.payload_variant.security.serial_enabled) - requiresReboot = false; + changes = SEGMENT_CONFIG | SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; + + requiresReboot = true; break; case meshtastic_Config_device_ui_tag: diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index a891813cb..a97ce04a7 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -49,6 +49,12 @@ 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); + if (node && nodeInfoLiteHasXeddsaSigned(node) && !mp.xeddsa_signed) { + LOG_WARN("Dropping unsigned NodeInfo 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)); diff --git a/test/test_crypto/test_main.cpp b/test/test_crypto/test_main.cpp index 15f372398..b9573c314 100644 --- a/test/test_crypto/test_main.cpp +++ b/test/test_crypto/test_main.cpp @@ -2,6 +2,7 @@ #include "CryptoEngine.h" #include "TestUtil.h" +#include #include void HexToBytes(uint8_t *result, const std::string hex, size_t len = 0) @@ -152,6 +153,134 @@ void test_PKC(void) TEST_ASSERT_EQUAL_MEMORY(expected_decrypted, decrypted, 10); } +void test_XEdDSA(void) +{ + uint8_t private_key[32]; + uint8_t x_public_key[32]; + uint8_t ed_private_key[32]; + uint8_t ed_public_key[32]; + uint8_t ed_public_key2[32]; + uint8_t message[] = "This is a test!"; + uint8_t message2[] = "This is a test."; + uint8_t signature[64]; + uint32_t fromNode = 0x1234; + uint32_t packetId = 0xDEADBEEF; + uint32_t portnum = 1; + for (int times = 0; times < 10; times++) { + printf("Start of time %u\n", times); + crypto->generateKeyPair(x_public_key, private_key); + XEdDSA::priv_curve_to_ed_keys(private_key, ed_private_key, ed_public_key); + crypto->curve_to_ed_pub(x_public_key, ed_public_key2); + TEST_ASSERT_EQUAL_MEMORY(ed_public_key, ed_public_key2, 32); + + // Sign and verify with metadata + TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), signature)); + TEST_ASSERT(crypto->xeddsa_verify(x_public_key, fromNode, packetId, portnum, message, sizeof(message), signature)); + + // Different payload fails + TEST_ASSERT_FALSE( + crypto->xeddsa_verify(x_public_key, fromNode, packetId, portnum, message2, sizeof(message2), signature)); + + // Different fromNode fails + TEST_ASSERT_FALSE( + crypto->xeddsa_verify(x_public_key, fromNode + 1, packetId, portnum, message, sizeof(message), signature)); + + // Different packetId fails + TEST_ASSERT_FALSE( + crypto->xeddsa_verify(x_public_key, fromNode, packetId + 1, portnum, message, sizeof(message), signature)); + + // Different portnum fails + TEST_ASSERT_FALSE( + crypto->xeddsa_verify(x_public_key, fromNode, packetId, portnum + 1, message, sizeof(message), signature)); + } +} + +// A signature only verifies under the signer's own key; a different key (or an all-zero key) fails. +void test_XEdDSA_cross_key_reject(void) +{ + uint8_t pubA[32], privA[32]; + uint8_t pubB[32], privB[32]; + uint8_t signature[64]; + uint8_t message[] = "cross-key check"; + uint32_t fromNode = 0x4242, packetId = 0xABCD1234, portnum = 7; + + crypto->generateKeyPair(pubA, privA); // engine now holds key A + TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), signature)); + + crypto->generateKeyPair(pubB, privB); // unrelated key pair + + TEST_ASSERT_TRUE(crypto->xeddsa_verify(pubA, fromNode, packetId, portnum, message, sizeof(message), signature)); + TEST_ASSERT_FALSE(crypto->xeddsa_verify(pubB, fromNode, packetId, portnum, message, sizeof(message), signature)); + + uint8_t zeroKey[32] = {0}; + TEST_ASSERT_FALSE(crypto->xeddsa_verify(zeroKey, fromNode, packetId, portnum, message, sizeof(message), signature)); +} + +// Signing with an unset (all-zero) private key must fail rather than emit a bogus signature. +void test_XEdDSA_empty_key_sign_fails(void) +{ + CryptoEngine fresh; // freshly constructed: xeddsa_private_key is all zero + uint8_t signature[64]; + uint8_t message[] = "no key"; + TEST_ASSERT_FALSE(fresh.xeddsa_sign(0x1, 0x2, 0x3, message, sizeof(message), signature)); +} + +// curve_to_ed_pub caches the last converted key; verifying A, then B, then A must stay correct. +void test_XEdDSA_curve_to_ed_cache(void) +{ + uint8_t pubA[32], privA[32], sigA[64]; + uint8_t pubB[32], privB[32], sigB[64]; + uint8_t message[] = "cache check"; + uint32_t fromNode = 0x11, packetId = 0x22, portnum = 3; + + crypto->generateKeyPair(pubA, privA); + TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), sigA)); + crypto->generateKeyPair(pubB, privB); + TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), sigB)); + + // Interleave keys to exercise both cache hits and cache invalidation. + TEST_ASSERT_TRUE(crypto->xeddsa_verify(pubA, fromNode, packetId, portnum, message, sizeof(message), sigA)); + TEST_ASSERT_TRUE(crypto->xeddsa_verify(pubB, fromNode, packetId, portnum, message, sizeof(message), sigB)); + TEST_ASSERT_TRUE(crypto->xeddsa_verify(pubA, fromNode, packetId, portnum, message, sizeof(message), sigA)); + TEST_ASSERT_FALSE(crypto->xeddsa_verify(pubA, fromNode, packetId, portnum, message, sizeof(message), sigB)); +} + +// A payload at the maximum signable size (DATA_PAYLOAD_LEN - signature) round-trips and detects tampering. +void test_XEdDSA_max_payload(void) +{ + const size_t len = meshtastic_Constants_DATA_PAYLOAD_LEN - XEDDSA_SIGNATURE_SIZE; + uint8_t payload[meshtastic_Constants_DATA_PAYLOAD_LEN]; + for (size_t i = 0; i < len; i++) + payload[i] = (uint8_t)(i * 7 + 1); + + uint8_t pub[32], priv[32], signature[64]; + crypto->generateKeyPair(pub, priv); + uint32_t fromNode = 0xFEED, packetId = 0xC0DE, portnum = 1; + + TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, payload, len, signature)); + TEST_ASSERT(crypto->xeddsa_verify(pub, fromNode, packetId, portnum, payload, len, signature)); + payload[0] ^= 0x01; + TEST_ASSERT_FALSE(crypto->xeddsa_verify(pub, fromNode, packetId, portnum, payload, len, signature)); +} + +// Signing the same message twice yields signatures that both verify. This XEdDSA implementation is +// deterministic in practice (the two signatures are typically byte-identical, even though +// HardwareRNG::fill provides real entropy on this platform), so we assert only the security-relevant +// property — every produced signature verifies — rather than asserting (non-)determinism. +void test_XEdDSA_repeated_sign_verifies(void) +{ + uint8_t pub[32], priv[32], sig1[64], sig2[64]; + uint8_t message[] = "same message"; + uint32_t fromNode = 0x9, packetId = 0x9, portnum = 9; + + crypto->generateKeyPair(pub, priv); + TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), sig1)); + TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), sig2)); + + TEST_ASSERT_TRUE(crypto->xeddsa_verify(pub, fromNode, packetId, portnum, message, sizeof(message), sig1)); + TEST_ASSERT_TRUE(crypto->xeddsa_verify(pub, fromNode, packetId, portnum, message, sizeof(message), sig2)); +} + void test_AES_CTR(void) { uint8_t expected[32]; @@ -192,6 +321,12 @@ void setup() RUN_TEST(test_DH25519); RUN_TEST(test_AES_CTR); RUN_TEST(test_PKC); + RUN_TEST(test_XEdDSA); + RUN_TEST(test_XEdDSA_cross_key_reject); + RUN_TEST(test_XEdDSA_empty_key_sign_fails); + RUN_TEST(test_XEdDSA_curve_to_ed_cache); + RUN_TEST(test_XEdDSA_max_payload); + RUN_TEST(test_XEdDSA_repeated_sign_verifies); exit(UNITY_END()); // stop unit testing } diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp new file mode 100644 index 000000000..03949db80 --- /dev/null +++ b/test/test_packet_signing/test_main.cpp @@ -0,0 +1,392 @@ +// Tests for XEdDSA packet-signing *policy* — the receive-path accept/reject behavior and the +// send-path signing policy — as opposed to the raw sign/verify primitive (covered in test_crypto). +// +// The decision logic under test lives inside perhapsDecode()/perhapsEncode() (free functions in +// Router.cpp). It only runs after a packet is decrypted, so every case drives a real +// encode -> decode round-trip through the default channel (black-box, no production changes). +// +// 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 stricter "drop unsigned NodeInfo from a known signer" rule + +#include "MeshTypes.h" // include BEFORE TestUtil.h +#include "TestUtil.h" +#include + +#if !(MESHTASTIC_EXCLUDE_PKI) + +#include "mesh/Channels.h" +#include "mesh/CryptoEngine.h" +#include "mesh/NodeDB.h" +#include "mesh/Router.h" +#include "modules/NodeInfoModule.h" +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Test fixture identifiers +// --------------------------------------------------------------------------- +static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; +static constexpr NodeNum REMOTE_NODE = 0x0B0B0B0B; + +// A "small" broadcast payload that leaves room for a 64-byte signature (payload + 64 < 233), +// and an "oversized" one that does not (payload + 64 >= 233) yet still encodes within a LoRa frame. +static constexpr size_t SMALL_PAYLOAD = 16; +static constexpr size_t OVERSIZED_PAYLOAD = 180; + +// --------------------------------------------------------------------------- +// MockNodeDB — inject nodes with controlled public keys / signer bits. +// Mirrors the pattern in test/test_hop_scaling. meshNodes/numMeshNodes are public on NodeDB. +// --------------------------------------------------------------------------- +class MockNodeDB : public NodeDB +{ + public: + void clearTestNodes() + { + testNodes.clear(); + meshNodes = &testNodes; + numMeshNodes = 0; + } + + // Add a bare node and return a stable handle (fetch via getMeshNode so the pointer stays valid + // even if the vector reallocates after later adds). + void addNode(NodeNum num) + { + meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero; + node.num = num; + testNodes.push_back(node); + meshNodes = &testNodes; + numMeshNodes = testNodes.size(); + } + + void setPublicKey(NodeNum num, const uint8_t *pubKey) + { + meshtastic_NodeInfoLite *n = getMeshNode(num); + TEST_ASSERT_NOT_NULL(n); + n->public_key.size = 32; + memcpy(n->public_key.bytes, pubKey, 32); + } + + void setSignerBit(NodeNum num, bool value) + { + meshtastic_NodeInfoLite *n = getMeshNode(num); + TEST_ASSERT_NOT_NULL(n); + nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, value); + } + + std::vector testNodes; +}; + +static MockNodeDB *mockNodeDB = nullptr; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// Build a decoded packet with a deterministic payload of the requested size. +static meshtastic_MeshPacket makeDecoded(NodeNum from, NodeNum to, meshtastic_PortNum port, size_t payloadLen) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = to; + p.id = 0x12345678; + p.channel = 0; // primary channel index (perhapsEncode rewrites this to the channel hash) + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = port; + p.decoded.payload.size = payloadLen; + for (size_t i = 0; i < payloadLen; i++) + p.decoded.payload.bytes[i] = (uint8_t)(i & 0xff); + return p; +} + +// Sign a decoded packet with the CryptoEngine's current key — used to simulate a *remote* signer, +// because perhapsEncode only auto-signs packets that originate from us. +static void signWithCurrentKey(meshtastic_MeshPacket *p) +{ + bool ok = crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size, + p->decoded.xeddsa_signature.bytes); + TEST_ASSERT_TRUE_MESSAGE(ok, "xeddsa_sign failed in test setup"); + p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE; +} + +// Encrypt (perhapsEncode) then decrypt+evaluate (perhapsDecode) the same packet in place. +static DecodeState roundTrip(meshtastic_MeshPacket *p) +{ + meshtastic_Routing_Error enc = perhapsEncode(p); + TEST_ASSERT_EQUAL_MESSAGE(meshtastic_Routing_Error_NONE, enc, "perhapsEncode did not succeed"); + TEST_ASSERT_EQUAL_MESSAGE(meshtastic_MeshPacket_encrypted_tag, p->which_payload_variant, + "perhapsEncode left packet unencrypted"); + return perhapsDecode(p); +} + +static bool remoteSignerBit() +{ + return nodeInfoLiteHasXeddsaSigned(mockNodeDB->getMeshNode(REMOTE_NODE)); +} + +// --------------------------------------------------------------------------- +// Unity lifecycle +// --------------------------------------------------------------------------- +void setUp(void) +{ + // Clean global config/owner; 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; + owner = meshtastic_User_init_zero; + + mockNodeDB = new MockNodeDB(); + mockNodeDB->clearTestNodes(); + nodeDB = mockNodeDB; + 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(); +} + +void tearDown(void) +{ + delete mockNodeDB; + mockNodeDB = nullptr; + nodeDB = nullptr; +} + +// =========================================================================== +// Group A — receive-side accept/reject matrix +// =========================================================================== + +// 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) +{ + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); // engine now holds REMOTE's key + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setPublicKey(REMOTE_NODE, pub); + + TEST_ASSERT_FALSE(remoteSignerBit()); // not known as a signer yet + + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + signWithCurrentKey(&p); + + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); + TEST_ASSERT_TRUE(p.xeddsa_signed); + TEST_ASSERT_TRUE_MESSAGE(remoteSignerBit(), "verified signature must set the signer bit"); +} + +// A2: a tampered signature from a known key -> dropped. +void test_A2_bad_signature_dropped(void) +{ + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setPublicKey(REMOTE_NODE, pub); + + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + signWithCurrentKey(&p); + p.decoded.xeddsa_signature.bytes[0] ^= 0xFF; // corrupt the signature + + TEST_ASSERT_EQUAL(DECODE_FAILURE, 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) +{ + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + mockNodeDB->addNode(REMOTE_NODE); // node exists, but no public key stored + + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + signWithCurrentKey(&p); + + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); + TEST_ASSERT_FALSE_MESSAGE(p.xeddsa_signed, "cannot be marked verified without a key"); + TEST_ASSERT_FALSE_MESSAGE(remoteSignerBit(), "must not learn signer without verifying"); +} + +// A4: downgrade protection — unsigned small broadcast from a known signer -> dropped. +void test_A4_downgrade_unsigned_broadcast_from_signer_dropped(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setSignerBit(REMOTE_NODE, true); // we've seen this node sign before + + 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)); +} + +// A5: no prior knowledge — unsigned small broadcast from a non-signer -> accepted. +void test_A5_unsigned_broadcast_from_nonsigner_accepted(void) +{ + mockNodeDB->addNode(REMOTE_NODE); // signer bit clear + + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); + TEST_ASSERT_FALSE(p.xeddsa_signed); +} + +// A6: unsigned UNICAST from a known signer -> accepted (unicasts are never signed). +void test_A6_unsigned_unicast_from_signer_accepted(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setSignerBit(REMOTE_NODE, true); + + // Unicast to us; PRIVATE_APP avoids the unrelated legacy-DM rejection for TEXT_MESSAGE_APP. + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_PRIVATE_APP, SMALL_PAYLOAD); + + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); +} + +// A7: unsigned OVERSIZED broadcast from a known signer -> accepted (couldn't have carried a sig). +void test_A7_unsigned_oversized_broadcast_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, OVERSIZED_PAYLOAD); + + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); +} + +// =========================================================================== +// Group B — send-side signing policy (perhapsEncode) +// =========================================================================== + +// B1: our own small broadcast is auto-signed (and verifies on the way back in). +void test_B1_local_broadcast_is_signed(void) +{ + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); // engine signs with this; store the matching pubkey for us + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, pub); + + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); + TEST_ASSERT_EQUAL_MESSAGE(XEDDSA_SIGNATURE_SIZE, p.decoded.xeddsa_signature.size, "broadcast should be auto-signed"); + TEST_ASSERT_TRUE(p.xeddsa_signed); +} + +// B2: our own 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); + + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); + TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "unicast must not be signed"); +} + +// B3: our own oversized broadcast is NOT signed (signature wouldn't fit). +void test_B3_local_oversized_broadcast_not_signed(void) +{ + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, OVERSIZED_PAYLOAD); + + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); + TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "oversized broadcast must not be signed"); +} + +// =========================================================================== +// Group C — NodeInfoModule downgrade drop (stricter: any unsigned NodeInfo from a known signer) +// =========================================================================== +class NodeInfoTestShim : public NodeInfoModule +{ + public: + using NodeInfoModule::handleReceivedProtobuf; // protected virtual -> exposed for direct call +}; + +static meshtastic_MeshPacket makeNodeInfoPacket(bool signed_) +{ + // 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; +} + +// C1: unsigned NodeInfo from a node that previously signed -> dropped. +void test_C1_unsigned_nodeinfo_from_signer_dropped(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setSignerBit(REMOTE_NODE, true); + + NodeInfoTestShim shim; + meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/false); + meshtastic_User user = meshtastic_User_init_zero; + user.is_licensed = owner.is_licensed; + + TEST_ASSERT_TRUE_MESSAGE(shim.handleReceivedProtobuf(mp, &user), "unsigned NodeInfo from signer must be dropped"); +} + +// C2: signed NodeInfo from a known signer -> not dropped by this rule. +void test_C2_signed_nodeinfo_from_signer_not_dropped(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setSignerBit(REMOTE_NODE, true); + + NodeInfoTestShim shim; + meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/true); + meshtastic_User user = meshtastic_User_init_zero; + user.is_licensed = owner.is_licensed; + + TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user)); +} + +// C3: unsigned NodeInfo from a node we've never seen sign -> not dropped. +void test_C3_unsigned_nodeinfo_from_nonsigner_not_dropped(void) +{ + mockNodeDB->addNode(REMOTE_NODE); // signer bit clear + + NodeInfoTestShim shim; + meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/false); + meshtastic_User user = meshtastic_User_init_zero; + user.is_licensed = owner.is_licensed; + + TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user)); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== Group A: receive-side accept/reject ===\n"); + RUN_TEST(test_A1_valid_signature_accepted_and_learns_signer); + RUN_TEST(test_A2_bad_signature_dropped); + RUN_TEST(test_A3_signed_no_pubkey_accepted_unverified); + RUN_TEST(test_A4_downgrade_unsigned_broadcast_from_signer_dropped); + RUN_TEST(test_A5_unsigned_broadcast_from_nonsigner_accepted); + RUN_TEST(test_A6_unsigned_unicast_from_signer_accepted); + RUN_TEST(test_A7_unsigned_oversized_broadcast_from_signer_accepted); + + printf("\n=== Group B: send-side signing policy ===\n"); + RUN_TEST(test_B1_local_broadcast_is_signed); + RUN_TEST(test_B2_local_unicast_not_signed); + RUN_TEST(test_B3_local_oversized_broadcast_not_signed); + + 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); + + exit(UNITY_END()); +} + +void loop() {} + +#else // MESHTASTIC_EXCLUDE_PKI + +void setUp(void) {} +void tearDown(void) {} +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} +void loop() {} + +#endif diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index 726018d4b..a94defff2 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -70,8 +70,8 @@ lib_deps = https://github.com/mverch67/libpax/archive/6f52ee989301cdabaeef00bcbf93bff55708ce2f.zip # renovate: datasource=custom.pio depName=XPowersLib packageName=lewisxhe/library/XPowersLib lewisxhe/XPowersLib@0.3.3 - # renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto - rweather/Crypto@0.4.0 + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master + https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip lib_ignore = segger_rtt diff --git a/variants/esp32/esp32.ini b/variants/esp32/esp32.ini index 50b475a6c..511c94870 100644 --- a/variants/esp32/esp32.ini +++ b/variants/esp32/esp32.ini @@ -55,5 +55,5 @@ lib_deps = https://github.com/mverch67/libpax/archive/6f52ee989301cdabaeef00bcbf93bff55708ce2f.zip # renovate: datasource=custom.pio depName=XPowersLib packageName=lewisxhe/library/XPowersLib lewisxhe/XPowersLib@0.3.3 - # renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto - rweather/Crypto@0.4.0 + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master + https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip diff --git a/variants/esp32p4/esp32p4.ini b/variants/esp32p4/esp32p4.ini index 44094ee04..7995a5205 100644 --- a/variants/esp32p4/esp32p4.ini +++ b/variants/esp32p4/esp32p4.ini @@ -104,7 +104,7 @@ lib_deps = ${networking_extra.lib_deps} ${environmental_base.lib_deps} ${radiolib_base.lib_deps} - # renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto - rweather/Crypto@0.4.0 + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master + https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip # renovate: datasource=git-refs depName=meshtastic-ESP32_Codec2 packageName=https://github.com/meshtastic/ESP32_Codec2 gitBranch=master https://github.com/meshtastic/ESP32_Codec2/archive/633326c78ac251c059ab3a8c430fcdf25b41672f.zip diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 978b1fdd6..9006f926a 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -25,8 +25,8 @@ lib_deps = ${networking_extra.lib_deps} ${radiolib_base.lib_deps} ${environmental_base.lib_deps} - # renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto - rweather/Crypto@0.4.0 + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto + https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX lovyan03/LovyanGFX@1.2.21 ; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/pine64/libch341-spi-userspace gitBranch=main diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index d334a1901..642dfdb10 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -124,7 +124,7 @@ test_testing_command = ; USB-SPI bridge. No BlueZ, libgpiod, or Linux I2C — those require Linux. ; ; Prerequisites (Homebrew): -; brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config +; brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config jsoncpp ; # Optional: enable the HTTP API (PiWebServer) on macOS: ; brew install ulfius ; @@ -176,6 +176,9 @@ extends = native_base ; the macOS dev loop anyway, and Apple ld's equivalent (`-Wl,-map,`) ; uses different argument shape. build_unflags = -Wl,-Map,"${platformio.build_dir}"/output.map + ; libi2c is Linux-only but build_flags_common carries -li2c (also duplicated in the Linux-only + ; build_flags); macOS has no libi2c, so strip it here to let the link succeed. + -li2c build_flags = ${portduino_base.build_flags_common} -I variants/native/portduino -I/opt/homebrew/include diff --git a/variants/nrf52840/nrf52.ini b/variants/nrf52840/nrf52.ini index 189aba1e7..f99c78d8a 100644 --- a/variants/nrf52840/nrf52.ini +++ b/variants/nrf52840/nrf52.ini @@ -48,8 +48,8 @@ build_src_filter = lib_deps= ${arduino_base.lib_deps} ${radiolib_base.lib_deps} - # renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto - rweather/Crypto@0.4.0 + # renovate: datasource=github-tags depName=meshtastic/Crypto packageName=meshtastic/Crypto + https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip lib_ignore = BluetoothOTA diff --git a/variants/nrf54l15/nrf54l15.ini b/variants/nrf54l15/nrf54l15.ini index 45dab1a41..d772de4d5 100644 --- a/variants/nrf54l15/nrf54l15.ini +++ b/variants/nrf54l15/nrf54l15.ini @@ -53,7 +53,8 @@ lib_compat_mode = off lib_deps = ${arduino_base.lib_deps} ${radiolib_base.lib_deps} - rweather/Crypto@0.4.0 + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master + https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip ; Cherry-picked sensor libs from environmental_base. The full ; environmental_base pulls Adafruit_SSD1306 / GFX which need Arduino ; pin macros (digitalPinToPort / portOutputRegister) that the Zephyr diff --git a/variants/rp2040/rp2040.ini b/variants/rp2040/rp2040.ini index fb29153ea..2b7002397 100644 --- a/variants/rp2040/rp2040.ini +++ b/variants/rp2040/rp2040.ini @@ -31,5 +31,5 @@ lib_deps = ${environmental_base.lib_deps} ${environmental_extra.lib_deps} ${radiolib_base.lib_deps} - # renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto - rweather/Crypto@0.4.0 + # renovate: datasource=github-tags depName=meshtastic/Crypto packageName=meshtastic/Crypto + https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip diff --git a/variants/rp2350/rp2350.ini b/variants/rp2350/rp2350.ini index 964545311..2931fd7b6 100644 --- a/variants/rp2350/rp2350.ini +++ b/variants/rp2350/rp2350.ini @@ -28,5 +28,5 @@ lib_deps = ${environmental_base.lib_deps} ${environmental_extra.lib_deps} ${radiolib_base.lib_deps} - # renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto - rweather/Crypto@0.4.0 + # renovate: datasource=github-tags depName=meshtastic/Crypto packageName=meshtastic/Crypto + https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index 051a25524..2116b046e 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -25,6 +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. -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. @@ -53,8 +54,8 @@ debug_tool = stlink lib_deps = ${env.lib_deps} ${radiolib_base.lib_deps} - # renovate: datasource=git-refs depName=caveman99-stm32-Crypto packageName=https://github.com/caveman99/Crypto gitBranch=main - https://github.com/caveman99/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main + https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip lib_ignore = OneButton