diff --git a/docs/key-teleport.md b/docs/key-teleport.md index 08c543fd..37a92248 100644 --- a/docs/key-teleport.md +++ b/docs/key-teleport.md @@ -44,8 +44,7 @@ The first byte encodes what the package contents (under all the encryption). - `x` - XPRV mode, full details - 4 bytes (XPRV) + base58 *decoded* binary-XPRV follows - `n` - one or many notes export (JSON array) - `v` - seed vault export (JSON: one secret key but includes name, source of key) -- `p` - binary PSBT to be signed -- `P` - a more-signed binary PSBT being returned back to sender +- `p` - binary PSBT to be signed, perhaps multisig but not required. ## QR details @@ -58,30 +57,38 @@ New type codes for BBQr are defined for the purposes of this application: - `R` contains `(pubkey)` ... begins the process from receiver; compressed pubkey is 33 bytes - `S` contains `(pubkey)(data)` ... data from sender; first 33 bytes are sender's pubkey -- `E` for PSBT: `(randint)(data)` ... randint (4 bytes) indicates which randomly - selected derived subkey from pre-shared xpub associated with receiver +- `E` for Multisig PSBT: `(randint)(data)` ... randint (4 byte nonce) indicates which + derived subkey from pre-shared xpub associated with receiver All the data is encrypted with the exception of the pubkey or randint. Keep in mind those are both nonce values picked uniquely for each transfer. -### PSBT Key Picking +### PSBT Key Selection -When sending PSBT data, the keys involved are picked at random by the sender in range: - 5000..(2^30). +When sending PSBT data, a nonce is picked at random by the sender +in range: `0..(2^28)` -This is called `randint`. The receiver's pubkey will be +This nonce is called `randint`. The receiver's pubkey will be .../20250317/(randint) -where `...` is the derivation used in the multisig setup for the co-signer who will -receive the package. The sender's keypair is implied by: - - .../20250318/(randint) +where `...` is the derivation used in the multisig wallet for the co-signer who will +receive the package. The sender's keypair has the same sub key path assuming all +co-signers have same derivation path from root (not required). Because both the sender and receiver already have each other's XPUB they can derive the appropriate pubkeys (and privkey for their side) without communicating more than `randint`. The sending COLDCARD will pick a new random value each time. +When receiving a multisig PSBT encrypted this way, the receiver does not need +to any setup (nor numeric password) and can receive a QR code at any time. +This works because the shared multisig wallet is already setup. Receiver will +take the nonce value (randint) and seach all pre-defined multisig wallets for +any pubkey that can decrypt the package successfully (based on checksum inside +first layer of ECDH encryption). + +The next layer of encryption (paranoid password) is unchanged. + ## Encryption Details AES-256-CTR is used exclusively. Session key is picked via ECDH with final @@ -175,7 +182,33 @@ and the site will be served over SSL. to send, from secure notes to seeds and so on. - For PSBT multisig, user must pick a single co-signer (who hasn't already - signed) and the QR is prepared for that receiver. They should get another - chance to do the same for the other possible co-signers. + signed) and the QR is prepared for that receiver. Because we + cannot do arbitary conbining, it's best if the next signer continues + to teleport the updated PSBT to further signers. In other words, + a daisy-chain pattern is prefered to a star patter. The signer + who completes the Mth (of N) signature will be able to finalize + the transaction, and ideally with PushTx feature, broadcast it. +# Security Comments +## Such short passwords? + +We are using 8-character passwords because we want them to be +practical to share over non-digital channels such as a voice phone +call, or hand-written note. + +It is important to remind users that the passwords should be sent +by a different channel from the QR itself. Best is to call up your +other party and say the letters to them directly. + +## Is it safe to save image of QR to cloud? + +Yes, this seems safe. Of course, if you can control it, perhaps not +a risk to accept... but the QR is encrypted via ECDH using a key +that is forgotten after the transfer, so forward privacy is protected. +Also your cloud service (or photo roll, chat app log, etc) will not +have the 8-character password which is also required unpack the secrets. + +The QR codes themselves are fully random and do not reveal the +identity of your COLDCARD, your on chain funds or anything linked +to you. diff --git a/shared/actions.py b/shared/actions.py index e673ac7e..7ae139de 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -1657,13 +1657,14 @@ async def list_files(*A): async def file_picker(suffix=None, min_size=1, max_size=1000000, taster=None, choices=None, none_msg=None, force_vdisk=False, slot_b=None, - allow_batch_sign=False, ux=True): + allow_batch=False, ux=True): # present a menu w/ a list of files... to be read # - optionally, enforce a max size, and provide a "tasting" function - # - if msg==None, don't prompt, just do the search and return list + # - if (not ux), don't prompt, just do the search and return list # - if choices is provided; skip search process # - escape: allow these chars to skip picking process # - slot_b: None=>pick slot w/ card in it, or A if both. + # - allow_batch: adds an "all of the above" choice: ("menu label", menu_handler) if choices is None: choices = [] @@ -1707,7 +1708,7 @@ async def file_picker(suffix=None, min_size=1, max_size=1000000, taster=None, label = fn while label in sofar: # just the file name isn't unique enough sometimes? - # - shouldn't happen anymore now that we dno't support internal FS + # - shouldn't happen anymore now that we don't support internal FS # - unless we do muliple paths label += path.split('/')[-1] + '/' + fn @@ -1744,10 +1745,10 @@ async def file_picker(suffix=None, min_size=1, max_size=1000000, taster=None, choices.sort() items = [MenuItem(label, f=clicked, arg=(path, fn)) for label, path, fn in choices] - if allow_batch_sign and len(choices) > 1: - # we know that each choices member is psbt as allow_batch_sign is only True - # in Ready To Sign - items.insert(0, MenuItem("[Sign All]", f=batch_sign, arg=choices)) + if allow_batch and len(choices) > 1: + # Allow an "all" selection + label, funct = allow_batch + items.insert(0, MenuItem(label, f=funct, arg=choices)) menu = MenuSystem(items) the_ux.push(menu) @@ -1895,7 +1896,7 @@ from your desktop wallet software or command line tools.\n\n''' input_psbt = path + '/' + fn else: # multiples - ask which, and offer batch to sign them all - input_psbt = await file_picker(choices=choices, allow_batch_sign=True) + input_psbt = await file_picker(choices=choices, allow_batch=("[Sign All]", batch_sign)) if not input_psbt: return diff --git a/shared/auth.py b/shared/auth.py index 1968e3ba..e8a0ffbb 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -1040,6 +1040,12 @@ class ApproveTransaction(UserAuthorizedAction): continue break + elif version.has_qwerty and self.psbt.active_multisig: + # Offer to teleport the result + from teleport import kt_send_psbt + + await kt_send_psbt(self.psbt, self.result[0], post_signing=True) + async def txn_explorer(self): # Page through unlimited-sized transaction details # - shows all outputs (including change): their address and amounts. @@ -1250,15 +1256,19 @@ def psbt_encoding_taster(taste, psbt_len): async def done_signing(psbt, input_method=None, filename=None, force_vdisk=False, output_encoder=None, slot_b=False, finalize=None): + # User authorized PSBT for signing, and we added signatures. + # - allow PushTX if enabled (first thing) + # - can save final TXN out to SD card/VirtDisk, share by NFC, QR. + from glob import dis, PSRAM from files import CardSlot, CardMissingError from sffile import SFFile from ux import show_qr_code, import_export_prompt, ux_show_story txid = None + is_complete = psbt.is_complete() with SFFile(TXN_OUTPUT_OFFSET, max_size=MAX_TXN_LEN, message="Saving...") as psram: - is_complete = psbt.is_complete() if is_complete: txid = psbt.finalize(psram) else: @@ -1439,10 +1449,11 @@ async def done_signing(psbt, input_method=None, filename=None, force_vdisk=False n += 1 -async def sign_psbt_file(filename, force_vdisk=False, slot_b=None, abort=False): + +async def sign_psbt_file(filename, force_vdisk=False, slot_b=None, just_read=False, ux_abort=False): # sign a PSBT file found on a MicroSD card # - or from VirtualDisk (mk4) - from files import CardSlot + # - to re-use reading/decoding logic, pass just_read from glob import dis from ux import the_ux @@ -1490,6 +1501,9 @@ async def sign_psbt_file(filename, force_vdisk=False, slot_b=None, abort=False): assert total <= psbt_len psbt_len = total + if just_read: + return psbt_len + UserAuthorizedAction.cleanup() UserAuthorizedAction.active_request = ApproveTransaction( psbt_len, @@ -1498,7 +1512,7 @@ async def sign_psbt_file(filename, force_vdisk=False, slot_b=None, abort=False): "force_vdisk": force_vdisk, "output_encoder": output_encoder} ) - if abort: + if ux_abort: # needed for auto vdisk mode abort_and_push(UserAuthorizedAction.active_request) else: diff --git a/shared/flow.py b/shared/flow.py index 3d017008..76d646ad 100644 --- a/shared/flow.py +++ b/shared/flow.py @@ -39,13 +39,14 @@ if version.has_battery: from battery import battery_idle_timeout_chooser, brightness_chooser from q1 import scan_and_bag from notes import make_notes_menu - from teleport import kt_start_rx + from teleport import kt_start_rx, kt_send_file_psbt else: battery_idle_timeout_chooser = None brightness_chooser = None scan_and_bag = None make_notes_menu = None kt_start_rx = None + kt_send_file_psbt = None # @@ -215,6 +216,7 @@ FileMgmtMenu = [ MenuItem('Export Wallet', predicate=has_secrets, menu=WalletExportMenu), #dup elsewhere MenuItem('Sign Text File', predicate=has_secrets, f=sign_message_on_sd), MenuItem('Batch Sign PSBT', predicate=has_secrets, f=batch_sign), + MenuItem('Teleport Multisig PSBT', predicate=version.has_qr, f=kt_send_file_psbt), MenuItem('List Files', f=list_files), MenuItem('Verify Sig File', f=verify_sig_file), MenuItem('NFC File Share', predicate=nfc_enabled, f=nfc_share_file, shortcut=KEY_NFC), diff --git a/shared/multisig.py b/shared/multisig.py index 514fdfb6..ac9674c7 100644 --- a/shared/multisig.py +++ b/shared/multisig.py @@ -22,6 +22,9 @@ 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) + class MultisigOutOfSpace(RuntimeError): pass @@ -468,6 +471,10 @@ class MultisigWallet(WalletABC): 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. @@ -1180,6 +1187,84 @@ Press (1) to see extended public keys, '''.format(M=M, N=N, name=self.name, exp= 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) + + 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()) + + 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 (not kp) or (kp_deriv != ms.xfp_paths[my_xfp]): + # my keypair is cachable if path the same in 2nd MS wallet and so on + 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() + + # 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.") diff --git a/shared/psbt.py b/shared/psbt.py index 598b6714..557b2d30 100644 --- a/shared/psbt.py +++ b/shared/psbt.py @@ -2232,6 +2232,19 @@ class psbtObject(psbtProxy): sigs = sigs[:self.active_multisig.M] return sigs + def multisig_xfps_needed(self): + # provide the set of xfp's that still need to sign PSBT + # - used to find which multisig-signer needs to go next + rv = set() + for inp in self.inputs: + for pk, pth in inp.subpaths.items(): + if pk in inp.part_sigs: + continue + if pk in inp.added_sigs: + continue + rv.add(pth[0]) + return rv + def finalize(self, fd): # Stream out the finalized transaction, with signatures applied # - assumption is it's complete already. diff --git a/shared/teleport.py b/shared/teleport.py index 8be063dd..dc1f6f10 100644 --- a/shared/teleport.py +++ b/shared/teleport.py @@ -4,7 +4,7 @@ # secure environment of two Q's. # import sys, uzlib, ngu, aes256ctr, bip39, json, stash -from utils import problem_file_line, B2A, xfp2str, deserialize_secret +from utils import problem_file_line, B2A, xfp2str, deserialize_secret, keypath_to_str from ubinascii import unhexlify as a2b_hex from ubinascii import hexlify as b2a_hex from glob import settings @@ -14,21 +14,17 @@ from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL from bbqr import b32encode, b32decode from menu import MenuItem, MenuSystem from notes import NoteContentBase +from sffile import SFFile +from multisig import MultisigWallet +# One page github-hosted static website that shows QR based on URL contents pushed by NFC KT_DOMAIN = 'keyteleport.com' -''' -- `w` - 12/18/24 words - 16/24/32 bytes follow -- `m` - (one byte of length) + (up to 71 bytes) - BIP-32 raw master secret [rare] -- `r` - raw XPRV mode - 64 bytes follow which are the chain code then master privkey -- `x` - XPRV mode, full details - 4 bytes (XPRV) + base58 *decoded* binary-XPRV follows -- `n` - secure note or password (JSON) -- `e` - full notes export (JSON array) -- `v` - seed vault export (JSON: one secret key but includes includes name, source of key) -- `p` - binary PSBT to be signed -- `P` - a more-signed binary PSBT being returned back to sender -''' - +# No length/size worries with simple secrets, but massive notes and big PSBT, +# with lots of UTXO, cannot be passed via NFC URL, because we are limited by +# NFC chip (8k) and URL length (4k or less) inside. BBQr is not limited however. +# - but the website is ready to make animated BBQr nicely +NFC_SIZE_LIMIT = const(4096) def short_bbqr(type_code, data): # Short-circuit basic BBQr encoding here: always Base32, single part: 1 of 1 @@ -134,7 +130,7 @@ async def tk_show_payload(type_code, payload, title, msg, cta=None): from ux_q1 import show_bbqr_codes hints = KEY_QR - if NFC: + if NFC and len(payload) < NFC_SIZE_LIMIT: hints += KEY_NFC msg += ' or %s to view on your phone' % KEY_NFC @@ -147,7 +143,7 @@ async def tk_show_payload(type_code, payload, title, msg, cta=None): if ch == KEY_NFC and NFC: await nfc_push_kt(short_bbqr(type_code, payload)) elif ch == KEY_QR or ch == 'y': - # NOTE: CTA rarely seen, but maybe? + # NOTE: CTA rarely seen, but maybe sometimes? await show_bbqr_codes(type_code, payload, msg=cta) elif ch == 'x': return @@ -178,8 +174,8 @@ WARNING: Receiver will have full access to all Bitcoin controlled by these keys! menu = SecretPickerMenu(rx_pubkey) the_ux.push(menu) -async def kt_do_send(rx_pubkey, dtype, raw=None, obj=None): - # Example: cleartext = b'w'+ (b'A'*16) +async def kt_do_send(rx_pubkey, dtype, raw=None, obj=None, prefix='', rx_label='the receiver', kp=None): + # We are rendering a QR and showing it to them for sending to another Q from glob import dis cleartext = dtype.encode() + (raw or json.dumps(obj).encode()) @@ -189,22 +185,27 @@ async def kt_do_send(rx_pubkey, dtype, raw=None, obj=None): dis.progress_bar_show(0.25) # all new EC key - my_keypair = ngu.secp256k1.keypair() + my_keypair = kp or ngu.secp256k1.keypair() dis.progress_bar_show(0.75) - payload = encode_payload(my_keypair, rx_pubkey, noid_key, cleartext) + payload = prefix + encode_payload(my_keypair, rx_pubkey, noid_key, cleartext, + for_psbt=bool(prefix)) dis.progress_bar_show(1) - msg = "Share this password with the receiver, via some different channel:"\ - "\n\n %s = %s\n\n" % (txt, ' '.join(txt)) + msg = "Share this password with %s, via some different channel:"\ + "\n\n %s = %s\n\n" % (rx_label, txt, ' '.join(txt)) msg += "ENTER to view QR" - await tk_show_payload('S', payload, 'Teleport Password', msg, cta='Show to Receiver') + print(msg) + await tk_show_payload('S' if not prefix else 'E', + payload, 'Teleport Password', msg, cta='Show to Receiver') - from flow import goto_top_menu - goto_top_menu() + if not prefix: + # not PSBT case ... reset menus, we are deep! + from flow import goto_top_menu + goto_top_menu() def pick_noid_key(): # pick an 40 bit password, shown as base32 @@ -218,6 +219,8 @@ def pick_noid_key(): async def kt_decode_rx(is_psbt, payload): # we are getting data back from a sender, decode it. + prompt = 'Teleport Password (text)' + if not is_psbt: rx_key = settings.get("ktrx") if not rx_key: @@ -230,28 +233,32 @@ async def kt_decode_rx(is_psbt, payload): body = payload[33:] pair = ngu.secp256k1.keypair(a2b_hex(rx_key)) + ses_key, body = decode_step1(pair, his_pubkey, body) else: - randint = payload[0:4] - body = payload[4:] + # Multisig PSBT: will need to iterate over a few wallets and each N-1 possible senders + if not MultisigWallet.exists(): + await ux_show_story("Incoming PSBT requires multisig wallet(s) to be already setup, but you have none.") + return - # may need to iterate over a few wallets? - # TODO: multisig + ses_key, body, sender_xfp = MultisigWallet.kt_search_rxkey(payload) - ses_key, body = decode_step1(pair, his_pubkey, body) + if sender_xfp is not None: + prompt = 'Teleport Password from [%s]' % xfp2str(sender_xfp) if not ses_key: # when ECDH fails, it's truncation or wrong RX key (due to sender using old rx key, # or the numeric code the sender entered was wrong, etc) - await ux_show_story("QR code was damaged, numeric password was wrong, " - "or it was sent to a different user. " - "Sender must start again.", title="Teleport Fail") + await ux_show_story("QR code was damaged, "+ + ("numeric password was wrong, " if not is_psbt else "")+ + "or it was sent to a different user. " + "Sender must start again.", title="Teleport Fail") return from glob import dis while 1: # ask for noid key pw = await ux_input_text('', confirm_exit=False, hex_only=False, max_len=8, - prompt='Teleport Password (text)', min_len=8, b39_complete=False, scan_ok=False, + prompt=prompt, min_len=8, b39_complete=False, scan_ok=False, placeholder='********', funct_keys=None, force_xy=None) if not pw: return @@ -286,7 +293,6 @@ async def kt_accept_values(dtype, raw): - `n` - one or many notes export (JSON array) - `v` - seed vault export (JSON: one secret key but includes includes name, source of key) - `p` - binary PSBT to be signed - - `P` - a more-signed binary PSBT being returned back to sender ''' from chains import current_chain, slip32_deserialize from flow import has_se_secrets, goto_top_menu @@ -308,9 +314,9 @@ async def kt_accept_values(dtype, raw): assert ch.name == chains.current_chain.name, 'wrong chain' enc = stash.SecretStash.encode(node=node) - elif dtype in 'pP': - # raw PSBT -- bigger - from auth import sign_transaction + elif dtype == 'p': + # raw PSBT -- much bigger more complex + from auth import sign_transaction, TXN_INPUT_OFFSET psbt_len = len(raw) # copy into PSRAM @@ -373,7 +379,7 @@ def noid_stretch(session_key, noid_key): # TODO: measure timing of this on real Q return ngu.hash.pbkdf2_sha512(session_key, noid_key, 5000)[0:32] -def encode_payload(my_keypair, his_pubkey, noid_key, body): +def encode_payload(my_keypair, his_pubkey, noid_key, body, for_psbt=False): # do all the encryption for sender assert len(his_pubkey) == 33 assert len(noid_key) == 5 @@ -389,6 +395,10 @@ def encode_payload(my_keypair, his_pubkey, noid_key, body): b2 = aes256ctr.new(session_key).cipher(b1) b2 += ngu.hash.sha256s(b1)[-2:] + if for_psbt: + # no need to share pubkey for PSBT files + return b2 + return my_keypair.pubkey().to_bytes() + b2 def decode_step1(my_keypair, his_pubkey, body): @@ -541,5 +551,165 @@ class SecretPickerMenu(MenuSystem): return await kt_do_send(self.rx_pubkey, 's', raw=raw) + + +async def kt_send_psbt(psbt, psbt_len=None, post_signing=False): + # We just finishing adding our signature to an incomplete PSBT. + # User wants to send to one or more other senders for them to complete signing. + + # who remains to sign? look at inputs + ms = psbt.active_multisig + all_xfps = [x for x,p in ms.get_xfp_paths()] + need = [x for x in psbt.multisig_xfps_needed() if x in all_xfps] + + # maybe it's not really a PSBT where we know the other signers? might be + # a weird coinjoin we dont fully understand + if not need: + if not post_signing: + await ux_show_story("No more signers?") + return + + num_to_complete = ms.M - (ms.N - len(need)) + + if post_signing: + # They just approved and signed a MS txn perhaps via USB or QR or any source + # - offer to save? + # - offer them to teleport it (we only come this far if possible) + + if num_to_complete <= 0: + # fully signed. we can probably finalize it too + # - they have no copy of the result, if it came in via teleport + # - if from USB, we'd be uploading back, SD would be saved, etc + return + + ch = await ux_show_story("%d more signatures are still required. Press (T) to pick another co-signer to get it next using QR codes." % num_to_complete, title="Teleport PSBT?", escape='t') + if ch != 't': return + + + if not psbt_len: + # we need it serialized, might have only saved into Base64 or something + from io import BytesIO + + with BytesIO() as fd: + psbt.serialize(fd) # need prog bar? + + psbt_len = fd.tell() + bin_psbt = fd.getvalue() + else: + # move out of PSRAM + from auth import TXN_OUTPUT_OFFSET + + with SFFile(TXN_OUTPUT_OFFSET, psbt_len) as fd: + bin_psbt = fd.read(psbt_len) + + my_xfp = settings.get('xfp') + + # if my_xfp in need: + # - we haven't signed yet? let's do that now .. except we've lost some of the + # data we need such as filename to save back into. + # - so just keep going instead... maybe they want to be last signer? + + # Make them pick a single next signer. It's not helpful to do multiple at once + # here, since we need signatures to be added serially so that last + # signer can do finalization. We don't have a general purpose combiner. + + async def done_cb(m, idx, item): + m.next_xfp = need[idx] + the_ux.pop() + + ci = [] + next_signer = None + for idx, x in enumerate(all_xfps): + txt = '[%s] Co-signer #%d' % (xfp2str(x), idx+1) + f = done_cb + if x == my_xfp: + txt += ': YOU' + f = None # we dont offer to QR to ourselves + elif x not in need: + txt += ': DONE' + f = None + mi = MenuItem(txt, f=f) + if x not in need: + # show check if we've got sig + mi.is_chosen = lambda: True + elif next_signer == None: + next_signer = idx + ci.append(mi) + + m = MenuSystem(ci) + m.next_xfp = None + m.goto_idx(next_signer) # position cursor on next candidate + the_ux.push(m) + await m.interact() + + if m.next_xfp: + assert m.next_xfp != my_xfp + ri, rx_pubkey, kp = ms.kt_make_rxkey(m.next_xfp) + await kt_do_send(rx_pubkey, 'p', raw=bin_psbt, prefix=ri, kp=kp, + rx_label='[%s] co-signer' % xfp2str(m.next_xfp)) + +async def kt_send_file_psbt(*a): + # Menu item: choose a PSBT file from SD card, and send to co-signers. + # Heavy code re-use here. Need to find the multisig wallet associated w/ file, + # so we need to parse it and we must be one of the co-signers. + + from actions import is_psbt, file_picker + from auth import sign_psbt_file, TXN_INPUT_OFFSET + from version import MAX_TXN_LEN + from ux import import_export_prompt + from psbt import psbtObject + from glob import dis + + # choose any PSBT from SD + picked = await import_export_prompt("PSBT", is_import=True, no_nfc=True, no_qr=True) + if picked == KEY_CANCEL: + return + choices = await file_picker(suffix='psbt', min_size=50, ux=False, + max_size=MAX_TXN_LEN, taster=is_psbt, **picked) + if not choices: + # error msg already shown + return + + if len(choices) == 1: + # single - skip the menu + label,path,fn = choices[0] + input_psbt = path + '/' + fn + else: + # multiples - make them pick one + input_psbt = await file_picker(choices=choices) + if not input_psbt: + return + + # read into PSRAM from wherever + psbt_len = await sign_psbt_file(input_psbt, just_read=True, **picked) + + dis.fullscreen("Validating...") + try: + dis.progress_sofar(1, 4) + with SFFile(TXN_INPUT_OFFSET, length=psbt_len, message='Reading...') as fd: + # NOTE: psbtObject captures the file descriptor and uses it later + psbt = psbtObject.read_psbt(fd) + + await psbt.validate() # might do UX: accept multisig import + + dis.progress_sofar(2, 4) + psbt.consider_inputs() + dis.progress_sofar(3, 4) + + psbt.consider_keys() + + except Exception as exc: + # not going to do full reporting here, use our other code for that! + await ux_show_story("Cannot validate PSBT?\n\n"+str(exc), "PSBT Load Failed") + return + finally: + dis.progress_bar_show(1) + + if not psbt.active_multisig: + await ux_show_story("We are not part of this multisig wallet.", "Cannot Teleport PSBT") + return + + await kt_send_psbt(psbt, psbt_len=psbt_len) + # EOF diff --git a/shared/vdisk.py b/shared/vdisk.py index 483281d0..47b31e2b 100644 --- a/shared/vdisk.py +++ b/shared/vdisk.py @@ -114,7 +114,7 @@ class VirtDisk: def new_psbt(self, filename): # New incoming PSBT has been detected, start to sign it. from auth import sign_psbt_file - uasyncio.create_task(sign_psbt_file(filename, force_vdisk=True, abort=True)) + uasyncio.create_task(sign_psbt_file(filename, force_vdisk=True, ux_abort=True)) def new_firmware(self, filename, sz): # potential new firmware file detected diff --git a/testing/conftest.py b/testing/conftest.py index 46f191f0..c401b34b 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -1291,10 +1291,13 @@ def try_sign_microsd(open_microsd, cap_story, pick_menu_item, goto_home, assert False, 'timed out' txid = None - lines = story.split('\n\n') - if 'Final TXID:' in story: - txid = lines[-2].split("\n")[-1] - result_fname = lines[-3] + lines = story.split('\n') + if 'Final TXID:' in lines: + txid = lines[-1] + result_fname = lines[-4] + elif 'Key Teleport' in lines[-1]: + # ignore "Press (T) to use Key Teleport to send PSBT to other co-signers" footer + result_fname = lines[2] else: result_fname = lines[-2] diff --git a/testing/devtest/set_encoded_secret.py b/testing/devtest/set_encoded_secret.py index f10080ad..f9b56c76 100644 --- a/testing/devtest/set_encoded_secret.py +++ b/testing/devtest/set_encoded_secret.py @@ -16,6 +16,6 @@ raw = main.ENCODED_SECRET pa.change(new_secret=raw) pa.new_main_secret(raw) -print("New key in effect: %s" % settings.get('xpub', 'MISSING')) -print("Fingerprint: %s" % xfp2str(settings.get('xfp', 0))) +print("New key in effect (encoded): %s" % settings.get('xpub', 'MISSING')) +print(".. w/ XFP= %s" % xfp2str(settings.get('xfp', 0))) diff --git a/testing/devtest/set_seed.py b/testing/devtest/set_seed.py index 6b2be19b..27c253d1 100644 --- a/testing/devtest/set_seed.py +++ b/testing/devtest/set_seed.py @@ -23,6 +23,7 @@ PassphraseMenu.pp_sofar = '' SettingsObject.master_sv_data = {} SettingsObject.master_nvram_key = None set_seed_value(main.WORDS) +stash.SensitiveValues.clear_cache() settings.set('chain', 'XTN') settings.set('words', len(main.WORDS)) @@ -30,7 +31,7 @@ settings.set('terms_ok', True) settings.set('idle_to', 0) print("New key in effect: %s" % settings.get('xpub', 'MISSING')) -print("Fingerprint: %s" % xfp2str(settings.get('xfp', 0))) +print(".. w/ fingerprint: %s" % xfp2str(settings.get('xfp', 0))) # impt: if going from xprv => seed words, main menu needs updating goto_top_menu() diff --git a/testing/test_bbqr.py b/testing/test_bbqr.py index 9694ff0b..b95d473d 100644 --- a/testing/test_bbqr.py +++ b/testing/test_bbqr.py @@ -152,20 +152,30 @@ def render_bbqr(need_keypress, cap_screen_qr, sim_exec, readback_bbqr_ll): @pytest.fixture -def try_sign_bbqr(cap_story, scan_a_qr, press_select, press_cancel, need_keypress, goto_home, - readback_bbqr): - def doit(psbt, type_code="P", approve=True, nfc_push_tx=False, **kws): +def split_scan_bbqr(scan_a_qr, goto_home, need_keypress): + + # take big data and send it via series of BBQr thru emulated scanner + def doit(raw_data, type_code, **kws): goto_home() need_keypress(KEY_QR) # def split_qrs(raw, type_code, encoding=None, # min_split=1, max_split=1295, min_version=5, max_version=40 - actual_vers, parts = split_qrs(psbt, type_code, **kws) + actual_vers, parts = split_qrs(raw_data, type_code, **kws) random.shuffle(parts) for p in parts: scan_a_qr(p) - time.sleep(4.0 / len(parts)) # just so we can watch + time.sleep(2.0 / len(parts)) # just so we can watch + + return doit + +@pytest.fixture +def try_sign_bbqr(cap_story, scan_a_qr, press_select, press_cancel, need_keypress, + readback_bbqr, split_scan_bbqr): + def doit(psbt, type_code="P", approve=True, nfc_push_tx=False, **kws): + + split_scan_bbqr(psbt, type_code, **kws) for r in range(20): title, story = cap_story() diff --git a/testing/test_multisig.py b/testing/test_multisig.py index 0fb0e08b..78d05b26 100644 --- a/testing/test_multisig.py +++ b/testing/test_multisig.py @@ -1271,12 +1271,14 @@ def make_myself_wallet(dev, set_bip39_pw, offer_ms_import, press_select, clear_m def select_wallet(idx): # select to specific pw + print(f"--- switch to another leg of MS: {idx} ---") xfp = set_bip39_pw(passwords[idx]) if do_import: offer_ms_import(config) time.sleep(.1) press_select() assert xfp == keys[idx][0] + return xfp return (keys, select_wallet) diff --git a/testing/test_teleport.py b/testing/test_teleport.py index 3fa4881b..3d3706a1 100644 --- a/testing/test_teleport.py +++ b/testing/test_teleport.py @@ -4,14 +4,15 @@ # # - you'll need v1.0.1 of bbqr library for this to work # -import pytest, time, re -from helpers import prandom +import pytest, time, re, pdb +from helpers import prandom, xfp2str from binascii import a2b_hex from bbqr import split_qrs, join_qrs from charcodes import KEY_QR, KEY_NFC from base64 import b32encode +from constants import * -from test_bbqr import readback_bbqr +from test_bbqr import readback_bbqr, split_scan_bbqr from test_notes import need_some_notes, need_some_passwords from test_ephemeral import SEEDVAULT_TEST_DATA @@ -35,9 +36,9 @@ def rx_start(grab_payload, goto_home, pick_menu_item): @pytest.fixture() -def grab_payload(press_select, need_keypress, press_cancel, nfc_read_url, cap_story, nfc_block4rf, cap_screen_qr): +def grab_payload(press_select, need_keypress, press_cancel, nfc_read_url, cap_story, nfc_block4rf, cap_screen_qr, readback_bbqr): - # start the Rx process, capturing numeric code + # started the process; capture pw/code and QR contents, verify NFC works def doit(tt_code, allow_reuse=True, reset_pubkey=False): expect_in_title = 'Receive' if tt_code == 'R' else 'Teleport Password' @@ -83,9 +84,15 @@ def grab_payload(press_select, need_keypress, press_cancel, nfc_read_url, cap_s need_keypress(KEY_QR) - qr_data = cap_screen_qr().decode() + if tt_code != 'E': + qr_data = cap_screen_qr().decode() + filetype, qr_raw = join_qrs([qr_data]) + else: + # will be multi-frame BBQr in case of PSBT + filetype, qr_raw = readback_bbqr() + # this is un-split BBQR which didn't really happen, but useful + qr_data = f'B$2{filetype}0100' + b32encode(qr_raw).decode('ascii') - filetype, qr_raw = join_qrs([qr_data]) assert filetype == tt_code if nfc_raw: assert nfc_raw == qr_raw @@ -93,23 +100,29 @@ def grab_payload(press_select, need_keypress, press_cancel, nfc_read_url, cap_s press_cancel() press_cancel() - return code, qr_data + return code, qr_data, qr_raw return doit @pytest.fixture() -def rx_complete(press_select, need_keypress, press_cancel, cap_story, scan_a_qr, enter_complex, cap_screen, goto_home): +def rx_complete(press_select, need_keypress, press_cancel, cap_story, scan_a_qr, enter_complex, cap_screen, goto_home, split_scan_bbqr): # finish the teleport by doing QR and getting data def doit(data, pw, expect_fail=False): goto_home() need_keypress(KEY_QR) time.sleep(.250) # required - scan_a_qr(data) + + if isinstance(data, tuple): + bbrq_type, raw = data + split_scan_bbqr(raw, bbrq_type, max_version=27) + else: + assert len(data) < 2000 # USB protocol limit + scan_a_qr(data) time.sleep(.250) # required if expect_fail: return scr = cap_screen() - assert 'Teleport Password (text)' in scr + assert 'Teleport Password' in scr enter_complex(pw) time.sleep(.150) # required @@ -175,7 +188,7 @@ def test_tx_quick_note(rx_start, tx_start, cap_menu, enter_complex, pick_menu_it enter_complex(msg) time.sleep(.150) # required - pw, data = grab_payload('S') + pw, data, _ = grab_payload('S') assert len(pw) == 8 # now, send that back @@ -219,7 +232,7 @@ def test_tx_master_send(rx_start, tx_start, cap_menu, enter_complex, pick_menu_i press_select() time.sleep(.150) # required? - pw, data = grab_payload('S') + pw, data, _ = grab_payload('S') # now, send that back rx_complete(data, pw) @@ -252,7 +265,7 @@ def test_tx_notes(qty, rx_start, tx_start, cap_menu, enter_complex, pick_menu_it pick_menu_item('Export All Notes & Passwords') time.sleep(.150) # required? - pw, data = grab_payload('S') + pw, data, _ = grab_payload('S') # now, send that back rx_complete(data, pw) @@ -299,7 +312,7 @@ def test_tx_seedvault(data, rx_start, tx_start, cap_menu, enter_complex, pick_me pick_menu_item(mi) time.sleep(.150) # required? - pw, data = grab_payload('S') + pw, data, _ = grab_payload('S') settings_set("seeds", []) @@ -323,7 +336,7 @@ def test_tx_seedvault(data, rx_start, tx_start, cap_menu, enter_complex, pick_me pick_menu_item('Restore Master') press_select() -def test_rx_truncated(rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select): +def test_rx_truncated(rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, rx_complete, cap_story, press_cancel, press_select): # Truncate the RX Code code, rx_pubkey = rx_start() pw = tx_start(rx_pubkey[:-3], code, expect_fail='Truncated KT RX') @@ -342,7 +355,7 @@ def test_tx_wrong_pub(rx_start, tx_start, cap_menu, enter_complex, pick_menu_ite press_select() time.sleep(.150) # required? - pw, data = grab_payload('S') + pw, data, _ = grab_payload('S') # now, send that back rx_complete(data, pw, expect_fail=True) @@ -355,4 +368,83 @@ def test_tx_wrong_pub(rx_start, tx_start, cap_menu, enter_complex, pick_menu_ite press_cancel() +@pytest.mark.unfinalized +@pytest.mark.parametrize('num_ins', [ 15 ]) +@pytest.mark.parametrize('M', [2]) # [ 2, 4, 1]) +@pytest.mark.parametrize('segwit', [True]) +@pytest.mark.parametrize('incl_xpubs', [ False, True ]) +def test_teleport_ms_sign(M, use_regtest, make_myself_wallet, segwit, num_ins, dev, clear_ms, + fake_ms_txn, try_sign, incl_xpubs, bitcoind, cap_story, need_keypress, + cap_menu, pick_menu_item, grab_payload, rx_complete, press_select): + + # IMPORTANT: wont work if you start simulator with --ms flag. Use no args + + all_out_styles = list(unmap_addr_fmt.keys()) + num_outs = len(all_out_styles) + + clear_ms() + use_regtest() + + # create a wallet, with 3 bip39 pw's + keys, select_wallet = make_myself_wallet(M, do_import=(not incl_xpubs)) + N = len(keys) + assert M<=N + + psbt = fake_ms_txn(num_ins, num_outs, M, keys, segwit_in=segwit, incl_xpubs=incl_xpubs, + outstyles=all_out_styles, change_outputs=list(range(1,num_outs))) + + open(f'debug/myself-before.psbt', 'wb').write(psbt) + + cur_wallet = 0 + my_xfp = select_wallet(cur_wallet) + + _, updated = try_sign(psbt, accept_ms_import=incl_xpubs) + open(f'debug/myself-after-1.psbt', 'wb').write(updated) + assert updated != psbt + + title, body = cap_story() + assert title == 'Teleport PSBT?' + assert 'Press (T)' in body + + need_keypress('t') + + time.sleep(.1) + + # expect: a menu of xfp to pick from + m = cap_menu() + assert len(m) == N + assert 'YOU' in [ln for ln in m if xfp2str(my_xfp) in ln][0] + + idx = 1 + next_xfp = keys[1][0] + assert next_xfp != my_xfp + + # choose one that isn't me + nm, = [mi for mi in m if xfp2str(next_xfp) in mi] + pick_menu_item(nm) + + pw, data, qr_raw = grab_payload('E') + assert len(pw) == 8 + + open(f'debug/next_qr.txt', 'wt').write(f'{xfp2str(next_xfp)}\n\n{pw}\n\n{data}') + + # switch personalities, and try to read that QR + new_xfp = select_wallet(idx) + assert new_xfp == next_xfp + + # import and sign + rx_complete(('E', qr_raw), pw) + + title, body = cap_story() + assert title == 'OK TO SEND?' + + press_select() + time.sleep(.25) + + title, body = cap_story() + assert title == 'Teleport PSBT?' + assert '2 more signatures' in body + + pdb.set_trace() + # EOF