507 lines
16 KiB
Python
507 lines
16 KiB
Python
# (c) Copyright 2025 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
|
#
|
|
# Verify hobble works: a restricted access mode, without export/view of seed and more.
|
|
#
|
|
# - spending policy menu and txn checks should not be in this file, instead expand
|
|
# test_ccc.py or create test_sssp.py
|
|
#
|
|
# Additional tests, elsewhere:
|
|
#
|
|
# - test_teleport.py::test_teleport_ms_sign
|
|
# - verifies: MS psbt KT should still work in hobbled mode
|
|
#
|
|
# - test_teleport.py::test_hobble_limited
|
|
# - verifies: scan a KT and have it rejected if not PSBT type: so R and E types
|
|
#
|
|
# - login_settings_tests.py for login/bypass UX
|
|
#
|
|
#
|
|
import pytest, time, os, pdb
|
|
from bip32 import BIP32Node
|
|
from constants import simulator_fixed_words, simulator_fixed_xprv
|
|
from test_ephemeral import SEEDVAULT_TEST_DATA, WORDLISTS
|
|
from test_ephemeral import confirm_tmp_seed, verify_ephemeral_secret_ui
|
|
from test_ux import word_menu_entry
|
|
from charcodes import KEY_QR
|
|
|
|
@pytest.fixture
|
|
def set_hobble(sim_exec, settings_set, settings_remove, goto_home):
|
|
def doit(mode, enabled={}): # okeys, words, notes
|
|
assert mode in { True, False, 2 }
|
|
assert not (set(enabled) - {'okeys', 'words', 'notes'}), enabled
|
|
|
|
if mode:
|
|
v = dict(en=True, pol={})
|
|
for w in enabled:
|
|
v[w] = True
|
|
settings_set('sssp', v)
|
|
print(f'sssp = {v!r}')
|
|
else:
|
|
settings_remove('sssp')
|
|
|
|
sim_exec(f'''
|
|
from pincodes import pa; from actions import goto_top_menu
|
|
pa.hobbled_mode = {mode!r}
|
|
goto_top_menu()
|
|
''')
|
|
goto_home() # required, not sure why
|
|
|
|
yield doit
|
|
|
|
doit(False)
|
|
|
|
@pytest.mark.parametrize('en_okeys', [ True, False] )
|
|
@pytest.mark.parametrize('en_notes', [ True, False] )
|
|
@pytest.mark.parametrize('en_nfc', [ True, False] )
|
|
@pytest.mark.parametrize('en_multisig', [ True, False] )
|
|
def test_menu_contents(set_hobble, pick_menu_item, cap_menu, en_okeys, en_notes, settings_set,
|
|
need_some_notes, is_q1, en_nfc, sim_exec, en_multisig, vdisk_disabled):
|
|
|
|
# just enough to pass/fail the menu predicates!
|
|
settings_set('seedvault', True)
|
|
|
|
#settings_set('nfc', en_nfc)
|
|
sim_exec(f'import glob; glob.NFC = {(True if en_nfc else None)!r};')
|
|
|
|
settings_set('multisig', en_multisig)
|
|
|
|
if is_q1:
|
|
need_some_notes()
|
|
|
|
# main menu basics
|
|
expect = {'Ready To Sign', 'Address Explorer', 'Advanced/Tools' }
|
|
|
|
if is_q1:
|
|
expect.add('Scan Any QR Code')
|
|
else:
|
|
expect.add('Secure Logout')
|
|
|
|
en = set()
|
|
if en_okeys:
|
|
en.add('okeys')
|
|
expect.add('Seed Vault')
|
|
expect.add('Passphrase')
|
|
|
|
if en_notes:
|
|
en.add('notes')
|
|
if is_q1:
|
|
expect.add('Secure Notes & Passwords')
|
|
|
|
# enables hobble and goes to top menu
|
|
set_hobble(True, en)
|
|
|
|
m = cap_menu()
|
|
assert set(m) == expect, 'Main menu wrong'
|
|
|
|
# advanced menu
|
|
pick_menu_item("Advanced/Tools")
|
|
|
|
adv_expect = { 'File Management',
|
|
'Export Wallet',
|
|
'View Identity',
|
|
'Paper Wallets',
|
|
'Destroy Seed',
|
|
f'Show {"Firmware" if is_q1 else "FW"} Version' }
|
|
|
|
if is_q1 and en_multisig:
|
|
adv_expect.add('Teleport Multisig PSBT')
|
|
|
|
if en_nfc:
|
|
adv_expect.add('NFC Tools')
|
|
|
|
if en_okeys:
|
|
adv_expect.add('Temporary Seed')
|
|
adv_expect.add('WIF Store')
|
|
|
|
m = cap_menu()
|
|
assert set(m) == adv_expect, "Adv menu wrong"
|
|
|
|
# file management
|
|
pick_menu_item("File Management")
|
|
|
|
fm_expect = { 'Sign Text File',
|
|
'Batch Sign PSBT',
|
|
'List Files',
|
|
'Export Wallet',
|
|
'Verify Sig File',
|
|
'Format SD Card' }
|
|
|
|
if not vdisk_disabled:
|
|
fm_expect.add('Format RAM Disk')
|
|
|
|
if en_nfc:
|
|
fm_expect.add('NFC File Share')
|
|
if is_q1:
|
|
fm_expect.add('BBQr File Share')
|
|
fm_expect.add('QR File Share')
|
|
|
|
m = cap_menu()
|
|
assert set(m) == fm_expect, "File Mgmt menu wrong"
|
|
|
|
|
|
def test_h_notes(only_q1, set_hobble, pick_menu_item, cap_menu, settings_set,
|
|
need_some_notes, sim_exec, settings_remove, press_cancel):
|
|
'''
|
|
* load a secure note/pw; check readonly once hobbled
|
|
* cannot export
|
|
* cannot edit
|
|
* can view / use for kbd emulation
|
|
* check notes not offered if none defined
|
|
* check readonly features on notes when note pre-defined before entering hobbled mode
|
|
'''
|
|
need_some_notes()
|
|
set_hobble(True, {'notes'})
|
|
|
|
pick_menu_item('Secure Notes & Passwords')
|
|
|
|
m = cap_menu()
|
|
assert m == [ '1: Title Here' ]
|
|
pick_menu_item(m[0])
|
|
|
|
m = cap_menu()
|
|
assert m == [ '"Title Here"', 'View Note', 'Sign Note Text' ]
|
|
|
|
set_hobble(True, {'notes', 'okeys'})
|
|
|
|
pick_menu_item('Secure Notes & Passwords')
|
|
pick_menu_item('1: Title Here')
|
|
assert cap_menu() == ['"Title Here"', 'View Note', 'Sign Note Text', 'Apply as BIP-39 Passphrase']
|
|
|
|
# clear notes, should not be offered
|
|
settings_remove('notes')
|
|
settings_remove('secnap')
|
|
set_hobble(True, {'notes'})
|
|
|
|
m = cap_menu()
|
|
assert 'Secure Notes & Passwords' not in m
|
|
|
|
def test_kt_limits(only_q1, set_hobble, pick_menu_item, cap_menu, settings_set, need_some_notes,
|
|
sim_exec, settings_remove):
|
|
'''
|
|
- key teleport
|
|
* check KT only offered if MS wallet setup
|
|
'''
|
|
settings_remove('multisig')
|
|
set_hobble(True)
|
|
pick_menu_item("Advanced/Tools")
|
|
|
|
assert 'Teleport Multisig PSBT' not in cap_menu()
|
|
# converse already tested in test_menu_contents
|
|
|
|
@pytest.mark.parametrize('sv_empty', [ True, False] )
|
|
def test_h_seedvault(sv_empty, set_hobble, pick_menu_item, cap_menu, settings_set, sim_exec,
|
|
settings_remove, restore_main_seed, settings_get, press_cancel, press_select,
|
|
cap_story):
|
|
'''
|
|
- seed vault can be accessed, when enabled
|
|
- temp seeds are read-only: no create, no rename, etc.
|
|
- SV menu item is offered iff SV enabled; can be empty or not.
|
|
'''
|
|
|
|
settings_set('seedvault', True)
|
|
if sv_empty:
|
|
settings_set('seeds', [])
|
|
else:
|
|
settings_set('seeds', [])
|
|
xfp, enc = SEEDVAULT_TEST_DATA[0][0:2]
|
|
settings_set("seeds", [(xfp, '80'+enc, f"Menu Label", "meta-source")])
|
|
|
|
set_hobble(True, {'okeys'})
|
|
|
|
assert cap_menu()[0] == 'Ready To Sign', 'restart simulator now'
|
|
pick_menu_item('Seed Vault')
|
|
|
|
m = cap_menu()
|
|
if sv_empty:
|
|
assert m == ['(none saved yet)']
|
|
else:
|
|
assert m == [' 1: Menu Label']
|
|
|
|
pick_menu_item(m[0])
|
|
m = cap_menu()
|
|
assert m == ['Menu Label', 'Use This Seed']
|
|
|
|
pick_menu_item(m[0])
|
|
title, story = cap_story()
|
|
assert 'Origin:\nmeta-source' in story
|
|
press_cancel()
|
|
|
|
pick_menu_item('Use This Seed')
|
|
title, story = cap_story()
|
|
assert 'temporary master key is in effect' in story
|
|
press_select()
|
|
|
|
# arrive back in main menu, w/ tmp seed in effect
|
|
# - but we are still hobbled.
|
|
# - XFP shown
|
|
# - Restore master should be offered.
|
|
m = cap_menu()
|
|
assert m[0] == f'[{xfp}]'
|
|
assert m[-1] == 'Restore Master'
|
|
assert "Settings" not in m # in hobbled mode
|
|
|
|
pick_menu_item("Advanced/Tools")
|
|
m = cap_menu()
|
|
# we are in tmp seed session, restore master if you want to destroy seed
|
|
assert 'Destroy Seed' not in m
|
|
press_cancel()
|
|
|
|
pick_menu_item("Restore Master")
|
|
title, story = cap_story()
|
|
assert 'main wallet' in story
|
|
press_select()
|
|
|
|
|
|
# clear keys from sv, should not be offered in menu, even if okeys set.
|
|
settings_remove('seedvault')
|
|
set_hobble(True, {'okeys'})
|
|
|
|
m = cap_menu()
|
|
assert 'Seed Vault' not in m
|
|
|
|
@pytest.mark.parametrize('mode', [ 'words', 'qr', 'xprv', 'tapsigner', 'coldcard', 'b39pass'])
|
|
def test_h_tempseeds(mode, set_hobble, pick_menu_item, cap_menu, settings_set, is_q1,
|
|
press_select, cap_story, word_menu_entry, confirm_tmp_seed, enter_complex,
|
|
verify_ephemeral_secret_ui, scan_a_qr, tapsigner_encrypted_backup,
|
|
need_keypress, enter_hex, open_microsd, microsd_path, go_to_passphrase):
|
|
'''
|
|
- can import and use a key for signing
|
|
- NOT offered chance to save into seedvault
|
|
'''
|
|
if not is_q1 and mode == 'qr': return
|
|
|
|
settings_set('seedvault', True)
|
|
settings_set('seeds', [])
|
|
|
|
set_hobble(True, {'okeys'})
|
|
|
|
if mode != "b39pass":
|
|
pick_menu_item("Advanced/Tools")
|
|
pick_menu_item('Temporary Seed')
|
|
|
|
m = cap_menu()
|
|
assert 'Generate Words' not in m
|
|
assert all((i.startswith("Import ") or i.endswith(' Backup') or i == 'Restore Seed XOR')
|
|
for i in m), m
|
|
|
|
words, expect_xfp = WORDLISTS[12]
|
|
|
|
if mode == 'words':
|
|
# just quick tests here, not in-depth
|
|
# - from test_ephemeral_seed_import_words()
|
|
pick_menu_item("Import Words")
|
|
pick_menu_item(f"12 Words")
|
|
time.sleep(0.1)
|
|
word_menu_entry(words.split())
|
|
|
|
elif mode == 'qr':
|
|
pick_menu_item("Import from QR Scan")
|
|
val = ' '.join(words.split()).upper()
|
|
scan_a_qr(val)
|
|
time.sleep(0.2)
|
|
|
|
elif mode == 'tapsigner':
|
|
# like test_ephemeral_seed_import_tapsigner()
|
|
fname, backup_key_hex, node = tapsigner_encrypted_backup('sd', testnet=True)
|
|
expect_xfp = node.fingerprint().hex().upper()
|
|
pick_menu_item("Tapsigner Backup")
|
|
time.sleep(0.1)
|
|
need_keypress('1')
|
|
time.sleep(0.1)
|
|
pick_menu_item(fname)
|
|
|
|
time.sleep(0.1)
|
|
_, story = cap_story()
|
|
assert "your TAPSIGNER" in story
|
|
|
|
press_select() # yes I have backup key
|
|
enter_hex(backup_key_hex)
|
|
|
|
elif mode == 'coldcard':
|
|
# like test_temporary_from_backup()
|
|
# - but skip making new bk file
|
|
fn = 'data/tip-index-famous-embark-tobacco-rice-attitude-interest-mask-random-amazing-initial.7z'
|
|
pw = fn[5:-3].split('-')
|
|
|
|
contents = open(fn, 'rb').read()
|
|
with open_microsd('example.7z', 'wb') as fd:
|
|
fd.write(contents)
|
|
|
|
pick_menu_item("Coldcard Backup")
|
|
time.sleep(0.1)
|
|
need_keypress('1')
|
|
time.sleep(0.1)
|
|
pick_menu_item('example.7z')
|
|
|
|
word_menu_entry(pw, has_checksum=False)
|
|
|
|
time.sleep(.1)
|
|
press_select() # confirm loading of the backup
|
|
time.sleep(.1)
|
|
title, story = cap_story()
|
|
assert title == 'FAILED'
|
|
assert 'successfully tested recovery' in story
|
|
|
|
press_select()
|
|
return
|
|
|
|
elif mode == 'xprv':
|
|
fname = "ek.txt"
|
|
node = BIP32Node.from_master_secret(os.urandom(32), netcode="XTN")
|
|
expect_xfp = node.fingerprint().hex().upper()
|
|
ek = node.hwif(as_private=True)
|
|
with open(microsd_path(fname), "w") as f:
|
|
f.write(ek)
|
|
|
|
pick_menu_item("Import XPRV")
|
|
time.sleep(0.1)
|
|
_, story = cap_story()
|
|
if "Press (1) to import extended private key" in story:
|
|
need_keypress("1")
|
|
|
|
time.sleep(0.1)
|
|
pick_menu_item(fname)
|
|
|
|
elif mode == "b39pass":
|
|
from mnemonic import Mnemonic
|
|
go_to_passphrase()
|
|
passphrase = "sssp"
|
|
seed = Mnemonic.to_seed(simulator_fixed_words, passphrase=passphrase)
|
|
node = BIP32Node.from_master_secret(seed, netcode="XTN")
|
|
expect_xfp = node.fingerprint().hex().upper()
|
|
|
|
enter_complex(passphrase, apply=True)
|
|
time.sleep(.2)
|
|
title, story = cap_story()
|
|
assert title[1:-1] == expect_xfp
|
|
assert "Above is the master key fingerprint of the new wallet" in story
|
|
press_select()
|
|
time.sleep(.1)
|
|
title, story = cap_story()
|
|
assert "store temporary seed into Seed Vault" not in story
|
|
time.sleep(.1)
|
|
|
|
else:
|
|
raise pytest.fail(mode)
|
|
|
|
if mode != "b39pass":
|
|
# different UX for passphrase - verified above
|
|
confirm_tmp_seed(seedvault=False, check_sv_not_offered=True)
|
|
|
|
# do not verify presence of Seed Vault menu item - irrelevant
|
|
verify_ephemeral_secret_ui(expected_xfp=expect_xfp, mnemonic=None, seed_vault=None)
|
|
|
|
time.sleep(.1)
|
|
m = cap_menu()
|
|
if mode in ["words", "qr"]:
|
|
# verify okeys is respected in tmp seed
|
|
assert "Passphrase" in m
|
|
|
|
pick_menu_item("Restore Master")
|
|
press_select()
|
|
|
|
|
|
@pytest.mark.parametrize('en_okeys', [ True, False])
|
|
def test_h_usbcmds(en_okeys, set_hobble, dev):
|
|
# test various usb commands are blocked during hobble
|
|
|
|
from ckcc_protocol.protocol import CCProtoError
|
|
|
|
set_hobble(True, {'okeys'} if en_okeys else {})
|
|
|
|
block_list = [ 'back', 'enrl', 'bagi', 'hsms', 'user', 'nwur', 'rmur' ]
|
|
|
|
if not en_okeys:
|
|
block_list.insert(0, 'pass')
|
|
|
|
for cmd in block_list:
|
|
with pytest.raises(CCProtoError) as ee:
|
|
got = dev.send_recv(cmd)
|
|
assert 'Spending policy in effect' in str(ee)
|
|
|
|
|
|
@pytest.mark.parametrize('en_okeys', [ True, False])
|
|
def test_h_qrscan(en_okeys, set_hobble, scan_a_qr, need_keypress, press_cancel, cap_screen, only_q1,
|
|
cap_story, press_select, pick_menu_item):
|
|
# verify whitelist of QR types is correct when in hobbled mode
|
|
# - no private key material, unless "okeys" is set
|
|
# - no teleport starting, except multisig co-signing
|
|
#
|
|
set_hobble(True, {'okeys'} if en_okeys else {})
|
|
|
|
words, _ = WORDLISTS[12]
|
|
keys = [
|
|
' '.join(w[0:4] for w in words.split()),
|
|
simulator_fixed_xprv]
|
|
|
|
for ss in keys:
|
|
need_keypress(KEY_QR)
|
|
scan_a_qr(ss)
|
|
time.sleep(1)
|
|
|
|
title, story = cap_story()
|
|
if en_okeys:
|
|
assert 'New temporary master key is in effect' in story
|
|
press_select()
|
|
|
|
pick_menu_item("Restore Master")
|
|
press_select()
|
|
else:
|
|
assert 'Blocked when Spending Policy is in force.' in story
|
|
press_select()
|
|
|
|
for dt in 'RSE':
|
|
need_keypress(KEY_QR)
|
|
tt = f'B$H{dt}0100'+('A'*80)
|
|
scan_a_qr(tt)
|
|
time.sleep(1)
|
|
|
|
if dt == 'E':
|
|
title, story = cap_story()
|
|
assert 'Incoming PSBT requires multisig wallet' in story
|
|
press_cancel()
|
|
else:
|
|
scr = cap_screen() # stays in scanning mode
|
|
assert 'KT Blocked' in scr
|
|
|
|
def test_h_seedxor(set_hobble, need_keypress, press_cancel, cap_screen,
|
|
cap_story, press_select, pick_menu_item, settings_set):
|
|
# can start import via seed XOR, but cannot include master seed phrase
|
|
# as part of it.
|
|
|
|
settings_set('seedvault', True)
|
|
settings_set('seeds', [])
|
|
set_hobble(True, {'okeys'})
|
|
|
|
pick_menu_item("Advanced/Tools")
|
|
pick_menu_item('Temporary Seed')
|
|
pick_menu_item('Restore Seed XOR')
|
|
|
|
title, story = cap_story()
|
|
assert 'A/B/C' in story
|
|
press_select() # select 24 words
|
|
time.sleep(0.1)
|
|
|
|
title, story = cap_story()
|
|
assert 'Since you have' in story
|
|
assert "include this Coldcard's seed" not in story # WEAK: fragile if UX changes
|
|
|
|
press_cancel()
|
|
|
|
|
|
def test_empty_notes_bug(set_hobble, goto_notes, cap_menu, pick_menu_item, is_q1):
|
|
if not is_q1:
|
|
raise pytest.skip("No notes on Mk4")
|
|
|
|
goto_notes() # enable notes - but do not add any
|
|
set_hobble(True, {"notes"})
|
|
|
|
pick_menu_item("Secure Notes & Passwords")
|
|
# here yikes would follow
|
|
time.sleep(.1)
|
|
m = cap_menu()
|
|
assert len(m) == 1
|
|
assert m[0] == "(none saved yet)"
|
|
|
|
# EOF
|