Support atomic two-packet signed fleet commands

Automatically split longer signed envelopes into two bounded group-data packets, reserve both before queueing, and roll back exact packet copies and retries on admission failure. Collect fragments in either order with a fixed deadline and verify the complete command before durable replay reservation and dispatch.

Raise whole-fleet protocol capacity to 230 bytes, preserve target-specific budgets and radio acknowledgement barriers, and reject rule-token and USB rescue-line truncation. Document existing app input limits and add real-signature, sanitizer, queue failure, timeout, replay, and compact-board regressions.
This commit is contained in:
mikecarper
2026-10-09 00:46:50 -07:00
parent d74a0f8b44
commit fdad79eaf7
18 changed files with 1649 additions and 143 deletions
+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, secondary-radio changes, radio schedules, and clock updates 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.
* **[Private fleet management](https://mikecarper.github.io/MeshCore/fleet_management/).** Deploy filtering, secondary-radio changes, radio schedules, and clock updates to a whole fleet, one node, or a comma-separated list of public-key targets. The publisher signs each complete command once, splitting longer requests across two packets; 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.
+53 -17
View File
@@ -61,6 +61,8 @@ last accepted sequence. It does not reveal the channel key. `set fleet.channel
off` disables reception; `set fleet.controller off` revokes the publisher. Key
changes use the existing individually authenticated admin path, never fleet
commands.
`get fleet.stats` includes `parts`, the number of fragments currently buffered
for an incomplete two-packet command.
Nodes need a clock set to current UTC. Commands expire after ten minutes and may
be at most one minute ahead of a receiver's clock. A bad clock fails closed;
@@ -107,10 +109,9 @@ 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. Whole-fleet commands use the compact FMC2 format with no target
records, whether the CLI target is omitted or explicitly `all`. This reduces the
header from 29 to 14 bytes. Whole-fleet, multiple-target, and prefix commands
require receivers that support FMC2. A single complete key keeps the legacy FMC1
format, and updated receivers also accept legacy whole-fleet packets. Separate
fleets use separate channel keys; named subgroups are not part of this first version.
header from 29 to 14 bytes. A single complete key uses the smaller fixed-target
FMC1 format. 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.
@@ -124,18 +125,53 @@ sequence; the operation should be chosen to be idempotent.
## Supported controls
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. Omitting targets or using
`all` saves 15 header bytes; the command limit remains 72 bytes. A single complete
key uses the legacy 29-byte header and also allows a full 72-byte command.
The local app/console's command-length limit also applies. Oversized or invalid
lists are rejected as a whole, without sending or truncating any targets.
The short legacy USB rescue console cannot fit a full-key command; use the app
or normal terminal for longer lists.
The Companion automatically uses **one or two LoRa packets**, depending on the
signed command's size. Whole-fleet commands fit **87 ASCII bytes** in one packet
or **230 bytes** across two. Targets consume some of that allowance:
| Targets | One packet: command bytes | Two packets: command bytes |
| --- | ---: | ---: |
| Omitted or `all` | 87 | 230 |
| One 8-character public-key prefix | 82 | 225 |
| One 12-character public-key prefix | 80 | 223 |
| One complete 64-character public key | 72 | 215 |
| Two 8-character public-key prefixes | 77 | 220 |
| Two complete public keys | 53 | 196 |
Each prefix in a list uses 5 or 7 bytes; each complete key in a list uses 17.
A single complete key uses the smaller fixed-target format with a 29-byte header,
so its limits come from packet space. Lists are capped at 17 entries. Requests
exceeding their target-specific budget are rejected before signing or sending,
with no partial target list.
The existing app/console input limit also applies. The app's normal 176-byte
command frame fits **161 command bytes** after `fleet send 3 `; a correlated
request uses three more bytes, and explicit targets reduce the space further.
The shorter USB rescue console has an 80-byte input buffer. The 230-byte limit
is the fleet protocol's capacity, rather than a promise that every console can
enter that much text. Oversized or invalid lists are rejected as a whole,
without sending or truncating any targets.
### Two-packet delivery
Longer commands keep **one 64-byte signature over the entire command**, including
its channel, targets, sequence and expiry. The signed envelope is split into two
`FMP1` fragments. Each fragment has an 11-byte collection header and up to 154
envelope bytes, within the existing 165-byte group-data limit. Together they can
carry a 308-byte signed envelope. No third packet is used.
The Companion reserves both packets before sending. If either cannot be queued,
it cancels the queued part and any radio copy, reports an error, and leaves the
sequence available for a retry. Successful queueing still does not guarantee
delivery over LoRa.
A receiver accepts either arrival order and identical duplicates. It executes
and acknowledges **only after both parts arrive and the complete signature,
target, clock and replay checks pass**. Missing or altered parts cannot execute
a partial command. One bounded assembly is kept for up to five minutes from the
first part; duplicates do not extend that deadline. A fresh command with a newer
eligible sequence replaces an incomplete older transfer, so a lost part does
not block retries. Reboot or changing enrollment discards the partial transfer.
| Control | Fleet commands |
| --- | --- |
@@ -212,7 +248,7 @@ move nodes off the normal network; use a temporary window or a planned recovery
path for experiments. A forwarding policy can also partition the fleet.
The receiver is allocated lazily when fleet settings are present or queried, with
one bounded mailbox. Cryptographic work and storage writes occur after the receive
one bounded mailbox and one 308-byte fragment assembly. Cryptographic work and storage writes occur after the receive
stack unwinds. Signature-validation
attempts are bounded to protect the radio loop from matching-hash junk. A corrupt
or unreadable fleet store denies commands rather than discarding its replay state.
+94 -21
View File
@@ -1691,6 +1691,64 @@ bool MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk
}
}
#if MESH_ENABLE_FLEET_CONTROL
void MyMesh::onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original, uint8_t priority) {
mesh::Mesh::onRadioProfileCopyQueued(packet, original, priority);
if (original && original == _fleet_admission_original) _fleet_admission_copy = packet;
}
bool MyMesh::sendFleetCommandData(mesh::GroupChannel& channel, const uint8_t* envelope, size_t length) {
if (!envelope || !length || length > mesh::FleetCommand::MaxEnvelopeLength) return false;
if (length <= mesh::FleetCommand::MaxPayloadLength) {
return sendGroupData(channel, nullptr, OUT_PATH_UNKNOWN,
mesh::FleetCommand::DataType, envelope, (int)length);
}
mesh::Packet* reserved[2] = {};
uint8_t group[3 + mesh::FleetCommand::MaxPayloadLength];
group[0] = (uint8_t)mesh::FleetCommand::DataType;
group[1] = (uint8_t)(mesh::FleetCommand::DataType >> 8);
// Reserve both encrypted packets before handing either to the dispatcher.
for (uint8_t part = 0; part < 2; ++part) {
const size_t bytes = mesh::FleetCommand::fragment(envelope, length, part,
group + 3, sizeof(group) - 3);
if (bytes) {
group[2] = (uint8_t)bytes;
reserved[part] = createGroupDatagram(PAYLOAD_TYPE_GRP_DATA, channel, group, 3 + bytes);
}
if (!reserved[part]) {
if (reserved[0]) releasePacket(reserved[0]);
memset(group, 0, sizeof(group));
return false;
}
}
memset(group, 0, sizeof(group));
_fleet_admission_original = reserved[0];
_fleet_admission_copy = nullptr;
const bool first_queued = sendFloodScoped(channel, reserved[0]);
mesh::Packet* first_copy = _fleet_admission_copy;
_fleet_admission_original = nullptr;
_fleet_admission_copy = nullptr;
if (!first_queued) {
// sendFloodScoped/sendPacket owns and releases the failed first packet.
releasePacket(reserved[1]);
return false;
}
if (sendFloodScoped(channel, reserved[1])) return true;
// A failed second send has already been released. No dispatcher loop runs
// inside admission, so the first packet and its optional copy are still
// queued and can be retired by exact pointer without touching other work.
for (int i = _mgr->getOutboundTotal() - 1; i >= 0; --i) {
const mesh::Packet* queued = _mgr->getOutboundByIdx(i);
if (queued != reserved[0] && queued != first_copy) continue;
mesh::Packet* cancelled = _mgr->removeOutboundByIdx(i);
cancelOutboundRadioRetry(cancelled);
onSendFail(cancelled); // Clear its pending flood retry metadata too.
releasePacket(cancelled);
}
return false;
}
#endif
void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
const char *text) {
#if MESH_ENABLE_ONE_KEY_DM
@@ -5264,7 +5322,10 @@ void MyMesh::resetUsbHostSessionInput() {
// CLI rescue deliberately remains active across reconnects, but no bytes
// typed by the old host may complete a command for the new one. This is
// especially important for the rescue shell's erase/rm commands.
if (_cli_rescue) memset(cli_command, 0, sizeof(cli_command));
if (_cli_rescue) {
memset(cli_command, 0, sizeof(cli_command));
_cli_line_overflow = false;
}
}
void MyMesh::handleCmdFrame(size_t len) {
@@ -9520,6 +9581,7 @@ void MyMesh::handleTerminalCommand(char* command) {
void MyMesh::enterCLIRescue() {
_cli_rescue = true;
cli_command[0] = 0;
_cli_line_overflow = false;
mesh::usbTerminalPort().println("========= CLI Rescue =========");
}
@@ -9784,16 +9846,15 @@ 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];
uint8_t payload[mesh::FleetCommand::MaxEnvelopeLength];
const size_t length = mesh::FleetCommand::encode(
self_id, channel.channel.secret, sequence,
sequence + mesh::FleetCommand::MaxLifetime, targets, text,
payload, sizeof(payload));
if (!length) {
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)) {
"Error: fleet targets and command exceed two packets; use fewer targets");
} else if (!sendFleetCommandData(channel.channel, payload, length)) {
snprintf(reply, reply_capacity, "Error: could not queue fleet command");
} else {
last_fleet_sequence = sequence;
@@ -10076,30 +10137,42 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
void MyMesh::checkCLIRescueCmd() {
Stream& output = mesh::usbTerminalPort();
Stream& input = mesh::usbCompanionPort();
int len = strlen(cli_command);
// `cli_command` must stay NUL-terminated within its bounds. If it ever isn't,
// strlen() above can return >= sizeof(cli_command) and the loop below would
// then index past the buffer, so clamp defensively.
if (len >= (int)sizeof(cli_command)) {
size_t len = strnlen(cli_command, sizeof(cli_command));
if (len == sizeof(cli_command)) {
cli_command[0] = 0;
len = 0;
_cli_line_overflow = true;
}
while (input.available() && len < sizeof(cli_command)-1) {
unsigned budget = sizeof(cli_command);
while (budget-- && input.available()) {
char c = input.read();
if (c != '\n') {
cli_command[len++] = c;
cli_command[len] = 0;
}
output.print(c); // echo
}
if (len == sizeof(cli_command)-1) { // buffer full: treat as a completed line
cli_command[sizeof(cli_command)-2] = '\r'; // place end-of-line marker inside the buffer
cli_command[sizeof(cli_command)-1] = 0; // keep the buffer NUL-terminated
if (c == '\r' || c == '\n') {
if (_cli_line_overflow) {
_cli_line_overflow = false;
#if defined(STM32_PLATFORM)
output.println(" Error: unknown command");
#else
output.print("ERR: too long\r\n");
#endif
} else if (len) goto complete_line;
return;
}
if (_cli_line_overflow) continue;
if (len == sizeof(cli_command)-1) {
_cli_line_overflow = true;
cli_command[0] = 0;
len = 0;
continue;
}
cli_command[len++] = c;
cli_command[len] = 0;
}
if (len > 0 && cli_command[len - 1] == '\r') { // received complete line
cli_command[len - 1] = 0; // replace newline with C string null terminator
return;
complete_line:
{
reply_buf[0] = 0;
#if COMPANION_FEATURE_READER
if (mesh::handleReaderCommand(cli_command, output)) {
+10
View File
@@ -355,6 +355,10 @@ protected:
bool sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis);
bool sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override;
bool sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override;
#if MESH_ENABLE_FLEET_CONTROL
bool sendFleetCommandData(mesh::GroupChannel& channel, const uint8_t* envelope, size_t length);
void onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original, uint8_t priority) override;
#endif
void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override;
#if MESH_PACKET_LOGGING && !MESH_PACKET_LOGGING_COMPACT
@@ -466,6 +470,11 @@ public:
bool canRecoverUsbLogging() const;
private:
#if MESH_ENABLE_FLEET_CONTROL
// Exact ownership of the optional radio copy during two-part admission.
mesh::Packet* _fleet_admission_original = nullptr;
mesh::Packet* _fleet_admission_copy = nullptr;
#endif
// Only snapshot the changed value: CompanionNodePrefs owns self-referencing
// runtime adapters and must not be copied as a transaction snapshot.
template <typename T> bool savePreference(T& preference, T value) {
@@ -723,6 +732,7 @@ private:
#endif
bool send_unscoped; // force un-scoped flood (instead of using send_scope)
char cli_command[80];
bool _cli_line_overflow = false;
char reply_buf[166];
uint8_t app_target_ver;
uint8_t *sign_data;
+18 -7
View File
@@ -7956,12 +7956,12 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
}
}
cursor = skipFloodFilterSpaces(cursor);
if (strlen(cursor) >= 192) {
char params[256];
if (strlen(cursor) >= sizeof(params)) {
strcpy(reply, "Err - rule parameters too long");
return;
}
char params[192];
strcpy(params, cursor);
char* tokens[18];
int token_count = 0;
@@ -8244,9 +8244,12 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
}
char rate_text[24];
const bool compact = floodFilterAsciiStartsWith(tokens[i], "q=");
StrHelper::strncpy(rate_text,
tokens[i] + (compact ? 2 : strlen("rate=")),
sizeof(rate_text));
const char* value = tokens[i] + (compact ? 2 : strlen("rate="));
if (strlen(value) >= sizeof(rate_text)) {
strcpy(reply, "Err - rate value too long");
return;
}
StrHelper::strncpy(rate_text, value, sizeof(rate_text));
char* slash = strchr(rate_text, '/');
if ((!compact && slash == NULL)
|| (slash != NULL && !(strcmp(slash, "/min") == 0
@@ -8574,12 +8577,16 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
}
}
cursor = skipFloodFilterSpaces(cursor);
if (strlen(cursor) >= 140) {
#if MESH_ENABLE_FLEET_CONTROL
char params[256];
#else
char params[140];
#endif
if (strlen(cursor) >= sizeof(params)) {
strcpy(reply, "Err - filter parameters too long");
return;
}
char params[140];
strcpy(params, cursor);
char* tokens[7];
int token_count = 0;
@@ -10175,6 +10182,10 @@ void MyMesh::setFloodGroupModeration(const char* args, char* reply) {
return;
}
char rate_text[24];
if (strlen(option + 5) >= sizeof(rate_text)) {
strcpy(reply, "Err - rate value too long");
return;
}
StrHelper::strncpy(rate_text, option + 5, sizeof(rate_text));
char* slash = strchr(rate_text, '/');
if (slash == NULL || !(strcmp(slash, "/min") == 0 || strcmp(slash, "/m") == 0)) {
@@ -1275,12 +1275,12 @@ void FloodRuleEngine::set(const char* args, char* reply,
}
}
cursor = skipSpaces(cursor);
if (strlen(cursor) >= 192) {
char params[256];
if (strlen(cursor) >= sizeof(params)) {
copyString(reply, "Err - rule parameters too long", 160);
return;
}
char params[192];
copyString(params, cursor, sizeof(params));
char* tokens[18];
int token_count = 0;
@@ -1564,9 +1564,12 @@ void FloodRuleEngine::set(const char* args, char* reply,
}
bool compact = asciiStartsWith(tokens[i], "q=");
char rate_text[24];
copyString(rate_text,
tokens[i] + (compact ? 2 : strlen("rate=")),
sizeof(rate_text));
const char* value = tokens[i] + (compact ? 2 : strlen("rate="));
if (strlen(value) >= sizeof(rate_text)) {
copyString(reply, "Err - rate value too long", 160);
return;
}
copyString(rate_text, value, sizeof(rate_text));
char* slash = strchr(rate_text, '/');
if ((!compact && slash == NULL)
|| (slash != NULL && !(strcmp(slash, "/min") == 0
+68 -12
View File
@@ -78,9 +78,10 @@ bool FleetChannel::handleConfig(const char* command, char* reply, size_t capacit
snprintf(reply, capacity, "> %s", key);
} else snprintf(reply, capacity, "> unset");
} else if (!strcmp(command, "get fleet.stats")) {
snprintf(reply, capacity, "> accepted=%lu rejected=%lu busy=%lu pending=%u ack=%u",
snprintf(reply, capacity, "> accepted=%lu rejected=%lu busy=%lu pending=%u ack=%u parts=%u",
(unsigned long)accepted_, (unsigned long)rejected_, (unsigned long)busy_,
unsigned(pending_), unsigned(barrier_.waiting()));
unsigned(pending_), unsigned(barrier_.waiting()),
unsigned(bool(assembly_parts_ & 1)) + unsigned(bool(assembly_parts_ & 2)));
} else if (!strncmp(command, "set fleet.channel ", 18)) {
if (pending_ || barrier_.waiting()) {
snprintf(reply, capacity, "Err - fleet command pending; retry later"); return true;
@@ -96,6 +97,7 @@ bool FleetChannel::handleConfig(const char* command, char* reply, size_t capacit
snprintf(reply, capacity, "Err - fleet settings not saved; store unavailable"); return true;
}
configured_ = enabled;
clearAssembly();
memset(channel_.secret, 0, sizeof(channel_.secret));
memcpy(channel_.secret, key, sizeof(key));
Utils::sha256(channel_.hash, sizeof(channel_.hash), key, sizeof(key));
@@ -115,6 +117,7 @@ bool FleetChannel::handleConfig(const char* command, char* reply, size_t capacit
}
memcpy(controller_.pub_key, key, 32);
controller_set_ = !zero(key, 32);
clearAssembly();
snprintf(reply, capacity, "OK - fleet publisher %s", controller_set_ ? "set" : "off");
} else return false;
return true;
@@ -139,29 +142,82 @@ void FleetChannel::receive(Packet* packet, Mesh& mesh, const TransportKey* scope
(void)mesh;
}
bool FleetChannel::decode(Mesh& mesh, FleetCommand::Decoded& command) {
void FleetChannel::clearAssembly() {
memset(assembly_, 0, sizeof(assembly_));
assembly_sequence_ = assembly_deadline_ = 0;
assembly_length_ = 0;
assembly_parts_ = 0;
}
void FleetChannel::serviceAssemblyDeadline() {
if (assembly_parts_ && int32_t(uint32_t(millis()) - assembly_deadline_) >= 0)
clearAssembly();
}
FleetChannel::DecodeResult FleetChannel::decode(Mesh& mesh, FleetCommand::Decoded& command) {
const uint32_t now_ms = millis();
if (uint32_t(now_ms - verify_window_) >= 1000) {
verify_window_ = now_ms; verify_attempts_ = 0;
}
// A forged matching channel hash must not monopolize the radio loop with
// Ed25519 work. Legitimate publishers can retry with a fresh sequence.
if (verify_attempts_ >= 4) return false;
if (verify_attempts_ >= 4) return DecodeResult::Rejected;
++verify_attempts_;
uint8_t data[MAX_PACKET_PAYLOAD];
const int length = Utils::MACThenDecrypt(channel_.secret, data, incoming_ + 1, incoming_len_ - 1);
if (length < 3 || data[0] != uint8_t(FleetCommand::DataType)
|| data[1] != uint8_t(FleetCommand::DataType >> 8)) return false;
|| data[1] != uint8_t(FleetCommand::DataType >> 8)) return DecodeResult::Rejected;
const size_t payload_length = data[2];
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::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;
if (payload_length > FleetCommand::MaxPayloadLength || unpadded > (size_t)length
|| (unpadded + 15) / 16 * 16 != (size_t)length
|| !zero(data + unpadded, length - unpadded)) return DecodeResult::Rejected;
const uint8_t* envelope = data + 3;
size_t envelope_length = payload_length;
bool assembled = false;
if (payload_length >= 4 && !memcmp(envelope, "FMP1", 4)) {
FleetCommand::Fragment fragment;
if (!FleetCommand::parseFragment(envelope, payload_length, fragment)
|| fragment.sequence <= last_sequence_) return DecodeResult::Rejected;
const uint32_t now = mesh.getRTCClock()->getCurrentTime();
if (fragment.sequence < FleetCommand::MinEpoch
|| (fragment.sequence > now && fragment.sequence - now > FleetCommand::MaxClockLead)
|| (now > fragment.sequence && now - fragment.sequence > FleetCommand::MaxLifetime))
return DecodeResult::Rejected;
// A fresh retry must not wait five minutes for a lost second fragment.
// Older transfers and conflicting fragments cannot disturb newer intent.
if (assembly_parts_ && fragment.sequence > assembly_sequence_) clearAssembly();
if (assembly_parts_ && (assembly_sequence_ != fragment.sequence
|| assembly_length_ != fragment.total_length))
return DecodeResult::Rejected;
if (!assembly_parts_) {
assembly_sequence_ = fragment.sequence;
assembly_length_ = fragment.total_length;
// A duplicate cannot extend this fixed five-minute collection window.
assembly_deadline_ = now_ms + 300000UL;
}
const size_t offset = fragment.index * FleetCommand::FragmentDataLength;
const uint8_t mask = uint8_t(1U << fragment.index);
if (assembly_parts_ & mask) {
return !memcmp(assembly_ + offset, fragment.data, fragment.length)
? DecodeResult::Partial : DecodeResult::Rejected;
}
memcpy(assembly_ + offset, fragment.data, fragment.length);
assembly_parts_ |= mask;
if (assembly_parts_ != 3) return DecodeResult::Partial;
envelope = assembly_;
envelope_length = assembly_length_;
assembled = true;
}
const bool valid = envelope_length >= FleetCommand::MinHeaderSize + FleetCommand::SignatureSize
&& storage::readLE32(envelope + 4) > last_sequence_
&& (!assembled || storage::readLE32(envelope + 4) == assembly_sequence_)
&& FleetCommand::decode(controller_, channel_.secret, envelope, envelope_length,
mesh.getRTCClock()->getCurrentTime(), mesh.self_id.pub_key, command);
if (assembled || (valid && assembly_parts_ && command.sequence >= assembly_sequence_)) clearAssembly();
if (!valid) return DecodeResult::Rejected;
broadcast_ = command.broadcast;
return true;
return DecodeResult::Accepted;
}
bool FleetChannel::acknowledge(Mesh& mesh, const char* name, uint32_t sequence,
+12 -2
View File
@@ -54,6 +54,10 @@ class FleetChannel {
bool broadcast_ = false;
bool ack_profile_mutation_ = false;
uint8_t incoming_[MAX_PACKET_PAYLOAD]{};
uint8_t assembly_[FleetCommand::MaxEnvelopeLength]{};
uint32_t assembly_sequence_ = 0, assembly_deadline_ = 0;
uint16_t assembly_length_ = 0;
uint8_t assembly_parts_ = 0;
uint8_t incoming_len_ = 0, rx_profile_ = 0, path_hash_size_ = 1;
uint32_t rx_generation_ = 0;
TransportKey reply_scope_{};
@@ -63,7 +67,10 @@ class FleetChannel {
bool load();
bool save(bool enabled, const uint8_t* key, const uint8_t* controller,
uint32_t sequence);
bool decode(Mesh& mesh, FleetCommand::Decoded& command);
enum class DecodeResult { Rejected, Partial, Accepted };
DecodeResult decode(Mesh& mesh, FleetCommand::Decoded& command);
void clearAssembly();
void serviceAssemblyDeadline();
bool acknowledge(Mesh& mesh, const char* name, uint32_t sequence,
const char* reply, bool radio_mutation);
void finish(RadioProfileCLI& profiles, bool delivered);
@@ -83,12 +90,15 @@ class FleetChannel {
void service(Mesh& mesh, RadioProfileCLI& profiles, const char* name,
Handler handle) {
serviceDeadline(mesh, profiles);
serviceAssemblyDeadline();
if (!pending_) return;
pending_ = false;
if (barrier_.waiting() || profiles.hasReplyMutation()
|| (hooks_ && hooks_->hasFleetReplyMutation())) { ++busy_; return; }
FleetCommand::Decoded command;
if (!decode(mesh, command)) { ++rejected_; return; }
const DecodeResult decoded = decode(mesh, command);
if (decoded == DecodeResult::Partial) return;
if (decoded != DecodeResult::Accepted) { ++rejected_; return; }
// Reserve before dispatch, including failed commands. A lost reply, reboot,
// or retry can never repeat a mutation. A storage failure grants no control.
if (!save(configured_, channel_.secret, controller_.pub_key, command.sequence)) {
+47 -7
View File
@@ -7,11 +7,16 @@ namespace {
constexpr char Domain[] = "MeshCoreFleet1";
constexpr size_t SignedMessageCapacity = sizeof(Domain) - 1
+ FleetCommand::KeySize + FleetCommand::MaxPayloadLength
+ FleetCommand::KeySize + FleetCommand::MaxEnvelopeLength
- FleetCommand::SignatureSize;
static_assert(FleetCommand::HeaderSize + FleetCommand::MaxCommandLength
+ FleetCommand::SignatureSize <= FleetCommand::MaxPayloadLength,
"Legacy fleet envelope must fit the shared packet budget");
static_assert(FleetCommand::HeaderSize + FleetCommand::FixedTargetMaxCommandLength
+ FleetCommand::SignatureSize <= FleetCommand::MaxEnvelopeLength,
"Fixed-target fleet envelope must fit the two-packet budget");
static_assert(FleetCommand::MinHeaderSize + FleetCommand::MaxCommandLength
+ FleetCommand::SignatureSize <= FleetCommand::MaxEnvelopeLength,
"Compact fleet envelope must fit the two-packet budget");
static_assert(FleetCommand::MaxCommandLength <= UINT8_MAX,
"Fleet command length must fit the signed length byte");
constexpr uint8_t PublicKey[FleetCommand::KeySize] = {
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72
@@ -355,7 +360,7 @@ size_t FleetCommand::encode(const LocalIdentity& publisher, const uint8_t* key,
const size_t command_length = strlen(command);
const size_t unsigned_length = HeaderSize + command_length;
const size_t length = unsigned_length + SignatureSize;
if (capacity < length) return 0;
if (length > MaxEnvelopeLength || capacity < length) return 0;
memcpy(output, "FMC1", 4);
write32(output + 4, sequence);
write32(output + 8, expires);
@@ -389,7 +394,7 @@ size_t FleetCommand::encode(const LocalIdentity& publisher, const uint8_t* key,
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;
if (length > MaxEnvelopeLength || capacity < length) return 0;
memcpy(output, "FMC2", 4);
write32(output + 4, sequence);
write32(output + 8, expires);
@@ -411,7 +416,7 @@ bool FleetCommand::decode(const Identity& publisher, const uint8_t* key,
if (!privateKeyAllowed(key) || !payload || !self_public_key
|| zero(publisher.pub_key, PUB_KEY_SIZE) || zero(self_public_key, PUB_KEY_SIZE)
|| now < MinEpoch || length < MinHeaderSize + SignatureSize
|| length > MaxPayloadLength) return false;
|| length > MaxEnvelopeLength) return false;
const size_t unsigned_length = length - SignatureSize;
size_t command_offset;
bool broadcast;
@@ -458,4 +463,39 @@ bool FleetCommand::decode(const Identity& publisher, const uint8_t* key,
return true;
}
size_t FleetCommand::fragment(const uint8_t* envelope, size_t length, uint8_t index,
uint8_t* output, size_t capacity) {
if (!envelope || !output || length <= MaxPayloadLength
|| length > MaxEnvelopeLength || index > 1
|| (memcmp(envelope, "FMC1", 4) && memcmp(envelope, "FMC2", 4))) return 0;
const size_t offset = size_t(index) * FragmentDataLength;
const size_t chunk_length = index ? length - offset : FragmentDataLength;
const size_t fragment_length = FragmentHeaderSize + chunk_length;
if (capacity < fragment_length) return 0;
memcpy(output, "FMP1", 4);
memcpy(output + 4, envelope + 4, 4); // The receiver checks this against signed UTC sequence.
output[8] = uint8_t(length);
output[9] = uint8_t(length >> 8);
output[10] = index;
memcpy(output + FragmentHeaderSize, envelope + offset, chunk_length);
return fragment_length;
}
bool FleetCommand::parseFragment(const uint8_t* payload, size_t length, Fragment& output) {
output = {};
if (!payload || length < FragmentHeaderSize || length > MaxPayloadLength
|| memcmp(payload, "FMP1", 4)) return false;
const uint16_t total = uint16_t(payload[8]) | (uint16_t(payload[9]) << 8);
const uint8_t index = payload[10];
if (total <= MaxPayloadLength || total > MaxEnvelopeLength || index > 1) return false;
const size_t chunk_length = index ? total - FragmentDataLength : FragmentDataLength;
if (length != FragmentHeaderSize + chunk_length) return false;
output.sequence = read32(payload + 4);
output.total_length = total;
output.index = index;
output.data = payload + FragmentHeaderSize;
output.length = chunk_length;
return true;
}
} // namespace mesh
+25 -3
View File
@@ -16,12 +16,17 @@ public:
static constexpr size_t KeySize = 16;
static constexpr size_t TargetSize = 16;
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 MaxPayloadLength = 165;
static constexpr size_t FragmentHeaderSize = 11;
static constexpr size_t FragmentDataLength = MaxPayloadLength - FragmentHeaderSize;
static constexpr size_t MaxEnvelopeLength = 2 * FragmentDataLength;
static constexpr size_t SinglePacketMaxCommandLength = MaxPayloadLength - MinHeaderSize - SignatureSize;
static constexpr size_t MaxCommandLength = MaxEnvelopeLength - MinHeaderSize - SignatureSize;
static constexpr size_t FixedTargetMaxCommandLength = MaxEnvelopeLength - HeaderSize - SignatureSize;
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;
static constexpr uint32_t MaxClockLead = 60;
@@ -41,6 +46,14 @@ public:
uint8_t data[MaxTargetBytes];
};
struct Fragment {
uint32_t sequence;
uint16_t total_length;
uint8_t index;
const uint8_t* data;
size_t length;
};
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.
@@ -59,7 +72,7 @@ public:
// Broadcast and prefix/list requests use FMC2. Zero records imply all;
// a single complete key retains FMC1 encoding. Targets and command share
// the packet budget, so valid lists can still be too large for a command.
// the two-packet envelope budget, so lists reduce the command allowance.
static size_t encode(const LocalIdentity& publisher, const uint8_t* key,
uint32_t sequence, uint32_t expires,
const Targets& targets, const char* command,
@@ -71,6 +84,15 @@ public:
static bool 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);
// Envelopes <= MaxPayloadLength travel unchanged. Longer envelopes use
// exactly two FMP1 frames, each bounded by the group payload limit.
static size_t fragment(const uint8_t* envelope, size_t length, uint8_t index,
uint8_t* output, size_t capacity);
// Fragment metadata is untrusted. The receiver must assemble both parts,
// verify the entire envelope once, require its signed sequence to match,
// and durably reserve replay state before executing any command.
static bool parseFragment(const uint8_t* payload, size_t length, Fragment& output);
};
} // namespace mesh
+5 -1
View File
@@ -23,7 +23,7 @@
namespace mesh {
struct FleetCommand {
static constexpr uint16_t DataType = 0xFF01;
static constexpr size_t KeySize = 16, TargetSize = 16, MaxPayloadLength = 165;
static constexpr size_t KeySize = 16, TargetSize = 16, MaxPayloadLength = 165, MaxEnvelopeLength = 308;
static constexpr uint32_t MinEpoch = 1735689600UL, MaxLifetime = 600;
struct Targets { uint8_t length = 0, count = 0, data[86] = {}; };
static bool parseTargets(const char*, size_t, Targets&) { return false; }
@@ -52,4 +52,8 @@ struct CompanionFleetFixture {
assert(false && "fleet packet admission belongs in its dedicated suite");
return false;
}
bool sendFleetCommandData(FleetFixtureChannel&, const uint8_t*, size_t) {
assert(false && "fleet packet admission belongs in its dedicated suite");
return false;
}
};
+1 -1
View File
@@ -155,7 +155,7 @@ class CompanionExtraFsHilGuardTest(unittest.TestCase):
self.assertNotIn("handleExtraFsHilCommand", generic)
self.assertNotIn("hil extrafs", generic)
self.assertNotIn("handleExtraFsHilCommand", on_air)
self.assertIn("handleCommand(text, sender_timestamp, reply)", on_air)
self.assertIn("handleCommand(text, sender_timestamp ? sender_timestamp : 1, reply)", on_air)
def test_filler_reports_only_read_verified_durable_bytes(self):
body = function_body(
+451 -21
View File
@@ -7,6 +7,7 @@ supplies real Ed25519 signing and verification through the Identity interface.
"""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
@@ -24,10 +25,23 @@ HARNESS = r'''
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include <helpers/FleetCommand.h>
#include <helpers/CompanionFrameLimits.h>
#include <openssl/evp.h>
struct RescueStream : Stream {
std::string input, output;
size_t position = 0, reads = 0;
int available() override { return position < input.size(); }
int read() override { ++reads; return input[position++]; }
size_t write(uint8_t value) override { output += char(value); return 1; }
};
static RescueStream rescue_input, rescue_output;
namespace mesh {
Stream& usbTerminalPort() { return rescue_output; }
Stream& usbCompanionPort() { return rescue_input; }
Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); }
LocalIdentity::LocalIdentity() : Identity() {
memset(prv_key, 0xAA, 32);
@@ -62,23 +76,78 @@ bool Identity::verify(const uint8_t* signature, const uint8_t* data, int length)
#define MAX_GROUP_CHANNELS 4
#define OUT_PATH_UNKNOWN 255
namespace mesh {
struct GroupChannel {
uint8_t secret[32] = {};
uint8_t tx_radio = 0;
};
struct Packet {
uint8_t payload[168] = {};
size_t payload_len = 0;
uint8_t tx_radio = 0;
uint16_t scope = 0;
bool isRouteDirect() const { return false; }
uint8_t getPayloadType() const { return 6; }
};
struct Mesh {
unsigned base_copy_hooks = 0;
void onRadioProfileCopyQueued(Packet*, const Packet*, uint8_t) { ++base_copy_hooks; }
};
}
using GroupChannel = mesh::GroupChannel;
#define PAYLOAD_TYPE_GRP_DATA 6
#define PAYLOAD_TYPE_TRACE 9
struct PacketManager {
mesh::Packet pool[64];
bool used[64] = {};
std::vector<mesh::Packet*> outbound;
unsigned total = 0, allocations = 0, released = 0;
unsigned fail_allocation = 0;
mesh::Packet* allocNew() {
if (++allocations == fail_allocation) return nullptr;
for (unsigned i = 0; i < 64; ++i) if (!used[i]) {
used[i] = true; pool[i] = {}; return &pool[i];
}
return nullptr;
}
void free(mesh::Packet* packet) {
const ptrdiff_t index = packet - pool;
assert(index >= 0 && index < 64 && used[index]);
used[index] = false; ++released;
}
unsigned freeCount() const { unsigned count = 0; for (bool value : used) count += !value; return count; }
int getOutboundTotal() const { return int(total); }
mesh::Packet* getOutboundByIdx(int index) { return outbound.at(index); }
mesh::Packet* removeOutboundByIdx(int index) {
auto* packet = outbound.at(index); outbound.erase(outbound.begin() + index); --total;
return packet;
}
void queue(mesh::Packet* packet) { outbound.push_back(packet); ++total; }
};
struct ChannelDetails { GroupChannel channel; char name[32] = {}; };
struct Clock { uint32_t now = mesh::FleetCommand::MinEpoch + 1000;
uint32_t getCurrentTime() const { return now; } };
class MyMesh {
class MyMesh : public mesh::Mesh {
public:
mesh::LocalIdentity self_id;
Clock clock;
ChannelDetails channels[MAX_GROUP_CHANNELS];
bool queue_ok = true;
unsigned queued = 0;
PacketManager manager;
PacketManager* _mgr = &manager;
unsigned& queued = manager.total;
unsigned queue_calls = 0, fail_queue = 0, failed_hooks = 0, radio_cancellations = 0;
bool radio_copy = false;
mesh::Packet* _fleet_admission_original = nullptr;
mesh::Packet* _fleet_admission_copy = nullptr;
char cli_command[80] = {};
bool _cli_line_overflow = false;
bool _cli_rescue = false;
std::vector<std::string> rescue_commands;
uint8_t sent_policy = 0;
uint16_t sent_type = 0;
uint8_t sent[mesh::FleetCommand::MaxPayloadLength] = {};
uint8_t sent[mesh::FleetCommand::MaxEnvelopeLength] = {};
size_t sent_length = 0;
MyMesh() {
strcpy(channels[1].name, "private fleet");
@@ -90,18 +159,64 @@ class MyMesh {
out = channels[index]; return true;
}
Clock* getRTCClock() { return &clock; }
mesh::Packet* createGroupDatagram(uint8_t type, const GroupChannel& channel,
const uint8_t* data, size_t length) {
assert(type == PAYLOAD_TYPE_GRP_DATA && length <= sizeof(mesh::Packet::payload));
auto* packet = manager.allocNew();
if (!packet) return nullptr;
packet->tx_radio = channel.tx_radio; packet->payload_len = length;
memcpy(packet->payload, data, length); return packet;
}
void releasePacket(mesh::Packet* packet) { manager.free(packet); }
void onSendFail(mesh::Packet*) { ++failed_hooks; }
void cancelOutboundRadioRetry(const mesh::Packet*) { ++radio_cancellations; }
void onTracePacketQueuedForSend(mesh::Packet*) { assert(false); }
bool queueOutboundPacket(mesh::Packet* packet, uint8_t, uint32_t) {
if (++queue_calls == fail_queue || !queue_ok) return false;
manager.queue(packet);
sent_policy = packet->tx_radio;
sent_type = uint16_t(packet->payload[0]) | uint16_t(packet->payload[1]) << 8;
const uint8_t* blob = packet->payload + 3;
const size_t length = packet->payload[2];
assert(length + 3 == packet->payload_len && length <= mesh::FleetCommand::MaxPayloadLength);
mesh::FleetCommand::Fragment part;
if (mesh::FleetCommand::parseFragment(blob, length, part)) {
memcpy(sent + part.index * mesh::FleetCommand::FragmentDataLength, part.data, part.length);
sent_length = part.total_length;
} else {
memcpy(sent, blob, length); sent_length = length;
}
if (radio_copy) {
auto* copy = manager.allocNew(); assert(copy); *copy = *packet;
manager.queue(copy); onRadioProfileCopyQueued(copy, packet, 1);
}
return true;
}
bool sendPacket(mesh::Packet*, uint8_t, uint32_t = 0);
bool sendFloodScoped(const GroupChannel& channel, mesh::Packet* packet, uint32_t = 0) {
assert(packet->tx_radio == channel.tx_radio);
packet->scope = 0x4321; // Scope adapter; production helper preserves this channel route.
return sendPacket(packet, 1);
}
bool sendGroupData(GroupChannel& channel, uint8_t* path, uint8_t path_length,
uint16_t type, const uint8_t* data, int length) {
assert(path == nullptr && path_length == OUT_PATH_UNKNOWN);
assert(length > 0 && size_t(length) <= sizeof(sent));
if (!queue_ok) return false;
sent_policy = channel.tx_radio; sent_type = type;
memcpy(sent, data, length); sent_length = length; ++queued;
return true;
assert(length > 0 && size_t(length) <= mesh::FleetCommand::MaxPayloadLength);
uint8_t group[168] = {uint8_t(type), uint8_t(type >> 8), uint8_t(length)};
memcpy(group + 3, data, length);
auto* packet = createGroupDatagram(PAYLOAD_TYPE_GRP_DATA, channel, group, 3 + length);
return packet && sendFloodScoped(channel, packet);
}
bool sendFleetCommandData(GroupChannel&, const uint8_t*, size_t);
void onRadioProfileCopyQueued(mesh::Packet*, const mesh::Packet*, uint8_t);
bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply);
void checkCLIRescueCmd();
void enterCLIRescue();
void resetUsbHostSessionInput();
};
@METHOD@
@SEND_HELPERS@
@RESCUE_READER@
static void rejected(MyMesh& node, const char* text, uint32_t remote = 0) {
char reply[160] = {};
@@ -118,6 +233,37 @@ static std::string fullKey(uint8_t value) {
return hex;
}
static void checkedFragments(MyMesh& node, size_t first_index) {
assert(node.sent_length > mesh::FleetCommand::MaxPayloadLength);
uint8_t assembled[mesh::FleetCommand::MaxEnvelopeLength] = {};
// Read in reverse order; the immutable part index determines placement.
for (uint8_t index : {1, 0}) {
auto* packet = node.manager.outbound.at(first_index + index);
assert(packet->scope == 0x4321 && packet->tx_radio == 2);
assert(packet->payload[0] == 1 && packet->payload[1] == 0xff);
const size_t bytes = packet->payload[2];
mesh::FleetCommand::Fragment part;
assert(mesh::FleetCommand::parseFragment(packet->payload + 3, bytes, part));
assert(part.index == index && part.total_length == node.sent_length);
assert(part.sequence == node.clock.now && bytes <= mesh::FleetCommand::MaxPayloadLength);
assert(part.length == (index == 0 ? 154 : node.sent_length - 154));
assert(bytes == mesh::FleetCommand::FragmentHeaderSize + part.length);
memcpy(assembled + index * 154, part.data, part.length);
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,
packet->payload + 3, bytes, node.clock.now, recipient, decoded));
}
assert(!memcmp(assembled, node.sent, node.sent_length));
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,
assembled, node.sent_length, node.clock.now, recipient, decoded));
assembled[node.sent_length - 1] ^= 1;
assert(!mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
assembled, node.sent_length, node.clock.now, recipient, decoded));
}
static void compactBroadcast(MyMesh& node, const char* command) {
assert(!memcmp(node.sent, "FMC2", 4));
assert(node.sent[12] == 0); // No target records means every authorized receiver.
@@ -125,6 +271,8 @@ static void compactBroadcast(MyMesh& node, const char* command) {
assert(mesh::FleetCommand::MinHeaderSize == 14);
assert(node.sent_length == 14 + strlen(command) + mesh::FleetCommand::SignatureSize);
assert(!memcmp(node.sent + 14, command, strlen(command)));
if (node.sent_length > mesh::FleetCommand::MaxPayloadLength)
checkedFragments(node, node.manager.outbound.size() - 2);
for (uint8_t value : {0x42, 0x77}) {
uint8_t recipient[32]; memset(recipient, value, sizeof(recipient));
mesh::FleetCommand::Decoded decoded;
@@ -225,12 +373,210 @@ int main(int argc, char** argv) {
}
const std::string prefix = "set flood.filter ";
const std::string maximum = prefix + std::string(mesh::FleetCommand::MaxCommandLength - prefix.size(), '1');
assert(maximum.size() == 72 && mesh::FleetCommand::commandAllowed(maximum.c_str()));
assert(maximum.size() == 230 && mesh::FleetCommand::commandAllowed(maximum.c_str()));
rejected(node, ("fleet send 1 " + maximum + "1").c_str());
rejected(node, ("fleet send 1 all " + maximum + "1").c_str());
assert(node.handleCommand(("fleet send 1 " + maximum).c_str(), 0, reply));
compactBroadcast(node, maximum.c_str());
assert(node.sent_length == 150 && node.queued == 1); // Compact header keeps the 72-byte cap.
assert(node.sent_length == 308 && node.queued == 2);
++node.clock.now;
assert(node.handleCommand(("fleet send 1 all " + maximum).c_str(), 0, reply));
compactBroadcast(node, maximum.c_str());
assert(node.sent_length == 308 && node.queued == 4);
} else if (scenario == "target_command_capacity") {
struct Boundary { std::string targets; size_t command_length; };
const Boundary boundaries[] = {
{"42424242", 225}, {"424242424242", 223}, {fullKey(0x42), 215},
{"42424242,51515151", 220}, {"42424242,515151515151", 218},
{fullKey(0x42) + "," + fullKey(0x51), 196},
};
const std::string prefix = "set flood.filter ";
for (const Boundary& boundary : boundaries) {
const std::string fitting = prefix + std::string(boundary.command_length - prefix.size(), '1');
const std::string oversize = fitting + '1';
assert(mesh::FleetCommand::commandAllowed(oversize.c_str()));
const std::string cli_prefix = "fleet send 1 " + boundary.targets + " ";
const unsigned before = node.queued;
rejected(node, (cli_prefix + oversize).c_str());
// A valid target plus a long command must not become a broadcast, a
// partial send, or a consumed sequence. A queue refusal is retryable too.
assert(node.handleCommand((cli_prefix + oversize).c_str(), 0, reply));
assert(strstr(reply, "exceed two packets") && node.queued == before);
node.queue_ok = false;
rejected(node, (cli_prefix + fitting).c_str());
node.queue_ok = true;
assert(node.handleCommand((cli_prefix + fitting).c_str(), 0, reply));
assert(node.queued == before + 2 && node.sent_length == 308);
assert(node.sent_policy == 2);
uint8_t recipient[32]; memset(recipient, 0x42, sizeof(recipient));
mesh::FleetCommand::Decoded decoded;
assert(mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
node.sent, node.sent_length, node.clock.now, recipient, decoded));
assert(decoded.sequence == node.clock.now && !strcmp(decoded.command, fitting.c_str()));
assert(!memcmp(node.sent, boundary.targets.size() == 64 ? "FMC1" : "FMC2", 4));
memset(recipient, 0x77, sizeof(recipient));
assert(!mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
node.sent, node.sent_length, node.clock.now, recipient, decoded));
rejected(node, (cli_prefix + fitting).c_str()); // Successful send consumes its second.
++node.clock.now;
}
assert(node.queued == 12);
} else if (scenario == "maximum_correlated_app_command") {
const std::string prefix = "set flood.filter ";
const std::string maximum = prefix + std::string(158 - prefix.size(), '1');
const std::string local_cli = "AB|fleet send 1 " + maximum;
// The app's local CLI frame adds opcode 66 and a trailing NUL. Its request
// correlation prefix is already inside local_cli; all are ASCII bytes.
assert(local_cli.size() + 2 == MAX_FRAME_SIZE);
assert((local_cli + '1').size() + 2 > MAX_FRAME_SIZE);
rejected(node, local_cli.c_str(), 1);
node.queue_ok = false;
rejected(node, local_cli.c_str());
node.queue_ok = true;
assert(node.handleCommand(local_cli.c_str(), 0, reply));
assert(!strncmp(reply, "AB|OK - fleet command queued; seq=", 32));
compactBroadcast(node, maximum.c_str());
assert(node.sent_length == 236 && node.queued == 2);
assert(node.sent_policy == 2);
rejected(node, local_cli.c_str());
++node.clock.now;
const std::string unprefixed = prefix + std::string(161 - prefix.size(), '1');
const std::string implicit = "fleet send 1 " + unprefixed;
assert(implicit.size() + 2 == MAX_FRAME_SIZE);
assert((implicit + '1').size() + 2 > MAX_FRAME_SIZE);
assert(node.handleCommand(implicit.c_str(), 0, reply));
compactBroadcast(node, unprefixed.c_str());
assert(node.sent_length == 239 && node.queued == 4);
} else if (scenario == "fragment_boundaries") {
struct Boundary { std::string targets; size_t single_length; };
const Boundary boundaries[] = {
{"", 87}, {"42424242 ", 82}, {"424242424242 ", 80}, {fullKey(0x42) + " ", 72},
};
const std::string prefix = "set flood.filter ";
for (const Boundary& boundary : boundaries) {
std::string command = prefix + std::string(boundary.single_length - prefix.size(), '1');
const std::string cli_prefix = "fleet send 1 " + boundary.targets;
const size_t before = node.manager.outbound.size();
assert(node.handleCommand((cli_prefix + command).c_str(), 0, reply));
assert(node.queued == before + 1 && node.sent_length == 165);
assert(!memcmp(node.manager.outbound.back()->payload + 3,
boundary.targets.size() == 65 ? "FMC1" : "FMC2", 4));
++node.clock.now; command += '1';
assert(node.handleCommand((cli_prefix + command).c_str(), 0, reply));
assert(node.queued == before + 3 && node.sent_length == 166);
checkedFragments(node, before + 1);
++node.clock.now;
}
assert(node.queued == 12);
} else if (scenario == "fragment_atomic_admission") {
unsigned scenario_index = 0;
for (const char* failure : {"pool_first", "pool_second", "queue_first", "queue_second", "queue_second_copy"}) {
MyMesh trial; trial.clock.now += ++scenario_index * 10;
const std::string prefix = "set flood.filter ";
const std::string command = prefix + std::string(161 - prefix.size(), '1');
const std::string cli = "fleet send 1 " + command;
auto* unrelated = trial.manager.allocNew(); assert(unrelated);
unrelated->payload[0] = 99; trial.manager.queue(unrelated);
const unsigned free_before = trial.manager.freeCount();
if (!strcmp(failure, "pool_first")) trial.manager.fail_allocation = trial.manager.allocations + 1;
if (!strcmp(failure, "pool_second")) trial.manager.fail_allocation = trial.manager.allocations + 2;
if (!strcmp(failure, "queue_first")) trial.fail_queue = trial.queue_calls + 1;
if (!strcmp(failure, "queue_second")) trial.fail_queue = trial.queue_calls + 2;
if (!strcmp(failure, "queue_second_copy")) {
trial.fail_queue = trial.queue_calls + 2; trial.radio_copy = true;
}
rejected(trial, cli.c_str());
assert(trial.queued == 1 && trial.manager.outbound[0] == unrelated);
assert(trial.manager.freeCount() == free_before);
assert(!trial._fleet_admission_original && !trial._fleet_admission_copy);
if (!strcmp(failure, "queue_first")) assert(trial.failed_hooks == 1);
if (!strcmp(failure, "queue_second"))
assert(trial.failed_hooks == 2 && trial.radio_cancellations == 1);
if (!strcmp(failure, "queue_second_copy")) {
assert(trial.failed_hooks == 3 && trial.radio_cancellations == 2);
assert(trial.base_copy_hooks == 1); // The production override chains Mesh.
}
trial.manager.fail_allocation = 0; trial.fail_queue = 0; trial.radio_copy = false;
assert(trial.handleCommand(cli.c_str(), 0, reply));
assert(trial.queued == 3 && trial.manager.outbound[0] == unrelated);
compactBroadcast(trial, command.c_str());
assert(trial.manager.freeCount() == free_before - 2);
}
} else if (scenario == "rescue_overflow") {
const std::string unsafe = "fleet send 1 set flood.rule.1 type=grp_txt drop hops=all in=any priority=00001 suspend=tempradio";
assert(unsafe.size() > sizeof(node.cli_command));
// The old forced-completion behavior would execute this valid prefix,
// silently omitting the requested suspend=tempradio condition.
const std::string truncated = unsafe.substr(0, 78);
assert(mesh::FleetCommand::commandAllowed(truncated.c_str() + 13));
rescue_input.input = unsafe + "\r\nfleet send 1 clock\r\n";
while (rescue_input.available()) {
const size_t before = rescue_input.reads;
node.checkCLIRescueCmd();
assert(rescue_input.reads - before <= sizeof(node.cli_command));
}
assert(node.rescue_commands.size() == 1 && node.rescue_commands[0] == "fleet send 1 clock");
assert(node.queued == 1 && !node._cli_line_overflow && node.cli_command[0] == 0);
assert(rescue_output.output.find("ERR: too long") != std::string::npos);
compactBroadcast(node, "clock");
} else if (scenario == "rescue_split_overflow") {
rescue_input.input = std::string(400, 'x');
node.checkCLIRescueCmd();
assert(rescue_input.reads == sizeof(node.cli_command) && node._cli_line_overflow);
assert(node.rescue_commands.empty() && node.queued == 0);
while (rescue_input.available()) node.checkCLIRescueCmd();
assert(node._cli_line_overflow && node.rescue_commands.empty());
// Even a valid fleet suffix is discarded until the actual line ending.
rescue_input.input += "fleet send 1 set radio2 off\nfleet send 1 clock\n";
while (rescue_input.available()) node.checkCLIRescueCmd();
assert(node.rescue_commands.size() == 1 && node.rescue_commands[0] == "fleet send 1 clock");
assert(node.queued == 1 && !node._cli_line_overflow);
} else if (scenario == "rescue_boundaries") {
const std::string exact(79, 'x');
rescue_input.input = exact;
node.checkCLIRescueCmd();
assert(node.rescue_commands.empty() && strlen(node.cli_command) == 79);
rescue_input.input += "\r\nfirst\r\nsecond\nthird\r";
while (rescue_input.available()) node.checkCLIRescueCmd();
const std::vector<std::string> expected = {exact, "first", "second", "third"};
assert(node.rescue_commands == expected && !node._cli_line_overflow);
assert(node.cli_command[0] == 0);
} else if (scenario == "rescue_unterminated") {
memset(node.cli_command, 'x', sizeof(node.cli_command));
rescue_input.input = "fleet send 1 set radio2 off\r\nfleet send 1 clock\r";
while (rescue_input.available()) node.checkCLIRescueCmd();
assert(node.rescue_commands.size() == 1 && node.rescue_commands[0] == "fleet send 1 clock");
assert(node.queued == 1 && !node._cli_line_overflow);
} else if (scenario == "rescue_session_reset") {
node._cli_line_overflow = true; strcpy(node.cli_command, "old host prefix");
node.enterCLIRescue();
assert(node._cli_rescue && !node._cli_line_overflow && node.cli_command[0] == 0);
rescue_input.input = "fleet send 1 clock\r";
while (rescue_input.available()) node.checkCLIRescueCmd();
assert(node.queued == 1);
++node.clock.now;
node._cli_line_overflow = true; strcpy(node.cli_command, "old host prefix");
node.resetUsbHostSessionInput();
assert(!node._cli_line_overflow);
for (char value : node.cli_command) assert(value == 0);
rescue_input.input += "fleet send 1 clock\n";
while (rescue_input.available()) node.checkCLIRescueCmd();
assert(node.queued == 2 && node.rescue_commands.size() == 2);
} else if (scenario == "stm32_rescue") {
rescue_input.input = std::string(400, 'x') + "fleet send 1 clock\r\nnext\r\n";
while (rescue_input.available()) {
const size_t before = rescue_input.reads;
node.checkCLIRescueCmd();
assert(rescue_input.reads - before <= sizeof(node.cli_command));
}
assert(node.rescue_commands == std::vector<std::string>{"next"});
assert(rescue_output.output.find(" Error: unknown command") != std::string::npos);
assert(rescue_output.output.find("ERR: too long") == std::string::npos);
assert(node.queued == 0 && !node._cli_line_overflow);
memset(node.cli_command, 'x', sizeof(node.cli_command));
rescue_input.input += "suffix\nlast\n";
while (rescue_input.available()) node.checkCLIRescueCmd();
assert(node.rescue_commands == (std::vector<std::string>{"next", "last"}));
} else if (scenario == "explicit_target_forms") {
for (const std::string& targets : {std::string("42424242"), std::string("424242424242"),
fullKey(0x42), std::string("42424242,515151515151,") + fullKey(0x63)}) {
@@ -258,7 +604,7 @@ int main(int argc, char** argv) {
"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());
rejected(node, ("fleet send 1 all set flood.filter " + std::string(mesh::FleetCommand::MaxCommandLength, '1')).c_str());
assert(node.handleCommand("fleet\tsend\t1\tall\tset radio2 off", 0, reply));
assert(!strncmp(reply, "OK - fleet command queued", 25));
} else if (scenario == "private_channel") {
@@ -378,14 +724,16 @@ int main(int argc, char** argv) {
}
mesh::FleetCommand::Targets parsed;
assert(mesh::FleetCommand::parseTargets(targets.c_str(), targets.size(), parsed));
const std::string prefix = "set flood.filter ";
const std::string too_long = prefix + std::string(mesh::FleetCommand::MaxCommandLength - prefix.size(), '1');
assert(node.handleCommand(("fleet send 1 " + targets + " " + too_long).c_str(), 0, reply));
assert(strstr(reply, "exceed two packets") && node.queued == 0);
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);
assert(node.queued == 2); // The valid list now fits one atomic two-packet envelope.
++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);
assert(node.queued == 2);
} else if (scenario == "scheduled_sets") {
const char* relative[] = {
"set radioat 910.5,500,5,5,+1,auto",
@@ -462,9 +810,15 @@ int main(int argc, char** argv) {
assert(mesh::FleetCommand::parseTargets(targets.c_str(), targets.size(), parsed));
assert(mesh::FleetCommand::commandAllowed(scheduled.c_str()));
assert(node.handleCommand(("fleet send 1 " + targets + " " + scheduled).c_str(), 0, reply));
assert(strstr(reply, "exceed one packet") && node.queued == 0);
assert(!strncmp(reply, "OK - fleet command queued", 25) && node.queued == 2);
uint8_t recipient[32]; memset(recipient, 0x42, sizeof(recipient));
mesh::FleetCommand::Decoded decoded;
assert(mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
node.sent, node.sent_length, node.clock.now, recipient, decoded));
assert(!strcmp(decoded.command, scheduled.c_str()));
++node.clock.now;
assert(node.handleCommand(("fleet send 1 42424242 " + scheduled).c_str(), 0, reply));
assert(node.queued == 1 && node.sent_policy == 2);
assert(node.queued == 3 && node.sent_policy == 2);
} else if (scenario == "clock_controls") {
uint8_t recipient[32]; memset(recipient, 0x42, sizeof(recipient));
for (const char* clock_command : {"clock", "clock sync", "time 1800000000"}) {
@@ -510,10 +864,45 @@ class CompanionFleetControlTests(unittest.TestCase):
work = Path(cls.work.name)
cls.binary = work / "companion-fleet"
fixture = work / "fixture.cpp"
fixture.write_text(HARNESS.replace("@METHOD@", cls.production))
helpers = "\n".join(body(source, signature) for signature in (
"void MyMesh::onRadioProfileCopyQueued(", "bool MyMesh::sendFleetCommandData(",
))
send_packet = body((ROOT / "src/Dispatcher.cpp").read_text(), "bool Dispatcher::sendPacket(")
send_packet = send_packet.replace("bool Dispatcher::sendPacket(Packet*", "bool MyMesh::sendPacket(mesh::Packet*", 1)
rescue = body(source, "void MyMesh::checkCLIRescueCmd()")
# Keep the entire production reader/line-admission logic. Replace only
# downstream rescue commands (filesystem/UI peripherals) with a record
# and the real Companion fleet dispatcher used throughout this suite.
rescue = rescue[:rescue.index(" reply_buf[0] = 0;")]
# Print::println(text) is absent from the shared host Stream adapter.
# Preserve STM32's actual shared error text and adapt only printing.
rescue = rescue.replace('output.println(" Error: unknown command");',
'output.print(" Error: unknown command"); output.println();')
rescue += '''
rescue_commands.emplace_back(cli_command);
char reply[160] = {};
handleCommand(cli_command, 0, reply);
cli_command[0] = 0;
}
}
'''
reset = body(source, "void MyMesh::resetUsbHostSessionInput()")
enter = body(source, "void MyMesh::enterCLIRescue()")
# The shared Stream mock lacks Print::println(text); preserve the
# actual entry-state reset and adapt only this banner's formatting.
banner = 'mesh::usbTerminalPort().println("========= CLI Rescue =========");'
assert enter.count(banner) == 1
enter = enter.replace(banner, 'mesh::usbTerminalPort().print("========= CLI Rescue =========\\n");')
cls.fixture = fixture
fixture.write_text(HARNESS.replace("@METHOD@", cls.production)
.replace("@SEND_HELPERS@", send_packet + "\n" + helpers)
.replace("@RESCUE_READER@", rescue + "\n" + reset + "\n" + enter))
sanitizer_flags = (["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie"]
if os.environ.get("MESHCORE_FLEET_SANITIZERS") == "1" else [])
cls.sanitizer_flags = sanitizer_flags
result = subprocess.run([
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-O2",
"-Wno-unused-parameter", "-O2", *sanitizer_flags,
"-fstack-usage", "-isystem", str(ROOT / "test/mocks"),
"-I", str(ROOT / "src"), str(fixture),
str(ROOT / "src/helpers/FleetCommand.cpp"), "-lcrypto", "-o", str(cls.binary),
@@ -546,9 +935,50 @@ class CompanionFleetControlTests(unittest.TestCase):
def test_unknown_or_malformed_explicit_targets_never_fall_back_to_broadcast(self):
self.scenario("implicit_target_ambiguity")
def test_omitted_target_still_requires_canonical_allowlisted_command_and_72_byte_cap(self):
def test_omitted_target_requires_canonical_allowlisted_command_and_230_byte_cap(self):
self.scenario("implicit_command_controls")
def test_target_specific_exact_packet_limits_reject_atomically_and_allow_retry(self):
self.scenario("target_command_capacity")
def test_maximum_correlated_implicit_command_fits_app_frame_and_remains_local_only(self):
self.scenario("maximum_correlated_app_command")
def test_one_packet_boundary_automatically_becomes_two_signed_fragments(self):
self.scenario("fragment_boundaries")
def test_two_part_admission_reserves_pool_and_rolls_back_exact_packets_and_radio_copy(self):
self.scenario("fragment_atomic_admission")
def test_rescue_overflow_discards_full_fleet_rule_and_preserves_next_crlf_command(self):
self.scenario("rescue_overflow")
def test_rescue_overflow_drains_bounded_chunks_and_never_executes_valid_suffix(self):
self.scenario("rescue_split_overflow")
def test_rescue_exact_capacity_waits_for_real_terminator_and_keeps_lines_separate(self):
self.scenario("rescue_boundaries")
def test_rescue_unterminated_buffer_is_bounded_and_discards_remainder(self):
self.scenario("rescue_unterminated")
def test_rescue_entry_and_usb_host_recovery_reset_discard_state(self):
self.scenario("rescue_session_reset")
def test_stm32_rescue_shared_error_still_drains_and_rejects_entire_overflow(self):
binary = Path(self.work.name) / "companion-stm32-rescue"
built = subprocess.run([
self.compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-DSTM32_PLATFORM=1", "-O2", *self.sanitizer_flags,
"-isystem", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"),
str(self.fixture), str(ROOT / "src/helpers/FleetCommand.cpp"),
"-lcrypto", "-o", str(binary),
], capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
result = subprocess.run([str(binary), "stm32_rescue"],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_explicit_8_12_64_hex_and_list_targets_remain_restricted(self):
self.scenario("explicit_target_forms")
@@ -576,7 +1006,7 @@ class CompanionFleetControlTests(unittest.TestCase):
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):
def test_valid_list_exceeding_two_packet_capacity_never_partially_sends(self):
self.scenario("target_capacity")
def test_four_schedule_set_forms_are_signed_without_rewriting(self):
+319 -3
View File
@@ -279,13 +279,16 @@ static void enroll(FleetChannel& fleet,const LocalIdentity& publisher){
assert(config(fleet,std::string("set fleet.controller ")+hex).rfind("OK",0)==0);
}
static Packet raw(const uint8_t* bytes,size_t length){
Packet packet;Utils::sha256(packet.payload,1,channel_key,16);
Packet packet;
assert(1+CIPHER_MAC_SIZE+(length+15)/16*16<=sizeof(packet.payload));
Utils::sha256(packet.payload,1,channel_key,16);
packet.payload_len=1+Utils::encryptThenMAC(channel_key,packet.payload+1,bytes,int(length));
packet.radio_profile=1;packet.radio_generation=17;packet.path_hash_size=2;
return packet;
}
static Packet command(const LocalIdentity& publisher,uint32_t sequence,const char* text="set radio2 off",
const char* target="all"){
assert(strlen(text)<=FleetCommand::MaxPayloadLength-FleetCommand::HeaderSize-FleetCommand::SignatureSize);
uint8_t payload[184]={},destination[16];assert(FleetCommand::parseTarget(target,destination));
size_t length=FleetCommand::encode(publisher,channel_key,sequence,sequence+300,destination,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);
@@ -321,7 +324,7 @@ static std::string publicHex(const Identity& identity){
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);
assert(text_size<=FleetCommand::MaxCommandLength);
assert(text_size<=FleetCommand::MaxPayloadLength-FleetCommand::HeaderSize-FleetCommand::SignatureSize);
uint8_t* payload=data+3;memcpy(payload,"FMC1",4);
storage::writeLE32(payload+4,sequence);storage::writeLE32(payload+8,expires);
payload[28]=uint8_t(text_size);memcpy(payload+29,text,text_size);
@@ -332,6 +335,20 @@ static Packet signedUnchecked(const LocalIdentity& publisher,uint32_t sequence,u
data[0]=uint8_t(FleetCommand::DataType);data[1]=uint8_t(FleetCommand::DataType>>8);
data[2]=uint8_t(29+text_size+64);return raw(data,3+data[2]);
}
static Packet compactUnchecked(const LocalIdentity& publisher,uint32_t sequence,const char* text){
uint8_t data[184]={},message[160]={};const size_t text_size=strlen(text);
// One byte over the valid limit still fits the cipher's final padded block.
assert(text_size<=FleetCommand::SinglePacketMaxCommandLength+1);
uint8_t* payload=data+3;memcpy(payload,"FMC2",4);
storage::writeLE32(payload+4,sequence);storage::writeLE32(payload+8,sequence+300);
payload[12]=0;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 void bind(FleetChannel& fleet,Mesh& mesh,RadioProfileCLI& profiles){
mesh.copying=[&](Packet* second,const Packet* first){fleet.copy(second,first);};
@@ -351,8 +368,213 @@ static unsigned apply(FleetChannel& fleet,fs::FS& fs,Mesh& mesh,RadioProfileCLI&
static void reseal(std::vector<uint8_t>& image){
storage::writeLE32(image.data()+60,storage::updateCRC32(0xffffffff,image.data(),60));
}
static std::string longRule(){
const std::string prefix="set flood.rule.1 type=grp_txt channel="+std::string(64,'1')
+" prefix=AABBCC,DDEEFF,112233 hops=6+ priority=255 suspend=tempradio mode=radio hashbytes=3 in=any";
const std::string action=" action=drop";
assert(prefix.size()+action.size()<=FleetCommand::MaxCommandLength);
return prefix+std::string(FleetCommand::MaxCommandLength-prefix.size()-action.size(),' ')+action;
}
static std::vector<uint8_t> envelope(const LocalIdentity& publisher,uint32_t sequence,
const std::string& text,const std::string& target="all"){
FleetCommand::Targets targets;assert(FleetCommand::parseTargets(target.c_str(),target.size(),targets));
std::vector<uint8_t> bytes(FleetCommand::MaxEnvelopeLength);
size_t length=FleetCommand::encode(publisher,channel_key,sequence,sequence+300,targets,
text.c_str(),bytes.data(),bytes.size());
assert(length);bytes.resize(length);return bytes;
}
static Packet part(const std::vector<uint8_t>& bytes,uint8_t index){
uint8_t data[168]={};size_t length=FleetCommand::fragment(bytes.data(),bytes.size(),index,
data+3,sizeof(data)-3);
assert(length&&length<=FleetCommand::MaxPayloadLength);data[0]=uint8_t(FleetCommand::DataType);
data[1]=uint8_t(FleetCommand::DataType>>8);data[2]=uint8_t(length);
Packet result=raw(data,3+length);assert(result.payload_len<=MAX_PACKET_PAYLOAD);return result;
}
template<typename Change> static Packet changePart(const Packet& packet,Change change){
uint8_t data[184]={};int length=Utils::MACThenDecrypt(channel_key,data,packet.payload+1,packet.payload_len-1);
assert(length>0);change(data+3);return raw(data,size_t(length));
}
struct FragmentFixture {
fs::FS disk;Mesh mesh;RadioProfileCLI profiles;FleetChannel fleet{&disk};LocalIdentity publisher;
std::string expected=longRule();unsigned calls=0;bool mutation=false;
FragmentFixture(){enroll(fleet,publisher);bind(fleet,mesh,profiles);}
uint32_t reserved()const{return storage::readLE32(disk.files.at(Path).data()+56);}
std::string stats(){return config(fleet,"get fleet.stats");}
unsigned feed(Packet& packet,const TransportKey* scope=nullptr,uint32_t advance_ms=1000){
fake_ms+=advance_ms;const unsigned before=calls;
const auto image=disk.files.at(Path);const unsigned writes=disk.writes,verifies=verify_calls;
fleet.receive(&packet,mesh,scope);
assert(disk.files.at(Path)==image&&disk.writes==writes&&verify_calls==verifies);
fleet.service(mesh,profiles,"long:rule",[&](uint32_t seq,const char* text,char* reply){
++calls;assert(reserved()==seq&&profiles.command);
assert(mesh.scoped_profile==packet.radio_profile&&mesh.scoped_generation==packet.radio_generation);
assert(strlen(text)==expected.size()&&!strcmp(text,expected.c_str()));
assert(text[expected.size()]==0);if(mutation)profiles.stage();strcpy(reply,"OK");
});
assert(!profiles.command&&mesh.scoped_profile==0&&mesh.scoped_generation==0);return calls-before;
}
void tick(uint32_t advance_ms){
fake_ms+=advance_ms;fleet.service(mesh,profiles,"node",[](uint32_t,const char*,char*){assert(false);});
}
void partial(){assert(calls==0&&reserved()==0&&mesh.queued==0&&!profiles.mutation);
assert(stats().find("accepted=0")!=std::string::npos&&stats().find("parts=1")!=std::string::npos);}
};
static int fragmentScenario(const std::string& scenario){
FragmentFixture f;const uint32_t seq=f.mesh.clock.now;
auto bytes=envelope(f.publisher,seq,f.expected);assert(bytes.size()==308&&f.expected.size()==230);
Packet a=part(bytes,0),b=part(bytes,1);assert(a.payload_len==179&&b.payload_len==179);
if(scenario=="fragments_orders"){
for(unsigned reverse=0;reverse<2;++reverse){
FragmentFixture x;auto encoded=envelope(x.publisher,seq,x.expected);
Packet first=part(encoded,reverse?1:0),second=part(encoded,reverse?0:1);
const auto old=x.disk.files.at(Path);const unsigned verifies=verify_calls,writes=x.disk.writes;
assert(x.feed(first)==0);x.partial();assert(x.disk.files.at(Path)==old&&x.disk.writes==writes);
assert(verify_calls==verifies&&x.stats().find("rejected=0")!=std::string::npos);
assert(x.feed(second)==1&&verify_calls==verifies+1&&x.reserved()==seq&&x.mesh.queued==1);
assert(x.stats().find("parts=0")!=std::string::npos&&last_jitter_max==60500);
assert(x.feed(first)==0&&x.feed(second)==0&&x.calls==1);
FleetChannel rebooted(&x.disk);unsigned calls=0;
for(Packet* packet:{&first,&second}){fake_ms+=1000;rebooted.receive(packet,x.mesh);
rebooted.service(x.mesh,x.profiles,"node",[&](uint32_t,const char*,char*){++calls;});}
assert(calls==0&&x.reserved()==seq);
}
}else if(scenario=="fragments_duplicates"){
const unsigned verifies=verify_calls,writes=f.disk.writes;
assert(f.feed(b)==0&&f.feed(b)==0&&f.feed(b)==0);f.partial();
assert(verify_calls==verifies&&f.disk.writes==writes&&f.stats().find("rejected=0")!=std::string::npos);
assert(f.feed(a)==1&&f.feed(a)==0&&f.feed(b)==0&&f.calls==1&&f.mesh.queued==1);
}else if(scenario=="fragments_minimum_size"){
f.expected="set flood.rule.1 05 c="+std::string(64,'1')+" d";
assert(f.expected.size()==88);auto small=envelope(f.publisher,seq,f.expected);
assert(small.size()==166);Packet first=part(small,0),second=part(small,1);
assert(first.payload_len==179&&second.payload_len==35);
assert(f.feed(second)==0);f.partial();assert(f.feed(first)==1&&f.reserved()==seq);
FleetCommand::Targets all;assert(FleetCommand::parseTargets("all",3,all));
uint8_t output[308];memset(output,0xA5,sizeof(output));
const std::string too_long=longRule()+" ";assert(too_long.size()==231);
assert(!FleetCommand::encode(f.publisher,channel_key,seq+1,seq+301,all,too_long.c_str(),output,sizeof(output)));
for(uint8_t byte:output)assert(byte==0xA5);
}else if(scenario=="fragments_expired_during_collection"){
assert(f.feed(a)==0);f.partial();f.mesh.clock.now+=301;
assert(f.feed(b)==0&&f.reserved()==0&&f.mesh.queued==0&&f.calls==0);
assert(f.stats().find("parts=0")!=std::string::npos);
auto retry=envelope(f.publisher,f.mesh.clock.now,f.expected);
Packet first=part(retry,0),second=part(retry,1);
assert(f.feed(first)==0&&f.feed(second)==1&&f.reserved()==f.mesh.clock.now);
}else if(scenario=="fragments_conflicts"){
assert(f.feed(a)==0);f.partial();
Packet changed=changePart(a,[](uint8_t* frame){frame[11+90]^=1;});
assert(f.feed(changed)==0);f.partial();
Packet length=changePart(a,[](uint8_t* frame){storage::writeLE16(frame+8,307);});
assert(f.feed(length)==0);f.partial();
Packet index=changePart(a,[](uint8_t* frame){frame[10]=2;});
assert(f.feed(index)==0);f.partial();
assert(f.feed(b)==1&&f.calls==1&&f.reserved()==seq);
}else if(scenario=="fragments_fresh_retry"){
assert(f.feed(a)==0);f.partial();auto newer=envelope(f.publisher,seq+1,f.expected);
Packet fresh_first=part(newer,1),fresh_second=part(newer,0);
assert(f.feed(fresh_first)==0);f.partial();
assert(f.feed(b)==0);f.partial();assert(f.feed(fresh_second)==1&&f.reserved()==seq+1);
assert(f.feed(a)==0&&f.feed(b)==0&&f.calls==1&&f.mesh.queued==1);
}else if(scenario=="fragments_tamper"){
Packet mac=a;mac.payload[1]^=1;assert(f.feed(mac)==0&&f.stats().find("parts=0")!=std::string::npos);
assert(f.feed(a)==0);f.partial();const auto saved=f.disk.files.at(Path);
Packet signature=changePart(b,[](uint8_t* frame){frame[11+153]^=1;});
assert(f.feed(signature)==0&&f.calls==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0);
assert(f.stats().find("parts=0")!=std::string::npos);
assert(f.feed(b)==0&&f.feed(a)==1&&f.calls==1);
}else if(scenario=="fragments_wrong_signer"){
LocalIdentity stranger;auto forged=envelope(stranger,seq,f.expected);
Packet first=part(forged,0),second=part(forged,1);
assert(f.feed(first)==0);f.partial();assert(f.feed(second)==0&&f.reserved()==0&&f.mesh.queued==0);
assert(f.stats().find("parts=0")!=std::string::npos&&f.feed(a)==0&&f.feed(b)==1);
}else if(scenario=="fragments_fixed_timeout"){
assert(f.feed(a)==0);f.partial();assert(f.feed(a,nullptr,299999)==0);f.partial();
f.tick(1);assert(f.stats().find("parts=0")!=std::string::npos&&f.reserved()==0&&f.mesh.queued==0);
assert(f.feed(b)==0);f.partial();assert(f.feed(a)==1);
}else if(scenario=="fragments_timeout_wrap"){
fake_ms=UINT32_MAX-200000;assert(f.feed(b,nullptr,0)==0);f.partial();f.tick(299999);f.partial();
f.tick(1);assert(f.stats().find("parts=0")!=std::string::npos&&f.reserved()==0);
assert(f.feed(a)==0&&f.feed(b)==1);
}else if(scenario=="fragments_sequence_bounds"){
for(uint32_t invalid:{FleetCommand::MinEpoch-1,seq+61,seq-601}){
Packet changed=changePart(a,[&](uint8_t* frame){storage::writeLE32(frame+4,invalid);});
assert(f.feed(changed)==0&&f.stats().find("parts=0")!=std::string::npos&&f.reserved()==0);
}
Packet mismatch_a=changePart(a,[&](uint8_t* frame){storage::writeLE32(frame+4,seq+1);});
Packet mismatch_b=changePart(b,[&](uint8_t* frame){storage::writeLE32(frame+4,seq+1);});
const unsigned verifies=verify_calls;assert(f.feed(mismatch_a)==0&&f.feed(mismatch_b)==0);
assert(f.reserved()==0&&verify_calls==verifies&&f.stats().find("parts=0")!=std::string::npos);
assert(f.feed(a)==0&&f.feed(b)==1);
}else if(scenario=="fragments_target"){
LocalIdentity stranger;f.expected="set flood.rule.1 any "+std::string(150,' ')+"drop";
auto other=envelope(f.publisher,seq,f.expected,publicHex(stranger));
Packet other_a=part(other,0),other_b=part(other,1);assert(f.feed(other_a)==0);f.partial();
assert(f.feed(other_b)==0&&f.reserved()==0&&f.mesh.queued==0);
auto mine=envelope(f.publisher,seq,f.expected,publicHex(f.mesh.self_id));
Packet mine_a=part(mine,0),mine_b=part(mine,1);
assert(f.feed(mine_b)==0&&f.feed(mine_a)==1&&last_jitter_max==1500);
}else if(scenario=="fragments_config_reboot"){
assert(f.feed(a)==0);f.partial();assert(config(f.fleet,std::string("set fleet.channel ")+Key).rfind("OK",0)==0);
assert(f.stats().find("parts=0")!=std::string::npos&&f.feed(b)==0);f.partial();
assert(config(f.fleet,"set fleet.controller "+publicHex(f.publisher)).rfind("OK",0)==0);
assert(f.stats().find("parts=0")!=std::string::npos&&f.feed(a)==0);f.partial();
FleetChannel rebooted(&f.disk);unsigned calls=0;fake_ms+=1000;rebooted.receive(&b,f.mesh);
rebooted.service(f.mesh,f.profiles,"node",[&](uint32_t,const char*,char*){++calls;});
assert(calls==0&&f.reserved()==0&&f.mesh.queued==0);
fake_ms+=1000;rebooted.receive(&a,f.mesh);
rebooted.service(f.mesh,f.profiles,"node",[&](uint32_t sequence,const char* text,char* reply){
++calls;assert(sequence==seq&&!strcmp(text,f.expected.c_str()));strcpy(reply,"OK");});
assert(calls==1&&f.reserved()==seq);
}else if(scenario=="fragments_reserve_failure"){
const auto saved=f.disk.files.at(Path);f.disk.fail_write=true;const unsigned writes=f.disk.writes;
assert(f.feed(b)==0);f.partial();assert(f.disk.writes==writes&&f.disk.files.at(Path)==saved);
assert(f.feed(a)==0&&f.disk.files.at(Path)==saved&&f.mesh.queued==0&&f.calls==0);
assert(config(f.fleet,"get fleet.channel").find("store=error")!=std::string::npos);
f.disk.fail_write=false;assert(f.feed(a)==0&&f.feed(b)==0);
FleetChannel rebooted(&f.disk);unsigned calls=0;
for(Packet* packet:{&a,&b}){fake_ms+=1000;rebooted.receive(packet,f.mesh);
rebooted.service(f.mesh,f.profiles,"node",[&](uint32_t,const char*,char* reply){++calls;strcpy(reply,"OK");});}
assert(calls==1&&f.reserved()==seq);
}else if(scenario=="fragments_scoped_radio_ack"){
f.mutation=true;f.mesh.fanout=true;TransportKey scope;memset(scope.key,0x12,sizeof(scope.key));
a.radio_profile=0;a.radio_generation=9;b.radio_profile=1;b.radio_generation=27;b.path_hash_size=3;
b.scoped=true;b.transport_codes[0]=scope.calcTransportCode(&b);
assert(f.feed(a)==0);f.partial();assert(f.feed(b,&scope)==1&&f.fleet.waiting()&&f.profiles.mutation);
assert(f.mesh.manager.outbound.size()==2&&f.mesh.ack_scoped&&f.mesh.ack_path_size==3);
assert(f.mesh.ack_codes[0]==scope.calcTransportCode(f.mesh.manager.outbound[0]));
assert(f.mesh.ack_codes[0]!=b.transport_codes[0]&&last_jitter_max==15500);
f.fleet.complete(f.mesh.manager.removeOutboundByIdx(0),f.profiles);
assert(f.fleet.waiting()&&f.profiles.mutation&&f.profiles.commits==0);
f.fleet.fail(f.mesh.manager.removeOutboundByIdx(0),f.profiles);
assert(!f.fleet.waiting()&&!f.profiles.mutation&&f.profiles.commits==1);
assert(f.feed(a)==0&&f.feed(b,&scope)==0&&f.calls==1);
}else if(scenario=="fragments_unknown_scope"){
f.mutation=true;a.scoped=true;a.transport_codes[0]=123;
assert(f.feed(b)==0&&f.feed(a)==1&&f.mesh.queued==0&&f.profiles.rollbacks==1);
assert(f.reserved()==seq&&!f.fleet.waiting()&&!f.profiles.mutation);
}else if(scenario=="fragments_single_interrupt"){
assert(f.feed(a)==0);f.partial();f.expected="get radio2";
Packet single=targetsCommand(f.publisher,seq+1,"all",f.expected.c_str());
assert(f.feed(single)==1&&f.reserved()==seq+1&&f.stats().find("parts=0")!=std::string::npos);
assert(f.feed(b)==0&&f.feed(a)==0&&f.calls==1&&f.mesh.queued==1);
}else if(scenario=="fragments_malformed_frame"){
for(unsigned damage=0;damage<4;++damage){
Packet malformed=changePart(a,[&](uint8_t* frame){
if(damage==0)storage::writeLE16(frame+8,309);
else if(damage==1)storage::writeLE16(frame+8,165);
else if(damage==2)frame[10]=2;
else frame[-1]=uint8_t(FleetCommand::MaxPayloadLength-1);
});assert(f.feed(malformed)==0&&f.stats().find("parts=0")!=std::string::npos);
}
assert(f.reserved()==0&&f.mesh.queued==0&&f.feed(a)==0&&f.feed(b)==1);
}else assert(false);
printf("fleet runtime %s passed\n",scenario.c_str());return 0;
}
int main(int argc,char** argv){
assert(argc==2);const std::string scenario=argv[1];fs::FS fs;Mesh mesh;RadioProfileCLI profiles;
assert(argc==2);const std::string scenario=argv[1];
if(scenario.rfind("fragments_",0)==0)return fragmentScenario(scenario);
fs::FS fs;Mesh mesh;RadioProfileCLI profiles;
FleetChannel fleet(&fs);LocalIdentity publisher;bind(fleet,mesh,profiles);
if(scenario=="enrollment"){
Packet packet=command(publisher,mesh.clock.now);assert(apply(fleet,fs,mesh,profiles,packet)==0);
@@ -588,6 +810,43 @@ int main(int argc,char** argv){
assert(apply(fleet,fs,mesh,profiles,mutation)==0);
FleetChannel rebooted(&fs);
assert(apply(rebooted,fs,mesh,profiles,packet)==0&&apply(rebooted,fs,mesh,profiles,mutation)==0);
}else if(scenario=="fmc2_maximum_command"){
enroll(fleet,publisher);
const std::string secret(64,'1');
const std::string text="set flood.rule.1 5 c="+secret+" d";
const std::string oversized="set flood.rule.1 05 c="+secret+" d";
assert(text.size()==87&&text.size()==FleetCommand::SinglePacketMaxCommandLength);
assert(oversized.size()==88&&FleetCommand::commandAllowed(oversized.c_str()));
FleetCommand::Targets all;assert(FleetCommand::parseTargets("all",3,all));
uint8_t output[184];memset(output,0xA5,sizeof(output));
assert(FleetCommand::encode(publisher,channel_key,mesh.clock.now,mesh.clock.now+300,all,
oversized.c_str(),output,sizeof(output))==166);
assert(output[166]==0xA5);
Packet packet=targetsCommand(publisher,mesh.clock.now,"all",text.c_str());
uint8_t plaintext[184]={};
const int size=Utils::MACThenDecrypt(channel_key,plaintext,packet.payload+1,packet.payload_len-1);
assert(size==176&&packet.payload_len==179&&packet.payload_len<=MAX_PACKET_PAYLOAD);
assert(plaintext[2]==FleetCommand::MaxPayloadLength&&plaintext[3+13]==87);
assert(!memcmp(plaintext+3,"FMC2",4)&&plaintext[3+12]==0);
assert(!memcmp(plaintext+3+14,text.data(),text.size()));
for(unsigned i=3+plaintext[2];i<unsigned(size);++i)assert(plaintext[i]==0);
const auto previous=fs.files.at(Path);unsigned calls=0;
struct Capture {char text[88]{};char guard='!';} capture;
const auto dispatch=[&](FleetChannel& receiver,Packet& request){
const unsigned before=calls;receiver.receive(&request,mesh);
receiver.service(mesh,profiles,"long-rule",[&](uint32_t seq,const char* command,char* reply){
++calls;assert(storage::readLE32(fs.files.at(Path).data()+56)==seq);
assert(strlen(command)==text.size()&&!strcmp(command,text.c_str())&&command[86]=='d'&&command[87]==0);
memcpy(capture.text,command,text.size()+1);assert(capture.guard=='!');strcpy(reply,"OK");
});return calls-before;
};
plaintext[3+14+86]='s';Packet changed=raw(plaintext,size);
assert(dispatch(fleet,changed)==0&&fs.files.at(Path)==previous&&mesh.queued==0);
Packet too_long=compactUnchecked(publisher,mesh.clock.now,oversized.c_str());
assert(too_long.payload_len==179&&dispatch(fleet,too_long)==0&&fs.files.at(Path)==previous);
assert(dispatch(fleet,packet)==1&&last_jitter_max==60500&&mesh.queued==1);
assert(!strcmp(capture.text,text.c_str())&&capture.guard=='!');
assert(dispatch(fleet,packet)==0);FleetChannel rebooted(&fs);assert(dispatch(rebooted,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);
@@ -770,6 +1029,9 @@ class SHA256 : public SHA256Base {{ public:
def test_actual_zero_target_sender_reaches_receiver_and_keeps_ack_and_reboot_replay_safety(self):
self.scenario("fmc2_encoded_all")
def test_single_packet_87_byte_body_is_intact_signed_and_88_bytes_require_fragmentation(self):
self.scenario("fmc2_maximum_command")
def test_new_public_key_prefix_envelope_dispatches_and_staggers_possible_multiple_acks(self):
self.scenario("fmc2_short_prefix")
@@ -785,6 +1047,60 @@ class SHA256 : public SHA256Base {{ public:
def test_existing_remote_radio_mutation_blocks_fleet_without_reserving_sequence(self):
self.scenario("existing_mutation")
def test_two_packets_deliver_exact_230_byte_command_in_either_order_once_across_reboot(self):
self.scenario("fragments_orders")
def test_identical_fragments_never_repeat_execution_or_reserve_partial_requests(self):
self.scenario("fragments_duplicates")
def test_smallest_fragmented_command_uses_a_short_second_packet_and_231_bytes_are_rejected(self):
self.scenario("fragments_minimum_size")
def test_command_expiring_between_parts_never_dispatches_and_fresh_retry_can_succeed(self):
self.scenario("fragments_expired_during_collection")
def test_conflicting_parts_cannot_destroy_a_valid_incomplete_transfer(self):
self.scenario("fragments_conflicts")
def test_fresh_retry_preempts_missing_part_and_delayed_old_parts_cannot_pollute_it(self):
self.scenario("fragments_fresh_retry")
def test_mac_and_last_signature_byte_tampering_never_authorize_partial_commands(self):
self.scenario("fragments_tamper")
def test_fragmented_request_from_another_real_signing_identity_is_rejected(self):
self.scenario("fragments_wrong_signer")
def test_fragment_deadline_is_fixed_despite_duplicates_and_missing_part(self):
self.scenario("fragments_fixed_timeout")
def test_fragment_deadline_handles_millisecond_timer_wrap(self):
self.scenario("fragments_timeout_wrap")
def test_fragment_sequence_plausibility_and_signed_sequence_match_precede_reservation(self):
self.scenario("fragments_sequence_bounds")
def test_fragmented_nonmatching_target_preserves_sequence_for_matching_retry(self):
self.scenario("fragments_target")
def test_channel_controller_changes_and_reboot_clear_partial_transfers(self):
self.scenario("fragments_config_reboot")
def test_fragment_completion_storage_failure_has_no_dispatch_or_reply(self):
self.scenario("fragments_reserve_failure")
def test_final_fragment_scope_and_radio_profile_feed_both_ack_copy_commit_barrier(self):
self.scenario("fragments_scoped_radio_ack")
def test_fragmented_radio_mutation_with_unknown_final_scope_rolls_back(self):
self.scenario("fragments_unknown_scope")
def test_new_valid_single_packet_can_interrupt_an_incomplete_two_packet_transfer(self):
self.scenario("fragments_single_interrupt")
def test_malformed_fragment_size_index_and_padding_never_allocate_partial_intent(self):
self.scenario("fragments_malformed_frame")
if __name__ == "__main__":
unittest.main()
+190 -19
View File
@@ -2,8 +2,10 @@
#include <Ed25519.h>
#include <helpers/FleetCommand.h>
#include <SHA256.h>
#include <algorithm>
#include <array>
#include <string>
#include <utility>
#include <vector>
namespace {
@@ -36,7 +38,7 @@ protected:
uint32_t sequence = Now,
uint32_t expires = Now + 120,
const uint8_t* target = nullptr) {
std::vector<uint8_t> bytes(Fleet::MaxPayloadLength);
std::vector<uint8_t> bytes(Fleet::MaxEnvelopeLength);
const size_t size = Fleet::encode(publisher, ChannelKey, sequence, expires,
target ? target : all.data(), command, bytes.data(), bytes.size());
bytes.resize(size);
@@ -50,7 +52,7 @@ protected:
std::vector<uint8_t> encodeTargets(const Fleet::Targets& targets,
const char* command = "get radio2") {
std::vector<uint8_t> bytes(Fleet::MaxPayloadLength);
std::vector<uint8_t> bytes(Fleet::MaxEnvelopeLength);
const size_t size = Fleet::encode(publisher, ChannelKey, Now, Now + 120,
targets, command, bytes.data(), bytes.size());
bytes.resize(size);
@@ -153,7 +155,7 @@ TEST_F(FleetCommandTest, ScheduledCommandsRejectMalformedTimesFieldsAndSelectors
+ std::string(Fleet::MaxCommandLength - prefix.size() - suffix.size(), '0') + suffix;
ASSERT_EQ(Fleet::MaxCommandLength, maximum.size());
EXPECT_TRUE(Fleet::commandAllowed(maximum.c_str()));
EXPECT_TRUE(decode(encode(maximum.c_str())));
EXPECT_TRUE(decode(encodeTargets(Fleet::Targets{}, maximum.c_str())));
const std::string oversized = prefix + '0' + maximum.substr(prefix.size());
EXPECT_FALSE(Fleet::commandAllowed(oversized.c_str()));
}
@@ -289,21 +291,28 @@ TEST_F(FleetCommandTest, FailedSignatureVerificationNeverReturnsCommand) {
EXPECT_STREQ("", decoded.command);
}
TEST_F(FleetCommandTest, FullPayloadLimitFitsWithoutOverflow) {
TEST_F(FleetCommandTest, LegacyPayloadLimitFitsAndCannotBeRaisedByDestinationCapacity) {
const std::string prefix = "set flood.rule.1 ";
const std::string maximum = prefix + std::string(Fleet::MaxCommandLength - prefix.size(), 'a');
const std::string maximum = prefix + std::string(Fleet::FixedTargetMaxCommandLength - prefix.size(), 'a');
ASSERT_TRUE(Fleet::commandAllowed(maximum.c_str()));
const auto bytes = encode(maximum.c_str());
ASSERT_EQ(Fleet::MaxPayloadLength, bytes.size());
ASSERT_EQ(Fleet::MaxEnvelopeLength, bytes.size());
EXPECT_TRUE(decode(bytes));
EXPECT_STREQ(maximum.c_str(), decoded.command);
EXPECT_FALSE(Fleet::commandAllowed((maximum + "a").c_str()));
EXPECT_TRUE(Fleet::commandAllowed((maximum + "a").c_str()));
EXPECT_TRUE(encode((maximum + "a").c_str()).empty());
std::array<uint8_t, Fleet::MaxPayloadLength + 2> output;
std::array<uint8_t, Fleet::MaxEnvelopeLength + 64> output;
output.fill(0xA5);
EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 10, all.data(),
maximum.c_str(), output.data() + 1, 164));
maximum.c_str(), output.data() + 1, Fleet::MaxEnvelopeLength - 1));
for (uint8_t value : output) EXPECT_EQ(0xA5, value);
for (size_t command_length : {Fleet::FixedTargetMaxCommandLength + 1, Fleet::MaxCommandLength}) {
const std::string oversized = prefix + std::string(command_length - prefix.size(), 'a');
ASSERT_TRUE(Fleet::commandAllowed(oversized.c_str()));
EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 10, all.data(),
oversized.c_str(), output.data(), output.size()));
for (uint8_t value : output) EXPECT_EQ(0xA5, value);
}
}
TEST_F(FleetCommandTest, RejectsTruncationTrailingPaddingAndWrongMagic) {
@@ -439,7 +448,7 @@ TEST_F(FleetCommandTest, TargetRecordAndPayloadBudgetsAreSeparateAndBounded) {
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;
EXPECT_FALSE(encodeTargets(targets).empty()) << count;
}
Fleet::Targets targets{};
list += ",01020304";
@@ -452,7 +461,7 @@ TEST_F(FleetCommandTest, TargetRecordAndPayloadBudgetsAreSeparateAndBounded) {
ASSERT_TRUE(Fleet::parseTargets(list.data(), list.size(), targets));
EXPECT_EQ(count * 17, targets.length);
}
EXPECT_TRUE(encodeTargets(targets).empty());
EXPECT_FALSE(encodeTargets(targets).empty());
list += ',' + full;
EXPECT_FALSE(Fleet::parseTargets(list.data(), list.size(), targets));
}
@@ -485,17 +494,25 @@ TEST_F(FleetCommandTest, CompactBroadcastCapacityAndCommandLimitRemainBounded) {
const std::string prefix = "set flood.rule.1 ";
const std::string maximum = prefix + std::string(Fleet::MaxCommandLength - prefix.size(), 'a');
const auto bytes = encodeTargets(targets, maximum.c_str());
ASSERT_EQ(150U, bytes.size());
EXPECT_EQ(15U, encode(maximum.c_str()).size() - bytes.size());
ASSERT_EQ(230U, maximum.size());
ASSERT_EQ(Fleet::MaxEnvelopeLength, bytes.size());
ASSERT_TRUE(Fleet::commandAllowed(maximum.c_str()));
EXPECT_TRUE(decode(bytes));
EXPECT_STREQ(maximum.c_str(), decoded.command);
EXPECT_TRUE(decoded.broadcast);
std::array<uint8_t, Fleet::MaxPayloadLength> output;
std::array<uint8_t, Fleet::MaxEnvelopeLength> output;
output.fill(0xA5);
EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 120, targets,
maximum.c_str(), output.data(), bytes.size() - 1));
for (uint8_t byte : output) EXPECT_EQ(0xA5, byte);
const std::string oversized = maximum + 'a';
EXPECT_FALSE(Fleet::commandAllowed(oversized.c_str()));
EXPECT_TRUE(encodeTargets(targets, oversized.c_str()).empty());
for (const char invalid : {'\n', ';', char(0x80)}) {
const std::string malformed = maximum.substr(0, maximum.size() - 1) + invalid;
EXPECT_FALSE(Fleet::commandAllowed(malformed.c_str()));
EXPECT_TRUE(encodeTargets(targets, malformed.c_str()).empty());
}
for (size_t length = 0; length < bytes.size(); ++length) {
const std::vector<uint8_t> truncated(bytes.begin(), bytes.begin() + length);
EXPECT_FALSE(decode(truncated));
@@ -565,7 +582,7 @@ TEST_F(FleetCommandTest, AnyMatchingEntryIsAcceptedButMalformedLaterEntryIsRejec
bytes[13 + targets.length] = 0;
EXPECT_FALSE(decode(bytes));
bytes = encodeTargets(targets);
bytes[13 + targets.length] = 73;
bytes[13 + targets.length] = Fleet::MaxCommandLength + 1;
EXPECT_FALSE(decode(bytes));
for (size_t length = 0; length < bytes.size(); ++length) {
const std::vector<uint8_t> truncated(bytes.begin(), bytes.begin() + length);
@@ -615,21 +632,52 @@ TEST_F(FleetCommandTest, InvalidExternalTargetStructuresNeverWriteOutput) {
}
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 std::string prefix = "set flood.rule.1 ";
const std::string maximum = prefix + std::string(
Fleet::MaxCommandLength - targets.length - prefix.size(), 'a');
EXPECT_EQ(Fleet::MaxEnvelopeLength, 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;
std::array<uint8_t, Fleet::MaxEnvelopeLength> 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, EveryTargetFormUsesItsExactCommandBudgetWithoutPartialWrites) {
char full[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(full, publisher.pub_key, PUB_KEY_SIZE);
const std::string short_key(full, 8), long_key(full, 12);
const std::string prefix = "set flood.rule.1 ";
const std::vector<std::pair<std::string, size_t>> cases = {
{"all", 230}, {short_key, 225}, {long_key, 223}, {full, 215},
{short_key + ',' + short_key, 220}, {short_key + ',' + long_key, 218},
{std::string(full) + ',' + short_key, 208}, {std::string(full) + ',' + full, 196}
};
for (const auto& item : cases) {
SCOPED_TRACE(item.first);
Fleet::Targets targets{};
ASSERT_TRUE(Fleet::parseTargets(item.first.data(), item.first.size(), targets));
const std::string maximum = prefix + std::string(item.second - prefix.size(), 'a');
ASSERT_TRUE(Fleet::commandAllowed(maximum.c_str()));
const auto bytes = encodeTargets(targets, maximum.c_str());
ASSERT_EQ(Fleet::MaxEnvelopeLength, bytes.size());
ASSERT_TRUE(decode(bytes));
EXPECT_STREQ(maximum.c_str(), decoded.command);
const std::string oversized = maximum + 'a';
EXPECT_TRUE(encodeTargets(targets, oversized.c_str()).empty());
std::array<uint8_t, Fleet::MaxEnvelopeLength + 64> output;
output.fill(0xA5);
EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 120, targets,
oversized.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;
@@ -641,6 +689,129 @@ TEST_F(FleetCommandTest, UnrepresentableZeroDigestNeverBecomesAllDuringLegacyEnc
EXPECT_FALSE(decode(bytes));
}
TEST_F(FleetCommandTest, SinglePacketBoundariesRemainExactBeforeFragmentation) {
char full[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(full, publisher.pub_key, PUB_KEY_SIZE);
const std::string prefix = "set flood.rule.1 ";
for (const auto& item : std::vector<std::pair<std::string, size_t>>{
{"all", 87}, {std::string(full, 8), 82}, {std::string(full, 12), 80}, {full, 72}}) {
Fleet::Targets targets{};
ASSERT_TRUE(Fleet::parseTargets(item.first.data(), item.first.size(), targets));
const std::string command = prefix + std::string(item.second - prefix.size(), 'a');
const auto single = encodeTargets(targets, command.c_str());
ASSERT_EQ(Fleet::MaxPayloadLength, single.size());
std::array<uint8_t, Fleet::MaxPayloadLength> output;
output.fill(0xA5);
EXPECT_EQ(0U, Fleet::fragment(single.data(), single.size(), 0, output.data(), output.size()));
for (uint8_t byte : output) EXPECT_EQ(0xA5, byte);
const auto longer = encodeTargets(targets, (command + 'a').c_str());
ASSERT_EQ(Fleet::MaxPayloadLength + 1, longer.size());
EXPECT_EQ(Fleet::MaxPayloadLength,
Fleet::fragment(longer.data(), longer.size(), 0, output.data(), output.size()));
EXPECT_EQ(Fleet::FragmentHeaderSize + longer.size() - Fleet::FragmentDataLength,
Fleet::fragment(longer.data(), longer.size(), 1, output.data(), output.size()));
}
}
TEST_F(FleetCommandTest, TwoExactFragmentsReassembleInEitherOrderAndVerifyOnlyWholeEnvelope) {
const std::string prefix = "set flood.rule.1 ";
for (size_t command_length : {88U, 89U, 229U, 230U}) {
const std::string command = prefix + std::string(command_length - prefix.size(), 'a');
const auto envelope = encodeTargets(Fleet::Targets{}, command.c_str());
ASSERT_EQ(Fleet::MinHeaderSize + command_length + Fleet::SignatureSize, envelope.size());
std::array<std::vector<uint8_t>, 2> fragments;
for (uint8_t index = 0; index < 2; ++index) {
fragments[index].resize(Fleet::MaxPayloadLength);
const size_t size = Fleet::fragment(envelope.data(), envelope.size(), index,
fragments[index].data(), fragments[index].size());
ASSERT_GT(size, 0U);
ASSERT_LE(size, Fleet::MaxPayloadLength);
fragments[index].resize(size);
}
for (bool reverse : {false, true}) {
std::vector<uint8_t> assembled(envelope.size());
const unsigned before = g_mock_ed25519_verify_calls;
for (unsigned step = 0; step < 2; ++step) {
const uint8_t index = reverse ? 1 - step : step;
Fleet::Fragment part;
ASSERT_TRUE(Fleet::parseFragment(fragments[index].data(), fragments[index].size(), part));
EXPECT_EQ(Now, part.sequence);
EXPECT_EQ(envelope.size(), part.total_length);
EXPECT_EQ(index, part.index);
EXPECT_EQ(index ? envelope.size() - Fleet::FragmentDataLength : Fleet::FragmentDataLength,
part.length);
EXPECT_EQ(0, memcmp(part.data, envelope.data() + index * Fleet::FragmentDataLength, part.length));
std::copy_n(part.data, part.length, assembled.begin() + index * Fleet::FragmentDataLength);
EXPECT_FALSE(decode(fragments[index])); // A fragment cannot authorize a command.
EXPECT_EQ(before, g_mock_ed25519_verify_calls);
Fleet::Fragment duplicate;
ASSERT_TRUE(Fleet::parseFragment(fragments[index].data(), fragments[index].size(), duplicate));
EXPECT_EQ(part.data, duplicate.data);
EXPECT_EQ(part.length, duplicate.length);
}
EXPECT_EQ(envelope, assembled);
ASSERT_TRUE(decode(assembled));
EXPECT_EQ(before + 1, g_mock_ed25519_verify_calls);
EXPECT_STREQ(command.c_str(), decoded.command);
EXPECT_TRUE(decoded.broadcast);
}
}
}
TEST_F(FleetCommandTest, FragmentEncoderRejectsInvalidInputBeforeWriting) {
const std::string command = "set flood.rule.1 " + std::string(72, 'a');
auto envelope = encodeTargets(Fleet::Targets{}, command.c_str());
ASSERT_GT(envelope.size(), Fleet::MaxPayloadLength);
std::array<uint8_t, Fleet::MaxPayloadLength + 64> output;
output.fill(0xA5);
for (size_t length : {size_t(0), Fleet::MaxPayloadLength, Fleet::MaxEnvelopeLength + 1})
EXPECT_EQ(0U, Fleet::fragment(envelope.data(), length, 0, output.data(), output.size()));
EXPECT_EQ(0U, Fleet::fragment(nullptr, envelope.size(), 0, output.data(), output.size()));
EXPECT_EQ(0U, Fleet::fragment(envelope.data(), envelope.size(), 0, nullptr, output.size()));
for (uint8_t index : {uint8_t(2), uint8_t(255)})
EXPECT_EQ(0U, Fleet::fragment(envelope.data(), envelope.size(), index, output.data(), output.size()));
EXPECT_EQ(0U, Fleet::fragment(envelope.data(), envelope.size(), 0, output.data(), 164));
EXPECT_EQ(0U, Fleet::fragment(envelope.data(), envelope.size(), 1, output.data(),
Fleet::FragmentHeaderSize + envelope.size() - Fleet::FragmentDataLength - 1));
envelope[0] = 'X';
EXPECT_EQ(0U, Fleet::fragment(envelope.data(), envelope.size(), 0, output.data(), output.size()));
for (uint8_t byte : output) EXPECT_EQ(0xA5, byte);
}
TEST_F(FleetCommandTest, FragmentParserRejectsMalformedLengthIndexAndMagicAndClearsOutput) {
const std::string command = "set flood.rule.1 " + std::string(72, 'a');
const auto envelope = encodeTargets(Fleet::Targets{}, command.c_str());
std::vector<uint8_t> valid(Fleet::MaxPayloadLength);
ASSERT_EQ(valid.size(), Fleet::fragment(envelope.data(), envelope.size(), 0, valid.data(), valid.size()));
const auto reject = [&](const uint8_t* bytes, size_t length) {
Fleet::Fragment part{123, 300, 1, valid.data(), 100};
EXPECT_FALSE(Fleet::parseFragment(bytes, length, part));
EXPECT_EQ(0U, part.sequence); EXPECT_EQ(0, part.total_length); EXPECT_EQ(0, part.index);
EXPECT_EQ(nullptr, part.data); EXPECT_EQ(0U, part.length);
};
reject(nullptr, valid.size());
for (size_t length = 0; length < valid.size(); ++length) reject(valid.data(), length);
auto malformed = valid;
malformed.push_back(0);
reject(malformed.data(), malformed.size());
malformed = valid; malformed[0] = 'X'; reject(malformed.data(), malformed.size());
for (uint16_t total : {uint16_t(0), uint16_t(165), uint16_t(309), uint16_t(65535)}) {
malformed = valid; malformed[8] = uint8_t(total); malformed[9] = uint8_t(total >> 8);
reject(malformed.data(), malformed.size());
}
for (uint8_t index : {uint8_t(1), uint8_t(2), uint8_t(255)}) {
malformed = valid; malformed[10] = index; reject(malformed.data(), malformed.size());
}
valid.resize(Fleet::MaxPayloadLength);
valid.resize(Fleet::fragment(envelope.data(), envelope.size(), 1, valid.data(), valid.size()));
ASSERT_FALSE(valid.empty());
malformed = valid; malformed.pop_back(); reject(malformed.data(), malformed.size());
malformed = valid; malformed.push_back(0); reject(malformed.data(), malformed.size());
Fleet::Fragment part;
ASSERT_TRUE(Fleet::parseFragment(valid.data(), valid.size(), part));
EXPECT_EQ(envelope.size() - Fleet::FragmentDataLength, part.length);
}
} // namespace
int main(int argc, char** argv) {
+274 -21
View File
@@ -34,6 +34,8 @@ FIXTURE = r'''
#include <string>
#include <vector>
inline unsigned fleet_sign_calls = 0;
// The production Identity interface is retained. OpenSSL implements its
// signatures here so these tests cannot inherit native's always-true mock.
namespace mesh {
@@ -46,6 +48,7 @@ LocalIdentity::LocalIdentity(RNG* rng) {
EVP_PKEY_free(key);
}
void LocalIdentity::sign(uint8_t* signature, const uint8_t* message, int length) const {
++fleet_sign_calls;
EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, prv_key, 32);
EVP_MD_CTX* context = EVP_MD_CTX_new(); assert(key && context);
assert(EVP_DigestSignInit(context, nullptr, nullptr, nullptr, key) == 1);
@@ -84,26 +87,83 @@ void hex(const uint8_t* bytes, size_t size) {
int main(int argc, char** argv) {
using Fleet = mesh::FleetCommand;
if (argc == 7 && std::string(argv[1]) == "encode") {
const std::string operation = argc > 1 ? argv[1] : "";
if (argc == 7 && (operation == "encode" || operation == "encode-wide")) {
FixedRng rng; mesh::LocalIdentity publisher(&rng);
const auto key = unhex(argv[2]); assert(key.size() == Fleet::KeySize);
std::array<uint8_t, Fleet::TargetSize> target;
if (!Fleet::parseTarget(argv[3], target.data())) { std::cout << "reject\n"; return 0; }
std::array<uint8_t, Fleet::MaxPayloadLength> output;
std::array<uint8_t, Fleet::MaxEnvelopeLength + 64> output;
output.fill(0xA5);
const unsigned signed_before = fleet_sign_calls;
const size_t capacity = operation == "encode-wide" ? output.size() : Fleet::MaxEnvelopeLength;
const size_t size = Fleet::encode(publisher, key.data(), uint32_t(std::stoul(argv[4])),
uint32_t(std::stoul(argv[5])), target.data(), argv[6], output.data(), output.size());
if (!size) std::cout << "reject\n"; else hex(output.data(), size);
uint32_t(std::stoul(argv[5])), target.data(), argv[6], output.data(), capacity);
assert(size <= Fleet::MaxEnvelopeLength);
if (!size) {
assert(fleet_sign_calls == signed_before);
for (uint8_t byte : output) assert(byte == 0xA5);
std::cout << "reject\n";
} else {
assert(fleet_sign_calls == signed_before + 1);
for (size_t i = size; i < output.size(); ++i) assert(output[i] == 0xA5);
hex(output.data(), size);
}
return 0;
}
if (argc == 7 && std::string(argv[1]) == "encode2") {
if (argc == 7 && (operation == "encode2" || operation == "encode2-wide")) {
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;
std::array<uint8_t, Fleet::MaxEnvelopeLength + 64> output;
output.fill(0xA5);
const unsigned signed_before = fleet_sign_calls;
const size_t capacity = operation == "encode2-wide" ? output.size() : Fleet::MaxEnvelopeLength;
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);
uint32_t(std::stoul(argv[5])), targets, argv[6], output.data(), capacity);
assert(size <= Fleet::MaxEnvelopeLength);
if (!size) {
assert(fleet_sign_calls == signed_before);
for (uint8_t byte : output) assert(byte == 0xA5);
std::cout << "reject\n";
} else {
assert(fleet_sign_calls == signed_before + 1);
for (size_t i = size; i < output.size(); ++i) assert(output[i] == 0xA5);
hex(output.data(), size);
}
return 0;
}
if (argc == 5 && operation == "fragment") {
const auto envelope = unhex(argv[2]);
const unsigned index = unsigned(std::stoul(argv[3]));
assert(index <= 255);
const size_t capacity = std::stoul(argv[4]);
std::array<uint8_t, Fleet::MaxPayloadLength + 64> output;
assert(capacity <= output.size()); output.fill(0xA5);
const unsigned signed_before = fleet_sign_calls;
const size_t size = Fleet::fragment(envelope.data(), envelope.size(), uint8_t(index),
output.data(), capacity);
assert(fleet_sign_calls == signed_before && size <= Fleet::MaxPayloadLength);
if (!size) {
for (uint8_t byte : output) assert(byte == 0xA5);
std::cout << "reject\n";
} else {
for (size_t i = size; i < output.size(); ++i) assert(output[i] == 0xA5);
hex(output.data(), size);
}
return 0;
}
if (argc == 3 && operation == "parse-fragment") {
const auto payload = unhex(argv[2]);
Fleet::Fragment output{123, 300, 1, payload.data(), 100};
if (!Fleet::parseFragment(payload.data(), payload.size(), output)) {
assert(!output.sequence && !output.total_length && !output.index && !output.data && !output.length);
std::cout << "reject\n";
} else {
std::cout << output.sequence << '\n' << output.total_length << '\n' << unsigned(output.index) << '\n';
hex(output.data, output.length);
}
return 0;
}
if (argc == 7 && (std::string(argv[1]) == "decode" || std::string(argv[1]) == "decode2")) {
@@ -142,6 +202,13 @@ def envelope2(entries=(), command="get radio2", sequence=NOW, expires=NOW + 120,
return unsigned + publisher.sign(DOMAIN + channel + unsigned)
def fragment_frame(payload, index):
"""Independent wire framing; the signature stays in the whole envelope."""
sequence = struct.unpack("<I", payload[4:8])[0]
return (b"FMP1" + struct.pack("<IHB", sequence, len(payload), index)
+ payload[index * 154:(index + 1) * 154])
class FleetCommandCryptoTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
@@ -299,8 +366,18 @@ class FleetCommandCryptoTests(unittest.TestCase):
self.assertEqual(len(maximum), 72)
self.assertNotEqual(self.decode(envelope(maximum)), "reject")
self.assertNotEqual(self.decode(envelope2(entries, maximum)), "reject")
oversized = prefix + "0" + maximum[len(prefix):]
self.assertEqual(self.decode(envelope(oversized)), "reject")
larger = prefix + "0" + maximum[len(prefix):]
self.assertNotEqual(self.decode(envelope(larger)), "reject")
self.assertNotEqual(self.decode(envelope2(entries, larger)), "reject")
target_maximum = prefix + "0" * (218 - len(prefix) - len(suffix)) + suffix
self.assertNotEqual(self.decode(envelope2(entries, target_maximum)), "reject")
too_large = prefix + "0" + target_maximum[len(prefix):]
self.assertEqual(self.decode(envelope2(entries, too_large)), "reject")
broadcast_maximum = prefix + "0" * (230 - len(prefix) - len(suffix)) + suffix
expected = envelope2(command=broadcast_maximum)
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), "all", NOW, NOW + 120,
broadcast_maximum), expected.hex())
self.assertNotEqual(self.decode(expected), "reject")
def test_clock_management_has_real_signatures_and_no_prefix_aliases(self):
for command in ["clock", "clock sync", "time 1735689600", "time 1800000000", "time 4294967295"]:
@@ -333,12 +410,18 @@ class FleetCommandCryptoTests(unittest.TestCase):
self.assertEqual(self.decode(forged), "reject")
self.assertNotEqual(self.decode(original, now=NOW + 120), "reject")
def test_exact_and_oversized_command_boundaries(self):
maximum = "set flood.rule.1 " + "a" * (72 - len("set flood.rule.1 "))
def test_fixed_target_exact_limit_cannot_be_raised_by_destination_capacity(self):
maximum = "set flood.rule.1 " + "a" * (215 - len("set flood.rule.1 "))
accepted = envelope(maximum)
self.assertEqual(len(accepted), 165)
self.assertEqual(len(accepted), 308)
self.assertNotEqual(self.decode(accepted), "reject")
self.assertEqual(self.decode(envelope(maximum + "a")), "reject")
for operation in ["encode", "encode-wide"]:
self.assertEqual(self.run_tool(operation, CHANNEL.hex(), "all", NOW, NOW + 120, maximum),
accepted.hex())
for length in [216, 230, 231]:
command = "set flood.rule.1 " + "a" * (length - len("set flood.rule.1 "))
self.assertEqual(self.run_tool(operation, CHANNEL.hex(), "all", NOW, NOW + 120, command), "reject")
def test_valid_signature_does_not_override_clock_policy(self):
for sequence, expires, now in [
@@ -412,23 +495,193 @@ class FleetCommandCryptoTests(unittest.TestCase):
self.assertEqual(self.decode(original, publisher=OTHER_PUBLIC), "reject")
self.assertEqual(self.decode(original, channel=bytes(range(17, 33))), "reject")
def test_compact_broadcast_keeps_72_byte_command_and_packet_framing_limits(self):
def test_compact_broadcast_fits_230_byte_signed_command_and_framing_limits(self):
prefix = "set flood.rule.1 "
maximum = prefix + "a" * (72 - len(prefix))
maximum = prefix + "a" * (230 - len(prefix))
expected = envelope2(command=maximum)
actual = self.run_tool("encode2", CHANNEL.hex(), "all", NOW, NOW + 120, maximum)
self.assertEqual(actual, expected.hex())
self.assertEqual(len(expected), 150)
self.assertEqual(len(envelope(maximum)) - len(expected), 15)
self.assertNotEqual(self.decode(expected), "reject")
self.assertEqual(len(expected), 308)
PUBLISHER.public_key().verify(expected[-64:], DOMAIN + CHANNEL + expected[:-64])
for node in [PUBLIC, OTHER_PUBLIC]:
self.assertEqual(self.decode(expected, self_key=node, metadata=True),
f"{NOW}\n{NOW + 120}\n{maximum}\n1")
oversized = maximum + "a"
self.assertEqual(self.decode(envelope2(command=oversized)), "reject")
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), "all", NOW, NOW + 120, oversized), "reject")
self.assertEqual(self.run_tool("encode2-wide", CHANNEL.hex(), "all", NOW, NOW + 120, oversized), "reject")
for invalid in [b"\n", b";", b"\x80", b"\0"]:
malformed = maximum[:-1].encode("ascii") + invalid
self.assertEqual(self.decode(envelope2(command=malformed)), "reject")
for length in [0, 12, 13, 14, len(expected) - 64, len(expected) - 1]:
with self.subTest(length=length):
self.assertEqual(self.decode(expected[:length]), "reject")
self.assertEqual(self.decode(expected + b"\0"), "reject")
def test_each_target_form_has_exact_packet_budget_and_atomic_oversize_rejection(self):
short, long = PUBLIC[:4].hex(), PUBLIC[:6].hex()
digest = hashlib.sha256(PUBLIC).digest()[:16]
for token, entries, maximum_length in [
("all", [], 230), (short, [(4, PUBLIC[:4])], 225),
(long, [(6, PUBLIC[:6])], 223), (PUBLIC.hex(), None, 215),
(f"{short},{short}", [(4, PUBLIC[:4])] * 2, 220),
(f"{short},{long}", [(4, PUBLIC[:4]), (6, PUBLIC[:6])], 218),
(f"{PUBLIC.hex()},{short}", [(16, digest), (4, PUBLIC[:4])], 208),
(f"{PUBLIC.hex()},{PUBLIC.hex()}", [(16, digest)] * 2, 196)]:
with self.subTest(token=token, maximum=maximum_length):
prefix = "set flood.rule.1 "
maximum = prefix + "a" * (maximum_length - len(prefix))
expected = (envelope(maximum, target=digest) if entries is None
else envelope2(entries, maximum))
self.assertEqual(len(expected), 308)
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, maximum),
expected.hex())
self.assertNotEqual(self.decode(expected), "reject")
# The fixture verifies no writes and no signature calls on
# rejection, even when its output buffer exceeds packet size.
oversized = maximum + "a"
for operation in ["encode2", "encode2-wide"]:
self.assertEqual(self.run_tool(operation, CHANNEL.hex(), token, NOW, NOW + 120,
oversized), "reject")
signed_oversized = (envelope(oversized, target=digest) if entries is None
else envelope2(entries, oversized))
self.assertEqual(self.decode(signed_oversized), "reject")
altered = bytearray(expected)
altered[-65] = ord("b")
self.assertEqual(self.decode(altered), "reject")
def test_maximum_broadcast_signature_covers_final_byte_and_230_byte_length(self):
prefix = "set flood.rule.1 "
maximum = prefix + "a" * (230 - len(prefix))
original = envelope2(command=maximum)
self.assertNotEqual(self.decode(original), "reject")
for command in [maximum[:-1] + "b", maximum[:-1]]:
changed = envelope2(command=command)
self.assertNotEqual(self.decode(changed), "reject")
self.assertEqual(self.decode(changed[:-64] + original[-64:]), "reject")
for length_byte in [0, 87, 229, 231, 255]:
changed = bytearray(original)
changed[13] = length_byte
self.assertEqual(self.decode(changed), "reject")
for offset in range(len(original) - 64, len(original)):
with self.subTest(signature_byte=offset):
changed = bytearray(original)
changed[offset] ^= 1
self.assertEqual(self.decode(changed), "reject")
def test_single_packet_boundaries_and_first_two_fragment_command(self):
digest = hashlib.sha256(PUBLIC).digest()[:16]
for token, entries, maximum in [
("all", [], 87), (PUBLIC[:4].hex(), [(4, PUBLIC[:4])], 82),
(PUBLIC[:6].hex(), [(6, PUBLIC[:6])], 80),
(PUBLIC.hex(), None, 72)]:
with self.subTest(token=token):
prefix = "set flood.rule.1 "
command = prefix + "a" * (maximum - len(prefix))
single = (envelope(command, target=digest) if entries is None
else envelope2(entries, command))
self.assertEqual(len(single), 165)
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
command), single.hex())
self.assertNotEqual(self.decode(single), "reject")
for index in [0, 1]:
self.assertEqual(self.run_tool("fragment", single.hex(), index, 165), "reject")
longer = (envelope(command + "a", target=digest) if entries is None
else envelope2(entries, command + "a"))
self.assertEqual(len(longer), 166)
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
command + "a"), longer.hex())
self.assertNotEqual(self.decode(longer), "reject")
for index, size in [(0, 165), (1, 23)]:
actual = bytes.fromhex(self.run_tool("fragment", longer.hex(), index, 165))
self.assertEqual(actual, fragment_frame(longer, index))
self.assertEqual(len(actual), size)
def test_two_fragments_reassemble_out_of_order_with_one_real_signature(self):
for size in [166, 167, 307, 308]:
with self.subTest(size=size):
prefix = "set flood.rule.1 "
command = prefix + "a" * (size - 78 - len(prefix))
original = envelope2(command=command)
self.assertEqual(len(original), size)
# The fixture requires exactly one signature call for encode
# and no signature calls while cutting either fragment.
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), "all", NOW, NOW + 120,
command), original.hex())
parts = [bytes.fromhex(self.run_tool("fragment", original.hex(), index, 165))
for index in [0, 1]]
for index, part in enumerate(parts):
self.assertEqual(part, fragment_frame(original, index))
self.assertLessEqual(len(part), 165)
self.assertEqual(self.run_tool("parse-fragment", part.hex()),
f"{NOW}\n{size}\n{index}\n{part[11:].hex()}")
self.assertEqual(self.decode(part), "reject")
for order in [(0, 1), (1, 0), (0, 0, 1), (1, 1, 0)]:
with self.subTest(order=order):
assembly = bytearray(size)
for index in order:
part = parts[index]
assembly[index * 154:index * 154 + len(part) - 11] = part[11:]
assembly = bytes(assembly)
self.assertEqual(assembly, original)
PUBLISHER.public_key().verify(assembly[-64:], DOMAIN + CHANNEL + assembly[:-64])
for node in [PUBLIC, OTHER_PUBLIC]:
self.assertEqual(self.decode(assembly, self_key=node, metadata=True),
f"{NOW}\n{NOW + 120}\n{command}\n1")
def test_fragment_codec_bounds_and_exact_shapes_reject_without_output_writes(self):
prefix = "set flood.rule.1 "
original = envelope2(command=prefix + "a" * (88 - len(prefix)))
parts = [fragment_frame(original, index) for index in [0, 1]]
for source in [b"", original[:165], original + b"a" * (309 - len(original)),
b"FMX1" + original[4:], b"FMP1" + original[4:]]:
with self.subTest(source_length=len(source), magic=source[:4]):
self.assertEqual(self.run_tool("fragment", source.hex(), 0, 229), "reject")
for index in [2, 255]:
self.assertEqual(self.run_tool("fragment", original.hex(), index, 229), "reject")
for index, required in [(0, 165), (1, 23)]:
for capacity in [0, 10, required - 1]:
self.assertEqual(self.run_tool("fragment", original.hex(), index, capacity), "reject")
self.assertEqual(self.run_tool("fragment", original.hex(), index, 229), parts[index].hex())
malformed = [b"", b"FMP1", parts[0][:10], parts[0][:11],
parts[0][:-1], parts[0] + b"\0", parts[1][:-1], parts[1] + b"\0",
b"FMX1" + parts[0][4:]]
for total in [0, 154, 165, 309, 65535]:
malformed.append(parts[0][:8] + struct.pack("<H", total) + parts[0][10:])
for index in [1, 2, 255]:
malformed.append(parts[0][:10] + bytes([index]) + parts[0][11:])
malformed.append(parts[1][:10] + b"\0" + parts[1][11:])
for part in malformed:
with self.subTest(part=part.hex()):
self.assertEqual(self.run_tool("parse-fragment", part.hex()), "reject")
def test_fragment_tampering_and_mixed_commands_cannot_forge_complete_signature(self):
prefix = "set flood.rule.1 "
command = prefix + "a" * (230 - len(prefix))
original = envelope2(command=command)
parts = [fragment_frame(original, index) for index in [0, 1]]
self.assertNotEqual(self.decode(original), "reject")
# Every chunk contains signed data. Matching frame metadata and an
# allowed changed command cannot substitute for the full signature.
for index, data_offset in [(0, 14 + len(prefix)), (1, 0), (1, 153)]:
changed = bytearray(parts[index])
changed[11 + data_offset] ^= 1
self.assertNotEqual(self.run_tool("parse-fragment", changed.hex()), "reject")
chunks = [parts[0][11:], parts[1][11:]]
chunks[index] = changed[11:]
self.assertEqual(self.decode(b"".join(chunks)), "reject")
different = envelope2(command=prefix + "b" * (230 - len(prefix)))
self.assertNotEqual(self.decode(different), "reject")
different_parts = [fragment_frame(different, index) for index in [0, 1]]
for combined in [parts[0][11:] + different_parts[1][11:],
different_parts[0][11:] + parts[1][11:]]:
self.assertEqual(self.decode(combined), "reject")
# Frame metadata is deliberately unauthenticated and only admits a
# bounded assembly. Runtime must compare it to the signed sequence.
changed_sequence = parts[0][:4] + struct.pack("<I", NOW + 1) + parts[0][8:]
self.assertTrue(self.run_tool("parse-fragment", changed_sequence.hex()).startswith(f"{NOW + 1}\n"))
self.assertEqual(self.decode(changed_sequence), "reject")
def test_real_signature_round_trip_for_mixed_prefixes_and_full_keys(self):
entries = [(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6]), (16, hashlib.sha256(PUBLIC).digest()[:16])]
token = f"{PUBLIC[:4].hex()},{OTHER_PUBLIC[:6].hex()},{PUBLIC.hex()}"
@@ -502,10 +755,10 @@ class FleetCommandCryptoTests(unittest.TestCase):
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 "))
maximum = "set flood.rule.1 " + "a" * (215 - len("set flood.rule.1 "))
entries = [(4, PUBLIC[:4])] * 3
expected = envelope2(entries, maximum)
self.assertEqual(len(expected), 165)
self.assertEqual(len(expected), 308)
token = ",".join([PUBLIC[:4].hex()] * 3)
actual = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, maximum)
self.assertEqual(actual, expected.hex())
@@ -516,7 +769,7 @@ class FleetCommandCryptoTests(unittest.TestCase):
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)
self.assertNotEqual(result, "reject")
def test_fmc2_rejects_short_trailing_and_signed_forbidden_commands(self):
original = envelope2([(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6])])
+2 -2
View File
@@ -220,8 +220,8 @@ class Nrf52ExtraFsContractTest(unittest.TestCase):
next_at = handler.index("cmd_frame[0] == CMD_SET_ADVERT_NAME", get_at)
get_contacts = handler[get_at:next_at]
incomplete_at = get_contacts.index("hasIncompleteContactLoad()")
lock_at = get_contacts.index("_serial->lockReplyRoute()")
self.assertLess(incomplete_at, lock_at)
route_at = get_contacts.index("_iter_reply_route = _serial->captureReplyRoute()")
self.assertLess(incomplete_at, route_at)
self.assertIn("ERR_CODE_FILE_IO_ERROR", get_contacts)
base = (ROOT / "src" / "helpers" / "BaseChatMesh.cpp").read_text(
@@ -204,6 +204,48 @@ TEST_F(TransportFilter, InvalidModesAndActionsDoNotReplaceSavedRule) {
EXPECT_FALSE(blocked(p, RULE_MODE_RADIO));
}
TEST_F(TransportFilter, TwoPacketFleetRulePreservesActionAfterLongParameters) {
const std::string prefix = "set flood.rule.1 type=grp_txt channel=" + std::string(64, '1')
+ " prefix=AABBCC,DDEEFF,112233 hops=6+ priority=255 suspend=tempradio"
" mode=radio hashbytes=3 in=any";
const std::string action = " action=drop";
ASSERT_LT(prefix.size() + action.size(), 230U);
const std::string setter = prefix + std::string(230 - prefix.size() - action.size(), ' ') + action;
ASSERT_EQ(230U, setter.size());
ASSERT_GT(setter.size() - strlen("set flood.rule.1 "), 192U);
command(setter.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
const auto saved = fs.files["/flood_filter"];
mesh::Packet p;
p.header = (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT) | ROUTE_TYPE_FLOOD;
p.setPathHashSizeAndCount(3, 6);
memset(p.path, 0, p.getPathByteLen());
const uint8_t path[] = {0xAA,0xBB,0xCC,0xDD,0xEE,0xFF,0x11,0x22,0x33};
memcpy(p.path, path, sizeof(path));
uint8_t key[PUB_KEY_SIZE];memset(key, 0x11, sizeof(key));
mesh::Utils::sha256(p.payload, 1, key, sizeof(key));
const uint8_t text[] = "fleet: sample";
p.payload_len = 1 + mesh::Utils::encryptThenMAC(key, p.payload + 1, text, sizeof(text));
EXPECT_TRUE(blocked(p, RULE_MODE_RADIO));
std::string stop = setter;stop.replace(stop.size() - 4, 4, "stop");
command(stop.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
EXPECT_FALSE(blocked(p, RULE_MODE_RADIO));
EXPECT_NE(saved, fs.files["/flood_filter"]);
const auto unchanged = fs.files["/flood_filter"];
command(("set flood.rule.1 any " + std::string(256, ' ') + "drop").c_str());
EXPECT_EQ(0, strncmp(reply, "Err - rule parameters too long", 29));
EXPECT_EQ(unchanged, fs.files["/flood_filter"]);
}
TEST_F(TransportFilter, OverlongRateTokenCannotDiscardItsInvalidSuffix) {
install("radio");const auto saved = fs.files["/flood_filter"];
command(("set flood.rule.3 any q=" + std::string(23, '0') + "x stop").c_str());
EXPECT_EQ(0, strncmp(reply, "Err - rate value too long", 24)) << reply;
EXPECT_EQ(saved, fs.files["/flood_filter"]);
}
TEST_F(TransportFilter, CombinedModeSharesRateAndResetsAfterMinute) {
command("set flood.rule.3 type=any mode=bridge,cross rate=1/min");
ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
@@ -490,6 +532,35 @@ TEST_F(TransportFilter, PrivateKeyReferencesCopyLocallyWithoutDisclosingKey) {
EXPECT_EQ(empty, fs.files["/flood_filter"]);
}
TEST_F(TransportFilter, WholeFleetMaximumRulePreservesItsFinalDropAction) {
const std::string setter = "set flood.rule.1 5 c=" + std::string(64, '1') + " d";
ASSERT_EQ(87U, setter.size());
command(setter.c_str());
ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
mesh::Packet p;
p.header = (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT) | ROUTE_TYPE_FLOOD;
p.setPathHashSizeAndCount(1, 0);
uint8_t key[PUB_KEY_SIZE];
memset(key, 0x11, sizeof(key));
mesh::Utils::sha256(p.payload, 1, key, sizeof(key));
const uint8_t text[] = "fleet: sample";
p.payload_len = 1 + mesh::Utils::encryptThenMAC(key, p.payload + 1, text, sizeof(text));
EXPECT_TRUE(blocked(p, RULE_MODE_RADIO));
command("get fr.1");
const std::string formatted = reply;
ASSERT_GE(formatted.size(), 2U);
EXPECT_EQ(" d", formatted.substr(formatted.size() - 2));
// A stop action at the same final byte has different forwarding behavior.
std::string stop = setter;
stop.back() = 's';
command(stop.c_str());
ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
EXPECT_FALSE(blocked(p, RULE_MODE_RADIO));
}
TEST(TransportModes, KeyFingerprintCollisionsCannotSelectAnArbitrarySecret) {
struct Row {
bool active = true;