Harden recovery authentication and signed OTA admission

This commit is contained in:
mikecarper committed 2026-10-10 01:25:08 -07:00
1 parent 74bc91bf69
commit 5fa06ea49b
40 files changed
+3631 -2107

No files matched your search

+3 -2
View File
@@ -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;
+1 -1
View File
@@ -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");
}
}
+1 -1
View File
@@ -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;
+71 -34
View File
@@ -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;
+15 -4
View File
@@ -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);
+3 -1
View File
@@ -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));
+5 -1
View File
@@ -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.
+12 -2
View File
@@ -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.
File diff suppressed because it is too large. Load diff
+20 -11
View File
@@ -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=/");
+16 -10
View File
@@ -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;
}
+4 -2
View File
@@ -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;
+6
View File
@@ -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
View File
@@ -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);
+37 -8
View File
@@ -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)
};
+22
View File
@@ -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