add full backup to key-teleport

This commit is contained in:
Peter D. Gray 2025-04-05 13:33:09 -04:00
parent 5c5f8902a1
commit f23d7f09bf
No known key found for this signature in database
GPG Key ID: A2DCD558C2BE5D7C
7 changed files with 296 additions and 104 deletions

View File

@ -12,11 +12,14 @@ NFC, passive websites, and QR/BBQr codes.
## Steps
- Receiver picks an EC keypair, stores it in settings, and publishes the pubkey via a QR/NFC
- Sender gets that, picks own keypair, and does ECDH to arrive at a shared session key
- The pubkey is encrypted by a short 8-digit numeric code, which should be
sent by a different channel.
- Sender gets QR and numeric code, picks own keypair, and does ECDH to arrive at a
shared session key
- Sender picks a human-readable secret which is independent of anything else (P key)
- The secret data (perhaps a seed phrase, XPRV, secure note, etc) is AES encrypted with P key,
then encrypted + MAC added with session key
- Data packet is sent to receiver, who can reconstruct the session key via ECDH
- The secret data (perhaps a seed phrase, XPRV, secure note, full backup, etc) is
AES-256-CTR encrypted with P key, then encrypted + MAC added with session key
- Data packet is sent to receiver (via BBQr), who can reconstruct the session key via ECDH
- Prompt user for the P key to finish decoding
- Decoded secret value is saved to Seed Vault or secure notes as appropriate
- Receiver destroys EC keypair used in transfer
@ -30,7 +33,7 @@ NFC, passive websites, and QR/BBQr codes.
## Notes and Limitations
- max 4k (after encoding) of data is possible due to HTTP limitations
- all transfers are "data typed" and decode only expected on COLDCARD
- all transfers are "data typed" and decode only on COLDCARD
- Q model is required due to the use of QR codes to ultimately get data into the COLDCARD
@ -45,6 +48,7 @@ The first byte encodes what the package contents (under all the encryption).
- `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, perhaps multisig but not required.
- `b` - complete system backup file (text lines, internal format)
## QR details
@ -60,8 +64,10 @@ New type codes for BBQr are defined for the purposes of this application:
- `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.
All the data is encrypted with the exception randint. Keep in mind
this is a nonce value picked uniquely for each transfer. The
receiver's pubkey is only weakly encrypted by the 8-digit numeric
password, but is also a nonce effectively.
### PSBT Key Selection
@ -81,7 +87,7 @@ 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.
to do 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
@ -126,7 +132,7 @@ the pubkey QR on another channel. The code is randomly picked, but
only represents about 26 bits of entropy and is stretched with
a single round of SHA256 before being used as a AES-256-CTR key
to decrypt the pubkey. No checksum verifies correct
decryption, so any code is accepted, and will with near-100% odds
decryption, so any code is accepted, and will with near-50% odds,
decrypt to a valid pubkey.
When the sender is given the receiver's pubkey via QR code, it
@ -135,7 +141,7 @@ Thus a MiTM who injects their pubkey will be detected and blocked.
The "paranoid key" serves the same role in the other direction but
it is Base32 character set, so it will not look similar or be
confusing.
confusing as to its purpose.
# Web Component
@ -165,12 +171,16 @@ is optional since the QR can be shown on the Q itself, and would
pass the same data.
Since the website is running on Github, Coinkite does not have
access to IP addresses or other log details. Because the data for
access to IP addresses or other access log details. Because the data for
teleport is "after the hash" it is never sent to Github's servers
but remains in the browser only. All JS resources referenced by the
webpage will have content hashes applied to prevent interference,
and the site will be served over SSL.
Visit [keyteleport.com](https://keyteleport.com/), or an
[example small QR](https://keyteleport.com/#B$2R0100VHT2AGUUH7KUZUUSTOWOIWHJX3XM7GA2N4BHQOXDFHXLVHVA7K6ZO)
and [view source code](https://github.com/coinkite/keyteleport.com).
# UX Details
- When the receive process is started by the user, a pubkey is picked
@ -185,7 +195,7 @@ and the site will be served over SSL.
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
a daisy-chain pattern is prefered to a star pattern. The signer
who completes the Mth (of N) signature will be able to finalize
the transaction, and ideally with PushTx feature, broadcast it.
@ -197,7 +207,7 @@ 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
It is very 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.

View File

@ -263,6 +263,25 @@ def restore_from_dict_ll(vals):
return None, need_ftux
def text_bk_parser(contents):
# given a (binary encoded) text file, decode into a dict of values
# - use json rules to decode the "value" sides
vals = {}
for line in contents.decode().split('\n'):
if not line: continue
if line[0] == '#': continue
try:
k,v = line.split(' = ', 1)
#print("%s = %s" % (k, v))
vals[k] = ujson.loads(v)
except:
print("unable to decode line: %r" % line)
# but keep going!
return vals
async def restore_tmp_from_dict_ll(vals):
from glob import dis
@ -624,19 +643,7 @@ async def restore_complete_doit(fname_or_fd, words, file_cleanup=None, temporary
await needs_microsd()
return
vals = {}
for line in contents.decode().split('\n'):
if not line: continue
if line[0] == '#': continue
try:
k,v = line.split(' = ', 1)
#print("%s = %s" % (k, v))
vals[k] = ujson.loads(v)
except:
print("unable to decode line: %r" % line)
# but keep going!
vals = text_bk_parser(contents)
# this leads to reboot if it works, else errors shown, etc.
if temporary:

View File

@ -422,6 +422,7 @@ EmptyWallet = [
MenuItem('Help', f=virgin_help, predicate=not version.has_qwerty),
MenuItem('Advanced/Tools', menu=AdvancedPinnedVirginMenu, shortcut='t'),
MenuItem('Settings', menu=SettingsMenu),
ShortcutItem(KEY_QR, predicate=version.has_qr, f=scan_any_qr, arg=(True, False)),
]
# In operation, normal system, after a good PIN received.

View File

@ -179,11 +179,9 @@ async def kt_start_send(rx_data):
break
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!
'''
msg = '''You can now Key Teleport secrets! Choose what to share on next screen.\
\n
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
@ -310,6 +308,7 @@ 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
- `b` - complete system backup file (text, internal format)
'''
from flow import has_se_secrets, goto_top_menu
@ -345,6 +344,29 @@ async def kt_accept_values(dtype, raw):
sign_transaction(psbt_len, flags=STXN_FINALIZE)
return
elif dtype == 'b':
# full system backup, including master: text lines
from backups import text_bk_parser, restore_tmp_from_dict_ll, restore_from_dict
vals = text_bk_parser(raw)
assert vals # empty?
from flow import has_secrets
if has_secrets():
# restores as tmp secret and/or offers to save to SeedVault
prob = await restore_tmp_from_dict_ll(vals)
else:
# we have no secret, so... reboot if it works, else errors shown, etc.
prob = await restore_from_dict(vals)
if prob:
await ux_show_story(prob, title='FAILED')
else:
# force new rx key because this tfr worked
settings.remove_key("ktrx")
return
elif dtype in 'nv':
# all are JSON things
js = json.loads(raw)
@ -502,6 +524,8 @@ class SecretPickerMenu(MenuSystem):
msg = 'Master Seed Words' if word_based_seed() else 'Master XPRV'
m.append( MenuItem(msg, f=self.share_master_secret) )
m.append( MenuItem("Full COLDCARD Backup", f=self.share_full_backup) )
super().__init__(m)
@ -544,6 +568,28 @@ class SecretPickerMenu(MenuSystem):
await kt_do_send(self.rx_pubkey, 'n', obj=body)
async def share_full_backup(self, *a):
# context, and warn them
ch = await ux_show_story("Sending complete backup, including master secret, "
"seed vault (if any), multisig wallets, notes/passwords, and all settings! "
"The receiving "
"COLDCARD must already have the master seed wiped to be able to install "
"everything, otherwise only master secret and multisig are saved into a tmp seed. "
"OK to proceed?")
if ch != 'y': return
from backups import render_backup_contents
# renders a text file, with rather a lot of comments; strip them
bkup = render_backup_contents(bypass_tmp=True)
out = []
for ln in bkup.split('\n'):
if not ln: continue
if ln[0] == '#': continue
out.append(ln)
await kt_do_send(self.rx_pubkey, 'b', raw=b'\n'.join(ln.encode() for ln in out))
async def share_master_secret(self, _, _2, item):
# altho menu items look different we are sharing same thing:
# - up to 72 bytes from secure elements

View File

@ -2207,9 +2207,48 @@ def check_and_decrypt_backup(microsd_path):
@pytest.fixture
def restore_backup_cs(unit_test, pick_menu_item, cap_story, cap_menu,
def restore_backup_unpacked(unit_test, pick_menu_item, cap_story, cap_menu,
press_select, word_menu_entry, get_setting, is_q1,
need_keypress, scan_a_qr, cap_screen, enter_complex):
# check things are right after unpack & install; FTUX shown
def doit(avail_settings=None):
time.sleep(.3)
title, body = cap_story()
# on simulator Disable USB is always off - so FTUX all the time
assert title == 'NO-TITLE' # no Welcome!
assert "best security practices" in body
assert "USB disabled" in body
assert "NFC disabled" in body
assert "VirtDisk disabled" in body
assert "You can change these under Settings > Hardware On/Off" in body
press_select()
time.sleep(.3)
title, body = cap_story()
assert title == 'Success!'
assert 'has been successfully restored' in body
if avail_settings:
for key in avail_settings:
assert get_setting(key)
# after successful restore - user is in default mode - all OFF
# (besides USB on simulator - that is always ON)
assert not get_setting("nfc")
assert not get_setting("vidsk")
# avoid simulator reboot; restore normal state
unit_test('devtest/abort_ux.py')
return doit
@pytest.fixture
def restore_backup_cs(unit_test, pick_menu_item, cap_story, cap_menu,
press_select, word_menu_entry, get_setting, is_q1,
need_keypress, scan_a_qr, cap_screen, enter_complex, restore_backup_unpacked):
# restore backup with clear seed as first step
def doit(fn, passphrase, avail_settings=None, pass_way=None, custom_bkpw=False):
unit_test('devtest/clear_seed.py')
@ -2244,33 +2283,7 @@ def restore_backup_cs(unit_test, pick_menu_item, cap_story, cap_menu,
else:
word_menu_entry(passphrase, has_checksum=False)
time.sleep(.3)
title, body = cap_story()
# on simulator Disable USB is always off - so FTUX all the time
assert title == 'NO-TITLE' # no Welcome!
assert "best security practices" in body
assert "USB disabled" in body
assert "NFC disabled" in body
assert "VirtDisk disabled" in body
assert "You can change these under Settings > Hardware On/Off" in body
press_select()
time.sleep(.3)
title, body = cap_story()
assert title == 'Success!'
assert 'has been successfully restored' in body
if avail_settings:
for key in avail_settings:
assert get_setting(key)
# after successful restore - user is in default mode - all OFF
# (besides USB on simulator - that is always ON)
assert not get_setting("nfc")
assert not get_setting("vidsk")
# avoid simulator reboot; restore normal state
unit_test('devtest/abort_ux.py')
restore_backup_unpacked(avail_settings=avail_settings)
return doit

View File

@ -176,6 +176,31 @@ def backup_system(settings_set, settings_remove, goto_home, pick_menu_item,
return doit
@pytest.fixture
def make_big_notes(settings_set, sim_exec):
def doit(count=9):
print(">>> Making huge backup file")
# - to bypass USB msg limit, append as we go
notes = []
settings_set('notes', [])
for n in range(count):
v = { fld:('a'*30) if fld != 'misc' else 'b'*1800
for fld in ['user', 'password', 'site', 'misc'] }
v['title'] = f'Note {n+1}'
notes.append(v)
rv = sim_exec(cmd := f'settings.current["notes"].append({v!r})')
assert 'error' not in rv.lower()
rv = sim_exec('settings.changed()')
assert 'error' not in rv.lower()
assert len(notes) == count
return notes
return doit
@pytest.mark.qrcode
@pytest.mark.parametrize('multisig', [False, 'multisig'])
@ -191,7 +216,7 @@ def test_make_backup(multisig, goto_home, pick_menu_item, cap_story, need_keypre
generate_ephemeral_words, set_bip39_pw, verify_backup_file,
check_and_decrypt_backup, restore_backup_cs, clear_ms, seedvault,
restore_main_seed, import_ephemeral_xprv, backup_system,
press_cancel, sim_exec, pass_way, garbage_collector):
press_cancel, sim_exec, pass_way, garbage_collector, make_big_notes):
# Make an encrypted 7z backup, verify it, and even restore it!
clear_ms()
reset_seed_words()
@ -201,20 +226,7 @@ def test_make_backup(multisig, goto_home, pick_menu_item, cap_story, need_keypre
# test larger backup files > 10,000 bytes
if multisig == False and st == None and not reuse_pw and not save_pw and not seedvault:
# pick just one test case.
# - to bypass USB msg limit, append as we go
print(">>> Making huge backup file")
notes = []
settings_set('notes', [])
for n in range(9):
v = { fld:('a'*30) if fld != 'misc' else 'b'*1800
for fld in ['user', 'password', 'site', 'misc'] }
v['title'] = f'Note {n+1}'
notes.append(v)
rv = sim_exec(cmd := f'settings.current["notes"].append({v!r})')
print(rv)
assert 'error' not in rv.lower()
rv = sim_exec(cmd := f'settings.changed()')
assert 'error' not in rv.lower()
notes = make_big_notes()
else:
notes = None

View File

@ -5,12 +5,13 @@
# - you'll need v1.0.1 of bbqr library for this to work
#
import pytest, time, re, pdb
from helpers import prandom, xfp2str, str2xfp
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
# All tests in this file are exclusively meant for Q
#
@ -30,6 +31,16 @@ def rx_start(grab_payload, goto_home, pick_menu_item):
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):
@ -81,14 +92,10 @@ def grab_payload(press_select, need_keypress, press_cancel, nfc_read_url, cap_s
need_keypress(KEY_QR)
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').rstrip('=')
# 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
@ -163,7 +170,7 @@ def tx_start(press_select, need_keypress, press_cancel, goto_home, pick_menu_ite
assert title == 'Key Teleport: Send'
assert 'secure notes' in story
assert 'Choose what to share' in story
assert 'WARNING' in story
press_select()
@ -224,7 +231,8 @@ def test_tx_quick_note(rx_start, tx_start, cap_menu, enter_complex, pick_menu_it
press_select()
def test_tx_master_send(rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, 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)
@ -242,15 +250,27 @@ def test_tx_master_send(rx_start, tx_start, cap_menu, enter_complex, pick_menu_i
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()
assert title == 'FAILED'
assert 'Cannot use master seed as temp' in body
assert 'successfully tested' in body
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()
@ -505,7 +525,7 @@ def test_teleport_big_ms(make_myself_wallet, clear_ms,
set_master_key,
goto_home, press_nfc, nfc_read, settings_get, settings_set, open_microsd, import_ms_wallet):
# define lots of wallets, do teleport from SD disk
# define lots of wallets and do teleport from SD disk
clear_ms()
M, N = 2, 15
@ -583,26 +603,109 @@ def test_teleport_big_ms(make_myself_wallet, clear_ms,
assert title == 'Final TXID'
'''
@pytest.mark.parametrize('N', [14, 20])
@pytest.mark.parametrize('M', [2, 14])
@pytest.mark.parametrize('incl_xpubs', [ False ])
def test_teleport_sd_psbt(M, use_regtest, make_myself_wallet, segwit, 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, ndef_parse_txn_psbt,
press_nfc, nfc_read, settings_get, settings_set, open_microsd):
@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 struct import unpack
from ckcc_protocol.protocol import CCProtocolPacker, CCProtoError
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, segwit_in=False,
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)
keys = import_ms_wallet(M, N, descriptor=descriptor, bip67=bip67)
if testcase == 'strong':
notes = make_big_notes()
keys, select_wallet = make_myself_wallet(M, do_import=(not incl_xpubs))
'''
# 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', [])
# TODO
# - send single-sig PSBT
# - ms psbt send when lots of unrelated wallets on rx side
# - ms psbt from disk file
# EOF