fix(python): handle missing THP continuations during multi-chunk payloads
What changed, and why it matters
This commit fixes a bug in the Python Trezor library's handling of multi-part USB messages. When a message is split into several chunks, some middle chunks could be skipped or arrive out of order, causing the library to misread the message or fail. The fix makes the receiver more tolerant: it skips unexpected continuation packets and re-synchronizes when a new message starts in the middle of an expected stream. This is a reliability fix in the host-side Python code, not the hardware wallet firmware itself, and it does not appear to be a direct exploit for stealing coins.
Update the trezorlib Python package to include this fix. Developers using the library for multi-chunk THP communication over USB should verify message integrity after the update. No firmware update is required. If the underlying transport issue (#6539/#6506) is known to cause device-side problems, monitor those issues for additional vendor guidance.
Security signals we found
Protocol desynchronization / missing continuation handling
Host-side transport robustness fix
Potential for message truncation or misassembly before fix
No cryptographic, PIN, or seed handling changes
Evidence from the diff
The patch rewrites read_and_assemble() in python/src/trezorlib/thp/thp_io.py. Previously the function read the initial packet once, then looped until enough continuation bytes were received. If a continuation packet was missing or an unrelated packet appeared, the old code either returned empty data or raised a ProtocolError. The new code loops at the top level, re-processes unexpected continuation packets, and breaks out to treat a non-continuation chunk as a new initial packet. This makes the THP (Trezor Host Protocol) reader resilient to skipped/misordered continuation packets on transports such as USB where retransmissions can occur. The change is purely in the host Python library; no firmware or cryptographic code is modified.
Changed components
python/src/trezorlib/thp/thp_io.pyTrezor Python client library THP readerInspect captured patch +51 / −40
diff --git a/python/.changelog.d/6555.fixed b/python/.changelog.d/6555.fixed
new file mode 100644
index 00000000..c789cdf8
--- /dev/null
+++ b/python/.changelog.d/6555.fixed
@@ -0,0 +1 @@
+Handle missing THP continuations during multi-chunk payloads.
diff --git a/python/src/trezorlib/thp/thp_io.py b/python/src/trezorlib/thp/thp_io.py
index 1d98b58d..62c437dc 100644
--- a/python/src/trezorlib/thp/thp_io.py
+++ b/python/src/trezorlib/thp/thp_io.py
@@ -37,11 +37,11 @@ DEFAULT_MAX_RETRIES = 10
LOG = logging.getLogger(__name__)
-class FirstPacket(t.NamedTuple):
+class ReceivedMessage(t.NamedTuple):
ctrl_byte: int
cid: int
data_length: int
- data: bytes
+ data: bytearray
def write_payload_to_wire(transport: Transport, message: Message) -> None:
@@ -86,41 +86,51 @@ def read_and_assemble(transport: Transport, timeout: float | None = None) -> Mes
3. `checksum` (`bytes`): crc32 checksum of the header + data.
"""
- buffer = bytearray()
-
- # Read header with first part of message data
- head = read_first(transport, timeout)
- buffer.extend(head.data)
-
- # Read the rest of the message
- while len(buffer) < head.data_length:
- buffer.extend(read_next(transport, head.cid, timeout))
-
- msg = Message.parse(head.ctrl_byte, head.cid, bytes(buffer[: head.data_length]))
- return msg
-
-
-def read_first(transport: Transport, timeout: float | None = None) -> FirstPacket:
- chunk = transport.read_chunk(timeout=timeout)
- try:
- ctrl_byte, cid, data_length = struct.unpack(
- FORMAT_STR_INIT, chunk[:INIT_HEADER_LENGTH]
- )
- except struct.error:
- raise exceptions.ProtocolError("Invalid header")
-
- data = chunk[INIT_HEADER_LENGTH:]
- return FirstPacket(ctrl_byte, cid, data_length, data)
-
-
-def read_next(transport: Transport, cid: int, timeout: float | None = None) -> bytes:
- chunk = transport.read_chunk(timeout=timeout)
- ctrl_byte, read_cid = struct.unpack(FORMAT_STR_CONT, chunk[:CONT_HEADER_LENGTH])
- if read_cid != cid:
- LOG.warning("Ignoring packet for channel %s (wanted %s)", read_cid, cid)
- return b""
- if ctrl_byte != CONTINUATION_PACKET:
- raise exceptions.ProtocolError(
- f"Expected continuation, got: {control_byte.to_string(ctrl_byte)}"
- )
- return chunk[CONT_HEADER_LENGTH:]
+ while True:
+ # Process header with first part of message data
+ chunk = transport.read_chunk(timeout=timeout)
+ while True:
+ try:
+ ctrl_byte, cid, data_length = struct.unpack(
+ FORMAT_STR_INIT, chunk[:INIT_HEADER_LENGTH]
+ )
+ except struct.error:
+ raise exceptions.ProtocolError("Invalid header")
+
+ if ctrl_byte == CONTINUATION_PACKET:
+ LOG.warning("Skipping unexpected continuation packet")
+ break
+
+ received = ReceivedMessage(
+ ctrl_byte, cid, data_length, bytearray(chunk[INIT_HEADER_LENGTH:])
+ )
+
+ # Process the rest of the message
+ while True:
+ if len(received.data) >= received.data_length:
+ # Enough data has been received
+ return Message.parse(
+ received.ctrl_byte,
+ received.cid,
+ bytes(received.data[: received.data_length]),
+ )
+
+ chunk = transport.read_chunk(timeout=timeout)
+ ctrl_byte, cid = struct.unpack(
+ FORMAT_STR_CONT, chunk[:CONT_HEADER_LENGTH]
+ )
+ if ctrl_byte != CONTINUATION_PACKET:
+ LOG.warning(
+ "Expected continuation, got: %s",
+ control_byte.to_string(ctrl_byte),
+ )
+ # Keep the unexpected chunk for to be re-processed by the outer loop
+ break
+
+ if received.cid != cid:
+ LOG.warning(
+ "Ignoring packet for channel %s (wanted %s)", cid, received.cid
+ )
+ continue
+
+ received.data.extend(chunk[CONT_HEADER_LENGTH:])
Why this scored 42/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.