feat(core): allow signing messages with multisig keys
What changed, and why it matters
This commit fixes a bug where Trezor hardware wallets refused to sign text messages with keys used for multisig wallets (BIP-45, BIP-48, Casa, Unchained). The change makes the device allow message signing on any key path where it can already display a public key or address. It also updates the companion trezorctl tool so that when it auto-detects a multisig-style path, it requests a single-key signature instead. This is a feature/bugfix rather than a vulnerability patch: it widens the set of allowed paths for the SignMessage operation, but only to paths the device already considers safe for public-key/address display.
Review as a normal feature/bugfix. Verify that the extra schemas added for SignMessage cannot be reused by other message types (the SignMessage.is_type_of guard appears to ensure this). Confirm that the fork-coin alias exclusion and taproot exclusion are correctly enforced in production builds. No emergency security response is indicated by the diff alone.
Security signals we found
Path validation relaxation scoped to SignMessage only
Multisig account/leaf paths now derivable for message signing
SPENDTAPROOT explicitly excluded from extra schemas
Fork-coin Bitcoin-namespace aliases deliberately not granted
CLI maps SPENDMULTISIG to SPENDADDRESS for single-key message signatures
New unit and device tests assert allowed/forbidden path matrix
Evidence from the diff
The firmware change refactors path validation in core/src/apps/bitcoin/keychain.py. For SignMessage requests it now adds, to the allowed path patterns, the multisig variants of the current script type plus the xpub-export prefixes. A new helper _xpub_export_patterns() computes the deepest-hardened/account prefixes at which an xpub may be exported, and validate_xpub_path_against_script_type() now simply matches against those prefixes. The with_keychain decorator additionally grants extra keychain schemas for SignMessage when the script type is SPENDADDRESS, SPENDP2SHWITNESS or SPENDWITNESS, so that account-level and leaf-level BIP-48/BIP-45/Unchained/Casa nodes can be derived. SPENDTAPROOT is deliberately excluded, keeping SLIP-25 taproot behind UnlockPath. The python/trezorlib CLI change maps SPENDMULTISIG auto-detection to SPENDADDRESS for message signing, because message signatures are inherently single-key. Tests are updated to expect SIGNS instead of FORBIDDEN/WARN for the newly allowed paths, and new leniency tests cover BIP-45, Unchained, ordinary account nodes, and a negative root-path case.
Changed components
core/src/apps/bitcoin/keychain.pycore/tests/test_apps.bitcoin.keychain.pypython/src/trezorlib/cli/btc.pytests/device_tests/bitcoin/test_signmessage.pyInspect captured patch +200 / −39
### core/.changelog.d/7717.fixed
@@ -0,0 +1 @@
+Allow signing messages with multisig keys and exportable account nodes.
### core/src/apps/bitcoin/keychain.py
@@ -176,30 +176,35 @@ def validate_path_against_script_type(
coin, script_type, multisig, include_fw_signing=SignMessage.is_type_of(msg)
)
+ if SignMessage.is_type_of(msg):
+ patterns += _get_patterns_for_script_type(coin, script_type, multisig=True)
+ patterns += _xpub_export_patterns(coin, script_type)
+
return any(
PathSchema.parse(pattern, coin.slip44).match(address_n) for pattern in patterns
)
-def validate_xpub_path_against_script_type(
+def _xpub_export_patterns(
coin: coininfo.CoinInfo,
- address_n: Bip32Path,
script_type: InputScriptType,
-) -> bool:
- """Checks whether the path is a plausible xpub export point.
+) -> list[str]:
+ """Prefixes of the supported patterns at which an xpub may be exported.
- A prefix of a pattern ending at its deepest hardened level, or at its
- account level where that is deeper. Both multisig cases are accepted.
+ A prefix ending at the deepest hardened level, or at the account level
+ where that is deeper.
"""
- patterns = _get_patterns_for_script_type(coin, script_type, False)
- patterns += _get_patterns_for_script_type(coin, script_type, True)
+ patterns = _get_patterns_for_script_type(coin, script_type, multisig=False)
+ patterns += _get_patterns_for_script_type(coin, script_type, multisig=True)
+ export_patterns: list[str] = []
for pattern in patterns:
components = pattern.split("/")[1:]
deepest_hardened = 0
account = 0
for i, component in enumerate(components):
+ # No pattern here uses a wildcard; "*'" would read as hardened.
if component.endswith("'"):
deepest_hardened = i + 1
if component in ("account", "account'"):
@@ -208,18 +213,27 @@ def validate_xpub_path_against_script_type(
if deepest_hardened == 0:
continue
- if len(address_n) not in (deepest_hardened, max(deepest_hardened, account)):
- continue
+ for depth in (deepest_hardened, max(deepest_hardened, account)):
+ prefix = "m/" + "/".join(components[:depth])
+ if prefix not in export_patterns:
+ export_patterns.append(prefix)
+
+ return export_patterns
- prefix = "m/" + "/".join(components[: len(address_n)])
- if PathSchema.parse(prefix, coin.slip44).match(address_n):
- return True
- return False
+def validate_xpub_path_against_script_type(
+ coin: coininfo.CoinInfo,
+ address_n: Bip32Path,
+ script_type: InputScriptType,
+) -> bool:
+ return any(
+ PathSchema.parse(pattern, coin.slip44).match(address_n)
+ for pattern in _xpub_export_patterns(coin, script_type)
+ )
def _get_schemas_for_coin(
- coin: coininfo.CoinInfo, unlock_schemas: Iterable[PathSchema] = ()
+ coin: coininfo.CoinInfo, extra_schemas: Iterable[PathSchema] = ()
) -> Iterable[PathSchema]:
import gc
@@ -273,7 +287,7 @@ def _get_schemas_for_coin(
patterns.append(PATTERN_BIP86)
schemas = get_schemas_from_patterns(patterns, coin)
- schemas.extend(unlock_schemas)
+ schemas.extend(extra_schemas)
gc.collect()
return [schema.copy() for schema in schemas]
@@ -314,11 +328,11 @@ def _get_coin_by_name(coin_name: str | None) -> coininfo.CoinInfo:
async def _get_keychain_for_coin(
coin: coininfo.CoinInfo,
- unlock_schemas: Iterable[PathSchema] = (),
+ extra_schemas: Iterable[PathSchema] = (),
) -> Keychain:
from apps.common.keychain import get_keychain
- schemas = _get_schemas_for_coin(coin, unlock_schemas)
+ schemas = _get_schemas_for_coin(coin, extra_schemas)
slip21_namespaces = [[b"SLIP-0019"], [b"SLIP-0024"]]
keychain = await get_keychain(coin.curve_name, schemas, slip21_namespaces)
return keychain
@@ -369,8 +383,23 @@ async def wrapper(
auth_msg: MessageType | None = None,
) -> MsgOut:
coin = _get_coin_by_name(msg.coin_name)
- unlock_schemas = _get_unlock_schemas(msg, auth_msg, coin)
- keychain = await _get_keychain_for_coin(coin, unlock_schemas)
+ extra_schemas = _get_unlock_schemas(msg, auth_msg, coin)
+ if SignMessage.is_type_of(msg):
+ script_type = msg.script_type or InputScriptType.SPENDADDRESS
+ # Only the export points need granting; leaf patterns are already
+ # in _get_schemas_for_coin(). SPENDTAPROOT is out, which keeps
+ # PATTERN_SLIP25_TAPROOT behind UnlockPath.
+ if script_type in (
+ InputScriptType.SPENDADDRESS,
+ InputScriptType.SPENDP2SHWITNESS,
+ InputScriptType.SPENDWITNESS,
+ ):
+ # Do not add Bitcoin-path aliases for fork coins.
+ extra_schemas += [
+ PathSchema.parse(pattern, coin.slip44)
+ for pattern in _xpub_export_patterns(coin, script_type)
+ ]
+ keychain = await _get_keychain_for_coin(coin, extra_schemas)
if AuthorizeCoinJoin.is_type_of(auth_msg):
auth_obj = authorization.from_cached_message(auth_msg)
return await func(msg, keychain, coin, auth_obj)
### core/tests/test_apps.bitcoin.keychain.py
@@ -9,7 +9,9 @@
from apps.bitcoin.keychain import (
_get_coin_by_name,
_get_keychain_for_coin,
+ validate_path_against_script_type,
validate_xpub_path_against_script_type,
+ with_keychain,
)
if not utils.USE_THP:
@@ -221,5 +223,102 @@ def is_export_point(address_n, script_type):
self.assertFalse(is_export_point([], InputScriptType.SPENDADDRESS))
+class TestSignMessageBip48(TestCaseWithContext):
+ """BIP-48 message signing: the account node and the leaf."""
+
+ if utils.USE_THP:
+
+ def setUp(self):
+ seed = bip39.seed(" ".join(["all"] * 12), "")
+ context.cache_set(cache_common.APP_COMMON_SEED, seed)
+
+ else:
+
+ def setUp(self):
+ cache_codec.start_session()
+ seed = bip39.seed(" ".join(["all"] * 12), "")
+ cache_codec.get_active_session().set(cache_common.APP_COMMON_SEED, seed)
+
+ def _sign_message(self, address_n, script_type, coin_name="Bitcoin"):
+ from trezor.messages import SignMessage
+
+ return SignMessage(
+ address_n=address_n,
+ message=b"hello",
+ coin_name=coin_name,
+ script_type=script_type,
+ )
+
+ def _derive(self, msg):
+ """Derive msg.address_n through the schemas with_keychain() picks."""
+
+ @with_keychain
+ async def handler(msg, keychain, coin):
+ keychain.derive(msg.address_n)
+ return True
+
+ return await_result(handler(msg))
+
+ def test_account_node_and_leaf(self):
+ from trezor.enums import InputScriptType
+
+ coin = _get_coin_by_name("Bitcoin")
+ account = [H_(48), H_(0), H_(0), H_(2)]
+ leaf = account + [0, 0]
+
+ for address_n in (account, leaf):
+ msg = self._sign_message(address_n, InputScriptType.SPENDWITNESS)
+ self.assertTrue(validate_path_against_script_type(coin, msg))
+ self.assertTrue(self._derive(msg))
+
+ def test_script_type_must_match_the_level(self):
+ """Only the matching account node is granted, so this is not derivable."""
+ from trezor.enums import InputScriptType
+
+ coin = _get_coin_by_name("Bitcoin")
+ msg = self._sign_message(
+ [H_(48), H_(0), H_(0), H_(2)], InputScriptType.SPENDADDRESS
+ )
+
+ self.assertFalse(validate_path_against_script_type(coin, msg))
+ self.assertRaises(wire.DataError, self._derive, msg)
+
+ def test_fork_coins_get_no_bitcoin_path_alias(self):
+ """The Bitcoin-namespace alias of a fork must not carry the grant.
+
+ Bcash is not a segwit coin, so SPENDADDRESS is the only script type
+ under which it has a BIP-48 pattern at all.
+ """
+ from trezor.enums import InputScriptType
+
+ # Bcash's own coin type is granted...
+ msg = self._sign_message(
+ [H_(48), H_(145), H_(0), H_(0)],
+ InputScriptType.SPENDADDRESS,
+ coin_name="Bcash",
+ )
+ self.assertTrue(self._derive(msg))
+
+ # ...the Bitcoin-namespace alias of it is not.
+ msg = self._sign_message(
+ [H_(48), H_(0), H_(0), H_(0)],
+ InputScriptType.SPENDADDRESS,
+ coin_name="Bcash",
+ )
+ self.assertRaises(wire.DataError, self._derive, msg)
+
+ def test_grant_is_sign_message_only(self):
+ """GetAddress gets no account node; it is not a spendable path."""
+ from trezor.enums import InputScriptType
+ from trezor.messages import GetAddress
+
+ msg = GetAddress(
+ address_n=[H_(48), H_(0), H_(0), H_(2)],
+ coin_name="Bitcoin",
+ script_type=InputScriptType.SPENDWITNESS,
+ )
+ self.assertRaises(wire.DataError, self._derive, msg)
+
+
if __name__ == "__main__":
unittest.main()
### python/.changelog.d/7717.fixed
@@ -0,0 +1 @@
+`trezorctl btc sign-message`: sign multisig paths with their single-key analogue.
### python/src/trezorlib/cli/btc.py
@@ -466,6 +466,9 @@ def sign_message(
address_n = tools.parse_path(address)
if script_type is None:
script_type = guess_script_type_from_path(address_n)
+ if script_type is messages.InputScriptType.SPENDMULTISIG:
+ # Message signing is single-key; use the single-key analogue.
+ script_type = messages.InputScriptType.SPENDADDRESS
res = btc.sign_message(
session,
coin,
### tests/device_tests/bitcoin/test_signmessage.py
@@ -513,45 +513,42 @@ def test_signmessage_path_warning(session: Session):
VECTORS_BIP48_MATRIX = ( # path, script_type, expected
# 0h is the legacy-multisig level; message signing is single-key, so it
# signs as p2pkh and trezorctl sends SPENDADDRESS rather than SPENDMULTISIG
- pytest.param("m/48h/0h/0h/0h", S.SPENDADDRESS, FORBIDDEN, id="account_0h-address"),
+ pytest.param("m/48h/0h/0h/0h", S.SPENDADDRESS, SIGNS, id="account_0h-address"),
pytest.param(
"m/48h/0h/0h/0h", S.SPENDP2SHWITNESS, FORBIDDEN, id="account_0h-p2shsegwit"
),
pytest.param("m/48h/0h/0h/0h", S.SPENDWITNESS, FORBIDDEN, id="account_0h-segwit"),
- pytest.param("m/48h/0h/0h/0h/0/0", S.SPENDADDRESS, WARN, id="leaf_0h-address"),
+ pytest.param("m/48h/0h/0h/0h/0/0", S.SPENDADDRESS, SIGNS, id="leaf_0h-address"),
pytest.param(
"m/48h/0h/0h/0h/0/0", S.SPENDP2SHWITNESS, WARN, id="leaf_0h-p2shsegwit"
),
pytest.param("m/48h/0h/0h/0h/0/0", S.SPENDWITNESS, WARN, id="leaf_0h-segwit"),
# 1h is the P2SH-segwit level
pytest.param("m/48h/0h/0h/1h", S.SPENDADDRESS, FORBIDDEN, id="account_1h-address"),
pytest.param(
- "m/48h/0h/0h/1h", S.SPENDP2SHWITNESS, FORBIDDEN, id="account_1h-p2shsegwit"
+ "m/48h/0h/0h/1h", S.SPENDP2SHWITNESS, SIGNS, id="account_1h-p2shsegwit"
),
pytest.param("m/48h/0h/0h/1h", S.SPENDWITNESS, FORBIDDEN, id="account_1h-segwit"),
pytest.param("m/48h/0h/0h/1h/0/0", S.SPENDADDRESS, WARN, id="leaf_1h-address"),
pytest.param(
- "m/48h/0h/0h/1h/0/0", S.SPENDP2SHWITNESS, WARN, id="leaf_1h-p2shsegwit"
+ "m/48h/0h/0h/1h/0/0", S.SPENDP2SHWITNESS, SIGNS, id="leaf_1h-p2shsegwit"
),
pytest.param("m/48h/0h/0h/1h/0/0", S.SPENDWITNESS, WARN, id="leaf_1h-segwit"),
- # 2h is the native-segwit level -- the paths reported in #7717
+ # 2h is the native-segwit level
pytest.param("m/48h/0h/0h/2h", S.SPENDADDRESS, FORBIDDEN, id="account_2h-address"),
pytest.param(
"m/48h/0h/0h/2h", S.SPENDP2SHWITNESS, FORBIDDEN, id="account_2h-p2shsegwit"
),
- pytest.param("m/48h/0h/0h/2h", S.SPENDWITNESS, FORBIDDEN, id="account_2h-segwit"),
+ pytest.param("m/48h/0h/0h/2h", S.SPENDWITNESS, SIGNS, id="account_2h-segwit"),
pytest.param("m/48h/0h/0h/2h/0/0", S.SPENDADDRESS, WARN, id="leaf_2h-address"),
pytest.param(
"m/48h/0h/0h/2h/0/0", S.SPENDP2SHWITNESS, WARN, id="leaf_2h-p2shsegwit"
),
- pytest.param("m/48h/0h/0h/2h/0/0", S.SPENDWITNESS, WARN, id="leaf_2h-segwit"),
+ pytest.param("m/48h/0h/0h/2h/0/0", S.SPENDWITNESS, SIGNS, id="leaf_2h-segwit"),
)
-LEGACY_LEVEL_EXPECTED = WARN
-
-@pytest.mark.parametrize("path, script_type, expected", VECTORS_BIP48_MATRIX)
-def test_signmessage_bip48_matrix(
+def _assert_sign_message(
session: Session,
path: str,
script_type: messages.InputScriptType,
@@ -598,6 +595,44 @@ def test_signmessage_bip48_matrix(
assert sig.signature
+@pytest.mark.parametrize("path, script_type, expected", VECTORS_BIP48_MATRIX)
+def test_signmessage_bip48_matrix(
+ session: Session,
+ path: str,
+ script_type: messages.InputScriptType,
+ expected: str,
+):
+ _assert_sign_message(session, path, script_type, expected)
+
+
+
+VECTORS_SIGNMESSAGE_LENIENCY = ( # path, script_type, expected
+ # The BIP-45 cosigner node: PATTERN_BIP45 is unhardened below m/45'
+ pytest.param("m/45h", S.SPENDADDRESS, SIGNS, id="bip45_cosigner_node"),
+ pytest.param("m/45h/0/0/0", S.SPENDADDRESS, SIGNS, id="bip45_leaf"),
+ # Unchained, whose account level is hardened
+ pytest.param("m/45h/0h/0h/1000000/0/0", S.SPENDADDRESS, SIGNS, id="unchained_leaf"),
+ # An ordinary account node, where any wallet shares its xpub
+ pytest.param("m/44h/0h/0h", S.SPENDADDRESS, SIGNS, id="bip44_account"),
+ pytest.param("m/84h/0h/0h", S.SPENDWITNESS, SIGNS, id="bip84_account"),
+ # The script type still has to match the purpose
+ pytest.param("m/44h/0h/0h", S.SPENDWITNESS, FORBIDDEN, id="bip44_account-segwit"),
+ # A pattern with no hardened component has no export point, so the root
+ # stays withheld
+ pytest.param("m", S.SPENDADDRESS, FORBIDDEN, id="root"),
+)
+
+
+@pytest.mark.parametrize("path, script_type, expected", VECTORS_SIGNMESSAGE_LENIENCY)
+def test_signmessage_leniency(
+ session: Session,
+ path: str,
+ script_type: messages.InputScriptType,
+ expected: str,
+):
+ _assert_sign_message(session, path, script_type, expected)
+
+
def test_signmessage_bip48_legacy_level_signs_as_p2pkh(session: Session):
# The path alone reads as SPENDMULTISIG, but there is no multisig message
# signature, so trezorctl sends the single-key analogue.
@@ -606,14 +641,7 @@ def test_signmessage_bip48_legacy_level_signs_as_p2pkh(session: Session):
address_n = parse_path("m/48h/0h/0h/0h/0/0")
assert guess_script_type_from_path(address_n) is S.SPENDMULTISIG
- expected_responses = []
- if LEGACY_LEVEL_EXPECTED == WARN:
- expected_responses.append(
- message_filters.ButtonRequest(
- code=messages.ButtonRequestType.UnknownDerivationPath
- )
- )
- expected_responses += [
+ expected_responses = [
message_filters.ButtonRequest(code=messages.ButtonRequestType.Other),
message_filters.ButtonRequest(code=messages.ButtonRequestType.Other),
messages.MessageSignature,Why this scored 35/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.