diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md index 0395d73d..0a7b846e 100644 --- a/releases/Next-ChangeLog.md +++ b/releases/Next-ChangeLog.md @@ -31,18 +31,19 @@ This lists the new changes that have not yet been published in a normal release. # Mk4 Specific Changes -## 5.3.4 - 2024-08-xx +## 5.4.0 - 2024-09-12 - Shared enhancements and fixes listed above. # Q Specific Changes -## 1.2.4Q - 2024-08-xx +## 1.3.0Q - 2024-09-12 - New Feature: Seed XOR can be imported by scanning SeedQR parts. - New Feature: Input backup password from QR scan. - New Feature: (BB)QR file share of arbitrary files. +- New Feature: `Create Airgapped` now works with BBQRs - Bugfix: Properly clear LCD screen after BBQR is shown. - Bugfix: Writing to empty slot B caused broken card reader. - Bugfix: During Seed XOR import, display correct letter B if own seed already added to the mix. diff --git a/shared/auth.py b/shared/auth.py index 3ca20392..8bf24ad5 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -1506,11 +1506,9 @@ def usb_show_address(addr_format, subpath): class NewEnrollRequest(UserAuthorizedAction): - def __init__(self, ms, auto_export=False): + def __init__(self, ms): super().__init__() self.wallet = ms - self.auto_export = auto_export - # self.result ... will be re-serialized xpub async def interact(self): @@ -1520,14 +1518,7 @@ class NewEnrollRequest(UserAuthorizedAction): try: ch = await ms.confirm_import() - if ch == 'y': - if self.auto_export: - # save cosigner details now too - await ms.export_wallet_file('created on', - "\n\nImport that file onto the other Coldcards involved with this multisig wallet.") - await ms.export_electrum() - - else: + if ch != 'y': # they don't want to! self.refused = True await ux_dramatic_pause("Refused.", 2) diff --git a/shared/multisig.py b/shared/multisig.py index ca9671c7..5b9c9a23 100644 --- a/shared/multisig.py +++ b/shared/multisig.py @@ -4,9 +4,9 @@ # import stash, chains, ustruct, ure, uio, sys, ngu, uos, ujson, version from utils import xfp2str, str2xfp, swab32, cleanup_deriv_path, keypath_to_str, to_ascii_printable -from utils import str_to_keypath, problem_file_line, parse_extended_key +from utils import str_to_keypath, problem_file_line, parse_extended_key, get_filesize from ux import ux_show_story, ux_confirm, ux_dramatic_pause, ux_clear_keys -from ux import import_export_prompt, ux_enter_bip32_index, show_qr_code, OK, X +from ux import import_export_prompt, ux_enter_bip32_index, show_qr_code, ux_enter_number, OK, X from files import CardSlot, CardMissingError, needs_microsd from descriptor import MultisigDescriptor, multisig_descriptor_template from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AFC_SCRIPT, MAX_SIGNERS @@ -1506,7 +1506,7 @@ async def export_multisig_xpubs(*a): msg = '''\ This feature creates a small file containing \ the extended public keys (XPUB) you would need to join \ -a multisig wallet using the 'Create Airgapped' feature. +a multisig wallet. Public keys for BIP-48 conformant paths are used: @@ -1591,23 +1591,53 @@ P2WSH: # msg += '\n\nMultisig XPUB signature file written:\n\n%s' % sig_nice await ux_show_story(msg) -async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, force_vdisk=False): - # collect all xpub- exports on current SD card (must be >= 1) to make "air gapped" wallet - # - ask for M value - # - create wallet, save and also export - # - also create electrum skel to go with that - # - only expected to work with our ccxp-foo.json export files. - from utils import get_filesize +async def validate_xpub_for_ms(obj, af_str, deriv, chain, my_xfp, xpubs): + ln = obj.get(af_str) + # value in file is BE32, but we want LE32 internally + xfp = str2xfp(obj['xfp']) + if not deriv: + deriv = cleanup_deriv_path(obj[af_str + '_deriv']) + else: + assert deriv == obj[af_str + '_deriv'], "wrong derivation: %s != %s" % ( + deriv, obj[af_str + '_deriv']) - chain = chains.current_chain() - my_xfp = settings.get('xfp') + return MultisigWallet.check_xpub(xfp, ln, deriv, chain.ctype, my_xfp, xpubs), deriv +async def ms_coordinator_qr(af_str, my_xfp, chain): + from ux_q1 import QRScannerInteraction + num_mine = 0 + num_files = 0 + xpubs = [] + deriv = None + msg = 'Scan Multisig XPUBs from a BBQr' + while True: + vals = await QRScannerInteraction().scan_json(msg) + if vals: + try: + is_mine, deriv = await validate_xpub_for_ms(vals, af_str, deriv, + chain, my_xfp, xpubs) + except Exception as e: + msg = "Failure: %s" % str(e) + continue + + if is_mine: + num_mine += 1 + + num_files += 1 + + msg = "Number of keys scanned: %d" % len(xpubs) + if vals is None: break + + return xpubs, deriv, num_mine, num_files + + +async def ms_coordinator_file(af_str, my_xfp, chain, slot_b=None): + num_mine = 0 + num_files = 0 xpubs = [] - files = [] - has_mine = 0 deriv = None try: - with CardSlot(force_vdisk=force_vdisk) as card: + with CardSlot(slot_b=slot_b) as card: for path in card.get_paths(): for fn, ftype, *var in uos.ilistdir(path): if ftype == 0x4000: @@ -1632,22 +1662,12 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, force_vdisk= with open(full_fname, 'rt') as fp: vals = ujson.load(fp) - ln = vals.get(mode) - - # value in file is BE32, but we want LE32 internally - xfp = str2xfp(vals['xfp']) - if not deriv: - deriv = cleanup_deriv_path(vals[mode+'_deriv']) - else: - assert deriv == vals[mode+'_deriv'], "wrong derivation: %s != %s"%( - deriv, vals[mode+'_deriv']) - - is_mine = MultisigWallet.check_xpub(xfp, ln, deriv, - chain.ctype, my_xfp, xpubs) + is_mine, deriv = await validate_xpub_for_ms(vals, af_str, deriv, + chain, my_xfp, xpubs) if is_mine: - has_mine += 1 + num_mine += 1 - files.append(fn) + num_files += 1 except CardMissingError: raise @@ -1661,28 +1681,51 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, force_vdisk= await needs_microsd() return - # remove dups; easy to happen if you double-tap the export - delme = set() - for i in range(len(xpubs)): - for j in range(len(xpubs)): - if j in delme: continue - if i == j: continue - if xpubs[i] == xpubs[j]: - delme.add(j) - if delme: - xpubs = [x for idx,x in enumerate(xpubs) if idx not in delme] + return xpubs, deriv, num_mine, num_files - if not xpubs or len(xpubs) == 1 and has_mine: - await ux_show_story("Unable to find any Coldcard exported keys on this card. Must have filename: ccxp-....json") +async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False): + # collect all xpub- exports (must be >= 1) to make "air gapped" wallet + # - function f specifies a way how to collect co-signer info - currently SD and QR (Q only) + # - ask for M value + # - create wallet, save and also export + # - also create electrum skel to go with that + # - only expected to work with our ccxp-foo.json export file format + from glob import dis + + chain = chains.current_chain() + my_xfp = settings.get('xfp') + + if is_qr: + xpubs, deriv, num_mine, num_files = await ms_coordinator_qr(mode, my_xfp, chain) + else: + xpubs, deriv, num_mine, num_files = await ms_coordinator_file(mode, my_xfp, chain) + if CardSlot.both_inserted(): + bxpubs, _, bnum_mine, bnum_files = await ms_coordinator_file(mode, my_xfp, + chain, True) + xpubs += bxpubs + num_mine += bnum_mine + num_files += bnum_files + + # # remove dups; easy to happen if you double-tap the export + xpubs = list(set(xpubs)) + + if not xpubs or len(xpubs) == 1 and num_mine: + if is_qr: + msg = "No XPUBs scanned. Exit." + else: + msg = ("Unable to find any Coldcard exported keys on this card." + " Must have filename: ccxp-....json") + await ux_show_story(msg) return # add myself if not included already - if not has_mine: - with stash.SensitiveValues() as sv: - node = sv.derive_path(deriv) - xpubs.append( (my_xfp, deriv, chain.serialize_public(node, AF_P2SH)) ) - else: - assert has_mine == 1, "same coldcard included" + if not num_mine: + if await ux_show_story("Add current Coldcard with above XFP ?", + title="[%s]" % xfp2str(my_xfp)): + dis.fullscreen("Wait...") + with stash.SensitiveValues() as sv: + node = sv.derive_path(deriv) + xpubs.append((my_xfp, deriv, chain.serialize_public(node, AF_P2SH))) N = len(xpubs) @@ -1692,34 +1735,11 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, force_vdisk= # pick useful M value to start assert N >= 2 - M = (N - 1) if N < 4 else ((N//2)+1) + 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 - while 1: - msg = '''How many need to sign?\n %d of %d - -Press (7 or 9) to change M value, or %s \ -to continue. - -If you expected more or less keys (N=%d #files=%d), \ -then check card and file contents. - -Coldcard multisig setup file and an Electrum wallet file will be created automatically.\ -''' % (M, N, OK, N, len(files)) - - ch = await ux_show_story(msg, escape='123479') - - if ch in '1234': - M = min(N, int(ch)) # undocumented shortcut - elif ch == '9': - M = min(N, M+1) - elif ch == '7': - M = max(1, M-1) - elif ch == 'x': - await ux_dramatic_pause('Aborted.', 2) - return - elif ch == 'y': - break - # create appropriate object assert 1 <= M <= N <= MAX_SIGNERS @@ -1728,7 +1748,7 @@ Coldcard multisig setup file and an Electrum wallet file will be created automat from auth import NewEnrollRequest, UserAuthorizedAction - UserAuthorizedAction.active_request = NewEnrollRequest(ms, auto_export=True) + UserAuthorizedAction.active_request = NewEnrollRequest(ms) # menu item case: add to stack from ux import the_ux @@ -1736,8 +1756,18 @@ Coldcard multisig setup file and an Electrum wallet file will be created automat async def create_ms_step1(*a): # Show story, have them pick address format. + ch = None + is_qr = False + if version.has_qr: + ch = await ux_show_story("Press "+ KEY_QR + " to scan multisg XPUBs from BBQr.") - ch = await ux_show_story('''\ + if ch == KEY_QR: + is_qr = True + ch = await ux_show_story("Choose address format. Default is P2WSH addresses (segwit)." + " Press (1) for P2SH-P2WSH.", escape="1") + + else: + ch = await ux_show_story('''\ Insert SD card (or eject SD card to use Virtual Disk) with exported XPUB files from at least one other \ Coldcard. A multisig wallet will be constructed using those keys and \ this device. @@ -1751,7 +1781,7 @@ Default is P2WSH addresses (segwit) or press (1) for P2SH-P2WSH.''', escape='1') else: return - return await ondevice_multisig_create(n, f) + return await ondevice_multisig_create(n, f, is_qr) async def import_multisig_nfc(*a): diff --git a/testing/test_multisig.py b/testing/test_multisig.py index cb438367..e238bb74 100644 --- a/testing/test_multisig.py +++ b/testing/test_multisig.py @@ -24,6 +24,7 @@ from ctransaction import CTransaction, CTxOut, CTxIn, COutPoint, uint256_from_st from io import BytesIO from hashlib import sha256 from bbqr import split_qrs +from charcodes import KEY_QR def HARD(n=0): @@ -1545,12 +1546,14 @@ def test_ms_sign_myself(M, use_regtest, make_myself_wallet, segwit, num_ins, dev @pytest.mark.parametrize('addr_fmt', ['p2wsh', 'p2sh-p2wsh']) @pytest.mark.parametrize('acct_num', [ 0, 99, 4321]) @pytest.mark.parametrize('N', [ 3, 14]) +@pytest.mark.parametrize('way', [ "sd", "qr"]) def test_make_airgapped(addr_fmt, acct_num, N, goto_home, cap_story, pick_menu_item, - need_keypress, microsd_path, set_bip39_pw, clear_ms, - get_settings, load_export, is_q1, press_select, press_cancel): + need_keypress, microsd_path, set_bip39_pw, clear_ms, enter_number, + get_settings, load_export, is_q1, press_select, press_cancel, + cap_screen, way, scan_a_qr, skip_if_useless_way): # test UX and math for bip45 export - # cleanup + skip_if_useless_way(way) from glob import glob for fn in glob(microsd_path('ccxp-*.json')): assert fn @@ -1587,61 +1590,95 @@ def test_make_airgapped(addr_fmt, acct_num, N, goto_home, cap_story, pick_menu_i pick_menu_item('Settings') pick_menu_item('Multisig Wallets') pick_menu_item('Create Airgapped') + if is_q1: + time.sleep(.1) + title, story = cap_story() + assert "scan multisg XPUBs from BBQr" in story + if way == "qr": + need_keypress(KEY_QR) + else: + press_select() + time.sleep(.1) title, story = cap_story() - assert 'XPUB' in story + if way == "sd": + assert 'XPUB' in story + else: + # only QR way offers this special prompt + assert "address format" in story if addr_fmt == 'p2wsh': press_select() elif addr_fmt == 'p2sh-p2wsh': need_keypress('1') elif addr_fmt == 'p2sh': - need_keypress('2') + need_keypress('2') # does not work imo else: assert 0, addr_fmt - time.sleep(.1) - title, story = cap_story() + if way == "qr": + # first non-json garbage + scan_a_qr("aaaaaaaaaaaaaaaaaaaa") + time.sleep(1) + scr = cap_screen() + assert f"Expected JSON data" in scr - assert ('(N=%d #files=%d' % (N, N)) in story + # JSON but wrong + _, parts = split_qrs('{"json": "but wrong","missing": "important data"}', + 'J', max_version=20) + for p in parts: + scan_a_qr(p) + + time.sleep(1) + scr = cap_screen() + assert f"Failure: xfp" in scr # missing xfp + + # need to scan json XPUBs here + for i, fname in enumerate(glob(microsd_path('ccxp-*.json'))): + with open(fname, 'r') as f: + jj = f.read() + _, parts = split_qrs(jj, 'J', max_version=20) + + for p in parts: + scan_a_qr(p) + + time.sleep(1) + scr = cap_screen() + assert f"Number of keys scanned: {i+1}" in scr + + press_cancel() # quit QR animation + + time.sleep(.1) + scr = cap_screen() + assert "How many need to sign?(M)" in scr if N == 3: - assert '2 of 3' in story M = 2 elif N == 14: - assert '8 of 14' in story M = 8 elif N == 4: - assert '3 of 4' in story - need_keypress('7') - time.sleep(.05) - title, story = cap_story() assert '2 of 4' in story M = 2 else: assert 0, N - press_select() - + enter_number(M) time.sleep(.1) title, story = cap_story() assert "Create new multisig" in story press_select() - - impf, fname = load_export("sd", label="Coldcard multisig setup", is_json=False, sig_check=False, - tail_check="Import that file onto the other Coldcards involved with this multisig wallet", - ret_fname=True) + # we use clear_ms fixture at the begining of each test + # new multisig wallet is first menu item + press_select() + pick_menu_item("Coldcard Export") + impf, fname = load_export("sd", label="Coldcard multisig setup", is_json=False, + sig_check=False, ret_fname=True) cc_fname = microsd_path(fname) assert f'Policy: {M} of {N}' in impf if addr_fmt != 'p2sh': assert f'Format: {addr_fmt.upper()}' in impf - wal, fname = load_export("sd", is_json=True, label="Electrum multisig wallet", sig_check=False, - ret_fname=True) - el_fname = microsd_path(fname) - assert f'{M}of{N}' in wal['wallet_type'] - press_select() press_select() @@ -1650,7 +1687,6 @@ def test_make_airgapped(addr_fmt, acct_num, N, goto_home, cap_story, pick_menu_i # capture useful test data for testing Electrum plugin, etc for fn in glob(microsd_path('ccxp-*.json')): shutil.copy(fn, 'data/multisig/'+fn.rsplit('/', 1)[1]) - shutil.copy(el_fname, f'data/multisig/el-{addr_fmt}-myself.json') shutil.copy(cc_fname, f'data/multisig/export-{addr_fmt}-myself.txt') json.dump(get_settings()['multisig'][0], @@ -1680,8 +1716,6 @@ def test_make_airgapped(addr_fmt, acct_num, N, goto_home, cap_story, pick_menu_i need_keypress('1') time.sleep(.05) - title, story = cap_story() - # test code ehre # abort import, good enough press_cancel()