Default fleet commands to compact whole-fleet targets

This commit is contained in:
mikecarper committed 2026-10-08 23:57:50 -07:00
1 parent 89f26fe1c9
commit d74a0f8b44
8 files changed
+322 -53

No files matched your search

+30 -21
View File
@@ -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
```
+15 -12
View File
@@ -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;
+2 -6
View File
@@ -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);
+3 -3
View File
@@ -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,
+134 -2
View File
@@ -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")
+26
View File
@@ -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")
+51 -2
View File
@@ -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];
+61 -7
View File
@@ -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])]