Skip to main content

confium_tc_core/
share_adapter.rs

1//! Cross-scheme share adapter — normalized intermediate representation.
2//!
3//! Different threshold schemes (CMP20, FROST-P256, GG18) use different
4//! share types internally. The `NormalizedShare` struct provides a
5//! scheme-agnostic format for serialization, migration, and interop.
6//!
7//! Each scheme crate implements `ShareAdapter` for its own share type,
8//! converting to/from `NormalizedShare`.
9
10use serde::{Deserialize, Serialize};
11
12/// Scheme-agnostic share representation. Any P-256-based threshold
13/// scheme share can be normalized to this format.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct NormalizedShare {
16    /// Source scheme (e.g., "CMP20", "FROST-P256", "GG18").
17    pub scheme: String,
18    /// Quorum identifier.
19    pub quorum_id: String,
20    /// 1-based party index.
21    pub party_idx: u32,
22    /// Threshold T.
23    pub threshold: u32,
24    /// Total party count N.
25    pub party_count: u32,
26    /// Share scalar (32 bytes, big-endian).
27    pub scalar_hex: String,
28    /// Joint public key (SEC1 uncompressed, hex).
29    pub public_key_hex: String,
30}
31
32/// Errors during share normalization.
33#[derive(Debug, thiserror::Error)]
34pub enum AdapterError {
35    /// Invalid scalar bytes.
36    #[error("invalid scalar: {0}")]
37    InvalidScalar(String),
38    /// Invalid public key bytes.
39    #[error("invalid public key: {0}")]
40    InvalidPublicKey(String),
41    /// Field mismatch.
42    #[error("field mismatch: {0}")]
43    FieldMismatch(String),
44}
45
46impl NormalizedShare {
47    /// Create a new normalized share.
48    pub fn new(
49        scheme: &str,
50        quorum_id: &str,
51        party_idx: u32,
52        threshold: u32,
53        party_count: u32,
54        scalar_bytes: &[u8],
55        public_key_bytes: &[u8],
56    ) -> Result<Self, AdapterError> {
57        if scalar_bytes.len() != 32 {
58            return Err(AdapterError::InvalidScalar(format!(
59                "expected 32 bytes, got {}",
60                scalar_bytes.len()
61            )));
62        }
63        Ok(Self {
64            scheme: scheme.into(),
65            quorum_id: quorum_id.into(),
66            party_idx,
67            threshold,
68            party_count,
69            scalar_hex: hex::encode(scalar_bytes),
70            public_key_hex: hex::encode(public_key_bytes),
71        })
72    }
73
74    /// Get the share scalar as bytes.
75    pub fn scalar_bytes(&self) -> Result<Vec<u8>, AdapterError> {
76        hex::decode(&self.scalar_hex).map_err(|e| AdapterError::InvalidScalar(e.to_string()))
77    }
78
79    /// Get the public key as bytes.
80    pub fn public_key_bytes(&self) -> Result<Vec<u8>, AdapterError> {
81        hex::decode(&self.public_key_hex).map_err(|e| AdapterError::InvalidPublicKey(e.to_string()))
82    }
83
84    /// Check if two shares are from the same quorum and have the same
85    /// joint public key (i.e., they're compatible for aggregation).
86    pub fn is_compatible_with(&self, other: &NormalizedShare) -> bool {
87        self.quorum_id == other.quorum_id
88            && self.public_key_hex == other.public_key_hex
89            && self.threshold == other.threshold
90            && self.party_count == other.party_count
91    }
92
93    /// Serialize to JSON.
94    pub fn to_json(&self) -> Result<String, serde_json::Error> {
95        serde_json::to_string_pretty(self)
96    }
97
98    /// Deserialize from JSON.
99    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
100        serde_json::from_str(json)
101    }
102
103    /// Rename the scheme. Used when migrating a share from one scheme
104    /// to another (e.g., "CMP20" → "FROST-P256" after re-sharing).
105    pub fn reclassify(mut self, new_scheme: &str) -> Self {
106        self.scheme = new_scheme.into();
107        self
108    }
109}
110
111/// Trait for converting scheme-specific share types to/from the
112/// normalized representation.
113pub trait ShareAdapter {
114    /// Convert to a normalized share.
115    fn to_normalized(&self, quorum_id: &str) -> Result<NormalizedShare, AdapterError>;
116
117    /// Convert from a normalized share. Returns `Err` if the scheme
118    /// doesn't match.
119    fn from_normalized(normalized: &NormalizedShare) -> Result<Self, AdapterError>
120    where
121        Self: Sized;
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    fn make_share(scheme: &str) -> NormalizedShare {
129        NormalizedShare::new(scheme, "quorum-alpha", 3, 2, 5, &[0xAA; 32], &[0x04; 65]).unwrap()
130    }
131
132    #[test]
133    fn new_validates_scalar_length() {
134        assert!(NormalizedShare::new("CMP20", "q", 1, 2, 3, &[0; 31], &[0; 65]).is_err());
135        assert!(NormalizedShare::new("CMP20", "q", 1, 2, 3, &[0; 32], &[0; 65]).is_ok());
136    }
137
138    #[test]
139    fn scalar_bytes_round_trips() {
140        let share = make_share("CMP20");
141        let bytes = share.scalar_bytes().unwrap();
142        assert_eq!(bytes, vec![0xAA; 32]);
143    }
144
145    #[test]
146    fn public_key_bytes_round_trips() {
147        let share = make_share("CMP20");
148        let bytes = share.public_key_bytes().unwrap();
149        assert_eq!(bytes, vec![0x04; 65]);
150    }
151
152    #[test]
153    fn is_compatible_same_quorum() {
154        let a = make_share("CMP20");
155        let b = make_share("CMP20");
156        assert!(a.is_compatible_with(&b));
157    }
158
159    #[test]
160    fn is_incompatible_different_quorum() {
161        let a = make_share("CMP20");
162        let mut b = make_share("CMP20");
163        b.quorum_id = "different".into();
164        assert!(!a.is_compatible_with(&b));
165    }
166
167    #[test]
168    fn is_incompatible_different_threshold() {
169        let a = make_share("CMP20");
170        let mut b = make_share("CMP20");
171        b.threshold = 3;
172        assert!(!a.is_compatible_with(&b));
173    }
174
175    #[test]
176    fn json_round_trip() {
177        let share = make_share("CMP20");
178        let json = share.to_json().unwrap();
179        let recovered = NormalizedShare::from_json(&json).unwrap();
180        assert_eq!(share, recovered);
181    }
182
183    #[test]
184    fn reclassify_changes_scheme() {
185        let share = make_share("CMP20");
186        let reclassified = share.reclassify("FROST-P256");
187        assert_eq!(reclassified.scheme, "FROST-P256");
188    }
189
190    #[test]
191    fn reclassify_preserves_other_fields() {
192        let share = make_share("CMP20");
193        let reclassified = share.reclassify("GG18");
194        assert_eq!(reclassified.quorum_id, "quorum-alpha");
195        assert_eq!(reclassified.party_idx, 3);
196        assert_eq!(reclassified.threshold, 2);
197    }
198}