From 2ab94abca1871394da082d9e3aa3b0ced98b2deb Mon Sep 17 00:00:00 2001 From: Marko Bencun Date: Sat, 21 Feb 2026 21:06:40 +0000 Subject: [PATCH] eth: add option to disable antiklepto in eth_sign_typed_msg --- CHANGELOG-npm.md | 2 + CHANGELOG-rust.md | 2 + examples/eth.rs | 2 +- sandbox/package-lock.json | 2 +- sandbox/src/Ethereum.tsx | 7 ++- src/eth.rs | 30 +++++++++-- src/wasm/mod.rs | 9 +++- tests/test_eth.rs | 111 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 157 insertions(+), 8 deletions(-) diff --git a/CHANGELOG-npm.md b/CHANGELOG-npm.md index 39bfe1f..8e214fa 100644 --- a/CHANGELOG-npm.md +++ b/CHANGELOG-npm.md @@ -2,6 +2,8 @@ ## [Unreleased] - eth: add support for streaming transactions with large data +- eth: add optional `useAntiklepto` argument to `ethSignTypedMessage()` (set to `false` for + deterministic typed-message signatures, firmware >=9.26.0) ## 0.12.0 - btc: add support for OP_RETURN outputs diff --git a/CHANGELOG-rust.md b/CHANGELOG-rust.md index 0a3b156..0b9a3d5 100644 --- a/CHANGELOG-rust.md +++ b/CHANGELOG-rust.md @@ -2,6 +2,8 @@ ## [Unreleased] - eth: add support for streaming transactions with large data +- eth: add `use_antiklepto` toggle to `eth_sign_typed_message()` (set `false` for deterministic + typed-message signatures, firmware >=9.26.0) ## 0.11.0 - btc: add support for OP_RETURN outputs diff --git a/examples/eth.rs b/examples/eth.rs index a2cb938..0c76c59 100644 --- a/examples/eth.rs +++ b/examples/eth.rs @@ -110,7 +110,7 @@ async fn eth_demo() { println!("Signign typed message..."); let signature = paired_bitbox - .eth_sign_typed_message(1, &"m/44'/60'/0'/0/0".try_into().unwrap(), EIP712_MSG) + .eth_sign_typed_message(1, &"m/44'/60'/0'/0/0".try_into().unwrap(), EIP712_MSG, true) .await .unwrap(); println!("Signature: {}", hex::encode(signature)); diff --git a/sandbox/package-lock.json b/sandbox/package-lock.json index 2e0b145..fcb8c37 100644 --- a/sandbox/package-lock.json +++ b/sandbox/package-lock.json @@ -30,7 +30,7 @@ }, "../pkg": { "name": "bitbox-api", - "version": "0.12.0", + "version": "0.13.0", "license": "Apache-2.0" }, "node_modules/@esbuild/aix-ppc64": { diff --git a/sandbox/src/Ethereum.tsx b/sandbox/src/Ethereum.tsx index 00cb174..89c29df 100644 --- a/sandbox/src/Ethereum.tsx +++ b/sandbox/src/Ethereum.tsx @@ -392,6 +392,7 @@ function EthSignTypedMessage({ bb02 } : Props) { const [chainID, setChainID] = useState(1); const [keypath, setKeypath] = useState('m/44\'/60\'/0\'/0/0'); const [msg, setMsg] = useState(exampleMsg); + const [useAntiklepto, setUseAntiklepto] = useState(true); const [result, setResult] = useState(); const [running, setRunning] = useState(false); const [err, setErr] = useState(); @@ -404,7 +405,7 @@ function EthSignTypedMessage({ bb02 } : Props) { setResult(undefined); setErr(undefined); try { - setResult(await bb02.ethSignTypedMessage(BigInt(chainID), keypath, JSON.parse(msg))); + setResult(await bb02.ethSignTypedMessage(BigInt(chainID), keypath, JSON.parse(msg), useAntiklepto)); } catch (err) { setErr(bitbox.ensureError(err)); } finally { @@ -424,6 +425,10 @@ function EthSignTypedMessage({ bb02 } : Props) { Keypath setKeypath(e.target.value)} /> + diff --git a/src/eth.rs b/src/eth.rs index 939947b..bdb9d5f 100644 --- a/src/eth.rs +++ b/src/eth.rs @@ -642,13 +642,18 @@ impl PairedBitBox { /// Signs an Ethereum EIP-712 typed message. It returns a 65 byte signature (R, S, and 1 byte /// recID). 27 is added to the recID to denote an uncompressed pubkey. + /// If `use_antiklepto` is false, signing is deterministic and requires firmware >=9.26.0. pub async fn eth_sign_typed_message( &self, chain_id: u64, keypath: &Keypath, json_msg: &str, + use_antiklepto: bool, ) -> Result<[u8; 65], Error> { self.validate_version(">=9.12.0")?; + if !use_antiklepto { + self.validate_version(">=9.26.0")?; + } let msg: Eip712Message = serde_json::from_str(json_msg) .map_err(|_| Error::EthTypedMessage("Could not parse EIP-712 JSON message".into()))?; @@ -673,7 +678,11 @@ impl PairedBitBox { .collect::, String>>() .map_err(Error::EthTypedMessage)?; - let host_nonce = crate::antiklepto::gen_host_nonce()?; + let host_nonce = if use_antiklepto { + Some(crate::antiklepto::gen_host_nonce()?) + } else { + None + }; let mut response = self .query_proto_eth(pb::eth_request::Request::SignTypedMsg( @@ -682,8 +691,10 @@ impl PairedBitBox { keypath: keypath.to_vec(), types: parsed_types, primary_type: msg.primary_type.clone(), - host_nonce_commitment: Some(pb::AntiKleptoHostNonceCommitment { - commitment: crate::antiklepto::host_commit(&host_nonce).to_vec(), + host_nonce_commitment: host_nonce.as_ref().map(|host_nonce| { + pb::AntiKleptoHostNonceCommitment { + commitment: crate::antiklepto::host_commit(host_nonce).to_vec(), + } }), }, )) @@ -696,7 +707,18 @@ impl PairedBitBox { )) .await?; } - let mut signature = self.handle_antiklepto(&response, host_nonce).await?; + let mut signature = if use_antiklepto { + self.handle_antiklepto(&response, host_nonce.unwrap()) + .await? + } else { + match response { + pb::eth_response::Response::Sign(pb::EthSignResponse { signature }) => signature + .as_slice() + .try_into() + .map_err(|_| Error::UnexpectedResponse)?, + _ => return Err(Error::UnexpectedResponse), + } + }; // 27 is the magic constant to add to the recoverable ID to denote an uncompressed pubkey. signature[64] += 27; Ok(signature) diff --git a/src/wasm/mod.rs b/src/wasm/mod.rs index 57d0fdd..b13ad6f 100644 --- a/src/wasm/mod.rs +++ b/src/wasm/mod.rs @@ -526,17 +526,24 @@ impl PairedBitBox { /// Signs an Ethereum EIP-712 typed message. It returns a 65 byte signature (R, S, and 1 byte /// recID). 27 is added to the recID to denote an uncompressed pubkey. + /// `use_antiklepto` defaults to `true` if omitted. #[wasm_bindgen(js_name = ethSignTypedMessage)] pub async fn eth_sign_typed_message( &self, chain_id: u64, keypath: types::TsKeypath, msg: JsValue, + use_antiklepto: Option, ) -> Result { let json_msg: String = js_sys::JSON::stringify(&msg).unwrap().into(); let signature = self .device - .eth_sign_typed_message(chain_id, &keypath.try_into()?, &json_msg) + .eth_sign_typed_message( + chain_id, + &keypath.try_into()?, + &json_msg, + use_antiklepto.unwrap_or(true), + ) .await?; Ok(serde_wasm_bindgen::to_value(&types::EthSignature { diff --git a/tests/test_eth.rs b/tests/test_eth.rs index fe7d72a..f819332 100644 --- a/tests/test_eth.rs +++ b/tests/test_eth.rs @@ -14,6 +14,54 @@ use bitcoin::secp256k1; use tiny_keccak::{Hasher, Keccak}; use util::test_initialized_simulators; +const EIP712_MSG: &str = r#" +{ + "types": { + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "version", "type": "string" }, + { "name": "chainId", "type": "uint256" }, + { "name": "verifyingContract", "type": "address" } + ], + "Attachment": [ + { "name": "contents", "type": "string" } + ], + "Person": [ + { "name": "name", "type": "string" }, + { "name": "wallet", "type": "address" }, + { "name": "age", "type": "uint8" } + ], + "Mail": [ + { "name": "from", "type": "Person" }, + { "name": "to", "type": "Person" }, + { "name": "contents", "type": "string" }, + { "name": "attachments", "type": "Attachment[]" } + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "from": { + "name": "Cow", + "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826", + "age": 20 + }, + "to": { + "name": "Bob", + "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", + "age": "0x1e" + }, + "contents": "Hello, Bob!", + "attachments": [{ "contents": "attachment1" }, { "contents": "attachment2" }] + } +} +"#; + fn keccak256(data: &[u8]) -> [u8; 32] { let mut hasher = Keccak::v256(); hasher.update(data); @@ -201,3 +249,66 @@ async fn test_eth_sign_1559_transaction_streaming() { }) .await } + +#[tokio::test] +async fn test_eth_sign_typed_message_antiklepto_enabled() { + test_initialized_simulators(async |paired_bitbox| { + let signature_antiklepto_1 = paired_bitbox + .eth_sign_typed_message(1, &"m/44'/60'/0'/0/0".try_into().unwrap(), EIP712_MSG, true) + .await + .unwrap(); + let signature_antiklepto_2 = paired_bitbox + .eth_sign_typed_message(1, &"m/44'/60'/0'/0/0".try_into().unwrap(), EIP712_MSG, true) + .await + .unwrap(); + assert_eq!(signature_antiklepto_1.len(), 65); + assert_eq!(signature_antiklepto_2.len(), 65); + assert_ne!(signature_antiklepto_1, signature_antiklepto_2); + }) + .await +} + +#[tokio::test] +async fn test_eth_sign_typed_message_antiklepto_disabled() { + test_initialized_simulators(async |paired_bitbox| { + if semver::VersionReq::parse(">=9.26.0") + .unwrap() + .matches(paired_bitbox.version()) + { + let signature_no_antiklepto_1 = paired_bitbox + .eth_sign_typed_message( + 1, + &"m/44'/60'/0'/0/0".try_into().unwrap(), + EIP712_MSG, + false, + ) + .await + .unwrap(); + let signature_no_antiklepto_2 = paired_bitbox + .eth_sign_typed_message( + 1, + &"m/44'/60'/0'/0/0".try_into().unwrap(), + EIP712_MSG, + false, + ) + .await + .unwrap(); + assert_eq!(signature_no_antiklepto_1.len(), 65); + assert_eq!(signature_no_antiklepto_2.len(), 65); + assert_eq!(signature_no_antiklepto_1, signature_no_antiklepto_2); + return; + } + + let err = paired_bitbox + .eth_sign_typed_message( + 1, + &"m/44'/60'/0'/0/0".try_into().unwrap(), + EIP712_MSG, + false, + ) + .await + .unwrap_err(); + assert!(matches!(err, bitbox_api::error::Error::Version(">=9.26.0"))); + }) + .await +}