Files
firmware/test/test_ack_proof/test_main.cpp
T
Jonathan BennettandClaude Opus 5 57bdedf324 Bind the ack proof to the node we addressed, not the ack's sender (#11932)
* Bind the ack proof to the node we addressed, not the ack's sender

The MAC proves only that its author holds a pairwise key with us, and every
keyed peer holds one. ackProofVerify looks the key up by getFrom(p) - the ack's
claimed sender - and nothing compared that against the node we actually sent to,
so C, whose authoritative key we hold, could read our packet id out of the
cleartext header and mint a receipt for a packet that went to B. It verified
VALID. "An authenticated delivery receipt from the actual recipient" was not
what the code delivered.

Guard on orig->packet->to before verifying. Since that establishes
getFrom(p) == orig->packet->to, the existing key lookup is then correct and
AckProof.cpp is untouched.

Bailing out rather than verifying against the recipient key and reporting
INVALID is deliberate twice over: 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 come from a node that is not
the destination and must not be logged as proof mismatches. A broadcast original
has no single recipient, so there is nothing to bind to.

Also correct the header doc. It claimed channel (non-PKI) traffic gets nothing
from this, but isProvableAck tests only the ack's shape: a DM that travelled
under channel encryption still gets a proven ack when we hold the peer's key,
because the secret comes from X25519 rather than from the channel. That is more
coverage than the receipt needs and it costs one X25519 per ack generated - both
worth stating rather than implying the opposite.

test_proof_from_third_peer_fails_under_recipient_key pins the property the guard
relies on: Carol's proof for a packet Alice sent to Bob verifies under Carol's
key and fails under Bob's. The guard itself is not directly assertable while
every branch of the verdict switch returns true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hLcVif8GDEmA2k77hmFG8

* Make the sender check honor ACK_PROOF_ENFORCE

The mismatch branch returned true unconditionally. Today every branch of this
function returns true, so that reads as equivalent - but if enforcement is ever
switched on it is a hole rather than a no-op. The sender field is not
authenticated, so an attacker would simply address the ack from anyone other
than the node we sent to, take the early return, and skip the proof requirement
entirely.

Hold only success acks to it. A nak legitimately arrives from an intermediate
rather than from the destination - NO_CHANNEL, PKI_UNKNOWN_PUBKEY and
MAX_RETRANSMIT all do - so naks keep today's behavior in either mode.

Broadcast gets its own unconditional return rather than sharing the condition.
orig->packet->to is NODENUM_BROADCAST there and can never equal any sender, so
folding it into the sender check would, under enforcement, stop every reliable
broadcast from ever being acked.

This does not make enforcement sound on its own and the comment says so: ABSENT
still permits, so spoofing the sender and omitting the proof gets through
regardless, and ABSENT cannot be made to block for the reasons recorded at
ACK_PROOF_ENFORCE. The narrower point is that a flag named "enforce" should not
have a branch that silently ignores it.

Raised by CodeRabbit on #11932. Its reading - that this is a live authorization
bypass a third party can use to suppress retransmissions - does not hold: the
sender field is unauthenticated either way, and perhapsGenerateImplicitAckForOwn
Overheard already clears a pending retransmission on a replayed copy of our own
ciphertext, with no ack and no key involved. The structural point stands on its
own merits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hLcVif8GDEmA2k77hmFG8

* Avoid cppcheck's duplicateValueTernary in the enforce check

`return isAck ? !ACK_PROOF_ENFORCE : true` has the same value in both arms
while the flag is off, which is the whole point of the line - and is exactly
what cppcheck reports:

  style: Same value in both branches of ternary operator. [duplicateValueTernary]

That failed the `check` matrix on every platform. Write it as an if, which
expresses the same thing and does not trip the rule, and say so in a comment so
it does not get folded back into a ternary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hLcVif8GDEmA2k77hmFG8

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-24 07:15:54 +00:00

460 lines
21 KiB
C++

