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/psbt_bip376.py b/bip-0376/psbt_bip376.py new file mode 100644 index 0000000000..086aeedb77 --- /dev/null +++ b/bip-0376/psbt_bip376.py @@ -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(" 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) diff --git a/bip-0376/reference.py b/bip-0376/reference.py new file mode 100755 index 0000000000..b5bb793396 --- /dev/null +++ b/bip-0376/reference.py @@ -0,0 +1,144 @@ +#!/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 + +BIP375_DIR = Path(__file__).resolve().parents[1] / "bip-0375" +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()) diff --git a/bip-0376/test-vectors.json b/bip-0376/test-vectors.json new file mode 100644 index 0000000000..af899fab65 --- /dev/null +++ b/bip-0376/test-vectors.json @@ -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" + } + } + ] +}