eth: allow EIP-712 message signing without anti-klepto
What changed, and why it matters
This commit adds an optional mode for signing Ethereum typed messages (EIP-712) that skips the anti-klepto protocol, falling back to normal deterministic signatures. The change is intentional and documented: some DeFi apps need signatures that are reproducible by other wallets, which the anti-klepto protocol prevents. The feature is gated behind a new host flag and requires firmware v9.26.0 or newer. It does not remove anti-klepto from other signing paths such as Bitcoin or regular Ethereum transactions.
Review whether the opt-out is appropriately communicated to users and whether the device UI clearly distinguishes anti-klepto vs. non-anti-klepto EIP-712 signatures. Confirm that no other code paths accidentally pass `None` where anti-klepto is required. Consider adding a device-level confirmation prompt when anti-klepto is disabled.
Security signals we found
Feature adds a way to bypass anti-klepto for one signing path
Bypass is opt-in by the host and version-gated
Core signing primitive now supports both anti-klepto and deterministic modes
Other signing paths remain anti-klepto-only
No input validation, buffer overflow, or key-leakage changes observed
Evidence from the diff
The patch makes secp256k1_sign accept an optional host_nonce. When None, it uses sign_ecdsa_recoverable with plain RFC6979 deterministic nonce derivation. When Some, the existing anti-exfil/anti-klepto path is preserved. Only the Ethereum EIP-712 typed message flow (sign_typed_msg.rs and the Python API eth_sign_typed_msg) is updated to allow the host to omit the host-nonce commitment. Bitcoin message/transaction signing and Ethereum transaction/plain-message signing continue to require anti-klepto. Tests are added to verify that a zero host nonce differs from no host nonce and that no-host-nonce signatures are deterministic and match standard ECDSA.
Changed components
Ethereum EIP-712 typed message signingbitbox-secp256k1 signing primitivePython BitBox02 client library (`eth_sign_typed_msg`)Firmware anti-klepto protocol handlingInspect captured patch +108 / −29
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e1f2ae0c..d8ecfcaf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ customers cannot upgrade their bootloader, its changes are recorded separately.
### [Unreleased]
- Improve experience of moving back and forth when entering password characters
- Ethereum: add data streaming support for transactions with large (>6144 bytes) data
+- Ethereum: allow EIP-712 typed message signing without anti-klepto host nonce commitment
### v9.25.0
- BitBox02 Nova: improved password stretching algorithm
diff --git a/py/bitbox02/CHANGELOG.md b/py/bitbox02/CHANGELOG.md
index a1fcd1e5..80c8b84c 100644
--- a/py/bitbox02/CHANGELOG.md
+++ b/py/bitbox02/CHANGELOG.md
@@ -5,6 +5,7 @@
- Bitcoin: add support for OP_RETURN outputs
- Add `change_password()`
- Backups: return non-naive timestamps with UTC timezone
+- Disabling anti-klepto for EIP-712 typed message signing requires firmware version v9.26.0 or newer
# 7.0.0
- get_info: add optional device initialized boolean to returned tuple
diff --git a/py/bitbox02/bitbox02/bitbox02/bitbox02.py b/py/bitbox02/bitbox02/bitbox02/bitbox02.py
index dc10b8f1..8c4e6a7f 100644
--- a/py/bitbox02/bitbox02/bitbox02/bitbox02.py
+++ b/py/bitbox02/bitbox02/bitbox02/bitbox02.py
@@ -1064,14 +1064,21 @@ class BitBox02(BitBoxCommonAPI):
return format_as_uncompressed(signature)
def eth_sign_typed_msg(
- self, keypath: Sequence[int], msg: Dict[str, Any], chain_id: int = 1
+ self,
+ keypath: Sequence[int],
+ msg: Dict[str, Any],
+ chain_id: int = 1,
+ use_antiklepto: bool = True,
) -> bytes:
"""
Sign a EIP-712 typed message.
+ Set `use_antiklepto=False` to sign without the anti-klepto protocol.
"""
# pylint: disable=too-many-statements
self._require_atleast(semver.VersionInfo(9, 12, 0))
+ if not use_antiklepto:
+ self._require_atleast(semver.VersionInfo(9, 26, 0))
def format_as_uncompressed(sig: bytes) -> bytes:
# 27 is the magic constant to add to the recoverable ID to denote an uncompressed
@@ -1210,8 +1217,12 @@ class BitBox02(BitBoxCommonAPI):
for key, val in msg["types"].items()
],
primary_type=msg["primaryType"],
- host_nonce_commitment=antiklepto.AntiKleptoHostNonceCommitment(
- commitment=antiklepto_host_commit(host_nonce),
+ host_nonce_commitment=(
+ antiklepto.AntiKleptoHostNonceCommitment(
+ commitment=antiklepto_host_commit(host_nonce),
+ )
+ if use_antiklepto
+ else None
),
)
)
@@ -1228,19 +1239,23 @@ class BitBox02(BitBoxCommonAPI):
)
)
- assert response.WhichOneof("response") == "antiklepto_signer_commitment"
- signer_commitment = response.antiklepto_signer_commitment.commitment
+ if use_antiklepto:
+ assert response.WhichOneof("response") == "antiklepto_signer_commitment"
+ signer_commitment = response.antiklepto_signer_commitment.commitment
- request = eth.ETHRequest()
- request.antiklepto_signature.CopyFrom(
- antiklepto.AntiKleptoSignatureRequest(host_nonce=host_nonce)
- )
+ request = eth.ETHRequest()
+ request.antiklepto_signature.CopyFrom(
+ antiklepto.AntiKleptoSignatureRequest(host_nonce=host_nonce)
+ )
- signature = self._eth_msg_query(request, expected_response="sign").sign.signature
- antiklepto_verify(host_nonce, signer_commitment, signature[:64])
+ signature = self._eth_msg_query(request, expected_response="sign").sign.signature
+ antiklepto_verify(host_nonce, signer_commitment, signature[:64])
- if self.debug:
- print("Antiklepto nonce verification PASSED")
+ if self.debug:
+ print("Antiklepto nonce verification PASSED")
+ else:
+ assert response.WhichOneof("response") == "sign"
+ signature = response.sign.signature
return format_as_uncompressed(signature)
diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml
index de495e7a..26bca929 100644
--- a/src/rust/Cargo.toml
+++ b/src/rust/Cargo.toml
@@ -21,8 +21,6 @@ members = [
resolver = "2"
[workspace.dependencies]
-# The secp-recovery feature is currently only needed in tests to make use of `RecoverableSignature`.
-# Attempting to enable it conditionally only for tests somehow leads to linking errors (duplicate secp256k1 symbols).
bitcoin = { version = "0.32.7", default-features = false, features = ["secp-recovery"] }
cortex-m = { version = "0.7.7", features = ["critical-section-single-core"] }
diff --git a/src/rust/bitbox-secp256k1/src/lib.rs b/src/rust/bitbox-secp256k1/src/lib.rs
index 5ddacc8f..b2c5d11b 100644
--- a/src/rust/bitbox-secp256k1/src/lib.rs
+++ b/src/rust/bitbox-secp256k1/src/lib.rs
@@ -128,8 +128,9 @@ impl Deref for GlobalContext {
/// Sign message with private key using the given private key.
///
-/// Details about `host_nonce`, the host nonce contribution. Instead of using plain rfc6979 to
-/// generate the nonce in this signature, the following formula is used:
+/// If `host_nonce` is `Some`, the host nonce contribution is mixed into the nonce derivation.
+/// Instead of using plain rfc6979 to generate the nonce in this signature, the following formula
+/// is used:
///
/// r = rfc6979(..., additional_data=Hash_d(host_nonce))
/// R = r * G (pubkey to secret r)
@@ -143,8 +144,9 @@ impl Deref for GlobalContext {
/// # Arguments
/// * `private_key` - 32 byte private key
/// * `msg` - 32 byte message to sign
-/// * `host_nonce` - 32 byte nonce contribution. Cannot be NULL.
-/// Intended to be a contribution by the host. If there is none available, use 32 zero bytes.
+/// * `host_nonce` - optional 32 byte nonce contribution.
+/// If `Some`, anti-exfil signing is used.
+/// If `None`, regular deterministic ECDSA signing (without host nonce contribution) is used.
///
/// # Returns
/// * `Ok(SignResult)` containing signature in compact format and recoverable id on success
@@ -152,8 +154,19 @@ impl Deref for GlobalContext {
pub fn secp256k1_sign(
private_key: &[u8; 32],
msg: &[u8; 32],
- host_nonce: &[u8; 32],
+ host_nonce: Option<&[u8; 32]>,
) -> Result<SignResult, ()> {
+ let Some(host_nonce) = host_nonce else {
+ let message = bitcoin::secp256k1::Message::from_digest_slice(msg).map_err(|_| ())?;
+ let secret_key = bitcoin::secp256k1::SecretKey::from_slice(private_key).map_err(|_| ())?;
+ let recoverable_sig = SECP256K1.sign_ecdsa_recoverable(&message, &secret_key);
+ let (recid, signature) = recoverable_sig.serialize_compact();
+ return Ok(SignResult {
+ signature,
+ recid: recid.to_i32().try_into().unwrap(),
+ });
+ };
+
let mut sig = MaybeUninit::<bitcoin::secp256k1::ffi::Signature>::uninit();
let mut recid: c_int = 0;
if unsafe {
@@ -378,7 +391,7 @@ mod tests {
let msg = [0x88u8; 32];
let host_nonce = [0x56u8; 32];
- let sign_result = secp256k1_sign(&private_key, &msg, &host_nonce).unwrap();
+ let sign_result = secp256k1_sign(&private_key, &msg, Some(&host_nonce)).unwrap();
// Verify signature against expected pubkey.
@@ -406,6 +419,56 @@ mod tests {
);
}
+ #[test]
+ fn test_secp256k1_sign_zero_host_nonce_differs_from_no_host_nonce_sign() {
+ let private_key = hex!("a2d8cf543c60d65162b5a06f0cef9760c883f8aa09f31236859faa85d0b74c7c");
+ let msg = [0x88u8; 32];
+ let host_nonce = [0u8; 32];
+
+ let anti_exfil_signature = secp256k1_sign(&private_key, &msg, Some(&host_nonce))
+ .unwrap()
+ .signature;
+
+ let no_host_nonce_signature = secp256k1_sign(&private_key, &msg, None).unwrap().signature;
+
+ assert_ne!(anti_exfil_signature, no_host_nonce_signature);
+ }
+
+ #[test]
+ fn test_secp256k1_sign_no_host_nonce_deterministic() {
+ let private_key = hex!("a2d8cf543c60d65162b5a06f0cef9760c883f8aa09f31236859faa85d0b74c7c");
+ let msg = [0x88u8; 32];
+
+ let sign_result_1 = secp256k1_sign(&private_key, &msg, None).unwrap();
+ let sign_result_2 = secp256k1_sign(&private_key, &msg, None).unwrap();
+ let message = secp256k1::Message::from_digest_slice(&msg).unwrap();
+ let secret_key = SecretKey::from_slice(&private_key).unwrap();
+ let expected_pubkey = secret_key.public_key(SECP256K1);
+ let recoverable_sig = secp256k1::ecdsa::RecoverableSignature::from_compact(
+ &sign_result_1.signature,
+ secp256k1::ecdsa::RecoveryId::from_i32(sign_result_1.recid as i32).unwrap(),
+ )
+ .unwrap();
+
+ assert_eq!(sign_result_1.signature, sign_result_2.signature);
+ assert_eq!(
+ sign_result_1.signature,
+ hex!(
+ "a58eaaad54d6af33e3844b1c59b70aa9a0ad5bb9e072e5d006a4cd3b27694fc12d38d8488c07586207bf0ade93af18fc7d28e0242df938ff1f495d489111192a"
+ ),
+ );
+ assert_eq!(
+ sign_result_1.signature,
+ SECP256K1
+ .sign_ecdsa(&message, &secret_key)
+ .serialize_compact(),
+ );
+ assert_eq!(
+ SECP256K1.recover_ecdsa(&message, &recoverable_sig).unwrap(),
+ expected_pubkey,
+ );
+ }
+
#[test]
fn test_secp256k1_nonce_commit() {
let private_key = hex!("a2d8cf543c60d65162b5a06f0cef9760c883f8aa09f31236859faa85d0b74c7c");
diff --git a/src/rust/bitbox02-rust/src/hww/api/bitcoin/signmsg.rs b/src/rust/bitbox02-rust/src/hww/api/bitcoin/signmsg.rs
index 9b601848..cd60857f 100644
--- a/src/rust/bitbox02-rust/src/hww/api/bitcoin/signmsg.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/bitcoin/signmsg.rs
@@ -111,7 +111,7 @@ pub async fn process(
.try_into()
.unwrap(),
&sighash,
- &host_nonce,
+ Some(&host_nonce),
)?;
let mut signature: Vec<u8> = sign_result.signature.to_vec();
signature.push(sign_result.recid);
diff --git a/src/rust/bitbox02-rust/src/hww/api/bitcoin/signtx.rs b/src/rust/bitbox02-rust/src/hww/api/bitcoin/signtx.rs
index 0aa094fc..0529b4f5 100644
--- a/src/rust/bitbox02-rust/src/hww/api/bitcoin/signtx.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/bitcoin/signtx.rs
@@ -1252,7 +1252,7 @@ async fn _process(
let sign_result = crate::secp256k1::secp256k1_sign(
private_key.as_slice().try_into().unwrap(),
&sighash,
- &host_nonce,
+ Some(&host_nonce),
)?;
drop(private_key);
next_response.next.has_signature = true;
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 a0163650..0af15844 100644
--- a/src/rust/bitbox02-rust/src/hww/api/ethereum/sign.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/ethereum/sign.rs
@@ -450,7 +450,7 @@ pub async fn _process(
.try_into()
.unwrap(),
&hash,
- &host_nonce,
+ Some(&host_nonce),
)?;
let mut signature: Vec<u8> = sign_result.signature.to_vec();
signature.push(sign_result.recid);
diff --git a/src/rust/bitbox02-rust/src/hww/api/ethereum/sign_typed_msg.rs b/src/rust/bitbox02-rust/src/hww/api/ethereum/sign_typed_msg.rs
index f17fcb88..d2ba380a 100644
--- a/src/rust/bitbox02-rust/src/hww/api/ethereum/sign_typed_msg.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/ethereum/sign_typed_msg.rs
@@ -563,10 +563,10 @@ pub async fn process(
)?;
// Send signer commitment to host and wait for the host nonce from the host.
- super::antiklepto_get_host_nonce(signer_commitment).await?
+ Some(super::antiklepto_get_host_nonce(signer_commitment).await?)
}
- _ => return Err(Error::InvalidInput),
+ None => None,
};
let sign_result = crate::secp256k1::secp256k1_sign(
@@ -575,7 +575,7 @@ pub async fn process(
.try_into()
.unwrap(),
&sighash,
- &host_nonce,
+ host_nonce.as_ref(),
)?;
let mut signature: Vec<u8> = sign_result.signature.to_vec();
signature.push(sign_result.recid);
diff --git a/src/rust/bitbox02-rust/src/hww/api/ethereum/signmsg.rs b/src/rust/bitbox02-rust/src/hww/api/ethereum/signmsg.rs
index 11e9f05e..92067f15 100644
--- a/src/rust/bitbox02-rust/src/hww/api/ethereum/signmsg.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/ethereum/signmsg.rs
@@ -80,7 +80,7 @@ pub async fn process(
.try_into()
.unwrap(),
&sighash,
- &host_nonce,
+ Some(&host_nonce),
)?;
let mut signature: Vec<u8> = sign_result.signature.to_vec();
signature.push(sign_result.recid);
diff --git a/src/rust/bitbox02-rust/src/keystore.rs b/src/rust/bitbox02-rust/src/keystore.rs
index 0dba1570..f2db9fda 100644
--- a/src/rust/bitbox02-rust/src/keystore.rs
+++ b/src/rust/bitbox02-rust/src/keystore.rs
@@ -1958,7 +1958,8 @@ mod tests {
// Protocol step 3: host_nonce sent from host to signer to be used in step 4.
// Sign - protocol step 4.
let sign_result =
- crate::secp256k1::secp256k1_sign(&private_key_bytes, &msg, &host_nonce).unwrap();
+ crate::secp256k1::secp256k1_sign(&private_key_bytes, &msg, Some(&host_nonce))
+ .unwrap();
let signature =
secp256k1::ecdsa::Signature::from_compact(&sign_result.signature).unwrap();
Why this scored 31/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.