Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/proof-of-reserves-bip-322.md
Original file line number Diff line number Diff line change
@@ -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.
```
89 changes: 78 additions & 11 deletions shared/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions shared/nfc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 79 additions & 4 deletions shared/psbt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1132,7 +1140,7 @@ def parse_txn(self):
self.txn_version, marker, flags = unpack("<iBB", fd.read(6))
self.had_witness = (marker == 0 and flags != 0x0)

assert self.txn_version in {1,2,3}, "bad txn version"
assert self.txn_version in {0,1,2,3}, TX_VER_ERR

if not self.had_witness:
# rewind back over marker+flags
Expand Down Expand Up @@ -1416,20 +1424,35 @@ async def validate(self):
assert not self.has_goc, "v0 requires exclusion of global output count"
assert not self.has_gtv, "v0 requires exclusion of global txn version"
assert self.txn, "v0 requires inclusion of global unsigned tx"
assert self.txn[1] > 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 = []
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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("<iBB", fd.read(6))
assert txn_version == 0, TX_VER_ERR
wit_format = (marker == 0 and flags != 0x0)
if not wit_format:
fd.seek(-2, 1)

num_in = deser_compact_size(fd)
assert num_in == 1, "num ins"
tx_inp = CTxIn()
tx_inp.deserialize(fd)
try:
assert len(tx_inp.scriptSig) == 34
assert tx_inp.scriptSig[0] == 0
assert tx_inp.scriptSig[1] == 32
except:
assert False, "scriptSig"
self.por322_msg_hash = tx_inp.scriptSig[2:]
try:
assert tx_inp.prevout.hash == 0
assert tx_inp.prevout.n == 0xffffffff
except:
assert False, "prevout"

num_out = deser_compact_size(fd)
assert num_out == 1, "num outs"
tx_out = CTxOut()
tx_out.deserialize(fd)
self.por322_msg_challenge = tx_out.scriptPubKey
assert tx_out.nValue == 0, "nVal"

fd.seek(old_pos)
except Exception as e:
raise FatalPSBTIssue("i0: invalid BIP-322 'to_spend': %s" % e)

del utxo

# XXX scan witness data provided, and consider those ins signed if not multisig?

if not foreign:
# no foreign inputs, we can calculate the total input value
assert total_in > 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))
Expand Down Expand Up @@ -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:
Expand Down
15 changes: 12 additions & 3 deletions shared/ux.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading