Keep the deployed SD target and layout, select qualified internal delta storage when no card is usable, and retain exact SD bootloader-update gates. Add storage, capability, transfer, recipe, and release-selection regression coverage. Also preserve mandatory runtime packet logging in canonical XIAO images when verbose build debugging is disabled, fixing the four stopped release-matrix qualification failures.
1189 lines
52 KiB
Python
1189 lines
52 KiB
Python
"""
|
|
motalib - build/parse/verify MeshCore ``.mota`` firmware-update containers.
|
|
|
|
Pure logic, no CLI. Implements docs/ota_protocol.md (v2 application / v3 bootloader, fixed layout).
|
|
|
|
The wire format (all integers little-endian):
|
|
|
|
container = MAGIC(4) | MOTA_TOTAL_SIZE(4) | MANIFEST | PAYLOAD | TRAILER(5)
|
|
|
|
manifest = format_ver(1) flags(1) hash_algo(1) target_id(4) fw_version(4)
|
|
image_size(4) payload_size(4) block_size_log2(1) merkle_root(4)
|
|
image_hash(32) codec_id(1) hw_id(32)
|
|
base_hash(8) signer_pubkey(32) signature(64) approval(4)
|
|
leaves[](4*BC)
|
|
|
|
Fixed layout: every field is always present at a constant offset (base_hash/signer_pubkey/signature are
|
|
zero-filled for a full / unsigned container), so manifest-minus-leaves is always 197 bytes and `leaves[]`
|
|
is the only variable-length field. Hashes are SHA-256, truncated per multihash (sha2-256:N = first N bytes).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import io
|
|
import re
|
|
import struct
|
|
from dataclasses import dataclass, field
|
|
from typing import List, Optional, Tuple
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reference constants (must match docs/ota_protocol.md and the device code)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
MAGIC = b"mOTA" # 6D 4F 54 41
|
|
TRAILER = b"vk496" # 76 6B 34 39 36
|
|
ENDF_MAGIC = b"EndF" # 45 6E 64 46
|
|
# Fixed-length trailer: marker(4) + body_len(4) + body_hash8(8) + fw_version(4) + target_id(4) + hw_id(32).
|
|
ENDF_LEN = 56
|
|
|
|
FORMAT_VER = 2 # application compatibility name
|
|
APP_FORMAT_VER = 2
|
|
BOOT_FORMAT_VER = 3
|
|
HASH_ALGO_SHA256 = 0x12 # multihash code for sha2-256
|
|
|
|
FLAG_FULL = 0x01
|
|
FLAG_SIGNED = 0x02
|
|
FLAG_BOOTLOADER = 0x04
|
|
FLAG_KNOWN = FLAG_FULL | FLAG_SIGNED | FLAG_BOOTLOADER
|
|
|
|
CODEC_FULL = 0
|
|
CODEC_DETOOLS_SEQUENTIAL = 1 # detools `sequential` patch (decoded on-device by vendored detools C)
|
|
CODEC_DETOOLS_INPLACE = 2 # detools `in-place` patch (nRF52 bootloader-handoff path; TBD)
|
|
CODEC_NAMES = {CODEC_FULL: "full", CODEC_DETOOLS_SEQUENTIAL: "detools-sequential",
|
|
CODEC_DETOOLS_INPLACE: "detools-in-place"}
|
|
|
|
APPROVAL_NOT = b"\xff\xff\xff\xff" # erased = not approved
|
|
APPROVAL_YES = b"APRV" # 41 50 52 56 = approved
|
|
|
|
DEFAULT_BLOCK_SIZE = 2048
|
|
MAX_APP_BLOCK_SIZE = 2048
|
|
BOOTLOADER_BLOCK_SIZE = 1024
|
|
|
|
# Conservative fallback for firmware built before the layout record below existed. Keep in sync with
|
|
# OtaFlashLayout_nrf52.h and motatool's format.rs.
|
|
NRF52_INPLACE_MEMORY = 0x00098000
|
|
|
|
NRF52_APP_BASE_S140_V6 = 0x00026000
|
|
NRF52_APP_BASE_S140_V7 = 0x00027000
|
|
NRF52_EXTRAFS_START = 0x000D4000
|
|
NRF52_BOOT_SCRATCH_START = 0x000E0000
|
|
NRF52_BOOT_SCRATCH_END = 0x000EA000
|
|
NRF52_APP_END = 0x000ED000
|
|
NRF52_BOOT_CONTAINER_SIZE = 41330
|
|
NRF52_SHARED_BOOT_STAGE_START = 0x000E2000
|
|
NRF52_FLASH_PAGE = 4096
|
|
NRF52_HYBRID_RAM_SIZE = 65536
|
|
|
|
# A validated nRF52 firmware carries this record immediately before EndF. It lets an offline packager
|
|
# derive the actual app base and staging ceiling from the built artifact, without a board-name allowlist.
|
|
# magic(8) | version(1) | flags(1) | record_len(2) | app_base(4) | linked_app_end(4) | stage_ceiling(4)
|
|
NRF52_LAYOUT_MAGIC = b"mOTALay1"
|
|
NRF52_LAYOUT_VERSION = 1
|
|
NRF52_LAYOUT_LEN = 24
|
|
NRF52_LAYOUT_FLAG_SD = 0x01
|
|
NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS = 0x02
|
|
NRF52_LAYOUT_FLAG_QSPI = 0x04
|
|
NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH = 0x08
|
|
NRF52_LAYOUT_FLAG_HYBRID_RAM = 0x10
|
|
NRF52_LAYOUT_FLAG_AUTO_STORE = 0x20
|
|
|
|
XIAO_BOOT_BOARD_ID_BASE = 0x28860044
|
|
XIAO_BOOT_BOARD_ID_SENSE = 0x28860045
|
|
XIAO_BOOT_IMAGE_START = 0x000F4000
|
|
XIAO_BOOT_IMAGE_SIZE = 0x0000A000
|
|
XIAO_BOOT_MANIFEST_MAGIC = b"BLMFCRC1"
|
|
XIAO_BOOT_MANIFEST_VERSION = 1
|
|
XIAO_BOOT_MANIFEST_SIZE = 44
|
|
BOOT_CONTINUITY_MAGIC = b"BLM2SOFT"
|
|
BOOT_CONTINUITY_VERSION = 2
|
|
BOOT_CONTINUITY_SIZE = 32
|
|
BOOT_CONTINUITY_FAMILY_S140 = 140
|
|
BOOT_CONTINUITY_LAYOUT_ABI = 1
|
|
BOOT_ENVELOPE_SIZE = XIAO_BOOT_MANIFEST_SIZE + BOOT_CONTINUITY_SIZE
|
|
BOOT_CANDIDATE_MANIFEST_OFFSET = XIAO_BOOT_IMAGE_SIZE - BOOT_ENVELOPE_SIZE
|
|
XIAO_BOOT_DEVICE_NAME = b"XIAO_DFU".ljust(16, b"\0")
|
|
XIAO_BOOT_CAPS_MAGIC = b"MOTABLDR"
|
|
BOOT_STORAGE_SD = 0x01
|
|
BOOT_STORAGE_STAGE_CEILING = 0x02
|
|
XIAO_BOOT_STORAGE_QSPI = 0x04
|
|
XIAO_BOOT_STORAGE_UPDATE = 0x08
|
|
BOOT_STORAGE_HEADER_W25 = 0x10
|
|
BOOT_STORAGE_KNOWN = 0x1F
|
|
BOOT_STORAGE_SD_UPDATE = BOOT_STORAGE_SD | XIAO_BOOT_STORAGE_UPDATE
|
|
BOOT_STORAGE_QSPI_UPDATE = (BOOT_STORAGE_STAGE_CEILING | XIAO_BOOT_STORAGE_QSPI |
|
|
XIAO_BOOT_STORAGE_UPDATE)
|
|
BOOT_STORAGE_INTERNAL_UPDATE = BOOT_STORAGE_STAGE_CEILING | XIAO_BOOT_STORAGE_UPDATE
|
|
BOOT_STORAGE_RAK_AUTO_UPDATE = BOOT_STORAGE_QSPI_UPDATE | BOOT_STORAGE_HEADER_W25
|
|
RAK_AUTO_BOOTLOADER_IDENTITIES = (
|
|
(0x239A0029, "3401_AUTO_DFU"),
|
|
(0x239A0029, "4631_AUTO_DFU"),
|
|
)
|
|
BOOT_REQUIRED_APP_CODEC_MASK = ((1 << CODEC_FULL) |
|
|
(1 << CODEC_DETOOLS_INPLACE))
|
|
|
|
# Exact embedded manifests for the qualified shared-internal nRF52840
|
|
# bootloaders. Parsing/inspection remains generic, but package creation is
|
|
# deliberately limited to this collision-audited release inventory. Carrier
|
|
# aliases use the same installed bootloader identity and therefore do not add
|
|
# rows here.
|
|
INTERNAL_BOOTLOADER_IDENTITIES = (
|
|
(0x239A0029, "GAT562_DFU"),
|
|
(0x239A0071, "TOWER_V2_OTA"),
|
|
(0x239A0071, "T096_DFU"),
|
|
(0x239A0071, "T1_DFU"),
|
|
(0x239A0071, "T114_DFU"),
|
|
(0x239A0071, "MESH_POCKET_OTA"),
|
|
(0x239A00B3, "KeepteenLT1_OTA"),
|
|
(0x239A0029, "MX25_DFU"),
|
|
(0x239A00B3, "PROM_DFU"),
|
|
(0x28860057, "T1KE_DFU"),
|
|
(0x239A00DA, "TNM3_DFU"),
|
|
(0x239A0029, "3401_DFU"),
|
|
(0x239A0029, "4631_DFU"),
|
|
(0x239A0029, "RTAG_DFU"),
|
|
)
|
|
|
|
# MeshTower V2 has both a lean internal-store application and an exact SD-store
|
|
# application. They intentionally retain the same embedded bootloader identity
|
|
# and signed wire target; the candidate capability marker selects the matching
|
|
# storage contract and is checked again by the device before approval.
|
|
SD_BOOTLOADER_IDENTITIES = (
|
|
(0x239A0071, "TOWER_V2_OTA"),
|
|
)
|
|
|
|
# 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
|
|
# profiles) runs S140 v6.
|
|
BOOTLOADER_S140_V7_IDENTITIES = (
|
|
(XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"),
|
|
(XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU"),
|
|
(0x239A0029, "MX25_DFU"),
|
|
(0x28860057, "T1KE_DFU"),
|
|
)
|
|
BOOTLOADER_S140_V6_FWID = 0x00B6
|
|
BOOTLOADER_S140_V7_FWID = 0x0123
|
|
|
|
# MeshTower V2's SD-backed OTA target keeps the staged .mota off-chip, so the application may use the
|
|
# complete S140 v6 application region up to InternalFS instead of leaving room for internal staging.
|
|
# This is deliberately target-specific: other nRF52 OTA builds still need NRF52_INPLACE_MEMORY above.
|
|
NRF52_SD_APP_MEMORY = 0x000C7000
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Multihash helpers (sha2-256 truncations)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def sha256(data: bytes) -> bytes:
|
|
return hashlib.sha256(data).digest()
|
|
|
|
|
|
def mh4(data: bytes) -> bytes:
|
|
return hashlib.sha256(data).digest()[:4]
|
|
|
|
|
|
def mh8(data: bytes) -> bytes:
|
|
return hashlib.sha256(data).digest()[:8]
|
|
|
|
|
|
def mh32(data: bytes) -> bytes:
|
|
return hashlib.sha256(data).digest()
|
|
|
|
|
|
def ed25519_public_bytes(key) -> bytes:
|
|
"""Return a raw Ed25519 public key across supported cryptography versions."""
|
|
public_key = key.public_key() if hasattr(key, "public_key") else key
|
|
public_bytes_raw = getattr(public_key, "public_bytes_raw", None)
|
|
if public_bytes_raw is not None:
|
|
return public_bytes_raw()
|
|
|
|
# public_bytes_raw() was added after the stable serialization API. Keep
|
|
# the OTA builder usable on distro-provided cryptography releases too.
|
|
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
return public_key.public_bytes(Encoding.Raw, PublicFormat.Raw)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fw_version packing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def pack_version(s) -> int:
|
|
"""'1.16.0' or '1.16.0.2' -> uint32 (MAJOR<<24|MINOR<<16|PATCH<<8|pre). Ints pass through."""
|
|
if isinstance(s, int):
|
|
return s & 0xFFFFFFFF
|
|
s = s.strip().lstrip("vV")
|
|
parts = [int(p) for p in s.split(".")]
|
|
parts += [0] * (4 - len(parts))
|
|
maj, mnr, pat, pre = parts[:4]
|
|
return ((maj & 0xFF) << 24) | ((mnr & 0xFF) << 16) | ((pat & 0xFF) << 8) | (pre & 0xFF)
|
|
|
|
|
|
def unpack_version(v: int) -> str:
|
|
return f"{(v >> 24) & 0xFF}.{(v >> 16) & 0xFF}.{(v >> 8) & 0xFF}.{v & 0xFF}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# target_id
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def target_id_for_env(env_name: str) -> int:
|
|
"""4-byte build-target id = sha2-256:4(env_name), little-endian uint32.
|
|
|
|
The PlatformIO env name (e.g. 'RAK_4631_companion_radio_usb') uniquely captures hardware AND
|
|
role/partition layout. build.sh injects the same value as -D MOTA_TARGET_ID so the device's
|
|
getOtaTargetId() matches what the packager stamps into the manifest. (Must match build.sh.)
|
|
"""
|
|
d = hashlib.sha256(env_name.encode()).digest()[:4]
|
|
return int.from_bytes(d, "little")
|
|
|
|
|
|
def hardware_id_for_env(env_name: str) -> str:
|
|
"""Derive a stable hardware-family tag from a PlatformIO environment name.
|
|
|
|
Role/profile suffixes are removed so a deliberate role switch on the same physical board remains
|
|
possible, while a cross-board install is rejected. Long family names retain a short hash suffix to
|
|
avoid collisions inside EndF's fixed 32-byte field.
|
|
"""
|
|
# Optional storage that requires a different bootloader/application pair is
|
|
# a distinct hardware class even though it uses the same WisBlock Core.
|
|
exact_storage_hardware = {
|
|
"RAK_4631_repeater_rak15001_slot_c_lora_ota": "RAK4631_RAK15001_C",
|
|
"RAK_4631_repeater_w25q16_lora_ota": "RAK4631_W25Q16",
|
|
"RAK_3401_repeater_rak13302_w25q16_lora_ota": "RAK3401_RAK13302_W25Q16",
|
|
}
|
|
for exact_env, hardware_id in exact_storage_hardware.items():
|
|
if env_name.casefold() == exact_env.casefold():
|
|
return hardware_id
|
|
|
|
role = re.search(
|
|
r"[_-](?:partition_expander|repeater|repeatr|room_server|room_svr|sensor|terminal_chat|kiss_modem|"
|
|
r"companion_radio|companion|comp_radio)(?=[_-]|$)", env_name, re.IGNORECASE)
|
|
family = (env_name[:role.start()] if role else env_name).strip("_-") or env_name.strip("_-")
|
|
# Match the explicit MOTA_HW_ID shared by every role in these variant
|
|
# families. The environment spelling is not always the physical-board tag.
|
|
family = {
|
|
"Heltec_WSL3": "Heltec_v3",
|
|
"Heltec_t114_without_display": "Heltec_t114",
|
|
"RAK_4631": "RAK4631",
|
|
}.get(family, family)
|
|
if len(family) <= 32:
|
|
return family
|
|
suffix = hashlib.sha256(family.encode()).hexdigest()[:8]
|
|
return f"{family[:23].rstrip('_-')}-{suffix}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# EndF trailer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class FwIdent:
|
|
"""Self-describing firmware identity carried in the EndF trailer (docs/ota_protocol.md Section 2) so a node /
|
|
the packaging tool reads it straight from the firmware instead of relying on build flags or filenames."""
|
|
fw_version: int = 0 # packed MAJOR<<24 | MINOR<<16 | PATCH<<8 | pre
|
|
target_id: int = 0 # sha2-256:4(pio_env) as uint32 LE - hw + role + partition (fetch routing)
|
|
hw_id: str = "" # readable hardware tag (brick-safety), e.g. "RAK4631"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Nrf52Layout:
|
|
"""Resolved flash geometry embedded immediately before an nRF52 firmware's EndF trailer."""
|
|
app_base: int
|
|
linked_app_end: int
|
|
stage_ceiling: int
|
|
flags: int = 0
|
|
|
|
@property
|
|
def sd_backed(self) -> bool:
|
|
return bool(self.flags & NRF52_LAYOUT_FLAG_SD)
|
|
|
|
@property
|
|
def qspi_backed(self) -> bool:
|
|
return bool(self.flags & NRF52_LAYOUT_FLAG_QSPI)
|
|
|
|
@property
|
|
def external_backed(self) -> bool:
|
|
return self.sd_backed or self.qspi_backed
|
|
|
|
@property
|
|
def bootloader_scratch(self) -> bool:
|
|
return bool(self.flags & NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH)
|
|
|
|
@property
|
|
def hybrid_ram(self) -> bool:
|
|
return bool(self.flags & NRF52_LAYOUT_FLAG_HYBRID_RAM)
|
|
|
|
@property
|
|
def auto_store(self) -> bool:
|
|
return bool(self.flags & NRF52_LAYOUT_FLAG_AUTO_STORE)
|
|
|
|
def nrf52_stage_ceiling_for_layout(linked_app_end: int, uses_internal_extrafs: bool) -> int:
|
|
"""Select a safe staging ceiling from linker geometry and actual secondary-storage type."""
|
|
if uses_internal_extrafs:
|
|
return NRF52_EXTRAFS_START
|
|
if linked_app_end in (NRF52_EXTRAFS_START, NRF52_BOOT_SCRATCH_START, NRF52_APP_END):
|
|
return NRF52_APP_END
|
|
return NRF52_EXTRAFS_START
|
|
|
|
|
|
def nrf52_hybrid_stage_plan(total_size: int, app_base: int, app_end: int,
|
|
stage_ceiling: int) -> Optional[Tuple[int, int, int]]:
|
|
"""Return the frozen (flash_start, flash_len, ram_len) split, or None.
|
|
|
|
Keep this byte-for-byte equivalent to
|
|
mota_nrf52_hybrid_stage_plan() in OtaFlashLayout_nrf52.h. Hybrid is an
|
|
expanded ED000-only profile; at least one complete flash page contains the
|
|
header, manifest, leaves, and APRV word.
|
|
"""
|
|
if (stage_ceiling != NRF52_APP_END or
|
|
app_base not in (NRF52_APP_BASE_S140_V6,
|
|
NRF52_APP_BASE_S140_V7) or
|
|
not (app_base <= app_end <= stage_ceiling) or
|
|
total_size < 8 + 197 + 5 or total_size > 0xFFFFFFFF):
|
|
return None
|
|
flash_needed = max(NRF52_FLASH_PAGE,
|
|
max(0, total_size - NRF52_HYBRID_RAM_SIZE))
|
|
flash_len = ((flash_needed + NRF52_FLASH_PAGE - 1) //
|
|
NRF52_FLASH_PAGE) * NRF52_FLASH_PAGE
|
|
if flash_len >= total_size or flash_len > stage_ceiling - app_end:
|
|
return None
|
|
ram_len = total_size - flash_len
|
|
if not (0 < ram_len <= NRF52_HYBRID_RAM_SIZE):
|
|
return None
|
|
return stage_ceiling - flash_len, flash_len, ram_len
|
|
|
|
|
|
def build_nrf52_layout(layout: Nrf52Layout) -> bytes:
|
|
if layout.app_base not in (NRF52_APP_BASE_S140_V6, NRF52_APP_BASE_S140_V7):
|
|
raise ValueError(f"unsupported nRF52 app base 0x{layout.app_base:X}")
|
|
if layout.linked_app_end not in (NRF52_EXTRAFS_START,
|
|
NRF52_BOOT_SCRATCH_START, NRF52_APP_END):
|
|
raise ValueError(f"unsupported nRF52 linked app end 0x{layout.linked_app_end:X}")
|
|
if layout.stage_ceiling not in (NRF52_EXTRAFS_START, NRF52_APP_END):
|
|
raise ValueError(f"unsupported nRF52 staging ceiling 0x{layout.stage_ceiling:X}")
|
|
if not (layout.app_base < layout.linked_app_end <= NRF52_APP_END):
|
|
raise ValueError("invalid nRF52 app region")
|
|
known_flags = (NRF52_LAYOUT_FLAG_SD | NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS |
|
|
NRF52_LAYOUT_FLAG_QSPI | NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH |
|
|
NRF52_LAYOUT_FLAG_HYBRID_RAM | NRF52_LAYOUT_FLAG_AUTO_STORE)
|
|
if layout.flags & ~known_flags:
|
|
raise ValueError(f"unsupported nRF52 layout flags 0x{layout.flags:X}")
|
|
if layout.sd_backed and layout.qspi_backed:
|
|
raise ValueError("nRF52 layout cannot use both SD and QSPI staging")
|
|
if layout.auto_store and (layout.sd_backed or layout.qspi_backed or
|
|
layout.flags & NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS):
|
|
raise ValueError("nRF52 adaptive staging has its own exclusive layout flag")
|
|
if layout.external_backed and layout.flags & NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS:
|
|
raise ValueError("nRF52 external staging cannot also reserve internal ExtraFS")
|
|
if layout.hybrid_ram:
|
|
if (layout.external_backed or
|
|
layout.flags & NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS):
|
|
raise ValueError("nRF52 hybrid RAM staging requires internal flash without ExtraFS")
|
|
if (layout.linked_app_end != NRF52_APP_END or
|
|
layout.stage_ceiling != NRF52_APP_END):
|
|
raise ValueError(
|
|
"nRF52 hybrid RAM staging requires the exact 0xED000 internal-only profile")
|
|
if layout.bootloader_scratch:
|
|
qspi_shape = (layout.qspi_backed and not layout.sd_backed and
|
|
layout.linked_app_end == NRF52_BOOT_SCRATCH_START)
|
|
if not qspi_shape:
|
|
raise ValueError("nRF52 bootloader scratch requires the XIAO QSPI layout")
|
|
elif layout.linked_app_end == NRF52_BOOT_SCRATCH_START:
|
|
raise ValueError("reserved bootloader geometry requires the bootloader-scratch flag")
|
|
expected_ceiling = (NRF52_APP_END if layout.external_backed else
|
|
nrf52_stage_ceiling_for_layout(
|
|
layout.linked_app_end,
|
|
bool(layout.flags & NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS)))
|
|
if layout.stage_ceiling != expected_ceiling:
|
|
raise ValueError(f"nRF52 staging ceiling 0x{layout.stage_ceiling:X} is inconsistent with layout flags")
|
|
return struct.pack("<8sBBHIII", NRF52_LAYOUT_MAGIC, NRF52_LAYOUT_VERSION, layout.flags,
|
|
NRF52_LAYOUT_LEN, layout.app_base, layout.linked_app_end,
|
|
layout.stage_ceiling)
|
|
|
|
|
|
def ensure_nrf52_layout(body: bytes, layout: Nrf52Layout) -> bytes:
|
|
"""Append or replace the fixed layout record on an EndF-free firmware body."""
|
|
record = build_nrf52_layout(layout)
|
|
if len(body) >= NRF52_LAYOUT_LEN and body[-NRF52_LAYOUT_LEN:-NRF52_LAYOUT_LEN + 8] == NRF52_LAYOUT_MAGIC:
|
|
body = body[:-NRF52_LAYOUT_LEN]
|
|
return body + record
|
|
|
|
|
|
def parse_nrf52_layout(image: bytes) -> Optional[Nrf52Layout]:
|
|
"""Read a validated layout record from an EndF-trailed firmware; old firmware returns None."""
|
|
if not has_endf(image) or len(image) < ENDF_LEN + NRF52_LAYOUT_LEN:
|
|
return None
|
|
record = image[-ENDF_LEN - NRF52_LAYOUT_LEN:-ENDF_LEN]
|
|
try:
|
|
magic, version, flags, record_len, app_base, linked_end, ceiling = struct.unpack(
|
|
"<8sBBHIII", record)
|
|
if magic != NRF52_LAYOUT_MAGIC or version != NRF52_LAYOUT_VERSION or record_len != NRF52_LAYOUT_LEN:
|
|
return None
|
|
layout = Nrf52Layout(app_base, linked_end, ceiling, flags)
|
|
if build_nrf52_layout(layout) != record:
|
|
return None
|
|
return layout
|
|
except (ValueError, struct.error):
|
|
return None
|
|
|
|
|
|
def build_endf(body: bytes, ident: Optional["FwIdent"] = None) -> bytes:
|
|
"""The fixed 56-byte EndF trailer for a firmware BODY (identity zero-filled if not given)."""
|
|
ident = ident or FwIdent()
|
|
hw = ident.hw_id.encode("ascii", "replace")[:32].ljust(32, b"\0")
|
|
return (ENDF_MAGIC + struct.pack("<I", len(body)) + mh8(body)
|
|
+ struct.pack("<II", ident.fw_version & 0xFFFFFFFF, ident.target_id & 0xFFFFFFFF) + hw)
|
|
|
|
|
|
def has_endf(image: bytes) -> bool:
|
|
"""True iff `image` ends with a self-consistent (fixed 56-byte) EndF trailer (image == BODY || EndF)."""
|
|
if len(image) < ENDF_LEN:
|
|
return False
|
|
t = image[-ENDF_LEN:]
|
|
return (t[:4] == ENDF_MAGIC and struct.unpack("<I", t[4:8])[0] == len(image) - ENDF_LEN
|
|
and t[8:16] == mh8(image[:-ENDF_LEN]))
|
|
|
|
|
|
def parse_endf(image: bytes) -> Tuple[bytes, bytes]:
|
|
"""Return (body, body_hash8) for an image that ends with a valid EndF. Raises otherwise."""
|
|
if not has_endf(image):
|
|
raise ValueError("image has no valid EndF trailer")
|
|
return image[:-ENDF_LEN], image[-ENDF_LEN + 8:-ENDF_LEN + 16]
|
|
|
|
|
|
def parse_endf_ident(image: bytes) -> Optional["FwIdent"]:
|
|
"""The self-describing identity from a valid EndF trailer, or None if there is no valid trailer."""
|
|
if not has_endf(image):
|
|
return None
|
|
t = image[-ENDF_LEN:]
|
|
fw, tgt = struct.unpack("<II", t[16:24])
|
|
return FwIdent(fw, tgt, t[24:56].rstrip(b"\0").decode("ascii", "replace"))
|
|
|
|
|
|
def ensure_endf(image: bytes, ident: Optional["FwIdent"] = None) -> Tuple[bytes, bytes]:
|
|
"""Return (image_with_endf, body_hash8). Appends EndF (with `ident` if given) if not already present."""
|
|
if has_endf(image):
|
|
_, h8 = parse_endf(image)
|
|
return image, h8
|
|
body_hash8 = mh8(image)
|
|
return image + build_endf(image, ident), body_hash8
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Merkle tree (sha2-256:4 leaves/nodes, promote-odd, no padding)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def block_count(payload_size: int, block_size: int) -> int:
|
|
return (payload_size + block_size - 1) // block_size
|
|
|
|
|
|
def leaf_hashes(payload: bytes, block_size: int) -> List[bytes]:
|
|
return [mh4(payload[i:i + block_size]) for i in range(0, len(payload), block_size)]
|
|
|
|
|
|
def merkle_root(leaves: List[bytes]) -> bytes:
|
|
if not leaves:
|
|
raise ValueError("empty payload / no leaves")
|
|
level = list(leaves)
|
|
while len(level) > 1:
|
|
nxt = []
|
|
n = len(level)
|
|
for i in range(0, n, 2):
|
|
if i + 1 < n:
|
|
nxt.append(mh4(level[i] + level[i + 1]))
|
|
else:
|
|
nxt.append(level[i]) # promote lone last node unchanged
|
|
level = nxt
|
|
return level[0]
|
|
|
|
|
|
def merkle_proof(leaves: List[bytes], index: int) -> List[Tuple[bytes, bool]]:
|
|
"""Proof for block `index`: list of (sibling_digest, sibling_is_left)."""
|
|
proof: List[Tuple[bytes, bool]] = []
|
|
level = list(leaves)
|
|
idx = index
|
|
while len(level) > 1:
|
|
n = len(level)
|
|
is_last_odd = (n % 2 == 1) and (idx == n - 1)
|
|
if not is_last_odd:
|
|
if idx % 2 == 0:
|
|
proof.append((level[idx + 1], False)) # sibling on the right
|
|
else:
|
|
proof.append((level[idx - 1], True)) # sibling on the left
|
|
nxt = [mh4(level[i] + level[i + 1]) if i + 1 < n else level[i]
|
|
for i in range(0, n, 2)]
|
|
idx //= 2
|
|
level = nxt
|
|
return proof
|
|
|
|
|
|
def proof_siblings(leaves: List[bytes], index: int) -> bytes:
|
|
"""Wire form of a proof: just the ordered sibling digests, concatenated.
|
|
|
|
The left/right direction is derived by the verifier from the block index + count
|
|
(sibling is on the left iff the current index is odd), so no direction bits are sent.
|
|
"""
|
|
return b"".join(sib for sib, _ in merkle_proof(leaves, index))
|
|
|
|
|
|
def verify_proof(leaf: bytes, index: int, proof: List[Tuple[bytes, bool]],
|
|
root: bytes, count: int) -> bool:
|
|
h = leaf
|
|
idx = index
|
|
n = count
|
|
p = 0
|
|
while n > 1:
|
|
is_last_odd = (n % 2 == 1) and (idx == n - 1)
|
|
if is_last_odd:
|
|
pass # promoted, no proof element
|
|
else:
|
|
if p >= len(proof):
|
|
return False
|
|
sib, is_left = proof[p]
|
|
p += 1
|
|
h = mh4(sib + h) if is_left else mh4(h + sib)
|
|
idx //= 2
|
|
n = (n + 1) // 2
|
|
return h == root and p == len(proof)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Manifest + container
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class Manifest:
|
|
format_ver: int = FORMAT_VER
|
|
flags: int = 0
|
|
hash_algo: int = HASH_ALGO_SHA256
|
|
target_id: int = 0
|
|
fw_version: int = 0
|
|
image_size: int = 0
|
|
payload_size: int = 0
|
|
block_size_log2: int = 11
|
|
merkle_root: bytes = b"\0\0\0\0"
|
|
image_hash: bytes = b"\0" * 32
|
|
codec_id: int = CODEC_FULL
|
|
hw_id: bytes = b"\0" * 32 # 32-byte NUL-padded ASCII hardware tag (signed)
|
|
# Fixed-layout: these are ALWAYS present (zero-filled when not applicable), so the manifest has a
|
|
# constant size and a trivial offset-based parser; only leaves[] is variable.
|
|
base_hash: bytes = b"\0" * 8 # 8 bytes; zero for a full image (meaningful iff !FULL)
|
|
signer_pubkey: bytes = b"\0" * 32 # 32 bytes; zero when unsigned (meaningful iff SIGNED)
|
|
signature: bytes = b"\0" * 64 # 64 bytes; zero when unsigned (meaningful iff SIGNED)
|
|
approval: bytes = APPROVAL_NOT
|
|
leaves: List[bytes] = field(default_factory=list)
|
|
|
|
@property
|
|
def is_full(self) -> bool:
|
|
return bool(self.flags & FLAG_FULL)
|
|
|
|
@property
|
|
def is_signed(self) -> bool:
|
|
return bool(self.flags & FLAG_SIGNED)
|
|
|
|
@property
|
|
def is_bootloader(self) -> bool:
|
|
return bool(self.flags & FLAG_BOOTLOADER)
|
|
|
|
@property
|
|
def block_size(self) -> int:
|
|
return 1 << self.block_size_log2
|
|
|
|
@property
|
|
def block_count(self) -> int:
|
|
return block_count(self.payload_size, self.block_size)
|
|
|
|
def signed_region(self) -> bytes:
|
|
"""Bytes the Ed25519 signature covers: format_ver .. signer_pubkey (fixed 129 bytes)."""
|
|
out = bytearray()
|
|
out += bytes([self.format_ver, self.flags, self.hash_algo])
|
|
out += struct.pack("<IIII", self.target_id, self.fw_version,
|
|
self.image_size, self.payload_size)
|
|
out += bytes([self.block_size_log2])
|
|
out += self.merkle_root
|
|
out += self.image_hash
|
|
out += bytes([self.codec_id])
|
|
out += self.hw_id # 32-byte hardware tag (part of the signed head)
|
|
out += self.base_hash # always present (zero for a full image)
|
|
out += self.signer_pubkey # always present (zero when unsigned)
|
|
return bytes(out)
|
|
|
|
def serialize(self) -> bytes:
|
|
"""Fixed layout: signed_region(129) + signature(64) + approval(4) + leaves[4*BC]."""
|
|
out = bytearray(self.signed_region())
|
|
out += self.signature # always present (zero when unsigned)
|
|
out += self.approval
|
|
for lf in self.leaves:
|
|
out += lf
|
|
return bytes(out)
|
|
|
|
|
|
def hw_id_bytes(s) -> bytes:
|
|
"""Pack a hardware tag (str or bytes) into the fixed 32-byte NUL-padded field."""
|
|
if s is None:
|
|
return b"\0" * 32
|
|
raw = s.encode("ascii") if isinstance(s, str) else bytes(s)
|
|
if len(raw) > 32:
|
|
raise ValueError("hw_id must be <= 32 bytes")
|
|
return raw + b"\0" * (32 - len(raw))
|
|
|
|
|
|
def xiao_bootloader_hw_id(board_id: int) -> str:
|
|
if board_id == XIAO_BOOT_BOARD_ID_BASE:
|
|
return "XIAO_BL_28860044"
|
|
if board_id == XIAO_BOOT_BOARD_ID_SENSE:
|
|
return "XIAO_BL_28860045"
|
|
raise ValueError(f"unsupported XIAO bootloader board ID 0x{board_id:08X}")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BootloaderIdentity:
|
|
manifest_offset: int
|
|
image_start: int
|
|
image_size: int
|
|
board_id: int
|
|
device_name: str
|
|
crc32: int
|
|
boot_version: Optional[int] = None
|
|
softdevice_family: Optional[int] = None
|
|
softdevice_fwid: Optional[int] = None
|
|
app_base: Optional[int] = None
|
|
layout_abi: Optional[int] = None
|
|
compat_flags: Optional[int] = None
|
|
|
|
|
|
XiaoBootloaderIdentity = BootloaderIdentity # compatibility for callers of the original reference API
|
|
|
|
|
|
def bootloader_version_valid(version: int) -> bool:
|
|
return version not in (0, 0xFFFFFFFF) and version & 0xFF != 0
|
|
|
|
|
|
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
|
|
try:
|
|
end = name_raw.index(0)
|
|
except ValueError:
|
|
return None
|
|
if end == 0 or end > 15 or any(name_raw[end:]) or any(b < 0x21 or b > 0x7E for b in name_raw[:end]):
|
|
return None
|
|
return name_raw[:end].decode("ascii", "strict")
|
|
|
|
|
|
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):
|
|
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):
|
|
return board_id
|
|
return int.from_bytes(sha256(bootloader_hw_id(board_id, device_name))[:4], "little")
|
|
|
|
|
|
def audit_bootloader_target_inventory(application_target_ids=()) -> dict:
|
|
"""Return target->identity after rejecting boot/app target collisions.
|
|
|
|
The generic image parser intentionally accepts future canonical embedded
|
|
identities for diagnostics. Only the curated identities below are allowed
|
|
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
|
|
app_ids = set(application_target_ids)
|
|
targets = {}
|
|
for identity in identities:
|
|
target = bootloader_target_id(*identity)
|
|
if target in targets:
|
|
raise ValueError(
|
|
f"bootloader target collision 0x{target:08X}: "
|
|
f"{targets[target]!r} vs {identity!r}")
|
|
if target in app_ids:
|
|
raise ValueError(
|
|
f"bootloader target 0x{target:08X} collides with an application target")
|
|
targets[target] = identity
|
|
return targets
|
|
|
|
|
|
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")))
|
|
|
|
|
|
def bootloader_qualified_platform_profile(board_id: int, device_name: str):
|
|
"""Return exact (family, FWID, app_base, layout ABI), or None if unqualified."""
|
|
identity = (board_id, device_name)
|
|
if not bootloader_identity_is_buildable(*identity):
|
|
return None
|
|
if identity in BOOTLOADER_S140_V7_IDENTITIES:
|
|
return (BOOT_CONTINUITY_FAMILY_S140, BOOTLOADER_S140_V7_FWID,
|
|
NRF52_APP_BASE_S140_V7, BOOT_CONTINUITY_LAYOUT_ABI)
|
|
return (BOOT_CONTINUITY_FAMILY_S140, BOOTLOADER_S140_V6_FWID,
|
|
NRF52_APP_BASE_S140_V6, BOOT_CONTINUITY_LAYOUT_ABI)
|
|
|
|
|
|
def bootloader_qualified_storage_profiles(board_id: int, device_name: str):
|
|
"""Return the exact allowed capability-marker profiles for one identity."""
|
|
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")):
|
|
return (BOOT_STORAGE_QSPI_UPDATE,)
|
|
if identity in SD_BOOTLOADER_IDENTITIES:
|
|
# One embedded Tower identity supports two distinct application
|
|
# targets; its marker selects either the shared internal slot or SD.
|
|
return (BOOT_STORAGE_INTERNAL_UPDATE, BOOT_STORAGE_SD_UPDATE)
|
|
if identity in INTERNAL_BOOTLOADER_IDENTITIES:
|
|
return (BOOT_STORAGE_INTERNAL_UPDATE,)
|
|
return None
|
|
|
|
|
|
def _bootloader_vector_sane(image: bytes) -> bool:
|
|
if len(image) < 8:
|
|
return False
|
|
sp, reset = struct.unpack_from("<II", image)
|
|
entry = reset & ~1
|
|
return (sp & 7) == 0 and 0x20000000 <= sp <= 0x20040000 and (reset & 1) != 0 \
|
|
and XIAO_BOOT_IMAGE_START <= entry < XIAO_BOOT_IMAGE_START + XIAO_BOOT_IMAGE_SIZE
|
|
|
|
|
|
def parse_bootloader_identity(image: bytes) -> Optional[BootloaderIdentity]:
|
|
"""Find one CRC-valid base identity, then validate its optional BLM2 extension.
|
|
|
|
Duplicate accounting intentionally mirrors deployed legacy bootloaders: a
|
|
CRC-valid BLMF-v1 record counts even when adjacent claimed BLM2 metadata is
|
|
malformed. Otherwise a corrupt extension could hide a second privileged
|
|
identity from a new packager while an installed legacy updater rejects it.
|
|
"""
|
|
if len(image) != XIAO_BOOT_IMAGE_SIZE:
|
|
return None
|
|
found = None
|
|
for off in range(0, len(image) - XIAO_BOOT_MANIFEST_SIZE + 1, 4):
|
|
if image[off:off + 8] != XIAO_BOOT_MANIFEST_MAGIC:
|
|
continue
|
|
version, size = struct.unpack_from("<HH", image, off + 8)
|
|
start, image_size, board_id = struct.unpack_from("<III", image, off + 12)
|
|
name_raw = image[off + 24:off + 40]
|
|
stored_crc = struct.unpack_from("<I", image, off + 40)[0]
|
|
device_name = _bootloader_device_name(board_id, name_raw)
|
|
if (version != XIAO_BOOT_MANIFEST_VERSION or size != XIAO_BOOT_MANIFEST_SIZE or
|
|
start != XIAO_BOOT_IMAGE_START or image_size != XIAO_BOOT_IMAGE_SIZE or
|
|
device_name is None):
|
|
continue
|
|
crc_image = bytearray(image)
|
|
crc_image[off + 40:off + 44] = b"\0" * 4
|
|
import zlib
|
|
if zlib.crc32(crc_image) & 0xFFFFFFFF != stored_crc:
|
|
continue
|
|
candidate = BootloaderIdentity(off, start, image_size, board_id, device_name,
|
|
stored_crc)
|
|
if found is not None:
|
|
return None
|
|
found = candidate
|
|
if found is None:
|
|
return None
|
|
|
|
ext = image[found.manifest_offset + XIAO_BOOT_MANIFEST_SIZE:
|
|
found.manifest_offset + BOOT_ENVELOPE_SIZE]
|
|
magic0 = len(ext) >= 4 and ext[:4] == BOOT_CONTINUITY_MAGIC[:4]
|
|
magic1 = len(ext) >= 8 and ext[4:8] == BOOT_CONTINUITY_MAGIC[4:8]
|
|
if not magic0 and not magic1:
|
|
return found
|
|
if len(ext) != BOOT_CONTINUITY_SIZE or not magic0 or not magic1:
|
|
return None
|
|
ext_version, ext_size = struct.unpack_from("<HH", ext, 8)
|
|
boot_version, family, fwid, app_base, layout_abi, compat, reserved = \
|
|
struct.unpack_from("<IHHIHHI", ext, 12)
|
|
if (ext_version != BOOT_CONTINUITY_VERSION or ext_size != BOOT_CONTINUITY_SIZE or
|
|
not bootloader_version_valid(boot_version) or family == 0 or fwid == 0 or
|
|
app_base == 0 or layout_abi == 0 or compat != 0 or reserved != 0):
|
|
return None
|
|
return BootloaderIdentity(found.manifest_offset, found.image_start, found.image_size,
|
|
found.board_id, found.device_name, found.crc32,
|
|
boot_version, family, fwid, app_base, layout_abi, compat)
|
|
|
|
|
|
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
|
|
|
|
|
|
def bootloader_caps_storage(image: bytes) -> Optional[int]:
|
|
"""Return the one exact supported self-update storage marker, or None."""
|
|
found = None
|
|
valid_count = 0
|
|
for off in range(0, len(image) - 16 + 1, 4):
|
|
if image[off:off + 8] != XIAO_BOOT_CAPS_MAGIC:
|
|
continue
|
|
abi, codecs = struct.unpack_from("<HH", image, off + 8)
|
|
storage = image[off + 12]
|
|
if (abi < BOOT_FORMAT_VER or abi == 0xFFFF or
|
|
codecs & BOOT_REQUIRED_APP_CODEC_MASK != BOOT_REQUIRED_APP_CODEC_MASK or
|
|
storage & ~BOOT_STORAGE_KNOWN or image[off + 13:off + 16] != b"\0\0\0" or
|
|
not storage & XIAO_BOOT_STORAGE_UPDATE):
|
|
continue
|
|
valid_count += 1
|
|
if valid_count != 1:
|
|
return None
|
|
if storage in (BOOT_STORAGE_SD_UPDATE, BOOT_STORAGE_QSPI_UPDATE,
|
|
BOOT_STORAGE_INTERNAL_UPDATE, BOOT_STORAGE_RAK_AUTO_UPDATE):
|
|
found = storage
|
|
return found if valid_count == 1 else None
|
|
|
|
|
|
def xiao_bootloader_caps_ok(image: bytes) -> bool:
|
|
return bootloader_caps_storage(image) == BOOT_STORAGE_QSPI_UPDATE
|
|
|
|
|
|
def bootloader_optional_app_storage(image: bytes) -> int:
|
|
offsets = [off for off in range(0, len(image) - 7, 4)
|
|
if image[off:off + 8] == b"MOTASTOR"]
|
|
if not offsets:
|
|
return 0
|
|
flags = image[offsets[0] + 12] if offsets[0] + 16 <= len(image) else 0
|
|
ram = b"MOTARAMA" + struct.pack("<HHI", 1, 72, 65536)
|
|
record = ram + b"MOTASTOR" + struct.pack("<HHBBBB", 1, 16, flags, 0, 0, 0)
|
|
ram_count = sum(image[off:off + 16] == ram
|
|
for off in range(0, len(image) - 15, 4))
|
|
if (len(offsets) != 1 or offsets[0] < 16 or
|
|
flags not in (0x14, 0x02) or ram_count != 1 or
|
|
image[offsets[0] - 16:offsets[0] + 16] != record):
|
|
raise ValueError("optional application storage marker is invalid or ambiguous")
|
|
return flags
|
|
|
|
|
|
def validate_bootloader_image(image: bytes, target_id: Optional[int] = None,
|
|
signed_hw_id: Optional[bytes] = None) -> BootloaderIdentity:
|
|
if len(image) != XIAO_BOOT_IMAGE_SIZE:
|
|
raise ValueError(f"bootloader image must be exactly {XIAO_BOOT_IMAGE_SIZE} bytes")
|
|
if not _bootloader_vector_sane(image):
|
|
raise ValueError("bootloader vector table is invalid")
|
|
identity = parse_bootloader_identity(image)
|
|
if identity is None:
|
|
raise ValueError("bootloader embedded manifest/CRC is invalid or ambiguous")
|
|
if identity.boot_version is None:
|
|
raise ValueError("bootloader lacks the BLM2/SOFT continuity extension")
|
|
if identity.manifest_offset != BOOT_CANDIDATE_MANIFEST_OFFSET:
|
|
raise ValueError(
|
|
f"bootloader continuity envelope must be at exact offset "
|
|
f"0x{BOOT_CANDIDATE_MANIFEST_OFFSET:04X}")
|
|
expected_target = bootloader_target_id(identity.board_id, identity.device_name)
|
|
if target_id is not None and target_id != expected_target:
|
|
raise ValueError("bootloader target ID does not match embedded identity")
|
|
expected_hw = bootloader_hw_id(identity.board_id, identity.device_name)
|
|
if signed_hw_id is not None and bytes(signed_hw_id) != expected_hw:
|
|
raise ValueError("bootloader signed hw_id does not match embedded identity")
|
|
expected_storage = bootloader_qualified_storage_profiles(
|
|
identity.board_id, identity.device_name)
|
|
if expected_storage is None:
|
|
# Keep generic parsing/inspection useful for future canonical images;
|
|
# only the signing/build path below admits the curated inventory.
|
|
expected_storage = (BOOT_STORAGE_INTERNAL_UPDATE,)
|
|
actual_storage = bootloader_caps_storage(image)
|
|
if actual_storage not in expected_storage:
|
|
expected = "/".join(f"0x{value:02X}" for value in expected_storage)
|
|
raise ValueError(f"bootloader lacks exact ABI 3 self-update capabilities {expected}")
|
|
optional_storage = bootloader_optional_app_storage(image)
|
|
if optional_storage == 0x14 and (actual_storage != BOOT_STORAGE_INTERNAL_UPDATE or
|
|
identity.board_id != 0x239A0029 or
|
|
identity.device_name not in ("3401_DFU", "4631_DFU")):
|
|
raise ValueError("optional RAK application storage requires the deployed board identity")
|
|
if optional_storage == 0x02 and (actual_storage != BOOT_STORAGE_SD_UPDATE or
|
|
(identity.board_id, identity.device_name) != (0x239A0071, "TOWER_V2_OTA")):
|
|
raise ValueError("optional internal storage requires the deployed MeshTower SD identity")
|
|
if actual_storage == BOOT_STORAGE_RAK_AUTO_UPDATE or optional_storage:
|
|
ram_marker = b"MOTARAMA" + struct.pack("<HHI", 1, 72, 65536)
|
|
count = sum(image[off:off + 16] == ram_marker
|
|
for off in range(0, len(image) - 15, 4))
|
|
if count != 1 or struct.unpack_from("<I", image)[0] > 0x20030000:
|
|
raise ValueError("adaptive bootloader must retain its 64 KiB hybrid RAM arena")
|
|
expected_platform = bootloader_qualified_platform_profile(
|
|
identity.board_id, identity.device_name)
|
|
if expected_platform is not None:
|
|
actual_platform = (identity.softdevice_family, identity.softdevice_fwid,
|
|
identity.app_base, identity.layout_abi)
|
|
if actual_platform != expected_platform:
|
|
family, fwid, app_base, layout_abi = expected_platform
|
|
raise ValueError(
|
|
"bootloader continuity platform does not match qualified "
|
|
f"profile S{family}/0x{fwid:04X}/0x{app_base:X}/ABI{layout_abi}")
|
|
return identity
|
|
|
|
|
|
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):
|
|
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")
|
|
return identity
|
|
|
|
|
|
def _validate_lengths(m: Manifest):
|
|
assert len(m.merkle_root) == 4
|
|
assert len(m.image_hash) == 32
|
|
assert len(m.hw_id) == 32
|
|
assert len(m.approval) == 4
|
|
assert len(m.base_hash) == 8 # fixed layout: always present (zero for full)
|
|
assert len(m.signer_pubkey) == 32
|
|
assert len(m.signature) == 64
|
|
|
|
|
|
def build_manifest(*, target_id: int, fw_version: int, image_size: int, payload: bytes,
|
|
block_size: int, image_hash: bytes, codec_id: int, is_full: bool,
|
|
base_hash: Optional[bytes] = None, sign_priv=None, hw_id=None,
|
|
bootloader: bool = False) -> Manifest:
|
|
assert (block_size & (block_size - 1)) == 0, "block_size must be a power of two"
|
|
if not 1 < block_size <= MAX_APP_BLOCK_SIZE:
|
|
raise ValueError(
|
|
f"application block size must be at most {MAX_APP_BLOCK_SIZE} bytes")
|
|
leaves = leaf_hashes(payload, block_size)
|
|
if bootloader:
|
|
identity = validate_bootloader_image(payload, target_id)
|
|
audit_bootloader_target_inventory()
|
|
if not bootloader_identity_is_buildable(identity.board_id, identity.device_name):
|
|
raise ValueError("bootloader embedded identity is not in the qualified build inventory")
|
|
if (not bootloader_version_valid(fw_version) or
|
|
fw_version != identity.boot_version or not is_full or codec_id != CODEC_FULL or
|
|
image_size != XIAO_BOOT_IMAGE_SIZE or
|
|
block_size != BOOTLOADER_BLOCK_SIZE or base_hash not in (None, b"\0" * 8)):
|
|
raise ValueError("bootloader package version must equal BLM2 metadata and use a 40 KiB CODEC_FULL image with 1 KiB blocks")
|
|
if sign_priv is None:
|
|
raise ValueError("bootloader package must be signed")
|
|
expected_hw = bootloader_hw_id(identity.board_id, identity.device_name)
|
|
if hw_id is not None and hw_id_bytes(hw_id) != expected_hw:
|
|
raise ValueError("bootloader package hw_id must be canonical for its embedded identity")
|
|
hw_id = expected_hw
|
|
m = Manifest(
|
|
format_ver=BOOT_FORMAT_VER if bootloader else APP_FORMAT_VER,
|
|
flags=((FLAG_FULL if is_full else 0) | (FLAG_SIGNED if sign_priv is not None else 0) |
|
|
(FLAG_BOOTLOADER if bootloader else 0)),
|
|
target_id=target_id,
|
|
fw_version=fw_version,
|
|
image_size=image_size,
|
|
payload_size=len(payload),
|
|
block_size_log2=block_size.bit_length() - 1,
|
|
merkle_root=merkle_root(leaves),
|
|
image_hash=image_hash,
|
|
codec_id=codec_id,
|
|
hw_id=hw_id_bytes(hw_id),
|
|
base_hash=(b"\0" * 8 if is_full else base_hash), # always 8 bytes; zero for a full image
|
|
leaves=leaves,
|
|
)
|
|
if not is_full and (base_hash is None or len(base_hash) != 8):
|
|
raise ValueError("delta requires an 8-byte base_hash")
|
|
if sign_priv is not None:
|
|
m.signer_pubkey = ed25519_public_bytes(sign_priv)
|
|
m.signature = sign_priv.sign(m.signed_region()) # else signer_pubkey/signature stay zero
|
|
_validate_lengths(m)
|
|
return m
|
|
|
|
|
|
def build_container(manifest: Manifest, payload: bytes) -> bytes:
|
|
assert len(payload) == manifest.payload_size
|
|
mser = manifest.serialize()
|
|
total = 4 + 4 + len(mser) + len(payload) + len(TRAILER)
|
|
return MAGIC + struct.pack("<I", total) + mser + payload + TRAILER
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class Parsed:
|
|
manifest: Manifest
|
|
payload: bytes
|
|
total_size: int
|
|
|
|
|
|
def parse_container(blob: bytes) -> Parsed:
|
|
if blob[:4] != MAGIC:
|
|
raise ValueError("bad MAGIC")
|
|
if blob[-5:] != TRAILER:
|
|
raise ValueError("bad TRAILER")
|
|
total = struct.unpack("<I", blob[4:8])[0]
|
|
if total != len(blob):
|
|
raise ValueError(f"MOTA_TOTAL_SIZE {total} != actual {len(blob)}")
|
|
|
|
r = io.BytesIO(blob[8:-5]) # manifest + payload (trailer already validated/stripped)
|
|
|
|
def take(n):
|
|
b = r.read(n)
|
|
if len(b) != n:
|
|
raise ValueError("truncated manifest")
|
|
return b
|
|
|
|
m = Manifest()
|
|
m.format_ver = take(1)[0]
|
|
m.flags = take(1)[0]
|
|
if m.format_ver == APP_FORMAT_VER:
|
|
if m.flags & FLAG_BOOTLOADER or m.flags & ~FLAG_KNOWN:
|
|
raise ValueError("v2 application manifest has invalid flags")
|
|
elif m.format_ver == BOOT_FORMAT_VER:
|
|
if m.flags != FLAG_FULL | FLAG_SIGNED | FLAG_BOOTLOADER:
|
|
raise ValueError("v3 bootloader manifest requires exact FULL|SIGNED|BOOTLOADER flags")
|
|
else:
|
|
raise ValueError(f"unsupported format_ver {m.format_ver}")
|
|
m.hash_algo = take(1)[0]
|
|
m.target_id, m.fw_version, m.image_size, m.payload_size = struct.unpack("<IIII", take(16))
|
|
m.block_size_log2 = take(1)[0]
|
|
if m.format_ver == APP_FORMAT_VER:
|
|
if not 1 <= m.block_size_log2 <= MAX_APP_BLOCK_SIZE.bit_length() - 1:
|
|
raise ValueError(
|
|
f"v2 application block size must be at most {MAX_APP_BLOCK_SIZE} bytes")
|
|
elif m.block_size_log2 != BOOTLOADER_BLOCK_SIZE.bit_length() - 1:
|
|
raise ValueError("v3 bootloader manifest requires 1 KiB blocks")
|
|
m.merkle_root = take(4)
|
|
m.image_hash = take(32)
|
|
m.codec_id = take(1)[0]
|
|
m.hw_id = take(32)
|
|
m.base_hash = take(8) # fixed layout: always present (zero for full)
|
|
m.signer_pubkey = take(32)
|
|
m.signature = take(64)
|
|
m.approval = take(4)
|
|
bc = m.block_count
|
|
m.leaves = [take(4) for _ in range(bc)]
|
|
|
|
payload = r.read(m.payload_size)
|
|
if len(payload) != m.payload_size:
|
|
raise ValueError("truncated payload")
|
|
rest = r.read() # trailer was stripped above, so nothing should remain
|
|
if rest != b"":
|
|
raise ValueError("trailing bytes after payload")
|
|
if m.is_bootloader:
|
|
if (not bootloader_version_valid(m.fw_version) or m.codec_id != CODEC_FULL or
|
|
m.image_size != XIAO_BOOT_IMAGE_SIZE or
|
|
m.payload_size != XIAO_BOOT_IMAGE_SIZE or
|
|
m.block_size_log2 != 10 or m.block_count != 40 or m.base_hash != b"\0" * 8):
|
|
raise ValueError("v3 bootloader manifest geometry/identity is invalid")
|
|
try:
|
|
identity = validate_bootloader_image(payload, m.target_id, m.hw_id)
|
|
if m.fw_version != identity.boot_version:
|
|
raise ValueError("outer bootloader version does not match BLM2 metadata")
|
|
except (UnicodeDecodeError, ValueError) as exc:
|
|
raise ValueError(f"v3 bootloader image identity/capability is invalid: {exc}") from exc
|
|
return Parsed(manifest=m, payload=payload, total_size=total)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Verification
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def verify(parsed: Parsed, *, expect_pub: Optional[bytes] = None,
|
|
base_image: Optional[bytes] = None) -> List[str]:
|
|
"""Return a list of problem strings (empty == fully valid for what could be checked)."""
|
|
problems: List[str] = []
|
|
m, payload = parsed.manifest, parsed.payload
|
|
|
|
if m.is_bootloader:
|
|
try:
|
|
identity = validate_bootloader_image(payload, m.target_id, m.hw_id)
|
|
if m.fw_version != identity.boot_version:
|
|
problems.append("outer bootloader version does not match BLM2 metadata")
|
|
except (UnicodeDecodeError, ValueError) as exc:
|
|
problems.append(f"bootloader image contract: {exc}")
|
|
|
|
# block_count / leaves
|
|
if len(m.leaves) != m.block_count:
|
|
problems.append(f"leaves count {len(m.leaves)} != block_count {m.block_count}")
|
|
|
|
# merkle root must match recomputation from the actual payload blocks
|
|
recomputed_leaves = leaf_hashes(payload, m.block_size)
|
|
if recomputed_leaves != m.leaves:
|
|
problems.append("stored leaves[] do not match payload blocks")
|
|
try:
|
|
if merkle_root(recomputed_leaves) != m.merkle_root:
|
|
problems.append("merkle_root does not match payload")
|
|
except ValueError as e:
|
|
problems.append(f"merkle: {e}")
|
|
|
|
# spot-check a proof round-trips (block 0 and last)
|
|
if recomputed_leaves:
|
|
for idx in {0, len(recomputed_leaves) - 1}:
|
|
pr = merkle_proof(recomputed_leaves, idx)
|
|
if not verify_proof(recomputed_leaves[idx], idx, pr, m.merkle_root, len(recomputed_leaves)):
|
|
problems.append(f"merkle proof failed for block {idx}")
|
|
|
|
# approval must be 'not approved' in a distributed container
|
|
if m.approval != APPROVAL_NOT:
|
|
problems.append(f"approval is not the erased sentinel (got {m.approval.hex()})")
|
|
|
|
# signature
|
|
if m.is_signed:
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
from cryptography.exceptions import InvalidSignature
|
|
pub = Ed25519PublicKey.from_public_bytes(m.signer_pubkey)
|
|
try:
|
|
pub.verify(m.signature, m.signed_region())
|
|
except InvalidSignature:
|
|
problems.append("Ed25519 signature INVALID")
|
|
if expect_pub is not None and m.signer_pubkey != expect_pub:
|
|
problems.append("signer_pubkey != expected key")
|
|
|
|
# image_hash: directly checkable only for full images (payload IS the image)
|
|
if m.is_full:
|
|
if mh32(payload) != m.image_hash:
|
|
problems.append("image_hash does not match full payload")
|
|
elif base_image is not None:
|
|
# delta: optionally apply against a provided base to confirm image_hash
|
|
try:
|
|
rebuilt = _apply_detools_delta(m.codec_id, payload, _ensure_base(base_image))
|
|
if mh32(rebuilt) != m.image_hash:
|
|
problems.append("delta applied to base does not match image_hash")
|
|
if len(rebuilt) != m.image_size:
|
|
problems.append("delta result size != image_size")
|
|
except Exception as e: # noqa: BLE001
|
|
problems.append(f"delta apply check failed: {e}")
|
|
return problems
|
|
|
|
|
|
def _apply_detools_delta(codec_id: int, payload: bytes, base_image: bytes) -> bytes:
|
|
"""Apply a detools delta using the decoder selected by its manifest codec."""
|
|
import detools
|
|
|
|
if codec_id == CODEC_DETOOLS_SEQUENTIAL:
|
|
out = io.BytesIO()
|
|
detools.apply_patch(io.BytesIO(base_image), io.BytesIO(payload), out)
|
|
return out.getvalue()
|
|
|
|
if codec_id == CODEC_DETOOLS_INPLACE:
|
|
patch_type, patch_info = detools.patch_info(io.BytesIO(payload))
|
|
if patch_type != "in-place":
|
|
raise ValueError(f"in-place codec contains a {patch_type} patch")
|
|
|
|
# detools embeds the required flash-memory geometry in every in-place
|
|
# patch. apply_patch_in_place() requires a mutable image of at least
|
|
# that size and writes the reconstructed image at offset zero.
|
|
memory_size = patch_info[3]
|
|
memory = io.BytesIO(base_image.ljust(memory_size, b"\xff"))
|
|
rebuilt_size = detools.apply_patch_in_place(memory, io.BytesIO(payload))
|
|
memory.seek(0)
|
|
return memory.read(rebuilt_size)
|
|
|
|
raise ValueError(f"unsupported detools codec_id {codec_id}")
|
|
|
|
|
|
def _ensure_base(base_image: bytes) -> bytes:
|
|
img, _ = ensure_endf(base_image)
|
|
return img
|