diff --git a/docs/fleet_management.md b/docs/fleet_management.md index 7175faf00..5886b94d1 100644 --- a/docs/fleet_management.md +++ b/docs/fleet_management.md @@ -9,7 +9,8 @@ title: Private fleet management # Private fleet management -Fleet control lets one publisher send filtering and secondary-radio commands to +Fleet control lets one publisher send filtering, radio schedules, secondary-radio +changes, and clock commands to 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; @@ -74,16 +75,21 @@ repeater, and no app protocol change is required. Examples use private channel index `3`: ```text -fleet send 3 all get radio2 -fleet send 3 all set tempradio2 910.5,500,8,5,rxtx,120 -fleet send 3 all set radio2.cross auto -fleet send 3 all set flood.rule.4 type=grp_txt hops=6+ drop -fleet send 3 all del flood.rule.4 +fleet send 3 get radio2 +fleet send 3 set tempradio2 910.5,500,8,5,rxtx,120 +fleet send 3 set radio2.cross auto +fleet send 3 set flood.rule.4 type=grp_txt hops=6+ drop +fleet send 3 del flood.rule.4 ``` -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: +Targets are optional: omitting the target sends to **all enrolled nodes** on the +private fleet channel. Explicit `all` still works, for example +`fleet send 3 all get radio2`. + +To address particular nodes, insert **8 or 12 hexadecimal characters from the +start of a node's public key**, or its **complete 64-character public key**, before +the command. Separate multiple targets with commas, without spaces. Lengths can +be mixed: ```text fleet send 3 1257aee5 get radio2 @@ -99,10 +105,12 @@ fleet. Names and short LoRa path IDs are not accepted as target identities. 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. +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. Only local commands on the publishing Companion can originate fleet sends. A remote CLI command cannot turn that Companion into a multicast signing proxy. @@ -121,8 +129,9 @@ 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. +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 @@ -156,12 +165,12 @@ replies that the command is unsupported. Second-profile schedules use `rx` or `rxtx` after the coding rate: ```text -fleet send 3 all clock -fleet send 3 all clock sync -fleet send 3 all set radioat 910.5,500,8,5,+5 -fleet send 3 all set tempradioat 910.5,500,8,5,+5,+15 -fleet send 3 all set radioat2 910.5,500,8,5,rxtx,+5 -fleet send 3 all set tempradioat2 910.5,500,8,5,rxtx,+5,+15 +fleet send 3 clock +fleet send 3 clock sync +fleet send 3 set radioat 910.5,500,8,5,+5 +fleet send 3 set tempradioat 910.5,500,8,5,+5,+15 +fleet send 3 set radioat2 910.5,500,8,5,rxtx,+5 +fleet send 3 set tempradioat2 910.5,500,8,5,rxtx,+5,+15 fleet send 3 1257aee5 get tempradioat2 1 fleet send 3 1257aee5 del tempradioat2 all ``` diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 0feb5fd5a..cd2e070a6 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 [all|key[,key...]] "); return true; } text += 4; @@ -9741,18 +9741,21 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, return true; } while (*text == ' ' || *text == '\t') ++text; - const size_t target_length = strcspn(text, " \t"); - 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; + mesh::FleetCommand::Targets targets{}; // No target means the whole fleet. + // Only a complete permitted command can omit its target. An invalid key + // or list must never silently broaden a request to the whole fleet. + if (!mesh::FleetCommand::commandAllowed(text)) { + const size_t target_length = strcspn(text, " \t"); + // 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; + while (*text == ' ' || *text == '\t') ++text; } - text += target_length; - while (*text == ' ' || *text == '\t') ++text; if (!mesh::FleetCommand::commandAllowed(text)) { snprintf(reply, reply_capacity, "Error: command is not permitted for fleet management"); return true; diff --git a/src/helpers/FleetCommand.cpp b/src/helpers/FleetCommand.cpp index 283f3a9e9..6bafe41f4 100644 --- a/src/helpers/FleetCommand.cpp +++ b/src/helpers/FleetCommand.cpp @@ -380,12 +380,8 @@ size_t FleetCommand::encode(const LocalIdentity& publisher, const uint8_t* key, 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); - } + // Broadcast has no target record in FMC2. A single complete key retains + // its original encoding, which is smaller than a one-record FMC2 envelope. 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); diff --git a/src/helpers/FleetCommand.h b/src/helpers/FleetCommand.h index 5aa66c3ce..795e2e3ec 100644 --- a/src/helpers/FleetCommand.h +++ b/src/helpers/FleetCommand.h @@ -57,9 +57,9 @@ 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. + // 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. static size_t encode(const LocalIdentity& publisher, const uint8_t* key, uint32_t sequence, uint32_t expires, const Targets& targets, const char* command, diff --git a/test/test_companion_fleet_control.py b/test/test_companion_fleet_control.py index 633ad7822..d208cb859 100644 --- a/test/test_companion_fleet_control.py +++ b/test/test_companion_fleet_control.py @@ -107,7 +107,8 @@ static void rejected(MyMesh& node, const char* text, uint32_t remote = 0) { char reply[160] = {}; const unsigned before = node.queued; assert(node.handleCommand(text, remote, reply)); - assert(!strncmp(reply, "Error:", 6)); + const char* result = strlen(reply) > 3 && reply[2] == '|' ? reply + 3 : reply; + assert(!strncmp(result, "Error:", 6)); assert(before == node.queued); } @@ -117,6 +118,24 @@ static std::string fullKey(uint8_t value) { return hex; } +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. + assert(node.sent[13] == strlen(command)); + assert(mesh::FleetCommand::MinHeaderSize == 14); + assert(node.sent_length == 14 + strlen(command) + mesh::FleetCommand::SignatureSize); + assert(!memcmp(node.sent + 14, command, strlen(command))); + for (uint8_t value : {0x42, 0x77}) { + uint8_t recipient[32]; memset(recipient, value, 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.broadcast && !strcmp(decoded.command, command)); + assert(decoded.sequence == node.clock.now); + assert(decoded.expires == node.clock.now + mesh::FleetCommand::MaxLifetime); + } +} + int main(int argc, char** argv) { assert(argc == 2); const std::string scenario = argv[1]; @@ -135,7 +154,102 @@ 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. + compactBroadcast(node, "set radio2 off"); + } else if (scenario == "implicit_broadcast") { + for (const char* command : {"set radio2 off", "get radio2.status", "del flood.filter all", + "clock", "clock sync", "time 1800000000", + "set tempradioat2 910.5,500,5,5,rxtx,+1,+2,auto"}) { + const std::string implicit = std::string("fleet send 1 ") + command; + assert(node.handleCommand(implicit.c_str(), 0, reply)); + assert(!strncmp(reply, "OK - fleet command queued", 25)); + assert(node.sent_policy == 2 && node.sent_type == mesh::FleetCommand::DataType); + compactBroadcast(node, command); + ++node.clock.now; + const std::string explicit_all = std::string("fleet send 1 all ") + command; + assert(node.handleCommand(explicit_all.c_str(), 0, reply)); + compactBroadcast(node, command); + ++node.clock.now; + } + assert(node.queued == 14); + } else if (scenario == "implicit_local_prefix_tabs") { + rejected(node, "fleet send 1 set radio2 off", 1); + rejected(node, "AB|fleet send 1 set radio2 off", 100); + rejected(node, "AB|fleet\tsend\t1\tset radio2 off", 1); + assert(node.handleCommand(" \tAB| fleet\tsend\t1\tset radio2 off", 0, reply)); + assert(!strncmp(reply, "AB|OK - fleet command queued; seq=", 32)); + compactBroadcast(node, "set radio2 off"); + assert(node.queued == 1 && node.sent_policy == 2); + ++node.clock.now; + assert(node.handleCommand("CD|fleet\tsend\t1\tall\tclock sync", 0, reply)); + assert(!strncmp(reply, "CD|OK - fleet command queued; seq=", 32)); + compactBroadcast(node, "clock sync"); + assert(node.queued == 2); + } else if (scenario == "implicit_retry_sequence") { + node.queue_ok = false; + rejected(node, "fleet send 1 set radio2 off"); + node.queue_ok = true; + assert(node.handleCommand("fleet send 1 all set radio2 off", 0, reply)); + compactBroadcast(node, "set radio2 off"); + rejected(node, "fleet send 1 set radio2 off"); + ++node.clock.now; + assert(node.handleCommand("EF|fleet send 1 set radio2 off", 0, reply)); + assert(!strncmp(reply, "EF|OK - fleet command queued; seq=", 32)); + compactBroadcast(node, "set radio2 off"); + rejected(node, "fleet send 1 all set radio2 off"); + --node.clock.now; + rejected(node, "fleet send 1 clock sync"); + node.clock.now += 2; + rejected(node, "fleet send 1 nonsense set radio2 off"); + assert(node.handleCommand("fleet send 1 clock sync", 0, reply)); + compactBroadcast(node, "clock sync"); + assert(node.queued == 3); + } else if (scenario == "implicit_target_ambiguity") { + for (const char* targets : {"nonsense", "All", "ALL", "all,42424242", "42424242,all", + "allall", "4242424", "424242424", "4242424242424", "GG424242", + "42424242,,51515151", "42424242,", ",42424242", "set", "get", "clock"}) { + rejected(node, (std::string("fleet send 1 ") + targets + " set radio2 off").c_str()); + } + rejected(node, ("fleet send 1 " + fullKey(0) + " set radio2 off").c_str()); + rejected(node, ("fleet send 1 " + std::string(65, '4') + " set radio2 off").c_str()); + assert(node.handleCommand("fleet send 1 get radio2", 0, reply)); + compactBroadcast(node, "get radio2"); + assert(node.queued == 1); // No typo became a broadcast or consumed a sequence. + } else if (scenario == "implicit_command_controls") { + for (const char* command : {"reboot", "get prv.key", "get password", "get clock", + "set radio2 of", "clock sync extra", "set radio2 off\nreboot", "clock\r", + "set\tradio2 off", "set radio2 off", "clock sync ", "clock\x7f", + "set radio2 off;reboot", "set radio2 off|reboot", "set radio2 off&reboot", + "set flood.filter `reboot`", "set flood.filter \\reboot"}) { + rejected(node, (std::string("fleet send 1 ") + command).c_str()); + rejected(node, (std::string("fleet send 1 all ") + command).c_str()); + } + 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())); + 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. + } 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)}) { + assert(node.handleCommand(("fleet send 1 " + targets + " set radio2 off").c_str(), 0, reply)); + 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, "set radio2 off")); + // Prefixes and lists spread replies like broadcasts because they may + // select several receivers, but a nonmatching node still cannot act. + assert(decoded.broadcast == (targets.size() != 64)); + 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)); + assert(node.sent_policy == 2); + ++node.clock.now; + } + assert(node.queued == 4); } 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", @@ -420,6 +534,24 @@ class CompanionFleetControlTests(unittest.TestCase): def test_local_client_boundary_and_channel_tx_route(self): self.scenario("local_boundary") + def test_omitted_target_and_explicit_all_emit_compact_signed_broadcasts(self): + self.scenario("implicit_broadcast") + + def test_implicit_broadcast_requires_local_client_and_preserves_prefix_and_tabs(self): + self.scenario("implicit_local_prefix_tabs") + + def test_implicit_and_explicit_all_share_queue_retry_and_same_second_replay_guard(self): + self.scenario("implicit_retry_sequence") + + 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): + self.scenario("implicit_command_controls") + + def test_explicit_8_12_64_hex_and_list_targets_remain_restricted(self): + self.scenario("explicit_target_forms") + def test_invalid_syntax_targets_and_forbidden_commands(self): self.scenario("syntax") diff --git a/test/test_fleet_channel_runtime.py b/test/test_fleet_channel_runtime.py index df606ba0a..b1ce58dea 100644 --- a/test/test_fleet_channel_runtime.py +++ b/test/test_fleet_channel_runtime.py @@ -565,6 +565,29 @@ int main(int argc,char** argv){ assert(plaintext[2]0); + assert(!memcmp(plaintext+3,"FMC2",4)&&plaintext[3+12]==0); + assert(plaintext[2]==FleetCommand::MinHeaderSize+strlen("get radio2")+FleetCommand::SignatureSize); + assert(plaintext[2]0); @@ -744,6 +767,9 @@ class SHA256 : public SHA256Base {{ public: def test_new_short_broadcast_envelope_dispatches_and_preserves_replay_protection(self): self.scenario("fmc2_short_all") + def test_actual_zero_target_sender_reaches_receiver_and_keeps_ack_and_reboot_replay_safety(self): + self.scenario("fmc2_encoded_all") + def test_new_public_key_prefix_envelope_dispatches_and_staggers_possible_multiple_acks(self): self.scenario("fmc2_short_prefix") diff --git a/test/test_fleet_command/test_fleet_command.cpp b/test/test_fleet_command/test_fleet_command.cpp index c97847725..2d993ca1b 100644 --- a/test/test_fleet_command/test_fleet_command.cpp +++ b/test/test_fleet_command/test_fleet_command.cpp @@ -457,10 +457,59 @@ TEST_F(FleetCommandTest, TargetRecordAndPayloadBudgetsAreSeparateAndBounded) { EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets)); } -TEST_F(FleetCommandTest, AllAndSingleCompleteKeyKeepLegacyFmc1Encoding) { +TEST_F(FleetCommandTest, ZeroTargetsUsesCompactBroadcastWhileLegacyAllStillDecodes) { Fleet::Targets targets{}; + const auto implicit = encodeTargets(targets); ASSERT_TRUE(Fleet::parseTargets("all", 3, targets)); - EXPECT_EQ(encode(), encodeTargets(targets)); + const auto explicit_all = encodeTargets(targets); + ASSERT_EQ(implicit, explicit_all); + ASSERT_EQ(Fleet::MinHeaderSize + strlen("get radio2") + Fleet::SignatureSize, + implicit.size()); + EXPECT_EQ(15U, encode().size() - implicit.size()); + EXPECT_EQ(std::string("FMC2"), std::string(implicit.begin(), implicit.begin() + 4)); + EXPECT_EQ(0, implicit[12]); + ASSERT_TRUE(decode(implicit)); + EXPECT_TRUE(decoded.broadcast); + EXPECT_STREQ("get radio2", decoded.command); + std::array unrelated; + unrelated.fill(0xAA); + ASSERT_TRUE(Fleet::decode(publisher, ChannelKey, implicit.data(), implicit.size(), + Now, unrelated.data(), decoded)); + EXPECT_TRUE(decoded.broadcast); + ASSERT_TRUE(decode(encode())); + EXPECT_TRUE(decoded.broadcast); +} + +TEST_F(FleetCommandTest, CompactBroadcastCapacityAndCommandLimitRemainBounded) { + Fleet::Targets targets{}; + 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()); + EXPECT_TRUE(decode(bytes)); + EXPECT_TRUE(decoded.broadcast); + 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_TRUE(encodeTargets(targets, oversized.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)); + } + auto trailing = bytes; + trailing.push_back(0); + EXPECT_FALSE(decode(trailing)); + g_mock_ed25519_verify_result = false; + EXPECT_FALSE(decode(bytes)); + EXPECT_FALSE(decoded.broadcast); +} + +TEST_F(FleetCommandTest, SingleCompleteKeyKeepsLegacyFmc1Encoding) { + Fleet::Targets targets{}; char full[PUB_KEY_SIZE * 2 + 1]; mesh::Utils::toHex(full, publisher.pub_key, PUB_KEY_SIZE); uint8_t hash[Fleet::TargetSize]; diff --git a/test/test_fleet_command_crypto.py b/test/test_fleet_command_crypto.py index 2e0ba6a09..a24f2575e 100644 --- a/test/test_fleet_command_crypto.py +++ b/test/test_fleet_command_crypto.py @@ -367,13 +367,67 @@ class FleetCommandCryptoTests(unittest.TestCase): self.assertEqual(self.decode(envelope(channel=channel), channel=channel), "reject") self.assertEqual(self.run_tool("encode", channel.hex(), "all", NOW, NOW + 120, "get radio2"), "reject") - def test_new_sender_preserves_fmc1_for_all_and_single_complete_key(self): - for token, target in [("all", b"\0" * 16), (PUBLIC.hex(), hashlib.sha256(PUBLIC).digest()[:16])]: - with self.subTest(token=token): - actual = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2") - self.assertEqual(actual, envelope(target=target).hex()) - expected_broadcast = "1" if token == "all" else "0" - self.assertTrue(self.decode(bytes.fromhex(actual), metadata=True).endswith("\n" + expected_broadcast)) + def test_compact_broadcast_is_signed_without_target_bytes_and_legacy_all_still_decodes(self): + expected = envelope2() + actual = self.run_tool("encode2", CHANNEL.hex(), "all", NOW, NOW + 120, "get radio2") + self.assertEqual(actual, expected.hex()) + self.assertEqual(expected[:4], b"FMC2") + self.assertEqual(expected[12], 0) + self.assertEqual(len(expected), 14 + 10 + 64) + self.assertEqual(len(envelope()) - len(expected), 15) + PUBLISHER.public_key().verify(expected[-64:], DOMAIN + CHANNEL + expected[:-64]) + for node in [PUBLIC, OTHER_PUBLIC, hashlib.sha256(b"unrelated broadcast receiver").digest()]: + with self.subTest(node=node.hex()): + self.assertEqual(self.decode(expected, self_key=node, metadata=True), + f"{NOW}\n{NOW + 120}\nget radio2\n1") + self.assertEqual(self.decode(envelope(), self_key=node, metadata=True), + f"{NOW}\n{NOW + 120}\nget radio2\n1") + + def test_new_sender_preserves_fmc1_for_single_complete_key(self): + expected = envelope(target=hashlib.sha256(PUBLIC).digest()[:16]) + actual = self.run_tool("encode2", CHANNEL.hex(), PUBLIC.hex(), NOW, NOW + 120, "get radio2") + self.assertEqual(actual, expected.hex()) + self.assertTrue(self.decode(bytes.fromhex(actual), metadata=True).endswith("\n0")) + + def test_compact_broadcast_signature_binds_no_target_count_command_and_times(self): + original = envelope2() + # All of these are valid, matching requests on their own. Attaching + # the old zero-target signature must not authorize the changed data. + for changed in [ + envelope2([(4, PUBLIC[:4])]), + envelope2([(16, hashlib.sha256(PUBLIC).digest()[:16])]), + envelope2(command="get tempradio2"), + envelope2(sequence=NOW + 1), envelope2(expires=NOW + 121)]: + with self.subTest(changed=changed.hex()): + self.assertNotEqual(self.decode(changed), "reject") + self.assertEqual(self.decode(changed[:-64] + original[-64:]), "reject") + for offset in range(len(original) - 64, len(original)): + with self.subTest(signature_byte=offset): + changed = bytearray(original) + changed[offset] ^= 1 + self.assertEqual(self.decode(changed), "reject") + changed = bytearray(original) + changed[12] = 1 + self.assertEqual(self.decode(changed), "reject") + self.assertEqual(self.decode(original, publisher=OTHER_PUBLIC), "reject") + self.assertEqual(self.decode(original, channel=bytes(range(17, 33))), "reject") + + def test_compact_broadcast_keeps_72_byte_command_and_packet_framing_limits(self): + prefix = "set flood.rule.1 " + maximum = prefix + "a" * (72 - len(prefix)) + expected = envelope2(command=maximum) + actual = self.run_tool("encode2", CHANNEL.hex(), "all", NOW, NOW + 120, maximum) + self.assertEqual(actual, expected.hex()) + self.assertEqual(len(expected), 150) + self.assertEqual(len(envelope(maximum)) - len(expected), 15) + self.assertNotEqual(self.decode(expected), "reject") + oversized = maximum + "a" + self.assertEqual(self.decode(envelope2(command=oversized)), "reject") + self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), "all", NOW, NOW + 120, oversized), "reject") + for length in [0, 12, 13, 14, len(expected) - 64, len(expected) - 1]: + with self.subTest(length=length): + self.assertEqual(self.decode(expected[:length]), "reject") + self.assertEqual(self.decode(expected + b"\0"), "reject") def test_real_signature_round_trip_for_mixed_prefixes_and_full_keys(self): entries = [(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6]), (16, hashlib.sha256(PUBLIC).digest()[:16])]