hobbled mode support for spending policy

This commit is contained in:
Peter D. Gray 2025-08-18 08:57:12 -04:00
parent 7d20f03639
commit 980bfd9b1c
No known key found for this signature in database
GPG Key ID: A2DCD558C2BE5D7C
13 changed files with 596 additions and 46 deletions

View File

@ -319,7 +319,7 @@ Press (6) to prove you read to the end of this message.''', title='WARNING', esc
if ch == '6': break
# do the actual picking
pin = await lll.get_new_pin(title)
pin = await lll.get_new_pin()
del lll
if pin is None: return
@ -573,8 +573,11 @@ async def clear_seed(*a):
# This is super dangerous for the customer's money.
import seed
if await any_active_duress_ux():
return await ux_aborted()
# in hobble mode, they cannot reach duress wallets and/or maybe we don't
# want to reveal them? So don't block them based on that.
if not pa.hobbled_mode:
if await any_active_duress_ux():
return await ux_aborted()
if not await ux_confirm('Wipe seed words and reset wallet. '
'All funds will be lost. '
@ -587,7 +590,7 @@ async def clear_seed(*a):
if not await ux_confirm('''Are you REALLY sure though???\n\n\
This action will certainly cause you to lose all funds associated with this wallet, \
unless you have a backup of the seed words and know how to import them into a \
new wallet.''', confirm_key='4'):
new wallet.''', 'AGAIN...', confirm_key='4'):
return await ux_aborted()
# clear all trick PINs from SE2
@ -800,26 +803,41 @@ async def start_login_sequence():
# If that didn't work, or no skip defined, force
# them to login successfully.
sp_unlock = False
try:
from trick_pins import tp
# Get a PIN and try to use it to login
# - does warnings about attempt usage counts
await block_until_login()
sp_unlock = tp.was_sp_unlock()
if sp_unlock:
# Trying to unlock spending policy: ask for main PIN next.
while 1:
await ux_show_story("Spending Policy Unlock: Please provide Main PIN next.")
pa.reset()
await block_until_login()
# we don't really know if that was the Main PIN (could easily be the bypass
# PIN again) and if it's a duress wallet, that's cool... so just be
# sure we got some secret material to work/play with
if pa.has_secrets(): break
# Do we need to do countdown delay? (real or otherwise)
# Q/Mk4 approach:
# - wiping has already occured if that was picked
# - wiping has already occured if that was selected by trick details
# - delay is variable, stored in tc_arg
from trick_pins import tp
delay = tp.was_countdown_pin()
# Maybe they do know the right PIN, but do a delay anyway, because they wanted that
# Maybe they do know the right PIN, but always do a delay anyway, because they wanted that
if not delay:
delay = settings.get('lgto', 0)
if delay:
# kill some time, with countdown, and get "the" PIN again for real login
pa.reset()
await ux_login_countdown(delay * (60 if not version.is_devmode else 1))
# keep it simple for Mk4+: just challenge again for any PIN
@ -847,14 +865,31 @@ async def start_login_sequence():
# handle upgrades/downgrade issues
try:
await version_migration()
except:
pass
except: pass
# Maybe insist on the "right" microSD being already installed?
try:
from pwsave import MicroSD2FA
MicroSD2FA.enforce_policy()
except: pass # robustness: keep going!
except: pass
# apply the hobbling for the spending policy, if appropriate
try:
from ccc import sssp_spending_policy, sssp_word_challenge
if sp_unlock and sssp_spending_policy('words'):
# challenge them also for first and last seed word! (will reboot on fail)
await sssp_word_challenge()
if sp_unlock:
# Disable spending policy going forward; user has to re-enable.
pa.hobbled_mode = False
sssp_spending_policy('en', change=False)
else:
# normal entry mode, but might have policy enabled, if so enable it now.
pa.hobbled_mode = sssp_spending_policy('en')
except: pass
# implement idle timeout now that we are logged-in
IMPT.start_task('idle', idle_logout())
@ -943,7 +978,7 @@ async def restore_main_secret(*a):
goto_top_menu()
def make_top_menu():
from flow import VirginSystem, NormalSystem, EmptyWallet, FactoryMenu
from flow import VirginSystem, NormalSystem, EmptyWallet, FactoryMenu, HobbledTopMenu
from glob import hsm_active, settings
from pincodes import pa
@ -955,6 +990,9 @@ def make_top_menu():
elif pa.is_blank():
# let them play a little before picking a PIN first time
m = MenuSystem(VirginSystem, should_cont=lambda: pa.is_blank())
elif pa.hobbled_mode:
# let them do a few things, but not all the things.
m = MenuSystem(HobbledTopMenu)
else:
assert pa.is_successful(), "nonblank but wrong pin"
@ -2013,7 +2051,7 @@ Write it down.'''
while 1:
lll.reset()
lll.subtitle = "New " + title
pin = await lll.get_new_pin(title)
pin = await lll.get_new_pin()
if pin is None:
return await ux_aborted()

View File

@ -2,6 +2,13 @@
#
# ccc.py - ColdCard Co-sign feature. Be a leg in a 2-of-3 that is signed based on a policy.
#
# Rebranding/single-signer addtions:
#
# - "CCC" will now be branded as "Spending Policy (Co-Sign)" was "ColdCard Cosigning"
# - single singer policies will be called "Spending Policy"
# - internally: CCC is the multisig stuff, vs SSSP: Single Signer Spending Policy
# - "hobbled" refers to less-than full control over Coldcard, even though you have main PIN
#
import gc, chains, version, ngu, web2fa, bip39, re
from chains import NLOCK_IS_TIME
from utils import swab32, xfp2str, truncate_address, deserialize_secret, show_single_address
@ -886,4 +893,288 @@ async def key_c_challenge(words):
m = CCCConfigMenu()
the_ux.push(m)
def sssp_spending_policy(key, default=False, change=None):
# This function can be used to check if feature(s) are enabled in
# the single-signer policy settings. Might be used while hobbled.
# keys:
# 'en' = feature enabled; hobble on next boot
# 'notes' = allow access to knows
# 'words' = add first/last seed words to challenge to unlock
# 'okeys' = allow BIP-39 and/or seed vault
v = settings.get('sssp', dict())
if key in { 'en', 'notes', 'words', 'okeys' }:
# booleans: present or removed from dict
if change is not None:
if change:
v[key] = True
else:
v.pop(key, None)
settings.put('sssp', v)
settings.save()
return (key in v) or default
raise KeyError(key)
return default
async def toggle_sssp_feature(*a):
from pincodes import pa
from actions import goto_top_menu
if pa.hobbled_mode == 2:
# allow exit from test-drive mode, directly into editing settings
pa.hobbled_mode = False
goto_top_menu()
elif settings.get('sssp'):
# normal entry into menu system, after the first time
assert not pa.hobbled_mode
else:
en = await sssp_enable()
if not en: return
m = SSSPConfigMenu()
the_ux.push(m)
async def sssp_enable():
# enabling the feature
# - collect and setup a new trick pin
# - set sssp settings w/ something non-empty but still disabled.
# - return T if they completed enabling process
from login import LoginUX
from trick_pins import tp
from pincodes import pa
# enable the feature -- not simple!
# - pick new (trick pin) that lets you back here.
# - collect a policy setup, maybe 2FA enrol too
# - lock that down
ch = await ux_show_story('''\
You can define a "spending policy" which stops you from signing \
transactions unless conditions are met.
Spending policies can restrict: magnitude (BTC out), \
velocity (blocks between txn), address whitelisting, \
and/or require confirmation by 2FA phone app.
When active, your COLDCARD \
is locked into a special mode that restricts seed access, backups, settings and other features.
First step is to define a new PIN code that is used when you want to bypass or \
disable this feature.
''',
title="Spending Policy")
if ch != 'y':
# just a tourist
return
# re-use existing PIN if there for some reason
new_pin = tp.has_sp_unlock()
if not new_pin:
# all existing PINS
have = set(tp.all_tricks())
have.add(pa.pin.decode())
while 1:
lll = LoginUX()
lll.is_setting = True
lll.subtitle = "Spending Policy" + (" Unlock" if version.has_qwerty else '')
new_pin = await lll.get_new_pin()
if new_pin is None:
return
if (new_pin not in have):
tp.define_unlock_pin(new_pin)
break
await ux_show_story("That PIN (%s) is already in use. All PIN codes must be unique."
% new_pin)
# all features disabled to to start
settings.set('sssp', dict(en=False, pol={}))
settings.save()
# continue into config menu
return True
async def sssp_word_challenge(*a):
# Ask for first/last seed word and verify. Return if correct answers given.
# Reboots on failure.
from stash import SensitiveValues
with SensitiveValues() as sv:
if sv.mode == 'words':
words = bip39.b2a_words(sv.raw).split(' ')
want_words = words[:1] + words[-1:]
assert len(want_words) == 2
else:
# they are using XPRV or something, skip test entirely
return
got_words = None
for retry in range(2):
if version.has_qwerty:
# see special rendering code for this case in ux_q1.py:ux_draw_words(num_words=2)
from ux_q1 import seed_word_entry
got_words = await seed_word_entry('First and Last Seed Words', 2, has_checksum=False)
else:
from seed import WordNestMenu
# TODO: fix bugs here on Mk4. really not working. XXX
got_words = None
async def check_challenge_cb(words):
WordNestMenu.pop_all()
got_words = words
m = WordNestMenu(num_words=2, has_checksum=False, done_cb=check_challenge_cb)
the_ux.push(m)
await m.interact()
if got_words == want_words:
# success - done
return
await ux_show_story("Sorry, those words are incorrect.")
# they failed; log them out ... they can just try login again
from actions import login_now
login_now()
# NOT-REACHED
class SSSPCheckedMenuItem(MenuItem):
# Show a checkmark if **top level** security setting is defined and not the default
# - only works inside SSSPPolicyMenu?
# - similar to menu.py:ToggleMenuItem
def __init__(self, label, polkey, story, **kws):
super().__init__(label, **kws)
self.polkey = polkey
self.story = story
def is_chosen(self):
# should we show a check in menu? check the current SSSP settings
return sssp_spending_policy(self.polkey)
async def activate(self, menu, idx):
# do simple toggle on request
was = sssp_spending_policy(self.polkey)
msg = self.story + "\n\n%s?" % ('Disable' if was else 'Enable')
ch = await ux_show_story(msg)
if ch == 'x': return
sssp_spending_policy(self.polkey, change=(not was))
class SSSPConfigMenu(MenuSystem):
def __init__(self):
items = self.construct()
super().__init__(items)
def update_contents(self):
tmp = self.construct()
self.replace_items(tmp)
def construct(self):
from multisig import MultisigWallet, make_ms_wallet_menu
my_xfp = CCCFeature.get_xfp()
items = [
# xxxxxxxxxxxxxxxx
MenuItem('Spending Policy'), # just a title?
MenuItem('Set Policy...'), #, menu=CCCPolicyMenu.be_a_submenu),
SSSPCheckedMenuItem('Word Check', 'notes', 'Allow (read-only) access to secure notes and passwords? Otherwise, they are inaccessible.'),
SSSPCheckedMenuItem('Allow Notes', 'words', 'To change Spending Policy, addition to special PIN, you must provide the first and last seed words.'),
SSSPCheckedMenuItem('Related Keys', 'okeys', 'Allow access to BIP-39 passphrase wallets based on master seed, or Seed Vault (if any). Spending Policy applies too all.'),
#MenuItem('Test Word Challenge', f=sssp_word_challenge), # XXX test only?
]
if CCCFeature.last_fail_reason:
# xxxxxxxxxxxxxxxx
items.insert(1, MenuItem('Last Violation', f=self.debug_last_fail))
items.append(MenuItem('Remove Policy', f=self.remove_sssp))
items.append(MenuItem('Test Drive', f=self.test_drive))
items.append(MenuItem('ACTIVATE', f=self.activate_feature))
return items
async def activate_feature(self, *a):
# Policy is being set in stone now; confirm and switch to hobble mode, etc.
from trick_pins import tp
bypass_pin = tp.has_sp_unlock()
if not bypass_pin:
msg = "You have no Spending Policy bypass PIN defined, so changes to this COLDCARD cannot be made past this point. Only option will be to destroy seed and reload everything."
else:
msg = "To return to normal unlimited spending mode, you will need to enter the special pin (%s), then the Main PIN" % bypass_pin
if sssp_spending_policy('words'):
msg += ', followed by the first and last seed words'
msg += '.'
if not await ux_confirm(msg, 'CONTINUE?'):
return
# set it for next login
sssp_spending_policy('en', change=True)
# make it real ... could reboot here instead, but no need.
from pincodes import pa
from actions import goto_top_menu
pa.hobbled_mode = True
goto_top_menu()
async def test_drive(self, *a):
# allow test drive of feature
if not await ux_confirm("See what COLDCARD operation will look like with Spending Policy enabled.", 'CONTINUE?'):
return
from pincodes import pa
from actions import goto_top_menu
pa.hobbled_mode = 2 # Truthy value to indicate they can escape easily
goto_top_menu()
async def debug_last_fail(self, *a):
# debug for customers: why did we reject that last txn?
pol = CCCFeature.get_policy()
bh = pol.get('block_h', None)
msg = ''
if bh:
msg += "CCC height:\n\n%s\n\n" % bh
msg += 'The most recent policy check failed because of:\n\n%s\n\nPress (4) to clear.' \
% CCCFeature.last_fail_reason
ch = await ux_show_story(msg, escape='4')
if ch == '4':
CCCFeature.last_fail_reason = ''
self.update_contents()
async def remove_sssp(self, *a):
# disable and remove feature
if not await ux_confirm('Bypass PIN will be removed, and all spending policy settings forgotten.'):
return
settings.remove_key('sssp')
settings.save()
from trick_pins import tp
tp.delete_sp_unlock_pins()
the_ux.pop()
# EOF

View File

@ -19,7 +19,7 @@ from countdowns import countdown_chooser
from paper import make_paper_wallet
from trick_pins import TrickPinMenu
from tapsigner import import_tapsigner_backup_file
from ccc import toggle_ccc_feature
from ccc import toggle_ccc_feature, sssp_spending_policy, toggle_sssp_feature
# useful shortcut keys
from charcodes import KEY_QR, KEY_NFC
@ -100,6 +100,15 @@ def hsm_available():
# contains hsm feature + can it be used (needs se2 secret and no tmp active)
return version.supports_hsm and has_real_secret()
def qr_and_ms():
# has QR scanner, and at least one MS wallet
if not version.has_qr: return False
return bool(settings.get('multisig', False))
def is_hobble_testdrive():
from pincodes import pa
return (pa.hobbled_mode == 2)
async def goto_home(*a):
goto_top_menu()
@ -376,9 +385,9 @@ AdvancedNormalMenu = [
story=("Enable HSM? Enables all user management commands, and other HSM-only USB commands. "
"By default these commands are disabled."),
predicate=hsm_available),
NonDefaultMenuItem('Coldcard Co-Signing', 'ccc', f=toggle_ccc_feature, predicate=is_not_tmp),
MenuItem('User Management', menu=make_users_menu,
predicate=hsm_available),
NonDefaultMenuItem('Spending Policy', 'sssp', f=toggle_sssp_feature, predicate=has_real_secret, shortcut='p'),
NonDefaultMenuItem('Spending Policy: Co-Signing', 'ccc', f=toggle_ccc_feature, predicate=is_not_tmp), # XXX mk4 width
MenuItem('User Management', menu=make_users_menu, predicate=hsm_available),
MenuItem('NFC Tools', predicate=nfc_enabled, menu=NFCToolsMenu, shortcut=KEY_NFC),
MenuItem("Danger Zone", menu=DangerZoneMenu, shortcut='z'),
]
@ -460,3 +469,71 @@ FactoryMenu = [
MenuItem("Debug Functions", menu=DebugFunctionsMenu, shortcut='f'),
MenuItem("Perform Selftest", f=start_selftest, shortcut='s'),
]
# Special menus for hobbled mode where we have a (single signer) spending policy in effect.
# - no access to secrets, backups, firmware up/downgrades.
# - secure notes, but readonly; can be disabled completely.
# - key teleport, but only for PSBT & multisig purposes.
# - can only be enabled after we have secrets, so no need for has_secrets tests here
#
# Slightly limited file menu when hobbled.
# - no backup/restore
HobbledFileMgmtMenu = [
# xxxxxxxxxxxxxxxx
MenuItem('Sign Text File', f=sign_message_on_sd),
MenuItem('Batch Sign PSBT', f=batch_sign),
MenuItem('List Files', f=list_files),
MenuItem('Export Wallet', predicate=has_secrets, menu=WalletExportMenu), #dup elsewhere
MenuItem('Verify Sig File', f=verify_sig_file),
MenuItem('NFC File Share', predicate=nfc_enabled, f=nfc_share_file, shortcut=KEY_NFC),
MenuItem('BBQr File Share', predicate=version.has_qr, f=qr_share_file, arg=True),
MenuItem('QR File Share', predicate=version.has_qr, f=qr_share_file, shortcut=KEY_QR),
MenuItem('Format SD Card', f=wipe_sd_card),
MenuItem('Format RAM Disk', predicate=vdisk_enabled, f=wipe_vdisk),
]
# NFC tools when hobbled: not much different.
HobbledNFCToolsMenu = [
MenuItem('Sign PSBT', f=nfc_sign_psbt),
MenuItem('Show Address', f=nfc_show_address),
MenuItem('Sign Message', f=nfc_sign_msg),
MenuItem('Verify Sig File', f=nfc_sign_verify),
MenuItem('Verify Address', f=nfc_address_verify),
MenuItem('File Share', f=nfc_share_file),
MenuItem('Push Transaction', f=nfc_pushtx_file,
predicate=lambda: settings.get("ptxurl", False)),
]
# Very limited advanced menu when hobbled.
HobbledAdvancedMenu = [
# xxxxxxxxxxxxxxxx
MenuItem("File Management", menu=HobbledFileMgmtMenu),
MenuItem('Export Wallet', predicate=has_secrets, menu=WalletExportMenu, shortcut='x'), # also inside FileMgmt
MenuItem('Teleport Multisig PSBT', predicate=qr_and_ms, f=kt_send_file_psbt),
MenuItem("View Identity", f=view_ident),
MenuItem('Paper Wallets', f=make_paper_wallet),
MenuItem('NFC Tools', predicate=nfc_enabled, menu=HobbledNFCToolsMenu, shortcut=KEY_NFC),
MenuItem("Destroy Seed", f=clear_seed),
]
# Main menu when a spending policy (hobbled) is in effect.
HobbledTopMenu = [
# xxxxxxxxxxxxxxxx
MenuItem('Ready To Sign', f=ready2sign, shortcut='r'),
MenuItem('Passphrase', menu=start_b39_pw,
predicate=lambda: word_based_seed and sssp_spending_policy('okeys'), shortcut='p'),
MenuItem('Scan Any QR Code', predicate=version.has_qr,
shortcut=KEY_QR, f=scan_any_qr, arg=(False, True)),
MenuItem("Address Explorer", menu=address_explore, shortcut='x'),
MenuItem('Secure Notes & Passwords', menu=make_notes_menu, shortcut='n',
predicate=lambda: settings.get("secnap", False) and sssp_spending_policy('notes')),
MenuItem('Type Passwords', f=password_entry, shortcut='t',
predicate=lambda: settings.get("emu", False)),
MenuItem('Seed Vault', menu=make_seed_vault_menu, shortcut='v',
predicate=lambda: settings.master_get('seedvault') and sssp_spending_policy('okeys')),
MenuItem('Advanced/Tools', menu=HobbledAdvancedMenu, shortcut='t'),
MenuItem('Secure Logout', f=logout_now, predicate=not version.has_battery),
MenuItem('EXIT TEST DRIVE', f=toggle_sssp_feature, predicate=is_hobble_testdrive),
ShortcutItem(KEY_NFC, predicate=nfc_enabled, menu=HobbledNFCToolsMenu),
]

View File

@ -270,7 +270,7 @@ suffix break point is correct.\n\n'''
return await self.interact()
async def get_new_pin(self, title, story=None):
async def get_new_pin(self, title=None, story=None):
# Do UX flow to get new (or change) PIN. Always does the double-entry thing
self.is_setting = True

