rework post-signing save process

This commit is contained in:
Peter D. Gray 2025-03-28 15:02:19 -04:00
parent 2723f93d7c
commit e8ae63922d
No known key found for this signature in database
GPG Key ID: A2DCD558C2BE5D7C
9 changed files with 252 additions and 197 deletions

View File

@ -513,7 +513,6 @@ class ApproveTransaction(UserAuthorizedAction):
return await self.failure("Signing failed late", exc)
if self.approved_cb:
kws = dict(psbt=self.psbt)
if self.is_sd and (ch == "b"):
self.cb_kws["slot_b"] = True
@ -801,8 +800,10 @@ async def done_signing(psbt, input_method=None, filename=None, force_vdisk=False
with SFFile(TXN_OUTPUT_OFFSET, max_size=MAX_TXN_LEN, message="Saving...") as psram:
if is_complete:
txid = psbt.finalize(psram)
noun = "Finalized TX ready for broadcast"
else:
psbt.serialize(psram)
noun = "Partly Signed PSBT"
txid = None
data_len = psram.tell()
@ -810,186 +811,216 @@ async def done_signing(psbt, input_method=None, filename=None, force_vdisk=False
UserAuthorizedAction.cleanup()
n = 0
ch = None
fname_input_method = input_method
first_time = True
if txid and await try_push_tx(data_len, txid, data_sha2):
n = 1 # go directly to reexport menu after pushTX
# go directly to reexport menu after pushTX
first_time = False
# for specific cases, key teleport is an option
offer_kt = False
if not is_complete and psbt.active_multisig and version.has_qwerty:
offer_kt = 'use Key Teleport to send PSBT to other co-signers'
while True:
base_msg = "Finalized TX ready for broadcast" if is_complete else "Partly Signed PSBT"
if n:
ch = await import_export_prompt(base_msg, no_qr=False if version.has_qr else True)
ch = None
if first_time:
# first time, assume they want to send out same way it came in -- dont prompt
if input_method == "qr":
ch = KEY_QR
elif input_method == "nfc":
ch = KEY_NFC
elif input_method == "kt":
# not reached .. because we offer KT method before this is called
ch = 't'
else:
# SD/VDisk
ch = {"force_vdisk": force_vdisk, "slot_b": slot_b}
if not ch:
# show all possible export options (based on hardware enabled, features)
ch = await import_export_prompt(noun, no_qr=not version.has_qr, offer_kt=offer_kt)
if ch == KEY_CANCEL:
break
elif ch == KEY_QR or input_method == "qr":
elif ch == KEY_QR:
here = PSRAM.read_at(TXN_OUTPUT_OFFSET, data_len)
hex_here = b2a_hex(here).upper()
msg = txid or 'Partly Signed PSBT'
try:
await show_qr_code(hex_here.decode(), is_alnum=True, msg=msg)
hex_here = b2a_hex(here).upper().decode()
await show_qr_code(hex_here, is_alnum=True, msg=msg)
except (ValueError, RuntimeError):
from ux_q1 import show_bbqr_codes
await show_bbqr_codes('T' if txid else 'P', hex_here, msg, already_hex=True)
await show_bbqr_codes('T' if txid else 'P', here, msg)
msg = base_msg + " shared via QR."
msg = noun + " shared via QR."
del here
elif ch == KEY_NFC or input_method == "nfc":
elif ch == KEY_NFC:
from glob import NFC
if is_complete:
await NFC.share_signed_txn(txid, TXN_OUTPUT_OFFSET, data_len, data_sha2)
else:
await NFC.share_psbt(TXN_OUTPUT_OFFSET, data_len, data_sha2)
msg = base_msg + " shared via NFC."
msg = noun + " shared via NFC."
elif ch == 't':
# after saving to card, they might want to teleport it
from teleport import kt_send_psbt
ok = await kt_send_psbt(psbt)
msg = 'Failed to Teleport' if not ok else 'Sent by Teleport'
else:
assert isinstance(ch, dict) or input_method is None # SD/VDisk
dis.fullscreen("Wait...")
if ch is None:
ch = {"force_vdisk": force_vdisk, "slot_b": slot_b}
if filename:
_, basename = filename.rsplit('/', 1)
base = basename.rsplit('.', 1)[0]
else:
base = fname_input_method
out2_fn = None
out_fn = None
from glob import settings
import os
del_after = settings.get('del', 0)
while 1:
# try to put back into same spot, but also do top-of-card
if not is_complete:
# keep the filename under control during multiple passes
target_fname = base.replace('-part', '') + '-part.psbt'
else:
# add -signed to end. We won't offer to sign again.
target_fname = base + '-signed.psbt'
try:
with CardSlot(readonly=True, **ch) as card:
out_full, out_fn = card.pick_filename(target_fname)
out_path = out_full.rsplit("/", 1)[0] + "/"
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
def _chunk_write(file_d, ofs, chunk=4096):
written = 0
while written < data_len:
if (written + chunk) > data_len:
chunk = data_len - written
file_d.write(PSRAM.read_at(ofs, chunk))
written += chunk
ofs += chunk
try:
with CardSlot(**ch) as card:
if is_complete and del_after:
# don't write signed PSBT if we'd just delete it anyway
out_fn = None
else:
with output_encoder(card.open(out_full, 'wb')) as fd:
# save as updated PSBT
if not is_complete:
_chunk_write(fd, TXN_OUTPUT_OFFSET)
else:
psbt.serialize(fd)
if is_complete:
# write out as hex too, if it's final
out2_full, out2_fn = card.pick_filename(
base + '-final.txn' if not del_after else 'tmp.txn',
out_path)
if out2_full:
with HexWriter(card.open(out2_full, 'w+t')) as fd:
# save transaction, in hex
if is_complete:
_chunk_write(fd, TXN_OUTPUT_OFFSET)
else:
txid = psbt.finalize(fd)
if del_after:
# rename it now that we know the txid
after_full, out2_fn = card.pick_filename(
txid + '.txn', out_path, overwrite=True)
os.rename(out2_full, after_full)
if del_after:
# this can do nothing if they swapped SDCard between steps, which is ok,
# but if the original file is still there, this blows it away.
# - if not yet final, the foo-part.psbt file stays
try:
card.securely_blank_file(filename)
except: pass
# 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
if force_vdisk:
await ux_show_story(prob, title='Error')
return
# prompt them to input another card?
ch = await ux_show_story(
prob + "Please insert an SDCard to receive signed transaction, "
"and press OK.", title="Need Card")
if ch == 'x':
await ux_aborted()
return
# done.
if out_fn:
msg = "Updated PSBT is:\n\n%s" % out_fn
if out2_fn:
msg += '\n\n'
else:
# del_after is probably set
msg = ''
if out2_fn:
msg += 'Finalized transaction (ready for broadcast):\n\n%s' % out2_fn
if txid and not del_after:
msg += '\n\nFinal TXID:\n' + txid
# typical case: save to SD card, show filenames we used
assert isinstance(ch, dict)
msg = await _save_to_disk(psbt, txid, ch, is_complete, data_len, output_encoder, filename)
if not msg:
# it failed so start again with all possible options enabled.
ch = None
input_method = None
first_time = False
continue
msg += '\n\n'
esc = '0'
msg += 'Press (0) to save again by another method.'
if not is_complete and psbt.active_multisig and version.has_qwerty:
# on Q, we can offer to "teleport" partly-signed file to othe other signers.
msg += 'Press (T) to use Key Teleport to send PSBT to other co-signers. '
esc += 't'
msg += 'Press (0) to re-export.'
ch = await ux_show_story(msg, title='PSBT Signed', escape=esc)
if ch == 't':
from teleport import kt_send_psbt
await kt_send_psbt(psbt)
elif ch != '0':
ch2 = await ux_show_story(msg, title='PSBT Signed', escape=esc)
if ch2 != '0':
break
else:
input_method = None
n += 1
first_time = False
async def _save_to_disk(psbt, txid, save_options, is_complete, data_len, output_encoder, filename=None):
# Saving a PSBT from PSRAM to something disk-like.
# - handle save-to-SD/VirtDisk cases. With re-attempt when no card, etc.
assert isinstance(save_options, dict) # from import_export_prompt
from glob import dis, settings, PSRAM
import os
dis.fullscreen("Wait...")
if filename:
_, basename = filename.rsplit('/', 1)
base = basename.rsplit('.', 1)[0]
else:
base = 'recent-txn'
# default encoding is binary
output_encoder = output_encoder or (lambda x:x)
out2_fn = None
out_fn = None
del_after = settings.get('del', 0)
def _chunk_write(file_d, ofs, chunk=4096):
written = 0
while written < data_len:
if (written + chunk) > data_len:
chunk = data_len - written
file_d.write(PSRAM.read_at(ofs, chunk))
written += chunk
ofs += chunk
while 1:
# try to put back into same spot, but also do top-of-card
if not is_complete:
# keep the filename under control during multiple passes
target_fname = base.replace('-part', '') + '-part.psbt'
else:
# add -signed to end. We won't offer to sign again.
target_fname = base + '-signed.psbt'
# attempt write-out
try:
with CardSlot(**save_options) as card:
out_full, out_fn = card.pick_filename(target_fname)
out_path = out_full.rsplit("/", 1)[0] + "/"
if is_complete and del_after:
# don't write signed PSBT if we'd just delete it anyway
out_fn = None
else:
with output_encoder(card.open(out_full, 'wb')) as fd:
# save as updated PSBT
if not is_complete:
_chunk_write(fd, TXN_OUTPUT_OFFSET)
else:
psbt.serialize(fd)
if is_complete:
# write out as hex too, if it's final
out2_full, out2_fn = card.pick_filename(
base + '-final.txn' if not del_after else 'tmp.txn',
out_path)
if out2_full:
with HexWriter(card.open(out2_full, 'w+t')) as fd:
# save transaction, in hex
if is_complete:
_chunk_write(fd, TXN_OUTPUT_OFFSET)
else:
txid = psbt.finalize(fd)
if del_after:
# rename it now that we know the txid
after_full, out2_fn = card.pick_filename(
txid + '.txn', out_path, overwrite=True)
os.rename(out2_full, after_full)
if del_after and filename:
# this can do nothing if they swapped SDCard between steps, which is ok,
# but if the original file is still there, this blows it away.
# - if not yet final, the foo-part.psbt file stays
try:
card.securely_blank_file(filename)
except: pass
# success and done!
break
except CardMissingError:
prob = 'Need a card!\n\n'
except OSError as exc:
prob = 'Failed to write!\n\n%s\n\n' % exc
sys.print_exception(exc)
# fall through to try again
# If this point reached, some problem, we could not write.
if save_options.get('force_vdisk'):
await ux_show_story(prob, title='Error')
# they can't fix here, so give up
return
# prompt them to input another card?
ch = await ux_show_story(
prob + "Please insert a card to receive signed transaction, "
"and press OK.", title="Need Card")
if ch == 'x':
return
# Done, show the filenames we used.
if out_fn:
msg = "Updated PSBT is:\n\n%s" % out_fn
if out2_fn:
msg += '\n\n'
else:
# del_after is probably set
msg = ''
if out2_fn:
msg += 'Finalized transaction (ready for broadcast):\n\n%s' % out2_fn
if txid and not del_after:
msg += '\n\nFinal TXID:\n' + txid
return msg
async def sign_psbt_file(filename, force_vdisk=False, slot_b=None, just_read=False, ux_abort=False):

View File

@ -601,8 +601,13 @@ async def kt_send_psbt(psbt, psbt_len=None, post_signing=False):
# - 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
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:
@ -681,6 +686,8 @@ async def kt_send_psbt(psbt, psbt_len=None, post_signing=False):
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,

View File

@ -392,7 +392,7 @@ def _import_prompt_builder(title, no_qr, no_nfc, slot_b_only=False):
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):
# Build the prompt for export
# - 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"
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
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"
escape += '4'
if offer_kt:
prompt += ", (T) to " + offer_kt
escape += 't'
if key0:
prompt += ', (0) ' + key0
escape += '0'
@ -471,19 +475,21 @@ def import_export_prompt_decode(ch):
return dict(force_vdisk=force_vdisk, slot_b=slot_b)
async def import_export_prompt(what_it_is, is_import=False, no_qr=False,
no_nfc=False, title=None, intro='', footnotes='',
slot_b_only=False, force_prompt=False):
no_nfc=False, title=None, intro='', footnotes='',
offer_kt=False, slot_b_only=False, force_prompt=False):
# Show story allowing user to select source for importing/exporting
# - return either str(mode) OR dict(file_args)
# - KEY_NFC or KEY_QR for those sources
# - KEY_CANCEL for abort by user
# - 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:
prompt, escape = _import_prompt_builder(what_it_is, no_qr, no_nfc, slot_b_only)
else:
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
# - assume that's what they want to do

