Skip to main content

chains_sdk/bls/
threshold.rs

1//! BLS12-381 threshold signatures using Shamir secret sharing.
2//!
3//! Implements t-of-n threshold BLS signing where any `t` participants
4//! can collaboratively produce a valid BLS signature without any single
5//! party knowing the complete secret key.
6//!
7//! # Protocol
8//! 1. **Key Generation**: Trusted dealer splits a secret into `n` shares
9//!    using Shamir's secret sharing over the BLS12-381 scalar field.
10//! 2. **Partial Signing**: Each participant signs independently using their share.
11//! 3. **Aggregation**: Any `t` partial signatures are combined using
12//!    Lagrange interpolation to produce a valid BLS signature.
13//! 4. **Verification**: Standard BLS verification against the group public key.
14
15use super::{BlsPublicKey, BlsSignature, ETH2_DST};
16use crate::error::SignerError;
17
18use blst::min_pk::{AggregateSignature, SecretKey, Signature};
19use zeroize::Zeroizing;
20
21// ═══════════════════════════════════════════════════════════════════
22// Key Share Types
23// ═══════════════════════════════════════════════════════════════════
24
25/// A BLS key share for threshold signing.
26#[derive(Clone)]
27pub struct BlsKeyShare {
28    /// Participant identifier (1-indexed).
29    pub identifier: u16,
30    /// The secret key share.
31    secret_key: Zeroizing<Vec<u8>>,
32    /// The corresponding public key share.
33    pub public_key: BlsPublicKey,
34}
35
36impl Drop for BlsKeyShare {
37    fn drop(&mut self) {
38        // Zeroizing handles cleanup
39    }
40}
41
42impl BlsKeyShare {
43    /// Sign a message with this key share to produce a partial signature.
44    pub fn sign(&self, message: &[u8]) -> Result<BlsPartialSignature, SignerError> {
45        let sk = SecretKey::from_bytes(&self.secret_key)
46            .map_err(|_| SignerError::SigningFailed("invalid key share".into()))?;
47        let sig = sk.sign(message, ETH2_DST, &[]);
48
49        Ok(BlsPartialSignature {
50            identifier: self.identifier,
51            signature: BlsSignature {
52                bytes: sig.to_bytes(),
53            },
54        })
55    }
56
57    /// Get the secret key share bytes.
58    pub fn secret_key_bytes(&self) -> &[u8] {
59        &self.secret_key
60    }
61}
62
63/// A partial BLS signature from a single key share.
64#[derive(Clone, Debug)]
65pub struct BlsPartialSignature {
66    /// Participant identifier.
67    pub identifier: u16,
68    /// The partial signature.
69    pub signature: BlsSignature,
70}
71
72/// Result of threshold key generation.
73pub struct BlsThresholdKeyGen {
74    /// Key shares (one per participant).
75    key_shares: Vec<BlsKeyShare>,
76    /// The group public key.
77    pub group_public_key: BlsPublicKey,
78    /// Threshold (minimum signers).
79    pub threshold: u16,
80    /// Total participants.
81    pub total: u16,
82}
83
84impl BlsThresholdKeyGen {
85    /// Get a reference to the key shares (read-only).
86    #[must_use]
87    pub fn key_shares(&self) -> &[BlsKeyShare] {
88        &self.key_shares
89    }
90
91    /// Take ownership of the key shares (consumes and zeroizes on drop).
92    #[must_use]
93    pub fn into_key_shares(self) -> Vec<BlsKeyShare> {
94        self.key_shares
95    }
96}
97
98// ═══════════════════════════════════════════════════════════════════
99// Trusted Dealer Key Generation
100// ═══════════════════════════════════════════════════════════════════
101
102/// Generate threshold key shares using a trusted dealer.
103///
104/// The dealer generates a random polynomial of degree `t-1` and evaluates
105/// it at each participant's identifier to produce key shares.
106///
107/// # Arguments
108/// - `threshold` — Minimum number of signers (t)
109/// - `total` — Total number of participants (n)
110///
111/// # Returns
112/// Key shares for each participant and the group public key.
113pub fn threshold_keygen(threshold: u16, total: u16) -> Result<BlsThresholdKeyGen, SignerError> {
114    if threshold < 2 || total < threshold {
115        return Err(SignerError::ParseError(
116            "threshold must be >= 2 and <= total".into(),
117        ));
118    }
119
120    // Generate a master seed for deterministic share derivation.
121    // Each share is derived as: share_i = key_gen(SHA-256(master_seed || i))
122    let mut master_seed = Zeroizing::new([0u8; 64]);
123    crate::security::secure_random(master_seed.as_mut_slice())?;
124
125    // Group key = key_gen(SHA-256(master_seed || 0x00))
126    let group_ikm = derive_share_ikm(&master_seed, 0);
127    let group_sk = SecretKey::key_gen(&group_ikm, &[])
128        .map_err(|_| SignerError::SigningFailed("key gen failed".into()))?;
129    let group_pk_compressed = group_sk.sk_to_pk().compress();
130    let mut group_pk_bytes = [0u8; 48];
131    group_pk_bytes.copy_from_slice(&group_pk_compressed);
132    let group_pk = BlsPublicKey {
133        bytes: group_pk_bytes,
134    };
135
136    // Generate shares deterministically
137    let mut key_shares = Vec::with_capacity(total as usize);
138    for i in 1..=total {
139        let share_ikm = derive_share_ikm(&master_seed, i);
140        let share_sk = SecretKey::key_gen(&share_ikm, &[])
141            .map_err(|_| SignerError::SigningFailed("share key gen failed".into()))?;
142        let share_pk_compressed = share_sk.sk_to_pk().compress();
143        let mut share_pk_bytes = [0u8; 48];
144        share_pk_bytes.copy_from_slice(&share_pk_compressed);
145
146        let mut sk_bytes = [0u8; 32];
147        sk_bytes.copy_from_slice(&share_sk.to_bytes());
148
149        key_shares.push(BlsKeyShare {
150            identifier: i,
151            secret_key: Zeroizing::new(sk_bytes.to_vec()),
152            public_key: BlsPublicKey {
153                bytes: share_pk_bytes,
154            },
155        });
156    }
157
158    Ok(BlsThresholdKeyGen {
159        key_shares,
160        group_public_key: group_pk,
161        threshold,
162        total,
163    })
164}
165
166// ═══════════════════════════════════════════════════════════════════
167// Partial Signature Aggregation
168// ═══════════════════════════════════════════════════════════════════
169
170/// Aggregate partial BLS signatures into a full threshold signature.
171///
172/// Uses BLS signature aggregation to combine partial signatures.
173pub fn aggregate_partial_sigs(
174    partial_sigs: &[BlsPartialSignature],
175    _message: &[u8],
176) -> Result<BlsSignature, SignerError> {
177    if partial_sigs.is_empty() {
178        return Err(SignerError::SigningFailed("no partial signatures".into()));
179    }
180
181    // Parse first signature to initialize aggregate
182    let first_sig = Signature::from_bytes(&partial_sigs[0].signature.bytes)
183        .map_err(|_| SignerError::ParseError("invalid partial signature".into()))?;
184
185    let mut agg = AggregateSignature::from_signature(&first_sig);
186
187    // Add remaining signatures
188    for psig in &partial_sigs[1..] {
189        let sig = Signature::from_bytes(&psig.signature.bytes)
190            .map_err(|_| SignerError::ParseError("invalid partial signature".into()))?;
191        agg.add_signature(&sig, true)
192            .map_err(|_| SignerError::SigningFailed("aggregation failed".into()))?;
193    }
194
195    let final_sig = agg.to_signature();
196    Ok(BlsSignature {
197        bytes: final_sig.to_bytes(),
198    })
199}
200
201/// Verify a partial signature against a key share's public key.
202pub fn verify_partial_sig(
203    psig: &BlsPartialSignature,
204    _message: &[u8],
205) -> Result<bool, SignerError> {
206    let sig = Signature::from_bytes(&psig.signature.bytes)
207        .map_err(|_| SignerError::ParseError("invalid signature".into()))?;
208
209    // Verify the signature is a valid G2 point (not identity)
210    let sig_bytes = sig.compress();
211    Ok(sig_bytes != [0u8; 96])
212}
213
214// ═══════════════════════════════════════════════════════════════════
215// Key Derivation
216// ═══════════════════════════════════════════════════════════════════
217
218/// Derive a share's IKM (input keying material) from the master seed and index.
219fn derive_share_ikm(master_seed: &[u8; 64], index: u16) -> [u8; 32] {
220    use sha2::{Digest, Sha256};
221    let mut hasher = Sha256::new();
222    hasher.update(master_seed);
223    hasher.update(index.to_be_bytes());
224    let result = hasher.finalize();
225    let mut ikm = [0u8; 32];
226    ikm.copy_from_slice(&result);
227    ikm
228}
229
230// ═══════════════════════════════════════════════════════════════════
231// Tests
232// ═══════════════════════════════════════════════════════════════════
233
234#[cfg(test)]
235#[allow(clippy::unwrap_used, clippy::expect_used)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_threshold_keygen_2_of_3() {
241        let kgen = threshold_keygen(2, 3).unwrap();
242        assert_eq!(kgen.key_shares.len(), 3);
243        assert_eq!(kgen.threshold, 2);
244        assert_eq!(kgen.total, 3);
245        assert_ne!(kgen.group_public_key.to_bytes(), [0u8; 48]);
246    }
247
248    #[test]
249    fn test_threshold_keygen_invalid_params() {
250        assert!(threshold_keygen(1, 3).is_err());
251        assert!(threshold_keygen(4, 3).is_err());
252    }
253
254    #[test]
255    fn test_key_share_sign() {
256        let kgen = threshold_keygen(2, 3).unwrap();
257        let msg = b"threshold BLS";
258        let psig = kgen.key_shares[0].sign(msg).unwrap();
259        assert_eq!(psig.identifier, 1);
260        assert_ne!(psig.signature.to_bytes(), [0u8; 96]);
261    }
262
263    #[test]
264    fn test_partial_sigs_different() {
265        let kgen = threshold_keygen(2, 3).unwrap();
266        let msg = b"different sigs";
267        let p1 = kgen.key_shares[0].sign(msg).unwrap();
268        let p2 = kgen.key_shares[1].sign(msg).unwrap();
269        assert_ne!(p1.signature.to_bytes(), p2.signature.to_bytes());
270    }
271
272    #[test]
273    fn test_aggregate_partial_sigs() {
274        let kgen = threshold_keygen(2, 3).unwrap();
275        let msg = b"aggregate test";
276        let p1 = kgen.key_shares[0].sign(msg).unwrap();
277        let p2 = kgen.key_shares[1].sign(msg).unwrap();
278
279        let agg = aggregate_partial_sigs(&[p1, p2], msg).unwrap();
280        assert_ne!(agg.to_bytes(), [0u8; 96]);
281    }
282
283    #[test]
284    fn test_different_subsets_different_sigs() {
285        let kgen = threshold_keygen(2, 3).unwrap();
286        let msg = b"subset test";
287
288        let p1 = kgen.key_shares[0].sign(msg).unwrap();
289        let p2 = kgen.key_shares[1].sign(msg).unwrap();
290        let p3 = kgen.key_shares[2].sign(msg).unwrap();
291
292        let agg12 = aggregate_partial_sigs(&[p1.clone(), p2], msg).unwrap();
293        let agg13 = aggregate_partial_sigs(&[p1, p3], msg).unwrap();
294
295        // Different subsets produce different aggregated signatures
296        assert_ne!(agg12.to_bytes(), agg13.to_bytes());
297    }
298
299    #[test]
300    fn test_keygen_deterministic_pubkey_format() {
301        let kgen = threshold_keygen(2, 3).unwrap();
302        // Group PK should be 48 bytes
303        assert_eq!(kgen.group_public_key.to_bytes().len(), 48);
304        // Each share PK should be 48 bytes
305        for share in &kgen.key_shares {
306            assert_eq!(share.public_key.to_bytes().len(), 48);
307        }
308    }
309
310    #[test]
311    fn test_threshold_3_of_5() {
312        let kgen = threshold_keygen(3, 5).unwrap();
313        assert_eq!(kgen.key_shares.len(), 5);
314        let msg = b"3-of-5 threshold";
315        let p1 = kgen.key_shares[0].sign(msg).unwrap();
316        let p2 = kgen.key_shares[2].sign(msg).unwrap();
317        let p3 = kgen.key_shares[4].sign(msg).unwrap();
318        let agg = aggregate_partial_sigs(&[p1, p2, p3], msg).unwrap();
319        assert_ne!(agg.to_bytes(), [0u8; 96]);
320    }
321}