BIP-322 changes after BIP got in to the complete state
What changed, and why it matters
This commit updates COLDCARD's BIP-322 (Bitcoin message signing / proof-of-reserves) implementation to match the finalized BIP-322 specification. The main user-visible change is that the message to be signed must now be embedded directly in the PSBT file under a new standard field, instead of being imported separately by the user. The device verifies that the embedded message matches the on-screen message before signing. The change also adds HSM policy checks so BIP-322 signing is governed by message-signing rules, and it prevents BIP-322 PSBTs from being finalized into broadcastable transactions. There is no obvious vulnerability introduced; it is primarily a standards-compliance and safety hardening patch.
Review the new validation logic in shared/psbt.py validate_bip322_input0 for completeness, especially the witness-UTXO reconstruction path and the handling of non-ASCII message warnings. Confirm that HSM msg_paths enforcement covers multisig and WIF-store edge cases. Because the patch is large and changes both parsing and policy, run the full BIP-322 and HSM test matrices (including --psbt2) before release.
Security signals we found
BIP-322 message now embedded in PSBT and verified against reconstructed to_spend, reducing reliance on external/user-supplied message files
BIP-322 PSBTs are explicitly prevented from being finalized into broadcastable transactions
HSM policy now applies message-signing path restrictions to BIP-322 PSBTs
SIGHASH_ALL is enforced for BIP-322 inputs
Legacy proof-of-reserves PSBTs without PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE are rejected
Segwit v0 address rendering now requires exact script length (22 or 34 bytes), hardening against malformed scripts
Empty scriptPubKey inputs are rejected (new regression test test_empty_input_scriptPubKey)
Evidence from the diff
The patch reworks BIP-322 handling in shared/psbt.py and shared/auth.py. Key changes: (1) PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE (0x09) is now required and carries the cleartext message; the device recomputes the tagged hash and reconstructs/validates the to_spend transaction. (2) Input 0 can now be supplied as a witness UTXO, with to_spend reconstructed from the message and scriptPubKey. (3) BIP-322 to_sign transaction versions 0 and 2 are accepted. (4) BIP-322 PSBTs are never finalized/extracted to a network transaction; a signed PSBT is always returned. (5) HSM policy now routes BIP-322 through msg_paths restrictions. (6) A segwit address-formatting check in shared/chains.py is tightened (length check added). (7) CTxOut.is_op_return() helper is added. The commit is large (+1106/-610) and includes extensive test updates and a new testing/sighash.py helper.
Changed components
shared/psbt.pyshared/auth.pyshared/hsm.pyshared/chains.pyshared/serializations.pydocs/proof-of-reserves-bip-322.mdtesting/bip322.pytesting/test_bip322.pytesting/test_hsm.pytesting/test_sign.pytesting/psbt.pytesting/sighash.pytesting/conftest.pyInspect captured patch +1106 / −610
diff --git a/docs/proof-of-reserves-bip-322.md b/docs/proof-of-reserves-bip-322.md
index 4e77b33..4df4ffa 100644
--- a/docs/proof-of-reserves-bip-322.md
+++ b/docs/proof-of-reserves-bip-322.md
@@ -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
-Proof of Reserves
+BIP-322 Message
- Amount 0.20000000 BTC
+ Message:
+ This is the signed message
- Message Hash:
- 11b5fe357842f5c368d2e3884d6a5ba577e3bc7cde132004f39b8c2a43a9cdec
+ Challenge Address:
+ bc1qzvjnhf7k70uxv6xvneaqxql7k09dd6nsr5wheq
- Message Challenge:
- 00140b2537a7d6f3cc668c9e9fa0303ffb3cad6e9b81
+ 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
- 21 inputs
- 1 output
+ Challenge Address:
+ bc1qzvjnhf7k70uxv6xvneaqxql7k09dd6nsr5wheq
- 0.00000000 BTC
- - OP_RETURN -
- null-data
+ 21 inputs
+ 1 output
- 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.
```
diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md
index 2622927..a6c9375 100644
--- a/releases/Next-ChangeLog.md
+++ b/releases/Next-ChangeLog.md
@@ -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
diff --git a/shared/auth.py b/shared/auth.py
index a445801..0ef3859 100644
--- a/shared/auth.py
+++ b/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
diff --git a/shared/chains.py b/shared/chains.py
index 1531b74..8aeace4 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 6018b83..09f33cb 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 430c04d..d2bc010 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("<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:])
diff --git a/shared/serializations.py b/shared/serializations.py
index 7d950ac..014f39c 100755
--- a/shared/serializations.py
+++ b/shared/serializations.py
@@ -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))
diff --git a/testing/bip322.py b/testing/bip322.py
index 1ffa645..73f106b 100644
--- a/testing/bip322.py
+++ b/testing/bip322.py
@@ -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
-
-
-@pytest.fixture
-def bip322_txn(dev, pytestconfig, create_msg_file):
-
- def doit(inputs, msg=b"POR", addr_fmt="p2wpkh", input_amount=1E8, to_sign_lock_time=0,
- sighash=None, psbt_hacker=None, witness_utxo=[], to_sign_nVersion=0):
-
- msg_challenge = None
-
- num_ins = len(inputs)
-
- psbt = BasicPSBT()
-
- to_sign = CTransaction()
- to_sign.nLockTime = to_sign_lock_time
- # must be set to 2 if BIP-68 is used (relative tx level lock)
- to_sign.nVersion = to_sign_nVersion
- master_xpub = dev.master_xpub or simulator_fixed_tprv
-
- # we have a key; use it to provide "plausible" value inputs
- mk = BIP32Node.from_wallet_key(master_xpub)
- mfp = mk.fingerprint()
-
- psbt.inputs = [BasicPSBTInput(idx=i) for i in range(num_ins)]
- psbt.outputs = []
-
- for i, inp in enumerate(inputs):
- sp = f"0/{i}"
- af = addr_fmt
- ia = input_amount
- 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])
-
+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
+
+
+def bip322_verify(psbt_bytes):
+ """Verify BIP-322 PSBT signatures without a full script interpreter.
+
+ 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))
+
+ 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
+
+ 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
+
+ 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'
+
+ 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:
- 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)
- 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
+ prev_tx = CTransaction()
+ prev_tx.deserialize(BytesIO(psbt.inputs[idx].utxo))
+ prev = prev_tx.vout[txin.prevout.n]
+ prevouts.append((prev.nValue, prev.scriptPubKey))
+
+ for idx, txin in enumerate(tx.vin):
+ amount, spk = prevouts[idx]
+
+ 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 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
+
+ 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:
- # 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))
+ 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 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"
+
+
+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):
+
+ msg_challenge = None
+
+ 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
+ if sighash is not None:
+ psbt.inputs[i].sighash = sighash
- to_spend.calc_sha256()
+ 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 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
-
- psbt = BasicPSBT()
-
- txn = CTransaction()
- txn.nVersion = to_sign_nVersion
- txn.nLockTime = lock_time
-
- 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
-
- # 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
+ msg_challenge = None
+
+ psbt = BasicPSBT()
+ psbt.bip322_msg = msg
+
+ txn = CTransaction()
+ txn.nVersion = to_sign_nVersion
+ txn.nLockTime = lock_time
+
+ 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
+
+ # 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()
-
- 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
+ # 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)
-
- # 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)
+ spendable = CTxIn(COutPoint(to_spend.sha256, 0), nSequence=seq)
+ txn.vin.append(spendable)
- psbt.outputs.append(op_ret_o)
+ # 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)
- if hack_psbt:
- hack_psbt(psbt)
+ psbt.outputs.append(op_ret_o)
- psbt.txn = txn.serialize_with_witness()
+ if hack_psbt:
+ hack_psbt(psbt)
- rv = BytesIO()
- psbt.serialize(rv)
- assert rv.tell() <= MAX_TXN_LEN, 'too fat'
+ psbt.txn = txn.serialize_with_witness()
+ 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
\ No newline at end of file
+ return rv.getvalue(), msg_challenge
diff --git a/testing/conftest.py b/testing/conftest.py
index 4634325..234e569 100644
--- a/testing/conftest.py
+++ b/testing/conftest.py
@@ -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
diff --git a/testing/psbt.py b/testing/psbt.py
index 1df1649..ab7912a 100644
--- a/testing/psbt.py
+++ b/testing/psbt.py
@@ -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)
diff --git a/testing/sighash.py b/testing/sighash.py
new file mode 100644
index 0000000..a413866
--- /dev/null
+++ b/testing/sighash.py
@@ -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)
diff --git a/testing/test_bip322.py b/testing/test_bip322.py
index e364913..cecf17e 100644
--- a/testing/test_bip322.py
+++ b/testing/test_bip322.py
@@ -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:\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
+
- 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
+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 0a4158c..af340ba 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 70e6a6d..1c93284 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
Why this scored 43/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.