Skip to main content

confium_tc/schemes/
mock.rs

1//! Mock threshold-signature scheme — `"mock-tc-sig"`.
2//!
3//! This is a deterministic, cryptographically meaningless scheme whose
4//! only purpose is to exercise the full [`crate::session::Session`]
5//! lifecycle (create → round → result) end-to-end. It proves the
6//! framework wiring is correct: the registry resolves the scheme, the
7//! session drives the rounds, the threshold property holds (any T of N
8//! parties produce identical output), and below-threshold coalitions
9//! fail.
10//!
11//! ## Protocol
12//!
13//! The "shared secret" is a fixed fake key baked into every party's
14//! share bytes. The "signature" is a deterministic function of the full
15//! party roster plus the signed message — it never depends on *which*
16//! coalition produced it, so any T-of-N produces the same bytes.
17//!
18//! Three rounds:
19//!
20//! - **Round 0** — broadcast: each party emits its `party_id` plus a
21//!   nonce derived deterministically from `(shared_key, party_id,
22//!   message)`. Determinism guarantees the nonce set is identical
23//!   across coalitions of the same party roster.
24//!
25//! - **Round 1** — broadcast: each party HMAC-SHA256-signs the sorted
26//!   nonce set with the shared key, then broadcasts the tag.
27//!
28//! - **Round 2** — complete: the party checks that at least T distinct
29//!   round-1 tags arrived (threshold enforcement), then rebuilds the
30//!   canonical signature by re-deriving every roster party's round-1
31//!   tag and concatenating them sorted by `party_id`. Because nonces
32//!   and the HMAC key are deterministic, this signature is identical on
33//!   every party regardless of coalition.
34
35use hmac::{Hmac, KeyInit, Mac};
36use sha2::{Digest, Sha256};
37
38use crate::Result;
39use crate::message::Message;
40use crate::registry::{RoundResult, SessionImpl, TcScheme, TcSchemeKind};
41use crate::session::SessionParams;
42
43/// Canonical scheme name advertised through the registry.
44pub const SCHEME_NAME: &str = "mock-tc-sig";
45
46/// HMAC keyed by the shared secret — the round-1 primitive.
47type HmacSha256 = Hmac<Sha256>;
48
49/// Mock threshold-signature scheme.
50///
51/// Stateless; all per-session state lives in [`MockTcSigSession`].
52pub struct MockTcSigScheme;
53
54impl TcScheme for MockTcSigScheme {
55    fn name(&self) -> &'static str {
56        SCHEME_NAME
57    }
58
59    fn kind(&self) -> TcSchemeKind {
60        TcSchemeKind::Signature
61    }
62
63    fn create_session(&self, params: &SessionParams) -> Result<Box<dyn SessionImpl>> {
64        let party_id = params.parties.get(params.this_party_idx)?.id.clone();
65        let threshold = params.threshold;
66        let message = params.message.clone().unwrap_or_default();
67        let roster_ids = params
68            .parties
69            .parties()
70            .iter()
71            .map(|p| p.id.clone())
72            .collect::<Vec<_>>();
73        // The shared secret is the party's share bytes (identical on
74        // every party in a real deployment). Default to a fixed
75        // well-known key when no share is supplied so tests don't need
76        // to fabricate one.
77        let shared_key = params
78            .local_share
79            .as_ref()
80            .map(|s| s.bytes().to_vec())
81            .unwrap_or_else(|| DEFAULT_SHARED_KEY.to_vec());
82
83        Ok(Box::new(MockTcSigSession {
84            party_id,
85            threshold,
86            roster_ids,
87            shared_key,
88            message,
89            round_done: 0,
90            collected_tags: 0,
91            signature: Vec::new(),
92        }))
93    }
94}
95
96// Register the scheme at link time so `Session::create("mock-tc-sig")`
97// resolves it through the framework registry.
98crate::register_tc_scheme!(MockTcSigScheme);
99
100/// The fixed fake "shared secret" used when no share is supplied.
101/// Every party in a coalition uses the same key — that is the entire
102/// premise of the mock.
103const DEFAULT_SHARED_KEY: &[u8] = b"confium-mock-tc-sig-shared-key";
104
105/// Per-party, per-session state for [`MockTcSigScheme`].
106struct MockTcSigSession {
107    /// Our canonical party id.
108    party_id: String,
109    /// Threshold T copied from session params.
110    threshold: u32,
111    /// Full roster of party ids — the canonical signature covers every
112    /// one of these, regardless of which coalition ran.
113    roster_ids: Vec<String>,
114    /// Shared HMAC key (same on every party).
115    shared_key: Vec<u8>,
116    /// Message being signed.
117    message: Vec<u8>,
118    /// How many `round` calls have run (0, then 1, 2, 3).
119    round_done: u8,
120    /// Distinct parties that contributed a round-1 tag. Drives the
121    /// threshold check in round 2.
122    collected_tags: usize,
123    /// Final signature bytes, populated in round 2.
124    signature: Vec<u8>,
125}
126
127impl MockTcSigSession {
128    /// Deterministically derive a party's nonce from the shared key,
129    /// its party id, and the message. The nonce is therefore identical
130    /// regardless of which coalition this party is part of.
131    fn derive_nonce(party_id: &str, shared_key: &[u8], message: &[u8]) -> Vec<u8> {
132        let mut hasher = Sha256::new();
133        hasher.update(b"mock-tc-sig-nonce");
134        hasher.update(shared_key);
135        hasher.update(party_id.as_bytes());
136        hasher.update(message);
137        hasher.finalize().to_vec()
138    }
139
140    /// The canonical round-1 blob every party HMACs: the sorted set of
141    /// `(party_id, nonce)` pairs from the full roster, followed by the
142    /// message. Identical on every party.
143    fn canonical_blob(roster_ids: &[String], shared_key: &[u8], message: &[u8]) -> Vec<u8> {
144        let mut entries: Vec<(String, Vec<u8>)> = roster_ids
145            .iter()
146            .map(|id| (id.clone(), Self::derive_nonce(id, shared_key, message)))
147            .collect();
148        entries.sort_by(|a, b| a.0.cmp(&b.0));
149        let mut blob = Vec::new();
150        for (id, nonce) in &entries {
151            blob.extend_from_slice(id.as_bytes());
152            blob.extend_from_slice(nonce);
153        }
154        blob.extend_from_slice(message);
155        blob
156    }
157
158    /// HMAC-SHA256 tag of the supplied data keyed with the shared secret.
159    fn hmac(shared_key: &[u8], data: &[u8]) -> Vec<u8> {
160        let mut mac = HmacSha256::new_from_slice(shared_key).expect("HMAC accepts any key length");
161        mac.update(data);
162        mac.finalize().into_bytes().to_vec()
163    }
164
165    /// Round 0: emit our nonce as a broadcast. No incoming messages on
166    /// the first round.
167    fn round0(&mut self) -> Result<RoundResult> {
168        let nonce = Self::derive_nonce(&self.party_id, &self.shared_key, &self.message);
169        let payload = frame(&self.party_id, &nonce);
170        let msg = Message::broadcast(&self.party_id, 1, payload);
171        Ok(RoundResult::new(vec![msg], false))
172    }
173
174    /// Round 1: compute the HMAC over the canonical nonce set and
175    /// broadcast the tag. Incoming nonces are ignored for the tag
176    /// computation (determinism makes them redundant) but the round
177    /// still consumes the slot in the protocol.
178    fn round1(&mut self, _incoming: &[Message]) -> Result<RoundResult> {
179        let blob = Self::canonical_blob(&self.roster_ids, &self.shared_key, &self.message);
180        let tag = Self::hmac(&self.shared_key, &blob);
181        let payload = frame(&self.party_id, &tag);
182        let msg = Message::broadcast(&self.party_id, 2, payload);
183        Ok(RoundResult::new(vec![msg], false))
184    }
185
186    /// Round 2: count distinct contributing parties, enforce the
187    /// threshold, and assemble the canonical signature.
188    fn round2(&mut self, incoming: &[Message]) -> Result<RoundResult> {
189        // Threshold enforcement: count distinct parties that sent a
190        // round-1 tag (plus ourselves).
191        let mut seen: std::collections::HashSet<String> = parse_frames(incoming)
192            .into_iter()
193            .map(|(id, _)| id)
194            .collect();
195        seen.insert(self.party_id.clone());
196        self.collected_tags = seen.len();
197
198        if (self.collected_tags as u32) < self.threshold {
199            return Err(crate::error::SchemeInternalSnafu { code: 0x1042u32 }.build());
200        }
201
202        // Canonical signature: every roster party's tag, sorted by id.
203        // Independent of the participating coalition.
204        let blob = Self::canonical_blob(&self.roster_ids, &self.shared_key, &self.message);
205        let tag = Self::hmac(&self.shared_key, &blob);
206        let mut sig = Vec::new();
207        sig.extend_from_slice(&tag);
208        self.signature = sig;
209        Ok(RoundResult::done())
210    }
211}
212
213impl SessionImpl for MockTcSigSession {
214    fn round(&mut self, incoming: &[Message]) -> Result<RoundResult> {
215        self.round_done = self.round_done.checked_add(1).ok_or_else(|| {
216            crate::error::RoundOverflowSnafu {
217                round: self.round_done,
218            }
219            .build()
220        })?;
221        match self.round_done {
222            1 => self.round0(),
223            2 => self.round1(incoming),
224            3 => self.round2(incoming),
225            other => Err(crate::error::RoundOverflowSnafu { round: other }.build()),
226        }
227    }
228
229    fn result(&self) -> Result<Vec<u8>> {
230        if self.round_done < 3 {
231            return Err(crate::error::SessionNotCompleteSnafu {}.build());
232        }
233        Ok(self.signature.clone())
234    }
235
236    fn destroy(&mut self) {
237        self.shared_key.fill(0);
238        self.message.fill(0);
239        self.signature.fill(0);
240    }
241}
242
243// ---------------------------------------------------------------------------
244// Wire format helpers — payload framing for the mock scheme's messages.
245// ---------------------------------------------------------------------------
246
247/// Frame a payload as `party_id_len:u8 | party_id | body`. Used for
248/// both round-0 nonces and round-1 tags since they share the shape.
249fn frame(party_id: &str, body: &[u8]) -> Vec<u8> {
250    let id = party_id.as_bytes();
251    debug_assert!(id.len() <= u8::MAX as usize, "party id fits in a byte tag");
252    let mut out = Vec::with_capacity(1 + id.len() + body.len());
253    out.push(id.len() as u8);
254    out.extend_from_slice(id);
255    out.extend_from_slice(body);
256    out
257}
258
259/// Parse framed payloads back into `(party_id, body)` pairs. Unknown or
260/// truncated frames are silently skipped — the mock is forgiving about
261/// the incoming shape so the test harness can mix in our own broadcasts.
262fn parse_frames(msgs: &[Message]) -> Vec<(String, Vec<u8>)> {
263    let mut out = Vec::with_capacity(msgs.len());
264    for m in msgs {
265        let p = &m.payload;
266        if p.is_empty() {
267            continue;
268        }
269        let len = p[0] as usize;
270        if p.len() < 1 + len {
271            continue;
272        }
273        let id = match std::str::from_utf8(&p[1..1 + len]) {
274            Ok(s) => s.to_string(),
275            Err(_) => continue,
276        };
277        out.push((id, p[1 + len..].to_vec()));
278    }
279    out
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::party::{Party, PartyList};
286    use crate::share::Share;
287
288    fn params(idx: usize, threshold: u32, message: &[u8]) -> SessionParams {
289        SessionParams {
290            scheme: SCHEME_NAME.to_string(),
291            parties: PartyList::from_parties(vec![
292                Party::inproc("alice"),
293                Party::inproc("bob"),
294                Party::inproc("carol"),
295            ]),
296            threshold,
297            this_party_idx: idx,
298            local_share: Some(Share::new(SCHEME_NAME, DEFAULT_SHARED_KEY.to_vec())),
299            message: Some(message.to_vec()),
300        }
301    }
302
303    #[test]
304    fn scheme_is_registered() {
305        let s = crate::registry::find(SCHEME_NAME);
306        assert!(s.is_some(), "mock-tc-sig must be registered at link time");
307        assert_eq!(s.unwrap().kind(), TcSchemeKind::Signature);
308    }
309
310    #[test]
311    fn nonce_is_deterministic_per_party() {
312        let a = MockTcSigSession::derive_nonce("alice", DEFAULT_SHARED_KEY, b"msg");
313        let b = MockTcSigSession::derive_nonce("alice", DEFAULT_SHARED_KEY, b"msg");
314        assert_eq!(a, b, "nonce must be deterministic for the same inputs");
315    }
316
317    #[test]
318    fn nonce_differs_per_party() {
319        let a = MockTcSigSession::derive_nonce("alice", DEFAULT_SHARED_KEY, b"msg");
320        let b = MockTcSigSession::derive_nonce("bob", DEFAULT_SHARED_KEY, b"msg");
321        assert_ne!(a, b, "different parties produce different nonces");
322    }
323
324    #[test]
325    fn canonical_blob_is_independent_of_roster_order() {
326        let mut a = vec!["alice".to_string(), "bob".to_string(), "carol".to_string()];
327        let mut b = vec!["carol".to_string(), "alice".to_string(), "bob".to_string()];
328        let blob_a = MockTcSigSession::canonical_blob(&a, DEFAULT_SHARED_KEY, b"msg");
329        let blob_b = MockTcSigSession::canonical_blob(&b, DEFAULT_SHARED_KEY, b"msg");
330        assert_eq!(blob_a, blob_b, "blob must sort the roster canonically");
331        a.sort();
332        b.sort();
333        assert_eq!(a, b);
334    }
335
336    #[test]
337    fn frame_round_trip() {
338        let f = frame("alice", &[1, 2, 3]);
339        let parsed = parse_frames(&[Message::broadcast("alice", 1, f.clone())]);
340        assert_eq!(parsed.len(), 1);
341        assert_eq!(parsed[0].0, "alice");
342        assert_eq!(parsed[0].1, vec![1, 2, 3]);
343    }
344
345    #[test]
346    fn parse_skips_truncated() {
347        let bad = vec![5, b'a'];
348        let parsed = parse_frames(&[Message::broadcast("x", 1, bad)]);
349        assert!(parsed.is_empty(), "truncated frame must be skipped");
350    }
351
352    #[test]
353    fn create_session_uses_default_key_when_no_share() {
354        let mut p = params(0, 2, b"msg");
355        p.local_share = None;
356        let s = MockTcSigScheme.create_session(&p);
357        assert!(s.is_ok(), "session creates without a share");
358    }
359}