View File

@ -809,6 +809,7 @@ def open_microsd(simulator, microsd_path):
# open a file from the simulated microsd
def doit(fn, mode='rb'):
assert fn, 'empty fname'
return open(microsd_path(fn), mode)
return doit
@ -928,7 +929,18 @@ def use_regtest(request, settings_set):
@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
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)
return simulator.master_fingerprint
yield doit
# Important cleanup: restore normal key, because other tests assume that
reset_seed_words()
return doit
@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
def doit():
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()
new_xfp = change_seed_words(simulator_fixed_words)
#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
@ -1290,16 +1291,18 @@ def try_sign_microsd(open_microsd, cap_story, pick_menu_item, goto_home,
else:
assert False, 'timed out'
txid = None
lines = story.split('\n')
txid = None
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]
txid = lines[lines.index('Final TXID:')+1]
# This is fragile!
# ignore "Press (T) to use Key Teleport to send PSBT to other co-signers" footer
# 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()
@ -2024,7 +2027,7 @@ def signing_artifacts_reexport(cap_story, need_keypress, load_export, press_canc
if txid:
assert txid in story
assert "Press (0) to re-export." in story
assert "Press (0) to save again" in story
need_keypress("0")
to_do = ["sd", "vdisk", "nfc", "qr"]

View File

@ -1,6 +1,7 @@
# (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
import stash, chains
from pincodes import pa
@ -11,7 +12,6 @@ from utils import xfp2str
from actions import goto_top_menu
from nvstore import SettingsObject
tn = chains.BitcoinTestnet
stash.bip39_passphrase = ''
@ -30,8 +30,9 @@ settings.set('words', len(main.WORDS))
settings.set('terms_ok', True)
settings.set('idle_to', 0)
print("New key in effect: %s" % settings.get('xpub', 'MISSING'))
print(".. w/ fingerprint: %s" % xfp2str(settings.get('xfp', 0)))
print("TESTING: New key in effect [%s]: %s..%s = %s" % (
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
goto_top_menu()

View File

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

View File

@ -274,7 +274,7 @@ def try_sign_nfc(cap_story, pick_menu_item, goto_home, need_keypress,
sim_exec('from pyb import SDCard; SDCard.ejected = False')
@pytest.fixture
def ndef_parse_txn_psbt():
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

View File

@ -1617,6 +1617,7 @@ def test_incomplete_signing(dev, try_sign, fake_txn, cap_story):
oo.serialize(fd)
mod_psbt = fd.getvalue()
raise pytest.xfail('issue #915 ')
with pytest.raises(CCProtoError) as ee:
orig, result = try_sign(mod_psbt, accept=True, finalize=True)

View File

@ -55,6 +55,7 @@ def grab_payload(press_select, need_keypress, press_cancel, nfc_read_url, cap_s
else:
press_select()
time.sleep(.1)
title, story = cap_story()
assert 'Teleport' in title
@ -349,6 +350,9 @@ def test_tx_seedvault(data, rx_start, tx_start, cap_menu, enter_complex, pick_me
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()