Merge pull request #473 from scgbckbone/ccc

ColdCard Cosign = CCC
This commit is contained in:
doc-hex 2025-02-26 09:48:18 -05:00 committed by GitHub
commit 6b9e2ef9b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 2777 additions and 302 deletions

View File

@ -198,3 +198,16 @@ We will summarize transaction outputs as "change" back into same wallet, however
- if you have an XFP collision between multiple wallets in SeedVault (ie. two wallets
with same descriptors, but different seeds) you will get false negatives
# CCC Feature (ColdCard Cosigning)
- only 12 or 24 word seeds (not XPRV) are accepted for "key C"
- velocy limit:
- based on a max magnitude per txn, and a required minimum block height
gap, based on previous `nLockTime` value in last-signed PSBT.
- if you sign a transaction, but never broadcast it, you will still have to wait out
the velocity policy.
- PSBT creator must put in `nLockTime` block heights (most already do to avoid fee sniping)
- maximum of 25 whitelisted addresses can be stored
- Web2FA: any number of mobile devices can be enrolled, but all will have the same shared secret
- any warning from the PSBT, such as huge fees, will prevent CCC cosign.

90
docs/web2fa.md Normal file
View File

@ -0,0 +1,90 @@
# Web 2FA Authentication
How to support [RFC 6238](https://www.rfc-editor.org/rfc/rfc6238)
TOTP (Time based One Time Password) 2FA check, on our little embedded
device without a real-time clock?
Solution: Store the pre-shared secret in the Coldcard, and send that
securely to a trusted webserver which knows the time and can do a
fancy UX. The backend accepts the numeric code and reveals a secret
that can be used back on the Coldcard to authorize an action.
For the Mk4, the secret is 8 digit numeric code to be entered,
for the COLDCARD Q, it is a QR code to be scanned.
### History / Background
The HSM feature uses HOTP tokens, which do not require a backend,
but are not as robust as time-based tokens.
For now Web2FA is only being used as part of CCC spending policy (opt in),
but we may find other uses for it.
## How It Works
- Web backend has a ECC keypair, with pubkey known to CC firmware releases.
- Usual 2fa base32 secret is picked by CC and stored in CC (so that server is stateless)
- CC creates URL encrypted to the pubkey of server, containing args:
- shared secret for TOTP (same value as held in user's phone)
- the response nonce (16 bytes, or 8 digits for Mk4) to be revealed to the user
on successful auth
- flag if Q model, so can provide a QR to be scanned in that case (rather than digits)
- some text label for what's being approved, which is presented to user so they can pick
correct 2fa shared secret.
- above is all encrypted in transit, and only the server can decrypt
- user is sent to that encrypted URL using NFC tap on the Coldcard
- user arrives at server:
- shown label [which also indicates the server can be trusted, since only it could decrypt it]
- prompt for 6 digits from authenticator app
- does [RFC 6238](https://www.rfc-editor.org/rfc/rfc6238) 2FA check using current time
- checks using current time and the shared secret provided by CC, fails if wrong.
- time based failure: offer retry (they typed too slow / minor clock drift)
- can offer to retry, but also do some rate limiting (only one attempt per 30-sec period)
- server will store very recent responses so attacker cannot get two codes
in any 30sec period (ie. blocks immediate reuse of same URL)
- until a valid code is given, user is stuck here
- when valid token received:
- if Q, show a QR code to be scanned, with the full nonce
- for non-Q system, a 8-digit decimal value is given: user has to enter that into the Coldcard
- web site shows instructions about what to do next on product.
## From Coldcard PoV
- makes complex encrypted URL, which contains a nonce it wants, waits for that nonce back (or QR)
- it's either the nonce from the URL, or fail
- if the right nonce, then we know the server knows the decryption key, and we
are trusting it actually verify the 2FA token properly.
## Encryption - Simple ECDH
- CC picks a secp256k1 keypair, generates compressed pubkey
- multiplies that private key by server's known public key
- apply sha256(resulting coordinate) => the session key
- apply AES-256-CTR over URL contents (ascii text)
- prepend 33 bytes of pubkey, and base64url encode all of it
- full url is: `https://coldcard.com/2fa?{base64 encoded binary}`
## Trust Issues
- 2FA enrol happens on the CC, which picks the shared secret and shows QR for mobile
app setup. Same TRNG process as picking a seed.
- Server knows the shared secret, but only during operation, and we won't store it [sorry,
gotta trust us on that, but no help to us to store it].
- Only we can run the server, because the private key is company-secret.
- MiTM and network snoopers get nothing because HTTPS is used and only your browser
can see the nonce, and only after you've given the right digits.
- Coinkite server could skip the 2FA checks and just give you the answer
you want to type into the Coldcard. Again, you have to trust us on that.
## URL Format
https://coldcard.com/2fa?ss={shared_secret}&q={is_q}&g={nonce}&nm={label_text}
- `shared_secret`: 16 chars of Base32-encoded pre-shared secret
- `is_q`: flag indicating use of QR to provide nonce back to user
- `nonce`: text string that is either 8 digits for Mk4, or hex digits for QR
- `nm`: human readable label for the transaction/purpose
Server will accept plaintext arguments as above, but normally everything
after the question mark is encrypted.

View File

@ -584,12 +584,10 @@ async def clear_seed(*a):
'Saved temporary seed settings and Seed Vault are lost.'):
return await ux_aborted()
ch = await ux_show_story('''Are you REALLY sure though???\n\n\
if not await ux_confirm('''Are you REALLY sure though???\n\n\
This action will certainly cause you to lose all funds associated with this wallet, \
unless you have a backup of the seed words and know how to import them into a \
new wallet.\n\nPress (4) to prove you read to the end of this message and accept all \
consequences.''', escape='4')
if ch != '4':
new wallet.''', confirm_key='4'):
return await ux_aborted()
# clear settings, address cache, settings from tmp seeds / seedvault seeds
@ -1761,7 +1759,7 @@ async def file_picker(suffix=None, min_size=1, max_size=1000000, taster=None,
if none_msg:
msg += none_msg
if suffix:
msg += '\n\nThe filename must end in "%s". ' % suffix
msg += '\n\nThe filename must end in %r. ' % suffix
msg += '\n\nMaybe insert (another) SD card and try again?'
@ -2330,6 +2328,21 @@ PUSHTX_SUPPLIERS = [
('mempool.space', 'https://mempool.space/pushtx#'),
]
async def feature_requires_nfc():
# prompt them that it's need (iff not already enabled)
# - return F if they decline
if settings.get('nfc'):
return True
# force on NFC, so it works... but they can still turn it off later, etc.
if not await ux_confirm("This feature requires NFC to be enabled. %s to enable." % OK):
return False
settings.set("nfc", 1)
await change_nfc_enable(1)
return True
async def pushtx_setup_menu(*a):
# let them pick a URL from menu to enable "pushtx" feature, and provide
# some background, and even let them enter a custom URL.
@ -2348,12 +2361,9 @@ async def pushtx_setup_menu(*a):
if ch != "y":
return
if not settings.get('nfc'):
# force on NFC, so it works... but they can still turn it off later, etc.
if not await ux_confirm("This feature requires NFC to be enabled. %s to enable." % OK):
return
settings.set("nfc", 1)
await change_nfc_enable(1)
if not await feature_requires_nfc():
# they don't want to proceed
return
async def doit(menu, picked, xx_self):
# using stock values, or Disable

View File

@ -17,18 +17,7 @@ from glob import settings
from auth import write_sig_file
from charcodes import KEY_QR, KEY_NFC, KEY_PAGE_UP, KEY_PAGE_DOWN, KEY_HOME, KEY_LEFT, KEY_RIGHT
from charcodes import KEY_CANCEL
from utils import show_single_address, problem_file_line
def truncate_address(addr):
# Truncates address to width of screen, replacing middle chars
if not version.has_qwerty:
# - 16 chars screen width
# - but 2 lost at left (menu arrow, corner arrow)
# - want to show not truncated on right side
return addr[0:6] + '' + addr[-6:]
else:
# tons of space on Q1
return addr[0:12] + '' + addr[-12:]
from utils import show_single_address, problem_file_line, truncate_address
def censor_address(addr):
# We don't like to show the user full multisig addresses because we cannot be certain

View File

@ -79,7 +79,6 @@ class UserAuthorizedAction:
top_ux = the_ux.top_of_stack()
if not isinstance(top_ux, cls) and cls.active_request.ux_done:
# do cleaup
print('recovery cleanup')
cls.cleanup()
return
@ -91,8 +90,8 @@ class UserAuthorizedAction:
# show line number and/or simple text about error
if exc:
print("%s:" % msg)
sys.print_exception(exc)
#print("%s:" % msg)
#sys.print_exception(exc)
msg += '\n\n'
em = str(exc)
@ -791,6 +790,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
# step 1: parse PSBT from PSRAM into in-memory objects.
@ -813,7 +813,8 @@ class ApproveTransaction(UserAuthorizedAction):
try:
await self.psbt.validate() # might do UX: accept multisig import
dis.progress_bar_show(0.10)
self.psbt.consider_inputs()
ccc_c_xfp = CCCFeature.get_xfp() # can be None
self.psbt.consider_inputs(cosign_xfp=ccc_c_xfp)
dis.progress_bar_show(0.33)
self.psbt.consider_keys()
@ -823,11 +824,12 @@ class ApproveTransaction(UserAuthorizedAction):
self.psbt.consider_dangerous_sighash()
dis.progress_bar_show(0.85)
except FraudulentChangeOutput as exc:
print('FraudulentChangeOutput: ' + exc.args[0])
#print('FraudulentChangeOutput: ' + exc.args[0])
return await self.failure(exc.args[0], title='Change Fraud')
except FatalPSBTIssue as exc:
print('FatalPSBTIssue: ' + exc.args[0])
#print('FatalPSBTIssue: ' + exc.args[0])
return await self.failure(exc.args[0])
except BaseException as exc:
del self.psbt
@ -841,6 +843,10 @@ class ApproveTransaction(UserAuthorizedAction):
return await self.failure(msg, exc)
# early test for spending policy; not an error if violates policy
# - might add warnings
could_ccc_sign, needs_2fa = CCCFeature.could_sign(self.psbt)
# step 2: figure out what we are approving, so we can get sign-off
# - outputs, amounts
# - fee
@ -908,13 +914,15 @@ class ApproveTransaction(UserAuthorizedAction):
ux_clear_keys(True)
dis.progress_bar_show(1) # finish the Validating...
if not hsm_active:
msg.write("\nPress %s to approve and sign transaction." % OK)
if needs_txn_explorer:
msg.write(" Press (2) to explore txn.")
if self.is_sd and CardSlot.both_inserted():
msg.write(" (B) to write to lower SD slot.")
msg.write(" X to abort.")
msg.write(" %s to abort." % X)
while True:
ch = await ux_show_story(msg, title="OK TO SEND?", escape="2b")
if ch == "2" and needs_txn_explorer:
@ -925,8 +933,8 @@ class ApproveTransaction(UserAuthorizedAction):
del msg
break
else:
# get approval (maybe) from the HSM
ch = await hsm_active.approve_transaction(self.psbt, self.psbt_sha, msg.getvalue())
dis.progress_bar_show(1) # finish the Validating...
except MemoryError:
# recovery? maybe.
@ -940,7 +948,7 @@ class ApproveTransaction(UserAuthorizedAction):
return await self.failure(msg)
if ch not in 'yb':
# they don't want to!
# they don't want to sign!
self.refused = True
await ux_dramatic_pause("Refused.", 1)
@ -950,11 +958,27 @@ class ApproveTransaction(UserAuthorizedAction):
self.done()
return
if needs_2fa and could_ccc_sign:
# They still need to pass web2fa challenge (but it meets other specs ok)
try:
await CCCFeature.web2fa_challenge()
except:
could_ccc_sign = False
ch = await ux_show_story("Will not add CCC signature. Proceed anyway?")
if ch != 'y':
return await self.failure("2FA Failed")
# do the actual signing.
try:
dis.fullscreen('Wait...')
gc.collect() # visible delay caused by this but also sign_it() below
self.psbt.sign_it()
if could_ccc_sign:
dis.fullscreen('CCC Sign...')
gc.collect()
CCCFeature.sign_psbt(self.psbt)
except FraudulentChangeOutput as exc:
return await self.failure(exc.args[0], title='Change Fraud')
except MemoryError:
@ -1039,7 +1063,7 @@ class ApproveTransaction(UserAuthorizedAction):
rv += 'Press RIGHT to see next group'
if offset:
rv += ', LEFT to go back'
rv += '. X to quit.'
rv += ('. %s to quit.' % X)
return rv
@ -1420,8 +1444,8 @@ class RemoteBackup(UserAuthorizedAction):
except BaseException as exc:
self.failed = "Error during backup process."
print("Backup failure: ")
sys.print_exception(exc)
#print("Backup failure: ")
#sys.print_exception(exc)
finally:
self.done()

View File

@ -104,6 +104,7 @@ def render_backup_contents(bypass_tmp=False):
if k == 'bkpw': continue # confusing/circular
if k == 'sd2fa': continue # do NOT backup SD 2FA (card can be lost or damaged)
if k == 'words': continue # words length is recalculated from secret
if k == 'ccc': continue # not supported, security issue
if k == 'seedvault' and not v: continue
if k == 'seeds' and not v: continue
ADD('setting.' + k, v)
@ -215,6 +216,11 @@ def restore_from_dict_ll(vals):
# old backups need this to function properly
continue
if k == 'ccc':
# CCC feature cannot be backed-up nor restored for security reasons
# (would allow replay attacks)
continue
if k == 'tp':
# restore trick pins, which may involve many ops
from trick_pins import tp

859
shared/ccc.py Normal file
View File

@ -0,0 +1,859 @@
# (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, pad_raw_secret
from glob import settings
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 pad_raw_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")
# we won't sign txn unless old height + velocity >= new height
if psbt.lock_time < (block_h + velocity):
raise CCCPolicyViolationError("velocity")
# 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 our 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?
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
try:
enc = CCCFeature.get_encoded_secret()
except:
# some test cases?
enc = None
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, meta='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 = [MenuItem(truncate_address(a), f=self.edit_addr, arg=a) for a in addrs]
if not items:
items.append(MenuItem("(none yet)"))
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)))
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.' % 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 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():
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
got.extend(here)
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(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(new)))
else:
await ux_show_story("Added new address to whitelist:\n\n%s" % 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="Txn 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 += ' (6) for import from Seed Vault'
ch = await ux_show_story(msg, escape='126', title="CCC Key C")
if ch == '1' or ch == '2':
nwords = 24 if ch == '2' else 12
async def done_key_C_import(words):
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:
words = WordNestMenu(nwords, done_cb=done_key_C_import)
return None # will call parent again
elif ch == '6':
# pick existing from Seed Vault
enc = await SeedVaultChooserMenu.pick(words_only=True)
if not enc: return None
words = SecretStash.decode_words(enc)
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
# do BIP-32 basics: capture XFP and XPUB and encoded version of the secret
CCCFeature.init_setup(words)
# 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!''')
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", escape='6')
if ch == '6' and version.is_devmode:
# debug hack: skip word entry
bypass = True
elif 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
from glob import dis
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

View File

@ -29,9 +29,16 @@ Slip132Version = namedtuple('Slip132Version', ('pub', 'priv', 'hint'))
# - from <https://github.com/Bit-Wasp/bitcoin-php/issues/576>
# - also electrum source: electrum/lib/constants.py
# nLockTime in transaction equal or above this value is a unix timestamp (time_t) not block height.
NLOCK_IS_TIME = const(500000000)
class ChainsBase:
curve = 'secp256k1'
menu_name = None # use 'name' if this isn't defined
core_name = None # name of chain's "core" p2p software
ccc_min_block = 0
# b44_cointype comes from
# <https://github.com/satoshilabs/slips/blob/master/slip-0044.md>
@ -292,6 +299,7 @@ class BitcoinMain(ChainsBase):
# see <https://github.com/bitcoin/bitcoin/blob/master/src/chainparams.cpp#L140>
ctype = 'BTC'
name = 'Bitcoin Mainnet'
ccc_min_block = 865572
slip132 = {
AF_CLASSIC: Slip132Version(0x0488B21E, 0x0488ADE4, 'x'),
@ -309,7 +317,7 @@ class BitcoinMain(ChainsBase):
b44_cointype = 0
class BitcoinTestnet(BitcoinMain):
class BitcoinTestnet(ChainsBase):
# testnet4 (was testnet3 up until 2025 but all parameters are the same)
ctype = 'XTN'
name = 'Bitcoin Testnet 4'
@ -331,7 +339,7 @@ class BitcoinTestnet(BitcoinMain):
b44_cointype = 1
class BitcoinRegtest(BitcoinMain):
class BitcoinRegtest(ChainsBase):
ctype = 'XRT'
name = 'Bitcoin Regtest'

View File

@ -51,4 +51,8 @@ class QRDecodeExplained(ValueError):
class UnknownAddressExplained(ValueError):
pass
# We're not going to co-sign using CCC feature
class CCCPolicyViolationError(RuntimeError):
pass
# EOF

View File

@ -19,6 +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
# useful shortcut keys
from charcodes import KEY_QR, KEY_NFC
@ -137,7 +138,7 @@ SettingsMenu = [
MenuItem('Login Settings', menu=LoginPrefsMenu),
MenuItem('Hardware On/Off', menu=HWTogglesMenu),
NonDefaultMenuItem('Multisig Wallets', 'multisig',
menu=make_multisig_menu, predicate=has_secrets),
menu=make_multisig_menu, predicate=has_secrets, shortcut='m'),
NonDefaultMenuItem('NFC Push Tx', 'ptxurl', menu=pushtx_setup_menu),
MenuItem('Display Units', chooser=value_resolution_chooser),
MenuItem('Max Network Fee', chooser=max_fee_chooser),
@ -235,6 +236,7 @@ DevelopersMenu = [
MenuItem('Warm Reset', f=reset_self),
MenuItem("Restore Txt Bkup", f=restore_everything_cleartext),
MenuItem("BKPW Override", menu=bkpw_override),
MenuItem('Reflash GPU', f=reflash_gpu, predicate=version.has_qwerty),
]
AdvancedVirginMenu = [ # No PIN, no secrets yet (factory fresh)
@ -326,7 +328,6 @@ correctly- crafted transactions signed on Testnet could be broadcast on Mainnet.
MenuItem('Settings Space', f=show_settings_space),
MenuItem('MCU Key Slots', f=show_mcu_keys_left),
MenuItem('Bless Firmware', f=bless_flash), # no need for this anymore?
MenuItem('Reflash GPU', f=reflash_gpu, predicate=version.has_qwerty),
MenuItem("Wipe LFS", f=wipe_filesystem), # kills other-seed settings, HSM stuff, addr cache
]
@ -366,6 +367,7 @@ AdvancedNormalMenu = [
story=("Enable HSM? Enables all user management commands, and other HSM-only USB commands. "
"By default these commands are disabled."),
predicate=hsm_available),
NonDefaultMenuItem('Coldcard Co-Signing', 'ccc', f=toggle_ccc_feature, predicate=is_not_tmp),
MenuItem('User Management', menu=make_users_menu,
predicate=hsm_available),
MenuItem('NFC Tools', predicate=nfc_enabled, menu=NFCToolsMenu, shortcut=KEY_NFC),

View File

@ -7,6 +7,7 @@
import stash, ustruct, chains, sys, gc, uio, ujson, uos, utime, ckcc, ngu, version
from sffile import SFFile
from utils import problem_file_line, cleanup_deriv_path, match_deriv_path
from utils import cleanup_payment_address
from pincodes import AE_LONG_SECRET_LEN
from stash import blank_object
from users import Users, MAX_NUMBER_USERS, calc_local_pincode
@ -149,22 +150,6 @@ def assert_empty_dict(j):
if extra:
raise ValueError("Unknown item: " + ', '.join(extra))
def cleanup_whitelist_value(s):
# one element in a list of addresses or paths or descriptors?
# - later matching is string-based, so just doing basic syntax check here
# - must be checksumed-base58 or bech32
try:
ngu.codecs.b58_decode(s)
return s
except: pass
try:
ngu.codecs.segwit_decode(s)
return s
except: pass
raise ValueError('bad whitelist value: ' + s)
class WhitelistOpts:
# contains various options related to whitelisting
@ -215,7 +200,7 @@ class ApprovalRule:
self.per_period = pop_int(j, 'per_period', 0, MAX_SATS)
self.max_amount = pop_int(j, 'max_amount', 0, MAX_SATS)
self.users = pop_list(j, 'users', check_user)
self.whitelist = pop_list(j, 'whitelist', cleanup_whitelist_value)
self.whitelist = pop_list(j, 'whitelist', cleanup_payment_address)
self.whitelist_opts = pop_dict(j, 'whitelist_opts', False, WhitelistOpts)
self.min_users = pop_int(j, 'min_users', 1, len(self.users))
self.local_conf = pop_bool(j, 'local_conf')

View File

@ -51,6 +51,8 @@ freeze_as_mpy('', [
'tapsigner.py',
'wallet.py',
'ownership.py',
'ccc.py',
'web2fa.py',
], opt=0)
# Optimize data-like files, since no need to debug them.

View File

@ -382,7 +382,7 @@ class MenuSystem:
self.up()
# events
def on_cancel(self):
async def on_cancel(self):
# override me
if the_ux.pop():
# top of stack (main top-level menu)
@ -393,7 +393,7 @@ class MenuSystem:
#
if picked is None:
# "go back" or cancel or something
self.on_cancel()
await self.on_cancel()
else:
await picked.activate(self, self.cursor)
@ -406,7 +406,7 @@ class MenuSystem:
gc.collect()
if self.multi_selected is not None:
# multichoice
self.on_cancel()
await self.on_cancel()
return ch
await self.activate(ch)

View File

@ -70,10 +70,12 @@ def init0():
rng_seeding()
async def dev_enable_repl(*a):
# Mk4: Enable serial port connection. You'll have to break case open.
# Enable serial port connection. You'll have to break case open.
from ux import ux_show_story
wipe_if_deltamode()
if not version.is_devmode: return
# allow REPL access
ckcc.vcp_enabled(True)

View File

@ -1332,6 +1332,7 @@ class MultisigMenu(MenuSystem):
@classmethod
def construct(cls):
# Dynamic menu with user-defined names of wallets shown
from glob import NFC
if not MultisigWallet.exists():
rv = [MenuItem('(none setup yet)', f=no_ms_yet)]
@ -1340,7 +1341,7 @@ class MultisigMenu(MenuSystem):
for ms in MultisigWallet.get_all():
rv.append(MenuItem('%d/%d: %s' % (ms.M, ms.N, ms.name),
menu=make_ms_wallet_menu, arg=ms.storage_idx))
from glob import NFC
rv.append(MenuItem('Import from File', f=import_multisig))
rv.append(MenuItem('Import from QR', f=import_multisig_qr,
predicate=version.has_qwerty, shortcut=KEY_QR))
@ -1359,7 +1360,6 @@ class MultisigMenu(MenuSystem):
rv.append(NonDefaultMenuItem(
'Unsorted Multisig?' if version.has_qwerty else 'Unsorted Multi?',
'unsort_ms', f=unsorted_ms_menu))
return rv
def update_contents(self):
@ -1484,24 +1484,26 @@ async def ms_wallet_detail(menu, label, item):
return await ms.show_detail()
async def export_multisig_xpubs(*a):
async def export_multisig_xpubs(*a, xfp=None, alt_secret=None, skip_prompt=False):
# WAS: Create a single text file with lots of docs, and all possible useful xpub values.
# THEN: Just create the one-liner xpub export value they need/want to support BIP-45
# NOW: Export JSON with one xpub per useful address type and semi-standard derivation path
#
# Consumer for this file is supposed to be ourselves, when we build on-device multisig.
# - consumer for this file is supposed to be ourselves, when we build on-device multisig.
# - however some 3rd parties are making use of it as well.
# - used for CCC feature now as well, but result looks just like normal export
#
from glob import NFC, dis
from ux import import_export_prompt
xfp = xfp2str(settings.get('xfp', 0))
xfp = xfp2str(xfp or settings.get('xfp', 0))
chain = chains.current_chain()
fname_pattern = 'ccxp-%s.json' % xfp
label = "Multisig XPUB"
msg = '''\
if not skip_prompt:
msg = '''\
This feature creates a small file containing \
the extended public keys (XPUB) you would need to join \
a multisig wallet.
@ -1515,9 +1517,9 @@ P2WSH:
{ok} to continue. {x} to abort.'''.format(coin=chain.b44_cointype, ok=OK, x=X)
ch = await ux_show_story(msg)
if ch != "y":
return
ch = await ux_show_story(msg)
if ch != "y":
return
acct_num = await ux_enter_bip32_index('Account Number:') or 0
@ -1537,7 +1539,7 @@ P2WSH:
def render(fp):
fp.write('{\n')
with stash.SensitiveValues() as sv:
with stash.SensitiveValues(secret=alt_secret) as sv:
for deriv, name, fmt in todo:
if fmt == AF_P2SH and acct_num:
continue
@ -1586,7 +1588,7 @@ P2WSH:
return
msg = '%s file written:\n\n%s' % (label, nice)
# msg += '\n\nMultisig XPUB signature file written:\n\n%s' % sig_nice
await ux_show_story(msg)
async def validate_xpub_for_ms(obj, af_str, chain, my_xfp, xpubs):
@ -1688,7 +1690,18 @@ async def ms_coordinator_file(af_str, my_xfp, chain, slot_b=None):
return xpubs, num_mine, num_files
async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False):
def add_own_xpub(chain, acct_num, addr_fmt, secret=None):
# Build out what's required for using master secret (or another
# encoded secret) as a co-signer
deriv = "m/48h/%dh/%dh/%dh" % (chain.b44_cointype, acct_num,
2 if addr_fmt == AF_P2WSH else 1)
with stash.SensitiveValues(secret=secret) as sv:
node = sv.derive_path(deriv)
the_xfp = sv.get_xfp()
return (the_xfp, deriv, chain.serialize_public(node, AF_P2SH))
async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False, for_ccc=None):
# collect all xpub- exports (must be >= 1) to make "air gapped" wallet
# - function f specifies a way how to collect co-signer info - currently SD and QR (Q only)
# - ask for M value
@ -1723,19 +1736,39 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False)
" Must have filename: ccxp-....json")
await ux_show_story(msg)
return
# add myself if not included already ?
if not num_mine:
if for_ccc:
secret, ccc_ms_count = for_ccc
# Always include 2 keys from CCC: own master (key A) and key C
# - force them to same derivation.
acct = await ux_enter_bip32_index('CCC Account Number:') or 0
dis.fullscreen("Wait...")
a = add_own_xpub(chain, acct, addr_fmt) # master: key A
c = add_own_xpub(chain, acct, addr_fmt, secret=secret)
# problem: above file searching may find xpub export from key C
# (or our master seed, exported) .. we can't add them again,
# since xfp are not unique and that's probably not what they wanted
got_xfps = [a[0], c[0]]
xpubs = [x for x in xpubs if x[0] not in got_xfps]
if not xpubs:
await ux_show_story("Need at least one other co-signer (key B).")
return
xpubs.append(a)
xpubs.append(c)
num_mine += 2
elif not num_mine:
# add myself if not included already? As an option.
ch = await ux_show_story("Add current Coldcard with above XFP ?",
title="[%s]" % xfp2str(my_xfp))
if ch == "y":
acct = await ux_enter_bip32_index('Account Number:') or 0
dis.fullscreen("Wait...")
deriv = "m/48h/%dh/%dh/%dh" % (chain.b44_cointype, acct,
2 if addr_fmt == AF_P2WSH else 1)
with stash.SensitiveValues() as sv:
node = sv.derive_path(deriv)
xpubs.append((my_xfp, deriv, chain.serialize_public(node, AF_P2SH)))
xpubs.append(add_own_xpub(chain, acct, addr_fmt))
num_mine += 1
N = len(xpubs)
@ -1744,18 +1777,28 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False)
await ux_show_story("Invalid number of signers,min is 2 max is %d." % MAX_SIGNERS)
return
# pick useful M value to start
M = await ux_enter_number("How many need to sign?(M)", N, can_cancel=True)
if not M:
await ux_dramatic_pause('Aborted.', 2)
return # user cancel
if for_ccc:
M = 2
else:
# pick useful M value to start
M = await ux_enter_number("How many need to sign?(M)", N, can_cancel=True)
if not M:
await ux_dramatic_pause('Aborted.', 2)
return # user cancel
dis.fullscreen("Wait...")
# create appropriate object
assert 1 <= M <= N <= MAX_SIGNERS
name = 'CC-%d-of-%d' % (M, N)
if for_ccc:
name = "Coldcard Co-sign" if version.has_qwerty else "CCC"
if ccc_ms_count:
# make name unique for each CCC wallet, but they can edit
name += " #%d" % (ccc_ms_count+1)
else:
name = 'CC-%d-of-%d' % (M, N)
ms = MultisigWallet(name, (M, N), xpubs, chain_type=chain.ctype, addr_fmt=addr_fmt)
if num_mine:
@ -1772,21 +1815,22 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False)
await ms.export_wallet_file(descriptor=True, desc_pretty=False)
async def create_ms_step1(*a):
async def create_ms_step1(*a, for_ccc=None):
# Show story, have them pick address format.
ch = None
is_qr = False
if version.has_qr:
# They have a scanner, could do QR codes...
ch = await ux_show_story("Press "+ KEY_QR + " to scan multisg XPUBs from "\
"QR codes (BBQr) or ENTER to use SD card(s).", title="QR or SD Card?")
ch = await ux_show_story("Press "+ KEY_QR + " to scan multisg XPUBs from "
"QR codes (BBQr) or ENTER to use SD card(s).",
title="QR or SD Card?")
if ch == KEY_QR:
is_qr = True
ch = await ux_show_story("Press ENTER for default address format (P2WSH, segwit), "\
ch = await ux_show_story("Press ENTER for default address format (P2WSH, segwit), "
"otherwise, press (1) for P2SH-P2WSH.", title="Address Format",
escape="1")
escape="1")
else:
ch = await ux_show_story('''\
@ -1804,7 +1848,7 @@ Default is P2WSH addresses (segwit) or press (1) for P2SH-P2WSH.''', escape='1')
return
try:
return await ondevice_multisig_create(n, f, is_qr)
return await ondevice_multisig_create(n, f, is_qr, for_ccc=for_ccc)
except Exception as e:
await ux_show_story('Failed to create multisig.\n\n%s\n%s' % (e, problem_file_line(e)),
title="ERROR")

View File

@ -275,6 +275,7 @@ class NFCHandler:
if done: break
async def push_tx_from_file(self):
# Pick (signed txn) file from SD card and broadcast via PushTx
# - assumes .txn extension (required)
@ -770,8 +771,8 @@ class NFCHandler:
if winner:
await verify_armored_signed_msg(winner, digest_check=False)
async def verify_address_nfc(self):
# Get an address or complete bip-21 url even and search it... slow.
async def read_address(self):
# Read an address or BIP-21 url and parse out addr (just one)
from utils import decode_bip21_text
def f(m):
@ -782,6 +783,11 @@ class NFCHandler:
winner = await self._nfc_reader(f, 'Unable to find address from NFC data.')
return winner
async def verify_address_nfc(self):
# Get an address or complete bip-21 url even and search it... slow.
winner = await self.read_address()
if winner:
from ownership import OWNERSHIP
await OWNERSHIP.search_ux(winner)

View File

@ -13,7 +13,7 @@ from files import CardMissingError, needs_microsd, CardSlot
from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL
from charcodes import KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6
from lcd_display import CHARS_W
from utils import problem_file_line, url_decode
from utils import problem_file_line, url_unquote
# title, username and such are limited that they fit on the one line both in
# text entry (W-2) and also in menu display (W-3)
@ -165,7 +165,7 @@ class NotesMenu(MenuSystem):
if got.startswith('otpauth://totp/'):
# see <https://github.com/google/google-authenticator/wiki/Key-Uri-Format>
tmp.title = url_decode(got[15:]).split('?', 1)[0]
tmp.title = url_unquote(got[15:]).split('?', 1)[0]
elif got.startswith('otpauth-migration://offline'):
# see <https://github.com/qistoph/otp_export>
tmp.title = 'Google Auth'

View File

@ -65,6 +65,7 @@ from utils import call_later_ms
# hmx = (bool) Force display of current XFP in home menu, even w/o tmp seed active
# unsort_ms = (bool) Allow unsorted multisig with BIP-67 disabled
# msas = multisig address show (do not censor multisig addresses)
# ccc = (complex) If present, CCC feature is enabled and key details stored here.
# Stored w/ key=00 for access before login
# _skip_pin = hard code a PIN value (dangerous, only for debug)
@ -76,7 +77,6 @@ from utils import call_later_ms
# cd_pin = [<=mk3] pin code which enables "countdown to brick" mode
# kbtn = (1 char str) button will wipe seed during login process (mk4+, Q)
# terms_ok = customer has signed-off on the terms of sale
# msas = multisig address show (do not censor multisig addresses)
# settings linked to seed
# LINKED_SETTINGS = ["multisig", "tp", "ovc", "xfp", "xpub", "words"]

View File

@ -4,13 +4,14 @@
#
from ustruct import unpack_from, unpack, pack
from ubinascii import hexlify as b2a_hex
from utils import xfp2str, B2A, keypath_to_str, problem_file_line
from utils import xfp2str, B2A, keypath_to_str
from utils import seconds2human_readable, datetime_from_timestamp, datetime_to_str
import stash, gc, history, sys, ngu, ckcc, chains
import stash, gc, history, sys, ngu, ckcc
from chains import NLOCK_IS_TIME
from uhashlib import sha256
from uio import BytesIO
from sffile import SizerFile
from multisig import MultisigWallet, disassemble_multisig, disassemble_multisig_mn
from multisig import MultisigWallet, disassemble_multisig_mn
from exceptions import FatalPSBTIssue, FraudulentChangeOutput
from serializations import ser_compact_size, deser_compact_size, hash160, hash256
from serializations import CTxIn, CTxInWitness, CTxOut, ser_string, ser_uint256, COutPoint
@ -547,7 +548,7 @@ class psbtInputProxy(psbtProxy):
blank_flds = (
'unknown', 'utxo', 'witness_utxo', 'sighash', 'redeem_script', 'witness_script',
'fully_signed', 'is_segwit', 'is_multisig', 'is_p2sh', 'num_our_keys',
'required_key', 'scriptSig', 'amount', 'scriptCode', 'added_sig', 'previous_txid',
'required_key', 'scriptSig', 'amount', 'scriptCode', 'previous_txid',
'prevout_idx', 'sequence', 'req_time_locktime', 'req_height_locktime'
)
@ -556,7 +557,8 @@ class psbtInputProxy(psbtProxy):
#self.utxo = None
#self.witness_utxo = None
self.part_sig = {}
self.part_sigs = {}
self.added_sigs = {} # signature that CC added (clearly separated from what can be already in part_sigs)
#self.sighash = None
self.subpaths = {} # will typically be non-empty for all inputs
#self.redeem_script = None
@ -579,7 +581,6 @@ class psbtInputProxy(psbtProxy):
#self.scriptCode = None # only expected for segwit inputs
# after signing, we'll have a signature to add to output PSBT
#self.added_sig = None
#self.previous_txid = None
#self.prevout_idx = None
@ -629,13 +630,13 @@ class psbtInputProxy(psbtProxy):
# rework the pubkey => subpath mapping
self.parse_subpaths(my_xfp, parent.warnings)
if self.part_sig:
if self.part_sigs:
# How complete is the set of signatures so far?
# - assuming PSBT creator doesn't give us extra data not required
# - seems harmless if they fool us into thinking already signed; we do nothing
# - could also look at pubkey needed vs. sig provided
# - could consider structure of MofN in p2sh cases
self.fully_signed = (len(self.part_sig) >= len(self.subpaths))
self.fully_signed = (len(self.part_sigs) >= len(self.subpaths))
else:
# No signatures at all yet for this input (typical non multisig)
self.fully_signed = False
@ -714,7 +715,7 @@ class psbtInputProxy(psbtProxy):
return utxo
def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt):
def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt, cosign_xfp=None):
# See what it takes to sign this particular input
# - type of script
# - which pubkey needed
@ -756,18 +757,18 @@ class psbtInputProxy(psbtProxy):
which_key, = self.subpaths.keys()
else:
# Assume we'll be signing with any key we know
# - limitation: we cannot be two legs of a multisig
# - limitation: we cannot be two legs of a multisig (only if CCC feature used)
# - but if partial sig already in place, ignore that one
if not which_key:
which_key = set()
for pubkey, path in self.subpaths.items():
if self.part_sig and (pubkey in self.part_sig):
if self.part_sigs and (pubkey in self.part_sigs):
# pubkey has already signed, so ignore
continue
if path[0] == my_xfp:
if path[0] in (my_xfp, cosign_xfp):
# slight chance of dup xfps, so handle
if not which_key:
which_key = set()
which_key.add(pubkey)
if not addr_is_segwit and \
@ -881,7 +882,7 @@ class psbtInputProxy(psbtProxy):
elif kt == PSBT_IN_WITNESS_UTXO:
self.witness_utxo = val
elif kt == PSBT_IN_PARTIAL_SIG:
self.part_sig[key[1:]] = val
self.part_sigs[key[1:]] = val
elif kt == PSBT_IN_BIP32_DERIVATION:
self.subpaths[key[1:]] = val
elif kt == PSBT_IN_REDEEM_SCRIPT:
@ -917,13 +918,13 @@ class psbtInputProxy(psbtProxy):
if self.witness_utxo:
wr(PSBT_IN_WITNESS_UTXO, self.witness_utxo)
if self.part_sig:
for pk in self.part_sig:
wr(PSBT_IN_PARTIAL_SIG, self.part_sig[pk], pk)
if self.part_sigs:
for pk, sig in self.part_sigs.items():
wr(PSBT_IN_PARTIAL_SIG, sig, pk)
if self.added_sig:
pubkey, sig = self.added_sig
wr(PSBT_IN_PARTIAL_SIG, sig, pubkey)
if self.added_sigs:
for pk, sig in self.added_sigs.items():
wr(PSBT_IN_PARTIAL_SIG, sig, pk)
if self.sighash is not None:
wr(PSBT_IN_SIGHASH_TYPE, pack('<I', self.sighash))
@ -1404,9 +1405,9 @@ class psbtObject(psbtProxy):
assert inp.prevout_idx is not None
assert inp.previous_txid
if inp.req_time_locktime is not None:
assert inp.req_time_locktime >= 500000000
assert inp.req_time_locktime >= NLOCK_IS_TIME
if inp.req_height_locktime is not None:
assert 0 < inp.req_height_locktime < 500000000
assert 0 < inp.req_height_locktime < NLOCK_IS_TIME
else:
# v0 requires exclusion
assert inp.prevout_idx is None
@ -1435,7 +1436,7 @@ class psbtObject(psbtProxy):
))
else:
msg = "This tx can only be spent after "
if self.lock_time < 500000000:
if self.lock_time < NLOCK_IS_TIME:
msg += "block height of %d" % self.lock_time
else:
try:
@ -1633,7 +1634,7 @@ class psbtObject(psbtProxy):
for p in probs:
self.warnings.append(('Troublesome Change Outs', p))
def consider_inputs(self):
def consider_inputs(self, cosign_xfp=None):
# Look at the UTXO's that we are spending. Do we have them? Do the
# hashes match, and what values are we getting?
# Important: parse incoming UTXO to build total input value
@ -1664,7 +1665,7 @@ class psbtObject(psbtProxy):
# type of signing will be required, and which key we need.
# - also validates redeem_script when present
# - also finds appropriate multisig wallet to be used
inp.determine_my_signing_key(i, utxo, self.my_xfp, self)
inp.determine_my_signing_key(i, utxo, self.my_xfp, self, cosign_xfp)
# iff to UTXO is segwit, then check it's value, and also
# capture that value, since it's supposed to be immutable
@ -1820,7 +1821,48 @@ class psbtObject(psbtProxy):
outp.serialize(out_fd, self.is_v2)
out_fd.write(b'\0')
def sign_it(self):
@staticmethod
def check_pubkey_at_path(sv, subpath, target_pk):
# derive actual pubkey from private
skp = keypath_to_str(subpath)
node = sv.derive_path(skp)
# check the pubkey of this BIP-32 node
if target_pk == node.pubkey():
return node
return None
@staticmethod
def ecdsa_grind_sign(sk, digest, sighash):
# Do the ACTUAL signature ... finally!!!
# We need to grind sometimes to get a positive R
# value that will encode (after DER) into a shorter string.
# - saves on miner's fee (which might be expected/required)
# - blends in with Bitcoin Core signatures which do this from 0.17.0
n = 0 # retry num
while True:
# time to produce signature on stm32: ~25.1ms
result = ngu.secp256k1.sign(sk, digest, n).to_bytes()
if result[1] < 0x80:
# - no need to check for low S value as those are generated by default
# by secp256k1 lib
# - to produce 71 bytes long signature (both low S low R values),
# we need on average 2 retries
# - worst case ~25 grinding iterations need to be performed total
break
n += 1
# DER serialization after we have low S and low R values in our signature
r = result[1:33]
s = result[33:65]
der_sig = ser_sig_der(r, s, sighash)
return der_sig
def sign_it(self, alternate_secret=None, my_xfp=None):
# txn is approved. sign all inputs we can sign. add signatures
# - hash the txn first
# - sign all inputs we have the key for
@ -1830,10 +1872,13 @@ class psbtObject(psbtProxy):
from glob import dis
from ownership import OWNERSHIP
with stash.SensitiveValues() as sv:
# Double check the change outputs are right. This is slow, but critical because
if my_xfp is None:
my_xfp = self.my_xfp
with stash.SensitiveValues(secret=alternate_secret) as sv:
# Double-check the change outputs are right. This is slow, but critical because
# it detects bad actors, not bugs or mistakes.
# - equivilent check already done for p2sh outputs when we re-built the redeem script
# - equivalent check already done for p2sh outputs when we re-built the redeem script
change_outs = [n for n,o in enumerate(self.outputs) if o.is_change]
if change_outs:
dis.fullscreen('Change Check...')
@ -1846,20 +1891,15 @@ class psbtObject(psbtProxy):
good = 0
for pubkey, subpath in oup.subpaths.items():
if subpath[0] != self.my_xfp:
# for multisig, will be N paths, and exactly one will
# be our key. For single-signer, should always be my XFP
continue
# derive actual pubkey from private
skp = keypath_to_str(subpath)
node = sv.derive_path(skp)
# check the pubkey of this BIP-32 node
if pubkey == node.pubkey():
good += 1
OWNERSHIP.note_subpath_used(subpath)
# for multisig, will be N paths, and exactly one will
# be our key. For single-signer, should always be my XFP
if subpath[0] == my_xfp:
# derive actual pubkey from private
res = self.check_pubkey_at_path(sv, subpath, pubkey)
if res:
good += 1
# TODO is this needed if output is multisig?
OWNERSHIP.note_subpath_used(subpath)
if not good:
raise FraudulentChangeOutput(out_idx,
@ -1871,7 +1911,6 @@ class psbtObject(psbtProxy):
# randomize secp context before each signing session
ngu.secp256k1.ctx_rnd()
# Sign individual inputs
success = set()
for in_idx, txi in self.input_iter():
dis.progress_sofar(in_idx, self.num_inputs)
@ -1898,12 +1937,8 @@ class psbtObject(psbtProxy):
# need to consider a set of possible keys, since xfp may not be unique
for which_key in inp.required_key:
# get node required
skp = keypath_to_str(inp.subpaths[which_key])
node = sv.derive_path(skp, register=False)
# expensive test, but works... and important
pu = node.pubkey()
if pu == which_key:
node = self.check_pubkey_at_path(sv, inp.subpaths[which_key], which_key)
if node:
break
else:
raise AssertionError("Input #%d needs pubkey I dont have" % in_idx)
@ -1913,10 +1948,10 @@ class psbtObject(psbtProxy):
which_key = inp.required_key
assert not inp.added_sig, "already done??"
assert not inp.added_sigs, "already done??"
assert which_key in inp.subpaths, 'unk key'
if inp.subpaths[which_key][0] != self.my_xfp:
if inp.subpaths[which_key][0] != my_xfp:
# we don't have the key for this subkey
# (redundant, required_key wouldn't be set)
continue
@ -1954,41 +1989,19 @@ class psbtObject(psbtProxy):
#print(" pubkey %s" % b2a_hex(which_key).decode('ascii'))
#print(" digest %s" % b2a_hex(digest).decode('ascii'))
# Do the ACTUAL signature ... finally!!!
# We need to grind sometimes to get a positive R
# value that will encode (after DER) into a shorter string.
# - saves on miner's fee (which might be expected/required)
# - blends in with Bitcoin Core signatures which do this from 0.17.0
n = 0 # retry num
while True:
# time to produce signature on stm32: ~25.1ms
result = ngu.secp256k1.sign(pk, digest, n).to_bytes()
if result[1] < 0x80:
# - no need to check for low S value as those are generated by default
# by secp256k1 lib
# - to produce 71 bytes long signature (both low S low R values),
# we need on average 2 retries
# - worst case ~25 grinding iterations need to be performed total
break
n += 1
# DER serialization after we have low S and low R values in our signature
r = result[1:33]
s = result[33:65]
der_sig = ser_sig_der(r, s, inp.sighash)
der_sig = self.ecdsa_grind_sign(pk, digest, inp.sighash)
# private key no longer required
stash.blank_object(pk)
stash.blank_object(node)
del pk, node, pu, skp, n
del pk, node
inp.added_sig = (which_key, der_sig)
inp.added_sigs[which_key] = der_sig
success.add(in_idx)
# Could remove sighash from input object - it is not required, takes space,
# and is already in signature or is implicit by not being part of the
# signature (taproot SIGHASH_DEFAULT)
## inp.sighash = None
if self.is_v2:
self.set_modifiable_flag(inp)
@ -1997,9 +2010,6 @@ class psbtObject(psbtProxy):
if inp.sighash == SIGHASH_ALL:
inp.sighash = None
# memory cleanup
del result, r, s
gc.collect()
# done.
@ -2187,7 +2197,7 @@ class psbtObject(psbtProxy):
# but we can't combine/finalize multisig stuff, so will never't be 'final'
return False
if inp.added_sig:
if inp.added_sigs:
signed += 1
return signed == self.num_inputs
@ -2230,10 +2240,10 @@ class psbtObject(psbtProxy):
else:
# insert the new signature(s), assuming fully signed txn.
assert inp.added_sig, 'No signature on input #%d'%in_idx
assert inp.added_sigs, 'No signature on input #%d'%in_idx
assert not inp.is_multisig, 'Multisig PSBT combine not supported'
pubkey, der_sig = inp.added_sig
pubkey, der_sig = list(inp.added_sigs.items())[0]
s = b''
s += ser_push_data(der_sig)
@ -2260,12 +2270,12 @@ class psbtObject(psbtProxy):
for in_idx, wit in self.input_witness_iter():
inp = self.inputs[in_idx]
if inp.is_segwit and inp.added_sig:
if inp.is_segwit and inp.added_sigs:
# put in new sig: wit is a CTxInWitness
assert not wit.scriptWitness.stack, 'replacing non-empty?'
assert not inp.is_multisig, 'Multisig PSBT combine not supported'
pubkey, der_sig = inp.added_sig
pubkey, der_sig = list(inp.added_sigs.items())[0]
assert pubkey[0] in {0x02, 0x03} and len(pubkey) == 33, "bad v0 pubkey"
wit.scriptWitness.stack = [der_sig, pubkey]

View File

@ -10,15 +10,15 @@
# - 'abandon' * 17 + 'agent'
# - 'abandon' * 11 + 'about'
#
import ngu, uctypes, bip39, random, stash, version
import ngu, uctypes, bip39, random, version
from ucollections import OrderedDict
from menu import MenuItem, MenuSystem
from utils import xfp2str, parse_extended_key, swab32, pad_raw_secret, problem_file_line
from uhashlib import sha256
from ux import ux_show_story, the_ux, ux_dramatic_pause, ux_confirm, OK, X
from ux import PressRelease, ux_input_numbers, ux_input_text, show_qr_code
from ux import PressRelease, ux_input_text, show_qr_code
from actions import goto_top_menu
from stash import SecretStash, ZeroSecretException
from stash import SecretStash, ZeroSecretException, SensitiveValues
from ubinascii import hexlify as b2a_hex
from pwsave import PassphraseSaver, PassphraseSaverMenu
from glob import settings, dis
@ -26,6 +26,7 @@ from pincodes import pa
from nvstore import SettingsObject
from files import CardMissingError, needs_microsd, CardSlot
from charcodes import KEY_QR, KEY_ENTER, KEY_CANCEL, KEY_CLEAR
from uasyncio import sleep_ms
# seed words lengths we support: 24=>256 bits, and recommended
@ -215,7 +216,7 @@ class WordNestMenu(MenuSystem):
while isinstance(the_ux.top_of_stack(), cls):
the_ux.pop()
def on_cancel(self):
async def on_cancel(self):
# user pressed cancel on a menu (so he's going upwards)
# - if it's a step where we added to the word list, undo that.
# - but keep them in our system until:
@ -411,10 +412,10 @@ async def new_from_dice(nwords):
await commit_new_words(words)
def in_seed_vault(encoded):
# Test if indicated xfp (or currently active XFP) is in the seed vault already.
# Test if indicated secret is in the seed vault already.
seeds = settings.master_get("seeds", [])
if seeds:
ss = stash.SecretStash.storage_serialize(encoded)
ss = SecretStash.storage_serialize(encoded)
if ss in [s[1] for s in seeds]:
return True
return False
@ -460,7 +461,7 @@ async def add_seed_to_vault(encoded, meta=None):
# Save it into master settings
seeds.append((new_xfp_str,
stash.SecretStash.storage_serialize(encoded),
SecretStash.storage_serialize(encoded),
xfp_ui,
meta))
@ -668,7 +669,7 @@ async def calc_bip39_passphrase(pw, bypass_tmp=False):
current_xfp = settings.get("xfp", 0)
with stash.SensitiveValues(bip39pw=pw, bypass_tmp=bypass_tmp) as sv:
with SensitiveValues(bip39pw=pw, bypass_tmp=bypass_tmp) as sv:
# can't do it without original seed words (late, but caller has checked)
assert sv.mode == 'words', sv.mode
nv = SecretStash.encode(xprv=sv.node)
@ -950,7 +951,7 @@ class SeedVaultMenu(MenuSystem):
# Save it into master settings
seeds.append((new_xfp_str,
stash.SecretStash.storage_serialize(pa.tmp_value),
SecretStash.storage_serialize(pa.tmp_value),
xfp_ui,
"unknown origin"))
@ -1026,6 +1027,47 @@ class SeedVaultMenu(MenuSystem):
tmp = self.construct()
self.replace_items(tmp)
class SeedVaultChooserMenu(MenuSystem):
def __init__(self, words_only=False):
self.result = None
seeds = settings.master_get("seeds", [])
items = []
for i, (xfp_str, encoded, name, meta) in enumerate(seeds):
encoded = pad_raw_secret(encoded)
if words_only and not SecretStash.is_words(encoded):
continue
item = MenuItem('%2d: %s' % (i+1, name), arg=encoded, f=self.picked)
items.append(item)
if not items:
items.append(MenuItem("(none suitable)"))
super().__init__(items)
async def picked(self, menu, idx, mi):
assert menu == self
# show as "checked", for a touch
menu.chosen = idx
menu.show()
await sleep_ms(100)
self.result = mi.arg
the_ux.pop() # causes interact to stop
@classmethod
async def pick(cls, **kws):
# nice simple blocking menu present and pick
m = cls(**kws)
the_ux.push(m)
await m.interact()
return m.result
class EphemeralSeedMenu(MenuSystem):
@staticmethod
@ -1077,17 +1119,14 @@ class EphemeralSeedMenu(MenuSystem):
async def make_ephemeral_seed_menu(*a):
if (not pa.tmp_value) and (not settings.master_get("seedvault", False)):
# force a warning on them, unless they are already doing it.
ch = await ux_show_story(
if not await ux_confirm(
"Temporary seed is a secret completely separate "
"from the master seed, typically held in device RAM and "
"not persisted between reboots in the Secure Element. "
"Enable the Seed Vault feature to store these secrets longer-term."
"\n\nPress (4) to prove you read to the end"
" of this message and accept all consequences.",
"Enable the Seed Vault feature to store these secrets longer-term.",
title="WARNING",
escape="4"
)
if ch != "4":
confirm_key="4"
):
return
rv = EphemeralSeedMenu.construct()
@ -1193,7 +1232,7 @@ class PassphraseMenu(MenuSystem):
return PassphraseSaverMenu(items)
def on_cancel(self):
async def on_cancel(self):
if not version.has_qwerty:
# zip to cancel item when they fail to exit via X button
self.goto_idx(self.count - 1)
@ -1208,7 +1247,9 @@ class PassphraseMenu(MenuSystem):
@classmethod
async def add_numbers(cls, *a):
# Mk4 only: add some digits (quick, easy)
pw = await ux_input_numbers(cls.pp_sofar)
from ux_mk4 import ux_input_digits
pw = await ux_input_digits(cls.pp_sofar)
if pw is not None:
cls.pp_sofar = pw
cls.check_length()

View File

@ -49,8 +49,10 @@ def numwords_to_len(num_words):
assert num_words in SEED_LEN_OPTS
return (num_words * 8) // 6
def len_from_marker(marker):
def _len_from_marker(marker):
# calculates length of entropy from CC marker
# - private detail of SecretStash
assert marker & 0x80 # wasn't actual words, might be xprv, etc
return ((marker & 0x3) + 2) * 8
class SecretStash:
@ -107,7 +109,7 @@ class SecretStash:
elif marker & 0x80:
# seed phrase
ll = len_from_marker(marker)
ll = _len_from_marker(marker)
# note:
# - byte length > number of words
@ -138,6 +140,30 @@ class SecretStash:
return 'master', ms, hd
@staticmethod
def is_words(secret):
# return False or number of words: 12, 18, 24
marker = secret[0]
if marker & 0x80:
return len_to_numwords(_len_from_marker(marker))
return False
@staticmethod
def decode_words(secret, bin_mode=False):
# Give a list of BIP-39 words from an encoded secret. Must be "words" type.
# - if bin_mode, return binary string representing the words, based on BIP-39
ll = _len_from_marker(secret[0])
# note:
# - byte length > number of words
# - not storing checksum
assert ll in [16, 24, 32]
# make master secret, using the memonic words, and passphrase (or empty string)
seed_bits = secret[1:1+ll]
return bip39.b2a_words(seed_bits).split() if not bin_mode else seed_bits
@staticmethod
def storage_serialize(secret):
# make it a JSON-compatible field
@ -153,7 +179,7 @@ class SecretStash:
if marker & 0x80:
# seed phrase
ll = len_from_marker(marker)
ll = _len_from_marker(marker)
return '%d words' % len_to_numwords(ll)
if marker == 0x00:
@ -182,7 +208,9 @@ class SensitiveValues:
self._bip39pw = bip39pw
if secret is not None:
if secret is Ellipsis:
self.mode = self.raw = self.node = None
elif secret is not None:
# sometimes we already know the secret
self.secret = secret
self.deltamode = False
@ -326,6 +354,9 @@ class SensitiveValues:
return xfp
def get_xfp(self):
return swab32(self.node.my_fp())
def register(self, item):
# Caller can add his own sensitive (derived?) data to our wiper
# typically would be byte arrays or byte strings, but also

View File

@ -2,7 +2,7 @@
#
# utils.py - Misc utils. My favourite kind of source file.
#
import gc, sys, ustruct, ngu, chains, ure, time, bip39, version
import gc, sys, ustruct, ngu, chains, ure, uos, uio, time, bip39, version, uasyncio
from ubinascii import unhexlify as a2b_hex
from ubinascii import hexlify as b2a_hex
from ubinascii import a2b_base64, b2a_base64
@ -91,7 +91,6 @@ def pop_count(i):
def get_filesize(fn):
# like os.path.getsize()
import uos
try:
return uos.stat(fn)[6]
except OSError:
@ -221,7 +220,6 @@ def to_ascii_printable(s, strip=False, only_printable=True):
def problem_file_line(exc):
# return a string of just the filename.py and line number where
# an exception occured. Best used on AssertionError.
import uio, sys, ure
tmp = uio.StringIO()
sys.print_exception(exc, tmp)
@ -252,7 +250,6 @@ def cleanup_deriv_path(bin_path, allow_star=False):
# - assume 'm' prefix, so '34' becomes 'm/34', etc
# - do not assume /// is m/0/0/0
# - if allow_star, then final position can be * or *h (wildcard)
import ure
from public_constants import MAX_PATH_DEPTH
s = to_ascii_printable(bin_path, strip=True).lower()
@ -432,7 +429,7 @@ def clean_shutdown(style=0):
# wipe SPI flash and shutdown (wiping main memory)
# - mk4: SPI flash not used, but NFC may hold data (PSRAM cleared by bootrom)
# - bootrom wipes every byte of SRAM, so no need to repeat here
import callgate, version, uasyncio
import callgate
# save if anything pending
from glob import settings
@ -498,7 +495,7 @@ def word_wrap(ln, w):
yield OUT_CTRL_ADDRESS + addr[pos:pos+aw]
pos += aw
return
# bad-break the line
sp = min(txtlen(ln), w)
nsp = sp
@ -554,16 +551,16 @@ def chunk_writer(fd, body):
dis.progress_bar_show(1)
def pad_raw_secret(raw_sec_str):
def pad_raw_secret(text_sec_str):
# Chip can hold 72-bytes as a secret
# every secret has 0th byte as marker
# then secret and padded to zero to AE_SECRET_LEN
from pincodes import AE_SECRET_LEN
raw = bytearray(AE_SECRET_LEN)
if len(raw_sec_str) % 2:
raw_sec_str += '0'
x = a2b_hex(raw_sec_str)
if len(text_sec_str) % 2:
text_sec_str += '0'
x = a2b_hex(text_sec_str)
raw[0:len(x)] = x
return raw
@ -611,7 +608,7 @@ def txid_from_fname(fname):
except: pass
return None
def url_decode(u):
def url_unquote(u):
# expand control chars from %XX and '+'
# - equiv to urllib.parse.unquote_plus
# - ure.sub is missing, so not being clever here.
@ -631,10 +628,17 @@ def url_decode(u):
return u
def url_quote(u):
# convert non-text chars into %hex for URL usage
# - urllib.parse.quote() but w/o as much thought
return ''.join( (ch if 33 <= ord(ch) <= 127 else '%%%02x' % ord(ch)) \
for ch in u)
def decode_bip21_text(got):
# Assume text is a BIP-21 payment address (url), with amount, description
# and url protocol prefix ... all optional except the address.
# - also will detect correctly encoded & checksummed xpubs
# - always verifies checksum of data it finds
proto, args, addr = None, None, None
@ -651,7 +655,7 @@ def decode_bip21_text(got):
args = dict()
for p in parts:
k, v = p.split('=', 1)
args[k] = url_decode(v)
args[k] = url_unquote(v)
# assume it's an bare address for now
if not addr:
@ -661,10 +665,12 @@ def decode_bip21_text(got):
try:
raw = ngu.codecs.b58_decode(addr)
# it's valid base58
# an address, P2PKH or xpub (xprv checked above)
# It's valid base58: could be
# an address, P2PKH or xpub/xprv
if addr[1:4] == 'pub':
return 'xpub', (addr,)
if addr[1:4] == 'prv':
return 'xprv', (addr,)
return 'addr', (proto, addr, args)
except:
@ -691,4 +697,32 @@ def chunk_address(addr):
# useful to show payment addresses specially
return [addr[i:i+4] for i in range(0, len(addr), 4)]
def cleanup_payment_address(s):
# Cleanup a payment address, or raise if bad checksum
# - later matching is string-based, so just doing basic syntax check here
# - must be checksumed-base58 or bech32
try:
ngu.codecs.b58_decode(s)
assert len(s) < 40 # or else it's an xpub/xprv
return s
except: pass
try:
ngu.codecs.segwit_decode(s)
return s.lower()
except: pass
raise ValueError('bad address value: ' + s)
def truncate_address(addr):
# Truncates address to width of screen, replacing middle chars
if not version.has_qwerty:
# - 16 chars screen width
# - but 2 lost at left (menu arrow, corner arrow)
# - want to show not truncated on right side
return addr[0:6] + '' + addr[-6:]
else:
# tons of space on Q1
return addr[0:12] + '' + addr[-12:]
# EOF

View File

@ -18,19 +18,21 @@ if version.has_qwerty:
from lcd_display import CHARS_W, CHARS_H
CH_PER_W = CHARS_W
STORY_H = CHARS_H
from ux_q1 import PressRelease, ux_enter_number, ux_input_numbers, ux_input_text, ux_show_pin
from ux_q1 import ux_login_countdown, ux_confirm, ux_dice_rolling, ux_render_words
from ux_q1 import PressRelease, ux_enter_number, ux_input_text, ux_show_pin
from ux_q1 import ux_login_countdown, ux_dice_rolling, ux_render_words
from ux_q1 import ux_show_phish_words
OK = "ENTER"
X = "CANCEL"
else:
# How many characters can we fit on each line? How many lines?
# (using FontSmall)
CH_PER_W = 17
# (using FontSmall) .. except it's an approximation since variable-width font.
# - even 19 could work sometimes, but not when line is completely full
# - really should look at rendered-width of text
CH_PER_W = 19
STORY_H = 5
from ux_mk4 import PressRelease, ux_enter_number, ux_input_numbers, ux_input_text, ux_show_pin
from ux_mk4 import ux_login_countdown, ux_confirm, ux_dice_rolling, ux_render_words
from ux_mk4 import PressRelease, ux_enter_number, ux_input_text, ux_show_pin
from ux_mk4 import ux_login_countdown, ux_dice_rolling, ux_render_words
from ux_mk4 import ux_show_phish_words
OK = "OK"
X = "X"
@ -246,7 +248,21 @@ async def ux_show_story(msg, title=None, escape=None, sensitive=False,
if ch in { KEY_NFC, KEY_QR }:
return ch
async def ux_confirm(msg, title="Are you SURE ?!?", confirm_key=None):
# confirmation screen, with stock title and Y=of course.
if not version.has_qwerty and len(title) > 12:
msg = title + "\n\n" + msg
title = None
suffix = ""
if confirm_key:
suffix = ("\n\nPress (%s) to prove you read to the end of this message"
" and accept all consequences.") % confirm_key
msg += suffix
r = await ux_show_story(msg, title=title, escape=confirm_key)
return r == (confirm_key or 'y')
async def idle_logout():
import glob

View File

@ -58,17 +58,9 @@ class PressRelease:
else:
self.last_key = ch
return ch
async def ux_confirm(msg):
# confirmation screen, with stock title and Y=of course.
from ux import ux_show_story
resp = await ux_show_story("Are you SURE ?!?\n\n" + msg)
return resp == 'y'
async def ux_enter_number(prompt, max_value, can_cancel=False):
async def ux_enter_number(prompt, max_value, can_cancel=False, value=''):
# return the decimal number which the user has entered
# - default/blank value assumed to be zero
# - clamps large values to the max
@ -80,7 +72,7 @@ async def ux_enter_number(prompt, max_value, can_cancel=False):
press = PressRelease('1234567890y')
y = 26
value = ''
value = str(value)
max_w = int(log(max_value, 10) + 1)
dis.clear()
@ -122,8 +114,8 @@ async def ux_enter_number(prompt, max_value, can_cancel=False):
# cleanup leading zeros and such
value = str(min(int(value), max_value))
async def ux_input_numbers(val):
# collect a series of digits
async def ux_input_digits(val, prompt=None, maxlen=32):
# collect a series of digits.
from glob import dis
from display import FontTiny
@ -137,6 +129,11 @@ async def ux_input_numbers(val):
dis.clear()
dis.text(None, -1, footer, FontTiny)
if prompt:
dis.text(0, 0, prompt)
y += 8
dis.save()
while 1:
@ -169,7 +166,7 @@ async def ux_input_numbers(val):
# quit if they press X on empty screen
return
else:
if len(here) < 32:
if len(here) < maxlen:
here += ch
async def ux_input_text(pw, confirm_exit=True, hex_only=False, max_len=100, min_len=0, **_kws):

View File

@ -77,16 +77,8 @@ class PressRelease:
else:
self.last_key = ch
return ch
async def ux_confirm(msg):
# confirmation screen, with stock title and Y=of course.
from ux import ux_show_story
resp = await ux_show_story(msg, title="Are you SURE ?!?")
return resp == 'y'
async def ux_enter_number(prompt, max_value, can_cancel=False):
async def ux_enter_number(prompt, max_value, can_cancel=False, value=''):
# return the decimal number which the user has entered
# - default/blank value assumed to be zero
# - clamps large values to the max
@ -96,7 +88,7 @@ async def ux_enter_number(prompt, max_value, can_cancel=False):
# allow key repeat on X only?
press = PressRelease()
value = ''
value = str(value)
max_w = int(log(max_value, 10) + 1)
dis.clear()
@ -125,6 +117,7 @@ async def ux_enter_number(prompt, max_value, can_cancel=False):
elif ch == KEY_DELETE:
if value:
value = value[0:-1]
dis.text(0, 4, ' '*CHARS_W)
elif ch == KEY_CLEAR:
value = ''
dis.text(0, 4, ' '*CHARS_W)
@ -141,11 +134,6 @@ async def ux_enter_number(prompt, max_value, can_cancel=False):
# cleanup leading zeros and such
value = str(min(int(value), max_value))
async def ux_input_numbers(val):
# collect a series of digits
# - not wanted on Q1; just get the digits mixed in w/ the text.
pass
async def ux_input_text(value, confirm_exit=False, hex_only=False, max_len=100,
prompt='Enter value', min_len=0, b39_complete=False, scan_ok=False,
placeholder=None, funct_keys=None, force_xy=None):
@ -572,6 +560,7 @@ def ux_draw_words(y, num_words, words):
cols = 2
xpos = [2, 18]
else:
assert num_words in (18, 24)
cols = 3
xpos = [0, 11, 23]
@ -604,6 +593,7 @@ async def seed_word_entry(prompt, num_words, has_checksum=True, done_cb=None):
# - max word length is 8, min is 3
# - useful: simulator.py --q1 --eff --seq 'aa ee 4i '
from glob import dis
from ux import ux_confirm
assert num_words and prompt and done_cb
@ -695,8 +685,7 @@ async def seed_word_entry(prompt, num_words, has_checksum=True, done_cb=None):
elif ch == KEY_CANCEL:
if word_num >= 2:
tmp = dis.save_state()
ok = await ux_confirm("Everything you've entered will be lost.")
if not ok:
if not await ux_confirm("Everything you've entered will be lost."):
dis.restore_state(tmp)
continue
return None
@ -791,7 +780,7 @@ class QRScannerInteraction:
pass
@staticmethod
async def scan(prompt, line2=None):
async def scan(prompt, line2=None, enter_quits=False):
# draw animation, while waiting for them to scan something
# - CANCEL to abort
# - returns a string, BBQr object or None.
@ -810,6 +799,8 @@ class QRScannerInteraction:
task = asyncio.create_task(SCAN.scan_once())
escape = KEY_CANCEL + (KEY_ENTER if enter_quits else '')
ph = 0
while 1:
if task.done():
@ -821,9 +812,9 @@ class QRScannerInteraction:
ph = (ph + 1) % len(frames)
# wait for key or 250ms animation delay
ch = await ux_wait_keydown(KEY_CANCEL, 250)
ch = await ux_wait_keydown(escape, 250)
if ch == KEY_CANCEL:
if ch and (ch in escape):
data = None
break
@ -835,14 +826,14 @@ class QRScannerInteraction:
return data
async def scan_general(self, prompt, convertor):
async def scan_general(self, prompt, convertor, line2=None, enter_quits=False):
# Scan stuff, and parse it .. raise QRDecodeExplained if you don't like it
# continues until something is accepted
problem = None
problem = line2
while 1:
try:
got = await self.scan(prompt, line2=problem)
got = await self.scan(prompt, line2=problem, enter_quits=enter_quits)
if got is None:
return None
@ -852,7 +843,7 @@ class QRScannerInteraction:
problem = str(exc)
continue
except Exception as exc:
#import sys; sys.print_exception(exc)
# import sys; sys.print_exception(exc)
problem = "Unable to decode QR"
continue
@ -882,6 +873,31 @@ class QRScannerInteraction:
return await self.scan_general(prompt, convertor)
async def scan_for_addresses(self, prompt, line2=None):
# accept only payment addresses; strips BIP-21 junk that might be there
# - always a list result, might be size one
from utils import decode_bip21_text
def addr_taster(got):
# could be muliple-line text file via BBQR or single line
got = decode_qr_result(got, expect_text=True)
try:
rv = []
for ln in got.split():
what, args = decode_bip21_text(ln)
if what == 'addr':
rv.append(args[1])
if rv:
return rv
except QRDecodeExplained:
raise
except:
pass
raise QRDecodeExplained("Not a payment address?")
return await self.scan_general(prompt, addr_taster, line2=line2, enter_quits=True)
async def scan_anything(self, expect_secret=False, tmp=False):
# start a QR scan, and act on what we find, whatever it may be.

View File

@ -17,7 +17,7 @@ class WalletABC:
# chain
def yield_addresses(self, start_idx, count, change_idx=0):
# TODO: returns various tuples, with at least (idx, address, ...)
# returns various tuples, with at least (idx, address, ...)
pass
def render_address(self, change_idx, idx):

176
shared/web2fa.py Normal file
View File

@ -0,0 +1,176 @@
# (c) Copyright 2021 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# web2fa.py -- Bounce a shared secret off a Coinkite server to allow mobile app 2FA.
#
#
import ngu, ndef, aes256ctr
from utils import b2a_base64url, url_quote, B2A
from version import has_qr
from ux import show_qr_code, ux_show_story, X
# Only Coldcard.com server knows private key for this pubkey. It protects
# the privacy of the values we send to the server.
#
# = 0231301ec4acec08c1c7d0181f4ffb8be70d693acccc86cccb8f00bf2e00fcabfd
SERVER_PUBKEY = b'\x02\x31\x30\x1e\xc4\xac\xec\x08\xc1\xc7\xd0\x18\x1f\x4f\xfb\x8b\xe7\x0d\x69\x3a\xcc\xcc\x86\xcc\xcb\x8f\x00\xbf\x2e\x00\xfc\xab\xfd'
def encrypt_details(qs):
# encryption and base64 here
# - pick single-use ephemeral secp256k1 keypair
# - do ECDH to generate a shared secret based on known pubkey of server
# - AES-256-CTR encryption based on that
# - base64url encode result
# pick a random key pair, just for this session
pair = ngu.secp256k1.keypair()
my_pubkey = pair.pubkey().to_bytes(False) # compressed format
session_key = pair.ecdh_multiply(SERVER_PUBKEY)
del pair
enc = aes256ctr.new(session_key).cipher
return b2a_base64url(my_pubkey + enc(qs.encode('ascii')))
async def perform_web2fa(label, shared_secret):
# send them to web, prompt for valid response. Return True if it all worked.
expect = await nfc_share_2fa_link(label, shared_secret)
if not expect:
# aborted at NFC step
return False
if has_qr:
# Make them scan the result, for example:
#
# CCC-AUTH:E902B3DAF2D98040F3A5F556D7CCC7C22BF3D455C146C4D4C0F7CF8B7937C530
#
from ux_q1 import QRScannerInteraction
from exceptions import QRDecodeExplained
prefix = 'CCC-AUTH:'
scanner = QRScannerInteraction()
def validate(got):
if not got.startswith(prefix):
raise QRDecodeExplained("QR isn't from our site")
if got != prefix+expect:
# probably attempted replay
raise QRDecodeExplained("Incorrect code?")
return got
data = await scanner.scan_general('Scan QR shown from Web', validate)
if not data:
return False # pressed cancel
# only one legal response possible, and already validated above
return data == (prefix+expect)
else:
#
# Mk4 and other devices w/o QR scanner, require user to enter 8 digits
#
from ux_mk4 import ux_input_digits
while 1:
got = await ux_input_digits('', maxlen=8,
prompt="8-digits From Web")
if not got:
# abort if empty entry
return False
if got == expect:
# good match
return True
ch = await ux_show_story("You entered an incorrect code. You must"
" enter the digits shown after the correct"
" 2FA code is provided to the website."
" Try again or %s to stop." % X)
if ch == 'x':
return False
# not reached
return False
async def web2fa_enroll(label, ss=None):
#
# Enroll: Pick a secret and test they have loaded it into their phone.
#
# must have NFC tho
from flow import feature_requires_nfc
if not await feature_requires_nfc():
# they don't want to proceed
return None
# Pick a shared secret; 10 bytes, so encodes to 16 base32 chars
ss = ss or ngu.codecs.b32_encode(ngu.random.bytes(10))
# show a QR that app know how to use
# - problem: on Mk4, not really enough space:
# - can only show up to 42 chars, and secret is 16, required overhead is 23 => 39 min
# - 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]))
while 1:
# show QR for enroll
await show_qr_code(qr, is_alnum=False, msg="Import into 2FA Mobile App")
# important: force them to prove they store it correctly
ok = await perform_web2fa('Enroll: ' + label, 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
def make_web2fa_url(wallet_name, shared_secret):
# Build complex URL into our server w/ encrypted data
# - picking a nonce in the process
prefix = 'coldcard.com/2fa?'
# random nonce: if we get this back, then server approves of TOTP answer
if has_qr:
# data for a QR
nonce = B2A(ngu.random.bytes(32)).upper()
else:
# 8 digits for human entry
nonce = '%08d' % ngu.random.uniform(1_0000_0000)
# compose URL
qs = 'g=%s&ss=%s&nm=%s&q=%d' % (nonce, shared_secret, url_quote(wallet_name), has_qr)
# encrypt that
qs = encrypt_details(qs)
return nonce, prefix + qs
async def nfc_share_2fa_link(wallet_name, shared_secret):
#
# Share complex NFC deeplink into 2fa backend; returns expected response-code.
# Next step is to prompt for that 8-digit code (mk4) or scan QR (Q)
#
from glob import NFC
assert NFC
nonce, url = make_web2fa_url(wallet_name, shared_secret)
n = ndef.ndefMaker()
n.add_url(url, https=True)
aborted = await NFC.share_start(n, prompt="Tap for 2FA Authentication",
line2="Wallet: " + wallet_name)
return None if aborted else nonce
# EOF

View File

@ -5,7 +5,7 @@
# - for secret spliting on paper
# - all combination of partial XOR seed phrases are working wallets
#
import stash, ngu, bip39, version
import ngu, bip39, version
from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause
from ux import show_qr_code, ux_render_words, OK
from seed import word_quiz, WordNestMenu, set_seed_value, set_ephemeral_seed
@ -14,7 +14,7 @@ from menu import MenuSystem, MenuItem
from actions import goto_top_menu
from utils import encode_seed_qr, pad_raw_secret
from charcodes import KEY_QR
from stash import SecretStash, blank_object, SensitiveValues, numwords_to_len, len_to_numwords
def xor(*args):
# bit-wise xor between all args
@ -69,7 +69,7 @@ Otherwise, press {ok} to continue.'''.format(n=num_parts, ok=OK), escape='2')
raw_secret = bytes(32)
try:
with stash.SensitiveValues() as sv:
with SensitiveValues() as sv:
if sv.deltamode:
# die rather than give up our secrets
import callgate
@ -82,7 +82,7 @@ Otherwise, press {ok} to continue.'''.format(n=num_parts, ok=OK), escape='2')
# checksum of target result is useful
chk_word = words[-1]
vlen = stash.numwords_to_len(len(words))
vlen = numwords_to_len(len(words))
del words
@ -106,7 +106,7 @@ Otherwise, press {ok} to continue.'''.format(n=num_parts, ok=OK), escape='2')
assert xor(*parts) == raw_secret # selftest
finally:
stash.blank_object(raw_secret)
blank_object(raw_secret)
word_parts = [bip39.b2a_words(p).split(' ') for p in parts]
@ -147,11 +147,11 @@ async def xor_all_done(data):
chk_words = None
if data is None:
# special case, needs something already in import_xor_parts
target_words = stash.len_to_numwords(len(import_xor_parts[0]))
target_words = len_to_numwords(len(import_xor_parts[0]))
else:
new_encoded = bip39.a2b_words(data) if isinstance(data, list) else data
import_xor_parts.append(new_encoded)
target_words = stash.len_to_numwords(len(new_encoded))
target_words = len_to_numwords(len(new_encoded))
XORWordNestMenu.pop_all()
@ -203,7 +203,7 @@ async def xor_all_done(data):
from pincodes import pa
from glob import dis
enc = stash.SecretStash.encode(seed_phrase=seed)
enc = SecretStash.encode(seed_phrase=seed)
if pa.is_secret_blank():
# save it since they have no other secret
@ -217,7 +217,7 @@ async def xor_all_done(data):
# only need XFPs for UI
# xfps = [
# xfp2str(swab32(
# stash.SecretStash.decode(stash.SecretStash.encode(seed_phrase=i))[2].my_fp()
# SecretStash.decode(SecretStash.encode(seed_phrase=i))[2].my_fp()
# ))
# for i in enc_parts
# ]
@ -294,7 +294,7 @@ or press (2) for 18 words XOR.''' % OK, escape="12")
if ch == 'x': return
if ch == '1':
dis.fullscreen("Wait...")
with stash.SensitiveValues() as sv:
with SensitiveValues() as sv:
if sv.deltamode:
# die rather than give up our secrets
import callgate
@ -307,15 +307,17 @@ or press (2) for 18 words XOR.''' % OK, escape="12")
# Add from Seed Vault?
# filter only those that are correct length and type from seed vault
opt = []
seeds = [] if pa.is_deltamode() else settings.master_get("seeds", [])
for i, (xfp_str, hex_str, _, _) in enumerate(seeds):
for i, (xfp_str, hex_str, _, _) in enumerate(settings.master_get("seeds", [])):
raw = pad_raw_secret(hex_str)
if raw[0] & 0x80:
# seed phrase
sk = raw[1:1 + stash.len_from_marker(raw[0])]
if stash.len_to_numwords(len(sk)) == desired_num_words:
opt.append((i, xfp_str, sk))
del seeds
nw = SecretStash.is_words(raw)
if nw and nw == desired_num_words:
# it is words, and right length
sk = SecretStash.decode_words(raw, bin_mode=True)
opt.append((i, xfp_str, sk))
blank_object(raw)
if opt:
escape = "2"
msg = ("Seed Vault is enabled. %d stored seeds have suitable type and length."

View File

@ -31,6 +31,8 @@ def pytest_addoption(parser):
default=False, help="run on real dev")
parser.addoption("--sim", action="store_true",
default=True, help="run on simulator")
parser.addoption("--localhost", action="store_true",
default=False, help="test web stuff against coldcard.com code running on localhost:5070")
parser.addoption("--manual", action="store_true",
default=False, help="operator must press keys on real CC")

