mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-20 20:51:59 +00:00
765 lines
31 KiB
JavaScript
765 lines
31 KiB
JavaScript
(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 CURRENT_HEADER = 111;
|
|
const TAG = 16;
|
|
const ENTRY = 13;
|
|
const PER_PAGE = 6;
|
|
const CURRENT_PER_PAGE = 4;
|
|
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 = new Uint8Array(plaintext);
|
|
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 = new Uint8Array(tag);
|
|
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 layout(payload) {
|
|
const magic = bytesToAscii(payload, 0, 4);
|
|
if (magic === "MGR1") return { header: HEADER, perPage: 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 management page.");
|
|
}
|
|
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);
|
|
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 / format.perPage) : 1;
|
|
if (total > MAX_KEYS || pages !== expectedPages || page >= pages ||
|
|
first !== page * format.perPage || first > total ||
|
|
count !== Math.min(format.perPage, total - first)) {
|
|
managementError("The management page index or ACL bounds are invalid.");
|
|
}
|
|
return format.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 || !layout(bytes.slice(payloadOffset))) return null;
|
|
const payload = bytes.slice(payloadOffset);
|
|
const canonical = canonicalLength(payload);
|
|
const padded = paddedFloodLength(canonical);
|
|
// Every GroupData route uses the group cipher-compatible padded length,
|
|
// even though management pages protect their own ACL with AES-SIV.
|
|
if (payload.length !== padded || !payload.slice(canonical).every((value) => value === 0)) {
|
|
managementError("Management 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 (!layout(bytes)) 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("Management 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/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
|
|
// fragment. Conversely, joining complete page lines creates a bogus
|
|
// oversized canonical page: let each line retain its own bounds.
|
|
try {
|
|
const whole = hexToBytes(input);
|
|
if (parseCanonical(whole) || parsePacket(whole)) return [whole];
|
|
} catch (_error) {
|
|
// JSON/log wrappers and multiple complete page lines are handled below.
|
|
}
|
|
const candidates = 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}(?:(?:[ \t:,_-]*)(?: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 && !layout(bytes)) {
|
|
// 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 management page was found. Paste canonical MGR1/MGR2 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 usbStatus(payload) {
|
|
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"];
|
|
const result = {};
|
|
names.forEach((name, bit) => { result[name] = Boolean(flags & (1 << bit)); });
|
|
result.stage = payload[85] & 3;
|
|
result.backoffStep = (payload[85] >> 2) & 15;
|
|
result.retrySeconds = uint32LE(payload, 86);
|
|
result.inactiveSeconds = uint32LE(payload, 90);
|
|
result.autoConnectedSeconds = uint32LE(payload, 94);
|
|
return result;
|
|
}
|
|
|
|
function usbWatchdogEvent(payload) {
|
|
if (bytesToAscii(payload, 0, 4) !== "MGR2") return null;
|
|
const code = payload[98];
|
|
const sequence = uint32LE(payload, 107);
|
|
if (!sequence) {
|
|
if (!payload.slice(98, 111).every((value) => value === 0)) {
|
|
managementError("Invalid empty USB watchdog event.");
|
|
}
|
|
return null;
|
|
}
|
|
const reasons = code & 15;
|
|
const actionCode = (code >> 4) & 7;
|
|
if (!reasons || actionCode < 1 || actionCode > 4) {
|
|
managementError("Invalid USB watchdog event reason/action.");
|
|
}
|
|
const names = ["host-absent", "reader-absent", "tx-stalled", "client-inactive"];
|
|
return {
|
|
reasons, reasonNames: names.filter((_name, bit) => reasons & (1 << bit)),
|
|
actionCode, action: [null, "soft-recovery", "reenumerate", "reboot-requested", "reboot-cancelled"][actionCode],
|
|
persisted: Boolean(code & 128), epoch: uint32LE(payload, 99),
|
|
uptimeSeconds: uint32LE(payload, 103), sequence,
|
|
};
|
|
}
|
|
|
|
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 {
|
|
protocol: bytesToAscii(payload, 0, 4),
|
|
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),
|
|
usbLogging: usbStatus(payload),
|
|
usbWatchdogLast: usbWatchdogEvent(payload),
|
|
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;
|
|
const header = layout(reference).header;
|
|
if (sorted.some((page) => !equalBytes(page.payload.slice(0, 78), reference.slice(0, 78)) ||
|
|
!equalBytes(page.payload.slice(HEADER, header), reference.slice(HEADER, header)) ||
|
|
page.payload[79] !== reference[79] || page.payload[80] !== reference[80])) {
|
|
managementError("Management 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 header = layout(payload).header;
|
|
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));
|
|
if (new Set(model.acl.map((entry) => entry.fingerprint)).size !== model.acl.length) {
|
|
managementError("Authenticated ACL data contains a duplicate fingerprint.");
|
|
}
|
|
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']");
|
|
let activeDecode = null;
|
|
|
|
function resetResults() {
|
|
results.hidden = true;
|
|
summary.replaceChildren();
|
|
aclTable.replaceChildren();
|
|
warningList.replaceChildren();
|
|
warnings.hidden = true;
|
|
status.textContent = "";
|
|
status.classList.toggle("management-status-authenticated", false);
|
|
}
|
|
|
|
function showError(value) {
|
|
results.hidden = true;
|
|
error.textContent = value instanceof Error ? value.message : String(value);
|
|
error.hidden = false;
|
|
}
|
|
|
|
function render(model) {
|
|
const values = [
|
|
["Protocol", model.public.protocol],
|
|
["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],
|
|
];
|
|
const usb = model.public.usbLogging;
|
|
if (usb) {
|
|
const stage = ["idle", "soft recovery wait", "re-enumeration wait", "reboot pending"][usb.stage];
|
|
values.push(["USB logging", usb.supported ? (usb.loggingEnabled ? "enabled" : "disabled") : "unsupported"],
|
|
["USB watchdog", usb.watchdogAuto ? "Auto (qualifying)" : usb.watchdogEnabled ? "On" : "Off"],
|
|
["USB Auto qualification", `${usb.autoConnectedSeconds}s of 1209600s`],
|
|
["USB host / reader", `${usb.hostConnected ? "host connected" : "no host"}; ${usb.readerConnected ? "reader connected" : "no reader"}`],
|
|
["USB logging client", usb.loggerActive ? "recent USB stats polling (15-minute lease)" : "inactive (stats lease or healthy USB link missing)"],
|
|
["USB recovery", `${usb.stalled ? "stalled; " : ""}${usb.recovering ? "recovering; " : ""}${usb.recoveryDeferred ? "deferred; " : ""}${stage}; backoff=${usb.backoffStep}`],
|
|
["USB retry interval / inactive", `${usb.retrySeconds}s / ${usb.inactiveSeconds}s`],
|
|
["USB persistence", usb.persistenceReady ? "ready" : "not ready"]);
|
|
} else {
|
|
values.push(["USB logging status", "unavailable in MGR1"]);
|
|
}
|
|
const event = model.public.usbWatchdogLast;
|
|
if (event) {
|
|
values.push(["Last USB watchdog event", `#${event.sequence}: ${event.action}`],
|
|
["USB watchdog event reasons", event.reasonNames.join(", ")],
|
|
["USB watchdog event time", `${timestampText(event.epoch)} (advisory RTC; raw epoch=${event.epoch})`],
|
|
["USB watchdog event uptime", `${event.uptimeSeconds}s at the recorded boot`],
|
|
["USB watchdog event persistence", event.persisted ? "saved" : "RAM-only"],
|
|
["USB watchdog action meaning", event.actionCode === 3
|
|
? "Reboot was requested; this does not prove a physical reset occurred."
|
|
: 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 === "MGR2"
|
|
? "none recorded" : "unavailable in MGR1"]);
|
|
}
|
|
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() {
|
|
const request = {};
|
|
activeDecode = request;
|
|
resetResults();
|
|
error.hidden = true;
|
|
decode.disabled = true;
|
|
try {
|
|
const model = await decodeManagement(input.value, password.value, candidate.value.trim());
|
|
if (activeDecode === request) render(model);
|
|
} catch (failure) {
|
|
if (activeDecode === request) showError(failure);
|
|
} finally {
|
|
if (activeDecode === request) {
|
|
activeDecode = null;
|
|
decode.disabled = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
decode.addEventListener("click", decodeInput);
|
|
clear.addEventListener("click", () => {
|
|
// Web Crypto cannot be cancelled, but an older promise must never
|
|
// redisplay decrypted ACL entries after Clear or a newer decode.
|
|
activeDecode = null;
|
|
input.value = "";
|
|
password.value = "";
|
|
candidate.value = "";
|
|
resetResults();
|
|
error.hidden = true;
|
|
error.textContent = "";
|
|
decode.disabled = false;
|
|
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);
|