diff --git a/docs/nrf52_embedded_bootloader.md b/docs/nrf52_embedded_bootloader.md index afbcc95d4..0ab03e69e 100644 --- a/docs/nrf52_embedded_bootloader.md +++ b/docs/nrf52_embedded_bootloader.md @@ -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 diff --git a/scripts/nrf52_embedded_bootloader.py b/scripts/nrf52_embedded_bootloader.py index f193b6f12..7785ed152 100644 --- a/scripts/nrf52_embedded_bootloader.py +++ b/scripts/nrf52_embedded_bootloader.py @@ -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} diff --git a/scripts/nrf52_legacy_bootloaders.py b/scripts/nrf52_legacy_bootloaders.py new file mode 100644 index 000000000..89f5544be --- /dev/null +++ b/scripts/nrf52_legacy_bootloaders.py @@ -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 diff --git a/scripts/package_cascade_release.py b/scripts/package_cascade_release.py index 75a1352a6..a556a0fd6 100644 --- a/scripts/package_cascade_release.py +++ b/scripts/package_cascade_release.py @@ -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) diff --git a/scripts/qualify_nrf52_embedded_bootloader.py b/scripts/qualify_nrf52_embedded_bootloader.py index 76fcce4a4..b38074d91 100644 --- a/scripts/qualify_nrf52_embedded_bootloader.py +++ b/scripts/qualify_nrf52_embedded_bootloader.py @@ -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, diff --git a/src/helpers/ota/BootloaderMigration.cpp b/src/helpers/ota/BootloaderMigration.cpp index 1675ac3e7..0126deab0 100644 --- a/src/helpers/ota/BootloaderMigration.cpp +++ b/src/helpers/ota/BootloaderMigration.cpp @@ -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(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)) { diff --git a/src/helpers/ota/BootloaderMigrationPolicy.h b/src/helpers/ota/BootloaderMigrationPolicy.h index 078ec06d3..59a4f67a9 100644 --- a/src/helpers/ota/BootloaderMigrationPolicy.h +++ b/src/helpers/ota/BootloaderMigrationPolicy.h @@ -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) { diff --git a/test/test_embedded_bootloader_stock_qualification.py b/test/test_embedded_bootloader_stock_qualification.py new file mode 100644 index 000000000..9fc817f64 --- /dev/null +++ b/test/test_embedded_bootloader_stock_qualification.py @@ -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() diff --git a/test/test_nrf52_embedded_bootloader.py b/test/test_nrf52_embedded_bootloader.py index 2a4c4f018..5ae8dd0c9 100644 --- a/test/test_nrf52_embedded_bootloader.py +++ b/test/test_nrf52_embedded_bootloader.py @@ -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"): diff --git a/test/test_nrf52_legacy_bootloaders.py b/test/test_nrf52_legacy_bootloaders.py new file mode 100644 index 000000000..eb0b6cf4f --- /dev/null +++ b/test/test_nrf52_legacy_bootloaders.py @@ -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 +#include +#include +#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 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 +#include +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() diff --git a/tools/mota/test_bootloader_migration_runtime.py b/tools/mota/test_bootloader_migration_runtime.py index 381b09773..f08394535 100644 --- a/tools/mota/test_bootloader_migration_runtime.py +++ b/tools/mota/test_bootloader_migration_runtime.py @@ -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'): diff --git a/tools/mota/tests/migration_stubs/BootloaderMigrationResource.h b/tools/mota/tests/migration_stubs/BootloaderMigrationResource.h index 787477b37..51f87cfdd 100644 --- a/tools/mota/tests/migration_stubs/BootloaderMigrationResource.h +++ b/tools/mota/tests/migration_stubs/BootloaderMigrationResource.h @@ -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; diff --git a/tools/mota/tests/migration_stubs/runtime.cpp b/tools/mota/tests/migration_stubs/runtime.cpp index c6016ceac..f82f47a15 100644 --- a/tools/mota/tests/migration_stubs/runtime.cpp +++ b/tools/mota/tests/migration_stubs/runtime.cpp @@ -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(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(migration::BOOT_BASE); + std::memset(boot, 0xFF, migration::BOOT_SIZE); + std::strcpy(reinterpret_cast(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(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)));