Harden recovery authentication and signed OTA admission
This commit is contained in:
1 parent
74bc91bf69
commit
5fa06ea49b
40 files changed
+3631
-2107
No files matched your search
+3
-2
@@ -173,7 +173,8 @@ static uint8_t otaTrafficPriority(const uint8_t* msg, uint16_t len) {
|
||||
|
||||
static bool isPacedOtaResponse(const uint8_t* msg, uint16_t len) {
|
||||
if (!msg || len == 0) return false;
|
||||
return msg[0] == ota::OTA_DATA || msg[0] == ota::OTA_PROOF;
|
||||
return msg[0] == ota::OTA_DATA || msg[0] == ota::OTA_PROOF
|
||||
|| msg[0] == ota::OTA_LEAVES || msg[0] == ota::OTA_MANIFEST;
|
||||
}
|
||||
|
||||
#if defined(ENABLE_OTA)
|
||||
@@ -191,7 +192,7 @@ static int queuedPacedOtaResponses(PacketManager* manager) {
|
||||
}
|
||||
|
||||
// Adapter so the portable OtaManager can emit packets through the mesh (message-priority, hop-capped).
|
||||
// DATA/PROOF admission is credit-limited; false leaves the manager's response descriptor intact.
|
||||
// Transfer response admission is credit-limited; false retains the response descriptor.
|
||||
bool Mesh::otaSendAdapter(void* ctx, const uint8_t* msg, uint16_t len, bool /*flood*/) {
|
||||
Mesh* m = (Mesh*)ctx;
|
||||
if (!m->isAnyTempRadioActive()) return false;
|
||||
|
||||
@@ -5137,7 +5137,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
|
||||
strcpy(reply, "Error: extra.sf requires an LR2021 radio");
|
||||
#endif
|
||||
} else {
|
||||
sprintf(reply, "unknown config: %s", config);
|
||||
strcpy(reply, "unknown config");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -654,7 +654,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sprintf(reply, "unknown config: %s", config);
|
||||
strcpy(reply, "unknown config");
|
||||
}
|
||||
} else if (memcmp(config, "mqtt.analyzer.us ", 17) == 0) {
|
||||
const int slot = 0;
|
||||
|
||||
@@ -81,7 +81,7 @@ bool FleetChannel::handleConfig(const char* command, char* reply, size_t capacit
|
||||
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(bool(assembly_parts_ & 1)) + unsigned(bool(assembly_parts_ & 2)));
|
||||
assemblyPartCount());
|
||||
} else if (!strncmp(command, "set fleet.channel ", 18)) {
|
||||
if (pending_ || barrier_.waiting()) {
|
||||
snprintf(reply, capacity, "Err - fleet command pending; retry later"); return true;
|
||||
@@ -142,16 +142,29 @@ void FleetChannel::receive(Packet* packet, Mesh& mesh, const TransportKey* scope
|
||||
(void)mesh;
|
||||
}
|
||||
|
||||
void FleetChannel::clearAssembly(Assembly& assembly) {
|
||||
memset(assembly.data, 0, sizeof(assembly.data));
|
||||
assembly.sequence = assembly.deadline = 0;
|
||||
assembly.length = 0;
|
||||
assembly.parts = 0;
|
||||
}
|
||||
|
||||
void FleetChannel::clearAssembly() {
|
||||
memset(assembly_, 0, sizeof(assembly_));
|
||||
assembly_sequence_ = assembly_deadline_ = 0;
|
||||
assembly_length_ = 0;
|
||||
assembly_parts_ = 0;
|
||||
for (auto& assembly : assemblies_) clearAssembly(assembly);
|
||||
}
|
||||
|
||||
unsigned FleetChannel::assemblyPartCount() const {
|
||||
unsigned count = 0;
|
||||
for (const auto& assembly : assemblies_)
|
||||
count += unsigned(bool(assembly.parts & 1)) + unsigned(bool(assembly.parts & 2));
|
||||
return count;
|
||||
}
|
||||
|
||||
void FleetChannel::serviceAssemblyDeadline() {
|
||||
if (assembly_parts_ && int32_t(uint32_t(millis()) - assembly_deadline_) >= 0)
|
||||
clearAssembly();
|
||||
for (auto& assembly : assemblies_) {
|
||||
if (assembly.parts && int32_t(uint32_t(millis()) - assembly.deadline) >= 0)
|
||||
clearAssembly(assembly);
|
||||
}
|
||||
}
|
||||
|
||||
FleetChannel::DecodeResult FleetChannel::decode(Mesh& mesh, FleetCommand::Decoded& command) {
|
||||
@@ -159,10 +172,6 @@ FleetChannel::DecodeResult FleetChannel::decode(Mesh& mesh, FleetCommand::Decode
|
||||
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 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)
|
||||
@@ -174,7 +183,7 @@ FleetChannel::DecodeResult FleetChannel::decode(Mesh& mesh, FleetCommand::Decode
|
||||
|| !zero(data + unpadded, length - unpadded)) return DecodeResult::Rejected;
|
||||
const uint8_t* envelope = data + 3;
|
||||
size_t envelope_length = payload_length;
|
||||
bool assembled = false;
|
||||
Assembly* assembled = nullptr;
|
||||
if (payload_length >= 4 && !memcmp(envelope, "FMP1", 4)) {
|
||||
FleetCommand::Fragment fragment;
|
||||
if (!FleetCommand::parseFragment(envelope, payload_length, fragment)
|
||||
@@ -184,38 +193,66 @@ FleetChannel::DecodeResult FleetChannel::decode(Mesh& mesh, FleetCommand::Decode
|
||||
|| (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;
|
||||
Assembly* slot = nullptr;
|
||||
for (auto& candidate : assemblies_) {
|
||||
if (candidate.parts && candidate.sequence == fragment.sequence) {
|
||||
// Conflicting metadata cannot replace a live transfer with this
|
||||
// sequence. Its identity remains untrusted until the full signature.
|
||||
if (candidate.length != fragment.total_length) return DecodeResult::Rejected;
|
||||
slot = &candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!slot) {
|
||||
for (auto& candidate : assemblies_) {
|
||||
if (!candidate.parts) { slot = &candidate; break; }
|
||||
}
|
||||
if (!slot) {
|
||||
// Evict the oldest collection by monotonic age, never by an unsigned
|
||||
// advertised sequence. A forged now+60 fragment cannot reserve the
|
||||
// receiver against a legitimate current command for five minutes.
|
||||
slot = &assemblies_[0];
|
||||
for (auto& candidate : assemblies_) {
|
||||
if (int32_t(candidate.deadline - slot->deadline) < 0) slot = &candidate;
|
||||
}
|
||||
clearAssembly(*slot);
|
||||
}
|
||||
slot->sequence = fragment.sequence;
|
||||
slot->length = fragment.total_length;
|
||||
// Fixed five-minute lifetime: duplicates never extend ownership.
|
||||
slot->deadline = now_ms + AssemblyLifetimeMillis;
|
||||
}
|
||||
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)
|
||||
if (slot->parts & mask) {
|
||||
return !memcmp(slot->data + 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;
|
||||
memcpy(slot->data + offset, fragment.data, fragment.length);
|
||||
slot->parts |= mask;
|
||||
if (slot->parts != 3) return DecodeResult::Partial;
|
||||
envelope = slot->data;
|
||||
envelope_length = slot->length;
|
||||
assembled = slot;
|
||||
}
|
||||
// The visible channel hash is only a routing hint. Charge the expensive
|
||||
// verification budget only at Ed25519 verification, after private-key MAC,
|
||||
// framing, replay, target, time and command checks. Keyless junk, recorded
|
||||
// old requests and incomplete transfers cannot consume those slots.
|
||||
const bool valid = envelope_length >= FleetCommand::MinHeaderSize + FleetCommand::SignatureSize
|
||||
&& storage::readLE32(envelope + 4) > last_sequence_
|
||||
&& (!assembled || storage::readLE32(envelope + 4) == assembly_sequence_)
|
||||
&& (!assembled || storage::readLE32(envelope + 4) == assembled->sequence)
|
||||
&& FleetCommand::decode(controller_, channel_.secret, envelope, envelope_length,
|
||||
mesh.getRTCClock()->getCurrentTime(), mesh.self_id.pub_key, command,
|
||||
matchesFleetRegion, regions_, matchesFleetLocation, hooks_);
|
||||
if (assembled || (valid && assembly_parts_ && command.sequence >= assembly_sequence_)) clearAssembly();
|
||||
matchesFleetRegion, regions_, matchesFleetLocation, hooks_,
|
||||
[](void* context) {
|
||||
auto* receiver = static_cast<FleetChannel*>(context);
|
||||
if (receiver->verify_attempts_ >= 4) return false;
|
||||
++receiver->verify_attempts_;
|
||||
return true;
|
||||
}, this);
|
||||
if (valid) clearAssembly();
|
||||
else if (assembled) clearAssembly(*assembled);
|
||||
if (!valid) return DecodeResult::Rejected;
|
||||
broadcast_ = command.broadcast;
|
||||
return DecodeResult::Accepted;
|
||||
|
||||
@@ -99,10 +99,19 @@ 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;
|
||||
// Fragment metadata has only channel-key authentication, not the
|
||||
// publisher's signature. Two bounded slots avoid making an unsigned future
|
||||
// sequence authoritative over every other command's assembly.
|
||||
struct Assembly {
|
||||
uint8_t data[FleetCommand::MaxEnvelopeLength]{};
|
||||
uint32_t sequence = 0, deadline = 0;
|
||||
uint16_t length = 0;
|
||||
uint8_t parts = 0;
|
||||
};
|
||||
static constexpr uint32_t AssemblyLifetimeMillis = 300000;
|
||||
static_assert(sizeof(Assembly) <= FleetCommand::MaxEnvelopeLength + 12,
|
||||
"fleet fragment slots must stay bounded");
|
||||
Assembly assemblies_[2]{};
|
||||
uint8_t incoming_len_ = 0, rx_profile_ = 0, path_hash_size_ = 1;
|
||||
uint32_t rx_generation_ = 0;
|
||||
TransportKey reply_scope_{};
|
||||
@@ -115,6 +124,8 @@ class FleetChannel {
|
||||
enum class DecodeResult { Rejected, Partial, Accepted };
|
||||
DecodeResult decode(Mesh& mesh, FleetCommand::Decoded& command);
|
||||
void clearAssembly();
|
||||
void clearAssembly(Assembly& assembly);
|
||||
unsigned assemblyPartCount() const;
|
||||
void serviceAssemblyDeadline();
|
||||
bool acknowledge(Mesh& mesh, const char* name, uint32_t sequence,
|
||||
const char* reply, bool radio_mutation);
|
||||
|
||||
@@ -579,7 +579,8 @@ bool FleetCommand::decode(const Identity& publisher, const uint8_t* key,
|
||||
const uint8_t* payload, size_t length, uint32_t now,
|
||||
const uint8_t* self_public_key, Decoded& output,
|
||||
RegionMatcher matcher, void* context,
|
||||
GeoMatcher geo_matcher, void* geo_context) {
|
||||
GeoMatcher geo_matcher, void* geo_context,
|
||||
SignatureGate signature_gate, void* signature_context) {
|
||||
memset(&output, 0, sizeof(output));
|
||||
if (!privateKeyAllowed(key) || !payload || !self_public_key
|
||||
|| zero(publisher.pub_key, PUB_KEY_SIZE) || zero(self_public_key, PUB_KEY_SIZE)
|
||||
@@ -620,6 +621,7 @@ bool FleetCommand::decode(const Identity& publisher, const uint8_t* key,
|
||||
memcpy(command, payload + command_offset, command_length);
|
||||
command[command_length] = 0;
|
||||
if (!commandAllowed(command)) return false;
|
||||
if (signature_gate && !signature_gate(signature_context)) return false;
|
||||
uint8_t message[SignedMessageCapacity];
|
||||
const size_t message_length = signedMessage(key, payload, unsigned_length, message);
|
||||
const bool valid = publisher.verify(payload + unsigned_length, message, int(message_length));
|
||||
|
||||
@@ -63,6 +63,9 @@ public:
|
||||
const char* name, size_t length);
|
||||
using GeoMatcher = bool (*)(void* context, int32_t latitude_e6,
|
||||
int32_t longitude_e6, uint32_t radius_meters);
|
||||
// Optional runtime work gate. Called only after all cheap envelope,
|
||||
// targeting and time checks, immediately before Ed25519 verification.
|
||||
using SignatureGate = bool (*)(void* context);
|
||||
|
||||
struct Fragment {
|
||||
uint32_t sequence;
|
||||
@@ -108,7 +111,8 @@ public:
|
||||
const uint8_t* payload, size_t length, uint32_t now,
|
||||
const uint8_t* self_public_key, Decoded& output,
|
||||
RegionMatcher matcher = nullptr, void* context = nullptr,
|
||||
GeoMatcher geo_matcher = nullptr, void* geo_context = nullptr);
|
||||
GeoMatcher geo_matcher = nullptr, void* geo_context = nullptr,
|
||||
SignatureGate signature_gate = nullptr, void* signature_context = nullptr);
|
||||
|
||||
// Envelopes <= MaxPayloadLength travel unchanged. Longer envelopes use
|
||||
// exactly two FMP1 frames, each bounded by the group payload limit.
|
||||
|
||||
@@ -2214,7 +2214,12 @@ bool MQTTBridge::setupSlot(int index) {
|
||||
#if defined(PORTABLE_MQTT_OBSERVER)
|
||||
// Portable images keep pinned roots for built-in presets. The general CA
|
||||
// bundle is intentionally left unreferenced so the linker can discard it.
|
||||
(void)needs_tls;
|
||||
if (needs_tls) {
|
||||
MQTT_DEBUG_PRINTLN("MQTT%d TLS refused: custom brokers require a CA bundle", index + 1);
|
||||
slot.initial_connect_done = false;
|
||||
slot.last_reconnect_attempt = millis();
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
if (needs_tls) {
|
||||
if (!s_ca_bundle_loaded) {
|
||||
@@ -2233,7 +2238,12 @@ bool MQTTBridge::setupSlot(int index) {
|
||||
slot.client->setCACertBundle(rootca_crt_bundle_start, bundle_len);
|
||||
s_ca_bundle_loaded = true;
|
||||
} else {
|
||||
MQTT_DEBUG_PRINTLN("MQTT%d TLS: no embedded cert bundle available", index + 1);
|
||||
// Never let ESP-IDF fall back to VERIFY_NONE for a TLS URI. In
|
||||
// particular, do not mint/send credentials or start the client.
|
||||
MQTT_DEBUG_PRINTLN("MQTT%d TLS refused: no embedded cert bundle available", index + 1);
|
||||
slot.initial_connect_done = false;
|
||||
slot.last_reconnect_attempt = millis();
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Global bundle already loaded - just attach the callback for this client.
|
||||
|
||||
+1944
-1943
File diff suppressed because it is too large.
Load diff
@@ -825,8 +825,8 @@ bool WebConfigServer::startSetupMode(char reply[]) {
|
||||
// Keep STA and the driver alive so an ESP-NOW owner is not torn down.
|
||||
// softAPConfig() adds AP to this STA interface for the SSID picker below.
|
||||
bool mode_ok = WiFi.mode(WIFI_STA);
|
||||
// Setup mode has no login. Drop any STA association so the open setup API is
|
||||
// reachable only from the setup AP, not from the operator's LAN.
|
||||
// Drop any STA association so setup/recovery stays on the physical AP.
|
||||
// A saved-network recovery AP still requires the existing admin login.
|
||||
WiFi.setAutoReconnect(false);
|
||||
bool disconnect_ok = WiFi.disconnect(false, true);
|
||||
delay(100);
|
||||
@@ -1809,13 +1809,15 @@ void WebConfigServer::collectBody(AsyncWebServerRequest* req, uint8_t* data, siz
|
||||
bool WebConfigServer::checkAuth(AsyncWebServerRequest* req) {
|
||||
_last_activity = millis();
|
||||
if (_mode == MODE_SETUP) {
|
||||
// AP+STA is used for scans and saved-network recovery. Keep the unauthenticated
|
||||
// setup API reachable only through the physical setup AP, never through a
|
||||
// briefly recovered LAN interface before tick() promotes the mode.
|
||||
return req && req->client()
|
||||
&& req->client()->localIP() == WiFi.softAPIP();
|
||||
// AP+STA is used for scans and saved-network recovery. A first-time setup
|
||||
// may provision a new password from the physical AP. Losing access to a
|
||||
// saved WiFi network must not grant that same password-reset privilege.
|
||||
if (!req || !req->client()
|
||||
|| req->client()->localIP() != WiFi.softAPIP()) return false;
|
||||
if (_initial_setup || _wifi_ssid[0] == 0) return true;
|
||||
} else if (_mode != MODE_LAN) {
|
||||
return false;
|
||||
}
|
||||
if (_mode != MODE_LAN) return false;
|
||||
NodeSnapshot node = {};
|
||||
{
|
||||
WCLock lock(_mux);
|
||||
@@ -1980,9 +1982,15 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
|
||||
void WebConfigServer::handleLogin(AsyncWebServerRequest* req) {
|
||||
if (_mode == MODE_OFF) { req->send(503); return; }
|
||||
_last_activity = millis();
|
||||
if (_mode == MODE_SETUP) { // no auth in setup mode
|
||||
req->send(200, "application/json", "{\"ok\":true}");
|
||||
return;
|
||||
if (_mode == MODE_SETUP) {
|
||||
if (!req->client() || req->client()->localIP() != WiFi.softAPIP()) {
|
||||
req->send(401, "application/json", "{\"error\":\"auth\"}");
|
||||
return;
|
||||
}
|
||||
if (_initial_setup || _wifi_ssid[0] == 0) {
|
||||
req->send(200, "application/json", "{\"ok\":true}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
NodeSnapshot node = {};
|
||||
{
|
||||
@@ -2028,6 +2036,7 @@ void WebConfigServer::handleLogin(AsyncWebServerRequest* req) {
|
||||
|
||||
void WebConfigServer::handleLogout(AsyncWebServerRequest* req) {
|
||||
if (_mode == MODE_OFF) { req->send(503); return; }
|
||||
if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
|
||||
_session_token[0] = 0;
|
||||
AsyncWebServerResponse* res = req->beginResponse(200, "application/json", "{\"ok\":true}");
|
||||
res->addHeader("Set-Cookie", "wcs=; Max-Age=0; Path=/");
|
||||
|
||||
@@ -6,18 +6,21 @@
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
bool FolderMotaStore::readByteT(uint8_t& b) const {
|
||||
uint32_t t0 = millis();
|
||||
while ((millis() - t0) < _to) {
|
||||
bool FolderMotaStore::readByteT(uint8_t& b, uint32_t started) const {
|
||||
while ((uint32_t)(millis() - started) < _to) {
|
||||
int c = _io.read();
|
||||
if (c >= 0) { b = (uint8_t)c; return true; }
|
||||
if (c >= 0) {
|
||||
if ((uint32_t)(millis() - started) >= _to) return false;
|
||||
b = (uint8_t)c;
|
||||
return true;
|
||||
}
|
||||
delay(1); // let BLE/WiFi callbacks deliver the response
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool FolderMotaStore::readExact(uint8_t* b, uint16_t n) const {
|
||||
for (uint16_t i = 0; i < n; i++) if (!readByteT(b[i])) return false;
|
||||
bool FolderMotaStore::readExact(uint8_t* b, uint16_t n, uint32_t started) const {
|
||||
for (uint16_t i = 0; i < n; i++) if (!readByteT(b[i], started)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -25,7 +28,10 @@ bool FolderMotaStore::readExact(uint8_t* b, uint16_t n) const {
|
||||
// XOR checksum, scan for the response magic, validate op+status+checksum, deliver payload.
|
||||
bool FolderMotaStore::txn(uint8_t op, const uint8_t* args, uint16_t arglen,
|
||||
uint8_t* payload, uint16_t payload_len) const {
|
||||
while (_io.read() >= 0) {} // drop any stale/partial bytes before a fresh request
|
||||
// Snapshot the currently queued stale bytes. A continuously sending TCP
|
||||
// client must not trap the mesh loop before a request has even been sent.
|
||||
int pending = _io.available();
|
||||
while (pending-- > 0) if (_io.read() < 0) break;
|
||||
uint8_t xs = op;
|
||||
for (uint16_t i = 0; i < arglen; i++) xs ^= args[i];
|
||||
// Keep one contiguous write for TCP (and fewer calls on UART). The largest
|
||||
@@ -51,16 +57,16 @@ bool FolderMotaStore::txn(uint8_t op, const uint8_t* args, uint16_t arglen,
|
||||
if (!got) return false;
|
||||
|
||||
uint8_t hdr[2];
|
||||
if (!readExact(hdr, 2)) return false; // op, status
|
||||
if (!readExact(hdr, 2, t0)) return false; // op, status
|
||||
if (hdr[0] != op) return false;
|
||||
uint8_t rxs = (uint8_t)(MOTA_SEEDER_RSP_MAGIC0 ^ MOTA_SEEDER_RSP_MAGIC1) ^ hdr[0] ^ hdr[1];
|
||||
bool ok = (hdr[1] == MS_STATUS_OK);
|
||||
if (ok && payload_len) {
|
||||
if (!readExact(payload, payload_len)) return false;
|
||||
if (!readExact(payload, payload_len, t0)) return false;
|
||||
for (uint16_t i = 0; i < payload_len; i++) rxs ^= payload[i];
|
||||
}
|
||||
uint8_t xsum;
|
||||
if (!readByteT(xsum)) return false;
|
||||
if (!readByteT(xsum, t0)) return false;
|
||||
if (xsum != rxs) return false; // corrupt frame -> caller retries
|
||||
return ok;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
// If the link drops, every op returns false: the OtaManager PAUSES and keeps its progress (it does NOT fall
|
||||
// back to RAM/flash). On reconnect the host still has the partial, and the manager re-reopen()s so only the
|
||||
// missing blocks are refetched. All ops are keyed by `mid` (set via set_mid() before the fetch begins).
|
||||
// Each framed response has one timeout_ms deadline, including sync, header,
|
||||
// payload and checksum. A host cannot extend it by sending one byte at a time.
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
@@ -48,8 +50,8 @@ private:
|
||||
// One request/response transaction over the seeder link (mirrors SerialMotaSource). `arglen` is uint16 so
|
||||
// OP_WRITE can carry its data inline. Returns true iff a well-formed OK response for `op` arrived in time.
|
||||
bool txn(uint8_t op, const uint8_t* args, uint16_t arglen, uint8_t* payload, uint16_t payload_len) const;
|
||||
bool readByteT(uint8_t& b) const;
|
||||
bool readExact(uint8_t* b, uint16_t n) const;
|
||||
bool readByteT(uint8_t& b, uint32_t started) const;
|
||||
bool readExact(uint8_t* b, uint16_t n, uint32_t started) const;
|
||||
|
||||
Stream& _io;
|
||||
uint32_t _to;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "OtaDeflate.h"
|
||||
#include "OtaStore.h"
|
||||
#include "SignerAllowlist.h"
|
||||
#include "OtaManifestPolicy.h"
|
||||
#include "OtaConfigState.h"
|
||||
#include "OtaApply.h"
|
||||
#include "OtaFormat.h"
|
||||
@@ -856,6 +857,11 @@ struct OtaContext {
|
||||
target_id = 0;
|
||||
#endif
|
||||
manager.begin(target_id, send, ctx);
|
||||
#if !defined(OTA_SEEDER_ONLY)
|
||||
manager.set_manifest_admission([](void* context, const MotaManifest& manifest) {
|
||||
return ota_manifest_trusted(manifest, static_cast<OtaContext*>(context)->allow);
|
||||
}, this);
|
||||
#endif
|
||||
#if defined(MOTA_MIGRATION_TARGET_ID)
|
||||
// Legacy observer images keep their deployed target identity for serving
|
||||
// and diagnostics, but automatically accept one canonical successor image.
|
||||
|
||||
+115
-41
@@ -50,6 +50,10 @@ bool OtaManager::set_speed(float speed) {
|
||||
|
||||
bool OtaManager::expandCatalog() {
|
||||
if (_catalog_heap || OTA_INLINE_CATALOG >= OTA_MAX_CATALOG) return false;
|
||||
// Passive on-air discovery never claims a 255-row heap. Large catalogs are
|
||||
// an explicit browse/archive/automatic-fetch resource choice.
|
||||
if (!_catalog_browse_requested && !_archive_interest && _autofetch == AUTOFETCH_OFF &&
|
||||
!_have_desired_mid && _desired_target == 0) return false;
|
||||
CatRow* expanded = static_cast<CatRow*>(malloc(sizeof(CatRow) * OTA_MAX_CATALOG));
|
||||
if (!expanded) return false;
|
||||
memcpy(expanded, _catalog_inline, sizeof(CatRow) * _n_cat);
|
||||
@@ -57,6 +61,13 @@ bool OtaManager::expandCatalog() {
|
||||
return true;
|
||||
}
|
||||
|
||||
void OtaManager::releaseSmallCatalog() {
|
||||
if (!_catalog_heap || _n_cat > OTA_INLINE_CATALOG) return;
|
||||
memcpy(_catalog_inline, _catalog_heap, sizeof(CatRow) * _n_cat);
|
||||
free(_catalog_heap);
|
||||
_catalog_heap = nullptr;
|
||||
}
|
||||
|
||||
uint8_t* OtaManager::ensureSourceLeaves(uint32_t bytes) {
|
||||
if (!bytes || bytes > OTA_PROOFGEN_SCRATCH) return nullptr;
|
||||
if (_src_leaves_capacity < bytes) {
|
||||
@@ -121,6 +132,9 @@ void OtaManager::begin(uint32_t my_target_id, OtaSend send, void* ctx) {
|
||||
memset(_src_offered, 0, sizeof(_src_offered));
|
||||
memset(_src_advertised, 0, sizeof(_src_advertised));
|
||||
_n_src = 0; _n_cat = 0;
|
||||
releaseSmallCatalog();
|
||||
_catalog_browse_requested = false;
|
||||
_auto_failure_at = 0; _auto_failure_delay = 0;
|
||||
clearPendingEgress();
|
||||
releaseColdBuffers();
|
||||
_adaptive_packet_speed = 1.0f;
|
||||
@@ -489,24 +503,17 @@ bool OtaManager::handleGetManifest(const uint8_t* m, uint16_t n) {
|
||||
// Serve the target's merkle leaves[] in fragments (for a motatool folder-capture warm-start). Only the
|
||||
// fragments set in want_mask are emitted, so a want_mask retry re-sends just the holes - never a full burst
|
||||
// (same anti-deadlock rationale as OTA_MANIFEST). This is the only leaf-diff piece that runs on every node.
|
||||
void OtaManager::handleGetLeaves(const uint8_t* m, uint16_t n) {
|
||||
bool OtaManager::handleGetLeaves(const uint8_t* m, uint16_t n) {
|
||||
GetLeavesMsg gl;
|
||||
if (!decode_get_leaves(m, n, gl)) return;
|
||||
if (!decode_get_leaves(m, n, gl) || gl.want_mask == 0) return false;
|
||||
ServeView* v = resolve(gl.manifest_id);
|
||||
if (!v || !v->m.leaves) return;
|
||||
uint32_t leaves_len = v->m.block_count * 4;
|
||||
uint8_t ftotal = (uint8_t)((leaves_len + OTA_LEAVES_FRAG - 1) / OTA_LEAVES_FRAG); if (ftotal == 0) ftotal = 1;
|
||||
for (uint8_t fi = 0; fi < ftotal; fi++) {
|
||||
if (!(gl.want_mask & (1u << fi))) continue; // fetcher didn't ask for this leaves fragment
|
||||
uint32_t off = (uint32_t)fi * OTA_LEAVES_FRAG;
|
||||
uint32_t fl = leaves_len - off; if (fl > OTA_LEAVES_FRAG) fl = OTA_LEAVES_FRAG;
|
||||
LeavesMsg lm;
|
||||
memcpy(lm.manifest_id, v->m.merkle_root, 4);
|
||||
lm.frag_idx = fi; lm.frag_total = ftotal;
|
||||
lm.bytes = v->m.leaves + off; lm.len = (uint16_t)fl;
|
||||
uint8_t b[MAX_PACKET_PAYLOAD];
|
||||
emit(b, encode_leaves(b, sizeof(b), lm), false);
|
||||
}
|
||||
if (!v || !v->m.leaves || v->m.block_count == 0 ||
|
||||
v->m.block_count > OTA_DIFF_MAX_BLOCKS) return false;
|
||||
const uint32_t total = (v->m.block_count * 4 + OTA_LEAVES_FRAG - 1) / OTA_LEAVES_FRAG;
|
||||
if (total == 0 || total > OTA_LEAVES_MAXFRAG) return false;
|
||||
const uint16_t valid_mask = total == 16 ? 0xFFFFu : (uint16_t)((1u << total) - 1u);
|
||||
const uint16_t wanted = (uint16_t)(gl.want_mask & valid_mask);
|
||||
return wanted && queueManifestJob(v->m.merkle_root, wanted, true);
|
||||
}
|
||||
|
||||
// Smallest mask covering `nf` fragments: bit k set for k in [0, nf). Caps at 16 (matches each pipeline
|
||||
@@ -548,10 +555,11 @@ bool OtaManager::queueServeJob(const uint8_t* mid, uint16_t block, uint16_t want
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OtaManager::queueManifestJob(const uint8_t* mid, uint16_t want_mask) {
|
||||
bool OtaManager::queueManifestJob(const uint8_t* mid, uint16_t want_mask, bool leaves) {
|
||||
for (uint8_t i = 0; i < _n_manifest_jobs; i++) {
|
||||
ManifestServeJob& job = _manifest_jobs[i];
|
||||
if (memcmp(job.mid, mid, 4) != 0 || !(job.route == _request_route)) continue;
|
||||
if (memcmp(job.mid, mid, 4) != 0 || !(job.route == _request_route) ||
|
||||
job.leaves != leaves) continue;
|
||||
job.pending_mask |= (uint16_t)(want_mask & ~job.emitted_mask);
|
||||
return true;
|
||||
}
|
||||
@@ -562,6 +570,7 @@ bool OtaManager::queueManifestJob(const uint8_t* mid, uint16_t want_mask) {
|
||||
job.pending_mask = want_mask;
|
||||
job.emitted_mask = 0;
|
||||
job.ready_at = _now_ms + manifestEgressGapMs();
|
||||
job.leaves = leaves;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -625,8 +634,14 @@ bool OtaManager::serviceManifestEgress(OtaReplyRouteValid route_valid, void* rou
|
||||
return true;
|
||||
}
|
||||
|
||||
const uint32_t mfl = v->mfl;
|
||||
uint8_t ftotal = (uint8_t)((mfl + OTA_MF_FRAG - 1) / OTA_MF_FRAG);
|
||||
const uint32_t mfl = job.leaves ? v->m.block_count * 4u : v->mfl;
|
||||
const uint16_t fragment_size = job.leaves ? OTA_LEAVES_FRAG : OTA_MF_FRAG;
|
||||
const uint32_t total = (mfl + fragment_size - 1u) / fragment_size;
|
||||
if ((job.leaves && (!v->m.leaves || total > OTA_LEAVES_MAXFRAG)) || total > 16u) {
|
||||
popManifestJob();
|
||||
return true;
|
||||
}
|
||||
uint8_t ftotal = (uint8_t)total;
|
||||
if (ftotal == 0) ftotal = 1;
|
||||
const uint16_t valid_mask = frag_full_mask(ftotal);
|
||||
job.pending_mask &= valid_mask;
|
||||
@@ -642,19 +657,26 @@ bool OtaManager::serviceManifestEgress(OtaReplyRouteValid route_valid, void* rou
|
||||
return true;
|
||||
}
|
||||
|
||||
const uint32_t off = (uint32_t)fragment * OTA_MF_FRAG;
|
||||
const uint32_t off = (uint32_t)fragment * fragment_size;
|
||||
uint32_t len = mfl - off;
|
||||
if (len > OTA_MF_FRAG) len = OTA_MF_FRAG;
|
||||
ManifestMsg message;
|
||||
memcpy(message.manifest_id, v->m.merkle_root, 4);
|
||||
message.frag_idx = fragment;
|
||||
message.frag_total = ftotal;
|
||||
message.bytes = v->m.manifest_start + off;
|
||||
message.len = (uint16_t)len;
|
||||
if (len > fragment_size) len = fragment_size;
|
||||
uint8_t wire[MAX_PACKET_PAYLOAD];
|
||||
const uint16_t wire_len = encode_manifest(wire, sizeof(wire), message);
|
||||
uint16_t wire_len = 0;
|
||||
if (job.leaves) {
|
||||
LeavesMsg message;
|
||||
memcpy(message.manifest_id, v->m.merkle_root, 4);
|
||||
message.frag_idx = fragment; message.frag_total = ftotal;
|
||||
message.bytes = v->m.leaves + off; message.len = (uint16_t)len;
|
||||
wire_len = encode_leaves(wire, sizeof(wire), message);
|
||||
} else {
|
||||
ManifestMsg message;
|
||||
memcpy(message.manifest_id, v->m.merkle_root, 4);
|
||||
message.frag_idx = fragment; message.frag_total = ftotal;
|
||||
message.bytes = v->m.manifest_start + off; message.len = (uint16_t)len;
|
||||
wire_len = encode_manifest(wire, sizeof(wire), message);
|
||||
}
|
||||
if (emit(wire, wire_len, false, &job.route)) {
|
||||
OTA_DBG("OTA: MANIFEST tx frag=%u/%u len=%u\n",
|
||||
OTA_DBG("OTA: %s tx frag=%u/%u len=%u\n", job.leaves ? "LEAVES" : "MANIFEST",
|
||||
(unsigned)fragment, (unsigned)ftotal, (unsigned)len);
|
||||
const uint16_t bit = (uint16_t)(1u << fragment);
|
||||
job.pending_mask &= (uint16_t)~bit;
|
||||
@@ -955,6 +977,7 @@ void OtaManager::invalidateCatalogSeeder(const uint8_t* seeder) {
|
||||
}
|
||||
i++;
|
||||
}
|
||||
releaseSmallCatalog();
|
||||
}
|
||||
|
||||
// A tiny per-node BEACON: record the source; ask it for its catalog (OTA_QUERY) only when we're
|
||||
@@ -1051,6 +1074,7 @@ bool OtaManager::sendQuery(const uint8_t* seeder, const uint8_t* digest, uint32_
|
||||
// User-initiated browse (`ota neighbors`): immediately ask every incomplete/changed source. A complete
|
||||
// digest-tagged catalog is already current, so paging through a 255-row list must not re-flood it each time.
|
||||
void OtaManager::queryAll() {
|
||||
_catalog_browse_requested = true;
|
||||
for (uint8_t i = 0; i < _n_src; i++) {
|
||||
Source& s = _sources[i];
|
||||
if (s.have_catalog) continue;
|
||||
@@ -1120,6 +1144,7 @@ void OtaManager::handleHave(const uint8_t* m, uint16_t n) {
|
||||
const uint8_t* mid = row;
|
||||
uint32_t target = rd_u32le(row + 4), fwver = rd_u32le(row + 8);
|
||||
uint8_t codec = row[12], flags = row[13];
|
||||
if (flags & ~MFLAG_KNOWN) continue;
|
||||
uint32_t have_count = rd_u16le(row + 14); // this source's progress
|
||||
CatRow* catalog = catalogData();
|
||||
int slot = -1, lru = 0; // upsert into the catalog (dedup by mid)
|
||||
@@ -1130,7 +1155,23 @@ void OtaManager::handleHave(const uint8_t* m, uint16_t n) {
|
||||
}
|
||||
if (slot < 0) {
|
||||
if (_n_cat >= catalogCapacity() && expandCatalog()) catalog = catalogData();
|
||||
slot = (_n_cat < catalogCapacity()) ? _n_cat++ : lru;
|
||||
if (_n_cat < catalogCapacity()) slot = _n_cat++;
|
||||
else {
|
||||
slot = lru;
|
||||
// A page is no longer complete when an LRU replacement loses any of
|
||||
// its rows. An eventual explicit browse must request those pages too,
|
||||
// instead of expanding a cache whose old holes remain suppressed.
|
||||
const CatRow& evicted = catalog[slot];
|
||||
for (uint8_t source = 0; source < _n_src; ++source) {
|
||||
for (uint8_t seeder = 0; seeder < evicted.n_seeders; ++seeder) {
|
||||
if (memcmp(_sources[source].seeder, evicted.seeders[seeder], 4) == 0) {
|
||||
_sources[source].have_mask = 0;
|
||||
_sources[source].have_catalog = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catalog[slot] = CatRow{};
|
||||
memcpy(catalog[slot].mid, mid, 4);
|
||||
memcpy(catalog[slot].seeders[0], hv.seeder_id, 4);
|
||||
@@ -1188,6 +1229,8 @@ bool OtaManager::wantRow(const uint8_t* mid, uint32_t target, uint32_t fw_versio
|
||||
return memcmp(mid, _desired_mid, 4) == 0 && (_desired_target == 0 || target == _desired_target);
|
||||
if (_desired_target) return target == _desired_target; // cross-target want (role switch)
|
||||
if (_autofetch == AUTOFETCH_OFF) return false; // discover only
|
||||
if (_auto_failure_delay &&
|
||||
(uint32_t)(_now_ms - _auto_failure_at) < _auto_failure_delay) return false;
|
||||
const uint32_t auto_target = _migration_target ? _migration_target : _target;
|
||||
if (target != auto_target) return false; // own target or one-way successor
|
||||
if (_autofetch == AUTOFETCH_SIGNED && !(flags & MFLAG_SIGNED)) return false; // signed-only policy
|
||||
@@ -1202,6 +1245,7 @@ void OtaManager::clearReassemblySlot(uint8_t slot) {
|
||||
_reasm[slot].mask = 0;
|
||||
_reasm[slot].need = 0;
|
||||
_reasm[slot].awaiting_proof = false;
|
||||
_reasm[slot].proof_failed = false;
|
||||
_reasm[slot].proof_request_at = 0;
|
||||
_reasm[slot].encoded_len = 0;
|
||||
_reasm[slot].wire_v2 = false;
|
||||
@@ -1387,6 +1431,15 @@ void OtaManager::clearFetchIntent() {
|
||||
void OtaManager::failFetch(FetchError error) {
|
||||
if (error == FETCH_ERROR_STORAGE && _fetch && _fetch->canReconnect()
|
||||
&& pauseFetchForDisconnect()) return;
|
||||
if (!_have_desired_mid && _desired_target == 0 && !_archive_fetch) {
|
||||
// Global as well as per-image: changing a forged MID must not bypass the
|
||||
// cooldown. Explicit operator pulls retain their intentional override.
|
||||
_auto_failure_at = _now_ms;
|
||||
if (_auto_failure_delay == 0) _auto_failure_delay = OTA_AUTOFETCH_FAILURE_BACKOFF_MS;
|
||||
else if (_auto_failure_delay > OTA_AUTOFETCH_FAILURE_MAX_BACKOFF_MS / 2u)
|
||||
_auto_failure_delay = OTA_AUTOFETCH_FAILURE_MAX_BACKOFF_MS;
|
||||
else _auto_failure_delay *= 2u;
|
||||
}
|
||||
_fetch_error = error;
|
||||
_fstate = FAILED;
|
||||
_paused_from = IDLE;
|
||||
@@ -1398,6 +1451,7 @@ void OtaManager::failFetch(FetchError error) {
|
||||
}
|
||||
|
||||
void OtaManager::completeFetch() {
|
||||
_auto_failure_delay = 0;
|
||||
_fetch_error = FETCH_ERROR_NONE;
|
||||
_fstate = COMPLETE;
|
||||
_paused_from = IDLE;
|
||||
@@ -1451,7 +1505,8 @@ OtaManager::PullResult OtaManager::startFetch(const uint8_t* mid, uint32_t targe
|
||||
_validate = validate; // motatool folder-capture warm-start (seed leaf-diff)
|
||||
// A validate pull is a FRESH seed capture, not a resume: the store already holds the seed's payload (not a
|
||||
// real partial), so never adopt it via resumeStaged - always re-begin and run the manifest->leaves->diff.
|
||||
if (!validate && resumeStaged(mid)) return PULL_RESUMED; // adopt a partial container left in the store
|
||||
const bool automatic = !_have_desired_mid && _desired_target == 0 && !_archive_fetch;
|
||||
if (!validate && resumeStagedFor(mid, automatic)) return PULL_RESUMED; // adopt a partial container left in the store
|
||||
memcpy(_fid, mid, 4);
|
||||
_have = 0; _fbc = 0; _ftotal = 0; _fflags = 0;
|
||||
_observed_path_transmissions = 0; // learn the actual source/relay path from this fetch's replies
|
||||
@@ -1508,7 +1563,8 @@ bool OtaManager::resumeFetchAfterReconnect() {
|
||||
} else {
|
||||
// Reopening is bound to the original MID/target and verifies payload bytes;
|
||||
// a replacement host file must not silently become the selected capture.
|
||||
if (!resumeStaged(_fid)) return false;
|
||||
const bool automatic = !_have_desired_mid && _desired_target == 0 && !_archive_fetch;
|
||||
if (!resumeStagedFor(_fid, automatic)) return false;
|
||||
}
|
||||
_paused_from = IDLE;
|
||||
_fetch_error = FETCH_ERROR_NONE;
|
||||
@@ -1563,7 +1619,9 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) {
|
||||
!ota_trusted_auto_version_allows(_running_fw_version, parsed.fw_version)) {
|
||||
failFetch(FETCH_ERROR_VERSION); return;
|
||||
}
|
||||
if (automatic_fetch && _autofetch == AUTOFETCH_SIGNED && !parsed.is_signed()) {
|
||||
if (automatic_fetch && _autofetch == AUTOFETCH_SIGNED &&
|
||||
(!parsed.is_signed() || !_admit_manifest ||
|
||||
!_admit_manifest(_admit_manifest_ctx, parsed))) {
|
||||
failFetch(FETCH_ERROR_MANIFEST); return;
|
||||
}
|
||||
if (parsed.hash_algo != HASH_ALGO_SHA256) { failFetch(FETCH_ERROR_HASH_ALGO); return; }
|
||||
@@ -1604,6 +1662,9 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) {
|
||||
uint8_t hdr[8];
|
||||
memcpy(hdr, MOTA_MAGIC, 4);
|
||||
wr_u32le(hdr + 4, total);
|
||||
// Approval is local authorization, not part of the signed peer manifest.
|
||||
// A sender must never be able to arm an install by supplying these bytes.
|
||||
memcpy(_mf_buf + MOTA_SIGNED_LEN + 64u, APPROVAL_NOT, sizeof(APPROVAL_NOT));
|
||||
if (!_fetch->write(0, hdr, 8) ||
|
||||
!_fetch->write(8, mf, mfl) ||
|
||||
!_fetch->write(total - 5, MOTA_TRAILER, 5)) { failFetch(FETCH_ERROR_STORAGE); return; }
|
||||
@@ -1708,11 +1769,14 @@ void OtaManager::diffStep() {
|
||||
}
|
||||
|
||||
bool OtaManager::resumeStaged(const uint8_t* want_mid) {
|
||||
return resumeStagedFor(want_mid, want_mid == nullptr);
|
||||
}
|
||||
|
||||
bool OtaManager::resumeStagedFor(const uint8_t* want_mid, bool automatic_resume) {
|
||||
if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST
|
||||
|| _fstate == WANT_LEAVES || _fstate == VERIFYING_STAGED) {
|
||||
return false;
|
||||
}
|
||||
const bool automatic_resume = want_mid == nullptr;
|
||||
const uint32_t expected_target = automatic_resume
|
||||
? (_migration_target ? _migration_target : _target)
|
||||
: _fexpected_target;
|
||||
@@ -1732,7 +1796,9 @@ bool OtaManager::resumeStaged(const uint8_t* want_mid) {
|
||||
if (expected_target != 0 && m.target_id != expected_target) return false;
|
||||
if (automatic_resume) {
|
||||
if (_autofetch == AUTOFETCH_OFF) return false;
|
||||
if (_autofetch == AUTOFETCH_SIGNED && !m.is_signed()) return false;
|
||||
if (_autofetch == AUTOFETCH_SIGNED &&
|
||||
(!m.is_signed() || !_admit_manifest ||
|
||||
!_admit_manifest(_admit_manifest_ctx, m))) return false;
|
||||
if (_enforce_auto_version &&
|
||||
!ota_trusted_auto_version_allows(_running_fw_version, m.fw_version)) return false;
|
||||
}
|
||||
@@ -2014,13 +2080,14 @@ bool OtaManager::handleProof(const uint8_t* m, uint16_t n) {
|
||||
ReassemblySlot& slot = _reasm[slot_index];
|
||||
uint32_t block = slot.block;
|
||||
uint32_t blen = blockLen(block);
|
||||
noteFetchActivity();
|
||||
if (!merkle_verify(slot.buf, blen, block, pm.proof, pm.n_proof, _froot, _fbc)) {
|
||||
notePipelineStall();
|
||||
clearReassemblySlot((uint8_t)slot_index); // bad -> drop and re-fetch only this block
|
||||
if (activePipelineSlots() == 0) { finishFlight(); requestMissing(); }
|
||||
// A forged proof must not discard valid in-flight DATA or cause an
|
||||
// immediate request burst. Accept a later valid proof; repair a genuinely
|
||||
// damaged block only at the ordinary airtime-aware recovery deadline.
|
||||
slot.proof_failed = true;
|
||||
return true;
|
||||
}
|
||||
noteFetchActivity();
|
||||
// Commit payload before its present marker. Preserve the selected store and
|
||||
// checkpoint on failure: reconnectable stores pause, while local storage
|
||||
// reports a retryable failure instead of waiting for a nonexistent link.
|
||||
@@ -2184,6 +2251,13 @@ void OtaManager::requestMissing() {
|
||||
uint8_t slot = (uint8_t)((_retry_slot + offset) % OTA_FETCH_PIPELINE);
|
||||
if (_reasm[slot].block == NO_BLOCK) continue;
|
||||
ReassemblySlot& reassembly = _reasm[slot];
|
||||
if (reassembly.proof_failed) {
|
||||
const uint32_t block = reassembly.block;
|
||||
clearReassemblySlot(slot);
|
||||
reassembly.block = block;
|
||||
const uint16_t fragment_data = _wire_v2_session ? OTA_FRAG_DATA_V2 : OTA_FRAG_DATA;
|
||||
reassembly.need = frag_full_mask((blockLen(block) + fragment_data - 1u) / fragment_data);
|
||||
}
|
||||
const bool partial_v2 = _wire_v2_session && reassembly.wire_v2 && reassembly.mask != 0 &&
|
||||
!reassembly.awaiting_proof;
|
||||
if (partial_v2 && reassembly.wire_stalls >= 1) {
|
||||
@@ -2316,7 +2390,7 @@ bool OtaManager::dispatchMessage(const uint8_t* msg, uint16_t len) {
|
||||
case OTA_HAVE: handleHave(msg, len); return false;
|
||||
case OTA_GET_MANIFEST: return handleGetManifest(msg, len);
|
||||
case OTA_MANIFEST: handleManifest(msg, len); return false;
|
||||
case OTA_GET_LEAVES: handleGetLeaves(msg, len); return false;
|
||||
case OTA_GET_LEAVES: return handleGetLeaves(msg, len);
|
||||
case OTA_LEAVES: handleLeaves(msg, len); return false;
|
||||
case OTA_REQ: return handleReq(msg, len);
|
||||
case OTA_DATA: return handleData(msg, len);
|
||||
|
||||
@@ -41,6 +41,10 @@ struct OtaReplyRoute {
|
||||
};
|
||||
typedef bool (*OtaReplyRouteValid)(void* ctx, const OtaReplyRoute& route);
|
||||
|
||||
// Device admission is separate from portable block transport. Signed-only
|
||||
// automatic fetches must authenticate the manifest before touching staging.
|
||||
typedef bool (*OtaManifestAdmit)(void* ctx, const MotaManifest& manifest);
|
||||
|
||||
// Read `len` payload bytes at offset `off` from the serve source (flash-backed self-serve); false on
|
||||
// error. nullptr means the payload is a contiguous RAM buffer (the staged `.mota`).
|
||||
typedef bool (*ServeReadFn)(void* ctx, uint32_t off, uint8_t* buf, uint32_t len);
|
||||
@@ -104,6 +108,16 @@ static constexpr uint16_t ota_max_block_capability() { return (uint16_t)OTA_MAX_
|
||||
#ifndef OTA_MANIFEST_MAX_RETRY
|
||||
#define OTA_MANIFEST_MAX_RETRY 20 // give up (FAILED) after this many GET_MANIFEST retries - frees the slot
|
||||
#endif
|
||||
#ifndef OTA_AUTOFETCH_FAILURE_BACKOFF_MS
|
||||
#define OTA_AUTOFETCH_FAILURE_BACKOFF_MS 60000u
|
||||
#endif
|
||||
#ifndef OTA_AUTOFETCH_FAILURE_MAX_BACKOFF_MS
|
||||
#define OTA_AUTOFETCH_FAILURE_MAX_BACKOFF_MS 900000u
|
||||
#endif
|
||||
static_assert(OTA_AUTOFETCH_FAILURE_BACKOFF_MS > 0u &&
|
||||
OTA_AUTOFETCH_FAILURE_BACKOFF_MS <= OTA_AUTOFETCH_FAILURE_MAX_BACKOFF_MS &&
|
||||
OTA_AUTOFETCH_FAILURE_MAX_BACKOFF_MS < 0x80000000u,
|
||||
"OTA automatic failure cooldown must support wrap-safe elapsed time");
|
||||
#ifndef OTA_MANIFEST_SERVE_QUEUE
|
||||
#define OTA_MANIFEST_SERVE_QUEUE 4 // bounded manifest response descriptors (fragments are emitted one at a time)
|
||||
#endif
|
||||
@@ -472,6 +486,9 @@ public:
|
||||
// Auto-fetch policy (manual `ota pull` always works regardless): 0=off (discover only), 1=any
|
||||
// compatible own-target advert, 2=only signed adverts. Conservative default = off.
|
||||
static const uint8_t AUTOFETCH_OFF = 0, AUTOFETCH_ANY = 1, AUTOFETCH_SIGNED = 2;
|
||||
void set_manifest_admission(OtaManifestAdmit admit, void* ctx) {
|
||||
_admit_manifest = admit; _admit_manifest_ctx = ctx;
|
||||
}
|
||||
void set_autofetch(uint8_t p) { _autofetch = p; reDiscover(); }
|
||||
uint8_t autofetch() const { return _autofetch; }
|
||||
// One-way release migration for a legacy identity. Automatic OTA follows the
|
||||
@@ -563,15 +580,18 @@ public:
|
||||
struct CatRow {
|
||||
uint8_t mid[4];
|
||||
uint32_t target_id, fw_version;
|
||||
uint8_t codec, flags;
|
||||
uint8_t seeders[OTA_CAT_SEEDERS][4]; // distinct sources advertising this mid (deduped; capped)
|
||||
uint16_t seeder_have[OTA_CAT_SEEDERS]; // progress reported by each tracked source
|
||||
uint32_t seeder_last_ms[OTA_CAT_SEEDERS]; // age of each tracked source's latest HAVE
|
||||
uint8_t n_seeders; // count of the above (capped at OTA_CAT_SEEDERS) - "N+ nodes have it"
|
||||
uint32_t have_max; // best block-count any source reported (== total when a full copy exists)
|
||||
uint32_t last_ms;
|
||||
uint32_t retry_after_ms; // per-image archive retry deadline (0 = eligible)
|
||||
uint32_t seeder_last_ms[OTA_CAT_SEEDERS]; // age of each tracked source's latest HAVE
|
||||
uint8_t seeders[OTA_CAT_SEEDERS][4]; // distinct sources advertising this mid (deduped; capped)
|
||||
uint16_t seeder_have[OTA_CAT_SEEDERS]; // progress reported by each tracked source
|
||||
uint16_t have_max; // HAVE carries a uint16 block count
|
||||
uint8_t codec;
|
||||
uint8_t flags : 3; // MFLAG_KNOWN, validated before caching
|
||||
uint8_t n_seeders : 3; // bounded by OTA_CAT_SEEDERS = 4
|
||||
};
|
||||
static_assert(sizeof(CatRow) * OTA_MAX_CATALOG <= 16384u,
|
||||
"Maximum OTA catalog exceeds its qualified 16 KiB heap budget");
|
||||
uint8_t catalogCount() const { return _n_cat; }
|
||||
const CatRow* catalogRow(uint8_t i) const { return i < _n_cat ? &catalogData()[i] : nullptr; }
|
||||
uint8_t sourceCount() const { return _n_src; } // distinct OTA sources (beacon senders) heard
|
||||
@@ -585,6 +605,7 @@ private:
|
||||
const CatRow* catalogData() const { return _catalog_heap ? _catalog_heap : _catalog_inline; }
|
||||
uint16_t catalogCapacity() const { return _catalog_heap ? OTA_MAX_CATALOG : OTA_INLINE_CATALOG; }
|
||||
bool expandCatalog();
|
||||
void releaseSmallCatalog();
|
||||
|
||||
bool emit(const uint8_t* b, uint16_t n, bool flood, const OtaReplyRoute* route = nullptr) {
|
||||
const OtaReplyRoute* previous = _reply_route;
|
||||
@@ -599,7 +620,7 @@ private:
|
||||
void handleHave(const uint8_t* m, uint16_t n); // peer: catalog rows (+ startFetch if a row matches)
|
||||
bool handleGetManifest(const uint8_t* m, uint16_t n);
|
||||
void handleManifest(const uint8_t* m, uint16_t n);
|
||||
void handleGetLeaves(const uint8_t* m, uint16_t n); // serve: send the requested leaf fragments
|
||||
bool handleGetLeaves(const uint8_t* m, uint16_t n); // serve: send the requested leaf fragments
|
||||
void handleLeaves(const uint8_t* m, uint16_t n); // fetcher (validate): reassemble the target leaves
|
||||
bool beginLeafDiff(); // enter WANT_LEAVES if a seed diff is viable
|
||||
void diffStep(); // diff one batch of seed blocks per loop tick
|
||||
@@ -612,6 +633,7 @@ private:
|
||||
bool wantRow(const uint8_t* mid, uint32_t target, uint32_t fw_version,
|
||||
uint8_t codec, uint8_t flags) const; // fetch this row?
|
||||
bool fetchActive() const;
|
||||
bool resumeStagedFor(const uint8_t* want_mid, bool automatic);
|
||||
void clearFetchIntent();
|
||||
void failFetch(FetchError error);
|
||||
void completeFetch();
|
||||
@@ -639,7 +661,7 @@ private:
|
||||
bool extended_length = false);
|
||||
void noteServedRequestPacing(const uint8_t* mid, uint16_t block,
|
||||
uint16_t want, uint16_t full_mask);
|
||||
bool queueManifestJob(const uint8_t* mid, uint16_t want_mask);
|
||||
bool queueManifestJob(const uint8_t* mid, uint16_t want_mask, bool leaves = false);
|
||||
uint32_t manifestEgressGapMs() const;
|
||||
uint32_t proofEgressGapMs() const;
|
||||
bool serviceManifestEgress(OtaReplyRouteValid route_valid, void* route_ctx);
|
||||
@@ -730,6 +752,7 @@ private:
|
||||
uint16_t pending_mask;
|
||||
uint16_t emitted_mask;
|
||||
uint32_t ready_at;
|
||||
bool leaves;
|
||||
};
|
||||
ManifestServeJob _manifest_jobs[OTA_MANIFEST_SERVE_QUEUE];
|
||||
uint8_t _n_manifest_jobs = 0;
|
||||
@@ -773,6 +796,10 @@ private:
|
||||
bool _archive_fetch = false; // capture-only pull: accept any target/codec
|
||||
bool _archive_interest = false; // query full catalogs for the persistent archive
|
||||
uint8_t _seeder_id[4] = {0,0,0,0}; // our node id (pubkey[0:4]) for advert seeder counting
|
||||
OtaManifestAdmit _admit_manifest = nullptr;
|
||||
void* _admit_manifest_ctx = nullptr;
|
||||
uint32_t _auto_failure_at = 0;
|
||||
uint32_t _auto_failure_delay = 0;
|
||||
uint8_t _autofetch = AUTOFETCH_OFF; // auto-fetch policy (persisted in NodePrefs)
|
||||
uint32_t _running_fw_version = 0; // trusted EndF version used only for automatic admission
|
||||
bool _enforce_auto_version = false; // OtaContext enables this even when EndF/version is unknown
|
||||
@@ -794,6 +821,7 @@ private:
|
||||
uint16_t mask = 0; // received FRAG_DATA-slice bitmap
|
||||
uint16_t need = 0; // full bitmap for this block
|
||||
bool awaiting_proof = false;
|
||||
bool proof_failed = false; // preserve DATA until the normal recovery deadline
|
||||
uint32_t proof_request_at = 0; // proactive-proof grace deadline; 0 after fallback is sent
|
||||
uint16_t encoded_len = 0; // v2 wire length; zero until the first DATA fragment establishes it
|
||||
bool wire_v2 = false;
|
||||
@@ -856,6 +884,7 @@ private:
|
||||
CatRow _catalog_inline[OTA_INLINE_CATALOG];
|
||||
CatRow* _catalog_heap = nullptr;
|
||||
uint8_t _n_cat = 0;
|
||||
bool _catalog_browse_requested = false;
|
||||
uint32_t _now_ms = 0; // coarse clock (fed by set_clock; for ages/LRU/jitter)
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include "MotaContainer.h"
|
||||
#include "SignerAllowlist.h"
|
||||
#include <Identity.h>
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
// The signed flag is advisory until the allowlisted Ed25519 key verifies the
|
||||
// complete signed manifest. This runs before automatic staging can erase or
|
||||
// replace a previously retained image; installation verifies again separately.
|
||||
inline bool ota_manifest_trusted(const MotaManifest& manifest, const SignerAllowlist& allow) {
|
||||
if (!manifest.is_signed() || !manifest.manifest_start || !manifest.signer_pubkey ||
|
||||
!manifest.signature || manifest.signed_len != MOTA_SIGNED_LEN ||
|
||||
!allow.contains(manifest.signer_pubkey)) return false;
|
||||
mesh::Identity signer(manifest.signer_pubkey);
|
||||
return signer.verify(manifest.signature, manifest.manifest_start, (int)manifest.signed_len);
|
||||
}
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
Reference in new issue
Block a user