View File

@ -23,3 +23,5 @@ git+https://github.com/coinkite/bsms-bitcoin-secure-multisig-setup.git@master#eg
# BBQr library
git+https://github.com/coinkite/BBQr.git@master#egg=bbqr&subdirectory=python
# for backend testing
requests==2.32.3

View File

@ -288,7 +288,8 @@ def main():
# test_nvram_mk4 needs to run without --eff
# se2 duress wallet activated as ephemeral seed requires proper `settings.load`
test_args = ["--set", "nfc=1"]
if test_module in ["test_ephemeral.py", "test_notes.py"]:
if test_module in ["test_ephemeral.py", "test_notes.py", "test_ccc.py"]:
# proper `settings.load` _ virtual disk
test_args = ["--set", "nfc=1", "--set", "vidsk=1"]
if args.q1 and '--q1' not in test_args:

1100
testing/test_ccc.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -137,8 +137,8 @@ def test_detector_xp(code, try_decode):
def test_urldecode(url, sim_exec):
from urllib.parse import unquote_plus
cmd = "from utils import url_decode; " + \
f"RV.write(url_decode({url!r}))"
cmd = "from utils import url_unquote; " + \
f"RV.write(url_unquote({url!r}))"
result = sim_exec(cmd)
assert result == unquote_plus(url)

