diff --git a/docs/spending-policy.md b/docs/spending-policy.md index deb82bc1..859eb195 100644 --- a/docs/spending-policy.md +++ b/docs/spending-policy.md @@ -1,12 +1,43 @@ - # Spending Policy A special mode where your coldcard will stop you from signing transactions if they exceed a spending policy you define beforehand. +# Tips and Tricks +## Money Manager Mode -## Tips and Tricks +You could setup a Coldcard for another person, perhaps a family member, +and enable web 2FA authentication. There does not need to be any +other spending policy limits (velocity could be unlimited). + +Then enrol your own phone with the required 2FA values, and +keep both that and the spending policy bypass PIN confidential. + +The holder the the Coldcard will need a 2FA code from your phone +when they want to spend. They can call you for the 6-digit code +from the 2FA app on your phone. This is not hard to provide over a +voice call. + +Because a spending policy is in effect, they will not be able to +see the seed words, other private key material, so regardless of +any spoofing or phishing, they cannot move funds without your help. + +You should record the bypass PIN in a safe way, so it can be revelaed +should you die. You do not need to share the risks associated with +holding a copy of their seed words. + +## Lock Out Changes to Policy + +You may go into the Trick Pin menu, find the Bypass PIN there. You +could delete or "hide" it. Hiding it is pointless since you cannot +get to the trick PIN while the policy is in effect. Deleting the +PIN however, is useful because it assures changes to spending policy +are impossible. To recover the COLDCARD when this move is later +regretted, under Advanced, there is "Destroy Seed" option which +will clear the seed words and all settings, including the spending policy. + +## Passphrase Considerations If you are using a BIP-39 passphrase for everything, you should probably do a "Lock Down Seed" (Advanced/Tools > Danger Zone > Seed @@ -17,6 +48,10 @@ reversed, so other funds you may have on the same seed words are protected. Once you are operating in XPRV mode, you can define a spending policy and know that it is restricted to only that wallet. +You can also block access to other related keys, which removes the +"Passphrase" entry option from the main menu, but that protection +doesn seem as strong. + ## Trick PIN Thoughts When doing your game theory w.r.t to bypass mode and this feature, @@ -27,8 +62,9 @@ have all your UTXO locations and total wallet balance (because they can export xpubs to any wallet and load balance from there). Therefore, a trick pin that leads to a duress wallet after giving up -the bypass unlock PIN does not fool them. Best would be to provide -a false bypass PIN that is in fact a wipe PIN. +the bypass unlock PIN, will not fool them. Best would be to provide +a false bypass PIN that is in fact a brick/wipe PIN. + ### Unlock Policy & Wipe @@ -41,10 +77,10 @@ to blank wallet now (no seed loaded). ### Delta Mode and Spending Policy If, from the start, you gave your "delta mode PIN" to the attackers, -then when they bypass the policy (after also getting the bypass PIN from you), -they will still be in Delta Mode. +then when they bypass the policy (after also getting the bypass PIN +from you), they will still be in Delta Mode. They could attempt unlimited spending, but transactions signed will not be valid. If they try to view the seed words or generally export -private key material, they may hit many of the "wipe seed if delta mode" -cases. +private key material, they will hit many of the "wipe seed if delta +mode" cases. diff --git a/graphics/graphics_mk4.py b/graphics/graphics_mk4.py index c2ca930d..4db6a7dd 100644 --- a/graphics/graphics_mk4.py +++ b/graphics/graphics_mk4.py @@ -19,7 +19,7 @@ class Graphics: scroll = (3, 61, 1, 0, b'@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@@\xe0@') - selected = (15, 12, 2, 0, b'\x00\x00\x00\x00\x00\x06\x00\x0c\x00\x18\x0000`\x18\xc0\r\x80\x07\x00\x02\x00\x00\x00') + selected = (9, 12, 2, 0, b'\x00\x00\x00\x00\x00\x80\x01\x80\x01\x00\x03\x00\x82\x00\xc6\x00d\x00<\x00\x18\x00\x00\x00') sm_box = (11, 17, 2, 0, b'\xe4\xe0\x80 \x80 \x80 \x00\x00\x00\x00\x80 \x00\x00\x00\x00\x00\x00\x80 \x00\x00\x00\x00\x80 \x80 \x80 \xe4\xe0') diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md index 708fa2d5..cd01990e 100644 --- a/releases/Next-ChangeLog.md +++ b/releases/Next-ChangeLog.md @@ -12,6 +12,12 @@ This lists the new changes that have not yet been published in a normal release. - Bugfix: Disallow negative input/output amounts in PSBT. - Bugfix: Fix filesystem initialization after Wife LFS or Destroy Seed +# Spending Policy Feature +- "Enable HSM" and "User Management" have moved into Advanced > Spending Policy +- old "CCC" feature has been renamed and moved into that menu +- new feature: Spending policies for "Single Signer" added: + - power new stuff + # Mk4 Specific Changes ## 5.4.? - 2025-08-xx diff --git a/shared/auth.py b/shared/auth.py index ecf2ee98..28d91b14 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -327,7 +327,7 @@ class ApproveTransaction(UserAuthorizedAction): async def interact(self): # Prompt user w/ details and get approval from glob import dis, hsm_active - from ccc import CCCFeature + from ccc import CCCFeature, SSSPFeature # step 1: parse PSBT from PSRAM into in-memory objects. @@ -387,7 +387,13 @@ class ApproveTransaction(UserAuthorizedAction): # early test for spending policy; not an error if violates policy # - might add warnings - could_ccc_sign, needs_2fa = CCCFeature.could_sign(self.psbt) + could_ccc_sign, ccc_needs_2fa = CCCFeature.could_cosign(self.psbt) + + # test for allowing any signature when in single-signer mode + # - but CCC will override it. + should_block, ss_needs_2fa = SSSPFeature.can_allow(self.psbt) + if should_block and not could_ccc_sign: + return await self.failure('Spending Policy violation.') # step 2: figure out what we are approving, so we can get sign-off # - outputs, amounts @@ -500,7 +506,7 @@ class ApproveTransaction(UserAuthorizedAction): self.done() return - if needs_2fa and could_ccc_sign: + if ccc_needs_2fa and could_ccc_sign: # They still need to pass web2fa challenge (but it meets other specs ok) try: await CCCFeature.web2fa_challenge() @@ -510,6 +516,13 @@ class ApproveTransaction(UserAuthorizedAction): if ch2 != 'y': return await self.failure("2FA Failed") + elif ss_needs_2fa: + # Need 2FA for single-sig case .. refuse to sign if it fails. + try: + await SSSPFeature.web2fa_challenge() + except: + return await self.failure("2FA Failed") + # do the actual signing. try: dis.fullscreen('Wait...') @@ -517,9 +530,13 @@ class ApproveTransaction(UserAuthorizedAction): self.psbt.sign_it() if could_ccc_sign: - dis.fullscreen('CCC Sign...') + # this is where the CCC co-signing happens. + dis.fullscreen('Co-Signing...') gc.collect() CCCFeature.sign_psbt(self.psbt) + else: + # maybe capture new min-height for velocity limit + SSSPFeature.update_last_signed(self.psbt) except FraudulentChangeOutput as exc: return await self.failure(exc.args[0], title='Change Fraud') @@ -530,8 +547,9 @@ class ApproveTransaction(UserAuthorizedAction): return await self.failure("Signing failed late", exc) try: - await done_signing(self.psbt, self, self.input_method, self.filename, self.output_encoder, - slot_b=True if ch == "b" else False, finalize=self.do_finalize) + await done_signing(self.psbt, self, self.input_method, + self.filename, self.output_encoder, + slot_b=(ch == "b"), finalize=self.do_finalize) self.done() except AbortInteraction: # user might have sent new sign cmd, while we still at export prompt diff --git a/shared/ccc.py b/shared/ccc.py index 99359927..a5d5bb9f 100644 --- a/shared/ccc.py +++ b/shared/ccc.py @@ -2,10 +2,10 @@ # # 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: +# Rebranding/single-signer additions: # -# - "CCC" will now be branded as "Spending Policy (Co-Sign)" was "ColdCard Cosigning" -# - single singer policies will be called "Spending Policy" +# - "CCC" (was "ColdCard Cosigning") will now be branded as "Spending Policy: Multisig" +# - single singer policies will be called "Spending Policy: Single Sig" # - 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 # @@ -18,18 +18,185 @@ from menu import MenuSystem, MenuItem, start_chooser from seed import seed_words_to_encoded_secret from stash import SecretStash from charcodes import KEY_QR, KEY_CANCEL, KEY_NFC -from exceptions import CCCPolicyViolationError +from exceptions import SpendPolicyViolation # limit to number of addresses in list MAX_WHITELIST = const(25) -class CCCFeature: +class SpendingPolicy(dict): + # Details of what is allowed or not. Same for single vs. multisig signing. + # - a dict() but with write-thru to setting value - # we don't show the user the reason for policy fail (by design, so attacker + # We don't show the user the reason for policy fail (by design, so attacker # cannot maximize their take against the policy), but during setup/experiments - # we offer to show the reason in the menu - last_fail_reason = "" + # we offer to show the reason in the menu. Includes both SS and MS cases. + last_fail_reason = '' + + def __init__(self, nvkey, pol_dict): + # deserialize and construct + #assert nvkey in { 'ccc', 'sssp' } + self.nvkey = nvkey + super().__init__(pol_dict) + + def _update_policy(self): + # serialize the spending policy, save it + v = dict(settings.get(self.nvkey, {})) + v['pol'] = self.copy() + settings.set(self.nvkey, v) + + def update_policy_key(self, **kws): + # update a few elements of the spending policy + # - all settings "saved" as they are changed. + # - return updated policy + self.update(kws) + self._update_policy() + + def meets_policy(self, psbt): + # Does policy allow signing this? Else raise why. Return T if web2fa required. + pol = self + + # not safe to sign any txn w/ warnings: might be complaining about + # massive miner fees, or weird OP_RETURN stuff + if psbt.warnings: + raise SpendPolicyViolation("has warnings") + + # Magnitude: size limits for output side (non change) + magnitude = pol.get("mag", None) + if magnitude is not None: + if magnitude < 1000: + # it is a BTC, convert to sats + magnitude = magnitude * 100000000 + + outgoing = psbt.total_value_out - psbt.total_change_value + if outgoing > magnitude: + raise SpendPolicyViolation("magnitude") + + # Velocity: if zero => no velocity checks + velocity = pol.get("vel", None) + if velocity: + if not psbt.lock_time: + raise SpendPolicyViolation("no nLockTime") + + if psbt.lock_time >= NLOCK_IS_TIME: + # this is unix timestamp - not allowed - fail + raise SpendPolicyViolation("nLockTime not height") + + block_h = pol.get("block_h", chains.current_chain().ccc_min_block) + if psbt.lock_time <= block_h: + raise SpendPolicyViolation("rewound (%d)" % psbt.lock_time) + + # we won't sign txn unless old height + velocity >= new height + if psbt.lock_time < (block_h + velocity): + raise SpendPolicyViolation("velocity (%d)" % psbt.lock_time) + + # Whitelist of outputs addresses + wl = pol.get("addrs", None) + if wl: + c = chains.current_chain() + wl = set(wl) + for idx, txo in psbt.output_iter(): + out = psbt.outputs[idx] + if not out.is_change: # ignore change + addr = c.render_address(txo.scriptPubKey) + if addr not in wl: + raise SpendPolicyViolation("whitelist: " + addr) + + # Web 2FA + # - slow, requires UX, and they might not acheive it... + # - wait until about to do signature + if pol.get('web2fa', False): + psbt.warnings.append(('CCC', 'Web 2FA required.')) + return True + + async def web2fa_challenge(self, msg): + # they are trying to sign something, so make them get out their phone + # - at this point they have already ok'ed the details of the txn + # - and we have approved other elements of the spending policy. + # - could show MS wallet name, or txn details but will not because that is + # an info leak to Coinkite... and we just don't want to know. + assert self.get('web2fa') + + ok = await web2fa.perform_web2fa(msg, self.get('web2fa')) + if not ok: + SpendingPolicy.last_fail_reason = '2FA Fail' + raise SpendPolicyViolation + + def update_last_signed(self, psbt): + # Call after successfully signing a PSBT ... notes the height involved. + # - might add other things besides height here someday + old_h = self.get('block_h', 1) + + if old_h < psbt.lock_time < NLOCK_IS_TIME: + # always update last block height, even if velocity isn't enabled yet + # - attacker might have changed to testnet, but there is no + # reason to ever lower block height. strictly ascending + self.update_policy_key(block_h=psbt.lock_time) + settings.save() + +class SSSPFeature: + # Using setting value "sssp" + + @classmethod + def is_enabled(cls): + return sssp_spending_policy('en') + + @classmethod + def update_last_signed(cls, psbt): + # new PSBT has been completely signed successfully. + if not cls.is_enabled(): + return + pol = cls.get_policy() + pol.update_last_signed(psbt) + + @classmethod + def default_policy(cls): + # a very basic and permissive policy, but non-zero too. + # - 1BTC per day + chain = chains.current_chain() + return SpendingPolicy('sssp', dict(mag=1, vel=144, + block_h=chain.ccc_min_block, web2fa='', addrs=[])) + + @classmethod + def get_policy(cls): + # de-serialize just the spending policy + return SpendingPolicy('sssp', settings.get('sssp', dict(pol={})).get('pol')) + + @classmethod + def can_allow(cls, psbt): + # We are looking at a PSBT: should we let user sign it, or block? + # - return (block_signing, needs_2fa_step) + if not cls.is_enabled: + exists = bool(settings.get('sssp', False)) + if exists: + # this will not block CCC co-signing, because that test is already + # done before this call. + psbt.warnings.append(('SP', "Spending Policy defined but disabled.")) + return False, False + + try: + # check policy + pol = cls.get_policy() + needs_2fa = pol.meets_policy(psbt) + except SpendPolicyViolation as e: + SpendingPolicy.last_fail_reason = str(e) + # caller will show msg + return True, False + + return False, needs_2fa + + @classmethod + async def web2fa_challenge(cls): + # they are trying to sign something, so make them get out their phone + # - at this point they have already ok'ed the details of the txn + # - and we have approved other elements of the spending policy. + # - could show MS wallet name, or txn details but will not because that is + # an info leak to Coinkite... and we just don't want to know. + await cls.get_policy().perform_web2fa('Approve Transaction') + + +class CCCFeature: + # Using setting value "ccc" @classmethod def is_enabled(cls): @@ -92,29 +259,13 @@ class CCCFeature: # a very basic and permissive policy, but non-zero too. # - 1BTC per day chain = chains.current_chain() - return dict(mag=1, vel=144, block_h=chain.ccc_min_block, web2fa='', addrs=[]) + return SpendingPolicy('ccc', dict(mag=1, vel=144, + block_h=chain.ccc_min_block, web2fa='', addrs=[])) @classmethod def get_policy(cls): # de-serialize just the spending policy - return dict(settings.get('ccc', dict(pol={})).get('pol')) - - @classmethod - def update_policy(cls, pol): - # serialize the spending policy, save it - v = dict(settings.get('ccc', {})) - v['pol'] = dict(pol) - settings.set('ccc', v) - return v['pol'] - - @classmethod - def update_policy_key(cls, **kws): - # update a few elements of the spending policy - # - all settings "saved" as they are changed. - # - return updated policy - p = cls.get_policy() - p.update(kws) - return cls.update_policy(p) + return SpendingPolicy('ccc', settings.get('ccc', dict(pol={})).get('pol')) @classmethod def remove_ccc(cls): @@ -124,66 +275,7 @@ class CCCFeature: settings.save() @classmethod - def meets_policy(cls, psbt): - # Does policy allow signing this? Else raise why - pol = cls.get_policy() - - # not safe to sign any txn w/ warnings: might be complaining about - # massive miner fees, or weird OP_RETURN stuff - if psbt.warnings: - raise CCCPolicyViolationError("has warnings") - - # Magnitude: size limits for output side (non change) - magnitude = pol.get("mag", None) - if magnitude is not None: - if magnitude < 1000: - # it is a BTC, convert to sats - magnitude = magnitude * 100000000 - - outgoing = psbt.total_value_out - psbt.total_change_value - if outgoing > magnitude: - raise CCCPolicyViolationError("magnitude") - - # Velocity: if zero => no velocity checks - velocity = pol.get("vel", None) - if velocity: - if not psbt.lock_time: - raise CCCPolicyViolationError("no nLockTime") - - if psbt.lock_time >= NLOCK_IS_TIME: - # this is unix timestamp - not allowed - fail - raise CCCPolicyViolationError("nLockTime not height") - - block_h = pol.get("block_h", chains.current_chain().ccc_min_block) - if psbt.lock_time <= block_h: - raise CCCPolicyViolationError("rewound (%d)" % psbt.lock_time) - - # we won't sign txn unless old height + velocity >= new height - if psbt.lock_time < (block_h + velocity): - raise CCCPolicyViolationError("velocity (%d)" % psbt.lock_time) - - # Whitelist of outputs addresses - wl = pol.get("addrs", None) - if wl: - c = chains.current_chain() - wl = set(wl) - for idx, txo in psbt.output_iter(): - out = psbt.outputs[idx] - if not out.is_change: # ignore change - addr = c.render_address(txo.scriptPubKey) - if addr not in wl: - raise CCCPolicyViolationError("whitelist") - - # Web 2FA - # - slow, requires UX, and they might not acheive it... - # - wait until about to do signature - if pol.get('web2fa', False): - psbt.warnings.append(('CCC', 'Web 2FA required.')) - return True - - - @classmethod - def could_sign(cls, psbt): + def could_cosign(cls, psbt): # We are looking at a PSBT: can we sign it, and would we? # - if we **could** but will not, due to policy, add warning msg # - return (we could sign, needs 2fa step) @@ -192,7 +284,7 @@ class CCCFeature: ms = psbt.active_multisig if not ms: - # single-sig CCC not supported + # not multisig, so ignore/permit return False, False # TODO: if key B has already signed the PSBT, and so we don't need key C, @@ -205,41 +297,29 @@ class CCCFeature: try: # check policy - needs_2fa = cls.meets_policy(psbt) - except CCCPolicyViolationError as e: - cls.last_fail_reason = str(e) + pol = cls.get_policy() + needs_2fa = pol.meets_policy(psbt) + except SpendPolicyViolation as e: + SpendingPolicy.last_fail_reason = str(e) psbt.warnings.append(('CCC', "Violates spending policy. Won't sign.")) return False, False return True, needs_2fa - @classmethod - async def web2fa_challenge(cls): - # they are trying to sign something, so make them get out their phone - # - at this point they have already ok'ed the details of the txn - # - and we have approved other elements of the spending policy. - # - could show MS wallet name, or txn details but will not because that is - # an info leak to Coinkite... and we just don't want to know. - pol = cls.get_policy() - - ok = await web2fa.perform_web2fa('Approve CCC Transaction', pol.get('web2fa')) - if not ok: - cls.last_fail_reason = '2FA Fail' - raise CCCPolicyViolationError - @classmethod def sign_psbt(cls, psbt): # do the math psbt.sign_it(cls.get_encoded_secret(), cls.get_xfp()) - cls.last_fail_reason = "" + SpendingPolicy.last_fail_reason = "" - old_h = cls.get_policy().get('block_h', 1) - if old_h < psbt.lock_time < NLOCK_IS_TIME: - # always update last block height, even if velocity isn't enabled yet - # - attacker might have changed to testnet, but there is no - # reason to ever lower block height. strictly ascending - cls.update_policy_key(block_h=psbt.lock_time) - settings.save() + pol = cls.get_policy() + pol.update_last_signed(psbt) + + @classmethod + async def web2fa_challenge(cls): + # do UX for web2fa; user is given option to proceed even if it fails + # (without the co-signing) + await cls.get_policy().web2fa_challenge('Approve Transaction: Co-Sign') def render_mag_value(mag): @@ -264,9 +344,10 @@ class CCCConfigMenu(MenuSystem): my_xfp = CCCFeature.get_xfp() items = [ - # xxxxxxxxxxxxxxxx - MenuItem('CCC [%s]' % xfp2str(my_xfp), f=self.show_ident), - MenuItem('Spending Policy', menu=CCCPolicyMenu.be_a_submenu), + MenuItem(('[%s] Co-Signing' if version.has_qwerty else '[%s]') + % xfp2str(my_xfp), f=self.show_ident), + MenuItem('Spending Policy', + menu=lambda *a: SpendingPolicyMenu.be_a_submenu(SSSPFeature.get_policy())), MenuItem('Export CCC XPUBs', f=self.export_xpub_c), MenuItem('Multisig Wallets'), ] @@ -281,7 +362,7 @@ class CCCConfigMenu(MenuSystem): items.append(MenuItem('↳ Build 2-of-N', f=self.build_2ofN, arg=count)) - if CCCFeature.last_fail_reason: + if SpendingPolicy.last_fail_reason: # xxxxxxxxxxxxxxxx items.insert(1, MenuItem('Last Violation', f=self.debug_last_fail)) @@ -299,11 +380,11 @@ class CCCConfigMenu(MenuSystem): 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 + % SpendingPolicy.last_fail_reason ch = await ux_show_story(msg, escape='4') if ch == '4': - CCCFeature.last_fail_reason = '' + SpendingPolicy.last_fail_reason = '' self.update_contents() async def remove_ccc(self, *a): @@ -386,23 +467,11 @@ be ready to show it as a QR, before proceeding.''' goto_top_menu() -class PolCheckedMenuItem(MenuItem): - # Show a checkmark if **policy** setting is defined and not the default - # - only works inside CCCPolicyMenu - def __init__(self, label, polkey, **kws): - super().__init__(label, **kws) - self.polkey = polkey - def is_chosen(self): - # should we show a check in parent menu? check the policy - m = the_ux.top_of_stack() - #assert isinstance(m, CCCPolicyMenu) - return bool(m.policy.get(self.polkey, False)) - - -class CCCAddrWhitelist(MenuSystem): +class SPAddrWhitelist(MenuSystem): # simulator arg: --seq tcENTERENTERsENTERwENTER - def __init__(self): + def __init__(self, pol): + self.policy = pol items = self.construct() super().__init__(items) @@ -411,12 +480,12 @@ class CCCAddrWhitelist(MenuSystem): self.replace_items(tmp) @classmethod - async def be_a_submenu(cls, *a): - return cls() + async def be_a_submenu(cls, pol, *a): + return cls(pol) def construct(self): # list of addresses - addrs = CCCFeature.get_policy().get('addrs', []) + addrs = self.policy.get('addrs', []) maxxed = (len(addrs) >= MAX_WHITELIST) items = [] @@ -451,15 +520,14 @@ class CCCAddrWhitelist(MenuSystem): def delete_addr(self, addr): # no confirm, stakes are low - addrs = CCCFeature.get_policy().get('addrs', []) + addrs = self.policy.get('addrs', []) addrs.remove(addr) - CCCFeature.update_policy_key(addrs=addrs) + self.policy.update_policy_key(addrs=addrs) self.update_contents() async def clear_all(self, *a): - if await ux_confirm("Irreversibly remove all addresses from the whitelist?", - confirm_key='4'): - CCCFeature.update_policy_key(addrs=[]) + if await ux_confirm("Remove all addresses from the whitelist?", confirm_key='4'): + self.policy.update_policy_key(addrs=[]) self.update_contents() async def import_file(self, *a): @@ -544,7 +612,7 @@ class CCCAddrWhitelist(MenuSystem): async def add_addresses(self, more_addrs): # add new entries, if unique; preserve ordering - addrs = CCCFeature.get_policy().get('addrs', []) + addrs = self.policy.get('addrs', []) new = [] for a in more_addrs: if a not in addrs: @@ -559,23 +627,39 @@ class CCCAddrWhitelist(MenuSystem): if len(addrs) > MAX_WHITELIST: return await self.maxed_out() - CCCFeature.update_policy_key(addrs=addrs) + self.policy.update_policy_key(addrs=addrs) self.update_contents() if len(new) > 1: await ux_show_story("Added %d new addresses to whitelist:\n\n%s" % (len(new), '\n\n'.join(show_single_address(a) for a in new))) else: - await ux_show_story("Added new address to whitelist:\n\n%s" % show_single_address(new[0])) + await ux_show_story("Added new address to whitelist:\n\n%s" % + show_single_address(new[0])) -class CCCPolicyMenu(MenuSystem): +class SPCheckedMenuItem(MenuItem): + # Show a checkmark if **policy** setting is defined and not the default + # - only works inside SpendingPolicyMenu + def __init__(self, label, polkey, **kws): + super().__init__(label, **kws) + self.polkey = polkey + + def is_chosen(self): + # should we show a check in parent menu? check the policy + m = the_ux.top_of_stack() + #assert isinstance(m, SpendingPolicyMenu) + return bool(m.policy.get(self.polkey, False)) + +class SpendingPolicyMenu(MenuSystem): # Build menu stack that allows edit of all features of the spending - # policy. Key C is set already at this point. + # policy. + # - supports both CCC and SSSP modes w/ same policies + # - Key C is set already at this point. # - and delete/cancel CCC (clears setting?) # - be a sticky menu that's hard to exit (ie. SAVE choice and no cancel out) - def __init__(self): - self.policy = CCCFeature.get_policy() + def __init__(self, pol): + self.policy = pol items = self.construct() super().__init__(items) @@ -584,17 +668,18 @@ class CCCPolicyMenu(MenuSystem): self.replace_items(tmp) @classmethod - async def be_a_submenu(cls, *a): - return cls() + async def be_a_submenu(cls, pol, *a): + return cls(pol) def construct(self): items = [ # xxxxxxxxxxxxxxxx - PolCheckedMenuItem('Max Magnitude', 'mag', f=self.set_magnitude), - PolCheckedMenuItem('Limit Velocity', 'vel', f=self.set_velocity), - PolCheckedMenuItem('Whitelist' + (' Addresses' if version.has_qr else ''), - 'addrs', menu=CCCAddrWhitelist.be_a_submenu), - PolCheckedMenuItem('Web 2FA', 'web2fa', f=self.toggle_2fa), + SPCheckedMenuItem('Max Magnitude', 'mag', f=self.set_magnitude), + SPCheckedMenuItem('Limit Velocity', 'vel', f=self.set_velocity), + SPCheckedMenuItem('Whitelist' + (' Addresses' if version.has_qr else ''), + 'addrs', + menu=lambda *a: SPAddrWhitelist.be_a_submenu(self.policy)), + SPCheckedMenuItem('Web 2FA', 'web2fa', f=self.toggle_2fa), ] if self.policy.get('web2fa'): @@ -608,15 +693,15 @@ class CCCPolicyMenu(MenuSystem): async def test_2fa(self, *a): ss = self.policy.get('web2fa') assert ss - ok = await web2fa.perform_web2fa('CCC Test', ss) + ok = await web2fa.perform_web2fa('Testing Only', ss) await ux_show_story('Correct code was given.' if ok else 'Failed or aborted.') async def enroll_more_2fa(self, *a): - # let more phones in on the party + # let more phones in on the party, but they get same shared secret ss = self.policy.get('web2fa') assert ss - await web2fa.web2fa_enroll('CCC', ss) + await web2fa.web2fa_enroll(ss) async def set_magnitude(self, *a): # Looks decent on both Q and Mk4... @@ -640,7 +725,7 @@ class CCCPolicyMenu(MenuSystem): else: msg += " maximum per-transaction: \n\n %s" % render_mag_value(val) - self.policy = CCCFeature.update_policy_key(**args) + self.policy.update_policy_key(**args) await ux_show_story(msg, title="TX Magnitude") @@ -650,7 +735,7 @@ class CCCPolicyMenu(MenuSystem): if not mag: msg = 'Velocity limit requires a per-transaction magnitude to be set.'\ ' This has been set to 1BTC as a starting value.' - self.policy = CCCFeature.update_policy_key(mag=1) + self.policy.update_policy_key(mag=1) await ux_show_story(msg) @@ -685,7 +770,7 @@ class CCCPolicyMenu(MenuSystem): which = 0 def set(idx, text): - self.policy = CCCFeature.update_policy_key(vel=va[idx]) + self.policy.update_policy_key(vel=va[idx]) return which, ch, set @@ -696,7 +781,7 @@ class CCCPolicyMenu(MenuSystem): if not await ux_confirm("Disable web 2FA check? Effect is immediate."): return - self.policy = CCCFeature.update_policy_key(web2fa='') + self.policy.update_policy_key(web2fa='') self.update_contents() await ux_show_story("Web 2FA has been disabled. If you re-enable it, a new " @@ -716,12 +801,12 @@ phone with Internet access and 2FA app holding correct shared-secret.''', return # challenge them, and don't set unless it works - ss = await web2fa.web2fa_enroll('CCC') + ss = await web2fa.web2fa_enroll() if not ss: return # update state - self.policy = CCCFeature.update_policy_key(web2fa=ss) + self.policy.update_policy_key(web2fa=ss) self.update_contents() async def gen_or_import(): @@ -921,7 +1006,8 @@ def sssp_spending_policy(key, default=False, change=None): return default -async def toggle_sssp_feature(*a): +async def sssp_feature_menu(*a): + # Show the top menu for SSSP feature, or enable access first time. from pincodes import pa from actions import goto_top_menu @@ -933,6 +1019,7 @@ async def toggle_sssp_feature(*a): # normal entry into menu system, after the first time assert not pa.hobbled_mode else: + # tell them a story, and maybe enable feature en = await sssp_enable() if not en: return @@ -1088,19 +1175,18 @@ class SSSPConfigMenu(MenuSystem): 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), + MenuItem('Edit Policy...', + menu=lambda *a: SpendingPolicyMenu.be_a_submenu(SSSPFeature.get_policy())), 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.'), + SSSPCheckedMenuItem('Allow Notes', 'words', 'To change Spending Policy, in 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). Same spending Policy applies to all.'), #MenuItem('Test Word Challenge', f=sssp_word_challenge), # XXX test only? ] - if CCCFeature.last_fail_reason: - # xxxxxxxxxxxxxxxx + if SpendingPolicy.last_fail_reason: + # xxxxxxxxxxxxxxxx items.insert(1, MenuItem('Last Violation', f=self.debug_last_fail)) items.append(MenuItem('Remove Policy', f=self.remove_sssp)) @@ -1149,18 +1235,18 @@ class SSSPConfigMenu(MenuSystem): async def debug_last_fail(self, *a): # debug for customers: why did we reject that last txn? - pol = CCCFeature.get_policy() + pol = SSSPFeature.get_policy() bh = pol.get('block_h', None) msg = '' if bh: - msg += "CCC height:\n\n%s\n\n" % bh + msg += "Last 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 + % SpendingPolicy.last_fail_reason ch = await ux_show_story(msg, escape='4') if ch == '4': - CCCFeature.last_fail_reason = '' + SpendingPolicy.last_fail_reason = '' self.update_contents() async def remove_sssp(self, *a): diff --git a/shared/display.py b/shared/display.py index 43ab64bb..80352a1d 100644 --- a/shared/display.py +++ b/shared/display.py @@ -266,17 +266,18 @@ class Display: if is_sel: self.dis.fill_rect(0, y, Display.WIDTH, h-1, 1) self.icon(2, y, 'wedge', invert=1) - self.text(x, y, msg, invert=1) + nx = self.text(x, y, msg, invert=1) else: - self.text(x, y, msg) + nx = self.text(x, y, msg) # LATER: removed because caused confusion w/ underscore #if msg[0] == ' ' and space_indicators: # see also graphics/mono/space.txt #self.icon(x-2, y+9, 'space', invert=is_sel) - if is_checked: - self.icon(108, y, 'selected', invert=is_sel) + if is_checked and nx <= 113: + # omit checkmark if it doesn't fit + self.icon(113, y, 'selected', invert=is_sel) def menu_show(self, *a): self.show() diff --git a/shared/exceptions.py b/shared/exceptions.py index 578ba9a3..a9a6f1e5 100644 --- a/shared/exceptions.py +++ b/shared/exceptions.py @@ -52,7 +52,7 @@ class UnknownAddressExplained(ValueError): pass # We're not going to co-sign using CCC feature -class CCCPolicyViolationError(RuntimeError): +class SpendPolicyViolation(RuntimeError): pass # EOF diff --git a/shared/flow.py b/shared/flow.py index 9e4cfb67..372a920c 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, sssp_spending_policy, toggle_sssp_feature +from ccc import toggle_ccc_feature, sssp_spending_policy, sssp_feature_menu # useful shortcut keys from charcodes import KEY_QR, KEY_NFC @@ -367,6 +367,19 @@ NFCToolsMenu = [ MenuItem('Push Transaction', f=nfc_pushtx_file, predicate=lambda: settings.get("ptxurl", False)), ] + +SpendingPolicySubMenu = [ + NonDefaultMenuItem('Single-Signer', 'sssp', f=sssp_feature_menu, predicate=has_real_secret), + NonDefaultMenuItem('Co-Sign Multi.' if not version.has_qwerty else 'Co-Sign Multisig (CCC)', + 'ccc', f=toggle_ccc_feature, predicate=is_not_tmp), + ToggleMenuItem('HSM Mode', 'hsmcmd', ['Default Off', 'Enable'], + story=("Enable HSM? Enables all user management commands, and other HSM-only USB commands. " + "By default these commands are disabled."), + predicate=hsm_available), + MenuItem('User Management', menu=make_users_menu, + predicate=lambda: hsm_available() and settings.get('hsmcmd', False)), +] + AdvancedNormalMenu = [ # xxxxxxxxxxxxxxxx MenuItem("Backup", menu=BackupStuffMenu), @@ -380,14 +393,8 @@ AdvancedNormalMenu = [ MenuItem("View Identity", f=view_ident), MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu), MenuItem("Key Teleport (start)", f=kt_start_rx, predicate=version.has_qr), + MenuItem("Spending Policy", menu=SpendingPolicySubMenu, shortcut='s'), MenuItem('Paper Wallets', f=make_paper_wallet), - ToggleMenuItem('Enable HSM', 'hsmcmd', ['Default Off', 'Enable'], - story=("Enable HSM? Enables all user management commands, and other HSM-only USB commands. " - "By default these commands are disabled."), - 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'), ] @@ -534,6 +541,6 @@ HobbledTopMenu = [ 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), + MenuItem('EXIT TEST DRIVE', f=sssp_feature_menu, predicate=is_hobble_testdrive), ShortcutItem(KEY_NFC, predicate=nfc_enabled, menu=HobbledNFCToolsMenu), ] diff --git a/shared/imptask.py b/shared/imptask.py index 5a96e0a8..297415eb 100644 --- a/shared/imptask.py +++ b/shared/imptask.py @@ -58,8 +58,8 @@ class ImportantTask: else: # uncaught exception in an unnamed (and unimportant) task print("UNNAMED: " + context["message"]) - # sys.print_exception(context["exception"]) - print("... future: %r" % context.get("future", '?')) + sys.print_exception(context["exception"]) # VERY USEFUL on sim + #print("... future: %r" % context.get("future", '?')) def start_task(self, name, awaitable): # start a critical task and watch for it to never die diff --git a/shared/menu.py b/shared/menu.py index 6aa9899c..98f3f0b7 100644 --- a/shared/menu.py +++ b/shared/menu.py @@ -306,10 +306,6 @@ class MenuSystem: if fcn and fcn(): checked = True - if not has_qwerty and checked and (len(msg) > 14): - # on mk4 every label longer than 14 will overlap with checkmark - checked = False - if self.multi_selected is not None and (real_idx in self.multi_selected): # ignore length constraint above, we need to visually show that # smthg is selected - in any case diff --git a/shared/web2fa.py b/shared/web2fa.py index 26a0820a..80f774e7 100644 --- a/shared/web2fa.py +++ b/shared/web2fa.py @@ -95,7 +95,7 @@ async def perform_web2fa(label, shared_secret): return False -async def web2fa_enroll(label, ss=None): +async def web2fa_enroll(ss=None): # # Enroll: Pick a secret and test they have loaded it into their phone. # @@ -115,8 +115,8 @@ async def web2fa_enroll(label, ss=None): # - can't fit any metadata, like username or our serial # in there # - better on Q1 where no limitations for this size of QR - qr = 'otpauth://totp/{nm}?secret={ss}'.format(ss=ss, - nm=url_quote(label if has_qr else label[0:4])) + nm = 'COLDCARD' if has_qr else 'CC' # must be url-safe + qr = 'otpauth://totp/{nm}?secret={ss}'.format(ss=ss, nm=nm) while 1: # show QR for enroll @@ -124,13 +124,12 @@ async def web2fa_enroll(label, ss=None): force_msg=True) # important: force them to prove they store it correctly - ok = await perform_web2fa('Enroll: ' + label, ss) + ok = await perform_web2fa('Enroll: COLDCARD', ss) if ok: break ch = await ux_show_story("That isn't correct. Please re-import and/or " "try again or %s to give up." % X) if ch == 'x': - # mk4 only? return None return ss diff --git a/testing/test_hobble.py b/testing/test_hobble.py index 161f153b..31567923 100644 --- a/testing/test_hobble.py +++ b/testing/test_hobble.py @@ -2,6 +2,9 @@ # # Verify hobble works: a restricted access mode, without export/view of seed and more. # +# - spending policy menu and txn checks should not be in this file, instead expand +# test_ccc.py or create test_sssp.py +# import pytest, time, re, pdb from helpers import prandom, xfp2str, str2xfp, str_to_path from bbqr import join_qrs @@ -13,6 +16,8 @@ from test_backup import make_big_notes '''TODO +When hobbled... + - check adv menu is minimal - load a secure note/pw; check readonly once hobbled - cannot export @@ -22,9 +27,10 @@ from test_backup import make_big_notes - check readonly features on notes when note pre-defined before entering hobbled mode - notes hidden if the exist but access disabled in policy -- check KT only offered if MS wallet setup -- scan a KT and have it rejected if not PSBT type: so R and E types -- MS psbt KT should still work in hobbled mode: test_teleport.py::test_teleport_ms_sign +- key teleport + - check KT only offered if MS wallet setup + - scan a KT and have it rejected if not PSBT type: so R and E types + - MS psbt KT should still work in hobbled mode: test_teleport.py::test_teleport_ms_sign - verify no settings menu - temp seeds are read only: no create, no rename, etc. @@ -39,9 +45,9 @@ from test_backup import make_big_notes - q1 vs mk4 style - wrong values given, etc -- update menu tree w/ hobble mode view - - verify whitelist of QR types is correct when in hobbled mode - no private key material, no teleport starting, unless "okeys" is set +- update menu tree w/ hobble mode view + '''