ownership: search particular named wallet via BIP-21 wallet query param
What changed, and why it matters
This commit adds a new feature to Coldcard hardware wallets: when verifying ownership of a Bitcoin address, users can now include a wallet name in the BIP-21 URL (for example, bitcoin:address?wallet=my_wallet). This tells the Coldcard to search only that specific multisig wallet, rather than checking all wallets. It is a user-facing convenience feature, not a security fix, and does not appear to introduce any obvious vulnerability.
No security action required. Treat as a normal feature review. If desired, verify that the 'wallet' query parameter value is properly sanitized and cannot be used to inject unexpected behavior into MultisigWallet.iter_wallets(), though the current implementation performs a simple string equality check against stored wallet names.
Security signals we found
No security-relevant signals detected in the diff or commit message.
Change is described as an enhancement in the changelog, not a bugfix or security fix.
New code validates that a named wallet exists before searching and raises a clear error if not.
No changes to cryptographic operations, key handling, memory safety, or authorization checks.
Evidence from the diff
The change extends the address ownership verification flow to accept and use the BIP-21 query parameter ‘wallet’. It modifies decode_bip21 handling in NFC and QR flows to pass parsed query arguments into OwnershipCache.search_ux(), which passes them to OwnershipCache.filter(). filter() now checks args.get(‘wallet’) and, if present, restricts MultisigWallet.iter_wallets() to the named wallet. If the named wallet does not exist, it raises UnknownAddressExplained. Tests cover both successful restricted searches and failure when the named wallet is missing or the address belongs to a different wallet.
Changed components
shared/ownership.pyshared/nfc.pyshared/ux_q1.pyshared/multisig.pytesting/test_decoders.pytesting/test_ownership.pyInspect captured patch +165 / −26
diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md
index 36bdade..e70390e 100644
--- a/releases/Next-ChangeLog.md
+++ b/releases/Next-ChangeLog.md
@@ -22,7 +22,10 @@ Spending policies for "Single Signers" adds new spending policy options:
- Enhancement: Show QR codes of output addresses in transaction output explorer. Explorer is
now offered for transactions of all sizes, not just complex ones.
- Enhancement: Added file rename, when listing contents of SD card.
-- Enhancement: Added ability to restore Coldcard backup via USB (requires updated ckcc, other)
+- Enhancement: Added ability to restore Coldcard backup via USB (TODO version of updated ckcc)
+- Enhancement: Address ownership allows to specify particular multisig wallet in which to search.
+ `wallet` query parameter is provided via [BIP-21](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki)
+ example: `tb1q4d67p7stxml3kdudrgkg5mgaxsrgzcqzjrrj4gg62nxtvnsnvqjsxjkej0?wallet=my_wal`
- Bugfix: If all change outputs have `nValue=0`, they were not shown in UX.
- Bugfix: Disallow negative input/output amounts in PSBT.
- Bugfix: Fix filesystem initialization after Wife LFS or Destroy Seed.
diff --git a/shared/multisig.py b/shared/multisig.py
index b17d044..83993d4 100644
--- a/shared/multisig.py
+++ b/shared/multisig.py
@@ -245,10 +245,11 @@ class MultisigWallet(WalletABC):
return rv
@classmethod
- def iter_wallets(cls, M=None, N=None, not_idx=None, addr_fmts=None):
+ def iter_wallets(cls, M=None, N=None, not_idx=None, addr_fmts=None, name=None):
# yield MS wallets we know about, that match at least right M,N if known.
# - this is only place we should be searching this list, please!!
# addr_fmts: list of address formats we're intersted in
+ # name: string ms wallet name
lst = settings.get('multisig', [])
for idx, rec in enumerate(lst):
@@ -256,6 +257,9 @@ class MultisigWallet(WalletABC):
# ignore one by index
continue
+ if name and (rec[0] != name):
+ continue
+
if M or N:
# peek at M/N
has_m, has_n = tuple(rec[1])
diff --git a/shared/nfc.py b/shared/nfc.py
index 2a48c2c..b01d4bc 100644
--- a/shared/nfc.py
+++ b/shared/nfc.py
@@ -736,7 +736,7 @@ class NFCHandler:
m = m.decode()
what, vals = decode_bip21_text(m)
if what == 'addr':
- return vals[1]
+ return vals
winner = await self._nfc_reader(f, 'Unable to find address from NFC data.')
@@ -744,10 +744,10 @@ class NFCHandler:
async def verify_address_nfc(self):
# Get an address or complete bip-21 url even and search it... slow.
- winner = await self.read_address()
- if winner:
+ _, addr, args = await self.read_address()
+ if addr:
from ownership import OWNERSHIP
- await OWNERSHIP.search_ux(winner)
+ await OWNERSHIP.search_ux(addr, args)
async def read_extended_private_key(self):
f = lambda x: x.decode().strip() if b"prv" in x else None
diff --git a/shared/ownership.py b/shared/ownership.py
index ef680a5..eb3434d 100644
--- a/shared/ownership.py
+++ b/shared/ownership.py
@@ -211,19 +211,31 @@ class OwnershipCache:
return doit
@classmethod
- def filter(cls, addr):
+ def filter(cls, addr, args):
# Filter possible candidates!
# - if you start w/ testnet, we'll follow that
from multisig import MultisigWallet
from public_constants import AFC_SCRIPT, AF_P2WPKH_P2SH, AF_P2SH, AF_P2WSH_P2SH
ch = chains.current_chain()
+ args = args or {}
addr_fmt = ch.possible_address_fmt(addr)
if not addr_fmt:
# might be valid address over on testnet vs mainnet
raise UnknownAddressExplained('That address is not valid on ' + ch.name)
+ # user has specified specific (named) wallet
+ named_wal = args.get("wallet", None)
+ if named_wal:
+ # quick search without deserialization
+ res = list(MultisigWallet.iter_wallets(name=named_wal))
+ if not res:
+ raise UnknownAddressExplained("Wallet '%s' not defined." % named_wal)
+
+ # only return desired named wallet, no other wallets are searched
+ return res
+
possibles = []
if addr_fmt & AFC_SCRIPT:
# multisig or script at least... must exist already
@@ -291,10 +303,10 @@ class OwnershipCache:
return None, None
@classmethod
- def search(cls, addr):
+ def search(cls, addr, args=None):
from glob import dis
- matches = OWNERSHIP.filter(addr)
+ matches = OWNERSHIP.filter(addr, args)
# build cache files for both external & internal chain
cachefs = []
@@ -332,7 +344,7 @@ class OwnershipCache:
' without finding a match.' % (c, len(matches)))
@classmethod
- async def search_ux(cls, addr):
+ async def search_ux(cls, addr, args):
# Provide a simple UX. Called functions do fullscreen, progress bar stuff.
from ux import ux_show_story, show_qr_code
from charcodes import KEY_QR
@@ -340,7 +352,7 @@ class OwnershipCache:
from public_constants import AFC_BECH32, AFC_BECH32M
try:
- _, wallet, subpath = cls.search(addr)
+ _, wallet, subpath = cls.search(addr, args)
is_ms = isinstance(wallet, MultisigWallet)
sp = wallet.render_path(*subpath)
diff --git a/shared/ux_q1.py b/shared/ux_q1.py
index bd7a488..0f5255e 100644
--- a/shared/ux_q1.py
+++ b/shared/ux_q1.py
@@ -1128,7 +1128,7 @@ async def ux_visualize_bip21(proto, addr, args):
if ch == '1':
from ownership import OWNERSHIP
- await OWNERSHIP.search_ux(addr)
+ await OWNERSHIP.search_ux(addr, args)
async def ux_visualize_wif(wif_str, kp, compressed, testnet):
# TODO: remove until we support signing w/ WIF keys IMHO
diff --git a/testing/test_decoders.py b/testing/test_decoders.py
index 50aa422..1eb16bf 100644
--- a/testing/test_decoders.py
+++ b/testing/test_decoders.py
@@ -54,40 +54,46 @@ def test_detector_bin(fname, expect, encoding, try_decode):
@pytest.mark.parametrize('url', [
'mtHSVByP9EYZmB26jASDdPVm19gvpecb5R',
+'BCRT1QUPYD58NDSH7LUT0ET0VTRQ432JVU9JTDX8FGYV',
'mtHSVByP9EYZmB26jASDdPVm19gvpecb5R?label=Luke-Jr',
'mtHSVByP9EYZmB26jASDdPVm19gvpecb5R?amount=20.3&label=Luke-Jr',
-'mtHSVByP9EYZmB26jASDdPVm19gvpecb5R?amount=50&label=Luke-Jr&message=Donation%20for%20project%20xyz',
+'BCRT1QUPYD58NDSH7LUT0ET0VTRQ432JVU9JTDX8FGYV?amount=50&label=Luke-Jr&message=Donation%20for%20project%20xyz',
'mtHSVByP9EYZmB26jASDdPVm19gvpecb5R?req-somethingyoudontunderstand=50&req-somethingelseyoudontget=999',
'mtHSVByP9EYZmB26jASDdPVm19gvpecb5R?somethingyoudontunderstand=50&somethingelseyoudontget=999',
+'tb1q4d67p7stxml3kdudrgkg5mgaxsrgzcqzjrrj4gg62nxtvnsnvqjsxjkej0?wallet=my_wal',
+'tb1q4d67p7stxml3kdudrgkg5mgaxsrgzcqzjrrj4gg62nxtvnsnvqjsxjkej0?wallet=my wal',
+'tb1q4d67p7stxml3kdudrgkg5mgaxsrgzcqzjrrj4gg62nxtvnsnvqjsxjkej0?wallet=my%20wal',
+'tb1q4d67p7stxml3kdudrgkg5mgaxsrgzcqzjrrj4gg62nxtvnsnvqjsxjkej0?wallet=my:wal',
+'tb1q4d67p7stxml3kdudrgkg5mgaxsrgzcqzjrrj4gg62nxtvnsnvqjsxjkej0?wallet=my-wal',
'mtHSVByP9EYZmB26jASDdPVm19gvpecb5R?label=total%20due:%20500',
])
@pytest.mark.parametrize('bip21', range(2))
-@pytest.mark.parametrize('addr_fmt', range(2))
-def test_detector_url(url, bip21, addr_fmt, try_decode):
- a1, a2 = ('mtHSVByP9EYZmB26jASDdPVm19gvpecb5R',
- 'BCRT1QUPYD58NDSH7LUT0ET0VTRQ432JVU9JTDX8FGYV')
+def test_detector_url(url, bip21, try_decode):
+ target = url.split('?', 1)[0]
+ if target[:2].lower() in ["tb", "bc"]:
+ target = target.lower()
if bip21:
- url = 'bitcoin:' + url
-
- if addr_fmt:
- url = url.replace(a1, a2)
- expect_addr = a2.lower()
- else:
- expect_addr = a1
+ url = f"bitcoin:{url}"
ft, vals = try_decode(url)
assert ft == 'addr'
proto, addr, args = vals
- assert addr == expect_addr
+ assert addr == target
assert proto == ('bitcoin' if bip21 else None)
p = urlparse(url)
assert (p.path == addr) or (p.path.lower() == addr.lower())
+ # below nest values to the list
xargs = parse_qs(p.query)
if args:
- assert xargs.keys() == args.keys()
+ assert len(xargs) == len(args)
+ for k, v in args.items():
+ val = xargs[k]
+ assert len(val) == 1
+ # unwrap value
+ assert val[0] == v
else:
assert not xargs
diff --git a/testing/test_ownership.py b/testing/test_ownership.py
index f7150d7..15a25a9 100644
--- a/testing/test_ownership.py
+++ b/testing/test_ownership.py
@@ -571,4 +571,118 @@ def test_20_more_build_after_match(sim_exec, import_ms_wallet, clear_ms, wipe_ca
assert "1 wallet(s)" in l
assert 'without finding a match' in l
+
+def test_named_wallet_search_fail(load_shared_mod, goto_home, pick_menu_item, nfc_write,
+ cap_story):
+ addr = fake_address(AF_P2WSH, True)
+ addr = f"{addr}?wallet=unknown"
+ cc_ndef = load_shared_mod('cc_ndef', '../shared/ndef.py')
+ n = cc_ndef.ndefMaker()
+ n.add_text(addr)
+ ccfile = n.bytes()
+
+ # run simulator w/ --set nfc=1 --eff
+ goto_home()
+ pick_menu_item('Advanced/Tools')
+ pick_menu_item('NFC Tools')
+ pick_menu_item('Verify Address')
+ open('debug/nfc-addr.ndef', 'wb').write(ccfile)
+ nfc_write(ccfile)
+
+ time.sleep(1)
+ title, story = cap_story()
+ assert addr.split("?", 1)[0] == addr_from_display_format(story.split("\n\n")[0])
+ assert "Wallet 'unknown' not defined." in story
+
+
+@pytest.mark.parametrize('valid', [True, False])
+@pytest.mark.parametrize('method', ["qr", "nfc"])
+def test_named_wallet_search(valid, method, clear_ms, import_ms_wallet, is_q1,
+ load_shared_mod, goto_home, pick_menu_item, scan_a_qr,
+ cap_story, need_keypress, nfc_write, use_testnet,
+ wipe_cache, settings_set):
+
+ from test_multisig import make_ms_address, HARD
+
+ if method == "qr" and (not is_q1):
+ raise pytest.skip("QR Mk")
+
+ wipe_cache() # very different codepaths
+ settings_set('accts', [])
+ use_testnet()
+ M, N = 2, 3
+ clear_ms()
+ ms_data = {}
+ # all ms wallets have same address format, different M/N
+ for i in range(3):
+ idx = 5
+ if i == 2:
+ idx = 763
+ name = f'msnw{i}'
+ keys = import_ms_wallet(M+i, N+i, AF_P2WSH, name=name, accept=True)
+ # last address
+ addr, scriptPubKey, script, details = make_ms_address(
+ M+i, keys, is_change=0, idx=idx, addr_fmt=AF_P2WSH,
+ testnet=True, path_mapper=lambda cosigner: [HARD(45), 0, idx]
+ )
+ ms_data[name] = (addr, scriptPubKey, script, keys)
+
+ if valid:
+ # msnw2 -> last added wallet
+ addr, *_ = ms_data["msnw2"]
+ else:
+ # will fail, even tho address is present in different wallet
+ # with wallet=<wal> only specified wallet is searched
+ addr, *_ = ms_data["msnw0"]
+
+ # will only search specified wallet
+ addr = f"{addr}?wallet=msnw2"
+
+ if method == 'qr':
+ goto_home()
+ pick_menu_item('Scan Any QR Code')
+ scan_a_qr(addr)
+ time.sleep(1)
+
+ title, story = cap_story()
+
+ assert addr.split("?", 1)[0] == addr_from_display_format(story.split("\n\n")[0])
+ assert '(1) to verify ownership' in story
+ need_keypress('1')
+
+ elif method == 'nfc':
+ cc_ndef = load_shared_mod('cc_ndef', '../shared/ndef.py')
+ n = cc_ndef.ndefMaker()
+ n.add_text(addr)
+ ccfile = n.bytes()
+
+ # run simulator w/ --set nfc=1 --eff
+ goto_home()
+ pick_menu_item('Advanced/Tools')
+ pick_menu_item('NFC Tools')
+ pick_menu_item('Verify Address')
+ open('debug/nfc-addr.ndef', 'wb').write(ccfile)
+ nfc_write(ccfile)
+ # press_select()
+
+ else:
+ raise ValueError(method)
+
+ time.sleep(1)
+ title, story = cap_story()
+ assert addr.split("?", 1)[0] == addr_from_display_format(story.split("\n\n")[0])
+
+ if valid:
+ assert title == ('Verified Address' if is_q1 else "Verified!")
+ assert 'Found in wallet' in story
+ assert 'Derivation path' in story
+
+ assert "msnw2" in story
+
+ else:
+ assert title == 'Unknown Address'
+ assert 'Searched 1528' in story # max
+ assert "1 wallet(s)" in story
+ assert 'without finding a match' in story
+
# EOF
Why this scored 20/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.