Support SenseCAP Solar P1 bootloader identity separately from XIAO

This commit is contained in:
mikecarper committed 2026-10-23 16:51:34 -07:00
1 parent d808d964d2
commit 9da754b137
9 files changed
+478 -53

No files matched your search

+8 -3
View File
@@ -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
+9 -2
View File
@@ -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
+6 -13
View File
@@ -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;
+20 -6
View File
@@ -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));
+10 -1
View File
@@ -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);
+146
View File
@@ -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()
+249
View File
@@ -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('<II', image, 0, 0x20030000, 0xF4101)
image[0x100:0x110] = b'MOTABLDR' + struct.pack('<HHBBBB', 3, 5, 0x0E, 0, 0, 0)
struct.pack_into('<8sHHIII16sI', image, OFFSET, b'BLMFCRC1', 1, 44,
0xF4000, SIZE, board, name.encode().ljust(16, b'\0'), 0)
struct.pack_into('<8sHHIHHIHHI', image, OFFSET + 44, b'BLM2SOFT', 2, 32,
version, 140, 0x123, 0x27000, 1, 0, 0)
struct.pack_into('<I', image, OFFSET + 40, zlib.crc32(image) & 0xFFFFFFFF)
return bytes(image)
SOURCE = r'''
#include <cassert>
#include <cstring>
#include <fstream>
#include <iterator>
#include <string>
#include <vector>
#include <openssl/evp.h>
#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<uint8_t> load(const std::string &directory,const char *name) {
std::ifstream input(directory+"/"+name,std::ios::binary); assert(input.good());
std::vector<uint8_t> bytes((std::istreambuf_iterator<char>(input)),{});
assert(bytes.size()==OTA_BOOT_IMAGE_SIZE); return bytes;
}
static OtaBootloaderIdentity parse(const std::vector<uint8_t> &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<uint8_t> &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<const char*>(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 <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 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('<I', duplicate, 0x428)[0]
(cls.path / 'duplicate.bin').write_bytes(duplicate)
cls.binary = cls.compile(cls.path)
@staticmethod
def compile(path, include=None):
flags = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie']
binary = path / 'runtime'
command = [os.environ.get('CXX', 'g++'), '-std=c++17', '-O1', '-Wall', '-Wextra', '-Werror', *flags]
if include:
command += ['-I', str(include)]
command += ['-I', str(path), '-I', str(ROOT / 'src'), '-I', str(ROOT / 'src/helpers/ota'),
str(path / 'test.cpp'), '-lcrypto', '-o', str(binary)]
subprocess.run(command, capture_output=True, text=True, check=True)
return binary
def scenario(self, name):
subprocess.run([str(self.binary), str(self.path), name], check=True)
def test_p1_canonical_signed_identity_uses_real_sha256(self):
hw = b'NRF_BL_28860044_SCAP_DFU'.ljust(32, b'\0')
self.assertEqual(int.from_bytes(hashlib.sha256(hw).digest()[:4], 'little'), 0xD677B472)
self.scenario('canonical')
def test_legacy_xiao_pairing_and_ids_remain_exact(self):
self.scenario('legacy')
def test_real_qspi_scanner_requires_crc_and_exact_storage(self):
self.scenario('qspi')
def test_embedded_migration_requires_exact_identity_and_strict_upgrade(self):
self.scenario('migration')
def test_two_independently_crc_valid_identities_are_rejected(self):
self.scenario('duplicate')
def test_signed_package_confirmation_cannot_cross_xiao_and_p1(self):
self.scenario('confirmation')
def test_negative_control_board_only_target_shortcut_is_caught(self):
header = (ROOT / 'src/helpers/ota/OtaBootloaderUpdate.h').read_text()
changed = header.replace('if (ota_xiao_bootloader_identity(board_id, device_name)) return board_id;',
'if (ota_xiao_bootloader_board_id(board_id)) return board_id;')
self.assertNotEqual(header, changed)
with tempfile.TemporaryDirectory(prefix='sensecap-shortcut-negative-') as directory:
path = Path(directory)
headers = path / 'helpers/ota'
headers.mkdir(parents=True)
(headers / 'OtaBootloaderUpdate.h').write_text(changed)
(headers / 'BootloaderMigrationPolicy.h').write_text(
(ROOT / 'src/helpers/ota/BootloaderMigrationPolicy.h').read_text())
(path / 'Utils.h').write_text(UTILS)
(path / 'test.cpp').write_text(SOURCE)
binary = self.compile(path, path)
result = subprocess.run([str(binary), str(self.path), 'canonical'], capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0)
self.assertIn('ota_bootloader_target_id(p1)==0xD677B472', result.stderr)
if __name__ == '__main__':
unittest.main()
+25 -20
View File
@@ -90,6 +90,13 @@ NRF52_LAYOUT_FLAG_AUTO_STORE = 0x20
XIAO_BOOT_BOARD_ID_BASE = 0x28860044
XIAO_BOOT_BOARD_ID_SENSE = 0x28860045
# Solar P1 shares XIAO's USB-derived board ID, but retains its own name and wire identity.
XIAO_BOOTLOADER_IDENTITIES = (
(XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"),
(XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU"),
)
SENSECAP_BOOTLOADER_IDENTITY = (XIAO_BOOT_BOARD_ID_BASE, "SCAP_DFU")
QSPI_BOOTLOADER_IDENTITIES = XIAO_BOOTLOADER_IDENTITIES + (SENSECAP_BOOTLOADER_IDENTITY,)
XIAO_BOOT_IMAGE_START = 0x000F4000
XIAO_BOOT_IMAGE_SIZE = 0x0000A000
XIAO_BOOT_MANIFEST_MAGIC = b"BLMFCRC1"
@@ -155,12 +162,10 @@ SD_BOOTLOADER_IDENTITIES = (
# Exact runtime continuity profiles for signed, remotely installable
# bootloaders. A CRC-valid BLM2 envelope is not sufficient for package
# creation: signing the wrong SoftDevice/application layout only produces a
# package every qualified device will refuse. XIAO/Sense, Minew MX25LE01, and
# T1000-E run S140 v7; the remaining inventory (including both Tower storage
# package every qualified device will refuse. XIAO/Sense, Solar P1, Minew
# MX25LE01, and T1000-E run S140 v7; the remaining inventory (including both Tower storage
# profiles) runs S140 v6.
BOOTLOADER_S140_V7_IDENTITIES = (
(XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"),
(XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU"),
BOOTLOADER_S140_V7_IDENTITIES = QSPI_BOOTLOADER_IDENTITIES + (
(0x239A0029, "MX25_DFU"),
(0x28860057, "T1KE_DFU"),
)
@@ -666,7 +671,12 @@ def _bootloader_device_name(board_id: int, name_raw: bytes) -> 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")
+5 -8
View File
@@ -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,