Skip to content

Commit 2590fa3

Browse files
committed
BIP-0376: use PSBT vectors for signer tests
1 parent d8db6b3 commit 2590fa3

4 files changed

Lines changed: 161 additions & 141 deletions

File tree

bip-0376.mediawiki

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ These are new fields added to the existing PSBT format. Because PSBT is designed
147147
== Reference implementation ==
148148

149149
A Python reference implementation is provided in [[bip-0376/reference.py|<code>bip-0376/reference.py</code>]].
150+
It uses the vendored <code>bitcoin_test</code> PSBT components and <code>secp256k1lab</code> test-only secp256k1 implementation from BIP 375.
150151

151152
It demonstrates the Signer behavior specified in this BIP:
152153

@@ -161,7 +162,7 @@ Machine-readable test vectors are provided in [[bip-0376/test-vectors.json|<code
161162

162163
The vector set includes:
163164

164-
* Valid cases with and without key negation.
165+
* Valid signing cases with and without key negation.
165166
* Invalid cases for output-key mismatch, zero tweaked key, and out-of-range spend key.
166167
167168
The reference implementation can be run against the vectors with:

bip-0376/psbt_bip376.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#!/usr/bin/env python3
2+
"""BIP-376 PSBT helpers."""
3+
4+
from io import BytesIO
5+
import struct
6+
from typing import Optional
7+
8+
from deps.bitcoin_test.messages import CTransaction, CTxOut, deser_compact_size, from_binary
9+
from deps.bitcoin_test.psbt import (
10+
PSBT,
11+
PSBTMap,
12+
PSBT_GLOBAL_INPUT_COUNT,
13+
PSBT_GLOBAL_OUTPUT_COUNT,
14+
PSBT_GLOBAL_UNSIGNED_TX,
15+
PSBT_GLOBAL_VERSION,
16+
PSBT_IN_TAP_KEY_SIG,
17+
PSBT_IN_WITNESS_UTXO,
18+
)
19+
20+
PSBT_IN_SP_SPEND_BIP32_DERIVATION = 0x1F
21+
PSBT_IN_SP_TWEAK = 0x20
22+
23+
24+
class BIP376PSBTMap(PSBTMap):
25+
"""PSBTMap with helpers for BIP-376 field access."""
26+
27+
def __getitem__(self, key):
28+
return self.map[key]
29+
30+
def __contains__(self, key):
31+
return key in self.map
32+
33+
def get(self, key, default=None):
34+
return self.map.get(key, default)
35+
36+
def get_by_key(self, key_type: int, key_data: bytes = b"") -> Optional[bytes]:
37+
if key_data == b"":
38+
return self.map.get(key_type)
39+
return self.map.get(bytes([key_type]) + key_data)
40+
41+
def set_by_key(self, key_type: int, value_data: bytes, key_data: bytes = b"") -> None:
42+
if key_data == b"":
43+
self.map[key_type] = value_data
44+
else:
45+
self.map[bytes([key_type]) + key_data] = value_data
46+
47+
48+
class BIP376PSBT(PSBT):
49+
"""PSBT that deserializes maps as BIP376PSBTMap instances."""
50+
51+
def deserialize(self, f):
52+
assert f.read(5) == b"psbt\xff"
53+
self.g = from_binary(BIP376PSBTMap, f)
54+
55+
self.version = 0
56+
if PSBT_GLOBAL_VERSION in self.g.map:
57+
assert PSBT_GLOBAL_INPUT_COUNT in self.g.map
58+
assert PSBT_GLOBAL_OUTPUT_COUNT in self.g.map
59+
self.version = struct.unpack("<I", self.g.map[PSBT_GLOBAL_VERSION])[0]
60+
assert self.version in [0, 2]
61+
if self.version == 2:
62+
self.in_count = deser_compact_size(
63+
BytesIO(self.g.map[PSBT_GLOBAL_INPUT_COUNT])
64+
)
65+
self.out_count = deser_compact_size(
66+
BytesIO(self.g.map[PSBT_GLOBAL_OUTPUT_COUNT])
67+
)
68+
else:
69+
assert PSBT_GLOBAL_UNSIGNED_TX in self.g.map
70+
tx = from_binary(CTransaction, self.g.map[PSBT_GLOBAL_UNSIGNED_TX])
71+
self.in_count = len(tx.vin)
72+
self.out_count = len(tx.vout)
73+
74+
self.i = [from_binary(BIP376PSBTMap, f) for _ in range(self.in_count)]
75+
self.o = [from_binary(BIP376PSBTMap, f) for _ in range(self.out_count)]
76+
return self
77+
78+
79+
def get_p2tr_witness_utxo_output_key(input_map: BIP376PSBTMap) -> bytes:
80+
witness_utxo = input_map.get(PSBT_IN_WITNESS_UTXO)
81+
if witness_utxo is None:
82+
raise ValueError("missing PSBT_IN_WITNESS_UTXO")
83+
84+
txout = from_binary(CTxOut, witness_utxo)
85+
script_pubkey = txout.scriptPubKey
86+
if len(script_pubkey) != 34 or script_pubkey[:2] != b"\x51\x20":
87+
raise ValueError("PSBT_IN_WITNESS_UTXO is not a P2TR output")
88+
return script_pubkey[2:]
89+
90+
91+
def get_sp_tweak(input_map: BIP376PSBTMap) -> bytes:
92+
tweak = input_map.get(PSBT_IN_SP_TWEAK)
93+
if tweak is None:
94+
raise ValueError("missing PSBT_IN_SP_TWEAK")
95+
if len(tweak) != 32:
96+
raise ValueError("PSBT_IN_SP_TWEAK must be 32 bytes")
97+
return tweak
98+
99+
100+
def set_tap_key_sig(input_map: BIP376PSBTMap, signature: bytes) -> None:
101+
if len(signature) not in (64, 65):
102+
raise ValueError("PSBT_IN_TAP_KEY_SIG must be 64 or 65 bytes")
103+
input_map.set_by_key(PSBT_IN_TAP_KEY_SIG, signature)

bip-0376/reference.py

Lines changed: 52 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -7,117 +7,25 @@
77

88
import json
99
import sys
10-
import hashlib
1110
from pathlib import Path
12-
from typing import Optional, Tuple
1311

14-
p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
15-
n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
16-
G = (
17-
0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
18-
0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8,
12+
BIP375_DIR = Path(__file__).resolve().parents[1] / "bip-0375"
13+
DEPS_DIR = BIP375_DIR / "deps"
14+
SECP256K1LAB_DIR = DEPS_DIR / "secp256k1lab/src"
15+
for dependency_path in (BIP375_DIR, DEPS_DIR, SECP256K1LAB_DIR):
16+
sys.path.insert(0, str(dependency_path))
17+
18+
from secp256k1lab.bip340 import schnorr_sign
19+
from secp256k1lab.secp256k1 import G, Scalar
20+
21+
from psbt_bip376 import (
22+
BIP376PSBT,
23+
PSBT_IN_SP_TWEAK,
24+
get_p2tr_witness_utxo_output_key,
25+
get_sp_tweak,
26+
set_tap_key_sig,
1927
)
2028

21-
Point = Tuple[int, int]
22-
23-
24-
def tagged_hash(tag: str, msg: bytes) -> bytes:
25-
tag_hash = hashlib.sha256(tag.encode("utf-8")).digest()
26-
return hashlib.sha256(tag_hash + tag_hash + msg).digest()
27-
28-
29-
def int_from_bytes(data: bytes) -> int:
30-
return int.from_bytes(data, byteorder="big")
31-
32-
33-
def bytes_from_int(x: int) -> bytes:
34-
return x.to_bytes(32, byteorder="big")
35-
36-
37-
def has_even_y(P: Point) -> bool:
38-
return (P[1] % 2) == 0
39-
40-
41-
def bytes_from_point(P: Point) -> bytes:
42-
return bytes_from_int(P[0])
43-
44-
45-
def xor_bytes(a: bytes, b: bytes) -> bytes:
46-
return bytes(x ^ y for (x, y) in zip(a, b))
47-
48-
49-
def lift_x(x_coord: int) -> Optional[Point]:
50-
if x_coord >= p:
51-
return None
52-
y_sq = (pow(x_coord, 3, p) + 7) % p
53-
y_coord = pow(y_sq, (p + 1) // 4, p)
54-
if pow(y_coord, 2, p) != y_sq:
55-
return None
56-
return (x_coord, y_coord if (y_coord % 2) == 0 else p - y_coord)
57-
58-
59-
def point_add(P1: Optional[Point], P2: Optional[Point]) -> Optional[Point]:
60-
if P1 is None:
61-
return P2
62-
if P2 is None:
63-
return P1
64-
if (P1[0] == P2[0]) and (P1[1] != P2[1]):
65-
return None
66-
if P1 == P2:
67-
lam = (3 * P1[0] * P1[0] * pow(2 * P1[1], p - 2, p)) % p
68-
else:
69-
lam = ((P2[1] - P1[1]) * pow(P2[0] - P1[0], p - 2, p)) % p
70-
x3 = (lam * lam - P1[0] - P2[0]) % p
71-
y3 = (lam * (P1[0] - x3) - P1[1]) % p
72-
return (x3, y3)
73-
74-
75-
def point_mul(P: Optional[Point], scalar: int) -> Optional[Point]:
76-
R = None
77-
for i in range(256):
78-
if (scalar >> i) & 1:
79-
R = point_add(R, P)
80-
P = point_add(P, P)
81-
return R
82-
83-
84-
def schnorr_verify(msg: bytes, pubkey: bytes, sig: bytes) -> bool:
85-
if len(pubkey) != 32 or len(sig) != 64:
86-
return False
87-
P = lift_x(int_from_bytes(pubkey))
88-
r = int_from_bytes(sig[0:32])
89-
s = int_from_bytes(sig[32:64])
90-
if P is None or r >= p or s >= n:
91-
return False
92-
e = int_from_bytes(tagged_hash("BIP0340/challenge", sig[0:32] + pubkey + msg)) % n
93-
R = point_add(point_mul(G, s), point_mul(P, n - e))
94-
if R is None:
95-
return False
96-
return has_even_y(R) and (R[0] == r)
97-
98-
99-
def schnorr_sign(msg: bytes, seckey: bytes, aux_rand: bytes) -> bytes:
100-
d0 = int_from_bytes(seckey)
101-
if not (1 <= d0 <= n - 1):
102-
raise ValueError("The secret key must be in the range 1..n-1.")
103-
if len(aux_rand) != 32:
104-
raise ValueError("aux_rand must be 32 bytes.")
105-
P = point_mul(G, d0)
106-
assert P is not None
107-
d = d0 if has_even_y(P) else n - d0
108-
t = xor_bytes(bytes_from_int(d), tagged_hash("BIP0340/aux", aux_rand))
109-
k0 = int_from_bytes(tagged_hash("BIP0340/nonce", t + bytes_from_point(P) + msg)) % n
110-
if k0 == 0:
111-
raise RuntimeError("Failure. This happens only with negligible probability.")
112-
R = point_mul(G, k0)
113-
assert R is not None
114-
k = k0 if has_even_y(R) else n - k0
115-
e = int_from_bytes(tagged_hash("BIP0340/challenge", bytes_from_point(R) + bytes_from_point(P) + msg)) % n
116-
sig = bytes_from_point(R) + bytes_from_int((k + e * d) % n)
117-
if not schnorr_verify(msg, bytes_from_point(P), sig):
118-
raise RuntimeError("The created signature does not pass verification.")
119-
return sig
120-
12129

12230
def parse_hex(data: str, expected_len: int, field_name: str) -> bytes:
12331
raw = bytes.fromhex(data)
@@ -126,29 +34,45 @@ def parse_hex(data: str, expected_len: int, field_name: str) -> bytes:
12634
return raw
12735

12836

129-
def derive_signing_key(spend_seckey: bytes, tweak: bytes, output_pubkey: bytes) -> Tuple[int, int, bool]:
130-
b_spend = int_from_bytes(spend_seckey)
131-
if not (1 <= b_spend <= n - 1):
37+
def derive_signing_key(
38+
spend_seckey: bytes, tweak: bytes, output_pubkey: bytes
39+
) -> tuple[Scalar, Scalar, bool]:
40+
try:
41+
b_spend = Scalar.from_bytes_checked(spend_seckey)
42+
except ValueError as exc:
43+
raise ValueError("spend key out of range") from exc
44+
if b_spend == 0:
13245
raise ValueError("spend key out of range")
13346

134-
tweak_int = int_from_bytes(tweak)
135-
d_raw = (b_spend + tweak_int) % n
47+
d_raw = b_spend + Scalar.from_bytes_wrapping(tweak)
13648
if d_raw == 0:
13749
raise ValueError("tweaked private key is zero")
13850

139-
Q = point_mul(G, d_raw)
140-
assert Q is not None
141-
negated = not has_even_y(Q)
142-
d = d_raw if not negated else n - d_raw
51+
Q = d_raw * G
52+
assert not Q.infinity
53+
negated = not Q.has_even_y()
54+
d = d_raw if not negated else -d_raw
14355

144-
Q_even = point_mul(G, d)
145-
assert Q_even is not None
146-
if bytes_from_point(Q_even) != output_pubkey:
56+
Q_even = d * G
57+
assert not Q_even.infinity
58+
if Q_even.to_bytes_xonly() != output_pubkey:
14759
raise ValueError("tweaked key does not match output key")
14860

14961
return d_raw, d, negated
15062

15163

64+
def sign_psbt(psbt_data: str, spend_seckey: bytes, message: bytes, aux_rand: bytes) -> str:
65+
psbt = BIP376PSBT.from_base64(psbt_data)
66+
for input_map in psbt.i:
67+
if input_map.get(PSBT_IN_SP_TWEAK) is None:
68+
continue
69+
tweak = get_sp_tweak(input_map)
70+
output_pubkey = get_p2tr_witness_utxo_output_key(input_map)
71+
_, d, _ = derive_signing_key(spend_seckey, tweak, output_pubkey)
72+
set_tap_key_sig(input_map, schnorr_sign(message, d.to_bytes(), aux_rand))
73+
return psbt.to_base64()
74+
75+
15276
def run_test_vectors(path: Path) -> bool:
15377
vectors = json.loads(path.read_text(encoding="utf-8"))
15478
all_passed = True
@@ -164,18 +88,18 @@ def run_test_vectors(path: Path) -> bool:
16488
print(f"- valid[{index}] {description}")
16589
try:
16690
spend_seckey = parse_hex(given["spend_seckey"], 32, "spend_seckey")
167-
tweak = parse_hex(given["tweak"], 32, "tweak")
168-
output_pubkey = parse_hex(given["output_pubkey"], 32, "output_pubkey")
16991
message = parse_hex(given["message"], 32, "message")
17092
aux_rand = parse_hex(given["aux_rand"], 32, "aux_rand")
17193

172-
d_raw, d, negated = derive_signing_key(spend_seckey, tweak, output_pubkey)
173-
signature = schnorr_sign(message, bytes_from_int(d), aux_rand)
174-
175-
assert bytes_from_int(d_raw).hex() == expected["raw_tweaked_seckey"]
176-
assert negated == expected["negated"]
177-
assert bytes_from_int(d).hex() == expected["final_seckey"]
178-
assert signature.hex() == expected["signature"]
94+
if "psbt" in given:
95+
signed_psbt = sign_psbt(given["psbt"], spend_seckey, message, aux_rand)
96+
assert signed_psbt == expected["psbt"]
97+
else:
98+
tweak = parse_hex(given["tweak"], 32, "tweak")
99+
output_pubkey = parse_hex(given["output_pubkey"], 32, "output_pubkey")
100+
_, d, _ = derive_signing_key(spend_seckey, tweak, output_pubkey)
101+
signature = schnorr_sign(message, d.to_bytes(), aux_rand)
102+
assert signature.hex() == expected["signature"]
179103
except Exception as exc:
180104
all_passed = False
181105
print(f" FAILED: {exc}")

bip-0376/test-vectors.json

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,24 @@
44
"description": "No negation required; tweaked key directly matches output key",
55
"given": {
66
"spend_seckey": "26c6ca8d553ebf5633cad2f5becd608acb99bfade1005254aa59bc4fc372985a",
7-
"tweak": "ac7b0d0420f0d4a567d9abcb8a52e02cfae21690fd8d2d5934370dcc5aaee221",
8-
"output_pubkey": "528b75296fa646acecf3fcb7c7697f92f7645ea0e41e6ee8a66554739d2da028",
7+
"psbt": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBERERERERERERERERERERERERERERERERERERERERERAQ8EAAAAAAEBK+gDAAAAAAAAIlEgUot1KW+mRqzs8/y3x2l/kvdkXqDkHm7opmVUc50toCgiHwKBLDacI8QYV1WBO0D07fDO3/vCSW94ApMlE+rXY/+u+AQAAAAAASAgrHsNBCDw1KVn2avLilLgLPriFpD9jS1ZNDcNzFqu4iEAAQMIhAMAAAAAAAABBAFqAA==",
98
"message": "289e5175e02c788c2d442cfe81d6be0533d8c13e253ef763fda45d37accfe4d4",
109
"aux_rand": "a617dfb275f834e26a6f0c94052dd88982c86297dba990fd96645026e7c69e10"
1110
},
1211
"expected": {
13-
"raw_tweaked_seckey": "d341d791762f93fb9ba47ec1492040b7c67bd63ede8d7fadde90ca1c1e217a7b",
14-
"negated": false,
15-
"final_seckey": "d341d791762f93fb9ba47ec1492040b7c67bd63ede8d7fadde90ca1c1e217a7b",
16-
"signature": "d0c4f5ee3768c03a8d8e1204b8e52c6a4ded1f456d0f1707e7841928945c5a45bc1c0bc671d79612ef1c67a54bd50d653ce3d33c1fd966ce9a91e053f9417778"
12+
"psbt": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBERERERERERERERERERERERERERERERERERERERERERAQ8EAAAAAAEBK+gDAAAAAAAAIlEgUot1KW+mRqzs8/y3x2l/kvdkXqDkHm7opmVUc50toCgiHwKBLDacI8QYV1WBO0D07fDO3/vCSW94ApMlE+rXY/+u+AQAAAAAASAgrHsNBCDw1KVn2avLilLgLPriFpD9jS1ZNDcNzFqu4iEBE0DQxPXuN2jAOo2OEgS45SxqTe0fRW0PFwfnhBkolFxaRbwcC8Zx15YS7xxnpUvVDWU849M8H9lmzpqR4FP5QXd4AAEDCIQDAAAAAAAAAQQBagA="
1713
}
1814
},
1915
{
2016
"description": "Negation required because (b_spend + tweak)G has odd Y",
2117
"given": {
2218
"spend_seckey": "26c6ca8d553ebf5633cad2f5becd608acb99bfade1005254aa59bc4fc372985a",
23-
"tweak": "58e2385eb96d1c906bbd807eafd1fddb80fb2f43026a16386a400e6832644cbc",
24-
"output_pubkey": "db0edc417c73c567add118de8d138b2d0b64083f0a1bd8e876936415de7edc46",
19+
"psbt": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISAQ8EAAAAAAEBK+kDAAAAAAAAIlEg2w7cQXxzxWet0RjejROLLQtkCD8KG9jodpNkFd5+3EYiHwKBLDacI8QYV1WBO0D07fDO3/vCSW94ApMlE+rXY/+u+AQAAAAAASAgWOI4XrltHJBrvYB+r9H924D7L0MCahY4akAOaDJkTLwAAQMIhQMAAAAAAAABBAFqAA==",
2520
"message": "a78521e49048b6e0d368d3fba417fc20c7546272dafa78a8a173fcca6c81233b",
2621
"aux_rand": "6b31977a8ac73ede3f3653ea0d96bc3656242461e31d771985a0b17084d3cf91"
2722
},
2823
"expected": {
29-
"raw_tweaked_seckey": "7fa902ec0eabdbe69f8853746e9f5e664c94eef0e36a688d1499cab7f5d6e516",
30-
"negated": true,
31-
"final_seckey": "8056fd13f15424196077ac8b9160a1986e19edf5cbde37aeab3893d4da5f5c2b",
32-
"signature": "3df67213afd895a833bc046e9455c77a7e40165638ad489669a5c498d4d71ab565a9c54abda2e23e931f7a0f78a9f151bba07b8400b7b96d24f857c8ba65c022"
24+
"psbt": "cHNidP8BAgQCAAAAAQQBAQEFAQEB+wQCAAAAAAEOIBISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISAQ8EAAAAAAEBK+kDAAAAAAAAIlEg2w7cQXxzxWet0RjejROLLQtkCD8KG9jodpNkFd5+3EYiHwKBLDacI8QYV1WBO0D07fDO3/vCSW94ApMlE+rXY/+u+AQAAAAAASAgWOI4XrltHJBrvYB+r9H924D7L0MCahY4akAOaDJkTLwBE0A99nITr9iVqDO8BG6UVcd6fkAWVjitSJZppcSY1NcatWWpxUq9ouI+kx96D3ip8VG7oHuEALe5bST4V8i6ZcAiAAEDCIUDAAAAAAAAAQQBagA="
3325
}
3426
}
3527
],

0 commit comments

Comments
 (0)