mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-20 05:18:54 -04:00
* 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>
434 lines
20 KiB
C++
434 lines
20 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 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");
|
|
}
|
|
|
|
// 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_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
|