diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 81270ac91..fc10faf0f 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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 diff --git a/README.md b/README.md index 433de4370..b26169e1c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/fleet_management.md b/docs/fleet_management.md new file mode 100644 index 000000000..618b3d905 --- /dev/null +++ b/docs/fleet_management.md @@ -0,0 +1,179 @@ +--- +layout: default +title: Private fleet management +--- + + + + + +# 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 +set fleet.channel +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 ` | +| Temporary second profile | `get tempradio2`, `set tempradio2 ` | +| Profile diagnostics | `get radio2.status`, `get radio2.timing` | +| Crossover | `get radio2.cross`, `set radio2.cross ` | +| 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). diff --git a/docs/index.md b/docs/index.md index 8142c8c7d..acc635ed5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index a29c35173..6c5717b42 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -4,6 +4,7 @@ #include "NotificationSettingsFile.h" #include "MyMesh.h" #include +#include #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 "); + 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; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index e7e695a23..fb7bd297c 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -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()); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 8e4a679de..b49659baf 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -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; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 901fd1bb1..f5d63a662 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -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(); diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 032770f70..acf7e6d56 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -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); diff --git a/mkdocs.yml b/mkdocs.yml index a680d0418..d9d843355 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/platformio.ini b/platformio.ini index 5940276e6..902e115ea 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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 diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 0229f08e0..801753275 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -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; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 314a3fbe7..15ccc717e 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -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 diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index feedaa649..df4a0b830 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -15,6 +15,10 @@ #include #include #include +#include +#if MESH_ENABLE_FLEET_CONTROL +#include +#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); diff --git a/src/helpers/CommonCLI_Management.cpp b/src/helpers/CommonCLI_Management.cpp index 5110b2b8e..f081eb58f 100644 --- a/src/helpers/CommonCLI_Management.cpp +++ b/src/helpers/CommonCLI_Management.cpp @@ -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; diff --git a/src/helpers/FleetChannel.cpp b/src/helpers/FleetChannel.cpp new file mode 100644 index 000000000..25a3d636a --- /dev/null +++ b/src/helpers/FleetChannel.cpp @@ -0,0 +1,237 @@ +#include "FleetChannel.h" +#include "FilePresence.h" +#include "FileRead.h" +#include "PersistentStoreFormat.h" +#include +#include +#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)) 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); +#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(mesh).onSendFail(packet); + mesh.releasePacket(packet); + } +} +} // namespace mesh diff --git a/src/helpers/FleetChannel.h b/src/helpers/FleetChannel.h new file mode 100644 index 000000000..79802c71e --- /dev/null +++ b/src/helpers/FleetChannel.h @@ -0,0 +1,100 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +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 +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 + 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 diff --git a/src/helpers/FleetCommand.cpp b/src/helpers/FleetCommand.cpp new file mode 100644 index 000000000..d7df4e18b --- /dev/null +++ b/src/helpers/FleetCommand.cpp @@ -0,0 +1,262 @@ +#include "FleetCommand.h" +#include +#include + +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 diff --git a/src/helpers/FleetCommand.h b/src/helpers/FleetCommand.h new file mode 100644 index 000000000..bd0395335 --- /dev/null +++ b/src/helpers/FleetCommand.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#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 diff --git a/test/fixtures/companion_fleet_dispatch.h b/test/fixtures/companion_fleet_dispatch.h new file mode 100644 index 000000000..6cb4d821e --- /dev/null +++ b/test/fixtures/companion_fleet_dispatch.h @@ -0,0 +1,54 @@ +#pragma once +#ifndef MESH_ENABLE_FLEET_CONTROL +#define MESH_ENABLE_FLEET_CONTROL 1 +#endif + +#include +#include +#include + +// 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 + 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; + } +}; diff --git a/test/test_companion_fleet_control.py b/test/test_companion_fleet_control.py new file mode 100644 index 000000000..c84e46f30 --- /dev/null +++ b/test/test_companion_fleet_control.py @@ -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 +#include +#include +#include +#include +#include +#include + +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 +#include +#include +#include +#include +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() diff --git a/test/test_companion_primary_radio_persistence.py b/test/test_companion_primary_radio_persistence.py index 5ef6ad5be..40ac3e38a 100644 --- a/test/test_companion_primary_radio_persistence.py +++ b/test/test_companion_primary_radio_persistence.py @@ -18,6 +18,7 @@ HARNESS = r''' #include #include #include +#include "companion_fleet_dispatch.h" #include #include #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'), diff --git a/test/test_fleet_channel_runtime.py b/test/test_fleet_channel_runtime.py new file mode 100644 index 000000000..e396fcd8e --- /dev/null +++ b/test/test_fleet_channel_runtime.py @@ -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 +#include +#include +#include +#include +#include +#include +#include +#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='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 +#include +#include +#include +#include +#include +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> 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 +#include +#include +#include +#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 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 copying; + std::function failure; + std::vector 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 +#include +#include +#include +#include +#include +@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& 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 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> invalid={{now-301,now-1},{now+61,now+100}, + {FleetCommand::MinEpoch-1,now+100},{now,now+FleetCommand::MaxLifetime+1},{now,now-1}}; + for(const auto& times:invalid){fake_ms+=1000; + Packet packet=signedUnchecked(publisher,times.first,times.second,"set radio2 off"); + assert(apply(fleet,fs,mesh,profiles,packet)==0); + } + mesh.clock.now=0;fake_ms+=1000;Packet packet=command(publisher,now); + assert(apply(fleet,fs,mesh,profiles,packet)==0);mesh.clock.now=now; + fake_ms+=1000;packet=signedUnchecked(publisher,now+60,now+100,"set radio2 off"); + assert(apply(fleet,fs,mesh,profiles,packet)==1); + }else if(scenario=="transaction_faults"){ + for(unsigned fault=0;fault<4;++fault){ + fs::FS disk;Mesh transport;RadioProfileCLI radio;FleetChannel receiver(&disk);enroll(receiver,publisher); + auto previous=disk.files.at(Path);Packet packet=command(publisher,transport.clock.now); + if(fault==0)disk.fail_open=true; + else if(fault==1)disk.fail_read=true; + else if(fault==2)disk.fail_rename=true; + else disk.fail_rename_from=std::string(Path)+".tmp"; + assert(apply(receiver,disk,transport,radio,packet)==0);assert(disk.files.at(Path)==previous); + assert(config(receiver,"get fleet.channel").find("store=error")!=std::string::npos); + } + }else if(scenario=="backup_replay"){ + enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now); + assert(apply(fleet,fs,mesh,profiles,packet)==1);const auto persisted=fs.files.at(Path); +#if defined(RP2040_PLATFORM) + fs.files.erase(Path);fs.files[std::string(Path)+".bak"]=persisted; +#else + // Atomic LittleFS replacement never has a live-name gap. Abandoned .tmp + // bytes are ignored; the committed live sequence remains authoritative. + fs.files[std::string(Path)+".tmp"]=persisted; +#endif + FleetChannel rebooted(&fs);assert(apply(rebooted,fs,mesh,profiles,packet)==0); + assert(storage::readLE32(fs.files.at(Path).data()+56)==mesh.clock.now); + + }else if(scenario=="wrong_publisher"){ + enroll(fleet,publisher);LocalIdentity stranger; + assert(memcmp(stranger.pub_key,publisher.pub_key,32)); + Packet packet=command(stranger,mesh.clock.now);assert(apply(fleet,fs,mesh,profiles,packet)==0); + assert(verify_calls==1&&mesh.queued==0&&storage::readLE32(fs.files[Path].data()+56)==0); + }else if(scenario=="bounded_ack"){ + enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now); + fleet.receive(&packet,mesh);fleet.service(mesh,profiles,"sixteen:character:very long name", + [&](uint32_t,const char*,char* reply){memset(reply,'x',159);reply[159]=0;}); + assert(mesh.queued==1);uint8_t data[184]={};Packet* ack=mesh.manager.outbound[0]; + assert(ack->payload_len<=MAX_PACKET_PAYLOAD); + assert(Utils::MACThenDecrypt(channel_key,data,ack->payload+1,ack->payload_len-1)==176); + assert(!strncmp((char*)data+5,"sixteen;characte",15)); + }else if(scenario=="unknown_scope"){ + enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);packet.scoped=true; + packet.transport_codes[0]=123;assert(apply(fleet,fs,mesh,profiles,packet,true)==1); + assert(mesh.queued==0&&mesh.released==1&&profiles.rollbacks==1&&!fleet.waiting()); + }else if(scenario=="rotated_scope"){ + struct Region {bool wildcard=false;bool isWildcard()const{return wildcard;}}; + struct Regions {TransportKey keys[2];int getTransportKeysFor(const Region&,TransportKey* out,int max){ + assert(max==MAX_TKS_ENTRIES);memcpy(out,keys,sizeof(keys));return 2;}} regions; + memset(regions.keys[0].key,0x11,16);memset(regions.keys[1].key,0x22,16); + Region region;TransportKey captured{};Packet packet=command(publisher,mesh.clock.now); + packet.scoped=true;packet.transport_codes[0]=regions.keys[1].calcTransportCode(&packet); + assert(packet.transport_codes[0]!=regions.keys[0].calcTransportCode(&packet)); + assert(captureFleetReplyScope(regions,®ion,&packet,captured)); + assert(!memcmp(captured.key,regions.keys[1].key,16)); + enroll(fleet,publisher);assert(apply(fleet,fs,mesh,profiles,packet,true,false,&captured)==1); + assert(mesh.ack_codes[0]==regions.keys[1].calcTransportCode(mesh.manager.outbound[0])); + assert(mesh.ack_codes[0]!=packet.transport_codes[0]); + region.wildcard=true;assert(!captureFleetReplyScope(regions,®ion,&packet,captured)); + assert(!captureFleetReplyScope(regions,static_cast(nullptr),&packet,captured)); + region.wildcard=false;packet.transport_codes[0]^=1; + assert(!captureFleetReplyScope(regions,®ion,&packet,captured)); + }else if(scenario=="existing_mutation"){ + enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now); + profiles.mutation=true;assert(apply(fleet,fs,mesh,profiles,packet)==0); + assert(storage::readLE32(fs.files[Path].data()+56)==0); + profiles.mutation=false;assert(apply(fleet,fs,mesh,profiles,packet)==1); + }else assert(false); + printf("fleet runtime %s passed\n",argv[1]); +} +''' + + +class FleetChannelRuntimeTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = shutil.which("g++") or shutil.which("clang++") + if compiler is None: + raise unittest.SkipTest("host C++ compiler required") + cls.work = tempfile.TemporaryDirectory(prefix="meshcore-fleet-runtime-") + work = Path(cls.work.name) + (work / "helpers").mkdir() + for name, content in {"Utils.h": UTILS, "FS.h": FILESYSTEM, "Mesh.h": MESH, + "helpers/RadioProfileCLI.h": PROFILE, + "Packet.h": '#pragma once\n#include \n', + "Adafruit_LittleFS.h": ''' +#pragma once +#include +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 \ninline fs::FS InternalFS;\n', + "Arduino.h": '#pragma once\n#include \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(out),length); }} + void finalizeHMAC(const uint8_t* key,size_t size,void* out,size_t length) {{ + SHA256Base::finalizeHMAC(key,size,static_cast(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() diff --git a/test/test_fleet_command/test_fleet_command.cpp b/test/test_fleet_command/test_fleet_command.cpp new file mode 100644 index 000000000..369998fb0 --- /dev/null +++ b/test/test_fleet_command/test_fleet_command.cpp @@ -0,0 +1,273 @@ +#include +#include +#include +#include +#include +#include +#include + +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 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 encode(const char* command = "get radio2", + uint32_t sequence = Now, + uint32_t expires = Now + 120, + const uint8_t* target = nullptr) { + std::vector 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& 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 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 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 output; + for (const uint8_t* key : {public_key, empty, static_cast(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 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 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 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(); +} diff --git a/test/test_fleet_command_crypto.py b/test/test_fleet_command_crypto.py new file mode 100644 index 000000000..f477cf4cd --- /dev/null +++ b/test/test_fleet_command_crypto.py @@ -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 +#include +#include +#include +#include +#include +#include +#include + +// 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 unhex(const char* input) { + const std::string text(input); assert(text.size() % 2 == 0); + std::vector 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 target; + if (!Fleet::parseTarget(argv[3], target.data())) { std::cout << "reject\n"; return 0; } + std::array 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(" +#include +#include +#include +#include +#include +#include +#define MESH_ENABLE_CLOCK_SYNC 0 +#define MAX_TKS_ENTRIES 16 +static std::vector 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 keys; + unsigned calls = 0; + int getTransportKeysFor(const Region&, TransportKey* out, int capacity) { + ++calls; + const int count = std::min(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 + void service(mesh::Mesh&, Profiles&, const char* name, Handler handler) { + assert(!strcmp(name, "fleet node")); ++serviced; + handler(sequence, command, reply); + } +}; +struct CLI { + Fleet fleet; + Profiles profiles; + bool enrolled = true; + Fleet* fleetChannel() { return enrolled ? &fleet : nullptr; } + Profiles& radioProfiles() { return profiles; } +}; +struct ClockSync { + unsigned observed = 0; + void observeGroupPacket(mesh::Packet*) { ++observed; } +}; +class Repeater : public mesh::Mesh { + public: + CLI _cli; + bool region_load_active = false; + Regions region_map; + Region region; + Region* recv_pkt_region = ®ion; + struct { const char* node_name = "fleet node"; } _prefs; + unsigned commands = 0; + uint32_t timestamp = 0; + @REPEATER_CAPABILITY@ + void onGroupPacketRecv(mesh::Packet*); + void serviceFleet(); + void handleCommand(uint32_t value, void* sender, char* command, char* reply) { + assert(sender == nullptr && !strcmp(command, "set radio2 off")); + timestamp = value; ++commands; strcpy(reply, "OK"); + } +}; +class Room : public mesh::Mesh { + public: + CLI _cli; + ClockSync _clock_sync; + bool region_load_active = false; + Regions region_map; + Region region; + Region* recv_pkt_region = ®ion; + struct { const char* node_name = "fleet node"; } _prefs; + unsigned commands = 0; + uint32_t timestamp = 0; + @ROOM_CAPABILITY@ + void onGroupPacketRecv(mesh::Packet*); + void serviceFleet(); +#if MESH_ENABLE_FLEET_CONTROL + void onSendComplete(mesh::Packet*) override; + void onSendFail(mesh::Packet*) override; + void onRadioProfileCopyQueued(mesh::Packet*, const mesh::Packet*, uint8_t) override; +#endif + void handleCommand(uint32_t value, char* command, char* reply) { + assert(!strcmp(command, "set radio2 off")); + timestamp = value; ++commands; strcpy(reply, "OK"); + } +}; +@METHODS@ + +template +void checkRole() { + Role role; + mesh::Packet packet; + TransportKey original, rotated; + memset(original.key, 0x11, sizeof(original.key)); + memset(rotated.key, 0x22, sizeof(rotated.key)); + role.region_map.keys = {original, rotated}; + packet.scoped = true; + packet.transport_codes[0] = rotated.calcTransportCode(&packet); + assert(role.supportsFleetControl()); + role.onGroupPacketRecv(&packet); + role.serviceFleet(); +#if MESH_ENABLE_FLEET_CONTROL + assert(role._cli.fleet.received == 1 && role._cli.fleet.serviced == 1); + assert(role._cli.fleet.scoped); + assert(!memcmp(role._cli.fleet.reply_scope.key, rotated.key, sizeof(rotated.key))); + assert(role.commands == 1 && role.timestamp == role._cli.fleet.sequence); + role.region_load_active = true; + role.serviceFleet(); + assert(role.commands == 1); + assert(strstr(role._cli.fleet.reply, "region load active")); +#else + assert(role._cli.fleet.received == 0 && role._cli.fleet.serviced == 0); + assert(role.commands == 0); +#endif + role._cli.enrolled = false; + const unsigned before = role._cli.fleet.serviced; + role.onGroupPacketRecv(&packet); + role.serviceFleet(); + assert(role._cli.fleet.serviced == before); +} + +static void checkScopeSelection() { + Regions regions; + Region region; + mesh::Packet packet; + TransportKey first, rotated, selected; + memset(first.key, 0x31, sizeof(first.key)); + memset(rotated.key, 0xA4, sizeof(rotated.key)); + regions.keys = {first, rotated}; + assert(!mesh::captureFleetReplyScope(regions, ®ion, &packet, selected)); + assert(regions.calls == 0); + packet.scoped = true; + packet.payload[0] = 0x6D; + packet.transport_codes[1] = rotated.calcTransportCode(&packet); + assert(mesh::captureFleetReplyScope(regions, ®ion, &packet, selected)); + assert(!memcmp(selected.key, rotated.key, sizeof(rotated.key))); + // Codes belong to the received payload. Changing it invalidates the match. + packet.payload[0] ^= 1; + assert(!mesh::captureFleetReplyScope(regions, ®ion, &packet, selected)); + packet.payload[0] ^= 1; + packet.transport_codes[0] = first.calcTransportCode(&packet); + packet.transport_codes[1] = 0; + assert(mesh::captureFleetReplyScope(regions, ®ion, &packet, selected)); + assert(!memcmp(selected.key, first.key, sizeof(first.key))); + region.wildcard = true; + const unsigned calls = regions.calls; + assert(!mesh::captureFleetReplyScope(regions, ®ion, &packet, selected)); + assert(!mesh::captureFleetReplyScope(regions, (const Region*)nullptr, &packet, selected)); + assert(!mesh::captureFleetReplyScope(regions, ®ion, nullptr, selected)); + assert(regions.calls == calls); +} + +int main() { + checkScopeSelection(); + checkRole(); checkRole(); + Room room; mesh::Packet first, copy; + room.onGroupPacketRecv(&first); + assert(room._clock_sync.observed == 1); // Existing clock delivery survives. + room.onSendComplete(&first); + room.onSendFail(&first); + room.onRadioProfileCopyQueued(©, &first, 7); +#if MESH_ENABLE_FLEET_CONTROL + assert((events == std::vector{1,2,3,4,5,6})); +#else + assert((events == std::vector{1,3,5})); +#endif + puts("Fleet role receive, deferred dispatch, busy guard and callbacks passed"); +} +''' + +AUTHORIZATION = r''' +#include +#include +#include +#include +#include +#include +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 ") + 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() diff --git a/test/test_local_cli_access.py b/test/test_local_cli_access.py index 7eab0cf76..6eebc2d13 100644 --- a/test/test_local_cli_access.py +++ b/test/test_local_cli_access.py @@ -20,6 +20,7 @@ PREAMBLE = r''' #include #include #include +#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),