-
Notifications
You must be signed in to change notification settings - Fork 5.9k
BIP-376: Silent Payment input signing behavior #2139
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
8c38c9e
d8db6b3
2590fa3
fe81dca
1f70448
722f501
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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] | ||
| 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) | ||
| 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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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()) | ||
| 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" | ||
| } | ||
| } | ||
| ] | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BIP 376is backwards compatible with PSBTv2. It isn't compatible with PSBTv0, as the new fields are marked as excluded.