From b0c02b1e470f1b92c2e18defeaec9475286943fb Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 8 Oct 2026 17:32:05 -0700 Subject: [PATCH] Support public-key prefixes and lists in signed fleet commands --- README.md | 2 +- docs/fleet_management.md | 47 +++- examples/companion_radio/MyMesh.cpp | 28 +-- src/helpers/FleetChannel.cpp | 4 +- src/helpers/FleetCommand.cpp | 154 ++++++++++-- src/helpers/FleetCommand.h | 23 ++ test/fixtures/companion_fleet_dispatch.h | 5 +- test/test_companion_fleet_control.py | 89 ++++++- test/test_fleet_channel_runtime.py | 77 +++++- .../test_fleet_command/test_fleet_command.cpp | 220 ++++++++++++++++++ test/test_fleet_command_crypto.py | 156 ++++++++++++- test/test_official_app_compatibility.py | 3 +- 12 files changed, 756 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index b26169e1c..897b55d06 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 and secondary-radio commands to a whole fleet or one node. The publisher signs commands; receivers hold its public verification key, with replay protection and acknowledgements before radio handoff. +* **[Private fleet management](https://mikecarper.github.io/MeshCore/fleet_management/).** Deploy filtering and secondary-radio commands 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. * **[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 618b3d905..4a5757aac 100644 --- a/docs/fleet_management.md +++ b/docs/fleet_management.md @@ -10,7 +10,8 @@ title: Private fleet management # Private fleet management Fleet control lets one publisher send filtering and secondary-radio commands to -all enrolled infrastructure nodes, or to one node, over a private LoRa channel. +all enrolled infrastructure nodes, one node, or a list of nodes over a private +LoRa channel. It is disabled until configured. It is supported on repeaters and room servers; fixed-size STM32 images omit it because of their flash limit. Both the publishing Companion and receivers need firmware built with fleet-control @@ -80,10 +81,28 @@ fleet send 3 all set flood.rule.4 type=grp_txt hops=6+ drop fleet send 3 all del flood.rule.4 ``` -To address one node, replace `all` with that node's complete 64-character public -key. Names and short path IDs are not target identities. On air, the target is a -128-bit SHA-256 digest of the full key. Separate fleets use separate channel keys; -named subgroups are not part of this first version. +Replace `all` with **8 or 12 hexadecimal characters from the start of a node's +public key**, or its **complete 64-character public key**. Separate multiple +targets with commas, without spaces. Lengths can be mixed: + +```text +fleet send 3 1257aee5 get radio2 +fleet send 3 1257aee5a754,9abc0123 get radio2 +fleet send 3 1257aee5,9abc01234567 set radio2.cross auto +``` + +These are example public-key prefixes; substitute your nodes' actual keys. +**A prefix addresses every enrolled node whose public key starts with it.** +Use all 64 characters when you need one exact identity, especially in a large +fleet. Names and short LoRa path IDs are not accepted as target identities. +`all` must appear alone. A node that matches several entries still executes the +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. Multiple-target commands and prefix targets require updated +publisher and receiver firmware. `all` and a single complete key retain the +previous wire format, which updated receivers also accept. 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. @@ -97,8 +116,17 @@ sequence; the operation should be chosen to be idempotent. ## Supported controls -The command body is limited to **72 ASCII bytes**, independently of the target's -full key in the local send command. Longer commands are rejected, not truncated. +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. `all` and a single complete +key retain the previous format's full 72-byte command allowance. +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. | Control | Fleet commands | | --- | --- | @@ -123,8 +151,9 @@ failed transmissions, or an acknowledgement timeout cancels the staged mutation. Temporary-radio leases retain their original monotonic end time; waiting for an acknowledgement does not lengthen them. -Individual acknowledgements wait 0.5–1.5 seconds; whole-fleet replies are spread -over 0.5–60.5 seconds (0.5–15.5 seconds for radio mutations, to preserve short +Exact single-node acknowledgements wait 0.5–1.5 seconds; prefix, multiple-target, +and whole-fleet replies are spread over 0.5–60.5 seconds (0.5–15.5 seconds for +radio mutations, to preserve short temporary leases). This reduces simultaneous responses but is not a reliable delivery protocol for an arbitrarily large fleet. Use individual requests for confirmation and divide very large fleets into separately keyed channels. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 6c5717b42..0feb5fd5a 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -9719,7 +9719,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, if (strncmp(text, "send", 4) || (text[4] != ' ' && text[4] != '\t')) { snprintf(reply, reply_capacity, - "Error: use fleet send "); + "Error: use fleet send "); return true; } text += 4; @@ -9741,20 +9741,14 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, return true; } while (*text == ' ' || *text == '\t') ++text; - // Reuse the wire buffer for the temporary target token. Encoding happens - // only after the target has been parsed into its separate binary buffer. - uint8_t payload[mesh::FleetCommand::MaxPayloadLength]; const size_t target_length = strcspn(text, " \t"); - if (target_length > PUB_KEY_SIZE * 2U - || text[target_length] == 0) { - snprintf(reply, reply_capacity, "Error: fleet target must be all or a full public key"); - return true; - } - memcpy(payload, text, target_length); - payload[target_length] = 0; - uint8_t target[mesh::FleetCommand::TargetSize]; - if (!mesh::FleetCommand::parseTarget((const char*)payload, target)) { - snprintf(reply, reply_capacity, "Error: fleet target must be all or a full public key"); + mesh::FleetCommand::Targets targets; + // Parse the complete comma-separated target token before signing anything. + // A list is one atomic packet; it must never become partial fan-out. + if (text[target_length] == 0 + || !mesh::FleetCommand::parseTargets(text, target_length, targets)) { + snprintf(reply, reply_capacity, + "Error: fleet targets must be all or comma-separated 8/12/64-hex keys"); return true; } text += target_length; @@ -9787,12 +9781,14 @@ 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]; const size_t length = mesh::FleetCommand::encode( self_id, channel.channel.secret, sequence, - sequence + mesh::FleetCommand::MaxLifetime, target, text, + sequence + mesh::FleetCommand::MaxLifetime, targets, text, payload, sizeof(payload)); if (!length) { - snprintf(reply, reply_capacity, "Error: fleet command is too long or invalid"); + 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)) { snprintf(reply, reply_capacity, "Error: could not queue fleet command"); diff --git a/src/helpers/FleetChannel.cpp b/src/helpers/FleetChannel.cpp index 25a3d636a..1d6b5a770 100644 --- a/src/helpers/FleetChannel.cpp +++ b/src/helpers/FleetChannel.cpp @@ -156,11 +156,11 @@ bool FleetChannel::decode(Mesh& mesh, FleetCommand::Decoded& command) { const size_t unpadded = 3 + payload_length; if (unpadded > (size_t)length || (unpadded + 15) / 16 * 16 != (size_t)length || !zero(data + unpadded, length - unpadded)) return false; - if (payload_length < FleetCommand::HeaderSize + FleetCommand::SignatureSize + 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; - broadcast_ = zero(data + 3 + 12, FleetCommand::TargetSize); + broadcast_ = command.broadcast; return true; } diff --git a/src/helpers/FleetCommand.cpp b/src/helpers/FleetCommand.cpp index d7df4e18b..6116db9a3 100644 --- a/src/helpers/FleetCommand.cpp +++ b/src/helpers/FleetCommand.cpp @@ -7,8 +7,11 @@ namespace { constexpr char Domain[] = "MeshCoreFleet1"; constexpr size_t SignedMessageCapacity = sizeof(Domain) - 1 - + FleetCommand::KeySize + FleetCommand::HeaderSize - + FleetCommand::MaxCommandLength; + + FleetCommand::KeySize + FleetCommand::MaxPayloadLength + - FleetCommand::SignatureSize; +static_assert(FleetCommand::HeaderSize + FleetCommand::MaxCommandLength + + FleetCommand::SignatureSize <= FleetCommand::MaxPayloadLength, + "Legacy fleet envelope must fit the shared packet budget"); constexpr uint8_t PublicKey[FleetCommand::KeySize] = { 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72 @@ -41,6 +44,37 @@ void targetHash(const uint8_t* full_key, uint8_t* target) { hash.finalize(target, FleetCommand::TargetSize); } +// Validate every record, including records after one that matches this node. +// An absent self key is used by encode to validate externally supplied lists. +bool targetRecords(const uint8_t* data, size_t available, uint8_t count, + const uint8_t* self, size_t& consumed, bool& matched, + bool& broadcast) { + consumed = 0; + matched = count == 0; + broadcast = count == 0 || count > 1; + if (count > FleetCommand::MaxTargetCount) return false; + uint8_t hash[FleetCommand::TargetSize]; + bool hashed = false; + for (unsigned i = 0; i < count; ++i) { + if (consumed >= available) return false; + const uint8_t type = data[consumed++]; + if (type != 4 && type != 6 && type != FleetCommand::TargetSize) return false; + if (type > available - consumed + || consumed + type > FleetCommand::MaxTargetBytes) return false; + if (type != FleetCommand::TargetSize) broadcast = true; + if (self) { + if (type == FleetCommand::TargetSize && !hashed) { + targetHash(self, hash); + hashed = true; + } + const uint8_t* expected = type == FleetCommand::TargetSize ? hash : self; + if (!memcmp(data + consumed, expected, type)) matched = true; + } + consumed += type; + } + return true; +} + int hexDigit(char digit) { if (digit >= '0' && digit <= '9') return digit - '0'; if (digit >= 'a' && digit <= 'f') return digit - 'a' + 10; @@ -150,6 +184,43 @@ bool FleetCommand::parseTarget(const char* text, uint8_t* target) { return !zero(target, TargetSize); } +bool FleetCommand::parseTargets(const char* text, size_t length, Targets& targets) { + memset(&targets, 0, sizeof(targets)); + if (!text || !length || length > MaxTargetCount * (PUB_KEY_SIZE * 2 + 1)) return false; + if (length == 3 && !memcmp(text, "all", 3)) return true; + // Clear partial output on failure without retaining another 88-byte list + // on the command parser's stack. + const auto fail = [&targets]() { + memset(&targets, 0, sizeof(targets)); + return false; + }; + size_t cursor = 0; + while (cursor < length) { + const size_t start = cursor; + while (cursor < length && text[cursor] != ',') ++cursor; + const size_t token_length = cursor - start; + if (token_length != 8 && token_length != 12 && token_length != PUB_KEY_SIZE * 2) return fail(); + const size_t type = token_length == PUB_KEY_SIZE * 2 ? TargetSize : token_length / 2; + if (targets.count == MaxTargetCount || targets.length + 1 + type > MaxTargetBytes) return fail(); + uint8_t full_key[PUB_KEY_SIZE]; + const size_t byte_length = token_length / 2; + for (size_t i = 0; i < byte_length; ++i) { + const int upper = hexDigit(text[start + i * 2]); + const int lower = hexDigit(text[start + i * 2 + 1]); + if (upper < 0 || lower < 0) return fail(); + full_key[i] = uint8_t((upper << 4) | lower); + } + if (type == TargetSize && zero(full_key, PUB_KEY_SIZE)) return fail(); + targets.data[targets.length++] = uint8_t(type); + if (type == TargetSize) targetHash(full_key, targets.data + targets.length); + else memcpy(targets.data + targets.length, full_key, type); + targets.length += uint8_t(type); + ++targets.count; + if (cursor < length && ++cursor == length) return fail(); + } + return true; +} + bool FleetCommand::commandAllowed(const char* command) { if (!command) return false; size_t length = 0; @@ -221,33 +292,87 @@ size_t FleetCommand::encode(const LocalIdentity& publisher, const uint8_t* key, return length; } +size_t FleetCommand::encode(const LocalIdentity& publisher, const uint8_t* key, + uint32_t sequence, uint32_t expires, + const Targets& targets, const char* command, + uint8_t* output, size_t capacity) { + if (!privateKeyAllowed(key) || !output || targets.length > MaxTargetBytes + || !lifetimeAllowed(sequence, expires) || !commandAllowed(command) + || zero(publisher.pub_key, PUB_KEY_SIZE)) return 0; + size_t consumed; + bool matched, broadcast; + if (!targetRecords(targets.data, targets.length, targets.count, nullptr, + consumed, matched, broadcast) || consumed != targets.length) return 0; + // Preserve compatibility with receivers implementing the original format + // whenever the request can be represented without prefixes or a list. + if (!targets.count) { + const uint8_t all[TargetSize] = {}; + return encode(publisher, key, sequence, expires, all, command, output, capacity); + } + if (targets.count == 1 && targets.data[0] == TargetSize + && !zero(targets.data + 1, TargetSize)) { + return encode(publisher, key, sequence, expires, targets.data + 1, command, output, capacity); + } + 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; + memcpy(output, "FMC2", 4); + write32(output + 4, sequence); + write32(output + 8, expires); + output[12] = targets.count; + memcpy(output + 13, targets.data, targets.length); + output[13 + targets.length] = uint8_t(command_length); + memcpy(output + MinHeaderSize + targets.length, command, command_length); + uint8_t message[SignedMessageCapacity]; + const size_t message_length = signedMessage(key, output, unsigned_length, message); + publisher.sign(output + unsigned_length, message, int(message_length)); + erase(message, sizeof(message)); + return length; +} + bool FleetCommand::decode(const Identity& publisher, const uint8_t* key, const uint8_t* payload, size_t length, uint32_t now, const uint8_t* self_public_key, Decoded& output) { memset(&output, 0, sizeof(output)); if (!privateKeyAllowed(key) || !payload || !self_public_key || zero(publisher.pub_key, PUB_KEY_SIZE) || zero(self_public_key, PUB_KEY_SIZE) - || now < MinEpoch || length < HeaderSize + SignatureSize - || length > MaxPayloadLength || memcmp(payload, "FMC1", 4)) return false; - const size_t command_length = payload[28]; + || now < MinEpoch || length < MinHeaderSize + SignatureSize + || length > MaxPayloadLength) return false; + const size_t unsigned_length = length - SignatureSize; + size_t command_offset; + bool broadcast; + if (!memcmp(payload, "FMC1", 4)) { + if (length < HeaderSize + SignatureSize) return false; + broadcast = zero(payload + 12, TargetSize); + if (!broadcast) { + uint8_t target[TargetSize]; + targetHash(self_public_key, target); + if (memcmp(target, payload + 12, TargetSize)) return false; + } + command_offset = HeaderSize; + } else if (!memcmp(payload, "FMC2", 4)) { + size_t consumed; + bool matched; + if (!targetRecords(payload + 13, unsigned_length - 13, payload[12], + self_public_key, consumed, matched, broadcast) + || !matched) return false; + command_offset = MinHeaderSize + consumed; + if (command_offset > unsigned_length) return false; + } else return false; + const size_t command_length = payload[command_offset - 1]; if (!command_length || command_length > MaxCommandLength - || length != HeaderSize + command_length + SignatureSize) return false; + || unsigned_length != command_offset + command_length) return false; const uint32_t sequence = read32(payload + 4), expires = read32(payload + 8); if (!lifetimeAllowed(sequence, expires) || expires < now || (sequence > now && sequence - now > MaxClockLead)) return false; - if (!zero(payload + 12, TargetSize)) { - uint8_t target[TargetSize]; - targetHash(self_public_key, target); - if (memcmp(target, payload + 12, TargetSize)) return false; - } // Reject embedded NUL bytes, which would authorize a different text from // the signed byte string when passed to the CLI parser. - if (memchr(payload + HeaderSize, 0, command_length)) return false; + if (memchr(payload + command_offset, 0, command_length)) return false; char command[MaxCommandLength + 1]; - memcpy(command, payload + HeaderSize, command_length); + memcpy(command, payload + command_offset, command_length); command[command_length] = 0; if (!commandAllowed(command)) return false; - const size_t unsigned_length = HeaderSize + command_length; uint8_t message[SignedMessageCapacity]; const size_t message_length = signedMessage(key, payload, unsigned_length, message); const bool valid = publisher.verify(payload + unsigned_length, message, int(message_length)); @@ -255,6 +380,7 @@ bool FleetCommand::decode(const Identity& publisher, const uint8_t* key, if (!valid) return false; output.sequence = sequence; output.expires = expires; + output.broadcast = broadcast; memcpy(output.command, command, command_length + 1); return true; } diff --git a/src/helpers/FleetCommand.h b/src/helpers/FleetCommand.h index bd0395335..ea2c2e2b3 100644 --- a/src/helpers/FleetCommand.h +++ b/src/helpers/FleetCommand.h @@ -26,6 +26,9 @@ public: 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 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; @@ -34,13 +37,25 @@ public: struct Decoded { uint32_t sequence; uint32_t expires; + bool broadcast; char command[MaxCommandLength + 1]; }; + struct Targets { + uint8_t length; + uint8_t count; + // Each record is a length/type byte (4, 6, or 16) followed by that many + // bytes. Prefixes are raw public-key bytes; complete keys are SHA-256/128. + uint8_t data[MaxTargetBytes]; + }; + static bool privateKeyAllowed(const uint8_t* key); // "all" becomes the reserved zero target; otherwise require one complete // public key and derive the first 16 bytes of SHA-256, not a short node ID. static bool parseTarget(const char* text, uint8_t* target); + // Parse one bounded token containing comma-separated 8-, 12-, or 64-digit + // public keys. "all" is accepted only alone and produces zero records. + static bool parseTargets(const char* text, size_t length, Targets& targets); static bool commandAllowed(const char* command); // Return the exact envelope size, or zero on invalid input. The signature @@ -50,6 +65,14 @@ public: const uint8_t* target, const char* command, uint8_t* output, size_t capacity); + // Prefix/list requests use FMC2. "all" and a single complete key retain + // FMC1 compatibility. Targets and command share the packet budget, so valid + // lists can still be too large for a given command. + static size_t encode(const LocalIdentity& publisher, const uint8_t* key, + uint32_t sequence, uint32_t expires, + const Targets& targets, const char* command, + uint8_t* output, size_t capacity); + // Accept an exact envelope only: group framing owns padding validation. // On success the caller must durably reject an old/repeated sequence before // any mutation, and defer execution until the receive stack has unwound. diff --git a/test/fixtures/companion_fleet_dispatch.h b/test/fixtures/companion_fleet_dispatch.h index 6cb4d821e..02951a345 100644 --- a/test/fixtures/companion_fleet_dispatch.h +++ b/test/fixtures/companion_fleet_dispatch.h @@ -25,12 +25,13 @@ struct FleetCommand { static constexpr uint16_t DataType = 0xFF01; static constexpr size_t KeySize = 16, TargetSize = 16, MaxPayloadLength = 165; static constexpr uint32_t MinEpoch = 1735689600UL, MaxLifetime = 600; - static bool parseTarget(const char*, uint8_t*) { return false; } + struct Targets { uint8_t length = 0, count = 0, data[86] = {}; }; + static bool parseTargets(const char*, size_t, Targets&) { return false; } static bool commandAllowed(const char*) { return false; } static bool privateKeyAllowed(const uint8_t*) { return false; } template static size_t encode(const Signer&, const uint8_t*, uint32_t, uint32_t, - const uint8_t*, const char*, uint8_t*, size_t) { + const Targets&, const char*, uint8_t*, size_t) { assert(false && "fleet encoding belongs in the dedicated real-codec suite"); return 0; } diff --git a/test/test_companion_fleet_control.py b/test/test_companion_fleet_control.py index c84e46f30..22c8c78d9 100644 --- a/test/test_companion_fleet_control.py +++ b/test/test_companion_fleet_control.py @@ -126,6 +126,7 @@ int main(int argc, char** argv) { assert(!node.handleCommand("fleet send 1 all set radio2 off", 0, nullptr)); assert(!node.handleCommand("fleetish", 0, reply)); rejected(node, "fleet send 1 all set radio2 off", 1); + rejected(node, "fleet send 1 00112233,445566778899 set radio2 off", 1); rejected(node, "fleet", 1); rejected(node, "fleet send 1 all reboot"); rejected(node, "fleet send 1 all get prv.key"); @@ -134,12 +135,13 @@ int main(int argc, char** argv) { assert(!strncmp(reply, "AB|OK - fleet command queued; seq=", 32)); assert(node.queued == 1 && node.sent_type == mesh::FleetCommand::DataType); assert(node.sent_policy == 2); // The configured radio2 channel route survives. + assert(!memcmp(node.sent, "FMC1", 4)); // Broadcasts remain compatible with earlier receivers. } else if (scenario == "syntax") { for (const char* command : {"fleet", "fleet send", "fleet sender 1 all set radio2 off", "fleet send -1 all set radio2 off", "fleet send 4 all set radio2 off", "fleet send 4294967296 all set radio2 off", "fleet send 1x all set radio2 off", "fleet send 1 all", "fleet send 1 a set radio2 off", - "fleet send 1 111111111111 set radio2 off", "fleet send 1 all get password", + "fleet send 1 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()); @@ -187,6 +189,7 @@ int main(int argc, char** argv) { + " set tempradio2 910.5,500,5,5,rxtx,1"; assert(node.handleCommand(command.c_str(), 0, reply)); assert(node.queued == 1); + assert(!memcmp(node.sent, "FMC1", 4)); // One full key keeps the original envelope. 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, @@ -197,6 +200,78 @@ int main(int argc, char** argv) { recipient[31] ^= 1; assert(!mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret, node.sent, node.sent_length, node.clock.now, recipient, decoded)); + } else if (scenario == "mixed_targets") { + const std::string command = "fleet send 1 42424242,515151515151," + fullKey(0x63) + + " set radio2 off"; + assert(node.handleCommand(command.c_str(), 0, reply)); + assert(node.queued == 1 && node.sent_policy == 2); + assert(!memcmp(node.sent, "FMC2", 4)); + mesh::FleetCommand::Decoded decoded; + uint8_t recipient[32]; + auto matched = [&]() { + return mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret, + node.sent, node.sent_length, node.clock.now, recipient, decoded); + }; + memset(recipient, 0x42, sizeof(recipient)); + assert(matched()); + assert(!strcmp(decoded.command, "set radio2 off")); + recipient[31] ^= 1; + assert(matched()); // An eight-hex target selects the four-byte prefix. + recipient[3] ^= 1; + assert(!matched()); + memset(recipient, 0x51, sizeof(recipient)); + assert(matched()); + recipient[31] ^= 1; + assert(matched()); // Twelve hex selects all six specified prefix bytes. + recipient[5] ^= 1; + assert(!matched()); + memset(recipient, 0x63, sizeof(recipient)); + assert(matched()); + recipient[31] ^= 1; + assert(!matched()); // Full keys retain the complete-key fingerprint. + memset(recipient, 0x77, sizeof(recipient)); + assert(!matched()); + } else if (scenario == "multiple_full_targets") { + const std::string command = "fleet send 1 " + fullKey(0x42) + "," + fullKey(0x63) + + " set radio2 off"; + assert(node.handleCommand(command.c_str(), 0, reply)); + assert(node.queued == 1); + assert(!memcmp(node.sent, "FMC2", 4)); + mesh::FleetCommand::Decoded decoded; + uint8_t recipient[32]; + for (uint8_t value : {0x42, 0x63}) { + memset(recipient, value, sizeof(recipient)); + assert(mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret, + node.sent, node.sent_length, node.clock.now, recipient, decoded)); + recipient[31] ^= 1; + assert(!mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret, + node.sent, node.sent_length, node.clock.now, recipient, decoded)); + } + } else if (scenario == "invalid_target_lists") { + for (const char* targets : {"all,00112233", "00112233,all", "00112233,,44556677", + ",00112233", "00112233,", "0011223", "001122334", "0011223344556", + "GG112233", "00112233,44556677889Z"}) { + rejected(node, (std::string("fleet send 1 ") + targets + " set radio2 off").c_str()); + } + assert(node.handleCommand("fleet send 1 00112233 set radio2 off", 0, reply)); + assert(node.queued == 1); // Invalid lists consumed no sequence or partial send. + } else if (scenario == "target_capacity") { + std::string targets; + for (unsigned i = 1; i <= 17; ++i) { + char prefix[9]; snprintf(prefix, sizeof(prefix), "%08x", i); + if (!targets.empty()) targets += ','; + targets += prefix; + } + mesh::FleetCommand::Targets parsed; + assert(mesh::FleetCommand::parseTargets(targets.c_str(), targets.size(), parsed)); + 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); + ++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); } else assert(false); printf("Companion fleet scenario %s passed\n", argv[1]); } @@ -263,6 +338,18 @@ class CompanionFleetControlTests(unittest.TestCase): def test_production_encode_targets_full_key_and_sets_bounded_expiry(self): self.scenario("signed_target") + def test_mixed_prefix_and_full_key_list_matches_only_selected_nodes(self): + self.scenario("mixed_targets") + + def test_multiple_full_keys_share_one_signed_packet(self): + self.scenario("multiple_full_targets") + + 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): + self.scenario("target_capacity") + def test_disabled_profiles_require_no_sender_peripherals_or_crypto_link(self): harness = r''' #include diff --git a/test/test_fleet_channel_runtime.py b/test/test_fleet_channel_runtime.py index e396fcd8e..df606ba0a 100644 --- a/test/test_fleet_channel_runtime.py +++ b/test/test_fleet_channel_runtime.py @@ -292,6 +292,32 @@ static Packet command(const LocalIdentity& publisher,uint32_t sequence,const cha return raw(payload,3+length); } +static Packet targetsCommand(const LocalIdentity& publisher,uint32_t sequence,const std::string& list, + const char* text="get radio2"){ + FleetCommand::Targets targets;assert(FleetCommand::parseTargets(list.c_str(),list.size(),targets)); + uint8_t payload[184]={}; + const size_t length=FleetCommand::encode(publisher,channel_key,sequence,sequence+300,targets,text, + payload+3,sizeof(payload)-3); + assert(length);payload[0]=uint8_t(FleetCommand::DataType);payload[1]=uint8_t(FleetCommand::DataType>>8); + payload[2]=uint8_t(length); + return raw(payload,3+length); +} +static Packet shortNewAll(const LocalIdentity& publisher,uint32_t sequence){ + constexpr const char* text="get radio2"; + uint8_t data[184]={},message[160]={};uint8_t* payload=data+3; + memcpy(payload,"FMC2",4);storage::writeLE32(payload+4,sequence); + storage::writeLE32(payload+8,sequence+300);payload[12]=0; + const size_t text_size=strlen(text);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 std::string publicHex(const Identity& identity){ + char text[65];Utils::toHex(text,identity.pub_key,32);return text; +} 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); @@ -317,7 +343,7 @@ static unsigned apply(FleetChannel& fleet,fs::FS& fs,Mesh& mesh,RadioProfileCLI& fleet.service(mesh,profiles,"test:node",[&](uint32_t seq,const char* text,char* reply){ ++calls;assert(storage::readLE32(fs.files.at(Path).data()+56)==seq); assert(mesh.scoped_profile==1&&mesh.scoped_generation==17);assert(profiles.command); - assert(!strcmp(text,"set radio2 off")||!strcmp(text,"get radio2.status")); + assert(!strcmp(text,"set radio2 off")||!strcmp(text,"get radio2.status")||!strcmp(text,"get radio2")); if(mutation)profiles.stage();strcpy(reply,fail_handler?"Err - rejected":"OK"); }); assert(mesh.scoped_profile==0&&mesh.scoped_generation==0);assert(!profiles.command);return calls; @@ -532,6 +558,40 @@ int main(int argc,char** argv){ assert(!captureFleetReplyScope(regions,static_cast(nullptr),&packet,captured)); region.wildcard=false;packet.transport_codes[0]^=1; assert(!captureFleetReplyScope(regions,®ion,&packet,captured)); + + }else if(scenario=="fmc2_short_all"){ + enroll(fleet,publisher);Packet packet=shortNewAll(publisher,mesh.clock.now); + uint8_t plaintext[184]={};assert(Utils::MACThenDecrypt(channel_key,plaintext,packet.payload+1,packet.payload_len-1)>0); + assert(plaintext[2]0); + assert(plaintext[2]==FleetCommand::HeaderSize+FleetCommand::SignatureSize); + assert(!memcmp(plaintext+3,"FMC2",4)); + assert(apply(fleet,fs,mesh,profiles,packet)==1);assert(last_jitter_max==60500); + FleetChannel rebooted(&fs);assert(apply(rebooted,fs,mesh,profiles,packet)==0); + }else if(scenario=="fmc2_overlap_matches_once"){ + enroll(fleet,publisher);const std::string mine=publicHex(mesh.self_id); + Packet packet=targetsCommand(publisher,mesh.clock.now,mine.substr(0,8)+","+mine); + assert(apply(fleet,fs,mesh,profiles,packet)==1);assert(mesh.queued==1&&last_jitter_max==60500); + assert(config(fleet,"get fleet.stats").find("accepted=1")!=std::string::npos); + }else if(scenario=="fmc2_nonmatching_no_reserve"){ + enroll(fleet,publisher);const auto previous=fs.files.at(Path);LocalIdentity stranger; + char different_prefix[9];uint8_t changed[4];memcpy(changed,mesh.self_id.pub_key,4);changed[0]^=1; + Utils::toHex(different_prefix,changed,4); + Packet packet=targetsCommand(publisher,mesh.clock.now,std::string(different_prefix)+","+publicHex(stranger)); + assert(apply(fleet,fs,mesh,profiles,packet)==0);assert(mesh.queued==0&&fs.files.at(Path)==previous); + packet=targetsCommand(publisher,mesh.clock.now,publicHex(stranger)+","+publicHex(mesh.self_id)); + assert(apply(fleet,fs,mesh,profiles,packet)==1);assert(last_jitter_max==60500); + }else if(scenario=="fmc2_target_tamper"){ + enroll(fleet,publisher);const auto previous=fs.files.at(Path); + Packet good=targetsCommand(publisher,mesh.clock.now,publicHex(mesh.self_id).substr(0,12)); + uint8_t plaintext[184]={};const int length=Utils::MACThenDecrypt(channel_key,plaintext,good.payload+1,good.payload_len-1); + assert(length>0);plaintext[3+13]^=1;Packet changed=raw(plaintext,length); + assert(apply(fleet,fs,mesh,profiles,changed)==0);assert(fs.files.at(Path)==previous); + assert(apply(fleet,fs,mesh,profiles,good)==1); }else if(scenario=="existing_mutation"){ enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now); profiles.mutation=true;assert(apply(fleet,fs,mesh,profiles,packet)==0); @@ -681,6 +741,21 @@ class SHA256 : public SHA256Base {{ public: def test_scope_selects_matching_rotated_region_key_and_authenticates_new_ack_payload(self): self.scenario("rotated_scope") + def test_new_short_broadcast_envelope_dispatches_and_preserves_replay_protection(self): + self.scenario("fmc2_short_all") + + def test_new_public_key_prefix_envelope_dispatches_and_staggers_possible_multiple_acks(self): + self.scenario("fmc2_short_prefix") + + def test_overlapping_prefix_and_full_key_matches_execute_once(self): + self.scenario("fmc2_overlap_matches_once") + + def test_nonmatching_target_list_never_reserves_sequence_and_matching_list_can_reuse_it(self): + self.scenario("fmc2_nonmatching_no_reserve") + + def test_modified_new_target_header_never_dispatches_without_valid_signature(self): + self.scenario("fmc2_target_tamper") + def test_existing_remote_radio_mutation_blocks_fleet_without_reserving_sequence(self): self.scenario("existing_mutation") diff --git a/test/test_fleet_command/test_fleet_command.cpp b/test/test_fleet_command/test_fleet_command.cpp index 369998fb0..0f84f1ca4 100644 --- a/test/test_fleet_command/test_fleet_command.cpp +++ b/test/test_fleet_command/test_fleet_command.cpp @@ -47,6 +47,15 @@ protected: return Fleet::decode(publisher, ChannelKey, bytes.data(), bytes.size(), now, publisher.pub_key, decoded); } + + std::vector encodeTargets(const Fleet::Targets& targets, + const char* command = "get radio2") { + std::vector bytes(Fleet::MaxPayloadLength); + const size_t size = Fleet::encode(publisher, ChannelKey, Now, Now + 120, + targets, command, bytes.data(), bytes.size()); + bytes.resize(size); + return bytes; + } }; TEST_F(FleetCommandTest, ExplicitRadioAndFilterFamiliesAreAllowed) { @@ -156,6 +165,7 @@ TEST_F(FleetCommandTest, ExactEnvelopeRoundTripsThroughVerifierBoundary) { EXPECT_EQ(calls + 1, g_mock_ed25519_verify_calls); EXPECT_EQ(Now, decoded.sequence); EXPECT_EQ(Now + 120, decoded.expires); + EXPECT_TRUE(decoded.broadcast); EXPECT_STREQ("get radio2", decoded.command); } @@ -226,6 +236,7 @@ TEST_F(FleetCommandTest, MatchesHashedCompleteSelfIdentityOrAll) { hash.update(publisher.pub_key, PUB_KEY_SIZE); hash.finalize(target.data(), target.size()); ASSERT_TRUE(decode(encode("get radio2", Now, Now + 120, target.data()))); + EXPECT_FALSE(decoded.broadcast); target[15] ^= 1; EXPECT_FALSE(decode(encode("get radio2", Now, Now + 120, target.data()))); EXPECT_TRUE(decode(encode())); @@ -265,6 +276,215 @@ TEST_F(FleetCommandTest, UnixRangeBoundaryDoesNotWrap) { EXPECT_TRUE(encode("get radio2", maximum - 50, 1).empty()); } +TEST_F(FleetCommandTest, BoundedTargetTokenParsesMixedPrefixWidthsAndCompleteKeys) { + Fleet::Targets targets{}; + const std::string full = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; + const std::string token = "A1B2C3D4,010203040506," + full; + const std::string joined = token + " get radio2"; + ASSERT_TRUE(Fleet::parseTargets(joined.data(), token.size(), targets)); + EXPECT_EQ(3, targets.count); + EXPECT_EQ(5 + 7 + 17, targets.length); + EXPECT_EQ(4, targets.data[0]); + EXPECT_EQ(0xA1, targets.data[1]); + EXPECT_EQ(0xD4, targets.data[4]); + EXPECT_EQ(6, targets.data[5]); + EXPECT_EQ(6, targets.data[11]); + EXPECT_EQ(16, targets.data[12]); + EXPECT_EQ(0xAE, targets.data[13]); + EXPECT_EQ(0xE4, targets.data[28]); + const char nonterminated[8] = {'0','1','0','2','0','3','0','4'}; + EXPECT_TRUE(Fleet::parseTargets(nonterminated, sizeof(nonterminated), targets)); + EXPECT_EQ(1, targets.count); + EXPECT_EQ(5, targets.length); + EXPECT_TRUE(Fleet::parseTargets("all plus other text", 3, targets)); + EXPECT_EQ(0, targets.count); + EXPECT_EQ(0, targets.length); +} + +TEST_F(FleetCommandTest, RejectsMalformedWholeTargetListsWithoutPartialResults) { + for (const std::string& token : { + std::string(""), std::string("all,01020304"), std::string("01020304,all"), + std::string("all,all"), std::string("ALL"), std::string(",01020304"), + std::string("01020304,"), std::string("01020304,,05060708"), + std::string("01020304,0506070g"), std::string("01020304 05060708"), + std::string("01020304, 05060708"), std::string("01020304\n"), + std::string("01"), std::string(10, '1'), std::string(16, '1'), + std::string(32, '1'), std::string(63, '1'), std::string(65, '1'), + std::string(64, '0'), std::string("01020304,") + std::string(64, '0')}) { + Fleet::Targets targets; + memset(&targets, 0xA5, sizeof(targets)); + EXPECT_FALSE(Fleet::parseTargets(token.data(), token.size(), targets)) << token; + EXPECT_EQ(0, targets.length); + EXPECT_EQ(0, targets.count); + } + Fleet::Targets targets{}; + EXPECT_FALSE(Fleet::parseTargets(nullptr, 8, targets)); + EXPECT_FALSE(Fleet::parseTargets("01020304", SIZE_MAX, targets)); + EXPECT_FALSE(Fleet::parseTargets("01020304", 0, targets)); +} + +TEST_F(FleetCommandTest, TargetRecordAndPayloadBudgetsAreSeparateAndBounded) { + std::string list; + for (unsigned count = 1; count <= 17; ++count) { + if (!list.empty()) list += ','; + list += "01020304"; + Fleet::Targets targets{}; + 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; + } + Fleet::Targets targets{}; + list += ",01020304"; + EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets)); + list.clear(); + const std::string full = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; + for (unsigned count = 1; count <= 5; ++count) { + if (!list.empty()) list += ','; + list += full; + ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets)); + EXPECT_EQ(count * 17, targets.length); + } + EXPECT_TRUE(encodeTargets(targets).empty()); + list += ',' + full; + EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets)); +} + +TEST_F(FleetCommandTest, AllAndSingleCompleteKeyKeepLegacyFmc1Encoding) { + Fleet::Targets targets{}; + ASSERT_TRUE(Fleet::parseTargets("all", 3, targets)); + EXPECT_EQ(encode(), encodeTargets(targets)); + char full[PUB_KEY_SIZE * 2 + 1]; + mesh::Utils::toHex(full, publisher.pub_key, PUB_KEY_SIZE); + uint8_t hash[Fleet::TargetSize]; + ASSERT_TRUE(Fleet::parseTarget(full, hash)); + ASSERT_TRUE(Fleet::parseTargets(full, 64, targets)); + EXPECT_EQ(encode("get radio2", Now, Now + 120, hash), encodeTargets(targets)); + EXPECT_TRUE(decode(encodeTargets(targets))); + EXPECT_FALSE(decoded.broadcast); +} + +TEST_F(FleetCommandTest, PrefixesAndListsUseFmc2WithSignedFraming) { + char full[PUB_KEY_SIZE * 2 + 1]; + mesh::Utils::toHex(full, publisher.pub_key, PUB_KEY_SIZE); + for (size_t width : {8U, 12U}) { + Fleet::Targets targets{}; + ASSERT_TRUE(Fleet::parseTargets(full, width, targets)); + const auto bytes = encodeTargets(targets); + ASSERT_FALSE(bytes.empty()); + EXPECT_EQ(std::string("FMC2"), std::string(bytes.begin(), bytes.begin() + 4)); + EXPECT_EQ(1, bytes[12]); + EXPECT_EQ(width / 2, bytes[13]); + EXPECT_TRUE(decode(bytes)); + EXPECT_TRUE(decoded.broadcast); + } + const std::string list = std::string(full) + ',' + full; + Fleet::Targets targets{}; + ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets)); + const auto bytes = encodeTargets(targets); + EXPECT_TRUE(decode(bytes)); + EXPECT_TRUE(decoded.broadcast); + EXPECT_EQ(2, bytes[12]); +} + +TEST_F(FleetCommandTest, AnyMatchingEntryIsAcceptedButMalformedLaterEntryIsRejected) { + char full[PUB_KEY_SIZE * 2 + 1]; + mesh::Utils::toHex(full, publisher.pub_key, PUB_KEY_SIZE); + const std::string prefix(full, 8); + Fleet::Targets targets{}; + const std::string list = prefix + ",01020304," + full; + ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets)); + auto bytes = encodeTargets(targets); + ASSERT_TRUE(decode(bytes)); + bytes[18] = 7; // Unknown type after the first matching four-byte prefix. + EXPECT_FALSE(decode(bytes)); + EXPECT_FALSE(decoded.broadcast); + bytes = encodeTargets(targets); + bytes[12] = 18; + EXPECT_FALSE(decode(bytes)); + bytes = encodeTargets(targets); + bytes[12] = 4; + EXPECT_FALSE(decode(bytes)); + bytes = encodeTargets(targets); + bytes[13 + targets.length] = 0; + EXPECT_FALSE(decode(bytes)); + bytes = encodeTargets(targets); + bytes[13 + targets.length] = 73; + EXPECT_FALSE(decode(bytes)); + for (size_t length = 0; length < bytes.size(); ++length) { + const std::vector truncated(bytes.begin(), bytes.begin() + length); + EXPECT_FALSE(decode(truncated)); + } +} + +TEST_F(FleetCommandTest, RawPrefixesIncludeLegitimateZeroPrefixesAndCollisions) { + Fleet::Targets targets{}; + ASSERT_TRUE(Fleet::parseTargets("00000000", 8, targets)); + const auto bytes = encodeTargets(targets); + uint8_t first[PUB_KEY_SIZE] = {}, second[PUB_KEY_SIZE] = {}; + first[4] = 1; + second[4] = 2; + ASSERT_TRUE(Fleet::decode(publisher, ChannelKey, bytes.data(), bytes.size(), + Now, first, decoded)); + EXPECT_TRUE(decoded.broadcast); + ASSERT_TRUE(Fleet::decode(publisher, ChannelKey, bytes.data(), bytes.size(), + Now, second, decoded)); + ASSERT_TRUE(Fleet::parseTargets("000000000100", 12, targets)); + const auto narrow = encodeTargets(targets); + EXPECT_TRUE(Fleet::decode(publisher, ChannelKey, narrow.data(), narrow.size(), + Now, first, decoded)); + EXPECT_FALSE(Fleet::decode(publisher, ChannelKey, narrow.data(), narrow.size(), + Now, second, decoded)); + first[0] = 1; + EXPECT_FALSE(Fleet::decode(publisher, ChannelKey, bytes.data(), bytes.size(), + Now, first, decoded)); +} + +TEST_F(FleetCommandTest, InvalidExternalTargetStructuresNeverWriteOutput) { + std::array output; + output.fill(0xA5); + for (unsigned invalid = 0; invalid < 7; ++invalid) { + Fleet::Targets targets{}; + if (invalid == 0) targets.length = 1; + if (invalid == 1) targets.count = 1; + if (invalid == 2) targets.count = 18; + if (invalid == 3) targets.length = 87; + if (invalid == 4) { targets.count = 1; targets.length = 5; targets.data[0] = 3; } + if (invalid == 5) { targets.count = 2; targets.length = 5; targets.data[0] = 4; } + if (invalid == 6) { targets.count = 1; targets.length = 4; targets.data[0] = 4; } + EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 120, + targets, "get radio2", output.data(), output.size())); + for (uint8_t byte : output) EXPECT_EQ(0xA5, byte); + } +} + +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 char* four = "01020304,05060708,090A0B0C,01020304"; + ASSERT_TRUE(Fleet::parseTargets(four, strlen(four), targets)); + 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, UnrepresentableZeroDigestNeverBecomesAllDuringLegacyEncoding) { + Fleet::Targets targets{}; + targets.count = 1; + targets.length = 17; + targets.data[0] = 16; + const auto bytes = encodeTargets(targets); + ASSERT_FALSE(bytes.empty()); + EXPECT_EQ(std::string("FMC2"), std::string(bytes.begin(), bytes.begin() + 4)); + EXPECT_FALSE(decode(bytes)); +} + } // namespace int main(int argc, char** argv) { diff --git a/test/test_fleet_command_crypto.py b/test/test_fleet_command_crypto.py index f477cf4cd..7223e9407 100644 --- a/test/test_fleet_command_crypto.py +++ b/test/test_fleet_command_crypto.py @@ -95,15 +95,29 @@ int main(int argc, char** argv) { if (!size) std::cout << "reject\n"; else hex(output.data(), size); return 0; } - if (argc == 7 && std::string(argv[1]) == "decode") { + if (argc == 7 && std::string(argv[1]) == "encode2") { + 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; + 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); + return 0; + } + if (argc == 7 && (std::string(argv[1]) == "decode" || std::string(argv[1]) == "decode2")) { const auto public_key = unhex(argv[2]), key = unhex(argv[3]), self = unhex(argv[4]), bytes = unhex(argv[6]); assert(public_key.size() == PUB_KEY_SIZE && self.size() == PUB_KEY_SIZE && key.size() == Fleet::KeySize); mesh::Identity publisher(public_key.data()); Fleet::Decoded output; if (!Fleet::decode(publisher, key.data(), bytes.data(), bytes.size(), uint32_t(std::stoul(argv[5])), self.data(), output)) { - assert(output.sequence == 0 && output.expires == 0 && output.command[0] == 0); + assert(output.sequence == 0 && output.expires == 0 && !output.broadcast && output.command[0] == 0); std::cout << "reject\n"; - } else std::cout << output.sequence << '\n' << output.expires << '\n' << output.command << '\n'; + } else { + std::cout << output.sequence << '\n' << output.expires << '\n' << output.command << '\n'; + if (std::string(argv[1]) == "decode2") std::cout << int(output.broadcast) << '\n'; + } return 0; } return 2; @@ -118,6 +132,16 @@ def envelope(command="get radio2", sequence=NOW, expires=NOW + 120, return unsigned + publisher.sign(DOMAIN + channel + unsigned) +def envelope2(entries=(), command="get radio2", sequence=NOW, expires=NOW + 120, + channel=CHANNEL, publisher=PUBLISHER, count=None): + command = command.encode("ascii") if isinstance(command, str) else command + records = b"".join(bytes([kind]) + value for kind, value in entries) + count = len(entries) if count is None else count + unsigned = (b"FMC2" + struct.pack(" 15) + + def test_fmc2_rejects_short_trailing_and_signed_forbidden_commands(self): + original = envelope2([(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6])]) + for length in [0, 12, 13, 14, 18, 19, len(original) - 1]: + self.assertEqual(self.decode(original[:length]), "reject") + self.assertEqual(self.decode(original + b"\0"), "reject") + for command in ["get prv.key", "set fleet.controller off", "get radio2\nreboot", b"get radio2\0"]: + self.assertEqual(self.decode(envelope2([(4, PUBLIC[:4])], command)), "reject") + + def test_fmc2_uses_same_signed_sequence_and_clock_rules(self): + entries = [(4, PUBLIC[:4])] + original = envelope2(entries) + self.assertEqual(struct.unpack("