// PROTOTYPE suite for the pairwise ACK proof (src/mesh/AckProof.h).
//
// Covers the mechanism end to end - key agreement, request_id binding, wire encoding, and the
// verify verdicts - without standing up a NodeDB. The NodeDB-backed policy wrappers
// (ackProofAttach/ackProofVerify) and the ReliableRouter hook are deliberately NOT covered here;
// they need the full router harness, and this is a spike.
#include "configuration.h"
#include "TestUtil.h"
#include <unity.h>
#if !(MESHTASTIC_EXCLUDE_PKI)
#include "mesh/AckProof.h"
#include "mesh/CryptoEngine.h"
#include "mesh/MeshTypes.h"
#include <pb_decode.h>
#include <pb_encode.h>
static constexpr NodeNum ALICE = 0x0A0A0A0A; // sends the DM, verifies the ack
static constexpr NodeNum BOB = 0x0B0B0B0B; // receives the DM, produces the ack
static constexpr NodeNum CAROL = 0x0C0C0C0C; // a third node whose key we also hold
static constexpr uint32_t REQUEST_ID = 0xABCD1234;
// Stand-in for the encoded Routing message the proof covers.
static const uint8_t ROUTING[] = {0x18, 0x00}; // error_reason = NONE
#define ROUTING_LEN sizeof(ROUTING)
struct Identity {
uint8_t pub[32];
uint8_t priv[32];
};
static Identity makeIdentity()
{
Identity id;
crypto->generateKeyPair(id.pub, id.priv);
return id;
}
/** Act as this node for subsequent crypto calls. */
static void becomeNode(const Identity &id)
{
uint8_t priv[32];
memcpy(priv, id.priv, sizeof(priv));
crypto->setDHPrivateKey(priv);
}
/** A minimal ROUTING ack, shaped the way MeshModule::allocAckNak builds one. */
static meshtastic_MeshPacket makeAck(NodeNum from, NodeNum to, uint32_t requestId)
{
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
p.from = from;
p.to = to;
p.id = 0x5150;
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
p.decoded.portnum = meshtastic_PortNum_ROUTING_APP;
p.decoded.request_id = requestId;
meshtastic_Routing ack = meshtastic_Routing_init_default;
ack.which_variant = meshtastic_Routing_error_reason_tag;
ack.error_reason = meshtastic_Routing_Error_NONE;
p.decoded.payload.size =
pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_Routing_msg, &ack);
TEST_ASSERT_GREATER_THAN(0, p.decoded.payload.size);
return p;
}
void setUp(void) {}
void tearDown(void) {}
// The whole scheme rests on this: both endpoints derive the same value from opposite halves of the
// key pair, and nobody else can.
void test_proof_is_pairwise_and_symmetric(void)
{
const Identity alice = makeIdentity();
const Identity bob = makeIdentity();
const Identity mallory = makeIdentity();
uint8_t fromBob[ACK_PROOF_SIZE], fromAlice[ACK_PROOF_SIZE], fromMallory[ACK_PROOF_SIZE];
// Both endpoints compute over the SAME direction tuple (Bob is the acker); only the key half
// they hold differs.
becomeNode(bob);
TEST_ASSERT_TRUE(crypto->ackProofCompute(alice.pub, BOB, ALICE, REQUEST_ID, ROUTING, ROUTING_LEN, fromBob));
becomeNode(alice);
TEST_ASSERT_TRUE(crypto->ackProofCompute(bob.pub, BOB, ALICE, REQUEST_ID, ROUTING, ROUTING_LEN, fromAlice));
TEST_ASSERT_EQUAL_UINT8_ARRAY_MESSAGE(fromBob, fromAlice, ACK_PROOF_SIZE,
"both endpoints must derive the same proof from the shared secret");
// A third party holding both public keys cannot reproduce it.
becomeNode(mallory);
TEST_ASSERT_TRUE(crypto->ackProofCompute(alice.pub, BOB, ALICE, REQUEST_ID, ROUTING, ROUTING_LEN, fromMallory));
TEST_ASSERT_TRUE_MESSAGE(memcmp(fromBob, fromMallory, ACK_PROOF_SIZE) != 0, "a non-peer must not derive the proof");
}
// request_id is an input, so a captured proof cannot be aimed at a different pending packet - the
// retargeting problem that the signature scheme needed an explicit buffer change to solve.
void test_proof_binds_request_id(void)
{
const Identity alice = makeIdentity();
const Identity bob = makeIdentity();
uint8_t a[ACK_PROOF_SIZE], b[ACK_PROOF_SIZE];
becomeNode(bob);
TEST_ASSERT_TRUE(crypto->ackProofCompute(alice.pub, BOB, ALICE, REQUEST_ID, ROUTING, ROUTING_LEN, a));
TEST_ASSERT_TRUE(crypto->ackProofCompute(alice.pub, BOB, ALICE, REQUEST_ID + 1, ROUTING, ROUTING_LEN, b));
TEST_ASSERT_TRUE_MESSAGE(memcmp(a, b, ACK_PROOF_SIZE) != 0, "proof must differ per request_id");
}
// Without an identity there is nothing to prove with, and we must fail rather than emit a constant.
void test_proof_requires_private_key(void)
{
const Identity peer = makeIdentity();
uint8_t zero[32] = {0}, proof[ACK_PROOF_SIZE];
crypto->setDHPrivateKey(zero);
TEST_ASSERT_FALSE(crypto->ackProofCompute(peer.pub, BOB, ALICE, REQUEST_ID, ROUTING, ROUTING_LEN, proof));
}
// An all-zero peer key is a weak point; Curve25519::dh2 rejects it and so must we.
void test_proof_rejects_weak_peer_key(void)
{
const Identity self = makeIdentity();
becomeNode(self);
uint8_t zeroPub[32] = {0}, proof[ACK_PROOF_SIZE];
TEST_ASSERT_FALSE(crypto->ackProofCompute(zeroPub, BOB, ALICE, REQUEST_ID, ROUTING, ROUTING_LEN, proof));
}
// The proof rides as a protobuf field appended to the encoded Routing payload. A decoder still
// reads the ack correctly with it present - which held when ack_proof was an unknown field to this
// build and must keep holding now that it is generated, since older firmware sees it as unknown.
void test_wire_roundtrip_and_unknown_field_tolerance(void)
{
const Identity alice = makeIdentity();
const Identity bob = makeIdentity();
becomeNode(bob);
meshtastic_MeshPacket ack = makeAck(BOB, ALICE, REQUEST_ID);
const pb_size_t bare = ack.decoded.payload.size;
TEST_ASSERT_TRUE(ackProofAttachWithKey(&ack, alice.pub));
// Encoded cost is the proof plus a tag and a length byte, and only on acks.
TEST_ASSERT_EQUAL_MESSAGE(bare + ACK_PROOF_SIZE + 2, ack.decoded.payload.size, "proof must cost exactly 10 encoded bytes");
uint8_t extracted[ACK_PROOF_SIZE], expected[ACK_PROOF_SIZE];
TEST_ASSERT_TRUE(ackProofExtract(ack.decoded.payload.bytes, ack.decoded.payload.size, extracted));
uint8_t bareRouting[64];
memcpy(bareRouting, ack.decoded.payload.bytes, bare);
TEST_ASSERT_TRUE(crypto->ackProofCompute(alice.pub, BOB, ALICE, REQUEST_ID, bareRouting, bare, expected));
TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, extracted, ACK_PROOF_SIZE);
// Parsing the ack is unaffected by the proof - the case older firmware, which does not know the
// field, will be in.
meshtastic_Routing decoded = meshtastic_Routing_init_default;
TEST_ASSERT_TRUE_MESSAGE(
pb_decode_from_bytes(ack.decoded.payload.bytes, ack.decoded.payload.size, &meshtastic_Routing_msg, &decoded),
"a decoder must still parse the Routing message with a proof present");
TEST_ASSERT_EQUAL(meshtastic_Routing_error_reason_tag, decoded.which_variant);
TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, decoded.error_reason);
}
// A bare ack (every current firmware) is ABSENT, not INVALID - this is what keeps the scheme
// deployable alongside nodes that know nothing about it.
void test_verify_verdicts(void)
{
const Identity alice = makeIdentity();
const Identity bob = makeIdentity();
const Identity mallory = makeIdentity();
becomeNode(bob);
meshtastic_MeshPacket bare = makeAck(BOB, ALICE, REQUEST_ID);
meshtastic_MeshPacket proven = makeAck(BOB, ALICE, REQUEST_ID);
TEST_ASSERT_TRUE(ackProofAttachWithKey(&proven, alice.pub));
becomeNode(alice);
TEST_ASSERT_EQUAL(AckProofResult::ABSENT, ackProofVerifyWithKey(&bare, REQUEST_ID, bob.pub));
TEST_ASSERT_EQUAL(AckProofResult::VALID, ackProofVerifyWithKey(&proven, REQUEST_ID, bob.pub));
// Present but unjudgeable, because we hold no key for the acker.
TEST_ASSERT_EQUAL(AckProofResult::NO_KEY, ackProofVerifyWithKey(&proven, REQUEST_ID, nullptr));
// Right proof, wrong pending packet: retargeting is caught.
TEST_ASSERT_EQUAL(AckProofResult::INVALID, ackProofVerifyWithKey(&proven, REQUEST_ID + 1, bob.pub));
// Same ack attributed to a different signer's key.
TEST_ASSERT_EQUAL(AckProofResult::INVALID, ackProofVerifyWithKey(&proven, REQUEST_ID, mallory.pub));
}
// A forger with the channel PSK can rewrite the ack, but cannot produce the proof.
void test_verify_rejects_tampered_proof(void)
{
const Identity alice = makeIdentity();
const Identity bob = makeIdentity();
becomeNode(bob);
meshtastic_MeshPacket ack = makeAck(BOB, ALICE, REQUEST_ID);
TEST_ASSERT_TRUE(ackProofAttachWithKey(&ack, alice.pub));
becomeNode(alice);
TEST_ASSERT_EQUAL(AckProofResult::VALID, ackProofVerifyWithKey(&ack, REQUEST_ID, bob.pub));
ack.decoded.payload.bytes[ack.decoded.payload.size - 1] ^= 0x01;
TEST_ASSERT_EQUAL(AckProofResult::INVALID, ackProofVerifyWithKey(&ack, REQUEST_ID, bob.pub));
}
// A truncated or over-long field is malformed, not a proof - an honest sender emits exactly
// ACK_PROOF_SIZE bytes, so anything else is rejected outright rather than compared.
void test_malformed_proof_field_is_absent(void)
{
meshtastic_MeshPacket ack = makeAck(BOB, ALICE, REQUEST_ID);
uint8_t junk[ACK_PROOF_SIZE - 1];
memset(junk, 0x5A, sizeof(junk));
pb_ostream_t stream = pb_ostream_from_buffer(ack.decoded.payload.bytes + ack.decoded.payload.size,
sizeof(ack.decoded.payload.bytes) - ack.decoded.payload.size);
TEST_ASSERT_TRUE(pb_encode_tag(&stream, PB_WT_STRING, ACK_PROOF_FIELD_NUMBER));
TEST_ASSERT_TRUE(pb_encode_string(&stream, junk, sizeof(junk)));
ack.decoded.payload.size += stream.bytes_written;
uint8_t out[ACK_PROOF_SIZE];
TEST_ASSERT_FALSE(ackProofExtract(ack.decoded.payload.bytes, ack.decoded.payload.size, out));
}
// Nothing is attached to packets that are not explicit acks, so no other traffic pays for this.
void test_attach_only_applies_to_routing_unicasts(void)
{
const Identity peer = makeIdentity();
const Identity self = makeIdentity();
becomeNode(self);
meshtastic_MeshPacket broadcast = makeAck(BOB, NODENUM_BROADCAST, REQUEST_ID);
TEST_ASSERT_FALSE_MESSAGE(ackProofAttachWithKey(&broadcast, peer.pub), "broadcasts have no single peer to prove to");
meshtastic_MeshPacket noRequestId = makeAck(BOB, ALICE, 0);
TEST_ASSERT_FALSE_MESSAGE(ackProofAttachWithKey(&noRequestId, peer.pub), "nothing to bind without a request_id");
meshtastic_MeshPacket text = makeAck(BOB, ALICE, REQUEST_ID);
text.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
TEST_ASSERT_FALSE_MESSAGE(ackProofAttachWithKey(&text, peer.pub), "only ROUTING acks carry a proof");
}
// THE load-bearing property for multi-hop: a repeater that holds the channel key relays by
// decoding and re-encoding the Data message (perhapsDecode decodes in place, then perhapsEncode
// re-runs pb_encode on p->decoded for any packet not from us). That cycle strips unknown fields at
// the Data level - so the proof survives only because it is nested inside Data.payload, which is an
// opaque bytes field the decoder copies through verbatim. Both halves are asserted here: if anyone
// ever "simplifies" this by moving the proof up alongside xeddsa_signature, the second half fails.
void test_proof_survives_relay_reencode(void)
{
const Identity alice = makeIdentity();
const Identity bob = makeIdentity();
becomeNode(bob);
meshtastic_MeshPacket ack = makeAck(BOB, ALICE, REQUEST_ID);
TEST_ASSERT_TRUE(ackProofAttachWithKey(&ack, alice.pub));
// A field appended at the *Data* level, as a control for what a hop destroys.
uint8_t control[4] = {0xDE, 0xAD, 0xBE, 0xEF};
uint8_t ctrlBuf[16];
pb_ostream_t ctrl = pb_ostream_from_buffer(ctrlBuf, sizeof(ctrlBuf));
TEST_ASSERT_TRUE(pb_encode_tag(&ctrl, PB_WT_STRING, 101)); // not a field of meshtastic_Data
TEST_ASSERT_TRUE(pb_encode_string(&ctrl, control, sizeof(control)));
uint8_t wire[512];
const size_t wireLen = pb_encode_to_bytes(wire, sizeof(wire), &meshtastic_Data_msg, &ack.decoded);
TEST_ASSERT_GREATER_THAN(0, wireLen);
memcpy(wire + wireLen, ctrlBuf, ctrl.bytes_written);
const size_t withControl = wireLen + ctrl.bytes_written;
// Exactly what a repeater does with what it received.
meshtastic_MeshPacket relayed = meshtastic_MeshPacket_init_zero;
relayed.from = BOB;
relayed.to = ALICE;
relayed.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
TEST_ASSERT_TRUE(pb_decode_from_bytes(wire, withControl, &meshtastic_Data_msg, &relayed.decoded));
uint8_t reencoded[512];
const size_t reencodedLen = pb_encode_to_bytes(reencoded, sizeof(reencoded), &meshtastic_Data_msg, &relayed.decoded);
TEST_ASSERT_GREATER_THAN(0, reencodedLen);
// The Data-level field is gone after one hop...
TEST_ASSERT_LESS_THAN_MESSAGE(withControl, reencodedLen, "a Data-level unknown field must NOT survive the relay re-encode");
// ...but the proof, nested in the payload blob, is still there and still verifies downstream.
meshtastic_MeshPacket downstream = meshtastic_MeshPacket_init_zero;
downstream.from = BOB;
downstream.to = ALICE;
downstream.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
TEST_ASSERT_TRUE(pb_decode_from_bytes(reencoded, reencodedLen, &meshtastic_Data_msg, &downstream.decoded));
TEST_ASSERT_EQUAL(REQUEST_ID, downstream.decoded.request_id);
becomeNode(alice);
TEST_ASSERT_EQUAL_MESSAGE(AckProofResult::VALID, ackProofVerifyWithKey(&downstream, REQUEST_ID, bob.pub),
"proof must survive a repeater's decode/re-encode cycle");
}
// REGRESSION: an ack and a nak for the same packet share from/to/portnum/request_id and differ
// only in the Routing payload. Channel crypto is CTR with no MAC, so if the payload were not bound
// a PSK holder could bit-flip a proven "delivered" into a "failed" and the proof would still
// verify. The easy direction is success -> failure; manufacturing a fake success is much narrower,
// needing a captured proofed nak, and the naks that exist either never reach the air or come from
// a node that by definition holds no key.
void test_proof_binds_error_reason(void)
{
const Identity alice = makeIdentity();
const Identity bob = makeIdentity();
becomeNode(bob);
meshtastic_MeshPacket ack = makeAck(BOB, ALICE, REQUEST_ID);
TEST_ASSERT_EQUAL_MESSAGE(0x18, ack.decoded.payload.bytes[0], "expected error_reason to be field 3, varint");
TEST_ASSERT_EQUAL_MESSAGE(meshtastic_Routing_Error_NONE, ack.decoded.payload.bytes[1], "expected a success ack");
TEST_ASSERT_TRUE(ackProofAttachWithKey(&ack, alice.pub));
becomeNode(alice);
TEST_ASSERT_EQUAL(AckProofResult::VALID, ackProofVerifyWithKey(&ack, REQUEST_ID, bob.pub));
// Exactly what a CTR bit-flip buys the attacker: same proof bytes, different verdict.
ack.decoded.payload.bytes[1] = meshtastic_Routing_Error_MAX_RETRANSMIT;
TEST_ASSERT_EQUAL_MESSAGE(AckProofResult::INVALID, ackProofVerifyWithKey(&ack, REQUEST_ID, bob.pub),
"flipping a proven ack into a nak must invalidate the proof");
}
// X25519 is symmetric - DH(a_priv, B_pub) == DH(b_priv, A_pub) - so without the direction bound an
// A->B proof for a request_id would equal the B->A proof for it. Cheap insurance; this pins it.
void test_proof_binds_direction(void)
{
const Identity alice = makeIdentity();
const Identity bob = makeIdentity();
becomeNode(bob);
uint8_t forward[ACK_PROOF_SIZE], reverse[ACK_PROOF_SIZE];
TEST_ASSERT_TRUE(crypto->ackProofCompute(alice.pub, BOB, ALICE, REQUEST_ID, ROUTING, ROUTING_LEN, forward));
TEST_ASSERT_TRUE(crypto->ackProofCompute(alice.pub, ALICE, BOB, REQUEST_ID, ROUTING, ROUTING_LEN, reverse));
TEST_ASSERT_TRUE_MESSAGE(memcmp(forward, reverse, ACK_PROOF_SIZE) != 0,
"A->B and B->A proofs must differ despite the symmetric shared secret");
}
// The MAC only proves its author holds a pairwise key with us, and every keyed peer holds one.
// test_proof_binds_direction covers A->B against B->A; this covers a THIRD keyed peer, which is the
// case that decides whether "receipt from the recipient" is true. Carol, whose key Alice holds,
// mints an ack for a packet Alice sent to Bob. It verifies under Carol's key - so a verifier that
// keys off the ack's claimed sender accepts it - and must fail under Bob's, which is the key
// ReliableRouter::ackProofPermitsAction now looks up.
void test_proof_from_third_peer_fails_under_recipient_key(void)
{
const Identity alice = makeIdentity();
const Identity bob = makeIdentity();
const Identity carol = makeIdentity();
// Carol acks a packet that Alice actually sent to Bob.
becomeNode(carol);
meshtastic_MeshPacket ack = makeAck(CAROL, ALICE, REQUEST_ID);
TEST_ASSERT_TRUE(ackProofAttachWithKey(&ack, alice.pub));
becomeNode(alice);
TEST_ASSERT_EQUAL_MESSAGE(AckProofResult::VALID, ackProofVerifyWithKey(&ack, REQUEST_ID, carol.pub),
"keying off the ack's claimed sender is what accepts a third-party proof");
TEST_ASSERT_EQUAL_MESSAGE(AckProofResult::INVALID, ackProofVerifyWithKey(&ack, REQUEST_ID, bob.pub),
"under the addressed node's key a third peer's proof must fail");
}
// Two proof fields mean two possible excisions and so two possible verdicts. Refuse rather than
// silently pick one, since an attacker with the channel key can append a second field at will.
void test_duplicate_proof_field_is_rejected(void)
{
const Identity alice = makeIdentity();
const Identity bob = makeIdentity();
becomeNode(bob);
meshtastic_MeshPacket ack = makeAck(BOB, ALICE, REQUEST_ID);
TEST_ASSERT_TRUE(ackProofAttachWithKey(&ack, alice.pub));
uint8_t first[ACK_PROOF_SIZE];
TEST_ASSERT_TRUE(ackProofExtract(ack.decoded.payload.bytes, ack.decoded.payload.size, first));
// Append a second, byte-identical proof field.
pb_ostream_t stream = pb_ostream_from_buffer(ack.decoded.payload.bytes + ack.decoded.payload.size,
sizeof(ack.decoded.payload.bytes) - ack.decoded.payload.size);
TEST_ASSERT_TRUE(pb_encode_tag(&stream, PB_WT_STRING, ACK_PROOF_FIELD_NUMBER));
TEST_ASSERT_TRUE(pb_encode_string(&stream, first, ACK_PROOF_SIZE));
ack.decoded.payload.size += stream.bytes_written;
uint8_t out[ACK_PROOF_SIZE];
TEST_ASSERT_FALSE_MESSAGE(ackProofExtract(ack.decoded.payload.bytes, ack.decoded.payload.size, out),
"a message with two proof fields must be rejected as malformed");
becomeNode(alice);
TEST_ASSERT_EQUAL(AckProofResult::ABSENT, ackProofVerifyWithKey(&ack, REQUEST_ID, bob.pub));
}
// The proof must cover fields this build does not understand, so that a future Routing field
// outside the oneof is protected and old and new nodes still agree on the hashed bytes. Excision
// gives that for free; re-encoding from the decoded struct would silently drop them.
void test_proof_covers_unknown_routing_fields(void)
{
const Identity alice = makeIdentity();
const Identity bob = makeIdentity();
becomeNode(bob);
meshtastic_MeshPacket ack = makeAck(BOB, ALICE, REQUEST_ID);
// A field this build's Routing schema does not define, as a newer sender would emit.
const pb_size_t beforeUnknown = ack.decoded.payload.size;
uint8_t extra[3] = {0x11, 0x22, 0x33};
pb_ostream_t pre = pb_ostream_from_buffer(ack.decoded.payload.bytes + ack.decoded.payload.size,
sizeof(ack.decoded.payload.bytes) - ack.decoded.payload.size);
TEST_ASSERT_TRUE(pb_encode_tag(&pre, PB_WT_STRING, 9)); // not a field of meshtastic_Routing
TEST_ASSERT_TRUE(pb_encode_string(&pre, extra, sizeof(extra)));
ack.decoded.payload.size += pre.bytes_written;
// Its contents start after the one-byte tag and one-byte length.
const size_t unknownDataStart = beforeUnknown + 2;
TEST_ASSERT_TRUE(ackProofAttachWithKey(&ack, alice.pub));
becomeNode(alice);
TEST_ASSERT_EQUAL(AckProofResult::VALID, ackProofVerifyWithKey(&ack, REQUEST_ID, bob.pub));
// Tamper with the field's CONTENTS, not its framing: the message must stay parseable so that
// the verdict is INVALID (the proof no longer matches) rather than ABSENT (no readable proof).
ack.decoded.payload.bytes[unknownDataStart] ^= 0x01;
TEST_ASSERT_EQUAL_MESSAGE(AckProofResult::INVALID, ackProofVerifyWithKey(&ack, REQUEST_ID, bob.pub),
"an unknown field must be covered by the proof");
}
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
RUN_TEST(test_proof_is_pairwise_and_symmetric);
RUN_TEST(test_proof_binds_request_id);
RUN_TEST(test_proof_binds_error_reason);
RUN_TEST(test_proof_binds_direction);
RUN_TEST(test_proof_requires_private_key);
RUN_TEST(test_proof_rejects_weak_peer_key);
RUN_TEST(test_wire_roundtrip_and_unknown_field_tolerance);
RUN_TEST(test_verify_verdicts);
RUN_TEST(test_verify_rejects_tampered_proof);
RUN_TEST(test_malformed_proof_field_is_absent);
RUN_TEST(test_proof_from_third_peer_fails_under_recipient_key);
RUN_TEST(test_duplicate_proof_field_is_rejected);
RUN_TEST(test_proof_covers_unknown_routing_fields);
RUN_TEST(test_attach_only_applies_to_routing_unicasts);
RUN_TEST(test_proof_survives_relay_reencode);
exit(UNITY_END());
}
void loop() {}
#else // PKI excluded
void setUp(void) {}
void tearDown(void) {}
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
exit(UNITY_END());
}
void loop() {}
#endif