From d5d7c4bb68fcc30c0a92ccfbc2b3d67e1159ac35 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Wed, 21 Jan 2026 01:09:27 +0100 Subject: [PATCH] BIP-322 Proof of Reserves --- docs/proof-of-reserves-bip-322.md | 48 ++++ shared/auth.py | 35 ++- shared/psbt.py | 86 ++++++- testing/bip322.py | 238 +++++++++++++++++ testing/conftest.py | 3 +- testing/test_bip322.py | 414 ++++++++++++++++++++++++++++++ testing/test_sign.py | 18 ++ 7 files changed, 823 insertions(+), 19 deletions(-) create mode 100644 docs/proof-of-reserves-bip-322.md create mode 100644 testing/bip322.py create mode 100644 testing/test_bip322.py 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 ad7962a1..c9e44c14 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -429,21 +429,27 @@ class ApproveTransaction(UserAuthorizedAction): elif wl >= 2: msg.write('(%d warnings below)\n\n' % wl) - if self.psbt.active_miniscript: - # show name of the multisig/miniscript wallet that we signed with - msg.write("Wallet: " + self.psbt.active_miniscript.name + "\n\n") - - 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: + 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.active_miniscript: + # show name of the multisig/miniscript wallet that we signed with + msg.write("Wallet: " + self.psbt.active_miniscript.name + "\n\n") - fee = self.psbt.calculate_fee() - if fee is not None: - msg.write("Network fee %s %s\n\n" % self.chain.render_value(fee)) + 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)) msg.write(" %d %s\n %d %s\n\n" % ( self.psbt.num_inputs, @@ -488,8 +494,9 @@ class ApproveTransaction(UserAuthorizedAction): 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 TXExplorer.start(self) continue diff --git a/shared/psbt.py b/shared/psbt.py index 33309e3e..c5696c44 100644 --- a/shared/psbt.py +++ b/shared/psbt.py @@ -44,6 +44,9 @@ from public_constants import ( psbt_tmp256 = bytearray(256) +# transaction version error +TX_VER_ERR = "bad txn version" + # PSBT proprietary keytype PSBT_PROPRIETARY = const(0xFC) @@ -1213,6 +1216,11 @@ class psbtObject(psbtProxy): 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 @@ -1292,7 +1300,7 @@ class psbtObject(psbtProxy): 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" @@ -1660,11 +1669,14 @@ class psbtObject(psbtProxy): self.validate_unkonwn(i, "input") + null_data_op_return = False for o in self.outputs: if self.is_v2: # v2 requires inclusion assert o.amount assert o.script + if o.amount == 0 and o.script == b'\x6a': + null_data_op_return = True else: # v0 requires exclusion assert o.amount is None @@ -1675,6 +1687,18 @@ class psbtObject(psbtProxy): self.validate_unkonwn(o, "output") + if not self.is_v2 and (self.num_outputs == 1): + for idx, txo in self.output_iter(): + if txo.nValue == 0 and txo.scriptPubKey == b'\x6a': + null_data_op_return = True + + if null_data_op_return and (len(self.outputs) == 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 + if not inp_have_subpath: # Can happen w/ Electrum in watch-mode on XPUB. It doesn't know XFP and # so doesn't insert that into PSBT. @@ -1801,6 +1825,11 @@ class psbtObject(psbtProxy): # 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: @@ -1974,7 +2003,8 @@ class psbtObject(psbtProxy): # iff to UTXO is segwit, then check it's value, and also # capture that value, since it's supposed to be immutable - if inp.af and inp.is_segwit: + # Proof of Reserves PSBT must not modify history + if inp.af and inp.is_segwitand and not self.por322: history.verify_amount(txi.prevout, inp.amount, i) if inp.af == AF_P2TR: @@ -1982,17 +2012,62 @@ class psbtObject(psbtProxy): # attribute after creating sighash self.my_tr_in = True + 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)) @@ -2311,6 +2386,9 @@ class psbtObject(psbtProxy): continue inp.handle_none_sighash() + if self.por322: + assert inp.sighash in [SIGHASH_ALL, SIGHASH_DEFAULT], "POR sighash not ALL/DEFAULT" + # decide if it is appropriate to drop sighash from PSBT if inp.taproot_subpaths: drop_sighash = (inp.sighash == SIGHASH_DEFAULT) diff --git a/testing/bip322.py b/testing/bip322.py new file mode 100644 index 00000000..7a5ece6f --- /dev/null +++ b/testing/bip322.py @@ -0,0 +1,238 @@ +# (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 bip322_txn(dev, pytestconfig): + + 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('