mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-22 10:22:34 +00:00
Finalize USB watchdog management format as MGR2
This commit is contained in:
1 parent
eeb25a8b5f
commit
5ee6059564
13 files changed
+269
-129
No files matched your search
@@ -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]) => {
|
||||
|
||||
+13
-10
@@ -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
|
||||
|
||||
<div class="management-tool" data-management-decoder>
|
||||
<label for="management-packet-input">MGR1/MGR2/MGR3 payload or complete GroupData packet hex</label>
|
||||
<label for="management-packet-input">MGR1/MGR2 payload or complete GroupData packet hex</label>
|
||||
<textarea
|
||||
id="management-packet-input"
|
||||
data-role="input"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
placeholder="Paste analyzer Raw Data, canonical MGR1/MGR2/MGR3 payload hex, or one MQTT raw value per line"
|
||||
placeholder="Paste analyzer Raw Data, canonical MGR1/MGR2 payload hex, or one MQTT raw value per line"
|
||||
aria-describedby="management-packet-help"
|
||||
></textarea>
|
||||
<p class="management-help" id="management-packet-help">
|
||||
@@ -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)
|
||||
|
||||
+12
-16
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
+1
-1
@@ -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 &&
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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);
|
||||
|
||||
+20
-5
@@ -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<const uint8_t*>(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<uint8_t> 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));
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
|
||||
+14
-14
@@ -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("<III", payload, 99)
|
||||
@@ -133,7 +131,7 @@ def decode_page(payload, password):
|
||||
|
||||
|
||||
def mqtt_payload(message):
|
||||
"""Extract an MGR1/MGR2/MGR3 payload from the observer's MQTT PACKET/raw JSON.
|
||||
"""Extract an MGR1/MGR2 payload from the observer's MQTT PACKET/raw JSON.
|
||||
|
||||
Path length is encoded, not simply a byte count. Region transport codes
|
||||
precede it. Never trust the redundant MQTT payload_len/type fields.
|
||||
@@ -158,16 +156,18 @@ def mqtt_payload(message):
|
||||
return None
|
||||
start = position + 1 + width * count
|
||||
payload = packet[start:]
|
||||
try:
|
||||
_, canonical = _page_bounds(payload)
|
||||
except ValueError:
|
||||
return None
|
||||
if (packet[0] >> 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):
|
||||
|
||||
Reference in new issue
Block a user