What changed, and why it matters
This is a large bundle of bug fixes for the COLDCARD hardware wallet firmware. Most changes fix user-interface crashes ('yikes'), incorrect error messages, or policy edge cases rather than a single critical vulnerability. The most security-relevant fixes are: (1) disabling NFC and Virtual Disk before entering HSM mode to reduce the attack surface of the locked-down operating mode; (2) preventing a trick/bypass PIN that has no secrets from completing a Single-Signer Spending Policy unlock; (3) stopping OP_RETURN and non-standard scripts from being hidden or mis-displayed during transaction approval; (4) rejecting malformed JSON/QR message-signing requests and control characters that could be used to trick the user; and (5) preventing duplicate multisig wallets with reordered keys from being imported as if they were only a rename. The commit also adds many regression tests for these behaviors.
Treat this as a routine but worthwhile bug-fix release. Users running HSM mode, CCC, multisig, or message-signing workflows benefit most. No immediate emergency response is warranted, but the firmware should be updated once the release is published. Reviewers should verify the new regression tests pass and that the HSM peripheral shutdown and SSSP login fixes behave as intended on real hardware.
Security signals we found
HSM mode now disables NFC and Virtual Disk peripherals to reduce USB/NFC attack surface
Single-Signer Spending Policy unlock no longer accepts a zero-secret bypass PIN as the 'main PIN'
OP_RETURN and non-standard scripts are no longer hidden as 'null-data' during transaction review
Malformed JSON/QR message-signing requests and UI control characters are rejected
Reordered multi(...) multisig descriptors with the same keys are now blocked as duplicates
Malformed NDEF records are handled without crashing
Address ownership validation is stricter and rejects unsupported payment addresses earlier
CCC whitelist import no longer mutates policy when the over-limit check fails
WIF store capacity limit is enforced consistently including via QR visualization
Seed XOR restore from Temporary Seed menu stays temporary even when master seed is blank
Evidence from the diff
The commit is a broad maintenance patch across shared firmware and test files. Security-relevant code changes include: hsm.py now shuts down NFC and Virtual Disk when HSM activates; actions.py loops the login sequence until a PIN that actually loads secrets is provided, and defers NFC/VDisk startup if HSM is active; auth.py corrects PSBT offset handling for Key Teleport and transaction explorer navigation; ccc.py catches bad BIP-39 checksums in the key-C challenge, fixes magnitude reset on cancel, handles OP_RETURN outputs with whitelists, and copies the whitelist before checking the limit; chains.py rewrites op_return() to only treat well-formed single-push scripts as null-data; decoders.py/msgsign.py reject UI control bytes and malformed JSON message-sign requests; multisig.py blocks reordered multi(…) with the same keys as duplicates; nfc.py handles malformed NDEF records; ownership.py uses stricter address validation; psbt.py corrects fully_signed detection for multisig inputs; serializations.py rejects truncated script pushes; stash.py clears the secret spot list; ux_q1.py fixes BIP-21 amount formatting and QR scan error handling; wif.py enforces the 30-item store limit via QR visualization; xor_seed.py forces temporary seed behavior for Seed XOR restore from the Temporary Seed menu. The remainder is test coverage and minor UI fixes.
Changed components
HSM policy activation (shared/hsm.py)Login / trick PIN handling (shared/actions.py, shared/trick_pins.py)Transaction approval and explorer (shared/auth.py, shared/psbt.py)CCC spending policy (shared/ccc.py)Script parsing / OP_RETURN display (shared/chains.py, shared/serializations.py)QR/NFC decoders and message signing (shared/decoders.py, shared/msgsign.py, shared/nfc.py)Multisig wallet import (shared/multisig.py)Address ownership verification (shared/ownership.py, shared/utils.py)Key Teleport (shared/teleport.py)WIF store (shared/wif.py)Seed XOR restore (shared/xor_seed.py, shared/seed.py)Q1 UX / QR scanner (shared/ux_q1.py, shared/keyboard.py, shared/lcd_display.py)Battery idle logout (shared/battery.py)File management (shared/actions.py)Firmware manifest (shared/manifest.py, shared/manifest_mk4.py)Inspect captured patch +1486 / −285
diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md
index db26c4c..5876fa6 100644
--- a/releases/Next-ChangeLog.md
+++ b/releases/Next-ChangeLog.md
@@ -4,6 +4,7 @@ This lists the new changes that have not yet been published in a normal release.
# Shared Improvements - Both Mk and Q
+- Bugfix: Disable Virtual Disk and NFC before activating HSM
- Bugfix: Custom address default menu position wrong
- Bugfix: Delta Mode Trick PIN was never restored from backup
- Bugfix: Proper error message for incorrect 7z headers
@@ -13,6 +14,27 @@ This lists the new changes that have not yet been published in a normal release.
- Bugfix: Do not show "Saving..." UX after failed Notes & Passwords import
- Bugfix: Incorrect error message caused by error in Verify/Decrypt Backup
- Bugfix: NFC Verify Address raised incorrect error message
+- Bugfix: Notes & Passwords bulk import JSON with BBQr encoded as text
+- Bugfix: CCC key C challenge handled bad BIP-39 checksum by crashing the UX; now treated as a wrong attempt (counts toward 3-strike lockout)
+- Bugfix: CCC magnitude reset from CANCEL on empty input
+- Bugfix: OP_RETURN in CCC with whitelist enabled caused yikes
+- Bugfix: TX Explorer crashed on foreign input with non-standard sighash
+- Bugfix: Malformed JSON message-sign request crashed signing UX
+- Bugfix: Reject UI-control bytes in JSON / QR text message-signing
+- Bugfix: Non-standard OP_RETURN outputs shown as "null-data", hiding part of the script
+- Bugfix: Over-limit CCC address-whitelist import was rejected but still modified the policy
+- Bugfix: Deleting a file right after renaming it (List Files) blanked the old name, leaving the renamed file
+- Bugfix: SSSP bypass PIN alone could complete login into a no-secret session. Second prompt now requires a PIN that loads secrets.
+- Bugfix: Reordered `multi(...)` multisig with same keys was misreported as name-only change. Now blocked as duplicate.
+- Bugfix: Max WIF store capacity limit was ignored if saving via QR WIF visualization
+- Bugfix: Force Seed XOR restore from Temporary Seed menu to remain temporary even when master seed is blank
+- Bugfix: Q1 seed word entry cursor alignment for 12-word seeds and preserve visible words after failed QR scans
+- Bugfix: Binary signed-transaction (.txn) failed in NFC/QR file share
+- Bugfix: yikes in transaction explorer for goto index for tx with only one output
+- Bugfix: Sending `signmessage` payload encoded as BBQr caused yikes
+- Bugfix: CCC/SSSP NFC whitelist import caused Yikes
+- Bugfix: Stricter address ownership validation rejects unrecognized payment addresses before wallet search
+- Bugfix: Handle malformed NDEF records robustly. Thanks, @Damir
# Mk Specific Changes
@@ -25,6 +47,10 @@ This lists the new changes that have not yet been published in a normal release.
## 1.4.xQ - 2065-04-xx
-- tbd
-
-
+- Bugfix: Teleporting a multisig PSBT file (without signing it first) sent stale data instead of the selected file
+- Bugfix: Fix export UX message after teleport PSBT import & sign
+- Bugfix: BIP-21 QR `amount` rendered with wrong decimal scaling on the Payment Address screen (e.g. `amount=1.1` was shown as `1.00000001 BTC`)
+- Bugfix: QR scan import (Scan Any QR Code, master/temp seed via QR) now surfaces a clean error story on any parser or seed-loading failure (e.g. wordlist-valid but bad-checksum SeedQR) instead of yikesing the menu task
+- Bugfix: Yikes when showing "QR too big" for a transaction output alone on an output-explorer page
+- Bugfix: Yikes receiving a malformed full-backup via Key Teleport
+- Bugfix: Keyboard debounce could leave a key stuck as "pressed" after release when another key was held
diff --git a/shared/actions.py b/shared/actions.py
index 943e9c7..259294e 100644
--- a/shared/actions.py
+++ b/shared/actions.py
@@ -820,12 +820,15 @@ async def start_login_sequence():
sp_unlock = tp.was_sp_unlock()
if sp_unlock:
# Trying to unlock spending policy: ask for main PIN next.
- await ux_show_story("Spending Policy Unlock: Please provide Main PIN next.")
- pa.reset()
- await block_until_login()
+ while True:
+ await ux_show_story("Spending Policy Unlock: Please provide Main PIN next.")
+ pa.reset()
+ await block_until_login()
+ if pa.has_secrets():
+ break
- # we don't really know if that was the Main PIN (could easily be the bypass
- # PIN again) and if it's a duress wallet, that's cool...
+ # Main or duress wallet PINs are acceptable here, but zero-secret
+ # trick PINs are not enough to disable spending policy.
# Do we need to do countdown delay? (real or otherwise)
# - wiping has already occurred if that was selected by trick details
@@ -938,12 +941,14 @@ async def start_login_sequence():
settings.master_set("seedvault", False)
except: pass
- if version.has_nfc and settings.get('nfc', 0):
+
+ from glob import hsm_active
+ if version.has_nfc and settings.get('nfc', 0) and not hsm_active:
# Maybe allow NFC now
import nfc
nfc.NFCHandler.startup()
- if settings.get('vidsk', 0):
+ if settings.get('vidsk', 0) and not hsm_active:
# Maybe start virtual disk
import vdisk
vdisk.VirtDisk()
@@ -1623,7 +1628,7 @@ async def qr_share_file(_1, _2, item):
# it's a txn, and we wrote as hex
data = data.decode()
else:
- assert data[2:8] == bytes(6)
+ assert data[1:4] == bytes(3)
data = b2a_hex(data).decode()
elif data[0:5] == b'psbt\xff':
tc = "P"
@@ -1778,6 +1783,7 @@ async def list_files(*A):
assert s not in new_basename, "illegal char"
uos.rename(path + "/" + basename, path + "/" + new_basename)
basename = new_basename
+ fn = path + "/" + basename # keep full path in sync (delete/sign use it)
except Exception as e:
await ux_show_story("Failed to rename the file. " + str(e),
title="Failure")
@@ -2444,7 +2450,11 @@ async def scan_any_qr(menu, label, item):
async def _scan_any_qr(expect_secret=False, tmp=False):
from ux_q1 import QRScannerInteraction
x = QRScannerInteraction()
- await x.scan_anything(expect_secret=expect_secret, tmp=tmp)
+ try:
+ await x.scan_anything(expect_secret=expect_secret, tmp=tmp)
+ except Exception as e:
+ await ux_show_story(msg="Failed to import from QR.\n\n%s\n%s" % (e, problem_file_line(e)),
+ title="ERROR")
PUSHTX_SUPPLIERS = [
diff --git a/shared/auth.py b/shared/auth.py
index 7a94d52..3bc1e5b 100644
--- a/shared/auth.py
+++ b/shared/auth.py
@@ -271,8 +271,9 @@ async def try_push_tx(data, txid, txn_sha=None):
class ApproveTransaction(UserAuthorizedAction):
def __init__(self, psbt_len, flags=None, psbt_sha=None, input_method=None,
- output_encoder=None, filename=None):
+ output_encoder=None, filename=None, offset=TXN_INPUT_OFFSET):
super().__init__()
+ self.offset = offset
self.psbt_len = psbt_len
# do finalize is None if not USB, None = decide based on is_complete
@@ -400,7 +401,7 @@ class ApproveTransaction(UserAuthorizedAction):
# step 1: parse PSBT from PSRAM into in-memory objects.
try:
- with SFFile(TXN_INPUT_OFFSET, length=self.psbt_len, message='Reading...') as fd:
+ with SFFile(self.offset, length=self.psbt_len, message='Reading...') as fd:
# NOTE: psbtObject captures the file descriptor and uses it later
self.psbt = psbtObject.read_psbt(fd)
except BaseException as exc:
@@ -764,11 +765,12 @@ class ApproveTransaction(UserAuthorizedAction):
msg.write('%s %s\n\n' % self.chain.render_value(total_change - visible_change_sum))
-def sign_transaction(psbt_len, flags=0x0, psbt_sha=None):
+def sign_transaction(psbt_len, flags=0x0, psbt_sha=None, input_method="usb", offset=TXN_INPUT_OFFSET):
# transaction (binary) loaded into PSRAM already, checksum checked
UserAuthorizedAction.check_busy(ApproveTransaction)
UserAuthorizedAction.active_request = ApproveTransaction(
- psbt_len, flags, psbt_sha=psbt_sha, input_method="usb",
+ psbt_len, flags, psbt_sha=psbt_sha, input_method=input_method,
+ offset=offset
)
# kill any menu stack, and put our thing at the top
@@ -839,6 +841,10 @@ async def done_signing(psbt, tx_req, input_method=None, filename=None,
msg = noun + " shared via USB."
title = "PSBT Signed"
+ elif input_method == "kt":
+ first_time = False
+ title = "PSBT Signed"
+
if txid and await try_push_tx(data_len, txid, data_sha2):
# go directly to reexport menu after pushTX
first_time = False
@@ -857,8 +863,6 @@ async def done_signing(psbt, tx_req, input_method=None, filename=None,
ch = KEY_QR
elif input_method == "nfc":
ch = KEY_NFC
- elif input_method == "kt":
- ch = 't'
else:
# SD/VDisk
ch = {"force_vdisk": input_method == "vdisk", "slot_b": slot_b}
@@ -916,8 +920,9 @@ async def done_signing(psbt, tx_req, input_method=None, filename=None,
elif (ch == 't') and not is_complete:
# they might want to teleport it, but only if we have PSBT
# there is no need to teleport PSBT if txn is already complete & ready to be broadcast
+ # updated PSBT is at TXN_OUTPUT_OFFSET (at TXN_INPUT_OFFSET is PSBT that is NOT updated)
from teleport import kt_send_psbt
- ok = await kt_send_psbt(psbt, data_len)
+ ok = await kt_send_psbt(psbt, data_len, psbt_offset=TXN_OUTPUT_OFFSET)
if ok is None:
title = "Failed to Teleport"
else:
@@ -1065,7 +1070,7 @@ async def _save_to_disk(psbt, txid, save_options, is_complete, data_len, output_
return msg
-
+
async def sign_psbt_file(filename, force_vdisk=False, slot_b=None, just_read=False, ux_abort=False):
# sign a PSBT file found on a MicroSD card
# - or from VirtualDisk (mk4)
@@ -1595,6 +1600,9 @@ class TXExplorer:
self.qr_msgs = []
self.title = None
+ def can_goto_idx(self):
+ return self.max_items > 1
+
@classmethod
async def start(cls, user_auth_action):
rv = [
@@ -1607,6 +1615,7 @@ class TXExplorer:
def make_ux_msg(self, offset, count):
from glob import dis
dis.fullscreen('Wait...')
+ esc = "4"+KEY_QR
rv = ""
qrs = []
change = []
@@ -1615,18 +1624,29 @@ class TXExplorer:
rv += item
dis.progress_sofar(idx-offset+1, count)
- rv += 'Press RIGHT to see next group'
+ hints = []
+ if end < self.max_items:
+ hints.append('RIGHT to see next group')
+ esc += KEY_RIGHT + "9"
if offset:
- rv += ', LEFT to go back'
+ hints.append('LEFT to go back')
+ esc += KEY_LEFT + "7"
- rv += ", (2) to go to index"
+ if self.can_goto_idx():
+ hints.append("(2) to go to index")
+ esc += "2"
if not version.has_qwerty:
# Q has hint key
- rv += ", (4) to show QR code"
- rv += ('. %s to quit.' % X)
+ hints.append("(4) to show QR code")
+
+ if hints:
+ rv += 'Press ' + ', '.join(hints)
+ rv += ('. %s to quit.' % X)
+ else:
+ rv += 'Press %s to quit.' % X
- return rv, qrs, change, end
+ return rv, qrs, change, end, esc
async def explore(self, *a):
@@ -1635,11 +1655,10 @@ class TXExplorer:
# - shows all inputs: utxo amount and address, txid & tx index.
start = 0
- msg, addrs, change, end = self.make_ux_msg(start, self.n)
+ msg, addrs, change, end, esc = self.make_ux_msg(start, self.n)
while True:
- ch = await ux_show_story(msg, title=self.title, escape='2479'+KEY_RIGHT+KEY_LEFT+KEY_QR,
- hint_icons=KEY_QR)
+ ch = await ux_show_story(msg, title=self.title, hint_icons=KEY_QR, escape=esc)
if ch == 'x':
del msg
return
@@ -1661,7 +1680,7 @@ class TXExplorer:
else:
# go forwards
start += self.n
- elif ch == "2":
+ elif (ch == "2") and (self.max_items > 1):
max_v = self.max_items - 1
res = await ux_enter_number("Start Idx (0-%d):" % max_v, max_value=max_v)
if res is None: continue
@@ -1670,7 +1689,7 @@ class TXExplorer:
# nothing changed - do not recalc msg
continue
- msg, addrs, change, end = self.make_ux_msg(start, self.n)
+ msg, addrs, change, end, esc = self.make_ux_msg(start, self.n)
class TXOutExplorer(TXExplorer):
@@ -1747,7 +1766,6 @@ class TXInpExplorer(TXExplorer):
psbt_item += "%s:\n%s%s\n\n" % (keypath_to_str(pth, prefix="%s/" % xfp2str(pth[0])),
b2a_hex(k).decode(), ws_note)
- M = None
if inp.is_multisig:
ks_coord = inp.witness_script or inp.redeem_script
if ks_coord:
@@ -1767,7 +1785,7 @@ class TXInpExplorer(TXExplorer):
if pk in inp.part_sigs:
done.append(xfp2str(pth[0]))
- if inp.fully_signed or (M and (len(done) >= M)):
+ if inp.fully_signed:
psbt_item += "Input fully signed.\n\n"
else:
psbt_item += "Already signed:\n"
@@ -1782,7 +1800,7 @@ class TXInpExplorer(TXExplorer):
1 | 0x80: "ALL|ANYONECANPAY",
2 | 0x80: "NONE|ANYONECANPAY",
3 | 0x80: "SINGLE|ANYONECANPAY",
- }[inp.sighash]
+ }.get(inp.sighash, "0x%02x (non-standard)" % inp.sighash)
if psbt_item:
psbt_item = "=== PSBT ===\n\n" + psbt_item
diff --git a/shared/backups.py b/shared/backups.py
index c881351..169533b 100644
--- a/shared/backups.py
+++ b/shared/backups.py
@@ -577,7 +577,11 @@ async def restore_complete(fname_or_fd, temporary=False, words=True, usb=False):
# give them a menu to pick from, and start picking
if usb:
# we're not originating from a menu
- words = await seed.WordNestMenu.get_n_words(12)
+ words = await seed.WordNestMenu.get_n_words(num_pw_words)
+ if len(words) != num_pw_words:
+ seed.WordNestMenu.pop_all()
+ return
+
await done(words)
else:
m = seed.WordNestMenu(num_words=num_pw_words, has_checksum=False, done_cb=done)
diff --git a/shared/battery.py b/shared/battery.py
index 405715d..be96290 100644
--- a/shared/battery.py
+++ b/shared/battery.py
@@ -138,12 +138,15 @@ async def batt_idle_logout():
# - even before login
import glob
from uasyncio import sleep_ms
- from glob import settings, dis
+ from glob import settings, dis, SCAN
import utime
while True:
await sleep_ms(20000) # 20 seconds
+ if SCAN.busy_scanning:
+ continue
+
if get_batt_level() is None:
# on USB power
continue
diff --git a/shared/bbqr.py b/shared/bbqr.py
index a7f5fa9..fbf0cd8 100644
--- a/shared/bbqr.py
+++ b/shared/bbqr.py
@@ -439,5 +439,18 @@ class BBQrPsramStorage(BBQrStorage):
from glob import PSRAM
return PSRAM.read_at(0, self.final_size)
+ def finalize(self):
+ self._finalize()
+
+ if self.hdr.encoding == 'Z':
+ self.zlib_decompress()
+
+ # PSBT-typed BBQrs end up at PSRAM[0..size]
+ # skip a redundant PSRAM->heap->PSRAM round-trip
+ if self.hdr.file_type == 'P':
+ return self.hdr.file_type, self.final_size, 'PSRAM'
+
+ return self.hdr.file_type, self.final_size, self.get_buffer()
+
# EOF
diff --git a/shared/ccc.py b/shared/ccc.py
index 2071d07..c536944 100644
--- a/shared/ccc.py
+++ b/shared/ccc.py
@@ -10,6 +10,7 @@
# - "hobbled" refers to less-than full control over Coldcard, even though you have main PIN
#
import gc, chains, version, ngu, web2fa, bip39, re
+from ubinascii import hexlify as b2a_hex
from chains import NLOCK_IS_TIME
from utils import swab32, xfp2str, truncate_address, deserialize_secret, show_single_address
from glob import settings, dis
@@ -122,7 +123,10 @@ class SpendingPolicy(dict):
for idx, txo in psbt.output_iter():
out = psbt.outputs[idx]
if not out.is_change: # ignore change
- addr = c.render_address(txo.scriptPubKey)
+ try:
+ addr = c.render_address(txo.scriptPubKey)
+ except ValueError:
+ addr = str(b2a_hex(txo.scriptPubKey), 'ascii')
if addr not in wl:
raise SpendPolicyViolation("whitelist: " + addr)
@@ -232,7 +236,12 @@ class CCCFeature:
@classmethod
def words_check(cls, words):
# Test if words provided are right
- enc = seed_words_to_encoded_secret(words)
+ try:
+ # a2b_words with checksum check
+ enc = seed_words_to_encoded_secret(words)
+ except:
+ return False
+
exp = cls.get_encoded_secret()
return enc == exp
@@ -585,11 +594,12 @@ class SPAddrWhitelist(MenuSystem):
if choice == KEY_CANCEL:
return
elif choice == KEY_NFC:
- addr = await NFC.read_address()
- if not addr:
+ res = await NFC.read_address()
+ if not res:
# error already displayed in nfc.py
return
+ _, addr, _ = res
await self.add_addresses([addr])
return
@@ -651,11 +661,12 @@ class SPAddrWhitelist(MenuSystem):
async def add_addresses(self, more_addrs):
# add new entries, if unique; preserve ordering
- addrs = self.policy.get('addrs', [])
+ # - work on a copy and check the limit *before* committing: the list
+ # from get('addrs') is the live, settings-backed one
+ addrs = list(self.policy.get('addrs', []))
new = []
for a in more_addrs:
- if a not in addrs:
- addrs.append(a)
+ if a not in addrs and a not in new:
new.append(a)
if not new:
@@ -663,10 +674,10 @@ class SPAddrWhitelist(MenuSystem):
'\n\n'.join(show_single_address(a) for a in more_addrs))
return
- if len(addrs) > MAX_WHITELIST:
+ if len(addrs) + len(new) > MAX_WHITELIST:
return await self.maxed_out()
- self.policy.update_policy_key(addrs=addrs)
+ self.policy.update_policy_key(addrs=addrs + new)
self.update_contents()
if len(new) > 1:
@@ -747,14 +758,10 @@ class SpendingPolicyMenu(MenuSystem):
was = self.policy.get('mag', 0)
val = await ux_enter_number('Transaction Max:', max_value=int(1e8),
value=(was or ''))
+ if val is None: return
args = dict(mag=val)
- if (val is None) or (val == was):
- msg = "Did not change"
- val = was
- else:
- msg = "You have set the"
- unchanged = False
+ msg = "Did not change" if val == was else "You have set the"
if not val:
msg = "No check for maximum transaction size will be done. "
diff --git a/shared/chains.py b/shared/chains.py
index 4833251..67b4060 100644
--- a/shared/chains.py
+++ b/shared/chains.py
@@ -264,50 +264,34 @@ class ChainsBase:
@classmethod
def op_return(cls, script):
- # returns decoded string op return data if script is op return otherwise None
- gen = disassemble(script)
- script_type = next(gen)
- if OP_RETURN not in script_type:
- return
-
try:
- data = next(gen)[0]
- if data:
- return data
- except StopIteration:
- pass
-
- return b""
+ gen = disassemble(script)
+ item, opcode = next(gen)
+ except (StopIteration, ValueError):
+ return None
- @classmethod
- def possible_address_fmt(cls, addr):
- # Given a text (serialized) address, return what
- # address format applies to the address, but
- # for AF_P2SH case, could be: AF_P2SH, AF_P2WPKH_P2SH, AF_P2WSH_P2SH. .. we don't know
- hrp = cls.bech32_hrp + "1"
- if addr.startswith(hrp):
- if addr.startswith(hrp+'p'):
- # segwit v1 (any ver=1 script or address, but for now just taproot...)
- return AF_P2TR
- elif addr.startswith(hrp+'q'):
- # segwit v0
- return AF_P2WPKH if len(addr) < 55 else AF_P2WSH
-
- return 0
+ if opcode != OP_RETURN:
+ return None
try:
- raw = ngu.codecs.b58_decode(addr)
- except ValueError:
- # not base58, not an error
- return 0
-
- if raw[0] == cls.b58_addr[0]:
- return AF_CLASSIC
- if raw[0] == cls.b58_script[0]:
- return AF_P2SH
-
- return 0
-
+ try:
+ data, opcode = next(gen)
+ except StopIteration:
+ return b"" # bare OP_RETURN
+
+ try:
+ next(gen)
+ return None # extra ops/pushes -> raw script display
+ except StopIteration: pass
+
+ except ValueError:
+ return None
+
+ if isinstance(data, bytes):
+ return data
+ if data is None and opcode == 0:
+ return b"" # OP_RETURN OP_0
+ return None
class BitcoinMain(ChainsBase):
# see <https://github.com/bitcoin/bitcoin/blob/master/src/chainparams.cpp#L140>
diff --git a/shared/decoders.py b/shared/decoders.py
index 0c3ea85..d0439d9 100644
--- a/shared/decoders.py
+++ b/shared/decoders.py
@@ -11,6 +11,15 @@ from bbqr import TYPE_LABELS
from utils import decode_bip21_text
+def decode_qr_text(got):
+ if isinstance(got, str):
+ return got
+
+ try:
+ return got.decode()
+ except UnicodeError:
+ raise QRDecodeExplained('UTF-8 decode failed')
+
def decode_seed_qr(data):
# SeedQR: 4 digit groups of index into word list
parts = [data[pos:pos + 4] for pos in range(0, len(data), 4)]
@@ -39,6 +48,8 @@ def decode_secret(got):
# - xprv / tprv
# - words (either full or prefixes, case insensitive)
# - SeedQR (github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md)
+ # - word lists are NOT BIP-39-checksum-validated here. Callers that
+ # require a valid seed must run bip39.a2b_words(...)
if len(got) > 300:
raise ValueError("Too big.")
@@ -51,7 +62,7 @@ def decode_secret(got):
# xprv or tprv: private key import for sure
# - verify checksum is right
try:
- raw = ngu.codecs.b58_decode(got)
+ ngu.codecs.b58_decode(got)
except:
raise ValueError('corrupt xprv?')
@@ -63,7 +74,7 @@ def decode_secret(got):
kp, testnet, compressed = decode_wif(got)
return 'wif', (got, kp, compressed, testnet)
except: pass
-
+
taste = got.strip().lower()
if taste.isdigit():
@@ -108,11 +119,8 @@ def decode_qr_result(got, expect_secret=False, expect_text=False, expect_bbqr=Fa
return got.decode()
if ty == 'P':
- # may already be in PSRAM, avoid a copy here
- from glob import PSRAM
- if PSRAM.is_at(got, 0):
- got = 'PSRAM' # see qr_psbt_sign()
-
+ # `got` is the literal 'PSRAM' from BBQrPsramStorage when data already there
+ # otherwise it's real bytes
return 'psbt', (None, final_size, got)
elif ty == 'T':
@@ -120,9 +128,10 @@ def decode_qr_result(got, expect_secret=False, expect_text=False, expect_bbqr=Fa
elif ty == 'U':
# continue thru code below for TEXT
- pass
+ got = decode_qr_text(got)
elif ty == 'J':
+ got = decode_qr_text(got)
what = "json"
if "msg" in got:
what = "smsg"
@@ -187,12 +196,7 @@ def decode_short_text(got):
# - if bad checksum on bitcoin addr, we treat as text... since might be
# return: what-it-is, (tuple)
- if not isinstance(got, str):
- # decode utf-8
- try:
- got = got.decode()
- except UnicodeError:
- raise QRDecodeExplained('UTF-8 decode failed')
+ got = decode_qr_text(got)
# might be a PSBT?
if len(got) > 100:
@@ -227,10 +231,11 @@ def decode_short_text(got):
cc_ms_pat = r"[0-9a-fA-F]+\s*:\s*[xtyYzZuUvV]pub[1-9A-HJ-NP-Za-km-z]+"
rgx = ure.compile(cc_ms_pat)
# go line by line and match above, once 2 matches observed - considered multisig
- # important to not use ure.search for big strings (can run out of stack)
+ # important to not use ure.search for big strings (can run out of stack);
+ # a real line here is a "<8-hex xfp>: <xpub>" key (~121 chars)
c = 0 # match count
for l in got.split("\n"):
- if rgx.search(l):
+ if len(l) <= 150 and rgx.search(l):
c += 1
if c > 1:
return 'multi', (got,)
diff --git a/shared/flow.py b/shared/flow.py
index 09b4265..1debe1c 100644
--- a/shared/flow.py
+++ b/shared/flow.py
@@ -488,7 +488,7 @@ NormalSystem = [
MenuItem("Address Explorer", menu=address_explore, shortcut='x'),
MenuItem('Secure Notes & Passwords', menu=make_notes_menu, shortcut='n',
predicate=lambda: version.has_qwerty and settings.get("secnap", False)),
- MenuItem('Type Passwords', f=password_entry, shortcut='t',
+ MenuItem('Type Passwords', f=password_entry, shortcut='e',
predicate=lambda: settings.get("emu", False) and has_secrets()),
MenuItem('Seed Vault', menu=make_seed_vault_menu, shortcut='v',
predicate=lambda: settings.master_get('seedvault') and has_secrets()),
diff --git a/shared/hsm.py b/shared/hsm.py
index c619798..6018b83 100644
--- a/shared/hsm.py
+++ b/shared/hsm.py
@@ -656,6 +656,15 @@ class HSMPolicy:
assert not glob.hsm_active
glob.hsm_active = self
+ # HSM is the locked-down operating mode: shut down peripherals
+ # that enlarge the USB-stack interaction surface.
+ # - VDisk: MSC bulk OUT and HID OUT share the STM32 OTG_FS RX FIFO;
+ # under load this can wedge the HID OUT endpoint permanently
+ if glob.VD is not None:
+ glob.VD.shutdown()
+ if glob.NFC is not None:
+ glob.NFC.shutdown()
+
self.start_time = utime.ticks_ms()
if new_file:
diff --git a/shared/keyboard.py b/shared/keyboard.py
index 7938ac7..b9296e9 100644
--- a/shared/keyboard.py
+++ b/shared/keyboard.py
@@ -134,7 +134,7 @@ class FullKeyboard(NumpadBase):
if self._history[kn] == NUM_SAMPLES:
self.is_pressed[kn] = 1
new_presses.add(kn)
- elif self._history[i] == 0:
+ elif self._history[kn] == 0:
self.is_pressed[kn] = 0
self._history[kn] = 0
diff --git a/shared/lcd_display.py b/shared/lcd_display.py
index ca153ce..6693d05 100644
--- a/shared/lcd_display.py
+++ b/shared/lcd_display.py
@@ -734,7 +734,9 @@ class Display:
lines = self.handle_qr_msg(msg, max_lines=True)
self.draw_qr_lines(lines, False)
- self.draw_qr_idx_hint(idx_hint)
+ if idx_hint:
+ self.draw_qr_idx_hint(idx_hint)
+
self.show()
def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert, partial_bar=None,
diff --git a/shared/manifest.py b/shared/manifest.py
index 4128769..a2cc842 100644
--- a/shared/manifest.py
+++ b/shared/manifest.py
@@ -13,8 +13,6 @@ freeze_as_mpy('', [
'compat7z.py',
'countdowns.py',
'descriptor.py',
- 'dev_helper.py',
- 'display.py',
'drv_entro.py',
'exceptions.py',
'export.py',
@@ -48,7 +46,6 @@ freeze_as_mpy('', [
'selftest.py',
'serializations.py',
'sffile.py',
- 'ssd1306.py',
'stash.py',
'tapsigner.py',
'trick_pins.py',
diff --git a/shared/manifest_mk4.py b/shared/manifest_mk4.py
index b7ac0c1..e3aa489 100644
--- a/shared/manifest_mk4.py
+++ b/shared/manifest_mk4.py
@@ -1,5 +1,6 @@
# Mk4 only files; would not be needed on Mk3 or earlier.
freeze_as_mpy('', [
+ 'display.py',
'hsm.py',
'hsm_ux.py',
'mempad.py',
diff --git a/shared/msgsign.py b/shared/msgsign.py
index 848819f..23ba61f 100644
--- a/shared/msgsign.py
+++ b/shared/msgsign.py
@@ -267,7 +267,7 @@ def validate_text_for_signing(text, only_printable=True):
# - 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
-
+ text = str(text, "ascii") # handle memoryview coming from USB
result = to_ascii_printable(text, only_printable=only_printable)
length = len(result)
@@ -315,6 +315,7 @@ def parse_msg_sign_request(data):
if text is None:
raise AssertionError("MSG required")
subpath = data_dict.get("subpath", subpath)
+ assert isinstance(subpath, str), "subpath"
addr_fmt = data_dict.get("addr_fmt", addr_fmt)
is_json = True
except ValueError:
@@ -333,11 +334,13 @@ def parse_msg_sign_request(data):
addr_fmt = addr_fmt_from_subpath(subpath)
if not subpath:
- subpath = chains.STD_DERIVATIONS[addr_fmt]
- subpath = subpath.format(
- coin_type=chains.current_chain().b44_cointype,
- account=0, change=0, idx=0
- )
+ try:
+ subpath = chains.STD_DERIVATIONS[addr_fmt]
+ subpath = subpath.format(
+ coin_type=chains.current_chain().b44_cointype,
+ account=0, change=0, idx=0
+ )
+ except: pass
return text, subpath, addr_fmt, is_json
diff --git a/shared/multisig.py b/shared/multisig.py
index ef1bb37..dc512ab 100644
--- a/shared/multisig.py
+++ b/shared/multisig.py
@@ -424,6 +424,11 @@ class MultisigWallet(WalletABC):
# do not allow to import multi if sortedmulti with the same set of keys
# already imported and vice-versa
return None, ["BIP-67 clash"], 1
+ elif not self.bip67 and self.xpubs != c.xpubs:
+ # multi(2,A,B) and multi(2,B,A) are consensus-different scripts;
+ # treat as duplicates -- don't allow either if a same-keys variant
+ # in a different order is already enrolled
+ return None, ["key order"], 1
elif self.name == c.name:
return None, [], 1
else:
@@ -1082,11 +1087,11 @@ class MultisigWallet(WalletABC):
story = 'Update NAME only of existing multisig wallet?'
elif num_dups and isinstance(diff_items, list):
# failures only
- story = "Duplicate wallet."
+ story = "Duplicate wallet. "
if diff_items:
story += diff_items[0]
else:
- story += ' All details are the same as existing!'
+ story += 'All details are the same as existing!'
is_dup = True
elif diff_items:
# Concern here is overwrite when similar, but we don't overwrite anymore, so
diff --git a/shared/nfc.py b/shared/nfc.py
index fe8e1d4..7aa78ee 100644
--- a/shared/nfc.py
+++ b/shared/nfc.py
@@ -618,7 +618,7 @@ class NFCHandler:
# it's a txn, and we wrote as hex
data = a2b_hex(data)
else:
- assert data[2:8] == bytes(6)
+ assert data[1:4] == bytes(3)
sha = ngu.hash.sha256s(data)
await self.share_signed_txn(txid, data, len(data), sha)
elif ext == 'psbt':
@@ -775,15 +775,17 @@ class NFCHandler:
if not data: return
winner = None
- for urn, msg, meta in ndef.record_parser(data):
- msg = bytes(msg)
- try:
- r = func(msg)
- if r is not None:
- winner = r
- break
- except:
- pass
+ try:
+ for urn, msg, meta in ndef.record_parser(data):
+ msg = bytes(msg)
+ try:
+ r = func(msg)
+ if r is not None:
+ winner = r
+ break
+ except:
+ pass
+ except Exception: pass # dont crash when given garbage
if not winner:
await ux_show_story(fail_msg)
diff --git a/shared/notes.py b/shared/notes.py
index e14a84f..bbdb74a 100644
--- a/shared/notes.py
+++ b/shared/notes.py
@@ -10,6 +10,7 @@ from ux_q1 import QRScannerInteraction
from actions import goto_top_menu
from glob import settings, dis
from files import CardMissingError, needs_microsd, CardSlot
+from public_constants import MSG_SIGNING_MAX_LENGTH
from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL
from charcodes import KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6
from lcd_display import CHARS_W
@@ -363,7 +364,8 @@ class NoteContentBase:
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)
+ return MenuItem("Sign Note Text", f=self.sign_txt_msg, arg=self.misc,
+ predicate=2 <= len(self.misc) <= MSG_SIGNING_MAX_LENGTH)
class PasswordContent(NoteContentBase):
diff --git a/shared/ownership.py b/shared/ownership.py
index fce59f8..99b8e31 100644
--- a/shared/ownership.py
+++ b/shared/ownership.py
@@ -8,7 +8,7 @@ from ucollections import namedtuple
from ubinascii import hexlify as b2a_hex
from ubinascii import unhexlify as a2b_hex
from exceptions import UnknownAddressExplained
-from utils import problem_file_line, show_single_address
+from utils import problem_file_line, show_single_address, validate_own_address
from public_constants import AFC_SCRIPT, AF_P2WPKH_P2SH, AF_P2SH, AF_P2WSH_P2SH, AF_P2TR, AF_P2WSH
# Track many addresses, but in compressed form
@@ -304,11 +304,10 @@ class OwnershipCache:
dis.fullscreen("Wait...")
- ch = chains.current_chain()
- addr_fmt = ch.possible_address_fmt(addr)
- if not addr_fmt:
- # might be valid address over on testnet vs mainnet
- raise UnknownAddressExplained('That address is not valid on ' + ch.name)
+ try:
+ addr, addr_fmt = validate_own_address(addr)
+ except Exception as e:
+ raise UnknownAddressExplained('That address is not valid on ' + e.args[0])
matches = OWNERSHIP.filter(addr_fmt, args)
@@ -343,7 +342,7 @@ class OwnershipCache:
dis.fullscreen("WIF Store...")
from wif import iter_wif_store_addresses
target_af = AF_P2WPKH_P2SH if addr_fmt == AF_P2SH else addr_fmt
- for i, store_addr in iter_wif_store_addresses(ch, target_af):
+ for i, store_addr in iter_wif_store_addresses(target_af):
if store_addr == addr:
return False, ("wif", target_af), i+1
diff --git a/shared/psbt.py b/shared/psbt.py
index dabbfb5..430c04d 100644
--- a/shared/psbt.py
+++ b/shared/psbt.py
@@ -665,7 +665,7 @@ class psbtInputProxy(psbtProxy):
# - assuming PSBT creator doesn't give us extra data not required
# - seems harmless if they fool us into thinking already signed; we do nothing
# - could also look at pubkey needed vs. sig provided
- # - could consider structure of MofN in p2sh cases
+ # - structure of MofN is considered in determine_my_signing_key where fully_signed is updated
self.fully_signed = (len(self.part_sigs) >= len(self.subpaths))
else:
# No signatures at all yet for this input (typical non multisig)
@@ -875,6 +875,11 @@ class psbtInputProxy(psbtProxy):
#print("redeem: %s" % b2a_hex(redeem_script))
M, N = disassemble_multisig_mn(redeem_script)
+
+ if len(self.part_sigs) >= M:
+ self.fully_signed = True
+ return
+
xfp_paths = list(self.subpaths.values())
xfp_paths.sort()
@@ -1781,8 +1786,6 @@ class psbtObject(psbtProxy):
prevouts.add(k)
inp = self.inputs[i]
- if inp.fully_signed:
- self.presigned_inputs.add(i)
if not inp.has_utxo():
if inp.num_our_keys and not inp.fully_signed:
@@ -1804,6 +1807,11 @@ class psbtObject(psbtProxy):
# - also validates redeem_script when present
# - also finds appropriate multisig wallet to be used
inp.determine_my_signing_key(i, utxo, self.my_xfp, self, cosign_xfp)
+ # determine_my_signing_key is updating fully_signed for multisig inputs
+ # based on redeem/witness script
+ if inp.fully_signed:
+ self.presigned_inputs.add(i)
+
if inp.required_key and self.wif_store:
is_in = False
for pk in inp.required_key if isinstance(inp.required_key, set) else [inp.required_key]:
diff --git a/shared/psram.py b/shared/psram.py
index 087aebb..5debb1e 100644
--- a/shared/psram.py
+++ b/shared/psram.py
@@ -25,10 +25,6 @@ class PSRAMWrapper:
return memoryview(self._wr)[offset:offset+ln]
- def is_at(self, ptr, offset):
- # is bytes() object really one we created at read_at
- return uctypes.addressof(ptr) == self.base+offset
-
# Be compatible with SPIFlash class...
def read(self, address, buf, cmd=None):
diff --git a/shared/seed.py b/shared/seed.py
index 537882d..bbca691 100644
--- a/shared/seed.py
+++ b/shared/seed.py
@@ -1166,7 +1166,7 @@ class EphemeralSeedMenu(MenuSystem):
from actions import nfc_recv_ephemeral, import_xprv
from actions import restore_backup, scan_any_qr
from tapsigner import import_tapsigner_backup_file
- from xor_seed import xor_restore_start
+ from xor_seed import xor_restore_temporary
from charcodes import KEY_QR
import_ephemeral_menu = [
@@ -1190,7 +1190,7 @@ class EphemeralSeedMenu(MenuSystem):
MenuItem("Import XPRV", f=import_xprv, arg=True), # ephemeral=True
MenuItem("Tapsigner Backup", f=import_tapsigner_backup_file, arg=True), # ephemeral=True
MenuItem("Coldcard Backup", f=restore_backup, arg=True), # tmp=True
- MenuItem("Restore Seed XOR", f=xor_restore_start),
+ MenuItem("Restore Seed XOR", f=xor_restore_temporary),
]
return rv
diff --git a/shared/serializations.py b/shared/serializations.py
index 1765371..7d950ac 100755
--- a/shared/serializations.py
+++ b/shared/serializations.py
@@ -195,41 +195,43 @@ def disassemble(script):
try:
offset = 0
+ slen = len(script)
while 1:
- if offset >= len(script):
+ if offset >= slen:
#print('dis %d done' % offset)
return
c = script[offset]
offset += 1
if 1 <= c <= 75:
- #print('dis %d: bytes=%s' % (offset, b2a_hex(script[offset:offset+c])))
- yield (script[offset:offset+c], None)
- offset += c
+ cnt = c
elif OP_1 <= c <= OP_16:
# OP_1 thru OP_16
- #print('dis %d: number=%d' % (offset, (c - OP_1 + 1)))
yield (c - OP_1 + 1, None)
+ continue
elif c == OP_PUSHDATA1:
cnt = script[offset]
offset += 1
- yield (script[offset:offset+cnt], None)
- offset += cnt
elif c == OP_PUSHDATA2:
# up to 65535 bytes
cnt, = struct.unpack_from("H", script, offset)
offset += 2
- yield (script[offset:offset+cnt], None)
- offset += cnt
elif c == OP_PUSHDATA4:
# no where to put so much data
raise NotImplementedError
elif c == OP_1NEGATE:
yield (-1, None)
+ continue
else:
# OP_0 included here
- #print('dis %d: opcode=%d' % (offset, c))
yield (None, c)
+ continue
+
+ # a data push of `cnt` bytes - reject if it runs off the end
+ if offset + cnt > slen:
+ raise ValueError
+ yield (script[offset:offset+cnt], None)
+ offset += cnt
except Exception as e:
# import sys;sys.print_exception(e)
raise ValueError("bad script")
diff --git a/shared/stash.py b/shared/stash.py
index 9f28e2c..80ef64c 100644
--- a/shared/stash.py
+++ b/shared/stash.py
@@ -215,6 +215,7 @@ class SensitiveValues:
self.deltamode = False
self.mode, self.raw, self.node = SecretStash.decode(self.secret, self._bip39pw)
+ self.spots.append(self.secret)
else:
# More typical: fetch the secret from bootloader and SE
# - but that's real slow, so avoid if possible
diff --git a/shared/teleport.py b/shared/teleport.py
index 5ebcaba..b45ba20 100644
--- a/shared/teleport.py
+++ b/shared/teleport.py
@@ -343,17 +343,20 @@ async def kt_accept_values(dtype, raw):
# This will take over UX w/ the signing process
# flags=None --> whether to finalize is decided based on psbt.is_complete
- sign_transaction(psbt_len, flags=None)
+ sign_transaction(psbt_len, flags=None, input_method="kt")
return
elif dtype == 'b':
# full system backup, including master: text lines
from backups import text_bk_parser, restore_tmp_from_dict_ll, restore_from_dict, extract_raw_secret
- vals = text_bk_parser(raw)
- assert vals # empty?
-
- raw_sec, _ = extract_raw_secret(vals)
+ try:
+ vals = text_bk_parser(raw)
+ assert vals # empty?
+ raw_sec, _ = extract_raw_secret(vals)
+ except Exception as e:
+ await ux_show_story("Invalid backup\n\n" + str(e), title='FAILED')
+ return
from flow import has_secrets
@@ -638,7 +641,7 @@ class SecretPickerMenu(MenuSystem):
await kt_do_send(self.rx_pubkey, 's', raw=raw)
-async def kt_send_psbt(psbt, psbt_len):
+async def kt_send_psbt(psbt, psbt_len, psbt_offset):
# We just finished adding our signature to an incomplete PSBT.
# User wants to send to one or more other senders for them to complete signing.
@@ -653,10 +656,8 @@ async def kt_send_psbt(psbt, psbt_len):
await ux_show_story("No more signers?")
return
- # move out of PSRAM
- from auth import TXN_OUTPUT_OFFSET
-
- with SFFile(TXN_OUTPUT_OFFSET, psbt_len) as fd:
+ # (TXN_OUTPUT_OFFSET after signing, TXN_INPUT_OFFSET for the file-teleport path)
+ with SFFile(psbt_offset, psbt_len) as fd:
bin_psbt = fd.read(psbt_len)
my_xfp = settings.get('xfp')
@@ -684,12 +685,12 @@ async def kt_send_psbt(psbt, psbt_len):
f = None
if x in need:
# we haven't signed ourselves yet, so allow that
- from auth import sign_transaction, TXN_INPUT_OFFSET
+ from auth import sign_transaction
async def sign_now(*a):
# this will reset the UX stack:
# flags=None --> whether to finalize is decided based on psbt.is_complete
- sign_transaction(psbt_len, flags=None)
+ sign_transaction(psbt_len, flags=None, input_method="kt", offset=psbt_offset)
f = sign_now
@@ -781,6 +782,6 @@ async def kt_send_file_psbt(*a):
await ux_show_story("We are not part of this multisig wallet.", "Cannot Teleport PSBT")
return
- await kt_send_psbt(psbt, psbt_len=psbt_len)
+ await kt_send_psbt(psbt, psbt_len=psbt_len, psbt_offset=TXN_INPUT_OFFSET)
# EOF
diff --git a/shared/trick_pins.py b/shared/trick_pins.py
index 90c3b20..56446d9 100644
--- a/shared/trick_pins.py
+++ b/shared/trick_pins.py
@@ -398,7 +398,7 @@ class TrickPinMgmt:
continue
if flags & TC_DELTA_MODE:
- prob, _ = validate_delta_pin(true_pin, pin)
+ prob, arg = validate_delta_pin(true_pin, pin)
if prob:
# just forget it, no UI here to report issue
continue
diff --git a/shared/usb.py b/shared/usb.py
index 587ad68..1f48a86 100644
--- a/shared/usb.py
+++ b/shared/usb.py
@@ -532,7 +532,7 @@ class USBHandler:
assert 50 < txn_len <= MAX_TXN_LEN, "badlen"
from auth import sign_transaction
- sign_transaction(txn_len, (flags & STXN_FLAGS_MASK), txn_sha)
+ sign_transaction(txn_len, (flags & STXN_FLAGS_MASK), txn_sha, input_method="usb")
return None
if cmd == 'stok' or cmd == 'bkok' or cmd == 'smok' or cmd == 'pwok':
diff --git a/shared/utils.py b/shared/utils.py
index dffb8a4..780535e 100644
--- a/shared/utils.py
+++ b/shared/utils.py
@@ -8,7 +8,7 @@ from ubinascii import hexlify as b2a_hex
from ubinascii import a2b_base64, b2a_base64
from charcodes import OUT_CTRL_ADDRESS, OUT_CTRL_NOWRAP
from uhashlib import sha256
-from public_constants import MAX_PATH_DEPTH, AF_CLASSIC
+from public_constants import MAX_PATH_DEPTH, AF_CLASSIC, AF_P2SH, AF_P2WPKH, AF_P2WSH, AF_P2TR
B2A = lambda x: str(b2a_hex(x), 'ascii')
@@ -193,29 +193,18 @@ def str2xfp(txt):
# Inverse of xfp2str
return ustruct.unpack('<I', a2b_hex(txt))[0]
-def is_ascii(s):
- if len(s) == len(s.encode()):
- return True
- return False
-
-def is_printable(s):
- PRINTABLE = range(32, 127)
- for ch in s:
- if ord(ch) not in PRINTABLE:
- return False
- return True
-def to_ascii_printable(s, strip=False, only_printable=True):
+def to_ascii_printable(s, only_printable=True):
try:
- s = str(s, 'ascii')
- if strip:
- s = s.strip()
- assert is_ascii(s)
- if only_printable:
- assert is_printable(s)
+ # s must be a string!
+ # in relaxed mode allow \n and \t; reject other C0 controls / DEL
+ extra = b'' if only_printable else b'\t\n'
+ for o in s.encode('ascii'):
+ assert 32 <= o <= 126 or (o in extra)
return s
except:
- raise AssertionError("must be ascii" + (" printable" if only_printable else ""))
+ err = "must be ascii printable" + ("" if only_printable else ", tab, or newline")
+ raise AssertionError(err)
def problem_file_line(exc):
@@ -252,7 +241,7 @@ def cleanup_deriv_path(bin_path, allow_star=False):
# - do not assume /// is m/0/0/0
# - if allow_star, then final position can be * or *h (wildcard)
- s = to_ascii_printable(bin_path, strip=True).lower()
+ s = to_ascii_printable(str(bin_path, "ascii").strip()).lower()
# empty string is valid
if s == '': return 'm'
@@ -691,6 +680,35 @@ def decode_bip21_text(got):
raise ValueError('not bip-21')
+def validate_own_address(addr):
+ ch = chains.current_chain()
+ addr_l = addr.lower()
+
+ if addr_l[:3] in ("bc1", "tb1") or addr_l[:5] == 'bcrt1':
+ try:
+ hrp, witver, data = ngu.codecs.segwit_decode(addr)
+
+ assert hrp == ch.bech32_hrp
+ assert witver == 0
+ if len(data) == 20:
+ return addr_l, AF_P2WPKH
+ if len(data) == 32:
+ return addr_l, AF_P2WSH
+ except: pass
+
+ # Bitcoin main/test/reg base58 address prefixes.
+ elif addr and addr[0] in '123mn':
+ try:
+ raw = ngu.codecs.b58_decode(addr)
+ assert len(raw) == 21
+ if raw[0] == ch.b58_addr[0]:
+ return addr, AF_CLASSIC
+ if raw[0] == ch.b58_script[0]:
+ return addr, AF_P2SH
+ except: pass
+
+ assert False, ch.name
+
def encode_seed_qr(words):
return ''.join('%04d' % bip39.get_word_index(w) for w in words)
diff --git a/shared/ux_q1.py b/shared/ux_q1.py
index 768d1ca..579b941 100644
--- a/shared/ux_q1.py
+++ b/shared/ux_q1.py
@@ -121,7 +121,7 @@ async def ux_enter_number(prompt, max_value, can_cancel=True, value=''):
dis.text(0, 4, ' '*CHARS_W)
elif ch == KEY_CANCEL:
if can_cancel:
- # quit if they press X on empty screen
+ # quit if they press CANCEL on any screen
return None
elif '0' <= ch <= '9':
if len(value) == max_w:
@@ -578,7 +578,7 @@ def ux_draw_words(y, num_words, words):
if num_words == 12:
# luxious space after colon
msg = ('%2d: ' % n) + word
- x_off = 3
+ x_off = 4
else:
if n <= n_per_c:
# no space in front of 1: thru N: in leftmost column of 3
@@ -667,7 +667,7 @@ async def seed_word_entry(prompt, num_words, has_checksum=True, done_cb=None, li
what, vals = decode_qr_result(got, expect_secret=True)
except QRDecodeExplained as e:
err_msg = str(e)
- redraw_words()
+ redraw_words(words)
continue
if what != "words":
@@ -881,7 +881,7 @@ class QRScannerInteraction:
file_type, _, data = decode_qr_result(got, expect_bbqr=True)
if file_type == 'U':
data = data.strip()
- if data[0] == '{' and data[-1] == '}':
+ if data[:1] == b'{' and data[-1:] == b'}':
file_type = 'J'
if file_type != 'J':
raise QRDecodeExplained('Expected JSON data')
@@ -1057,7 +1057,7 @@ async def qr_psbt_sign(decoder, psbt_len, raw):
psbt_len = total
else:
- with SFFile(TXN_INPUT_OFFSET, max_size=psbt_len) as out:
+ with SFFile(TXN_INPUT_OFFSET, length=psbt_len) as out:
taste = out.read(10)
_, output_encoder, _ = psbt_encoding_taster(taste, psbt_len)
@@ -1109,20 +1109,22 @@ async def ux_visualize_bip21(proto, addr, args):
# - imho, a bare address is a valid BIP-21 URL so we come here too
# - validate address ownership on request
from ux import ux_show_story
+ from chains import current_chain
msg = show_single_address(addr) + '\n\n'
args = args or {}
if 'amount' in args:
- msg += 'Amount: '
try:
amt = args.pop('amount')
- whole, frac = amt.split('.', 1)
- frac = int(frac) if frac else 0
- whole = int(whole) if whole else 0
- msg += '%d.%08d BTC\n' % (whole, frac)
+ whole, _, frac = amt.partition('.')
+ assert whole.isdigit()
+ assert len(whole) <= 8
+ assert len(frac) <= 8
+ sats = int((whole or '0') + (frac + '00000000')[:8])
+ msg += 'Amount: %s %s\n' % current_chain().render_value(sats)
except:
- msg += '(corrupt)\n'
+ msg += 'Amount: (corrupt)\n'
for fn in ['label', 'message', 'lightning']:
if fn in args:
@@ -1199,7 +1201,6 @@ async def show_bbqr_codes(type_code, data, msg, already_hex=False):
from ux import ux_wait_keydown
import uqr
- assert not PSRAM.is_at(data, 0) # input data would be overwritten with our work
assert type_code in TYPE_LABELS
dis.fullscreen('Generating BBQr...', .1)
diff --git a/shared/wif.py b/shared/wif.py
index cc1d2bb..aca772c 100644
--- a/shared/wif.py
+++ b/shared/wif.py
@@ -12,6 +12,7 @@ from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL
from public_constants import AF_P2WPKH
from msgsign import msg_signing_done
+MAX_ITEMS = 30
def decode_wif(wif):
# Decode base58 encoded WIF string, return keypair and metadata
@@ -33,7 +34,7 @@ def decode_wif(wif):
return kp, testnet, compressed
-def iter_wif_store_addresses(chain, addr_fmt):
+def iter_wif_store_addresses(addr_fmt):
# nothing found among singlesig & registered multisig wallets
# check WIF store
wifs = settings.get("wifs", [])
@@ -41,7 +42,37 @@ def iter_wif_store_addresses(chain, addr_fmt):
for i, (pk, sk) in enumerate(wifs):
node = node_from_pubkey(a2b_hex(pk))
- yield i, chain.address(node, addr_fmt)
+ yield i, chains.current_chain().address(node, addr_fmt)
+
+
+def save_wif_store_items(new_wifs):
+ saved = settings.get("wifs", [])
+ len_saved = len(saved)
+ unique = []
+ dups = 0
+
+ for item in new_wifs:
+ if item in unique:
+ continue
+
+ if item not in saved:
+ unique.append(item)
+ else:
+ dups += 1
+
+ err = ("No valid WIF key found." + (" Contains duplicate WIF(s)" if dups else ""))
+ assert unique, err
+
+ err = ("Max %d items allowed in WIF Store.\n\nAttempted to import %d keys,"
+ " while remaining WIF store capacity is only %d. Please, make room"
+ " first." % (MAX_ITEMS, len(unique), MAX_ITEMS - len_saved))
+ assert (len_saved + len(unique)) <= MAX_ITEMS, err
+
+ saved.extend(unique)
+ settings.set('wifs', saved)
+ settings.save()
+
+ return len(unique)
async def ux_visualize_wif(wif_str, kp, compressed, testnet):
@@ -58,21 +89,18 @@ async def ux_visualize_wif(wif_str, kp, compressed, testnet):
ch = await ux_show_story(msg, title="WIF Key", escape=esc)
if ch == "1":
- saved = settings.get("wifs", [])
- if (pk, sk) in saved:
- await ux_show_story("Already saved in WIF Store.", title="Failure")
- return
-
- saved.append((pk, sk))
- settings.set('wifs', saved)
- settings.save()
+ title = "Success"
+ try:
+ save_wif_store_items([(pk, sk)])
+ msg = "Saved to WIF Store."
+ except Exception as e:
+ title = "Failure"
+ msg = str(e)
- await ux_show_story("Saved to WIF Store.", title="Success")
+ await ux_show_story(msg, title=title)
class WIFStore(MenuSystem):
- MAX_ITEMS = 30
-
def __init__(self):
items = self.construct()
super().__init__(items)
@@ -104,7 +132,7 @@ class WIFStore(MenuSystem):
items = []
- if len(wifs) < self.MAX_ITEMS:
+ if len(wifs) < MAX_ITEMS:
items.append(MenuItem('Import WIF', f=self.import_wif, predicate=not_hobbled_mode))
a_items = []
@@ -299,12 +327,8 @@ class WIFStore(MenuSystem):
# allow commas, spaces, and newlines as separators
got = got.replace(',', ' ').split()
- saved = settings.get("wifs", [])
- len_saved = len(saved)
-
try:
new_wifs = []
- dups = 0
for here in got:
here = here.strip()
@@ -323,28 +347,10 @@ class WIFStore(MenuSystem):
sk = b2a_hex(kp.privkey()).decode()
pk = b2a_hex(kp.pubkey().to_bytes()).decode()
- item = (pk, sk)
- if item in new_wifs:
- # duplicate in import content
- continue
-
- if item in saved: # ignore dups
- dups += 1
- else:
- new_wifs.append(item)
+ new_wifs.append((pk, sk))
- assert new_wifs, 'no valid WIF found' if not dups else 'duplicate WIF(s)'
+ save_wif_store_items(new_wifs)
- if (len_saved + len(new_wifs)) > self.MAX_ITEMS:
- await ux_show_story("Max %d items allowed in WIF Store.\n\nAttempted to import %d keys,"
- " while remaining WIF store capacity is only %d. Please, make room"
- " first." % (self.MAX_ITEMS, len(new_wifs), self.MAX_ITEMS - len_saved),
- title="Failure")
- return
-
- saved.extend(new_wifs)
- settings.set('wifs', saved)
- settings.save()
self.update_contents()
except Exception as e:
diff --git a/shared/xor_seed.py b/shared/xor_seed.py
index 0b2663c..211227d 100644
--- a/shared/xor_seed.py
+++ b/shared/xor_seed.py
@@ -124,7 +124,7 @@ You have confirmed the details of the new split.''')
# - stores encoded secret bytes (not word lists)
import_xor_parts = []
-async def xor_all_done(data):
+async def xor_all_done(data, force_tmp, done_cb):
# So we have another part, might be done or not.
global import_xor_parts
@@ -178,9 +178,9 @@ async def xor_all_done(data):
if version.has_qwerty:
from ux_q1 import seed_word_entry
await seed_word_entry("Part %s Words" % chr(65+len(import_xor_parts)),
- target_words, done_cb=xor_all_done)
+ target_words, done_cb=done_cb)
else:
- nxt = XORWordNestMenu(num_words=target_words, done_cb=xor_all_done)
+ nxt = XORWordNestMenu(num_words=target_words, done_cb=done_cb)
the_ux.push(nxt)
elif ch == '2':
@@ -190,7 +190,7 @@ async def xor_all_done(data):
enc = SecretStash.encode(seed_phrase=seed)
- if pa.is_secret_blank():
+ if pa.is_secret_blank() and not force_tmp:
# save it since they have no other secret
set_seed_value(encoded=enc)
# update menu contents now that wallet defined
@@ -239,7 +239,7 @@ async def show_n_parts(parts, chk_word):
return await ux_show_story(msg, title="Record these:", sensitive=True, escape="4",
hint_icons=KEY_QR)
-async def xor_restore_start(*a):
+async def xor_restore_start(*a, force_tmp=False):
# shown on import menu when no seed of any kind yet
# - or operational system
ch = await ux_show_story('''\
@@ -261,6 +261,9 @@ or press (2) for 18 words XOR.''' % OK, escape="12")
global import_xor_parts
import_xor_parts.clear()
+ async def done_cb(data):
+ return await xor_all_done(data, force_tmp=force_tmp, done_cb=done_cb)
+
from pincodes import pa
from glob import dis
@@ -317,14 +320,17 @@ or press (2) for 18 words XOR.''' % OK, escape="12")
if selected:
import_xor_parts += [opt[i][-1] for i in range(len(opt)) if i in selected]
- return await xor_all_done(None)
+ return await done_cb(None)
if version.has_qwerty:
from ux_q1 import seed_word_entry
# if current loaded seed is added to xor - it is always A
await seed_word_entry("Part %s Words" % (chr(65+len(import_xor_parts))),
- desired_num_words, done_cb=xor_all_done)
+ desired_num_words, done_cb=done_cb)
else:
- return XORWordNestMenu(num_words=desired_num_words, done_cb=xor_all_done)
+ return XORWordNestMenu(num_words=desired_num_words, done_cb=done_cb)
+
+async def xor_restore_temporary(*a):
+ return await xor_restore_start(*a, force_tmp=True)
# EOF
diff --git a/testing/bip322.py b/testing/bip322.py
index 32ed216..1ffa645 100644
--- a/testing/bip322.py
+++ b/testing/bip322.py
@@ -205,7 +205,7 @@ def bip322_ms_txn(pytestconfig, create_msg_file):
for pubkey, xfp_path in details:
psbt.inputs[i].bip32_paths[pubkey] = b''.join(struct.pack('<I', j) for j in xfp_path)
- if with_sigs and (xfp_path[0] != keys[-1][0]): # only cosigner signatures are added
+ if with_sigs and (xfp_path[0] != keys[-1][0]) and len(psbt.inputs[i].part_sigs) < (M-1): # only cosigner signatures are added
psbt.inputs[i].part_sigs[pubkey] = b"\x30" + 70*b"a"
if i == 0:
diff --git a/testing/conftest.py b/testing/conftest.py
index f66deee..501422f 100644
--- a/testing/conftest.py
+++ b/testing/conftest.py
@@ -2643,7 +2643,8 @@ def txin_explorer(cap_story, press_cancel, need_keypress, is_q1, cap_menu,
time.sleep(.1)
title, story = cap_story()
ss = story.split("\n\n")
- assert "Press RIGHT to see next group" in ss[-1]
+ if i < (num_inputs - 1):
+ assert "RIGHT to see next group" in ss[-1]
if i:
assert " LEFT to go back" in ss[-1]
else:
@@ -2718,7 +2719,8 @@ def txout_explorer(cap_story, press_cancel, need_keypress, is_q1, verify_qr_addr
_, story = cap_story()
ss = story.split("\n\n")
assert len(ss) == (len(d) * 2) + 1
- assert "Press RIGHT to see next group" in ss[-1]
+ if (i + n) < len(data):
+ assert "RIGHT to see next group" in ss[-1]
if i:
assert " LEFT to go back" in ss[-1]
else:
diff --git a/testing/login_settings_tests.py b/testing/login_settings_tests.py
index 1df5e93..3652370 100644
--- a/testing/login_settings_tests.py
+++ b/testing/login_settings_tests.py
@@ -700,6 +700,52 @@ def test_sssp_bypass_pin(request, word_check, randomize):
device.close()
+def test_sssp_bypass_pin_alone_no_login(request):
+ main_pin = "22-22"
+ bypass_pin = "111-111"
+ is_Q = request.config.getoption('--Q')
+
+ clean_sim_data()
+ sim = ColdcardSimulator(args=["--q1"] if is_Q else [])
+ sim.start(start_wait=6)
+ device = ColdcardDevice(is_simulator=True)
+
+ _pick_menu_item(device, is_Q, "Advanced/Tools")
+ _pick_menu_item(device, is_Q, "Spending Policy")
+ _pick_menu_item(device, is_Q, "Single-Signer")
+ _press_select(device, is_Q)
+ _login(device, is_Q, bypass_pin)
+ _login(device, is_Q, bypass_pin)
+ time.sleep(2)
+ sim.stop()
+ device.close()
+
+ sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", main_pin, "--early-usb"])
+ sim.start(start_wait=6)
+ device = ColdcardDevice(is_simulator=True)
+
+
+ _login(device, is_Q, bypass_pin)
+ time.sleep(.1)
+ _, story = _cap_story(device)
+ assert "Spending Policy Unlock" in story
+ _press_select(device, is_Q)
+ time.sleep(.1)
+ _login(device, is_Q, bypass_pin) # bypass PIN a 2nd time, instead of main PIN
+ time.sleep(1.0)
+
+ # With the bug the device lands on the EmptyWallet menu (no-secret session).
+ # With the fix the zero-secret PIN is rejected and login does not complete.
+ scr = _cap_screen(device)
+ assert "New Seed Words" not in scr
+ assert "Import Existing" not in scr
+
+ assert "provide Main PIN" in scr
+
+ sim.stop()
+ device.close()
+
+
def test_sssp_login_countdown(request):
bypass_pin = "236-156"
is_Q = request.config.getoption('--Q')
diff --git a/testing/seedless_tests.py b/testing/seedless_tests.py
index 4920bb8..1e5dee5 100644
--- a/testing/seedless_tests.py
+++ b/testing/seedless_tests.py
@@ -1,14 +1,14 @@
# (c) Copyright 2024 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
import pytest, pdb, time, random, os
-from charcodes import KEY_CANCEL
+from charcodes import KEY_CANCEL, KEY_QR
from core_fixtures import _pick_menu_item, _press_select
-from core_fixtures import _need_keypress, _sim_exec
+from core_fixtures import _need_keypress, _sim_exec, _cap_story
from run_sim_tests import ColdcardSimulator, clean_sim_data
from ckcc_protocol.client import ColdcardDevice
-def test_status_bar_rewrite_after_restore_master(request):
+def test_status_bar_rewrite_after_restore_master():
from PIL import Image
clean_sim_data() # remove all from previous
sim = ColdcardSimulator(args=["--q1", "-l"])
@@ -37,4 +37,24 @@ def test_status_bar_rewrite_after_restore_master(request):
rv1 = Image.open(fn1)
rv0.show()
rv1.show()
- sim.stop()
\ No newline at end of file
+ sim.stop()
+
+
+def test_seedless_qr_import_bad_checksum():
+ clean_sim_data()
+ sim = ColdcardSimulator(args=["--q1", "-l"])
+ sim.start(start_wait=3)
+ device = ColdcardDevice(is_simulator=True)
+ try:
+ _need_keypress(device, KEY_QR)
+ time.sleep(.3)
+
+ # Inject a bad-checksum SeedQR via the simulator's scan queue
+ bad_seed = '0000' * 12
+ _sim_exec(device, 'glob.SCAN._q.put_nowait(%r)' % bad_seed.encode())
+ time.sleep(.5)
+
+ title, story = _cap_story(device)
+ assert 'checksum fail' in story
+ finally:
+ sim.stop()
\ No newline at end of file
diff --git a/testing/test_ccc.py b/testing/test_ccc.py
index cd5b3ac..3b75be3 100644
--- a/testing/test_ccc.py
+++ b/testing/test_ccc.py
@@ -14,7 +14,7 @@ from pysecp256k1 import ec_seckey_verify, ec_pubkey_parse, ec_pubkey_serialize,
from mnemonic import Mnemonic
from bip32 import BIP32Node
from constants import AF_P2WSH
-from charcodes import KEY_QR
+from charcodes import KEY_QR, KEY_NFC
from bbqr import split_qrs
from psbt import BasicPSBT
@@ -702,6 +702,52 @@ def test_ccc_whitelist(whitelist_ok, setup_ccc, ccc_ms_setup,
policy_sign(bitcoind_wo, psbt, violation=None if whitelist_ok else "whitelist")
+def test_ccc_whitelist_nfc_import(setup_ccc, settings_set, pick_menu_item, cap_story,
+ cap_menu, press_select, press_nfc, nfc_write_text,
+ settings_get, skip_if_useless_way, is_q1):
+ skip_if_useless_way("nfc")
+
+ settings_set("ccc", None)
+ addr = "bcrt1qlk39jrclgnawa42tvhu2n7se987qm96qg8v76e"
+
+ setup_ccc(vel="Unlimited")
+
+ pick_menu_item("Spending Policy")
+ pick_menu_item("Whitelist Addresses" if is_q1 else "Whitelist")
+
+ time.sleep(.1)
+ m = cap_menu()
+ assert "(none yet)" in m
+ assert "Import from File" in m
+
+ pick_menu_item("Import from File")
+ time.sleep(.1)
+ _, story = cap_story()
+
+ if f"press {KEY_NFC if is_q1 else '(3)'} to import via NFC" not in story:
+ pytest.xfail("NFC disabled")
+
+ press_nfc()
+ time.sleep(.2)
+ nfc_write_text(addr)
+ time.sleep(.3)
+
+ _, story = cap_story()
+ assert "Added new address to whitelist" in story
+ assert addr in story
+
+ press_select()
+ time.sleep(.1)
+
+ m = cap_menu()
+ mi_addrs = [a for a in m if '⋯' in a]
+ assert len(mi_addrs) == 1
+ _start, _end = mi_addrs[0].split('⋯')
+ assert addr.startswith(_start)
+ assert addr.endswith(_end)
+ assert settings_get("ccc")["pol"]["addrs"] == [addr]
+
+
@pytest.mark.bitcoind
@pytest.mark.parametrize("velocity_mi", ['6 blocks (hour)', '48 blocks (8h)'])
def test_ccc_velocity(velocity_mi, setup_ccc, ccc_ms_setup, bitcoind, settings_set,
@@ -930,6 +976,72 @@ def test_maxed_out(settings_set, setup_ccc, enter_enabled_ccc, ccc_ms_setup, sim
restore_main_seed()
+@pytest.mark.bitcoind
+def test_ccc_whitelist_overlimit_no_mutation(settings_set, setup_ccc, enter_enabled_ccc,
+ ccc_ms_setup, bitcoind_create_watch_only_wallet,
+ settings_get, pick_menu_item, cap_menu, cap_story,
+ cap_screen, scan_a_qr, press_select, press_cancel,
+ is_q1, microsd_path, need_keypress, restore_main_seed):
+ # An over-limit whitelist import must be rejected WITHOUT having already
+ # mutated the (settings-backed) policy address list.
+ settings_set("ccc", None)
+ settings_set("chain", "XRT")
+ settings_set("multisig", [])
+
+ c_words = "cluster comic depend absent grain circle demand tag pass clock certain strategy lunar bless pulse useful comfort fatigue glove decorate taste allow adult journey".split()
+ setup_ccc(c_words=c_words, mag=100000000, vel=None, whitelist=None)
+ b_words = "ceiling apology excite illegal accident define boat prosper decrease utility romance try trial dizzy win lawsuit much sustain similar meadow draw oil cousin wagon".split()
+ _, target_mi = ccc_ms_setup(b_words=b_words)
+ bitcoind_wo = bitcoind_create_watch_only_wallet(target_mi)
+
+ enter_enabled_ccc(c_words)
+ desc_str = bitcoind_wo.listdescriptors()["descriptors"][0]["desc"]
+ addrs = bitcoind_wo.deriveaddresses(desc_str, (0, 25))
+ base, extra = addrs[:24], addrs[24:26]
+
+ setup_ccc(c_words, whitelist=base, first_time=False)
+ assert len(settings_get("ccc")["pol"]["addrs"]) == 24
+
+ # back at the CCC menu now -- import 2 more
+ pick_menu_item("Spending Policy")
+ pick_menu_item("Whitelist Addresses" if is_q1 else "Whitelist")
+ time.sleep(.1)
+ if is_q1:
+ pick_menu_item("Scan QR")
+ for i, a in enumerate(extra, start=1):
+ scan_a_qr(a)
+ for _ in range(10):
+ scr = cap_screen()
+ if (f"Got {i} so far" in scr) and ("ENTER to apply" in scr):
+ break
+ time.sleep(.2)
+ else:
+ assert False, "scan not registered"
+ press_select()
+ else:
+ fname = "ccc_over.txt"
+ with open(microsd_path(fname), "w") as f:
+ for a in extra:
+ f.write(a + "\n")
+ pick_menu_item("Import from File")
+ time.sleep(.1)
+ _, story = cap_story()
+ if "Press (1)" in story:
+ need_keypress("1")
+ pick_menu_item(fname)
+
+ time.sleep(.2)
+ _, story = cap_story()
+ assert "Max %d items in whitelist" % 25 in story
+ press_select()
+
+ assert settings_get("ccc")["pol"]["addrs"] == base
+
+ press_cancel()
+ press_cancel()
+ restore_main_seed()
+
+
@pytest.mark.parametrize("seed_vault", [True, False])
def test_load_and_sign_key_C(settings_set, setup_ccc, enter_enabled_ccc, ccc_ms_setup, sim_exec,
bitcoind_create_watch_only_wallet, pick_menu_item, load_export,
@@ -1269,4 +1381,103 @@ def test_ms_setup_cosigner_import(way, ftype, is_bbqr, N, goto_home, settings_se
for _, obj in keys:
assert f"[{obj['xfp'].lower()}/{obj['p2wsh_deriv'].replace('m/', '')}]{obj['p2wsh']}" in desc
+
+def test_ccc_challenge_qr_bad_checksum_crash(setup_ccc, goto_ccc_menu, cap_story, need_keypress,
+ press_select, press_cancel, scan_a_qr, sim_exec,
+ settings_set, is_q1):
+ if not is_q1:
+ pytest.skip('Q1 only (QR scan path)')
+
+ settings_set('ccc', None)
+ settings_set('seedvault', False) # avoid seed-vault bypass path
+
+ setup_ccc()
+
+ # reset the fail counter so the assertion below is unambiguous
+ sim_exec('import ccc; ccc.NUM_CHALLENGE_FAILS = 0')
+
+ goto_ccc_menu()
+ time.sleep(.1)
+ title, story = cap_story()
+ assert title == 'CCC Enabled'
+ assert 'policy cannot be viewed' in story
+ press_select()
+ time.sleep(.1)
+
+ need_keypress(KEY_QR)
+ time.sleep(.1)
+
+ # SeedQR with 12 zero-indices = "abandon" * 12 = wordlist-valid but
+ # consensus-invalid BIP-39 checksum
+ bad_seed_qr = '0000' * 12
+ scan_a_qr(bad_seed_qr)
+ time.sleep(.5)
+ press_select()
+
+ title, story = cap_story()
+ assert 'Sorry, those words are incorrect' in story
+
+ # The challenge callback must have been reached -- counter stays 1.
+ fails = int(sim_exec('import ccc; RV.write(str(ccc.NUM_CHALLENGE_FAILS))'))
+ assert fails == 1
+
+ press_cancel()
+ press_cancel()
+
+
+def test_ccc_magnitude_cancel_preserves_value(setup_ccc, enter_enabled_ccc, settings_set,
+ settings_get, pick_menu_item, cap_menu,
+ press_select, press_cancel, press_delete):
+ settings_set('ccc', None)
+
+ c_words = setup_ccc(mag=1) # 1 BTC magnitude
+ assert settings_get('ccc')['pol']['mag'] == 1
+
+ enter_enabled_ccc(c_words)
+ pick_menu_item('Spending Policy')
+ pick_menu_item('Max Magnitude')
+ time.sleep(.1)
+
+ press_delete() # delete 1
+ time.sleep(.1)
+ press_cancel()
+ time.sleep(.1)
+
+ menu = cap_menu()
+ # back in the menu, CANCEL on empty value
+ assert 'Max Magnitude' == menu[0]
+
+ # magnitude unchanged
+ time.sleep(.1)
+ mag = settings_get('ccc')['pol']['mag']
+ assert mag == 1
+
+ settings_set('ccc', None)
+
+
+@pytest.mark.bitcoind
+def test_ccc_whitelist_op_return(setup_ccc, ccc_ms_setup, bitcoind, settings_set,
+ policy_sign, bitcoind_create_watch_only_wallet):
+ settings_set("ccc", None)
+ settings_set("chain", "XRT")
+ settings_set("multisig", [])
+
+ whitelist = ["bcrt1qqca9eefwz8tzn7rk6aumhwhapyf5vsrtrddxxp"]
+ setup_ccc(whitelist=whitelist, vel="Unlimited")
+ _, target_mi = ccc_ms_setup()
+ bitcoind_wo = bitcoind_create_watch_only_wallet(target_mi)
+
+ multi_addr = bitcoind_wo.getnewaddress()
+ bitcoind.supply_wallet.sendtoaddress(address=multi_addr, amount=5.0)
+ bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
+
+ op_return_data = b"Coldcard CCC OP_RETURN test"
+ send_to = whitelist[0]
+ psbt_resp = bitcoind_wo.walletcreatefundedpsbt(
+ [], [{send_to: 1}, {"data": op_return_data.hex()}], 0, {"fee_rate": 2}
+ )
+ psbt = psbt_resp.get("psbt")
+
+ policy_sign(bitcoind_wo, psbt, violation="whitelist")
+
# EOF
diff --git a/testing/test_ephemeral.py b/testing/test_ephemeral.py
index bd21407..3e1f173 100644
--- a/testing/test_ephemeral.py
+++ b/testing/test_ephemeral.py
@@ -512,6 +512,28 @@ def test_ephemeral_seed_generate(num_words, generate_ephemeral_words, dice,
restore_main_seed(preserve_settings)
+def test_ephemeral_seed_import_qr_bad_checksum(reset_seed_words, goto_eph_seed_menu,
+ pick_menu_item, scan_a_qr, cap_story,
+ press_cancel, is_q1):
+ if not is_q1:
+ pytest.skip('Q1 only (QR scan path)')
+
+ reset_seed_words()
+ goto_eph_seed_menu()
+ pick_menu_item('Import from QR Scan')
+ time.sleep(.1)
+
+ # SeedQR with 12 zero-indices = "abandon" * 12, wordlist-valid but
+ # consensus-invalid BIP-39 checksum.
+ scan_a_qr('0000' * 12)
+ time.sleep(.5)
+
+ title, story = cap_story()
+ assert 'checksum fail' in story
+ press_cancel()
+ press_cancel()
+
+
@pytest.mark.parametrize("num_words", [12, 18, 24])
@pytest.mark.parametrize("way", ["input", "nfc", "qr"])
@pytest.mark.parametrize("truncated", [False, True])
diff --git a/testing/test_msg.py b/testing/test_msg.py
index 03b7aee..8569486 100644
--- a/testing/test_msg.py
+++ b/testing/test_msg.py
@@ -2,7 +2,7 @@
#
# Message signing.
#
-import pytest, time, os, itertools, hashlib, json
+import pytest, time, os, itertools, hashlib, json, random
from bip32 import BIP32Node
from msg import verify_message, RFC_SIGNATURE_TEMPLATE, sign_message, parse_signed_message
from base64 import b64encode, b64decode
@@ -11,6 +11,7 @@ from ckcc_protocol.constants import *
from constants import addr_fmt_names, msg_sign_unmap_addr_fmt
from charcodes import KEY_QR, KEY_NFC
from helpers import addr_from_display_format
+from bbqr import split_qrs
def addr_fmt_from_subpath(subpath):
@@ -543,7 +544,69 @@ def test_sign_msg_fails(dev, sign_on_microsd, msg, subpath, addr_fmt, concern,
assert concern in story
-@pytest.mark.parametrize('msg,num_iter,expect', [
+def test_sign_msg_malformed_json_subpath_type(open_microsd, microsd_path, goto_home,
+ pick_menu_item, cap_story, press_cancel):
+ fname = 't-msgsign-bad.json'
+
+ try: os.unlink(microsd_path(fname))
+ except OSError: pass
+
+ with open_microsd(fname, 'wt') as sd:
+ sd.write(json.dumps({"msg": "hello", "subpath": 84}))
+
+ goto_home()
+ pick_menu_item('Advanced/Tools')
+ pick_menu_item('File Management')
+ pick_menu_item('Sign Text File')
+ time.sleep(.1)
+ pick_menu_item(fname)
+ time.sleep(.1)
+
+ title, story = cap_story()
+ assert not story.startswith('Ok to sign this?')
+ assert story.startswith('Problem: subpath')
+ press_cancel()
+
+ with open_microsd(fname, 'wt') as sd:
+ sd.write(json.dumps({"msg": "hello", "addr_fmt": 8}))
+
+ pick_menu_item('Sign Text File')
+ time.sleep(.1)
+ pick_menu_item(fname)
+ time.sleep(.1)
+
+ title, story = cap_story()
+ assert not story.startswith('Ok to sign this?')
+ assert story.startswith('Problem: Invalid address format')
+ press_cancel()
+
+
+def test_sign_msg_json_rejects_ui_control_chars(open_microsd, microsd_path,
+ goto_home, pick_menu_item, cap_story):
+ # JSON message-sign relaxes printable validation to allow \n and \t, but
+ # other C0 control bytes (e.g. \x01) must still be rejected
+ fname = 't-msgsign-ctrl.json'
+ try: os.unlink(microsd_path(fname))
+ except OSError: pass
+
+ with open_microsd(fname, 'wt') as sd:
+ sd.write(json.dumps({"msg": "\x01CONFIRM SEND\nrealmsg", "subpath": "m"}))
+
+ goto_home()
+ pick_menu_item('Advanced/Tools')
+ pick_menu_item('File Management')
+ pick_menu_item('Sign Text File')
+ time.sleep(.1)
+ pick_menu_item(fname)
+ time.sleep(.2)
+
+ title, story = cap_story()
+ assert not story.startswith('Ok to sign this?')
+ assert story.startswith('Problem: ')
+ assert 'must be ascii printable, tab, or newline' in story
+
+
+@pytest.mark.parametrize('msg,num_iter,expect', [
('Test2', 1, 'IHra0jSywF1TjIJ5uf7IDECae438cr4o3VmG6Ri7hYlDL+pUEXyUfwLwpiAfUQVqQFLgs6OaX0KsoydpuwRI71o='),
('Test', 2, 'IDgMx1ljPhLHlKUOwnO/jBIgK+K8n8mvDUDROzTgU8gOaPDMs+eYXJpNXXINUx5WpeV605p5uO6B3TzBVcvs478='),
('Test1', 3, 'IEt/v9K95YVFuRtRtWaabPVwWOFv1FSA/e874I8ABgYMbRyVvHhSwLFz0RZuO87ukxDd4TOsRdofQwMEA90LCgI='),
@@ -1021,6 +1084,36 @@ def test_sparrow_qr_sign_msg(msg, path, skip_if_useless_way, need_keypress, scan
assert res is True
+def test_sparrow_qr_sign_msg_via_bbqr(skip_if_useless_way, need_keypress, scan_a_qr,
+ cap_story, press_select, msg_sign_export,
+ addr_vs_path, verify_msg_sign_story):
+ skip_if_useless_way("qr")
+
+ path = "m/84h/0"
+ msg = "a" * 240
+ data = "signmessage %s ascii:%s" % (path, msg)
+ addr_fmt = addr_fmt_from_subpath(path)
+
+ need_keypress(KEY_QR)
+
+ _, parts = split_qrs(data, 'U', encoding='2', max_version=20)
+ random.shuffle(parts)
+ for p in parts:
+ scan_a_qr(p)
+
+ time.sleep(1)
+
+ title, story = cap_story()
+ subpath = verify_msg_sign_story(story, msg, path, addr_fmt)
+ press_select()
+
+ signed_msg = msg_sign_export("qr")
+ ret_msg, addr, sig = parse_signed_message(signed_msg)
+ assert ret_msg == msg
+ addr_vs_path(addr, subpath, addr_fmt)
+ assert verify_message(addr, sig, ret_msg) 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):
diff --git a/testing/test_multisig.py b/testing/test_multisig.py
index 266851e..244f3d6 100644
--- a/testing/test_multisig.py
+++ b/testing/test_multisig.py
@@ -3381,6 +3381,34 @@ def test_bare_cc_ms_qr_import(N, make_multisig, scan_a_qr, clear_ms, goto_home,
press_cancel()
+def test_ms_qr_import_per_cosigner_paths(make_multisig, scan_a_qr, clear_ms, goto_home,
+ pick_menu_item, cap_story, press_cancel, is_q1):
+ # this wasn't tested
+ # not needed on EDGE
+ if not is_q1:
+ raise pytest.skip("No QR support for Mk4")
+ clear_ms()
+ M, N = 2, 3
+ deriv_tmpl = "m/214748364{idx}h/" + "/".join(["2147483647h"] * 11) # 12 components
+ keys = make_multisig(M, N, deriv=deriv_tmpl)
+ config = "Name: per-path-qr\nPolicy: %d of %d\nFormat: P2WSH\n\n" % (M, N)
+ for idx, (xfp, master, sub) in enumerate(keys):
+ config += "Derivation: %s\n%s: %s\n\n" % (deriv_tmpl.format(idx=idx),
+ xfp2str(xfp), sub.hwif(as_private=False))
+
+ actual_vers, parts = split_qrs(config, 'U', max_version=20)
+ random.shuffle(parts)
+ goto_home()
+ pick_menu_item("Scan Any QR Code")
+ for p in parts:
+ scan_a_qr(p)
+ time.sleep(2.0 / len(parts))
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "Create new multisig wallet?" in story
+ press_cancel()
+
+
@pytest.mark.parametrize("desc", ["multi", "sortedmulti"])
@pytest.mark.parametrize("data", [
# (out_style, amount, is_change)
@@ -3443,6 +3471,78 @@ def test_import_duplicate_shuffled_keys_legacy(clear_ms, make_multisig, import_m
assert f'{OK} to approve' not in story
press_cancel()
+def test_import_reorder_different_name_multi(clear_ms, make_multisig, offer_ms_import,
+ settings_set, cap_story, press_select,
+ press_cancel):
+ settings_set("unsort_ms", 1)
+ clear_ms()
+ M, N = 2, 3
+ keys = make_multisig(M, N)
+
+ def build_desc(klist):
+ key_list = [(xfp, "m/45h", sk.hwif(as_private=False)) for xfp, _, sk in klist]
+ d = MultisigDescriptor(M=M, N=N, keys=key_list, addr_fmt=AF_P2WSH, is_sorted=False)
+ return d.serialize()
+
+ val_a = json.dumps({"name": "victim", "desc": build_desc(keys)})
+ title, story = offer_ms_import(val_a)
+ assert "Create new multisig" in story
+ press_select()
+ time.sleep(.1)
+
+ keys[0], keys[1] = keys[1], keys[0]
+ val_b = json.dumps({"name": "attacker", "desc": build_desc(keys)})
+ title, story = offer_ms_import(val_b)
+ assert "Update NAME only" not in story
+ assert "Duplicate wallet. key order" in story
+ press_cancel()
+
+
+@pytest.mark.parametrize("is_sorted", [True, False])
+def test_import_same_keys_same_order_rename(is_sorted, clear_ms, make_multisig, offer_ms_import,
+ settings_set, cap_story, press_select, press_cancel):
+ settings_set("unsort_ms", 1)
+ clear_ms()
+ M, N = 2, 3
+ keys = make_multisig(M, N)
+ key_list = [(xfp, "m/45h", sk.hwif(as_private=False)) for xfp, _, sk in keys]
+ desc = MultisigDescriptor(M=M, N=N, keys=key_list, addr_fmt=AF_P2WSH,
+ is_sorted=is_sorted).serialize()
+
+ title, story = offer_ms_import(json.dumps({"name": "original", "desc": desc}))
+ assert "Create new multisig" in story
+ press_select()
+ time.sleep(.1)
+
+ title, story = offer_ms_import(json.dumps({"name": "renamed", "desc": desc}))
+ assert "Update NAME only" in story
+ assert "Duplicate wallet" not in story
+ press_cancel()
+
+
+def test_import_sortedmulti_reorder_rename(clear_ms, make_multisig, offer_ms_import,
+ cap_story, press_select, press_cancel):
+ clear_ms()
+ M, N = 2, 3
+ keys = make_multisig(M, N)
+
+ def build_desc(klist):
+ key_list = [(xfp, "m/45h", sk.hwif(as_private=False)) for xfp, _, sk in klist]
+ return MultisigDescriptor(M=M, N=N, keys=key_list, addr_fmt=AF_P2WSH,
+ is_sorted=True).serialize()
+
+ title, story = offer_ms_import(json.dumps({"name": "original", "desc": build_desc(keys)}))
+ assert "Create new multisig" in story
+ press_select()
+ time.sleep(.1)
+
+ keys[0], keys[1] = keys[1], keys[0]
+ title, story = offer_ms_import(json.dumps({"name": "renamed", "desc": build_desc(keys)}))
+ assert "Update NAME only" in story
+ assert "Duplicate wallet" not in story
+ press_cancel()
+
+
@pytest.mark.parametrize("order", list(itertools.product([True, False], repeat=2)))
def test_import_duplicate_shuffled_keys(clear_ms, make_multisig, import_ms_wallet,
cap_story, press_cancel, order, OK):
@@ -4207,7 +4307,7 @@ def test_fwd_slash_in_name(import_ms_wallet, clear_ms, pick_menu_item, need_keyp
@pytest.mark.parametrize("chain", ["BTC", "XTN"])
@pytest.mark.parametrize("M_N", [(3, 5)])#, (14, 15)])
-@pytest.mark.parametrize("complete", [True, False, None])
+@pytest.mark.parametrize("complete", [False, None])
@pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh", "p2sh-p2wsh"])
def test_txin_explorer(dev, chain, M_N, addr_fmt, fake_ms_txn, start_sign, settings_set, txin_explorer,
cap_story, pytestconfig, import_ms_wallet, complete, clear_ms):
@@ -4222,9 +4322,7 @@ def test_txin_explorer(dev, chain, M_N, addr_fmt, fake_ms_txn, start_sign, setti
descriptor=True, addr_fmt=addr_fmt)
all_xfps = [xfp2str(k[0]) for k in keys][:-1] # remove myself
- if complete:
- target_xfps = all_xfps[:M]
- elif complete is False:
+ if complete is False:
target_xfps = all_xfps[:M-1]
else:
target_xfps = []
@@ -4286,4 +4384,43 @@ def test_ms_xpubs_account_cancel(goto_home, pick_menu_item, press_cancel, cap_me
press_cancel()
time.sleep(.2)
assert "Export XPUB" in cap_menu()
+
+
+@pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh", "p2sh"])
+@pytest.mark.parametrize("num_ins", [1, 10])
+@pytest.mark.parametrize("incl_self", [True, False])
+def test_fully_signed(addr_fmt, num_ins, import_ms_wallet, fake_ms_txn, start_sign, cap_story,
+ press_cancel, clear_ms, incl_self):
+ clear_ms()
+ M, N = 2, 4
+ keys = import_ms_wallet(M, N, name='fully_signed', accept=True, netcode="XTN",
+ descriptor=True, addr_fmt="p2wsh")
+
+ # both below cases include full necessary (dummy)signature set (M)
+ if incl_self:
+ i, j = 2, 4 # remove two random co-signers, keep myself as already signed
+ else:
+ i, j = 0, 2 # remove myself + one more random co-signer
+
+ xfps = [xfp2str(k[0]) for k in keys][i:j]
+
+ assert len(xfps) == M
+
+ def hack(psbt):
+ for inp in psbt.inputs:
+ for i, (pk, pth) in enumerate(inp.bip32_paths.items()):
+ xfp = pth[:4].hex().upper()
+ if xfp in xfps:
+ inp.part_sigs[pk] = os.urandom(71) # fake sig
+
+ psbt = fake_ms_txn(num_ins, 2, M, keys, inp_af=unmap_addr_fmt[addr_fmt],
+ hack_psbt=hack)
+
+ start_sign(psbt)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "Failure" == title
+ assert "completely signed already" in story
+ press_cancel()
+
# EOF
diff --git a/testing/test_notes.py b/testing/test_notes.py
index 260ffad..54437c7 100644
--- a/testing/test_notes.py
+++ b/testing/test_notes.py
@@ -520,6 +520,31 @@ def test_top_import(goto_notes, cap_menu, cap_story, need_keypress, settings_get
goto_notes()
+def test_top_import_u_typed_json(goto_notes, cap_menu, cap_story, need_keypress,
+ settings_get, settings_set, scan_a_qr):
+ settings_set('notes', [])
+
+ goto_notes('Import')
+ need_keypress(KEY_QR)
+
+ notes = {"coldcard_notes": [{"title": "demo", "misc": "x"}]}
+ jj = json.dumps(notes)
+ _, parts = split_qrs(jj, 'U', max_version=20) # deliberately U-typed
+ for p in parts:
+ scan_a_qr(p)
+
+ time.sleep(.5)
+ m = cap_menu()
+ for _ in range(3):
+ if "1:" in m[0]:
+ break
+ time.sleep(.2)
+ m = cap_menu()
+
+ assert settings_get('notes') == notes["coldcard_notes"]
+ goto_notes()
+
+
@pytest.mark.parametrize('qr,title', [
('otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30',
'ACME Co:john.doe@email.com'),
@@ -747,4 +772,26 @@ def test_sign_password_free_form(chain, change, idx, need_some_passwords, settin
pick_menu_item("Sign Note Text")
sign_msg_from_text(msg, AF_P2WPKH, None, change, idx, "qr", chain)
+
+@pytest.mark.parametrize("length", [1, 241])
+def test_sign_misc_length(length, settings_set, cap_menu, goto_notes,
+ pick_menu_item, press_cancel):
+ msg = "a" * length
+ settings_set('notes', [
+ {'misc': msg,
+ 'password': '89898989898989898989898989898',
+ 'site': 'https://abaaba.com',
+ 'title': "BA",
+ 'user': 'BABA'},
+ {'title': "AB",
+ 'misc': msg,}
+ ])
+ goto_notes()
+ pick_menu_item(f"1: BA")
+ assert "Sign Note Text" not in cap_menu()
+
+ press_cancel()
+ pick_menu_item(f"2: AB")
+ assert "Sign Note Text" not in cap_menu()
+
# EOF
diff --git a/testing/test_ownership.py b/testing/test_ownership.py
index d82c2df..5b5500b 100644
--- a/testing/test_ownership.py
+++ b/testing/test_ownership.py
@@ -268,6 +268,56 @@ def test_ux(valid, testnet, method,
assert "1 wallet(s)" in story
assert 'without finding a match' in story
+
+@pytest.mark.parametrize('addr', [
+ '7FzPuteovG12fi', # valid Base58Check, but not a payment address
+ '14h3c6cfU92', # valid Base58Check, but wrong payload length
+ 'tb1pqqqq4cagmm', # valid Bech32, but wrong chain
+ bech32_encode('tb', 1, bytes(range(32))), # valid Bech32m, but not supported here
+])
+@pytest.mark.parametrize('method', ['qr', 'nfc'])
+def test_invalid_address_ownership(addr, method, goto_home, pick_menu_item,
+ scan_a_qr, cap_story, need_keypress,
+ nfc_write, load_shared_mod, src_root_dir,
+ sim_root_dir, skip_if_useless_way, use_testnet):
+ skip_if_useless_way(method)
+ use_testnet()
+
+ if method == 'qr':
+ goto_home()
+ pick_menu_item('Scan Any QR Code')
+ scan_a_qr(addr)
+ time.sleep(1)
+
+ title, story = cap_story()
+
+ assert addr == addr_from_display_format(story.split("\n\n")[0])
+ assert '(1) to verify ownership' in story
+ need_keypress('1')
+
+ elif method == 'nfc':
+ cc_ndef = load_shared_mod('cc_ndef', f'{src_root_dir}/shared/ndef.py')
+ n = cc_ndef.ndefMaker()
+ n.add_text(addr)
+ ccfile = n.bytes()
+
+ goto_home()
+ pick_menu_item('Advanced/Tools')
+ pick_menu_item('NFC Tools')
+ pick_menu_item('Verify Address')
+ with open(f'{sim_root_dir}/debug/nfc-addr.ndef', 'wb') as f:
+ f.write(ccfile)
+ nfc_write(ccfile)
+
+
+ time.sleep(1)
+ title, story = cap_story()
+ assert addr == addr_from_display_format(story.split("\n\n")[0])
+ assert title == 'Unknown Address'
+ assert 'That address is not valid on Bitcoin Testnet' in story
+ assert 'without finding a match' not in story
+
+
@pytest.mark.parametrize("af", ["P2SH-Segwit", "Segwit P2WPKH", "Classic P2PKH", "ms0"])
def test_address_explorer_saver(af, wipe_cache, settings_set, goto_address_explorer,
pick_menu_item, need_keypress, sim_exec, clear_ms,
@@ -481,8 +531,7 @@ def test_ae_saver(wipe_cache, settings_set, goto_address_explorer, cap_story,
def test_regtest_addr_on_mainnet(goto_home, is_q1, pick_menu_item, scan_a_qr, nfc_write, cap_story,
need_keypress, load_shared_mod, use_mainnet, src_root_dir, sim_root_dir):
- # testing bug in chains.possible_address_fmt
- # allowed regtest addresses to be allowed on main chain
+ # Regtest addresses must not be accepted on main chain.
goto_home()
use_mainnet()
addr = "bcrt1qmff7njttlp6tqtj0nq7svcj2p9takyqm3mfl06"
diff --git a/testing/test_seed_xor.py b/testing/test_seed_xor.py
index d14cf88..05e3949 100644
--- a/testing/test_seed_xor.py
+++ b/testing/test_seed_xor.py
@@ -443,6 +443,49 @@ def test_xor_import_empty(parts, expect, pick_menu_item, cap_story, need_keypres
reset_seed_words()
+def test_blank_tmp_seed_xor_restore(unit_test, goto_eph_seed_menu, pick_menu_item, cap_story,
+ choose_by_word_length, word_menu_entry, need_keypress, OK,
+ confirm_tmp_seed, verify_ephemeral_secret_ui, reset_seed_words):
+ # From the Temporary Seed menu, Seed XOR restore must not persist into a blank SE.
+ parts = [zero16, ones16]
+ expect = ones16
+ num_words = 12
+
+ unit_test('devtest/clear_seed.py')
+
+ goto_eph_seed_menu()
+ pick_menu_item('Restore Seed XOR')
+
+ time.sleep(0.1)
+ title, body = cap_story()
+ assert 'all the parts' in body
+ assert f"Press {OK} for 24 words" in body
+ assert "press (1)" in body
+ assert "press (2)" in body
+
+ choose_by_word_length(num_words)
+ time.sleep(0.01)
+
+ for n, part in enumerate(parts):
+ word_menu_entry(part.split())
+
+ time.sleep(0.01)
+ title, body = cap_story()
+ assert f"You've entered {n + 1} parts so far" in body
+
+ if n != len(parts) - 1:
+ assert "Or (2)" not in body
+ need_keypress('1')
+ else:
+ assert "Or (2) if done" in body
+ assert f"{num_words}: {expect.split()[-1]}" in body
+
+ need_keypress('2')
+ confirm_tmp_seed(seedvault=False)
+ verify_ephemeral_secret_ui(mnemonic=expect.split(), seed_vault=False)
+ reset_seed_words()
+
+
@pytest.mark.parametrize("num_words", [12, 24])
@pytest.mark.parametrize("num_parts", [2, 4, 20])
@pytest.mark.parametrize("incl_self", [True, False])
diff --git a/testing/test_sign.py b/testing/test_sign.py
index e93b1de..d59ec40 100644
--- a/testing/test_sign.py
+++ b/testing/test_sign.py
@@ -1873,6 +1873,25 @@ def test_op_return_signing(op_return_data, dev, fake_txn, bitcoind_d_sim_watch,
assert isinstance(tx_id, str) and len(tx_id) == 64
+def test_op_return_trailing_data_not_hidden(fake_txn, start_sign, cap_story):
+ weird = b'\x6a\x00\x04hide' # OP_RETURN OP_0 <push b"hide">
+
+ def hack(psbt):
+ t = CTransaction()
+ t.deserialize(BytesIO(psbt.txn))
+ t.vout[0].scriptPubKey = weird
+ psbt.txn = t.serialize_with_witness()
+
+ psbt = fake_txn(1, 2, segwit_in=True, psbt_v2=False, psbt_hacker=hack)
+ start_sign(psbt)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert title == 'OK TO SEND?'
+ flat = story.lower().replace(' ', '')
+ assert 'null-data' not in flat
+ assert weird.hex() in flat
+
+
@pytest.mark.parametrize("unknowns", [
# tuples (unknown_global, unknown_ins, unknown_outs)
({b"x" * 16: b"y" * 16}, {b"q": b"p"}, {b"w" * 5: b"z" * 22}),
@@ -3225,6 +3244,32 @@ def test_txout_explorer_op_return(finalize, data, fake_txn, start_sign, cap_stor
end_sign(finalize=finalize)
+def test_txout_explorer_qr_too_big_single_item(fake_txn, start_sign, cap_story, cap_screen,
+ need_keypress, pick_menu_item, press_cancel,
+ is_q1):
+ if not is_q1:
+ raise pytest.skip("Q1 QR fallback")
+
+ psbt = fake_txn(1, 10, segwit_in=True, psbt_v2=False, op_return=[(0, b'a' * 1000)])
+ start_sign(psbt)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert title == "OK TO SEND?"
+
+ need_keypress("2")
+ pick_menu_item("Outputs")
+ time.sleep(.1)
+ need_keypress(KEY_RIGHT)
+ time.sleep(.1)
+ need_keypress(KEY_QR)
+ time.sleep(.5)
+ scr = cap_screen()
+ assert "QR too big" in scr
+
+ press_cancel()
+ press_cancel()
+
+
def test_low_R_grinding(dev, goto_home, microsd_path, press_select, offer_ms_import,
cap_story, try_sign, reset_seed_words, clear_ms):
reset_seed_words()
@@ -3552,6 +3597,27 @@ def test_unknown_input_script(stype, fake_txn , start_sign, cap_story, use_testn
txin_explorer(len(ins), ins)
+@pytest.mark.parametrize("mi", ["Inputs", "Outputs"])
+def test_tx_explorer_goto_idx_single_item_yikes(mi, fake_txn, start_sign, cap_story, use_testnet,
+ need_keypress, pick_menu_item, press_cancel, cap_menu):
+ use_testnet()
+ psbt = fake_txn(1, 1, segwit_in=True)
+ start_sign(psbt)
+ title, story = cap_story()
+ assert title == "OK TO SEND?"
+
+ need_keypress("2")
+ pick_menu_item(mi)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "(2)" not in story
+ need_keypress("2") # must not yikes
+ press_cancel()
+ menu = cap_menu()
+ assert "Inputs" in menu
+ assert "Outputs" in menu
+
+
def test_tx_explorer_goto_idx(fake_txn, start_sign, cap_story, use_testnet, need_keypress,
pick_menu_item, cap_screen, enter_number, press_cancel, is_q1):
use_testnet()
@@ -3618,6 +3684,45 @@ def test_tx_explorer_goto_idx(fake_txn, start_sign, cap_story, use_testnet, need
press_cancel()
+def test_input_explorer_foreign_bad_sighash(fake_txn, start_sign, cap_story,
+ need_keypress, pick_menu_item, press_cancel,
+ use_testnet):
+ # PSBT has a foreign input (not ours) carrying a PSBT_IN_SIGHASH_TYPE value
+ # outside ALL_SIGHASH_FLAGS. consider_dangerous_sighash() only validates
+ # our-key inputs, so the PSBT passes validation and reaches the approval UX.
+ # Browsing the TX Explorer -> Inputs must not crash on the foreign input.
+ use_testnet()
+
+ def hack(psbt):
+ # Make input 0 foreign: replace its xfp prefix with a non-matching one.
+ foreign_xfp = b"\xab\xcd\xef\x01"
+ new_paths = {}
+ for pk, path_bytes in psbt.inputs[0].bip32_paths.items():
+ new_paths[pk] = foreign_xfp + path_bytes[4:]
+ psbt.inputs[0].bip32_paths = new_paths
+ psbt.inputs[0].sighash = 0x05
+
+ psbt = fake_txn(2, 2, segwit_in=True, psbt_hacker=hack)
+ start_sign(psbt)
+ time.sleep(.1)
+ title, _ = cap_story()
+ assert title == "OK TO SEND?"
+
+ need_keypress("2")
+ time.sleep(.1)
+ pick_menu_item("Inputs")
+ time.sleep(.2)
+
+ title, story = cap_story()
+ # foreign input shown first; must render the raw value, not crash
+ assert title == "Input 0"
+ assert "sighash: 0x05 (non-standard)" in story
+
+ press_cancel()
+ press_cancel()
+ press_cancel()
+
+
@pytest.mark.parametrize("segwit", [True, False])
def test_txn_nVersion_zero(segwit, fake_txn, start_sign, cap_story, goto_home):
goto_home()
diff --git a/testing/test_teleport.py b/testing/test_teleport.py
index f966f3f..385bc15 100644
--- a/testing/test_teleport.py
+++ b/testing/test_teleport.py
@@ -542,6 +542,8 @@ def test_teleport_ms_sign(M, use_regtest, make_myself_wallet, num_ins, dev, clea
if 'Finalized TX' in body:
break
+ assert "shared via USB" not in body
+ assert "Updated PSBT is" not in story # assert not written to SD/Vdisk
assert '(T) to use Key Teleport to send PSBT to other co-signers' in body
num_sigs_needed -= 1
@@ -651,6 +653,52 @@ def test_teleport_big_ms(make_myself_wallet, clear_ms, fake_ms_txn, try_sign, ca
press_cancel()
+def test_teleport_file_psbt_uses_loaded_file(make_myself_wallet, clear_ms, fake_ms_txn, cap_story,
+ need_keypress, cap_menu, pick_menu_item, grab_payload,
+ rx_complete, set_master_key, goto_home, settings_get,
+ settings_set, open_microsd, import_ms_wallet, press_cancel):
+ clear_ms()
+ M, N = 2, 4
+ keys = import_ms_wallet(M, N, name='ms-tp', unique=11, accept=True,
+ descriptor=False, bip67=True)
+ psbt = fake_ms_txn(1, 1, M, keys)
+
+ fname = 'ms-tp.psbt'
+ open_microsd(fname, 'wb').write(psbt)
+
+ goto_home()
+ pick_menu_item('Advanced/Tools')
+ pick_menu_item('File Management')
+ pick_menu_item('Teleport Multisig PSBT')
+ need_keypress('1')
+ try:
+ pick_menu_item(fname)
+ except KeyError:
+ pass
+
+ m = cap_menu()
+ assert len(m) == N
+ target = next(i for i in m if 'YOU' not in i)
+ target_xfp = str2xfp(target[1:9])
+ # forward the (unsigned) file to this co-signer, instead of signing first and forwarding after
+ pick_menu_item(target)
+
+ pw, data, qr_raw = grab_payload('E')
+
+ tmp_ms = settings_get('multisig')
+
+ # become that co-signer; give it the one wallet it shares with us
+ node, = [n for x, n, _ in keys if x == target_xfp]
+ set_master_key(node.hwif(as_private=True))
+ settings_set('multisig', [tmp_ms[-1]])
+
+ # with the bug the payload is stale/zero bytes or whatever was in OUT_OFFSET -> PSBT load fails;
+ rx_complete(('E', qr_raw), pw, expect_xfp=simulator_fixed_xfp)
+ title, body = cap_story()
+ assert title == 'OK TO SEND?'
+ press_cancel()
+
+
@pytest.mark.manual
def test_teleport_real_ms(dev, fake_ms_txn):
#
@@ -757,6 +805,29 @@ def test_send_backup(testcase, rx_start, tx_start, cap_menu, enter_complex, pick
settings_set('notes', [])
+def test_teleport_backup_invalid_raw_secret(grab_payload, rx_complete, goto_home,
+ pick_menu_item, cap_story, is_q1):
+ # yikes. Must instead show a clean FAILED story.
+ if not is_q1:
+ raise pytest.skip("Q1 Key Teleport")
+ from teleport_protocol import sender_step1
+
+ goto_home()
+ pick_menu_item('Advanced/Tools')
+ pick_menu_item('Key Teleport (start)')
+ code, _qr_data, qr_raw = grab_payload('R')
+
+ bad_backup = b'chain = "XTN"\n'
+ cleartext = b'b' + bad_backup
+ noid_txt, encrypted_payload, _, _ = sender_step1(code, qr_raw, cleartext)
+
+ rx_complete(('S', encrypted_payload), noid_txt)
+ time.sleep(.5)
+ title, body = cap_story()
+ assert title == 'FAILED'
+ assert "Invalid backup" in body
+
+
def test_hobble_limited(set_hobble, scan_a_qr, cap_menu, cap_screen, pick_menu_item, grab_payload,
rx_complete, cap_story, press_cancel, press_select, settings_get,
settings_set, restore_backup_unpacked, main_do_over, set_encoded_secret,
diff --git a/testing/test_unit.py b/testing/test_unit.py
index 8db8a83..8c112e2 100644
--- a/testing/test_unit.py
+++ b/testing/test_unit.py
@@ -238,7 +238,39 @@ def test_cleanup_deriv_path_fails(path, ans, sim_exec, star=True):
assert 'Traceback' in rv
assert ans in rv
-
+
+
+@pytest.mark.parametrize('script_hex, expect', [
+ # not OP_RETURN -> None
+ ('51', None), # OP_1
+ ('0014' + '00'*20, None), # p2wpkh
+ # real null-data -> b""
+ ('6a', b''), # bare OP_RETURN
+ ('6a00', b''), # OP_RETURN OP_0
+ ('6a4c00', b''), # OP_RETURN PUSHDATA1 len 0 (empty push)
+ # single push -> the data
+ ('6a0468696465', b'hide'), # OP_RETURN <push "hide">
+ ('6a01ff', b'\xff'), # OP_RETURN <push 0xff>
+ # data behind OP_RETURN -> None (caller shows raw script)
+ ('6a000468696465', None), # OP_RETURN OP_0 <push "hide">
+ ('6a04414141410442424242', None), # OP_RETURN <push><push>
+ ('6a55', None), # OP_RETURN OP_5
+ # non-push opcode after OP_RETURN (not OP_0) -> None, not null-data
+ ('6a76', None), # OP_RETURN OP_DUP
+ ('6a6a', None), # OP_RETURN OP_RETURN
+ # truncated / malformed pushes after OP_RETURN -> None (show raw script)
+ ('6a04ff', None), # OP_RETURN <direct push len 4, only 1 byte>
+ ('6a4c04ff', None), # OP_RETURN PUSHDATA1 len 4, only 1 byte
+ ('6a4d', None), # OP_RETURN PUSHDATA2 truncated length
+ ('', None), # empty script
+])
+def test_op_return_decode(script_hex, expect, sim_exec):
+ cmd = ('from chains import BitcoinMain; from ubinascii import unhexlify; '
+ 'RV.write(repr(BitcoinMain.op_return(unhexlify(%r))))' % script_hex)
+ rv = sim_exec(cmd)
+ assert 'Traceback' not in rv, rv
+ assert rv == repr(expect)
+
@pytest.mark.parametrize('patterns, paths, answers', [
(["m"], ("m", "m/2", "*", "any"), [True, False, False, False]),
@@ -326,16 +358,30 @@ def test_word_wrap(txt, target, width, sim_exec):
assert lines == target
+def check_own_address_detect(addr, sim_exec):
+ cmd = f"""
+from glob import settings
+from utils import validate_own_address
+rv = []
+for ctype in ('BTC', 'XTN', 'XRT'):
+ settings.set('chain', ctype)
+ try:
+ rv.append((ctype, validate_own_address({addr!r})[1]))
+ except:
+ rv.append((ctype, 0))
+settings.set('chain', 'XTN')
+RV.write(repr(rv))
+"""
+ lst = sim_exec(cmd)
+ assert 'Error' not in lst
+ return eval(lst)
+
+
@pytest.mark.parametrize('addr,net,fmt', [
( 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', 'BTC', AF_P2WPKH ),
])
def test_addr_detect(addr, net, fmt, sim_exec):
- cmd = f'from chains import AllChains; RV.write(repr([(ch.ctype, ch.possible_address_fmt({addr!r})) for ch in AllChains]))'
- print(cmd)
- lst = sim_exec(cmd)
- assert 'Error' not in lst
-
- for got_net, match in eval(lst):
+ for got_net, match in check_own_address_detect(addr, sim_exec):
if match:
assert net == got_net
assert match == fmt
@@ -356,16 +402,11 @@ def test_addr_fake_detect(addr_fmt, testnet, sim_exec):
addr = fake_address(addr_fmt, testnet)
- cmd = f'from chains import AllChains; RV.write(repr([(ch.ctype, ch.possible_address_fmt({addr!r})) for ch in AllChains]))'
- lst = sim_exec(cmd)
- assert 'Error' not in lst
- #print(lst)
-
expect_net = ('BTC' if not testnet else 'XTN')
expect_addr_fmt = addr_fmt if addr_fmt not in { AF_P2WSH_P2SH, AF_P2WPKH_P2SH } else AF_P2SH
- for got_net, match in eval(lst):
+ for got_net, match in check_own_address_detect(addr, sim_exec):
if match:
if got_net == 'XRT':
assert expect_net == 'XTN'
diff --git a/testing/test_ux.py b/testing/test_ux.py
index dcfb335..8c262f4 100644
--- a/testing/test_ux.py
+++ b/testing/test_ux.py
@@ -1,6 +1,7 @@
# (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
-import pytest, time, os, re, hashlib, shutil, functools
+import pytest, time, os, re, hashlib, shutil, functools, ndef
+from binascii import b2a_hex
from helpers import xfp2str, prandom
from charcodes import KEY_QR, KEY_NFC, KEY_DELETE
from constants import AF_CLASSIC, simulator_fixed_words, simulator_fixed_xfp
@@ -758,7 +759,7 @@ def test_sign_file_from_list_files(f_len, goto_home, cap_story, pick_menu_item,
def test_rename_from_list_files(goto_home, cap_story, pick_menu_item, need_keypress, is_q1,
- microsd_path, press_select, cap_screen, enter_complex):
+ microsd_path, press_select, cap_screen, enter_complex, cap_menu):
def clear(fname):
for i in range(len(fname)):
if not is_q1 and not i:
@@ -818,6 +819,15 @@ def test_rename_from_list_files(goto_home, cap_story, pick_menu_item, need_keypr
assert not os.path.exists(fpath)
assert os.path.exists(microsd_path(new_fname))
+ # delete (6) from the same loop must blank the *renamed* file, not the stale old path
+ assert "(6) to delete" in story
+ need_keypress("6")
+ time.sleep(.1)
+ menu = cap_menu()
+ assert "List Files" in menu
+ assert not os.path.exists(microsd_path(new_fname))
+ assert not os.path.exists(fpath)
+
def test_bip39_pw_signing_xfp_ux(pick_menu_item, press_select, cap_story, enter_complex,
reset_seed_words, cap_menu, go_to_passphrase, microsd_wipe):
@@ -857,6 +867,28 @@ def test_q1_seed_word_entry_bug(word_menu_entry, unit_test, pick_menu_item,
expect_ftux()
+def test_q1_seed_word_bad_qr_keeps_words(unit_test, pick_menu_item, is_q1, do_keypresses,
+ need_keypress, scan_a_qr, cap_screen):
+ if not is_q1:
+ raise pytest.skip("Q only")
+
+ unit_test('devtest/clear_seed.py')
+ pick_menu_item('Import Existing')
+ pick_menu_item('12 Words')
+
+ do_keypresses("aba")
+ time.sleep(1)
+ assert "1: abandon" in cap_screen()
+
+ need_keypress(KEY_QR)
+ scan_a_qr("not a seed qr")
+ time.sleep(1)
+
+ screen = cap_screen()
+ assert "1: abandon" in screen
+ assert "Unable to decode as secret" in screen
+
+
def test_custom_pushtx_url(goto_home, pick_menu_item, press_select, enter_complex,
cap_story, cap_menu, settings_remove, need_keypress,
press_cancel, is_q1, settings_get, OK):
@@ -1021,6 +1053,53 @@ def test_qr_share_files(fname, pick_menu_item, goto_home, is_q1, cap_menu, cap_s
assert res == qr.decode()
os.remove(f'{sim_root_dir}/MicroSD/' + fname)
+
+@pytest.mark.parametrize("way", ["nfc", "qr"])
+def test_share_binary_txn_file(way, goto_home, pick_menu_item, src_root_dir, sim_root_dir,
+ press_select, cap_story, cap_screen_qr, is_q1,
+ nfc_read, nfc_block4rf):
+ if way == "qr" and not is_q1:
+ pytest.skip("QR share is Q1 only")
+
+ with open(f"{src_root_dir}/testing/data/devils-txn.txn", "r") as f:
+ binary = bytes.fromhex(f.read().strip())
+ assert binary[2:8] != bytes(6)
+
+ fname = "binary-l01.txn"
+ dst = f"{sim_root_dir}/MicroSD/{fname}"
+ with open(dst, "wb") as f:
+ f.write(binary)
+
+ try:
+ goto_home()
+ pick_menu_item("Advanced/Tools")
+ pick_menu_item("File Management")
+ pick_menu_item("NFC File Share" if way == "nfc" else "QR File Share")
+ time.sleep(.1)
+ pick_menu_item(fname)
+ time.sleep(.2)
+
+ title, story = cap_story()
+ assert "ERROR" not in title
+
+ if way == "nfc":
+ nfc_block4rf()
+ res = nfc_read()
+ got_txn = None
+ for got in ndef.message_decoder(res):
+ if got.type == 'urn:nfc:ext:bitcoin.org:txn':
+ got_txn = bytes(got.data)
+ break
+ assert got_txn == binary
+ press_select()
+ else:
+ qr = cap_screen_qr()
+ assert qr.decode().lower() == b2a_hex(binary).decode().lower()
+ finally:
+ try: os.remove(dst)
+ except OSError: pass
+
+
@pytest.mark.parametrize("word,cs_word", [
# few combos with all words with length 8 + their longest possible checksum word
("acoustic", "decrease"),
@@ -1155,6 +1234,80 @@ def test_nickname_cancel_preserves_existing(already_set, goto_home, pick_menu_it
settings_remove("nick") # clean-up
+@pytest.mark.parametrize('chain', ['BTC', 'XTN'])
+@pytest.mark.parametrize('rz', [8, 5, 2, 0])
+@pytest.mark.parametrize('amount', [
+ '1.1',
+ '50',
+ '0.12345678',
+ '1.10000000',
+])
+def test_bip21_amount_display(amount, chain, rz, settings_set, settings_remove, scan_a_qr,
+ cap_story, goto_home, need_keypress, press_cancel):
+ settings_set('chain', chain)
+ settings_set('rz', rz)
+
+ whole, _, frac = amount.partition('.')
+ sats = int((whole or '0') + (frac + '00000000')[:8])
+
+ if rz == 8:
+ amt = '%d.%08d %s' % (sats // 100000000, sats % 100000000, chain)
+ elif rz == 5:
+ amt = '%d.%05d m%s' % (sats // 100000, sats % 100000, chain)
+ elif rz == 2:
+ amt = '%d.%02d bits' % (sats // 100, sats % 100)
+ else:
+ assert rz == 0
+ amt = '%d sats' % sats
+
+ expected = 'Amount: %s' % amt
+
+ # base58 P2PKH decodes regardless of chain setting (we exploit bug here to not need to specify 2 addrs)
+ addr = 'mtHSVByP9EYZmB26jASDdPVm19gvpecb5R'
+ url = 'bitcoin:%s?amount=%s' % (addr, amount)
+
+ goto_home()
+ need_keypress(KEY_QR)
+ time.sleep(.1)
+ scan_a_qr(url)
+ time.sleep(.5)
+
+ title, body = cap_story()
+ assert title == 'Payment Address', title
+ assert expected in body
+
+ press_cancel()
+ settings_set('chain', 'XTN')
+ settings_remove('rz')
+
+
+@pytest.mark.parametrize('amount', [
+ '999999999', # 9-digit whole part: 99,999,999 > 21M BTC supply
+ '999999999.0', # same, with explicit fractional zero
+ '1.123456789', # 9-digit fractional part: sub-satoshi precision
+ 'abc', # not numeric at all
+ '1.5a', # mixed digits + alpha in fractional part
+ '-1.0', # negative sign breaks isdigit()
+ '1,5', # comma not handled (no dot found, whole isn't digits)
+ '', # empty string
+])
+def test_bip21_amount_display_corrupt(amount, scan_a_qr, cap_story, goto_home,
+ need_keypress, press_cancel):
+ addr = 'mtHSVByP9EYZmB26jASDdPVm19gvpecb5R'
+ url = 'bitcoin:%s?amount=%s' % (addr, amount)
+
+ goto_home()
+ need_keypress(KEY_QR)
+ time.sleep(.1)
+ scan_a_qr(url)
+ time.sleep(.5)
+
+ title, body = cap_story()
+ assert title == 'Payment Address', title
+ assert 'Amount: (corrupt)' in body
+ press_cancel()
+
+
@pytest.mark.onetime
def test_dump_menutree(sim_execfile):
# saves to ../unix/work/menudump.txt
diff --git a/testing/test_wif.py b/testing/test_wif.py
index cf399c0..ac77fce 100644
--- a/testing/test_wif.py
+++ b/testing/test_wif.py
@@ -101,7 +101,7 @@ def test_wif_store_import_paper_wallet(goto_home, pick_menu_item, press_select,
def test_wif_store_import_fail(way, wif, err, import_wif_to_store, skip_if_useless_way,
settings_remove, press_select, cap_story, use_testnet, settings_get):
- err = err or "no valid WIF found"
+ err = err or "No valid WIF key found"
skip_if_useless_way(way)
use_testnet()
settings_remove("wifs")
@@ -336,6 +336,38 @@ def test_wif_store_capacity(import_wif_to_store, settings_remove, press_select,
assert "Import WIF" in menu
+def test_visualize_wif_store_capacity(is_q1, goto_home, use_testnet, settings_remove,
+ import_wif_to_store, settings_get, need_keypress,
+ scan_a_qr, cap_story, press_select):
+ if not is_q1:
+ raise pytest.skip("need scanner")
+
+ settings_remove("wifs")
+ use_testnet()
+
+ goto_home()
+ import_wif_to_store([make_fake_wif() for _ in range(30)])
+ assert len(settings_get("wifs", [])) == 30
+
+ goto_home()
+ need_keypress(KEY_QR)
+ scan_a_qr("cUR6JLQCmdPPt3op4jEYmFhjHpWC2AoZaWmZqoDaBQYMXN4QeKuc")
+ time.sleep(1)
+
+ title, story = cap_story()
+ assert title == "WIF Key"
+ assert "Press (1) to import to WIF Store" in story
+
+ need_keypress("1")
+ time.sleep(.1)
+
+ title, story = cap_story()
+ assert title == "Failure"
+ assert "Max 30 items allowed in WIF Store" in story
+ assert len(settings_get("wifs", [])) == 30
+ press_select()
+
+
def test_wif_store_import_duplicate(settings_remove, import_wif_to_store, settings_get, cap_menu, cap_story,
goto_home):
goto_home()
@@ -799,7 +831,7 @@ def test_visualize_wif(wif, testnet, is_q1, goto_home, need_keypress, use_testne
time.sleep(.1)
title, story = cap_story()
assert title == "Failure"
- assert "Already saved in WIF Store" in story
+ assert "duplicate WIF" in story
press_select()
# EOF
\ No newline at end of file
Why this scored 66/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.