diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md index 9b0de56a..46855713 100644 --- a/releases/Next-ChangeLog.md +++ b/releases/Next-ChangeLog.md @@ -4,7 +4,8 @@ This lists the new changes that have not yet been published in a normal release. # Shared Improvements - Both Mk4 and Q -- tbd +- New Feature: Export [BIP-380](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki) extended key expression. + Navigate to `Advanced/Tools -> Export Wallet -> Key Expression` # Mk4 Specific Changes diff --git a/shared/actions.py b/shared/actions.py index a0a7f654..690c997b 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -14,7 +14,7 @@ from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause, ux_aborted from ux import ux_enter_bip32_index, ux_input_text, import_export_prompt, OK, X, ux_render_words from export import export_contents, make_summary_file, make_descriptor_wallet_export from export import make_bitcoin_core_wallet, generate_wasabi_wallet, generate_generic_export -from export import generate_unchained_export, generate_electrum_wallet +from export import generate_unchained_export, generate_electrum_wallet, make_key_expression_export from files import CardSlot, CardMissingError, needs_microsd from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH from glob import settings @@ -1193,6 +1193,46 @@ async def ss_descriptor_skeleton(_0, _1, item): ] the_ux.push(MenuSystem(rv)) + +async def key_expression_skeleton_step2(_1, _2, item): + # pick a semi-random file name, render and save it. + orig_path = item.arg + await make_key_expression_export(orig_path) + +async def key_expression_skeleton(_0, _1, item): + # Export key expression -> [xfp/d/e/r]xpub + + acct_num = 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': + return + + todo = [ + ("Segwit P2WPKH", "m/84h/%dh/%dh"), + ("Classic P2PKH", "m/44h/%dh/%dh"), + ("P2SH-Segwit", "m/49h/%dh/%dh"), + ("Multi P2WSH", "m/48h/%dh/%dh/2h"), + ("Multi P2SH-P2WSH", "m/48h/%dh/%dh/1h"), + ] + + from address_explorer import KeypathMenu + + async def doit(*a): + return KeypathMenu(ranged=False, done_fn=make_key_expression_export) + + ct = chains.current_chain().b44_cointype + + rv = [ + MenuItem(label, f=key_expression_skeleton_step2, arg=orig_der % (ct, acct_num)) + for label, orig_der in todo + ] + rv += [MenuItem("Custom Path", menu=doit)] + + the_ux.push(MenuSystem(rv)) + async def samourai_post_mix_descriptor_export(*a): name = "POST-MIX" post_mix_acct_num = 2147483646 diff --git a/shared/address_explorer.py b/shared/address_explorer.py index 0452a6cd..c0c3ba8c 100644 --- a/shared/address_explorer.py +++ b/shared/address_explorer.py @@ -30,8 +30,10 @@ def censor_address(addr): return addr[0:12] + '___' + addr[12+3:] class KeypathMenu(MenuSystem): - def __init__(self, path=None, nl=0): + def __init__(self, path=None, nl=0, ranged=True, done_fn=None): self.prefix = None + self.done_fn = done_fn + self.ranged = ranged if path is None: # Top level menu; useful shortcuts, and special case just "m" @@ -40,10 +42,13 @@ class KeypathMenu(MenuSystem): MenuItem("m/44h/⋯", f=self.deeper), MenuItem("m/49h/⋯", f=self.deeper), MenuItem("m/84h/⋯", f=self.deeper), - MenuItem("m/0/{idx}", menu=self.done), - MenuItem("m/{idx}", menu=self.done), MenuItem("m", f=self.done), ] + if self.ranged: + items += [ + MenuItem("m/0/{idx}", menu=self.done), + MenuItem("m/{idx}", menu=self.done), + ] else: # drill down one layer: (nl) is the current leaf # - hardened choice first @@ -53,11 +58,14 @@ class KeypathMenu(MenuSystem): MenuItem(p+"/⋯", menu=self.deeper), MenuItem(p+"h", menu=self.done), MenuItem(p, menu=self.done), - MenuItem(p+"h/0/{idx}", menu=self.done), - MenuItem(p+"/0/{idx}", menu=self.done), #useful shortcut? - MenuItem(p+"h/{idx}", menu=self.done), - MenuItem(p+"/{idx}", menu=self.done), ] + if self.ranged: + items += [ + MenuItem(p + "h/0/{idx}", menu=self.done), + MenuItem(p + "/0/{idx}", menu=self.done), # useful shortcut? + MenuItem(p + "h/{idx}", menu=self.done), + MenuItem(p + "/{idx}", menu=self.done), + ] # simple consistent truncation when needed max_wide = max(len(mi.label) for mi in items) @@ -95,9 +103,12 @@ class KeypathMenu(MenuSystem): if isinstance(top, KeypathMenu): the_ux.pop() continue - assert isinstance(top, AddressListMenu) + # assert isinstance(top, AddressListMenu), type(top) break + if self.done_fn: + return await self.done_fn(final_path) + return PickAddrFmtMenu(final_path, top) async def deeper(self, _1, _2, item): @@ -105,7 +116,7 @@ class KeypathMenu(MenuSystem): assert val.endswith('/⋯') cpath = val[:-2] nl = await ux_enter_bip32_index('%s/' % cpath, unlimited=True) - return KeypathMenu(cpath, nl) + return KeypathMenu(cpath, nl, ranged=self.ranged, done_fn=self.done_fn) class PickAddrFmtMenu(MenuSystem): def __init__(self, path, parent): diff --git a/shared/export.py b/shared/export.py index b30bb3d7..52e80940 100644 --- a/shared/export.py +++ b/shared/export.py @@ -471,7 +471,7 @@ async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int dis.fullscreen('Generating...') chain = chains.current_chain() - xfp = settings.get('xfp') + xfp = settings.get('xfp', 0) dis.progress_bar_show(0.1) if mode is None: mode = chains.af_to_bip44_purpose(addr_type) @@ -503,5 +503,18 @@ async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int await export_contents("Descriptor", body, fname_pattern, derive + "/0/0", addr_type, force_prompt=True, direct_way=direct_way) + +async def make_key_expression_export(orig_der, fname_pattern="key_expr.txt"): + + xfp = xfp2str(settings.get('xfp', 0)).lower() + + with stash.SensitiveValues() as sv: + ek = chains.current_chain().serialize_public(sv.derive_path(orig_der)) + + body = "[%s/%s]%s" % (xfp, orig_der.replace("m/", ""), ek) + + await export_contents("Key Expression", body, fname_pattern, + None, None, force_prompt=True) + # EOF diff --git a/shared/flow.py b/shared/flow.py index b0f08006..45392d30 100644 --- a/shared/flow.py +++ b/shared/flow.py @@ -235,6 +235,7 @@ WalletExportMenu = [ MenuItem("Descriptor", f=ss_descriptor_skeleton), MenuItem("Generic JSON", f=generic_skeleton), MenuItem("Export XPUB", menu=XpubExportMenu), + MenuItem("Key Expression", f=key_expression_skeleton), MenuItem("Dump Summary", predicate=has_secrets, f=dump_summary), ] diff --git a/testing/conftest.py b/testing/conftest.py index 14e43082..1bd5d76c 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -2568,8 +2568,8 @@ def skip_if_useless_way(is_q1, nfc_disabled, vdisk_disabled): # when NFC is disabled, no point trying to do a PSBT via NFC # - important: run_sim_tests.py will enable NFC for complete testing # - similarly: the Mk4 and earlier had no QR scanner, so cannot use that as input - def doit(way): - if way == "qr" and not is_q1: + def doit(way, allow_mk4_qr=False): + if way == "qr" and (not is_q1 and not allow_mk4_qr): raise pytest.skip("mk4 QR not supported") elif way == 'nfc' and nfc_disabled(): # runner will test these cases, but fail faster otherwise diff --git a/testing/test_export.py b/testing/test_export.py index 9c539d1a..a9f02350 100644 --- a/testing/test_export.py +++ b/testing/test_export.py @@ -13,7 +13,6 @@ from bip32 import BIP32Node from ckcc_protocol.constants import * from helpers import xfp2str, slip132undo from conftest import simulator_fixed_xfp, simulator_fixed_tprv, simulator_fixed_words, simulator_fixed_xprv -from ckcc_protocol.constants import AF_CLASSIC, AF_P2WPKH from pprint import pprint from charcodes import KEY_NFC, KEY_QR @@ -888,4 +887,131 @@ def test_samourai_vs_generic(chain, account, settings_set, pick_menu_item, goto_ file_desc = load_export("sd", label="Descriptor", is_json=False, addr_fmt=AF_P2WPKH) assert file_desc.strip() == file_desc_generic.strip() + +@pytest.mark.parametrize("chain", ["BTC", "XTN"]) +@pytest.mark.parametrize("way", ["sd", "vdisk", "nfc", "qr"]) +@pytest.mark.parametrize("addr_fmt", [AF_P2WPKH, AF_P2WPKH_P2SH, AF_CLASSIC, AF_P2WSH, AF_P2WSH_P2SH]) +@pytest.mark.parametrize("acct_num", [None, (2 ** 31) - 1]) +def test_key_expression_export(chain, addr_fmt, acct_num, goto_home, settings_set, need_keypress, + pick_menu_item, way, cap_story, cap_menu, virtdisk_path, dev, + load_export, press_select, skip_if_useless_way): + + skip_if_useless_way(way, allow_mk4_qr=True) + + settings_set('chain', chain) + chain_num = 1 if chain in ["XTN", "XRT"] else 0 + goto_home() + pick_menu_item("Advanced/Tools") + pick_menu_item("Export Wallet") + pick_menu_item("Key Expression") + time.sleep(.1) + _, story = cap_story() + assert "This saves a extended key expression" in story + assert "Press (1) to enter a non-zero account number" in story + assert "sensitive--in terms of privacy" in story + assert "not compromise your funds directly" in story + + if isinstance(acct_num, int): + need_keypress("1") # chosse account number + for ch in str(acct_num): + need_keypress(ch) # input num + press_select() # confirm selection + else: + press_select() # confirm story + acct_num = 0 + + menu = cap_menu() + if addr_fmt == AF_P2WPKH: + menu_item = "Segwit P2WPKH" + derive = f"m/84h/{chain_num}h/{acct_num}h" + elif addr_fmt == AF_P2WPKH_P2SH: + menu_item = "P2SH-Segwit" + derive = f"m/49h/{chain_num}h/{acct_num}h" + elif addr_fmt == AF_CLASSIC: + menu_item = "Classic P2PKH" + derive = f"m/44h/{chain_num}h/{acct_num}h" + elif addr_fmt == AF_P2WSH: + menu_item = "Multi P2WSH" + derive = f"m/48h/{chain_num}h/{acct_num}h/2h" + else: + assert addr_fmt == AF_P2WSH_P2SH + menu_item = "Multi P2SH-P2WSH" + derive = f"m/48h/{chain_num}h/{acct_num}h/1h" + + assert menu_item in menu + pick_menu_item(menu_item) + + contents = load_export(way, label="Key Expression", is_json=False, sig_check=False) + key_exp = contents.strip() + + xfp = dev.master_fingerprint + xfp = xfp2str(xfp).lower() + + seed = Mnemonic.to_seed(simulator_fixed_words) + node = BIP32Node.from_master_secret( + seed, netcode="BTC" if chain == "BTC" else "XTN" + ).subkey_for_path(derive) + + target = f"[{xfp}/{derive.replace('m/', '')}]{node.hwif()}" + assert key_exp == target + + +@pytest.mark.parametrize('path', [ + # NOTE: (2**31)-1 = 0x7fff_ffff = 2147483647 + "m/2147483647/2147483647/2147483647/2147483647/2147483647/2147483647/2147483647/2147483647", + "m/1/2/3/4/5", + "m/1h/2h/3h/4h/5h", + "m/45h", +]) +def test_custom_key_expression_export(path, goto_home, pick_menu_item, cap_menu, need_keypress, + press_select, load_export, use_testnet, dev): + use_testnet() + goto_home() + pick_menu_item("Advanced/Tools") + pick_menu_item("Export Wallet") + pick_menu_item("Key Expression") + press_select() # story + pick_menu_item("Custom Path") + + # blind entry, using only first 2 menu items + deeper = path.split("/")[1:] + for depth, part in enumerate(deeper): + time.sleep(.01) + m = cap_menu() + for mi in m: + assert "{idx}" not in mi # ranged values not allowed here + if depth == 0: + assert m[0] == 'm/⋯' + pick_menu_item(m[0]) + else: + assert m[0].endswith("h/⋯") + assert m[1].endswith("/⋯") + assert m[0] != m[1] + + pick_menu_item(m[0 if last_part[-1] == "h" else 1]) + + # enter path component + for d in part: + if d == "h": break + need_keypress(d) + press_select() + + last_part = part + + time.sleep(.01) + m = cap_menu() + pick_menu_item(m[2 if part[-1] == "h" else 3]) + + contents = load_export("sd", label="Key Expression", is_json=False, sig_check=False) + key_exp = contents.strip() + + xfp = dev.master_fingerprint + xfp = xfp2str(xfp).lower() + + seed = Mnemonic.to_seed(simulator_fixed_words) + node = BIP32Node.from_master_secret(seed, netcode="XTN").subkey_for_path(path) + + target = f"[{xfp}/{path.replace('m/', '')}]{node.hwif()}" + assert key_exp == target + # EOF