From 34e0187aaa9ea7d7bf9668b18d2ff70013cb280f Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 17 Sep 2026 14:31:02 -0700 Subject: [PATCH] Add browser management report decoder --- .github/workflows/run-unit-tests.yml | 4 +- docs/_javascript/management_decoder.js | 639 +++++++++++++++++++++++ docs/_stylesheets/management_decoder.css | 136 +++++ docs/index.md | 1 + docs/management_decoder.md | 82 +++ docs/management_reports.md | 4 + mkdocs.yml | 2 + test/test_management_decoder.js | 75 +++ 8 files changed, 942 insertions(+), 1 deletion(-) create mode 100644 docs/_javascript/management_decoder.js create mode 100644 docs/_stylesheets/management_decoder.css create mode 100644 docs/management_decoder.md create mode 100644 test/test_management_decoder.js diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index df7dd2d8..8431716a 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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 diff --git a/docs/_javascript/management_decoder.js b/docs/_javascript/management_decoder.js new file mode 100644 index 00000000..9b6bdb4e --- /dev/null +++ b/docs/_javascript/management_decoder.js @@ -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); diff --git a/docs/_stylesheets/management_decoder.css b/docs/_stylesheets/management_decoder.css new file mode 100644 index 00000000..102b0ac8 --- /dev/null +++ b/docs/_stylesheets/management_decoder.css @@ -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; } +} diff --git a/docs/index.md b/docs/index.md index 2b5d465f..0173103f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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) diff --git a/docs/management_decoder.md b/docs/management_decoder.md new file mode 100644 index 00000000..ec33accd --- /dev/null +++ b/docs/management_decoder.md @@ -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 + +
+ + +

+ Spaces, line breaks, colons, dashes, 0x, and MQTT JSON fields named + raw or data are accepted. Paste all pages from one report + together to view a complete multi-page ACL. Press Ctrl/Command+Enter to decode. +

+ + + +

+ The password remains in this page only. The decoder derives the MGR1 AES-SIV key in + your browser and does not send it anywhere. +

+ + + +

+ 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. +

+ +
+ + + +
+ + + + +
+ +## 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). diff --git a/docs/management_reports.md b/docs/management_reports.md index 5a954094..f9604c21 100644 --- a/docs/management_reports.md +++ b/docs/management_reports.md @@ -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 | diff --git a/mkdocs.yml b/mkdocs.yml index a4c36316..f19d8731 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/test/test_management_decoder.js b/test/test_management_decoder.js new file mode 100644 index 00000000..3e8592b9 --- /dev/null +++ b/test/test_management_decoder.js @@ -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); +});