mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-25 13:53:41 +00:00
Add browser management report decoder
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user