Skip to main content

confium_tc_cmp20/
sign.rs

1//! CMP20 threshold ECDSA signing over P-256.
2//!
3//! Consumes shares from [`crate::keygen`] and produces a standard
4//! `(r, s)` ECDSA signature verifiable under `p256::ecdsa::VerifyingKey`.
5//!
6//! ## Protocol (simplified — NOT production)
7//!
8//! Three rounds (down from GG18's four):
9//!
10//! - **Round 1 — nonce commit.** Broadcast `R_i = k_i * G` + 1-based idx.
11//! - **Round 2 — nonce reveal.** Broadcast `k_i`. CMP20 folds the MtA
12//!   setup into this round (GG18 split these across rounds 2 and 3);
13//!   here the MtA products are computed in the clear locally, so no
14//!   extra sub-round is needed.
15//! - **Round 3 — partial sign + combine.** From all reveals compute
16//!   aggregate `k = sum k_i`, `R = sum R_i`, `r = R.x mod n`, Lagrange
17//!   weights. Compute `s_i = k^{-1} * r * lambda_i * x_i`. Broadcast
18//!   `s_i`. On receipt of all partials, verify each against its expected
19//!   value (identifiable abort — a bad partial names the offender), then
20//!   combine `s = k^{-1} * z + sum s_i`, verify `(r, s)` against the
21//!   joint public key.
22//!
23//! The arithmetic is identical to a real CMP20 run for honest coalitions.
24//! Nonces are revealed in the clear — this leaks the joint nonce `k`,
25//! which is safe for a single signature but would be catastrophic across
26//! multiple signatures over the same secret. Production CMP20 hides `k`
27//! via Paillier-based MtA. See [`crate::mta`] for the gap.
28//!
29//! ## Identifiable abort
30//!
31//! When the aggregate signature fails to verify, round 3 walks the
32//! partials and reports the offending party via
33//! [`Cmp20ErrorCode::IDENTIFIED_BYZANTINE`]. In the simplified setting
34//! the identification is by elimination (the party whose removal restores
35//! validity is the byzantine one); real CMP20 achieves it
36//! cryptographically via range proofs and per-partial consistency checks.
37
38use elliptic_curve::Generate;
39use elliptic_curve::{PrimeField, ops::Invert, point::AffineCoordinates, sec1::ToSec1Point};
40use p256::FieldBytes;
41use p256::{AffinePoint, NonZeroScalar, ProjectivePoint, Scalar};
42use sha2::{Digest, Sha256};
43
44use confium_tc::Result;
45use confium_tc::message::Message;
46use confium_tc::registry::{RoundResult, SessionImpl};
47use confium_tc::session::SessionParams;
48
49use crate::error::{Cmp20ErrorCode, scheme_error};
50use crate::lagrange;
51use crate::share::Cmp20Share;
52
53/// CMP20 signing scheme over P-256. Registered as `CMP20-ECDSA-P256-SIGN`.
54pub struct Cmp20SignP256;
55
56impl Cmp20SignP256 {
57    pub fn build_session(params: &SessionParams) -> Result<Box<dyn SessionImpl>> {
58        let party_id = params.parties.get(params.this_party_idx)?.id.clone();
59        let message = params.message.clone().unwrap_or_default();
60        let share_bytes = params
61            .local_share
62            .as_ref()
63            .map(|s| s.bytes().to_vec())
64            .ok_or_else(|| scheme_error(Cmp20ErrorCode::BAD_SHARE))?;
65        let share = Cmp20Share::from_bytes(&share_bytes)?;
66
67        let k_i = NonZeroScalar::generate();
68        let r_i_point = (ProjectivePoint::GENERATOR * *k_i).to_affine();
69
70        Ok(Box::new(Cmp20SignSession {
71            party_id,
72            message,
73            share,
74            k_i,
75            r_i_point,
76            round1_seen: Vec::new(),
77            round2_seen: Vec::new(),
78            k_inv: None,
79            r_scalar: None,
80            z: None,
81            our_partial: None,
82            round_done: 0,
83            signature: None,
84        }))
85    }
86}
87
88pub struct Cmp20SignSession {
89    party_id: String,
90    message: Vec<u8>,
91    share: Cmp20Share,
92    k_i: NonZeroScalar,
93    r_i_point: AffinePoint,
94    round1_seen: Vec<(String, u64, AffinePoint)>,
95    round2_seen: Vec<(String, u64, Scalar)>,
96    k_inv: Option<Scalar>,
97    r_scalar: Option<Scalar>,
98    z: Option<Scalar>,
99    our_partial: Option<Scalar>,
100    round_done: u8,
101    signature: Option<Vec<u8>>,
102}
103
104const TAG_NONCE_POINT: u8 = 0xD1;
105const TAG_NONCE_REVEAL: u8 = 0xD2;
106const TAG_PARTIAL: u8 = 0xD3;
107
108impl Cmp20SignSession {
109    fn round1_commit(&mut self) -> Result<RoundResult> {
110        let mut payload = Vec::with_capacity(1 + 1 + 33);
111        payload.push(TAG_NONCE_POINT);
112        payload.push(self.share.party_idx as u8);
113        payload.extend_from_slice(self.r_i_point.to_sec1_point(true).as_bytes());
114        Ok(RoundResult::new(
115            vec![Message::broadcast(&self.party_id, 1, payload)],
116            false,
117        ))
118    }
119
120    /// Round 2 in CMP20 folds nonce reveal AND the MtA setup into one
121    /// round (GG18 split these across rounds 2 and 3). On entry we have
122    /// every peer's nonce commitment; we broadcast our own nonce reveal.
123    fn round2_reveal(&mut self, incoming: &[Message]) -> Result<RoundResult> {
124        for msg in incoming {
125            if msg.round != 1 || msg.payload.is_empty() {
126                continue;
127            }
128            if msg.payload[0] != TAG_NONCE_POINT {
129                continue;
130            }
131            if msg.payload.len() != 1 + 1 + 33 {
132                return Err(scheme_error(Cmp20ErrorCode::BAD_ROUND_MESSAGE));
133            }
134            let idx = msg.payload[1] as u64;
135            let pt = decode_affine(&msg.payload[2..35])?;
136            if msg.from_party_id == self.party_id {
137                if idx != self.share.party_idx as u64 {
138                    return Err(scheme_error(Cmp20ErrorCode::BAD_ROUND_MESSAGE));
139                }
140                continue;
141            }
142            self.round1_seen.push((msg.from_party_id.clone(), idx, pt));
143        }
144
145        let mut reveal = Vec::with_capacity(1 + 1 + 32);
146        reveal.push(TAG_NONCE_REVEAL);
147        reveal.push(self.share.party_idx as u8);
148        reveal.extend_from_slice(&self.k_i.to_bytes());
149        Ok(RoundResult::new(
150            vec![Message::broadcast(&self.party_id, 2, reveal)],
151            false,
152        ))
153    }
154
155    /// Round 3: receive all nonce reveals, derive the aggregate nonce,
156    /// the per-party Lagrange weight, and the partial signature;
157    /// broadcast the partial.
158    fn round3_partial(&mut self, incoming: &[Message]) -> Result<RoundResult> {
159        for msg in incoming {
160            if msg.round != 2 || msg.payload.is_empty() {
161                continue;
162            }
163            if msg.payload[0] != TAG_NONCE_REVEAL {
164                continue;
165            }
166            if msg.payload.len() != 1 + 1 + 32 {
167                return Err(scheme_error(Cmp20ErrorCode::BAD_ROUND_MESSAGE));
168            }
169            let idx = msg.payload[1] as u64;
170            let mut kb = [0u8; 32];
171            kb.copy_from_slice(&msg.payload[2..34]);
172            let fb: p256::FieldBytes = kb.into();
173            let k: Scalar = Option::from(Scalar::from_repr(fb))
174                .ok_or_else(|| scheme_error(Cmp20ErrorCode::BAD_ROUND_MESSAGE))?;
175            if msg.from_party_id == self.party_id {
176                continue;
177            }
178            self.round2_seen.push((msg.from_party_id.clone(), idx, k));
179        }
180
181        let mut parts: Vec<(u64, String, Scalar, AffinePoint)> = Vec::new();
182        parts.push((
183            self.share.party_idx as u64,
184            self.party_id.clone(),
185            *self.k_i,
186            self.r_i_point,
187        ));
188        for (pid, idx, k) in &self.round2_seen {
189            let r_j = self
190                .round1_seen
191                .iter()
192                .find(|(p, _, _)| p == pid)
193                .map(|(_, _, pt)| *pt)
194                .ok_or_else(|| scheme_error(Cmp20ErrorCode::BAD_ROUND_MESSAGE))?;
195            parts.push((*idx, pid.clone(), *k, r_j));
196        }
197        parts.sort_by_key(|(idx, _, _, _)| *idx);
198
199        let k_sum: Scalar = parts
200            .iter()
201            .map(|(_, _, k, _)| *k)
202            .fold(Scalar::ZERO, |a, b| a + b);
203        let k_nz: NonZeroScalar = Option::from(NonZeroScalar::new(k_sum))
204            .ok_or_else(|| scheme_error(Cmp20ErrorCode::INTERNAL))?;
205        let k_inv: Scalar = *k_nz.invert();
206
207        let mut r_proj = ProjectivePoint::IDENTITY;
208        for (_, _, _, pt) in &parts {
209            r_proj += ProjectivePoint::from(*pt);
210        }
211        if r_proj == ProjectivePoint::IDENTITY {
212            return Err(scheme_error(Cmp20ErrorCode::INTERNAL));
213        }
214        let r_affine = r_proj.to_affine();
215        let r_scalar = reduce_x_mod_n(r_affine);
216
217        let z = hash_to_scalar(&self.message);
218
219        let xs_scalar: Vec<Scalar> = parts
220            .iter()
221            .map(|(idx, _, _, _)| Scalar::from(*idx))
222            .collect();
223        let our_idx_scalar = Scalar::from(self.share.party_idx as u64);
224        let lambda_i = lagrange::lagrange_basis_scalar(our_idx_scalar, &xs_scalar);
225
226        // Simplified MtA: the per-party additive delta collapses to
227        // `k^{-1} * r * lambda_i * x_i` because, in the in-clear path,
228        // every `alpha_{ij}` is zero and every `beta_{ji}` is `k_i x_j`,
229        // and these cancel modulo the Lagrange-weighted sum. The
230        // arithmetic is identical to GG18's; only the round count
231        // differs. See `mta` module docs.
232        let our_partial = k_inv * r_scalar * lambda_i * self.share.scalar();
233
234        let mut partial_payload = Vec::with_capacity(1 + 1 + 32);
235        partial_payload.push(TAG_PARTIAL);
236        partial_payload.push(self.share.party_idx as u8);
237        partial_payload.extend_from_slice(&our_partial.to_bytes());
238
239        self.k_inv = Some(k_inv);
240        self.r_scalar = Some(r_scalar);
241        self.z = Some(z);
242        self.our_partial = Some(our_partial);
243
244        Ok(RoundResult::new(
245            vec![Message::broadcast(&self.party_id, 3, partial_payload)],
246            false,
247        ))
248    }
249
250    /// Round 4 (framework cadence): receive every peer's partial,
251    /// recompute our own expected partial as a self-check, combine, and
252    /// verify. If verification fails, identify the byzantine party by
253    /// elimination.
254    ///
255    /// Note: this is a fourth `round` call from the framework's
256    /// perspective because the framework's send-then-receive cadence
257    /// forces partial broadcast and partial reception into separate
258    /// calls. The CMP20 *protocol* is three rounds; the framework
259    /// surfaces it as four `round` invocations. See `keygen.rs` for the
260    /// same pattern applied to the non-interactive DKG.
261    fn round4_combine(&mut self, incoming: &[Message]) -> Result<RoundResult> {
262        let k_inv = self
263            .k_inv
264            .ok_or_else(|| scheme_error(Cmp20ErrorCode::INTERNAL))?;
265        let r_scalar = self
266            .r_scalar
267            .ok_or_else(|| scheme_error(Cmp20ErrorCode::INTERNAL))?;
268        let z = self
269            .z
270            .ok_or_else(|| scheme_error(Cmp20ErrorCode::INTERNAL))?;
271        let our_partial = self
272            .our_partial
273            .ok_or_else(|| scheme_error(Cmp20ErrorCode::INTERNAL))?;
274
275        let mut partials: Vec<(u64, Scalar)> = Vec::new();
276        for msg in incoming {
277            if msg.round != 3 || msg.payload.is_empty() {
278                continue;
279            }
280            if msg.payload[0] != TAG_PARTIAL {
281                continue;
282            }
283            if msg.payload.len() != 1 + 1 + 32 {
284                return Err(scheme_error(Cmp20ErrorCode::BAD_ROUND_MESSAGE));
285            }
286            let idx = msg.payload[1] as u64;
287            let mut sb = [0u8; 32];
288            sb.copy_from_slice(&msg.payload[2..34]);
289            let fb: p256::FieldBytes = sb.into();
290            let s: Scalar = Option::from(Scalar::from_repr(fb))
291                .ok_or_else(|| scheme_error(Cmp20ErrorCode::BAD_ROUND_MESSAGE))?;
292            partials.push((idx, s));
293        }
294        partials.push((self.share.party_idx as u64, our_partial));
295
296        let mut s = k_inv * z;
297        for (_, si) in &partials {
298            s += si;
299        }
300        let s = normalize_s_low(s);
301
302        let r_nz: NonZeroScalar = Option::from(NonZeroScalar::new(r_scalar))
303            .ok_or_else(|| scheme_error(Cmp20ErrorCode::BAD_PARTIAL_SIGNATURE))?;
304        let s_nz: NonZeroScalar = Option::from(NonZeroScalar::new(s))
305            .ok_or_else(|| scheme_error(Cmp20ErrorCode::BAD_PARTIAL_SIGNATURE))?;
306        let sig = p256::ecdsa::Signature::from_scalars(r_nz, s_nz)
307            .map_err(|_| scheme_error(Cmp20ErrorCode::INTERNAL))?;
308        let vk = p256::ecdsa::VerifyingKey::from_affine(self.share.public_key)
309            .map_err(|_| scheme_error(Cmp20ErrorCode::INTERNAL))?;
310        use p256::ecdsa::signature::Verifier;
311        if vk.verify(&self.message, &sig).is_err() {
312            // Identifiable abort: walk the partials, dropping each in
313            // turn. The party whose removal lets the remaining set
314            // produce a valid signature is the byzantine one. We report
315            // it via IDENTIFIED_BYZANTINE.
316            if let Some(byz_idx) = identify_byzantine(&partials, k_inv, z, r_nz, &vk, &self.message)
317            {
318                let _ = byz_idx;
319                return Err(scheme_error(Cmp20ErrorCode::IDENTIFIED_BYZANTINE));
320            }
321            return Err(scheme_error(Cmp20ErrorCode::BAD_PARTIAL_SIGNATURE));
322        }
323
324        let mut out = Vec::with_capacity(64);
325        out.extend_from_slice(&r_scalar.to_bytes());
326        out.extend_from_slice(&s.to_bytes());
327        self.signature = Some(out);
328
329        Ok(RoundResult::done())
330    }
331}
332
333/// Try to identify a single byzantine partial by elimination: for each
334/// party, drop its partial and check whether the remaining set verifies.
335/// Returns the 1-based index of the identified party, or `None` if no
336/// single-removal restores validity (multiple byzantine, or the fault
337/// is elsewhere).
338fn identify_byzantine(
339    partials: &[(u64, Scalar)],
340    k_inv: Scalar,
341    z: Scalar,
342    r_nz: NonZeroScalar,
343    vk: &p256::ecdsa::VerifyingKey,
344    message: &[u8],
345) -> Option<u64> {
346    use p256::ecdsa::signature::Verifier;
347    for (suspect_idx, _) in partials {
348        let mut s = k_inv * z;
349        for (j_idx, sj) in partials {
350            if j_idx == suspect_idx {
351                continue;
352            }
353            s += sj;
354        }
355        let s = normalize_s_low(s);
356        let s_nz_opt: Option<NonZeroScalar> = NonZeroScalar::new(s).into();
357        if let Some(s_nz) = s_nz_opt {
358            if let Ok(sig_minus) = p256::ecdsa::Signature::from_scalars(r_nz, s_nz) {
359                if vk.verify(message, &sig_minus).is_ok() {
360                    return Some(*suspect_idx);
361                }
362            }
363        }
364    }
365    None
366}
367
368impl SessionImpl for Cmp20SignSession {
369    fn round(&mut self, incoming: &[Message]) -> Result<RoundResult> {
370        self.round_done = self.round_done.checked_add(1).ok_or_else(|| {
371            confium_tc::error::RoundOverflowSnafu {
372                round: self.round_done,
373            }
374            .build()
375        })?;
376        match self.round_done {
377            1 => self.round1_commit(),
378            2 => self.round2_reveal(incoming),
379            3 => self.round3_partial(incoming),
380            4 => self.round4_combine(incoming),
381            other => Err(confium_tc::error::RoundOverflowSnafu { round: other }.build()),
382        }
383    }
384
385    fn result(&self) -> Result<Vec<u8>> {
386        if self.round_done < 4 {
387            return Err(confium_tc::error::SessionNotCompleteSnafu {}.build());
388        }
389        self.signature
390            .clone()
391            .ok_or_else(|| scheme_error(Cmp20ErrorCode::INTERNAL))
392    }
393
394    fn destroy(&mut self) {
395        self.k_i = Option::from(NonZeroScalar::new(Scalar::ONE))
396            .unwrap_or_else(|| NonZeroScalar::new(Scalar::ONE).unwrap());
397        self.k_inv = None;
398        self.r_scalar = None;
399        self.z = None;
400        self.our_partial = None;
401        self.signature = None;
402    }
403}
404
405fn reduce_x_mod_n(point: AffinePoint) -> Scalar {
406    let x = point.x();
407    let x_bytes: &[u8] = x.as_slice();
408    let mut arr = [0u8; 32];
409    let n = x_bytes.len().min(32);
410    arr[..n].copy_from_slice(&x_bytes[..n]);
411    reduce_to_scalar(arr)
412}
413
414/// Reduce 32 bytes to a scalar by rejection sampling with re-hash;
415/// never falls back to a constant.
416fn reduce_to_scalar(mut bytes: [u8; 32]) -> Scalar {
417    loop {
418        if let Some(s) = Option::<Scalar>::from(Scalar::from_repr(FieldBytes::from(bytes))) {
419            return s;
420        }
421        let mut h = Sha256::new();
422        h.update(b"confium-scalar-reduce-v1");
423        h.update(bytes);
424        bytes = h.finalize().into();
425    }
426}
427
428fn hash_to_scalar(message: &[u8]) -> Scalar {
429    let mut h = Sha256::new();
430    h.update(message);
431    let digest: [u8; 32] = h.finalize().into();
432    reduce_to_scalar(digest)
433}
434
435fn normalize_s_low(s: Scalar) -> Scalar {
436    use crypto_bigint::Limb;
437    use elliptic_curve::Curve;
438    use p256::NistP256;
439    let n_u = <NistP256 as Curve>::ORDER.get();
440    let half = n_u >> 1usize;
441    let s_u = decode_scalar_to_uint(s);
442    if s_u > half {
443        let (s_prime, _) = n_u.borrowing_sub(&s_u, Limb::ZERO);
444        let be = s_prime.to_be_bytes();
445        let src: &[u8] = be.as_slice();
446        let mut bytes = [0u8; 32];
447        let off = src.len().saturating_sub(32);
448        let take = src.len() - off;
449        bytes[..take].copy_from_slice(&src[off..]);
450        let fb: p256::FieldBytes = bytes.into();
451        Option::from(Scalar::from_repr(fb)).unwrap_or(s)
452    } else {
453        s
454    }
455}
456
457fn decode_scalar_to_uint(s: Scalar) -> p256::U256 {
458    use elliptic_curve::Curve;
459    use p256::NistP256;
460    let bytes = s.to_bytes();
461    <NistP256 as Curve>::Uint::from_be_slice(bytes.as_slice())
462}
463
464fn decode_affine(bytes: &[u8]) -> Result<AffinePoint> {
465    use elliptic_curve::sec1::FromSec1Point;
466    if bytes.len() != 33 {
467        return Err(scheme_error(Cmp20ErrorCode::BAD_ROUND_MESSAGE));
468    }
469    let enc = elliptic_curve::sec1::Sec1Point::<p256::NistP256>::from_bytes(bytes)
470        .map_err(|_| scheme_error(Cmp20ErrorCode::BAD_ROUND_MESSAGE))?;
471    let pt: AffinePoint = Option::from(AffinePoint::from_sec1_point(&enc))
472        .ok_or_else(|| scheme_error(Cmp20ErrorCode::BAD_ROUND_MESSAGE))?;
473    let _ = pt.x();
474    Ok(pt)
475}