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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions bip-0376.mediawiki
Original file line number Diff line number Diff line change
Expand Up @@ -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|<code>bip-0376/reference.py</code>]].
It uses the vendored <code>bitcoin_test</code> PSBT components and <code>secp256k1lab</code> test-only secp256k1 implementation from BIP 375.

It demonstrates the Signer behavior specified in this BIP:

* Key derivation using ''d = (b<sub>spend</sub> + 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|<code>bip-0376/test-vectors.json</code>]].

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:

<pre>
./bip-0376/reference.py bip-0376/test-vectors.json
</pre>

== Appendix ==

Expand Down
103 changes: 103 additions & 0 deletions bip-0376/psbt_bip376.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""BIP-376 PSBT helpers."""

from io import BytesIO
import struct
from typing import Optional

from deps.bitcoin_test.messages import CTransaction, CTxOut, deser_compact_size, from_binary
from deps.bitcoin_test.psbt import (
PSBT,
PSBTMap,
PSBT_GLOBAL_INPUT_COUNT,
PSBT_GLOBAL_OUTPUT_COUNT,
PSBT_GLOBAL_UNSIGNED_TX,
PSBT_GLOBAL_VERSION,
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):
"""PSBT that deserializes maps as BIP376PSBTMap instances."""

def deserialize(self, f):
assert f.read(5) == b"psbt\xff"
self.g = from_binary(BIP376PSBTMap, 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("<I", self.g.map[PSBT_GLOBAL_VERSION])[0]
assert self.version in [0, 2]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BIP 376 is backwards compatible with PSBTv2. It isn't compatible with PSBTv0, as the new fields are marked as excluded.

if self.version == 2:
self.in_count = deser_compact_size(
BytesIO(self.g.map[PSBT_GLOBAL_INPUT_COUNT])
)
self.out_count = deser_compact_size(
BytesIO(self.g.map[PSBT_GLOBAL_OUTPUT_COUNT])
)
else:
assert PSBT_GLOBAL_UNSIGNED_TX in self.g.map
tx = from_binary(CTransaction, self.g.map[PSBT_GLOBAL_UNSIGNED_TX])
self.in_count = len(tx.vin)
self.out_count = len(tx.vout)

self.i = [from_binary(BIP376PSBTMap, f) for _ in range(self.in_count)]
self.o = [from_binary(BIP376PSBTMap, f) for _ in range(self.out_count)]
return self


def get_p2tr_witness_utxo_output_key(input_map: BIP376PSBTMap) -> 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)
144 changes: 144 additions & 0 deletions bip-0376/reference.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""BIP-0376 reference implementation and test vector runner.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recommend you to follow the approach taken in #2087 and #2046 , use secp256k1lab and the components from bitcoin_test that you need. That would focus the review process on BIP 376.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, thank you


Run:
./bip-0376/reference.py bip-0376/test-vectors.json
"""

import json
import sys
from pathlib import Path

BIP375_DIR = Path(__file__).resolve().parents[1] / "bip-0375"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is brittle, I would do exactly what bip-0375 does and copy the whole secp256k1lab source code in this directory, and then do the Path magic.

DEPS_DIR = BIP375_DIR / "deps"
SECP256K1LAB_DIR = DEPS_DIR / "secp256k1lab/src"
for dependency_path in (BIP375_DIR, DEPS_DIR, SECP256K1LAB_DIR):
sys.path.insert(0, str(dependency_path))

from secp256k1lab.bip340 import schnorr_sign
from secp256k1lab.secp256k1 import G, Scalar

