# (c) Copyright 2024 by Coinkite Inc. This file is covered by license found in COPYING-CC. # # Key Teleport (a Q-only feature) # # - you'll need v1.0.1 of bbqr library for this to work # import pytest, time, re, pdb from helpers import prandom, xfp2str, str2xfp, str_to_path from bbqr import join_qrs from charcodes import KEY_QR, KEY_NFC from base64 import b32encode from constants import * from test_ephemeral import SEEDVAULT_TEST_DATA from test_backup import make_big_notes from test_hobble import set_hobble # All tests in this file are exclusively meant for Q # @pytest.fixture(autouse=True) def THIS_FILE_requires_q1(is_q1, is_headless): if not is_q1 or is_headless: raise pytest.skip('Q1 only (not headless)') @pytest.fixture def rx_start(grab_payload, goto_home, pick_menu_item): def doit(**kws): goto_home() pick_menu_item('Advanced/Tools') pick_menu_item('Key Teleport (start)') return grab_payload('R', **kws)[0:2] return doit @pytest.fixture def main_do_over(unit_test, settings_get, settings_set): # reset all contents, including master secret ... except ktrx # - so you can test backup-restore onto blank unit def doit(): kp = settings_get('ktrx') unit_test('devtest/clear_seed.py') settings_set('ktrx', kp) return doit @pytest.fixture def grab_payload(press_select, need_keypress, press_cancel, nfc_read_url, cap_story, nfc_block4rf, cap_screen_qr, readback_bbqr): # 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' title, story = cap_story() if 'Reuse' in title and tt_code == 'R': assert allow_reuse assert 'press (R)' in story if reset_pubkey: # make a new key anyway need_keypress('r') else: press_select() time.sleep(.1) title, story = cap_story() assert 'Teleport' in title assert expect_in_title in title assert 'QR' in story code, = re.findall(' (\w{8}) = ', story) assert len(code) == 8 nfc_raw = None if KEY_NFC in story: # test NFC case -- when enabled need_keypress(KEY_NFC) # expect NFC animation nfc_block4rf() url = nfc_read_url().replace('%24', '$') assert url.startswith('https://keyteleport.com/#') nfc_data = url.rsplit('#')[1] assert nfc_data.startswith(f'B$2{tt_code}0100') filetype, nfc_raw = join_qrs([nfc_data]) # update your bbqr install if fails assert filetype == tt_code need_keypress(KEY_QR) # will be multi-frame BBQr in case of PSBT, other cases usually one frame 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').rstrip('=') assert filetype == tt_code if nfc_raw: assert nfc_raw == qr_raw press_cancel() press_cancel() 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, split_scan_bbqr): # finish the teleport by doing QR and getting data def doit(data, pw, expect_fail=False, expect_xfp=None): goto_home() need_keypress(KEY_QR) time.sleep(.250) # required if isinstance(data, tuple): bbrq_type, raw = data split_scan_bbqr(raw, bbrq_type, max_version=26) else: assert len(data) < 2000 # USB protocol limit scan_a_qr(data) if expect_fail: time.sleep(.200) return for _ in range(10): scr = cap_screen() if 'Teleport Password' in scr: break time.sleep(.2) else: raise RuntimeError("Teleport Password not in screen") if expect_xfp: assert xfp2str(expect_xfp) in scr enter_complex(pw) time.sleep(.150) # required return doit @pytest.fixture def tx_start(press_select, need_keypress, press_cancel, goto_home, pick_menu_item, cap_story, scan_a_qr, enter_complex, cap_screen): # start the Tx process, capturing password and leaving you are picker menu def doit(rx_qr, rx_code, expect_fail=None, expect_wrong_code=False): goto_home() need_keypress(KEY_QR) time.sleep(.250) # required scan_a_qr(rx_qr) for _ in range(10): scr = cap_screen() if expect_fail and expect_fail in scr: return elif 'Teleport Password (number)' in scr: break time.sleep(.2) else: assert False, "Teleport Password not in screen" enter_complex(rx_code) time.sleep(.150) # required title, story = cap_story() if expect_wrong_code: # not a sure thing if 'Incorrect Teleport Pass' in story: return True assert title == 'Key Teleport: Send' assert 'Choose what to share' in story assert 'WARNING' in story press_select() return doit def test_rx_reuse(rx_start): # check rx pubkey re-use logic code, enc_pubkey = rx_start(allow_reuse=True, reset_pubkey=True) assert code.isdigit() code2, enc_pubkey2 = rx_start(allow_reuse=True, reset_pubkey=False) assert code2 == code assert enc_pubkey2 == enc_pubkey code3, pk3 = rx_start(allow_reuse=True, reset_pubkey=True) assert code3 != code def test_tx_quick_note(rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select): # Send a quick-note code, rx_pubkey = rx_start() pw = tx_start(rx_pubkey, code) m = cap_menu() assert 'Master Seed Words' in m assert 'Quick Text Message' in m # other contents require other features to be enabled msg = b32encode(prandom(10)).decode('ascii') pick_menu_item('Quick Text Message') enter_complex(msg) time.sleep(.150) # required pw, data, _ = grab_payload('S') assert len(pw) == 8 # now, send that back rx_complete(data, pw) # should arrive in notes menu m = cap_menu() assert m[-1] == 'Import' mi = [i for i in m if i.endswith(': Quick Note')] assert mi pick_menu_item(mi[-1]) # most recent test # view note m = cap_menu() assert m[0] == '"Quick Note"' pick_menu_item(m[0]) _, body = cap_story() assert body == msg # cleanup press_cancel() pick_menu_item('Delete') press_select() @pytest.mark.parametrize('testcase', [ 'weak', 'strong']) def test_tx_master_send(testcase, rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select, main_do_over): # Send master secret, but doesn't really work since same as what we have code, rx_pubkey = rx_start() pw = tx_start(rx_pubkey, code) # other contents require other features to be enabled pick_menu_item('Master Seed Words') title, body = cap_story() assert 'Are you SURE' in title assert 'MASTER secret' in body assert '24 words' in body press_select() time.sleep(.150) # required? pw, data, _ = grab_payload('S') if testcase == 'strong': # virginized main_do_over() # now, send that back rx_complete(data, pw) title, body = cap_story() if testcase == 'weak': assert title == 'FAILED' assert 'Cannot use master seed as temp' in body assert 'successfully tested' in body elif testcase == 'strong': # real product would reboot; simulator just quietly goes back to top menu? assert title == '' m = cap_menu() assert m[0] == 'Ready To Sign' press_cancel() @pytest.mark.parametrize('qty', [1, 3]) def test_tx_notes(qty, rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select, need_some_passwords, need_some_notes, settings_set, settings_get): # Send notes. settings_set('notes', []) need_some_notes() notes = need_some_passwords() assert len(notes) >= qty code, rx_pubkey = rx_start() pw = tx_start(rx_pubkey, code) # other contents require other features to be enabled if qty == 1: pick_menu_item('Single Note / Password') pick_menu_item('1: ' + notes[0]["title"]) else: pick_menu_item('Export All Notes & Passwords') time.sleep(.150) # required? pw, data, _ = grab_payload('S') # now, send that back rx_complete(data, pw) # arrive in settings menu, on last item (last imported) m = cap_menu() assert m[-1] == 'Import' after = settings_get('notes', None) assert notes[0:qty] == after[-qty:] settings_set('notes', []) press_cancel() @pytest.mark.parametrize('data', SEEDVAULT_TEST_DATA[0:2]) def test_tx_seedvault(data, rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select, settings_set, settings_get, goto_home, need_keypress): # Send seeds from vault xfp, entropy, mnemonic = data # build stashed encoded secrets entropy_bytes = bytes.fromhex(entropy) if mnemonic: vlen = len(entropy_bytes) assert vlen in [16, 24, 32] marker = 0x80 | ((vlen // 8) - 2) stored_secret = bytes([marker]) + entropy_bytes else: stored_secret = entropy_bytes pkg = [xfp, stored_secret.hex(), f"[{xfp}]", "from testing"] settings_set("seedvault", True) settings_set("seeds", [pkg]) # get ready to send code, rx_pubkey = rx_start(reset_pubkey=True) pw = tx_start(rx_pubkey, code) pick_menu_item('From Seed Vault') mi, = (i for i in cap_menu() if i.endswith(f"[{xfp}]")) pick_menu_item(mi) time.sleep(.150) # required? pw, data, _ = grab_payload('S') settings_set("seeds", []) rx_complete(data, pw) if settings_get("seedvault", False): time.sleep(.1) title, body = cap_story() assert 'Press (1) to store temp' in body assert 'to continue without saving' in body need_keypress('1') time.sleep(.1) title, body = cap_story() assert xfp in body assert 'Saved to Seed Vault' in body assert settings_get('seeds') == [pkg] goto_home() pick_menu_item('Restore Master') press_select() time.sleep(.1) assert settings_get('xfp', -1) == simulator_fixed_xfp def test_rx_truncated(rx_start, tx_start): # Truncate the RX Code code, rx_pubkey = rx_start() tx_start(rx_pubkey[:-3], code, expect_fail='Truncated KT RX') def test_tx_wrong_pub(rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select): # simulate wrong numeric code only -- sender doesn't know right_code, rx_pubkey = rx_start() for attempt in range(20): code = '%08d' % attempt failed = tx_start(rx_pubkey, code, expect_wrong_code=True) if failed: # 50% odds (apx, maybe?) of wrong code being detected. print(f'{code} => wasnt accepted') continue break else: raise pytest.fail('huh') # other contents require other features to be enabled pick_menu_item('Master Seed Words') time.sleep(.150) # required? press_select() time.sleep(.150) # required? pw, data, _ = grab_payload('S') # now, send that back rx_complete(data, pw, expect_fail=True) time.sleep(.1) title, body = cap_story() assert title == 'Teleport Fail' assert 'password was wrong' in body assert 'start again' in body press_cancel() @pytest.mark.unfinalized @pytest.mark.parametrize('num_ins', [ 15 ]) @pytest.mark.parametrize('M', [4]) @pytest.mark.parametrize('hobbled', [True, False]) def test_teleport_ms_sign(M, use_regtest, make_myself_wallet, num_ins, dev, clear_ms, hobbled, fake_ms_txn, try_sign, bitcoind, cap_story, need_keypress, cap_menu, pick_menu_item, grab_payload, rx_complete, press_select, ndef_parse_txn_psbt, press_nfc, nfc_read, settings_get, settings_set, txid_from_export_prompt, sim_root_dir, set_hobble, readback_bbqr, nfc_is_enabled, goto_home, restore_main_seed): # IMPORTANT: won't 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, addr_fmt="p2wsh", do_import=True) N = len(keys) assert M<=N if hobbled: # we need to import before hobbled mode is enabled for i in range(3): # 4th is simulator (ignore) select_wallet(i) restore_main_seed(preserve_settings=True) time.sleep(.1) set_hobble(True, {'okeys'}) goto_home() psbt = fake_ms_txn(num_ins, num_outs, M, keys, inp_af=AF_P2WSH, outstyles=all_out_styles, change_outputs=list(range(1,num_outs))) with open(f'{sim_root_dir}/debug/myself-before.psbt', 'wb') as f: f.write(psbt) cur_wallet = 0 my_xfp = select_wallet(cur_wallet, no_import=hobbled) _, updated = try_sign(psbt, accept_ms_import=False, exit_export_loop=False) with open(f'{sim_root_dir}/debug/myself-after-1.psbt', 'wb') as f: f.write(updated) assert updated != psbt title, body = cap_story() assert title == "PSBT Signed" assert '(T) to use Key Teleport to send PSBT to other co-signers' in body num_sigs_needed = M - 1 # we have already signed with first at this point while 1: # expect: a menu of other signers to pick from need_keypress('t') time.sleep(.1) m = cap_menu() assert len(m) == N assert 'YOU' in [ln for ln in m if xfp2str(my_xfp) in ln][0] unsigned = [ln[1:9] for ln in m if (xfp2str(my_xfp) not in ln) and ('DONE' not in ln)] assert unsigned # find another signer for idx, (xfp, *_) in enumerate(keys): if xfp2str(xfp) in unsigned: break else: assert 0, 'missing unsigned' # check XFP changes next_xfp = keys[idx][0] assert next_xfp != my_xfp last_xfp = my_xfp # pick other xfp to send to nm, = [mi for mi in m if xfp2str(next_xfp) in mi] pick_menu_item(nm) # grab the payload and pw pw, data, qr_raw = grab_payload('E') assert len(pw) == 8 nn = xfp2str(next_xfp) with open(f'{sim_root_dir}/debug/next_qr_{nn}.txt', 'wt') as f: f.write(f'{nn}\n\n{pw}\n\n{data}') time.sleep(.1) title, story = cap_story() assert title == 'Sent by Teleport' s, aux = ("", "is") if num_sigs_needed == 1 else ("s", "are") msg = "%d more signature%s %s still required." % (num_sigs_needed, s, aux) assert msg in story # switch personalities, and try to read that QR new_xfp = select_wallet(idx, no_import=hobbled) assert new_xfp == next_xfp my_xfp = next_xfp assert settings_get('xfp') == my_xfp # import and sign rx_complete(('E', qr_raw), pw, expect_xfp=last_xfp) title, body = cap_story() assert title == 'OK TO SEND?' press_select() time.sleep(.25) title, body = cap_story() if 'Finalized TX' in body: break assert "shared via USB" not in body assert "Updated PSBT is" not in story # assert not written to SD/Vdisk assert '(T) to use Key Teleport to send PSBT to other co-signers' in body num_sigs_needed -= 1 txid = txid_from_export_prompt() press_select() # exit QR if nfc_is_enabled(): # share signed txn via low-level NFC press_nfc() time.sleep(.1) contents = nfc_read() got_psbt, got_txn, _ = ndef_parse_txn_psbt(contents, txid, expect_finalized=True) else: # NFC disabled. use other means .. bbqr need_keypress(KEY_QR) tcode, contents = readback_bbqr() got_txn = (tcode == 'T') got_psbt = (tcode == 'P') assert not got_psbt assert got_txn def test_teleport_big_ms(make_myself_wallet, clear_ms, fake_ms_txn, try_sign, cap_story, need_keypress, cap_menu, pick_menu_item, grab_payload, rx_complete, press_select, ndef_parse_txn_psbt, set_master_key, goto_home, press_nfc, nfc_read, settings_get, settings_set, open_microsd, import_ms_wallet, press_cancel): # define lots of wallets and do teleport from SD disk clear_ms() M, N = 2, 15 for i in range(5): keys = import_ms_wallet(M, N, name=f'ms{i}-test', unique=(i*73), accept=True, descriptor=False, bip67=True) # just use last wallet psbt = fake_ms_txn(1, 1, M, keys) fname = 'ms-example.psbt' open_microsd(fname, 'wb').write(psbt) goto_home() pick_menu_item('Advanced/Tools') pick_menu_item('File Management') pick_menu_item('Teleport Multisig PSBT') need_keypress('1') # top slot try: pick_menu_item(fname) except KeyError: # maybe just one file that is suitable --> str8 to xfp picking pass # on Co-signer list menu m = cap_menu() assert len(m) == N myself, = [i for i in m if 'YOU' in i] pick_menu_item(myself) title, body = cap_story() assert title == 'OK TO SEND?' press_select() time.sleep(.25) # have 1 sigs now, need one more via teleport title, body = cap_story() assert '(T) to use Key Teleport to send PSBT to other co-signers' in body need_keypress('t') # pick another one randomly m = cap_menu() assert len(m) == N target = m[-1] if 'YOU' not in m[0] else m[-2] pick_menu_item(target) target_xfp = str2xfp(target[1:9]) # capture QR+pw to go there pw, data, qr_raw = grab_payload('E') tmp_ms = settings_get('multisig') # switch to that key, receive it node, = [n for x,n,_ in keys if x == target_xfp] set_master_key(node.hwif(as_private=True)) # copy over the one MS wallet this xfp was involved in settings_set('multisig', [tmp_ms[-1]]) # import and sign rx_complete(('E', qr_raw), pw, expect_xfp=simulator_fixed_xfp) title, body = cap_story() assert title == 'OK TO SEND?' press_select() time.sleep(.25) title, body = cap_story() assert 'Finalized TX' in body press_cancel() def test_teleport_file_psbt_uses_loaded_file(make_myself_wallet, clear_ms, fake_ms_txn, cap_story, need_keypress, cap_menu, pick_menu_item, grab_payload, rx_complete, set_master_key, goto_home, settings_get, settings_set, open_microsd, import_ms_wallet, press_cancel): clear_ms() M, N = 2, 4 keys = import_ms_wallet(M, N, name='ms-tp', unique=11, accept=True, descriptor=False, bip67=True) psbt = fake_ms_txn(1, 1, M, keys) fname = 'ms-tp.psbt' open_microsd(fname, 'wb').write(psbt) goto_home() pick_menu_item('Advanced/Tools') pick_menu_item('File Management') pick_menu_item('Teleport Multisig PSBT') need_keypress('1') try: pick_menu_item(fname) except KeyError: pass m = cap_menu() assert len(m) == N target = next(i for i in m if 'YOU' not in i) target_xfp = str2xfp(target[1:9]) # forward the (unsigned) file to this co-signer, instead of signing first and forwarding after pick_menu_item(target) pw, data, qr_raw = grab_payload('E') tmp_ms = settings_get('multisig') # become that co-signer; give it the one wallet it shares with us node, = [n for x, n, _ in keys if x == target_xfp] set_master_key(node.hwif(as_private=True)) settings_set('multisig', [tmp_ms[-1]]) # with the bug the payload is stale/zero bytes or whatever was in OUT_OFFSET -> PSBT load fails; rx_complete(('E', qr_raw), pw, expect_xfp=simulator_fixed_xfp) title, body = cap_story() assert title == 'OK TO SEND?' press_cancel() @pytest.mark.manual def test_teleport_real_ms(dev, fake_ms_txn): # # Do a 2-of-2 w/ USB-attached REAL Q and simulator # - build ms wallet beforehand, both devices (QR); default air-gap settings # - this makes fake txn, sents to (real) device via USB # - do your signature, press (T) to teleport to next # - observe BBQr, but press NFC and capture URL text via keyteleport.com # - get that BBQr string into clipboard, and paste into simulator # - observe working signature on sim side # # py.test test_teleport.py --dev --manual -k test_teleport_real_ms # from bip32 import BIP32Node from ckcc_protocol.protocol import CCProtocolPacker M = N = 2 #p2wsh deriv = "m/48h/1h/0h/2h" # simulator key n = BIP32Node.from_hwif(simulator_fixed_xprv).subkey_for_path(deriv) keys = [ (simulator_fixed_xfp, None, n) ] # add device xfp = dev.master_fingerprint xpk = dev.send_recv(CCProtocolPacker.get_xpub(deriv)) node = BIP32Node.from_wallet_key(xpk) keys.append((xfp, None, node)) def p2wsh_mapper(cosigner_idx): # match the default paths created by CC in airgapped MS wallet creation. return str_to_path(deriv) psbt = fake_ms_txn(3, 2, M, keys, fee=10000, outvals=None, inp_af=AF_P2WSH, outstyles=['p2pkh'], change_outputs=[], incl_xpubs=False, hack_change_out=False, input_amount=1E8, path_mapper=p2wsh_mapper) open('debug/teleport_real_ms.psbt', 'wb').write(psbt) ll, sha = dev.upload_file(psbt) dev.send_recv(CCProtocolPacker.sign_transaction(ll, sha)) print("Follow signing prompts on device, and then do teleport back " "to Simulator via NFC => website => clipboard") @pytest.mark.parametrize('testcase', [ 'weak', 'partial', 'strong']) def test_send_backup(testcase, rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select, settings_get, settings_set, restore_backup_unpacked, main_do_over, set_encoded_secret, reset_seed_words, make_big_notes): # Send complete backup file. code, rx_pubkey = rx_start() pw = tx_start(rx_pubkey, code) if testcase == 'strong': notes = make_big_notes() # other contents require other features to be enabled pick_menu_item('Full COLDCARD Backup') title, body = cap_story() assert 'Sending complete backup' in body press_select() time.sleep(.150) # required? pw, data, qr_raw = grab_payload('S') if testcase == 'partial': # be on a different master, so backup is restored into seed vault/tmp seed kp = settings_get('ktrx') set_encoded_secret(b'\x20' + prandom(32)) settings_set('ktrx', kp) if testcase == 'strong': # wipe everything; except we need the keypair main_do_over() # now, send that back rx_complete(('S', qr_raw), pw) title, body = cap_story() if testcase == 'weak': assert title == 'FAILED' assert 'Cannot use master seed as temp' in body assert 'successfully tested' in body press_cancel() elif testcase == 'partial': # should be in a tmp seed now assert title == '[0F056943]' assert 'temporary master key is in effect' in body reset_seed_words() elif testcase == 'strong': restore_backup_unpacked() assert settings_get('notes') == notes settings_set('notes', []) def test_teleport_backup_invalid_raw_secret(grab_payload, rx_complete, goto_home, pick_menu_item, cap_story, is_q1): # yikes. Must instead show a clean FAILED story. if not is_q1: raise pytest.skip("Q1 Key Teleport") from teleport_protocol import sender_step1 goto_home() pick_menu_item('Advanced/Tools') pick_menu_item('Key Teleport (start)') code, _qr_data, qr_raw = grab_payload('R') bad_backup = b'chain = "XTN"\n' cleartext = b'b' + bad_backup noid_txt, encrypted_payload, _, _ = sender_step1(code, qr_raw, cleartext) rx_complete(('S', encrypted_payload), noid_txt) time.sleep(.5) title, body = cap_story() assert title == 'FAILED' assert "Invalid backup" in body def test_hobble_limited(set_hobble, scan_a_qr, cap_menu, cap_screen, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select, settings_get, settings_set, restore_backup_unpacked, main_do_over, set_encoded_secret, reset_seed_words, make_big_notes): # verify: in hobbled mode, KT is blocked for everything except multisig cases set_hobble(True) from bbqr import split_qrs _, parts = split_qrs(b's'*33, 'R') rx_complete(parts[0], '12345678', expect_fail=True) time.sleep(.1) last = cap_screen().split('\n')[-1] assert last == 'KT Blocked' _, parts = split_qrs(b's'*33, 'S') rx_complete(parts[0], 'abcdefgh', expect_fail=True) time.sleep(.1) last = cap_screen().split('\n')[-1] assert last == 'KT Blocked' # EOF