Skip to main content

confium_tc_elgamal_p256/
lib.rs

1//! Threshold ElGamal over P-256.
2//!
3//! Real implementation using P-256 group operations. Threshold variant:
4//! the secret key `x` is Shamir-shared among N parties; any T can
5//! decrypt collaboratively. The decryption produces the original
6//! shared secret point.
7//!
8//! Used for medium-term sealed data (5-10 year appeals window in OIML CNML).
9//!
10//! See `TODO.roadmap/31-threshold-encryption.md` for full spec.
11
12#![forbid(unsafe_code)]
13#![allow(missing_docs)] // TODO: document before 1.0
14
15pub mod keys;
16pub mod shamir;
17
18use p256::elliptic_curve::PrimeField;
19use p256::elliptic_curve::rand_core;
20use p256::elliptic_curve::sec1::ToSec1Point;
21use p256::elliptic_curve::subtle::CtOption;
22use p256::{AffinePoint, FieldBytes, ProjectivePoint, Scalar};
23use serde::{Deserialize, Serialize};
24use sha2::{Digest as _, Sha256};
25
26pub use keys::generate_keypair;
27pub use shamir::{Share, recover_secret, split_secret};
28
29/// Algorithm identifier.
30pub const ALGORITHM: &str = "ElGamal-P256-threshold";
31
32/// Public key wrapping an affine P-256 point.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct PublicKey {
35    /// SEC1-encoded public key bytes (uncompressed, 65 bytes).
36    pub bytes: Vec<u8>,
37}
38
39impl PublicKey {
40    /// Construct from an affine point.
41    pub fn from_affine(point: AffinePoint) -> Self {
42        Self {
43            bytes: point.to_sec1_point(false).as_bytes().to_vec(),
44        }
45    }
46}
47
48/// Share of the secret key held by one party.
49/// The secret `bytes` field is zeroized on drop.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct DecryptionShare {
52    /// Party index.
53    pub party_index: u32,
54    /// Scalar share bytes (32 bytes).
55    pub bytes: Vec<u8>,
56}
57
58impl Drop for DecryptionShare {
59    fn drop(&mut self) {
60        use zeroize::Zeroize;
61        self.bytes.zeroize();
62    }
63}
64
65/// Ciphertext: pair of EC points (c1, c2) per ElGamal.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct Ciphertext {
68    /// SEC1-encoded c1 (ephemeral public key).
69    pub c1: Vec<u8>,
70    /// SEC1-encoded c2 (shared_secret_point + plaintext_point).
71    pub c2: Vec<u8>,
72}
73
74/// A partial decryption from one party.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct PartialDecryption {
77    /// Contributing party index.
78    pub party_index: u32,
79    /// SEC1-encoded point.
80    pub bytes: Vec<u8>,
81}
82
83/// Errors during threshold ElGamal operations.
84#[derive(Debug, thiserror::Error)]
85pub enum ElGamalError {
86    /// Threshold not met.
87    #[error("threshold not met: have {have}, need {need}")]
88    ThresholdNotMet {
89        /// Count received.
90        have: usize,
91        /// Required threshold.
92        need: u32,
93    },
94    /// SEC1 decoding failure.
95    #[error("SEC1 decode failed: {0}")]
96    Sec1Decode(String),
97    /// Duplicate party indices.
98    #[error("duplicate party index: {0}")]
99    DuplicateParty(u32),
100    /// Invalid scalar bytes.
101    #[error("invalid scalar: {0}")]
102    InvalidScalar(String),
103}
104
105fn decode_point(bytes: &[u8]) -> Result<ProjectivePoint, ElGamalError> {
106    use p256::elliptic_curve::sec1::FromSec1Point;
107    let ep = p256::elliptic_curve::sec1::Sec1Point::<p256::NistP256>::from_bytes(bytes)
108        .map_err(|e| ElGamalError::Sec1Decode(format!("encoded point: {e}")))?;
109    let ct_opt = AffinePoint::from_sec1_point(&ep);
110    let affine = Option::<AffinePoint>::from(ct_opt)
111        .ok_or_else(|| ElGamalError::Sec1Decode("point at infinity".into()))?;
112    Ok(ProjectivePoint::from(affine))
113}
114
115fn encode_point(point: &ProjectivePoint) -> Vec<u8> {
116    point.to_affine().to_sec1_point(false).as_bytes().to_vec()
117}
118
119fn decode_scalar(bytes: &[u8]) -> Result<Scalar, ElGamalError> {
120    if bytes.len() != 32 {
121        return Err(ElGamalError::InvalidScalar(format!(
122            "expected 32 bytes, got {}",
123            bytes.len()
124        )));
125    }
126    let mut arr = [0u8; 32];
127    arr.copy_from_slice(bytes);
128    let fb = FieldBytes::from(arr);
129    let ct: CtOption<Scalar> = Scalar::from_repr(fb);
130    Option::<Scalar>::from(ct).ok_or_else(|| ElGamalError::InvalidScalar("out of range".into()))
131}
132
133/// Encapsulate a fresh shared secret to `recipient_public_key`.
134///
135/// Standard ElGamal:
136/// - Generate ephemeral scalar `r`
137/// - c1 = r * G
138/// - c2 = r * recipient_pubkey
139/// - shared_secret = X-coordinate of c2 (32 bytes)
140///
141/// The receiver threshold-decrypts to recover the same c2 point, then
142/// takes its X-coordinate as the shared secret.
143pub fn encapsulate(
144    recipient_public_key: &PublicKey,
145) -> Result<(Ciphertext, Vec<u8>), ElGamalError> {
146    use p256::elliptic_curve::rand_core::Rng;
147
148    let recipient_pt = decode_point(&recipient_public_key.bytes)?;
149
150    // Random ephemeral scalar
151    let r = loop {
152        let mut buf = [0u8; 32];
153        rand_core::UnwrapErr(getrandom::SysRng).fill_bytes(&mut buf);
154        if let Some(s) = Option::<Scalar>::from(Scalar::from_repr(FieldBytes::from(buf))) {
155            if s != Scalar::ZERO {
156                break s;
157            }
158        }
159    };
160
161    let c1 = ProjectivePoint::GENERATOR * r;
162    let c2 = recipient_pt * r;
163
164    let c1_bytes = encode_point(&c1);
165    let c2_bytes = encode_point(&c2);
166    let shared_secret = x_coordinate(&c2);
167
168    Ok((
169        Ciphertext {
170            c1: c1_bytes,
171            c2: c2_bytes,
172        },
173        shared_secret,
174    ))
175}
176
177/// Compute a partial decryption: `share * c1`.
178pub fn partial_decrypt(
179    share: &DecryptionShare,
180    ciphertext: &Ciphertext,
181) -> Result<PartialDecryption, ElGamalError> {
182    let s = decode_scalar(&share.bytes)?;
183    let c1 = decode_point(&ciphertext.c1)?;
184    let partial = c1 * s;
185    Ok(PartialDecryption {
186        party_index: share.party_index,
187        bytes: encode_point(&partial),
188    })
189}
190
191/// Aggregate T partial decryptions into the shared secret point.
192///
193/// For KEM-style encapsulate (shared_secret = X(r * recipient_pubkey)):
194/// the shared secret point is `combined = sum_i λ_i * partial_i = x * c1`.
195/// Returns the X-coordinate of `combined` (32 bytes).
196pub fn aggregate_partials(
197    partials: &[PartialDecryption],
198    threshold: u32,
199    _ciphertext: &Ciphertext,
200) -> Result<Vec<u8>, ElGamalError> {
201    if (partials.len() as u32) < threshold {
202        return Err(ElGamalError::ThresholdNotMet {
203            have: partials.len(),
204            need: threshold,
205        });
206    }
207
208    let mut seen = std::collections::HashSet::new();
209    for p in partials {
210        if !seen.insert(p.party_index) {
211            return Err(ElGamalError::DuplicateParty(p.party_index));
212        }
213    }
214
215    // combined = sum_i [ λ_i * partial_i ] = x * c1 = r * recipient_pubkey (for KEM)
216    let mut combined = ProjectivePoint::IDENTITY;
217    for p_i in partials {
218        let x_i = party_to_scalar(p_i.party_index);
219        let mut numerator = Scalar::ONE;
220        let mut denominator = Scalar::ONE;
221        for p_j in partials {
222            if p_j.party_index == p_i.party_index {
223                continue;
224            }
225            let x_j = party_to_scalar(p_j.party_index);
226            numerator *= negate(&x_j);
227            denominator *= x_i.sub(&x_j);
228        }
229        let denom_inv = invert(&denominator);
230        let lagrange = numerator * denom_inv;
231
232        let partial_point = decode_point(&p_i.bytes)?;
233        let weighted = partial_point * lagrange;
234        combined = combined.add(&weighted);
235    }
236
237    Ok(x_coordinate(&combined))
238}
239
240fn x_coordinate(point: &ProjectivePoint) -> Vec<u8> {
241    let affine = point.to_affine();
242    let ep = affine.to_sec1_point(false);
243    let bytes = ep.as_bytes();
244    if bytes.len() == 65 {
245        bytes[1..33].to_vec()
246    } else {
247        bytes.to_vec()
248    }
249}
250
251fn negate(s: &Scalar) -> Scalar {
252    Scalar::ZERO.sub(s)
253}
254
255fn invert(s: &Scalar) -> Scalar {
256    // Garbage-in-garbage-out on zero input; protocol callers pass
257    // non-zero scalars (sweep ledger: SEC-audit-notes).
258    let ct: CtOption<Scalar> = s.invert();
259    Option::<Scalar>::from(ct).unwrap_or(Scalar::ZERO)
260}
261
262/// Reduce 32 bytes to a scalar by rejection sampling with re-hash;
263/// never falls back to a constant.
264fn reduce_to_scalar(mut bytes: [u8; 32]) -> Scalar {
265    loop {
266        if let Some(s) = Option::<Scalar>::from(Scalar::from_repr(FieldBytes::from(bytes))) {
267            return s;
268        }
269        let mut h = Sha256::new();
270        h.update(b"confium-scalar-reduce-v1");
271        h.update(bytes);
272        bytes = h.finalize().into();
273    }
274}
275
276fn party_to_scalar(v: u32) -> Scalar {
277    let mut arr = [0u8; 32];
278    arr[28..32].copy_from_slice(&v.to_be_bytes());
279    reduce_to_scalar(arr)
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn encapsulate_then_aggregate_round_trip() {
288        // 1. Generate keypair, split into 3 shares (T=2).
289        let keypair = generate_keypair();
290        let pk = PublicKey::from_affine(keypair.public_key);
291        let shares = split_secret(&keypair.secret_scalar, 2, 3);
292
293        // 2. Encapsulate to pk.
294        let (ciphertext, shared_secret) = encapsulate(&pk).unwrap();
295        assert_eq!(shared_secret.len(), 32);
296
297        // 3. Each of T shares computes partial decryption.
298        let decryption_shares: Vec<DecryptionShare> = shares
299            .iter()
300            .map(|s| DecryptionShare {
301                party_index: s.x,
302                bytes: {
303                    let fb: FieldBytes = s.y.to_bytes();
304                    let arr: [u8; 32] = fb.into();
305                    arr.to_vec()
306                },
307            })
308            .collect();
309        let partials: Vec<PartialDecryption> = decryption_shares
310            .iter()
311            .take(2)
312            .map(|ds| partial_decrypt(ds, &ciphertext).unwrap())
313            .collect();
314
315        // 4. Aggregate.
316        let recovered = aggregate_partials(&partials, 2, &ciphertext).unwrap();
317        assert_eq!(recovered.len(), 32);
318        // X-coordinate of c2 matches shared secret.
319        assert_eq!(recovered, shared_secret);
320    }
321
322    #[test]
323    fn aggregate_below_threshold_fails() {
324        let keypair = generate_keypair();
325        let pk = PublicKey::from_affine(keypair.public_key);
326        let shares = split_secret(&keypair.secret_scalar, 3, 5);
327        let (ciphertext, _) = encapsulate(&pk).unwrap();
328        let decryption_shares: Vec<DecryptionShare> = shares
329            .iter()
330            .map(|s| DecryptionShare {
331                party_index: s.x,
332                bytes: {
333                    let fb: FieldBytes = s.y.to_bytes();
334                    let arr: [u8; 32] = fb.into();
335                    arr.to_vec()
336                },
337            })
338            .collect();
339        let partials: Vec<PartialDecryption> = decryption_shares
340            .iter()
341            .take(2)
342            .map(|ds| partial_decrypt(ds, &ciphertext).unwrap())
343            .collect();
344        let result = aggregate_partials(&partials, 3, &ciphertext);
345        assert!(matches!(result, Err(ElGamalError::ThresholdNotMet { .. })));
346    }
347
348    #[test]
349    fn different_share_subsets_recover_same_secret() {
350        let keypair = generate_keypair();
351        let pk = PublicKey::from_affine(keypair.public_key);
352        let shares = split_secret(&keypair.secret_scalar, 3, 5);
353        let (ciphertext, shared_secret) = encapsulate(&pk).unwrap();
354
355        let decryption_shares: Vec<DecryptionShare> = shares
356            .iter()
357            .map(|s| DecryptionShare {
358                party_index: s.x,
359                bytes: {
360                    let fb: FieldBytes = s.y.to_bytes();
361                    let arr: [u8; 32] = fb.into();
362                    arr.to_vec()
363                },
364            })
365            .collect();
366
367        // Subset A: shares 0, 1, 2
368        let partials_a: Vec<PartialDecryption> = decryption_shares[0..3]
369            .iter()
370            .map(|ds| partial_decrypt(ds, &ciphertext).unwrap())
371            .collect();
372        let recovered_a = aggregate_partials(&partials_a, 3, &ciphertext).unwrap();
373
374        // Subset B: shares 2, 3, 4
375        let partials_b: Vec<PartialDecryption> = decryption_shares[2..5]
376            .iter()
377            .map(|ds| partial_decrypt(ds, &ciphertext).unwrap())
378            .collect();
379        let recovered_b = aggregate_partials(&partials_b, 3, &ciphertext).unwrap();
380
381        assert_eq!(recovered_a, shared_secret);
382        assert_eq!(recovered_b, shared_secret);
383    }
384}
385
386#[cfg(test)]
387mod proptests {
388    use super::*;
389    use proptest::prelude::*;
390
391    // For any threshold T in [2, 5] and party count N in [T, 8],
392    // the threshold ElGamal round-trip must produce the same shared secret.
393    proptest! {
394        #[test]
395        fn elgamal_roundtrip_any_threshold(t in 2u32..=5, n in 5u32..=8) {
396            let kp = generate_keypair();
397            let pub_key = PublicKey::from_affine(kp.public_key);
398            let shares = split_secret(&kp.secret_scalar, t, n);
399            let decryption_shares: Vec<DecryptionShare> = shares
400                .iter()
401                .map(|s| DecryptionShare {
402                    party_index: s.x,
403                    bytes: {
404                        let fb: FieldBytes = s.y.to_bytes();
405                        let arr: [u8; 32] = fb.into();
406                        arr.to_vec()
407                    },
408                })
409                .collect();
410
411            let (ciphertext, shared_secret) = encapsulate(&pub_key)?;
412
413            let partials: Vec<PartialDecryption> = decryption_shares[..t as usize]
414                .iter()
415                .map(|ds| partial_decrypt(ds, &ciphertext).unwrap())
416                .collect();
417            let recovered = aggregate_partials(&partials, t, &ciphertext)?;
418
419            prop_assert_eq!(recovered, shared_secret);
420        }
421    }
422
423    // Threshold invariant: T shares recover the secret, T-1 do NOT.
424    proptest! {
425        #[test]
426        fn elgamal_below_threshold_fails(t in 2u32..=4, n in 5u32..=8) {
427            let kp = generate_keypair();
428            let pub_key = PublicKey::from_affine(kp.public_key);
429            let shares = split_secret(&kp.secret_scalar, t, n);
430            let decryption_shares: Vec<DecryptionShare> = shares
431                .iter()
432                .map(|s| DecryptionShare {
433                    party_index: s.x,
434                    bytes: {
435                        let fb: FieldBytes = s.y.to_bytes();
436                        let arr: [u8; 32] = fb.into();
437                        arr.to_vec()
438                    },
439                })
440                .collect();
441
442            let (ciphertext, _) = encapsulate(&pub_key)?;
443
444            let partials: Vec<PartialDecryption> = decryption_shares[..(t - 1) as usize]
445                .iter()
446                .map(|ds| partial_decrypt(ds, &ciphertext).unwrap())
447                .collect();
448            let result = aggregate_partials(&partials, t, &ciphertext);
449            prop_assert!(result.is_err(), "below-threshold should fail");
450        }
451    }
452}