firmware/testing/test_hobble.py
2026-06-24 14:07:25 -04:00

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