890 lines
31 KiB
Python
890 lines
31 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.
|
|
#
|
|
import gc, chains, version, ngu, web2fa, bip39, re
|
|
from chains import NLOCK_IS_TIME
|
|
from utils import swab32, xfp2str, truncate_address, deserialize_secret, show_single_address
|
|
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 CCCPolicyViolationError
|
|
|
|
|
|
# limit to number of addresses in list
|
|
MAX_WHITELIST = const(25)
|
|
|
|
class CCCFeature:
|
|
|
|
# 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 = ""
|
|
|
|
@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
|
|
enc = seed_words_to_encoded_secret(words)
|
|
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 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)
|
|
|
|
@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 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):
|
|
# 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:
|
|
# single-sig CCC not supported
|
|
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
|
|
needs_2fa = cls.meets_policy(psbt)
|
|
except CCCPolicyViolationError as e:
|
|
cls.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 = ""
|
|
|
|
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()
|
|
|
|
|
|
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 = [
|
|
# xxxxxxxxxxxxxxxx
|
|
MenuItem('CCC [%s]' % xfp2str(my_xfp), f=self.show_ident),
|
|
MenuItem('Spending Policy', menu=CCCPolicyMenu.be_a_submenu),
|
|
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 CCCFeature.last_fail_reason:
|
|
# 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?
|
|
pol = CCCFeature.get_policy()
|
|
bh = pol.get('block_h', None)
|
|
msg = ''
|
|
if bh:
|
|
msg += "CCC height:\n\n%s\n\n" % bh
|
|
|
|
msg += 'The most recent policy check failed because of:\n\n%s\n\nPress (4) to clear.' \
|
|
% CCCFeature.last_fail_reason
|
|
ch = await ux_show_story(msg, escape='4')
|
|
|
|
if ch == '4':
|
|
CCCFeature.last_fail_reason = ''
|
|
self.update_contents()
|
|
|
|
async def remove_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 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):
|
|
# simulator arg: --seq tcENTERENTERsENTERwENTER
|
|
def __init__(self):
|
|
items = self.construct()
|
|
super().__init__(items)
|
|
|
|
def update_contents(self):
|
|
tmp = self.construct()
|
|
self.replace_items(tmp)
|
|
|
|
@classmethod
|
|
async def be_a_submenu(cls, *a):
|
|
return cls()
|
|
|
|
def construct(self):
|
|
# list of addresses
|
|
addrs = CCCFeature.get_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 = CCCFeature.get_policy().get('addrs', [])
|
|
addrs.remove(addr)
|
|
CCCFeature.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=[])
|
|
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:
|
|
addr = await NFC.read_address()
|
|
if not addr:
|
|
# error already displayed in nfc.py
|
|
return
|
|
|
|
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
|
|
addrs = CCCFeature.get_policy().get('addrs', [])
|
|
new = []
|
|
for a in more_addrs:
|
|
if a not in addrs:
|
|
addrs.append(a)
|
|
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) > MAX_WHITELIST:
|
|
return await self.maxed_out()
|
|
|
|
CCCFeature.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]))
|
|
|
|
class CCCPolicyMenu(MenuSystem):
|
|
# Build menu stack that allows edit of all features of the spending
|
|
# policy. 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()
|
|
items = self.construct()
|
|
super().__init__(items)
|
|
|
|
def update_contents(self):
|
|
tmp = self.construct()
|
|
self.replace_items(tmp)
|
|
|
|
@classmethod
|
|
async def be_a_submenu(cls, *a):
|
|
return cls()
|
|
|
|
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),
|
|
]
|
|
|
|
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('CCC Test', 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
|
|
ss = self.policy.get('web2fa')
|
|
assert ss
|
|
await web2fa.web2fa_enroll('CCC', 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),
|
|
can_cancel=True, value=(was or ''))
|
|
|
|
args = dict(mag=val)
|
|
if (val is None) or (val == was):
|
|
msg = "Did not change"
|
|
val = was
|
|
else:
|
|
msg = "You have set the"
|
|
unchanged = False
|
|
|
|
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 = CCCFeature.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 = CCCFeature.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
|
|
|
|
# reminder: dont forget the poor Mk4 users
|
|
# 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 = CCCFeature.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 = CCCFeature.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('CCC')
|
|
if not ss:
|
|
return
|
|
|
|
# update state
|
|
self.policy = CCCFeature.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)
|
|
|
|
# EOF
|