Packet Signing via XEdDSA (#10478)
* Test commit for XEdDSA support * Update to Crypto lib in Meshtatic org * Generate a new node identity on key generation (#7628) * Generate a new node identity on key generation * Fixes * Fixes * Fixes * Messed up * Fixes * Update src/modules/AdminModule.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/mesh/NodeDB.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Figured it out! * Cleanup * Update src/mesh/NodeDB.h Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/mesh/NodeDB.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/modules/AdminModule.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update crypto commit hash * Some fixes for xeddsa pr (#9610) * fix: add null check for getMeshNode() in NodeInfoModule getMeshNode() can return nullptr for unknown nodes. Dereferencing without a check crashes the firmware when receiving NodeInfo from a node not yet in the database. * fix: enforce XEdDSA signature verification and prevent stripping Previously, failed signature verification still allowed the packet through, making signatures purely cosmetic. Now: - Failed verification drops the packet (DECODE_FAILURE) - Successfully verified nodes get HAS_XEDDSA_SIGNED bitfield set - Unsigned packets from previously-signing nodes are rejected - Log levels reduced from WARN/ERROR to DEBUG/WARN as appropriate * fix: include packet metadata in XEdDSA signature The signature now covers [fromNode | packetId | portnum | payload] instead of just the payload bytes. This prevents: - Replay attacks (different packetId fails verification) - Reattribution (different fromNode fails verification) - Portnum redirection (different portnum fails verification) Also adds a key initialization check to xeddsa_sign (returns false if XEdDSA keys are all zeros) and checks the return value in the encode path. * fix: handle existing key pair in AdminModule security config When a user provides both a valid private key and public key via admin config, the crypto engine's DH private key and owner public key were never loaded. DMs and XEdDSA signing would silently break. Add an else branch to load both keys into the crypto engine. * perf: cache Ed25519 public key conversion in xeddsa_verify curve_to_ed_pub() performs field element parsing, inversion, and multiplication on every call. Since packets from the same node tend to arrive in bursts, a single-entry cache avoids repeating this expensive conversion for consecutive packets from one sender. * fix: skip identity cleanup when node number is unchanged createNewIdentity() was called on every generateCryptoKeyPair(), including normal boots where the same key is regenerated. This caused unnecessary NodeDB writes and old-node cleanup logic to run when the node number hadn't actually changed. Also fixes only zeroing byte[0] of the old node's public key instead of clearing the entire array. * fix: replace hardcoded 120 with derived XEDDSA_SIGNATURE_SIZE constant The payload size check for XEdDSA signing used a magic number (120). Replace with a derivation from DATA_PAYLOAD_LEN and XEDDSA_SIGNATURE_SIZE so the limit adjusts automatically if constants change. This also increases the max signable payload from 120 to 169 bytes, which is still safe since the actual encoded size is checked after pb_encode. * fix: add const qualifiers to XEdDSA verify and curve_to_ed_pub inputs pubKey, payload, and signature parameters in xeddsa_verify are input-only and should not be modified. Same for curve_pubkey in curve_to_ed_pub. * chore: remove commented-out old Crypto dependency in portduino.ini * Leave out the admin module change for now --------- Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz> * trunk * protobuf re-update * Protobufs * Merge resolution fix * Put XEDDSA on the right bit * NodeDB update to new nodeInfoLite accessors, etc * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Refine unsigned packet rejection logic in Router (#10534) * use hardware random to fill the first 32 signature bytes with entropy prior to signing. * Add XEdDSA packet-signing policy tests and update dependencies for macos * Minor fixes * integrate XEdDSA support and update dependencies across multiple modules --------- Co-authored-by: Ben Meadors <benmmeadors@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Wessel <github@weebl.me> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
This commit is contained in:
co-authored by
GitHub
Ben Meadors
Copilot
Wessel
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
thebentern
parent
07a87a8254
commit
8267bb22bd
@@ -2,6 +2,7 @@
|
||||
#include "CryptoEngine.h"
|
||||
|
||||
#include "TestUtil.h"
|
||||
#include <XEdDSA.h>
|
||||
#include <unity.h>
|
||||
|
||||
void HexToBytes(uint8_t *result, const std::string hex, size_t len = 0)
|
||||
@@ -152,6 +153,134 @@ void test_PKC(void)
|
||||
TEST_ASSERT_EQUAL_MEMORY(expected_decrypted, decrypted, 10);
|
||||
}
|
||||
|
||||
void test_XEdDSA(void)
|
||||
{
|
||||
uint8_t private_key[32];
|
||||
uint8_t x_public_key[32];
|
||||
uint8_t ed_private_key[32];
|
||||
uint8_t ed_public_key[32];
|
||||
uint8_t ed_public_key2[32];
|
||||
uint8_t message[] = "This is a test!";
|
||||
uint8_t message2[] = "This is a test.";
|
||||
uint8_t signature[64];
|
||||
uint32_t fromNode = 0x1234;
|
||||
uint32_t packetId = 0xDEADBEEF;
|
||||
uint32_t portnum = 1;
|
||||
for (int times = 0; times < 10; times++) {
|
||||
printf("Start of time %u\n", times);
|
||||
crypto->generateKeyPair(x_public_key, private_key);
|
||||
XEdDSA::priv_curve_to_ed_keys(private_key, ed_private_key, ed_public_key);
|
||||
crypto->curve_to_ed_pub(x_public_key, ed_public_key2);
|
||||
TEST_ASSERT_EQUAL_MEMORY(ed_public_key, ed_public_key2, 32);
|
||||
|
||||
// Sign and verify with metadata
|
||||
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), signature));
|
||||
TEST_ASSERT(crypto->xeddsa_verify(x_public_key, fromNode, packetId, portnum, message, sizeof(message), signature));
|
||||
|
||||
// Different payload fails
|
||||
TEST_ASSERT_FALSE(
|
||||
crypto->xeddsa_verify(x_public_key, fromNode, packetId, portnum, message2, sizeof(message2), signature));
|
||||
|
||||
// Different fromNode fails
|
||||
TEST_ASSERT_FALSE(
|
||||
crypto->xeddsa_verify(x_public_key, fromNode + 1, packetId, portnum, message, sizeof(message), signature));
|
||||
|
||||
// Different packetId fails
|
||||
TEST_ASSERT_FALSE(
|
||||
crypto->xeddsa_verify(x_public_key, fromNode, packetId + 1, portnum, message, sizeof(message), signature));
|
||||
|
||||
// Different portnum fails
|
||||
TEST_ASSERT_FALSE(
|
||||
crypto->xeddsa_verify(x_public_key, fromNode, packetId, portnum + 1, message, sizeof(message), signature));
|
||||
}
|
||||
}
|
||||
|
||||
// A signature only verifies under the signer's own key; a different key (or an all-zero key) fails.
|
||||
void test_XEdDSA_cross_key_reject(void)
|
||||
{
|
||||
uint8_t pubA[32], privA[32];
|
||||
uint8_t pubB[32], privB[32];
|
||||
uint8_t signature[64];
|
||||
uint8_t message[] = "cross-key check";
|
||||
uint32_t fromNode = 0x4242, packetId = 0xABCD1234, portnum = 7;
|
||||
|
||||
crypto->generateKeyPair(pubA, privA); // engine now holds key A
|
||||
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), signature));
|
||||
|
||||
crypto->generateKeyPair(pubB, privB); // unrelated key pair
|
||||
|
||||
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pubA, fromNode, packetId, portnum, message, sizeof(message), signature));
|
||||
TEST_ASSERT_FALSE(crypto->xeddsa_verify(pubB, fromNode, packetId, portnum, message, sizeof(message), signature));
|
||||
|
||||
uint8_t zeroKey[32] = {0};
|
||||
TEST_ASSERT_FALSE(crypto->xeddsa_verify(zeroKey, fromNode, packetId, portnum, message, sizeof(message), signature));
|
||||
}
|
||||
|
||||
// Signing with an unset (all-zero) private key must fail rather than emit a bogus signature.
|
||||
void test_XEdDSA_empty_key_sign_fails(void)
|
||||
{
|
||||
CryptoEngine fresh; // freshly constructed: xeddsa_private_key is all zero
|
||||
uint8_t signature[64];
|
||||
uint8_t message[] = "no key";
|
||||
TEST_ASSERT_FALSE(fresh.xeddsa_sign(0x1, 0x2, 0x3, message, sizeof(message), signature));
|
||||
}
|
||||
|
||||
// curve_to_ed_pub caches the last converted key; verifying A, then B, then A must stay correct.
|
||||
void test_XEdDSA_curve_to_ed_cache(void)
|
||||
{
|
||||
uint8_t pubA[32], privA[32], sigA[64];
|
||||
uint8_t pubB[32], privB[32], sigB[64];
|
||||
uint8_t message[] = "cache check";
|
||||
uint32_t fromNode = 0x11, packetId = 0x22, portnum = 3;
|
||||
|
||||
crypto->generateKeyPair(pubA, privA);
|
||||
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), sigA));
|
||||
crypto->generateKeyPair(pubB, privB);
|
||||
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), sigB));
|
||||
|
||||
// Interleave keys to exercise both cache hits and cache invalidation.
|
||||
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pubA, fromNode, packetId, portnum, message, sizeof(message), sigA));
|
||||
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pubB, fromNode, packetId, portnum, message, sizeof(message), sigB));
|
||||
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pubA, fromNode, packetId, portnum, message, sizeof(message), sigA));
|
||||
TEST_ASSERT_FALSE(crypto->xeddsa_verify(pubA, fromNode, packetId, portnum, message, sizeof(message), sigB));
|
||||
}
|
||||
|
||||
// A payload at the maximum signable size (DATA_PAYLOAD_LEN - signature) round-trips and detects tampering.
|
||||
void test_XEdDSA_max_payload(void)
|
||||
{
|
||||
const size_t len = meshtastic_Constants_DATA_PAYLOAD_LEN - XEDDSA_SIGNATURE_SIZE;
|
||||
uint8_t payload[meshtastic_Constants_DATA_PAYLOAD_LEN];
|
||||
for (size_t i = 0; i < len; i++)
|
||||
payload[i] = (uint8_t)(i * 7 + 1);
|
||||
|
||||
uint8_t pub[32], priv[32], signature[64];
|
||||
crypto->generateKeyPair(pub, priv);
|
||||
uint32_t fromNode = 0xFEED, packetId = 0xC0DE, portnum = 1;
|
||||
|
||||
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, payload, len, signature));
|
||||
TEST_ASSERT(crypto->xeddsa_verify(pub, fromNode, packetId, portnum, payload, len, signature));
|
||||
payload[0] ^= 0x01;
|
||||
TEST_ASSERT_FALSE(crypto->xeddsa_verify(pub, fromNode, packetId, portnum, payload, len, signature));
|
||||
}
|
||||
|
||||
// Signing the same message twice yields signatures that both verify. This XEdDSA implementation is
|
||||
// deterministic in practice (the two signatures are typically byte-identical, even though
|
||||
// HardwareRNG::fill provides real entropy on this platform), so we assert only the security-relevant
|
||||
// property — every produced signature verifies — rather than asserting (non-)determinism.
|
||||
void test_XEdDSA_repeated_sign_verifies(void)
|
||||
{
|
||||
uint8_t pub[32], priv[32], sig1[64], sig2[64];
|
||||
uint8_t message[] = "same message";
|
||||
uint32_t fromNode = 0x9, packetId = 0x9, portnum = 9;
|
||||
|
||||
crypto->generateKeyPair(pub, priv);
|
||||
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), sig1));
|
||||
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), sig2));
|
||||
|
||||
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pub, fromNode, packetId, portnum, message, sizeof(message), sig1));
|
||||
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pub, fromNode, packetId, portnum, message, sizeof(message), sig2));
|
||||
}
|
||||
|
||||
void test_AES_CTR(void)
|
||||
{
|
||||
uint8_t expected[32];
|
||||
@@ -192,6 +321,12 @@ void setup()
|
||||
RUN_TEST(test_DH25519);
|
||||
RUN_TEST(test_AES_CTR);
|
||||
RUN_TEST(test_PKC);
|
||||
RUN_TEST(test_XEdDSA);
|
||||
RUN_TEST(test_XEdDSA_cross_key_reject);
|
||||
RUN_TEST(test_XEdDSA_empty_key_sign_fails);
|
||||
RUN_TEST(test_XEdDSA_curve_to_ed_cache);
|
||||
RUN_TEST(test_XEdDSA_max_payload);
|
||||
RUN_TEST(test_XEdDSA_repeated_sign_verifies);
|
||||
exit(UNITY_END()); // stop unit testing
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
// Tests for XEdDSA packet-signing *policy* — the receive-path accept/reject behavior and the
|
||||
// send-path signing policy — as opposed to the raw sign/verify primitive (covered in test_crypto).
|
||||
//
|
||||
// The decision logic under test lives inside perhapsDecode()/perhapsEncode() (free functions in
|
||||
// Router.cpp). It only runs after a packet is decrypted, so every case drives a real
|
||||
// encode -> decode round-trip through the default channel (black-box, no production changes).
|
||||
//
|
||||
// Group A receive-side accept/reject matrix (verify, downgrade protection, signer-bit learning)
|
||||
// Group B send-side signing policy (which outgoing packets perhapsEncode signs)
|
||||
// Group C NodeInfoModule's stricter "drop unsigned NodeInfo from a known signer" rule
|
||||
|
||||
#include "MeshTypes.h" // include BEFORE TestUtil.h
|
||||
#include "TestUtil.h"
|
||||
#include <unity.h>
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
|
||||
#include "mesh/Channels.h"
|
||||
#include "mesh/CryptoEngine.h"
|
||||
#include "mesh/NodeDB.h"
|
||||
#include "mesh/Router.h"
|
||||
#include "modules/NodeInfoModule.h"
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixture identifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A;
|
||||
static constexpr NodeNum REMOTE_NODE = 0x0B0B0B0B;
|
||||
|
||||
// A "small" broadcast payload that leaves room for a 64-byte signature (payload + 64 < 233),
|
||||
// and an "oversized" one that does not (payload + 64 >= 233) yet still encodes within a LoRa frame.
|
||||
static constexpr size_t SMALL_PAYLOAD = 16;
|
||||
static constexpr size_t OVERSIZED_PAYLOAD = 180;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MockNodeDB — inject nodes with controlled public keys / signer bits.
|
||||
// Mirrors the pattern in test/test_hop_scaling. meshNodes/numMeshNodes are public on NodeDB.
|
||||
// ---------------------------------------------------------------------------
|
||||
class MockNodeDB : public NodeDB
|
||||
{
|
||||
public:
|
||||
void clearTestNodes()
|
||||
{
|
||||
testNodes.clear();
|
||||
meshNodes = &testNodes;
|
||||
numMeshNodes = 0;
|
||||
}
|
||||
|
||||
// Add a bare node and return a stable handle (fetch via getMeshNode so the pointer stays valid
|
||||
// even if the vector reallocates after later adds).
|
||||
void addNode(NodeNum num)
|
||||
{
|
||||
meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
|
||||
node.num = num;
|
||||
testNodes.push_back(node);
|
||||
meshNodes = &testNodes;
|
||||
numMeshNodes = testNodes.size();
|
||||
}
|
||||
|
||||
void setPublicKey(NodeNum num, const uint8_t *pubKey)
|
||||
{
|
||||
meshtastic_NodeInfoLite *n = getMeshNode(num);
|
||||
TEST_ASSERT_NOT_NULL(n);
|
||||
n->public_key.size = 32;
|
||||
memcpy(n->public_key.bytes, pubKey, 32);
|
||||
}
|
||||
|
||||
void setSignerBit(NodeNum num, bool value)
|
||||
{
|
||||
meshtastic_NodeInfoLite *n = getMeshNode(num);
|
||||
TEST_ASSERT_NOT_NULL(n);
|
||||
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, value);
|
||||
}
|
||||
|
||||
std::vector<meshtastic_NodeInfoLite> testNodes;
|
||||
};
|
||||
|
||||
static MockNodeDB *mockNodeDB = nullptr;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Build a decoded packet with a deterministic payload of the requested size.
|
||||
static meshtastic_MeshPacket makeDecoded(NodeNum from, NodeNum to, meshtastic_PortNum port, size_t payloadLen)
|
||||
{
|
||||
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
|
||||
p.from = from;
|
||||
p.to = to;
|
||||
p.id = 0x12345678;
|
||||
p.channel = 0; // primary channel index (perhapsEncode rewrites this to the channel hash)
|
||||
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||
p.decoded.portnum = port;
|
||||
p.decoded.payload.size = payloadLen;
|
||||
for (size_t i = 0; i < payloadLen; i++)
|
||||
p.decoded.payload.bytes[i] = (uint8_t)(i & 0xff);
|
||||
return p;
|
||||
}
|
||||
|
||||
// Sign a decoded packet with the CryptoEngine's current key — used to simulate a *remote* signer,
|
||||
// because perhapsEncode only auto-signs packets that originate from us.
|
||||
static void signWithCurrentKey(meshtastic_MeshPacket *p)
|
||||
{
|
||||
bool ok = crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size,
|
||||
p->decoded.xeddsa_signature.bytes);
|
||||
TEST_ASSERT_TRUE_MESSAGE(ok, "xeddsa_sign failed in test setup");
|
||||
p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
|
||||
}
|
||||
|
||||
// Encrypt (perhapsEncode) then decrypt+evaluate (perhapsDecode) the same packet in place.
|
||||
static DecodeState roundTrip(meshtastic_MeshPacket *p)
|
||||
{
|
||||
meshtastic_Routing_Error enc = perhapsEncode(p);
|
||||
TEST_ASSERT_EQUAL_MESSAGE(meshtastic_Routing_Error_NONE, enc, "perhapsEncode did not succeed");
|
||||
TEST_ASSERT_EQUAL_MESSAGE(meshtastic_MeshPacket_encrypted_tag, p->which_payload_variant,
|
||||
"perhapsEncode left packet unencrypted");
|
||||
return perhapsDecode(p);
|
||||
}
|
||||
|
||||
static bool remoteSignerBit()
|
||||
{
|
||||
return nodeInfoLiteHasXeddsaSigned(mockNodeDB->getMeshNode(REMOTE_NODE));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unity lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
void setUp(void)
|
||||
{
|
||||
// Clean global config/owner; zeroed config => rebroadcast ALL (no KNOWN_ONLY drop) and
|
||||
// security.private_key.size == 0 (PKI encrypt path skipped => simple channel crypto).
|
||||
config = meshtastic_LocalConfig_init_zero;
|
||||
owner = meshtastic_User_init_zero;
|
||||
|
||||
mockNodeDB = new MockNodeDB();
|
||||
mockNodeDB->clearTestNodes();
|
||||
nodeDB = mockNodeDB;
|
||||
myNodeInfo.my_node_num = LOCAL_NODE; // drives isFromUs()/getFrom()/isToUs()
|
||||
|
||||
// Working primary channel with the default PSK so encrypt/decrypt round-trips.
|
||||
channels.initDefaults();
|
||||
channels.onConfigChanged();
|
||||
}
|
||||
|
||||
void tearDown(void)
|
||||
{
|
||||
delete mockNodeDB;
|
||||
mockNodeDB = nullptr;
|
||||
nodeDB = nullptr;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Group A — receive-side accept/reject matrix
|
||||
// ===========================================================================
|
||||
|
||||
// A1: valid signature from a node whose key we know -> accepted, marked signed, signer bit learned.
|
||||
void test_A1_valid_signature_accepted_and_learns_signer(void)
|
||||
{
|
||||
uint8_t pub[32], priv[32];
|
||||
crypto->generateKeyPair(pub, priv); // engine now holds REMOTE's key
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
mockNodeDB->setPublicKey(REMOTE_NODE, pub);
|
||||
|
||||
TEST_ASSERT_FALSE(remoteSignerBit()); // not known as a signer yet
|
||||
|
||||
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
|
||||
signWithCurrentKey(&p);
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
|
||||
TEST_ASSERT_TRUE(p.xeddsa_signed);
|
||||
TEST_ASSERT_TRUE_MESSAGE(remoteSignerBit(), "verified signature must set the signer bit");
|
||||
}
|
||||
|
||||
// A2: a tampered signature from a known key -> dropped.
|
||||
void test_A2_bad_signature_dropped(void)
|
||||
{
|
||||
uint8_t pub[32], priv[32];
|
||||
crypto->generateKeyPair(pub, priv);
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
mockNodeDB->setPublicKey(REMOTE_NODE, pub);
|
||||
|
||||
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
|
||||
signWithCurrentKey(&p);
|
||||
p.decoded.xeddsa_signature.bytes[0] ^= 0xFF; // corrupt the signature
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_FAILURE, roundTrip(&p));
|
||||
}
|
||||
|
||||
// A3: signed packet but we have no key for the sender -> accepted unverified, signer bit NOT set.
|
||||
void test_A3_signed_no_pubkey_accepted_unverified(void)
|
||||
{
|
||||
uint8_t pub[32], priv[32];
|
||||
crypto->generateKeyPair(pub, priv);
|
||||
mockNodeDB->addNode(REMOTE_NODE); // node exists, but no public key stored
|
||||
|
||||
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
|
||||
signWithCurrentKey(&p);
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
|
||||
TEST_ASSERT_FALSE_MESSAGE(p.xeddsa_signed, "cannot be marked verified without a key");
|
||||
TEST_ASSERT_FALSE_MESSAGE(remoteSignerBit(), "must not learn signer without verifying");
|
||||
}
|
||||
|
||||
// A4: downgrade protection — unsigned small broadcast from a known signer -> dropped.
|
||||
void test_A4_downgrade_unsigned_broadcast_from_signer_dropped(void)
|
||||
{
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
mockNodeDB->setSignerBit(REMOTE_NODE, true); // we've seen this node sign before
|
||||
|
||||
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
|
||||
// from != us, so perhapsEncode leaves it unsigned.
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_FAILURE, roundTrip(&p));
|
||||
}
|
||||
|
||||
// A5: no prior knowledge — unsigned small broadcast from a non-signer -> accepted.
|
||||
void test_A5_unsigned_broadcast_from_nonsigner_accepted(void)
|
||||
{
|
||||
mockNodeDB->addNode(REMOTE_NODE); // signer bit clear
|
||||
|
||||
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
|
||||
TEST_ASSERT_FALSE(p.xeddsa_signed);
|
||||
}
|
||||
|
||||
// A6: unsigned UNICAST from a known signer -> accepted (unicasts are never signed).
|
||||
void test_A6_unsigned_unicast_from_signer_accepted(void)
|
||||
{
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
mockNodeDB->setSignerBit(REMOTE_NODE, true);
|
||||
|
||||
// Unicast to us; PRIVATE_APP avoids the unrelated legacy-DM rejection for TEXT_MESSAGE_APP.
|
||||
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_PRIVATE_APP, SMALL_PAYLOAD);
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
|
||||
}
|
||||
|
||||
// A7: unsigned OVERSIZED broadcast from a known signer -> accepted (couldn't have carried a sig).
|
||||
void test_A7_unsigned_oversized_broadcast_from_signer_accepted(void)
|
||||
{
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
mockNodeDB->setSignerBit(REMOTE_NODE, true);
|
||||
|
||||
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, OVERSIZED_PAYLOAD);
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Group B — send-side signing policy (perhapsEncode)
|
||||
// ===========================================================================
|
||||
|
||||
// B1: our own small broadcast is auto-signed (and verifies on the way back in).
|
||||
void test_B1_local_broadcast_is_signed(void)
|
||||
{
|
||||
uint8_t pub[32], priv[32];
|
||||
crypto->generateKeyPair(pub, priv); // engine signs with this; store the matching pubkey for us
|
||||
mockNodeDB->addNode(LOCAL_NODE);
|
||||
mockNodeDB->setPublicKey(LOCAL_NODE, pub);
|
||||
|
||||
meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
|
||||
TEST_ASSERT_EQUAL_MESSAGE(XEDDSA_SIGNATURE_SIZE, p.decoded.xeddsa_signature.size, "broadcast should be auto-signed");
|
||||
TEST_ASSERT_TRUE(p.xeddsa_signed);
|
||||
}
|
||||
|
||||
// B2: our own unicast is NOT signed.
|
||||
void test_B2_local_unicast_not_signed(void)
|
||||
{
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
|
||||
meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_PRIVATE_APP, SMALL_PAYLOAD);
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
|
||||
TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "unicast must not be signed");
|
||||
}
|
||||
|
||||
// B3: our own oversized broadcast is NOT signed (signature wouldn't fit).
|
||||
void test_B3_local_oversized_broadcast_not_signed(void)
|
||||
{
|
||||
meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, OVERSIZED_PAYLOAD);
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
|
||||
TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "oversized broadcast must not be signed");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Group C — NodeInfoModule downgrade drop (stricter: any unsigned NodeInfo from a known signer)
|
||||
// ===========================================================================
|
||||
class NodeInfoTestShim : public NodeInfoModule
|
||||
{
|
||||
public:
|
||||
using NodeInfoModule::handleReceivedProtobuf; // protected virtual -> exposed for direct call
|
||||
};
|
||||
|
||||
static meshtastic_MeshPacket makeNodeInfoPacket(bool signed_)
|
||||
{
|
||||
// Broadcast so the module's phone-forward path (which needs `service`) is skipped.
|
||||
meshtastic_MeshPacket mp = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD);
|
||||
mp.xeddsa_signed = signed_;
|
||||
return mp;
|
||||
}
|
||||
|
||||
// C1: unsigned NodeInfo from a node that previously signed -> dropped.
|
||||
void test_C1_unsigned_nodeinfo_from_signer_dropped(void)
|
||||
{
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
mockNodeDB->setSignerBit(REMOTE_NODE, true);
|
||||
|
||||
NodeInfoTestShim shim;
|
||||
meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/false);
|
||||
meshtastic_User user = meshtastic_User_init_zero;
|
||||
user.is_licensed = owner.is_licensed;
|
||||
|
||||
TEST_ASSERT_TRUE_MESSAGE(shim.handleReceivedProtobuf(mp, &user), "unsigned NodeInfo from signer must be dropped");
|
||||
}
|
||||
|
||||
// C2: signed NodeInfo from a known signer -> not dropped by this rule.
|
||||
void test_C2_signed_nodeinfo_from_signer_not_dropped(void)
|
||||
{
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
mockNodeDB->setSignerBit(REMOTE_NODE, true);
|
||||
|
||||
NodeInfoTestShim shim;
|
||||
meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/true);
|
||||
meshtastic_User user = meshtastic_User_init_zero;
|
||||
user.is_licensed = owner.is_licensed;
|
||||
|
||||
TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user));
|
||||
}
|
||||
|
||||
// C3: unsigned NodeInfo from a node we've never seen sign -> not dropped.
|
||||
void test_C3_unsigned_nodeinfo_from_nonsigner_not_dropped(void)
|
||||
{
|
||||
mockNodeDB->addNode(REMOTE_NODE); // signer bit clear
|
||||
|
||||
NodeInfoTestShim shim;
|
||||
meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/false);
|
||||
meshtastic_User user = meshtastic_User_init_zero;
|
||||
user.is_licensed = owner.is_licensed;
|
||||
|
||||
TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user));
|
||||
}
|
||||
|
||||
void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
|
||||
printf("\n=== Group A: receive-side accept/reject ===\n");
|
||||
RUN_TEST(test_A1_valid_signature_accepted_and_learns_signer);
|
||||
RUN_TEST(test_A2_bad_signature_dropped);
|
||||
RUN_TEST(test_A3_signed_no_pubkey_accepted_unverified);
|
||||
RUN_TEST(test_A4_downgrade_unsigned_broadcast_from_signer_dropped);
|
||||
RUN_TEST(test_A5_unsigned_broadcast_from_nonsigner_accepted);
|
||||
RUN_TEST(test_A6_unsigned_unicast_from_signer_accepted);
|
||||
RUN_TEST(test_A7_unsigned_oversized_broadcast_from_signer_accepted);
|
||||
|
||||
printf("\n=== Group B: send-side signing policy ===\n");
|
||||
RUN_TEST(test_B1_local_broadcast_is_signed);
|
||||
RUN_TEST(test_B2_local_unicast_not_signed);
|
||||
RUN_TEST(test_B3_local_oversized_broadcast_not_signed);
|
||||
|
||||
printf("\n=== Group C: NodeInfoModule downgrade drop ===\n");
|
||||
RUN_TEST(test_C1_unsigned_nodeinfo_from_signer_dropped);
|
||||
RUN_TEST(test_C2_signed_nodeinfo_from_signer_not_dropped);
|
||||
RUN_TEST(test_C3_unsigned_nodeinfo_from_nonsigner_not_dropped);
|
||||
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
|
||||
#else // MESHTASTIC_EXCLUDE_PKI
|
||||
|
||||
void setUp(void) {}
|
||||
void tearDown(void) {}
|
||||
void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
exit(UNITY_END());
|
||||
}
|
||||
void loop() {}
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user