Add signed private fleet controls for filtering and secondary radio
Enroll a private channel and publisher public key on repeaters and room servers. Local Companion fleet sends sign targeted or whole-fleet commands, with expiry, durable replay protection, and a restricted command allowlist. Preserve exact transmitted-reply barriers for secondary-radio changes, bound validation and replies, and qualify crypto, persistence, transport scope, role permissions, and compact-profile exclusions with regression tests.
This commit is contained in:
1 parent
a14bbdc58f
commit
d83fa7cf8e
27 files changed
+3028
-2
No files matched your search
+54
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
#ifndef MESH_ENABLE_FLEET_CONTROL
|
||||
#define MESH_ENABLE_FLEET_CONTROL 1
|
||||
#endif
|
||||
|
||||
#include <cassert>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
// These narrower dispatch fixtures retain the complete production fleet branch
|
||||
// but have no channel peripheral. The separate companion fleet suite compiles
|
||||
// the real codec with real Ed25519 and exercises packet admission end to end.
|
||||
#ifndef MAX_GROUP_CHANNELS
|
||||
#define MAX_GROUP_CHANNELS 4
|
||||
#endif
|
||||
#ifndef PUB_KEY_SIZE
|
||||
#define PUB_KEY_SIZE 32
|
||||
#endif
|
||||
#ifndef OUT_PATH_UNKNOWN
|
||||
#define OUT_PATH_UNKNOWN 255
|
||||
#endif
|
||||
|
||||
namespace mesh {
|
||||
struct FleetCommand {
|
||||
static constexpr uint16_t DataType = 0xFF01;
|
||||
static constexpr size_t KeySize = 16, TargetSize = 16, MaxPayloadLength = 165;
|
||||
static constexpr uint32_t MinEpoch = 1735689600UL, MaxLifetime = 600;
|
||||
static bool parseTarget(const char*, uint8_t*) { return false; }
|
||||
static bool commandAllowed(const char*) { return false; }
|
||||
static bool privateKeyAllowed(const uint8_t*) { return false; }
|
||||
template<class Signer>
|
||||
static size_t encode(const Signer&, const uint8_t*, uint32_t, uint32_t,
|
||||
const uint8_t*, const char*, uint8_t*, size_t) {
|
||||
assert(false && "fleet encoding belongs in the dedicated real-codec suite");
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
struct FleetFixtureChannel { uint8_t secret[32] = {}; };
|
||||
struct ChannelDetails { FleetFixtureChannel channel; char name[32] = {}; };
|
||||
struct FleetFixtureClock { uint32_t getCurrentTime() const { return 0; } };
|
||||
struct CompanionFleetFixture {
|
||||
bool getChannel(int, ChannelDetails&) { return false; }
|
||||
FleetFixtureClock* getRTCClock() {
|
||||
static FleetFixtureClock clock;
|
||||
return &clock;
|
||||
}
|
||||
bool sendGroupData(FleetFixtureChannel&, uint8_t*, uint8_t, uint16_t,
|
||||
const uint8_t*, int) {
|
||||
assert(false && "fleet packet admission belongs in its dedicated suite");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute the real Companion fleet-send CLI, preserving its client boundary.
|
||||
|
||||
Only channel storage and packet admission are mocked here. Protocol encoding,
|
||||
target hashing and permission checks come from FleetCommand.cpp; OpenSSL
|
||||
supplies real Ed25519 signing and verification through the Identity interface.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from cpp_source import body
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE = ROOT / "examples/companion_radio/MyMesh.cpp"
|
||||
|
||||
HARNESS = r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <helpers/FleetCommand.h>
|
||||
#include <openssl/evp.h>
|
||||
|
||||
namespace mesh {
|
||||
Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); }
|
||||
LocalIdentity::LocalIdentity() : Identity() {
|
||||
memset(prv_key, 0xAA, 32);
|
||||
EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, prv_key, 32);
|
||||
assert(key);
|
||||
size_t length = sizeof(pub_key);
|
||||
assert(EVP_PKEY_get_raw_public_key(key, pub_key, &length) == 1);
|
||||
assert(length == sizeof(pub_key));
|
||||
memcpy(prv_key + 32, pub_key, sizeof(pub_key));
|
||||
EVP_PKEY_free(key);
|
||||
}
|
||||
void LocalIdentity::sign(uint8_t* signature, const uint8_t* data, int length) const {
|
||||
EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, prv_key, 32);
|
||||
EVP_MD_CTX* context = EVP_MD_CTX_new();
|
||||
assert(key && context);
|
||||
assert(EVP_DigestSignInit(context, nullptr, nullptr, nullptr, key) == 1);
|
||||
size_t size = SIGNATURE_SIZE;
|
||||
assert(EVP_DigestSign(context, signature, &size, data, length) == 1);
|
||||
assert(size == SIGNATURE_SIZE);
|
||||
EVP_MD_CTX_free(context); EVP_PKEY_free(key);
|
||||
}
|
||||
bool Identity::verify(const uint8_t* signature, const uint8_t* data, int length) const {
|
||||
EVP_PKEY* key = EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, pub_key, sizeof(pub_key));
|
||||
EVP_MD_CTX* context = EVP_MD_CTX_new();
|
||||
assert(key && context);
|
||||
assert(EVP_DigestVerifyInit(context, nullptr, nullptr, nullptr, key) == 1);
|
||||
const bool valid = EVP_DigestVerify(context, signature, SIGNATURE_SIZE, data, length) == 1;
|
||||
EVP_MD_CTX_free(context); EVP_PKEY_free(key);
|
||||
return valid;
|
||||
}
|
||||
}
|
||||
|
||||
#define MAX_GROUP_CHANNELS 4
|
||||
#define OUT_PATH_UNKNOWN 255
|
||||
struct GroupChannel {
|
||||
uint8_t secret[32] = {};
|
||||
uint8_t tx_radio = 0;
|
||||
};
|
||||
struct ChannelDetails { GroupChannel channel; char name[32] = {}; };
|
||||
struct Clock { uint32_t now = mesh::FleetCommand::MinEpoch + 1000;
|
||||
uint32_t getCurrentTime() const { return now; } };
|
||||
class MyMesh {
|
||||
public:
|
||||
mesh::LocalIdentity self_id;
|
||||
Clock clock;
|
||||
ChannelDetails channels[MAX_GROUP_CHANNELS];
|
||||
bool queue_ok = true;
|
||||
unsigned queued = 0;
|
||||
uint8_t sent_policy = 0;
|
||||
uint16_t sent_type = 0;
|
||||
uint8_t sent[mesh::FleetCommand::MaxPayloadLength] = {};
|
||||
size_t sent_length = 0;
|
||||
MyMesh() {
|
||||
strcpy(channels[1].name, "private fleet");
|
||||
memset(channels[1].channel.secret, 0x21, mesh::FleetCommand::KeySize);
|
||||
channels[1].channel.tx_radio = 2;
|
||||
}
|
||||
bool getChannel(int index, ChannelDetails& out) {
|
||||
if (index < 0 || index >= MAX_GROUP_CHANNELS) return false;
|
||||
out = channels[index]; return true;
|
||||
}
|
||||
Clock* getRTCClock() { return &clock; }
|
||||
bool sendGroupData(GroupChannel& channel, uint8_t* path, uint8_t path_length,
|
||||
uint16_t type, const uint8_t* data, int length) {
|
||||
assert(path == nullptr && path_length == OUT_PATH_UNKNOWN);
|
||||
assert(length > 0 && size_t(length) <= sizeof(sent));
|
||||
if (!queue_ok) return false;
|
||||
sent_policy = channel.tx_radio; sent_type = type;
|
||||
memcpy(sent, data, length); sent_length = length; ++queued;
|
||||
return true;
|
||||
}
|
||||
bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply);
|
||||
};
|
||||
@METHOD@
|
||||
|
||||
static void rejected(MyMesh& node, const char* text, uint32_t remote = 0) {
|
||||
char reply[160] = {};
|
||||
const unsigned before = node.queued;
|
||||
assert(node.handleCommand(text, remote, reply));
|
||||
assert(!strncmp(reply, "Error:", 6));
|
||||
assert(before == node.queued);
|
||||
}
|
||||
|
||||
static std::string fullKey(uint8_t value) {
|
||||
char hex[65];
|
||||
for (unsigned i = 0; i < 32; ++i) sprintf(hex + i * 2, "%02x", value);
|
||||
return hex;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc == 2);
|
||||
const std::string scenario = argv[1];
|
||||
MyMesh node; char reply[160] = {};
|
||||
if (scenario == "local_boundary") {
|
||||
assert(!node.handleCommand(nullptr, 0, reply));
|
||||
assert(!node.handleCommand("fleet send 1 all set radio2 off", 0, nullptr));
|
||||
assert(!node.handleCommand("fleetish", 0, reply));
|
||||
rejected(node, "fleet send 1 all set radio2 off", 1);
|
||||
rejected(node, "fleet", 1);
|
||||
rejected(node, "fleet send 1 all reboot");
|
||||
rejected(node, "fleet send 1 all get prv.key");
|
||||
rejected(node, "fleet send 1 all fleet send 1 all set radio2 off");
|
||||
assert(node.handleCommand(" \tAB| fleet send 1 all set radio2 off", 0, reply));
|
||||
assert(!strncmp(reply, "AB|OK - fleet command queued; seq=", 32));
|
||||
assert(node.queued == 1 && node.sent_type == mesh::FleetCommand::DataType);
|
||||
assert(node.sent_policy == 2); // The configured radio2 channel route survives.
|
||||
} else if (scenario == "syntax") {
|
||||
for (const char* command : {"fleet", "fleet send", "fleet sender 1 all set radio2 off",
|
||||
"fleet send -1 all set radio2 off", "fleet send 4 all set radio2 off",
|
||||
"fleet send 4294967296 all set radio2 off", "fleet send 1x all set radio2 off",
|
||||
"fleet send 1 all", "fleet send 1 a set radio2 off",
|
||||
"fleet send 1 111111111111 set radio2 off", "fleet send 1 all get password",
|
||||
"fleet send 1 all set radio2 off\nreboot"}) rejected(node, command);
|
||||
rejected(node, ("fleet send 1 " + std::string(65, '1') + " set radio2 off").c_str());
|
||||
rejected(node, ("fleet send 1 all set flood.filter " + std::string(150, '1')).c_str());
|
||||
assert(node.handleCommand("fleet\tsend\t1\tall\tset radio2 off", 0, reply));
|
||||
assert(!strncmp(reply, "OK - fleet command queued", 25));
|
||||
} else if (scenario == "private_channel") {
|
||||
rejected(node, "fleet send 0 all set radio2 off");
|
||||
memset(node.channels[1].channel.secret, 0, 32);
|
||||
rejected(node, "fleet send 1 all set radio2 off");
|
||||
const uint8_t public_key[16] = {0x8b,0x33,0x87,0xe9,0xc5,0xcd,0xea,0x6a,
|
||||
0xc9,0xe5,0xed,0xba,0xa1,0x15,0xcd,0x72};
|
||||
memcpy(node.channels[1].channel.secret, public_key, sizeof(public_key));
|
||||
strcpy(node.channels[1].name, "renamed public");
|
||||
rejected(node, "fleet send 1 all set radio2 off");
|
||||
memset(node.channels[1].channel.secret, 0x21, 32);
|
||||
rejected(node, "fleet send 1 all set radio2 off");
|
||||
memset(node.channels[1].channel.secret + 16, 0, 16);
|
||||
strcpy(node.channels[1].name, "Public"); // Names are not key authorization.
|
||||
assert(node.handleCommand("fleet send 1 all set radio2 off", 0, reply));
|
||||
assert(node.queued == 1);
|
||||
} else if (scenario == "queue_sequence") {
|
||||
node.queue_ok = false;
|
||||
rejected(node, "fleet send 1 all set radio2 off");
|
||||
node.queue_ok = true;
|
||||
assert(node.handleCommand("fleet send 1 all set radio2 off", 0, reply));
|
||||
assert(node.queued == 1);
|
||||
rejected(node, "fleet send 1 all set radio2 off");
|
||||
++node.clock.now;
|
||||
assert(node.handleCommand("fleet send 1 all set radio2 off", 0, reply));
|
||||
assert(node.queued == 2);
|
||||
--node.clock.now;
|
||||
rejected(node, "fleet send 1 all set radio2 off");
|
||||
} else if (scenario == "clock") {
|
||||
node.clock.now = 0;
|
||||
rejected(node, "fleet send 1 all set radio2 off");
|
||||
node.clock.now = mesh::FleetCommand::MinEpoch - 1;
|
||||
rejected(node, "fleet send 1 all set radio2 off");
|
||||
node.clock.now = UINT32_MAX;
|
||||
rejected(node, "fleet send 1 all set radio2 off");
|
||||
node.clock.now = mesh::FleetCommand::MinEpoch;
|
||||
assert(node.handleCommand("fleet send 1 all set radio2 off", 0, reply));
|
||||
assert(node.queued == 1);
|
||||
} else if (scenario == "signed_target") {
|
||||
const std::string command = "fleet send 1 " + fullKey(0x42)
|
||||
+ " set tempradio2 910.5,500,5,5,rxtx,1";
|
||||
assert(node.handleCommand(command.c_str(), 0, reply));
|
||||
assert(node.queued == 1);
|
||||
uint8_t recipient[32]; memset(recipient, 0x42, sizeof(recipient));
|
||||
mesh::FleetCommand::Decoded decoded;
|
||||
assert(mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
|
||||
node.sent, node.sent_length, node.clock.now, recipient, decoded));
|
||||
assert(decoded.sequence == node.clock.now);
|
||||
assert(decoded.expires == node.clock.now + mesh::FleetCommand::MaxLifetime);
|
||||
assert(!strcmp(decoded.command, "set tempradio2 910.5,500,5,5,rxtx,1"));
|
||||
recipient[31] ^= 1;
|
||||
assert(!mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
|
||||
node.sent, node.sent_length, node.clock.now, recipient, decoded));
|
||||
} else assert(false);
|
||||
printf("Companion fleet scenario %s passed\n", argv[1]);
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class CompanionFleetControlTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
if compiler is None:
|
||||
raise unittest.SkipTest("host C++ compiler required")
|
||||
cls.compiler = compiler
|
||||
source = SOURCE.read_text()
|
||||
function = body(source, "bool MyMesh::handleCommand(const char* command,")
|
||||
# Retain the real NULL, whitespace and request-prefix handling too.
|
||||
prefix = function[:function.index("#if COMPANION_FEATURE_READER")]
|
||||
fleet_start = function.rindex("#if MESH_ENABLE_FLEET_CONTROL", 0,
|
||||
function.index('if (!strncmp(command, "fleet", 5)'))
|
||||
fleet_end = function.index(" if (_radio_profiles.handle", fleet_start)
|
||||
# Keep the actual compile guard as well as its complete branch so
|
||||
# compact profiles prove that none of the fleet dependencies survive.
|
||||
cls.production = prefix + function[fleet_start:fleet_end] + "\n return false;\n}\n"
|
||||
cls.work = tempfile.TemporaryDirectory(prefix="meshcore-fleet-companion-")
|
||||
work = Path(cls.work.name)
|
||||
cls.binary = work / "companion-fleet"
|
||||
fixture = work / "fixture.cpp"
|
||||
fixture.write_text(HARNESS.replace("@METHOD@", cls.production))
|
||||
result = subprocess.run([
|
||||
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-Wno-unused-parameter", "-O2",
|
||||
"-fstack-usage", "-isystem", str(ROOT / "test/mocks"),
|
||||
"-I", str(ROOT / "src"), str(fixture),
|
||||
str(ROOT / "src/helpers/FleetCommand.cpp"), "-lcrypto", "-o", str(cls.binary),
|
||||
], capture_output=True, text=True, timeout=60)
|
||||
if result.returncode:
|
||||
cls.work.cleanup()
|
||||
raise AssertionError(result.stdout + result.stderr)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.work.cleanup()
|
||||
|
||||
def scenario(self, name):
|
||||
result = subprocess.run([str(self.binary), name], capture_output=True,
|
||||
text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_local_client_boundary_and_channel_tx_route(self):
|
||||
self.scenario("local_boundary")
|
||||
|
||||
def test_invalid_syntax_targets_and_forbidden_commands(self):
|
||||
self.scenario("syntax")
|
||||
|
||||
def test_private_128_bit_key_required_even_if_channel_is_renamed(self):
|
||||
self.scenario("private_channel")
|
||||
|
||||
def test_queue_failure_does_not_consume_sequence_and_success_does(self):
|
||||
self.scenario("queue_sequence")
|
||||
|
||||
def test_invalid_clock_and_timestamp_overflow_never_queue(self):
|
||||
self.scenario("clock")
|
||||
|
||||
def test_production_encode_targets_full_key_and_sets_bounded_expiry(self):
|
||||
self.scenario("signed_target")
|
||||
|
||||
def test_disabled_profiles_require_no_sender_peripherals_or_crypto_link(self):
|
||||
harness = r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <helpers/FleetCommand.h>
|
||||
static_assert(MESH_ENABLE_FLEET_CONTROL == 0, "compact profile must disable fleet");
|
||||
// No channels, clock, signing identity, codec definitions or sendGroupData.
|
||||
class MyMesh {
|
||||
public:
|
||||
bool handleCommand(const char*, uint32_t, char*);
|
||||
};
|
||||
@METHOD@
|
||||
int main() {
|
||||
MyMesh node;
|
||||
char reply[160] = {};
|
||||
assert(!node.handleCommand("fleet send 1 all set radio2 off", 0, reply));
|
||||
assert(!node.handleCommand("fleet send 1 all set radio2 off", 1, reply));
|
||||
assert(!node.handleCommand("AB|fleet send 1 all set radio2 off", 0, reply));
|
||||
}
|
||||
'''
|
||||
work = Path(self.work.name)
|
||||
source = work / "disabled.cpp"
|
||||
source.write_text(harness.replace("@METHOD@", self.production))
|
||||
for profile, flags in (("stm32-default", ["-DSTM32_PLATFORM=1"]),
|
||||
("explicit-disable", ["-DMESH_ENABLE_FLEET_CONTROL=0"])):
|
||||
with self.subTest(profile=profile):
|
||||
executable = work / profile
|
||||
built = subprocess.run([
|
||||
self.compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-Wno-unused-parameter", "-isystem", str(ROOT / "test/mocks"),
|
||||
"-I", str(ROOT / "src"), *flags, str(source), "-o", str(executable),
|
||||
], capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
||||
result = subprocess.run([str(executable)], capture_output=True,
|
||||
text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -18,6 +18,7 @@ HARNESS = r'''
|
||||
#include <helpers/RadioProfileCLI.h>
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#include <helpers/radiolib/RXPowerSaving.h>
|
||||
#include "companion_fleet_dispatch.h"
|
||||
#include <sys/stat.h>
|
||||
#include <limits>
|
||||
#define constrain(value,low,high) ((value)<(low)?(low):((value)>(high)?(high):(value)))
|
||||
@@ -57,7 +58,8 @@ struct Radio : mesh::Radio {
|
||||
};
|
||||
struct Sensors { double node_lat=0,node_lon=0; } sensors;
|
||||
@COMMAND_FILTER@
|
||||
struct MyMesh {
|
||||
struct MyMesh : CompanionFleetFixture {
|
||||
uint8_t self_id = 0; // Signing is unreachable in this radio-only fixture.
|
||||
CompanionNodePrefs _prefs;
|
||||
DataStore store;
|
||||
DataStore* _store=&store;
|
||||
@@ -293,6 +295,7 @@ public:
|
||||
build = subprocess.run([compiler,'-std=c++17','-Wall','-Wextra',
|
||||
'-Wno-unused-parameter','-Wno-sign-compare','-Wno-reorder',
|
||||
'-DESP32_PLATFORM=1',*flags,'-I',str(work),
|
||||
'-I',str(ROOT/'test/fixtures'),
|
||||
'-I',str(ROOT/'test/fixtures/radio_profiles/mocks'),
|
||||
'-I',str(ROOT/'test/mocks'),'-I',str(ROOT/'src'),
|
||||
'-I',str(ROOT/'src/helpers'),'-I',str(ROOT),str(work/'test.cpp'),
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute production fleet receiver/storage/crypto boundaries with host mocks.
|
||||
|
||||
FleetChannel.cpp and FleetCommand.cpp are compiled unchanged. Only hardware,
|
||||
packet admission, clock and filesystem I/O are mocked. Ed25519 signatures and
|
||||
SHA-256 use real implementations; AES encryption/MAC are supplied by OpenSSL.
|
||||
The real RP2040 ContactFileTransaction and nRF52 AtomicFileWriter validate and
|
||||
publish persisted state using their actual platform-specific implementations.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from cpp_source import body
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
UTILS = r'''
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
#include <cassert>
|
||||
#include <SHA256.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/hmac.h>
|
||||
#define PUB_KEY_SIZE 32
|
||||
#define PRV_KEY_SIZE 64
|
||||
#define SIGNATURE_SIZE 64
|
||||
#define PATH_HASH_SIZE 1
|
||||
#define CIPHER_MAC_SIZE 2
|
||||
#define CIPHER_BLOCK_SIZE 16
|
||||
inline unsigned hash_calls = 0, decrypt_calls = 0, verify_calls = 0, last_jitter_max = 0;
|
||||
namespace mesh {
|
||||
class RNG { public: uint32_t nextInt(uint32_t low, uint32_t high) {
|
||||
assert(low == 500 && (high == 60500 || high == 15500 || high == 1500)); last_jitter_max=high; return 1000;
|
||||
}};
|
||||
class Utils { public:
|
||||
static void sha256(uint8_t* out, size_t count, const uint8_t* data, int length) {
|
||||
++hash_calls; SHA256 hash; hash.update(data, length); hash.finalize(out, count);
|
||||
}
|
||||
static void toHex(char* out, const uint8_t* data, size_t length) {
|
||||
for (size_t i = 0; i < length; ++i) sprintf(out + 2*i, "%02x", data[i]);
|
||||
out[2*length] = 0;
|
||||
}
|
||||
static bool fromHex(uint8_t* out, int length, const char* hex) {
|
||||
if (strlen(hex) != size_t(2*length)) return false;
|
||||
for (int i=0;i<length;++i) {
|
||||
auto digit=[](char c) { return c>='0'&&c<='9'?c-'0':c>='a'&&c<='f'?c-'a'+10:
|
||||
c>='A'&&c<='F'?c-'A'+10:-1; };
|
||||
int a=digit(hex[2*i]), b=digit(hex[2*i+1]); if(a<0||b<0) return false;
|
||||
out[i]=uint8_t(a*16+b);
|
||||
} return true;
|
||||
}
|
||||
static int encryptThenMAC(const uint8_t* key, uint8_t* out, const uint8_t* data, int size) {
|
||||
uint8_t padded[256] = {}; const int rounded = (size+15)/16*16;
|
||||
assert(rounded <= int(sizeof(padded))); memcpy(padded,data,size);
|
||||
EVP_CIPHER_CTX* ctx=EVP_CIPHER_CTX_new(); assert(ctx);
|
||||
assert(EVP_EncryptInit_ex(ctx,EVP_aes_128_ecb(),nullptr,key,nullptr));
|
||||
EVP_CIPHER_CTX_set_padding(ctx,0); int produced=0, tail=0;
|
||||
assert(EVP_EncryptUpdate(ctx,out+CIPHER_MAC_SIZE,&produced,padded,rounded));
|
||||
assert(EVP_EncryptFinal_ex(ctx,out+CIPHER_MAC_SIZE+produced,&tail));
|
||||
EVP_CIPHER_CTX_free(ctx); assert(produced+tail==rounded);
|
||||
uint8_t mac[32]; unsigned mac_len=0;
|
||||
assert(HMAC(EVP_sha256(),key,32,out+CIPHER_MAC_SIZE,rounded,mac,&mac_len));
|
||||
memcpy(out,mac,CIPHER_MAC_SIZE); return rounded+CIPHER_MAC_SIZE;
|
||||
}
|
||||
static int MACThenDecrypt(const uint8_t* key,uint8_t* out,const uint8_t* data,int size) {
|
||||
++decrypt_calls; int cipher_size=size-CIPHER_MAC_SIZE;
|
||||
if(cipher_size<=0 || cipher_size%16) return 0;
|
||||
uint8_t mac[32]; unsigned mac_len=0;
|
||||
assert(HMAC(EVP_sha256(),key,32,data+CIPHER_MAC_SIZE,cipher_size,mac,&mac_len));
|
||||
if(memcmp(mac,data,CIPHER_MAC_SIZE)) return 0;
|
||||
EVP_CIPHER_CTX* ctx=EVP_CIPHER_CTX_new(); assert(ctx);
|
||||
assert(EVP_DecryptInit_ex(ctx,EVP_aes_128_ecb(),nullptr,key,nullptr));
|
||||
EVP_CIPHER_CTX_set_padding(ctx,0); int produced=0,tail=0;
|
||||
assert(EVP_DecryptUpdate(ctx,out,&produced,data+CIPHER_MAC_SIZE,cipher_size));
|
||||
assert(EVP_DecryptFinal_ex(ctx,out+produced,&tail)); EVP_CIPHER_CTX_free(ctx);
|
||||
return produced+tail;
|
||||
}
|
||||
};
|
||||
}
|
||||
'''
|
||||
|
||||
FILESYSTEM = r'''
|
||||
#pragma once
|
||||
#include <map>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <memory>
|
||||
#include <cstring>
|
||||
#include <algorithm>
|
||||
namespace fs { class FS; }
|
||||
class File {
|
||||
fs::FS* owner_=nullptr; std::string path_; size_t cursor_=0; bool live_=false;
|
||||
public:
|
||||
File() = default;
|
||||
File(fs::FS* owner,const char* path,bool live):owner_(owner),path_(path),live_(live){}
|
||||
explicit File(fs::FS& owner):owner_(&owner){}
|
||||
bool open(const char* path,int flags);
|
||||
explicit operator bool() const { return live_; }
|
||||
bool isDirectory() const { return false; }
|
||||
size_t size() const;
|
||||
size_t read(uint8_t* data,size_t length);
|
||||
size_t write(const uint8_t* data,size_t length);
|
||||
void flush() {}
|
||||
void close() {live_=false;}
|
||||
};
|
||||
namespace fs {
|
||||
class FS { public:
|
||||
std::map<std::string,std::vector<uint8_t>> files;
|
||||
bool fail_open=false, fail_read=false, fail_write=false, fail_rename=false;
|
||||
unsigned reads=0,writes=0,renames=0;
|
||||
std::string fail_rename_from;
|
||||
void _lockFS() {} void _unlockFS() {} FS* _getFS(){return this;}
|
||||
bool exists(const char* path) {return files.count(path)!=0;}
|
||||
bool remove(const char* path) {return files.erase(path)!=0;}
|
||||
bool mkdir(const char*) {return true;}
|
||||
bool rename(const char* old,const char* next) {
|
||||
++renames; if(fail_rename || fail_rename_from==old || !exists(old)) return false;
|
||||
files[next]=std::move(files[old]);files.erase(old);return true;
|
||||
}
|
||||
File open(const char* path,const char* mode="r") {
|
||||
if(!path||fail_open) return {};
|
||||
if(mode[0]=='w') {files[path].clear();return File(this,path,true);}
|
||||
return File(this,path,exists(path));
|
||||
}
|
||||
};
|
||||
}
|
||||
inline bool File::open(const char* path,int flags){
|
||||
if(!owner_)return false;*this=owner_->open(path,flags==2?"w":"r");return live_;
|
||||
}
|
||||
inline size_t File::size() const {return live_?owner_->files[path_].size():0;}
|
||||
inline size_t File::read(uint8_t* data,size_t length) {
|
||||
++owner_->reads; if(!live_||owner_->fail_read) return 0;
|
||||
auto& bytes=owner_->files[path_];size_t n=std::min(length,bytes.size()-cursor_);
|
||||
memcpy(data,bytes.data()+cursor_,n);cursor_+=n;return n;
|
||||
}
|
||||
inline size_t File::write(const uint8_t* data,size_t length) {
|
||||
++owner_->writes; if(!live_||owner_->fail_write) return 0;
|
||||
auto& bytes=owner_->files[path_];bytes.insert(bytes.end(),data,data+length);return length;
|
||||
}
|
||||
'''
|
||||
|
||||
MESH = r'''
|
||||
#pragma once
|
||||
#include <Identity.h>
|
||||
#include <FS.h>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
#define MAX_PACKET_PAYLOAD 184
|
||||
#define PAYLOAD_TYPE_GRP_TXT 5
|
||||
#define PAYLOAD_TYPE_GRP_DATA 6
|
||||
#define FLOOD_RETRY_POLICY_DENY 1
|
||||
inline uint32_t fake_ms = 1000;
|
||||
inline uint32_t millis() {return fake_ms;}
|
||||
namespace mesh {
|
||||
struct Packet {
|
||||
uint8_t type=PAYLOAD_TYPE_GRP_DATA,payload[MAX_PACKET_PAYLOAD]={},flood_retry_policy=0;
|
||||
uint16_t payload_len=0,transport_codes[2]={};
|
||||
uint32_t radio_generation=0;uint8_t radio_profile=0,path_hash_size=1;
|
||||
bool scoped=false,radio_reply=false,radio_bound=false;
|
||||
uint8_t getPayloadType() const{return type;}
|
||||
uint8_t getPathHashSize() const{return path_hash_size;}
|
||||
bool hasTransportCodes() const{return scoped;}
|
||||
};
|
||||
struct GroupChannel {uint8_t hash[PATH_HASH_SIZE]={},secret[32]={};};
|
||||
struct Clock {uint32_t now=1735689600UL+1000;
|
||||
uint32_t getCurrentTime() const{return now;}};
|
||||
class Mesh;
|
||||
class Dispatcher {public:
|
||||
class ReceiveProfileScope {Mesh& mesh_;uint8_t old_;uint32_t generation_;
|
||||
public:ReceiveProfileScope(Mesh&,uint8_t,uint32_t);~ReceiveProfileScope();};
|
||||
virtual void onSendFail(Packet*)=0;
|
||||
};
|
||||
class PacketManager {public:
|
||||
std::vector<Packet*> outbound;
|
||||
int getOutboundTotal() const{return int(outbound.size());}
|
||||
Packet* getOutboundByIdx(int i){return outbound.at(i);}
|
||||
Packet* removeOutboundByIdx(int i){Packet* p=outbound.at(i);outbound.erase(outbound.begin()+i);return p;}
|
||||
};
|
||||
class Mesh:public Dispatcher {public:
|
||||
LocalIdentity self_id;Clock clock;RNG rng;PacketManager manager;PacketManager* _mgr=&manager;
|
||||
std::function<void(Packet*,const Packet*)> copying;
|
||||
std::function<void(Packet*)> failure;
|
||||
std::vector<Packet*> allocated;
|
||||
Packet* in_flight=nullptr;bool queue_ok=true,allocation_ok=true,fanout=false;
|
||||
unsigned queued=0,cancelled=0,released=0;
|
||||
uint8_t scoped_profile=0,ack_path_size=0;uint32_t scoped_generation=0,ack_delay=0;
|
||||
bool ack_scoped=false;uint16_t ack_codes[2]={};
|
||||
~Mesh(){for(Packet* p:allocated)delete p;}
|
||||
Clock* getRTCClock(){return &clock;}RNG* getRNG(){return &rng;}
|
||||
Packet* createGroupDatagram(uint8_t type,const GroupChannel& channel,const uint8_t* data,size_t length){
|
||||
assert(length<=168);if(!allocation_ok)return nullptr;Packet* p=new Packet;allocated.push_back(p);p->type=type;
|
||||
p->payload[0]=channel.hash[0];p->payload_len=1+Utils::encryptThenMAC(channel.secret,p->payload+1,data,int(length));return p;
|
||||
}
|
||||
bool sendFlood(Packet* p,uint32_t delay,uint8_t hashes){
|
||||
ack_delay=delay;ack_path_size=hashes;
|
||||
if(!queue_ok)return false;manager.outbound.push_back(p);++queued;
|
||||
if(fanout){Packet* second=new Packet(*p);allocated.push_back(second);manager.outbound.push_back(second);
|
||||
if(copying)copying(second,p);}
|
||||
return true;
|
||||
}
|
||||
bool sendFlood(Packet* p,const uint16_t* codes,uint32_t delay,uint8_t hashes){
|
||||
ack_scoped=true;memcpy(ack_codes,codes,sizeof(ack_codes));return sendFlood(p,delay,hashes);
|
||||
}
|
||||
const Packet* getOutboundInFlight()const{return in_flight;}
|
||||
void cancelOutboundRadioRetry(const Packet* p){assert(p==in_flight);++cancelled;}
|
||||
void onSendFail(Packet* p)override{if(failure)failure(p);}
|
||||
void releasePacket(Packet*){++released;}
|
||||
};
|
||||
inline Dispatcher::ReceiveProfileScope::ReceiveProfileScope(Mesh& m,uint8_t profile,uint32_t generation)
|
||||
:mesh_(m),old_(m.scoped_profile),generation_(m.scoped_generation){m.scoped_profile=profile;m.scoped_generation=generation;}
|
||||
inline Dispatcher::ReceiveProfileScope::~ReceiveProfileScope(){mesh_.scoped_profile=old_;mesh_.scoped_generation=generation_;}
|
||||
}
|
||||
'''
|
||||
|
||||
PROFILE = r'''
|
||||
#pragma once
|
||||
namespace mesh {
|
||||
class RadioProfileCLI {public:
|
||||
bool mutation=false,command=false;uint32_t generation=0;
|
||||
unsigned finishes=0,commits=0,rollbacks=0;
|
||||
void beginReplyCommand(){command=true;}void endReplyCommand(){command=false;}
|
||||
bool hasReplyMutation()const{return mutation;}
|
||||
uint32_t replyMutationGeneration()const{return generation;}
|
||||
void stage(){assert(command);mutation=true;++generation;}
|
||||
bool finishReplyMutation(bool delivered){assert(mutation);mutation=false;++finishes;
|
||||
if(delivered)++commits;else ++rollbacks;return true;}
|
||||
};
|
||||
}
|
||||
'''
|
||||
|
||||
HARNESS = r'''
|
||||
#include <helpers/FleetChannel.h>
|
||||
#include <helpers/PersistentStoreFormat.h>
|
||||
#include <cassert>
|
||||
#include <string>
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
@TRANSPORT_METHODS@
|
||||
|
||||
inline unsigned identity_counter=0;
|
||||
namespace mesh {
|
||||
Identity::Identity(){memset(pub_key,0,sizeof(pub_key));}
|
||||
LocalIdentity::LocalIdentity():Identity(){
|
||||
memset(prv_key,0xAA+identity_counter++,32);EVP_PKEY* key=EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519,nullptr,prv_key,32);
|
||||
assert(key);size_t length=sizeof(pub_key);assert(EVP_PKEY_get_raw_public_key(key,pub_key,&length)==1);
|
||||
memcpy(prv_key+32,pub_key,32);EVP_PKEY_free(key);
|
||||
}
|
||||
void LocalIdentity::sign(uint8_t* sig,const uint8_t* data,int length)const{
|
||||
EVP_PKEY* key=EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519,nullptr,prv_key,32);
|
||||
EVP_MD_CTX* ctx=EVP_MD_CTX_new();assert(key&&ctx);assert(EVP_DigestSignInit(ctx,nullptr,nullptr,nullptr,key)==1);
|
||||
size_t size=64;assert(EVP_DigestSign(ctx,sig,&size,data,length)==1);assert(size==64);
|
||||
EVP_MD_CTX_free(ctx);EVP_PKEY_free(key);
|
||||
}
|
||||
bool Identity::verify(const uint8_t* sig,const uint8_t* data,int length)const{
|
||||
++verify_calls;EVP_PKEY* key=EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519,nullptr,pub_key,32);
|
||||
EVP_MD_CTX* ctx=EVP_MD_CTX_new();assert(key&&ctx);assert(EVP_DigestVerifyInit(ctx,nullptr,nullptr,nullptr,key)==1);
|
||||
bool valid=EVP_DigestVerify(ctx,sig,64,data,length)==1;EVP_MD_CTX_free(ctx);EVP_PKEY_free(key);return valid;
|
||||
}
|
||||
}
|
||||
using namespace mesh;
|
||||
static constexpr const char* Path="/fleet_channel";
|
||||
static constexpr const char* Key="21212121212121212121212121212121";
|
||||
static uint8_t channel_key[32]={0x21,0x21,0x21,0x21,0x21,0x21,0x21,0x21,
|
||||
0x21,0x21,0x21,0x21,0x21,0x21,0x21,0x21};
|
||||
static std::string config(FleetChannel& fleet,const std::string& command){
|
||||
char reply[160]={};assert(fleet.handleConfig(command.c_str(),reply,sizeof(reply)));return reply;
|
||||
}
|
||||
static void enroll(FleetChannel& fleet,const LocalIdentity& publisher){
|
||||
assert(config(fleet,std::string("set fleet.channel ")+Key).rfind("OK",0)==0);
|
||||
char hex[65];Utils::toHex(hex,publisher.pub_key,32);
|
||||
assert(config(fleet,std::string("set fleet.controller ")+hex).rfind("OK",0)==0);
|
||||
}
|
||||
static Packet raw(const uint8_t* bytes,size_t length){
|
||||
Packet packet;Utils::sha256(packet.payload,1,channel_key,16);
|
||||
packet.payload_len=1+Utils::encryptThenMAC(channel_key,packet.payload+1,bytes,int(length));
|
||||
packet.radio_profile=1;packet.radio_generation=17;packet.path_hash_size=2;
|
||||
return packet;
|
||||
}
|
||||
static Packet command(const LocalIdentity& publisher,uint32_t sequence,const char* text="set radio2 off",
|
||||
const char* target="all"){
|
||||
uint8_t payload[184]={},destination[16];assert(FleetCommand::parseTarget(target,destination));
|
||||
size_t length=FleetCommand::encode(publisher,channel_key,sequence,sequence+300,destination,text,payload+3,sizeof(payload)-3);
|
||||
assert(length);payload[0]=uint8_t(FleetCommand::DataType);payload[1]=uint8_t(FleetCommand::DataType>>8);payload[2]=uint8_t(length);
|
||||
return raw(payload,3+length);
|
||||
}
|
||||
|
||||
static Packet signedUnchecked(const LocalIdentity& publisher,uint32_t sequence,uint32_t expires,
|
||||
const char* text){
|
||||
uint8_t data[184]={},message[160]={};size_t text_size=strlen(text);
|
||||
assert(text_size<=FleetCommand::MaxCommandLength);
|
||||
uint8_t* payload=data+3;memcpy(payload,"FMC1",4);
|
||||
storage::writeLE32(payload+4,sequence);storage::writeLE32(payload+8,expires);
|
||||
payload[28]=uint8_t(text_size);memcpy(payload+29,text,text_size);
|
||||
constexpr const char* domain="MeshCoreFleet1";const size_t domain_size=strlen(domain);
|
||||
memcpy(message,domain,domain_size);memcpy(message+domain_size,channel_key,16);
|
||||
memcpy(message+domain_size+16,payload,29+text_size);
|
||||
publisher.sign(payload+29+text_size,message,int(domain_size+16+29+text_size));
|
||||
data[0]=uint8_t(FleetCommand::DataType);data[1]=uint8_t(FleetCommand::DataType>>8);
|
||||
data[2]=uint8_t(29+text_size+64);return raw(data,3+data[2]);
|
||||
}
|
||||
|
||||
static void bind(FleetChannel& fleet,Mesh& mesh,RadioProfileCLI& profiles){
|
||||
mesh.copying=[&](Packet* second,const Packet* first){fleet.copy(second,first);};
|
||||
mesh.failure=[&](Packet* p){fleet.fail(p,profiles);};
|
||||
}
|
||||
static unsigned apply(FleetChannel& fleet,fs::FS& fs,Mesh& mesh,RadioProfileCLI& profiles,Packet& packet,
|
||||
bool mutation=false,bool fail_handler=false,const TransportKey* scope=nullptr){
|
||||
unsigned calls=0;fleet.receive(&packet,mesh,scope);
|
||||
fleet.service(mesh,profiles,"test:node",[&](uint32_t seq,const char* text,char* reply){
|
||||
++calls;assert(storage::readLE32(fs.files.at(Path).data()+56)==seq);
|
||||
assert(mesh.scoped_profile==1&&mesh.scoped_generation==17);assert(profiles.command);
|
||||
assert(!strcmp(text,"set radio2 off")||!strcmp(text,"get radio2.status"));
|
||||
if(mutation)profiles.stage();strcpy(reply,fail_handler?"Err - rejected":"OK");
|
||||
});
|
||||
assert(mesh.scoped_profile==0&&mesh.scoped_generation==0);assert(!profiles.command);return calls;
|
||||
}
|
||||
static void reseal(std::vector<uint8_t>& image){
|
||||
storage::writeLE32(image.data()+60,storage::updateCRC32(0xffffffff,image.data(),60));
|
||||
}
|
||||
int main(int argc,char** argv){
|
||||
assert(argc==2);const std::string scenario=argv[1];fs::FS fs;Mesh mesh;RadioProfileCLI profiles;
|
||||
FleetChannel fleet(&fs);LocalIdentity publisher;bind(fleet,mesh,profiles);
|
||||
if(scenario=="enrollment"){
|
||||
Packet packet=command(publisher,mesh.clock.now);assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
assert(config(fleet,"get fleet.channel").find("off,controller=unset,store=ok")!=std::string::npos);
|
||||
assert(config(fleet,"set fleet.channel 00000000000000000000000000000000").rfind("Err",0)==0);
|
||||
assert(config(fleet,"set fleet.channel 8b3387e9c5cdea6ac9e5edbaa115cd72").rfind("Err",0)==0);
|
||||
assert(config(fleet,"set fleet.controller 0000000000000000000000000000000000000000000000000000000000000000").rfind("Err",0)==0);
|
||||
assert(config(fleet,std::string("set fleet.channel ")+Key).rfind("OK",0)==0);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==0);enroll(fleet,publisher);
|
||||
const auto& image=fs.files.at(Path);assert(image.size()==64);
|
||||
assert(!memcmp(image.data()+24,publisher.pub_key,32));
|
||||
const std::vector<uint8_t> private_seed(32,0xAB);
|
||||
assert(std::search(image.begin(),image.end(),private_seed.begin(),private_seed.end())==image.end());
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==1);
|
||||
assert(config(fleet,"set fleet.controller off").rfind("OK",0)==0);
|
||||
++mesh.clock.now;packet=command(publisher,mesh.clock.now);assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
}else if(scenario=="deferred_single_mailbox"){
|
||||
enroll(fleet,publisher);Packet a=command(publisher,mesh.clock.now),b=command(publisher,mesh.clock.now+1);
|
||||
unsigned decrypt_before=decrypt_calls,verify_before=verify_calls,writes_before=fs.writes;
|
||||
fleet.receive(&a,mesh);fleet.receive(&b,mesh);
|
||||
assert(decrypt_calls==decrypt_before&&verify_calls==verify_before&&fs.writes==writes_before);
|
||||
assert(config(fleet,"get fleet.stats").find("busy=1 pending=1")!=std::string::npos);
|
||||
assert(config(fleet,"set fleet.channel off").rfind("Err",0)==0);
|
||||
unsigned calls=0;fleet.service(mesh,profiles,"node",[&](uint32_t seq,const char*,char* reply){
|
||||
++calls;assert(seq==mesh.clock.now);strcpy(reply,"OK");});assert(calls==1);
|
||||
assert(config(fleet,"get fleet.stats").find("accepted=1")!=std::string::npos);
|
||||
}else if(scenario=="durable_replay"){
|
||||
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet,false,true)==1);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==0);FleetChannel rebooted(&fs);
|
||||
assert(apply(rebooted,fs,mesh,profiles,packet)==0);
|
||||
++mesh.clock.now;packet=command(publisher,mesh.clock.now);assert(apply(rebooted,fs,mesh,profiles,packet)==1);
|
||||
}else if(scenario=="reserve_fault"){
|
||||
enroll(fleet,publisher);const auto previous=fs.files.at(Path);fs.fail_write=true;
|
||||
Packet packet=command(publisher,mesh.clock.now);assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
assert(fs.files.at(Path)==previous);fs.fail_write=false;
|
||||
assert(config(fleet,"get fleet.channel").find("store=error")!=std::string::npos);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==0);FleetChannel rebooted(&fs);
|
||||
assert(apply(rebooted,fs,mesh,profiles,packet)==1);
|
||||
}else if(scenario=="config_fault"){
|
||||
enroll(fleet,publisher);const auto previous=fs.files.at(Path);fs.fail_write=true;
|
||||
assert(config(fleet,"set fleet.channel off").rfind("Err",0)==0);
|
||||
assert(fs.files.at(Path)==previous);fs.fail_write=false;
|
||||
assert(config(fleet,"get fleet.channel").find("on,controller=set")!=std::string::npos);
|
||||
FleetChannel rebooted(&fs);Packet packet=command(publisher,mesh.clock.now);
|
||||
assert(apply(rebooted,fs,mesh,profiles,packet)==1);
|
||||
}else if(scenario=="images"){
|
||||
enroll(fleet,publisher);const auto original=fs.files.at(Path);
|
||||
for(unsigned damage=0;damage<7;++damage){
|
||||
fs.files[Path]=original;auto& image=fs.files[Path];
|
||||
if(damage==0)image.resize(63);else if(damage==1)image.push_back(0);
|
||||
else if(damage==2)image[0]='X';else if(damage==3)image[4]=2;
|
||||
else if(damage==4)image[5]=1;else if(damage==5)image[60]^=1;
|
||||
else memset(image.data()+8,0,16);
|
||||
if(damage>=3&&damage!=5)reseal(image);
|
||||
FleetChannel bad(&fs);assert(config(bad,"get fleet.channel").find("store=error")!=std::string::npos);
|
||||
Packet packet=command(publisher,mesh.clock.now);assert(apply(bad,fs,mesh,profiles,packet)==0);
|
||||
assert(config(bad,std::string("set fleet.channel ")+Key).rfind("Err",0)==0);
|
||||
}
|
||||
fs.files[Path]=original;fs.fail_read=true;FleetChannel unreadable(&fs);fs.fail_read=false;
|
||||
assert(config(unreadable,"get fleet.channel").find("store=error")!=std::string::npos);
|
||||
#if defined(RP2040_PLATFORM)
|
||||
fs.files.erase(Path);fs.files[std::string(Path)+".bak"]=original;
|
||||
FleetChannel recovered(&fs);assert(config(recovered,"get fleet.channel").find("store=ok")!=std::string::npos);
|
||||
assert(fs.files[Path]==original);Packet packet=command(publisher,mesh.clock.now);
|
||||
assert(apply(recovered,fs,mesh,profiles,packet)==1);
|
||||
#endif
|
||||
}else if(scenario=="ack_both_copies"){
|
||||
enroll(fleet,publisher);mesh.fanout=true;Packet packet=command(publisher,mesh.clock.now);
|
||||
TransportKey scope;memset(scope.key,0x12,sizeof(scope.key));
|
||||
packet.scoped=true;packet.transport_codes[0]=scope.calcTransportCode(&packet);packet.transport_codes[1]=456;
|
||||
assert(apply(fleet,fs,mesh,profiles,packet,true,false,&scope)==1);assert(fleet.waiting());
|
||||
assert(mesh.manager.outbound.size()==2);assert(mesh.ack_scoped&&mesh.ack_codes[1]==0);
|
||||
assert(mesh.ack_codes[0]==scope.calcTransportCode(mesh.manager.outbound[0]));
|
||||
assert(mesh.ack_codes[0]!=packet.transport_codes[0]);
|
||||
assert(mesh.ack_delay==1000&&mesh.ack_path_size==2&&last_jitter_max==15500);
|
||||
assert(mesh.manager.outbound[0]->radio_reply&&!mesh.manager.outbound[0]->radio_bound);
|
||||
assert(mesh.manager.outbound[0]->flood_retry_policy==FLOOD_RETRY_POLICY_DENY);
|
||||
fleet.receive(&packet,mesh);assert(config(fleet,"get fleet.stats").find("busy=1")!=std::string::npos);
|
||||
fleet.complete(mesh.manager.removeOutboundByIdx(0),profiles);assert(fleet.waiting());assert(profiles.commits==0);
|
||||
fleet.fail(mesh.manager.removeOutboundByIdx(0),profiles);assert(!fleet.waiting());assert(profiles.commits==1&&profiles.rollbacks==0);
|
||||
}else if(scenario=="ack_all_fail"){
|
||||
enroll(fleet,publisher);mesh.fanout=true;Packet packet=command(publisher,mesh.clock.now);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet,true)==1);
|
||||
fleet.fail(mesh.manager.removeOutboundByIdx(0),profiles);assert(fleet.waiting()&&profiles.rollbacks==0);
|
||||
fleet.fail(mesh.manager.removeOutboundByIdx(0),profiles);assert(!fleet.waiting());assert(profiles.rollbacks==1);
|
||||
}else if(scenario=="ack_queue_or_allocation"){
|
||||
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);mesh.queue_ok=false;
|
||||
assert(apply(fleet,fs,mesh,profiles,packet,true)==1);assert(!fleet.waiting()&&profiles.rollbacks==1);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet,true)==0);mesh.queue_ok=true;mesh.allocation_ok=false;
|
||||
++mesh.clock.now;packet=command(publisher,mesh.clock.now);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet,true)==1);assert(profiles.rollbacks==2);
|
||||
}else if(scenario=="ack_timeout"){
|
||||
enroll(fleet,publisher);mesh.fanout=true;Packet packet=command(publisher,mesh.clock.now);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet,true)==1);fake_ms+=300000;
|
||||
fleet.service(mesh,profiles,"node",[](uint32_t,const char*,char*){assert(false);});
|
||||
assert(mesh.manager.outbound.empty()&&mesh.released==2);assert(profiles.rollbacks==1&&!fleet.waiting());
|
||||
++mesh.clock.now;packet=command(publisher,mesh.clock.now);assert(apply(fleet,fs,mesh,profiles,packet,true)==1);
|
||||
mesh.in_flight=mesh.manager.removeOutboundByIdx(0);fake_ms+=300000;
|
||||
fleet.service(mesh,profiles,"node",[](uint32_t,const char*,char*){assert(false);});
|
||||
assert(mesh.cancelled==1&&fleet.waiting());fleet.fail(mesh.in_flight,profiles);mesh.in_flight=nullptr;
|
||||
assert(!fleet.waiting()&&profiles.rollbacks==2);
|
||||
}else if(scenario=="tamper_padding_target"){
|
||||
enroll(fleet,publisher);Packet good=command(publisher,mesh.clock.now);
|
||||
uint8_t data[184];int size=Utils::MACThenDecrypt(channel_key,data,good.payload+1,good.payload_len-1);
|
||||
data[3+29]^=1;Packet forged=raw(data,size);assert(apply(fleet,fs,mesh,profiles,forged)==0);
|
||||
size=Utils::MACThenDecrypt(channel_key,data,good.payload+1,good.payload_len-1);
|
||||
data[size-1]=1;Packet padded=raw(data,size);assert(apply(fleet,fs,mesh,profiles,padded)==0);
|
||||
Packet mac=good;mac.payload[1]^=1;assert(apply(fleet,fs,mesh,profiles,mac)==0);
|
||||
char target[65];uint8_t other[32];memset(other,0x23,sizeof(other));Utils::toHex(target,other,sizeof(other));
|
||||
Packet wrong=command(publisher,mesh.clock.now,"set radio2 off",target);assert(apply(fleet,fs,mesh,profiles,wrong)==0);
|
||||
fake_ms+=1000;Utils::toHex(target,mesh.self_id.pub_key,32);
|
||||
Packet mine=command(publisher,mesh.clock.now,"set radio2 off",target);assert(apply(fleet,fs,mesh,profiles,mine)==1);
|
||||
assert(last_jitter_max==1500);
|
||||
}else if(scenario=="verify_budget"){
|
||||
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);
|
||||
packet.payload[1]^=1;unsigned before=decrypt_calls;
|
||||
for(unsigned i=0;i<20;++i)assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
assert(decrypt_calls-before==4);fake_ms+=1000;packet=command(publisher,mesh.clock.now);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==1);
|
||||
}else if(scenario=="primary_denied"){
|
||||
assert(!FleetCommand::commandAllowed("set radio 915,500,5,5"));
|
||||
assert(!FleetCommand::commandAllowed("set tempradio 915,500,5,5,10"));
|
||||
assert(!FleetCommand::commandAllowed("set password pass"));
|
||||
assert(!FleetCommand::commandAllowed("reboot"));
|
||||
assert(!FleetCommand::commandAllowed("set fleet.controller off"));
|
||||
|
||||
}else if(scenario=="signed_forbidden"){
|
||||
enroll(fleet,publisher);
|
||||
for(const char* text:{"set radio 915,500,5,5","set password pass","reboot",
|
||||
"set fleet.channel off","get prv.key","set flood.max 5;reboot"}){
|
||||
fake_ms+=1000;Packet packet=signedUnchecked(publisher,mesh.clock.now,mesh.clock.now+300,text);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
}
|
||||
assert(storage::readLE32(fs.files[Path].data()+56)==0);assert(mesh.queued==0);
|
||||
}else if(scenario=="clock_limits"){
|
||||
enroll(fleet,publisher);const uint32_t now=mesh.clock.now;
|
||||
const std::vector<std::pair<uint32_t,uint32_t>> invalid={{now-301,now-1},{now+61,now+100},
|
||||
{FleetCommand::MinEpoch-1,now+100},{now,now+FleetCommand::MaxLifetime+1},{now,now-1}};
|
||||
for(const auto& times:invalid){fake_ms+=1000;
|
||||
Packet packet=signedUnchecked(publisher,times.first,times.second,"set radio2 off");
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
}
|
||||
mesh.clock.now=0;fake_ms+=1000;Packet packet=command(publisher,now);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==0);mesh.clock.now=now;
|
||||
fake_ms+=1000;packet=signedUnchecked(publisher,now+60,now+100,"set radio2 off");
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==1);
|
||||
}else if(scenario=="transaction_faults"){
|
||||
for(unsigned fault=0;fault<4;++fault){
|
||||
fs::FS disk;Mesh transport;RadioProfileCLI radio;FleetChannel receiver(&disk);enroll(receiver,publisher);
|
||||
auto previous=disk.files.at(Path);Packet packet=command(publisher,transport.clock.now);
|
||||
if(fault==0)disk.fail_open=true;
|
||||
else if(fault==1)disk.fail_read=true;
|
||||
else if(fault==2)disk.fail_rename=true;
|
||||
else disk.fail_rename_from=std::string(Path)+".tmp";
|
||||
assert(apply(receiver,disk,transport,radio,packet)==0);assert(disk.files.at(Path)==previous);
|
||||
assert(config(receiver,"get fleet.channel").find("store=error")!=std::string::npos);
|
||||
}
|
||||
}else if(scenario=="backup_replay"){
|
||||
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==1);const auto persisted=fs.files.at(Path);
|
||||
#if defined(RP2040_PLATFORM)
|
||||
fs.files.erase(Path);fs.files[std::string(Path)+".bak"]=persisted;
|
||||
#else
|
||||
// Atomic LittleFS replacement never has a live-name gap. Abandoned .tmp
|
||||
// bytes are ignored; the committed live sequence remains authoritative.
|
||||
fs.files[std::string(Path)+".tmp"]=persisted;
|
||||
#endif
|
||||
FleetChannel rebooted(&fs);assert(apply(rebooted,fs,mesh,profiles,packet)==0);
|
||||
assert(storage::readLE32(fs.files.at(Path).data()+56)==mesh.clock.now);
|
||||
|
||||
}else if(scenario=="wrong_publisher"){
|
||||
enroll(fleet,publisher);LocalIdentity stranger;
|
||||
assert(memcmp(stranger.pub_key,publisher.pub_key,32));
|
||||
Packet packet=command(stranger,mesh.clock.now);assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
assert(verify_calls==1&&mesh.queued==0&&storage::readLE32(fs.files[Path].data()+56)==0);
|
||||
}else if(scenario=="bounded_ack"){
|
||||
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);
|
||||
fleet.receive(&packet,mesh);fleet.service(mesh,profiles,"sixteen:character:very long name",
|
||||
[&](uint32_t,const char*,char* reply){memset(reply,'x',159);reply[159]=0;});
|
||||
assert(mesh.queued==1);uint8_t data[184]={};Packet* ack=mesh.manager.outbound[0];
|
||||
assert(ack->payload_len<=MAX_PACKET_PAYLOAD);
|
||||
assert(Utils::MACThenDecrypt(channel_key,data,ack->payload+1,ack->payload_len-1)==176);
|
||||
assert(!strncmp((char*)data+5,"sixteen;characte",15));
|
||||
}else if(scenario=="unknown_scope"){
|
||||
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);packet.scoped=true;
|
||||
packet.transport_codes[0]=123;assert(apply(fleet,fs,mesh,profiles,packet,true)==1);
|
||||
assert(mesh.queued==0&&mesh.released==1&&profiles.rollbacks==1&&!fleet.waiting());
|
||||
}else if(scenario=="rotated_scope"){
|
||||
struct Region {bool wildcard=false;bool isWildcard()const{return wildcard;}};
|
||||
struct Regions {TransportKey keys[2];int getTransportKeysFor(const Region&,TransportKey* out,int max){
|
||||
assert(max==MAX_TKS_ENTRIES);memcpy(out,keys,sizeof(keys));return 2;}} regions;
|
||||
memset(regions.keys[0].key,0x11,16);memset(regions.keys[1].key,0x22,16);
|
||||
Region region;TransportKey captured{};Packet packet=command(publisher,mesh.clock.now);
|
||||
packet.scoped=true;packet.transport_codes[0]=regions.keys[1].calcTransportCode(&packet);
|
||||
assert(packet.transport_codes[0]!=regions.keys[0].calcTransportCode(&packet));
|
||||
assert(captureFleetReplyScope(regions,®ion,&packet,captured));
|
||||
assert(!memcmp(captured.key,regions.keys[1].key,16));
|
||||
enroll(fleet,publisher);assert(apply(fleet,fs,mesh,profiles,packet,true,false,&captured)==1);
|
||||
assert(mesh.ack_codes[0]==regions.keys[1].calcTransportCode(mesh.manager.outbound[0]));
|
||||
assert(mesh.ack_codes[0]!=packet.transport_codes[0]);
|
||||
region.wildcard=true;assert(!captureFleetReplyScope(regions,®ion,&packet,captured));
|
||||
assert(!captureFleetReplyScope(regions,static_cast<Region*>(nullptr),&packet,captured));
|
||||
region.wildcard=false;packet.transport_codes[0]^=1;
|
||||
assert(!captureFleetReplyScope(regions,®ion,&packet,captured));
|
||||
}else if(scenario=="existing_mutation"){
|
||||
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);
|
||||
profiles.mutation=true;assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
assert(storage::readLE32(fs.files[Path].data()+56)==0);
|
||||
profiles.mutation=false;assert(apply(fleet,fs,mesh,profiles,packet)==1);
|
||||
}else assert(false);
|
||||
printf("fleet runtime %s passed\n",argv[1]);
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class FleetChannelRuntimeTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
if compiler is None:
|
||||
raise unittest.SkipTest("host C++ compiler required")
|
||||
cls.work = tempfile.TemporaryDirectory(prefix="meshcore-fleet-runtime-")
|
||||
work = Path(cls.work.name)
|
||||
(work / "helpers").mkdir()
|
||||
for name, content in {"Utils.h": UTILS, "FS.h": FILESYSTEM, "Mesh.h": MESH,
|
||||
"helpers/RadioProfileCLI.h": PROFILE,
|
||||
"Packet.h": '#pragma once\n#include <Mesh.h>\n',
|
||||
"Adafruit_LittleFS.h": '''
|
||||
#pragma once
|
||||
#include <FS.h>
|
||||
using Adafruit_LittleFS = fs::FS;
|
||||
namespace Adafruit_LittleFS_Namespace {}
|
||||
#define FILE_O_READ 1
|
||||
#define FILE_O_WRITE 2
|
||||
#define LFS_ERR_NOENT -2
|
||||
struct lfs_info {};
|
||||
inline int lfs_stat(fs::FS* fs,const char* path,lfs_info*) {return fs->exists(path)?0:LFS_ERR_NOENT;}
|
||||
''',
|
||||
"InternalFileSystem.h": '#pragma once\n#include <FS.h>\ninline fs::FS InternalFS;\n',
|
||||
"Arduino.h": '#pragma once\n#include <Mesh.h>\n'}.items():
|
||||
(work / name).write_text(content)
|
||||
fixture = work / "fixture.cpp"
|
||||
transport_source = (ROOT / "src/helpers/TransportKeyStore.cpp").read_text()
|
||||
methods = "\n".join(body(transport_source, signature) for signature in [
|
||||
"uint16_t TransportKey::calcTransportCode(", "bool TransportKey::isNull("])
|
||||
fixture.write_text(HARNESS.replace("@TRANSPORT_METHODS@", methods))
|
||||
sha_header = ROOT / "test/mocks/SHA256.h"
|
||||
(work / "SHA256.h").write_text(f'''
|
||||
#pragma once
|
||||
#define SHA256 SHA256Base
|
||||
#include "{sha_header}"
|
||||
#undef SHA256
|
||||
class SHA256 : public SHA256Base {{ public:
|
||||
void finalize(void* out,size_t length) {{ SHA256Base::finalize(static_cast<uint8_t*>(out),length); }}
|
||||
void finalizeHMAC(const uint8_t* key,size_t size,void* out,size_t length) {{
|
||||
SHA256Base::finalizeHMAC(key,size,static_cast<uint8_t*>(out),length); }}
|
||||
}};
|
||||
''')
|
||||
cls.binaries = {}
|
||||
sanitizer_flags = (["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie"]
|
||||
if os.environ.get("MESHCORE_FLEET_SANITIZERS") == "1" else [])
|
||||
for backend in ["RP2040", "NRF52"]:
|
||||
binary = work / f"fleet-runtime-{backend.lower()}"
|
||||
sources = [str(ROOT / "src/helpers/FleetChannel.cpp"),
|
||||
str(ROOT / "src/helpers/FleetCommand.cpp")]
|
||||
if backend == "NRF52":
|
||||
sources.append(str(ROOT / "src/helpers/AtomicFileWriter.cpp"))
|
||||
result = subprocess.run([
|
||||
compiler, *sanitizer_flags, "-std=c++17", f"-D{backend}_PLATFORM",
|
||||
"-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter",
|
||||
"-Wno-misleading-indentation", "-Wno-sign-compare", "-O2", "-g",
|
||||
"-I", str(work), "-isystem", str(ROOT / "test/mocks"),
|
||||
"-I", str(ROOT / "src"), str(fixture), *sources,
|
||||
"-lcrypto", "-o", str(binary),
|
||||
], capture_output=True, text=True, timeout=60)
|
||||
if result.returncode:
|
||||
cls.work.cleanup()
|
||||
raise AssertionError(f"{backend}:\n" + result.stdout + result.stderr)
|
||||
cls.binaries[backend] = binary
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.work.cleanup()
|
||||
|
||||
def scenario(self, name):
|
||||
for backend, binary in self.binaries.items():
|
||||
with self.subTest(backend=backend):
|
||||
result = subprocess.run([str(binary), name], capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_private_channel_and_public_only_publisher_enrollment(self):
|
||||
self.scenario("enrollment")
|
||||
|
||||
def test_receive_defers_crypto_writes_and_accepts_one_pending_command(self):
|
||||
self.scenario("deferred_single_mailbox")
|
||||
|
||||
def test_sequence_reserved_before_handler_including_error_survives_reboot(self):
|
||||
self.scenario("durable_replay")
|
||||
|
||||
def test_failed_sequence_save_grants_no_control_and_disables_receiver(self):
|
||||
self.scenario("reserve_fault")
|
||||
|
||||
def test_failed_configuration_write_preserves_durable_enrollment(self):
|
||||
self.scenario("config_fault")
|
||||
|
||||
def test_corrupt_short_long_unreadable_images_fail_closed_and_backup_recovers(self):
|
||||
self.scenario("images")
|
||||
|
||||
def test_ack_waits_for_both_profile_copies_with_one_success_and_retains_rx_scope(self):
|
||||
self.scenario("ack_both_copies")
|
||||
|
||||
def test_both_ack_transmissions_failing_roll_back_radio_mutation(self):
|
||||
self.scenario("ack_all_fail")
|
||||
|
||||
def test_ack_allocation_queue_failure_rolls_back_but_keeps_reserved_sequence(self):
|
||||
self.scenario("ack_queue_or_allocation")
|
||||
|
||||
def test_ack_timeout_cancels_queued_and_inflight_packets_before_rollback(self):
|
||||
self.scenario("ack_timeout")
|
||||
|
||||
def test_real_signature_mac_padding_and_full_identity_target_checks(self):
|
||||
self.scenario("tamper_padding_target")
|
||||
|
||||
def test_forged_packets_have_bounded_verification_cost(self):
|
||||
self.scenario("verify_budget")
|
||||
|
||||
def test_fleet_permission_never_allows_primary_radio_or_administrator_changes(self):
|
||||
self.scenario("primary_denied")
|
||||
|
||||
def test_publisher_signed_commands_still_cannot_escape_fleet_capability(self):
|
||||
self.scenario("signed_forbidden")
|
||||
|
||||
def test_expiry_future_clock_bounds_and_invalid_rtc_prevent_dispatch(self):
|
||||
self.scenario("clock_limits")
|
||||
|
||||
def test_open_read_rename_and_publish_faults_preserve_state_and_fail_closed(self):
|
||||
self.scenario("transaction_faults")
|
||||
|
||||
def test_recovered_backup_preserves_durable_anti_replay_sequence(self):
|
||||
self.scenario("backup_replay")
|
||||
|
||||
def test_another_valid_ed25519_identity_cannot_authorize_fleet_commands(self):
|
||||
self.scenario("wrong_publisher")
|
||||
|
||||
def test_long_cli_reply_and_sender_name_fit_one_encrypted_group_packet(self):
|
||||
self.scenario("bounded_ack")
|
||||
|
||||
def test_unknown_scoped_route_refuses_ack_and_rolls_back_radio_mutation(self):
|
||||
self.scenario("unknown_scope")
|
||||
|
||||
def test_scope_selects_matching_rotated_region_key_and_authenticates_new_ack_payload(self):
|
||||
self.scenario("rotated_scope")
|
||||
|
||||
def test_existing_remote_radio_mutation_blocks_fleet_without_reserving_sequence(self):
|
||||
self.scenario("existing_mutation")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,273 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <Ed25519.h>
|
||||
#include <helpers/FleetCommand.h>
|
||||
#include <SHA256.h>
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
using Fleet = mesh::FleetCommand;
|
||||
constexpr uint32_t Now = 1791500000UL;
|
||||
constexpr uint8_t ChannelKey[16] = {
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16
|
||||
};
|
||||
|
||||
class FixedRng : public mesh::RNG {
|
||||
public:
|
||||
void random(uint8_t* dest, size_t size) override {
|
||||
for (size_t i = 0; i < size; ++i) dest[i] = uint8_t(i + 1);
|
||||
}
|
||||
};
|
||||
|
||||
class FleetCommandTest : public ::testing::Test {
|
||||
protected:
|
||||
FixedRng rng;
|
||||
mesh::LocalIdentity publisher{&rng};
|
||||
std::array<uint8_t, Fleet::TargetSize> all{};
|
||||
Fleet::Decoded decoded{};
|
||||
bool previous_verifier = g_mock_ed25519_verify_result;
|
||||
|
||||
void SetUp() override { g_mock_ed25519_verify_result = true; }
|
||||
void TearDown() override { g_mock_ed25519_verify_result = previous_verifier; }
|
||||
|
||||
std::vector<uint8_t> encode(const char* command = "get radio2",
|
||||
uint32_t sequence = Now,
|
||||
uint32_t expires = Now + 120,
|
||||
const uint8_t* target = nullptr) {
|
||||
std::vector<uint8_t> bytes(Fleet::MaxPayloadLength);
|
||||
const size_t size = Fleet::encode(publisher, ChannelKey, sequence, expires,
|
||||
target ? target : all.data(), command, bytes.data(), bytes.size());
|
||||
bytes.resize(size);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
bool decode(const std::vector<uint8_t>& bytes, uint32_t now = Now) {
|
||||
return Fleet::decode(publisher, ChannelKey, bytes.data(), bytes.size(),
|
||||
now, publisher.pub_key, decoded);
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(FleetCommandTest, ExplicitRadioAndFilterFamiliesAreAllowed) {
|
||||
for (const char* command : {
|
||||
"get radio2", "get tempradio2", "get radio2.status", "get radio2.timing",
|
||||
"get radio2.cross", "set radio2.cross auto", "set radio2.cross on",
|
||||
"set radio2.cross off", "set radio2 off", "set tempradio2 off",
|
||||
"set radio2 910.5,500,5,5,rxtx",
|
||||
"set tempradio2 910.5,500,5,5,rx,10,auto",
|
||||
"set radio2 910.5,500,5,5,rx,32",
|
||||
"get flood.filter", "get flood.filter.1", "get flood.filter.blacklist.2",
|
||||
"set flood.filter.2 grp_txt 4+", "set flood.filter.blacklist A1,B2",
|
||||
"del flood.filter.blacklist.2", "del flood.filter all",
|
||||
"get flood.rule.2", "set flood.rule.2 type=any rate=10/min stop",
|
||||
"del flood.rule.2", "del flood.rule all",
|
||||
"get flood.moderation.2", "set flood.moderation public '*' rate=2/min",
|
||||
"del flood.moderation all", "get flood.channel.scope.3",
|
||||
"set flood.channel.scope public scope=usa", "del flood.channel.scope.3",
|
||||
"get flood.channel.scope.require.2",
|
||||
"set flood.channel.scope.require.2 #fleet", "del flood.channel.scope.require all",
|
||||
"get flood.channel.data", "set flood.channel.data on",
|
||||
"get flood.channel.data.hops", "set flood.channel.data.hops 4",
|
||||
"get flood.max", "set flood.max 8", "get flood.max.unscoped",
|
||||
"set flood.max.unscoped 4", "get flood.max.advert", "set flood.max.advert 6"}) {
|
||||
EXPECT_TRUE(Fleet::commandAllowed(command)) << command;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, NoGenericAdminNamespaceOrPrefixEscapes) {
|
||||
for (const char* command : {
|
||||
"get prv.key", "backup prv.key 1234567890ABCDEF", "get wifi.password",
|
||||
"set wifi.password abc", "setperm abc 3", "reboot", "start ota",
|
||||
"get fleet", "set fleet.channel abc", "region add usa", "get radio",
|
||||
"set radio 910.5,500,5,5", "get radio2.reply", "get radio2.scan",
|
||||
"set tx.reply both", "set radioat2 910.5,500,5,5,rxtx,+5",
|
||||
"get flood.filter.secret", "get flood.filtering", "get flood.rulebook",
|
||||
"get flood.rule.1.secret", "get flood.filter.blacklistx",
|
||||
"get flood.channel.scope.required", "get flood.channel.data.secret",
|
||||
"get flood.max.secret", "get flood.max.unscoped.extra", "get flood.rule.0",
|
||||
"get flood.rule.01", "get flood.rule.256", "get flood.rule.9999999999",
|
||||
"xy|set radio2 off", "GET radio2", "get radio2", " get radio2",
|
||||
"get radio2 ", "get radio2 off", "del radio2", "set radio2",
|
||||
"set radio2.cross true", "del flood.channel.data", "set flood.filter.2",
|
||||
"del flood.rule.2 extra", "get flood.rule.1 all", "set flood.max",
|
||||
"set radio2 910.5,500,5,5,rxtx,auto,99",
|
||||
"set radio2 910.5,500,5,5,off", "set radio2 910.5,500,5,5,rxtx,",
|
||||
"set tempradio2 910.5,500,5,5,rxtx",
|
||||
"set tempradio2 910.5,500,5,5,rxtx,+10",
|
||||
"set radio2 910.5,500,5,5,rxtx;reboot", "get radio2\nreboot",
|
||||
"get radio2\r", "get\tradio2", "get radio2|reboot", "get radio2&reboot",
|
||||
"set flood.rule.1 type=any `reboot`", "set flood.rule.1 type=any\\reboot"}) {
|
||||
EXPECT_FALSE(Fleet::commandAllowed(command)) << command;
|
||||
}
|
||||
const std::string non_ascii = "set flood.rule.1 " + std::string(1, char(0x80));
|
||||
EXPECT_FALSE(Fleet::commandAllowed(non_ascii.c_str()));
|
||||
EXPECT_FALSE(Fleet::commandAllowed(nullptr));
|
||||
EXPECT_FALSE(Fleet::commandAllowed(""));
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, OnlyCompletePublicKeyTargetsOrExplicitAll) {
|
||||
std::array<uint8_t, Fleet::TargetSize> target;
|
||||
target.fill(0xAA);
|
||||
EXPECT_TRUE(Fleet::parseTarget("all", target.data()));
|
||||
EXPECT_EQ(all, target);
|
||||
EXPECT_FALSE(Fleet::parseTarget("ALL", target.data()));
|
||||
EXPECT_FALSE(Fleet::parseTarget("1234", target.data()));
|
||||
EXPECT_FALSE(Fleet::parseTarget(std::string(64, '0').c_str(), target.data()));
|
||||
EXPECT_FALSE(Fleet::parseTarget(std::string(64, 'z').c_str(), target.data()));
|
||||
EXPECT_FALSE(Fleet::parseTarget(nullptr, target.data()));
|
||||
EXPECT_FALSE(Fleet::parseTarget("all", nullptr));
|
||||
const char* full = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
|
||||
ASSERT_TRUE(Fleet::parseTarget(full, target.data()));
|
||||
const std::array<uint8_t, 16> expected = {
|
||||
0xae, 0x21, 0x6c, 0x2e, 0xf5, 0x24, 0x7a, 0x37,
|
||||
0x82, 0xc1, 0x35, 0xef, 0xa2, 0x79, 0xa3, 0xe4
|
||||
};
|
||||
EXPECT_EQ(expected, target); // independently generated SHA-256 vector
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, RejectsPublicZeroOrMissingPrivacyKeys) {
|
||||
const uint8_t public_key[16] = {
|
||||
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
|
||||
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72
|
||||
};
|
||||
const uint8_t empty[16] = {};
|
||||
EXPECT_TRUE(Fleet::privateKeyAllowed(ChannelKey));
|
||||
EXPECT_FALSE(Fleet::privateKeyAllowed(public_key));
|
||||
EXPECT_FALSE(Fleet::privateKeyAllowed(empty));
|
||||
EXPECT_FALSE(Fleet::privateKeyAllowed(nullptr));
|
||||
std::array<uint8_t, Fleet::MaxPayloadLength> output;
|
||||
for (const uint8_t* key : {public_key, empty, static_cast<const uint8_t*>(nullptr)}) {
|
||||
EXPECT_EQ(0U, Fleet::encode(publisher, key, Now, Now + 10, all.data(),
|
||||
"get radio2", output.data(), output.size()));
|
||||
const auto bytes = encode();
|
||||
EXPECT_FALSE(Fleet::decode(publisher, key, bytes.data(), bytes.size(), Now,
|
||||
publisher.pub_key, decoded));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, ExactEnvelopeRoundTripsThroughVerifierBoundary) {
|
||||
const auto bytes = encode();
|
||||
ASSERT_EQ(Fleet::HeaderSize + 10 + Fleet::SignatureSize, bytes.size());
|
||||
EXPECT_EQ(std::string("FMC1"), std::string(bytes.begin(), bytes.begin() + 4));
|
||||
EXPECT_EQ(10, bytes[28]);
|
||||
const unsigned calls = g_mock_ed25519_verify_calls;
|
||||
ASSERT_TRUE(decode(bytes));
|
||||
EXPECT_EQ(calls + 1, g_mock_ed25519_verify_calls);
|
||||
EXPECT_EQ(Now, decoded.sequence);
|
||||
EXPECT_EQ(Now + 120, decoded.expires);
|
||||
EXPECT_STREQ("get radio2", decoded.command);
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, FailedSignatureVerificationNeverReturnsCommand) {
|
||||
const auto bytes = encode();
|
||||
g_mock_ed25519_verify_result = false;
|
||||
decoded.sequence = 123;
|
||||
strcpy(decoded.command, "stale");
|
||||
const unsigned calls = g_mock_ed25519_verify_calls;
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
EXPECT_EQ(calls + 1, g_mock_ed25519_verify_calls);
|
||||
EXPECT_EQ(0U, decoded.sequence);
|
||||
EXPECT_EQ(0U, decoded.expires);
|
||||
EXPECT_STREQ("", decoded.command);
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, FullPayloadLimitFitsWithoutOverflow) {
|
||||
const std::string prefix = "set flood.rule.1 ";
|
||||
const std::string maximum = prefix + std::string(Fleet::MaxCommandLength - prefix.size(), 'a');
|
||||
ASSERT_TRUE(Fleet::commandAllowed(maximum.c_str()));
|
||||
const auto bytes = encode(maximum.c_str());
|
||||
ASSERT_EQ(Fleet::MaxPayloadLength, bytes.size());
|
||||
EXPECT_TRUE(decode(bytes));
|
||||
EXPECT_STREQ(maximum.c_str(), decoded.command);
|
||||
EXPECT_FALSE(Fleet::commandAllowed((maximum + "a").c_str()));
|
||||
EXPECT_TRUE(encode((maximum + "a").c_str()).empty());
|
||||
std::array<uint8_t, Fleet::MaxPayloadLength + 2> output;
|
||||
output.fill(0xA5);
|
||||
EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 10, all.data(),
|
||||
maximum.c_str(), output.data() + 1, 164));
|
||||
for (uint8_t value : output) EXPECT_EQ(0xA5, value);
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, RejectsTruncationTrailingPaddingAndWrongMagic) {
|
||||
const auto original = encode();
|
||||
for (size_t size = 0; size < original.size(); ++size) {
|
||||
const std::vector<uint8_t> shortened(original.begin(), original.begin() + size);
|
||||
EXPECT_FALSE(decode(shortened)) << size;
|
||||
}
|
||||
auto bytes = original;
|
||||
bytes.push_back(0);
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
bytes = original;
|
||||
bytes[0] ^= 1;
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
bytes = original;
|
||||
bytes[28] = 0;
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
bytes[28] = 73;
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, RejectsEmbeddedNulAndForbiddenSignedCommandShape) {
|
||||
auto bytes = encode();
|
||||
bytes[Fleet::HeaderSize + 4] = 0;
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
bytes = encode();
|
||||
memcpy(bytes.data() + Fleet::HeaderSize, "get radio1", 10);
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
bytes = encode();
|
||||
bytes[Fleet::HeaderSize + 9] = '\n';
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, MatchesHashedCompleteSelfIdentityOrAll) {
|
||||
std::array<uint8_t, Fleet::TargetSize> target;
|
||||
SHA256 hash;
|
||||
hash.update(publisher.pub_key, PUB_KEY_SIZE);
|
||||
hash.finalize(target.data(), target.size());
|
||||
ASSERT_TRUE(decode(encode("get radio2", Now, Now + 120, target.data())));
|
||||
target[15] ^= 1;
|
||||
EXPECT_FALSE(decode(encode("get radio2", Now, Now + 120, target.data())));
|
||||
EXPECT_TRUE(decode(encode()));
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, RequiresPlausibleClockAndBoundedExpiry) {
|
||||
EXPECT_TRUE(encode("get radio2", Fleet::MinEpoch - 1, Fleet::MinEpoch + 10).empty());
|
||||
EXPECT_TRUE(encode("get radio2", Now, Now - 1).empty());
|
||||
EXPECT_TRUE(encode("get radio2", Now, Now + 601).empty());
|
||||
EXPECT_FALSE(decode(encode(), Fleet::MinEpoch - 1));
|
||||
EXPECT_TRUE(decode(encode(), Now + 120));
|
||||
EXPECT_FALSE(decode(encode(), Now + 121));
|
||||
EXPECT_TRUE(decode(encode("get radio2", Now + 60, Now + 120)));
|
||||
EXPECT_FALSE(decode(encode("get radio2", Now + 61, Now + 120)));
|
||||
EXPECT_TRUE(decode(encode("get radio2", Now, Now + 600)));
|
||||
EXPECT_TRUE(decode(encode("get radio2", Now, Now)));
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, RejectsMissingAndZeroIdentityInputs) {
|
||||
const auto bytes = encode();
|
||||
const uint8_t empty[32] = {};
|
||||
const mesh::Identity absent(empty);
|
||||
EXPECT_FALSE(Fleet::decode(absent, ChannelKey, bytes.data(), bytes.size(),
|
||||
Now, publisher.pub_key, decoded));
|
||||
EXPECT_FALSE(Fleet::decode(publisher, ChannelKey, bytes.data(), bytes.size(),
|
||||
Now, empty, decoded));
|
||||
EXPECT_FALSE(Fleet::decode(publisher, ChannelKey, bytes.data(), bytes.size(),
|
||||
Now, nullptr, decoded));
|
||||
EXPECT_FALSE(Fleet::decode(publisher, ChannelKey, nullptr, bytes.size(),
|
||||
Now, publisher.pub_key, decoded));
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, UnixRangeBoundaryDoesNotWrap) {
|
||||
const uint32_t maximum = UINT32_MAX;
|
||||
EXPECT_TRUE(decode(encode("get radio2", maximum, maximum), maximum - 60));
|
||||
EXPECT_FALSE(decode(encode("get radio2", maximum, maximum), maximum - 61));
|
||||
EXPECT_TRUE(encode("get radio2", maximum - 50, 1).empty());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise the production fleet envelope with real Ed25519, never native mocks."""
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
NOW = 1791500000
|
||||
CHANNEL = bytes(range(1, 17))
|
||||
DOMAIN = b"MeshCoreFleet1"
|
||||
PUBLISHER = Ed25519PrivateKey.from_private_bytes(bytes(range(1, 33)))
|
||||
PUBLIC = PUBLISHER.public_key().public_bytes(
|
||||
serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
||||
OTHER = Ed25519PrivateKey.from_private_bytes(bytes(range(33, 65)))
|
||||
OTHER_PUBLIC = OTHER.public_key().public_bytes(
|
||||
serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
||||
|
||||
|
||||
FIXTURE = r'''
|
||||
#include <helpers/FleetCommand.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <array>
|
||||
#include <cassert>
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// The production Identity interface is retained. OpenSSL implements its
|
||||
// signatures here so these tests cannot inherit native's always-true mock.
|
||||
namespace mesh {
|
||||
Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); }
|
||||
LocalIdentity::LocalIdentity(RNG* rng) {
|
||||
memset(prv_key, 0, sizeof(prv_key)); rng->random(prv_key, 32);
|
||||
EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, prv_key, 32);
|
||||
assert(key); size_t length = sizeof(pub_key);
|
||||
assert(EVP_PKEY_get_raw_public_key(key, pub_key, &length) == 1 && length == sizeof(pub_key));
|
||||
EVP_PKEY_free(key);
|
||||
}
|
||||
void LocalIdentity::sign(uint8_t* signature, const uint8_t* message, int length) const {
|
||||
EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, prv_key, 32);
|
||||
EVP_MD_CTX* context = EVP_MD_CTX_new(); assert(key && context);
|
||||
assert(EVP_DigestSignInit(context, nullptr, nullptr, nullptr, key) == 1);
|
||||
size_t size = SIGNATURE_SIZE;
|
||||
assert(EVP_DigestSign(context, signature, &size, message, size_t(length)) == 1);
|
||||
assert(size == SIGNATURE_SIZE); EVP_MD_CTX_free(context); EVP_PKEY_free(key);
|
||||
}
|
||||
bool Identity::verify(const uint8_t* signature, const uint8_t* message, int length) const {
|
||||
EVP_PKEY* key = EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, pub_key, sizeof(pub_key));
|
||||
EVP_MD_CTX* context = EVP_MD_CTX_new(); assert(key && context);
|
||||
assert(EVP_DigestVerifyInit(context, nullptr, nullptr, nullptr, key) == 1);
|
||||
const bool valid = EVP_DigestVerify(context, signature, SIGNATURE_SIZE, message, size_t(length)) == 1;
|
||||
EVP_MD_CTX_free(context); EVP_PKEY_free(key); return valid;
|
||||
}
|
||||
} // namespace mesh
|
||||
|
||||
struct FixedRng : mesh::RNG {
|
||||
void random(uint8_t* output, size_t size) override {
|
||||
for (size_t i = 0; i < size; ++i) output[i] = uint8_t(i + 1);
|
||||
}
|
||||
};
|
||||
|
||||
std::vector<uint8_t> unhex(const char* input) {
|
||||
const std::string text(input); assert(text.size() % 2 == 0);
|
||||
std::vector<uint8_t> bytes(text.size() / 2);
|
||||
for (size_t i = 0; i < bytes.size(); ++i)
|
||||
bytes[i] = uint8_t(std::stoul(text.substr(i * 2, 2), nullptr, 16));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
void hex(const uint8_t* bytes, size_t size) {
|
||||
constexpr char digits[] = "0123456789abcdef";
|
||||
for (size_t i = 0; i < size; ++i) std::cout << digits[bytes[i] >> 4] << digits[bytes[i] & 15];
|
||||
std::cout << '\n';
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
using Fleet = mesh::FleetCommand;
|
||||
if (argc == 7 && std::string(argv[1]) == "encode") {
|
||||
FixedRng rng; mesh::LocalIdentity publisher(&rng);
|
||||
const auto key = unhex(argv[2]); assert(key.size() == Fleet::KeySize);
|
||||
std::array<uint8_t, Fleet::TargetSize> target;
|
||||
if (!Fleet::parseTarget(argv[3], target.data())) { std::cout << "reject\n"; return 0; }
|
||||
std::array<uint8_t, Fleet::MaxPayloadLength> output;
|
||||
const size_t size = Fleet::encode(publisher, key.data(), uint32_t(std::stoul(argv[4])),
|
||||
uint32_t(std::stoul(argv[5])), target.data(), argv[6], output.data(), output.size());
|
||||
if (!size) std::cout << "reject\n"; else hex(output.data(), size);
|
||||
return 0;
|
||||
}
|
||||
if (argc == 7 && std::string(argv[1]) == "decode") {
|
||||
const auto public_key = unhex(argv[2]), key = unhex(argv[3]), self = unhex(argv[4]), bytes = unhex(argv[6]);
|
||||
assert(public_key.size() == PUB_KEY_SIZE && self.size() == PUB_KEY_SIZE && key.size() == Fleet::KeySize);
|
||||
mesh::Identity publisher(public_key.data()); Fleet::Decoded output;
|
||||
if (!Fleet::decode(publisher, key.data(), bytes.data(), bytes.size(),
|
||||
uint32_t(std::stoul(argv[5])), self.data(), output)) {
|
||||
assert(output.sequence == 0 && output.expires == 0 && output.command[0] == 0);
|
||||
std::cout << "reject\n";
|
||||
} else std::cout << output.sequence << '\n' << output.expires << '\n' << output.command << '\n';
|
||||
return 0;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def envelope(command="get radio2", sequence=NOW, expires=NOW + 120,
|
||||
target=b"\0" * 16, channel=CHANNEL, publisher=PUBLISHER):
|
||||
command = command.encode("ascii") if isinstance(command, str) else command
|
||||
unsigned = b"FMC1" + struct.pack("<II", sequence, expires) + target + bytes([len(command)]) + command
|
||||
return unsigned + publisher.sign(DOMAIN + channel + unsigned)
|
||||
|
||||
|
||||
class FleetCommandCryptoTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.directory = tempfile.TemporaryDirectory(prefix="meshcore-fleet-crypto-")
|
||||
cls.addClassCleanup(cls.directory.cleanup)
|
||||
path = Path(cls.directory.name)
|
||||
source = path / "fleet-crypto.cpp"
|
||||
source.write_text(FIXTURE)
|
||||
cls.binary = path / "fleet-crypto"
|
||||
build = subprocess.run([
|
||||
"g++", "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-Wno-unused-parameter", "-Wno-sign-compare",
|
||||
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
|
||||
"-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"),
|
||||
str(source), str(ROOT / "src/helpers/FleetCommand.cpp"),
|
||||
"-lcrypto", "-o", str(cls.binary)], capture_output=True, text=True)
|
||||
if build.returncode:
|
||||
raise AssertionError(build.stdout + build.stderr)
|
||||
|
||||
def run_tool(self, *arguments):
|
||||
result = subprocess.run([str(self.binary), *map(str, arguments)],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertNotIn("runtime error:", result.stderr)
|
||||
self.assertNotIn("AddressSanitizer", result.stderr)
|
||||
return result.stdout.strip()
|
||||
|
||||
def decode(self, payload, channel=CHANNEL, publisher=PUBLIC, self_key=PUBLIC, now=NOW):
|
||||
return self.run_tool("decode", publisher.hex(), channel.hex(), self_key.hex(), now, payload.hex())
|
||||
|
||||
def test_cpp_and_python_signatures_match_exactly(self):
|
||||
expected = envelope()
|
||||
actual = self.run_tool("encode", CHANNEL.hex(), "all", NOW, NOW + 120, "get radio2")
|
||||
self.assertEqual(actual, expected.hex())
|
||||
PUBLISHER.public_key().verify(expected[-64:], DOMAIN + CHANNEL + expected[:-64])
|
||||
self.assertEqual(self.decode(expected), f"{NOW}\n{NOW + 120}\nget radio2")
|
||||
|
||||
def test_python_signature_accepted_for_complete_hashed_target(self):
|
||||
target = hashlib.sha256(PUBLIC).digest()[:16]
|
||||
expected = envelope(target=target)
|
||||
actual = self.run_tool("encode", CHANNEL.hex(), PUBLIC.hex(), NOW, NOW + 120, "get radio2")
|
||||
self.assertEqual(actual, expected.hex())
|
||||
self.assertNotEqual(self.decode(expected), "reject")
|
||||
self.assertEqual(self.decode(expected, self_key=OTHER_PUBLIC), "reject")
|
||||
|
||||
def test_all_targets_reaches_another_receiver_without_other_private_key(self):
|
||||
self.assertNotEqual(self.decode(envelope(), self_key=OTHER_PUBLIC), "reject")
|
||||
|
||||
def test_wrong_publisher_and_forged_publisher_signature_rejected(self):
|
||||
self.assertEqual(self.decode(envelope(), publisher=OTHER_PUBLIC), "reject")
|
||||
self.assertEqual(self.decode(envelope(publisher=OTHER)), "reject")
|
||||
self.assertNotEqual(self.decode(envelope(publisher=OTHER), publisher=OTHER_PUBLIC), "reject")
|
||||
|
||||
def test_signature_is_bound_to_the_encryption_channel(self):
|
||||
different = bytes(range(17, 33))
|
||||
original = envelope()
|
||||
self.assertEqual(self.decode(original, channel=different), "reject")
|
||||
self.assertEqual(self.decode(envelope(channel=different)), "reject")
|
||||
self.assertNotEqual(self.decode(envelope(channel=different), channel=different), "reject")
|
||||
|
||||
def test_every_signature_byte_is_verified(self):
|
||||
original = envelope()
|
||||
for offset in range(len(original) - 64, len(original)):
|
||||
with self.subTest(signature_byte=offset):
|
||||
altered = bytearray(original)
|
||||
altered[offset] ^= 1
|
||||
self.assertEqual(self.decode(altered), "reject")
|
||||
|
||||
def test_tampered_command_that_remains_allowed_is_rejected(self):
|
||||
original = envelope(command="set flood.max 4")
|
||||
# Equal-length valid commands exercise cryptographic rejection, rather
|
||||
# than relying on the allowlist rejecting the changed text.
|
||||
other = envelope(command="set flood.max 5")
|
||||
forged = other[:-64] + original[-64:]
|
||||
self.assertEqual(self.decode(forged), "reject")
|
||||
|
||||
def test_every_header_field_and_target_is_signed(self):
|
||||
for changed in [
|
||||
envelope(sequence=NOW + 1)[:-64],
|
||||
envelope(expires=NOW + 121)[:-64],
|
||||
envelope(target=hashlib.sha256(PUBLIC).digest()[:16])[:-64],
|
||||
envelope(command="get tempradio2")[:-64]]:
|
||||
with self.subTest(header=changed.hex()):
|
||||
self.assertEqual(self.decode(changed + envelope()[-64:]), "reject")
|
||||
|
||||
def test_signed_forbidden_commands_and_multistatement_text_rejected(self):
|
||||
for command in ["get prv.key", "setperm 0102 3", "start ota", "reboot",
|
||||
"get radio2\nreboot", "xy|set radio2 off", "get radio2;reboot",
|
||||
"get radio2\0", "get flood.filter.secret", "get radio2.scan"]:
|
||||
with self.subTest(command=command):
|
||||
self.assertEqual(self.decode(envelope(command)), "reject")
|
||||
|
||||
def test_exact_and_oversized_command_boundaries(self):
|
||||
maximum = "set flood.rule.1 " + "a" * (72 - len("set flood.rule.1 "))
|
||||
accepted = envelope(maximum)
|
||||
self.assertEqual(len(accepted), 165)
|
||||
self.assertNotEqual(self.decode(accepted), "reject")
|
||||
self.assertEqual(self.decode(envelope(maximum + "a")), "reject")
|
||||
|
||||
def test_valid_signature_does_not_override_clock_policy(self):
|
||||
for sequence, expires, now in [
|
||||
(NOW, NOW - 1, NOW), (NOW, NOW + 601, NOW),
|
||||
(NOW + 61, NOW + 120, NOW), (NOW, NOW + 120, NOW + 121),
|
||||
(1735689599, 1735689600, 1735689600), (NOW, NOW + 120, 0)]:
|
||||
with self.subTest(sequence=sequence, expires=expires, now=now):
|
||||
self.assertEqual(self.decode(envelope(sequence=sequence, expires=expires), now=now), "reject")
|
||||
self.assertNotEqual(self.decode(envelope(sequence=NOW + 60, expires=NOW + 120)), "reject")
|
||||
self.assertNotEqual(self.decode(envelope(), now=NOW + 120), "reject")
|
||||
|
||||
def test_truncation_extra_padding_and_changed_length_rejected(self):
|
||||
original = envelope()
|
||||
for length in [0, 1, 28, 29, 92, len(original) - 1]:
|
||||
with self.subTest(length=length):
|
||||
self.assertEqual(self.decode(original[:length]), "reject")
|
||||
self.assertEqual(self.decode(original + b"\0"), "reject")
|
||||
altered = bytearray(original)
|
||||
altered[28] = 0
|
||||
self.assertEqual(self.decode(altered), "reject")
|
||||
|
||||
def test_default_public_key_cannot_form_fleet_privacy_channel(self):
|
||||
public_channel = bytes.fromhex("8b3387e9c5cdea6ac9e5edbaa115cd72")
|
||||
for channel in [b"\0" * 16, public_channel]:
|
||||
with self.subTest(channel=channel.hex()):
|
||||
self.assertEqual(self.decode(envelope(channel=channel), channel=channel), "reject")
|
||||
self.assertEqual(self.run_tool("encode", channel.hex(), "all", NOW, NOW + 120, "get radio2"), "reject")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,376 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute production role hooks around the separately tested fleet receiver."""
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from cpp_source import body
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
HARNESS = r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
#include <SHA256.h>
|
||||
#define MESH_ENABLE_CLOCK_SYNC 0
|
||||
#define MAX_TKS_ENTRIES 16
|
||||
static std::vector<int> events;
|
||||
namespace mesh {
|
||||
struct Packet {
|
||||
bool scoped = false;
|
||||
uint16_t transport_codes[2] = {};
|
||||
uint8_t payload[32] = {}, payload_len = 32, type = 5;
|
||||
bool hasTransportCodes() const { return scoped; }
|
||||
uint8_t getPayloadType() const { return type; }
|
||||
};
|
||||
struct Mesh {
|
||||
virtual ~Mesh() = default;
|
||||
virtual bool supportsFleetControl() const { return false; }
|
||||
virtual void onSendComplete(Packet*) { events.push_back(1); }
|
||||
virtual void onSendFail(Packet*) { events.push_back(3); }
|
||||
virtual void onRadioProfileCopyQueued(Packet*, const Packet*, uint8_t) { events.push_back(5); }
|
||||
};
|
||||
}
|
||||
struct TransportKey {
|
||||
uint8_t key[16] = {};
|
||||
uint16_t calcTransportCode(const mesh::Packet*) const;
|
||||
};
|
||||
struct Region { bool wildcard = false; bool isWildcard() const { return wildcard; } };
|
||||
struct Regions {
|
||||
std::vector<TransportKey> keys;
|
||||
unsigned calls = 0;
|
||||
int getTransportKeysFor(const Region&, TransportKey* out, int capacity) {
|
||||
++calls;
|
||||
const int count = std::min<int>(keys.size(), capacity);
|
||||
for (int i = 0; i < count; ++i) out[i] = keys[i];
|
||||
return count;
|
||||
}
|
||||
};
|
||||
@SCOPE_HELPERS@
|
||||
struct Profiles {};
|
||||
struct Fleet {
|
||||
unsigned received = 0, serviced = 0;
|
||||
bool scoped = false;
|
||||
TransportKey reply_scope;
|
||||
uint32_t sequence = 1735689610;
|
||||
char command[80] = "set radio2 off", reply[160] = {};
|
||||
void receive(mesh::Packet*, mesh::Mesh&, const TransportKey* scope) {
|
||||
++received; scoped = scope != nullptr;
|
||||
if (scope) reply_scope = *scope;
|
||||
}
|
||||
void complete(mesh::Packet*, Profiles&) { events.push_back(2); }
|
||||
void fail(mesh::Packet*, Profiles&) { events.push_back(4); }
|
||||
void copy(mesh::Packet*, const mesh::Packet*) { events.push_back(6); }
|
||||
template<typename Handler>
|
||||
void service(mesh::Mesh&, Profiles&, const char* name, Handler handler) {
|
||||
assert(!strcmp(name, "fleet node")); ++serviced;
|
||||
handler(sequence, command, reply);
|
||||
}
|
||||
};
|
||||
struct CLI {
|
||||
Fleet fleet;
|
||||
Profiles profiles;
|
||||
bool enrolled = true;
|
||||
Fleet* fleetChannel() { return enrolled ? &fleet : nullptr; }
|
||||
Profiles& radioProfiles() { return profiles; }
|
||||
};
|
||||
struct ClockSync {
|
||||
unsigned observed = 0;
|
||||
void observeGroupPacket(mesh::Packet*) { ++observed; }
|
||||
};
|
||||
class Repeater : public mesh::Mesh {
|
||||
public:
|
||||
CLI _cli;
|
||||
bool region_load_active = false;
|
||||
Regions region_map;
|
||||
Region region;
|
||||
Region* recv_pkt_region = ®ion;
|
||||
struct { const char* node_name = "fleet node"; } _prefs;
|
||||
unsigned commands = 0;
|
||||
uint32_t timestamp = 0;
|
||||
@REPEATER_CAPABILITY@
|
||||
void onGroupPacketRecv(mesh::Packet*);
|
||||
void serviceFleet();
|
||||
void handleCommand(uint32_t value, void* sender, char* command, char* reply) {
|
||||
assert(sender == nullptr && !strcmp(command, "set radio2 off"));
|
||||
timestamp = value; ++commands; strcpy(reply, "OK");
|
||||
}
|
||||
};
|
||||
class Room : public mesh::Mesh {
|
||||
public:
|
||||
CLI _cli;
|
||||
ClockSync _clock_sync;
|
||||
bool region_load_active = false;
|
||||
Regions region_map;
|
||||
Region region;
|
||||
Region* recv_pkt_region = ®ion;
|
||||
struct { const char* node_name = "fleet node"; } _prefs;
|
||||
unsigned commands = 0;
|
||||
uint32_t timestamp = 0;
|
||||
@ROOM_CAPABILITY@
|
||||
void onGroupPacketRecv(mesh::Packet*);
|
||||
void serviceFleet();
|
||||
#if MESH_ENABLE_FLEET_CONTROL
|
||||
void onSendComplete(mesh::Packet*) override;
|
||||
void onSendFail(mesh::Packet*) override;
|
||||
void onRadioProfileCopyQueued(mesh::Packet*, const mesh::Packet*, uint8_t) override;
|
||||
#endif
|
||||
void handleCommand(uint32_t value, char* command, char* reply) {
|
||||
assert(!strcmp(command, "set radio2 off"));
|
||||
timestamp = value; ++commands; strcpy(reply, "OK");
|
||||
}
|
||||
};
|
||||
@METHODS@
|
||||
|
||||
template<typename Role>
|
||||
void checkRole() {
|
||||
Role role;
|
||||
mesh::Packet packet;
|
||||
TransportKey original, rotated;
|
||||
memset(original.key, 0x11, sizeof(original.key));
|
||||
memset(rotated.key, 0x22, sizeof(rotated.key));
|
||||
role.region_map.keys = {original, rotated};
|
||||
packet.scoped = true;
|
||||
packet.transport_codes[0] = rotated.calcTransportCode(&packet);
|
||||
assert(role.supportsFleetControl());
|
||||
role.onGroupPacketRecv(&packet);
|
||||
role.serviceFleet();
|
||||
#if MESH_ENABLE_FLEET_CONTROL
|
||||
assert(role._cli.fleet.received == 1 && role._cli.fleet.serviced == 1);
|
||||
assert(role._cli.fleet.scoped);
|
||||
assert(!memcmp(role._cli.fleet.reply_scope.key, rotated.key, sizeof(rotated.key)));
|
||||
assert(role.commands == 1 && role.timestamp == role._cli.fleet.sequence);
|
||||
role.region_load_active = true;
|
||||
role.serviceFleet();
|
||||
assert(role.commands == 1);
|
||||
assert(strstr(role._cli.fleet.reply, "region load active"));
|
||||
#else
|
||||
assert(role._cli.fleet.received == 0 && role._cli.fleet.serviced == 0);
|
||||
assert(role.commands == 0);
|
||||
#endif
|
||||
role._cli.enrolled = false;
|
||||
const unsigned before = role._cli.fleet.serviced;
|
||||
role.onGroupPacketRecv(&packet);
|
||||
role.serviceFleet();
|
||||
assert(role._cli.fleet.serviced == before);
|
||||
}
|
||||
|
||||
static void checkScopeSelection() {
|
||||
Regions regions;
|
||||
Region region;
|
||||
mesh::Packet packet;
|
||||
TransportKey first, rotated, selected;
|
||||
memset(first.key, 0x31, sizeof(first.key));
|
||||
memset(rotated.key, 0xA4, sizeof(rotated.key));
|
||||
regions.keys = {first, rotated};
|
||||
assert(!mesh::captureFleetReplyScope(regions, ®ion, &packet, selected));
|
||||
assert(regions.calls == 0);
|
||||
packet.scoped = true;
|
||||
packet.payload[0] = 0x6D;
|
||||
packet.transport_codes[1] = rotated.calcTransportCode(&packet);
|
||||
assert(mesh::captureFleetReplyScope(regions, ®ion, &packet, selected));
|
||||
assert(!memcmp(selected.key, rotated.key, sizeof(rotated.key)));
|
||||
// Codes belong to the received payload. Changing it invalidates the match.
|
||||
packet.payload[0] ^= 1;
|
||||
assert(!mesh::captureFleetReplyScope(regions, ®ion, &packet, selected));
|
||||
packet.payload[0] ^= 1;
|
||||
packet.transport_codes[0] = first.calcTransportCode(&packet);
|
||||
packet.transport_codes[1] = 0;
|
||||
assert(mesh::captureFleetReplyScope(regions, ®ion, &packet, selected));
|
||||
assert(!memcmp(selected.key, first.key, sizeof(first.key)));
|
||||
region.wildcard = true;
|
||||
const unsigned calls = regions.calls;
|
||||
assert(!mesh::captureFleetReplyScope(regions, ®ion, &packet, selected));
|
||||
assert(!mesh::captureFleetReplyScope(regions, (const Region*)nullptr, &packet, selected));
|
||||
assert(!mesh::captureFleetReplyScope(regions, ®ion, nullptr, selected));
|
||||
assert(regions.calls == calls);
|
||||
}
|
||||
|
||||
int main() {
|
||||
checkScopeSelection();
|
||||
checkRole<Repeater>(); checkRole<Room>();
|
||||
Room room; mesh::Packet first, copy;
|
||||
room.onGroupPacketRecv(&first);
|
||||
assert(room._clock_sync.observed == 1); // Existing clock delivery survives.
|
||||
room.onSendComplete(&first);
|
||||
room.onSendFail(&first);
|
||||
room.onRadioProfileCopyQueued(©, &first, 7);
|
||||
#if MESH_ENABLE_FLEET_CONTROL
|
||||
assert((events == std::vector<int>{1,2,3,4,5,6}));
|
||||
#else
|
||||
assert((events == std::vector<int>{1,3,5}));
|
||||
#endif
|
||||
puts("Fleet role receive, deferred dispatch, busy guard and callbacks passed");
|
||||
}
|
||||
'''
|
||||
|
||||
AUTHORIZATION = r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
#include <initializer_list>
|
||||
static unsigned configurations = 0, allocations = 0;
|
||||
namespace mesh {
|
||||
struct FleetChannel {
|
||||
explicit FleetChannel(void*) { ++allocations; }
|
||||
bool handleConfig(const char*, char* reply, size_t capacity) {
|
||||
assert(capacity == 160); ++configurations; strcpy(reply, "OK"); return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
struct Callbacks {
|
||||
bool supported = true;
|
||||
bool supportsFleetControl() const { return supported; }
|
||||
};
|
||||
class CommonCLI {
|
||||
public:
|
||||
Callbacks callbacks;
|
||||
Callbacks* _callbacks = &callbacks;
|
||||
void* _management_fs = nullptr;
|
||||
mesh::FleetChannel* _fleet_channel = nullptr;
|
||||
~CommonCLI() { delete _fleet_channel; }
|
||||
bool handleFleetCommand(const char*, char*);
|
||||
};
|
||||
struct ClientInfo {
|
||||
enum Role { Guest, Admin, RegionManager, FilterManager } role;
|
||||
bool isAdmin() const { return role == Admin; }
|
||||
bool isRegionMgr() const { return role == RegionManager; }
|
||||
bool isFilterMgr() const { return role == FilterManager; }
|
||||
};
|
||||
@MANAGER_HELPERS@
|
||||
@COMMON_FLEET@
|
||||
static void dispatch(CommonCLI& cli, ClientInfo* sender, const char* command, char* reply) {
|
||||
@MANAGER_GUARD@
|
||||
if (!cli.handleFleetCommand(command, reply)) strcpy(reply, "unhandled");
|
||||
}
|
||||
int main() {
|
||||
CommonCLI cli;
|
||||
char reply[160] = {};
|
||||
ClientInfo admin{ClientInfo::Admin};
|
||||
for (const char* command : {"get fleet.channel", "get fleet.controller", "get fleet.stats",
|
||||
"set fleet.channel off", "set fleet.controller 010203"}) {
|
||||
for (auto role : {ClientInfo::Guest, ClientInfo::RegionManager, ClientInfo::FilterManager}) {
|
||||
ClientInfo sender{role};
|
||||
const unsigned before = configurations;
|
||||
dispatch(cli, &sender, command, reply);
|
||||
assert(!strcmp(reply, "Err - not permitted"));
|
||||
assert(configurations == before);
|
||||
assert(!isRegionMgrAllowed(command) && !isFilterMgrAllowed(command));
|
||||
}
|
||||
dispatch(cli, &admin, command, reply);
|
||||
assert(!strcmp(reply, "OK"));
|
||||
dispatch(cli, nullptr, command, reply);
|
||||
assert(!strcmp(reply, "OK"));
|
||||
}
|
||||
assert(allocations == 1 && configurations == 10);
|
||||
assert(isFilterMgrAllowed("set flood.filter.1 group-text 4"));
|
||||
assert(isRegionMgrAllowed("set flood.channel.scope public #local"));
|
||||
assert(!isFilterMgrAllowed("set radio2 off"));
|
||||
assert(!isRegionMgrAllowed("get password"));
|
||||
CommonCLI unsupported;
|
||||
unsupported.callbacks.supported = false;
|
||||
assert(unsupported.handleFleetCommand("set fleet.channel off", reply));
|
||||
assert(strstr(reply, "infrastructure roles"));
|
||||
assert(allocations == 1 && configurations == 10);
|
||||
assert(!cli.handleFleetCommand("set fleeter.channel off", reply));
|
||||
puts("Fleet enrollment remains restricted to local/admin infrastructure dispatch");
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class FleetRoleWiringTests(unittest.TestCase):
|
||||
def test_enrollment_common_cli_stays_behind_live_manager_permissions(self):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
self.assertIsNotNone(compiler)
|
||||
repeater = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text()
|
||||
helpers = "\n".join(body(repeater, signature) for signature in (
|
||||
"static bool commandFamilyMatches(", "static bool isCommonManagerReadOnlyAllowed(",
|
||||
"static bool isRegionMgrAllowed(", "static bool isFilterMgrAllowed(",
|
||||
))
|
||||
handler = body(repeater, "void MyMesh::handleCommand(uint32_t sender_timestamp,")
|
||||
guard_start = handler.rindex("if (sender && !sender->isAdmin())", 0,
|
||||
handler.index("mesh::cli::handleACLGet("))
|
||||
guard = body(handler[guard_start:], "if (sender && !sender->isAdmin())")
|
||||
common = body((ROOT / "src/helpers/CommonCLI_Management.cpp").read_text(),
|
||||
"bool CommonCLI::handleFleetCommand(")
|
||||
harness = AUTHORIZATION.replace("@MANAGER_HELPERS@", helpers)
|
||||
harness = harness.replace("@COMMON_FLEET@", common).replace("@MANAGER_GUARD@", guard)
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-fleet-authorization-") as directory:
|
||||
work = Path(directory)
|
||||
source = work / "fixture.cpp"
|
||||
source.write_text(harness)
|
||||
for extended_filters in (0, 1):
|
||||
with self.subTest(extended_filters=extended_filters):
|
||||
executable = work / str(extended_filters)
|
||||
built = subprocess.run([
|
||||
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-DMESH_ENABLE_FLOOD_RULE_ENGINE=" + str(extended_filters),
|
||||
str(source), "-o", str(executable),
|
||||
], capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
||||
checked = subprocess.run([str(executable)], capture_output=True,
|
||||
text=True, timeout=10)
|
||||
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
|
||||
def test_roles_with_feature_enabled_and_disabled(self):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
self.assertIsNotNone(compiler)
|
||||
methods = []
|
||||
capabilities = {}
|
||||
for role, folder in (("Repeater", "simple_repeater"), ("Room", "simple_room_server")):
|
||||
source = (ROOT / "examples" / folder / "MyMesh.cpp").read_text()
|
||||
header = (ROOT / "examples" / folder / "MyMesh.h").read_text()
|
||||
capabilities[role] = body(header, "bool supportsFleetControl() const override")
|
||||
methods.append(body(source, "void MyMesh::onGroupPacketRecv(").replace("MyMesh::", role + "::", 1))
|
||||
loop = body(source, "void MyMesh::loop()")
|
||||
fleet = body(loop, "if (_cli.fleetChannel())")
|
||||
methods.append("void " + role + "::serviceFleet() {\n#if MESH_ENABLE_FLEET_CONTROL\n"
|
||||
+ fleet + "\n#endif\n}\n")
|
||||
if role == "Room":
|
||||
for signature in ("void MyMesh::onSendComplete(", "void MyMesh::onSendFail(",
|
||||
"void MyMesh::onRadioProfileCopyQueued("):
|
||||
methods.append("#if MESH_ENABLE_FLEET_CONTROL\n"
|
||||
+ body(source, signature).replace("MyMesh::", role + "::", 1)
|
||||
+ "\n#endif\n")
|
||||
harness = HARNESS.replace("@METHODS@", "\n".join(methods))
|
||||
scope = body((ROOT / "src/helpers/FleetChannel.h").read_text(),
|
||||
"template <typename Regions, typename Region>")
|
||||
transport_code = body((ROOT / "src/helpers/TransportKeyStore.cpp").read_text(),
|
||||
"uint16_t TransportKey::calcTransportCode(")
|
||||
# The host SHA-256 shim has a byte-pointer output API; on-device Crypto
|
||||
# accepts void*. Preserve the production algorithm with that cast only.
|
||||
transport_code = transport_code.replace("&code, 2", "(uint8_t*)&code, 2")
|
||||
harness = harness.replace("@SCOPE_HELPERS@", transport_code + "\nnamespace mesh {\n" + scope + "\n}\n")
|
||||
harness = harness.replace("@REPEATER_CAPABILITY@", capabilities["Repeater"])
|
||||
harness = harness.replace("@ROOM_CAPABILITY@", capabilities["Room"])
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-fleet-role-") as directory:
|
||||
work = Path(directory)
|
||||
fixture = work / "fixture.cpp"
|
||||
fixture.write_text(harness)
|
||||
for enabled in (0, 1):
|
||||
with self.subTest(fleet_control=enabled):
|
||||
executable = work / str(enabled)
|
||||
built = subprocess.run([
|
||||
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-isystem", str(ROOT / "test/mocks"),
|
||||
"-DMESH_ENABLE_FLEET_CONTROL=" + str(enabled),
|
||||
str(fixture), "-o", str(executable),
|
||||
], capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
||||
checked = subprocess.run([str(executable)], capture_output=True,
|
||||
text=True, timeout=10)
|
||||
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -20,6 +20,7 @@ PREAMBLE = r'''
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#include <helpers/RadioProfileCommandUtils.h>
|
||||
#include <helpers/BatteryChargeCLI.h>
|
||||
#include "companion_fleet_dispatch.h"
|
||||
#define ESP32 1
|
||||
#define WIFI_SSID "test"
|
||||
#define WITH_MQTT_BRIDGE 1
|
||||
@@ -113,7 +114,7 @@ struct WebConfigServer {
|
||||
};
|
||||
const char* FIRMWARE_VERSION="test";
|
||||
const char* FIRMWARE_BUILD_DATE="test";
|
||||
struct MyMesh {
|
||||
struct MyMesh : CompanionFleetFixture {
|
||||
mesh::RadioProfileCLI _radio_profiles;
|
||||
Identity self_id;
|
||||
Store store; Store* _store=&store;
|
||||
@@ -163,6 +164,11 @@ SCENARIOS = r'''
|
||||
int main() {
|
||||
MyMesh node;
|
||||
char reply[160] = {};
|
||||
for (uint32_t timestamp : {0u, 100u}) {
|
||||
node.onCLICommandRecv(ContactInfo{}, nullptr, timestamp,
|
||||
"fleet send 1 all set radio2 off", reply);
|
||||
assert(strstr(reply, "requires local Companion access"));
|
||||
}
|
||||
assert(node.handleCommand("set charge.voltage 4.1",0,reply));
|
||||
assert(board.charge_target==4100 && strstr(reply,"saved"));
|
||||
for(uint32_t stamp:{0u,100u}) {
|
||||
@@ -365,6 +371,7 @@ int main() {
|
||||
binary = Path(temp) / "test"
|
||||
result = subprocess.run([
|
||||
"c++", "-std=c++17", "-x", "c++", "-", "-I"+str(ROOT / "src"),
|
||||
"-I"+str(ROOT / "test/fixtures"),
|
||||
"-DENABLE_PRIVATE_KEY_EXPORT="+str(export),
|
||||
*(["-DWITH_WEBCONFIG=1"] if webconfig else []),
|
||||
*SANITIZER_FLAGS, "-o", str(binary),
|
||||
|
||||
Reference in new issue
Block a user