Merge pull request #668 from Foundation-Devices/fix/refuse-untrusted-multisig
What changed, and why it matters
This update changes how Passport handles multisig wallet setups proposed by a transaction file (PSBT). Previously, if the user declined to import a proposed multisig configuration while using a temporary seed, the device would still sign the transaction and only warn that the config wouldn't be saved. Now it cancels signing instead. Also, when a temporary seed is active, the device now asks the user before using a PSBT-proposed multisig wallet rather than silently skipping that step. The goal is to prevent a user from accidentally signing a transaction with an untrusted or unexpected multisig wallet.
Treat this as a security-hardening fix and include it in the next firmware release. Review related PSBT signing paths to ensure no other warn-and-continue behavior exists for unapproved multisig configurations. Consider whether the same abort-on-rejection behavior should apply to non-temporary seeds as well.
Security signals we found
Behavioral change from warn-and-continue to abort-on-rejection for untrusted PSBT-proposed multisig wallets
Default policy change for temporary seeds from silent skip (MUSIG_SKIP) to explicit ask (MUSIG_ASK)
Added unit test covering rejection, approval, and default policy behavior
Changelog explicitly describes the change as a security-relevant confirmation requirement
Evidence from the diff
The patch modifies sign_psbt_common_flow.py so that check_multisig_import() aborts the signing flow (set_result(None)) when the user rejects import of a PSBT-proposed multisig wallet. Previously it showed an ErrorPage and continued signing if the user dismissed the warning. public_constants.py changes MUSIG_TEMP_DEFAULT from MUSIG_SKIP to MUSIG_ASK, so temporary seeds default to prompting for approval of PSBT-proposed multisig wallets. A unit test verifies that rejection cancels the flow, approval proceeds, and the default policy for temporary seeds is MUSIG_ASK.
Changed components
Passport firmware signing flow: ports/stm32/boards/Passport/modules/flows/sign_psbt_common_flow.pyMultisig policy defaults: ports/stm32/boards/Passport/modules/public_constants.pyUnit test suite: ports/stm32/boards/Passport/modules/tests/test_unit.py and tests/unit/psbt_multisig_approval.pyInspect captured patch +114 / −6
### CHANGELOG.md
@@ -5,6 +5,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
-->
## Head
+- Require confirmation before using PSBT-proposed multisig wallets with temporary seeds,
+ and cancel signing if import is declined
- Added Coconut Wallet as a single-sig Connect Wallet option
- Improved self-send transaction information formatting (PASS1-638)
- Added the key manager extension, compatible with BIP85 and Nostr (PASS1-24)
### ports/stm32/boards/Passport/modules/flows/sign_psbt_common_flow.py
@@ -42,17 +42,14 @@ async def validate_psbt(self):
async def check_multisig_import(self):
from flows import ImportMultisigWalletFlow
- from pages import ErrorPage
# Based on the import mode and whether this already exists, the validation step
# will have set this flag.
if self.psbt.multisig_import_needs_approval:
result = await ImportMultisigWalletFlow(self.psbt.active_multisig).run()
if not result:
- text = 'The transaction can still be signed, but this multisig config will not be saved.'
- result2 = await ErrorPage(text=text, left_micron=microns.Back).show()
- if not result2:
- return
+ self.set_result(None)
+ return
self.goto(self.show_transaction_details)
### ports/stm32/boards/Passport/modules/public_constants.py
@@ -116,7 +116,7 @@
MUSIG_ASK = const(1)
MUSIG_SKIP = const(2)
MUSIG_DEFAULT = MUSIG_ASK
-MUSIG_TEMP_DEFAULT = MUSIG_SKIP
+MUSIG_TEMP_DEFAULT = MUSIG_ASK
# Default Directories
DIR_BACKUPS = 'backups'
### ports/stm32/boards/Passport/modules/tests/test_unit.py
@@ -20,6 +20,10 @@ def test_ext_settings(test):
assert test('ext_settings.py') == b'OK'
+def test_psbt_multisig_approval(test):
+ assert test('psbt_multisig_approval.py') == b'OK'
+
+
def test_ui(test):
assert test('ui.py') == b'OK'
### ports/stm32/boards/Passport/modules/tests/unit/psbt_multisig_approval.py
@@ -0,0 +1,105 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# Test approval of multisig wallets proposed by PSBTs.
+
+import common
+import flows
+import uasyncio as asyncio
+from flows import SignPsbtCommonFlow
+from public_constants import MUSIG_ASK, MUSIG_SKIP
+from utils import get_multisig_policy
+
+
+class FakeImportMultisigWalletFlow:
+ result = None
+ calls = 0
+
+ def __init__(self, wallet):
+ assert wallet == 'proposed-wallet'
+ FakeImportMultisigWalletFlow.calls += 1
+
+ async def run(self):
+ return self.result
+
+
+class FakePsbt:
+ def __init__(self, needs_approval):
+ self.multisig_import_needs_approval = needs_approval
+ self.active_multisig = 'proposed-wallet'
+
+
+class FakeSettings:
+ temporary_mode = True
+
+ def __init__(self, policy=None):
+ self.policy = policy
+
+ def get(self, key, default=None):
+ if key == 'temporary_seed':
+ return 'temporary-seed'
+ if key == 'multisig_policy' and self.policy is not None:
+ return self.policy
+ return default
+
+
+class FakeSignFlow:
+ def __init__(self, needs_approval):
+ self.psbt = FakePsbt(needs_approval)
+ self.show_transaction_details = 'transaction-details'
+ self.completed = False
+ self.result = 'unset'
+ self.next_state = None
+
+ def set_result(self, result):
+ self.completed = True
+ self.result = result
+
+ def goto(self, state):
+ self.next_state = state
+
+
+async def run_tests():
+ original_import_flow = flows.ImportMultisigWalletFlow
+ original_settings = common.settings
+
+ try:
+ flows.ImportMultisigWalletFlow = FakeImportMultisigWalletFlow
+
+ FakeImportMultisigWalletFlow.result = False
+ FakeImportMultisigWalletFlow.calls = 0
+ flow = FakeSignFlow(needs_approval=True)
+ await SignPsbtCommonFlow.check_multisig_import(flow)
+ assert FakeImportMultisigWalletFlow.calls == 1
+ assert flow.completed
+ assert flow.result is None
+ assert flow.next_state is None
+
+ FakeImportMultisigWalletFlow.result = True
+ FakeImportMultisigWalletFlow.calls = 0
+ flow = FakeSignFlow(needs_approval=True)
+ await SignPsbtCommonFlow.check_multisig_import(flow)
+ assert FakeImportMultisigWalletFlow.calls == 1
+ assert not flow.completed
+ assert flow.next_state == flow.show_transaction_details
+
+ FakeImportMultisigWalletFlow.calls = 0
+ flow = FakeSignFlow(needs_approval=False)
+ await SignPsbtCommonFlow.check_multisig_import(flow)
+ assert FakeImportMultisigWalletFlow.calls == 0
+ assert not flow.completed
+ assert flow.next_state == flow.show_transaction_details
+
+ common.settings = FakeSettings()
+ assert get_multisig_policy() == MUSIG_ASK
+
+ common.settings = FakeSettings(policy=MUSIG_SKIP)
+ assert get_multisig_policy() == MUSIG_SKIP
+
+ return_value.write(b'OK')
+ finally:
+ flows.ImportMultisigWalletFlow = original_import_flow
+ common.settings = original_settings
+
+
+asyncio.run(run_tests())Why this scored 59/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.