Files
MeshCore/test/test_fleet_clock_control.py
T

364 lines
16 KiB
Python

#!/usr/bin/env python3
"""Deliver real signed fleet requests to the production CommonCLI clock branches.
Receiver, codec and anti-replay storage run unchanged. Shared fleet-runtime
fixtures provide hardware I/O and real OpenSSL Ed25519/AES/MAC interfaces. The
CommonCLI setter and manual-clock hook ordering are extracted from production;
only the RTC peripheral and reply date formatting are adapted for the host.
"""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from cpp_source import body
from test_fleet_channel_runtime import FILESYSTEM, HARNESS as RUNTIME, MESH, PROFILE, REGION_MAP, UTILS
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <ctime>
// Date formatting is a host peripheral adapter; it never mutates the RTC.
class DateTime {
std::tm value_{};
public:
explicit DateTime(uint32_t seconds) {
const std::time_t epoch = seconds;
const std::tm* value = std::gmtime(&epoch);
assert(value); value_ = *value;
}
int hour() const { return value_.tm_hour; }
int minute() const { return value_.tm_min; }
int day() const { return value_.tm_mday; }
int month() const { return value_.tm_mon + 1; }
int year() const { return value_.tm_year + 1900; }
};
@ATOI@
struct ManualClockCallbacks {
Clock& clock;
unsigned hooks = 0;
uint32_t observed = 0;
void onManualClockSet() {
assert(clock.sets == hooks + 1); // The production hook follows the setter.
++hooks; observed = clock.getCurrentTime();
}
};
class CommonCLI {
Clock& rtc_;
public:
ManualClockCallbacks callbacks;
ManualClockCallbacks* _callbacks = &callbacks;
explicit CommonCLI(Clock& rtc) : rtc_(rtc), callbacks{rtc} {}
Clock* getRTCClock() { return &rtc_; }
void clockCommand(uint32_t sender_timestamp, const char* command, char* reply) {
@CLOCK_BRANCHES@
else assert(false && "fleet clock dispatch accepted an unexpected command");
}
};
struct Fixture {
fs::FS disk;
Mesh mesh;
RadioProfileCLI profiles;
FleetChannel fleet{&disk};
LocalIdentity publisher;
CommonCLI cli{mesh.clock};
unsigned dispatched = 0;
char reply[160] = {};
Fixture() { enroll(fleet, publisher); bind(fleet, mesh, profiles); }
unsigned offer(Packet& packet, FleetChannel* receiver = nullptr) {
fake_ms += 1000; // Admission checks, rather than rate throttling, decide each case.
const unsigned before = dispatched;
FleetChannel& destination = receiver ? *receiver : fleet;
destination.receive(&packet, mesh);
destination.service(mesh, profiles, "clock node",
[&](uint32_t sequence, const char* command_text, char* response) {
++dispatched;
assert(storage::readLE32(disk.files.at(Path).data() + 56) == sequence);
assert(profiles.command);
cli.clockCommand(sequence, command_text, response);
strcpy(reply, response);
});
return dispatched - before;
}
void unchanged(Packet& packet) {
const uint32_t now = mesh.clock.now;
const unsigned setters = mesh.clock.sets, hooks = cli.callbacks.hooks;
const auto saved = disk.files.at(Path);
assert(offer(packet) == 0);
assert(mesh.clock.now == now && mesh.clock.sets == setters);
assert(cli.callbacks.hooks == hooks && disk.files.at(Path) == saved);
}
};
int main(int argc, char** argv) {
assert(argc == 2);
const std::string scenario = argv[1];
Fixture f;
const uint32_t initial = f.mesh.clock.now;
if (scenario == "clock_read") {
Packet packet = command(f.publisher, initial, "clock");
assert(f.offer(packet) == 1);
assert(strstr(f.reply, "UTC") && !strstr(f.reply, "clock set"));
assert(f.mesh.clock.now == initial && f.mesh.clock.sets == 0);
assert(f.cli.callbacks.hooks == 0);
assert(storage::readLE32(f.disk.files.at(Path).data() + 56) == initial);
} else if (scenario == "sync_forward") {
const uint32_t sender_time = initial + 5;
Packet packet = command(f.publisher, sender_time, "clock sync");
assert(f.offer(packet) == 1);
assert(f.mesh.clock.now == sender_time + 1);
assert(f.mesh.clock.sets == 1 && f.cli.callbacks.hooks == 1);
assert(f.cli.callbacks.observed == sender_time + 1);
assert(!strncmp(f.reply, "OK - clock set:", 15));
assert(storage::readLE32(f.disk.files.at(Path).data() + 56) == sender_time);
} else if (scenario == "sync_uint32_boundary") {
// Fleet envelopes require a later expiry and cannot carry this timestamp.
// Exercise the production CommonCLI branch directly to cover other CLI
// transports as well: adding one to UINT32_MAX must never reset the RTC.
const auto saved = f.disk.files.at(Path);
f.mesh.clock.now = UINT32_MAX - 1;
f.cli.clockCommand(UINT32_MAX, "clock sync", f.reply);
assert(f.mesh.clock.now == UINT32_MAX - 1);
assert(f.mesh.clock.sets == 0 && f.cli.callbacks.hooks == 0);
assert(strstr(f.reply, "ERR"));
assert(f.disk.files.at(Path) == saved && f.dispatched == 0);
// The adjacent representable result remains a valid forward update.
f.mesh.clock.now = UINT32_MAX - 2;
f.cli.clockCommand(UINT32_MAX - 1, "clock sync", f.reply);
assert(f.mesh.clock.now == UINT32_MAX);
assert(f.mesh.clock.sets == 1 && f.cli.callbacks.hooks == 1);
assert(f.cli.callbacks.observed == UINT32_MAX);
assert(!strncmp(f.reply, "OK - clock set:", 15));
} else if (scenario == "explicit_forward") {
const uint32_t requested = initial + 20;
const std::string text = "time " + std::to_string(requested);
Packet packet = command(f.publisher, initial, text.c_str());
assert(f.offer(packet) == 1);
assert(f.mesh.clock.now == requested);
assert(f.mesh.clock.sets == 1 && f.cli.callbacks.hooks == 1);
assert(f.cli.callbacks.observed == requested);
assert(!strncmp(f.reply, "OK - clock set:", 15));
// The signed request sequence, rather than the requested clock value,
// remains the durable anti-replay identity.
assert(storage::readLE32(f.disk.files.at(Path).data() + 56) == initial);
} else if (scenario == "backward_and_equal") {
Packet sync = command(f.publisher, initial - 1, "clock sync");
assert(f.offer(sync) == 1);
assert(strstr(f.reply, "clock cannot go backwards"));
Packet equal = command(f.publisher, initial, "clock sync");
assert(f.offer(equal) == 1);
assert(strstr(f.reply, "clock cannot go backwards"));
const std::string backward = "time " + std::to_string(initial - 1);
Packet earlier = command(f.publisher, initial + 1, backward.c_str());
assert(f.offer(earlier) == 1);
assert(strstr(f.reply, "clock cannot go backwards"));
const std::string same = "time " + std::to_string(initial);
Packet unchanged = command(f.publisher, initial + 2, same.c_str());
assert(f.offer(unchanged) == 1);
assert(strstr(f.reply, "clock cannot go backwards"));
assert(f.mesh.clock.now == initial && f.mesh.clock.sets == 0);
assert(f.cli.callbacks.hooks == 0);
} else if (scenario == "signatures_and_target") {
const std::string text = "time " + std::to_string(initial + 20);
Packet valid = command(f.publisher, initial, text.c_str());
uint8_t plain[184] = {};
const int size = Utils::MACThenDecrypt(channel_key, plain, valid.payload + 1,
valid.payload_len - 1);
assert(size > 3 && plain[2] > FleetCommand::SignatureSize);
plain[3 + plain[2] - 1] ^= 1;
Packet invalid_signature = raw(plain, 3 + plain[2]);
const unsigned before = verify_calls;
f.unchanged(invalid_signature);
assert(verify_calls == before + 1); // MAC was valid; Ed25519 denied control.
LocalIdentity stranger;
Packet wrong_publisher = command(stranger, initial, text.c_str());
f.unchanged(wrong_publisher);
Packet wrong_target = command(f.publisher, initial, text.c_str(), publicHex(stranger).c_str());
f.unchanged(wrong_target);
assert(f.offer(valid) == 1); // Rejected requests did not reserve the sequence.
assert(f.mesh.clock.now == initial + 20 && f.cli.callbacks.hooks == 1);
} else if (scenario == "durable_replay") {
Packet packet = command(f.publisher, initial + 5, "clock sync");
assert(f.offer(packet) == 1);
f.unchanged(packet);
FleetChannel rebooted(&f.disk);
const unsigned before = f.dispatched;
const auto saved = f.disk.files.at(Path);
assert(f.offer(packet, &rebooted) == 0);
assert(f.dispatched == before && f.mesh.clock.now == initial + 6);
assert(f.mesh.clock.sets == 1 && f.cli.callbacks.hooks == 1);
assert(f.disk.files.at(Path) == saved);
} else if (scenario == "invalid_rtc") {
Packet packet = command(f.publisher, initial + 5, "clock sync");
for (uint32_t invalid : {0u, FleetCommand::MinEpoch - 1}) {
f.mesh.clock.now = invalid;
f.unchanged(packet);
}
assert(f.dispatched == 0 && f.mesh.clock.sets == 0);
f.mesh.clock.now = initial;
assert(f.offer(packet) == 1);
assert(f.mesh.clock.now == initial + 6 && f.cli.callbacks.hooks == 1);
} else if (scenario == "expired_and_future") {
Packet expired = signedUnchecked(f.publisher, initial - 300, initial - 1, "clock sync");
f.unchanged(expired);
Packet future = command(f.publisher, initial + FleetCommand::MaxClockLead + 1, "clock sync");
f.unchanged(future);
assert(f.dispatched == 0 && f.mesh.clock.sets == 0 && f.cli.callbacks.hooks == 0);
Packet current = command(f.publisher, initial + 1, "clock sync");
assert(f.offer(current) == 1);
assert(f.mesh.clock.now == initial + 2 && f.cli.callbacks.hooks == 1);
} else if (scenario == "signed_invalid_clock_forms") {
for (const char* text : {"get clock", "set clock", "clock sync extra", "time NaN",
"time -1", "time 4294967296", "time 1735689599"}) {
Packet packet = signedUnchecked(f.publisher, initial, initial + 300, text);
f.unchanged(packet);
}
assert(f.dispatched == 0 && f.mesh.clock.sets == 0 && f.cli.callbacks.hooks == 0);
} else assert(false);
printf("Fleet clock scenario %s passed\n", argv[1]);
}
'''
class FleetClockControlTests(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-clock-")
work = Path(cls.work.name)
(work / "helpers").mkdir()
clock_getter = "uint32_t getCurrentTime() const{return now;}"
assert MESH.count(clock_getter) == 1
mesh = MESH.replace(clock_getter, clock_getter + "\n unsigned sets=0;"
" void setCurrentTime(uint32_t value){++sets;now=value;}")
for name, content in {
"Utils.h": UTILS, "FS.h": FILESYSTEM, "Mesh.h": mesh,
"helpers/RadioProfileCLI.h": PROFILE,
"helpers/RegionMap.h": REGION_MAP,
"Packet.h": '#pragma once\n#include <Mesh.h>\n',
"Arduino.h": '#pragma once\n#include <Mesh.h>\n',
"Adafruit_LittleFS.h": r'''
#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',
}.items():
(work / name).write_text(content)
sha = ROOT / "test/mocks/SHA256.h"
(work / "SHA256.h").write_text(f'''
#pragma once
#define SHA256 SHA256Base
#include "{sha}"
#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); }}
}};
''')
common = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
# The ESP-only *next* command opens its #if before the clock-sync
# branch's closing brace. Separate that following command's guard from
# the unconditional clock body without altering any clock statements.
sync_start = common.index('if (memcmp(command, "clock sync", 10) == 0)')
next_guard = '#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)'
guard_start = common.index(next_guard, sync_start)
assert common[guard_start + len(next_guard):].startswith(
'\n } else if (memcmp(command, "start webconfig", 15) == 0')
clock_source = common[:guard_start] + common[guard_start + len(next_guard):]
branches = "\nelse ".join(body(clock_source, signature) for signature in (
'if (memcmp(command, "clock sync", 10) == 0)',
'if (memcmp(command, "clock", 5) == 0)',
'if (memcmp(command, "time ", 5) == 0)',
))
clock_fixture = HARNESS.replace("@ATOI@", body(common, "static uint32_t _atoi("))
clock_fixture = clock_fixture.replace("@CLOCK_BRANCHES@", branches)
transport = (ROOT / "src/helpers/TransportKeyStore.cpp").read_text()
transport_methods = "\n".join(body(transport, signature) for signature in (
"uint16_t TransportKey::calcTransportCode(", "bool TransportKey::isNull(",
))
shared = RUNTIME[:RUNTIME.index("static unsigned apply(")]
fixture = work / "fixture.cpp"
fixture.write_text(shared.replace("@TRANSPORT_METHODS@", transport_methods) + clock_fixture)
sanitizer_flags = (["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie"]
if os.environ.get("MESHCORE_FLEET_SANITIZERS") == "1" else [])
cls.binaries = {}
for backend in ("RP2040", "NRF52"):
binary = work / ("fleet-clock-" + 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"))
built = subprocess.run([
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-function", "-Wno-unused-parameter", "-Wno-misleading-indentation", "-Wno-sign-compare",
"-O2", *sanitizer_flags, "-D" + backend + "_PLATFORM",
"-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 built.returncode:
cls.work.cleanup()
raise AssertionError(backend + ":\n" + built.stdout + built.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_clock_getter_preserves_rtc_and_durably_consumes_request(self):
self.scenario("clock_read")
def test_clock_sync_uses_signed_sender_utc_plus_one_and_calls_manual_hook(self):
self.scenario("sync_forward")
def test_clock_sync_uint32_boundary_never_wraps_rtc_or_fires_manual_hook(self):
self.scenario("sync_uint32_boundary")
def test_explicit_forward_time_calls_production_setter_and_hook(self):
self.scenario("explicit_forward")
def test_backward_and_equal_clock_requests_never_set_rtc_or_call_hook(self):
self.scenario("backward_and_equal")
def test_invalid_signature_publisher_and_target_cannot_change_clock(self):
self.scenario("signatures_and_target")
def test_replayed_clock_change_is_denied_before_and_after_receiver_reboot(self):
self.scenario("durable_replay")
def test_invalid_or_unset_rtc_has_no_signed_clock_bootstrap_exception(self):
self.scenario("invalid_rtc")
def test_expired_and_excessively_future_clock_envelopes_never_dispatch(self):
self.scenario("expired_and_future")
def test_valid_signature_never_grants_malformed_clock_commands(self):
self.scenario("signed_invalid_clock_forms")
if __name__ == "__main__":
unittest.main()