Merge branch 'nickez/bb02-finalize-elf'
What changed, and why it matters
This commit is a build-system refactor that unifies how firmware image headers are generated for the BitBox02 and BitBox03. It replaces a device-specific Python script and a C header placeholder with a shared Python tool and JSON manifests, and removes an older intermediate 'prepare unsigned stage1' step. There is no direct evidence in the commit of a security vulnerability being fixed or introduced; the changes are structural and add more validation and tests.
Treat as a normal build-system refactor. Review the new validation rules and test coverage for completeness, but no immediate security response is indicated by the commit content alone.
Security signals we found
New input validation in image_header.py for magic, flags, product_id, monotonic_version, marketing_version, image length, and zeroed signature slots
Rust parsing now validates slot length, header length alignment, and image length bounds before deriving code length
Removal of prepare-stage1-unsigned path reduces attack surface in build tooling
Added test/scripts/test_image_header.py compatibility tests against shipped stage1 binaries
Evidence from the diff
The merge refactors image-header generation: scripts/bitbox03_image_header.py is replaced by scripts/image_header.py, src/bootloader/stage1_header.c is removed in favor of a CMake-generated header object, and bootloader_update.py loses the prepare-stage1-unsigned subcommand. The new shared header format adds fields (flags, product_id, header_version, monotonic_version, marketing_version, reserved, signatures) and enforces length, magic, version, and signature-zero checks. Rust parsing in bitbox-boot-utils is updated to match, and a new unit-test suite verifies compatibility with shipped BitBox02 stage1 binaries and BitBox03 layouts. No security bug is described or patched in the diff.
Changed components
scripts/image_header.pyscripts/bootloader_update.pysrc/CMakeLists.txtsrc/bootloader/stage1_header.c (removed)src/bootloader/image_header.json.in (new)src/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.py (new)Inspect captured patch +573 / −144
### Makefile
@@ -268,12 +268,12 @@ bitbox03-boot0-release:
arm-none-eabi-size -Ax src/rust/target/thumbv8m.main-none-eabihf/release/bitbox03-boot0
bitbox03-boot1:
(cd src/rust; cargo bitbox03-boot1-stm32u5a9j-dk)
- python3 scripts/bitbox03_image_header.py finalize-elf src/rust/target/thumbv8m.main-none-eabihf/debug/bitbox03-boot1
+ python3 scripts/image_header.py finalize-elf src/rust/target/thumbv8m.main-none-eabihf/debug/bitbox03-boot1
arm-none-eabi-size src/rust/target/thumbv8m.main-none-eabihf/debug/bitbox03-boot1
arm-none-eabi-size -Ax src/rust/target/thumbv8m.main-none-eabihf/debug/bitbox03-boot1
bitbox03-boot1-release:
(cd src/rust; cargo bitbox03-boot1-stm32u5a9j-dk-release)
- python3 scripts/bitbox03_image_header.py finalize-elf src/rust/target/thumbv8m.main-none-eabihf/release/bitbox03-boot1
+ python3 scripts/image_header.py finalize-elf src/rust/target/thumbv8m.main-none-eabihf/release/bitbox03-boot1
arm-none-eabi-size src/rust/target/thumbv8m.main-none-eabihf/release/bitbox03-boot1
arm-none-eabi-size -Ax src/rust/target/thumbv8m.main-none-eabihf/release/bitbox03-boot1
bitbox03-factorysetup:
@@ -286,12 +286,12 @@ bitbox03-factorysetup-release:
arm-none-eabi-size -Ax src/rust/target/thumbv8m.main-none-eabihf/release/bitbox03-factorysetup
bitbox03-firmware:
(cd src/rust; cargo bitbox03-firmware-stm32u5a9j-dk)
- python3 scripts/bitbox03_image_header.py finalize-elf src/rust/target/thumbv8m.main-none-eabihf/debug/bitbox03-firmware
+ python3 scripts/image_header.py finalize-elf src/rust/target/thumbv8m.main-none-eabihf/debug/bitbox03-firmware
arm-none-eabi-size src/rust/target/thumbv8m.main-none-eabihf/debug/bitbox03-firmware
arm-none-eabi-size -Ax src/rust/target/thumbv8m.main-none-eabihf/debug/bitbox03-firmware
bitbox03-firmware-release:
(cd src/rust; cargo bitbox03-firmware-stm32u5a9j-dk-release)
- python3 scripts/bitbox03_image_header.py finalize-elf src/rust/target/thumbv8m.main-none-eabihf/release/bitbox03-firmware
+ python3 scripts/image_header.py finalize-elf src/rust/target/thumbv8m.main-none-eabihf/release/bitbox03-firmware
arm-none-eabi-size src/rust/target/thumbv8m.main-none-eabihf/release/bitbox03-firmware
arm-none-eabi-size -Ax src/rust/target/thumbv8m.main-none-eabihf/release/bitbox03-firmware
### scripts/bootloader_update.py
@@ -138,30 +138,6 @@ def _unpack_header(header: bytes) -> Stage1Header:
}
-def _pack_prefix(header: Stage1Header) -> bytes:
- return struct.pack(
- STAGE1_HEADER_PREFIX_FORMAT,
- STAGE1_HEADER_MAGIC,
- header["flags"],
- STAGE1_HEADER_FORMAT_VERSION,
- header["product_id"],
- header["header_len"],
- header["image_len"],
- header["monotonic_version"],
- header["stage1_marketing_version_len"],
- header["stage1_marketing_version_field"],
- header["reserved"],
- )
-
-
-def _pack_header(prefix: bytes, sigs: list[bytes]) -> bytes:
- if len(prefix) != STAGE1_HEADER_SIGNED_LEN:
- raise RuntimeError("invalid signed header prefix length")
- if len(sigs) != STAGE1_ROOT_KEY_COUNT or any(len(sig) != STAGE1_SIGNATURE_LEN for sig in sigs):
- raise RuntimeError("invalid header signatures")
- return prefix + b"".join(sigs)
-
-
def _stage1_signed_digest(image: bytes) -> bytes:
header = _unpack_header(image[:STAGE1_HEADER_LEN])
if len(image) <= header["header_len"]:
@@ -214,18 +190,6 @@ def _validate_fixed_fields(header: Stage1Header, expected_product_id: int | None
raise RuntimeError("reserved header bytes are not zero")
-def _validate_raw_stage1(image: bytes) -> Stage1Header:
- header = _unpack_header(image[:STAGE1_HEADER_LEN])
- _validate_fixed_fields(header)
- if header["image_len"] != 0:
- raise RuntimeError("raw stage1 image length field is not zero")
- if not _signatures_are_zero(header):
- raise RuntimeError("raw stage1 signatures are not zero")
- if len(image) <= header["header_len"] or len(image) > STAGE1_MAX_LEN:
- raise RuntimeError("raw stage1 image length is invalid")
- return header
-
-
def _validate_complete_stage1(
image: bytes,
expected_product_id: int | None,
@@ -251,19 +215,6 @@ def _write_if_changed(path: Path, data: bytes) -> None:
path.write_bytes(data)
-def prepare_stage1_unsigned(args: argparse.Namespace) -> None:
- image = Path(args.raw_bin).read_bytes()
- header = _validate_raw_stage1(image)
- header["image_len"] = len(image)
- prefix = _pack_prefix(header)
- unsigned_stage1 = (
- _pack_header(prefix, [b"\x00" * STAGE1_SIGNATURE_LEN] * STAGE1_ROOT_KEY_COUNT)
- + image[STAGE1_HEADER_LEN:]
- )
- _validate_complete_stage1(unsigned_stage1, None, require_signatures=False)
- _write_if_changed(Path(args.unsigned_bin), unsigned_stage1)
-
-
def _stage1_expected_flags(development: bool) -> int:
return STAGE1_HEADER_FLAG_DEVELOPMENT if development else 0
@@ -328,20 +279,6 @@ def main() -> None:
)
subparsers = parser.add_subparsers()
- prepare_parser = subparsers.add_parser(
- "prepare-stage1-unsigned",
- help="create an unsigned stage1 image from a raw linked stage1 binary",
- description=(
- "Validate the raw stage1 binary produced by objcopy, fill the stage1 "
- "image_len header field with the actual image length, keep the signature "
- "array zeroed, and write the canonical unsigned stage1 image that is "
- "ready to be signed."
- ),
- )
- prepare_parser.add_argument("--raw-bin", required=True)
- prepare_parser.add_argument("--unsigned-bin", required=True)
- prepare_parser.set_defaults(func=prepare_stage1_unsigned)
-
update_parser = subparsers.add_parser(
"create-stage1-fw-embedding",
help="create the stage1 update payload consumed by blupgrade firmware",
### 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/CMakeLists.txt
@@ -485,12 +485,36 @@ if(CMAKE_CROSSCOMPILING)
)
set(BB02_BLUPD_FIRMWARE_TARGETS ${BB02_BLUPD_FIRMWARE_TARGETS} PARENT_SCOPE)
- function(add_bb02_stage1_target target rustlib product bootloader_type is_plus)
+ function(add_bb02_stage1_target target rustlib product product_id bootloader_type is_plus)
+ set(header_dir ${CMAKE_CURRENT_BINARY_DIR}/bootloader/${target})
+ set(header_manifest ${header_dir}/image_header.json)
+ set(header_bin ${header_dir}/image_header.bin)
+ set(header_obj ${header_dir}/image_header.o)
+ set(header_flags 0)
+ if(bootloader_type STREQUAL "BOOTLOADER_DEVDEVICE")
+ set(header_flags 1)
+ endif()
+ configure_file(bootloader/image_header.json.in ${header_manifest} @ONLY)
+ add_custom_command(
+ OUTPUT ${header_bin} ${header_obj}
+ COMMAND
+ ${PYTHON_EXECUTABLE} ${CMAKE_SOURCE_DIR}/scripts/image_header.py
+ render-header --manifest ${header_manifest} --output ${header_bin}
+ COMMAND
+ ${CMAKE_OBJCOPY} -I binary -O elf32-littlearm -B arm
+ --set-section-alignment .data=4
+ --rename-section .data=.stage1_header,alloc,load,readonly,data,contents
+ ${header_bin} ${header_obj}
+ DEPENDS ${header_manifest} ${CMAKE_SOURCE_DIR}/scripts/image_header.py
+ COMMENT "Generating image header for ${target}"
+ VERBATIM
+ )
+ set_source_files_properties(${header_obj} PROPERTIES GENERATED TRUE EXTERNAL_OBJECT TRUE)
add_executable(${target}.elf
${BOOTLOADER-SOURCES}
${PLATFORM-BITBOX02-SOURCES}
${QTOUCH-SOURCES}
- ${CMAKE_SOURCE_DIR}/src/bootloader/stage1_header.c
+ ${header_obj}
)
target_compile_options(${target}.elf PRIVATE -fno-lto)
target_link_libraries(${target}.elf PRIVATE
@@ -526,65 +550,72 @@ if(CMAKE_CROSSCOMPILING)
add_custom_command(
TARGET ${target}.elf POST_BUILD
COMMAND ${CMAKE_SIZE} ${target}.elf
- COMMAND ${CMAKE_OBJCOPY} -O binary ${target}.elf ${target}.raw.bin
COMMAND
${PYTHON_EXECUTABLE}
- ${CMAKE_SOURCE_DIR}/scripts/bootloader_update.py
- prepare-stage1-unsigned
- --raw-bin ${target}.raw.bin
- --unsigned-bin ${target}.bin
+ ${CMAKE_SOURCE_DIR}/scripts/image_header.py
+ finalize-elf --section .stage1_header ${target}.elf
+ COMMAND ${CMAKE_OBJCOPY} -O binary ${target}.elf ${target}.bin
WORKING_DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}
COMMENT "\nGenerating binary ${target}.bin"
)
set_property(TARGET ${target}.elf PROPERTY EXCLUDE_FROM_ALL ON)
endfunction()
+ # Product IDs must match bootloader/bootloader_product.h.
add_bb02_stage1_target(
bootloader-stage1-bitbox02-btconly-development
bb02-bl-btconly-development_rust_c
PRODUCT_BITBOX_BTCONLY
+ 2
BOOTLOADER_DEVDEVICE
FALSE)
add_bb02_stage1_target(
bootloader-stage1-bitbox02-btconly-production
bb02-bl-btconly-production_rust_c
PRODUCT_BITBOX_BTCONLY
+ 2
BOOTLOADER_PRODUCTION
FALSE)
add_bb02_stage1_target(
bootloader-stage1-bitbox02-multi-development
bb02-bl-multi-development_rust_c
PRODUCT_BITBOX_MULTI
+ 1
BOOTLOADER_DEVDEVICE
FALSE)
add_bb02_stage1_target(
bootloader-stage1-bitbox02-multi-production
bb02-bl-multi-production_rust_c
PRODUCT_BITBOX_MULTI
+ 1
BOOTLOADER_PRODUCTION
FALSE)
add_bb02_stage1_target(
bootloader-stage1-bitbox02nova-btconly-development
bb02p-bl-btconly-development_rust_c
PRODUCT_BITBOX_PLUS_BTCONLY
+ 4
BOOTLOADER_DEVDEVICE
TRUE)
add_bb02_stage1_target(
bootloader-stage1-bitbox02nova-btconly-production
bb02p-bl-btconly-production_rust_c
PRODUCT_BITBOX_PLUS_BTCONLY
+ 4
BOOTLOADER_PRODUCTION
TRUE)
add_bb02_stage1_target(
bootloader-stage1-bitbox02nova-multi-development
bb02p-bl-multi-development_rust_c
PRODUCT_BITBOX_PLUS_MULTI
+ 3
BOOTLOADER_DEVDEVICE
TRUE)
add_bb02_stage1_target(
bootloader-stage1-bitbox02nova-multi-production
bb02p-bl-multi-production_rust_c
PRODUCT_BITBOX_PLUS_MULTI
+ 3
BOOTLOADER_PRODUCTION
TRUE)
### src/bootloader/image_header.json.in
@@ -0,0 +1,7 @@
+{
+ "magic": "BBS1",
+ "flags": @header_flags@,
+ "product_id": @product_id@,
+ "monotonic_version": 1,
+ "marketing_version": "@BOOTLOADER_VERSION_FULL@"
+}
### src/bootloader/stage1_header.c
@@ -1,30 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-
-#include "bootloader_upgrade/bootloader_upgrade.h"
-#include <bootloader/bootloader_product.h>
-#include <bootloader/bootloader_version.h>
-
-_Static_assert(
- BOOTLOADER_VERSION_LEN <= BB02_STAGE1_HEADER_STAGE1_MARKETING_VERSION_MAX_LEN,
- "stage1 marketing version too long for stage1 header");
-
-#ifdef BOOTLOADER_DEVDEVICE
- #define BB02_STAGE1_HEADER_FLAGS BB02_STAGE1_FLAG_DEVELOPMENT
-#else
- #define BB02_STAGE1_HEADER_FLAGS 0u
-#endif
-
-const bb02_stage1_header_t bb02_stage1_header_placeholder
- __attribute__((used, section(".stage1_header"), aligned(4))) = {
- .magic = BB02_STAGE1_HEADER_MAGIC,
- .flags = BB02_STAGE1_HEADER_FLAGS,
- .header_version = BB02_STAGE1_HEADER_FORMAT_VERSION,
- .product_id = BB02_STAGE1_PRODUCT_ID,
- .header_len = BB02_STAGE1_HEADER_LEN,
- .image_len = 0,
- .monotonic_version = 1,
- .stage1_marketing_version_len = BOOTLOADER_VERSION_LEN,
- .stage1_marketing_version = BOOTLOADER_VERSION,
- .reserved = {0},
- .signatures = {{0}},
-};
### src/rust/bins/bitbox03-boot1/build.rs
@@ -13,7 +13,7 @@ fn run_command(command: &mut Command, description: &str) {
fn generate_header_object(manifest_dir: &Path, out_dir: &Path) {
let repo_root = manifest_dir.join("../../../..");
- let script = repo_root.join("scripts/bitbox03_image_header.py");
+ let script = repo_root.join("scripts/image_header.py");
let header_manifest = manifest_dir.join("image_header.json");
let header_bin = out_dir.join("bitbox03-boot1-header.bin");
let header_object = out_dir.join("bitbox03-boot1-header.o");
### 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/build.rs
@@ -14,7 +14,7 @@ fn run_command(command: &mut Command, description: &str) {
}
fn generate_header_object(manifest_dir: &Path, out_dir: &Path, repo_root: &Path) {
- let script = repo_root.join("scripts/bitbox03_image_header.py");
+ let script = repo_root.join("scripts/image_header.py");
let header_manifest = manifest_dir.join("image_header.json");
let header_bin = out_dir.join("bitbox03-firmware-header.bin");
let header_object = out_dir.join("bitbox03-firmware-header.o");
### 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
@@ -1,29 +1,62 @@
// SPDX-License-Identifier: Apache-2.0
-// Must stay in sync with HEADER_LEN in scripts/bitbox03_image_header.py.
+// 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,287 @@
+# 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)
+
+ def test_finalize_stage1_unsigned_image(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
+ actual = image_header.finalize_header_code_size(header, payload_len)
+ bootloader_update._validate_complete_stage1(
+ actual + payload, 1, require_signatures=False
+ )
+ self.assertEqual(actual[:16], header[:16])
+ self.assertEqual(actual[24:], header[24:])
+ 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._unpack_header(header)
+ 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 12/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.