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 committed 2026-10-09 00:46:50 -07:00
1 parent d74a0f8b44
commit fdad79eaf7
18 files changed
+1649 -143

No files matched your search

+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,