From 5ee6059564bdf22bc4c5d97fd87f055cac086bca Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 3 Oct 2026 16:34:37 -0700 Subject: [PATCH] Finalize USB watchdog management format as MGR2 --- docs/_javascript/management_decoder.js | 21 ++-- docs/management_decoder.md | 23 ++-- docs/management_reports.md | 28 ++--- docs/usb_logging_watchdog.md | 20 +++- src/Mesh.cpp | 2 +- src/helpers/ManagementReport.h | 28 +++-- src/helpers/ManagementReporter.cpp | 2 +- test/fixtures/management/protocol_test.cpp | 25 ++++- test/fixtures/management/runtime_test.cpp | 4 +- test/test_management_decoder.js | 67 ++++++++---- test/test_management_report.py | 121 ++++++++++++++++----- test/test_trace_retry/test_trace_retry.cpp | 29 ++++- tools/management/report.py | 28 ++--- 13 files changed, 269 insertions(+), 129 deletions(-) diff --git a/docs/_javascript/management_decoder.js b/docs/_javascript/management_decoder.js index 0afd745c1..5095f6cd1 100644 --- a/docs/_javascript/management_decoder.js +++ b/docs/_javascript/management_decoder.js @@ -7,12 +7,10 @@ // which Web Crypto exposes directly. AES-CBC with a zero IV supplies the // required AES block primitive; only its first ciphertext block is used. const HEADER = 83; - const MGR2_HEADER = 98; const CURRENT_HEADER = 111; const TAG = 16; const ENTRY = 13; const PER_PAGE = 6; - const MGR2_PER_PAGE = 5; const CURRENT_PER_PAGE = 4; const MAX_KEYS = 36; const ZERO_BLOCK = new Uint8Array(16); @@ -221,17 +219,16 @@ function layout(payload) { const magic = bytesToAscii(payload, 0, 4); if (magic === "MGR1") return { header: HEADER, perPage: PER_PAGE }; - if (magic === "MGR2") return { header: MGR2_HEADER, perPage: MGR2_PER_PAGE }; - if (magic === "MGR3") return { header: CURRENT_HEADER, perPage: CURRENT_PER_PAGE }; + if (magic === "MGR2") return { header: CURRENT_HEADER, perPage: CURRENT_PER_PAGE }; return null; } function canonicalLength(payload) { const format = layout(payload); if (!format || payload.length < format.header + TAG) { - managementError("This data does not begin with a complete MGR1/MGR2/MGR3 management page."); + managementError("This data does not begin with a complete MGR1/MGR2 management page."); } - if (format.header >= MGR2_HEADER && ((uint16LE(payload, 83) & 0xf800) || (payload[85] & 0xc0))) { + if (format.header === CURRENT_HEADER && ((uint16LE(payload, 83) & 0xf800) || (payload[85] & 0xc0))) { managementError("Reserved USB status bits must be zero."); } if (format.header === CURRENT_HEADER) usbWatchdogEvent(payload); @@ -306,7 +303,7 @@ function inputByteStreams(input) { if (typeof input !== "string" || input.trim() === "") { - managementError("Paste one or more MGR1/MGR2/MGR3 payloads or GroupData packet hex values first."); + managementError("Paste one or more MGR1/MGR2 payloads or GroupData packet hex values first."); } if (input.length > 32768) managementError("The pasted value is too large to be management-report data."); // A wrapped single page is one candidate, not an invalid first-line @@ -359,7 +356,7 @@ } } if (!found.length) { - managementError("No complete management page was found. Paste canonical MGR1/MGR2/MGR3 payload hex or an entire GroupData packet."); + managementError("No complete management page was found. Paste canonical MGR1/MGR2 payload hex or an entire GroupData packet."); } return found; } @@ -393,7 +390,7 @@ } function usbStatus(payload) { - if (!["MGR2", "MGR3"].includes(bytesToAscii(payload, 0, 4))) return null; + if (bytesToAscii(payload, 0, 4) !== "MGR2") return null; const flags = uint16LE(payload, 83); const names = ["supported", "loggingEnabled", "watchdogEnabled", "hostConnected", "readerConnected", "stalled", "recovering", "recoveryDeferred", "persistenceReady", "watchdogAuto", "loggerActive"]; @@ -408,7 +405,7 @@ } function usbWatchdogEvent(payload) { - if (bytesToAscii(payload, 0, 4) !== "MGR3") return null; + if (bytesToAscii(payload, 0, 4) !== "MGR2") return null; const code = payload[98]; const sequence = uint32LE(payload, 107); if (!sequence) { @@ -626,8 +623,8 @@ : event.actionCode === 4 ? "Reboot request was cancelled; no physical reset is claimed." : "Recovery attempt was recorded; this does not prove successful recovery."]); } else { - values.push(["Last USB watchdog event", model.public.protocol === "MGR3" - ? "none recorded" : "unavailable in MGR1/MGR2"]); + values.push(["Last USB watchdog event", model.public.protocol === "MGR2" + ? "none recorded" : "unavailable in MGR1"]); } summary.replaceChildren(); values.forEach(([term, description]) => { diff --git a/docs/management_decoder.md b/docs/management_decoder.md index 50268a3f2..093feff7c 100644 --- a/docs/management_decoder.md +++ b/docs/management_decoder.md @@ -1,6 +1,6 @@ # Management report decoder -Paste MGR1, MGR2, or MGR3 management-report payloads or complete GroupData packets from a +Paste MGR1 or MGR2 management-report payloads or complete GroupData packets from a packet analyzer to read a radio's public management information. Everything, including password authentication and ACL decryption, happens locally in this browser. The report, password, and candidate public key are never uploaded. @@ -13,13 +13,13 @@ by someone who does not know that password. ## Decode a management report
- +

