Skip to content

Commit af48ba1

Browse files
authored
[❄] Add SharedKey::grind_fingerprint (#230)
So we can do fingerprint grinding outside of chilldkg module.
1 parent 845b8f8 commit af48ba1

File tree

3 files changed

+163
-93
lines changed

3 files changed

+163
-93
lines changed

schnorr_fun/src/frost/chilldkg/encpedpop.rs

Lines changed: 31 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -216,82 +216,45 @@ impl AggKeygenInput {
216216
.ok_or("the shared secret was zero")
217217
}
218218

219-
/// Grinds all polynomial coefficients to achieve a fingerprint by rejection sampling.
220-
/// This is meant to be run by the coordinator.
219+
/// Embeds a proof-of-work `fingerprint` into the aggregated polynomial.
221220
///
222-
/// This method modifies each non-constant coefficient of the polynomial through group
223-
/// addition until the running hash (computed by sequentially incorporating coefficients)
224-
/// has the required number of leading zero bits at each step.
221+
/// This coordinator-only operation modifies the DKG output to include
222+
/// a verifiable proof of work by grinding the polynomial coefficients.
223+
/// The `fingerprint` specifies the required difficulty (number of leading
224+
/// zero bits) and an optional tag to include in the hash.
225225
///
226-
/// The fingerprint is tied to the specific public key by including it in the hash.
226+
/// The process:
227+
/// 1. Grinds the shared key's polynomial to achieve the fingerprint
228+
/// 2. Updates the aggregated polynomial with the ground coefficients
229+
/// 3. Homomorphically applies the same tweaks to all encrypted shares
227230
///
228-
/// ## Parameters
229-
/// - `fingerprint`: The fingerprint configuration specifying difficulty and tag
230-
///
231-
/// ## Security
232-
/// This modification does not affect the security of the DKG as it only modifies
233-
/// non-constant polynomial coefficients which are malleable by the coordinator.
231+
/// This modification preserves the security of the DKG because:
232+
/// - The shared secret (constant term) remains unchanged
233+
/// - Only non-constant coefficients are modified, which are already
234+
/// malleable by the coordinator
235+
/// - The homomorphic property ensures all shares remain consistent
236+
/// - Participants can verify the fingerprint matches the claimed difficulty
234237
pub fn grind_fingerprint<H: Hash32>(&mut self, fingerprint: Fingerprint) {
235238
if self.inner.agg_poly.is_empty() {
236239
return;
237240
}
238241

239-
// Get the public key (first coefficient) by aggregating key contributions
240-
let public_key = self
241-
.inner
242-
.key_contrib
243-
.iter()
244-
.fold(Point::zero(), |agg, (point, _)| g!(agg + point))
245-
.normalize();
246-
247-
// Start with hash including the tag with length prefix
248-
let mut hash_state = H::default();
249-
if !fingerprint.tag.is_empty() {
250-
hash_state = hash_state.add([fingerprint.tag.len() as u8]);
251-
hash_state = hash_state.add(fingerprint.tag.as_bytes());
252-
}
253-
hash_state = hash_state.add(public_key);
254-
255-
let mut tweaks = Vec::with_capacity(self.inner.agg_poly.len());
256-
257-
// Grind each coefficient in sequence, note that agg_poly only
258-
// contains the non-constant coefficients.
259-
for coeff_index in 0..self.inner.agg_poly.len() {
260-
let mut total_tweak = Scalar::<Public, Zero>::zero();
261-
let original_coeff = self.inner.agg_poly[coeff_index];
262-
let mut current_coeff = original_coeff;
263-
264-
loop {
265-
// Clone the hash state from previous coefficients and add current one
266-
let hash = hash_state.clone().add(current_coeff);
267-
// Check if hash has required number of leading zero bits
268-
let hash_bytes: [u8; 32] = hash.clone().finalize_fixed().into();
269-
if Fingerprint::leading_zero_bits(&hash_bytes[..])
270-
>= fingerprint.bit_length as usize
271-
{
272-
// Update hash_state for next coefficient because the next coefficient hash
273-
// includes the current one.
274-
hash_state = hash;
275-
break;
276-
}
277-
278-
// Add one more G to the coefficient
279-
total_tweak += s!(1);
280-
current_coeff = g!(current_coeff + G).normalize();
281-
}
282-
283-
// Apply the final tweak to the polynomial coefficient
284-
self.inner.agg_poly[coeff_index] = current_coeff;
285-
tweaks.push(total_tweak);
286-
}
287-
288-
// Apply all tweaks to encrypted shares at once
289-
for (share_index, (_enc_key, encrypted_share)) in &mut self.encrypted_shares {
290-
let mut power = *share_index;
291-
for tweak in &tweaks {
292-
*encrypted_share += s!(tweak * power).public();
293-
power *= share_index;
294-
}
242+
let mut shared_key = self.shared_key();
243+
let tweak_poly = shared_key.grind_fingerprint::<H>(fingerprint);
244+
// replace our poly with the one that has the fingerprint
245+
self.inner.agg_poly = shared_key.point_polynomial()[1..].to_vec();
246+
debug_assert!(self.shared_key().check_fingerprint::<H>(&fingerprint));
247+
248+
for (share_index, (_encryption_key, encrypted_secret_share)) in &mut self.encrypted_shares {
249+
// 💡 The share encryption is homomorphic so we can apply the tweak
250+
// operations the same way as if the coordinator had a local copy of
251+
// the the unencrypted secret share.
252+
let mut tmp = SecretShare {
253+
index: *share_index,
254+
share: *encrypted_secret_share,
255+
};
256+
tmp.homomorphic_poly_add(&tweak_poly);
257+
*encrypted_secret_share = tmp.share;
295258
}
296259
}
297260
}

schnorr_fun/src/frost/share.rs

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,12 @@ use secp256kfun::{poly, prelude::*};
5050
/// [`bech32m`]: https://bips.xyz/350
5151
5252
#[derive(Copy, Clone, PartialEq, Eq)]
53-
pub struct SecretShare {
53+
pub struct SecretShare<S = Secret> {
5454
/// The scalar index for this secret share, usually this is a small number but it can take any
5555
/// value (other than 0).
5656
pub index: ShareIndex,
5757
/// The secret scalar which is the output of the polynomial evaluated at `index`
58-
pub share: Scalar<Secret, Zero>,
58+
pub share: Scalar<S, Zero>,
5959
}
6060

6161
impl SecretShare {
@@ -68,7 +68,9 @@ impl SecretShare {
6868

6969
poly::scalar::interpolate_and_eval_poly_at_0(&index_and_secret[..])
7070
}
71+
}
7172

73+
impl<S: Secrecy> SecretShare<S> {
7274
/// Encodes the secret share to 64 bytes. The first 32 is the index and the second 32 is the
7375
/// secret.
7476
pub fn to_bytes(&self) -> [u8; 64] {
@@ -94,10 +96,26 @@ impl SecretShare {
9496
image: g!(self.share * G).normalize(),
9597
}
9698
}
99+
100+
/// Homomorphically adds a scalar polynomial to this secret share.
101+
///
102+
/// Given a scalar polynomial, evaluates it at this share's index and adds
103+
/// the result to the share value. This operation preserves the polynomial
104+
/// relationship between shares due to the homomorphic properties of
105+
/// Shamir's secret sharing.
106+
///
107+
/// Afterwards the share so that it's no longer valid with original
108+
/// [`SharedKey`] polynomial but is valid instead for the sum of the
109+
/// original polynomial and the new one.
110+
///
111+
/// [`Sharedkey`]: crate::frost::SharedKey
112+
pub fn homomorphic_poly_add(&mut self, poly: &[Scalar<Public, Zero>]) {
113+
self.share += poly::scalar::eval(poly, self.index);
114+
}
97115
}
98116

99117
secp256kfun::impl_fromstr_deserialize! {
100-
name => "secp256k1 FROST share",
118+
name => "secp256k1 FROST secret share",
101119
fn from_bytes(bytes: [u8;64]) -> Option<SecretShare> {
102120
SecretShare::from_bytes(bytes)
103121
}
@@ -234,6 +252,27 @@ impl<Z: ZeroChoice, T: PointType> PairedSecretShare<T, Z> {
234252
}
235253
}
236254

255+
/// Homomorphically adds a scalar polynomial to this paired secret share.
256+
///
257+
/// See [`SecretShare::homomorphic_poly_add`] for more info.
258+
#[must_use]
259+
pub fn homomorphic_poly_add(
260+
mut self,
261+
poly: &[Scalar<Public, Zero>],
262+
) -> PairedSecretShare<Normal, Zero> {
263+
// Apply the polynomial to the secret share
264+
self.secret_share.homomorphic_poly_add(poly);
265+
let pk_tweak = poly.first().copied().unwrap_or(Scalar::zero());
266+
267+
// Update the public key by the constant term
268+
let public_key = g!(self.public_key + pk_tweak * G).normalize();
269+
270+
PairedSecretShare {
271+
secret_share: self.secret_share,
272+
public_key,
273+
}
274+
}
275+
237276
/// Converts a `PairedSecretShare<T, Zero>` to a `PairedSecretShare<T, NonZero>`.
238277
///
239278
/// If the paired shared key *was* actually zero ([`is_zero`] returns true) it returns `None`.

schnorr_fun/src/frost/shared_key.rs

Lines changed: 90 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use super::{PairedSecretShare, SecretShare, ShareImage, ShareIndex, VerificationShare};
22
use alloc::vec::Vec;
33
use core::{marker::PhantomData, ops::Deref};
4-
use secp256kfun::{poly, prelude::*};
4+
use secp256kfun::{hash::Hash32, poly, prelude::*};
55

66
/// A polynomial where the first coefficient (constant term) is the image of a secret `Scalar` that
77
/// has been shared in a [Shamir's secret sharing] structure.
@@ -21,7 +21,7 @@ pub struct SharedKey<T = Normal, Z = NonZero> {
2121
ty: PhantomData<(T, Z)>,
2222
}
2323

24-
impl<T: PointType, Z: ZeroChoice> SharedKey<T, Z> {
24+
impl<T: Normalized, Z: ZeroChoice> SharedKey<T, Z> {
2525
/// "pair" a secret share that belongs to this shared key so you can keep track of tweaks to the
2626
/// public key and the secret share together.
2727
///
@@ -186,35 +186,32 @@ impl<T: PointType, Z: ZeroChoice> SharedKey<T, Z> {
186186
}
187187
}
188188

189-
/// Checks if the polynomial coefficients contain a fingerprint by verifying that
190-
/// each coefficient (when hashed with all previous coefficients) has the required
191-
/// number of leading zero bits.
189+
/// Checks if the polynomial coefficients contain the specified `fingerprint`.
192190
///
193-
/// This is useful for detecting if shares come from the same DKG session or if
194-
/// they have been corrupted.
191+
/// Verifies that each non-constant coefficient, when hashed together with all
192+
/// previous coefficients, produces a hash with at least `fingerprint.bit_length`
193+
/// leading zero bits. This allows detection of shares from the same DKG session
194+
/// and helps identify corrupted or mismatched shares.
195195
///
196-
/// ## Parameters
197-
/// - `fingerprint`: The expected fingerprint configuration
198-
///
199-
/// ## Returns
200-
/// `true` if all coefficients match the fingerprint pattern, `false` otherwise
196+
/// Returns `true` if all coefficients match the fingerprint pattern, `false`
197+
/// if any coefficient fails to meet the difficulty requirement.
201198
pub fn check_fingerprint<H: crate::fun::hash::Hash32>(
202199
&self,
203200
fingerprint: &Fingerprint,
204201
) -> bool {
205202
use crate::fun::hash::HashAdd;
206203

207-
if self.point_polynomial.is_empty() {
204+
// the fingerprint is only placed on the non-constant coefficients so it
205+
// can't be detected with a length 1 polynomial
206+
if self.point_polynomial.len() <= 1 {
208207
return true;
209208
}
210209

211-
// Start with hash including the tag with length prefix
212-
let mut hash_state = H::default();
213-
if !fingerprint.tag.is_empty() {
214-
hash_state = hash_state.add([fingerprint.tag.len() as u8]);
215-
hash_state = hash_state.add(fingerprint.tag.as_bytes());
216-
}
217-
hash_state = hash_state.add(self.point_polynomial[0]);
210+
let mut hash_state = H::default()
211+
.add([fingerprint.tag.len() as u8])
212+
.add(fingerprint.tag.as_bytes())
213+
// the public key is unmolested by the fingerprint
214+
.add(self.point_polynomial[0]);
218215

219216
// Check each non-constant coefficient
220217
for i in 1..self.point_polynomial.len() {
@@ -231,6 +228,72 @@ impl<T: PointType, Z: ZeroChoice> SharedKey<T, Z> {
231228

232229
true
233230
}
231+
232+
/// Grinds polynomial coefficients to embed the specified `fingerprint` through
233+
/// proof-of-work.
234+
///
235+
/// For each non-constant coefficient, repeatedly adds the generator point `G`
236+
/// until the cumulative hash (including the tag, public key, and all previous
237+
/// coefficients) has at least `fingerprint.bit_length` leading zero bits.
238+
/// This process modifies the polynomial while preserving the shared secret.
239+
///
240+
/// Returns a scalar polynomial where the constant term is always zero
241+
/// and the remaining coefficients indicate how many times `G` was added
242+
/// to each polynomial coefficient. This polynomial can be added to secret
243+
/// shares using [`SecretShare::homomorphic_poly_add`] to maintain consistency.
244+
///
245+
/// The computational cost increases exponentially with `bit_length`: each
246+
/// additional bit doubles the expected work required.
247+
pub fn grind_fingerprint<H: Hash32>(
248+
&mut self,
249+
fingerprint: Fingerprint,
250+
) -> Vec<Scalar<Public, Zero>> {
251+
let mut tweaks = Vec::with_capacity(self.threshold());
252+
// We don't mutate the first coefficient
253+
tweaks.push(Scalar::<Public, _>::zero());
254+
255+
if self.point_polynomial.len() <= 1 {
256+
// only mutate non-constant terms
257+
return tweaks;
258+
}
259+
260+
use secp256kfun::hash::HashAdd;
261+
let mut hash_state = H::default()
262+
.add([fingerprint.tag.len() as u8])
263+
.add(fingerprint.tag.as_bytes())
264+
// the public key is unmolested from the fingerprint grinding
265+
.add(self.point_polynomial[0]);
266+
267+
for coeff in &mut self.point_polynomial[1..] {
268+
let mut total_tweak = Scalar::<Public, Zero>::zero();
269+
let mut current_coeff = *coeff;
270+
271+
loop {
272+
// Clone the hash state from previous coefficients and add current one
273+
let hash = hash_state.clone().add(current_coeff);
274+
// Check if hash has required number of leading zero bits
275+
let hash_bytes: [u8; 32] = hash.clone().finalize_fixed().into();
276+
if Fingerprint::leading_zero_bits(&hash_bytes[..])
277+
>= fingerprint.bit_length as usize
278+
{
279+
// Update hash_state for next coefficient because the next coefficient hash
280+
// includes the current one.
281+
hash_state = hash;
282+
break;
283+
}
284+
285+
// Add one more G to the coefficient
286+
total_tweak += s!(1);
287+
current_coeff = g!(current_coeff + G).normalize();
288+
}
289+
290+
// Apply the final tweak to the polynomial coefficient
291+
*coeff = current_coeff;
292+
tweaks.push(total_tweak);
293+
}
294+
295+
tweaks
296+
}
234297
}
235298

236299
impl SharedKey {
@@ -376,8 +439,13 @@ bincode::impl_borrow_decode!(SharedKey<EvenY, NonZero>);
376439

377440
/// Configuration for polynomial fingerprinting in DKG protocols.
378441
///
379-
/// This allows coordinators to embed identifying information into polynomial coefficients
380-
/// to help detect when shares from different DKG sessions are mixed.
442+
/// A `Fingerprint` allows coordinators to embed proof-of-work into polynomial
443+
/// coefficients during distributed key generation. This helps detect when shares
444+
/// from different DKG sessions are accidentally mixed and provides resistance
445+
/// against resource exhaustion attacks.
446+
///
447+
/// The fingerprint is computed by hashing the public key with each subsequent
448+
/// coefficient, requiring each hash to have a minimum number of leading zero bits.
381449
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
382450
pub struct Fingerprint {
383451
/// Number of leading zero bits required in the hash

0 commit comments

Comments
 (0)