chore(python): add sanity check to `Struct`'s parsing
What changed, and why it matters
This commit adds a new optional sanity-check layer to the Python code that parses Trezor firmware files. It verifies that re-encoding a parsed firmware image produces the exact same bytes, and that any 'reserved' fields are all zeroes. The change is defensive: it can help detect malformed or tampered firmware images during analysis, but the strict mode is off by default, so existing behavior is preserved unless a caller explicitly opts in.
Treat this as a hardening/defensive patch rather than an active vulnerability fix. Review whether callers that validate or install firmware should enable `strict=True` to benefit from the new checks. No urgent user action is required, but downstream tools may want to expose the strict option.
Security signals we found
Adds round-trip integrity check (parse-then-build byte equality) for firmware images
Adds zero-byte validation for reserved/padding fields
Replaces anonymous padding with named Reserved fields so they can be sanity-checked
Strict checking is disabled by default, preserving backward-compatible parsing behavior
No device-side or embedded firmware changes; change is limited to host-side Python tooling
Evidence from the diff
The patch introduces SanityCheckedStruct, a subclass of construct_classes.Struct, plus a Reserved construct helper. Several firmware header/image classes in trezorlib/firmware are switched from Struct to SanityCheckedStruct, replacing anonymous c.Padding fields with named Reserved fields. A new parse(..., strict=False) method runs two checks after parsing: (1) build() reproduces the original bytes, and (2) every Reserved field contains only zero bytes. Failures are warnings by default and only raise SanityCheckError when strict=True. parse_image in firmware_headers.py propagates the strict flag through all firmware parsers.
Changed components
python/src/trezorlib/firmware/sanity_struct.py (new)python/src/trezorlib/construct_helpers.pypython/src/trezorlib/firmware/core.pypython/src/trezorlib/firmware/legacy.pypython/src/trezorlib/firmware/vendor.pypython/src/trezorlib/firmware/secmon.pypython/src/trezorlib/_internal/firmware_headers.pypython/src/trezorlib/firmware/__init__.pyInspect captured patch +214 / −42
diff --git a/python/src/trezorlib/_internal/firmware_headers.py b/python/src/trezorlib/_internal/firmware_headers.py
index 2154191d..41209c9c 100644
--- a/python/src/trezorlib/_internal/firmware_headers.py
+++ b/python/src/trezorlib/_internal/firmware_headers.py
@@ -29,6 +29,7 @@ from typing_extensions import Protocol, Self, runtime_checkable
from .. import _ed25519, cosi, firmware
from ..firmware import models as fw_models
+from ..firmware.sanity_struct import STRICT_SANITY_CHECK_DEFAULT
SYM_OK = click.style("\u2714", fg="green")
SYM_FAIL = click.style("\u274c", fg="red")
@@ -621,34 +622,36 @@ class LegacyV2Firmware(firmware.LegacyV2Firmware):
return self.header.v1_key_indexes
-def parse_image(image: bytes) -> SignableImageProto:
+def parse_image(
+ image: bytes, strict: bool = STRICT_SANITY_CHECK_DEFAULT
+) -> SignableImageProto:
try:
- return VendorFirmware.parse(image)
+ return VendorFirmware.parse(image, strict=strict)
except c.ConstructError:
pass
try:
- return VendorHeader.parse(image)
+ return VendorHeader.parse(image, strict=strict)
except c.ConstructError:
pass
try:
- return SecmonImage.parse(image)
+ return SecmonImage.parse(image, strict=strict)
except c.ConstructError:
pass
try:
- firmware_img = firmware.core.FirmwareImage.parse(image)
+ firmware_img = firmware.core.FirmwareImage.parse(image, strict=strict)
if firmware_img.header.magic == firmware.core.HeaderType.BOOTLOADER:
- return BootloaderImage.parse(image)
+ return BootloaderImage.parse(image, strict=strict)
if firmware_img.header.magic == firmware.core.HeaderType.FIRMWARE:
- return LegacyV2Firmware.parse(image)
+ return LegacyV2Firmware.parse(image, strict=strict)
raise ValueError("Unrecognized firmware header magic")
except c.ConstructError:
pass
try:
- return LegacyFirmware.parse(image)
+ return LegacyFirmware.parse(image, strict=strict)
except c.ConstructError:
pass
diff --git a/python/src/trezorlib/construct_helpers.py b/python/src/trezorlib/construct_helpers.py
index f4e166ad..fb5a769b 100644
--- a/python/src/trezorlib/construct_helpers.py
+++ b/python/src/trezorlib/construct_helpers.py
@@ -48,3 +48,9 @@ class TupleAdapter(construct.Adapter):
def _decode(self, obj: t.Any, context: t.Any, path: t.Any) -> t.Any:
return tuple(obj)
+
+
+class Reserved(construct.Default):
+ def __init__(self, length: t.Any) -> None:
+ subcon = construct.Bytes(length)
+ super().__init__(subcon, b"\x00" * length)
diff --git a/python/src/trezorlib/firmware/__init__.py b/python/src/trezorlib/firmware/__init__.py
index 81f476c3..c95c0782 100644
--- a/python/src/trezorlib/firmware/__init__.py
+++ b/python/src/trezorlib/firmware/__init__.py
@@ -32,6 +32,7 @@ if True:
from .consts import * # noqa: F401, F403
from .core import * # noqa: F401, F403
from .legacy import * # noqa: F401, F403
+ from .sanity_struct import * # noqa: F401, F403
from .secmon import * # noqa: F401, F403
from .util import ( # noqa: F401
FirmwareIntegrityError,
diff --git a/python/src/trezorlib/firmware/core.py b/python/src/trezorlib/firmware/core.py
index 81125f8c..67033928 100644
--- a/python/src/trezorlib/firmware/core.py
+++ b/python/src/trezorlib/firmware/core.py
@@ -22,12 +22,13 @@ from copy import copy
from enum import Enum
import construct as c
-from construct_classes import Struct, subcon
+from construct_classes import subcon
from .. import cosi, merkle_tree
-from ..construct_helpers import EnumAdapter, TupleAdapter
+from ..construct_helpers import EnumAdapter, Reserved, TupleAdapter
from . import consts, models, util
from .models import Model
+from .sanity_struct import SanityCheckedStruct
from .vendor import VendorHeader
__all__ = [
@@ -47,7 +48,7 @@ class HeaderType(Enum):
NRF_FIRMWARE = bytes.fromhex("3DB8F396")
-class FirmwareHeader(Struct):
+class FirmwareHeader(SanityCheckedStruct):
magic: HeaderType
header_len: int
expiry: int
@@ -57,11 +58,13 @@ class FirmwareHeader(Struct):
hw_model: Model | bytes
hw_revision: int
monotonic: int
+ reserved_0: bytes
hashes: list[bytes]
v1_signatures: list[bytes]
v1_key_indexes: list[int]
+ reserved_1: bytes
sigmask: int
signature: bytes
@@ -82,13 +85,13 @@ class FirmwareHeader(Struct):
"hw_model" / EnumAdapter(c.Bytes(4), Model),
"hw_revision" / c.Int8ul,
"monotonic" / c.Int8ul,
- "_reserved" / c.Padding(2),
+ "reserved_0" / Reserved(2),
"hashes" / c.Bytes(32)[16],
"v1_signatures" / c.Bytes(64)[consts.V1_SIGNATURE_SLOTS],
"v1_key_indexes" / c.Int8ul[consts.V1_SIGNATURE_SLOTS], # pylint: disable=E1136
- "_reserved" / c.Padding(220),
+ "reserved_1" / Reserved(220),
"sigmask" / c.Byte,
"signature" / c.Bytes(64),
@@ -105,7 +108,7 @@ class FirmwareHeader(Struct):
# fmt: on
-class FirmwareImage(Struct):
+class FirmwareImage(SanityCheckedStruct):
"""Raw firmware image.
Consists of firmware header and code block.
@@ -177,7 +180,7 @@ class FirmwareImage(Struct):
return None
-class VendorFirmware(Struct):
+class VendorFirmware(SanityCheckedStruct):
"""Firmware image prefixed by a vendor header.
This is the expected format of firmware binaries for Trezor core models."""
@@ -219,7 +222,7 @@ class VendorFirmware(Struct):
return self.firmware.model()
-class BootHeader(Struct):
+class BootHeader(SanityCheckedStruct):
magic: HeaderType
hw_model: Model | bytes
hw_revision: int
@@ -227,6 +230,8 @@ class BootHeader(Struct):
fix_version: tuple[int, int, int, int]
min_prev_version: tuple[int, int, int, int]
monotonic: int
+ sigmask: int
+ reserved: bytes
auth_len: int
header_len: int
code_length: int
@@ -235,6 +240,7 @@ class BootHeader(Struct):
firmware_root: bytes
_pre_padding_len: int
+ padding: bytes
_post_padding_len: int
# fmt: off
@@ -249,7 +255,7 @@ class BootHeader(Struct):
"min_prev_version" / TupleAdapter(c.Int8ul, c.Int8ul, c.Int8ul, c.Int8ul),
"monotonic" / c.Int8ul,
"sigmask" / c.Int8ul,
- "_reserved" / c.Padding(2),
+ "reserved" / Reserved(2),
"header_len" / c.Int32ul,
"auth_len" / c.Int32ul,
"code_length" / c.Rebuild(
@@ -263,13 +269,13 @@ class BootHeader(Struct):
# Variable-length padding that's part of the authenticated header
"_pre_padding_len" / c.Tell,
- "_padding" / c.Padding(c.this.auth_len - c.this._pre_padding_len),
+ "padding" / Reserved(c.this.auth_len - c.this._pre_padding_len),
"_post_padding_len" / c.Tell,
)
# fmt: on
-class BootHeaderUnauth(Struct):
+class BootHeaderUnauth(SanityCheckedStruct):
"""Unauthenticated part of the boot header."""
merkle_proof: list[bytes]
@@ -292,7 +298,7 @@ class BootHeaderUnauth(Struct):
# fmt: on
-class BootableImage(Struct):
+class BootableImage(SanityCheckedStruct):
"""Raw firmware image.
Consists of boot header and code block.
diff --git a/python/src/trezorlib/firmware/legacy.py b/python/src/trezorlib/firmware/legacy.py
index e03f591e..a975d49e 100644
--- a/python/src/trezorlib/firmware/legacy.py
+++ b/python/src/trezorlib/firmware/legacy.py
@@ -21,14 +21,16 @@ import typing as t
from dataclasses import field
import construct as c
-from construct_classes import Struct, subcon
+from construct_classes import subcon
from cryptography import exceptions as crypto_exceptions
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec, utils
+from ..construct_helpers import Reserved
from . import consts, models, util
from .core import FirmwareImage
from .models import Model
+from .sanity_struct import SanityCheckedStruct
__all__ = [
"LegacyFirmware",
@@ -169,7 +171,7 @@ class LegacyV2Firmware(FirmwareImage):
raise util.InvalidSignatureError("Firmware is not unsigned.")
-class LegacyFirmware(Struct):
+class LegacyFirmware(SanityCheckedStruct):
"""Legacy firmware image.
Consists of a custom header and code block.
This is the expected format of firmware binaries for Trezor One pre-1.8.0.
@@ -178,7 +180,10 @@ class LegacyFirmware(Struct):
expected format of firmware binary for Trezor One version 1.8.0, which can be installed
by both the older and the newer bootloader."""
+ magic: bytes
+ code_length: int
key_indexes: list[int]
+ reserved: bytes
signatures: list[bytes]
code: bytes
flags: dict[str, t.Any] = field(default_factory=dict)
@@ -190,10 +195,10 @@ class LegacyFirmware(Struct):
"code_length" / c.Rebuild(c.Int32ul, c.len_(c.this.code)),
"key_indexes" / c.Int8ul[consts.V1_SIGNATURE_SLOTS], # pylint: disable=E1136
"flags" / c.BitStruct(
- c.Padding(7),
+ "reserved" / c.BitsInteger(7),
"restore_storage" / c.Flag,
),
- "_reserved" / c.Padding(52),
+ "reserved" / Reserved(52),
"signatures" / c.Bytes(64)[consts.V1_SIGNATURE_SLOTS],
"code" / c.Bytes(c.this.code_length),
c.Terminated,
diff --git a/python/src/trezorlib/firmware/sanity_struct.py b/python/src/trezorlib/firmware/sanity_struct.py
new file mode 100644
index 00000000..55e5c401
--- /dev/null
+++ b/python/src/trezorlib/firmware/sanity_struct.py
@@ -0,0 +1,140 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+from __future__ import annotations
+
+import logging
+import typing as t
+
+from construct import Transformed
+from construct_classes import Struct
+
+from ..construct_helpers import Reserved
+
+LOG = logging.getLogger(__name__)
+
+STRICT_SANITY_CHECK_DEFAULT: bool = False
+
+# workaround for mypy self type bug
+Self = t.TypeVar("Self", bound="SanityCheckedStruct")
+
+
+class SanityCheckError(Exception):
+ def __init__(
+ self, errors: list[str], image: SanityCheckedStruct, *args: t.Any
+ ) -> None:
+ self.errors = errors
+ self.image = image
+ super().__init__(*args)
+
+ def get_error_message(self) -> str:
+ return f"\033[1;31mERROR:\033[0m Sanity check failed!\n{self._get_formatted_message()}\n"
+
+ def get_warning_message(self) -> str:
+ return f"\033[1;33mWARNING:\033[0m Sanity check failed!\n{self._get_formatted_message()}\n"
+
+ def _get_formatted_message(self) -> str:
+ return "\n".join(f" - {err}" for err in self.errors)
+
+ def __str__(self) -> str:
+ return f"Sanity check failed!\n{self._get_formatted_message()}"
+
+
+class SanityCheckedStruct(Struct):
+
+ @classmethod
+ def parse(
+ cls: t.Type[Self], data: bytes, *, strict: bool = STRICT_SANITY_CHECK_DEFAULT
+ ) -> Self:
+ parsed_image = super().parse(data)
+ try:
+ parsed_image.sanity_check(data)
+ except SanityCheckError as e:
+ if strict:
+ raise
+ LOG.warning(e.get_warning_message())
+ return parsed_image
+
+ def sanity_check(self, image: bytes, errors: t.Sequence[str] = ()) -> None:
+ """Sanity check
+
+ - Parsing and rebuilding does not modify the image bytes.
+ - Reserved fields are parsed correctly and contain only zeroes.
+ """
+
+ _errors: list[str] = list(errors)
+ is_ok = True
+
+ # Parsing and rebuilding does not modify the image bytes
+ rebuilt_image = self.build()
+ if image != rebuilt_image:
+ _errors.append('"Parsing and rebuilding image" sanity check failed.')
+ is_ok = False
+
+ is_ok = is_ok and self._subcons_sanity_check(_errors)
+
+ if not is_ok:
+ raise SanityCheckError(_errors, self)
+
+ def _subcons_sanity_check(self, errors: list[str]) -> bool:
+ try:
+ subcon = self.SUBCON
+
+ # VendorTrust is wrapped multiple times in `Transformed`
+ while isinstance(subcon, Transformed):
+ subcon = subcon.subcon
+
+ subcon_fields: t.ItemsView[str, t.Any] = subcon._subcons.items()
+ except Exception as e:
+ errors.append(f"Failed to parse subcon fields. {e}")
+ return False
+
+ is_ok = True
+ for name, value in subcon_fields:
+
+ # Skip private fields
+ if name[0] == "_":
+ continue
+
+ # Public fields should be present in the class
+ try:
+ field_data = getattr(self, name)
+ except AttributeError:
+ errors.append(f"Missing subcon field: \033[1m{name}\033[0m")
+ is_ok = False
+ continue
+
+ # Check that `Reserved` fields are all zeroes.
+ # Extraction to `inner` is necessary because `value` is internally packed into `construct.core.Renamed`
+ # and `isinstance(value, Reserved)` returns False even for `Reserved` fields.
+ inner = value.subcon if hasattr(value, "subcon") else value
+ if isinstance(inner, Reserved):
+ if not all(v == 0 for v in field_data):
+ try:
+ value_str = field_data.hex()
+ except Exception:
+ value_str = str(field_data)
+ errors.append(
+ f"Reserved field \033[1m{name}\033[0m is not zero: {value_str}"
+ )
+ is_ok = False
+ continue
+
+ # Recursive check
+ if isinstance(field_data, SanityCheckedStruct):
+ is_ok = is_ok and field_data._subcons_sanity_check(errors)
+
+ return is_ok
diff --git a/python/src/trezorlib/firmware/secmon.py b/python/src/trezorlib/firmware/secmon.py
index ca02fcb7..c8fd51d1 100644
--- a/python/src/trezorlib/firmware/secmon.py
+++ b/python/src/trezorlib/firmware/secmon.py
@@ -19,11 +19,12 @@ from __future__ import annotations
from copy import copy
import construct as c
-from construct_classes import Struct, subcon
+from construct_classes import subcon
-from ..construct_helpers import EnumAdapter, TupleAdapter
+from ..construct_helpers import EnumAdapter, Reserved, TupleAdapter
from . import util
from .models import Model
+from .sanity_struct import SanityCheckedStruct
__all__ = [
"SecmonHeader",
@@ -31,32 +32,34 @@ __all__ = [
]
-class SecmonHeader(Struct):
+class SecmonHeader(SanityCheckedStruct):
header_len: int
code_length: int
version: tuple[int, int, int, int]
hw_model: Model | bytes
hw_revision: int
monotonic: int
+ reserved_0: bytes
hash: bytes
+ reserved_1: bytes
sigmask: int
signature: bytes
# fmt: off
SUBCON = c.Struct(
"_start_offset" / c.Tell,
- "magic" / c.Const(b"TSEC"),
+ "_magic" / c.Const(b"TSEC"),
"header_len" / c.Int32ul,
"code_length" / c.Int32ul,
"version" / TupleAdapter(c.Int8ul, c.Int8ul, c.Int8ul, c.Int8ul),
"hw_model" / EnumAdapter(c.Bytes(4), Model),
"hw_revision" / c.Int8ul,
"monotonic" / c.Int8ul,
- "_reserved" / c.Padding(2),
+ "reserved_0" / Reserved(2),
"hash" / c.Bytes(32),
- "_reserved" / c.Padding(391),
+ "reserved_1" / Reserved(391),
"sigmask" / c.Byte,
"signature" / c.Bytes(64),
@@ -70,7 +73,7 @@ class SecmonHeader(Struct):
# fmt: on
-class SecmonImage(Struct):
+class SecmonImage(SanityCheckedStruct):
"""Raw secmon image.
Consists of secmon header and code block.
diff --git a/python/src/trezorlib/firmware/vendor.py b/python/src/trezorlib/firmware/vendor.py
index 202e99ab..ada5abb3 100644
--- a/python/src/trezorlib/firmware/vendor.py
+++ b/python/src/trezorlib/firmware/vendor.py
@@ -21,19 +21,23 @@ import typing as t
from copy import copy
import construct as c
-from construct_classes import Struct, subcon
+from construct_classes import subcon
from .. import cosi
-from ..construct_helpers import EnumAdapter, TupleAdapter
+from ..construct_helpers import EnumAdapter, Reserved, TupleAdapter
from ..toif import ToifStruct
from . import util
from .models import Model
+from .sanity_struct import SanityCheckedStruct
__all__ = [
"VendorTrust",
"VendorHeader",
]
+if t.TYPE_CHECKING:
+ from . import HeaderType
+
def _transform_vendor_trust(data: bytes) -> bytes:
"""Byte-swap and bit-invert the VendorTrust field.
@@ -48,7 +52,7 @@ def _transform_vendor_trust(data: bytes) -> bytes:
return bytes(~b & 0xFF for b in data)[::-1]
-class VendorTrust(Struct):
+class VendorTrust(SanityCheckedStruct):
limit_runtime: bool
deny_provisioning_access: bool
_dont_provide_secret: bool
@@ -58,11 +62,11 @@ class VendorTrust(Struct):
red_background: bool
delay: int
- _reserved: int = 0
+ reserved: int = 0
SUBCON = c.Transformed(
c.BitStruct(
- "_reserved" / c.Default(c.BitsInteger(5), 0b11111),
+ "reserved" / c.Default(c.BitsInteger(5), 0b11111),
"limit_runtime" / c.Default(c.Flag, 1),
"deny_provisioning_access" / c.Default(c.Flag, 1),
"_dont_provide_secret"
@@ -88,17 +92,21 @@ class VendorTrust(Struct):
)
-class VendorHeader(Struct):
+class VendorHeader(SanityCheckedStruct):
+ magic: HeaderType
header_len: int
expiry: int
version: tuple[int, int]
sig_m: int
- # sig_n: int
+ # _sig_n: int
hw_model: Model | bytes
fw_type: int
+ reserved_0: bytes
pubkeys: list[bytes]
text: str
image: dict[str, t.Any]
+
+ reserved_1: bytes
sigmask: int
signature: bytes
@@ -112,12 +120,12 @@ class VendorHeader(Struct):
"expiry" / c.Int32ul,
"version" / TupleAdapter(c.Int8ul, c.Int8ul),
"sig_m" / c.Int8ul,
- "sig_n" / c.Rebuild(c.Int8ul, c.len_(c.this.pubkeys)),
+ "_sig_n" / c.Rebuild(c.Int8ul, c.len_(c.this.pubkeys)),
"trust" / VendorTrust.SUBCON,
"hw_model" / EnumAdapter(c.Bytes(4), Model),
"fw_type" / c.Int8ul,
- "_reserved" / c.Padding(9),
- "pubkeys" / c.Bytes(32)[c.this.sig_n],
+ "reserved_0" / Reserved(9),
+ "pubkeys" / c.Bytes(32)[c.this._sig_n],
"text" / c.Aligned(4, c.PascalString(c.Int8ul, "utf-8")),
"image" / ToifStruct,
"_end_offset" / c.Tell,
@@ -125,7 +133,7 @@ class VendorHeader(Struct):
"_min_header_len" / c.Check(c.this.header_len > (c.this._end_offset - c.this._start_offset) + 65),
"_header_len_aligned" / c.Check(c.this.header_len % 512 == 0),
- c.Padding(c.this.header_len - c.this._end_offset + c.this._start_offset - 65),
+ "reserved_1" / Reserved(c.this.header_len - c.this._end_offset + c.this._start_offset - 65),
"sigmask" / c.Byte,
"signature" / c.Bytes(64),
)
Why this scored 35/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.