address format matching from PSBT witness/redeem script instead of PSBT_XPUBs derivation paths
What changed, and why it matters
This change fixes how COLDCARD figures out the address format for multisig transactions. Previously it guessed based on the derivation path numbers in the PSBT's XPUB records, which can be misleading or attacker-controlled. Now it looks directly at the actual witness/redeem script in the transaction input, which is harder to fake. A wrong address format could make the wallet think it is signing one type of address while the transaction really pays to another, potentially tricking the user into approving a payment they did not intend.
Treat this as a security-hardening fix and include it in the next firmware release. Users signing multisig transactions, especially with non-standard or vendor-specific derivation paths, should upgrade. Review whether any existing wallet metadata created via the old path-based guessing needs re-validation.
Security signals we found
Removed derivation-path-based address-format inference that trusted PSBT_XPUB path data
Added script-based address-format inference from witness_script / redeem_script
Added N == len(self.xpubs) consistency check in multisig script parsing
Added skip for inputs with no subpaths during own-input search
Added tests for non-standard derivation paths and mixed-input PSBTs
Evidence from the diff
The patch removes guess_addr_fmt(), which inferred AF_P2SH / AF_P2WSH_P2SH / AF_P2WSH from the first hardened derivation path component (45 or 48’) and the 48’ sub-path index. Instead, psbtObject.guess_M_of_N() now derives the address format from the presence and combination of witness_script and redeem_script in the first own PSBT input: witness-only => AF_P2WSH, both => AF_P2WSH_P2SH, redeem-only => AF_P2SH. It also skips inputs with no subpaths and validates that the N extracted from the script matches the number of global PSBT_XPUBs. MultisigWallet.import_from_psbt() now takes the caller-supplied af rather than recomputing it from derivation paths. Tests were added to exercise arbitrary derivation paths and a Casa PSBT case.
Changed components
shared/psbt.pyshared/multisig.pyCOLDCARD multisig signing flowPSBT import/wallet creation pathInspect captured patch +154 / −35
diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md
index a40e077..c1b98d1 100644
--- a/releases/Next-ChangeLog.md
+++ b/releases/Next-ChangeLog.md
@@ -4,6 +4,7 @@ This lists the new changes that have not yet been published in a normal release.
# Shared Improvements - Both Mk4 and Q
+- Enhancement: Address format guessing changed from PSBT_XPUBs derivation paths & now is based on witness/redeem script of first own PSBT input.
- Bugfix: Exiting text input of Custom Backup Password causes yikes
# Mk4 Specific Changes
diff --git a/shared/multisig.py b/shared/multisig.py
index 1e692d4..3d56d1b 100644
--- a/shared/multisig.py
+++ b/shared/multisig.py
@@ -969,34 +969,7 @@ class MultisigWallet(WalletABC):
print('%s: %s' % (xfp2str(xfp), val), file=fp)
@classmethod
- def guess_addr_fmt(cls, npath):
- # Assuming the bips are being respected, what address format will be used,
- # based on indicated numeric subkey path observed.
- # - return None if unsure, no errors
- #
- #( "m/45h", 'p2sh', AF_P2SH),
- #( "m/48h/{coin}h/0h/1h", 'p2sh_p2wsh', AF_P2WSH_P2SH),
- #( "m/48h/{coin}h/0h/2h", 'p2wsh', AF_P2WSH)
-
- top = npath[0] & 0x7fffffff
- if top == npath[0]:
- # non-hardened top? rare/bad
- return
-
- if top == 45:
- return AF_P2SH
-
- if top == 48:
- if len(npath) < 4: return
-
- last = npath[3] & 0x7fffffff
- if last == 1:
- return AF_P2WSH_P2SH
- if last == 2:
- return AF_P2WSH
-
- @classmethod
- def import_from_psbt(cls, M, N, xpubs_list):
+ def import_from_psbt(cls, af, M, N, xpubs_list):
# given the raw data from PSBT global header, offer the user
# the details, and/or bypass that all and just trust the data.
# - xpubs_list is a list of (xfp+path, binary BIP-32 xpub)
@@ -1025,14 +998,13 @@ class MultisigWallet(WalletABC):
expect_chain, my_xfp, xpubs)
if is_mine:
has_mine += 1
- addr_fmt = cls.guess_addr_fmt(path)
assert has_mine == 1 # 'my key not included'
name = 'PSBT-%d-of-%d' % (M, N)
# this will always create sortedmulti multisig (BIP-67)
# because BIP-174 came years after wide spread acceptance of BIP-67 policy
- ms = cls(name, (M, N), xpubs, chain_type=expect_chain, addr_fmt=addr_fmt or AF_P2SH)
+ ms = cls(name, (M, N), xpubs, chain_type=expect_chain, addr_fmt=af)
# may just keep in-memory version, no approval required, if we are
# trusting PSBT's today, otherwise caller will need to handle UX w.r.t new wallet
diff --git a/shared/psbt.py b/shared/psbt.py
index 27d1010..76c5acc 100644
--- a/shared/psbt.py
+++ b/shared/psbt.py
@@ -1228,6 +1228,8 @@ class psbtObject(psbtProxy):
# first one it finds.
#
for i in self.inputs:
+ # definitely not our if no subpaths
+ if not i.subpaths: continue
ks = i.witness_script or i.redeem_script
if not ks: continue
@@ -1235,13 +1237,22 @@ class psbtObject(psbtProxy):
if rs[-1] != OP_CHECKMULTISIG: continue
M, N = disassemble_multisig_mn(rs)
+ # does not match PSBT_XPUBS length
+ if N != len(self.xpubs): continue
+
assert 1 <= M <= N <= MAX_SIGNERS
- return (M, N)
+ # guess address format also - based on scripts provided by PSBT provider
+ if i.witness_script and not i.redeem_script:
+ af = AF_P2WSH
+ elif i.witness_script and i.redeem_script:
+ af = AF_P2WSH_P2SH
+ else:
+ af = AF_P2SH
- # not multisig, probably
- return None, None
+ return af, M, N
+ return None, None, None
async def handle_xpubs(self):
# Lookup correct wallet based on xpubs in globals
@@ -1269,7 +1280,7 @@ class psbtObject(psbtProxy):
self.active_multisig = candidates[0]
else:
# don't want to guess M if not needed, but we need it
- M, N = self.guess_M_of_N()
+ af, M, N = self.guess_M_of_N()
if not N:
# not multisig, but we can still verify:
@@ -1291,7 +1302,7 @@ class psbtObject(psbtProxy):
if not self.active_multisig:
# Maybe create wallet, for today, forever, or fail, etc.
- proposed, need_approval = MultisigWallet.import_from_psbt(M, N, self.xpubs)
+ proposed, need_approval = MultisigWallet.import_from_psbt(af, M, N, self.xpubs)
if need_approval:
# do a complex UX sequence, which lets them save new wallet
from glob import hsm_active
diff --git a/testing/test_multisig.py b/testing/test_multisig.py
index 1220193..9adfb13 100644
--- a/testing/test_multisig.py
+++ b/testing/test_multisig.py
@@ -4032,4 +4032,139 @@ def test_wrapped_segwit_vs_sh_psbt(clear_ms, import_ms_wallet, start_sign, end_s
assert "OK TO SEND?" not in title
assert "spk mismatch" in story
+
+@pytest.mark.parametrize("af", [AF_P2SH, AF_P2WSH, AF_P2WSH_P2SH])
+def test_af_psbt_input_matching(af, clear_ms, fake_ms_txn, import_ms_wallet, goto_home, cap_story,
+ start_sign, end_sign, settings_set):
+ M, N = 3, 5
+ clear_ms()
+ goto_home()
+
+ settings_set("pms", 2) # Trust PSBT
+
+ # random path that does not match anything
+ path = "m/21/21/21"
+
+ def path_mapper(idx):
+ kk = str_to_path(path)
+ return kk + [0, 0]
+
+ def incl_xpubs(idx, xfp, m, sk):
+ kk = str_to_path(path)
+ bp = pack('<%dI' % (path.count("/") + 1), xfp, *kk)
+ return sk.node.serialize_public(), bp
+
+ keys = import_ms_wallet(M, N, name='psbt_af_match', accept=True, addr_fmt=af,
+ common=path, do_import=False)
+
+ psbt = fake_ms_txn(1, 2, M, keys, incl_xpubs=incl_xpubs, inp_af=af,
+ outstyles=ADDR_STYLES_MS, change_outputs=[0], path_mapper=path_mapper)
+ start_sign(psbt)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "Invalid PSBT" not in story
+ res = end_sign(accept=True)
+ po = BasicPSBT().parse(res)
+ assert len(po.inputs[0].part_sigs) == 1
+
+
+def test_casa_case(clear_ms, settings_set, start_sign, end_sign, cap_story, set_seed_words):
+ clear_ms()
+ set_seed_words("cannon budget unknown inhale select virtual absurd chapter inch firm inquiry valley")
+ settings_set("pms", 2) # Trust PSBT
+ # got this PSBT directly form Casa (part of their test suite)
+ psbt = 'cHNidP8BAFMBAAAAAeB18EjWQ2J8kHcbWSOWLZ4XG9TROiK2EqIAn2a5pe+PAAAAAAD9////AS9EAgAAAAAAF6kURuQXuB5Udus5+DWGg/ZP1bK5/5mHAAAAAE8BAkKJ7wM+PJ4JAAAAAOIDwvV5ejMJ0rSyNey8cKbskf4kk73yRvCe8cUEiNhiA4E0IkUc+Xmx5ndEYFbZ9sHkOnOXJWeSjxIN6Go1AMfiEM3yQGYxAAAAAQAAAAAAAABPAQJCie8DR+pdIQAADTESbO7YkHNCwPnMVS6sXbxDRiMahe6Eil9h9RzUx1aiKQL+RIAGlCJ8PIu+x5O+oSdz9kSY/1vbnZxjm99fMRYWuRAS1W01MQAAAAEAAAAAAAAATwECQonvA+HsXFkAAAAAsdd6QnUkHTmhRlBNy/VQOWcZHfdPJSf4tX6LWUj1VWMCYWPVp4pXPi5mg/AC9ZP4sdbLtwyRwvalwzNO6KfrzaIQXbGC5jEAAAABAAAAAAAAAAABAPgBAAAAAAEB+Qe27L6aqLnQJ4sbxsWvQR6mhcNk0Y1DIbARPdJjSd4BAAAAFxYAFCU3KVhnuRLNeMk85jv3FgbOR9PH/f///wIrcgIAAAAAABepFJanKkFHtvWWwbHNOjPR6NP7RPfqhwoxrQUAAAAAF6kU5xbgzlw1qmkUVzLnuWp6lOPJpImHAkgwRQIhAMtF6v3RgUOxfTs9uGKAV6jjFb3TPlcZSrhRqgO8QlQ2AiANiNAi5rEGfAR0cAp8AadOOIlcQFH+X0Pf98Nz0KF5vQEhAqiLyMuk2fePxFgctRiB5QB/jwBA7q/zWtHgUbskc3rQAAAAAAEBICtyAgAAAAAAF6kUlqcqQUe29ZbBsc06M9Ho0/tE9+qHAQQiACDHvYHyHI3mL9BOaF+AgriPtki9tfeDyUhVBytva0dqmgEFaVIhAnJjmbStmsYp7bb8aAN/aN2hKiLk+6SzNpcjJftG5703IQKO3IofMd3egH0WqIpjS/M3iusXuFuAHA06s2eLBSCs+CECpbdrv+ihGqUyCBYU+K7QgpXuMD7sOt0zcltPV04PJz1TriIGAnJjmbStmsYp7bb8aAN/aN2hKiLk+6SzNpcjJftG5703GM3yQGYxAAAAAQAAAAAAAAAAAAAAAAAAACIGAo7cih8x3d6AfRaoimNL8zeK6xe4W4AcDTqzZ4sFIKz4GBLVbTUxAAAAAQAAAAAAAAAAAAAAAAAAACIGAqW3a7/ooRqlMggWFPiu0IKV7jA+7DrdM3JbT1dODyc9GF2xguYxAAAAAQAAAAAAAAAAAAAAAAAAAAAA'
+ start_sign(base64.b64decode(psbt))
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "Invalid PSBT" not in story
+ res = end_sign(psbt)
+ po = BasicPSBT().parse(res)
+ assert len(po.inputs[0].part_sigs) == 1
+
+
+@pytest.mark.parametrize("af", [AF_P2SH, AF_P2WSH, AF_P2WSH_P2SH])
+@pytest.mark.parametrize("psbt_v2", [True, False])
+def test_af_matching_convoluted_case(af, psbt_v2, clear_ms, fake_ms_txn, import_ms_wallet, goto_home,
+ pick_menu_item, cap_story, press_select, start_sign, end_sign,
+ is_q1, settings_set):
+ # merge two multisig PSBTs, each with one input (two inputs after merge)
+ # first input is not ours, but has same M, N
+ # second is ours, but address format matching will be based on first
+ M, N = 3, 5
+ clear_ms()
+ goto_home()
+ settings_set("pms", 2) # TRUST PSBT
+
+ # random path that does not match anything
+ path = "m/21/21/21"
+
+ def path_mapper(idx):
+ kk = str_to_path(path)
+ return kk + [0, 0]
+
+ def incl_xpubs(idx, xfp, m, sk):
+ kk = str_to_path(path)
+ bp = pack('<%dI' % (path.count("/") + 1), xfp, *kk)
+ return sk.node.serialize_public(), bp
+
+ keys0 = import_ms_wallet(M, N, name='00', accept=True, addr_fmt=af,
+ common=path, do_import=False)
+
+ psbt0 = fake_ms_txn(1, 2, M, keys0, incl_xpubs=incl_xpubs, inp_af=af,
+ outstyles=ADDR_STYLES_MS, change_outputs=[0], path_mapper=path_mapper)
+ # max confusion
+ af1 = {
+ AF_P2SH: AF_P2WSH_P2SH,
+ AF_P2WSH: AF_P2WSH_P2SH,
+ AF_P2WSH_P2SH: AF_P2SH
+ }[af]
+
+ keys1 = import_ms_wallet(M, N+1, name='11', accept=True, addr_fmt=af1,
+ common=path, do_import=False)
+
+ # last key is ours - drop it - as if it has our key, we will fail
+ keys1 = keys1[:-1]
+
+ psbt1 = fake_ms_txn(1, 2, M, keys1, incl_xpubs=incl_xpubs, inp_af=af1,
+ outstyles=ADDR_STYLES_MS, change_outputs=[0], path_mapper=path_mapper)
+
+ # now combine above PSBT so that one that we wanna sign (and preserve XPUBS is only the second input)
+ # aka trick our matching algo to be wrong
+ p0 = BasicPSBT().parse(psbt0)
+ p1 = BasicPSBT().parse(psbt1)
+
+ # change to PSBT v2 to not need handle txn
+ p00 = BasicPSBT().parse(p0.to_v2())
+ p11 = BasicPSBT().parse(p1.to_v2())
+
+ combined = BasicPSBT()
+ combined.version = 2
+ combined.txn_version = 2
+
+ combined.xpubs = p0.xpubs
+
+ combined.input_count = p00.input_count + p11.input_count
+ combined.output_count = p00.output_count + p11.output_count
+ combined.fallback_locktime = 0
+
+ # put the one that we will not be signig first (i.e no matching PSBT_XPUBS)
+ combined.inputs = p11.inputs + p00.inputs
+ combined.outputs = p11.outputs + p00.outputs
+
+ # drop xfp paths for input 0 - otherwise failure - correct
+ combined.inputs[0].bip32_paths = {}
+
+ psbt = combined.to_v2() if psbt_v2 else combined.to_v0()
+ start_sign(psbt)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "(1 warning below)" in story
+ assert "Limited Signing" in story
+ assert "We are not signing these inputs, because we do not know the key: 0" in story
+ res = end_sign(accept=True)
+ po = BasicPSBT().parse(res)
+ assert len(po.inputs[0].part_sigs) == 0 # considered not ours
+ assert len(po.inputs[1].part_sigs) == 1 # signature added
+
# EOF
Why this scored 63/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.