#!/usr/bin/env python3 """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 or the companion raw-data notification. """ import argparse import getpass import hashlib import hmac import json import struct from pathlib import Path HEADER, ENTRY, PER_PAGE, TAG, MAX_KEYS = 83, 13, 6, 16, 36 CURRENT_HEADER, CURRENT_PER_PAGE = 111, 4 def _layout(payload): 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") return result def _page_bounds(payload): header, per_page = _layout(payload) 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) page, pages, total, first, count = payload[78:83] expected = max(1, (total + per_page - 1) // per_page) if (total > MAX_KEYS or pages != expected or page >= pages or first != page * per_page or first > total or count != min(per_page, total - first)): raise ValueError("invalid management page bounds") return header, header + count * ENTRY + TAG def _usb_status(payload): if payload[:4] != b"MGR2": return None flags = int.from_bytes(payload[83:85], "little") names = ("supported", "logging_enabled", "watchdog_enabled", "host_connected", "reader_connected", "stalled", "recovering", "recovery_deferred", "persistence_ready", "watchdog_auto", "logger_active") result = {name: bool(flags & (1 << bit)) for bit, name in enumerate(names)} result.update(stage=payload[85] & 3, backoff_step=(payload[85] >> 2) & 15, retry_seconds=int.from_bytes(payload[86:90], "little"), inactive_seconds=int.from_bytes(payload[90:94], "little"), auto_connected_seconds=int.from_bytes(payload[94:98], "little")) return result def _watchdog_event(payload): if payload[:4] != b"MGR2": return None code = payload[98] epoch, uptime, sequence = struct.unpack_from("> 4) & 7 if not reasons or not 1 <= action <= 4: raise ValueError("invalid USB watchdog event reason/action") names = ("host-absent", "reader-absent", "tx-stalled", "client-inactive") actions = (None, "soft-recovery", "reenumerate", "reboot-requested", "reboot-cancelled") return dict(reasons=reasons, reason_names=[name for bit, name in enumerate(names) if reasons & (1 << bit)], action_code=action, action=actions[action], persisted=bool(code & 128), epoch=epoch, uptime_seconds=uptime, sequence=sequence) def password_key(password): encoded = password.encode("utf-8") if not 12 <= len(encoded) <= 96: raise ValueError("password must contain 12..96 UTF-8 bytes") return hashlib.sha256(b"#" + encoded).digest() def derive(key, domain, radio): return hmac.digest(key, domain.encode("ascii") + radio, "sha256") def fingerprint(password, radio, administrator): if len(radio) != 16 or len(administrator) != 32: raise ValueError("radio ID must be 16 bytes; administrator key must be 32 bytes") key = derive(password_key(password), "MeshCore-MGR1-ACL", radio) return hmac.digest(key, radio + administrator, "sha256")[:12] def _temperature(value): if value == 0: return None if value == 252: return "below -50 C" if value == 253: return "above 200 C" if value > 253: raise ValueError("reserved temperature value") return value - 51 def _extrema(raw): voltage = int.from_bytes(raw[:2], "little") return dict(min_voltage_mv=voltage or None, min_temperature_c=_temperature(raw[2]), max_temperature_c=_temperature(raw[3])) def decode_page(payload, password): from Crypto.Cipher import AES # PyCryptodome; independent AES-SIV implementation header, canonical = _page_bounds(payload) count = payload[82] if len(payload) != canonical or len(payload) > 179: raise ValueError("invalid management page bounds") key = derive(password_key(password), "MeshCore-MGR1-SIV", payload[4:20]) cipher = AES.new(key, AES.MODE_SIV) cipher.update(payload[:header]) private = cipher.decrypt_and_verify(payload[header:-TAG], payload[-TAG:]) entries = [] seen = set() for i in range(count): token = private[i * ENTRY:i * ENTRY + 12] flags = private[i * ENTRY + 12] if not flags or flags & ~3: raise ValueError("invalid ACL role flags") if token in seen: raise ValueError("duplicate ACL fingerprint") seen.add(token) entries.append(dict(fingerprint=token.hex(), admin=bool(flags & 1), ota_signer=bool(flags & 2))) return entries def mqtt_payload(message): """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. """ if not isinstance(message, dict) or message.get("direction", "rx") != "rx": return None raw = message.get("raw") if message.get("type") == "PACKET" else message.get("data") if not isinstance(raw, str): return None try: packet = bytes.fromhex(raw) except ValueError: return None if not 2 <= len(packet) <= 255 or packet[0] >> 6 or (packet[0] >> 2) & 15 not in (6, 15): return None position = 5 if packet[0] & 3 in (0, 3) else 1 if position >= len(packet): return None path_len = packet[position] width, count = (path_len >> 6) + 1, path_len & 63 if width > 3 or width * count > 64: 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: padded = 3 + ((canonical - 3 + 15) // 16) * 16 if padded > 179 or len(payload) != padded or any(payload[canonical:]): return None payload = payload[:canonical] elif len(payload) != canonical or len(payload) > 179: return None return payload def mqtt_reports(messages, password): """Group a saved MQTT capture, deduplicating copies heard by many uplinks. A conflicting page fails closed. Only fully authenticated complete reports are returned. """ snapshots = {} for message in messages: payload = mqtt_payload(message) if payload is None: continue decode_page(payload, password) identity = payload[4:24] pages = snapshots.setdefault(identity, {}) previous = pages.get(payload[78]) if previous is not None and previous != payload: raise ValueError("conflicting MQTT copies for one management page") pages[payload[78]] = payload results = [] for pages in snapshots.values(): first = next(iter(pages.values())) if len(pages) == first[79]: results.append(decode_report(list(pages.values()), password)) return results def decode_report(payloads, password): if not payloads or len(payloads) > 9: raise ValueError("one through nine pages required") # Check every page before indexing metadata, then reject omissions, # duplicates, mixed snapshots and unauthenticated public fields. decoded = [(p, decode_page(p, password)) for p in payloads] decoded.sort(key=lambda item: item[0][78]) first = decoded[0][0] if len(decoded) != first[79] or [p[78] for p, _ in decoded] != list(range(first[79])): raise ValueError("incomplete or duplicate report pages") header, _ = _layout(first) if any(p[:78] != first[:78] or p[79:81] != first[79:81] or p[HEADER:header] != first[HEADER:header] for p, _ in decoded): raise ValueError("mixed report snapshots") acl = [entry for _, entries in decoded for entry in entries] if len({entry["fingerprint"] for entry in acl}) != len(acl): raise ValueError("duplicate ACL fingerprint") fields = struct.unpack_from("> n) & 255) for n in (24, 16, 8, 0)) result = dict(protocol=first[:4].decode("ascii"), radio_id=radio.hex(), sequence=sequence, timestamp=timestamp, firmware_version=version(firmware) if valid & 1 else None, bootloader_version=version(bootloader) if valid & 2 else None, target_id=f"{target:08x}" if valid & 4 else None, base_hash=first[40:48].hex() if valid & 4 else None, image_length=int.from_bytes(first[48:52], "little") if valid & 4 else None, staging_capacity=int.from_bytes(first[52:56], "little") if valid & 8 else None, ota_capabilities=int.from_bytes(first[56:60], "little"), uptime_hours=int.from_bytes(first[60:62], "little"), weekly=_extrema(first[62:66]), since_report=_extrema(first[66:70]), history_hours=first[70], interval_days=first[71], role=first[72], capability_bits=first[73], active_bits=first[74], known_bits=first[75], partial_week=bool(valid & 16), partial_period=bool(valid & 32), temperature_source="MCU" if valid & 64 else "unknown", usb_logging=_usb_status(first), usb_watchdog_last=_watchdog_event(first), acl=acl) return result def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("pages", type=Path, help="JSON array of raw payload hex strings") parser.add_argument("--match-admin", help="full public key to match against private ACL fingerprints") parser.add_argument("--mqtt", action="store_true", help="input is JSON array or JSONL of saved MQTT uplinks") args = parser.parse_args() try: administrator = None if args.match_admin is not None: administrator = bytes.fromhex(args.match_admin) if len(administrator) != 32: raise ValueError("administrator key must be 32 bytes") text = args.pages.read_text(encoding="utf-8") if args.mqtt: messages = json.loads(text) if text.lstrip().startswith("[") else [json.loads(line) for line in text.splitlines() if line.strip()] else: payloads = [bytes.fromhex(p) for p in json.loads(text)] password = getpass.getpass("Management password: ") if args.mqtt: reports = mqtt_reports(messages, password) else: reports = [decode_report(payloads, password)] if administrator is not None: for report in reports: match = fingerprint(password, bytes.fromhex(report["radio_id"]), administrator).hex() report["matching_acl"] = [entry for entry in report["acl"] if entry["fingerprint"] == match] output = json.dumps(reports if args.mqtt else reports[0], indent=2) except EOFError: parser.error("management password input unavailable; run in an interactive terminal") except RecursionError: parser.error("input JSON is nested too deeply") except (OSError, ValueError, TypeError, KeyError) as exc: parser.error(str(exc)) print(output) if __name__ == "__main__": main()