Files
HaloKeymind/docs/_javascript/management_decoder.js
T

640 lines
25 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 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);