Files
firmware/src/mesh/ReliableRouter.cpp
T
James Rich 20284840a8 Report the ack proof verdict to the client (#11965)
* Report the ack proof verdict to the client

* Test that a proven ack reaches the phone as VALID

* Say why the verdict clear and the pending gate sit where they do
2026-09-25 13:33:58 +00:00

338 lines
17 KiB
C++

#include "ReliableRouter.h"
#include "AckProof.h"
#include "Default.h"
#include "MeshTypes.h"
#include "NodeDB.h"
#include "UptimeClock.h"
#include "configuration.h"
#include "memGet.h"
#include "mesh-pb-constants.h"
#include "modules/NodeInfoModule.h"
#include "modules/RoutingModule.h"
// ReliableRouter::ReliableRouter() {}
/**
* If the message is want_ack, then add it to a list of packets to retransmit.
* If we run out of retransmissions, send a nak packet towards the original client to indicate failure.
*/
ErrorCode ReliableRouter::send(meshtastic_MeshPacket *p)
{
if (isBlockedEventCoordinatePacket(p)) {
LOG_DEBUG("Suppress reliable coordinate send on event (everyone) channel");
packetPool.release(p);
return meshtastic_Routing_Error_NOT_AUTHORIZED;
}
const GlobalPacketId key(p);
const bool retransmitting = p->want_ack;
if (p->want_ack) {
DEBUG_HEAP_BEFORE;
auto copy = packetPool.allocCopy(*p);
DEBUG_HEAP_AFTER("ReliableRouter::send", copy);
if (copy) {
const uint8_t totalAttempts = isBroadcast(p->to) ? NUM_RELIABLE_RETX : NUM_RELIABLE_UNICAST_ATTEMPTS;
startRetransmission(copy, totalAttempts);
}
}
/* If we have pending retransmissions, add the airtime of this packet to it, because during that time we cannot receive an
(implicit) ACK. Otherwise, we might retransmit too early.
*/
for (auto i = pending.begin(); i != pending.end(); i++) {
if (i->first.id != p->id) {
i->second.nextTxMsec += iface->getPacketTime(p);
}
}
ErrorCode result = isBroadcast(p->to) ? FloodingRouter::send(p) : NextHopRouter::send(p);
// Duty-cycle rejections may clear before the scheduled retry.
if (retransmitting && result != ERRNO_OK && result != meshtastic_Routing_Error_DUTY_CYCLE_LIMIT)
stopRetransmission(key);
return result;
}
void ReliableRouter::perhapsGenerateImplicitAckForOwnOverheard(const meshtastic_MeshPacket *p)
{
// Note: do not use getFrom() here, because we want to ignore messages sent from phone
if (p->from != getNodeNum())
return;
printPacket("Rx someone rebroadcasting for us", p);
// We are seeing someone rebroadcast one of our transmissions. If this is the first time we saw
// this, cancel any retransmissions we have queued up and generate an internal ack for the
// original sending process. Header-only (from/id), so it works even for a packet we cannot
// decrypt - notably a PKI DM we originated, which is opaque to us when overheard.
// This "optimization", does save lots of airtime. For DMs, you also get a real ACK back
// from the intended recipient.
auto key = GlobalPacketId(getFrom(p), p->id);
auto old = findPendingPacket(key);
if (old) {
LOG_DEBUG("Generate implicit ack");
// NOTE: we do NOT check p->wantAck here because p is the INCOMING rebroadcast and that packet is not expected to be
// marked as wantAck
// Pass the overheard rebroadcast as the relay source so the ack carries the relaying node's id
// and the RSSI/SNR we heard it at.
sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, old->packet->channel, 0, false, p);
// Only stop retransmissions if the rebroadcast came via LoRa
if (p->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA) {
stopRetransmission(key);
}
} else {
LOG_DEBUG("Didn't find pending packet");
}
}
bool ReliableRouter::shouldFilterReceived(const meshtastic_MeshPacket *p)
{
perhapsGenerateImplicitAckForOwnOverheard(p);
/* At this point we have already deleted the pending retransmission if this packet was an (implicit) ACK to it.
Now for all other pending retransmissions, we have to add the airtime of this received packet to the retransmission timer,
because while receiving this packet, we could not have received an (implicit) ACK for it.
If we don't add this, we will likely retransmit too early.
*/
for (auto i = pending.begin(); i != pending.end(); i++) {
i->second.nextTxMsec += iface->getPacketTime(p, true);
}
return isBroadcast(p->to) ? FloodingRouter::shouldFilterReceived(p) : NextHopRouter::shouldFilterReceived(p);
}
/**
* If we receive a want_ack packet (do not check for wasSeenRecently), send back an ack (this might generate multiple ack sends in
* case the our first ack gets lost)
*
* If we receive an ack packet (do check wasSeenRecently), clear out any retransmissions and
* forward the ack to the application layer.
*
* If we receive a nak packet (do check wasSeenRecently), clear out any retransmissions
* and forward the nak to the application layer.
*
* Otherwise, let superclass handle it.
*/
void ReliableRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtastic_Routing *c)
{
lastAckProof = {};
if (isToUs(p)) { // ignore ack/nak/want_ack packets that are not address to us (we only handle 0 hop reliability)
if (!MeshModule::currentReply) {
if (p->want_ack) {
if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) {
/* A response may be set to want_ack for retransmissions, but we don't need to ACK a response if it received
an implicit ACK already. If we received it directly or via NextHopRouter, only ACK with a hop limit of 0 to
make sure the other side stops retransmitting. */
if (shouldSuccessAckWithWantAck(p)) {
// If this packet should always be ACKed reliably with want_ack back to the original sender, make sure we
// do that unconditionally.
sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel,
routingModule->getHopLimitForResponse(*p), true);
} else if (!p->decoded.request_id && !p->decoded.reply_id) {
// If it's not an ACK or a reply, send an ACK.
sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel,
routingModule->getHopLimitForResponse(*p));
} else if ((getHopsAway(*p) == 0) || p->next_hop != NO_NEXT_HOP_PREFERENCE) {
// If we received the packet directly from the original sender, send a 0-hop ACK since the original sender
// won't overhear any implicit ACKs. If we received the packet via NextHopRouter, also send a 0-hop ACK to
// stop the immediate relayer's retransmissions.
sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel, 0);
}
} else if (p->which_payload_variant == meshtastic_MeshPacket_encrypted_tag && p->channel == 0 &&
(nodeDB->getMeshNode(p->from) == nullptr || nodeDB->getMeshNode(p->from)->public_key.size == 0)) {
LOG_INFO("PKI packet from unknown node, send PKI_UNKNOWN_PUBKEY");
sendAckNak(meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY, getFrom(p), p->id, channels.getPrimaryIndex(),
routingModule->getHopLimitForResponse(*p));
} else {
// Send a 'NO_CHANNEL' error on the primary channel if want_ack packet destined for us cannot be decoded
sendAckNak(meshtastic_Routing_Error_NO_CHANNEL, getFrom(p), p->id, channels.getPrimaryIndex(),
routingModule->getHopLimitForResponse(*p));
}
} else if (p->next_hop == nodeDB->getLastByteOfNodeNum(getNodeNum()) && p->hop_limit > 0) {
// No wantAck, but we need to ACK with hop limit of 0 if we were the next hop to stop their retransmissions
sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel, 0);
}
} else {
LOG_DEBUG("Another module replied to this message, no need for 2nd ack");
}
if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && c &&
c->error_reason == meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY) {
if (owner.public_key.size == 32) {
LOG_INFO("PKI decrypt failure, send a NodeInfo");
nodeInfoModule->sendOurNodeInfo(p->from, false, p->channel, true);
}
}
// We consider an ack to be either a !routing packet with a request ID or a routing packet with !error
PacketId ackId = ((c && c->error_reason == meshtastic_Routing_Error_NONE) || !c) ? p->decoded.request_id : 0;
// A nak is a routing packt that has an error code
PacketId nakId = (c && c->error_reason != meshtastic_Routing_Error_NONE) ? p->decoded.request_id : 0;
// We intentionally don't check wasSeenRecently, because it is harmless to delete non existent retransmission records
if ((ackId || nakId) && ackProofPermitsAction(p, ackId ? ackId : nakId, ackId != 0) &&
// Implicit ACKs from MQTT should not stop retransmissions
!(isFromUs(p) && p->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT)) {
LOG_DEBUG("Received a %s for 0x%08x, stopping retransmissions", ackId ? "ACK" : "NAK", ackId);
if (ackId) {
stopRetransmission(p->to, ackId);
// M3: an end-to-end ACK proves the directed route to the ACK's sender currently works,
// so clear its failure count and refresh freshness (keeps a good route pinned).
if (!isBroadcast(getFrom(p)))
noteRouteSuccess(getFrom(p), Time::stampMillis());
} else {
stopRetransmission(p->to, nakId);
}
}
}
// handle the packet as normal
isBroadcast(p->to) ? FloodingRouter::sniffReceived(p, c) : NextHopRouter::sniffReceived(p, c);
}
#if !(MESHTASTIC_EXCLUDE_PKI)
static meshtastic_MeshPacket_AckProofStatus toAckProofStatus(AckProofResult result)
{
switch (result) {
case AckProofResult::VALID:
return meshtastic_MeshPacket_AckProofStatus_ACK_PROOF_VALID;
case AckProofResult::INVALID:
return meshtastic_MeshPacket_AckProofStatus_ACK_PROOF_INVALID;
case AckProofResult::NO_KEY:
return meshtastic_MeshPacket_AckProofStatus_ACK_PROOF_NO_KEY;
case AckProofResult::ABSENT:
break;
}
return meshtastic_MeshPacket_AckProofStatus_ACK_PROOF_ABSENT;
}
#endif
meshtastic_MeshPacket_AckProofStatus ReliableRouter::ackProofStatusFor(const meshtastic_MeshPacket &p) const
{
if (lastAckProof.from == getFrom(&p) && lastAckProof.id == p.id)
return lastAckProof.status;
return meshtastic_MeshPacket_AckProofStatus_ACK_PROOF_ABSENT;
}
bool ReliableRouter::ackProofPermitsAction(const meshtastic_MeshPacket *p, PacketId originalId, bool isAck)
{
#if !(MESHTASTIC_EXCLUDE_PKI)
// Cheap gate FIRST: verification costs one X25519 per call (setDHPublicKey runs Curve25519::dh2
// and nothing caches the result), and an attacker with the channel key picks when we pay it. A
// packet id is visible in the cleartext header, so without this gate a forged ack for any id at
// all forces a DH. With it, only ids we genuinely have outstanding can, and only while pending.
//
// The cost of that gate: a relay's rebroadcast of our own DM already cleared this entry via
// perhapsGenerateImplicitAckForOwnOverheard, so on most multi-hop DMs the peer's genuine proof
// arrives after the entry is gone and reads ABSENT. Do not "fix" that by dropping the gate - it
// is the DoS guard. Checking the proof before the echo clears the entry, or keeping a short-lived
// record of completed sends to verify against, is the shape of a real fix.
PendingPacket *orig = findPendingPacket(p->to, originalId);
if (!orig)
return true;
const bool expected = orig->packet && orig->packet->pki_encrypted;
// Only the node we ADDRESSED can prove receipt. The MAC only proves its author holds a pairwise
// key with us, and every keyed peer holds one - so verifying against the key of whoever the ack
// claims to be from would accept a proof minted by any of them. C, whose key we hold, can read
// our packet id out of the cleartext header and mint a VALID-looking receipt for a packet that
// went to B. Bind to orig->packet->to instead.
//
// Bailing out here rather than verifying against the recipient key and reporting INVALID is
// deliberate on both counts: it skips the X25519 an attacker would otherwise choose when we
// pay, and a third-party ack is "not a receipt" rather than "a forged receipt" - naks from
// intermediates (NO_CHANNEL, PKI_UNKNOWN_PUBKEY, MAX_RETRANSMIT) legitimately arrive from a
// node that is not the destination, and must not be logged as proof mismatches.
//
// A broadcast original has no single recipient to bind to, so there is nothing to check, and
// any recipient may answer it. That case permits unconditionally - holding it to the sender
// check under enforcement would stop every reliable broadcast from ever being acked.
if (!orig->packet || isBroadcast(orig->packet->to))
return true;
if (getFrom(p) != orig->packet->to) {
LOG_DEBUG("Ack for 0x%08x is not from the node we addressed, not a receipt", originalId);
// Advisory today, so both arms are true and this reads as dead code. It is not: under
// enforcement a bare `return true` here would be a hole, since the sender field is
// unauthenticated and an attacker would simply address the ack from anyone other than the
// node we sent to, skipping the proof requirement entirely. Only success acks are held to
// it; a nak from an intermediate is legitimate and keeps today's behavior either way.
//
// Closing this does NOT make enforcement sound on its own - ABSENT still permits, so an
// attacker spoofing `from` and sending no proof gets through anyway, and ABSENT cannot be
// made to block for the reasons recorded at ACK_PROOF_ENFORCE. The point is narrower: a
// flag named "enforce" should not have a branch that silently ignores it.
//
// Written as an if rather than `isAck ? !ACK_PROOF_ENFORCE : true` because with the flag
// off both arms of that ternary are `true`, which cppcheck reports as duplicateValueTernary.
if (isAck && ACK_PROOF_ENFORCE)
return false;
return true;
}
// Safe to key off getFrom(p) below only because it is now known equal to orig->packet->to.
const AckProofResult verdict = ackProofVerify(p, originalId);
lastAckProof = {getFrom(p), p->id, toAckProofStatus(verdict)};
switch (verdict) {
case AckProofResult::VALID:
LOG_DEBUG("ACK proof OK for 0x%08x", originalId);
return true;
case AckProofResult::INVALID:
// Somebody produced an ack for our packet without holding the shared secret.
LOG_WARN("ACK proof MISMATCH for 0x%08x from 0x%08x%s", originalId, getFrom(p),
ACK_PROOF_ENFORCE ? ", ignoring ack" : " (advisory)");
return !ACK_PROOF_ENFORCE;
case AckProofResult::NO_KEY:
LOG_DEBUG("ACK proof present for 0x%08x but no authoritative key for 0x%08x", originalId, getFrom(p));
return true;
case AckProofResult::ABSENT:
// What every current firmware sends, and what every non-PKI peer will always send, so this
// can only ever be reported - never enforced. See ACK_PROOF_ENFORCE in AckProof.h.
if (expected)
LOG_INFO("Unproven ack for PKI packet 0x%08x from 0x%08x", originalId, getFrom(p));
return true;
}
#else
(void)p;
(void)originalId;
(void)isAck;
#endif
return true;
}
/**
* If we ACK this packet, should we set want_ack=true on the ACK for reliable delivery of the ACK packet?
*/
bool ReliableRouter::shouldSuccessAckWithWantAck(const meshtastic_MeshPacket *p)
{
// Don't ACK-with-want-ACK outgoing packets
if (isFromUs(p))
return false;
// Only ACK-with-want-ACK if the original packet asked for want_ack
if (!p->want_ack)
return false;
// Only ACK-with-want-ACK packets to us (not broadcast)
if (!isToUs(p))
return false;
// Special case for text message DMs:
bool isTextMessage =
(p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) &&
IS_ONE_OF(p->decoded.portnum, meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP);
if (isTextMessage) {
// If it's a non-broadcast text message, and the original asked for want_ack,
// let's send an ACK that is itself want_ack to improve reliability of confirming delivery back to the sender.
// This should include all DMs regardless of whether or not reply_id is set.
return true;
}
return false;
}