teleport tests

This commit is contained in:
Peter D. Gray 2025-03-21 14:20:35 -04:00
parent 8a589d22b1
commit 2cc91accaa
No known key found for this signature in database
GPG Key ID: A2DCD558C2BE5D7C
6 changed files with 234 additions and 16 deletions

View File

@ -43,4 +43,13 @@ This lists the new changes that have not yet been published in a normal release.
## 1.3.2Q - 2025-03-??
- Feature: Key Teleport -- Easily and securely move seed phrases, secure notes/passwords and PSBT
between two Q using QR codes and/or NFC with helper website. See protocol spec in
[docs/key-teleport.md][https://github.com/Coldcard/firmware/blob/master/docs/key-teleport.md]
- can send master seed (words, xprv), anything held in seed vault, secure notes/passwords
(singular, or all) and PSBT involved in a multisig
- ECDH to create session key for AES-256-CTR, with another layer of AES-256-CTR using a
short password (stretched by PBKDF2-SHA512) inside
- receiver shows sender a QR and a numeric code; sender replies with a QR and 8-char
password
- Enhancement: Always choose the biggest possible display size for QR

View File

@ -85,7 +85,7 @@ We will re-use same values as last try, unless you press (R) for new values to b
msg = '''To receive teleport of sensitive data from another COLDCARD, \
share this Receiver Password with sender:
%s = %s
%s = %s
and show the QR on next screen to the sender. ENTER or %s to show here''' % (
short_code, ' '.join(short_code), KEY_QR)
@ -168,13 +168,14 @@ async def kt_start_send(rx_data):
"Incorrect Teleport Password.\n\nYou can try again or CANCEL to stop.")
if ch == 'x': return
msg = '''You can now teleport secrets. You can select from seed words, temporary keys, \
secure notes and passwords. \
msg = '''You can now teleport secrets! Select from seed words, seed vault keys, \
secure notes or passwords. \
WARNING: Receiver will have full access to all Bitcoin controlled by these keys!
'''
ch = await ux_show_story(msg, title="Key Teleport: Send")
if ch != 'y': return
# pick what to send from a series of submenus
menu = SecretPickerMenu(rx_pubkey)

View File

@ -1610,6 +1610,24 @@ def nfc_read(request, needs_nfc):
except:
return doit_usb
@pytest.fixture()
def nfc_read_url(nfc_read, press_cancel):
# gives URL from ndef
def doit():
contents = nfc_read()
press_cancel() # exit NFC animation
# expect a single record, a URL
got, = ndef.message_decoder(contents)
assert got.type == 'urn:nfc:wkt:U'
return got.uri
return doit
@pytest.fixture()
def nfc_write(request, needs_nfc, is_q1):
# WRITE data into NFC "chip"

View File

@ -7,6 +7,7 @@ from helpers import prandom
from binascii import a2b_hex
from bbqr import split_qrs, join_qrs
from charcodes import KEY_QR
from base64 import b32decode, b32encode
# All tests in this file are exclusively meant for Q
#

View File

@ -421,7 +421,7 @@ def test_ndef_roundtrip(load_shared_mod):
@pytest.mark.parametrize('chain', ['BTC', 'XTN'])
@pytest.mark.parametrize('way', ['sd', 'nfc', 'usb', 'qr'])
def test_nfc_pushtx(num_outs, chain, enable_nfc, settings_set, settings_remove,
try_sign, fake_txn, nfc_block4rf, nfc_read, press_cancel,
try_sign, fake_txn, nfc_block4rf, nfc_read_url, press_cancel,
cap_story, cap_screen, has_qwerty, way, try_sign_microsd,
try_sign_nfc, scan_a_qr, need_keypress, press_select,
goto_home, multisig, fake_ms_txn, import_ms_wallet,
@ -491,20 +491,12 @@ def test_nfc_pushtx(num_outs, chain, enable_nfc, settings_set, settings_remove,
scr = cap_screen()
assert 'TXID:' in scr
contents = nfc_read()
uri = nfc_read_url()
print(f'nfc contents = {len(contents)}')
assert uri.startswith(prefix)
assert uri.startswith(prefix + 't')
press_cancel() # exit NFC animation
# expect a single record, a URL
got, = ndef.message_decoder(contents)
assert got.type == 'urn:nfc:wkt:U'
assert got.uri.startswith(prefix)
assert got.uri.startswith(prefix + 't')
parts = urlsplit(got.uri)
parts = urlsplit(uri)
args = parse_qsl(unquote(parts.fragment))
assert args[0][0] == 't', 'txn must be first'

197
testing/test_teleport.py Normal file
View File

@ -0,0 +1,197 @@
# (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
from helpers import prandom
from binascii import a2b_hex
from bbqr import split_qrs, join_qrs
from charcodes import KEY_QR, KEY_NFC
from base64 import b32encode
from test_bbqr import readback_bbqr
# 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():
goto_home()
pick_menu_item('Advanced/Tools')
pick_menu_item('Key Teleport (start)')
return grab_payload(': Receive', 'R')
return doit
@pytest.fixture()
def grab_payload(press_select, need_keypress, press_cancel, nfc_read_url, cap_story, nfc_block4rf, cap_screen_qr):
# start the Rx process, capturing numeric code
def doit(expect_in_title, tt_code, allow_reuse=True, reset_pubkey=False):
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()
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)
qr_data = cap_screen_qr().decode()
filetype, qr_raw = join_qrs([qr_data])
assert filetype == tt_code
if nfc_raw: assert nfc_raw == qr_raw
press_cancel()
press_cancel()
return code, qr_data
return doit
@pytest.fixture()
def rx_complete(press_select, need_keypress, press_cancel, cap_story, scan_a_qr, enter_complex, cap_screen, goto_home):
# finish the teleport by doing QR and getting data
def doit(data, pw):
goto_home()
need_keypress(KEY_QR)
time.sleep(.250) # required
scan_a_qr(data)
time.sleep(.250) # required
scr = cap_screen()
assert 'Teleport Password (text)' 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):
goto_home()
need_keypress(KEY_QR)
time.sleep(.250) # required
scan_a_qr(rx_qr)
time.sleep(.250) # required
scr = cap_screen()
assert 'Teleport Password (number)' in scr
enter_complex(rx_code)
time.sleep(.150) # required
title, story = cap_story()
assert title == 'Key Teleport: Send'
assert 'secure notes' in story
assert 'WARNING' in story
press_select()
return doit
def test_rx_reuse(rx_start, settings_remove):
code, enc_pubkey = rx_start(True, True)
assert code.isdigit()
code2, enc_pubkey2 = rx_start(True, False)
assert code2 == code
assert enc_pubkey2 == enc_pubkey
code3, pk3 = rx_start(True, True)
assert code3 != code
def test_tx_quick_note(rx_start, tx_start, settings_remove, 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('Teleport Password', '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()
# EOF