diff --git a/docs/proof-of-reserves-bip-322.md b/docs/proof-of-reserves-bip-322.md index 4e77b338..4df4ffab 100644 --- a/docs/proof-of-reserves-bip-322.md +++ b/docs/proof-of-reserves-bip-322.md @@ -9,56 +9,104 @@ BIP-322 specification: 1) # mention warning at top wl= len(self.psbt.warnings) @@ -486,20 +437,15 @@ class ApproveTransaction(UserAuthorizedAction): msg.write('(%d warnings below)\n\n' % wl) if self.psbt.por322: - + msg.write("%s\n\n" % ("Proof of Reserves" if is_por else "BIP-322 Message")) + msg.write("Message:\n%s\n\n" % self.psbt.por322_msg) + if is_por: + msg.write("Amount %s %s\n\n" % self.chain.render_value(self.psbt.total_value_in)) 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()) + addr = self.chain.render_address(self.psbt.por322_msg_challenge) + msg.write("Challenge Address:\n%s\n\n" % show_single_address(addr)) + except ValueError: + msg.write("Message Challenge:\n%s\n\n" % b2a_hex(self.psbt.por322_msg_challenge).decode()) else: if self.psbt.consolidation_tx: # consolidating txn that doesn't change balance of account. @@ -513,15 +459,18 @@ class ApproveTransaction(UserAuthorizedAction): 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, - "input" if self.psbt.num_inputs == 1 else "inputs", - self.psbt.num_outputs, - "output" if self.psbt.num_outputs == 1 else "outputs", - )) + if not self.psbt.por322 or is_por: + msg.write(" %d %s\n %d %s\n\n" % ( + self.psbt.num_inputs, + "input" if self.psbt.num_inputs == 1 else "inputs", + self.psbt.num_outputs, + "output" if self.psbt.num_outputs == 1 else "outputs", + )) + + if not self.psbt.por322: + # outputs + change story created here + self.output_summary_text(msg) - # outputs + change story created here - self.output_summary_text(msg) gc.collect() if self.psbt.ux_notes: @@ -549,8 +498,13 @@ class ApproveTransaction(UserAuthorizedAction): if not hsm_active: esc = "2" - msg.write("Press %s to approve and sign transaction." - " Press (2) to explore transaction." % OK) + noun = "transaction" + if self.psbt.por322: + noun = "proof of reserves" if is_por else "message" + + msg.write("Press %s to approve and sign %s." + " Press (2) to explore transaction." % (OK, noun)) + if (self.input_method == "sd") and CardSlot.both_inserted(): esc += "b" msg.write(" (B) to write to lower SD slot.") @@ -817,13 +771,19 @@ async def done_signing(psbt, tx_req, input_method=None, filename=None, # USB case - user can choose whether to attempt finalization is_complete = finalize + if psbt.por322: + # network txn strips PSBT BIP-32 with paths with pubkey required for verification + # overrides --finalize from USB + # disable pushTX for BIP-322 + is_complete = False + with SFFile(TXN_OUTPUT_OFFSET, max_size=MAX_TXN_LEN, message="Saving...") as psram: if is_complete: txid = psbt.finalize(psram) noun = "Finalized TX ready for broadcast" else: psbt.serialize(psram) - noun = "Partly Signed PSBT" + noun = "Signed BIP-322 PSBT" if psbt.por322 else "Partly Signed PSBT" txid = None data_len = psram.tell() @@ -894,7 +854,7 @@ async def done_signing(psbt, tx_req, input_method=None, filename=None, elif ch == KEY_QR: here = PSRAM.read_at(TXN_OUTPUT_OFFSET, data_len) - msg = txid or 'Partly Signed PSBT' + msg = txid or noun try: if len(here) > 920: # too big for simple QR - use BBQr instead diff --git a/shared/chains.py b/shared/chains.py index 1531b740..8aeace43 100644 --- a/shared/chains.py +++ b/shared/chains.py @@ -254,7 +254,7 @@ class ChainsBase: return ngu.codecs.b58_encode(cls.b58_script + script[2:2+20]) # segwit v0 (P2WPKH, P2WSH) - if script[0] == 0 and script[1] in (0x14, 0x20) and (ll-2) == script[1]: + if ll in (22, 34) and script[0] == 0 and script[1] in (0x14, 0x20) and (ll-2) == script[1]: return ngu.codecs.segwit_encode(cls.bech32_hrp, script[0], script[2:]) # segwit v1 (P2TR) and later segwit version diff --git a/shared/hsm.py b/shared/hsm.py index 6018b834..09f33cb3 100644 --- a/shared/hsm.py +++ b/shared/hsm.py @@ -5,7 +5,7 @@ # Unattended signing of transactions and messages, subject to a set of rules. # import ustruct, chains, sys, gc, uio, ujson, uos, utime, ckcc, ngu -from utils import problem_file_line, cleanup_deriv_path, match_deriv_path +from utils import problem_file_line, cleanup_deriv_path, match_deriv_path, keypath_to_str from utils import cleanup_payment_address from pincodes import AE_LONG_SECRET_LEN from stash import blank_object @@ -882,9 +882,6 @@ class HSMPolicy: # do this super early so always cleared even if other issues local_ok = self.consume_local_code(psbt_sha) - if not self.rules: - raise ValueError("no txn signing allowed") - # reject anything with warning, probably if psbt.warnings: if self.warnings_ok: @@ -892,6 +889,32 @@ class HSMPolicy: else: raise ValueError("has %d warning(s)" % len(psbt.warnings)) + if psbt.por322: + if not self.msg_paths: + raise ValueError("Message signing not permitted") + + for inp in psbt.inputs: + if not inp.required_key: + continue + + if inp.is_multisig: + paths = [ + keypath_to_str(inp.subpaths[pk]) + for pk in inp.required_key + if pk in inp.subpaths + ] + else: + paths = [keypath_to_str(inp.subpaths[inp.required_key])] + + if not any(match_deriv_path(self.msg_paths, p) for p in paths): + raise ValueError("Message signing not enabled for that path") + + self.approve(log, "BIP-322 message signing allowed") + return 'y' + + if not self.rules: + raise ValueError("no txn signing allowed") + # See who has entered creditials already (all must be valid). users = [] for u, (token, counter) in auth.items(): diff --git a/shared/psbt.py b/shared/psbt.py index 430c04d0..d2bc010b 100644 --- a/shared/psbt.py +++ b/shared/psbt.py @@ -5,7 +5,7 @@ import stash, gc, history, sys, ngu, ckcc, chains from ustruct import unpack_from, unpack, pack from ubinascii import hexlify as b2a_hex -from utils import xfp2str, B2A, keypath_to_str +from utils import xfp2str, B2A, keypath_to_str, is_ascii from utils import seconds2human_readable, datetime_from_timestamp, datetime_to_str, node_from_privkey from chains import NLOCK_IS_TIME from uhashlib import sha256 @@ -14,7 +14,7 @@ from sffile import SizerFile from multisig import MultisigWallet, disassemble_multisig_mn from exceptions import FatalPSBTIssue, FraudulentChangeOutput from serializations import ser_compact_size, deser_compact_size, hash160 -from serializations import CTxIn, CTxInWitness, CTxOut, ser_string, COutPoint +from serializations import CTransaction, CTxIn, CTxInWitness, CTxOut, ser_string, COutPoint from serializations import ser_sig_der, uint256_from_str, ser_push_data from serializations import SIGHASH_ALL, SIGHASH_SINGLE, SIGHASH_NONE, SIGHASH_ANYONECANPAY from serializations import ALL_SIGHASH_FLAGS @@ -31,13 +31,23 @@ from public_constants import ( PSBT_GLOBAL_TX_MODIFIABLE, PSBT_GLOBAL_OUTPUT_COUNT, PSBT_GLOBAL_INPUT_COUNT, PSBT_GLOBAL_FALLBACK_LOCKTIME, PSBT_GLOBAL_TX_VERSION, PSBT_IN_PREVIOUS_TXID, PSBT_IN_OUTPUT_INDEX, PSBT_IN_SEQUENCE, PSBT_IN_REQUIRED_TIME_LOCKTIME, - PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, MAX_PATH_DEPTH, MAX_SIGNERS, + PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE, MAX_PATH_DEPTH, MAX_SIGNERS, 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" +# single sha256 of b'BIP0322-signed-message' +BIP322_TAG_HASH = b'te\x84\xa1\x87/\xa1\x00AUN\xff\xa08\xd6\x12IB\xddy\xb4\xe5\x8aL\xda\x18N\x13\xdb\xe6,I' + +def build_bip322_to_spend(msg_hash, message_challenge): + to_spend = CTransaction() + to_spend.nVersion = 0 + to_spend.vin = [CTxIn(COutPoint(hash=0, n=0xffffffff), scriptSig=b'\x00\x20' + msg_hash)] + to_spend.vout = [CTxOut(0, message_challenge)] + return to_spend + # PSBT proprietary keytype PSBT_PROPRIETARY = const(0xFC) @@ -1081,6 +1091,7 @@ class psbtObject(psbtProxy): # Proof of Reserves self.por322 = False + self.por322_msg = None self.por322_msg_hash = None self.por322_msg_challenge = None @@ -1114,12 +1125,35 @@ class psbtObject(psbtProxy): # bytes of length 1 (tx modifiable in short_values) assert len(val) == 1 self.txn_modifiable = val[0] + elif kt == PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE: + assert len(key) == 1 + self.por322_msg = self.get(val).decode() else: self.unknown = self.unknown or {} if key in self.unknown: raise FatalPSBTIssue("Duplicate key. Key for unknown value already provided in global namespace.") self.unknown[key] = val + def validate_bip322_input0(self, inp, txi, utxo): + msg_hash = ngu.hash.sha256t(BIP322_TAG_HASH, self.por322_msg.encode(), True) + message_challenge = utxo.scriptPubKey + to_spend = build_bip322_to_spend(msg_hash, message_challenge) + to_spend_hash = uint256_from_str(ngu.hash.sha256d(to_spend.serialize_without_witness())) + + assert txi.prevout.hash == to_spend_hash, "to_spend hash" + assert txi.prevout.n == 0, "prevout n" + assert utxo.nValue == 0, "input0 value" + + if inp.utxo: + old_pos = self.fd.tell() + raw_utxo = self.get(inp.utxo) + self.fd.seek(old_pos) + assert raw_utxo == to_spend.serialize_without_witness(), "utxo" + + self.por322_msg_hash = msg_hash + assert message_challenge, "empty message_challenge" + self.por322_msg_challenge = message_challenge + def output_iter(self, start=0, stop=None): # yield the txn's outputs: index, (CTxOut object) for each if stop is None: @@ -1462,24 +1496,34 @@ class psbtObject(psbtProxy): out = self.outputs[idx] if self.is_v2: # v2 requires inclusion - assert out.amount + assert out.amount is not None 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 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 + assert self.por322_msg, "msg" + self.por322 = bool(self.por322_msg) + + if self.por322: + if not is_ascii(self.por322_msg): + self.warnings.append(( + "Message", + "Message contains non-ASCII characters that may not be readable on this screen." + )) if self.txn_version == 0: # only allow txn version 0 for Proof of Reserves txn (BIP-322) assert self.por322, TX_VER_ERR + if self.por322: + assert self.txn_version in {0, 2}, TX_VER_ERR + # time based relative locks tb_rel_locks = [] # block height based relative locks @@ -1670,6 +1714,9 @@ class psbtObject(psbtProxy): if input.sighash not in ALL_SIGHASH_FLAGS: raise FatalPSBTIssue("Unsupported sighash flag 0x%x" % input.sighash) + if self.por322 and input.sighash != SIGHASH_ALL: + raise FatalPSBTIssue("POR not SIGHASH_ALL") + if input.sighash != SIGHASH_ALL: sh_unusual = True @@ -1830,42 +1877,8 @@ class psbtObject(psbtProxy): 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("= 1 + assert tx.vin[0].prevout.hash == to_spend.sha256 + assert tx.vin[0].prevout.n == 0 + assert not (len(tx.vin) == 1 and (tx.vin[0].nSequence != 0 or tx.nLockTime != 0)) + assert len(tx.vout) == 1 + assert tx.vout[0].nValue == 0 + assert tx.vout[0].scriptPubKey == b'\x6a' - 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 - pubkey = None # public key - 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] - if inp[3] is not None: - pubkey = inp[3] - except: - pass - - if pubkey: - int_path = [0] - sec = pubkey + prevouts = [] + for idx, txin in enumerate(tx.vin): + if idx == 0: + prevouts.append((0, script_pubkey)) + else: + assert idx < len(psbt.inputs) + if psbt.inputs[idx].witness_utxo: + prev = CTxOut() + prev.deserialize(BytesIO(psbt.inputs[idx].witness_utxo)) else: - int_path = str_to_path(sp) - sec = mk.subkey_for_path(sp).sec() + prev_tx = CTransaction() + prev_tx.deserialize(BytesIO(psbt.inputs[idx].utxo)) + prev = prev_tx.vout[txin.prevout.n] + prevouts.append((prev.nValue, prev.scriptPubKey)) - subkey = PublicKey.parse(sec) + for idx, txin in enumerate(tx.vin): + amount, spk = prevouts[idx] - assert len(sec) == 33, "expect compressed" + inp = psbt.inputs[idx] + if len(spk) == 25 and spk[:3] == b'\x76\xa9\x14' and spk[-2:] == b'\x88\xac': + assert len(inp.part_sigs) == 1 + pub, sig = next(iter(inp.part_sigs.items())) + assert hash160(pub) == spk[3:23] + assert ecdsa_verify_sig(pub, sig, legacy_sighash(tx, idx, spk)) + continue - 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 + if len(spk) == 22 and spk[:2] == b'\x00\x14': + assert len(inp.part_sigs) == 1 + pub, sig = next(iter(inp.part_sigs.items())) + assert hash160(pub) == spk[2:22] + script_code = b'\x76\xa9\x14' + spk[2:22] + b'\x88\xac' + assert ecdsa_verify_sig(pub, sig, segwit_v0_sighash(tx, idx, script_code, amount)) + continue - 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.h160() - - 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(f'<{"I" * len(int_path)}', *int_path) - scr = bytes([0x76, 0xa9, 0x14]) + subkey.h160() + bytes([0x88, 0xac]) + if len(spk) == 34 and spk[:2] == b'\x00\x20': + assert inp.witness_script + assert hashlib.sha256(inp.witness_script).digest() == spk[2:34] + assert inp.part_sigs + sighash = segwit_v0_sighash(tx, idx, inp.witness_script, amount) + for pub, sig in inp.part_sigs.items(): + assert ecdsa_verify_sig(pub, sig, sighash) + continue + if len(spk) == 34 and spk[:2] == b'\x51\x20': + assert inp.taproot_key_sig + if len(inp.taproot_key_sig) == 64: + sighash = SIGHASH_DEFAULT + sig = inp.taproot_key_sig else: - raise ValueError("unknown addr_fmt %s" % af) + assert len(inp.taproot_key_sig) == 65 + sighash = inp.taproot_key_sig[-1] + sig = inp.taproot_key_sig[:-1] + digest = taproot_sighash(tx, idx, prevouts, sighash) + assert schnorrsig_verify(sig, digest, xonly_pubkey_parse(spk[2:34])) + continue - if i == 0: - # first input always spends to_spend - to_spend = CTransaction() - to_spend.nVersion = 0 - out_point = COutPoint(hash=0, n=0xffffffff) - 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: - # other outputs that we want to prove ownership - to_spend = CTransaction() - to_spend.nVersion = 0 - out_point = COutPoint( - uint256_from_str(struct.pack('4Q', 0xdead, 0xbeef, 0, i)), - 73 - ) - to_spend.vin = [CTxIn(out_point, nSequence=0xffffffff)] - to_spend.vout.append(CTxOut(int(ia), scr)) + if len(spk) == 23 and spk[:2] == b'\xa9\x14' and spk[-1:] == b'\x87': + assert inp.redeem_script + assert hash160(inp.redeem_script) == spk[2:22] + + if len(inp.redeem_script) == 22 and inp.redeem_script[:2] == b'\x00\x14': + assert len(inp.part_sigs) == 1 + pub, sig = next(iter(inp.part_sigs.items())) + assert hash160(pub) == inp.redeem_script[2:22] + script_code = b'\x76\xa9\x14' + inp.redeem_script[2:22] + b'\x88\xac' + assert ecdsa_verify_sig(pub, sig, segwit_v0_sighash(tx, idx, script_code, amount)) + continue + + if len(inp.redeem_script) == 34 and inp.redeem_script[:2] == b'\x00\x20': + assert inp.witness_script + assert inp.redeem_script == b'\x00\x20' + hashlib.sha256(inp.witness_script).digest() + assert inp.part_sigs + sighash = segwit_v0_sighash(tx, idx, inp.witness_script, amount) + for pub, sig in inp.part_sigs.items(): + assert ecdsa_verify_sig(pub, sig, sighash) + continue + + assert inp.part_sigs + sighash = legacy_sighash(tx, idx, inp.redeem_script) + for pub, sig in inp.part_sigs.items(): + assert ecdsa_verify_sig(pub, sig, sighash) + continue + + assert False, "unsupported script" - if sighash is not None: - psbt.inputs[i].sighash = sighash +def bip322_txn(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, + psbt_v2=False, master_xpub=None): - to_spend.calc_sha256() + msg_challenge = None - if i in witness_utxo: - psbt.inputs[i].witness_utxo = to_spend.vout[-1].serialize() - else: - psbt.inputs[i].utxo = to_spend.serialize_with_witness() + num_ins = len(inputs) + psbt = BasicPSBT() + psbt.bip322_msg = msg + + 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 = 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 + pubkey = None # public key + 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] + if inp[3] is not None: + pubkey = inp[3] + except: + pass + + if pubkey: + int_path = [0] + sec = pubkey + else: + int_path = str_to_path(sp) + sec = mk.subkey_for_path(sp).sec() + + subkey = PublicKey.parse(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.h160() + + 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(f'<{"I" * len(int_path)}', *int_path) + scr = bytes([0x76, 0xa9, 0x14]) + subkey.h160() + bytes([0x88, 0xac]) + + else: + raise ValueError("unknown addr_fmt %s" % af) + + if i == 0: + # first input always spends to_spend + to_spend = CTransaction() + to_spend.nVersion = 0 + out_point = COutPoint(hash=0, n=0xffffffff) + msg_hash = bip322_msg_hash(msg) + 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: + # other outputs that we want to prove ownership + to_spend = CTransaction() + to_spend.nVersion = 0 + out_point = COutPoint( + uint256_from_str(struct.pack('4Q', 0xdead, 0xbeef, 0, i)), + 73 + ) + to_spend.vin = [CTxIn(out_point, nSequence=0xffffffff)] + to_spend.vout.append(CTxOut(int(ia), scr)) + + + if sighash is not None: + psbt.inputs[i].sighash = sighash + + to_spend.calc_sha256() + + if i in witness_utxo: + psbt.inputs[i].witness_utxo = to_spend.vout[-1].serialize() + else: + psbt.inputs[i].utxo = to_spend.serialize_with_witness() + + if len(inputs) == 1: + # basic msg sign + seq = 0 + else: if to_sign_lock_time and not i: seq = 0xfffffffd else: seq = 0xffffffff - spendable = CTxIn(COutPoint(to_spend.sha256, 0), nSequence=seq) - to_sign.vin.append(spendable) + spendable = CTxIn(COutPoint(to_spend.sha256, 0), nSequence=seq) + to_sign.vin.append(spendable) - # just one zero amount output with script null data OP_RETURN - op_ret_o = BasicPSBTOutput(idx=0) - op_return_out = CTxOut(0, b'\x6a') - to_sign.vout.append(op_return_out) + # just one zero amount output with script null data OP_RETURN + op_ret_o = BasicPSBTOutput(idx=0) + op_return_out = CTxOut(0, b'\x6a') + to_sign.vout.append(op_return_out) - psbt.outputs.append(op_ret_o) + psbt.outputs.append(op_ret_o) - psbt.txn = to_sign.serialize_with_witness() + psbt.txn = to_sign.serialize_with_witness() - # last minute chance to mod PSBT object - if psbt_hacker: - psbt_hacker(psbt) + # last minute chance to mod PSBT object + if psbt_hacker: + psbt_hacker(psbt) - rv = BytesIO() - psbt.serialize(rv) - assert rv.tell() <= MAX_TXN_LEN, 'too fat' + if psbt_v2: + psbt.parsed_txn = CTransaction() + psbt.parsed_txn.deserialize(BytesIO(psbt.txn)) + psbt.to_v2() - return rv.getvalue(), msg_challenge + rv = BytesIO() + psbt.serialize(rv) + assert rv.tell() <= MAX_TXN_LEN, 'too fat' - return doit + return rv.getvalue(), msg_challenge -@pytest.fixture -def bip322_ms_txn(pytestconfig, create_msg_file): +def bip322_ms_txn(num_ins, M, keys, msg=b"POR", inp_af=AF_P2WSH, input_amount=1E8, path_mapper=None, + lock_time=0, with_sigs=False, sighash=None, hack_psbt=None, to_sign_nVersion=0, + psbt_v2=False): 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, - lock_time=0, with_sigs=False, sighash=None, hack_psbt=None, to_sign_nVersion=0): + msg_challenge = None - msg_challenge = None + psbt = BasicPSBT() + psbt.bip322_msg = msg - psbt = BasicPSBT() + txn = CTransaction() + txn.nVersion = to_sign_nVersion + txn.nLockTime = lock_time - txn = CTransaction() - txn.nVersion = to_sign_nVersion - txn.nLockTime = lock_time + psbt.inputs = [BasicPSBTInput(idx=i) for i in range(num_ins)] + psbt.outputs = [] - psbt.inputs = [BasicPSBTInput(idx=i) for i in range(num_ins)] - psbt.outputs = [] + for i in range(num_ins): + # make a fake txn to supply each of the inputs + # - each input is 1BTC - for i in range(num_ins): - # make a fake txn to supply each of the inputs - # - each input is 1BTC + # addr where the fake money will be stored. + addr, scriptPubKey, script, details = make_ms_address( + M, keys, idx=i, addr_fmt=inp_af, path_mapper=path_mapper + ) - # addr where the fake money will be stored. - addr, scriptPubKey, script, details = make_ms_address( - M, keys, idx=i, addr_fmt=inp_af, path_mapper=path_mapper + if inp_af == AF_P2WSH: + psbt.inputs[i].witness_script = script + elif inp_af == AF_P2SH: + psbt.inputs[i].redeem_script = script + else: + assert inp_af == AF_P2WSH_P2SH + psbt.inputs[i].witness_script = script + psbt.inputs[i].redeem_script = b'\0\x20' + hashlib.sha256(script).digest() + + for pubkey, xfp_path in details: + psbt.inputs[i].bip32_paths[pubkey] = b''.join(struct.pack(' 1 + verify_msg_bip322_por(msg.decode(), is_por=is_por) time.sleep(.1) title, story = cap_story() assert title == "OK TO SIGN?" - assert "Proof of Reserves" in story + assert ("Proof of Reserves" if is_por else "BIP-322 Message") in story assert 'Network fee' not in story # different story for POR - if len(ins) == 1: - # only the message signed input - amount is zero - assert "Amount 0.00000000 XTN" in story - assert "1 input" in story + assert "explore transaction" in story + if not is_por: + assert "Proof of Reserves" not in story + assert "Amount " not in story + assert "1 input" not in story + assert "1 output" not in story + assert "sign message" in story else: assert ("Amount %s XTN" % str(Decimal(amt/100000000).quantize(Decimal('.00000001')))) in story assert ("%d inputs" % num_ins) in story + assert "sign proof of reserves" in story - assert ("Message Hash:\n%s" % bip322_msg_hash(msg).hex()) in story - assert ("Message Challenge:\n%s" % msg_challenge.hex()) in story - assert "1 output" in story + assert ("Message:\n%s" % msg.decode()) in story + assert "Message Hash:" not in story + assert "Challenge Address:" in story + assert "Message Challenge:" not in story + assert ("1 output" in story) == is_por + assert "- OP_RETURN -" not in story + assert "null-data" not in story + signed = end_sign(accept=True, exit_export_loop=False) + bip322_verify(signed) + title, story = cap_story() + assert title == "PSBT Signed" + assert "Signed BIP-322 PSBT shared via USB." in story + assert "Finalized TX ready for broadcast" not in story + assert "TXID:" not in story + press_cancel() + + +def test_bip322_por_utf8_msg(bip322_txn, start_sign, end_sign, cap_story, press_select, + bip322_verify): + msg = "UTF-8 support: öäüéàè - test text".encode() + psbt, _ = bip322_txn([["p2wpkh", None, None]], msg=msg) + + start_sign(psbt, finalize=True) + title, story = cap_story() + assert title == "OK TO SIGN?" + assert "BIP-322 Message" in story + assert "Proof of Reserves" not in story + assert msg.decode() in story + assert "WARNING" in story + assert "non-ASCII characters" in story + assert "Message Hash:" not in story + signed = end_sign(accept=True) + bip322_verify(signed) + + +def test_bip322_global_msg_hash_mismatch(bip322_txn, start_sign, cap_story): + def hack(psbt_in): + psbt_in.bip322_msg = b"wrong message" + + psbt, _ = bip322_txn([["p2wpkh", None, None]], msg=b"right message", psbt_hacker=hack) + + start_sign(psbt, finalize=True) + title, story = cap_story() + assert title == "Failure" + assert "i0: invalid BIP-322 'to_spend'" in story + assert "to_spend hash" in story + + +def test_bip322_missing_global_msg(bip322_txn, start_sign, cap_story): + def hack(psbt_in): + psbt_in.bip322_msg = None + + psbt, _ = bip322_txn([["p2wpkh", None, None]], psbt_hacker=hack) + + start_sign(psbt, finalize=True) + title, story = cap_story() + assert title == "Failure" + assert "msg" in story + + +def test_bip322_missing_input0_utxo(bip322_txn, start_sign, cap_story): + def hack(psbt_in): + psbt_in.inputs[0].utxo = None + psbt_in.inputs[0].witness_utxo = None + + psbt, _ = bip322_txn([["p2wpkh", None, None]], psbt_hacker=hack) + + start_sign(psbt, finalize=True) + title, story = cap_story() + assert title == "Failure" + assert "Missing own UTXO" in story + + +@pytest.mark.parametrize("ins,label", [ + ([["p2wpkh", None, None]], "BIP-322 Message"), + ([["p2wpkh", None, None], ["p2wpkh", None, 10000000]], "Proof of Reserves"), +]) +def test_bip322_psbtv2_accepted(ins, label, bip322_txn, start_sign, end_sign, cap_story, + bip322_verify): + psbt, _ = bip322_txn(ins, psbt_v2=True) + + start_sign(psbt, finalize=True) + title, story = cap_story() + assert title == "OK TO SIGN?" + assert label in story + signed = end_sign(accept=True) + bip322_verify(signed) + + +@pytest.mark.parametrize("to_sign_nVersion", [1, 3]) +def test_bip322_invalid_to_sign_version(to_sign_nVersion, bip322_txn, start_sign, cap_story): + psbt, _ = bip322_txn([["p2wpkh", None, None], ["p2wpkh", None, 10000000]], + to_sign_nVersion=to_sign_nVersion) + + start_sign(psbt, finalize=True) + title, story = cap_story() + assert title == "Failure" + assert "bad txn version" in story + + +def test_bip322_input0_explorer(bip322_txn, start_sign, cap_story, need_keypress, + pick_menu_item, press_cancel): + psbt, msg_challenge = bip322_txn([["p2wpkh", None, None], ["p2wpkh", None, 10000000]]) + + start_sign(psbt, finalize=True) + title, story = cap_story() + assert title == "OK TO SIGN?" + assert "Press (2) to explore transaction" in story + + need_keypress("2") + time.sleep(.1) + pick_menu_item("Inputs") + time.sleep(.1) + + title, story = cap_story() + assert title == "Input 0" + + sections = story.split("\n\n") + txid, n = sections[0].split(":") + assert len(txid) == 64 + assert n == "0" + + assert "=== UTXO ===" in sections + utxo_idx = sections.index("=== UTXO ===") + assert sections[utxo_idx + 1] == "0.00000000 XTN" + assert sections[utxo_idx + 2] == msg_challenge.hex() + assert addr_from_display_format(sections[utxo_idx + 3]) == render_address(msg_challenge) + assert sections[utxo_idx + 4] == "Address Format: p2wpkh" + + assert "=== PSBT ===" in sections + assert "Our key:" in sections + assert "- OP_RETURN -" not in story + assert "null-data" not in story + + press_cancel() + + +def test_bip322_output_explorer(bip322_txn, start_sign, cap_story, need_keypress, + pick_menu_item, press_cancel): + psbt, _ = bip322_txn([["p2wpkh", None, None], ["p2wpkh", None, 10000000]]) + + start_sign(psbt, finalize=True) + title, story = cap_story() + assert title == "OK TO SIGN?" + assert "Proof of Reserves" in story + assert "- OP_RETURN -" not in story + assert "null-data" not in story + assert "Press (2) to explore transaction" in story + + need_keypress("2") + time.sleep(.1) + pick_menu_item("Outputs") + time.sleep(.1) + + title, story = cap_story() + assert title == "0-0" + assert "Output 0:" in story + assert "0.00000000 XTN" in story assert "- OP_RETURN -" in story assert "null-data" in story - end_sign(accept=True, finalize=True) + + press_cancel() + press_cancel() + press_cancel() @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, verify_msg_bip322_por): - settings_set("sighshchk", 0) # disable checks +def test_bip322_por_invalid_sighash(sighash, bip322_txn, start_sign, cap_story, settings_set): + settings_set("sighshchk", 1) # BIP-322 POR still requires SIGHASH_ALL in warn-only mode. # all POR txns must have only SIGHASH_ALL psbt, _ = bip322_txn([["p2sh-p2wpkh", None, None], ["p2wpkh", None, 100000], ["p2pkh", None, 1000000]], sighash=SIGHASH_MAP[sighash]) start_sign(psbt, finalize=True) - title, story = cap_story() - if "NONE" in sighash: - 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) title, story = cap_story() + assert title == "Failure" assert "POR not SIGHASH_ALL" in story @@ -152,14 +251,34 @@ def test_bip322_por_invalid_sighash(sighash, bip322_txn, start_sign, cap_story, [["p2wpkh", None, None]], [["p2wpkh", None, None], ["p2wpkh", None, 10000000]], ]) -def test_bip322_0th_input_witness_utxo(ins, bip322_txn, start_sign, cap_story): - # not allowed - 0th input needs to have full pre-segwit utxo +def test_bip322_0th_input_witness_utxo(ins, bip322_txn, start_sign, end_sign, cap_story, + verify_msg_bip322_por, bip322_verify): + # allowed when the BIP-322 message is provided in the global PSBT field psbt, _ = bip322_txn(ins, witness_utxo=[0]) + start_sign(psbt, finalize=True) + verify_msg_bip322_por("POR", is_por=len(ins) > 1) + time.sleep(.1) + title, story = cap_story() + assert title == "OK TO SIGN?" + signed = end_sign(accept=True) + bip322_verify(signed) + + +def test_bip322_0th_input_witness_utxo_requires_zero_value(bip322_txn, start_sign, cap_story): + def hack(psbt_in): + txo = CTxOut() + txo.deserialize(BytesIO(psbt_in.inputs[0].witness_utxo)) + txo.nValue = 1 + psbt_in.inputs[0].witness_utxo = txo.serialize() + + psbt, _ = bip322_txn([["p2wpkh", None, None], ["p2wpkh", None, 10000000]], + witness_utxo=[0], psbt_hacker=hack) + start_sign(psbt, finalize=True) title, story = cap_story() assert title == "Failure" assert "i0: invalid BIP-322 'to_spend'" in story - assert "utxo" in story + assert "input0 value" in story @pytest.mark.parametrize("ins", [ @@ -168,18 +287,21 @@ def test_bip322_0th_input_witness_utxo(ins, bip322_txn, start_sign, cap_story): [["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, - verify_msg_bip322_por): - # allowed - 0th input needs to have full pre-segwit utxo, all other can be just witness_utxo + verify_msg_bip322_por, bip322_verify): + # allowed - input 0 has full utxo here, other inputs can be witness_utxo-only 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") + verify_msg_bip322_por(msg.decode()) time.sleep(.1) title, story = cap_story() assert title == "OK TO SIGN?" - assert bip322_msg_hash(msg).hex() in story - assert msg_challenge.hex() in story - end_sign(accept=True, finalize=True) + assert ("Message:\n%s" % msg.decode()) in story + assert "Message Hash:" not in story + assert "Challenge Address:" in story + assert "Message Challenge:" not in story + signed = end_sign(accept=True) + bip322_verify(signed) @pytest.mark.parametrize("ins", [ @@ -194,8 +316,9 @@ def test_bip322_incomplete_psbt_bip32_paths(ins, bip322_txn, start_sign, cap_sto verify_msg_bip322_por): def hack(psbt_in): + without_paths = 0 if len(psbt_in.inputs) == 1 else 1 for i, inp in enumerate(psbt_in.inputs): - if i == 0: + if i == without_paths: inp.bip32_paths = None psbt, _ = bip322_txn(ins, psbt_hacker=hack) @@ -205,12 +328,26 @@ def test_bip322_incomplete_psbt_bip32_paths(ins, bip322_txn, start_sign, cap_sto assert title == "Failure" assert 'PSBT does not contain any key path information.' in story else: - verify_msg_bip322_por("POR", way="sd") + verify_msg_bip322_por("POR") 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 + assert "because we do not know the key: 1" in story + + +def test_bip322_por_input0_bip32_paths_required(bip322_txn, start_sign, cap_story): + def hack(psbt_in): + psbt_in.inputs[0].bip32_paths = None + + psbt, _ = bip322_txn([["p2wpkh", None, None], ["p2wpkh", None, 10000000]], + psbt_hacker=hack) + + start_sign(psbt) + title, story = cap_story() + assert title == "Failure" + assert "i0: invalid BIP-322 'to_spend'" in story + assert "not our key" in story @pytest.mark.parametrize("ins", [ @@ -265,7 +402,7 @@ def test_bip322_invalid_to_spend_scriptSig(ins, bip322_txn, start_sign, cap_stor title, story = cap_story() assert title == "Failure" assert "i0: invalid BIP-322 'to_spend'" in story - assert "scriptSig" in story + assert "to_spend hash" in story @pytest.mark.parametrize("ins", [ @@ -293,9 +430,7 @@ def test_bip322_invalid_to_spend_prevout(ins, bip322_txn, start_sign, cap_story) to_spend_tx.calc_sha256() - spendable = CTxIn(COutPoint(to_spend_tx.sha256, 0)) - - to_sign_tx.vin = [spendable] + to_sign_tx.vin[0] = CTxIn(COutPoint(to_spend_tx.sha256, 0)) psbt_in.txn = to_sign_tx.serialize_with_witness() @@ -304,7 +439,7 @@ def test_bip322_invalid_to_spend_prevout(ins, bip322_txn, start_sign, cap_story) title, story = cap_story() assert title == "Failure" assert "i0: invalid BIP-322 'to_spend'" in story - assert "prevout" in story + assert "to_spend hash" in story @pytest.mark.parametrize("ins", [ @@ -342,7 +477,7 @@ def test_bip322_invalid_to_spend_num_inputs(ins, bip322_txn, start_sign, cap_sto title, story = cap_story() assert title == "Failure" assert "i0: invalid BIP-322 'to_spend'" in story - assert "num ins" in story + assert "to_spend hash" in story @pytest.mark.parametrize("ins", [ @@ -380,7 +515,7 @@ def test_bip322_invalid_to_spend_num_outputs(ins, bip322_txn, start_sign, cap_st title, story = cap_story() assert title == "Failure" assert "i0: invalid BIP-322 'to_spend'" in story - assert "num outs" in story + assert "to_spend hash" in story @pytest.mark.parametrize("ins", [ @@ -416,7 +551,7 @@ def test_bip322_invalid_to_spend_out_nVal(ins, bip322_txn, start_sign, cap_story title, story = cap_story() assert title == "Failure" assert "i0: invalid BIP-322 'to_spend'" in story - assert "nVal" in story + assert "to_spend hash" in story @pytest.mark.parametrize("addr_fmt", [AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH]) @@ -424,7 +559,8 @@ 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, verify_msg_bip322_por): + import_ms_wallet, clear_ms, num_ins, verify_msg_bip322_por, + bip322_verify): clear_ms() M, N = M_N @@ -448,29 +584,38 @@ 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") + is_por = num_ins > 1 + verify_msg_bip322_por("POR", is_por=is_por) time.sleep(.1) title, story = cap_story() assert title == "OK TO SIGN?" - assert "Proof of Reserves" in story + assert ("Proof of Reserves" if is_por else "BIP-322 Message") in story assert 'Network fee' not in story - if num_ins == 1: - # only the message signed input - amount is zero - assert "Amount 0.00000000 XTN" in story - assert "1 input" in story + if not is_por: + assert "Proof of Reserves" not in story + assert "Amount " not in story + assert "1 input" not in story + assert "1 output" not in story else: amt = (num_ins - 1) * inp_amount str_amt = str(Decimal(amt / 100000000).quantize(Decimal('.00000001'))) assert ("Amount %s XTN" % str_amt) in story assert ("%d inputs" % num_ins) in story - assert ("Message Hash:\n%s" % bip322_msg_hash(b"POR").hex()) in story - assert ("Message Challenge:\n%s" % msg_challenge.hex()) in story - assert "1 output" in story - assert "- OP_RETURN -" in story - assert "null-data" in story + assert "Message:\nPOR" in story + assert "Message Hash:" not in story + assert "Challenge Address:" in story + assert "Message Challenge:" not in story + assert ("1 output" in story) == is_por + assert "- OP_RETURN -" not in story + assert "null-data" not in story - end_sign(accept=True, finalize=signed) + signed_psbt = end_sign(accept=True) + if signed: + # with_sigs=True preloads placeholder cosigner signatures; the device + # can accept the PSBT shape, but a real signature verifier must reject it. + with pytest.raises(AssertionError): + bip322_verify(signed_psbt) @pytest.mark.parametrize("addr_fmt", [AF_P2WSH, AF_P2SH]) @@ -504,82 +649,12 @@ def test_bip322_invalid_ms_psbt(addr_fmt, bip322_ms_txn, start_sign, cap_story, 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 - - -def test_bip322_msg_import_fail(bip322_txn, start_sign, end_sign, cap_story, need_keypress, - press_select, OK, press_cancel, cap_menu, microsd_path, enter_complex): - - msg = b"it's me!" - psbt, msg_challenge = bip322_txn([["p2wpkh", None, None]], msg=msg) - start_sign(psbt, finalize=True) - time.sleep(.1) - title, story = cap_story() - assert 'BIP-322' in title - - need_keypress("1") # SD - time.sleep(.1) - title, story = cap_story() - assert f"Press {OK} to approve message" in story - press_cancel() # refuse - time.sleep(.1) - assert "Ready To Sign" in cap_menu() - - start_sign(psbt, finalize=True) - time.sleep(.1) - title, story = cap_story() - assert 'BIP-322' in title - - need_keypress("0") # manual input - # leave empty - press_cancel() - time.sleep(.1) - title, story = cap_story() - assert title == "Failure" - assert "need msg" in story - assert "Msg verification failed" in story - press_cancel() - - start_sign(psbt, finalize=True) - time.sleep(.1) - title, story = cap_story() - assert 'BIP-322' in title - - need_keypress("0") # manual input - enter_complex("AAA", apply=False, b39pass=False) # msg wrong - time.sleep(.1) - title, story = cap_story() - assert title == "Failure" - assert "Msg verification failed" in story - assert "hash verification failed" in story - press_cancel() - - @pytest.mark.parametrize("num_ins", [1, 12]) @pytest.mark.parametrize("addr_fmt", ["p2pkh", "p2wpkh", "p2sh-p2wpkh"]) def test_wif_store_sign_bip322_por(num_ins, addr_fmt, bip322_txn, goto_home, pick_menu_item, need_keypress, start_sign, end_sign, cap_menu, cap_story, - press_cancel, settings_remove, press_select, import_wif_to_store): + press_cancel, settings_remove, press_select, import_wif_to_store, + bip322_verify): settings_remove("wifs") @@ -600,7 +675,7 @@ def test_wif_store_sign_bip322_por(num_ins, addr_fmt, bip322_txn, goto_home, pic ins.append([addr_fmt, None, amt , n.node.private_key.K.sec()]) msg = b"Coinkite" - psbt, msg_challenge = bip322_txn(ins, msg=b"Coinkite") + psbt, msg_challenge = bip322_txn(ins, msg=msg) import_wif_to_store(wifs) @@ -610,22 +685,52 @@ def test_wif_store_sign_bip322_por(num_ins, addr_fmt, bip322_txn, goto_home, pic start_sign(psbt, finalize=True) time.sleep(.1) title, story = cap_story() - assert "import message" in story - # msg file was auto-gened on SD card - need_keypress("1") - time.sleep(.1) - title, story = cap_story() - assert title == "Message:" + assert title == "OK TO SIGN?" + assert ("Proof of Reserves" if num_ins > 1 else "BIP-322 Message") in story + if num_ins == 1: + assert "Proof of Reserves" not in story + assert "Amount " not in story + assert "1 input" not in story + assert "1 output" not in story assert msg.decode() in story - press_select() - time.sleep(.1) - title, story = cap_story() - assert "Proof of Reserves" in story + assert "Message Hash:" not in story assert "warning" in story if num_ins == 1: assert "WIF store: 0" in story else: assert f"WIF store: {', '.join([str(i) for i in range(num_ins)])}" in story - end_sign(finalize=True) + signed = end_sign() + bip322_verify(signed) + + +@pytest.mark.parametrize("bip32_paths", [True, False]) +@pytest.mark.parametrize("por", [True, False]) +def test_bip322_empty_message_challenge_rejected(bip32_paths, por, bip322_txn, + start_sign, cap_story): + def hack(psbt_in): + to_spend_tx = CTransaction() + to_sign_tx = CTransaction() + to_spend_tx.deserialize(BytesIO(psbt_in.inputs[0].utxo)) + to_sign_tx.deserialize(BytesIO(psbt_in.txn)) + + if not bip32_paths: + psbt_in.inputs[0].bip32_paths = None + to_spend_tx.vout[0].scriptPubKey = b"" + psbt_in.inputs[0].utxo = to_spend_tx.serialize_with_witness() + + to_spend_tx.calc_sha256() + to_sign_tx.vin[0] = CTxIn(COutPoint(to_spend_tx.sha256, 0), + nSequence=to_sign_tx.vin[0].nSequence) + psbt_in.txn = to_sign_tx.serialize_with_witness() + + ins = [["p2wpkh", None, None]] + if por: + ins.append(["p2wpkh", None, 10000000]) + + psbt, _ = bip322_txn(ins, psbt_hacker=hack) + + start_sign(psbt) + title, story = cap_story() + assert title == "Failure" # EOF diff --git a/testing/test_hsm.py b/testing/test_hsm.py index 0a4158c3..af340bac 100644 --- a/testing/test_hsm.py +++ b/testing/test_hsm.py @@ -937,6 +937,72 @@ def test_sign_msg_any(quick_start_hsm, attempt_msg_sign, addr_fmt=AF_CLASSIC): for p in permit+block: attempt_msg_sign(None, msg, p, addr_fmt=addr_fmt) + +def test_bip322_psbt_uses_msg_sign_policy(quick_start_hsm, change_hsm, attempt_psbt, + bip322_txn): + psbt, _ = bip322_txn([["p2wpkh", "0/0", None]], msg=b"HSM BIP-322 message") + + quick_start_hsm(DICT(msg_paths=["m/0/0"])) + attempt_psbt(psbt) + + change_hsm(DICT(msg_paths=["any"])) + attempt_psbt(psbt) + + change_hsm(DICT(msg_paths=["m/9"])) + attempt_psbt(psbt, "Message signing not enabled for that path") + + change_hsm(DICT(rules=[{}])) + attempt_psbt(psbt, "Message signing not permitted") + + +def test_bip322_por_psbt_uses_msg_sign_policy(quick_start_hsm, change_hsm, attempt_psbt, + bip322_txn): + psbt, _ = bip322_txn([ + ["p2wpkh", "0/0", None], + ["p2wpkh", "0/1", 1000000], + ["p2sh-p2wpkh", "0/2", 2000000], + ["p2pkh", "0/3", 3000000], + ], msg=b"HSM BIP-322 proof of reserves") + + quick_start_hsm(DICT(msg_paths=["m/0/*"])) + attempt_psbt(psbt) + + change_hsm(DICT(msg_paths=["any"])) + attempt_psbt(psbt) + + change_hsm(DICT(msg_paths=["m/0/0", "m/0/1", "m/0/2"])) + attempt_psbt(psbt, "Message signing not enabled for that path") + + change_hsm(DICT(msg_paths=["m/0/0", "m/0/1", "m/0/2", "m/0/3"])) + attempt_psbt(psbt) + + change_hsm(DICT(rules=[{}])) + attempt_psbt(psbt, "Message signing not permitted") + + +def test_bip322_ms_psbt_uses_msg_sign_policy(quick_start_hsm, change_hsm, attempt_psbt, + bip322_ms_txn, import_ms_wallet, clear_ms): + clear_ms() + deriv = "m/48h/1h/0h/2h" + + def path_mapper(idx): + return [0x80000030, 0x80000001, 0x80000000, 0x80000002, 0, 0] + + keys = import_ms_wallet(1, 1, name="hsm_bip322_msg", accept=True, addr_fmt=AF_P2WSH, + common=deriv, do_import=True, descriptor=True) + psbt, _ = bip322_ms_txn(1, 1, keys, path_mapper=path_mapper, inp_af=AF_P2WSH, + msg=b"HSM multisig BIP-322 message") + + quick_start_hsm(DICT(msg_paths=[deriv + "/0/0"])) + attempt_psbt(psbt) + + change_hsm(DICT(msg_paths=["any"])) + attempt_psbt(psbt) + + change_hsm(DICT(msg_paths=["m/48h/1h/0h/2h/0/9"])) + attempt_psbt(psbt, "Message signing not enabled for that path") + + def test_must_log(dev, start_hsm, sd_cards_eject, attempt_msg_sign, fake_txn, attempt_psbt, is_simulator): # stop everything if can't log policy = DICT(must_log=True, msg_paths=['m'], rules=[{}]) diff --git a/testing/test_sign.py b/testing/test_sign.py index 70e6a6d0..1c93284b 100644 --- a/testing/test_sign.py +++ b/testing/test_sign.py @@ -3779,4 +3779,37 @@ def test_txid_qr(fake_txn, start_sign, cap_story, press_cancel, press_select, go assert "(6) for QR Code of TXID" in story press_cancel() + +@pytest.mark.parametrize("segwit_in", [True, False]) +def test_empty_input_scriptPubKey(segwit_in, dev, fake_txn, start_sign, cap_story): + def hack(psbt): + target_idx = 0 + + if segwit_in: + txo = CTxOut() + txo.deserialize(BytesIO(psbt.inputs[target_idx].witness_utxo)) + txo.scriptPubKey = b"" + psbt.inputs[target_idx].witness_utxo = txo.serialize() + else: + supply_tx = CTransaction() + supply_tx.deserialize(BytesIO(psbt.inputs[target_idx].utxo)) + supply_tx.vout[0].scriptPubKey = b"" + psbt.inputs[target_idx].utxo = supply_tx.serialize_with_witness() + + supply_tx.calc_sha256() + + spend_tx = CTransaction() + spend_tx.deserialize(BytesIO(psbt.txn)) + spend_tx.vin[target_idx] = CTxIn( + COutPoint(supply_tx.sha256, 0), + nSequence=spend_tx.vin[target_idx].nSequence, + ) + psbt.txn = spend_tx.serialize_with_witness() + + psbt = fake_txn(2, 1, dev.master_xpub, psbt_hacker=hack, segwit_in=segwit_in) + + start_sign(psbt) + title, _ = cap_story() + assert title == "Failure" + # EOF