Skip to main content

bsv/primitives/
key_shares.rs

1//! Shamir's Secret Sharing for private keys.
2//!
3//! Implements key splitting and reconstruction using polynomial interpolation
4//! over GF(p). Follows the TS SDK PrivateKey.ts KeyShares implementation.
5
6use crate::primitives::big_number::{BigNumber, Endian};
7use crate::primitives::curve::Curve;
8use crate::primitives::error::PrimitivesError;
9use crate::primitives::hash::sha512_hmac;
10use crate::primitives::polynomial::{PointInFiniteField, Polynomial};
11use crate::primitives::private_key::PrivateKey;
12use crate::primitives::random::random_bytes;
13use crate::primitives::utils::to_hex;
14
15/// Shamir's Secret Sharing for PrivateKey backup and recovery.
16///
17/// Splits a private key into `total` shares such that any `threshold`
18/// shares can reconstruct the original key, but fewer shares reveal
19/// nothing about the secret.
20///
21/// Share format (backup string): "base58(x).base58(y).threshold.integrity"
22/// where integrity is the first 8 hex chars of hash160(pubkey) as hex.
23pub struct KeyShares {
24    pub points: Vec<PointInFiniteField>,
25    pub threshold: usize,
26    pub integrity: String,
27}
28
29impl KeyShares {
30    /// Create a new KeyShares instance.
31    pub fn new(points: Vec<PointInFiniteField>, threshold: usize, integrity: String) -> Self {
32        KeyShares {
33            points,
34            threshold,
35            integrity,
36        }
37    }
38
39    /// Split a private key into shares using Shamir's Secret Sharing.
40    ///
41    /// # Arguments
42    /// * `key` - The private key to split
43    /// * `threshold` - Minimum shares needed to reconstruct (must be >= 2)
44    /// * `total` - Total shares to generate (must be >= threshold)
45    ///
46    /// # Returns
47    /// A KeyShares instance containing the shares, threshold, and integrity hash.
48    pub fn split(
49        key: &PrivateKey,
50        threshold: usize,
51        total: usize,
52    ) -> Result<Self, PrimitivesError> {
53        if threshold < 2 {
54            return Err(PrimitivesError::ThresholdError(
55                "threshold must be at least 2".to_string(),
56            ));
57        }
58        if total < 2 {
59            return Err(PrimitivesError::ThresholdError(
60                "totalShares must be at least 2".to_string(),
61            ));
62        }
63        if threshold > total {
64            return Err(PrimitivesError::ThresholdError(
65                "threshold should be less than or equal to totalShares".to_string(),
66            ));
67        }
68
69        let curve = Curve::secp256k1();
70        let key_bytes = key.to_bytes();
71        let poly = Polynomial::from_private_key(&key_bytes, threshold);
72
73        let mut points = Vec::with_capacity(total);
74        let mut used_x_coords: Vec<BigNumber> = Vec::new();
75
76        // Cryptographically secure x-coordinate generation
77        // Matching TS SDK: uses HMAC-SHA-512 with master seed for x-coordinate generation
78        let seed = random_bytes(64);
79
80        for i in 0..total {
81            let mut x: BigNumber;
82            let mut attempts = 0u32;
83
84            loop {
85                let mut counter = Vec::new();
86                counter.push(i as u8);
87                counter.push(attempts as u8);
88                counter.extend_from_slice(&random_bytes(32));
89
90                let h = sha512_hmac(&seed, &counter);
91                x = BigNumber::from_bytes(&h, Endian::Big);
92                x = x
93                    .umod(&curve.p)
94                    .map_err(|e| PrimitivesError::ArithmeticError(format!("mod p: {e}")))?;
95
96                attempts += 1;
97                if attempts > 5 {
98                    return Err(PrimitivesError::ThresholdError(
99                        "Failed to generate unique x coordinate after 5 attempts".to_string(),
100                    ));
101                }
102
103                // Check x is non-zero and not already used
104                if x.is_zero() {
105                    continue;
106                }
107                let mut duplicate = false;
108                for existing in &used_x_coords {
109                    if existing.cmp(&x) == 0 {
110                        duplicate = true;
111                        break;
112                    }
113                }
114                if !duplicate {
115                    break;
116                }
117            }
118
119            used_x_coords.push(x.clone());
120            let y = poly.value_at(&x);
121            points.push(PointInFiniteField::new(x, y));
122        }
123
124        // Integrity hash: first 8 hex chars of hash160(compressed pubkey) as hex
125        // TS SDK: this.toPublicKey().toHash('hex').slice(0, 8)
126        let pubkey = key.to_public_key();
127        let pubkey_hash = pubkey.to_hash();
128        let integrity = to_hex(&pubkey_hash);
129        let integrity = integrity[..8].to_string();
130
131        Ok(KeyShares {
132            points,
133            threshold,
134            integrity,
135        })
136    }
137
138    /// Convert shares to backup format strings.
139    ///
140    /// Each share is formatted as: "base58(x).base58(y).threshold.integrity"
141    pub fn to_backup_format(&self) -> Vec<String> {
142        self.points
143            .iter()
144            .map(|share| {
145                format!(
146                    "{}.{}.{}",
147                    share.to_string_repr(),
148                    self.threshold,
149                    self.integrity
150                )
151            })
152            .collect()
153    }
154
155    /// Parse shares from backup format strings.
156    ///
157    /// Each share must be in format: "base58(x).base58(y).threshold.integrity"
158    pub fn from_backup_format(shares: &[String]) -> Result<Self, PrimitivesError> {
159        if shares.is_empty() {
160            return Err(PrimitivesError::InvalidFormat(
161                "No shares provided".to_string(),
162            ));
163        }
164
165        let mut threshold = 0usize;
166        let mut integrity = String::new();
167        let mut points = Vec::with_capacity(shares.len());
168
169        for (idx, share) in shares.iter().enumerate() {
170            let parts: Vec<&str> = share.split('.').collect();
171            if parts.len() != 4 {
172                return Err(PrimitivesError::InvalidFormat(format!(
173                    "Invalid share format in share {idx}. Expected format: \"x.y.t.i\" - received {share}"
174                )));
175            }
176
177            let t_str = parts[2];
178            let i_str = parts[3];
179
180            let t: usize = t_str.parse().map_err(|_| {
181                PrimitivesError::InvalidFormat(format!(
182                    "Threshold not a valid number in share {idx}"
183                ))
184            })?;
185
186            if idx != 0 && threshold != t {
187                return Err(PrimitivesError::InvalidFormat(format!(
188                    "Threshold mismatch in share {idx}"
189                )));
190            }
191            if idx != 0 && integrity != i_str {
192                return Err(PrimitivesError::InvalidFormat(format!(
193                    "Integrity mismatch in share {idx}"
194                )));
195            }
196
197            threshold = t;
198            integrity = i_str.to_string();
199
200            let point_str = format!("{}.{}", parts[0], parts[1]);
201            let point = PointInFiniteField::from_string_repr(&point_str)?;
202            points.push(point);
203        }
204
205        Ok(KeyShares::new(points, threshold, integrity))
206    }
207
208    /// Reconstruct a private key from shares.
209    ///
210    /// Requires at least `threshold` shares. Uses Lagrange interpolation
211    /// to recover the secret (polynomial value at x=0).
212    ///
213    /// # Arguments
214    /// * `shares` - The KeyShares containing points, threshold, and integrity hash
215    ///
216    /// # Returns
217    /// The reconstructed PrivateKey, validated against the integrity hash.
218    pub fn reconstruct(shares: &KeyShares) -> Result<PrivateKey, PrimitivesError> {
219        let threshold = shares.threshold;
220
221        if threshold < 2 {
222            return Err(PrimitivesError::ThresholdError(
223                "threshold must be at least 2".to_string(),
224            ));
225        }
226
227        if shares.points.len() < threshold {
228            return Err(PrimitivesError::ThresholdError(format!(
229                "At least {threshold} shares are required to reconstruct the private key"
230            )));
231        }
232
233        // Check for duplicate x values
234        for i in 0..threshold {
235            for j in (i + 1)..threshold {
236                if shares.points[i].x.cmp(&shares.points[j].x) == 0 {
237                    return Err(PrimitivesError::ThresholdError(
238                        "Duplicate share detected, each must be unique.".to_string(),
239                    ));
240                }
241            }
242        }
243
244        // Lagrange interpolation at x=0
245        let poly = Polynomial::new(shares.points.clone(), Some(threshold));
246        let secret = poly.value_at(&BigNumber::zero());
247
248        // Create PrivateKey from recovered secret
249        let secret_bytes = secret.to_array(Endian::Big, Some(32));
250        let key = PrivateKey::from_bytes(&secret_bytes)?;
251
252        // Validate integrity hash
253        let pubkey = key.to_public_key();
254        let pubkey_hash = pubkey.to_hash();
255        let integrity_hash = to_hex(&pubkey_hash);
256        let integrity_check = &integrity_hash[..8];
257
258        if integrity_check != shares.integrity {
259            return Err(PrimitivesError::ThresholdError(
260                "Integrity hash mismatch".to_string(),
261            ));
262        }
263
264        Ok(key)
265    }
266}
267
268// ---------------------------------------------------------------------------
269// Tests
270// ---------------------------------------------------------------------------
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_key_shares_split_produces_correct_count() {
278        let key = PrivateKey::from_random().unwrap();
279        let shares = KeyShares::split(&key, 2, 5).unwrap();
280        assert_eq!(shares.points.len(), 5);
281        assert_eq!(shares.threshold, 2);
282        assert!(!shares.integrity.is_empty());
283    }
284
285    #[test]
286    fn test_key_shares_split_and_reconstruct_threshold_2_of_3() {
287        let key = PrivateKey::from_random().unwrap();
288        let shares = KeyShares::split(&key, 2, 3).unwrap();
289
290        // Use first 2 shares (indices 0, 1)
291        let subset = KeyShares::new(
292            shares.points[..2].to_vec(),
293            shares.threshold,
294            shares.integrity.clone(),
295        );
296        let recovered = KeyShares::reconstruct(&subset).unwrap();
297        assert_eq!(
298            recovered.to_hex(),
299            key.to_hex(),
300            "Should recover original key from 2 of 3 shares"
301        );
302    }
303
304    #[test]
305    fn test_key_shares_split_and_reconstruct_threshold_3_of_5() {
306        let key = PrivateKey::from_random().unwrap();
307        let shares = KeyShares::split(&key, 3, 5).unwrap();
308
309        // Use shares at indices 0, 2, 4
310        let subset = KeyShares::new(
311            vec![
312                shares.points[0].clone(),
313                shares.points[2].clone(),
314                shares.points[4].clone(),
315            ],
316            shares.threshold,
317            shares.integrity.clone(),
318        );
319        let recovered = KeyShares::reconstruct(&subset).unwrap();
320        assert_eq!(
321            recovered.to_hex(),
322            key.to_hex(),
323            "Should recover original key from 3 of 5 shares"
324        );
325    }
326
327    #[test]
328    fn test_key_shares_insufficient_shares_fails() {
329        let key = PrivateKey::from_random().unwrap();
330        let shares = KeyShares::split(&key, 3, 5).unwrap();
331
332        // Only provide 2 shares when threshold is 3
333        let subset = KeyShares::new(
334            shares.points[..2].to_vec(),
335            shares.threshold,
336            shares.integrity.clone(),
337        );
338        let result = KeyShares::reconstruct(&subset);
339        assert!(
340            result.is_err(),
341            "Should fail with fewer than threshold shares"
342        );
343    }
344
345    #[test]
346    fn test_key_shares_threshold_validation() {
347        let key = PrivateKey::from_random().unwrap();
348
349        // threshold < 2
350        assert!(KeyShares::split(&key, 1, 3).is_err());
351
352        // total < 2
353        assert!(KeyShares::split(&key, 2, 1).is_err());
354
355        // threshold > total
356        assert!(KeyShares::split(&key, 4, 3).is_err());
357    }
358
359    #[test]
360    fn test_key_shares_backup_format_roundtrip() {
361        let key = PrivateKey::from_random().unwrap();
362        let shares = KeyShares::split(&key, 2, 3).unwrap();
363
364        // Convert to backup format
365        let backup = shares.to_backup_format();
366        assert_eq!(backup.len(), 3);
367
368        // Each backup string should have 4 dot-separated parts
369        for b in &backup {
370            let parts: Vec<&str> = b.split('.').collect();
371            assert_eq!(parts.len(), 4, "Backup format should be x.y.t.i");
372        }
373
374        // Parse back and reconstruct
375        let parsed = KeyShares::from_backup_format(&backup[..2]).unwrap();
376        let recovered = KeyShares::reconstruct(&parsed).unwrap();
377        assert_eq!(
378            recovered.to_hex(),
379            key.to_hex(),
380            "Should recover from backup format"
381        );
382    }
383
384    #[test]
385    fn test_key_shares_integrity_hash() {
386        let key = PrivateKey::from_random().unwrap();
387        let shares = KeyShares::split(&key, 2, 3).unwrap();
388
389        // Integrity should be 8 hex chars
390        assert_eq!(
391            shares.integrity.len(),
392            8,
393            "Integrity hash should be 8 hex chars"
394        );
395
396        // All shares in backup format should have the same integrity
397        let backup = shares.to_backup_format();
398        for b in &backup {
399            assert!(b.ends_with(&shares.integrity));
400        }
401    }
402
403    #[test]
404    fn test_key_shares_integrity_mismatch_detected() {
405        let key = PrivateKey::from_random().unwrap();
406        let shares = KeyShares::split(&key, 2, 3).unwrap();
407
408        // Corrupt integrity
409        let corrupt_shares = KeyShares::new(
410            shares.points[..2].to_vec(),
411            shares.threshold,
412            "deadbeef".to_string(), // wrong integrity
413        );
414        let result = KeyShares::reconstruct(&corrupt_shares);
415        assert!(result.is_err(), "Should fail on integrity mismatch");
416    }
417
418    #[test]
419    fn test_key_shares_known_key() {
420        // Use a known key for reproducibility
421        let key = PrivateKey::from_hex(
422            "e8f32e723decf4051aefac8e2c93c9c5b214313817cdb01a1494b917c8436b35",
423        )
424        .unwrap();
425
426        let shares = KeyShares::split(&key, 2, 3).unwrap();
427        let subset = KeyShares::new(
428            shares.points[1..3].to_vec(),
429            shares.threshold,
430            shares.integrity.clone(),
431        );
432        let recovered = KeyShares::reconstruct(&subset).unwrap();
433        assert_eq!(recovered.to_hex(), key.to_hex(), "Should recover known key");
434    }
435
436    #[test]
437    fn test_key_shares_invalid_backup_format() {
438        let bad = vec!["not.valid".to_string()];
439        assert!(KeyShares::from_backup_format(&bad).is_err());
440    }
441
442    #[test]
443    fn test_key_shares_any_subset_reconstructs() {
444        // With threshold=2, total=4, any 2 shares should work
445        let key = PrivateKey::from_random().unwrap();
446        let shares = KeyShares::split(&key, 2, 4).unwrap();
447
448        // Try all pairs
449        for i in 0..4 {
450            for j in (i + 1)..4 {
451                let subset = KeyShares::new(
452                    vec![shares.points[i].clone(), shares.points[j].clone()],
453                    shares.threshold,
454                    shares.integrity.clone(),
455                );
456                let recovered = KeyShares::reconstruct(&subset).unwrap();
457                assert_eq!(
458                    recovered.to_hex(),
459                    key.to_hex(),
460                    "Shares ({i}, {j}) should reconstruct"
461                );
462            }
463        }
464    }
465}