eth: validate payment request coin purchase coin type and keypath
What changed, and why it matters
This commit adds missing safety checks for Ethereum payment requests on the BitBox02 hardware wallet. Previously, when a user paid a merchant via a Bitcoin payment request that included an Ethereum coin-purchase memo, the device did not verify that the requested Ethereum coin type and the keypath matched a supported mainnet network. A malicious or buggy payment request could therefore ask the device to prove ownership of an address on an unexpected or test Ethereum network, potentially misleading the user or weakening the security guarantee of the payment request. The patch now rejects unsupported coin types (notably testnet coin type 1) and ensures the keypath's second component matches the declared coin type.
Treat this as a security hardening fix and include it in the next firmware release. Review whether other memo types or address-derivation paths have similar missing coin_type/keypath validation. Ensure the payment-request signing flow cannot be coerced into deriving or displaying addresses on unexpected networks.
Security signals we found
Missing input validation on externally supplied coin_type and BIP-32 keypath in payment request memo
Cross-chain/cross-coin confusion between Bitcoin payment request and Ethereum address derivation
Testnet coin type (SLIP44=1) explicitly excluded from allowed payment-request coin types
Keypath second component (BIP-44 coin type) now enforced to match memo coin_type
ValidationError::Other used for rejection, consistent with existing error handling
Evidence from the diff
The change introduces ethereum::params::is_valid_payment_request_coin_type(coin_type: u32) which returns true only if the coin type appears in the local Ethereum network parameter table and is not the testnet coin type 1 (SLIP44 Ethereum testnet). In payment_request.rs::validate_common, when a CoinPurchaseMemo uses AddressDerivation::Eth, the code now: (1) rejects invalid coin types, (2) computes coin_type + HARDENED and compares it to _eth.keypath[1], returning ValidationError::Other on mismatch, and only then derives and compares the address. Unit tests were added covering the invalid testnet coin type case and the mismatched keypath/coin_type case.
Changed components
src/rust/bitbox02-rust/src/hww/api/ethereum/params.rssrc/rust/bitbox02-rust/src/hww/api/payment_request.rsBitBox02 firmware Ethereum payment request validation (app-ethereum feature)Inspect captured patch +121 / −0
diff --git a/src/rust/bitbox02-rust/src/hww/api/ethereum/params.rs b/src/rust/bitbox02-rust/src/hww/api/ethereum/params.rs
index 1636426..e8a7c40 100644
--- a/src/rust/bitbox02-rust/src/hww/api/ethereum/params.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/ethereum/params.rs
@@ -129,6 +129,13 @@ pub fn is_known_network(coin: Option<EthCoin>, chain_id: u64) -> bool {
get(coin, chain_id).is_some()
}
+/// Returns true if the coin type is allowed in ETH CoinPurchaseMemo payment requests.
+///
+/// This is derived from the local network table, but excludes the testnet coin type `1`.
+pub(crate) fn is_valid_payment_request_coin_type(coin_type: u32) -> bool {
+ coin_type != 1 && PARAMS.iter().any(|params| params.slip44() == coin_type)
+}
+
/// Get the chain parameters by `coin` or `chain_id`. If `chain_id` is non-zero, `coin` is
/// ignored. If `coin` is None. `chain_id` alone is used.
///
@@ -196,4 +203,12 @@ mod tests {
assert!(get(None, 2).is_none());
assert!(get(None, 0).is_none());
}
+
+ #[test]
+ pub fn test_is_valid_payment_request_coin_type() {
+ assert!(is_valid_payment_request_coin_type(60));
+ assert!(!is_valid_payment_request_coin_type(1));
+ assert!(!is_valid_payment_request_coin_type(0));
+ assert!(!is_valid_payment_request_coin_type(61));
+ }
}
diff --git a/src/rust/bitbox02-rust/src/hww/api/payment_request.rs b/src/rust/bitbox02-rust/src/hww/api/payment_request.rs
index 72fcc1f..20bab25 100644
--- a/src/rust/bitbox02-rust/src/hww/api/payment_request.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/payment_request.rs
@@ -387,6 +387,18 @@ fn validate_common(
Some(memo::coin_purchase_memo::AddressDerivation::Eth(_eth)) => {
#[cfg(feature = "app-ethereum")]
{
+ if !super::ethereum::params::is_valid_payment_request_coin_type(
+ coin_purchase_memo.coin_type,
+ ) {
+ return Err(ValidationError::Other);
+ }
+ let expected_bip44 = coin_purchase_memo
+ .coin_type
+ .checked_add(util::bip32::HARDENED)
+ .ok_or(ValidationError::Other)?;
+ if _eth.keypath.get(1) != Some(&expected_bip44) {
+ return Err(ValidationError::Other);
+ }
let derived_address = super::ethereum::derive_address(hal, &_eth.keypath)
.map_err(|_| ValidationError::Other)?;
if derived_address != coin_purchase_memo.address {
@@ -456,6 +468,8 @@ mod tests {
use crate::hal::testing::ui::Screen;
use crate::hww::api::bitcoin::params;
#[cfg(feature = "app-ethereum")]
+ use crate::hww::api::ethereum;
+ #[cfg(feature = "app-ethereum")]
use crate::hww::api::ethereum::params as eth_params;
fn make_text_memo(note: &str) -> Memo {
@@ -869,6 +883,98 @@ mod tests {
// Unhappy cases:
+ #[cfg(feature = "app-ethereum")]
+ {
+ // ETH destinations must use a supported mainnet coin_type from ethereum::params.
+ let destination_keypath = &[
+ 44 + util::bip32::HARDENED,
+ 1 + util::bip32::HARDENED,
+ 0 + util::bip32::HARDENED,
+ 0,
+ 0,
+ ];
+ let destination_address =
+ ethereum::derive_address(&mut mock_hal, destination_keypath).unwrap();
+ let mut payment_request = pb::BtcPaymentRequestRequest {
+ recipient_name: "Test Merchant".into(),
+ memos: vec![make_coin_purchase_memo(
+ 1,
+ "0.25 ETH",
+ &destination_address,
+ Some(memo::coin_purchase_memo::AddressDerivation::Eth(
+ memo::coin_purchase_memo::EthAddressDerivation {
+ keypath: destination_keypath.to_vec(),
+ },
+ )),
+ )],
+ nonce: vec![],
+ total_amount: value,
+ signature: vec![],
+ };
+ tst_sign_payment_request(
+ source_coin_type,
+ &mut payment_request,
+ &value_bytes,
+ address,
+ );
+ assert!(matches!(
+ validate_common(
+ &mut mock_hal,
+ source_coin_type,
+ &payment_request,
+ &value_bytes,
+ address
+ ),
+ Err(ValidationError::Other)
+ ));
+ }
+
+ #[cfg(feature = "app-ethereum")]
+ {
+ // ETH destination keypath must match the memo coin_type.
+ let destination_keypath = &[
+ 44 + util::bip32::HARDENED,
+ 1 + util::bip32::HARDENED,
+ 0 + util::bip32::HARDENED,
+ 0,
+ 0,
+ ];
+ let destination_address =
+ ethereum::derive_address(&mut mock_hal, destination_keypath).unwrap();
+ let mut payment_request = pb::BtcPaymentRequestRequest {
+ recipient_name: "Test Merchant".into(),
+ memos: vec![make_coin_purchase_memo(
+ 60,
+ "0.25 ETH",
+ &destination_address,
+ Some(memo::coin_purchase_memo::AddressDerivation::Eth(
+ memo::coin_purchase_memo::EthAddressDerivation {
+ keypath: destination_keypath.to_vec(),
+ },
+ )),
+ )],
+ nonce: vec![],
+ total_amount: value,
+ signature: vec![],
+ };
+ tst_sign_payment_request(
+ source_coin_type,
+ &mut payment_request,
+ &value_bytes,
+ address,
+ );
+ assert!(matches!(
+ validate_common(
+ &mut mock_hal,
+ source_coin_type,
+ &payment_request,
+ &value_bytes,
+ address
+ ),
+ Err(ValidationError::Other)
+ ));
+ }
+
#[cfg(feature = "app-ethereum")]
{
// Invalid ETH keypath in CoinPurchaseMemo
Why this scored 61/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.