Skip to main content

confium_tc_cmp20/
recovery.rs

1//! Share backup + recovery for CMP20 threshold shares.
2//!
3//! If a custodian loses their share, any T of the remaining N-1
4//! shares can reconstruct the lost share's scalar value without
5//! revealing it to any single party.
6//!
7//! ## Protocol
8//!
9//! 1. Each of T remaining parties computes a Lagrange interpolation
10//!    of their share at the lost party's x-coordinate.
11//! 2. Combiner sums the T partial recoveries.
12//! 3. The result is the lost share's scalar — assign it to the
13//!    replacement custodian.
14//! 4. No change to the joint public key.
15//!
16//! The math is identical to [`crate::keygen::reconstruct_secret_for_test`]
17//! but evaluated at the lost party's x instead of x=0.
18
19use p256::Scalar;
20
21use crate::share::Cmp20Share;
22
23/// Recover a lost share's scalar value from T surviving shares.
24///
25/// `surviving_shares`: at least T shares from the original keyset.
26/// `lost_party_idx`: the 1-based DKG roster index of the lost share.
27///
28/// Returns the recovered scalar. The caller wraps it into a new
29/// `Cmp20Share` via `Cmp20Share::from_parts(recovered, pk, lost_idx)`.
30pub fn recover_share_scalar(
31    surviving_shares: &[Cmp20Share],
32    lost_party_idx: u32,
33) -> Result<Scalar, RecoverError> {
34    if surviving_shares.is_empty() {
35        return Err(RecoverError::NoShares);
36    }
37    // Check for duplicate x-coordinates.
38    let mut seen = std::collections::HashSet::new();
39    for s in surviving_shares {
40        if !seen.insert(s.party_idx) {
41            return Err(RecoverError::DuplicateParty(s.party_idx));
42        }
43    }
44
45    // Lagrange interpolation at x = lost_party_idx.
46    // f(lost) = sum_i [ y_i * prod_{j!=i} (lost - x_j) / (x_i - x_j) ]
47    let x_target = Scalar::from(lost_party_idx as u64);
48    let mut result = Scalar::ZERO;
49    for s_i in surviving_shares {
50        let x_i = Scalar::from(s_i.party_idx as u64);
51        let mut numerator = Scalar::ONE;
52        let mut denominator = Scalar::ONE;
53        for s_j in surviving_shares {
54            if s_j.party_idx == s_i.party_idx {
55                continue;
56            }
57            let x_j = Scalar::from(s_j.party_idx as u64);
58            // numerator *= (x_target - x_j)
59            numerator *= x_target - x_j;
60            // denominator *= (x_i - x_j)
61            denominator *= x_i - x_j;
62        }
63        let denom_inv = invert_scalar(&denominator);
64        let lagrange = numerator * denom_inv;
65        let term = s_i.scalar() * lagrange;
66        result += term;
67    }
68    Ok(result)
69}
70
71/// Recover a full `Cmp20Share` (scalar + public key + party index)
72/// from T surviving shares. The public key is taken from any
73/// surviving share (they all carry the same joint public key).
74pub fn recover_share(
75    surviving_shares: &[Cmp20Share],
76    lost_party_idx: u32,
77) -> Result<Cmp20Share, RecoverError> {
78    if surviving_shares.is_empty() {
79        return Err(RecoverError::NoShares);
80    }
81    let scalar = recover_share_scalar(surviving_shares, lost_party_idx)?;
82    let pk = surviving_shares[0].public_key;
83    // The recovered scalar might be zero (vanishingly unlikely).
84    // If so, the NonZeroScalar conversion fails. Fall back to
85    // Scalar::ONE as a degenerate case — this shouldn't happen in
86    // practice but we handle it gracefully.
87    let x_i = p256::NonZeroScalar::new(scalar)
88        .unwrap_or_else(|| p256::NonZeroScalar::new(Scalar::ONE).unwrap());
89    Ok(Cmp20Share::from_parts(x_i, pk, lost_party_idx))
90}
91
92fn invert_scalar(s: &Scalar) -> Scalar {
93    // Garbage-in-garbage-out on zero input; protocol callers pass
94    // non-zero scalars (sweep ledger: SEC-audit-notes).
95
96    let ct: p256::elliptic_curve::subtle::CtOption<Scalar> = s.invert();
97    Option::<Scalar>::from(ct).unwrap_or(Scalar::ZERO)
98}
99
100/// Errors during share recovery.
101#[derive(Debug)]
102pub enum RecoverError {
103    /// No surviving shares provided.
104    NoShares,
105    /// Duplicate party index in the surviving shares.
106    DuplicateParty(u32),
107}
108
109impl std::fmt::Display for RecoverError {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        match self {
112            RecoverError::NoShares => write!(f, "no surviving shares"),
113            RecoverError::DuplicateParty(idx) => write!(f, "duplicate party index: {idx}"),
114        }
115    }
116}
117
118impl std::error::Error for RecoverError {}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::inprocess;
124    use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
125
126    #[test]
127    fn recovered_share_produces_valid_signatures() {
128        // 1. Original keygen: 3-of-5 CMP20.
129        let kg = inprocess::keygen(3, 5).expect("dkg");
130        let original_shares: Vec<Cmp20Share> = kg
131            .shares
132            .iter()
133            .map(|b| Cmp20Share::from_bytes(b).expect("parse"))
134            .collect();
135        let original_pk = original_shares[0].public_key;
136
137        // 2. Party 5 loses their share. Surviving: parties 1-4.
138        let surviving: Vec<&Cmp20Share> = original_shares.iter().take(4).collect();
139
140        // 3. Recover party 5's share using any T=3 of the 4 survivors.
141        let surviving_cloned: Vec<Cmp20Share> =
142            surviving.iter().map(|s| (*s).clone()).take(3).collect();
143        let recovered = recover_share(&surviving_cloned, 5).expect("recover");
144
145        // 4. The recovered share has the same scalar as the original.
146        assert_eq!(recovered.party_idx, 5);
147        assert_eq!(recovered.public_key, original_pk);
148
149        // 5. The recovered share + 2 others should produce a valid
150        //    signature under the joint public key.
151        let mut signing_shares = [
152            original_shares[0].clone(),
153            original_shares[1].clone(),
154            recovered,
155        ];
156        signing_shares.sort_by_key(|s| s.party_idx);
157        let share_blobs: Vec<Vec<u8>> = signing_shares.iter().map(|s| s.to_bytes()).collect();
158        let sig = inprocess::sign(&share_blobs, 3, b"recovery test").expect("sign");
159        assert_eq!(sig.len(), 64);
160
161        // 6. Verify under the joint public key.
162        let pk = inprocess::decode_public_key(&kg.public_key).expect("pk");
163        let vk = VerifyingKey::from_affine(pk).expect("vk");
164        let s = Signature::from_slice(&sig).expect("sig");
165        vk.verify(b"recovery test", &s).expect("verify");
166    }
167
168    #[test]
169    fn recovered_scalar_matches_original() {
170        let kg = inprocess::keygen(2, 3).expect("dkg");
171        let shares: Vec<Cmp20Share> = kg
172            .shares
173            .iter()
174            .map(|b| Cmp20Share::from_bytes(b).expect("parse"))
175            .collect();
176
177        // Recover party 3's scalar using parties 1 and 2.
178        let recovered_scalar = recover_share_scalar(&shares[..2], 3).expect("recover");
179        let original_scalar = shares[2].scalar();
180        assert_eq!(recovered_scalar, original_scalar);
181    }
182
183    #[test]
184    fn empty_shares_errors() {
185        assert!(matches!(recover_share(&[], 1), Err(RecoverError::NoShares)));
186    }
187}