Multisig PSBT support

This commit is contained in:
Peter D. Gray 2025-03-26 11:36:01 -04:00 committed by doc-hex
parent 6fd2ef619e
commit 39adb2ac41
14 changed files with 521 additions and 95 deletions

View File

@ -44,8 +44,7 @@ The first byte encodes what the package contents (under all the encryption).
- `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
- `P` - a more-signed binary PSBT being returned back to sender
- `p` - binary PSBT to be signed, perhaps multisig but not required.
## QR details
@ -58,30 +57,38 @@ 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 PSBT: `(randint)(data)` ... randint (4 bytes) indicates which randomly
selected derived subkey from pre-shared xpub associated with receiver
- `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 Picking
### PSBT Key Selection
When sending PSBT data, the keys involved are picked at random by the sender in range:
5000..(2^30).
When sending PSBT data, a nonce is picked at random by the sender
in range: `0..(2^28)`
This is called `randint`. The receiver's pubkey will be
This nonce is called `randint`. The receiver's pubkey will be
.../20250317/(randint)
where `...` is the derivation used in the multisig setup for the co-signer who will
receive the package. The sender's keypair is implied by:
.../20250318/(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
@ -175,7 +182,33 @@ and the site will be served over SSL.
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. They should get another
chance to do the same for the other possible co-signers.
signed) and the QR is prepared for that receiver. Because we
cannot do arbitary conbining, it's best if the next signer continues
to teleport the updated PSBT to further signers. In other words,
a daisy-chain pattern is prefered to a star patter. The signer
who completes the Mth (of N) signature will be able to finalize
the transaction, and ideally with PushTx feature, broadcast it.
# Security Comments
## Such short passwords?
We are using 8-character passwords because we want them to be
practical to share over non-digital channels such as a voice phone
call, or hand-written note.
It is important to remind users that the passwords should be sent
by a different channel from the QR itself. Best is to call up your
other party and say the letters to them directly.
## Is it safe to save image of QR to cloud?
Yes, this seems safe. Of course, if you can control it, perhaps not
a risk to accept... but the QR is encrypted via ECDH using a key
that is forgotten after the transfer, so forward privacy is protected.
Also your cloud service (or photo roll, chat app log, etc) will not
have the 8-character password which is also required unpack the secrets.
The QR codes themselves are fully random and do not reveal the
identity of your COLDCARD, your on chain funds or anything linked
to you.

View File

