diff --git a/shared/address_explorer.py b/shared/address_explorer.py index a3b40696..91347ba9 100644 --- a/shared/address_explorer.py +++ b/shared/address_explorer.py @@ -9,7 +9,6 @@ from ux import ux_show_story, the_ux, ux_enter_bip32_index from ux import export_prompt_builder, import_export_prompt_decode from menu import MenuSystem, MenuItem from public_constants import AFC_BECH32, AFC_BECH32M, AF_P2WPKH, AF_P2TR, AF_CLASSIC -from multisig import MultisigWallet from miniscript import MiniScriptWallet from uasyncio import sleep_ms from uhashlib import sha256 @@ -199,11 +198,6 @@ class AddressListMenu(MenuSystem): items.append(MenuItem("Account Number", f=self.change_account)) items.append(MenuItem("Custom Path", menu=self.make_custom)) - # if they have MS wallets, add those next - for ms in MultisigWallet.iter_wallets(): - if not ms.addr_fmt: continue - items.append(MenuItem(ms.name, f=self.pick_miniscript, arg=ms)) - # if they have miniscript wallets, add those next for msc in MiniScriptWallet.iter_wallets(): items.append(MenuItem(msc.name, f=self.pick_miniscript, arg=msc)) @@ -267,7 +261,7 @@ Press (3) if you really understand and accept these risks. async def show_n_addresses(self, path, addr_fmt, ms_wallet, start=0, n=10, allow_change=True): # Displays n addresses by replacing {idx} in path format. # - also for other {account} numbers - # - or multisig case + # - or miniscript case from glob import dis, NFC from wallet import MAX_BIP32_IDX diff --git a/shared/auth.py b/shared/auth.py index 47288b59..18805b86 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -1451,11 +1451,9 @@ class NewMiniscriptEnrollRequest(UserAuthorizedAction): self.pop_menu() -def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False, bsms_index=None, - miniscript=False): +def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False, bsms_index=None): # Offer to import (enroll) a new multisig/miniscript wallet. Allow reject by user. from glob import dis - from multisig import MultisigWallet from miniscript import MiniScriptWallet UserAuthorizedAction.cleanup() @@ -1487,17 +1485,7 @@ def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False, bsms_ # this call will raise on parsing errors, so let them rise up # and be shown on screen/over usb - if miniscript is None: - # autodetect - try: - msc = MultisigWallet.from_file(config, name=name) - except: - msc = MiniScriptWallet.from_file(config, name=name) - - elif miniscript: - msc = MiniScriptWallet.from_file(config, name=name, bip388=bip388) - else: - msc = MultisigWallet.from_file(config, name=name) + msc = MiniScriptWallet.from_file(config, name=name, bip388=bip388) UserAuthorizedAction.active_request = NewMiniscriptEnrollRequest(msc, bsms_index=bsms_index) diff --git a/shared/ccc.py b/shared/ccc.py index 0a7ec236..6da6d9ea 100644 --- a/shared/ccc.py +++ b/shared/ccc.py @@ -331,8 +331,8 @@ class CCCConfigMenu(MenuSystem): xfp = CCCFeature.get_xfp() enc = CCCFeature.get_encoded_secret() - from multisig import export_multisig_xpubs - await export_multisig_xpubs(xfp=xfp, alt_secret=enc, skip_prompt=True) + from miniscript import export_miniscript_xpubs + await export_miniscript_xpubs(xfp=xfp, alt_secret=enc, skip_prompt=True) async def build_2ofN(self, m, l, i): count = i.arg diff --git a/shared/hsm.py b/shared/hsm.py index f34eac20..53cc45ed 100644 --- a/shared/hsm.py +++ b/shared/hsm.py @@ -11,7 +11,6 @@ from pincodes import AE_LONG_SECRET_LEN from stash import blank_object from users import Users, MAX_NUMBER_USERS, calc_local_pincode from public_constants import MAX_USERNAME_LEN -from multisig import MultisigWallet from miniscript import MiniScriptWallet from ubinascii import hexlify as b2a_hex from uhashlib import sha256 @@ -179,7 +178,7 @@ class ApprovalRule: # - users: list of authorized users # - min_users: how many of those are needed to approve # - local_conf: local user must also confirm w/ code - # - wallet: which multisig/miniscript wallet to restrict to, or '1' for single signer only + # - wallet: which miniscript wallet to restrict to, or '1' for single signer only # - min_pct_self_transfer: minimum percentage of own input value that must go back to self # - patterns: list of transaction patterns to check for. Valid values: # * EQ_NUM_INS_OUTS: the number of inputs and outputs must be equal @@ -196,7 +195,6 @@ class ApprovalRule: return u self.index = idx+1 - self.ms_type = "multisig" self.per_period = pop_int(j, 'per_period', 0, MAX_SATS) self.max_amount = pop_int(j, 'max_amount', 0, MAX_SATS) self.users = pop_list(j, 'users', check_user) @@ -221,13 +219,10 @@ class ApprovalRule: # redundant w/ code in pop_int() above assert 1 <= self.min_users <= len(self.users), "range" - # if specified, 'wallet' must be an existing multisig wallet's name + # if specified, 'wallet' must be an existing miniscript wallet's name if self.wallet and self.wallet != '1': - ms_names = [ms.name for ms in MultisigWallet.get_all()] msc_names = [msc.name for msc in MiniScriptWallet.get_all()] - assert self.wallet in (ms_names+msc_names), "unknown wallet: "+self.wallet - if self.wallet in msc_names: - self.ms_type = "miniscript" + assert self.wallet in msc_names, "unknown wallet: " + self.wallet # patterns must be valid for p in self.patterns: @@ -273,7 +268,7 @@ class ApprovalRule: if self.wallet == '1': rv += ' (singlesig only)' elif self.wallet: - rv += ' from %s wallet "%s"' % (self.ms_type, self.wallet) + rv += ' from miniscript wallet "%s"' % self.wallet if self.users: rv += ' may be authorized by ' @@ -314,13 +309,10 @@ class ApprovalRule: # Does this rule apply to this PSBT file? if self.wallet: # rule limited to one wallet - if psbt.active_multisig: - # if multisig signing, might need to match specific wallet name - assert self.wallet == psbt.active_multisig.name, 'wrong multisig wallet' - elif psbt.active_miniscript: + if psbt.active_miniscript: assert self.wallet == psbt.active_miniscript.name, 'wrong miniscript wallet' else: - # non multisig, but does this rule apply to all wallets or single-singers + # not miniscript, but does this rule apply to all wallets or single-singers assert self.wallet == '1', 'singlesig only' if self.max_amount is not None: @@ -988,8 +980,7 @@ def hsm_status_report(): rv['approval_wait'] = True rv['users'] = Users.list() - rv['wallets'] = [ms.name for ms in MultisigWallet.get_all()] \ - + [msc.name for msc in MiniScriptWallet.get_all()] + rv['wallets'] = [msc.name for msc in MiniScriptWallet.get_all()] rv['chain'] = settings.get('chain', 'BTC') diff --git a/shared/miniscript.py b/shared/miniscript.py index 55d31028..8d41a386 100644 --- a/shared/miniscript.py +++ b/shared/miniscript.py @@ -16,11 +16,18 @@ from utils import problem_file_line, xfp2str, to_ascii_printable, swab32, show_s from charcodes import KEY_QR, KEY_CANCEL, KEY_NFC, KEY_ENTER from glob import settings +# Arbitrary value, not 0 or 1, used to derive a pubkey from preshared xpub in Key Teleport KT_RXPUBKEY_DERIV = const(20250317) +# PSBT Xpub trust policies +TRUST_VERIFY = const(0) +TRUST_OFFER = const(1) +TRUST_PSBT = const(2) + class MiniScriptWallet(BaseStorageWallet): key_name = "miniscript" + disable_checks = False def __init__(self, name, desc_tmplt=None, keys_info=None, desc=None, af=None, ik_u=None): @@ -35,6 +42,15 @@ class MiniScriptWallet(BaseStorageWallet): self.addr_fmt = af self.ik_u = ik_u + @classmethod + def get_trust_policy(cls): + + which = settings.get('pms', None) + if which is None: + which = TRUST_VERIFY if cls.exists() else TRUST_OFFER + + return which + @property def chain(self): return chains.current_chain() @@ -538,7 +554,7 @@ async def import_miniscript(*a): from auth import maybe_enroll_xpub try: possible_name = (fn.split('/')[-1].split('.'))[0] if fn else None - maybe_enroll_xpub(config=data, name=possible_name, miniscript=True) + maybe_enroll_xpub(config=data, name=possible_name) except BaseException as e: await ux_show_story('Failed to import miniscript.\n\n%s\n%s' % (e, problem_file_line(e))) @@ -557,7 +573,7 @@ async def import_miniscript_qr(*a): # press pressed CANCEL return try: - maybe_enroll_xpub(config=data, miniscript=True) + maybe_enroll_xpub(config=data) except Exception as e: await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) @@ -610,6 +626,11 @@ class MiniscriptMenu(MenuSystem): arg=msc.storage_idx)) from glob import NFC rv.append(MenuItem('Import', f=import_miniscript)) + rv.append(MenuItem('Export XPUB', f=export_miniscript_xpubs)) + rv.append(MenuItem('BSMS (BIP-129)', menu=make_ms_wallet_bsms_menu)) + rv.append(MenuItem('Create Airgapped', f=create_ms_step1)) + rv.append(MenuItem('Trust PSBT?', f=trust_psbt_menu)) + rv.append(MenuItem('Skip Checks?', f=disable_checks_menu)) rv.append(ShortcutItem(KEY_NFC, predicate=lambda: NFC is not None, f=import_miniscript_nfc)) rv.append(ShortcutItem(KEY_QR, predicate=lambda: version.has_qwerty, @@ -634,6 +655,164 @@ async def make_miniscript_menu(*a): return MiniscriptMenu(rv) +def disable_checks_chooser(): + ch = ['Normal', 'Skip Checks'] + + def xset(idx, text): + MiniScriptWallet.disable_checks = bool(idx) + + return int(MiniScriptWallet.disable_checks), ch, xset + +async def disable_checks_menu(*a): + + if not MiniScriptWallet.disable_checks: + ch = await ux_show_story('''\ +With many different wallet vendors and implementors involved, it can \ +be hard to create a PSBT consistent with the many keys involved. \ +With this setting, you can \ +disable the more stringent verification checks your Coldcard normally provides. + +USE AT YOUR OWN RISK. These checks exist for good reason! Signed txn may \ +not be accepted by network. + +This settings lasts only until power down. + +Press (4) to confirm entering this DANGEROUS mode. +''', escape='4') + + if ch != '4': return + + start_chooser(disable_checks_chooser) + + +def psbt_xpubs_policy_chooser(): + # Chooser for trust policy + ch = ['Verify Only', 'Offer Import', 'Trust PSBT'] + + def xset(idx, text): + settings.set('pms', idx) + + return MiniScriptWallet.get_trust_policy(), ch, xset + +async def trust_psbt_menu(*a): + # show a story then go into chooser + + ch = await ux_show_story('''\ +This setting controls what the Coldcard does \ +with the co-signer public keys (XPUB) that may \ +be provided inside a PSBT file. Three choices: + +- Verify Only. Do not import the xpubs found, but do \ +verify the correct wallet already exists on the Coldcard. + +- Offer Import. If it's a new multisig wallet, offer to import \ +the details and store them as a new wallet in the Coldcard. + +- Trust PSBT. Use the wallet data in the PSBT as a temporary, +multisig wallet, and do not import it. This permits some \ +deniability and additional privacy. + +When the XPUB data is not provided in the PSBT, regardless of the above, \ +we require the appropriate multisig wallet to already exist \ +on the Coldcard. Default is to 'Offer' unless a multisig wallet already \ +exists, otherwise 'Verify'.''') + + if ch == 'x': return + start_chooser(psbt_xpubs_policy_chooser) + + +async def ms_wallet_electrum_export(menu, label, item): + # create a JSON file that Electrum can use. Challenges: + # - file contains derivation paths for each co-signer to use + # - electrum is using BIP-43 with purpose=48 (purpose48_derivation) to make paths like: + # m/48h/1h/0h/2h + # - above is now called BIP-48 + # - other signers might not be coldcards (we don't know) + # solution: + # - when building air-gap, pick address type at that point, and matching path to suit + # - could check path prefix and addr_fmt make sense together, but meh. + ms = item.arg + from actions import electrum_export_story + + derivs, dsum = ms.get_deriv_paths() + + msg = 'The new wallet will have derivation path:\n %s\n and use %s addresses.\n' % ( + dsum, MultisigWallet.render_addr_fmt(ms.addr_fmt) ) + + if await ux_show_story(electrum_export_story(msg)) != 'y': + return + + await ms.export_electrum() + + +async def export_miniscript_xpubs(*a, xfp=None, alt_secret=None, skip_prompt=False): + # WAS: Create a single text file with lots of docs, and all possible useful xpub values. + # THEN: Just create the one-liner xpub export value they need/want to support BIP-45 + # NOW: Export JSON with one xpub per useful address type and semi-standard derivation path + # + # - consumer for this file is supposed to be ourselves, when we build on-device multisig. + # - however some 3rd parties are making use of it as well. + # - used for CCC feature now as well, but result looks just like normal export + # + xfp = xfp2str(xfp or settings.get('xfp', 0)) + chain = chains.current_chain() + + fname_pattern = 'ccxp-%s.json' % xfp + label = "Multisig XPUB" + + if not skip_prompt: + msg = '''\ +This feature creates a small file containing \ +the extended public keys (XPUB) you would need to join \ +a multisig wallet. + +Public keys for BIP-48 conformant paths are used: + +P2SH-P2WSH: + m/48h/{coin}h/{{acct}}h/1h +P2WSH: + m/48h/{coin}h/{{acct}}h/2h +P2TR: + m/48h/{coin}h/{{acct}}h/3h + +{ok} to continue. {x} to abort.'''.format(coin=chain.b44_cointype, ok=OK, x=X) + + ch = await ux_show_story(msg) + if ch != "y": + return + + acct = await ux_enter_bip32_index('Account Number:') or 0 + + def render(acct_num): + sign_der = None + with uio.StringIO() as fp: + fp.write('{\n') + with stash.SensitiveValues(secret=alt_secret) as sv: + for name, deriv, fmt in chains.MS_STD_DERIVATIONS: + if fmt == AF_P2SH and acct_num: + continue + dd = deriv.format(coin=chain.b44_cointype, acct_num=acct_num) + if fmt == AF_P2WSH: + sign_der = dd + "/0/0" + node = sv.derive_path(dd) + xp = chain.serialize_public(node, fmt) + fp.write(' "%s_deriv": "%s",\n' % (name, dd)) + fp.write(' "%s": "%s",\n' % (name, xp)) + xpub = chain.serialize_public(node) + descriptor_template = multisig_descriptor_template(xpub, dd, xfp, fmt) + if descriptor_template is None: + continue + fp.write(' "%s_desc": "%s",\n' % (name, descriptor_template)) + + fp.write(' "account": "%d",\n' % acct_num) + fp.write(' "xfp": "%s"\n}\n' % xfp) + return fp.getvalue(), sign_der, AF_CLASSIC + + from export import export_contents + await export_contents(label, lambda: render(acct), fname_pattern, + force_bbqr=True, is_json=True) + + class Number: def __init__(self, num): self.num = num diff --git a/shared/multisig.py b/shared/multisig.py index 284b02cf..f7f87578 100644 --- a/shared/multisig.py +++ b/shared/multisig.py @@ -4,30 +4,17 @@ # import stash, chains, ustruct, ure, uio, sys, ngu, uos, ujson, version from ubinascii import hexlify as b2a_hex -from utils import xfp2str, str2xfp, cleanup_deriv_path, keypath_to_str, to_ascii_printable, extract_cosigner -from utils import str_to_keypath, problem_file_line, check_xpub, get_filesize, show_single_address -from ux import ux_show_story, ux_confirm, ux_dramatic_pause, ux_clear_keys -from ux import ux_enter_bip32_index, ux_enter_number, OK, X -from files import CardSlot, CardMissingError, needs_microsd -from descriptor import Descriptor -from miniscript import Key, Sortedmulti, Number, Multi -from desc_utils import multisig_descriptor_template -from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AFC_SCRIPT, MAX_SIGNERS, AF_P2TR, AF_CLASSIC -from menu import MenuSystem, MenuItem, start_chooser +from utils import xfp2str, keypath_to_str +from utils import check_xpub +from ux import ux_show_story, ux_dramatic_pause, ux_clear_keys +from ux import OK, X +from public_constants import AF_P2SH, MAX_SIGNERS, AF_CLASSIC from opcodes import OP_CHECKMULTISIG from exceptions import FatalPSBTIssue from glob import settings -from charcodes import KEY_NFC, KEY_QR from serializations import disassemble -from wallet import BaseStorageWallet, MAX_BIP32_IDX +from wallet import BaseStorageWallet -# PSBT Xpub trust policies -TRUST_VERIFY = const(0) -TRUST_OFFER = const(1) -TRUST_PSBT = const(2) - -# Arbitrary value, not 0 or 1, used to derive a pubkey from preshared xpub in Key Teleport -KT_RXPUBKEY_DERIV = const(20250317) def disassemble_multisig_mn(redeem_script): # pull out just M and N from script. Simple, faster, no memory. @@ -109,217 +96,6 @@ def make_redeem_script(M, nodes, subkey_idx, bip67=True): return b''.join(pubkeys) class MultisigWallet(BaseStorageWallet): - # Capture the info we need to store long-term in order to participate in a - # multisig wallet as a co-signer. - # - can be saved to nvram - # - can be imported from a simple text file - # - can be displayed to user in a menu (and deleted) - # - required during signing to verify change outputs - # - can reconstruct any redeem script from this - # Challenges: - # - can be big, taking big % of 4k storage in nvram - # - complex object, want to have flexibility going forward - FORMAT_NAMES = [ - (AF_P2SH, 'p2sh'), - (AF_P2WSH, 'p2wsh'), - (AF_P2WSH_P2SH, 'p2sh-p2wsh'), # preferred - (AF_P2TR, 'p2tr'), - (AF_P2WSH_P2SH, 'p2wsh-p2sh'), # obsolete (now an alias) - ] - - # optional: user can short-circuit many checks (system wide, one power-cycle only) - disable_checks = False - key_name = "multisig" - - def __init__(self, name, m_of_n, xpubs, addr_fmt=AF_P2SH, bip67=True): - super().__init__() - - self.name = name - assert len(m_of_n) == 2 - self.M, self.N = m_of_n - assert len(xpubs[0]) == 3 - self.xpubs = xpubs # list of (xfp(int), deriv, xpub(str)) - self.addr_fmt = addr_fmt # address format for wallet - self.bip67 = bip67 - - # calc useful cache value: numeric xfp+subpath, with lookup - self.xfp_paths = {} - for xfp, deriv, xpub in self.xpubs: - self.xfp_paths[xfp] = str_to_keypath(xfp, deriv) - - assert len(self.xfp_paths) == self.N, 'dup XFP' # not supported - - @classmethod - def render_addr_fmt(cls, addr_fmt): - for k, v in cls.FORMAT_NAMES: - if k == addr_fmt: - return v.upper() - return '?' - - def render_path(self, change_idx, idx): - # assuming shared derivations for all cosigners. Wrongish. - derivs, _ = self.get_deriv_paths() - if len(derivs) > 1: - deriv = '(various)' - else: - deriv = derivs[0] - return deriv + '/%d/%d' % (change_idx, idx) - - def get_my_deriv(self, my_xfp): - for tup in self.xpubs: - if tup[0] == my_xfp: - return tup[1] - - @classmethod - def get_trust_policy(cls): - - which = settings.get('pms', None) - if which is None: - which = TRUST_VERIFY if cls.exists() else TRUST_OFFER - - return which - - @property - def chain(self): - return chains.current_chain() - - def serialize(self): - # return a JSON-able object - - opts = dict() - if self.addr_fmt != AF_P2SH: - opts['ft'] = self.addr_fmt - - # Data compression: most legs will all use same derivation. - # put a int(0) in place and set option 'pp' to be derivation - # (used to be common_prefix assumption) - pp = list(sorted(set(d for _,d,_ in self.xpubs))) - if len(pp) == 1: - # generate old-format data, to preserve firmware downgrade path - xp = [(a, c) for a,deriv,c in self.xpubs] - opts['pp'] = pp[0] - else: - # allow for distinct deriv paths on each leg - opts['d'] = pp - xp = [(a, pp.index(deriv),c) for a,deriv,c in self.xpubs] - - # make list already, will become one after json ser/deser - res = [self.name, (self.M, self.N), xp, opts] - if not self.bip67: - # wallets that do not follow BIP-67 are backwards incompatible - res.append(0) - - return res - - @classmethod - def deserialize(cls, vals, idx=-1): - # take json object, make instance. - bip67 = 1 # default enabled, requires 5-element serialization to disable - if len(vals) == 5: - bip67 = vals[-1] - vals = vals[:-1] - - name, m_of_n, xpubs, opts = vals - - if len(xpubs[0]) == 2: - # promote from old format to new: assume common prefix is the derivation - # for all of them - # PROBLEM: we don't have enough info if no common prefix can be assumed - common_prefix = opts.get('pp', None) - if not common_prefix: - # TODO: this should raise a warning, not supported anymore - common_prefix = 'm' - common_prefix = common_prefix.replace("'", "h") - xpubs = [(a, common_prefix, b) for a,b in xpubs] - else: - # new format decompression - if 'd' in opts: - derivs = [p.replace("'", "h") for p in opts.get('d')] - xpubs = [(a, derivs[b], c) for a,b,c in xpubs] - - rv = cls(name, m_of_n, xpubs, addr_fmt=opts.get('ft', AF_P2SH), - bip67=bool(bip67)) - rv.storage_idx = idx - return rv - - @classmethod - def iter_wallets(cls, M=None, N=None, addr_fmt=None): - # yield MS wallets we know about, that match at least right M,N if known. - # - this is only place we should be searching this list, please!! - lst = settings.get(cls.key_name, []) - c = chains.current_key_chain() - for idx, rec in enumerate(lst): - if M or N: - # peek at M/N - has_m, has_n = tuple(rec[1]) - if M is not None and has_m != M: continue - if N is not None and has_n != N: continue - - if addr_fmt is not None: - opts = rec[3] - af = opts.get('ft', AF_P2SH) - if af != addr_fmt: continue - - yield cls.deserialize(rec, idx) - - def get_xfp_paths(self): - # return list of lists [xfp, *deriv] - return list(self.xfp_paths.values()) - - @classmethod - def find_match(cls, M, N, xfp_paths, addr_fmt=None): - # Find index of matching wallet - # - xfp_paths is list of lists: [xfp, *path] like in psbt files - # - M and N must be known - # - returns instance, or None if not found - for rv in cls.iter_wallets(M, N, addr_fmt=addr_fmt): - if rv.matching_subpaths(xfp_paths): - return rv - - return None - - @classmethod - def find_candidates(cls, xfp_paths, addr_fmt=None, M=None): - # Return a list of matching wallets for various M values. - # - xpfs_paths should already be sorted - # - returns set of matches, of any M value - - # we know N, but not M at this point. - N = len(xfp_paths) - - matches = [] - for rv in cls.iter_wallets(M=M, addr_fmt=addr_fmt): - if rv.matching_subpaths(xfp_paths): - matches.append(rv) - - return matches - - def matching_subpaths(self, xfp_paths): - # Does this wallet use same set of xfp values, and - # the same prefix path per-each xfp, as indicated - # xfp_paths (unordered)? - # - could also check non-prefix part is all non-hardened - if len(xfp_paths) != len(self.xfp_paths): - # cannot be the same if len(w0.N) != len(w1.N) - # maybe check duplicates first? - return False - for x in xfp_paths: - if x[0] not in self.xfp_paths: - return False - prefix = self.xfp_paths[x[0]] - - if len(x) < len(prefix): - # PSBT specs a path shorter than wallet's xpub - #print('path len: %d vs %d' % (len(prefix), len(x))) - return False - - comm = len(prefix) - if tuple(prefix[:comm]) != tuple(x[:comm]): - # xfp => maps to wrong path - #print('path mismatch:\n%r\n%r\ncomm=%d' % (prefix[:comm], x[:comm], comm)) - return False - - return True def assert_matching(self, M, N, xfp_paths): # compare in-memory wallet with details recovered from PSBT @@ -329,19 +105,6 @@ class MultisigWallet(BaseStorageWallet): if self.disable_checks: return assert self.matching_subpaths(xfp_paths), "wrong XFP/derivs" - @classmethod - def quick_check(cls, M, N, xfp_xor): - # quicker? USB method. - rv = [] - for ms in cls.iter_wallets(M, N): - x = 0 - for xfp in ms.xfp_paths.keys(): - x ^= xfp - if x != xfp_xor: continue - - return True - - return False def has_similar(self): # check if we already have a saved duplicate to this proposed wallet @@ -391,389 +154,6 @@ class MultisigWallet(BaseStorageWallet): return None, diffs, len(similar) - def delete(self): - # remove saved entry - # - important: not expecting more than one instance of this class in memory - assert self.storage_idx >= 0 - - # safety check - for existing in self.iter_wallets(M=self.M, N=self.N, addr_fmt=self.addr_fmt): - if existing.storage_idx != self.storage_idx: continue - break - else: - raise IndexError # consistency bug - - lst = settings.get(self.key_name, []) - del lst[self.storage_idx] - if lst: - settings.set(self.key_name, lst) - else: - settings.remove_key(self.key_name) - settings.save() - - self.storage_idx = -1 - - def xpubs_with_xfp(self, xfp): - # return set of indexes of xpubs with indicated xfp - return set(xp_idx for xp_idx, (wxfp, _, _) in enumerate(self.xpubs) - if wxfp == xfp) - - def xpubs_from_xfp(self, xfp): - # return list of XPUB's which match xfp; typically one. - return [xpub for (wxfp, _, xpub) in self.xpubs if wxfp == xfp] - - def yield_addresses(self, start_idx, count, change_idx=0): - # Assuming a suffix of /0/0 on the defined prefix's, yield - # possible deposit addresses for this wallet. - ch = chains.current_chain() - - assert self.addr_fmt, 'no addr fmt known' - - # setup - nodes = [] - paths = [] - for xfp, deriv, xpub in self.xpubs: - # load bip32 node for each cosigner - node = ch.deserialize_node(xpub, AF_P2SH) - node.derive(change_idx, False) - # indicate path used (for UX) - path = "[%s%s/%d/{idx}]" % (xfp2str(xfp), deriv.replace("m", ""), change_idx) - nodes.append(node) - paths.append(path) - - idx = start_idx - while count: - if idx > MAX_BIP32_IDX: - break - # make the redeem script, convert into address - script = make_redeem_script(self.M, nodes, idx, self.bip67) - addr = ch.p2sh_address(self.addr_fmt, script) - - yield idx, addr, [p.format(idx=idx) for p in paths], script - - idx += 1 - count -= 1 - - def make_addresses_msg(self, msg, start, n, change=0): - from glob import dis - - addrs = [] - - for idx, addr, paths, script in self.yield_addresses(start, n, change): - if idx == 0 and self.N <= 4: - msg += '\n'.join(paths) + '\n =>\n' - else: - msg += '.../%d/%d =>\n' % (change, idx) - - addrs.append(addr) - msg += show_single_address(addr) + '\n\n' - dis.progress_sofar(idx - start + 1, n) - - return msg, addrs - - def generate_address_csv(self, start, n, change): - yield '"' + '","'.join(['Index', 'Payment Address', - 'Redeem Script (%d of %d)' % (self.M, self.N)] - + (['Derivation'] * self.N)) + '"\n' - - for (idx, addr, derivs, script) in self.yield_addresses(start, n, change_idx=change): - ln = '%d,"%s","%s","' % (idx, addr, b2a_hex(script).decode()) - ln += '","'.join(derivs) - ln += '"\n' - - yield ln - - def validate_script(self, redeem_script, subpaths=None, xfp_paths=None): - # Check we can generate all pubkeys in the redeem script, raise on errors. - # - working from pubkeys in the script, because duplicate XFP can happen - # - if disable_checks is set better to handle in caller, but we're also neutered - # - # redeem_script: what we expect and we were given - # subpaths: pubkey => (xfp, *path) - # xfp_paths: (xfp, *path) in same order as pubkeys in redeem script - - subpath_help = [] - used = set() - ch = self.chain - - M, N, pubkeys = disassemble_multisig(redeem_script) - assert M==self.M and N == self.N, 'wrong M/N in script' - - if self.disable_checks: return ['UNVERIFIED'] - - for pk_order, pubkey in enumerate(pubkeys): - check_these = [] - - # TODO: this could be simpler now that XFP is unique per co-signer - if subpaths: - # in PSBT, we are given a map from pubkey to xfp/path, use it - # while remembering it's potentially one-2-many - assert pubkey in subpaths, "unexpected pubkey" - xfp, *path = subpaths[pubkey] - - for xp_idx, (wxfp, _, xpub) in enumerate(self.xpubs): - if wxfp != xfp: continue - if xp_idx in used: continue # only allow once - check_these.append((xp_idx, path)) - else: - # Without PSBT, USB caller must provide xfp+path - # in same order as they occur inside redeem script. - # Working solely from the redeem script's pubkeys, we - # wouldn't know which xpub to use, nor correct path for it. - xfp, *path = xfp_paths[pk_order] - - for xp_idx in self.xpubs_with_xfp(xfp): - if xp_idx in used: continue # only allow once - check_these.append((xp_idx, path)) - - here = None - too_shallow = False - for xp_idx, path in check_these: - if not self.bip67: - assert xp_idx == pk_order, "script key order" - - # matched fingerprint, try to make pubkey that needs to match - xpub = self.xpubs[xp_idx][-1] - - node = ch.deserialize_node(xpub, AF_P2SH); assert node - dp = node.depth() - - #print("%s => deriv=%s dp=%d len(path)=%d path=%s" % - # (xfp2str(xfp), self.xpubs[xp_idx][1], dp, len(path), path)) - - if not (0 <= dp <= len(path)): - # obscure case: xpub isn't deep enough to represent - # indicated path... not wrong really. - too_shallow = True - dp = 0 - - for sp in path[dp:]: - assert not (sp & 0x80000000), 'hard deriv' - node.derive(sp, False) # works in-place - - found_pk = node.pubkey() - - # Document path(s) used. Not sure this is useful info to user tho. - # - Do not show what we can't verify: we don't really know the hardened - # part of the path from fingerprint to here. - here = '[%s]' % xfp2str(xfp) - if dp != len(path): - here = here[:-1] + ('/_'*dp) + keypath_to_str(path[dp:], '/', 0) + "]" - - if found_pk != pubkey: - # Not a match but not an error by itself, since might be - # another dup xfp to look at still. - - #print('pk mismatch: %s => %s != %s' % ( - # here, b2a_hex(found_pk), b2a_hex(pubkey))) - continue - - subpath_help.append(here) - - used.add(xp_idx) - break - else: - msg = 'pk#%d wrong' % (pk_order+1) - if not check_these: - msg += ', unknown XFP' - elif here: - msg += ', tried: ' + here - if too_shallow: - msg += ', too shallow' - raise AssertionError(msg) - - if self.bip67 and pk_order: - # verify sorted order - assert bytes(pubkey) > bytes(pubkeys[pk_order-1]), 'BIP-67 violation' - - assert len(used) == self.N, 'not all keys used: %d of %d' % (len(used), self.N) - - return subpath_help - - @classmethod - def from_simple_text(cls, lines): - # standard multisig file format - more than one line - has_mine = 0 - M, N = -1, -1 - deriv = None - name = None - xpubs = [] - addr_fmt = AF_P2SH - my_xfp = settings.get('xfp') - for ln in lines: - # remove comments - comm = ln.find('#') - if comm == 0: - continue - if comm != -1: - if not ln[comm + 1:comm + 2].isdigit(): - ln = ln[0:comm] - - ln = ln.strip() - - if ':' not in ln: - if 'pub' in ln: - # pointless optimization: allow bare xpub if we can calc xfp - label = '00000000' - value = ln - else: - # complain? - # if ln: print("no colon: " + ln) - continue - else: - label, value = ln.split(':', 1) - label = label.lower() - - value = value.strip() - - if label == 'name': - name = value - elif label == 'policy': - try: - # accepts: 2 of 3 2/3 2,3 2 3 etc - mat = ure.search(r'(\d+)\D*(\d+)', value) - assert mat - M = int(mat.group(1)) - N = int(mat.group(2)) - assert 1 <= M <= N <= MAX_SIGNERS - except: - raise AssertionError('bad policy line') - - elif label == 'derivation': - # reveal the path derivation for following key(s) - try: - assert value, 'blank' - deriv = cleanup_deriv_path(value) - except BaseException as exc: - raise AssertionError('bad derivation line: ' + str(exc)) - - elif label == 'format': - # pick segwit vs. classic vs. wrapped version - value = value.lower() - for fmt_code, fmt_label in cls.FORMAT_NAMES: - if value == fmt_label: - addr_fmt = fmt_code - break - else: - raise AssertionError('bad format line') - elif len(label) == 8: - try: - xfp = str2xfp(label) - except: - # complain? - # print("Bad xfp: " + ln) - continue - - # deserialize, update list and lots of checks - is_mine, item = check_xpub(xfp, value, deriv, chains.current_key_chain().ctype, - my_xfp, cls.disable_checks) - xpubs.append(item) - if is_mine: - has_mine += 1 - - return name, addr_fmt, xpubs, has_mine, M, N - - @classmethod - def from_descriptor(cls, descriptor: str): - # expect descriptor here if only one line, normal multisig file requires more lines - has_mine = 0 - my_xfp = settings.get('xfp') - xpubs = [] - - descriptor = Descriptor.from_string(descriptor) - assert descriptor.is_basic_multisig, "not multisig" # raises - addr_fmt = descriptor.addr_fmt - - M, N = descriptor.miniscript.m_n() - for key in descriptor.miniscript.keys: - assert key.derivation.indexes == ((0,1), "*"), "Invalid subderivation path - only 0/* or <0;1>/* allowed" - xfp = key.origin.cc_fp - deriv = key.origin.str_derivation() - xpub = key.extended_public_key() - deriv = cleanup_deriv_path(deriv) - is_mine, item = check_xpub(xfp, xpub, deriv, chains.current_key_chain().ctype, - my_xfp, cls.disable_checks) - xpubs.append(item) - if is_mine: - has_mine += 1 - - return None, addr_fmt, xpubs, has_mine, M, N, descriptor.is_sortedmulti - - def to_descriptor(self): - keys = [ - Key.from_cc_data(xfp, deriv, xpub) - for xfp, deriv, xpub in self.xpubs - ] - _cls = Sortedmulti if self.bip67 else Multi - miniscript = _cls(Number(self.M), *keys) - desc = Descriptor(miniscript=miniscript, addr_fmt=self.addr_fmt) - return desc - - @classmethod - def from_file(cls, config, name=None): - # Given a simple text file, parse contents and create instance (unsaved). - # format is: label: value - # where label is: - # name: nameforwallet - # policy: M of N - # format: p2sh (+etc) - # derivation: m/45h/0 (common prefix) - # (8digithex): xpub of cosigner - # - # Descriptor support - # * text file containing multisig descriptor - # - # quick checks: - # - name: 1-20 ascii chars - # - M of N line (assume N of N if not spec'd) - # - xpub: any bip32 serialization we understand, but be consistent - # - expect_chain = chains.current_key_chain().ctype - if Descriptor.is_descriptor(config): - # assume descriptor, classic config should not contain sertedmulti( and check for checksum separator - # ignore name - _, addr_fmt, xpubs, has_mine, M, N, bip67 = cls.from_descriptor(config) - else: - # oldschool - bip67 = True - lines = [line for line in config.split('\n') if line] # remove empty lines - parsed_name, addr_fmt, xpubs, has_mine, M, N = cls.from_simple_text(lines) - if parsed_name: - # if name provided in file, use that instead of name inferred from filename - name = parsed_name - - assert len(xpubs), 'need xpubs' - - if M == N == -1: - # default policy: all keys - N = M = len(xpubs) - - if not name: - # provide a default name - name = '%d-of-%d' % (M, N) - - try: - name = to_ascii_printable(name) - assert 1 <= len(name) <= 20 - except: - raise AssertionError('name must be ascii, 1..20 long') - - assert 1 <= M <= N <= MAX_SIGNERS, 'M/N range' - assert N == len(xpubs), 'wrong # of xpubs, expect %d' % N - assert addr_fmt & AFC_SCRIPT, 'script style addr fmt' - - # check we're included... do not insert ourselves, even tho we - # have enough info, simply because other signers need to know my xpubkey anyway - assert has_mine != 0, 'my key not included' - assert has_mine == 1, 'my key included more than once' - - # done. have all the parts - return cls(name, (M, N), xpubs, addr_fmt=addr_fmt, bip67=bip67) - - def make_fname(self, prefix, suffix='txt'): - rv = '%s-%s.%s' % (prefix, self.name, suffix) - return rv.replace(' ', '_') - async def export_electrum(self): # Generate and save an Electrum JSON file. from export import export_contents @@ -807,101 +187,6 @@ class MultisigWallet(BaseStorageWallet): await export_contents('Electrum multisig wallet', doit, self.make_fname('el', 'json'), is_json=True) - async def export_wallet_file(self, mode="exported from", descriptor=False, - core=False, desc_pretty=True): - # create a text file with the details; ready for import to next Coldcard - my_xfp = settings.get('xfp') - # both core and CC export contains newlines, not supported with simple QR - force_bbqr = True - if core: - name = "Bitcoin Core" - fname_pattern = self.make_fname('bitcoin-core') - elif descriptor: - # classic descriptor is one-liner, can be exported as simple QR if size allows - # pretty desc has newlines - needs BBQr - force_bbqr = desc_pretty - name = "Descriptor" - fname_pattern = self.make_fname('desc') - else: - name = "Coldcard" - fname_pattern = self.make_fname('export') - - hdr = '%s %s' % (mode, xfp2str(my_xfp)) - label = "%s multisig setup" % name - - with uio.StringIO() as fp: - self.render_export(fp, hdr_comment=hdr, descriptor=descriptor, - core=core, desc_pretty=desc_pretty) - body = fp.getvalue() - - # create airgapped, where own key is not included in the ms setup, no key to sign with - af = None - der = self.get_my_deriv(my_xfp) - if der: - der = der + "/0/0" - af = AF_CLASSIC - - from export import export_contents - await export_contents(label, body, fname_pattern, der, af, force_bbqr=force_bbqr) - - def render_export(self, fp, hdr_comment=None, descriptor=False, core=False, desc_pretty=True): - if descriptor: - # serialize descriptor - desc_obj = self.to_descriptor() - if core: - core_obj = desc_obj.bitcoin_core_serialize() - core_str = ujson.dumps(core_obj) - print("importdescriptors '%s'\n" % core_str, file=fp) - else: - if desc_pretty: - # TODO pretty serialize - desc = desc_obj.to_string(internal=False) - else: - desc = desc_obj.to_string(internal=False) - print("%s\n" % desc, file=fp) - else: - if hdr_comment: - print("# Coldcard Multisig setup file (%s)\n#" % hdr_comment, file=fp) - - print("Name: %s\nPolicy: %d of %d" % (self.name, self.M, self.N), file=fp) - - if self.addr_fmt != AF_P2SH: - print("Format: " + self.render_addr_fmt(self.addr_fmt), file=fp) - - last_deriv = None - for xfp, deriv, val in self.xpubs: - if last_deriv != deriv: - print("\nDerivation: %s\n" % deriv, file=fp) - last_deriv = deriv - - print('%s: %s' % (xfp2str(xfp), val), file=fp) - - @classmethod - def guess_addr_fmt(cls, npath): - # Assuming the bips are being respected, what address format will be used, - # based on indicated numeric subkey path observed. - # - return None if unsure, no errors - # - #( "m/45h", 'p2sh', AF_P2SH), - #( "m/48h/{coin}h/0h/1h", 'p2sh_p2wsh', AF_P2WSH_P2SH), - #( "m/48h/{coin}h/0h/2h", 'p2wsh', AF_P2WSH) - - top = npath[0] & 0x7fffffff - if top == npath[0]: - # non-hardened top? rare/bad - return - - if top == 45: - return AF_P2SH - - if top == 48: - if len(npath) < 4: return - - last = npath[3] & 0x7fffffff - if last == 1: - return AF_P2WSH_P2SH - if last == 2: - return AF_P2WSH @classmethod def import_from_psbt(cls, M, N, xpubs_list): @@ -987,18 +272,6 @@ class MultisigWallet(BaseStorageWallet): else: assert False # not reachable, since we picked wallet based on xfps - def get_deriv_paths(self): - # List of unique derivation paths being used. Often length one. - # - also a rendered single-value summary - derivs = sorted(set(d for _,d,_ in self.xpubs)) - - if len(derivs) == 1: - dsum = derivs[0] - else: - dsum = 'Varies (%d)' % len(derivs) - - return derivs, dsum - async def confirm_import(self): # prompt them about a new wallet, let them see details and then commit change. M, N = self.M, self.N @@ -1082,418 +355,7 @@ Press (1) to see extended public keys, '''.format(M=M, N=N, name=self.name, exp= return ch - async def show_detail(self, verbose=True): - # Show the xpubs; might be 2k or more rendered. - msg = uio.StringIO() - if verbose: - if not self.bip67: - msg.write("WARNING: BIP-67 disabled! Unsorted multisig - order of keys in descriptor/backup is crucial.\n\n") - - vmsg = ('Policy: {M} of {N}\n' - 'Blockchain: {ctype}\n' - 'Addresses: {at}\n\n') - vmsg = vmsg.format(M=self.M, N=self.N, ctype=chains.current_chain().ctype, - at=self.render_addr_fmt(self.addr_fmt)) - msg.write(vmsg) - - # order of keys in self.xpubs is same as order of keys in CC import format or descriptor - for idx, (xfp, deriv, xpub) in enumerate(self.xpubs): - if idx: - msg.write('\n---===---\n\n') - - msg.write('%s:\n %s\n\n%s\n' % (xfp2str(xfp), deriv, xpub)) - - if self.addr_fmt not in (AF_P2SH, AF_P2TR): - # SLIP-132 format [yz]pubs here when not p2sh mode. - # - has same info as proper bitcoin serialization, but looks much different - node = self.chain.deserialize_node(xpub, AF_P2SH) - xp = self.chain.serialize_public(node, self.addr_fmt) - - msg.write('\nSLIP-132 equiv:\n%s\n' % xp) - - return await ux_show_story(msg, title=self.name) - - # Key Teleport support, where a co-signers pubkeys are used for ECDH - - def kt_make_rxkey(self, xfp): - # Derive the receiver's pubkey from preshared xpub and a special derivation - # - also provide the keypair we're using from our side of connection - # - returns 4 byte nonce which is sent un-encrypted, his_pubkey and my_keypair - ri = ngu.random.uniform(1<<28) - try: - xpub, = self.xpubs_from_xfp(xfp) - except ValueError: - raise RuntimeError("dup or missing xfp") - - node = self.chain.deserialize_node(xpub, AF_P2SH) - node.derive(KT_RXPUBKEY_DERIV, False) - node.derive(ri, False) - pubkey = node.pubkey() - - kp = self.kt_my_keypair(ri) - - #print("psbt sender: ri=%d toward xfp: %s ... %s" % (ri, xfp2str(xfp), B2A(pubkey))) - - return ri.to_bytes(4, 'big'), pubkey, kp - - def kt_my_keypair(self, ri): - # Calc my keypair for sending PSBT files. - # - - my_xfp = settings.get('xfp') - - # Find the derivation path used by my leg of this multisig - deriv = list(self.xfp_paths[my_xfp]) - deriv.append(KT_RXPUBKEY_DERIV) - deriv.append(ri) - - path = keypath_to_str(deriv) - - with stash.SensitiveValues() as sv: - node = sv.derive_path(path) - - kp = ngu.secp256k1.keypair(node.privkey()) - - #print("my keypair: ri=%d my_xfp=%s ... %s" % ( - # ri, xfp2str(my_xfp), B2A(kp.pubkey().to_bytes()))) - - return kp - - @classmethod - def kt_search_rxkey(cls, payload): - # Construct the keypair for to be decryption - # - has to try pubkey each all the unique XFP for all co-signers in all wallets - # - checks checksum of ECDH unwrapped data to see if it's the right one - # - returns session key, decrypted first layer, and XFP of sender - from teleport import decode_step1 - - # this nonce is part of the derivation path so each txn gets new keys - ri = int.from_bytes(payload[0:4], 'big') - - my_xfp = settings.get('xfp') - - kp = None - for ms in cls.iter_wallets(): - if my_xfp not in ms.xfp_paths: - # we aren't a party to this MS wallet? not supposed to happen, but - # easy to handle - continue - - if (not kp) or (kp_deriv != ms.xfp_paths[my_xfp]): - # my keypair is cachable if my derivation path is the - # same in subsequent MS wallet - kp = ms.kt_my_keypair(ri) - kp_deriv = ms.xfp_paths[my_xfp] - - for xfp, deriv, xpub in ms.xpubs: - if xfp == my_xfp: continue - - node = ms.chain.deserialize_node(xpub, AF_P2SH) - node.derive(KT_RXPUBKEY_DERIV, False) - node.derive(ri, False) - - his_pubkey = node.pubkey() - - #print("try decode: ri=%d toward xfp: %s ... from %s <= to %s" % ( - # ri, xfp2str(xfp), B2A(his_pubkey), B2A(kp.pubkey().to_bytes())), end=' ... ') - - # if implied session key decodes the checksum, it is right - ses_key, body = decode_step1(kp, his_pubkey, payload[4:]) - - if ses_key: - return ses_key, body, xfp - - return None, None, None - -async def no_ms_yet(*a): - # action for 'no wallets yet' menu item - await ux_show_story("You don't have any multisig wallets yet.") - -def disable_checks_chooser(): - ch = ['Normal', 'Skip Checks'] - - def xset(idx, text): - MultisigWallet.disable_checks = bool(idx) - - return int(MultisigWallet.disable_checks), ch, xset - -async def disable_checks_menu(*a): - - if not MultisigWallet.disable_checks: - ch = await ux_show_story('''\ -With many different wallet vendors and implementors involved, it can \ -be hard to create a PSBT consistent with the many keys involved. \ -With this setting, you can \ -disable the more stringent verification checks your Coldcard normally provides. - -USE AT YOUR OWN RISK. These checks exist for good reason! Signed txn may \ -not be accepted by network. - -This settings lasts only until power down. - -Press (4) to confirm entering this DANGEROUS mode. -''', escape='4') - - if ch != '4': return - - start_chooser(disable_checks_chooser) - - -def psbt_xpubs_policy_chooser(): - # Chooser for trust policy - ch = ['Verify Only', 'Offer Import', 'Trust PSBT'] - - def xset(idx, text): - settings.set('pms', idx) - - return MultisigWallet.get_trust_policy(), ch, xset - -async def trust_psbt_menu(*a): - # show a story then go into chooser - - ch = await ux_show_story('''\ -This setting controls what the Coldcard does \ -with the co-signer public keys (XPUB) that may \ -be provided inside a PSBT file. Three choices: - -- Verify Only. Do not import the xpubs found, but do \ -verify the correct wallet already exists on the Coldcard. - -- Offer Import. If it's a new multisig wallet, offer to import \ -the details and store them as a new wallet in the Coldcard. - -- Trust PSBT. Use the wallet data in the PSBT as a temporary, -multisig wallet, and do not import it. This permits some \ -deniability and additional privacy. - -When the XPUB data is not provided in the PSBT, regardless of the above, \ -we require the appropriate multisig wallet to already exist \ -on the Coldcard. Default is to 'Offer' unless a multisig wallet already \ -exists, otherwise 'Verify'.''') - - if ch == 'x': return - start_chooser(psbt_xpubs_policy_chooser) - - -class MultisigMenu(MenuSystem): - - @classmethod - def construct(cls): - # Dynamic menu with user-defined names of wallets shown - from glob import NFC - - from bsms import make_ms_wallet_bsms_menu - - if not MultisigWallet.exists(): - rv = [MenuItem(MultisigWallet.none_setup_yet(), f=no_ms_yet)] - else: - rv = [] - for ms in MultisigWallet.get_all(): - rv.append(MenuItem('%d/%d: %s' % (ms.M, ms.N, ms.name), - menu=make_ms_wallet_menu, arg=ms.storage_idx)) - - rv.append(MenuItem('Import from File', f=import_multisig)) - rv.append(MenuItem('Import from QR', f=import_multisig_qr, - predicate=version.has_qwerty, shortcut=KEY_QR)) - rv.append(MenuItem('Import via NFC', f=import_multisig_nfc, - predicate=bool(NFC), shortcut=KEY_NFC)) - rv.append(MenuItem('Export XPUB', f=export_multisig_xpubs)) - rv.append(MenuItem('BSMS (BIP-129)', menu=make_ms_wallet_bsms_menu)) - rv.append(MenuItem('Create Airgapped', f=create_ms_step1)) - rv.append(MenuItem('Trust PSBT?', f=trust_psbt_menu)) - rv.append(MenuItem('Skip Checks?', f=disable_checks_menu)) - - return rv - - def update_contents(self): - # Reconstruct the list of wallets on this dynamic menu, because - # we added or changed them and are showing that same menu again. - tmp = self.construct() - self.replace_items(tmp) - - -async def make_multisig_menu(*a): - # list of all multisig wallets, and high-level settings/actions - from pincodes import pa - - if not pa.has_secrets(): - await ux_show_story("You must have wallet seed before creating multisig wallets.") - return - - rv = MultisigMenu.construct() - return MultisigMenu(rv) - -async def make_ms_wallet_menu(menu, label, item): - # details, actions on single multisig wallet - ms = MultisigWallet.get_by_idx(item.arg) - if not ms: return - - rv = [ - MenuItem('"%s"' % ms.name, f=ms_wallet_detail, arg=ms), - MenuItem('View Details', f=ms_wallet_detail, arg=ms), - MenuItem('Delete', f=ms_wallet_delete, arg=ms), - ] - if ms.bip67: - rv += [ - MenuItem('Coldcard Export', f=ms_wallet_ckcc_export, arg=(ms, {})), - MenuItem('Electrum Wallet', f=ms_wallet_electrum_export, arg=ms), - ] - # only way to export non-BIP-67 ms wallet is descriptors (+core export) - rv.append(MenuItem('Descriptors', menu=make_ms_wallet_descriptor_menu, arg=ms)) - return rv - -async def make_ms_wallet_descriptor_menu(menu, label, item): - # descriptor menu - ms = item.arg - if not ms: - return - - rv = [ - MenuItem('View Descriptor', f=ms_wallet_show_descriptor, arg=ms), - MenuItem('Export', f=ms_wallet_ckcc_export, - arg=(ms, {"descriptor": True, "desc_pretty": False})), - MenuItem('Bitcoin Core', f=ms_wallet_ckcc_export, - arg=(ms, {"descriptor": True, "core": True})), - ] - return rv - -async def ms_wallet_delete(menu, label, item): - ms = item.arg - - # delete - if not await ux_confirm("Delete this multisig wallet (%s)?\n\nFunds may be impacted." - % ms.name): - await ux_dramatic_pause('Aborted.', 3) - return - - ms.delete() - await ux_dramatic_pause('Deleted.', 3) - - # update/hide from menu - #menu.update_contents() - - from ux import the_ux - # pop stack - the_ux.pop() - - m = the_ux.top_of_stack() - m.update_contents() - -async def ms_wallet_ckcc_export(menu, label, item): - # create a text file with the details; ready for import to next Coldcard - ms = item.arg[0] - kwargs = item.arg[1] - await ms.export_wallet_file(**kwargs) - -async def ms_wallet_show_descriptor(menu, label, item): - from glob import dis - dis.fullscreen("Wait...") - ms = item.arg - desc = ms.to_descriptor() - desc_str = desc.to_string(internal=False) - ch = await ux_show_story("Press (1) to export in pretty human readable format.\n\n" + desc_str, escape="1") - if ch == "1": - await ms.export_wallet_file(descriptor=True, desc_pretty=True) - -async def ms_wallet_electrum_export(menu, label, item): - # create a JSON file that Electrum can use. Challenges: - # - file contains derivation paths for each co-signer to use - # - electrum is using BIP-43 with purpose=48 (purpose48_derivation) to make paths like: - # m/48h/1h/0h/2h - # - above is now called BIP-48 - # - other signers might not be coldcards (we don't know) - # solution: - # - when building air-gap, pick address type at that point, and matching path to suit - # - could check path prefix and addr_fmt make sense together, but meh. - ms = item.arg - from actions import electrum_export_story - - derivs, dsum = ms.get_deriv_paths() - - msg = 'The new wallet will have derivation path:\n %s\n and use %s addresses.\n' % ( - dsum, MultisigWallet.render_addr_fmt(ms.addr_fmt) ) - - if await ux_show_story(electrum_export_story(msg)) != 'y': - return - - await ms.export_electrum() - - -async def ms_wallet_detail(menu, label, item): - # show details of single multisig wallet - from glob import dis - ms = item.arg - dis.fullscreen("Wait...") - return await ms.show_detail() - - -async def export_multisig_xpubs(*a, xfp=None, alt_secret=None, skip_prompt=False): - # WAS: Create a single text file with lots of docs, and all possible useful xpub values. - # THEN: Just create the one-liner xpub export value they need/want to support BIP-45 - # NOW: Export JSON with one xpub per useful address type and semi-standard derivation path - # - # - consumer for this file is supposed to be ourselves, when we build on-device multisig. - # - however some 3rd parties are making use of it as well. - # - used for CCC feature now as well, but result looks just like normal export - # - xfp = xfp2str(xfp or settings.get('xfp', 0)) - chain = chains.current_chain() - - fname_pattern = 'ccxp-%s.json' % xfp - label = "Multisig XPUB" - - if not skip_prompt: - msg = '''\ -This feature creates a small file containing \ -the extended public keys (XPUB) you would need to join \ -a multisig wallet. - -Public keys for BIP-48 conformant paths are used: - -P2SH-P2WSH: - m/48h/{coin}h/{{acct}}h/1h -P2WSH: - m/48h/{coin}h/{{acct}}h/2h -P2TR: - m/48h/{coin}h/{{acct}}h/3h - -{ok} to continue. {x} to abort.'''.format(coin=chain.b44_cointype, ok=OK, x=X) - - ch = await ux_show_story(msg) - if ch != "y": - return - - acct = await ux_enter_bip32_index('Account Number:') or 0 - - def render(acct_num): - sign_der = None - with uio.StringIO() as fp: - fp.write('{\n') - with stash.SensitiveValues(secret=alt_secret) as sv: - for name, deriv, fmt in chains.MS_STD_DERIVATIONS: - if fmt == AF_P2SH and acct_num: - continue - dd = deriv.format(coin=chain.b44_cointype, acct_num=acct_num) - if fmt == AF_P2WSH: - sign_der = dd + "/0/0" - node = sv.derive_path(dd) - xp = chain.serialize_public(node, fmt) - fp.write(' "%s_deriv": "%s",\n' % (name, dd)) - fp.write(' "%s": "%s",\n' % (name, xp)) - xpub = chain.serialize_public(node) - descriptor_template = multisig_descriptor_template(xpub, dd, xfp, fmt) - if descriptor_template is None: - continue - fp.write(' "%s_desc": "%s",\n' % (name, descriptor_template)) - - fp.write(' "account": "%d",\n' % acct_num) - fp.write(' "xfp": "%s"\n}\n' % xfp) - return fp.getvalue(), sign_der, AF_CLASSIC - - from export import export_contents - await export_contents(label, lambda: render(acct), fname_pattern, - force_bbqr=True, is_json=True) async def validate_xpub_for_ms(obj, af_str, chain, my_xfp, xpubs): # Read xpub and validate from JSON received via SD card or BBQr @@ -1510,6 +372,7 @@ async def validate_xpub_for_ms(obj, af_str, chain, my_xfp, xpubs): xpubs.append(item) return is_mine + async def ms_coordinator_qr(af_str, my_xfp, chain): # Scan a number of JSON files from BBQr w/ derive, xfp and xpub details. # @@ -1579,7 +442,8 @@ async def ms_coordinator_file(af_str, my_xfp, chain, slot_b=None): # ignore subdirs continue - if fn.endswith('.bsms'): pass # allows files with [xfp/p/a/t/h]xpub + if fn.endswith('.bsms'): + pass # allows files with [xfp/p/a/t/h]xpub elif not fn.startswith('ccxp-') or not fn.endswith('.json'): # wrong prefix/suffix: ignore continue @@ -1628,17 +492,19 @@ async def ms_coordinator_file(af_str, my_xfp, chain, slot_b=None): return xpubs, num_mine, num_files + def add_own_xpub(chain, acct_num, addr_fmt, secret=None): # Build out what's required for using master secret (or another # encoded secret) as a co-signer deriv = "m/48h/%dh/%dh/%dh" % (chain.b44_cointype, acct_num, - 2 if addr_fmt == AF_P2WSH else 1) + 2 if addr_fmt == AF_P2WSH else 1) with stash.SensitiveValues(secret=secret) as sv: node = sv.derive_path(deriv) the_xfp = sv.get_xfp() return (the_xfp, deriv, chain.serialize_public(node, AF_P2SH)) + async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False, for_ccc=None): # 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) @@ -1682,7 +548,7 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False, acct = await ux_enter_bip32_index('CCC Account Number:') or 0 dis.fullscreen("Wait...") - a = add_own_xpub(chain, acct, addr_fmt) # master: key A + a = add_own_xpub(chain, acct, addr_fmt) # master: key A c = add_own_xpub(chain, acct, addr_fmt, secret=secret) # problem: above file searching may find xpub export from key C @@ -1733,7 +599,7 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False, name = "Coldcard Co-sign" if version.has_qwerty else "CCC" if ccc_ms_count: # make name unique for each CCC wallet, but they can edit - name += " #%d" % (ccc_ms_count+1) + name += " #%d" % (ccc_ms_count + 1) else: name = 'CC-%d-of-%d' % (M, N) @@ -1760,8 +626,8 @@ async def create_ms_step1(*a, for_ccc=None): if version.has_qr: # They have a scanner, could do QR codes... - ch = await ux_show_story("Press "+ KEY_QR + " to scan multisg XPUBs from " - "QR codes (BBQr) or ENTER to use SD card(s).", + ch = await ux_show_story("Press " + KEY_QR + " to scan multisg XPUBs from " + "QR codes (BBQr) or ENTER to use SD card(s).", title="QR or SD Card?") if ch == KEY_QR: @@ -1790,77 +656,4 @@ Default is P2WSH addresses (segwit) or press (1) for P2SH-P2WSH.''', escape='1') except Exception as e: await ux_show_story('Failed to create multisig.\n\n%s\n%s' % (e, problem_file_line(e)), title="ERROR") - - -async def import_multisig_nfc(*a): - from glob import NFC - # this menu option should not be available if NFC is disabled - try: - return await NFC.import_miniscript_nfc(legacy_multisig=True) - except Exception as e: - await ux_show_story(title="ERROR", msg="Failed to import multisig. %s" % str(e)) - -async def import_multisig_qr(*a): - from auth import maybe_enroll_xpub - from ux_q1 import QRScannerInteraction - data = await QRScannerInteraction().scan_text('Scan Multisig from a QR code') - if not data: - # pressed CANCEL - return - - try: - maybe_enroll_xpub(config=data) - except Exception as e: - await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) - -async def import_multisig(*a): - # pick text file from SD card, import as multisig setup file - from actions import file_picker - from glob import VD - - force_vdisk = False - if VD: - prompt = "Press (1) to import multisig wallet file from SD Card" - escape = "1" - if VD is not None: - prompt += ", press (2) to import from Virtual Disk" - escape += "2" - prompt += "." - ch = await ux_show_story(prompt, escape=escape) - if ch == "1": - force_vdisk=False - elif ch == "2": - force_vdisk = True - else: - return - - def possible(filename): - with open(filename, 'rt') as fd: - for ln in fd: - if "sh(" in ln or "wsh(" in ln: - # descriptor import - return True - if 'pub' in ln: - return True - - fn = await file_picker(suffix=['.txt', '.json'], min_size=100, max_size=350*200, - taster=possible, force_vdisk=force_vdisk) - if not fn: return - - try: - with CardSlot(force_vdisk=force_vdisk) as card: - with open(fn, 'rt') as fp: - data = fp.read() - except CardMissingError: - await needs_microsd() - return - - from auth import maybe_enroll_xpub - try: - possible_name = (fn.split('/')[-1].split('.'))[0] - maybe_enroll_xpub(config=data, name=possible_name) - except Exception as e: - #import sys; sys.print_exception(e) - await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) - # EOF diff --git a/shared/nfc.py b/shared/nfc.py index fe8230ea..a3ec08e8 100644 --- a/shared/nfc.py +++ b/shared/nfc.py @@ -802,7 +802,7 @@ class NFCHandler: return await self._nfc_reader(f, 'Unable to find BSMS data in NDEF data') - async def import_miniscript_nfc(self, legacy_multisig=False): + async def import_miniscript_nfc(self): def f(m): if len(m) < 70: return m = m.decode() @@ -816,7 +816,7 @@ class NFCHandler: from auth import maybe_enroll_xpub try: - maybe_enroll_xpub(config=winner, miniscript=not legacy_multisig) + maybe_enroll_xpub(config=winner) except Exception as e: await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) diff --git a/shared/ownership.py b/shared/ownership.py index a7c5b12f..a685c37b 100644 --- a/shared/ownership.py +++ b/shared/ownership.py @@ -208,7 +208,6 @@ class OwnershipCache: # Find it! # - returns wallet object, and tuple2 of final 2 subpath components # - if you start w/ testnet, we'll follow that - from multisig import MultisigWallet from miniscript import MiniScriptWallet from glob import dis @@ -226,15 +225,11 @@ class OwnershipCache: if addr_fmt & AFC_SCRIPT: # multisig or script at least.. must exist already - possibles.extend(MultisigWallet.iter_wallets(addr_fmt=addr_fmt)) - msc = [w for w in MiniScriptWallet.iter_wallets() if w.addr_fmt == addr_fmt] - possibles.extend(msc) + possibles.extend([w for w in MiniScriptWallet.iter_wallets() if w.addr_fmt == addr_fmt]) if addr_fmt == AF_P2SH: # might look like P2SH but actually be AF_P2WSH_P2SH - possibles.extend(MultisigWallet.iter_wallets(addr_fmt=AF_P2WSH_P2SH)) - msc = [w for w in MiniScriptWallet.iter_wallets() if w.addr_fmt == AF_P2WSH_P2SH] - possibles.extend(msc) + possibles.extend([w for w in MiniScriptWallet.iter_wallets() if w.addr_fmt == AF_P2WSH_P2SH]) # Might be single-sig p2wpkh wrapped in p2sh ... but that was a transition # thing that hopefully is going away, so if they have any multisig wallets, @@ -313,13 +308,12 @@ class OwnershipCache: # Provide a simple UX. Called functions do fullscreen, progress bar stuff. from ux import ux_show_story, show_qr_code from charcodes import KEY_QR - from multisig import MultisigWallet from miniscript import MiniScriptWallet from public_constants import AFC_BECH32, AFC_BECH32M try: wallet, subpath = OWNERSHIP.search(addr) - is_complex = isinstance(wallet, MultisigWallet) or isinstance(wallet, MiniScriptWallet) + is_complex = isinstance(wallet, MiniScriptWallet) sp = None msg = show_single_address(addr) diff --git a/shared/psbt.py b/shared/psbt.py index ac828c28..4427c092 100644 --- a/shared/psbt.py +++ b/shared/psbt.py @@ -12,8 +12,8 @@ from uhashlib import sha256 from uio import BytesIO from sffile import SizerFile from chains import taptweak, tapleaf_hash -from miniscript import MiniScriptWallet, Key -from multisig import MultisigWallet, disassemble_multisig_mn +from miniscript import MiniScriptWallet +from multisig import disassemble_multisig_mn from exceptions import FatalPSBTIssue, FraudulentChangeOutput from serializations import ser_compact_size, deser_compact_size, hash160 from serializations import CTxIn, CTxInWitness, CTxOut, ser_string, COutPoint @@ -483,7 +483,7 @@ class psbtOutputProxy(psbtProxy): for k, v in self.unknown.items(): wr(k[0], v, k[1:]) - def validate(self, out_idx, txo, my_xfp, active_multisig, active_miniscript, parent): + def validate(self, out_idx, txo, my_xfp, active_miniscript, parent): # Do things make sense for this output? # NOTE: We might think it's a change output just because the PSBT @@ -535,11 +535,9 @@ class psbtOutputProxy(psbtProxy): pkh = addr_or_pubkey if af == 'p2sh': - # P2SH or Multisig output # Can be both, or either one depending on address type redeem_script = self.get(self.redeem_script) if self.redeem_script else None - witness_script = self.get(self.witness_script) if self.witness_script else None if expect_pubkey: # num_ours == 1 and len(subpaths) == 1, single sig, we only allow p2sh-p2wpkh @@ -562,6 +560,12 @@ class psbtOutputProxy(psbtProxy): else: if active_miniscript: + # TODO disable checks + # if MultisigWallet.disable_checks: + # # Without validation, we have to assume all outputs + # # will be taken from us, and are not really change. + # self.is_change = False + # return af # scriptPubkey can be compared against script that we build - if exact match change # if not - not change - no need for redeem/witness script # @@ -574,66 +578,13 @@ class psbtOutputProxy(psbtProxy): except Exception as e: raise FraudulentChangeOutput(out_idx, "Change output scriptPubkey: %s" % e) - elif active_multisig: - # Multisig change output, for wallet we're supposed to be a part of. - # - our key must be part of it - # - must look like input side redeem script (same fingerprints) - # - assert M/N structure of output to match any inputs we have signed in PSBT! - # - assert all provided pubkeys are in redeem script, not just ours - # - we get all of that by re-constructing the script from our wallet details - if MultisigWallet.disable_checks: - # Without validation, we have to assume all outputs - # will be taken from us, and are not really change. - self.is_change = False - return af - - scr = witness_script or redeem_script - if not scr: - raise FatalPSBTIssue("Missing redeem/witness script for output #%d" % out_idx) - - # redeem script must be exactly what we expect - # - pubkeys will be reconstructed from derived paths here - # - BIP-45, BIP-67 rules applied (BIP-67 optional from now - depending on imported descriptor) - # - p2sh-p2wsh needs witness script here, not redeem script value - # - if details provided in output section, must our match multisig wallet - try: - active_multisig.validate_script(scr, subpaths=self.subpaths) - except BaseException as exc: - raise FraudulentChangeOutput(out_idx, - "P2WSH or P2SH change output script: %s" % exc) else: - # it cannot be change if it doesn't precisely match our multisig setup - # - might be a p2sh output for another wallet that isn't us + # it cannot be change if it doesn't precisely match our miniscript setup + # - might be a output for another wallet that isn't us # - not fraud, just an output with more details than we need. self.is_change = False return af - if is_segwit: - # p2wsh case - # - need witness script and check it's hash against proposed p2wsh value - assert len(addr_or_pubkey) == 32 - expect_wsh = ngu.hash.sha256s(witness_script) - if expect_wsh != addr_or_pubkey: - raise FraudulentChangeOutput(out_idx, "P2WSH witness script has wrong hash") - - self.is_change = True - return af - - if witness_script: - # p2sh-p2wsh case (because it had witness script) - expect_rs = b'\x00\x20' + ngu.hash.sha256s(witness_script) - - if redeem_script and expect_rs != redeem_script: - # iff they provide a redeeem script, then it needs to match - # what we expect it to be - raise FraudulentChangeOutput(out_idx, - "P2SH-P2WSH redeem script provided, and doesn't match") - - expect_pkh = hash160(expect_rs) - else: - # old BIP-16 style; looks like payment addr - expect_pkh = hash160(redeem_script) - elif af == 'p2pkh': # input is hash160 of a single public key assert len(addr_or_pubkey) == 20 @@ -680,10 +631,10 @@ class psbtInputProxy(psbtProxy): blank_flds = ( 'unknown', 'utxo', 'witness_utxo', 'sighash', 'redeem_script', 'witness_script', - 'fully_signed', 'is_segwit', 'is_multisig', 'is_p2sh', 'num_our_keys', + 'fully_signed', 'is_segwit', 'is_p2sh', 'num_our_keys', 'required_key', 'scriptSig', 'amount', 'scriptCode', 'previous_txid', 'prevout_idx', 'sequence', 'req_time_locktime', 'req_height_locktime', 'taproot_key_sig', - 'taproot_merkle_root', 'taproot_script_sigs', 'taproot_scripts', "use_keypath", "subpaths", + 'taproot_merkle_root', 'taproot_script_sigs', 'taproot_scripts', "subpaths", "taproot_subpaths", "taproot_internal_key", "is_miniscript", ) @@ -706,7 +657,6 @@ class psbtInputProxy(psbtProxy): # we can't really learn this until we take apart the UTXO's scriptPubKey #self.is_segwit = None - #self.is_multisig = None #self.is_p2sh = False #self.required_key = None # which of our keys will be used to sign input @@ -812,7 +762,7 @@ class psbtInputProxy(psbtProxy): # - could consider structure of MofN in p2sh cases self.fully_signed = (len(self.part_sigs) >= len(self.subpaths)) else: - # No signatures at all yet for this input (typical non multisig) + # No signatures at all yet for this input (typical non miniscript) self.fully_signed = False if self.taproot_key_sig: @@ -912,7 +862,6 @@ class psbtInputProxy(psbtProxy): self.required_key = None return - self.is_multisig = False self.is_miniscript = False self.is_p2sh = False which_key = None @@ -931,7 +880,7 @@ class psbtInputProxy(psbtProxy): self.is_segwit = True if addr_type == 'p2sh': - # multisig input + # miniscript input self.is_p2sh = True # we must have the redeem script already (else fail) @@ -948,7 +897,6 @@ class psbtInputProxy(psbtProxy): which_key, = self.subpaths.keys() else: # Assume we'll be signing with any key we know - # - limitation: we cannot be two legs of a multisig (only if CCC feature used) # - but if partial sig already in place, ignore that one if not which_key: which_key = set() @@ -970,14 +918,10 @@ class psbtInputProxy(psbtProxy): addr = redeem_script[2:22] self.is_segwit = True else: - # multiple keys involved, we probably can't do the finalize step - M, N = disassemble_multisig_mn(redeem_script) - if M is None and N is None: - self.is_miniscript = True - else: - self.is_multisig = True + # multiple keys involved + self.is_miniscript = True - if self.witness_script and not self.is_segwit and (self.is_miniscript or self.is_multisig): + if self.witness_script and (not self.is_segwit) and self.is_miniscript: # bugfix addr_type = 'p2sh-p2wsh' self.is_segwit = True @@ -1022,7 +966,6 @@ class psbtInputProxy(psbtProxy): which_key = xonly_pubkey # if we find a possibility to spend keypath (internal_key) - we do keypath # even though script path is available - self.use_keypath = True break else: internal_key = self.get(self.taproot_internal_key) @@ -1047,34 +990,6 @@ class psbtInputProxy(psbtProxy): # we don't know how to "solve" this type of input pass - if self.is_multisig: - # We will be signing this input, so - # - find which wallet it is or - # - check it's the right M/N to match redeem script - # - which_key can be empty set, meaning all is already signed - - #print("redeem: %s" % b2a_hex(redeem_script)) - xfp_paths = list(self.subpaths.values()) - xfp_paths.sort() - - if not psbt.active_multisig: - # search for multisig wallet - wal = MultisigWallet.find_match(M, N, xfp_paths) - if not wal: - raise FatalPSBTIssue('Unknown multisig wallet') - - psbt.active_multisig = wal - else: - # check consistent w/ already selected wallet - psbt.active_multisig.assert_matching(M, N, xfp_paths) - - # validate redeem script, by disassembling it and checking all pubkeys - try: - psbt.active_multisig.validate_script(redeem_script, subpaths=self.subpaths) - except BaseException as exc: - # sys.print_exception(exc) - raise FatalPSBTIssue('Input #%d: %s' % (my_idx, exc)) - if self.is_miniscript: try: xfp_paths = [item[1:] for item in self.taproot_subpaths.values() if len(item[1:]) > 1] @@ -1082,13 +997,14 @@ class psbtInputProxy(psbtProxy): xfp_paths = list(self.subpaths.values()) xfp_paths.sort() - if not psbt.active_miniscript: + if psbt.active_miniscript: + psbt.active_miniscript.matching_subpaths(xfp_paths), "wrong wallet" + else: wal = MiniScriptWallet.find_match(xfp_paths) if not wal: raise FatalPSBTIssue('Unknown miniscript wallet') psbt.active_miniscript = wal - assert psbt.active_miniscript try: # contains PSBT merkle root verification psbt.active_miniscript.validate_script_pubkey(utxo.scriptPubKey, @@ -1114,7 +1030,7 @@ class psbtInputProxy(psbtProxy): # # Also need this scriptCode for native segwit p2pkh # - assert not self.is_multisig + assert not self.is_miniscript self.scriptCode = b'\x19\x76\xa9\x14' + addr + b'\x88\xac' elif not self.scriptCode: # Segwit P2SH. We need the witness script to be provided. @@ -1297,9 +1213,8 @@ class psbtObject(psbtProxy): self.hashValues = None self.hashScriptPubKeys = None - # this points to a MS wallet, during operation - # - we are only supporting a single multisig wallet during signing - self.active_multisig = None + # this points to a Miniscript wallet, during operation + # - we are only supporting a single miniscript wallet during signing self.active_miniscript = None self.warnings = [] @@ -1778,7 +1693,7 @@ class psbtObject(psbtProxy): for idx, txo in self.output_iter(): output = self.outputs[idx] # perform output validation - af = output.validate(idx, txo, self.my_xfp, self.active_multisig, self.active_miniscript, self) + af = output.validate(idx, txo, self.my_xfp, self.active_miniscript, self) assert txo.nValue >= 0, "negative output value: o%d" % idx total_out += txo.nValue @@ -2028,7 +1943,7 @@ class psbtObject(psbtProxy): # Look at what kind of input this will be, and therefore what # type of signing will be required, and which key we need. # - also validates redeem_script when present - # - also finds appropriate multisig wallet to be used + # - also finds appropriate miniscript wallet to be used inp.determine_my_signing_key(i, utxo, self.my_xfp, self, cosign_xfp) # iff to UTXO is segwit, then check it's value, and also @@ -2038,8 +1953,6 @@ class psbtObject(psbtProxy): del utxo - # XXX scan witness data provided, and consider those ins signed if not multisig? - if not foreign: # no foreign inputs, we can calculate the total input value assert total_in > 0, "zero value txn" @@ -2078,8 +1991,9 @@ class psbtObject(psbtProxy): 'Some input(s) provided were already completely signed by other parties: ' + seq_to_str(self.presigned_inputs))) - if MultisigWallet.disable_checks: - self.warnings.append(('Danger', 'Some multisig checks are disabled.')) + # TODO + # if MultisigWallet.disable_checks: + # self.warnings.append(('Danger', 'Some multisig checks are disabled.')) def calculate_fee(self): # what miner's reward is included in txn? @@ -2273,7 +2187,7 @@ class psbtObject(psbtProxy): res = self.check_pubkey_at_path(sv, subpath, pubkey) if res: good += 1 - # TODO is this needed if output is multisig? + # TODO is this needed if output is multisig? imo not needed note_subpath used is only used with single-sig OWNERSHIP.note_subpath_used(subpath) if oup.taproot_subpaths: @@ -2283,7 +2197,7 @@ class psbtObject(psbtProxy): res = self.check_pubkey_at_path(sv, subpath, xonly_pk, is_xonly=True) if res: good += 1 - # TODO is this needed if output is miniscript? + # TODO is this needed if output is miniscript? imo not needed note_subpath used is only used with single-sig OWNERSHIP.note_subpath_used(subpath) if not good: @@ -2319,7 +2233,7 @@ class psbtObject(psbtProxy): tr_sh = [] inp.handle_none_sighash() to_sign = [] - if isinstance(inp.required_key, set) and (inp.is_multisig or inp.is_miniscript): + if isinstance(inp.required_key, set) and inp.is_miniscript: # need to consider a set of possible keys, since xfp may not be unique for which_key in inp.required_key: # get node required @@ -2761,11 +2675,14 @@ class psbtObject(psbtProxy): # double SHA256 return ngu.hash.sha256s(rv.digest()) - def multi_input_complete(self, inp): - # raises if input is not multisig or no active_multisig loaded - assert inp.is_multisig - if len(inp.part_sigs) >= self.active_multisig.M: - return True + def miniscript_input_complete(self, inp): + desc = self.active_miniscript.to_descriptor() + if desc.is_basic_multisig: + # we can only finalize multisig inputs from all miniscript set + M, N = desc.miniscript.m_n + if len(inp.part_sigs) >= M: + return True + return False def is_complete(self): # Are all the inputs (now) signed? @@ -2776,11 +2693,8 @@ class psbtObject(psbtProxy): # plus we added some signatures for i, inp in enumerate(self.inputs): if i in self.presigned_inputs: continue - if inp.is_miniscript and not inp.use_keypath: - # but we can't combine/finalize miniscript stuff, so will never't be 'final'7 - return False - elif inp.is_multisig and self.active_multisig: - if self.multi_input_complete(inp): + elif inp.is_miniscript and self.active_miniscript: + if self.miniscript_input_complete(inp): signed += 1 elif inp.part_sigs and len(inp.part_sigs) == len(inp.subpaths): signed += 1 @@ -2790,24 +2704,27 @@ class psbtObject(psbtProxy): return signed == self.num_inputs def multisig_signatures(self, inp): - assert self.active_multisig + assert self.active_miniscript + desc = self.active_miniscript.to_descriptor() + assert desc.is_basic_multisig + M, N = desc.miniscript.m_n - if self.active_multisig.bip67: + if desc.is_sortedmulti: # BIP-67 easy just sort by public keys sigs = [sig for pk, sig in sorted(inp.part_sigs.items())] else: # need to respect the order of keys in actual descriptor sigs = [] - for xfp, _, _ in self.active_multisig.xpubs: + for key in desc.keys: for pk, pth in inp.subpaths.items(): # if xfp matches but pk not in all_sigs -> signer haven't signed # it is ok in threshold multisig - just skip - if (xfp == pth[0]) and (pk in inp.part_sigs): + if (key.origin.cc_fp == pth[0]) and (pk in inp.part_sigs): sigs.append(inp.part_sigs[pk]) break # save space and only provide necessary amount of signatures (smaller tx, less fees) - sigs = sigs[:self.active_multisig.M] + sigs = sigs[:M] return sigs def singlesig_signature(self, inp): @@ -2879,8 +2796,8 @@ class psbtObject(psbtProxy): inp = self.inputs[in_idx] # first check - if no signature(s) - fail soon - if inp.is_multisig: - assert self.multi_input_complete(inp), 'Incomplete signature set on input #%d' % in_idx + if inp.is_miniscript: + assert self.miniscript_input_complete(inp), 'Incomplete signature set on input #%d' % in_idx else: # single signature ssig = self.singlesig_signature(inp) @@ -2903,7 +2820,7 @@ class psbtObject(psbtProxy): else: # insert the new signature(s), assuming fully signed txn. - if inp.is_multisig: + if inp.is_miniscript: # p2sh multisig (non-segwit) sigs = self.multisig_signatures(inp) ss = b"\x00" @@ -2942,7 +2859,7 @@ class psbtObject(psbtProxy): # can be 65 bytes if sighash != SIGHASH_DEFAULT (0x00) assert len(inp.taproot_key_sig) in (64, 65) wit.scriptWitness.stack = [inp.taproot_key_sig] - elif inp.is_multisig: + elif inp.is_miniscript: sigs = self.multisig_signatures(inp) wit.scriptWitness.stack = [b""] + sigs + [self.get(inp.witness_script)] else: diff --git a/shared/teleport.py b/shared/teleport.py index ccc9b4b4..8e4d6f64 100644 --- a/shared/teleport.py +++ b/shared/teleport.py @@ -15,7 +15,6 @@ from bbqr import b32encode, b32decode from menu import MenuItem, MenuSystem from notes import NoteContentBase from sffile import SFFile -from multisig import MultisigWallet from miniscript import MiniScriptWallet from stash import SensitiveValues, SecretStash, blank_object, bip39_passphrase @@ -252,15 +251,11 @@ async def kt_decode_rx(is_psbt, payload): ses_key, body = decode_step1(pair, his_pubkey, body) else: # Multisig PSBT: will need to iterate over a few wallets and each N-1 possible senders - if (not MultisigWallet.exists()) and (not MiniScriptWallet.exists()): + if not MiniScriptWallet.exists(): await ux_show_story("Incoming PSBT requires miniscript wallet(s) to be already setup, but you have none.") return - ses_key, body, sender_xfp = MultisigWallet.kt_search_rxkey(payload) - - if sender_xfp is None: - ses_key, body, sender_xfp = MiniScriptWallet.kt_search_rxkey(payload) - + ses_key, body, sender_xfp = MiniScriptWallet.kt_search_rxkey(payload) if sender_xfp is not None: prompt = 'Teleport Password from [%s]' % xfp2str(sender_xfp) diff --git a/shared/usb.py b/shared/usb.py index 88a07210..d2b54ede 100644 --- a/shared/usb.py +++ b/shared/usb.py @@ -505,7 +505,7 @@ class USBHandler: # Start an UX interaction, return immediately here from auth import maybe_enroll_xpub - maybe_enroll_xpub(sf_len=file_len, ux_reset=True, miniscript=True) + maybe_enroll_xpub(sf_len=file_len, ux_reset=True) return None @@ -571,13 +571,6 @@ class USBHandler: from auth import start_show_miniscript_address return b'asci' + start_show_miniscript_address(msc, change, idx) - if cmd == 'msck': - # Quick check to test if we have a wallet already installed. - from multisig import MultisigWallet - M, N, xfp_xor = unpack_from('<3I', args) - - return int(MultisigWallet.quick_check(M, N, xfp_xor)) - if cmd == 'stxn': # sign transaction txn_len, flags, txn_sha = unpack_from('