mirror of
https://github.com/meshtastic/firmware.git
synced 2026-10-13 17:32:56 -04:00
Prove explicit acks with Routing.ack_proof (#11877)
* Prove explicit acks with Routing.ack_proof
Explicit acks are ROUTING_APP packets, and ROUTING_APP is excluded from PKC, so
an ack travels under channel encryption alone - and the default channel key is
public. Anyone in range can forge one, and the client grants its strongest
delivery claim on the strength of the ack's unauthenticated `from`.
Where the acknowledged packet was PKI encrypted the endpoints already share a
Curve25519 secret, so the recipient can prove receipt in ~10 encoded bytes:
ack_proof = HMAC-SHA256(shared_key,
"ack" | LE32(from) | LE32(to) | LE32(request_id)
| routing)[0..8)
where `routing` is the encoded Routing message without the ack_proof field,
taken as received with that byte range removed rather than re-encoded. Excising
keeps the value a function of the received bytes alone, so it does not depend on
two implementations' encoders agreeing and does not drop fields this build has
never heard of. For the same reason the sender appends the field rather than
setting it on a decoded struct and re-encoding.
What this does and does not buy. It buys an authenticated delivery receipt from
the actual recipient, which is the property a forged ack costs a user and which
matters where people act on a delivery confirmation. It does NOT protect the
retransmission loop, and must not be described as if it does:
perhapsGenerateImplicitAckForOwnOverheard clears a pending retransmission on any
overheard rebroadcast of our own (from, id), header-only and keyless, so
replaying the originator's own ciphertext stops their retries more cheaply than
forging an ack. No ack authentication of any kind closes that path.
Advisory only, and deliberately not a step toward enforcement. A rule requiring
a proof once a peer has sent one would make a missing proof destroy the only
delivery signal we have, on state the user cannot see: the proof needs the peer
to hold our key, and peer-side eviction, a downgrade or a factory reset are all
invisible to us. A valid proof marks the ack verified; anything else behaves
exactly as today. The client renders the difference.
Verification costs one X25519 and nothing caches the shared secret, so
ackProofPermitsAction gates on findPendingPacket first - otherwise a forged ack
naming any packet id, which is visible in the cleartext header, would force a DH.
Depends on meshtastic/protobufs#1094 for the generated field.
* test: correct a comment that predates ack_proof being generated
The wire-roundtrip test described ack_proof as an unknown field, which was true
while the prototype hand-encoded it. The field is generated now, so this build
understands it - but older firmware does not, which is the case the assertion
actually covers.
* Fix the EXCLUDE_PKI test build, and format
Two copies of test_proof_binds_error_reason and test_proof_binds_direction were
sitting in the #else branch of test_ack_proof, where Identity, makeIdentity,
makeAck, becomeNode, crypto and ACK_PROOF_SIZE do not exist. They were also
unregistered, so they were dead code that only served to break the build. The
native suite never compiles that branch, so a local run could not see it.
Also apply clang-format to a declaration that had been wrapped by hand.
* Exempt test_ack_proof from trufflehog's Lob false positive
Same detector and same shape as the three suites already listed here: it
stitches nearby hex literals into one candidate string, and this suite's node
numbers and request ids (0x0A0A0A0A, 0x0B0B0B0B, 0xABCD1234) happen to match a
Lob API key. The suite holds no literal key material - every key it uses comes
from crypto->generateKeyPair at runtime.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hLcVif8GDEmA2k77hmFG8
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
54334ff936
commit
6f3f0bd7c2
9 files changed
+824
-2
No files matched your search
@@ -1,4 +1,5 @@
|
||||
#include "ReliableRouter.h"
|
||||
#include "AckProof.h"
|
||||
#include "Default.h"
|
||||
#include "MeshTypes.h"
|
||||
#include "NodeDB.h"
|
||||
@@ -172,7 +173,7 @@ void ReliableRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtas
|
||||
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) &&
|
||||
if ((ackId || nakId) && ackProofPermitsAction(p, ackId ? ackId : nakId) &&
|
||||
// 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);
|
||||
@@ -192,6 +193,44 @@ void ReliableRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtas
|
||||
isBroadcast(p->to) ? FloodingRouter::sniffReceived(p, c) : NextHopRouter::sniffReceived(p, c);
|
||||
}
|
||||
|
||||
bool ReliableRouter::ackProofPermitsAction(const meshtastic_MeshPacket *p, PacketId originalId)
|
||||
{
|
||||
#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.
|
||||
PendingPacket *orig = findPendingPacket(p->to, originalId);
|
||||
if (!orig)
|
||||
return true;
|
||||
const bool expected = orig->packet && orig->packet->pki_encrypted;
|
||||
|
||||
switch (ackProofVerify(p, originalId)) {
|
||||
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;
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* If we ACK this packet, should we set want_ack=true on the ACK for reliable delivery of the ACK packet?
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user