commit
6b9e2ef9b9
@ -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
90
docs/web2fa.md
Normal 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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
859
shared/ccc.py
Normal 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
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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"]
|
||||
|
||||
192
shared/psbt.py
192
shared/psbt.py
@ -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]
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
30
shared/ux.py
30
shared/ux.py
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
176
shared/web2fa.py
Normal 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
|
||||
@ -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."
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
1100
testing/test_ccc.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user