@ -1657,13 +1657,14 @@ async def list_files(*A):
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,
allow_batch_sign=False, ux=True):
allow_batch=False, ux=True):
# present a menu w/ a list of files... to be read
# - 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
# - escape: allow these chars to skip picking process
# - 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:
choices = []
@ -1707,7 +1708,7 @@ async def file_picker(suffix=None, min_size=1, max_size=1000000, taster=None,
label = fn
while label in sofar:
# 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
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()
items = [MenuItem(label, f=clicked, arg=(path, fn)) for label, path, fn in choices]
if allow_batch_sign and len(choices) > 1:
# we know that each choices member is psbt as allow_batch_sign is only True
# in Ready To Sign
items.insert(0, MenuItem("[Sign All]", f=batch_sign, arg=choices))
if allow_batch and len(choices) > 1:
# Allow an "all" selection
label, funct = allow_batch
items.insert(0, MenuItem(label, f=funct, arg=choices))
menu = MenuSystem(items)
the_ux.push(menu)
@ -1895,7 +1896,7 @@ from your desktop wallet software or command line tools.\n\n'''
input_psbt = path + '/' + fn
else:
# 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:
return

View File

@ -1040,6 +1040,12 @@ class ApproveTransaction(UserAuthorizedAction):
continue
break
elif version.has_qwerty and self.psbt.active_multisig:
# Offer to teleport the result
from teleport import kt_send_psbt
await kt_send_psbt(self.psbt, self.result[0], post_signing=True)
async def txn_explorer(self):
# Page through unlimited-sized transaction details
# - shows all outputs (including change): their address and amounts.
@ -1250,15 +1256,19 @@ def psbt_encoding_taster(taste, psbt_len):
async def done_signing(psbt, input_method=None, filename=None, force_vdisk=False,
output_encoder=None, slot_b=False, finalize=None):
# User authorized PSBT for signing, and we added signatures.
# - allow PushTX if enabled (first thing)
# - can save final TXN out to SD card/VirtDisk, share by NFC, QR.
from glob import dis, PSRAM
from files import CardSlot, CardMissingError
from sffile import SFFile
from ux import show_qr_code, import_export_prompt, ux_show_story
txid = None
is_complete = psbt.is_complete()
with SFFile(TXN_OUTPUT_OFFSET, max_size=MAX_TXN_LEN, message="Saving...") as psram:
is_complete = psbt.is_complete()
if is_complete:
txid = psbt.finalize(psram)
else:
@ -1439,10 +1449,11 @@ async def done_signing(psbt, input_method=None, filename=None, force_vdisk=False
n += 1
async def sign_psbt_file(filename, force_vdisk=False, slot_b=None, abort=False):
async def sign_psbt_file(filename, force_vdisk=False, slot_b=None, just_read=False, ux_abort=False):
# sign a PSBT file found on a MicroSD card
# - or from VirtualDisk (mk4)
from files import CardSlot
# - to re-use reading/decoding logic, pass just_read
from glob import dis
from ux import the_ux
@ -1490,6 +1501,9 @@ async def sign_psbt_file(filename, force_vdisk=False, slot_b=None, abort=False):
assert total <= psbt_len
psbt_len = total
if just_read:
return psbt_len
UserAuthorizedAction.cleanup()
UserAuthorizedAction.active_request = ApproveTransaction(
psbt_len,
@ -1498,7 +1512,7 @@ async def sign_psbt_file(filename, force_vdisk=False, slot_b=None, abort=False):
"force_vdisk": force_vdisk,
"output_encoder": output_encoder}
)
if abort:
if ux_abort:
# needed for auto vdisk mode
abort_and_push(UserAuthorizedAction.active_request)
else:

View File

@ -39,13 +39,14 @@ if version.has_battery:
from battery import battery_idle_timeout_chooser, brightness_chooser
from q1 import scan_and_bag
from notes import make_notes_menu
from teleport import kt_start_rx
from teleport import kt_start_rx, kt_send_file_psbt
else:
battery_idle_timeout_chooser = None
brightness_chooser = None
scan_and_bag = None
make_notes_menu = None
kt_start_rx = None
kt_send_file_psbt = None
#
@ -215,6 +216,7 @@ FileMgmtMenu = [
MenuItem('Export Wallet', predicate=has_secrets, menu=WalletExportMenu), #dup elsewhere
MenuItem('Sign Text File', predicate=has_secrets, f=sign_message_on_sd),
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('Verify Sig File', f=verify_sig_file),
MenuItem('NFC File Share', predicate=nfc_enabled, f=nfc_share_file, shortcut=KEY_NFC),

View File

@ -22,6 +22,9 @@ TRUST_VERIFY = const(0)
TRUST_OFFER = const(1)
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):
pass
@ -468,6 +471,10 @@ class MultisigWallet(WalletABC):
return set(xp_idx for xp_idx, (wxfp, _, _) in enumerate(self.xpubs)
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):
# Assuming a suffix of /0/0 on the defined prefix's, yield
# possible deposit addresses for this wallet.
@ -1180,6 +1187,84 @@ 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)
# 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)
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())
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 (not kp) or (kp_deriv != ms.xfp_paths[my_xfp]):
# my keypair is cachable if path the same in 2nd MS wallet and so on
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()
# 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):
# action for 'no wallets yet' menu item
await ux_show_story("You don't have any multisig wallets yet.")

View File

@ -2232,6 +2232,19 @@ class psbtObject(psbtProxy):
sigs = sigs[:self.active_multisig.M]
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):
# Stream out the finalized transaction, with signatures applied
# - assumption is it's complete already.

View File

@ -4,7 +4,7 @@
# secure environment of two Q's.
#
import sys, uzlib, ngu, aes256ctr, bip39, json, stash
from utils import problem_file_line, B2A, xfp2str, deserialize_secret
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
@ -14,21 +14,17 @@ 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'
'''
- `w` - 12/18/24 words - 16/24/32 bytes follow
- `m` - (one byte of length) + (up to 71 bytes) - BIP-32 raw master secret [rare]
- `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` - secure note or password (JSON)
- `e` - full 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
- `P` - a more-signed binary PSBT being returned back to sender
'''
# 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
@ -134,7 +130,7 @@ async def tk_show_payload(type_code, payload, title, msg, cta=None):
from ux_q1 import show_bbqr_codes
hints = KEY_QR
if NFC:
if NFC and len(payload) < NFC_SIZE_LIMIT:
hints += KEY_NFC
msg += ' or %s to view on your phone' % KEY_NFC
@ -147,7 +143,7 @@ async def tk_show_payload(type_code, payload, title, msg, cta=None):
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?
# NOTE: CTA rarely seen, but maybe sometimes?
await show_bbqr_codes(type_code, payload, msg=cta)
elif ch == 'x':
return
@ -178,8 +174,8 @@ WARNING: Receiver will have full access to all Bitcoin controlled by these keys!
menu = SecretPickerMenu(rx_pubkey)
the_ux.push(menu)
async def kt_do_send(rx_pubkey, dtype, raw=None, obj=None):
# Example: cleartext = b'w'+ (b'A'*16)
async def kt_do_send(rx_pubkey, dtype, raw=None, obj=None, prefix='', 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())
@ -189,22 +185,27 @@ async def kt_do_send(rx_pubkey, dtype, raw=None, obj=None):
dis.progress_bar_show(0.25)
# all new EC key
my_keypair = ngu.secp256k1.keypair()
my_keypair = kp or ngu.secp256k1.keypair()
dis.progress_bar_show(0.75)
payload = encode_payload(my_keypair, rx_pubkey, noid_key, cleartext)
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 the receiver, via some different channel:"\
"\n\n %s = %s\n\n" % (txt, ' '.join(txt))
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', payload, 'Teleport Password', msg, cta='Show to Receiver')
print(msg)
await tk_show_payload('S' if not prefix else 'E',
payload, 'Teleport Password', msg, cta='Show to Receiver')
from flow import goto_top_menu
goto_top_menu()
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
@ -218,6 +219,8 @@ def pick_noid_key():
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:
@ -230,28 +233,32 @@ async def kt_decode_rx(is_psbt, payload):
body = payload[33:]
pair = ngu.secp256k1.keypair(a2b_hex(rx_key))
ses_key, body = decode_step1(pair, his_pubkey, body)
else:
randint = payload[0:4]
body = payload[4:]
# 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
# may need to iterate over a few wallets?
# TODO: multisig
ses_key, body, sender_xfp = MultisigWallet.kt_search_rxkey(payload)
ses_key, body = decode_step1(pair, his_pubkey, body)
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, "
"or it was sent to a different user. "
"Sender must start again.", title="Teleport Fail")
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='Teleport Password (text)', min_len=8, b39_complete=False, scan_ok=False,
prompt=prompt, min_len=8, b39_complete=False, scan_ok=False,
placeholder='********', funct_keys=None, force_xy=None)
if not pw: return
@ -286,7 +293,6 @@ async def kt_accept_values(dtype, raw):
- `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
- `P` - a more-signed binary PSBT being returned back to sender
'''
from chains import current_chain, slip32_deserialize
from flow import has_se_secrets, goto_top_menu
@ -308,9 +314,9 @@ async def kt_accept_values(dtype, raw):
assert ch.name == chains.current_chain.name, 'wrong chain'
enc = stash.SecretStash.encode(node=node)
elif dtype in 'pP':
# raw PSBT -- bigger
from auth import sign_transaction
elif dtype == 'p':
# raw PSBT -- much bigger more complex
from auth import sign_transaction, TXN_INPUT_OFFSET
psbt_len = len(raw)
# copy into PSRAM
@ -373,7 +379,7 @@ 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):
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
@ -389,6 +395,10 @@ def encode_payload(my_keypair, his_pubkey, noid_key, body):
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):
@ -541,5 +551,165 @@ class SecretPickerMenu(MenuSystem):
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 get it next using QR codes." % num_to_complete, title="Teleport PSBT?", escape='t')
if ch != 't': 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 = need[idx]
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 # we dont offer to QR to ourselves
elif x not in need:
txt += ': DONE'
f = None
mi = MenuItem(txt, f=f)
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))
async def kt_send_file_psbt(*a):
# Menu item: choose a PSBT file from SD card, and send to co-signers.
# Heavy code re-use here. Need to find the multisig wallet associated w/ file,
# so we need to parse it and we must be one of the co-signers.
from actions import is_psbt, file_picker
from auth import sign_psbt_file, TXN_INPUT_OFFSET
from version import MAX_TXN_LEN
from ux import import_export_prompt
from psbt import psbtObject
from glob import dis
# choose any PSBT from SD
picked = await import_export_prompt("PSBT", is_import=True, no_nfc=True, no_qr=True)
if picked == KEY_CANCEL:
return
choices = await file_picker(suffix='psbt', min_size=50, ux=False,
max_size=MAX_TXN_LEN, taster=is_psbt, **picked)
if not choices:
# error msg already shown
return
if len(choices) == 1:
# single - skip the menu
label,path,fn = choices[0]
input_psbt = path + '/' + fn
else:
# multiples - make them pick one
input_psbt = await file_picker(choices=choices)
if not input_psbt:
return
# read into PSRAM from wherever
psbt_len = await sign_psbt_file(input_psbt, just_read=True, **picked)
dis.fullscreen("Validating...")
try:
dis.progress_sofar(1, 4)
with SFFile(TXN_INPUT_OFFSET, length=psbt_len, message='Reading...') as fd:
# NOTE: psbtObject captures the file descriptor and uses it later
psbt = psbtObject.read_psbt(fd)
await psbt.validate() # might do UX: accept multisig import
dis.progress_sofar(2, 4)
psbt.consider_inputs()
dis.progress_sofar(3, 4)
psbt.consider_keys()
except Exception as exc:
# not going to do full reporting here, use our other code for that!
await ux_show_story("Cannot validate PSBT?\n\n"+str(exc), "PSBT Load Failed")
return
finally:
dis.progress_bar_show(1)
if not psbt.active_multisig:
await ux_show_story("We are not part of this multisig wallet.", "Cannot Teleport PSBT")
return
await kt_send_psbt(psbt, psbt_len=psbt_len)
# EOF

