Skip to content

Commit a6b9d50

Browse files
committed
Added experimental FROST support
1 parent c0b7d57 commit a6b9d50

File tree

4 files changed

+286
-0
lines changed

4 files changed

+286
-0
lines changed

buidl/cecc.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ def __init__(self, csec=None, usec=None):
4747
def __eq__(self, other):
4848
return self.sec() == other.sec()
4949

50+
def __hash__(self):
51+
return hash(self.sec())
52+
5053
def __repr__(self):
5154
return f"S256Point({self.sec().hex()})"
5255

buidl/frost.py

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
from secrets import randbelow
2+
3+
from buidl.ecc import N, G, S256Point, SchnorrSignature
4+
from buidl.helper import (
5+
big_endian_to_int,
6+
encode_varint,
7+
int_to_big_endian,
8+
)
9+
from buidl.hash import hash_challenge
10+
from buidl.phash import tagged_hash
11+
12+
13+
def hash_frost_keygen(m):
14+
"""Hash used for cooperative key generation. This should be a tagged hash"""
15+
return tagged_hash(b"FROST/keygen", m)
16+
17+
18+
def hash_frost_commitment(m):
19+
"""Hash used for message commitment in signing. This should be a tagged hash"""
20+
return tagged_hash(b"FROST/commitment", m)
21+
22+
23+
class FrostParticipant:
24+
"""Represents a participant in a t-of-n FROST"""
25+
26+
def __init__(self, t, n, index):
27+
# t-of-n FROST with this one being at index in [1, n]
28+
self.t = t
29+
self.n = n
30+
self.index = index
31+
self.keygen_coefficients = None
32+
self.coefficient_commitments = [[] for _ in range(self.n)]
33+
self.shares_from = [None for _ in range(self.n)]
34+
35+
def key_generation_round_1(self, name):
36+
if self.keygen_coefficients is not None:
37+
raise ValueError("secrets have already been defined")
38+
# generate t random numbers for a Shamir polynomial
39+
self.keygen_coefficients = [randbelow(N) for _ in range(self.t)]
40+
my_commitments = [coef * G for coef in self.keygen_coefficients]
41+
self.coefficient_commitments[self.index] = my_commitments
42+
k = randbelow(N) # TODO: change this to use the k generation from bip340
43+
r = k * G
44+
c = hash_frost_keygen(
45+
encode_varint(self.index) + name + my_commitments[0].xonly() + r.xonly()
46+
)
47+
# proof proves that we know the first coefficient
48+
proof = (k + self.keygen_coefficients[0] * big_endian_to_int(c)) % N
49+
return (my_commitments, r, proof)
50+
51+
def poly_value(self, x):
52+
"""return the polynomial value f(x) for the polynomial defined by the secrets"""
53+
result = 0
54+
for coef_index in range(self.t):
55+
result += self.keygen_coefficients[coef_index] * x**coef_index % N
56+
return result % N
57+
58+
def verify_round_1(self, name, participant_index, commitments, r, proof):
59+
"""check that the commitment at index 0, r and proof are valid"""
60+
if participant_index == self.index:
61+
return
62+
c = hash_frost_keygen(
63+
encode_varint(participant_index) + name + commitments[0].xonly() + r.xonly()
64+
)
65+
if r != -big_endian_to_int(c) * commitments[0] + proof:
66+
raise RuntimeError("commitment does not correspond to proof")
67+
self.coefficient_commitments[participant_index] = commitments
68+
69+
def key_generation_round_2(self):
70+
"""Deal out shares to each participant corresponding to their index + 1"""
71+
shares = []
72+
for participant_index in range(self.n):
73+
shares.append(self.poly_value(participant_index + 1))
74+
self.shares_from[self.index] = shares[self.index]
75+
shares[self.index] = None
76+
return shares
77+
78+
def verify_round_2(self, participant_index, share):
79+
"""Check that we have a valid point in the committed Shamir polynomial
80+
from this participant"""
81+
if participant_index == self.index:
82+
return
83+
commitments = self.coefficient_commitments[participant_index]
84+
x = self.index + 1
85+
target = share * G
86+
points = []
87+
for coef_index in range(self.t):
88+
coef = x**coef_index % N
89+
points.append(coef * commitments[coef_index])
90+
if S256Point.combine(points) != target:
91+
raise RuntimeError("share does not correspond to the commitment")
92+
self.shares_from[participant_index] = share
93+
94+
def compute_keys(self):
95+
"""Now compute the pubkeys for each participant and the secret share for
96+
our pubkey"""
97+
self.pubkeys = []
98+
for _ in range(self.n):
99+
points = []
100+
for participant_index in range(self.n):
101+
for coef_index in range(self.t):
102+
coef = (self.index + 1) ** coef_index % N
103+
points.append(
104+
coef
105+
* self.coefficient_commitments[participant_index][coef_index]
106+
)
107+
self.pubkeys.append(S256Point.combine(points))
108+
# the constant term of the combined polynomial is the pubkey
109+
self.group_pubkey = S256Point.combine(
110+
[
111+
self.coefficient_commitments[participant_index][0]
112+
for participant_index in range(self.n)
113+
]
114+
)
115+
# the secret shares that were dealt to us, we now combine for the secret
116+
self.secret = sum(self.shares_from) % N
117+
# sanity check against the public key we computed
118+
self.pubkey = self.pubkeys[self.index]
119+
if self.secret * G != self.pubkey:
120+
raise RuntimeError("something wrong with the secret")
121+
# if we have an odd group key, negate everything
122+
if self.group_pubkey.parity:
123+
# negate the pubkeys, the group pubkey and our secret
124+
self.pubkeys = [-1 * p for p in self.pubkeys]
125+
self.group_pubkey = -1 * self.group_pubkey
126+
self.secret = N - self.secret
127+
self.pubkey = self.pubkeys[self.index]
128+
return self.group_pubkey
129+
130+
def generate_nonce_pairs(self, num=200):
131+
"""We now deal to everyone the nonces we will be using for signing.
132+
Each signing requires a pair of nonces and we return the nonce commitments"""
133+
# create two nonces for use in the signing
134+
self.nonces = {}
135+
self.nonce_pubs = []
136+
for _ in range(num):
137+
# this should probably involve some deterministic process involving
138+
# the private key
139+
nonce_1, nonce_2 = randbelow(N), randbelow(N)
140+
nonce_pub_1 = nonce_1 * G
141+
nonce_pub_2 = nonce_2 * G
142+
self.nonces[nonce_pub_1] = (nonce_1, nonce_2)
143+
self.nonce_pubs.append((nonce_pub_1, nonce_pub_2))
144+
return self.nonce_pubs
145+
146+
def register_nonce_pubs(self, nonce_pubs_list):
147+
"""When we receive the nonce commitments, we store them"""
148+
self.nonces_available = []
149+
for nonce_pubs in nonce_pubs_list:
150+
nonce_lookup = {}
151+
for nonce_pub_1, nonce_pub_2 in nonce_pubs:
152+
nonce_lookup[(nonce_pub_1, nonce_pub_2)] = True
153+
self.nonces_available.append(nonce_lookup)
154+
155+
def compute_group_r(self, msg, nonces_to_use):
156+
"""The R that we use for signing can be computed based on the nonces
157+
we are using and the message that we're signing"""
158+
# add up the first nonces as normal
159+
ds = []
160+
for key in sorted(nonces_to_use.keys()):
161+
value = nonces_to_use[key]
162+
ds.append(value[0])
163+
result = [S256Point.combine(ds)]
164+
# the second nonces need to be multiplied by the commitment
165+
for key in sorted(nonces_to_use.keys()):
166+
value = nonces_to_use[key]
167+
commitment = (
168+
big_endian_to_int(
169+
hash_frost_commitment(
170+
msg + encode_varint(key) + value[0].xonly() + value[1].xonly()
171+
)
172+
)
173+
% N
174+
)
175+
result.append(commitment * value[1])
176+
return S256Point.combine(result)
177+
178+
def sign(self, msg, nonces_to_use):
179+
"""Sign using our secret share given the nonces we are supposed to use"""
180+
group_r = self.compute_group_r(msg, nonces_to_use)
181+
# compute the lagrange coefficient based on the participants
182+
lagrange = 1
183+
for key in sorted(nonces_to_use.keys()):
184+
value = nonces_to_use[key]
185+
if not self.nonces_available[key][value]:
186+
raise ValueError("Using an unknown or already used nonce")
187+
if key == self.index:
188+
my_commitment = (
189+
big_endian_to_int(
190+
hash_frost_commitment(
191+
msg
192+
+ encode_varint(key)
193+
+ value[0].xonly()
194+
+ value[1].xonly()
195+
)
196+
)
197+
% N
198+
)
199+
else:
200+
lagrange *= (key + 1) * pow(key - self.index, -1, N) % N
201+
# the group challenge is the normal Schnorr Signature challenge from BIP340
202+
challenge = big_endian_to_int(
203+
hash_challenge(group_r.xonly() + self.group_pubkey.xonly() + msg)
204+
)
205+
# use the two nonces to compute the k we will use
206+
my_d, my_e = self.nonces[nonces_to_use[self.index][0]]
207+
my_k = my_d + my_e * my_commitment
208+
d_pub, e_pub = my_d * G, my_e * G
209+
my_r = S256Point.combine([d_pub, my_commitment * e_pub])
210+
# if the group r is odd, we negate everything
211+
if group_r.parity:
212+
group_r = -1 * group_r
213+
my_k = N - my_k
214+
my_r = -1 * my_r
215+
sig_share = (my_k + lagrange * self.secret * challenge) % N
216+
# sanity check the s we generated
217+
second = (challenge * lagrange % N) * self.pubkey
218+
if -1 * second + sig_share != my_r:
219+
raise RuntimeError("signature didn't do what we expected")
220+
# delete nonce used
221+
for key in sorted(nonces_to_use.keys()):
222+
value = nonces_to_use[key]
223+
del self.nonces_available[key][value]
224+
return sig_share
225+
226+
def combine_shares(self, shares, msg, nonces_to_use):
227+
"""Convenience method to return a Schnorr Signature once
228+
the participants have returned their shares"""
229+
r = self.compute_group_r(msg, nonces_to_use)
230+
s = sum(shares) % N
231+
return SchnorrSignature.parse(r.xonly() + int_to_big_endian(s, 32))

