Skip to main content

confium_coordinator/
dkg_coordinator.rs

1//! DKG coordinator protocol — distributed key generation.
2//!
3//! Each party contributes randomness to generate a shared keypair
4//! without any single party knowing the full secret.
5
6use getrandom::SysRng;
7use p256::elliptic_curve::rand_core::UnwrapErr;
8use p256::elliptic_curve::{Field, PrimeField};
9use p256::{AffinePoint, FieldBytes, ProjectivePoint, Scalar};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest as _, Sha256};
12use std::collections::HashMap;
13
14/// A DKG contribution from one party.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct DkgContribution {
17    pub party_idx: u32,
18    /// Shamir share for each other party.
19    pub shares: HashMap<u32, String>,
20    /// VSS commitment (SEC1 compressed, hex).
21    pub commitment_hex: String,
22}
23
24/// DKG session state.
25#[derive(Debug)]
26pub struct DkgSession {
27    pub threshold: u32,
28    pub party_count: u32,
29    pub contributions: HashMap<u32, DkgContribution>,
30}
31
32impl DkgSession {
33    pub fn new(threshold: u32, party_count: u32) -> Self {
34        Self {
35            threshold,
36            party_count,
37            contributions: HashMap::new(),
38        }
39    }
40
41    pub fn submit(&mut self, contrib: DkgContribution) -> Result<(), String> {
42        if contrib.party_idx == 0 || contrib.party_idx > self.party_count {
43            return Err("invalid party index".into());
44        }
45        if self.contributions.contains_key(&contrib.party_idx) {
46            return Err("duplicate contribution".into());
47        }
48        self.contributions.insert(contrib.party_idx, contrib);
49        Ok(())
50    }
51
52    pub fn is_complete(&self) -> bool {
53        self.contributions.len() == self.party_count as usize
54    }
55
56    pub fn contribution_count(&self) -> usize {
57        self.contributions.len()
58    }
59
60    pub fn missing_parties(&self) -> Vec<u32> {
61        (1..=self.party_count)
62            .filter(|i| !self.contributions.contains_key(i))
63            .collect()
64    }
65}
66
67/// Generate a DKG contribution: polynomial shares for all parties.
68pub fn generate_contribution(
69    party_idx: u32,
70    threshold: u32,
71    party_count: u32,
72) -> (DkgContribution, Scalar) {
73    // Random polynomial: f(0) = secret, f(x) for x in 1..N
74    let coeffs: Vec<Scalar> = (0..threshold)
75        .map(|_| Scalar::random(&mut UnwrapErr(SysRng)))
76        .collect();
77
78    let secret = coeffs[0];
79
80    // Compute shares for each party: f(i)
81    let mut shares = HashMap::new();
82    for i in 1..=party_count {
83        let eval = eval_polynomial(&coeffs, i);
84        let bytes: [u8; 32] = eval.to_repr().into();
85        shares.insert(i, hex::encode(bytes));
86    }
87
88    // VSS commitment: g^{coeffs[0]}
89    let commitment = (ProjectivePoint::GENERATOR * secret).to_affine();
90    use p256::elliptic_curve::sec1::ToSec1Point;
91    let commitment_hex = hex::encode(commitment.to_sec1_point(true).as_bytes());
92
93    (
94        DkgContribution {
95            party_idx,
96            shares,
97            commitment_hex,
98        },
99        secret,
100    )
101}
102
103/// Compute party i's aggregate share from all contributions.
104pub fn compute_aggregate_share(session: &DkgSession, party_idx: u32) -> Result<Scalar, String> {
105    if !session.is_complete() {
106        return Err("DKG not complete".into());
107    }
108    let mut aggregate = Scalar::ZERO;
109    for contrib in session.contributions.values() {
110        let share_hex = contrib
111            .shares
112            .get(&party_idx)
113            .ok_or_else(|| format!("missing share for party {party_idx}"))?;
114        let bytes = hex::decode(share_hex).map_err(|e| e.to_string())?;
115        if bytes.len() != 32 {
116            return Err("invalid share length".into());
117        }
118        let arr: [u8; 32] = bytes.as_slice().try_into().unwrap();
119        let fb = FieldBytes::from(arr);
120        let share = Option::<Scalar>::from(Scalar::from_repr(fb))
121            .ok_or_else(|| "invalid scalar".to_string())?;
122        aggregate += share;
123    }
124    Ok(aggregate)
125}
126
127/// Compute the joint public key from all VSS commitments.
128pub fn compute_joint_public_key(session: &DkgSession) -> Result<AffinePoint, String> {
129    if !session.is_complete() {
130        return Err("DKG not complete".into());
131    }
132    use p256::elliptic_curve::sec1::FromSec1Point;
133    let mut sum = ProjectivePoint::IDENTITY;
134    for contrib in session.contributions.values() {
135        let bytes = hex::decode(&contrib.commitment_hex).map_err(|e| e.to_string())?;
136        let encoded = p256::elliptic_curve::sec1::Sec1Point::<p256::NistP256>::from_bytes(&bytes)
137            .map_err(|e| e.to_string())?;
138        let point = Option::<AffinePoint>::from(AffinePoint::from_sec1_point(&encoded))
139            .ok_or_else(|| "invalid commitment point".to_string())?;
140        sum += ProjectivePoint::from(point);
141    }
142    Ok(sum.to_affine())
143}
144
145fn eval_polynomial(coeffs: &[Scalar], x: u32) -> Scalar {
146    let x_scalar = u32_to_scalar(x);
147    let mut result = Scalar::ZERO;
148    let mut x_pow = Scalar::ONE;
149    for c in coeffs {
150        result += c * &x_pow;
151        x_pow *= x_scalar;
152    }
153    result
154}
155
156/// Reduce 32 bytes to a scalar by rejection sampling with re-hash;
157/// never falls back to a constant.
158fn reduce_to_scalar(mut bytes: [u8; 32]) -> Scalar {
159    loop {
160        if let Some(s) = Option::<Scalar>::from(Scalar::from_repr(FieldBytes::from(bytes))) {
161            return s;
162        }
163        let mut h = Sha256::new();
164        h.update(b"confium-scalar-reduce-v1");
165        h.update(bytes);
166        bytes = h.finalize().into();
167    }
168}
169
170fn u32_to_scalar(v: u32) -> Scalar {
171    // Garbage-in-garbage-out on zero input; protocol callers pass
172    // non-zero scalars (sweep ledger: SEC-audit-notes).
173    let mut arr = [0u8; 32];
174    arr[28..32].copy_from_slice(&v.to_be_bytes());
175    let fb = FieldBytes::from(arr);
176    Option::<Scalar>::from(Scalar::from_repr(fb)).unwrap_or(Scalar::ZERO)
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn dkg_session_starts_empty() {
185        let session = DkgSession::new(2, 3);
186        assert_eq!(session.contribution_count(), 0);
187        assert!(!session.is_complete());
188    }
189
190    #[test]
191    fn submit_contribution() {
192        let mut session = DkgSession::new(2, 3);
193        let (contrib, _) = generate_contribution(1, 2, 3);
194        session.submit(contrib).unwrap();
195        assert_eq!(session.contribution_count(), 1);
196    }
197
198    #[test]
199    fn complete_when_all_contributed() {
200        let mut session = DkgSession::new(2, 3);
201        for i in 1..=3 {
202            let (contrib, _) = generate_contribution(i, 2, 3);
203            session.submit(contrib).unwrap();
204        }
205        assert!(session.is_complete());
206    }
207
208    #[test]
209    fn duplicate_contribution_rejected() {
210        let mut session = DkgSession::new(2, 3);
211        let (c1, _) = generate_contribution(1, 2, 3);
212        session.submit(c1).unwrap();
213        let (c2, _) = generate_contribution(1, 2, 3);
214        assert!(session.submit(c2).is_err());
215    }
216
217    #[test]
218    fn aggregate_share_computed() {
219        let mut session = DkgSession::new(2, 3);
220        for i in 1..=3 {
221            let (contrib, _) = generate_contribution(i, 2, 3);
222            session.submit(contrib).unwrap();
223        }
224        let share = compute_aggregate_share(&session, 1).unwrap();
225        assert!(share != Scalar::ZERO);
226    }
227
228    #[test]
229    fn joint_public_key_computed() {
230        let mut session = DkgSession::new(2, 3);
231        for i in 1..=3 {
232            let (contrib, _) = generate_contribution(i, 2, 3);
233            session.submit(contrib).unwrap();
234        }
235        let pk = compute_joint_public_key(&session).unwrap();
236        // Just verify it's not identity
237        assert!(pk != AffinePoint::IDENTITY);
238    }
239
240    #[test]
241    fn missing_parties_listed() {
242        let mut session = DkgSession::new(2, 5);
243        let (c1, _) = generate_contribution(1, 2, 5);
244        session.submit(c1).unwrap();
245        let (c3, _) = generate_contribution(3, 2, 5);
246        session.submit(c3).unwrap();
247        assert_eq!(session.missing_parties(), vec![2, 4, 5]);
248    }
249
250    #[test]
251    fn contribution_has_shares_for_all_parties() {
252        let (contrib, _) = generate_contribution(1, 2, 3);
253        assert_eq!(contrib.shares.len(), 3);
254        assert!(contrib.shares.contains_key(&1));
255        assert!(contrib.shares.contains_key(&2));
256        assert!(contrib.shares.contains_key(&3));
257    }
258}