Skip to main content

llmtxt_core/
bft.rs

1//! Byzantine Fault Tolerant (BFT) consensus primitives for LLMtxt.
2//!
3//! # Background
4//!
5//! A system of `n` validators can tolerate `f` Byzantine (malicious or faulty)
6//! validators if and only if `n >= 3f + 1`. The minimum quorum required to
7//! reach consensus is `2f + 1` (a "supermajority").
8//!
9//! This module implements:
10//! * [`bft_quorum`] — compute the minimum quorum for a given `n` and `f`.
11//! * [`bft_max_faults`] — compute the maximum tolerable faults for `n`.
12//! * [`bft_check`] — check whether a given vote count satisfies the quorum.
13//! * [`hash_chain_extend`] — extend a tamper-evident hash chain with a new event.
14//! * [`verify_chain`] — verify the integrity of an entire chain of events.
15
16use sha2::{Digest, Sha256};
17
18// ── BFT Quorum Math ──────────────────────────────────────────────
19
20/// Compute the minimum quorum (number of votes) needed for BFT consensus.
21///
22/// Formula: `quorum = 2f + 1` where `f` is the maximum number of Byzantine faults.
23///
24/// # Arguments
25/// * `_n` — total number of validators (informational; used for validation)
26/// * `f`  — maximum number of Byzantine faults to tolerate
27///
28/// # Returns
29/// The minimum number of approvals required: `2f + 1`.
30///
31/// # Panics
32/// Does not panic. Returns 1 when `f == 0`.
33///
34/// # Example
35/// ```
36/// use llmtxt_core::bft::bft_quorum;
37/// assert_eq!(bft_quorum(3, 1), 3); // 2*1+1 = 3 out of 3 validators
38/// assert_eq!(bft_quorum(7, 2), 5); // 2*2+1 = 5 out of 7 validators
39/// assert_eq!(bft_quorum(1, 0), 1); // 2*0+1 = 1 (no Byzantine tolerance)
40/// ```
41pub fn bft_quorum(_n: u32, f: u32) -> u32 {
42    2 * f + 1
43}
44
45/// Compute the maximum number of Byzantine faults `n` validators can tolerate.
46///
47/// Formula: `f = (n - 1) / 3` (integer division).
48///
49/// # Example
50/// ```
51/// use llmtxt_core::bft::bft_max_faults;
52/// assert_eq!(bft_max_faults(3), 0); // 3 validators: f=0 (need 4 for f=1)
53/// assert_eq!(bft_max_faults(4), 1); // 4 validators: f=1
54/// assert_eq!(bft_max_faults(7), 2); // 7 validators: f=2
55/// ```
56pub fn bft_max_faults(n: u32) -> u32 {
57    if n == 0 {
58        return 0;
59    }
60    (n - 1) / 3
61}
62
63/// Check whether `votes` satisfies the BFT quorum for fault tolerance `f`.
64///
65/// Returns `true` when `votes >= 2f + 1`.
66///
67/// # Example
68/// ```
69/// use llmtxt_core::bft::bft_check;
70/// assert!(bft_check(3, 1));  // 3 >= 2*1+1: quorum reached
71/// assert!(!bft_check(2, 1)); // 2 < 3: quorum not reached
72/// ```
73pub fn bft_check(votes: u32, f: u32) -> bool {
74    votes >= bft_quorum(0, f)
75}
76
77// ── Hash Chain ───────────────────────────────────────────────────
78
79/// A single event in a tamper-evident hash chain.
80///
81/// Each event records the hash of the previous event (or a sentinel for the
82/// first event) so that any tampering with a stored event is detectable by
83/// recomputing the chain.
84#[derive(Debug, Clone)]
85pub struct ChainedEvent {
86    /// SHA-256 of the preceding event's hash + this event's bytes.
87    /// For the first event in a chain, this is `SHA-256(event_bytes)`.
88    pub chain_hash: [u8; 32],
89    /// The raw event payload (JSON, binary, etc.) — content is opaque.
90    pub event_bytes: Vec<u8>,
91    /// Hash of the previous event's `chain_hash`, or `[0u8; 32]` for first.
92    pub prev_hash: [u8; 32],
93}
94
95/// Extend a tamper-evident hash chain with a new event.
96///
97/// Computes: `chain_hash = SHA-256(prev_hash || event_bytes)`.
98///
99/// # Arguments
100/// * `prev_hash`   — 32-byte hash of the previous event (use `[0u8; 32]` for the first event)
101/// * `event_bytes` — raw event payload bytes
102///
103/// # Returns
104/// 32-byte chain hash for the new event.
105///
106/// # Example
107/// ```
108/// use llmtxt_core::bft::hash_chain_extend;
109/// let genesis_prev = [0u8; 32];
110/// let h1 = hash_chain_extend(&genesis_prev, b"first event");
111/// let h2 = hash_chain_extend(&h1, b"second event");
112/// assert_ne!(h1, h2);
113/// assert_ne!(h2, [0u8; 32]);
114/// ```
115pub fn hash_chain_extend(prev_hash: &[u8; 32], event_bytes: &[u8]) -> [u8; 32] {
116    let mut hasher = Sha256::new();
117    hasher.update(prev_hash);
118    hasher.update(event_bytes);
119    hasher.finalize().into()
120}
121
122/// Verify the integrity of a chain of events.
123///
124/// Recomputes each event's `chain_hash` from its `prev_hash` and `event_bytes`,
125/// comparing against the stored `chain_hash`. Returns `false` as soon as a
126/// mismatch is found.
127///
128/// The first event's `prev_hash` should be `[0u8; 32]` (the genesis sentinel).
129///
130/// # Returns
131/// `true` if the entire chain is intact, `false` if any event was tampered with.
132///
133/// # Example
134/// ```
135/// use llmtxt_core::bft::{ChainedEvent, hash_chain_extend, verify_chain};
136///
137/// let prev = [0u8; 32];
138/// let bytes1 = b"event one".to_vec();
139/// let h1 = hash_chain_extend(&prev, &bytes1);
140/// let bytes2 = b"event two".to_vec();
141/// let h2 = hash_chain_extend(&h1, &bytes2);
142///
143/// let chain = vec![
144///     ChainedEvent { chain_hash: h1, event_bytes: bytes1, prev_hash: prev },
145///     ChainedEvent { chain_hash: h2, event_bytes: bytes2.clone(), prev_hash: h1 },
146/// ];
147/// assert!(verify_chain(&chain));
148///
149/// // Tamper with the second event
150/// let mut bad_chain = chain.clone();
151/// bad_chain[1].event_bytes = b"TAMPERED".to_vec();
152/// assert!(!verify_chain(&bad_chain));
153/// ```
154pub fn verify_chain(events: &[ChainedEvent]) -> bool {
155    for event in events {
156        let expected = hash_chain_extend(&event.prev_hash, &event.event_bytes);
157        if expected != event.chain_hash {
158            return false;
159        }
160    }
161    true
162}
163
164// ── WASM exports ─────────────────────────────────────────────────
165
166/// WASM: compute BFT quorum for fault count `f`.
167///
168/// Returns `2f + 1` as a u32.
169#[cfg(feature = "wasm")]
170#[wasm_bindgen::prelude::wasm_bindgen]
171pub fn bft_quorum_wasm(n: u32, f: u32) -> u32 {
172    bft_quorum(n, f)
173}
174
175/// WASM: compute hash chain extension.
176///
177/// `prev_hash_hex` — 64-char lowercase hex of the 32-byte previous hash.
178/// `event_json`    — UTF-8 event payload string.
179///
180/// Returns 64-char lowercase hex of the new chain hash, or `{"error":"..."}`.
181#[cfg(feature = "wasm")]
182#[wasm_bindgen::prelude::wasm_bindgen]
183pub fn hash_chain_extend_wasm(prev_hash_hex: &str, event_json: &str) -> String {
184    let Ok(prev_bytes) = hex::decode(prev_hash_hex) else {
185        return r#"{"error":"invalid prev_hash_hex"}"#.to_string();
186    };
187    let Ok(prev_arr): Result<[u8; 32], _> = prev_bytes.try_into() else {
188        return r#"{"error":"prev_hash must be 32 bytes"}"#.to_string();
189    };
190    let hash = hash_chain_extend(&prev_arr, event_json.as_bytes());
191    hex::encode(hash)
192}
193
194// ── Tests ─────────────────────────────────────────────────────────
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_bft_quorum_basic() {
202        // f=0: quorum=1 (trivial consensus, no Byzantine tolerance)
203        assert_eq!(bft_quorum(1, 0), 1);
204        // f=1: quorum=3 (classic 3f+1=4, but quorum is 2f+1=3)
205        assert_eq!(bft_quorum(4, 1), 3);
206        // f=2: quorum=5
207        assert_eq!(bft_quorum(7, 2), 5);
208        // f=3: quorum=7
209        assert_eq!(bft_quorum(10, 3), 7);
210    }
211
212    #[test]
213    fn test_bft_max_faults() {
214        assert_eq!(bft_max_faults(0), 0);
215        assert_eq!(bft_max_faults(1), 0);
216        assert_eq!(bft_max_faults(3), 0); // 3 needs f=1 → need n=4
217        assert_eq!(bft_max_faults(4), 1);
218        assert_eq!(bft_max_faults(7), 2);
219        assert_eq!(bft_max_faults(10), 3);
220    }
221
222    #[test]
223    fn test_bft_check() {
224        // f=1: quorum=3
225        assert!(bft_check(3, 1));
226        assert!(bft_check(4, 1));
227        assert!(!bft_check(2, 1));
228        assert!(!bft_check(1, 1));
229
230        // f=2: quorum=5
231        assert!(bft_check(5, 2));
232        assert!(!bft_check(4, 2));
233
234        // f=0: quorum=1 (anyone's vote counts)
235        assert!(bft_check(1, 0));
236        assert!(!bft_check(0, 0));
237    }
238
239    #[test]
240    fn test_hash_chain_extend_deterministic() {
241        let prev = [0u8; 32];
242        let h1 = hash_chain_extend(&prev, b"event one");
243        let h2 = hash_chain_extend(&prev, b"event one");
244        assert_eq!(h1, h2, "same inputs must produce same hash");
245    }
246
247    #[test]
248    fn test_hash_chain_extend_unique() {
249        let prev = [0u8; 32];
250        let h1 = hash_chain_extend(&prev, b"event one");
251        let h2 = hash_chain_extend(&prev, b"event two");
252        assert_ne!(h1, h2);
253    }
254
255    #[test]
256    fn test_hash_chain_extend_chaining() {
257        let prev = [0u8; 32];
258        let h1 = hash_chain_extend(&prev, b"event one");
259        let h2 = hash_chain_extend(&h1, b"event two");
260        // h2 depends on h1 — changing the order changes the result
261        let h2_swapped = hash_chain_extend(&prev, b"event two");
262        assert_ne!(h2, h2_swapped);
263    }
264
265    #[test]
266    fn test_verify_chain_valid() {
267        let prev = [0u8; 32];
268        let bytes1 = b"approval:agent-1:APPROVED".to_vec();
269        let h1 = hash_chain_extend(&prev, &bytes1);
270        let bytes2 = b"approval:agent-2:APPROVED".to_vec();
271        let h2 = hash_chain_extend(&h1, &bytes2);
272        let bytes3 = b"approval:agent-3:APPROVED".to_vec();
273        let h3 = hash_chain_extend(&h2, &bytes3);
274
275        let chain = vec![
276            ChainedEvent {
277                chain_hash: h1,
278                event_bytes: bytes1,
279                prev_hash: prev,
280            },
281            ChainedEvent {
282                chain_hash: h2,
283                event_bytes: bytes2,
284                prev_hash: h1,
285            },
286            ChainedEvent {
287                chain_hash: h3,
288                event_bytes: bytes3,
289                prev_hash: h2,
290            },
291        ];
292
293        assert!(verify_chain(&chain), "valid chain must verify");
294    }
295
296    #[test]
297    fn test_verify_chain_tampered_payload() {
298        let prev = [0u8; 32];
299        let bytes1 = b"approval:agent-1:APPROVED".to_vec();
300        let h1 = hash_chain_extend(&prev, &bytes1);
301
302        let mut chain = vec![ChainedEvent {
303            chain_hash: h1,
304            event_bytes: bytes1,
305            prev_hash: prev,
306        }];
307
308        // Tamper with the payload bytes
309        chain[0].event_bytes = b"approval:agent-1:REJECTED".to_vec();
310        assert!(
311            !verify_chain(&chain),
312            "tampered payload must fail verification"
313        );
314    }
315
316    #[test]
317    fn test_verify_chain_tampered_hash() {
318        let prev = [0u8; 32];
319        let bytes1 = b"approval:agent-1:APPROVED".to_vec();
320        let h1 = hash_chain_extend(&prev, &bytes1);
321
322        let mut chain = vec![ChainedEvent {
323            chain_hash: h1,
324            event_bytes: bytes1,
325            prev_hash: prev,
326        }];
327
328        // Tamper with the stored chain hash
329        chain[0].chain_hash[0] ^= 0xFF;
330        assert!(
331            !verify_chain(&chain),
332            "tampered chain_hash must fail verification"
333        );
334    }
335
336    #[test]
337    fn test_verify_chain_empty() {
338        // Empty chain is trivially valid
339        assert!(verify_chain(&[]));
340    }
341
342    #[test]
343    fn test_bft_adversarial_3honest_2byzantine() {
344        // Adversarial scenario: 5 total agents, f=1 (BFT quorum = 3)
345        // 3 honest agents vote APPROVED, 2 Byzantine agents vote REJECTED
346        // Result: APPROVED wins because 3 >= 2*1+1 = 3
347        let f = 1u32;
348        let honest_votes = 3u32;
349        let byzantine_votes = 2u32;
350
351        // Honest quorum is reached
352        assert!(bft_check(honest_votes, f), "honest quorum must be reached");
353        // Byzantine faction cannot override (they'd need a quorum too)
354        assert!(
355            !bft_check(byzantine_votes, f),
356            "byzantine faction must NOT reach quorum"
357        );
358    }
359
360    #[test]
361    fn test_bft_default_config_f1_quorum3() {
362        // Default config: f=1, quorum=3
363        // A document with minApprovals=3 requires 3 distinct approvals
364        let f = 1u32;
365        let quorum = bft_quorum(0, f);
366        assert_eq!(quorum, 3);
367        assert!(bft_check(3, f));
368        assert!(!bft_check(2, f));
369    }
370
371    #[test]
372    fn test_hash_chain_10_sequential() {
373        // Simulate 10 sequential approval events — verify chain integrity
374        let mut prev = [0u8; 32];
375        let mut chain = Vec::new();
376
377        for i in 0u32..10 {
378            let event_bytes = format!("approval:agent-{i}:APPROVED").into_bytes();
379            let chain_hash = hash_chain_extend(&prev, &event_bytes);
380            chain.push(ChainedEvent {
381                chain_hash,
382                event_bytes,
383                prev_hash: prev,
384            });
385            prev = chain_hash;
386        }
387
388        assert_eq!(chain.len(), 10);
389        assert!(verify_chain(&chain), "10-event chain must verify");
390
391        // Tamper with event 5 — verification must fail
392        chain[5].event_bytes = b"TAMPERED".to_vec();
393        assert!(!verify_chain(&chain), "tampered event must fail");
394    }
395}