Merge branch 'develop' into nailed-test-number
This commit is contained in:
@@ -220,6 +220,44 @@ bool detectSHT21SerialNumber(TwoWire *i2cBus, uint8_t address)
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Everest Semiconductor ES7210 4-channel audio ADC, as found on the T-Deck. Its AD1/AD0 straps
|
||||||
|
// select 0x40..0x43, so it lands squarely on the INA/SHT2X addresses. Chip ID registers and their
|
||||||
|
// reset values, per the ES7210 datasheet rev 2.0.
|
||||||
|
static constexpr uint8_t ES7210_CHIP_ID1_REG = 0x3D;
|
||||||
|
static constexpr uint8_t ES7210_CHIP_ID1 = 0x72;
|
||||||
|
static constexpr uint8_t ES7210_CHIP_ID0_REG = 0x3E;
|
||||||
|
static constexpr uint8_t ES7210_CHIP_ID0 = 0x10;
|
||||||
|
|
||||||
|
static bool readByteRegister(TwoWire *i2cBus, uint8_t address, uint8_t reg, uint8_t &value)
|
||||||
|
{
|
||||||
|
i2cBus->beginTransmission(address);
|
||||||
|
i2cBus->write(reg);
|
||||||
|
|
||||||
|
if (i2cBus->endTransmission() != 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (i2cBus->requestFrom(address, (uint8_t)1) != 1 || !i2cBus->available())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
value = i2cBus->read();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The INA219 has no manufacturer or die ID register to check, so it is inferred from "something
|
||||||
|
// answered here and it wasn't anything else we know". That makes positively identifying the other
|
||||||
|
// occupants of this address the only way to keep them out of the fallback.
|
||||||
|
static bool detectES7210(TwoWire *i2cBus, uint8_t address)
|
||||||
|
{
|
||||||
|
uint8_t id = 0;
|
||||||
|
|
||||||
|
// Read the high ID byte first so anything that clearly isn't an ES7210 costs a single
|
||||||
|
// transaction, then confirm with the low byte.
|
||||||
|
if (!readByteRegister(i2cBus, address, ES7210_CHIP_ID1_REG, id) || id != ES7210_CHIP_ID1)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return readByteRegister(i2cBus, address, ES7210_CHIP_ID0_REG, id) && id == ES7210_CHIP_ID0;
|
||||||
|
}
|
||||||
|
|
||||||
#define SCAN_SIMPLE_CASE(ADDR, T, ...) \
|
#define SCAN_SIMPLE_CASE(ADDR, T, ...) \
|
||||||
case ADDR: \
|
case ADDR: \
|
||||||
logFoundDevice(__VA_ARGS__); \
|
logFoundDevice(__VA_ARGS__); \
|
||||||
@@ -472,13 +510,23 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
|||||||
type = INA260;
|
type = INA260;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The ES7210 audio ADC shares this address on some boards (T-Deck). It answers
|
||||||
|
// none of the checks above, so identify it here and leave the type unset - driving
|
||||||
|
// an audio codec as if it were a power monitor crashes the device. See #11115.
|
||||||
|
if (type == NONE && detectES7210(i2cBus, (uint8_t)addr.address)) {
|
||||||
|
LOG_INFO("ES7210 audio codec at 0x%x, not a power sensor", (uint8_t)addr.address);
|
||||||
|
break;
|
||||||
|
}
|
||||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
|
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
|
||||||
if (type == NONE && detectSHT21SerialNumber(i2cBus, (uint8_t)addr.address)) {
|
if (type == NONE && detectSHT21SerialNumber(i2cBus, (uint8_t)addr.address)) {
|
||||||
logFoundDevice("SHTXX (SHT2X)", (uint8_t)addr.address);
|
logFoundDevice("SHTXX (SHT2X)", (uint8_t)addr.address);
|
||||||
type = SHTXX;
|
type = SHTXX;
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
else { // Assume INA219 if none of the above ones are found
|
// Guarded on the type rather than chained to the SHT2X check above, so that a
|
||||||
|
// positively identified INA226/INA260 isn't overwritten right after being found.
|
||||||
|
if (type == NONE) { // Assume INA219 if none of the above ones are found
|
||||||
logFoundDevice("INA219", (uint8_t)addr.address);
|
logFoundDevice("INA219", (uint8_t)addr.address);
|
||||||
type = INA219;
|
type = INA219;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1111,6 +1111,23 @@ void setup()
|
|||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(SENSECAP_INDICATOR)
|
||||||
|
// The ST7701 panel shares SCK/MOSI/MISO (41/48/47) with the SX1262, and its host is SPI2_HOST,
|
||||||
|
// which on the S3 is the same peripheral as the Arduino `SPI` object (FSPI == SPI2).
|
||||||
|
// LovyanGFX bit-bangs the ST7701 init sequence on those pins, and because this variant builds
|
||||||
|
// with USE_ARDUINO_HAL_GPIO it does so via Arduino pinMode()/digitalWrite(). pinMode() calls
|
||||||
|
// perimanSetPinBus(.., ESP32_BUS_TYPE_GPIO, ..), whose deinit callback (spiDetachBus_SCK) ends
|
||||||
|
// up in spiStopBus() and gates the SPI2 clock. RadioLib then spins forever in spiTransferByte()
|
||||||
|
// waiting on cmd.update, which never clears on a stopped peripheral, and the watchdog fires.
|
||||||
|
//
|
||||||
|
// Restart the bus here, after the panel is up and before the radio is touched. Note that
|
||||||
|
// SPIClass::begin() early-returns when _spi is already non-NULL, so end() first is required.
|
||||||
|
SPI.end();
|
||||||
|
SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, -1); // CS is an IO-expander pin, driven by RadioLib
|
||||||
|
SPI.setFrequency(4000000);
|
||||||
|
LOG_DEBUG("SPI2 restarted after ST7701 init (SCK=%d, MISO=%d, MOSI=%d)", LORA_SCK, LORA_MISO, LORA_MOSI);
|
||||||
|
#endif
|
||||||
|
|
||||||
auto rIf = initLoRa();
|
auto rIf = initLoRa();
|
||||||
|
|
||||||
lateInitVariant(); // Do board specific init (see extra_variants/README.md for documentation)
|
lateInitVariant(); // Do board specific init (see extra_variants/README.md for documentation)
|
||||||
@@ -1278,6 +1295,9 @@ extern meshtastic_DeviceMetadata getDeviceMetadata()
|
|||||||
|
|
||||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||||
deviceMetadata.hasPKC = true;
|
deviceMetadata.hasPKC = true;
|
||||||
|
#endif
|
||||||
|
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||||
|
deviceMetadata.has_xeddsa = true;
|
||||||
#endif
|
#endif
|
||||||
return deviceMetadata;
|
return deviceMetadata;
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-19
@@ -51,8 +51,8 @@ bool FloodingRouter::shouldFilterReceived(const meshtastic_MeshPacket *p)
|
|||||||
LOG_DEBUG("Repeated reliable tx");
|
LOG_DEBUG("Repeated reliable tx");
|
||||||
// Check if it's still in the Tx queue, if not, we have to relay it again
|
// Check if it's still in the Tx queue, if not, we have to relay it again
|
||||||
if (!findInTxQueue(p->from, p->id)) {
|
if (!findInTxQueue(p->from, p->id)) {
|
||||||
reprocessPacket(p);
|
if (reprocessPacket(p))
|
||||||
perhapsRebroadcast(p);
|
perhapsRebroadcast(p);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
perhapsCancelDupe(p);
|
perhapsCancelDupe(p);
|
||||||
@@ -68,6 +68,12 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p)
|
|||||||
{
|
{
|
||||||
// isRebroadcaster() is duplicated in perhapsRebroadcast(), but this avoids confusing log messages
|
// isRebroadcaster() is duplicated in perhapsRebroadcast(), but this avoids confusing log messages
|
||||||
if (isRebroadcaster() && iface && p->hop_limit > 0) {
|
if (isRebroadcaster() && iface && p->hop_limit > 0) {
|
||||||
|
// Verify the replacement before deleting the valid lower-hop copy waiting in the TX queue.
|
||||||
|
// This is intentionally redundant with ReliableRouter's ingress gate: it keeps this helper
|
||||||
|
// safe if another caller is introduced later.
|
||||||
|
if (passesRoutingAuthGate(const_cast<meshtastic_MeshPacket *>(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
|
// 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.
|
// 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
|
uint8_t dropThreshold = p->hop_limit; // remove queued packets that have fewer hops remaining
|
||||||
@@ -75,7 +81,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,
|
LOG_DEBUG("Processing upgraded packet 0x%08x for rebroadcast with hop limit %d (dropping queued < %d)", p->id,
|
||||||
p->hop_limit, dropThreshold);
|
p->hop_limit, dropThreshold);
|
||||||
|
|
||||||
reprocessPacket(p);
|
if (!reprocessPacket(p))
|
||||||
|
return true;
|
||||||
perhapsRebroadcast(p);
|
perhapsRebroadcast(p);
|
||||||
|
|
||||||
rxDupe++;
|
rxDupe++;
|
||||||
@@ -87,32 +94,24 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p)
|
|||||||
return false;
|
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<meshtastic_MeshPacket *>(p));
|
||||||
|
if (decodedState != DecodeState::DECODE_SUCCESS && decodedState != DecodeState::DECODE_OPAQUE)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (nodeDB)
|
if (nodeDB)
|
||||||
nodeDB->updateFrom(*p);
|
nodeDB->updateFrom(*p);
|
||||||
|
|
||||||
#if !MESHTASTIC_EXCLUDE_TRACEROUTE
|
#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<meshtastic_MeshPacket *>(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<int>(decodedState), p->id, getFrom(p));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (traceRouteModule && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
|
if (traceRouteModule && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
|
||||||
p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP) {
|
p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP) {
|
||||||
traceRouteModule->processUpgradedPacket(*p);
|
traceRouteModule->processUpgradedPacket(*p);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool FloodingRouter::roleAllowsCancelingDupe(const meshtastic_MeshPacket *p)
|
bool FloodingRouter::roleAllowsCancelingDupe(const meshtastic_MeshPacket *p)
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class FloodingRouter : public Router
|
|||||||
bool perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p);
|
bool perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p);
|
||||||
|
|
||||||
/* Call when we receive a packet that needs some reprocessing, but afterwards should be filtered */
|
/* 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
|
// Return false for roles like ROUTER which should always rebroadcast even when we've heard another rebroadcast of
|
||||||
// the same packet
|
// the same packet
|
||||||
@@ -75,4 +75,4 @@ class FloodingRouter : public Router
|
|||||||
|
|
||||||
// Return true if we are a rebroadcaster
|
// Return true if we are a rebroadcaster
|
||||||
bool isRebroadcaster();
|
bool isRebroadcaster();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,25 @@
|
|||||||
|
|
||||||
NextHopRouter::NextHopRouter() {}
|
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)
|
PendingPacket::PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions)
|
||||||
{
|
{
|
||||||
packet = p;
|
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);
|
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
|
// Check if it's still in the Tx queue, if not, we have to relay it again
|
||||||
if (!findInTxQueue(p->from, p->id)) {
|
if (!findInTxQueue(p->from, p->id)) {
|
||||||
reprocessPacket(p);
|
if (reprocessPacket(p))
|
||||||
perhapsRebroadcast(p);
|
perhapsRebroadcast(p);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
bool isRepeated = getHopsAway(*p) == 0;
|
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 repeated and not in Tx queue anymore, try relaying again, or if we are the destination, send the ACK again
|
||||||
if (isRepeated) {
|
if (isRepeated) {
|
||||||
if (!findInTxQueue(p->from, p->id)) {
|
if (!findInTxQueue(p->from, p->id)) {
|
||||||
reprocessPacket(p);
|
if (reprocessPacket(p) && !perhapsRebroadcast(p) && isToUs(p) && p->want_ack) {
|
||||||
if (!perhapsRebroadcast(p) && isToUs(p) && p->want_ack) {
|
|
||||||
sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel, 0);
|
sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ class NextHopRouter : public FloodingRouter
|
|||||||
* @return true to abandon the packet
|
* @return true to abandon the packet
|
||||||
*/
|
*/
|
||||||
virtual bool shouldFilterReceived(const meshtastic_MeshPacket *p) override;
|
virtual bool shouldFilterReceived(const meshtastic_MeshPacket *p) override;
|
||||||
|
bool relayOpaquePacket(const meshtastic_MeshPacket *p) override;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look for packets we need to relay
|
* Look for packets we need to relay
|
||||||
@@ -213,4 +214,4 @@ class NextHopRouter : public FloodingRouter
|
|||||||
/** Check if we should be rebroadcasting this packet if so, do so.
|
/** Check if we should be rebroadcasting this packet if so, do so.
|
||||||
* @return true if we did rebroadcast */
|
* @return true if we did rebroadcast */
|
||||||
bool perhapsRebroadcast(const meshtastic_MeshPacket *p) override;
|
bool perhapsRebroadcast(const meshtastic_MeshPacket *p) override;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1861,6 +1861,8 @@ bool NodeDB::enforceSatelliteCaps()
|
|||||||
// them if they do); otherwise tracker/sensor/tak_tracker are role-protected.
|
// them if they do); otherwise tracker/sensor/tak_tracker are role-protected.
|
||||||
static uint8_t warmProtectedCategory(const meshtastic_NodeInfoLite &n)
|
static uint8_t warmProtectedCategory(const meshtastic_NodeInfoLite &n)
|
||||||
{
|
{
|
||||||
|
if (nodeInfoLiteHasXeddsaSigned(&n))
|
||||||
|
return static_cast<uint8_t>(WarmProtected::XeddsaSigner);
|
||||||
if (n.bitfield & (NODEINFO_BITFIELD_IS_FAVORITE_MASK | NODEINFO_BITFIELD_IS_IGNORED_MASK |
|
if (n.bitfield & (NODEINFO_BITFIELD_IS_FAVORITE_MASK | NODEINFO_BITFIELD_IS_IGNORED_MASK |
|
||||||
NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK))
|
NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK))
|
||||||
return static_cast<uint8_t>(WarmProtected::Flag);
|
return static_cast<uint8_t>(WarmProtected::Flag);
|
||||||
@@ -3934,6 +3936,8 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
|
|||||||
lite->public_key.size = 32;
|
lite->public_key.size = 32;
|
||||||
memcpy(lite->public_key.bytes, warm.public_key, 32);
|
memcpy(lite->public_key.bytes, warm.public_key, 32);
|
||||||
}
|
}
|
||||||
|
if (warmProtOf(warm) == static_cast<uint8_t>(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);
|
LOG_MIGRATION("Rehydrated node 0x%08x from warm tier (key=%d)", n, lite->public_key.size == 32);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
+278
-43
@@ -12,6 +12,8 @@
|
|||||||
#include "mesh-pb-constants.h"
|
#include "mesh-pb-constants.h"
|
||||||
#include "meshUtils.h"
|
#include "meshUtils.h"
|
||||||
#include "modules/RoutingModule.h"
|
#include "modules/RoutingModule.h"
|
||||||
|
#include <ErriezCRC32.h>
|
||||||
|
#include <pb_decode.h>
|
||||||
#include <pb_encode.h>
|
#include <pb_encode.h>
|
||||||
#if HAS_TRAFFIC_MANAGEMENT
|
#if HAS_TRAFFIC_MANAGEMENT
|
||||||
#endif
|
#endif
|
||||||
@@ -66,6 +68,79 @@ Allocator<meshtastic_MeshPacket> &packetPool = staticPool;
|
|||||||
|
|
||||||
static uint8_t bytes[MAX_LORA_PAYLOAD_LEN + 1] __attribute__((__aligned__));
|
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)
|
||||||
|
{
|
||||||
|
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
|
* Constructor
|
||||||
*
|
*
|
||||||
@@ -84,6 +159,8 @@ Router::Router() : concurrency::OSThread("Router"), fromRadioQueue(MAX_RX_FROMRA
|
|||||||
// init Lockguard for crypt operations
|
// init Lockguard for crypt operations
|
||||||
assert(!cryptLock);
|
assert(!cryptLock);
|
||||||
cryptLock = new concurrency::Lock();
|
cryptLock = new concurrency::Lock();
|
||||||
|
if (!routingAuthCacheLock)
|
||||||
|
routingAuthCacheLock = new concurrency::Lock();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p)
|
bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p)
|
||||||
@@ -248,8 +325,10 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src)
|
|||||||
// No need to deliver externally if the destination is the local node
|
// No need to deliver externally if the destination is the local node
|
||||||
if (isToUs(p)) {
|
if (isToUs(p)) {
|
||||||
printPacket("Enqueued local", p);
|
printPacket("Enqueued local", p);
|
||||||
enqueueReceivedMessage(p);
|
// Preserve the trusted origin explicitly. Queueing used to erase src and make a local
|
||||||
return ERRNO_OK;
|
// phone/module packet indistinguishable from remote already-decoded ingress.
|
||||||
|
handleReceived(p, src);
|
||||||
|
return ERRNO_SHOULD_RELEASE;
|
||||||
} else if (!iface) {
|
} else if (!iface) {
|
||||||
// We must be sending to remote nodes also, fail if no interface found
|
// We must be sending to remote nodes also, fail if no interface found
|
||||||
abortSendAndNak(meshtastic_Routing_Error_NO_INTERFACE, p);
|
abortSendAndNak(meshtastic_Routing_Error_NO_INTERFACE, p);
|
||||||
@@ -500,18 +579,55 @@ static bool canonicalSignableSize(meshtastic_Data *d, size_t *size)
|
|||||||
return pb_get_encoded_size(size, &meshtastic_Data_msg, d);
|
return pb_get_encoded_size(size, &meshtastic_Data_msg, d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
LOG_DEBUG("Verified first-contact XEdDSA NodeInfo from 0x%08x", p->from);
|
||||||
|
return NodeInfoBootstrapResult::VERIFIED;
|
||||||
|
}
|
||||||
|
|
||||||
bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
|
bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
|
||||||
{
|
{
|
||||||
|
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.
|
// Only a signature we verify below may mark this packet signed; never trust an inbound flag.
|
||||||
p->xeddsa_signed = false;
|
p->xeddsa_signed = false;
|
||||||
if (p->decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE) {
|
if (p->decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE) {
|
||||||
|
meshtastic_NodeInfoLite_public_key_t senderKey = {0, {0}};
|
||||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
|
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
|
||||||
if (node && node->public_key.size == 32) {
|
if (nodeDB->copyPublicKey(p->from, senderKey)) {
|
||||||
p->xeddsa_signed =
|
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);
|
p->decoded.payload.size, p->decoded.xeddsa_signature.bytes);
|
||||||
if (p->xeddsa_signed) {
|
if (p->xeddsa_signed) {
|
||||||
// Learn this node as a signer, so a later unsigned signable broadcast from it is dropped
|
// 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);
|
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
|
||||||
LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from);
|
LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from);
|
||||||
} else {
|
} else {
|
||||||
@@ -519,7 +635,16 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else {
|
} 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);
|
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) {
|
} else if (p->decoded.xeddsa_signature.size != 0) {
|
||||||
// A signature field that is neither empty nor a full 64 bytes is malformed - honest
|
// A signature field that is neither empty nor a full 64 bytes is malformed - honest
|
||||||
@@ -530,15 +655,24 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
|
|||||||
p->from);
|
p->from);
|
||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
// Truly unsigned (signature size 0) - only reject the class a signing node always signs: a
|
if (p->pki_encrypted)
|
||||||
// non-PKI broadcast whose signed encoding would still fit the LoRa frame. Size p->decoded
|
return true;
|
||||||
// canonically so this counts the same fields the sender's signedDataFits() gate counted;
|
if (strict) {
|
||||||
// adding XEDDSA_SIGNATURE_FIELD_BYTES to that unsigned base mirrors it exactly, whatever
|
LOG_WARN("Dropping unsigned packet from 0x%08x in Strict signature mode", p->from);
|
||||||
// fields the Data carried. Unicast/PKI packets and broadcasts too big to carry a signature
|
return false;
|
||||||
// are never signed, so they must not be hard-failed here even for a known signer.
|
}
|
||||||
|
if (compatible)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// In Balanced, preserve legacy unsigned-unicast compatibility and only reject the class a
|
||||||
|
// signing node always signs: a non-PKI broadcast whose signed encoding would still fit the
|
||||||
|
// LoRa frame. Canonical sizing removes unknown protobuf fields before mirroring the
|
||||||
|
// sender-side signedDataFits() gate, so this counts the same fields that gate counted.
|
||||||
|
// Unicast 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 (PKI already returned above).
|
||||||
// isKnownXeddsaSigner consults the warm tier too: a signer evicted from the hot store
|
// isKnownXeddsaSigner consults the warm tier too: a signer evicted from the hot store
|
||||||
// must not become impersonatable via unsigned broadcasts until it is re-heard.
|
// must not become impersonatable via unsigned broadcasts until it is re-heard.
|
||||||
if (nodeDB->isKnownXeddsaSigner(p->from) && !p->pki_encrypted && isBroadcast(p->to)) {
|
if (nodeDB->isKnownXeddsaSigner(p->from) && isBroadcast(p->to)) {
|
||||||
size_t canonicalSize;
|
size_t canonicalSize;
|
||||||
if (!canonicalSignableSize(&p->decoded, &canonicalSize))
|
if (!canonicalSignableSize(&p->decoded, &canonicalSize))
|
||||||
return true; // can't size it; never drop on a sizing failure
|
return true; // can't size it; never drop on a sizing failure
|
||||||
@@ -552,6 +686,55 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
RoutingAuthVerdict passesRoutingAuthGate(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 wire = *p;
|
||||||
|
meshtastic_MeshPacket authCandidate = *p;
|
||||||
|
routingAuthEvaluations++;
|
||||||
|
if (authCandidate.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.
|
||||||
|
authCandidate.pki_encrypted = false;
|
||||||
|
authCandidate.public_key.size = 0;
|
||||||
|
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||||
|
concurrency::LockGuard g(cryptLock);
|
||||||
|
if (!checkXeddsaReceivePolicy(&authCandidate)) {
|
||||||
|
LOG_WARN("Already-decoded packet rejected by signature policy before routing state update");
|
||||||
|
return RoutingAuthVerdict::REJECT;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
p->xeddsa_signed = authCandidate.xeddsa_signed;
|
||||||
|
wire = *p;
|
||||||
|
storeRoutingAuthCache(wire, authCandidate);
|
||||||
|
return RoutingAuthVerdict::ACCEPT;
|
||||||
|
}
|
||||||
|
const DecodeState state = perhapsDecode(&authCandidate);
|
||||||
|
if (state == DecodeState::DECODE_POLICY_REJECT) {
|
||||||
|
LOG_WARN("Packet rejected by signature policy before routing state update");
|
||||||
|
return RoutingAuthVerdict::REJECT;
|
||||||
|
}
|
||||||
|
if (state == DecodeState::DECODE_FATAL) {
|
||||||
|
LOG_WARN("Fatal decode error before routing state update");
|
||||||
|
return RoutingAuthVerdict::REJECT;
|
||||||
|
}
|
||||||
|
if (state == DecodeState::DECODE_FAILURE) {
|
||||||
|
LOG_WARN("Decryptable packet failed decoding/authentication before routing state update");
|
||||||
|
return RoutingAuthVerdict::REJECT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only an explicit unknown-channel result remains eligible for opaque relay.
|
||||||
|
if (state == DecodeState::DECODE_OPAQUE)
|
||||||
|
return RoutingAuthVerdict::OPAQUE_RELAY_ONLY;
|
||||||
|
storeRoutingAuthCache(wire, authCandidate);
|
||||||
|
return RoutingAuthVerdict::ACCEPT;
|
||||||
|
}
|
||||||
|
|
||||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||||
// The fallback costs three X25519 ops before the AEAD tag is checked. Budget is global because p->from is
|
// The fallback costs three X25519 ops before the AEAD tag is checked. Budget is global because p->from is
|
||||||
// attacker-controlled; successful runs refund, and their key is then persisted for the fast path.
|
// attacker-controlled; successful runs refund, and their key is then persisted for the fast path.
|
||||||
@@ -613,17 +796,25 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
|||||||
if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag)
|
if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag)
|
||||||
return DecodeState::DECODE_SUCCESS; // If packet was already decoded just return
|
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;
|
||||||
|
|
||||||
size_t rawSize = p->encrypted.size;
|
size_t rawSize = p->encrypted.size;
|
||||||
if (rawSize > sizeof(bytes)) {
|
if (rawSize > sizeof(bytes)) {
|
||||||
LOG_ERROR("Packet too large to attempt decryption! (rawSize=%d > 256)", rawSize);
|
LOG_ERROR("Packet too large to attempt decryption! (rawSize=%d > 256)", rawSize);
|
||||||
return DecodeState::DECODE_FATAL;
|
return DecodeState::DECODE_FATAL;
|
||||||
}
|
}
|
||||||
bool decrypted = false;
|
bool decrypted = false;
|
||||||
|
bool pkiAttempted = false;
|
||||||
|
bool matchedChannel = false;
|
||||||
ChannelIndex chIndex = 0;
|
ChannelIndex chIndex = 0;
|
||||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||||
meshtastic_NodeInfoLite *ourNode = nullptr;
|
meshtastic_NodeInfoLite *ourNode = nullptr;
|
||||||
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD &&
|
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD &&
|
||||||
(ourNode = nodeDB->getMeshNode(p->to)) != nullptr && ourNode->public_key.size > 0) {
|
(ourNode = nodeDB->getMeshNode(p->to)) != nullptr && ourNode->public_key.size > 0) {
|
||||||
|
pkiAttempted = true;
|
||||||
|
LOG_DEBUG("Attempt PKI decryption");
|
||||||
// Resolve the sender's public key only for actual PKI-decrypt candidates: prefer NodeDB
|
// Resolve the sender's public key only for actual PKI-decrypt candidates: prefer NodeDB
|
||||||
// (hot store or warm tier), else a not-yet-committed key held during an in-progress
|
// (hot store or warm tier), else a not-yet-committed key held during an in-progress
|
||||||
// key-verification handshake. On a full NodeDB miss, copyPublicKey() falls through to a
|
// key-verification handshake. On a full NodeDB miss, copyPublicKey() falls through to a
|
||||||
@@ -708,6 +899,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
|||||||
for (chIndex = 0; chIndex < channels.getNumChannels(); chIndex++) {
|
for (chIndex = 0; chIndex < channels.getNumChannels(); chIndex++) {
|
||||||
// Try to use this hash/channel pair
|
// Try to use this hash/channel pair
|
||||||
if (channels.decryptForHash(chIndex, p->channel)) {
|
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
|
// 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.
|
// fresh copy for each decrypt attempt.
|
||||||
memcpy(bytes, p->encrypted.bytes, rawSize);
|
memcpy(bytes, p->encrypted.bytes, rawSize);
|
||||||
@@ -743,10 +935,9 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
|||||||
p->channel = chIndex; // change to store the index instead of the hash
|
p->channel = chIndex; // change to store the index instead of the hash
|
||||||
|
|
||||||
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||||
// Runs before the bitfield merge below: that merge can set want_response, adding wire bytes
|
// Run before merging local-only bitfield state into the decoded Data.
|
||||||
// the sender never encoded and skewing the policy's sizing of p->decoded.
|
|
||||||
if (!checkXeddsaReceivePolicy(p))
|
if (!checkXeddsaReceivePolicy(p))
|
||||||
return DecodeState::DECODE_FAILURE;
|
return DecodeState::DECODE_POLICY_REJECT;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if (p->decoded.has_bitfield)
|
if (p->decoded.has_bitfield)
|
||||||
@@ -804,7 +995,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
|||||||
return DecodeState::DECODE_SUCCESS;
|
return DecodeState::DECODE_SUCCESS;
|
||||||
} else {
|
} else {
|
||||||
LOG_WARN("No suitable channel found for decoding, hash was 0x%x!", p->channel);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,6 +1013,34 @@ static bool signedDataFits(meshtastic_Data *d)
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||||
|
bool wouldEncryptWithPKC(const meshtastic_MeshPacket *p, ChannelIndex chIndex, bool haveDestKey)
|
||||||
|
{
|
||||||
|
// First, only PKC encrypt packets we are originating
|
||||||
|
return isFromUs(p) &&
|
||||||
|
#if ARCH_PORTDUINO
|
||||||
|
// Sim radio via the cli flag skips PKC
|
||||||
|
!portduino_config.force_simradio &&
|
||||||
|
#endif
|
||||||
|
// Don't use PKC with Ham mode
|
||||||
|
!owner.is_licensed &&
|
||||||
|
// Don't use PKC on 'serial' or 'gpio' channels unless explicitly requested
|
||||||
|
!(p->pki_encrypted != true && (strcasecmp(channels.getName(chIndex), Channels::serialChannel) == 0 ||
|
||||||
|
strcasecmp(channels.getName(chIndex), Channels::gpioChannel) == 0)) &&
|
||||||
|
// Check for valid keys and single node destination
|
||||||
|
config.security.private_key.size == 32 && !isBroadcast(p->to) &&
|
||||||
|
// Some portnums either make no sense to send with PKC
|
||||||
|
p->decoded.portnum != meshtastic_PortNum_TRACEROUTE_APP && p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP &&
|
||||||
|
p->decoded.portnum != meshtastic_PortNum_ROUTING_APP && p->decoded.portnum != meshtastic_PortNum_POSITION_APP &&
|
||||||
|
// We allow Key Verification messages to be sent without a known destination key, since the point of those messages is
|
||||||
|
// to exchange keys. The first exchange (no usable key yet) falls through to channel encryption; the follow-on packet
|
||||||
|
// uses the pending key resolved into haveDestKey/destKey above.
|
||||||
|
// Though possible the first packet each direction should go non-pkc
|
||||||
|
// to handle the case where the remote node has our key, but we don't have theirs.
|
||||||
|
!(p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP && !haveDestKey);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/** Return 0 for success or a Routing_Error code for failure
|
/** Return 0 for success or a Routing_Error code for failure
|
||||||
*/
|
*/
|
||||||
meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
|
meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
|
||||||
@@ -915,28 +1134,7 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
|
|||||||
}
|
}
|
||||||
// We may want to retool things so we can send a PKC packet when the client specifies a key and nodenum, even if the node
|
// We may want to retool things so we can send a PKC packet when the client specifies a key and nodenum, even if the node
|
||||||
// is not in the local nodedb
|
// is not in the local nodedb
|
||||||
// First, only PKC encrypt packets we are originating
|
if (wouldEncryptWithPKC(p, chIndex, haveDestKey)) {
|
||||||
if (isFromUs(p) &&
|
|
||||||
#if ARCH_PORTDUINO
|
|
||||||
// Sim radio via the cli flag skips PKC
|
|
||||||
!portduino_config.force_simradio &&
|
|
||||||
#endif
|
|
||||||
// Don't use PKC with Ham mode
|
|
||||||
!owner.is_licensed &&
|
|
||||||
// Don't use PKC on 'serial' or 'gpio' channels unless explicitly requested
|
|
||||||
!(p->pki_encrypted != true && (strcasecmp(channels.getName(chIndex), Channels::serialChannel) == 0 ||
|
|
||||||
strcasecmp(channels.getName(chIndex), Channels::gpioChannel) == 0)) &&
|
|
||||||
// Check for valid keys and single node destination
|
|
||||||
config.security.private_key.size == 32 && !isBroadcast(p->to) &&
|
|
||||||
// Some portnums either make no sense to send with PKC
|
|
||||||
p->decoded.portnum != meshtastic_PortNum_TRACEROUTE_APP && p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP &&
|
|
||||||
p->decoded.portnum != meshtastic_PortNum_ROUTING_APP && p->decoded.portnum != meshtastic_PortNum_POSITION_APP &&
|
|
||||||
// We allow Key Verification messages to be sent without a known destination key, since the point of those messages is
|
|
||||||
// to exchange keys. The first exchange (no usable key yet) falls through to channel encryption; the follow-on packet
|
|
||||||
// uses the pending key resolved into haveDestKey/destKey above.
|
|
||||||
// Though possible the first packet each direction should go non-pkc
|
|
||||||
// to handle the case where the remote node has our key, but we don't have theirs.
|
|
||||||
!(p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP && !haveDestKey)) {
|
|
||||||
LOG_DEBUG("Use PKI!");
|
LOG_DEBUG("Use PKI!");
|
||||||
if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN)
|
if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN)
|
||||||
return meshtastic_Routing_Error_TOO_LARGE;
|
return meshtastic_Routing_Error_TOO_LARGE;
|
||||||
@@ -1008,8 +1206,6 @@ NodeNum Router::getNodeNum()
|
|||||||
void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
|
void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
|
||||||
{
|
{
|
||||||
bool skipHandle = false;
|
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.
|
// Store a copy of the encrypted packet for MQTT.
|
||||||
// Local, not a class member: handleReceived re-enters itself when a module
|
// Local, not a class member: handleReceived re-enters itself when a module
|
||||||
@@ -1020,12 +1216,31 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
|
|||||||
meshtastic_MeshPacket *p_encrypted = packetPool.allocCopy(*p);
|
meshtastic_MeshPacket *p_encrypted = packetPool.allocCopy(*p);
|
||||||
DEBUG_HEAP_AFTER("Router::handleReceived", p_encrypted);
|
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 (src == RX_SRC_RADIO)
|
||||||
|
applyRoutingAuthCache(p);
|
||||||
|
|
||||||
|
// 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
|
// Take those raw bytes and convert them back into a well structured protobuf we can understand
|
||||||
auto decodedState = perhapsDecode(p);
|
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
|
// Fatal decoding error, we can't do anything with this packet
|
||||||
LOG_WARN("Fatal decode error, dropping packet");
|
LOG_WARN(decodedState == DecodeState::DECODE_POLICY_REJECT
|
||||||
cancelSending(p->from, p->id);
|
? "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;
|
skipHandle = true;
|
||||||
} else if (decodedState == DecodeState::DECODE_SUCCESS) {
|
} else if (decodedState == DecodeState::DECODE_SUCCESS) {
|
||||||
// parsing was successful, queue for our recipient
|
// parsing was successful, queue for our recipient
|
||||||
@@ -1101,7 +1316,7 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
|
|||||||
} else {
|
} 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
|
// 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)
|
// 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))
|
!isBroadcast(p->to) && !isToUs(p))
|
||||||
p_encrypted->pki_encrypted = true;
|
p_encrypted->pki_encrypted = true;
|
||||||
// After potentially altering it, publish received message to MQTT if we're not the original transmitter of the packet
|
// After potentially altering it, publish received message to MQTT if we're not the original transmitter of the packet
|
||||||
@@ -1150,6 +1365,7 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)
|
|||||||
#endif
|
#endif
|
||||||
// assert(radioConfig.has_preferences);
|
// assert(radioConfig.has_preferences);
|
||||||
if (is_in_repeated(config.lora.ignore_incoming, p->from)) {
|
if (is_in_repeated(config.lora.ignore_incoming, p->from)) {
|
||||||
|
clearRoutingAuthCache();
|
||||||
LOG_DEBUG("Ignore msg, 0x%08x is in our ignore list", p->from);
|
LOG_DEBUG("Ignore msg, 0x%08x is in our ignore list", p->from);
|
||||||
packetPool.release(p);
|
packetPool.release(p);
|
||||||
return;
|
return;
|
||||||
@@ -1157,30 +1373,49 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)
|
|||||||
|
|
||||||
meshtastic_NodeInfoLite const *node = nodeDB->getMeshNode(p->from);
|
meshtastic_NodeInfoLite const *node = nodeDB->getMeshNode(p->from);
|
||||||
if (nodeInfoLiteIsIgnored(node)) {
|
if (nodeInfoLiteIsIgnored(node)) {
|
||||||
|
clearRoutingAuthCache();
|
||||||
LOG_DEBUG("Ignore msg, 0x%08x is ignored", p->from);
|
LOG_DEBUG("Ignore msg, 0x%08x is ignored", p->from);
|
||||||
packetPool.release(p);
|
packetPool.release(p);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (p->from == NODENUM_BROADCAST) {
|
if (p->from == NODENUM_BROADCAST) {
|
||||||
|
clearRoutingAuthCache();
|
||||||
LOG_DEBUG("Ignore msg from broadcast address");
|
LOG_DEBUG("Ignore msg from broadcast address");
|
||||||
packetPool.release(p);
|
packetPool.release(p);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.lora.ignore_mqtt && p->via_mqtt) {
|
if (config.lora.ignore_mqtt && p->via_mqtt) {
|
||||||
|
clearRoutingAuthCache();
|
||||||
LOG_DEBUG("Msg came in via MQTT from 0x%08x", p->from);
|
LOG_DEBUG("Msg came in via MQTT from 0x%08x", p->from);
|
||||||
packetPool.release(p);
|
packetPool.release(p);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldDropPacketForPreHop(*p)) {
|
if (shouldDropPacketForPreHop(*p)) {
|
||||||
|
clearRoutingAuthCache();
|
||||||
logHopStartDrop(*p, "pre-hop drop");
|
logHopStartDrop(*p, "pre-hop drop");
|
||||||
packetPool.release(p);
|
packetPool.release(p);
|
||||||
return;
|
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)) {
|
if (shouldFilterReceived(p)) {
|
||||||
|
clearRoutingAuthCache();
|
||||||
LOG_DEBUG("Incoming msg was filtered from 0x%08x", p->from);
|
LOG_DEBUG("Incoming msg was filtered from 0x%08x", p->from);
|
||||||
packetPool.release(p);
|
packetPool.release(p);
|
||||||
return;
|
return;
|
||||||
|
|||||||
+26
-16
@@ -112,6 +112,9 @@ class Router : protected concurrency::OSThread, protected PacketHistory
|
|||||||
*/
|
*/
|
||||||
virtual bool shouldFilterReceived(const meshtastic_MeshPacket *p) { return false; }
|
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.
|
* Determine if hop_limit should be decremented for a relay operation.
|
||||||
* Returns false (preserve hop_limit) only if all conditions are met:
|
* Returns false (preserve hop_limit) only if all conditions are met:
|
||||||
@@ -161,7 +164,8 @@ class Router : protected concurrency::OSThread, protected PacketHistory
|
|||||||
void abortSendAndNak(meshtastic_Routing_Error err, meshtastic_MeshPacket *p);
|
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
|
/** FIXME - move this into a mesh packet class
|
||||||
* Remove any encryption and decode the protobufs inside this packet (if necessary).
|
* Remove any encryption and decode the protobufs inside this packet (if necessary).
|
||||||
@@ -170,29 +174,35 @@ enum DecodeState { DECODE_SUCCESS, DECODE_FAILURE, DECODE_FATAL };
|
|||||||
*/
|
*/
|
||||||
DecodeState perhapsDecode(meshtastic_MeshPacket *p);
|
DecodeState perhapsDecode(meshtastic_MeshPacket *p);
|
||||||
|
|
||||||
|
/** Apply receive authentication before routing state mutation; unknown-channel packets may remain opaque relay-only. */
|
||||||
|
RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p);
|
||||||
|
#ifdef PIO_UNIT_TESTING
|
||||||
|
uint32_t routingAuthEvaluationCount();
|
||||||
|
void resetRoutingAuthEvaluationCount();
|
||||||
|
#endif
|
||||||
|
|
||||||
/** Return 0 for success or a Routing_Error code for failure
|
/** Return 0 for success or a Routing_Error code for failure
|
||||||
*/
|
*/
|
||||||
meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p);
|
meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p);
|
||||||
|
|
||||||
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||||
/** XEdDSA receive-side signature policy. When the packet carries a 64-byte signature *and* the
|
/** Enforce the configured XEdDSA receive policy. The caller must hold cryptLock.
|
||||||
* sender's public key is known, verify it: on success learn the sender's signer bit, on failure
|
* Returns false when the packet must be dropped. */
|
||||||
* 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).
|
|
||||||
*
|
|
||||||
* The fit test sizes p->decoded with the real encoder, so it measures the fields the sender
|
|
||||||
* encoded rather than any raw wire length.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p);
|
bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||||
|
/**
|
||||||
|
* Would perhapsEncode() PKC-encrypt this outgoing packet? Callers that must know the encryption a
|
||||||
|
* packet will get before it is encoded (e.g. pinning a peer key at request time) have to ask this
|
||||||
|
* rather than inspect p, whose pki_encrypted/public_key fields are only populated on the RX path.
|
||||||
|
*
|
||||||
|
* @param chIndex the channel index p carries before encoding rewrites it to a hash.
|
||||||
|
* @param haveDestKey whether a public key for p->to was resolvable.
|
||||||
|
*/
|
||||||
|
bool wouldEncryptWithPKC(const meshtastic_MeshPacket *p, ChannelIndex chIndex, bool haveDestKey);
|
||||||
|
#endif
|
||||||
|
|
||||||
extern Router *router;
|
extern Router *router;
|
||||||
|
|
||||||
/// Generate a unique packet id
|
/// Generate a unique packet id
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ static constexpr uint32_t WARM_SIGNER_MASK = 0x01u;
|
|||||||
enum class WarmFormat : uint8_t { Current, V2, V1 };
|
enum class WarmFormat : uint8_t { Current, V2, V1 };
|
||||||
|
|
||||||
// Protected category cached alongside role so consumers needn't re-derive the mapping.
|
// 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, bool signer)
|
inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot, bool signer)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ class UdpMulticastHandler final
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP;
|
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
|
// Authentication metadata is local-only; Router re-establishes it after successful PKI decryption.
|
||||||
// PKI DMs), so it must be copied verbatim for the router to attempt PKI/channel decryption. Keep
|
mp.pki_encrypted = false;
|
||||||
// pki_encrypted/public_key too so downstream auth/metadata can reflect PKI usage correctly.
|
mp.public_key.size = 0;
|
||||||
UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp);
|
UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp);
|
||||||
// Unset received SNR/RSSI
|
// Unset received SNR/RSSI
|
||||||
p->rx_snr = 0;
|
p->rx_snr = 0;
|
||||||
|
|||||||
@@ -1136,6 +1136,16 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
|
|||||||
rotated.private_key = incoming.private_key;
|
rotated.private_key = incoming.private_key;
|
||||||
incoming = rotated;
|
incoming = rotated;
|
||||||
}
|
}
|
||||||
|
#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;
|
config.security = incoming;
|
||||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) && !(MESHTASTIC_EXCLUDE_PKI)
|
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) && !(MESHTASTIC_EXCLUDE_PKI)
|
||||||
// First provisioning (no key) generates one; a private key supplied without its public key derives it.
|
// First provisioning (no key) generates one; a private key supplied without its public key derives it.
|
||||||
@@ -1975,7 +1985,15 @@ void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p)
|
|||||||
if (!responseVariant)
|
if (!responseVariant)
|
||||||
return; // not a getter whose response we can pair
|
return; // not a getter whose response we can pair
|
||||||
|
|
||||||
const bool keyValid = p.pki_encrypted && p.public_key.size == 32;
|
// Pin the key perhapsEncode will actually encrypt to, resolved the same way it resolves it.
|
||||||
|
// p.public_key is NOT it: nothing populates that field on the outgoing path (only perhapsDecode
|
||||||
|
// sets it, on RX), so reading it here pinned nothing and left `from` as the sole check.
|
||||||
|
bool keyValid = false;
|
||||||
|
meshtastic_NodeInfoLite_public_key_t destKey = {0, {0}};
|
||||||
|
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||||
|
const bool haveDestKey = nodeDB->copyPublicKey(p.to, destKey);
|
||||||
|
keyValid = haveDestKey && wouldEncryptWithPKC(&p, p.channel, haveDestKey);
|
||||||
|
#endif
|
||||||
|
|
||||||
// One entry per request (a client sends N indexed get_channel requests, each answered once, so
|
// One entry per request (a client sends N indexed get_channel requests, each answered once, so
|
||||||
// entries must not merge). Free slot, else evict the oldest by rollover-safe elapsed time.
|
// entries must not merge). Free slot, else evict the oldest by rollover-safe elapsed time.
|
||||||
@@ -1994,6 +2012,7 @@ void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p)
|
|||||||
}
|
}
|
||||||
|
|
||||||
slot->to = p.to;
|
slot->to = p.to;
|
||||||
|
slot->requestId = p.id;
|
||||||
slot->sentAtMs = millis();
|
slot->sentAtMs = millis();
|
||||||
slot->expectedResponse = responseVariant;
|
slot->expectedResponse = responseVariant;
|
||||||
slot->moduleConfigType = admin.which_payload_variant == meshtastic_AdminMessage_get_module_config_request_tag
|
slot->moduleConfigType = admin.which_payload_variant == meshtastic_AdminMessage_get_module_config_request_tag
|
||||||
@@ -2001,7 +2020,7 @@ void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p)
|
|||||||
: 0;
|
: 0;
|
||||||
slot->keyValid = keyValid;
|
slot->keyValid = keyValid;
|
||||||
if (keyValid)
|
if (keyValid)
|
||||||
memcpy(slot->key, p.public_key.bytes, 32);
|
memcpy(slot->key, destKey.bytes, 32);
|
||||||
else
|
else
|
||||||
memset(slot->key, 0, 32);
|
memset(slot->key, 0, 32);
|
||||||
LOG_DEBUG("Admin request sent to 0x%08x, expecting its response", p.to);
|
LOG_DEBUG("Admin request sent to 0x%08x, expecting its response", p.to);
|
||||||
@@ -2014,6 +2033,11 @@ bool AdminModule::responseIsSolicited(const meshtastic_MeshPacket &mp, pb_size_t
|
|||||||
for (auto &o : outstandingAdminRequests) {
|
for (auto &o : outstandingAdminRequests) {
|
||||||
if (o.to != mp.from || o.expectedResponse != responseVariant)
|
if (o.to != mp.from || o.expectedResponse != responseVariant)
|
||||||
continue;
|
continue;
|
||||||
|
// mp.from is unauthenticated, so also require the response to echo our request's packet id
|
||||||
|
// (setReplyTo puts it in decoded.request_id). A blind injector must now guess it. Id 0 is
|
||||||
|
// no token at all - an omitted request_id decodes to 0 - so such a slot never matches.
|
||||||
|
if (o.requestId == 0 || mp.decoded.request_id != o.requestId)
|
||||||
|
continue;
|
||||||
if (!Throttle::isWithinTimespanMs(o.sentAtMs, kOutstandingAdminRequestMs)) {
|
if (!Throttle::isWithinTimespanMs(o.sentAtMs, kOutstandingAdminRequestMs)) {
|
||||||
o.to = 0; // lapsed; free the slot and keep looking for another live match
|
o.to = 0; // lapsed; free the slot and keep looking for another live match
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -90,10 +90,11 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
|
|||||||
static constexpr uint32_t kOutstandingAdminRequestMs = 300 * 1000; // same window as the session passkey
|
static constexpr uint32_t kOutstandingAdminRequestMs = 300 * 1000; // same window as the session passkey
|
||||||
struct OutstandingAdminRequest {
|
struct OutstandingAdminRequest {
|
||||||
NodeNum to; // 0 = free slot
|
NodeNum to; // 0 = free slot
|
||||||
|
uint32_t requestId; // our request's packet id; the response must echo it as request_id
|
||||||
uint32_t sentAtMs; // millis() when this request went out
|
uint32_t sentAtMs; // millis() when this request went out
|
||||||
pb_size_t expectedResponse; // the one response variant this request authorizes
|
pb_size_t expectedResponse; // the one response variant this request authorizes
|
||||||
uint8_t moduleConfigType; // for get_module_config_request: which ModuleConfigType we asked
|
uint8_t moduleConfigType; // for get_module_config_request: which ModuleConfigType we asked
|
||||||
uint8_t key[32]; // pinned destination key when the request went out over PKC
|
uint8_t key[32]; // pinned destination key when the request goes out over PKC
|
||||||
bool keyValid;
|
bool keyValid;
|
||||||
};
|
};
|
||||||
OutstandingAdminRequest outstandingAdminRequests[kOutstandingAdminRequests] = {};
|
OutstandingAdminRequest outstandingAdminRequests[kOutstandingAdminRequests] = {};
|
||||||
|
|||||||
@@ -234,4 +234,4 @@ int32_t NodeInfoModule::runOnce()
|
|||||||
sendOurNodeInfo(NODENUM_BROADCAST, requestReplies); // Send our info (don't request replies)
|
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);
|
return Default::getConfiguredOrDefaultMs(config.device.node_info_broadcast_secs, default_node_info_broadcast_secs);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-9
@@ -150,7 +150,8 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
|
|||||||
p->hop_limit = e.packet->hop_limit;
|
p->hop_limit = e.packet->hop_limit;
|
||||||
p->hop_start = e.packet->hop_start;
|
p->hop_start = e.packet->hop_start;
|
||||||
p->want_ack = e.packet->want_ack;
|
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->transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT;
|
||||||
p->which_payload_variant = e.packet->which_payload_variant;
|
p->which_payload_variant = e.packet->which_payload_variant;
|
||||||
memcpy(&p->decoded, &e.packet->decoded, std::max(sizeof(p->decoded), sizeof(p->encrypted)));
|
memcpy(&p->decoded, &e.packet->decoded, std::max(sizeof(p->decoded), sizeof(p->encrypted)));
|
||||||
@@ -175,12 +176,9 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
|
|||||||
// impersonate a signing node with unsigned broadcasts. Hold cryptLock like the RF path
|
// impersonate a signing node with unsigned broadcasts. Hold cryptLock like the RF path
|
||||||
// (perhapsDecode) does - checkXeddsaReceivePolicy -> xeddsa_verify mutates shared
|
// (perhapsDecode) does - checkXeddsaReceivePolicy -> xeddsa_verify mutates shared
|
||||||
// CryptoEngine cache state, and MQTT ingress can run on a different task.
|
// CryptoEngine cache state, and MQTT ingress can run on a different task.
|
||||||
{
|
if (passesRoutingAuthGate(p.get()) != RoutingAuthVerdict::ACCEPT) {
|
||||||
concurrency::LockGuard g(cryptLock);
|
LOG_INFO("Ignore decoded message failing XEdDSA policy");
|
||||||
if (!checkXeddsaReceivePolicy(p.get())) {
|
return;
|
||||||
LOG_INFO("Ignore decoded message failing XEdDSA policy");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -193,8 +191,7 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
|
|||||||
// likely they discovered each other via a channel we have downlink enabled for
|
// likely they discovered each other via a channel we have downlink enabled for
|
||||||
if (isToUs(p.get()) || (nodeInfoLiteHasUser(tx) && nodeInfoLiteHasUser(rx)))
|
if (isToUs(p.get()) || (nodeInfoLiteHasUser(tx) && nodeInfoLiteHasUser(rx)))
|
||||||
router->enqueueReceivedMessage(p.release());
|
router->enqueueReceivedMessage(p.release());
|
||||||
} else if (router &&
|
} else if (router && passesRoutingAuthGate(p.get()) == RoutingAuthVerdict::ACCEPT)
|
||||||
perhapsDecode(p.get()) == DecodeState::DECODE_SUCCESS) // ignore messages if we don't have the channel key
|
|
||||||
router->enqueueReceivedMessage(p.release());
|
router->enqueueReceivedMessage(p.release());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,10 @@
|
|||||||
#include "support/MockMeshService.h"
|
#include "support/MockMeshService.h"
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
|
#ifdef ARCH_PORTDUINO
|
||||||
|
#include "platform/portduino/PortduinoGlue.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A;
|
static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A;
|
||||||
static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; // authorized admin, sends remote admin to us
|
static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; // authorized admin, sends remote admin to us
|
||||||
static constexpr NodeNum QUERIED_NODE = 0x0C0C0C0C; // a remote we send admin requests to
|
static constexpr NodeNum QUERIED_NODE = 0x0C0C0C0C; // a remote we send admin requests to
|
||||||
@@ -29,8 +33,43 @@ static const uint8_t ADMIN_KEY[32] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
|
|||||||
0xcc, 0xdd, 0xee, 0xff, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
|
0xcc, 0xdd, 0xee, 0xff, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
|
||||||
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x20};
|
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x20};
|
||||||
|
|
||||||
|
// MeshService assigns every outgoing packet an id before noteOutgoingAdminRequest sees it, and
|
||||||
|
// setReplyTo echoes it back as decoded.request_id, so the pairing is keyed on it.
|
||||||
|
static constexpr uint32_t REQUEST_ID = 0x5EED0001;
|
||||||
|
static const uint8_t QUERIED_KEY[32] = {0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCa, 0xCb,
|
||||||
|
0xCc, 0xCd, 0xCe, 0xCf, 0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6,
|
||||||
|
0xD7, 0xD8, 0xD9, 0xDa, 0xDb, 0xDc, 0xDd, 0xDe, 0xDf, 0xE0};
|
||||||
|
|
||||||
|
// NodeDB with injectable nodes, so a destination can have a stored public key to pin.
|
||||||
|
class MockNodeDB : public NodeDB
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void clearTestNodes()
|
||||||
|
{
|
||||||
|
testNodes.clear();
|
||||||
|
meshNodes = &testNodes;
|
||||||
|
numMeshNodes = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void addNodeWithKey(NodeNum num, const uint8_t *key)
|
||||||
|
{
|
||||||
|
meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero;
|
||||||
|
n.num = num;
|
||||||
|
if (key) {
|
||||||
|
n.public_key.size = 32;
|
||||||
|
memcpy(n.public_key.bytes, key, 32);
|
||||||
|
}
|
||||||
|
testNodes.push_back(n);
|
||||||
|
meshNodes = &testNodes;
|
||||||
|
numMeshNodes = testNodes.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<meshtastic_NodeInfoLite> testNodes;
|
||||||
|
};
|
||||||
|
|
||||||
static MockMeshService *mockService = nullptr;
|
static MockMeshService *mockService = nullptr;
|
||||||
static AdminModuleTestShim *admin = nullptr;
|
static AdminModuleTestShim *admin = nullptr;
|
||||||
|
static MockNodeDB *mockNodeDB = nullptr;
|
||||||
|
|
||||||
// A remote, PKC-authorized set_owner. `session` (if non-empty) is the session_passkey the client presents.
|
// A remote, PKC-authorized set_owner. `session` (if non-empty) is the session_passkey the client presents.
|
||||||
static meshtastic_MeshPacket makeRemoteSetOwner(const char *newLongName, const uint8_t *session, size_t sessionLen,
|
static meshtastic_MeshPacket makeRemoteSetOwner(const char *newLongName, const uint8_t *session, size_t sessionLen,
|
||||||
@@ -71,6 +110,7 @@ static meshtastic_MeshPacket makeModuleConfigResponse(NodeNum from, meshtastic_A
|
|||||||
mp.to = LOCAL_NODE;
|
mp.to = LOCAL_NODE;
|
||||||
mp.channel = 0;
|
mp.channel = 0;
|
||||||
mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||||
|
mp.decoded.request_id = REQUEST_ID; // setReplyTo echoes the request's packet id
|
||||||
return mp;
|
return mp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +133,7 @@ static meshtastic_MeshPacket makeOutgoingRequest(NodeNum to, const meshtastic_Ad
|
|||||||
p.to = to;
|
p.to = to;
|
||||||
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||||
p.decoded.portnum = meshtastic_PortNum_ADMIN_APP;
|
p.decoded.portnum = meshtastic_PortNum_ADMIN_APP;
|
||||||
|
p.id = REQUEST_ID;
|
||||||
p.decoded.payload.size =
|
p.decoded.payload.size =
|
||||||
pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_AdminMessage_msg, &req);
|
pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_AdminMessage_msg, &req);
|
||||||
return p;
|
return p;
|
||||||
@@ -114,11 +155,24 @@ void setUp(void)
|
|||||||
admin = new AdminModuleTestShim();
|
admin = new AdminModuleTestShim();
|
||||||
admin->deferSaves(); // no disk/reboot side effects when a setter is accepted
|
admin->deferSaves(); // no disk/reboot side effects when a setter is accepted
|
||||||
|
|
||||||
if (!nodeDB)
|
if (!mockNodeDB)
|
||||||
nodeDB = new NodeDB();
|
mockNodeDB = new MockNodeDB();
|
||||||
|
mockNodeDB->clearTestNodes();
|
||||||
|
nodeDB = mockNodeDB;
|
||||||
myNodeInfo.my_node_num = LOCAL_NODE;
|
myNodeInfo.my_node_num = LOCAL_NODE;
|
||||||
|
|
||||||
|
#ifdef ARCH_PORTDUINO
|
||||||
|
// The native test harness boots Portduino in simulated mode, and wouldEncryptWithPKC()
|
||||||
|
// hard-disables PKC whenever force_simradio is set. Left true, no outgoing admin request is
|
||||||
|
// ever key-pinned, so the pinning tests below cannot exercise what they are asserting.
|
||||||
|
// Model a real (non-sim) device instead.
|
||||||
|
portduino_config.force_simradio = false;
|
||||||
|
#endif
|
||||||
|
|
||||||
config = meshtastic_LocalConfig_init_zero;
|
config = meshtastic_LocalConfig_init_zero;
|
||||||
|
// A real device always holds a private key; without one perhapsEncode never picks PKC.
|
||||||
|
config.security.private_key.size = 32;
|
||||||
|
memset(config.security.private_key.bytes, 0xA5, 32);
|
||||||
// Authorize ADMIN_NODE's key as an admin key so the PKC path accepts it and we reach the session gate.
|
// Authorize ADMIN_NODE's key as an admin key so the PKC path accepts it and we reach the session gate.
|
||||||
config.security.admin_key[0].size = 32;
|
config.security.admin_key[0].size = 32;
|
||||||
memcpy(config.security.admin_key[0].bytes, ADMIN_KEY, 32);
|
memcpy(config.security.admin_key[0].bytes, ADMIN_KEY, 32);
|
||||||
@@ -411,38 +465,104 @@ void test_response_variant_must_match_request(void)
|
|||||||
// must not relax the PKC pin of an earlier one (the old shared-slot model cleared it).
|
// must not relax the PKC pin of an earlier one (the old shared-slot model cleared it).
|
||||||
void test_pinned_request_keeps_its_key_after_an_unpinned_request(void)
|
void test_pinned_request_keeps_its_key_after_an_unpinned_request(void)
|
||||||
{
|
{
|
||||||
uint8_t key[32];
|
// QUERIED has a stored key, so a request to it will be PKC-encrypted and pins that key.
|
||||||
memset(key, 0xC1, 32);
|
// STRANGER has none, so a request to it cannot be pinned.
|
||||||
|
mockNodeDB->addNodeWithKey(QUERIED_NODE, QUERIED_KEY);
|
||||||
|
mockNodeDB->addNodeWithKey(STRANGER_NODE, nullptr);
|
||||||
|
|
||||||
// A PKC-pinned get_config request to STRANGER (pins `key`).
|
|
||||||
meshtastic_AdminMessage cfg = meshtastic_AdminMessage_init_zero;
|
meshtastic_AdminMessage cfg = meshtastic_AdminMessage_init_zero;
|
||||||
cfg.which_payload_variant = meshtastic_AdminMessage_get_config_request_tag;
|
cfg.which_payload_variant = meshtastic_AdminMessage_get_config_request_tag;
|
||||||
meshtastic_MeshPacket pinned = makeOutgoingRequest(STRANGER_NODE, cfg);
|
admin->noteOutgoingAdminRequest(makeOutgoingRequest(QUERIED_NODE, cfg));
|
||||||
pinned.pki_encrypted = true;
|
|
||||||
pinned.public_key.size = 32;
|
|
||||||
memcpy(pinned.public_key.bytes, key, 32);
|
|
||||||
admin->noteOutgoingAdminRequest(pinned);
|
|
||||||
|
|
||||||
// A later, unpinned get_owner request to the same node.
|
|
||||||
meshtastic_AdminMessage own = meshtastic_AdminMessage_init_zero;
|
meshtastic_AdminMessage own = meshtastic_AdminMessage_init_zero;
|
||||||
own.which_payload_variant = meshtastic_AdminMessage_get_owner_request_tag;
|
own.which_payload_variant = meshtastic_AdminMessage_get_owner_request_tag;
|
||||||
admin->noteOutgoingAdminRequest(makeOutgoingRequest(STRANGER_NODE, own));
|
admin->noteOutgoingAdminRequest(makeOutgoingRequest(STRANGER_NODE, own));
|
||||||
|
|
||||||
meshtastic_AdminMessage m;
|
meshtastic_AdminMessage m;
|
||||||
meshtastic_MeshPacket resp = makeModuleConfigResponse(STRANGER_NODE, m); // from set; pki off by default
|
meshtastic_MeshPacket resp = makeModuleConfigResponse(QUERIED_NODE, m); // pki off by default
|
||||||
|
|
||||||
// A plaintext get_config_response is still rejected: the config request was pinned.
|
|
||||||
TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_config_response_tag, 0),
|
TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_config_response_tag, 0),
|
||||||
"an unpinned request must not relax an earlier request's key pin");
|
"an unpinned request must not relax an earlier request's key pin");
|
||||||
// Over PKC with the pinned key it is accepted.
|
|
||||||
resp.pki_encrypted = true;
|
resp.pki_encrypted = true;
|
||||||
resp.public_key.size = 32;
|
resp.public_key.size = 32;
|
||||||
memcpy(resp.public_key.bytes, key, 32);
|
memcpy(resp.public_key.bytes, QUERIED_KEY, 32);
|
||||||
TEST_ASSERT_TRUE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_config_response_tag, 0));
|
TEST_ASSERT_TRUE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_config_response_tag, 0));
|
||||||
// The unpinned get_owner_response is accepted in plaintext (its request carried no pin).
|
}
|
||||||
resp.pki_encrypted = false;
|
|
||||||
resp.public_key.size = 0;
|
// The pin is taken from the destination's stored NodeDB key - the key perhapsEncode will encrypt
|
||||||
TEST_ASSERT_TRUE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_owner_response_tag, 0));
|
// to. Reading the outgoing packet's public_key instead pinned nothing: nothing populates that
|
||||||
|
// field before encryption, so every real request was unpinned and `from` alone admitted responses.
|
||||||
|
void test_request_to_keyed_node_pins_the_stored_key(void)
|
||||||
|
{
|
||||||
|
mockNodeDB->addNodeWithKey(QUERIED_NODE, QUERIED_KEY);
|
||||||
|
admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(QUERIED_NODE));
|
||||||
|
|
||||||
|
meshtastic_AdminMessage m;
|
||||||
|
meshtastic_MeshPacket plain = makeModuleConfigResponse(QUERIED_NODE, m);
|
||||||
|
TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(plain, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG),
|
||||||
|
"a plaintext response must not answer a PKC-pinned request");
|
||||||
|
|
||||||
|
meshtastic_MeshPacket wrongKey = makeModuleConfigResponse(QUERIED_NODE, m);
|
||||||
|
wrongKey.pki_encrypted = true;
|
||||||
|
wrongKey.public_key.size = 32;
|
||||||
|
memset(wrongKey.public_key.bytes, 0x77, 32);
|
||||||
|
TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(wrongKey, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG),
|
||||||
|
"a response under a different key must not be accepted");
|
||||||
|
|
||||||
|
meshtastic_MeshPacket good = makeModuleConfigResponse(QUERIED_NODE, m);
|
||||||
|
good.pki_encrypted = true;
|
||||||
|
good.public_key.size = 32;
|
||||||
|
memcpy(good.public_key.bytes, QUERIED_KEY, 32);
|
||||||
|
TEST_ASSERT_TRUE_MESSAGE(admin->responseIsSolicited(good, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG),
|
||||||
|
"the pinned key must still admit the genuine response");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ham mode never uses PKC, so pinning a key there would reject the legitimate plaintext response.
|
||||||
|
void test_ham_mode_request_is_not_pinned(void)
|
||||||
|
{
|
||||||
|
owner.is_licensed = true;
|
||||||
|
mockNodeDB->addNodeWithKey(QUERIED_NODE, QUERIED_KEY);
|
||||||
|
admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(QUERIED_NODE));
|
||||||
|
|
||||||
|
meshtastic_AdminMessage m;
|
||||||
|
meshtastic_MeshPacket mp = makeModuleConfigResponse(QUERIED_NODE, m);
|
||||||
|
TEST_ASSERT_TRUE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG),
|
||||||
|
"a request that could not have gone out over PKC must not be pinned");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The response must echo our request's packet id, so an injector cannot answer a request it did
|
||||||
|
// not see just by naming the right node and variant.
|
||||||
|
void test_response_with_wrong_request_id_is_rejected(void)
|
||||||
|
{
|
||||||
|
admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(STRANGER_NODE));
|
||||||
|
|
||||||
|
meshtastic_AdminMessage m;
|
||||||
|
meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m);
|
||||||
|
mp.decoded.request_id = REQUEST_ID ^ 0xFFFF; // answers some other request
|
||||||
|
|
||||||
|
TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG),
|
||||||
|
"a response that does not echo our request id must be rejected");
|
||||||
|
|
||||||
|
mp.decoded.request_id = REQUEST_ID;
|
||||||
|
TEST_ASSERT_TRUE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG),
|
||||||
|
"the matching request id must still be accepted");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request we could not bind to an id must not be answerable by a response that simply omits
|
||||||
|
// request_id, which decodes to 0.
|
||||||
|
void test_request_without_an_id_admits_nothing(void)
|
||||||
|
{
|
||||||
|
meshtastic_MeshPacket req = makeOutgoingModuleConfigRequest(STRANGER_NODE);
|
||||||
|
req.id = 0;
|
||||||
|
admin->noteOutgoingAdminRequest(req);
|
||||||
|
|
||||||
|
meshtastic_AdminMessage m;
|
||||||
|
meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m);
|
||||||
|
mp.decoded.request_id = 0;
|
||||||
|
|
||||||
|
TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG),
|
||||||
|
"a zero request id must not act as a matching token");
|
||||||
}
|
}
|
||||||
|
|
||||||
// A remote_hardware response must answer a request for that exact subtype, not just any module
|
// A remote_hardware response must answer a request for that exact subtype, not just any module
|
||||||
@@ -542,6 +662,10 @@ void setup()
|
|||||||
RUN_TEST(test_request_to_one_node_does_not_admit_another);
|
RUN_TEST(test_request_to_one_node_does_not_admit_another);
|
||||||
RUN_TEST(test_response_variant_must_match_request);
|
RUN_TEST(test_response_variant_must_match_request);
|
||||||
RUN_TEST(test_pinned_request_keeps_its_key_after_an_unpinned_request);
|
RUN_TEST(test_pinned_request_keeps_its_key_after_an_unpinned_request);
|
||||||
|
RUN_TEST(test_request_to_keyed_node_pins_the_stored_key);
|
||||||
|
RUN_TEST(test_ham_mode_request_is_not_pinned);
|
||||||
|
RUN_TEST(test_response_with_wrong_request_id_is_rejected);
|
||||||
|
RUN_TEST(test_request_without_an_id_admits_nothing);
|
||||||
RUN_TEST(test_module_config_subtype_must_match);
|
RUN_TEST(test_module_config_subtype_must_match);
|
||||||
RUN_TEST(test_response_is_consumed_no_replay);
|
RUN_TEST(test_response_is_consumed_no_replay);
|
||||||
RUN_TEST(test_outgoing_setter_does_not_admit_responses);
|
RUN_TEST(test_outgoing_setter_does_not_admit_responses);
|
||||||
|
|||||||
@@ -159,7 +159,8 @@ void test_E1_perhaps_decode_fuzz(void)
|
|||||||
|
|
||||||
DecodeState st = perhapsDecode(&p);
|
DecodeState st = perhapsDecode(&p);
|
||||||
// Any verdict is fine; the contract is that arbitrary ciphertext never crashes the pipeline.
|
// 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);
|
TEST_ASSERT_TRUE(st == DECODE_SUCCESS || st == DECODE_FAILURE || st == DECODE_OPAQUE || st == DECODE_FATAL ||
|
||||||
|
st == DECODE_POLICY_REJECT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ class MockRouter : public Router
|
|||||||
class MockMeshService : public MeshService
|
class MockMeshService : public MeshService
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
|
// No PhoneAPI reader exists in these tests, so packets the receive pipeline forwards to the phone
|
||||||
|
// (MeshService::sendToPhone enqueues pooled copies into toPhoneQueue) would leak at teardown. Drain
|
||||||
|
// the queue like the phone would. This surfaced once sendLocal() began dispatching local packets
|
||||||
|
// through handleReceived() directly rather than via the (mock-overridden) enqueueReceivedMessage().
|
||||||
|
~MockMeshService()
|
||||||
|
{
|
||||||
|
while (meshtastic_MeshPacket *p = getForPhone())
|
||||||
|
releaseToPool(p);
|
||||||
|
}
|
||||||
void sendMqttMessageToClientProxy(meshtastic_MqttClientProxyMessage *m) override
|
void sendMqttMessageToClientProxy(meshtastic_MqttClientProxyMessage *m) override
|
||||||
{
|
{
|
||||||
messages_.emplace_back(*m);
|
messages_.emplace_back(*m);
|
||||||
@@ -334,6 +343,7 @@ const meshtastic_MeshPacket encrypted = {
|
|||||||
// Initialize mocks and configuration before running each test.
|
// Initialize mocks and configuration before running each test.
|
||||||
void setUp(void)
|
void setUp(void)
|
||||||
{
|
{
|
||||||
|
config = meshtastic_LocalConfig_init_zero;
|
||||||
moduleConfig.mqtt =
|
moduleConfig.mqtt =
|
||||||
meshtastic_ModuleConfig_MQTTConfig{.enabled = true, .map_reporting_enabled = true, .has_map_report_settings = true};
|
meshtastic_ModuleConfig_MQTTConfig{.enabled = true, .map_reporting_enabled = true, .has_map_report_settings = true};
|
||||||
moduleConfig.mqtt.map_report_settings = meshtastic_ModuleConfig_MapReportSettings{
|
moduleConfig.mqtt.map_report_settings = meshtastic_ModuleConfig_MapReportSettings{
|
||||||
@@ -627,6 +637,8 @@ void test_receiveWithoutChannelDownlink(void)
|
|||||||
// Test receiving an encrypted MeshPacket on the PKI topic.
|
// Test receiving an encrypted MeshPacket on the PKI topic.
|
||||||
void test_receiveEncryptedPKITopicToUs(void)
|
void test_receiveEncryptedPKITopicToUs(void)
|
||||||
{
|
{
|
||||||
|
config.security.packet_signature_policy =
|
||||||
|
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT;
|
||||||
meshtastic_MeshPacket e = encrypted;
|
meshtastic_MeshPacket e = encrypted;
|
||||||
e.to = myNodeInfo.my_node_num;
|
e.to = myNodeInfo.my_node_num;
|
||||||
|
|
||||||
@@ -718,6 +730,8 @@ static meshtastic_MeshPacket makeDecodedBroadcast()
|
|||||||
// signing node (audit F3).
|
// signing node (audit F3).
|
||||||
void test_receiveDropsUnsignedBroadcastFromSigner(void)
|
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;
|
mockNodeDB->emptyNode.bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
|
||||||
|
|
||||||
const meshtastic_MeshPacket p = makeDecodedBroadcast();
|
const meshtastic_MeshPacket p = makeDecodedBroadcast();
|
||||||
@@ -729,6 +743,8 @@ void test_receiveDropsUnsignedBroadcastFromSigner(void)
|
|||||||
// The same unsigned broadcast from a node never seen signing is accepted.
|
// The same unsigned broadcast from a node never seen signing is accepted.
|
||||||
void test_receiveAcceptsUnsignedBroadcastFromNonSigner(void)
|
void test_receiveAcceptsUnsignedBroadcastFromNonSigner(void)
|
||||||
{
|
{
|
||||||
|
config.security.packet_signature_policy =
|
||||||
|
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED;
|
||||||
const meshtastic_MeshPacket p = makeDecodedBroadcast();
|
const meshtastic_MeshPacket p = makeDecodedBroadcast();
|
||||||
unitTest->publish(&p);
|
unitTest->publish(&p);
|
||||||
|
|
||||||
@@ -760,6 +776,8 @@ void test_receiveVerifiesSignedDecodedDownlink(void)
|
|||||||
// A decoded downlink carrying a signature that fails verification is dropped.
|
// A decoded downlink carrying a signature that fails verification is dropped.
|
||||||
void test_receiveDropsBadSignatureOnDecodedDownlink(void)
|
void test_receiveDropsBadSignatureOnDecodedDownlink(void)
|
||||||
{
|
{
|
||||||
|
config.security.packet_signature_policy =
|
||||||
|
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE;
|
||||||
uint8_t pub[32], priv[32];
|
uint8_t pub[32], priv[32];
|
||||||
crypto->generateKeyPair(pub, priv);
|
crypto->generateKeyPair(pub, priv);
|
||||||
mockNodeDB->emptyNode.public_key.size = 32;
|
mockNodeDB->emptyNode.public_key.size = 32;
|
||||||
@@ -775,6 +793,53 @@ void test_receiveDropsBadSignatureOnDecodedDownlink(void)
|
|||||||
|
|
||||||
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
|
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)
|
#endif // !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||||
|
|
||||||
// Only the same fields that are transmitted over LoRa should be set in MQTT messages.
|
// Only the same fields that are transmitted over LoRa should be set in MQTT messages.
|
||||||
@@ -1096,6 +1161,9 @@ void setup()
|
|||||||
RUN_TEST(test_receiveAcceptsUnsignedBroadcastFromNonSigner);
|
RUN_TEST(test_receiveAcceptsUnsignedBroadcastFromNonSigner);
|
||||||
RUN_TEST(test_receiveVerifiesSignedDecodedDownlink);
|
RUN_TEST(test_receiveVerifiesSignedDecodedDownlink);
|
||||||
RUN_TEST(test_receiveDropsBadSignatureOnDecodedDownlink);
|
RUN_TEST(test_receiveDropsBadSignatureOnDecodedDownlink);
|
||||||
|
RUN_TEST(test_receiveCompatibleAcceptsUnsignedBroadcastFromSigner);
|
||||||
|
RUN_TEST(test_receiveStrictDropsUnsignedPortnumsAndUnicast);
|
||||||
|
RUN_TEST(test_receiveStrictDoesNotTrustDecodedPkiFlag);
|
||||||
#endif
|
#endif
|
||||||
RUN_TEST(test_receiveIgnoresUnexpectedFields);
|
RUN_TEST(test_receiveIgnoresUnexpectedFields);
|
||||||
RUN_TEST(test_receiveIgnoresInvalidHopLimit);
|
RUN_TEST(test_receiveIgnoresInvalidHopLimit);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,5 +16,8 @@ build_flags =
|
|||||||
-DMESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR=1
|
-DMESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR=1
|
||||||
-DMESHTASTIC_EXCLUDE_I2C=1
|
-DMESHTASTIC_EXCLUDE_I2C=1
|
||||||
-DMESHTASTIC_EXCLUDE_GPS=1
|
-DMESHTASTIC_EXCLUDE_GPS=1
|
||||||
|
build_unflags =
|
||||||
|
${arduino_base.build_unflags}
|
||||||
|
-DMESHTASTIC_EXCLUDE_XEDDSA=1
|
||||||
|
|
||||||
upload_port = stlink
|
upload_port = stlink
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ build_flags =
|
|||||||
-DMESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR=1
|
-DMESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR=1
|
||||||
-DMESHTASTIC_EXCLUDE_I2C=1
|
-DMESHTASTIC_EXCLUDE_I2C=1
|
||||||
-DMESHTASTIC_EXCLUDE_GPS=1
|
-DMESHTASTIC_EXCLUDE_GPS=1
|
||||||
|
build_unflags =
|
||||||
|
${arduino_base.build_unflags}
|
||||||
|
-DMESHTASTIC_EXCLUDE_XEDDSA=1
|
||||||
|
|
||||||
lib_deps =
|
lib_deps =
|
||||||
${stm32_base.lib_deps}
|
${stm32_base.lib_deps}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ build_flags =
|
|||||||
-DMESHTASTIC_EXCLUDE_BLUETOOTH=1
|
-DMESHTASTIC_EXCLUDE_BLUETOOTH=1
|
||||||
-DMESHTASTIC_EXCLUDE_WIFI=1
|
-DMESHTASTIC_EXCLUDE_WIFI=1
|
||||||
-DMESHTASTIC_EXCLUDE_TZ=1 ; Exclude TZ to save some flash space.
|
-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.
|
||||||
-DMESHTASTIC_EXCLUDE_PKT_HISTORY_HASH=1
|
-DMESHTASTIC_EXCLUDE_PKT_HISTORY_HASH=1
|
||||||
-DMESHTASTIC_EXCLUDE_RANGETEST=1
|
-DMESHTASTIC_EXCLUDE_RANGETEST=1
|
||||||
-DMESHTASTIC_EXCLUDE_WAYPOINT=1
|
-DMESHTASTIC_EXCLUDE_WAYPOINT=1
|
||||||
@@ -65,4 +65,4 @@ lib_ignore =
|
|||||||
OneButton
|
OneButton
|
||||||
|
|
||||||
; Set a custom linker script with a higher MinStackSize value, to match NRF52.
|
; Set a custom linker script with a higher MinStackSize value, to match NRF52.
|
||||||
board_build.ldscript = $PROJECT_DIR/variants/stm32/stm32wle5xx.ld
|
board_build.ldscript = $PROJECT_DIR/variants/stm32/stm32wle5xx.ld
|
||||||
|
|||||||
Reference in New Issue
Block a user