bootloader: use shared stage1 header tool
What changed, and why it matters
This commit is a build-system refactoring for the BitBox02 bootloader. It replaces a C-language placeholder for the bootloader's stage1 header with a Python tool that generates the same header from JSON manifests during the build. The old Python helper that prepared unsigned stage1 images is removed because it is no longer needed. The commit explicitly states that generated headers and final binaries match the previous output, and tests were updated and pass. There is no indication of a security vulnerability being fixed or introduced.
No security action required. Treat as a normal build-refactoring commit. Reviewers may optionally verify reproducibility by comparing generated stage1 binaries against pre-change artifacts, as the commit already claims they match.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change moves stage1 header generation into the existing image_header.py tooling. CMake now renders a JSON manifest per target, calls image_header.py render-header to produce a binary header, converts it to an ELF object with objcopy, links it into the stage1 ELF, then calls image_header.py finalize-elf to write the actual image length into the linked header before producing the final binary. The removed bootloader_update.py command and C placeholder performed equivalent steps. Tests confirm byte-equivalence for headers and validation logic for finalized images.
Changed components
scripts/bootloader_update.pysrc/CMakeLists.txtsrc/bootloader/image_header.json.insrc/bootloader/stage1_header.ctest/scripts/test_image_header.pyInspect captured patch +52 / −110
### 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",
### 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}},
-};
### test/scripts/test_image_header.py
@@ -140,21 +140,18 @@ def test_render_stage1_matches_shipped_headers(self) -> None:
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:
+ 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
- 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())
+ 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
)
@@ -216,7 +213,7 @@ def test_render_rejects_invalid_stage1_metadata(self) -> None:
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")
+ parsed = bootloader_update._unpack_header(header)
self.assertEqual(parsed["monotonic_version"], 65535)
self.assertEqual(parsed["stage1_marketing_version"], "x" * 37)
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.