Compare commits

...

24 Commits

Author SHA1 Message Date
Peter D. Gray
2312569c05
little fixes 2025-03-28 15:08:16 -04:00
Peter D. Gray
e8ae63922d
rework post-signing save process 2025-03-28 15:02:19 -04:00
Peter D. Gray
2723f93d7c
refactor-out msgsign.py from auth.py 2025-03-28 09:58:24 -04:00
Peter D. Gray
a0524ebe60
robustness fix 2025-03-28 09:54:42 -04:00
Peter D. Gray
75ca5608dd
sign our leg from teleport co-signer menu 2025-03-28 09:54:42 -04:00
Peter D. Gray
64cb4d20f7
teleport 2-of-15 2025-03-28 09:54:42 -04:00
Peter D. Gray
66874de399
bugfix 2025-03-28 09:54:42 -04:00
Peter D. Gray
16753f7418
historical note 2025-03-28 09:54:41 -04:00
scgbckbone
ad56b3644a
remove finms: always finalize multisig txns if possible 2025-03-28 09:54:37 -04:00
Peter D. Gray
f552b881d6
refactoring NFC 2025-03-28 09:38:42 -04:00
Peter D. Gray
cf8b05780b
catch bad numeric password 2025-03-28 09:37:18 -04:00
Peter D. Gray
2e6fe8ec68
nits 2025-03-28 09:37:18 -04:00
Peter D. Gray
f4aaed0506
bugfix, cleanups 2025-03-28 09:37:17 -04:00
Peter D. Gray
126962e785
test bug fixes 2025-03-28 09:37:17 -04:00
scgbckbone
e15670bef6
fixes 2025-03-28 09:37:17 -04:00
Peter D. Gray
aadf53d0c4
Multisig PSBT support 2025-03-28 09:37:10 -04:00
Peter D. Gray
749459752f
remove checksum on rx pubkey 2025-03-28 09:23:49 -04:00
Peter D. Gray
fcbe05ed68
nits 2025-03-28 09:23:49 -04:00
Peter D. Gray
bd172063e3
key teleport tests 2025-03-28 09:23:48 -04:00
Peter D. Gray
ab89a4db2a
fixes 2025-03-28 09:23:48 -04:00
Peter D. Gray
2cc91accaa
teleport tests 2025-03-28 09:23:48 -04:00
Peter D. Gray
8a589d22b1
added quick note 2025-03-28 09:23:48 -04:00
Peter D. Gray
5c463e5cde
Seedvault refactor, more on KT 2025-03-28 09:23:48 -04:00
Peter D. Gray
44dc30a57a
Rebased 2025-03-28 09:23:34 -04:00
48 changed files with 2819 additions and 881 deletions

214
docs/key-teleport.md Normal file
View File

@ -0,0 +1,214 @@
# Key Teleport
Purpose: Send a small quantity of very secret data between two COLDCARD Q systems, with
no risk of anything in the middle learning the secret.
Method: ECDH and AES-256-CTR plus an extra wrapping layer, transmitted over a mixture of
NFC, passive websites, and QR/BBQr codes.
# Protocol Overview
## Steps
- Receiver picks an EC keypair, stores it in settings, and publishes the pubkey via a QR/NFC
- Sender gets that, picks own keypair, and does ECDH to arrive at a shared session key
- Sender picks a human-readable secret which is independent of anything else (P key)
- The secret data (perhaps a seed phrase, XPRV, secure note, etc) is AES encrypted with P key,
then encrypted + MAC added with session key
- Data packet is sent to receiver, who can reconstruct the session key via ECDH
- Prompt user for the P key to finish decoding
- Decoded secret value is saved to Seed Vault or secure notes as appropriate
- Receiver destroys EC keypair used in transfer
### When used for PSBT Multisig
- No action required on receiver
- Sender uses the pubkey derived from pre-shared XPUB involved in the multisig wallet.
- Same steps, but drops immediately into signing process when decoded correctly
## Notes and Limitations
- max 4k (after encoding) of data is possible due to HTTP limitations
- all transfers are "data typed" and decode only expected on COLDCARD
- Q model is required due to the use of QR codes to ultimately get data into the COLDCARD
# Details
## Data Type Codes
The first byte encodes what the package contents (under all the encryption).
- `s` - 12/18/24 words/raw master/xprv - 17-72 bytes follow, encoded in an internal format
- `x` - XPRV mode, full details - 4 bytes (XPRV) + base58 *decoded* binary-XPRV follows
- `n` - one or many notes export (JSON array)
- `v` - seed vault export (JSON: one secret key but includes name, source of key)
- `p` - binary PSBT to be signed, perhaps multisig but not required.
## QR details
BBQr is always used for the QR's involved in this process, even if
they are short enough for a normal QR code. Because the BBQr is
being generated by the COLDCARD embedded firmware, it will not be
compressed and will always be Base32 encoded.
New type codes for BBQr are defined for the purposes of this application:
- `R` contains `(pubkey)` ... begins the process from receiver; compressed pubkey is 33 bytes
- `S` contains `(pubkey)(data)` ... data from sender; first 33 bytes are sender's pubkey
- `E` for Multisig PSBT: `(randint)(data)` ... randint (4 byte nonce) indicates which
derived subkey from pre-shared xpub associated with receiver
All the data is encrypted with the exception of the pubkey or randint. Keep in mind
those are both nonce values picked uniquely for each transfer.
### PSBT Key Selection
When sending PSBT data, a nonce is picked at random by the sender
in range: `0..(2^28)`
This nonce is called `randint`. The receiver's pubkey will be
.../20250317/(randint)
where `...` is the derivation used in the multisig wallet for the co-signer who will
receive the package. The sender's keypair has the same sub key path assuming all
co-signers have same derivation path from root (not required).
Because both the sender and receiver already have each other's XPUB they can derive
the appropriate pubkeys (and privkey for their side) without communicating
more than `randint`. The sending COLDCARD will pick a new random value each time.
When receiving a multisig PSBT encrypted this way, the receiver does not need
to any setup (nor numeric password) and can receive a QR code at any time.
This works because the shared multisig wallet is already setup. Receiver will
take the nonce value (randint) and seach all pre-defined multisig wallets for
any pubkey that can decrypt the package successfully (based on checksum inside
first layer of ECDH encryption).
The next layer of encryption (paranoid password) is unchanged.
## Encryption Details
AES-256-CTR is used exclusively. Session key is picked via ECDH with final
key value being the SHA256 over 64 bytes of coordinate X (concat) Y.
While ECDH is enough to assure privacy from men in the middle, we
add an additional layer of encryption. We call this the "paranoid key" internally
and in the UX it is called "Teleport Password".
The user sees a random 8-character password, generated as a random 40-bit value, but
shown in Base32 (8 chars) for the human to enter. We apply PBKDF2-SHA512 with
an interation count of 5000 to stretch that to 512 bits, of which we use half.
The session key is used as the key for the KDF, and the entered value as salt.
- ECDH arrives at session key
- decrypt (AES-256-CTR) the binary body of message
- verify checksum:
- final 2 bytes should be `== SHA256(decrypted body[0:-2])[-2:]`
- if not, corruption, truncation, or wrong keys
- if that decryption is correct, then prompt user for the paranoid key (8 chars)
- stretch that value using session key and 5000 iterations of PBKDF2-SHA512
- use upper 256 bits and run AES-256-CTR again
- same checksum of 2 bytes of SHA256 are found inside after decryption
Encryption adds 4 bytes of overhead because of these MAC values,
but should catch truncation and bitrot. There are no other
protections against truncation as length data is not transmitted.
# Receiver Password
When the teleport process is started, the receiver shares his pubkey
as QR. However, we also show an 8-digit numeric password. The
purpose of this is force the receiver to share this separately from
the pubkey QR on another channel. The code is randomly picked, but
only represents about 26 bits of entropy and is stretched with
a single round of SHA256 before being used as a AES-256-CTR key
to decrypt the pubkey. No checksum verifies correct
decryption, so any code is accepted, and will with near-100% odds
decrypt to a valid pubkey.
When the sender is given the receiver's pubkey via QR code, it
prompts for the numeric code and uses it to decrypt the pubkey.
Thus a MiTM who injects their pubkey will be detected and blocked.
The "paranoid key" serves the same role in the other direction but
it is Base32 character set, so it will not look similar or be
confusing.
# Web Component
In order to "teleport" the contents of a QR code over NFC, we will
publish a static website directly from an open Github repository.
The single-page website contains javascript code which looks at the
"hash" part of the incoming URL (`window.location.hash`) and if it
meets the requirements, renders a large QR. The QR data must look like
a correctly-encoded BBQr with one of the 3 type-codes above (`R` `S` or `E`).
Otherwise the website could render any QR, which we don't want to
support.
The page will offer "copy to clipboard" features for the data inside
the QR as a URL (ie. same URL as shown) and as an image and of course,
the COLDCARD Q can scan from the web browser screen itself.
When the BBQr data is larger than comfortable for a single QR, the
website can split into a multi-frame BBQr. The website can
do this without understanding the contents of the BBQr data (all
of which is encrypted). Download options will be provided for
single-frame QR, animated PNG, and "stacked BBQr" (a single tall
PNG with each QR frame stacked).
On the COLDCARD side, when NFC is tapped, it will offer a long URL
to this site with the data to be transferred "after the hash". This
is optional since the QR can be shown on the Q itself, and would
pass the same data.
Since the website is running on Github, Coinkite does not have
access to IP addresses or other log details. Because the data for
teleport is "after the hash" it is never sent to Github's servers
but remains in the browser only. All JS resources referenced by the
webpage will have content hashes applied to prevent interference,
and the site will be served over SSL.
# UX Details
- When the receive process is started by the user, a pubkey is picked
and stored, so that they can come back later (after a power cycle)
and make use of the data encoded by the sender. However once a package
is decoded successfully, that key is deleted.
- Sender must start by scanning the QR from a receiver. Then can pick what
to send, from secure notes to seeds and so on.
- For PSBT multisig, user must pick a single co-signer (who hasn't already
signed) and the QR is prepared for that receiver. Because we
cannot do arbitary conbining, it's best if the next signer continues
to teleport the updated PSBT to further signers. In other words,
a daisy-chain pattern is prefered to a star patter. The signer
who completes the Mth (of N) signature will be able to finalize
the transaction, and ideally with PushTx feature, broadcast it.
# Security Comments
## Such short passwords?
We are using 8-character passwords because we want them to be
practical to share over non-digital channels such as a voice phone
call, or hand-written note.
It is important to remind users that the passwords should be sent
by a different channel from the QR itself. Best is to call up your
other party and say the letters to them directly.
## Is it safe to save image of QR to cloud?
Yes, this seems safe. Of course, if you can control it, perhaps not
a risk to accept... but the QR is encrypted via ECDH using a key
that is forgotten after the transfer, so forward privacy is protected.
Also your cloud service (or photo roll, chat app log, etc) will not
have the 8-character password which is also required unpack the secrets.
The QR codes themselves are fully random and do not reveal the
identity of your COLDCARD, your on chain funds or anything linked
to you.

View File

