What changed, and why it matters
This commit adds a new user-facing verification step for BIP-322 'Proof of Reserves' messages on the COLDCARD hardware wallet. Before signing a proof-of-reserves transaction, the device now asks the user to import or type the original human-readable message, checks that it matches the cryptographic hash embedded in the transaction, and shows it on screen for approval. This is a security-hardening feature: it closes a gap where the user might otherwise sign a proof without seeing what message they are actually attesting to. The commit itself is defensive and does not appear to introduce an obvious vulnerability, but it is a partial implementation (marked with TODOs) and the change is large enough that bugs in the new input paths could affect signing safety.
Review the new `por322_msg_verify()` function for robust input validation and safe failure handling, especially around the assert-based checks and exception paths. Complete the TODOs (move the BIP-322 tag hash to a constants module and adopt the newer `libngu` sha256t API). Ensure the NFC/QR/SD/vdisk import paths sanitize or length-limit messages appropriately and that malformed input cannot crash or confuse the signing flow. Run the expanded test suite, including the new `test_bip322_msg_import` parametrized cases, on real hardware.
Security signals we found
Adds user-verifiable message display before BIP-322 proof-of-reserves signing
Adds hash-equality check between supplied message and PSBT embedded message hash
Introduces new untrusted input paths: NFC, QR/BBQR, SD card, virtual disk, and on-device keyboard for BIP-322 messages
Contains TODO comments indicating the implementation is incomplete
Hard-codes BIP-322 tag hash as a raw byte literal with a TODO to move it elsewhere
Uses assert for error handling on user-supplied data (msg_hash comparison)
No explicit vendor security disclosure or CVE references present in commit or supplied materials
Evidence from the diff
The patch implements por322_msg_verify() in shared/auth.py, invoked during ApproveTransaction when self.psbt.por322 is set. It prompts the user to supply the BIP-322 message via manual input, NFC, QR/BBQR, SD card, or virtual disk. The code computes sha256(bip322_tag_hash || bip322_tag_hash || msg) and compares it to self.psbt.por322_msg_hash; if the comparison fails or the user refuses, signing aborts. shared/nfc.py adds read_bip322_msg() and shared/ux.py extends the import/export prompt builder with optional custom keys. Tests are updated to create message files and exercise the new import paths. The change is defensive: it ensures the user sees the exact message before producing a BIP-322 signature. However, the implementation contains TODO comments about moving the tag hash and using a newer libngu API, and the new input surfaces are not yet fully exercised by the diff alone.
Changed components
shared/auth.pyshared/nfc.pyshared/ux.pytesting/bip322.pytesting/conftest.pytesting/test_bip322.pyInspect captured patch +218 / −16
diff --git a/shared/auth.py b/shared/auth.py
index 216e4a6..5bf4639 100644
--- a/shared/auth.py
+++ b/shared/auth.py
@@ -13,8 +13,8 @@ from public_constants import STXN_FINALIZE, STXN_VISUALIZE, STXN_SIGNED, AF_P2SH
from sffile import SFFile
from menu import MenuSystem, MenuItem
from serializations import ser_uint256, SIGHASH_ALL
-from ux import ux_show_story, abort_and_goto, ux_dramatic_pause, ux_clear_keys, ux_confirm
-from ux import show_qr_code, OK, X, abort_and_push, AbortInteraction, the_ux, ux_enter_number
+from ux import ux_show_story, abort_and_goto, ux_dramatic_pause, ux_clear_keys, ux_confirm, the_ux
+from ux import show_qr_code, OK, X, abort_and_push, AbortInteraction, ux_input_text, ux_enter_number
from usb import CCBusyError
from utils import (HexWriter, xfp2str, problem_file_line, cleanup_deriv_path, B2A,
show_single_address, keypath_to_str, seconds2human_readable)
@@ -286,6 +286,56 @@ class ApproveTransaction(UserAuthorizedAction):
self.result = None # will be (len, sha256) of the resulting PSBT
self.chain = chains.current_chain()
+ async def por322_msg_verify(self):
+ # https://gist.github.com/orangesurf/0c1d0a31d3ebe7e48335a34d56788d4c
+ from glob import NFC
+ from ux import import_export_prompt
+ from actions import file_picker
+ ch = await import_export_prompt("message", is_import=True, force_prompt=True,
+ intro="Import msg that hashes to 'to_spend' msg hash.",
+ key0="to input message manually", title="BIP-322 MSG",
+ no_qr=not version.has_qwerty)
+
+ # TODO move elswhere
+ bip322_tag_hash = b'te\x84\xa1\x87/\xa1\x00AUN\xff\xa08\xd6\x12IB\xddy\xb4\xe5\x8aL\xda\x18N\x13\xdb\xe6,I'
+
+ if ch == KEY_CANCEL:
+ return
+ elif ch == "0":
+ msg = await ux_input_text("")
+ elif ch == KEY_NFC:
+ msg = await NFC.read_bip322_msg()
+ elif ch == KEY_QR:
+ from ux_q1 import QRScannerInteraction
+ msg = await QRScannerInteraction().scan_text('Scan MSG from a QR code')
+ else:
+ choices = await file_picker(suffix='.txt', ux=False)
+ target = "%s.txt" % b2a_hex(self.psbt.por322_msg_hash).decode()
+
+ for fname, dir, _ in choices:
+ if target == fname:
+ fn = dir + "/" + fname
+ break
+ else:
+ fn = await file_picker(choices=choices)
+
+ if not fn: return
+
+ with CardSlot(readonly=True, **ch) as card:
+ with open(fn, 'rt') as fd:
+ msg = fd.read()
+
+ # TODO needs newer libngu with sha256t
+ assert msg, "need msg"
+ msg_hash = ngu.hash.sha256s(bip322_tag_hash+bip322_tag_hash+msg)
+ assert msg_hash == self.psbt.por322_msg_hash, "hash verification failed"
+ ch = await ux_show_story(
+ msg+"\n\nPress %s to approve message, otherwise %s to exit." % (OK, X),
+ title="MSG:"
+ )
+ return True if ch == "y" else False
+
+
def render_output(self, o):
# Pretty-print a transactions output.
# - expects CTxOut object
@@ -430,6 +480,16 @@ class ApproveTransaction(UserAuthorizedAction):
msg.write('(%d warnings below)\n\n' % wl)
if self.psbt.por322:
+
+ try:
+ if not await self.por322_msg_verify():
+ self.refused = True
+ await ux_dramatic_pause("Refused.", 1)
+ self.done()
+ return
+ except Exception as exc:
+ return await self.failure("Msg verification failed.", exc)
+
msg.write("Proof of Reserves\n\n")
msg.write("Amount %s %s\n\n" % self.chain.render_value(self.psbt.total_value_in))
msg.write("Message Hash:\n%s\n\n" % b2a_hex(self.psbt.por322_msg_hash).decode())
diff --git a/shared/nfc.py b/shared/nfc.py
index b06b6f4..53f509b 100644
--- a/shared/nfc.py
+++ b/shared/nfc.py
@@ -759,6 +759,10 @@ class NFCHandler:
f = lambda x: a2b_base64(x.decode()) if 150 <= len(x) <= 280 else None
return await self._nfc_reader(f, 'Unable to find base64 encoded TAPSIGNER backup.')
+ async def read_bip322_msg(self):
+ f = lambda x: x.decode()
+ return await self._nfc_reader(f, 'Unable to find BIP-322 message.')
+
async def _nfc_reader(self, func, fail_msg):
data = await self.start_nfc_rx()
if not data: return
diff --git a/shared/ux.py b/shared/ux.py
index 5fffd42..e6eb8c6 100644
--- a/shared/ux.py
+++ b/shared/ux.py
@@ -357,12 +357,12 @@ async def ux_enter_bip32_index(prompt, can_cancel=False, unlimited=False):
return await ux_enter_number(prompt=prompt, max_value=max_value, can_cancel=can_cancel)
-def _import_prompt_builder(title, no_qr, no_nfc, slot_b_only=False):
+def _import_prompt_builder(title, no_qr, no_nfc, slot_b_only=False, key0=None, key6=None):
from glob import NFC, VD
prompt, escape = None, KEY_CANCEL+"x"
- if (NFC or VD) or num_sd_slots>1:
+ if (NFC or VD) or (num_sd_slots > 1) or key0 or key6:
if slot_b_only and (num_sd_slots>1):
prompt = "Press (B) to import %s from lower slot SD Card" % title
escape += "b"
@@ -388,6 +388,14 @@ def _import_prompt_builder(title, no_qr, no_nfc, slot_b_only=False):
prompt += ", " + KEY_QR + " to scan QR code"
escape += KEY_QR
+ if key6:
+ prompt += ', (6) ' + key6
+ escape += '6'
+
+ if key0:
+ prompt += ', (0) ' + key0
+ escape += '0'
+
prompt += "."
return prompt, escape
@@ -492,7 +500,8 @@ async def import_export_prompt(what_it_is, is_import=False, no_qr=False,
from glob import NFC
if is_import:
- prompt, escape = _import_prompt_builder(what_it_is, no_qr, no_nfc, slot_b_only)
+ prompt, escape = _import_prompt_builder(what_it_is, no_qr, no_nfc, slot_b_only,
+ key0=key0, key6=key6)
else:
prompt, escape = export_prompt_builder(what_it_is, no_qr, no_nfc, key6=key6, key0=key0,
force_prompt=force_prompt, offer_kt=offer_kt)
diff --git a/testing/bip322.py b/testing/bip322.py
index 7a5ece6..2aecb05 100644
--- a/testing/bip322.py
+++ b/testing/bip322.py
@@ -18,7 +18,20 @@ def bip322_msg_hash(msg):
@pytest.fixture
-def bip322_txn(dev, pytestconfig):
+def create_msg_file(sim_root_dir, garbage_collector):
+
+ def doit(msg, msg_hash):
+ # carelessly overwrites
+ fpath = f"{sim_root_dir}/MicroSD/{msg_hash.hex()}.txt"
+ with open(fpath, "w") as f:
+ f.write(msg.decode())
+ garbage_collector.append(fpath)
+
+ return doit
+
+
+@pytest.fixture
+def bip322_txn(dev, pytestconfig, create_msg_file):
def doit(inputs, msg=b"POR", addr_fmt="p2wpkh", input_amount=1E8, to_sign_lock_time=0,
sighash=None, psbt_hacker=None, witness_utxo=[], to_sign_nVersion=0):
@@ -88,7 +101,9 @@ def bip322_txn(dev, pytestconfig):
to_spend = CTransaction()
to_spend.nVersion = 0
out_point = COutPoint(hash=0, n=0xffffffff)
- to_spend.vin = [CTxIn(out_point, scriptSig=b'\x00\x20' + bip322_msg_hash(msg))]
+ msg_hash = bip322_msg_hash(msg)
+ create_msg_file(msg, msg_hash)
+ to_spend.vin = [CTxIn(out_point, scriptSig=b'\x00\x20' + msg_hash)]
to_spend.vout = [CTxOut(0, scr)] # always zero val
msg_challenge = scr
else:
@@ -144,7 +159,7 @@ def bip322_txn(dev, pytestconfig):
@pytest.fixture
-def bip322_ms_txn(pytestconfig):
+def bip322_ms_txn(pytestconfig, create_msg_file):
from test_multisig import make_ms_address
def doit(num_ins, M, keys, msg=b"POR", inp_af=AF_P2WSH, input_amount=1E8, path_mapper=None,
@@ -188,7 +203,9 @@ def bip322_ms_txn(pytestconfig):
to_spend = CTransaction()
to_spend.nVersion = 0
out_point = COutPoint(hash=0, n=0xffffffff)
- to_spend.vin = [CTxIn(out_point, scriptSig=b'\x00\x20' + bip322_msg_hash(msg))]
+ msg_hash = bip322_msg_hash(msg)
+ create_msg_file(msg, msg_hash)
+ to_spend.vin = [CTxIn(out_point, scriptSig=b'\x00\x20' + msg_hash)]
to_spend.vout.append(CTxOut(0, scriptPubKey))
msg_challenge = scriptPubKey
else:
diff --git a/testing/conftest.py b/testing/conftest.py
index ca57b1e..f37222e 100644
--- a/testing/conftest.py
+++ b/testing/conftest.py
@@ -2927,6 +2927,6 @@ from test_seed_xor import restore_seed_xor
from test_sign import txid_from_export_prompt
from test_ux import pass_word_quiz, word_menu_entry, enable_hw_ux
from txn import fake_txn
-from bip322 import bip322_txn, bip322_ms_txn
+from bip322 import bip322_txn, bip322_ms_txn, create_msg_file
# EOF
diff --git a/testing/test_bip322.py b/testing/test_bip322.py
index 54ad4d6..b4b3043 100644
--- a/testing/test_bip322.py
+++ b/testing/test_bip322.py
@@ -2,13 +2,83 @@
#
# BIP-322 message signing & Proof of Reserves
#
-import pytest
+import pytest, time
from io import BytesIO
from decimal import Decimal
from constants import SIGHASH_MAP, AF_P2SH, AF_P2WSH, AF_P2WSH_P2SH
from bip322 import bip322_txn, bip322_ms_txn, bip322_msg_hash
from ctransaction import CTransaction, CTxIn, COutPoint
from helpers import str_to_path
+from charcodes import KEY_QR, KEY_NFC
+from bbqr import split_qrs
+
+
+@pytest.fixture
+def verify_msg_bip322_por(cap_story, need_keypress, press_select, press_cancel, cap_menu,
+ nfc_write_text, is_q1, press_nfc, scan_a_qr, split_scan_bbqr,
+ enter_complex, pick_menu_item):
+ def doit(msg, refuse=False, way="sd", fname=None):
+ title, story = cap_story()
+ assert title == "BIP-322 MSG"
+ # file was already created with bip322_txn fixture above
+ if "qr" in way and not is_q1:
+ raise pytest.xfail("Mk4 no QR")
+
+ if way == "input":
+ enter_complex(msg, b39pass=False)
+ elif way == "qr":
+ assert f"{KEY_QR} to scan QR code" in story
+ need_keypress(KEY_QR)
+ scan_a_qr(msg)
+ time.sleep(1)
+
+ elif way == "bbqr":
+ assert f"{KEY_QR} to scan QR code" in story
+ need_keypress(KEY_QR)
+
+ # def split_qrs(raw, type_code, encoding=None,
+ # min_split=1, max_split=1295, min_version=5, max_version=40
+ actual_vers, parts = split_qrs(msg, "U", max_version=20)
+
+ for p in parts:
+ scan_a_qr(p)
+ time.sleep(2.0 / len(parts)) # just so we can watch
+
+ time.sleep(1)
+
+ elif way == "nfc":
+ if f"press {KEY_NFC if is_q1 else '(3)'} to import via NFC" not in story:
+ pytest.xfail("NFC disabled")
+ else:
+ press_nfc()
+ time.sleep(0.2)
+ nfc_write_text(msg)
+ time.sleep(0.3)
+ else:
+ assert way in ["sd", "vdisk"]
+ if way == "vdisk":
+ if "(2) to import from Virtual Disk" not in story:
+ pytest.xfail("Vdisk disabled")
+ else:
+ need_keypress("2")
+ else:
+ need_keypress("1")
+
+ if fname:
+ pick_menu_item(fname)
+
+
+ time.sleep(.1)
+ title, story = cap_story()
+ assert msg in story
+ if refuse:
+ press_cancel()
+ time.sleep(.1)
+ assert "Ready To Sign" in cap_menu()
+ else:
+ press_select()
+
+ return doit
@pytest.mark.parametrize("msg", [b"POR", b"This is the signed message"])
@@ -20,11 +90,16 @@ from helpers import str_to_path
[["p2wpkh", None, None]] + ([["p2wpkh", None, 1000000]] * 20),
[["p2pkh", None, None]] + ([["p2wpkh", None, 1000000]] * 5) + ([["p2sh-p2wpkh", None, 10000000]] * 5),
])
-def test_bip322_por(msg, ins, bip322_txn, start_sign, end_sign, cap_story):
+def test_bip322_por(msg, ins, bip322_txn, start_sign, end_sign, cap_story, need_keypress,
+ press_select, verify_msg_bip322_por):
num_ins = len(ins)
amt = sum([i[2] or 0 for i in ins])
psbt, msg_challenge = bip322_txn(ins, msg=msg)
start_sign(psbt, finalize=True)
+
+ verify_msg_bip322_por(msg.decode(), way="sd")
+
+ time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SIGN?"
assert "Proof of Reserves" in story
@@ -46,7 +121,8 @@ def test_bip322_por(msg, ins, bip322_txn, start_sign, end_sign, cap_story):
@pytest.mark.parametrize("sighash", [sh for sh in SIGHASH_MAP if sh != 'ALL'])
-def test_bip322_por_invalid_sighash(sighash, bip322_txn, start_sign, cap_story, settings_set, end_sign):
+def test_bip322_por_invalid_sighash(sighash, bip322_txn, start_sign, cap_story, settings_set,
+ end_sign, verify_msg_bip322_por):
settings_set("sighshchk", 0) # disable checks
# all POR txns must have only SIGHASH_ALL
psbt, _ = bip322_txn([["p2sh-p2wpkh", None, None], ["p2wpkh", None, 100000], ["p2pkh", None, 1000000]],
@@ -57,6 +133,10 @@ def test_bip322_por_invalid_sighash(sighash, bip322_txn, start_sign, cap_story,
assert title == "Failure"
return
+ verify_msg_bip322_por("POR", way="sd")
+
+ time.sleep(.1)
+ title, story = cap_story()
assert "warning" in story
with pytest.raises(Exception):
end_sign(accept=True, finalize=True)
@@ -84,11 +164,14 @@ def test_bip322_0th_input_witness_utxo(ins, bip322_txn, start_sign, cap_story):
[["p2wpkh", None, None], ["p2sh-p2wpkh", None, 10000000], ["p2pkh", None, 10000000]],
[["p2pkh", None, None], ["p2wpkh", None, 10000000], ["p2sh-p2wpkh", None, 10000000]],
])
-def test_bip322_Xth_input_witness_utxo(ins, bip322_txn, start_sign, cap_story, end_sign):
+def test_bip322_Xth_input_witness_utxo(ins, bip322_txn, start_sign, cap_story, end_sign,
+ verify_msg_bip322_por):
# allowed - 0th input needs to have full pre-segwit utxo, all other can be just witness_utxo
msg = b"hellow world"
psbt, msg_challenge = bip322_txn(ins, witness_utxo=[1, 2], msg=msg)
start_sign(psbt, finalize=True)
+ verify_msg_bip322_por(msg.decode(), way="sd")
+ time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SIGN?"
assert bip322_msg_hash(msg).hex() in story
@@ -104,7 +187,8 @@ def test_bip322_Xth_input_witness_utxo(ins, bip322_txn, start_sign, cap_story, e
[["p2wpkh", None, None], ["p2pkh", None, 10000000], ["p2pkh", None, 10000000]],
[["p2sh-p2wpkh", None, None], ["p2sh-p2wpkh", None, 10000000], ["p2sh-p2wpkh", None, 10000000]],
])
-def test__bip322_incomplete_psbt_bip32_paths(ins, bip322_txn, start_sign, cap_story):
+def test_bip322_incomplete_psbt_bip32_paths(ins, bip322_txn, start_sign, cap_story,
+ verify_msg_bip322_por):
def hack(psbt_in):
for i, inp in enumerate(psbt_in.inputs):
@@ -118,6 +202,9 @@ def test__bip322_incomplete_psbt_bip32_paths(ins, bip322_txn, start_sign, cap_st
assert title == "Failure"
assert 'PSBT does not contain any key path information.' in story
else:
+ verify_msg_bip322_por("POR", way="sd")
+ time.sleep(.1)
+ title, story = cap_story()
assert "warning" in story
assert "Limited Signing" in story
assert "because we do not know the key: 0" in story
@@ -334,7 +421,7 @@ def test_bip322_invalid_to_spend_out_nVal(ins, bip322_txn, start_sign, cap_story
@pytest.mark.parametrize("signed", [True, False])
@pytest.mark.parametrize("num_ins", [1, 7])
def test_ms_bip322_por(addr_fmt, M_N, signed, bip322_ms_txn, start_sign, end_sign, cap_story,
- import_ms_wallet, clear_ms, num_ins):
+ import_ms_wallet, clear_ms, num_ins, verify_msg_bip322_por):
clear_ms()
M, N = M_N
@@ -358,6 +445,8 @@ def test_ms_bip322_por(addr_fmt, M_N, signed, bip322_ms_txn, start_sign, end_sig
psbt, msg_challenge = bip322_ms_txn(num_ins, M, keys, path_mapper=path_mapper, inp_af=addr_fmt,
with_sigs=signed, input_amount=inp_amount)
start_sign(psbt, finalize=signed)
+ verify_msg_bip322_por("POR", way="sd")
+ time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SIGN?"
assert "Proof of Reserves" in story
@@ -411,4 +500,27 @@ def test_bip322_invalid_ms_psbt(addr_fmt, bip322_ms_txn, start_sign, cap_story,
assert title == "Failure"
assert "Missing redeem/witness script" in story
+
+@pytest.mark.parametrize("msg", [b"COLDCARD\n\nTHE\n\nBEST\n\nSIGNER", b"X" * 512])
+@pytest.mark.parametrize("ins", [
+ [["p2sh-p2wpkh", None, None]],
+ [["p2pkh", None, None]] + ([["p2wpkh", None, 1000000]] * 5) + ([["p2sh-p2wpkh", None, 10000000]] * 5),
+])
+@pytest.mark.parametrize("way", ["sd", "qr", "nfc", "vdisk", "bbqr"])
+def test_bip322_msg_import(msg, ins, way, bip322_txn, start_sign, end_sign, cap_story, need_keypress,
+ press_select, verify_msg_bip322_por):
+
+ if b"\n" in msg and way == "qr":
+ raise pytest.skip("QR code with newlines not supported")
+
+ psbt, msg_challenge = bip322_txn(ins, msg=msg)
+ start_sign(psbt, finalize=True)
+
+ verify_msg_bip322_por(msg.decode(), way=way)
+
+ time.sleep(.1)
+ title, story = cap_story()
+ assert title == "OK TO SIGN?"
+ assert "Proof of Reserves" in story
+
# EOF
\ No newline at end of file
Why this scored 42/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.