Re-add mixed-inputs warning for multi-wallet transactions (#382)
What changed, and why it matters
This commit restores a security warning on the Specter DIY hardware wallet. When a user is about to sign a Bitcoin transaction that spends coins from more than one wallet, or from several unknown sources, the device now shows a clear 'mixed inputs' warning at the top of the confirmation screen. This helps protect against a known class of attack where a malicious co-signer or software wallet can trick the device into sending change to the wrong address, potentially stealing funds. The warning had existed before but was accidentally dropped during an earlier code refactor.
Users should upgrade to firmware containing this commit and verify that mixed-input transactions now display a clear warning before signing. Developers should review the new warning pipeline for consistency with other transaction checks and consider the documented plan to centralize all transaction warnings.
Security signals we found
Re-adds a security warning that was previously removed
Mitigates multisig/mixed-input change-address attack class
Warning is now displayed at the top of the confirmation screen without requiring scrolling
Covers both known-known and known-unknown wallet input mixing
Includes regression tests referencing the prior refactor that dropped the warning
Updates security model documentation to reflect the restored behavior
Evidence from the diff
The patch reintroduces mixed-inputs transaction warnings in Specter DIY. A new add_warnings(wallets, meta) method is added to src/apps/wallets/manager.py and called from both the Bitcoin and Liquid preprocess_psbt() paths. It appends a warning when the PSBT spends inputs from more than one detected wallet group, or when multiple inputs are unknown and therefore cannot be proven to share a single wallet policy. The GUI screen src/gui/screens/transaction.py is updated to render the warning prominently at the top of both confirmation pages so it is visible without scrolling. Comprehensive unit and integration tests are added, including a regression test that verifies the full PSBT preprocessing pipeline now emits the warning again.
Changed components
src/apps/wallets/manager.pysrc/apps/wallets/liquid/manager.pysrc/gui/screens/transaction.pydocs/security-model.mdInspect captured patch +283 / −21
### docs/screenshots/mixed-inputs-warning-page1.png
[binary or diff unavailable]
### docs/screenshots/mixed-inputs-warning-page2.png
[binary or diff unavailable]
### docs/security-model.md
@@ -292,13 +292,15 @@ The following rules apply to transactions that the wallet will sign:
the wallet it belongs to and its amount, so you can see exactly which
of your wallets is spending. Inputs that belong to a wallet the device
does **not** know are shown as "Unknown wallet" and trigger an explicit
- warning, because the device cannot verify change for them. Note: if a
- transaction spends from several of your *own* wallets at once, the
- device shows you the per-wallet breakdown but does not block or
- specifically warn about it — check the breakdown yourself. (A dedicated
- "mixed inputs" warning existed in older firmware versions but was
- removed; it related to the multisig change-address
- [attack](https://blog.trezor.io/details-of-the-multisig-change-address-issue-and-its-mitigation-6370ad73ed2a).)
+ warning, because the device cannot verify change for them. If a
+ transaction spends inputs from more than one wallet group, the device
+ shows an explicit mixed-inputs warning at the top of the transaction
+ confirmation, so it is visible without scrolling.
+ This also applies when known-wallet and unknown-wallet inputs are mixed:
+ the unknown-wallet warning is shown and the mixed-inputs warning is
+ included in the transaction confirmation. The warning is particularly
+ intended to mitigate the class of multisig/mixed-input change-address
+ [attack](https://blog.trezor.io/details-of-the-multisig-change-address-issue-and-its-mitigation-6370ad73ed2a).
- Change outputs show the name of the wallet they are sent to.
- To use a multisig or miniscript wallet you first need to import the
wallet by adding the wallet descriptor (over QR, USB or SD card). The
@@ -348,14 +350,12 @@ sign something you didn't confirm on the device screen.
secrets from the main MCU (see "Threat model").
- Transaction warnings are currently implemented in several different
places rather than in one central pipeline: the device warns about
- unknown wallets in the inputs, about sighash flags other than
- SIGHASH_ALL (offering to sign SIGHASH_ALL inputs only), about
- transactions that were already signed, and about address indexes beyond
- the wallet's gap limit. Brainstorming / open work: consolidate these
- checks into a single warning pipeline and maintain a complete list of
- everything the device verifies before signing, so this documentation
- cannot silently drift away from the implementation again (as happened
- with the removed "mixed inputs" warning).
+ unknown wallets in the inputs, about mixed-wallet inputs, about sighash
+ flags other than SIGHASH_ALL (offering to sign SIGHASH_ALL inputs only),
+ about transactions that were already signed, and about address indexes
+ beyond the wallet's gap limit. Brainstorming / open work: consolidate
+ these checks into a single warning pipeline and maintain a complete list
+ of everything the device verifies before signing.
## Reporting vulnerabilities
### src/apps/wallets/liquid/manager.py
@@ -574,6 +574,7 @@ def preprocess_psbt(self, stream, fout):
# separator
fout.write(b"\x00")
+ self.add_warnings(wallets, meta)
return wallets, meta
### src/apps/wallets/manager.py
@@ -767,8 +767,30 @@ def preprocess_psbt(self, stream, fout):
out.write_to(fout, version=psbtv.version)
meta["fee"] = fee
+ self.add_warnings(wallets, meta)
return wallets, meta
+ def add_warnings(self, wallets, meta):
+ """
+ Populates meta["warnings"] for the transaction confirmation screen.
+ Warns if the transaction spends inputs from multiple different
+ wallets, or if multiple unknown inputs cannot be grouped by wallet
+ (multisig change-address attack mitigation).
+ Appends to existing warnings instead of replacing them.
+ """
+ warning = None
+ if len(wallets) > 1:
+ warning = "Mixed inputs from different wallets!"
+ elif None in wallets and len(meta.get("inputs", [])) > 1:
+ # With only the None wallet bucket, every input is unknown. We
+ # cannot prove that those inputs share the same wallet policy.
+ warning = "Multiple unknown inputs may be from different wallets!"
+
+ if warning:
+ warnings = meta.setdefault("warnings", [])
+ if warning not in warnings:
+ warnings.append(warning)
+
def sign_psbtview(self, psbtv, out_stream, wallets, sighash):
for w in wallets:
if w is None:
### src/gui/screens/transaction.py
@@ -65,6 +65,12 @@ def __init__(self, title, meta):
self.style_warning = style_warning
self.style_gray = style_gray
+ warning_text = None
+ if "warnings" in meta and len(meta["warnings"]) > 0:
+ warning_text = "WARNING!\n" + "\n".join(meta["warnings"])
+ self.warning = self.add_warning(self.page, warning_text)
+ obj = self.warning
+
num_change_outputs = 0
for out in meta["outputs"]:
# first only show destination addresses
@@ -87,15 +93,16 @@ def __init__(self, title, meta):
obj = fee
- if "warnings" in meta and len(meta["warnings"]) > 0:
- text = "WARNING!\n" + "\n".join(meta["warnings"])
- self.warning = add_label(text, scr=self.page)
- self.warning.set_style(0, style_warning)
- self.warning.align(obj, lv.ALIGN.OUT_BOTTOM_MID, 0, 30)
+ page2_warning = None
+ if warning_text:
+ page2_warning = self.add_warning(self.page2, warning_text)
meta_inputs_len = len(meta["inputs"])
lbl = add_label("%d %s" % (meta_inputs_len, "INPUT" if meta_inputs_len == 1 else "INPUTS"), scr=self.page2)
- lbl.align(self.page2, lv.ALIGN.IN_TOP_MID, 0, 30)
+ if page2_warning:
+ lbl.align(page2_warning, lv.ALIGN.OUT_BOTTOM_MID, 0, 20)
+ else:
+ lbl.align(self.page2, lv.ALIGN.IN_TOP_MID, 0, 30)
obj = lbl
for i, inp in enumerate(meta["inputs"]):
idxlbl = lv.label(self.page2)
@@ -230,6 +237,12 @@ def __init__(self, title, meta):
self.toggle_details()
+ def add_warning(self, page, text):
+ warning = add_label(text, scr=page)
+ warning.set_style(0, self.style_warning)
+ warning.align(page, lv.ALIGN.IN_TOP_MID, 0, 20)
+ return warning
+
def toggle_details(self):
if self.details_sw.get_state():
self.page2.set_hidden(False)
### test/tests_native/__init__.py
@@ -1 +1,2 @@
from .test_wallet_manager_parsing import *
+from .test_wallet_manager_warnings import *
### test/tests_native/test_wallet_manager_warnings.py
@@ -0,0 +1,225 @@
+import sys
+
+if sys.implementation.name != 'micropython':
+ from native_support import setup_native_stubs
+
+ setup_native_stubs()
+
+from unittest import TestCase
+from io import BytesIO
+import gc
+from binascii import hexlify
+
+from embit import bip32, ec, script
+from embit.descriptor import Descriptor
+from embit.psbt import PSBT, DerivationPath
+from embit.transaction import Transaction, TransactionInput, TransactionOutput
+from tests.util import get_keystore, get_wallets_app, clear_testdir
+
+MIXED_INPUTS_WARNING = "Mixed inputs from different wallets!"
+UNKNOWN_INPUTS_WARNING = "Multiple unknown inputs may be from different wallets!"
+
+# Watch-only testnet wallets used by the integration tests below.
+DESCRIPTOR_A = (
+ "MixTestA&wpkh([71348C8A/84h/1h/0h]tpubDCTb5JhwTc9S3pfEMNMajVPCEgCDxHTiBwmJgzLa2Znne2pPQ4dh1CjpS7ibiPBEXeJRJxddRaW1ZxxWyDvrndrQk8vqfco9Uvr7Eseo55L/{0,1}/*)"
+)
+DESCRIPTOR_B = (
+ "MixTestB&wpkh([0EBCE71A/84h/1h/0h]tpubDCXmKn7bo4uUsnUDU1CLbCRRjVuurCunD5jRZMtFps71qwTgL1XUM8BJhcJYJqNhqTHUE8kU28GhApgz4o2FHjqE4ZC3QQN3k6VUa3z6ZMQ/{0,1}/*)"
+)
+
+# Fake but self-consistent PSBTs (generated with psbt_faker, spending
+# made-up UTXOs). PSBT_SINGLE spends two inputs from wallet A only,
+# PSBT_MIXED spends one input from wallet A and one from wallet B.
+PSBT_SINGLE = "cHNidP8BAJoCAAAAArUycUzxHEYzxqsTEJVpL5MYmW9wZOC1U6TXC0PKgX/KAAAAAAD/////iMm3mDjFqdaBbHE/ZVrLcAIFnLVeCxQzlFg0wCHDEi0AAAAAAP////8CDN/1BQAAAAAWABQC0qr2BrLmrF9lcRZk4RxX07tggQzf9QUAAAAAFgAUrVwtkW35L7+4yws03wUISOuLit8AAAAAAAEBHwDh9QUAAAAAFgAU8mDo58D7gGT7T9CxRQuGRlwAiqAiBgLI4yrmNHwvj2S5sdX3WsF7jY7rTe1p0sW7ZRKC5s4HaRhxNIyKVAAAgAEAAIAAAACAAQAAAAAAAAAAAQEfAOH1BQAAAAAWABRAVRuVv94TtoL/GQBX1PqmTtF2HiIGA2yDc8YimWC+7tsbIVV0hpE4tgd+Bm0rciZp7YsrL6xWGHE0jIpUAACAAQAAgAAAAIABAAAAAQAAAAABARYAFALSqvYGsuasX2VxFmThHFfTu2CBIgIC/gh+oUaTn9z4FWQlnHORv710xPo5ORPAuJqGvqFtSWUYcTSMilQAAIABAACAAAAAgAAAAAAAAAAAAAEBFgAUrVwtkW35L7+4yws03wUISOuLit8A"
+PSBT_MIXED = "cHNidP8BAJoCAAAAArUycUzxHEYzxqsTEJVpL5MYmW9wZOC1U6TXC0PKgX/KAAAAAAD/////9N9hUp4UTYXTCCHy4pkQAEF6xe8hP88jB6bQIO/XttEAAAAAAP////8CGN31BQAAAAAWABQggAVX3zK/wgkFSYcWcRCWgrMA1Rjd9QUAAAAAFgAUywr+Xnk67suP4O4XkARG2PJ2FucAAAAAAAEBHwDh9QUAAAAAFgAU8mDo58D7gGT7T9CxRQuGRlwAiqAiBgLI4yrmNHwvj2S5sdX3WsF7jY7rTe1p0sW7ZRKC5s4HaRhxNIyKVAAAgAEAAIAAAACAAQAAAAAAAAAAAQEfAOH1BQAAAAAWABT4MqE0hU0txS199SKSqpnSPcS/vyIGAhyvfzUnvLMT5JSMbPZOflc81MOmxvKfsELZApdS+FFXGA685xpUAACAAQAAgAAAAIABAAAAAAAAAAABARYAFCCABVffMr/CCQVJhxZxEJaCswDVAAEBFgAUywr+Xnk67suP4O4XkARG2PJ2FucA"
+
+
+class WalletManagerWarningsTest(TestCase):
+ def setUp(self):
+ clear_testdir()
+ self.keystore = get_keystore()
+ self.wallets_app = get_wallets_app(self.keystore, "regtest")
+ self.manager = self.wallets_app.manager
+
+ def tearDown(self):
+ clear_testdir()
+ gc.collect()
+
+ def _meta(self):
+ return {
+ "inputs": [],
+ "outputs": [],
+ "signed_inputs": 0,
+ "tx_version": 2,
+ "locktime": 0,
+ }
+
+ def test_no_warning_for_empty_wallets(self):
+ meta = self._meta()
+ self.manager.add_warnings({}, meta)
+ self.assertFalse("warnings" in meta)
+
+ def test_no_warning_for_single_wallet(self):
+ meta = self._meta()
+ wallets = {object(): {"amount": 1000, "gaps": [0, 0]}}
+ self.manager.add_warnings(wallets, meta)
+ self.assertFalse("warnings" in meta)
+
+ def test_no_warning_for_single_unknown_input(self):
+ meta = self._meta()
+ meta["inputs"] = [{}]
+ wallets = {None: {"amount": 1000, "gaps": None}}
+ self.manager.add_warnings(wallets, meta)
+ self.assertFalse("warnings" in meta)
+
+ def test_warning_for_multiple_unknown_inputs(self):
+ meta = self._meta()
+ meta["inputs"] = [{}, {}]
+ wallets = {None: {"amount": 2000, "gaps": None}}
+ self.manager.add_warnings(wallets, meta)
+ self.assertEqual(meta["warnings"], [UNKNOWN_INPUTS_WARNING])
+
+ def test_warning_for_mixed_inputs_from_two_wallets(self):
+ meta = self._meta()
+ wallets = {
+ object(): {"amount": 1000, "gaps": [0, 0]},
+ object(): {"amount": 2000, "gaps": [0, 0]},
+ }
+ self.manager.add_warnings(wallets, meta)
+ self.assertEqual(meta["warnings"], [MIXED_INPUTS_WARNING])
+
+ def test_warning_for_mixed_known_and_unknown_wallets(self):
+ meta = self._meta()
+ wallets = {
+ object(): {"amount": 1000, "gaps": [0, 0]},
+ None: {"amount": 2000, "gaps": None},
+ }
+ self.manager.add_warnings(wallets, meta)
+ self.assertEqual(meta["warnings"], [MIXED_INPUTS_WARNING])
+
+ def test_existing_warnings_are_preserved(self):
+ meta = self._meta()
+ meta["warnings"] = ["Some other warning!"]
+ wallets = {
+ object(): {"amount": 1000, "gaps": [0, 0]},
+ object(): {"amount": 2000, "gaps": [0, 0]},
+ }
+ self.manager.add_warnings(wallets, meta)
+ self.assertEqual(
+ meta["warnings"], ["Some other warning!", MIXED_INPUTS_WARNING]
+ )
+
+ def test_warning_is_not_duplicated(self):
+ meta = self._meta()
+ wallets = {
+ object(): {"amount": 1000, "gaps": [0, 0]},
+ object(): {"amount": 2000, "gaps": [0, 0]},
+ }
+ self.manager.add_warnings(wallets, meta)
+ self.manager.add_warnings(wallets, meta)
+ self.assertEqual(meta["warnings"], [MIXED_INPUTS_WARNING])
+
+
+class WalletManagerWarningsIntegrationTest(TestCase):
+ """
+ Regression tests for the full wiring: PSBT -> preprocess_psbt() ->
+ wallet detection -> meta["warnings"]. The mixed-inputs warning was
+ originally lost during the Liquid refactor (c476cde) precisely because
+ this wiring was dropped, so unit tests of add_warnings alone are not
+ sufficient.
+ """
+
+ def setUp(self):
+ clear_testdir()
+ self.keystore = get_keystore()
+ self.wallets_app = get_wallets_app(self.keystore, "test")
+ self.manager = self.wallets_app.manager
+
+ def tearDown(self):
+ clear_testdir()
+ gc.collect()
+
+ def _import(self, desc):
+ self.manager.add_wallet(self.manager.parse_wallet(desc))
+
+ def _preprocess(self, b64):
+ raw = PSBT.from_string(b64).serialize()
+ return self.manager.preprocess_psbt(BytesIO(raw), BytesIO())
+
+ def test_preprocess_single_wallet_tx_produces_no_warning(self):
+ self._import(DESCRIPTOR_A)
+ self._import(DESCRIPTOR_B)
+ wallets, meta = self._preprocess(PSBT_SINGLE)
+ self.assertEqual(len(wallets), 1)
+ self.assertFalse("warnings" in meta)
+
+ def test_preprocess_mixed_wallets_tx_produces_warning(self):
+ self._import(DESCRIPTOR_A)
+ self._import(DESCRIPTOR_B)
+ wallets, meta = self._preprocess(PSBT_MIXED)
+ self.assertEqual(len(wallets), 2)
+ self.assertIn(MIXED_INPUTS_WARNING, meta.get("warnings", []))
+
+ def test_preprocess_mixed_with_unknown_wallet_produces_warning(self):
+ # multisig change-address attack scenario: the victim's wallet is
+ # imported, the attacker's inputs are unknown - the device must
+ # still warn about mixed inputs
+ self._import(DESCRIPTOR_A)
+ wallets, meta = self._preprocess(PSBT_MIXED)
+ self.assertEqual(len(wallets), 2)
+ self.assertTrue(None in wallets)
+ self.assertIn(MIXED_INPUTS_WARNING, meta.get("warnings", []))
+
+ def test_preprocess_multiple_unknown_signable_policies_warns(self):
+ paths = [
+ bip32.parse_path("m/48h/1h/0h/2h/0/0"),
+ bip32.parse_path("m/48h/1h/0h/2h/0/1"),
+ ]
+ device_pubs = [
+ self.keystore.root.derive(path).key.get_public_key() for path in paths
+ ]
+ cosigner_pubs = [
+ ec.PrivateKey(bytes([3]) * 32).get_public_key(),
+ ec.PrivateKey(bytes([4]) * 32).get_public_key(),
+ ]
+ descriptors = [
+ Descriptor.from_string(
+ "wsh(sortedmulti(2,%s,%s))"
+ % (
+ hexlify(device_pubs[i].sec()).decode(),
+ hexlify(cosigner_pubs[i].sec()).decode(),
+ )
+ )
+ for i in range(2)
+ ]
+ tx = Transaction(
+ vin=[TransactionInput(b"1" * 32, 0), TransactionInput(b"2" * 32, 0)],
+ vout=[TransactionOutput(190000, script.p2wpkh(device_pubs[0]))],
+ )
+ psbt = PSBT(tx)
+ for i in range(2):
+ psbt.inputs[i].witness_utxo = TransactionOutput(
+ 100000, descriptors[i].script_pubkey()
+ )
+ psbt.inputs[i].witness_script = descriptors[i].witness_script()
+ psbt.inputs[i].bip32_derivations[device_pubs[i]] = DerivationPath(
+ self.keystore.fingerprint, paths[i]
+ )
+
+ filled = BytesIO()
+ wallets, meta = self.manager.preprocess_psbt(BytesIO(psbt.serialize()), filled)
+
+ self.assertEqual(len(wallets), 1)
+ self.assertTrue(None in wallets)
+ self.assertIn(UNKNOWN_INPUTS_WARNING, meta.get("warnings", []))
+
+ # Both inputs are signable despite collapsing into the same unknown bucket.
+ filled.seek(0)
+ psbtv = self.manager.PSBTViewClass.view(filled, compress=True)
+ signed = BytesIO()
+ self.manager.sign_psbtview(psbtv, signed, wallets, None)
+ signed.seek(0)
+ signed_psbt = PSBT.read_from(signed)
+ self.assertEqual(
+ [len(inp.partial_sigs) for inp in signed_psbt.inputs], [1, 1]
+ )Why this scored 60/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.