Protect nRF SD OTA authorization RAM from source-only stores

This commit is contained in:
mikecarper committed 2026-10-17 15:35:34 -07:00
1 parent 9c3df94f99
commit 5ee9ceeb20
14 files changed
+556 -9

No files matched your search

+91
View File
@@ -0,0 +1,91 @@
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include "helpers/BorrowableFrameBuffer.h"
#include "helpers/ota/OtaSdAuthRecord.h"
#define STRINGIFY_INNER(value) #value
#define STRINGIFY(value) STRINGIFY_INNER(value)
#ifdef TEST_AUTH_START
asm(".global __mota_sd_auth_start__\n.set __mota_sd_auth_start__, " STRINGIFY(TEST_AUTH_START));
asm(".global __mota_sd_auth_end__\n.set __mota_sd_auth_end__, " STRINGIFY(TEST_AUTH_END));
#endif
// Simulate an ordinary linker with a large .svc_data/.fs_data prefix. Its
// initialized .data starts after the auth slot, but the slot is still owned by
// the application. A __data_start__ check alone would incorrectly allow writes.
asm(".global __data_start__\n.set __data_start__, 0x20006100");
static unsigned barriers;
[[maybe_unused]] static void __DMB() { ++barriers; }
[[maybe_unused]] static void __DSB() { ++barriers; }
namespace mesh {
namespace ota {
class OtaStoreSdNrf52 {
public:
OtaStoreSdNrf52();
};
#include "production_auth.inc"
} // namespace ota
} // namespace mesh
struct Frame { uint8_t bytes[96]; };
struct Workspace {
mesh::ota::OtaStoreSdNrf52 store;
uint32_t payload = 0x12345678;
};
int main() {
void* const mapped = mmap(reinterpret_cast<void*>(0x20006000), 4096,
PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, -1, 0);
assert(mapped == reinterpret_cast<void*>(0x20006000));
uint8_t* const page = static_cast<uint8_t*>(mapped);
uint8_t before[4096];
memset(page, 0xA7, 4096);
memcpy(before, page, sizeof(before));
mesh::BorrowableFrameBuffer<Frame, 256, 128, Workspace> queue;
int head = 39;
for (size_t index = 0; index < queue.capacity(); ++index)
memset(queue.at(index).bytes, static_cast<int>(index), sizeof(Frame));
for (unsigned cycle = 0; cycle != 64; ++cycle) {
Workspace* const workspace = queue.acquire(97, head);
assert(workspace && workspace->payload == 0x12345678 && head == 0);
assert(queue.acquire(97, head) == workspace);
assert(queue.capacity() == 128);
for (size_t index = 0; index < 97; ++index) {
const uint8_t expected = static_cast<uint8_t>(index + 39);
for (uint8_t byte : queue.at(index).bytes) assert(byte == expected);
}
queue.release(head);
queue.release(head);
assert(queue.capacity() == 256);
}
if (EXPECT_OWNS) {
for (unsigned index = 8; index < 80; ++index) assert(page[index] == 0);
assert(barriers == 128);
} else {
assert(memcmp(page, before, sizeof(before)) == 0);
assert(barriers == 0);
}
#if !defined(OTA_SEEDER_ONLY)
uint8_t record[mesh::ota::MOTA_SD_AUTH_LEN];
for (size_t index = 0; index < sizeof(record); ++index)
record[index] = static_cast<uint8_t>(index + 1);
memcpy(before, page, sizeof(before));
assert(mesh::ota::publish_sd_auth_record(record) == static_cast<bool>(EXPECT_OWNS));
if (EXPECT_OWNS) {
assert(memcmp(page + 8, record, sizeof(record)) == 0);
mesh::ota::clear_sd_auth_record();
for (unsigned index = 8; index < 80; ++index) assert(page[index] == 0);
} else {
assert(memcmp(page, before, sizeof(before)) == 0);
}
#endif
// Neither successful receivers nor failed/absent ownership touch either side.
for (unsigned index = 0; index < 8; ++index) assert(page[index] == 0xA7);
for (unsigned index = 80; index < 4096; ++index) assert(page[index] == 0xA7);
assert(munmap(mapped, 4096) == 0);
puts("SD auth ownership and 64 queue lifetimes passed");
}
+5
View File
@@ -263,6 +263,7 @@ class FirmwareCapabilityCheckerTest(unittest.TestCase):
(0, "internal_flash", ["in_place_delta"]),
(4, "external_qspi", ["full", "in_place_delta"]),
(48, "adaptive_internal_or_external_qspi", ["full", "in_place_delta"]),
(32, "adaptive_internal_or_external_qspi", ["full", "in_place_delta"]),
(1, "external_sd", ["full", "in_place_delta"])]:
result, manifest = self.run_checker(
b"dfu invalid in-place patch geometry", "--platform", "NRF52_PLATFORM",
@@ -279,6 +280,10 @@ class FirmwareCapabilityCheckerTest(unittest.TestCase):
self.assertIn("adaptive OTAFIX", details["bootloader"])
self.assertTrue(any("*_AUTO_DFU" in note for note in details["notes"]))
self.assertFalse(any("self-update is unavailable" in note for note in details["notes"]))
if flags == 32:
self.assertTrue(any("flash-only" in note for note in details["notes"]))
self.assertTrue(any("self-update are not compiled" in note for note in details["notes"]))
self.assertFalse(any("needs the retained-RAM" in note for note in details["notes"]))
def test_nrf52_full_companion_cannot_claim_lora_self_update(self):
result, manifest = self.run_checker(
+63
View File
@@ -9,6 +9,7 @@ from pathlib import Path
import sys
import tempfile
import unittest
from unittest import mock
from intelhex import IntelHex
@@ -73,6 +74,68 @@ def stamp(action, path, env):
class Nrf52EndfHexTests(unittest.TestCase):
def test_explicit_legacy_rak_sd_stamps_actual_flash_only_adaptive_capability(self):
with tempfile.TemporaryDirectory() as directory:
env = BuildEnvironment(ml.NRF52_APP_BASE_S140_V6)
env["CPPDEFINES"].extend(["RAK_4631", "OTA_SD_STORE", "OTA_RAK15002_SD_STORE",
"OTA_RAK_AUTO_STORE", "OTA_QSPI_STORE", "OTA_FLASH_STORE"])
env.linker = str(ROOT / "boards/nrf52840_s140_v6_sd_ota.ld")
env["LDSCRIPT_PATH"] = env.linker
load_post_script(env)
path = Path(directory) / "legacy.hex"
image = IntelHex()
image.frombytes(bytes(range(256)), offset=env.app_base)
image.write_hex_file(str(path))
with mock.patch.dict(os.environ, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}):
stamp(env.post_actions["$BUILD_DIR/${PROGNAME}.hex"], path, env)
layout = ml.parse_nrf52_layout(read_application(path))
self.assertEqual(layout, ml.Nrf52Layout(
env.app_base, ml.NRF52_APP_END, ml.NRF52_APP_END, ml.NRF52_LAYOUT_FLAG_AUTO_STORE))
self.assertTrue(layout.auto_store)
self.assertFalse(layout.hybrid_ram)
self.assertFalse(layout.external_backed)
def test_legacy_adaptive_exception_rejects_wrong_flags_maps_and_flash_geometry(self):
base_flags = ["RAK_4631", "OTA_SD_STORE", "OTA_RAK15002_SD_STORE",
"OTA_RAK_AUTO_STORE", "OTA_QSPI_STORE", "OTA_FLASH_STORE"]
cases = (
("not_explicit", {}, {}, "RAK adaptive OTA requires"),
("missing_qspi", {"remove": "OTA_QSPI_STORE"}, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "both stores"),
("missing_flash", {"remove": "OTA_FLASH_STORE"}, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "both stores"),
("seeder", {"add": ["OTA_SEEDER_ONLY"]}, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "requires a receiver"),
("wrong_active", {"active": "boards/nrf52840_s140_v6.ld"}, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "active linker"),
("wrong_configured", {"configured": "boards/nrf52840_s140_v6.ld"}, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "configured linker"),
("wrong_softdevice", {"base": ml.NRF52_APP_BASE_S140_V7}, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "exact S140 v6"),
("wrong_flash_end", {"end": ml.NRF52_EXTRAFS_START}, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "exact S140 v6"),
("hybrid_only", {"add": ["OTA_HYBRID_RAM_STORE"]}, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "enabled together"),
("hybrid_pair", {"add": ["OTA_HYBRID_RAM_STORE", "OTA_INTERNAL_BOOTLOADER_UPDATE"],
"configured": str(ROOT / "boards/nrf52840_s140_v6_mota64.ld"),
"active": str(ROOT / "boards/nrf52840_s140_v6_mota64.ld")},
{"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "without hybrid RAM"),
("unmarked_sd_role", {"remove": "OTA_RAK15002_SD_STORE"}, {"MESHCORE_NRF52_LEGACY_FLASH_OTA": "1"}, "both SD and QSPI"),
)
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "legacy.hex"
for name, changes, process_env, error in cases:
with self.subTest(case=name):
env = BuildEnvironment(changes.get("base", ml.NRF52_APP_BASE_S140_V6))
env["CPPDEFINES"].extend(base_flags)
if "remove" in changes:
env["CPPDEFINES"].remove(changes["remove"])
env["CPPDEFINES"].extend(changes.get("add", []))
env.maximum_size = changes.get("end", ml.NRF52_APP_END) - env.app_base
env.linker = changes.get("configured", str(ROOT / "boards/nrf52840_s140_v6_sd_ota.ld"))
env["LDSCRIPT_PATH"] = changes.get("active", str(ROOT / "boards/nrf52840_s140_v6_sd_ota.ld"))
load_post_script(env)
image = IntelHex()
image.frombytes(bytes(range(256)), offset=env.app_base)
image.write_hex_file(str(path))
before = path.read_bytes()
with mock.patch.dict(os.environ, process_env, clear=True):
with self.assertRaisesRegex(RuntimeError, error):
stamp(env.post_actions["$BUILD_DIR/${PROGNAME}.hex"], path, env)
self.assertEqual(path.read_bytes(), before)
def test_hex_hook_appends_valid_identity_and_preserves_other_segments(self):
for app_base in (ml.NRF52_APP_BASE_S140_V6, ml.NRF52_APP_BASE_S140_V7):
with self.subTest(app_base=hex(app_base)), tempfile.TemporaryDirectory() as tmp:
+118 -1
View File
@@ -8,6 +8,9 @@ import os
from pathlib import Path
import re
import runpy
import shutil
import subprocess
import tempfile
import unittest
from unittest import mock
@@ -44,6 +47,9 @@ class FakeEnvironment:
def __getitem__(self, key):
return self.values[key]
def get(self, key, default=None):
return self.values.get(key, default)
def BoardConfig(self):
return self.board
@@ -62,9 +68,11 @@ class FakeEnvironment:
raise AssertionError(f"unexpected fake-SCons call: {values!r}")
def run_selector(*, pioenv, board_values, project=ROOT, requested=False, legacy_flash=None):
def run_selector(*, pioenv, board_values, project=ROOT, requested=False, legacy_flash=None,
flags=(), cppdefines=()):
board = FakeBoardConfig(board_values)
environment = FakeEnvironment(project, pioenv, board)
environment.values.update(BUILD_FLAGS=flags, CPPDEFINES=cppdefines)
process_environment = (
{"MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE": "1"}
if requested else {}
@@ -136,6 +144,42 @@ def c_integer(path, name):
class Nrf52InternalBootloaderSelectorTest(unittest.TestCase):
def test_legacy_sd_receiver_reserves_auth_without_hybrid_features(self):
for flags, cppdefines in (("-D OTA_SD_STORE=1", ()),
((), [("OTA_SD_STORE", 1)]),
("-DOTA_SD_STORE -DOTA_SEEDER_ONLY -UOTA_SEEDER_ONLY", ())):
with self.subTest(flags=flags, cppdefines=cppdefines):
environment, output = run_selector(
pioenv="RAK_4631_repeater_lora_ota_no_external_sensors",
legacy_flash="1", flags=flags, cppdefines=cppdefines,
board_values={"build.mcu": "nrf52840",
"build.ldscript": "boards/nrf52840_s140_v6.ld"},
)
expected = str(ROOT / "boards/nrf52840_s140_v6_sd_ota.ld")
self.assertEqual(environment.cppdefines, [])
self.assertEqual(environment.board.updates, [("build.ldscript", expected)])
self.assertEqual(environment.replacements, {"LDSCRIPT_PATH": expected})
self.assertIn("reserved SD authorization", output)
def test_legacy_source_only_or_undefined_sd_keeps_ordinary_linker(self):
for flags in ("-DOTA_SD_STORE -DOTA_SEEDER_ONLY=1",
"-DOTA_SD_STORE -UOTA_SD_STORE",
"-DOTA_SD_STORE -DOTA_SEEDER_ONLY=0"):
with self.subTest(flags=flags):
environment, _ = run_selector(
pioenv=V6_TARGET, legacy_flash="1", flags=flags,
board_values={"build.mcu": "nrf52840",
"build.ldscript": "nrf52840_s140_v6.ld"},
)
self.assertEqual(environment.board.updates, [])
self.assertEqual(environment.replacements, {})
def test_legacy_sd_unknown_reservation_map_fails_closed(self):
with self.assertRaisesRegex(RuntimeError, "missing legacy SD authorization linker"):
run_selector(pioenv=V7_TARGET, legacy_flash="1", flags="-DOTA_SD_STORE=1",
board_values={"build.mcu": "nrf52840",
"build.ldscript": "nrf52840_s140_v7.ld"})
def test_legacy_flash_preserves_linker_for_direct_and_release_builds(self):
for requested in (False, True):
with self.subTest(requested=requested):
@@ -262,6 +306,79 @@ class Nrf52InternalBootloaderSelectorTest(unittest.TestCase):
class Nrf52HybridLinkerContractTest(unittest.TestCase):
def test_real_linker_markers_do_not_confuse_late_data_with_reservation(self):
compiler, linker, nm = (shutil.which(name) for name in ("cc", "ld", "nm"))
if not all((compiler, linker, nm)):
self.skipTest("native cc, ld and nm are required for linker fixtures")
# A large framework registration prefix moves .data beyond the auth
# ABI even in an unreserved map. Only the explicit region markers can
# distinguish it from a genuinely reserved receiver layout.
assembly = """
.section .persistent_magic,"aw"
.long 0
.section .persistent_data,"aw"
.long 0
.section .svc_data,"aw"
.space 128
.section .fs_data,"aw"
.space 32
.section .data,"aw"
.long 0x3f800000
"""
common = """
SECTIONS {
.data : { __data_start__ = .; *(.data) } > RAM
}
"""
with tempfile.TemporaryDirectory(prefix="meshcore-sd-auth-linker-") as directory:
work = Path(directory)
(work / "fixture.S").write_text(assembly, encoding="ascii")
(work / "nrf52_common.ld").write_text(common, encoding="ascii")
subprocess.run([compiler, "-c", str(work / "fixture.S"), "-o", str(work / "fixture.o")],
check=True, capture_output=True, text=True, timeout=30)
for suffix in ("v6", "v7", "v6_sd_ota", "v6_mota64", "v7_mota64"):
with self.subTest(suffix=suffix):
source = (ROOT / "boards" / f"nrf52840_s140_{suffix}.ld").read_text()
# Native tools suffice: omit only the ARM C library group;
# every production MEMORY/SECTIONS/ASSERT is kept verbatim.
source = re.sub(r"^GROUP\([^\n]*\)\s*$", "", source, flags=re.M)
(work / "fixture.ld").write_text(source, encoding="ascii")
linked = subprocess.run([linker, "-T", "fixture.ld", "fixture.o", "-o", "fixture.elf"],
cwd=work, text=True, capture_output=True, timeout=30)
self.assertEqual(linked.returncode, 0, linked.stdout + linked.stderr)
output = subprocess.check_output([nm, str(work / "fixture.elf")], text=True)
symbols = {name: int(value, 16) for value, _kind, name in
(line.split() for line in output.splitlines())}
self.assertGreaterEqual(symbols["__data_start__"], 0x20006050)
if suffix in ("v6", "v7"):
self.assertNotIn("__mota_sd_auth_start__", symbols)
self.assertNotIn("__mota_sd_auth_end__", symbols)
else:
self.assertEqual(symbols["__mota_sd_auth_start__"], 0x20006008)
self.assertEqual(symbols["__mota_sd_auth_end__"], 0x20006050)
def test_sd_authorization_has_explicit_reserved_boundaries(self):
for suffix in ("v6_sd_ota", "v6_mota64", "v7_mota64"):
with self.subTest(suffix=suffix):
path = ROOT / "boards" / f"nrf52840_s140_{suffix}.ld"
regions = memory_regions(path)
source = path.read_text(encoding="utf-8")
self.assertEqual(regions["RAM"][0], 0x20006050)
self.assertEqual(regions["PERSISTENT_RAM"], (0x20006000, 80))
self.assertIn("__mota_sd_auth_start__ = ", source)
self.assertIn("__mota_sd_auth_end__ = ", source)
self.assertIn("ASSERT(ORIGIN(RAM) >= __mota_sd_auth_end__,", source)
if suffix == "v6_sd_ota":
self.assertEqual(regions["RAM"][1], 0x20040000 - 0x20006050)
self.assertIn("ASSERT(__mota_sd_auth_start__ == 0x20006008,", source)
self.assertIn("ASSERT(__mota_sd_auth_end__ == 0x20006050,", source)
else:
self.assertIn("__mota_sd_auth_start__ = __mota_hybrid_handoff_start__;", source)
self.assertIn("__mota_sd_auth_end__ = __mota_hybrid_handoff_end__;", source)
for version in ("v6", "v7"):
source = (ROOT / "boards" / f"nrf52840_s140_{version}.ld").read_text()
self.assertNotIn("__mota_sd_auth_", source)
def test_v6_and_v7_maps_match_runtime_handoff_abi(self):
handoff = ROOT / "src" / "helpers" / "ota" / "OtaHybridHandoff.h"
layout = ROOT / "src" / "helpers" / "ota" / "OtaFlashLayout_nrf52.h"
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Execute the production SD auth helpers and constructor with RAM canaries."""
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "src/helpers/ota/OtaStoreSdNrf52.cpp"
class OtaSdAuthOwnershipTest(unittest.TestCase):
def test_source_and_receiver_ownership_and_queue_lifetimes(self):
if not sys.platform.startswith("linux"):
self.skipTest("fixed-address RAM canaries require Linux mmap")
compiler = shutil.which("g++") or shutil.which("clang++")
if compiler is None:
self.skipTest("a host C++ compiler is required")
source = SOURCE.read_text(encoding="utf-8")
helpers = source.split("namespace {", 1)[1].split("} // namespace", 1)[0]
constructor = extract_braced(source, "OtaStoreSdNrf52::OtaStoreSdNrf52()")
cases = (
("source_unreserved", True, None, None, False),
("source_reserved", True, 0x20006008, 0x20006050, False),
("receiver_unreserved", False, None, None, False),
("receiver_wrong_start", False, 0x20006000, 0x20006050, False),
("receiver_wrong_end", False, 0x20006008, 0x20006058, False),
("receiver_reserved", False, 0x20006008, 0x20006050, True),
)
with tempfile.TemporaryDirectory(prefix="meshcore-sd-auth-owner-") as directory:
work = Path(directory)
generated = work / "production_auth.inc"
generated.write_text(helpers + "\n" + constructor + "\n", encoding="ascii")
for name, seeder, start, end, owns in cases:
with self.subTest(case=name):
binary = work / name
flags = ["-DOTA_RAK15002_SD_STORE=1", "-DEXPECT_OWNS=" + str(int(owns))]
if seeder:
flags += ["-DOTA_SEEDER_ONLY=1"]
if start is not None:
flags += [f"-DTEST_AUTH_START={start}", f"-DTEST_AUTH_END={end}"]
built = subprocess.run([
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie", "-g", *flags,
"-I", str(ROOT / "src"), "-I", str(work),
str(ROOT / "test/fixtures/ota_sd_auth_ownership.cpp"),
"-o", str(binary),
], text=True, capture_output=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
checked = subprocess.run([str(binary)], text=True, capture_output=True, timeout=10)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("SD auth ownership and 64 queue lifetimes passed", checked.stdout)
if __name__ == "__main__":
unittest.main()