enhancement: make multisig rename explicit
What changed, and why it matters
This commit changes how COLDCARD handles multisig wallet names. Previously, re-importing a wallet file or descriptor with a different name would silently rename the existing wallet. Now, renaming must be done through an explicit 'Rename' menu action, and re-importing a duplicate with a different name is rejected. This is a security-hardening UX change that prevents accidental or malicious wallet renaming during import, which could confuse users about which wallet they are signing for.
No immediate action required; this is a hardening improvement. Users should ensure firmware is updated to a release containing this change and verify multisig wallet names after enrollment or rename operations.
Security signals we found
Prevents silent wallet renaming via re-imported descriptors/files
Adds explicit user-controlled rename flow with duplicate-name guard
Reduces risk of UI confusion / social-engineering around multisig identity
Hardens duplicate-detection logic by removing name-change exception
Evidence from the diff
The patch removes the ‘name_change’ rename path from MultisigWallet.has_similar() and confirm_import(). Re-importing an already-enrolled multisig wallet with a different name is now treated as a duplicate and rejected. A new ‘Rename’ menu item (ms_wallet_rename) is added to the multisig wallet menu, allowing explicit in-place renaming with duplicate-name checks. Tests are updated to reflect that re-import no longer renames wallets and that the dedicated rename flow works.
Changed components
shared/multisig.pyreleases/Next-ChangeLog.mdtesting/test_ccc.pytesting/test_multisig.pytesting/test_sign.pyInspect captured patch +166 / −66
### releases/Next-ChangeLog.md
@@ -76,6 +76,9 @@ This lists the new changes that have not yet been published in a normal release.
during multisig wallet enrollment. Thanks to [@drk1wi](https://github.com/drk1wi) for reporting this.
- Bugfix: Reject backup files that request excessive password-derivation work.
- Bugfix: Reject duplicate multisig wallet names.
+- Change: Multisig wallet names can now be changed with a dedicated `Rename`
+ action in the wallet menu. Reimporting an enrollment file or descriptor no
+ longer renames an existing wallet.
- Bugfix: Separate the SE1 check nonce from the PIN digest. Thanks to
[@instagibbs](https://github.com/instagibbs) for reporting this issue.
- Bugfix: Clear volatile application data when the seed is wiped.
### shared/multisig.py
@@ -422,10 +422,7 @@ def commit(self):
def has_similar(self):
# check if we already have a saved duplicate to this proposed wallet
- # - return (name_change, diff_items, count_similar) where:
- # - name_change is existing wallet that has exact match, different name
- # - diff_items: text list of similarity/differences
- # - count_similar: same N, same xfp+paths
+ # - returns (is_duplicate, differences)
lst = self.get_xfp_paths()
c = self.find_match(self.M, self.N, lst, addr_fmts=[self.addr_fmt])
@@ -436,32 +433,31 @@ def has_similar(self):
# multi(2,A,B) is treated as duplicate of multi(2,B,A)
# consensus-wise they are different script/wallet but CC
# don't allow to import one if other already imported
- return None, ['xpubs'], 0
+ return False, ['xpubs']
elif self.bip67 != c.bip67:
# treat same keys inside different desc multi/sortedmulti as duplicates
# sortedmulti(2,A,B) is considered same as multi(2,A,B) or multi(2,B,A)
# do not allow to import multi if sortedmulti with the same set of keys
# already imported and vice-versa
- return None, ["BIP-67 clash"], 1
+ return True, ["BIP-67 clash"]
elif not self.bip67 and self.xpubs != c.xpubs:
# multi(2,A,B) and multi(2,B,A) are consensus-different scripts;
# treat as duplicates -- don't allow either if a same-keys variant
# in a different order is already enrolled
- return None, ["key order"], 1
- elif self.name == c.name:
- return None, [], 1
- elif self.name_is_used(self.name, c.storage_idx):
- return None, ['name already exists'], 1
- else:
- return c, ['name'], 0
+ return True, ["key order"]
+
+ if self.name_is_used(self.name, c.storage_idx):
+ return True, ['Name already exists.']
+
+ return True, ['All details are the same as existing!']
if self.name_is_used(self.name, self.storage_idx):
- return None, ['name already exists'], 1
+ return True, ['Name already exists.']
similar = MultisigWallet.find_candidates(lst)
if not similar:
# no matches, good.
- return None, [], 0
+ return False, None
# See if the xpubs are changing, which is risky... other differences like
# name are okay.
@@ -471,12 +467,10 @@ def has_similar(self):
diffs.add('M differs')
if c.addr_fmt != self.addr_fmt:
diffs.add('address type')
- if c.name != self.name:
- diffs.add('name')
if c.xpubs != self.xpubs:
diffs.add('xpubs')
- return None, diffs, len(similar)
+ return False, diffs
def delete(self):
# remove saved entry
@@ -1128,19 +1122,13 @@ async def confirm_import(self):
exp = '{M} signatures, from {N} possible co-signers, will be required to approve spends.'.format(M=M, N=N)
# Look for duplicate stuff
- name_change, diff_items, num_dups = self.has_similar()
+ is_dup, diff_items = self.has_similar()
- is_dup = False
- if name_change:
- story = 'Update NAME only of existing multisig wallet?'
- elif num_dups and isinstance(diff_items, list):
+ if is_dup:
# failures only
- story = "Duplicate wallet. "
+ story = "Duplicate wallet."
if diff_items:
- story += diff_items[0]
- else:
- story += 'All details are the same as existing!'
- is_dup = True
+ story += ' ' + diff_items[0]
elif diff_items:
# Concern here is overwrite when similar, but we don't overwrite anymore, so
# more of a warning about funny business.
@@ -1187,9 +1175,6 @@ async def confirm_import(self):
if ch == 'y' and not is_dup:
# save to nvram, may raise MultisigOutOfSpace
- if name_change:
- name_change.delete()
-
assert self.storage_idx == -1
self.commit()
await ux_dramatic_pause("Saved.", 2)
@@ -1489,6 +1474,7 @@ async def make_ms_wallet_menu(menu, label, item):
rv = [
MenuItem('"%s"' % ms.name, f=ms_wallet_detail, arg=ms),
MenuItem('View Details', f=ms_wallet_detail, arg=ms),
+ MenuItem('Rename', f=ms_wallet_rename, arg=ms),
MenuItem('Delete', f=ms_wallet_delete, arg=ms),
]
if ms.bip67:
@@ -1500,6 +1486,22 @@ async def make_ms_wallet_menu(menu, label, item):
rv.append(MenuItem('Descriptors', menu=make_ms_wallet_descriptor_menu, arg=ms))
return rv
+async def ms_wallet_rename(menu, label, item):
+ from ux import ux_input_text, the_ux
+
+ ms = item.arg
+ name = await ux_input_text(ms.name, max_len=20)
+ if not name or name == ms.name:
+ return
+
+ if ms.name_is_used(name, ms.storage_idx):
+ return await ux_show_story('Name in use.')
+
+ ms.name = name
+ ms.commit()
+ the_ux.pop()
+ the_ux.top_of_stack().update_contents()
+
async def make_ms_wallet_descriptor_menu(menu, label, item):
# descriptor menu
ms = item.arg
### testing/test_ccc.py
@@ -14,7 +14,7 @@
from mnemonic import Mnemonic
from bip32 import BIP32Node
from constants import AF_P2WSH
-from charcodes import KEY_QR, KEY_NFC
+from charcodes import KEY_CLEAR, KEY_QR, KEY_NFC
from bbqr import split_qrs
from psbt import BasicPSBT
@@ -1214,7 +1214,8 @@ def test_ccc_xpub_export(chain, c_num_words, acct, settings_set, load_export, se
def test_multiple_multisig_wallets(settings_set, setup_ccc, enter_enabled_ccc, ccc_ms_setup,
bitcoind_create_watch_only_wallet, cap_story, bitcoind,
policy_sign, settings_get, cap_menu, pick_menu_item,
- press_select, load_export, offer_ms_import, goto_home):
+ press_select, load_export, offer_ms_import, goto_home,
+ need_keypress, enter_text, is_q1):
# - 'build 2-of-N' path
goto_home()
settings_set("ccc", None)
@@ -1278,8 +1279,7 @@ def test_multiple_multisig_wallets(settings_set, setup_ccc, enter_enabled_ccc, c
assert mi not in m
# export one of the wallets
- w_mn, w_name = ami.rsplit(" ", 1)
- new_name = "new"
+ mi_prefix, old_name = ami.split(": ", 1)
pick_menu_item(ami) # just another ms wallet
pick_menu_item("Coldcard Export")
ms_conf = load_export("sd", label="Coldcard multisig setup", is_json=False)
@@ -1290,16 +1290,21 @@ def test_multiple_multisig_wallets(settings_set, setup_ccc, enter_enabled_ccc, c
press_select()
time.sleep(.1)
- # try rename
- ms_conf = ms_conf.replace(w_name, new_name)
- _, story = offer_ms_import(ms_conf)
- assert "Update NAME only of existing multisig wallet?" in story
- press_select()
- time.sleep(.1)
-
+ # rename from the wallet menu
enter_enabled_ccc(words)
+ pick_menu_item(ami)
+ pick_menu_item("Rename")
+ if is_q1:
+ new_name = "new"
+ need_keypress(KEY_CLEAR)
+ enter_text(new_name)
+ else:
+ new_name = old_name[:-1] + str(int(old_name[-1]) + 1)
+ need_keypress("5")
+ press_select()
+
m = cap_menu()
- assert f"{w_mn} {new_name}" in m
+ assert f"{mi_prefix}: {new_name}" in m
def test_remove_ccc(settings_set, setup_ccc, ccc_ms_setup, settings_get, policy_sign,
### testing/test_multisig.py
@@ -24,7 +24,7 @@
from io import BytesIO
from hashlib import sha256
from bbqr import split_qrs
-from charcodes import KEY_QR
+from charcodes import KEY_CLEAR, KEY_QR
def HARD(n=0):
@@ -1031,7 +1031,7 @@ def config(unique, wallet_name=name):
time.sleep(.1)
_, story = offer_ms_import(config(2))
- assert 'Duplicate wallet. name already exists' in story
+ assert 'Duplicate wallet. Name already exists.' in story
assert ('%s to approve' % OK) not in story
press_cancel()
@@ -1041,9 +1041,9 @@ def config(unique, wallet_name=name):
press_select()
time.sleep(.1)
- # Renaming the first wallet must not overwrite the second or delete the first.
+ # Reimporting the first wallet cannot rename it to the second wallet's name.
_, story = offer_ms_import(config(1, other_name))
- assert 'Duplicate wallet. name already exists' in story
+ assert 'Duplicate wallet. Name already exists.' in story
assert ('%s to approve' % OK) not in story
press_cancel()
assert [rec[0] for rec in settings_get('multisig')] == [name, other_name]
@@ -1074,8 +1074,8 @@ def doit(M, N, addr_fmt=None):
@pytest.mark.parametrize('N', [ 5, 10])
def test_import_dup_safe(N, clear_ms, make_multisig, offer_ms_import,
need_keypress, cap_story, goto_home, pick_menu_item,
- cap_menu, is_q1, press_select, OK):
- # import wallet, rename it, (check that indicated, works), attempt same w/ addr fmt different
+ cap_menu, is_q1, press_select, press_cancel, OK):
+ # import wallet, reject duplicate, attempt same keys w/ addr fmt different
M = N
@@ -1108,13 +1108,11 @@ def has_name(name, num_wallets=1):
press_select()
has_name('xxx-orig')
- # just simple rename
+ # importing the same wallet under another name is still a duplicate
title, story = offer_ms_import(make_named('xxx-new'))
- assert 'update name only' in story.lower()
- assert 'xxx-new' in story
-
- press_select()
- has_name('xxx-new')
+ assert 'Duplicate wallet' in story
+ press_cancel()
+ has_name('xxx-orig')
assert N < 15, 'cant make more, no space'
@@ -3611,8 +3609,9 @@ def build_desc(klist):
@pytest.mark.parametrize("is_sorted", [True, False])
-def test_import_same_keys_same_order_rename(is_sorted, clear_ms, make_multisig, offer_ms_import,
- settings_set, cap_story, press_select, press_cancel):
+def test_import_same_keys_same_order_different_name(is_sorted, clear_ms, make_multisig,
+ offer_ms_import, settings_set, cap_story,
+ press_select, press_cancel):
settings_set("unsort_ms", 1)
clear_ms()
M, N = 2, 3
@@ -3627,13 +3626,12 @@ def test_import_same_keys_same_order_rename(is_sorted, clear_ms, make_multisig,
time.sleep(.1)
title, story = offer_ms_import(json.dumps({"name": "renamed", "desc": desc}))
- assert "Update NAME only" in story
- assert "Duplicate wallet" not in story
+ assert "Duplicate wallet. All details are the same as existing!" in story
press_cancel()
-def test_import_sortedmulti_reorder_rename(clear_ms, make_multisig, offer_ms_import,
- cap_story, press_select, press_cancel):
+def test_import_sortedmulti_reorder_different_name(clear_ms, make_multisig, offer_ms_import,
+ cap_story, press_select, press_cancel):
clear_ms()
M, N = 2, 3
keys = make_multisig(M, N)
@@ -3650,11 +3648,104 @@ def build_desc(klist):
keys[0], keys[1] = keys[1], keys[0]
title, story = offer_ms_import(json.dumps({"name": "renamed", "desc": build_desc(keys)}))
- assert "Update NAME only" in story
- assert "Duplicate wallet" not in story
+ assert "Duplicate wallet" in story
+ press_cancel()
+
+
+def test_reimport_unnamed_descriptor(clear_ms, make_multisig, offer_ms_import,
+ press_select, press_cancel):
+ clear_ms()
+ M, N = 2, 3
+
+ def make_desc(unique):
+ keys = make_multisig(M, N, unique=unique)
+ key_list = [(xfp, "m/45h", sk.hwif(as_private=False)) for xfp, _, sk in keys]
+ return MultisigDescriptor(M=M, N=N, keys=key_list, addr_fmt=AF_P2WSH,
+ is_sorted=True).serialize()
+
+ desc = make_desc(0)
+ _, story = offer_ms_import(desc)
+ assert "Create new multisig" in story
+ assert "2-of-3" in story
+ press_select()
+ time.sleep(.1)
+
+ _, story = offer_ms_import(desc)
+ assert "Duplicate wallet" in story
+ press_cancel()
+
+ _, story = offer_ms_import(make_desc(1))
+ assert "Create new multisig" in story
+ assert "2-of-3 #2" in story
press_cancel()
+def test_rename_wallet(clear_ms, import_ms_wallet, goto_home, pick_menu_item, cap_menu,
+ cap_story, need_keypress, enter_text, press_select, press_cancel,
+ settings_get, is_q1):
+ clear_ms()
+ import_ms_wallet(2, 3, name='123', unique=0, accept=True)
+ import_ms_wallet(2, 3, name='125', unique=1, accept=True)
+
+ goto_home()
+ pick_menu_item('Settings')
+ pick_menu_item('Multisig Wallets')
+ pick_menu_item('2/3: 123')
+ pick_menu_item('Rename')
+
+ if is_q1:
+ need_keypress(KEY_CLEAR)
+ enter_text('124')
+ else:
+ need_keypress('5') # change final digit from 3 to 4
+ press_select()
+
+ menu = cap_menu()
+ assert '2/3: 124' in menu
+ assert '2/3: 123' not in menu
+
+ pick_menu_item('2/3: 124')
+ menu = cap_menu()
+ assert '"124"' in menu
+ assert '"123"' not in menu
+ assert 'View Details' in menu
+ assert 'Rename' in menu
+
+ pick_menu_item('Rename')
+ if is_q1:
+ need_keypress(KEY_CLEAR)
+ enter_text('125')
+ else:
+ need_keypress('5') # change final digit from 4 to 5
+ press_select()
+
+ title, story = cap_story()
+ assert 'Name in use.' in title + story
+ press_select()
+
+ menu = cap_menu()
+ assert '"124"' in menu
+ pick_menu_item('Rename')
+ if is_q1:
+ press_cancel()
+ else:
+ # Delete the name and confirm leaving it unchanged.
+ press_cancel()
+ press_cancel()
+ press_cancel()
+ _, story = cap_story()
+ assert 'leave without any changes' in story
+ press_select()
+
+ menu = cap_menu()
+ assert '"124"' in menu
+ press_cancel()
+ menu = cap_menu()
+ assert '2/3: 124' in menu
+ assert '2/3: 125' in menu
+ assert [rec[0] for rec in settings_get('multisig')] == ['124', '125']
+
+
@pytest.mark.parametrize("order", list(itertools.product([True, False], repeat=2)))
def test_import_duplicate_shuffled_keys(clear_ms, make_multisig, import_ms_wallet,
cap_story, press_cancel, order, OK):
### testing/test_sign.py
@@ -3424,7 +3424,6 @@ def test_txout_explorer_qr_too_big_single_item(fake_txn, start_sign, cap_story,
def test_low_R_grinding(dev, goto_home, microsd_path, press_select, offer_ms_import,
cap_story, try_sign, reset_seed_words, clear_ms):
reset_seed_words()
- clear_ms()
desc = "sh(sortedmulti(2,[6ba6cfd0/45h]tpubD9429UXFGCTKJ9NdiNK4rC5ygqSUkginycYHccqSg5gkmyQ7PZRHNjk99M6a6Y3NY8ctEUUJvCu6iCCui8Ju3xrHRu3Ez1CKB4ZFoRZDdP9/0/*,[747b698e/45h]tpubD97nVL37v5tWyMf9ofh5rznwhh1593WMRg6FT4o6MRJkKWANtwAMHYLrcJFsFmPfYbY1TE1LLQ4KBb84LBPt1ubvFwoosvMkcWJtMwvXgSc/0/*,[7bb026be/45h]tpubD9ArfXowvGHnuECKdGXVKDMfZVGdephVWg8fWGWStH3VKHzT4ph3A4ZcgXWqFu1F5xGTfxncmrnf3sLC86dup2a8Kx7z3xQ3AgeNTQeFxPa/0/*,[0f056943/45h]tpubD8NXmKsmWp3a3DXhbihAYbYLGaRNVdTnr6JoSxxfXYQcmwVtW2hv8QoDwng6JtEonmJoL3cNEwfd2cLXMpGezwZ2vL2dQ7259bueNKj9C8n/0/*))#up0sw2xp"
# PSBT created via fake_ms_txn, grinded in test_ms_sign_myself
psbt_fname = "myself-72sig.psbt"
@@ -3445,11 +3444,11 @@ def test_low_R_grinding(dev, goto_home, microsd_path, press_select, offer_ms_imp
assert "[747B698E]" in title
press_select()
+ clear_ms()
time.sleep(.1)
_, story = offer_ms_import(desc)
- assert "Create new multisig wallet?" in story \
- or 'Update NAME only of existing multisig' in story
+ assert "Create new multisig wallet?" in story
time.sleep(.1)
press_select()
Why this scored 34/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.