View File

@ -98,12 +98,12 @@ def get_seed_value_ux(goto_home, pick_menu_item, need_keypress, cap_story,
pick_menu_item("Danger Zone")
pick_menu_item("Seed Functions")
pick_menu_item('View Seed Words')
time.sleep(.01)
time.sleep(.1)
title, body = cap_story()
assert ('Are you SURE' in body) or ('Are you SURE' in title)
assert 'can control all funds' in body
press_select() # skip warning
time.sleep(0.01)
time.sleep(0.1)
title, story = cap_story()
if nfc:
@ -149,7 +149,7 @@ def get_identity_story(goto_home, pick_menu_item, cap_story):
@pytest.fixture
def goto_eph_seed_menu(goto_home, pick_menu_item, cap_story, need_keypress):
def goto_eph_seed_menu(goto_home, pick_menu_item, cap_story, need_keypress, is_q1):
def _doit():
goto_home()
pick_menu_item("Advanced/Tools")
@ -397,6 +397,7 @@ def generate_ephemeral_words(goto_eph_seed_menu, pick_menu_item, press_select,
assert len(e_seed_words) == num_words
need_keypress("6") # skip quiz
time.sleep(.1)
press_select() # yes - I'm sure
confirm_tmp_seed(seedvault=seed_vault)

View File

@ -1925,8 +1925,9 @@ def _test_single_sig_sighash(cap_story, press_select, start_sign, end_sign, dev,
psbt_sh = x.as_b64_str()
# make useful reference psbt along the way
open(f'debug/sighash-{sighash[0] if len(sighash) == 1 else "MIX"}.psbt'\
.replace('|', '-'), 'wt').write(psbt_sh)
with open(f'debug/sighash-{sighash[0] if len(sighash) == 1 else "MIX"}.psbt'\
.replace('|', '-'), 'wt') as f:
f.write(psbt_sh)
# get story out of CC via visualize feature
start_sign(psbt_sh_bytes, False, stxn_flags=STXN_VISUALIZE)

View File

@ -95,33 +95,34 @@ def word_menu_entry(cap_menu, pick_menu_item, is_q1, do_keypresses, cap_screen):
# easier for us on Q, but have to anticipate the autocomplete
for n, w in enumerate(words, start=1):
do_keypresses(w[0:2])
time.sleep(0.50)
time.sleep(0.05)
if 'Next key' in cap_screen():
do_keypresses(w[2])
time.sleep(.1)
time.sleep(.01)
if 'Next key' in cap_screen():
if len(w) > 3:
do_keypresses(w[3])
else:
do_keypresses(KEY_DOWN)
time.sleep(.1)
time.sleep(.01)
pat = rf'{n}:\s?{w}'
for x in range(10):
if re.search(pat, cap_screen()):
break
time.sleep(0.20)
time.sleep(0.02)
else:
raise RuntimeError('timeout')
if len(words) == 23:
do_keypresses(KEY_DOWN)
time.sleep(.3)
time.sleep(.03)
cap_scr = cap_screen()
while 'Next key' in cap_scr:
target = cap_scr.split("\n")[-1].replace("Next key: ", "")
# picks first choice!?
do_keypresses(target[0])
time.sleep(.3)
time.sleep(.03)
cap_scr = cap_screen()
else:
cap_scr = cap_screen()
@ -994,7 +995,7 @@ def test_dump_menutree(sim_execfile):
sim_execfile('devtest/menu_dump.py')
if 0:
# show what the final word can be (debug only)
# show what the final word can be (debug only) Mk4 only
def test_23_words(goto_home, pick_menu_item, cap_story, need_keypress, unit_test, cap_menu, word_menu_entry, get_secrets, reset_seed_words, cap_screen_qr, qr_quality_check):
unit_test('devtest/clear_seed.py')