492 lines
27 KiB
Python
492 lines
27 KiB
Python
"""Execute the bounded MT codec with real Crypto AES and an independent oracle.
|
|
|
|
No PlatformIO, hardware or downloaded sources are used. Set MESHCORE_CRYPTO_DIR
|
|
to an existing rweather/Crypto checkout, or use the cached PlatformIO dependency.
|
|
The Python cryptography package independently generates encrypted wire vectors.
|
|
"""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_KEY = bytes.fromhex("d4f1bb3a20290759f0bcffabcf4e6901")
|
|
FROM, PACKET_ID = 0x11223344, 0x89ABCDEF
|
|
|
|
|
|
def varint(value):
|
|
encoded = bytearray()
|
|
while value >= 128:
|
|
encoded.append((value & 127) | 128)
|
|
value >>= 7
|
|
encoded.append(value)
|
|
return bytes(encoded)
|
|
|
|
|
|
def data_text(text):
|
|
return b"\x08\x01\x12" + varint(len(text)) + text
|
|
|
|
|
|
def data_nodeinfo(long_name, short_name, sender):
|
|
# Independent handwritten schema oracle: User fields 1/2/3/7/9 only.
|
|
user_id = ("!%08x" % sender).encode("ascii")
|
|
user = b"\x0a" + varint(len(user_id)) + user_id
|
|
if long_name:
|
|
user += b"\x12" + varint(len(long_name)) + long_name
|
|
user += b"\x1a" + varint(len(short_name)) + short_name
|
|
user += b"\x38\x01\x48\x01" # CLIENT_MUTE=1, is_unmessagable=true.
|
|
return b"\x08\x04\x12" + varint(len(user)) + user
|
|
|
|
|
|
def reference_frame(data, name="LongFast", key=DEFAULT_KEY, *, to=0xFFFFFFFF,
|
|
sender=FROM, packet_id=PACKET_ID, flags=0x63):
|
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
channel_hash = 0
|
|
for byte in name.encode() + key:
|
|
channel_hash ^= byte
|
|
header = struct.pack("<III4B", to, sender, packet_id, flags, channel_hash, 0, 0)
|
|
encryptor = Cipher(algorithms.AES(key), modes.CTR(struct.pack("<QQ", packet_id, sender))).encryptor()
|
|
return header + encryptor.update(data) + encryptor.finalize()
|
|
|
|
|
|
def array(value):
|
|
return "{" + ",".join(map(str, value)) + "}"
|
|
|
|
|
|
def literal(value):
|
|
return '"' + "".join("\\x%02x" % byte for byte in value) + '"'
|
|
|
|
|
|
HARNESS = r'''
|
|
#include <helpers/MeshtextChat.h>
|
|
#include <cassert>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <initializer_list>
|
|
using namespace mesh::mt;
|
|
struct Golden {
|
|
uint8_t key[32]; size_t key_len; const char* name; const char* text; size_t text_len;
|
|
uint8_t frame[255]; size_t frame_len;
|
|
};
|
|
static const Golden golden[] = { @GOLDEN@ };
|
|
// Stock 2.7.26 Router::perhapsEncode marks Data.bitfield present even when
|
|
// config_ok_to_mqtt and want_response are both false: field 9 is 0x48,0x00.
|
|
static const Golden stock_zero_bitfield[] = { @STOCK_ZERO_BITFIELD@ };
|
|
struct NodeInfoGolden {
|
|
uint8_t key[32]; size_t key_len; const char* name;
|
|
const char* long_name; size_t long_name_len; const char* short_name; size_t short_name_len;
|
|
uint32_t from, id; uint8_t hops; uint8_t frame[255]; size_t frame_len;
|
|
};
|
|
static const NodeInfoGolden node_info_golden[] = { @NODEINFO_GOLDEN@ };
|
|
struct Malformed { const char* label; uint8_t frame[255]; size_t length; };
|
|
static const Malformed malformed[] = { @MALFORMED@ };
|
|
struct Modem {
|
|
Region region; Preset preset; bool valid; float frequency, bandwidth; uint8_t sf, cr;
|
|
};
|
|
static const Modem modems[] = { @MODEMS@ };
|
|
static void presets() {
|
|
Preset preset = Preset::LongFast;
|
|
for (const auto* name : {"ShortTurbo", "ShortFast", "ShortSlow", "MediumTurbo", "MediumFast",
|
|
"MediumSlow", "LongTurbo", "LongFast", "LongMod", "LongSlow"}) {
|
|
assert(parsePreset(name, preset)); assert(!strcmp(presetName(preset), name));
|
|
}
|
|
assert(parsePreset("LongModerate", preset) && preset == Preset::LongModerate);
|
|
assert(parsePreset("longfast", preset) && preset == Preset::LongFast);
|
|
assert(parsePreset("shortturbo", preset) && preset == Preset::ShortTurbo);
|
|
assert(parsePreset("LoNgTuRbO", preset) && preset == Preset::LongTurbo);
|
|
assert(parsePreset("LONGMODERATE", preset) && preset == Preset::LongModerate);
|
|
for (const auto* invalid : {"", "Long-Fast", "LongUltra", "mt-LongFast", "LongFast "}) {
|
|
preset = Preset::LongFast;
|
|
assert(!parsePreset(invalid, preset) && preset == Preset::LongFast);
|
|
}
|
|
assert(!parsePreset(nullptr, preset));
|
|
assert(presetName(static_cast<Preset>(255)) == nullptr);
|
|
for (const auto& test : modems) {
|
|
mesh::RadioProfileParams params;
|
|
params.freq = 123; const auto original = params;
|
|
assert(modemConfig(test.region, test.preset, params) == test.valid);
|
|
if (!test.valid) { assert(params == original); continue; }
|
|
assert(std::fabs(params.freq - test.frequency) < 0.0001f);
|
|
assert(params.bw == test.bandwidth && params.sf == test.sf && params.cr == test.cr);
|
|
assert(params.preamble == 16 && params.protocol == mesh::RadioProfileProtocol::Meshtext);
|
|
assert(modemConfig(test.region, test.preset, params, 1));
|
|
const float start = test.region == Region::US ? 902 : test.region == Region::EU868 ? 869.4f
|
|
: test.region == Region::EU433 ? 433 : 915;
|
|
const uint32_t width = test.region == Region::US ? 26000 : test.region == Region::EU868 ? 250
|
|
: test.region == Region::EU433 ? 1000 : 13000;
|
|
const uint32_t slots = width / static_cast<uint32_t>(test.bandwidth);
|
|
assert(std::fabs(params.freq - (start + test.bandwidth / 2000)) < 0.0001f);
|
|
assert(modemConfig(test.region, test.preset, params, slots));
|
|
const auto last = params;
|
|
assert(!modemConfig(test.region, test.preset, params, slots + 1) && params == last);
|
|
assert(!modemConfig(test.region, test.preset, params, 0xffffffffU) && params == last);
|
|
}
|
|
mesh::RadioProfileParams params;
|
|
assert(!modemConfig(Region::Unset, Preset::LongFast, params));
|
|
assert(!modemConfig(static_cast<Region>(255), Preset::LongFast, params));
|
|
assert(!modemConfig(Region::US, static_cast<Preset>(255), params));
|
|
puts("PASS presets");
|
|
}
|
|
static void frames() {
|
|
for (const auto& test : golden) {
|
|
uint8_t frame[MaxFrameSize + 2]; memset(frame, 0xa5, sizeof(frame));
|
|
const size_t length = encodeText(0x11223344, 0x89abcdef, 3, test.name, test.key, test.key_len,
|
|
test.text, test.text_len, frame);
|
|
assert(length == test.frame_len && !memcmp(frame, test.frame, length));
|
|
assert(frame[MaxFrameSize] == 0xa5 && frame[MaxFrameSize + 1] == 0xa5);
|
|
DecodedText decoded;
|
|
assert(decodeText(test.frame, test.frame_len, test.name, test.key, test.key_len, 0x99887766, decoded));
|
|
assert(decoded.from == 0x11223344 && decoded.id == 0x89abcdef);
|
|
assert(decoded.hop_limit == 3 && decoded.hop_start == 3 && decoded.hops == 0);
|
|
assert(decoded.text_len == test.text_len && !memcmp(decoded.text, test.text, test.text_len));
|
|
assert(decoded.text[decoded.text_len] == 0);
|
|
memcpy(frame, test.frame, test.frame_len); frame[12] = (7 << 5) | 2;
|
|
assert(decodeText(frame, length, test.name, test.key, test.key_len, 0x99887766, decoded));
|
|
assert(decoded.hops == 5 && decoded.hop_start == 7 && decoded.hop_limit == 2);
|
|
frame[12] = 3; // older hop-start-less packets remain readable
|
|
assert(decodeText(frame, length, test.name, test.key, test.key_len, 0x99887766, decoded));
|
|
assert(decoded.hops == 0);
|
|
assert(!decodeText(frame, length, test.name, test.key, test.key_len, 0x11223344, decoded));
|
|
frame[13] ^= 1;
|
|
assert(!decodeText(frame, length, test.name, test.key, test.key_len, 0x99887766, decoded));
|
|
}
|
|
assert(!memcmp(DefaultPsk, golden[0].key, 16));
|
|
puts("PASS frames");
|
|
}
|
|
static void malformed_frames() {
|
|
for (const auto& test : malformed) {
|
|
DecodedText decoded;
|
|
decoded.id = 123; strcpy(decoded.text, "unchanged"); decoded.text_len = 9;
|
|
if (decodeText(test.frame, test.length, "LongFast", DefaultPsk, 16, 0x99887766, decoded)) {
|
|
fprintf(stderr, "accepted malformed vector: %s\n", test.label); assert(false);
|
|
}
|
|
assert(decoded.id == 123 && decoded.text_len == 9 && !strcmp(decoded.text, "unchanged"));
|
|
}
|
|
puts("PASS malformed");
|
|
}
|
|
static void node_info_frames() {
|
|
static_assert(MaxLongNameSize == 24, "stock long-name limit");
|
|
static_assert(MaxShortNameSize == 4, "stock short-name max_size:5 includes NUL");
|
|
for (const auto& test : node_info_golden) {
|
|
uint8_t frame[MaxFrameSize + 2]; memset(frame, 0xa5, sizeof(frame));
|
|
const size_t length = encodeNodeInfo(test.from, test.id, test.hops, test.name, test.key, test.key_len,
|
|
test.long_name, test.long_name_len,
|
|
test.short_name, test.short_name_len, frame);
|
|
// Full independent encrypted-frame equality also proves exact standard User
|
|
// fields, matching nonce/header, and absence of Data.want_response/keys/MAC.
|
|
assert(length == test.frame_len && !memcmp(frame, test.frame, length));
|
|
assert(frame[length] == 0xa5 && frame[MaxFrameSize] == 0xa5 && frame[MaxFrameSize + 1] == 0xa5);
|
|
assert((frame[12] & 0x18) == 0); // Neither want_ack nor via_mqtt.
|
|
assert((frame[12] & 7) == test.hops && (frame[12] >> 5) == test.hops);
|
|
assert(frame[14] == 0 && frame[15] == 0);
|
|
DecodedText decoded; decoded.id = 123; strcpy(decoded.text, "unchanged");
|
|
assert(!decodeText(frame, length, test.name, test.key, test.key_len, 0x99887766, decoded));
|
|
assert(decoded.id == 123 && !strcmp(decoded.text, "unchanged")); // NodeInfo RX still ignored.
|
|
}
|
|
puts("PASS nodeinfo");
|
|
}
|
|
static void node_info_bounds() {
|
|
uint8_t frame[MaxFrameSize]; memset(frame, 0xa5, sizeof(frame));
|
|
const auto invalid = [&](uint32_t from, uint32_t id, uint8_t hops, const char* name,
|
|
const uint8_t* key, size_t key_len, const char* long_name, size_t long_len,
|
|
const char* short_name, size_t short_len) {
|
|
assert(!encodeNodeInfo(from, id, hops, name, key, key_len, long_name, long_len, short_name, short_len, frame));
|
|
for (uint8_t byte : frame) assert(byte == 0xa5); // Rejection writes no partial packet.
|
|
};
|
|
char long_name[MaxLongNameSize + 1]; memset(long_name, 'L', sizeof(long_name));
|
|
char short_name[MaxShortNameSize + 1]; memset(short_name, 'S', sizeof(short_name));
|
|
for (uint32_t from : {0U, 1U, 2U, 3U, 0xffffffffU})
|
|
invalid(from, 1, 7, "LongFast", DefaultPsk, 16, "long", 4, "shrt", 4);
|
|
invalid(4, 0, 7, "LongFast", DefaultPsk, 16, "long", 4, "shrt", 4);
|
|
invalid(4, 1, 8, "LongFast", DefaultPsk, 16, "long", 4, "shrt", 4);
|
|
for (const auto* name : {static_cast<const char*>(nullptr), "", "123456789012"})
|
|
invalid(4, 1, 7, name, DefaultPsk, 16, "long", 4, "shrt", 4);
|
|
for (size_t key_len : {size_t(0), size_t(1), size_t(15), size_t(17), size_t(31), size_t(33)})
|
|
invalid(4, 1, 7, "LongFast", DefaultPsk, key_len, "long", 4, "shrt", 4);
|
|
invalid(4, 1, 7, "LongFast", nullptr, 16, "long", 4, "shrt", 4);
|
|
invalid(4, 1, 7, "LongFast", DefaultPsk, 16, nullptr, 0, "shrt", 4);
|
|
invalid(4, 1, 7, "LongFast", DefaultPsk, 16, "long", 4, nullptr, 4);
|
|
invalid(4, 1, 7, "LongFast", DefaultPsk, 16, "long", 4, "", 0);
|
|
for (size_t size : {MaxLongNameSize + 1, size_t(-1)})
|
|
invalid(4, 1, 7, "LongFast", DefaultPsk, 16, long_name, size, "shrt", 4);
|
|
for (size_t size : {MaxShortNameSize + 1, size_t(-1)})
|
|
invalid(4, 1, 7, "LongFast", DefaultPsk, 16, "long", 4, short_name, size);
|
|
const char* bad_names[] = {"\0", "\xc0\xaf", "\xe0\x80\x80", "\xed\xa0\x80",
|
|
"\xf0\x80\x80\x80", "\xf4\x90\x80\x80", "\x80", "\xc2", "\xe2\x82"};
|
|
const size_t bad_lengths[] = {1, 2, 3, 3, 4, 4, 1, 1, 2};
|
|
for (size_t i = 0; i < sizeof(bad_lengths) / sizeof(bad_lengths[0]); ++i) {
|
|
invalid(4, 1, 7, "LongFast", DefaultPsk, 16, bad_names[i], bad_lengths[i], "shrt", 4);
|
|
invalid(4, 1, 7, "LongFast", DefaultPsk, 16, "long", 4, bad_names[i], bad_lengths[i]);
|
|
}
|
|
const char embedded_nul[] = {'a', 0, 'b'};
|
|
invalid(4, 1, 7, "LongFast", DefaultPsk, 16, embedded_nul, 3, "shrt", 4);
|
|
invalid(4, 1, 7, "LongFast", DefaultPsk, 16, "long", 4, embedded_nul, 3);
|
|
assert(!encodeNodeInfo(4, 1, 7, "LongFast", DefaultPsk, 16, "long", 4, "shrt", 4, nullptr));
|
|
// Exact-length byte arrays need no trailing NUL; the codec never scans them.
|
|
assert(encodeNodeInfo(4, 1, 7, "LongFast", DefaultPsk, 16,
|
|
long_name, MaxLongNameSize, short_name, MaxShortNameSize, frame));
|
|
assert(encodeNodeInfo(4, 1, 0, "LongFast", DefaultPsk, 16, "", 0, "a", 1, frame));
|
|
// A single non-ASCII scalar can occupy the complete four-byte stock field.
|
|
assert(encodeNodeInfo(4, 1, 3, "LongFast", DefaultPsk, 16, "\xc3\xa9", 2, "\xf0\x9f\x9a\x80", 4, frame));
|
|
puts("PASS nodeinfo_bounds");
|
|
}
|
|
static void argument_bounds() {
|
|
uint8_t frame[MaxFrameSize] = {};
|
|
char text[MaxTextSize + 2]; memset(text, 'x', sizeof(text));
|
|
assert(encodeText(1, 1, 7, "LongFast", DefaultPsk, 16, text, MaxTextSize, frame));
|
|
for (size_t length : {size_t(0), MaxTextSize + 1, size_t(0xffffffffU)})
|
|
assert(!encodeText(1, 1, 3, "LongFast", DefaultPsk, 16, text, length, frame));
|
|
assert(!encodeText(0, 1, 3, "LongFast", DefaultPsk, 16, text, 1, frame));
|
|
assert(!encodeText(0xffffffffU, 1, 3, "LongFast", DefaultPsk, 16, text, 1, frame));
|
|
assert(!encodeText(1, 0, 3, "LongFast", DefaultPsk, 16, text, 1, frame));
|
|
assert(!encodeText(1, 1, 8, "LongFast", DefaultPsk, 16, text, 1, frame));
|
|
assert(encodeText(1, 1, 3, "12345678901", DefaultPsk, 16, text, 1, frame));
|
|
for (const auto* name : {static_cast<const char*>(nullptr), "", "123456789012", "01234567890123456789012345678901xx"})
|
|
assert(!encodeText(1, 1, 3, name, DefaultPsk, 16, text, 1, frame));
|
|
for (size_t key_length : {size_t(0), size_t(1), size_t(15), size_t(17), size_t(31), size_t(33)})
|
|
assert(!encodeText(1, 1, 3, "LongFast", DefaultPsk, key_length, text, 1, frame));
|
|
assert(!encodeText(1, 1, 3, "LongFast", nullptr, 16, text, 1, frame));
|
|
assert(!encodeText(1, 1, 3, "LongFast", DefaultPsk, 16, nullptr, 1, frame));
|
|
assert(!encodeText(1, 1, 3, "LongFast", DefaultPsk, 16, text, 1, nullptr));
|
|
const char* invalid_utf8[] = {"\0", "\xc0\xaf", "\xe0\x80\x80", "\xed\xa0\x80",
|
|
"\xf0\x80\x80\x80", "\xf4\x90\x80\x80", "\x80", "\xc2", "\xe2\x82"};
|
|
const size_t lengths[] = {1, 2, 3, 3, 4, 4, 1, 1, 2};
|
|
for (size_t i = 0; i < sizeof(lengths) / sizeof(lengths[0]); ++i)
|
|
assert(!encodeText(1, 1, 3, "LongFast", DefaultPsk, 16, invalid_utf8[i], lengths[i], frame));
|
|
const char nul_text[] = {'a', 0, 'b'};
|
|
assert(!encodeText(1, 1, 3, "LongFast", DefaultPsk, 16, nul_text, 3, frame));
|
|
DecodedText decoded;
|
|
assert(!decodeText(nullptr, 50, "LongFast", DefaultPsk, 16, 0, decoded));
|
|
assert(!decodeText(frame, 0, "LongFast", DefaultPsk, 16, 0, decoded));
|
|
assert(!decodeText(frame, 256, "LongFast", DefaultPsk, 16, 0, decoded));
|
|
assert(!decodeText(frame, 50, "LongFast", nullptr, 16, 0, decoded));
|
|
assert(!decodeText(frame, 50, nullptr, DefaultPsk, 16, 0, decoded));
|
|
puts("PASS bounds");
|
|
}
|
|
static void extensions() {
|
|
const uint8_t frame[] = @EXTENSION_FRAME@;
|
|
DecodedText decoded;
|
|
assert(decodeText(frame, sizeof(frame), "LongFast", DefaultPsk, 16, 0x99887766, decoded));
|
|
assert(decoded.text_len == 1 && !strcmp(decoded.text, "x"));
|
|
// Header ACK/request flags do not make this passive decoder transmit.
|
|
const uint8_t requests[] = @REQUEST_FRAME@;
|
|
assert(decodeText(requests, sizeof(requests), "LongFast", DefaultPsk, 16, 0x99887766, decoded));
|
|
puts("PASS extensions");
|
|
}
|
|
static void stock_zero_bitfield_frames() {
|
|
for (const auto& test : stock_zero_bitfield) {
|
|
DecodedText decoded;
|
|
assert(decodeText(test.frame, test.frame_len, test.name, test.key, test.key_len,
|
|
0x99887766, decoded));
|
|
assert(decoded.from == 0x11223344 && decoded.id == 0x89abcdef);
|
|
assert(decoded.hop_limit == 7 && decoded.hop_start == 7 && decoded.hops == 0);
|
|
assert((test.frame[12] & 0x18) == 0); // Stock SDK requested neither ACK nor MQTT.
|
|
assert(decoded.text_len == test.text_len && !memcmp(decoded.text, test.text, test.text_len));
|
|
assert(decoded.text[decoded.text_len] == 0);
|
|
}
|
|
puts("PASS stock_zero_bitfield");
|
|
}
|
|
static void dedup() {
|
|
SeenPackets seen;
|
|
assert(!seen.seen(1, 1));
|
|
assert(seen.remember(1, 1)); assert(seen.seen(1, 1)); assert(!seen.remember(1, 1));
|
|
assert(seen.remember(2, 1)); // same ID from another sender is distinct
|
|
assert(!seen.remember(0, 1) && !seen.remember(1, 0) && !seen.remember(0xffffffffU, 1));
|
|
seen.clear(); assert(!seen.seen(1, 1));
|
|
for (uint32_t i = 1; i <= SeenPackets::Capacity; ++i) assert(seen.remember(1, i));
|
|
assert(seen.seen(1, 1)); assert(seen.remember(1, 33)); assert(!seen.seen(1, 1));
|
|
// Invalid data never poisons dedup; the subsequent valid frame is accepted.
|
|
seen.clear(); DecodedText decoded;
|
|
for (const auto& test : malformed)
|
|
if (decodeText(test.frame, test.length, "LongFast", DefaultPsk, 16, 0x99887766, decoded))
|
|
seen.remember(decoded.from, decoded.id);
|
|
assert(!seen.seen(0x11223344, 0x89abcdef));
|
|
assert(decodeText(golden[0].frame, golden[0].frame_len, "LongFast", DefaultPsk, 16, 0x99887766, decoded));
|
|
assert(seen.remember(decoded.from, decoded.id));
|
|
assert(!seen.remember(decoded.from, decoded.id));
|
|
// Successful own TX uses the same pair cache to suppress over-air echoes.
|
|
seen.clear(); assert(seen.remember(0x11223344, 0x89abcdef));
|
|
assert(seen.seen(0x11223344, 0x89abcdef));
|
|
puts("PASS dedup");
|
|
}
|
|
int main(int argc, char** argv) {
|
|
assert(argc == 2);
|
|
if (!strcmp(argv[1], "presets")) presets();
|
|
else if (!strcmp(argv[1], "frames")) frames();
|
|
else if (!strcmp(argv[1], "malformed")) malformed_frames();
|
|
else if (!strcmp(argv[1], "nodeinfo")) node_info_frames();
|
|
else if (!strcmp(argv[1], "nodeinfo_bounds")) node_info_bounds();
|
|
else if (!strcmp(argv[1], "bounds")) argument_bounds();
|
|
else if (!strcmp(argv[1], "extensions")) extensions();
|
|
else if (!strcmp(argv[1], "stock_zero_bitfield")) stock_zero_bitfield_frames();
|
|
else if (!strcmp(argv[1], "dedup")) dedup();
|
|
else assert(false);
|
|
}
|
|
'''
|
|
|
|
|
|
def vectors():
|
|
inputs = [(DEFAULT_KEY, "LongFast", b"hello"),
|
|
(bytes(range(32)), "LongTurbo", "caf\u00e9 \U0001f6f0\ufe0f".encode()),
|
|
(bytes(range(16)), "LongMod", bytes(range(33, 127))),
|
|
(DEFAULT_KEY, "LongFast", b"a" * 127),
|
|
(DEFAULT_KEY, "LongFast", b"b" * 128),
|
|
(bytes(range(32)), "LongTurbo", b"c" * 228),
|
|
(DEFAULT_KEY, "LongFast", "\u0080\u07ff\u0800\ud7ff\ue000\uffff\U00010000\U0010ffff".encode())]
|
|
golden = []
|
|
for key, name, text in inputs:
|
|
frame = reference_frame(data_text(text), name, key)
|
|
golden.append("{" + array(key) + "," + str(len(key)) + ',"' + name + '",'
|
|
+ literal(text) + "," + str(len(text)) + "," + array(frame) + "," + str(len(frame)) + "}")
|
|
stock_zero_bitfield = []
|
|
for key, name in [(bytes(range(16)), "ShortTurbo"), (bytes(range(32)), "LongFast")]:
|
|
text = "stock field9 zero caf\u00e9".encode()
|
|
# Independently encrypted exact stock Data shape, not our encodeText:
|
|
# TEXT_MESSAGE_APP + UTF-8 payload + OPTIONAL uint32 bitfield=0.
|
|
data = data_text(text) + b"\x48\x00"
|
|
frame = reference_frame(data, name, key, flags=0xE7)
|
|
stock_zero_bitfield.append("{" + array(key) + "," + str(len(key)) + ',"' + name + '",'
|
|
+ literal(text) + "," + str(len(text)) + ","
|
|
+ array(frame) + "," + str(len(frame)) + "}")
|
|
node_inputs = [(DEFAULT_KEY, "LongFast", b"MeshCore", b"CORE", FROM, 7),
|
|
(bytes(range(32)), "LongTurbo", ("\u00e9" * 12).encode(), "\U0001f680".encode(), 0xABCDEF12, 0),
|
|
(bytes(range(16)), "LongMod", b"L" * 24, "\u00e9\u00e9".encode(), 4, 3),
|
|
(bytes(range(32)), "private", b"", b"a", 0xFFFFFFFE, 1)]
|
|
node_inputs += [(DEFAULT_KEY, "LongFast", "\u6d4b\u8bd5".encode(), "\u6d4bA".encode(), FROM, hops)
|
|
for hops in (2, 4, 5, 6)]
|
|
node_info = []
|
|
for offset, (key, name, long_name, short_name, sender, hops) in enumerate(node_inputs):
|
|
packet_id = PACKET_ID + offset
|
|
frame = reference_frame(data_nodeinfo(long_name, short_name, sender), name, key,
|
|
sender=sender, packet_id=packet_id, flags=hops | (hops << 5))
|
|
node_info.append("{" + array(key) + "," + str(len(key)) + ',"' + name + '",'
|
|
+ literal(long_name) + "," + str(len(long_name)) + ","
|
|
+ literal(short_name) + "," + str(len(short_name)) + ","
|
|
+ str(sender) + "U," + str(packet_id) + "U," + str(hops) + ","
|
|
+ array(frame) + "," + str(len(frame)) + "}")
|
|
bad_data = {
|
|
"empty Data": b"", "missing port": b"\x12\x01x", "missing text": b"\x08\x01",
|
|
"empty text": b"\x08\x01\x12\x00", "other app": b"\x08\x02\x12\x01x",
|
|
"wrong port wire": b"\x0a\x01\x01\x12\x01x", "wrong payload wire": b"\x08\x01\x10\x01",
|
|
"truncated length": b"\x08\x01\x12\x80", "oversized length": b"\x08\x01\x12\xff\x01x",
|
|
"field zero": b"\x00" + data_text(b"x"), "unknown group wire": data_text(b"x") + b"\x53",
|
|
"overflow varint": b"\xff" * 9 + b"\x02", "noncanonical tag": b"\x88\x00\x01\x12\x01x",
|
|
"duplicate port": data_text(b"x") + b"\x08\x01", "duplicate text": data_text(b"x") + b"\x12\x01y",
|
|
"UTF8 surrogate": data_text(b"\xed\xa0\x80"), "embedded NUL": data_text(b"x\x00y"),
|
|
"proxied DM dest": data_text(b"x") + b"\x25" + struct.pack("<I", 0x99887766),
|
|
"invalid boolean": data_text(b"x") + b"\x18\x02", "bad source wire": data_text(b"x") + b"\x28\x01",
|
|
"truncated fixed32": data_text(b"x") + b"\x25\x00\x00",
|
|
"truncated unknown fixed64": data_text(b"x") + b"\x51\x00",
|
|
"long text": data_text(b"x" * 229),
|
|
}
|
|
rejected = [(label, reference_frame(data)) for label, data in bad_data.items()]
|
|
rejected += [("DM", reference_frame(data_text(b"x"), to=0x99887766)),
|
|
("zero sender", reference_frame(data_text(b"x"), sender=0)),
|
|
("reserved sender", reference_frame(data_text(b"x"), sender=0xFFFFFFFF)),
|
|
("zero packet ID", reference_frame(data_text(b"x"), packet_id=0)),
|
|
("short header", reference_frame(data_text(b"x"))[:15]),
|
|
("overlong frame", bytes(250))]
|
|
malformed = ["{\"" + label + "\"," + array(frame) + "," + str(len(frame)) + "}" for label, frame in rejected]
|
|
extension = reference_frame(data_text(b"x") + b"\x50\x81\x01\x59" + bytes(8)
|
|
+ b"\x62\x03abc\x6d" + bytes(4))
|
|
request = reference_frame(data_text(b"x") + b"\x18\x01", flags=0x6B)
|
|
presets = [("ShortTurbo", 500000, 7, 5), ("ShortFast", 250000, 7, 5),
|
|
("ShortSlow", 250000, 8, 5), ("MediumTurbo", 500000, 9, 5),
|
|
("MediumFast", 250000, 9, 5), ("MediumSlow", 250000, 10, 5),
|
|
("LongTurbo", 500000, 11, 8), ("LongFast", 250000, 11, 5),
|
|
("LongModerate", 125000, 11, 8), ("LongSlow", 125000, 12, 8)]
|
|
modems = []
|
|
for region, start, end in [("US", 902000000, 928000000), ("EU868", 869400000, 869650000),
|
|
("EU433", 433000000, 434000000), ("ANZ", 915000000, 928000000)]:
|
|
for preset, bandwidth, sf, cr in presets:
|
|
name = "LongMod" if preset == "LongModerate" else preset
|
|
name_hash = 5381
|
|
for byte in name.encode():
|
|
name_hash = (name_hash * 33 + byte) & 0xFFFFFFFF
|
|
slots = (end - start) // bandwidth
|
|
frequency = (start + bandwidth // 2 + (name_hash % slots) * bandwidth) / 1e6 if slots else 0.0
|
|
modems.append("{Region::" + region + ",Preset::" + preset + "," + str(bool(slots)).lower()
|
|
+ "," + str(frequency) + "f," + str(bandwidth / 1000) + "f," + str(sf) + "," + str(cr) + "}")
|
|
return (HARNESS.replace("@GOLDEN@", ",\n".join(golden)).replace("@MALFORMED@", ",\n".join(malformed))
|
|
.replace("@STOCK_ZERO_BITFIELD@", ",\n".join(stock_zero_bitfield))
|
|
.replace("@NODEINFO_GOLDEN@", ",\n".join(node_info))
|
|
.replace("@MODEMS@", ",\n".join(modems)).replace("@EXTENSION_FRAME@", array(extension))
|
|
.replace("@REQUEST_FRAME@", array(request)))
|
|
|
|
|
|
class MeshtextChatTest(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
try:
|
|
import cryptography # noqa: F401
|
|
except ImportError:
|
|
raise unittest.SkipTest("Python cryptography is required for the independent AES oracle")
|
|
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
|
|
if not compiler:
|
|
raise unittest.SkipTest("a host C++ compiler is required")
|
|
required = ["AESCommon.cpp", "AES128.cpp", "AES256.cpp", "BlockCipher.cpp", "Crypto.cpp"]
|
|
override = os.environ.get("MESHCORE_CRYPTO_DIR")
|
|
candidates = [Path(override)] if override else sorted((ROOT / ".pio/libdeps").glob("*/Crypto"))
|
|
crypto = next((path for path in candidates if all((path / item).is_file() for item in required)), None)
|
|
if not crypto:
|
|
raise unittest.SkipTest("an existing rweather/Crypto dependency is required (MESHCORE_CRYPTO_DIR)")
|
|
cls.temp = tempfile.TemporaryDirectory(prefix="meshcore-mt-codec-")
|
|
cls.addClassCleanup(cls.temp.cleanup)
|
|
work = Path(cls.temp.name)
|
|
source = work / "chat.cpp"
|
|
source.write_text(vectors(), encoding="utf-8")
|
|
cls.binary = work / ("chat.exe" if sys.platform == "win32" else "chat")
|
|
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
|
|
if sys.platform.startswith("linux") else [])
|
|
result = subprocess.run(
|
|
[compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter",
|
|
"-DMESH_ENABLE_MESHTEXT_CHAT=1",
|
|
*sanitizers, "-I" + str(ROOT / "src"), "-I" + str(crypto), str(source),
|
|
str(ROOT / "src/helpers/MeshtextChat.cpp"), *[str(crypto / item) for item in required],
|
|
"-o", str(cls.binary)], capture_output=True, text=True, timeout=60,
|
|
)
|
|
if result.returncode:
|
|
raise AssertionError(result.stdout + result.stderr)
|
|
|
|
def run_case(self, name):
|
|
result = subprocess.run([str(self.binary), name], capture_output=True, text=True, timeout=30)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertIn("PASS " + name, result.stdout)
|
|
|
|
def test_modem_presets_regions_and_strict_slots(self):
|
|
self.run_case("presets")
|
|
|
|
def test_actual_aes128_aes256_against_independent_golden_frames(self):
|
|
self.run_case("frames")
|
|
|
|
def test_malformed_protobuf_utf8_and_broadcast_only(self):
|
|
self.run_case("malformed")
|
|
|
|
def test_explicit_standard_nodeinfo_aes128_aes256_all_hops_and_no_receive(self):
|
|
self.run_case("nodeinfo")
|
|
|
|
def test_nodeinfo_stock_name_utf8_and_argument_bounds(self):
|
|
self.run_case("nodeinfo_bounds")
|
|
|
|
def test_argument_and_text_bounds(self):
|
|
self.run_case("bounds")
|
|
|
|
def test_safe_unknown_fields_and_no_request_response_action(self):
|
|
self.run_case("extensions")
|
|
|
|
def test_stock_present_zero_bitfield_broadcast_hops7_aes128_and_aes256(self):
|
|
self.run_case("stock_zero_bitfield")
|
|
|
|
def test_dedup_only_valid_packets_and_own_tx(self):
|
|
self.run_case("dedup")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|