diff --git a/messages/eth.proto b/messages/eth.proto index 65c8f90..a47a6ac 100644 --- a/messages/eth.proto +++ b/messages/eth.proto @@ -1,16 +1,4 @@ -// Copyright 2019 Shift Cryptosecurity AG -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +// SPDX-License-Identifier: Apache-2.0 syntax = "proto3"; package shiftcrypto.bitbox02; @@ -63,6 +51,8 @@ message ETHSignRequest { // If non-zero, `coin` is ignored and `chain_id` is used to identify the network. uint64 chain_id = 10; ETHAddressCase address_case = 11; + // For streaming: if non-zero, data field should be empty and data will be requested in chunks + uint32 data_length = 12; } // TX payload for an EIP-1559 (type 2) transaction: https://eips.ethereum.org/EIPS/eip-1559 @@ -78,6 +68,17 @@ message ETHSignEIP1559Request { bytes data = 9; AntiKleptoHostNonceCommitment host_nonce_commitment = 10; ETHAddressCase address_case = 11; + // For streaming: if non-zero, data field should be empty and data will be requested in chunks + uint32 data_length = 12; +} + +message ETHSignDataRequestChunkResponse { + uint32 offset = 1; + uint32 length = 2; +} + +message ETHSignDataResponseChunkRequest { + bytes chunk = 1; } message ETHSignMessageRequest { @@ -154,6 +155,7 @@ message ETHRequest { ETHSignTypedMessageRequest sign_typed_msg = 5; ETHTypedMessageValueRequest typed_msg_value = 6; ETHSignEIP1559Request sign_eip1559 = 7; + ETHSignDataResponseChunkRequest data_chunk = 8; } } @@ -163,5 +165,6 @@ message ETHResponse { ETHSignResponse sign = 2; AntiKleptoSignerCommitment antiklepto_signer_commitment = 3; ETHTypedMessageValueResponse typed_msg_value = 4; + ETHSignDataRequestChunkResponse data_chunk_request = 5; } } diff --git a/src/eth.rs b/src/eth.rs index 596eb6a..591f54e 100644 --- a/src/eth.rs +++ b/src/eth.rs @@ -20,6 +20,10 @@ use num_bigint::{BigInt, BigUint}; //use num_traits::ToPrimitive; use serde_json::Value; +/// Threshold above which transaction data is streamed in chunks. +/// Transactions with data larger than this use streaming mode. +const STREAMING_THRESHOLD: usize = 6144; + impl PairedBitBox { async fn query_proto_eth( &self, @@ -478,6 +482,31 @@ impl PairedBitBox { } } + /// Handles streaming of transaction data when in streaming mode. + /// The device requests data chunks, and this method responds with the requested chunks. + async fn handle_eth_data_streaming( + &self, + data: &[u8], + mut response: pb::eth_response::Response, + ) -> Result { + while let pb::eth_response::Response::DataChunkRequest(chunk_req) = &response { + let offset = chunk_req.offset as usize; + let length = chunk_req.length as usize; + + if offset + length > data.len() { + return Err(Error::UnexpectedResponse); + } + + let chunk = data[offset..offset + length].to_vec(); + response = self + .query_proto_eth(pb::eth_request::Request::DataChunk( + pb::EthSignDataResponseChunkRequest { chunk }, + )) + .await?; + } + Ok(response) + } + /// Signs an Ethereum transaction. It returns a 65 byte signature (R, S, and 1 byte recID). The /// `tx` param can be constructed manually or parsed from a raw transaction using /// `raw_tx_slice.try_into()` (`rlp` feature required). @@ -491,6 +520,11 @@ impl PairedBitBox { // passing chainID instead of coin only since v9.10.0 self.validate_version(">=9.10.0")?; + let use_streaming = tx.data.len() > STREAMING_THRESHOLD; + if use_streaming { + self.validate_version(">=9.26.0")?; + } + let host_nonce = crate::antiklepto::gen_host_nonce()?; let request = pb::eth_request::Request::Sign(pb::EthSignRequest { coin: 0, @@ -500,14 +534,19 @@ impl PairedBitBox { gas_limit: crate::util::remove_leading_zeroes(&tx.gas_limit), recipient: tx.recipient.to_vec(), value: crate::util::remove_leading_zeroes(&tx.value), - data: tx.data.clone(), + data: if use_streaming { vec![] } else { tx.data.clone() }, host_nonce_commitment: Some(pb::AntiKleptoHostNonceCommitment { commitment: crate::antiklepto::host_commit(&host_nonce).to_vec(), }), chain_id, address_case: address_case.unwrap_or(pb::EthAddressCase::Mixed).into(), + data_length: if use_streaming { tx.data.len() as u32 } else { 0 }, }); - let response = self.query_proto_eth(request).await?; + + let mut response = self.query_proto_eth(request).await?; + if use_streaming { + response = self.handle_eth_data_streaming(&tx.data, response).await?; + } self.handle_antiklepto(&response, host_nonce).await } @@ -523,6 +562,11 @@ impl PairedBitBox { // EIP1559 is suported from v9.16.0 self.validate_version(">=9.16.0")?; + let use_streaming = tx.data.len() > STREAMING_THRESHOLD; + if use_streaming { + self.validate_version(">=9.26.0")?; + } + let host_nonce = crate::antiklepto::gen_host_nonce()?; let request = pb::eth_request::Request::SignEip1559(pb::EthSignEip1559Request { chain_id: tx.chain_id, @@ -535,13 +579,18 @@ impl PairedBitBox { gas_limit: crate::util::remove_leading_zeroes(&tx.gas_limit), recipient: tx.recipient.to_vec(), value: crate::util::remove_leading_zeroes(&tx.value), - data: tx.data.clone(), + data: if use_streaming { vec![] } else { tx.data.clone() }, host_nonce_commitment: Some(pb::AntiKleptoHostNonceCommitment { commitment: crate::antiklepto::host_commit(&host_nonce).to_vec(), }), address_case: address_case.unwrap_or(pb::EthAddressCase::Mixed).into(), + data_length: if use_streaming { tx.data.len() as u32 } else { 0 }, }); - let response = self.query_proto_eth(request).await?; + + let mut response = self.query_proto_eth(request).await?; + if use_streaming { + response = self.handle_eth_data_streaming(&tx.data, response).await?; + } self.handle_antiklepto(&response, host_nonce).await } diff --git a/src/shiftcrypto.bitbox02.rs b/src/shiftcrypto.bitbox02.rs index ee80be9..65aa64b 100644 --- a/src/shiftcrypto.bitbox02.rs +++ b/src/shiftcrypto.bitbox02.rs @@ -157,12 +157,16 @@ pub struct DeviceInfoResponse { pub mnemonic_passphrase_enabled: bool, #[prost(uint32, tag = "5")] pub monotonic_increments_remaining: u32, - /// From v9.6.0: "ATECC608A" or "ATECC608B". + /// From v9.6.0: "ATECC608A" or "ATECC608B" or "OPTIGA_TRUST_M_V3". #[prost(string, tag = "6")] pub securechip_model: ::prost::alloc::string::String, /// Only present in Bluetooth-enabled devices. #[prost(message, optional, tag = "7")] pub bluetooth: ::core::option::Option, + /// From v9.25.0. This together with `securechip_model` determines the password stretching + /// algorithm. + #[prost(string, tag = "8")] + pub password_stretching_algo: ::prost::alloc::string::String, } /// Nested message and enum types in `DeviceInfoResponse`. pub mod device_info_response { @@ -1706,6 +1710,9 @@ pub struct EthSignRequest { pub chain_id: u64, #[prost(enumeration = "EthAddressCase", tag = "11")] pub address_case: i32, + /// For streaming: if non-zero, data field should be empty and data will be requested in chunks + #[prost(uint32, tag = "12")] + pub data_length: u32, } /// TX payload for an EIP-1559 (type 2) transaction: #[cfg_attr(feature = "wasm", derive(serde::Serialize, serde::Deserialize))] @@ -1744,6 +1751,25 @@ pub struct EthSignEip1559Request { pub host_nonce_commitment: ::core::option::Option, #[prost(enumeration = "EthAddressCase", tag = "11")] pub address_case: i32, + /// For streaming: if non-zero, data field should be empty and data will be requested in chunks + #[prost(uint32, tag = "12")] + pub data_length: u32, +} +#[cfg_attr(feature = "wasm", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "wasm", serde(rename_all = "camelCase"))] +#[derive(Clone, Copy, PartialEq, ::prost::Message)] +pub struct EthSignDataRequestChunkResponse { + #[prost(uint32, tag = "1")] + pub offset: u32, + #[prost(uint32, tag = "2")] + pub length: u32, +} +#[cfg_attr(feature = "wasm", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "wasm", serde(rename_all = "camelCase"))] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct EthSignDataResponseChunkRequest { + #[prost(bytes = "vec", tag = "1")] + pub chunk: ::prost::alloc::vec::Vec, } #[cfg_attr(feature = "wasm", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "wasm", serde(rename_all = "camelCase"))] @@ -1952,7 +1978,7 @@ pub struct EthTypedMessageValueRequest { #[cfg_attr(feature = "wasm", serde(rename_all = "camelCase"))] #[derive(Clone, PartialEq, ::prost::Message)] pub struct EthRequest { - #[prost(oneof = "eth_request::Request", tags = "1, 2, 3, 4, 5, 6, 7")] + #[prost(oneof = "eth_request::Request", tags = "1, 2, 3, 4, 5, 6, 7, 8")] pub request: ::core::option::Option, } /// Nested message and enum types in `ETHRequest`. @@ -1975,13 +2001,15 @@ pub mod eth_request { TypedMsgValue(super::EthTypedMessageValueRequest), #[prost(message, tag = "7")] SignEip1559(super::EthSignEip1559Request), + #[prost(message, tag = "8")] + DataChunk(super::EthSignDataResponseChunkRequest), } } #[cfg_attr(feature = "wasm", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "wasm", serde(rename_all = "camelCase"))] #[derive(Clone, PartialEq, ::prost::Message)] pub struct EthResponse { - #[prost(oneof = "eth_response::Response", tags = "1, 2, 3, 4")] + #[prost(oneof = "eth_response::Response", tags = "1, 2, 3, 4, 5")] pub response: ::core::option::Option, } /// Nested message and enum types in `ETHResponse`. @@ -1998,6 +2026,8 @@ pub mod eth_response { AntikleptoSignerCommitment(super::AntiKleptoSignerCommitment), #[prost(message, tag = "4")] TypedMsgValue(super::EthTypedMessageValueResponse), + #[prost(message, tag = "5")] + DataChunkRequest(super::EthSignDataRequestChunkResponse), } } /// Kept for backwards compatibility. Use chain_id instead, introduced in v9.10.0. diff --git a/tests/test_eth.rs b/tests/test_eth.rs new file mode 100644 index 0000000..b95184e --- /dev/null +++ b/tests/test_eth.rs @@ -0,0 +1,150 @@ +#![cfg(feature = "simulator")] +// SPDX-License-Identifier: Apache-2.0 + +// Simulators only run on linux/amd64. +#![cfg(all(target_os = "linux", target_arch = "x86_64"))] + +#[cfg(not(feature = "tokio"))] +compile_error!("Enable the tokio feature to run simulator tests"); + +mod util; + +use bitbox_api::eth::{EIP1559Transaction, Transaction}; +use util::test_initialized_simulators; + +#[tokio::test] +async fn test_eth_address() { + test_initialized_simulators(async |paired_bitbox| { + let address = paired_bitbox + .eth_address(1, &"m/44'/60'/0'/0/0".try_into().unwrap(), false) + .await + .unwrap(); + // Verify address format (0x prefix, 40 hex chars) + assert!(address.starts_with("0x")); + assert_eq!(address.len(), 42); + }) + .await +} + +#[tokio::test] +async fn test_eth_sign_transaction_small() { + test_initialized_simulators(async |paired_bitbox| { + // Skip if firmware doesn't support ETH + if !paired_bitbox.eth_supported() { + return; + } + + // Small data (under threshold) - traditional mode + let tx = Transaction { + nonce: vec![0x01], + gas_price: vec![0x01], + gas_limit: vec![0x52, 0x08], + recipient: [0x04, 0xf2, 0x64, 0xcf, 0x34, 0x44, 0x03, 0x13, 0xb4, 0xa0, + 0x19, 0x2a, 0x35, 0x28, 0x14, 0xfb, 0xe9, 0x27, 0xb8, 0x85], + value: vec![0x01], + data: vec![0xAB; 100], + }; + + let result = paired_bitbox + .eth_sign_transaction(1, &"m/44'/60'/0'/0/0".try_into().unwrap(), &tx, None) + .await; + assert!(result.is_ok(), "eth_sign_transaction failed: {:?}", result.err()); + assert_eq!(result.unwrap().len(), 65); + }) + .await +} + +#[tokio::test] +async fn test_eth_sign_transaction_streaming() { + test_initialized_simulators(async |paired_bitbox| { + // Skip if firmware doesn't support streaming (requires >=9.26.0) + if !semver::VersionReq::parse(">=9.26.0") + .unwrap() + .matches(paired_bitbox.version()) + { + return; + } + + // Large data (over threshold) - streaming mode + let tx = Transaction { + nonce: vec![0x01], + gas_price: vec![0x01], + gas_limit: vec![0x52, 0x08], + recipient: [0x04, 0xf2, 0x64, 0xcf, 0x34, 0x44, 0x03, 0x13, 0xb4, 0xa0, + 0x19, 0x2a, 0x35, 0x28, 0x14, 0xfb, 0xe9, 0x27, 0xb8, 0x85], + value: vec![0x01], + data: vec![0xAB; 10000], + }; + + let result = paired_bitbox + .eth_sign_transaction(1, &"m/44'/60'/0'/0/0".try_into().unwrap(), &tx, None) + .await; + assert!(result.is_ok(), "eth_sign_transaction streaming failed: {:?}", result.err()); + assert_eq!(result.unwrap().len(), 65); + }) + .await +} + +#[tokio::test] +async fn test_eth_sign_1559_transaction_small() { + test_initialized_simulators(async |paired_bitbox| { + // EIP-1559 requires >=9.16.0 + if !semver::VersionReq::parse(">=9.16.0") + .unwrap() + .matches(paired_bitbox.version()) + { + return; + } + + let tx = EIP1559Transaction { + chain_id: 1, + nonce: vec![0x01], + max_priority_fee_per_gas: vec![0x01], + max_fee_per_gas: vec![0x01], + gas_limit: vec![0x52, 0x08], + recipient: [0x04, 0xf2, 0x64, 0xcf, 0x34, 0x44, 0x03, 0x13, 0xb4, 0xa0, + 0x19, 0x2a, 0x35, 0x28, 0x14, 0xfb, 0xe9, 0x27, 0xb8, 0x85], + value: vec![0x01], + data: vec![0xAB; 100], + }; + + let result = paired_bitbox + .eth_sign_1559_transaction(&"m/44'/60'/0'/0/0".try_into().unwrap(), &tx, None) + .await; + assert!(result.is_ok(), "eth_sign_1559_transaction failed: {:?}", result.err()); + assert_eq!(result.unwrap().len(), 65); + }) + .await +} + +#[tokio::test] +async fn test_eth_sign_1559_transaction_streaming() { + test_initialized_simulators(async |paired_bitbox| { + // Skip if firmware doesn't support streaming (requires >=9.26.0) + if !semver::VersionReq::parse(">=9.26.0") + .unwrap() + .matches(paired_bitbox.version()) + { + return; + } + + let tx = EIP1559Transaction { + chain_id: 1, + nonce: vec![0x01], + max_priority_fee_per_gas: vec![0x01], + max_fee_per_gas: vec![0x01], + gas_limit: vec![0x52, 0x08], + recipient: [0x04, 0xf2, 0x64, 0xcf, 0x34, 0x44, 0x03, 0x13, 0xb4, 0xa0, + 0x19, 0x2a, 0x35, 0x28, 0x14, 0xfb, 0xe9, 0x27, 0xb8, 0x85], + value: vec![0x01], + data: vec![0xCD; 8000], + }; + + let result = paired_bitbox + .eth_sign_1559_transaction(&"m/44'/60'/0'/0/0".try_into().unwrap(), &tx, None) + .await; + assert!(result.is_ok(), "eth_sign_1559_transaction streaming failed: {:?}", result.err()); + assert_eq!(result.unwrap().len(), 65); + }) + .await +}