diff --git a/bip-0376.mediawiki b/bip-0376.mediawiki index a005f08ec0..57777df2db 100644 --- a/bip-0376.mediawiki +++ b/bip-0376.mediawiki @@ -146,11 +146,30 @@ These are new fields added to the existing PSBT format. Because PSBT is designed == Reference implementation == -'''''TODO''''' +A Python reference implementation is provided in [[bip-0376/reference.py|bip-0376/reference.py]]. +It uses the vendored bitcoin_test PSBT components and secp256k1lab test-only secp256k1 implementation from BIP 375. + +It demonstrates the Signer behavior specified in this BIP: + +* Key derivation using ''d = (bspend + tweak) mod n''. +* Key negation when ''d·G'' has odd y-coordinate. +* Verification that the resulting x-only public key matches the output key ''P''. +* BIP 340 signing with the derived key. === Test vectors === -'''''TODO''''' +Machine-readable test vectors are provided in [[bip-0376/test-vectors.json|bip-0376/test-vectors.json]]. + +The vector set includes: + +* Valid signing cases with and without key negation. +* Invalid cases for output-key mismatch, zero tweaked key, and out-of-range spend key. + +The reference implementation can be run against the vectors with: + +
+./bip-0376/reference.py bip-0376/test-vectors.json
+
== Appendix == diff --git a/bip-0376/deps/bitcoin_test/messages.py b/bip-0376/deps/bitcoin_test/messages.py new file mode 100644 index 0000000000..90d2630301 --- /dev/null +++ b/bip-0376/deps/bitcoin_test/messages.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +# Copyright (c) 2010 ArtForz -- public domain half-a-node +# Copyright (c) 2012 Jeff Garzik +# Copyright (c) 2010-present The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Bitcoin test framework primitive and message structures + +CBlock, CTransaction, CBlockHeader, CTxIn, CTxOut, etc....: + data structures that should map to corresponding structures in + bitcoin/primitives + +msg_block, msg_tx, msg_headers, etc.: + data structures that represent network messages + +ser_*, deser_*: functions that handle serialization/deserialization. + +Classes use __slots__ to ensure extraneous attributes aren't accidentally added +by tests, compromising their intended effect. +""" + +######################################################################## +# Adapted from Bitcoin Core test framework messages.py +# for BIP-375 PSBT validation tests. +######################################################################## + +import copy +import hashlib +import math +from io import BytesIO + +COIN = 100000000 # 1 btc in satoshis +WITNESS_SCALE_FACTOR = 4 + +# ============================================================================ +# Serialization utilities +# ============================================================================ + +def hash160(s: bytes) -> bytes: + return hashlib.new("ripemd160", sha256(s)).digest() + + +def sha256(s: bytes) -> bytes: + return hashlib.sha256(s).digest() + + +def hash256(s: bytes) -> bytes: + return sha256(sha256(s)) + + +def ser_compact_size(l): + r = b"" + if l < 253: + r = l.to_bytes(1, "little") + elif l < 0x10000: + r = (253).to_bytes(1, "little") + l.to_bytes(2, "little") + elif l < 0x100000000: + r = (254).to_bytes(1, "little") + l.to_bytes(4, "little") + else: + r = (255).to_bytes(1, "little") + l.to_bytes(8, "little") + return r + + +def deser_compact_size(f): + nit = int.from_bytes(f.read(1), "little") + if nit == 253: + nit = int.from_bytes(f.read(2), "little") + elif nit == 254: + nit = int.from_bytes(f.read(4), "little") + elif nit == 255: + nit = int.from_bytes(f.read(8), "little") + return nit + + +def ser_varint(l): + r = b"" + while True: + r = bytes([(l & 0x7f) | (0x80 if len(r) > 0 else 0x00)]) + r + if l <= 0x7f: + return r + l = (l >> 7) - 1 + + +def deser_varint(f): + n = 0 + while True: + dat = f.read(1)[0] + n = (n << 7) | (dat & 0x7f) + if (dat & 0x80) > 0: + n += 1 + else: + return n + + +def deser_string(f): + nit = deser_compact_size(f) + return f.read(nit) + + +def ser_string(s): + return ser_compact_size(len(s)) + s + + +def deser_uint256(f): + return int.from_bytes(f.read(32), 'little') + + +def ser_uint256(u): + return u.to_bytes(32, 'little') + + +def uint256_from_str(s): + return int.from_bytes(s[:32], 'little') + + +def uint256_from_compact(c): + nbytes = (c >> 24) & 0xFF + v = (c & 0xFFFFFF) << (8 * (nbytes - 3)) + return v + + +# deser_function_name: Allow for an alternate deserialization function on the +# entries in the vector. +def deser_vector(f, c, deser_function_name=None): + nit = deser_compact_size(f) + r = [] + for _ in range(nit): + t = c() + if deser_function_name: + getattr(t, deser_function_name)(f) + else: + t.deserialize(f) + r.append(t) + return r + + +# ser_function_name: Allow for an alternate serialization function on the +# entries in the vector (we use this for serializing the vector of transactions +# for a witness block). +def ser_vector(l, ser_function_name=None): + r = ser_compact_size(len(l)) + for i in l: + if ser_function_name: + r += getattr(i, ser_function_name)() + else: + r += i.serialize() + return r + + +def deser_uint256_vector(f): + nit = deser_compact_size(f) + r = [] + for _ in range(nit): + t = deser_uint256(f) + r.append(t) + return r + + +def ser_uint256_vector(l): + r = ser_compact_size(len(l)) + for i in l: + r += ser_uint256(i) + return r + + +def deser_string_vector(f): + nit = deser_compact_size(f) + r = [] + for _ in range(nit): + t = deser_string(f) + r.append(t) + return r + + +def ser_string_vector(l): + r = ser_compact_size(len(l)) + for sv in l: + r += ser_string(sv) + return r + +# like from_hex, but without the hex part +def from_binary(cls, stream): + """deserialize a binary stream (or bytes object) into an object""" + # handle bytes object by turning it into a stream + was_bytes = isinstance(stream, bytes) + if was_bytes: + stream = BytesIO(stream) + obj = cls() + obj.deserialize(stream) + if was_bytes: + assert len(stream.read()) == 0 + return obj + + +# ============================================================================ +# Transaction data structures +# ============================================================================ + +class COutPoint: + __slots__ = ("hash", "n") + + def __init__(self, hash=0, n=0): + self.hash = hash + self.n = n + + def deserialize(self, f): + self.hash = deser_uint256(f) + self.n = int.from_bytes(f.read(4), "little") + + def serialize(self): + r = b"" + r += ser_uint256(self.hash) + r += self.n.to_bytes(4, "little") + return r + + def __repr__(self): + return "COutPoint(hash=%064x n=%i)" % (self.hash, self.n) + +class CTxIn: + __slots__ = ("nSequence", "prevout", "scriptSig") + + def __init__(self, outpoint=None, scriptSig=b"", nSequence=0): + if outpoint is None: + self.prevout = COutPoint() + else: + self.prevout = outpoint + self.scriptSig = scriptSig + self.nSequence = nSequence + + def deserialize(self, f): + self.prevout = COutPoint() + self.prevout.deserialize(f) + self.scriptSig = deser_string(f) + self.nSequence = int.from_bytes(f.read(4), "little") + + def serialize(self): + r = b"" + r += self.prevout.serialize() + r += ser_string(self.scriptSig) + r += self.nSequence.to_bytes(4, "little") + return r + + def __repr__(self): + return "CTxIn(prevout=%s scriptSig=%s nSequence=%i)" \ + % (repr(self.prevout), self.scriptSig.hex(), + self.nSequence) + + +class CTxOut: + __slots__ = ("nValue", "scriptPubKey") + + def __init__(self, nValue=0, scriptPubKey=b""): + self.nValue = nValue + self.scriptPubKey = scriptPubKey + + def deserialize(self, f): + self.nValue = int.from_bytes(f.read(8), "little", signed=True) + self.scriptPubKey = deser_string(f) + + def serialize(self): + r = b"" + r += self.nValue.to_bytes(8, "little", signed=True) + r += ser_string(self.scriptPubKey) + return r + + def __repr__(self): + return "CTxOut(nValue=%i.%08i scriptPubKey=%s)" \ + % (self.nValue // COIN, self.nValue % COIN, + self.scriptPubKey.hex()) + + +class CScriptWitness: + __slots__ = ("stack",) + + def __init__(self): + # stack is a vector of strings + self.stack = [] + + def __repr__(self): + return "CScriptWitness(%s)" % \ + (",".join([x.hex() for x in self.stack])) + + def is_null(self): + if self.stack: + return False + return True + + +class CTxInWitness: + __slots__ = ("scriptWitness",) + + def __init__(self): + self.scriptWitness = CScriptWitness() + + def deserialize(self, f): + self.scriptWitness.stack = deser_string_vector(f) + + def serialize(self): + return ser_string_vector(self.scriptWitness.stack) + + def __repr__(self): + return repr(self.scriptWitness) + + def is_null(self): + return self.scriptWitness.is_null() + + +class CTxWitness: + __slots__ = ("vtxinwit",) + + def __init__(self): + self.vtxinwit = [] + + def deserialize(self, f): + for i in range(len(self.vtxinwit)): + self.vtxinwit[i].deserialize(f) + + def serialize(self): + r = b"" + # This is different than the usual vector serialization -- + # we omit the length of the vector, which is required to be + # the same length as the transaction's vin vector. + for x in self.vtxinwit: + r += x.serialize() + return r + + def __repr__(self): + return "CTxWitness(%s)" % \ + (';'.join([repr(x) for x in self.vtxinwit])) + + def is_null(self): + for x in self.vtxinwit: + if not x.is_null(): + return False + return True + + +class CTransaction: + __slots__ = ("nLockTime", "version", "vin", "vout", "wit") + + def __init__(self, tx=None): + if tx is None: + self.version = 2 + self.vin = [] + self.vout = [] + self.wit = CTxWitness() + self.nLockTime = 0 + else: + self.version = tx.version + self.vin = copy.deepcopy(tx.vin) + self.vout = copy.deepcopy(tx.vout) + self.nLockTime = tx.nLockTime + self.wit = copy.deepcopy(tx.wit) + + def deserialize(self, f): + self.version = int.from_bytes(f.read(4), "little") + self.vin = deser_vector(f, CTxIn) + flags = 0 + if len(self.vin) == 0: + flags = int.from_bytes(f.read(1), "little") + # Not sure why flags can't be zero, but this + # matches the implementation in bitcoind + if (flags != 0): + self.vin = deser_vector(f, CTxIn) + self.vout = deser_vector(f, CTxOut) + else: + self.vout = deser_vector(f, CTxOut) + if flags != 0: + self.wit.vtxinwit = [CTxInWitness() for _ in range(len(self.vin))] + self.wit.deserialize(f) + else: + self.wit = CTxWitness() + self.nLockTime = int.from_bytes(f.read(4), "little") + + def serialize_without_witness(self): + r = b"" + r += self.version.to_bytes(4, "little") + r += ser_vector(self.vin) + r += ser_vector(self.vout) + r += self.nLockTime.to_bytes(4, "little") + return r + + # Only serialize with witness when explicitly called for + def serialize_with_witness(self): + flags = 0 + if not self.wit.is_null(): + flags |= 1 + r = b"" + r += self.version.to_bytes(4, "little") + if flags: + dummy = [] + r += ser_vector(dummy) + r += flags.to_bytes(1, "little") + r += ser_vector(self.vin) + r += ser_vector(self.vout) + if flags & 1: + if (len(self.wit.vtxinwit) != len(self.vin)): + # vtxinwit must have the same length as vin + self.wit.vtxinwit = self.wit.vtxinwit[:len(self.vin)] + for _ in range(len(self.wit.vtxinwit), len(self.vin)): + self.wit.vtxinwit.append(CTxInWitness()) + r += self.wit.serialize() + r += self.nLockTime.to_bytes(4, "little") + return r + + # Regular serialization is with witness -- must explicitly + # call serialize_without_witness to exclude witness data. + def serialize(self): + return self.serialize_with_witness() + + @property + def wtxid_hex(self): + """Return wtxid (transaction hash with witness) as hex string.""" + return hash256(self.serialize())[::-1].hex() + + @property + def wtxid_int(self): + """Return wtxid (transaction hash with witness) as integer.""" + return uint256_from_str(hash256(self.serialize_with_witness())) + + @property + def txid_hex(self): + """Return txid (transaction hash without witness) as hex string.""" + return hash256(self.serialize_without_witness())[::-1].hex() + + @property + def txid_int(self): + """Return txid (transaction hash without witness) as integer.""" + return uint256_from_str(hash256(self.serialize_without_witness())) + + def is_valid(self): + for tout in self.vout: + if tout.nValue < 0 or tout.nValue > 21000000 * COIN: + return False + return True + + # Calculate the transaction weight using witness and non-witness + # serialization size (does NOT use sigops). + def get_weight(self): + with_witness_size = len(self.serialize_with_witness()) + without_witness_size = len(self.serialize_without_witness()) + return (WITNESS_SCALE_FACTOR - 1) * without_witness_size + with_witness_size + + def get_vsize(self): + return math.ceil(self.get_weight() / WITNESS_SCALE_FACTOR) + + def __repr__(self): + return "CTransaction(version=%i vin=%s vout=%s wit=%s nLockTime=%i)" \ + % (self.version, repr(self.vin), repr(self.vout), repr(self.wit), self.nLockTime) \ No newline at end of file diff --git a/bip-0376/deps/bitcoin_test/psbt.py b/bip-0376/deps/bitcoin_test/psbt.py new file mode 100644 index 0000000000..2820430865 --- /dev/null +++ b/bip-0376/deps/bitcoin_test/psbt.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +# Copyright (c) 2022-present The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +######################################################################## +# Adapted from Bitcoin Core test framework psbt.py +# for BIP-375 PSBT validation tests. +######################################################################## + +import base64 +import struct + +from io import BytesIO + +from .messages import ( + CTransaction, + deser_string, + deser_compact_size, + from_binary, + ser_compact_size, +) + + +# global types +PSBT_GLOBAL_UNSIGNED_TX = 0x00 +PSBT_GLOBAL_XPUB = 0x01 +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_VERSION = 0xfb +PSBT_GLOBAL_PROPRIETARY = 0xfc + +# per-input types +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 +PSBT_IN_RIPEMD160 = 0x0a +PSBT_IN_SHA256 = 0x0b +PSBT_IN_HASH160 = 0x0c +PSBT_IN_HASH256 = 0x0d +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_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_MUSIG2_PARTICIPANT_PUBKEYS = 0x1a +PSBT_IN_MUSIG2_PUB_NONCE = 0x1b +PSBT_IN_MUSIG2_PARTIAL_SIG = 0x1c +PSBT_IN_PROPRIETARY = 0xfc + +# per-output types +PSBT_OUT_REDEEM_SCRIPT = 0x00 +PSBT_OUT_WITNESS_SCRIPT = 0x01 +PSBT_OUT_BIP32_DERIVATION = 0x02 +PSBT_OUT_AMOUNT = 0x03 +PSBT_OUT_SCRIPT = 0x04 +PSBT_OUT_TAP_INTERNAL_KEY = 0x05 +PSBT_OUT_TAP_TREE = 0x06 +PSBT_OUT_TAP_BIP32_DERIVATION = 0x07 +PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS = 0x08 +PSBT_OUT_PROPRIETARY = 0xfc + + +class PSBTMap: + """Class for serializing and deserializing PSBT maps""" + + def __init__(self, map=None): + self.map = map if map is not None else {} + + def deserialize(self, f): + m = {} + while True: + k = deser_string(f) + if len(k) == 0: + break + v = deser_string(f) + if len(k) == 1: + k = k[0] + assert k not in m + m[k] = v + self.map = m + + def serialize(self): + m = b"" + for k,v in self.map.items(): + if isinstance(k, int) and 0 <= k and k <= 255: + k = bytes([k]) + if isinstance(v, list): + assert all(type(elem) is bytes for elem in v) + v = b"".join(v) # simply concatenate the byte-strings w/o size prefixes + m += ser_compact_size(len(k)) + k + m += ser_compact_size(len(v)) + v + m += b"\x00" + return m + +class PSBT: + """Class for serializing and deserializing PSBTs""" + + def __init__(self, *, g=None, i=None, o=None): + self.g = g if g is not None else PSBTMap() + self.i = i if i is not None else [] + self.o = o if o is not None else [] + self.in_count = len(i) if i is not None else None + self.out_count = len(o) if o is not None else None + self.version = None + + def deserialize(self, f): + assert f.read(5) == b"psbt\xff" + self.g = from_binary(PSBTMap, f) + + self.version = 0 + if PSBT_GLOBAL_VERSION in self.g.map: + assert PSBT_GLOBAL_INPUT_COUNT in self.g.map + assert PSBT_GLOBAL_OUTPUT_COUNT in self.g.map + self.version = struct.unpack(" bytes: + d0 = int_from_bytes(seckey) + if not (1 <= d0 <= GE.ORDER - 1): + raise ValueError("The secret key must be an integer in the range 1..n-1.") + P = d0 * G + assert not P.infinity + return P.to_bytes_xonly() + + +def schnorr_sign( + msg: bytes, seckey: bytes, aux_rand: bytes, tag_prefix: str = "BIP0340" +) -> bytes: + d0 = int_from_bytes(seckey) + if not (1 <= d0 <= GE.ORDER - 1): + raise ValueError("The secret key must be an integer in the range 1..n-1.") + if len(aux_rand) != 32: + raise ValueError("aux_rand must be 32 bytes instead of %i." % len(aux_rand)) + P = d0 * G + assert not P.infinity + d = d0 if P.has_even_y() else GE.ORDER - d0 + t = xor_bytes(bytes_from_int(d), tagged_hash(tag_prefix + "/aux", aux_rand)) + k0 = ( + int_from_bytes(tagged_hash(tag_prefix + "/nonce", t + P.to_bytes_xonly() + msg)) + % GE.ORDER + ) + if k0 == 0: + raise RuntimeError("Failure. This happens only with negligible probability.") + R = k0 * G + assert not R.infinity + k = k0 if R.has_even_y() else GE.ORDER - k0 + e = ( + int_from_bytes( + tagged_hash( + tag_prefix + "/challenge", R.to_bytes_xonly() + P.to_bytes_xonly() + msg + ) + ) + % GE.ORDER + ) + sig = R.to_bytes_xonly() + bytes_from_int((k + e * d) % GE.ORDER) + assert schnorr_verify(msg, P.to_bytes_xonly(), sig, tag_prefix=tag_prefix) + return sig + + +def schnorr_verify( + msg: bytes, pubkey: bytes, sig: bytes, tag_prefix: str = "BIP0340" +) -> bool: + if len(pubkey) != 32: + raise ValueError("The public key must be a 32-byte array.") + if len(sig) != 64: + raise ValueError("The signature must be a 64-byte array.") + try: + P = GE.from_bytes_xonly(pubkey) + except ValueError: + return False + r = int_from_bytes(sig[0:32]) + s = int_from_bytes(sig[32:64]) + if (r >= FE.SIZE) or (s >= GE.ORDER): + return False + e = ( + int_from_bytes(tagged_hash(tag_prefix + "/challenge", sig[0:32] + pubkey + msg)) + % GE.ORDER + ) + R = s * G - e * P + if R.infinity or (not R.has_even_y()) or (R.x != r): + return False + return True diff --git a/bip-0376/deps/secp256k1lab/src/secp256k1lab/ecdh.py b/bip-0376/deps/secp256k1lab/src/secp256k1lab/ecdh.py new file mode 100644 index 0000000000..73f47fa1a7 --- /dev/null +++ b/bip-0376/deps/secp256k1lab/src/secp256k1lab/ecdh.py @@ -0,0 +1,16 @@ +import hashlib + +from .secp256k1 import GE, Scalar + + +def ecdh_compressed_in_raw_out(seckey: bytes, pubkey: bytes) -> GE: + """TODO""" + shared_secret = Scalar.from_bytes_checked(seckey) * GE.from_bytes_compressed(pubkey) + assert not shared_secret.infinity # prime-order group + return shared_secret + + +def ecdh_libsecp256k1(seckey: bytes, pubkey: bytes) -> bytes: + """TODO""" + shared_secret = ecdh_compressed_in_raw_out(seckey, pubkey) + return hashlib.sha256(shared_secret.to_bytes_compressed()).digest() diff --git a/bip-0376/deps/secp256k1lab/src/secp256k1lab/keys.py b/bip-0376/deps/secp256k1lab/src/secp256k1lab/keys.py new file mode 100644 index 0000000000..3e28897e99 --- /dev/null +++ b/bip-0376/deps/secp256k1lab/src/secp256k1lab/keys.py @@ -0,0 +1,15 @@ +from .secp256k1 import GE, G +from .util import int_from_bytes + +# The following function is based on the BIP 327 reference implementation +# https://github.com/bitcoin/bips/blob/master/bip-0327/reference.py + + +# Return the plain public key corresponding to a given secret key +def pubkey_gen_plain(seckey: bytes) -> bytes: + d0 = int_from_bytes(seckey) + if not (1 <= d0 <= GE.ORDER - 1): + raise ValueError("The secret key must be an integer in the range 1..n-1.") + P = d0 * G + assert not P.infinity + return P.to_bytes_compressed() diff --git a/bip-0376/deps/secp256k1lab/src/secp256k1lab/py.typed b/bip-0376/deps/secp256k1lab/src/secp256k1lab/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/bip-0376/deps/secp256k1lab/src/secp256k1lab/secp256k1.py b/bip-0376/deps/secp256k1lab/src/secp256k1lab/secp256k1.py new file mode 100644 index 0000000000..6e262bf51e --- /dev/null +++ b/bip-0376/deps/secp256k1lab/src/secp256k1lab/secp256k1.py @@ -0,0 +1,454 @@ +# Copyright (c) 2022-2023 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +"""Test-only implementation of low-level secp256k1 field and group arithmetic + +It is designed for ease of understanding, not performance. + +WARNING: This code is slow and trivially vulnerable to side channel attacks. Do not use for +anything but tests. + +Exports: +* FE: class for secp256k1 field elements +* GE: class for secp256k1 group elements +* G: the secp256k1 generator point +""" + +# TODO Docstrings of methods still say "field element" +class APrimeFE: + """Objects of this class represent elements of a prime field. + + They are represented internally in numerator / denominator form, in order to delay inversions. + """ + + # The size of the field (also its modulus and characteristic). + SIZE: int + + def __init__(self, a=0, b=1): + """Initialize a field element a/b; both a and b can be ints or field elements.""" + if isinstance(a, type(self)): + num = a._num + den = a._den + else: + num = a % self.SIZE + den = 1 + if isinstance(b, type(self)): + den = (den * b._num) % self.SIZE + num = (num * b._den) % self.SIZE + else: + den = (den * b) % self.SIZE + assert den != 0 + if num == 0: + den = 1 + self._num = num + self._den = den + + def __add__(self, a): + """Compute the sum of two field elements (second may be int).""" + if isinstance(a, type(self)): + return type(self)(self._num * a._den + self._den * a._num, self._den * a._den) + if isinstance(a, int): + return type(self)(self._num + self._den * a, self._den) + return NotImplemented + + def __radd__(self, a): + """Compute the sum of an integer and a field element.""" + return type(self)(a) + self + + @classmethod + # REVIEW This should be + # def sum(cls, *es: Iterable[Self]) -> Self: + # but Self needs the typing_extension package on Python <= 3.12. + def sum(cls, *es): + """Compute the sum of field elements. + + sum(a, b, c, ...) is identical to (0 + a + b + c + ...).""" + return sum(es, start=cls(0)) + + def __sub__(self, a): + """Compute the difference of two field elements (second may be int).""" + if isinstance(a, type(self)): + return type(self)(self._num * a._den - self._den * a._num, self._den * a._den) + if isinstance(a, int): + return type(self)(self._num - self._den * a, self._den) + return NotImplemented + + def __rsub__(self, a): + """Compute the difference of an integer and a field element.""" + return type(self)(a) - self + + def __mul__(self, a): + """Compute the product of two field elements (second may be int).""" + if isinstance(a, type(self)): + return type(self)(self._num * a._num, self._den * a._den) + if isinstance(a, int): + return type(self)(self._num * a, self._den) + return NotImplemented + + def __rmul__(self, a): + """Compute the product of an integer with a field element.""" + return type(self)(a) * self + + def __truediv__(self, a): + """Compute the ratio of two field elements (second may be int).""" + if isinstance(a, type(self)) or isinstance(a, int): + return type(self)(self, a) + return NotImplemented + + def __pow__(self, a): + """Raise a field element to an integer power.""" + return type(self)(pow(self._num, a, self.SIZE), pow(self._den, a, self.SIZE)) + + def __neg__(self): + """Negate a field element.""" + return type(self)(-self._num, self._den) + + def __int__(self): + """Convert a field element to an integer in range 0..SIZE-1. The result is cached.""" + if self._den != 1: + self._num = (self._num * pow(self._den, -1, self.SIZE)) % self.SIZE + self._den = 1 + return self._num + + def sqrt(self): + """Compute the square root of a field element if it exists (None otherwise).""" + raise NotImplementedError + + def is_square(self): + """Determine if this field element has a square root.""" + # A more efficient algorithm is possible here (Jacobi symbol). + return self.sqrt() is not None + + def is_even(self): + """Determine whether this field element, represented as integer in 0..SIZE-1, is even.""" + return int(self) & 1 == 0 + + def __eq__(self, a): + """Check whether two field elements are equal (second may be an int).""" + if isinstance(a, type(self)): + return (self._num * a._den - self._den * a._num) % self.SIZE == 0 + return (self._num - self._den * a) % self.SIZE == 0 + + def to_bytes(self): + """Convert a field element to a 32-byte array (BE byte order).""" + return int(self).to_bytes(32, 'big') + + @classmethod + def from_int_checked(cls, v): + """Convert an integer to a field element (no overflow allowed).""" + if v >= cls.SIZE: + raise ValueError + return cls(v) + + @classmethod + def from_int_wrapping(cls, v): + """Convert an integer to a field element (reduced modulo SIZE).""" + return cls(v % cls.SIZE) + + @classmethod + def from_bytes_checked(cls, b): + """Convert a 32-byte array to a field element (BE byte order, no overflow allowed).""" + v = int.from_bytes(b, 'big') + return cls.from_int_checked(v) + + @classmethod + def from_bytes_wrapping(cls, b): + """Convert a 32-byte array to a field element (BE byte order, reduced modulo SIZE).""" + v = int.from_bytes(b, 'big') + return cls.from_int_wrapping(v) + + def __str__(self): + """Convert this field element to a 64 character hex string.""" + return f"{int(self):064x}" + + def __repr__(self): + """Get a string representation of this field element.""" + return f"{type(self).__qualname__}(0x{int(self):x})" + + +class FE(APrimeFE): + SIZE = 2**256 - 2**32 - 977 + + def sqrt(self): + # Due to the fact that our modulus p is of the form (p % 4) == 3, the Tonelli-Shanks + # algorithm (https://en.wikipedia.org/wiki/Tonelli-Shanks_algorithm) is simply + # raising the argument to the power (p + 1) / 4. + + # To see why: (p-1) % 2 = 0, so 2 divides the order of the multiplicative group, + # and thus only half of the non-zero field elements are squares. An element a is + # a (nonzero) square when Euler's criterion, a^((p-1)/2) = 1 (mod p), holds. We're + # looking for x such that x^2 = a (mod p). Given a^((p-1)/2) = 1, that is equivalent + # to x^2 = a^(1 + (p-1)/2) mod p. As (1 + (p-1)/2) is even, this is equivalent to + # x = a^((1 + (p-1)/2)/2) mod p, or x = a^((p+1)/4) mod p. + v = int(self) + s = pow(v, (self.SIZE + 1) // 4, self.SIZE) + if s**2 % self.SIZE == v: + return type(self)(s) + return None + + +class Scalar(APrimeFE): + """TODO Docstring""" + SIZE = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 + + +class GE: + """Objects of this class represent secp256k1 group elements (curve points or infinity) + + GE objects are immutable. + + Normal points on the curve have fields: + * x: the x coordinate (a field element) + * y: the y coordinate (a field element, satisfying y^2 = x^3 + 7) + * infinity: False + + The point at infinity has field: + * infinity: True + """ + + # TODO The following two class attributes should probably be just getters as + # classmethods to enforce immutability. Unfortunately Python makes it hard + # to create "classproperties". `G` could then also be just a classmethod. + + # Order of the group (number of points on the curve, plus 1 for infinity) + ORDER = Scalar.SIZE + + # Number of valid distinct x coordinates on the curve. + ORDER_HALF = ORDER // 2 + + @property + def infinity(self): + """Whether the group element is the point at infinity.""" + return self._infinity + + @property + def x(self): + """The x coordinate (a field element) of a non-infinite group element.""" + assert not self.infinity + return self._x + + @property + def y(self): + """The y coordinate (a field element) of a non-infinite group element.""" + assert not self.infinity + return self._y + + def __init__(self, x=None, y=None): + """Initialize a group element with specified x and y coordinates, or infinity.""" + if x is None: + # Initialize as infinity. + assert y is None + self._infinity = True + else: + # Initialize as point on the curve (and check that it is). + fx = FE(x) + fy = FE(y) + assert fy**2 == fx**3 + 7 + self._infinity = False + self._x = fx + self._y = fy + + def __add__(self, a): + """Add two group elements together.""" + # Deal with infinity: a + infinity == infinity + a == a. + if self.infinity: + return a + if a.infinity: + return self + if self.x == a.x: + if self.y != a.y: + # A point added to its own negation is infinity. + assert self.y + a.y == 0 + return GE() + else: + # For identical inputs, use the tangent (doubling formula). + lam = (3 * self.x**2) / (2 * self.y) + else: + # For distinct inputs, use the line through both points (adding formula). + lam = (self.y - a.y) / (self.x - a.x) + # Determine point opposite to the intersection of that line with the curve. + x = lam**2 - (self.x + a.x) + y = lam * (self.x - x) - self.y + return GE(x, y) + + @staticmethod + def sum(*ps): + """Compute the sum of group elements. + + GE.sum(a, b, c, ...) is identical to (GE() + a + b + c + ...).""" + return sum(ps, start=GE()) + + @staticmethod + def batch_mul(*aps): + """Compute a (batch) scalar group element multiplication. + + GE.batch_mul((a1, p1), (a2, p2), (a3, p3)) is identical to a1*p1 + a2*p2 + a3*p3, + but more efficient.""" + # Reduce all the scalars modulo order first (so we can deal with negatives etc). + naps = [(int(a), p) for a, p in aps] + # Start with point at infinity. + r = GE() + # Iterate over all bit positions, from high to low. + for i in range(255, -1, -1): + # Double what we have so far. + r = r + r + # Add then add the points for which the corresponding scalar bit is set. + for (a, p) in naps: + if (a >> i) & 1: + r += p + return r + + def __rmul__(self, a): + """Multiply an integer with a group element.""" + if self == G: + return FAST_G.mul(Scalar(a)) + return GE.batch_mul((Scalar(a), self)) + + def __neg__(self): + """Compute the negation of a group element.""" + if self.infinity: + return self + return GE(self.x, -self.y) + + def __sub__(self, a): + """Subtract a group element from another.""" + return self + (-a) + + def __eq__(self, a): + """Check if two group elements are equal.""" + return (self - a).infinity + + def has_even_y(self): + """Determine whether a non-infinity group element has an even y coordinate.""" + assert not self.infinity + return self.y.is_even() + + def to_bytes_compressed(self): + """Convert a non-infinite group element to 33-byte compressed encoding.""" + assert not self.infinity + return bytes([3 - self.y.is_even()]) + self.x.to_bytes() + + def to_bytes_compressed_with_infinity(self): + """Convert a group element to 33-byte compressed encoding, mapping infinity to zeros.""" + if self.infinity: + return 33 * b"\x00" + return self.to_bytes_compressed() + + def to_bytes_uncompressed(self): + """Convert a non-infinite group element to 65-byte uncompressed encoding.""" + assert not self.infinity + return b'\x04' + self.x.to_bytes() + self.y.to_bytes() + + def to_bytes_xonly(self): + """Convert (the x coordinate of) a non-infinite group element to 32-byte xonly encoding.""" + assert not self.infinity + return self.x.to_bytes() + + @staticmethod + def lift_x(x): + """Return group element with specified field element as x coordinate (and even y).""" + y = (FE(x)**3 + 7).sqrt() + if y is None: + raise ValueError + if not y.is_even(): + y = -y + return GE(x, y) + + @staticmethod + def from_bytes_compressed(b): + """Convert a compressed to a group element.""" + assert len(b) == 33 + if b[0] != 2 and b[0] != 3: + raise ValueError + x = FE.from_bytes_checked(b[1:]) + r = GE.lift_x(x) + if b[0] == 3: + r = -r + return r + + @staticmethod + def from_bytes_uncompressed(b): + """Convert an uncompressed to a group element.""" + assert len(b) == 65 + if b[0] != 4: + raise ValueError + x = FE.from_bytes_checked(b[1:33]) + y = FE.from_bytes_checked(b[33:]) + if y**2 != x**3 + 7: + raise ValueError + return GE(x, y) + + @staticmethod + def from_bytes(b): + """Convert a compressed or uncompressed encoding to a group element.""" + assert len(b) in (33, 65) + if len(b) == 33: + return GE.from_bytes_compressed(b) + else: + return GE.from_bytes_uncompressed(b) + + @staticmethod + def from_bytes_xonly(b): + """Convert a point given in xonly encoding to a group element.""" + assert len(b) == 32 + x = FE.from_bytes_checked(b) + r = GE.lift_x(x) + return r + + @staticmethod + def is_valid_x(x): + """Determine whether the provided field element is a valid X coordinate.""" + return (FE(x)**3 + 7).is_square() + + def __str__(self): + """Convert this group element to a string.""" + if self.infinity: + return "(inf)" + return f"({self.x},{self.y})" + + def __repr__(self): + """Get a string representation for this group element.""" + if self.infinity: + return "GE()" + return f"GE(0x{int(self.x):x},0x{int(self.y):x})" + + def __hash__(self): + """Compute a non-cryptographic hash of the group element.""" + if self.infinity: + return 0 # 0 is not a valid x coordinate + return int(self.x) + + +# The secp256k1 generator point +G = GE.lift_x(0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798) + + +class FastGEMul: + """Table for fast multiplication with a constant group element. + + Speed up scalar multiplication with a fixed point P by using a precomputed lookup table with + its powers of 2: + + table = [P, 2*P, 4*P, (2^3)*P, (2^4)*P, ..., (2^255)*P] + + During multiplication, the points corresponding to each bit set in the scalar are added up, + i.e. on average ~128 point additions take place. + """ + + def __init__(self, p): + self.table = [p] # table[i] = (2^i) * p + for _ in range(255): + p = p + p + self.table.append(p) + + def mul(self, a): + result = GE() + a = int(a) + for bit in range(a.bit_length()): + if a & (1 << bit): + result += self.table[bit] + return result + +# Precomputed table with multiples of G for fast multiplication +FAST_G = FastGEMul(G) diff --git a/bip-0376/deps/secp256k1lab/src/secp256k1lab/util.py b/bip-0376/deps/secp256k1lab/src/secp256k1lab/util.py new file mode 100644 index 0000000000..d8c744b795 --- /dev/null +++ b/bip-0376/deps/secp256k1lab/src/secp256k1lab/util.py @@ -0,0 +1,24 @@ +import hashlib + + +# This implementation can be sped up by storing the midstate after hashing +# tag_hash instead of rehashing it all the time. +def tagged_hash(tag: str, msg: bytes) -> bytes: + tag_hash = hashlib.sha256(tag.encode()).digest() + return hashlib.sha256(tag_hash + tag_hash + msg).digest() + + +def bytes_from_int(x: int) -> bytes: + return x.to_bytes(32, byteorder="big") + + +def xor_bytes(b0: bytes, b1: bytes) -> bytes: + return bytes(x ^ y for (x, y) in zip(b0, b1)) + + +def int_from_bytes(b: bytes) -> int: + return int.from_bytes(b, byteorder="big") + + +def hash_sha256(b: bytes) -> bytes: + return hashlib.sha256(b).digest() diff --git a/bip-0376/psbt_bip376.py b/bip-0376/psbt_bip376.py new file mode 100644 index 0000000000..d0beda15c1 --- /dev/null +++ b/bip-0376/psbt_bip376.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""BIP-376 PSBT helpers.""" + +from io import BytesIO +import struct +from typing import Optional + +from deps.bitcoin_test.messages import CTxOut, deser_compact_size, from_binary +from deps.bitcoin_test.psbt import ( + PSBT, + PSBTMap, + PSBT_GLOBAL_INPUT_COUNT, + PSBT_GLOBAL_OUTPUT_COUNT, + PSBT_GLOBAL_VERSION, + PSBT_IN_FINAL_SCRIPTWITNESS, + PSBT_IN_TAP_KEY_SIG, + PSBT_IN_WITNESS_UTXO, +) + +PSBT_IN_SP_SPEND_BIP32_DERIVATION = 0x1F +PSBT_IN_SP_TWEAK = 0x20 + + +class BIP376PSBTMap(PSBTMap): + """PSBTMap with helpers for BIP-376 field access.""" + + def __getitem__(self, key): + return self.map[key] + + def __contains__(self, key): + return key in self.map + + def get(self, key, default=None): + return self.map.get(key, default) + + def get_by_key(self, key_type: int, key_data: bytes = b"") -> Optional[bytes]: + if key_data == b"": + return self.map.get(key_type) + return self.map.get(bytes([key_type]) + key_data) + + def set_by_key(self, key_type: int, value_data: bytes, key_data: bytes = b"") -> None: + if key_data == b"": + self.map[key_type] = value_data + else: + self.map[bytes([key_type]) + key_data] = value_data + + +class BIP376PSBT(PSBT): + """PSBTv2 that deserializes maps as BIP376PSBTMap instances.""" + + def deserialize(self, f): + assert f.read(5) == b"psbt\xff" + self.g = from_binary(BIP376PSBTMap, f) + + if PSBT_GLOBAL_VERSION not in self.g.map: + raise ValueError("BIP-376 requires PSBTv2") + assert PSBT_GLOBAL_INPUT_COUNT in self.g.map + assert PSBT_GLOBAL_OUTPUT_COUNT in self.g.map + self.version = struct.unpack(" bytes: + witness_utxo = input_map.get(PSBT_IN_WITNESS_UTXO) + if witness_utxo is None: + raise ValueError("missing PSBT_IN_WITNESS_UTXO") + + txout = from_binary(CTxOut, witness_utxo) + script_pubkey = txout.scriptPubKey + if len(script_pubkey) != 34 or script_pubkey[:2] != b"\x51\x20": + raise ValueError("PSBT_IN_WITNESS_UTXO is not a P2TR output") + return script_pubkey[2:] + + +def get_sp_tweak(input_map: BIP376PSBTMap) -> bytes: + tweak = input_map.get(PSBT_IN_SP_TWEAK) + if tweak is None: + raise ValueError("missing PSBT_IN_SP_TWEAK") + if len(tweak) != 32: + raise ValueError("PSBT_IN_SP_TWEAK must be 32 bytes") + return tweak + + +def set_tap_key_sig(input_map: BIP376PSBTMap, signature: bytes) -> None: + if len(signature) not in (64, 65): + raise ValueError("PSBT_IN_TAP_KEY_SIG must be 64 or 65 bytes") + input_map.set_by_key(PSBT_IN_TAP_KEY_SIG, signature) + + +def remove_sp_finalized_fields(input_map: BIP376PSBTMap) -> None: + if input_map.get(PSBT_IN_FINAL_SCRIPTWITNESS) is None: + return + + for key in ( + PSBT_IN_SP_TWEAK, + PSBT_IN_TAP_KEY_SIG, + PSBT_IN_WITNESS_UTXO, + ): + input_map.map.pop(key, None) + + derivation_prefix = bytes([PSBT_IN_SP_SPEND_BIP32_DERIVATION]) + for key in list(input_map.map): + if isinstance(key, bytes) and key.startswith(derivation_prefix): + input_map.map.pop(key, None) diff --git a/bip-0376/reference.py b/bip-0376/reference.py new file mode 100755 index 0000000000..351396bef0 --- /dev/null +++ b/bip-0376/reference.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""BIP-0376 reference implementation and test vector runner. + +Run: + ./bip-0376/reference.py bip-0376/test-vectors.json +""" + +import json +import sys +from pathlib import Path + +PROJECT_DIR = Path(__file__).resolve().parent +DEPS_DIR = PROJECT_DIR / "deps" +SECP256K1LAB_DIR = DEPS_DIR / "secp256k1lab/src" +for dependency_path in (PROJECT_DIR, DEPS_DIR, SECP256K1LAB_DIR): + sys.path.insert(0, str(dependency_path)) + +from secp256k1lab.bip340 import schnorr_sign, schnorr_verify +from secp256k1lab.secp256k1 import G, Scalar + +from deps.bitcoin_test.messages import ser_string_vector +from deps.bitcoin_test.psbt import ( + PSBT_IN_FINAL_SCRIPTWITNESS, + PSBT_IN_TAP_KEY_SIG, +) +from psbt_bip376 import ( + BIP376PSBT, + PSBT_IN_SP_SPEND_BIP32_DERIVATION, + PSBT_IN_SP_TWEAK, + get_p2tr_witness_utxo_output_key, + get_sp_tweak, + remove_sp_finalized_fields, + set_tap_key_sig, +) + + +def parse_hex(data: str, expected_len: int, field_name: str) -> bytes: + raw = bytes.fromhex(data) + if len(raw) != expected_len: + raise ValueError(f"{field_name} must be {expected_len} bytes.") + return raw + + +def load_psbt(psbt_data: dict) -> BIP376PSBT: + if "hex" in psbt_data: + return BIP376PSBT.from_hex(psbt_data["hex"]) + if "base64" in psbt_data: + return BIP376PSBT.from_base64(psbt_data["base64"]) + raise ValueError("psbt must contain hex or base64") + + +def encode_psbt(psbt: BIP376PSBT) -> dict: + return { + "hex": psbt.to_hex(), + "base64": psbt.to_base64(), + } + + +def derive_signing_key( + spend_seckey: bytes, tweak: bytes, output_pubkey: bytes +) -> tuple[Scalar, Scalar, bool]: + try: + b_spend = Scalar.from_bytes_checked(spend_seckey) + except ValueError as exc: + raise ValueError("spend key out of range") from exc + if b_spend == 0: + raise ValueError("spend key out of range") + + d_raw = b_spend + Scalar.from_bytes_wrapping(tweak) + if d_raw == 0: + raise ValueError("tweaked private key is zero") + + Q = d_raw * G + assert not Q.infinity + negated = not Q.has_even_y() + d = d_raw if not negated else -d_raw + + Q_even = d * G + assert not Q_even.infinity + if Q_even.to_bytes_xonly() != output_pubkey: + raise ValueError("tweaked key does not match output key") + + return d_raw, d, negated + + +def update_psbt(psbt_data: dict, supplementary: dict) -> dict: + psbt = load_psbt(psbt_data) + input_index = supplementary.get("input_index", 0) + input_map = psbt.i[input_index] + tweak = parse_hex(supplementary["tweak"], 32, "tweak") + + if "spend_pubkey" in supplementary: + spend_pubkey = parse_hex(supplementary["spend_pubkey"], 33, "spend_pubkey") + derivation = bytes.fromhex( + supplementary.get("spend_bip32_derivation", "00000000") + ) + if len(derivation) < 4 or len(derivation) % 4 != 0: + raise ValueError( + "spend_bip32_derivation must be fingerprint plus path elements" + ) + input_map.set_by_key( + PSBT_IN_SP_SPEND_BIP32_DERIVATION, derivation, spend_pubkey + ) + input_map.set_by_key(PSBT_IN_SP_TWEAK, tweak) + + return encode_psbt(psbt) + + +def sign_psbt(psbt_data: dict, spend_seckey: bytes, message: bytes, aux_rand: bytes) -> dict: + psbt = load_psbt(psbt_data) + for input_map in psbt.i: + if input_map.get(PSBT_IN_SP_TWEAK) is None: + continue + tweak = get_sp_tweak(input_map) + output_pubkey = get_p2tr_witness_utxo_output_key(input_map) + _, d, _ = derive_signing_key(spend_seckey, tweak, output_pubkey) + set_tap_key_sig(input_map, schnorr_sign(message, d.to_bytes(), aux_rand)) + return encode_psbt(psbt) + + +def finalize_psbt(psbt_data: dict, message: bytes) -> dict: + psbt = load_psbt(psbt_data) + for input_map in psbt.i: + if input_map.get(PSBT_IN_SP_TWEAK) is None: + continue + signature = input_map.get(PSBT_IN_TAP_KEY_SIG) + if signature is None: + raise ValueError("missing PSBT_IN_TAP_KEY_SIG") + + output_pubkey = get_p2tr_witness_utxo_output_key(input_map) + if not schnorr_verify(message, output_pubkey, signature[:64]): + raise ValueError("invalid PSBT_IN_TAP_KEY_SIG") + + if input_map.get(PSBT_IN_FINAL_SCRIPTWITNESS) is None: + input_map.set_by_key( + PSBT_IN_FINAL_SCRIPTWITNESS, ser_string_vector([signature]) + ) + remove_sp_finalized_fields(input_map) + return encode_psbt(psbt) + + +def run_case(case: dict) -> dict: + supplementary = case.get("supplementary", {}) + task = supplementary["task"] + psbt_data = case["psbt"] + + if task == "update": + return update_psbt(psbt_data, supplementary) + + if task in ("sign", "fail_sign"): + spend_seckey = parse_hex(supplementary["spend_seckey"], 32, "spend_seckey") + message = parse_hex(supplementary["message"], 32, "message") + aux_rand = parse_hex(supplementary["aux_rand"], 32, "aux_rand") + return sign_psbt(psbt_data, spend_seckey, message, aux_rand) + + if task in ("finalize", "fail"): + message = parse_hex(supplementary["message"], 32, "message") + return finalize_psbt(psbt_data, message) + + raise ValueError(f"unknown task: {task}") + + +def run_test_vectors(path: Path) -> bool: + vectors = json.loads(path.read_text(encoding="utf-8")) + all_passed = True + + cases = vectors.get("cases", []) + print(f"Description: {vectors.get('description', 'N/A')}") + print(f"Version: {vectors.get('version', 'N/A')}") + print(f"Running {len(cases)} cases") + for index, case in enumerate(cases): + description = case["description"] + task = case.get("supplementary", {})["task"] + print(f"- cases[{index}] {description}") + try: + result = run_case(case) + if task in ("fail", "fail_sign"): + all_passed = False + print(" FAILED: expected an exception") + continue + expected = case["expected"]["psbt"] + if result != expected: + all_passed = False + print(" FAILED: PSBT mismatch") + except Exception as exc: + if task in ("fail", "fail_sign"): + continue + all_passed = False + print(f" FAILED: {exc}") + + print("All test vectors passed." if all_passed else "Some test vectors failed.") + return all_passed + + +def main() -> int: + if len(sys.argv) > 2: + print(f"Usage: {sys.argv[0]} [test-vectors.json]") + return 1 + + if len(sys.argv) == 2: + vector_path = Path(sys.argv[1]) + else: + vector_path = Path(__file__).with_name("test-vectors.json") + + if not vector_path.is_file(): + print(f"Vector file not found: {vector_path}") + return 1 + + return 0 if run_test_vectors(vector_path) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bip-0376/test-vectors.json b/bip-0376/test-vectors.json new file mode 100644 index 0000000000..c7a1e75790 --- /dev/null +++ b/bip-0376/test-vectors.json @@ -0,0 +1,145 @@ +{ + "description": "BIP 376 test vectors", + "version": "0.1.0", + "notes": [ + "PSBT encodings are provided as hex and base64 for convenience.", + "The supplementary.task field selects the role operation: update, sign, fail_sign, finalize, or fail.", + "Supplementary fields provide deterministic signing material for vector validation only." + ], + "cases": [ + { + "description": "Updater adds PSBT_IN_SP_TWEAK", + "psbt": { + "hex": "70736274ff01020402000000010401010105010101fb040200000000010e201111111111111111111111111111111111111111111111111111111111111111010f040000000001012be803000000000000225120528b75296fa646acecf3fcb7c7697f92f7645ea0e41e6ee8a66554739d2da0280001030884030000000000000104016a00", + "base64": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBERERERERERERERERERERERERERERERERERERERERERAQ8EAAAAAAEBK+gDAAAAAAAAIlEgUot1KW+mRqzs8/y3x2l/kvdkXqDkHm7opmVUc50toCgAAQMIhAMAAAAAAAABBAFqAA==" + }, + "supplementary": { + "task": "update", + "tweak": "ac7b0d0420f0d4a567d9abcb8a52e02cfae21690fd8d2d5934370dcc5aaee221" + }, + "expected": { + "psbt": { + "hex": "70736274ff01020402000000010401010105010101fb040200000000010e201111111111111111111111111111111111111111111111111111111111111111010f040000000001012be803000000000000225120528b75296fa646acecf3fcb7c7697f92f7645ea0e41e6ee8a66554739d2da028012020ac7b0d0420f0d4a567d9abcb8a52e02cfae21690fd8d2d5934370dcc5aaee2210001030884030000000000000104016a00", + "base64": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBERERERERERERERERERERERERERERERERERERERERERAQ8EAAAAAAEBK+gDAAAAAAAAIlEgUot1KW+mRqzs8/y3x2l/kvdkXqDkHm7opmVUc50toCgBICCsew0EIPDUpWfZq8uKUuAs+uIWkP2NLVk0Nw3MWq7iIQABAwiEAwAAAAAAAAEEAWoA" + } + } + }, + { + "description": "Updater adds PSBT_IN_SP_TWEAK and PSBT_IN_SP_SPEND_BIP32_DERIVATION", + "psbt": { + "hex": "70736274ff01020402000000010401010105010101fb040200000000010e201111111111111111111111111111111111111111111111111111111111111111010f040000000001012be803000000000000225120528b75296fa646acecf3fcb7c7697f92f7645ea0e41e6ee8a66554739d2da0280001030884030000000000000104016a00", + "base64": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBERERERERERERERERERERERERERERERERERERERERERAQ8EAAAAAAEBK+gDAAAAAAAAIlEgUot1KW+mRqzs8/y3x2l/kvdkXqDkHm7opmVUc50toCgAAQMIhAMAAAAAAAABBAFqAA==" + }, + "supplementary": { + "task": "update", + "tweak": "ac7b0d0420f0d4a567d9abcb8a52e02cfae21690fd8d2d5934370dcc5aaee221", + "spend_pubkey": "02812c369c23c4185755813b40f4edf0cedffbc2496f7802932513ead763ffaef8", + "spend_bip32_derivation": "00000000" + }, + "expected": { + "psbt": { + "hex": "70736274ff01020402000000010401010105010101fb040200000000010e201111111111111111111111111111111111111111111111111111111111111111010f040000000001012be803000000000000225120528b75296fa646acecf3fcb7c7697f92f7645ea0e41e6ee8a66554739d2da028221f02812c369c23c4185755813b40f4edf0cedffbc2496f7802932513ead763ffaef80400000000012020ac7b0d0420f0d4a567d9abcb8a52e02cfae21690fd8d2d5934370dcc5aaee2210001030884030000000000000104016a00", + "base64": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBERERERERERERERERERERERERERERERERERERERERERAQ8EAAAAAAEBK+gDAAAAAAAAIlEgUot1KW+mRqzs8/y3x2l/kvdkXqDkHm7opmVUc50toCgiHwKBLDacI8QYV1WBO0D07fDO3/vCSW94ApMlE+rXY/+u+AQAAAAAASAgrHsNBCDw1KVn2avLilLgLPriFpD9jS1ZNDcNzFqu4iEAAQMIhAMAAAAAAAABBAFqAA==" + } + } + }, + { + "description": "Signer sets PSBT_IN_TAP_KEY_SIG with expected signature for input with PSBT_IN_SP_TWEAK set using PSBT_IN_SP_SPEND_BIP32_DERIVATION", + "psbt": { + "hex": "70736274ff01020402000000010401010105010101fb040200000000010e201111111111111111111111111111111111111111111111111111111111111111010f040000000001012be803000000000000225120528b75296fa646acecf3fcb7c7697f92f7645ea0e41e6ee8a66554739d2da028221f02812c369c23c4185755813b40f4edf0cedffbc2496f7802932513ead763ffaef80400000000012020ac7b0d0420f0d4a567d9abcb8a52e02cfae21690fd8d2d5934370dcc5aaee2210001030884030000000000000104016a00", + "base64": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBERERERERERERERERERERERERERERERERERERERERERAQ8EAAAAAAEBK+gDAAAAAAAAIlEgUot1KW+mRqzs8/y3x2l/kvdkXqDkHm7opmVUc50toCgiHwKBLDacI8QYV1WBO0D07fDO3/vCSW94ApMlE+rXY/+u+AQAAAAAASAgrHsNBCDw1KVn2avLilLgLPriFpD9jS1ZNDcNzFqu4iEAAQMIhAMAAAAAAAABBAFqAA==" + }, + "supplementary": { + "task": "sign", + "spend_seckey": "26c6ca8d553ebf5633cad2f5becd608acb99bfade1005254aa59bc4fc372985a", + "message": "289e5175e02c788c2d442cfe81d6be0533d8c13e253ef763fda45d37accfe4d4", + "aux_rand": "a617dfb275f834e26a6f0c94052dd88982c86297dba990fd96645026e7c69e10" + }, + "expected": { + "psbt": { + "hex": "70736274ff01020402000000010401010105010101fb040200000000010e201111111111111111111111111111111111111111111111111111111111111111010f040000000001012be803000000000000225120528b75296fa646acecf3fcb7c7697f92f7645ea0e41e6ee8a66554739d2da028221f02812c369c23c4185755813b40f4edf0cedffbc2496f7802932513ead763ffaef80400000000012020ac7b0d0420f0d4a567d9abcb8a52e02cfae21690fd8d2d5934370dcc5aaee221011340d0c4f5ee3768c03a8d8e1204b8e52c6a4ded1f456d0f1707e7841928945c5a45bc1c0bc671d79612ef1c67a54bd50d653ce3d33c1fd966ce9a91e053f94177780001030884030000000000000104016a00", + "base64": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBERERERERERERERERERERERERERERERERERERERERERAQ8EAAAAAAEBK+gDAAAAAAAAIlEgUot1KW+mRqzs8/y3x2l/kvdkXqDkHm7opmVUc50toCgiHwKBLDacI8QYV1WBO0D07fDO3/vCSW94ApMlE+rXY/+u+AQAAAAAASAgrHsNBCDw1KVn2avLilLgLPriFpD9jS1ZNDcNzFqu4iEBE0DQxPXuN2jAOo2OEgS45SxqTe0fRW0PFwfnhBkolFxaRbwcC8Zx15YS7xxnpUvVDWU849M8H9lmzpqR4FP5QXd4AAEDCIQDAAAAAAAAAQQBagA=" + } + } + }, + { + "description": "Signer sets PSBT_IN_TAP_KEY_SIG with expected signature for input with PSBT_IN_SP_TWEAK without PSBT_IN_SP_SPEND_BIP32_DERIVATION field", + "psbt": { + "hex": "70736274ff01020402000000010401010105010101fb040200000000010e201111111111111111111111111111111111111111111111111111111111111111010f040000000001012be803000000000000225120528b75296fa646acecf3fcb7c7697f92f7645ea0e41e6ee8a66554739d2da028012020ac7b0d0420f0d4a567d9abcb8a52e02cfae21690fd8d2d5934370dcc5aaee2210001030884030000000000000104016a00", + "base64": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBERERERERERERERERERERERERERERERERERERERERERAQ8EAAAAAAEBK+gDAAAAAAAAIlEgUot1KW+mRqzs8/y3x2l/kvdkXqDkHm7opmVUc50toCgBICCsew0EIPDUpWfZq8uKUuAs+uIWkP2NLVk0Nw3MWq7iIQABAwiEAwAAAAAAAAEEAWoA" + }, + "supplementary": { + "task": "sign", + "spend_seckey": "26c6ca8d553ebf5633cad2f5becd608acb99bfade1005254aa59bc4fc372985a", + "message": "289e5175e02c788c2d442cfe81d6be0533d8c13e253ef763fda45d37accfe4d4", + "aux_rand": "a617dfb275f834e26a6f0c94052dd88982c86297dba990fd96645026e7c69e10" + }, + "expected": { + "psbt": { + "hex": "70736274ff01020402000000010401010105010101fb040200000000010e201111111111111111111111111111111111111111111111111111111111111111010f040000000001012be803000000000000225120528b75296fa646acecf3fcb7c7697f92f7645ea0e41e6ee8a66554739d2da028012020ac7b0d0420f0d4a567d9abcb8a52e02cfae21690fd8d2d5934370dcc5aaee221011340d0c4f5ee3768c03a8d8e1204b8e52c6a4ded1f456d0f1707e7841928945c5a45bc1c0bc671d79612ef1c67a54bd50d653ce3d33c1fd966ce9a91e053f94177780001030884030000000000000104016a00", + "base64": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBERERERERERERERERERERERERERERERERERERERERERAQ8EAAAAAAEBK+gDAAAAAAAAIlEgUot1KW+mRqzs8/y3x2l/kvdkXqDkHm7opmVUc50toCgBICCsew0EIPDUpWfZq8uKUuAs+uIWkP2NLVk0Nw3MWq7iIQETQNDE9e43aMA6jY4SBLjlLGpN7R9FbQ8XB+eEGSiUXFpFvBwLxnHXlhLvHGelS9UNZTzj0zwf2WbOmpHgU/lBd3gAAQMIhAMAAAAAAAABBAFqAA==" + } + } + }, + { + "description": "Signer sets PSBT_IN_TAP_KEY_SIG with expected signature with d.G odd, for input with PSBT_IN_SP_TWEAK set", + "psbt": { + "hex": "70736274ff01020402000000010401010105010101fb040200000000010e201212121212121212121212121212121212121212121212121212121212121212010f040000000001012be903000000000000225120db0edc417c73c567add118de8d138b2d0b64083f0a1bd8e876936415de7edc4601202058e2385eb96d1c906bbd807eafd1fddb80fb2f43026a16386a400e6832644cbc0001030885030000000000000104016a00", + "base64": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISAQ8EAAAAAAEBK+kDAAAAAAAAIlEg2w7cQXxzxWet0RjejROLLQtkCD8KG9jodpNkFd5+3EYBICBY4jheuW0ckGu9gH6v0f3bgPsvQwJqFjhqQA5oMmRMvAABAwiFAwAAAAAAAAEEAWoA" + }, + "supplementary": { + "task": "sign", + "spend_seckey": "26c6ca8d553ebf5633cad2f5becd608acb99bfade1005254aa59bc4fc372985a", + "message": "a78521e49048b6e0d368d3fba417fc20c7546272dafa78a8a173fcca6c81233b", + "aux_rand": "6b31977a8ac73ede3f3653ea0d96bc3656242461e31d771985a0b17084d3cf91" + }, + "expected": { + "psbt": { + "hex": "70736274ff01020402000000010401010105010101fb040200000000010e201212121212121212121212121212121212121212121212121212121212121212010f040000000001012be903000000000000225120db0edc417c73c567add118de8d138b2d0b64083f0a1bd8e876936415de7edc4601202058e2385eb96d1c906bbd807eafd1fddb80fb2f43026a16386a400e6832644cbc0113403df67213afd895a833bc046e9455c77a7e40165638ad489669a5c498d4d71ab565a9c54abda2e23e931f7a0f78a9f151bba07b8400b7b96d24f857c8ba65c0220001030885030000000000000104016a00", + "base64": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISAQ8EAAAAAAEBK+kDAAAAAAAAIlEg2w7cQXxzxWet0RjejROLLQtkCD8KG9jodpNkFd5+3EYBICBY4jheuW0ckGu9gH6v0f3bgPsvQwJqFjhqQA5oMmRMvAETQD32chOv2JWoM7wEbpRVx3p+QBZWOK1IlmmlxJjU1xq1ZanFSr2i4j6TH3oPeKnxUbuge4QAt7ltJPhXyLplwCIAAQMIhQMAAAAAAAABBAFqAA==" + } + } + }, + { + "description": "Signer fails if the x-coordinate of d.G does not equal the output key in the P2TR output script", + "psbt": { + "hex": "70736274ff01020402000000010401010105010101fb040200000000010e201111111111111111111111111111111111111111111111111111111111111111010f040000000001012be803000000000000225120528b75296fa646acecf3fcb7c7697f92f7645ea0e41e6ee8a66554739d2da029221f02812c369c23c4185755813b40f4edf0cedffbc2496f7802932513ead763ffaef80400000000012020ac7b0d0420f0d4a567d9abcb8a52e02cfae21690fd8d2d5934370dcc5aaee2210001030884030000000000000104016a00", + "base64": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBERERERERERERERERERERERERERERERERERERERERERAQ8EAAAAAAEBK+gDAAAAAAAAIlEgUot1KW+mRqzs8/y3x2l/kvdkXqDkHm7opmVUc50toCkiHwKBLDacI8QYV1WBO0D07fDO3/vCSW94ApMlE+rXY/+u+AQAAAAAASAgrHsNBCDw1KVn2avLilLgLPriFpD9jS1ZNDcNzFqu4iEAAQMIhAMAAAAAAAABBAFqAA==" + }, + "supplementary": { + "task": "fail_sign", + "spend_seckey": "26c6ca8d553ebf5633cad2f5becd608acb99bfade1005254aa59bc4fc372985a", + "message": "289e5175e02c788c2d442cfe81d6be0533d8c13e253ef763fda45d37accfe4d4", + "aux_rand": "a617dfb275f834e26a6f0c94052dd88982c86297dba990fd96645026e7c69e10" + } + }, + { + "description": "Input finalizer fails to verify PSBT_IN_TAP_KEY_SIG for PSBT_IN_SP_TWEAK", + "psbt": { + "hex": "70736274ff01020402000000010401010105010101fb040200000000010e201111111111111111111111111111111111111111111111111111111111111111010f040000000001012be803000000000000225120528b75296fa646acecf3fcb7c7697f92f7645ea0e41e6ee8a66554739d2da028221f02812c369c23c4185755813b40f4edf0cedffbc2496f7802932513ead763ffaef80400000000012020ac7b0d0420f0d4a567d9abcb8a52e02cfae21690fd8d2d5934370dcc5aaee221011340d0c4f5ee3768c03a8d8e1204b8e52c6a4ded1f456d0f1707e7841928945c5a45bc1c0bc671d79612ef1c67a54bd50d653ce3d33c1fd966ce9a91e053f94177790001030884030000000000000104016a00", + "base64": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBERERERERERERERERERERERERERERERERERERERERERAQ8EAAAAAAEBK+gDAAAAAAAAIlEgUot1KW+mRqzs8/y3x2l/kvdkXqDkHm7opmVUc50toCgiHwKBLDacI8QYV1WBO0D07fDO3/vCSW94ApMlE+rXY/+u+AQAAAAAASAgrHsNBCDw1KVn2avLilLgLPriFpD9jS1ZNDcNzFqu4iEBE0DQxPXuN2jAOo2OEgS45SxqTe0fRW0PFwfnhBkolFxaRbwcC8Zx15YS7xxnpUvVDWU849M8H9lmzpqR4FP5QXd5AAEDCIQDAAAAAAAAAQQBagA=" + }, + "supplementary": { + "task": "fail", + "message": "289e5175e02c788c2d442cfe81d6be0533d8c13e253ef763fda45d37accfe4d4" + } + }, + { + "description": "Input finalizer removes PSBT_IN_SP_TWEAK, PSBT_IN_SP_SPEND_BIP32_DERIVATION, PSBT_IN_TAP_KEY_SIG, and PSBT_IN_WITNESS_UTXO fields for a finalized input", + "psbt": { + "hex": "70736274ff01020402000000010401010105010101fb040200000000010e201111111111111111111111111111111111111111111111111111111111111111010f040000000001012be803000000000000225120528b75296fa646acecf3fcb7c7697f92f7645ea0e41e6ee8a66554739d2da028221f02812c369c23c4185755813b40f4edf0cedffbc2496f7802932513ead763ffaef80400000000012020ac7b0d0420f0d4a567d9abcb8a52e02cfae21690fd8d2d5934370dcc5aaee221011340d0c4f5ee3768c03a8d8e1204b8e52c6a4ded1f456d0f1707e7841928945c5a45bc1c0bc671d79612ef1c67a54bd50d653ce3d33c1fd966ce9a91e053f94177780108420140d0c4f5ee3768c03a8d8e1204b8e52c6a4ded1f456d0f1707e7841928945c5a45bc1c0bc671d79612ef1c67a54bd50d653ce3d33c1fd966ce9a91e053f94177780001030884030000000000000104016a00", + "base64": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBERERERERERERERERERERERERERERERERERERERERERAQ8EAAAAAAEBK+gDAAAAAAAAIlEgUot1KW+mRqzs8/y3x2l/kvdkXqDkHm7opmVUc50toCgiHwKBLDacI8QYV1WBO0D07fDO3/vCSW94ApMlE+rXY/+u+AQAAAAAASAgrHsNBCDw1KVn2avLilLgLPriFpD9jS1ZNDcNzFqu4iEBE0DQxPXuN2jAOo2OEgS45SxqTe0fRW0PFwfnhBkolFxaRbwcC8Zx15YS7xxnpUvVDWU849M8H9lmzpqR4FP5QXd4AQhCAUDQxPXuN2jAOo2OEgS45SxqTe0fRW0PFwfnhBkolFxaRbwcC8Zx15YS7xxnpUvVDWU849M8H9lmzpqR4FP5QXd4AAEDCIQDAAAAAAAAAQQBagA=" + }, + "supplementary": { + "task": "finalize", + "message": "289e5175e02c788c2d442cfe81d6be0533d8c13e253ef763fda45d37accfe4d4" + }, + "expected": { + "psbt": { + "hex": "70736274ff01020402000000010401010105010101fb040200000000010e201111111111111111111111111111111111111111111111111111111111111111010f04000000000108420140d0c4f5ee3768c03a8d8e1204b8e52c6a4ded1f456d0f1707e7841928945c5a45bc1c0bc671d79612ef1c67a54bd50d653ce3d33c1fd966ce9a91e053f94177780001030884030000000000000104016a00", + "base64": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBERERERERERERERERERERERERERERERERERERERERERAQ8EAAAAAAEIQgFA0MT17jdowDqNjhIEuOUsak3tH0VtDxcH54QZKJRcWkW8HAvGcdeWEu8cZ6VL1Q1lPOPTPB/ZZs6akeBT+UF3eAABAwiEAwAAAAAAAAEEAWoA" + } + } + } + ] +}