Merge remote-tracking branch 'agent/benma-agent/btc-signmsg-m45'
What changed, and why it matters
This commit adds the ability to sign Bitcoin messages using keys in the m/45' keypath namespace, which is used by some external services like Unchained and Casa. Previously, message signing only worked for standard Bitcoin address keypaths. The change splits address derivation into a validated and an unvalidated variant, and adds a new keypath check for m/45' paths. It also updates the on-screen message to warn users that the key belongs to an external service, so they don't mistake it for a normal wallet address.
Review that derive_address_simple_unvalidated() cannot be reached from any other workflow without prior validation, and that the m/45' depth limit and script-config restrictions are sufficient for the intended external-service use case. Consider whether additional UI warnings or keypath depth restrictions are warranted.
Security signals we found
New keypath acceptance logic in message signing
Refactoring of address derivation into validated and unvalidated variants
UI disclosure added for external-service keypaths
No explicit security advisory or CVE referenced in commit
Evidence from the diff
The patch extends BtcSignMessageRequest handling to accept keypaths under m/45’ (purpose 45’) in addition to standard BIP44/BIP84/BIP86 address keypaths. It introduces derive_address_simple_unvalidated(), which performs address derivation without keypath checks, and refactors derive_address_simple() to call it after validation. In signmsg::process(), the code first tries standard keypath validation; if that fails, it falls back to validate_external_service_keypath(), which only requires the first element to be 45’ and the depth to be <= 10. The UI body is changed to append ‘External service key’ for non-standard paths. Tests and a Python CLI option are added.
Changed components
src/rust/bitbox02-rust/src/hww/api/bitcoin/signmsg.rssrc/rust/bitbox02-rust/src/hww/api/bitcoin.rssrc/rust/bitbox02-rust/src/hww/api/bitcoin/keypath.rspy/send_message.pyCHANGELOG.mdInspect captured patch +163 / −18
### CHANGELOG.md
@@ -9,6 +9,7 @@ customers cannot upgrade their bootloader, its changes are recorded separately.
### [Unreleased]
- Add support for BitBoxSync
- Display long transaction and swap amounts in full instead of truncating them
+- Bitcoin: allow signing messages with keys in the m/45' application namespace
### v9.26.5
- Security improvements
### py/send_message.py
@@ -1010,12 +1010,14 @@ def sign(
coin: "bitbox02.btc.BTCCoin.V",
keypath: Sequence[int],
script_config: bitbox02.btc.BTCScriptConfig,
+ print_address: bool = True,
) -> None:
- address = self._device.btc_address(
- coin=coin, keypath=keypath, script_config=script_config, display=False
- )
+ if print_address:
+ address = self._device.btc_address(
+ coin=coin, keypath=keypath, script_config=script_config, display=False
+ )
- print("Address:", address)
+ print("Address:", address)
msg = input(r"Message to sign (\n = newline): ")
if msg.startswith("0x"):
@@ -1049,9 +1051,19 @@ def sign_testnet() -> None:
)
sign(bitbox02.btc.TBTC, keypath, script_config)
+ def sign_external_service() -> None:
+ keypath = [45 + HARDENED, 0 + HARDENED, 0 + HARDENED, 0, 0]
+ script_config = bitbox02.btc.BTCScriptConfig(
+ simple_type=bitbox02.btc.BTCScriptConfig.P2WPKH
+ )
+ # The address endpoint only accepts standard keypaths. The sign-message workflow
+ # derives and displays the address itself.
+ sign(bitbox02.btc.BTC, keypath, script_config, print_address=False)
+
choices = (
("Mainnet", sign_mainnet),
("Testnet", sign_testnet),
+ ("External service (m/45')", sign_external_service),
)
choice = ask_user(choices)
if callable(choice):
### src/rust/bitbox02-rust/src/hww/api/bitcoin.rs
@@ -136,22 +136,16 @@ async fn xpub(
Ok(Response::Pub(pb::PubResponse { r#pub: xpub }))
}
-pub async fn derive_address_simple(
+/// Derives a simple address without validating the keypath. Callers must validate the keypath
+/// according to the requirements of their workflow before calling this function.
+async fn derive_address_simple_unvalidated(
hal: &mut impl crate::hal::Hal,
coin: BtcCoin,
simple_type: SimpleType,
keypath: &[u32],
compute: crate::keystore::Compute,
) -> Result<String, Error> {
let coin_params = params::get(coin);
- keypath::validate_address_simple(
- keypath,
- coin_params.bip44_coin,
- simple_type,
- coin_params.taproot_support,
- keypath::ReceiveSpend::Receive,
- )
- .or(Err(Error::InvalidInput))?;
Ok(common::Payload::from_simple(
hal,
&mut crate::xpubcache::XpubCache::new(compute),
@@ -163,6 +157,25 @@ pub async fn derive_address_simple(
.address(coin_params)?)
}
+pub async fn derive_address_simple(
+ hal: &mut impl crate::hal::Hal,
+ coin: BtcCoin,
+ simple_type: SimpleType,
+ keypath: &[u32],
+ compute: crate::keystore::Compute,
+) -> Result<String, Error> {
+ let coin_params = params::get(coin);
+ keypath::validate_address_simple(
+ keypath,
+ coin_params.bip44_coin,
+ simple_type,
+ coin_params.taproot_support,
+ keypath::ReceiveSpend::Receive,
+ )
+ .or(Err(Error::InvalidInput))?;
+ derive_address_simple_unvalidated(hal, coin, simple_type, keypath, compute).await
+}
+
/// Processes a SimpleType (single-sig) address api call.
async fn address_simple(
hal: &mut impl crate::hal::Hal,
### src/rust/bitbox02-rust/src/hww/api/bitcoin/keypath.rs
@@ -160,7 +160,7 @@ pub fn validate_xpub(keypath: &[u32], expected_coin: u32, taproot_support: bool)
return Ok(());
}
}
- // m/45', used/exported by Unchained.
+ // m/45', used/exported by Unchained, Casa.
if keypath == [45 + HARDENED] {
return Ok(());
}
### src/rust/bitbox02-rust/src/hww/api/bitcoin/signmsg.rs
@@ -18,8 +18,21 @@ use crate::keystore;
use crate::hal::Ui;
use crate::workflow::verify_message;
use bitcoin::consensus::encode::{VarInt, serialize};
+use util::bip32::HARDENED;
const MAX_MESSAGE_SIZE: usize = 1024;
+const MAX_KEYPATH_DEPTH: usize = 10;
+const EXTERNAL_SERVICE_PURPOSE: u32 = 45 + HARDENED;
+
+/// Validate a keypath in the external service application namespace.
+fn validate_external_service_keypath(keypath: &[u32]) -> Result<(), Error> {
+ match keypath.first() {
+ Some(first) if *first == EXTERNAL_SERVICE_PURPOSE && keypath.len() <= MAX_KEYPATH_DEPTH => {
+ Ok(())
+ }
+ _ => Err(Error::InvalidInput),
+ }
+}
/// Process a sign message request.
///
@@ -50,8 +63,22 @@ pub async fn process(
return Err(Error::InvalidInput);
}
- // Keypath and script_config are validated in address_simple().
- let address = super::derive_address_simple(
+ // Standard Bitcoin keypaths keep the existing UI. Keypaths in the external service
+ // application namespace are accepted and identified as external service keys.
+ let coin_params = super::params::get(coin);
+ let is_standard_keypath = super::keypath::validate_address_simple(
+ keypath,
+ coin_params.bip44_coin,
+ simple_type,
+ coin_params.taproot_support,
+ super::keypath::ReceiveSpend::Receive,
+ )
+ .is_ok();
+ if !is_standard_keypath {
+ validate_external_service_keypath(keypath)?;
+ }
+
+ let address = super::derive_address_simple_unvalidated(
hal,
coin,
simple_type,
@@ -61,7 +88,11 @@ pub async fn process(
.await?;
let address_formatted = util::strings::format_address(&address);
- let basic_info = format!("Coin: {}", super::params::get(coin).name);
+ let basic_info = if is_standard_keypath {
+ format!("Coin: {}", coin_params.name)
+ } else {
+ format!("Coin: {}\nExternal service key", coin_params.name)
+ };
let confirm_params = ConfirmParams {
title: "Sign message",
body: &basic_info,
@@ -144,10 +175,51 @@ mod tests {
use crate::keystore::testing::mock_unlocked;
use crate::workflow::confirm::{MAX_CONFIRM_BODY_SIZE, TRUNCATION_WARNING_BODY};
use alloc::boxed::Box;
- use util::bip32::HARDENED;
+ use hex_lit::hex;
const MESSAGE: &str = "message";
+ #[test]
+ fn test_validate_external_service_keypath() {
+ assert!(validate_external_service_keypath(&[EXTERNAL_SERVICE_PURPOSE]).is_ok());
+ assert!(
+ validate_external_service_keypath(&[
+ EXTERNAL_SERVICE_PURPOSE,
+ 0 + HARDENED,
+ 0,
+ 1 + HARDENED,
+ 1,
+ 2 + HARDENED,
+ 2,
+ 3 + HARDENED,
+ 3,
+ 4,
+ ])
+ .is_ok()
+ );
+
+ assert_eq!(
+ validate_external_service_keypath(&[]),
+ Err(Error::InvalidInput)
+ );
+ assert_eq!(
+ validate_external_service_keypath(&[45]),
+ Err(Error::InvalidInput)
+ );
+ assert_eq!(
+ validate_external_service_keypath(&[44 + HARDENED]),
+ Err(Error::InvalidInput)
+ );
+ assert_eq!(
+ validate_external_service_keypath(&[46 + HARDENED]),
+ Err(Error::InvalidInput)
+ );
+ assert_eq!(
+ validate_external_service_keypath(&[EXTERNAL_SERVICE_PURPOSE; MAX_KEYPATH_DEPTH + 1]),
+ Err(Error::InvalidInput)
+ );
+ }
+
#[async_test::test]
pub async fn test_p2wpkh() {
let request = pb::BtcSignMessageRequest {
@@ -192,6 +264,53 @@ mod tests {
);
}
+ #[async_test::test]
+ pub async fn test_p2wpkh_nonstandard_keypath() {
+ let request = pb::BtcSignMessageRequest {
+ coin: BtcCoin::Btc as _,
+ script_config: Some(pb::BtcScriptConfigWithKeypath {
+ script_config: Some(pb::BtcScriptConfig {
+ config: Some(Config::SimpleType(SimpleType::P2wpkh as _)),
+ }),
+ keypath: vec![EXTERNAL_SERVICE_PURPOSE, 0 + HARDENED, 0 + HARDENED, 0, 0],
+ }),
+ msg: MESSAGE.as_bytes().to_vec(),
+ host_nonce_commitment: None,
+ };
+
+ mock_unlocked();
+ let mut mock_hal = TestingHal::new();
+ assert_eq!(
+ process(&mut mock_hal, &request).await,
+ Ok(Response::SignMessage(pb::BtcSignMessageResponse {
+ signature: hex!(
+ "29eae5774a7cd1393746121a5533a683d7927fe29066982b88b342a52d216217720610ef451ab9d60e8bbe519057c6619d16a06351aa76d695fe6fb13c5b1f1b00"
+ )
+ .to_vec(),
+ }))
+ );
+ assert_eq!(
+ mock_hal.ui.screens,
+ vec![
+ Screen::Confirm {
+ title: "Sign message".into(),
+ body: "Coin: Bitcoin\nExternal service key".into(),
+ longtouch: false,
+ },
+ Screen::Confirm {
+ title: "Address".into(),
+ body: "bc1q y5mk a6rf x0ek uwfx 5nkk 098y kfec xxfn 6uja 5z".into(),
+ longtouch: false,
+ },
+ Screen::Confirm {
+ title: "Sign message".into(),
+ body: MESSAGE.into(),
+ longtouch: true,
+ },
+ ]
+ );
+ }
+
#[async_test::test]
pub async fn test_p2wpkh_long_message_warning() {
let msg = "m".repeat(MAX_CONFIRM_BODY_SIZE + 1);Why this scored 32/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.