Compare commits
24 Commits
master
...
keytelepor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2312569c05 | ||
|
|
e8ae63922d | ||
|
|
2723f93d7c | ||
|
|
a0524ebe60 | ||
|
|
75ca5608dd | ||
|
|
64cb4d20f7 | ||
|
|
66874de399 | ||
|
|
16753f7418 | ||
|
|
ad56b3644a | ||
|
|
f552b881d6 | ||
|
|
cf8b05780b | ||
|
|
2e6fe8ec68 | ||
|
|
f4aaed0506 | ||
|
|
126962e785 | ||
|
|
e15670bef6 | ||
|
|
aadf53d0c4 | ||
|
|
749459752f | ||
|
|
fcbe05ed68 | ||
|
|
bd172063e3 | ||
|
|
ab89a4db2a | ||
|
|
2cc91accaa | ||
|
|
8a589d22b1 | ||
|
|
5c463e5cde | ||
|
|
44dc30a57a |
214
docs/key-teleport.md
Normal file
214
docs/key-teleport.md
Normal 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.
|
||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
871
shared/auth.py
871
shared/auth.py
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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. "
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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
519
shared/msgsign.py
Normal 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
|
||||||
@ -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.")
|
||||||
|
|||||||
@ -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.')
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
132
shared/seed.py
132
shared/seed.py
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
755
shared/teleport.py
Normal 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
|
||||||
@ -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):
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
16
shared/ux.py
16
shared/ux.py
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
@ -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)))
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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))))
|
||||||
|
|
||||||
|
|||||||
@ -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:]
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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])
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
609
testing/test_teleport.py
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user