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}