View File

@ -119,7 +119,7 @@ class ShortcutItem(MenuItem):
super().__init__('SHORTCUT', shortcut=key, **kws)
class NonDefaultMenuItem(MenuItem):
# Show a checkmark if setting is defined and not the default ... so know know it's set
# Show a checkmark if setting is defined and not the default
def __init__(self, label, nvkey, prelogin=False, default_value=None, **kws):
super().__init__(label, **kws)
self.nvkey = nvkey

View File

@ -21,6 +21,14 @@ from utils import problem_file_line, url_unquote, wipe_if_deltamode
ONE_LINE = CHARS_W-2
async def make_notes_menu(*a):
if pa.hobbled_mode:
# Read only version of menu system
# - used when spending policy in effect
# - must have some notes already, or unreachable
assert NoteContent.count()
rv = NotesMenu(NotesMenu.construct_readonly())
rv.readonly = True
return rv
if not settings.get('secnap', False):
# Explain feature, and then enable if interested. Drop them into menu.
@ -105,6 +113,8 @@ async def get_a_password(old_value, min_len=0, max_len=128):
class NotesMenu(MenuSystem):
readonly = False
@classmethod
def construct(cls):
# Dynamic menu with user-defined names of notes shown
@ -121,7 +131,8 @@ class NotesMenu(MenuSystem):
rv = []
for note in NoteContent.get_all():
rv.append(MenuItem('%d: %s' % (note.idx+1, note.title), menu=note.make_menu))
rv.append(MenuItem('%d: %s' % (note.idx+1, note.title),
menu=lambda *_: note.make_menu()))
rv.extend(news)
@ -134,6 +145,18 @@ class NotesMenu(MenuSystem):
return rv
@classmethod
def construct_readonly(cls):
# When only allowed to view, no export/add new/delete.
wipe_if_deltamode()
rv = []
for note in NoteContent.get_all():
rv.append(MenuItem('%d: %s' % (note.idx+1, note.title),
menu=lambda *_: note.make_menu(readonly=True)))
return rv
@classmethod
async def export_all(cls, *a):
await start_export(NoteContent.get_all())
@ -344,25 +367,32 @@ class PasswordContent(NoteContentBase):
flds = ['title', 'user', 'password', 'site', 'misc' ]
type_label = 'password'
async def make_menu(self, *a):
async def make_menu(self, readonly=False):
rv = [MenuItem('"%s"' % self.title, f=self.view)]
if self.user:
rv.append(MenuItem('%s' % self.user, f=self.view))
if self.site:
rv.append(MenuItem('%s' % self.site, f=self.view))
#if self.misc: rv.append(MenuItem('↳ (notes)', f=self.view))
return rv + [
rv += [
MenuItem('View Password', f=self.view_pw),
MenuItem('Send Password', f=self.send_pw, predicate=lambda: settings.get('du', True)),
MenuItem('Export', f=self.export),
MenuItem('Edit Metadata', f=self.edit),
MenuItem('Delete', f=self.delete),
MenuItem('Change Password', f=self.change_pw),
]
if not readonly:
rv += [
MenuItem('Export', f=self.export),
MenuItem('Edit Metadata', f=self.edit),
MenuItem('Delete', f=self.delete),
MenuItem('Change Password', f=self.change_pw),
]
rv += [
self.sign_misc_menu_item(),
ShortcutItem(KEY_QR, f=self.view_qr_menu, arg=self.type_label),
ShortcutItem(KEY_NFC, f=self.share_nfc, arg=self.type_label),
]
return rv
async def view(self, *a):
pl = len(self.password)
m = ''
@ -476,18 +506,24 @@ class NoteContent(NoteContentBase):
flds = ['title', 'misc']
type_label = 'note'
async def make_menu(self, *a):
async def make_menu(self, readonly=False):
# Details and actions for this Note
return [
rv = [
MenuItem('"%s"' % self.title, f=self.view),
MenuItem('View Note', f=self.view),
MenuItem('Edit', f=self.edit),
MenuItem('Delete', f=self.delete),
MenuItem('Export', f=self.export),
]
if not readonly:
rv += [
MenuItem('Edit', f=self.edit),
MenuItem('Delete', f=self.delete),
MenuItem('Export', f=self.export),
]
rv += [
self.sign_misc_menu_item(),
ShortcutItem(KEY_QR, f=self.view_qr_menu, arg="misc"),
ShortcutItem(KEY_NFC, f=self.share_nfc, arg='misc'),
]
return rv
async def view(self, *a):
ch = await ux_show_story(self.misc, title=self.title, escape=KEY_QR,

View File

@ -67,6 +67,7 @@ from utils import call_later_ms
# msas = multisig address show (do not censor multisig addresses)
# ccc = (complex) If present, CCC feature is enabled and key details stored here.
# ktrx = (privkey) Key teleport Rx has been started, this will be our keypair
# sssp = (complex) If present, spending-policy (single signer) feature are defined
# Stored w/ key=00 for access before login
# _skip_pin = hard code a PIN value (dangerous, only for debug)

View File

@ -3,7 +3,6 @@
# pincodes.py - manage PIN code (which map to wallet seeds)
#
import ustruct, ckcc, version, chains, stash
# from ubinascii import hexlify as b2a_hex
from callgate import enter_dfu
from bip39 import wordlist_en
@ -127,6 +126,9 @@ class PinAttempt:
self.private_state = 0 # opaque data, but preserve
self.cached_main_pin = bytearray(32)
# If set, a spending policy is in effect, and so even tho we know the master
# seed, we are not going to let them see it, nor sign things we dont like, etc.
self.hobbled_mode = False
assert MAX_PIN_LEN == 32 # update FMT otherwise
assert ustruct.calcsize(PIN_ATTEMPT_FMT_V1) == PIN_ATTEMPT_SIZE_V1
@ -533,6 +535,7 @@ class PinAttempt:
from trick_pins import TC_DELTA_MODE
return bool(self.delay_required & TC_DELTA_MODE)
def get_tc_values(self):
# Mk4 only
# return (tc_flags, tc_arg)

View File

@ -879,6 +879,8 @@ class SeedVaultMenu(MenuSystem):
ch = await ux_show_story(title="[" + rec.xfp + "]", msg=msg, escape=esc)
if ch == "x": return
assert not pa.hobbled_mode
dis.fullscreen("Saving...")
wipe_slot = not current_active and (ch != "1")
@ -890,6 +892,7 @@ class SeedVaultMenu(MenuSystem):
xs.blank()
del xs
# CAUTION: will get shadow copy if in tmp seed mode already
seeds = settings.master_get("seeds", [])
try:
@ -926,6 +929,8 @@ class SeedVaultMenu(MenuSystem):
from glob import dis
from ux import ux_input_text
assert not pa.hobbled_mode
idx, old = item.arg
new_label = await ux_input_text(old.label, confirm_exit=False, max_len=40)
@ -956,6 +961,8 @@ class SeedVaultMenu(MenuSystem):
async def _add_current_tmp(*a):
from pincodes import pa
assert not pa.hobbled_mode
assert pa.tmp_value
main_xfp = settings.master_get("xfp", 0)
@ -997,6 +1004,7 @@ class SeedVaultMenu(MenuSystem):
seeds = list(seed_vault_iter())
if not seeds:
assert not pa.hobbled_mode
rv.append(MenuItem('(none saved yet)'))
if pa.tmp_value:
rv.append(add_current_tmp)
@ -1016,8 +1024,8 @@ class SeedVaultMenu(MenuSystem):
submenu = [
MenuItem(rec.label, f=cls._detail, arg=(rec, encoded)),
MenuItem('Use This Seed', f=cls._set, arg=encoded),
MenuItem('Rename', f=cls._rename, arg=(i, rec)),
MenuItem('Delete', f=cls._remove, arg=(i, rec, encoded)),
MenuItem('Rename', f=cls._rename, arg=(i, rec), predicate=not pa.hobbled_mode),
MenuItem('Delete', f=cls._remove, arg=(i, rec, encoded), predicate=not pa.hobbled_mode),
]
if is_active:
submenu[1] = MenuItem("Seed In Use")
@ -1035,7 +1043,7 @@ class SeedVaultMenu(MenuSystem):
rv.append(item)
if pa.tmp_value:
if seeds and (not tmp_in_sv):
if seeds and (not tmp_in_sv) and not pa.hobbled_mode:
# give em chance to store current active
rv.append(add_current_tmp)
@ -1137,6 +1145,8 @@ class EphemeralSeedMenu(MenuSystem):
async def make_ephemeral_seed_menu(*a):
assert not pa.hobbled_mode
if (not pa.tmp_value) and (not settings.master_get("seedvault", False)):
# force a warning on them, unless they are already doing it.
if not await ux_confirm(

View File

@ -307,11 +307,17 @@ async def kt_accept_values(dtype, raw):
- `b` - complete system backup file (text, internal format)
'''
from flow import has_se_secrets, goto_top_menu
from pincodes import pa
enc = None
origin = 'Teleported'
label = None
if pa.hobbled_mode and dtype != 'p':
await ux_show_story('Only PSBT for multisig accepted in this mode.', title='FAILED')
return
if dtype == 's':
# words / bip 32 master / xprv, etc
enc = bytearray(72)
@ -475,6 +481,12 @@ def decode_step2(session_key, noid_key, body):
async def kt_incoming(type_code, payload):
# incoming BBQr was scanned (via main menu, etc)
from pincodes import pa
if pa.hobbled_mode and type_code != 'E':
# only PSBT rx is supported in hobbled mode
# TODO: fail silently? good enough?
return
if type_code == 'R':
# they want to send to this guy
return await kt_start_send(payload)
@ -495,6 +507,10 @@ class SecretPickerMenu(MenuSystem):
def __init__(self, rx_pubkey):
self.rx_pubkey = rx_pubkey
# this menu should be unreachable in hobbled mode.
from pincodes import pa
assert not pa.hobbled_mode
from flow import word_based_seed, is_tmp, has_se_secrets
has_notes = bool(NoteContentBase.count())
has_sv = bool(settings.get('seedvault', False))

View File

@ -32,7 +32,7 @@ TC_WORD_WALLET = const(0x1000)
TC_XPRV_WALLET = const(0x0800)
TC_DELTA_MODE = const(0x0400)
TC_REBOOT = const(0x0200)
TC_RFU = const(0x0100)
TC_FW_DEFINED = const(0x0100)
# for our use, not implemented in bootrom
TC_BLANK_WALLET = const(0x0080)
TC_COUNTDOWN = const(0x0040) # tc_arg = minutes of delay
@ -40,6 +40,10 @@ TC_COUNTDOWN = const(0x0040) # tc_arg = minutes of delay
# tc_args encoding:
# TC_WORD_WALLET -> BIP-85 index, 1001..1003 for 24 words, 2001..2003 for 12-words
# If TC_FW_DEFINED is true, then we can do anything with this PIN at the firmware
# level. First application is to unlock spending stuff.
TCA_SP_UNLOCK = const(0x0001) # spending policy unlock
# special "pin" used as catch-all for wrong pins
WRONG_PIN_CODE = '!p'
@ -274,6 +278,10 @@ class TrickPinMgmt:
# put them in order, with "wrong" last
return sorted(self.tp.keys(), key=lambda i: i if (i != WRONG_PIN_CODE) else 'Z')
def define_unlock_pin(self, new_pin):
# user is setting the bypass PIN for first time.
self.update_slot(new_pin.encode(), new=True, tc_flags=TC_FW_DEFINED, tc_arg=TCA_SP_UNLOCK)
def was_countdown_pin(self):
# was the trick pin just used? if so how much delay needed (or zero if not)
from pincodes import pa
@ -284,6 +292,30 @@ class TrickPinMgmt:
else:
return 0
def was_sp_unlock(self):
# was a trick pin just used that enables acess to spending policy?
# - ok if it's also a trick PIN .. a wiping bypass for example
from pincodes import pa
tc_flags, tc_arg = pa.get_tc_values()
return bool(tc_flags & TC_FW_DEFINED) and (tc_arg == TCA_SP_UNLOCK)
def has_sp_unlock(self):
# if spending policy defined, this PIN allows adjustment
# - not TRICK bypass choices, like ones that wipe
# - could be multiple, but only first returned.
for k, (sn,flags,arg) in self.tp.items():
if (flags == TC_FW_DEFINED) and (arg == TCA_SP_UNLOCK):
return k
return None
def delete_sp_unlock_pins(self):
# remove all bypass pins, they are done w/ feature
for k, (sn,flags,arg) in self.tp.items():
if (flags & TC_FW_DEFINED) and (arg == TCA_SP_UNLOCK):
self.clear_slots(range(sn, sn+1))
self.forget_pin(k)
def get_deltamode_pins(self):
# iterate over all delta-mode PIN's defined.
for k, (sn,flags,args) in self.tp.items():
@ -606,6 +638,9 @@ the seed phrase, but still a somewhat riskier mode.
For this mode only, trick PIN must be same length as true PIN and \
differ only in final 4 positions (ignoring dash).\
''', flags=TC_DELTA_MODE),
StoryMenuItem('Policy Unlock', "Adds (another?) Spending Policy unlock PIN.", flags=TC_FW_DEFINED, arg=TCA_SP_UNLOCK),
StoryMenuItem('Policy Unlock & Wipe' if version.has_qwerty else 'P.U. & Wipe',
"Pretends correct Spending Policy unlock PIN given, but silently wipes seed before asking for main PIN.", flags=TC_FW_DEFINED|TC_WIPE, arg=TCA_SP_UNLOCK),
]
m = MenuSystem(FirstMenu)
m.goto_idx(1)
@ -651,9 +686,14 @@ setting) the Coldcard will always brick after 13 failed PIN attempts.''')
the_ux.push(m)
async def clear_all(self, m,l,item):
if not await ux_confirm("Remove ALL TRICK PIN codes and special wrong-pin handling?"):
return
if tp.has_sp_unlock():
if not await ux_confirm("You will not be able to bypass spending policy anymore."):
return
if any(tp.get_duress_pins()):
if not await ux_confirm("Any funds on the duress wallet(s) have been moved already?"):
return
@ -662,7 +702,7 @@ setting) the Coldcard will always brick after 13 failed PIN attempts.''')
m.update_contents()
async def hide_pin(self, m,l, item):
pin, slot_num, flags = item.arg
pin, slot_num, flags, arg = item.arg
if flags & TC_DELTA_MODE:
await ux_show_story('''Delta mode PIN will be hidden if trick PIN menu is shown \
@ -670,12 +710,14 @@ to attacker, and we need to update this record if the main PIN is changed, so we
hiding this item.''')
return
if pin != WRONG_PIN_CODE:
if (flags == TC_FW_DEFINED) and (arg == TCA_SP_UNLOCK):
msg = "It will still be possible to change or disable the spending policy if this PIN if known."
elif pin == WRONG_PIN_CODE:
msg = "This will hide what happens with wrong PINs from the menus but it will still be in effect."
else:
msg = '''This will hide the PIN from the menus but it will still be in effect.
You can restore it by trying to re-add the same PIN (%s) again later.''' % pin
else:
msg = "This will hide what happens with wrong PINs from the menus but it will still be in effect."
if not await ux_confirm(msg): return
@ -715,12 +757,16 @@ You can restore it by trying to re-add the same PIN (%s) again later.''' % pin
await ux_show_story("Failed: %s" % exc)
async def delete_pin(self, m,l, item):
pin, slot_num, flags = item.arg
pin, slot_num, flags, arg = item.arg
if flags & (TC_WORD_WALLET | TC_XPRV_WALLET):
if not await ux_confirm("Any funds on this duress wallet have been moved already?"):
return
if (flags == TC_FW_DEFINED) and (arg == TCA_SP_UNLOCK):
if not await ux_confirm("Changes to the spending policy will not be possible anymore."):
return
if pin == WRONG_PIN_CODE:
msg = "Remove special handling of wrong PINs?"
else:
@ -748,8 +794,7 @@ You can restore it by trying to re-add the same PIN (%s) again later.''' % pin
ch = await ux_show_story('''\
This will temporarily load the secrets associated with this trick wallet \
so you may perform transactions with it. Reboot the Coldcard to restore \
normal operation.''')
so you may perform transactions with it.''')
if ch != 'y': return
b, slot = tp.get_by_pin(pin)
@ -882,6 +927,8 @@ Wallet is XPRV-based and derived from a fixed path.''' % pin
rv.append(MenuItem("↳Pretends Wrong"))
elif flags & TC_DELTA_MODE:
rv.append(MenuItem("↳Delta Mode"))
elif (flags & TC_FW_DEFINED) and (arg == TCA_SP_UNLOCK):
rv.append(MenuItem("↳Unlock Policy")) # width issues on Mk4
for m, msg in [
(TC_WIPE, '↳Wipes seed'),
@ -895,8 +942,8 @@ Wallet is XPRV-based and derived from a fixed path.''' % pin
rv.append(MenuItem("Activate Wallet", f=self.activate_wallet, arg=(pin, flags, arg)))
rv.extend([
MenuItem('Hide Trick', f=self.hide_pin, arg=(pin, slot_num, flags)),
MenuItem('Delete Trick', f=self.delete_pin, arg=(pin, slot_num, flags)),
MenuItem('Hide Trick', f=self.hide_pin, arg=(pin, slot_num, flags, arg)),
MenuItem('Delete Trick', f=self.delete_pin, arg=(pin, slot_num, flags, arg)),
])
if pin != WRONG_PIN_CODE:
rv.append(
@ -907,6 +954,7 @@ Wallet is XPRV-based and derived from a fixed path.''' % pin
class StoryMenuItem(MenuItem):
def __init__(self, label, story, flags=0, **kws):
# arg= .. handled by super
self.story = story
self.flags = flags
super().__init__(label, **kws)

View File

@ -429,6 +429,8 @@ def clean_shutdown(style=0):
# wipe SPI flash and shutdown (wiping main memory)
# - mk4: SPI flash not used, but NFC may hold data (PSRAM cleared by bootrom)
# - bootrom wipes every byte of SRAM, so no need to repeat here
# - style=2 => reboot and try login again
# - default is logout and (if applicable) power down.
import callgate
# save if anything pending

View File

@ -553,6 +553,16 @@ def ux_draw_words(y, num_words, words):
# Draw seed words on single screen (hard) and return x/y position of start of each
from glob import dis
if num_words == 2:
# simple version for first & last words, used only during login to spending policy
X = 14
Y = y+1
dis.text(X-7, Y, 'FIRST: %s' % words[0])
dis.text(X-4, Y+1, '')
dis.text(X-6, Y+2, 'LAST: %s' % words[-1])
return [ (X, Y), (X, Y+2) ]
if num_words == 12:
cols = 2
xpos = [2, 18]
@ -902,6 +912,8 @@ class QRScannerInteraction:
async def scan_anything(self, expect_secret=False, tmp=False):
# start a QR scan, and act on what we find, whatever it may be.
from ux import ux_show_story
from pincodes import pa
problem = None
while 1:
prompt = 'Scan any QR code, or CANCEL' if not expect_secret else \
@ -923,6 +935,21 @@ class QRScannerInteraction:
problem = "Unable to decode QR"
continue
if pa.hobbled_mode:
# block most imports in hobbled mode.
# - specific checks in place for teleport (PSBT is okay)
from ccc import sssp_spending_policy
whitelist = {'psbt', 'addr', 'vmsg', 'text', 'xpub', 'teleport' }
sv_ok = sssp_spending_policy('okeys')
if sv_ok:
# seed vault, and tmp seeds are okay with user, even in hobble mode
whitelist.update({'xprv', 'words'})
if what not in whitelist:
await ux_show_story("Blocked when Spending Policy is in force", title='Sorry')
return
if what == 'xprv':
from actions import import_extended_key_as_secret
text_xprv, = vals
@ -1104,6 +1131,7 @@ async def ux_visualize_bip21(proto, addr, args):
await OWNERSHIP.search_ux(addr)
async def ux_visualize_wif(wif_str, kp, compressed, testnet):
# TODO: remove until we support signing w/ WIF keys IMHO
from ux import ux_show_story
msg = wif_str + "\n\n"
msg += "chain: %s\n\n" % ("XTN" if testnet else "BTC")