@ -43,4 +43,13 @@ This lists the new changes that have not yet been published in a normal release.
## 1.3.2Q - 2025-03-?? ## 1.3.2Q - 2025-03-??
- Feature: Key Teleport -- Easily and securely move seed phrases, secure notes/passwords and PSBT
between two Q using QR codes and/or NFC with helper website. See protocol spec in
[docs/key-teleport.md][https://github.com/Coldcard/firmware/blob/master/docs/key-teleport.md]
- can send master seed (words, xprv), anything held in seed vault, secure notes/passwords
(singular, or all) and PSBT involved in a multisig
- ECDH to create session key for AES-256-CTR, with another layer of AES-256-CTR using a
short password (stretched by PBKDF2-SHA512) inside
- receiver shows sender a QR and a numeric code; sender replies with a QR and 8-char
password
- Enhancement: Always choose the biggest possible display size for QR - Enhancement: Always choose the biggest possible display size for QR

View File

@ -1292,11 +1292,11 @@ async def verify_backup(*A):
# do a limited CRC-check over encrypted file # do a limited CRC-check over encrypted file
await backups.verify_backup_file(fn) await backups.verify_backup_file(fn)
async def import_extended_key_as_secret(extended_key, ephemeral, meta=None): async def import_extended_key_as_secret(extended_key, ephemeral, origin=None):
try: try:
import seed import seed
if ephemeral: if ephemeral:
await seed.set_ephemeral_seed_extended_key(extended_key, meta=meta) await seed.set_ephemeral_seed_extended_key(extended_key, origin=origin)
else: else:
await seed.set_seed_extended_key(extended_key) await seed.set_seed_extended_key(extended_key)
except ValueError: except ValueError:
@ -1360,7 +1360,7 @@ async def import_xprv(_1, _2, item):
extended_key = ln extended_key = ln
break break
await import_extended_key_as_secret(extended_key, ephemeral, meta='Imported XPRV') await import_extended_key_as_secret(extended_key, ephemeral, origin='Imported XPRV')
# not reached; will do reset. # not reached; will do reset.
async def need_clear_seed(*a): async def need_clear_seed(*a):
@ -1648,7 +1648,7 @@ async def list_files(*A):
card.securely_blank_file(fn) card.securely_blank_file(fn)
break break
else: else:
from auth import write_sig_file from msgsign import write_sig_file
sig_nice = write_sig_file([(digest, fn)]) sig_nice = write_sig_file([(digest, fn)])
await ux_show_story("Signature file %s written." % sig_nice) await ux_show_story("Signature file %s written." % sig_nice)
@ -1657,13 +1657,14 @@ async def list_files(*A):
async def file_picker(suffix=None, min_size=1, max_size=1000000, taster=None, async def file_picker(suffix=None, min_size=1, max_size=1000000, taster=None,
choices=None, none_msg=None, force_vdisk=False, slot_b=None, choices=None, none_msg=None, force_vdisk=False, slot_b=None,
allow_batch_sign=False, ux=True): allow_batch=False, ux=True):
# present a menu w/ a list of files... to be read # present a menu w/ a list of files... to be read
# - optionally, enforce a max size, and provide a "tasting" function # - optionally, enforce a max size, and provide a "tasting" function
# - if msg==None, don't prompt, just do the search and return list # - if (not ux), don't prompt, just do the search and return list
# - if choices is provided; skip search process # - if choices is provided; skip search process
# - escape: allow these chars to skip picking process # - escape: allow these chars to skip picking process
# - slot_b: None=>pick slot w/ card in it, or A if both. # - slot_b: None=>pick slot w/ card in it, or A if both.
# - allow_batch: adds an "all of the above" choice: ("menu label", menu_handler)
if choices is None: if choices is None:
choices = [] choices = []
@ -1707,7 +1708,7 @@ async def file_picker(suffix=None, min_size=1, max_size=1000000, taster=None,
label = fn label = fn
while label in sofar: while label in sofar:
# just the file name isn't unique enough sometimes? # just the file name isn't unique enough sometimes?
# - shouldn't happen anymore now that we dno't support internal FS # - shouldn't happen anymore now that we don't support internal FS
# - unless we do muliple paths # - unless we do muliple paths
label += path.split('/')[-1] + '/' + fn label += path.split('/')[-1] + '/' + fn
@ -1744,10 +1745,10 @@ async def file_picker(suffix=None, min_size=1, max_size=1000000, taster=None,
choices.sort() choices.sort()
items = [MenuItem(label, f=clicked, arg=(path, fn)) for label, path, fn in choices] items = [MenuItem(label, f=clicked, arg=(path, fn)) for label, path, fn in choices]
if allow_batch_sign and len(choices) > 1: if allow_batch and len(choices) > 1:
# we know that each choices member is psbt as allow_batch_sign is only True # Allow an "all" selection
# in Ready To Sign label, funct = allow_batch
items.insert(0, MenuItem("[Sign All]", f=batch_sign, arg=choices)) items.insert(0, MenuItem(label, f=funct, arg=choices))
menu = MenuSystem(items) menu = MenuSystem(items)
the_ux.push(menu) the_ux.push(menu)
@ -1895,7 +1896,7 @@ from your desktop wallet software or command line tools.\n\n'''
input_psbt = path + '/' + fn input_psbt = path + '/' + fn
else: else:
# multiples - ask which, and offer batch to sign them all # multiples - ask which, and offer batch to sign them all
input_psbt = await file_picker(choices=choices, allow_batch_sign=True) input_psbt = await file_picker(choices=choices, allow_batch=("[Sign All]", batch_sign))
if not input_psbt: if not input_psbt:
return return
@ -1945,7 +1946,7 @@ async def verify_sig_file(*a):
return return
# start the process # start the process
from auth import verify_txt_sig_file from msgsign import verify_txt_sig_file
await verify_txt_sig_file(fn) await verify_txt_sig_file(fn)

View File

@ -14,7 +14,7 @@ from uasyncio import sleep_ms
from uhashlib import sha256 from uhashlib import sha256
from ubinascii import hexlify as b2a_hex from ubinascii import hexlify as b2a_hex
from glob import settings from glob import settings
from auth import write_sig_file from msgsign 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_QR, KEY_NFC, KEY_PAGE_UP, KEY_PAGE_DOWN, KEY_HOME, KEY_LEFT, KEY_RIGHT
from charcodes import KEY_CANCEL from charcodes import KEY_CANCEL
from utils import show_single_address, problem_file_line, truncate_address from utils import show_single_address, problem_file_line, truncate_address
@ -391,7 +391,7 @@ Press (3) if you really understand and accept these risks.
else: else:
# only custom path sets allow_change to False # only custom path sets allow_change to False
# msg sign # msg sign
from auth import sign_with_own_address from msgsign import sign_with_own_address
await sign_with_own_address(path, addr_fmt) await sign_with_own_address(path, addr_fmt)
elif n is None: elif n is None:

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@
import compat7z, stash, ckcc, chains, gc, sys, bip39, uos, ngu import compat7z, stash, ckcc, chains, gc, sys, bip39, uos, ngu
from ubinascii import hexlify as b2a_hex from ubinascii import hexlify as b2a_hex
from ubinascii import unhexlify as a2b_hex from ubinascii import unhexlify as a2b_hex
from utils import pad_raw_secret from utils import deserialize_secret
from ux import ux_show_story, ux_confirm, ux_dramatic_pause, OK, X, ux_input_text from ux import ux_show_story, ux_confirm, ux_dramatic_pause, OK, X, ux_input_text
import version, ujson import version, ujson
from uio import StringIO from uio import StringIO
@ -127,7 +127,7 @@ def extract_raw_secret(chain, vals):
assert 'raw_secret' in vals assert 'raw_secret' in vals
rs = vals.pop('raw_secret') rs = vals.pop('raw_secret')
raw = pad_raw_secret(rs) raw = deserialize_secret(rs)
# check we can decode this right (might be different firmare) # check we can decode this right (might be different firmare)
opmode, bits, node = stash.SecretStash.decode(raw) opmode, bits, node = stash.SecretStash.decode(raw)
@ -277,7 +277,7 @@ async def restore_tmp_from_dict_ll(vals):
from seed import set_ephemeral_seed from seed import set_ephemeral_seed
from actions import goto_top_menu from actions import goto_top_menu
await set_ephemeral_seed(raw, chain, meta="Coldcard Backup") await set_ephemeral_seed(raw, chain, origin="Coldcard Backup")
for k, v in vals.items(): for k, v in vals.items():
if not k[:8] == "setting.": if not k[:8] == "setting.":
continue continue

View File

@ -12,7 +12,8 @@ b32encode = ngu.codecs.b32_encode
b32decode = ngu.codecs.b32_decode b32decode = ngu.codecs.b32_decode
TYPE_LABELS = dict(P='PSBT File', T='Transaction', J='JSON', C='CBOR', U='Unicode Text', TYPE_LABELS = dict(P='PSBT File', T='Transaction', J='JSON', C='CBOR', U='Unicode Text',
X='Executable', B='Binary') X='Executable', B='Binary',
R='KT Rx', S='KT Tx', E='KT PSBT')
def int2base36(n): def int2base36(n):
# convert an integer to two digits of base 36 string. 00 thu ZZ as bytes # convert an integer to two digits of base 36 string. 00 thu ZZ as bytes
@ -242,7 +243,7 @@ class BBQrState:
# provide UX -- even if we didn't use it # provide UX -- even if we didn't use it
dis.draw_bbqr_progress(hdr, self.parts) dis.draw_bbqr_progress(hdr, self.parts)
# do we need more still? # return T if we need more parts still
return (len(self.parts) < hdr.num_parts) or self.runt return (len(self.parts) < hdr.num_parts) or self.runt
class BBQrStorage: class BBQrStorage:

View File

@ -4,7 +4,7 @@
# #
import gc, chains, version, ngu, web2fa, bip39, re import gc, chains, version, ngu, web2fa, bip39, re
from chains import NLOCK_IS_TIME from chains import NLOCK_IS_TIME
from utils import swab32, xfp2str, truncate_address, pad_raw_secret, show_single_address from utils import swab32, xfp2str, truncate_address, deserialize_secret, show_single_address
from glob import settings, dis from glob import settings, dis
from ux import ux_confirm, ux_show_story, the_ux, OK, ux_dramatic_pause, ux_enter_number, ux_aborted from 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 menu import MenuSystem, MenuItem, start_chooser
@ -45,7 +45,7 @@ class CCCFeature:
def get_encoded_secret(cls): def get_encoded_secret(cls):
# Gets the key C as encoded binary secret, compatible w/ # Gets the key C as encoded binary secret, compatible w/
# encodings used in stash. # encodings used in stash.
return pad_raw_secret(settings.get('ccc')['secret']) return deserialize_secret(settings.get('ccc')['secret'])
@classmethod @classmethod
def get_xfp(cls): def get_xfp(cls):
@ -369,7 +369,7 @@ be ready to show it as a QR, before proceeding.'''
from actions import goto_top_menu from actions import goto_top_menu
enc = CCCFeature.get_encoded_secret() enc = CCCFeature.get_encoded_secret()
await set_ephemeral_seed(enc, meta='Key C from CCC') await set_ephemeral_seed(enc, origin='Key C from CCC')
goto_top_menu() goto_top_menu()
@ -725,10 +725,10 @@ async def gen_or_import():
elif ch == '6': elif ch == '6':
# pick existing from Seed Vault # pick existing from Seed Vault
enc = await SeedVaultChooserMenu.pick(words_only=True) picked = await SeedVaultChooserMenu.pick(words_only=True)
if not enc: return None if picked:
words = SecretStash.decode_words(enc) words = SecretStash.decode_words(deserialize_secret(picked.encoded))
await enable_step1(words) await enable_step1(words)
return None return None

View File

@ -101,7 +101,7 @@ def decode_qr_result(got, expect_secret=False, expect_text=False, expect_bbqr=Fa
try: try:
ty, final_size, got = got.storage.finalize() ty, final_size, got = got.storage.finalize()
except BaseException as exc: except BaseException as exc:
import sys; sys.print_exception(exc) #import sys; sys.print_exception(exc)
raise QRDecodeExplained("BBQr decode failed: " + str(exc)) raise QRDecodeExplained("BBQr decode failed: " + str(exc))
if expect_bbqr: if expect_bbqr:
@ -136,6 +136,13 @@ def decode_qr_result(got, expect_secret=False, expect_text=False, expect_bbqr=Fa
what = "smsg" what = "smsg"
return what, (got,) return what, (got,)
elif ty in 'RSE':
# key-teleport related
if ty == 'R' and len(got) != 33:
raise QRDecodeExplained("Truncated KT RX")
return 'teleport', (ty, got)
else: else:
msg = TYPE_LABELS.get(ty, 'Unknown FileType') msg = TYPE_LABELS.get(ty, 'Unknown FileType')
raise QRDecodeExplained("Sorry, %s not useful." % msg) raise QRDecodeExplained("Sorry, %s not useful." % msg)

View File

@ -11,7 +11,7 @@ from ux import ux_show_story, ux_enter_bip32_index, the_ux, ux_confirm, ux_drama
from menu import MenuItem, MenuSystem from menu import MenuItem, MenuSystem
from ubinascii import hexlify as b2a_hex from ubinascii import hexlify as b2a_hex
from ubinascii import b2a_base64 from ubinascii import b2a_base64
from auth import write_sig_file from msgsign import write_sig_file
from utils import chunk_writer, xfp2str, swab32 from utils import chunk_writer, xfp2str, swab32
from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL
@ -263,7 +263,7 @@ async def drv_entro_step2(_1, picked, _2, just_pick=False):
xfp_str = xfp2str(settings.get("xfp", 0)) xfp_str = xfp2str(settings.get("xfp", 0))
await seed.set_ephemeral_seed( await seed.set_ephemeral_seed(
encoded, encoded,
meta='BIP85 Derived from [%s], index=%d' % (xfp_str, index) origin='BIP85 Derived from [%s], index=%d' % (xfp_str, index)
) )
goto_top_menu() goto_top_menu()
break break

View File

@ -8,7 +8,7 @@ from ucollections import OrderedDict
from utils import xfp2str, swab32, chunk_writer from utils import xfp2str, swab32, chunk_writer
from ux import ux_show_story from ux import ux_show_story
from glob import settings from glob import settings
from auth import write_sig_file from msgsign import write_sig_file
from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH
from charcodes import KEY_NFC, KEY_CANCEL, KEY_QR from charcodes import KEY_NFC, KEY_CANCEL, KEY_QR
from ownership import OWNERSHIP from ownership import OWNERSHIP

View File

@ -39,11 +39,14 @@ if version.has_battery:
from battery import battery_idle_timeout_chooser, brightness_chooser from battery import battery_idle_timeout_chooser, brightness_chooser
from q1 import scan_and_bag from q1 import scan_and_bag
from notes import make_notes_menu from notes import make_notes_menu
from teleport import kt_start_rx, kt_send_file_psbt
else: else:
battery_idle_timeout_chooser = None battery_idle_timeout_chooser = None
brightness_chooser = None brightness_chooser = None
scan_and_bag = None scan_and_bag = None
make_notes_menu = None make_notes_menu = None
kt_start_rx = None
kt_send_file_psbt = None
# #
@ -213,6 +216,7 @@ FileMgmtMenu = [
MenuItem('Export Wallet', predicate=has_secrets, menu=WalletExportMenu), #dup elsewhere MenuItem('Export Wallet', predicate=has_secrets, menu=WalletExportMenu), #dup elsewhere
MenuItem('Sign Text File', predicate=has_secrets, f=sign_message_on_sd), MenuItem('Sign Text File', predicate=has_secrets, f=sign_message_on_sd),
MenuItem('Batch Sign PSBT', predicate=has_secrets, f=batch_sign), MenuItem('Batch Sign PSBT', predicate=has_secrets, f=batch_sign),
MenuItem('Teleport Multisig PSBT', predicate=version.has_qr, f=kt_send_file_psbt),
MenuItem('List Files', f=list_files), MenuItem('List Files', f=list_files),
MenuItem('Verify Sig File', f=verify_sig_file), MenuItem('Verify Sig File', f=verify_sig_file),
MenuItem('NFC File Share', predicate=nfc_enabled, f=nfc_share_file, shortcut=KEY_NFC), MenuItem('NFC File Share', predicate=nfc_enabled, f=nfc_share_file, shortcut=KEY_NFC),
@ -253,6 +257,7 @@ AdvancedPinnedVirginMenu = [ # Has PIN but no secrets yet
MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu), MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu),
MenuItem("Upgrade Firmware", menu=UpgradeMenu, predicate=is_not_tmp), MenuItem("Upgrade Firmware", menu=UpgradeMenu, predicate=is_not_tmp),
MenuItem("File Management", menu=FileMgmtMenu), MenuItem("File Management", menu=FileMgmtMenu),
MenuItem("Key Teleport (start)", f=kt_start_rx, predicate=version.has_qr),
MenuItem('Paper Wallets', f=make_paper_wallet), MenuItem('Paper Wallets', f=make_paper_wallet),
MenuItem('Perform Selftest', f=start_selftest), MenuItem('Perform Selftest', f=start_selftest),
MenuItem("I Am Developer.", menu=maybe_dev_menu), MenuItem("I Am Developer.", menu=maybe_dev_menu),
@ -362,6 +367,7 @@ AdvancedNormalMenu = [
f=drv_entro_start), f=drv_entro_start),
MenuItem("View Identity", f=view_ident), MenuItem("View Identity", f=view_ident),
MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu), MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu),
MenuItem("Key Teleport (start)", f=kt_start_rx, predicate=version.has_qr),
MenuItem('Paper Wallets', f=make_paper_wallet), MenuItem('Paper Wallets', f=make_paper_wallet),
ToggleMenuItem('Enable HSM', 'hsmcmd', ['Default Off', 'Enable'], ToggleMenuItem('Enable HSM', 'hsmcmd', ['Default Off', 'Enable'],
story=("Enable HSM? Enables all user management commands, and other HSM-only USB commands. " story=("Enable HSM? Enables all user management commands, and other HSM-only USB commands. "

View File

@ -18,7 +18,7 @@ from glob import settings
# - 8 bytes exact satoshi value => base64 (pad trimmed) => 11 chars # - 8 bytes exact satoshi value => base64 (pad trimmed) => 11 chars
# - stored satoshi value is XOR'ed with LSB from prevout txn hash, which isn't stored # - stored satoshi value is XOR'ed with LSB from prevout txn hash, which isn't stored
# - result is a 31 character string for each history entry, plus 4 overhead => 35 each # - result is a 31 character string for each history entry, plus 4 overhead => 35 each
# - if we store 30 of those it's about 25% of total setting space # - if we store 30 of those it's about 25% of total setting space (Mk3)
# #
HISTORY_SAVED = const(30) HISTORY_SAVED = const(30)
HISTORY_MAX_MEM = const(128) HISTORY_MAX_MEM = const(128)

View File

@ -806,8 +806,12 @@ class Display:
else: else:
pat = '' # clear line pat = '' # clear line
self.text(None, -3, pat) if count == hdr.num_parts and count == 1:
# skip the BS, it's a simple one
self.progress_bar_show(1)
return
self.text(None, -3, pat)
self.text(None, -2, 'Keep scanning more...' if count < hdr.num_parts else 'Got all parts!') self.text(None, -2, 'Keep scanning more...' if count < hdr.num_parts else 'Got all parts!')
self.text(None, -1, '%s: %d of %d parts' % (hdr.file_label(), count, hdr.num_parts), self.text(None, -1, '%s: %d of %d parts' % (hdr.file_label(), count, hdr.num_parts),
dark=True) dark=True)

View File

@ -5,6 +5,7 @@ freeze_as_mpy('', [
'actions.py', 'actions.py',
'address_explorer.py', 'address_explorer.py',
'auth.py', 'auth.py',
'msgsign.py',
'backups.py', 'backups.py',
'callgate.py', 'callgate.py',
'chains.py', 'chains.py',

View File

@ -1,4 +1,4 @@
# Q1/Mk4 only files; would not be needed on Mk3 or earlier. # Q1 only files; would not be needed on Mk4
freeze_as_mpy('', [ freeze_as_mpy('', [
'psram.py', 'psram.py',
'mk4.py', 'mk4.py',
@ -18,6 +18,7 @@ freeze_as_mpy('', [
'battery.py', 'battery.py',
'notes.py', 'notes.py',
'calc.py', 'calc.py',
'teleport.py',
], opt=0) ], opt=0)
# Optimize data-like files, since no need to debug them. # Optimize data-like files, since no need to debug them.

519
shared/msgsign.py Normal file
View File

@ -0,0 +1,519 @@
# (c) Copyright 2025 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# Signatures over text ... not transactions.
#
import stash, chains, sys, gc, ngu, ujson, version
from ubinascii import b2a_base64, a2b_base64
from ubinascii import hexlify as b2a_hex
from ubinascii import unhexlify as a2b_hex
from uhashlib import sha256
from public_constants import MSG_SIGNING_MAX_LENGTH
from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH
from charcodes import KEY_QR, KEY_NFC, KEY_ENTER, KEY_CANCEL
from ux import ux_show_story, OK, X, ux_enter_bip32_index
from utils import problem_file_line, to_ascii_printable, show_single_address
from files import CardSlot, CardMissingError, needs_microsd
def rfc_signature_template(msg, addr, sig):
# RFC2440 <https://www.ietf.org/rfc/rfc2440.txt> style signatures, popular
# since the genesis block, but not really part of any BIP as far as I know.
#
return [
"-----BEGIN BITCOIN SIGNED MESSAGE-----\n",
"%s\n" % msg,
"-----BEGIN BITCOIN SIGNATURE-----\n",
"%s\n" % addr,
"%s\n" % sig,
"-----END BITCOIN SIGNATURE-----\n"
]
def parse_armored_signature_file(contents):
# XXX limited parser: will fail w/ messages containing dashes
sep = "-----"
assert contents.count(sep) == 6, "Armor text MUST be surrounded by exactly five (5) dashes."
temp = contents.split(sep)
msg = temp[2].strip()
addr_sig = temp[4].strip()
addr, sig_str = addr_sig.split()
return msg, addr, sig_str
def verify_signature(msg, addr, sig_str):
# Look at a base64 signature, and given address. Do full verification.
# - raise on errors
# - return warnings as string: can only be mismatch between addr format encoded in recid
warnings = ""
script = None
hash160 = None
invalid_addr_fmt_msg = "Invalid address format - must be one of p2pkh, p2sh-p2wpkh, or p2wpkh."
invalid_addr = "Invalid signature for message."
if addr[0] in "1mn":
addr_fmt = AF_CLASSIC
decoded_addr = ngu.codecs.b58_decode(addr)
hash160 = decoded_addr[1:] # remove prefix
elif addr.startswith("bc1q") or addr.startswith("tb1q") or addr.startswith("bcrt1q"):
if len(addr) > 44: # testnet/mainnet max singlesig len 42, regtest 44
# p2wsh
raise ValueError(invalid_addr_fmt_msg)
addr_fmt = AF_P2WPKH
_, _, hash160 = ngu.codecs.segwit_decode(addr)
elif addr[0] in "32":
addr_fmt = AF_P2WPKH_P2SH
decoded_addr = ngu.codecs.b58_decode(addr)
script = decoded_addr[1:] # remove prefix
else:
raise ValueError(invalid_addr_fmt_msg)
try:
sig_bytes = a2b_base64(sig_str)
if not sig_bytes or len(sig_bytes) != 65:
# can return b'' in case of wrong, can also raise
raise ValueError("invalid encoding")
header_byte = sig_bytes[0]
header_base = chains.current_chain().sig_hdr_base(addr_fmt)
if (header_byte - header_base) not in (0, 1, 2, 3):
# wrong header value only - this can still verify OK
warnings += "Specified address format does not match signature header byte format."
# least two significant bits
rec_id = (header_byte - 27) & 0x03
# need to normalize it to 31 base for ngu
new_header_byte = 31 + rec_id
sig = ngu.secp256k1.signature(bytes([new_header_byte]) + sig_bytes[1:])
except ValueError as e:
raise ValueError("Parsing signature failed - %s." % str(e))
digest = chains.current_chain().hash_message(msg.encode('ascii'))
try:
rec_pubkey = sig.verify_recover(digest)
except ValueError as e:
raise ValueError("Invalid signature for msg - %s." % str(e))
rec_pubkey_bytes = rec_pubkey.to_bytes()
rec_hash160 = ngu.hash.hash160(rec_pubkey_bytes)
if script:
target = bytes([0, 20]) + rec_hash160
target = ngu.hash.hash160(target)
if target != script:
raise ValueError(invalid_addr)
else:
if rec_hash160 != hash160:
raise ValueError(invalid_addr)
return warnings
async def verify_armored_signed_msg(contents, digest_check=True):
# Verify on-disk checksums of files listed inside a signed file.
# - digest_check=False for NFC cases, where we do not have filesystem
from glob import dis
dis.fullscreen("Verifying...")
try:
msg, addr, sig_str = parse_armored_signature_file(contents)
except Exception as e:
e_line = problem_file_line(e)
await ux_show_story("Malformed signature file. %s %s" % (str(e), e_line), title="FAILURE")
return
try:
sig_warn = verify_signature(msg, addr, sig_str)
except Exception as e:
await ux_show_story(str(e), title="ERROR")
return
title = "CORRECT"
warn_msg = ""
err_msg = ""
story = "Good signature by address:\n%s" % show_single_address(addr)
if digest_check:
digest_prob = verify_signed_file_digest(msg)
if digest_prob:
err, digest_warn = digest_prob
if digest_warn:
title = "WARNING"
wmsg_base = "not present. Contents verification not possible."
if len(digest_warn) == 1:
fname = digest_warn[0][0]
warn_msg += "'%s' is %s" % (fname, wmsg_base)
else:
warn_msg += "Files:\n" + "\n".join("> %s" % fname for fname, _ in digest_warn)
warn_msg += "\nare %s" % wmsg_base
if err:
title = "ERROR"
for fname, calc, got in err:
err_msg += ("Referenced file '%s' has wrong contents.\n"
"Got:\n%s\n\nExpected:\n%s" % (fname, got, calc))
if sig_warn:
# we know not ours only because wrong recid header used & not BIP-137 compliant
story = "Correctly signed, but not by this Coldcard. %s" % sig_warn
await ux_show_story('\n\n'.join(m for m in [err_msg, story, warn_msg] if m), title=title)
async def verify_txt_sig_file(filename):
# copy message into memory
try:
with CardSlot() as card:
with card.open(filename, 'rt') as fd:
text = fd.read()
except CardMissingError:
await needs_microsd()
return
except Exception as e:
await ux_show_story('Error: ' + str(e))
return
await verify_armored_signed_msg(text)
async def msg_sign_ux_get_subpath(addr_fmt):
# Ask for account number, and maybe change component of path for signature.
# - return full derivation path to be used.
purpose = chains.af_to_bip44_purpose(addr_fmt)
chain_n = chains.current_chain().b44_cointype
acct = await ux_enter_bip32_index('Account Number:') or 0
ch = await ux_show_story(title="Change?",
msg="Press (0) to use internal/change address,"
" %s to use external/receive address." % OK, escape="0")
change = 1 if ch == '0' else 0
idx = await ux_enter_bip32_index('Index Number:') or 0
return "m/%dh/%dh/%dh/%d/%d" % (purpose, chain_n, acct, change, idx)
def sign_export_contents(content_list, deriv, addr_fmt, pk=None):
# Return signed message over hashes of files.
msg2sign = make_signature_file_msg(content_list)
bitcoin_digest = chains.current_chain().hash_message(msg2sign)
sig_bytes, addr = sign_message_digest(bitcoin_digest, deriv, "Signing...", addr_fmt, pk=pk)
sig = b2a_base64(sig_bytes).decode().strip()
return rfc_signature_template(addr=addr, msg=msg2sign.decode(), sig=sig)
def verify_signed_file_digest(msg):
# Look inside a list of hashs and file names, and
# verify at their actual hashes and return list of issues if any.
from files import CardSlot
parsed_msg = parse_signature_file_msg(msg)
if not parsed_msg:
# not our format
return
try:
err, warn = [], []
with CardSlot() as card:
for digest, fname in parsed_msg:
path = card.abs_path(fname)
if not card.exists(path):
warn.append((fname, None))
continue
path = card.abs_path(fname)
md = sha256()
with open(path, "rb") as f:
while True:
chunk = f.read(1024)
if not chunk:
break
md.update(chunk)
h = b2a_hex(md.digest()).decode().strip()
if h != digest:
err.append((fname, h, digest))
except:
# fail silently if issues with reading files or SD issues
# no digest checking
return
return err, warn
def write_sig_file(content_list, derive=None, addr_fmt=AF_CLASSIC, pk=None, sig_name=None):
from glob import dis
if derive is None:
ct = chains.current_chain().b44_cointype
derive = "m/44'/%d'/0'/0/0" % ct
fpath = content_list[0][1]
if len(content_list) > 1:
# we're signing contents of more files - need generic name for sig file
assert sig_name
sig_nice = sig_name + ".sig"
sig_fpath = fpath.rsplit("/", 1)[0] + "/" + sig_nice
else:
sig_fpath = fpath.rsplit(".", 1)[0] + ".sig"
sig_nice = sig_fpath.split("/")[-1]
sig_gen = sign_export_contents([(h, f.split("/")[-1]) for h, f in content_list],
derive, addr_fmt, pk=pk)
with open(sig_fpath, 'wt') as fd:
for i, part in enumerate(sig_gen):
fd.write(part)
return sig_nice
def validate_text_for_signing(text, only_printable=True):
# Check for some UX/UI traps in the message itself.
# - messages must be short and ascii only. Our charset is limited
# - too many spaces, leading/trailing can be an issue
# MSG_MAX_SPACES = 4 # impt. compared to -=- positioning
result = to_ascii_printable(text, only_printable=only_printable)
length = len(result)
assert length >= 2, "msg too short (min. 2)"
assert length <= MSG_SIGNING_MAX_LENGTH, "msg too long (max. %d)" % MSG_SIGNING_MAX_LENGTH
assert " " not in result, 'too many spaces together in msg(max. 3)'
# other confusion w/ whitepace
assert result[0] != ' ', 'leading space(s) in msg'
assert result[-1] != ' ', 'trailing space(s) in msg'
# looks ok
return result
def addr_fmt_from_subpath(subpath):
if not subpath:
af = "p2pkh"
elif subpath[:4] == "m/84":
af = "p2wpkh"
elif subpath[:4] == "m/49":
af = "p2sh-p2wpkh"
else:
af = "p2pkh"
return af
def parse_msg_sign_request(data):
subpath = ""
addr_fmt = None
is_json = False
# sparrow compat
if "signmessage" in data:
try:
mark, subpath, *msg_line = data.split(" ", 2)
assert mark == "signmessage"
# subpath will be verified & cleaned later
assert msg_line[0][:6] == "ascii:"
text = msg_line[0][6:]
return text, subpath, addr_fmt_from_subpath(subpath), is_json
except:pass
# ===
try:
data_dict = ujson.loads(data.strip())
text = data_dict.get("msg", None)
if text is None:
raise AssertionError("MSG required")
subpath = data_dict.get("subpath", subpath)
addr_fmt = data_dict.get("addr_fmt", addr_fmt)
is_json = True
except ValueError:
lines = data.split("\n")
assert len(lines) >= 1, "min 1 line"
assert len(lines) <= 3, "max 3 lines"
if len(lines) == 1:
text = lines[0]
elif len(lines) == 2:
text, subpath = lines
else:
text, subpath, addr_fmt = lines
if not addr_fmt:
addr_fmt = addr_fmt_from_subpath(subpath)
if not subpath:
subpath = chains.STD_DERIVATIONS[addr_fmt]
subpath = subpath.format(
coin_type=chains.current_chain().b44_cointype,
account=0, change=0, idx=0
)
return text, subpath, addr_fmt, is_json
def make_signature_file_msg(content_list):
# list of tuples consisting of (hash, file_name)
return b"\n".join([
b2a_hex(h) + b" " + fname.encode()
for h, fname in content_list
])
def parse_signature_file_msg(msg):
# only succeed for our format digest + 2 spaces + fname
try:
res = []
lines = msg.split('\n')
for ln in lines:
d, fn = ln.split(' ')
# should not need to strip if our file format, so dont
# is hex? is 32 bytes long?
assert len(a2b_hex(d)) == 32
res.append((d, fn))
return res
except:
return
def sign_message_digest(digest, subpath, prompt, addr_fmt=AF_CLASSIC, pk=None):
# do the signature itself!
from glob import dis
ch = chains.current_chain()
if prompt:
dis.fullscreen(prompt, percent=.25)
if pk is None:
with stash.SensitiveValues() as sv:
node = sv.derive_path(subpath)
dis.progress_sofar(50, 100)
pk = node.privkey()
addr = ch.address(node, addr_fmt)
else:
# if private key is provided, derivation subpath is ignored
# and given private key is used for signing.
node = ngu.hdnode.HDNode().from_chaincode_privkey(bytes(32), pk)
dis.progress_sofar(50, 100)
addr = ch.address(node, addr_fmt)
dis.progress_sofar(75, 100)
rv = ngu.secp256k1.sign(pk, digest, 0).to_bytes()
# AF_CLASSIC header byte base 31 is returned by default from ngu - NOOP
if addr_fmt != AF_CLASSIC:
# ngu only produces header base for compressed p2pkh, anyways get only rec_id
rv = bytearray(rv)
rec_id = (rv[0] - 27) & 0x03
rv[0] = rec_id + ch.sig_hdr_base(addr_fmt=addr_fmt)
dis.progress_bar_show(1)
return rv, addr
async def ux_sign_msg(txt, approved_cb=None, kill_menu=True):
from menu import MenuSystem, MenuItem
from ux import the_ux
async def done(_1, _2, item):
from auth import approve_msg_sign
text, af = item.arg
subpath = await msg_sign_ux_get_subpath(af)
await approve_msg_sign(text, subpath, af, approved_cb=approved_cb,
kill_menu=kill_menu, only_printable=False)
# pick address format
rv = [
MenuItem(chains.addr_fmt_label(af), f=done, arg=(txt, af))
for af in chains.SINGLESIG_AF
]
the_ux.push(MenuSystem(rv))
async def msg_signing_done(signature, address, text):
from ux import import_export_prompt
ch = await import_export_prompt("Signed Msg", is_import=False,
no_qr=not version.has_qwerty)
if ch == KEY_CANCEL:
return
if isinstance(ch, dict):
await sd_sign_msg_done(signature, address, text, "msg_sign", **ch)
elif version.has_qr and ch == KEY_QR:
from ux_q1 import qr_msg_sign_done
await qr_msg_sign_done(signature, address, text)
elif ch in KEY_NFC+"3":
from glob import NFC
if NFC:
await NFC.msg_sign_done(signature, address, text)
async def sign_with_own_address(subpath, addr_fmt):
# used for cases where we already have the key picked, but need the message:
# * address_explorer custom path
# * positive ownership test
from glob import dis
to_sign = await ux_input_text("", scan_ok=True, prompt="Enter MSG") # max len is 100 only here
if not to_sign: return
await approve_msg_sign(to_sign, subpath, addr_fmt, approved_cb=msg_signing_done, kill_menu=True)
async def sd_sign_msg_done(signature, address, text, base=None, orig_path=None,
slot_b=None, force_vdisk=False):
from glob import dis
dis.fullscreen('Generating...')
out_fn = None
sig = b2a_base64(signature).decode('ascii').strip()
while 1:
# try to put back into same spot
# add -signed to end.
target_fname = base + '-signed.txt'
lst = [orig_path]
if orig_path:
lst.append(None)
for path in lst:
try:
with CardSlot(readonly=True, slot_b=slot_b, force_vdisk=force_vdisk) as card:
out_full, out_fn = card.pick_filename(target_fname, path)
out_path = path
if out_full: break
except CardMissingError:
prob = 'Missing card.\n\n'
out_fn = None
if not out_fn:
# need them to insert a card
prob = ''
else:
# attempt write-out
try:
dis.fullscreen("Saving...")
with CardSlot(slot_b=slot_b, force_vdisk=force_vdisk) as card:
with card.open(out_full, 'wt') as fd:
# save in full RFC style
# gen length is 6
gen = rfc_signature_template(addr=address, msg=text, sig=sig)
for i, part in enumerate(gen):
fd.write(part)
# success and done!
break
except OSError as exc:
prob = 'Failed to write!\n\n%s\n\n' % exc
sys.print_exception(exc)
# fall through to try again
# prompt them to input another card?
ch = await ux_show_story(prob + "Please insert an SDCard to receive signed message, "
"and press %s." % OK, title="Need Card")
if ch == 'x':
await ux_aborted()
return
# done.
msg = "Created new file:\n\n%s" % out_fn
await ux_show_story(msg, title='File Signed')
# EOF

View File

@ -4,7 +4,7 @@
# #
import stash, chains, ustruct, ure, uio, sys, ngu, uos, ujson, version import stash, chains, ustruct, ure, uio, sys, ngu, uos, ujson, version
from utils import xfp2str, str2xfp, swab32, cleanup_deriv_path, keypath_to_str, to_ascii_printable from utils import xfp2str, str2xfp, swab32, cleanup_deriv_path, keypath_to_str, to_ascii_printable
from utils import str_to_keypath, problem_file_line, parse_extended_key, get_filesize from utils import str_to_keypath, problem_file_line, parse_extended_key, get_filesize, B2A
from ux import ux_show_story, ux_confirm, ux_dramatic_pause, ux_clear_keys from ux import ux_show_story, ux_confirm, ux_dramatic_pause, ux_clear_keys
from ux import import_export_prompt, ux_enter_bip32_index, ux_enter_number, OK, X from ux import import_export_prompt, ux_enter_bip32_index, ux_enter_number, OK, X
from files import CardSlot, CardMissingError, needs_microsd from files import CardSlot, CardMissingError, needs_microsd
@ -22,6 +22,9 @@ TRUST_VERIFY = const(0)
TRUST_OFFER = const(1) TRUST_OFFER = const(1)
TRUST_PSBT = const(2) TRUST_PSBT = const(2)
# Arbitrary value, not 0 or 1, used to derive a pubkey from preshared xpub in Key Teleport
KT_RXPUBKEY_DERIV = const(20250317)
class MultisigOutOfSpace(RuntimeError): class MultisigOutOfSpace(RuntimeError):
pass pass
@ -468,6 +471,10 @@ class MultisigWallet(WalletABC):
return set(xp_idx for xp_idx, (wxfp, _, _) in enumerate(self.xpubs) return set(xp_idx for xp_idx, (wxfp, _, _) in enumerate(self.xpubs)
if wxfp == xfp) if wxfp == xfp)
def xpubs_from_xfp(self, xfp):
# return list of XPUB's which match xfp; typically one.
return [xpub for (wxfp, _, xpub) in self.xpubs if wxfp == xfp]
def yield_addresses(self, start_idx, count, change_idx=0): def yield_addresses(self, start_idx, count, change_idx=0):
# Assuming a suffix of /0/0 on the defined prefix's, yield # Assuming a suffix of /0/0 on the defined prefix's, yield
# possible deposit addresses for this wallet. # possible deposit addresses for this wallet.
@ -1180,6 +1187,98 @@ Press (1) to see extended public keys, '''.format(M=M, N=N, name=self.name, exp=
return await ux_show_story(msg, title=self.name) return await ux_show_story(msg, title=self.name)
# Key Teleport support, where a co-signers pubkeys are used for ECDH
def kt_make_rxkey(self, xfp):
# Derive the receiver's pubkey from preshared xpub and a special derivation
# - also provide the keypair we're using from our side of connection
# - returns 4 byte nonce which is sent un-encrypted, his_pubkey and my_keypair
ri = ngu.random.uniform(1<<28)
try:
xpub, = self.xpubs_from_xfp(xfp)
except ValueError:
raise RuntimeError("dup or missing xfp")
node = self.chain.deserialize_node(xpub, AF_P2SH)
node.derive(KT_RXPUBKEY_DERIV, False)
node.derive(ri, False)
pubkey = node.pubkey()
kp = self.kt_my_keypair(ri)
#print("psbt sender: ri=%d toward xfp: %s ... %s" % (ri, xfp2str(xfp), B2A(pubkey)))
return ri.to_bytes(4, 'big'), pubkey, kp
def kt_my_keypair(self, ri):
# Calc my keypair for sending PSBT files.
#
my_xfp = settings.get('xfp')
# Find the derivation path used by my leg of this multisig
deriv = list(self.xfp_paths[my_xfp])
deriv.append(KT_RXPUBKEY_DERIV)
deriv.append(ri)
path = keypath_to_str(deriv)
with stash.SensitiveValues() as sv:
node = sv.derive_path(path)
kp = ngu.secp256k1.keypair(node.privkey())
#print("my keypair: ri=%d my_xfp=%s ... %s" % (
# ri, xfp2str(my_xfp), B2A(kp.pubkey().to_bytes())))
return kp
@classmethod
def kt_search_rxkey(cls, payload):
# Construct the keypair for to be decryption
# - has to try pubkey each all the unique XFP for all co-signers in all wallets
# - checks checksum of ECDH unwrapped data to see if it's the right one
# - returns session key, decrypted first layer, and XFP of sender
from teleport import decode_step1
# this nonce is part of the derivation path so each txn gets new keys
ri = int.from_bytes(payload[0:4], 'big')
my_xfp = settings.get('xfp')
kp = None
for ms in cls.iter_wallets():
if my_xfp not in ms.xfp_paths:
# we aren't a party to this MS wallet? not supposed to happen, but
# easy to handle
continue
if (not kp) or (kp_deriv != ms.xfp_paths[my_xfp]):
# my keypair is cachable if my derivation path is the
# same in subsequent MS wallet
kp = ms.kt_my_keypair(ri)
kp_deriv = ms.xfp_paths[my_xfp]
for xfp, deriv, xpub in ms.xpubs:
if xfp == my_xfp: continue
node = ms.chain.deserialize_node(xpub, AF_P2SH)
node.derive(KT_RXPUBKEY_DERIV, False)
node.derive(ri, False)
his_pubkey = node.pubkey()
#print("try decode: ri=%d toward xfp: %s ... from %s <= to %s" % (
# ri, xfp2str(xfp), B2A(his_pubkey), B2A(kp.pubkey().to_bytes())), end=' ... ')
# if implied session key decodes the checksum, it is right
ses_key, body = decode_step1(kp, his_pubkey, payload[4:])
if ses_key:
return ses_key, body, xfp
return None, None, None
async def no_ms_yet(*a): async def no_ms_yet(*a):
# action for 'no wallets yet' menu item # action for 'no wallets yet' menu item
await ux_show_story("You don't have any multisig wallets yet.") await ux_show_story("You don't have any multisig wallets yet.")

View File

@ -665,7 +665,7 @@ class NFCHandler:
if winner: if winner:
try: try:
from seed import set_ephemeral_seed_words from seed import set_ephemeral_seed_words
await set_ephemeral_seed_words(winner, meta='NFC Import') await set_ephemeral_seed_words(winner, origin='NFC Import')
except Exception as e: except Exception as e:
#import sys; sys.print_exception(e) #import sys; sys.print_exception(e)
await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e)))
@ -716,14 +716,14 @@ class NFCHandler:
msg_sign_request=winner) msg_sign_request=winner)
async def msg_sign_done(self, signature, address, text): async def msg_sign_done(self, signature, address, text):
from auth import rfc_signature_template_gen from msgsign import rfc_signature_template
sig = b2a_base64(signature).decode('ascii').strip() sig = b2a_base64(signature).decode('ascii').strip()
armored_str = "".join(rfc_signature_template_gen(addr=address, msg=text, sig=sig)) armored_str = "".join(rfc_signature_template(addr=address, msg=text, sig=sig))
await self.share_text(armored_str) await self.share_text(armored_str)
async def verify_sig_nfc(self): async def verify_sig_nfc(self):
from auth import verify_armored_signed_msg from msgsign import verify_armored_signed_msg
f = lambda x: x.decode().strip() if b"SIGNED MESSAGE" in x else None f = lambda x: x.decode().strip() if b"SIGNED MESSAGE" in x else None
winner = await self._nfc_reader(f, 'Unable to find signed message.') winner = await self._nfc_reader(f, 'Unable to find signed message.')

View File

@ -318,7 +318,7 @@ class NoteContentBase:
await start_export([self]) await start_export([self])
async def sign_txt_msg(self, a, b, item): async def sign_txt_msg(self, a, b, item):
from auth import ux_sign_msg, msg_signing_done from msgsign import ux_sign_msg, msg_signing_done
txt = item.arg txt = item.arg
await ux_sign_msg(txt, approved_cb=msg_signing_done, kill_menu=False) await ux_sign_msg(txt, approved_cb=msg_signing_done, kill_menu=False)
@ -537,7 +537,7 @@ class NoteContent(NoteContentBase):
async def start_export(notes): async def start_export(notes):
# Save out notes/passwords # Save out notes/passwords
from glob import NFC from glob import NFC
from auth import write_sig_file from msgsign import write_sig_file
import ujson as json import ujson as json
from ux_q1 import show_bbqr_codes from ux_q1 import show_bbqr_codes
@ -619,7 +619,13 @@ async def import_from_other(menu, *a):
records = json.load(open(fn, 'rt')) records = json.load(open(fn, 'rt'))
# We have some JSON, parsed now. # We have some JSON, parsed now.
# - should dedup, but we aren't await import_from_json(records)
await ux_dramatic_pause('Saved.', 3)
menu.update_contents()
async def import_from_json(records):
# should dedup, but we aren't
try: try:
assert 'coldcard_notes' in records, 'Incorrect format' assert 'coldcard_notes' in records, 'Incorrect format'
@ -629,14 +635,11 @@ async def import_from_other(menu, *a):
was = list(settings.get('notes', [])) was = list(settings.get('notes', []))
was.extend(new) was.extend(new)
settings.put('notes', was) settings.set('notes', was)
settings.set('secnap', True)
settings.save() settings.save()
except Exception as e: except Exception as e:
await ux_show_story(title="Failure", msg=str(e) + '\n\n' + problem_file_line(e)) await ux_show_story(title="Failure", msg=str(e) + '\n\n' + problem_file_line(e))
await ux_dramatic_pause('Saved.', 3)
menu.update_contents()
# EOF # EOF

View File

@ -66,6 +66,8 @@ from utils import call_later_ms
# unsort_ms = (bool) Allow unsorted multisig with BIP-67 disabled # unsort_ms = (bool) Allow unsorted multisig with BIP-67 disabled
# msas = multisig address show (do not censor multisig addresses) # msas = multisig address show (do not censor multisig addresses)
# ccc = (complex) If present, CCC feature is enabled and key details stored here. # ccc = (complex) If present, CCC feature is enabled and key details stored here.
# ktrx = (privkey) Key teleport Rx has been started, this will be our keypair
# kttx = (words) Key teleport Tx: last words used (paranoid key)
# Stored w/ key=00 for access before login # Stored w/ key=00 for access before login
# _skip_pin = hard code a PIN value (dangerous, only for debug) # _skip_pin = hard code a PIN value (dangerous, only for debug)
@ -279,6 +281,7 @@ class SettingsObject:
def leaving_master_seed(self): def leaving_master_seed(self):
# going from master seed to a tmp seed, so capture a few values we need. # going from master seed to a tmp seed, so capture a few values we need.
self.save_if_dirty()
SettingsObject.master_nvram_key = self.nvram_key SettingsObject.master_nvram_key = self.nvram_key

View File

@ -340,7 +340,7 @@ class OwnershipCache:
is_alnum=(wallet.addr_fmt & (AFC_BECH32 | AFC_BECH32M)), is_alnum=(wallet.addr_fmt & (AFC_BECH32 | AFC_BECH32M)),
msg=addr, is_addrs=True) msg=addr, is_addrs=True)
elif not is_ms and (ch == "0"): # only singlesig elif not is_ms and (ch == "0"): # only singlesig
from auth import sign_with_own_address from msgsign import sign_with_own_address
await sign_with_own_address(sp, wallet.addr_fmt) await sign_with_own_address(sp, wallet.addr_fmt)
else: else:
break break

View File

@ -83,7 +83,7 @@ class PaperWalletMaker:
try: try:
import ngu import ngu
from auth import write_sig_file from msgsign import write_sig_file
from chains import current_chain from chains import current_chain
from serializations import hash160 from serializations import hash160
from stash import blank_object from stash import blank_object

View File

@ -2232,6 +2232,19 @@ class psbtObject(psbtProxy):
sigs = sigs[:self.active_multisig.M] sigs = sigs[:self.active_multisig.M]
return sigs return sigs
def multisig_xfps_needed(self):
# provide the set of xfp's that still need to sign PSBT
# - used to find which multisig-signer needs to go next
rv = set()
for inp in self.inputs:
for pk, pth in inp.subpaths.items():
if pk in inp.part_sigs:
continue
if pk in inp.added_sigs:
continue
rv.add(pth[0])
return rv
def finalize(self, fd): def finalize(self, fd):
# Stream out the finalized transaction, with signatures applied # Stream out the finalized transaction, with signatures applied
# - assumption is it's complete already. # - assumption is it's complete already.

View File

@ -18,7 +18,7 @@ class QRDisplaySingle(UserInteraction):
# Show a single QR code for (typically) a list of addresses, or a single value. # Show a single QR code for (typically) a list of addresses, or a single value.
def __init__(self, addrs, is_alnum, start_n=0, sidebar=None, msg=None, def __init__(self, addrs, is_alnum, start_n=0, sidebar=None, msg=None,
is_addrs=False, force_msg=False): is_addrs=False, force_msg=False, allow_nfc=True):
self.is_alnum = is_alnum self.is_alnum = is_alnum
self.idx = 0 # start with first address self.idx = 0 # start with first address
self.invert = False # looks better, but neither mode is ideal self.invert = False # looks better, but neither mode is ideal
@ -29,6 +29,7 @@ class QRDisplaySingle(UserInteraction):
self.msg = msg self.msg = msg
self.qr_data = None self.qr_data = None
self.force_msg = force_msg self.force_msg = force_msg
self.allow_nfc = allow_nfc
def calc_qr(self, msg): def calc_qr(self, msg):
# Version 2 would be nice, but can't hold what we need, even at min error correction, # Version 2 would be nice, but can't hold what we need, even at min error correction,
@ -95,13 +96,15 @@ class QRDisplaySingle(UserInteraction):
self.redraw() self.redraw()
continue continue
elif NFC and (ch == '3' or ch == KEY_NFC): elif NFC and (ch == '3' or ch == KEY_NFC):
# Share any QR over NFC! if not self.allow_nfc:
await NFC.share_text(self.addrs[self.idx]) # not a valid as text over NFC sometimes; treat as cancel
self.redraw() break
else:
# Share any QR over NFC!
await NFC.share_text(self.addrs[self.idx])
self.redraw()
continue continue
elif ch in 'xy'+KEY_ENTER+KEY_CANCEL: elif ch in 'xy'+KEY_ENTER+KEY_CANCEL:
if dis.has_lcd:
dis.real_clear() # bugfix
break break
elif len(self.addrs) == 1: elif len(self.addrs) == 1:
continue continue
@ -123,6 +126,10 @@ class QRDisplaySingle(UserInteraction):
self.qr_data = None self.qr_data = None
self.redraw() self.redraw()
# bugfix
if dis.has_lcd:
dis.real_clear()
async def interact(self): async def interact(self):
await self.interact_bare() await self.interact_bare()
the_ux.pop() the_ux.pop()

View File

@ -201,7 +201,7 @@ class QRScanner:
if not rv: continue if not rv: continue
if rv[0:2] == 'B$' and bbqr.collect(rv): if rv[0:2] == 'B$' and bbqr.collect(rv):
# BBQr protocol detected; collect more data # BBQr protocol detected, accepted need to collect more data
continue continue
break break

View File

@ -13,7 +13,8 @@
import ngu, uctypes, bip39, random, version import ngu, uctypes, bip39, random, version
from ucollections import OrderedDict from ucollections import OrderedDict
from menu import MenuItem, MenuSystem from menu import MenuItem, MenuSystem
from utils import xfp2str, parse_extended_key, swab32, pad_raw_secret, problem_file_line, wipe_if_deltamode from utils import xfp2str, parse_extended_key, swab32
from utils import deserialize_secret, problem_file_line, wipe_if_deltamode
from uhashlib import sha256 from uhashlib import sha256
from ux import ux_show_story, the_ux, ux_dramatic_pause, ux_confirm, OK, X from ux import ux_show_story, the_ux, ux_dramatic_pause, ux_confirm, OK, X
from ux import PressRelease, ux_input_text, show_qr_code from ux import PressRelease, ux_input_text, show_qr_code
@ -27,13 +28,22 @@ from nvstore import SettingsObject
from files import CardMissingError, needs_microsd, CardSlot from files import CardMissingError, needs_microsd, CardSlot
from charcodes import KEY_QR, KEY_ENTER, KEY_CANCEL, KEY_CLEAR from charcodes import KEY_QR, KEY_ENTER, KEY_CANCEL, KEY_CLEAR
from uasyncio import sleep_ms from uasyncio import sleep_ms
from ucollections import namedtuple
# seed words lengths we support: 24=>256 bits, and recommended # seed words lengths we support: 24=>256 bits, and recommended
VALID_LENGTHS = (24, 18, 12) VALID_LENGTHS = (24, 18, 12)
# bit flag that means "also include bare prefix as a valid word" # bit flag that means "also include bare prefix as a valid word"
_PREFIX_MARKER = const(1<<26) _PREFIX_MARKER = const(1<<26)
# what we store (in JSON as a tuple) for each seed vault key.
# - 'encoded' is hex, and has is trimmed of right side zeros
VaultEntry = namedtuple('VaultEntry', 'xfp encoded label origin')
def seed_vault_iter():
# iterate over all seeds in the vault; returns VaultEntry instances.
for tup in settings.master_get("seeds", []):
yield VaultEntry(*tup)
def letter_choices(sofar='', depth=0, thres=5): def letter_choices(sofar='', depth=0, thres=5):
# make a list of word completions based on indicated prefix # make a list of word completions based on indicated prefix
@ -413,14 +423,16 @@ async def new_from_dice(nwords):
def in_seed_vault(encoded): def in_seed_vault(encoded):
# Test if indicated secret is in the seed vault already. # Test if indicated secret is in the seed vault already.
seeds = settings.master_get("seeds", []) hss = None
if seeds: for rec in seed_vault_iter():
ss = SecretStash.storage_serialize(encoded) if not hss:
if ss in [s[1] for s in seeds]: hss = SecretStash.storage_serialize(encoded)
if hss == rec.encoded:
return True return True
return False return False
async def add_seed_to_vault(encoded, meta=None): async def add_seed_to_vault(encoded, origin=None, label=None):
if not settings.master_get("seedvault", False): if not settings.master_get("seedvault", False):
# seed vault disabled # seed vault disabled
@ -460,10 +472,9 @@ async def add_seed_to_vault(encoded, meta=None):
return return
# Save it into master settings # Save it into master settings
seeds.append((new_xfp_str, rec = VaultEntry(xfp=new_xfp_str, encoded=SecretStash.storage_serialize(encoded),
SecretStash.storage_serialize(encoded), label=(label or xfp_ui), origin=origin)
xfp_ui, seeds.append(tuple(rec))
meta))
settings.master_set("seeds", seeds) settings.master_set("seeds", seeds)
@ -472,9 +483,10 @@ async def add_seed_to_vault(encoded, meta=None):
return True return True
async def set_ephemeral_seed(encoded, chain=None, summarize_ux=True, bip39pw='', async def set_ephemeral_seed(encoded, chain=None, summarize_ux=True, bip39pw='',
is_restore=False, meta=None): is_restore=False, origin=None, label=None):
# Capture tmp seed into vault, if so enabled, and regardless apply it as new tmp.
if not is_restore: if not is_restore:
await add_seed_to_vault(encoded, meta=meta) await add_seed_to_vault(encoded, origin=origin, label=label)
dis.fullscreen("Wait...") dis.fullscreen("Wait...")
applied, err_msg = pa.tmp_secret(encoded, chain=chain, bip39pw=bip39pw) applied, err_msg = pa.tmp_secret(encoded, chain=chain, bip39pw=bip39pw)
@ -491,11 +503,11 @@ async def set_ephemeral_seed(encoded, chain=None, summarize_ux=True, bip39pw='',
return applied return applied
async def set_ephemeral_seed_words(words, meta): async def set_ephemeral_seed_words(words, origin):
dis.progress_bar_show(0.1) dis.progress_bar_show(0.1)
encoded = seed_words_to_encoded_secret(words) encoded = seed_words_to_encoded_secret(words)
dis.progress_bar_show(0.5) dis.progress_bar_show(0.5)
await set_ephemeral_seed(encoded, meta=meta) await set_ephemeral_seed(encoded, origin=origin)
goto_top_menu() goto_top_menu()
async def ephemeral_seed_generate_from_dice(nwords): async def ephemeral_seed_generate_from_dice(nwords):
@ -512,7 +524,7 @@ async def ephemeral_seed_generate_from_dice(nwords):
words = await approve_word_list(seed, nwords, ephemeral=True) words = await approve_word_list(seed, nwords, ephemeral=True)
if words: if words:
dis.fullscreen("Applying...") dis.fullscreen("Applying...")
await set_ephemeral_seed_words(words, meta='Dice') await set_ephemeral_seed_words(words, origin='Dice')
def generate_seed(): def generate_seed():
# Generate 32 bytes of best-quality high entropy TRNG bytes. # Generate 32 bytes of best-quality high entropy TRNG bytes.
@ -535,7 +547,7 @@ async def make_new_wallet(nwords):
async def ephemeral_seed_import(nwords): async def ephemeral_seed_import(nwords):
async def import_done_cb(words): async def import_done_cb(words):
dis.fullscreen("Applying...") dis.fullscreen("Applying...")
await set_ephemeral_seed_words(words, meta='Imported') await set_ephemeral_seed_words(words, origin='Imported')
if version.has_qwerty: if version.has_qwerty:
from ux_q1 import seed_word_entry from ux_q1 import seed_word_entry
@ -549,17 +561,17 @@ async def ephemeral_seed_generate(nwords):
words = await approve_word_list(seed, nwords, ephemeral=True) words = await approve_word_list(seed, nwords, ephemeral=True)
if words: if words:
dis.fullscreen("Applying...") dis.fullscreen("Applying...")
await set_ephemeral_seed_words(words, meta="TRNG Words") await set_ephemeral_seed_words(words, origin="TRNG Words")
async def set_seed_extended_key(extended_key): async def set_seed_extended_key(extended_key):
encoded, chain = xprv_to_encoded_secret(extended_key) encoded, chain = xprv_to_encoded_secret(extended_key)
set_seed_value(encoded=encoded, chain=chain) set_seed_value(encoded=encoded, chain=chain)
goto_top_menu(first_time=True) goto_top_menu(first_time=True)
async def set_ephemeral_seed_extended_key(extended_key, meta=None): async def set_ephemeral_seed_extended_key(extended_key, origin=None):
encoded, chain = xprv_to_encoded_secret(extended_key) encoded, chain = xprv_to_encoded_secret(extended_key)
dis.fullscreen("Applying...") dis.fullscreen("Applying...")
await set_ephemeral_seed(encoded=encoded, chain=chain, meta=meta) await set_ephemeral_seed(encoded=encoded, chain=chain, origin=origin)
goto_top_menu() goto_top_menu()
async def approve_word_list(seed, nwords, ephemeral=False): async def approve_word_list(seed, nwords, ephemeral=False):
@ -637,8 +649,8 @@ def xprv_to_encoded_secret(xprv):
def set_seed_value(words=None, encoded=None, chain=None): def set_seed_value(words=None, encoded=None, chain=None):
# Save the seed words (or other encoded private key) into secure element, # Save the seed words (or other encoded private key) into secure element.
# and reboot. BIP-39 passphrase is not set at this point (empty string). # BIP-39 passphrase is not set at this point (empty string).
if words: if words:
nv = seed_words_to_encoded_secret(words) nv = seed_words_to_encoded_secret(words)
else: else:
@ -679,7 +691,7 @@ async def calc_bip39_passphrase(pw, bypass_tmp=False):
async def set_bip39_passphrase(pw, bypass_tmp=False, summarize_ux=True): async def set_bip39_passphrase(pw, bypass_tmp=False, summarize_ux=True):
nv, xfp, parent_xfp = await calc_bip39_passphrase(pw, bypass_tmp=bypass_tmp) nv, xfp, parent_xfp = await calc_bip39_passphrase(pw, bypass_tmp=bypass_tmp)
ret = await set_ephemeral_seed(nv, summarize_ux=summarize_ux, bip39pw=pw, ret = await set_ephemeral_seed(nv, summarize_ux=summarize_ux, bip39pw=pw,
meta="BIP-39 Passphrase on [%s]" % xfp2str(parent_xfp)) origin="BIP-39 Passphrase on [%s]" % xfp2str(parent_xfp))
dis.draw_status(bip39=int(bool(pw)), xfp=xfp, tmp=1) dis.draw_status(bip39=int(bool(pw)), xfp=xfp, tmp=1)
return ret return ret
@ -820,7 +832,7 @@ class SeedVaultMenu(MenuSystem):
from glob import dis from glob import dis
dis.fullscreen("Applying...") dis.fullscreen("Applying...")
xfp, encoded = item.arg encoded = item.arg # 72 bytes binary
await set_ephemeral_seed(encoded, is_restore=True) await set_ephemeral_seed(encoded, is_restore=True)
@ -832,15 +844,15 @@ class SeedVaultMenu(MenuSystem):
esc = "" esc = ""
tmp_val = False tmp_val = False
idx, xfp_str, encoded = item.arg idx, rec, encoded = item.arg
current_active = (pa.tmp_value == bytes(encoded)) current_active = (pa.tmp_value == bytes(encoded))
msg = "Remove seed from seed vault " msg = "Remove seed from seed vault"
if pa.tmp_value and current_active: if pa.tmp_value and current_active:
tmp_val = True tmp_val = True
msg += "?\n\n" msg += "?\n\n"
else: else:
msg += ("and delete its settings?\n\n" msg += (" and delete its settings?\n\n"
"Press %s to continue, press (1) to " "Press %s to continue, press (1) to "
"only remove from seed vault and keep " "only remove from seed vault and keep "
"encrypted settings for later use.\n\n") % OK "encrypted settings for later use.\n\n") % OK
@ -848,7 +860,7 @@ class SeedVaultMenu(MenuSystem):
msg += "WARNING: Funds will be lost if wallet is not backed-up elsewhere." msg += "WARNING: Funds will be lost if wallet is not backed-up elsewhere."
ch = await ux_show_story(title="[" + xfp_str + "]", msg=msg, escape=esc) ch = await ux_show_story(title="[" + rec.xfp + "]", msg=msg, escape=esc)
if ch == "x": return if ch == "x": return
dis.fullscreen("Saving...") dis.fullscreen("Saving...")
@ -882,13 +894,13 @@ class SeedVaultMenu(MenuSystem):
@staticmethod @staticmethod
async def _detail(menu, label, item): async def _detail(menu, label, item):
xfp_str, encoded, name, meta = item.arg rec, encoded = item.arg
# - first byte represents type of secret (internal encoding flag) # - first byte represents type of secret (internal encoding flags)
txt = SecretStash.summary(encoded[0]) txt = SecretStash.summary(encoded[0])
detail = "Name:\n%s\n\nMaster XFP:\n%s\n\nOrigin:\n%s\n\nSecret Type:\n%s" \ detail = "Name:\n%s\n\nMaster XFP: %s\nSecret Type: %s\n\nOrigin:\n%s\n\n" \
% (name, xfp_str, meta, txt) % (rec.label, rec.xfp, txt, rec.origin)
await ux_show_story(detail) await ux_show_story(detail)
@ -898,30 +910,25 @@ class SeedVaultMenu(MenuSystem):
from glob import dis from glob import dis
from ux import ux_input_text from ux import ux_input_text
idx, xfp_str = item.arg idx, old = item.arg
new_label = await ux_input_text(old.label, confirm_exit=False, max_len=40)
seeds = settings.master_get("seeds", []) if not new_label:
chk_xfp, encoded, old_name, meta = seeds[idx]
assert chk_xfp == xfp_str
new_name = await ux_input_text(old_name, confirm_exit=False, max_len=40)
if not new_name:
return return
dis.fullscreen("Saving...") dis.fullscreen("Saving...")
seeds = settings.master_get("seeds", [])
# save it # save it
seeds[idx] = (chk_xfp, encoded, new_name, meta) seeds[idx] = (old.xfp, old.encoded, new_label, old.origin)
# need to load and work on master secrets, will be slow if on tmp seed # need to load and work on master secrets, will be slow if on tmp seed
settings.master_set("seeds", seeds) settings.master_set("seeds", seeds)
# update label in sub-menu # update label in sub-menu
menu.items[0].label = new_name menu.items[0].label = new_label
menu.items[0].arg = menu.items[0].arg[0:2] + (new_name,) + menu.items[0].arg[3:] menu.items[0].arg = VaultEntry(*seeds[idx])
# .. and name in parent menu too # and name in parent menu too
parent = the_ux.parent_of(menu) parent = the_ux.parent_of(menu)
if parent: if parent:
parent.update_contents() parent.update_contents()
@ -949,10 +956,9 @@ class SeedVaultMenu(MenuSystem):
seeds = settings.master_get("seeds", []) seeds = settings.master_get("seeds", [])
# Save it into master settings # Save it into master settings
seeds.append((new_xfp_str, seeds.append(VaultEntry(new_xfp_str,
SecretStash.storage_serialize(pa.tmp_value), SecretStash.storage_serialize(pa.tmp_value),
xfp_ui, xfp_ui, "unknown origin"))
"unknown origin"))
settings.master_set("seeds", seeds) settings.master_set("seeds", seeds)
@ -969,7 +975,7 @@ class SeedVaultMenu(MenuSystem):
rv = [] rv = []
add_current_tmp = MenuItem("Add current tmp", f=cls._add_current_tmp) add_current_tmp = MenuItem("Add current tmp", f=cls._add_current_tmp)
seeds = settings.master_get("seeds", []) seeds = list(seed_vault_iter())
if not seeds: if not seeds:
rv.append(MenuItem('(none saved yet)')) rv.append(MenuItem('(none saved yet)'))
@ -980,16 +986,19 @@ class SeedVaultMenu(MenuSystem):
wipe_if_deltamode() wipe_if_deltamode()
tmp_in_sv = False tmp_in_sv = False
for i, (xfp_str, encoded, name, meta) in enumerate(seeds): for i, rec in enumerate(seeds):
is_active = False is_active = False
encoded = pad_raw_secret(encoded)
# de-serialize encoded secret
encoded = deserialize_secret(rec.encoded)
if encoded == pa.tmp_value: if encoded == pa.tmp_value:
is_active = tmp_in_sv = True is_active = tmp_in_sv = True
submenu = [ submenu = [
MenuItem(name, f=cls._detail, arg=(xfp_str, encoded, name, meta)), MenuItem(rec.label, f=cls._detail, arg=(rec, encoded)),
MenuItem('Use This Seed', f=cls._set, arg=(xfp_str, encoded)), MenuItem('Use This Seed', f=cls._set, arg=encoded),
MenuItem('Rename', f=cls._rename, arg=(i, xfp_str)), MenuItem('Rename', f=cls._rename, arg=(i, rec)),
MenuItem('Delete', f=cls._remove, arg=(i, xfp_str, encoded)), MenuItem('Delete', f=cls._remove, arg=(i, rec, encoded)),
] ]
if is_active: if is_active:
submenu[1] = MenuItem("Seed In Use") submenu[1] = MenuItem("Seed In Use")
@ -1000,7 +1009,7 @@ class SeedVaultMenu(MenuSystem):
# DO NOT offer any modification api (rename/delete) # DO NOT offer any modification api (rename/delete)
submenu = submenu[:2] submenu = submenu[:2]
item = MenuItem('%2d: %s' % (i+1, name), menu=MenuSystem(submenu)) item = MenuItem('%2d: %s' % (i+1, rec.label), menu=MenuSystem(submenu))
if is_active: if is_active:
item.is_chosen = lambda: True item.is_chosen = lambda: True
@ -1026,15 +1035,12 @@ class SeedVaultChooserMenu(MenuSystem):
def __init__(self, words_only=False): def __init__(self, words_only=False):
self.result = None self.result = None
seeds = settings.master_get("seeds", [])
items = [] items = []
for i, rec in enumerate(seed_vault_iter()):
for i, (xfp_str, encoded, name, meta) in enumerate(seeds): if words_only and not SecretStash.is_words(deserialize_secret(rec.encoded)):
encoded = pad_raw_secret(encoded)
if words_only and not SecretStash.is_words(encoded):
continue continue
item = MenuItem('%2d: %s' % (i+1, name), arg=encoded, f=self.picked) item = MenuItem('%2d: %s' % (i+1, rec.label), arg=rec, f=self.picked)
items.append(item) items.append(item)
if not items: if not items:
@ -1323,7 +1329,7 @@ async def apply_pass_value(new_pp):
return return
await set_ephemeral_seed(nv, summarize_ux=False, bip39pw=new_pp, await set_ephemeral_seed(nv, summarize_ux=False, bip39pw=new_pp,
meta="BIP-39 Passphrase on [%s]" % parent_xfp_str) origin="BIP-39 Passphrase on [%s]" % parent_xfp_str)
if ch == '1': if ch == '1':
try: try:

View File

@ -167,6 +167,7 @@ class SecretStash:
@staticmethod @staticmethod
def storage_serialize(secret): def storage_serialize(secret):
# make it a JSON-compatible field # make it a JSON-compatible field
# - converse: utils.deserialize_secret()
return B2A(bytes(secret).rstrip(b"\x00")) return B2A(bytes(secret).rstrip(b"\x00"))
@staticmethod @staticmethod
@ -422,13 +423,4 @@ class SensitiveValues:
self.register(pk) self.register(pk)
return pk return pk
def encoded_secret(self):
# we do not support master as secret - only extended keys and mnemonics
if self.mode == "xprv":
nv = SecretStash.encode(xprv=self.node)
else:
assert self.mode == "words"
nv = SecretStash.encode(seed_phrase=self.raw)
return nv
# EOF # EOF

View File

@ -33,7 +33,7 @@ async def import_tapsigner_backup_file(_1, _2, item):
from pincodes import pa from pincodes import pa
assert pa.is_secret_blank() # "must not have secret" assert pa.is_secret_blank() # "must not have secret"
meta = "from " origin = "from "
label = "TAPSIGNER encrypted backup file" label = "TAPSIGNER encrypted backup file"
choice = await import_export_prompt(label, is_import=True) choice = await import_export_prompt(label, is_import=True)
@ -69,7 +69,7 @@ async def import_tapsigner_backup_file(_1, _2, item):
else: else:
fn = await file_picker(suffix="aes", min_size=100, max_size=160, **choice) fn = await file_picker(suffix="aes", min_size=100, max_size=160, **choice)
if not fn: return if not fn: return
meta += (" (%s)" % fn) origin += (" (%s)" % fn)
try: try:
with CardSlot(**choice) as card: with CardSlot(**choice) as card:
with open(fn, 'rb') as fp: with open(fn, 'rb') as fp:
@ -103,6 +103,6 @@ async def import_tapsigner_backup_file(_1, _2, item):
await ux_show_story(title="FAILURE", msg=str(e)) await ux_show_story(title="FAILURE", msg=str(e))
continue continue
await import_extended_key_as_secret(extended_key, ephemeral, meta=meta) await import_extended_key_as_secret(extended_key, ephemeral, origin=origin)
# EOF # EOF

755
shared/teleport.py Normal file
View File

@ -0,0 +1,755 @@
# (c) Copyright 2025 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# teleport.py - Magically transport extremely sensitive data between the
# secure environment of two Q's.
#
import sys, uzlib, ngu, aes256ctr, bip39, json, stash
from utils import problem_file_line, B2A, xfp2str, deserialize_secret, keypath_to_str
from ubinascii import unhexlify as a2b_hex
from ubinascii import hexlify as b2a_hex
from glob import settings
from ux import ux_show_story, ux_confirm, show_qr_code, the_ux, ux_dramatic_pause
from ux_q1 import show_bbqr_codes, QRScannerInteraction, ux_input_text
from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL
from bbqr import b32encode, b32decode
from menu import MenuItem, MenuSystem
from notes import NoteContentBase
from sffile import SFFile
from multisig import MultisigWallet
# One page github-hosted static website that shows QR based on URL contents pushed by NFC
KT_DOMAIN = 'keyteleport.com'
# No length/size worries with simple secrets, but massive notes and big PSBT,
# with lots of UTXO, cannot be passed via NFC URL, because we are limited by
# NFC chip (8k) and URL length (4k or less) inside. BBQr is not limited however.
# - but the website is ready to make animated BBQr nicely
NFC_SIZE_LIMIT = const(4096)
def short_bbqr(type_code, data):
# Short-circuit basic BBQr encoding here: always Base32, single part: 1 of 1
# - used only for NFC link, where website may split again into parts
hdr = 'B$2%s0100' % type_code
return hdr + b32encode(data)
async def nfc_push_kt(qrdata):
# NFC push to send them to our QR-rendering website
import ndef
url = KT_DOMAIN + '#' + qrdata
n = ndef.ndefMaker()
n.add_url(url, https=True)
from glob import NFC
await NFC.share_loop(n, prompt="View QR on web", line2=KT_DOMAIN)
async def kt_start_rx(*a):
# menu item to "start a receive" operation
rx_key = settings.get("ktrx")
if rx_key:
# Maybe re-use same one? Vaguely risky? Concern is they are confused and
# we don't want to lose the pubkey if they should be scanning not here.
ch = await ux_show_story('''Looks like last attempt wasn't completed. \
You need to do QR scan of data from the sender to move to the next step. \
We will re-use same values as last try, unless you press (R) for new values to be picked.''',
title='Reuse Pubkey?', escape='r'+KEY_QR, hint_icons=KEY_QR)
if ch == KEY_QR:
# help them scan now!
x = QRScannerInteraction()
await x.scan_anything(expect_secret=False, tmp=False)
return
elif ch == 'r':
# wipe and restart; sender's work might be lost
rx_key = None
else:
# keep old keypair -- they might be confused
kp = ngu.secp256k1.keypair(a2b_hex(rx_key))
if not rx_key:
# pick a random key pair, just for this session
kp = ngu.secp256k1.keypair()
settings.set("ktrx", b2a_hex(kp.privkey()))
settings.save()
short_code, payload = generate_rx_code(kp)
msg = '''To receive teleport of sensitive data from another COLDCARD, \
share this Receiver Password with sender:
%s = %s
and show the QR on next screen to the sender. ENTER or %s to show here''' % (
short_code, ' '.join(short_code), KEY_QR)
await tk_show_payload('R', payload, 'Key Teleport: Receive', msg, cta='Show to Sender')
def generate_rx_code(kp):
# Receiver-side password: given a pubkey (33 bytes, compressed format)
# - construct an 8-digit decimal "password"
# - it's a AES key, but only 26 bits worth
pubkey = bytearray(kp.pubkey().to_bytes()) # default: compressed format
#assert len(pubkey) == 33
# - want the code to be deterministic, but I also don't want to save it
nk = ngu.hash.sha256d(kp.privkey() + b'COLCARD4EVER')
# first byte will be 0x02 or 0x03 (Y coord) -- remove those known 7 bits
pubkey[0] ^= nk[20] & 0xfe
num = '%08d' % (int.from_bytes(nk[4:8], 'big') % 1_0000_0000)
# encryption after baby key stretch
kk = ngu.hash.sha256s(num.encode())
enc = aes256ctr.new(kk).cipher(pubkey)
return num, enc
def decrypt_rx_pubkey(code, payload):
# given a 8-digit numeric code, make the key and then decrypt/checksum check
# - every value works, there is no fail.
kk = ngu.hash.sha256s(code.encode())
rx_pubkey = bytearray(aes256ctr.new(kk).cipher(payload))
# first byte will be 0x02 or 0x03 but other 7 bits are noise
rx_pubkey[0] &= 0x01
rx_pubkey[0] |= 0x02
# validate that it's on the curve... otherwise the code is wrong
try:
ngu.secp256k1.pubkey(rx_pubkey)
return rx_pubkey
except:
return None
async def tk_show_payload(type_code, payload, title, msg, cta=None):
# show the QR and/or NFC
# - MAYBE: make easier/faster to pick NFC from QR screen and vice-versa
from glob import NFC
from bbqr import num_qr_needed
from ux_q1 import show_bbqr_codes
hints = KEY_QR
if NFC and len(payload) < NFC_SIZE_LIMIT:
hints += KEY_NFC
msg += ' or %s to view on your phone' % KEY_NFC
msg += '. CANCEL to stop.'
# simply show the QR
while 1:
ch = await ux_show_story(msg, title=title, hint_icons=hints)
if ch == KEY_NFC and NFC:
await nfc_push_kt(short_bbqr(type_code, payload))
elif ch == KEY_QR or ch == 'y':
# NOTE: CTA rarely seen, but maybe sometimes?
await show_bbqr_codes(type_code, payload, msg=cta)
elif ch == 'x':
return
async def kt_start_send(rx_data):
# a QR was scanned and it held (most of) a pubkey
# - they want to send to this guy
# - ask them what to send, etc
while 1:
# - ask for the sender's password -- nearly any value will be accepted
code = await ux_input_text('', confirm_exit=False, hex_only=True, max_len=8,
prompt='Teleport Password (number)', min_len=8, b39_complete=False, scan_ok=False,
placeholder='########', funct_keys=None, force_xy=None)
if not code: return
rx_pubkey = decrypt_rx_pubkey(code, rx_data)
if not rx_pubkey:
# I think only about 50% odds of catching an incorrect code. Not sure
ch = await ux_show_story(
"Incorrect Teleport Password. You can try again or CANCEL to stop.")
if ch == 'x': return
break
msg = '''You can now teleport secrets! Select from seed words, seed vault keys, \
secure notes or passwords. \
WARNING: Receiver will have full access to all Bitcoin controlled by these keys!
'''
ch = await ux_show_story(msg, title="Key Teleport: Send")
if ch != 'y': return
# pick what to send from a series of submenus
menu = SecretPickerMenu(rx_pubkey)
the_ux.push(menu)
async def kt_do_send(rx_pubkey, dtype, raw=None, obj=None, prefix=b'', rx_label='the receiver', kp=None):
# We are rendering a QR and showing it to them for sending to another Q
from glob import dis
cleartext = dtype.encode() + (raw or json.dumps(obj).encode())
# Pick and show noid key to sender
noid_key, txt = pick_noid_key()
dis.progress_bar_show(0.25)
# all new EC key
my_keypair = kp or ngu.secp256k1.keypair()
dis.progress_bar_show(0.75)
payload = prefix + encode_payload(my_keypair, rx_pubkey, noid_key, cleartext,
for_psbt=bool(prefix))
dis.progress_bar_show(1)
msg = "Share this password with %s, via some different channel:"\
"\n\n %s = %s\n\n" % (rx_label, txt, ' '.join(txt))
msg += "ENTER to view QR"
await tk_show_payload('S' if not prefix else 'E',
payload, 'Teleport Password', msg, cta='Show to Receiver')
if not prefix:
# not PSBT case ... reset menus, we are deep!
from flow import goto_top_menu
goto_top_menu()
def pick_noid_key():
# pick an 40 bit password, shown as base32
# - on rx, libngu base32 decoder will convert '018' into 'OLB'
# - but a little tempted to removed vowels here?
k = ngu.random.bytes(5)
txt = b32encode(k).upper()
return k, txt
async def kt_decode_rx(is_psbt, payload):
# we are getting data back from a sender, decode it.
prompt = 'Teleport Password (text)'
if not is_psbt:
rx_key = settings.get("ktrx")
if not rx_key:
await ux_show_story("Not expecting any teleports. You need to start over.")
await kt_start_rx() # help them to start over? idk maybe not.
return
his_pubkey = payload[0:33]
body = payload[33:]
pair = ngu.secp256k1.keypair(a2b_hex(rx_key))
ses_key, body = decode_step1(pair, his_pubkey, body)
else:
# Multisig PSBT: will need to iterate over a few wallets and each N-1 possible senders
if not MultisigWallet.exists():
await ux_show_story("Incoming PSBT requires multisig wallet(s) to be already setup, but you have none.")
return
ses_key, body, sender_xfp = MultisigWallet.kt_search_rxkey(payload)
if sender_xfp is not None:
prompt = 'Teleport Password from [%s]' % xfp2str(sender_xfp)
if not ses_key:
# when ECDH fails, it's truncation or wrong RX key (due to sender using old rx key,
# or the numeric code the sender entered was wrong, etc)
await ux_show_story("QR code was damaged, "+
("numeric password was wrong, " if not is_psbt else "")+
"or it was sent to a different user. "
"Sender must start again.", title="Teleport Fail")
return
from glob import dis
while 1:
# ask for noid key
pw = await ux_input_text('', confirm_exit=False, hex_only=False, max_len=8,
prompt=prompt, min_len=8, b39_complete=False, scan_ok=False,
placeholder='********', funct_keys=None, force_xy=None)
if not pw: return
dis.progress_bar_show(0)
try:
assert len(pw) == 8
noid_key = b32decode(pw) # case insenstive, and smart about confused chars
final = decode_step2(ses_key, noid_key, body)
if final is not None:
break
except:
pass
finally:
dis.progress_bar_show(1)
ch = await ux_show_story(
"Incorrect Teleport Password. You can try again or CANCEL to stop.")
if ch == 'x': return
# will ask again
# success w/ decoding. but maybe something goes wrong or they reject a confirm step
# so keep the rx key alive still
await kt_accept_values(chr(final[0]), final[1:])
async def kt_accept_values(dtype, raw):
# We got some secret, decode it more, and save it.
'''
- `s` - secret, encoded per stash.py
- `r` - raw XPRV mode - 64 bytes follow which are the chain code then master privkey
- `x` - XPRV mode, full details - 4 bytes (XPRV) + base58 *decoded* binary-XPRV follows
- `n` - one or many notes export (JSON array)
- `v` - seed vault export (JSON: one secret key but includes includes name, source of key)
- `p` - binary PSBT to be signed
'''
from chains import current_chain, slip32_deserialize
from flow import has_se_secrets, goto_top_menu
enc = None
origin = 'Teleported'
label = None
if dtype == 's':
# words / bip 32 master / xprv, etc
enc = bytearray(72)
enc[0:len(raw)] = raw
elif dtype == 'x':
# it's an XPRV, but in binary.. some extra data we throw away here; sigh
# XXX no way to send this .. but was thinking of address explorer
txt = ngu.codecs.b58_encode(raw)
node, ch, _, _ = slip32_deserialize(txt)
assert ch.name == chains.current_chain.name, 'wrong chain'
enc = stash.SecretStash.encode(node=node)
elif dtype == 'p':
# raw PSBT -- much bigger more complex
from auth import sign_transaction, TXN_INPUT_OFFSET
from public_constants import STXN_FINALIZE
psbt_len = len(raw)
# copy into PSRAM
with SFFile(TXN_INPUT_OFFSET, max_size=psbt_len) as out:
out.write(raw)
# This will take over UX w/ the signing process
sign_transaction(psbt_len, flags=STXN_FINALIZE)
return
elif dtype in 'nv':
# all are JSON things
js = json.loads(raw)
if dtype == 'v':
# one key export from a seed vault
# - watch for incompatibility here if we ever change VaultEntry
from seed import VaultEntry
rec = VaultEntry(*js)
enc = deserialize_secret(rec.encoded)
origin = rec.origin
label = rec.label
elif dtype == 'n':
# import secure note(s)
from notes import import_from_json, make_notes_menu, NoteContent
settings.remove_key("ktrx") # force new rx key after this point
await import_from_json(dict(coldcard_notes=js))
await ux_dramatic_pause('Imported.', 2)
# force them into notes submenu so they can see result right away
# - highlight to last note, which should be the just-added one(s)
goto_top_menu()
nm = await make_notes_menu()
nm.goto_idx(NoteContent.count()-1)
the_ux.push(nm)
return
else:
raise ValueError(dtype)
# key material is arriving; offer to use as main secret, or tmp, or seed vault?
settings.remove_key("ktrx") # force new rx key after this point
assert enc
from seed import set_ephemeral_seed, set_seed_value
if not has_se_secrets():
# unit has nothing, so this will be the master seed
set_seed_value(encoded=enc)
ok = True
else:
ok = await set_ephemeral_seed(enc, origin=origin, label=label)
if ok:
goto_top_menu()
def noid_stretch(session_key, noid_key):
# TODO: measure timing of this on real Q
return ngu.hash.pbkdf2_sha512(session_key, noid_key, 5000)[0:32]
def encode_payload(my_keypair, his_pubkey, noid_key, body, for_psbt=False):
# do all the encryption for sender
assert len(his_pubkey) == 33
assert len(noid_key) == 5
# this can fail with ValueError: secp256k1_ec_pubkey_parse
# if the user has provided the wrong value for numeric password
# - better to catch this sooner in decrypt_rx_pubkey
session_key = my_keypair.ecdh_multiply(his_pubkey)
# stretch noid key out -- will be slow
pk = noid_stretch(session_key, noid_key)
b1 = aes256ctr.new(pk).cipher(body)
b1 += ngu.hash.sha256s(body)[-2:]
b2 = aes256ctr.new(session_key).cipher(b1)
b2 += ngu.hash.sha256s(b1)[-2:]
if for_psbt:
# no need to share pubkey for PSBT files
return b2
return my_keypair.pubkey().to_bytes() + b2
def decode_step1(my_keypair, his_pubkey, body):
# Do ECDH and remove top layer of encryption
try:
assert len(body) >= 3
session_key = my_keypair.ecdh_multiply(his_pubkey)
rv = aes256ctr.new(session_key).cipher(body[:-2])
chk = ngu.hash.sha256s(rv)[-2:]
assert chk == body[-2:] # likely means wrong rx key, or truncation
except:
return None, None
return session_key, rv
def decode_step2(session_key, noid_key, body):
# After we have the noid key, can decode true payload
assert len(noid_key) == 5
pk = noid_stretch(session_key, noid_key)
msg = aes256ctr.new(pk).cipher(body[:-2])
chk = ngu.hash.sha256s(msg)[-2:]
return msg if chk == body[-2:] else None
async def kt_incoming(type_code, payload):
# incoming BBQr was scanned (via main menu, etc)
from exceptions import QRDecodeExplained
if type_code == 'R':
# they want to send to this guy
return await kt_start_send(payload)
elif type_code == 'S':
# we are receiving something, let's try to decode
return await kt_decode_rx(False, payload)
elif type_code == 'E':
# incoming PSBT!
return await kt_decode_rx(True, payload)
else:
raise ValueError(type_code)
class SecretPickerMenu(MenuSystem):
def __init__(self, rx_pubkey):
self.rx_pubkey = rx_pubkey
from flow import word_based_seed, is_tmp
from stash import bip39_passphrase
has_notes = bool(NoteContentBase.count())
has_ms = bool(settings.get('multisig', False))
has_sv = bool(settings.get('seedvault', False))
# Q-only feature, so menu can be W I D E
# - in increasing order of important / sensitivity!
m = [
MenuItem('Quick Text Message', f=self.quick_note),
MenuItem('Multisig PSBT for Signing', predicate=has_ms),
MenuItem('Single Note / Password', predicate=has_notes, menu=self.pick_note_submenu),
MenuItem('Export All Notes & Passwords', predicate=has_notes, f=self.picked_note),
]
if has_sv:
m.append( MenuItem('From Seed Vault', menu=self.pick_vault_submenu) )
if is_tmp():
# tmp seed, or maybe bip39 is in effect
# - all are the current master secret
msg = 'Temp Secret (words)' if word_based_seed() else (
'XPRV from Words+Passphrase' if bip39_passphrase else 'Temp XPRV Secret')
else:
# real master secret
msg = 'Master Seed Words' if word_based_seed() else 'Master XPRV'
m.append( MenuItem(msg, f=self.share_master_secret) )
super().__init__(m)
async def pick_vault_submenu(self, *a):
# pick a secret from seed vault
from seed import SeedVaultChooserMenu
rec = await SeedVaultChooserMenu.pick()
if rec:
await kt_do_send(self.rx_pubkey, 'v', obj=list(rec))
async def pick_note_submenu(self, *a):
# Make a submenu to select a single note/password
rv = []
for note in NoteContentBase.get_all():
rv.append(MenuItem('%d: %s' % (note.idx+1, note.title), f=self.picked_note, arg=note))
return rv
async def quick_note(self, _, _2, item):
# accept a text string, and send as a note
from notes import NoteContent
txt = await ux_input_text('', max_len=100,
prompt='Enter your message', min_len=1, b39_complete=True, scan_ok=True,
placeholder='Attack at dawn.')
if not txt: return
n = NoteContent(dict(title="Quick Note", misc=txt))
await kt_do_send(self.rx_pubkey, 'n', obj=[n.serialize()])
async def picked_note(self, _, _2, item):
# exporting note(s)
if item.arg is None:
# export all
body = [n.serialize() for n in NoteContentBase.get_all()]
else:
# single note/password
body = [item.arg.serialize()]
await kt_do_send(self.rx_pubkey, 'n', obj=body)
async def share_master_secret(self, _, _2, item):
# altho menu items look different we are sharing same thing:
# - up to 72 bytes from secure elements
from stash import SensitiveValues, SecretStash, blank_object
with stash.SensitiveValues(bypass_tmp=False, enforce_delta=True) as sv:
raw = bytearray(sv.secret)
xfp = xfp2str(sv.get_xfp())
# rtrim zeros
while raw[-1] == 0:
raw = raw[0:-1]
summary = SecretStash.summary(raw[0])
from pincodes import pa
scale = 'your MASTER secret' if not pa.tmp_value else 'a temporary secret'
msg = "Sharing %s [%s] (%s)." % (scale, xfp, summary)
msg += "\n\nWARNING: Allows full control over all associated Bitcoin!"
if not await ux_confirm(msg):
blank_object(raw)
return
await kt_do_send(self.rx_pubkey, 's', raw=raw)
async def kt_send_psbt(psbt, psbt_len=None, post_signing=False):
# We just finishing adding our signature to an incomplete PSBT.
# User wants to send to one or more other senders for them to complete signing.
# who remains to sign? look at inputs
ms = psbt.active_multisig
all_xfps = [x for x,p in ms.get_xfp_paths()]
need = [x for x in psbt.multisig_xfps_needed() if x in all_xfps]
# maybe it's not really a PSBT where we know the other signers? might be
# a weird coinjoin we dont fully understand
if not need:
if not post_signing:
await ux_show_story("No more signers?")
return
num_to_complete = ms.M - (ms.N - len(need))
if post_signing:
# They just approved and signed a MS txn perhaps via USB or QR or any source
# - offer to save?
# - offer them to teleport it (we only come this far if possible)
if num_to_complete <= 0:
# fully signed. we can probably finalize it too
# - they have no copy of the result, if it came in via teleport
# - if from USB, we'd be uploading back, SD would be saved, etc
return
ch = await ux_show_story("%d more signatures are still required. Press (T) to pick another co-signer to sign next, using QR codes, or ENTER for other options." % num_to_complete, title="Teleport PSBT?", escape='t')
if ch != 't':
# ENTER/CANCEL both come here because we don't want to lose the PSBT
# - they also do a "T" and teleport again
from auth import done_signing
await done_signing(psbt)
return
if not psbt_len:
# we need it serialized, might have only saved into Base64 or something
from io import BytesIO
with BytesIO() as fd:
psbt.serialize(fd) # need prog bar?
psbt_len = fd.tell()
bin_psbt = fd.getvalue()
else:
# move out of PSRAM
from auth import TXN_OUTPUT_OFFSET
with SFFile(TXN_OUTPUT_OFFSET, psbt_len) as fd:
bin_psbt = fd.read(psbt_len)
my_xfp = settings.get('xfp')
# if my_xfp in need:
# - we haven't signed yet? let's do that now .. except we've lost some of the
# data we need such as filename to save back into.
# - so just keep going instead... maybe they want to be last signer?
# Make them pick a single next signer. It's not helpful to do multiple at once
# here, since we need signatures to be added serially so that last
# signer can do finalization. We don't have a general purpose combiner.
async def done_cb(m, idx, item):
m.next_xfp = item.arg
the_ux.pop()
ci = []
next_signer = None
for idx, x in enumerate(all_xfps):
txt = '[%s] Co-signer #%d' % (xfp2str(x), idx+1)
f = done_cb
if x == my_xfp:
txt += ': YOU'
f = None
if x in need:
# we haven't signed ourselves yet, so allow that
from auth import sign_transaction, TXN_INPUT_OFFSET
from public_constants import STXN_FINALIZE
async def sign_now(*a):
# this will reset the UX stack:
sign_transaction(psbt_len, flags=STXN_FINALIZE)
f = sign_now
elif x not in need:
txt += ': DONE'
f = None
mi = MenuItem(txt, f=f, arg=x)
if x not in need:
# show check if we've got sig
mi.is_chosen = lambda: True
elif next_signer == None:
next_signer = idx
ci.append(mi)
m = MenuSystem(ci)
m.next_xfp = None
m.goto_idx(next_signer) # position cursor on next candidate
the_ux.push(m)
await m.interact()
if m.next_xfp:
assert m.next_xfp != my_xfp
ri, rx_pubkey, kp = ms.kt_make_rxkey(m.next_xfp)
await kt_do_send(rx_pubkey, 'p', raw=bin_psbt, prefix=ri, kp=kp,
rx_label='[%s] co-signer' % xfp2str(m.next_xfp))
return True
async def kt_send_file_psbt(*a):
# Menu item: choose a PSBT file from SD card, and send to co-signers.
# Heavy code re-use here. Need to find the multisig wallet associated w/ file,
# so we need to parse it and we must be one of the co-signers.
from actions import is_psbt, file_picker
from auth import sign_psbt_file, TXN_INPUT_OFFSET
from version import MAX_TXN_LEN
from ux import import_export_prompt
from psbt import psbtObject
from glob import dis
# choose any PSBT from SD
picked = await import_export_prompt("PSBT", is_import=True, no_nfc=True, no_qr=True)
if picked == KEY_CANCEL:
return
choices = await file_picker(suffix='psbt', min_size=50, ux=False,
max_size=MAX_TXN_LEN, taster=is_psbt, **picked)
if not choices:
# error msg already shown
return
if len(choices) == 1:
# single - skip the menu
label,path,fn = choices[0]
input_psbt = path + '/' + fn
else:
# multiples - make them pick one
input_psbt = await file_picker(choices=choices)
if not input_psbt:
return
# read into PSRAM from wherever
psbt_len = await sign_psbt_file(input_psbt, just_read=True, **picked)
dis.fullscreen("Validating...")
try:
dis.progress_sofar(1, 4)
with SFFile(TXN_INPUT_OFFSET, length=psbt_len, message='Reading...') as fd:
# NOTE: psbtObject captures the file descriptor and uses it later
psbt = psbtObject.read_psbt(fd)
await psbt.validate() # might do UX: accept multisig import
dis.progress_sofar(2, 4)
psbt.consider_inputs()
dis.progress_sofar(3, 4)
psbt.consider_keys()
except Exception as exc:
# not going to do full reporting here, use our other code for that!
await ux_show_story("Cannot validate PSBT?\n\n"+str(exc), "PSBT Load Failed")
return
finally:
dis.progress_bar_show(1)
if not psbt.active_multisig:
await ux_show_story("We are not part of this multisig wallet.", "Cannot Teleport PSBT")
return
await kt_send_psbt(psbt, psbt_len=psbt_len)
# EOF

View File

@ -776,7 +776,7 @@ normal operation.''')
# switch over to new secret! # switch over to new secret!
dis.fullscreen("Applying...") dis.fullscreen("Applying...")
await set_ephemeral_seed(encoded, meta=name) await set_ephemeral_seed(encoded, origin=name)
goto_top_menu() goto_top_menu()
async def countdown_details(self, m, l, item): async def countdown_details(self, m, l, item):

View File

@ -551,10 +551,11 @@ def chunk_writer(fd, body):
dis.progress_bar_show(1) dis.progress_bar_show(1)
def pad_raw_secret(text_sec_str): def deserialize_secret(text_sec_str):
# Chip can hold 72-bytes as a secret # Chip can hold 72-bytes as a secret
# every secret has 0th byte as marker # - has 0th byte as marker, secret and zero padding to AE_SECRET_LEN
# then secret and padded to zero to AE_SECRET_LEN # - also does hex to binary conversion
# - converse of: SecretStash.storage_serialize()
from pincodes import AE_SECRET_LEN from pincodes import AE_SECRET_LEN
raw = bytearray(AE_SECRET_LEN) raw = bytearray(AE_SECRET_LEN)
@ -745,4 +746,17 @@ def chunk_checksum(fd, chunk=1024):
return md.digest() return md.digest()
def xor(*args):
# bit-wise xor between all args
vlen = len(args[0])
# all have to be same length
assert all(len(e) == vlen for e in args)
rv = bytearray(vlen)
for i in range(vlen):
for a in args:
rv[i] ^= a[i]
return rv
# EOF # EOF

View File

@ -392,7 +392,7 @@ def _import_prompt_builder(title, no_qr, no_nfc, slot_b_only=False):
return prompt, escape return prompt, escape
def export_prompt_builder(what_it_is, no_qr=False, no_nfc=False, key0=None, def export_prompt_builder(what_it_is, no_qr=False, no_nfc=False, key0=None, offer_kt=False,
force_prompt=False): force_prompt=False):
# Build the prompt for export # Build the prompt for export
# - key0 can be for special stuff # - key0 can be for special stuff
@ -401,7 +401,7 @@ def export_prompt_builder(what_it_is, no_qr=False, no_nfc=False, key0=None,
prompt, escape = None, KEY_CANCEL+"x" prompt, escape = None, KEY_CANCEL+"x"
if (NFC or VD) or (num_sd_slots>1) or key0 or force_prompt: if (NFC or VD) or (num_sd_slots>1) or key0 or force_prompt or offer_kt:
# no need to spam with another prompt, only option is SD card # no need to spam with another prompt, only option is SD card
prompt = "Press (1) to save %s to SD Card" % what_it_is prompt = "Press (1) to save %s to SD Card" % what_it_is
@ -431,6 +431,10 @@ def export_prompt_builder(what_it_is, no_qr=False, no_nfc=False, key0=None,
prompt += ", (4) to show QR code" prompt += ", (4) to show QR code"
escape += '4' escape += '4'
if offer_kt:
prompt += ", (T) to " + offer_kt
escape += 't'
if key0: if key0:
prompt += ', (0) ' + key0 prompt += ', (0) ' + key0
escape += '0' escape += '0'
@ -471,19 +475,21 @@ def import_export_prompt_decode(ch):
return dict(force_vdisk=force_vdisk, slot_b=slot_b) return dict(force_vdisk=force_vdisk, slot_b=slot_b)
async def import_export_prompt(what_it_is, is_import=False, no_qr=False, async def import_export_prompt(what_it_is, is_import=False, no_qr=False,
no_nfc=False, title=None, intro='', footnotes='', no_nfc=False, title=None, intro='', footnotes='',
slot_b_only=False, force_prompt=False): offer_kt=False, slot_b_only=False, force_prompt=False):
# Show story allowing user to select source for importing/exporting # Show story allowing user to select source for importing/exporting
# - return either str(mode) OR dict(file_args) # - return either str(mode) OR dict(file_args)
# - KEY_NFC or KEY_QR for those sources # - KEY_NFC or KEY_QR for those sources
# - KEY_CANCEL for abort by user # - KEY_CANCEL for abort by user
# - dict() => do file system thing, using file_args to control vdisk vs. SD vs slot_b # - dict() => do file system thing, using file_args to control vdisk vs. SD vs slot_b
# - 't' => key teleport, but only offered with offer_kt is set (contetxt, and Q only)
if is_import: if is_import:
prompt, escape = _import_prompt_builder(what_it_is, no_qr, no_nfc, slot_b_only) prompt, escape = _import_prompt_builder(what_it_is, no_qr, no_nfc, slot_b_only)
else: else:
prompt, escape = export_prompt_builder(what_it_is, no_qr, no_nfc, prompt, escape = export_prompt_builder(what_it_is, no_qr, no_nfc,
force_prompt=force_prompt) force_prompt=force_prompt, offer_kt=offer_kt)
# TODO: detect if we're only asking A or B, when just one card is inserted # TODO: detect if we're only asking A or B, when just one card is inserted
# - assume that's what they want to do # - assume that's what they want to do

View File

@ -593,7 +593,7 @@ async def seed_word_entry(prompt, num_words, has_checksum=True, done_cb=None):
from glob import dis from glob import dis
from ux import ux_confirm from ux import ux_confirm
assert num_words and prompt and done_cb assert num_words and prompt
def redraw_words(wrds=None): def redraw_words(wrds=None):
if not wrds: if not wrds:
@ -751,7 +751,10 @@ async def seed_word_entry(prompt, num_words, has_checksum=True, done_cb=None):
else: else:
err_msg = 'Next key: ' + nextchars err_msg = 'Next key: ' + nextchars
await done_cb(words) if done_cb:
await done_cb(words)
return words
def ux_dice_rolling(): def ux_dice_rolling():
from glob import dis from glob import dis
@ -969,13 +972,14 @@ class QRScannerInteraction:
if what == "vmsg": if what == "vmsg":
data, = vals data, = vals
from auth import verify_armored_signed_msg from msgsign import verify_armored_signed_msg
await verify_armored_signed_msg(data) await verify_armored_signed_msg(data)
return return
if what == "smsg": if what == "smsg":
data, = vals data, = vals
from auth import approve_msg_sign, msg_signing_done from auth import approve_msg_sign,
from msgsign import msg_signing_done
await approve_msg_sign(None, None, None, await approve_msg_sign(None, None, None,
msg_sign_request=data, kill_menu=True, msg_sign_request=data, kill_menu=True,
approved_cb=msg_signing_done) approved_cb=msg_signing_done)
@ -987,6 +991,11 @@ class QRScannerInteraction:
await ux_visualize_textqr(txt) await ux_visualize_textqr(txt)
return return
if what == 'teleport':
from teleport import kt_incoming
await kt_incoming(*vals)
return
# not reached? # not reached?
problem = 'Unhandled: ' + what problem = 'Unhandled: ' + what
@ -1114,7 +1123,7 @@ async def ux_visualize_wif(wif_str, kp, compressed, testnet):
async def qr_msg_sign_done(signature, address, text): async def qr_msg_sign_done(signature, address, text):
from ux import ux_show_story from ux import ux_show_story
from auth import rfc_signature_template_gen from msgsign import rfc_signature_template
from export import export_by_qr from export import export_by_qr
sig = b2a_base64(signature).decode('ascii').strip() sig = b2a_base64(signature).decode('ascii').strip()
@ -1126,12 +1135,13 @@ async def qr_msg_sign_done(signature, address, text):
if ch == "y": if ch == "y":
await export_by_qr(sig, "Signature", "U") await export_by_qr(sig, "Signature", "U")
if ch == "0": if ch == "0":
armored_str = "".join(rfc_signature_template_gen(addr=address, msg=text, armored_str = "".join(rfc_signature_template(addr=address, msg=text,
sig=sig)) sig=sig))
await show_bbqr_codes("U", armored_str, "Armored MSG") await show_bbqr_codes("U", armored_str, "Armored MSG")
async def qr_sign_msg(txt): async def qr_sign_msg(txt):
from auth import ux_sign_msg from msgsign import ux_sign_msg
await ux_sign_msg(txt, approved_cb=qr_msg_sign_done, kill_menu=True) await ux_sign_msg(txt, approved_cb=qr_msg_sign_done, kill_menu=True)
async def ux_visualize_textqr(txt, maxlen=MSG_SIGNING_MAX_LENGTH): async def ux_visualize_textqr(txt, maxlen=MSG_SIGNING_MAX_LENGTH):
@ -1148,8 +1158,6 @@ async def ux_visualize_textqr(txt, maxlen=MSG_SIGNING_MAX_LENGTH):
msg = "%s\n\nAbove is text that was scanned. " % txt msg = "%s\n\nAbove is text that was scanned. " % txt
if escape: if escape:
msg += " Press (0) to sign the text. " msg += " Press (0) to sign the text. "
else:
msg += "We can't do any more with it."
ch = await ux_show_story(title="Simple Text", msg=msg, escape=escape) ch = await ux_show_story(title="Simple Text", msg=msg, escape=escape)
if escape and (ch == "0"): if escape and (ch == "0"):
@ -1207,8 +1215,14 @@ async def show_bbqr_codes(type_code, data, msg, already_hex=False):
pos += part_size pos += part_size
# first first packet, want to discover a working small value for QR version
if pkt == 0:
mnv = 10 if num_parts > 1 else 1
else:
mnv = force_version
# do the hard work # do the hard work
qr_data = uqr.make(hdr+body, min_version=(10 if pkt == 0 else force_version), qr_data = uqr.make(hdr+body, min_version=mnv,
max_version=force_version, encoding=uqr.Mode_ALPHANUMERIC) max_version=force_version, encoding=uqr.Mode_ALPHANUMERIC)
# save the rendered QR # save the rendered QR
@ -1226,7 +1240,7 @@ async def show_bbqr_codes(type_code, data, msg, already_hex=False):
del qr_data del qr_data
dis.progress_bar_show((pkt+1) / num_parts) dis.progress_sofar((pkt+1), num_parts)
# display rate (plus time to send to display, etc) # display rate (plus time to send to display, etc)
ms_per_each = 200 ms_per_each = 200

View File

@ -114,7 +114,7 @@ class VirtDisk:
def new_psbt(self, filename): def new_psbt(self, filename):
# New incoming PSBT has been detected, start to sign it. # New incoming PSBT has been detected, start to sign it.
from auth import sign_psbt_file from auth import sign_psbt_file
uasyncio.create_task(sign_psbt_file(filename, force_vdisk=True, abort=True)) uasyncio.create_task(sign_psbt_file(filename, force_vdisk=True, ux_abort=True))
def new_firmware(self, filename, sz): def new_firmware(self, filename, sz):
# potential new firmware file detected # potential new firmware file detected

View File

@ -8,27 +8,14 @@
import ngu, bip39, version import ngu, bip39, version
from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause
from ux import show_qr_code, ux_render_words, OK from ux import show_qr_code, ux_render_words, OK
from seed import word_quiz, WordNestMenu, set_seed_value, set_ephemeral_seed from seed import word_quiz, WordNestMenu, set_seed_value, set_ephemeral_seed, seed_vault_iter
from glob import settings from glob import settings
from menu import MenuSystem, MenuItem from menu import MenuSystem, MenuItem
from actions import goto_top_menu from actions import goto_top_menu
from utils import encode_seed_qr, pad_raw_secret from utils import encode_seed_qr, deserialize_secret, xor
from charcodes import KEY_QR from charcodes import KEY_QR
from stash import SecretStash, blank_object, SensitiveValues, numwords_to_len, len_to_numwords from stash import SecretStash, blank_object, SensitiveValues, numwords_to_len, len_to_numwords
def xor(*args):
# bit-wise xor between all args
vlen = len(args[0])
# all have to be same length
assert all(len(e) == vlen for e in args)
rv = bytearray(vlen)
for i in range(vlen):
for a in args:
rv[i] ^= a[i]
return rv
async def xor_split_start(*a): async def xor_split_start(*a):
ch = await ux_show_story('''\ ch = await ux_show_story('''\
@ -216,13 +203,11 @@ async def xor_all_done(data):
# )) # ))
# for i in enc_parts # for i in enc_parts
# ] # ]
await set_ephemeral_seed( await set_ephemeral_seed(enc,
enc, origin='SeedXOR(%d parts, check: "%s")' % (num_parts, chk_word))
meta='SeedXOR(%d parts, check: "%s")' % (
num_parts, chk_word
)
)
goto_top_menu() goto_top_menu()
break break
class XORWordNestMenu(WordNestMenu): class XORWordNestMenu(WordNestMenu):
@ -297,14 +282,14 @@ or press (2) for 18 words XOR.''' % OK, escape="12")
# Add from Seed Vault? # Add from Seed Vault?
# filter only those that are correct length and type from seed vault # filter only those that are correct length and type from seed vault
opt = [] opt = []
for i, (xfp_str, hex_str, _, _) in enumerate(settings.master_get("seeds", [])): for i, rec in enumerate(seed_vault_iter()):
raw = pad_raw_secret(hex_str) raw = deserialize_secret(rec.encoded)
nw = SecretStash.is_words(raw) nw = SecretStash.is_words(raw)
if nw and nw == desired_num_words: if nw and nw == desired_num_words:
# it is words, and right length # it is words, and right length
sk = SecretStash.decode_words(raw, bin_mode=True) sk = SecretStash.decode_words(raw, bin_mode=True)
opt.append((i, xfp_str, sk)) opt.append((i, rec.xfp, sk))
blank_object(raw) blank_object(raw)

View File

@ -809,6 +809,7 @@ def open_microsd(simulator, microsd_path):
# open a file from the simulated microsd # open a file from the simulated microsd
def doit(fn, mode='rb'): def doit(fn, mode='rb'):
assert fn, 'empty fname'
return open(microsd_path(fn), mode) return open(microsd_path(fn), mode)
return doit return doit
@ -928,7 +929,18 @@ def use_regtest(request, settings_set):
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def set_seed_words(sim_exec, sim_execfile, simulator, reset_seed_words): def set_seed_words(change_seed_words, reset_seed_words):
def doit(w):
return change_seed_words(w)
yield doit
# Important cleanup: restore normal key, because other tests assume that
reset_seed_words()
@pytest.fixture(scope="function")
def change_seed_words(sim_exec, sim_execfile, simulator):
# load simulator w/ a specific bip32 master key # load simulator w/ a specific bip32 master key
def doit(words): def doit(words):
@ -943,30 +955,19 @@ def set_seed_words(sim_exec, sim_execfile, simulator, reset_seed_words):
#print("sim xfp: 0x%08x" % simulator.master_fingerprint) #print("sim xfp: 0x%08x" % simulator.master_fingerprint)
return simulator.master_fingerprint return simulator.master_fingerprint
yield doit return doit
# Important cleanup: restore normal key, because other tests assume that
reset_seed_words()
@pytest.fixture() @pytest.fixture()
def reset_seed_words(sim_exec, sim_execfile, simulator): def reset_seed_words(change_seed_words):
# load simulator w/ a specific bip39 seed phrase # load simulator w/ a specific bip39 seed phrase
def doit(): def doit():
words = simulator_fixed_words new_xfp = change_seed_words(simulator_fixed_words)
cmd = 'import main; main.WORDS = %r;' % words.split()
sim_exec(cmd)
rv = sim_execfile('devtest/set_seed.py')
if rv: pytest.fail(rv)
simulator.start_encryption()
simulator.check_mitm()
#print("sim xfp: 0x%08x (reset)" % simulator.master_fingerprint) #print("sim xfp: 0x%08x (reset)" % simulator.master_fingerprint)
assert simulator.master_fingerprint == simulator_fixed_xfp assert new_xfp == simulator_fixed_xfp
return words return simulator_fixed_words
return doit return doit
@ -1290,13 +1291,18 @@ def try_sign_microsd(open_microsd, cap_story, pick_menu_item, goto_home,
else: else:
assert False, 'timed out' assert False, 'timed out'
lines = story.split('\n')
txid = None txid = None
lines = story.split('\n\n') if 'Final TXID:' in lines:
if 'Final TXID:' in story: txid = lines[lines.index('Final TXID:')+1]
txid = lines[-2].split("\n")[-1]
result_fname = lines[-3] # This is fragile!
else: # ignore "Press (T) to use Key Teleport to send PSBT to other co-signers" footer
result_fname = lines[-2] # ignore "Press (0) to save again by..."
# - want the .txn if present, else the .psbt file
t, = [l for l in lines if l.endswith('.txn')] or [None]
p, = [l for l in lines if l.endswith('.psbt')] or [None]
result_fname = t or p
result = open_microsd(result_fname, 'rb').read() result = open_microsd(result_fname, 'rb').read()
@ -1610,6 +1616,24 @@ def nfc_read(request, needs_nfc):
except: except:
return doit_usb return doit_usb
@pytest.fixture()
def nfc_read_url(nfc_read, press_cancel):
# gives URL from ndef
def doit():
contents = nfc_read()
press_cancel() # exit NFC animation
# expect a single record, a URL
got, = ndef.message_decoder(contents)
assert got.type == 'urn:nfc:wkt:U'
return got.uri
return doit
@pytest.fixture() @pytest.fixture()
def nfc_write(request, needs_nfc, is_q1): def nfc_write(request, needs_nfc, is_q1):
# WRITE data into NFC "chip" # WRITE data into NFC "chip"
@ -2003,7 +2027,7 @@ def signing_artifacts_reexport(cap_story, need_keypress, load_export, press_canc
if txid: if txid:
assert txid in story assert txid in story
assert "Press (0) to re-export." in story assert "Press (0) to save again" in story
need_keypress("0") need_keypress("0")
to_do = ["sd", "vdisk", "nfc", "qr"] to_do = ["sd", "vdisk", "nfc", "qr"]

View File

@ -16,6 +16,6 @@ raw = main.ENCODED_SECRET
pa.change(new_secret=raw) pa.change(new_secret=raw)
pa.new_main_secret(raw) pa.new_main_secret(raw)
print("New key in effect: %s" % settings.get('xpub', 'MISSING')) print("New key in effect (encoded): %s" % settings.get('xpub', 'MISSING'))
print("Fingerprint: %s" % xfp2str(settings.get('xfp', 0))) print(".. w/ XFP= %s" % xfp2str(settings.get('xfp', 0)))

View File

@ -1,6 +1,7 @@
# (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC. # (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC.
# #
# load up the simulator w/ indicated list of seed words # Load up the simulator w/ indicated list of seed words
#
from sim_settings import sim_defaults from sim_settings import sim_defaults
import stash, chains import stash, chains
from pincodes import pa from pincodes import pa
@ -11,7 +12,6 @@ from utils import xfp2str
from actions import goto_top_menu from actions import goto_top_menu
from nvstore import SettingsObject from nvstore import SettingsObject
tn = chains.BitcoinTestnet tn = chains.BitcoinTestnet
stash.bip39_passphrase = '' stash.bip39_passphrase = ''
@ -23,14 +23,16 @@ PassphraseMenu.pp_sofar = ''
SettingsObject.master_sv_data = {} SettingsObject.master_sv_data = {}
SettingsObject.master_nvram_key = None SettingsObject.master_nvram_key = None
set_seed_value(main.WORDS) set_seed_value(main.WORDS)
stash.SensitiveValues.clear_cache()
settings.set('chain', 'XTN') settings.set('chain', 'XTN')
settings.set('words', len(main.WORDS)) settings.set('words', len(main.WORDS))
settings.set('terms_ok', True) settings.set('terms_ok', True)
settings.set('idle_to', 0) settings.set('idle_to', 0)
print("New key in effect: %s" % settings.get('xpub', 'MISSING')) print("TESTING: New key in effect [%s]: %s..%s = %s" % (
print("Fingerprint: %s" % xfp2str(settings.get('xfp', 0))) xfp2str(settings.get('xfp', 0)), main.WORDS[0], main.WORDS[-1],
settings.get('xpub', 'MISSING')))
# impt: if going from xprv => seed words, main menu needs updating # impt: if going from xprv => seed words, main menu needs updating
goto_top_menu() goto_top_menu()

View File

@ -1,6 +1,7 @@
# (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC. # (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC.
# #
# load up the simulator w/ indicated test master key # load up the simulator w/ indicated test master key in TPRV format.
#
import main, ngu import main, ngu
from sim_settings import sim_defaults from sim_settings import sim_defaults
import stash, chains import stash, chains
@ -34,8 +35,9 @@ pa.change(new_secret=raw)
pa.new_main_secret(raw) pa.new_main_secret(raw)
settings.set('words', False) settings.set('words', False)
print("New key in effect: %s" % settings.get('xpub', 'MISSING'))
print("Fingerprint: %s" % xfp2str(settings.get('xfp', 0)))
assert settings.get('xfp', 0) == swab32(node.my_fp()) assert settings.get('xfp', 0) == swab32(node.my_fp())
print("TESTING: New tprv in effect [%s]: %s" % (
settings.get('xpub', 'MISSING'),
xfp2str(settings.get('xfp', 0))))

View File

@ -101,6 +101,11 @@ def xfp2str(xfp):
from struct import pack from struct import pack
return b2a_hex(pack('<I', xfp)).decode('ascii').upper() return b2a_hex(pack('<I', xfp)).decode('ascii').upper()
def str2xfp(s):
assert len(s) == 8
b = bytes.fromhex(s)
return int.from_bytes(b, 'little')
def addr_from_display_format(dis_addr): def addr_from_display_format(dis_addr):
assert dis_addr[0] == '\x02' # OUT_CTRL_ADDRESS assert dis_addr[0] == '\x02' # OUT_CTRL_ADDRESS
return dis_addr[1:] return dis_addr[1:]

View File

@ -7,6 +7,7 @@ from helpers import prandom
from binascii import a2b_hex from binascii import a2b_hex
from bbqr import split_qrs, join_qrs from bbqr import split_qrs, join_qrs
from charcodes import KEY_QR from charcodes import KEY_QR
from base64 import b32decode, b32encode
# All tests in this file are exclusively meant for Q # All tests in this file are exclusively meant for Q
# #
@ -151,20 +152,30 @@ def render_bbqr(need_keypress, cap_screen_qr, sim_exec, readback_bbqr_ll):
@pytest.fixture @pytest.fixture
def try_sign_bbqr(cap_story, scan_a_qr, press_select, press_cancel, need_keypress, goto_home, def split_scan_bbqr(scan_a_qr, goto_home, need_keypress):
readback_bbqr):
def doit(psbt, type_code="P", approve=True, nfc_push_tx=False, **kws): # take big data and send it via series of BBQr thru emulated scanner
def doit(raw_data, type_code, **kws):
goto_home() goto_home()
need_keypress(KEY_QR) need_keypress(KEY_QR)
# def split_qrs(raw, type_code, encoding=None, # def split_qrs(raw, type_code, encoding=None,
# min_split=1, max_split=1295, min_version=5, max_version=40 # min_split=1, max_split=1295, min_version=5, max_version=40
actual_vers, parts = split_qrs(psbt, type_code, **kws) actual_vers, parts = split_qrs(raw_data, type_code, **kws)
random.shuffle(parts) random.shuffle(parts)
for p in parts: for p in parts:
scan_a_qr(p) scan_a_qr(p)
time.sleep(4.0 / len(parts)) # just so we can watch time.sleep(2.0 / len(parts)) # just so we can watch
return doit
@pytest.fixture
def try_sign_bbqr(cap_story, scan_a_qr, press_select, press_cancel, need_keypress,
readback_bbqr, split_scan_bbqr):
def doit(psbt, type_code="P", approve=True, nfc_push_tx=False, **kws):
split_scan_bbqr(psbt, type_code, **kws)
for r in range(20): for r in range(20):
title, story = cap_story() title, story = cap_story()

View File

@ -932,7 +932,6 @@ def test_verify_signature_file_truncated(way, microsd_path, cap_story, verify_ar
else: else:
assert title == "FAILURE" assert title == "FAILURE"
assert "Armor text MUST be surrounded by exactly five (5) dashes" in story assert "Armor text MUST be surrounded by exactly five (5) dashes" in story
assert "auth.py" in story
@pytest.mark.parametrize("msg", ["this is the message to sign", "this is meessage to sign\n with newline", "a"*200]) @pytest.mark.parametrize("msg", ["this is the message to sign", "this is meessage to sign\n with newline", "a"*200])

View File

@ -1271,12 +1271,14 @@ def make_myself_wallet(dev, set_bip39_pw, offer_ms_import, press_select, clear_m
def select_wallet(idx): def select_wallet(idx):
# select to specific pw # select to specific pw
print(f"--- switch to another leg of MS: {idx} ---")
xfp = set_bip39_pw(passwords[idx]) xfp = set_bip39_pw(passwords[idx])
if do_import: if do_import:
offer_ms_import(config) offer_ms_import(config)
time.sleep(.1) time.sleep(.1)
press_select() press_select()
assert xfp == keys[idx][0] assert xfp == keys[idx][0]
return xfp
return (keys, select_wallet) return (keys, select_wallet)

View File

@ -263,7 +263,43 @@ def try_sign_nfc(cap_story, pick_menu_item, goto_home, need_keypress,
press_select() press_select()
txid = None txid = None
got_txid, got_psbt, got_txn = nfc_read_txn(txid=txid, contents=contents) got_psbt, got_txn, got_txid = ndef_parse_txn_psbt(contents, txid, encoding, expect_finalize)
return ip, (got_psbt or got_txn), (txid or got_txid)
yield doit
# cleanup / restore
sim_exec('from pyb import SDCard; SDCard.ejected = False')
@pytest.fixture
def ndef_parse_txn_psbt(press_cancel):
def doit(contents, txid=None, encoding='binary', expect_finalized=True):
# from NFC data read, what did we get?
got_txid = None
got_txn = None
got_psbt = None
got_hash = None
for got in ndef.message_decoder(contents):
if got.type == 'urn:nfc:wkt:T':
assert 'Transaction' in got.text or 'PSBT' in got.text
if 'Transaction' in got.text and txid:
assert txid in got.text
elif got.type == 'urn:nfc:ext:bitcoin.org:txid':
got_txid = b2a_hex(got.data).decode('ascii')
elif got.type == 'urn:nfc:ext:bitcoin.org:txn':
got_txn = got.data
elif got.type == 'urn:nfc:ext:bitcoin.org:psbt':
got_psbt = got.data
elif got.type == 'urn:nfc:ext:bitcoin.org:sha256':
got_hash = got.data
else:
raise ValueError(got.type)
assert got_psbt or got_txn, 'no data?'
assert got_hash
assert got_hash == sha256(got_psbt or got_txn).digest()
if got_txid and not txid: if got_txid and not txid:
# Txid not shown in pure NFC case # Txid not shown in pure NFC case
@ -272,11 +308,11 @@ def try_sign_nfc(cap_story, pick_menu_item, goto_home, need_keypress,
if got_txid: if got_txid:
assert got_txn assert got_txn
assert got_txid == txid assert got_txid == txid
assert expect_finalize assert expect_finalized
result = got_txn result = got_txn
open("debug/nfc-result.txn", 'wb').write(result) open("debug/nfc-result.txn", 'wb').write(result)
else: else:
assert not expect_finalize assert not expect_finalized
result = got_psbt result = got_psbt
open("debug/nfc-result.psbt", 'wb').write(result) open("debug/nfc-result.psbt", 'wb').write(result)
@ -310,12 +346,10 @@ def try_sign_nfc(cap_story, pick_menu_item, goto_home, need_keypress,
assert was != now assert was != now
press_cancel() # exit re-export animation press_cancel() # exit re-export animation
return ip, (got_psbt or got_txn), txid
yield doit return got_psbt, got_txn, got_txid
# cleanup / restore return doit
sim_exec('from pyb import SDCard; SDCard.ejected = False')
@pytest.mark.parametrize('num_outs', [ 1, 20, 250]) @pytest.mark.parametrize('num_outs', [ 1, 20, 250])
def test_nfc_after(num_outs, fake_txn, try_sign, nfc_read, need_keypress, def test_nfc_after(num_outs, fake_txn, try_sign, nfc_read, need_keypress,
@ -421,7 +455,7 @@ def test_ndef_roundtrip(load_shared_mod):
@pytest.mark.parametrize('chain', ['BTC', 'XTN']) @pytest.mark.parametrize('chain', ['BTC', 'XTN'])
@pytest.mark.parametrize('way', ['sd', 'nfc', 'usb', 'qr']) @pytest.mark.parametrize('way', ['sd', 'nfc', 'usb', 'qr'])
def test_nfc_pushtx(num_outs, chain, enable_nfc, settings_set, settings_remove, def test_nfc_pushtx(num_outs, chain, enable_nfc, settings_set, settings_remove,
try_sign, fake_txn, nfc_block4rf, nfc_read, press_cancel, try_sign, fake_txn, nfc_block4rf, nfc_read_url, press_cancel,
cap_story, cap_screen, has_qwerty, way, try_sign_microsd, cap_story, cap_screen, has_qwerty, way, try_sign_microsd,
try_sign_nfc, scan_a_qr, need_keypress, press_select, try_sign_nfc, scan_a_qr, need_keypress, press_select,
goto_home, multisig, fake_ms_txn, import_ms_wallet, goto_home, multisig, fake_ms_txn, import_ms_wallet,
@ -491,20 +525,12 @@ def test_nfc_pushtx(num_outs, chain, enable_nfc, settings_set, settings_remove,
scr = cap_screen() scr = cap_screen()
assert 'TXID:' in scr assert 'TXID:' in scr
contents = nfc_read() uri = nfc_read_url()
print(f'nfc contents = {len(contents)}') assert uri.startswith(prefix)
assert uri.startswith(prefix + 't')
press_cancel() # exit NFC animation parts = urlsplit(uri)
# expect a single record, a URL
got, = ndef.message_decoder(contents)
assert got.type == 'urn:nfc:wkt:U'
assert got.uri.startswith(prefix)
assert got.uri.startswith(prefix + 't')
parts = urlsplit(got.uri)
args = parse_qsl(unquote(parts.fragment)) args = parse_qsl(unquote(parts.fragment))
assert args[0][0] == 't', 'txn must be first' assert args[0][0] == 't', 'txn must be first'

View File

@ -47,6 +47,7 @@ def need_some_notes(settings_get, settings_set):
notes = settings_get('notes', []) notes = settings_get('notes', [])
if not notes: if not notes:
settings_set('notes', [dict(misc=body, title=title)]) settings_set('notes', [dict(misc=body, title=title)])
settings_set('secnap', True)
return notes return notes
return doit return doit
@ -54,8 +55,8 @@ def need_some_notes(settings_get, settings_set):
def need_some_passwords(settings_get, settings_set): def need_some_passwords(settings_get, settings_set):
def doit(): def doit():
notes = settings_get('notes', []) notes = settings_get('notes', [])
if any(n.get('password', False) for n in notes): if not any(1 for n in notes if n.get('password', False)):
settings_set('notes', [ notes.extend([
{'misc': 'More Notes AAAA', {'misc': 'More Notes AAAA',
'password': 'fds65fd5f1sd51s', 'password': 'fds65fd5f1sd51s',
'site': 'https://a.com', 'site': 'https://a.com',
@ -67,6 +68,8 @@ def need_some_passwords(settings_get, settings_set):
'title': 'B-Title', 'title': 'B-Title',
'user': 'Buzzer'} 'user': 'Buzzer'}
]) ])
settings_set('notes', notes)
settings_set('secnap', True)
return notes return notes
return doit return doit

View File

@ -1617,6 +1617,7 @@ def test_incomplete_signing(dev, try_sign, fake_txn, cap_story):
oo.serialize(fd) oo.serialize(fd)
mod_psbt = fd.getvalue() mod_psbt = fd.getvalue()
raise pytest.xfail('issue #915 ')
with pytest.raises(CCProtoError) as ee: with pytest.raises(CCProtoError) as ee:
orig, result = try_sign(mod_psbt, accept=True, finalize=True) orig, result = try_sign(mod_psbt, accept=True, finalize=True)
@ -3032,12 +3033,18 @@ def test_low_R_grinding(dev, goto_home, microsd_path, press_select, offer_ms_imp
time.sleep(.1) time.sleep(.1)
title, story = cap_story() title, story = cap_story()
if 'Seed Vault' in story:
press_select()
time.sleep(.1)
title, story = cap_story()
assert "[747B698E]" in title assert "[747B698E]" in title
press_select() press_select()
time.sleep(.1) time.sleep(.1)
_, story = offer_ms_import(desc) _, story = offer_ms_import(desc)
assert "Create new multisig wallet?" in story assert "Create new multisig wallet?" in story \
or 'Update NAME only of existing multisig' in story
time.sleep(.1) time.sleep(.1)
press_select() press_select()
@ -3045,6 +3052,7 @@ def test_low_R_grinding(dev, goto_home, microsd_path, press_select, offer_ms_imp
# only on firmware versions that do only 10 grinding iterations # only on firmware versions that do only 10 grinding iterations
try_sign(base64.b64decode(b64psbt), accept=True) try_sign(base64.b64decode(b64psbt), accept=True)
reset_seed_words()
def test_null_data_op_return(fake_txn, start_sign, end_sign, reset_seed_words): def test_null_data_op_return(fake_txn, start_sign, end_sign, reset_seed_words):
reset_seed_words() reset_seed_words()

609
testing/test_teleport.py Normal file
View File

@ -0,0 +1,609 @@
# (c) Copyright 2024 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# Key Teleport (a Q-only feature)
#
# - you'll need v1.0.1 of bbqr library for this to work
#
import pytest, time, re, pdb
from helpers import prandom, xfp2str, str2xfp
from binascii import a2b_hex
from bbqr import split_qrs, join_qrs
from charcodes import KEY_QR, KEY_NFC
from base64 import b32encode
from constants import *
from test_bbqr import readback_bbqr, split_scan_bbqr
from test_notes import need_some_notes, need_some_passwords
from test_ephemeral import SEEDVAULT_TEST_DATA
from test_nfc import ndef_parse_txn_psbt
# All tests in this file are exclusively meant for Q
#
@pytest.fixture(autouse=True)
def THIS_FILE_requires_q1(is_q1, is_headless):
if not is_q1 or is_headless:
raise pytest.skip('Q1 only (not headless)')
@pytest.fixture()
def rx_start(grab_payload, goto_home, pick_menu_item):
def doit(**kws):
goto_home()
pick_menu_item('Advanced/Tools')
pick_menu_item('Key Teleport (start)')
return grab_payload('R', **kws)[0:2]
return doit
@pytest.fixture()
def grab_payload(press_select, need_keypress, press_cancel, nfc_read_url, cap_story, nfc_block4rf, cap_screen_qr, readback_bbqr):
# started the process; capture pw/code and QR contents, verify NFC works
def doit(tt_code, allow_reuse=True, reset_pubkey=False):
expect_in_title = 'Receive' if tt_code == 'R' else 'Teleport Password'
title, story = cap_story()
if 'Reuse' in title and tt_code == 'R':
assert allow_reuse
assert 'press (R)' in story
if reset_pubkey:
# make a new key anyway
need_keypress('r')
else:
press_select()
time.sleep(.1)
title, story = cap_story()
assert 'Teleport' in title
assert expect_in_title in title
assert 'QR' in story
code, = re.findall(' (\w{8}) = ', story)
assert len(code) == 8
nfc_raw = None
if KEY_NFC in story:
# test NFC case -- when enabled
need_keypress(KEY_NFC)
# expect NFC animation
nfc_block4rf()
url = nfc_read_url().replace('%24', '$')
assert url.startswith('https://keyteleport.com#')
nfc_data = url.rsplit('#')[1]
assert nfc_data.startswith(f'B$2{tt_code}0100')
filetype, nfc_raw = join_qrs([nfc_data]) # update your bbqr install if fails
assert filetype == tt_code
need_keypress(KEY_QR)
if tt_code != 'E':
qr_data = cap_screen_qr().decode()
filetype, qr_raw = join_qrs([qr_data])
else:
# will be multi-frame BBQr in case of PSBT
filetype, qr_raw = readback_bbqr()
# this is un-split BBQR which didn't really happen, but useful
qr_data = f'B$2{filetype}0100' + b32encode(qr_raw).decode('ascii').rstrip('=')
assert filetype == tt_code
if nfc_raw: assert nfc_raw == qr_raw
press_cancel()
press_cancel()
return code, qr_data, qr_raw
return doit
@pytest.fixture()
def rx_complete(press_select, need_keypress, press_cancel, cap_story, scan_a_qr, enter_complex, cap_screen, goto_home, split_scan_bbqr):
# finish the teleport by doing QR and getting data
def doit(data, pw, expect_fail=False, expect_xfp=None):
goto_home()
need_keypress(KEY_QR)
time.sleep(.250) # required
if isinstance(data, tuple):
bbrq_type, raw = data
split_scan_bbqr(raw, bbrq_type, max_version=26)
else:
assert len(data) < 2000 # USB protocol limit
scan_a_qr(data)
if expect_fail:
time.sleep(.200)
return
for retries in range(20):
scr = cap_screen()
if 'Teleport Password' in scr: break
time.sleep(.200)
if expect_xfp:
assert xfp2str(expect_xfp) in scr
enter_complex(pw)
time.sleep(.150) # required
return doit
@pytest.fixture()
def tx_start(press_select, need_keypress, press_cancel, goto_home, pick_menu_item, cap_story, scan_a_qr, enter_complex, cap_screen):
# start the Tx process, capturing password and leaving you are picker menu
def doit(rx_qr, rx_code, expect_fail=None, expect_wrong_code=False):
goto_home()
need_keypress(KEY_QR)
time.sleep(.250) # required
scan_a_qr(rx_qr)
time.sleep(.250) # required
scr = cap_screen()
if expect_fail:
assert expect_fail in scr
return
assert 'Teleport Password (number)' in scr
enter_complex(rx_code)
time.sleep(.150) # required
title, story = cap_story()
if expect_wrong_code:
# not a sure thing
if 'Incorrect Teleport Pass' in story:
return True
assert title == 'Key Teleport: Send'
assert 'secure notes' in story
assert 'WARNING' in story
press_select()
return doit
def test_rx_reuse(rx_start):
# check rx pubkey re-use logic
code, enc_pubkey = rx_start(allow_reuse=True, reset_pubkey=True)
assert code.isdigit()
code2, enc_pubkey2 = rx_start(allow_reuse=True, reset_pubkey=False)
assert code2 == code
assert enc_pubkey2 == enc_pubkey
code3, pk3 = rx_start(allow_reuse=True, reset_pubkey=True)
assert code3 != code
def test_tx_quick_note(rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select):
# Send a quick-note
code, rx_pubkey = rx_start()
pw = tx_start(rx_pubkey, code)
m = cap_menu()
assert 'Master Seed Words' in m
assert 'Quick Text Message' in m
# other contents require other features to be enabled
msg = b32encode(prandom(10)).decode('ascii')
pick_menu_item('Quick Text Message')
enter_complex(msg)
time.sleep(.150) # required
pw, data, _ = grab_payload('S')
assert len(pw) == 8
# now, send that back
rx_complete(data, pw)
# should arrive in notes menu
m = cap_menu()
assert m[-1] == 'Import'
mi = [i for i in m if i.endswith(': Quick Note')]
assert mi
pick_menu_item(mi[-1]) # most recent test
# view note
m = cap_menu()
assert m[0] == '"Quick Note"'
pick_menu_item(m[0])
_, body = cap_story()
assert body == msg
# cleanup
press_cancel()
pick_menu_item('Delete')
press_select()
def test_tx_master_send(rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select):
# Send master secret, but doesn't really work since same as what we have
code, rx_pubkey = rx_start()
pw = tx_start(rx_pubkey, code)
# other contents require other features to be enabled
pick_menu_item('Master Seed Words')
title, body = cap_story()
assert 'Are you SURE' in title
assert 'MASTER secret' in body
assert '24 words' in body
press_select()
time.sleep(.150) # required?
pw, data, _ = grab_payload('S')
# now, send that back
rx_complete(data, pw)
title, body = cap_story()
assert title == 'FAILED'
assert 'Cannot use master seed as temp' in body
assert 'successfully tested' in body
press_cancel()
@pytest.mark.parametrize('qty', [1, 3])
def test_tx_notes(qty, rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select, need_some_passwords, need_some_notes, settings_set, settings_get):
# Send notes.
settings_set('notes', [])
need_some_notes()
notes = need_some_passwords()
assert len(notes) >= qty
code, rx_pubkey = rx_start()
pw = tx_start(rx_pubkey, code)
# other contents require other features to be enabled
if qty == 1:
pick_menu_item('Single Note / Password')
pick_menu_item('1: ' + notes[0]["title"])
else:
pick_menu_item('Export All Notes & Passwords')
time.sleep(.150) # required?
pw, data, _ = grab_payload('S')
# now, send that back
rx_complete(data, pw)
# arrive in settings menu, on last item (last imported)
m = cap_menu()
assert m[-1] == 'Import'
after = settings_get('notes', None)
assert notes[0:qty] == after[-qty:]
settings_set('notes', [])
press_cancel()
@pytest.mark.parametrize('data', SEEDVAULT_TEST_DATA[0:2])
def test_tx_seedvault(data, rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select, settings_set, settings_get, goto_home, need_keypress):
# Send seeds from vault
xfp, entropy, mnemonic = data
# build stashed encoded secrets
entropy_bytes = bytes.fromhex(entropy)
if mnemonic:
vlen = len(entropy_bytes)
assert vlen in [16, 24, 32]
marker = 0x80 | ((vlen // 8) - 2)
stored_secret = bytes([marker]) + entropy_bytes
else:
stored_secret = entropy_bytes
pkg = (xfp, stored_secret.hex(), f"[{xfp}]", "from testing")
settings_set("seedvault", True)
settings_set("seeds", [pkg])
# get ready to send
code, rx_pubkey = rx_start(reset_pubkey=True)
pw = tx_start(rx_pubkey, code)
pick_menu_item('From Seed Vault')
mi, = (i for i in cap_menu() if i.endswith(f"[{xfp}]"))
pick_menu_item(mi)
time.sleep(.150) # required?
pw, data, _ = grab_payload('S')
settings_set("seeds", [])
rx_complete(data, pw)
if settings_get("seedvault", False):
time.sleep(.1)
title, body = cap_story()
assert 'Press (1) to store temp' in body
assert 'to continue without saving' in body
need_keypress('1')
time.sleep(.1)
title, body = cap_story()
assert xfp in body
assert 'Saved to Seed Vault' in body
assert settings_get('seeds') == [pkg]
goto_home()
pick_menu_item('Restore Master')
press_select()
time.sleep(.1)
assert settings_get('xfp', -1) == simulator_fixed_xfp
def test_rx_truncated(rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, rx_complete, cap_story, press_cancel, press_select):
# Truncate the RX Code
code, rx_pubkey = rx_start()
pw = tx_start(rx_pubkey[:-3], code, expect_fail='Truncated KT RX')
def test_tx_wrong_pub(rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select):
# simulate wrong numeric code only -- sender doesn't know
right_code, rx_pubkey = rx_start()
for attempt in range(20):
code = '%08d' % attempt
failed = tx_start(rx_pubkey, code, expect_wrong_code=True)
if failed:
# 50% odds (apx, maybe?) of wrong code being detected.
print(f'{code} => wasnt accepted')
continue
break
else:
raise pytest.fail('huh')
# other contents require other features to be enabled
pick_menu_item('Master Seed Words')
time.sleep(.150) # required?
press_select()
time.sleep(.150) # required?
pw, data, _ = grab_payload('S')
# now, send that back
rx_complete(data, pw, expect_fail=True)
title, body = cap_story()
assert title == 'Teleport Fail'
assert 'password was wrong' in body
assert 'start again' in body
press_cancel()
@pytest.mark.unfinalized
@pytest.mark.parametrize('num_ins', [ 15 ])
@pytest.mark.parametrize('M', [2, 4])
@pytest.mark.parametrize('segwit', [True])
@pytest.mark.parametrize('incl_xpubs', [ False ])
def test_teleport_ms_sign(M, use_regtest, make_myself_wallet, segwit, num_ins, dev, clear_ms,
fake_ms_txn, try_sign, incl_xpubs, bitcoind, cap_story, need_keypress,
cap_menu, pick_menu_item, grab_payload, rx_complete, press_select, ndef_parse_txn_psbt,
press_nfc, nfc_read, settings_get, settings_set):
# IMPORTANT: wont work if you start simulator with --ms flag. Use no args
all_out_styles = list(unmap_addr_fmt.keys())
num_outs = len(all_out_styles)
clear_ms()
use_regtest()
# create a wallet, with 3 bip39 pw's
keys, select_wallet = make_myself_wallet(M, do_import=(not incl_xpubs))
N = len(keys)
assert M<=N
psbt = fake_ms_txn(num_ins, num_outs, M, keys, segwit_in=segwit, incl_xpubs=incl_xpubs,
outstyles=all_out_styles, change_outputs=list(range(1,num_outs)))
open(f'debug/myself-before.psbt', 'wb').write(psbt)
cur_wallet = 0
my_xfp = select_wallet(cur_wallet)
_, updated = try_sign(psbt, accept_ms_import=incl_xpubs)
open(f'debug/myself-after-1.psbt', 'wb').write(updated)
assert updated != psbt
title, body = cap_story()
assert title == 'Teleport PSBT?'
assert 'Press (T)' in body
while 1:
# expect: a menu of other signers to pick from
need_keypress('t')
time.sleep(.1)
m = cap_menu()
assert len(m) == N
assert 'YOU' in [ln for ln in m if xfp2str(my_xfp) in ln][0]
unsigned = [ln[1:9] for ln in m if (xfp2str(my_xfp) not in ln) and ('DONE' not in ln)]
assert unsigned
# find another signer
for idx, (xfp, *_) in enumerate(keys):
if xfp2str(xfp) in unsigned:
break
else:
assert 0, 'missing unsigned'
# check XFP changes
next_xfp = keys[idx][0]
assert next_xfp != my_xfp
last_xfp = my_xfp
# pick other xfp to send to
nm, = [mi for mi in m if xfp2str(next_xfp) in mi]
pick_menu_item(nm)
# grab the payload and pw
pw, data, qr_raw = grab_payload('E')
assert len(pw) == 8
nn = xfp2str(next_xfp)
open(f'debug/next_qr_{nn}.txt', 'wt').write(f'{nn}\n\n{pw}\n\n{data}')
# switch personalities, and try to read that QR
new_xfp = select_wallet(idx)
assert new_xfp == next_xfp
my_xfp = next_xfp
assert settings_get('xfp') == my_xfp
# import and sign
rx_complete(('E', qr_raw), pw, expect_xfp=last_xfp)
title, body = cap_story()
assert title == 'OK TO SEND?'
press_select()
time.sleep(.25)
title, body = cap_story()
if title != 'Teleport PSBT?':
break
assert title == 'Teleport PSBT?'
assert 'more signatures' in body
assert title == 'Final TXID'
txid = body.split()[0]
# share signed txn via low-level NFC
press_nfc()
time.sleep(.1)
contents = nfc_read()
got_psbt, got_txn, _ = ndef_parse_txn_psbt(contents, txid, expect_finalized=True)
assert not got_psbt
assert got_txn
def test_teleport_big_ms(make_myself_wallet, clear_ms,
fake_ms_txn, try_sign, cap_story, need_keypress,
cap_menu, pick_menu_item, grab_payload, rx_complete, press_select, ndef_parse_txn_psbt,
set_master_key,
goto_home, press_nfc, nfc_read, settings_get, settings_set, open_microsd, import_ms_wallet):
# define lots of wallets, do teleport from SD disk
clear_ms()
M, N = 2, 15
for i in range(5):
keys = import_ms_wallet(M, N, name=f'ms{i}-test', unique=(i*73), accept=True,
descriptor=False, bip67=True)
# just use last wallet
psbt = fake_ms_txn(1, 1, M, keys)
fname = 'ms-example.psbt'
open_microsd(fname, 'wb').write(psbt)
goto_home()
pick_menu_item('Advanced/Tools')
pick_menu_item('File Management')
pick_menu_item('Teleport Multisig PSBT')
need_keypress('1') # top slot
pick_menu_item(fname)
# on Co-signer list menu
m = cap_menu()
assert len(m) == N
myself, = [i for i in m if 'YOU' in i]
pick_menu_item(myself)
title, body = cap_story()
assert title == 'OK TO SEND?'
press_select()
time.sleep(.25)
# have 1 sigs now, need one more via teleport
title, body = cap_story()
assert title == 'Teleport PSBT?'
need_keypress('t')
# pick another one randomly
m = cap_menu()
assert len(m) == N
target = m[-1] if 'YOU' not in m[0] else m[-2]
pick_menu_item(target)
target_xfp = str2xfp(target[1:9])
# capture QR+pw to go there
pw, data, qr_raw = grab_payload('E')
tmp_ms = settings_get('multisig')
# switch to that key, receive it
node, = [n for x,n,_ in keys if x == target_xfp]
set_master_key(node.hwif(as_private=True))
# copy over the one MS wallet this xfp was involved in
settings_set('multisig', [tmp_ms[-1]])
# import and sign
rx_complete(('E', qr_raw), pw, expect_xfp=simulator_fixed_xfp)
title, body = cap_story()
assert title == 'OK TO SEND?'
press_select()
time.sleep(.25)
title, body = cap_story()
assert title == 'Final TXID'
'''
@pytest.mark.parametrize('N', [14, 20])
@pytest.mark.parametrize('M', [2, 14])
@pytest.mark.parametrize('incl_xpubs', [ False ])
def test_teleport_sd_psbt(M, use_regtest, make_myself_wallet, segwit, dev, clear_ms,
fake_ms_txn, try_sign, incl_xpubs, bitcoind, cap_story, need_keypress,
cap_menu, pick_menu_item, grab_payload, rx_complete, press_select, ndef_parse_txn_psbt,
press_nfc, nfc_read, settings_get, settings_set, open_microsd):
keys = import_ms_wallet(M, N, descriptor=descriptor, bip67=bip67)
keys, select_wallet = make_myself_wallet(M, do_import=(not incl_xpubs))
'''
# TODO
# - send single-sig PSBT
# - ms psbt send when lots of unrelated wallets on rx side
# - ms psbt from disk file
# EOF