diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md index d7f3ee09..138e1551 100644 --- a/releases/Next-ChangeLog.md +++ b/releases/Next-ChangeLog.md @@ -5,12 +5,19 @@ This lists the new changes that have not yet been published in a normal release. # Shared Improvements - Both Mk4 and Q +- New Feature: JSON message signing. Use JSON object to pass data to sign in form `{"msg":"","subpath":"","addr_fmt": ""}` +- New Feature: Sign message from note text, or password note +- New Feature: Sign message with key resulting from positive ownership check. Press (0) + enter/scan message text +- New Feature: Sign message with key selected from Address Explorer Custom Path menu. Press (2) + enter/scan message text - Enhancement: Hide Secure Notes & Passwords in Deltamode. Wipe seed if notes menu accessed. - Enhancement: Hide Seed Vault in Deltamode. Wipe seed if Seed Vault menu accessed. - Enhancement: Add ability to switch between BIP-32 xpub, and obsolete SLIP-132 format in `Export XPUB` - Enhancement: Use the fact that master seed cannot be used as ephemeral seed, to show message about successful master seed verification. +- Change: If derivation path is omitted during message signing, default is used + based on address format (`m/44h/0h/0h/0/0` for p2pkh, and `m/84h/0h/0h/0/0` for p2wpkh). + Default is no longer root (m). - Bugfix: Sometimes see a struck screen after _Verifying..._ in boot up sequence. On Q, result is blank screen, on Mk4, result is three-dots screen. - Bugfix: Do not allow to enable/disable Seed Vault feature when in temporary seed mode. @@ -26,12 +33,6 @@ This lists the new changes that have not yet been published in a normal release. # Mk4 Specific Changes ## 5.4.1 - 2024-??-?? -- Change: If derivation path is omitted during message signing, default is used - based on address format (`m/44h/0h/0h/0/0` for p2pkh, and `m/84h/0h/0h/0/0` for p2wpkh). - Default is no longer root (m). - - -## 5.4.? - 2024-??-?? - Enhancement: Export single sig descriptor with simple QR. @@ -40,5 +41,8 @@ This lists the new changes that have not yet been published in a normal release. ## 1.3.1Q - 2024-??-?? +- New Feature: Verify Signed RFC messages via BBQr +- New Feature: Sign message from QR scan (format has to be JSON) +- Enhancement: Sign scanned Simple Text by pressing (0). Next screens query information about key to use. - Bugfix: Properly re-draw status bar after Restore Master on COLDCARD without master seed. diff --git a/shared/actions.py b/shared/actions.py index 4dd27364..63620402 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -4,12 +4,12 @@ # # Every function here is called directly by a menu item. They should all be async. # -import ckcc, pyb, version, uasyncio, sys, uos +import ckcc, pyb, version, uasyncio, sys, uos, chains from uhashlib import sha256 from uasyncio import sleep_ms from ubinascii import hexlify as b2a_hex from utils import imported, problem_file_line, get_filesize, encode_seed_qr -from utils import xfp2str, B2A, addr_fmt_label, txid_from_fname +from utils import xfp2str, B2A, txid_from_fname from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause, ux_aborted from ux import ux_enter_bip32_index, ux_input_text, import_export_prompt, OK, X from export import make_json_wallet, make_summary_file, make_descriptor_wallet_export @@ -1106,9 +1106,9 @@ async def electrum_skeleton(*a): return rv = [ - MenuItem(addr_fmt_label(af), f=electrum_skeleton_step2, + MenuItem(chains.addr_fmt_label(af), f=electrum_skeleton_step2, arg=(af, account_num)) - for af in [AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH] + for af in chains.SINGLESIG_AF ] the_ux.push(MenuSystem(rv)) @@ -1122,7 +1122,7 @@ def ss_descriptor_export_story(addition="", background="", acct=True): async def ss_descriptor_skeleton(_0, _1, item): # Export of descriptor data (wallet) int_ext, addition, f_pattern = None, "", "descriptor.txt" - allowed_af = [AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH] + allowed_af = chains.SINGLESIG_AF if item.arg: int_ext, allowed_af, ll, f_pattern = item.arg addition = " for " + ll @@ -1149,7 +1149,7 @@ async def ss_descriptor_skeleton(_0, _1, item): fname_pattern=f_pattern) else: rv = [ - MenuItem(addr_fmt_label(af), f=descriptor_skeleton_step2, + MenuItem(chains.addr_fmt_label(af), f=descriptor_skeleton_step2, arg=(af, account_num, int_ext, f_pattern)) for af in allowed_af ] @@ -1890,10 +1890,11 @@ async def sign_message_on_sd(*a): # min 1 line max 3 lines return 1 <= len(lines) <= 3 - fn = await file_picker(suffix='txt', min_size=2, max_size=500, taster=is_signable, - none_msg=('Must be one line of text, optionally ' + fn = await file_picker(suffix=['txt', "json"], min_size=2, max_size=500, taster=is_signable, + none_msg=('Must be txt file with one msg line, optionally ' 'followed by a subkey derivation path on a second line ' - 'and/or address format on third line.')) + 'and/or address format on third line. JSON msg signing ' + 'format also supported')) if not fn: return diff --git a/shared/address_explorer.py b/shared/address_explorer.py index 2bb52ef0..fe0044f0 100644 --- a/shared/address_explorer.py +++ b/shared/address_explorer.py @@ -8,14 +8,14 @@ import chains, stash, version from ux import ux_show_story, the_ux, ux_enter_bip32_index from ux import export_prompt_builder, import_export_prompt_decode from menu import MenuSystem, MenuItem -from public_constants import AFC_BECH32, AFC_BECH32M, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH +from public_constants import AFC_BECH32, AFC_BECH32M, AF_P2WPKH from multisig import MultisigWallet from uasyncio import sleep_ms from uhashlib import sha256 from ubinascii import hexlify as b2a_hex from glob import settings from auth import write_sig_file -from utils import addr_fmt_label, censor_address +from utils import censor_address from charcodes import KEY_QR, KEY_NFC, KEY_PAGE_UP, KEY_PAGE_DOWN, KEY_HOME, KEY_LEFT, KEY_RIGHT from charcodes import KEY_CANCEL @@ -112,9 +112,8 @@ class PickAddrFmtMenu(MenuSystem): def __init__(self, path, parent): self.parent = parent items = [ - MenuItem(addr_fmt_label(AF_CLASSIC), f=self.done, arg=(path, AF_CLASSIC)), - MenuItem(addr_fmt_label(AF_P2WPKH), f=self.done, arg=(path, AF_P2WPKH)), - MenuItem(addr_fmt_label(AF_P2WPKH_P2SH), f=self.done, arg=(path, AF_P2WPKH_P2SH)), + MenuItem(chains.addr_fmt_label(af), f=self.done, arg=(path, af)) + for af in chains.SINGLESIG_AF ] super().__init__(items) if path.startswith("m/84h"): @@ -198,7 +197,7 @@ class AddressListMenu(MenuSystem): indent = ' ↳ ' if version.has_qwerty else '↳' for i, (address, path, addr_fmt) in enumerate(choices): axi = address[-4:] # last 4 address characters - items.append(MenuItem(addr_fmt_label(addr_fmt), f=self.pick_single, + items.append(MenuItem(chains.addr_fmt_label(addr_fmt), f=self.pick_single, arg=(path, addr_fmt, axi))) items.append(MenuItem(indent+address, f=self.pick_single, arg=(path, addr_fmt, axi))) @@ -338,6 +337,9 @@ Press (3) if you really understand and accept these risks. msg += '\n\n' if n: msg += "Press RIGHT to see next group, LEFT to go back. X to quit." + else: + escape += "0" + msg += " Press (0) to sign message with this key." return msg, addrs, escape @@ -383,8 +385,15 @@ Press (3) if you really understand and accept these risks. continue - elif choice == '0' and allow_change: - change = 1 + elif choice == '0': + if allow_change: + change = 1 + else: + # only custom path sets allow_change to False + # msg sign + from auth import sign_with_own_address + await sign_with_own_address(path, addr_fmt) + elif n is None: # makes no sense to do any of below, showing just single address continue diff --git a/shared/auth.py b/shared/auth.py index fe0ae699..e6908103 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -13,12 +13,12 @@ from public_constants import AFC_SCRIPT, AF_CLASSIC, AFC_BECH32, AF_P2WPKH, AF_P from public_constants import STXN_FLAGS_MASK, STXN_FINALIZE, STXN_VISUALIZE, STXN_SIGNED from sffile import SFFile from ux import ux_aborted, ux_show_story, abort_and_goto, ux_dramatic_pause, ux_clear_keys -from ux import show_qr_code, OK, X +from ux import show_qr_code, OK, X, ux_input_text, ux_enter_bip32_index from usb import CCBusyError from utils import HexWriter, xfp2str, problem_file_line, cleanup_deriv_path -from utils import B2A, parse_addr_fmt_str, to_ascii_printable, parse_msg_sign_request +from utils import B2A, to_ascii_printable from psbt import psbtObject, FatalPSBTIssue, FraudulentChangeOutput -from files import CardSlot +from files import CardSlot, CardMissingError, needs_microsd from exceptions import HSMDenied from version import MAX_TXN_LEN from charcodes import KEY_QR, KEY_NFC, KEY_ENTER, KEY_CANCEL, KEY_LEFT, KEY_RIGHT @@ -282,14 +282,13 @@ def write_sig_file(content_list, derive=None, addr_fmt=AF_CLASSIC, pk=None, sig_ dis.progress_bar_show(i / 6) return sig_nice -def validate_text_for_signing(text): +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 - MSG_MAX_SPACES = 4 # impt. compared to -=- positioning - - result = to_ascii_printable(text) + result = to_ascii_printable(text, only_printable=only_printable) length = len(result) assert length >= 2, "msg too short (min. 2)" @@ -302,17 +301,55 @@ def validate_text_for_signing(text): # looks ok return result +def parse_msg_sign_request(data): + subpath = "" + addr_fmt = "p2pkh" + is_json = False + 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 = "p2pkh" + + 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 + + class ApproveMessageSign(UserAuthorizedAction): def __init__(self, text, subpath, addr_fmt, approved_cb=None, - msg_sign_request=None): + msg_sign_request=None, only_printable=True): super().__init__() - + is_json = False if msg_sign_request: - text, subpath, addr_fmt = parse_msg_sign_request(msg_sign_request) + text, subpath, addr_fmt, is_json = parse_msg_sign_request(msg_sign_request) - self.text = validate_text_for_signing(text) + self.text = validate_text_for_signing( + text, only_printable=not is_json and only_printable + ) self.subpath = cleanup_deriv_path(subpath) - self.addr_fmt = parse_addr_fmt_str(addr_fmt) + self.addr_fmt = chains.parse_addr_fmt_str(addr_fmt) self.approved_cb = approved_cb from glob import dis @@ -363,10 +400,158 @@ def sign_msg(text, subpath, addr_fmt): abort_and_goto(UserAuthorizedAction.active_request) +async def msg_sign_ux_get_subpath(addr_fmt): + 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) + + +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, msg_sign_ux_get_subpath + + 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 approve_msg_sign(text, subpath, addr_fmt, approved_cb=None, + msg_sign_request=None, kill_menu=False, + only_printable=True): + UserAuthorizedAction.cleanup() + UserAuthorizedAction.check_busy(ApproveMessageSign) + try: + UserAuthorizedAction.active_request = ApproveMessageSign( + text, subpath, addr_fmt, + approved_cb=approved_cb, + msg_sign_request=msg_sign_request, + only_printable=only_printable, + ) + if kill_menu: + abort_and_goto(UserAuthorizedAction.active_request) + else: + # do not kill the menu stack! just append + from ux import the_ux + the_ux.push(UserAuthorizedAction.active_request) + except (AssertionError, ValueError) as exc: + await ux_show_story("Problem: %s\n\nMessage to be signed must be a single line of ASCII text." % exc) + return + + +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_gen(addr=address, msg=text, sig=sig) + for i, part in enumerate(gen): + fd.write(part) + dis.progress_bar_show(i / 6) + + # 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') + + async def sign_txt_file(filename): # sign a one-line text file found on a MicroSD card # - not yet clear how to do address types other than 'classic' - from files import CardSlot, CardMissingError from ux import the_ux async def done(signature, address, text): @@ -376,59 +561,8 @@ async def sign_txt_file(filename): orig_path, basename = filename.rsplit('/', 1) orig_path += '/' base = basename.rsplit('.', 1)[0] - 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' - - for path in [orig_path, None]: - try: - with CardSlot(readonly=True) 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() as card: - with card.open(out_full, 'wt') as fd: - # save in full RFC style - # gen length is 6 - gen = rfc_signature_template_gen(addr=address, msg=text, sig=sig) - for i, part in enumerate(gen): - fd.write(part) - dis.progress_bar_show(i / 6) - - # 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') + await sd_sign_msg_done(signature, address, text, base, orig_path) UserAuthorizedAction.cleanup() UserAuthorizedAction.check_busy() @@ -438,16 +572,8 @@ async def sign_txt_file(filename): with card.open(filename, 'rt') as fd: res = fd.read() - try: - UserAuthorizedAction.active_request = ApproveMessageSign( - None, None, None, approved_cb=done, - msg_sign_request=res - ) - # do not kill the menu stack! - the_ux.push(UserAuthorizedAction.active_request) - except AssertionError as exc: - await ux_show_story("Problem: %s\n\nMessage to be signed must be a single line of ASCII text." % exc) - return + await approve_msg_sign(None, None, None, approved_cb=done, + msg_sign_request=res) def verify_signature(msg, addr, sig_str): warnings = "" @@ -564,7 +690,6 @@ async def verify_armored_signed_msg(contents, digest_check=True): 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): - from files import CardSlot, CardMissingError, needs_microsd # copy message into memory try: with CardSlot() as card: @@ -1074,7 +1199,6 @@ def psbt_encoding_taster(taste, psbt_len): async def sign_psbt_file(filename, force_vdisk=False, slot_b=None): # sign a PSBT file found on a MicroSD card # - or from VirtualDisk (mk4) - from files import CardSlot, CardMissingError from glob import dis from ux import the_ux diff --git a/shared/chains.py b/shared/chains.py index a1055bc6..a4367ca3 100644 --- a/shared/chains.py +++ b/shared/chains.py @@ -12,6 +12,9 @@ from serializations import hash160, ser_compact_size, disassemble from ucollections import namedtuple from opcodes import OP_RETURN, OP_1, OP_16 + +SINGLESIG_AF = (AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH) + # See SLIP 132 # for background on these version bytes. Not to be confused with SLIP-32 which involves Bech32. Slip132Version = namedtuple('Slip132Version', ('pub', 'priv', 'hint')) @@ -401,6 +404,47 @@ CommonDerivations = [ AF_P2WPKH ), # generates bc1 bech32 addresses ] +STD_DERIVATIONS = { + "p2pkh": CommonDerivations[0][1], + "p2sh-p2wpkh": CommonDerivations[1][1], + "p2wpkh-p2sh": CommonDerivations[1][1], + "p2wpkh": CommonDerivations[2][1], +} + +def parse_addr_fmt_str(addr_fmt): + # accepts strings and also integers if already parsed + try: + if isinstance(addr_fmt, int): + if addr_fmt in [AF_P2WPKH_P2SH, AF_P2WPKH, AF_CLASSIC]: + return addr_fmt + else: + raise ValueError + + addr_fmt = addr_fmt.lower() + if addr_fmt in ("p2sh-p2wpkh", "p2wpkh-p2sh"): + return AF_P2WPKH_P2SH + elif addr_fmt == "p2pkh": + return AF_CLASSIC + elif addr_fmt == "p2wpkh": + return AF_P2WPKH + else: + raise ValueError + except ValueError: + raise ValueError("Invalid address format: '%s'\n\n" + "Choose from p2pkh, p2wpkh, p2sh-p2wpkh." % addr_fmt) + + +def af_to_bip44_purpose(addr_fmt): + # single signature only + return {AF_CLASSIC: 44, + AF_P2WPKH_P2SH: 49, + AF_P2WPKH: 84}[addr_fmt] + + +def addr_fmt_label(addr_fmt): + return {AF_CLASSIC: "Classic P2PKH", + AF_P2WPKH_P2SH: "P2SH-Segwit", + AF_P2WPKH: "Segwit P2WPKH"}[addr_fmt] def verify_recover_pubkey(sig, digest): # verifies a message digest against a signature and recovers diff --git a/shared/decoders.py b/shared/decoders.py index 6903e37c..c7a6caf2 100644 --- a/shared/decoders.py +++ b/shared/decoders.py @@ -4,7 +4,7 @@ # # included in Q builds only, not Mk4 --> manifest_q1.py # -import ngu, bip39, ure, stash +import ngu, bip39, ure, stash, json from ubinascii import unhexlify as a2b_hex from exceptions import QRDecodeExplained from bbqr import TYPE_LABELS @@ -131,7 +131,11 @@ def decode_qr_result(got, expect_secret=False, expect_text=False, expect_bbqr=Fa pass elif ty == 'J': - return 'json', (got,) + what = "json" + if "msg" in got: + what = "smsg" + + return what, (got,) else: msg = TYPE_LABELS.get(ty, 'Unknown FileType') raise QRDecodeExplained("Sorry, %s not useful." % msg) @@ -159,6 +163,12 @@ def decode_qr_result(got, expect_secret=False, expect_text=False, expect_bbqr=Fa if expect_secret: raise QRDecodeExplained("Not a secret?") + try: + dct = json.loads(got) + if "msg" in dct: + return "smsg", (got,) + except: pass + # try to recognize various bitcoin-related text strings... return decode_short_text(got) @@ -178,6 +188,9 @@ def decode_short_text(got): # might be a PSBT? if len(got) > 100: + if got.lstrip().startswith("-----BEGIN BITCOIN SIGNED MESSAGE-----"): + return "vmsg", (got,) + from auth import psbt_encoding_taster try: decoder, _, psbt_len = psbt_encoding_taster(got[0:10].encode(), len(got)) diff --git a/shared/export.py b/shared/export.py index 52cc75f3..1df1c660 100644 --- a/shared/export.py +++ b/shared/export.py @@ -405,14 +405,7 @@ def generate_electrum_wallet(addr_type, account_num): xfp = settings.get('xfp') # Must get the derivation path, and the SLIP32 version bytes right! - if addr_type == AF_CLASSIC: - mode = 44 - elif addr_type == AF_P2WPKH: - mode = 84 - elif addr_type == AF_P2WPKH_P2SH: - mode = 49 - else: - raise ValueError(addr_type) + mode = chains.af_to_bip44_purpose(addr_type) OWNERSHIP.note_wallet_used(addr_type, account_num) @@ -508,14 +501,7 @@ async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int xfp = settings.get('xfp') dis.progress_bar_show(0.1) if mode is None: - if addr_type == AF_CLASSIC: - mode = 44 - elif addr_type == AF_P2WPKH: - mode = 84 - elif addr_type == AF_P2WPKH_P2SH: - mode = 49 - else: - raise ValueError(addr_type) + mode = chains.af_to_bip44_purpose(addr_type) OWNERSHIP.note_wallet_used(addr_type, account_num) diff --git a/shared/nfc.py b/shared/nfc.py index 3df377ef..0cf55ecc 100644 --- a/shared/nfc.py +++ b/shared/nfc.py @@ -7,7 +7,7 @@ # - has GPIO signal "??" which is multipurpose on its own pin # - this chip chosen because it can disable RF interaction # -import utime, ngu, ndef, stash +import utime, ngu, ndef, stash, chains from uasyncio import sleep_ms import uasyncio as asyncio from ustruct import pack, unpack @@ -15,7 +15,7 @@ from ubinascii import unhexlify as a2b_hex from ubinascii import b2a_base64, a2b_base64 from ux import ux_show_story, ux_wait_keydown, OK, X -from utils import B2A, problem_file_line, parse_addr_fmt_str, txid_from_fname +from utils import B2A, problem_file_line, txid_from_fname from public_constants import AF_CLASSIC from charcodes import KEY_ENTER, KEY_CANCEL @@ -727,7 +727,7 @@ class NFCHandler: else: subpath, addr_fmt_str = winner try: - addr_fmt = parse_addr_fmt_str(addr_fmt_str) + addr_fmt = chains.parse_addr_fmt_str(addr_fmt_str) except AssertionError as e: await ux_show_story(str(e)) return @@ -738,32 +738,21 @@ class NFCHandler: await the_ux.interact() # need this otherwise NFC animation takes over async def start_msg_sign(self): - from auth import UserAuthorizedAction, ApproveMessageSign - from ux import the_ux - - UserAuthorizedAction.cleanup() + from auth import approve_msg_sign def f(m): m = m.decode() split_msg = m.split("\n") if 1 <= len(split_msg) <= 3: - return split_msg + return m winner = await self._nfc_reader(f, 'Unable to find correctly formated message to sign.') - if not winner: return - UserAuthorizedAction.check_busy(ApproveMessageSign) - try: - UserAuthorizedAction.active_request = ApproveMessageSign( - None, None, None, approved_cb=self.msg_sign_done, - msg_sign_request=winner - ) - the_ux.push(UserAuthorizedAction.active_request) - except AssertionError as exc: - await ux_show_story("Problem: %s\n\nMessage to be signed must be a single line of ASCII text." % exc) - return + await approve_msg_sign(None, None, None, approved_cb=self.msg_sign_done, + msg_sign_request=winner) + async def msg_sign_done(self, signature, address, text): from auth import rfc_signature_template_gen diff --git a/shared/notes.py b/shared/notes.py index 14b9d2c6..2cb145f2 100644 --- a/shared/notes.py +++ b/shared/notes.py @@ -298,6 +298,15 @@ class NoteContentBase: # single export await start_export([self]) + async def sign_txt_msg(self, a, b, item): + from auth import ux_sign_msg, msg_signing_done + txt = item.arg + await ux_sign_msg(txt, approved_cb=msg_signing_done, kill_menu=False) + + def sign_misc_menu_item(self): + return MenuItem("Sign Note Text", f=self.sign_txt_msg, arg=self.misc) + + class PasswordContent(NoteContentBase): # "Passwords" have a few more fields and are more structured flds = ['title', 'user', 'password', 'site', 'misc' ] @@ -317,6 +326,7 @@ class PasswordContent(NoteContentBase): MenuItem('Edit Metadata', f=self.edit), MenuItem('Delete', f=self.delete), MenuItem('Change Password', f=self.change_pw), + self.sign_misc_menu_item(), ShortcutItem(KEY_QR, f=self.view_qr), ShortcutItem(KEY_NFC, f=self.share_nfc, arg='password'), ] @@ -446,6 +456,7 @@ class NoteContent(NoteContentBase): MenuItem('Edit', f=self.edit), MenuItem('Delete', f=self.delete), MenuItem('Export', f=self.export), + self.sign_misc_menu_item(), ShortcutItem(KEY_QR, f=self.view_qr), ShortcutItem(KEY_NFC, f=self.share_nfc, arg='misc'), ] diff --git a/shared/ownership.py b/shared/ownership.py index 6c811bc0..994a207c 100644 --- a/shared/ownership.py +++ b/shared/ownership.py @@ -247,7 +247,7 @@ class OwnershipCache: if af == addr_fmt and acct_num: w = MasterSingleSigWallet(addr_fmt, account_idx=acct_num) possibles.append(w) - except ValueError: pass # if not single sig address format + except (KeyError, ValueError): pass # if not single sig address format if not possibles: # can only happen w/ scripts; for single-signer we have things to check @@ -307,26 +307,43 @@ class OwnershipCache: # Provide a simple UX. Called functions do fullscreen, progress bar stuff. from ux import ux_show_story, show_qr_code from charcodes import KEY_QR + from multisig import MultisigWallet from public_constants import AFC_BECH32, AFC_BECH32M try: wallet, subpath = OWNERSHIP.search(addr) + is_ms = isinstance(wallet, MultisigWallet) + sp = wallet.render_path(*subpath) msg = addr msg += '\n\nFound in wallet:\n ' + wallet.name - msg += '\nDerivation path:\n ' + wallet.render_path(*subpath) - if version.has_qwerty: - esc = KEY_QR + msg += '\nDerivation path:\n ' + sp + if is_ms: + esc = "" else: - msg += '\n\nPress (1) for QR' - esc = '1' + esc = "0" + msg += "\n\nPress (0) to sign message with this key." + + if version.has_qwerty: + esc += KEY_QR + else: + msg += ' (1) for address QR' + esc += '1' while 1: ch = await ux_show_story(msg, title="Verified Address", - escape=esc, hint_icons=KEY_QR) - if ch != esc: break - await show_qr_code(addr, is_alnum=(wallet.addr_fmt & (AFC_BECH32 | AFC_BECH32M)), - msg=addr) + escape=esc, hint_icons=KEY_QR) + if ch in ("1"+KEY_QR): + await show_qr_code( + addr, + is_alnum=(wallet.addr_fmt & (AFC_BECH32 | AFC_BECH32M)), + msg=addr + ) + elif not is_ms and (ch == "0"): # only singlesig + from auth import sign_with_own_address + await sign_with_own_address(sp, wallet.addr_fmt) + else: + break except UnknownAddressExplained as exc: await ux_show_story(addr + '\n\n' + str(exc), title="Unknown Address") diff --git a/shared/utils.py b/shared/utils.py index a2761141..d4542b43 100644 --- a/shared/utils.py +++ b/shared/utils.py @@ -7,17 +7,9 @@ from ubinascii import unhexlify as a2b_hex from ubinascii import hexlify as b2a_hex from ubinascii import a2b_base64, b2a_base64 from uhashlib import sha256 -from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH B2A = lambda x: str(b2a_hex(x), 'ascii') -STD_DERIVATIONS = { - "p2pkh": "m/44h/{chain}h/0h/0/0", - "p2sh-p2wpkh": "m/49h/{chain}h/0h/0/0", - "p2wpkh-p2sh": "m/49h/{chain}h/0h/0/0", - "p2wpkh": "m/84h/{chain}h/0h/0/0", -} - try: from font_iosevka import FontIosevka DOUBLE_WIDE = FontIosevka.DOUBLE_WIDE @@ -212,16 +204,17 @@ def is_printable(s): return False return True -def to_ascii_printable(s, strip=False): +def to_ascii_printable(s, strip=False, only_printable=True): try: s = str(s, 'ascii') if strip: s = s.strip() assert is_ascii(s) - assert is_printable(s) + if only_printable: + assert is_printable(s) return s except: - raise AssertionError('must be ascii printable') + raise AssertionError("must be ascii" + (" printable" if only_printable else "")) def problem_file_line(exc): @@ -271,7 +264,7 @@ def cleanup_deriv_path(bin_path, allow_star=False): # regex for valid chars, m at start, maybe /*h or /* at end sometimes mat = ure.match(r"(m|m/|)[0-9/h]*" + ('' if not allow_star else r"(\*h|\*|)"), s) - assert mat.group(0) == s, "invalid characters" + assert mat.group(0) == s, "invalid characters in path" parts = s.split('/') @@ -512,24 +505,6 @@ def word_wrap(ln, w): yield left -def parse_addr_fmt_str(addr_fmt): - # accepts strings and also integers if already parsed - from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH - - if addr_fmt in [AF_P2WPKH_P2SH, AF_P2WPKH, AF_CLASSIC]: - return addr_fmt - - addr_fmt = addr_fmt.lower() - if addr_fmt in ("p2sh-p2wpkh", "p2wpkh-p2sh"): - return AF_P2WPKH_P2SH - elif addr_fmt == "p2pkh": - return AF_CLASSIC - elif addr_fmt == "p2wpkh": - return AF_P2WPKH - else: - raise ValueError("Invalid address format: '%s'\n\n" - "Choose from p2pkh, p2wpkh, p2sh-p2wpkh." % addr_fmt) - def parse_extended_key(ln, private=False): # read an xpub/ypub/etc and return BIP-32 node and what chain it's on. # - can handle any garbage line @@ -566,14 +541,6 @@ def chunk_writer(fd, body): dis.progress_bar_show(1) -def addr_fmt_label(addr_fmt): - return { - AF_CLASSIC: "Classic P2PKH", - AF_P2WPKH_P2SH: "P2SH-Segwit", - AF_P2WPKH: "Segwit P2WPKH" - }[addr_fmt] - - def pad_raw_secret(raw_sec_str): # Chip can hold 72-bytes as a secret # every secret has 0th byte as marker @@ -708,28 +675,4 @@ def decode_bip21_text(got): def encode_seed_qr(words): return ''.join('%04d' % bip39.get_word_index(w) for w in words) -def parse_msg_sign_request(data): - lines = data.split("\n") - assert len(lines) >= 1, "min 1 line" - assert len(lines) <= 3, "max 3 lines" - - subpath = "" - addr_fmt = "p2pkh" - 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 = "p2pkh" - - if not subpath: - subpath = STD_DERIVATIONS[addr_fmt] - subpath = subpath.format( - chain=chains.current_chain().b44_cointype - ) - - return text, subpath, addr_fmt - # EOF diff --git a/shared/ux_q1.py b/shared/ux_q1.py index 90025df8..166f9e11 100644 --- a/shared/ux_q1.py +++ b/shared/ux_q1.py @@ -2,7 +2,7 @@ # # ux_q1.py - UX/UI interactions that are Q1 specific and use big screen, keyboard. # -import utime, gc, ngu, sys +import utime, gc, ngu, sys, chains import uasyncio as asyncio from uasyncio import sleep_ms from charcodes import * @@ -12,7 +12,10 @@ import bip39 from decoders import decode_qr_result from ubinascii import hexlify as b2a_hex from ubinascii import unhexlify as a2b_hex +from ubinascii import b2a_base64 + from utils import problem_file_line +from public_constants import MSG_SIGNING_MAX_LENGTH from glob import numpad # may be None depending on import order, careful class PressRelease: @@ -951,6 +954,20 @@ class QRScannerInteraction: await ux_visualize_wif(wif_str, key_pair, compressed, testnet) return + if what == "vmsg": + data, = vals + from auth import verify_armored_signed_msg + await verify_armored_signed_msg(data) + return + + if what == "smsg": + data, = vals + from auth import approve_msg_sign, msg_signing_done + await approve_msg_sign(None, None, None, + msg_sign_request=data, kill_menu=True, + approved_cb=msg_signing_done) + return + if what == 'text' or what == 'xpub': # we couldn't really decode it. txt, = vals @@ -1107,15 +1124,49 @@ async def ux_visualize_wif(wif_str, kp, compressed, testnet): msg += "public key sec:\n" + b2a_hex(kp.pubkey().to_bytes(not compressed)).decode() + "\n\n" await ux_show_story(msg, title="WIF") -async def ux_visualize_textqr(txt, maxlen=200): +async def qr_msg_sign_done(signature, address, text): + from ux import ux_show_story + from auth import rfc_signature_template_gen + from export import export_by_qr + + sig = b2a_base64(signature).decode('ascii').strip() + while True: + ch = await ux_show_story("Press ENTER to export signature QR only, " + "(0) to export full RFC template, " + "CANCEL if done.", escape="0") + if ch == "x": break + if ch == "y": + await export_by_qr(sig, "Signature", "U") + if ch == "0": + armored_str = "".join(rfc_signature_template_gen(addr=address, msg=text, + sig=sig)) + await show_bbqr_codes("U", armored_str, "Armored MSG") + +async def qr_sign_msg(txt): + from auth import ux_sign_msg + 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): # Show simple text. Don't crash on huge things, but be # able to show a full xpub. from ux import ux_show_story - if len(txt) > maxlen: + + txt_len = len(txt) + escape = "0" + if txt_len > maxlen: + escape = None txt = txt[0:maxlen] + '...' - await ux_show_story("%s\n\nAbove is text that was scanned. " - "We can't do any more with it." % txt, title="Simple Text") + msg = "%s\n\nAbove is text that was scanned. " % txt + if escape: + 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) + if escape and (ch == "0"): + await qr_sign_msg(txt) + async def show_bbqr_codes(type_code, data, msg, already_hex=False): # Compress, encode and split data, then show it animated... diff --git a/shared/wallet.py b/shared/wallet.py index 016b8a32..1ef61152 100644 --- a/shared/wallet.py +++ b/shared/wallet.py @@ -4,7 +4,6 @@ # import chains from descriptor import Descriptor -from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH from stash import SensitiveValues MAX_BIP32_IDX = (2 ** 31) - 1 @@ -41,17 +40,9 @@ class MasterSingleSigWallet(WalletABC): # - path is optional, and then we use standard path for addr_fmt # - path can be overriden when we come here via address explorer - if addr_fmt == AF_P2WPKH: - n = 'Segwit P2WPKH' - prefix = path or 'm/84h/{coin_type}h/{account}h' - elif addr_fmt == AF_CLASSIC: - n = 'Classic P2PKH' - prefix = path or 'm/44h/{coin_type}h/{account}h' - elif addr_fmt == AF_P2WPKH_P2SH: - n = 'P2WPKH-in-P2SH' - prefix = path or 'm/49h/{coin_type}h/{account}h' - else: - raise ValueError(addr_fmt) + n = chains.addr_fmt_label(addr_fmt) + purpose = chains.af_to_bip44_purpose(addr_fmt) + prefix = path or 'm/%dh/{coin_type}h/{account}h' % purpose if chain_name: self.chain = chains.get_chain(chain_name) diff --git a/testing/conftest.py b/testing/conftest.py index 0fe5de78..2f195bd2 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -2258,6 +2258,7 @@ from test_drv_entro import derive_bip85_secret, activate_bip85_ephemeral from test_ephemeral import generate_ephemeral_words, import_ephemeral_xprv, goto_eph_seed_menu from test_ephemeral import ephemeral_seed_disabled_ui, restore_main_seed, confirm_tmp_seed from test_ephemeral import verify_ephemeral_secret_ui, get_identity_story, get_seed_value_ux, seed_vault_enable +from test_msg import verify_msg_sign_story, sign_msg_from_text, msg_sign_export, sign_msg_from_address from test_multisig import import_ms_wallet, make_multisig, offer_ms_import, fake_ms_txn from test_multisig import make_ms_address, clear_ms, make_myself_wallet, import_multisig from test_se2 import goto_trick_menu, clear_all_tricks, new_trick_pin, se2_gate, new_pin_confirmed diff --git a/testing/constants.py b/testing/constants.py index 2a619229..e509a614 100644 --- a/testing/constants.py +++ b/testing/constants.py @@ -41,6 +41,7 @@ addr_fmt_names = { AF_P2WSH: 'p2wsh', AF_P2WPKH_P2SH: 'p2wpkh-p2sh', AF_P2WSH_P2SH: 'p2wsh-p2sh', + AF_P2TR: "p2tr", } diff --git a/testing/msg.py b/testing/msg.py index 442052e7..e9594502 100644 --- a/testing/msg.py +++ b/testing/msg.py @@ -22,11 +22,12 @@ RFC_SIGNATURE_TEMPLATE = '''\ def parse_signed_message(msg): - msplit = msg.strip().split("\n") - assert msplit[0] == "-----BEGIN BITCOIN SIGNED MESSAGE-----" - assert msplit[2] == "-----BEGIN BITCOIN SIGNATURE-----" - assert msplit[5] == "-----END BITCOIN SIGNATURE-----" - return msplit[1], msplit[3], msplit[4] + msplit = msg.strip().rsplit("\n", 4) + assert msplit[0].startswith("-----BEGIN BITCOIN SIGNED MESSAGE-----\n") + msg = msplit[0].replace("-----BEGIN BITCOIN SIGNED MESSAGE-----\n", "") + assert msplit[1] == "-----BEGIN BITCOIN SIGNATURE-----" + assert msplit[4] == "-----END BITCOIN SIGNATURE-----" + return msg, msplit[2], msplit[3] def sig_hdr_base(addr_fmt): diff --git a/testing/test_address_explorer.py b/testing/test_address_explorer.py index 608f24b2..58876c7f 100644 --- a/testing/test_address_explorer.py +++ b/testing/test_address_explorer.py @@ -383,7 +383,8 @@ def test_custom_path(path_sidx, which_fmt, addr_vs_path, pick_menu_item, goto_ad need_keypress, cap_menu, parse_display_screen, validate_address, cap_screen_qr, qr_quality_check, nfc_read_text, get_setting, press_select, press_cancel, is_q1, press_nfc, cap_story, - generate_addresses_file, settings_set, set_addr_exp_start_idx): + generate_addresses_file, settings_set, set_addr_exp_start_idx, + sign_msg_from_address): path, start_idx = path_sidx settings_set('aei', True if start_idx else False) @@ -443,8 +444,8 @@ def test_custom_path(path_sidx, which_fmt, addr_vs_path, pick_menu_item, goto_ad time.sleep(.5) # .2 not enuf m = cap_menu() - assert m[0] == 'Classic P2PKH' - assert m[1] == 'Segwit P2WPKH' + assert m[1] == 'Classic P2PKH' + assert m[0] == 'Segwit P2WPKH' assert m[2] == 'P2SH-Segwit' fmts = { @@ -505,6 +506,16 @@ def test_custom_path(path_sidx, which_fmt, addr_vs_path, pick_menu_item, goto_ad f_path, f_addr = next(addr_gen) assert f_path == path assert f_addr == addr + press_select() # file written + + # msg sign + time.sleep(.1) + title, body = cap_story() + assert "Press (0) to sign message with this key" in body + need_keypress('0') + msg = "COLDCARD the rock solid HWW" + sign_msg_from_address(msg, addr, path, which_fmt, "sd", True) + press_cancel() else: n = 10 if (start_idx + n) > MAX_BIP32_IDX: diff --git a/testing/test_bbqr.py b/testing/test_bbqr.py index a4010b49..08ccc713 100644 --- a/testing/test_bbqr.py +++ b/testing/test_bbqr.py @@ -373,4 +373,26 @@ def test_psbt_static(file, goto_home, cap_story, scan_a_qr, press_select, assert res["complete"] is True assert rb.hex() == res["hex"] + +def test_verify_signed_msg(goto_home, need_keypress, scan_a_qr, cap_story): + goto_home() + need_keypress(KEY_QR) + + data = """\n\n\n \t \n-----BEGIN BITCOIN SIGNED MESSAGE----- +5b9e372262952ed399dcdd4f5f08458a6d2811f120cddcb4267099f68f60207c addresses.csv +-----BEGIN BITCOIN SIGNATURE----- +tb1qupyd58ndsh7lut0et0vtrq432jvu9jtdyws9n9 +KDOloGMDU3fv+Y3NRSe17SoO4uSKo9IUU2+baJ/pqaHZBuvmW6j5nnv/N4M5BCVawiUig/qzExZpFsA7ZKzlUmU= +-----END BITCOIN SIGNATURE-----\n\n\n\n""" + + actual_vers, parts = split_qrs(data, 'U', max_version=20) + + for p in parts: + scan_a_qr(p) + time.sleep(4.0 / len(parts)) # just so we can watch + + title, story = cap_story() + assert "Good signature by address" in story + + # EOF diff --git a/testing/test_decoders.py b/testing/test_decoders.py index 4faa3886..7cb73462 100644 --- a/testing/test_decoders.py +++ b/testing/test_decoders.py @@ -179,4 +179,25 @@ def test_wif(data, try_decode): assert compressed == tcompressed assert testnet == ttestnet +@pytest.mark.parametrize('data', [ + '{"msg": "coinkite"}', + '{"msg": "coink\n\n\tite", "subpath": "m/99h"}', + '{"msg": "coinkite", "subpath": "m/96420h", "addr_fmt": "p2wpkh"}', +]) +def test_json_msg_sign(data, try_decode): + ft, vals = try_decode(data) + assert ft == "smsg" + assert vals[0] == data + + +@pytest.mark.parametrize('data', [ + "-----BEGIN BITCOIN SIGNED MESSAGE-----\ncoinkite\n-----BEGIN BITCOIN SIGNATURE-----\nmtHSVByP9EYZmB26jASDdPVm19gvpecb5R\nH3c6imctVKRRYC1zOBAitdb/PuoQ9j0xaR6qKXH5dQECZH5OuvvE7aoL6j/WOaR/CFq/+SvIZPAzIhvQYBizBUc=\n-----END BITCOIN SIGNATURE-----", + "\n\n-----BEGIN BITCOIN SIGNED MESSAGE-----\ncoinkite\n-----BEGIN BITCOIN SIGNATURE-----\nmtHSVByP9EYZmB26jASDdPVm19gvpecb5R\nH3c6imctVKRRYC1zOBAitdb/PuoQ9j0xaR6qKXH5dQECZH5OuvvE7aoL6j/WOaR/CFq/+SvIZPAzIhvQYBizBUc=\n-----END BITCOIN SIGNATURE-----", + "\n\n\t-----BEGIN BITCOIN SIGNED MESSAGE-----\ncoinkite\n-----BEGIN BITCOIN SIGNATURE-----\nmtHSVByP9EYZmB26jASDdPVm19gvpecb5R\nH3c6imctVKRRYC1zOBAitdb/PuoQ9j0xaR6qKXH5dQECZH5OuvvE7aoL6j/WOaR/CFq/+SvIZPAzIhvQYBizBUc=\n-----END BITCOIN SIGNATURE-----", +]) +def test_json_msg_verify(data, try_decode): + ft, vals = try_decode(data) + assert ft == "vmsg" + assert vals[0] == data + # EOF diff --git a/testing/test_msg.py b/testing/test_msg.py index d6a3f058..1c4b1cb9 100644 --- a/testing/test_msg.py +++ b/testing/test_msg.py @@ -2,13 +2,14 @@ # # Message signing. # -import pytest, time, os, itertools, hashlib +import pytest, time, os, itertools, hashlib, json from bip32 import BIP32Node from msg import verify_message, RFC_SIGNATURE_TEMPLATE, sign_message, parse_signed_message from base64 import b64encode, b64decode from ckcc_protocol.protocol import CCProtocolPacker, CCProtoError, CCUserRefused from ckcc_protocol.constants import * from constants import addr_fmt_names, msg_sign_unmap_addr_fmt +from charcodes import KEY_QR, KEY_NFC def default_derivation_by_af(addr_fmt, testnet=True): @@ -68,6 +69,200 @@ def test_sign_msg_refused(dev, press_cancel): done = dev.send_recv(CCProtocolPacker.get_signed_msg(), timeout=None) +@pytest.fixture +def verify_msg_sign_story(): + def doit(story, msg, subpath=None, addr_fmt=None, testnet=True, addr=None): + assert story.startswith('Ok to sign this?') + assert msg in story + assert 'Using the key associated' in story + + if addr: + assert addr in story + + if not subpath: + assert 'm =>' not in story + subpath = default_derivation_by_af(addr_fmt or AF_CLASSIC, testnet) + else: + subpath = subpath.lower().replace("'", "h") + + assert ('%s =>' % subpath) in story + return subpath + + return doit + + +@pytest.fixture +def msg_sign_export(cap_story, press_nfc, nfc_read_text, press_select, press_cancel, + readback_bbqr, cap_screen_qr, need_keypress, microsd_path, + virtdisk_path, is_q1, OK): + def doit(way, qr_only=False): + time.sleep(.1) + title, story = cap_story() + + if way == "sd": + if "Press (1) to save Signed Msg" in story: + need_keypress("1") + + elif way == "nfc": + if f"press {KEY_NFC if is_q1 else '(3)'} to share via NFC" not in story: + pytest.xfail("NFC disabled") + else: + press_nfc() + time.sleep(0.2) + signed_msg = nfc_read_text() + time.sleep(0.3) + press_cancel() + time.sleep(.1) + title, story = cap_story() + assert f"Press {OK} to share again" in story + press_cancel() + + elif way == "qr": + if not is_q1: + pytest.xfail("QR disabled") + + if not qr_only: + need_keypress(KEY_QR) + + time.sleep(.1) + title, story = cap_story() + assert "Press ENTER to export signature QR only" in story + assert "(0) to export full RFC template" in story + press_select() + time.sleep(.1) + sig_only = cap_screen_qr().decode('ascii') + press_select() + time.sleep(.1) + need_keypress("0") + time.sleep(.1) + file_type, signed_msg = readback_bbqr() + signed_msg = signed_msg.decode() + assert file_type == "U" + assert sig_only in signed_msg + press_select() + press_cancel() + + else: + # virtual disk + if "press (2) to save to Virtual Disk" not in story: + pytest.xfail("Vdisk disabled") + else: + need_keypress("2") + + if way in ("sd", "vdisk"): + path_f = microsd_path if way == "sd" else virtdisk_path + time.sleep(.1) + title, story = cap_story() + fname = story.split("\n\n")[-1] + with open(path_f(fname), "r") as f: + signed_msg = f.read() + + return signed_msg + + return doit + + +@pytest.fixture +def sign_msg_from_text(pick_menu_item, enter_number, press_select, + cap_story, need_keypress, settings_set, is_q1, + addr_vs_path, bitcoind, msg_sign_export, + verify_msg_sign_story, OK): + # used when signing note/passwords misc content + # used after simple text QR scan + # expects to start at menu which offers different single sig address formats + + def doit(msg, addr_fmt, acct, change, idx, way, chain="XTN", qr_only=False): + settings_set("chain", chain) + path = "m" + # pick address format from menu + if addr_fmt == AF_CLASSIC: + path += "/44h" + af_label = "Classic P2PKH" + elif addr_fmt == AF_P2WPKH: + path += "/84h" + af_label = "Segwit P2WPKH" + else: + path += "/49h" + af_label = "P2SH-Segwit" + + pick_menu_item(af_label) + + # chain - no user input - depends on current active settings + if chain == "BTC": + path += "/0h" + else: + path += "/1h" + + # pick account + if acct is None: + path += "/0h" + press_select() + else: + path += ("/%dh" % acct) + enter_number(acct) + + time.sleep(.1) + title, story = cap_story() + assert title == "Change?" + assert "Press (0) to use internal/change address" in story + assert f"{OK} to use external/receive address" in story + if change: + path += "/1" + need_keypress("0") + else: + path += "/0" + press_select() + + # index num + if idx is None: + path += "/0" + press_select() + else: + path += ("/%d" % idx) + enter_number(idx) + + time.sleep(.1) + title, story = cap_story() + path = verify_msg_sign_story(story, msg, path, addr_fmt, testnet=True if chain == "XTN" else False) + press_select() + + signed_msg = msg_sign_export(way, qr_only) + + ret_msg, addr, sig = parse_signed_message(signed_msg) + addr_vs_path(addr, path, addr_fmt, testnet=True if chain == "XTN" else False) + assert verify_message(addr, sig, ret_msg) is True + if addr_fmt == AF_CLASSIC and chain == "XTN": + res = bitcoind.rpc.verifymessage(addr, sig, ret_msg) + assert res is True + + return doit + + +@pytest.fixture +def sign_msg_from_address(need_keypress, scan_a_qr, press_select, enter_complex, cap_story, + addr_vs_path, verify_msg_sign_story, msg_sign_export): + def doit(msg, addr, subpath, addr_fmt, way=None, testnet=True): + if way == 'qr': + # scan text via QR + need_keypress(KEY_QR) + scan_a_qr(msg) + time.sleep(1) + press_select() + else: + enter_complex(msg, b39pass=False) + + time.sleep(.1) + title, story = cap_story() + verify_msg_sign_story(story, msg, subpath, addr_fmt, testnet, addr) + press_select() + time.sleep(.1) + signed_msg = msg_sign_export(way) + ret_msg, addr, sig = parse_signed_message(signed_msg) + addr_vs_path(addr, subpath, addr_fmt, testnet=testnet) + + return doit + + @pytest.mark.parametrize('path,expect', [ ('1/1hard/2', 'invalid characters'), ('m/m/m/1/1hard/2', 'invalid characters'), @@ -92,24 +287,36 @@ def test_bad_paths(dev, path, expect): @pytest.fixture def sign_on_microsd(open_microsd, cap_story, pick_menu_item, goto_home, - press_select, microsd_path): + press_select, microsd_path, verify_msg_sign_story): # sign a file on the microSD card - def doit(msg, subpath="", addr_fmt=None, expect_fail=False, testnet=True): - fname = 't-msgsign.txt' + def doit(msg, subpath="", addr_fmt=None, expect_fail=False, testnet=True, + use_json=False): + + suffix = "json" if use_json else "txt" + fname = f't-msgsign.{suffix}' result_fname = 't-msgsign-signed.txt' # cleanup try: os.unlink(microsd_path(result_fname)) except OSError: pass + with open_microsd(fname, 'wt') as sd: - sd.write(msg + '\n') - if subpath or addr_fmt: - sd.write((subpath or "") + '\n') + if use_json: + res = {"msg": msg} + if subpath: + res["subpath"] = subpath if addr_fmt is not None: - sd.write(addr_fmt_names[addr_fmt]) + res["addr_fmt"] = addr_fmt_names[addr_fmt] + sd.write(json.dumps(res)) + else: + sd.write(msg + '\n') + if subpath or addr_fmt: + sd.write((subpath or "") + '\n') + if addr_fmt is not None: + sd.write(addr_fmt_names[addr_fmt]) goto_home() pick_menu_item('Advanced/Tools') @@ -129,19 +336,8 @@ def sign_on_microsd(open_microsd, cap_story, pick_menu_item, goto_home, assert not story.startswith('Ok to sign this?') return story - assert story.startswith('Ok to sign this?') - - assert msg in story - assert 'Using the key associated' in story - if not subpath: - assert 'm =>' not in story - pth = default_derivation_by_af(addr_fmt or AF_CLASSIC, testnet) - assert pth in story - else: - x_subpath = subpath.lower().replace("'", "h") - assert ('%s =>' % x_subpath) in story - - press_select() + verify_msg_sign_story(story, msg, subpath, addr_fmt, testnet) + press_select() # confirm msg sign # wait for it to finish for r in range(10): @@ -151,27 +347,23 @@ def sign_on_microsd(open_microsd, cap_story, pick_menu_item, goto_home, else: assert False, 'timed out' - lines = [i.strip() for i in open_microsd(result_fname, 'rt').readlines()] + with open_microsd(result_fname, 'rt') as f: + res = f.read() - assert lines[0] == '-----BEGIN BITCOIN SIGNED MESSAGE-----' - assert lines[1:-4] == [msg] - assert lines[-4] == '-----BEGIN BITCOIN SIGNATURE-----' - addr = lines[-3] - sig = lines[-2] - assert lines[-1] == '-----END BITCOIN SIGNATURE-----' - - return sig, addr + ret_msg, addr, sig = parse_signed_message(res) + assert ret_msg == msg + return sig, addr, msg return doit @pytest.mark.bitcoind # only for testnet and p2pkh -@pytest.mark.parametrize('msg', [ 'ab', 'hello', 'abc def eght', "x"*140, 'a'*240]) +@pytest.mark.parametrize("use_json", [True, False]) +@pytest.mark.parametrize('msg', [ 'ab', 'abc def eght', "x"*140, 'a'*240]) @pytest.mark.parametrize('path', [ "m/84'/0'/22'", None, 'm', "m/1/2", - "m/1'/100'", 'm/23h/22h', ]) @pytest.mark.parametrize('addr_fmt', [ @@ -182,11 +374,14 @@ def sign_on_microsd(open_microsd, cap_story, pick_menu_item, goto_home, ]) @pytest.mark.parametrize("testnet", [True, False]) def test_sign_msg_microsd_good(sign_on_microsd, msg, path, addr_vs_path, - addr_fmt, testnet, settings_set, bitcoind): + addr_fmt, testnet, settings_set, bitcoind, + use_json): settings_set("chain", "XTN" if testnet else "BTC") # cases we expect to work - sig, addr = sign_on_microsd(msg, path, addr_fmt, testnet=testnet) + sig, addr, ret_msg = sign_on_microsd(msg, path, addr_fmt, testnet=testnet, + use_json=use_json) + assert msg == ret_msg raw = b64decode(sig) assert 40 <= len(raw) <= 65 @@ -201,13 +396,29 @@ def test_sign_msg_microsd_good(sign_on_microsd, msg, path, addr_vs_path, addr_vs_path(addr, path, addr_fmt, testnet=testnet) assert verify_message(addr, sig, msg) is True if addr_fmt == AF_CLASSIC and testnet: - res = bitcoind.rpc.verifymessage(addr, sig, msg) + res = bitcoind.rpc.verifymessage(addr, sig, ret_msg) assert res is True @pytest.fixture -def sign_using_nfc(goto_home, pick_menu_item, nfc_write_text, cap_story): - def doit(body, expect_fail=True): +def sign_using_nfc(goto_home, pick_menu_item, nfc_write_text, cap_story, press_select, + nfc_read_text, addr_vs_path, press_cancel, OK, verify_msg_sign_story): + def doit(msg, subpath=None, addr_fmt=None, expect_fail=False, use_json=False, + testnet=True): + if use_json: + res = {"msg": msg} + if subpath: + res["subpath"] = subpath + if addr_fmt is not None: + res["addr_fmt"] = addr_fmt_names[addr_fmt] + body = json.dumps(res) + else: + body = msg + "\n" + if subpath or addr_fmt: + body += ((subpath or "") + '\n') + if addr_fmt is not None: + body += addr_fmt_names[addr_fmt] + goto_home() pick_menu_item('Advanced/Tools') pick_menu_item('NFC Tools') @@ -216,49 +427,115 @@ def sign_using_nfc(goto_home, pick_menu_item, nfc_write_text, cap_story): time.sleep(0.5) if expect_fail: return cap_story() - raise NotImplementedError + + if not addr_fmt: + addr_fmt = AF_CLASSIC + + if not subpath: + subpath = default_derivation_by_af(addr_fmt, testnet=testnet) + + _, story = cap_story() + subpath = verify_msg_sign_story(story, msg, subpath, addr_fmt, testnet) + press_select() + signed_msg = nfc_read_text() + if "BITCOIN SIGNED MESSAGE" not in signed_msg: + # missed it? again + signed_msg = nfc_read_text() + press_select() # exit NFC animation + pmsg, addr, sig = parse_signed_message(signed_msg) + assert pmsg == msg + addr_vs_path(addr, subpath, addr_fmt, testnet=testnet) + assert verify_message(addr, sig, msg) is True + time.sleep(0.5) + _, story = cap_story() + assert f"Press {OK} to share again" in story + press_select() + signed_msg_again = nfc_read_text() + assert signed_msg == signed_msg_again + press_cancel() # exit NFC animation + press_cancel() # do not want to share again + + return sig, addr, msg return doit -@pytest.mark.parametrize('msg,concern,no_file', [ - ('', 'too short', 0), # zero length not supported - ('a'*1000, 'too long', 1), # too big, won't even be offered as a file - ('a'*300, 'too long', 0), # too big - ('a'*241, 'too long', 0), # too big - ('hello%20sworld'%'', 'many spaces', 0), # spaces - ('hello%10sworld'%'', 'many spaces', 0), # spaces - ('hello%5sworld'%'', 'many spaces', 0), # spaces - ('test\ttest', "must be ascii printable", 0), - ('testêtest', "must be ascii printable", 0), -]) -@pytest.mark.parametrize('transport', ['sd', 'usb', 'nfc']) -def test_sign_msg_fails(dev, sign_on_microsd, msg, concern, no_file, - transport, sign_using_nfc, path='m/12/34'): +@pytest.mark.bitcoind +@pytest.mark.parametrize("way", ["nfc", "sd"]) +@pytest.mark.parametrize("msg", ['test\ttest', "\n\n\tmsg\n\n\tsigning"]) +def test_sign_msg_with_ascii_non_printable_chars(msg, way, sign_on_microsd, addr_vs_path, + settings_set, bitcoind, sign_using_nfc): + # only works with the JSON format + settings_set("chain", "XTN") + if way == "sd": + sig, addr, ret_msg = sign_on_microsd(msg, "", None, use_json=True) + else: + sig, addr, ret_msg = sign_using_nfc(msg, "", None, use_json=True) + + assert ret_msg == msg + raw = b64decode(sig) + assert 40 <= len(raw) <= 65 + + addr_fmt = AF_CLASSIC + path = default_derivation_by_af(addr_fmt, testnet=True) + + # check expected addr was used + addr_vs_path(addr, path, addr_fmt) + assert verify_message(addr, sig, msg) is True + res = bitcoind.rpc.verifymessage(addr, sig, msg) + assert res is True + + +@pytest.mark.parametrize('msg,subpath,addr_fmt,concern,no_file,no_json', [ + ('', "m", AF_CLASSIC, 'too short', 0, 0), # zero length not supported + ('a'*1000, "m/1", AF_P2WPKH,'too long', 1, 0), # too big, won't even be offered as a file + ('a'*241, "m/400", AF_P2WPKH_P2SH, 'too long', 0, 0), # too big + ('hello%20sworld'%'', "m", AF_CLASSIC, 'many spaces', 0, 0), # spaces + ('hello%10sworld'%'', "m/1h/3h", AF_P2WPKH_P2SH, 'many spaces', 0, 0), # spaces + ('hello%5sworld'%'', "m", AF_CLASSIC, 'many spaces', 0, 0), # spaces + ("coinkite", "m", AF_P2WSH, "Invalid address format", 0, 0), # invalid address format + ("coinkite", "m", AF_P2WSH_P2SH, "Invalid address format", 0, 0), # invalid address format + ("coinkite", " m", AF_P2TR, "Invalid address format", 0, 0), # invalid address format + ("coinkite", "m/0/0/0/0/0/0/0/0/0/0/0/0/0", AF_CLASSIC, "too deep", 0, 0), # invalid path + ("coinkite", "m/0/0/0/0/0/q/0/0/0", AF_P2WPKH, "invalid characters in path", 0, 0), # invalid path + ("coinkite ", "m", AF_CLASSIC, "trailing space(s)", 0, 0), # invalid msg - trailing space + (" coinkite", "m", AF_P2WPKH_P2SH, "leading space(s)", 0, 0), # invalid msg - leading space + ('testêtest', "m", AF_P2WPKH, "must be ascii", 0, 0), + # below works only with the JSON format + ('test\ttest', "m", AF_CLASSIC, "must be ascii printable", 0, 1), +]) +@pytest.mark.parametrize("use_json", [True, False]) +@pytest.mark.parametrize('transport', ['sd', 'usb', 'nfc']) +def test_sign_msg_fails(dev, sign_on_microsd, msg, subpath, addr_fmt, concern, + no_file, no_json, transport, sign_using_nfc, use_json): + if use_json and no_json: + # special cases with ascii non printable characters - can be present in json + raise pytest.skip("json can contain ASCII non-printable in msg") if transport == 'usb': with pytest.raises(CCProtoError) as ee: try: encoded_msg = msg.encode('ascii') except UnicodeEncodeError: encoded_msg = msg.encode() - dev.send_recv(CCProtocolPacker.sign_message(encoded_msg, path), timeout=None) + dev.send_recv(CCProtocolPacker.sign_message(encoded_msg, subpath, addr_fmt), timeout=None) story = ee.value.args[0] elif transport == 'sd': try: - story = sign_on_microsd(msg, path, expect_fail=True) + story = sign_on_microsd(msg, subpath, addr_fmt, expect_fail=True, use_json=use_json) assert story.startswith('Problem: ') except AssertionError as e: if no_file: assert ("No suitable files found" in str(e)) or story == 'NO-FILE' return elif transport == 'nfc': - title, story = sign_using_nfc(msg+"\n"+path, expect_fail=True) + title, story = sign_using_nfc(msg, subpath, addr_fmt, expect_fail=True, use_json=use_json) assert title == 'ERROR' or "Problem" in story else: raise ValueError(transport) assert concern in story + @pytest.mark.parametrize('msg,num_iter,expect', [ ('Test2', 1, 'IHra0jSywF1TjIJ5uf7IDECae438cr4o3VmG6Ri7hYlDL+pUEXyUfwLwpiAfUQVqQFLgs6OaX0KsoydpuwRI71o='), ('Test', 2, 'IDgMx1ljPhLHlKUOwnO/jBIgK+K8n8mvDUDROzTgU8gOaPDMs+eYXJpNXXINUx5WpeV605p5uO6B3TzBVcvs478='), @@ -303,36 +580,15 @@ def test_low_R_cases(msg, num_iter, expect, dev, set_seed_words, use_mainnet, assert sig == expect -@pytest.mark.parametrize("body", [ - "coinkite\nm\np2wsh", # invalid address format - "coinkite\nm\np2sh-p2wsh", # invalid address format - "coinkite\nm\np2tr", # invalid address format - "coinkite\nm/0/0/0/0/0/0/0/0/0/0/0/0/0\np2pkh", # invalid path - "coinkite\nm/0/0/0/0/0/q/0/0/0\np2pkh", # invalid path - "coinkite yes!\nm\np2pkh", # invalid msg - too many spaces - "c\nm\np2pkh", # invalid msg - too short - "coinkite \nm\np2pkh", # invalid msg - trailing space - " coinkite\nm\np2pkh", # invalid msg - leading space -]) -def test_nfc_msg_signing_invalid(body, goto_home, pick_menu_item, nfc_write_text, cap_story): - goto_home() - pick_menu_item('Advanced/Tools') - pick_menu_item('NFC Tools') - pick_menu_item('Sign Message') - nfc_write_text(body) - time.sleep(0.5) - title, story = cap_story() - assert title == 'ERROR' or "Problem" in story - @pytest.mark.bitcoind # only for testnet and p2pkh @pytest.mark.parametrize("testnet", [True, False]) -@pytest.mark.parametrize("msg", ["coinkite", "Coldcard Signing Device!", 200 * "a"]) -@pytest.mark.parametrize("path", ["", "m/84'/0'/0'/300/0", "m/800h/0h", "m/0/0/0/0/1/1/1"]) -@pytest.mark.parametrize("str_addr_fmt", ["p2pkh", "", "p2wpkh", "p2wpkh-p2sh", "p2sh-p2wpkh"]) -def test_nfc_msg_signing(msg, path, str_addr_fmt, nfc_write_text, nfc_read_text, pick_menu_item, - goto_home, cap_story, press_select, press_cancel, addr_vs_path, OK, - testnet, settings_set, bitcoind): +@pytest.mark.parametrize("use_json", [True, False]) +@pytest.mark.parametrize("msg", ["Coldcard Signing Device!", 200 * "a"]) +@pytest.mark.parametrize("path", ["", "m/84'/0'/0'/300/0", "m/0/0/0/0/1/1/1"]) +@pytest.mark.parametrize("addr_fmt", [AF_CLASSIC, None, AF_P2WPKH, AF_P2WPKH_P2SH]) +def test_nfc_msg_signing(msg, path, addr_fmt, testnet, settings_set, bitcoind, use_json, + sign_using_nfc, goto_home): settings_set("chain", "XTN" if testnet else "BTC") for _ in range(5): @@ -343,45 +599,10 @@ def test_nfc_msg_signing(msg, path, str_addr_fmt, nfc_write_text, nfc_read_text, except: time.sleep(0.5) - pick_menu_item('Advanced/Tools') - pick_menu_item('NFC Tools') - pick_menu_item('Sign Message') - if str_addr_fmt != "": - addr_fmt = msg_sign_unmap_addr_fmt[str_addr_fmt] - body = "\n".join([msg, path, str_addr_fmt]) - else: - addr_fmt = AF_CLASSIC - body = "\n".join([msg, path]) - - if not path: - path = default_derivation_by_af(addr_fmt, testnet=testnet) - - nfc_write_text(body) - time.sleep(0.5) - _, story = cap_story() - assert "Ok to sign this?" in story - assert msg in story - assert path.replace("'", "h") in story - press_select() - signed_msg = nfc_read_text() - if "BITCOIN SIGNED MESSAGE" not in signed_msg: - # missed it? again - signed_msg = nfc_read_text() - press_select() # exit NFC animation - pmsg, addr, sig = parse_signed_message(signed_msg) - assert pmsg == msg - addr_vs_path(addr, path, addr_fmt, testnet=testnet) - assert verify_message(addr, sig, msg) is True - time.sleep(0.5) - _, story = cap_story() - assert f"Press {OK} to share again" in story - press_select() - signed_msg_again = nfc_read_text() - assert signed_msg == signed_msg_again - press_cancel() # exit NFC animation - press_cancel() # do not want to share again + addr, sig, ret_msg = sign_using_nfc(msg, path, addr_fmt, testnet=testnet, use_json=use_json) + assert msg == ret_msg if addr_fmt == AF_CLASSIC and testnet: - res = bitcoind.rpc.verifymessage(addr, sig, msg) + res = bitcoind.rpc.verifymessage(sig, addr, ret_msg) assert res is True @pytest.fixture @@ -417,7 +638,8 @@ def test_verify_signature_file(way, addr_fmt, path, msg, sign_on_microsd, goto_h cap_story, bitcoind, microsd_path, nfc_write_text, verify_armored_signature, chain, settings_set): settings_set("chain", chain) - sig, addr = sign_on_microsd(msg, path, msg_sign_unmap_addr_fmt[addr_fmt]) + sig, addr, ret_msg = sign_on_microsd(msg, path, msg_sign_unmap_addr_fmt[addr_fmt]) + assert ret_msg == msg fname = 't-msgsign-signed.txt' should = RFC_SIGNATURE_TEMPLATE.format(addr=addr, sig=sig, msg=msg) with open(microsd_path(fname), "r") as f: @@ -705,4 +927,77 @@ def test_verify_signature_file_truncated(way, microsd_path, cap_story, verify_ar 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("addr_fmt", [AF_CLASSIC, AF_P2WPKH]) +@pytest.mark.parametrize("acct", [None, 5555]) +def test_sign_scanned_text(msg, addr_fmt, acct, goto_home, need_keypress, scan_a_qr, + sign_msg_from_text, cap_story, skip_if_useless_way): + skip_if_useless_way("qr") + goto_home() + need_keypress(KEY_QR) + scan_a_qr(msg) + time.sleep(1) + title, story = cap_story() + assert title == "Simple Text" + assert "Press (0) to sign the text" in story + need_keypress("0") + sign_msg_from_text(msg, addr_fmt, acct, False, 999, "qr", "XTN", True) + + +@pytest.mark.parametrize("data", [ + {"msg": "msg to be signed via QR"}, + {"msg": "msg with some\n\t\n control characters", "addr_fmt": "p2sh-p2wpkh"}, + {"msg": 100*"CC", "addr_fmt": "p2wpkh", "subpath": "m/900h/0"}, +]) +@pytest.mark.parametrize("way", ["sd", "nfc", "qr"]) +def test_sign_scanned_json(data, way, goto_home, need_keypress, scan_a_qr, + cap_story, msg_sign_export, press_select, + addr_vs_path, bitcoind, skip_if_useless_way, + verify_msg_sign_story): + skip_if_useless_way(way) + goto_home() + af = data.get("addr_fmt", None) + if not af: + addr_fmt = AF_CLASSIC + else: + addr_fmt = msg_sign_unmap_addr_fmt[af] + + need_keypress(KEY_QR) + scan_a_qr(json.dumps(data)) + time.sleep(1) + title, story = cap_story() + + subpath = verify_msg_sign_story(story, data["msg"], data.get("subpath", None), addr_fmt) + press_select() + + signed_msg = msg_sign_export(way) + ret_msg, addr, sig = parse_signed_message(signed_msg) + assert ret_msg == data["msg"] + # check expected addr was used + addr_vs_path(addr, subpath, addr_fmt) + assert verify_message(addr, sig, ret_msg) is True + if addr_fmt == AF_CLASSIC: + res = bitcoind.rpc.verifymessage(addr, sig, ret_msg) + assert res is True + + +@pytest.mark.parametrize("msg", [(50*"a")+"\n\n"+(100*"b"), "Balance replenish 564565456254"]) +def test_verify_scanned_signed_msg(msg, scan_a_qr, need_keypress, goto_home, cap_story, + skip_if_useless_way): + skip_if_useless_way("qr") + wallet = BIP32Node.from_master_secret(os.urandom(32)) + addr = wallet.address() + sk = bytes(wallet.node.private_key) + sig = sign_message(sk, msg.encode()) + armored = RFC_SIGNATURE_TEMPLATE.format(addr=addr, sig=sig, msg=msg) + + goto_home() + need_keypress(KEY_QR) + scan_a_qr(armored) + time.sleep(1) + title, story = cap_story() + assert title == "CORRECT" + assert "Good signature by address" in story + # EOF diff --git a/testing/test_notes.py b/testing/test_notes.py index 659ece1d..437b3ae4 100644 --- a/testing/test_notes.py +++ b/testing/test_notes.py @@ -5,7 +5,7 @@ import pytest, time, json, random, os, pdb from helpers import prandom from charcodes import * - +from constants import AF_CLASSIC, AF_P2WPKH_P2SH, AF_P2WPKH from test_bbqr import readback_bbqr from bbqr import split_qrs @@ -43,10 +43,10 @@ def goto_notes(cap_story, cap_menu, press_select, goto_home, pick_menu_item): @pytest.fixture def need_some_notes(settings_get, settings_set): # create a note or use what's there, provide as obj - def doit(): + def doit(title='Title Here', body='Body'): notes = settings_get('notes', []) if not notes: - settings_set('notes', [dict(misc='Body', title='Title Here')]) + settings_set('notes', [dict(misc=body, title=title)]) return notes return doit @@ -384,7 +384,7 @@ def test_huge_notes(size, encoding, goto_notes, enter_text, cap_menu, need_keypr time.sleep(.5) # decompression time in some cases m = cap_menu() - assert m[-1] == 'Export' + assert m[-2] == 'Export' notes = settings_get('notes') assert len(notes) == 1 @@ -624,4 +624,41 @@ def test_tmp_notes_separation(goto_notes, pick_menu_item, generate_ephemeral_wor assert 'pwd-tmp' not in mm assert 'note-tmp2' not in mm + +@pytest.mark.parametrize("msg", ["COLDCARD rocks!", "cc\nCC"]) +@pytest.mark.parametrize("addr_fmt", [AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH]) +@pytest.mark.parametrize("acct", [None, 0, 9999]) +@pytest.mark.parametrize("way", ["sd", "qr", "nfc", "vdisk"]) +def test_sign_note_body(msg, addr_fmt, acct, need_some_notes, + pick_menu_item, sign_msg_from_text, way, + goto_notes, settings_set): + settings_set("notes", []) + title = "aaa" + need_some_notes(title, msg) + goto_notes() + pick_menu_item(f"1: {title}") + pick_menu_item("Sign Note Text") + sign_msg_from_text(msg, addr_fmt, acct, False, 0, way) + + +@pytest.mark.parametrize("chain", ["BTC", "XTN"]) +@pytest.mark.parametrize("change", [True, False]) +@pytest.mark.parametrize("idx", [None, 0, 9999]) +def test_sign_password_free_form(chain, change, idx, need_some_passwords, settings_set, + goto_notes, pick_menu_item, sign_msg_from_text): + settings_set('notes', []) # clear + title = "A" + msg = 'More Notes AAAA' + settings_set('notes', [ + {'misc': msg, + 'password': 'fds65fd5f1sd51s', + 'site': 'https://a.com', + 'title': title, + 'user': 'AAA'} + ]) + goto_notes() + pick_menu_item(f"1: {title}") + pick_menu_item("Sign Note Text") + sign_msg_from_text(msg, AF_P2WPKH, None, change, idx, "qr", chain) + # EOF diff --git a/testing/test_ownership.py b/testing/test_ownership.py index 181c9f57..b8ce787a 100644 --- a/testing/test_ownership.py +++ b/testing/test_ownership.py @@ -92,8 +92,7 @@ def test_positive(addr_fmt, offset, subaccount, testnet, from_empty, change_idx, menu_item = expect_name = 'Classic P2PKH' path = "m/44h/{ct}h/{acc}h" elif addr_fmt == AF_P2WPKH_P2SH: - expect_name = 'P2WPKH-in-P2SH' - menu_item = 'P2SH-Segwit' + menu_item = expect_name = 'P2SH-Segwit' path = "m/49h/{ct}h/{acc}h" clear_ms() elif addr_fmt == AF_P2WPKH: @@ -154,25 +153,39 @@ def test_positive(addr_fmt, offset, subaccount, testnet, from_empty, change_idx, @pytest.mark.parametrize('valid', [ True, False] ) @pytest.mark.parametrize('testnet', [ True, False] ) @pytest.mark.parametrize('method', [ 'qr', 'nfc'] ) -def test_ux(valid, testnet, method, +@pytest.mark.parametrize('multisig', [ True, False] ) +def test_ux(valid, testnet, method, sim_exec, wipe_cache, make_myself_wallet, use_testnet, goto_home, pick_menu_item, press_cancel, press_select, settings_set, is_q1, nfc_write, need_keypress, - cap_screen, cap_story, load_shared_mod, scan_a_qr + cap_screen, cap_story, load_shared_mod, scan_a_qr, skip_if_useless_way, + sign_msg_from_address, multisig, import_ms_wallet, clear_ms, ): - + skip_if_useless_way(method) addr_fmt = AF_CLASSIC if valid: - mk = BIP32Node.from_wallet_key(simulator_fixed_tprv if testnet else simulator_fixed_xprv) - path = "m/44h/{ct}h/{acc}h/0/3".format(acc=0, ct=(1 if testnet else 0)) - sk = mk.subkey_for_path(path) - addr = sk.address(netcode="XTN" if testnet else "BTC") + if multisig: + from test_multisig import make_ms_address, HARD + M, N = 2, 3 + + expect_name = f'own_ux_test' + clear_ms() + keys = import_ms_wallet(M, N, AF_P2WSH, name=expect_name, accept=1) + + # iffy: no cosigner index in this wallet, so indicated that w/ path_mapper + addr, scriptPubKey, script, details = make_ms_address( + M, keys, is_change=0, idx=50, addr_fmt=AF_P2WSH, + testnet=int(testnet), path_mapper=lambda cosigner: [HARD(45), 0, 50] + ) + else: + mk = BIP32Node.from_wallet_key(simulator_fixed_tprv if testnet else simulator_fixed_xprv) + path = "m/44h/{ct}h/{acc}h/0/3".format(acc=0, ct=(1 if testnet else 0)) + sk = mk.subkey_for_path(path) + addr = sk.address(netcode="XTN" if testnet else "BTC") else: - addr = fake_address(addr_fmt, testnet) + addr = fake_address(addr_fmt, testnet) if method == 'qr': - if not is_q1: - raise pytest.skip('no QR on Mk4') goto_home() pick_menu_item('Scan Any QR Code') scan_a_qr(addr) @@ -214,7 +227,17 @@ def test_ux(valid, testnet, method, assert title == 'Verified Address' assert 'Found in wallet' in story assert 'Derivation path' in story - assert 'P2PKH' in story + + if multisig: + assert expect_name in story + assert "Press (0) to sign message with this key" not in story + else: + assert 'P2PKH' in story + assert "Press (0) to sign message with this key" in story + need_keypress('0') + msg = "coinkite CC the most solid HWW" + sign_msg_from_address(msg, addr, path, addr_fmt, method, testnet) + else: assert title == 'Unknown Address' assert 'Searched ' in story @@ -280,9 +303,7 @@ def test_address_explorer_saver(af, wipe_cache, settings_set, goto_address_explo assert title == 'Verified Address' assert 'Found in wallet' in story assert 'Derivation path' in story - if af == "P2SH-Segwit": - assert "P2WPKH-in-P2SH" in story - elif af == "Segwit P2WPKH": + if af == "Segwit P2WPKH": assert " P2WPKH " in story else: assert af in story