multisig: validate complete local xpub
What changed, and why it matters
This firmware update fixes a multisig wallet import check. Previously, the device only verified that the public key portion of an extended public key (xpub) matched what it expected; now it also verifies the chain code. A mismatched chain code could let a malicious or malformed xpub slip through validation, potentially causing the wallet to derive wrong or non-matching addresses and making backups or transaction coordination unreliable. The fix is accompanied by a new unit test that confirms a wrong chain code is rejected.
Treat as a security fix and include in release notes. Users importing multisig wallets should update firmware. Review whether any already-imported multisig wallets could have been created with mismatched chain codes and consider re-importing from a trusted coordinator.
Security signals we found
Validation gap: only public key checked, chain code ignored before patch
Multisig xpub import path affected
New unit test asserts rejection of substituted chain code
CHANGELOG describes change as security-relevant validation improvement
Evidence from the diff
In multisig_wallet.py, MultisigWallet.check_xpub() previously compared only node.public_key() against the locally derived node’s public key. The patch adds a comparison of node.chain_code() as well, ensuring the complete xpub (public key + chain code) matches the local derivation before accepting it. A new unit test (multisig_xpub_validation.py) mocks import_xpub and SensitiveValues to verify that a tampered chain code raises an assertion with ‘wrong xpub’. The change is small and defensive; it does not by itself prove an active exploit, but it closes a validation gap that could allow a partially-forged xpub to be accepted.
Changed components
ports/stm32/boards/Passport/modules/multisig_wallet.pyports/stm32/boards/Passport/modules/tests/unit/multisig_xpub_validation.pyMultisigWallet.check_xpub()Local xpub derivation and validation during multisig importInspect captured patch +95 / −2
### CHANGELOG.md
@@ -5,6 +5,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
-->
## Head
+- Validate the complete local xpub when importing multisig wallets
- 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
### ports/stm32/boards/Passport/modules/multisig_wallet.py
@@ -798,8 +798,9 @@ def check_xpub(cls, xfp, xpub, deriv, expect_chain, my_xfp, xpubs):
# and that's not supported
with stash.SensitiveValues() as sv:
chk_node = sv.derive_path(deriv)
- assert node.public_key() == chk_node.public_key(), \
- "(m=%s)/%s wrong pubkey" % (xfp2str(xfp), deriv[2:])
+ assert node.public_key() == chk_node.public_key() and \
+ node.chain_code() == chk_node.chain_code(), \
+ "(m=%s)/%s wrong xpub" % (xfp2str(xfp), deriv[2:])
# serialize xpub w/ BIP32 standard now.
# - this has effect of stripping SLIP-132 confusion away
### ports/stm32/boards/Passport/modules/tests/test_unit.py
@@ -34,3 +34,7 @@ def test_foundation(test):
def test_psbt_amounts(test):
assert test('psbt_amounts.py') == b'OK'
+
+
+def test_multisig_xpub_validation(test):
+ assert test('multisig_xpub_validation.py') == b'OK'
### ports/stm32/boards/Passport/modules/tests/unit/multisig_xpub_validation.py
@@ -0,0 +1,87 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+import multisig_wallet
+import stash
+
+from multisig_wallet import MultisigWallet
+from public_constants import AF_P2SH
+
+
+MY_XFP = 0x12345678
+DERIVATION = "m/48'/0'/0'/2'"
+PUBLIC_KEY = b'\x02' + (b'\x11' * 32)
+CHAIN_CODE = b'\x22' * 32
+
+
+class FakeNode:
+ def __init__(self, public_key, chain_code):
+ self._public_key = public_key
+ self._chain_code = chain_code
+
+ def depth(self):
+ return 4
+
+ def public_key(self):
+ return self._public_key
+
+ def chain_code(self):
+ return self._chain_code
+
+
+class FakeChain:
+ ctype = 'BTC'
+
+ @staticmethod
+ def serialize_public(_node, addr_fmt):
+ assert addr_fmt == AF_P2SH
+ return 'normalized-xpub'
+
+
+class FakeSensitiveValues:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, _exc_type, _exc, _traceback):
+ pass
+
+ @staticmethod
+ def derive_path(derivation):
+ assert derivation == DERIVATION
+ return FakeNode(PUBLIC_KEY, CHAIN_CODE)
+
+
+def check_node(node):
+ original_import_xpub = multisig_wallet.import_xpub
+ original_sensitive_values = stash.SensitiveValues
+
+ try:
+ multisig_wallet.import_xpub = lambda _xpub: (node, FakeChain, AF_P2SH)
+ stash.SensitiveValues = FakeSensitiveValues
+ xpubs = []
+ is_mine = MultisigWallet.check_xpub(
+ MY_XFP,
+ 'imported-xpub',
+ DERIVATION,
+ 'BTC',
+ MY_XFP,
+ xpubs,
+ )
+ return is_mine, xpubs
+ finally:
+ multisig_wallet.import_xpub = original_import_xpub
+ stash.SensitiveValues = original_sensitive_values
+
+
+is_mine, xpubs = check_node(FakeNode(PUBLIC_KEY, CHAIN_CODE))
+assert is_mine
+assert xpubs == [(MY_XFP, DERIVATION, 'normalized-xpub')]
+
+try:
+ check_node(FakeNode(PUBLIC_KEY, b'\x33' * 32))
+except AssertionError as exc:
+ assert 'wrong xpub' in str(exc)
+else:
+ raise AssertionError('Expected a substituted chain code to be rejected')
+
+return_value.write(b'OK')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.