Support reviewed stock nRF52 bootloaders in embedded updates

This commit is contained in:
mikecarper committed 2026-11-01 00:33:59 -07:00
1 parent 54d5d97cc1
commit f7793ee63f
13 files changed
+936 -38

No files matched your search

+27 -4
View File
@@ -15,8 +15,9 @@ the exact board's [OTAFIX 2.4.12 bootloader](https://github.com/mikecarper/Adafr
in one application image. After installing that application, it checks the
bootloader once at 70 seconds after startup and updates it when needed. The
check waits for idle radio and OTA activity, and skips a valid matching
bootloader at the same or newer version. Recovery and unrecognized bootloaders
are excluded from automatic installation. Ordinary firmware has no automatic
bootloader at the same or newer version. Reviewed stock bootloaders, including
RAK 0.4.3, are supported by exact installed-image hashes rather than their names.
Recovery and unrecognized bootloaders are excluded from automatic installation. Ordinary firmware has no automatic
bootloader update.
This follows the internal-flash bridge in
@@ -71,7 +72,28 @@ and their output names stay unchanged. The embedded image remains an
application UF2/DFU package, not a combined SoftDevice/bootloader DFU package.
An installed bootloader with valid compatible identity metadata must match the
candidate's board and runtime layout. For the explicitly qualified legacy
candidate's board and runtime layout. Modern identity mismatches never fall back
to stock-loader rules. Current all-in-one Repeater and Room Server builds also
include a small, exact-byte stock inventory automatically; no environment variable
or separate legacy file is needed for those images. The initial reviewed inventory
covers the published factory RAK 0.3.2/0.4.1/0.4.2/0.4.3/0.4.4 images on RAK4631
and RAK3401, R1 Neo's own image, T114 vendor images, T-Echo/Lite, T1000-E's recovery
image, Wio Tracker L1, and the exact XIAO base/Sense product images. See
[`nrf52_legacy_bootloaders.py`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/scripts/nrf52_legacy_bootloaders.py)
for exact source URLs, file and padded-image hashes, hardware and layout mappings.
Each entry binds the entire 40 KiB bootloader region, the physical build target,
the replacement profile and the S140/application layout. A different byte, shared
USB ID or a matching version string is insufficient. A RAK3401 may legitimately
have the factory RAK4631 loader; its replacement is still the exact RAK3401 profile.
Related boards do not inherit representative module/SDK hashes automatically.
Unknown or recovery images remain excluded, and flash protection, MBR geometry,
watchdog and durable-state checks remain mandatory. These are source/image
compatibility checks, not a claim that every transition or power interruption has
been hardware-qualified. Full Companion is **not** an all-in-one updater: use the
separate exact-board USB bootloader ZIP and reinstall its application afterward.
For the separately qualified legacy
RAK4631 transition, also supply the exact installed bootloader image:
```sh
@@ -83,7 +105,8 @@ The legacy exception binds the entire padded bootloader region by SHA-256.
It accepts the qualified MeshEnvy `0.9.2-ev1` RAK4631 image, whose full-region
SHA-256 is `64bfe69d56eefc2d0fe51770902aff70ea0cc06cad261d3d219e4a3c0efac8dc`.
A matching version string alone is insufficient; another board's image or an
unknown stock bootloader is not accepted by that exception.
unknown stock bootloader is not accepted by that explicit exception. It remains
separate from the automatic reviewed-stock inventory.
A P1 already running a XIAO bootloader needs a deliberate identity migration;
the embedded installer does not change one board identity into another. Use
+15
View File
@@ -14,6 +14,8 @@ import tempfile
from urllib.request import urlopen
import zipfile
from nrf52_legacy_bootloaders import legacy_bootloaders_for
ROOT = Path(__file__).resolve().parents[1]
VERSION = "2.4.12"
BOOT_VERSION = 0x02040CFF
@@ -246,6 +248,10 @@ def emit_resource(project, target, output, *, flags=(), cppdefines=(), src_filte
if linked_base is not None and linked_base != identity.app_base:
raise ValueError("candidate SoftDevice base does not match the application linker")
legacy = load_legacy(legacy_path) if legacy_path else None
stock_legacy = legacy_bootloaders_for(profile["id"], parsed["sourceHardware"],
identity.app_base, identity.softdevice_fwid)
if len(stock_legacy) > 32:
raise ValueError("too many reviewed stock bootloader identities")
output = Path(output)
output.mkdir(parents=True, exist_ok=True)
arrays = {"migration_candidate": image,
@@ -258,7 +264,15 @@ def emit_resource(project, target, output, *, flags=(), cppdefines=(), src_filte
source += f"alignas(4) const uint8_t {name}[{len(data)}] = {{\n"
source += "".join(" " + ", ".join(f"0x{value:02X}" for value in data[index:index + 16]) + ",\n"
for index in range(0, len(data), 16)) + "};\n"
# A zero-count resource retains one inert row so C++ never needs a
# non-standard zero-length array. The count, not the sentinel, authorizes it.
stock_hashes = [bytes.fromhex(item["image_sha256"]) for item in stock_legacy]
header += f"extern const uint8_t migration_stock_sha256[{max(1, len(stock_hashes))}][32];\n"
source += f"alignas(4) const uint8_t migration_stock_sha256[{max(1, len(stock_hashes))}][32] = {{\n"
source += "".join(" {" + ", ".join(f"0x{value:02X}" for value in digest) + "},\n"
for digest in (stock_hashes or [bytes(32)])) + "};\n"
values = {"migration_has_legacy": ("bool", "true" if legacy else "false"),
"migration_stock_count": ("uint32_t", str(len(stock_hashes))),
"migration_app_base": ("uint32_t", hex(identity.app_base)),
"migration_boot_version": ("uint32_t", hex(identity.boot_version)),
"migration_board_id": ("uint32_t", hex(identity.board_id)),
@@ -278,6 +292,7 @@ def emit_resource(project, target, output, *, flags=(), cppdefines=(), src_filte
"source_zip_sha256": profile["files"]["zip"]["sha256"],
"candidate_sha256": hashlib.sha256(image).hexdigest(),
"legacy_sha256": hashlib.sha256(legacy).hexdigest() if legacy else None,
"stock_legacy": stock_legacy,
"image_size": BOOT_SIZE, "app_base": identity.app_base,
"boot_version": identity.boot_version, "board_id": identity.board_id,
"softdevice_fwid": identity.softdevice_fwid, "device_name": identity.device_name}
+310
View File
@@ -0,0 +1,310 @@
"""Reviewed stock bootloader byte identities for application-preserving MBR copies.
These are exact 40 KiB images, not version-string, USB-ID or board-family rules.
A source image is admitted only for the listed physical hardware, replacement
profile and *running* S140 layout. Runtime protection/MBR/vector/persistence gates
remain mandatory. This inventory does not claim live power-cut qualification.
Sources and hashes are cross-checked against the independent stock reader audit.
Representative SDK images are not propagated to other products sharing a chip.
"""
from __future__ import annotations
import re
# Bind each physical target to its replacement, not a Cartesian product of
# shared factory images. In particular RAK3401 never selects a 4631 successor.
_PHYSICAL_PROFILES = {
"RAK_3401": "wiscore_rak3401_auto",
"RAK_4631": "wiscore_rak4631_auto",
"R1Neo": "wiscore_rak4631_auto",
"Heltec_t114": "heltec_t114",
"Heltec_t114_without_display": "heltec_t114",
"LilyGo_T-Echo": "lilygo_techo",
"LilyGo_T-Echo-Lite": "lilygo_techo_lite",
"LilyGo_T-Echo-Lite_non_shell": "lilygo_techo_lite",
"t1000e": "t1000_e",
"WioTrackerL1": "wio_tracker_l1",
"Xiao_nrf52": "xiao_nrf52840_ble",
"Xiao_nrf52_Sense": "xiao_nrf52840_ble_sense",
}
_RECORDS = [
{
"name": "HT-n5262_bootloader-0.9.0_s140_6.1.1.hex",
"version": "0.9.0-2-g836c8dc-dirty",
"source_url": "https://raw.githubusercontent.com/HelTecAutomation/Heltec_nRF52/main/bootloader/HT-n5262/HT-n5262_bootloader-0.9.0_s140_6.1.1.hex",
"artifact_sha256": "b0eb140afac57633208ace8a98908e8c90149a38c1b0b62cf65e0ec48acea1b7",
"image_sha256": "8618c2d91d855afab573d4e9e7efcdc61d80242faa7b9e74f403a81e744abe16",
"profiles": [
"heltec_t114"
],
"hardware": [
"Heltec_t114",
"Heltec_t114_without_display"
],
"app_base": 155648,
"softdevice_fwid": 182
},
{
"name": "t-echo_lite_nrf52840_bootloader-0.9.2-dirty_s140_6.1.1_202411271344.hex",
"version": "0.9.2-dirty",
"source_url": "https://raw.githubusercontent.com/Xinyuan-LilyGO/T-Echo-Lite/main/bootloader/t-echo_lite_nrf52840_bootloader-0.9.2-dirty_s140_6.1.1_202411271344.hex",
"artifact_sha256": "278a059c0288d919a166373da6241f36a53b33518c915f380878daee7b0a09e8",
"image_sha256": "911bd9a317c3b69c8db1406dae2857410d8e608b256a9c06b5d351d90da63995",
"profiles": [
"lilygo_techo_lite"
],
"hardware": [
"LilyGo_T-Echo-Lite",
"LilyGo_T-Echo-Lite_non_shell"
],
"app_base": 155648,
"softdevice_fwid": 182
},
{
"name": "wiscore_rak4631_board_bootloader-0.4.3.hex",
"version": "0.4.3",
"source_url": "https://raw.githubusercontent.com/RAKWireless/WisBlock/master/bootloader/RAK4630/Latest/WisCore_RAK4631_Bootloader/wiscore_rak4631_board_bootloader-0.4.3.hex",
"artifact_sha256": "e537340f695523b80c36c3cba6b74ae9b25c2d5047cf195eea19535b7171c356",
"image_sha256": "6cbfa569a05cbd232dabe4a654abe9c04b7a03ab457615028d28f2d1329e58d2",
"profiles": [
"wiscore_rak4631_auto",
"wiscore_rak3401_auto"
],
"hardware": [
"RAK_4631",
"RAK_3401"
],
"app_base": 155648,
"softdevice_fwid": 182
},
{
"name": "wiscore_rak4631_board_bootloader-0.4.4.hex",
"version": "0.4.4",
"source_url": "https://raw.githubusercontent.com/RAKWireless/WisBlock/master/bootloader/RAK4630/Latest/wiscore_rak4631_board_bootloader-0.4.4.hex",
"artifact_sha256": "29635d90eccd181ce206bfa10aa0ca92b9cff61735a6205321efe241e0f1298a",
"image_sha256": "34d59ed3c584b973ced76917cc9c7a7bc979f0b71865055729dc9a8326dc326b",
"profiles": [
"wiscore_rak4631_auto",
"wiscore_rak3401_auto"
],
"hardware": [
"RAK_4631",
"RAK_3401"
],
"app_base": 155648,
"softdevice_fwid": 182
},
{
"name": "RAK4630_bootloader.hex",
"version": "0.3.2-109-gd6b28e6",
"source_url": "https://raw.githubusercontent.com/RAKWireless/WisBlock/master/bootloader/RAK4630/Old/V0.3.2/RAK4630_bootloader.hex",
"artifact_sha256": "5c44ea2eb151c20c2cb5a63ca3ff4c4d10ae11b29f97812fb7a21167de7acb44",
"image_sha256": "f345e1b2b85c88c32b3b1ad95d3330fd1d9bcf2ee0df25289c3e2f0d5fb3228d",
"profiles": [
"wiscore_rak4631_auto",
"wiscore_rak3401_auto"
],
"hardware": [
"RAK_4631",
"RAK_3401"
],
"app_base": 155648,
"softdevice_fwid": 182
},
{
"name": "RAK4630_bootloader-0.4.1.hex",
"version": "0.4.1-13-g5e6690e-dirty",
"source_url": "https://raw.githubusercontent.com/RAKWireless/WisBlock/master/bootloader/RAK4630/Old/V0.4.1/RAK4630_bootloader-0.4.1.hex",
"artifact_sha256": "3f317b2d05c8c65a98a5de306c7055078426ed5398dabb99ed1d2af93c270579",
"image_sha256": "15003850514750f2606c9edb91ed81bf636d75a47d0961f3a8644847be2a9ef7",
"profiles": [
"wiscore_rak4631_auto",
"wiscore_rak3401_auto"
],
"hardware": [
"RAK_4631",
"RAK_3401"
],
"app_base": 155648,
"softdevice_fwid": 182
},
{
"name": "WisCore_RAK4631_Board_Bootloader.hex",
"version": "0.4.2",
"source_url": "https://raw.githubusercontent.com/RAKWireless/WisBlock/master/bootloader/RAK4630/Old/V0.4.2/WisCore_RAK4631_Board_Bootloader.hex",
"artifact_sha256": "44013dd02aa5335cbeb85a6b838408dc34b267e8d732965ab4c62730fa81b9a1",
"image_sha256": "c77b8a4de1c33bedcb96d28cfa68d5a1c510e7cd886cfc62e6d07fbf2126ccea",
"profiles": [
"wiscore_rak4631_auto",
"wiscore_rak3401_auto"
],
"hardware": [
"RAK_4631",
"RAK_3401"
],
"app_base": 155648,
"softdevice_fwid": 182
},
{
"name": "lilygo_techo_bootloader-0.6.1_s140_6.1.1.hex",
"version": "0.6.1-2-g1224915",
"source_url": "https://raw.githubusercontent.com/Xinyuan-LilyGO/T-Echo/main/bootloader/lilygo_techo_bootloader-0.6.1_s140_6.1.1.hex",
"artifact_sha256": "0f19e2a83ab76ead26e729f9c6ee5e5db6b8c6e45e6ed47b01d648d531f2418b",
"image_sha256": "f5f3a732241590d49a355b78d12aee4952115d773796f8e40e86ff1cf0fbcc50",
"profiles": [
"lilygo_techo"
],
"hardware": [
"LilyGo_T-Echo"
],
"app_base": 155648,
"softdevice_fwid": 182
},
{
"name": "Seeed_XIAO_nRF52840_bootloader-0.6.2_s140_7.3.0.hex",
"version": "0.6.1",
"source_url": "https://raw.githubusercontent.com/Seeed-Studio/Adafruit_nRF52_Arduino/master/bootloader/Seeed_XIAO_nRF52840/Seeed_XIAO_nRF52840_bootloader-0.6.2_s140_7.3.0.hex",
"artifact_sha256": "c79c8cf75ebb7abfa53b02fd3584aa9a5aeb8dc1b073f674f4fa162225630c6c",
"image_sha256": "77941feb9ea9d99acaeb1f815a905e23f618381390d40f38374a894505e6798f",
"profiles": [
"xiao_nrf52840_ble"
],
"hardware": [
"Xiao_nrf52"
],
"app_base": 159744,
"softdevice_fwid": 291
},
{
"name": "Seeed_XIAO_nRF52840_Plus_bootloader-0.6.2_s140_7.3.0.hex",
"version": "0.9.2-29-g6a9a6a3",
"source_url": "https://raw.githubusercontent.com/Seeed-Studio/Adafruit_nRF52_Arduino/master/bootloader/Seeed_XIAO_nRF52840_Plus/Seeed_XIAO_nRF52840_Plus_bootloader-0.6.2_s140_7.3.0.hex",
"artifact_sha256": "6bd76f49e1dfa839d0084b9af5563115df2ee438f1980327dd4364792475048b",
"image_sha256": "caf05e859193001ef745d21ae71b218f2f23a0ed995a4adc69debcdb31e35ba8",
"profiles": [
"xiao_nrf52840_ble"
],
"hardware": [
"Xiao_nrf52"
],
"app_base": 159744,
"softdevice_fwid": 291
},
{
"name": "Seeed_XIAO_nRF52840_Sense_bootloader-0.6.2_s140_7.3.0.hex",
"version": "0.6.1",
"source_url": "https://raw.githubusercontent.com/Seeed-Studio/Adafruit_nRF52_Arduino/master/bootloader/Seeed_XIAO_nRF52840_Sense/Seeed_XIAO_nRF52840_Sense_bootloader-0.6.2_s140_7.3.0.hex",
"artifact_sha256": "ac654c6cab225a933278c8be09b92b41bc4c044d064bd8c9c3314c1c4a0cc8c8",
"image_sha256": "894f2e6df7663fcc5c398e210db7f2e7f21d3da53036e0a11576909c849f78fb",
"profiles": [
"xiao_nrf52840_ble_sense"
],
"hardware": [
"Xiao_nrf52_Sense"
],
"app_base": 159744,
"softdevice_fwid": 291
},
{
"name": "Seeed_XIAO_nRF52840_Sense_Plus_bootloader-0.6.2_s140_7.3.0.hex",
"version": "0.9.2-29-g6a9a6a3",
"source_url": "https://raw.githubusercontent.com/Seeed-Studio/Adafruit_nRF52_Arduino/master/bootloader/Seeed_XIAO_nRF52840_Sense_Plus/Seeed_XIAO_nRF52840_Sense_Plus_bootloader-0.6.2_s140_7.3.0.hex",
"artifact_sha256": "b401776e6586f6065c46187d190dfa4acf9f7897a7d7c5f12a34662dff405e64",
"image_sha256": "b19bd74a743a8311b12a07ae5cc17f28966d6e61b66f93f2afb2ceb52c5240d2",
"profiles": [
"xiao_nrf52840_ble_sense"
],
"hardware": [
"Xiao_nrf52_Sense"
],
"app_base": 159744,
"softdevice_fwid": 291
},
{
"name": "wio_tracker_l1_bootloader-0.10.0_s140_7.3.0.zip",
"version": "0.7.0-22-g277a0c8",
"source_url": "https://files.seeedstudio.com/wiki/SenseCAP/Meshtastic/adafruit/wio_tracker_l1_bootloader-0.10.0_s140_7.3.0.zip",
"artifact_sha256": "6d8fab06f8fd146653b93196ebb86203e336d74bb7f06d1dabc89f31d23783d1",
"image_sha256": "4e9c4b9a9d610ccb7c7e9f16757eb51bedeeb2b30b438d5c1ad07a852f2a24b4",
"profiles": [
"wio_tracker_l1"
],
"hardware": [
"WioTrackerL1"
],
"app_base": 159744,
"softdevice_fwid": 291
},
{
"name": "t1000_e_bootloader-0.9.1-5-g488711a_s140_7.3.0.zip",
"version": "0.9.1-5-g488711a",
"source_url": "https://files.seeedstudio.com/wiki/SenseCAP/lorahub/t1000_e_bootloader-0.9.1-5-g488711a_s140_7.3.0.zip",
"artifact_sha256": "87c4ba0721be16cca9b784ccb70ea41103100767978431ba47c692d2644bdccc",
"image_sha256": "9f286a5c8c634a65e4f6d5b63c35eeb1466f900de9abb58ae109e67a82092562",
"profiles": [
"t1000_e"
],
"hardware": [
"t1000e"
],
"app_base": 159744,
"softdevice_fwid": 291
},
{
"name": "r1_neo_board_bootloader-0.9.2-31-g990aa7f-dirty_s140_6.1.1.hex",
"version": "0.9.2-31-g990aa7f-dirty",
"source_url": "https://github.com/muzi-works/Adafruit_nRF52_Bootloader/releases/download/0.9.2/r1_neo_board_bootloader-0.9.2-31-g990aa7f-dirty_s140_6.1.1.hex",
"artifact_sha256": "54f77f1e2c6cf5675aec9730cff27087f3f838d7f7075dba5a266e6e0ac355ff",
"image_sha256": "8bfe28094a824f08bf703bf3ccc929e0b21b0b37f94ccee3fbd89413b4eedfeb",
"profiles": [
"wiscore_rak4631_auto"
],
"hardware": [
"R1Neo"
],
"app_base": 155648,
"softdevice_fwid": 182
},
{
"name": "HT-n5262-bootloader-20240709.hex",
"version": "0.9.0-2-g836c8dc-dirty",
"source_url": "https://resource.heltec.cn/download/Mesh_Node_T114/firmware/HT-n5262-bootloader-20240709.hex",
"artifact_sha256": "e950057bebc4beddafdf30955b2d473c4cc26ed008858cf114c613ba0c6c8f88",
"image_sha256": "906fbbe787e18b789a695ae8bec6a5c31320f6948a656344c30bde0eaca1afbd",
"profiles": [
"heltec_t114"
],
"hardware": [
"Heltec_t114",
"Heltec_t114_without_display"
],
"app_base": 155648,
"softdevice_fwid": 182
}
]
def legacy_bootloaders_for(profile_id, source_hardware, app_base, softdevice_fwid):
"""Return fresh provenance records for this exact build and running layout."""
if _PHYSICAL_PROFILES.get(source_hardware) != profile_id:
return []
result = []
seen = set()
for record in _RECORDS:
if (profile_id not in record["profiles"]
or source_hardware not in record["hardware"]
or app_base != record["app_base"]
or softdevice_fwid != record["softdevice_fwid"]):
continue
digest = record["image_sha256"]
if (not re.fullmatch(r"[0-9a-f]{64}", digest)
or not re.fullmatch(r"[0-9a-f]{64}", record["artifact_sha256"])
or not record["source_url"].startswith("https://")
or digest in seen):
raise ValueError("invalid or duplicate reviewed stock bootloader")
seen.add(digest)
result.append({**record, "profiles": list(record["profiles"]),
"hardware": list(record["hardware"])})
return result
+60 -8
View File
@@ -25,7 +25,7 @@ from package_cardputer_license import validate_artifact_notice as validate_cardp
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools" / "mota"))
from motalib import parse_endf_ident, parse_nrf52_layout, target_id_for_env
from motalib import parse_endf, parse_endf_ident, parse_nrf52_layout, target_id_for_env
FIRMWARE_SUFFIXES = {".bin", ".uf2", ".hex", ".zip"}
@@ -34,6 +34,56 @@ def is_companion(target):
return "companion" in target.lower() or "comp_radio" in target.lower()
def embedded_stock_legacy_records(profile, parsed):
"""Read the stock inventory for the exact pinned replacement's S140 ABI."""
from nrf52_legacy_bootloaders import legacy_bootloaders_for
match = re.search(r"_s140_([0-9.]+)\.zip$", profile["files"]["zip"]["name"])
geometry = {"6.1.1": (0x26000, 0xB6), "7.3.0": (0x27000, 0x123)}
if match is None or match[1] not in geometry:
raise ValueError("unrecognized pinned embedded bootloader S140 geometry")
return legacy_bootloaders_for(profile["id"], parsed["sourceHardware"], *geometry[match[1]])
def validate_embedded_stock_metadata(metadata, profile, parsed):
# Preserve historical releases that predate stock support. A new stock
# claim must match the current reviewed inventory completely, not a subset
# with convenient names or a hash copied from another board's package.
if "stock_legacy" not in metadata:
return "stock_legacy_offset" not in metadata
if metadata["stock_legacy"] != embedded_stock_legacy_records(profile, parsed):
return False
offset = metadata.get("stock_legacy_offset")
return (type(offset) is int and offset >= 0 and offset % 4 == 0
if metadata["stock_legacy"] else offset is None)
def validate_embedded_stock_body(metadata, body):
if "stock_legacy" not in metadata or not metadata["stock_legacy"]:
return
payload = b"".join(bytes.fromhex(item["image_sha256"]) for item in metadata["stock_legacy"])
start, candidate = metadata["stock_legacy_offset"], metadata["app_offset"]
end = start + len(payload)
if (body[start:end] != payload or body.find(payload) != start
or body.find(payload, start + 1) >= 0
or not (end <= candidate or start >= candidate + metadata["image_bytes"])):
raise ValueError("packaged application lacks its exact reviewed stock bootloader hash table")
def validate_embedded_bootloader_application(manifest, application):
"""Recheck embedded resources in the actual published DFU application."""
embedded = manifest.get("embedded_bootloader")
if not embedded:
return
body, _ = parse_endf(application)
offset, size = embedded["app_offset"], embedded["image_bytes"]
candidate = body[offset:offset + size]
if (len(candidate) != size
or hashlib.sha256(candidate).hexdigest() != embedded["candidate_sha256"]
or b"BL embedded 2.4.12" not in body):
raise ValueError("packaged embedded bootloader differs from qualification")
validate_embedded_stock_body(embedded, body)
def embedded_bootloader_variant(manifest):
"""Keep ordinary and exact, qualified bootloader-carrying images distinct."""
metadata = manifest.get("embedded_bootloader")
@@ -49,7 +99,7 @@ def embedded_bootloader_variant(manifest):
if not isinstance(metadata, dict):
raise ValueError(f"{artifact}: invalid embedded bootloader metadata")
try:
profile, _ = select_profile(load_manifest(), target)
profile, parsed = select_profile(load_manifest(), target)
except (ValueError, KeyError) as exc:
raise ValueError(f"{artifact}: embedded bootloader requires an exact supported profile") from exc
verified = any(check.get("capability") == capability
@@ -65,6 +115,7 @@ def embedded_bootloader_variant(manifest):
or not isinstance(metadata.get("candidate_sha256"), str)
or not re.fullmatch(r"[0-9a-f]{64}", metadata["candidate_sha256"])
or metadata.get("legacy_sha256") not in (None, LEGACY_SHA256)
or not validate_embedded_stock_metadata(metadata, profile, parsed)
or metadata.get("image_bytes") != BOOT_SIZE
or type(offset) is not int or offset < 0 or offset % 4
or metadata.get("activation") != "ota bootloader update"
@@ -240,16 +291,13 @@ def validate_nrf52_sensor_pairs(records):
raise ValueError(f"{artifact_target}: sensor pair application exceeds its linked flash region")
embedded = manifest.get("embedded_bootloader")
if embedded:
offset, size = embedded["app_offset"], embedded["image_bytes"]
candidate = application[offset:offset + size]
if (len(candidate) != size
or hashlib.sha256(candidate).hexdigest() != embedded["candidate_sha256"]
or b"BL embedded 2.4.12" not in application):
raise ValueError(f"{artifact_target}: packaged embedded bootloader differs from qualification")
validate_embedded_bootloader_application(manifest, application)
proof = (identity.target_id, identity.hw_id, identity.fw_version,
layout.app_base, layout.linked_app_end, layout.stage_ceiling, layout.flags,
manifest.get("ota_update_requirements", {}), manifest.get("source_commit"),
tuple((key, embedded.get(key)) for key in ("profile", "candidate_sha256", "legacy_sha256"))
+ ((json.dumps(embedded.get("stock_legacy"), sort_keys=True),)
if "stock_legacy" in embedded else ())
if embedded else ())
groups.setdefault((manifest["target"], bootloader), []).append((sensor, proof))
for (target, bootloader), pair in groups.items():
@@ -304,6 +352,10 @@ def collect_artifacts(directory, version):
if ".uf2" not in extensions or (manifest.get("ota_update_verified") and ".zip" not in extensions):
raise ValueError(f"{stem}: nRF52 UF2/DFU artifacts incomplete")
check_uf2(directory / (stem + ".uf2"))
if manifest.get("embedded_bootloader"):
with zipfile.ZipFile(directory / (stem + ".zip")) as package:
entry = json.loads(package.read("manifest.json"))["manifest"]["application"]
validate_embedded_bootloader_application(manifest, package.read(entry["bin_file"]))
if any(item.stat().st_size == 0 for item in files):
raise ValueError(f"{stem}: empty firmware artifact")
memory = validate_package(directory / stem)
+21 -1
View File
@@ -9,6 +9,7 @@ import zipfile
from nrf52_embedded_bootloader import (ROOT, BOOT_SIZE, VERSION, MANIFEST_SHA256,
LEGACY_SHA256, load_manifest, select_profile, download_zip, validate_candidate_zip)
from nrf52_legacy_bootloaders import legacy_bootloaders_for
sys.path.insert(0, str(ROOT / "tools/mota"))
from motalib import parse_endf, parse_endf_ident, parse_nrf52_layout, target_id_for_env
@@ -19,8 +20,10 @@ def qualify(directory, manifest_path):
manifest = json.loads(manifest_path.read_text())
if manifest.get("platform") != "NRF52_PLATFORM" or manifest.get("verified") is not True:
raise ValueError("embedded bootloader requires qualified nRF52 firmware")
profile, _ = select_profile(load_manifest(), manifest["target"])
profile, parsed = select_profile(load_manifest(), manifest["target"])
candidate, identity = validate_candidate_zip(download_zip(profile, ROOT / ".pio/otafix-cache"), profile)
stock_legacy = legacy_bootloaders_for(profile["id"], parsed["sourceHardware"],
identity.app_base, identity.softdevice_fwid)
resource = directory / "bootloader-migration-resource"
metadata = json.loads((resource / "BootloaderMigrationResource.json").read_text())
if (metadata.get("profile") != profile["id"] or metadata.get("release") != VERSION
@@ -28,6 +31,12 @@ def qualify(directory, manifest_path):
or metadata.get("asset_sha256") != profile["files"]["zip"]["sha256"]
or metadata.get("candidate_sha256") != hashlib.sha256(candidate).hexdigest()
or metadata.get("legacy_sha256") not in (None, LEGACY_SHA256)
or metadata.get("target") != manifest["target"]
or metadata.get("hardware") != parsed["sourceHardware"]
or metadata.get("role") != parsed["role"]
or metadata.get("app_base") != identity.app_base
or metadata.get("softdevice_fwid") != identity.softdevice_fwid
or metadata.get("stock_legacy") != stock_legacy
or (resource / "BootloaderMigrationResource.bin").read_bytes() != candidate):
raise ValueError("embedded bootloader build resource differs from its pinned release")
with zipfile.ZipFile(directory / "firmware.zip") as package:
@@ -44,12 +53,23 @@ def qualify(directory, manifest_path):
or offset + BOOT_SIZE > len(body)
or b"BL embedded 2.4.12" not in body):
raise ValueError("packaged application lacks the complete aligned embedded bootloader or activation code")
# Do not promote a copied JSON claim into legacy-update qualification. The
# entire ordered hash table must exist in the EndF-verified application body
# outside the replacement bootloader, not in its trailer or a sidecar file.
stock_hashes = b"".join(bytes.fromhex(item["image_sha256"]) for item in stock_legacy)
stock_offset = body.find(stock_hashes) if stock_hashes else None
if stock_hashes and (stock_offset < 0 or stock_offset % 4
or body.find(stock_hashes, stock_offset + 1) >= 0
or not (stock_offset + len(stock_hashes) <= offset
or stock_offset >= offset + BOOT_SIZE)):
raise ValueError("packaged application lacks its exact reviewed stock bootloader hash table")
manifest["embedded_bootloader"] = {
"release": VERSION, "profile": profile["id"],
"candidate_sha256": hashlib.sha256(candidate).hexdigest(),
"asset_sha256": profile["files"]["zip"]["sha256"],
"manifest_sha256": MANIFEST_SHA256,
"legacy_sha256": metadata.get("legacy_sha256"),
"stock_legacy": stock_legacy, "stock_legacy_offset": stock_offset,
"app_offset": offset, "image_bytes": BOOT_SIZE,
"activation": "ota bootloader update",
"automatic_delay_seconds": 70,
+28 -14
View File
@@ -87,22 +87,24 @@ bool installed_up_to_date() {
migration_softdevice_fwid);
}
bool installed_allows_automatic() {
bool qualified_legacy_hash(const uint8_t digest[32]) {
return migration::legacy_digest_allowed(digest, migration_stock_sha256,
migration_stock_count) ||
(migration_has_legacy && memcmp(digest, migration_legacy_sha256, 32) == 0);
}
bool installed_allows_update() {
const uint8_t *boot = reinterpret_cast<const uint8_t *>(migration::BOOT_BASE);
OtaBootloaderIdentity installed;
const bool valid = ota_bootloader_identity_from_image(boot, migration::BOOT_SIZE, installed);
automatic_variant = migration::installed_variant(boot, migration::BOOT_SIZE,
valid ? installed.boot_version : 0);
if (automatic_variant == migration::InstalledVariant::Recovery) return false;
if (valid && automatic_variant == migration::InstalledVariant::Normal &&
migration::installed_matches_resource(installed, migration_board_id, migration_device_name,
mota_nrf52_app_base(), migration_softdevice_fwid))
return true;
// Only the explicitly pinned legacy bridge may omit modern identity/text.
if (!migration_has_legacy) return false;
uint8_t digest[32];
hash_region(migration::BOOT_BASE, migration::BOOT_SIZE, digest);
return memcmp(digest, migration_legacy_sha256, sizeof(digest)) == 0;
if (!valid) installed = OtaBootloaderIdentity();
uint8_t digest[32] = {};
if (!installed.present) hash_region(migration::BOOT_BASE, migration::BOOT_SIZE, digest);
return migration::installed_source_allowed(installed, automatic_variant,
!installed.present && qualified_legacy_hash(digest), migration_board_id,
migration_device_name, mota_nrf52_app_base(), migration_softdevice_fwid);
}
bool lock_automatic_for_firmware() {
@@ -220,8 +222,13 @@ void install() {
// A damaged or ambiguous modern manifest cannot masquerade as a legacy
// loader. Only the explicitly pinned, byte-exact legacy image is eligible.
if (!valid_identity) installed = OtaBootloaderIdentity();
if (!migration::installed_can_migrate(installed, candidate, migration_has_legacy,
memcmp(digest, migration_legacy_sha256, sizeof(digest)) == 0,
const bool legacy_matches = qualified_legacy_hash(digest);
if (!migration::installed_source_allowed(installed,
migration::installed_variant(boot, migration::BOOT_SIZE,
valid_identity ? installed.boot_version : 0),
legacy_matches, migration_board_id, migration_device_name,
snapshot.app_base, snapshot.softdevice_fwid) ||
!migration::installed_can_migrate(installed, candidate, true, legacy_matches,
snapshot.app_base, snapshot.softdevice_fwid))
fail_and_reset(12);
@@ -318,6 +325,13 @@ bool bootloader_migration_request(const char *confirmation, char *reply, size_t
return true;
}
already_installed = false;
if (!installed_allows_update()) {
loader_excluded = true;
last_result = 12;
result_is_current = true;
snprintf(reply, capacity, "ERR installed bootloader is not a reviewed exact compatible image");
return false;
}
if (!lock_automatic_for_firmware()) {
last_result = 18;
result_is_current = true;
@@ -389,7 +403,7 @@ void bootloader_migration_tick(bool outbound_idle, bool ota_idle) {
automatic_blocked = true;
return;
}
if (!installed_allows_automatic()) {
if (!installed_allows_update()) {
loader_excluded = true;
automatic_blocked = true;
if (!write_reboot_marker(0)) {
+27 -2
View File
@@ -31,7 +31,7 @@ inline bool boot_text_matches(const uint8_t *text, size_t available,
// The caller must separately verify the image CRC, identity and platform.
inline InstalledVariant installed_variant(const uint8_t *image, size_t size,
uint32_t version) {
if (!image || size != BOOT_SIZE || !ota_bootloader_version_valid(version))
if (!image || size != BOOT_SIZE)
return InstalledVariant::Unknown;
static const char header[] = "UF2 Bootloader ";
static const char recovery[] = "R_0x";
@@ -44,6 +44,7 @@ inline InstalledVariant installed_variant(const uint8_t *image, size_t size,
boot_text_matches(image + i, left, recovery_header, sizeof(recovery_header) - 1))
return InstalledVariant::Recovery;
}
if (!ota_bootloader_version_valid(version)) return InstalledVariant::Unknown;
char normal[48], packed[16];
const unsigned preview = version & 0xFFu;
snprintf(normal, sizeof(normal),
@@ -144,7 +145,8 @@ inline bool installed_can_migrate(const OtaBootloaderIdentity &installed,
bool has_legacy, bool legacy_hash_matches,
uint32_t app_base, uint16_t fwid) {
if (!installed.present)
return has_legacy && legacy_hash_matches;
return has_legacy && legacy_hash_matches &&
ota_bootloader_continuity_matches_runtime(candidate, 140, fwid, app_base, 1);
return installed.crc_ok && installed.continuity_present &&
ota_bootloader_identity_matches(installed, candidate) &&
ota_bootloader_continuity_gate(installed, candidate, candidate.boot_version,
@@ -160,6 +162,29 @@ inline bool installed_matches_resource(const OtaBootloaderIdentity &installed,
ota_bootloader_continuity_matches_runtime(installed, 140, fwid, app_base, 1);
}
inline bool legacy_digest_allowed(const uint8_t digest[32],
const uint8_t (*approved)[32], size_t count) {
if (!digest || !approved || count > 32u) return false;
for (size_t i = 0; i < count; ++i)
if (memcmp(digest, approved[i], 32) == 0) return true;
return false;
}
// Automatic, manual arming and the final pre-copy gate use the same source
// policy. Stock names/versions and shared USB IDs are never authorization.
// Valid modern metadata cannot fall through to the legacy exception, even
// when its board, continuity, CRC or version record does not match.
inline bool installed_source_allowed(const OtaBootloaderIdentity &installed,
InstalledVariant variant, bool legacy_hash_matches,
uint32_t board_id, const char name[16],
uint32_t app_base, uint16_t fwid) {
if (variant == InstalledVariant::Recovery) return false;
if (installed.present)
return variant == InstalledVariant::Normal &&
installed_matches_resource(installed, board_id, name, app_base, fwid);
return legacy_hash_matches;
}
inline bool installed_up_to_date(const OtaBootloaderIdentity &installed,
uint32_t board_id, const char name[16],
uint32_t version, uint32_t app_base, uint16_t fwid) {
@@ -0,0 +1,199 @@
"""Stock migration claims must survive packaged application revalidation."""
from copy import deepcopy
import hashlib
import json
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
import zipfile
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
sys.path.insert(0, str(ROOT / "tools/mota"))
import nrf52_embedded_bootloader as embed
from nrf52_legacy_bootloaders import legacy_bootloaders_for
import package_cascade_release as release
import qualify_nrf52_embedded_bootloader as qualifier
from motalib import FwIdent, Nrf52Layout, ensure_endf, ensure_nrf52_layout, target_id_for_env
class StockQualificationTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="embedded-stock-qualification-")
self.addCleanup(self.temp.cleanup)
self.directory = Path(self.temp.name)
self.resource = self.directory / "bootloader-migration-resource"
self.resource.mkdir()
self.target = "RAK_3401_repeater"
self.profile, self.parsed = embed.select_profile(embed.load_manifest(), self.target)
self.candidate = b"\xa5" * embed.BOOT_SIZE
self.stock = legacy_bootloaders_for(self.profile["id"], self.parsed["sourceHardware"],
0x26000, 0xB6)
self.assertTrue(any(item["version"] == "0.4.3" for item in self.stock))
self.hash_table = b"".join(bytes.fromhex(item["image_sha256"]) for item in self.stock)
self.metadata = dict(target=self.target, hardware=self.parsed["sourceHardware"],
role=self.parsed["role"], profile=self.profile["id"], release=embed.VERSION,
manifest_sha256=embed.MANIFEST_SHA256,
asset_sha256=self.profile["files"]["zip"]["sha256"],
candidate_sha256=hashlib.sha256(self.candidate).hexdigest(), legacy_sha256=None,
app_base=0x26000, softdevice_fwid=0xB6, stock_legacy=deepcopy(self.stock))
self.manifest = dict(target=self.target, platform="NRF52_PLATFORM", verified=True)
self.manifest_path = self.directory / "firmware.capabilities.json"
self.manifest_path.write_text(json.dumps(self.manifest))
(self.resource / "BootloaderMigrationResource.bin").write_bytes(self.candidate)
self.write_metadata()
self.write_package()
self.stack = patch.object(qualifier, "download_zip", return_value=b"pinned fixture ZIP")
self.stack.start()
self.addCleanup(self.stack.stop)
self.validation = patch.object(qualifier, "validate_candidate_zip", return_value=(
self.candidate, SimpleNamespace(app_base=0x26000, softdevice_fwid=0xB6)))
self.validation.start()
self.addCleanup(self.validation.stop)
def write_metadata(self):
(self.resource / "BootloaderMigrationResource.json").write_text(json.dumps(self.metadata))
def write_package(self, *, table=None, suffix=b"", body_prefix=b"META"):
table = self.hash_table if table is None else table
body = body_prefix + self.candidate + table + suffix + b"BL embedded 2.4.12\0"
self.application, _ = ensure_endf(ensure_nrf52_layout(body,
Nrf52Layout(0x26000, 0xED000, 0xED000, 0x10)),
FwIdent(0x01180002, target_id_for_env(self.target), "RAK3401"))
with zipfile.ZipFile(self.directory / "firmware.zip", "w") as archive:
archive.writestr("manifest.json", json.dumps({"manifest": {"application": {
"bin_file": "application.bin", "dat_file": "application.dat"}}}))
archive.writestr("application.bin", self.application)
archive.writestr("application.dat", b"test init packet")
def qualify(self):
return qualifier.qualify(self.directory, self.manifest_path)
def public_manifest(self, metadata):
return {**self.manifest, "schema_version": 2,
"artifact_target": self.target + "-with-bootloader-2.4.12-full-ota",
"build_profile": "auto", "ota_update_verified": True,
"embedded_bootloader": metadata, "capabilities": ["bootloader.embedded"],
"verification": [dict(capability="bootloader.embedded", present=True,
source="packaged application")]}
def test_real_rak_stock_inventory_is_proven_in_the_published_body(self):
metadata = self.qualify()
self.assertEqual(metadata["stock_legacy"], self.stock)
self.assertEqual(metadata["stock_legacy_offset"], 4 + embed.BOOT_SIZE)
manifest = self.public_manifest(metadata)
self.assertEqual(release.embedded_bootloader_variant(manifest), embed.VERSION)
release.validate_embedded_bootloader_application(manifest, self.application)
self.assertEqual(json.loads(self.manifest_path.read_text())["embedded_bootloader"], metadata)
def test_sidecar_forgery_wrong_target_and_mutated_inventory_are_refused(self):
original = deepcopy(self.metadata)
changes = [dict(stock_legacy=[]), dict(target="RAK_4631_repeater"),
dict(hardware="RAK_4631"), dict(app_base=0x27000),
dict(softdevice_fwid=0x123), dict(role="companion")]
for key in ("image_sha256", "artifact_sha256", "source_url", "version", "profiles", "hardware"):
stock = deepcopy(self.stock)
stock[0][key] = [] if isinstance(stock[0][key], list) else "forged"
changes.append(dict(stock_legacy=stock))
for change in changes:
with self.subTest(change=change):
self.metadata = {**original, **change}
self.write_metadata()
before = self.manifest_path.read_bytes()
with self.assertRaisesRegex(ValueError, "pinned release"):
self.qualify()
self.assertEqual(self.manifest_path.read_bytes(), before)
def test_missing_reordered_mutated_duplicate_or_unaligned_hash_tables_are_refused(self):
cases = [dict(table=b""), dict(table=self.hash_table[::-1]),
dict(table=bytes([self.hash_table[0] ^ 1]) + self.hash_table[1:]),
dict(suffix=self.hash_table), dict(body_prefix=b"META!")]
for options in cases:
with self.subTest(options=options):
self.write_package(**options)
before = self.manifest_path.read_bytes()
with self.assertRaisesRegex(ValueError, "aligned embedded|stock bootloader hash table"):
self.qualify()
self.assertEqual(self.manifest_path.read_bytes(), before)
def test_public_claims_are_exact_and_malformed_offsets_never_authorize_support(self):
metadata = self.qualify()
original = self.public_manifest(metadata)
changes = [dict(stock_legacy=[]), dict(stock_legacy=list(reversed(self.stock))),
dict(stock_legacy_offset=None), dict(stock_legacy_offset=True),
dict(stock_legacy_offset=-4), dict(stock_legacy_offset=1)]
wrong = deepcopy(self.stock)
wrong[0]["hardware"] = ["RAK_4631"]
changes.append(dict(stock_legacy=wrong))
for change in changes:
with self.subTest(change=change):
manifest = deepcopy(original)
manifest["embedded_bootloader"].update(change)
with self.assertRaisesRegex(ValueError, "pinned release"):
release.embedded_bootloader_variant(manifest)
historical = deepcopy(original)
del historical["embedded_bootloader"]["stock_legacy"]
del historical["embedded_bootloader"]["stock_legacy_offset"]
self.assertEqual(release.embedded_bootloader_variant(historical), embed.VERSION)
def test_release_rechecks_the_actual_package_instead_of_trusting_qualification_json(self):
metadata = self.qualify()
manifest = self.public_manifest(metadata)
self.write_package(table=b"\0" * len(self.hash_table))
with self.assertRaisesRegex(ValueError, "stock bootloader hash table"):
release.validate_embedded_bootloader_application(manifest, self.application)
for offset in (metadata["stock_legacy_offset"] + 4, 4, len(self.application) + 4):
changed = deepcopy(manifest)
changed["embedded_bootloader"]["stock_legacy_offset"] = offset
with self.subTest(offset=offset), self.assertRaisesRegex(ValueError, "stock bootloader hash table"):
release.validate_embedded_bootloader_application(changed, self.application)
def test_full_companion_and_sensor_still_cannot_claim_embedded_bootloader(self):
metadata = self.qualify()
for target in ("RAK_3401_companion_radio_full", "RAK_3401_sensor"):
manifest = self.public_manifest(metadata)
manifest["target"] = target
with self.subTest(target=target), self.assertRaisesRegex(ValueError, "exact supported profile"):
release.embedded_bootloader_variant(manifest)
def test_sensor_pair_publication_reads_hashes_from_each_actual_dfu_zip(self):
metadata = self.qualify()
records = []
for sensor in ("full", "reduced"):
manifest = self.public_manifest(deepcopy(metadata))
manifest["artifact_target"] = self.target + "-with-bootloader-2.4.12-" + sensor + "-ota"
manifest["capabilities"] += ["sensor.profile." + sensor, "ota.update.lora"]
manifest["ota_update_methods"] = ["lora"]
manifest["verification"].append(dict(capability="ota.update.lora", present=True,
source="packaged application"))
path = self.directory / (manifest["artifact_target"] + "-v1.18.0.2-test.zip")
path.write_bytes((self.directory / "firmware.zip").read_bytes())
records.append(dict(manifest=manifest, files=[path]))
release.validate_nrf52_sensor_pairs(records)
# Keep the valid qualification metadata but replace only the package's
# table and recompute EndF. Metadata is not sufficient publication proof.
self.write_package(table=b"\0" * len(self.hash_table))
records[1]["files"][0].write_bytes((self.directory / "firmware.zip").read_bytes())
with self.assertRaisesRegex(ValueError, "stock bootloader hash table"):
release.validate_nrf52_sensor_pairs(records)
def test_candidate_bytes_cannot_impersonate_the_separate_runtime_hash_table(self):
metadata = self.qualify()
manifest = self.public_manifest(deepcopy(metadata))
candidate = self.hash_table + self.candidate[len(self.hash_table):]
body = b"META" + candidate + b"BL embedded 2.4.12\0"
application, _ = ensure_endf(ensure_nrf52_layout(body,
Nrf52Layout(0x26000, 0xED000, 0xED000, 0x10)),
FwIdent(0x01180002, target_id_for_env(self.target), "RAK3401"))
manifest["embedded_bootloader"].update(
candidate_sha256=hashlib.sha256(candidate).hexdigest(), stock_legacy_offset=4)
with self.assertRaisesRegex(ValueError, "stock bootloader hash table"):
release.validate_embedded_bootloader_application(manifest, application)
if __name__ == "__main__":
unittest.main()
+20 -1
View File
@@ -22,6 +22,7 @@ ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
sys.path.insert(0, str(ROOT / "tools/mota"))
import nrf52_embedded_bootloader as EMBED
from nrf52_legacy_bootloaders import legacy_bootloaders_for
import motalib
@@ -138,6 +139,9 @@ class EmbeddedBootloaderTest(unittest.TestCase):
self.assertEqual(result["version"], "2.4.12")
self.assertEqual(result["softdevice_fwid"], 0x123 if v7 else 0xB6)
self.assertEqual(result["app_base"], 0x27000 if v7 else 0x26000)
expected_stock = legacy_bootloaders_for(result["profile"], result["hardware"],
result["app_base"], result["softdevice_fwid"])
self.assertEqual(result["stock_legacy"], expected_stock)
self.assertEqual(result, json.loads((output / "BootloaderMigrationResource.json").read_text()))
harness = output / "main.cpp"
harness.write_text('''#include "BootloaderMigrationResource.h"
@@ -159,10 +163,15 @@ int main() {
strcmp(migration_device_name, "%s") ||
memcmp(expected_hash, migration_candidate_sha256, 32)) return 1;
for (uint8_t byte : migration_legacy_sha256) if (byte) return 2;
const uint8_t stock_hashes[][32] = {%s};
if (migration_stock_count != %d ||
memcmp(stock_hashes, migration_stock_sha256, sizeof(stock_hashes))) return 4;
return fwrite(migration_candidate, 1, 40960, stdout) == 40960 ? 0 : 3;
}
''' % (",".join(map(str, hashlib.sha256(image).digest())), result["app_base"],
result["softdevice_fwid"], result["board_id"], EMBED.BOOT_VERSION, result["device_name"]))
result["softdevice_fwid"], result["board_id"], EMBED.BOOT_VERSION, result["device_name"],
",".join("{" + ",".join(map(str, bytes.fromhex(item["image_sha256"]))) + "}"
for item in expected_stock), len(expected_stock)))
executable = output / ("resource.exe" if os.name == "nt" else "resource")
subprocess.run([compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror",
str(output / "BootloaderMigrationResource.cpp"), str(harness),
@@ -179,6 +188,16 @@ int main() {
with self.assertRaisesRegex(ValueError, "release identity"):
EMBED.load_manifest(self.project)
def test_no_reviewed_stock_image_emits_an_inert_zero_count_resource(self):
with patch.object(EMBED, "legacy_bootloaders_for", return_value=[]):
result = self.emit()
self.assertEqual(result["stock_legacy"], [])
source = (self.project / "generated/BootloaderMigrationResource.cpp").read_text()
self.assertIn("const uint32_t migration_stock_count = 0;", source)
self.assertIn("alignas(4) const uint8_t migration_stock_sha256[1][32]", source)
row = source.split("migration_stock_sha256[1][32] = {\n", 1)[1].split("};", 1)[0]
self.assertEqual(row.count("0x00"), 32)
def test_whole_zip_pin_and_internal_crc_are_independent_checks(self):
good = candidate(boot_image())
with self.assertRaisesRegex(ValueError, "pinned release"):
+156
View File
@@ -0,0 +1,156 @@
"""Exact-image stock bootstrap inventory and the shared C++ migration gate."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from nrf52_legacy_bootloaders import _RECORDS, legacy_bootloaders_for
from cpp_source import body
SOURCE = r'''
#include <cassert>
#include <cstring>
#include <vector>
#include "helpers/ota/BootloaderMigrationPolicy.h"
using namespace mesh::ota;
using namespace mesh::ota::migration;
int main() {
const uint8_t approved[][32] = {{1,2,3}, {4,5,6}};
uint8_t digest[32]; std::memcpy(digest,approved[1],32);
assert(legacy_digest_allowed(digest,approved,2));
assert(!legacy_digest_allowed(digest,approved,1));
assert(!legacy_digest_allowed(digest,approved,0));
assert(!legacy_digest_allowed(digest,approved,33));
assert(!legacy_digest_allowed(nullptr,approved,2));
assert(!legacy_digest_allowed(digest,nullptr,2));
for (unsigned i=0;i<32;++i) {
digest[i]^=1; assert(!legacy_digest_allowed(digest,approved,2)); digest[i]^=1;
}
OtaBootloaderIdentity stock,modern;
modern.present=modern.crc_ok=modern.continuity_present=true;
modern.image_start=BOOT_BASE; modern.image_size=BOOT_SIZE;
modern.board_id=0x239A0029; std::strcpy(modern.device_name,"4631_DFU");
modern.boot_version=0x02040BFF; modern.softdevice_family=140;
modern.softdevice_fwid=0xB6; modern.app_base=0x26000; modern.layout_abi=1;
auto candidate=modern; candidate.boot_version=0x02040CFF;
const char name[16]="4631_DFU";
assert(installed_source_allowed(stock,InstalledVariant::Unknown,true,0x239A0029,name,0x26000,0xB6));
assert(!installed_source_allowed(stock,InstalledVariant::Unknown,false,0x239A0029,name,0x26000,0xB6));
assert(!installed_source_allowed(stock,InstalledVariant::Recovery,true,0x239A0029,name,0x26000,0xB6));
assert(installed_can_migrate(stock,candidate,true,true,0x26000,0xB6));
assert(!installed_can_migrate(stock,candidate,false,true,0x26000,0xB6));
assert(!installed_can_migrate(stock,candidate,true,false,0x26000,0xB6));
assert(!installed_can_migrate(stock,candidate,true,true,0x27000,0x123));
assert(installed_source_allowed(modern,InstalledVariant::Normal,false,0x239A0029,name,0x26000,0xB6));
assert(!installed_source_allowed(modern,InstalledVariant::Recovery,true,0x239A0029,name,0x26000,0xB6));
assert(!installed_source_allowed(modern,InstalledVariant::Unknown,true,0x239A0029,name,0x26000,0xB6));
for (unsigned fault=0;fault<6;++fault) {
auto wrong=modern;
if(fault==0)wrong.crc_ok=false;
if(fault==1)wrong.board_id=0x28860044;
if(fault==2)std::strcpy(wrong.device_name,"3401_DFU");
if(fault==3)wrong.continuity_present=false;
if(fault==4)wrong.app_base=0x27000;
if(fault==5)wrong.softdevice_fwid=0x123;
assert(!installed_source_allowed(wrong,InstalledVariant::Normal,true,0x239A0029,name,0x26000,0xB6));
}
std::vector<uint8_t> image(BOOT_SIZE,0xFF);
std::memcpy(image.data()+10,"UF2 Bootloader 0.4.3\r\n",22);
assert(installed_variant(image.data(),image.size(),0)==InstalledVariant::Unknown);
std::memcpy(image.data()+100,"R_0x02040CFF",12);
assert(installed_variant(image.data(),image.size(),0)==InstalledVariant::Recovery);
}
'''
UTILS = '''#pragma once
#include <stdint.h>
#include <stddef.h>
namespace mesh { class Utils { public:
static void sha256(uint8_t*,size_t,const uint8_t*,int);
static void sha256(uint8_t*,size_t,const uint8_t*,int,const uint8_t*,int);
}; }
'''
class LegacyBootloaderTests(unittest.TestCase):
def test_every_record_matches_independent_public_stock_reader_audit(self):
artifacts = json.loads((ROOT / "test/fixtures/nrf52_bootloader_version/stock_images.json").read_text())["artifacts"]
self.assertEqual(len(_RECORDS), 16)
for record in _RECORDS:
matches = [item for item in artifacts if item["url"] == record["source_url"]]
self.assertEqual(len(matches), 1, record["name"])
source = matches[0]
self.assertEqual(record["version"], source["version"])
self.assertEqual(record["image_sha256"], source["image_sha256"])
self.assertEqual(record["artifact_sha256"], source["sha256"])
self.assertEqual((source["start"], source["size"]), ("0xf4000", 40960))
def test_factory_rak_revisions_work_for_exact_3401_and_4631_not_tag_or_neo(self):
for hardware, profile in (("RAK_3401", "wiscore_rak3401_auto"),
("RAK_4631", "wiscore_rak4631_auto")):
records = legacy_bootloaders_for(profile, hardware, 0x26000, 0xB6)
self.assertEqual(len(records), 5)
stock = next(item for item in records if item["version"] == "0.4.3")
self.assertEqual(stock["image_sha256"], "6cbfa569a05cbd232dabe4a654abe9c04b7a03ab457615028d28f2d1329e58d2")
neo = legacy_bootloaders_for("wiscore_rak4631_auto", "R1Neo", 0x26000, 0xB6)
self.assertEqual(len(neo), 1)
self.assertIn("muzi-works", neo[0]["source_url"])
self.assertEqual(legacy_bootloaders_for("wismesh_tag", "RAK_WisMesh_Tag", 0x26000, 0xB6), [])
self.assertEqual(legacy_bootloaders_for("wiscore_rak4631_auto", "RAK_3401", 0x26000, 0xB6), [])
self.assertEqual(legacy_bootloaders_for("wiscore_rak3401_auto", "RAK_4631", 0x26000, 0xB6), [])
def test_shared_module_or_wrong_softdevice_cannot_inherit_a_stock_hash(self):
for record in _RECORDS:
profile, hardware = record["profiles"][0], record["hardware"][0]
self.assertEqual(legacy_bootloaders_for(profile, hardware, record["app_base"] ^ 0x1000, record["softdevice_fwid"]), [])
self.assertEqual(legacy_bootloaders_for(profile, hardware, record["app_base"], record["softdevice_fwid"] ^ 1), [])
self.assertEqual(legacy_bootloaders_for(profile, "Unreviewed", record["app_base"], record["softdevice_fwid"]), [])
for profile, hardware, base, fwid in (
("heltec_t096", "Heltec_t096", 0x26000, 0xB6),
("xiao_nrf52840_ble", "ikoka_stick_nrf_33dbm", 0x27000, 0x123),
("wio_tracker_l1", "WioTrackerL1-1W", 0x27000, 0x123),
("sensecap_solar_p1", "SenseCap_Solar", 0x27000, 0x123)):
self.assertEqual(legacy_bootloaders_for(profile, hardware, base, fwid), [])
def test_registry_results_are_not_mutable_global_authorization(self):
result = legacy_bootloaders_for("wiscore_rak3401_auto", "RAK_3401", 0x26000, 0xB6)
result[0]["hardware"].append("OTHER")
result[0]["image_sha256"] = "0" * 64
again = legacy_bootloaders_for("wiscore_rak3401_auto", "RAK_3401", 0x26000, 0xB6)
self.assertNotIn("OTHER", again[0]["hardware"])
self.assertNotEqual(again[0]["image_sha256"], "0" * 64)
def test_manual_and_automatic_requests_share_the_source_gate_and_final_copy_rechecks(self):
source = (ROOT / "src/helpers/ota/BootloaderMigration.cpp").read_text()
self.assertIn("installed_allows_update()", body(source, "void bootloader_migration_tick("))
request = body(source, "bool bootloader_migration_request(")
self.assertLess(request.index("installed_allows_update()"), request.index("lock_automatic_for_firmware()"))
install = body(source, "void install()")
self.assertIn("installed_source_allowed(", install)
self.assertIn("installed_can_migrate(", install)
self.assertLess(install.index("installed_source_allowed("), install.index("SD_MBR_COMMAND_COPY_BL"))
def test_real_cpp_policy_keeps_modern_identity_recovery_and_layout_guards(self):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "host C++ compiler required")
with tempfile.TemporaryDirectory(prefix="stock-bootloader-policy-") as directory:
path = Path(directory)
(path / "Utils.h").write_text(UTILS)
(path / "test.cpp").write_text(SOURCE)
binary = path / ("policy.exe" if os.name == "nt" else "policy")
sanitize = [] if os.name == "nt" else ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
subprocess.run([compiler, "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", *sanitize,
"-I", str(path), "-I", str(ROOT / "src"), "-I", str(ROOT / "src/helpers/ota"),
str(path / "test.cpp"), "-o", str(binary)], check=True, capture_output=True, text=True)
subprocess.run([str(binary)], check=True, capture_output=True, text=True)
if __name__ == "__main__":
unittest.main()
@@ -3,7 +3,8 @@
The ARM SVC adapter is replaced by a failing trap, and the EndF stub refuses
installation before hardware access. Real CRC-bound installed manifests test
the version check; a controlled digest exercises the exact candidate no-op.
the version check; controlled digests exercise the exact candidate no-op and
reviewed-stock source gate (not the SHA-256 primitive itself).
This tests scheduling and cancellation, not cryptography, MBR copying or
power-loss recovery; those require separate qualification. The durable-store
boundary is modeled here and tested against real file faults separately.
@@ -41,13 +42,23 @@ class MigrationRuntimeTests(unittest.TestCase):
def run_scenario(self, scenario):
subprocess.run([str(self.binary), scenario], check=True)
def test_recovery_unknown_or_damaged_bootloaders_require_an_explicit_command(self):
def test_recovery_unknown_or_damaged_bootloaders_refuse_manual_and_automatic_commands(self):
for scenario in ('automatic-recovery-prefix', 'automatic-recovery-suffix',
'automatic-unlabelled-loader', 'automatic-ambiguous-loader',
'automatic-foreign-loader', 'automatic-damaged-loader'):
with self.subTest(scenario=scenario):
self.run_scenario(scenario)
def test_reviewed_stock_loader_arms_manually_or_at_70_seconds_without_legacy_file(self):
for scenario in ('stock-automatic', 'stock-manual'):
with self.subTest(scenario=scenario):
self.run_scenario(scenario)
def test_stock_hash_is_not_a_modern_identity_or_recovery_override(self):
for scenario in ('stock-damaged-hash', 'stock-recovery', 'stock-modern-mismatch'):
with self.subTest(scenario=scenario):
self.run_scenario(scenario)
def test_same_firmware_remains_locked_after_a_cold_boot(self):
for scenario in ('durable-same-firmware-cold-boot',
'durable-same-firmware-old-loader'):
@@ -4,6 +4,8 @@
extern const uint8_t migration_candidate[40960];
extern const uint8_t migration_candidate_sha256[32];
extern const uint8_t migration_legacy_sha256[32];
extern const uint8_t migration_stock_sha256[1][32];
extern const uint32_t migration_stock_count;
extern const bool migration_has_legacy;
extern const uint32_t migration_app_base, migration_boot_version, migration_board_id;
extern const uint16_t migration_softdevice_fwid;
+58 -6
View File
@@ -34,6 +34,8 @@ unsigned retained_reads = 0;
unsigned retained_clears = 0;
unsigned retained_sets = 0;
bool candidate_installed = false;
bool stock_installed = false;
bool stock_hash_damaged = false;
bool interrupt_hash = false;
bool interrupt_classification = false;
unsigned classification_checks = 0;
@@ -89,6 +91,12 @@ InstalledVariant installed_variant_for_runtime(const uint8_t *image, size_t size
const uint8_t migration_candidate[40960] = {};
const uint8_t migration_candidate_sha256[32] = { 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF };
const uint8_t migration_legacy_sha256[32] = {};
// The real registry's stock RAK 0.4.3 image identity; the SHA stub below
// controls whether that digest is observed, not the cryptographic primitive.
const uint8_t migration_stock_sha256[1][32] = {{
0x6c,0xbf,0xa5,0x69,0xa0,0x5c,0xbd,0x23,0x2d,0xab,0xe4,0xa6,0x54,0xab,0xe9,0xc0,
0x4b,0x7a,0x03,0xab,0x45,0x76,0x15,0x02,0x8d,0x28,0xf2,0xd1,0x32,0x9e,0x58,0xd2}};
const uint32_t migration_stock_count = 1;
const bool migration_has_legacy = false;
const uint32_t migration_app_base = 0x26000, migration_boot_version = 0x02040CFF,
migration_board_id = 0x239A0029;
@@ -168,6 +176,10 @@ void SHA256::finalize(void *out, size_t size) {
std::memcpy(out, migration_test::application_hash, size);
else if (migration_test::candidate_installed)
std::memcpy(out, migration_candidate_sha256, size);
else if (migration_test::stock_installed) {
std::memcpy(out, migration_stock_sha256[0], size);
if (migration_test::stock_hash_damaged) static_cast<uint8_t *>(out)[31] ^= 1;
}
}
namespace mesh {
@@ -373,11 +385,50 @@ int main(int argc, char **argv) {
now = 140000;
bootloader_migration_tick(true, true);
no_install(softdevice_checks);
arm(); // Recovery/unknown loaders remain an explicit operator decision.
assert(!bootloader_migration_request(nullptr, reply, sizeof(reply)));
assert(!bootloader_migration_pending());
assert(std::strstr(reply, "not a reviewed exact compatible image"));
assert(durable_lock_present && store_writes == 1);
now = 141500;
bootloader_migration_tick(true, true);
assert(endf_checks == 1);
assert(endf_checks == 0); // Explicit commands cannot bypass source identity.
} else if (std::strncmp(scenario, "stock-", 6) == 0) {
auto *boot = reinterpret_cast<uint8_t *>(migration::BOOT_BASE);
std::memset(boot, 0xFF, migration::BOOT_SIZE);
std::strcpy(reinterpret_cast<char *>(boot + 0x100), "UF2 Bootloader 0.4.3\r\n");
stock_installed = true;
const bool automatic = std::strcmp(scenario, "stock-automatic") == 0;
const bool manual = std::strcmp(scenario, "stock-manual") == 0;
if (std::strcmp(scenario, "stock-damaged-hash") == 0) stock_hash_damaged = true;
else if (std::strcmp(scenario, "stock-recovery") == 0)
std::strcpy(reinterpret_cast<char *>(boot + 0x100), "UF2 Bootloader R_0x02040BFF\r\n");
else if (std::strcmp(scenario, "stock-modern-mismatch") == 0) {
auto *p = installed_manifest(0x02040BFF);
p[24] = 'X'; seal_manifest(p);
} else assert(automatic || manual);
if (automatic) {
now = 69999; bootloader_migration_tick(true, true);
assert(!bootloader_migration_pending());
now = 70000; bootloader_migration_tick(true, true);
} else {
const bool accepted = bootloader_migration_request(nullptr, reply, sizeof(reply));
assert(accepted == manual);
}
assert(bootloader_migration_pending() == (automatic || manual));
if (automatic || manual) {
assert(durable_lock_present && store_writes == 1);
assert(power_registers.GPREGRET == mesh::ota::ATTEMPT_MARKER);
now += 1499; bootloader_migration_tick(true, true);
assert(bootloader_migration_pending() && endf_checks == 0);
++now; bootloader_migration_tick(true, true);
assert(!bootloader_migration_pending() && endf_checks == 1);
} else {
assert(!durable_lock_present && store_writes == 0);
assert(std::strstr(reply, "not a reviewed exact compatible image"));
now = 70000; bootloader_migration_tick(true, true);
assert(!bootloader_migration_pending());
assert(endf_checks == 0);
}
} else if (std::strcmp(scenario, "previous-image-interrupted-classification") == 0) {
durable_lock_present = true;
std::memcpy(durable_lock_hash, application_hash, 32);
@@ -785,13 +836,14 @@ int main(int argc, char **argv) {
if (std::strcmp(mutation, "crc") == 0) p[40] ^= 1;
bootloader_migration_status(reply, sizeof(reply));
assert(!std::strstr(reply, "up to date"));
assert(bootloader_migration_request(nullptr, reply, sizeof(reply)));
assert(bootloader_migration_pending());
assert(!bootloader_migration_request(nullptr, reply, sizeof(reply)));
assert(!bootloader_migration_pending());
assert(std::strstr(reply, "not a reviewed exact compatible image"));
assert(!std::strstr(reply, "up to date"));
now = 1500;
bootloader_migration_tick(true, true);
// The production install preflight still runs, and the EndF stub refuses it.
assert(endf_checks == 1);
// Source rejection happens while the application/SoftDevice stays running.
assert(endf_checks == 0);
assert(!bootloader_migration_pending());
} else if (std::strcmp(scenario, "wrong-confirmation") == 0) {
assert(!bootloader_migration_request("0123456789ABCDE0", reply, sizeof(reply)));