feat: Add master fingerprint calculation.
What changed, and why it matters
This commit adds a new build-time tooling feature for Trezor firmware: it calculates a single 'master fingerprint' that summarizes all the individual firmware image fingerprints produced during a reproducible build. It also improves the existing fingerprint tool so each fingerprint is labeled with the device model and image type (bootloader, firmware, etc.). There is no indication this change fixes a security bug or introduces a vulnerability; it is a transparency/reproducibility improvement.
No security action required. Treat as a normal feature/reproducibility improvement. Reviewers may optionally verify that the canonical sorting and hashing logic matches the intended SLIP-26 specification and that CI artifact permissions are correct.
Security signals we found
No memory-unsafe code added
No cryptographic primitives implemented; uses standard hashlib.sha256
No privilege escalation, authentication bypass, or secret exposure observed
Build scripts modified to aggregate and display reproducible-build fingerprints
New parsing code validates input length and format and raises ValueError on malformed data
Evidence from the diff
The patch introduces python/tools/master-fingerprint.py and supporting library code (trezorlib/internal/master_fingerprint.py, slip26.py) that parses labeled per-artifact SHA-256 fingerprints and computes an aggregate SHA-256 over them in canonical order. It updates python/tools/firmware-fingerprint.py to emit ‘
Changed components
python/tools/firmware-fingerprint.pypython/tools/master-fingerprint.pypython/src/trezorlib/_internal/master_fingerprint.pypython/src/trezorlib/_internal/slip26.pypython/tests/test_master_fingerprint.pybuild-docker.sh.github/workflows/common.ymlInspect captured patch +439 / −57
diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml
index 3d75cac0..663031e0 100644
--- a/.github/workflows/common.yml
+++ b/.github/workflows/common.yml
@@ -143,10 +143,19 @@ jobs:
- name: Show fingerprints
run: |
tree _artifacts
- for file in _artifacts/*/*/*.fingerprint
- do
- echo "\`$(tr -d '\n' < $file)\` ${file%.fingerprint}" >> $GITHUB_STEP_SUMMARY
- done
+ COMMIT_HASH=$(git rev-parse HEAD)
+ fingerprints_file="_artifacts/${COMMIT_HASH}.fingerprints"
+ master_file="_artifacts/${COMMIT_HASH}.master"
+ if [ -f "$fingerprints_file" ]; then
+ {
+ echo '```'
+ cat "$fingerprints_file"
+ if [ -f "$master_file" ]; then
+ cat "$master_file"
+ fi
+ echo '```'
+ } >> $GITHUB_STEP_SUMMARY
+ fi
cat $GITHUB_STEP_SUMMARY
if: always()
diff --git a/build-docker.sh b/build-docker.sh
index 6953538e..83d27dab 100755
--- a/build-docker.sh
+++ b/build-docker.sh
@@ -218,7 +218,7 @@ fi # init
# append common part to script
cat <<EOF >> "$SCRIPT_NAME"
$GIT_CLEAN_REPO
- git submodule update --init --recursive
+ git submodule update --init --recursive --depth 1
uv sync --locked
cd core/embed/rust
cargo fetch
@@ -285,11 +285,16 @@ for TREZOR_MODEL in ${MODELS[@]}; do
rm -rf /build/*
uv run make clean vendor $MAKE_TARGETS QUIET_MODE=1
for item in bootloader secmon kernel firmware prodtest; do
- if [ -s build-xtask/artifacts/$TREZOR_MODEL/\$item.bin ]; then
- uv run ../python/tools/firmware-fingerprint.py \
- -o build-xtask/artifacts/$TREZOR_MODEL/\$item.bin.fingerprint \
- build-xtask/artifacts/$TREZOR_MODEL/\$item.bin \
- || echo "No fingerprint for build-xtask/artifacts/$TREZOR_MODEL/\$item.bin"
+ # Append the labeled fingerprint, preceded by '# <artifact name>'.
+ if [ "\$item" != kernel ] && [ -s build-xtask/artifacts/$TREZOR_MODEL/\$item.bin ]; then
+ src=\$(ls build-xtask/artifacts/pub/\$item-$TREZOR_MODEL*.bin 2>/dev/null | head -n1 || true)
+ src=\${src##*/}
+ {
+ echo "# core${DIRSUFFIX}/\$item/\${src:-\$item.bin}"
+ uv run ../python/tools/firmware-fingerprint.py \
+ build-xtask/artifacts/$TREZOR_MODEL/\$item.bin
+ echo
+ } >> /local/build/${COMMIT_HASH}.fingerprints
fi
if [ -f build-xtask/artifacts/$TREZOR_MODEL/\$item.elf ]; then
# copy only the artifacts to the build output directory
@@ -300,6 +305,7 @@ for TREZOR_MODEL in ${MODELS[@]}; do
fi
done
chown -R $USER:$GROUP /build
+ chown $USER:$GROUP /local/build/${COMMIT_HASH}.fingerprints 2>/dev/null || true
EOF
echo
@@ -456,10 +462,15 @@ if echo "${MODELS[@]}" | grep -q T1B1 ; then
cp firmware/trezor.bin build/firmware/firmware.bin
cp firmware/firmware*.bin build/firmware/ || true # ignore missing file as it will not be present in old tags
cp firmware/trezor.elf build/firmware/firmware.elf
- uv run ../python/tools/firmware-fingerprint.py \
- -o build/firmware/firmware.bin.fingerprint \
- build/firmware/firmware.bin
+ src=\$(ls build/firmware/firmware-T1B1*.bin 2>/dev/null | head -n1 || true)
+ src=\${src##*/}
+ {
+ echo "# legacy${DIRSUFFIX}/firmware/\${src:-firmware.bin}"
+ uv run ../python/tools/firmware-fingerprint.py build/firmware/firmware.bin
+ echo
+ } >> /local/build/${COMMIT_HASH}.fingerprints
chown -R $USER:$GROUP /build
+ chown $USER:$GROUP /local/build/${COMMIT_HASH}.fingerprints 2>/dev/null || true
EOF
echo
@@ -489,29 +500,37 @@ echo " docker rmi $SNAPSHOT_NAME"
echo
echo "Built from commit $COMMIT_HASH"
echo
-echo "Fingerprints:"
-
-# Display core and legacy fingerprints (if built)
-for VARIANT in core legacy; do
- for MODEL in ${MODELS[@]}; do
- for DIRSUFFIX in "" "-bitcoinonly" $DIRSUFFIX_OVERRIDE; do
- BUILD_DIR=build/${VARIANT}-${MODEL}${DIRSUFFIX}
- for file in $BUILD_DIR/*/*.fingerprint; do
- if [ -f "$file" ]; then
- origfile="${file%.fingerprint}"
- fingerprint=$(tr -d '\n' < $file)
- chunkified_fingerprint=$(echo "$fingerprint" | sed 's/.\{4\}/& /g')
- echo -e "\033[1m$chunkified_fingerprint\033[0m $origfile"
- fi
- done
- done
- done
-done
-# Display nRF fingerprints (if built)
+FINGERPRINTS_FILE="build/${COMMIT_HASH}.fingerprints"
+MASTER_FILE="build/${COMMIT_HASH}.master"
+if [ -f "$FINGERPRINTS_FILE" ]; then
+ echo "Fingerprints ($FINGERPRINTS_FILE):"
+ echo
+ cat "$FINGERPRINTS_FILE"
+ $DOCKER run \
+ --network=host \
+ --rm \
+ -v "$DIR:/local" \
+ --init \
+ "$SNAPSHOT_NAME" \
+ /nix/var/nix/profiles/default/bin/nix-shell --run \
+ "cd /reproducible-build/trezor-firmware \
+ && uv run python/tools/master-fingerprint.py /local/$FINGERPRINTS_FILE \
+ > /local/$MASTER_FILE \
+ && chown $USER:$GROUP /local/$MASTER_FILE" \
+ || { rm -f "$MASTER_FILE"; exit 1; }
+ cat "$MASTER_FILE"
+else
+ echo "(no core/legacy firmware images built)"
+fi
+
+# nRF fingerprints (if built) use a plain sha256 of the whole binary and are not
+# part of the labeled fingerprints file.
if [ "$OPT_BUILD_NRF" -eq 1 ]; then
NRF_BUILD_DIR=build/nrf
if [ -d "$NRF_BUILD_DIR" ]; then
+ echo
+ echo "nRF fingerprints:"
for file in $NRF_BUILD_DIR/firmware/*.fingerprint $NRF_BUILD_DIR/bootloader/*.fingerprint; do
if [ -f "$file" ]; then
origfile="${file%.fingerprint}"
diff --git a/python/.changelog.d/+master-fingerprint.added b/python/.changelog.d/+master-fingerprint.added
new file mode 100644
index 00000000..4f6f2fdb
--- /dev/null
+++ b/python/.changelog.d/+master-fingerprint.added
@@ -0,0 +1 @@
+Added master-fingerprint.py tool, which computes a master fingerprint over a set of labeled firmware fingerprints.
diff --git a/python/.changelog.d/+master-fingerprint.changed b/python/.changelog.d/+master-fingerprint.changed
new file mode 100644
index 00000000..e9053e3a
--- /dev/null
+++ b/python/.changelog.d/+master-fingerprint.changed
@@ -0,0 +1 @@
+firmware-fingerprint.py now labels the fingerprint with the image's SLIP-26 model and purpose ("<model>_<purpose>: HEX") and supports vendor headers and new-style PQ bootloader images.
diff --git a/python/src/trezorlib/_internal/master_fingerprint.py b/python/src/trezorlib/_internal/master_fingerprint.py
new file mode 100644
index 00000000..29d4a6bf
--- /dev/null
+++ b/python/src/trezorlib/_internal/master_fingerprint.py
@@ -0,0 +1,75 @@
+# 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 hashlib
+from typing import TextIO
+
+from .slip26 import parse_label
+
+# Artefact info and fingerprint: (model integer, purpose, 32-byte fingerprint).
+ArtefactFingerprint = tuple[int, int, bytes]
+
+
+def parse_fingerprints_file(f: TextIO) -> set[ArtefactFingerprint]:
+ """Parse ``<label>: HEX`` lines (as produced by firmware-fingerprint.py) into
+ ``(model, purpose, fingerprint)`` targets.
+
+ Blank lines and comments starting with ``#`` are ignored.
+ """
+ result: set[ArtefactFingerprint] = set()
+ for lineno, raw in enumerate(f.read().splitlines(), 1):
+ line = raw.split("#", 1)[0].strip() # drop comments
+ if not line:
+ continue
+
+ label, sep, hex_fingerprint = line.partition(":")
+ if not sep:
+ raise ValueError(f"{f.name}:{lineno}: expected 'label: HEX', got {raw!r}")
+
+ try:
+ model, purpose = parse_label(label.strip())
+ except ValueError as e:
+ raise ValueError(f"{f.name}:{lineno}: {e}") from e
+
+ try:
+ # whitespace between hex digits is allowed, e.g. "1111 2222 ..."
+ fingerprint = bytes.fromhex("".join(hex_fingerprint.split()))
+ except ValueError:
+ raise ValueError(f"{f.name}:{lineno}: invalid hex") from None
+
+ if len(fingerprint) != 32:
+ raise ValueError(
+ f"{f.name}:{lineno}: fingerprint must be 32 bytes, got {len(fingerprint)}"
+ )
+
+ result.add((model, purpose, fingerprint))
+
+ return result
+
+
+def master_fingerprint(fingerprints: set[ArtefactFingerprint]) -> bytes:
+ """Hash the targets into the master fingerprint, in canonical order."""
+ if not fingerprints:
+ raise ValueError("no fingerprints found")
+
+ ctx = hashlib.sha256()
+ for model, purpose, fingerprint in sorted(fingerprints):
+ ctx.update(model.to_bytes(4, "little"))
+ ctx.update(purpose.to_bytes(1, "little"))
+ ctx.update(fingerprint)
+ return ctx.digest()
diff --git a/python/src/trezorlib/_internal/slip26.py b/python/src/trezorlib/_internal/slip26.py
new file mode 100644
index 00000000..10013b6e
--- /dev/null
+++ b/python/src/trezorlib/_internal/slip26.py
@@ -0,0 +1,94 @@
+# 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
+
+from enum import IntEnum
+
+
+# SLIP-26 purposes.
+class Purpose(IntEnum):
+ BOOTLOADER = 0
+ VENDOR_HEADER = 1
+ FIRMWARE = 2
+ DEFINITIONS = 3
+ PRODTEST = 4
+ CA_FIRMWARE = 5
+ FIDO_AUTHORITY = 6
+ FIDO_BACKUP = 7
+ BITCOIN_ONLY = 8
+ TRANSLATIONS = 9
+ SECURE_MONITOR = 10
+ NRF_FIRMWARE = 11
+ FIRMWARE_ROOT_2025 = 12
+
+
+# Purpose strings used in fingerprints files ("<model>_<purpose>: HEX").
+PURPOSE_TO_STR: dict[Purpose, str] = {
+ Purpose.BOOTLOADER: "bootloader",
+ Purpose.VENDOR_HEADER: "vendorheader",
+ Purpose.FIRMWARE: "universal",
+ Purpose.DEFINITIONS: "definitions",
+ Purpose.PRODTEST: "prodtest",
+ Purpose.CA_FIRMWARE: "ca",
+ Purpose.BITCOIN_ONLY: "btconly",
+ Purpose.TRANSLATIONS: "translations",
+ Purpose.SECURE_MONITOR: "secmon",
+ Purpose.NRF_FIRMWARE: "nrf",
+ Purpose.FIRMWARE_ROOT_2025: "firmware_root_2025",
+}
+STR_TO_PURPOSE: dict[str, Purpose] = {v: k for k, v in PURPOSE_TO_STR.items()}
+
+# Vendor-header text -> purpose, for classifying a vendor firmware image.
+VENDOR_TEXT_TO_PURPOSE: dict[str, Purpose] = {
+ "SatoshiLabs": Purpose.FIRMWARE,
+ "Trezor": Purpose.FIRMWARE,
+ "UNSAFE, FACTORY TEST ONLY": Purpose.PRODTEST,
+ "Internal CA": Purpose.CA_FIRMWARE,
+ "Trezor Bitcoin-only": Purpose.BITCOIN_ONLY,
+}
+
+
+def make_label(model_int: int, purpose: Purpose) -> str:
+ """Render a fingerprints-file label, e.g. (T3W1, BITCOIN_ONLY) -> "t3w1_btconly".
+
+ ``model_int`` is the little-endian ASCII model integer, or 0 for model-agnostic
+ objects (rendered as the bare label).
+ """
+ purpose_str = PURPOSE_TO_STR.get(purpose)
+ if purpose_str is None:
+ raise ValueError(f"purpose {purpose} has no label")
+
+ if model_int == 0:
+ # model-agnostic label, e.g. "translations"
+ return purpose_str
+
+ return f"{model_int.to_bytes(4, 'little').decode().lower()}_{purpose_str}"
+
+
+def parse_label(label: str) -> tuple[int, Purpose]:
+ """Inverse of `make_label`: "<model>_<purpose>" (or a bare, model-agnostic
+ "<purpose>") -> (model, purpose).
+ """
+ if label in STR_TO_PURPOSE:
+ return 0, STR_TO_PURPOSE[label]
+
+ model_str, sep, purpose_str = label.partition("_")
+ if sep and purpose_str in STR_TO_PURPOSE:
+ model = int.from_bytes(model_str.upper().encode(), "little")
+ return model, STR_TO_PURPOSE[purpose_str]
+
+ raise ValueError(f"unknown label {label!r}")
diff --git a/python/tests/test_master_fingerprint.py b/python/tests/test_master_fingerprint.py
new file mode 100644
index 00000000..1dc2b286
--- /dev/null
+++ b/python/tests/test_master_fingerprint.py
@@ -0,0 +1,98 @@
+# 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>.
+
+import io
+
+import pytest
+
+from trezorlib._internal.master_fingerprint import (
+ master_fingerprint,
+ parse_fingerprints_file,
+)
+from trezorlib._internal.slip26 import Purpose
+
+T3T1 = int.from_bytes(b"T3T1", "little")
+FP1 = "11" * 32
+FP2 = "22" * 32
+FP3 = "33" * 32
+
+TRIPLES = {
+ (T3T1, Purpose.FIRMWARE, bytes.fromhex(FP1)),
+ (T3T1, Purpose.SECURE_MONITOR, bytes.fromhex(FP2)),
+ (0, Purpose.DEFINITIONS, bytes.fromhex(FP3)),
+}
+
+# sha256 of the triples above, sorted, each hashed as model (4B LE), purpose
+# (1B), fingerprint (32B); computed independently of the implementation
+MASTER = bytes.fromhex(
+ "f83c2542aa3edecfbed2246c39db58f8fd459f0059300402c2fd70f20d063723"
+)
+
+
+def _fingerprints_file(text: str, name: str = "fps.txt") -> io.StringIO:
+ f = io.StringIO(text)
+ f.name = name
+ return f
+
+
+def test_parse_fingerprints_file():
+ """Comments, blank lines, chunked hex and duplicates are handled"""
+ chunked = " ".join(FP2[i : i + 4] for i in range(0, len(FP2), 4))
+ text = (
+ "# core-T3T1/firmware/firmware-T3T1-2.12.2-987f81b8.bin\n"
+ f"t3t1_universal: {FP1}\n"
+ "\n"
+ f"t3t1_secmon: {chunked}\n"
+ f"definitions: {FP3} # trailing comment\n"
+ f"t3t1_universal: {FP1}\n"
+ )
+ assert parse_fingerprints_file(_fingerprints_file(text)) == TRIPLES
+
+
+@pytest.mark.parametrize(
+ "line,error",
+ [
+ (f"t3t1_universal {FP1}", "expected 'label: HEX'"),
+ (f"t3t1_unknown: {FP1}", "unknown label"),
+ ("t3t1_universal: xyz", "invalid hex"),
+ ("t3t1_universal: aabb", "must be 32 bytes"),
+ ],
+)
+def test_parse_fingerprints_file_errors(line, error):
+ """Malformed lines are rejected with the file name and line number"""
+ text = f"t3t1_universal: {FP1}\n{line}\n"
+ with pytest.raises(ValueError) as e:
+ parse_fingerprints_file(_fingerprints_file(text))
+ assert str(e.value).startswith("fps.txt:2: ")
+ assert error in str(e.value)
+
+
+def test_master_fingerprint():
+ assert master_fingerprint(TRIPLES) == MASTER
+
+
+def test_master_fingerprint_order_independent():
+ """The result must not depend on the order the fingerprints were listed in"""
+ text_a = f"t3t1_universal: {FP1}\nt3t1_secmon: {FP2}\ndefinitions: {FP3}\n"
+ text_b = f"definitions: {FP3}\nt3t1_universal: {FP1}\nt3t1_secmon: {FP2}\n"
+ master_a = master_fingerprint(parse_fingerprints_file(_fingerprints_file(text_a)))
+ master_b = master_fingerprint(parse_fingerprints_file(_fingerprints_file(text_b)))
+ assert master_a == master_b == MASTER
+
+
+def test_master_fingerprint_empty():
+ with pytest.raises(ValueError, match="no fingerprints found"):
+ master_fingerprint(set())
diff --git a/python/tools/firmware-fingerprint.py b/python/tools/firmware-fingerprint.py
index d841c070..ddcec113 100755
--- a/python/tools/firmware-fingerprint.py
+++ b/python/tools/firmware-fingerprint.py
@@ -16,51 +16,72 @@
# 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>.
-import sys
from typing import BinaryIO, TextIO
import click
from trezorlib._internal import firmware_headers
+from trezorlib._internal.slip26 import VENDOR_TEXT_TO_PURPOSE, Purpose, make_label
+from trezorlib.firmware.models import Model
@click.command()
@click.argument("filename", type=click.File("rb"))
@click.option("-o", "--output", type=click.File("w"), default="-")
def firmware_fingerprint(filename: BinaryIO, output: TextIO) -> None:
- """Display fingerprint of a firmware file."""
+ """Display the labeled fingerprint ("<model>_<purpose>: HEX") of a firmware file."""
data = filename.read()
- orig_err = None
try:
fw = firmware_headers.parse_image(data)
- except Exception as e:
- orig_err = e
+ except Exception as parse_err:
+ try:
+ # Try to parse as a new-style PQ boot header.
+ digest = firmware_headers.BootloaderV2Image.parse(data).merkle_root()
+ except Exception:
+ raise click.ClickException(str(parse_err)) from None
+ model_int = 0
+ purpose = Purpose.FIRMWARE_ROOT_2025
else:
- if isinstance(fw, firmware_headers.VendorFirmware):
+ if isinstance(fw, firmware_headers.LegacyV2Firmware):
+ model = Model.T1B1
+ elif isinstance(fw, firmware_headers.CosiSignedMixin):
try:
- # try to parse code as secmon
- # if it succeeds, the image is secmon-only and the fingerprint
- # relevant for signing is that of the secmon
+ model = Model.from_hw_model(fw.get_header().hw_model)
+ except ValueError as e:
+ raise click.ClickException(str(e)) from None
+ else:
+ raise click.ClickException(f"unsupported image type {type(fw).__name__}")
+
+ model_int = int.from_bytes(model.value, "little")
+ digest = fw.digest()
+ if isinstance(fw, firmware_headers.SecmonImage):
+ purpose = Purpose.SECURE_MONITOR
+ elif isinstance(fw, firmware_headers.BootloaderImage):
+ purpose = Purpose.BOOTLOADER
+ elif isinstance(fw, firmware_headers.VendorHeader):
+ purpose = Purpose.VENDOR_HEADER
+ elif isinstance(fw, firmware_headers.LegacyV2Firmware):
+ purpose = Purpose.FIRMWARE
+ elif isinstance(fw, firmware_headers.VendorFirmware):
+ try:
+ # A vendor image may wrap a secmon-only build. Try to parse code
+ # as secmon. If it succeeds, the image is secmon-only and the
+ # fingerprint relevant for signing is that of the secmon.
secmon = firmware_headers.SecmonImage.parse(fw.firmware.code)
- click.echo(secmon.digest().hex(), file=output)
- return
except Exception:
- pass
- click.echo(fw.digest().hex(), file=output)
- return
-
- try:
- click.echo(
- firmware_headers.BootloaderV2Image.parse(data).merkle_root().hex(),
- file=output,
- )
- except Exception as e:
- if orig_err is not None:
- click.echo(orig_err, err=True)
+ purpose = VENDOR_TEXT_TO_PURPOSE.get(fw.vendor_header.text)
+ if purpose is None:
+ raise click.ClickException(
+ f"unsupported vendor header {fw.vendor_header.text!r}"
+ ) from None
+ else:
+ purpose = Purpose.SECURE_MONITOR
+ digest = secmon.digest()
else:
- click.echo(e, err=True)
- sys.exit(2)
+ raise click.ClickException(f"unsupported image type {type(fw).__name__}")
+
+ click.echo(f"{make_label(model_int, purpose)}: {digest.hex(' ', 2)}", file=output)
if __name__ == "__main__":
diff --git a/python/tools/master-fingerprint.py b/python/tools/master-fingerprint.py
new file mode 100755
index 00000000..9855a910
--- /dev/null
+++ b/python/tools/master-fingerprint.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+
+# 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 sys
+from typing import TextIO
+
+import click
+
+from trezorlib._internal.master_fingerprint import (
+ ArtefactFingerprint,
+ master_fingerprint,
+ parse_fingerprints_file,
+)
+
+
+@click.command()
+@click.argument(
+ "fingerprints_files",
+ metavar="[FINGERPRINTS_FILE]...",
+ nargs=-1,
+ type=click.File("r"),
+)
+def firmware_master_fingerprint(fingerprints_files: tuple[TextIO, ...]) -> None:
+ """Compute the master fingerprint from one or more fingerprints files.
+
+ Each FINGERPRINTS_FILE holds '<label>: HEX' lines, as produced by
+ firmware-fingerprint.py. When no file is given, stdin is read. Blank lines
+ and comments starting with '#' are ignored. Model-agnostic objects that are
+ not built here (e.g. definitions, translations) can be appended as
+ 'definitions: HEX' / 'translations: HEX' lines. The fingerprints are
+ de-duplicated, sorted in canonical order, and hashed into the result.
+ """
+ if not fingerprints_files:
+ fingerprints_files = (sys.stdin,)
+ try:
+ fingerprints: set[ArtefactFingerprint] = set()
+ for f in fingerprints_files:
+ fingerprints |= parse_fingerprints_file(f)
+ master = master_fingerprint(fingerprints)
+ except ValueError as e:
+ raise click.ClickException(str(e)) from e
+
+ click.echo(f"Master fingerprint: {master.hex(' ', 2)}")
+
+
+if __name__ == "__main__":
+ firmware_master_fingerprint()
Why this scored 13/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.