feat(python): introduce `NrfHeader` and `NrfImage`
What changed, and why it matters
This commit adds new Python code to the Trezor firmware tools for parsing and building Nordic Semiconductor (nRF) style firmware images. It is a feature addition, not a fix. The code handles firmware headers, TLV metadata blocks, SHA-256 digests, Ed25519 signatures, and device model identifiers. There is no indication in the commit that this resolves a security bug or is being released as a security patch.
No immediate security action is required. Treat this as a routine feature addition. If using these new classes for production firmware signing or verification, review the signature verification logic, TLV parsing robustness, and ensure the model-to-key mapping in `get_nrf_keys` is trustworthy.
Security signals we found
Adds Ed25519 signature verification for nRF firmware images
Adds SHA-256 digest computation over header + image data + protected TLV
Adds integrity checks on TLV table magics, protected TLV size, and trailer
Uses model-specific public key lookup for signature verification
No changelog entry and no security-relevant description in commit message
Evidence from the diff
The change introduces NrfHeader and NrfImage classes in python/src/trezorlib/firmware/nrf.py, exposes them via python/src/trezorlib/firmware/__init__.py, and adds formatting/verification helpers in python/src/trezorlib/_internal/firmware_headers.py. The parser uses construct to decode a fixed nRF image header, protected/unprotected TLV tables, image data, and a trailer. It supports SHA-256 integrity digests, Ed25519 dual-signature verification keyed by model, and sigmask-based key selection. Integrity checks reject unexpected TLV magics, mismatched protected-TLV sizes, and non-empty trailers. The commit is tagged [no changelog] and titled as a feature.
Changed components
python/src/trezorlib/firmware/nrf.pypython/src/trezorlib/firmware/__init__.pypython/src/trezorlib/_internal/firmware_headers.pyInspect captured patch +388 / −1
diff --git a/python/src/trezorlib/_internal/firmware_headers.py b/python/src/trezorlib/_internal/firmware_headers.py
index 32dfb03f..6b3562b5 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 import nrf
from ..firmware.sanity_struct import STRICT_SANITY_CHECK_DEFAULT
SYM_OK = click.style("\u2714", fg="green")
@@ -311,7 +312,10 @@ class VendorHeader(firmware.VendorHeader, CosiSignedMixin):
NAME: t.ClassVar[str] = "vendorheader"
DEV_KEYS: t.ClassVar[t.Sequence[bytes]] = _make_dev_keys(b"\x44", b"\x45")
- SUBCON = c.Struct(*firmware.VendorHeader.SUBCON.subcons, c.Terminated)
+ SUBCON = c.Struct(
+ *firmware.VendorHeader.SUBCON.subcons,
+ c.Terminated,
+ )
def get_header(self) -> CosiSignatureHeaderProto:
return self
@@ -630,6 +634,85 @@ class LegacyV2Firmware(firmware.LegacyV2Firmware):
return self.header.v1_key_indexes
+class NrfImage(nrf.NrfImage):
+ NAME: t.ClassVar[str] = "nrf"
+
+ def signature_present(self) -> bool:
+ return (
+ nrf.TlvType.SIGNATURE1 in self.unprotected_tlv
+ and nrf.TlvType.SIGNATURE2 in self.unprotected_tlv
+ )
+
+ def format(self, verbose: bool = False) -> str:
+ header_str = _format_header(self.header)
+ image_str = f"Image data: {len(self.img_data)} bytes"
+ tlvs_str = _format_tlvs(self.protected_tlv, self.unprotected_tlv)
+ fingerprint_str = (
+ f"Calculated fingerprint: {click.style(chunkify(self.digest()), bold=True)}"
+ )
+ sig_result = check_signature_any(self)
+ sig_ok = SYM_OK if sig_result.is_ok() else SYM_FAIL
+ sig_str = f"{sig_ok} Signature is {sig_result.value}"
+
+ return "\n".join(
+ [
+ header_str,
+ image_str,
+ tlvs_str,
+ fingerprint_str,
+ sig_str,
+ ]
+ )
+
+
+def _format_header(header: nrf.NrfHeader) -> str:
+ header_dict = asdict(header)
+ header_out = header_dict.copy()
+
+ for key, val in header_out.items():
+ if "version" in key:
+ header_out[key] = LiteralStr(_format_version_nRF(val))
+
+ return "NrfHeader " + format_container(header_out)
+
+
+def _format_version_nRF(version: tuple[int, int, int, int]) -> str:
+ return "{}.{}.{}+{}".format(*version)
+
+
+def _format_tlvs(
+ *tlv_tables: nrf.TlvTable,
+ padding: str = " " * 4,
+) -> str:
+ total_size = 0
+ for table in tlv_tables:
+ total_size += table.length
+
+ output = [f"TLVs (count: {len(tlv_tables)}, total_size: {total_size} bytes) {{"]
+
+ def _add(s: str, depth: int = 1) -> None:
+ output.append(padding * depth + s)
+
+ for tlv in tlv_tables:
+ type_name = tlv.magic.name
+ _add(f"{type_name} ({tlv.length} bytes) {{")
+
+ for entry in tlv.entries:
+ if isinstance(entry.id, nrf.TlvType):
+ name = entry.id.name
+ else:
+ name = f"unrecognized {entry.id}"
+ if len(entry.data) > 64:
+ data = entry.data[:64].hex() + "..."
+ elif isinstance(entry.id, nrf.TlvType) and entry.id.name == "MODEL":
+ data = f"{entry.data.decode()} ({entry.data.hex()})"
+ else:
+ data = entry.data.hex()
+ _add(f"{name}: ({len(entry.data)} bytes) {data}", depth=2)
+ _add("}")
+ return "\n".join(output) + "\n}"
+
+
def parse_image(
image: bytes, strict: bool = STRICT_SANITY_CHECK_DEFAULT
) -> SignableImageProto:
diff --git a/python/src/trezorlib/firmware/__init__.py b/python/src/trezorlib/firmware/__init__.py
index c95c0782..67222b58 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 .nrf import * # noqa: F401, F403
from .sanity_struct import * # noqa: F401, F403
from .secmon import * # noqa: F401, F403
from .util import ( # noqa: F401
diff --git a/python/src/trezorlib/firmware/nrf.py b/python/src/trezorlib/firmware/nrf.py
new file mode 100644
index 00000000..0ed6ee76
--- /dev/null
+++ b/python/src/trezorlib/firmware/nrf.py
@@ -0,0 +1,303 @@
+from __future__ import annotations
+
+import hashlib
+import typing as t
+from enum import IntEnum
+
+import construct as c
+from construct_classes import subcon
+from typing_extensions import Self
+
+from .. import _ed25519 as ed25519
+from ..construct_helpers import EnumAdapter, TupleAdapter
+from . import util
+from .models import Model, get_nrf_keys
+from .sanity_struct import STRICT_SANITY_CHECK_DEFAULT, SanityCheckedStruct
+
+__all__ = ["NrfHeader", "NrfImage"]
+
+
+NRF_IMAGE_MAGIC = bytes.fromhex("3DB8F396")
+NRF_IMAGE_HEADER_SIZE = 32
+
+
+class TlvType(IntEnum):
+ SHA256 = 0x0010
+ SIGNATURE1 = 0x00A0
+ SIGNATURE2 = 0x00A1
+ SIGMASK = 0x00A2
+ MODEL = 0x00A3
+
+
+class TlvTableType(IntEnum):
+ PROTECTED = 0x6908
+ UNPROTECTED = 0x6907
+
+
+class TlvEntry(SanityCheckedStruct):
+ id: int | TlvType
+ data: bytes
+
+ SUBCON = c.Struct(
+ "id" / EnumAdapter(c.Int16ul, TlvType),
+ "data" / c.Prefixed(c.Int16ul, c.GreedyBytes),
+ )
+
+
+class TlvTable(SanityCheckedStruct):
+ magic: TlvTableType
+ entries: list[TlvEntry] = subcon(TlvEntry)
+ length: int = 4
+
+ SUBCON = c.Struct(
+ "magic" / EnumAdapter(c.Int16ul, TlvTableType),
+ "length" / c.Int16ul,
+ "entries" / c.FixedSized(c.this.length - 4, c.GreedyRange(TlvEntry.SUBCON)),
+ )
+
+ def _update_length(self) -> None:
+ self.length = sum(len(entry.build()) for entry in self.entries) + 4
+
+ def __post_init__(self) -> None:
+ self._update_length()
+
+ def build(self) -> bytes:
+ self._update_length()
+ return super().build()
+
+ def __getitem__(self, key: TlvType) -> bytes:
+ for entry in self.entries:
+ if entry.id == key:
+ return entry.data
+ raise KeyError(f"TlvType {key} not found")
+
+ def __setitem__(self, key: TlvType, value: bytes) -> None:
+ for entry in self.entries:
+ if entry.id == key:
+ entry.data = value
+ return
+
+ self.entries.append(TlvEntry(id=key, data=value))
+
+ def __delitem__(self, key: TlvType) -> None:
+ for i, entry in enumerate(self.entries):
+ if entry.id == key:
+ del self.entries[i]
+ return
+ raise KeyError(f"TlvType {key} not found")
+
+ def __contains__(self, key: TlvType) -> bool:
+ return any(entry.id == key for entry in self.entries)
+
+
+class NrfHeader(SanityCheckedStruct):
+ load_addr: int
+ hdr_size: int
+ protected_tlv_size: int
+ img_size: int
+ flags: int
+ version: tuple[int, int, int, int]
+ _trailing_data: bytes
+
+ SUBCON = c.Struct(
+ "_start_offset" / c.Tell,
+ "_magic" / c.Const(NRF_IMAGE_MAGIC, c.Bytes(4)),
+ "load_addr" / c.Int32ul,
+ "hdr_size" / c.Int16ul,
+ "protected_tlv_size" / c.Int16ul,
+ "img_size" / c.Int32ul,
+ "flags" / c.Int32ul,
+ "version" / TupleAdapter(c.Int8ul, c.Int8ul, c.Int16ul, c.Int32ul),
+ "_hdr_known_end" / c.Tell,
+ "_trailing_data"
+ / c.Default(
+ c.Bytes(c.this.hdr_size - c.this._hdr_known_end + c.this._start_offset),
+ b"\x00" * (c.this.hdr_size - c.this._hdr_known_end + c.this._start_offset),
+ ),
+ )
+
+ @property
+ def trailing_data(self) -> bytes:
+ return self._trailing_data
+
+ @trailing_data.setter
+ def trailing_data(self, value: bytes) -> None:
+ old_len = len(self._trailing_data)
+ self._trailing_data = value
+ self.hdr_size += len(value) - old_len
+
+ @classmethod
+ def create(
+ cls,
+ *,
+ version: tuple[int, int, int, int],
+ header_size: int,
+ flags: int = 0,
+ padding_byte: bytes = b"\x00",
+ ) -> Self:
+ # explicitly build the subcon without providing trailing_data
+ header_empty = cls.SUBCON.build(
+ dict(
+ load_addr=0,
+ hdr_size=header_size,
+ protected_tlv_size=0,
+ img_size=0,
+ flags=flags,
+ version=version,
+ )
+ )
+ # re-parsing will pick out the default value
+ reparsed = cls.SUBCON.parse(header_empty)
+ # ...which we can use to figure out the correct length
+
+ assert reparsed is not None
+
+ padding_bytes = bytearray(padding_byte * len(reparsed["_trailing_data"]))
+ # XXX hack to get binary identical with imgtool:
+ padding_bytes[0:4] = b"\x00\x00\x00\x00"
+ return cls(
+ load_addr=0,
+ hdr_size=header_size,
+ protected_tlv_size=0,
+ img_size=0,
+ flags=flags,
+ version=version,
+ _trailing_data=bytes(padding_bytes),
+ )
+
+
+class NrfImage(SanityCheckedStruct):
+ header: NrfHeader = subcon(NrfHeader)
+ img_data: bytes
+ protected_tlv: TlvTable = subcon(TlvTable)
+ unprotected_tlv: TlvTable = subcon(TlvTable)
+ trailer: bytes
+
+ SUBCON = c.Struct(
+ "header" / NrfHeader.SUBCON,
+ "img_data" / c.Bytes(c.this.header.img_size),
+ "protected_tlv" / TlvTable.SUBCON,
+ "unprotected_tlv" / TlvTable.SUBCON,
+ "trailer" / c.GreedyBytes,
+ )
+
+ @classmethod
+ def parse(cls, data: bytes, *, strict: bool = STRICT_SANITY_CHECK_DEFAULT) -> Self:
+ parsed = super().parse(data, strict=strict)
+ parsed._verify_integrity()
+ return parsed
+
+ def build(self) -> bytes:
+ self._sync_header_fields()
+ self._update_digest()
+ self.protected_tlv._update_length()
+ self.unprotected_tlv._update_length()
+ return super().build()
+
+ def _sync_header_fields(self) -> None:
+ self.header.img_size = len(self.img_data)
+ self.header.protected_tlv_size = len(self.protected_tlv.build())
+
+ def _verify_integrity(self) -> None:
+ if self.protected_tlv.magic != TlvTableType.PROTECTED:
+ raise ValueError(
+ f"Parsed NrfImage has unexpected magic in protected tlv table: {self.protected_tlv.magic}."
+ )
+ if self.unprotected_tlv.magic != TlvTableType.UNPROTECTED:
+ raise ValueError(
+ f"Parsed NrfImage has unexpected magic in unprotected tlv table: {self.unprotected_tlv.magic}."
+ )
+ if len(self.protected_tlv.build()) != self.header.protected_tlv_size:
+ raise ValueError(
+ f"Parsed NrfImage has invalid protected_tlv_size: {self.header.protected_tlv_size}."
+ )
+ if self.trailer != b"":
+ raise ValueError(
+ f"Parsed NrfImage has invalid trailer data: {self.trailer}."
+ )
+
+ def _update_digest(self) -> None:
+ self.unprotected_tlv[TlvType.SHA256] = self.digest()
+
+ def digest(self) -> bytes:
+ self._sync_header_fields()
+ hasher = hashlib.sha256()
+ hasher.update(self.header.build())
+ hasher.update(self.img_data)
+ hasher.update(self.protected_tlv.build())
+ return hasher.digest()
+
+ @property
+ def model(self) -> Model:
+ return Model(self.protected_tlv[TlvType.MODEL])
+
+ @model.setter
+ def model(self, model: Model) -> None:
+ self.protected_tlv[TlvType.MODEL] = model.value
+
+ @property
+ def sigmask(self) -> int:
+ return int.from_bytes(self.protected_tlv[TlvType.SIGMASK], "little")
+
+ def insert_sigmask(self, sigmask: int) -> None:
+ self.protected_tlv[TlvType.SIGMASK] = sigmask.to_bytes(1, "little")
+ self._update_digest()
+
+ def set_signatures(self, signatures: tuple[bytes, bytes]) -> None:
+ self.unprotected_tlv[TlvType.SIGNATURE1] = signatures[0]
+ self.unprotected_tlv[TlvType.SIGNATURE2] = signatures[1]
+
+ def public_keys(self, dev_keys: bool = False) -> t.Sequence[bytes]:
+ return get_nrf_keys(self.model, dev_keys)
+
+ @classmethod
+ def create(
+ cls,
+ *,
+ version: tuple[int, int, int, int],
+ model: Model,
+ img_data: bytes,
+ header_size: int,
+ flags: int = 0,
+ padding_byte: bytes = b"\xff",
+ sigmask: int = 0x03,
+ ) -> Self:
+ header = NrfHeader.create(
+ version=version,
+ header_size=header_size,
+ flags=flags,
+ padding_byte=padding_byte,
+ )
+ image = cls(
+ header=header,
+ img_data=img_data,
+ protected_tlv=TlvTable(magic=TlvTableType.PROTECTED, entries=[]),
+ unprotected_tlv=TlvTable(magic=TlvTableType.UNPROTECTED, entries=[]),
+ trailer=b"",
+ )
+ image.insert_sigmask(sigmask)
+ image.model = model
+
+ # keep SHA256 TLV aligned with current content
+ image._update_digest()
+ return image
+
+ def verify(self, dev_keys: bool = False) -> None:
+ digest = self.digest()
+ sigmask = self.sigmask
+ keys = self.public_keys(dev_keys)
+ signature_1 = self.unprotected_tlv[TlvType.SIGNATURE1]
+ signature_2 = self.unprotected_tlv[TlvType.SIGNATURE2]
+
+ if sigmask.bit_length() > len(keys):
+ raise ValueError("Sigmask specifies more public keys than provided.")
+
+ selected_keys = [key for i, key in enumerate(keys) if sigmask & (1 << i)]
+
+ if len(selected_keys) != 2:
+ raise ValueError("Sigmask does not specify two keys.")
+ try:
+ ed25519.checkvalid(signature_1, digest, selected_keys[0])
+ ed25519.checkvalid(signature_2, digest, selected_keys[1])
+ except ed25519.SignatureMismatch as e:
+ raise util.InvalidSignatureError("Invalid signature") from e
Why this scored 19/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.