eth: update python api to support streaming large transactions
What changed, and why it matters
This commit updates the BitBox02 Python client library to support signing Ethereum transactions whose extra data field is larger than 6,144 bytes. Instead of sending the whole transaction data in one message, the client now breaks it into chunks requested by the hardware wallet. The change is a feature addition in the Python API and test script; it does not by itself fix a known vulnerability, but it introduces new code paths that handle large transaction data and must correctly validate chunk boundaries.
Treat as a normal feature commit. Reviewers should verify that `_handle_eth_chunking()` cannot be driven into an infinite loop by a malicious or buggy device, that offset/length validation is robust, and that the antiklepto and non-antiklepto paths both reject unexpected response types after chunking. No immediate security patch is indicated by the available evidence.
Security signals we found
New chunking protocol handler added in client library
Boundary check on chunk offset/length against transaction_data length
Unexpected-response checks after chunking loop
Firmware version gating (>=9.26.0) for streaming large transactions
No vendor disclosure of a security bug in commit message or diff
Evidence from the diff
The patch adds _handle_eth_chunking() to the Python BitBox02 class to service device-driven chunk requests for large ETH transaction payloads. eth_sign() and eth_sign_eip1559() now set data_length and send an empty data field when len(data) > 6144, then stream the data in chunks. The non-antiklepto path also checks that the final response is sign, and the antiklepto path checks for antiklepto_signer_commitment. A 10 KB streaming test case is added to send_message.py, and rlp is added as a dependency. The commit requires firmware >= 9.26.0 for streaming.
Changed components
py/bitbox02/bitbox02/bitbox02/bitbox02.pypy/send_message.pypy/requirements.txtInspect captured patch +101 / −8
diff --git a/py/bitbox02/bitbox02/bitbox02/bitbox02.py b/py/bitbox02/bitbox02/bitbox02/bitbox02.py
index 007f042..dc10b8f 100644
--- a/py/bitbox02/bitbox02/bitbox02/bitbox02.py
+++ b/py/bitbox02/bitbox02/bitbox02/bitbox02.py
@@ -809,6 +809,40 @@ class BitBox02(BitBoxCommonAPI):
)
return eth_response
+ def _handle_eth_chunking(
+ self, initial_response: eth.ETHResponse, transaction_data: bytes
+ ) -> eth.ETHResponse:
+ """
+ Handle chunk request/response loop for ETH transactions with large data.
+ Returns the final response after all chunks have been sent.
+ """
+ # pylint: disable=no-member
+ response = initial_response
+
+ while response.WhichOneof("response") == "data_request_chunk":
+ chunk_request = response.data_request_chunk
+ offset = chunk_request.offset
+ length = chunk_request.length
+
+ if self.debug:
+ print(f"Chunk request: offset={offset}, length={length}")
+
+ if offset > len(transaction_data) or offset + length > len(transaction_data):
+ raise Exception(
+ f"Invalid chunk request: offset={offset}, length={length}, data_len={len(transaction_data)}"
+ )
+
+ chunk = transaction_data[offset : offset + length]
+
+ if self.debug:
+ print(f"Sending chunk: {len(chunk)} bytes")
+
+ request = eth.ETHRequest()
+ request.data_response_chunk.CopyFrom(eth.ETHSignDataResponseChunkRequest(chunk=chunk))
+ response = self._eth_msg_query(request)
+
+ return response
+
def _eth_coin(self, chain_id: int) -> "eth.ETHCoin.V":
"""Returns the deprecated `coin` enum value for a given chain_id. Only ETH, Ropsten and Rinkeby are converted, as these were the only supported networks up to v9.10.0. With v9.10.0, the chain ID is passed directly, and the `coin` field is ignored."""
if self.version < semver.VersionInfo(9, 10, 0):
@@ -868,9 +902,18 @@ class BitBox02(BitBoxCommonAPI):
else:
request.sign.host_nonce_commitment.commitment = antiklepto_host_commit(host_nonce)
- signer_commitment = self._eth_msg_query(
- request, expected_response="antiklepto_signer_commitment"
- ).antiklepto_signer_commitment.commitment
+ response = self._eth_msg_query(request)
+
+ # Handle chunk requests if streaming
+ while response.WhichOneof("response") == "data_request_chunk":
+ response = self._handle_eth_chunking(response, data)
+
+ if response.WhichOneof("response") != "antiklepto_signer_commitment":
+ raise Exception(
+ f"Unexpected response: {response.WhichOneof('response')}, expected: antiklepto_signer_commitment"
+ )
+
+ signer_commitment = response.antiklepto_signer_commitment.commitment
request = eth.ETHRequest()
request.antiklepto_signature.CopyFrom(
@@ -885,6 +928,9 @@ class BitBox02(BitBoxCommonAPI):
return signature
+ # Streaming threshold: use chunking for data larger than this
+ streaming_threshold = 6144
+
if is_eip1559:
self._require_atleast(semver.VersionInfo(9, 16, 0))
(
@@ -905,6 +951,11 @@ class BitBox02(BitBoxCommonAPI):
raise Exception(
f"chainID argument ({chain_id}) does not match chainID encoded in transaction ({decoded_chain_id_int})"
)
+
+ require_streaming = len(data) > streaming_threshold
+ if require_streaming:
+ self._require_atleast(semver.VersionInfo(9, 26, 0))
+
request = eth.ETHRequest()
# pylint: disable=no-member
request.sign_eip1559.CopyFrom(
@@ -917,13 +968,19 @@ class BitBox02(BitBoxCommonAPI):
gas_limit=gas_limit,
recipient=recipient,
value=value,
- data=data,
+ data=b"" if require_streaming else data,
address_case=address_case,
+ data_length=len(data) if require_streaming else 0,
)
)
return handle_antiklepto(request)
nonce, gas_price, gas_limit, recipient, value, data, _, _, _ = rlp.decode(transaction)
+
+ require_streaming = len(data) > streaming_threshold
+ if require_streaming:
+ self._require_atleast(semver.VersionInfo(9, 26, 0))
+
request = eth.ETHRequest()
# pylint: disable=no-member
request.sign.CopyFrom(
@@ -936,8 +993,9 @@ class BitBox02(BitBoxCommonAPI):
gas_limit=gas_limit,
recipient=recipient,
value=value,
- data=data,
+ data=b"" if require_streaming else data,
address_case=address_case,
+ data_length=len(data) if require_streaming else 0,
)
)
@@ -945,7 +1003,19 @@ class BitBox02(BitBoxCommonAPI):
if supports_antiklepto:
return handle_antiklepto(request)
- return self._eth_msg_query(request, expected_response="sign").sign.signature
+ # Non-antiklepto path: handle chunking if needed
+ response = self._eth_msg_query(request)
+ if require_streaming and response.WhichOneof("response") == "data_request_chunk":
+ response = self._handle_eth_chunking(response, data)
+ if response.WhichOneof("response") != "sign":
+ raise Exception(
+ f"Unexpected response after chunking: {response.WhichOneof('response')}, expected: sign"
+ )
+ elif response.WhichOneof("response") != "sign":
+ raise Exception(
+ f"Unexpected response: {response.WhichOneof('response')}, expected: sign"
+ )
+ return response.sign.signature
def eth_sign_msg(self, msg: bytes, keypath: Sequence[int], chain_id: int = 1) -> bytes:
"""
diff --git a/py/requirements.txt b/py/requirements.txt
index 2323017..e5abd1b 100644
--- a/py/requirements.txt
+++ b/py/requirements.txt
@@ -4,3 +4,4 @@ types-tzlocal
bitbox02
requests
types-requests
+rlp
diff --git a/py/send_message.py b/py/send_message.py
index fe60dc7..538e257 100755
--- a/py/send_message.py
+++ b/py/send_message.py
@@ -37,6 +37,13 @@ from bitbox02.communication import (
import u2f
import u2f.bitbox02
+try:
+ # Optional rlp dependency only needed to sign ethereum transactions.
+ # pylint: disable=import-error
+ import rlp
+except ModuleNotFoundError:
+ pass
+
def eprint(*args: Any, **kwargs: Any) -> None:
"""
@@ -1075,10 +1082,10 @@ class SendMessage:
eprint("Aborted by user")
def _sign_eth_tx(self) -> None:
- # pylint: disable=line-too-long
+ # pylint: disable=line-too-long,too-many-branches
inp = input(
- "Select one of: 1=normal; 2=erc20; 3=erc721; 4=unknown erc20; 5=large data field; 6=BSC; 7=unknown network; 8=eip1559; 9=Arbitrum: "
+ "Select one of: 1=normal; 2=erc20; 3=erc721; 4=unknown erc20; 5=large data field; 6=BSC; 7=unknown network; 8=eip1559; 9=Arbitrum; 10=streaming (10KB data): "
).strip()
chain_id = 1 # mainnet
@@ -1121,6 +1128,21 @@ class SendMessage:
tx = binascii.unhexlify(
"02f0010184773594008502540be40082520894d61054f4456d0555dc2dd82b77f7ad6074836149865af3107a400080808080"
)
+ elif inp == "10":
+ nonce = b"\x01"
+ gas_price = b"\x04\xa8\x17\xc8\x00" # 20 gwei
+ gas_limit = b"\x0f\x42\x40" # 1,000,000
+ recipient = (
+ b"\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44"
+ )
+ value = b"" # Empty for zero value (no leading zeros allowed)
+ data = bytes([i % 256 for i in range(10000)])
+ v = b"\x25" # chain_id=1
+ r = b"\x01" * 32
+ s = b"\x01" * 32
+ tx = rlp.encode([nonce, gas_price, gas_limit, recipient, value, data, v, r, s])
+ if self._debug:
+ print(f"Streaming test transaction: {len(data)} bytes of data")
else:
print("None selected")
return
Why this scored 21/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.