BBQRs and Create Airgapped
This commit is contained in:
parent
4579b1eff3
commit
602cc622bb
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user