Skip to main content

confium_test_harness/
byzantine.rs

1//! Byzantine peer behavior simulation.
2//!
3//! The harness wraps the in-process transport with a
4//! [`ByzantineTransport`] that intercepts every [`Message`] flowing
5//! between parties and applies the configured [`PeerBehavior`] for the
6//! sender. This is what NIST uses to probe a candidate scheme's fault
7//! tolerance: does it complete, or does it abort with a proof of
8//! misbehavior?
9//!
10//! Behaviors are keyed by party id (the same canonical id used in
11//! `confium-tc::party::Party`). A sender with no configured behavior is
12//! treated as [`PeerBehavior::Honest`].
13//!
14//! The wrapper is transport-agnostic: it operates on [`Message`] values
15//! that the runner hands it. Wiring it to a `confium-net::Transport`
16//! byte stream is the runner's job (it serializes each `Message` before
17//! send and deserializes on recv); this layer sees the structured
18//! [`Message`] and decides what (if anything) to forward.
19
20use std::collections::HashMap;
21
22use confium_tc::Message;
23
24/// What a single party does to its outgoing messages.
25///
26/// Mirrors the `type` strings from the test vector schema in
27/// `TODO.roadmap/09-nist-evaluation-harness.md`:
28///
29/// - `honest` — pass messages through unchanged
30/// - `byzantine-drop` — silently drop all messages from one round
31/// - `byzantine-tamper` — flip a bit in every payload
32/// - `byzantine-replay` — duplicate the previous round's messages
33/// - `byzantine-malicious` — substitute a crafted payload
34/// - `byzantine-collusion` — alias for `malicious`; the runner treats
35///   any group of N-1 colluding peers as N-1 individual malicious
36///   senders
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum PeerBehavior {
39    Honest,
40    Drop,
41    Tamper,
42    Replay,
43    Malicious,
44    Collusion,
45}
46
47impl PeerBehavior {
48    /// Map a vector's `type = "..."` string to a behavior. Returns
49    /// `None` for an unknown tag so the vector parser can surface a
50    /// clear error.
51    pub fn from_tag(tag: &str) -> Option<Self> {
52        match tag {
53            "honest" => Some(PeerBehavior::Honest),
54            "byzantine-drop" => Some(PeerBehavior::Drop),
55            "byzantine-tamper" => Some(PeerBehavior::Tamper),
56            "byzantine-replay" => Some(PeerBehavior::Replay),
57            "byzantine-malicious" => Some(PeerBehavior::Malicious),
58            "byzantine-collusion" => Some(PeerBehavior::Collusion),
59            _ => None,
60        }
61    }
62
63    /// The canonical vector tag for this behavior.
64    pub fn as_tag(self) -> &'static str {
65        match self {
66            PeerBehavior::Honest => "honest",
67            PeerBehavior::Drop => "byzantine-drop",
68            PeerBehavior::Tamper => "byzantine-tamper",
69            PeerBehavior::Replay => "byzantine-replay",
70            PeerBehavior::Malicious => "byzantine-malicious",
71            PeerBehavior::Collusion => "byzantine-collusion",
72        }
73    }
74}
75
76/// One entry in a vector's `[[peer_behavior]]` array: which party, and
77/// what they do. `drop_round` selects the round a `byzantine-drop`
78/// party goes silent in (`None` = drop in round 1, which is the
79/// spec's default example).
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct BehaviorSpec {
82    pub party_id: String,
83    pub behavior: PeerBehavior,
84    /// Round to drop messages in (Drop behavior only). Defaults to 1.
85    pub drop_round: Option<u8>,
86}
87
88/// Message-level interceptor that applies per-party behaviors.
89///
90/// Construct with [`ByzantineTransport::new`] (or
91/// [`ByzantineTransport::from_specs`]) and call
92/// [`ByzantineTransport::route`] with each batch of outgoing messages
93/// for one round. The returned `Vec<Message>` is what the recipients
94/// actually see.
95///
96/// The wrapper owns a small per-party history so the `Replay` behavior
97/// can resurface the previous round's traffic.
98#[derive(Debug, Default)]
99pub struct ByzantineTransport {
100    behaviors: HashMap<String, BehaviorSpec>,
101    /// Last round of messages each party sent, for `Replay`.
102    last_sent: HashMap<String, Vec<Message>>,
103}
104
105impl ByzantineTransport {
106    pub fn new() -> Self {
107        ByzantineTransport::default()
108    }
109
110    pub fn from_specs(specs: Vec<BehaviorSpec>) -> Self {
111        let mut behaviors = HashMap::new();
112        for spec in specs {
113            behaviors.insert(spec.party_id.clone(), spec);
114        }
115        ByzantineTransport {
116            behaviors,
117            last_sent: HashMap::new(),
118        }
119    }
120
121    /// Configure (or replace) the behavior for `party_id`.
122    pub fn set(&mut self, spec: BehaviorSpec) {
123        self.behaviors.insert(spec.party_id.clone(), spec);
124    }
125
126    /// Look up the configured behavior for a party; defaults to Honest.
127    pub fn behavior_for(&self, party_id: &str) -> PeerBehavior {
128        self.behaviors
129            .get(party_id)
130            .map(|s| s.behavior)
131            .unwrap_or(PeerBehavior::Honest)
132    }
133
134    /// Apply every party's behavior to a batch of outgoing messages
135    /// from one round, returning the messages recipients will actually
136    /// observe. Ordering is preserved within each party's contribution.
137    pub fn route(&mut self, outgoing: &[Message]) -> Vec<Message> {
138        // Group by sender so each party's behavior sees its own prior
139        // round as a unit.
140        let mut by_sender: HashMap<&str, Vec<&Message>> = HashMap::new();
141        for msg in outgoing {
142            by_sender
143                .entry(msg.from_party_id.as_str())
144                .or_default()
145                .push(msg);
146        }
147
148        let mut delivered = Vec::with_capacity(outgoing.len());
149        for (sender, msgs) in by_sender {
150            let behavior = self.behavior_for(sender);
151            // Stash this round's honest view for next round's replay
152            // before we consume `msgs`.
153            let honest_view: Vec<Message> = msgs.iter().map(|m| (*m).clone()).collect();
154            let delivered_for_sender = match behavior {
155                PeerBehavior::Honest => honest_view.clone(),
156                PeerBehavior::Drop => {
157                    let spec = self.behaviors.get(sender);
158                    let target_round = spec.and_then(|s| s.drop_round).unwrap_or(1);
159                    if msgs.iter().any(|m| m.round == target_round) {
160                        // This round is the drop target — emit nothing.
161                        Vec::new()
162                    } else {
163                        honest_view.clone()
164                    }
165                }
166                PeerBehavior::Tamper => msgs.iter().map(|m| tamper_message(m)).collect::<Vec<_>>(),
167                PeerBehavior::Malicious | PeerBehavior::Collusion => msgs
168                    .iter()
169                    .map(|m| malicious_message(m))
170                    .collect::<Vec<_>>(),
171                PeerBehavior::Replay => {
172                    if let Some(prev) = self.last_sent.get(sender) {
173                        prev.clone()
174                    } else {
175                        // First round: nothing to replay, fall through
176                        // honest so the protocol can at least start.
177                        honest_view.clone()
178                    }
179                }
180            };
181            self.last_sent.insert(sender.to_string(), honest_view);
182            delivered.extend(delivered_for_sender);
183        }
184        delivered
185    }
186}
187
188/// Flip the low bit of the first payload byte. Empty payloads stay
189/// empty (nothing to tamper with) — schemes that care will reject the
190/// resulting malformed message.
191fn tamper_message(msg: &Message) -> Message {
192    let mut payload = msg.payload.clone();
193    if !payload.is_empty() {
194        payload[0] ^= 0x01;
195    }
196    Message {
197        from_party_id: msg.from_party_id.clone(),
198        to_party_id: msg.to_party_id.clone(),
199        round: msg.round,
200        payload,
201    }
202}
203
204/// Substitute a recognizable bogus payload so a correct scheme rejects
205/// the message. We keep the envelope (from/to/round) so the harness can
206/// attribute the misbehavior to the right party.
207fn malicious_message(msg: &Message) -> Message {
208    Message {
209        from_party_id: msg.from_party_id.clone(),
210        to_party_id: msg.to_party_id.clone(),
211        round: msg.round,
212        payload: b"BYZANTINE-MALICIOUS-PAYLOAD".to_vec(),
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn msg(from: &str, to: Option<&str>, round: u8, payload: &[u8]) -> Message {
221        Message {
222            from_party_id: from.to_string(),
223            to_party_id: to.map(|s| s.to_string()),
224            round,
225            payload: payload.to_vec(),
226        }
227    }
228
229    #[test]
230    fn honest_passes_through_unchanged() {
231        let mut tport = ByzantineTransport::new();
232        tport.set(BehaviorSpec {
233            party_id: "alice".into(),
234            behavior: PeerBehavior::Honest,
235            drop_round: None,
236        });
237        let m = msg("alice", None, 1, &[0xAA, 0xBB]);
238        let out = tport.route(std::slice::from_ref(&m));
239        assert_eq!(out, vec![m]);
240    }
241
242    #[test]
243    fn unconfigured_party_defaults_honest() {
244        let mut tport = ByzantineTransport::new();
245        let m = msg("ghost", None, 1, &[1]);
246        let out = tport.route(std::slice::from_ref(&m));
247        assert_eq!(out.len(), 1);
248        assert_eq!(out[0].payload, vec![1]);
249    }
250
251    #[test]
252    fn drop_actually_drops_target_round() {
253        let mut tport = ByzantineTransport::new();
254        tport.set(BehaviorSpec {
255            party_id: "eve".into(),
256            behavior: PeerBehavior::Drop,
257            drop_round: Some(2),
258        });
259        // Round 1 — passes.
260        let r1 = msg("eve", None, 1, &[10]);
261        assert_eq!(tport.route(&[r1]).len(), 1);
262        // Round 2 — dropped.
263        let r2 = msg("eve", None, 2, &[20]);
264        assert!(
265            tport.route(&[r2]).is_empty(),
266            "round-2 message must be dropped"
267        );
268        // Round 3 — passes again.
269        let r3 = msg("eve", None, 3, &[30]);
270        assert_eq!(tport.route(&[r3]).len(), 1);
271    }
272
273    #[test]
274    fn drop_defaults_to_round_one_when_unspecified() {
275        let mut tport = ByzantineTransport::new();
276        tport.set(BehaviorSpec {
277            party_id: "eve".into(),
278            behavior: PeerBehavior::Drop,
279            drop_round: None,
280        });
281        let r1 = msg("eve", None, 1, &[1]);
282        assert!(tport.route(&[r1]).is_empty());
283    }
284
285    #[test]
286    fn tamper_flips_a_bit() {
287        let mut tport = ByzantineTransport::new();
288        tport.set(BehaviorSpec {
289            party_id: "mallory".into(),
290            behavior: PeerBehavior::Tamper,
291            drop_round: None,
292        });
293        let original = msg("mallory", None, 1, &[0b0000_0000, 0xFF]);
294        let out = tport.route(std::slice::from_ref(&original));
295        assert_eq!(out.len(), 1);
296        // First byte flipped, second untouched.
297        assert_eq!(out[0].payload, vec![0b0000_0001, 0xFF]);
298        assert_ne!(out[0].payload, original.payload);
299    }
300
301    #[test]
302    fn tamper_leaves_empty_payload_untouched() {
303        let mut tport = ByzantineTransport::from_specs(vec![BehaviorSpec {
304            party_id: "m".into(),
305            behavior: PeerBehavior::Tamper,
306            drop_round: None,
307        }]);
308        let empty = msg("m", None, 1, &[]);
309        let out = tport.route(std::slice::from_ref(&empty));
310        assert!(out[0].payload.is_empty());
311    }
312
313    #[test]
314    fn malicious_substitutes_payload() {
315        let mut tport = ByzantineTransport::new();
316        tport.set(BehaviorSpec {
317            party_id: "mallory".into(),
318            behavior: PeerBehavior::Malicious,
319            drop_round: None,
320        });
321        let original = msg("mallory", Some("alice"), 2, &[0xDE, 0xAD]);
322        let out = tport.route(&[original]);
323        assert_eq!(out[0].payload, b"BYZANTINE-MALICIOUS-PAYLOAD");
324        // Envelope preserved so attribution still works.
325        assert_eq!(out[0].from_party_id, "mallory");
326        assert_eq!(out[0].to_party_id.as_deref(), Some("alice"));
327        assert_eq!(out[0].round, 2);
328    }
329
330    #[test]
331    fn collusion_behaves_like_malicious() {
332        let mut tport = ByzantineTransport::from_specs(vec![BehaviorSpec {
333            party_id: "c".into(),
334            behavior: PeerBehavior::Collusion,
335            drop_round: None,
336        }]);
337        let out = tport.route(&[msg("c", None, 1, &[0x01])]);
338        assert_eq!(out[0].payload, b"BYZANTINE-MALICIOUS-PAYLOAD");
339    }
340
341    #[test]
342    fn replay_re_sends_previous_round() {
343        let mut tport = ByzantineTransport::new();
344        tport.set(BehaviorSpec {
345            party_id: "ralph".into(),
346            behavior: PeerBehavior::Replay,
347            drop_round: None,
348        });
349        // Round 1: nothing cached, falls back honest.
350        let r1 = msg("ralph", None, 1, &[11]);
351        let out1 = tport.route(std::slice::from_ref(&r1));
352        assert_eq!(out1, vec![r1.clone()]);
353        // Round 2: replays round 1.
354        let r2 = msg("ralph", None, 2, &[22]);
355        let out2 = tport.route(&[r2]);
356        assert_eq!(out2.len(), 1);
357        assert_eq!(out2[0].payload, vec![11]);
358        assert_eq!(out2[0].round, 1, "replayed message keeps old round number");
359    }
360
361    #[test]
362    fn behavior_round_trips_through_tags() {
363        for behavior in [
364            PeerBehavior::Honest,
365            PeerBehavior::Drop,
366            PeerBehavior::Tamper,
367            PeerBehavior::Replay,
368            PeerBehavior::Malicious,
369            PeerBehavior::Collusion,
370        ] {
371            let tag = behavior.as_tag();
372            assert_eq!(PeerBehavior::from_tag(tag), Some(behavior));
373        }
374    }
375
376    #[test]
377    fn unknown_tag_yields_none() {
378        assert!(PeerBehavior::from_tag("byzantine-shenanigans").is_none());
379    }
380}