mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-22 17:41:19 +00:00
Support public-key prefixes and lists in signed fleet commands
This commit is contained in:
1 parent
d83fa7cf8e
commit
b0c02b1e47
12 files changed
+756
-52
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,®ion,&packet,captured));
|
||||
|
||||
}else if(scenario=="fmc2_short_all"){
|
||||
enroll(fleet,publisher);Packet packet=shortNewAll(publisher,mesh.clock.now);
|
||||
uint8_t plaintext[184]={};assert(Utils::MACThenDecrypt(channel_key,plaintext,packet.payload+1,packet.payload_len-1)>0);
|
||||
assert(plaintext[2]<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) {
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user