diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 94e571c23..ab807497d 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -262,16 +262,21 @@ jobs: - name: Verify bootloader version regressions without hardware or downloads run: python3 -B test/test_nrf52_bootloader_version.py -v + - name: Install host identity test dependencies + run: | + python3 -m pip install cryptography + sudo apt-get update + sudo apt-get install -y libssl-dev + - name: Verify embedded bootloader variant and guarded activation run: | python3 -B test/test_nrf52_embedded_variant.py -v python3 -B test/test_nrf52_embedded_bootloader.py -v + python3 -B test/test_sensecap_bootloader_identity.py -v + python3 -B test/test_sensecap_bootloader_runtime.py -v python3 -B tools/mota/test_bootloader_migration.py -v python3 -B tools/mota/test_bootloader_migration_runtime.py -v - - name: Install signed-package verification dependency - run: python3 -m pip install cryptography - - name: Verify old-firmware compatibility in both OTA launchers run: python3 -B -m unittest discover -s tools/lora_ota -p test_lora_ota_bootloader_version.py -v diff --git a/docs/nrf52_embedded_bootloader.md b/docs/nrf52_embedded_bootloader.md index a522289b1..5f446176e 100644 --- a/docs/nrf52_embedded_bootloader.md +++ b/docs/nrf52_embedded_bootloader.md @@ -34,8 +34,9 @@ MESHCORE_NRF52_EMBED_BOOTLOADER=2.4.12 pio run -e TARGET The generator matches the board and storage to the 2.4.12 catalog and verifies the release asset before embedding only its 40 KiB bootloader region. Host -validation covers 22 profiles. SenseCAP Solar P1 is excluded because its shared -XIAO board ID and different device name need separate identity support. +validation covers all 23 published profiles, including SenseCAP Solar P1. +P1 uses its exact `SCAP_DFU` identity and S140 v7 layout; XIAO's `XIAO_DFU` +identity remains separate despite the shared USB board ID. Unsupported profiles are refused. The usual application, capacity and memory checks still apply: the resource adds 40 KiB plus update code, so some profiles will not fit. The build does not remove features or @@ -60,6 +61,12 @@ 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. +A P1 already running a XIAO bootloader needs a deliberate identity migration; +the embedded installer does not change one board identity into another. Use +the documented temporary recovery bridge for the installed identity, followed +by the normal P1 bootloader. The all-in-one P1 variant embeds the normal 2.4.12 +P1 image, not a recovery image. + ## Install and activate 1. Install the application through its normal USB UF2 or serial/Bluetooth DFU diff --git a/src/helpers/Nrf52BootloaderVersion.h b/src/helpers/Nrf52BootloaderVersion.h index 33c2d0013..7067513ba 100644 --- a/src/helpers/Nrf52BootloaderVersion.h +++ b/src/helpers/Nrf52BootloaderVersion.h @@ -141,16 +141,10 @@ inline bool nrf52BootloaderVersion(const uint8_t* image, size_t size, identity.continuity_present) { const uint32_t version = identity.boot_version; const unsigned preview = version & 0xffu; - int written; - if (preview == 0xffu) { - written = snprintf(out, capacity, "OTAFIX%u.%u.%u", - (unsigned)(version >> 24), (unsigned)((version >> 16) & 0xffu), - (unsigned)((version >> 8) & 0xffu)); - } else { - written = snprintf(out, capacity, "OTAFIX%u.%u.%u-preview.%u", - (unsigned)(version >> 24), (unsigned)((version >> 16) & 0xffu), - (unsigned)((version >> 8) & 0xffu), preview); - } + const int written = snprintf(out, capacity, + preview == 0xffu ? "OTAFIX%u.%u.%u" : "OTAFIX%u.%u.%u-preview.%u", + (unsigned)(version >> 24), (unsigned)((version >> 16) & 0xffu), + (unsigned)((version >> 8) & 0xffu), preview); if (written > 0 && (size_t)written < capacity) return true; out[0] = 0; return false; @@ -168,7 +162,6 @@ inline bool nrf52BootloaderVersion(const uint8_t* image, size_t size, const bool magic0 = ota::ota_boot_rd32(image + offset) == ota::OTA_BOOT_MANIFEST_MAGIC0; const bool magic1 = ota::ota_boot_rd32(image + offset + 4u) == ota::OTA_BOOT_MANIFEST_MAGIC1; if (magic0 || magic1) { - ota::OtaBootloaderIdentity continuity; allow_standalone = magic0 && magic1 && ota::ota_boot_rd16(image + offset + 8u) == ota::OTA_BOOT_MANIFEST_VERSION && @@ -179,8 +172,8 @@ inline bool nrf52BootloaderVersion(const uint8_t* image, size_t size, ota::ota_boot_image_crc32(image, size, offset + 40u) == ota::ota_boot_rd32(image + offset + 40u) && ota::ota_bootloader_continuity_parse(image + offset, size - offset, - continuity) && - continuity.continuity_present; + identity) && + identity.continuity_present; } } const size_t scan_size = size < ota::OTA_BOOT_IMAGE_SIZE ? size : ota::OTA_BOOT_IMAGE_SIZE; diff --git a/src/helpers/ota/OtaBootloaderUpdate.h b/src/helpers/ota/OtaBootloaderUpdate.h index c14400300..63466bc1b 100644 --- a/src/helpers/ota/OtaBootloaderUpdate.h +++ b/src/helpers/ota/OtaBootloaderUpdate.h @@ -143,13 +143,16 @@ inline bool ota_bootloader_live_bank_preserves_scratch(uint32_t app_base, } #endif -// Only the two official XIAO nRF52840 OTAFIX identities are eligible. Runtime matching against the -// installed, CRC-valid manifest distinguishes the base and Sense modules even for carrier-board aliases. +// XIAO's deployed wire IDs remain stable. SenseCAP Solar P1 shares the base +// VID/PID-derived board ID, so its distinct device name is part of its identity. static const uint32_t OTA_XIAO_BOARD_ID_BASE = 0x28860044UL; static const uint32_t OTA_XIAO_BOARD_ID_SENSE = 0x28860045UL; static const uint8_t OTA_XIAO_BOOT_DEVICE_NAME[OTA_BOOT_DEVICE_NAME_SIZE] = { 'X','I','A','O','_','D','F','U', 0,0,0,0,0,0,0,0 }; +static const uint8_t OTA_SENSECAP_BOOT_DEVICE_NAME[OTA_BOOT_DEVICE_NAME_SIZE] = { + 'S','C','A','P','_','D','F','U', 0,0,0,0,0,0,0,0 +}; struct OtaBootloaderIdentity { bool present = false; @@ -204,6 +207,11 @@ inline bool ota_xiao_bootloader_board_id(uint32_t board_id) { return board_id == OTA_XIAO_BOARD_ID_BASE || board_id == OTA_XIAO_BOARD_ID_SENSE; } +inline bool ota_xiao_bootloader_identity(uint32_t board_id, const uint8_t name[16]) { + return ota_xiao_bootloader_board_id(board_id) && name && + memcmp(name, OTA_XIAO_BOOT_DEVICE_NAME, OTA_BOOT_DEVICE_NAME_SIZE) == 0; +} + inline bool ota_bootloader_board_id_valid(uint32_t board_id) { return board_id != 0u && board_id != UINT32_MAX; } @@ -213,8 +221,13 @@ inline bool ota_bootloader_board_id_valid(uint32_t board_id) { // identities retain their exact legacy name and signed hardware IDs. inline bool ota_bootloader_device_name_valid(uint32_t board_id, const uint8_t name[16]) { if (!name) return false; - if (ota_xiao_bootloader_board_id(board_id)) - return memcmp(name, OTA_XIAO_BOOT_DEVICE_NAME, OTA_BOOT_DEVICE_NAME_SIZE) == 0; + if (ota_xiao_bootloader_board_id(board_id)) { + // Both qualified names share the complete suffix and zero padding. + if (memcmp(name + 4, OTA_XIAO_BOOT_DEVICE_NAME + 4, OTA_BOOT_DEVICE_NAME_SIZE - 4) != 0) + return false; + return memcmp(name, "XIAO", 4) == 0 || + (board_id == OTA_XIAO_BOARD_ID_BASE && memcmp(name, "SCAP", 4) == 0); + } size_t end = 0; while (end < OTA_BOOT_DEVICE_NAME_SIZE && name[end] != 0) { if (name[end] < 0x21u || name[end] > 0x7Eu) return false; @@ -245,7 +258,8 @@ inline bool ota_bootloader_hw_id(uint32_t board_id, const uint8_t device_name[16 uint8_t out[32]) { if (!out || !ota_bootloader_board_id_valid(board_id) || !ota_bootloader_device_name_valid(board_id, device_name)) return false; - if (ota_xiao_bootloader_board_id(board_id)) return ota_xiao_bootloader_hw_id(board_id, out); + if (ota_xiao_bootloader_identity(board_id, device_name)) + return ota_xiao_bootloader_hw_id(board_id, out); static const char hex[] = "0123456789ABCDEF"; memset(out, 0, 32); memcpy(out, "NRF_BL_", 7); @@ -261,7 +275,7 @@ inline bool ota_bootloader_hw_id(uint32_t board_id, const uint8_t device_name[16 inline bool ota_bootloader_hw_id(const OtaBootloaderIdentity& identity, uint8_t out[32]); inline uint32_t ota_bootloader_target_id(uint32_t board_id, const uint8_t device_name[16]) { - if (ota_xiao_bootloader_board_id(board_id)) return board_id; + if (ota_xiao_bootloader_identity(board_id, device_name)) return board_id; uint8_t hw_id[32], digest[4]; if (!ota_bootloader_hw_id(board_id, device_name, hw_id)) return 0; mh4(digest, hw_id, sizeof(hw_id)); diff --git a/test/fixtures/nrf52_bootloader_version/test_version.cpp b/test/fixtures/nrf52_bootloader_version/test_version.cpp index a10fa0310..bfdc2f169 100644 --- a/test/fixtures/nrf52_bootloader_version/test_version.cpp +++ b/test/fixtures/nrf52_bootloader_version/test_version.cpp @@ -164,10 +164,19 @@ int main(int argc, char** argv) { put32(bytes, OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 20, OTA_XIAO_BOARD_ID_BASE); memset(bytes.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 24, 0, 16); memcpy(bytes.data() + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 24, "SCAP_DFU", 8); + put16(bytes, OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 62, 0x123); + put32(bytes, OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 64, 0x27000); + refreshManifestCrc(bytes); + OtaBootloaderIdentity sensecap; + assert(ota_bootloader_identity_from_image(bytes.data(), bytes.size(), sensecap)); + assert(sensecap.board_id == OTA_XIAO_BOARD_ID_BASE); + assert(strcmp(sensecap.device_name, "SCAP_DFU") == 0); + expect(bytes, "OTAFIX2.4.9"); // exact SenseCAP P1 shares the base USB board ID + put32(bytes, OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 20, OTA_XIAO_BOARD_ID_SENSE); refreshManifestCrc(bytes); OtaBootloaderIdentity unexpected_board; assert(!ota_bootloader_identity_from_image(bytes.data(), bytes.size(), unexpected_board)); - expect(bytes, "v0.11.0-OTAFIX2.4.9"); // diagnostic fallback for an unexpected board name + expect(bytes, "v0.11.0-OTAFIX2.4.9"); // SCAP_DFU is not valid on the Sense board ID bytes = image(); standalone(bytes, 0x100, "v0.11.0-OTAFIX2.4.9"); manifest(bytes, 0x020409ff); put16(bytes, OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 8, 0); diff --git a/test/test_sensecap_bootloader_identity.py b/test/test_sensecap_bootloader_identity.py new file mode 100644 index 000000000..60e9cbef6 --- /dev/null +++ b/test/test_sensecap_bootloader_identity.py @@ -0,0 +1,146 @@ +"""SenseCAP Solar P1 keeps an exact identity distinct from deployed XIAOs.""" +import copy +import hashlib +import json +from pathlib import Path +import re +import sys +import unittest + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools/mota")) +import motalib as ml +from test_nrf52_embedded_bootloader import boot_image, candidate, profile +import nrf52_embedded_bootloader as embed + + +P1_IDENTITY = (0x28860044, "SCAP_DFU") +P1_TARGET = 0xD677B472 +P1_HW_ID = b"NRF_BL_28860044_SCAP_DFU".ljust(32, b"\0") + + +class SensecapBootloaderIdentityTest(unittest.TestCase): + def image(self, **kwargs): + return boot_image(v7=True, name="SCAP_DFU", **kwargs) + + def package_options(self, image=None): + image = self.image() if image is None else image + return dict(target_id=P1_TARGET, fw_version=embed.BOOT_VERSION, + image_size=len(image), payload=image, block_size=1024, + image_hash=ml.mh32(image), codec_id=ml.CODEC_FULL, + is_full=True, sign_priv=Ed25519PrivateKey.generate(), bootloader=True) + + def test_p1_parses_and_uses_its_generic_exact_identity(self): + image = self.image() + identity = ml.validate_bootloader_image(image, target_id=P1_TARGET, signed_hw_id=P1_HW_ID) + self.assertEqual((identity.board_id, identity.device_name), P1_IDENTITY) + self.assertEqual((identity.softdevice_family, identity.softdevice_fwid, identity.app_base, + identity.layout_abi), (140, 0x123, 0x27000, 1)) + self.assertEqual(ml.bootloader_target_id(*P1_IDENTITY), P1_TARGET) + self.assertEqual(ml.bootloader_hw_id(*P1_IDENTITY), P1_HW_ID) + self.assertTrue(ml.bootloader_identity_is_buildable(*P1_IDENTITY)) + self.assertEqual(ml.bootloader_qualified_storage_profiles(*P1_IDENTITY), (0x0E,)) + self.assertEqual(ml.bootloader_qualified_platform_profile(*P1_IDENTITY), (140, 0x123, 0x27000, 1)) + + def test_signed_p1_package_builds_parses_and_verifies(self): + options = self.package_options() + manifest = ml.build_manifest(**options) + parsed = ml.parse_container(ml.build_container(manifest, options["payload"])) + self.assertEqual(parsed.manifest.target_id, P1_TARGET) + self.assertEqual(parsed.manifest.hw_id, P1_HW_ID) + self.assertEqual(ml.verify(parsed, expect_pub=ml.ed25519_public_bytes(options["sign_priv"])), []) + + def test_existing_xiao_wire_ids_and_wrappers_remain_unchanged(self): + for board_id, expected in ((0x28860044, b"XIAO_BL_28860044"), + (0x28860045, b"XIAO_BL_28860045")): + with self.subTest(board_id=hex(board_id)): + image = boot_image(v7=True, board_id=board_id) + self.assertEqual(ml.bootloader_target_id(board_id, "XIAO_DFU"), board_id) + self.assertEqual(ml.bootloader_hw_id(board_id, "XIAO_DFU"), expected.ljust(32, b"\0")) + self.assertEqual(ml.parse_xiao_bootloader_identity(image).device_name, "XIAO_DFU") + self.assertEqual(ml.validate_xiao_bootloader_image(image, board_id).board_id, board_id) + self.assertIsNone(ml.parse_xiao_bootloader_identity(self.image())) + with self.assertRaisesRegex(ValueError, "not a deployed XIAO"): + ml.validate_xiao_bootloader_image(self.image()) + + def test_only_the_exact_p1_pair_is_admitted_on_shared_xiao_ids(self): + for board_id, name in ((0x28860045, "SCAP_DFU"), (0x28860044, "OTHER_DFU"), + (0x28860045, "OTHER_DFU"), (0x28860044, "R_SCAP_DFU"), + (0x28860044, "SCAP_DFU_V2"), (0x28860044, "SCAP_DFU\0X")): + with self.subTest(board_id=hex(board_id), name=name): + image = boot_image(v7=True, board_id=board_id, name=name) + self.assertIsNone(ml.parse_bootloader_identity(image)) + self.assertFalse(ml.bootloader_identity_is_buildable(board_id, name)) + with self.assertRaises(ValueError): + ml.bootloader_hw_id(board_id, name) + with self.assertRaises(ValueError): + ml.bootloader_target_id(board_id, name) + with self.assertRaises(ValueError): + ml.validate_bootloader_image(image) + + def test_cross_board_wire_ids_cannot_be_used_to_sign_p1_or_xiao(self): + for changes in ({"target_id": 0x28860044}, {"target_id": 0x28860045}, + {"hw_id": "XIAO_BL_28860044"}, {"hw_id": "XIAO_BL_28860045"}): + with self.subTest(changes=changes): + options = self.package_options() + options.update(changes) + with self.assertRaises(ValueError): + ml.build_manifest(**options) + options = self.package_options(boot_image(v7=True)) + with self.assertRaisesRegex(ValueError, "target ID"): + ml.build_manifest(**options) + + def test_p1_rejects_wrong_softdevice_storage_and_corrupt_crc(self): + for image in (boot_image(name="SCAP_DFU", board_id=0x28860044), + self.image(app_base=0x26000), self.image(storage=0x0A)): + with self.subTest(digest=hashlib.sha256(image).hexdigest()), self.assertRaises(ValueError): + ml.validate_bootloader_image(image) + corrupt = bytearray(self.image()) + corrupt[0x300] ^= 1 + self.assertIsNone(ml.parse_bootloader_identity(bytes(corrupt))) + + def test_p1_target_is_disjoint_from_all_generated_application_targets(self): + header = (ROOT / "src/helpers/ota/OtaTargets.h").read_text() + application_ids = {int(value, 16) for value in re.findall(r"\{ 0x([0-9a-fA-F]{8}),", header)} + self.assertTrue(application_ids) + inventory = ml.audit_bootloader_target_inventory(application_ids) + self.assertEqual(inventory[P1_TARGET], P1_IDENTITY) + self.assertEqual(inventory[0x28860044], (0x28860044, "XIAO_DFU")) + self.assertEqual(inventory[0x28860045], (0x28860045, "XIAO_DFU")) + with self.assertRaisesRegex(ValueError, "collides with an application"): + ml.audit_bootloader_target_inventory(application_ids | {P1_TARGET}) + + def test_embedding_accepts_p1_normal_asset_and_rejects_recovery_substitution(self): + data = candidate(self.image(), v7=True) + item = profile(data, v7=True, name="SCAP_DFU") + image, identity = embed.validate_candidate_zip(data, item) + self.assertEqual(image, self.image()) + self.assertEqual(identity.device_name, "SCAP_DFU") + # Even a CRC-valid same-name image cannot replace the pinned normal ZIP. + different = candidate(self.image(boot_version=0x02040BFF), v7=True) + with self.assertRaisesRegex(ValueError, "pinned release asset"): + embed.validate_candidate_zip(different, item) + recovery = copy.deepcopy(item) + recovery["files"]["zip"]["url"] = recovery["files"]["zip"]["url"].replace( + "/releases/download/", "/releases/download/R_") + with self.assertRaisesRegex(ValueError, "pinned bootloader ZIP metadata"): + embed.validate_candidate_zip(data, recovery) + + def test_cached_production_2_4_12_p1_asset_if_available(self): + manifest = json.loads((ROOT / "docs/_data/bootloader_manifest.json").read_text()) + item = next(item for item in manifest["profiles"] if item["id"] == "sensecap_solar_p1") + asset = item["files"]["zip"] + self.assertEqual(asset["sha256"], "694fca8e68fb695ac1a5ef701baf26e8d52a7eb188f886496dcb6bc8b6fdc7cb") + path = ROOT / ".pio/otafix-cache" / (asset["sha256"] + "-" + asset["name"]) + if not path.is_file(): + self.skipTest("published P1 ZIP is not cached; offline fixtures cover validation") + image, identity = embed.validate_candidate_zip(path.read_bytes(), item) + self.assertEqual(hashlib.sha256(image).hexdigest(), + "d33c035c783e555049d2b41fe2f12367c6a87ee30b041c627d19088a2a6d8855") + self.assertEqual((identity.board_id, identity.device_name), P1_IDENTITY) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_sensecap_bootloader_runtime.py b/test/test_sensecap_bootloader_runtime.py new file mode 100644 index 000000000..521307afd --- /dev/null +++ b/test/test_sensecap_bootloader_runtime.py @@ -0,0 +1,249 @@ +"""Exercise exact P1/XIAO bootloader identities in the real C++ validators.""" +import hashlib +import os +from pathlib import Path +import struct +import subprocess +import sys +import tempfile +import unittest +import zlib + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools/mota')) +from test_mota import _set_two_valid_xiao_manifest_crcs, _xiao_manifest_crc + +SIZE, OFFSET = 40960, 40960 - 76 + + +def bootloader(name='SCAP_DFU', board=0x28860044, version=0x02040CFF): + image = bytearray(b'\xff' * SIZE) + struct.pack_into(' +#include +#include +#include +#include +#include +#include +#include "helpers/ota/BootloaderMigrationPolicy.h" +namespace mesh { +void Utils::sha256(uint8_t *out,size_t count,const uint8_t *a,int alen,const uint8_t *b,int blen) { + assert(count<=32 && alen>=0 && blen>=0); + uint8_t digest[32]; unsigned size=0; + EVP_MD_CTX *ctx=EVP_MD_CTX_new(); assert(ctx); + assert(EVP_DigestInit_ex(ctx,EVP_sha256(),nullptr)==1); + assert(EVP_DigestUpdate(ctx,a,alen)==1); + if (blen) assert(EVP_DigestUpdate(ctx,b,blen)==1); + assert(EVP_DigestFinal_ex(ctx,digest,&size)==1 && size==32); + EVP_MD_CTX_free(ctx); std::memcpy(out,digest,count); +} +void Utils::sha256(uint8_t *out,size_t count,const uint8_t *a,int length) { + sha256(out,count,a,length,nullptr,0); +} +} +using namespace mesh::ota; +static std::vector load(const std::string &directory,const char *name) { + std::ifstream input(directory+"/"+name,std::ios::binary); assert(input.good()); + std::vector bytes((std::istreambuf_iterator(input)),{}); + assert(bytes.size()==OTA_BOOT_IMAGE_SIZE); return bytes; +} +static OtaBootloaderIdentity parse(const std::vector &bytes) { + OtaBootloaderIdentity id; + assert(ota_bootloader_vector_sane(bytes.data())); + assert(ota_bootloader_candidate_identity_from_image(bytes.data(),bytes.size(),id)); + return id; +} +struct Store { + const std::vector &bytes; + bool read(uint32_t offset,uint8_t *out,uint32_t size) { + if (offset>bytes.size() || size>bytes.size()-offset) return false; + std::memcpy(out,bytes.data()+offset,size); return true; + } +}; +int main(int argc,char **argv) { + assert(argc==3); + const std::string directory=argv[1], scenario=argv[2]; + auto image=load(directory,"p1.bin"); auto p1=parse(image); + auto base=parse(load(directory,"xiao-base.bin")); + auto sense=parse(load(directory,"xiao-sense.bin")); + auto old=parse(load(directory,"p1-old.bin")); + const uint8_t p1_name[16]={'S','C','A','P','_','D','F','U'}; + const uint8_t xiao_name[16]={'X','I','A','O','_','D','F','U'}; + if (scenario=="canonical") { + assert(p1.board_id==0x28860044 && p1.crc_ok && p1.continuity_present); + assert(p1.softdevice_fwid==0x123 && p1.app_base==0x27000 && p1.boot_version==0x02040CFF); + uint8_t hw[32]; assert(ota_bootloader_hw_id(p1,hw)); + const char expected[32]="NRF_BL_28860044_SCAP_DFU"; + assert(!std::memcmp(hw,expected,32)); + assert(ota_bootloader_target_id(p1)==0xD677B472); + assert(ota_bootloader_target_id(p1)!=base.board_id); + } else if (scenario=="legacy") { + for (const auto &id:{base,sense}) { + uint8_t hw[32]; assert(ota_bootloader_hw_id(id,hw)); + const char expected_base[32]="XIAO_BL_28860044", expected_sense[32]="XIAO_BL_28860045"; + assert(!std::memcmp(hw,id.board_id==base.board_id?expected_base:expected_sense,32)); + assert(ota_bootloader_target_id(id)==id.board_id); + assert(ota_xiao_bootloader_identity(id.board_id,xiao_name)); + } + assert(!ota_xiao_bootloader_identity(p1.board_id,p1_name)); + assert(!ota_bootloader_device_name_valid(0x28860045,p1_name)); + assert(ota_bootloader_target_id(0x28860045,p1_name)==0); + uint8_t wrong[16]={'B','O','G','U','S'}; + assert(!ota_bootloader_device_name_valid(0x28860044,wrong)); + assert(ota_bootloader_target_id(0x28860044,wrong)==0); + uint8_t unpadded[16]; std::memcpy(unpadded,p1_name,16); unpadded[15]=1; + assert(!ota_bootloader_device_name_valid(0x28860044,unpadded)); + assert(ota_bootloader_target_id(0x28860044,unpadded)==0); + } else if (scenario=="qspi") { + Store store{image}; OtaBootloaderIdentity parsed; OtaBootloaderCapsMarker caps; + assert(ota_bootloader_external_image_metadata(store,0,0x0E,parsed,caps)); + assert(ota_bootloader_identity_matches(p1,parsed) && caps.storage_flags==0x0E); + assert(!ota_bootloader_external_image_metadata(store,0,0x0A,parsed,caps)); + image[0x200]^=1; + assert(!ota_bootloader_candidate_identity_from_image(image.data(),image.size(),parsed)); + assert(!ota_bootloader_external_image_metadata(store,0,0x0E,parsed,caps)); + } else if (scenario=="migration") { + using namespace mesh::ota::migration; + assert(candidate_matches_resource(p1,0x28860044,reinterpret_cast(p1_name), + p1.boot_version,0x27000,0x123)); + assert(installed_can_migrate(old,p1,false,false,0x27000,0x123)); + assert(!installed_can_migrate(base,p1,true,true,0x27000,0x123)); + assert(!installed_can_migrate(p1,base,true,true,0x27000,0x123)); + assert(!installed_can_migrate(sense,p1,true,true,0x27000,0x123)); + assert(!installed_can_migrate(p1,p1,true,true,0x27000,0x123)); + assert(!installed_can_migrate(p1,old,true,true,0x27000,0x123)); + assert(!installed_can_migrate(old,p1,false,false,0x26000,0xB6)); + } else if (scenario=="duplicate") { + image=load(directory,"duplicate.bin"); OtaBootloaderIdentity id; + OtaBootloaderIdentity first,second; + assert(ota_bootloader_manifest_parse(image.data()+0x400,0x400,first,76)); + assert(ota_bootloader_manifest_parse(image.data()+OTA_BOOT_CANDIDATE_MANIFEST_OFFSET, + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET,second,76)); + assert(ota_boot_image_crc32(image.data(),image.size(),0x400+40)==first.crc32); + assert(ota_boot_image_crc32(image.data(),image.size(),OTA_BOOT_CANDIDATE_MANIFEST_OFFSET+40)==second.crc32); + assert(!ota_bootloader_identity_from_image(image.data(),image.size(),id)); + Store store{image}; OtaBootloaderCapsMarker caps; + assert(!ota_bootloader_external_image_metadata(store,0,0x0E,id,caps)); + } else { + uint8_t hw[32],xhw[32],digest[32],zero[8]={},mid[4]={1,2,3,4}; + assert(ota_bootloader_hw_id(p1,hw) && ota_bootloader_hw_id(base,xhw)); + mesh::Utils::sha256(digest,32,image.data(),image.size()); + MotaManifest manifest; + manifest.format_ver=3; manifest.flags=MFLAG_FULL|MFLAG_SIGNED|MFLAG_BOOTLOADER; + manifest.codec_id=CODEC_FULL; manifest.image_size=manifest.payload_size=OTA_BOOT_IMAGE_SIZE; + manifest.block_size_log2=10; manifest.block_count=40; manifest.base_hash=zero; + manifest.target_id=ota_bootloader_target_id(p1); manifest.hw_id=hw; + manifest.merkle_root=mid; manifest.image_hash=digest; + assert(ota_bootloader_confirmation_gate(manifest,old,mid,mid,digest)==OTA_BOOT_CONFIRM_OK); + assert(ota_bootloader_confirmation_gate(manifest,base,mid,mid,digest)==OTA_BOOT_CONFIRM_TARGET); + manifest.target_id=base.board_id; + assert(ota_bootloader_confirmation_gate(manifest,old,mid,mid,digest)==OTA_BOOT_CONFIRM_TARGET); + manifest.target_id=ota_bootloader_target_id(p1); manifest.hw_id=xhw; + assert(ota_bootloader_confirmation_gate(manifest,old,mid,mid,digest)==OTA_BOOT_CONFIRM_HW_ID); + manifest.hw_id=hw; uint8_t wrong[8]={}; + assert(ota_bootloader_confirmation_gate(manifest,old,mid,wrong,digest)==OTA_BOOT_CONFIRM_MID); + assert(ota_bootloader_confirmation_gate(manifest,old,mid,mid,wrong)==OTA_BOOT_CONFIRM_IMAGE_HASH); + } +} +''' + +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 SensecapBootloaderRuntimeTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.directory = tempfile.TemporaryDirectory(prefix='sensecap-runtime-') + cls.addClassCleanup(cls.directory.cleanup) + cls.path = Path(cls.directory.name) + (cls.path / 'Utils.h').write_text(UTILS) + (cls.path / 'test.cpp').write_text(SOURCE) + for name, image in { + 'p1.bin': bootloader(), 'p1-old.bin': bootloader(version=0x02040BFF), + 'xiao-base.bin': bootloader('XIAO_DFU', version=0x02040BFF), + 'xiao-sense.bin': bootloader('XIAO_DFU', 0x28860045, 0x02040BFF), + }.items(): + (cls.path / name).write_bytes(image) + duplicate = bytearray(bootloader()) + duplicate[0x400:0x400+76] = bootloader('XIAO_DFU')[OFFSET:] + _set_two_valid_xiao_manifest_crcs(duplicate, 0x400, OFFSET) + assert _xiao_manifest_crc(duplicate, 0x400) == struct.unpack_from(' Optional[str]: if len(name_raw) != 16 or board_id in (0, 0xFFFFFFFF): return None if board_id in (XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE): - return "XIAO_DFU" if name_raw == XIAO_BOOT_DEVICE_NAME else None + if name_raw == XIAO_BOOT_DEVICE_NAME: + return "XIAO_DFU" + if (board_id == SENSECAP_BOOTLOADER_IDENTITY[0] and + name_raw == SENSECAP_BOOTLOADER_IDENTITY[1].encode("ascii").ljust(16, b"\0")): + return SENSECAP_BOOTLOADER_IDENTITY[1] + return None try: end = name_raw.index(0) except ValueError: @@ -680,13 +690,13 @@ def bootloader_hw_id(board_id: int, device_name: str) -> bytes: name_raw = device_name.encode("ascii", "strict") if len(name_raw) > 15 or _bootloader_device_name(board_id, name_raw.ljust(16, b"\0")) is None: raise ValueError("invalid embedded bootloader board/name identity") - if board_id in (XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE): + if (board_id, device_name) in XIAO_BOOTLOADER_IDENTITIES: return hw_id_bytes(xiao_bootloader_hw_id(board_id)) return hw_id_bytes(f"NRF_BL_{board_id:08X}_{device_name}") def bootloader_target_id(board_id: int, device_name: str) -> int: - if board_id in (XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE): + if (board_id, device_name) in XIAO_BOOTLOADER_IDENTITIES: return board_id return int.from_bytes(sha256(bootloader_hw_id(board_id, device_name))[:4], "little") @@ -699,10 +709,8 @@ def audit_bootloader_target_inventory(application_target_ids=()) -> dict: to enter a newly built signed package, and release tests pass the generated application target table here to keep the two wire namespaces disjoint. """ - identities = ( - (XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"), - (XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU"), - ) + INTERNAL_BOOTLOADER_IDENTITIES + RAK_AUTO_BOOTLOADER_IDENTITIES + identities = (QSPI_BOOTLOADER_IDENTITIES + INTERNAL_BOOTLOADER_IDENTITIES + + RAK_AUTO_BOOTLOADER_IDENTITIES) app_ids = set(application_target_ids) targets = {} for identity in identities: @@ -721,9 +729,7 @@ def audit_bootloader_target_inventory(application_target_ids=()) -> dict: def bootloader_identity_is_buildable(board_id: int, device_name: str) -> bool: return ((board_id, device_name) in INTERNAL_BOOTLOADER_IDENTITIES or (board_id, device_name) in RAK_AUTO_BOOTLOADER_IDENTITIES or - (board_id, device_name) in ( - (XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"), - (XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU"))) + (board_id, device_name) in QSPI_BOOTLOADER_IDENTITIES) def bootloader_qualified_platform_profile(board_id: int, device_name: str): @@ -743,8 +749,7 @@ def bootloader_qualified_storage_profiles(board_id: int, device_name: str): identity = (board_id, device_name) if identity in RAK_AUTO_BOOTLOADER_IDENTITIES: return (BOOT_STORAGE_RAK_AUTO_UPDATE,) - if identity in ((XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"), - (XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU")): + if identity in QSPI_BOOTLOADER_IDENTITIES: return (BOOT_STORAGE_QSPI_UPDATE,) if identity in SD_BOOTLOADER_IDENTITIES: # One embedded Tower identity supports two distinct application @@ -823,8 +828,8 @@ def parse_bootloader_identity(image: bytes) -> Optional[BootloaderIdentity]: def parse_xiao_bootloader_identity(image: bytes) -> Optional[XiaoBootloaderIdentity]: """Compatibility wrapper restricted to the two deployed XIAO identities.""" identity = parse_bootloader_identity(image) - return identity if identity and identity.board_id in ( - XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE) else None + return identity if identity and (identity.board_id, identity.device_name) in \ + XIAO_BOOTLOADER_IDENTITIES else None def bootloader_caps_storage(image: bytes) -> Optional[int]: @@ -931,7 +936,7 @@ def validate_bootloader_image(image: bytes, target_id: Optional[int] = None, def validate_xiao_bootloader_image(image: bytes, board_id: Optional[int] = None) -> XiaoBootloaderIdentity: identity = validate_bootloader_image(image) - if identity.board_id not in (XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE): + if (identity.board_id, identity.device_name) not in XIAO_BOOTLOADER_IDENTITIES: raise ValueError("bootloader is not a deployed XIAO identity") if board_id is not None and identity.board_id != board_id: raise ValueError("bootloader embedded board ID does not match package target") diff --git a/tools/mota/test_mota.py b/tools/mota/test_mota.py index 283a39ece..6831af0c1 100644 --- a/tools/mota/test_mota.py +++ b/tools/mota/test_mota.py @@ -1693,7 +1693,8 @@ def test_bootloader_build_inventory_is_unique_and_disjoint_from_app_targets(): for value in re.findall(r"\{ 0x([0-9a-fA-F]{8}),", target_header) } audited = ml.audit_bootloader_target_inventory(app_targets) - assert len(audited) == len(expected) + 4 # internal, two XIAOs, two adaptive RAKs + assert len(audited) == len(expected) + 5 # internal, two XIAOs, Solar P1, two adaptive RAKs + assert audited[0xD677B472] == (0x28860044, "SCAP_DFU") assert audited[0xD04AB3AB] == (0x239A0029, "3401_AUTO_DFU") assert audited[0xFEEAFD1B] == (0x239A0029, "4631_AUTO_DFU") @@ -1714,10 +1715,7 @@ def test_bootloader_build_inventory_is_unique_and_disjoint_from_app_targets(): def test_qualified_bootloader_platform_and_storage_profiles_are_exact(): - qualified = ( - (ml.XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"), - (ml.XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU"), - ) + ml.INTERNAL_BOOTLOADER_IDENTITIES + qualified = ml.QSPI_BOOTLOADER_IDENTITIES + ml.INTERNAL_BOOTLOADER_IDENTITIES for board_id, device_name in qualified: family, fwid, app_base, layout_abi = \ ml.bootloader_qualified_platform_profile(board_id, device_name) @@ -1731,8 +1729,7 @@ def test_qualified_bootloader_platform_and_storage_profiles_are_exact(): assert (fwid, app_base) == (ml.BOOTLOADER_S140_V6_FWID, ml.NRF52_APP_BASE_S140_V6) for storage in storages: - if board_id in (ml.XIAO_BOOT_BOARD_ID_BASE, - ml.XIAO_BOOT_BOARD_ID_SENSE): + if (board_id, device_name) in ml.XIAO_BOOTLOADER_IDENTITIES: image = _xiao_bootloader_image(board_id) else: image = bytearray(_generic_bootloader_image( @@ -1767,7 +1764,7 @@ def test_qualified_bootloader_platform_and_storage_profiles_are_exact(): pass # Capability storage is part of the profile too. Tower alone admits both - # internal and SD; QSPI is XIAO-only and non-Tower internals cannot claim SD. + # internal and SD; QSPI is qualified per exact name, and other internals cannot claim SD. for invalid in ( _generic_bootloader_image(storage=ml.BOOT_STORAGE_SD_UPDATE), _generic_bootloader_image(board_id=0x239A0071,