feat(tools): script to verify signed firmwares
What changed, and why it matters
This commit adds a new helper script that lets developers check whether a signed Trezor firmware file truly matches its unsigned counterpart and whether its digital signature is valid. It is a defensive verification tool, not a fix for a security flaw, and introduces no vulnerability in the device code itself.
No security action required. This is a defensive tooling addition. Reviewers may optionally inspect the normalization logic to ensure all signature-carrying fields are correctly zeroed for each supported image format.
Security signals we found
Adds a defensive verification tool for signed firmware binaries
Compares unsigned and signed images after zeroing signature fields to detect unauthorized modifications
Verifies signatures against production keys using existing trezorlib code
Supports TRZV, TSEC, TRZB, and TRZQ image formats
No changes to bootloader, firmware, or cryptographic runtime code
Evidence from the diff
The commit introduces core/tools/trezor_core_tools/verify_signed_firmware.py, a CLI utility that compares an unsigned bootloader/secmon/firmware binary with the signed binary returned by the signing service. It normalizes both images by zeroing signature-carrying fields, rebuilds them, and checks for byte equality; it then verifies the signature against production keys using existing trezorlib parsers. The tool is registered in pyproject.toml, documented in core/tools/README.md, and referenced in docs/common/reproducible-build.md as an optional alternative to the manual zero-signature hash comparison in the release checklist.
Changed components
core/tools/trezor_core_tools/verify_signed_firmware.pycore/tools/pyproject.tomlcore/tools/README.mddocs/common/reproducible-build.mdInspect captured patch +275 / −0
diff --git a/core/tools/README.md b/core/tools/README.md
index dbb99881..7a864578 100644
--- a/core/tools/README.md
+++ b/core/tools/README.md
@@ -35,6 +35,20 @@ Generate a vendor header binary from a json description.
Combine a flashable image from a boardloader, bootloader, and firmware.
+### `verify_signed_firmware`
+
+Lives in `trezor_core_tools/verify_signed_firmware.py` and is exposed as a CLI tool by
+the same name.
+
+Given an unsigned image and the signed one returned from the signing service, it
+confirms the two are identical except in the signature-carrying fields and that the
+signature verifies against the production keys. It auto-detects the image type, so it
+handles firmware (`TRZV`), secmon (`TSEC`), and bootloader formats (`TRZB`, `TRZQ`).
+
+Use `verify_signed_firmware --help` for the full usage. The most common form is
+`verify_signed_firmware unsigned.bin signed.bin`; with no arguments it scans the
+current directory for `*-signed.bin` pairs.
+
## Everything else
### `codegen`
diff --git a/core/tools/pyproject.toml b/core/tools/pyproject.toml
index c61dd293..2bacbd78 100644
--- a/core/tools/pyproject.toml
+++ b/core/tools/pyproject.toml
@@ -17,6 +17,7 @@ lsgen = "trezor_core_tools.lsgen:main"
hash_signer = "trezor_core_tools.hash_signer:main"
combine_firmware = "trezor_core_tools.combine_firmware:main"
bootloader_hashes = "trezor_core_tools.bootloader_hashes:main"
+verify_signed_firmware = "trezor_core_tools.verify_signed_firmware:main"
[build-system]
requires = ["flit_core >= 3.11,<5"]
diff --git a/core/tools/trezor_core_tools/verify_signed_firmware.py b/core/tools/trezor_core_tools/verify_signed_firmware.py
new file mode 100755
index 00000000..52feb7cf
--- /dev/null
+++ b/core/tools/trezor_core_tools/verify_signed_firmware.py
@@ -0,0 +1,255 @@
+#!/usr/bin/env python3
+"""Verify that a SIGNED firmware/secmon/bootloader binary matches the UNSIGNED one.
+
+Two things are checked for every pair:
+
+\b
+ 1. Equivalence -- the signed binary is identical to the one you sent EXCEPT in
+ the signature-carrying fields. We parse both images, zero those fields,
+ rebuild, and compare. This covers the vendor header, the firmware/boot
+ header, and the entire code body -- everything signing is NOT allowed to
+ touch.
+
+\b
+ 2. Authenticity -- the signature on the signed file is cryptographically valid
+ against the PRODUCTION keys (mandatory, not optional). This also
+ re-validates the code hashes / Merkle root.
+
+\b
+Supported image types (auto-detected from the 4-byte magic):
+ TRZV firmware (vendor header + firmware header + code) -> all current models
+ TSEC secmon
+ TRZB legacy bootloader (CoSi-signed firmware image)
+ TRZQ PQ bootloader (SLH-DSA + ed25519 signatures) -> Safe 7
+
+\b
+Usage:
+ verify_signed_firmware.py UNSIGNED.bin SIGNED.bin # explicit pair
+ verify_signed_firmware.py SIGNED.bin # derive UNSIGNED by dropping "-signed"
+ verify_signed_firmware.py # scan ./ for *-signed.bin pairs
+
+Exit code: 0 only if EVERY pair both matches and is genuinely signed; 1 otherwise.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import sys
+from pathlib import Path
+
+import click
+
+from trezorlib._internal import firmware_headers as fh
+from trezorlib.firmware import FirmwareHeader, SecmonHeader
+from trezorlib.firmware.core import FirmwareImage
+
+
+# ---- pretty output -------------------------------------------------------
+def ok(msg: str) -> None:
+ click.echo(" " + click.style("✔", fg="green") + " " + msg)
+
+
+def bad(msg: str) -> None:
+ click.echo(" " + click.style("✘", fg="red") + " " + msg)
+
+
+def info(msg: str) -> None:
+ click.echo(" " + click.style("•", fg="yellow") + " " + msg)
+
+
+# ---- trezorlib glue ------------------------------------------------------
+def parse_any(data: bytes) -> fh.SignableImageProto | fh.BootloaderV2Image:
+ """Parse any supported image. parse_image() doesn't dispatch TRZQ, so do it here."""
+ if data[:4] == b"TRZQ":
+ return fh.BootloaderV2Image.parse(data)
+ return fh.parse_image(data)
+
+
+def _zero_cosi_header(h: FirmwareHeader | SecmonHeader) -> None:
+ """Zero the signature fields of a CoSi firmware/secmon/bootloader header."""
+ h.signature = b"\x00" * 64
+ h.sigmask = 0
+ if not isinstance(h, SecmonHeader):
+ h.v1_signatures = [b"\x00" * 64] * len(h.v1_signatures)
+ h.v1_key_indexes = [0] * len(h.v1_key_indexes)
+
+
+def normalized(data: bytes) -> bytes:
+ """Rebuild the image with all signature-carrying fields zeroed.
+
+ Two images that are identical apart from their signatures produce identical
+ normalized bytes. build() round-trips faithfully for unmodified images, so the
+ only bytes this changes are the signature fields themselves.
+ """
+ img = parse_any(data)
+ if isinstance(img, fh.VendorFirmware): # TRZV
+ _zero_cosi_header(img.firmware.header)
+ elif isinstance(img, fh.BootloaderV2Image): # TRZQ
+ img.header.sigmask = 0
+ img.unauth.slh_signatures = [
+ b"\x00" * len(x) for x in img.unauth.slh_signatures
+ ]
+ img.unauth.ec_signatures = [b"\x00" * len(x) for x in img.unauth.ec_signatures]
+ elif isinstance(img, fh.SecmonImage | fh.BootloaderImage | FirmwareImage):
+ # TSEC | TRZB | bare FirmwareImage
+ _zero_cosi_header(img.header)
+ else:
+ raise TypeError(f"don't know how to normalize image type {type(img).__name__}")
+ return img.build()
+
+
+def _runs(offsets: list[int]) -> int:
+ """Count contiguous runs in a sorted list of byte offsets."""
+ runs = 0
+ prev = None
+ for o in offsets:
+ if prev is None or o != prev + 1:
+ runs += 1
+ prev = o
+ return runs
+
+
+# ---- core verification of one (unsigned, signed) pair --------------------
+def verify_pair(unsigned: Path, signed: Path) -> bool:
+ click.echo()
+ click.echo(click.style(f"== {signed.name}", bold=True))
+
+ for f in (unsigned, signed):
+ if not f.is_file():
+ bad(f"missing file: {f}")
+ return False
+
+ if unsigned.samefile(signed):
+ bad("both arguments are the SAME file")
+ return False
+
+ u = unsigned.read_bytes()
+ s = signed.read_bytes()
+
+ if len(u) != len(s):
+ bad(f"size mismatch: unsigned={len(u)} signed={len(s)} -> NOT the same build")
+ return False
+
+ try:
+ img = parse_any(s)
+ except Exception as e: # noqa: BLE001
+ bad(f"could not parse signed image (magic={s[:4]!r}): {type(e).__name__}: {e}")
+ return False
+ info(
+ f"type: {getattr(img, 'NAME', '?')} ({s[:4].decode('ascii', 'replace')}) size: {len(s)} B"
+ )
+
+ okall = True
+
+ # ---- CHECK 1 (decisive): identical except the signature fields ---------
+ try:
+ nu, ns = normalized(u), normalized(s)
+ except Exception as e: # noqa: BLE001
+ bad(f"could not normalize for comparison: {type(e).__name__}: {e}")
+ return False
+
+ if nu == ns:
+ digest = hashlib.sha256(ns).hexdigest()
+ ok(f"identical except signature fields, normalized sha256: {digest}")
+ else:
+ bad(
+ "content differs OUTSIDE the signature fields -- signed binary does NOT match!"
+ )
+ if len(nu) != len(ns):
+ info(f" normalized sizes differ: {len(nu)} vs {len(ns)}")
+ nd = [i for i in range(min(len(nu), len(ns))) if nu[i] != ns[i]]
+ if nd:
+ info(
+ f" first content difference at byte {nd[0]} ({len(nd)} content bytes differ)"
+ )
+ okall = False
+
+ # ---- CHECK 2 (diagnostic): what actually changed -----------------------
+ diffs = [i for i in range(len(u)) if u[i] != s[i]]
+ if not diffs:
+ bad(
+ "files are byte-for-byte IDENTICAL -- nothing was (re)signed; a genuine "
+ "signing changes the signature fields. Wrong files?"
+ )
+ okall = False
+ else:
+ confined = " [all within signature fields]" if nu == ns else ""
+ info(f"{len(diffs)} bytes differ across {_runs(diffs)} run(s){confined}")
+
+ # ---- CHECK 3: signature authenticity -----------------------------------
+ try:
+ if not img.signature_present():
+ bad("no signature present in the signed file")
+ okall = False
+ else:
+ img.verify() # production keys; raises on bad signature or bad hashes
+ ok(
+ f"signature is GENUINE -- verified against production keys ({getattr(img, 'NAME', 'image')})"
+ )
+ except Exception as e: # noqa: BLE001
+ bad(
+ f"signature verification FAILED against production keys: {type(e).__name__}: {e}"
+ )
+ okall = False
+
+ return okall
+
+
+# ---- argument handling ---------------------------------------------------
+def collect_pairs(files: list[Path]) -> list[tuple[Path, Path]]:
+ if len(files) == 2:
+ return [(files[0], files[1])]
+
+ if len(files) == 1:
+ signed = files[0]
+ if not signed.name.endswith("-signed.bin"):
+ raise click.UsageError("Single-argument form expects a *-signed.bin file.")
+ return [(signed.with_name(signed.name.replace("-signed.bin", ".bin")), signed)]
+
+ if len(files) == 0:
+ pairs = [
+ (signed.with_name(signed.name.replace("-signed.bin", ".bin")), signed)
+ for signed in sorted(Path.cwd().glob("*-signed.bin"))
+ ]
+ if not pairs:
+ raise click.UsageError(
+ f"No *-signed.bin files in {Path.cwd()}. Pass files explicitly; see --help."
+ )
+ return pairs
+
+ raise click.UsageError("Too many arguments (expected 0, 1, or 2). See --help.")
+
+
+@click.command(
+ context_settings={"help_option_names": ["-h", "--help"]},
+ help=__doc__,
+)
+@click.argument(
+ "files",
+ nargs=-1,
+ type=click.Path(path_type=Path),
+)
+def main(files: tuple[Path, ...]) -> None:
+ pairs = collect_pairs(list(files))
+
+ # Evaluate every pair (do not short-circuit) so each gets reported.
+ results = [verify_pair(unsigned, signed) for unsigned, signed in pairs]
+ all_ok = all(results)
+
+ click.echo()
+ if all_ok:
+ click.echo(
+ click.style(
+ "ALL PAIRS VERIFIED -- signed binaries match the unsigned ones "
+ "(signature fields only) and are genuinely signed.",
+ fg="green",
+ bold=True,
+ )
+ )
+ return
+ click.echo(click.style("VERIFICATION FAILED -- see above.", fg="red", bold=True))
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/common/reproducible-build.md b/docs/common/reproducible-build.md
index af4b4c0c..443cc88e 100644
--- a/docs/common/reproducible-build.md
+++ b/docs/common/reproducible-build.md
@@ -54,6 +54,11 @@ bash build-docker.sh --models=T3T1 core/v2.8.3 # build only for Trezor Safe 5
The result won't be bit-by-bit identical with the official images because the
official images are signed while local builds aren't.
+The manual steps below zero out the signature and compare hashes, which keeps the
+verification dependent only on standard tools. Trezor developers can alternatively use
+the `verify_signed_firmware` tool (in `core/tools`) to automate this zero-signature
+comparison across all image formats.
+
### Trezor T and the Safe family
You can use `trezorctl` to download the official firmware image for your device:
Why this scored 15/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.