from psbt_bip376 import (
BIP376PSBT,
PSBT_IN_SP_TWEAK,
get_p2tr_witness_utxo_output_key,
get_sp_tweak,
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 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 sign_psbt(psbt_data: str, spend_seckey: bytes, message: bytes, aux_rand: bytes) -> str:
psbt = BIP376PSBT.from_base64(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 psbt.to_base64()


def run_test_vectors(path: Path) -> bool:
vectors = json.loads(path.read_text(encoding="utf-8"))
all_passed = True

valid_vectors = vectors.get("valid", [])
invalid_vectors = vectors.get("invalid", [])

print(f"Running {len(valid_vectors)} valid vectors")
for index, vector in enumerate(valid_vectors):
description = vector["description"]
given = vector["given"]
expected = vector["expected"]
print(f"- valid[{index}] {description}")
try:
spend_seckey = parse_hex(given["spend_seckey"], 32, "spend_seckey")
message = parse_hex(given["message"], 32, "message")
aux_rand = parse_hex(given["aux_rand"], 32, "aux_rand")

if "psbt" in given:
signed_psbt = sign_psbt(given["psbt"], spend_seckey, message, aux_rand)
assert signed_psbt == expected["psbt"]
else:
tweak = parse_hex(given["tweak"], 32, "tweak")
output_pubkey = parse_hex(given["output_pubkey"], 32, "output_pubkey")
_, d, _ = derive_signing_key(spend_seckey, tweak, output_pubkey)
signature = schnorr_sign(message, d.to_bytes(), aux_rand)
assert signature.hex() == expected["signature"]
except Exception as exc:
all_passed = False
print(f" FAILED: {exc}")

print(f"Running {len(invalid_vectors)} invalid vectors")
for index, vector in enumerate(invalid_vectors):
description = vector["description"]
given = vector["given"]
print(f"- invalid[{index}] {description}")
try:
spend_seckey = parse_hex(given["spend_seckey"], 32, "spend_seckey")
tweak = parse_hex(given["tweak"], 32, "tweak")
output_pubkey = parse_hex(given["output_pubkey"], 32, "output_pubkey")
derive_signing_key(spend_seckey, tweak, output_pubkey)
all_passed = False
print(" FAILED: expected an exception")
except Exception:
pass

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())
54 changes: 54 additions & 0 deletions bip-0376/test-vectors.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"valid": [
{
"description": "No negation required; tweaked key directly matches output key",
"given": {
"spend_seckey": "26c6ca8d553ebf5633cad2f5becd608acb99bfade1005254aa59bc4fc372985a",
"psbt": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBERERERERERERERERERERERERERERERERERERERERERAQ8EAAAAAAEBK+gDAAAAAAAAIlEgUot1KW+mRqzs8/y3x2l/kvdkXqDkHm7opmVUc50toCgiHwKBLDacI8QYV1WBO0D07fDO3/vCSW94ApMlE+rXY/+u+AQAAAAAASAgrHsNBCDw1KVn2avLilLgLPriFpD9jS1ZNDcNzFqu4iEAAQMIhAMAAAAAAAABBAFqAA==",
"message": "289e5175e02c788c2d442cfe81d6be0533d8c13e253ef763fda45d37accfe4d4",
"aux_rand": "a617dfb275f834e26a6f0c94052dd88982c86297dba990fd96645026e7c69e10"
},
"expected": {
"psbt": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBERERERERERERERERERERERERERERERERERERERERERAQ8EAAAAAAEBK+gDAAAAAAAAIlEgUot1KW+mRqzs8/y3x2l/kvdkXqDkHm7opmVUc50toCgiHwKBLDacI8QYV1WBO0D07fDO3/vCSW94ApMlE+rXY/+u+AQAAAAAASAgrHsNBCDw1KVn2avLilLgLPriFpD9jS1ZNDcNzFqu4iEBE0DQxPXuN2jAOo2OEgS45SxqTe0fRW0PFwfnhBkolFxaRbwcC8Zx15YS7xxnpUvVDWU849M8H9lmzpqR4FP5QXd4AAEDCIQDAAAAAAAAAQQBagA="
}
},
{
"description": "Negation required because (b_spend + tweak)G has odd Y",
"given": {
"spend_seckey": "26c6ca8d553ebf5633cad2f5becd608acb99bfade1005254aa59bc4fc372985a",
"psbt": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISAQ8EAAAAAAEBK+kDAAAAAAAAIlEg2w7cQXxzxWet0RjejROLLQtkCD8KG9jodpNkFd5+3EYiHwKBLDacI8QYV1WBO0D07fDO3/vCSW94ApMlE+rXY/+u+AQAAAAAASAgWOI4XrltHJBrvYB+r9H924D7L0MCahY4akAOaDJkTLwAAQMIhQMAAAAAAAABBAFqAA==",
"message": "a78521e49048b6e0d368d3fba417fc20c7546272dafa78a8a173fcca6c81233b",
"aux_rand": "6b31977a8ac73ede3f3653ea0d96bc3656242461e31d771985a0b17084d3cf91"
},
"expected": {
"psbt": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISAQ8EAAAAAAEBK+kDAAAAAAAAIlEg2w7cQXxzxWet0RjejROLLQtkCD8KG9jodpNkFd5+3EYiHwKBLDacI8QYV1WBO0D07fDO3/vCSW94ApMlE+rXY/+u+AQAAAAAASAgWOI4XrltHJBrvYB+r9H924D7L0MCahY4akAOaDJkTLwBE0A99nITr9iVqDO8BG6UVcd6fkAWVjitSJZppcSY1NcatWWpxUq9ouI+kx96D3ip8VG7oHuEALe5bST4V8i6ZcAiAAEDCIUDAAAAAAAAAQQBagA="
}
}
],
"invalid": [
{
"description": "Tweaked key does not match output key",
"given": {
"spend_seckey": "26c6ca8d553ebf5633cad2f5becd608acb99bfade1005254aa59bc4fc372985a",
"tweak": "ac7b0d0420f0d4a567d9abcb8a52e02cfae21690fd8d2d5934370dcc5aaee221",
"output_pubkey": "528b75296fa646acecf3fcb7c7697f92f7645ea0e41e6ee8a66554739d2da029"
}
},
{
"description": "Tweaked private key is zero",
"given": {
"spend_seckey": "26c6ca8d553ebf5633cad2f5becd608acb99bfade1005254aa59bc4fc372985a",
"tweak": "d9393572aac140a9cc352d0a41329f73ef151d38ce484de71578a23d0cc3a8e7",
"output_pubkey": "528b75296fa646acecf3fcb7c7697f92f7645ea0e41e6ee8a66554739d2da028"
}
},
{
"description": "Spend key out of range",
"given": {
"spend_seckey": "0000000000000000000000000000000000000000000000000000000000000000",
"tweak": "ac7b0d0420f0d4a567d9abcb8a52e02cfae21690fd8d2d5934370dcc5aaee221",
"output_pubkey": "528b75296fa646acecf3fcb7c7697f92f7645ea0e41e6ee8a66554739d2da028"
}
}
]
}