scripts: unify the BitBox image format
What changed, and why it matters
This commit refactors the firmware image header format used by BitBox hardware wallets so that BitBox02 and BitBox03 share a single 1024-byte layout. It adds new metadata fields (product ID, version numbers, signature slots) and switches the stored image length from 32-bit to 64-bit. The Rust bootloader code that reads these headers is updated with explicit length checks to avoid integer problems. There is no direct evidence in the commit of an exploitable vulnerability being fixed; it reads as a structural unification and hardening change.
Treat as a format-hardening refactor rather than an active vulnerability fix. Review that all shipped BitBox02 stage1 headers remain compatible (tests claim this), verify the new Rust bounds checks cover every boot path, and ensure the signature-slot offsets match the bootloader's verification code before release.
Security signals we found
Changed binary image header format and parser
Added checked integer conversions for 64-bit image length
Added bounds checks on slot length, header length, and image length in Rust loader
Added validation of magic, header version, reserved bytes, and signature slots in Python tooling
Added tests for malformed headers and out-of-bounds lengths
No explicit security bug or CVE mentioned in commit message
Evidence from the diff
The patch unifies the BitBox image header format across devices. scripts/image_header.py now builds a 1024-byte header with BBS1/BBFW magic, flags, product_id, monotonic_version, marketing_version, header_version, a 64-bit image_len, reserved bytes, and three 64-byte signature slots. The Rust ImageHeader struct and vector_table_from_image_header loader are updated to parse the new layout and perform checked conversions (usize::try_from for image_len, checked_sub for code length, slot_len < IMAGE_HEADER_LEN guard). Tests are added/updated to verify compatibility with shipped BitBox02 stage1 headers, malformed header rejection, ELF flash span finalization, and loader bounds. The change is defensive: it reduces parsing ambiguity and enforces length bounds, but it is also a format change that could introduce compatibility risks if not fully rolled out.
Changed components
scripts/image_header.pysrc/rust/bitbox-boot-utils/src/image_header.rssrc/rust/bitbox-boot-utils/src/lib.rssrc/rust/bins/bitbox03-boot1/image_header.jsonsrc/rust/bins/bitbox03-firmware/image_header.jsontest/scripts/test_image_header.pyInspect captured patch +524 / −37
### scripts/image_header.py
@@ -1,51 +1,149 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
-"""Create and update BitBox03 image headers."""
+"""Create and update headers in the BitBox image format.
+
+Manifests specify magic (BBS1 for stage1, BBFW for firmware), flags, product_id,
+monotonic_version, and marketing_version. Header format version, lengths,
+reserved bytes, and empty signature slots are derived from the binary format.
+The image length includes the header.
+
+Use finalize-elf --section .stage1_header for existing BitBox02 stage1 ELFs.
+"""
from __future__ import annotations
import argparse
import json
+import struct
from pathlib import Path
from typing import Any, TypedDict, cast
# Must stay in sync with IMAGE_HEADER_LEN in src/rust/bitbox-boot-utils/src/image_header.rs.
HEADER_LEN = 1024
+# Must stay in sync with src/bootloader_upgrade/bootloader_upgrade.h and
+# src/bootloader/bootloader_product.h.
+STAGE1_MAGIC = b"BBS1"
+FIRMWARE_MAGIC = b"BBFW"
+HEADER_VERSION = 1
+BITBOX02_STAGE1_MAX_LEN = 0xBFE0
+MARKETING_VERSION_MAX_LEN = 37
+HEADER_PREFIX = struct.Struct("<4sIHHIQHB37s")
+SIGNATURES_OFFSET = 832
+
class HeaderManifest(TypedDict):
magic: bytes
+ flags: int
+ product_id: int
+ monotonic_version: int
+ marketing_version: str
+
+
+def _manifest_int(manifest: dict[str, Any], name: str, minimum: int, maximum: int) -> int:
+ value = manifest.get(name)
+ if type(value) is not int or not minimum <= value <= maximum:
+ raise ValueError(f"manifest field '{name}' must be an integer in {minimum}..{maximum}")
+ return value
+
+
+def _marketing_version(value: Any) -> bytes:
+ if not isinstance(value, str):
+ raise ValueError("manifest field 'marketing_version' must be a string")
+ version = value.encode("ascii")
+ if not 1 <= len(version) <= MARKETING_VERSION_MAX_LEN or any(
+ char < 0x21 or char > 0x7E for char in version
+ ):
+ raise ValueError("marketing version must contain 1..37 printable non-space ASCII bytes")
+ return version
+
def _load_header_manifest(path: Path) -> HeaderManifest:
with path.open("r", encoding="utf-8") as infile:
- manifest = cast(dict[str, Any], json.load(infile))
+ manifest = json.load(infile)
+ if not isinstance(manifest, dict):
+ raise ValueError("header manifest must be an object")
magic = manifest.get("magic")
if not isinstance(magic, str) or len(magic.encode("ascii")) != 4:
raise ValueError("manifest field 'magic' must be a 4-byte ASCII string")
- return {"magic": magic.encode("ascii")}
+ return {
+ "magic": magic.encode("ascii"),
+ "flags": _manifest_int(manifest, "flags", 0, 1),
+ "product_id": _manifest_int(manifest, "product_id", 0, 0xFFFF),
+ "monotonic_version": _manifest_int(manifest, "monotonic_version", 0, 0xFFFF),
+ "marketing_version": _marketing_version(manifest.get("marketing_version")).decode("ascii"),
+ }
def build_header(*, manifest: HeaderManifest, code_size: int) -> bytes:
- """Build an image header."""
- if not 0 <= code_size <= 0xFFFF_FFFF:
- raise ValueError("code_size must be a u32")
-
+ """Build an unsigned header; a zero code size leaves image_len unset."""
+ if manifest["magic"] not in (STAGE1_MAGIC, FIRMWARE_MAGIC):
+ raise ValueError("unsupported image header magic")
+ if not 0 <= code_size <= 0xFFFF_FFFF_FFFF_FFFF - HEADER_LEN:
+ raise ValueError("image length must fit in a u64")
+ fields = cast(dict[str, Any], manifest)
+ version = _marketing_version(manifest.get("marketing_version"))
header = bytearray(HEADER_LEN)
- header[0:4] = manifest["magic"]
- header[4:8] = HEADER_LEN.to_bytes(4, "little")
- header[8:12] = code_size.to_bytes(4, "little")
+ HEADER_PREFIX.pack_into(
+ header,
+ 0,
+ manifest["magic"],
+ _manifest_int(fields, "flags", 0, 1),
+ HEADER_VERSION,
+ _manifest_int(fields, "product_id", 0, 0xFFFF),
+ HEADER_LEN,
+ 0, # The raw linked image has not been sized yet.
+ _manifest_int(fields, "monotonic_version", 0, 0xFFFF),
+ len(version),
+ version,
+ )
+ if code_size:
+ return finalize_header_code_size(bytes(header), code_size)
return bytes(header)
def finalize_header_code_size(header_bytes: bytes, code_size: int) -> bytes:
+ """Fill the total image length in an unsigned header from its payload size."""
if len(header_bytes) != HEADER_LEN:
raise ValueError("header must be exactly 1024 bytes")
- if not 0 <= code_size <= 0xFFFF_FFFF:
- raise ValueError("code_size must be a u32")
+ image_len = HEADER_LEN + code_size
+ if not HEADER_LEN < image_len <= 0xFFFF_FFFF_FFFF_FFFF:
+ raise ValueError("image length is invalid")
+ (
+ magic,
+ flags,
+ header_version,
+ product_id,
+ header_len,
+ previous_image_len,
+ monotonic_version,
+ version_len,
+ version_field,
+ ) = HEADER_PREFIX.unpack_from(header_bytes)
+ # BitBox02 stage1 must end before the factory randomness. BitBox03 uses
+ # its board-specific flash slots, which are enforced by the linker/loader.
+ if magic == STAGE1_MAGIC and product_id in (1, 2, 3, 4) and image_len > BITBOX02_STAGE1_MAX_LEN:
+ raise ValueError("BitBox02 stage1 image length is invalid")
+ manifest: HeaderManifest = {
+ "magic": magic,
+ "flags": flags,
+ "product_id": product_id,
+ "monotonic_version": monotonic_version,
+ "marketing_version": version_field[:version_len].decode("ascii"),
+ }
+ if header_version != HEADER_VERSION or header_len != HEADER_LEN:
+ raise ValueError("invalid header version or length")
+ if previous_image_len not in (0, image_len):
+ raise ValueError("image length does not match payload")
+ if any(header_bytes[SIGNATURES_OFFSET:]):
+ raise ValueError("header signatures are not zero")
updated = bytearray(header_bytes)
- updated[8:12] = code_size.to_bytes(4, "little")
+ updated[16:24] = bytes(8)
+ if bytes(updated) != build_header(manifest=manifest, code_size=0):
+ raise ValueError("invalid header padding or marketing version length")
+ updated[16:24] = image_len.to_bytes(8, "little")
return bytes(updated)
@@ -60,7 +158,7 @@ def _read_u32(data: bytes, offset: int) -> int:
def _validate_elf32_le(data: bytes, elf: Path) -> None:
if data[:4] != b"\x7fELF":
raise ValueError(f"{elf} is not an ELF file")
- if data[4] != 1 or data[5] != 1:
+ if len(data) < 52 or data[4] != 1 or data[5] != 1:
raise ValueError(f"{elf} must be a little-endian ELF32 file")
@@ -133,7 +231,7 @@ def _elf_flash_payload_len(elf: Path, payload_address: int) -> int:
payload_len = payload_end - payload_address
if payload_len == 0:
- raise ValueError(f"{elf} does not contain a flash payload after .image_header")
+ raise ValueError(f"{elf} does not contain a flash payload after the image header")
return payload_len
@@ -151,10 +249,10 @@ def cmd_finalize_code_size(args: argparse.Namespace) -> None:
def cmd_finalize_elf(args: argparse.Namespace) -> None:
elf = args.elf
- header_address, header_offset, header_size = _elf_section(elf, ".image_header")
+ header_address, header_offset, header_size = _elf_section(elf, args.section)
if header_size != HEADER_LEN:
raise ValueError(
- f"{elf} .image_header must be exactly {HEADER_LEN} bytes, got {header_size}"
+ f"{elf} {args.section} must be exactly {HEADER_LEN} bytes, got {header_size}"
)
payload_len = _elf_flash_payload_len(elf, header_address + header_size)
@@ -164,7 +262,7 @@ def cmd_finalize_elf(args: argparse.Namespace) -> None:
outfile.seek(header_offset)
outfile.write(finalize_header_code_size(header_bytes, payload_len))
- print(f"finalized {elf}: code_size={payload_len}")
+ print(f"finalized {elf}: image_len={header_size + payload_len}")
def build_parser() -> argparse.ArgumentParser:
@@ -183,6 +281,7 @@ def build_parser() -> argparse.ArgumentParser:
finalize_elf = subparsers.add_parser("finalize-elf")
finalize_elf.add_argument("elf", type=Path)
+ finalize_elf.add_argument("--section", default=".image_header", help="ELF header section name")
return parser
### src/rust/bins/bitbox03-boot1/image_header.json
@@ -1,3 +1,7 @@
{
- "magic": "BBB1"
+ "magic": "BBS1",
+ "flags": 0,
+ "product_id": 0,
+ "monotonic_version": 1,
+ "marketing_version": "dev"
}
### src/rust/bins/bitbox03-firmware/image_header.json
@@ -1,3 +1,7 @@
{
- "magic": "BBFW"
+ "magic": "BBFW",
+ "flags": 0,
+ "product_id": 0,
+ "monotonic_version": 1,
+ "marketing_version": "dev"
}
### src/rust/bitbox-boot-utils/src/image_header.rs
@@ -2,28 +2,61 @@
// Must stay in sync with HEADER_LEN in scripts/image_header.py.
pub const IMAGE_HEADER_LEN: usize = 1024;
-pub const IMAGE_HEADER_MAGIC_BOOT1: [u8; 4] = *b"BBB1";
+pub const IMAGE_HEADER_MAGIC_BOOT1: [u8; 4] = *b"BBS1";
pub const IMAGE_HEADER_MAGIC_FIRMWARE: [u8; 4] = *b"BBFW";
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
#[repr(C)]
+// Must match bb02_stage1_header_t in src/bootloader_upgrade/bootloader_upgrade.h.
pub struct ImageHeader {
pub magic: [u8; 4],
+ pub flags: u32,
+ pub header_version: u16,
+ pub product_id: u16,
+ // Total header length; the vector table starts at this offset.
pub header_len: u32,
- pub code_size: u32,
+ // Total image length, including the header.
+ pub image_len: u64,
+ pub monotonic_version: u16,
+ pub marketing_version_len: u8,
+ pub marketing_version: [u8; 37],
+ pub reserved: [u8; 768],
+ // Signatures are the last 3 * 64 bytes of the header.
+ pub signatures: [[u8; 64]; 3],
}
impl ImageHeader {
pub fn try_from_bytes(bytes: &[u8]) -> Result<Self, ()> {
let bytes: &[u8; core::mem::size_of::<Self>()] = bytes.try_into().map_err(|_| ())?;
Ok(Self {
magic: bytes[..4].try_into().map_err(|_| ())?,
- header_len: u32::from_le_bytes(bytes[4..8].try_into().map_err(|_| ())?),
- code_size: u32::from_le_bytes(bytes[8..12].try_into().map_err(|_| ())?),
+ flags: u32::from_le_bytes(bytes[4..8].try_into().map_err(|_| ())?),
+ header_version: u16::from_le_bytes(bytes[8..10].try_into().map_err(|_| ())?),
+ product_id: u16::from_le_bytes(bytes[10..12].try_into().map_err(|_| ())?),
+ header_len: u32::from_le_bytes(bytes[12..16].try_into().map_err(|_| ())?),
+ image_len: u64::from_le_bytes(bytes[16..24].try_into().map_err(|_| ())?),
+ monotonic_version: u16::from_le_bytes(bytes[24..26].try_into().map_err(|_| ())?),
+ marketing_version_len: bytes[26],
+ marketing_version: bytes[27..64].try_into().map_err(|_| ())?,
+ reserved: bytes[64..832].try_into().map_err(|_| ())?,
+ signatures: [
+ bytes[832..896].try_into().map_err(|_| ())?,
+ bytes[896..960].try_into().map_err(|_| ())?,
+ bytes[960..1024].try_into().map_err(|_| ())?,
+ ],
})
}
}
const _: [(); 0x000] = [(); core::mem::offset_of!(ImageHeader, magic)];
-const _: [(); 0x004] = [(); core::mem::offset_of!(ImageHeader, header_len)];
-const _: [(); 0x008] = [(); core::mem::offset_of!(ImageHeader, code_size)];
+const _: [(); 0x004] = [(); core::mem::offset_of!(ImageHeader, flags)];
+const _: [(); 0x008] = [(); core::mem::offset_of!(ImageHeader, header_version)];
+const _: [(); 0x00a] = [(); core::mem::offset_of!(ImageHeader, product_id)];
+const _: [(); 0x00c] = [(); core::mem::offset_of!(ImageHeader, header_len)];
+const _: [(); 0x010] = [(); core::mem::offset_of!(ImageHeader, image_len)];
+const _: [(); 0x018] = [(); core::mem::offset_of!(ImageHeader, monotonic_version)];
+const _: [(); 0x01a] = [(); core::mem::offset_of!(ImageHeader, marketing_version_len)];
+const _: [(); 0x01b] = [(); core::mem::offset_of!(ImageHeader, marketing_version)];
+const _: [(); 0x040] = [(); core::mem::offset_of!(ImageHeader, reserved)];
+const _: [(); 0x340] = [(); core::mem::offset_of!(ImageHeader, signatures)];
+const _: [(); IMAGE_HEADER_LEN] = [(); core::mem::size_of::<ImageHeader>()];
### src/rust/bitbox-boot-utils/src/lib.rs
@@ -210,22 +210,25 @@ pub unsafe fn vector_table_from_image_header(
ram_len: usize,
expected_magic: [u8; 4],
) -> Result<*const u32, ()> {
+ if slot_len < IMAGE_HEADER_LEN {
+ return Err(());
+ }
let bytes = unsafe { &*(slot_address as *const [u8; core::mem::size_of::<ImageHeader>()]) };
let header = ImageHeader::try_from_bytes(bytes)?;
if header.magic != expected_magic {
return Err(());
}
let header_len = header.header_len as usize;
- if header_len <= core::mem::size_of::<ImageHeader>() || !header_len.is_multiple_of(1024) {
+ if header_len < IMAGE_HEADER_LEN || !header_len.is_multiple_of(1024) {
return Err(());
}
- let code_len = header.code_size as usize;
+ let image_len = usize::try_from(header.image_len).map_err(|_| ())?;
+ let code_len = image_len.checked_sub(header_len).ok_or(())?;
if code_len < 8 {
return Err(());
}
- let image_len = header_len.checked_add(code_len).ok_or(())?;
if image_len > slot_len {
return Err(());
}
@@ -357,8 +360,10 @@ mod tests {
) -> [u8; core::mem::size_of::<ImageHeader>()] {
let mut header = [0u8; core::mem::size_of::<ImageHeader>()];
header[..4].copy_from_slice(&magic);
- header[4..8].copy_from_slice(&header_len.to_le_bytes());
- header[8..12].copy_from_slice(&code_size.to_le_bytes());
+ header[8..10].copy_from_slice(&1u16.to_le_bytes());
+ header[12..16].copy_from_slice(&header_len.to_le_bytes());
+ header[16..24]
+ .copy_from_slice(&(u64::from(header_len) + u64::from(code_size)).to_le_bytes());
header
}
@@ -455,15 +460,31 @@ mod tests {
#[test]
fn test_image_header_try_from_bytes() {
- let bytes = [
- b'B', b'B', b'F', b'W', 0x00, 0x04, 0x00, 0x00, 0x34, 0x12, 0x00, 0x00,
- ];
+ let mut bytes = [0u8; IMAGE_HEADER_LEN];
+ bytes[..27].copy_from_slice(&[
+ b'B', b'B', b'F', b'W', 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x04, 0x00, 0x00, 0x04,
+ 0x00, 0x00, 0x34, 0x12, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0xcd, 0xab, 0x06,
+ ]);
+ bytes[27..33].copy_from_slice(b"v1.2.3");
+ bytes[64..832].fill(0x5a);
+ bytes[832..896].fill(1);
+ bytes[896..960].fill(2);
+ bytes[960..1024].fill(3);
let header = ImageHeader::try_from_bytes(&bytes).unwrap();
- assert_eq!(core::mem::size_of::<ImageHeader>(), 12);
+ assert_eq!(core::mem::size_of::<ImageHeader>(), 1024);
+ assert_eq!(IMAGE_HEADER_MAGIC_BOOT1, *b"BBS1");
assert_eq!(header.magic, IMAGE_HEADER_MAGIC_FIRMWARE);
+ assert_eq!(header.flags, 1);
+ assert_eq!(header.header_version, 1);
+ assert_eq!(header.product_id, 4);
assert_eq!(header.header_len, IMAGE_HEADER_LEN as u32);
- assert_eq!(header.code_size, 0x1234);
+ assert_eq!(header.image_len, 0x1_0000_1234);
+ assert_eq!(header.monotonic_version, 0xabcd);
+ assert_eq!(header.marketing_version_len, 6);
+ assert_eq!(&header.marketing_version[..6], b"v1.2.3");
+ assert_eq!(header.reserved, [0x5a; 768]);
+ assert_eq!(header.signatures, [[1; 64], [2; 64], [3; 64]]);
}
#[test]
@@ -487,7 +508,7 @@ mod tests {
fn test_vector_table_from_image_header_header_len_too_small() {
assert_test_image_invalid(
IMAGE_HEADER_MAGIC_BOOT1,
- core::mem::size_of::<ImageHeader>() as u32,
+ (IMAGE_HEADER_LEN - 1) as u32,
8,
TEST_IMAGE_LEN,
);
@@ -533,6 +554,42 @@ mod tests {
);
}
+ #[test]
+ fn test_vector_table_from_image_header_invalid_image_len() {
+ for image_len in [0u64, 1023, 0x1_0000_0408, u64::MAX] {
+ let mut image = build_test_image::<TEST_IMAGE_LEN>(
+ IMAGE_HEADER_MAGIC_BOOT1,
+ IMAGE_HEADER_LEN as u32,
+ 8,
+ );
+ image.0[16..24].copy_from_slice(&image_len.to_le_bytes());
+ assert_eq!(
+ // SAFETY: TestImage is u32-aligned, readable and immutable for
+ // the call, including both vector-table entries.
+ unsafe {
+ vector_table_from_image_header(
+ image.0.as_ptr() as usize,
+ image.0.len(),
+ TEST_RAM_BASE,
+ TEST_RAM_LEN,
+ IMAGE_HEADER_MAGIC_BOOT1,
+ )
+ },
+ Err(())
+ );
+ }
+ }
+
+ #[test]
+ fn test_vector_table_from_image_header_slot_too_small() {
+ assert_test_image_invalid(
+ IMAGE_HEADER_MAGIC_BOOT1,
+ IMAGE_HEADER_LEN as u32,
+ 8,
+ IMAGE_HEADER_LEN - 1,
+ );
+ }
+
#[test]
fn test_vector_table_from_image_header_invalid_vector_table() {
assert_test_image_invalid(
### test/scripts/test_image_header.py
@@ -0,0 +1,290 @@
+# SPDX-License-Identifier: Apache-2.0
+
+"""Compatibility tests for scripts/image_header.py."""
+
+import argparse
+import contextlib
+import io
+import json
+import struct
+import tempfile
+import unittest
+from pathlib import Path
+
+from scripts import bootloader_update, image_header
+
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
+
+
+def stage1_manifest(**overrides: object) -> dict:
+ """Return the metadata for a development stage1 image."""
+ return {
+ "magic": "BBS1",
+ "flags": 1,
+ "product_id": 1,
+ "monotonic_version": 1,
+ "marketing_version": "v1.2.2+dev",
+ **overrides,
+ }
+
+
+def make_elf(header: bytes, section: str, flash_address: int) -> tuple[bytes, bytes]:
+ """Make an ELF with flash code, a gap, flash-backed .data, and a RAM-only load."""
+ header_offset = 0x1000
+ payload = b"code" * 4 + bytes(16) + b"data"
+ flash = header + payload
+ data = bytearray(header_offset + len(flash) + 4)
+ data[header_offset : header_offset + len(flash)] = flash
+ data[-4:] = b"RAM!"
+ names = b"\x00.shstrtab\x00" + section.encode("ascii") + b"\x00"
+ data[0x200 : 0x200 + len(names)] = names
+ struct.pack_into(
+ "<16sHHIIIIIHHHHHH",
+ data,
+ 0,
+ b"\x7fELF\x01\x01\x01" + bytes(9),
+ 2,
+ 40,
+ 1,
+ 0,
+ 52,
+ 0x100,
+ 0,
+ 52,
+ 32,
+ 3,
+ 40,
+ 3,
+ 1,
+ )
+ struct.pack_into(
+ "<IIIIIIII",
+ data,
+ 52,
+ 1,
+ header_offset,
+ flash_address,
+ flash_address,
+ len(header) + 16,
+ len(header) + 16,
+ 5,
+ 4,
+ )
+ struct.pack_into(
+ "<IIIIIIII",
+ data,
+ 84,
+ 1,
+ header_offset + len(flash) - 4,
+ 0x20000200,
+ flash_address + len(flash) - 4,
+ 4,
+ 64,
+ 6,
+ 4,
+ )
+ struct.pack_into("<IIIIIIII", data, 116, 1, len(data) - 4, 0x20010000, 0x20010000, 4, 4, 6, 4)
+ struct.pack_into("<IIIIIIIIII", data, 0x100 + 40, 1, 3, 0, 0, 0x200, len(names), 0, 0, 1, 0)
+ struct.pack_into(
+ "<IIIIIIIIII",
+ data,
+ 0x100 + 80,
+ 11,
+ 1,
+ 2,
+ flash_address,
+ header_offset,
+ len(header),
+ 0,
+ 0,
+ 4,
+ 0,
+ )
+ return bytes(data), payload
+
+
+class ImageHeaderTests(unittest.TestCase):
+ """Keep both devices compatible with the BitBox image format."""
+
+ def setUp(self) -> None:
+ temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(temporary.cleanup)
+ self.directory = Path(temporary.name)
+
+ def render(self, manifest: dict) -> bytes:
+ manifest_path = self.directory / "manifest.json"
+ output = self.directory / "header.bin"
+ manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
+ args = image_header.build_parser().parse_args(
+ ["render-header", "--manifest", str(manifest_path), "--output", str(output)]
+ )
+ image_header.cmd_render(args)
+ return output.read_bytes()
+
+ def test_render_stage1_matches_shipped_headers(self) -> None:
+ images = sorted((REPOSITORY_ROOT / "src/bootloader_upgrade/bin").glob("*stage1*.bin"))
+ self.assertTrue(images)
+ for path in images:
+ with self.subTest(image=path.name):
+ expected = bytearray(path.read_bytes()[:1024])
+ fields = bootloader_update._unpack_header(expected)
+ actual = self.render(
+ stage1_manifest(
+ flags=fields["flags"],
+ product_id=fields["product_id"],
+ monotonic_version=fields["monotonic_version"],
+ marketing_version=fields["stage1_marketing_version"],
+ )
+ )
+ expected[16:24] = bytes(8)
+ expected[832:] = bytes(192)
+ self.assertEqual(actual, expected)
+ bootloader_update._validate_raw_stage1(actual + b"payload")
+
+ def test_finalize_stage1_matches_existing_post_processing(self) -> None:
+ header = self.render(stage1_manifest())
+ for payload_len in (1, 36, 0xBFE0 - 1024):
+ with self.subTest(payload_len=payload_len):
+ payload = b"x" * payload_len
+ raw = self.directory / "raw.bin"
+ expected = self.directory / "unsigned.bin"
+ raw.write_bytes(header + payload)
+ bootloader_update.prepare_stage1_unsigned(
+ argparse.Namespace(raw_bin=raw, unsigned_bin=expected)
+ )
+ actual = image_header.finalize_header_code_size(header, payload_len)
+ self.assertEqual(actual + payload, expected.read_bytes())
+ self.assertEqual(
+ image_header.finalize_header_code_size(actual, payload_len), actual
+ )
+
+ def test_finalize_code_size_command_stage1(self) -> None:
+ header = self.render(stage1_manifest())
+ payload = self.directory / "payload.bin"
+ output = self.directory / "final.bin"
+ payload.write_bytes(b"payload")
+ args = image_header.build_parser().parse_args(
+ [
+ "finalize-code-size",
+ "--header",
+ str(self.directory / "header.bin"),
+ "--payload",
+ str(payload),
+ "--output",
+ str(output),
+ ]
+ )
+ image_header.cmd_finalize_code_size(args)
+ self.assertEqual(
+ output.read_bytes(), header[:16] + (1031).to_bytes(8, "little") + header[24:]
+ )
+
+ def test_render_bitbox03_shared_layout(self) -> None:
+ for target, magic in (("bitbox03-boot1", b"BBS1"), ("bitbox03-firmware", b"BBFW")):
+ with self.subTest(target=target):
+ path = REPOSITORY_ROOT / "src/rust/bins" / target / "image_header.json"
+ header = self.render(json.loads(path.read_text(encoding="utf-8")))
+ fields = bootloader_update._unpack_header(header)
+ self.assertEqual(fields["magic"], int.from_bytes(magic, "little"))
+ self.assertEqual(fields["header_len"], 1024)
+ self.assertEqual(fields["image_len"], 0)
+ self.assertEqual(fields["header_version"], 1)
+ self.assertEqual(header[64:], bytes(960))
+ # BitBox03 images are not limited to the BitBox02 stage1 slot.
+ for code_size in (1234, 0x200000, 2**32):
+ finalized = image_header.finalize_header_code_size(header, code_size)
+ self.assertEqual(
+ finalized,
+ header[:16] + (1024 + code_size).to_bytes(8, "little") + header[24:],
+ )
+
+ def test_render_rejects_invalid_stage1_metadata(self) -> None:
+ for field, values in {
+ "flags": (-1, 2, True, "1"),
+ "product_id": (-1, 65536, False, 1.5),
+ "monotonic_version": (-1, 65536, True, "1"),
+ "marketing_version": ("", "x" * 38, "v1 2", "v1\x00", "v1\n", "v1é", None),
+ }.items():
+ for value in values:
+ with self.subTest(field=field, value=value), self.assertRaises(ValueError):
+ self.render(stage1_manifest(**{field: value}))
+ missing = stage1_manifest()
+ del missing[field]
+ with self.subTest(missing=field), self.assertRaises(ValueError):
+ self.render(missing)
+
+ def test_render_stage1_metadata_boundaries(self) -> None:
+ header = self.render(stage1_manifest(monotonic_version=65535, marketing_version="x" * 37))
+ parsed = bootloader_update._validate_raw_stage1(header + b"payload")
+ self.assertEqual(parsed["monotonic_version"], 65535)
+ self.assertEqual(parsed["stage1_marketing_version"], "x" * 37)
+
+ def test_finalize_stage1_rejects_invalid_headers(self) -> None:
+ header = self.render(stage1_manifest())
+ for offset, value in (
+ (0, b"XXXX"),
+ (4, (2).to_bytes(4, "little")),
+ (8, b"\x02\x00"),
+ (12, (2048).to_bytes(4, "little")),
+ (16, (1234).to_bytes(8, "little")),
+ (26, b"\x00"),
+ (26, b"\xff"),
+ (27, b"\x00"),
+ (63, b"x"),
+ (64, b"x"),
+ (831, b"x"),
+ (832, b"x"),
+ (1023, b"x"),
+ ):
+ invalid = bytearray(header)
+ invalid[offset : offset + len(value)] = value
+ with self.subTest(offset=offset, value=value), self.assertRaises(ValueError):
+ image_header.finalize_header_code_size(bytes(invalid), 7)
+ for invalid in (header[:-1], header + b"x"):
+ with self.subTest(length=len(invalid)), self.assertRaises(ValueError):
+ image_header.finalize_header_code_size(invalid, 7)
+ for size in (-1, 0, 0xBFE0 - 1024 + 1, 2**64):
+ with self.subTest(size=size), self.assertRaises(ValueError):
+ image_header.finalize_header_code_size(header, size)
+
+ def test_finalize_elf_flash_span(self) -> None:
+ for manifest, section, address in (
+ (stage1_manifest(), ".stage1_header", 0x2000),
+ (stage1_manifest(product_id=0), ".image_header", 0x08002000),
+ (stage1_manifest(magic="BBFW", product_id=0), ".image_header", 0x08020000),
+ ):
+ with self.subTest(magic=manifest["magic"]):
+ header = self.render(manifest)
+ original, payload = make_elf(header, section, address)
+ elf = self.directory / "image.elf"
+ elf.write_bytes(original)
+ arguments = ["finalize-elf", str(elf)]
+ if section != ".image_header":
+ arguments += ["--section", section]
+ args = image_header.build_parser().parse_args(arguments)
+ with contextlib.redirect_stdout(io.StringIO()):
+ image_header.cmd_finalize_elf(args)
+ actual = elf.read_bytes()
+ offset, size, value = 16, 8, 1024 + len(payload)
+ if manifest["product_id"] == 1:
+ bootloader_update._validate_complete_stage1(
+ actual[0x1000 : 0x1000 + value], 1, require_signatures=False
+ )
+ expected = bytearray(original)
+ expected[0x1000 + offset : 0x1000 + offset + size] = value.to_bytes(size, "little")
+ self.assertEqual(actual, expected)
+
+ def test_finalize_elf_rejects_signed_stage1_without_modifying_file(self) -> None:
+ header = bytearray(self.render(stage1_manifest()))
+ header[832] = 1
+ original, _ = make_elf(bytes(header), ".stage1_header", 0x2000)
+ elf = self.directory / "signed.elf"
+ elf.write_bytes(original)
+ with self.assertRaisesRegex(ValueError, "signatures"):
+ image_header.cmd_finalize_elf(argparse.Namespace(elf=elf, section=".stage1_header"))
+ self.assertEqual(elf.read_bytes(), original)
+
+
+if __name__ == "__main__":
+ unittest.main()Why this scored 27/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.