mirror of
https://github.com/f4exb/sdrangel.git
synced 2026-06-27 14:03:24 -04:00
565 lines
24 KiB
C++
565 lines
24 KiB
C++
|
|
///////////////////////////////////////////////////////////////////////////////////
|
||
|
|
// Copyright (C) 2026 Tom Hensel <code@jitter.eu> //
|
||
|
|
// //
|
||
|
|
// This program is free software; you can redistribute it and/or modify //
|
||
|
|
// it under the terms of the GNU General Public License as published by //
|
||
|
|
// the Free Software Foundation as version 3 of the License, or //
|
||
|
|
// (at your option) any later version. //
|
||
|
|
// //
|
||
|
|
// modemmeshcore smoke test. Exercises the crypto + ADVERT builder round-trip. //
|
||
|
|
// No QtTest infra yet — this is a standalone executable that aborts on first //
|
||
|
|
// inconsistency. Run from build dir: ./modemmeshcore/modemmeshcore_smoke //
|
||
|
|
///////////////////////////////////////////////////////////////////////////////////
|
||
|
|
|
||
|
|
#include "meshcorepacket.h"
|
||
|
|
#include "meshcore_crypto.h"
|
||
|
|
#include "meshcore_builders.h"
|
||
|
|
#include "meshcore_command.h"
|
||
|
|
#include "meshcore_decoder.h"
|
||
|
|
|
||
|
|
#include <QByteArray>
|
||
|
|
#include <QMap>
|
||
|
|
|
||
|
|
#include <cstdio>
|
||
|
|
#include <cstdlib>
|
||
|
|
|
||
|
|
namespace
|
||
|
|
{
|
||
|
|
|
||
|
|
#define REQUIRE(cond, msg) do { \
|
||
|
|
if (!(cond)) { \
|
||
|
|
std::fprintf(stderr, "FAIL: %s (line %d): %s\n", msg, __LINE__, #cond); \
|
||
|
|
std::abort(); \
|
||
|
|
} \
|
||
|
|
std::printf(" ok: %s\n", msg); \
|
||
|
|
} while (0)
|
||
|
|
|
||
|
|
QByteArray fromHex(const char* hex) { return QByteArray::fromHex(QByteArray(hex)); }
|
||
|
|
|
||
|
|
void testCryptoRoundtrip()
|
||
|
|
{
|
||
|
|
using namespace modemmeshcore;
|
||
|
|
|
||
|
|
// Deterministic 32-byte seed (test vector).
|
||
|
|
const QByteArray seed = fromHex("0102030405060708090a0b0c0d0e0f10111213141516171819"
|
||
|
|
"1a1b1c1d1e1f20");
|
||
|
|
REQUIRE(seed.size() == kPubKeySize, "seed is 32 bytes");
|
||
|
|
|
||
|
|
const QByteArray pub32 = detail::derivePubKey(seed);
|
||
|
|
REQUIRE(pub32.size() == kPubKeySize, "pub32 derived from seed is 32 bytes");
|
||
|
|
|
||
|
|
// expandedKey(seed) is MeshCore's 64-byte form (clamp(SHA512(seed)[0..32]) || ..).
|
||
|
|
const QByteArray expanded = detail::expandedKey(seed);
|
||
|
|
REQUIRE(expanded.size() == kPrvKeySize, "expandedKey is 64 bytes");
|
||
|
|
|
||
|
|
// ECDH to self: computing shared secret with our own pub key should succeed
|
||
|
|
// and produce 32 bytes. (Round-trip validation: same secret on both sides.)
|
||
|
|
const QByteArray sharedSelf = detail::sharedSecret(seed, pub32);
|
||
|
|
REQUIRE(sharedSelf.size() == kPubKeySize, "ECDH self-shared secret is 32B");
|
||
|
|
|
||
|
|
// Encrypt-then-MAC round-trip with the self-shared secret.
|
||
|
|
const QByteArray plaintext = QByteArray("Hello MeshCore!");
|
||
|
|
const QByteArray encrypted = detail::encryptThenMac(sharedSelf, plaintext);
|
||
|
|
REQUIRE(!encrypted.isEmpty(), "encryptThenMac produced output");
|
||
|
|
REQUIRE(encrypted.size() >= kCipherMacSize + kCipherBlockSize,
|
||
|
|
"encryptThenMac output >= 18 bytes");
|
||
|
|
|
||
|
|
const QByteArray decrypted = detail::macThenDecrypt(sharedSelf, encrypted);
|
||
|
|
REQUIRE(!decrypted.isEmpty(), "macThenDecrypt validated MAC");
|
||
|
|
// decrypted is zero-padded; the prefix must equal the plaintext
|
||
|
|
REQUIRE(decrypted.startsWith(plaintext), "decrypted starts with plaintext");
|
||
|
|
|
||
|
|
// MAC tamper: flip one byte in the ciphertext, decrypt should fail
|
||
|
|
QByteArray tampered = encrypted;
|
||
|
|
tampered[5] = static_cast<char>(static_cast<uint8_t>(tampered[5]) ^ 0x01);
|
||
|
|
const QByteArray badDecrypt = detail::macThenDecrypt(sharedSelf, tampered);
|
||
|
|
REQUIRE(badDecrypt.isEmpty(), "tampered MAC rejected");
|
||
|
|
|
||
|
|
// Ed25519 sign / verify
|
||
|
|
const QByteArray msg = QByteArray("MeshCore signing test message");
|
||
|
|
const QByteArray sig = detail::signEd25519(seed, msg);
|
||
|
|
REQUIRE(sig.size() == kSignatureSize, "signature is 64 bytes");
|
||
|
|
REQUIRE(detail::verifyEd25519(sig, pub32, msg), "signature verifies under pub32");
|
||
|
|
|
||
|
|
// Tampered message must fail verification
|
||
|
|
QByteArray badMsg = msg;
|
||
|
|
badMsg[0] = static_cast<char>(static_cast<uint8_t>(badMsg[0]) ^ 0x01);
|
||
|
|
REQUIRE(!detail::verifyEd25519(sig, pub32, badMsg), "tampered msg fails verify");
|
||
|
|
}
|
||
|
|
|
||
|
|
void testAdvertBuilder()
|
||
|
|
{
|
||
|
|
using namespace modemmeshcore;
|
||
|
|
|
||
|
|
const QByteArray seed = fromHex("0102030405060708090a0b0c0d0e0f10111213141516171819"
|
||
|
|
"1a1b1c1d1e1f20");
|
||
|
|
const QByteArray pub32 = detail::derivePubKey(seed);
|
||
|
|
|
||
|
|
builders::AdvertOptions opts;
|
||
|
|
opts.nodeType = AdvertNodeChat;
|
||
|
|
opts.name = QStringLiteral("SDRangelTest");
|
||
|
|
opts.timestamp = 1700000000U;
|
||
|
|
opts.routeType = RouteFlood;
|
||
|
|
|
||
|
|
const QByteArray packet = builders::buildAdvert(seed, pub32, opts);
|
||
|
|
REQUIRE(!packet.isEmpty(), "ADVERT packet built");
|
||
|
|
|
||
|
|
// Minimum size: header(1) + path_len(1) + body(pub32+ts4+sig64+flags1+name)
|
||
|
|
const int expectedMin = 1 + 1 + kPubKeySize + 4 + kSignatureSize + 1
|
||
|
|
+ opts.name.toUtf8().size();
|
||
|
|
REQUIRE(packet.size() == expectedMin, "ADVERT exact size matches spec");
|
||
|
|
|
||
|
|
// Header byte: route=Flood(1), payload=Advert(4), version=0
|
||
|
|
const uint8_t header = static_cast<uint8_t>(packet[0]);
|
||
|
|
REQUIRE((header & kRouteMask) == RouteFlood, "header route = Flood");
|
||
|
|
REQUIRE(((header >> kPayloadTypeShift) & kPayloadTypeMask) == PayloadAdvert,
|
||
|
|
"header payload = Advert");
|
||
|
|
|
||
|
|
// path_len byte: empty path -> 0
|
||
|
|
REQUIRE(static_cast<uint8_t>(packet[1]) == 0, "path_len = 0 (no path)");
|
||
|
|
|
||
|
|
// pubkey at offset 2 must equal pub32
|
||
|
|
REQUIRE(packet.mid(2, kPubKeySize) == pub32, "pubkey at offset 2 matches");
|
||
|
|
}
|
||
|
|
|
||
|
|
void testPathLenEncoding()
|
||
|
|
{
|
||
|
|
using namespace modemmeshcore;
|
||
|
|
|
||
|
|
// 1-byte hash mode, 0 hashes -> 0x00
|
||
|
|
REQUIRE(encodePathLen(0, 1) == 0x00, "encode(0,1) == 0x00");
|
||
|
|
// 1-byte hash mode, 5 hashes -> 0x05
|
||
|
|
REQUIRE(encodePathLen(5, 1) == 0x05, "encode(5,1) == 0x05");
|
||
|
|
// 2-byte hash mode, 3 hashes -> 0x40 | 3 = 0x43
|
||
|
|
REQUIRE(encodePathLen(3, 2) == 0x43, "encode(3,2) == 0x43");
|
||
|
|
// 3-byte hash mode, 1 hash -> 0x80 | 1 = 0x81
|
||
|
|
REQUIRE(encodePathLen(1, 3) == 0x81, "encode(1,3) == 0x81");
|
||
|
|
|
||
|
|
// Round-trip
|
||
|
|
const PathLen p1 = decodePathLen(0x05);
|
||
|
|
REQUIRE(p1.hashCount == 5 && p1.hashSize == 1 && p1.totalBytes == 5,
|
||
|
|
"decode(0x05) = {5,1,5}");
|
||
|
|
const PathLen p2 = decodePathLen(0x43);
|
||
|
|
REQUIRE(p2.hashCount == 3 && p2.hashSize == 2 && p2.totalBytes == 6,
|
||
|
|
"decode(0x43) = {3,2,6}");
|
||
|
|
const PathLen p3 = decodePathLen(0x81);
|
||
|
|
REQUIRE(p3.hashCount == 1 && p3.hashSize == 3 && p3.totalBytes == 3,
|
||
|
|
"decode(0x81) = {1,3,3}");
|
||
|
|
}
|
||
|
|
|
||
|
|
} // namespace
|
||
|
|
|
||
|
|
void testEncryptedBuilders()
|
||
|
|
{
|
||
|
|
using namespace modemmeshcore;
|
||
|
|
namespace b = modemmeshcore::builders;
|
||
|
|
|
||
|
|
const QByteArray seedA = fromHex("0102030405060708090a0b0c0d0e0f10111213141516171819"
|
||
|
|
"1a1b1c1d1e1f20");
|
||
|
|
const QByteArray seedB = fromHex("a1a2a3a4a5a6a7a8a9aaabacadaeafb0"
|
||
|
|
"b1b2b3b4b5b6b7b8b9babbbcbdbebfc0");
|
||
|
|
const QByteArray pubA = detail::derivePubKey(seedA);
|
||
|
|
const QByteArray pubB = detail::derivePubKey(seedB);
|
||
|
|
REQUIRE(pubA.size() == kPubKeySize, "pubA derived");
|
||
|
|
REQUIRE(pubB.size() == kPubKeySize, "pubB derived");
|
||
|
|
|
||
|
|
// ECDH symmetry: shared(A->B) == shared(B->A)
|
||
|
|
const QByteArray sharedAB = detail::sharedSecret(seedA, pubB);
|
||
|
|
const QByteArray sharedBA = detail::sharedSecret(seedB, pubA);
|
||
|
|
REQUIRE(!sharedAB.isEmpty() && sharedAB == sharedBA, "ECDH symmetric");
|
||
|
|
|
||
|
|
// ---- TXT_MSG: A -> B ----
|
||
|
|
{
|
||
|
|
b::TxtMsgOptions opts;
|
||
|
|
opts.timestamp = 1700000001U;
|
||
|
|
opts.routeType = RouteDirect;
|
||
|
|
const QByteArray text = QByteArray("Hello from A");
|
||
|
|
const QByteArray pkt = b::buildTxtMsg(seedA, pubB, text, opts);
|
||
|
|
REQUIRE(!pkt.isEmpty(), "TXT_MSG built");
|
||
|
|
|
||
|
|
// Header byte: route=Direct, payload=Txt
|
||
|
|
REQUIRE((static_cast<uint8_t>(pkt[0]) & kRouteMask) == RouteDirect,
|
||
|
|
"TXT_MSG route=Direct");
|
||
|
|
REQUIRE(((static_cast<uint8_t>(pkt[0]) >> kPayloadTypeShift) & kPayloadTypeMask)
|
||
|
|
== PayloadTxt, "TXT_MSG payload=Txt");
|
||
|
|
REQUIRE(static_cast<uint8_t>(pkt[1]) == 0, "TXT_MSG path_len=0");
|
||
|
|
|
||
|
|
// dest_hash and src_hash at offsets 2 and 3
|
||
|
|
REQUIRE(static_cast<uint8_t>(pkt[2]) == static_cast<uint8_t>(pubB[0]),
|
||
|
|
"TXT_MSG dest_hash = pubB[0]");
|
||
|
|
REQUIRE(static_cast<uint8_t>(pkt[3]) == static_cast<uint8_t>(pubA[0]),
|
||
|
|
"TXT_MSG src_hash = pubA[0]");
|
||
|
|
|
||
|
|
// RX-side: B decrypts using shared secret.
|
||
|
|
const QByteArray macThenCt = pkt.mid(4);
|
||
|
|
const QByteArray plaintext = detail::macThenDecrypt(sharedBA, macThenCt);
|
||
|
|
REQUIRE(!plaintext.isEmpty(), "TXT_MSG decrypts under B's secret");
|
||
|
|
// plaintext layout: ts(4) || flags(1) || text || pad
|
||
|
|
REQUIRE(plaintext.size() >= 5 + text.size(), "TXT_MSG plaintext length OK");
|
||
|
|
REQUIRE(plaintext.mid(5, text.size()) == text,
|
||
|
|
"TXT_MSG plaintext text matches");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- ANON_REQ: A -> B ----
|
||
|
|
{
|
||
|
|
b::AnonReqOptions opts;
|
||
|
|
opts.timestamp = 1700000002U;
|
||
|
|
const QByteArray data = QByteArray("anon-req payload");
|
||
|
|
const QByteArray pkt = b::buildAnonReq(seedA, pubB, data, opts);
|
||
|
|
REQUIRE(!pkt.isEmpty(), "ANON_REQ built");
|
||
|
|
|
||
|
|
REQUIRE(((static_cast<uint8_t>(pkt[0]) >> kPayloadTypeShift) & kPayloadTypeMask)
|
||
|
|
== PayloadAnonReq, "ANON_REQ payload=AnonReq");
|
||
|
|
|
||
|
|
// dest_hash at 2, sender_pub at 3..3+32
|
||
|
|
REQUIRE(static_cast<uint8_t>(pkt[2]) == static_cast<uint8_t>(pubB[0]),
|
||
|
|
"ANON_REQ dest_hash = pubB[0]");
|
||
|
|
REQUIRE(pkt.mid(3, kPubKeySize) == pubA, "ANON_REQ sender_pub at offset 3");
|
||
|
|
|
||
|
|
// RX-side decrypt
|
||
|
|
const QByteArray macThenCt = pkt.mid(3 + kPubKeySize);
|
||
|
|
const QByteArray plaintext = detail::macThenDecrypt(sharedBA, macThenCt);
|
||
|
|
REQUIRE(!plaintext.isEmpty(), "ANON_REQ decrypts");
|
||
|
|
REQUIRE(plaintext.mid(4, data.size()) == data, "ANON_REQ data matches");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- GRP_TXT: public channel ----
|
||
|
|
{
|
||
|
|
const QByteArray psk = b::publicChannelPsk();
|
||
|
|
REQUIRE(psk.size() == 16, "public PSK is 16 bytes");
|
||
|
|
const b::GroupChannel pub = b::GroupChannel::fromPsk("public", psk);
|
||
|
|
REQUIRE(pub.isValid(), "public channel valid");
|
||
|
|
REQUIRE(pub.hash.size() == 1, "channel hash is 1 byte");
|
||
|
|
REQUIRE(pub.secret.size() == kPubKeySize, "channel secret zero-padded to 32");
|
||
|
|
|
||
|
|
b::GrpTxtOptions opts;
|
||
|
|
opts.timestamp = 1700000003U;
|
||
|
|
const QByteArray text = QByteArray("Test: public message");
|
||
|
|
const QByteArray pkt = b::buildGrpTxt(pub, text, opts);
|
||
|
|
REQUIRE(!pkt.isEmpty(), "GRP_TXT built");
|
||
|
|
|
||
|
|
REQUIRE(((static_cast<uint8_t>(pkt[0]) >> kPayloadTypeShift) & kPayloadTypeMask)
|
||
|
|
== PayloadGrpTxt, "GRP_TXT payload=GrpTxt");
|
||
|
|
REQUIRE(static_cast<uint8_t>(pkt[2]) == static_cast<uint8_t>(pub.hash[0]),
|
||
|
|
"GRP_TXT channel_hash matches");
|
||
|
|
|
||
|
|
// Decrypt with channel secret
|
||
|
|
const QByteArray macThenCt = pkt.mid(3);
|
||
|
|
const QByteArray plaintext = detail::macThenDecrypt(pub.secret, macThenCt);
|
||
|
|
REQUIRE(!plaintext.isEmpty(), "GRP_TXT decrypts under channel secret");
|
||
|
|
REQUIRE(plaintext.mid(5, text.size()) == text, "GRP_TXT text matches");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- ACK ----
|
||
|
|
{
|
||
|
|
const QByteArray msgHash = fromHex("deadbeef");
|
||
|
|
b::AckOptions opts;
|
||
|
|
const QByteArray pkt = b::buildAck(pubA, msgHash, opts);
|
||
|
|
REQUIRE(!pkt.isEmpty(), "ACK built");
|
||
|
|
REQUIRE(((static_cast<uint8_t>(pkt[0]) >> kPayloadTypeShift) & kPayloadTypeMask)
|
||
|
|
== PayloadAck, "ACK payload=Ack");
|
||
|
|
REQUIRE(pkt.size() == 1 + 1 + 1 + 4, "ACK size = header + path_len + dest + msg_hash");
|
||
|
|
REQUIRE(static_cast<uint8_t>(pkt[2]) == static_cast<uint8_t>(pubA[0]),
|
||
|
|
"ACK dest_hash = pubA[0]");
|
||
|
|
REQUIRE(pkt.mid(3, 4) == msgHash, "ACK msg_hash matches");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void testCommandParser()
|
||
|
|
{
|
||
|
|
using namespace modemmeshcore;
|
||
|
|
|
||
|
|
// ---- isCommand ----
|
||
|
|
REQUIRE(Packet::isCommand("MESHCORE: type=advert"), "isCommand uppercase prefix");
|
||
|
|
REQUIRE(Packet::isCommand("meshcore:type=ack"), "isCommand lowercase no space");
|
||
|
|
REQUIRE(!Packet::isCommand("Hello world"), "isCommand rejects plain text");
|
||
|
|
|
||
|
|
// ---- tokenize ----
|
||
|
|
{
|
||
|
|
QMap<QString, QString> kv;
|
||
|
|
QString err;
|
||
|
|
REQUIRE(command::tokenize("a=1; b=hello world; c=0xff", kv, err),
|
||
|
|
"tokenize ok");
|
||
|
|
REQUIRE(kv.value("a") == "1", "tokenize a=1");
|
||
|
|
REQUIRE(kv.value("b") == "hello world", "tokenize whitespace value");
|
||
|
|
REQUIRE(kv.value("c") == "0xff", "tokenize hex value");
|
||
|
|
REQUIRE(kv.size() == 3, "tokenize 3 entries");
|
||
|
|
}
|
||
|
|
{
|
||
|
|
QMap<QString, QString> kv;
|
||
|
|
QString err;
|
||
|
|
REQUIRE(!command::tokenize("a=1; bad-token", kv, err),
|
||
|
|
"tokenize rejects malformed");
|
||
|
|
REQUIRE(!err.isEmpty(), "tokenize sets error");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- ADVERT via command ----
|
||
|
|
const QByteArray seedHex = "0102030405060708090a0b0c0d0e0f10"
|
||
|
|
"111213141516171819""1a1b1c1d1e1f20";
|
||
|
|
{
|
||
|
|
QString cmd = QStringLiteral("MESHCORE: type=advert; seed=") +
|
||
|
|
QString::fromLatin1(seedHex) +
|
||
|
|
QStringLiteral("; name=NodeA; ts=1700000100");
|
||
|
|
QByteArray frame;
|
||
|
|
QString summary, err;
|
||
|
|
REQUIRE(Packet::buildFrameFromCommand(cmd, frame, summary, err),
|
||
|
|
"advert command builds");
|
||
|
|
REQUIRE(!frame.isEmpty(), "advert frame non-empty");
|
||
|
|
// Header: route=Flood, payload=Advert
|
||
|
|
REQUIRE((static_cast<uint8_t>(frame[0]) & kRouteMask) == RouteFlood,
|
||
|
|
"advert command default route=Flood");
|
||
|
|
REQUIRE(((static_cast<uint8_t>(frame[0]) >> kPayloadTypeShift) & kPayloadTypeMask)
|
||
|
|
== PayloadAdvert,
|
||
|
|
"advert command payload=Advert");
|
||
|
|
REQUIRE(summary.contains("advert"), "advert summary mentions type");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- TXT_MSG via command ----
|
||
|
|
{
|
||
|
|
// Generate a destination identity
|
||
|
|
const QByteArray destSeed = QByteArray::fromHex(
|
||
|
|
"a1a2a3a4a5a6a7a8a9aaabacadaeafb0"
|
||
|
|
"b1b2b3b4b5b6b7b8b9babbbcbdbebfc0");
|
||
|
|
const QByteArray destPub = detail::derivePubKey(destSeed);
|
||
|
|
|
||
|
|
QString cmd = QStringLiteral("MESHCORE: type=txt_msg; seed=") +
|
||
|
|
QString::fromLatin1(seedHex) +
|
||
|
|
QStringLiteral("; dest=") +
|
||
|
|
QString::fromLatin1(destPub.toHex()) +
|
||
|
|
QStringLiteral("; text=Hello via parser; ts=1700000200");
|
||
|
|
QByteArray frame;
|
||
|
|
QString summary, err;
|
||
|
|
REQUIRE(Packet::buildFrameFromCommand(cmd, frame, summary, err),
|
||
|
|
"txt_msg command builds");
|
||
|
|
REQUIRE(((static_cast<uint8_t>(frame[0]) >> kPayloadTypeShift) & kPayloadTypeMask)
|
||
|
|
== PayloadTxt, "txt_msg payload=Txt");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- GRP_TXT via channel=public ----
|
||
|
|
{
|
||
|
|
QString cmd = QStringLiteral("MESHCORE: type=grp_txt; channel=public; "
|
||
|
|
"text=Hello group; ts=1700000300");
|
||
|
|
QByteArray frame;
|
||
|
|
QString summary, err;
|
||
|
|
REQUIRE(Packet::buildFrameFromCommand(cmd, frame, summary, err),
|
||
|
|
"grp_txt public builds");
|
||
|
|
REQUIRE(summary.contains("grp_txt"), "grp_txt summary");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- ACK ----
|
||
|
|
{
|
||
|
|
const QByteArray destSeed = QByteArray::fromHex(
|
||
|
|
"a1a2a3a4a5a6a7a8a9aaabacadaeafb0"
|
||
|
|
"b1b2b3b4b5b6b7b8b9babbbcbdbebfc0");
|
||
|
|
const QByteArray destPub = detail::derivePubKey(destSeed);
|
||
|
|
QString cmd = QStringLiteral("MESHCORE: type=ack; dest=") +
|
||
|
|
QString::fromLatin1(destPub.toHex()) +
|
||
|
|
QStringLiteral("; msg_hash=deadbeef");
|
||
|
|
QByteArray frame;
|
||
|
|
QString summary, err;
|
||
|
|
REQUIRE(Packet::buildFrameFromCommand(cmd, frame, summary, err),
|
||
|
|
"ack command builds");
|
||
|
|
REQUIRE(frame.size() == 1 + 1 + 1 + 4, "ack exact size");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- error cases ----
|
||
|
|
{
|
||
|
|
QByteArray frame;
|
||
|
|
QString summary, err;
|
||
|
|
REQUIRE(!Packet::buildFrameFromCommand("MESHCORE: name=foo", frame, summary, err),
|
||
|
|
"missing type rejected");
|
||
|
|
REQUIRE(err.contains("type"), "error mentions type");
|
||
|
|
}
|
||
|
|
{
|
||
|
|
QByteArray frame;
|
||
|
|
QString summary, err;
|
||
|
|
REQUIRE(!Packet::buildFrameFromCommand("MESHCORE: type=advert; seed=00",
|
||
|
|
frame, summary, err),
|
||
|
|
"short seed rejected");
|
||
|
|
REQUIRE(err.contains("seed"), "error mentions seed");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- deriveTxRadioSettings ----
|
||
|
|
{
|
||
|
|
TxRadioSettings settings;
|
||
|
|
QString err;
|
||
|
|
REQUIRE(Packet::deriveTxRadioSettings(
|
||
|
|
"MESHCORE: type=advert; sf=8; bw=62500; cr=8; sync=0x12; freq=869.618M",
|
||
|
|
settings, err),
|
||
|
|
"deriveTxRadioSettings parses");
|
||
|
|
REQUIRE(settings.spreadFactor == 8, "sf=8 applied");
|
||
|
|
REQUIRE(settings.bandwidthHz == 62500, "bw=62500 applied");
|
||
|
|
REQUIRE(settings.parityBits == 4, "cr=8 -> parity=4");
|
||
|
|
REQUIRE(settings.syncWord == 0x12, "sync=0x12 applied");
|
||
|
|
REQUIRE(settings.centerFrequencyHz == 869618000, "freq=869.618M applied");
|
||
|
|
REQUIRE(settings.hasCommand, "hasCommand set");
|
||
|
|
REQUIRE(settings.hasLoRaParams, "hasLoRaParams set");
|
||
|
|
REQUIRE(settings.hasCenterFrequency, "hasCenterFrequency set");
|
||
|
|
}
|
||
|
|
{
|
||
|
|
TxRadioSettings settings;
|
||
|
|
QString err;
|
||
|
|
REQUIRE(Packet::deriveTxRadioSettings("Hello world", settings, err),
|
||
|
|
"non-command -> defaults OK");
|
||
|
|
REQUIRE(settings.bandwidthHz == kDefaultBandwidthHz, "default BW");
|
||
|
|
REQUIRE(settings.spreadFactor == kDefaultSpreadFactor, "default SF");
|
||
|
|
REQUIRE(settings.parityBits == kDefaultParityBits, "default parity");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
QString findField(const modemmeshcore::DecodeResult& r, const QString& path)
|
||
|
|
{
|
||
|
|
for (const auto& f : r.fields) {
|
||
|
|
if (f.path == path) return f.value;
|
||
|
|
}
|
||
|
|
return QString();
|
||
|
|
}
|
||
|
|
|
||
|
|
void testRoundtripDecode()
|
||
|
|
{
|
||
|
|
using namespace modemmeshcore;
|
||
|
|
namespace b = modemmeshcore::builders;
|
||
|
|
|
||
|
|
const QByteArray seedA = fromHex("0102030405060708090a0b0c0d0e0f10"
|
||
|
|
"111213141516171819""1a1b1c1d1e1f20");
|
||
|
|
const QByteArray seedB = fromHex("a1a2a3a4a5a6a7a8a9aaabacadaeafb0"
|
||
|
|
"b1b2b3b4b5b6b7b8b9babbbcbdbebfc0");
|
||
|
|
const QByteArray pubA = detail::derivePubKey(seedA);
|
||
|
|
const QByteArray pubB = detail::derivePubKey(seedB);
|
||
|
|
|
||
|
|
// ---- ADVERT round-trip ----
|
||
|
|
{
|
||
|
|
b::AdvertOptions o;
|
||
|
|
o.timestamp = 1700001000U;
|
||
|
|
o.name = "NodeA";
|
||
|
|
o.routeType = RouteFlood;
|
||
|
|
const QByteArray pkt = b::buildAdvert(seedA, pubA, o);
|
||
|
|
REQUIRE(!pkt.isEmpty(), "advert built");
|
||
|
|
|
||
|
|
DecodeResult r;
|
||
|
|
REQUIRE(Packet::decodeFrame(pkt, r), "advert decoded");
|
||
|
|
REQUIRE(r.isFrame, "advert isFrame");
|
||
|
|
REQUIRE(r.payloadType == PayloadAdvert, "advert payloadType=Advert");
|
||
|
|
REQUIRE(r.dataDecoded, "advert dataDecoded");
|
||
|
|
REQUIRE(findField(r, "advert.pubkey") == QString::fromLatin1(pubA.toHex()),
|
||
|
|
"advert.pubkey roundtrip");
|
||
|
|
REQUIRE(findField(r, "advert.timestamp") == "1700001000",
|
||
|
|
"advert.timestamp roundtrip");
|
||
|
|
REQUIRE(findField(r, "advert.name") == "NodeA", "advert.name roundtrip");
|
||
|
|
REQUIRE(findField(r, "advert.signature_valid") == "true",
|
||
|
|
"advert signature verifies");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- TXT_MSG round-trip A -> B (B's identity + A as contact) ----
|
||
|
|
{
|
||
|
|
b::TxtMsgOptions o;
|
||
|
|
o.timestamp = 1700002000U;
|
||
|
|
o.routeType = RouteDirect;
|
||
|
|
const QByteArray text = QByteArray("Hello B from A");
|
||
|
|
const QByteArray pkt = b::buildTxtMsg(seedA, pubB, text, o);
|
||
|
|
REQUIRE(!pkt.isEmpty(), "txt_msg built A->B");
|
||
|
|
|
||
|
|
const QString keys = QString("identity=") + QString::fromLatin1(seedB.toHex())
|
||
|
|
+ "; contact:alice=" + QString::fromLatin1(pubA.toHex());
|
||
|
|
DecodeResult r;
|
||
|
|
REQUIRE(Packet::decodeFrame(pkt, r, keys), "txt_msg decoded");
|
||
|
|
REQUIRE(r.payloadType == PayloadTxt, "txt_msg payloadType=Txt");
|
||
|
|
REQUIRE(r.decrypted, "txt_msg decrypted");
|
||
|
|
REQUIRE(findField(r, "txt.text") == text, "txt.text roundtrip");
|
||
|
|
REQUIRE(findField(r, "txt.sender_name") == "alice", "txt.sender_name");
|
||
|
|
REQUIRE(r.keyLabel == "contact:alice", "txt keyLabel");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Wrong-key path: B has a key list with no matching contact
|
||
|
|
{
|
||
|
|
const QByteArray text = QByteArray("Hello");
|
||
|
|
b::TxtMsgOptions o;
|
||
|
|
o.timestamp = 1700002001U;
|
||
|
|
const QByteArray pkt = b::buildTxtMsg(seedA, pubB, text, o);
|
||
|
|
|
||
|
|
const QString keys = QString("identity=") + QString::fromLatin1(seedB.toHex());
|
||
|
|
DecodeResult r;
|
||
|
|
REQUIRE(Packet::decodeFrame(pkt, r, keys), "txt_msg envelope decoded");
|
||
|
|
REQUIRE(!r.decrypted, "txt_msg not decrypted (no matching contact)");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- GRP_TXT round-trip ----
|
||
|
|
{
|
||
|
|
const QByteArray psk = b::publicChannelPsk();
|
||
|
|
const b::GroupChannel pub = b::GroupChannel::fromPsk("public", psk);
|
||
|
|
b::GrpTxtOptions o;
|
||
|
|
o.timestamp = 1700003000U;
|
||
|
|
const QByteArray text = QByteArray("Public group test");
|
||
|
|
const QByteArray pkt = b::buildGrpTxt(pub, text, o);
|
||
|
|
REQUIRE(!pkt.isEmpty(), "grp_txt built");
|
||
|
|
|
||
|
|
const QString keys = "channel:public=public";
|
||
|
|
DecodeResult r;
|
||
|
|
REQUIRE(Packet::decodeFrame(pkt, r, keys), "grp_txt decoded");
|
||
|
|
REQUIRE(r.payloadType == PayloadGrpTxt, "grp_txt payloadType=GrpTxt");
|
||
|
|
REQUIRE(r.decrypted, "grp_txt decrypted");
|
||
|
|
REQUIRE(findField(r, "grp.text") == text, "grp.text roundtrip");
|
||
|
|
REQUIRE(findField(r, "grp.channel") == "public", "grp.channel matches");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- ANON_REQ round-trip ----
|
||
|
|
{
|
||
|
|
const QByteArray data = QByteArray("anon-payload");
|
||
|
|
b::AnonReqOptions o;
|
||
|
|
o.timestamp = 1700004000U;
|
||
|
|
const QByteArray pkt = b::buildAnonReq(seedA, pubB, data, o);
|
||
|
|
const QString keys = QString("identity=") + QString::fromLatin1(seedB.toHex());
|
||
|
|
DecodeResult r;
|
||
|
|
REQUIRE(Packet::decodeFrame(pkt, r, keys), "anon_req decoded");
|
||
|
|
REQUIRE(r.payloadType == PayloadAnonReq, "anon_req payloadType=AnonReq");
|
||
|
|
REQUIRE(r.decrypted, "anon_req decrypted");
|
||
|
|
REQUIRE(findField(r, "anon.text") == QString::fromUtf8(data), "anon.text roundtrip");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- ACK round-trip ----
|
||
|
|
{
|
||
|
|
const QByteArray msgHash = fromHex("cafebabe");
|
||
|
|
b::AckOptions o;
|
||
|
|
const QByteArray pkt = b::buildAck(pubA, msgHash, o);
|
||
|
|
DecodeResult r;
|
||
|
|
REQUIRE(Packet::decodeFrame(pkt, r), "ack decoded");
|
||
|
|
REQUIRE(r.payloadType == PayloadAck, "ack payloadType=Ack");
|
||
|
|
REQUIRE(findField(r, "ack.msg_hash") == "cafebabe", "ack.msg_hash roundtrip");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- validateKeySpecList ----
|
||
|
|
{
|
||
|
|
QString err; int n = -1;
|
||
|
|
const QString good = QString("identity=") + QString::fromLatin1(seedA.toHex())
|
||
|
|
+ "; channel:public=public";
|
||
|
|
REQUIRE(Packet::validateKeySpecList(good, err, &n), "valid key list");
|
||
|
|
REQUIRE(n == 2, "valid key list count = 2");
|
||
|
|
|
||
|
|
REQUIRE(!Packet::validateKeySpecList("identity=ff", err, &n),
|
||
|
|
"short identity rejected");
|
||
|
|
REQUIRE(!Packet::validateKeySpecList("", err, &n), "empty rejected");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
int main(int argc, char** argv)
|
||
|
|
{
|
||
|
|
(void)argc;
|
||
|
|
(void)argv;
|
||
|
|
|
||
|
|
std::printf("modemmeshcore smoke test\n");
|
||
|
|
std::printf("[1] testPathLenEncoding\n");
|
||
|
|
testPathLenEncoding();
|
||
|
|
std::printf("[2] testCryptoRoundtrip\n");
|
||
|
|
testCryptoRoundtrip();
|
||
|
|
std::printf("[3] testAdvertBuilder\n");
|
||
|
|
testAdvertBuilder();
|
||
|
|
std::printf("[4] testEncryptedBuilders\n");
|
||
|
|
testEncryptedBuilders();
|
||
|
|
std::printf("[5] testCommandParser\n");
|
||
|
|
testCommandParser();
|
||
|
|
std::printf("[6] testRoundtripDecode\n");
|
||
|
|
testRoundtripDecode();
|
||
|
|
std::printf("\nALL OK\n");
|
||
|
|
return 0;
|
||
|
|
}
|