eth: implement data producer for streaming
What changed, and why it matters
This commit adds support for signing Ethereum transactions with very large 'data' fields by streaming the data in 4 KB chunks instead of loading it all at once. It also adds safety checks: streamed and inline data cannot both be provided, and streamed data is capped at 1 MB. The change is a feature/refactoring with embedded defensive checks, but it is not described by the vendor as a security fix.
Review the IPC request/response pairing for the new DataRequestChunk/DataResponseChunk messages to ensure the host cannot inject malformed chunks, truncate responses, or cause the device to hash different data than the user approved. Verify that the 1 MB cap and chunk-length validation are enforced in all code paths and that the UI confirmation accurately reflects the data being signed.
Security signals we found
Adds streaming/chunking of large transaction data to avoid loading up to 1 MB into memory at once
Adds mutual-exclusion check preventing both inline `data` and `data_length` streaming from being active simultaneously
Adds explicit size caps: 6144 bytes for inline data, 1 MB for streamed data
Propagates errors from IPC chunking through the sighash call chain instead of silently ignoring failures
Validates received chunk length matches requested chunk length and rejects wrong response types
Changes user confirmation message for large streamed data to indicate size rather than showing raw hex
Evidence from the diff
The patch refactors Ethereum sighash computation in the BitBox02 firmware to use a DataProducer trait with two implementations: SimpleProducer (inline small data) and ChunkingProducer (4 KB IPC chunks for large data). Error handling is propagated via Result instead of Option. sign.rs selects ChunkingProducer when request.data_length > 0, otherwise SimpleProducer. New validation in _process enforces mutual exclusion between inline data and streaming data, limits non-streaming data to 6144 bytes, limits streaming data to 1 MB, and shows a size-based confirmation when data is too large to display.
Changed components
src/rust/bitbox02-rust/src/hww/api/ethereum/sighash.rssrc/rust/bitbox02-rust/src/hww/api/ethereum/sign.rsEthereum transaction signing flowEthereum sighash/RLP hashingInspect captured patch +221 / −67
diff --git a/src/rust/bitbox02-rust/src/hww/api/ethereum/sighash.rs b/src/rust/bitbox02-rust/src/hww/api/ethereum/sighash.rs
index 471739b..af7d2d9 100644
--- a/src/rust/bitbox02-rust/src/hww/api/ethereum/sighash.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/ethereum/sighash.rs
@@ -10,16 +10,19 @@ use core::ops::DerefMut;
use alloc::vec::Vec;
+use super::Error;
+
/// An async producer/generator of a bytes array. This is used to be able to accumulate the RLP hash
/// of the `data` field, which can be very large and has to be streamed in chunks in that case.
pub trait DataProducer {
+ type Error;
/// Returns the length of the data.
- fn len(&self) -> usize;
+ fn len(&self) -> u32;
/// Returns the first byte of the data.
fn first_byte(&self) -> u8;
- /// Produces a chunk of the data. Returns `Some` if data was available, and `None` when there
- /// are no more chunks.
- async fn next(&mut self) -> Option<Vec<u8>>;
+ /// Produces a chunk of the data. Returns `Ok(Some(data))` if data was available,
+ /// `Ok(None)` when there are no more chunks, or `Err` on failure.
+ async fn next(&mut self) -> Result<Option<Vec<u8>>, Self::Error>;
}
/// Produces a byte slice in one shot.
@@ -32,20 +35,88 @@ impl<'a> SimpleProducer<'a> {
}
impl<'a> DataProducer for SimpleProducer<'a> {
- fn len(&self) -> usize {
- self.0.len()
+ type Error = Error;
+
+ fn len(&self) -> u32 {
+ self.0.len() as u32
}
fn first_byte(&self) -> u8 {
self.0[0]
}
- async fn next(&mut self) -> Option<Vec<u8>> {
+ async fn next(&mut self) -> Result<Option<Vec<u8>>, Self::Error> {
if !self.1 {
self.1 = true;
- Some(self.0.to_vec())
+ Ok(Some(self.0.to_vec()))
} else {
- None
+ Ok(None)
+ }
+ }
+}
+
+pub struct ChunkingProducer {
+ total_length: u32,
+ offset: u32,
+ first_byte_cached: Option<u8>,
+}
+
+impl ChunkingProducer {
+ pub fn new(total_length: u32) -> Self {
+ Self {
+ total_length,
+ offset: 0,
+ first_byte_cached: None,
+ }
+ }
+}
+
+impl DataProducer for ChunkingProducer {
+ type Error = Error;
+
+ fn len(&self) -> u32 {
+ self.total_length
+ }
+
+ fn first_byte(&self) -> u8 {
+ self.first_byte_cached.unwrap()
+ }
+
+ async fn next(&mut self) -> Result<Option<Vec<u8>>, Self::Error> {
+ if self.offset >= self.total_length {
+ return Ok(None);
+ }
+
+ const CHUNK_SIZE: u32 = 4096;
+ let remaining = self.total_length - self.offset;
+ let chunk_length = core::cmp::min(CHUNK_SIZE, remaining);
+
+ let response = super::next_request(super::pb::eth_response::Response::DataRequestChunk(
+ super::pb::EthSignDataRequestChunkResponse {
+ offset: self.offset,
+ length: chunk_length,
+ },
+ ))
+ .await?;
+
+ match response {
+ super::pb::eth_request::Request::DataResponseChunk(
+ super::pb::EthSignDataResponseChunkRequest { chunk },
+ ) => {
+ // Error: chunk size mismatch
+ if chunk.len() != chunk_length as usize {
+ return Err(Error::InvalidInput);
+ }
+
+ if self.offset == 0 && !chunk.is_empty() {
+ self.first_byte_cached = Some(chunk[0]);
+ }
+
+ self.offset += chunk.len() as u32;
+ Ok(Some(chunk))
+ }
+ // Error: wrong response type
+ _ => Err(Error::InvalidInput),
}
}
}
@@ -75,7 +146,10 @@ trait Write {
// Writes the given data to the writer.
fn write(&mut self, data: &[u8]);
// Same as `write`, but it writes all the data produced by the async data producer.
- async fn write_producer<D: DataProducer, T: DerefMut<Target = D>>(&mut self, producer: T);
+ async fn write_producer<D: DataProducer, T: DerefMut<Target = D>>(
+ &mut self,
+ producer: T,
+ ) -> Result<(), D::Error>;
}
struct Hasher(Keccak256);
@@ -85,10 +159,14 @@ impl Write for Hasher {
self.0.update(data);
}
- async fn write_producer<D: DataProducer, T: DerefMut<Target = D>>(&mut self, mut producer: T) {
- while let Some(data) = producer.next().await {
+ async fn write_producer<D: DataProducer, T: DerefMut<Target = D>>(
+ &mut self,
+ mut producer: T,
+ ) -> Result<(), D::Error> {
+ while let Some(data) = producer.next().await? {
self.0.update(&data);
}
+ Ok(())
}
}
@@ -99,8 +177,12 @@ impl Write for Counter {
self.0 += data.len() as u32;
}
- async fn write_producer<D: DataProducer, T: DerefMut<Target = D>>(&mut self, producer: T) {
- self.0 += producer.len() as u32;
+ async fn write_producer<D: DataProducer, T: DerefMut<Target = D>>(
+ &mut self,
+ producer: T,
+ ) -> Result<(), D::Error> {
+ self.0 += producer.len();
+ Ok(())
}
}
@@ -130,13 +212,13 @@ fn hash_element<W: Write>(writer: &mut W, bytes: &[u8]) {
async fn hash_producer<W: Write, D: DataProducer, T: DerefMut<Target = D>>(
writer: &mut W,
producer: T,
-) {
+) -> Result<(), D::Error> {
// hash header
let len = producer.len();
if len != 1 || producer.first_byte() > 0x7f {
hash_header(writer, 0x80, 0xb7, len as _);
}
- writer.write_producer(producer).await;
+ writer.write_producer(producer).await
}
fn hash_u64<W: Write>(writer: &mut W, value: u64) {
@@ -151,25 +233,26 @@ fn hash_u64<W: Write>(writer: &mut W, value: u64) {
async fn hash_params_legacy<W: Write, D: DataProducer>(
writer: &mut W,
params: &ParamsLegacy<'_, D>,
-) {
+) -> Result<(), D::Error> {
hash_element(writer, params.nonce);
hash_element(writer, params.gas_price);
hash_element(writer, params.gas_limit);
hash_element(writer, params.recipient);
hash_element(writer, params.value);
- hash_producer(writer, params.data.borrow_mut()).await;
+ hash_producer(writer, params.data.borrow_mut()).await?;
{
// EIP155, encodes <chainID><0><0>
hash_u64(writer, params.chain_id);
hash_u64(writer, 0);
hash_u64(writer, 0);
}
+ Ok(())
}
async fn hash_params_eip1559<W: Write, D: DataProducer>(
writer: &mut W,
params: &ParamsEIP1559<'_, D>,
-) {
+) -> Result<(), D::Error> {
hash_u64(writer, params.chain_id);
hash_element(writer, params.nonce);
hash_element(writer, params.max_priority_fee_per_gas);
@@ -177,8 +260,9 @@ async fn hash_params_eip1559<W: Write, D: DataProducer>(
hash_element(writer, params.gas_limit);
hash_element(writer, params.recipient);
hash_element(writer, params.value);
- hash_producer(writer, params.data.borrow_mut()).await;
+ hash_producer(writer, params.data.borrow_mut()).await?;
hash_header(writer, RLP_SMALL_TAG, RLP_LARGE_TAG, 0); // access list not currently supported and hashed as empty list
+ Ok(())
}
/// Computes the sighash of an Ethereum transaction, using the chain_id as described in EIP155.
@@ -186,29 +270,31 @@ async fn hash_params_eip1559<W: Write, D: DataProducer>(
/// not allowed to have leading zeros (unchecked).
///
/// See https://github.com/ethereum/wiki/wiki/RLP
-pub async fn compute_legacy<D: DataProducer>(params: &ParamsLegacy<'_, D>) -> Result<[u8; 32], ()> {
+pub async fn compute_legacy<D: DataProducer<Error = Error>>(
+ params: &ParamsLegacy<'_, D>,
+) -> Result<[u8; 32], Error> {
// We hash [nonce, gas price, gas limit, recipient, value, data], RLP encoded.
// The list length prefix is (0xc0 + length of the encoding of all elements).
// 1) calculate length
let mut counter = Counter(0);
- hash_params_legacy(&mut counter, params).await;
+ hash_params_legacy(&mut counter, params).await?;
if counter.0 > 0xffff {
// Don't support bigger than this for now.
- return Err(());
+ return Err(Error::InvalidInput);
}
// 2) hash len and encoded tx elements
let mut hasher = Hasher(Keccak256::new());
hash_header(&mut hasher, RLP_SMALL_TAG, RLP_LARGE_TAG, counter.0 as u16);
- hash_params_legacy(&mut hasher, params).await;
+ hash_params_legacy(&mut hasher, params).await?;
Ok(hasher.0.finalize().into())
}
-pub async fn compute_eip1559<D: DataProducer>(
+pub async fn compute_eip1559<D: DataProducer<Error = Error>>(
params: &ParamsEIP1559<'_, D>,
-) -> Result<[u8; 32], ()> {
+) -> Result<[u8; 32], Error> {
// https://eips.ethereum.org/EIPS/eip-1559
// We hash [chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas limit, recipient, value, data, access list]
// RLP encoded. Prefixed with 0x02 for EIP1559 transaction type
@@ -216,18 +302,18 @@ pub async fn compute_eip1559<D: DataProducer>(
// 1) calculate length
let mut counter = Counter(0);
- hash_params_eip1559(&mut counter, params).await;
+ hash_params_eip1559(&mut counter, params).await?;
if counter.0 > 0xffff {
// Don't support bigger than this for now.
- return Err(());
+ return Err(Error::InvalidInput);
}
// 2) hash len and encoded tx elements
let mut hasher = Hasher(Keccak256::new());
hasher.write(&[0x02]); // prefix the rlp encoding with transaction type before hashing
hash_header(&mut hasher, RLP_SMALL_TAG, RLP_LARGE_TAG, counter.0 as u16);
- hash_params_eip1559(&mut hasher, params).await;
+ hash_params_eip1559(&mut hasher, params).await?;
Ok(hasher.0.finalize().into())
}
diff --git a/src/rust/bitbox02-rust/src/hww/api/ethereum/sign.rs b/src/rust/bitbox02-rust/src/hww/api/ethereum/sign.rs
index 2c4ad13..5ccdc4b 100644
--- a/src/rust/bitbox02-rust/src/hww/api/ethereum/sign.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/ethereum/sign.rs
@@ -87,6 +87,12 @@ impl Transaction<'_> {
}
}
}
+ fn data_length(&self) -> u32 {
+ match self {
+ Transaction::Legacy(legacy) => legacy.data_length,
+ Transaction::Eip1559(eip1559) => eip1559.data_length,
+ }
+ }
}
/// Converts `recipient` to an array of 20 chars. If `recipient` is
@@ -156,34 +162,69 @@ fn parse_fee<'a>(request: &Transaction<'_>, params: &'a Params) -> Amount<'a> {
}
async fn hash_legacy(chain_id: u64, request: &pb::EthSignRequest) -> Result<[u8; 32], Error> {
- let hash = super::sighash::compute_legacy(&super::sighash::ParamsLegacy {
- nonce: &request.nonce,
- gas_price: &request.gas_price,
- gas_limit: &request.gas_limit,
- recipient: &request.recipient,
- value: &request.value,
- data: core::cell::RefCell::new(super::sighash::SimpleProducer::new(&request.data)),
- chain_id,
- })
- .await
- .map_err(|_| Error::InvalidInput)?;
- Ok(hash)
+ if request.data_length > 0 {
+ let hash = super::sighash::compute_legacy(&super::sighash::ParamsLegacy {
+ nonce: &request.nonce,
+ gas_price: &request.gas_price,
+ gas_limit: &request.gas_limit,
+ recipient: &request.recipient,
+ value: &request.value,
+ data: core::cell::RefCell::new(super::sighash::ChunkingProducer::new(
+ request.data_length,
+ )),
+ chain_id,
+ })
+ .await
+ .map_err(|_| Error::InvalidInput)?;
+ Ok(hash)
+ } else {
+ let hash = super::sighash::compute_legacy(&super::sighash::ParamsLegacy {
+ nonce: &request.nonce,
+ gas_price: &request.gas_price,
+ gas_limit: &request.gas_limit,
+ recipient: &request.recipient,
+ value: &request.value,
+ data: core::cell::RefCell::new(super::sighash::SimpleProducer::new(&request.data)),
+ chain_id,
+ })
+ .await
+ .map_err(|_| Error::InvalidInput)?;
+ Ok(hash)
+ }
}
async fn hash_eip1559(request: &pb::EthSignEip1559Request) -> Result<[u8; 32], Error> {
- let hash = super::sighash::compute_eip1559(&super::sighash::ParamsEIP1559 {
- chain_id: request.chain_id,
- nonce: &request.nonce,
- max_priority_fee_per_gas: &request.max_priority_fee_per_gas,
- max_fee_per_gas: &request.max_fee_per_gas,
- gas_limit: &request.gas_limit,
- recipient: &request.recipient,
- value: &request.value,
- data: core::cell::RefCell::new(super::sighash::SimpleProducer::new(&request.data)),
- })
- .await
- .map_err(|_| Error::InvalidInput)?;
- Ok(hash)
+ if request.data_length > 0 {
+ let hash = super::sighash::compute_eip1559(&super::sighash::ParamsEIP1559 {
+ chain_id: request.chain_id,
+ nonce: &request.nonce,
+ max_priority_fee_per_gas: &request.max_priority_fee_per_gas,
+ max_fee_per_gas: &request.max_fee_per_gas,
+ gas_limit: &request.gas_limit,
+ recipient: &request.recipient,
+ value: &request.value,
+ data: core::cell::RefCell::new(super::sighash::ChunkingProducer::new(
+ request.data_length,
+ )),
+ })
+ .await
+ .map_err(|_| Error::InvalidInput)?;
+ Ok(hash)
+ } else {
+ let hash = super::sighash::compute_eip1559(&super::sighash::ParamsEIP1559 {
+ chain_id: request.chain_id,
+ nonce: &request.nonce,
+ max_priority_fee_per_gas: &request.max_priority_fee_per_gas,
+ max_fee_per_gas: &request.max_fee_per_gas,
+ gas_limit: &request.gas_limit,
+ recipient: &request.recipient,
+ value: &request.value,
+ data: core::cell::RefCell::new(super::sighash::SimpleProducer::new(&request.data)),
+ })
+ .await
+ .map_err(|_| Error::InvalidInput)?;
+ Ok(hash)
+ }
}
/// Verifies an ERC20 transfer.
@@ -239,11 +280,17 @@ async fn verify_standard_transaction(
) -> Result<(), Error> {
let recipient = parse_recipient(request.recipient())?;
- if !request.data().is_empty() {
+ let data_length = request.data_length();
+
+ if !request.data().is_empty() || data_length > 0 {
hal.ui()
.confirm(&confirm::Params {
title: "Unknown\ncontract",
- body: "You will be shown\nthe raw\ntransaction data.",
+ body: if data_length > 0 {
+ "You are signing a\ncontract interaction\nwith large data."
+ } else {
+ "You will be shown\nthe raw\ntransaction data."
+ },
accept_is_nextarrow: true,
..Default::default()
})
@@ -257,16 +304,29 @@ async fn verify_standard_transaction(
})
.await?;
- hal.ui()
- .confirm(&confirm::Params {
- title: "Transaction\ndata",
- body: &hex::encode(request.data()),
- scrollable: true,
- display_size: request.data().len(),
- accept_is_nextarrow: true,
- ..Default::default()
- })
- .await?;
+ if data_length > 0 {
+ // Streaming mode: data is too large to display, show size instead
+ hal.ui()
+ .confirm(&confirm::Params {
+ title: "Transaction\ndata",
+ body: &alloc::format!("{} bytes\n(too large to\ndisplay)", data_length),
+ accept_is_nextarrow: true,
+ ..Default::default()
+ })
+ .await?;
+ } else {
+ // Traditional mode: show hex data
+ hal.ui()
+ .confirm(&confirm::Params {
+ title: "Transaction\ndata",
+ body: &hex::encode(request.data()),
+ scrollable: true,
+ display_size: request.data().len(),
+ accept_is_nextarrow: true,
+ ..Default::default()
+ })
+ .await?;
+ }
}
let address = super::address::from_pubkey_hash(&recipient, request.case()?);
@@ -315,14 +375,22 @@ pub async fn _process(
}
// Size limits.
+ const MAX_STREAMING_DATA_LENGTH: u32 = 1024 * 1024;
+ const MAX_NONSTREAMING_DATA_LENGTH: usize = 6144;
if request.nonce().len() > 16
|| request.gas_limit().len() > 16
|| request.value().len() > 32
- || request.data().len() > 6144
+ || request.data().len() > MAX_NONSTREAMING_DATA_LENGTH
+ || request.data_length() > MAX_STREAMING_DATA_LENGTH
{
return Err(Error::InvalidInput);
}
+ // Can't use both inline data and streaming at the same time.
+ if request.data_length() > 0 && !request.data().is_empty() {
+ return Err(Error::InvalidInput);
+ }
+
// No zero prefix in the big endian numbers.
if let [0, ..] = request.nonce()[..] {
return Err(Error::InvalidInput);
Why this scored 34/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.