Support public-key prefixes and lists in signed fleet commands

This commit is contained in:
mikecarper committed 2026-10-08 17:32:05 -07:00
1 parent d83fa7cf8e
commit b0c02b1e47
12 files changed
+756 -52

No files matched your search

+1 -1
View File
@@ -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.
+38 -9
View File
@@ -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.
+12 -16
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|64-hex-key> <command>");
"Error: use fleet send <channel-index> <all|key[,key...]> <command>");
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");
+2 -2
View File
@@ -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;
}
+140 -14
View File
@@ -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;
}
+23
View File
@@ -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.
+3 -2
View File
@@ -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<class Signer>
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;
}
+88 -1
View File
@@ -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 <cassert>
+76 -1
View File
@@ -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<Region*>(nullptr),&packet,captured));
region.wildcard=false;packet.transport_codes[0]^=1;
assert(!captureFleetReplyScope(regions,&region,&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]<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_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);
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")
@@ -47,6 +47,15 @@ protected:
return Fleet::decode(publisher, ChannelKey, bytes.data(), bytes.size(),
now, publisher.pub_key, decoded);
}
std::vector<uint8_t> encodeTargets(const Fleet::Targets& targets,
const char* command = "get radio2") {
std::vector<uint8_t> 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<uint8_t> 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<uint8_t, Fleet::MaxPayloadLength> 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<uint8_t, Fleet::MaxPayloadLength> 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) {
+151 -5
View File
@@ -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<uint8_t, Fleet::MaxPayloadLength> 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("<II", sequence, expires) + bytes([count])
+ records + bytes([len(command)]) + command)
return unsigned + publisher.sign(DOMAIN + channel + unsigned)
class FleetCommandCryptoTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
@@ -145,8 +169,9 @@ class FleetCommandCryptoTests(unittest.TestCase):
self.assertNotIn("AddressSanitizer", result.stderr)
return result.stdout.strip()
def decode(self, payload, channel=CHANNEL, publisher=PUBLIC, self_key=PUBLIC, now=NOW):
return self.run_tool("decode", publisher.hex(), channel.hex(), self_key.hex(), now, payload.hex())
def decode(self, payload, channel=CHANNEL, publisher=PUBLIC, self_key=PUBLIC, now=NOW, metadata=False):
return self.run_tool("decode2" if metadata else "decode",
publisher.hex(), channel.hex(), self_key.hex(), now, payload.hex())
def test_cpp_and_python_signatures_match_exactly(self):
expected = envelope()
@@ -244,6 +269,127 @@ 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_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])]
token = f"{PUBLIC[:4].hex()},{OTHER_PUBLIC[:6].hex()},{PUBLIC.hex()}"
expected = envelope2(entries)
actual = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2")
self.assertEqual(actual, expected.hex())
self.assertTrue(self.decode(expected, metadata=True).endswith("\n1"))
self.assertTrue(self.decode(expected, self_key=OTHER_PUBLIC, metadata=True).endswith("\n1"))
unrelated = hashlib.sha256(b"unrelated fleet node").digest()
self.assertEqual(self.decode(expected, self_key=unrelated), "reject")
def test_single_raw_prefix_is_a_broadcast_capability_for_matching_collisions(self):
first = PUBLIC
second = PUBLIC[:4] + bytes([PUBLIC[4] ^ 1]) + PUBLIC[5:]
broad = envelope2([(4, PUBLIC[:4])])
self.assertTrue(self.decode(broad, self_key=first, metadata=True).endswith("\n1"))
self.assertTrue(self.decode(broad, self_key=second, metadata=True).endswith("\n1"))
narrow = envelope2([(6, PUBLIC[:6])])
self.assertNotEqual(self.decode(narrow, self_key=first), "reject")
self.assertEqual(self.decode(narrow, self_key=second), "reject")
full = envelope2([(16, hashlib.sha256(first).digest()[:16])])
self.assertTrue(self.decode(full, self_key=first, metadata=True).endswith("\n0"))
self.assertEqual(self.decode(full, self_key=second), "reject")
def test_fmc2_all_is_accepted_and_duplicate_matches_decode_once(self):
self.assertTrue(self.decode(envelope2(), metadata=True).endswith("\n1"))
duplicates = envelope2([(16, hashlib.sha256(PUBLIC).digest()[:16])] * 2)
result = self.decode(duplicates, metadata=True)
self.assertEqual(result, f"{NOW}\n{NOW + 120}\nget radio2\n1")
def test_legitimate_zero_prefixes_are_accepted_but_zero_complete_identity_is_not(self):
node = b"\0" * 6 + b"\1" * 26
for width in [4, 6]:
with self.subTest(width=width):
self.assertNotEqual(self.decode(envelope2([(width, b"\0" * width)]), self_key=node), "reject")
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), "0" * 64, NOW, NOW + 120, "get radio2"), "reject")
def test_multitarget_signature_binds_every_target_entry_and_count(self):
original = envelope2([(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6]),
(16, hashlib.sha256(PUBLIC).digest()[:16])])
# The first prefix still matches. Every later record byte nevertheless
# remains signed, so modifying a nonmatching or duplicate target fails.
for offset in range(18, 13 + 5 + 7 + 17):
with self.subTest(target_byte=offset):
changed = bytearray(original)
changed[offset] ^= 1
self.assertEqual(self.decode(changed), "reject")
changed = bytearray(original)
changed[12] = 2
self.assertEqual(self.decode(changed), "reject")
def test_malformed_target_after_matching_record_is_rejected_even_when_signed(self):
matching = (4, PUBLIC[:4])
malformed = [
[(3, b"abc")], [(0, b"")], [(5, b"abcde")], [(32, PUBLIC)],
[(16, b"x" * 15)], [(6, b"x" * 5)], [(255, b"")]]
for suffix in malformed:
with self.subTest(suffix=suffix):
self.assertEqual(self.decode(envelope2([matching] + suffix)), "reject")
self.assertEqual(self.decode(envelope2([matching], count=18)), "reject")
self.assertEqual(self.decode(envelope2([matching], count=0)), "reject")
self.assertEqual(self.decode(envelope2([matching], count=2)), "reject")
def test_new_target_parser_refuses_partial_or_mixed_all_lists(self):
for token in ["", "all," + PUBLIC[:4].hex(), PUBLIC[:4].hex() + ",all",
PUBLIC[:4].hex() + ",", "," + PUBLIC[:4].hex(),
PUBLIC[:4].hex() + ",," + OTHER_PUBLIC[:4].hex(),
PUBLIC[:4].hex() + ",zzzzzzzz", "1" * 10, "1" * 16,
",".join([PUBLIC.hex()] * 6), ",".join([PUBLIC[:4].hex()] * 18)]:
with self.subTest(token=token):
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2"), "reject")
def test_fmc2_packet_capacity_is_checked_before_signing_or_sending(self):
maximum = "set flood.rule.1 " + "a" * (72 - len("set flood.rule.1 "))
entries = [(4, PUBLIC[:4])] * 3
expected = envelope2(entries, maximum)
self.assertEqual(len(expected), 165)
token = ",".join([PUBLIC[:4].hex()] * 3)
actual = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, maximum)
self.assertEqual(actual, expected.hex())
self.assertNotEqual(self.decode(expected), "reject")
token += "," + PUBLIC[:4].hex()
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, maximum), "reject")
self.assertEqual(self.decode(envelope2(entries * 2, maximum)), "reject")
for count in [15, 16, 17]:
token = ",".join([PUBLIC[:4].hex()] * count)
result = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2")
self.assertEqual(result == "reject", count > 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("<I", original[4:8])[0], NOW)
forged = envelope2(entries, sequence=NOW + 1)[:-64] + original[-64:]
self.assertEqual(self.decode(forged), "reject")
for sequence, expires, now in [(NOW + 61, NOW + 120, NOW),
(NOW, NOW + 601, NOW), (NOW, NOW + 120, NOW + 121)]:
self.assertEqual(self.decode(envelope2(entries, sequence=sequence, expires=expires), now=now), "reject")
def test_fmc2_signature_cannot_move_between_channels_or_publishers(self):
original = envelope2([(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6])])
self.assertEqual(self.decode(original, publisher=OTHER_PUBLIC), "reject")
self.assertEqual(self.decode(original, channel=bytes(range(17, 33))), "reject")
self.assertEqual(self.decode(envelope2([(4, PUBLIC[:4])], publisher=OTHER)), "reject")
if __name__ == "__main__":
unittest.main()
+2 -1
View File
@@ -251,7 +251,8 @@ public:
checked([compiler, "-std=c++17", "-Wall", "-Wextra", "-Wno-unused-function",
"-Wno-unused-parameter", "-Wno-sign-compare", "-Wno-reorder",
"-DESP32_PLATFORM=1", *SANITIZERS,
f"-I{work}", f"-I{ROOT / 'test/fixtures/radio_profiles/mocks'}",
f"-I{work}", f"-I{ROOT / 'test/fixtures'}",
f"-I{ROOT / 'test/fixtures/radio_profiles/mocks'}",
f"-I{ROOT / 'test/mocks'}", f"-I{ROOT / 'src'}", f"-I{ROOT / 'src/helpers'}", f"-I{ROOT}",
str(work / "radio.cpp"), *[str(ROOT / "src/helpers" / name) for name in (
"ConfigSerializer.cpp", "DynamicConfigSerializer.cpp", "CommonRadioPrefs.cpp",