Merge remote-tracking branch 'agent/benma-agent/btc-signmsg-m48'
What changed, and why it matters
This commit adds support for signing Bitcoin messages using keys under a new keypath prefix, m/48', alongside the previously allowed m/45'. It is a feature expansion, not a fix for a security flaw. The change is documented in the changelog and version bump, with no indication of a security issue.
No security action required. Treat as routine feature release. If reviewing for policy compliance, verify that m/48' is an intentionally supported external-service namespace.
Security signals we found
No security-relevant signals present in commit or references
Feature addition: expanded allowed BIP-32 purpose for external-service message signing
Existing guardrails (max message size, max keypath depth, user confirmation) remain unchanged
Evidence from the diff
The patch modifies the Bitcoin message-signing API to accept keypaths starting with m/48’ in addition to m/45’. It updates the validation constant to an array of allowed purposes, adds unit tests for m/48’, and extends the Python test script. The CHANGELOG and versions.json are updated to v9.27.1. There is no change to message-size limits, keypath-depth limits, or user confirmation flow.
Changed components
src/rust/bitbox02-rust/src/hww/api/bitcoin/signmsg.rspy/send_message.pyCHANGELOG.mdversions.jsonInspect captured patch +86 / −11
### CHANGELOG.md
@@ -7,7 +7,10 @@ customers cannot upgrade their bootloader, its changes are recorded separately.
## Firmware
### [Unreleased]
+
+### v9.27.1
- Ethereum: display the EIP-712 message type before signing
+- Bitcoin: allow signing messages with keys in the m/48' application namespace
### v9.27.0
- Display long transaction and swap amounts in full instead of truncating them
### py/send_message.py
@@ -1058,8 +1058,8 @@ 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]
+ def sign_external_service(purpose: int) -> None:
+ keypath = [purpose + HARDENED, 0 + HARDENED, 0 + HARDENED, 0, 0]
script_config = bitbox02.btc.BTCScriptConfig(
simple_type=bitbox02.btc.BTCScriptConfig.P2WPKH
)
@@ -1070,7 +1070,8 @@ def sign_external_service() -> None:
choices = (
("Mainnet", sign_mainnet),
("Testnet", sign_testnet),
- ("External service (m/45')", sign_external_service),
+ ("External service (m/45')", lambda: sign_external_service(45)),
+ ("External service (m/48')", lambda: sign_external_service(48)),
)
choice = ask_user(choices)
if callable(choice):
### src/rust/bitbox02-rust/src/hww/api/bitcoin/signmsg.rs
@@ -22,12 +22,17 @@ use util::bip32::HARDENED;
const MAX_MESSAGE_SIZE: usize = 1024;
const MAX_KEYPATH_DEPTH: usize = 10;
-const EXTERNAL_SERVICE_PURPOSE: u32 = 45 + HARDENED;
+const EXTERNAL_SERVICE_PURPOSE_M45: u32 = 45 + HARDENED;
+const EXTERNAL_SERVICE_PURPOSE_M48: u32 = 48 + HARDENED;
+const EXTERNAL_SERVICE_PURPOSES: [u32; 2] =
+ [EXTERNAL_SERVICE_PURPOSE_M45, EXTERNAL_SERVICE_PURPOSE_M48];
/// 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 => {
+ Some(first)
+ if EXTERNAL_SERVICE_PURPOSES.contains(first) && keypath.len() <= MAX_KEYPATH_DEPTH =>
+ {
Ok(())
}
_ => Err(Error::InvalidInput),
@@ -181,10 +186,11 @@ mod tests {
#[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_M45]).is_ok());
+ assert!(validate_external_service_keypath(&[EXTERNAL_SERVICE_PURPOSE_M48]).is_ok());
assert!(
validate_external_service_keypath(&[
- EXTERNAL_SERVICE_PURPOSE,
+ EXTERNAL_SERVICE_PURPOSE_M45,
0 + HARDENED,
0,
1 + HARDENED,
@@ -206,6 +212,10 @@ mod tests {
validate_external_service_keypath(&[45]),
Err(Error::InvalidInput)
);
+ assert_eq!(
+ validate_external_service_keypath(&[48]),
+ Err(Error::InvalidInput)
+ );
assert_eq!(
validate_external_service_keypath(&[44 + HARDENED]),
Err(Error::InvalidInput)
@@ -215,7 +225,9 @@ mod tests {
Err(Error::InvalidInput)
);
assert_eq!(
- validate_external_service_keypath(&[EXTERNAL_SERVICE_PURPOSE; MAX_KEYPATH_DEPTH + 1]),
+ validate_external_service_keypath(
+ &[EXTERNAL_SERVICE_PURPOSE_M48; MAX_KEYPATH_DEPTH + 1]
+ ),
Err(Error::InvalidInput)
);
}
@@ -265,14 +277,20 @@ mod tests {
}
#[async_test::test]
- pub async fn test_p2wpkh_nonstandard_keypath() {
+ pub async fn test_p2wpkh_m45_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],
+ keypath: vec![
+ EXTERNAL_SERVICE_PURPOSE_M45,
+ 0 + HARDENED,
+ 0 + HARDENED,
+ 0,
+ 0,
+ ],
}),
msg: MESSAGE.as_bytes().to_vec(),
host_nonce_commitment: None,
@@ -311,6 +329,59 @@ mod tests {
);
}
+ #[async_test::test]
+ pub async fn test_p2wpkh_m48_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_M48,
+ 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!(
+ "10be07206be68b250970f88faee73e666fa7cfc071965889617b68badef5291d2aee2404a36d5e2e43511b03825e146ade250a8a317accb93bfa2ca10dbbeb1301"
+ )
+ .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 unjs etyg npj5 fq08 74ex 9qgx d9ue tyk4 lgqf x3".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);
### versions.json
@@ -1,5 +1,5 @@
{
- "firmware": "v9.27.0",
+ "firmware": "v9.27.1",
"bootloader": "v1.2.2",
"stage0": 1
}Why this scored 18/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.