1302 lines
46 KiB
Python
1302 lines
46 KiB
Python
# (c) Copyright 2024 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
|
#
|
|
# ccc.py - ColdCard Co-sign feature. Be a leg in a 2-of-3 that is signed based on a policy.
|
|
#
|
|
# Rebranding/single-signer additions:
|
|
#
|
|
# - "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
|
|
#
|
|
import gc, chains, version, ngu, web2fa, bip39, re
|
|
from ubinascii import hexlify as b2a_hex
|
|
from chains import NLOCK_IS_TIME
|
|
from utils import swab32, xfp2str, truncate_address, deserialize_secret, show_single_address
|
|
from glob import settings, dis
|
|
from ux import ux_confirm, ux_show_story, the_ux, OK, ux_dramatic_pause, ux_enter_number, ux_aborted
|
|
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 SpendPolicyViolation
|
|
|
|
|
|
# limit to number of addresses in list
|
|
MAX_WHITELIST = const(25)
|
|
|
|
class LastFailReason:
|
|
# 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. Includes both SS and MS cases.
|
|
# - now holding this in a setting so they can power-cycle and bypass to view
|
|
|
|
@classmethod
|
|
def record(cls, msg):
|
|
settings.put('lfr', msg)
|
|
|
|
@classmethod
|
|
def get(cls):
|
|
return settings.get('lfr', None)
|
|
|
|
@classmethod
|
|
def clear(cls):
|
|
settings.remove_key('lfr')
|
|
|
|
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
|
|
|
|
def __init__(self, nvkey, pol_dict=None):
|
|
# deserialize and construct
|
|
#assert nvkey in { 'ccc', 'sssp' }
|
|
self.nvkey = nvkey
|
|
super().__init__()
|
|
|
|
if pol_dict is not None:
|
|
self.clear()
|
|
self.update(pol_dict.items())
|
|
else:
|
|
v = dict(settings.master_get(self.nvkey, {})).get('pol', None)
|
|
if v is not None:
|
|
self.update(v.items()) # mpy bugfix, when called with SpendingPolicy
|
|
|
|
|
|
def _save_policy(self, master_only=True):
|
|
# serialize the spending policy, save it
|
|
v = dict(settings.master_get(self.nvkey, {}))
|
|
v['pol'] = self.copy()
|
|
settings.master_set(self.nvkey, v, master_only=master_only)
|
|
|
|
def update_policy_key(self, _quiet=False, _master_only=True, **kws):
|
|
# Update a few elements of the spending policy
|
|
# - all changes are saved immediately (which is a little slow/visible)
|
|
if not _quiet:
|
|
dis.fullscreen("Saving...")
|
|
|
|
self.update(kws)
|
|
self._save_policy(_master_only)
|
|
|
|
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
|
|
try:
|
|
addr = c.render_address(txo.scriptPubKey)
|
|
except ValueError:
|
|
addr = str(b2a_hex(txo.scriptPubKey), 'ascii')
|
|
if addr not in wl:
|
|
raise SpendPolicyViolation("whitelist: " + addr)
|
|
|
|
# Web 2FA
|
|
# - slow, requires UX, and they might not achieve it...
|
|
# - wait until about to do signature
|
|
if pol.get('web2fa', False):
|
|
psbt.warnings.append((pol.nvkey.upper(), '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:
|
|
LastFailReason.record('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
|
|
LastFailReason.clear()
|
|
|
|
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
|
|
# allow update block_h from temporary seed
|
|
self.update_policy_key(_quiet=True, _master_only=False, block_h=psbt.lock_time)
|
|
|
|
class SSSPFeature:
|
|
# Using setting value "sssp"
|
|
|
|
@classmethod
|
|
def is_enabled(cls):
|
|
# can be test drive, or is feature enabled?
|
|
from pincodes import pa
|
|
return (pa.hobbled_mode == 2) or sssp_spending_policy('en')
|
|
|
|
@classmethod
|
|
def update_last_signed(cls, psbt):
|
|
# new PSBT has been completely signed successfully.
|
|
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')
|
|
|
|
@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.master_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:
|
|
LastFailReason.record(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().web2fa_challenge('Approve Transaction')
|
|
|
|
|
|
class CCCFeature:
|
|
# Using setting value "ccc"
|
|
|
|
@classmethod
|
|
def is_enabled(cls):
|
|
# Is the feature enabled right now?
|
|
return bool(settings.get('ccc', False))
|
|
|
|
@classmethod
|
|
def words_check(cls, words):
|
|
# Test if words provided are right
|
|
try:
|
|
# a2b_words with checksum check
|
|
enc = seed_words_to_encoded_secret(words)
|
|
except:
|
|
return False
|
|
|
|
exp = cls.get_encoded_secret()
|
|
return enc == exp
|
|
|
|
@classmethod
|
|
def get_num_words(cls):
|
|
# return 12 or 24
|
|
return SecretStash.is_words(cls.get_encoded_secret())
|
|
|
|
@classmethod
|
|
def get_encoded_secret(cls):
|
|
# Gets the key C as encoded binary secret, compatible w/
|
|
# encodings used in stash.
|
|
return deserialize_secret(settings.get('ccc')['secret'])
|
|
|
|
@classmethod
|
|
def get_xfp(cls):
|
|
# Just the XFP value for our key C
|
|
ccc = settings.get('ccc')
|
|
return ccc['c_xfp'] if ccc else None
|
|
|
|
@classmethod
|
|
def get_master_xpub(cls):
|
|
ccc = settings.get('ccc')
|
|
return ccc['c_xpub'] if ccc else None
|
|
|
|
@classmethod
|
|
def init_setup(cls, words):
|
|
# Encode 12 or 24 words into the secret to held as key C.
|
|
# - also capture XFP and XPUB for key C
|
|
# TODO: move to "storage locker"?
|
|
assert len(words) in (12, 24)
|
|
enc = seed_words_to_encoded_secret(words)
|
|
_,_,node = SecretStash.decode(enc)
|
|
|
|
chain = chains.current_chain()
|
|
xfp = swab32(node.my_fp())
|
|
xpub = chain.serialize_public(node) # fully useless value tho
|
|
|
|
# NOTE: b_xfp and b_xpub still needed, but that's another step, not yet.
|
|
|
|
v = dict(secret=SecretStash.storage_serialize(enc),
|
|
c_xfp=xfp, c_xpub=xpub,
|
|
pol=CCCFeature.default_policy())
|
|
|
|
settings.put('ccc', v)
|
|
settings.save()
|
|
|
|
@classmethod
|
|
def default_policy(cls):
|
|
# a very basic and permissive policy, but non-zero too.
|
|
# - 1BTC per day
|
|
chain = chains.current_chain()
|
|
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 SpendingPolicy('ccc')
|
|
|
|
@classmethod
|
|
def remove_ccc(cls):
|
|
# delete our settings complete; lose key C .. already confirmed
|
|
# - leave MS in place
|
|
settings.remove_key('ccc')
|
|
settings.save()
|
|
|
|
@classmethod
|
|
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)
|
|
if not cls.is_enabled():
|
|
return False, False
|
|
|
|
ms = psbt.active_multisig
|
|
if not ms:
|
|
# 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,
|
|
# don't try to sign; maybe show warning?
|
|
|
|
xfp = cls.get_xfp()
|
|
if xfp not in ms.xfp_paths:
|
|
# does not involve us
|
|
return False, False
|
|
|
|
try:
|
|
# check policy
|
|
pol = cls.get_policy()
|
|
needs_2fa = pol.meets_policy(psbt)
|
|
except SpendPolicyViolation as e:
|
|
LastFailReason.record(str(e))
|
|
psbt.warnings.append(('CCC', "Violates spending policy. Won't sign."))
|
|
return False, False
|
|
|
|
return True, needs_2fa
|
|
|
|
@classmethod
|
|
def sign_psbt(cls, psbt):
|
|
# do the math
|
|
psbt.sign_it(cls.get_encoded_secret(), cls.get_xfp())
|
|
LastFailReason.clear()
|
|
|
|
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):
|
|
# handle integer bitcoins, and satoshis in same value
|
|
if mag < 1000:
|
|
return '%d BTC' % mag
|
|
else:
|
|
return '%d SATS' % mag
|
|
|
|
|
|
class CCCConfigMenu(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 = [
|
|
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(CCCFeature.get_policy())),
|
|
MenuItem('Export CCC XPUBs', f=self.export_xpub_c),
|
|
MenuItem('Multisig Wallets'),
|
|
]
|
|
|
|
# look for wallets that are defined related to CCC feature, shortcut to them
|
|
count = 0
|
|
for ms in MultisigWallet.get_all():
|
|
if my_xfp in ms.xfp_paths:
|
|
items.append(MenuItem('↳ %d/%d: %s' % (ms.M, ms.N, ms.name),
|
|
menu=make_ms_wallet_menu, arg=ms.storage_idx))
|
|
count += 1
|
|
|
|
items.append(MenuItem('↳ Build 2-of-N', f=self.build_2ofN, arg=count))
|
|
|
|
if LastFailReason.get():
|
|
# xxxxxxxxxxxxxxxx
|
|
items.insert(1, MenuItem('Last Violation', f=self.debug_last_fail))
|
|
|
|
items.append(MenuItem('Load Key C', f=self.enter_temp_mode))
|
|
items.append(MenuItem('Remove CCC', f=self.remove_ccc))
|
|
|
|
return items
|
|
|
|
async def debug_last_fail(self, *a):
|
|
# debug for customers: why did we reject that last txn?
|
|
c = chains.current_chain()
|
|
def_bh = c.ccc_min_block
|
|
pol = CCCFeature.get_policy()
|
|
bh = pol.get('block_h', None)
|
|
bh_clear = ''
|
|
msg = ''
|
|
escape = "4"
|
|
if bh is not None:
|
|
msg += 'CCC height:\n\n%s\n\n' % bh
|
|
if bh != def_bh:
|
|
bh_clear = 'Press (1) to clear block height. '
|
|
escape += "1"
|
|
|
|
lfr = LastFailReason.get()
|
|
msg += ('The most recent policy check failed because of:\n\n%s\n\n'
|
|
'%sPress (4) to clear last fail reason.' % (lfr, bh_clear))
|
|
ch = await ux_show_story(msg, escape=escape)
|
|
|
|
if ch == '4':
|
|
LastFailReason.clear()
|
|
self.update_contents()
|
|
elif ch == '1':
|
|
if await ux_confirm("Reset block height to default value %d for %s?" % (def_bh, c.name)):
|
|
pol.update_policy_key(_quiet=True, _master_only=False, block_h=def_bh)
|
|
|
|
|
|
|
|
async def remove_ccc(self, *a):
|
|
# disable and remove feature
|
|
if not await ux_confirm('Key C will be lost, and policy settings forgotten.'
|
|
' This unit will only be able to partly sign transactions.'
|
|
' To completely remove this wallet, proceed to the multisig'
|
|
' menu and remove related wallet entries.'):
|
|
return
|
|
|
|
if not await ux_confirm("Funds in related wallet/s may be impacted.", confirm_key='4'):
|
|
return await ux_aborted()
|
|
|
|
CCCFeature.remove_ccc()
|
|
the_ux.pop()
|
|
|
|
async def on_cancel(self):
|
|
# trying to exit from CCCConfigMenu
|
|
from seed import in_seed_vault
|
|
|
|
enc = CCCFeature.get_encoded_secret()
|
|
|
|
if in_seed_vault(enc):
|
|
# remind them to clear the seed-vault copy of Key C because it defeats feature
|
|
await ux_show_story("Key C is in your Seed Vault. If you are done with setup, "
|
|
"you MUST delete it from the Vault!", title='REMINDER')
|
|
|
|
the_ux.pop()
|
|
|
|
async def export_xpub_c(self, *a):
|
|
# do standard Coldcard export for multisig setups
|
|
xfp = CCCFeature.get_xfp()
|
|
enc = CCCFeature.get_encoded_secret()
|
|
|
|
from multisig import export_multisig_xpubs
|
|
await export_multisig_xpubs(xfp=xfp, alt_secret=enc, skip_prompt=True)
|
|
|
|
async def build_2ofN(self, m, l, i):
|
|
count = i.arg
|
|
# ask for a key B, assume A and C are defined => export MS config and import into self.
|
|
# - like the airgap setup, but assume A and C are this Coldcard
|
|
m = '''Builds simple 2-of-N multisig wallet, with this Coldcard's main secret (key A), \
|
|
the CCC policy-controlled key C, and at least one other device, as key B. \
|
|
\nYou will need to export the XPUB from another Coldcard and place it on an SD Card, or \
|
|
be ready to show it as a QR, before proceeding.'''
|
|
if await ux_show_story(m) != 'y':
|
|
return
|
|
|
|
from multisig import create_ms_step1
|
|
|
|
# picks addr fmt, QR or not, gets at least one file, then...
|
|
await create_ms_step1(for_ccc=(CCCFeature.get_encoded_secret(), count))
|
|
|
|
# prompt for file, prompt for our acct number, unless already exported to this card?
|
|
|
|
async def show_ident(self, *a):
|
|
# give some background? or just KISS for now?
|
|
xfp = xfp2str(CCCFeature.get_xfp())
|
|
xpub = CCCFeature.get_master_xpub()
|
|
await ux_show_story(
|
|
"Key C:\n\n"
|
|
"XFP (Master Fingerprint):\n\n %s\n\n"
|
|
"Master Extended Public Key:\n\n %s " % (xfp, xpub))
|
|
|
|
async def enter_temp_mode(self, *a):
|
|
# apply key C as temp seed, so you can do anything with it
|
|
# - just a shortcut, since they have the words, and could enter them
|
|
# - one-way trip because the CCC feature won't be enabled inside the temp seed settings
|
|
if await ux_show_story(
|
|
'Loads the CCC controlled seed (key C) as a Temporary Seed and allows '
|
|
'easy use of all Coldcard features on that key.\n\nIf you save into Seed Vault, '
|
|
'access to CCC Config menu is quick and easy.') != 'y':
|
|
return
|
|
|
|
from seed import set_ephemeral_seed
|
|
from actions import goto_top_menu
|
|
|
|
enc = CCCFeature.get_encoded_secret()
|
|
await set_ephemeral_seed(enc, origin='Key C from CCC')
|
|
|
|
goto_top_menu()
|
|
|
|
|
|
class SPAddrWhitelist(MenuSystem):
|
|
# simulator arg: --seq tcENTERENTERsENTERwENTER
|
|
def __init__(self, pol):
|
|
self.policy = pol
|
|
items = self.construct()
|
|
super().__init__(items)
|
|
|
|
def update_contents(self):
|
|
tmp = self.construct()
|
|
self.replace_items(tmp)
|
|
|
|
@classmethod
|
|
async def be_a_submenu(cls, pol, *a):
|
|
return cls(pol)
|
|
|
|
def construct(self):
|
|
# list of addresses
|
|
addrs = self.policy.get('addrs', [])
|
|
maxxed = (len(addrs) >= MAX_WHITELIST)
|
|
|
|
items = []
|
|
# better to show usability options at the top, as we can have up to 25 addresses in the menu
|
|
if version.has_qr:
|
|
items.append(MenuItem('Scan QR', f=(self.maxed_out if maxxed else self.scan_qr),
|
|
shortcut=KEY_QR))
|
|
|
|
items.append(MenuItem('Import from File',
|
|
f=(self.maxed_out if maxxed else self.import_file)))
|
|
|
|
# show most recent added addresses at the top of the menu list
|
|
a_items = [MenuItem(truncate_address(a), f=self.edit_addr, arg=a) for a in addrs[::-1]]
|
|
|
|
if a_items:
|
|
items += a_items
|
|
if len(a_items) > 1:
|
|
items.append(MenuItem("Clear Whitelist", f=self.clear_all))
|
|
else:
|
|
items.append(MenuItem("(none yet)"))
|
|
|
|
return items
|
|
|
|
async def edit_addr(self, menu, idx, item):
|
|
# show detail and offer delete
|
|
addr = item.arg
|
|
msg = ('Spends to this address will be permitted:\n\n%s'
|
|
'\n\nPress (4) to delete.' % show_single_address(addr))
|
|
ch = await ux_show_story(msg, escape='4')
|
|
if ch == '4':
|
|
self.delete_addr(addr)
|
|
|
|
def delete_addr(self, addr):
|
|
# no confirm, stakes are low
|
|
addrs = self.policy.get('addrs', [])
|
|
addrs.remove(addr)
|
|
self.policy.update_policy_key(addrs=addrs)
|
|
self.update_contents()
|
|
|
|
async def clear_all(self, *a):
|
|
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):
|
|
# Import from a file, or NFC.
|
|
# - simulator: --seq tcENTERENTERsENTERwENTERiENTER1
|
|
# - very forgiving, does not care about file format
|
|
# - but also silent on all errors
|
|
from ux import import_export_prompt
|
|
from glob import NFC
|
|
from actions import file_picker
|
|
from files import CardSlot
|
|
from utils import cleanup_payment_address
|
|
|
|
choice = await import_export_prompt("List of addresses", is_import=True, no_qr=True)
|
|
|
|
if choice == KEY_CANCEL:
|
|
return
|
|
elif choice == KEY_NFC:
|
|
res = await NFC.read_address()
|
|
if not res:
|
|
# error already displayed in nfc.py
|
|
return
|
|
|
|
_, addr, _ = res
|
|
await self.add_addresses([addr])
|
|
return
|
|
|
|
# loose RE to match any group of chars that could be addresses
|
|
# - really just removing whitespace and punctuation
|
|
# - lacking re.findall(), so using re.split() on negatives
|
|
pat = re.compile(r'[^A-Za-z0-9]')
|
|
|
|
# pick a likely-looking file: just looking at size and extension
|
|
fn = await file_picker(suffix=['.csv', '.txt'],
|
|
min_size=20, max_size=20000,
|
|
none_msg="Must contain payment addresses", **choice)
|
|
|
|
if not fn: return
|
|
|
|
results = []
|
|
with CardSlot(readonly=True, **choice) as card:
|
|
with open(fn, 'rt') as fd:
|
|
for ln in fd.readlines():
|
|
if len(results) >= MAX_WHITELIST:
|
|
# no need to clog memory and parse more, we're done
|
|
break
|
|
for here in pat.split(ln):
|
|
if len(here) >= 4:
|
|
try:
|
|
addr = cleanup_payment_address(here)
|
|
results.append(addr)
|
|
except: pass
|
|
|
|
if not results:
|
|
await ux_show_story("Unable to find any payment addresses in that file.")
|
|
else:
|
|
# silently limit to first 25 results; lets them use addresses.csv easily
|
|
await self.add_addresses(results[:MAX_WHITELIST])
|
|
|
|
|
|
async def scan_qr(self, *a):
|
|
# Scan and return a text string. For things like BIP-39 passphrase
|
|
# and perhaps they are re-using a QR from something else. Don't act on contents.
|
|
from ux_q1 import QRScannerInteraction
|
|
q = QRScannerInteraction()
|
|
|
|
got = []
|
|
ln = ''
|
|
while 1:
|
|
here = await q.scan_for_addresses("Bitcoin Address(es) to Whitelist", line2=ln)
|
|
if not here: break
|
|
for addr in here:
|
|
if addr not in got:
|
|
got.append(addr)
|
|
ln = 'Got %d so far. ENTER to apply.' % len(got)
|
|
|
|
if got:
|
|
# import them
|
|
await self.add_addresses(got)
|
|
|
|
async def maxed_out(self, *a):
|
|
await ux_show_story("Max %d items in whitelist. Please make room first." % MAX_WHITELIST)
|
|
|
|
async def add_addresses(self, more_addrs):
|
|
# add new entries, if unique; preserve ordering
|
|
# - work on a copy and check the limit *before* committing: the list
|
|
# from get('addrs') is the live, settings-backed one
|
|
addrs = list(self.policy.get('addrs', []))
|
|
new = []
|
|
for a in more_addrs:
|
|
if a not in addrs and a not in new:
|
|
new.append(a)
|
|
|
|
if not new:
|
|
await ux_show_story("Already in whitelist:\n\n" +
|
|
'\n\n'.join(show_single_address(a) for a in more_addrs))
|
|
return
|
|
|
|
if len(addrs) + len(new) > MAX_WHITELIST:
|
|
return await self.maxed_out()
|
|
|
|
self.policy.update_policy_key(addrs=addrs + new)
|
|
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]))
|
|
|
|
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.
|
|
# - 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, pol):
|
|
self.policy = pol
|
|
items = self.construct()
|
|
super().__init__(items)
|
|
|
|
def update_contents(self):
|
|
tmp = self.construct()
|
|
self.replace_items(tmp)
|
|
|
|
@classmethod
|
|
async def be_a_submenu(cls, pol, *a):
|
|
return cls(pol)
|
|
|
|
def construct(self):
|
|
items = [
|
|
# xxxxxxxxxxxxxxxx
|
|
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'):
|
|
items.extend([
|
|
MenuItem('↳ Test 2FA', f=self.test_2fa),
|
|
MenuItem('↳ Enroll More', f=self.enroll_more_2fa),
|
|
])
|
|
|
|
return items
|
|
|
|
async def test_2fa(self, *a):
|
|
ss = self.policy.get('web2fa')
|
|
assert 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, but they get same shared secret
|
|
ss = self.policy.get('web2fa')
|
|
assert ss
|
|
await web2fa.web2fa_enroll(ss)
|
|
|
|
async def set_magnitude(self, *a):
|
|
# Looks decent on both Q and Mk4...
|
|
was = self.policy.get('mag', 0)
|
|
val = await ux_enter_number('Transaction Max:', max_value=int(1e8),
|
|
value=(was or ''))
|
|
if val is None: return
|
|
|
|
args = dict(mag=val)
|
|
msg = "Did not change" if val == was else "You have set the"
|
|
|
|
if not val:
|
|
msg = "No check for maximum transaction size will be done. "
|
|
if self.policy.get('vel', 0):
|
|
msg += 'Velocity check also disabled. '
|
|
args['vel'] = 0
|
|
else:
|
|
msg += " maximum per-transaction: \n\n %s" % render_mag_value(val)
|
|
|
|
self.policy.update_policy_key(**args)
|
|
|
|
await ux_show_story(msg, title="TX Magnitude")
|
|
|
|
async def set_velocity(self, *a):
|
|
mag = self.policy.get('mag', 0) or 0
|
|
|
|
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.update_policy_key(mag=1)
|
|
|
|
await ux_show_story(msg)
|
|
|
|
start_chooser(self.velocity_chooser)
|
|
|
|
|
|
def velocity_chooser(self):
|
|
# offer some useful values from a menu
|
|
vel = self.policy.get('vel', 0) # in blocks
|
|
|
|
# xxxxxxxxxxxxxxxx
|
|
ch = [ 'Unlimited',
|
|
'6 blocks (hour)',
|
|
'24 blocks (4h)',
|
|
'48 blocks (8h)',
|
|
'72 blocks (12h)',
|
|
'144 blocks (day)',
|
|
'288 blocks (2d)',
|
|
'432 blocks (3d)',
|
|
'720 blocks (5d)',
|
|
'1008 blocks (1w)',
|
|
'2016 blocks (2w)',
|
|
'3024 blocks (3w)',
|
|
'4032 blocks (4w)',
|
|
]
|
|
va = [0] + [int(x.split()[0]) for x in ch[1:]]
|
|
|
|
try:
|
|
which = va.index(vel)
|
|
except ValueError:
|
|
which = 0
|
|
|
|
def set(idx, text):
|
|
self.policy.update_policy_key(vel=va[idx])
|
|
|
|
return which, ch, set
|
|
|
|
async def toggle_2fa(self, *a):
|
|
if self.policy.get('web2fa'):
|
|
# enabled already
|
|
|
|
if not await ux_confirm("Disable web 2FA check? Effect is immediate."):
|
|
return
|
|
|
|
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 "
|
|
"secret will be generated, so it is safe to remove it from your "
|
|
"phone at this point.")
|
|
|
|
return
|
|
|
|
ch = await ux_show_story('''When enabled, any spend (signing) requires \
|
|
use of mobile 2FA application (TOTP RFC-6238). Shared-secret is picked now, \
|
|
and loaded on your phone via QR code.
|
|
|
|
WARNING: You will not be able to sign transactions if you do not have an NFC-enabled \
|
|
phone with Internet access and 2FA app holding correct shared-secret.''',
|
|
title="Web 2FA")
|
|
if ch != 'y':
|
|
return
|
|
|
|
# challenge them, and don't set unless it works
|
|
ss = await web2fa.web2fa_enroll()
|
|
if not ss:
|
|
return
|
|
|
|
# update state
|
|
self.policy.update_policy_key(web2fa=ss)
|
|
self.update_contents()
|
|
|
|
async def gen_or_import():
|
|
# returns 12 words, or None to abort
|
|
from seed import WordNestMenu, generate_seed, approve_word_list, SeedVaultChooserMenu
|
|
|
|
msg = "Press %s to generate a new 12-word seed phrase to be used "\
|
|
"as the Coldcard Co-Signing Secret (key C).\n\nOr press (1) to import existing "\
|
|
"12-words or (2) for 24-words import." % OK
|
|
|
|
if settings.master_get("seedvault", False):
|
|
msg += ' Press (6) to import from Seed Vault.'
|
|
|
|
ch = await ux_show_story(msg, escape='126', title="CCC Key C")
|
|
|
|
if ch in '12':
|
|
nwords = 24 if ch == '2' else 12
|
|
|
|
async def done_key_C_import(words):
|
|
if not version.has_qwerty:
|
|
WordNestMenu.pop_all()
|
|
await enable_step1(words)
|
|
|
|
if version.has_qwerty:
|
|
from ux_q1 import seed_word_entry
|
|
await seed_word_entry('Key C Seed Words', nwords, done_cb=done_key_C_import)
|
|
else:
|
|
nxt = WordNestMenu(nwords, done_cb=done_key_C_import)
|
|
the_ux.push(nxt)
|
|
|
|
return None # will call parent again
|
|
|
|
elif ch == '6':
|
|
# pick existing from Seed Vault
|
|
picked = await SeedVaultChooserMenu.pick(words_only=True)
|
|
if picked:
|
|
words = SecretStash.decode_words(deserialize_secret(picked.encoded))
|
|
await enable_step1(words)
|
|
|
|
return None
|
|
|
|
elif ch == 'y':
|
|
# normal path: pick 12 words, quiz them
|
|
await ux_dramatic_pause('Generating...', 3)
|
|
seed = generate_seed()
|
|
words = await approve_word_list(seed, 12)
|
|
else:
|
|
return None
|
|
|
|
return words
|
|
|
|
|
|
async def toggle_ccc_feature(*a):
|
|
# The only menu item show to user!
|
|
if settings.get('ccc'):
|
|
return await modify_ccc_settings()
|
|
|
|
# enable the feature -- not simple!
|
|
# - create C key (maybe import?)
|
|
# - collect a policy setup, maybe 2FA enrol too
|
|
# - lock that down
|
|
# - TODO copy
|
|
ch = await ux_show_story('''\
|
|
Adds an additional seed to your Coldcard, and enforces a "spending policy" whenever \
|
|
it signs with that key. Spending policies can restrict: magnitude (BTC out), \
|
|
velocity (blocks between txn), address whitelisting, and/or require confirmation by 2FA phone app.
|
|
|
|
Assuming the use of a 2-of-3 multisig wallet, keys are as follows:\n
|
|
A=Coldcard (master seed), B=Backup Key (offline/recovery), C=Spending Policy Key.
|
|
|
|
Spending policy cannot be viewed or changed without knowledge of key C.\
|
|
''',
|
|
title="Coldcard Co-Signing" if version.has_qwerty else 'CC Co-Sign')
|
|
|
|
if ch != 'y':
|
|
# just a tourist
|
|
return
|
|
|
|
await enable_step1(None)
|
|
|
|
async def enable_step1(words):
|
|
if not words:
|
|
words = await gen_or_import()
|
|
if not words: return
|
|
|
|
dis.fullscreen("Wait...")
|
|
dis.busy_bar(True)
|
|
try:
|
|
# do BIP-32 basics: capture XFP and XPUB and encoded version of the secret
|
|
CCCFeature.init_setup(words)
|
|
finally:
|
|
dis.busy_bar(False)
|
|
|
|
# continue into config menu
|
|
m = CCCConfigMenu()
|
|
|
|
the_ux.push(m)
|
|
|
|
async def modify_ccc_settings():
|
|
# Generally not expecting changes to policy on the fly because
|
|
# that's the whole point. Use the B key to override individual spends
|
|
# but if you can prove you have C key, then it's harmless to allow changes
|
|
# since you could just spend as needed.
|
|
|
|
enc = CCCFeature.get_encoded_secret()
|
|
bypass = False
|
|
|
|
from seed import in_seed_vault
|
|
if in_seed_vault(enc):
|
|
# If seed vault enabled and they have the key C in there already, just go
|
|
# directly into menu (super helpful for debug/setup/testing time). We do warn tho.
|
|
await ux_show_story('''You have a copy of the CCC key C in the Seed Vault, so \
|
|
you may proceed to change settings now.\n\nYou must delete that key from the vault once \
|
|
setup and debug is finished, or all benefit of this feature is lost!''', title='REMINDER')
|
|
|
|
bypass = True
|
|
|
|
else:
|
|
ch = await ux_show_story(
|
|
"Spending policy cannot be viewed, changed nor disabled, "
|
|
"unless you have the seed words for key C.",
|
|
title="CCC Enabled")
|
|
|
|
if ch != 'y': return
|
|
|
|
if bypass:
|
|
# doing full decode cycle here for better testing
|
|
chk, raw, _ = SecretStash.decode(enc)
|
|
assert chk == 'words'
|
|
words = bip39.b2a_words(raw).split(' ')
|
|
await key_c_challenge(words)
|
|
return
|
|
|
|
# small info-leak here: exposing 12 vs 24 words, but we expect most to be 12 anyway
|
|
nwords = CCCFeature.get_num_words()
|
|
|
|
import seed
|
|
if version.has_qwerty:
|
|
from ux_q1 import seed_word_entry
|
|
await seed_word_entry('Enter Seed Words', nwords, done_cb=key_c_challenge)
|
|
else:
|
|
return seed.WordNestMenu(nwords, done_cb=key_c_challenge)
|
|
|
|
NUM_CHALLENGE_FAILS = 0
|
|
|
|
async def key_c_challenge(words):
|
|
# They entered some words, if they match our key C then allow edit of policy
|
|
|
|
if not version.has_qwerty:
|
|
from seed import WordNestMenu
|
|
WordNestMenu.pop_all()
|
|
|
|
dis.fullscreen('Verifying...')
|
|
|
|
if not CCCFeature.words_check(words):
|
|
# keep an in-memory counter, and after 3 fails, reboot
|
|
global NUM_CHALLENGE_FAILS
|
|
NUM_CHALLENGE_FAILS += 1
|
|
if NUM_CHALLENGE_FAILS >= 3:
|
|
from utils import clean_shutdown
|
|
clean_shutdown()
|
|
|
|
await ux_show_story("Sorry, those words are incorrect.")
|
|
return
|
|
|
|
# success. they are in.
|
|
|
|
# got to config menu
|
|
m = CCCConfigMenu()
|
|
the_ux.push(m)
|
|
|
|
def sssp_spending_policy(key, default=False, set_value=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.master_get('sssp', dict())
|
|
|
|
if key in { 'en', 'notes', 'words', 'okeys' }:
|
|
# booleans: present or removed from dict
|
|
if set_value is not None:
|
|
if set_value:
|
|
v[key] = True
|
|
else:
|
|
v.pop(key, None)
|
|
|
|
settings.master_set('sssp', v, master_only=True)
|
|
|
|
return (key in v) or default
|
|
|
|
raise KeyError(key)
|
|
|
|
|
|
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
|
|
|
|
if pa.hobbled_mode == 2:
|
|
# allow exit from test-drive mode, directly into editing settings
|
|
pa.hobbled_mode = False
|
|
goto_top_menu()
|
|
elif settings.master_get('sssp'):
|
|
# 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
|
|
|
|
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 version.has_qwerty else "Spend 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:
|
|
have = tp.all_tricks()
|
|
main_pin = 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
|
|
|
|
dis.fullscreen("Saving...")
|
|
|
|
# quick checks - does not spot hidden trick pins
|
|
if (new_pin != main_pin) and (new_pin not in have):
|
|
# verify uniqueness within SE2
|
|
b, slot = tp.get_by_pin(new_pin)
|
|
if slot is None:
|
|
tp.define_unlock_pin(new_pin)
|
|
break
|
|
|
|
await tp.err_unique_pin(new_pin)
|
|
|
|
# all features disabled to start
|
|
settings.master_set('sssp', dict(en=False, pol={}), master_only=True)
|
|
|
|
# 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':
|
|
# they are using XPRV or something, skip test entirely
|
|
return
|
|
|
|
words = bip39.b2a_words(sv.raw).split(' ')
|
|
want_words = words[:1] + words[-1:]
|
|
assert len(want_words) == 2
|
|
|
|
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
|
|
got_words = await WordNestMenu.get_n_words(2)
|
|
|
|
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
|
|
await 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
|
|
|
|
# this can be slow, so show something
|
|
dis.fullscreen("Saving...")
|
|
sssp_spending_policy(self.polkey, set_value=(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):
|
|
items = [
|
|
# xxxxxxxxxxxxxxxx
|
|
MenuItem('Edit Policy...',
|
|
menu=lambda *a: SpendingPolicyMenu.be_a_submenu(SSSPFeature.get_policy())),
|
|
SSSPCheckedMenuItem('Word Check', 'words', 'To change Spending Policy, in addition to special PIN, you must provide the first and last seed words.'),
|
|
SSSPCheckedMenuItem('Allow Notes', 'notes', 'Allow (read-only) access to secure notes and passwords? Otherwise, they are inaccessible.', predicate=version.has_qwerty),
|
|
SSSPCheckedMenuItem('Related Keys', 'okeys', 'Allow access to BIP-39 passphrase wallets based on master seed, and Seed Vault (read-only). Single Spending Policy applies to all.'),
|
|
#MenuItem('Test Word Challenge', f=sssp_word_challenge), # XXX test only?
|
|
]
|
|
|
|
if LastFailReason.get():
|
|
# 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
|
|
dis.fullscreen("Saving...")
|
|
sssp_spending_policy('en', set_value=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 = SSSPFeature.get_policy()
|
|
bh = pol.get('block_h', None)
|
|
msg = ''
|
|
if bh:
|
|
msg += "Last height:\n\n%s\n\n" % bh
|
|
|
|
lfr = LastFailReason.get()
|
|
msg += 'The most recent policy check failed because of:\n\n%s\n\nPress (4) to clear.' \
|
|
% lfr
|
|
ch = await ux_show_story(msg, escape='4')
|
|
|
|
if ch == '4':
|
|
LastFailReason.clear()
|
|
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
|