From 5ee9ceeb200b28062ac5aebee8dcf96f9b97a335 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 17 Oct 2026 15:35:34 -0700 Subject: [PATCH] Protect nRF SD OTA authorization RAM from source-only stores --- .github/workflows/run-unit-tests.yml | 3 + boards/nrf52840_s140_v6_mota64.ld | 4 + boards/nrf52840_s140_v6_sd_ota.ld | 11 ++ boards/nrf52840_s140_v7_mota64.ld | 4 + scripts/check_firmware_capabilities.py | 6 +- .../check_nrf52_companion_ota_workspace.py | 115 +++++++++++++++++ scripts/nrf52_internal_bootloader_link.py | 24 +++- src/helpers/ota/OtaStoreSdNrf52.cpp | 22 +++- test/fixtures/ota_sd_auth_ownership.cpp | 91 ++++++++++++++ test/test_firmware_capabilities.py | 5 + test/test_nrf52_endf_hex.py | 63 ++++++++++ test/test_nrf52_internal_bootloader_link.py | 119 +++++++++++++++++- test/test_ota_sd_auth_ownership.py | 63 ++++++++++ tools/mota/pio_endf.py | 35 +++++- 14 files changed, 556 insertions(+), 9 deletions(-) create mode 100644 scripts/check_nrf52_companion_ota_workspace.py create mode 100644 test/fixtures/ota_sd_auth_ownership.cpp create mode 100644 test/test_ota_sd_auth_ownership.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 03758688f..4dd9c7ec9 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -26,6 +26,7 @@ jobs: run: | python3 -m pip install unicorn==2.1.4 python3 -B scripts/check_nrf52_companion_crypto.py .pio/build/RAK_4631_companion_radio_full/firmware.elf + python3 -B scripts/check_nrf52_companion_ota_workspace.py .pio/build/RAK_4631_companion_radio_full/firmware.elf - name: Qualify direct-OTA-only XIAO Full Companion # This configuration has no Meshtext/Ethernet application callbacks. @@ -786,6 +787,8 @@ jobs: python3 -B test/test_ethernet_terminal_shutdown.py -v python3 -B test/test_rak_sd_receiver.py -v python3 -B test/test_rak15002_shared_spi.py -v + python3 -B test/test_ota_sd_auth_ownership.py -v + python3 -B test/test_nrf52_internal_bootloader_link.py -v python3 -B test/test_rak_nor_gpio_reservations.py -v python3 -B test/test_rak_companion_ethernet_worker.py -v python3 -B test/test_rak_sd_context_handoff.py -v diff --git a/boards/nrf52840_s140_v6_mota64.ld b/boards/nrf52840_s140_v6_mota64.ld index a77daf7ff..059940b14 100644 --- a/boards/nrf52840_s140_v6_mota64.ld +++ b/boards/nrf52840_s140_v6_mota64.ld @@ -30,6 +30,8 @@ SECTIONS /* Export the direct-access handoff ABI even though no input section owns it. */ __mota_hybrid_handoff_start__ = ADDR(.persistent) + SIZEOF(.persistent); __mota_hybrid_handoff_end__ = ORIGIN(PERSISTENT_RAM) + LENGTH(PERSISTENT_RAM); + __mota_sd_auth_start__ = __mota_hybrid_handoff_start__; + __mota_sd_auth_end__ = __mota_hybrid_handoff_end__; ASSERT(ADDR(.persistent) == 0x20006000, "mOTA retained clock prefix start must remain fixed") @@ -43,6 +45,8 @@ SECTIONS "mOTA hybrid handoff must remain exactly 72 bytes") ASSERT(ORIGIN(RAM) == __mota_hybrid_handoff_end__, "normal RAM must start immediately after the mOTA handoff") + ASSERT(ORIGIN(RAM) >= __mota_sd_auth_end__, + "application RAM must not overlap SD OTA authorization") . = ALIGN(4); .svc_data : diff --git a/boards/nrf52840_s140_v6_sd_ota.ld b/boards/nrf52840_s140_v6_sd_ota.ld index 2880ed278..d1f942451 100644 --- a/boards/nrf52840_s140_v6_sd_ota.ld +++ b/boards/nrf52840_s140_v6_sd_ota.ld @@ -24,8 +24,19 @@ SECTIONS . = ALIGN(4); } > PERSISTENT_RAM + __mota_sd_auth_start__ = ADDR(.persistent) + SIZEOF(.persistent); + __mota_sd_auth_end__ = ORIGIN(PERSISTENT_RAM) + LENGTH(PERSISTENT_RAM); + + ASSERT(ADDR(.persistent) == 0x20006000, + "SD OTA retained clock prefix start must remain fixed") ASSERT(SIZEOF(.persistent) == 8, "MeshTower SD OTA retained clock prefix must remain exactly 8 bytes") + ASSERT(__mota_sd_auth_start__ == 0x20006008, + "SD OTA authorization start must remain fixed") + ASSERT(__mota_sd_auth_end__ == 0x20006050, + "SD OTA authorization end must remain fixed") + ASSERT(ORIGIN(RAM) >= __mota_sd_auth_end__, + "application RAM must not overlap SD OTA authorization") . = ALIGN(4); .svc_data : diff --git a/boards/nrf52840_s140_v7_mota64.ld b/boards/nrf52840_s140_v7_mota64.ld index 2c95b47e5..b7a88aae6 100644 --- a/boards/nrf52840_s140_v7_mota64.ld +++ b/boards/nrf52840_s140_v7_mota64.ld @@ -30,6 +30,8 @@ SECTIONS /* Export the direct-access handoff ABI even though no input section owns it. */ __mota_hybrid_handoff_start__ = ADDR(.persistent) + SIZEOF(.persistent); __mota_hybrid_handoff_end__ = ORIGIN(PERSISTENT_RAM) + LENGTH(PERSISTENT_RAM); + __mota_sd_auth_start__ = __mota_hybrid_handoff_start__; + __mota_sd_auth_end__ = __mota_hybrid_handoff_end__; ASSERT(ADDR(.persistent) == 0x20006000, "mOTA retained clock prefix start must remain fixed") @@ -43,6 +45,8 @@ SECTIONS "mOTA hybrid handoff must remain exactly 72 bytes") ASSERT(ORIGIN(RAM) == __mota_hybrid_handoff_end__, "normal RAM must start immediately after the mOTA handoff") + ASSERT(ORIGIN(RAM) >= __mota_sd_auth_end__, + "application RAM must not overlap SD OTA authorization") . = ALIGN(4); .svc_data : diff --git a/scripts/check_firmware_capabilities.py b/scripts/check_firmware_capabilities.py index 4c10f3a6e..e532e27e8 100644 --- a/scripts/check_firmware_capabilities.py +++ b/scripts/check_firmware_capabilities.py @@ -40,7 +40,11 @@ def nrf52_lora_details(application): "Artifact inspection verifies compiled support; it does not verify the bootloader installed on a physical device."] if layout.auto_store: notes.append("One application detects RAK external NOR and chooses QSPI only with the exact matching OTAFIX bootloader; otherwise it uses internal flash where safe.") - notes.append("External QSPI accepts full images and in-place deltas; internal flash accepts in-place deltas only and needs the retained-RAM OTAFIX profile.") + if layout.hybrid_ram: + notes.append("External QSPI accepts full images and in-place deltas; internal flash accepts in-place deltas only and needs the retained-RAM OTAFIX profile.") + else: + notes.append("External QSPI accepts full images and in-place deltas; this legacy application's internal staging is flash-only and accepts in-place deltas only.") + notes.append("Retained-RAM staging and bootloader self-update are not compiled into this legacy application.") notes.append("OTAFIX 2.4.11 provides normal RAK3401/RAK4631 *_auto bootloaders with standard board identities and internal signed bootloader-update staging. Historical *_AUTO_DFU or dedicated external identities need the documented local identity-matching migration, not a cross-profile LoRa update.") elif layout.hybrid_ram: notes.append("Requires the exact-board retained-RAM handoff bootloader profile; a transfer cannot resume after the receiver restarts.") diff --git a/scripts/check_nrf52_companion_ota_workspace.py b/scripts/check_nrf52_companion_ota_workspace.py new file mode 100644 index 000000000..d3309e666 --- /dev/null +++ b/scripts/check_nrf52_companion_ota_workspace.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Check linked ARM Companion OTA queue ownership without touching hardware. + +Requires unicorn==2.1.4. Executes the actual acquire/release callbacks starting +from the ELF's initialized globals and empty queue. Watch the fixed SD handoff +RAM through repeated construction/destruction: source-only archive stores must +never write it, even though ordinary linkers put application globals there. +This checks memory ownership, not radio/USB/SD hardware behavior. +""" + +import argparse +import hashlib +import json +import struct + +from firmware_elf import FirmwareElf +from unicorn import (Uc, UcError, UC_ARCH_ARM, UC_MODE_THUMB, UC_MODE_MCLASS, + UC_HOOK_MEM_WRITE) +from unicorn import arm_const as arm + + +AUTH_START, AUTH_END = 0x20006008, 0x20006050 + + +def check(path): + elf = FirmwareElf(path) + if elf.unpack("H", 18)[0] != 40: + raise ValueError("expected an ARM firmware ELF") + + def callback(fragment): + matches = [name for name in elf.symbols if fragment in name] + if len(matches) != 1: + raise ValueError(f"expected one linked {fragment} callback, found {len(matches)}") + return matches[0] + + acquire = callback("acquireOfflineQueueForOta") + release = callback("releaseOfflineQueueFromOta") + owner = elf.address("the_mesh") + machine = Uc(UC_ARCH_ARM, UC_MODE_THUMB | UC_MODE_MCLASS) + machine.mem_map(0, 0x100000) + machine.mem_map(0x20000000, 0x40000) + machine.mem_map(0xE000E000, 0x1000) + for section in elf.sections: + if section[1] != 8 and section[2] & 2 and section[5]: + machine.mem_write(section[3], elf.section_bytes(section)) + # Full Companion callbacks execute softfp instructions while constructing + # the real OtaContext. Enable Cortex-M4F CP10/CP11 just as startup does. + machine.mem_write(0xE000ED88, struct.pack(" AUTH_START: + violations.append((uc.reg_read(arm.UC_ARM_REG_PC), address, size)) + uc.emu_stop() + + machine.hook_add(UC_HOOK_MEM_WRITE, write_hook) + + def call(name): + machine.reg_write(arm.UC_ARM_REG_SP, stack_top) + machine.reg_write(arm.UC_ARM_REG_LR, stop | 1) + machine.reg_write(arm.UC_ARM_REG_R0, owner) + try: + machine.emu_start(elf.address(name) | 1, stop, timeout=10000000, count=2000000) + except UcError as error: + raise ValueError(f"{name}: ARM execution failed: {error}") from error + if violations: + pc, address, size = violations[0] + raise ValueError(f"{name}: write to SD auth/application RAM at {address:#x} " + f"({size} bytes), instruction {pc:#x}") + if machine.reg_read(arm.UC_ARM_REG_PC) != stop: + raise ValueError(name + " did not return within the instruction/time limit") + if machine.reg_read(arm.UC_ARM_REG_SP) != stack_top: + raise ValueError(name + " did not restore the stack") + if bytes(machine.mem_read(AUTH_START, AUTH_END - AUTH_START)) != before: + raise ValueError(name + " changed SD auth/application globals") + return machine.reg_read(arm.UC_ARM_REG_R0) + + workspace = None + for _cycle in range(4): + pointer = call(acquire) + if not 0x20006050 <= pointer < stack_top: + raise ValueError(f"OTA workspace pointer outside application RAM: {pointer:#x}") + if workspace is not None and workspace != pointer: + raise ValueError("shared OTA workspace address changed across release/acquire") + workspace = pointer + if call(acquire) != workspace: + raise ValueError("already-acquired OTA workspace is not idempotent") + call(release) + call(release) + return { + "passed": True, + "elf_sha256": hashlib.sha256(elf.data).hexdigest(), + "queue_lifetimes": 4, + "workspace_address": hex(workspace), + "protected_sd_auth_range": [hex(AUTH_START), hex(AUTH_END)], + "scope": "Linked ARM empty-queue OTA acquisition/release; no hardware qualification.", + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("elf") + args = parser.parse_args() + try: + print(json.dumps(check(args.elf), indent=2)) + except (ValueError, OSError) as error: + parser.exit(1, f"FAILED: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/nrf52_internal_bootloader_link.py b/scripts/nrf52_internal_bootloader_link.py index 4c3bfc8f5..2d5381ec2 100644 --- a/scripts/nrf52_internal_bootloader_link.py +++ b/scripts/nrf52_internal_bootloader_link.py @@ -9,6 +9,7 @@ closed if a target does not use the normal S140 v6/v7 1 MiB application layout. Import("env") +import importlib.util import os from pathlib import Path @@ -65,7 +66,28 @@ if requested: f"linker={current or 'none'}" ) if legacy_flash == "1": - print("nRF52 legacy flash OTA: ordinary linker; no hybrid RAM or bootloader self-update") + # Unified RAK receivers can select SD at runtime even in legacy flash + # mode. Reserve its 72-byte authorization ABI without a hybrid arena. + policy_path = Path(env["PROJECT_DIR"]) / "scripts/nrf52_flash_trim.py" + spec = importlib.util.spec_from_file_location("nrf52_legacy_sd_flags", policy_path) + policy = importlib.util.module_from_spec(spec) + spec.loader.exec_module(policy) + definitions = policy.flag_defines(env.get("BUILD_FLAGS", ()), + env.get("CPPDEFINES", ())) + sd_receiver = (definitions.get("OTA_SD_STORE") is not None and + definitions.get("OTA_SEEDER_ONLY") is None) + if sd_receiver: + sd_linker = Path(env["PROJECT_DIR"]) / "boards" / ( + "nrf52840_s140_%s_sd_ota.ld" % suffix + ) + if not sd_linker.is_file(): + raise RuntimeError("missing legacy SD authorization linker: %s" % sd_linker) + board.update("build.ldscript", str(sd_linker)) + env.Replace(LDSCRIPT_PATH=str(sd_linker)) + print("nRF52 legacy flash OTA: %s; reserved SD authorization; no hybrid RAM or bootloader self-update" + % sd_linker.name) + else: + print("nRF52 legacy flash OTA: ordinary linker; no hybrid RAM or bootloader self-update") else: mota_linker = Path(env["PROJECT_DIR"]) / "boards" / ( "nrf52840_s140_%s_mota64.ld" % suffix diff --git a/src/helpers/ota/OtaStoreSdNrf52.cpp b/src/helpers/ota/OtaStoreSdNrf52.cpp index aae4092e2..cf9de6714 100644 --- a/src/helpers/ota/OtaStoreSdNrf52.cpp +++ b/src/helpers/ota/OtaStoreSdNrf52.cpp @@ -35,16 +35,35 @@ namespace ota { namespace { +#if !defined(OTA_SEEDER_ONLY) +// Only qualified receiver linkers reserve this fixed bootloader ABI. Ordinary +// Companion maps place initialized application globals at the same address. +// Weak, absent markers therefore mean no ownership, not permission to write. +extern "C" { +extern uint8_t __mota_sd_auth_start__[] __attribute__((weak)); +extern uint8_t __mota_sd_auth_end__[] __attribute__((weak)); +} + +static bool sd_auth_record_reserved() { + return reinterpret_cast(__mota_sd_auth_start__) == MOTA_SD_AUTH_ADDR && + reinterpret_cast(__mota_sd_auth_end__) == MOTA_SD_AUTH_ADDR + MOTA_SD_AUTH_LEN; +} +#endif + static void clear_sd_auth_record() { +#if !defined(OTA_SEEDER_ONLY) + if (!sd_auth_record_reserved()) return; volatile uint8_t* const retained = reinterpret_cast(MOTA_SD_AUTH_ADDR); for (uint32_t i = 0; i < MOTA_SD_AUTH_LEN; ++i) retained[i] = 0; __DMB(); __DSB(); +#endif } #if !defined(OTA_SEEDER_ONLY) static bool publish_sd_auth_record(const uint8_t record[MOTA_SD_AUTH_LEN]) { + if (!sd_auth_record_reserved()) return false; volatile uint8_t* const retained = reinterpret_cast(MOTA_SD_AUTH_ADDR); // Invalidate magic first, then publish the body and magic last. A reset at any @@ -74,7 +93,8 @@ OtaStoreSdNrf52::OtaStoreSdNrf52() { // If an earlier reset did not enter OTAFIX, stale authorization must not arm a // later unrelated reboot. OTAFIX runs before application constructors and - // consumes a valid record on the intended apply boot. + // consumes a valid record on the intended apply boot. Source-only stores + // never own that record, and receivers require a linker-reserved region. clear_sd_auth_record(); } diff --git a/test/fixtures/ota_sd_auth_ownership.cpp b/test/fixtures/ota_sd_auth_ownership.cpp new file mode 100644 index 000000000..865a40be8 --- /dev/null +++ b/test/fixtures/ota_sd_auth_ownership.cpp @@ -0,0 +1,91 @@ +#include +#include +#include +#include +#include +#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(0x20006000), 4096, + PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, -1, 0); + assert(mapped == reinterpret_cast(0x20006000)); + uint8_t* const page = static_cast(mapped); + uint8_t before[4096]; + memset(page, 0xA7, 4096); + memcpy(before, page, sizeof(before)); + mesh::BorrowableFrameBuffer queue; + int head = 39; + for (size_t index = 0; index < queue.capacity(); ++index) + memset(queue.at(index).bytes, static_cast(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(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(index + 1); + memcpy(before, page, sizeof(before)); + assert(mesh::ota::publish_sd_auth_record(record) == static_cast(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"); +} diff --git a/test/test_firmware_capabilities.py b/test/test_firmware_capabilities.py index 912ad88b8..d8d22ed57 100644 --- a/test/test_firmware_capabilities.py +++ b/test/test_firmware_capabilities.py @@ -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( diff --git a/test/test_nrf52_endf_hex.py b/test/test_nrf52_endf_hex.py index 63c304a4c..78b4c9ced 100644 --- a/test/test_nrf52_endf_hex.py +++ b/test/test_nrf52_endf_hex.py @@ -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: diff --git a/test/test_nrf52_internal_bootloader_link.py b/test/test_nrf52_internal_bootloader_link.py index 2224b18c9..05f79b5d3 100644 --- a/test/test_nrf52_internal_bootloader_link.py +++ b/test/test_nrf52_internal_bootloader_link.py @@ -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" diff --git a/test/test_ota_sd_auth_ownership.py b/test/test_ota_sd_auth_ownership.py new file mode 100644 index 000000000..d284e5ee7 --- /dev/null +++ b/test/test_ota_sd_auth_ownership.py @@ -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() diff --git a/tools/mota/pio_endf.py b/tools/mota/pio_endf.py index 8934ebca7..6d2302227 100644 --- a/tools/mota/pio_endf.py +++ b/tools/mota/pio_endf.py @@ -154,6 +154,21 @@ def _board_maximum_size(build_env): return None +def _validate_nrf52_legacy_sd_profile(app_start, linked_app_end, active_linker, + configured_linker, project_dir): + """Legacy adaptive SD still needs its exact retained-authorization map.""" + if app_start != ml.NRF52_APP_BASE_S140_V6 or linked_app_end != ml.NRF52_APP_END: + raise RuntimeError("legacy RAK SD OTA requires the exact S140 v6 0xED000 profile") + expected = _normalized_linker_path( + os.path.join(str(project_dir), "boards", "nrf52840_s140_v6_sd_ota.ld"), project_dir) + if not os.path.isfile(expected): + raise RuntimeError("legacy RAK SD authorization linker is missing") + for label, value in (("active", active_linker), ("configured", configured_linker)): + if _normalized_linker_path(value, project_dir) != expected: + raise RuntimeError(f"legacy RAK SD OTA requires {expected}; " + f"{label} linker is {value or 'none'}") + + def _source_filter_text(): """Resolved source-filter text for detecting code that is actually part of this environment.""" srcf = "" @@ -276,8 +291,9 @@ def _append_endf_hex(source, target, env): # Intel-HEX path (nRF52: app f if optional_rak_sd and (not sd_enabled or _cppdef("RAK_4631") is None or not (auto_store or (seeder_only and _builds_companion_radio()))): raise RuntimeError("optional RAK15002 SD requires an exact RAK4631 adaptive receiver or source-only Companion") - # Optional SD does not change the frozen application layout: receivers - # retain AUTO+HYBRID internal staging, and Full Companion retains ExtraFS. + # Optional SD does not change the frozen application flash layout: normal + # receivers retain AUTO+HYBRID, explicit legacy receivers use AUTO without + # hybrid RAM, and Full Companion retains ExtraFS. # The runtime selects SD only after validating its separate loader ABI. sd_backed = sd_enabled and not optional_rak_sd qspi_bootloader_update = _cppdef("OTA_QSPI_BOOTLOADER_UPDATE") is not None @@ -291,9 +307,18 @@ def _append_endf_hex(source, target, env): # Intel-HEX path (nRF52: app f env["PROJECT_DIR"]) if sd_backed and qspi_backed: raise RuntimeError("nRF52 build cannot enable both SD and QSPI OTA stores") - if auto_store and (not qspi_backed or _cppdef("OTA_FLASH_STORE") is None or - not hybrid_ram or not internal_bootloader_update): - raise RuntimeError("RAK adaptive OTA requires both stores and retained-RAM internal support") + if auto_store: + if not qspi_backed or _cppdef("OTA_FLASH_STORE") is None: + raise RuntimeError("RAK adaptive OTA requires both stores and retained-RAM internal support") + if os.environ.get("MESHCORE_NRF52_LEGACY_FLASH_OTA") == "1": + if (not optional_rak_sd or seeder_only or _builds_companion_radio() or + hybrid_ram or internal_bootloader_update): + raise RuntimeError("legacy adaptive SD OTA requires a receiver without hybrid RAM or bootloader self-update") + _validate_nrf52_legacy_sd_profile( + app_start, linked_app_end, env.get("LDSCRIPT_PATH", ""), + board.get("build.ldscript", ""), env["PROJECT_DIR"]) + elif not hybrid_ram or not internal_bootloader_update: + raise RuntimeError("RAK adaptive OTA requires both stores and retained-RAM internal support") if qspi_backed and _cppdef("QSPIFLASH") is not None: raise RuntimeError("raw QSPI OTA staging cannot share a chip with QSPIFLASH") if qspi_bootloader_update: