BIP-322 changes after BIP got in to the complete state
This commit is contained in:
parent
7e92e5162a
commit
d656f371c7
@ -9,56 +9,104 @@ BIP-322 specification: <https://github.com/bitcoin/bips/blob/master/bip-0322.med
|
||||
COLDCARD accepts a specially crafted PSBT file to sign as BIP-322 Proof of Reserves. The PSBT
|
||||
must meet all these requirements:
|
||||
|
||||
* COLDCARD acts as a BIP-322 PSBT signer. It validates the BIP-322 `to_sign`
|
||||
transaction, shows the message from `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE`, and
|
||||
adds signatures to the PSBT. Finalizing and encoding the final BIP-322
|
||||
signature string is the responsibility of the finalizer.
|
||||
* PSBT MUST include `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE = 0x09`; the value is
|
||||
the exact message shown to the user and signed by BIP-322.
|
||||
* 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`
|
||||
* First (0th) input in `to_sign` transaction MUST have full (pre-segwit) UTXO (`PSBT_IN_NON_WITNESS_UTXO`) a.k.a `to_spend`.
|
||||
* First (0th) input in `to_sign` `PSBT_IN_NON_WITNESS_UTXO` transaction (`to_spend`) is as defined
|
||||
in [BIP-322](https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki#full):
|
||||
* PSBT (`to_sign`) MUST have at least one input.
|
||||
* First (0th) input of `to_sign` MUST spend the BIP-322 `to_spend` output.
|
||||
* Input 0 MUST include one of `PSBT_IN_NON_WITNESS_UTXO` or `PSBT_IN_WITNESS_UTXO`.
|
||||
* When input 0 provides `PSBT_IN_WITNESS_UTXO`, COLDCARD reconstructs the
|
||||
expected `to_spend` txid from `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE` and the
|
||||
witness UTXO scriptPubKey.
|
||||
* When input 0 provides `PSBT_IN_NON_WITNESS_UTXO`, it MUST be the BIP-322
|
||||
`to_spend` transaction as defined in
|
||||
[BIP-322](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 & first input MUST be `to_spend` full txn
|
||||
* PSBT (`to_sign`) MUST only have one output with null-data `OP_RETURN`
|
||||
* `to_sign` transaction version MUST be 0 or 2.
|
||||
* Optionally inputs can be added to `to_sign` for Proof of Reserve signing.
|
||||
* PSBT MUST be version 0.
|
||||
* PSBT MUST be version 0 or 2.
|
||||
* Foreign inputs not allowed in POR PSBT.
|
||||
|
||||
The signatures created by the BIP-322 process will never be suitable
|
||||
for a on-chain Bitcoin transaction that could move funds, because
|
||||
of these restrictions imposed by BIP-322.
|
||||
|
||||
### Output
|
||||
|
||||
COLDCARD always returns a signed PSBT for BIP-322 message signing and Proof of
|
||||
Reserves. It never returns an extracted/finalized transaction for these PSBTs.
|
||||
This is true even when finalization is requested over USB, such as with
|
||||
`ckcc unsigned.psbt --finalize`.
|
||||
|
||||
The signed PSBT is the handoff artifact for the external finalizer/verifier. It
|
||||
keeps the PSBT metadata needed to verify or finalize the BIP-322 signature,
|
||||
including public keys, scripts, partial signatures, and UTXO data. This matters
|
||||
because the address being proven normally commits only to a hash of the public
|
||||
key or script, not the public key or script itself.
|
||||
|
||||
### Proof of Reserves Signing Experience
|
||||
|
||||
After Coldcard recognizes BIP-322 PoR PSBT it asks the user to
|
||||
import a human-readable message that was used to build `to_spend`
|
||||
scriptSig. This message must hash exactly the `message_hash` from
|
||||
the PSBT, otherwise signing is not offered.
|
||||
After Coldcard recognizes a BIP-322 PSBT it reads the message from
|
||||
`PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE` and shows it to the user for approval.
|
||||
COLDCARD verifies that the message hash matches the input 0 `to_spend`
|
||||
commitment before offering to sign.
|
||||
|
||||
When the PSBT contains only input 0, COLDCARD labels the request as
|
||||
`BIP-322 Message`, because it is message signing and does not prove ownership
|
||||
of any additional reserve UTXOs. In that case it does not show transaction
|
||||
input/output counts. When the PSBT contains additional inputs, COLDCARD labels
|
||||
the request as `Proof of Reserves` and shows the reserve amount.
|
||||
|
||||
If the message contains non-ASCII characters, COLDCARD warns that some
|
||||
characters may not be readable on screen.
|
||||
|
||||
Legacy PoR PSBTs without `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE` are rejected by
|
||||
this flow.
|
||||
|
||||
Read more [here.](https://gist.github.com/orangesurf/0c1d0a31d3ebe7e48335a34d56788d4c)
|
||||
|
||||
Example screen text:
|
||||
Example screen text for a one-input BIP-322 message signing PSBT:
|
||||
|
||||
```text
|
||||
BIP-322 Message
|
||||
|
||||
Message:
|
||||
This is the signed message
|
||||
|
||||
Challenge Address:
|
||||
bc1qzvjnhf7k70uxv6xvneaqxql7k09dd6nsr5wheq
|
||||
|
||||
Press ENTER to approve and sign message. Press (2) to explore transaction.
|
||||
CANCEL to abort.
|
||||
```
|
||||
|
||||
Example screen text for a Proof of Reserves PSBT:
|
||||
|
||||
```text
|
||||
Proof of Reserves
|
||||
|
||||
Message:
|
||||
POR
|
||||
|
||||
Amount 0.20000000 BTC
|
||||
|
||||
Message Hash:
|
||||
11b5fe357842f5c368d2e3884d6a5ba577e3bc7cde132004f39b8c2a43a9cdec
|
||||
Challenge Address:
|
||||
bc1qzvjnhf7k70uxv6xvneaqxql7k09dd6nsr5wheq
|
||||
|
||||
Message Challenge:
|
||||
00140b2537a7d6f3cc668c9e9fa0303ffb3cad6e9b81
|
||||
21 inputs
|
||||
1 output
|
||||
|
||||
21 inputs
|
||||
1 output
|
||||
|
||||
0.00000000 BTC
|
||||
- OP_RETURN -
|
||||
null-data
|
||||
|
||||
Press ENTER to approve and sign transaction. Press (2) to explore txn
|
||||
outputs. CANCEL to abort.
|
||||
Press ENTER to approve and sign proof of reserves. Press (2) to explore transaction.
|
||||
CANCEL to abort.
|
||||
```
|
||||
|
||||
2
external/ckcc-protocol
vendored
2
external/ckcc-protocol
vendored
@ -1 +1 @@
|
||||
Subproject commit 51de25089ef0154f6cc4b54a849e611e8c88a3fd
|
||||
Subproject commit 3d1dfa858beb58b8dac37d8c66d7aed2909812f2
|
||||
@ -4,6 +4,8 @@ This lists the new changes that have not yet been published in a normal release.
|
||||
|
||||
# Shared Improvements - Both Mk and Q
|
||||
|
||||
- Change: BIP-322 Proof of Reserves & message signing PSBT requires PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE
|
||||
(read more [BIP-322 Proof of Reserves documentation](../docs/proof-of-reserves-bip-322.md) )
|
||||
- Bugfix: Disable Virtual Disk and NFC before activating HSM
|
||||
- Bugfix: Custom address default menu position wrong
|
||||
- Bugfix: Delta Mode Trick PIN was never restored from backup
|
||||
|
||||
110
shared/auth.py
110
shared/auth.py
@ -292,56 +292,6 @@ 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 Messsage" if version.has_qwerty else 'BIP-322 MSG',
|
||||
no_qr=not version.has_qwerty)
|
||||
|
||||
# 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'
|
||||
|
||||
if ch == KEY_CANCEL:
|
||||
return
|
||||
elif ch == "0":
|
||||
msg = await ux_input_text("", confirm_exit=False)
|
||||
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 message from a QR code')
|
||||
else:
|
||||
choices = await file_picker(suffix='.txt', ux=False, **ch)
|
||||
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, **ch)
|
||||
|
||||
if not fn: return
|
||||
|
||||
with CardSlot(readonly=True, **ch) as card:
|
||||
with open(fn, 'rt') as fd:
|
||||
msg = fd.read()
|
||||
|
||||
assert msg, "need msg"
|
||||
msg_hash = ngu.hash.sha256t(bip322_tag_hash, msg, True)
|
||||
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="Message:"
|
||||
)
|
||||
return True if ch == "y" else False
|
||||
|
||||
|
||||
def render_output(self, o):
|
||||
# Pretty-print a transactions output.
|
||||
# - expects CTxOut object
|
||||
@ -477,6 +427,7 @@ class ApproveTransaction(UserAuthorizedAction):
|
||||
#
|
||||
try:
|
||||
msg = uio.StringIO()
|
||||
is_por = self.psbt.por322 and (self.psbt.num_inputs > 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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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():
|
||||
|
||||
106
shared/psbt.py
106
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("<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)
|
||||
assert inp.required_key, "not our key"
|
||||
self.validate_bip322_input0(inp, txi, utxo)
|
||||
except Exception as e:
|
||||
raise FatalPSBTIssue("i0: invalid BIP-322 'to_spend': %s" % e)
|
||||
|
||||
@ -2011,6 +2024,9 @@ class psbtObject(psbtProxy):
|
||||
for v, k in self.xpubs:
|
||||
wr(PSBT_GLOBAL_XPUB, v, k)
|
||||
|
||||
if self.por322_msg:
|
||||
wr(PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE, self.por322_msg.encode())
|
||||
|
||||
if self.unknown:
|
||||
for k, v in self.unknown.items():
|
||||
wr(k[0], v, k[1:])
|
||||
|
||||
@ -379,7 +379,7 @@ class CTxOut(object):
|
||||
# rare, pay to full pubkey
|
||||
return AF_BARE_PK, self.scriptPubKey[2:2+33], False
|
||||
|
||||
if self.scriptPubKey[0] == OP_RETURN:
|
||||
if self.is_op_return():
|
||||
return OP_RETURN, self.scriptPubKey, False
|
||||
|
||||
return None, self.scriptPubKey, None
|
||||
@ -410,6 +410,9 @@ class CTxOut(object):
|
||||
and (self.scriptPubKey[0] == 0x21 or self.scriptPubKey[0] == 0x41) \
|
||||
and self.scriptPubKey[-1] == 0xac
|
||||
|
||||
def is_op_return(self):
|
||||
return self.scriptPubKey and (self.scriptPubKey[0] == OP_RETURN)
|
||||
|
||||
#def __repr__(self):
|
||||
# return "CTxOut(nValue=%d scriptPubKey=%s)" \
|
||||
# % (self.nValue, b2a_hex(self.scriptPubKey))
|
||||
|
||||
@ -2,14 +2,18 @@
|
||||
#
|
||||
# construct Proof of Reserves transaction according to BIP-322
|
||||
#
|
||||
import pytest, struct, hashlib
|
||||
import 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 helpers import hash160, str_to_path, taptweak
|
||||
from bip32 import BIP32Node, PublicKey
|
||||
from constants import simulator_fixed_tprv, AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH
|
||||
from ctransaction import CTransaction, COutPoint, CTxIn, CTxOut, uint256_from_str
|
||||
from sighash import legacy_sighash, segwit_v0_sighash, taproot_sighash, SIGHASH_DEFAULT, SIGHASH_ALL
|
||||
from pysecp256k1 import ec_pubkey_parse, ecdsa_signature_parse_der, ecdsa_verify
|
||||
from pysecp256k1.extrakeys import xonly_pubkey_parse
|
||||
from pysecp256k1.schnorrsig import schnorrsig_verify
|
||||
|
||||
|
||||
def bip322_msg_hash(msg):
|
||||
@ -17,248 +21,402 @@ def bip322_msg_hash(msg):
|
||||
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
|
||||
def ecdsa_verify_sig(pubkey, sig, digest):
|
||||
if not sig or sig[-1] != SIGHASH_ALL:
|
||||
return False
|
||||
try:
|
||||
parsed = ecdsa_signature_parse_der(sig[:-1])
|
||||
return bool(ecdsa_verify(parsed, ec_pubkey_parse(pubkey), digest))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bip322_txn(dev, pytestconfig, create_msg_file):
|
||||
def bip322_verify(psbt_bytes):
|
||||
"""Verify BIP-322 PSBT signatures without a full script interpreter.
|
||||
|
||||
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):
|
||||
Enforces the BIP-322 transaction shape, SIGHASH_ALL for ECDSA,
|
||||
SIGHASH_DEFAULT/SIGHASH_ALL for taproot, and direct signature checks for
|
||||
p2pkh, p2wpkh, p2sh-p2wpkh, sh, wsh, and p2tr key-path.
|
||||
It intentionally omits consensus-level script evaluation rules such as
|
||||
CLEANSTACK, MINIMALIF, NULLFAIL beyond empty CHECKMULTISIG dummy,
|
||||
CODESEPARATOR/FindAndDelete handling, and NOP-upgrade checks; unsupported
|
||||
scripts raise AssertionError.
|
||||
"""
|
||||
psbt = BasicPSBT().parse(psbt_bytes)
|
||||
assert psbt.bip322_msg is not None
|
||||
msg = psbt.bip322_msg
|
||||
tx = CTransaction()
|
||||
if psbt.txn:
|
||||
tx.deserialize(BytesIO(psbt.txn))
|
||||
else:
|
||||
tx.nVersion = psbt.txn_version
|
||||
tx.nLockTime = psbt.fallback_locktime or 0
|
||||
for inp in psbt.inputs:
|
||||
tx.vin.append(CTxIn(COutPoint(uint256_from_str(inp.previous_txid), inp.prevout_idx),
|
||||
nSequence=inp.sequence if inp.sequence is not None else 0xffffffff))
|
||||
for out in psbt.outputs:
|
||||
tx.vout.append(CTxOut(out.amount, out.script))
|
||||
|
||||
msg_challenge = None
|
||||
inp0 = psbt.inputs[0]
|
||||
to_spend = None
|
||||
if inp0.utxo:
|
||||
to_spend = CTransaction()
|
||||
to_spend.deserialize(BytesIO(inp0.utxo))
|
||||
assert len(to_spend.vout) == 1
|
||||
assert to_spend.vout[0].nValue == 0
|
||||
script_pubkey = to_spend.vout[0].scriptPubKey
|
||||
else:
|
||||
assert inp0.witness_utxo
|
||||
witness_utxo = CTxOut()
|
||||
witness_utxo.deserialize(BytesIO(inp0.witness_utxo))
|
||||
assert witness_utxo.nValue == 0
|
||||
script_pubkey = witness_utxo.scriptPubKey
|
||||
|
||||
num_ins = len(inputs)
|
||||
expected_to_spend = CTransaction()
|
||||
expected_to_spend.nVersion = 0
|
||||
expected_to_spend.nLockTime = 0
|
||||
expected_to_spend.vin = [CTxIn(COutPoint(hash=0, n=0xffffffff),
|
||||
scriptSig=b'\x00\x20' + bip322_msg_hash(msg),
|
||||
nSequence=0)]
|
||||
expected_to_spend.vout = [CTxOut(0, script_pubkey)]
|
||||
expected_to_spend.calc_sha256()
|
||||
if to_spend:
|
||||
assert to_spend.serialize_without_witness() == expected_to_spend.serialize_without_witness()
|
||||
to_spend = expected_to_spend
|
||||
|
||||
psbt = BasicPSBT()
|
||||
assert tx.nVersion in (0, 2)
|
||||
assert len(tx.vin) >= 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('<I', j) for j in xfp_path)
|
||||
if with_sigs and (xfp_path[0] != keys[-1][0]) and len(psbt.inputs[i].part_sigs) < (M-1): # only cosigner signatures are added
|
||||
psbt.inputs[i].part_sigs[pubkey] = b"\x30" + 70*b"a"
|
||||
|
||||
if i == 0:
|
||||
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.append(CTxOut(0, scriptPubKey))
|
||||
msg_challenge = scriptPubKey
|
||||
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(input_amount), scriptPubKey))
|
||||
|
||||
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()
|
||||
# always add whole txn as utxo
|
||||
psbt.inputs[i].utxo = to_spend.serialize_with_witness()
|
||||
if sighash is not None and (i != 0):
|
||||
psbt.inputs[i].sighash = sighash
|
||||
|
||||
for pubkey, xfp_path in details:
|
||||
psbt.inputs[i].bip32_paths[pubkey] = b''.join(struct.pack('<I', j) for j in xfp_path)
|
||||
if with_sigs and (xfp_path[0] != keys[-1][0]) and len(psbt.inputs[i].part_sigs) < (M-1): # only cosigner signatures are added
|
||||
psbt.inputs[i].part_sigs[pubkey] = b"\x30" + 70*b"a"
|
||||
|
||||
if i == 0:
|
||||
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.append(CTxOut(0, scriptPubKey))
|
||||
msg_challenge = scriptPubKey
|
||||
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(input_amount), scriptPubKey))
|
||||
|
||||
# always add whole txn as utxo
|
||||
psbt.inputs[i].utxo = to_spend.serialize_with_witness()
|
||||
if sighash is not None and (i != 0):
|
||||
psbt.inputs[i].sighash = sighash
|
||||
|
||||
to_spend.calc_sha256()
|
||||
to_spend.calc_sha256()
|
||||
|
||||
if num_ins == 1:
|
||||
# basic msg sign
|
||||
seq = 0
|
||||
else:
|
||||
if lock_time and not i:
|
||||
seq = 0xfffffffd
|
||||
else:
|
||||
seq = 0xffffffff
|
||||
|
||||
spendable = CTxIn(COutPoint(to_spend.sha256, 0), nSequence=seq)
|
||||
txn.vin.append(spendable)
|
||||
spendable = CTxIn(COutPoint(to_spend.sha256, 0), nSequence=seq)
|
||||
txn.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')
|
||||
txn.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')
|
||||
txn.vout.append(op_return_out)
|
||||
|
||||
psbt.outputs.append(op_ret_o)
|
||||
psbt.outputs.append(op_ret_o)
|
||||
|
||||
if hack_psbt:
|
||||
hack_psbt(psbt)
|
||||
if hack_psbt:
|
||||
hack_psbt(psbt)
|
||||
|
||||
psbt.txn = txn.serialize_with_witness()
|
||||
psbt.txn = txn.serialize_with_witness()
|
||||
if psbt_v2:
|
||||
psbt.parsed_txn = CTransaction()
|
||||
psbt.parsed_txn.deserialize(BytesIO(psbt.txn))
|
||||
psbt.to_v2()
|
||||
|
||||
rv = BytesIO()
|
||||
psbt.serialize(rv)
|
||||
assert rv.tell() <= MAX_TXN_LEN, 'too fat'
|
||||
rv = BytesIO()
|
||||
psbt.serialize(rv)
|
||||
assert rv.tell() <= MAX_TXN_LEN, 'too fat'
|
||||
|
||||
return rv.getvalue(), msg_challenge
|
||||
|
||||
return doit
|
||||
return rv.getvalue(), msg_challenge
|
||||
|
||||
@ -3019,6 +3019,26 @@ def import_wif_to_store(goto_home, pick_menu_item, cap_story, press_select, cap_
|
||||
|
||||
return doit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bip322_txn(dev, pytestconfig):
|
||||
from bip322 import bip322_txn
|
||||
return functools.partial(bip322_txn, master_xpub=dev.master_xpub,
|
||||
psbt_v2=pytestconfig.getoption('psbt2'))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bip322_ms_txn(pytestconfig):
|
||||
from bip322 import bip322_ms_txn
|
||||
return functools.partial(bip322_ms_txn, psbt_v2=pytestconfig.getoption('psbt2'))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bip322_verify():
|
||||
from bip322 import bip322_verify
|
||||
return bip322_verify
|
||||
|
||||
|
||||
# useful fixtures
|
||||
from test_backup import backup_system
|
||||
from test_bbqr import readback_bbqr, render_bbqr, readback_bbqr_ll, try_sign_bbqr, split_scan_bbqr
|
||||
@ -3039,6 +3059,5 @@ 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, create_msg_file
|
||||
|
||||
# EOF
|
||||
|
||||
@ -14,58 +14,59 @@ b2a_hex = lambda a: str(_b2a_hex(a), 'ascii')
|
||||
# BIP-174 aka PSBT defined values
|
||||
#
|
||||
# GLOBAL ===
|
||||
PSBT_GLOBAL_UNSIGNED_TX = 0x00
|
||||
PSBT_GLOBAL_XPUB = 0x01
|
||||
PSBT_GLOBAL_VERSION = 0xfb
|
||||
PSBT_GLOBAL_PROPRIETARY = 0xfc
|
||||
PSBT_GLOBAL_UNSIGNED_TX = 0x00
|
||||
PSBT_GLOBAL_XPUB = 0x01
|
||||
PSBT_GLOBAL_VERSION = 0xfb
|
||||
PSBT_GLOBAL_PROPRIETARY = 0xfc
|
||||
|
||||
# BIP-370
|
||||
PSBT_GLOBAL_TX_VERSION = 0x02
|
||||
PSBT_GLOBAL_FALLBACK_LOCKTIME = 0x03
|
||||
PSBT_GLOBAL_INPUT_COUNT = 0x04
|
||||
PSBT_GLOBAL_OUTPUT_COUNT = 0x05
|
||||
PSBT_GLOBAL_TX_MODIFIABLE = 0x06
|
||||
PSBT_GLOBAL_TX_VERSION = 0x02
|
||||
PSBT_GLOBAL_FALLBACK_LOCKTIME = 0x03
|
||||
PSBT_GLOBAL_INPUT_COUNT = 0x04
|
||||
PSBT_GLOBAL_OUTPUT_COUNT = 0x05
|
||||
PSBT_GLOBAL_TX_MODIFIABLE = 0x06
|
||||
PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE = 0x09
|
||||
|
||||
# INPUTS ===
|
||||
PSBT_IN_NON_WITNESS_UTXO = 0x00
|
||||
PSBT_IN_WITNESS_UTXO = 0x01
|
||||
PSBT_IN_PARTIAL_SIG = 0x02
|
||||
PSBT_IN_SIGHASH_TYPE = 0x03
|
||||
PSBT_IN_REDEEM_SCRIPT = 0x04
|
||||
PSBT_IN_WITNESS_SCRIPT = 0x05
|
||||
PSBT_IN_BIP32_DERIVATION = 0x06
|
||||
PSBT_IN_FINAL_SCRIPTSIG = 0x07
|
||||
PSBT_IN_FINAL_SCRIPTWITNESS = 0x08
|
||||
PSBT_IN_POR_COMMITMENT = 0x09 # Proof of Reserves
|
||||
PSBT_IN_RIPEMD160 = 0x0a
|
||||
PSBT_IN_SHA256 = 0x0b
|
||||
PSBT_IN_HASH160 = 0x0c
|
||||
PSBT_IN_HASH256 = 0x0d
|
||||
PSBT_IN_NON_WITNESS_UTXO = 0x00
|
||||
PSBT_IN_WITNESS_UTXO = 0x01
|
||||
PSBT_IN_PARTIAL_SIG = 0x02
|
||||
PSBT_IN_SIGHASH_TYPE = 0x03
|
||||
PSBT_IN_REDEEM_SCRIPT = 0x04
|
||||
PSBT_IN_WITNESS_SCRIPT = 0x05
|
||||
PSBT_IN_BIP32_DERIVATION = 0x06
|
||||
PSBT_IN_FINAL_SCRIPTSIG = 0x07
|
||||
PSBT_IN_FINAL_SCRIPTWITNESS = 0x08
|
||||
PSBT_IN_POR_COMMITMENT = 0x09 # Proof of Reserves
|
||||
PSBT_IN_RIPEMD160 = 0x0a
|
||||
PSBT_IN_SHA256 = 0x0b
|
||||
PSBT_IN_HASH160 = 0x0c
|
||||
PSBT_IN_HASH256 = 0x0d
|
||||
# BIP-370
|
||||
PSBT_IN_PREVIOUS_TXID = 0x0e
|
||||
PSBT_IN_OUTPUT_INDEX = 0x0f
|
||||
PSBT_IN_SEQUENCE = 0x10
|
||||
PSBT_IN_REQUIRED_TIME_LOCKTIME = 0x11
|
||||
PSBT_IN_REQUIRED_HEIGHT_LOCKTIME = 0x12
|
||||
PSBT_IN_PREVIOUS_TXID = 0x0e
|
||||
PSBT_IN_OUTPUT_INDEX = 0x0f
|
||||
PSBT_IN_SEQUENCE = 0x10
|
||||
PSBT_IN_REQUIRED_TIME_LOCKTIME = 0x11
|
||||
PSBT_IN_REQUIRED_HEIGHT_LOCKTIME = 0x12
|
||||
# BIP-371
|
||||
PSBT_IN_TAP_KEY_SIG = 0x13
|
||||
PSBT_IN_TAP_SCRIPT_SIG = 0x14
|
||||
PSBT_IN_TAP_LEAF_SCRIPT = 0x15
|
||||
PSBT_IN_TAP_BIP32_DERIVATION = 0x16
|
||||
PSBT_IN_TAP_INTERNAL_KEY = 0x17
|
||||
PSBT_IN_TAP_MERKLE_ROOT = 0x18
|
||||
PSBT_IN_TAP_KEY_SIG = 0x13
|
||||
PSBT_IN_TAP_SCRIPT_SIG = 0x14
|
||||
PSBT_IN_TAP_LEAF_SCRIPT = 0x15
|
||||
PSBT_IN_TAP_BIP32_DERIVATION = 0x16
|
||||
PSBT_IN_TAP_INTERNAL_KEY = 0x17
|
||||
PSBT_IN_TAP_MERKLE_ROOT = 0x18
|
||||
|
||||
# OUTPUTS ===
|
||||
PSBT_OUT_REDEEM_SCRIPT = 0x00
|
||||
PSBT_OUT_WITNESS_SCRIPT = 0x01
|
||||
PSBT_OUT_BIP32_DERIVATION = 0x02
|
||||
PSBT_OUT_REDEEM_SCRIPT = 0x00
|
||||
PSBT_OUT_WITNESS_SCRIPT = 0x01
|
||||
PSBT_OUT_BIP32_DERIVATION = 0x02
|
||||
# BIP-370
|
||||
PSBT_OUT_AMOUNT = 0x03
|
||||
PSBT_OUT_SCRIPT = 0x04
|
||||
PSBT_OUT_AMOUNT = 0x03
|
||||
PSBT_OUT_SCRIPT = 0x04
|
||||
# BIP-371
|
||||
PSBT_OUT_TAP_INTERNAL_KEY = 0x05
|
||||
PSBT_OUT_TAP_TREE = 0x06
|
||||
PSBT_OUT_TAP_BIP32_DERIVATION = 0x07
|
||||
PSBT_OUT_TAP_INTERNAL_KEY = 0x05
|
||||
PSBT_OUT_TAP_TREE = 0x06
|
||||
PSBT_OUT_TAP_BIP32_DERIVATION = 0x07
|
||||
|
||||
PSBT_PROP_CK_ID = b"COINKITE"
|
||||
|
||||
@ -352,6 +353,7 @@ class BasicPSBT:
|
||||
self.outputs = []
|
||||
self.txn_modifiable = None
|
||||
self.fallback_locktime = None
|
||||
self.bip322_msg = None
|
||||
self.unknown = {}
|
||||
self.parsed_txn = None
|
||||
|
||||
@ -360,6 +362,7 @@ class BasicPSBT:
|
||||
a.input_count == b.input_count and \
|
||||
a.output_count == b.output_count and \
|
||||
a.fallback_locktime == b.fallback_locktime and \
|
||||
a.bip322_msg == b.bip322_msg and \
|
||||
a.txn_version == b.txn_version and \
|
||||
a.version == b.version and \
|
||||
len(a.inputs) == len(b.inputs) and \
|
||||
@ -422,6 +425,8 @@ class BasicPSBT:
|
||||
num_outs = self.output_count
|
||||
elif kt == PSBT_GLOBAL_TX_MODIFIABLE:
|
||||
self.txn_modifiable = val[0]
|
||||
elif kt == PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE:
|
||||
self.bip322_msg = val
|
||||
else:
|
||||
self.unknown[key] = val
|
||||
|
||||
@ -434,8 +439,8 @@ class BasicPSBT:
|
||||
if self.version == 0:
|
||||
assert self.txn, 'v0: missing reqd section - PSBT_GLOBAL_UNSIGNED_TX'
|
||||
elif self.version == 2:
|
||||
# tx version needs to be at least 2 because locktimes
|
||||
assert self.txn_version in {2, 3}, 'v2: missing reqd section - PSBT_GLOBAL_TX_VERSION'
|
||||
assert self.txn_version is not None, 'v2: missing reqd section - PSBT_GLOBAL_TX_VERSION'
|
||||
assert self.txn_version != 0 or self.bip322_msg, 'bad txn version'
|
||||
assert self.input_count is not None, 'v2: missing reqd section - PSBT_GLOBAL_INPUT_COUNT'
|
||||
assert self.output_count is not None, 'v2: missing reqd section - PSBT_GLOBAL_OUTPUT_COUNT'
|
||||
|
||||
@ -483,6 +488,9 @@ class BasicPSBT:
|
||||
if self.version is not None:
|
||||
wr(PSBT_GLOBAL_VERSION, struct.pack('<I', self.version))
|
||||
|
||||
if self.bip322_msg is not None:
|
||||
wr(PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE, self.bip322_msg)
|
||||
|
||||
if isinstance(self.unknown, list):
|
||||
# just so I can test duplicate unknown values
|
||||
# list of tuples [(key0, val0), (key1, val1)]
|
||||
@ -512,7 +520,7 @@ class BasicPSBT:
|
||||
def to_v2(self):
|
||||
if self.version is None or self.version == 0:
|
||||
self.version = 2
|
||||
self.txn_version = 2
|
||||
self.txn_version = self.parsed_txn.nVersion
|
||||
self.txn = None
|
||||
self.input_count = len(self.parsed_txn.vin)
|
||||
self.output_count = len(self.parsed_txn.vout)
|
||||
|
||||
55
testing/sighash.py
Normal file
55
testing/sighash.py
Normal file
@ -0,0 +1,55 @@
|
||||
# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
#
|
||||
# Bitcoin transaction signature hash helpers for tests.
|
||||
#
|
||||
import copy
|
||||
import hashlib
|
||||
import struct
|
||||
|
||||
from ctransaction import hash256
|
||||
from serialize import ser_string
|
||||
from pysecp256k1 import tagged_sha256
|
||||
|
||||
|
||||
SIGHASH_DEFAULT = 0
|
||||
SIGHASH_ALL = 1
|
||||
|
||||
|
||||
def legacy_sighash(tx, in_idx, script_code, sighash=SIGHASH_ALL):
|
||||
tmp = copy.deepcopy(tx)
|
||||
for txin in tmp.vin:
|
||||
txin.scriptSig = b''
|
||||
tmp.vin[in_idx].scriptSig = script_code
|
||||
return hash256(tmp.serialize_without_witness() + struct.pack('<I', sighash))
|
||||
|
||||
|
||||
def segwit_v0_sighash(tx, in_idx, script_code, amount, sighash=SIGHASH_ALL):
|
||||
hash_prevouts = hash256(b''.join(i.prevout.serialize() for i in tx.vin))
|
||||
hash_sequence = hash256(b''.join(struct.pack('<I', i.nSequence) for i in tx.vin))
|
||||
hash_outputs = hash256(b''.join(o.serialize() for o in tx.vout))
|
||||
txin = tx.vin[in_idx]
|
||||
preimage = struct.pack('<i', tx.nVersion)
|
||||
preimage += hash_prevouts + hash_sequence
|
||||
preimage += txin.prevout.serialize()
|
||||
preimage += ser_string(script_code)
|
||||
preimage += struct.pack('<q', amount)
|
||||
preimage += struct.pack('<I', txin.nSequence)
|
||||
preimage += hash_outputs
|
||||
preimage += struct.pack('<I', tx.nLockTime)
|
||||
preimage += struct.pack('<I', sighash)
|
||||
return hash256(preimage)
|
||||
|
||||
|
||||
def taproot_sighash(tx, in_idx, prevouts, sighash=SIGHASH_DEFAULT):
|
||||
assert sighash in (SIGHASH_DEFAULT, SIGHASH_ALL)
|
||||
preimage = bytes([sighash])
|
||||
preimage += struct.pack('<i', tx.nVersion)
|
||||
preimage += struct.pack('<I', tx.nLockTime)
|
||||
preimage += hashlib.sha256(b''.join(i.prevout.serialize() for i in tx.vin)).digest()
|
||||
preimage += hashlib.sha256(b''.join(struct.pack('<q', amount) for amount, spk in prevouts)).digest()
|
||||
preimage += hashlib.sha256(b''.join(ser_string(spk) for amount, spk in prevouts)).digest()
|
||||
preimage += hashlib.sha256(b''.join(struct.pack('<I', i.nSequence) for i in tx.vin)).digest()
|
||||
preimage += hashlib.sha256(b''.join(o.serialize() for o in tx.vout)).digest()
|
||||
preimage += b'\x00'
|
||||
preimage += struct.pack('<I', in_idx)
|
||||
return tagged_sha256(b"TapSighash", b'\x00' + preimage)
|
||||
@ -1,82 +1,29 @@
|
||||
# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
#
|
||||
# BIP-322 Message Signing and Proof of Reserves
|
||||
# NOTE: Run this module with and without --psbt2 to cover both PSBT versions.
|
||||
#
|
||||
import pytest, time, os
|
||||
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, BIP32Node
|
||||
from ctransaction import CTransaction, CTxIn, COutPoint
|
||||
from helpers import str_to_path
|
||||
from charcodes import KEY_QR, KEY_NFC
|
||||
from bbqr import split_qrs
|
||||
from bip322 import bip322_txn, bip322_ms_txn, BIP32Node
|
||||
from ctransaction import CTransaction, CTxIn, COutPoint, CTxOut
|
||||
from helpers import addr_from_display_format, str_to_path
|
||||
from txn import render_address
|
||||
|
||||
|
||||
@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 "BIP-322" in title
|
||||
# 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)
|
||||
def verify_msg_bip322_por(cap_story, press_select, press_cancel, cap_menu):
|
||||
def doit(msg, is_por=True, refuse=False):
|
||||
title, story = cap_story()
|
||||
assert title == "OK TO SIGN?"
|
||||
assert ("Proof of Reserves" if is_por else "BIP-322 Message") in story
|
||||
assert msg in story
|
||||
if refuse:
|
||||
press_cancel()
|
||||
time.sleep(.1)
|
||||
assert "Ready To Sign" in cap_menu()
|
||||
else:
|
||||
press_select()
|
||||
|
||||
return doit
|
||||
|
||||
@ -91,7 +38,8 @@ def verify_msg_bip322_por(cap_story, need_keypress, press_select, press_cancel,
|
||||
[["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, need_keypress,
|
||||
press_select, verify_msg_bip322_por, sim_root_dir):
|
||||
press_select, verify_msg_bip322_por, sim_root_dir, press_cancel,
|
||||
bip322_verify):
|
||||
num_ins = len(ins)
|
||||
amt = sum([i[2] or 0 for i in ins])
|
||||
psbt, msg_challenge = bip322_txn(ins, msg=msg)
|
||||
@ -100,51 +48,202 @@ def test_bip322_por(msg, ins, bip322_txn, start_sign, end_sign, cap_story, need_
|
||||
|
||||
start_sign(psbt, finalize=True)
|
||||
|
||||
verify_msg_bip322_por(msg.decode(), way="sd")
|
||||
is_por = num_ins > 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
|
||||
|
||||
@ -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=[{}])
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user