spending policy implemented

This commit is contained in:
Peter D. Gray 2025-08-18 16:13:26 -04:00 committed by doc-hex
parent 28ba1adce3
commit f12457cbb5
12 changed files with 377 additions and 222 deletions

View File

@ -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.

View File

@ -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')

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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()

View File

@ -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

View File

@ -19,7 +19,7 @@ from countdowns import countdown_chooser
from paper import make_paper_wallet
from trick_pins import TrickPinMenu
from tapsigner import import_tapsigner_backup_file
from ccc import toggle_ccc_feature, 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),
]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
'''