refactor(python): move protocols out of transport
What changed, and why it matters
This commit is a pure code reorganization: it moves the Trezor host-side protocol implementation files from one Python package directory (transport/thp) to another (thp) and adds a new protocol_v1.py module. The actual logic, constants, and behavior appear unchanged. There is no indication this fixes or introduces a security vulnerability.
No security action required. Treat as normal maintenance; verify downstream imports and tests still pass after the package move.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The diff shows a refactor of python/src/trezorlib: existing THP (Trezor Host Protocol) modules are relocated from python/src/trezorlib/transport/thp/ to python/src/trezorlib/thp/ with identical content, and a new protocol_v1.py is created containing the legacy v1 protocol/session/client code. Imports are adjusted accordingly. No functional security changes are visible in the supplied diff.
Changed components
python/src/trezorlib/protocol_v1.pypython/src/trezorlib/thp/channel.pypython/src/trezorlib/thp/checksum.pypython/src/trezorlib/thp/control_byte.pypython/src/trezorlib/thp/cpace.pypython/src/trezorlib/thp/curve25519.pypython/src/trezorlib/thp/message_header.pypython/src/trezorlib/thp/protocol_v2.pypython/src/trezorlib/thp/thp_io.pyInspect captured patch +1300 / −1052
diff --git a/python/src/trezorlib/protocol_v1.py b/python/src/trezorlib/protocol_v1.py
new file mode 100644
index 00000000..afab415e
--- /dev/null
+++ b/python/src/trezorlib/protocol_v1.py
@@ -0,0 +1,248 @@
+# 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 io
+import logging
+import struct
+import typing as t
+
+from . import client, exceptions, messages
+from .log import DUMP_BYTES
+from .transport import Transport
+
+if t.TYPE_CHECKING:
+ from .mapping import ProtobufMapping
+ from .models import TrezorModel
+
+LOG = logging.getLogger(__name__)
+
+HEADER_FMT = ">HL"
+HEADER_LEN = struct.calcsize(HEADER_FMT)
+
+
+def write(transport: Transport, message_type: int, message_data: bytes) -> None:
+ """Write message bytes to transport, chunked according to protocol v1."""
+ chunk_size = transport.CHUNK_SIZE
+ header = struct.pack(HEADER_FMT, message_type, len(message_data))
+
+ if chunk_size is None:
+ transport.write_chunk(header + message_data)
+ return
+
+ buffer = io.BytesIO(b"##" + header + message_data)
+ while chunk_payload := buffer.read(chunk_size):
+ chunk = b"?" + chunk_payload
+ # pad to chunk size
+ chunk = chunk.ljust(chunk_size, b"\x00")
+ transport.write_chunk(chunk)
+
+
+def read(transport: Transport, timeout: float | None = None) -> tuple[int, bytes]:
+ """Read out and reassemble protocol-v1 chunked message from transport."""
+ if timeout is None:
+ timeout = client._DEFAULT_READ_TIMEOUT
+
+ # Chunked transports prefix the first packet with "?##" and all following packets with "?".
+ # Non-chunked (i.e., bridge) just load all the data in one go.
+ use_chunk_magic = transport.CHUNK_SIZE is not None
+
+ def read_next_chunk() -> bytes:
+ chunk = transport.read_chunk(timeout=timeout)
+ if use_chunk_magic and chunk[:1] != b"?":
+ raise exceptions.ProtocolError(f"Missing chunk magic: {chunk.hex()}")
+ return chunk[1:]
+
+ # process first chunk
+ chunk = read_next_chunk()
+ if use_chunk_magic:
+ # '?' was stripped in read_next_chunk(), we just detect the "##"
+ if chunk[:2] != b"##":
+ raise exceptions.ProtocolError(
+ f"Unexpected first chunk magic: {chunk.hex()}"
+ )
+ chunk = chunk[2:]
+
+ # extract header
+ header = chunk[:HEADER_LEN]
+ msg_type, datalen = struct.unpack(HEADER_FMT, header)
+
+ # read rest of the message
+ buffer = bytearray(chunk[HEADER_LEN:])
+ while len(buffer) < datalen:
+ buffer.extend(read_next_chunk())
+ return msg_type, bytes(buffer[:datalen])
+
+
+class SessionV1(client.Session["TrezorClientV1"]):
+ def __init__(
+ self,
+ client: TrezorClientV1,
+ *,
+ session_id: bytes | None = None,
+ seedless: bool = False,
+ ) -> None:
+ super().__init__(client)
+ self.session_id = session_id
+ self.seedless = seedless
+ self.is_invalid = False
+
+ def resume(self) -> None:
+ if self.session_id is None:
+ raise RuntimeError("resuming session without id")
+ self.initialize()
+
+ def _activate_self(self) -> None:
+ if self.is_invalid:
+ raise exceptions.InvalidSessionError(self.session_id)
+ if self.client._last_active_session is not self:
+ self.client._last_active_session = self
+ self.resume()
+
+ def _write(self, msg: t.Any) -> None:
+ self._activate_self()
+ LOG.debug(
+ f"sending message: {msg.__class__.__name__}",
+ extra={"protobuf": msg},
+ )
+ msg_type, msg_bytes = self.client.mapping.encode(msg)
+ LOG.log(
+ DUMP_BYTES,
+ f"encoded as type {msg_type} ({len(msg_bytes)} bytes): {msg_bytes.hex()}",
+ )
+ write(self.client.transport, msg_type, msg_bytes)
+
+ def _read(self, timeout: float | None = None) -> t.Any:
+ if self.is_invalid:
+ raise exceptions.InvalidSessionError(self.session_id)
+ assert self.client._last_active_session is self
+ msg_type, msg_bytes = self._read(timeout=timeout)
+ LOG.log(
+ DUMP_BYTES,
+ f"received type {msg_type} ({len(msg_bytes)} bytes): {msg_bytes.hex()}",
+ )
+ msg = self.client.mapping.decode(msg_type, msg_bytes)
+ LOG.debug(
+ f"received message: {msg.__class__.__name__}",
+ extra={"protobuf": msg},
+ )
+
+ from .debuglink import TrezorClientDebugLink
+
+ if isinstance(self.client, TrezorClientDebugLink):
+ self.client.notify_read(msg)
+
+ return msg
+
+ def initialize(self, *, derive_cardano: bool | None = None) -> None:
+ # avoid triggering a resume() in _activate_self()
+ self.client._last_active_session = self
+ resp = self.call_raw(
+ messages.Initialize(
+ session_id=self.session_id, derive_cardano=derive_cardano
+ )
+ )
+ features = messages.Features.ensure_isinstance(resp)
+ session_id = features.session_id
+ if self.session_id is None or self.seedless:
+ self.session_id = session_id
+ elif self.session_id != session_id:
+ self.is_invalid = True
+ raise exceptions.InvalidSessionError(session_id)
+
+ def derive_seed(
+ self,
+ passphrase: str | type[client.PassphraseOnDevice],
+ derive_cardano: bool,
+ ) -> None:
+ if self.session_id is not None:
+ raise exceptions.TrezorException("Session already initialized")
+ self.initialize(derive_cardano=derive_cardano)
+ resp = self.call(
+ messages.GetAddress(
+ address_n=client.PASSPHRASE_TEST_PATH, coin_name="Testnet"
+ )
+ )
+ # no passphrase was requested
+ if isinstance(resp, messages.Address):
+ if self.features.passphrase_protection is True:
+ raise exceptions.TrezorException(
+ "Failed to activate passphrase session"
+ )
+ if passphrase not in (None, client.PassphraseOnDevice):
+ raise exceptions.PassphraseDisabledError
+
+ return
+
+ resp = messages.PassphraseRequest.ensure_isinstance(resp)
+ if passphrase is client.PassphraseOnDevice:
+ ack = messages.PassphraseAck(on_device=True)
+ else:
+ assert isinstance(passphrase, str)
+ ack = messages.PassphraseAck(passphrase=passphrase)
+ resp = self.call(ack)
+ if isinstance(resp, messages.Deprecated_PassphraseStateRequest):
+ self.session_id = resp.state
+ resp = self.call(messages.Deprecated_PassphraseStateAck())
+ messages.Address.ensure_isinstance(resp)
+ self.refresh_features()
+
+
+class TrezorClientV1(client.TrezorClient[SessionV1]):
+ _last_active_session: SessionV1 | None = None
+
+ def __init__(
+ self,
+ transport: Transport,
+ *,
+ model: TrezorModel | None,
+ mapping: ProtobufMapping | None,
+ app_name: str,
+ host_name: str | None,
+ ) -> None:
+ """
+ TODO
+ """
+ super().__init__(
+ model=model,
+ mapping=mapping,
+ app_name=app_name,
+ host_name=host_name,
+ )
+ LOG.info(f"creating client instance for device: {transport.get_path()}")
+ self.transport = transport
+ self._seedless_session = SessionV1(client=self, seedless=True)
+
+ def get_session(
+ self,
+ passphrase: str | type[client.PassphraseOnDevice] | None = "",
+ *,
+ derive_cardano: bool = False,
+ ) -> SessionV1:
+ """
+ Returns a new session.
+ """
+ if passphrase is None:
+ return self._seedless_session
+ session = SessionV1(client=self)
+ session.derive_seed(passphrase, derive_cardano)
+ return session
+
+ def _get_features(self) -> messages.Features:
+ return self._seedless_session.call(
+ messages.GetFeatures(), expect=messages.Features
+ )
diff --git a/python/src/trezorlib/thp/channel.py b/python/src/trezorlib/thp/channel.py
new file mode 100644
index 00000000..44747331
--- /dev/null
+++ b/python/src/trezorlib/thp/channel.py
@@ -0,0 +1,41 @@
+# 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 typing as t
+
+from ... import messages
+from ...mapping import ProtobufMapping
+from .. import Transport
+
+
+class Channel:
+ _DEFAULT_READ_TIMEOUT: t.ClassVar[float | None] = None
+
+ def __init__(
+ self,
+ transport: Transport,
+ mapping: ProtobufMapping,
+ ) -> None:
+ self.transport = transport
+ self.mapping = mapping
+
+ def get_features(self) -> messages.Features:
+ raise NotImplementedError()
+
+ def update_features(self) -> None:
+ raise NotImplementedError
diff --git a/python/src/trezorlib/thp/checksum.py b/python/src/trezorlib/thp/checksum.py
new file mode 100644
index 00000000..04de5168
--- /dev/null
+++ b/python/src/trezorlib/thp/checksum.py
@@ -0,0 +1,35 @@
+# 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 zlib
+
+CHECKSUM_LENGTH = 4
+
+
+def compute(data: bytes) -> bytes:
+ """
+ Returns a CRC-32 checksum of the provided `data`.
+ """
+ return zlib.crc32(data).to_bytes(CHECKSUM_LENGTH, "big")
+
+
+def is_valid(checksum: bytes, data: bytes) -> bool:
+ """
+ Checks whether the CRC-32 checksum of the `data` is the same
+ as the checksum provided in `checksum`.
+ """
+ data_checksum = compute(data)
+ return checksum == data_checksum
diff --git a/python/src/trezorlib/thp/control_byte.py b/python/src/trezorlib/thp/control_byte.py
new file mode 100644
index 00000000..59bc0ea8
--- /dev/null
+++ b/python/src/trezorlib/thp/control_byte.py
@@ -0,0 +1,85 @@
+# 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 typing import Optional
+
+CODEC_V1 = 0x3F
+CONTINUATION_PACKET = 0x80
+HANDSHAKE_INIT_REQ = 0x00
+HANDSHAKE_INIT_RES = 0x01
+HANDSHAKE_COMP_REQ = 0x02
+HANDSHAKE_COMP_RES = 0x03
+ENCRYPTED_TRANSPORT = 0x04
+
+CONTINUATION_PACKET_MASK = 0x80
+ACK_MASK = 0xF7
+DATA_MASK = 0xE7
+
+ACK_MESSAGE = 0x20
+_ERROR = 0x42
+CHANNEL_ALLOCATION_REQ = 0x40
+_CHANNEL_ALLOCATION_RES = 0x41
+
+TREZOR_STATE_UNPAIRED = b"\x00"
+TREZOR_STATE_PAIRED = b"\x01"
+
+
+def add_seq_bit_to_ctrl_byte(ctrl_byte: int, seq_bit: int) -> int:
+ if seq_bit == 0:
+ return ctrl_byte & 0xEF
+ if seq_bit == 1:
+ return ctrl_byte | 0x10
+ raise Exception("Unexpected sequence bit")
+
+
+def add_ack_bit_to_ctrl_byte(ctrl_byte: int, ack_bit: int) -> int:
+ if ack_bit == 0:
+ return ctrl_byte & 0xF7
+ if ack_bit == 1:
+ return ctrl_byte | 0x08
+ raise Exception("Unexpected acknowledgement bit")
+
+
+def get_seq_bit(ctrl_byte: int) -> Optional[int]:
+ if ctrl_byte & 0xE0:
+ # not all message types contain SEQ bit
+ return None
+
+ return (ctrl_byte & 0x10) >> 4
+
+
+def is_ack(ctrl_byte: int) -> bool:
+ return ctrl_byte & ACK_MASK == ACK_MESSAGE
+
+
+def is_error(ctrl_byte: int) -> bool:
+ return ctrl_byte == _ERROR
+
+
+def is_continuation(ctrl_byte: int) -> bool:
+ return ctrl_byte & CONTINUATION_PACKET_MASK == CONTINUATION_PACKET
+
+
+def is_encrypted_transport(ctrl_byte: int) -> bool:
+ return ctrl_byte & DATA_MASK == ENCRYPTED_TRANSPORT
+
+
+def is_handshake_init_req(ctrl_byte: int) -> bool:
+ return ctrl_byte & DATA_MASK == HANDSHAKE_INIT_REQ
+
+
+def is_handshake_comp_req(ctrl_byte: int) -> bool:
+ return ctrl_byte & DATA_MASK == HANDSHAKE_COMP_REQ
diff --git a/python/src/trezorlib/thp/cpace.py b/python/src/trezorlib/thp/cpace.py
new file mode 100644
index 00000000..6df9678c
--- /dev/null
+++ b/python/src/trezorlib/thp/cpace.py
@@ -0,0 +1,56 @@
+# 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 typing as t
+from hashlib import sha512
+
+from . import curve25519
+
+_PREFIX = b"\x08\x43\x50\x61\x63\x65\x32\x35\x35\x06"
+_PADDING = b"\x6f\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x20"
+
+
+class Cpace:
+ """
+ CPace, a balanced composable PAKE: https://datatracker.ietf.org/doc/draft-irtf-cfrg-cpace/
+ """
+
+ random_bytes: t.Callable[[int], bytes]
+
+ def __init__(self, handshake_hash: bytes) -> None:
+ self.handshake_hash: bytes = handshake_hash
+ self.shared_secret: bytes
+ self.host_private_key: bytes
+ self.host_public_key: bytes
+
+ def generate_keys_and_secret(
+ self, code_code_entry: bytes, trezor_public_key: bytes
+ ) -> None:
+ """
+ Generate ephemeral key pair and a shared secret using Elligator2 with X25519.
+ """
+ sha_ctx = sha512(_PREFIX)
+ sha_ctx.update(code_code_entry)
+ sha_ctx.update(_PADDING)
+ sha_ctx.update(self.handshake_hash)
+ sha_ctx.update(b"\x00")
+ pregenerator = sha_ctx.digest()[:32]
+ generator = curve25519.elligator2(pregenerator)
+ self.host_private_key = self.random_bytes(32)
+ self.host_public_key = curve25519.multiply(self.host_private_key, generator)
+ self.shared_secret = curve25519.multiply(
+ self.host_private_key, trezor_public_key
+ )
diff --git a/python/src/trezorlib/thp/curve25519.py b/python/src/trezorlib/thp/curve25519.py
new file mode 100644
index 00000000..7f8bfb5b
--- /dev/null
+++ b/python/src/trezorlib/thp/curve25519.py
@@ -0,0 +1,175 @@
+# 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
+
+p = 2**255 - 19
+J = 486662
+
+c3 = 19681161376707505956807079304988542015446066515923890162744021073123829784752 # sqrt(-1)
+c4 = 7237005577332262213973186563042994240829374041602535252466099000494570602493 # (p - 5) // 8
+a24 = 121666 # (J + 2) // 4
+
+
+def decode_scalar(scalar: bytes) -> int:
+ # decodeScalar25519 from
+ # https://datatracker.ietf.org/doc/html/rfc7748#section-5
+
+ if len(scalar) != 32:
+ raise ValueError("Invalid length of scalar")
+
+ array = bytearray(scalar)
+ array[0] &= 248
+ array[31] &= 127
+ array[31] |= 64
+
+ return int.from_bytes(array, "little")
+
+
+def decode_coordinate(coordinate: bytes) -> int:
+ # decodeUCoordinate from
+ # https://datatracker.ietf.org/doc/html/rfc7748#section-5
+ if len(coordinate) != 32:
+ raise ValueError("Invalid length of coordinate")
+
+ array = bytearray(coordinate)
+ array[-1] &= 0x7F
+ return int.from_bytes(array, "little") % p
+
+
+def encode_coordinate(coordinate: int) -> bytes:
+ # encodeUCoordinate from
+ # https://datatracker.ietf.org/doc/html/rfc7748#section-5
+ return coordinate.to_bytes(32, "little")
+
+
+def get_private_key(secret: bytes) -> bytes:
+ return decode_scalar(secret).to_bytes(32, "little")
+
+
+def get_public_key(private_key: bytes) -> bytes:
+ base_point = int.to_bytes(9, 32, "little")
+ return multiply(private_key, base_point)
+
+
+def multiply(private_scalar: bytes, public_point: bytes) -> bytes:
+ # X25519 from
+ # https://datatracker.ietf.org/doc/html/rfc7748#section-5
+
+ def ladder_operation(
+ x1: int, x2: int, z2: int, x3: int, z3: int
+ ) -> tuple[int, int, int, int]:
+ # https://hyperelliptic.org/EFD/g1p/auto-montgom-xz.html#ladder-ladd-1987-m-3
+ # (x4, z4) = 2 * (x2, z2)
+ # (x5, z5) = (x2, z2) + (x3, z3)
+ # where (x1, 1) = (x3, z3) - (x2, z2)
+
+ a = (x2 + z2) % p
+ aa = (a * a) % p
+ b = (x2 - z2) % p
+ bb = (b * b) % p
+ e = (aa - bb) % p
+ c = (x3 + z3) % p
+ d = (x3 - z3) % p
+ da = (d * a) % p
+ cb = (c * b) % p
+ t0 = (da + cb) % p
+ x5 = (t0 * t0) % p
+ t1 = (da - cb) % p
+ t2 = (t1 * t1) % p
+ z5 = (x1 * t2) % p
+ x4 = (aa * bb) % p
+ t3 = (a24 * e) % p
+ t4 = (bb + t3) % p
+ z4 = (e * t4) % p
+
+ return x4, z4, x5, z5
+
+ def conditional_swap(first: int, second: int, condition: int) -> tuple[int, int]:
+ # Returns (second, first) if condition is true and (first, second) otherwise
+ # Must be implemented in a way that it is constant time
+ true_mask = -condition
+ false_mask = ~true_mask
+ return (first & false_mask) | (second & true_mask), (second & false_mask) | (
+ first & true_mask
+ )
+
+ k = decode_scalar(private_scalar)
+ u = decode_coordinate(public_point)
+
+ x_1 = u
+ x_2 = 1
+ z_2 = 0
+ x_3 = u
+ z_3 = 1
+ swap = 0
+
+ for i in reversed(range(256)):
+ bit = (k >> i) & 1
+ swap = bit ^ swap
+ (x_2, x_3) = conditional_swap(x_2, x_3, swap)
+ (z_2, z_3) = conditional_swap(z_2, z_3, swap)
+ swap = bit
+ x_2, z_2, x_3, z_3 = ladder_operation(x_1, x_2, z_2, x_3, z_3)
+
+ (x_2, x_3) = conditional_swap(x_2, x_3, swap)
+ (z_2, z_3) = conditional_swap(z_2, z_3, swap)
+
+ x = pow(z_2, p - 2, p) * x_2 % p
+ return encode_coordinate(x)
+
+
+def elligator2(point: bytes) -> bytes:
+ # map_to_curve_elligator2_curve25519 from
+ # https://www.rfc-editor.org/rfc/rfc9380.html#ell2-opt
+
+ def conditional_move(first: int, second: int, condition: bool) -> int:
+ # Returns second if condition is true and first otherwise
+ # Must be implemented in a way that it is constant time
+ true_mask = -condition
+ false_mask = ~true_mask
+ return (first & false_mask) | (second & true_mask)
+
+ u = decode_coordinate(point)
+ tv1 = (u * u) % p
+ tv1 = (2 * tv1) % p
+ xd = (tv1 + 1) % p
+ x1n = (-J) % p
+ tv2 = (xd * xd) % p
+ gxd = (tv2 * xd) % p
+ gx1 = (J * tv1) % p
+ gx1 = (gx1 * x1n) % p
+ gx1 = (gx1 + tv2) % p
+ gx1 = (gx1 * x1n) % p
+ tv3 = (gxd * gxd) % p
+ tv2 = (tv3 * tv3) % p
+ tv3 = (tv3 * gxd) % p
+ tv3 = (tv3 * gx1) % p
+ tv2 = (tv2 * tv3) % p
+ y11 = pow(tv2, c4, p)
+ y11 = (y11 * tv3) % p
+ y12 = (y11 * c3) % p
+ tv2 = (y11 * y11) % p
+ tv2 = (tv2 * gxd) % p
+ e1 = tv2 == gx1
+ y1 = conditional_move(y12, y11, e1)
+ x2n = (x1n * tv1) % p
+ tv2 = (y1 * y1) % p
+ tv2 = (tv2 * gxd) % p
+ e3 = tv2 == gx1
+ xn = conditional_move(x2n, x1n, e3)
+ x = xn * pow(xd, p - 2, p) % p
+ return encode_coordinate(x)
diff --git a/python/src/trezorlib/thp/message_header.py b/python/src/trezorlib/thp/message_header.py
new file mode 100644
index 00000000..525522f9
--- /dev/null
+++ b/python/src/trezorlib/thp/message_header.py
@@ -0,0 +1,112 @@
+# 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 struct
+
+from typing_extensions import Self
+
+CODEC_V1 = 0x3F
+CONTINUATION_PACKET = 0x80
+HANDSHAKE_INIT_REQ = 0x00
+HANDSHAKE_INIT_RES = 0x01
+HANDSHAKE_COMP_REQ = 0x02
+HANDSHAKE_COMP_RES = 0x03
+ENCRYPTED_TRANSPORT = 0x04
+
+CONTINUATION_PACKET_MASK = 0x80
+ACK_MASK = 0xF7
+DATA_MASK = 0xE7
+
+ACK_MESSAGE = 0x20
+_ERROR = 0x42
+CHANNEL_ALLOCATION_REQ = 0x40
+_CHANNEL_ALLOCATION_RES = 0x41
+
+PING = 0x43
+PONG = 0x44
+
+TREZOR_STATE_UNPAIRED = b"\x00"
+TREZOR_STATE_PAIRED = b"\x01"
+
+BROADCAST_CHANNEL_ID = 0xFFFF
+
+
+class MessageHeader:
+ format_str_init = ">BHH"
+ format_str_cont = ">BH"
+
+ def __init__(self, ctrl_byte: int, cid: int, length: int) -> None:
+ self.ctrl_byte = ctrl_byte
+ self.cid = cid
+ self.data_length = length
+
+ def to_bytes_init(self) -> bytes:
+ return struct.pack(
+ self.format_str_init, self.ctrl_byte, self.cid, self.data_length
+ )
+
+ def to_bytes_cont(self) -> bytes:
+ return struct.pack(self.format_str_cont, CONTINUATION_PACKET, self.cid)
+
+ def pack_to_init_buffer(self, buffer: bytearray, buffer_offset: int = 0) -> None:
+ struct.pack_into(
+ self.format_str_init,
+ buffer,
+ buffer_offset,
+ self.ctrl_byte,
+ self.cid,
+ self.data_length,
+ )
+
+ def pack_to_cont_buffer(self, buffer: bytearray, buffer_offset: int = 0) -> None:
+ struct.pack_into(
+ self.format_str_cont, buffer, buffer_offset, CONTINUATION_PACKET, self.cid
+ )
+
+ def is_ack(self) -> bool:
+ return self.ctrl_byte & ACK_MASK == ACK_MESSAGE
+
+ def is_channel_allocation_response(self) -> bool:
+ return (
+ self.cid == BROADCAST_CHANNEL_ID
+ and self.ctrl_byte == _CHANNEL_ALLOCATION_RES
+ )
+
+ def is_pong(self) -> bool:
+ return self.cid == BROADCAST_CHANNEL_ID and self.ctrl_byte == PONG
+
+ def is_handshake_init_response(self) -> bool:
+ return self.ctrl_byte & DATA_MASK == HANDSHAKE_INIT_RES
+
+ def is_handshake_comp_response(self) -> bool:
+ return self.ctrl_byte & DATA_MASK == HANDSHAKE_COMP_RES
+
+ def is_encrypted_transport(self) -> bool:
+ return self.ctrl_byte & DATA_MASK == ENCRYPTED_TRANSPORT
+
+ @classmethod
+ def get_error_header(cls, cid: int, length: int) -> Self:
+ return cls(_ERROR, cid, length)
+
+ @classmethod
+ def get_channel_allocation_request_header(cls, length: int) -> Self:
+ return cls(CHANNEL_ALLOCATION_REQ, BROADCAST_CHANNEL_ID, length)
+
+ @classmethod
+ def get_ping_header(cls, length: int) -> Self:
+ return cls(PING, BROADCAST_CHANNEL_ID, length)
diff --git a/python/src/trezorlib/thp/protocol_v2.py b/python/src/trezorlib/thp/protocol_v2.py
new file mode 100644
index 00000000..0ab6e2ea
--- /dev/null
+++ b/python/src/trezorlib/thp/protocol_v2.py
@@ -0,0 +1,427 @@
+# 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 os
+import typing as t
+from binascii import hexlify
+
+from noise.connection import Keypair, NoiseConnection
+
+from ... import exceptions, messages, protobuf
+from ...mapping import ProtobufMapping
+from .. import Transport
+from ..thp import checksum, thp_io
+from ..thp.checksum import CHECKSUM_LENGTH
+from ..thp.message_header import MessageHeader
+from . import control_byte
+from .channel import Channel
+
+LOG = logging.getLogger(__name__)
+
+DEFAULT_SESSION_ID: int = 0
+
+MAX_RETRANSMISSION_COUNT = 50
+
+TREZOR_STATE_UNPAIRED = b"\x00"
+TREZOR_STATE_PAIRED = b"\x01"
+TREZOR_STATE_PAIRED_AUTOCONNECT = b"\x02"
+TREZOR_STATES = [
+ TREZOR_STATE_UNPAIRED,
+ TREZOR_STATE_PAIRED,
+ TREZOR_STATE_PAIRED_AUTOCONNECT,
+]
+
+if t.TYPE_CHECKING:
+ pass
+MT = t.TypeVar("MT", bound=protobuf.MessageType)
+
+
+class ProtocolV2Channel(Channel):
+ channel_id: int
+ sync_bit_send: int
+ sync_bit_receive: int
+ handshake_hash: bytes
+ device_properties: bytes
+
+ _features: messages.Features | None = None
+ _is_paired: bool = False
+
+ def __init__(
+ self,
+ transport: Transport,
+ mapping: ProtobufMapping,
+ credential: bytes | None = None,
+ prepare_channel_without_pairing: bool = True,
+ ) -> None:
+ super().__init__(transport, mapping)
+ self._reset_sync_bits()
+ if prepare_channel_without_pairing:
+ # allow skipping unrelated response packets (e.g. in case of retransmissions)
+ self._do_channel_allocation(retries=MAX_RETRANSMISSION_COUNT)
+ LOG.debug("THP channel allocated: %04x", self.channel_id)
+ self._do_handshake(credential=credential)
+ LOG.debug("THP handshake done: is_paired=%s", self._is_paired)
+
+ def get_channel(self) -> ProtocolV2Channel:
+ if not self._is_paired:
+ raise RuntimeError("Channel is not paired")
+ return self
+
+ def read(self, session_id: int, timeout: float | None = None) -> t.Any:
+ sid, msg_type, msg_data = self.read_and_decrypt(timeout)
+ if sid != session_id:
+ raise Exception(
+ f"Received messsage on a different session (expected/received): ({session_id}/{sid}) "
+ )
+ return self.mapping.decode(msg_type, msg_data)
+
+ def write(self, session_id: int, msg: t.Any) -> None:
+ msg_type, msg_data = self.mapping.encode(msg)
+ self._encrypt_and_write(session_id, msg_type, msg_data)
+
+ def get_features(self) -> messages.Features:
+ if not self._is_paired:
+ raise RuntimeError("Channel is not paired")
+ if self._features is None:
+ self.update_features()
+ assert self._features is not None
+ return self._features
+
+ def update_features(self, timeout: float | None = None) -> None:
+ message = messages.GetFeatures()
+ message_type, message_data = self.mapping.encode(message)
+ self.session_id: int = DEFAULT_SESSION_ID
+ self._encrypt_and_write(DEFAULT_SESSION_ID, message_type, message_data)
+ header, _payload = self._read_until_valid_crc_check()
+ if not header.is_ack():
+ raise exceptions.TrezorException("ACK expected")
+ _, msg_type, msg_data = self.read_and_decrypt(timeout)
+ features = self.mapping.decode(msg_type, msg_data)
+ if not isinstance(features, messages.Features):
+ raise exceptions.TrezorException("Unexpected response to GetFeatures")
+ self._features = features
+
+ def _send_message(
+ self,
+ message: protobuf.MessageType,
+ session_id: int = DEFAULT_SESSION_ID,
+ ) -> None:
+ message_type, message_data = self.mapping.encode(message)
+ self._encrypt_and_write(session_id, message_type, message_data)
+ self._read_ack()
+
+ def _read_message(self, message_type: type[MT], timeout: float | None = None) -> MT:
+ _, msg_type, msg_data = self.read_and_decrypt(timeout)
+ msg = self.mapping.decode(msg_type, msg_data)
+ assert isinstance(msg, message_type)
+ return msg
+
+ def _reset_sync_bits(self) -> None:
+ self.sync_bit_send = 0
+ self.sync_bit_receive = 0
+
+ def sync_responses(
+ self, retries: int = MAX_RETRANSMISSION_COUNT, timeout: float = 10.0
+ ) -> None:
+ """Make sure the event loop is running and ready."""
+ nonce = os.urandom(8)
+ thp_io.write_payload_to_wire_and_add_checksum(
+ self.transport,
+ MessageHeader.get_ping_header(len(nonce) + CHECKSUM_LENGTH),
+ nonce,
+ )
+ for _ in range(1 + retries):
+ header, payload = self._read_until_valid_crc_check(timeout=timeout)
+ if self._is_valid_pong(header, payload, nonce):
+ break
+ else:
+ raise RuntimeError("Invalid ping response")
+
+ def _do_channel_allocation(self, retries: int = 0) -> None:
+ channel_allocation_nonce = os.urandom(8)
+ self._send_channel_allocation_request(channel_allocation_nonce)
+ cid, dp = self._read_channel_allocation_response(
+ channel_allocation_nonce, retries=retries
+ )
+ self.channel_id = cid
+ self.device_properties = dp
+
+ def _send_channel_allocation_request(self, nonce: bytes) -> None:
+ thp_io.write_payload_to_wire_and_add_checksum(
+ self.transport,
+ MessageHeader.get_channel_allocation_request_header(
+ len(nonce) + CHECKSUM_LENGTH
+ ),
+ nonce,
+ )
+
+ def _read_channel_allocation_response(
+ self, expected_nonce: bytes, retries: int = 0
+ ) -> tuple[int, bytes]:
+ for _ in range(1 + retries):
+ header, payload = self._read_until_valid_crc_check()
+ if self._is_valid_channel_allocation_response(
+ header, payload, expected_nonce
+ ):
+ break
+ else:
+ raise Exception("Invalid channel allocation response.")
+
+ channel_id = int.from_bytes(payload[8:10], "big")
+ device_properties = payload[10:]
+ return (channel_id, device_properties)
+
+ def _init_noise(
+ self,
+ randomness_static: bytes | None = None,
+ randomness_ephemeral: bytes | None = None,
+ ) -> None:
+ randomness_static = randomness_static or os.urandom(32)
+ self._noise = NoiseConnection.from_name(b"Noise_XX_25519_AESGCM_SHA256")
+ self._noise.set_as_initiator()
+ self._noise.set_keypair_from_private_bytes(Keypair.STATIC, randomness_static)
+ if randomness_ephemeral is not None:
+ self._noise.set_keypair_from_private_bytes(
+ Keypair.EPHEMERAL, randomness_ephemeral
+ )
+ prologue = bytes(self.device_properties)
+ self._noise.set_prologue(prologue)
+ self._noise.start_handshake()
+
+ def _do_handshake(
+ self,
+ credential: bytes | None = None,
+ host_static_randomness: bytes | None = None,
+ host_ephemeral_randomness: bytes | None = None,
+ ) -> None:
+
+ randomness_static = host_static_randomness or os.urandom(32)
+ if host_ephemeral_randomness is not None:
+ self._init_noise(randomness_static, host_ephemeral_randomness)
+ else:
+ self._init_noise(randomness_static)
+ self._send_handshake_init_request()
+ self._read_ack()
+ self._read_handshake_init_response()
+ self._send_handshake_completion_request(
+ credential,
+ )
+ self._read_ack()
+ return self._read_handshake_completion_response()
+
+ def _send_handshake_init_request(self, try_to_unlock: bool = True) -> None:
+ payload = self._noise.write_message(bytes([try_to_unlock]))
+ ha_init_req_header = MessageHeader(
+ 0, self.channel_id, len(payload) + CHECKSUM_LENGTH
+ )
+
+ thp_io.write_payload_to_wire_and_add_checksum(
+ self.transport, ha_init_req_header, payload
+ )
+
+ def _read_handshake_init_response(self) -> bytes:
+ header, payload = self._read_until_valid_crc_check()
+
+ if not header.is_handshake_init_response():
+ LOG.error("Received message is not a valid handshake init response message")
+
+ self._send_ack_bit(bit=0)
+ self._noise.read_message(payload)
+ return payload
+
+ def _send_handshake_completion_request(
+ self,
+ credential: bytes | None = None,
+ ) -> None:
+ # TODO implement key recognition
+ # print(
+ # "TREZOR's static pubkey:\n",
+ # self.noise.noise_protocol.handshake_state.rs.public.public_bytes_raw(),
+ # )
+
+ msg_data = self.mapping.encode_without_wire_type(
+ messages.ThpHandshakeCompletionReqNoisePayload(
+ host_pairing_credential=credential,
+ )
+ )
+ message2 = self._noise.write_message(payload=msg_data)
+
+ ha_completion_req_header = MessageHeader(
+ 0x12,
+ self.channel_id,
+ len(message2) + CHECKSUM_LENGTH,
+ )
+ thp_io.write_payload_to_wire_and_add_checksum(
+ self.transport,
+ ha_completion_req_header,
+ message2, # encrypted_host_static_pubkey + encrypted_payload,
+ )
+ self.handshake_hash = self._noise.get_handshake_hash()
+
+ def _read_handshake_completion_response(self) -> None:
+ # Read handshake completion response
+ header, data = self._read_until_valid_crc_check()
+ if not header.is_handshake_comp_response():
+ LOG.error("Received message is not a valid handshake completion response")
+ trezor_state = self._noise.decrypt(bytes(data))
+ assert trezor_state in TREZOR_STATES
+ self._send_ack_bit(bit=1)
+ self._is_paired = trezor_state != TREZOR_STATE_UNPAIRED
+
+ def _read_ack(self) -> None:
+ header, payload = self._read_until_valid_crc_check()
+ if not header.is_ack() or len(payload) > 0:
+ LOG.error("Received message is not a valid ACK")
+
+ def _send_ack_bit(self, bit: int) -> None:
+ if bit not in (0, 1):
+ raise ValueError("Invalid ACK bit")
+ LOG.debug(f"sending ack {bit}")
+ ctrl_byte = 0x20 if bit == 0 else 0x28
+ header = MessageHeader(ctrl_byte, self.channel_id, 4)
+ thp_io.write_payload_to_wire_and_add_checksum(self.transport, header, b"")
+
+ def _encrypt_and_write(
+ self,
+ session_id: int,
+ message_type: int,
+ message_data: bytes,
+ ctrl_byte: int | None = None,
+ ) -> None:
+
+ if ctrl_byte is None:
+ ctrl_byte = control_byte.add_seq_bit_to_ctrl_byte(0x04, self.sync_bit_send)
+ self.sync_bit_send = 1 - self.sync_bit_send
+
+ sid = session_id.to_bytes(1, "big")
+ msg_type = message_type.to_bytes(2, "big")
+ data = sid + msg_type + message_data
+
+ encrypted_message = self._noise.encrypt(data)
+
+ header = MessageHeader(
+ ctrl_byte, self.channel_id, len(encrypted_message) + CHECKSUM_LENGTH
+ )
+
+ thp_io.write_payload_to_wire_and_add_checksum(
+ self.transport, header, encrypted_message
+ )
+
+ def read_and_decrypt(
+ self, timeout: float | None = None
+ ) -> t.Tuple[int, int, bytes]:
+ while True:
+ header, raw_payload = self._read_until_valid_crc_check(timeout)
+ if header.cid != self.channel_id:
+ # Received message from different channel - discard
+ continue
+ if control_byte.is_ack(header.ctrl_byte):
+ continue
+ if not header.is_encrypted_transport():
+ LOG.error(
+ "Trying to decrypt not encrypted message! ("
+ + hexlify(header.to_bytes_init() + raw_payload).decode()
+ + ")"
+ )
+
+ seq_bit = control_byte.get_seq_bit(header.ctrl_byte)
+ assert seq_bit is not None
+ LOG.debug(
+ "--> Get sequence bit %d %s %s",
+ seq_bit,
+ "from control byte",
+ hexlify(header.ctrl_byte.to_bytes(1, "big")).decode(),
+ )
+ self._send_ack_bit(bit=seq_bit)
+
+ message = self._noise.decrypt(bytes(raw_payload))
+ session_id = message[0]
+ message_type = message[1:3]
+ message_data = message[3:]
+ return (
+ session_id,
+ int.from_bytes(message_type, "big"),
+ message_data,
+ )
+
+ def _read_until_valid_crc_check(
+ self, timeout: float | None = None
+ ) -> t.Tuple[MessageHeader, bytes]:
+ if timeout is None:
+ timeout = self._DEFAULT_READ_TIMEOUT
+
+ while True:
+ header, payload, chksum = thp_io.read(self.transport, timeout)
+ if not checksum.is_valid(chksum, header.to_bytes_init() + payload):
+ LOG.error(
+ "Received a message with an invalid checksum:"
+ + hexlify(header.to_bytes_init() + payload + chksum).decode()
+ )
+ continue
+
+ seq_bit = control_byte.get_seq_bit(header.ctrl_byte)
+ if seq_bit is not None:
+ if seq_bit != self.sync_bit_receive:
+ LOG.warning(
+ "Received unexpected message: sync bit=%d, expected=%d",
+ seq_bit,
+ self.sync_bit_receive,
+ )
+ continue
+
+ self.sync_bit_receive = 1 - self.sync_bit_receive
+
+ if control_byte.is_error(header.ctrl_byte):
+ code = payload[0]
+ raise _ERRORS_MAP.get(code) or exceptions.ThpUnknownError(code)
+
+ return header, payload
+
+ def _is_valid_channel_allocation_response(
+ self, header: MessageHeader, payload: bytes, original_nonce: bytes
+ ) -> bool:
+ if not header.is_channel_allocation_response():
+ LOG.error("Received message is not a channel allocation response")
+ return False
+ if len(payload) < 10:
+ LOG.error("Invalid channel allocation response payload")
+ return False
+ if payload[:8] != original_nonce:
+ LOG.error("Invalid channel allocation response payload (nonce mismatch)")
+ return False
+ return True
+
+ def _is_valid_pong(
+ self, header: MessageHeader, payload: bytes, original_nonce: bytes
+ ) -> bool:
+ if not header.is_pong():
+ LOG.error("Received message is not a pong")
+ return False
+ if payload != original_nonce:
+ LOG.error("Invalid pong payload (nonce mismatch)")
+ return False
+ return True
+
+
+_ERRORS_MAP = {
+ 1: exceptions.TransportBusy,
+ 2: exceptions.UnallocatedChannel,
+ 3: exceptions.DecryptionFailed,
+ 5: exceptions.DeviceLocked,
+}
diff --git a/python/src/trezorlib/thp/thp_io.py b/python/src/trezorlib/thp/thp_io.py
new file mode 100644
index 00000000..e0dd60aa
--- /dev/null
+++ b/python/src/trezorlib/thp/thp_io.py
@@ -0,0 +1,121 @@
+# 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 struct
+
+from ...exceptions import ThpError
+from .. import Transport
+from ..thp import checksum
+from .message_header import MessageHeader
+
+INIT_HEADER_LENGTH = 5
+CONT_HEADER_LENGTH = 3
+MAX_PAYLOAD_LEN = 60000
+MESSAGE_TYPE_LENGTH = 2
+
+CONTINUATION_PACKET = 0x80
+
+
+def write_payload_to_wire_and_add_checksum(
+ transport: Transport, header: MessageHeader, payload: bytes
+) -> None:
+ chksum = checksum.compute(header.to_bytes_init() + payload)
+ data = payload + chksum
+ if len(data) > MAX_PAYLOAD_LEN:
+ raise RuntimeError("Message too large")
+ write_payload_to_wire(transport, header, data)
+
+
+def write_payload_to_wire(
+ transport: Transport, header: MessageHeader, payload: bytes
+) -> None:
+ if transport.CHUNK_SIZE is None:
+ transport.write_chunk(payload)
+ return
+
+ chunk = (
+ header.to_bytes_init() + payload[: transport.CHUNK_SIZE - INIT_HEADER_LENGTH]
+ )
+ chunk = chunk.ljust(transport.CHUNK_SIZE, b"\x00")
+ transport.write_chunk(chunk)
+
+ buffer = payload[transport.CHUNK_SIZE - INIT_HEADER_LENGTH :]
+ while buffer:
+ chunk = (
+ header.to_bytes_cont() + buffer[: transport.CHUNK_SIZE - CONT_HEADER_LENGTH]
+ )
+ chunk = chunk.ljust(transport.CHUNK_SIZE, b"\x00")
+ transport.write_chunk(chunk)
+ buffer = buffer[transport.CHUNK_SIZE - CONT_HEADER_LENGTH :]
+
+
+def read(
+ transport: Transport, timeout: float | None = None
+) -> tuple[MessageHeader, bytes, bytes]:
+ """
+ Reads from the given wire transport.
+
+ Returns `Tuple[MessageHeader, bytes, bytes]`:
+ 1. `header` (`MessageHeader`): Header of the message.
+ 2. `data` (`bytes`): Contents of the message (if any).
+ 3. `checksum` (`bytes`): crc32 checksum of the header + data.
+
+ """
+ buffer = bytearray()
+
+ # Read header with first part of message data
+ header, first_chunk = read_first(transport, timeout)
+ buffer.extend(first_chunk)
+
+ # Read the rest of the message
+ while len(buffer) < header.data_length:
+ buffer.extend(read_next(transport, header.cid, timeout))
+
+ data_len = header.data_length - checksum.CHECKSUM_LENGTH
+ msg_data = buffer[:data_len]
+ chksum = buffer[data_len : data_len + checksum.CHECKSUM_LENGTH]
+
+ return (header, bytes(msg_data), bytes(chksum))
+
+
+def read_first(
+ transport: Transport, timeout: float | None = None
+) -> tuple[MessageHeader, bytes]:
+ chunk = transport.read_chunk(timeout)
+ try:
+ ctrl_byte, cid, data_length = struct.unpack(
+ MessageHeader.format_str_init, chunk[:INIT_HEADER_LENGTH]
+ )
+ except Exception:
+ raise RuntimeError("Cannot parse header")
+
+ data = chunk[INIT_HEADER_LENGTH:]
+ return MessageHeader(ctrl_byte, cid, data_length), data
+
+
+def read_next(transport: Transport, cid: int, timeout: float | None = None) -> bytes:
+ chunk = transport.read_chunk(timeout)
+ ctrl_byte, read_cid = struct.unpack(
+ MessageHeader.format_str_cont, chunk[:CONT_HEADER_LENGTH]
+ )
+ if ctrl_byte != CONTINUATION_PACKET:
+ raise ThpError("Continuation packet with incorrect control byte")
+ if read_cid != cid:
+ raise ThpError("Continuation packet for different channel")
+
+ return chunk[CONT_HEADER_LENGTH:]
diff --git a/python/src/trezorlib/transport/thp/channel.py b/python/src/trezorlib/transport/thp/channel.py
deleted file mode 100644
index 44747331..00000000
--- a/python/src/trezorlib/transport/thp/channel.py
+++ /dev/null
@@ -1,41 +0,0 @@
-# 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 typing as t
-
-from ... import messages
-from ...mapping import ProtobufMapping
-from .. import Transport
-
-
-class Channel:
- _DEFAULT_READ_TIMEOUT: t.ClassVar[float | None] = None
-
- def __init__(
- self,
- transport: Transport,
- mapping: ProtobufMapping,
- ) -> None:
- self.transport = transport
- self.mapping = mapping
-
- def get_features(self) -> messages.Features:
- raise NotImplementedError()
-
- def update_features(self) -> None:
- raise NotImplementedError
diff --git a/python/src/trezorlib/transport/thp/checksum.py b/python/src/trezorlib/transport/thp/checksum.py
deleted file mode 100644
index 04de5168..00000000
--- a/python/src/trezorlib/transport/thp/checksum.py
+++ /dev/null
@@ -1,35 +0,0 @@
-# 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 zlib
-
-CHECKSUM_LENGTH = 4
-
-
-def compute(data: bytes) -> bytes:
- """
- Returns a CRC-32 checksum of the provided `data`.
- """
- return zlib.crc32(data).to_bytes(CHECKSUM_LENGTH, "big")
-
-
-def is_valid(checksum: bytes, data: bytes) -> bool:
- """
- Checks whether the CRC-32 checksum of the `data` is the same
- as the checksum provided in `checksum`.
- """
- data_checksum = compute(data)
- return checksum == data_checksum
diff --git a/python/src/trezorlib/transport/thp/control_byte.py b/python/src/trezorlib/transport/thp/control_byte.py
deleted file mode 100644
index 59bc0ea8..00000000
--- a/python/src/trezorlib/transport/thp/control_byte.py
+++ /dev/null
@@ -1,85 +0,0 @@
-# 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 typing import Optional
-
-CODEC_V1 = 0x3F
-CONTINUATION_PACKET = 0x80
-HANDSHAKE_INIT_REQ = 0x00
-HANDSHAKE_INIT_RES = 0x01
-HANDSHAKE_COMP_REQ = 0x02
-HANDSHAKE_COMP_RES = 0x03
-ENCRYPTED_TRANSPORT = 0x04
-
-CONTINUATION_PACKET_MASK = 0x80
-ACK_MASK = 0xF7
-DATA_MASK = 0xE7
-
-ACK_MESSAGE = 0x20
-_ERROR = 0x42
-CHANNEL_ALLOCATION_REQ = 0x40
-_CHANNEL_ALLOCATION_RES = 0x41
-
-TREZOR_STATE_UNPAIRED = b"\x00"
-TREZOR_STATE_PAIRED = b"\x01"
-
-
-def add_seq_bit_to_ctrl_byte(ctrl_byte: int, seq_bit: int) -> int:
- if seq_bit == 0:
- return ctrl_byte & 0xEF
- if seq_bit == 1:
- return ctrl_byte | 0x10
- raise Exception("Unexpected sequence bit")
-
-
-def add_ack_bit_to_ctrl_byte(ctrl_byte: int, ack_bit: int) -> int:
- if ack_bit == 0:
- return ctrl_byte & 0xF7
- if ack_bit == 1:
- return ctrl_byte | 0x08
- raise Exception("Unexpected acknowledgement bit")
-
-
-def get_seq_bit(ctrl_byte: int) -> Optional[int]:
- if ctrl_byte & 0xE0:
- # not all message types contain SEQ bit
- return None
-
- return (ctrl_byte & 0x10) >> 4
-
-
-def is_ack(ctrl_byte: int) -> bool:
- return ctrl_byte & ACK_MASK == ACK_MESSAGE
-
-
-def is_error(ctrl_byte: int) -> bool:
- return ctrl_byte == _ERROR
-
-
-def is_continuation(ctrl_byte: int) -> bool:
- return ctrl_byte & CONTINUATION_PACKET_MASK == CONTINUATION_PACKET
-
-
-def is_encrypted_transport(ctrl_byte: int) -> bool:
- return ctrl_byte & DATA_MASK == ENCRYPTED_TRANSPORT
-
-
-def is_handshake_init_req(ctrl_byte: int) -> bool:
- return ctrl_byte & DATA_MASK == HANDSHAKE_INIT_REQ
-
-
-def is_handshake_comp_req(ctrl_byte: int) -> bool:
- return ctrl_byte & DATA_MASK == HANDSHAKE_COMP_REQ
diff --git a/python/src/trezorlib/transport/thp/cpace.py b/python/src/trezorlib/transport/thp/cpace.py
deleted file mode 100644
index 6df9678c..00000000
--- a/python/src/trezorlib/transport/thp/cpace.py
+++ /dev/null
@@ -1,56 +0,0 @@
-# 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 typing as t
-from hashlib import sha512
-
-from . import curve25519
-
-_PREFIX = b"\x08\x43\x50\x61\x63\x65\x32\x35\x35\x06"
-_PADDING = b"\x6f\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x20"
-
-
-class Cpace:
- """
- CPace, a balanced composable PAKE: https://datatracker.ietf.org/doc/draft-irtf-cfrg-cpace/
- """
-
- random_bytes: t.Callable[[int], bytes]
-
- def __init__(self, handshake_hash: bytes) -> None:
- self.handshake_hash: bytes = handshake_hash
- self.shared_secret: bytes
- self.host_private_key: bytes
- self.host_public_key: bytes
-
- def generate_keys_and_secret(
- self, code_code_entry: bytes, trezor_public_key: bytes
- ) -> None:
- """
- Generate ephemeral key pair and a shared secret using Elligator2 with X25519.
- """
- sha_ctx = sha512(_PREFIX)
- sha_ctx.update(code_code_entry)
- sha_ctx.update(_PADDING)
- sha_ctx.update(self.handshake_hash)
- sha_ctx.update(b"\x00")
- pregenerator = sha_ctx.digest()[:32]
- generator = curve25519.elligator2(pregenerator)
- self.host_private_key = self.random_bytes(32)
- self.host_public_key = curve25519.multiply(self.host_private_key, generator)
- self.shared_secret = curve25519.multiply(
- self.host_private_key, trezor_public_key
- )
diff --git a/python/src/trezorlib/transport/thp/curve25519.py b/python/src/trezorlib/transport/thp/curve25519.py
deleted file mode 100644
index 7f8bfb5b..00000000
--- a/python/src/trezorlib/transport/thp/curve25519.py
+++ /dev/null
@@ -1,175 +0,0 @@
-# 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
-
-p = 2**255 - 19
-J = 486662
-
-c3 = 19681161376707505956807079304988542015446066515923890162744021073123829784752 # sqrt(-1)
-c4 = 7237005577332262213973186563042994240829374041602535252466099000494570602493 # (p - 5) // 8
-a24 = 121666 # (J + 2) // 4
-
-
-def decode_scalar(scalar: bytes) -> int:
- # decodeScalar25519 from
- # https://datatracker.ietf.org/doc/html/rfc7748#section-5
-
- if len(scalar) != 32:
- raise ValueError("Invalid length of scalar")
-
- array = bytearray(scalar)
- array[0] &= 248
- array[31] &= 127
- array[31] |= 64
-
- return int.from_bytes(array, "little")
-
-
-def decode_coordinate(coordinate: bytes) -> int:
- # decodeUCoordinate from
- # https://datatracker.ietf.org/doc/html/rfc7748#section-5
- if len(coordinate) != 32:
- raise ValueError("Invalid length of coordinate")
-
- array = bytearray(coordinate)
- array[-1] &= 0x7F
- return int.from_bytes(array, "little") % p
-
-
-def encode_coordinate(coordinate: int) -> bytes:
- # encodeUCoordinate from
- # https://datatracker.ietf.org/doc/html/rfc7748#section-5
- return coordinate.to_bytes(32, "little")
-
-
-def get_private_key(secret: bytes) -> bytes:
- return decode_scalar(secret).to_bytes(32, "little")
-
-
-def get_public_key(private_key: bytes) -> bytes:
- base_point = int.to_bytes(9, 32, "little")
- return multiply(private_key, base_point)
-
-
-def multiply(private_scalar: bytes, public_point: bytes) -> bytes:
- # X25519 from
- # https://datatracker.ietf.org/doc/html/rfc7748#section-5
-
- def ladder_operation(
- x1: int, x2: int, z2: int, x3: int, z3: int
- ) -> tuple[int, int, int, int]:
- # https://hyperelliptic.org/EFD/g1p/auto-montgom-xz.html#ladder-ladd-1987-m-3
- # (x4, z4) = 2 * (x2, z2)
- # (x5, z5) = (x2, z2) + (x3, z3)
- # where (x1, 1) = (x3, z3) - (x2, z2)
-
- a = (x2 + z2) % p
- aa = (a * a) % p
- b = (x2 - z2) % p
- bb = (b * b) % p
- e = (aa - bb) % p
- c = (x3 + z3) % p
- d = (x3 - z3) % p
- da = (d * a) % p
- cb = (c * b) % p
- t0 = (da + cb) % p
- x5 = (t0 * t0) % p
- t1 = (da - cb) % p
- t2 = (t1 * t1) % p
- z5 = (x1 * t2) % p
- x4 = (aa * bb) % p
- t3 = (a24 * e) % p
- t4 = (bb + t3) % p
- z4 = (e * t4) % p
-
- return x4, z4, x5, z5
-
- def conditional_swap(first: int, second: int, condition: int) -> tuple[int, int]:
- # Returns (second, first) if condition is true and (first, second) otherwise
- # Must be implemented in a way that it is constant time
- true_mask = -condition
- false_mask = ~true_mask
- return (first & false_mask) | (second & true_mask), (second & false_mask) | (
- first & true_mask
- )
-
- k = decode_scalar(private_scalar)
- u = decode_coordinate(public_point)
-
- x_1 = u
- x_2 = 1
- z_2 = 0
- x_3 = u
- z_3 = 1
- swap = 0
-
- for i in reversed(range(256)):
- bit = (k >> i) & 1
- swap = bit ^ swap
- (x_2, x_3) = conditional_swap(x_2, x_3, swap)
- (z_2, z_3) = conditional_swap(z_2, z_3, swap)
- swap = bit
- x_2, z_2, x_3, z_3 = ladder_operation(x_1, x_2, z_2, x_3, z_3)
-
- (x_2, x_3) = conditional_swap(x_2, x_3, swap)
- (z_2, z_3) = conditional_swap(z_2, z_3, swap)
-
- x = pow(z_2, p - 2, p) * x_2 % p
- return encode_coordinate(x)
-
-
-def elligator2(point: bytes) -> bytes:
- # map_to_curve_elligator2_curve25519 from
- # https://www.rfc-editor.org/rfc/rfc9380.html#ell2-opt
-
- def conditional_move(first: int, second: int, condition: bool) -> int:
- # Returns second if condition is true and first otherwise
- # Must be implemented in a way that it is constant time
- true_mask = -condition
- false_mask = ~true_mask
- return (first & false_mask) | (second & true_mask)
-
- u = decode_coordinate(point)
- tv1 = (u * u) % p
- tv1 = (2 * tv1) % p
- xd = (tv1 + 1) % p
- x1n = (-J) % p
- tv2 = (xd * xd) % p
- gxd = (tv2 * xd) % p
- gx1 = (J * tv1) % p
- gx1 = (gx1 * x1n) % p
- gx1 = (gx1 + tv2) % p
- gx1 = (gx1 * x1n) % p
- tv3 = (gxd * gxd) % p
- tv2 = (tv3 * tv3) % p
- tv3 = (tv3 * gxd) % p
- tv3 = (tv3 * gx1) % p
- tv2 = (tv2 * tv3) % p
- y11 = pow(tv2, c4, p)
- y11 = (y11 * tv3) % p
- y12 = (y11 * c3) % p
- tv2 = (y11 * y11) % p
- tv2 = (tv2 * gxd) % p
- e1 = tv2 == gx1
- y1 = conditional_move(y12, y11, e1)
- x2n = (x1n * tv1) % p
- tv2 = (y1 * y1) % p
- tv2 = (tv2 * gxd) % p
- e3 = tv2 == gx1
- xn = conditional_move(x2n, x1n, e3)
- x = xn * pow(xd, p - 2, p) % p
- return encode_coordinate(x)
diff --git a/python/src/trezorlib/transport/thp/message_header.py b/python/src/trezorlib/transport/thp/message_header.py
deleted file mode 100644
index 525522f9..00000000
--- a/python/src/trezorlib/transport/thp/message_header.py
+++ /dev/null
@@ -1,112 +0,0 @@
-# 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 struct
-
-from typing_extensions import Self
-
-CODEC_V1 = 0x3F
-CONTINUATION_PACKET = 0x80
-HANDSHAKE_INIT_REQ = 0x00
-HANDSHAKE_INIT_RES = 0x01
-HANDSHAKE_COMP_REQ = 0x02
-HANDSHAKE_COMP_RES = 0x03
-ENCRYPTED_TRANSPORT = 0x04
-
-CONTINUATION_PACKET_MASK = 0x80
-ACK_MASK = 0xF7
-DATA_MASK = 0xE7
-
-ACK_MESSAGE = 0x20
-_ERROR = 0x42
-CHANNEL_ALLOCATION_REQ = 0x40
-_CHANNEL_ALLOCATION_RES = 0x41
-
-PING = 0x43
-PONG = 0x44
-
-TREZOR_STATE_UNPAIRED = b"\x00"
-TREZOR_STATE_PAIRED = b"\x01"
-
-BROADCAST_CHANNEL_ID = 0xFFFF
-
-
-class MessageHeader:
- format_str_init = ">BHH"
- format_str_cont = ">BH"
-
- def __init__(self, ctrl_byte: int, cid: int, length: int) -> None:
- self.ctrl_byte = ctrl_byte
- self.cid = cid
- self.data_length = length
-
- def to_bytes_init(self) -> bytes:
- return struct.pack(
- self.format_str_init, self.ctrl_byte, self.cid, self.data_length
- )
-
- def to_bytes_cont(self) -> bytes:
- return struct.pack(self.format_str_cont, CONTINUATION_PACKET, self.cid)
-
- def pack_to_init_buffer(self, buffer: bytearray, buffer_offset: int = 0) -> None:
- struct.pack_into(
- self.format_str_init,
- buffer,
- buffer_offset,
- self.ctrl_byte,
- self.cid,
- self.data_length,
- )
-
- def pack_to_cont_buffer(self, buffer: bytearray, buffer_offset: int = 0) -> None:
- struct.pack_into(
- self.format_str_cont, buffer, buffer_offset, CONTINUATION_PACKET, self.cid
- )
-
- def is_ack(self) -> bool:
- return self.ctrl_byte & ACK_MASK == ACK_MESSAGE
-
- def is_channel_allocation_response(self) -> bool:
- return (
- self.cid == BROADCAST_CHANNEL_ID
- and self.ctrl_byte == _CHANNEL_ALLOCATION_RES
- )
-
- def is_pong(self) -> bool:
- return self.cid == BROADCAST_CHANNEL_ID and self.ctrl_byte == PONG
-
- def is_handshake_init_response(self) -> bool:
- return self.ctrl_byte & DATA_MASK == HANDSHAKE_INIT_RES
-
- def is_handshake_comp_response(self) -> bool:
- return self.ctrl_byte & DATA_MASK == HANDSHAKE_COMP_RES
-
- def is_encrypted_transport(self) -> bool:
- return self.ctrl_byte & DATA_MASK == ENCRYPTED_TRANSPORT
-
- @classmethod
- def get_error_header(cls, cid: int, length: int) -> Self:
- return cls(_ERROR, cid, length)
-
- @classmethod
- def get_channel_allocation_request_header(cls, length: int) -> Self:
- return cls(CHANNEL_ALLOCATION_REQ, BROADCAST_CHANNEL_ID, length)
-
- @classmethod
- def get_ping_header(cls, length: int) -> Self:
- return cls(PING, BROADCAST_CHANNEL_ID, length)
diff --git a/python/src/trezorlib/transport/thp/protocol_v2.py b/python/src/trezorlib/transport/thp/protocol_v2.py
deleted file mode 100644
index 0ab6e2ea..00000000
--- a/python/src/trezorlib/transport/thp/protocol_v2.py
+++ /dev/null
@@ -1,427 +0,0 @@
-# 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 os
-import typing as t
-from binascii import hexlify
-
-from noise.connection import Keypair, NoiseConnection
-
-from ... import exceptions, messages, protobuf
-from ...mapping import ProtobufMapping
-from .. import Transport
-from ..thp import checksum, thp_io
-from ..thp.checksum import CHECKSUM_LENGTH
-from ..thp.message_header import MessageHeader
-from . import control_byte
-from .channel import Channel
-
-LOG = logging.getLogger(__name__)
-
-DEFAULT_SESSION_ID: int = 0
-
-MAX_RETRANSMISSION_COUNT = 50
-
-TREZOR_STATE_UNPAIRED = b"\x00"
-TREZOR_STATE_PAIRED = b"\x01"
-TREZOR_STATE_PAIRED_AUTOCONNECT = b"\x02"
-TREZOR_STATES = [
- TREZOR_STATE_UNPAIRED,
- TREZOR_STATE_PAIRED,
- TREZOR_STATE_PAIRED_AUTOCONNECT,
-]
-
-if t.TYPE_CHECKING:
- pass
-MT = t.TypeVar("MT", bound=protobuf.MessageType)
-
-
-class ProtocolV2Channel(Channel):
- channel_id: int
- sync_bit_send: int
- sync_bit_receive: int
- handshake_hash: bytes
- device_properties: bytes
-
- _features: messages.Features | None = None
- _is_paired: bool = False
-
- def __init__(
- self,
- transport: Transport,
- mapping: ProtobufMapping,
- credential: bytes | None = None,
- prepare_channel_without_pairing: bool = True,
- ) -> None:
- super().__init__(transport, mapping)
- self._reset_sync_bits()
- if prepare_channel_without_pairing:
- # allow skipping unrelated response packets (e.g. in case of retransmissions)
- self._do_channel_allocation(retries=MAX_RETRANSMISSION_COUNT)
- LOG.debug("THP channel allocated: %04x", self.channel_id)
- self._do_handshake(credential=credential)
- LOG.debug("THP handshake done: is_paired=%s", self._is_paired)
-
- def get_channel(self) -> ProtocolV2Channel:
- if not self._is_paired:
- raise RuntimeError("Channel is not paired")
- return self
-
- def read(self, session_id: int, timeout: float | None = None) -> t.Any:
- sid, msg_type, msg_data = self.read_and_decrypt(timeout)
- if sid != session_id:
- raise Exception(
- f"Received messsage on a different session (expected/received): ({session_id}/{sid}) "
- )
- return self.mapping.decode(msg_type, msg_data)
-
- def write(self, session_id: int, msg: t.Any) -> None:
- msg_type, msg_data = self.mapping.encode(msg)
- self._encrypt_and_write(session_id, msg_type, msg_data)
-
- def get_features(self) -> messages.Features:
- if not self._is_paired:
- raise RuntimeError("Channel is not paired")
- if self._features is None:
- self.update_features()
- assert self._features is not None
- return self._features
-
- def update_features(self, timeout: float | None = None) -> None:
- message = messages.GetFeatures()
- message_type, message_data = self.mapping.encode(message)
- self.session_id: int = DEFAULT_SESSION_ID
- self._encrypt_and_write(DEFAULT_SESSION_ID, message_type, message_data)
- header, _payload = self._read_until_valid_crc_check()
- if not header.is_ack():
- raise exceptions.TrezorException("ACK expected")
- _, msg_type, msg_data = self.read_and_decrypt(timeout)
- features = self.mapping.decode(msg_type, msg_data)
- if not isinstance(features, messages.Features):
- raise exceptions.TrezorException("Unexpected response to GetFeatures")
- self._features = features
-
- def _send_message(
- self,
- message: protobuf.MessageType,
- session_id: int = DEFAULT_SESSION_ID,
- ) -> None:
- message_type, message_data = self.mapping.encode(message)
- self._encrypt_and_write(session_id, message_type, message_data)
- self._read_ack()
-
- def _read_message(self, message_type: type[MT], timeout: float | None = None) -> MT:
- _, msg_type, msg_data = self.read_and_decrypt(timeout)
- msg = self.mapping.decode(msg_type, msg_data)
- assert isinstance(msg, message_type)
- return msg
-
- def _reset_sync_bits(self) -> None:
- self.sync_bit_send = 0
- self.sync_bit_receive = 0
-
- def sync_responses(
- self, retries: int = MAX_RETRANSMISSION_COUNT, timeout: float = 10.0
- ) -> None:
- """Make sure the event loop is running and ready."""
- nonce = os.urandom(8)
- thp_io.write_payload_to_wire_and_add_checksum(
- self.transport,
- MessageHeader.get_ping_header(len(nonce) + CHECKSUM_LENGTH),
- nonce,
- )
- for _ in range(1 + retries):
- header, payload = self._read_until_valid_crc_check(timeout=timeout)
- if self._is_valid_pong(header, payload, nonce):
- break
- else:
- raise RuntimeError("Invalid ping response")
-
- def _do_channel_allocation(self, retries: int = 0) -> None:
- channel_allocation_nonce = os.urandom(8)
- self._send_channel_allocation_request(channel_allocation_nonce)
- cid, dp = self._read_channel_allocation_response(
- channel_allocation_nonce, retries=retries
- )
- self.channel_id = cid
- self.device_properties = dp
-
- def _send_channel_allocation_request(self, nonce: bytes) -> None:
- thp_io.write_payload_to_wire_and_add_checksum(
- self.transport,
- MessageHeader.get_channel_allocation_request_header(
- len(nonce) + CHECKSUM_LENGTH
- ),
- nonce,
- )
-
- def _read_channel_allocation_response(
- self, expected_nonce: bytes, retries: int = 0
- ) -> tuple[int, bytes]:
- for _ in range(1 + retries):
- header, payload = self._read_until_valid_crc_check()
- if self._is_valid_channel_allocation_response(
- header, payload, expected_nonce
- ):
- break
- else:
- raise Exception("Invalid channel allocation response.")
-
- channel_id = int.from_bytes(payload[8:10], "big")
- device_properties = payload[10:]
- return (channel_id, device_properties)
-
- def _init_noise(
- self,
- randomness_static: bytes | None = None,
- randomness_ephemeral: bytes | None = None,
- ) -> None:
- randomness_static = randomness_static or os.urandom(32)
- self._noise = NoiseConnection.from_name(b"Noise_XX_25519_AESGCM_SHA256")
- self._noise.set_as_initiator()
- self._noise.set_keypair_from_private_bytes(Keypair.STATIC, randomness_static)
- if randomness_ephemeral is not None:
- self._noise.set_keypair_from_private_bytes(
- Keypair.EPHEMERAL, randomness_ephemeral
- )
- prologue = bytes(self.device_properties)
- self._noise.set_prologue(prologue)
- self._noise.start_handshake()
-
- def _do_handshake(
- self,
- credential: bytes | None = None,
- host_static_randomness: bytes | None = None,
- host_ephemeral_randomness: bytes | None = None,
- ) -> None:
-
- randomness_static = host_static_randomness or os.urandom(32)
- if host_ephemeral_randomness is not None:
- self._init_noise(randomness_static, host_ephemeral_randomness)
- else:
- self._init_noise(randomness_static)
- self._send_handshake_init_request()
- self._read_ack()
- self._read_handshake_init_response()
- self._send_handshake_completion_request(
- credential,
- )
- self._read_ack()
- return self._read_handshake_completion_response()
-
- def _send_handshake_init_request(self, try_to_unlock: bool = True) -> None:
- payload = self._noise.write_message(bytes([try_to_unlock]))
- ha_init_req_header = MessageHeader(
- 0, self.channel_id, len(payload) + CHECKSUM_LENGTH
- )
-
- thp_io.write_payload_to_wire_and_add_checksum(
- self.transport, ha_init_req_header, payload
- )
-
- def _read_handshake_init_response(self) -> bytes:
- header, payload = self._read_until_valid_crc_check()
-
- if not header.is_handshake_init_response():
- LOG.error("Received message is not a valid handshake init response message")
-
- self._send_ack_bit(bit=0)
- self._noise.read_message(payload)
- return payload
-
- def _send_handshake_completion_request(
- self,
- credential: bytes | None = None,
- ) -> None:
- # TODO implement key recognition
- # print(
- # "TREZOR's static pubkey:\n",
- # self.noise.noise_protocol.handshake_state.rs.public.public_bytes_raw(),
- # )
-
- msg_data = self.mapping.encode_without_wire_type(
- messages.ThpHandshakeCompletionReqNoisePayload(
- host_pairing_credential=credential,
- )
- )
- message2 = self._noise.write_message(payload=msg_data)
-
- ha_completion_req_header = MessageHeader(
- 0x12,
- self.channel_id,
- len(message2) + CHECKSUM_LENGTH,
- )
- thp_io.write_payload_to_wire_and_add_checksum(
- self.transport,
- ha_completion_req_header,
- message2, # encrypted_host_static_pubkey + encrypted_payload,
- )
- self.handshake_hash = self._noise.get_handshake_hash()
-
- def _read_handshake_completion_response(self) -> None:
- # Read handshake completion response
- header, data = self._read_until_valid_crc_check()
- if not header.is_handshake_comp_response():
- LOG.error("Received message is not a valid handshake completion response")
- trezor_state = self._noise.decrypt(bytes(data))
- assert trezor_state in TREZOR_STATES
- self._send_ack_bit(bit=1)
- self._is_paired = trezor_state != TREZOR_STATE_UNPAIRED
-
- def _read_ack(self) -> None:
- header, payload = self._read_until_valid_crc_check()
- if not header.is_ack() or len(payload) > 0:
- LOG.error("Received message is not a valid ACK")
-
- def _send_ack_bit(self, bit: int) -> None:
- if bit not in (0, 1):
- raise ValueError("Invalid ACK bit")
- LOG.debug(f"sending ack {bit}")
- ctrl_byte = 0x20 if bit == 0 else 0x28
- header = MessageHeader(ctrl_byte, self.channel_id, 4)
- thp_io.write_payload_to_wire_and_add_checksum(self.transport, header, b"")
-
- def _encrypt_and_write(
- self,
- session_id: int,
- message_type: int,
- message_data: bytes,
- ctrl_byte: int | None = None,
- ) -> None:
-
- if ctrl_byte is None:
- ctrl_byte = control_byte.add_seq_bit_to_ctrl_byte(0x04, self.sync_bit_send)
- self.sync_bit_send = 1 - self.sync_bit_send
-
- sid = session_id.to_bytes(1, "big")
- msg_type = message_type.to_bytes(2, "big")
- data = sid + msg_type + message_data
-
- encrypted_message = self._noise.encrypt(data)
-
- header = MessageHeader(
- ctrl_byte, self.channel_id, len(encrypted_message) + CHECKSUM_LENGTH
- )
-
- thp_io.write_payload_to_wire_and_add_checksum(
- self.transport, header, encrypted_message
- )
-
- def read_and_decrypt(
- self, timeout: float | None = None
- ) -> t.Tuple[int, int, bytes]:
- while True:
- header, raw_payload = self._read_until_valid_crc_check(timeout)
- if header.cid != self.channel_id:
- # Received message from different channel - discard
- continue
- if control_byte.is_ack(header.ctrl_byte):
- continue
- if not header.is_encrypted_transport():
- LOG.error(
- "Trying to decrypt not encrypted message! ("
- + hexlify(header.to_bytes_init() + raw_payload).decode()
- + ")"
- )
-
- seq_bit = control_byte.get_seq_bit(header.ctrl_byte)
- assert seq_bit is not None
- LOG.debug(
- "--> Get sequence bit %d %s %s",
- seq_bit,
- "from control byte",
- hexlify(header.ctrl_byte.to_bytes(1, "big")).decode(),
- )
- self._send_ack_bit(bit=seq_bit)
-
- message = self._noise.decrypt(bytes(raw_payload))
- session_id = message[0]
- message_type = message[1:3]
- message_data = message[3:]
- return (
- session_id,
- int.from_bytes(message_type, "big"),
- message_data,
- )
-
- def _read_until_valid_crc_check(
- self, timeout: float | None = None
- ) -> t.Tuple[MessageHeader, bytes]:
- if timeout is None:
- timeout = self._DEFAULT_READ_TIMEOUT
-
- while True:
- header, payload, chksum = thp_io.read(self.transport, timeout)
- if not checksum.is_valid(chksum, header.to_bytes_init() + payload):
- LOG.error(
- "Received a message with an invalid checksum:"
- + hexlify(header.to_bytes_init() + payload + chksum).decode()
- )
- continue
-
- seq_bit = control_byte.get_seq_bit(header.ctrl_byte)
- if seq_bit is not None:
- if seq_bit != self.sync_bit_receive:
- LOG.warning(
- "Received unexpected message: sync bit=%d, expected=%d",
- seq_bit,
- self.sync_bit_receive,
- )
- continue
-
- self.sync_bit_receive = 1 - self.sync_bit_receive
-
- if control_byte.is_error(header.ctrl_byte):
- code = payload[0]
- raise _ERRORS_MAP.get(code) or exceptions.ThpUnknownError(code)
-
- return header, payload
-
- def _is_valid_channel_allocation_response(
- self, header: MessageHeader, payload: bytes, original_nonce: bytes
- ) -> bool:
- if not header.is_channel_allocation_response():
- LOG.error("Received message is not a channel allocation response")
- return False
- if len(payload) < 10:
- LOG.error("Invalid channel allocation response payload")
- return False
- if payload[:8] != original_nonce:
- LOG.error("Invalid channel allocation response payload (nonce mismatch)")
- return False
- return True
-
- def _is_valid_pong(
- self, header: MessageHeader, payload: bytes, original_nonce: bytes
- ) -> bool:
- if not header.is_pong():
- LOG.error("Received message is not a pong")
- return False
- if payload != original_nonce:
- LOG.error("Invalid pong payload (nonce mismatch)")
- return False
- return True
-
-
-_ERRORS_MAP = {
- 1: exceptions.TransportBusy,
- 2: exceptions.UnallocatedChannel,
- 3: exceptions.DecryptionFailed,
- 5: exceptions.DeviceLocked,
-}
diff --git a/python/src/trezorlib/transport/thp/thp_io.py b/python/src/trezorlib/transport/thp/thp_io.py
deleted file mode 100644
index e0dd60aa..00000000
--- a/python/src/trezorlib/transport/thp/thp_io.py
+++ /dev/null
@@ -1,121 +0,0 @@
-# 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 struct
-
-from ...exceptions import ThpError
-from .. import Transport
-from ..thp import checksum
-from .message_header import MessageHeader
-
-INIT_HEADER_LENGTH = 5
-CONT_HEADER_LENGTH = 3
-MAX_PAYLOAD_LEN = 60000
-MESSAGE_TYPE_LENGTH = 2
-
-CONTINUATION_PACKET = 0x80
-
-
-def write_payload_to_wire_and_add_checksum(
- transport: Transport, header: MessageHeader, payload: bytes
-) -> None:
- chksum = checksum.compute(header.to_bytes_init() + payload)
- data = payload + chksum
- if len(data) > MAX_PAYLOAD_LEN:
- raise RuntimeError("Message too large")
- write_payload_to_wire(transport, header, data)
-
-
-def write_payload_to_wire(
- transport: Transport, header: MessageHeader, payload: bytes
-) -> None:
- if transport.CHUNK_SIZE is None:
- transport.write_chunk(payload)
- return
-
- chunk = (
- header.to_bytes_init() + payload[: transport.CHUNK_SIZE - INIT_HEADER_LENGTH]
- )
- chunk = chunk.ljust(transport.CHUNK_SIZE, b"\x00")
- transport.write_chunk(chunk)
-
- buffer = payload[transport.CHUNK_SIZE - INIT_HEADER_LENGTH :]
- while buffer:
- chunk = (
- header.to_bytes_cont() + buffer[: transport.CHUNK_SIZE - CONT_HEADER_LENGTH]
- )
- chunk = chunk.ljust(transport.CHUNK_SIZE, b"\x00")
- transport.write_chunk(chunk)
- buffer = buffer[transport.CHUNK_SIZE - CONT_HEADER_LENGTH :]
-
-
-def read(
- transport: Transport, timeout: float | None = None
-) -> tuple[MessageHeader, bytes, bytes]:
- """
- Reads from the given wire transport.
-
- Returns `Tuple[MessageHeader, bytes, bytes]`:
- 1. `header` (`MessageHeader`): Header of the message.
- 2. `data` (`bytes`): Contents of the message (if any).
- 3. `checksum` (`bytes`): crc32 checksum of the header + data.
-
- """
- buffer = bytearray()
-
- # Read header with first part of message data
- header, first_chunk = read_first(transport, timeout)
- buffer.extend(first_chunk)
-
- # Read the rest of the message
- while len(buffer) < header.data_length:
- buffer.extend(read_next(transport, header.cid, timeout))
-
- data_len = header.data_length - checksum.CHECKSUM_LENGTH
- msg_data = buffer[:data_len]
- chksum = buffer[data_len : data_len + checksum.CHECKSUM_LENGTH]
-
- return (header, bytes(msg_data), bytes(chksum))
-
-
-def read_first(
- transport: Transport, timeout: float | None = None
-) -> tuple[MessageHeader, bytes]:
- chunk = transport.read_chunk(timeout)
- try:
- ctrl_byte, cid, data_length = struct.unpack(
- MessageHeader.format_str_init, chunk[:INIT_HEADER_LENGTH]
- )
- except Exception:
- raise RuntimeError("Cannot parse header")
-
- data = chunk[INIT_HEADER_LENGTH:]
- return MessageHeader(ctrl_byte, cid, data_length), data
-
-
-def read_next(transport: Transport, cid: int, timeout: float | None = None) -> bytes:
- chunk = transport.read_chunk(timeout)
- ctrl_byte, read_cid = struct.unpack(
- MessageHeader.format_str_cont, chunk[:CONT_HEADER_LENGTH]
- )
- if ctrl_byte != CONTINUATION_PACKET:
- raise ThpError("Continuation packet with incorrect control byte")
- if read_cid != cid:
- raise ThpError("Continuation packet for different channel")
-
- return chunk[CONT_HEADER_LENGTH:]
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.