Add browser management report decoder

This commit is contained in:
mikecarper
2026-09-17 14:31:12 -07:00
parent aa317abbb0
commit 34e0187aaa
8 changed files with 942 additions and 1 deletions
+3 -1
View File
@@ -23,7 +23,9 @@ jobs:
mkdir -p .pio/libdeps/management-test
pio pkg install --global --library 'rweather/Crypto@0.4.0' --storage-dir .pio/libdeps/management-test
- name: Verify protocol, encryption, scheduling and MQTT decoding
run: python3 -B test/test_management_report.py -v
run: |
python3 -B test/test_management_report.py -v
node test/test_management_decoder.js
stm32-companion-smoke:
runs-on: ubuntu-latest
+639
View File
@@ -0,0 +1,639 @@
(function (global) {
"use strict";
// This decoder intentionally uses only the browser Web Crypto API. In
// particular, the management password is never sent to a server or put in a
// URL. AES-SIV needs CMAC and ECB-style single-block encryption, neither of
// 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 TAG = 16;
const ENTRY = 13;
const PER_PAGE = 6;
const MAX_KEYS = 36;
const ZERO_BLOCK = new Uint8Array(16);
const UTF8 = new TextEncoder();
const EXAMPLE_PASSWORD = "management test password";
const EXAMPLE_PAGE =
"4D475231000102030405060708090A0B0C0D0E0F2A00000000F153650501110106040200CDAB3412112233445566778840E2010000900100070700004800700E2850840E2A4EA815011F051F4F00000101000138BD699262E721CD2E9018D9E52A0AF74669C9D56685DEFFD8ABEA3C58";
class ManagementDecodeError extends Error {
constructor(message) {
super(message);
this.name = "ManagementDecodeError";
}
}
function bytesToHex(bytes) {
return Array.from(bytes, (value) => value.toString(16).padStart(2, "0"))
.join("")
.toUpperCase();
}
function hexToBytes(text) {
if (typeof text !== "string") {
throw new ManagementDecodeError("Packet data must be hexadecimal text.");
}
const normalized = text
.trim()
.replace(/^['"]|['",;]$/g, "")
.replace(/0x/gi, "")
.replace(/[\s:,_-]/g, "");
if (!normalized || normalized.length % 2 || !/^[0-9a-f]+$/i.test(normalized)) {
throw new ManagementDecodeError("Packet data must contain complete hexadecimal bytes.");
}
const bytes = new Uint8Array(normalized.length / 2);
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Number.parseInt(normalized.slice(index * 2, index * 2 + 2), 16);
}
return bytes;
}
function concatBytes(...parts) {
const length = parts.reduce((total, part) => total + part.length, 0);
const result = new Uint8Array(length);
let offset = 0;
parts.forEach((part) => {
result.set(part, offset);
offset += part.length;
});
return result;
}
function equalBytes(left, right) {
if (left.length !== right.length) return false;
let difference = 0;
for (let index = 0; index < left.length; index += 1) difference |= left[index] ^ right[index];
return difference === 0;
}
function uint16LE(bytes, offset) {
return bytes[offset] | (bytes[offset + 1] << 8);
}
function uint32LE(bytes, offset) {
return (
bytes[offset] |
(bytes[offset + 1] << 8) |
(bytes[offset + 2] << 16) |
(bytes[offset + 3] << 24)
) >>> 0;
}
function managementError(message) {
throw new ManagementDecodeError(message);
}
function cryptoApi() {
if (!global.crypto || !global.crypto.subtle) {
managementError("This browser does not provide Web Crypto. Open the decoder over HTTPS in a current browser.");
}
return global.crypto.subtle;
}
function doubleBlock(block) {
const result = new Uint8Array(16);
const carry = block[0] >> 7;
for (let index = 0; index < 15; index += 1) {
result[index] = ((block[index] << 1) | (block[index + 1] >> 7)) & 0xff;
}
result[15] = ((block[15] << 1) & 0xff) ^ (carry ? 0x87 : 0);
return result;
}
async function aesBlockEncryptor(keyBytes) {
const subtle = cryptoApi();
const key = await subtle.importKey("raw", keyBytes, { name: "AES-CBC" }, false, ["encrypt"]);
return async function encryptBlock(block) {
if (block.length !== 16) managementError("Internal AES block length is invalid.");
const encrypted = new Uint8Array(await subtle.encrypt({ name: "AES-CBC", iv: ZERO_BLOCK }, key, block));
// Web Crypto's AES-CBC applies PKCS#7 padding. Its first block is exactly
// AES-ECB(key, block), which is the CMAC primitive required by RFC 5297.
return encrypted.slice(0, 16);
};
}
async function cmac(encryptBlock, data) {
const l = await encryptBlock(ZERO_BLOCK);
const k1 = doubleBlock(l);
let chain = new Uint8Array(16);
let offset = 0;
while (data.length - offset > 16) {
const block = new Uint8Array(16);
for (let index = 0; index < 16; index += 1) block[index] = chain[index] ^ data[offset + index];
chain = await encryptBlock(block);
offset += 16;
}
const remaining = data.length - offset;
const final = new Uint8Array(16);
if (remaining === 16) {
for (let index = 0; index < 16; index += 1) final[index] = data[offset + index] ^ k1[index];
} else {
const k2 = doubleBlock(k1);
for (let index = 0; index < remaining; index += 1) final[index] = data[offset + index];
final[remaining] = 0x80;
for (let index = 0; index < 16; index += 1) final[index] ^= k2[index];
}
for (let index = 0; index < 16; index += 1) final[index] ^= chain[index];
return encryptBlock(final);
}
async function s2v(macKey, aad, plaintext) {
const encryptBlock = await aesBlockEncryptor(macKey);
let d = await cmac(encryptBlock, ZERO_BLOCK);
const aadMac = await cmac(encryptBlock, aad);
d = doubleBlock(d);
for (let index = 0; index < 16; index += 1) d[index] ^= aadMac[index];
if (plaintext.length >= 16) {
const adjusted = plaintext.slice();
const last = adjusted.length - 16;
for (let index = 0; index < 16; index += 1) adjusted[last + index] ^= d[index];
return cmac(encryptBlock, adjusted);
}
d = doubleBlock(d);
const padded = new Uint8Array(16);
padded.set(plaintext);
padded[plaintext.length] = 0x80;
for (let index = 0; index < 16; index += 1) padded[index] ^= d[index];
return cmac(encryptBlock, padded);
}
async function ctrCrypt(keyBytes, tag, input) {
const subtle = cryptoApi();
const counter = tag.slice();
counter[8] &= 0x7f;
counter[12] &= 0x7f;
const key = await subtle.importKey("raw", keyBytes, { name: "AES-CTR" }, false, ["decrypt"]);
return new Uint8Array(await subtle.decrypt(
{ name: "AES-CTR", counter, length: 128 }, key, input
));
}
async function openSiv(key, aad, ciphertext, tag) {
if (key.length !== 32 || tag.length !== TAG) managementError("Management encryption data has an invalid length.");
const plaintext = await ctrCrypt(key.slice(16), tag, ciphertext);
const expected = await s2v(key.slice(0, 16), aad, plaintext);
if (!equalBytes(expected, tag)) {
plaintext.fill(0);
managementError("Password is wrong or this management page was modified.");
}
return plaintext;
}
async function sha256(data) {
return new Uint8Array(await cryptoApi().digest("SHA-256", data));
}
async function hmacSha256(keyBytes, data) {
const subtle = cryptoApi();
const key = await subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
return new Uint8Array(await subtle.sign("HMAC", key, data));
}
async function passwordKey(password) {
if (typeof password !== "string") managementError("Enter the management password as text.");
const encoded = UTF8.encode(password);
if (encoded.length < 12 || encoded.length > 96) {
managementError("Management passwords must contain 12 through 96 UTF-8 bytes.");
}
return sha256(concatBytes(Uint8Array.of(0x23), encoded));
}
async function deriveKey(root, domain, radio) {
return hmacSha256(root, concatBytes(UTF8.encode(domain), radio));
}
async function aclFingerprint(password, radio, administrator) {
const normalized = typeof administrator === "string" ? hexToBytes(administrator) : administrator;
if (normalized.length !== 32) managementError("A candidate administrator key must be a complete 32-byte public key.");
const root = await passwordKey(password);
const key = await deriveKey(root, "MeshCore-MGR1-ACL", radio);
return (await hmacSha256(key, concatBytes(radio, normalized))).slice(0, 12);
}
function canonicalLength(payload) {
if (payload.length < HEADER + TAG || bytesToAscii(payload, 0, 4) !== "MGR1") {
managementError("This data does not begin with an MGR1 management page.");
}
const page = payload[78];
const pages = payload[79];
const total = payload[80];
const first = payload[81];
const count = payload[82];
const expectedPages = total ? Math.ceil(total / PER_PAGE) : 1;
if (total > MAX_KEYS || pages !== expectedPages || page >= pages ||
first !== page * PER_PAGE || first > total ||
count !== Math.min(PER_PAGE, total - first)) {
managementError("The MGR1 page index or ACL bounds are invalid.");
}
return HEADER + count * ENTRY + TAG;
}
function paddedFloodLength(canonical) {
return 3 + Math.ceil((canonical - 3) / 16) * 16;
}
function bytesToAscii(bytes, offset, length) {
let text = "";
for (let index = 0; index < length; index += 1) text += String.fromCharCode(bytes[offset + index]);
return text;
}
function routeDescription(route) {
return ["transport flood", "flood", "direct", "transport direct"][route] || "unknown";
}
function parsePacket(bytes) {
if (bytes.length < 2 || bytes[0] >> 6 !== 0 || ((bytes[0] >> 2) & 0x0f) !== 0x06) return null;
const route = bytes[0] & 0x03;
const pathOffset = route === 0 || route === 3 ? 5 : 1;
if (pathOffset >= bytes.length) return null;
const pathInfo = bytes[pathOffset];
const width = (pathInfo >> 6) + 1;
const hops = pathInfo & 0x3f;
if (width > 3 || hops * width > 64) return null;
const payloadOffset = pathOffset + 1 + width * hops;
if (payloadOffset >= bytes.length || bytesToAscii(bytes, payloadOffset, 4) !== "MGR1") return null;
const payload = bytes.slice(payloadOffset);
const canonical = canonicalLength(payload);
const padded = paddedFloodLength(canonical);
if (payload.length !== canonical &&
(payload.length !== padded || !payload.slice(canonical).every((value) => value === 0))) {
managementError("MGR1 packet padding or length is invalid.");
}
return {
payload: payload.slice(0, canonical),
envelope: {
header: bytes[0],
route: routeDescription(route),
routeCode: route,
pathHops: hops,
pathHashBytes: width,
},
};
}
function parseCanonical(bytes) {
if (bytesToAscii(bytes, 0, 4) !== "MGR1") return null;
const canonical = canonicalLength(bytes);
const padded = paddedFloodLength(canonical);
if (bytes.length !== canonical &&
(bytes.length !== padded || !bytes.slice(canonical).every((value) => value === 0))) {
managementError("MGR1 payload padding or length is invalid.");
}
return { payload: bytes.slice(0, canonical), envelope: null };
}
function inputByteStreams(input) {
if (typeof input !== "string" || input.trim() === "") {
managementError("Paste one or more MGR1 payloads or GroupData packet hex values first.");
}
if (input.length > 32768) managementError("The pasted value is too large to be management-report data.");
const candidates = [input, ...input.split(/\r?\n/)];
const quotedRaw = /["'](?:raw|data)["']\s*:\s*["']([^"']+)["']/gi;
let match;
while ((match = quotedRaw.exec(input)) !== null) candidates.push(match[1]);
const streams = input.match(/(?:0x)?[0-9a-f]{2}(?:(?:[\s:,_-]*)(?:0x)?[0-9a-f]{2}){15,}/gi);
if (streams) candidates.push(...streams);
const unique = new Map();
candidates.forEach((candidate) => {
try {
const bytes = hexToBytes(candidate);
unique.set(bytesToHex(bytes), bytes);
} catch (_error) {
// Explanatory prose and JSON wrappers are expected around analyzer data.
}
});
return [...unique.values()];
}
function parseInput(input) {
const found = [];
const seen = new Set();
for (const bytes of inputByteStreams(input)) {
let parsed = null;
try {
parsed = parseCanonical(bytes) || parsePacket(bytes);
} catch (error) {
if (error instanceof ManagementDecodeError && bytesToAscii(bytes, 0, 4) !== "MGR1") {
// It may be an unrelated hex stream alongside a valid packet.
continue;
}
throw error;
}
if (!parsed) continue;
const key = bytesToHex(parsed.payload);
if (!seen.has(key)) {
seen.add(key);
found.push(parsed);
}
}
if (!found.length) {
managementError("No complete MGR1 management page was found. Paste canonical MGR1 payload hex or an entire GroupData packet.");
}
return found;
}
function temperature(value) {
if (value === 0) return "unavailable";
if (value === 252) return "below -50 C";
if (value === 253) return "above 200 C";
if (value > 253) return "reserved/invalid";
return `${value - 51} C`;
}
function extrema(bytes, offset) {
const voltage = uint16LE(bytes, offset);
return `${voltage ? `${voltage} mV` : "unavailable"}; ${temperature(bytes[offset + 2])} to ${temperature(bytes[offset + 3])}`;
}
function version(bytes, offset) {
const value = uint32LE(bytes, offset);
return `${(value >>> 24) & 0xff}.${(value >>> 16) & 0xff}.${(value >>> 8) & 0xff}.${value & 0xff}`;
}
function featureNames(bits) {
const definitions = [[1, "Wi-Fi"], [2, "GPS"], [4, "NTP time"], [8, "USB data"], [16, "LoRa OTA"]];
const names = definitions.filter(([bit]) => bits & bit).map(([, name]) => name);
return names.length ? names.join(", ") : "none";
}
function roleName(value) {
return ({ 1: "repeater", 2: "room server", 3: "sensor" })[value] || `unknown (${value})`;
}
function publicFields(payload, envelope) {
const valid = uint16LE(payload, 76);
const capabilities = payload[73];
const active = payload[74];
const known = payload[75];
const knownActive = featureNames(active & known);
const unknownActive = featureNames(capabilities & ~known);
return {
radioId: bytesToHex(payload.slice(4, 20)),
sequence: uint32LE(payload, 20),
timestamp: uint32LE(payload, 24),
firmware: valid & 1 ? version(payload, 28) : "unavailable",
bootloader: valid & 2 ? version(payload, 32) : "unavailable",
target: valid & 4 ? uint32LE(payload, 36).toString(16).padStart(8, "0").toUpperCase() : "unavailable",
baseHash: valid & 4 ? bytesToHex(payload.slice(40, 48)) : "unavailable",
imageLength: valid & 4 ? `${uint32LE(payload, 48)} bytes` : "unavailable",
staging: valid & 8 ? `${uint32LE(payload, 52)} bytes` : "unavailable",
otaCapabilities: `0x${uint32LE(payload, 56).toString(16).padStart(8, "0").toUpperCase()}`,
uptime: `${uint16LE(payload, 60)} hours`,
weekly: extrema(payload, 62),
sinceReport: extrema(payload, 66),
history: `${payload[70]} hours${valid & 16 ? " (partial)" : ""}`,
interval: `${payload[71]} days`,
role: roleName(payload[72]),
compiled: featureNames(capabilities),
active: knownActive + (unknownActive !== "none" ? `; unknown: ${unknownActive}` : ""),
page: `${payload[78] + 1} of ${payload[79]}`,
acl: `${payload[80]} total; ${payload[82]} on this page`,
partialSince: Boolean(valid & 32),
mcuTemperature: Boolean(valid & 64),
envelope,
};
}
function reportGroup(pages) {
const first = pages[0];
const radio = bytesToHex(first.payload.slice(4, 20));
const sequence = uint32LE(first.payload, 20);
const byPage = new Map();
pages.forEach((page) => {
if (bytesToHex(page.payload.slice(4, 20)) !== radio || uint32LE(page.payload, 20) !== sequence) {
managementError("Input contains more than one report. Decode one radio and sequence at a time.");
}
const index = page.payload[78];
if (byPage.has(index) && !equalBytes(byPage.get(index).payload, page.payload)) {
managementError("Conflicting copies were provided for the same management page.");
}
byPage.set(index, page);
});
const sorted = [...byPage.values()].sort((left, right) => left.payload[78] - right.payload[78]);
const reference = sorted[0].payload;
if (sorted.some((page) => !equalBytes(page.payload.slice(0, 78), reference.slice(0, 78)) ||
page.payload[79] !== reference[79] || page.payload[80] !== reference[80])) {
managementError("MGR1 pages do not belong to the same snapshot.");
}
return sorted;
}
async function decryptPage(payload, password, candidate) {
const root = await passwordKey(password);
const key = await deriveKey(root, "MeshCore-MGR1-SIV", payload.slice(4, 20));
const privateLength = payload[82] * ENTRY;
const plaintext = await openSiv(key, payload.slice(0, HEADER),
payload.slice(HEADER, HEADER + privateLength), payload.slice(HEADER + privateLength));
try {
let wanted = null;
if (candidate) wanted = await aclFingerprint(password, payload.slice(4, 20), candidate);
const entries = [];
for (let offset = 0; offset < plaintext.length; offset += ENTRY) {
const flags = plaintext[offset + 12];
if (!flags || flags & ~3) managementError("Authenticated ACL data contains invalid role flags.");
const fingerprint = plaintext.slice(offset, offset + 12);
entries.push({
index: payload[81] + offset / ENTRY,
fingerprint: bytesToHex(fingerprint),
administrator: Boolean(flags & 1),
otaSigner: Boolean(flags & 2),
candidateMatch: wanted ? equalBytes(fingerprint, wanted) : null,
});
}
return entries;
} finally {
plaintext.fill(0);
}
}
async function decodeManagement(input, password, candidate) {
const pages = reportGroup(parseInput(input));
const first = pages[0];
const model = {
public: publicFields(first.payload, first.envelope),
pageCount: first.payload[79],
suppliedPages: pages.length,
complete: pages.length === first.payload[79],
authenticated: false,
acl: [],
warnings: [],
};
if (!password) {
model.warnings.push("Public fields are plaintext but unauthenticated until the management password is supplied.");
model.warnings.push("ACL entries remain encrypted. Their 12-byte fingerprints cannot be reversed into public keys.");
return model;
}
for (const page of pages) model.acl.push(...await decryptPage(page.payload, password, candidate));
model.authenticated = true;
if (!model.complete) {
model.warnings.push(`Authenticated ${pages.length} of ${first.payload[79]} pages. Paste the remaining pages to view the complete ACL.`);
}
if (!model.acl.length) model.warnings.push("This report contains no administrator or OTA-signer ACL entries.");
if (candidate && !model.acl.some((entry) => entry.candidateMatch)) {
model.warnings.push("The supplied candidate administrator key does not appear in the decoded ACL entries.");
}
return model;
}
function timestampText(epoch) {
if (!epoch) return "unavailable";
const date = new Date(epoch * 1000);
return Number.isNaN(date.getTime()) ? "invalid" : `${date.toISOString().replace("T", " ").replace(".000Z", " UTC")}`;
}
function appendTextCell(row, tag, text) {
const cell = document.createElement(tag);
cell.textContent = String(text);
row.appendChild(cell);
}
function initializeDecoder() {
const root = document.querySelector("[data-management-decoder]");
if (!root) return;
const input = root.querySelector("[data-role='input']");
const password = root.querySelector("[data-role='password']");
const candidate = root.querySelector("[data-role='candidate']");
const decode = root.querySelector("[data-role='decode']");
const clear = root.querySelector("[data-role='clear']");
const error = root.querySelector("[data-role='error']");
const results = root.querySelector("[data-role='results']");
const summary = root.querySelector("[data-role='summary']");
const status = root.querySelector("[data-role='status']");
const warnings = root.querySelector("[data-role='warnings']");
const warningList = root.querySelector("[data-role='warning-list']");
const aclTable = root.querySelector("[data-role='acl-table']");
function showError(value) {
results.hidden = true;
error.textContent = value instanceof Error ? value.message : String(value);
error.hidden = false;
}
function render(model) {
const values = [
["Reporter", model.public.radioId], ["Sequence", model.public.sequence],
["Report time", timestampText(model.public.timestamp)], ["Role", model.public.role],
["Firmware", model.public.firmware], ["Bootloader", model.public.bootloader],
["EndF target", model.public.target], ["Delta base hash", model.public.baseHash],
["Image length", model.public.imageLength], ["Staging", model.public.staging],
["OTA capabilities", model.public.otaCapabilities], ["Uptime", model.public.uptime],
["Weekly extrema", model.public.weekly], ["Since-report extrema", model.public.sinceReport],
["History", model.public.history], ["Report interval", model.public.interval],
["Compiled capabilities", model.public.compiled], ["Known active capabilities", model.public.active],
["Page", model.public.page], ["ACL", model.public.acl],
];
summary.replaceChildren();
values.forEach(([term, description]) => {
const item = document.createElement("div");
const dt = document.createElement("dt");
const dd = document.createElement("dd");
dt.textContent = term;
dd.textContent = description;
item.append(dt, dd);
summary.appendChild(item);
});
status.textContent = model.authenticated
? `Password-authenticated ${model.suppliedPages}/${model.pageCount} page${model.pageCount === 1 ? "" : "s"}.`
: "Public-only decode — no authenticity claim without the management password.";
status.classList.toggle("management-status-authenticated", model.authenticated);
warningList.replaceChildren();
model.warnings.forEach((note) => {
const item = document.createElement("li");
item.textContent = note;
warningList.appendChild(item);
});
warnings.hidden = !model.warnings.length;
aclTable.replaceChildren();
const head = document.createElement("thead");
const heading = document.createElement("tr");
["ACL index", "Fingerprint", "Permissions", "Candidate key"].forEach((text) => appendTextCell(heading, "th", text));
head.appendChild(heading);
aclTable.appendChild(head);
const body = document.createElement("tbody");
model.acl.forEach((entry) => {
const row = document.createElement("tr");
appendTextCell(row, "td", entry.index + 1);
appendTextCell(row, "td", entry.fingerprint);
appendTextCell(row, "td", [entry.administrator && "administrator", entry.otaSigner && "OTA signer"].filter(Boolean).join(", "));
appendTextCell(row, "td", entry.candidateMatch === null ? "not checked" : entry.candidateMatch ? "matches" : "does not match");
body.appendChild(row);
});
if (!model.acl.length) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.colSpan = 4;
cell.textContent = model.authenticated ? "No ACL entries in the supplied page(s)." : "Enter the password to decrypt ACL entries.";
row.appendChild(cell);
body.appendChild(row);
}
aclTable.appendChild(body);
results.hidden = false;
}
async function decodeInput() {
error.hidden = true;
decode.disabled = true;
try {
render(await decodeManagement(input.value, password.value, candidate.value.trim()));
} catch (failure) {
showError(failure);
} finally {
decode.disabled = false;
}
}
decode.addEventListener("click", decodeInput);
clear.addEventListener("click", () => {
input.value = "";
password.value = "";
candidate.value = "";
results.hidden = true;
error.hidden = true;
input.focus();
});
input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
decodeInput();
}
});
root.querySelector("[data-role='example']").addEventListener("click", () => {
input.value = EXAMPLE_PAGE;
password.value = EXAMPLE_PASSWORD;
candidate.value = "";
decodeInput();
});
}
const api = Object.freeze({
EXAMPLE_PAGE,
EXAMPLE_PASSWORD,
ManagementDecodeError,
aesBlockEncryptor,
s2v,
openSiv,
passwordKey,
deriveKey,
aclFingerprint,
parseInput,
decodeManagement,
});
global.MeshCoreManagementDecoder = api;
if (typeof module === "object" && module.exports) module.exports = api;
if (typeof document !== "undefined") {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializeDecoder, { once: true });
} else {
initializeDecoder();
}
}
})(typeof globalThis !== "undefined" ? globalThis : this);
+136
View File
@@ -0,0 +1,136 @@
.management-tool {
--management-border: color-mix(in srgb, var(--md-default-fg-color) 18%, transparent);
--management-soft-bg: color-mix(in srgb, var(--md-default-fg-color) 5%, transparent);
--management-accent-bg: color-mix(in srgb, var(--md-primary-fg-color) 12%, transparent);
margin: 1.5rem 0;
padding: 1rem;
border: 1px solid var(--management-border);
border-radius: 0.65rem;
background: var(--md-default-bg-color);
box-shadow: 0 0.15rem 0.6rem color-mix(in srgb, #000 10%, transparent);
}
.management-tool [hidden] { display: none !important; }
.management-tool label {
display: block;
margin: 0.8rem 0 0.35rem;
font-weight: 700;
}
.management-tool textarea,
.management-tool input[type="text"],
.management-tool input[type="password"] {
display: block;
width: 100%;
box-sizing: border-box;
padding: 0.75rem;
border: 1px solid var(--management-border);
border-radius: 0.4rem;
color: var(--md-code-fg-color);
background: var(--md-code-bg-color);
font: 0.82rem/1.55 var(--md-code-font-family);
}
.management-tool textarea { min-height: 8rem; resize: vertical; overflow-wrap: anywhere; }
.management-tool textarea:focus-visible,
.management-tool input:focus-visible,
.management-tool button:focus-visible {
outline: 0.15rem solid var(--md-accent-fg-color);
outline-offset: 0.12rem;
}
.management-help {
margin: 0.35rem 0 0;
color: color-mix(in srgb, var(--md-default-fg-color) 70%, transparent);
font-size: 0.82rem;
}
.management-actions {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
align-items: center;
margin-top: 0.9rem;
}
.management-tool button {
appearance: none;
min-height: 2.25rem;
padding: 0.42rem 0.75rem;
border: 1px solid var(--management-border);
border-radius: 0.35rem;
color: var(--md-default-fg-color);
background: var(--management-soft-bg);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.management-tool button:hover {
border-color: var(--md-primary-fg-color);
background: var(--management-accent-bg);
}
.management-tool .management-primary-action {
border-color: var(--md-primary-fg-color);
color: var(--md-primary-bg-color);
background: var(--md-primary-fg-color);
}
.management-tool .management-primary-action:hover { background: var(--md-primary-fg-color); filter: brightness(1.12); }
.management-tool button:disabled { opacity: 0.65; cursor: progress; }
.management-error,
.management-warnings,
.management-status {
margin-top: 1rem;
padding: 0.75rem 0.9rem;
border-left: 0.25rem solid;
border-radius: 0.25rem;
}
.management-error { border-color: #d32f2f; background: color-mix(in srgb, #d32f2f 10%, transparent); }
.management-warnings { border-color: #ed9b00; background: color-mix(in srgb, #ed9b00 11%, transparent); }
.management-warnings ul { margin: 0.25rem 0 0 1rem; }
.management-status { border-color: #ed9b00; background: color-mix(in srgb, #ed9b00 11%, transparent); }
.management-status-authenticated { border-color: #3b9c62; background: color-mix(in srgb, #3b9c62 11%, transparent); }
.management-results { margin-top: 1.35rem; padding-top: 0.25rem; border-top: 1px solid var(--management-border); }
.management-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 0.65rem;
margin: 1rem 0;
}
.management-summary > div {
min-width: 0;
padding: 0.65rem 0.75rem;
border: 1px solid var(--management-border);
border-radius: 0.4rem;
background: var(--management-soft-bg);
}
.management-summary dt {
color: color-mix(in srgb, var(--md-default-fg-color) 70%, transparent);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.025em;
text-transform: uppercase;
}
.management-summary dd { margin: 0.2rem 0 0; overflow-wrap: anywhere; font-size: 0.88rem; font-weight: 600; }
.management-table-wrap { max-height: 28rem; overflow: auto; border: 1px solid var(--management-border); border-radius: 0.4rem; }
.management-tool .management-table { display: table; width: 100%; margin: 0; border-collapse: collapse; white-space: nowrap; font-variant-numeric: tabular-nums; }
.management-tool .management-table th,
.management-tool .management-table td { padding: 0.5rem 0.65rem; border-bottom: 1px solid var(--management-border); text-align: left; }
.management-tool .management-table th { position: sticky; top: 0; z-index: 1; background: var(--md-default-bg-color); }
.management-tool .management-table tbody tr:last-child td { border-bottom: 0; }
.management-tool .management-table tbody tr:nth-child(even) { background: var(--management-soft-bg); }
@media (max-width: 44rem) {
.management-tool { padding: 0.75rem; }
.management-tool button { flex: 1 1 auto; }
}
+1
View File
@@ -16,6 +16,7 @@ Below are a few quick start guides.
- [LoRa CLI Host Service](./host_cli_service.md)
- [Filter Policy Playground](./filter_tool.md)
- [Telemetry Decoder](./telemetry_decoder.md)
- [Management Report Decoder](./management_decoder.md)
- [CLI Availability by Firmware Build](./cli_build_matrix.md)
- [Classic ESP32 image memory budget](./esp32_memory_budget.md)
- [Full Companion contact caches and capacity trials](./companion_contact_cache.md)
+82
View File
@@ -0,0 +1,82 @@
# Management report decoder
Paste MGR1 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.
The public portion of a report is deliberately plaintext, but it is **not
authenticated** until a management password is supplied. A password-authenticated
page proves that its public fields and encrypted ACL bytes have not been altered
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 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 payload hex, or one MQTT raw value per line"
aria-describedby="management-packet-help"
></textarea>
<p class="management-help" id="management-packet-help">
Spaces, line breaks, colons, dashes, <code>0x</code>, and MQTT JSON fields named
<code>raw</code> or <code>data</code> are accepted. Paste all pages from one report
together to view a complete multi-page ACL. Press Ctrl/Command+Enter to decode.
</p>
<label for="management-password-input">Management password <span>(optional for public fields; required for ACLs)</span></label>
<input id="management-password-input" data-role="password" type="password" autocomplete="new-password">
<p class="management-help">
The password remains in this page only. The decoder derives the MGR1 AES-SIV key in
your browser and does not send it anywhere.
</p>
<label for="management-candidate-input">Candidate administrator public key <span>(optional)</span></label>
<input id="management-candidate-input" data-role="candidate" type="text" autocomplete="off" spellcheck="false" placeholder="64 hexadecimal characters">
<p class="management-help">
ACL encryption reveals per-radio 12-byte fingerprints, not recoverable public keys.
Supplying a complete candidate key checks whether its fingerprint appears in this report.
</p>
<div class="management-actions">
<button class="management-primary-action" type="button" data-role="decode">Decode management report</button>
<button type="button" data-role="clear">Clear local data</button>
<button type="button" data-role="example">Load authenticated example</button>
</div>
<div class="management-error" data-role="error" role="alert" aria-live="polite" hidden></div>
<section class="management-results" data-role="results" aria-live="polite" hidden>
<div class="management-status" data-role="status"></div>
<dl class="management-summary" data-role="summary"></dl>
<div class="management-warnings" data-role="warnings" hidden>
<strong>Decode notes</strong>
<ul data-role="warning-list"></ul>
</div>
<h2>Encrypted ACL entries</h2>
<div class="management-table-wrap">
<table class="management-table" data-role="acl-table"></table>
</div>
</section>
</div>
## What the decoder accepts
- Complete `PAYLOAD_TYPE_GRP_DATA` (`0x06`) analyzer/MQTT packet hex. It checks
the MeshCore route header, encoded path length, MGR1 page bounds, and required
zero padding.
- A canonical MGR1 payload beginning with `4D475231` (`MGR1`).
- One raw packet or canonical payload per line; duplicate observations of an
identical page are deduplicated.
Use the same password configured by `set mgmt.password`. A decoded ACL lists
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.
For the report schedule, public-field layout, cryptographic design, and the
offline Python capture tool, see [Management reports](management_reports.md).
+4
View File
@@ -122,6 +122,10 @@ of canonical payload hex strings. `--match-admin FULL_PUBLIC_KEY` in canonical
payload mode matches a known administrator against the encrypted fingerprints.
This is an offline capture decoder, not a broker subscriber or downlink service.
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 payload (little endian)
| Offset | Bytes | Field |
+2
View File
@@ -20,6 +20,7 @@ extra_css:
- _stylesheets/extra.css
- _stylesheets/firmware_picker.css
- _stylesheets/telemetry_decoder.css
- _stylesheets/management_decoder.css
- _stylesheets/filter_tool.css
- _stylesheets/preset_test.css?v=20260916-3
@@ -27,5 +28,6 @@ extra_javascript:
- https://unpkg.com/leaflet@1.9.4/dist/leaflet.js
- _javascript/firmware_picker.js
- _javascript/telemetry_decoder.js
- _javascript/management_decoder.js
- _javascript/filter_tool.js
- _javascript/preset_test.js?v=20260916-5
+75
View File
@@ -0,0 +1,75 @@
"use strict";
const assert = require("assert");
if (!globalThis.crypto) globalThis.crypto = require("crypto").webcrypto;
const decoder = require("../docs/_javascript/management_decoder.js");
function hex(text) {
return Uint8Array.from(Buffer.from(text, "hex"));
}
(async function run() {
// RFC 5297 Appendix A.1: independent known-answer coverage for the exact
// AES-SIV-CMAC-256 algorithm used to protect an MGR1 ACL page.
const key = hex("fffefdfcfbfaf9f8f7f6f5f4f3f2f1f0f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff");
const aad = hex("101112131415161718191a1b1c1d1e1f2021222324252627");
const ciphertext = hex("40c02b9690c4dc04daef7f6afe5c");
const tag = hex("85632d07c6e8f37f950acd320a2ecc93");
assert.strictEqual(
Buffer.from(await decoder.openSiv(key, aad, ciphertext, tag)).toString("hex"),
"112233445566778899aabbccddee"
);
const publicOnly = await decoder.decodeManagement(decoder.EXAMPLE_PAGE, "");
assert.strictEqual(publicOnly.authenticated, false);
assert.strictEqual(publicOnly.public.radioId, "000102030405060708090A0B0C0D0E0F");
assert.strictEqual(publicOnly.public.firmware, "1.17.1.5");
assert.strictEqual(publicOnly.acl.length, 0);
const decoded = await decoder.decodeManagement(
decoder.EXAMPLE_PAGE,
decoder.EXAMPLE_PASSWORD
);
assert.strictEqual(decoded.authenticated, true);
assert.strictEqual(decoded.public.bootloader, "0.2.4.6");
assert.strictEqual(decoded.public.target, "1234ABCD");
assert.strictEqual(decoded.acl.length, 1);
assert.strictEqual(decoded.acl[0].fingerprint, "AABBCCDDEEFF001122334455");
assert.strictEqual(decoded.acl[0].administrator, true);
assert.strictEqual(decoded.acl[0].otaSigner, true);
const groupData = "1A00" + decoder.EXAMPLE_PAGE;
const packet = await decoder.decodeManagement(groupData, decoder.EXAMPLE_PASSWORD);
assert.strictEqual(packet.public.envelope.route, "direct");
assert.strictEqual(packet.public.envelope.pathHops, 0);
const paddedRouted = "1A0177" + decoder.EXAMPLE_PAGE + "000000";
const routed = await decoder.decodeManagement(paddedRouted, decoder.EXAMPLE_PASSWORD);
assert.strictEqual(routed.public.envelope.pathHops, 1);
assert.strictEqual(routed.acl[0].administrator, true);
// An empty ACL is a valid, authenticated page. AES-CTR has no padding, so
// it must also work when the SIV ciphertext has zero bytes.
const emptyAclPage =
"4D475231000102030405060708090A0B0C0D0E0F2A00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000A815011F051F4F000001000000DAA550D7FEFAEB13412CF9E457A45F2A";
const emptyAcl = await decoder.decodeManagement(emptyAclPage, decoder.EXAMPLE_PASSWORD);
assert.strictEqual(emptyAcl.authenticated, true);
assert.strictEqual(emptyAcl.acl.length, 0);
await assert.rejects(
decoder.decodeManagement(decoder.EXAMPLE_PAGE, "not the right management password"),
/Password is wrong/
);
const altered = decoder.EXAMPLE_PAGE.slice(0, -2) + "00";
await assert.rejects(
decoder.decodeManagement(altered, decoder.EXAMPLE_PASSWORD),
/Password is wrong/
);
await assert.rejects(
decoder.decodeManagement(decoder.EXAMPLE_PAGE, "short"),
/12 through 96/
);
console.log("management browser decoder checks passed");
})().catch((error) => {
console.error(error.stack || error);
process.exit(1);
});