View File

@ -114,7 +114,7 @@ class VirtDisk:
def new_psbt(self, filename):
# New incoming PSBT has been detected, start to sign it.
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):
# potential new firmware file detected

View File

@ -1291,10 +1291,13 @@ def try_sign_microsd(open_microsd, cap_story, pick_menu_item, goto_home,
assert False, 'timed out'
txid = None
lines = story.split('\n\n')
if 'Final TXID:' in story:
txid = lines[-2].split("\n")[-1]
result_fname = lines[-3]
lines = story.split('\n')
if 'Final TXID:' in lines:
txid = lines[-1]
result_fname = lines[-4]
elif 'Key Teleport' in lines[-1]:
# ignore "Press (T) to use Key Teleport to send PSBT to other co-signers" footer
result_fname = lines[2]
else:
result_fname = lines[-2]

View File

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

View File

@ -23,6 +23,7 @@ PassphraseMenu.pp_sofar = ''
SettingsObject.master_sv_data = {}
SettingsObject.master_nvram_key = None
set_seed_value(main.WORDS)
stash.SensitiveValues.clear_cache()
settings.set('chain', 'XTN')
settings.set('words', len(main.WORDS))
@ -30,7 +31,7 @@ settings.set('terms_ok', True)
settings.set('idle_to', 0)
print("New key in effect: %s" % settings.get('xpub', 'MISSING'))
print("Fingerprint: %s" % xfp2str(settings.get('xfp', 0)))
print(".. w/ fingerprint: %s" % xfp2str(settings.get('xfp', 0)))
# impt: if going from xprv => seed words, main menu needs updating
goto_top_menu()

View File

@ -152,20 +152,30 @@ def render_bbqr(need_keypress, cap_screen_qr, sim_exec, readback_bbqr_ll):
@pytest.fixture
def try_sign_bbqr(cap_story, scan_a_qr, press_select, press_cancel, need_keypress, goto_home,
readback_bbqr):
def doit(psbt, type_code="P", approve=True, nfc_push_tx=False, **kws):
def split_scan_bbqr(scan_a_qr, goto_home, need_keypress):
# take big data and send it via series of BBQr thru emulated scanner
def doit(raw_data, type_code, **kws):
goto_home()
need_keypress(KEY_QR)
# def split_qrs(raw, type_code, encoding=None,
# 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)
for p in parts:
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):
title, story = cap_story()

