Files
HaloKeymind/test/test_management_report.py
T

524 lines
31 KiB
Python

"""Production crypto vs RFC vector + independent Python decoder, no AES mocks."""
from pathlib import Path
import contextlib
import importlib.util
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location("management", ROOT / "tools/management/report.py")
report = importlib.util.module_from_spec(spec)
spec.loader.exec_module(report)
class ManagementTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
candidates = [Path(os.environ.get("MESHCORE_CRYPTO_DIR", "/nonexistent"))]
candidates += sorted((ROOT / ".pio/libdeps").glob("*/Crypto"))
cls.crypto = next((p for p in candidates if (p / "AES128.cpp").is_file()), None)
if cls.crypto is None:
raise RuntimeError("Install rweather/Crypto 0.4.0 or set MESHCORE_CRYPTO_DIR (no mock fallback)")
cls.work = tempfile.TemporaryDirectory()
cls.exe = Path(cls.work.name) / "management-test"
sources = ["AES128.cpp", "AESCommon.cpp", "BlockCipher.cpp", "Crypto.cpp", "SHA256.cpp", "Hash.cpp"]
host_define = [] if os.name == "nt" else ["-DHOST_BUILD"]
sanitizers = [] if os.name == "nt" else ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-omit-frame-pointer"]
cmd = [shutil.which("g++") or "g++", "-std=c++17", "-O1", "-g", "-Wall", "-Wextra"] + host_define + [
*sanitizers, "-I", str(cls.crypto), "-I", str(ROOT / "src"),
str(ROOT / "src/helpers/ManagementReport.cpp"),
str(ROOT / "test/fixtures/management/protocol_test.cpp")]
cmd += [str(cls.crypto / p) for p in sources] + ["-o", str(cls.exe)]
subprocess.run(cmd, check=True, capture_output=True, text=True)
result = subprocess.run([str(cls.exe)], check=True, capture_output=True, text=True)
emitted = [bytes.fromhex(line) for line in result.stdout.splitlines()]
cls.pages = [page for page in emitted if page[:4] == b"MGR2" and int.from_bytes(page[20:24], "little") == 42]
cls.legacy_pages = [page for page in emitted if page[:4] == b"MGR1"]
cls.empty_event_pages = [page for page in emitted if page[:4] == b"MGR2" and int.from_bytes(page[20:24], "little") == 43]
cls.prototypes = [cls._old_prototype(count) for count in (0, 4, 5)]
# An old four-entry page pads from 166 to 179 bytes, the same length as
# a current four-entry page. Its encrypted bytes can look like a valid
# public event. Public shape checks cannot distinguish that overlap;
# the unchanged AES-SIV key domain with the new 111-byte AAD must.
for sequence in range(44, 300):
prototype = cls._old_prototype(4, sequence)
padded = prototype + bytes(179 - len(prototype))
try:
report._page_bounds(padded)
except ValueError:
continue
cls.colliding_prototype = padded
break
else:
raise AssertionError("no public-shape overlap found for old MGR2 prototype")
# Compile the unchanged production runtime and transaction writer with
# a memory filesystem/radio in place of the hardware-facing headers.
fixture = ROOT / "test/fixtures/management"
for name in ("ManagementReporter.cpp", "ManagementReporter.h", "FileRead.h", "ContactFileTransaction.h", "PersistentStoreFormat.h"):
shutil.copyfile(ROOT / "src/helpers" / name, Path(cls.work.name) / name)
for name in ("CommonCLI.h", "Mesh.h"):
shutil.copyfile(fixture / name, Path(cls.work.name) / name)
shutil.copyfile(ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h", Path(cls.work.name) / "IdentityStore.h")
runtime = Path(cls.work.name) / "runtime-test"
runtime_cmd = [shutil.which("g++") or "g++", "-std=c++17", "-O1", "-g"] + host_define + ["-DRP2040_PLATFORM",
*sanitizers, "-I", cls.work.name,
"-I", str(cls.crypto), "-I", str(ROOT / "src/helpers"), "-I", str(ROOT / "src"),
str(Path(cls.work.name) / "ManagementReporter.cpp"), str(ROOT / "src/helpers/ManagementReport.cpp"),
str(fixture / "runtime_test.cpp")]
runtime_cmd += [str(cls.crypto / p) for p in sources] + ["-o", str(runtime)]
subprocess.run(runtime_cmd, check=True, capture_output=True, text=True)
cls.runtime = runtime
@classmethod
def tearDownClass(cls):
cls.work.cleanup()
@classmethod
def _old_prototype(cls, count, sequence=44):
"""Generate authentic unreleased MGR2 with its original 98-byte AAD."""
from Crypto.Cipher import AES
header = bytearray(cls.pages[0][:98])
header[20:24] = sequence.to_bytes(4, "little")
header[78:83] = bytes((0, 1, count, 0, count))
key = report.derive(report.password_key("management test password"),
"MeshCore-MGR1-SIV", header[4:20])
cipher = AES.new(key, AES.MODE_SIV)
cipher.update(header)
private = (bytes(12) + b"\x01") * count
ciphertext, tag = cipher.encrypt_and_digest(private)
return bytes(header) + ciphertext + tag
def _run_cli(self, path, *options):
output, errors = io.StringIO(), io.StringIO()
status = 0
with mock.patch.object(sys, "argv", ["report.py", str(path), *options]), \
mock.patch.object(report.getpass, "getpass", return_value="management test password"), \
contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors):
try:
report.main()
except SystemExit as error:
status = error.code
return status, output.getvalue(), errors.getvalue()
@classmethod
def _acl_pages(cls, entries, current=True):
from Crypto.Cipher import AES
base = cls.empty_event_pages[0][:111] if current else cls.legacy_pages[0][:83]
per_page = 4 if current else 6
count = max(1, (len(entries) + per_page - 1) // per_page)
pages = []
for index in range(count):
header = bytearray(base)
block = entries[index * per_page:(index + 1) * per_page]
header[78:83] = bytes((index, count, len(entries), index * per_page, len(block)))
key = report.derive(report.password_key("management test password"),
"MeshCore-MGR1-SIV", header[4:20])
cipher = AES.new(key, AES.MODE_SIV); cipher.update(header)
ciphertext, tag = cipher.encrypt_and_digest(b"".join(token + bytes((flags,)) for token, flags in block))
pages.append(bytes(header) + ciphertext + tag)
return pages
def test_authenticated_duplicate_acl_fingerprints_are_rejected(self):
password = "management test password"
for current in (False, True):
duplicate = self._acl_pages([(bytes(12), 1), (bytes(12), 2)], current)
with self.assertRaisesRegex(ValueError, "[Dd]uplicate"):
report.decode_page(duplicate[0], password)
with self.assertRaisesRegex(ValueError, "[Dd]uplicate"):
report.decode_report(duplicate, password)
per_page = 4 if current else 6
unique = [(index.to_bytes(12, "little"), 3) for index in range(per_page)]
duplicate = self._acl_pages([*unique, (unique[0][0], 2)], current)
for page in duplicate:
report.decode_page(page, password) # No duplicates within either page.
with self.assertRaisesRegex(ValueError, "[Dd]uplicate"):
report.decode_report(duplicate, password)
messages = []
for page in duplicate:
padded = page + bytes(3 + ((len(page) - 3 + 15) // 16) * 16 - len(page))
messages.append(dict(type="PACKET", direction="rx", raw=(b"\x1a\x00" + padded).hex()))
with self.assertRaisesRegex(ValueError, "[Dd]uplicate"):
report.mqtt_reports(messages, password)
valid = self._acl_pages([*unique, (per_page.to_bytes(12, "little"), 3)], current)
decoded = report.decode_report(valid, password)
self.assertEqual(len(decoded["acl"]), per_page + 1)
self.assertTrue(all(entry["admin"] and entry["ota_signer"] for entry in decoded["acl"]))
def test_cli_matches_administrator_for_each_mqtt_report(self):
from Crypto.Cipher import AES
administrator = bytes(range(32))
messages = []
for radio in (bytes(range(16)), bytes(range(16, 32))):
header = bytearray(self.empty_event_pages[0][:111])
header[4:20] = radio
header[78:83] = bytes((0, 1, 1, 0, 1))
key = report.derive(report.password_key("management test password"),
"MeshCore-MGR1-SIV", radio)
private = report.fingerprint("management test password", radio, administrator) + b"\x03"
cipher = AES.new(key, AES.MODE_SIV); cipher.update(header)
ciphertext, tag = cipher.encrypt_and_digest(private)
page = bytes(header) + ciphertext + tag
padded = page + bytes(3 + ((len(page) - 3 + 15) // 16) * 16 - len(page))
messages.append(dict(type="PACKET", direction="rx", raw=(b"\x1a\x00" + padded).hex()))
path = Path(self.work.name) / "cli-mqtt.json"
for jsonl in (False, True):
path.write_text("\n".join(json.dumps(message) for message in messages)
if jsonl else json.dumps(messages), encoding="ascii")
status, output, errors = self._run_cli(path, "--mqtt", "--match-admin", administrator.hex())
self.assertEqual((status, errors), (0, ""))
decoded = json.loads(output)
self.assertEqual(len(decoded), 2)
for item in decoded:
self.assertEqual(item.get("matching_acl"), item["acl"])
self.assertEqual(len(item["matching_acl"]), 1)
self.assertTrue(item["matching_acl"][0]["admin"] and item["matching_acl"][0]["ota_signer"])
status, output, errors = self._run_cli(path, "--mqtt", "--match-admin", "ff" * 32)
self.assertEqual((status, errors), (0, ""))
self.assertEqual([item.get("matching_acl") for item in json.loads(output)], [[], []])
def test_cli_rejects_invalid_match_key_even_with_no_mqtt_reports(self):
path = Path(self.work.name) / "cli-empty.json"
path.write_text("[]", encoding="ascii")
for candidate in ("not-hex", "00", ""):
status, output, errors = self._run_cli(path, "--mqtt", "--match-admin", candidate)
self.assertEqual(status, 2)
self.assertEqual(output, "")
self.assertIn("error:", errors)
def test_cli_file_read_errors_are_reported_without_tracebacks(self):
for path in (Path(self.work.name) / "missing.json", Path(self.work.name)):
status, output, errors = self._run_cli(path)
self.assertEqual(status, 2)
self.assertEqual(output, "")
self.assertIn("error:", errors)
self.assertNotIn("Traceback", errors)
def test_cli_closed_stdin_reports_input_errors_without_tracebacks(self):
path = Path(self.work.name) / "cli-no-password.json"
path.write_text(json.dumps([page.hex() for page in self.pages]), encoding="ascii")
for arguments, expected in (([str(path)], "password input unavailable"),
([str(path.parent / "absent.json")], "No such file"),
([str(path), "--match-admin", "00"], "administrator key")):
result = subprocess.run([sys.executable, "-B", str(ROOT / "tools/management/report.py"),
*arguments], input="", capture_output=True, text=True)
self.assertEqual(result.returncode, 2)
self.assertEqual(result.stdout, "")
self.assertIn(expected, result.stderr)
self.assertNotIn("Traceback", result.stderr)
if expected != "password input unavailable":
self.assertNotIn("Management password:", result.stderr)
def test_cli_canonical_match_and_input_errors(self):
path = Path(self.work.name) / "cli-pages.json"
path.write_text(json.dumps([page.hex() for page in self.pages]), encoding="ascii")
status, output, errors = self._run_cli(path, "--match-admin", "00" * 32)
self.assertEqual((status, errors), (0, ""))
self.assertEqual(json.loads(output)["matching_acl"], [])
for malformed in ("{", '["not hex"]'):
path.write_text(malformed, encoding="ascii")
status, output, errors = self._run_cli(path)
self.assertEqual(status, 2)
self.assertEqual(output, "")
self.assertIn("error:", errors)
def test_cli_rejects_excessive_json_nesting_before_password_prompt(self):
path = Path(self.work.name) / "cli-deep.json"
path.write_text("[" * 10000 + "0" + "]" * 10000, encoding="ascii")
for options in ((), ("--mqtt",)):
with mock.patch.object(report.getpass, "getpass") as password_prompt:
with mock.patch.object(sys, "argv", ["report.py", str(path), *options]), \
contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()) as errors:
with self.assertRaises(SystemExit) as failure:
report.main()
self.assertEqual(failure.exception.code, 2)
self.assertIn("nested too deeply", errors.getvalue())
self.assertNotIn("Traceback", errors.getvalue())
password_prompt.assert_not_called()
def test_interoperable_full_report(self):
decoded = report.decode_report(list(reversed(self.pages)), "management test password")
self.assertEqual(decoded["sequence"], 42)
self.assertEqual(len(decoded["acl"]), 36)
self.assertEqual(decoded["firmware_version"], "1.17.1.5")
self.assertEqual(decoded["protocol"], "MGR2")
self.assertEqual(len(self.pages), 9)
usb = decoded["usb_logging"]
self.assertTrue(usb["supported"] and usb["recovery_deferred"])
self.assertTrue(usb["host_connected"] and usb["reader_connected"])
self.assertTrue(usb["logger_active"])
self.assertEqual(usb["stage"], 2)
self.assertEqual(usb["backoff_step"], 8)
self.assertEqual(usb["retry_seconds"], 604800)
self.assertEqual(usb["inactive_seconds"], 0xffffffff)
self.assertTrue(usb["watchdog_auto"])
self.assertEqual(usb["auto_connected_seconds"], 1209600)
self.assertEqual(decoded["usb_watchdog_last"], dict(
reasons=15, reason_names=["host-absent", "reader-absent", "tx-stalled", "client-inactive"],
action_code=3, action="reboot-requested", persisted=True,
epoch=1700000001, uptime_seconds=777, sequence=42))
self.assertNotIn("manifest_id", decoded)
def test_legacy_report_still_decodes_without_inventing_usb_status(self):
decoded = report.decode_report(self.legacy_pages, "management test password")
self.assertEqual(decoded["protocol"], "MGR1")
self.assertEqual(len(decoded["acl"]), 36)
self.assertIsNone(decoded["usb_logging"])
self.assertIsNone(decoded["usb_watchdog_last"])
with self.assertRaises(ValueError):
report.decode_report([self.legacy_pages[0]] + self.pages[1:], "management test password")
def test_unreleased_98_byte_prototypes_are_not_accepted_as_current_pages(self):
for prototype in self.prototypes:
with self.subTest(count=prototype[82]):
with self.assertRaises(ValueError):
report.decode_page(prototype, "management test password")
with self.assertRaises(ValueError):
report.decode_report([prototype], "management test password")
for prefix in (b"\x3d\x00", b"\x19\x00"):
wire = prefix + prototype
if prefix[0] == 0x19:
wire += bytes(3 + ((len(prototype) - 3 + 15) // 16) * 16 - len(prototype))
message = dict(type="PACKET", direction="rx", raw=wire.hex())
extracted = report.mqtt_payload(message)
if extracted is not None:
# A padded four-entry prototype can overlap current
# public structure, but never authenticates as MGR2.
with self.assertRaises(ValueError):
report.decode_page(extracted, "management test password")
def test_old_prototype_padding_overlap_fails_current_authentication(self):
from Crypto.Cipher import AES
padded = self.colliding_prototype
old = padded[:166]
key = report.derive(report.password_key("management test password"),
"MeshCore-MGR1-SIV", old[4:20])
cipher = AES.new(key, AES.MODE_SIV); cipher.update(old[:98])
self.assertEqual(cipher.decrypt_and_verify(old[98:-16], old[-16:]),
(bytes(12) + b"\x01") * 4)
self.assertEqual(report._page_bounds(padded), (111, 179))
with self.assertRaises(ValueError):
report.decode_page(padded, "management test password")
for prefix in (b"\x3d\x00", b"\x19\x00"):
message = dict(type="PACKET", direction="rx", raw=(prefix + padded).hex())
self.assertEqual(report.mqtt_payload(message), padded)
with self.assertRaises(ValueError):
report.mqtt_reports([message], "management test password")
def test_current_none_event_with_empty_acl_is_authenticated(self):
decoded = report.decode_report(self.empty_event_pages, "management test password")
self.assertEqual(decoded["protocol"], "MGR2")
self.assertEqual(decoded["acl"], [])
self.assertIsNone(decoded["usb_watchdog_last"])
def test_current_reports_decode_in_browser_with_real_production_ciphertext(self):
node = shutil.which("node")
self.assertIsNotNone(node, "node is required for cross-language management decoding")
script = """
const assert = require('assert');
globalThis.crypto = require('crypto').webcrypto;
const decoder = require(process.argv[1]);
let data = '';
process.stdin.on('data', chunk => { data += chunk; });
process.stdin.on('end', async () => {
try {
const captures = JSON.parse(data);
const pages = captures.MGR2;
const legacy = await decoder.decodeManagement(captures.MGR1.join('\\n'), 'management test password');
assert(legacy.authenticated && legacy.complete);
assert.strictEqual(legacy.acl.length, 36);
assert.strictEqual(legacy.public.protocol, 'MGR1');
assert.strictEqual(legacy.pageCount, 6);
assert.strictEqual(legacy.public.usbWatchdogLast, null);
assert.strictEqual(legacy.public.usbLogging, null);
for (const prototype of captures.prototypes) {
await assert.rejects(decoder.decodeManagement(prototype, 'management test password'));
}
await assert.rejects(decoder.decodeManagement(captures.oldPadded, 'management test password'), /Password is wrong/);
const mgr3 = Buffer.from(pages[0], 'hex'); mgr3[3] = '3'.charCodeAt(0);
await assert.rejects(decoder.decodeManagement(mgr3.toString('hex'), 'management test password'));
const empty = await decoder.decodeManagement(captures.empty.join('\\n'), 'management test password');
assert(empty.authenticated && empty.complete && !empty.acl.length);
assert.strictEqual(empty.public.protocol, 'MGR2');
assert.strictEqual(empty.public.usbWatchdogLast, null);
const result = await decoder.decodeManagement(pages.join('\\n'), 'management test password');
assert(result.authenticated && result.complete);
assert.strictEqual(result.acl.length, 36);
assert.strictEqual(result.pageCount, 9);
assert.strictEqual(result.public.protocol, 'MGR2');
assert.strictEqual(result.public.usbLogging.retrySeconds, 604800);
assert.strictEqual(result.public.usbLogging.inactiveSeconds, 0xffffffff);
assert.strictEqual(result.public.usbLogging.hostConnected, true);
assert.strictEqual(result.public.usbLogging.readerConnected, true);
assert.strictEqual(result.public.usbLogging.loggerActive, true);
assert.strictEqual(result.public.usbLogging.watchdogAuto, true);
assert.strictEqual(result.public.usbLogging.autoConnectedSeconds, 1209600);
assert.strictEqual(result.public.usbWatchdogLast.epoch, 1700000001);
assert.strictEqual(result.public.usbWatchdogLast.uptimeSeconds, 777);
assert.strictEqual(result.public.usbWatchdogLast.sequence, 42);
assert.strictEqual(result.public.usbWatchdogLast.reasons, 15);
assert.strictEqual(result.public.usbWatchdogLast.action, 'reboot-requested');
assert.strictEqual(result.public.usbWatchdogLast.persisted, true);
const raw = pages.map(page => '1900' + page + '00'.repeat((3 + Math.ceil((page.length / 2 - 3) / 16) * 16) - page.length / 2));
const routed = await decoder.decodeManagement(raw.join('\\n'), 'management test password');
assert(routed.authenticated && routed.complete);
assert.strictEqual(routed.public.envelope.route, 'flood');
const mutated = Buffer.from(pages[0], 'hex'); mutated[87] ^= 1;
await assert.rejects(decoder.decodeManagement(mutated.toString('hex'), 'management test password'), /Password is wrong/);
const changedLogger = Buffer.from(pages[0], 'hex'); changedLogger[84] ^= 4;
await assert.rejects(decoder.decodeManagement(changedLogger.toString('hex'), 'management test password'), /Password is wrong/);
for (let offset = 98; offset < 111; ++offset) {
const changedEvent = Buffer.from(pages[0], 'hex'); changedEvent[offset] ^= 1;
await assert.rejects(decoder.decodeManagement(changedEvent.toString('hex'), 'management test password'), /Password is wrong|Invalid/);
}
const changedSnapshot = Buffer.from(pages[1], 'hex'); changedSnapshot[99] ^= 1;
await assert.rejects(decoder.decodeManagement(pages[0] + '\\n' + changedSnapshot.toString('hex'), ''), /same snapshot/);
} catch (error) { console.error(error); process.exitCode = 1; }
});
"""
import json
subprocess.run([node, "-e", script, str(ROOT / "docs/_javascript/management_decoder.js")],
input=json.dumps({"MGR2": [page.hex() for page in self.pages],
"MGR1": [page.hex() for page in self.legacy_pages],
"prototypes": [page.hex() for page in self.prototypes],
"oldPadded": self.colliding_prototype.hex(),
"empty": [page.hex() for page in self.empty_event_pages]}),
text=True, check=True, timeout=30)
def test_runtime_storage_scheduling_rollover_and_failures(self):
subprocess.run([str(self.runtime)], check=True)
def test_actual_legacy_route_adoption_persistence_and_precedence(self):
source = (ROOT / "src/helpers/CommonCLI_Management.cpp").read_text(encoding="utf-8")
state = source[source.index("static constexpr char DATA_ROUTE_FILE[]"):
source.index("static uint32_t readRoute32(")]
generated = ("namespace mesh {\n" + state +
extract_braced(source, "static uint8_t nibble(") + "\n" +
extract_braced(source, "static char* trimDataRoute(") + "\n" +
extract_braced(source, "static bool parseDataPath(") + "\n" +
extract_braced(source, "static void writeRoute32(") + "\n" +
extract_braced(source, "static bool dataRouteSave(") + "\n}\n" +
extract_braced(source, "bool CommonCLI::adoptLegacyDataTxPath(") + "\n")
(Path(self.work.name) / "production_route.inc").write_text(generated, encoding="ascii")
executable = Path(self.work.name) / "route-test"
sanitizers = [] if os.name == "nt" else ["-DHOST_BUILD", "-fsanitize=address,undefined",
"-fno-sanitize-recover=all", "-fno-omit-frame-pointer"]
compiled = subprocess.run([shutil.which("g++") or "g++", "-std=c++17", "-O1", "-g",
"-Wall", "-Wextra", "-DRP2040_PLATFORM", *sanitizers,
"-I", self.work.name,
str(ROOT / "test/fixtures/management/route_test.cpp"),
"-o", str(executable)], capture_output=True, text=True)
self.assertEqual(compiled.returncode, 0, compiled.stderr)
subprocess.run([str(executable)], check=True)
def test_tampering_and_wrong_password(self):
for position in (4, 20, 28, 74, 76, 83, 85, 86, 90, 94, *range(98, 111), 111, -1):
changed = bytearray(self.pages[0]); changed[position] ^= 1
with self.subTest(position=position), self.assertRaises(ValueError):
report.decode_report([bytes(changed)] + self.pages[1:], "management test password")
with self.assertRaises(ValueError):
report.decode_report(self.pages, "incorrect management password")
def test_logger_activity_is_authenticated_separately_from_connection(self):
changed = bytearray(self.pages[0]); changed[84] ^= 4
public = report._usb_status(changed)
self.assertTrue(public["host_connected"] and public["reader_connected"])
self.assertFalse(public["logger_active"])
with self.assertRaises(ValueError):
report.decode_page(bytes(changed), "management test password")
def test_page_omission_duplicate_mixed(self):
for pages in ([], self.pages[:-1], self.pages[1:] + [self.pages[1]], self.pages + [self.pages[0]]):
with self.assertRaises(ValueError):
report.decode_report(pages, "management test password")
def test_current_size_page_bounds_and_unknown_version_are_rejected(self):
for offset, value in ((3, ord("3")), (3, ord("4")), (79, 8), (81, 6), (82, 5), (80, 37), (84, 8), (85, 0xc0)):
changed = bytearray(self.pages[0]); changed[offset] = value
with self.subTest(offset=offset), self.assertRaises(ValueError):
report.decode_page(bytes(changed), "management test password")
for payload in (self.pages[0][:-1], self.pages[0] + b"\x00", *(self.pages[0][:n] for n in range(127))):
with self.assertRaises(ValueError):
report.decode_page(payload, "management test password")
mgr3 = bytearray(self.pages[0]); mgr3[3] = ord("3")
for prefix in (b"\x3d\x00", b"\x19\x00"):
self.assertIsNone(report.mqtt_payload(dict(type="PACKET", direction="rx", raw=(prefix + mgr3).hex())))
def test_event_validation_and_advisory_zero_time(self):
for code in (0x80, 0x8f, 0xdf, 0xef, 0xff):
changed = bytearray(self.pages[0]); changed[98] = code
with self.subTest(code=code), self.assertRaises(ValueError):
report.decode_page(bytes(changed), "management test password")
changed = bytearray(self.pages[0]); changed[107:111] = bytes(4)
with self.assertRaises(ValueError):
report.decode_page(bytes(changed), "management test password")
changed = bytearray(self.pages[0]); changed[99:103] = bytes(4)
self.assertEqual(report._watchdog_event(changed)["epoch"], 0)
for action in range(1, 5):
changed[98] = 15 | (action << 4)
event = report._watchdog_event(changed)
self.assertEqual(event["action_code"], action)
self.assertFalse(event["persisted"])
def test_authenticated_event_mismatch_is_not_one_snapshot(self):
from Crypto.Cipher import AES
page = self.pages[1]
key = report.derive(report.password_key("management test password"), "MeshCore-MGR1-SIV", page[4:20])
cipher = AES.new(key, AES.MODE_SIV); cipher.update(page[:111])
private = cipher.decrypt_and_verify(page[111:-16], page[-16:])
header = bytearray(page[:111]); header[99] ^= 1
cipher = AES.new(key, AES.MODE_SIV); cipher.update(header)
ciphertext, tag = cipher.encrypt_and_digest(private)
changed = bytes(header) + ciphertext + tag
self.assertEqual(report.decode_page(changed, "management test password"),
report.decode_page(page, "management test password"))
with self.assertRaisesRegex(ValueError, "mixed report snapshots"):
report.decode_report([self.pages[0], changed] + self.pages[2:], "management test password")
def test_fingerprints_and_password_length(self):
a = report.fingerprint("management test password", bytes(16), bytes(32))
b = report.fingerprint("management test password", bytes([1]) + bytes(15), bytes(32))
self.assertEqual(len(a), 12); self.assertNotEqual(a, b)
for password in ("short", "x" * 97):
with self.assertRaises(ValueError): report.password_key(password)
def test_companions_excluded(self):
for role in ("simple_repeater/MyMesh", "simple_room_server/MyMesh", "simple_sensor/SensorMesh"):
self.assertIn("_cli.beginManagement(*this, _fs)", (ROOT / "examples" / (role + ".cpp")).read_text())
for path in (ROOT / "examples/companion_radio").glob("*.*"):
if path.suffix in (".cpp", ".h"): self.assertNotIn("beginManagement", path.read_text())
def test_mqtt_paths_scopes_and_duplicate_uplinks(self):
messages = []
for route in range(4):
for page in self.pages:
wire = bytes([0x3c | route]) + (bytes(4) if route in (0, 3) else b"")
wire += bytes([0x42]) + bytes.fromhex("1234abcd") + page
message = dict(type="PACKET", direction="rx", raw=wire.hex())
self.assertEqual(report.mqtt_payload(message), page)
messages.extend([message, message])
decoded = report.mqtt_reports(messages, "management test password")
self.assertEqual(len(decoded), 1)
self.assertEqual(len(decoded[0]["acl"]), 36)
self.assertIsNone(report.mqtt_payload(dict(type="PACKET", direction="tx", raw=wire.hex())))
self.assertIsNone(report.mqtt_payload(dict(type="PACKET", direction="rx", raw="3dc0")))
self.assertEqual(report.mqtt_reports(messages[:2], "management test password"), [])
for page in self.pages:
padded = page + bytes(3 + ((len(page) - 3 + 15) // 16) * 16 - len(page))
message = dict(type="PACKET", direction="rx", raw=(b"\x19\x00" + padded).hex())
self.assertEqual(report.mqtt_payload(message), page)
malformed = padded[:-1] + b"\x01" if len(padded) > len(page) else padded + b"\x00"
message["raw"] = (b"\x19\x00" + malformed).hex()
self.assertIsNone(report.mqtt_payload(message))
if __name__ == "__main__": unittest.main()