Revert adaptive RAK app-owned bootloader recovery after ACL test

This commit is contained in:
mikecarper committed 2026-09-27 15:07:29 -07:00
1 parent a007346561
commit ea40663f09
17 files changed
+49 -521

No files matched your search

+6 -12
View File
@@ -149,7 +149,7 @@ MIN_MESHCLI_VERSION = (1, 6, 0)
# Tested format-3 inspect/verify/serve support. Keep automatic repairs pinned;
# neither a package nor a moving branch may choose code to install on the host.
MOTATOOL_REPAIR_REPOSITORY = "https://github.com/mikecarper/motatool.git"
MOTATOOL_REPAIR_REVISION = "beff192aec341077f6d9d4b4938e47323775b9eb"
MOTATOOL_REPAIR_REVISION = "8c38369e7d35ad50cf74261869676d52dd24adf7"
MOTATOOL_REPAIR_TIMEOUT_SECONDS = 60 * 60
# The pinned lockfile, not motatool's older package-level rust-version: zeroize
# needs edition 2024 (1.85), and idna_adapter/ICU 2.2 require Rust 1.86.
@@ -1085,15 +1085,11 @@ def compatible_mota(info: MotaInfo, target: TargetInfo) -> tuple[bool, str]:
f"bootloader target {info.target_id:08X} hw={info.hw_id}, need "
f"{target.boot_target_id:08X} hw={target.boot_hw_id}"
)
recovery = (target.boot_storage == 0x16 and
info.bootloader_storage == 0x16)
if target.bootloader_abi is None or target.bootloader_abi < (2 if recovery else 3):
return False, "bootloader install requires a capable installed OTAFIX profile"
if target.bootloader_abi is None or target.bootloader_abi < 3:
return False, "bootloader self-update requires installed ABI 3 or newer"
if target.bootloader_codecs != 0x5:
return False, "bootloader self-update requires FULL|INPLACE codecs 0x5"
expected_storage = (0x16 if recovery else
0x09 if target.nrf_sd else
0x0E if target.nrf_qspi else 0x0A)
expected_storage = 0x09 if target.nrf_sd else 0x0E if target.nrf_qspi else 0x0A
if (
target.boot_storage != expected_storage
or info.bootloader_storage != expected_storage
@@ -2769,7 +2765,7 @@ def query_bootloader_target(
if match is None:
raise OtaError(
f"destination cannot stage a bootloader update: {reply}; "
"requires an exact-board update or adaptive RAK recovery application"
"requires exact-board ABI-3 self-update bootloader and capable application"
)
board, target_id, name, _crc, abi, caps = match.groups()
library = bootloader_library()
@@ -2783,9 +2779,7 @@ def query_bootloader_target(
raise OtaError(f"invalid destination bootloader identity: {exc}") from exc
if derived_target != int(target_id, 16):
raise OtaError("destination bootloader board/name does not match its target ID")
recovery = int(caps, 16) == 0x16 and (int(board, 16), name) in \
library.RAK_AUTO_RECOVERY_IDENTITIES
if int(abi) < (2 if recovery else 3) or int(abi) != target.bootloader_abi:
if int(abi) < 3 or int(abi) != target.bootloader_abi:
raise OtaError("destination reports inconsistent or unsupported bootloader ABI")
return replace(
target, boot_target_id=derived_target, boot_hw_id=hw_id,
+4 -28
View File
@@ -21,7 +21,7 @@ import lora_ota as ota
from test_lora_ota import firmware, mota_blob, prepare_args, target, VERSION_NEW
def boot_blob(*, storage=0x0A, version=0x0117010D, rak_name="3401_AUTO_DFU"):
def boot_blob(*, storage=0x0A, version=0x0117010D):
"""Build a signed test-only candidate using the canonical reference builder."""
ml = ota.bootloader_library()
board, name, app_base = 0x239A0029, "3401_DFU", ml.NRF52_APP_BASE_S140_V6
@@ -30,12 +30,9 @@ def boot_blob(*, storage=0x0A, version=0x0117010D, rak_name="3401_AUTO_DFU"):
name = ml.XIAO_BOOT_DEVICE_NAME.rstrip(b"\0").decode("ascii")
elif storage == 0x09:
board, name = 0x239A0071, "TOWER_V2_OTA"
elif storage == 0x16:
board, name = 0x239A0029, rak_name
image = bytearray(b"\xff" * ml.XIAO_BOOT_IMAGE_SIZE)
struct.pack_into("<II", image, 0, 0x20040000, ml.XIAO_BOOT_IMAGE_START + 0x101)
struct.pack_into("<8sHHB3x", image, 0x80, ml.XIAO_BOOT_CAPS_MAGIC,
2 if storage == 0x16 else 3, 5, storage)
struct.pack_into("<8sHHB3x", image, 0x80, ml.XIAO_BOOT_CAPS_MAGIC, 3, 5, storage)
offset = ml.BOOT_CANDIDATE_MANIFEST_OFFSET
struct.pack_into(
"<8sHHIII16sI", image, offset, ml.XIAO_BOOT_MANIFEST_MAGIC,
@@ -62,8 +59,7 @@ def boot_blob(*, storage=0x0A, version=0x0117010D, rak_name="3401_AUTO_DFU"):
def boot_target(package):
return replace(
target(platform="nrf52", boot_codecs=5),
bootloader_abi=2 if package.bootloader_storage == 0x16 else 3,
target(platform="nrf52", boot_codecs=5), bootloader_abi=3,
boot_target_id=package.target_id, boot_hw_id=package.hw_id,
boot_storage=package.bootloader_storage,
nrf_sd=package.bootloader_storage == 9,
@@ -86,31 +82,11 @@ class BootloaderStagingTests(unittest.TestCase):
self.assertEqual(package.payload_size, 40960)
def test_all_supported_storage_profiles(self):
for storage in (10, 14, 9, 22):
for storage in (10, 14, 9):
with self.subTest(storage=storage):
package = ota.parse_mota(boot_blob(storage=storage))
self.assertEqual(ota.compatible_mota(package, boot_target(package)), (True, ""))
def test_adaptive_rak_recovery_accepts_internal_and_nor_storage(self):
for name in ("3401_AUTO_DFU", "4631_AUTO_DFU"):
package = ota.parse_mota(boot_blob(storage=0x16, rak_name=name))
for external in (False, True):
live = replace(boot_target(package), nrf_qspi=external)
self.assertEqual(ota.compatible_mota(package, live), (True, ""))
live = replace(boot_target(package), bootloader_abi=1)
self.assertFalse(ota.compatible_mota(package, live)[0])
def test_adaptive_rak_bootloader_status_uses_app_owned_recovery(self):
controller = mock.Mock()
controller.remote_command.return_value = (
"BL board=239A0029 target=D04AB3AB name=3401_AUTO_DFU "
"crc=12345678 abi=2 caps=16 | staged:none mid=- hash=-"
)
live = replace(target(platform="nrf52", boot_codecs=5), bootloader_abi=2)
result = ota.query_bootloader_target(controller, live)
self.assertEqual(result.boot_target_id, 0xD04AB3AB)
self.assertEqual(result.boot_storage, 0x16)
def test_malformed_bootloaders_fail_closed(self):
for offset in (9, 10, 11, 27, 28, 105, 137, 201, 205, 600):
with self.subTest(offset=offset):
+4 -7
View File
@@ -63,11 +63,9 @@ targeting a deployed receiver that predates the extended transport descriptor.
Passing `bootloader=True` is a deliberately narrow reference-only path: it
requires a nonzero version, a signed exact 40 KiB OTAFIX region, 1024-byte
blocks, full codec, zero base hash, a sane vector table, an exact embedded
manifest/CRC identity, and a marker retaining both application codecs
(`FULL|INPLACE`, mask `0x0005`). Self-update profiles require ABI 3 and the
exact boot-update storage capability. The two adaptive RAK recovery profiles
use ABI 2 and exact `0x16` application capabilities; their MeshCore
application verifies and copies the image through the MBR. Deployed XIAO packages retain their
manifest/CRC identity, and an ABI-3 marker retaining both application codecs
(`FULL|INPLACE`, mask `0x0005`), the selected storage, and boot-update
capabilities. Deployed XIAO packages retain their
`XIAO_BL_28860044`/`XIAO_BL_28860045` IDs. Generic packages derive the padded
`NRF_BL_<BOARD_ID>_<DEVICE_NAME>` ID and collision-checked wire target from the
full embedded manifest pair. Parsing and `verify()` repeat those gates; magic
@@ -78,8 +76,7 @@ builder accepts only identities in the qualified inventory. That inventory
currently covers the shared-internal nRF52840 targets, the two deployed XIAO
raw-QSPI identities, and the exact MeshTower V2 identity whose candidate
marker may select either its internal (`0x0A`) or microSD (`0x09`) application
layout, plus the two exact adaptive RAK identities (`3401_AUTO_DFU` and
`4631_AUTO_DFU`). The test suite verifies those boot target IDs are unique and disjoint
layout. The test suite verifies those boot target IDs are unique and disjoint
from the generated application target table.
This library does not authorize a device update. A capable node will only arm
+8 -28
View File
@@ -108,15 +108,11 @@ BOOT_STORAGE_SD = 0x01
BOOT_STORAGE_STAGE_CEILING = 0x02
XIAO_BOOT_STORAGE_QSPI = 0x04
XIAO_BOOT_STORAGE_UPDATE = 0x08
BOOT_STORAGE_KNOWN = 0x1F
BOOT_STORAGE_HEADER_W25 = 0x10
BOOT_STORAGE_KNOWN = 0x0F
BOOT_STORAGE_SD_UPDATE = BOOT_STORAGE_SD | XIAO_BOOT_STORAGE_UPDATE
BOOT_STORAGE_QSPI_UPDATE = (BOOT_STORAGE_STAGE_CEILING | XIAO_BOOT_STORAGE_QSPI |
XIAO_BOOT_STORAGE_UPDATE)
BOOT_STORAGE_INTERNAL_UPDATE = BOOT_STORAGE_STAGE_CEILING | XIAO_BOOT_STORAGE_UPDATE
BOOT_STORAGE_RAK_AUTO_RECOVERY = (BOOT_STORAGE_STAGE_CEILING |
XIAO_BOOT_STORAGE_QSPI |
BOOT_STORAGE_HEADER_W25)
BOOT_REQUIRED_APP_CODEC_MASK = ((1 << CODEC_FULL) |
(1 << CODEC_DETOOLS_INPLACE))
@@ -142,13 +138,6 @@ INTERNAL_BOOTLOADER_IDENTITIES = (
(0x239A0029, "RTAG_DFU"),
)
# These adaptive loaders do not self-update. Their exact MeshCore application
# authenticates a format-3 package and uses the MBR to replace the bootloader.
RAK_AUTO_RECOVERY_IDENTITIES = (
(0x239A0029, "3401_AUTO_DFU"),
(0x239A0029, "4631_AUTO_DFU"),
)
# MeshTower V2 has both a lean internal-store application and an exact SD-store
# application. They intentionally retain the same embedded bootloader identity
# and signed wire target; the candidate capability marker selects the matching
@@ -707,7 +696,7 @@ def audit_bootloader_target_inventory(application_target_ids=()) -> dict:
identities = (
(XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"),
(XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU"),
) + INTERNAL_BOOTLOADER_IDENTITIES + RAK_AUTO_RECOVERY_IDENTITIES
) + INTERNAL_BOOTLOADER_IDENTITIES
app_ids = set(application_target_ids)
targets = {}
for identity in identities:
@@ -725,7 +714,6 @@ def audit_bootloader_target_inventory(application_target_ids=()) -> dict:
def bootloader_identity_is_buildable(board_id: int, device_name: str) -> bool:
return ((board_id, device_name) in INTERNAL_BOOTLOADER_IDENTITIES or
(board_id, device_name) in RAK_AUTO_RECOVERY_IDENTITIES or
(board_id, device_name) in (
(XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"),
(XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU")))
@@ -755,8 +743,6 @@ def bootloader_qualified_storage_profiles(board_id: int, device_name: str):
return (BOOT_STORAGE_INTERNAL_UPDATE, BOOT_STORAGE_SD_UPDATE)
if identity in INTERNAL_BOOTLOADER_IDENTITIES:
return (BOOT_STORAGE_INTERNAL_UPDATE,)
if identity in RAK_AUTO_RECOVERY_IDENTITIES:
return (BOOT_STORAGE_RAK_AUTO_RECOVERY,)
return None
@@ -833,7 +819,7 @@ def parse_xiao_bootloader_identity(image: bytes) -> Optional[XiaoBootloaderIdent
def bootloader_caps_storage(image: bytes) -> Optional[int]:
"""Return one exact qualified self-update or app-owned recovery marker."""
"""Return the one exact supported self-update storage marker, or None."""
found = None
valid_count = 0
for off in range(0, len(image) - 16 + 1, 4):
@@ -841,22 +827,16 @@ def bootloader_caps_storage(image: bytes) -> Optional[int]:
continue
abi, codecs = struct.unpack_from("<HH", image, off + 8)
storage = image[off + 12]
if (abi < (APP_FORMAT_VER if storage == BOOT_STORAGE_RAK_AUTO_RECOVERY
else BOOT_FORMAT_VER) or abi == 0xFFFF or
if (abi < BOOT_FORMAT_VER or abi == 0xFFFF or
codecs & BOOT_REQUIRED_APP_CODEC_MASK != BOOT_REQUIRED_APP_CODEC_MASK or
storage & ~BOOT_STORAGE_KNOWN or
(storage & BOOT_STORAGE_HEADER_W25 and
storage != BOOT_STORAGE_RAK_AUTO_RECOVERY) or
image[off + 13:off + 16] != b"\0\0\0" or
(not storage & XIAO_BOOT_STORAGE_UPDATE and
storage != BOOT_STORAGE_RAK_AUTO_RECOVERY)):
storage & ~BOOT_STORAGE_KNOWN or image[off + 13:off + 16] != b"\0\0\0" or
not storage & XIAO_BOOT_STORAGE_UPDATE):
continue
valid_count += 1
if valid_count != 1:
return None
if storage in (BOOT_STORAGE_SD_UPDATE, BOOT_STORAGE_QSPI_UPDATE,
BOOT_STORAGE_INTERNAL_UPDATE,
BOOT_STORAGE_RAK_AUTO_RECOVERY):
BOOT_STORAGE_INTERNAL_UPDATE):
found = storage
return found if valid_count == 1 else None
@@ -895,7 +875,7 @@ def validate_bootloader_image(image: bytes, target_id: Optional[int] = None,
actual_storage = bootloader_caps_storage(image)
if actual_storage not in expected_storage:
expected = "/".join(f"0x{value:02X}" for value in expected_storage)
raise ValueError(f"bootloader lacks exact qualified update/recovery capabilities {expected}")
raise ValueError(f"bootloader lacks exact ABI 3 self-update capabilities {expected}")
expected_platform = bootloader_qualified_platform_profile(
identity.board_id, identity.device_name)
if expected_platform is not None:
+4 -36
View File
@@ -1328,12 +1328,12 @@ def _xiao_bootloader_image(board_id=ml.XIAO_BOOT_BOARD_ID_BASE,
def _generic_bootloader_image(board_id=0x239A0029, device_name="3401_DFU",
storage=ml.BOOT_STORAGE_INTERNAL_UPDATE,
boot_version=0x0117010D, apply_abi=ml.BOOT_FORMAT_VER):
boot_version=0x0117010D):
import zlib
image = bytearray(b"\xff" * ml.XIAO_BOOT_IMAGE_SIZE)
struct.pack_into("<II", image, 0, 0x20040000, ml.XIAO_BOOT_IMAGE_START + 0x101)
struct.pack_into("<8sHHB3x", image, 0x80, ml.XIAO_BOOT_CAPS_MAGIC,
apply_abi, ml.BOOT_REQUIRED_APP_CODEC_MASK,
ml.BOOT_FORMAT_VER, ml.BOOT_REQUIRED_APP_CODEC_MASK,
storage)
name = device_name.encode("ascii").ljust(16, b"\0")
off = ml.BOOT_CANDIDATE_MANIFEST_OFFSET
@@ -1575,36 +1575,6 @@ def test_generic_internal_bootloader_build_parse_and_strict_contract():
pass
def test_adaptive_rak_recovery_bootloader_packages_are_exact():
priv = Ed25519PrivateKey.generate()
for board_id, name in ml.RAK_AUTO_RECOVERY_IDENTITIES:
image = _generic_bootloader_image(
board_id=board_id, device_name=name,
storage=ml.BOOT_STORAGE_RAK_AUTO_RECOVERY,
apply_abi=ml.APP_FORMAT_VER)
identity = ml.validate_bootloader_image(image)
target = ml.bootloader_target_id(board_id, name)
manifest = ml.build_manifest(
target_id=target, fw_version=identity.boot_version,
image_size=len(image), payload=image, block_size=1024,
image_hash=ml.mh32(image), codec_id=ml.CODEC_FULL,
is_full=True, sign_priv=priv, bootloader=True)
parsed = ml.parse_container(ml.build_container(manifest, image))
assert ml.verify(parsed, expect_pub=ml.ed25519_public_bytes(priv)) == []
assert parsed.manifest.target_id == target
assert ml.bootloader_caps_storage(image) == ml.BOOT_STORAGE_RAK_AUTO_RECOVERY
for wrong in (ml.BOOT_STORAGE_INTERNAL_UPDATE,
ml.BOOT_STORAGE_QSPI_UPDATE):
invalid = _generic_bootloader_image(
board_id=board_id, device_name=name, storage=wrong)
try:
ml.validate_bootloader_image(invalid)
assert False, "adaptive recovery accepted wrong loader capabilities"
except ValueError:
pass
def test_gat562_internal_bootloader_profile_is_exact():
priv = Ed25519PrivateKey.generate()
image = _generic_bootloader_image(device_name="GAT562_DFU")
@@ -1702,9 +1672,7 @@ def test_bootloader_build_inventory_is_unique_and_disjoint_from_app_targets():
for value in re.findall(r"\{ 0x([0-9a-fA-F]{8}),", target_header)
}
audited = ml.audit_bootloader_target_inventory(app_targets)
assert len(audited) == len(expected) + 4 # two adaptive RAKs and two XIAOs
assert audited[0xD04AB3AB] == (0x239A0029, "3401_AUTO_DFU")
assert audited[0xFEEAFD1B] == (0x239A0029, "4631_AUTO_DFU")
assert len(audited) == len(expected) + 2 # generic internal plus two legacy XIAOs
# Generic parsing remains useful for inspecting a future candidate, but a
# signing/build call fails closed until its exact identity is qualified.
@@ -1726,7 +1694,7 @@ def test_qualified_bootloader_platform_and_storage_profiles_are_exact():
qualified = (
(ml.XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"),
(ml.XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU"),
) + ml.INTERNAL_BOOTLOADER_IDENTITIES + ml.RAK_AUTO_RECOVERY_IDENTITIES
) + ml.INTERNAL_BOOTLOADER_IDENTITIES
for board_id, device_name in qualified:
family, fwid, app_base, layout_abi = \
ml.bootloader_qualified_platform_profile(board_id, device_name)