mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-23 01:51:23 +00:00
Default fleet commands to compact whole-fleet targets
This commit is contained in:
1 parent
89f26fe1c9
commit
d74a0f8b44
8 files changed
+322
-53
No files matched your search
+30
-21
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 <channel-index> <all|key[,key...]> <command>");
|
||||
"Error: use fleet send <channel-index> [all|key[,key...]] <command>");
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -565,6 +565,29 @@ int main(int argc,char** argv){
|
||||
assert(plaintext[2]<FleetCommand::HeaderSize+FleetCommand::SignatureSize);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==1);assert(last_jitter_max==60500);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
}else if(scenario=="fmc2_encoded_all"){
|
||||
enroll(fleet,publisher);Packet packet=targetsCommand(publisher,mesh.clock.now,"all");
|
||||
uint8_t plaintext[184]={};
|
||||
assert(Utils::MACThenDecrypt(channel_key,plaintext,packet.payload+1,packet.payload_len-1)>0);
|
||||
assert(!memcmp(plaintext+3,"FMC2",4)&&plaintext[3+12]==0);
|
||||
assert(plaintext[2]==FleetCommand::MinHeaderSize+strlen("get radio2")+FleetCommand::SignatureSize);
|
||||
assert(plaintext[2]<FleetCommand::HeaderSize+FleetCommand::SignatureSize);
|
||||
Packet legacy=command(publisher,mesh.clock.now,"get radio2");
|
||||
assert(packet.payload_len+16==legacy.payload_len);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==1&&mesh.queued==1&&last_jitter_max==60500);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
mesh.releasePacket(mesh.manager.removeOutboundByIdx(0));
|
||||
++mesh.clock.now;fake_ms+=1000;mesh.fanout=true;
|
||||
Packet mutation=targetsCommand(publisher,mesh.clock.now,"all","set radio2 off");
|
||||
assert(apply(fleet,fs,mesh,profiles,mutation,true)==1);
|
||||
assert(fleet.waiting()&&profiles.mutation&&last_jitter_max==15500);
|
||||
fleet.complete(mesh.manager.removeOutboundByIdx(0),profiles);
|
||||
assert(fleet.waiting()&&profiles.mutation&&profiles.commits==0);
|
||||
fleet.complete(mesh.manager.removeOutboundByIdx(0),profiles);
|
||||
assert(!fleet.waiting()&&!profiles.mutation&&profiles.commits==1);
|
||||
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_short_prefix"){
|
||||
enroll(fleet,publisher);Packet packet=targetsCommand(publisher,mesh.clock.now,publicHex(mesh.self_id).substr(0,8));
|
||||
uint8_t plaintext[184]={};assert(Utils::MACThenDecrypt(channel_key,plaintext,packet.payload+1,packet.payload_len-1)>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")
|
||||
|
||||
|
||||
@@ -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<uint8_t, PUB_KEY_SIZE> 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<uint8_t, Fleet::MaxPayloadLength> 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<uint8_t> 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];
|
||||
|
||||
@@ -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])]
|
||||
|
||||
Reference in new issue
Block a user