Merge pull request #672 from Foundation-Devices/fix/validate-local-multisig-xpub
What changed, and why it matters
This update fixes a validation gap when importing multisig wallets into the Passport hardware wallet. Previously, the device only checked that the public key matched; now it also checks the chain code. Without this check, a tampered extended public key (xpub) could have been accepted as belonging to the wallet, potentially allowing an attacker to silently change the wallet's receiving addresses or make future transactions look valid when they are not. The fix is accompanied by a new test that confirms mismatched chain codes are rejected.
Treat this as a security fix and include it in the next firmware release. Review whether any other xpub validation paths (single-sig, cosigner import, QR/airgap imports) perform similar partial validation and apply the same complete-xpub check. Run the new unit test in CI and consider adding an integration test for multisig import with a corrupted xpub.
Security signals we found
Incomplete cryptographic input validation (public key only, not full xpub)
Potential acceptance of tampered extended public keys in multisig wallet import
Fix adds chain_code equality check alongside existing public_key equality check
New unit test specifically exercises rejection of substituted chain code
Evidence from the diff
In multisig_wallet.py, MultisigWallet.check_xpub() previously derived the local key at the supplied derivation and asserted only that node.public_key() matched the imported xpub’s public key. The patch adds an additional assertion that node.chain_code() matches the imported xpub’s chain code. The BIP32 extended public key format includes both a public key and a chain code; both are required to derive child keys and to compute the same addresses. Validating only the public key meant a malicious or malformed xpub with a correct public key but substituted chain code would pass validation. The commit also adds a unit test that injects fake dependencies and verifies that a mismatched chain code raises an AssertionError with ‘wrong xpub’.
Changed components
ports/stm32/boards/Passport/modules/multisig_wallet.pyMultisigWallet.check_xpub()Multisig wallet import flowInspect 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.