@@ -69,8 +69,7 @@ by someone who does not know that password. - Complete `PAYLOAD_TYPE_GRP_DATA` (`0x06`) analyzer/MQTT packet hex. It checks the MeshCore route header, encoded path length, version-specific page bounds, and required zero padding. -- A canonical payload beginning with `4D475231` (`MGR1`), `4D475232` (`MGR2`), - or `4D475233` (`MGR3`). +- A canonical payload beginning with `4D475231` (`MGR1`) or `4D475232` (`MGR2`). - One raw packet or canonical payload per line; duplicate observations of an identical page are deduplicated. @@ -79,22 +78,26 @@ the report-specific fingerprints and the administrator and/or OTA-signer flags. It cannot turn a fingerprint back into a full key. Use the optional candidate field to test a specific full public key. -MGR2 and MGR3 also show USB logging, host/reader connection, logger-client activity, stalled/recovering/deferred +MGR2 also shows USB logging, host/reader connection, logger-client activity, stalled/recovering/deferred state, recovery stage, saved backoff, retry interval/inactive seconds, Auto qualification seconds, and persistence readiness. Unsupported observation is shown explicitly; legacy MGR1 has no USB block and shows it as unavailable. These values are the report-start snapshot, not proof that a Pi or host application is healthy. Older decoder -copies require an update for MGR3. +copies require an update for MGR2. -MGR3 additionally shows the latest USB watchdog action, reason mask and names, +MGR2 also shows the latest USB watchdog action, reason mask and names, advisory node-clock timestamp, recorded-boot uptime seconds, event sequence, and whether the record was durably saved. Software recovery and re-enumeration mean attempted actions, not successful repairs. A reboot request is not proof that a reboot completed; a later cancellation can replace it. The node clock can be wrong, even when it resembles a valid date. Event uptime and sequence -remain useful for ordering. MGR1/MGR2 do not carry this event field and show it -as unavailable. All 36 ACL entries still fit, using up to nine MGR3 pages. +remain useful for ordering. MGR1 does not carry this event field and shows it +as unavailable. All 36 ACL entries still fit, using up to nine MGR2 pages. + +The earlier USB-only MGR2 development layout was never released. This decoder +accepts the finalized MGR2 format with its 111-byte public header and latest +watchdog event. Reports contain a frozen latest-event snapshot, not immediate watchdog alerts or a complete event history. See [USB logging watchdog](usb_logging_watchdog.md) diff --git a/docs/management_reports.md b/docs/management_reports.md index 1cbd945b4..5cf96aa0f 100644 --- a/docs/management_reports.md +++ b/docs/management_reports.md @@ -1,4 +1,4 @@ -# Management reports (MGR1/MGR2/MGR3) +# Management reports (MGR1/MGR2) Available on repeater (including observer), room-server and sensor firmware. Companions/terminal-chat nodes and KISS modems do not originate these reports. @@ -6,10 +6,11 @@ Off by default. A management password and explicit enable are both required. No radio settings, existing preferences layout, or OTA authorization policy is changed by enabling reporting. This protocol does **not** authorize updates. -Current firmware emits `MGR3`, including a public USB logging/recovery snapshot -and its latest watchdog event. Updated decoders still accept legacy `MGR1` and -`MGR2`; older decoder copies need an update to read MGR3. Reporting USB status -does not enable logging or change retry policy. +Current firmware emits `MGR2`, including a public USB logging/recovery snapshot +and its latest watchdog event. Updated decoders still accept released `MGR1`; +older decoder copies need an update to read MGR2. The earlier USB-only MGR2 +development layout was never released and was replaced before release. +Reporting USB status does not enable logging or change retry policy. ## CLI @@ -97,7 +98,7 @@ is bounded to 1.5 KiB, plus a small configuration object and temporary stack use ## Routing and MQTT Both direct/path and flood reports use **`PAYLOAD_TYPE_GRP_DATA` (`0x06`)**. -The route bits independently select direct or flood. `MGR1`/`MGR2`/`MGR3` are application +The route bits independently select direct or flood. `MGR1`/`MGR2` are application extensions with **literally plaintext public fields**, not a call to the ordinary encrypted `createGroupDatagram()` builder. A fixed public marker is not an owner or password-derived channel ID. There is no outer channel encryption. @@ -133,11 +134,11 @@ For a browser-local decoder that also decrypts and authenticates ACL entries, use the [Management report decoder](management_decoder.md). It does not upload the captured packet or password. -## Canonical MGR3 payload (little endian) +## Canonical MGR2 payload (little endian) | Offset | Bytes | Field | |---:|---:|---| -| 0 | 4 | `MGR3` | +| 0 | 4 | `MGR2` | | 4 | 16 | First 16 bytes of reporter public key | | 20 | 4 | Persisted report sequence (never wraps; exhaustion stops TX) | | 24 | 4 | RTC Unix timestamp; advisory, may be wrong | @@ -176,7 +177,7 @@ the captured packet or password. | after ACL | 16 | Full AES-SIV authentication tag | | after tag | 0-15 | Zero padding to group-compatible length; not part of canonical payload | -MGR3 retains all 36 ACL entries, using four per page and up to nine pages. The +MGR2 retains all 36 ACL entries, using four per page and up to nine pages. The largest canonical page is 179 bytes and already group-compatible; the radio payload budget is unchanged. The USB snapshot is frozen with the other public fields at report start and repeated on each page. It is not a @@ -230,11 +231,6 @@ normal reboot and mode changes, but is not a log of hardware hangs or sudden power failures. See [USB logging watchdog](usb_logging_watchdog.md) for storage failure, cancellation, and review limitations. -Legacy MGR2 retains the same offsets through byte 97, followed by encrypted ACL -entries at byte 98 and the authentication tag. It carries five entries per page -and up to eight pages. Its USB status is available, but watchdog event history -is unavailable rather than an empty modern record. - Legacy MGR1 retains the same offsets through byte 82, followed immediately by encrypted ACL entries at byte 83 and the authentication tag. It carries six entries per page and up to six pages, with no USB block. Its absent USB @@ -273,8 +269,8 @@ needs a candidate administrator's full key to identify a fingerprint. Encryption is [RFC 5297 AES-SIV-CMAC-256](https://www.rfc-editor.org/rfc/rfc5297), using the existing rweather AES primitive. The single associated-data string is -the complete clear header: 111 bytes for MGR3, including USB status and its event, -98 bytes for MGR2, or 83 bytes for MGR1. All retain the existing derivation domains; the authenticated +the complete clear header: 111 bytes for MGR2, including USB status and its event, +or 83 bytes for MGR1. Both retain the existing derivation domains; the authenticated magic and layout distinguish their wire versions. Ciphertext is exactly the ACL byte length. Full 16-byte tag, no truncation. This deterministic misuse-resistant mode avoids reliance on the firmware's noncryptographic general-purpose RNG or RTC nonces. diff --git a/docs/usb_logging_watchdog.md b/docs/usb_logging_watchdog.md index cd21c1508..de88cb1a9 100644 --- a/docs/usb_logging_watchdog.md +++ b/docs/usb_logging_watchdog.md @@ -155,10 +155,10 @@ an exact trigger time; this record covers the staged USB logging watchdog. | `defer`, `fs` | Ownership/backend deferral and verified durable persistence readiness. | | `rec`, `boot` | Attempted USB recoveries and durable reset authorizations. Attempts save the event and recovery count together; a failed save can leave the live recovery count unsaved. The reset count can include a later safety/health veto; it is not a count of completed physical reboots. | -The [MGR3 management report](management_reports.md) exports the USB status and +The [MGR2 management report](management_reports.md) exports the USB status and the same latest-event record as a frozen, authenticated public snapshot. Updated -Python and browser decoders retain MGR1/MGR2 support; older decoder copies must -be updated to read MGR3. Events appear on the normal management schedule, not +Python and browser decoders retain released MGR1 support; older decoder copies +must be updated to read MGR2. Events appear on the normal management schedule, not as immediate alerts. Reading status does not renew the logging-client lease. ## Verification @@ -182,12 +182,16 @@ and ordinary RAK 3x72 STM32 USB Companion. The STM32 image retains all CLI features and its 32 KiB filesystem, but has only 368 bytes of app flash margin; its unsupported USB watchdog machinery is compiled out. -Latest-event/MGR3 verification on the same date passed 116 USB regression tests, +Latest-event verification on the same date passed 116 USB regression tests, 15 management tests with fatal AddressSanitizer/UndefinedBehaviorSanitizer checks, and the browser decoder tests. Coverage includes timestamp callbacks, clock corrections and rollover, legacy-state migration, commit failures, reboot-intent cancellation, deferred cancellation saves, and authenticated -cross-language MGR1/MGR2/MGR3 decoding. The full native suite passed 1,713 cases +cross-language decoding of the development report formats. The extension was +called MGR3 during those pre-release checks, then assigned to MGR2 because the +earlier USB-only MGR2 layout had never been released. Current decoders support +released MGR1 and the finalized MGR2 layout; this reassignment was not a migration +of deployed MGR2 reports. The full native suite passed 1,713 cases during implementation; the final packet validator also passed a fresh 116-case routing suite and 5,855,168 previous/current validator comparisons under fatal sanitizers. @@ -217,3 +221,9 @@ latter uses 229,020 of 229,376 app flash bytes (356 bytes of margin), retaining all features and the 32 KiB filesystem. The bare recipe is tighter still. These checks do not qualify every board or physical USB recovery. No device was flashed and existing `out` artifacts were not changed. + +Finalizing the event-capable layout as MGR2 passed 1,719 native cases, 16 +management tests with real C++/Python/browser cryptography, the standalone +browser decoder checks, and the bare STM32 Companion compile. Coverage retains +MGR1, rejects retired formats and old prototype ciphertext, and verifies that +invalid management sends release their packet without queueing it. diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 2ae164ad2..cf17afb8d 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -1232,7 +1232,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { break; } - // MGR1/MGR2/MGR3 are plaintext management extensions, not encrypted channel + // MGR1/MGR2 are plaintext management extensions, not encrypted channel // datagram. Keep the legacy group payload length shape for opaque relay // compatibility. Recognize it before any channel decryption/delivery. if (pkt->getPayloadType() == PAYLOAD_TYPE_GRP_DATA && diff --git a/src/helpers/ManagementReport.h b/src/helpers/ManagementReport.h index 2f1cc9ee3..3b6e42c76 100644 --- a/src/helpers/ManagementReport.h +++ b/src/helpers/ManagementReport.h @@ -10,14 +10,13 @@ // protocol. See docs/management_reports.md for the byte layout and trust model. namespace mesh { namespace management { constexpr size_t HEADER = 83, TAG = 16, ENTRY = 13, PER_PAGE = 6; -constexpr size_t USB_STATUS_SIZE = 15, MGR2_HEADER = HEADER + USB_STATUS_SIZE; -constexpr size_t MGR2_PER_PAGE = 5; -constexpr size_t WATCHDOG_EVENT_OFFSET = MGR2_HEADER; -constexpr size_t CURRENT_HEADER = MGR2_HEADER + USB_WATCHDOG_EVENT_SIZE; +constexpr size_t USB_STATUS_SIZE = 15; +constexpr size_t WATCHDOG_EVENT_OFFSET = HEADER + USB_STATUS_SIZE; +constexpr size_t CURRENT_HEADER = WATCHDOG_EVENT_OFFSET + USB_WATCHDOG_EVENT_SIZE; constexpr size_t CURRENT_PER_PAGE = 4; constexpr size_t MAX_KEYS = 36, MAX_PAGES = 9; constexpr size_t MAX_PAYLOAD = CURRENT_HEADER + TAG + CURRENT_PER_PAGE * ENTRY; -static_assert(MAX_PAYLOAD == 179, "MGR3 must fit the legacy padded payload budget"); +static_assert(MAX_PAYLOAD == 179, "MGR2 must fit the legacy padded payload budget"); constexpr uint32_t DAY = 86400, FLOOD_INTERVAL = 21 * DAY; enum Feature : uint8_t { WIFI = 1, GPS = 2, NTP = 4, USB = 8, OTA = 16 }; enum Valid : uint16_t { FIRMWARE = 1, BOOTLOADER = 2, BASE = 4, STORE = 8, @@ -103,13 +102,13 @@ bool seal(const uint8_t key[32], const uint8_t* aad, size_t aad_len, bool open(const uint8_t key[32], const uint8_t* aad, size_t aad_len, uint8_t* data, size_t len, const uint8_t tag[16]); bool equal(const uint8_t* a, const uint8_t* b, size_t size); -inline bool currentPage(const uint8_t* p) { return !memcmp(p, "MGR3", 4); } -inline bool usbPage(const uint8_t* p) { return currentPage(p) || !memcmp(p, "MGR2", 4); } +inline bool currentPage(const uint8_t* p) { return !memcmp(p, "MGR2", 4); } +inline bool usbPage(const uint8_t* p) { return currentPage(p); } inline size_t headerSize(const uint8_t* p) { - return currentPage(p) ? CURRENT_HEADER : usbPage(p) ? MGR2_HEADER : HEADER; + return currentPage(p) ? CURRENT_HEADER : HEADER; } inline size_t entriesPerPage(const uint8_t* p) { - return currentPage(p) ? CURRENT_PER_PAGE : usbPage(p) ? MGR2_PER_PAGE : PER_PAGE; + return currentPage(p) ? CURRENT_PER_PAGE : PER_PAGE; } inline size_t pageSize(const uint8_t* p) { return headerSize(p) + p[82] * ENTRY + TAG; } inline size_t floodSize(size_t canonical) { return 3 + ((canonical - 3 + 15) / 16) * 16; } @@ -123,14 +122,13 @@ inline bool validPage(const uint8_t* p, size_t size, bool flood_padding = false) const uint32_t magic = read32(p); if ((magic & 0xffffffu) != 0x52474du) return false; // exact MGR prefix const unsigned version = (magic >> 24) - '1'; - if (version > 2) return false; - const size_t minimum = version == 0 ? HEADER + TAG - : version == 1 ? MGR2_HEADER + TAG : CURRENT_HEADER + TAG; - const size_t per_page = PER_PAGE - version; + if (version > 1) return false; + const size_t minimum = (version ? CURRENT_HEADER : HEADER) + TAG; + const size_t per_page = version ? CURRENT_PER_PAGE : PER_PAGE; if (size < minimum) return false; // Reserved bits occupy the upper flag byte and upper two stage bits. if (version && (read16(p + HEADER + 1) & 0xc0f8u)) return false; - if (version == 2 && !validUsbWatchdogEvent(p + WATCHDOG_EVENT_OFFSET)) return false; + if (version && !validUsbWatchdogEvent(p + WATCHDOG_EVENT_OFFSET)) return false; const unsigned page = p[78], pages = p[79], total = p[80], first = p[81], count = p[82]; const unsigned expected_pages = total ? (total + per_page - 1) / per_page : 1; if (total > MAX_KEYS || pages != expected_pages || page >= pages || first != page * per_page) return false; @@ -147,7 +145,7 @@ inline bool validPage(const uint8_t* p, size_t size, bool flood_padding = false) return true; } -// MGR2/MGR3 public USB snapshot. The AES-SIV AAD authenticates every byte. +// MGR2 public USB snapshot. The AES-SIV AAD authenticates every byte. inline void encodeUsbStatus(uint8_t* p, const UsbLoggingStatus& status) { const uint16_t flags = (status.supported ? 1u : 0u) | (status.logging_enabled ? 2u : 0u) | (status.watchdog_enabled ? 4u : 0u) | diff --git a/src/helpers/ManagementReporter.cpp b/src/helpers/ManagementReporter.cpp index 5bd5fb7cd..0c5b4eea1 100644 --- a/src/helpers/ManagementReporter.cpp +++ b/src/helpers/ManagementReporter.cpp @@ -103,7 +103,7 @@ uint32_t ManagementReporter::jitter() const { } void ManagementReporter::snapshot() { auto& w = *work; memset(w.header, 0, sizeof(w.header)); w.acl = AclList(); - auto* p = w.header; memcpy(p, "MGR3", 4); memcpy(p + 4, mesh.self_id.pub_key, 16); + auto* p = w.header; memcpy(p, "MGR2", 4); memcpy(p + 4, mesh.self_id.pub_key, 16); const UsbLoggingStatus usb = usbLoggingStatus(); encodeUsbStatus(p + HEADER, usb); encodeUsbWatchdogEvent(p + WATCHDOG_EVENT_OFFSET, usb.last_event); diff --git a/test/fixtures/management/protocol_test.cpp b/test/fixtures/management/protocol_test.cpp index 408b26223..f064292ef 100644 --- a/test/fixtures/management/protocol_test.cpp +++ b/test/fixtures/management/protocol_test.cpp @@ -77,8 +77,8 @@ int main() { usb.logger_active = false; encodeUsbStatus(usb_block, usb); assert(read16(usb_block) == 1023); // host/reader connection does not imply a logger usb.logger_active = true; - // Emit all wire versions for independent Python and browser decoders. - for (const char* magic : {"MGR1", "MGR2", "MGR3"}) { + // Emit both supported wire versions for independent Python/browser decoders. + for (const char* magic : {"MGR1", "MGR2"}) { const auto* version = reinterpret_cast(magic); const bool current = currentPage(version), has_usb = usbPage(version); const size_t header = headerSize(version), per_page = entriesPerPage(version); @@ -120,7 +120,7 @@ int main() { } // A current report with no recorded event is authenticated, not invented. { - uint8_t raw[179] = {}, enc[32]; memcpy(raw, "MGR3", 4); memcpy(raw + 4, radio, 16); + uint8_t raw[179] = {}, enc[32]; memcpy(raw, "MGR2", 4); memcpy(raw + 4, radio, 16); write32(raw + 20, 43); raw[79] = 1; deriveKey(root, "MeshCore-MGR1-SIV", radio, enc); assert(seal(enc, raw, CURRENT_HEADER, raw + CURRENT_HEADER, 0, raw + CURRENT_HEADER)); @@ -129,7 +129,7 @@ int main() { // Every ACL count retains complete coverage and the unchanged flood ceiling. for (unsigned total = 0; total <= MAX_KEYS; ++total) { for (unsigned page = 0; page < (total ? (total + 3) / 4 : 1); ++page) { - uint8_t raw[179] = {}; memcpy(raw, "MGR3", 4); + uint8_t raw[179] = {}; memcpy(raw, "MGR2", 4); raw[78] = page; raw[79] = total ? (total + 3) / 4 : 1; raw[80] = total; raw[81] = page * 4; raw[82] = total - raw[81] < 4 ? total - raw[81] : 4; const size_t size = pageSize(raw); @@ -139,8 +139,23 @@ int main() { } for (size_t n = 0; n < CURRENT_HEADER + TAG; ++n) { std::vector truncated(n); - for (size_t i = 0; i < n && i < 4; ++i) truncated[i] = "MGR3"[i]; + for (size_t i = 0; i < n && i < 4; ++i) truncated[i] = "MGR2"[i]; assert(!validPage(truncated.data(), n)); } for (size_t n = 0; n < HEADER + TAG; ++n) assert(!validPage(plain.data(), n)); + // No third wire version or USB-only 98-byte prototype interpretation. + for (uint8_t version : {uint8_t('0'), uint8_t('3'), uint8_t('4'), uint8_t(0xff)}) { + uint8_t raw[MAX_PAYLOAD] = {}; memcpy(raw, "MGR2", 4); + raw[3] = version; raw[79] = 1; + assert(!validPage(raw, CURRENT_HEADER + TAG)); + assert(!validPage(raw, floodSize(CURRENT_HEADER + TAG), true)); + } + { + uint8_t raw[MAX_PAYLOAD] = {}; memcpy(raw, "MGR2", 4); raw[79] = 1; + assert(!validPage(raw, 98 + TAG)); + assert(!validPage(raw, floodSize(98 + TAG), true)); + raw[80] = raw[82] = 5; + assert(!validPage(raw, 98 + TAG + 5 * ENTRY)); + assert(!validPage(raw, floodSize(98 + TAG + 5 * ENTRY), true)); + } } diff --git a/test/fixtures/management/runtime_test.cpp b/test/fixtures/management/runtime_test.cpp index 77fd16060..1cc5a3884 100644 --- a/test/fixtures/management/runtime_test.cpp +++ b/test/fixtures/management/runtime_test.cpp @@ -118,7 +118,7 @@ int main() { for (unsigned i = 0; i < 9; ++i) { const auto& p = f.mesh.packets[i]; assert(validPage(p.payload, p.payload_len)); assert(p.payload[78] == i && p.payload[80] == 36 && p.flood); - assert(!memcmp(p.payload, "MGR3", 4) && read16(p.payload + HEADER) == 61); + assert(!memcmp(p.payload, "MGR2", 4) && read16(p.payload + HEADER) == 61); assert(!(read16(p.payload + HEADER) & 1024)); // an open reader is not an active logger assert(read32(p.payload + HEADER + 3) == 7200); assert(read32(p.payload + HEADER + 7) == 3600); @@ -133,7 +133,7 @@ int main() { Fixture f; for (unsigned i = 0; i < 7; ++i) { ClientInfo c; c.id.pub_key[0] = i; f.acl.clients.push_back(c); } f.configure(false); f.advance(21 * DAY); assert(f.mesh.packets.size() == 1); - assert(!memcmp(f.mesh.packets[0].payload, "MGR3", 4)); + assert(!memcmp(f.mesh.packets[0].payload, "MGR2", 4)); assert(read16(f.mesh.packets[0].payload + HEADER) == 0); // unsupported is explicit test_usb_status.supported = true; test_usb_status.retry_seconds = 999; test_usb_status.watchdog_auto = true; test_usb_status.auto_connected_seconds = 100; diff --git a/test/test_management_decoder.js b/test/test_management_decoder.js index 9a89d65dd..490da032b 100644 --- a/test/test_management_decoder.js +++ b/test/test_management_decoder.js @@ -29,15 +29,16 @@ function hex(text) { assert.strictEqual(publicOnly.public.usbLogging, null); assert.strictEqual(publicOnly.public.usbWatchdogLast, null); - // Public MGR2 parsing is additive; actual MGR2 SIV ciphertext is checked - // cross-language by test_management_report.py using the production encoder. + // MGR2 is the full event-capable layout. The unreleased 98-byte prototype + // and MGR3 magic have no compatibility fallback. Actual MGR1/MGR2 SIV + // ciphertext is checked cross-language using the production encoder. const legacy = Buffer.from(decoder.EXAMPLE_PAGE, "hex"); const currentHeader = Buffer.from(legacy.subarray(0, 83)); currentHeader[3] = 0x32; const usb = Buffer.alloc(15); usb.writeUInt16LE(0x07ff, 0); usb[2] = 2 | (8 << 2); usb.writeUInt32LE(604800, 3); usb.writeUInt32LE(0xffffffff, 7); usb.writeUInt32LE(1209600, 11); - const current = Buffer.concat([currentHeader, usb, legacy.subarray(83)]); + const current = Buffer.concat([currentHeader, usb, Buffer.alloc(13), legacy.subarray(83)]); const currentPublic = await decoder.decodeManagement(current.toString("hex"), ""); assert.strictEqual(currentPublic.public.protocol, "MGR2"); assert.strictEqual(currentPublic.public.usbWatchdogLast, null); @@ -57,7 +58,7 @@ function hex(text) { assert.strictEqual(readerWithoutLogger.hostConnected, true); assert.strictEqual(readerWithoutLogger.readerConnected, true); assert.strictEqual(readerWithoutLogger.loggerActive, false); - const badCount = Buffer.from(current); badCount[82] = 6; + const badCount = Buffer.from(current); badCount[82] = 5; await assert.rejects(decoder.decodeManagement(badCount.toString("hex"), ""), /bounds/); await assert.rejects(decoder.decodeManagement(current.subarray(0, 99).toString("hex"), ""), /complete/); for (const [offset, value] of [[84, 8], [85, 0xc0]]) { @@ -65,39 +66,67 @@ function hex(text) { await assert.rejects(decoder.decodeManagement(reserved.toString("hex"), ""), /Reserved/); } - // Public MGR3 fields use their own layout; production ciphertext for all - // three versions is authenticated cross-language by the Python host test. - const mgr3Header = Buffer.from(current.subarray(0, 98)); mgr3Header[3] = 0x33; + const prototype = Buffer.concat([currentHeader, usb, legacy.subarray(83)]); + await assert.rejects(decoder.decodeManagement(prototype.toString("hex"), ""), /length|Invalid/); + const emptyPrototype = Buffer.alloc(98 + 16); + currentHeader.copy(emptyPrototype); emptyPrototype[80] = 0; emptyPrototype[82] = 0; + await assert.rejects(decoder.decodeManagement(emptyPrototype.toString("hex"), ""), /complete/); + const mgr3 = Buffer.from(current); mgr3[3] = 0x33; + await assert.rejects(decoder.decodeManagement(mgr3.toString("hex"), ""), /No complete/); + await assert.rejects(decoder.decodeManagement("1A00" + mgr3.toString("hex"), ""), /No complete/); + + // Watchdog fields remain at offsets 98..110 in current MGR2. const event = Buffer.alloc(13); event[0] = 0xbf; event.writeUInt32LE(1700000001, 1); event.writeUInt32LE(777, 5); event.writeUInt32LE(42, 9); - const mgr3 = Buffer.concat([mgr3Header, event, current.subarray(98)]); - const mgr3Public = await decoder.decodeManagement(mgr3.toString("hex"), ""); - assert.strictEqual(mgr3Public.public.protocol, "MGR3"); - assert.strictEqual(mgr3Public.public.usbLogging.loggerActive, true); - assert.deepStrictEqual(mgr3Public.public.usbWatchdogLast, { + const withEvent = Buffer.from(current); event.copy(withEvent, 98); + const eventPublic = await decoder.decodeManagement(withEvent.toString("hex"), ""); + assert.strictEqual(eventPublic.public.protocol, "MGR2"); + assert.strictEqual(eventPublic.public.usbLogging.loggerActive, true); + assert.deepStrictEqual(eventPublic.public.usbWatchdogLast, { reasons: 15, reasonNames: ["host-absent", "reader-absent", "tx-stalled", "client-inactive"], actionCode: 3, action: "reboot-requested", persisted: true, epoch: 1700000001, uptimeSeconds: 777, sequence: 42, }); - const none = Buffer.from(mgr3); none.fill(0, 98, 111); + const none = Buffer.from(withEvent); none.fill(0, 98, 111); assert.strictEqual((await decoder.decodeManagement(none.toString("hex"), "")).public.usbWatchdogLast, null); for (let action = 1; action <= 4; ++action) { - const ramOnly = Buffer.from(mgr3); ramOnly[98] = 15 | (action << 4); ramOnly.writeUInt32LE(0, 99); + const ramOnly = Buffer.from(withEvent); ramOnly[98] = 15 | (action << 4); ramOnly.writeUInt32LE(0, 99); const decodedEvent = (await decoder.decodeManagement(ramOnly.toString("hex"), "")).public.usbWatchdogLast; assert.strictEqual(decodedEvent.actionCode, action); assert.strictEqual(decodedEvent.persisted, false); assert.strictEqual(decodedEvent.epoch, 0); } - for (const code of [0x80, 0x8f, 0xdf, 0xef, 0xff]) { - const invalid = Buffer.from(mgr3); invalid[98] = code; + for (const code of [0x80, 0x8f, 0x90, 0xa0, 0xb0, 0xc0, 0xdf, 0xef, 0xff]) { + const invalid = Buffer.from(withEvent); invalid[98] = code; await assert.rejects(decoder.decodeManagement(invalid.toString("hex"), ""), /Invalid/); } - const invalidSequence = Buffer.from(mgr3); invalidSequence.writeUInt32LE(0, 107); + const invalidSequence = Buffer.from(withEvent); invalidSequence.writeUInt32LE(0, 107); await assert.rejects(decoder.decodeManagement(invalidSequence.toString("hex"), ""), /Invalid/); - const fiveEntries = Buffer.from(mgr3); fiveEntries[82] = 5; + const fiveEntries = Buffer.from(withEvent); fiveEntries[82] = 5; await assert.rejects(decoder.decodeManagement(fiveEntries.toString("hex"), ""), /bounds/); for (const size of [99, 110, 126]) { - await assert.rejects(decoder.decodeManagement(mgr3.subarray(0, size).toString("hex"), ""), /complete/); + await assert.rejects(decoder.decodeManagement(withEvent.subarray(0, size).toString("hex"), ""), /complete/); + } + // Thirty-six ACL keys use exactly nine current pages, four entries per + // page. The unchanged MGR1 layout remains six entries across six pages. + for (const [source, header, perPage, pageCount] of [ + [withEvent, 111, 4, 9], [legacy, 83, 6, 6], + ]) { + const pages = []; + for (let page = 0; page < pageCount; ++page) { + const packed = Buffer.alloc(header + perPage * 13 + 16); + source.copy(packed, 0, 0, header); + packed[78] = page; packed[79] = pageCount; packed[80] = 36; + packed[81] = page * perPage; packed[82] = perPage; + pages.push(packed.toString("hex")); + } + const report = await decoder.decodeManagement(pages.join("\n"), ""); + assert.strictEqual(report.complete, true); + assert.strictEqual(report.pageCount, pageCount); + assert.strictEqual(report.suppliedPages, pageCount); + assert.strictEqual(report.public.protocol, header === 111 ? "MGR2" : "MGR1"); + const excessive = Buffer.from(pages[0], "hex"); excessive[79] = pageCount + 1; + await assert.rejects(decoder.decodeManagement(excessive.toString("hex"), ""), /bounds/); } const decoded = await decoder.decodeManagement( diff --git a/test/test_management_report.py b/test/test_management_report.py index 53e6b8dee..5da506477 100644 --- a/test/test_management_report.py +++ b/test/test_management_report.py @@ -34,10 +34,25 @@ class ManagementTests(unittest.TestCase): subprocess.run(cmd, check=True, capture_output=True, text=True) result = subprocess.run([str(cls.exe)], check=True, capture_output=True, text=True) emitted = [bytes.fromhex(line) for line in result.stdout.splitlines()] - cls.pages = [page for page in emitted if page[:4] == b"MGR3" and int.from_bytes(page[20:24], "little") == 42] - cls.mgr2_pages = [page for page in emitted if page[:4] == b"MGR2"] + cls.pages = [page for page in emitted if page[:4] == b"MGR2" and int.from_bytes(page[20:24], "little") == 42] cls.legacy_pages = [page for page in emitted if page[:4] == b"MGR1"] - cls.empty_event_pages = [page for page in emitted if page[:4] == b"MGR3" and int.from_bytes(page[20:24], "little") == 43] + cls.empty_event_pages = [page for page in emitted if page[:4] == b"MGR2" and int.from_bytes(page[20:24], "little") == 43] + cls.prototypes = [cls._old_prototype(count) for count in (0, 4, 5)] + # An old four-entry page pads from 166 to 179 bytes, the same length as + # a current four-entry page. Its encrypted bytes can look like a valid + # public event. Public shape checks cannot distinguish that overlap; + # the unchanged AES-SIV key domain with the new 111-byte AAD must. + for sequence in range(44, 300): + prototype = cls._old_prototype(4, sequence) + padded = prototype + bytes(179 - len(prototype)) + try: + report._page_bounds(padded) + except ValueError: + continue + cls.colliding_prototype = padded + break + else: + raise AssertionError("no public-shape overlap found for old MGR2 prototype") # Compile the unchanged production runtime and transaction writer with # a memory filesystem/radio in place of the hardware-facing headers. fixture = ROOT / "test/fixtures/management" @@ -60,12 +75,27 @@ class ManagementTests(unittest.TestCase): def tearDownClass(cls): cls.work.cleanup() + @classmethod + def _old_prototype(cls, count, sequence=44): + """Generate authentic unreleased MGR2 with its original 98-byte AAD.""" + from Crypto.Cipher import AES + header = bytearray(cls.pages[0][:98]) + header[20:24] = sequence.to_bytes(4, "little") + header[78:83] = bytes((0, 1, count, 0, count)) + key = report.derive(report.password_key("management test password"), + "MeshCore-MGR1-SIV", header[4:20]) + cipher = AES.new(key, AES.MODE_SIV) + cipher.update(header) + private = (bytes(12) + b"\x01") * count + ciphertext, tag = cipher.encrypt_and_digest(private) + return bytes(header) + ciphertext + tag + def test_interoperable_full_report(self): decoded = report.decode_report(list(reversed(self.pages)), "management test password") self.assertEqual(decoded["sequence"], 42) self.assertEqual(len(decoded["acl"]), 36) self.assertEqual(decoded["firmware_version"], "1.17.1.5") - self.assertEqual(decoded["protocol"], "MGR3") + self.assertEqual(decoded["protocol"], "MGR2") self.assertEqual(len(self.pages), 9) usb = decoded["usb_logging"] self.assertTrue(usb["supported"] and usb["recovery_deferred"]) @@ -92,17 +122,46 @@ class ManagementTests(unittest.TestCase): with self.assertRaises(ValueError): report.decode_report([self.legacy_pages[0]] + self.pages[1:], "management test password") - def test_mgr2_crypto_still_decodes_without_inventing_event(self): - decoded = report.decode_report(self.mgr2_pages, "management test password") - self.assertEqual(decoded["protocol"], "MGR2") - self.assertEqual(len(decoded["acl"]), 36) - self.assertEqual(len(self.mgr2_pages), 8) - self.assertEqual(decoded["usb_logging"]["retry_seconds"], 604800) - self.assertIsNone(decoded["usb_watchdog_last"]) + def test_unreleased_98_byte_prototypes_are_not_accepted_as_current_pages(self): + for prototype in self.prototypes: + with self.subTest(count=prototype[82]): + with self.assertRaises(ValueError): + report.decode_page(prototype, "management test password") + with self.assertRaises(ValueError): + report.decode_report([prototype], "management test password") + for prefix in (b"\x3d\x00", b"\x19\x00"): + wire = prefix + prototype + if prefix[0] == 0x19: + wire += bytes(3 + ((len(prototype) - 3 + 15) // 16) * 16 - len(prototype)) + message = dict(type="PACKET", direction="rx", raw=wire.hex()) + extracted = report.mqtt_payload(message) + if extracted is not None: + # A padded four-entry prototype can overlap current + # public structure, but never authenticates as MGR2. + with self.assertRaises(ValueError): + report.decode_page(extracted, "management test password") + + def test_old_prototype_padding_overlap_fails_current_authentication(self): + from Crypto.Cipher import AES + padded = self.colliding_prototype + old = padded[:166] + key = report.derive(report.password_key("management test password"), + "MeshCore-MGR1-SIV", old[4:20]) + cipher = AES.new(key, AES.MODE_SIV); cipher.update(old[:98]) + self.assertEqual(cipher.decrypt_and_verify(old[98:-16], old[-16:]), + (bytes(12) + b"\x01") * 4) + self.assertEqual(report._page_bounds(padded), (111, 179)) + with self.assertRaises(ValueError): + report.decode_page(padded, "management test password") + for prefix in (b"\x3d\x00", b"\x19\x00"): + message = dict(type="PACKET", direction="rx", raw=(prefix + padded).hex()) + self.assertEqual(report.mqtt_payload(message), padded) + with self.assertRaises(ValueError): + report.mqtt_reports([message], "management test password") def test_current_none_event_with_empty_acl_is_authenticated(self): decoded = report.decode_report(self.empty_event_pages, "management test password") - self.assertEqual(decoded["protocol"], "MGR3") + self.assertEqual(decoded["protocol"], "MGR2") self.assertEqual(decoded["acl"], []) self.assertIsNone(decoded["usb_watchdog_last"]) @@ -118,25 +177,29 @@ process.stdin.on('data', chunk => { data += chunk; }); process.stdin.on('end', async () => { try { const captures = JSON.parse(data); - const pages = captures.MGR3; - for (const magic of ['MGR1', 'MGR2']) { - const legacy = await decoder.decodeManagement(captures[magic].join('\\n'), 'management test password'); - assert(legacy.authenticated && legacy.complete); - assert.strictEqual(legacy.acl.length, 36); - assert.strictEqual(legacy.public.protocol, magic); - assert.strictEqual(legacy.pageCount, magic === 'MGR1' ? 6 : 8); - assert.strictEqual(legacy.public.usbWatchdogLast, null); - assert.strictEqual(legacy.public.usbLogging === null, magic === 'MGR1'); + const pages = captures.MGR2; + const legacy = await decoder.decodeManagement(captures.MGR1.join('\\n'), 'management test password'); + assert(legacy.authenticated && legacy.complete); + assert.strictEqual(legacy.acl.length, 36); + assert.strictEqual(legacy.public.protocol, 'MGR1'); + assert.strictEqual(legacy.pageCount, 6); + assert.strictEqual(legacy.public.usbWatchdogLast, null); + assert.strictEqual(legacy.public.usbLogging, null); + for (const prototype of captures.prototypes) { + await assert.rejects(decoder.decodeManagement(prototype, 'management test password')); } + await assert.rejects(decoder.decodeManagement(captures.oldPadded, 'management test password'), /Password is wrong/); + const mgr3 = Buffer.from(pages[0], 'hex'); mgr3[3] = '3'.charCodeAt(0); + await assert.rejects(decoder.decodeManagement(mgr3.toString('hex'), 'management test password')); const empty = await decoder.decodeManagement(captures.empty.join('\\n'), 'management test password'); assert(empty.authenticated && empty.complete && !empty.acl.length); - assert.strictEqual(empty.public.protocol, 'MGR3'); + assert.strictEqual(empty.public.protocol, 'MGR2'); assert.strictEqual(empty.public.usbWatchdogLast, null); const result = await decoder.decodeManagement(pages.join('\\n'), 'management test password'); assert(result.authenticated && result.complete); assert.strictEqual(result.acl.length, 36); assert.strictEqual(result.pageCount, 9); - assert.strictEqual(result.public.protocol, 'MGR3'); + assert.strictEqual(result.public.protocol, 'MGR2'); assert.strictEqual(result.public.usbLogging.retrySeconds, 604800); assert.strictEqual(result.public.usbLogging.inactiveSeconds, 0xffffffff); assert.strictEqual(result.public.usbLogging.hostConnected, true); @@ -169,9 +232,10 @@ process.stdin.on('end', async () => { """ import json subprocess.run([node, "-e", script, str(ROOT / "docs/_javascript/management_decoder.js")], - input=json.dumps({"MGR3": [page.hex() for page in self.pages], - "MGR2": [page.hex() for page in self.mgr2_pages], + input=json.dumps({"MGR2": [page.hex() for page in self.pages], "MGR1": [page.hex() for page in self.legacy_pages], + "prototypes": [page.hex() for page in self.prototypes], + "oldPadded": self.colliding_prototype.hex(), "empty": [page.hex() for page in self.empty_event_pages]}), text=True, check=True, timeout=30) @@ -200,13 +264,16 @@ process.stdin.on('end', async () => { report.decode_report(pages, "management test password") def test_current_size_page_bounds_and_unknown_version_are_rejected(self): - for offset, value in ((3, ord("4")), (79, 8), (81, 6), (82, 5), (80, 37), (84, 8), (85, 0xc0)): + for offset, value in ((3, ord("3")), (3, ord("4")), (79, 8), (81, 6), (82, 5), (80, 37), (84, 8), (85, 0xc0)): changed = bytearray(self.pages[0]); changed[offset] = value with self.subTest(offset=offset), self.assertRaises(ValueError): report.decode_page(bytes(changed), "management test password") - for payload in (self.pages[0][:-1], self.pages[0] + b"\x00", self.pages[0][:99]): + for payload in (self.pages[0][:-1], self.pages[0] + b"\x00", *(self.pages[0][:n] for n in range(127))): with self.assertRaises(ValueError): report.decode_page(payload, "management test password") + mgr3 = bytearray(self.pages[0]); mgr3[3] = ord("3") + for prefix in (b"\x3d\x00", b"\x19\x00"): + self.assertIsNone(report.mqtt_payload(dict(type="PACKET", direction="rx", raw=(prefix + mgr3).hex()))) def test_event_validation_and_advisory_zero_time(self): for code in (0x80, 0x8f, 0xdf, 0xef, 0xff): diff --git a/test/test_trace_retry/test_trace_retry.cpp b/test/test_trace_retry/test_trace_retry.cpp index c347ac352..76e5437c3 100644 --- a/test/test_trace_retry/test_trace_retry.cpp +++ b/test/test_trace_retry/test_trace_retry.cpp @@ -174,7 +174,7 @@ TEST(ManagementRouting, GroupDataForBothRoutesKeepsPublicHeaderAndLegacyLength) uint8_t raw[mesh::management::CURRENT_HEADER + mesh::management::TAG] = {}; const uint8_t path[] = {0x12, 0xab}; uint8_t scope_key[16]; memset(scope_key, 0x42, sizeof(scope_key)); - for (const char* magic : {"MGR1", "MGR2", "MGR3"}) { + for (const char* magic : {"MGR1", "MGR2"}) { memset(raw, 0, sizeof(raw)); memcpy(raw, magic, 4); raw[79] = 1; const size_t size = mesh::management::headerSize(raw) + mesh::management::TAG; @@ -194,7 +194,7 @@ TEST(ManagementRouting, GroupDataForBothRoutesKeepsPublicHeaderAndLegacyLength) } TEST(ManagementRouting, FloodForwardingDoesNotNeedAKeyAndStillHonorsFilters) { - for (const char* magic : {"MGR1", "MGR2", "MGR3"}) { + for (const char* magic : {"MGR1", "MGR2"}) { TraceTestRadio radio; TraceTestClock clock; TraceTestRNG rng; TraceTestRTC rtc; TraceTestTables tables; StaticPoolPacketManager pool(8); TraceTestMesh mesh(radio, clock, rng, rtc, pool, tables); @@ -216,6 +216,31 @@ TEST(ManagementRouting, FloodForwardingDoesNotNeedAKeyAndStillHonorsFilters) { } } +TEST(ManagementRouting, RetiredPrototypeLayoutsCannotBeSentAndReleaseTheirPacket) { + TraceTestRadio radio; TraceTestClock clock; TraceTestRNG rng; TraceTestRTC rtc; + TraceTestTables tables; StaticPoolPacketManager pool(8); + TraceTestMesh mesh(radio, clock, rng, rtc, pool, tables); + uint8_t scope_key[16]; memset(scope_key, 0x42, sizeof(scope_key)); + const uint8_t path[] = {0x12}; + for (unsigned layout = 0; layout < 3; ++layout) { + uint8_t raw[mesh::management::MAX_PAYLOAD] = {}; + memcpy(raw, layout == 0 ? "MGR3" : "MGR2", 4); raw[79] = 1; + size_t size = layout == 0 ? mesh::management::CURRENT_HEADER + mesh::management::TAG + : 98 + mesh::management::TAG; + if (layout == 2) { + raw[80] = raw[82] = 5; + size += 5 * mesh::management::ENTRY; + } + for (bool flood : {false, true}) { + auto* packet = mesh.createRawData(raw, size); ASSERT_NE(nullptr, packet); + EXPECT_FALSE(mesh.sendManagementData(packet, flood, path, 1, 1, + flood ? scope_key : nullptr)); + EXPECT_EQ(0, pool.getOutboundTotal()); + EXPECT_EQ(8, pool.getFreeCount()); + } + } +} + class RetryCodingRateRadio : public TraceTestRadio { public: uint8_t cr = 5; diff --git a/tools/management/report.py b/tools/management/report.py index 8b5cbb324..dc2e83a2d 100644 --- a/tools/management/report.py +++ b/tools/management/report.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Decode and authenticate complete MGR1/MGR2/MGR3 management reports. +"""Decode and authenticate complete MGR1/MGR2 management reports. Input is a JSON list of raw payload hex strings, one entry for each page (not MeshCore packet headers/paths). A relay/collector can obtain these from RX logs @@ -14,13 +14,11 @@ import struct from pathlib import Path HEADER, ENTRY, PER_PAGE, TAG, MAX_KEYS = 83, 13, 6, 16, 36 -MGR2_HEADER, MGR2_PER_PAGE = 98, 5 CURRENT_HEADER, CURRENT_PER_PAGE = 111, 4 def _layout(payload): - formats = {b"MGR1": (HEADER, PER_PAGE), b"MGR2": (MGR2_HEADER, MGR2_PER_PAGE), - b"MGR3": (CURRENT_HEADER, CURRENT_PER_PAGE)} + formats = {b"MGR1": (HEADER, PER_PAGE), b"MGR2": (CURRENT_HEADER, CURRENT_PER_PAGE)} result = formats.get(payload[:4]) if result is None or len(payload) < result[0] + TAG: raise ValueError("invalid management payload") @@ -29,7 +27,7 @@ def _layout(payload): def _page_bounds(payload): header, per_page = _layout(payload) - if header >= MGR2_HEADER and (int.from_bytes(payload[83:85], "little") & 0xf800 or payload[85] & 0xc0): + if header == CURRENT_HEADER and (int.from_bytes(payload[83:85], "little") & 0xf800 or payload[85] & 0xc0): raise ValueError("reserved USB status bits must be zero") if header == CURRENT_HEADER: _watchdog_event(payload) @@ -43,7 +41,7 @@ def _page_bounds(payload): def _usb_status(payload): - if payload[:4] not in (b"MGR2", b"MGR3"): + if payload[:4] != b"MGR2": return None flags = int.from_bytes(payload[83:85], "little") names = ("supported", "logging_enabled", "watchdog_enabled", "host_connected", @@ -58,7 +56,7 @@ def _usb_status(payload): def _watchdog_event(payload): - if payload[:4] != b"MGR3": + if payload[:4] != b"MGR2": return None code = payload[98] epoch, uptime, sequence = struct.unpack_from("> 2) & 15 == 6: - try: - _, canonical = _page_bounds(payload) - except ValueError: - return None padded = 3 + ((canonical - 3 + 15) // 16) * 16 - if len(payload) != padded or any(payload[canonical:]): + if padded > 179 or len(payload) != padded or any(payload[canonical:]): return None payload = payload[:canonical] - return payload if payload[:4] in (b"MGR1", b"MGR2", b"MGR3") else None + elif len(payload) != canonical or len(payload) > 179: + return None + return payload def mqtt_reports(messages, password):