mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-11-04 08:37:53 +00:00
Support reviewed stock nRF52 bootloaders in embedded updates
This commit is contained in:
1 parent
54d5d97cc1
commit
f7793ee63f
13 files changed
+936
-38
No files matched your search
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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()
|
||||
@@ -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"):
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Reference in new issue
Block a user