mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-20 12:41:58 +00:00
Fix management browser authentication and stale decode results
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user