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:
mikecarper committed 2026-10-08 16:45:19 -07:00
1 parent a14bbdc58f
commit d83fa7cf8e
27 files changed
+3028 -2

No files matched your search

+7
View File
@@ -101,6 +101,13 @@ jobs:
- name: Run Unit Tests
run: pio test -e native -e native_kiss_modem -e native_ota_channel -vv
- name: Verify signed private fleet management
run: |
python3 -B test/test_fleet_command_crypto.py -v
python3 -B test/test_companion_fleet_control.py -v
python3 -B test/test_fleet_channel_runtime.py -v
python3 -B test/test_fleet_role_wiring.py -v
- name: Verify OTA channel persistence and published image tags
run: |
python3 -B test/test_ota_channel_persistence.py -v
+1
View File
@@ -3,6 +3,7 @@
* **[Two LoRa profiles on one device](https://mikecarper.github.io/MeshCore/radio_profiles/).** Time-share one transceiver between two frequencies and/or modulation settings. Choose transmission profiles per contact or channel, enable or isolate crossover traffic, schedule profiles, and use a temporary update profile alongside the normal network. Includes timing calibration and separate R2 screen pages.
* **[Firmware updates over LoRa](https://mikecarper.github.io/MeshCore/lora_ota_automation/).** Update repeaters, room servers, and sensors over LoRa on supported firmware and storage layouts.
* **[Repeater forwarding policies](https://mikecarper.github.io/MeshCore/flood_filtering/).** Actions include dropping, rate limiting, assigning scopes, selecting retries, and controlling forwarding priority. Separate policies cover ordinary radio forwarding, bridges, and profile crossover.
* **[Private fleet management](https://mikecarper.github.io/MeshCore/fleet_management/).** Deploy filtering and secondary-radio commands to a whole fleet or one node. The publisher signs commands; receivers hold its public verification key, with replay protection and acknowledgements before radio handoff.
* **[Flood retries that account for network topology](https://mikecarper.github.io/MeshCore/halo_keymind_settings/#flood-and-advert-settings).** Configure retry counts, path limits, packet-type limits, target prefixes, ignored repeaters, and channel-specific eligibility. Bridge buckets keep retrying until traffic is heard on the intended sides of a relay or the retry budget expires. Qualifying echoes cancel unnecessary attempts.
* **[Enhanced direct messages and direct-route retries](https://mikecarper.github.io/MeshCore/halo_keymind_settings/#direct-retry-settings).** Adapt the coding rate across bounded retry attempts and cancel unnecessary retries when a qualifying echo is heard. [Single Key DMs](https://mikecarper.github.io/MeshCore/companion_protocol/#private-text-when-only-the-sender-knows-the-recipient) let compatible Companions send private text when only the sender knows the recipient's key, using a signed, encrypted introduction. The recipient accepts the sender through the app or opts into automatic acceptance; compact STM32WL builds omit this feature.
* **[Enhanced telemetry history](https://mikecarper.github.io/MeshCore/telemetry_decoder/).** Send compact temperature and battery-voltage snapshots of up to 165 samples per packet, plus external-voltage history in chunks. Read retained GPS history through the CLI; GPS history is not included in the automatic telemetry packets. Decode snapshots and CLI history in the browser.
+179
View File
@@ -0,0 +1,179 @@
---
layout: default
title: Private fleet management
---
<!-- meshcore-hosted-doc-link:start -->
<p class="meshcore-hosted-doc-link"><a href="https://mikecarper.github.io/MeshCore/fleet_management/">View this page on MeshCore Docs</a>.</p>
<!-- meshcore-hosted-doc-link:end -->
# Private fleet management
Fleet control lets one publisher send filtering and secondary-radio commands to
all enrolled infrastructure nodes, or to one node, over a private LoRa channel.
It is disabled until configured. It is supported on repeaters and room servers;
fixed-size STM32 images omit it because of their flash limit.
Both the publishing Companion and receivers need firmware built with fleet-control
support; the existing app can use its local Command Line screen.
## Read-only receivers
Commands use two separate credentials:
- A random **128-bit channel key** encrypts the traffic. Receivers need this key
to read the private channel.
- The publisher's **Ed25519 public key** verifies commands. Only the publisher
holds the corresponding private key and can authorize changes.
Giving a repeater the channel key and publisher public key does **not** give it
the publisher's signing key. An ordinary channel message, a copied display name,
or possession of the channel key cannot authorize a fleet command. Each command
signature also binds the channel, target, sequence, expiry, and exact command.
This makes management commands read-only for receivers. It does not turn ordinary
MeshCore chat into a signed, read-only announcement channel: stock apps do not
verify signatures on ordinary channel text. Fleet acknowledgements are convenient
channel text, not authenticated inventory evidence. Use existing individually
authenticated administration for sensitive confirmation.
## Enroll nodes
Use an existing authenticated admin connection or the local console. Generate a
random private channel key; for example, `openssl rand -hex 16`. Do not use Public
or a predictable hashtag key. Add that same key as a private channel on the
publishing Companion, and note its channel index.
Get the publishing Companion's full public identity key from its device information
or existing identity command. On every managed repeater or room server, run:
```text
set fleet.controller <publisher-public-key-64-hex>
set fleet.channel <private-channel-key-32-hex>
get fleet.channel
get fleet.controller
get fleet.stats
```
`get fleet.channel` reports enrollment, storage health, a channel hash, and the
last accepted sequence. It does not reveal the channel key. `set fleet.channel
off` disables reception; `set fleet.controller off` revokes the publisher. Key
changes use the existing individually authenticated admin path, never fleet
commands.
Nodes need a clock set to current UTC. Commands expire after ten minutes and may
be at most one minute ahead of a receiver's clock. A bad clock fails closed;
correct it through the normal administration path.
## Send commands from the app
On the publishing Companion, use the existing local Command Line screen. The
Companion signs with its own identity. No publisher private key is copied to a
repeater, and no app protocol change is required.
Examples use private channel index `3`:
```text
fleet send 3 all get radio2
fleet send 3 all set tempradio2 910.5,500,8,5,rxtx,120
fleet send 3 all set radio2.cross auto
fleet send 3 all set flood.rule.4 type=grp_txt hops=6+ drop
fleet send 3 all del flood.rule.4
```
To address one node, replace `all` with that node's complete 64-character public
key. Names and short path IDs are not target identities. On air, the target is a
128-bit SHA-256 digest of the full key. Separate fleets use separate channel keys;
named subgroups are not part of this first version.
Only local commands on the publishing Companion can originate fleet sends. A
remote CLI command cannot turn that Companion into a multicast signing proxy.
An `OK` means the Companion queued the command, not that every receiver applied
it. Wait for acknowledgements and confirm the result on important nodes.
Use at most one new command per second. The Unix-second sequence is reserved in
durable storage **before** dispatch. The same sequence is never executed twice,
including after reboot or a lost reply. Retry an uncertain command with a fresh
sequence; the operation should be chosen to be idempotent.
## Supported controls
The command body is limited to **72 ASCII bytes**, independently of the target's
full key in the local send command. Longer commands are rejected, not truncated.
| Control | Fleet commands |
| --- | --- |
| Saved second profile | `get radio2`, `set radio2 <tuple|off>` |
| Temporary second profile | `get tempradio2`, `set tempradio2 <tuple|off>` |
| Profile diagnostics | `get radio2.status`, `get radio2.timing` |
| Crossover | `get radio2.cross`, `set radio2.cross <auto|on|off>` |
| Filter rules | `get/set/del flood.filter[.n]`, `flood.rule[.n]` |
| Path blacklist | `get/set/del flood.filter.blacklist[.n]` |
| Channel moderation | `get/set/del flood.moderation[.n]` |
| Scope policy | `get/set/del flood.channel.scope[.n]`, `flood.channel.scope.require[.n]` |
| Group-data forwarding | `get/set flood.channel.data`, `flood.channel.data.hops` |
| Flood hop limits | `get/set flood.max`, `flood.max.unscoped`, `flood.max.advert` |
Each role still checks whether it supports the requested setting. Primary radio
changes, identity export, passwords, ACL edits, filesystem operations, firmware
installation, reboot, and unrestricted CLI are outside fleet capability.
Secondary-radio mutations keep the old profile until their exact acknowledgement
packets drain, with at least one successful physical transmission. Queue failure,
failed transmissions, or an acknowledgement timeout cancels the staged mutation.
Temporary-radio leases retain their original monotonic end time; waiting for an
acknowledgement does not lengthen them.
Individual acknowledgements wait 0.5–1.5 seconds; whole-fleet replies are spread
over 0.5–60.5 seconds (0.5–15.5 seconds for radio mutations, to preserve short
temporary leases). This reduces simultaneous responses but is not a reliable
delivery protocol for an arbitrarily large fleet. Use individual requests for
confirmation and divide very large fleets into separately keyed channels.
## Filtering and recovery
Local fleet reception happens before a node's forwarding decision, so that node
can still process an authenticated command when a filter or `repeat off` blocks
forwarding. **Intermediate nodes can still block delivery to downstream nodes.**
Management traffic has no automatic exemption from forwarding filters. Preserve
an appropriate group-data/channel forwarding rule and an existing direct-admin
recovery path before changing network-wide filters. The primary radio is retained
by this capability, but a forwarding policy can still partition the fleet.
The receiver is allocated lazily when fleet settings are present or queried, with
one bounded mailbox. Cryptographic work and storage writes occur after the receive
stack unwinds. Signature-validation
attempts are bounded to protect the radio loop from matching-hash junk. A corrupt
or unreadable fleet store denies commands rather than discarding its replay state.
## Ideas for packet-abuse containment
A packet-count chart can show a burst, but cannot establish intent, an authenticated
source, or the airtime consumed. A large Trace spike deserves investigation;
MeshCore already rejects flood-form Trace and Control packets. Valid direct Trace
requests need different treatment from flood forwarding rules.
Useful next additions, rather than features already implemented here:
- **Temporary containment with automatic rollback:** deploy a drop/rate policy
for a bounded period and restore the previous policy unless confirmed.
- **Direct-request rate limits:** separately bound Trace, anonymous requests,
discovery, login, and expensive response generation without breaking legitimate
management, ACKs, or ongoing OTA exchanges.
- **Policy preview and revisions:** validate complete rule bundles, detect board
limitations, apply atomically, and report the active revision before rollout.
- **Better evidence:** report per-type receive/drop counts, airtime, queue pressure,
invalid signatures, duplicate rate, and radio recoveries. Truncated path IDs
remain observations, not authenticated attacker identities.
- **Authenticated inventory:** stagger signed status replies with firmware version,
uptime/reset reason, battery, memory/storage headroom, and radio errors.
- **Maintenance windows:** coordinated secondary-profile experiments and OTA
readiness checks, keeping existing firmware-signing policy intact.
- **Per-controller roles and revocation:** multiple public verification keys with
separate filter-only/radio permissions and independent replay state.
Start with observation, then a narrow temporary rule, then verify neighboring
nodes still communicate. Software filtering can reduce forwarding and response
amplification; it cannot recover airtime already occupied by an RF transmitter.
See [flood filtering](flood_filtering.md), [radio profiles](radio_profiles.md), and
[ACL administration](cli_commands.md#acl).
+1
View File
@@ -16,6 +16,7 @@ Choose firmware with the [firmware picker](firmware_picker.md). For help with a
- [Feature switches by role](role_feature_switches.md) and [Full Companion features](full_companion_features.md)
- [Firmware update guide](ota_user_guide.md), [easy LoRa OTA](ota_easy.md), and [scripted LoRa OTA](lora_ota_automation.md)
- [CLI commands](cli_commands.md), [availability by build](cli_build_matrix.md), and [radio profiles](radio_profiles.md)
- [Private fleet management](fleet_management.md): signed filtering and secondary-radio commands for a fleet or one node
- [Companion protocol](companion_protocol.md), [packet format](packet_format.md), and [QR codes](qr_codes.md)
The navigation groups the remaining guides under Documentation, including board-specific updates, networking, power, and protocol references.
+102
View File
@@ -4,6 +4,7 @@
#include "NotificationSettingsFile.h"
#include "MyMesh.h"
#include <helpers/CompanionTxRoutingCLI.h>
#include <helpers/FleetCommand.h>
#include "CompanionBluetooth.h"
#include "CompanionWireless.h"
#include "CompanionRetry.h"
@@ -9704,6 +9705,107 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
&& meshBleTraceCommand(command, reply, reply_capacity)) return true;
#endif
#if MESH_ENABLE_FLEET_CONTROL
if (!strncmp(command, "fleet", 5)
&& (command[5] == 0 || command[5] == ' ' || command[5] == '\t')) {
// Only the attached Companion client can originate multicast management.
// A remote CLI peer must not borrow this Companion's signing identity.
if (sender_timestamp != 0) {
snprintf(reply, reply_capacity, "Error: fleet send requires local Companion access");
return true;
}
const char* text = command + 5;
while (*text == ' ' || *text == '\t') ++text;
if (strncmp(text, "send", 4)
|| (text[4] != ' ' && text[4] != '\t')) {
snprintf(reply, reply_capacity,
"Error: use fleet send <channel-index> <all|64-hex-key> <command>");
return true;
}
text += 4;
while (*text == ' ' || *text == '\t') ++text;
uint32_t channel_index = 0;
bool have_index = false;
while (*text >= '0' && *text <= '9') {
have_index = true;
const uint8_t digit = *text++ - '0';
if (channel_index > (0xFFFFFFFFUL - digit) / 10UL) {
snprintf(reply, reply_capacity, "Error: invalid fleet channel index");
return true;
}
channel_index = channel_index * 10UL + digit;
}
if (!have_index || channel_index >= MAX_GROUP_CHANNELS
|| (*text != ' ' && *text != '\t')) {
snprintf(reply, reply_capacity, "Error: invalid fleet channel index");
return true;
}
while (*text == ' ' || *text == '\t') ++text;
// Reuse the wire buffer for the temporary target token. Encoding happens
// only after the target has been parsed into its separate binary buffer.
uint8_t payload[mesh::FleetCommand::MaxPayloadLength];
const size_t target_length = strcspn(text, " \t");
if (target_length > PUB_KEY_SIZE * 2U
|| text[target_length] == 0) {
snprintf(reply, reply_capacity, "Error: fleet target must be all or a full public key");
return true;
}
memcpy(payload, text, target_length);
payload[target_length] = 0;
uint8_t target[mesh::FleetCommand::TargetSize];
if (!mesh::FleetCommand::parseTarget((const char*)payload, target)) {
snprintf(reply, reply_capacity, "Error: fleet target must be all or a full public key");
return true;
}
text += target_length;
while (*text == ' ' || *text == '\t') ++text;
if (!mesh::FleetCommand::commandAllowed(text)) {
snprintf(reply, reply_capacity, "Error: command is not permitted for fleet management");
return true;
}
ChannelDetails channel;
if (!getChannel((int)channel_index, channel) || !channel.name[0]) {
snprintf(reply, reply_capacity, "Error: fleet channel is not configured");
return true;
}
bool padded_key = true;
for (size_t i = mesh::FleetCommand::KeySize;
i < sizeof(channel.channel.secret); ++i)
padded_key = padded_key && channel.channel.secret[i] == 0;
if (!padded_key || !mesh::FleetCommand::privateKeyAllowed(channel.channel.secret)) {
snprintf(reply, reply_capacity, "Error: fleet requires a private 128-bit channel key");
return true;
}
const uint32_t sequence = getRTCClock()->getCurrentTime();
static uint32_t last_fleet_sequence = 0;
if (sequence < mesh::FleetCommand::MinEpoch
|| sequence > 0xFFFFFFFFUL - mesh::FleetCommand::MaxLifetime) {
snprintf(reply, reply_capacity, "Error: set the Companion clock before fleet send");
return true;
}
if (sequence <= last_fleet_sequence) {
snprintf(reply, reply_capacity, "Error: wait for the next clock second before fleet send");
return true;
}
const size_t length = mesh::FleetCommand::encode(
self_id, channel.channel.secret, sequence,
sequence + mesh::FleetCommand::MaxLifetime, target, text,
payload, sizeof(payload));
if (!length) {
snprintf(reply, reply_capacity, "Error: fleet command is too long or invalid");
} else if (!sendGroupData(channel.channel, nullptr, OUT_PATH_UNKNOWN,
mesh::FleetCommand::DataType, payload, (int)length)) {
snprintf(reply, reply_capacity, "Error: could not queue fleet command");
} else {
last_fleet_sequence = sequence;
snprintf(reply, reply_capacity, "OK - fleet command queued; seq=%lu",
(unsigned long)sequence);
}
memset(payload, 0, sizeof(payload));
return true;
}
#endif
if (_radio_profiles.handle(command, reply, reply_capacity, sender_timestamp != 0)) return true;
if (!strncmp(command, "set tempradio ", 14)) command += 4;
if (!strcmp(command, "get tempradio")) command += 4;
+29
View File
@@ -2816,6 +2816,13 @@ void MyMesh::onAdvertRecv(mesh::Packet *packet, const mesh::Identity &id, uint32
}
void MyMesh::onGroupPacketRecv(mesh::Packet* packet) {
#if MESH_ENABLE_FLEET_CONTROL
if (_cli.fleetChannel()) {
TransportKey scope;
const bool known = mesh::captureFleetReplyScope(region_map, recv_pkt_region, packet, scope);
_cli.fleetChannel()->receive(packet, *this, known ? &scope : nullptr);
}
#endif
#if !defined(PORTABLE_MQTT_OBSERVER) && MESH_ENABLE_CLOCK_SYNC
// The base Mesh calls this for every unseen, structurally valid group packet
// before the forwarding decision. Public-channel decryption below also
@@ -4156,6 +4163,9 @@ bool MyMesh::sendRepeatersFloodText(const char* text, const TransportKey* scope,
void MyMesh::onSendComplete(mesh::Packet* packet) {
mesh::Mesh::onSendComplete(packet);
#if MESH_ENABLE_FLEET_CONTROL
if (_cli.fleetChannel()) _cli.fleetChannel()->complete(packet, _cli.radioProfiles());
#endif
if (temp_radio_reply_barrier.complete(packet) && !temp_radio_reply_barrier.waiting()) finishRadioReply(true);
if (packet == pending_battery_alert_packet) {
pending_battery_alert_packet = NULL;
@@ -4166,12 +4176,18 @@ void MyMesh::onSendComplete(mesh::Packet* packet) {
void MyMesh::onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original,
uint8_t priority) {
#if MESH_ENABLE_FLEET_CONTROL
if (_cli.fleetChannel()) _cli.fleetChannel()->copy(packet, original);
#endif
temp_radio_reply_barrier.trackCopy(original, packet);
mesh::Mesh::onRadioProfileCopyQueued(packet, original, priority);
}
void MyMesh::onSendFail(mesh::Packet* packet) {
mesh::Mesh::onSendFail(packet);
#if MESH_ENABLE_FLEET_CONTROL
if (_cli.fleetChannel()) _cli.fleetChannel()->fail(packet, _cli.radioProfiles());
#endif
if (temp_radio_reply_barrier.fail(packet)) {
// Failure of every acknowledgement copy cancels the unconfirmed
// handoff. A later cached-command retry may replay the reply, but it does
@@ -12585,6 +12601,19 @@ void MyMesh::loop() {
_cli.loop();
serviceRadioReplyDeadline();
processDeferredCliCommand();
#if MESH_ENABLE_FLEET_CONTROL
if (_cli.fleetChannel()) {
_cli.fleetChannel()->service(*this, _cli.radioProfiles(), _prefs.node_name,
[this](uint32_t sequence, char* command, char* reply) {
if (region_load_active) {
strcpy(reply, "Err - region load active; retry later"); return;
}
// Signature and a strict command allowlist are checked before this
// dispatch. Fleet capability never grants unrestricted admin CLI.
handleCommand(sequence, nullptr, command, reply);
});
}
#endif
servicePostMeshLoop();
#if defined(ENABLE_OTA) && OTA_DYNAMIC_CONTEXT
mesh::ota::ota_service_temp_radio_context(isAnyTempRadioActive());
+1
View File
@@ -1092,6 +1092,7 @@ public:
bool sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size);
// CommonCLICallbacks
bool supportsFleetControl() const override { return true; }
mesh::Radio* getProfileRadio() override { return _radio; }
void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins, uint16_t preamble = 0) override;
bool scheduleNormalRadio() override;
+38
View File
@@ -1166,9 +1166,35 @@ void MyMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id,
}
void MyMesh::onGroupPacketRecv(mesh::Packet* packet) {
#if MESH_ENABLE_FLEET_CONTROL
if (_cli.fleetChannel()) {
TransportKey scope;
const bool known = mesh::captureFleetReplyScope(region_map, recv_pkt_region, packet, scope);
_cli.fleetChannel()->receive(packet, *this, known ? &scope : nullptr);
}
#endif
_clock_sync.observeGroupPacket(packet);
}
#if MESH_ENABLE_FLEET_CONTROL
void MyMesh::onSendComplete(mesh::Packet* packet) {
mesh::Mesh::onSendComplete(packet);
if (_cli.fleetChannel()) _cli.fleetChannel()->complete(packet, _cli.radioProfiles());
}
void MyMesh::onSendFail(mesh::Packet* packet) {
mesh::Mesh::onSendFail(packet);
if (_cli.fleetChannel()) _cli.fleetChannel()->fail(packet, _cli.radioProfiles());
}
void MyMesh::onRadioProfileCopyQueued(mesh::Packet* packet,
const mesh::Packet* original,
uint8_t priority) {
mesh::Mesh::onRadioProfileCopyQueued(packet, original, priority);
if (_cli.fleetChannel()) _cli.fleetChannel()->copy(packet, original);
}
#endif
#if defined(WITH_MQTT_NEIGHBORS)
#define CTL_TYPE_NODE_DISCOVER_REQ 0x80
@@ -2679,6 +2705,18 @@ void MyMesh::loop() {
servicePendingSerialOutput();
#endif
_cli.loop();
#if MESH_ENABLE_FLEET_CONTROL
if (_cli.fleetChannel()) {
_cli.fleetChannel()->service(*this, _cli.radioProfiles(), _prefs.node_name,
[this](uint32_t sequence, char* command, char* reply) {
if (region_load_active) {
strcpy(reply, "Err - region load active; retry later");
return;
}
handleCommand(sequence, command, reply);
});
}
#endif
_clock_sync.loop();
#if MESH_ENABLE_TELEMETRY_HISTORY
sampleTelemetryHistory();
+7
View File
@@ -409,6 +409,12 @@ protected:
void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override;
void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override;
void onGroupPacketRecv(mesh::Packet* packet) override;
#if MESH_ENABLE_FLEET_CONTROL
void onSendComplete(mesh::Packet* packet) override;
void onSendFail(mesh::Packet* packet) override;
void onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original,
uint8_t priority) override;
#endif
#if defined(WITH_MQTT_NEIGHBORS)
void onControlDataRecv(mesh::Packet* packet) override;
#endif
@@ -431,6 +437,7 @@ protected:
#endif
public:
bool supportsFleetControl() const override { return true; }
MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables);
void begin(FILESYSTEM* fs);
+1
View File
@@ -74,6 +74,7 @@ nav:
- host_cli_service.md
- Radio, networking, and power:
- radio_profiles.md
- fleet_management.md
- carrier_wave.md
- espnow_bridge_setup.md
- WiFi.md
+1
View File
@@ -478,6 +478,7 @@ build_src_filter =
+<../src/helpers/ArduinoSerialInterface.cpp>
+<../src/helpers/DynamicConfigSerializer.cpp>
+<../src/helpers/CommonRadioPrefs.cpp>
+<../src/helpers/FleetCommand.cpp>
+<../src/helpers/ui/MomentaryButton.cpp>
lib_deps =
google/googletest @ 1.17.0
+1
View File
@@ -296,6 +296,7 @@ typedef uint32_t DispatcherAction;
* and scheduling of outbound Packets.
*/
class Dispatcher {
friend class FleetChannel; // bounded deferred receiver and exact reply retirement
enum class OutboundCancellation : uint8_t { None, Cancelled, Delivered };
Packet* outbound; // current outbound packet
unsigned long outbound_expiry, outbound_start, total_air_time, rx_air_time;
+3
View File
@@ -2856,6 +2856,9 @@ uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) {
void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) {
PrefsSaveReplyGuard save_reply(_prefs_save_failures, reply);
mesh::cli::normalizeCommandVerb(command);
#if MESH_ENABLE_FLEET_CONTROL
if (handleFleetCommand(command, reply)) return;
#endif
#if MESH_BATTERY_CHARGE_CONTROL
if (mesh::power::handleBatteryChargeCommand(*_board, command, reply, 160)) return;
#endif
+12
View File
@@ -15,6 +15,10 @@
#include <helpers/RepeaterRadioTiming.h>
#include <helpers/RadioProfileCLI.h>
#include <helpers/WirelessControl.h>
#include <helpers/FleetCommand.h>
#if MESH_ENABLE_FLEET_CONTROL
#include <helpers/FleetChannel.h>
#endif
#ifndef DEFAULT_CAD_ENABLED
#define DEFAULT_CAD_ENABLED 0
@@ -478,6 +482,7 @@ struct LegacyObserverTail {
class CommonCLICallbacks {
public:
virtual bool supportsFleetControl() const { return false; }
// True only after an actual NTP response this boot, not a plausible RTC.
virtual bool managementNtpSynced() const { return false; }
virtual mesh::Radio* getProfileRadio() { return nullptr; }
@@ -831,6 +836,10 @@ class CommonCLI {
bool _common_save_succeeded = false;
uint32_t _prefs_save_failures = 0;
mesh::RadioProfileCLI _radio_profiles;
#if MESH_ENABLE_FLEET_CONTROL
mesh::FleetChannel* _fleet_channel = nullptr; // allocate only when enrolled
bool handleFleetCommand(const char* command, char* reply);
#endif
mesh::RTCClock* getRTCClock() { return _rtc; }
void savePrefs(
@@ -887,6 +896,9 @@ public:
uint16_t preamble);
mesh::RadioProfileCLI& radioProfiles() { return _radio_profiles; }
const mesh::RadioProfileCLI& radioProfiles() const { return _radio_profiles; }
#if MESH_ENABLE_FLEET_CONTROL
mesh::FleetChannel* fleetChannel() { return _fleet_channel; }
#endif
static bool calculateRxPowerSavingLevel(uint32_t level, uint8_t sf, float bw, uint32_t preamble,
uint32_t* rx_us, uint32_t* sleep_us);
static bool recalculateRxPowerSavingFromLevel(NodePrefs* prefs);
+25
View File
@@ -245,6 +245,12 @@ static bool resolveDataScope(DataRouteState& route, TransportKey& scope,
void CommonCLI::beginManagement(mesh::Mesh& mesh, FILESYSTEM* fs) {
_management_mesh = &mesh; _management_fs = fs;
_management_last_ms = millis(); _management_uptime_ms = _management_last_ms;
#if MESH_ENABLE_FLEET_CONTROL
if (_callbacks->supportsFleetControl()
&& (fs->exists("/fleet_channel") || fs->exists("/fleet_channel.bak"))) {
_fleet_channel = new (std::nothrow) mesh::FleetChannel(fs);
}
#endif
if (!_data_route) {
mesh::hilStartupTrace("management_route_begin");
_data_route = new (std::nothrow) mesh::DataRouteState;
@@ -265,6 +271,25 @@ void CommonCLI::beginManagement(mesh::Mesh& mesh, FILESYSTEM* fs) {
mesh::hilStartupTrace("management_probe_ready");
}
#if MESH_ENABLE_FLEET_CONTROL
bool CommonCLI::handleFleetCommand(const char* command, char* reply) {
const bool fleet_command = !strncmp(command, "get fleet.", 10)
|| !strncmp(command, "set fleet.", 10);
if (!fleet_command) return false;
if (!_callbacks->supportsFleetControl()) {
strcpy(reply, "Err - fleet control is available on infrastructure roles"); return true;
}
if (!_fleet_channel) _fleet_channel = new (std::nothrow) mesh::FleetChannel(_management_fs);
if (!_fleet_channel) {
strcpy(reply, "Err - fleet control memory unavailable"); return true;
}
if (!_fleet_channel->handleConfig(command, reply, 160)) {
strcpy(reply, "Err - use fleet.channel, fleet.controller, or fleet.stats");
}
return true;
}
#endif
void CommonCLI::loopManagement() {
const uint32_t now = millis();
_management_uptime_ms += uint32_t(now - _management_last_ms); _management_last_ms = now;
+237
View File
@@ -0,0 +1,237 @@
#include "FleetChannel.h"
#include "FilePresence.h"
#include "FileRead.h"
#include "PersistentStoreFormat.h"
#include <Arduino.h>
#include <stdio.h>
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
#include "AtomicFileWriter.h"
#else
#include "ContactFileTransaction.h"
#endif
namespace mesh {
namespace {
constexpr char Path[] = "/fleet_channel";
constexpr size_t ImageSize = 64;
bool zero(const uint8_t* p, size_t length) {
uint8_t value = 0;
while (length--) value |= *p++;
return value == 0;
}
}
bool FleetChannel::load() {
if (!fs_) return false;
#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
if (!ContactFileTransaction::recover(fs_, Path, filePresence<FILESYSTEM>)) return false;
#endif
bool present = false;
if (!filePresence(fs_, Path, present)) return false;
if (!present) return true;
auto file = openFileRead(fs_, Path);
uint8_t image[ImageSize];
const bool read = file && file.size() == ImageSize
&& file.read(image, ImageSize) == (int)ImageSize;
file.close();
if (!read || memcmp(image, "FCS1", 4) != 0 || image[4] > 1
|| !zero(image + 5, 3)
|| storage::readLE32(image + 60) != storage::updateCRC32(0xffffffff, image, 60)) return false;
if (!zero(image + 8, 16) && !FleetCommand::privateKeyAllowed(image + 8)) return false;
configured_ = image[4] != 0;
if (configured_ && zero(image + 8, 16)) return false;
memcpy(channel_.secret, image + 8, 16);
Utils::sha256(channel_.hash, sizeof(channel_.hash), channel_.secret, 16);
memcpy(controller_.pub_key, image + 24, 32);
controller_set_ = !zero(controller_.pub_key, 32);
last_sequence_ = storage::readLE32(image + 56);
return true;
}
bool FleetChannel::save(bool enabled, const uint8_t* key, const uint8_t* controller,
uint32_t sequence) {
if (!healthy_ || !fs_) return false;
uint8_t image[ImageSize] = {'F', 'C', 'S', '1', uint8_t(enabled)};
memcpy(image + 8, key, 16);
memcpy(image + 24, controller, 32);
storage::writeLE32(image + 56, sequence);
storage::writeLE32(image + 60, storage::updateCRC32(0xffffffff, image, 60));
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
AtomicFileWriter writer(fs_, Path);
#else
ContactFileTransaction writer(fs_, Path, filePresence<FILESYSTEM>);
#endif
return writer && writer.write(image, sizeof(image)) == sizeof(image) && writer.commit();
}
bool FleetChannel::handleConfig(const char* command, char* reply, size_t capacity) {
if (!strcmp(command, "get fleet.channel")) {
char fingerprint[3];
Utils::toHex(fingerprint, channel_.hash, 1);
snprintf(reply, capacity, "> %s,controller=%s,store=%s,hash=%s,last=%lu",
configured_ ? "on" : "off", controller_set_ ? "set" : "unset",
healthy_ ? "ok" : "error", fingerprint, (unsigned long)last_sequence_);
} else if (!strcmp(command, "get fleet.controller")) {
if (controller_set_) {
char key[65];
Utils::toHex(key, controller_.pub_key, 32);
snprintf(reply, capacity, "> %s", key);
} else snprintf(reply, capacity, "> unset");
} else if (!strcmp(command, "get fleet.stats")) {
snprintf(reply, capacity, "> accepted=%lu rejected=%lu busy=%lu pending=%u ack=%u",
(unsigned long)accepted_, (unsigned long)rejected_, (unsigned long)busy_,
unsigned(pending_), unsigned(barrier_.waiting()));
} else if (!strncmp(command, "set fleet.channel ", 18)) {
if (pending_ || barrier_.waiting()) {
snprintf(reply, capacity, "Err - fleet command pending; retry later"); return true;
}
const char* value = command + 18;
uint8_t key[16] = {};
const bool enabled = strcmp(value, "off") != 0;
if (enabled && (strlen(value) != 32 || !Utils::fromHex(key, 16, value)
|| !FleetCommand::privateKeyAllowed(key))) {
snprintf(reply, capacity, "Err - use a private random 128-bit key or off"); return true;
}
if (!save(enabled, key, controller_.pub_key, last_sequence_)) {
snprintf(reply, capacity, "Err - fleet settings not saved; store unavailable"); return true;
}
configured_ = enabled;
memset(channel_.secret, 0, sizeof(channel_.secret));
memcpy(channel_.secret, key, sizeof(key));
Utils::sha256(channel_.hash, sizeof(channel_.hash), key, sizeof(key));
snprintf(reply, capacity, "OK - fleet channel %s; publisher signature required",
enabled ? "on" : "off");
} else if (!strncmp(command, "set fleet.controller ", 21)) {
if (pending_ || barrier_.waiting()) {
snprintf(reply, capacity, "Err - fleet command pending; retry later"); return true;
}
const char* value = command + 21;
uint8_t key[32] = {};
if (strcmp(value, "off") && (strlen(value) != 64 || !Utils::fromHex(key, 32, value) || zero(key, 32))) {
snprintf(reply, capacity, "Err - use publisher public key (64 hex) or off"); return true;
}
if (!save(configured_, channel_.secret, key, last_sequence_)) {
snprintf(reply, capacity, "Err - fleet settings not saved; store unavailable"); return true;
}
memcpy(controller_.pub_key, key, 32);
controller_set_ = !zero(key, 32);
snprintf(reply, capacity, "OK - fleet publisher %s", controller_set_ ? "set" : "off");
} else return false;
return true;
}
void FleetChannel::receive(Packet* packet, Mesh& mesh, const TransportKey* scope) {
if (!healthy_ || !configured_ || !controller_set_ || !packet
|| packet->getPayloadType() != PAYLOAD_TYPE_GRP_DATA
|| packet->payload_len < 1 + CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE
|| packet->payload_len > sizeof(incoming_) || packet->payload[0] != channel_.hash[0]) return;
if (pending_ || barrier_.waiting()) { ++busy_; return; }
// Do no decryption, signature verification, or writes on the receive stack.
memcpy(incoming_, packet->payload, packet->payload_len);
incoming_len_ = packet->payload_len;
rx_profile_ = packet->radio_profile;
rx_generation_ = packet->radio_generation;
path_hash_size_ = packet->getPathHashSize();
reply_scoped_ = packet->hasTransportCodes();
reply_scope_known_ = scope != nullptr && !scope->isNull();
if (reply_scope_known_) reply_scope_ = *scope;
pending_ = true;
(void)mesh;
}
bool FleetChannel::decode(Mesh& mesh, FleetCommand::Decoded& command) {
const uint32_t now_ms = millis();
if (uint32_t(now_ms - verify_window_) >= 1000) {
verify_window_ = now_ms; verify_attempts_ = 0;
}
// A forged matching channel hash must not monopolize the radio loop with
// Ed25519 work. Legitimate publishers can retry with a fresh sequence.
if (verify_attempts_ >= 4) return false;
++verify_attempts_;
uint8_t data[MAX_PACKET_PAYLOAD];
const int length = Utils::MACThenDecrypt(channel_.secret, data, incoming_ + 1, incoming_len_ - 1);
if (length < 3 || data[0] != uint8_t(FleetCommand::DataType)
|| data[1] != uint8_t(FleetCommand::DataType >> 8)) return false;
const size_t payload_length = data[2];
const size_t unpadded = 3 + payload_length;
if (unpadded > (size_t)length || (unpadded + 15) / 16 * 16 != (size_t)length
|| !zero(data + unpadded, length - unpadded)) return false;
if (payload_length < FleetCommand::HeaderSize + FleetCommand::SignatureSize
|| storage::readLE32(data + 7) <= last_sequence_) return false;
if (!FleetCommand::decode(controller_, channel_.secret, data + 3, payload_length,
mesh.getRTCClock()->getCurrentTime(), mesh.self_id.pub_key, command)) return false;
broadcast_ = zero(data + 3 + 12, FleetCommand::TargetSize);
return true;
}
bool FleetChannel::acknowledge(Mesh& mesh, const char* name, uint32_t sequence,
const char* reply, bool radio_mutation) {
// Mesh::createGroupDatagram reserves one channel byte and a full block's
// padding allowance, so at most 168 plaintext bytes fit a 184-byte payload.
uint8_t data[MAX_PACKET_PAYLOAD - CIPHER_BLOCK_SIZE] = {};
const uint32_t time = mesh.getRTCClock()->getCurrentTime();
memcpy(data, &time, sizeof(time));
data[4] = 0; // TXT_TYPE_PLAIN
char identity[13];
Utils::toHex(identity, mesh.self_id.pub_key, 6);
// Keep names bounded and remove ':' so ordinary app channel parsers agree.
char sender[17];
snprintf(sender, sizeof(sender), "%.16s", name ? name : "node");
for (char* p = sender; *p; ++p) if (*p == ':') *p = ';';
const int count = snprintf((char*)data + 5, sizeof(data) - 5,
"%s: fleet %lu %s: %s", sender,
(unsigned long)sequence, identity, reply);
if (count < 0) return false;
const size_t length = count >= int(sizeof(data) - 5) ? sizeof(data) - 6 : size_t(count);
Packet* packet = mesh.createGroupDatagram(PAYLOAD_TYPE_GRP_TXT, channel_, data, 5 + length);
if (!packet) return false;
packet->radio_reply = true;
packet->flood_retry_policy = FLOOD_RETRY_POLICY_DENY;
// Acknowledgements have one tracked physical attempt per profile. A later
// untracked retry must never trigger a completed radio transition twice.
packet->radio_bound = false;
if (radio_mutation) barrier_.prepare(packet);
// Keep short temporary leases useful: their clock started at admission and
// may not be extended by waiting for a fleet acknowledgement.
const uint32_t maximum_delay = broadcast_ ? (radio_mutation ? 15500 : 60500) : 1500;
const uint32_t delay = mesh.getRNG()->nextInt(500, maximum_delay);
bool queued = false;
if (reply_scoped_) {
if (!reply_scope_known_) {
mesh.releasePacket(packet);
if (radio_mutation) barrier_.clear();
return false;
}
uint16_t codes[2] = {reply_scope_.calcTransportCode(packet), 0};
queued = mesh.sendFlood(packet, codes, delay, path_hash_size_);
} else queued = mesh.sendFlood(packet, delay, path_hash_size_);
if (!queued) { if (radio_mutation) barrier_.clear(); return false; }
if (radio_mutation) {
barrier_.arm(packet);
ack_deadline_ = millis() + 300000UL;
}
return true;
}
void FleetChannel::finish(RadioProfileCLI& profiles, bool delivered) {
barrier_.clear(); ack_deadline_ = 0;
profiles.finishReplyMutation(delivered);
}
void FleetChannel::complete(Packet* packet, RadioProfileCLI& profiles) {
if (barrier_.complete(packet) && !barrier_.waiting()) finish(profiles, true);
}
void FleetChannel::fail(Packet* packet, RadioProfileCLI& profiles) {
if (barrier_.fail(packet)) finish(profiles, false);
else if (!barrier_.waiting() && barrier_.succeeded()) finish(profiles, true);
}
void FleetChannel::serviceDeadline(Mesh& mesh, RadioProfileCLI& profiles) {
if (!barrier_.waiting() || int32_t(uint32_t(millis()) - ack_deadline_) < 0) return;
if (barrier_.contains(mesh.getOutboundInFlight())) mesh.cancelOutboundRadioRetry(mesh.getOutboundInFlight());
for (int i = mesh._mgr->getOutboundTotal() - 1; i >= 0; --i) {
if (!barrier_.contains(mesh._mgr->getOutboundByIdx(i))) continue;
Packet* packet = mesh._mgr->removeOutboundByIdx(i);
static_cast<Dispatcher&>(mesh).onSendFail(packet);
mesh.releasePacket(packet);
}
}
} // namespace mesh
+100
View File
@@ -0,0 +1,100 @@
#pragma once
#include <Mesh.h>
#include <helpers/IdentityStore.h>
#include <helpers/RadioProfileCLI.h>
#include <helpers/TempRadioReplyBarrier.h>
#include <helpers/FleetCommand.h>
#include <helpers/TransportKeyStore.h>
namespace mesh {
// Scope codes authenticate the original packet's payload. A new reply needs
// fresh codes from the exact matching region key, including rotated keys.
template <typename Regions, typename Region>
bool captureFleetReplyScope(Regions& regions, const Region* region,
const Packet* packet, TransportKey& scope) {
if (!packet || !packet->hasTransportCodes() || !region || region->isWildcard()) return false;
TransportKey candidates[MAX_TKS_ENTRIES];
const int count = regions.getTransportKeysFor(*region, candidates, MAX_TKS_ENTRIES);
for (int i = 0; i < count && i < MAX_TKS_ENTRIES; ++i) {
const uint16_t code = candidates[i].calcTransportCode(packet);
if (code == packet->transport_codes[0] || code == packet->transport_codes[1]) {
scope = candidates[i]; return true;
}
}
return false;
}
// Optional infrastructure-only receiver. The publisher's private key is never
// enrolled here. The ordinary channel key provides privacy; signatures grant
// the deliberately narrower fleet capability.
class FleetChannel {
FILESYSTEM* fs_;
GroupChannel channel_{};
Identity controller_;
uint32_t last_sequence_ = 0;
uint32_t ack_deadline_ = 0;
uint32_t verify_window_ = 0;
uint32_t accepted_ = 0, rejected_ = 0, busy_ = 0;
uint8_t verify_attempts_ = 0;
bool configured_ = false, controller_set_ = false, healthy_ = false;
bool pending_ = false;
bool broadcast_ = false;
uint8_t incoming_[MAX_PACKET_PAYLOAD]{};
uint8_t incoming_len_ = 0, rx_profile_ = 0, path_hash_size_ = 1;
uint32_t rx_generation_ = 0;
TransportKey reply_scope_{};
bool reply_scoped_ = false, reply_scope_known_ = false;
TempRadioReplyBarrier barrier_;
bool load();
bool save(bool enabled, const uint8_t* key, const uint8_t* controller,
uint32_t sequence);
bool decode(Mesh& mesh, FleetCommand::Decoded& command);
bool acknowledge(Mesh& mesh, const char* name, uint32_t sequence,
const char* reply, bool radio_mutation);
void finish(RadioProfileCLI& profiles, bool delivered);
void serviceDeadline(Mesh& mesh, RadioProfileCLI& profiles);
public:
explicit FleetChannel(FILESYSTEM* fs) : fs_(fs) { healthy_ = load(); }
bool handleConfig(const char* command, char* reply, size_t capacity);
void receive(Packet* packet, Mesh& mesh, const TransportKey* scope = nullptr);
void complete(Packet* packet, RadioProfileCLI& profiles);
void fail(Packet* packet, RadioProfileCLI& profiles);
void copy(Packet* packet, const Packet* original) { barrier_.trackCopy(original, packet); }
bool waiting() const { return barrier_.waiting(); }
template <typename Handler>
void service(Mesh& mesh, RadioProfileCLI& profiles, const char* name,
Handler handle) {
serviceDeadline(mesh, profiles);
if (!pending_) return;
pending_ = false;
if (barrier_.waiting() || profiles.hasReplyMutation()) { ++busy_; return; }
FleetCommand::Decoded command;
if (!decode(mesh, command)) { ++rejected_; return; }
// Reserve before dispatch, including failed commands. A lost reply, reboot,
// or retry can never repeat a mutation. A storage failure grants no control.
if (!save(configured_, channel_.secret, controller_.pub_key, command.sequence)) {
healthy_ = false;
++rejected_;
return;
}
last_sequence_ = command.sequence;
++accepted_;
Dispatcher::ReceiveProfileScope receive_scope(mesh, rx_profile_, rx_generation_);
char reply[160] = {};
const uint32_t generation = profiles.replyMutationGeneration();
profiles.beginReplyCommand();
handle(command.sequence, command.command, reply);
profiles.endReplyCommand();
const bool changed = profiles.replyMutationGeneration() != generation;
if (!acknowledge(mesh, name, command.sequence, reply, changed) && changed) {
finish(profiles, false);
}
}
};
} // namespace mesh
+262
View File
@@ -0,0 +1,262 @@
#include "FleetCommand.h"
#include <SHA256.h>
#include <string.h>
namespace mesh {
namespace {
constexpr char Domain[] = "MeshCoreFleet1";
constexpr size_t SignedMessageCapacity = sizeof(Domain) - 1
+ FleetCommand::KeySize + FleetCommand::HeaderSize
+ FleetCommand::MaxCommandLength;
constexpr uint8_t PublicKey[FleetCommand::KeySize] = {
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72
};
bool zero(const uint8_t* value, size_t size) {
uint8_t combined = 0;
for (size_t i = 0; i < size; ++i) combined |= value[i];
return combined == 0;
}
void erase(uint8_t* bytes, size_t length) {
volatile uint8_t* destination = bytes;
while (length--) *destination++ = 0;
}
void write32(uint8_t* dest, uint32_t value) {
for (unsigned i = 0; i < 4; ++i) dest[i] = value >> (i * 8);
}
uint32_t read32(const uint8_t* source) {
uint32_t value = 0;
for (unsigned i = 0; i < 4; ++i) value |= uint32_t(source[i]) << (i * 8);
return value;
}
void targetHash(const uint8_t* full_key, uint8_t* target) {
SHA256 hash;
hash.update(full_key, PUB_KEY_SIZE);
hash.finalize(target, FleetCommand::TargetSize);
}
int hexDigit(char digit) {
if (digit >= '0' && digit <= '9') return digit - '0';
if (digit >= 'a' && digit <= 'f') return digit - 'a' + 10;
if (digit >= 'A' && digit <= 'F') return digit - 'A' + 10;
return -1;
}
bool lifetimeAllowed(uint32_t sequence, uint32_t expires) {
return sequence >= FleetCommand::MinEpoch && expires >= sequence
&& expires - sequence <= FleetCommand::MaxLifetime;
}
size_t signedMessage(const uint8_t* key, const uint8_t* payload,
size_t length, uint8_t* output) {
const size_t prefix = sizeof(Domain) - 1 + FleetCommand::KeySize;
memcpy(output, Domain, sizeof(Domain) - 1);
memcpy(output + sizeof(Domain) - 1, key, FleetCommand::KeySize);
memcpy(output + prefix, payload, length);
return prefix + length;
}
bool keyEquals(const char* key, size_t length, const char* candidate) {
return strlen(candidate) == length && !memcmp(key, candidate, length);
}
// The CLI handler owns board-specific range checks. Authorization admits
// only the exact namespace and a strictly numeric optional row suffix.
bool rowKey(const char* key, size_t length, const char* family) {
const size_t family_length = strlen(family);
if (length < family_length || memcmp(key, family, family_length)) return false;
if (length == family_length) return true;
if (key[family_length] != '.') return false;
size_t cursor = family_length + 1;
if (cursor == length || key[cursor] < '1' || key[cursor] > '9') return false;
unsigned row = 0;
for (; cursor < length; ++cursor) {
if (key[cursor] < '0' || key[cursor] > '9') return false;
row = row * 10 + unsigned(key[cursor] - '0');
if (row > 255) return false;
}
return true;
}
bool decimalToken(const char* text, size_t length) {
if (!length) return false;
bool dot = false, digit = false;
for (size_t i = 0; i < length; ++i) {
if (text[i] >= '0' && text[i] <= '9') digit = true;
else if (text[i] == '.' && !dot) dot = true;
else return false;
}
return digit;
}
bool integerToken(const char* text, size_t length) {
if (!length) return false;
for (size_t i = 0; i < length; ++i)
if (text[i] < '0' || text[i] > '9') return false;
return true;
}
bool radioTuple(const char* arguments, bool temporary) {
if (!strcmp(arguments, "off")) return true;
unsigned fields = 0;
const unsigned expected = temporary ? 6 : 5;
const char* cursor = arguments;
for (;;) {
const char* end = strchr(cursor, ',');
const size_t length = end ? size_t(end - cursor) : strlen(cursor);
bool valid;
if (fields < 2) valid = decimalToken(cursor, length);
else if (fields < 4 || (temporary && fields == 5))
valid = integerToken(cursor, length);
else if (fields == 4) valid = keyEquals(cursor, length, "rx")
|| keyEquals(cursor, length, "rxtx");
else valid = fields == expected
&& (integerToken(cursor, length) || keyEquals(cursor, length, "auto"));
if (!valid || ++fields > expected + 1) return false;
if (!end) return fields == expected || fields == expected + 1;
cursor = end + 1;
}
}
} // namespace
bool FleetCommand::privateKeyAllowed(const uint8_t* key) {
return key && !zero(key, KeySize) && memcmp(key, PublicKey, KeySize) != 0;
}
bool FleetCommand::parseTarget(const char* text, uint8_t* target) {
if (!text || !target) return false;
if (!strcmp(text, "all")) {
memset(target, 0, TargetSize);
return true;
}
if (strlen(text) != PUB_KEY_SIZE * 2) return false;
uint8_t full_key[PUB_KEY_SIZE];
for (size_t i = 0; i < PUB_KEY_SIZE; ++i) {
const int upper = hexDigit(text[i * 2]);
const int lower = hexDigit(text[i * 2 + 1]);
if (upper < 0 || lower < 0) return false;
full_key[i] = uint8_t((upper << 4) | lower);
}
// An invalid zero identity must not masquerade as the reserved all target.
if (zero(full_key, PUB_KEY_SIZE)) return false;
targetHash(full_key, target);
return !zero(target, TargetSize);
}
bool FleetCommand::commandAllowed(const char* command) {
if (!command) return false;
size_t length = 0;
while (command[length]) {
const unsigned char byte = command[length];
if (++length > MaxCommandLength || byte < 0x20 || byte > 0x7e
|| byte == ';' || byte == '|' || byte == '&' || byte == '`'
|| byte == '\\') return false;
}
if (!length || command[0] == ' ' || command[length - 1] == ' ') return false;
bool get = false, set = false, del = false;
if (!strncmp(command, "get ", 4)) get = true;
else if (!strncmp(command, "set ", 4)) set = true;
else if (!strncmp(command, "del ", 4)) del = true;
else return false;
const char* key = command + 4;
const char* separator = strchr(key, ' ');
const size_t key_length = separator ? size_t(separator - key) : strlen(key);
const char* arguments = separator ? separator + 1 : "";
if (!key_length || (separator && (!*arguments || *arguments == ' '))) return false;
if (keyEquals(key, key_length, "radio2")
|| keyEquals(key, key_length, "tempradio2")) {
return get ? !*arguments : set && radioTuple(arguments, key_length == 10);
}
if (keyEquals(key, key_length, "radio2.status")
|| keyEquals(key, key_length, "radio2.timing")) return get && !*arguments;
if (keyEquals(key, key_length, "radio2.cross")) {
return get ? !*arguments : set && (!strcmp(arguments, "on")
|| !strcmp(arguments, "off") || !strcmp(arguments, "auto"));
}
const bool rows = rowKey(key, key_length, "flood.filter")
|| rowKey(key, key_length, "flood.filter.blacklist")
|| rowKey(key, key_length, "flood.rule")
|| rowKey(key, key_length, "flood.moderation")
|| rowKey(key, key_length, "flood.channel.scope")
|| rowKey(key, key_length, "flood.channel.scope.require");
if (rows) return get ? !*arguments
: set ? *arguments != 0 : del && (!*arguments || !strcmp(arguments, "all"));
const bool settings = keyEquals(key, key_length, "flood.channel.data")
|| keyEquals(key, key_length, "flood.channel.data.hops")
|| keyEquals(key, key_length, "flood.max")
|| keyEquals(key, key_length, "flood.max.unscoped")
|| keyEquals(key, key_length, "flood.max.advert");
return settings && (get ? !*arguments : set && *arguments);
}
size_t FleetCommand::encode(const LocalIdentity& publisher, const uint8_t* key,
uint32_t sequence, uint32_t expires,
const uint8_t* target, const char* command,
uint8_t* output, size_t capacity) {
if (!privateKeyAllowed(key) || !target || !output
|| !lifetimeAllowed(sequence, expires) || !commandAllowed(command)
|| zero(publisher.pub_key, PUB_KEY_SIZE)) return 0;
const size_t command_length = strlen(command);
const size_t unsigned_length = HeaderSize + command_length;
const size_t length = unsigned_length + SignatureSize;
if (capacity < length) return 0;
memcpy(output, "FMC1", 4);
write32(output + 4, sequence);
write32(output + 8, expires);
memcpy(output + 12, target, TargetSize);
output[28] = uint8_t(command_length);
memcpy(output + HeaderSize, command, command_length);
uint8_t message[SignedMessageCapacity];
const size_t message_length = signedMessage(key, output, unsigned_length, message);
publisher.sign(output + unsigned_length, message, int(message_length));
erase(message, sizeof(message));
return length;
}
bool FleetCommand::decode(const Identity& publisher, const uint8_t* key,
const uint8_t* payload, size_t length, uint32_t now,
const uint8_t* self_public_key, Decoded& output) {
memset(&output, 0, sizeof(output));
if (!privateKeyAllowed(key) || !payload || !self_public_key
|| zero(publisher.pub_key, PUB_KEY_SIZE) || zero(self_public_key, PUB_KEY_SIZE)
|| now < MinEpoch || length < HeaderSize + SignatureSize
|| length > MaxPayloadLength || memcmp(payload, "FMC1", 4)) return false;
const size_t command_length = payload[28];
if (!command_length || command_length > MaxCommandLength
|| length != HeaderSize + command_length + SignatureSize) return false;
const uint32_t sequence = read32(payload + 4), expires = read32(payload + 8);
if (!lifetimeAllowed(sequence, expires) || expires < now
|| (sequence > now && sequence - now > MaxClockLead)) return false;
if (!zero(payload + 12, TargetSize)) {
uint8_t target[TargetSize];
targetHash(self_public_key, target);
if (memcmp(target, payload + 12, TargetSize)) return false;
}
// Reject embedded NUL bytes, which would authorize a different text from
// the signed byte string when passed to the CLI parser.
if (memchr(payload + HeaderSize, 0, command_length)) return false;
char command[MaxCommandLength + 1];
memcpy(command, payload + HeaderSize, command_length);
command[command_length] = 0;
if (!commandAllowed(command)) return false;
const size_t unsigned_length = HeaderSize + command_length;
uint8_t message[SignedMessageCapacity];
const size_t message_length = signedMessage(key, payload, unsigned_length, message);
const bool valid = publisher.verify(payload + unsigned_length, message, int(message_length));
erase(message, sizeof(message));
if (!valid) return false;
output.sequence = sequence;
output.expires = expires;
memcpy(output.command, command, command_length + 1);
return true;
}
} // namespace mesh
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "../Identity.h"
#ifndef MESH_ENABLE_FLEET_CONTROL
// Fixed 240 KiB STM32 images have no room for fleet control.
#if defined(STM32_PLATFORM)
#define MESH_ENABLE_FLEET_CONTROL 0
#else
#define MESH_ENABLE_FLEET_CONTROL 1
#endif
#endif
namespace mesh {
// Signed, private-channel fleet commands. The channel key supplies privacy;
// only the publisher's Ed25519 signature authorizes a command. Receivers store
// the public identity and never need the publisher's private identity.
class FleetCommand {
public:
static constexpr uint16_t DataType = 0xFF01;
static constexpr size_t KeySize = 16;
static constexpr size_t TargetSize = 16;
static constexpr size_t SignatureSize = 64;
static constexpr size_t MaxCommandLength = 72;
static constexpr size_t HeaderSize = 29;
static constexpr size_t MaxPayloadLength = 165;
static constexpr uint32_t MinEpoch = 1735689600UL; // 2025-01-01 UTC
static constexpr uint32_t MaxLifetime = 600;
static constexpr uint32_t MaxClockLead = 60;
struct Decoded {
uint32_t sequence;
uint32_t expires;
char command[MaxCommandLength + 1];
};
static bool privateKeyAllowed(const uint8_t* key);
// "all" becomes the reserved zero target; otherwise require one complete
// public key and derive the first 16 bytes of SHA-256, not a short node ID.
static bool parseTarget(const char* text, uint8_t* target);
static bool commandAllowed(const char* command);
// Return the exact envelope size, or zero on invalid input. The signature
// binds the domain, private channel key, complete header, and command bytes.
static size_t encode(const LocalIdentity& publisher, const uint8_t* key,
uint32_t sequence, uint32_t expires,
const uint8_t* target, const char* command,
uint8_t* output, size_t capacity);
// Accept an exact envelope only: group framing owns padding validation.
// On success the caller must durably reject an old/repeated sequence before
// any mutation, and defer execution until the receive stack has unwound.
static bool decode(const Identity& publisher, const uint8_t* key,
const uint8_t* payload, size_t length, uint32_t now,
const uint8_t* self_public_key, Decoded& output);
};
} // namespace mesh
+54
View File
@@ -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;
}
};
+307
View File
@@ -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'),
+689
View File
@@ -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,&region,&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,&region,&packet,captured));
assert(!captureFleetReplyScope(regions,static_cast<Region*>(nullptr),&packet,captured));
region.wildcard=false;packet.transport_codes[0]^=1;
assert(!captureFleetReplyScope(regions,&region,&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();
}
+249
View File
@@ -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()
+376
View File
@@ -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 = &region;
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 = &region;
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, &region, &packet, selected));
assert(regions.calls == 0);
packet.scoped = true;
packet.payload[0] = 0x6D;
packet.transport_codes[1] = rotated.calcTransportCode(&packet);
assert(mesh::captureFleetReplyScope(regions, &region, &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, &region, &packet, selected));
packet.payload[0] ^= 1;
packet.transport_codes[0] = first.calcTransportCode(&packet);
packet.transport_codes[1] = 0;
assert(mesh::captureFleetReplyScope(regions, &region, &packet, selected));
assert(!memcmp(selected.key, first.key, sizeof(first.key)));
region.wildcard = true;
const unsigned calls = regions.calls;
assert(!mesh::captureFleetReplyScope(regions, &region, &packet, selected));
assert(!mesh::captureFleetReplyScope(regions, (const Region*)nullptr, &packet, selected));
assert(!mesh::captureFleetReplyScope(regions, &region, 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(&copy, &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()
+8 -1
View File
@@ -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),