What changed, and why it matters
This commit adds an extra on-device confirmation step when signing Ethereum typed messages (EIP-712). Before walking through the message fields, the BitBox02 now shows the user the 'primary type' (for example 'Authorize' or 'Revoke'). The goal is to prevent a malicious computer program from swapping one message type for another that has the same field shapes, because the device would then bind the user-approved signature to the wrong type. It is a hardening fix rather than a clear-cut vulnerability patch.
Treat as a security-hardening improvement. Include in release notes and ensure the new confirmation screen is documented for users so they understand what 'Message type' means before approving signatures.
Security signals we found
New user confirmation of primary type before signing
Test explicitly covers same-shaped structs with different primary types
Prevents host-selected primary type from being silently substituted
Hardens EIP-712 signing transcript binding
Evidence from the diff
In src/rust/bitbox02-rust/src/hww/api/ethereum/sign_typed_msg.rs, confirm::confirm_value() is inserted before eip712_sighash() and the per-field traversal. It displays request.primary_type with title ‘Message type’. A new unit test verifies that ‘Authorize’ and ‘Revoke’ structs with identical members produce different screens at index 1, and that removing that screen makes the remaining screen lists identical. Existing end-to-end tests are updated to expect the extra screen. The CHANGELOG records the user-facing change. No CVE, advisory, or researcher attribution is present in the supplied materials.
Changed components
Ethereum EIP-712 typed message signing flowbitbox02-rust/src/hww/api/ethereum/sign_typed_msg.rsInspect captured patch +90 / −0
### CHANGELOG.md
@@ -7,6 +7,7 @@ customers cannot upgrade their bootloader, its changes are recorded separately.
## Firmware
### [Unreleased]
+- Ethereum: display the EIP-712 message type before signing
### v9.27.0
- Display long transaction and swap amounts in full instead of truncating them
### src/rust/bitbox02-rust/src/hww/api/ethereum/sign_typed_msg.rs
@@ -734,6 +734,18 @@ pub async fn process(
)
.await?;
+ confirm::confirm_value(
+ hal,
+ &ConfirmParams {
+ title: "Message type",
+ body: &request.primary_type,
+ scrollable: true,
+ accept_is_nextarrow: true,
+ ..Default::default()
+ },
+ )
+ .await?;
+
let sighash: [u8; 32] = eip712_sighash(
hal,
&request.types,
@@ -1711,6 +1723,78 @@ mod tests {
assert!(mock_hal.ui.screens.is_empty());
}
+ #[async_test::test]
+ async fn test_process_displays_primary_type() {
+ async fn run(primary_type: &'static str) -> Vec<Screen> {
+ mock_unlocked();
+ let typed_msg = alloc::rc::Rc::new(TypedMessage::new(
+ vec![
+ StructType {
+ name: DOMAIN_TYPE_NAME.into(),
+ members: vec![mk_member("chainId", mk_sized_type(DataType::Uint, 32))],
+ },
+ StructType {
+ name: "Authorize".into(),
+ members: vec![mk_member("amount", mk_sized_type(DataType::Uint, 32))],
+ },
+ StructType {
+ name: "Revoke".into(),
+ members: vec![mk_member("amount", mk_sized_type(DataType::Uint, 32))],
+ },
+ ],
+ primary_type,
+ Object::Struct(vec![Object::BigUint(BigUint::from(1u32))]),
+ Object::Struct(vec![Object::BigUint(BigUint::from(42u32))]),
+ ));
+ {
+ let typed_msg = typed_msg.clone();
+ *crate::hww::MOCK_NEXT_REQUEST.0.borrow_mut() = Some(Box::new(move |response| {
+ Ok(typed_msg.handle_host_response(&response).unwrap())
+ }));
+ }
+
+ let mut mock_hal = TestingHal::new();
+ let result = process(
+ &mut mock_hal,
+ &pb::EthSignTypedMessageRequest {
+ chain_id: 1,
+ keypath: vec![44 + HARDENED, 60 + HARDENED, 0 + HARDENED, 0, 0],
+ types: typed_msg.types.clone(),
+ primary_type: primary_type.into(),
+ host_nonce_commitment: None,
+ },
+ )
+ .await;
+
+ assert!(matches!(result, Ok(Response::Sign(_))));
+ mock_hal.ui.screens
+ }
+
+ let mut authorize_screens = run("Authorize").await;
+ let mut revoke_screens = run("Revoke").await;
+
+ assert_eq!(
+ authorize_screens[1],
+ Screen::Confirm {
+ title: "Message type".into(),
+ body: "Authorize".into(),
+ longtouch: false,
+ }
+ );
+ assert_eq!(
+ revoke_screens[1],
+ Screen::Confirm {
+ title: "Message type".into(),
+ body: "Revoke".into(),
+ longtouch: false,
+ }
+ );
+
+ authorize_screens.remove(1);
+ revoke_screens.remove(1);
+ assert_eq!(authorize_screens, revoke_screens);
+ }
+
/// Test computation of the domain separator, which is `hashStruct(domain)`.
#[async_test::test]
async fn test_domain_separator() {
@@ -2727,6 +2811,11 @@ mod tests {
body: address.clone(),
longtouch: false,
});
+ expected_screens.push(Screen::Confirm {
+ title: "Message type".into(),
+ body: tc.primary_type.clone(),
+ longtouch: false,
+ });
expected_screens.extend(tc.expected_screens.iter().map(|(title, body)| {
Screen::Confirm {
title: title.clone(),Why this scored 44/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.