From 980bfd9b1c59ad16946ae714905e1db8bdff3e46 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Mon, 18 Aug 2025 08:57:12 -0400 Subject: [PATCH] hobbled mode support for spending policy --- shared/actions.py | 66 +++++++--- shared/ccc.py | 291 +++++++++++++++++++++++++++++++++++++++++++ shared/flow.py | 85 ++++++++++++- shared/login.py | 2 +- shared/menu.py | 2 +- shared/notes.py | 60 +++++++-- shared/nvstore.py | 1 + shared/pincodes.py | 5 +- shared/seed.py | 16 ++- shared/teleport.py | 16 +++ shared/trick_pins.py | 68 ++++++++-- shared/utils.py | 2 + shared/ux_q1.py | 28 +++++ 13 files changed, 596 insertions(+), 46 deletions(-) diff --git a/shared/actions.py b/shared/actions.py index 370d8488..1f2e3a01 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -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() diff --git a/shared/ccc.py b/shared/ccc.py index 0a7ec236..99359927 100644 --- a/shared/ccc.py +++ b/shared/ccc.py @@ -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 diff --git a/shared/flow.py b/shared/flow.py index 872f5db5..9e4cfb67 100644 --- a/shared/flow.py +++ b/shared/flow.py @@ -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), +] diff --git a/shared/login.py b/shared/login.py index 4476cb55..bd9636eb 100644 --- a/shared/login.py +++ b/shared/login.py @@ -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 diff --git a/shared/menu.py b/shared/menu.py index fff34449..6aa9899c 100644 --- a/shared/menu.py +++ b/shared/menu.py @@ -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 diff --git a/shared/notes.py b/shared/notes.py index a97111b1..ddb2dd4e 100644 --- a/shared/notes.py +++ b/shared/notes.py @@ -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, diff --git a/shared/nvstore.py b/shared/nvstore.py index 2db8bd70..e3e51b40 100644 --- a/shared/nvstore.py +++ b/shared/nvstore.py @@ -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) diff --git a/shared/pincodes.py b/shared/pincodes.py index c2138b20..95327a07 100644 --- a/shared/pincodes.py +++ b/shared/pincodes.py @@ -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) diff --git a/shared/seed.py b/shared/seed.py index cc51ffd8..1bee6cb6 100644 --- a/shared/seed.py +++ b/shared/seed.py @@ -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( diff --git a/shared/teleport.py b/shared/teleport.py index cb51b6b9..77e7ba97 100644 --- a/shared/teleport.py +++ b/shared/teleport.py @@ -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)) diff --git a/shared/trick_pins.py b/shared/trick_pins.py index 34054687..7a36a389 100644 --- a/shared/trick_pins.py +++ b/shared/trick_pins.py @@ -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) diff --git a/shared/utils.py b/shared/utils.py index 7a24632d..c63d52e1 100644 --- a/shared/utils.py +++ b/shared/utils.py @@ -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 diff --git a/shared/ux_q1.py b/shared/ux_q1.py index 9c6db374..14ad3cdc 100644 --- a/shared/ux_q1.py +++ b/shared/ux_q1.py @@ -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")