buidl/pecc.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,9 @@ def __init__(self, x, y, a=None, b=None):
231231
def __eq__(self, other):
232232
return self.x == other.x and self.y == other.y
233233

234+
def __hash__(self):
235+
return hash(self.sec())
236+
234237
def __repr__(self):
235238
if self.x is None:
236239
return "S256Point(infinity)"

buidl/test/test_frost.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from itertools import combinations
2+
from unittest import TestCase
3+
4+
from buidl.frost import FrostParticipant
5+
from buidl.helper import sha256
6+
7+
8+
class FrostTest(TestCase):
9+
def test_frost(self):
10+
# create a three participant frost
11+
tests = [
12+
(1, 2),
13+
(2, 3),
14+
(3, 5),
15+
(4, 7),
16+
(5, 9),
17+
(3, 3),
18+
(4, 8),
19+
]
20+
for t, n in tests:
21+
participants = [FrostParticipant(t, n, i) for i in range(n)]
22+
round_1_data = []
23+
key_name = b"test"
24+
for p in participants:
25+
round_1_data.append(p.key_generation_round_1(key_name))
26+
for p in participants:
27+
for i in range(n):
28+
p.verify_round_1(key_name, i, *round_1_data[i])
29+
for i, p in enumerate(participants):
30+
for j, share in enumerate(p.key_generation_round_2()):
31+
participants[j].verify_round_2(i, share)
32+
for p in participants:
33+
group_pubkey = p.compute_keys()
34+
self.assertFalse(group_pubkey.parity)
35+
combos = combinations(participants, t)
36+
num_nonces = len([0 for _ in combos])
37+
nonce_pubs = []
38+
for p in participants:
39+
nonce_pubs.append(p.generate_nonce_pairs(num_nonces))
40+
for p in participants:
41+
p.register_nonce_pubs(nonce_pubs)
42+
msg = sha256(b"I am testing FROST")
43+
for ps in combinations(participants, t):
44+
nonces_to_use = {p.index: nonce_pubs[p.index].pop() for p in ps}
45+
shares = []
46+
for p in ps:
47+
shares.append(p.sign(msg, nonces_to_use))
48+
schnorr_sig = ps[0].combine_shares(shares, msg, nonces_to_use)
49+
self.assertTrue(group_pubkey.verify_schnorr(msg, schnorr_sig))

0 commit comments

Comments
 (0)