diff --git a/docs/_javascript/management_decoder.js b/docs/_javascript/management_decoder.js index 5095f6cd1..5192e52a0 100644 --- a/docs/_javascript/management_decoder.js +++ b/docs/_javascript/management_decoder.js @@ -149,7 +149,7 @@ for (let index = 0; index < 16; index += 1) d[index] ^= aadMac[index]; if (plaintext.length >= 16) { - const adjusted = plaintext.slice(); + 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); @@ -165,7 +165,7 @@ async function ctrCrypt(keyBytes, tag, input) { const subtle = cryptoApi(); - const counter = tag.slice(); + const counter = new Uint8Array(tag); counter[8] &= 0x7f; counter[12] &= 0x7f; const key = await subtle.importKey("raw", keyBytes, { name: "AES-CTR" }, false, ["decrypt"]); @@ -274,8 +274,9 @@ 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))) { + // 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 { @@ -538,6 +539,9 @@ 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.`); @@ -576,6 +580,17 @@ 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; @@ -677,24 +692,36 @@ } async function decodeInput() { + const request = {}; + activeDecode = request; + resetResults(); error.hidden = true; decode.disabled = true; try { - render(await decodeManagement(input.value, password.value, candidate.value.trim())); + const model = await decodeManagement(input.value, password.value, candidate.value.trim()); + if (activeDecode === request) render(model); } catch (failure) { - showError(failure); + if (activeDecode === request) showError(failure); } finally { - decode.disabled = false; + 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 = ""; - results.hidden = true; + resetResults(); error.hidden = true; + error.textContent = ""; + decode.disabled = false; input.focus(); }); input.addEventListener("keydown", (event) => { diff --git a/test/test_management_decoder.js b/test/test_management_decoder.js index 490da032b..267243729 100644 --- a/test/test_management_decoder.js +++ b/test/test_management_decoder.js @@ -1,6 +1,8 @@ "use strict"; const assert = require("assert"); +const fs = require("fs"); +const vm = require("vm"); if (!globalThis.crypto) globalThis.crypto = require("crypto").webcrypto; const decoder = require("../docs/_javascript/management_decoder.js"); @@ -8,6 +10,88 @@ function hex(text) { return Uint8Array.from(Buffer.from(text, "hex")); } +async function authenticatedAclPages(headerLength, perPage, entries) { + const root = await decoder.passwordKey(decoder.EXAMPLE_PASSWORD); + const pages = []; + for (let page = 0; page < Math.ceil(entries.length / perPage); ++page) { + const header = Buffer.alloc(headerLength); + Buffer.from(decoder.EXAMPLE_PAGE, "hex").copy(header, 0, 0, 83); + header[3] = headerLength === 111 ? 0x32 : 0x31; + const first = page * perPage; + const count = Math.min(perPage, entries.length - first); + header[78] = page; header[79] = Math.ceil(entries.length / perPage); + header[80] = entries.length; header[81] = first; header[82] = count; + const plaintext = Buffer.concat(entries.slice(first, first + count)); + const key = await decoder.deriveKey(root, "MeshCore-MGR1-SIV", header.subarray(4, 20)); + const tag = await decoder.s2v(key.subarray(0, 16), new Uint8Array(header), + new Uint8Array(plaintext)); + const counter = new Uint8Array(tag); counter[8] &= 0x7f; counter[12] &= 0x7f; + const ctrKey = await globalThis.crypto.subtle.importKey("raw", key.subarray(16), + { name: "AES-CTR" }, false, ["encrypt"]); + const ciphertext = await globalThis.crypto.subtle.encrypt( + { name: "AES-CTR", counter, length: 128 }, ctrKey, plaintext); + pages.push(Buffer.concat([header, Buffer.from(ciphertext), Buffer.from(tag)]).toString("hex")); + } + return pages; +} + +// Execute the actual browser entry point against a minimal DOM boundary. All +// cryptography remains Node's real Web Crypto; only one digest can be held to +// deterministically exercise an in-flight decode when the user clears/retries. +function browserFixture() { + function element() { + const handlers = new Map(); + const classes = new Set(); + return { + value: "", hidden: true, disabled: false, textContent: "", children: [], + classList: { toggle(name, enabled) { if (enabled) classes.add(name); else classes.delete(name); } }, + addEventListener(name, callback) { handlers.set(name, callback); }, + emit(name, event = {}) { return handlers.get(name)(event); }, + append(...children) { this.children.push(...children); }, + appendChild(child) { this.children.push(child); }, + replaceChildren(...children) { this.children = children; }, + focus() {}, + }; + } + const fields = new Map(); + const root = element(); + root.querySelector = (selector) => { + if (!fields.has(selector)) fields.set(selector, element()); + return fields.get(selector); + }; + let nextDigest = null; + const real = globalThis.crypto.subtle; + const subtle = {}; + for (const name of ["importKey", "encrypt", "decrypt", "sign"]) { + subtle[name] = (...args) => real[name](...args); + } + subtle.digest = async (...args) => { + const pending = real.digest(...args); + const gate = nextDigest; + nextDigest = null; + if (gate) { gate.started(); await gate.wait; } + return pending; + }; + const context = { + crypto: { subtle }, TextEncoder, Uint8Array, console, + document: { + readyState: "complete", querySelector: () => root, createElement: element, + }, + }; + vm.runInNewContext(fs.readFileSync(require.resolve( + "../docs/_javascript/management_decoder.js"), "utf8"), context); + return { + field(name) { return root.querySelector(`[data-role='${name}']`); }, + holdNextDigest() { + let started, release; + const began = new Promise((resolve) => { started = resolve; }); + const wait = new Promise((resolve) => { release = resolve; }); + nextDigest = { started, wait }; + return { began, release }; + }, + }; +} + (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. @@ -20,6 +104,25 @@ function hex(text) { "112233445566778899aabbccddee" ); + // Node Buffers implement slice() as a shared view. The exported helpers must + // leave caller buffers intact and accept the same independent RFC vector. + const bufferTag = Buffer.from(tag); + assert.strictEqual( + Buffer.from(await decoder.openSiv(Buffer.from(key), Buffer.from(aad), + Buffer.from(ciphertext), bufferTag)).toString("hex"), + "112233445566778899aabbccddee" + ); + assert.deepStrictEqual(bufferTag, Buffer.from(tag)); + for (const length of [16, 17, 32]) { + const plaintext = Buffer.alloc(length, 7); + const original = Buffer.from(plaintext); + const expected = await decoder.s2v(key.subarray(0, 16), aad, + new Uint8Array(plaintext)); + assert.deepStrictEqual(Buffer.from(await decoder.s2v(Buffer.from(key.subarray(0, 16)), + Buffer.from(aad), plaintext)), Buffer.from(expected)); + assert.deepStrictEqual(plaintext, original); + } + const publicOnly = await decoder.decodeManagement(decoder.EXAMPLE_PAGE, ""); assert.strictEqual(publicOnly.authenticated, false); assert.strictEqual(publicOnly.public.radioId, "000102030405060708090A0B0C0D0E0F"); @@ -129,6 +232,69 @@ function hex(text) { await assert.rejects(decoder.decodeManagement(excessive.toString("hex"), ""), /bounds/); } + // Multi-page assembly cannot mix event/USB snapshots or conflicting copies, + // even when radio identity and sequence happen to match. + const twoPages = []; + for (let page = 0; page < 2; ++page) { + const count = page === 0 ? 4 : 1; + const packed = Buffer.alloc(111 + count * 13 + 16); + withEvent.copy(packed, 0, 0, 111); + packed[78] = page; packed[79] = 2; packed[80] = 5; + packed[81] = page * 4; packed[82] = count; + twoPages.push(packed); + } + const joinPages = (...pages) => pages.map((page) => page.toString("hex")).join("\n"); + const ordered = await decoder.decodeManagement(joinPages(twoPages[1], twoPages[0]), ""); + assert.strictEqual(ordered.complete, true); + assert.strictEqual(ordered.public.page, "1 of 2"); + const partial = await decoder.decodeManagement(twoPages[1].toString("hex"), ""); + assert.strictEqual(partial.complete, false); + assert.strictEqual(partial.suppliedPages, 1); + assert.strictEqual((await decoder.decodeManagement(joinPages( + twoPages[0], twoPages[0]), "")).suppliedPages, 1); + for (const offset of [24, 83, 99, 103, 107]) { + const mixed = Buffer.from(twoPages[1]); mixed[offset] ^= 1; + await assert.rejects(decoder.decodeManagement(joinPages(twoPages[0], mixed), ""), /same snapshot/); + } + const conflicting = Buffer.from(twoPages[0]); conflicting[111] ^= 1; + await assert.rejects(decoder.decodeManagement(joinPages(twoPages[0], conflicting), ""), /Conflicting copies/); + for (const offset of [4, 20]) { + const otherReport = Buffer.from(twoPages[1]); otherReport[offset] ^= 1; + await assert.rejects(decoder.decodeManagement(joinPages(twoPages[0], otherReport), ""), /more than one report/); + } + + // The production allowlist merges permissions for each keyed fingerprint. + // Authenticated duplicates, within a page or across pages, are not a unique + // ACL and must fail instead of appearing as two different administrators. + for (const [header, perPage] of [[83, 6], [111, 4]]) { + const admin = Buffer.alloc(13); admin[12] = 1; + const signer = Buffer.from(admin); signer[12] = 2; + const withinPage = await authenticatedAclPages(header, perPage, [admin, signer]); + const publicOnly = await decoder.decodeManagement(withinPage.join("\n"), ""); + assert.strictEqual(publicOnly.authenticated, false); + assert.strictEqual(publicOnly.acl.length, 0); + await assert.rejects(decoder.decodeManagement(withinPage.join("\n"), + decoder.EXAMPLE_PASSWORD), /duplicate fingerprint/); + + const entries = Array.from({ length: perPage + 1 }, (_value, index) => { + const entry = Buffer.from(admin); entry[0] = index; return entry; + }); + entries[perPage] = signer; + const acrossPages = await authenticatedAclPages(header, perPage, entries); + await assert.rejects(decoder.decodeManagement(acrossPages.join("\n"), + decoder.EXAMPLE_PASSWORD), /duplicate fingerprint/); + assert.strictEqual((await decoder.decodeManagement(acrossPages[1], + decoder.EXAMPLE_PASSWORD)).authenticated, true); + + const combined = Buffer.from(admin); combined[12] = 3; + const valid = await authenticatedAclPages(header, perPage, [combined]); + const report = await decoder.decodeManagement(valid[0] + "\n" + valid[0], + decoder.EXAMPLE_PASSWORD); + assert.strictEqual(report.complete, true); + assert.strictEqual(report.acl.length, 1); + assert(report.acl[0].administrator && report.acl[0].otaSigner); + } + const decoded = await decoder.decodeManagement( decoder.EXAMPLE_PAGE, decoder.EXAMPLE_PASSWORD @@ -145,7 +311,7 @@ function hex(text) { const duplicateLines = decoder.EXAMPLE_PAGE + "\n" + decoder.EXAMPLE_PAGE; assert.strictEqual((await decoder.decodeManagement(duplicateLines, decoder.EXAMPLE_PASSWORD)).acl.length, 1); - const groupData = "1A00" + decoder.EXAMPLE_PAGE; + const groupData = "1A00" + decoder.EXAMPLE_PAGE + "000000"; const packet = await decoder.decodeManagement(groupData, decoder.EXAMPLE_PASSWORD); assert.strictEqual(packet.public.envelope.route, "direct"); assert.strictEqual(packet.public.envelope.pathHops, 0); @@ -154,6 +320,35 @@ function hex(text) { assert.strictEqual(routed.public.envelope.pathHops, 1); assert.strictEqual(routed.acl[0].administrator, true); + // Firmware pads management GroupData on every route, not only flood. Bare + // canonical pages are still valid pasted input, but not a wire envelope. + for (const [source, header, perPage] of [[legacy, 83, 6], [current, 111, 4]]) { + for (let count = 0; count <= perPage; ++count) { + const canonical = Buffer.alloc(header + count * 13 + 16); + source.copy(canonical, 0, 0, header); + canonical[78] = 0; canonical[79] = 1; canonical[80] = count; + canonical[81] = 0; canonical[82] = count; + assert.strictEqual((await decoder.decodeManagement(canonical.toString("hex"), "")).pageCount, 1); + const padded = Buffer.alloc(3 + Math.ceil((canonical.length - 3) / 16) * 16); + canonical.copy(padded); + for (let route = 0; route < 4; ++route) { + const envelope = Buffer.from(route === 0 || route === 3 + ? [0x18 | route, 1, 2, 3, 4, 0] : [0x18 | route, 0]); + const wire = Buffer.concat([envelope, padded]); + const result = await decoder.decodeManagement(wire.toString("hex"), ""); + assert.strictEqual(result.public.envelope.routeCode, route); + if (canonical.length !== padded.length) { + await assert.rejects(decoder.decodeManagement(Buffer.concat( + [envelope, canonical]).toString("hex"), ""), /No complete|padding/); + const badPadding = Buffer.from(wire); badPadding[badPadding.length - 1] = 1; + await assert.rejects(decoder.decodeManagement(badPadding.toString("hex"), ""), /No complete|padding/); + } + await assert.rejects(decoder.decodeManagement(Buffer.concat( + [wire, Buffer.alloc(16)]).toString("hex"), ""), /No complete|padding/); + } + } + } + // 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 = @@ -175,6 +370,75 @@ function hex(text) { decoder.decodeManagement(decoder.EXAMPLE_PAGE, "short"), /12 through 96/ ); + + const browser = browserFixture(); + browser.field("input").value = decoder.EXAMPLE_PAGE; + browser.field("password").value = decoder.EXAMPLE_PASSWORD; + const clearGate = browser.holdNextDigest(); + const oldDecode = browser.field("decode").emit("click"); + await clearGate.began; + browser.field("clear").emit("click"); + clearGate.release(); await oldDecode; + assert.strictEqual(browser.field("results").hidden, true, + "a decode completed after Clear must not reveal the old report"); + assert.strictEqual(browser.field("error").hidden, true); + assert.strictEqual(browser.field("input").value, ""); + assert.strictEqual(browser.field("password").value, ""); + assert.strictEqual(browser.field("decode").disabled, false); + assert.strictEqual(browser.field("summary").children.length, 0); + assert.strictEqual(browser.field("acl-table").children.length, 0); + + // Clear removes already-rendered secret rows, not only their visibility. + browser.field("input").value = decoder.EXAMPLE_PAGE; + browser.field("password").value = decoder.EXAMPLE_PASSWORD; + await browser.field("decode").emit("click"); + assert.strictEqual(browser.field("results").hidden, false); + assert(browser.field("acl-table").children.length > 0); + browser.field("clear").emit("click"); + assert.strictEqual(browser.field("acl-table").children.length, 0); + assert.strictEqual(browser.field("summary").children.length, 0); + assert.strictEqual(browser.field("status").textContent, ""); + + for (const oldPassword of [decoder.EXAMPLE_PASSWORD, "not the right management password"]) { + browser.field("input").value = decoder.EXAMPLE_PAGE; + browser.field("password").value = oldPassword; + const gate = browser.holdNextDigest(); + const superseded = browser.field("decode").emit("click"); + await gate.began; + browser.field("password").value = ""; + let prevented = false; + browser.field("input").emit("keydown", { + key: "Enter", ctrlKey: true, + preventDefault() { prevented = true; }, + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual(prevented, true); + assert.strictEqual(browser.field("results").hidden, false); + assert(browser.field("status").textContent.startsWith("Public-only")); + gate.release(); await superseded; + assert(browser.field("status").textContent.startsWith("Public-only"), + "an older success must not overwrite the newer decode"); + assert.strictEqual(browser.field("results").hidden, false); + assert.strictEqual(browser.field("error").hidden, true, + "an older error must not hide the newer decode"); + assert.strictEqual(browser.field("decode").disabled, false); + } + browser.field("password").value = decoder.EXAMPLE_PASSWORD; + const olderGate = browser.holdNextDigest(); + const older = browser.field("decode").emit("click"); + await olderGate.began; + const newerGate = browser.holdNextDigest(); + const newer = browser.field("decode").emit("click"); + await newerGate.began; + olderGate.release(); await older; + assert.strictEqual(browser.field("decode").disabled, true, + "an older completion must not unlock the current in-flight decode"); + assert.strictEqual(browser.field("results").hidden, true); + newerGate.release(); await newer; + assert.strictEqual(browser.field("decode").disabled, false); + assert.strictEqual(browser.field("results").hidden, false); + assert(browser.field("status").textContent.startsWith("Password-authenticated")); + console.log("management browser decoder checks passed"); })().catch((error) => { console.error(error.stack || error);