hobbled mode support for spending policy
What changed, and why it matters
This commit adds a new 'hobbled mode' feature to the COLDCARD hardware wallet firmware. It lets a user set a 'Spending Policy' that restricts what the device can do even when the main PIN is entered. The policy can limit access to seed words, backups, secure notes, and certain signing operations. A special bypass PIN is required to disable or change the policy. The change is a feature addition, not a fix for a known vulnerability, and the commit message and code comments describe it as a user-controlled self-protection mechanism.
Review the feature for correctness and edge cases, especially the Mk4 seed-word challenge TODO, the silent return in kt_incoming for hobbled mode, and the broad 'except: pass' blocks around policy application. Ensure the unlock flow cannot be bypassed and that hobbled_mode transitions are atomic and cannot be interrupted to leave the device in an inconsistent state. No immediate patch is indicated because this is a new feature rather than a disclosed vulnerability fix.
Security signals we found
New user-facing security feature (Spending Policy / hobbled mode)
New trick-PIN type TC_FW_DEFINED with firmware-defined behavior TCA_SP_UNLOCK
Restricted menu system and feature gating based on pa.hobbled_mode
Optional seed-word challenge (first and last words) to unlock policy
Blocks teleport of non-PSBT secrets and most QR imports while hobbled
Read-only secure notes when hobbled
Seed vault and ephemeral seed menus assert not hobbled_mode
No explicit bug fix or vulnerability disclosure in commit message
Evidence from the diff
The patch introduces a single-signer spending policy (SSSP) branded alongside the existing CCC (ColdCard Co-Sign) feature. It adds a persistent ‘sssp’ settings object, a new trick-PIN class (TC_FW_DEFINED / TCA_SP_UNLOCK) used to unlock the policy, and a hobbled_mode flag on the PinAttempt object. When hobbled_mode is active, the top-level menu is replaced with a restricted menu (HobbledTopMenu), seed access, backups, firmware upgrades, teleport of secrets, and note editing are blocked or limited, and QR/teleport imports are filtered to a whitelist. To exit hobbled mode the user must enter the special unlock PIN, then the main PIN, and optionally provide the first and last seed words. The code also includes a ‘test drive’ mode (hobbled_mode == 2) that can be exited easily.
Changed components
shared/actions.pyshared/ccc.pyshared/flow.pyshared/login.pyshared/menu.pyshared/notes.pyshared/nvstore.pyshared/pincodes.pyshared/seed.pyshared/teleport.pyshared/trick_pins.pyshared/utils.pyshared/ux_q1.pyInspect captured patch +596 / −46
diff --git a/shared/actions.py b/shared/actions.py
index 884c0a0..6aed184 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 0a7ec23..9935992 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 872f5db..9e4cfb6 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 4476cb5..bd9636e 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 fff3444..6aa9899 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 a97111b..ddb2dd4 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 2db8bd7..e3e51b4 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 c2138b2..95327a0 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 66e42f1..428aa2f 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 b470e55..f4e2ead 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 3405468..7a36a38 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 487aa49..816cdd0 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 9c6db37..14ad3cd 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")
Why this scored 37/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.