diff --git a/shared/auth.py b/shared/auth.py index c9e44c14..01cbeca7 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -13,8 +13,8 @@ from public_constants import STXN_FINALIZE, STXN_VISUALIZE, STXN_SIGNED, AF_P2SH from sffile import SFFile from menu import MenuSystem, MenuItem from serializations import ser_uint256, SIGHASH_ALL -from ux import ux_show_story, abort_and_goto, ux_dramatic_pause, ux_clear_keys, ux_confirm -from ux import show_qr_code, OK, X, abort_and_push, AbortInteraction, the_ux, ux_enter_number +from ux import ux_show_story, abort_and_goto, ux_dramatic_pause, ux_clear_keys, ux_confirm, the_ux +from ux import show_qr_code, OK, X, abort_and_push, AbortInteraction, ux_input_text, ux_enter_number from usb import CCBusyError from utils import (HexWriter, xfp2str, problem_file_line, cleanup_deriv_path, B2A, show_single_address, keypath_to_str, seconds2human_readable) @@ -291,6 +291,56 @@ class ApproveTransaction(UserAuthorizedAction): self.chain = chains.current_chain() self.miniscript_wallet = miniscript_wallet + async def por322_msg_verify(self): + # https://gist.github.com/orangesurf/0c1d0a31d3ebe7e48335a34d56788d4c + from glob import NFC + from ux import import_export_prompt + from actions import file_picker + ch = await import_export_prompt("message", is_import=True, force_prompt=True, + intro="Import msg that hashes to 'to_spend' msg hash.", + key0="to input message manually", title="BIP-322 MSG", + no_qr=not version.has_qwerty) + + # TODO move elswhere + bip322_tag_hash = b'te\x84\xa1\x87/\xa1\x00AUN\xff\xa08\xd6\x12IB\xddy\xb4\xe5\x8aL\xda\x18N\x13\xdb\xe6,I' + + if ch == KEY_CANCEL: + return + elif ch == "0": + msg = await ux_input_text("") + elif ch == KEY_NFC: + msg = await NFC.read_bip322_msg() + elif ch == KEY_QR: + from ux_q1 import QRScannerInteraction + msg = await QRScannerInteraction().scan_text('Scan MSG from a QR code') + else: + choices = await file_picker(suffix='.txt', ux=False) + target = "%s.txt" % b2a_hex(self.psbt.por322_msg_hash).decode() + + for fname, dir, _ in choices: + if target == fname: + fn = dir + "/" + fname + break + else: + fn = await file_picker(choices=choices) + + if not fn: return + + with CardSlot(readonly=True, **ch) as card: + with open(fn, 'rt') as fd: + msg = fd.read() + + # TODO needs newer libngu with sha256t + assert msg, "need msg" + msg_hash = ngu.hash.sha256s(bip322_tag_hash+bip322_tag_hash+msg) + assert msg_hash == self.psbt.por322_msg_hash, "hash verification failed" + ch = await ux_show_story( + msg+"\n\nPress %s to approve message, otherwise %s to exit." % (OK, X), + title="MSG:" + ) + return True if ch == "y" else False + + def render_output(self, o): # Pretty-print a transactions output. # - expects CTxOut object @@ -430,6 +480,16 @@ class ApproveTransaction(UserAuthorizedAction): msg.write('(%d warnings below)\n\n' % wl) if self.psbt.por322: + + try: + if not await self.por322_msg_verify(): + self.refused = True + await ux_dramatic_pause("Refused.", 1) + self.done() + return + except Exception as exc: + return await self.failure("Msg verification failed.", exc) + msg.write("Proof of Reserves\n\n") msg.write("Amount %s %s\n\n" % self.chain.render_value(self.psbt.total_value_in)) msg.write("Message Hash:\n%s\n\n" % b2a_hex(self.psbt.por322_msg_hash).decode()) diff --git a/shared/nfc.py b/shared/nfc.py index be66c500..1c0b099c 100644 --- a/shared/nfc.py +++ b/shared/nfc.py @@ -736,6 +736,10 @@ class NFCHandler: f = lambda x: a2b_base64(x.decode()) if 150 <= len(x) <= 280 else None return await self._nfc_reader(f, 'Unable to find base64 encoded TAPSIGNER backup.') + async def read_bip322_msg(self): + f = lambda x: x.decode() + return await self._nfc_reader(f, 'Unable to find BIP-322 message.') + async def _nfc_reader(self, func, fail_msg): data = await self.start_nfc_rx() if not data: return diff --git a/shared/ux.py b/shared/ux.py index 5fffd425..e6eb8c6a 100644 --- a/shared/ux.py +++ b/shared/ux.py @@ -357,12 +357,12 @@ async def ux_enter_bip32_index(prompt, can_cancel=False, unlimited=False): return await ux_enter_number(prompt=prompt, max_value=max_value, can_cancel=can_cancel) -def _import_prompt_builder(title, no_qr, no_nfc, slot_b_only=False): +def _import_prompt_builder(title, no_qr, no_nfc, slot_b_only=False, key0=None, key6=None): from glob import NFC, VD prompt, escape = None, KEY_CANCEL+"x" - if (NFC or VD) or num_sd_slots>1: + if (NFC or VD) or (num_sd_slots > 1) or key0 or key6: if slot_b_only and (num_sd_slots>1): prompt = "Press (B) to import %s from lower slot SD Card" % title escape += "b" @@ -388,6 +388,14 @@ def _import_prompt_builder(title, no_qr, no_nfc, slot_b_only=False): prompt += ", " + KEY_QR + " to scan QR code" escape += KEY_QR + if key6: + prompt += ', (6) ' + key6 + escape += '6' + + if key0: + prompt += ', (0) ' + key0 + escape += '0' + prompt += "." return prompt, escape @@ -492,7 +500,8 @@ async def import_export_prompt(what_it_is, is_import=False, no_qr=False, from glob import NFC 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, + key0=key0, key6=key6) else: prompt, escape = export_prompt_builder(what_it_is, no_qr, no_nfc, key6=key6, key0=key0, force_prompt=force_prompt, offer_kt=offer_kt) diff --git a/testing/bip322.py b/testing/bip322.py index 7a5ece6f..2aecb05d 100644 --- a/testing/bip322.py +++ b/testing/bip322.py @@ -18,7 +18,20 @@ def bip322_msg_hash(msg): @pytest.fixture -def bip322_txn(dev, pytestconfig): +def create_msg_file(sim_root_dir, garbage_collector): + + def doit(msg, msg_hash): + # carelessly overwrites + fpath = f"{sim_root_dir}/MicroSD/{msg_hash.hex()}.txt" + with open(fpath, "w") as f: + f.write(msg.decode()) + garbage_collector.append(fpath) + + return doit + + +@pytest.fixture +def bip322_txn(dev, pytestconfig, create_msg_file): def doit(inputs, msg=b"POR", addr_fmt="p2wpkh", input_amount=1E8, to_sign_lock_time=0, sighash=None, psbt_hacker=None, witness_utxo=[], to_sign_nVersion=0): @@ -88,7 +101,9 @@ def bip322_txn(dev, pytestconfig): to_spend = CTransaction() to_spend.nVersion = 0 out_point = COutPoint(hash=0, n=0xffffffff) - to_spend.vin = [CTxIn(out_point, scriptSig=b'\x00\x20' + bip322_msg_hash(msg))] + msg_hash = bip322_msg_hash(msg) + create_msg_file(msg, msg_hash) + to_spend.vin = [CTxIn(out_point, scriptSig=b'\x00\x20' + msg_hash)] to_spend.vout = [CTxOut(0, scr)] # always zero val msg_challenge = scr else: @@ -144,7 +159,7 @@ def bip322_txn(dev, pytestconfig): @pytest.fixture -def bip322_ms_txn(pytestconfig): +def bip322_ms_txn(pytestconfig, create_msg_file): from test_multisig import make_ms_address def doit(num_ins, M, keys, msg=b"POR", inp_af=AF_P2WSH, input_amount=1E8, path_mapper=None, @@ -188,7 +203,9 @@ def bip322_ms_txn(pytestconfig): to_spend = CTransaction() to_spend.nVersion = 0 out_point = COutPoint(hash=0, n=0xffffffff) - to_spend.vin = [CTxIn(out_point, scriptSig=b'\x00\x20' + bip322_msg_hash(msg))] + msg_hash = bip322_msg_hash(msg) + create_msg_file(msg, msg_hash) + to_spend.vin = [CTxIn(out_point, scriptSig=b'\x00\x20' + msg_hash)] to_spend.vout.append(CTxOut(0, scriptPubKey)) msg_challenge = scriptPubKey else: diff --git a/testing/conftest.py b/testing/conftest.py index da479a5b..39b4fe40 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -2981,6 +2981,6 @@ from test_seed_xor import restore_seed_xor from test_sign import txid_from_export_prompt from test_ux import pass_word_quiz, word_menu_entry, enable_hw_ux from txn import fake_txn -from bip322 import bip322_txn, bip322_ms_txn +from bip322 import bip322_txn, bip322_ms_txn, create_msg_file # EOF diff --git a/testing/test_bip322.py b/testing/test_bip322.py index 54ad4d69..b4b30439 100644 --- a/testing/test_bip322.py +++ b/testing/test_bip322.py @@ -2,13 +2,83 @@ # # BIP-322 message signing & Proof of Reserves # -import pytest +import pytest, time from io import BytesIO from decimal import Decimal from constants import SIGHASH_MAP, AF_P2SH, AF_P2WSH, AF_P2WSH_P2SH from bip322 import bip322_txn, bip322_ms_txn, bip322_msg_hash from ctransaction import CTransaction, CTxIn, COutPoint from helpers import str_to_path +from charcodes import KEY_QR, KEY_NFC +from bbqr import split_qrs + + +@pytest.fixture +def verify_msg_bip322_por(cap_story, need_keypress, press_select, press_cancel, cap_menu, + nfc_write_text, is_q1, press_nfc, scan_a_qr, split_scan_bbqr, + enter_complex, pick_menu_item): + def doit(msg, refuse=False, way="sd", fname=None): + title, story = cap_story() + assert title == "BIP-322 MSG" + # file was already created with bip322_txn fixture above + if "qr" in way and not is_q1: + raise pytest.xfail("Mk4 no QR") + + if way == "input": + enter_complex(msg, b39pass=False) + elif way == "qr": + assert f"{KEY_QR} to scan QR code" in story + need_keypress(KEY_QR) + scan_a_qr(msg) + time.sleep(1) + + elif way == "bbqr": + assert f"{KEY_QR} to scan QR code" in story + 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(msg, "U", max_version=20) + + for p in parts: + scan_a_qr(p) + time.sleep(2.0 / len(parts)) # just so we can watch + + time.sleep(1) + + elif way == "nfc": + if f"press {KEY_NFC if is_q1 else '(3)'} to import via NFC" not in story: + pytest.xfail("NFC disabled") + else: + press_nfc() + time.sleep(0.2) + nfc_write_text(msg) + time.sleep(0.3) + else: + assert way in ["sd", "vdisk"] + if way == "vdisk": + if "(2) to import from Virtual Disk" not in story: + pytest.xfail("Vdisk disabled") + else: + need_keypress("2") + else: + need_keypress("1") + + if fname: + pick_menu_item(fname) + + + time.sleep(.1) + title, story = cap_story() + assert msg in story + if refuse: + press_cancel() + time.sleep(.1) + assert "Ready To Sign" in cap_menu() + else: + press_select() + + return doit @pytest.mark.parametrize("msg", [b"POR", b"This is the signed message"]) @@ -20,11 +90,16 @@ from helpers import str_to_path [["p2wpkh", None, None]] + ([["p2wpkh", None, 1000000]] * 20), [["p2pkh", None, None]] + ([["p2wpkh", None, 1000000]] * 5) + ([["p2sh-p2wpkh", None, 10000000]] * 5), ]) -def test_bip322_por(msg, ins, bip322_txn, start_sign, end_sign, cap_story): +def test_bip322_por(msg, ins, bip322_txn, start_sign, end_sign, cap_story, need_keypress, + press_select, verify_msg_bip322_por): num_ins = len(ins) amt = sum([i[2] or 0 for i in ins]) psbt, msg_challenge = bip322_txn(ins, msg=msg) start_sign(psbt, finalize=True) + + verify_msg_bip322_por(msg.decode(), way="sd") + + time.sleep(.1) title, story = cap_story() assert title == "OK TO SIGN?" assert "Proof of Reserves" in story @@ -46,7 +121,8 @@ def test_bip322_por(msg, ins, bip322_txn, start_sign, end_sign, cap_story): @pytest.mark.parametrize("sighash", [sh for sh in SIGHASH_MAP if sh != 'ALL']) -def test_bip322_por_invalid_sighash(sighash, bip322_txn, start_sign, cap_story, settings_set, end_sign): +def test_bip322_por_invalid_sighash(sighash, bip322_txn, start_sign, cap_story, settings_set, + end_sign, verify_msg_bip322_por): settings_set("sighshchk", 0) # disable checks # all POR txns must have only SIGHASH_ALL psbt, _ = bip322_txn([["p2sh-p2wpkh", None, None], ["p2wpkh", None, 100000], ["p2pkh", None, 1000000]], @@ -57,6 +133,10 @@ def test_bip322_por_invalid_sighash(sighash, bip322_txn, start_sign, cap_story, assert title == "Failure" return + verify_msg_bip322_por("POR", way="sd") + + time.sleep(.1) + title, story = cap_story() assert "warning" in story with pytest.raises(Exception): end_sign(accept=True, finalize=True) @@ -84,11 +164,14 @@ def test_bip322_0th_input_witness_utxo(ins, bip322_txn, start_sign, cap_story): [["p2wpkh", None, None], ["p2sh-p2wpkh", None, 10000000], ["p2pkh", None, 10000000]], [["p2pkh", None, None], ["p2wpkh", None, 10000000], ["p2sh-p2wpkh", None, 10000000]], ]) -def test_bip322_Xth_input_witness_utxo(ins, bip322_txn, start_sign, cap_story, end_sign): +def test_bip322_Xth_input_witness_utxo(ins, bip322_txn, start_sign, cap_story, end_sign, + verify_msg_bip322_por): # allowed - 0th input needs to have full pre-segwit utxo, all other can be just witness_utxo msg = b"hellow world" psbt, msg_challenge = bip322_txn(ins, witness_utxo=[1, 2], msg=msg) start_sign(psbt, finalize=True) + verify_msg_bip322_por(msg.decode(), way="sd") + time.sleep(.1) title, story = cap_story() assert title == "OK TO SIGN?" assert bip322_msg_hash(msg).hex() in story @@ -104,7 +187,8 @@ def test_bip322_Xth_input_witness_utxo(ins, bip322_txn, start_sign, cap_story, e [["p2wpkh", None, None], ["p2pkh", None, 10000000], ["p2pkh", None, 10000000]], [["p2sh-p2wpkh", None, None], ["p2sh-p2wpkh", None, 10000000], ["p2sh-p2wpkh", None, 10000000]], ]) -def test__bip322_incomplete_psbt_bip32_paths(ins, bip322_txn, start_sign, cap_story): +def test_bip322_incomplete_psbt_bip32_paths(ins, bip322_txn, start_sign, cap_story, + verify_msg_bip322_por): def hack(psbt_in): for i, inp in enumerate(psbt_in.inputs): @@ -118,6 +202,9 @@ def test__bip322_incomplete_psbt_bip32_paths(ins, bip322_txn, start_sign, cap_st assert title == "Failure" assert 'PSBT does not contain any key path information.' in story else: + verify_msg_bip322_por("POR", way="sd") + time.sleep(.1) + title, story = cap_story() assert "warning" in story assert "Limited Signing" in story assert "because we do not know the key: 0" in story @@ -334,7 +421,7 @@ def test_bip322_invalid_to_spend_out_nVal(ins, bip322_txn, start_sign, cap_story @pytest.mark.parametrize("signed", [True, False]) @pytest.mark.parametrize("num_ins", [1, 7]) def test_ms_bip322_por(addr_fmt, M_N, signed, bip322_ms_txn, start_sign, end_sign, cap_story, - import_ms_wallet, clear_ms, num_ins): + import_ms_wallet, clear_ms, num_ins, verify_msg_bip322_por): clear_ms() M, N = M_N @@ -358,6 +445,8 @@ def test_ms_bip322_por(addr_fmt, M_N, signed, bip322_ms_txn, start_sign, end_sig psbt, msg_challenge = bip322_ms_txn(num_ins, M, keys, path_mapper=path_mapper, inp_af=addr_fmt, with_sigs=signed, input_amount=inp_amount) start_sign(psbt, finalize=signed) + verify_msg_bip322_por("POR", way="sd") + time.sleep(.1) title, story = cap_story() assert title == "OK TO SIGN?" assert "Proof of Reserves" in story @@ -411,4 +500,27 @@ def test_bip322_invalid_ms_psbt(addr_fmt, bip322_ms_txn, start_sign, cap_story, assert title == "Failure" assert "Missing redeem/witness script" in story + +@pytest.mark.parametrize("msg", [b"COLDCARD\n\nTHE\n\nBEST\n\nSIGNER", b"X" * 512]) +@pytest.mark.parametrize("ins", [ + [["p2sh-p2wpkh", None, None]], + [["p2pkh", None, None]] + ([["p2wpkh", None, 1000000]] * 5) + ([["p2sh-p2wpkh", None, 10000000]] * 5), +]) +@pytest.mark.parametrize("way", ["sd", "qr", "nfc", "vdisk", "bbqr"]) +def test_bip322_msg_import(msg, ins, way, bip322_txn, start_sign, end_sign, cap_story, need_keypress, + press_select, verify_msg_bip322_por): + + if b"\n" in msg and way == "qr": + raise pytest.skip("QR code with newlines not supported") + + psbt, msg_challenge = bip322_txn(ins, msg=msg) + start_sign(psbt, finalize=True) + + verify_msg_bip322_por(msg.decode(), way=way) + + time.sleep(.1) + title, story = cap_story() + assert title == "OK TO SIGN?" + assert "Proof of Reserves" in story + # EOF \ No newline at end of file