View File

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

View File

@ -4,14 +4,15 @@
#
# - you'll need v1.0.1 of bbqr library for this to work
#
import pytest, time, re
from helpers import prandom
import pytest, time, re, pdb
from helpers import prandom, xfp2str
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
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
@ -35,9 +36,9 @@ def rx_start(grab_payload, goto_home, pick_menu_item):
@pytest.fixture()
def grab_payload(press_select, need_keypress, press_cancel, nfc_read_url, cap_story, nfc_block4rf, cap_screen_qr):
def grab_payload(press_select, need_keypress, press_cancel, nfc_read_url, cap_story, nfc_block4rf, cap_screen_qr, readback_bbqr):
# start the Rx process, capturing numeric code
# 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'
@ -83,9 +84,15 @@ def grab_payload(press_select, need_keypress, press_cancel, nfc_read_url, cap_s
need_keypress(KEY_QR)
qr_data = cap_screen_qr().decode()
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')
filetype, qr_raw = join_qrs([qr_data])
assert filetype == tt_code
if nfc_raw: assert nfc_raw == qr_raw
@ -93,23 +100,29 @@ def grab_payload(press_select, need_keypress, press_cancel, nfc_read_url, cap_s
press_cancel()
press_cancel()
return code, qr_data
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):
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):
goto_home()
need_keypress(KEY_QR)
time.sleep(.250) # required
scan_a_qr(data)
if isinstance(data, tuple):
bbrq_type, raw = data
split_scan_bbqr(raw, bbrq_type, max_version=27)
else:
assert len(data) < 2000 # USB protocol limit
scan_a_qr(data)
time.sleep(.250) # required
if expect_fail: return
scr = cap_screen()
assert 'Teleport Password (text)' in scr
assert 'Teleport Password' in scr
enter_complex(pw)
time.sleep(.150) # required
@ -175,7 +188,7 @@ def test_tx_quick_note(rx_start, tx_start, cap_menu, enter_complex, pick_menu_it
enter_complex(msg)
time.sleep(.150) # required
pw, data = grab_payload('S')
pw, data, _ = grab_payload('S')
assert len(pw) == 8
# now, send that back
@ -219,7 +232,7 @@ def test_tx_master_send(rx_start, tx_start, cap_menu, enter_complex, pick_menu_i
press_select()
time.sleep(.150) # required?
pw, data = grab_payload('S')
pw, data, _ = grab_payload('S')
# now, send that back
rx_complete(data, pw)
@ -252,7 +265,7 @@ def test_tx_notes(qty, rx_start, tx_start, cap_menu, enter_complex, pick_menu_it
pick_menu_item('Export All Notes & Passwords')
time.sleep(.150) # required?
pw, data = grab_payload('S')
pw, data, _ = grab_payload('S')
# now, send that back
rx_complete(data, pw)
@ -299,7 +312,7 @@ def test_tx_seedvault(data, rx_start, tx_start, cap_menu, enter_complex, pick_me
pick_menu_item(mi)
time.sleep(.150) # required?
pw, data = grab_payload('S')
pw, data, _ = grab_payload('S')
settings_set("seeds", [])
@ -323,7 +336,7 @@ def test_tx_seedvault(data, rx_start, tx_start, cap_menu, enter_complex, pick_me
pick_menu_item('Restore Master')
press_select()
def test_rx_truncated(rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select):
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')
@ -342,7 +355,7 @@ def test_tx_wrong_pub(rx_start, tx_start, cap_menu, enter_complex, pick_menu_ite
press_select()
time.sleep(.150) # required?
pw, data = grab_payload('S')
pw, data, _ = grab_payload('S')
# now, send that back
rx_complete(data, pw, expect_fail=True)
@ -355,4 +368,83 @@ def test_tx_wrong_pub(rx_start, tx_start, cap_menu, enter_complex, pick_menu_ite
press_cancel()
@pytest.mark.unfinalized
@pytest.mark.parametrize('num_ins', [ 15 ])
@pytest.mark.parametrize('M', [2]) # [ 2, 4, 1])
@pytest.mark.parametrize('segwit', [True])
@pytest.mark.parametrize('incl_xpubs', [ False, True ])
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):
# 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
need_keypress('t')
time.sleep(.1)
# expect: a menu of xfp to pick from
m = cap_menu()
assert len(m) == N
assert 'YOU' in [ln for ln in m if xfp2str(my_xfp) in ln][0]
idx = 1
next_xfp = keys[1][0]
assert next_xfp != my_xfp
# choose one that isn't me
nm, = [mi for mi in m if xfp2str(next_xfp) in mi]
pick_menu_item(nm)
pw, data, qr_raw = grab_payload('E')
assert len(pw) == 8
open(f'debug/next_qr.txt', 'wt').write(f'{xfp2str(next_xfp)}\n\n{pw}\n\n{data}')
# switch personalities, and try to read that QR
new_xfp = select_wallet(idx)
assert new_xfp == next_xfp
# import and sign
rx_complete(('E', qr_raw), pw)
title, body = cap_story()
assert title == 'OK TO SEND?'
press_select()
time.sleep(.25)
title, body = cap_story()
assert title == 'Teleport PSBT?'
assert '2 more signatures' in body
pdb.set_trace()
# EOF