Skip to main content

confium_tc_ecies_p256/
lib.rs

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