Merge pull request #680 from Foundation-Devices/SFT-7355-harden-firmware-header-check
What changed, and why it matters
This commit hardens how the Passport hardware wallet checks firmware update headers. It makes sure developer/user-signed firmware cannot carry a second public-key index or a second signature, and it prevents a 'time-of-check/time-of-use' race where the firmware file on the microSD card could be swapped after the user reviewed it but before it is copied to flash. The change is defensive: it closes a class of bugs where a partly trusted or attacker-modified header might be accepted, but there is no direct evidence in the commit that an actual exploit exists in the wild.
Treat this as a legitimate hardening fix. Users should install the resulting firmware once it is released, and the vendor should consider whether the previous behavior (ignored second key/signature on developer images, and re-read headers during update) warrants a security advisory or CVE. Reviewers should verify that the cached header cannot be modified by untrusted code after it is set in update_firmware_flow.py.
Security signals we found
New validation requiring zeroed second key/signature for user-signed firmware
TOCTOU mitigation: cached header compared against re-read file header before signature verification and before staging to SPI flash
Staging of validated header from memory rather than re-reading from untrusted storage
Consistent use of a single classification helper (firmware_is_user_signed) across bootloader and application code
Addition of Rust unit tests and Python host tests for header mutation, truncation, and second-key/signature handling
Evidence from the diff
The patch introduces a new helper, firmware_is_user_signed(), and uses it consistently across the bootloader and MicroPython system module. For user-signed (developer) firmware, both verify_update_header_impl() in Rust and verify_header() in C now require signature.pubkey2 == 0 and signature.signature2 to be all-zero. Previously, the second key/signature were simply ignored for user-signed images, which could allow a non-zero second key or signature to be stored or interpreted differently elsewhere. The Python update flow now caches the header that was shown to the user and passes it to both verify_firmware_signature_task and copy_firmware_to_spi_flash_task. Those tasks re-read the header from disk and compare it to the cached header, failing with FIRMWARE_UPDATE_FAILED if it changed. copy_firmware_to_spi_flash_task also now stages the already-validated header from memory instead of re-reading it from the file, and it explicitly checks that the body length matches the declared size. The commit also adds unit and host-side tests covering these checks.
Changed components
extmod/foundation-rust/src/firmware.rsports/stm32/boards/Passport/bootloader/main.cports/stm32/boards/Passport/bootloader/update.cports/stm32/boards/Passport/bootloader/verify.cports/stm32/boards/Passport/include/firmware-classification.hports/stm32/boards/Passport/modpassport-system.hports/stm32/boards/Passport/modules/flows/update_firmware_flow.pyports/stm32/boards/Passport/modules/tasks/copy_firmware_to_spi_flash_task.pyports/stm32/boards/Passport/modules/tasks/verify_firmware_signature_task.pyInspect captured patch +570 / −28
### extmod/foundation-rust/src/firmware.rs
@@ -122,6 +122,16 @@ fn verify_update_header_impl(
return None;
}
+ // Developer images use only the first signature. Require the unused
+ // second key and signature to be zero in both installer entry points.
+ if header.is_signed_by_user()
+ && (header.signature.public_key2 != 0
+ || header.signature.signature2.serialize_compact() != [0; 64])
+ {
+ *result = FirmwareResult::InvalidHeader;
+ return None;
+ }
+
if header.information.timestamp < current_timestamp {
*result = FirmwareResult::TooOld {
timestamp: header.information.timestamp,
@@ -215,6 +225,140 @@ pub extern "C" fn verify_update_signatures(
#[cfg(test)]
mod tests {
use super::*;
+ use foundation_firmware::{
+ Information, HEADER_LEN, MAX_PUBLIC_KEYS, USER_KEY,
+ };
+ use secp256k1::{Message, SecretKey};
+
+ const KEY1_OFFSET: usize = Information::LEN;
+ const SIGNATURE1_OFFSET: usize = KEY1_OFFSET + 4;
+ const KEY2_OFFSET: usize = SIGNATURE1_OFFSET + 64;
+ const SIGNATURE2_OFFSET: usize = KEY2_OFFSET + 4;
+
+ fn developer_header(magic: u32) -> (Vec<u8>, [u8; 32], [u8; 64]) {
+ let info = Information {
+ magic,
+ timestamp: 1,
+ date: "Sep 21, 2026".try_into().unwrap(),
+ version: "2.4.0".try_into().unwrap(),
+ length: HEADER_LEN,
+ };
+ let mut header = vec![0; HEADER_LEN as usize];
+ header[..Information::LEN].copy_from_slice(&info.serialize());
+ header[KEY1_OFFSET..SIGNATURE1_OFFSET]
+ .copy_from_slice(&USER_KEY.to_le_bytes());
+
+ // Use a real signature so header mutations cannot be mistaken for a
+ // rejection of an otherwise invalid developer signature.
+ let hash = [42; 32];
+ let key = SecretKey::from_slice(&[1; 32]).unwrap();
+ let signature = PRE_ALLOCATED_CTX
+ .sign_ecdsa(&Message::from_digest(hash), &key)
+ .serialize_compact();
+ header[SIGNATURE1_OFFSET..KEY2_OFFSET].copy_from_slice(&signature);
+ let mut public_key = [0; 64];
+ public_key.copy_from_slice(
+ &PublicKey::from_secret_key(&PRE_ALLOCATED_CTX, &key)
+ .serialize_uncompressed()[1..],
+ );
+ (header, hash, public_key)
+ }
+
+ fn check_header(header: &[u8]) -> FirmwareResult {
+ let mut result = FirmwareResult::SignaturesOk;
+ verify_update_header(header.as_ptr(), header.len(), 0, &mut result);
+ result
+ }
+
+ fn check_signature(
+ header: &[u8],
+ hash: &[u8; 32],
+ key: &[u8; 64],
+ ) -> FirmwareResult {
+ let mut result = FirmwareResult::SignaturesOk;
+ verify_update_signatures(
+ header.as_ptr(),
+ header.len(),
+ 0,
+ hash,
+ Some(key),
+ &mut result,
+ );
+ result
+ }
+
+ #[test]
+ fn canonical_developer_firmware_requires_valid_signature() {
+ for magic in [FIRMWARE_MAGIC_MONO, FIRMWARE_MAGIC_COLOR] {
+ let (header, hash, key) = developer_header(magic);
+ assert!(matches!(
+ check_header(&header),
+ FirmwareResult::HeaderOk {
+ signed_by_user: true,
+ ..
+ }
+ ));
+ assert!(matches!(
+ check_signature(&header, &hash, &key),
+ FirmwareResult::SignaturesOk
+ ));
+ assert!(matches!(
+ check_signature(&header, &[0; 32], &key),
+ FirmwareResult::InvalidUserSignature
+ ));
+ }
+ }
+
+ #[test]
+ fn installer_rejects_nonzero_developer_second_key() {
+ for magic in [FIRMWARE_MAGIC_MONO, FIRMWARE_MAGIC_COLOR] {
+ let (mut header, hash, key) = developer_header(magic);
+ for index in [1, 2, MAX_PUBLIC_KEYS, u32::MAX] {
+ header[KEY2_OFFSET..SIGNATURE2_OFFSET]
+ .copy_from_slice(&index.to_le_bytes());
+ for result in [
+ check_header(&header),
+ check_signature(&header, &hash, &key),
+ ] {
+ assert!(matches!(result, FirmwareResult::InvalidHeader));
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn installer_rejects_nonzero_developer_second_signature() {
+ let (header, hash, key) = developer_header(FIRMWARE_MAGIC_COLOR);
+ for offset in 0..64 {
+ let mut malformed = header.clone();
+ malformed[SIGNATURE2_OFFSET + offset] = 1;
+ assert!(matches!(
+ check_header(&malformed),
+ FirmwareResult::InvalidHeader
+ ));
+ assert!(matches!(
+ check_signature(&malformed, &hash, &key),
+ FirmwareResult::InvalidHeader
+ ));
+ }
+ }
+
+ #[test]
+ fn official_header_may_have_second_key_and_signature() {
+ let (mut header, _, _) = developer_header(FIRMWARE_MAGIC_COLOR);
+ header[KEY1_OFFSET..SIGNATURE1_OFFSET]
+ .copy_from_slice(&0u32.to_le_bytes());
+ header[KEY2_OFFSET..SIGNATURE2_OFFSET]
+ .copy_from_slice(&1u32.to_le_bytes());
+ header.copy_within(SIGNATURE1_OFFSET..KEY2_OFFSET, SIGNATURE2_OFFSET);
+ assert!(matches!(
+ check_header(&header),
+ FirmwareResult::HeaderOk {
+ signed_by_user: false,
+ ..
+ }
+ ));
+ }
#[test]
fn sanity_test() {
### ports/stm32/boards/Passport/bootloader/main.c
@@ -24,7 +24,7 @@
#include "update.h"
#include "verify.h"
#include "spiflash.h"
-#include "firmware-keys.h"
+#include "firmware-classification.h"
#include "backlight.h"
#include "display.h"
@@ -520,7 +520,7 @@ static void microsd_firmware_recovery(void) {
}
// User signed firmware cannot be used as a factory reset firmware.
- if (sd_card_hdr.signature.pubkey1 == FW_USER_KEY && sd_card_hdr.signature.pubkey2 == 0) {
+ if (firmware_is_user_signed(&sd_card_hdr)) {
strcpy(message, "Firmware signed by a Developer PubKey cannot be used for recovery.");
goto fail;
}
### ports/stm32/boards/Passport/bootloader/update.c
@@ -26,7 +26,7 @@
#include "ui-splash.h"
#include "utils.h"
-#include "firmware-keys.h"
+#include "firmware-classification.h"
#include "flash.h"
#include "gpio.h"
#include "se-atecc608a.h"
@@ -338,7 +338,7 @@ void update_firmware(void) {
}
}
- bool is_spi_fw_user_signed = spihdr.signature.pubkey1 == FW_USER_KEY;
+ bool is_spi_fw_user_signed = firmware_is_user_signed(&spihdr);
secresult current_firmware_result;
// Handle the firmware hash update
@@ -486,7 +486,7 @@ void update_firmware(void) {
secresult is_user_signed_firmware_installed(void) {
passport_firmware_header_t* hdr = FW_HDR;
- return (hdr->signature.pubkey1 == FW_USER_KEY && hdr->signature.pubkey2 == 0) ? SEC_TRUE : SEC_FALSE;
+ return firmware_is_user_signed(hdr) ? SEC_TRUE : SEC_FALSE;
}
// Definitions for the code below
### ports/stm32/boards/Passport/bootloader/verify.c
@@ -17,6 +17,7 @@
#include <stdio.h>
#include "delay.h"
+#include "firmware-classification.h"
#include "firmware-keys.h"
#include "hash.h"
#include "se-config.h"
@@ -44,10 +45,13 @@ secresult verify_header(passport_firmware_header_t* hdr) {
if (hdr->info.fwlength < FW_HEADER_SIZE) goto fail;
if (hdr->info.fwlength > FW_MAX_FWLENGTH) goto fail;
- // if (hdr->signature.pubkey1 == 0) goto fail;
- if ((hdr->signature.pubkey1 != FW_USER_KEY) && (hdr->signature.pubkey1 > FW_MAX_PUB_KEYS)) goto fail;
- if (hdr->signature.pubkey1 != FW_USER_KEY) {
- // if (hdr->signature.pubkey2 == 0) goto fail;
+ if (firmware_is_user_signed(hdr)) {
+ if (hdr->signature.pubkey2 != 0) goto fail;
+ for (size_t i = 0; i < sizeof(hdr->signature.signature2); i++) {
+ if (hdr->signature.signature2[i] != 0) goto fail;
+ }
+ } else {
+ if (hdr->signature.pubkey1 > FW_MAX_PUB_KEYS) goto fail;
if (hdr->signature.pubkey2 > FW_MAX_PUB_KEYS) goto fail;
}
@@ -60,7 +64,7 @@ secresult verify_header(passport_firmware_header_t* hdr) {
secresult verify_signature(passport_firmware_header_t* hdr, uint8_t* fw_hash, uint32_t hashlen) {
int rc;
- if (hdr->signature.pubkey1 == FW_USER_KEY) {
+ if (firmware_is_user_signed(hdr)) {
uint8_t user_public_key[72] = {0};
#ifdef DEBUG_PRINT_VERIFY
printf("Checking user-signed signature\r\n");
### ports/stm32/boards/Passport/include/firmware-classification.h
@@ -0,0 +1,13 @@
+// SPDX-FileCopyrightText: 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <stdbool.h>
+#include "fwheader.h"
+#include "firmware-keys.h"
+
+static inline bool firmware_is_user_signed(const passport_firmware_header_t* header) {
+ // The first key selects which signature-verification path is used.
+ return header->signature.pubkey1 == FW_USER_KEY;
+}
### ports/stm32/boards/Passport/modpassport-system.h
@@ -18,6 +18,7 @@
#include "gpio.h"
#include "display.h"
#include "firmware-keys.h"
+#include "firmware-classification.h"
#include "frequency.h"
#include "dispatch.h"
#include "adc.h"
@@ -134,7 +135,7 @@ STATIC mp_obj_t mod_passport_System_get_software_info(mp_obj_t self) {
tuple[2] = mp_obj_new_int_from_uint(boot_counter);
// User-signed firmware?
- tuple[3] = (fwhdr->signature.pubkey1 == FW_USER_KEY) ? mp_const_true : mp_const_false;
+ tuple[3] = firmware_is_user_signed(fwhdr) ? mp_const_true : mp_const_false;
// Firmware date string
tuple[4] =
@@ -228,7 +229,7 @@ STATIC MP_DEFINE_CONST_FUN_OBJ_1(mod_passport_System_get_sd_root_obj, mod_passpo
STATIC mp_obj_t mod_passport_System_is_user_firmware_installed(mp_obj_t self) {
passport_firmware_header_t* fwhdr = (passport_firmware_header_t*)FW_HDR;
- return (fwhdr->signature.pubkey1 == FW_USER_KEY && fwhdr->signature.pubkey2 == 0) ? mp_const_true : mp_const_false;
+ return firmware_is_user_signed(fwhdr) ? mp_const_true : mp_const_false;
}
STATIC MP_DEFINE_CONST_FUN_OBJ_1(mod_passport_System_is_user_firmware_installed_obj,
mod_passport_System_is_user_firmware_installed);
### ports/stm32/boards/Passport/modules/flows/update_firmware_flow.py
@@ -26,6 +26,7 @@ def __init__(self, reset_after=True, statusbar=None):
self.statusbar = statusbar
self.filename = None
self.error_message = None
+ self.update_header = None
async def on_done(self, error=None, message=None):
self.error = error
@@ -81,6 +82,7 @@ async def show_firmware_details(self):
try:
version, is_user_signed = passport.verify_update_header(header)
self.version = version
+ self.update_header = header
except passport.InvalidFirmwareUpdate as e:
await ErrorPage(text='Firmware update is invalid.\n\n{}'.format(str(e))).show()
self.set_result(False)
@@ -109,7 +111,7 @@ async def verify_firmware_signature(self):
self.progress_page = ProgressPage(text='Verifying signatures', left_micron=None, right_micron=None)
self.verify_task = start_task(verify_firmware_signature_task(
- self.update_file_path, self.size, self.progress_page.set_progress, self.on_done))
+ self.update_file_path, self.size, self.update_header, self.progress_page.set_progress, self.on_done))
prev_top_level = ui.set_is_top_level(False)
result = await self.progress_page.show()
@@ -133,7 +135,7 @@ async def copy_to_flash(self):
self.progress_page = ProgressPage(text='Preparing Update', left_micron=None, right_micron=None)
self.update_task = start_task(copy_firmware_to_spi_flash_task(
- self.update_file_path, self.size, self.progress_page.set_progress, self.on_done))
+ self.update_file_path, self.size, self.update_header, self.progress_page.set_progress, self.on_done))
prev_top_level = ui.set_is_top_level(False)
result = await self.progress_page.show()
### ports/stm32/boards/Passport/modules/tasks/copy_firmware_to_spi_flash_task.py
@@ -28,19 +28,21 @@ async def sleep_and_timeout(sleep_time_ms, timeout_ms, sf):
assert timeout_ms > 0, 'Firmware update timed out'
-async def copy_firmware_to_spi_flash_task(file_path, size, on_progress, on_done):
+async def copy_firmware_to_spi_flash_task(file_path, size, expected_header, on_progress, on_done):
from common import system, sf
try:
with CardSlot() as card:
with open(file_path, 'rb') as fp:
try:
- offset = 0
-
header = fp.read(FW_HEADER_SIZE)
- # copy binary into serial flash
- fp.seek(offset)
+ if len(header) != FW_HEADER_SIZE or header != expected_header:
+ await on_done(Error.FIRMWARE_UPDATE_FAILED, "Firmware header changed. Select the file again.")
+ return
+
+ # Stage the checked header from memory, then read the body
+ # from the file's current position.
# Calculate the update request hash so that the booloader knows this was requested by the user, not
# injected into SPI flash by some external attacker.
@@ -58,6 +60,7 @@ async def copy_firmware_to_spi_flash_task(file_path, size, on_progress, on_done)
system.get_device_hash(device_hash)
except CardMissingError:
await on_done(Error.MICROSD_CARD_MISSING, None)
+ return
except Exception as e:
error_message = "Seek error: {}, Info: {}".format(e.__class__.__name__,
e.args[0] if len(e.args) == 1 else e.args)
@@ -86,26 +89,35 @@ async def copy_firmware_to_spi_flash_task(file_path, size, on_progress, on_done)
except CardMissingError:
await on_done(Error.MICROSD_CARD_MISSING, None)
+ return
except Exception as e:
error_message = "Erase error: {}, Info: {}".format(e.__class__.__name__,
e.args[0] if len(e.args) == 1 else e.args)
await on_done(Error.FIRMWARE_UPDATE_FAILED, error_message)
return
- while pos <= size + 256:
+ while pos < size + 256:
try:
# Update progress bar every 50 flash pages
if update_display % 50 == 0:
percent = int(((pos - 256) / size) * 100)
# print('pos = {} percent={}%'.format(pos, percent))
on_progress(percent)
- here = fp.readinto(buf)
- if not here:
- break
+ offset = pos - 256
+ remaining = min(len(buf), size - offset)
+ if offset < FW_HEADER_SIZE:
+ buf[:] = header[offset:offset + len(buf)]
+ here = len(buf)
+ else:
+ buf[:] = bytes(len(buf))
+ here = fp.readinto(memoryview(buf)[:remaining])
+ if here != remaining:
+ raise ValueError("Firmware file is truncated")
update_display += 1
except CardMissingError:
await on_done(Error.MICROSD_CARD_MISSING, None)
+ return
except Exception as e:
error_message = "Read error: {}, Info: {}".format(e.__class__.__name__,
e.args[0] if len(e.args) == 1 else e.args)
@@ -128,6 +140,7 @@ async def copy_firmware_to_spi_flash_task(file_path, size, on_progress, on_done)
await sleep_ms(1)
except CardMissingError:
await on_done(Error.MICROSD_CARD_MISSING, None)
+ return
except Exception as e:
error_message = "Write error: {}, Info: {}".format(e.__class__.__name__,
e.args[0] if len(e.args) == 1 else e.args)
@@ -143,6 +156,7 @@ async def copy_firmware_to_spi_flash_task(file_path, size, on_progress, on_done)
# Success
except CardMissingError:
await on_done(Error.MICROSD_CARD_MISSING, None)
+ return
except Exception as e:
error_message = "Hash error: {}, Info: {}".format(e.__class__.__name__,
e.args[0] if len(e.args) == 1 else e.args)
@@ -151,10 +165,12 @@ async def copy_firmware_to_spi_flash_task(file_path, size, on_progress, on_done)
except CardMissingError:
await on_done(Error.MICROSD_CARD_MISSING, None)
+ return
except Exception as e:
error_message = "Firmware update error: {}, Info: {}".format(e.__class__.__name__,
e.args[0] if len(e.args) == 1 else e.args)
await on_done(Error.FIRMWARE_UPDATE_FAILED, error_message)
+ return
await on_done(None, None)
### ports/stm32/boards/Passport/modules/tasks/verify_firmware_signature_task.py
@@ -14,18 +14,17 @@
from errors import Error
-async def verify_firmware_signature_task(file_path, size, on_progress, on_done):
+async def verify_firmware_signature_task(file_path, size, expected_header, on_progress, on_done):
header = None
s = trezorcrypto.sha256()
try:
with CardSlot() as card:
with open(file_path, 'rb') as fp:
- # This is assumed to have been validated before, TOCTOU
- # attacks are not an issue here since if the information data
- # changes so does the validation hash making the signature
- # verification to fail.
header = fp.read(FW_HEADER_SIZE)
+ if len(header) != FW_HEADER_SIZE or header != expected_header:
+ await on_done(Error.FIRMWARE_UPDATE_FAILED, "Firmware header changed. Select the file again.")
+ return
s.update(header[:FW_HEADER_INFORMATION_SIZE])
buf = bytearray(1024)
@@ -41,6 +40,8 @@ async def verify_firmware_signature_task(file_path, size, on_progress, on_done):
await sleep_ms(1)
here = fp.readinto(buf)
+ if not here:
+ raise ValueError("Firmware file is truncated")
s.update(buf[:here])
pos += here
### ports/stm32/boards/Passport/modules/tests/test_firmware_update_host.py
@@ -0,0 +1,361 @@
+# SPDX-FileCopyrightText: 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+"""Host tests: pytest ports/stm32/boards/Passport/modules/tests/test_firmware_update_host.py."""
+
+import asyncio
+import hashlib
+import importlib.util
+import os
+from pathlib import Path
+import shlex
+import subprocess
+import sys
+from types import SimpleNamespace
+
+import pytest
+
+MODULES = Path(__file__).resolve().parents[1]
+BOARD = MODULES.parent
+HEADER_SIZE = 2048
+ACTUAL_HEADER_SIZE = 170
+
+
+def load_module(relative):
+ spec = importlib.util.spec_from_file_location(Path(relative).stem, MODULES / relative)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+@pytest.fixture
+def environment(monkeypatch):
+ class CardMissingError(Exception):
+ pass
+
+ class CardSlot:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ pass
+
+ class Flash:
+ def __init__(self):
+ self.data = bytearray(b'\xff' * 16384)
+ self.writes = []
+ self.erases = []
+ self.fail_at = None
+
+ def is_busy(self):
+ return False
+
+ def sector_erase(self, address):
+ self.erases.append(address)
+ self.data[address:address + 4096] = b'\xff' * 4096
+
+ def write(self, address, data):
+ if address == self.fail_at:
+ raise CardMissingError()
+ self.writes.append(address)
+ self.data[address:address + len(data)] = data
+
+ def sha256(data, output):
+ output[:] = hashlib.sha256(data).digest()
+
+ async def sleep_ms(_):
+ pass
+
+ flash = Flash()
+ errors = SimpleNamespace(FIRMWARE_UPDATE_FAILED=1, MICROSD_CARD_MISSING=2)
+ device_hash = b'd' * 32
+ system = SimpleNamespace(get_device_hash=lambda out: out.__setitem__(slice(None), device_hash))
+ verified = []
+
+ def verify_signatures(header, digest):
+ verified.append((header, bytes(digest)))
+
+ passport = SimpleNamespace(IS_SIMULATOR=False, InvalidFirmwareUpdate=ValueError,
+ verify_update_signatures=verify_signatures)
+ stubs = {
+ 'constants': SimpleNamespace(FW_HEADER_SIZE=HEADER_SIZE, FW_ACTUAL_HEADER_SIZE=ACTUAL_HEADER_SIZE,
+ FW_HEADER_INFORMATION_SIZE=34),
+ 'trezorcrypto': SimpleNamespace(sha256=hashlib.sha256),
+ 'foundation': SimpleNamespace(sha256=sha256),
+ 'passport': passport,
+ 'uasyncio': SimpleNamespace(sleep_ms=sleep_ms),
+ 'ubinascii': __import__('binascii'),
+ 'files': SimpleNamespace(CardSlot=CardSlot, CardMissingError=CardMissingError),
+ 'errors': SimpleNamespace(Error=errors),
+ 'common': SimpleNamespace(system=system, sf=flash),
+ }
+ for name, value in stubs.items():
+ monkeypatch.setitem(sys.modules, name, value)
+ return SimpleNamespace(flash=flash, errors=errors, verified=verified, device_hash=device_hash)
+
+
+def run_task(relative, function, path, size, header, progress=lambda _: None):
+ results = []
+
+ async def done(error, message):
+ results.append((error, message))
+
+ module = load_module(relative)
+ module.open = lambda file, mode: open(file, mode, buffering=0)
+ asyncio.run(getattr(module, function)(str(path), size, header, progress, done))
+ return results
+
+
+@pytest.mark.parametrize('task', ['verify_firmware_signature_task', 'copy_firmware_to_spi_flash_task'])
+def test_changed_header_rejected_before_staging(environment, tmp_path, task):
+ expected = bytes(HEADER_SIZE)
+ changed = bytearray(expected)
+ changed[102] = 1 # Second public key index in the serialized header.
+ path = tmp_path / 'firmware.bin'
+ path.write_bytes(changed + bytes(2048))
+ results = run_task('tasks/' + task + '.py', task, path, 4096, expected)
+ assert len(results) == 1 and results[0][0] == environment.errors.FIRMWARE_UPDATE_FAILED
+ assert not environment.flash.erases
+ assert not environment.flash.writes
+ assert not environment.verified
+
+
+def test_checked_header_is_staged_without_rereading(environment, tmp_path):
+ header = bytes(HEADER_SIZE)
+ body = bytes(range(256)) * 9 + b'last'
+ image = header + body
+ path = tmp_path / 'firmware.bin'
+ path.write_bytes(image)
+ verify = 'verify_firmware_signature_task'
+ assert run_task('tasks/' + verify + '.py', verify, path, len(image), header) == [(None, None)]
+ assert environment.verified == [(header, hashlib.sha256(hashlib.sha256(header[:34] + body).digest()).digest())]
+
+ def change_source(_):
+ with path.open('r+b') as fp:
+ fp.seek(102)
+ fp.write(b'\x01')
+
+ copy = 'copy_firmware_to_spi_flash_task'
+ assert run_task('tasks/' + copy + '.py', copy, path, len(image), header, change_source) == [(None, None)]
+ assert environment.flash.data[256:256 + len(image)] == image
+ assert environment.flash.writes[-1] == 0
+ header_hash = hashlib.sha256(hashlib.sha256(header[:ACTUAL_HEADER_SIZE]).digest()).digest()
+ assert environment.flash.data[:32] == hashlib.sha256(header_hash + environment.device_hash).digest()
+
+
+@pytest.mark.parametrize('task', ['verify_firmware_signature_task', 'copy_firmware_to_spi_flash_task'])
+def test_truncated_body_fails_once(environment, tmp_path, task):
+ header = bytes(HEADER_SIZE)
+ path = tmp_path / 'firmware.bin'
+ path.write_bytes(header + b'short')
+ results = run_task('tasks/' + task + '.py', task, path, 4096, header)
+ assert len(results) == 1 and results[0][0] == environment.errors.FIRMWARE_UPDATE_FAILED
+ assert 0 not in environment.flash.writes
+ assert not environment.verified
+
+
+def test_copy_failure_never_authorizes_update(environment, tmp_path):
+ header = bytes(HEADER_SIZE)
+ path = tmp_path / 'firmware.bin'
+ path.write_bytes(header + bytes(2048))
+ environment.flash.fail_at = 512
+ task = 'copy_firmware_to_spi_flash_task'
+ results = run_task('tasks/' + task + '.py', task, path, 4096, header)
+ assert results == [(environment.errors.MICROSD_CARD_MISSING, None)]
+ assert 0 not in environment.flash.writes
+
+
+def test_installed_firmware_detection_c(tmp_path):
+ harness = tmp_path / 'detection.c'
+ source = (BOARD / 'bootloader/update.c').read_text()
+ start = source.index('secresult is_user_signed_firmware_installed(void) {')
+ detection = source[start:source.index('\n}', start) + 2]
+ harness.write_text('''
+#include <assert.h>
+#include "secresult.h"
+#include "firmware-classification.h"
+static passport_firmware_header_t installed;
+#define BL_FW_HDR_BASE (&installed)
+''' + detection + '''
+
+int main(void) {
+ const uint32_t second_keys[] = {0, 1, 2, FW_MAX_PUB_KEYS, UINT32_MAX};
+ for (unsigned int i = 0; i < sizeof(second_keys) / sizeof(second_keys[0]); i++) {
+ installed.signature.pubkey2 = second_keys[i];
+ installed.signature.pubkey1 = FW_USER_KEY;
+ assert(firmware_is_user_signed(&installed));
+ assert(is_user_signed_firmware_installed() == SEC_TRUE);
+ for (uint32_t key = 0; key < FW_MAX_PUB_KEYS; key++) {
+ installed.signature.pubkey1 = key;
+ assert(!firmware_is_user_signed(&installed));
+ assert(is_user_signed_firmware_installed() == SEC_FALSE);
+ }
+ }
+}
+''')
+ binary = tmp_path / 'detection'
+ subprocess.run(shlex.split(os.environ.get('CC', 'cc')) +
+ ['-std=c11', '-I', str(BOARD / 'include'), str(harness), '-o', str(binary)], check=True)
+ subprocess.run([str(binary)], check=True)
+
+
+@pytest.mark.parametrize('screen', ['COLOR', 'MONO'])
+def test_bootloader_header_validation_c(tmp_path, screen):
+ source = (BOARD / 'bootloader/verify.c').read_text()
+ start = source.index('secresult verify_header(')
+ validation = source[start:source.index('\n}', start) + 2]
+ harness = tmp_path / 'header.c'
+ harness.write_text('''
+#include <assert.h>
+#include <stddef.h>
+#include "firmware-classification.h"
+#include "secresult.h"
+''' + validation + '''
+int main(void) {
+ passport_firmware_header_t header = {0};
+#ifdef SCREEN_MODE_COLOR
+ header.info.magic = FW_HEADER_MAGIC_COLOR;
+#else
+ header.info.magic = FW_HEADER_MAGIC;
+#endif
+ header.info.timestamp = 1;
+ header.info.fwversion[0] = '1';
+ header.info.fwlength = FW_HEADER_SIZE;
+ header.signature.pubkey1 = FW_USER_KEY;
+ header.signature.signature1[0] = 1;
+ assert(verify_header(&header) == SEC_TRUE);
+
+ const uint32_t second_keys[] = {1, FW_MAX_PUB_KEYS, FW_USER_KEY, UINT32_MAX};
+ for (size_t i = 0; i < sizeof(second_keys) / sizeof(second_keys[0]); i++) {
+ header.signature.pubkey2 = second_keys[i];
+ assert(verify_header(&header) == SEC_FALSE);
+ }
+ header.signature.pubkey2 = 0;
+ for (size_t i = 0; i < sizeof(header.signature.signature2); i++) {
+ header.signature.signature2[i] = 1;
+ assert(verify_header(&header) == SEC_FALSE);
+ header.signature.signature2[i] = 0;
+ }
+ assert(verify_header(&header) == SEC_TRUE);
+
+ header.signature.signature2[0] = 1;
+ for (uint32_t first = 0; first < FW_MAX_PUB_KEYS; first++) {
+ for (uint32_t second = 0; second < FW_MAX_PUB_KEYS; second++) {
+ if (first == second) continue;
+ header.signature.pubkey1 = first;
+ header.signature.pubkey2 = second;
+ assert(verify_header(&header) == SEC_TRUE);
+ }
+ }
+}
+''')
+ binary = tmp_path / 'header'
+ subprocess.run(shlex.split(os.environ.get('CC', 'cc')) +
+ ['-std=c11', '-D', 'SCREEN_MODE_' + screen, '-I', str(BOARD / 'include'),
+ str(harness), '-o', str(binary)], check=True)
+ subprocess.run([str(binary)], check=True)
+
+
+@pytest.mark.parametrize('developer', [True, False])
+def test_key_removal_guard(monkeypatch, developer):
+ events = []
+
+ class Flow:
+ def __init__(self, initial_state):
+ pass
+
+ def set_result(self, result):
+ events.append(('result', result))
+
+ def goto(self, state):
+ events.append(('goto', state.__name__))
+
+ class Page:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ async def show(self):
+ events.append(('page', None))
+ return True
+
+ system = SimpleNamespace(is_user_firmware_installed=lambda: developer,
+ set_user_firmware_pubkey=lambda key: events.append(('clear', key)))
+ for name, stub in {
+ 'flows': SimpleNamespace(Flow=Flow),
+ 'pages': SimpleNamespace(SuccessPage=Page, ErrorPage=Page, QuestionPage=Page),
+ 'utils': SimpleNamespace(clear_cached_pubkey=lambda: events.append(('cache', None))),
+ 'common': SimpleNamespace(system=system),
+ }.items():
+ monkeypatch.setitem(sys.modules, name, stub)
+ flow = load_module('flows/remove_dev_pubkey_flow.py').RemoveDevPubkeyFlow()
+ asyncio.run(flow.check_for_user_signed_firmware())
+ if developer:
+ assert events == [('page', None), ('result', True)]
+ else:
+ assert events == [('goto', 'remove_dev_pubkey')]
+
+
+def test_update_flow_passes_selected_header_to_both_tasks(monkeypatch):
+ calls = []
+ pending = []
+
+ class Flow:
+ def __init__(self, **kwargs):
+ pass
+
+ def goto(self, state):
+ calls.append(state.__name__)
+
+ def set_result(self, result):
+ calls.append(result)
+
+ class ProgressPage:
+ def __init__(self, **kwargs):
+ self.result = None
+
+ def set_progress(self, percent):
+ pass
+
+ def set_result(self, result):
+ self.result = result
+
+ async def show(self):
+ await pending.pop()
+ return self.result
+
+ async def verify(path, size, header, progress, done):
+ calls.append(('verify', header))
+ await done(None, None)
+
+ async def copy(path, size, header, progress, done):
+ calls.append(('copy', header))
+ await done(None, None)
+
+ page_names = ['ErrorPage', 'ProgressPage', 'QuestionPage', 'SuccessPage', 'InsertMicroSDPage', 'InfoPage']
+ pages = {name: ProgressPage for name in page_names}
+ stubs = {
+ 'lvgl': SimpleNamespace(),
+ 'machine': SimpleNamespace(),
+ 'files': SimpleNamespace(CardSlot=None),
+ 'constants': SimpleNamespace(FW_HEADER_SIZE=HEADER_SIZE, FW_MAX_SIZE=2000000),
+ 'pages': SimpleNamespace(**pages),
+ 'tasks': SimpleNamespace(verify_firmware_signature_task=verify, copy_firmware_to_spi_flash_task=copy),
+ 'flows': SimpleNamespace(Flow=Flow, FilePickerFlow=None),
+ 'utils': SimpleNamespace(read_user_firmware_pubkey=None, is_all_zero=None, start_task=pending.append),
+ 'errors': SimpleNamespace(Error=None),
+ 'passport': SimpleNamespace(),
+ 'common': SimpleNamespace(ui=SimpleNamespace(set_is_top_level=lambda value: True),
+ system=SimpleNamespace(get_software_info=lambda: ('2.4.0', 0, 0, True, '')),
+ settings=SimpleNamespace(set=lambda *args: None, save=lambda: None)),
+ }
+ for name, value in stubs.items():
+ monkeypatch.setitem(sys.modules, name, value)
+ flow = load_module('flows/update_firmware_flow.py').UpdateFirmwareFlow(reset_after=False)
+ header = bytes(HEADER_SIZE)
+ flow.update_header = header
+ flow.update_file_path = 'firmware.bin'
+ flow.size = 4096
+ flow.version = '2.4.0'
+ asyncio.run(flow.verify_firmware_signature())
+ asyncio.run(flow.copy_to_flash())
+ assert calls == [('verify', header), 'copy_to_flash', ('copy', header), True]Why this scored 59/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.