chore(trezorlib): fix `BootloaderV2Image` handling in `firmware_headers.py`
What changed, and why it matters
This commit fixes how Trezor's Python library validates signatures for a new bootloader image format (BootloaderV2). Before the fix, the code ignored the signature bitmask and simply checked every signature against every public key in order. This could let an invalid or mismatched signature slip through, or cause validation to fail incorrectly. The patch makes the library read the bitmask, count signatures correctly, and only verify the signatures the image actually claims to have signed with. It also adds a missing signature-status line to the printed header. The change is in developer/CLI tooling, not the device firmware itself, so a direct attack on a real device is unlikely, but the library is used to inspect and validate firmware files.
Treat as a low-to-moderate security hardening fix. Update any local installations of trezorlib that inspect or validate BootloaderV2 images. No emergency device-side action is needed because the fix is in host-side Python tooling. If the buggy version was used to validate firmware in an automated CI/CD pipeline, re-validate any BootloaderV2 images processed by the old code.
Security signals we found
Signature verification logic corrected to enforce bitmask alignment
Added length/popcount validation for signature arrays
Missing signature status reporting added for BootloaderV2Image header output
Developer-tooling library used for firmware inspection/validation
Evidence from the diff
The diff updates python/src/trezorlib/_internal/firmware_headers.py. The main functional change is in BootloaderV2Image.verify(): it now uses header.sigmask to select which public keys (ed25519 and SLH-DSA) correspond to which signatures, instead of iterating signatures and keys by matching index positions. It adds length checks: sigmask bit length must not exceed the number of available public keys, and the popcount of sigmask must equal the number of provided ec_signatures and slh_signatures. It also exposes check_signature_any() and format_container() (renamed from private) and adds a public_keys() method to the CosiSignedImage protocol. The format method for BootloaderV2Image now reports signature status. Other changes are type annotations and internal renames.
Changed components
python/src/trezorlib/_internal/firmware_headers.pyBootloaderV2Image classCosiSignedImage protocolVendorHeader/VendorFirmware/BootloaderImage/SecmonImage/LegacyV2Firmware (call-site renames only)Inspect captured patch +59 / −25
diff --git a/python/src/trezorlib/_internal/firmware_headers.py b/python/src/trezorlib/_internal/firmware_headers.py
index a804a89a..2154191d 100644
--- a/python/src/trezorlib/_internal/firmware_headers.py
+++ b/python/src/trezorlib/_internal/firmware_headers.py
@@ -57,7 +57,9 @@ def all_zero(data: bytes) -> bool:
return all(b == 0 for b in data)
-def _check_signature_any(fw: "SignableImageProto", is_devel: bool = False) -> Status:
+def check_signature_any(
+ fw: SignableImageProto | BootloaderV2Image, is_devel: bool = False
+) -> Status:
if not fw.signature_present():
return Status.MISSING
try:
@@ -80,7 +82,7 @@ class LiteralStr(str):
pass
-def _format_container(
+def format_container(
pb: t.Union[c.Container, Struct, dict],
indent: int = 0,
sep: str = " " * 4,
@@ -178,7 +180,7 @@ def format_header(
all_ok = SYM_OK if hash_status.is_ok() and sig_status.is_ok() else SYM_FAIL
output = [
- "Firmware Header " + _format_container(header_out),
+ "Firmware Header " + format_container(header_out),
f"Fingerprint: {click.style(digest.hex(), bold=True)}",
f"{all_ok} Signature is {sig_status.value}, hashes are {hash_status.value}",
]
@@ -187,7 +189,7 @@ def format_header(
def format_secmon_header(
- header: firmware.SecmonHeader,
+ header: firmware.secmon.SecmonHeader,
code_hash: bytes,
digest: bytes,
sig_status: Status,
@@ -213,7 +215,7 @@ def format_secmon_header(
all_ok = SYM_OK if hash_status.is_ok() and sig_status.is_ok() else SYM_FAIL
output = [
- "SECMON Header " + _format_container(header_out),
+ "SECMON Header " + format_container(header_out),
"Code hash: " + click.style(code_hash.hex(), bold=True),
f"Fingerprint: {click.style(digest.hex(), bold=True)}",
f"{all_ok} Signature is {sig_status.value}, hash is {hash_status.value}",
@@ -264,6 +266,8 @@ class CosiSignedImage(SignableImageProto, Protocol):
def insert_signature(self, signature: bytes, sigmask: int) -> None: ...
+ def public_keys(self, dev_keys: bool = False) -> t.Sequence[bytes]: ...
+
@runtime_checkable
class LegacySignedImage(SignableImageProto, Protocol):
@@ -298,7 +302,7 @@ class CosiSignedMixin:
class VendorHeader(firmware.VendorHeader, CosiSignedMixin):
NAME: t.ClassVar[str] = "vendorheader"
- DEV_KEYS = _make_dev_keys(b"\x44", b"\x45")
+ DEV_KEYS: t.ClassVar[t.Sequence[bytes]] = _make_dev_keys(b"\x44", b"\x45")
SUBCON = c.Struct(*firmware.VendorHeader.SUBCON.subcons, c.Terminated)
@@ -308,7 +312,7 @@ class VendorHeader(firmware.VendorHeader, CosiSignedMixin):
def _format(self, terse: bool) -> str:
if not terse:
output = [
- "Vendor Header " + _format_container(self),
+ "Vendor Header " + format_container(self),
f"Pubkey bundle hash: {self.vhash().hex()}",
]
else:
@@ -323,7 +327,7 @@ class VendorHeader(firmware.VendorHeader, CosiSignedMixin):
if not terse:
output.append(f"Fingerprint: {click.style(self.digest().hex(), bold=True)}")
- sig_status = _check_signature_any(self)
+ sig_status = check_signature_any(self)
sym = SYM_OK if sig_status.is_ok() else SYM_FAIL
output.append(f"{sym} Signature is {sig_status.value}")
@@ -338,7 +342,7 @@ class VendorHeader(firmware.VendorHeader, CosiSignedMixin):
class VendorFirmware(firmware.VendorFirmware, CosiSignedMixin):
NAME: t.ClassVar[str] = "firmware"
- DEV_KEYS = _make_dev_keys(b"\x47", b"\x48")
+ DEV_KEYS: t.ClassVar[t.Sequence[bytes]] = _make_dev_keys(b"\x47", b"\x48")
def get_header(self) -> CosiSignatureHeaderProto:
return self.firmware.header
@@ -356,7 +360,7 @@ class VendorFirmware(firmware.VendorFirmware, CosiSignedMixin):
self.firmware.header,
self.firmware.code_hashes(),
self.digest(),
- _check_signature_any(self, is_devel),
+ check_signature_any(self, is_devel),
)
)
@@ -372,7 +376,7 @@ class VendorFirmware(firmware.VendorFirmware, CosiSignedMixin):
class BootloaderImage(firmware.FirmwareImage, CosiSignedMixin):
NAME: t.ClassVar[str] = "bootloader"
- DEV_KEYS = _make_dev_keys(b"\x41", b"\x42")
+ DEV_KEYS: t.ClassVar[t.Sequence[bytes]] = _make_dev_keys(b"\x41", b"\x42")
def get_header(self) -> CosiSignatureHeaderProto:
return self.header
@@ -382,7 +386,7 @@ class BootloaderImage(firmware.FirmwareImage, CosiSignedMixin):
self.header,
self.code_hashes(),
self.digest(),
- _check_signature_any(self),
+ check_signature_any(self),
)
def verify(self, dev_keys: bool = False) -> None:
@@ -405,7 +409,7 @@ class BootloaderImage(firmware.FirmwareImage, CosiSignedMixin):
class SecmonImage(firmware.SecmonImage, CosiSignedMixin):
NAME: t.ClassVar[str] = "secmon"
- DEV_KEYS = _make_dev_keys(b"\x41", b"\x42")
+ DEV_KEYS: t.ClassVar[t.Sequence[bytes]] = _make_dev_keys(b"\x41", b"\x42")
def get_header(self) -> CosiSignatureHeaderProto:
return self.header
@@ -415,7 +419,7 @@ class SecmonImage(firmware.SecmonImage, CosiSignedMixin):
self.header,
self.code_hash(),
self.digest(),
- _check_signature_any(self),
+ check_signature_any(self),
)
def verify(self, dev_keys: bool = False) -> None:
@@ -475,10 +479,14 @@ class BootloaderV2Image(firmware.BootableImage):
if "version" in key:
header_out[key] = LiteralStr(_format_version(val))
+ sig_status = check_signature_any(self)
+ all_ok = SYM_OK if sig_status.is_ok() else SYM_FAIL
+
output = [
- "Firmware Header " + _format_container(header_out),
+ "Firmware Header " + format_container(header_out),
f"Leaf hash: {click.style(self.leaf_hash().hex(), bold=True)}",
f"Merkle root: {click.style(self.merkle_root().hex(), bold=True)}",
+ f"{all_ok} Signature is {sig_status.value}",
]
return "\n".join(output)
@@ -487,18 +495,44 @@ class BootloaderV2Image(firmware.BootableImage):
digest = self.merkle_root()
hash_fn = self.get_hash_params().hash_function
-
- for idx, key in enumerate(self.public_ec_keys(dev_keys)):
- ext_digest = hash_fn(digest + self.unauth.slh_signatures[idx]).digest()
+ mask = self.header.sigmask
+
+ if (mask.bit_length() > len(self.public_ec_keys(dev_keys))) or (
+ mask.bit_length() > len(self.public_pq_keys(dev_keys))
+ ):
+ raise ValueError("Sigmask specifies more public keys than provided.")
+
+ # Verify ed25519 signatures
+ if mask.bit_count() != len( # pyright: ignore[reportAttributeAccessIssue] # bit_count() is not available with Python 3.9
+ self.unauth.ec_signatures
+ ):
+ raise ValueError("Sigmask does not specify valid number of ed25519 keys.")
+
+ sig_idx = 0
+ for pubkey_idx, key in enumerate(self.public_ec_keys(dev_keys)):
+ if not (mask & (1 << pubkey_idx)):
+ continue
+ ext_digest = hash_fn(digest + self.unauth.slh_signatures[sig_idx]).digest()
try:
- _ed25519.checkvalid(self.unauth.ec_signatures[idx], ext_digest, key)
+ _ed25519.checkvalid(self.unauth.ec_signatures[sig_idx], ext_digest, key)
except _ed25519.SignatureMismatch:
raise firmware.InvalidSignatureError("Invalid bootloader signature")
-
- for idx, key in enumerate(self.public_pq_keys(dev_keys)):
- key = PublicKey.from_digest(key, sha2_128s)
- if not key.verify(digest, self.unauth.slh_signatures[idx]):
+ sig_idx += 1
+
+ # Verify slh-dsa signatures
+ if mask.bit_count() != len( # pyright: ignore[reportAttributeAccessIssue] # bit_count() is not available with Python 3.9
+ self.unauth.slh_signatures
+ ):
+ raise ValueError("Sigmask does not specify valid number of slh-dsa keys.")
+
+ sig_idx = 0
+ for pubkey_idx, key in enumerate(self.public_pq_keys(dev_keys)):
+ if not (mask & (1 << pubkey_idx)):
+ continue
+ pq_key = PublicKey.from_digest(key, sha2_128s)
+ if not pq_key.verify(digest, self.unauth.slh_signatures[sig_idx]):
raise firmware.InvalidSignatureError("Invalid bootloader signature")
+ sig_idx += 1
class LegacyFirmware(firmware.LegacyFirmware):
@@ -528,7 +562,7 @@ class LegacyFirmware(firmware.LegacyFirmware):
else:
embedded_content = ""
- return _format_container(contents) + embedded_content
+ return format_container(contents) + embedded_content
def public_keys(
self, dev_keys: bool = False, signature_version: int = 2
@@ -567,7 +601,7 @@ class LegacyV2Firmware(firmware.LegacyV2Firmware):
self.header,
self.code_hashes(),
self.digest(),
- _check_signature_any(self),
+ check_signature_any(self),
)
def public_keys(
Why this scored 32/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.