From fdad79eaf70cfabb1cced60bb34dc0f6ef0af92f Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 9 Oct 2026 00:46:50 -0700 Subject: [PATCH] Support atomic two-packet signed fleet commands Automatically split longer signed envelopes into two bounded group-data packets, reserve both before queueing, and roll back exact packet copies and retries on admission failure. Collect fragments in either order with a fixed deadline and verify the complete command before durable replay reservation and dispatch. Raise whole-fleet protocol capacity to 230 bytes, preserve target-specific budgets and radio acknowledgement barriers, and reject rule-token and USB rescue-line truncation. Document existing app input limits and add real-signature, sanitizer, queue failure, timeout, replay, and compact-board regressions. --- README.md | 2 +- docs/fleet_management.md | 70 ++- examples/companion_radio/MyMesh.cpp | 115 ++++- examples/companion_radio/MyMesh.h | 10 + examples/simple_repeater/MyMesh.cpp | 25 +- .../simple_room_server/FloodRuleEngine.cpp | 13 +- src/helpers/FleetChannel.cpp | 80 ++- src/helpers/FleetChannel.h | 14 +- src/helpers/FleetCommand.cpp | 54 +- src/helpers/FleetCommand.h | 28 +- test/fixtures/companion_fleet_dispatch.h | 6 +- test/test_companion_extrafs_hil_guard.py | 2 +- test/test_companion_fleet_control.py | 472 +++++++++++++++++- test/test_fleet_channel_runtime.py | 322 +++++++++++- .../test_fleet_command/test_fleet_command.cpp | 209 +++++++- test/test_fleet_command_crypto.py | 295 ++++++++++- test/test_nrf52_extrafs_contract.py | 4 +- .../test_transport_filter.cpp | 71 +++ 18 files changed, 1649 insertions(+), 143 deletions(-) diff --git a/README.md b/README.md index 6d625119c..2f31f8596 100644 --- a/README.md +++ b/README.md @@ -3,7 +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, secondary-radio changes, radio schedules, and clock updates to a whole fleet, one node, or a comma-separated list of public-key targets. The publisher signs commands; receivers hold its public verification key, with replay protection and acknowledgements before radio handoff. +* **[Private fleet management](https://mikecarper.github.io/MeshCore/fleet_management/).** Deploy filtering, secondary-radio changes, radio schedules, and clock updates to a whole fleet, one node, or a comma-separated list of public-key targets. The publisher signs each complete command once, splitting longer requests across two packets; 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 index 5886b94d1..0f9f02a52 100644 --- a/docs/fleet_management.md +++ b/docs/fleet_management.md @@ -61,6 +61,8 @@ 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. +`get fleet.stats` includes `parts`, the number of fragments currently buffered +for an incomplete two-packet command. 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; @@ -107,10 +109,9 @@ command only once, and the signature protects the entire list. On air, full keys use a 128-bit SHA-256 digest; prefixes use their original public-key bytes. Whole-fleet commands use the compact FMC2 format with no target records, whether the CLI target is omitted or explicitly `all`. This reduces the -header from 29 to 14 bytes. Whole-fleet, multiple-target, and prefix commands -require receivers that support FMC2. A single complete key keeps the legacy FMC1 -format, and updated receivers also accept legacy whole-fleet packets. Separate -fleets use separate channel keys; named subgroups are not part of this first version. +header from 29 to 14 bytes. A single complete key uses the smaller fixed-target +FMC1 format. 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. @@ -124,18 +125,53 @@ sequence; the operation should be chosen to be idempotent. ## Supported controls -The command body is limited to **72 ASCII bytes**. Targets and command must also -fit one signed **165-byte envelope**. An 8-character target uses 5 bytes, a -12-character target uses 7, and a complete key uses 17. The fixed envelope and -signature use 78 bytes, leaving 87 bytes for the target list and command together. -For example, two complete keys leave 53 bytes for the command. Lists are capped -at 17 entries; packet-size limits usually allow fewer. Omitting targets or using -`all` saves 15 header bytes; the command limit remains 72 bytes. A single complete -key uses the legacy 29-byte header and also allows a full 72-byte command. -The local app/console's command-length limit also applies. Oversized or invalid -lists are rejected as a whole, without sending or truncating any targets. -The short legacy USB rescue console cannot fit a full-key command; use the app -or normal terminal for longer lists. +The Companion automatically uses **one or two LoRa packets**, depending on the +signed command's size. Whole-fleet commands fit **87 ASCII bytes** in one packet +or **230 bytes** across two. Targets consume some of that allowance: + +| Targets | One packet: command bytes | Two packets: command bytes | +| --- | ---: | ---: | +| Omitted or `all` | 87 | 230 | +| One 8-character public-key prefix | 82 | 225 | +| One 12-character public-key prefix | 80 | 223 | +| One complete 64-character public key | 72 | 215 | +| Two 8-character public-key prefixes | 77 | 220 | +| Two complete public keys | 53 | 196 | + +Each prefix in a list uses 5 or 7 bytes; each complete key in a list uses 17. +A single complete key uses the smaller fixed-target format with a 29-byte header, +so its limits come from packet space. Lists are capped at 17 entries. Requests +exceeding their target-specific budget are rejected before signing or sending, +with no partial target list. + +The existing app/console input limit also applies. The app's normal 176-byte +command frame fits **161 command bytes** after `fleet send 3 `; a correlated +request uses three more bytes, and explicit targets reduce the space further. +The shorter USB rescue console has an 80-byte input buffer. The 230-byte limit +is the fleet protocol's capacity, rather than a promise that every console can +enter that much text. Oversized or invalid lists are rejected as a whole, +without sending or truncating any targets. + +### Two-packet delivery + +Longer commands keep **one 64-byte signature over the entire command**, including +its channel, targets, sequence and expiry. The signed envelope is split into two +`FMP1` fragments. Each fragment has an 11-byte collection header and up to 154 +envelope bytes, within the existing 165-byte group-data limit. Together they can +carry a 308-byte signed envelope. No third packet is used. + +The Companion reserves both packets before sending. If either cannot be queued, +it cancels the queued part and any radio copy, reports an error, and leaves the +sequence available for a retry. Successful queueing still does not guarantee +delivery over LoRa. + +A receiver accepts either arrival order and identical duplicates. It executes +and acknowledges **only after both parts arrive and the complete signature, +target, clock and replay checks pass**. Missing or altered parts cannot execute +a partial command. One bounded assembly is kept for up to five minutes from the +first part; duplicates do not extend that deadline. A fresh command with a newer +eligible sequence replaces an incomplete older transfer, so a lost part does +not block retries. Reboot or changing enrollment discards the partial transfer. | Control | Fleet commands | | --- | --- | @@ -212,7 +248,7 @@ move nodes off the normal network; use a temporary window or a planned recovery path for experiments. A forwarding policy can also 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 +one bounded mailbox and one 308-byte fragment assembly. 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. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index cd2e070a6..3dfb36f8a 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1691,6 +1691,64 @@ bool MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk } } +#if MESH_ENABLE_FLEET_CONTROL +void MyMesh::onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original, uint8_t priority) { + mesh::Mesh::onRadioProfileCopyQueued(packet, original, priority); + if (original && original == _fleet_admission_original) _fleet_admission_copy = packet; +} + +bool MyMesh::sendFleetCommandData(mesh::GroupChannel& channel, const uint8_t* envelope, size_t length) { + if (!envelope || !length || length > mesh::FleetCommand::MaxEnvelopeLength) return false; + if (length <= mesh::FleetCommand::MaxPayloadLength) { + return sendGroupData(channel, nullptr, OUT_PATH_UNKNOWN, + mesh::FleetCommand::DataType, envelope, (int)length); + } + mesh::Packet* reserved[2] = {}; + uint8_t group[3 + mesh::FleetCommand::MaxPayloadLength]; + group[0] = (uint8_t)mesh::FleetCommand::DataType; + group[1] = (uint8_t)(mesh::FleetCommand::DataType >> 8); + // Reserve both encrypted packets before handing either to the dispatcher. + for (uint8_t part = 0; part < 2; ++part) { + const size_t bytes = mesh::FleetCommand::fragment(envelope, length, part, + group + 3, sizeof(group) - 3); + if (bytes) { + group[2] = (uint8_t)bytes; + reserved[part] = createGroupDatagram(PAYLOAD_TYPE_GRP_DATA, channel, group, 3 + bytes); + } + if (!reserved[part]) { + if (reserved[0]) releasePacket(reserved[0]); + memset(group, 0, sizeof(group)); + return false; + } + } + memset(group, 0, sizeof(group)); + _fleet_admission_original = reserved[0]; + _fleet_admission_copy = nullptr; + const bool first_queued = sendFloodScoped(channel, reserved[0]); + mesh::Packet* first_copy = _fleet_admission_copy; + _fleet_admission_original = nullptr; + _fleet_admission_copy = nullptr; + if (!first_queued) { + // sendFloodScoped/sendPacket owns and releases the failed first packet. + releasePacket(reserved[1]); + return false; + } + if (sendFloodScoped(channel, reserved[1])) return true; + // A failed second send has already been released. No dispatcher loop runs + // inside admission, so the first packet and its optional copy are still + // queued and can be retired by exact pointer without touching other work. + for (int i = _mgr->getOutboundTotal() - 1; i >= 0; --i) { + const mesh::Packet* queued = _mgr->getOutboundByIdx(i); + if (queued != reserved[0] && queued != first_copy) continue; + mesh::Packet* cancelled = _mgr->removeOutboundByIdx(i); + cancelOutboundRadioRetry(cancelled); + onSendFail(cancelled); // Clear its pending flood retry metadata too. + releasePacket(cancelled); + } + return false; +} +#endif + void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text) { #if MESH_ENABLE_ONE_KEY_DM @@ -5264,7 +5322,10 @@ void MyMesh::resetUsbHostSessionInput() { // CLI rescue deliberately remains active across reconnects, but no bytes // typed by the old host may complete a command for the new one. This is // especially important for the rescue shell's erase/rm commands. - if (_cli_rescue) memset(cli_command, 0, sizeof(cli_command)); + if (_cli_rescue) { + memset(cli_command, 0, sizeof(cli_command)); + _cli_line_overflow = false; + } } void MyMesh::handleCmdFrame(size_t len) { @@ -9520,6 +9581,7 @@ void MyMesh::handleTerminalCommand(char* command) { void MyMesh::enterCLIRescue() { _cli_rescue = true; cli_command[0] = 0; + _cli_line_overflow = false; mesh::usbTerminalPort().println("========= CLI Rescue ========="); } @@ -9784,16 +9846,15 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, snprintf(reply, reply_capacity, "Error: wait for the next clock second before fleet send"); return true; } - uint8_t payload[mesh::FleetCommand::MaxPayloadLength]; + uint8_t payload[mesh::FleetCommand::MaxEnvelopeLength]; const size_t length = mesh::FleetCommand::encode( self_id, channel.channel.secret, sequence, sequence + mesh::FleetCommand::MaxLifetime, targets, text, payload, sizeof(payload)); if (!length) { snprintf(reply, reply_capacity, - "Error: fleet targets and command exceed one packet; use fewer targets"); - } else if (!sendGroupData(channel.channel, nullptr, OUT_PATH_UNKNOWN, - mesh::FleetCommand::DataType, payload, (int)length)) { + "Error: fleet targets and command exceed two packets; use fewer targets"); + } else if (!sendFleetCommandData(channel.channel, payload, length)) { snprintf(reply, reply_capacity, "Error: could not queue fleet command"); } else { last_fleet_sequence = sequence; @@ -10076,30 +10137,42 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, void MyMesh::checkCLIRescueCmd() { Stream& output = mesh::usbTerminalPort(); Stream& input = mesh::usbCompanionPort(); - int len = strlen(cli_command); - // `cli_command` must stay NUL-terminated within its bounds. If it ever isn't, - // strlen() above can return >= sizeof(cli_command) and the loop below would - // then index past the buffer, so clamp defensively. - if (len >= (int)sizeof(cli_command)) { + size_t len = strnlen(cli_command, sizeof(cli_command)); + if (len == sizeof(cli_command)) { cli_command[0] = 0; len = 0; + _cli_line_overflow = true; } - while (input.available() && len < sizeof(cli_command)-1) { + unsigned budget = sizeof(cli_command); + while (budget-- && input.available()) { char c = input.read(); - if (c != '\n') { - cli_command[len++] = c; - cli_command[len] = 0; - } output.print(c); // echo - } - if (len == sizeof(cli_command)-1) { // buffer full: treat as a completed line - cli_command[sizeof(cli_command)-2] = '\r'; // place end-of-line marker inside the buffer - cli_command[sizeof(cli_command)-1] = 0; // keep the buffer NUL-terminated + if (c == '\r' || c == '\n') { + if (_cli_line_overflow) { + _cli_line_overflow = false; +#if defined(STM32_PLATFORM) + output.println(" Error: unknown command"); +#else + output.print("ERR: too long\r\n"); +#endif + } else if (len) goto complete_line; + return; + } + if (_cli_line_overflow) continue; + if (len == sizeof(cli_command)-1) { + _cli_line_overflow = true; + cli_command[0] = 0; + len = 0; + continue; + } + cli_command[len++] = c; + cli_command[len] = 0; } - if (len > 0 && cli_command[len - 1] == '\r') { // received complete line - cli_command[len - 1] = 0; // replace newline with C string null terminator + return; +complete_line: + { reply_buf[0] = 0; #if COMPANION_FEATURE_READER if (mesh::handleReaderCommand(cli_command, output)) { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index b416db7af..3b50d486b 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -355,6 +355,10 @@ protected: bool sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis); bool sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override; bool sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override; +#if MESH_ENABLE_FLEET_CONTROL + bool sendFleetCommandData(mesh::GroupChannel& channel, const uint8_t* envelope, size_t length); + void onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original, uint8_t priority) override; +#endif void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; #if MESH_PACKET_LOGGING && !MESH_PACKET_LOGGING_COMPACT @@ -466,6 +470,11 @@ public: bool canRecoverUsbLogging() const; private: +#if MESH_ENABLE_FLEET_CONTROL + // Exact ownership of the optional radio copy during two-part admission. + mesh::Packet* _fleet_admission_original = nullptr; + mesh::Packet* _fleet_admission_copy = nullptr; +#endif // Only snapshot the changed value: CompanionNodePrefs owns self-referencing // runtime adapters and must not be copied as a transaction snapshot. template bool savePreference(T& preference, T value) { @@ -723,6 +732,7 @@ private: #endif bool send_unscoped; // force un-scoped flood (instead of using send_scope) char cli_command[80]; + bool _cli_line_overflow = false; char reply_buf[166]; uint8_t app_target_ver; uint8_t *sign_data; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index fb613bc03..07cf4cf1a 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -7956,12 +7956,12 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, } } cursor = skipFloodFilterSpaces(cursor); - if (strlen(cursor) >= 192) { + char params[256]; + if (strlen(cursor) >= sizeof(params)) { strcpy(reply, "Err - rule parameters too long"); return; } - char params[192]; strcpy(params, cursor); char* tokens[18]; int token_count = 0; @@ -8244,9 +8244,12 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, } char rate_text[24]; const bool compact = floodFilterAsciiStartsWith(tokens[i], "q="); - StrHelper::strncpy(rate_text, - tokens[i] + (compact ? 2 : strlen("rate=")), - sizeof(rate_text)); + const char* value = tokens[i] + (compact ? 2 : strlen("rate=")); + if (strlen(value) >= sizeof(rate_text)) { + strcpy(reply, "Err - rate value too long"); + return; + } + StrHelper::strncpy(rate_text, value, sizeof(rate_text)); char* slash = strchr(rate_text, '/'); if ((!compact && slash == NULL) || (slash != NULL && !(strcmp(slash, "/min") == 0 @@ -8574,12 +8577,16 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, } } cursor = skipFloodFilterSpaces(cursor); - if (strlen(cursor) >= 140) { +#if MESH_ENABLE_FLEET_CONTROL + char params[256]; +#else + char params[140]; +#endif + if (strlen(cursor) >= sizeof(params)) { strcpy(reply, "Err - filter parameters too long"); return; } - char params[140]; strcpy(params, cursor); char* tokens[7]; int token_count = 0; @@ -10175,6 +10182,10 @@ void MyMesh::setFloodGroupModeration(const char* args, char* reply) { return; } char rate_text[24]; + if (strlen(option + 5) >= sizeof(rate_text)) { + strcpy(reply, "Err - rate value too long"); + return; + } StrHelper::strncpy(rate_text, option + 5, sizeof(rate_text)); char* slash = strchr(rate_text, '/'); if (slash == NULL || !(strcmp(slash, "/min") == 0 || strcmp(slash, "/m") == 0)) { diff --git a/examples/simple_room_server/FloodRuleEngine.cpp b/examples/simple_room_server/FloodRuleEngine.cpp index d7cae754d..44069042a 100644 --- a/examples/simple_room_server/FloodRuleEngine.cpp +++ b/examples/simple_room_server/FloodRuleEngine.cpp @@ -1275,12 +1275,12 @@ void FloodRuleEngine::set(const char* args, char* reply, } } cursor = skipSpaces(cursor); - if (strlen(cursor) >= 192) { + char params[256]; + if (strlen(cursor) >= sizeof(params)) { copyString(reply, "Err - rule parameters too long", 160); return; } - char params[192]; copyString(params, cursor, sizeof(params)); char* tokens[18]; int token_count = 0; @@ -1564,9 +1564,12 @@ void FloodRuleEngine::set(const char* args, char* reply, } bool compact = asciiStartsWith(tokens[i], "q="); char rate_text[24]; - copyString(rate_text, - tokens[i] + (compact ? 2 : strlen("rate=")), - sizeof(rate_text)); + const char* value = tokens[i] + (compact ? 2 : strlen("rate=")); + if (strlen(value) >= sizeof(rate_text)) { + copyString(reply, "Err - rate value too long", 160); + return; + } + copyString(rate_text, value, sizeof(rate_text)); char* slash = strchr(rate_text, '/'); if ((!compact && slash == NULL) || (slash != NULL && !(strcmp(slash, "/min") == 0 diff --git a/src/helpers/FleetChannel.cpp b/src/helpers/FleetChannel.cpp index 4826042b6..06dc3990e 100644 --- a/src/helpers/FleetChannel.cpp +++ b/src/helpers/FleetChannel.cpp @@ -78,9 +78,10 @@ bool FleetChannel::handleConfig(const char* command, char* reply, size_t capacit 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", + snprintf(reply, capacity, "> accepted=%lu rejected=%lu busy=%lu pending=%u ack=%u parts=%u", (unsigned long)accepted_, (unsigned long)rejected_, (unsigned long)busy_, - unsigned(pending_), unsigned(barrier_.waiting())); + unsigned(pending_), unsigned(barrier_.waiting()), + unsigned(bool(assembly_parts_ & 1)) + unsigned(bool(assembly_parts_ & 2))); } else if (!strncmp(command, "set fleet.channel ", 18)) { if (pending_ || barrier_.waiting()) { snprintf(reply, capacity, "Err - fleet command pending; retry later"); return true; @@ -96,6 +97,7 @@ bool FleetChannel::handleConfig(const char* command, char* reply, size_t capacit snprintf(reply, capacity, "Err - fleet settings not saved; store unavailable"); return true; } configured_ = enabled; + clearAssembly(); memset(channel_.secret, 0, sizeof(channel_.secret)); memcpy(channel_.secret, key, sizeof(key)); Utils::sha256(channel_.hash, sizeof(channel_.hash), key, sizeof(key)); @@ -115,6 +117,7 @@ bool FleetChannel::handleConfig(const char* command, char* reply, size_t capacit } memcpy(controller_.pub_key, key, 32); controller_set_ = !zero(key, 32); + clearAssembly(); snprintf(reply, capacity, "OK - fleet publisher %s", controller_set_ ? "set" : "off"); } else return false; return true; @@ -139,29 +142,82 @@ void FleetChannel::receive(Packet* packet, Mesh& mesh, const TransportKey* scope (void)mesh; } -bool FleetChannel::decode(Mesh& mesh, FleetCommand::Decoded& command) { +void FleetChannel::clearAssembly() { + memset(assembly_, 0, sizeof(assembly_)); + assembly_sequence_ = assembly_deadline_ = 0; + assembly_length_ = 0; + assembly_parts_ = 0; +} + +void FleetChannel::serviceAssemblyDeadline() { + if (assembly_parts_ && int32_t(uint32_t(millis()) - assembly_deadline_) >= 0) + clearAssembly(); +} + +FleetChannel::DecodeResult 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; + if (verify_attempts_ >= 4) return DecodeResult::Rejected; ++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; + || data[1] != uint8_t(FleetCommand::DataType >> 8)) return DecodeResult::Rejected; 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::MinHeaderSize + 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; + if (payload_length > FleetCommand::MaxPayloadLength || unpadded > (size_t)length + || (unpadded + 15) / 16 * 16 != (size_t)length + || !zero(data + unpadded, length - unpadded)) return DecodeResult::Rejected; + const uint8_t* envelope = data + 3; + size_t envelope_length = payload_length; + bool assembled = false; + if (payload_length >= 4 && !memcmp(envelope, "FMP1", 4)) { + FleetCommand::Fragment fragment; + if (!FleetCommand::parseFragment(envelope, payload_length, fragment) + || fragment.sequence <= last_sequence_) return DecodeResult::Rejected; + const uint32_t now = mesh.getRTCClock()->getCurrentTime(); + if (fragment.sequence < FleetCommand::MinEpoch + || (fragment.sequence > now && fragment.sequence - now > FleetCommand::MaxClockLead) + || (now > fragment.sequence && now - fragment.sequence > FleetCommand::MaxLifetime)) + return DecodeResult::Rejected; + // A fresh retry must not wait five minutes for a lost second fragment. + // Older transfers and conflicting fragments cannot disturb newer intent. + if (assembly_parts_ && fragment.sequence > assembly_sequence_) clearAssembly(); + if (assembly_parts_ && (assembly_sequence_ != fragment.sequence + || assembly_length_ != fragment.total_length)) + return DecodeResult::Rejected; + if (!assembly_parts_) { + assembly_sequence_ = fragment.sequence; + assembly_length_ = fragment.total_length; + // A duplicate cannot extend this fixed five-minute collection window. + assembly_deadline_ = now_ms + 300000UL; + } + const size_t offset = fragment.index * FleetCommand::FragmentDataLength; + const uint8_t mask = uint8_t(1U << fragment.index); + if (assembly_parts_ & mask) { + return !memcmp(assembly_ + offset, fragment.data, fragment.length) + ? DecodeResult::Partial : DecodeResult::Rejected; + } + memcpy(assembly_ + offset, fragment.data, fragment.length); + assembly_parts_ |= mask; + if (assembly_parts_ != 3) return DecodeResult::Partial; + envelope = assembly_; + envelope_length = assembly_length_; + assembled = true; + } + const bool valid = envelope_length >= FleetCommand::MinHeaderSize + FleetCommand::SignatureSize + && storage::readLE32(envelope + 4) > last_sequence_ + && (!assembled || storage::readLE32(envelope + 4) == assembly_sequence_) + && FleetCommand::decode(controller_, channel_.secret, envelope, envelope_length, + mesh.getRTCClock()->getCurrentTime(), mesh.self_id.pub_key, command); + if (assembled || (valid && assembly_parts_ && command.sequence >= assembly_sequence_)) clearAssembly(); + if (!valid) return DecodeResult::Rejected; broadcast_ = command.broadcast; - return true; + return DecodeResult::Accepted; } bool FleetChannel::acknowledge(Mesh& mesh, const char* name, uint32_t sequence, diff --git a/src/helpers/FleetChannel.h b/src/helpers/FleetChannel.h index 483b3efb7..21fc7c593 100644 --- a/src/helpers/FleetChannel.h +++ b/src/helpers/FleetChannel.h @@ -54,6 +54,10 @@ class FleetChannel { bool broadcast_ = false; bool ack_profile_mutation_ = false; uint8_t incoming_[MAX_PACKET_PAYLOAD]{}; + uint8_t assembly_[FleetCommand::MaxEnvelopeLength]{}; + uint32_t assembly_sequence_ = 0, assembly_deadline_ = 0; + uint16_t assembly_length_ = 0; + uint8_t assembly_parts_ = 0; uint8_t incoming_len_ = 0, rx_profile_ = 0, path_hash_size_ = 1; uint32_t rx_generation_ = 0; TransportKey reply_scope_{}; @@ -63,7 +67,10 @@ class FleetChannel { bool load(); bool save(bool enabled, const uint8_t* key, const uint8_t* controller, uint32_t sequence); - bool decode(Mesh& mesh, FleetCommand::Decoded& command); + enum class DecodeResult { Rejected, Partial, Accepted }; + DecodeResult decode(Mesh& mesh, FleetCommand::Decoded& command); + void clearAssembly(); + void serviceAssemblyDeadline(); bool acknowledge(Mesh& mesh, const char* name, uint32_t sequence, const char* reply, bool radio_mutation); void finish(RadioProfileCLI& profiles, bool delivered); @@ -83,12 +90,15 @@ class FleetChannel { void service(Mesh& mesh, RadioProfileCLI& profiles, const char* name, Handler handle) { serviceDeadline(mesh, profiles); + serviceAssemblyDeadline(); if (!pending_) return; pending_ = false; if (barrier_.waiting() || profiles.hasReplyMutation() || (hooks_ && hooks_->hasFleetReplyMutation())) { ++busy_; return; } FleetCommand::Decoded command; - if (!decode(mesh, command)) { ++rejected_; return; } + const DecodeResult decoded = decode(mesh, command); + if (decoded == DecodeResult::Partial) return; + if (decoded != DecodeResult::Accepted) { ++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)) { diff --git a/src/helpers/FleetCommand.cpp b/src/helpers/FleetCommand.cpp index 6bafe41f4..1d065c3df 100644 --- a/src/helpers/FleetCommand.cpp +++ b/src/helpers/FleetCommand.cpp @@ -7,11 +7,16 @@ namespace { constexpr char Domain[] = "MeshCoreFleet1"; constexpr size_t SignedMessageCapacity = sizeof(Domain) - 1 - + FleetCommand::KeySize + FleetCommand::MaxPayloadLength + + FleetCommand::KeySize + FleetCommand::MaxEnvelopeLength - FleetCommand::SignatureSize; -static_assert(FleetCommand::HeaderSize + FleetCommand::MaxCommandLength - + FleetCommand::SignatureSize <= FleetCommand::MaxPayloadLength, - "Legacy fleet envelope must fit the shared packet budget"); +static_assert(FleetCommand::HeaderSize + FleetCommand::FixedTargetMaxCommandLength + + FleetCommand::SignatureSize <= FleetCommand::MaxEnvelopeLength, + "Fixed-target fleet envelope must fit the two-packet budget"); +static_assert(FleetCommand::MinHeaderSize + FleetCommand::MaxCommandLength + + FleetCommand::SignatureSize <= FleetCommand::MaxEnvelopeLength, + "Compact fleet envelope must fit the two-packet budget"); +static_assert(FleetCommand::MaxCommandLength <= UINT8_MAX, + "Fleet command length must fit the signed length byte"); constexpr uint8_t PublicKey[FleetCommand::KeySize] = { 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72 @@ -355,7 +360,7 @@ size_t FleetCommand::encode(const LocalIdentity& publisher, const uint8_t* key, 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; + if (length > MaxEnvelopeLength || capacity < length) return 0; memcpy(output, "FMC1", 4); write32(output + 4, sequence); write32(output + 8, expires); @@ -389,7 +394,7 @@ size_t FleetCommand::encode(const LocalIdentity& publisher, const uint8_t* key, const size_t command_length = strlen(command); const size_t unsigned_length = MinHeaderSize + targets.length + command_length; const size_t length = unsigned_length + SignatureSize; - if (length > MaxPayloadLength || capacity < length) return 0; + if (length > MaxEnvelopeLength || capacity < length) return 0; memcpy(output, "FMC2", 4); write32(output + 4, sequence); write32(output + 8, expires); @@ -411,7 +416,7 @@ bool FleetCommand::decode(const Identity& publisher, const uint8_t* key, if (!privateKeyAllowed(key) || !payload || !self_public_key || zero(publisher.pub_key, PUB_KEY_SIZE) || zero(self_public_key, PUB_KEY_SIZE) || now < MinEpoch || length < MinHeaderSize + SignatureSize - || length > MaxPayloadLength) return false; + || length > MaxEnvelopeLength) return false; const size_t unsigned_length = length - SignatureSize; size_t command_offset; bool broadcast; @@ -458,4 +463,39 @@ bool FleetCommand::decode(const Identity& publisher, const uint8_t* key, return true; } +size_t FleetCommand::fragment(const uint8_t* envelope, size_t length, uint8_t index, + uint8_t* output, size_t capacity) { + if (!envelope || !output || length <= MaxPayloadLength + || length > MaxEnvelopeLength || index > 1 + || (memcmp(envelope, "FMC1", 4) && memcmp(envelope, "FMC2", 4))) return 0; + const size_t offset = size_t(index) * FragmentDataLength; + const size_t chunk_length = index ? length - offset : FragmentDataLength; + const size_t fragment_length = FragmentHeaderSize + chunk_length; + if (capacity < fragment_length) return 0; + memcpy(output, "FMP1", 4); + memcpy(output + 4, envelope + 4, 4); // The receiver checks this against signed UTC sequence. + output[8] = uint8_t(length); + output[9] = uint8_t(length >> 8); + output[10] = index; + memcpy(output + FragmentHeaderSize, envelope + offset, chunk_length); + return fragment_length; +} + +bool FleetCommand::parseFragment(const uint8_t* payload, size_t length, Fragment& output) { + output = {}; + if (!payload || length < FragmentHeaderSize || length > MaxPayloadLength + || memcmp(payload, "FMP1", 4)) return false; + const uint16_t total = uint16_t(payload[8]) | (uint16_t(payload[9]) << 8); + const uint8_t index = payload[10]; + if (total <= MaxPayloadLength || total > MaxEnvelopeLength || index > 1) return false; + const size_t chunk_length = index ? total - FragmentDataLength : FragmentDataLength; + if (length != FragmentHeaderSize + chunk_length) return false; + output.sequence = read32(payload + 4); + output.total_length = total; + output.index = index; + output.data = payload + FragmentHeaderSize; + output.length = chunk_length; + return true; +} + } // namespace mesh diff --git a/src/helpers/FleetCommand.h b/src/helpers/FleetCommand.h index 795e2e3ec..cad7f97be 100644 --- a/src/helpers/FleetCommand.h +++ b/src/helpers/FleetCommand.h @@ -16,12 +16,17 @@ public: 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 MinHeaderSize = 14; + static constexpr size_t MaxPayloadLength = 165; + static constexpr size_t FragmentHeaderSize = 11; + static constexpr size_t FragmentDataLength = MaxPayloadLength - FragmentHeaderSize; + static constexpr size_t MaxEnvelopeLength = 2 * FragmentDataLength; + static constexpr size_t SinglePacketMaxCommandLength = MaxPayloadLength - MinHeaderSize - SignatureSize; + static constexpr size_t MaxCommandLength = MaxEnvelopeLength - MinHeaderSize - SignatureSize; + static constexpr size_t FixedTargetMaxCommandLength = MaxEnvelopeLength - HeaderSize - SignatureSize; static constexpr size_t MaxTargetBytes = 86; static constexpr size_t MaxTargetCount = 17; - 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; @@ -41,6 +46,14 @@ public: uint8_t data[MaxTargetBytes]; }; + struct Fragment { + uint32_t sequence; + uint16_t total_length; + uint8_t index; + const uint8_t* data; + size_t length; + }; + 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. @@ -59,7 +72,7 @@ public: // Broadcast and prefix/list requests use FMC2. Zero records imply all; // a single complete key retains FMC1 encoding. Targets and command share - // the packet budget, so valid lists can still be too large for a command. + // the two-packet envelope budget, so lists reduce the command allowance. static size_t encode(const LocalIdentity& publisher, const uint8_t* key, uint32_t sequence, uint32_t expires, const Targets& targets, const char* command, @@ -71,6 +84,15 @@ public: 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); + + // Envelopes <= MaxPayloadLength travel unchanged. Longer envelopes use + // exactly two FMP1 frames, each bounded by the group payload limit. + static size_t fragment(const uint8_t* envelope, size_t length, uint8_t index, + uint8_t* output, size_t capacity); + // Fragment metadata is untrusted. The receiver must assemble both parts, + // verify the entire envelope once, require its signed sequence to match, + // and durably reserve replay state before executing any command. + static bool parseFragment(const uint8_t* payload, size_t length, Fragment& output); }; } // namespace mesh diff --git a/test/fixtures/companion_fleet_dispatch.h b/test/fixtures/companion_fleet_dispatch.h index 02951a345..60bfb9473 100644 --- a/test/fixtures/companion_fleet_dispatch.h +++ b/test/fixtures/companion_fleet_dispatch.h @@ -23,7 +23,7 @@ namespace mesh { struct FleetCommand { static constexpr uint16_t DataType = 0xFF01; - static constexpr size_t KeySize = 16, TargetSize = 16, MaxPayloadLength = 165; + static constexpr size_t KeySize = 16, TargetSize = 16, MaxPayloadLength = 165, MaxEnvelopeLength = 308; static constexpr uint32_t MinEpoch = 1735689600UL, MaxLifetime = 600; struct Targets { uint8_t length = 0, count = 0, data[86] = {}; }; static bool parseTargets(const char*, size_t, Targets&) { return false; } @@ -52,4 +52,8 @@ struct CompanionFleetFixture { assert(false && "fleet packet admission belongs in its dedicated suite"); return false; } + bool sendFleetCommandData(FleetFixtureChannel&, const uint8_t*, size_t) { + assert(false && "fleet packet admission belongs in its dedicated suite"); + return false; + } }; diff --git a/test/test_companion_extrafs_hil_guard.py b/test/test_companion_extrafs_hil_guard.py index 894af3e79..21fae160f 100644 --- a/test/test_companion_extrafs_hil_guard.py +++ b/test/test_companion_extrafs_hil_guard.py @@ -155,7 +155,7 @@ class CompanionExtraFsHilGuardTest(unittest.TestCase): self.assertNotIn("handleExtraFsHilCommand", generic) self.assertNotIn("hil extrafs", generic) self.assertNotIn("handleExtraFsHilCommand", on_air) - self.assertIn("handleCommand(text, sender_timestamp, reply)", on_air) + self.assertIn("handleCommand(text, sender_timestamp ? sender_timestamp : 1, reply)", on_air) def test_filler_reports_only_read_verified_durable_bytes(self): body = function_body( diff --git a/test/test_companion_fleet_control.py b/test/test_companion_fleet_control.py index d208cb859..0dfee3b5c 100644 --- a/test/test_companion_fleet_control.py +++ b/test/test_companion_fleet_control.py @@ -7,6 +7,7 @@ supplies real Ed25519 signing and verification through the Identity interface. """ from pathlib import Path +import os import shutil import subprocess import tempfile @@ -24,10 +25,23 @@ HARNESS = r''' #include #include #include +#include #include +#include #include +struct RescueStream : Stream { + std::string input, output; + size_t position = 0, reads = 0; + int available() override { return position < input.size(); } + int read() override { ++reads; return input[position++]; } + size_t write(uint8_t value) override { output += char(value); return 1; } +}; +static RescueStream rescue_input, rescue_output; + namespace mesh { +Stream& usbTerminalPort() { return rescue_output; } +Stream& usbCompanionPort() { return rescue_input; } Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } LocalIdentity::LocalIdentity() : Identity() { memset(prv_key, 0xAA, 32); @@ -62,23 +76,78 @@ bool Identity::verify(const uint8_t* signature, const uint8_t* data, int length) #define MAX_GROUP_CHANNELS 4 #define OUT_PATH_UNKNOWN 255 +namespace mesh { struct GroupChannel { uint8_t secret[32] = {}; uint8_t tx_radio = 0; }; +struct Packet { + uint8_t payload[168] = {}; + size_t payload_len = 0; + uint8_t tx_radio = 0; + uint16_t scope = 0; + bool isRouteDirect() const { return false; } + uint8_t getPayloadType() const { return 6; } +}; +struct Mesh { + unsigned base_copy_hooks = 0; + void onRadioProfileCopyQueued(Packet*, const Packet*, uint8_t) { ++base_copy_hooks; } +}; +} +using GroupChannel = mesh::GroupChannel; +#define PAYLOAD_TYPE_GRP_DATA 6 +#define PAYLOAD_TYPE_TRACE 9 + +struct PacketManager { + mesh::Packet pool[64]; + bool used[64] = {}; + std::vector outbound; + unsigned total = 0, allocations = 0, released = 0; + unsigned fail_allocation = 0; + mesh::Packet* allocNew() { + if (++allocations == fail_allocation) return nullptr; + for (unsigned i = 0; i < 64; ++i) if (!used[i]) { + used[i] = true; pool[i] = {}; return &pool[i]; + } + return nullptr; + } + void free(mesh::Packet* packet) { + const ptrdiff_t index = packet - pool; + assert(index >= 0 && index < 64 && used[index]); + used[index] = false; ++released; + } + unsigned freeCount() const { unsigned count = 0; for (bool value : used) count += !value; return count; } + int getOutboundTotal() const { return int(total); } + mesh::Packet* getOutboundByIdx(int index) { return outbound.at(index); } + mesh::Packet* removeOutboundByIdx(int index) { + auto* packet = outbound.at(index); outbound.erase(outbound.begin() + index); --total; + return packet; + } + void queue(mesh::Packet* packet) { outbound.push_back(packet); ++total; } +}; struct ChannelDetails { GroupChannel channel; char name[32] = {}; }; struct Clock { uint32_t now = mesh::FleetCommand::MinEpoch + 1000; uint32_t getCurrentTime() const { return now; } }; -class MyMesh { +class MyMesh : public mesh::Mesh { public: mesh::LocalIdentity self_id; Clock clock; ChannelDetails channels[MAX_GROUP_CHANNELS]; bool queue_ok = true; - unsigned queued = 0; + PacketManager manager; + PacketManager* _mgr = &manager; + unsigned& queued = manager.total; + unsigned queue_calls = 0, fail_queue = 0, failed_hooks = 0, radio_cancellations = 0; + bool radio_copy = false; + mesh::Packet* _fleet_admission_original = nullptr; + mesh::Packet* _fleet_admission_copy = nullptr; + char cli_command[80] = {}; + bool _cli_line_overflow = false; + bool _cli_rescue = false; + std::vector rescue_commands; uint8_t sent_policy = 0; uint16_t sent_type = 0; - uint8_t sent[mesh::FleetCommand::MaxPayloadLength] = {}; + uint8_t sent[mesh::FleetCommand::MaxEnvelopeLength] = {}; size_t sent_length = 0; MyMesh() { strcpy(channels[1].name, "private fleet"); @@ -90,18 +159,64 @@ class MyMesh { out = channels[index]; return true; } Clock* getRTCClock() { return &clock; } + mesh::Packet* createGroupDatagram(uint8_t type, const GroupChannel& channel, + const uint8_t* data, size_t length) { + assert(type == PAYLOAD_TYPE_GRP_DATA && length <= sizeof(mesh::Packet::payload)); + auto* packet = manager.allocNew(); + if (!packet) return nullptr; + packet->tx_radio = channel.tx_radio; packet->payload_len = length; + memcpy(packet->payload, data, length); return packet; + } + void releasePacket(mesh::Packet* packet) { manager.free(packet); } + void onSendFail(mesh::Packet*) { ++failed_hooks; } + void cancelOutboundRadioRetry(const mesh::Packet*) { ++radio_cancellations; } + void onTracePacketQueuedForSend(mesh::Packet*) { assert(false); } + bool queueOutboundPacket(mesh::Packet* packet, uint8_t, uint32_t) { + if (++queue_calls == fail_queue || !queue_ok) return false; + manager.queue(packet); + sent_policy = packet->tx_radio; + sent_type = uint16_t(packet->payload[0]) | uint16_t(packet->payload[1]) << 8; + const uint8_t* blob = packet->payload + 3; + const size_t length = packet->payload[2]; + assert(length + 3 == packet->payload_len && length <= mesh::FleetCommand::MaxPayloadLength); + mesh::FleetCommand::Fragment part; + if (mesh::FleetCommand::parseFragment(blob, length, part)) { + memcpy(sent + part.index * mesh::FleetCommand::FragmentDataLength, part.data, part.length); + sent_length = part.total_length; + } else { + memcpy(sent, blob, length); sent_length = length; + } + if (radio_copy) { + auto* copy = manager.allocNew(); assert(copy); *copy = *packet; + manager.queue(copy); onRadioProfileCopyQueued(copy, packet, 1); + } + return true; + } + bool sendPacket(mesh::Packet*, uint8_t, uint32_t = 0); + bool sendFloodScoped(const GroupChannel& channel, mesh::Packet* packet, uint32_t = 0) { + assert(packet->tx_radio == channel.tx_radio); + packet->scope = 0x4321; // Scope adapter; production helper preserves this channel route. + return sendPacket(packet, 1); + } 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; + assert(length > 0 && size_t(length) <= mesh::FleetCommand::MaxPayloadLength); + uint8_t group[168] = {uint8_t(type), uint8_t(type >> 8), uint8_t(length)}; + memcpy(group + 3, data, length); + auto* packet = createGroupDatagram(PAYLOAD_TYPE_GRP_DATA, channel, group, 3 + length); + return packet && sendFloodScoped(channel, packet); } + bool sendFleetCommandData(GroupChannel&, const uint8_t*, size_t); + void onRadioProfileCopyQueued(mesh::Packet*, const mesh::Packet*, uint8_t); bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply); + void checkCLIRescueCmd(); + void enterCLIRescue(); + void resetUsbHostSessionInput(); }; @METHOD@ +@SEND_HELPERS@ +@RESCUE_READER@ static void rejected(MyMesh& node, const char* text, uint32_t remote = 0) { char reply[160] = {}; @@ -118,6 +233,37 @@ static std::string fullKey(uint8_t value) { return hex; } +static void checkedFragments(MyMesh& node, size_t first_index) { + assert(node.sent_length > mesh::FleetCommand::MaxPayloadLength); + uint8_t assembled[mesh::FleetCommand::MaxEnvelopeLength] = {}; + // Read in reverse order; the immutable part index determines placement. + for (uint8_t index : {1, 0}) { + auto* packet = node.manager.outbound.at(first_index + index); + assert(packet->scope == 0x4321 && packet->tx_radio == 2); + assert(packet->payload[0] == 1 && packet->payload[1] == 0xff); + const size_t bytes = packet->payload[2]; + mesh::FleetCommand::Fragment part; + assert(mesh::FleetCommand::parseFragment(packet->payload + 3, bytes, part)); + assert(part.index == index && part.total_length == node.sent_length); + assert(part.sequence == node.clock.now && bytes <= mesh::FleetCommand::MaxPayloadLength); + assert(part.length == (index == 0 ? 154 : node.sent_length - 154)); + assert(bytes == mesh::FleetCommand::FragmentHeaderSize + part.length); + memcpy(assembled + index * 154, part.data, part.length); + 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, + packet->payload + 3, bytes, node.clock.now, recipient, decoded)); + } + assert(!memcmp(assembled, node.sent, node.sent_length)); + 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, + assembled, node.sent_length, node.clock.now, recipient, decoded)); + assembled[node.sent_length - 1] ^= 1; + assert(!mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret, + assembled, node.sent_length, node.clock.now, recipient, decoded)); +} + static void compactBroadcast(MyMesh& node, const char* command) { assert(!memcmp(node.sent, "FMC2", 4)); assert(node.sent[12] == 0); // No target records means every authorized receiver. @@ -125,6 +271,8 @@ static void compactBroadcast(MyMesh& node, const char* command) { assert(mesh::FleetCommand::MinHeaderSize == 14); assert(node.sent_length == 14 + strlen(command) + mesh::FleetCommand::SignatureSize); assert(!memcmp(node.sent + 14, command, strlen(command))); + if (node.sent_length > mesh::FleetCommand::MaxPayloadLength) + checkedFragments(node, node.manager.outbound.size() - 2); for (uint8_t value : {0x42, 0x77}) { uint8_t recipient[32]; memset(recipient, value, sizeof(recipient)); mesh::FleetCommand::Decoded decoded; @@ -225,12 +373,210 @@ int main(int argc, char** argv) { } const std::string prefix = "set flood.filter "; const std::string maximum = prefix + std::string(mesh::FleetCommand::MaxCommandLength - prefix.size(), '1'); - assert(maximum.size() == 72 && mesh::FleetCommand::commandAllowed(maximum.c_str())); + assert(maximum.size() == 230 && mesh::FleetCommand::commandAllowed(maximum.c_str())); rejected(node, ("fleet send 1 " + maximum + "1").c_str()); rejected(node, ("fleet send 1 all " + maximum + "1").c_str()); assert(node.handleCommand(("fleet send 1 " + maximum).c_str(), 0, reply)); compactBroadcast(node, maximum.c_str()); - assert(node.sent_length == 150 && node.queued == 1); // Compact header keeps the 72-byte cap. + assert(node.sent_length == 308 && node.queued == 2); + ++node.clock.now; + assert(node.handleCommand(("fleet send 1 all " + maximum).c_str(), 0, reply)); + compactBroadcast(node, maximum.c_str()); + assert(node.sent_length == 308 && node.queued == 4); + } else if (scenario == "target_command_capacity") { + struct Boundary { std::string targets; size_t command_length; }; + const Boundary boundaries[] = { + {"42424242", 225}, {"424242424242", 223}, {fullKey(0x42), 215}, + {"42424242,51515151", 220}, {"42424242,515151515151", 218}, + {fullKey(0x42) + "," + fullKey(0x51), 196}, + }; + const std::string prefix = "set flood.filter "; + for (const Boundary& boundary : boundaries) { + const std::string fitting = prefix + std::string(boundary.command_length - prefix.size(), '1'); + const std::string oversize = fitting + '1'; + assert(mesh::FleetCommand::commandAllowed(oversize.c_str())); + const std::string cli_prefix = "fleet send 1 " + boundary.targets + " "; + const unsigned before = node.queued; + rejected(node, (cli_prefix + oversize).c_str()); + // A valid target plus a long command must not become a broadcast, a + // partial send, or a consumed sequence. A queue refusal is retryable too. + assert(node.handleCommand((cli_prefix + oversize).c_str(), 0, reply)); + assert(strstr(reply, "exceed two packets") && node.queued == before); + node.queue_ok = false; + rejected(node, (cli_prefix + fitting).c_str()); + node.queue_ok = true; + assert(node.handleCommand((cli_prefix + fitting).c_str(), 0, reply)); + assert(node.queued == before + 2 && node.sent_length == 308); + assert(node.sent_policy == 2); + 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 && !strcmp(decoded.command, fitting.c_str())); + assert(!memcmp(node.sent, boundary.targets.size() == 64 ? "FMC1" : "FMC2", 4)); + memset(recipient, 0x77, sizeof(recipient)); + assert(!mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret, + node.sent, node.sent_length, node.clock.now, recipient, decoded)); + rejected(node, (cli_prefix + fitting).c_str()); // Successful send consumes its second. + ++node.clock.now; + } + assert(node.queued == 12); + } else if (scenario == "maximum_correlated_app_command") { + const std::string prefix = "set flood.filter "; + const std::string maximum = prefix + std::string(158 - prefix.size(), '1'); + const std::string local_cli = "AB|fleet send 1 " + maximum; + // The app's local CLI frame adds opcode 66 and a trailing NUL. Its request + // correlation prefix is already inside local_cli; all are ASCII bytes. + assert(local_cli.size() + 2 == MAX_FRAME_SIZE); + assert((local_cli + '1').size() + 2 > MAX_FRAME_SIZE); + rejected(node, local_cli.c_str(), 1); + node.queue_ok = false; + rejected(node, local_cli.c_str()); + node.queue_ok = true; + assert(node.handleCommand(local_cli.c_str(), 0, reply)); + assert(!strncmp(reply, "AB|OK - fleet command queued; seq=", 32)); + compactBroadcast(node, maximum.c_str()); + assert(node.sent_length == 236 && node.queued == 2); + assert(node.sent_policy == 2); + rejected(node, local_cli.c_str()); + ++node.clock.now; + const std::string unprefixed = prefix + std::string(161 - prefix.size(), '1'); + const std::string implicit = "fleet send 1 " + unprefixed; + assert(implicit.size() + 2 == MAX_FRAME_SIZE); + assert((implicit + '1').size() + 2 > MAX_FRAME_SIZE); + assert(node.handleCommand(implicit.c_str(), 0, reply)); + compactBroadcast(node, unprefixed.c_str()); + assert(node.sent_length == 239 && node.queued == 4); + } else if (scenario == "fragment_boundaries") { + struct Boundary { std::string targets; size_t single_length; }; + const Boundary boundaries[] = { + {"", 87}, {"42424242 ", 82}, {"424242424242 ", 80}, {fullKey(0x42) + " ", 72}, + }; + const std::string prefix = "set flood.filter "; + for (const Boundary& boundary : boundaries) { + std::string command = prefix + std::string(boundary.single_length - prefix.size(), '1'); + const std::string cli_prefix = "fleet send 1 " + boundary.targets; + const size_t before = node.manager.outbound.size(); + assert(node.handleCommand((cli_prefix + command).c_str(), 0, reply)); + assert(node.queued == before + 1 && node.sent_length == 165); + assert(!memcmp(node.manager.outbound.back()->payload + 3, + boundary.targets.size() == 65 ? "FMC1" : "FMC2", 4)); + ++node.clock.now; command += '1'; + assert(node.handleCommand((cli_prefix + command).c_str(), 0, reply)); + assert(node.queued == before + 3 && node.sent_length == 166); + checkedFragments(node, before + 1); + ++node.clock.now; + } + assert(node.queued == 12); + } else if (scenario == "fragment_atomic_admission") { + unsigned scenario_index = 0; + for (const char* failure : {"pool_first", "pool_second", "queue_first", "queue_second", "queue_second_copy"}) { + MyMesh trial; trial.clock.now += ++scenario_index * 10; + const std::string prefix = "set flood.filter "; + const std::string command = prefix + std::string(161 - prefix.size(), '1'); + const std::string cli = "fleet send 1 " + command; + auto* unrelated = trial.manager.allocNew(); assert(unrelated); + unrelated->payload[0] = 99; trial.manager.queue(unrelated); + const unsigned free_before = trial.manager.freeCount(); + if (!strcmp(failure, "pool_first")) trial.manager.fail_allocation = trial.manager.allocations + 1; + if (!strcmp(failure, "pool_second")) trial.manager.fail_allocation = trial.manager.allocations + 2; + if (!strcmp(failure, "queue_first")) trial.fail_queue = trial.queue_calls + 1; + if (!strcmp(failure, "queue_second")) trial.fail_queue = trial.queue_calls + 2; + if (!strcmp(failure, "queue_second_copy")) { + trial.fail_queue = trial.queue_calls + 2; trial.radio_copy = true; + } + rejected(trial, cli.c_str()); + assert(trial.queued == 1 && trial.manager.outbound[0] == unrelated); + assert(trial.manager.freeCount() == free_before); + assert(!trial._fleet_admission_original && !trial._fleet_admission_copy); + if (!strcmp(failure, "queue_first")) assert(trial.failed_hooks == 1); + if (!strcmp(failure, "queue_second")) + assert(trial.failed_hooks == 2 && trial.radio_cancellations == 1); + if (!strcmp(failure, "queue_second_copy")) { + assert(trial.failed_hooks == 3 && trial.radio_cancellations == 2); + assert(trial.base_copy_hooks == 1); // The production override chains Mesh. + } + trial.manager.fail_allocation = 0; trial.fail_queue = 0; trial.radio_copy = false; + assert(trial.handleCommand(cli.c_str(), 0, reply)); + assert(trial.queued == 3 && trial.manager.outbound[0] == unrelated); + compactBroadcast(trial, command.c_str()); + assert(trial.manager.freeCount() == free_before - 2); + } + } else if (scenario == "rescue_overflow") { + const std::string unsafe = "fleet send 1 set flood.rule.1 type=grp_txt drop hops=all in=any priority=00001 suspend=tempradio"; + assert(unsafe.size() > sizeof(node.cli_command)); + // The old forced-completion behavior would execute this valid prefix, + // silently omitting the requested suspend=tempradio condition. + const std::string truncated = unsafe.substr(0, 78); + assert(mesh::FleetCommand::commandAllowed(truncated.c_str() + 13)); + rescue_input.input = unsafe + "\r\nfleet send 1 clock\r\n"; + while (rescue_input.available()) { + const size_t before = rescue_input.reads; + node.checkCLIRescueCmd(); + assert(rescue_input.reads - before <= sizeof(node.cli_command)); + } + assert(node.rescue_commands.size() == 1 && node.rescue_commands[0] == "fleet send 1 clock"); + assert(node.queued == 1 && !node._cli_line_overflow && node.cli_command[0] == 0); + assert(rescue_output.output.find("ERR: too long") != std::string::npos); + compactBroadcast(node, "clock"); + } else if (scenario == "rescue_split_overflow") { + rescue_input.input = std::string(400, 'x'); + node.checkCLIRescueCmd(); + assert(rescue_input.reads == sizeof(node.cli_command) && node._cli_line_overflow); + assert(node.rescue_commands.empty() && node.queued == 0); + while (rescue_input.available()) node.checkCLIRescueCmd(); + assert(node._cli_line_overflow && node.rescue_commands.empty()); + // Even a valid fleet suffix is discarded until the actual line ending. + rescue_input.input += "fleet send 1 set radio2 off\nfleet send 1 clock\n"; + while (rescue_input.available()) node.checkCLIRescueCmd(); + assert(node.rescue_commands.size() == 1 && node.rescue_commands[0] == "fleet send 1 clock"); + assert(node.queued == 1 && !node._cli_line_overflow); + } else if (scenario == "rescue_boundaries") { + const std::string exact(79, 'x'); + rescue_input.input = exact; + node.checkCLIRescueCmd(); + assert(node.rescue_commands.empty() && strlen(node.cli_command) == 79); + rescue_input.input += "\r\nfirst\r\nsecond\nthird\r"; + while (rescue_input.available()) node.checkCLIRescueCmd(); + const std::vector expected = {exact, "first", "second", "third"}; + assert(node.rescue_commands == expected && !node._cli_line_overflow); + assert(node.cli_command[0] == 0); + } else if (scenario == "rescue_unterminated") { + memset(node.cli_command, 'x', sizeof(node.cli_command)); + rescue_input.input = "fleet send 1 set radio2 off\r\nfleet send 1 clock\r"; + while (rescue_input.available()) node.checkCLIRescueCmd(); + assert(node.rescue_commands.size() == 1 && node.rescue_commands[0] == "fleet send 1 clock"); + assert(node.queued == 1 && !node._cli_line_overflow); + } else if (scenario == "rescue_session_reset") { + node._cli_line_overflow = true; strcpy(node.cli_command, "old host prefix"); + node.enterCLIRescue(); + assert(node._cli_rescue && !node._cli_line_overflow && node.cli_command[0] == 0); + rescue_input.input = "fleet send 1 clock\r"; + while (rescue_input.available()) node.checkCLIRescueCmd(); + assert(node.queued == 1); + ++node.clock.now; + node._cli_line_overflow = true; strcpy(node.cli_command, "old host prefix"); + node.resetUsbHostSessionInput(); + assert(!node._cli_line_overflow); + for (char value : node.cli_command) assert(value == 0); + rescue_input.input += "fleet send 1 clock\n"; + while (rescue_input.available()) node.checkCLIRescueCmd(); + assert(node.queued == 2 && node.rescue_commands.size() == 2); + } else if (scenario == "stm32_rescue") { + rescue_input.input = std::string(400, 'x') + "fleet send 1 clock\r\nnext\r\n"; + while (rescue_input.available()) { + const size_t before = rescue_input.reads; + node.checkCLIRescueCmd(); + assert(rescue_input.reads - before <= sizeof(node.cli_command)); + } + assert(node.rescue_commands == std::vector{"next"}); + assert(rescue_output.output.find(" Error: unknown command") != std::string::npos); + assert(rescue_output.output.find("ERR: too long") == std::string::npos); + assert(node.queued == 0 && !node._cli_line_overflow); + memset(node.cli_command, 'x', sizeof(node.cli_command)); + rescue_input.input += "suffix\nlast\n"; + while (rescue_input.available()) node.checkCLIRescueCmd(); + assert(node.rescue_commands == (std::vector{"next", "last"})); } else if (scenario == "explicit_target_forms") { for (const std::string& targets : {std::string("42424242"), std::string("424242424242"), fullKey(0x42), std::string("42424242,515151515151,") + fullKey(0x63)}) { @@ -258,7 +604,7 @@ int main(int argc, char** argv) { "fleet send 1 11111111111111 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()); + rejected(node, ("fleet send 1 all set flood.filter " + std::string(mesh::FleetCommand::MaxCommandLength, '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") { @@ -378,14 +724,16 @@ int main(int argc, char** argv) { } mesh::FleetCommand::Targets parsed; assert(mesh::FleetCommand::parseTargets(targets.c_str(), targets.size(), parsed)); + const std::string prefix = "set flood.filter "; + const std::string too_long = prefix + std::string(mesh::FleetCommand::MaxCommandLength - prefix.size(), '1'); + assert(node.handleCommand(("fleet send 1 " + targets + " " + too_long).c_str(), 0, reply)); + assert(strstr(reply, "exceed two packets") && node.queued == 0); assert(node.handleCommand(("fleet send 1 " + targets + " set radio2 off").c_str(), 0, reply)); - assert(strstr(reply, "exceed one packet") && node.queued == 0); - assert(node.handleCommand("fleet send 1 00112233 set radio2 off", 0, reply)); - assert(node.queued == 1); + assert(node.queued == 2); // The valid list now fits one atomic two-packet envelope. ++node.clock.now; targets += ",44556677"; // More than the entire target-record capacity. rejected(node, ("fleet send 1 " + targets + " set radio2 off").c_str()); - assert(node.queued == 1); + assert(node.queued == 2); } else if (scenario == "scheduled_sets") { const char* relative[] = { "set radioat 910.5,500,5,5,+1,auto", @@ -462,9 +810,15 @@ int main(int argc, char** argv) { assert(mesh::FleetCommand::parseTargets(targets.c_str(), targets.size(), parsed)); assert(mesh::FleetCommand::commandAllowed(scheduled.c_str())); assert(node.handleCommand(("fleet send 1 " + targets + " " + scheduled).c_str(), 0, reply)); - assert(strstr(reply, "exceed one packet") && node.queued == 0); + assert(!strncmp(reply, "OK - fleet command queued", 25) && node.queued == 2); + 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(!strcmp(decoded.command, scheduled.c_str())); + ++node.clock.now; assert(node.handleCommand(("fleet send 1 42424242 " + scheduled).c_str(), 0, reply)); - assert(node.queued == 1 && node.sent_policy == 2); + assert(node.queued == 3 && node.sent_policy == 2); } else if (scenario == "clock_controls") { uint8_t recipient[32]; memset(recipient, 0x42, sizeof(recipient)); for (const char* clock_command : {"clock", "clock sync", "time 1800000000"}) { @@ -510,10 +864,45 @@ class CompanionFleetControlTests(unittest.TestCase): work = Path(cls.work.name) cls.binary = work / "companion-fleet" fixture = work / "fixture.cpp" - fixture.write_text(HARNESS.replace("@METHOD@", cls.production)) + helpers = "\n".join(body(source, signature) for signature in ( + "void MyMesh::onRadioProfileCopyQueued(", "bool MyMesh::sendFleetCommandData(", + )) + send_packet = body((ROOT / "src/Dispatcher.cpp").read_text(), "bool Dispatcher::sendPacket(") + send_packet = send_packet.replace("bool Dispatcher::sendPacket(Packet*", "bool MyMesh::sendPacket(mesh::Packet*", 1) + rescue = body(source, "void MyMesh::checkCLIRescueCmd()") + # Keep the entire production reader/line-admission logic. Replace only + # downstream rescue commands (filesystem/UI peripherals) with a record + # and the real Companion fleet dispatcher used throughout this suite. + rescue = rescue[:rescue.index(" reply_buf[0] = 0;")] + # Print::println(text) is absent from the shared host Stream adapter. + # Preserve STM32's actual shared error text and adapt only printing. + rescue = rescue.replace('output.println(" Error: unknown command");', + 'output.print(" Error: unknown command"); output.println();') + rescue += ''' + rescue_commands.emplace_back(cli_command); + char reply[160] = {}; + handleCommand(cli_command, 0, reply); + cli_command[0] = 0; + } +} +''' + reset = body(source, "void MyMesh::resetUsbHostSessionInput()") + enter = body(source, "void MyMesh::enterCLIRescue()") + # The shared Stream mock lacks Print::println(text); preserve the + # actual entry-state reset and adapt only this banner's formatting. + banner = 'mesh::usbTerminalPort().println("========= CLI Rescue =========");' + assert enter.count(banner) == 1 + enter = enter.replace(banner, 'mesh::usbTerminalPort().print("========= CLI Rescue =========\\n");') + cls.fixture = fixture + fixture.write_text(HARNESS.replace("@METHOD@", cls.production) + .replace("@SEND_HELPERS@", send_packet + "\n" + helpers) + .replace("@RESCUE_READER@", rescue + "\n" + reset + "\n" + enter)) + sanitizer_flags = (["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie"] + if os.environ.get("MESHCORE_FLEET_SANITIZERS") == "1" else []) + cls.sanitizer_flags = sanitizer_flags result = subprocess.run([ compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", - "-Wno-unused-parameter", "-O2", + "-Wno-unused-parameter", "-O2", *sanitizer_flags, "-fstack-usage", "-isystem", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"), str(fixture), str(ROOT / "src/helpers/FleetCommand.cpp"), "-lcrypto", "-o", str(cls.binary), @@ -546,9 +935,50 @@ class CompanionFleetControlTests(unittest.TestCase): def test_unknown_or_malformed_explicit_targets_never_fall_back_to_broadcast(self): self.scenario("implicit_target_ambiguity") - def test_omitted_target_still_requires_canonical_allowlisted_command_and_72_byte_cap(self): + def test_omitted_target_requires_canonical_allowlisted_command_and_230_byte_cap(self): self.scenario("implicit_command_controls") + def test_target_specific_exact_packet_limits_reject_atomically_and_allow_retry(self): + self.scenario("target_command_capacity") + + def test_maximum_correlated_implicit_command_fits_app_frame_and_remains_local_only(self): + self.scenario("maximum_correlated_app_command") + + def test_one_packet_boundary_automatically_becomes_two_signed_fragments(self): + self.scenario("fragment_boundaries") + + def test_two_part_admission_reserves_pool_and_rolls_back_exact_packets_and_radio_copy(self): + self.scenario("fragment_atomic_admission") + + def test_rescue_overflow_discards_full_fleet_rule_and_preserves_next_crlf_command(self): + self.scenario("rescue_overflow") + + def test_rescue_overflow_drains_bounded_chunks_and_never_executes_valid_suffix(self): + self.scenario("rescue_split_overflow") + + def test_rescue_exact_capacity_waits_for_real_terminator_and_keeps_lines_separate(self): + self.scenario("rescue_boundaries") + + def test_rescue_unterminated_buffer_is_bounded_and_discards_remainder(self): + self.scenario("rescue_unterminated") + + def test_rescue_entry_and_usb_host_recovery_reset_discard_state(self): + self.scenario("rescue_session_reset") + + def test_stm32_rescue_shared_error_still_drains_and_rejects_entire_overflow(self): + binary = Path(self.work.name) / "companion-stm32-rescue" + built = subprocess.run([ + self.compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-DSTM32_PLATFORM=1", "-O2", *self.sanitizer_flags, + "-isystem", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"), + str(self.fixture), str(ROOT / "src/helpers/FleetCommand.cpp"), + "-lcrypto", "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + result = subprocess.run([str(binary), "stm32_rescue"], + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def test_explicit_8_12_64_hex_and_list_targets_remain_restricted(self): self.scenario("explicit_target_forms") @@ -576,7 +1006,7 @@ class CompanionFleetControlTests(unittest.TestCase): def test_invalid_lists_reject_atomically_without_consuming_sequence(self): self.scenario("invalid_target_lists") - def test_valid_list_exceeding_packet_capacity_never_partially_sends(self): + def test_valid_list_exceeding_two_packet_capacity_never_partially_sends(self): self.scenario("target_capacity") def test_four_schedule_set_forms_are_signed_without_rewriting(self): diff --git a/test/test_fleet_channel_runtime.py b/test/test_fleet_channel_runtime.py index b1ce58dea..c535426b1 100644 --- a/test/test_fleet_channel_runtime.py +++ b/test/test_fleet_channel_runtime.py @@ -279,13 +279,16 @@ static void enroll(FleetChannel& fleet,const LocalIdentity& publisher){ 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 packet; + assert(1+CIPHER_MAC_SIZE+(length+15)/16*16<=sizeof(packet.payload)); + 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"){ + assert(strlen(text)<=FleetCommand::MaxPayloadLength-FleetCommand::HeaderSize-FleetCommand::SignatureSize); 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); @@ -321,7 +324,7 @@ static std::string publicHex(const Identity& identity){ 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); + assert(text_size<=FleetCommand::MaxPayloadLength-FleetCommand::HeaderSize-FleetCommand::SignatureSize); 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); @@ -332,6 +335,20 @@ static Packet signedUnchecked(const LocalIdentity& publisher,uint32_t sequence,u 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 Packet compactUnchecked(const LocalIdentity& publisher,uint32_t sequence,const char* text){ + uint8_t data[184]={},message[160]={};const size_t text_size=strlen(text); + // One byte over the valid limit still fits the cipher's final padded block. + assert(text_size<=FleetCommand::SinglePacketMaxCommandLength+1); + uint8_t* payload=data+3;memcpy(payload,"FMC2",4); + storage::writeLE32(payload+4,sequence);storage::writeLE32(payload+8,sequence+300); + payload[12]=0;payload[13]=uint8_t(text_size);memcpy(payload+14,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,14+text_size); + publisher.sign(payload+14+text_size,message,int(domain_size+16+14+text_size)); + data[0]=uint8_t(FleetCommand::DataType);data[1]=uint8_t(FleetCommand::DataType>>8); + data[2]=uint8_t(14+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);}; @@ -351,8 +368,213 @@ static unsigned apply(FleetChannel& fleet,fs::FS& fs,Mesh& mesh,RadioProfileCLI& static void reseal(std::vector& image){ storage::writeLE32(image.data()+60,storage::updateCRC32(0xffffffff,image.data(),60)); } +static std::string longRule(){ + const std::string prefix="set flood.rule.1 type=grp_txt channel="+std::string(64,'1') + +" prefix=AABBCC,DDEEFF,112233 hops=6+ priority=255 suspend=tempradio mode=radio hashbytes=3 in=any"; + const std::string action=" action=drop"; + assert(prefix.size()+action.size()<=FleetCommand::MaxCommandLength); + return prefix+std::string(FleetCommand::MaxCommandLength-prefix.size()-action.size(),' ')+action; +} +static std::vector envelope(const LocalIdentity& publisher,uint32_t sequence, + const std::string& text,const std::string& target="all"){ + FleetCommand::Targets targets;assert(FleetCommand::parseTargets(target.c_str(),target.size(),targets)); + std::vector bytes(FleetCommand::MaxEnvelopeLength); + size_t length=FleetCommand::encode(publisher,channel_key,sequence,sequence+300,targets, + text.c_str(),bytes.data(),bytes.size()); + assert(length);bytes.resize(length);return bytes; +} +static Packet part(const std::vector& bytes,uint8_t index){ + uint8_t data[168]={};size_t length=FleetCommand::fragment(bytes.data(),bytes.size(),index, + data+3,sizeof(data)-3); + assert(length&&length<=FleetCommand::MaxPayloadLength);data[0]=uint8_t(FleetCommand::DataType); + data[1]=uint8_t(FleetCommand::DataType>>8);data[2]=uint8_t(length); + Packet result=raw(data,3+length);assert(result.payload_len<=MAX_PACKET_PAYLOAD);return result; +} +template static Packet changePart(const Packet& packet,Change change){ + uint8_t data[184]={};int length=Utils::MACThenDecrypt(channel_key,data,packet.payload+1,packet.payload_len-1); + assert(length>0);change(data+3);return raw(data,size_t(length)); +} +struct FragmentFixture { + fs::FS disk;Mesh mesh;RadioProfileCLI profiles;FleetChannel fleet{&disk};LocalIdentity publisher; + std::string expected=longRule();unsigned calls=0;bool mutation=false; + FragmentFixture(){enroll(fleet,publisher);bind(fleet,mesh,profiles);} + uint32_t reserved()const{return storage::readLE32(disk.files.at(Path).data()+56);} + std::string stats(){return config(fleet,"get fleet.stats");} + unsigned feed(Packet& packet,const TransportKey* scope=nullptr,uint32_t advance_ms=1000){ + fake_ms+=advance_ms;const unsigned before=calls; + const auto image=disk.files.at(Path);const unsigned writes=disk.writes,verifies=verify_calls; + fleet.receive(&packet,mesh,scope); + assert(disk.files.at(Path)==image&&disk.writes==writes&&verify_calls==verifies); + fleet.service(mesh,profiles,"long:rule",[&](uint32_t seq,const char* text,char* reply){ + ++calls;assert(reserved()==seq&&profiles.command); + assert(mesh.scoped_profile==packet.radio_profile&&mesh.scoped_generation==packet.radio_generation); + assert(strlen(text)==expected.size()&&!strcmp(text,expected.c_str())); + assert(text[expected.size()]==0);if(mutation)profiles.stage();strcpy(reply,"OK"); + }); + assert(!profiles.command&&mesh.scoped_profile==0&&mesh.scoped_generation==0);return calls-before; + } + void tick(uint32_t advance_ms){ + fake_ms+=advance_ms;fleet.service(mesh,profiles,"node",[](uint32_t,const char*,char*){assert(false);}); + } + void partial(){assert(calls==0&&reserved()==0&&mesh.queued==0&&!profiles.mutation); + assert(stats().find("accepted=0")!=std::string::npos&&stats().find("parts=1")!=std::string::npos);} +}; +static int fragmentScenario(const std::string& scenario){ + FragmentFixture f;const uint32_t seq=f.mesh.clock.now; + auto bytes=envelope(f.publisher,seq,f.expected);assert(bytes.size()==308&&f.expected.size()==230); + Packet a=part(bytes,0),b=part(bytes,1);assert(a.payload_len==179&&b.payload_len==179); + if(scenario=="fragments_orders"){ + for(unsigned reverse=0;reverse<2;++reverse){ + FragmentFixture x;auto encoded=envelope(x.publisher,seq,x.expected); + Packet first=part(encoded,reverse?1:0),second=part(encoded,reverse?0:1); + const auto old=x.disk.files.at(Path);const unsigned verifies=verify_calls,writes=x.disk.writes; + assert(x.feed(first)==0);x.partial();assert(x.disk.files.at(Path)==old&&x.disk.writes==writes); + assert(verify_calls==verifies&&x.stats().find("rejected=0")!=std::string::npos); + assert(x.feed(second)==1&&verify_calls==verifies+1&&x.reserved()==seq&&x.mesh.queued==1); + assert(x.stats().find("parts=0")!=std::string::npos&&last_jitter_max==60500); + assert(x.feed(first)==0&&x.feed(second)==0&&x.calls==1); + FleetChannel rebooted(&x.disk);unsigned calls=0; + for(Packet* packet:{&first,&second}){fake_ms+=1000;rebooted.receive(packet,x.mesh); + rebooted.service(x.mesh,x.profiles,"node",[&](uint32_t,const char*,char*){++calls;});} + assert(calls==0&&x.reserved()==seq); + } + }else if(scenario=="fragments_duplicates"){ + const unsigned verifies=verify_calls,writes=f.disk.writes; + assert(f.feed(b)==0&&f.feed(b)==0&&f.feed(b)==0);f.partial(); + assert(verify_calls==verifies&&f.disk.writes==writes&&f.stats().find("rejected=0")!=std::string::npos); + assert(f.feed(a)==1&&f.feed(a)==0&&f.feed(b)==0&&f.calls==1&&f.mesh.queued==1); + }else if(scenario=="fragments_minimum_size"){ + f.expected="set flood.rule.1 05 c="+std::string(64,'1')+" d"; + assert(f.expected.size()==88);auto small=envelope(f.publisher,seq,f.expected); + assert(small.size()==166);Packet first=part(small,0),second=part(small,1); + assert(first.payload_len==179&&second.payload_len==35); + assert(f.feed(second)==0);f.partial();assert(f.feed(first)==1&&f.reserved()==seq); + FleetCommand::Targets all;assert(FleetCommand::parseTargets("all",3,all)); + uint8_t output[308];memset(output,0xA5,sizeof(output)); + const std::string too_long=longRule()+" ";assert(too_long.size()==231); + assert(!FleetCommand::encode(f.publisher,channel_key,seq+1,seq+301,all,too_long.c_str(),output,sizeof(output))); + for(uint8_t byte:output)assert(byte==0xA5); + }else if(scenario=="fragments_expired_during_collection"){ + assert(f.feed(a)==0);f.partial();f.mesh.clock.now+=301; + assert(f.feed(b)==0&&f.reserved()==0&&f.mesh.queued==0&&f.calls==0); + assert(f.stats().find("parts=0")!=std::string::npos); + auto retry=envelope(f.publisher,f.mesh.clock.now,f.expected); + Packet first=part(retry,0),second=part(retry,1); + assert(f.feed(first)==0&&f.feed(second)==1&&f.reserved()==f.mesh.clock.now); + }else if(scenario=="fragments_conflicts"){ + assert(f.feed(a)==0);f.partial(); + Packet changed=changePart(a,[](uint8_t* frame){frame[11+90]^=1;}); + assert(f.feed(changed)==0);f.partial(); + Packet length=changePart(a,[](uint8_t* frame){storage::writeLE16(frame+8,307);}); + assert(f.feed(length)==0);f.partial(); + Packet index=changePart(a,[](uint8_t* frame){frame[10]=2;}); + assert(f.feed(index)==0);f.partial(); + assert(f.feed(b)==1&&f.calls==1&&f.reserved()==seq); + }else if(scenario=="fragments_fresh_retry"){ + assert(f.feed(a)==0);f.partial();auto newer=envelope(f.publisher,seq+1,f.expected); + Packet fresh_first=part(newer,1),fresh_second=part(newer,0); + assert(f.feed(fresh_first)==0);f.partial(); + assert(f.feed(b)==0);f.partial();assert(f.feed(fresh_second)==1&&f.reserved()==seq+1); + assert(f.feed(a)==0&&f.feed(b)==0&&f.calls==1&&f.mesh.queued==1); + }else if(scenario=="fragments_tamper"){ + Packet mac=a;mac.payload[1]^=1;assert(f.feed(mac)==0&&f.stats().find("parts=0")!=std::string::npos); + assert(f.feed(a)==0);f.partial();const auto saved=f.disk.files.at(Path); + Packet signature=changePart(b,[](uint8_t* frame){frame[11+153]^=1;}); + assert(f.feed(signature)==0&&f.calls==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0); + assert(f.stats().find("parts=0")!=std::string::npos); + assert(f.feed(b)==0&&f.feed(a)==1&&f.calls==1); + }else if(scenario=="fragments_wrong_signer"){ + LocalIdentity stranger;auto forged=envelope(stranger,seq,f.expected); + Packet first=part(forged,0),second=part(forged,1); + assert(f.feed(first)==0);f.partial();assert(f.feed(second)==0&&f.reserved()==0&&f.mesh.queued==0); + assert(f.stats().find("parts=0")!=std::string::npos&&f.feed(a)==0&&f.feed(b)==1); + }else if(scenario=="fragments_fixed_timeout"){ + assert(f.feed(a)==0);f.partial();assert(f.feed(a,nullptr,299999)==0);f.partial(); + f.tick(1);assert(f.stats().find("parts=0")!=std::string::npos&&f.reserved()==0&&f.mesh.queued==0); + assert(f.feed(b)==0);f.partial();assert(f.feed(a)==1); + }else if(scenario=="fragments_timeout_wrap"){ + fake_ms=UINT32_MAX-200000;assert(f.feed(b,nullptr,0)==0);f.partial();f.tick(299999);f.partial(); + f.tick(1);assert(f.stats().find("parts=0")!=std::string::npos&&f.reserved()==0); + assert(f.feed(a)==0&&f.feed(b)==1); + }else if(scenario=="fragments_sequence_bounds"){ + for(uint32_t invalid:{FleetCommand::MinEpoch-1,seq+61,seq-601}){ + Packet changed=changePart(a,[&](uint8_t* frame){storage::writeLE32(frame+4,invalid);}); + assert(f.feed(changed)==0&&f.stats().find("parts=0")!=std::string::npos&&f.reserved()==0); + } + Packet mismatch_a=changePart(a,[&](uint8_t* frame){storage::writeLE32(frame+4,seq+1);}); + Packet mismatch_b=changePart(b,[&](uint8_t* frame){storage::writeLE32(frame+4,seq+1);}); + const unsigned verifies=verify_calls;assert(f.feed(mismatch_a)==0&&f.feed(mismatch_b)==0); + assert(f.reserved()==0&&verify_calls==verifies&&f.stats().find("parts=0")!=std::string::npos); + assert(f.feed(a)==0&&f.feed(b)==1); + }else if(scenario=="fragments_target"){ + LocalIdentity stranger;f.expected="set flood.rule.1 any "+std::string(150,' ')+"drop"; + auto other=envelope(f.publisher,seq,f.expected,publicHex(stranger)); + Packet other_a=part(other,0),other_b=part(other,1);assert(f.feed(other_a)==0);f.partial(); + assert(f.feed(other_b)==0&&f.reserved()==0&&f.mesh.queued==0); + auto mine=envelope(f.publisher,seq,f.expected,publicHex(f.mesh.self_id)); + Packet mine_a=part(mine,0),mine_b=part(mine,1); + assert(f.feed(mine_b)==0&&f.feed(mine_a)==1&&last_jitter_max==1500); + }else if(scenario=="fragments_config_reboot"){ + assert(f.feed(a)==0);f.partial();assert(config(f.fleet,std::string("set fleet.channel ")+Key).rfind("OK",0)==0); + assert(f.stats().find("parts=0")!=std::string::npos&&f.feed(b)==0);f.partial(); + assert(config(f.fleet,"set fleet.controller "+publicHex(f.publisher)).rfind("OK",0)==0); + assert(f.stats().find("parts=0")!=std::string::npos&&f.feed(a)==0);f.partial(); + FleetChannel rebooted(&f.disk);unsigned calls=0;fake_ms+=1000;rebooted.receive(&b,f.mesh); + rebooted.service(f.mesh,f.profiles,"node",[&](uint32_t,const char*,char*){++calls;}); + assert(calls==0&&f.reserved()==0&&f.mesh.queued==0); + fake_ms+=1000;rebooted.receive(&a,f.mesh); + rebooted.service(f.mesh,f.profiles,"node",[&](uint32_t sequence,const char* text,char* reply){ + ++calls;assert(sequence==seq&&!strcmp(text,f.expected.c_str()));strcpy(reply,"OK");}); + assert(calls==1&&f.reserved()==seq); + }else if(scenario=="fragments_reserve_failure"){ + const auto saved=f.disk.files.at(Path);f.disk.fail_write=true;const unsigned writes=f.disk.writes; + assert(f.feed(b)==0);f.partial();assert(f.disk.writes==writes&&f.disk.files.at(Path)==saved); + assert(f.feed(a)==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0&&f.calls==0); + assert(config(f.fleet,"get fleet.channel").find("store=error")!=std::string::npos); + f.disk.fail_write=false;assert(f.feed(a)==0&&f.feed(b)==0); + FleetChannel rebooted(&f.disk);unsigned calls=0; + for(Packet* packet:{&a,&b}){fake_ms+=1000;rebooted.receive(packet,f.mesh); + rebooted.service(f.mesh,f.profiles,"node",[&](uint32_t,const char*,char* reply){++calls;strcpy(reply,"OK");});} + assert(calls==1&&f.reserved()==seq); + }else if(scenario=="fragments_scoped_radio_ack"){ + f.mutation=true;f.mesh.fanout=true;TransportKey scope;memset(scope.key,0x12,sizeof(scope.key)); + a.radio_profile=0;a.radio_generation=9;b.radio_profile=1;b.radio_generation=27;b.path_hash_size=3; + b.scoped=true;b.transport_codes[0]=scope.calcTransportCode(&b); + assert(f.feed(a)==0);f.partial();assert(f.feed(b,&scope)==1&&f.fleet.waiting()&&f.profiles.mutation); + assert(f.mesh.manager.outbound.size()==2&&f.mesh.ack_scoped&&f.mesh.ack_path_size==3); + assert(f.mesh.ack_codes[0]==scope.calcTransportCode(f.mesh.manager.outbound[0])); + assert(f.mesh.ack_codes[0]!=b.transport_codes[0]&&last_jitter_max==15500); + f.fleet.complete(f.mesh.manager.removeOutboundByIdx(0),f.profiles); + assert(f.fleet.waiting()&&f.profiles.mutation&&f.profiles.commits==0); + f.fleet.fail(f.mesh.manager.removeOutboundByIdx(0),f.profiles); + assert(!f.fleet.waiting()&&!f.profiles.mutation&&f.profiles.commits==1); + assert(f.feed(a)==0&&f.feed(b,&scope)==0&&f.calls==1); + }else if(scenario=="fragments_unknown_scope"){ + f.mutation=true;a.scoped=true;a.transport_codes[0]=123; + assert(f.feed(b)==0&&f.feed(a)==1&&f.mesh.queued==0&&f.profiles.rollbacks==1); + assert(f.reserved()==seq&&!f.fleet.waiting()&&!f.profiles.mutation); + }else if(scenario=="fragments_single_interrupt"){ + assert(f.feed(a)==0);f.partial();f.expected="get radio2"; + Packet single=targetsCommand(f.publisher,seq+1,"all",f.expected.c_str()); + assert(f.feed(single)==1&&f.reserved()==seq+1&&f.stats().find("parts=0")!=std::string::npos); + assert(f.feed(b)==0&&f.feed(a)==0&&f.calls==1&&f.mesh.queued==1); + }else if(scenario=="fragments_malformed_frame"){ + for(unsigned damage=0;damage<4;++damage){ + Packet malformed=changePart(a,[&](uint8_t* frame){ + if(damage==0)storage::writeLE16(frame+8,309); + else if(damage==1)storage::writeLE16(frame+8,165); + else if(damage==2)frame[10]=2; + else frame[-1]=uint8_t(FleetCommand::MaxPayloadLength-1); + });assert(f.feed(malformed)==0&&f.stats().find("parts=0")!=std::string::npos); + } + assert(f.reserved()==0&&f.mesh.queued==0&&f.feed(a)==0&&f.feed(b)==1); + }else assert(false); + printf("fleet runtime %s passed\n",scenario.c_str());return 0; +} int main(int argc,char** argv){ - assert(argc==2);const std::string scenario=argv[1];fs::FS fs;Mesh mesh;RadioProfileCLI profiles; + assert(argc==2);const std::string scenario=argv[1]; + if(scenario.rfind("fragments_",0)==0)return fragmentScenario(scenario); + 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); @@ -588,6 +810,43 @@ int main(int argc,char** argv){ assert(apply(fleet,fs,mesh,profiles,mutation)==0); FleetChannel rebooted(&fs); assert(apply(rebooted,fs,mesh,profiles,packet)==0&&apply(rebooted,fs,mesh,profiles,mutation)==0); + }else if(scenario=="fmc2_maximum_command"){ + enroll(fleet,publisher); + const std::string secret(64,'1'); + const std::string text="set flood.rule.1 5 c="+secret+" d"; + const std::string oversized="set flood.rule.1 05 c="+secret+" d"; + assert(text.size()==87&&text.size()==FleetCommand::SinglePacketMaxCommandLength); + assert(oversized.size()==88&&FleetCommand::commandAllowed(oversized.c_str())); + FleetCommand::Targets all;assert(FleetCommand::parseTargets("all",3,all)); + uint8_t output[184];memset(output,0xA5,sizeof(output)); + assert(FleetCommand::encode(publisher,channel_key,mesh.clock.now,mesh.clock.now+300,all, + oversized.c_str(),output,sizeof(output))==166); + assert(output[166]==0xA5); + Packet packet=targetsCommand(publisher,mesh.clock.now,"all",text.c_str()); + uint8_t plaintext[184]={}; + const int size=Utils::MACThenDecrypt(channel_key,plaintext,packet.payload+1,packet.payload_len-1); + assert(size==176&&packet.payload_len==179&&packet.payload_len<=MAX_PACKET_PAYLOAD); + assert(plaintext[2]==FleetCommand::MaxPayloadLength&&plaintext[3+13]==87); + assert(!memcmp(plaintext+3,"FMC2",4)&&plaintext[3+12]==0); + assert(!memcmp(plaintext+3+14,text.data(),text.size())); + for(unsigned i=3+plaintext[2];i0); @@ -770,6 +1029,9 @@ class SHA256 : public SHA256Base {{ public: def test_actual_zero_target_sender_reaches_receiver_and_keeps_ack_and_reboot_replay_safety(self): self.scenario("fmc2_encoded_all") + def test_single_packet_87_byte_body_is_intact_signed_and_88_bytes_require_fragmentation(self): + self.scenario("fmc2_maximum_command") + def test_new_public_key_prefix_envelope_dispatches_and_staggers_possible_multiple_acks(self): self.scenario("fmc2_short_prefix") @@ -785,6 +1047,60 @@ class SHA256 : public SHA256Base {{ public: def test_existing_remote_radio_mutation_blocks_fleet_without_reserving_sequence(self): self.scenario("existing_mutation") + def test_two_packets_deliver_exact_230_byte_command_in_either_order_once_across_reboot(self): + self.scenario("fragments_orders") + + def test_identical_fragments_never_repeat_execution_or_reserve_partial_requests(self): + self.scenario("fragments_duplicates") + + def test_smallest_fragmented_command_uses_a_short_second_packet_and_231_bytes_are_rejected(self): + self.scenario("fragments_minimum_size") + + def test_command_expiring_between_parts_never_dispatches_and_fresh_retry_can_succeed(self): + self.scenario("fragments_expired_during_collection") + + def test_conflicting_parts_cannot_destroy_a_valid_incomplete_transfer(self): + self.scenario("fragments_conflicts") + + def test_fresh_retry_preempts_missing_part_and_delayed_old_parts_cannot_pollute_it(self): + self.scenario("fragments_fresh_retry") + + def test_mac_and_last_signature_byte_tampering_never_authorize_partial_commands(self): + self.scenario("fragments_tamper") + + def test_fragmented_request_from_another_real_signing_identity_is_rejected(self): + self.scenario("fragments_wrong_signer") + + def test_fragment_deadline_is_fixed_despite_duplicates_and_missing_part(self): + self.scenario("fragments_fixed_timeout") + + def test_fragment_deadline_handles_millisecond_timer_wrap(self): + self.scenario("fragments_timeout_wrap") + + def test_fragment_sequence_plausibility_and_signed_sequence_match_precede_reservation(self): + self.scenario("fragments_sequence_bounds") + + def test_fragmented_nonmatching_target_preserves_sequence_for_matching_retry(self): + self.scenario("fragments_target") + + def test_channel_controller_changes_and_reboot_clear_partial_transfers(self): + self.scenario("fragments_config_reboot") + + def test_fragment_completion_storage_failure_has_no_dispatch_or_reply(self): + self.scenario("fragments_reserve_failure") + + def test_final_fragment_scope_and_radio_profile_feed_both_ack_copy_commit_barrier(self): + self.scenario("fragments_scoped_radio_ack") + + def test_fragmented_radio_mutation_with_unknown_final_scope_rolls_back(self): + self.scenario("fragments_unknown_scope") + + def test_new_valid_single_packet_can_interrupt_an_incomplete_two_packet_transfer(self): + self.scenario("fragments_single_interrupt") + + def test_malformed_fragment_size_index_and_padding_never_allocate_partial_intent(self): + self.scenario("fragments_malformed_frame") + 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 index 2d993ca1b..f7b05adbf 100644 --- a/test/test_fleet_command/test_fleet_command.cpp +++ b/test/test_fleet_command/test_fleet_command.cpp @@ -2,8 +2,10 @@ #include #include #include +#include #include #include +#include #include namespace { @@ -36,7 +38,7 @@ protected: uint32_t sequence = Now, uint32_t expires = Now + 120, const uint8_t* target = nullptr) { - std::vector bytes(Fleet::MaxPayloadLength); + std::vector bytes(Fleet::MaxEnvelopeLength); const size_t size = Fleet::encode(publisher, ChannelKey, sequence, expires, target ? target : all.data(), command, bytes.data(), bytes.size()); bytes.resize(size); @@ -50,7 +52,7 @@ protected: std::vector encodeTargets(const Fleet::Targets& targets, const char* command = "get radio2") { - std::vector bytes(Fleet::MaxPayloadLength); + std::vector bytes(Fleet::MaxEnvelopeLength); const size_t size = Fleet::encode(publisher, ChannelKey, Now, Now + 120, targets, command, bytes.data(), bytes.size()); bytes.resize(size); @@ -153,7 +155,7 @@ TEST_F(FleetCommandTest, ScheduledCommandsRejectMalformedTimesFieldsAndSelectors + std::string(Fleet::MaxCommandLength - prefix.size() - suffix.size(), '0') + suffix; ASSERT_EQ(Fleet::MaxCommandLength, maximum.size()); EXPECT_TRUE(Fleet::commandAllowed(maximum.c_str())); - EXPECT_TRUE(decode(encode(maximum.c_str()))); + EXPECT_TRUE(decode(encodeTargets(Fleet::Targets{}, maximum.c_str()))); const std::string oversized = prefix + '0' + maximum.substr(prefix.size()); EXPECT_FALSE(Fleet::commandAllowed(oversized.c_str())); } @@ -289,21 +291,28 @@ TEST_F(FleetCommandTest, FailedSignatureVerificationNeverReturnsCommand) { EXPECT_STREQ("", decoded.command); } -TEST_F(FleetCommandTest, FullPayloadLimitFitsWithoutOverflow) { +TEST_F(FleetCommandTest, LegacyPayloadLimitFitsAndCannotBeRaisedByDestinationCapacity) { const std::string prefix = "set flood.rule.1 "; - const std::string maximum = prefix + std::string(Fleet::MaxCommandLength - prefix.size(), 'a'); + const std::string maximum = prefix + std::string(Fleet::FixedTargetMaxCommandLength - prefix.size(), 'a'); ASSERT_TRUE(Fleet::commandAllowed(maximum.c_str())); const auto bytes = encode(maximum.c_str()); - ASSERT_EQ(Fleet::MaxPayloadLength, bytes.size()); + ASSERT_EQ(Fleet::MaxEnvelopeLength, bytes.size()); EXPECT_TRUE(decode(bytes)); EXPECT_STREQ(maximum.c_str(), decoded.command); - EXPECT_FALSE(Fleet::commandAllowed((maximum + "a").c_str())); + EXPECT_TRUE(Fleet::commandAllowed((maximum + "a").c_str())); EXPECT_TRUE(encode((maximum + "a").c_str()).empty()); - std::array output; + 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)); + maximum.c_str(), output.data() + 1, Fleet::MaxEnvelopeLength - 1)); for (uint8_t value : output) EXPECT_EQ(0xA5, value); + for (size_t command_length : {Fleet::FixedTargetMaxCommandLength + 1, Fleet::MaxCommandLength}) { + const std::string oversized = prefix + std::string(command_length - prefix.size(), 'a'); + ASSERT_TRUE(Fleet::commandAllowed(oversized.c_str())); + EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 10, all.data(), + oversized.c_str(), output.data(), output.size())); + for (uint8_t value : output) EXPECT_EQ(0xA5, value); + } } TEST_F(FleetCommandTest, RejectsTruncationTrailingPaddingAndWrongMagic) { @@ -439,7 +448,7 @@ TEST_F(FleetCommandTest, TargetRecordAndPayloadBudgetsAreSeparateAndBounded) { ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets)); EXPECT_EQ(count, targets.count); EXPECT_EQ(count * 5, targets.length); - EXPECT_EQ(count <= 15, !encodeTargets(targets).empty()) << count; + EXPECT_FALSE(encodeTargets(targets).empty()) << count; } Fleet::Targets targets{}; list += ",01020304"; @@ -452,7 +461,7 @@ TEST_F(FleetCommandTest, TargetRecordAndPayloadBudgetsAreSeparateAndBounded) { ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets)); EXPECT_EQ(count * 17, targets.length); } - EXPECT_TRUE(encodeTargets(targets).empty()); + EXPECT_FALSE(encodeTargets(targets).empty()); list += ',' + full; EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets)); } @@ -485,17 +494,25 @@ TEST_F(FleetCommandTest, CompactBroadcastCapacityAndCommandLimitRemainBounded) { const std::string prefix = "set flood.rule.1 "; const std::string maximum = prefix + std::string(Fleet::MaxCommandLength - prefix.size(), 'a'); const auto bytes = encodeTargets(targets, maximum.c_str()); - ASSERT_EQ(150U, bytes.size()); - EXPECT_EQ(15U, encode(maximum.c_str()).size() - bytes.size()); + ASSERT_EQ(230U, maximum.size()); + ASSERT_EQ(Fleet::MaxEnvelopeLength, bytes.size()); + ASSERT_TRUE(Fleet::commandAllowed(maximum.c_str())); EXPECT_TRUE(decode(bytes)); + EXPECT_STREQ(maximum.c_str(), decoded.command); EXPECT_TRUE(decoded.broadcast); - std::array output; + std::array output; output.fill(0xA5); EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 120, targets, maximum.c_str(), output.data(), bytes.size() - 1)); for (uint8_t byte : output) EXPECT_EQ(0xA5, byte); const std::string oversized = maximum + 'a'; + EXPECT_FALSE(Fleet::commandAllowed(oversized.c_str())); EXPECT_TRUE(encodeTargets(targets, oversized.c_str()).empty()); + for (const char invalid : {'\n', ';', char(0x80)}) { + const std::string malformed = maximum.substr(0, maximum.size() - 1) + invalid; + EXPECT_FALSE(Fleet::commandAllowed(malformed.c_str())); + EXPECT_TRUE(encodeTargets(targets, malformed.c_str()).empty()); + } for (size_t length = 0; length < bytes.size(); ++length) { const std::vector truncated(bytes.begin(), bytes.begin() + length); EXPECT_FALSE(decode(truncated)); @@ -565,7 +582,7 @@ TEST_F(FleetCommandTest, AnyMatchingEntryIsAcceptedButMalformedLaterEntryIsRejec bytes[13 + targets.length] = 0; EXPECT_FALSE(decode(bytes)); bytes = encodeTargets(targets); - bytes[13 + targets.length] = 73; + bytes[13 + targets.length] = Fleet::MaxCommandLength + 1; EXPECT_FALSE(decode(bytes)); for (size_t length = 0; length < bytes.size(); ++length) { const std::vector truncated(bytes.begin(), bytes.begin() + length); @@ -615,21 +632,52 @@ TEST_F(FleetCommandTest, InvalidExternalTargetStructuresNeverWriteOutput) { } TEST_F(FleetCommandTest, LargestFmc2EnvelopeFitsAndOversizedCombinationWritesNothing) { - const std::string prefix = "set flood.rule.1 "; - const std::string maximum = prefix + std::string(Fleet::MaxCommandLength - prefix.size(), 'a'); Fleet::Targets targets{}; const char* three = "01020304,05060708,090A0B0C"; ASSERT_TRUE(Fleet::parseTargets(three, strlen(three), targets)); - EXPECT_EQ(Fleet::MaxPayloadLength, encodeTargets(targets, maximum.c_str()).size()); + const std::string prefix = "set flood.rule.1 "; + const std::string maximum = prefix + std::string( + Fleet::MaxCommandLength - targets.length - prefix.size(), 'a'); + EXPECT_EQ(Fleet::MaxEnvelopeLength, encodeTargets(targets, maximum.c_str()).size()); const char* four = "01020304,05060708,090A0B0C,01020304"; ASSERT_TRUE(Fleet::parseTargets(four, strlen(four), targets)); - std::array output; + std::array output; output.fill(0xA5); EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 120, targets, maximum.c_str(), output.data(), output.size())); for (uint8_t byte : output) EXPECT_EQ(0xA5, byte); } +TEST_F(FleetCommandTest, EveryTargetFormUsesItsExactCommandBudgetWithoutPartialWrites) { + char full[PUB_KEY_SIZE * 2 + 1]; + mesh::Utils::toHex(full, publisher.pub_key, PUB_KEY_SIZE); + const std::string short_key(full, 8), long_key(full, 12); + const std::string prefix = "set flood.rule.1 "; + const std::vector> cases = { + {"all", 230}, {short_key, 225}, {long_key, 223}, {full, 215}, + {short_key + ',' + short_key, 220}, {short_key + ',' + long_key, 218}, + {std::string(full) + ',' + short_key, 208}, {std::string(full) + ',' + full, 196} + }; + for (const auto& item : cases) { + SCOPED_TRACE(item.first); + Fleet::Targets targets{}; + ASSERT_TRUE(Fleet::parseTargets(item.first.data(), item.first.size(), targets)); + const std::string maximum = prefix + std::string(item.second - prefix.size(), 'a'); + ASSERT_TRUE(Fleet::commandAllowed(maximum.c_str())); + const auto bytes = encodeTargets(targets, maximum.c_str()); + ASSERT_EQ(Fleet::MaxEnvelopeLength, bytes.size()); + ASSERT_TRUE(decode(bytes)); + EXPECT_STREQ(maximum.c_str(), decoded.command); + const std::string oversized = maximum + 'a'; + EXPECT_TRUE(encodeTargets(targets, oversized.c_str()).empty()); + std::array output; + output.fill(0xA5); + EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 120, targets, + oversized.c_str(), output.data(), output.size())); + for (uint8_t byte : output) EXPECT_EQ(0xA5, byte); + } +} + TEST_F(FleetCommandTest, UnrepresentableZeroDigestNeverBecomesAllDuringLegacyEncoding) { Fleet::Targets targets{}; targets.count = 1; @@ -641,6 +689,129 @@ TEST_F(FleetCommandTest, UnrepresentableZeroDigestNeverBecomesAllDuringLegacyEnc EXPECT_FALSE(decode(bytes)); } +TEST_F(FleetCommandTest, SinglePacketBoundariesRemainExactBeforeFragmentation) { + char full[PUB_KEY_SIZE * 2 + 1]; + mesh::Utils::toHex(full, publisher.pub_key, PUB_KEY_SIZE); + const std::string prefix = "set flood.rule.1 "; + for (const auto& item : std::vector>{ + {"all", 87}, {std::string(full, 8), 82}, {std::string(full, 12), 80}, {full, 72}}) { + Fleet::Targets targets{}; + ASSERT_TRUE(Fleet::parseTargets(item.first.data(), item.first.size(), targets)); + const std::string command = prefix + std::string(item.second - prefix.size(), 'a'); + const auto single = encodeTargets(targets, command.c_str()); + ASSERT_EQ(Fleet::MaxPayloadLength, single.size()); + std::array output; + output.fill(0xA5); + EXPECT_EQ(0U, Fleet::fragment(single.data(), single.size(), 0, output.data(), output.size())); + for (uint8_t byte : output) EXPECT_EQ(0xA5, byte); + const auto longer = encodeTargets(targets, (command + 'a').c_str()); + ASSERT_EQ(Fleet::MaxPayloadLength + 1, longer.size()); + EXPECT_EQ(Fleet::MaxPayloadLength, + Fleet::fragment(longer.data(), longer.size(), 0, output.data(), output.size())); + EXPECT_EQ(Fleet::FragmentHeaderSize + longer.size() - Fleet::FragmentDataLength, + Fleet::fragment(longer.data(), longer.size(), 1, output.data(), output.size())); + } +} + +TEST_F(FleetCommandTest, TwoExactFragmentsReassembleInEitherOrderAndVerifyOnlyWholeEnvelope) { + const std::string prefix = "set flood.rule.1 "; + for (size_t command_length : {88U, 89U, 229U, 230U}) { + const std::string command = prefix + std::string(command_length - prefix.size(), 'a'); + const auto envelope = encodeTargets(Fleet::Targets{}, command.c_str()); + ASSERT_EQ(Fleet::MinHeaderSize + command_length + Fleet::SignatureSize, envelope.size()); + std::array, 2> fragments; + for (uint8_t index = 0; index < 2; ++index) { + fragments[index].resize(Fleet::MaxPayloadLength); + const size_t size = Fleet::fragment(envelope.data(), envelope.size(), index, + fragments[index].data(), fragments[index].size()); + ASSERT_GT(size, 0U); + ASSERT_LE(size, Fleet::MaxPayloadLength); + fragments[index].resize(size); + } + for (bool reverse : {false, true}) { + std::vector assembled(envelope.size()); + const unsigned before = g_mock_ed25519_verify_calls; + for (unsigned step = 0; step < 2; ++step) { + const uint8_t index = reverse ? 1 - step : step; + Fleet::Fragment part; + ASSERT_TRUE(Fleet::parseFragment(fragments[index].data(), fragments[index].size(), part)); + EXPECT_EQ(Now, part.sequence); + EXPECT_EQ(envelope.size(), part.total_length); + EXPECT_EQ(index, part.index); + EXPECT_EQ(index ? envelope.size() - Fleet::FragmentDataLength : Fleet::FragmentDataLength, + part.length); + EXPECT_EQ(0, memcmp(part.data, envelope.data() + index * Fleet::FragmentDataLength, part.length)); + std::copy_n(part.data, part.length, assembled.begin() + index * Fleet::FragmentDataLength); + EXPECT_FALSE(decode(fragments[index])); // A fragment cannot authorize a command. + EXPECT_EQ(before, g_mock_ed25519_verify_calls); + Fleet::Fragment duplicate; + ASSERT_TRUE(Fleet::parseFragment(fragments[index].data(), fragments[index].size(), duplicate)); + EXPECT_EQ(part.data, duplicate.data); + EXPECT_EQ(part.length, duplicate.length); + } + EXPECT_EQ(envelope, assembled); + ASSERT_TRUE(decode(assembled)); + EXPECT_EQ(before + 1, g_mock_ed25519_verify_calls); + EXPECT_STREQ(command.c_str(), decoded.command); + EXPECT_TRUE(decoded.broadcast); + } + } +} + +TEST_F(FleetCommandTest, FragmentEncoderRejectsInvalidInputBeforeWriting) { + const std::string command = "set flood.rule.1 " + std::string(72, 'a'); + auto envelope = encodeTargets(Fleet::Targets{}, command.c_str()); + ASSERT_GT(envelope.size(), Fleet::MaxPayloadLength); + std::array output; + output.fill(0xA5); + for (size_t length : {size_t(0), Fleet::MaxPayloadLength, Fleet::MaxEnvelopeLength + 1}) + EXPECT_EQ(0U, Fleet::fragment(envelope.data(), length, 0, output.data(), output.size())); + EXPECT_EQ(0U, Fleet::fragment(nullptr, envelope.size(), 0, output.data(), output.size())); + EXPECT_EQ(0U, Fleet::fragment(envelope.data(), envelope.size(), 0, nullptr, output.size())); + for (uint8_t index : {uint8_t(2), uint8_t(255)}) + EXPECT_EQ(0U, Fleet::fragment(envelope.data(), envelope.size(), index, output.data(), output.size())); + EXPECT_EQ(0U, Fleet::fragment(envelope.data(), envelope.size(), 0, output.data(), 164)); + EXPECT_EQ(0U, Fleet::fragment(envelope.data(), envelope.size(), 1, output.data(), + Fleet::FragmentHeaderSize + envelope.size() - Fleet::FragmentDataLength - 1)); + envelope[0] = 'X'; + EXPECT_EQ(0U, Fleet::fragment(envelope.data(), envelope.size(), 0, output.data(), output.size())); + for (uint8_t byte : output) EXPECT_EQ(0xA5, byte); +} + +TEST_F(FleetCommandTest, FragmentParserRejectsMalformedLengthIndexAndMagicAndClearsOutput) { + const std::string command = "set flood.rule.1 " + std::string(72, 'a'); + const auto envelope = encodeTargets(Fleet::Targets{}, command.c_str()); + std::vector valid(Fleet::MaxPayloadLength); + ASSERT_EQ(valid.size(), Fleet::fragment(envelope.data(), envelope.size(), 0, valid.data(), valid.size())); + const auto reject = [&](const uint8_t* bytes, size_t length) { + Fleet::Fragment part{123, 300, 1, valid.data(), 100}; + EXPECT_FALSE(Fleet::parseFragment(bytes, length, part)); + EXPECT_EQ(0U, part.sequence); EXPECT_EQ(0, part.total_length); EXPECT_EQ(0, part.index); + EXPECT_EQ(nullptr, part.data); EXPECT_EQ(0U, part.length); + }; + reject(nullptr, valid.size()); + for (size_t length = 0; length < valid.size(); ++length) reject(valid.data(), length); + auto malformed = valid; + malformed.push_back(0); + reject(malformed.data(), malformed.size()); + malformed = valid; malformed[0] = 'X'; reject(malformed.data(), malformed.size()); + for (uint16_t total : {uint16_t(0), uint16_t(165), uint16_t(309), uint16_t(65535)}) { + malformed = valid; malformed[8] = uint8_t(total); malformed[9] = uint8_t(total >> 8); + reject(malformed.data(), malformed.size()); + } + for (uint8_t index : {uint8_t(1), uint8_t(2), uint8_t(255)}) { + malformed = valid; malformed[10] = index; reject(malformed.data(), malformed.size()); + } + valid.resize(Fleet::MaxPayloadLength); + valid.resize(Fleet::fragment(envelope.data(), envelope.size(), 1, valid.data(), valid.size())); + ASSERT_FALSE(valid.empty()); + malformed = valid; malformed.pop_back(); reject(malformed.data(), malformed.size()); + malformed = valid; malformed.push_back(0); reject(malformed.data(), malformed.size()); + Fleet::Fragment part; + ASSERT_TRUE(Fleet::parseFragment(valid.data(), valid.size(), part)); + EXPECT_EQ(envelope.size() - Fleet::FragmentDataLength, part.length); +} + } // namespace int main(int argc, char** argv) { diff --git a/test/test_fleet_command_crypto.py b/test/test_fleet_command_crypto.py index a24f2575e..65a606a61 100644 --- a/test/test_fleet_command_crypto.py +++ b/test/test_fleet_command_crypto.py @@ -34,6 +34,8 @@ FIXTURE = r''' #include #include +inline unsigned fleet_sign_calls = 0; + // The production Identity interface is retained. OpenSSL implements its // signatures here so these tests cannot inherit native's always-true mock. namespace mesh { @@ -46,6 +48,7 @@ LocalIdentity::LocalIdentity(RNG* rng) { EVP_PKEY_free(key); } void LocalIdentity::sign(uint8_t* signature, const uint8_t* message, int length) const { + ++fleet_sign_calls; 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); @@ -84,26 +87,83 @@ void hex(const uint8_t* bytes, size_t size) { int main(int argc, char** argv) { using Fleet = mesh::FleetCommand; - if (argc == 7 && std::string(argv[1]) == "encode") { + const std::string operation = argc > 1 ? argv[1] : ""; + if (argc == 7 && (operation == "encode" || operation == "encode-wide")) { 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; + std::array output; + output.fill(0xA5); + const unsigned signed_before = fleet_sign_calls; + const size_t capacity = operation == "encode-wide" ? output.size() : Fleet::MaxEnvelopeLength; 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); + uint32_t(std::stoul(argv[5])), target.data(), argv[6], output.data(), capacity); + assert(size <= Fleet::MaxEnvelopeLength); + if (!size) { + assert(fleet_sign_calls == signed_before); + for (uint8_t byte : output) assert(byte == 0xA5); + std::cout << "reject\n"; + } else { + assert(fleet_sign_calls == signed_before + 1); + for (size_t i = size; i < output.size(); ++i) assert(output[i] == 0xA5); + hex(output.data(), size); + } return 0; } - if (argc == 7 && std::string(argv[1]) == "encode2") { + if (argc == 7 && (operation == "encode2" || operation == "encode2-wide")) { FixedRng rng; mesh::LocalIdentity publisher(&rng); const auto key = unhex(argv[2]); assert(key.size() == Fleet::KeySize); Fleet::Targets targets; if (!Fleet::parseTargets(argv[3], strlen(argv[3]), targets)) { std::cout << "reject\n"; return 0; } - std::array output; + std::array output; + output.fill(0xA5); + const unsigned signed_before = fleet_sign_calls; + const size_t capacity = operation == "encode2-wide" ? output.size() : Fleet::MaxEnvelopeLength; const size_t size = Fleet::encode(publisher, key.data(), uint32_t(std::stoul(argv[4])), - uint32_t(std::stoul(argv[5])), targets, argv[6], output.data(), output.size()); - if (!size) std::cout << "reject\n"; else hex(output.data(), size); + uint32_t(std::stoul(argv[5])), targets, argv[6], output.data(), capacity); + assert(size <= Fleet::MaxEnvelopeLength); + if (!size) { + assert(fleet_sign_calls == signed_before); + for (uint8_t byte : output) assert(byte == 0xA5); + std::cout << "reject\n"; + } else { + assert(fleet_sign_calls == signed_before + 1); + for (size_t i = size; i < output.size(); ++i) assert(output[i] == 0xA5); + hex(output.data(), size); + } + return 0; + } + if (argc == 5 && operation == "fragment") { + const auto envelope = unhex(argv[2]); + const unsigned index = unsigned(std::stoul(argv[3])); + assert(index <= 255); + const size_t capacity = std::stoul(argv[4]); + std::array output; + assert(capacity <= output.size()); output.fill(0xA5); + const unsigned signed_before = fleet_sign_calls; + const size_t size = Fleet::fragment(envelope.data(), envelope.size(), uint8_t(index), + output.data(), capacity); + assert(fleet_sign_calls == signed_before && size <= Fleet::MaxPayloadLength); + if (!size) { + for (uint8_t byte : output) assert(byte == 0xA5); + std::cout << "reject\n"; + } else { + for (size_t i = size; i < output.size(); ++i) assert(output[i] == 0xA5); + hex(output.data(), size); + } + return 0; + } + if (argc == 3 && operation == "parse-fragment") { + const auto payload = unhex(argv[2]); + Fleet::Fragment output{123, 300, 1, payload.data(), 100}; + if (!Fleet::parseFragment(payload.data(), payload.size(), output)) { + assert(!output.sequence && !output.total_length && !output.index && !output.data && !output.length); + std::cout << "reject\n"; + } else { + std::cout << output.sequence << '\n' << output.total_length << '\n' << unsigned(output.index) << '\n'; + hex(output.data, output.length); + } return 0; } if (argc == 7 && (std::string(argv[1]) == "decode" || std::string(argv[1]) == "decode2")) { @@ -142,6 +202,13 @@ def envelope2(entries=(), command="get radio2", sequence=NOW, expires=NOW + 120, return unsigned + publisher.sign(DOMAIN + channel + unsigned) +def fragment_frame(payload, index): + """Independent wire framing; the signature stays in the whole envelope.""" + sequence = struct.unpack(" 15) + self.assertNotEqual(result, "reject") def test_fmc2_rejects_short_trailing_and_signed_forbidden_commands(self): original = envelope2([(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6])]) diff --git a/test/test_nrf52_extrafs_contract.py b/test/test_nrf52_extrafs_contract.py index 432c66c17..7aed8fce2 100644 --- a/test/test_nrf52_extrafs_contract.py +++ b/test/test_nrf52_extrafs_contract.py @@ -220,8 +220,8 @@ class Nrf52ExtraFsContractTest(unittest.TestCase): next_at = handler.index("cmd_frame[0] == CMD_SET_ADVERT_NAME", get_at) get_contacts = handler[get_at:next_at] incomplete_at = get_contacts.index("hasIncompleteContactLoad()") - lock_at = get_contacts.index("_serial->lockReplyRoute()") - self.assertLess(incomplete_at, lock_at) + route_at = get_contacts.index("_iter_reply_route = _serial->captureReplyRoute()") + self.assertLess(incomplete_at, route_at) self.assertIn("ERR_CODE_FILE_IO_ERROR", get_contacts) base = (ROOT / "src" / "helpers" / "BaseChatMesh.cpp").read_text( diff --git a/test/test_transport_filter/test_transport_filter.cpp b/test/test_transport_filter/test_transport_filter.cpp index a557b3b61..322dad5cd 100644 --- a/test/test_transport_filter/test_transport_filter.cpp +++ b/test/test_transport_filter/test_transport_filter.cpp @@ -204,6 +204,48 @@ TEST_F(TransportFilter, InvalidModesAndActionsDoNotReplaceSavedRule) { EXPECT_FALSE(blocked(p, RULE_MODE_RADIO)); } +TEST_F(TransportFilter, TwoPacketFleetRulePreservesActionAfterLongParameters) { + const std::string prefix = "set flood.rule.1 type=grp_txt channel=" + std::string(64, '1') + + " prefix=AABBCC,DDEEFF,112233 hops=6+ priority=255 suspend=tempradio" + " mode=radio hashbytes=3 in=any"; + const std::string action = " action=drop"; + ASSERT_LT(prefix.size() + action.size(), 230U); + const std::string setter = prefix + std::string(230 - prefix.size() - action.size(), ' ') + action; + ASSERT_EQ(230U, setter.size()); + ASSERT_GT(setter.size() - strlen("set flood.rule.1 "), 192U); + command(setter.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply; + const auto saved = fs.files["/flood_filter"]; + + mesh::Packet p; + p.header = (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT) | ROUTE_TYPE_FLOOD; + p.setPathHashSizeAndCount(3, 6); + memset(p.path, 0, p.getPathByteLen()); + const uint8_t path[] = {0xAA,0xBB,0xCC,0xDD,0xEE,0xFF,0x11,0x22,0x33}; + memcpy(p.path, path, sizeof(path)); + uint8_t key[PUB_KEY_SIZE];memset(key, 0x11, sizeof(key)); + mesh::Utils::sha256(p.payload, 1, key, sizeof(key)); + const uint8_t text[] = "fleet: sample"; + p.payload_len = 1 + mesh::Utils::encryptThenMAC(key, p.payload + 1, text, sizeof(text)); + EXPECT_TRUE(blocked(p, RULE_MODE_RADIO)); + + std::string stop = setter;stop.replace(stop.size() - 4, 4, "stop"); + command(stop.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply; + EXPECT_FALSE(blocked(p, RULE_MODE_RADIO)); + EXPECT_NE(saved, fs.files["/flood_filter"]); + + const auto unchanged = fs.files["/flood_filter"]; + command(("set flood.rule.1 any " + std::string(256, ' ') + "drop").c_str()); + EXPECT_EQ(0, strncmp(reply, "Err - rule parameters too long", 29)); + EXPECT_EQ(unchanged, fs.files["/flood_filter"]); +} + +TEST_F(TransportFilter, OverlongRateTokenCannotDiscardItsInvalidSuffix) { + install("radio");const auto saved = fs.files["/flood_filter"]; + command(("set flood.rule.3 any q=" + std::string(23, '0') + "x stop").c_str()); + EXPECT_EQ(0, strncmp(reply, "Err - rate value too long", 24)) << reply; + EXPECT_EQ(saved, fs.files["/flood_filter"]); +} + TEST_F(TransportFilter, CombinedModeSharesRateAndResetsAfterMinute) { command("set flood.rule.3 type=any mode=bridge,cross rate=1/min"); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply; @@ -490,6 +532,35 @@ TEST_F(TransportFilter, PrivateKeyReferencesCopyLocallyWithoutDisclosingKey) { EXPECT_EQ(empty, fs.files["/flood_filter"]); } +TEST_F(TransportFilter, WholeFleetMaximumRulePreservesItsFinalDropAction) { + const std::string setter = "set flood.rule.1 5 c=" + std::string(64, '1') + " d"; + ASSERT_EQ(87U, setter.size()); + command(setter.c_str()); + ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply; + + mesh::Packet p; + p.header = (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT) | ROUTE_TYPE_FLOOD; + p.setPathHashSizeAndCount(1, 0); + uint8_t key[PUB_KEY_SIZE]; + memset(key, 0x11, sizeof(key)); + mesh::Utils::sha256(p.payload, 1, key, sizeof(key)); + const uint8_t text[] = "fleet: sample"; + p.payload_len = 1 + mesh::Utils::encryptThenMAC(key, p.payload + 1, text, sizeof(text)); + EXPECT_TRUE(blocked(p, RULE_MODE_RADIO)); + + command("get fr.1"); + const std::string formatted = reply; + ASSERT_GE(formatted.size(), 2U); + EXPECT_EQ(" d", formatted.substr(formatted.size() - 2)); + + // A stop action at the same final byte has different forwarding behavior. + std::string stop = setter; + stop.back() = 's'; + command(stop.c_str()); + ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply; + EXPECT_FALSE(blocked(p, RULE_MODE_RADIO)); +} + TEST(TransportModes, KeyFingerprintCollisionsCannotSelectAnArbitrarySecret) { struct Row { bool active = true;