diff --git a/docs/proof-of-reserves-bip-322.md b/docs/proof-of-reserves-bip-322.md new file mode 100644 index 00000000..ca9dd3ff --- /dev/null +++ b/docs/proof-of-reserves-bip-322.md @@ -0,0 +1,48 @@ +# [BIP-322](https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki) Generic Signed Message Format + +BIP link https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki + +## Proof of Reserves (POR) + +### POR PSBT +COLDCARD accepts specially crafted PSBT to sign BIP-322 Proof of Reserves +* PSBT requires PSBT_IN_BIP32_DERIVATION for each input +* p2sh wrapped segwit addresses MUST have proper redeem script in PSBT (PSBT_IN_REDEEM_SCRIPT) +* p2wsh segwit addresses MUST have proper witness script in PSBT (PSBT_IN_WITNESS_SCRIPT) +* 0th input in `to_sign` transaction MUST have full (pre-segwit) UTXO (PSBT_IN_NON_WITNESS_UTXO) a.k.a `to_spend`. +* 0th input in `to_sign` PSBT_IN_NON_WITNESS_UTXO transaction (`to_spend`) is as defined in https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki#full: + * 1 input, 1 output + * output nValue is 0 + * input prevout hash is 0 + * input prevout n is 0xffffffff + * input scriptSig is OP_0 PUSH32 message_hash + +* PSBT (`to_sign`) MUST have at least one input & 0th input is MUST be `to_spend` full txn +* PSBT (`to_sign`) MUST only have one output with null-data OP_RETURN +* optionally inputs can be added to `to_sign` for Proof of Reserve signing +* PSBT MUST be version 0 +* foreign inputs not allowed in POR PSBT + +### POR Signing UX + +```text +Proof of Reserves + + Amount 0.20000000 XTN + + Message Hash: + 11b5fe357842f5c368d2e3884d6a5ba577e3bc7cde132004f39b8c2a43a9cdec + + Message Challenge: + 00140b2537a7d6f3cc668c9e9fa0303ffb3cad6e9b81 + + 21 inputs + 1 output + + 0.00000000 XTN + - OP_RETURN - + null-data + + Press ENTER to approve and sign transaction. Press (2) to explore txn + outputs. CANCEL to abort. +``` \ No newline at end of file diff --git a/shared/auth.py b/shared/auth.py index 53cec108..54958585 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -12,7 +12,7 @@ from public_constants import STXN_FINALIZE, STXN_VISUALIZE, STXN_SIGNED from sffile import SFFile 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 +from ux import show_qr_code, OK, X, abort_and_push, AbortInteraction, ux_input_text from usb import CCBusyError from utils import HexWriter, xfp2str, problem_file_line, cleanup_deriv_path, B2A, show_single_address from psbt import psbtObject, FatalPSBTIssue, FraudulentChangeOutput @@ -283,6 +283,56 @@ def __init__(self, psbt_len, flags=None, psbt_sha=None, input_method=None, 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 @@ -426,17 +476,33 @@ async def interact(self): elif wl >= 2: msg.write('(%d warnings below)\n\n' % wl) - if self.psbt.consolidation_tx: - # consolidating txn that doesn't change balance of account. - msg.write("Consolidating %s %s\nwithin wallet.\n\n" % - self.chain.render_value(self.psbt.total_value_out)) + 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()) + msg.write("Message Challenge:\n%s\n\n" % b2a_hex(self.psbt.por322_msg_challenge).decode()) else: - msg.write("Sending %s %s\n" % self.chain.render_value( - self.psbt.total_value_out - self.psbt.total_change_value)) + if self.psbt.consolidation_tx: + # consolidating txn that doesn't change balance of account. + msg.write("Consolidating %s %s\nwithin wallet.\n\n" % + self.chain.render_value(self.psbt.total_value_out)) + else: + msg.write("Sending %s %s\n" % self.chain.render_value( + self.psbt.total_value_out - self.psbt.total_change_value)) - fee = self.psbt.calculate_fee() - if fee is not None: - msg.write("Network fee %s %s\n\n" % self.chain.render_value(fee)) + fee = self.psbt.calculate_fee() + if fee is not None: + msg.write("Network fee %s %s\n\n" % self.chain.render_value(fee)) msg.write(" %d %s\n %d %s\n\n" % ( self.psbt.num_inputs, @@ -481,8 +547,9 @@ async def interact(self): msg.write(" (B) to write to lower SD slot.") msg.write(" %s to abort." % X) + title = "OK TO %s?" % ("SIGN" if self.psbt.por322 else "SEND") while True: - ch = await ux_show_story(msg, title="OK TO SEND?", escape=esc) + ch = await ux_show_story(msg, title=title, escape=esc) if ch == "2": await self.txn_explorer() continue diff --git a/shared/nfc.py b/shared/nfc.py index b06b6f46..53f509b2 100644 --- a/shared/nfc.py +++ b/shared/nfc.py @@ -759,6 +759,10 @@ async def read_tapsigner_b64_backup(self): 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/psbt.py b/shared/psbt.py index 18997df9..a2735185 100644 --- a/shared/psbt.py +++ b/shared/psbt.py @@ -34,6 +34,9 @@ AF_P2WSH_P2SH, AF_P2TR, AF_P2WSH, AF_P2SH, AF_CLASSIC, AF_P2WPKH_P2SH, AF_P2WPKH, AF_BARE_PK ) +# transaction version error +TX_VER_ERR = "bad txn version" + # PSBT proprietary keytype PSBT_PROPRIETARY = const(0xFC) @@ -1051,6 +1054,11 @@ def __init__(self): self.has_goc = False # global output count self.has_gtv = False # global txn version + # Proof of Reserves + self.por322 = False + self.por322_msg_hash = None + self.por322_msg_challenge = None + @property def lock_time(self): return (self._lock_time or self.fallback_locktime) or 0 @@ -1132,7 +1140,7 @@ def parse_txn(self): self.txn_version, marker, flags = unpack(" 61, 'txn too short' + # smallest possible Proof of Reserves transaction has 61 bytes + assert self.txn[1] > 60, 'txn too short' assert self.fallback_locktime is None, "v0 requires exclusion of global fallback locktime" assert self.txn_modifiable is None, "v0 requires exclusion of global txn modifiable" + num_outs = 0 + null_data_op_return = False for idx, txo in self.output_iter(): + num_outs += 1 out = self.outputs[idx] if self.is_v2: # v2 requires inclusion assert out.amount assert out.script + if out.amount == 0 and out.script == b'\x6a': + null_data_op_return = True else: # v0 requires exclusion assert out.amount is None assert out.script is None + if txo.nValue == 0 and txo.scriptPubKey == b'\x6a': + null_data_op_return = True + + if null_data_op_return and (num_outs == 1): + self.por322 = True + + if self.txn_version == 0: + # only allow txn version 0 for Proof of Reserves txn (BIP-322) + assert self.por322, TX_VER_ERR # time based relative locks tb_rel_locks = [] @@ -1555,6 +1578,11 @@ def consider_outputs(self): # check fee is reasonable the_fee = self.calculate_fee() + + if self.por322: + # Proof of Reserves - nothing more to check - txn is invalid anyways + return + if the_fee is None: return if the_fee < 0: @@ -1748,20 +1776,64 @@ def consider_inputs(self, cosign_xfp=None): # iff to UTXO is segwit, then check it's value, and also # capture that value, since it's supposed to be immutable - if inp.is_segwit: + # Proof of Reserves PSBT must not modify history + if inp.is_segwit and not self.por322: history.verify_amount(txi.prevout, inp.amount, i) + if self.por322 and (i == 0): + # Proof of Reserves 'to_spend' validation + try: + assert inp.utxo, "utxo" + fd = self.fd + old_pos = fd.tell() + fd.seek(inp.utxo[0]) + + txn_version, marker, flags = unpack(" 0, "zero value txn" self.total_value_in = total_in + assert total_in > 0 or self.por322, "zero value txn" else: # 1+ inputs don't belong to us, we can't calculate the total input value # OK for multi-party transactions (coinjoin etc.) + assert not self.por322 # cannot have foreign inputs in POR txn self.total_value_in = None self.warnings.append( ("Unable to calculate fee", "Some input(s) haven't provided UTXO(s): " + seq_to_str(foreign)) @@ -2015,6 +2087,9 @@ def sign_it(self, alternate_secret=None, my_xfp=None): assert txi.scriptSig, "no scriptsig?" inp.handle_none_sighash() + if self.por322: + assert inp.sighash in [SIGHASH_ALL], "POR not SIGHASH_ALL" # add DEFAULT for taproot + if inp.is_multisig: # need to consider a set of possible keys, since xfp may not be unique for which_key in inp.required_key: diff --git a/shared/ux.py b/shared/ux.py index 5fffd425..e6eb8c6a 100644 --- a/shared/ux.py +++ b/shared/ux.py @@ -357,12 +357,12 @@ async def ux_enter_bip32_index(prompt, can_cancel=False, unlimited=False): return await ux_enter_number(prompt=prompt, max_value=max_value, can_cancel=can_cancel) -def _import_prompt_builder(title, no_qr, no_nfc, slot_b_only=False): +def _import_prompt_builder(title, no_qr, no_nfc, slot_b_only=False, key0=None, key6=None): from glob import NFC, VD prompt, escape = None, KEY_CANCEL+"x" - if (NFC or VD) or num_sd_slots>1: + if (NFC or VD) or (num_sd_slots > 1) or key0 or key6: if slot_b_only and (num_sd_slots>1): prompt = "Press (B) to import %s from lower slot SD Card" % title escape += "b" @@ -388,6 +388,14 @@ def _import_prompt_builder(title, no_qr, no_nfc, slot_b_only=False): prompt += ", " + KEY_QR + " to scan QR code" escape += KEY_QR + if key6: + prompt += ', (6) ' + key6 + escape += '6' + + if key0: + prompt += ', (0) ' + key0 + escape += '0' + prompt += "." return prompt, escape @@ -492,7 +500,8 @@ async def import_export_prompt(what_it_is, is_import=False, no_qr=False, from glob import NFC if is_import: - prompt, escape = _import_prompt_builder(what_it_is, no_qr, no_nfc, slot_b_only) + prompt, escape = _import_prompt_builder(what_it_is, no_qr, no_nfc, slot_b_only, + key0=key0, key6=key6) else: prompt, escape = export_prompt_builder(what_it_is, no_qr, no_nfc, key6=key6, key0=key0, force_prompt=force_prompt, offer_kt=offer_kt) diff --git a/testing/bip322.py b/testing/bip322.py new file mode 100644 index 00000000..2aecb05d --- /dev/null +++ b/testing/bip322.py @@ -0,0 +1,255 @@ +# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# construct Proof of Reserves transaction according to BIP-322 +# +import pytest, struct, hashlib +from ckcc_protocol.protocol import MAX_TXN_LEN +from psbt import BasicPSBT, BasicPSBTInput, BasicPSBTOutput +from io import BytesIO +from helpers import hash160, taptweak, str_to_path +from bip32 import BIP32Node +from constants import simulator_fixed_tprv, AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH +from ctransaction import CTransaction, COutPoint, CTxIn, CTxOut, uint256_from_str + + +def bip322_msg_hash(msg): + tag_hash = hashlib.sha256(b'BIP0322-signed-message').digest() + return hashlib.sha256(tag_hash + tag_hash + msg).digest() + + +@pytest.fixture +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): + + msg_challenge = None + + num_ins = len(inputs) + + psbt = BasicPSBT() + + to_sign = CTransaction() + to_sign.nLockTime = to_sign_lock_time + # must be set to 2 if BIP-68 is used (relative tx level lock) + to_sign.nVersion = to_sign_nVersion + master_xpub = dev.master_xpub or simulator_fixed_tprv + + # we have a key; use it to provide "plausible" value inputs + mk = BIP32Node.from_wallet_key(master_xpub) + mfp = mk.fingerprint() + + psbt.inputs = [BasicPSBTInput(idx=i) for i in range(num_ins)] + psbt.outputs = [] + + for i, inp in enumerate(inputs): + sp = f"0/{i}" + af = addr_fmt + ia = input_amount + try: + if inp[0] is not None: + af = inp[0] + if inp[1] is not None: + sp = inp[1] + if inp[2] is not None: + ia = inp[2] + except: + pass + + int_path = str_to_path(sp) + subkey = mk.subkey_for_path(sp) + sec = subkey.sec() + assert len(sec) == 33, "expect compressed" + + if af == "p2tr": + tweaked_xonly = taptweak(sec[1:]) + psbt.inputs[i].taproot_bip32_paths[sec[1:]] = b"\x00" + mfp + struct.pack(f'<{"I" * len(int_path)}', + *int_path) + scr = bytes([81, 32]) + tweaked_xonly + + elif af in ("p2wpkh", "p2sh-p2wpkh", "p2wpkh-p2sh"): + psbt.inputs[i].bip32_paths[sec] = mfp + struct.pack(f'<{"I" * len(int_path)}', *int_path) + scr = bytes([0x00, 0x14]) + subkey.hash160() + + if af != "p2wpkh": + # use classic p2wpkh (from above) as redeem script + psbt.inputs[i].redeem_script = scr + scr = bytes([0xa9, 0x14]) + hash160(scr) + bytes([0x87]) + + elif af == "p2pkh": + psbt.inputs[i].bip32_paths[sec] = mfp + struct.pack('