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:
1 parent
d74a0f8b44
commit
fdad79eaf7
18 files changed
+1649
-143
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user