What changed, and why it matters
This commit changes how number-entry prompts behave across the COLDCARD wallet interface. Previously, many prompts did not allow the user to cancel, so pressing the cancel button could be misread as entering '0' and the operation would continue. Now, canceling is explicitly allowed and the code checks for it, returning the user to the previous screen instead of proceeding with a default value. This is a user-experience and safety improvement rather than a remote-exploitable bug, but it prevents accidental or coerced confirmations in security-sensitive flows like exporting xpubs, creating multisig wallets, and signing messages.
Treat as a routine safety/UX fix. Reviewers should verify that every changed call site now checks for `None` before using the returned number, and that no remaining `or 0` fallback bypasses the new cancellation semantics. No emergency response is warranted.
Security signals we found
Default behavior change: numeric entry dialogs now allow cancellation by default
Multiple sensitive flows no longer treat cancel as confirmation/default value
New regression tests assert cancel preserves prior menu state
No cryptographic, memory-safety, or remote-attack changes present
Evidence from the diff
The patch flips the default of can_cancel from False to True in ux_enter_bip32_index and both ux_enter_number implementations (Mk4 and Q1). It then updates every caller to handle a None return value as cancellation instead of falling back to 0 via or 0. Affected flows include xpub/descriptor/key-expression exports, address explorer account/start-index changes, message signing subpath entry, multisig creation and XPUB export, BIP-85 derived seed index selection, and CCC spending-policy edits. Tests are added to verify that cancel returns the user to the prior menu without changing state.
Changed components
shared/ux.pyshared/ux_mk4.pyshared/ux_q1.pyshared/actions.pyshared/address_explorer.pyshared/auth.pyshared/ccc.pyshared/drv_entro.pyshared/msgsign.pyshared/multisig.pyInspect captured patch +114 / −49
diff --git a/shared/actions.py b/shared/actions.py
index 8a63c6a..4453548 100644
--- a/shared/actions.py
+++ b/shared/actions.py
@@ -1100,13 +1100,13 @@ async def export_xpub(label, _2, item):
if ch == 'x': return
if ch == "2":
slip132 = not slip132
- continue
+
if ch == '1':
- acct = await ux_enter_bip32_index('Account Number:') or 0
+ acct = await ux_enter_bip32_index('Account Number:')
+ if acct is None: continue
pth_split = path.split("/")
pth_split[-1] = ("%dh" % acct)
path = "/".join(pth_split)
- continue
# assume zero account if not picked
path = path.format(acct=acct)
@@ -1144,15 +1144,16 @@ async def electrum_skeleton(a, b, item):
ch = await ux_show_story(electrum_export_story(title), escape='1')
- account_num = 0
+ acct = 0
if ch == '1':
- account_num = await ux_enter_bip32_index('Account Number:') or 0
- elif ch != 'y':
+ acct = await ux_enter_bip32_index('Account Number:')
+
+ if (ch != 'y') or acct is None:
return
rv = [
MenuItem(chains.addr_fmt_label(af), f=electrum_skeleton_step2,
- arg=(af, account_num, title, fname_pat))
+ arg=(af, acct, title, fname_pat))
for af in chains.SINGLESIG_AF
]
the_ux.push(MenuSystem(rv))
@@ -1173,13 +1174,14 @@ async def ss_descriptor_skeleton(_0, _1, item):
int_ext, allowed_af, ll, f_pattern, direct_way = item.arg
addition = " for " + ll
- account_num = 0
+ acct = 0
if not direct_way:
ch = await ux_show_story(ss_descriptor_export_story(addition), escape='1')
if ch == '1':
- account_num = await ux_enter_bip32_index('Account Number:', unlimited=True) or 0
- elif ch != 'y':
+ acct = await ux_enter_bip32_index('Account Number:', unlimited=True)
+
+ if (ch != 'y') or acct is None:
return
if int_ext is None:
@@ -1191,12 +1193,12 @@ async def ss_descriptor_skeleton(_0, _1, item):
int_ext = False if ch == "1" else True
if len(allowed_af) == 1:
- await make_descriptor_wallet_export(allowed_af[0], account_num, int_ext=int_ext,
+ await make_descriptor_wallet_export(allowed_af[0], acct, int_ext=int_ext,
fname_pattern=f_pattern, direct_way=direct_way)
else:
rv = [
MenuItem(chains.addr_fmt_label(af), f=descriptor_skeleton_step2,
- arg=(af, account_num, int_ext, f_pattern, direct_way))
+ arg=(af, acct, int_ext, f_pattern, direct_way))
for af in allowed_af
]
the_ux.push(MenuSystem(rv))
@@ -1210,12 +1212,13 @@ async def key_expression_skeleton_step2(_1, _2, item):
async def key_expression_skeleton(_0, _1, item):
# Export key expression -> [xfp/d/e/r]xpub
- acct_num = 0
+ acct = 0
ch = await ux_show_story("This saves a extended key expression."
+ PICK_ACCOUNT + SENSITIVE_NOT_SECRET, escape='1')
if ch == '1':
- acct_num = await ux_enter_bip32_index('Account Number:', unlimited=True) or 0
- elif ch != 'y':
+ acct = await ux_enter_bip32_index('Account Number:', unlimited=True)
+
+ if (ch != 'y') or acct is None:
return
# element on 2nd index is address format for signed exports
@@ -1235,7 +1238,7 @@ async def key_expression_skeleton(_0, _1, item):
ct = chains.current_chain().b44_cointype
- rv = [ MenuItem(label, f=key_expression_skeleton_step2, arg=(orig_der % (ct, acct_num), af))
+ rv = [ MenuItem(label, f=key_expression_skeleton_step2, arg=(orig_der % (ct, acct), af))
for label, orig_der, af in todo ]
rv += [ MenuItem("Custom Path", menu=doit) ]
@@ -1285,14 +1288,15 @@ You can then run the commands in Bitcoin Core's console window, \
without ever connecting this Coldcard to a computer.\
''' + PICK_ACCOUNT + SENSITIVE_NOT_SECRET, escape='1')
- account_num = 0
+ acct = 0
if ch == '1':
- account_num = await ux_enter_bip32_index('Account Number:') or 0
- elif ch != 'y':
+ acct = await ux_enter_bip32_index('Account Number:')
+
+ if (ch != 'y') or acct is None:
return
# no choices to be made, just do it.
- await make_bitcoin_core_wallet(account_num)
+ await make_bitcoin_core_wallet(acct)
async def electrum_skeleton_step2(_1, _2, item):
@@ -1306,13 +1310,14 @@ async def _generic_export(prompt, label, f_pattern):
# like the Multisig export, make a single JSON file with
# basically all useful XPUB's in it.
ch = await ux_show_story(prompt + PICK_ACCOUNT + SENSITIVE_NOT_SECRET, escape="1")
- account_num = 0
+ acct = 0
if ch == '1':
- account_num = await ux_enter_bip32_index('Account Number:') or 0
- elif ch != 'y':
+ acct = await ux_enter_bip32_index('Account Number:')
+
+ if (ch != 'y') or acct is None:
return
- await export_contents(label, lambda: generate_generic_export(account_num),
+ await export_contents(label, lambda: generate_generic_export(acct),
f_pattern, is_json=True)
async def generic_skeleton(*A):
@@ -1357,16 +1362,17 @@ async def unchained_capital_export(*a):
ch = await ux_show_story('''\
This saves multisig XPUB information required to setup on the Unchained platform. \
''' + PICK_ACCOUNT + SENSITIVE_NOT_SECRET, escape="1")
- account_num = 0
+ acct = 0
if ch == '1':
- account_num = await ux_enter_bip32_index('Account Number:') or 0
- elif ch != 'y':
+ acct = await ux_enter_bip32_index('Account Number:')
+
+ if (ch != 'y') or acct is None:
return
xfp = xfp2str(settings.get('xfp', 0))
fname = 'unchained-%s.json' % xfp
- await export_contents('Unchained', lambda: generate_unchained_export(account_num),
+ await export_contents('Unchained', lambda: generate_unchained_export(acct),
fname, is_json=True)
diff --git a/shared/address_explorer.py b/shared/address_explorer.py
index c0c3ba8..838c815 100644
--- a/shared/address_explorer.py
+++ b/shared/address_explorer.py
@@ -115,7 +115,7 @@ class KeypathMenu(MenuSystem):
val = item.arg or item.label
assert val.endswith('/⋯')
cpath = val[:-2]
- nl = await ux_enter_bip32_index('%s/' % cpath, unlimited=True)
+ nl = await ux_enter_bip32_index('%s/' % cpath, unlimited=True, can_cancel=False)
return KeypathMenu(cpath, nl, ranged=self.ranged, done_fn=self.done_fn)
class PickAddrFmtMenu(MenuSystem):
@@ -241,11 +241,15 @@ class AddressListMenu(MenuSystem):
self.goto_idx(axi)
async def change_account(self, *a):
- self.account_num = await ux_enter_bip32_index('Account Number:') or 0
+ acct = await ux_enter_bip32_index('Account Number:')
+ if acct is None: return
+ self.account_num = acct
await self.render()
async def change_start_idx(self, *a):
- self.start = await ux_enter_bip32_index("Start index:", unlimited=True)
+ idx = await ux_enter_bip32_index("Start index:", unlimited=True)
+ if idx is None: return
+ self.start = idx
await self.render()
async def pick_single(self, _1, _2, item):
diff --git a/shared/auth.py b/shared/auth.py
index 378f9d8..7a94d52 100644
--- a/shared/auth.py
+++ b/shared/auth.py
@@ -1663,8 +1663,7 @@ class TXExplorer:
start += self.n
elif ch == "2":
max_v = self.max_items - 1
- res = await ux_enter_number("Start Idx (0-%d):" % max_v, max_value=max_v,
- can_cancel=True)
+ res = await ux_enter_number("Start Idx (0-%d):" % max_v, max_value=max_v)
if res is None: continue
start = res
else:
diff --git a/shared/ccc.py b/shared/ccc.py
index 160a2cf..2071d07 100644
--- a/shared/ccc.py
+++ b/shared/ccc.py
@@ -746,7 +746,7 @@ class SpendingPolicyMenu(MenuSystem):
# Looks decent on both Q and Mk4...
was = self.policy.get('mag', 0)
val = await ux_enter_number('Transaction Max:', max_value=int(1e8),
- can_cancel=True, value=(was or ''))
+ value=(was or ''))
args = dict(mag=val)
if (val is None) or (val == was):
diff --git a/shared/drv_entro.py b/shared/drv_entro.py
index d4ab62f..e928344 100644
--- a/shared/drv_entro.py
+++ b/shared/drv_entro.py
@@ -124,8 +124,7 @@ async def drv_entro_step2(_1, picked, _2, just_pick=False):
msg = "Password Index?" if picked == 7 else "Index Number?"
index = await ux_enter_bip32_index(msg, unlimited=settings.get("b85max", False))
- if index is None:
- return
+ if index is None: return
dis.fullscreen("Working...")
new_secret, width, s_mode, path = bip85_derive(picked, index)
@@ -292,7 +291,7 @@ async def password_entry(*args, **kwargs):
while True:
the_ux.pop()
- index = await ux_enter_bip32_index("Password Index?", can_cancel=True)
+ index = await ux_enter_bip32_index("Password Index?")
if index is None:
break
diff --git a/shared/msgsign.py b/shared/msgsign.py
index d95acd3..848819f 100644
--- a/shared/msgsign.py
+++ b/shared/msgsign.py
@@ -179,14 +179,16 @@ async def msg_sign_ux_get_subpath(addr_fmt):
purpose = chains.af_to_bip44_purpose(addr_fmt)
chain_n = chains.current_chain().b44_cointype
- acct = await ux_enter_bip32_index('Account Number:') or 0
+ acct = await ux_enter_bip32_index('Account Number:')
+ if acct is None: return
ch = await ux_show_story(title="Change?",
msg="Press (0) to use internal/change address,"
" %s to use external/receive address." % OK, escape="0")
change = 1 if ch == '0' else 0
- idx = await ux_enter_bip32_index('Index Number:') or 0
+ idx = await ux_enter_bip32_index('Index Number:')
+ if idx is None: return
return "m/%dh/%dh/%dh/%d/%d" % (purpose, chain_n, acct, change, idx)
@@ -408,6 +410,7 @@ async def ux_sign_msg(txt, approved_cb=None, kill_menu=True):
text, af = item.arg
subpath = await msg_sign_ux_get_subpath(af)
+ if subpath is None: return
await approve_msg_sign(text, subpath, af, approved_cb=approved_cb,
kill_menu=kill_menu, only_printable=False)
diff --git a/shared/multisig.py b/shared/multisig.py
index df7192f..ef1bb37 100644
--- a/shared/multisig.py
+++ b/shared/multisig.py
@@ -1566,7 +1566,8 @@ P2WSH:
if ch != "y":
return
- acct = await ux_enter_bip32_index('Account Number:') or 0
+ acct = await ux_enter_bip32_index('Account Number:')
+ if acct is None: return
def render(acct_num):
sign_der = None
@@ -1779,7 +1780,8 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False,
secret, ccc_ms_count = for_ccc
# Always include 2 keys from CCC: own master (key A) and key C
# - force them to same derivation.
- acct = await ux_enter_bip32_index('CCC Account Number:') or 0
+ acct = await ux_enter_bip32_index('CCC Account Number:')
+ if acct is None: return
dis.fullscreen("Wait...")
a = add_own_xpub(chain, acct, addr_fmt) # master: key A
@@ -1804,7 +1806,8 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False,
ch = await ux_show_story("Add current Coldcard with above XFP ?",
title="[%s]" % xfp2str(my_xfp))
if ch == "y":
- acct = await ux_enter_bip32_index('Account Number:') or 0
+ acct = await ux_enter_bip32_index('Account Number:')
+ if acct is None: return
dis.fullscreen("Wait...")
xpubs.append(add_own_xpub(chain, acct, addr_fmt))
num_mine += 1
@@ -1819,10 +1822,8 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False,
M = 2
else:
# pick useful M value to start
- M = await ux_enter_number("How many need to sign?(M)", N, can_cancel=True)
- if not M:
- await ux_dramatic_pause('Aborted.', 2)
- return # user cancel
+ M = await ux_enter_number("How many need to sign?(M)", N)
+ if M is None: return
dis.fullscreen("Wait...")
diff --git a/shared/ux.py b/shared/ux.py
index a322a96..c2d19ab 100644
--- a/shared/ux.py
+++ b/shared/ux.py
@@ -349,7 +349,7 @@ async def show_qr_code(data, is_alnum=False, msg=None, **kw):
o = QRDisplaySingle([data], is_alnum, msg=msg, **kw)
await o.interact_bare()
-async def ux_enter_bip32_index(prompt, can_cancel=False, unlimited=False):
+async def ux_enter_bip32_index(prompt, can_cancel=True, unlimited=False):
if unlimited:
max_value = (2 ** 31) - 1 # we handle hardened
else:
diff --git a/shared/ux_mk4.py b/shared/ux_mk4.py
index 29a5d9c..8750e72 100644
--- a/shared/ux_mk4.py
+++ b/shared/ux_mk4.py
@@ -60,7 +60,7 @@ class PressRelease:
return ch
-async def ux_enter_number(prompt, max_value, can_cancel=False, value=''):
+async def ux_enter_number(prompt, max_value, can_cancel=True, value=''):
# return the decimal number which the user has entered
# - default/blank value assumed to be zero
# - clamps large values to the max
diff --git a/shared/ux_q1.py b/shared/ux_q1.py
index 35bdb72..768d1ca 100644
--- a/shared/ux_q1.py
+++ b/shared/ux_q1.py
@@ -76,7 +76,7 @@ class PressRelease:
self.last_key = ch
return ch
-async def ux_enter_number(prompt, max_value, can_cancel=False, value=''):
+async def ux_enter_number(prompt, max_value, can_cancel=True, value=''):
# return the decimal number which the user has entered
# - default/blank value assumed to be zero
# - clamps large values to the max
diff --git a/testing/test_address_explorer.py b/testing/test_address_explorer.py
index 1f2021c..de35d86 100644
--- a/testing/test_address_explorer.py
+++ b/testing/test_address_explorer.py
@@ -554,4 +554,27 @@ def test_custom_path(path_sidx, which_fmt, addr_vs_path, pick_menu_item, goto_ad
for p, a in addr_gen:
addr_vs_path(a, p, addr_fmt=which_fmt)
+
+def test_change_account_cancel(goto_address_explorer, pick_menu_item, press_cancel, cap_menu):
+ goto_address_explorer()
+ time.sleep(.2)
+ pick_menu_item('Account Number')
+ time.sleep(.1)
+ press_cancel()
+ time.sleep(.2)
+ assert "Account Number" in cap_menu()
+
+
+def test_change_start_idx_cancel(goto_address_explorer, pick_menu_item, press_cancel, settings_set,
+ settings_remove, cap_menu):
+ settings_set('aei', 1)
+ goto_address_explorer()
+ time.sleep(.2)
+ pick_menu_item('Start Idx: 0')
+ time.sleep(.1)
+ press_cancel()
+ time.sleep(.2)
+ assert 'Start Idx: 0' in cap_menu()
+ settings_remove('aei')
+
# EOF
diff --git a/testing/test_drv_entro.py b/testing/test_drv_entro.py
index fb5fde6..0276a72 100644
--- a/testing/test_drv_entro.py
+++ b/testing/test_drv_entro.py
@@ -396,4 +396,23 @@ def test_export_nfc_when_disabled(pick_menu_item, goto_home, cap_story, press_se
assert "Ready To Sign" in m
+def test_bip85_index_cancel(goto_home, pick_menu_item, press_select, press_cancel,
+ cap_screen, is_q1):
+ mi = 'Derive Seed B85' if not is_q1 else 'Derive Seeds (BIP-85)'
+ goto_home()
+ pick_menu_item('Advanced/Tools')
+ pick_menu_item(mi)
+ press_select() # intro story
+ time.sleep(.1)
+ pick_menu_item('12 words')
+ time.sleep(.1)
+ screen = cap_screen()
+ assert 'Index Number' in screen
+ # cancel should pop back to the choices menu, can_cancel=True
+ press_cancel()
+ time.sleep(.2)
+ screen = cap_screen()
+ assert 'Index Number' not in screen
+
+
# EOF
diff --git a/testing/test_multisig.py b/testing/test_multisig.py
index 8bca38b..266851e 100644
--- a/testing/test_multisig.py
+++ b/testing/test_multisig.py
@@ -4275,4 +4275,15 @@ def test_txin_explorer_our_sig(dev, fake_ms_txn, start_sign, settings_set, clear
start_sign(psbt)
txin_explorer(num_ins, [(af, inp_amount, 0, "XTN", (M,N), None, None, False, [my_xfp])])
+
+def test_ms_xpubs_account_cancel(goto_home, pick_menu_item, press_cancel, cap_menu, press_select):
+ goto_home()
+ pick_menu_item('Settings')
+ pick_menu_item('Multisig Wallets')
+ pick_menu_item('Export XPUB')
+ press_select() # confirm story
+ time.sleep(.1)
+ press_cancel()
+ time.sleep(.2)
+ assert "Export XPUB" in cap_menu()
# EOF
Why this scored 40/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.