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