blackwall/hivemind/tests/battlefield.rs

720 lines
24 KiB
Rust
Raw Normal View History

//! Battlefield Simulation — Full-scale E2E integration tests for HiveMind Threat Mesh.
//!
//! Simulates a hostile network environment with 10 virtual HiveMind nodes
//! under Sybil attack, coordinated botnet detection, federated learning
//! with Byzantine actors, and ZKP-backed consensus verification.
//!
//! These tests exercise the real code paths at module integration boundaries,
//! NOT the libp2p transport layer (which requires a live network stack).
use common::hivemind as hm;
use hm::{IoC, IoCType, ThreatSeverity};
use hivemind::consensus::{ConsensusEngine, ConsensusResult};
use hivemind::ml::aggregator::{AggregatorError, FedAvgAggregator};
use hivemind::ml::defense::{GradientDefense, GradientVerdict};
use hivemind::ml::local_model::LocalModel;
use hivemind::reputation::ReputationStore;
use hivemind::sybil_guard::{SybilError, SybilGuard};
use hivemind::zkp::{prover, verifier};
use std::time::Instant;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Generate a deterministic peer pubkey from an ID byte.
fn peer_key(id: u8) -> [u8; 32] {
let mut key = [0u8; 32];
key[0] = id;
// Spread entropy so PoW nonce search starts differently per peer
key[31] = id.wrapping_mul(37);
key
}
/// Create a JA4 IoC for consensus testing.
fn make_ja4_ioc(ip: u32) -> IoC {
IoC {
ioc_type: IoCType::Ja4Fingerprint as u8,
severity: ThreatSeverity::High as u8,
ip,
ja4: Some("t13d1516h2_8daaf6152771_e5627efa2ab1".into()),
entropy_score: Some(7800),
description: "Suspicious JA4 fingerprint — possible C2 beacon".into(),
first_seen: now_secs(),
confirmations: 0,
zkp_proof: Vec::new(),
}
}
/// Create a malicious-IP IoC.
fn make_malicious_ip_ioc(ip: u32) -> IoC {
IoC {
ioc_type: IoCType::MaliciousIp as u8,
severity: ThreatSeverity::Critical as u8,
ip,
ja4: None,
entropy_score: None,
description: "Known C2 server".into(),
first_seen: now_secs(),
confirmations: 0,
zkp_proof: Vec::new(),
}
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
// ===========================================================================
// Scenario 1 — The Swarm: Spawn 10 virtual HiveMind nodes
// ===========================================================================
#[test]
fn scenario_1_swarm_spawn_10_nodes() {
let mut guard = SybilGuard::new();
let mut reputation = ReputationStore::new();
let start = Instant::now();
// Register 10 nodes via valid Proof-of-Work
for id in 1..=10u8 {
let pk = peer_key(id);
let challenge = SybilGuard::generate_pow(&pk, hm::POW_DIFFICULTY_BITS);
// PoW must verify successfully
let result = guard.verify_registration(&challenge);
assert!(
result.is_ok(),
"Node {id} PoW verification failed: {result:?}"
);
// Register peer as seed peer (bootstrap nodes get elevated stake)
reputation.register_seed_peer(&pk);
assert!(
reputation.is_trusted(&pk),
"Seed peer node {id} should be trusted (SEED_PEER_STAKE >= MIN_TRUSTED)"
);
}
let elapsed = start.elapsed();
eprintln!(
"[SWARM] 10 nodes registered via PoW in {:.2?} — all trusted (seed peers)",
elapsed
);
// All 10 peers should be tracked
assert_eq!(reputation.peer_count(), 10);
}
// ===========================================================================
// Scenario 2 — Sybil Attack: 5 malicious nodes with invalid PoW
// ===========================================================================
#[test]
fn scenario_2_sybil_attack_rejected() {
let mut guard = SybilGuard::new();
// First register 2 legitimate nodes so the SybilGuard has state
for id in 1..=2u8 {
let pk = peer_key(id);
let challenge = SybilGuard::generate_pow(&pk, hm::POW_DIFFICULTY_BITS);
guard
.verify_registration(&challenge)
.expect("Legitimate node should pass PoW");
}
// --- Attack vector 1: Wrong nonce (hash won't meet difficulty) ---
let pk = peer_key(200);
let mut forged = SybilGuard::generate_pow(&pk, hm::POW_DIFFICULTY_BITS);
forged.nonce = forged.nonce.wrapping_add(1); // corrupt the solution
let result = guard.verify_registration(&forged);
assert_eq!(
result,
Err(SybilError::InvalidProof),
"Corrupted nonce should yield InvalidProof"
);
// --- Attack vector 2: Insufficient difficulty ---
let low_diff = hm::PowChallenge {
peer_pubkey: peer_key(201),
nonce: 0,
timestamp: now_secs(),
difficulty: hm::POW_DIFFICULTY_BITS - 5, // too easy
};
let result = guard.verify_registration(&low_diff);
assert_eq!(
result,
Err(SybilError::InsufficientDifficulty),
"Low difficulty should be rejected"
);
// --- Attack vector 3: Stale timestamp ---
let pk = peer_key(202);
let mut stale = SybilGuard::generate_pow(&pk, hm::POW_DIFFICULTY_BITS);
stale.timestamp = 1_000_000; // year ~2001, way beyond TTL
let result = guard.verify_registration(&stale);
assert_eq!(
result,
Err(SybilError::StaleChallenge),
"Stale timestamp should be rejected"
);
// --- Attack vector 4: Future timestamp ---
let future = hm::PowChallenge {
peer_pubkey: peer_key(203),
nonce: 0,
timestamp: now_secs() + 3600, // 1 hour in the future
difficulty: hm::POW_DIFFICULTY_BITS,
};
let result = guard.verify_registration(&future);
assert_eq!(
result,
Err(SybilError::StaleChallenge),
"Future timestamp should be rejected"
);
// --- Attack vector 5: Replay with someone else's pubkey ---
let victim_pk = peer_key(1);
let attacker_pk = peer_key(204);
let mut replay = SybilGuard::generate_pow(&victim_pk, hm::POW_DIFFICULTY_BITS);
replay.peer_pubkey = attacker_pk; // swap pubkey — hash won't match
let result = guard.verify_registration(&replay);
assert_eq!(
result,
Err(SybilError::InvalidProof),
"Replay with swapped pubkey should fail"
);
eprintln!("[SYBIL] All 5 attack vectors rejected correctly");
}
// ===========================================================================
// Scenario 3 — The Botnet Blitz: 3 nodes detect same JA4 → consensus
// ===========================================================================
#[test]
fn scenario_3_botnet_blitz_consensus() {
let mut consensus = ConsensusEngine::new();
let mut reputation = ReputationStore::new();
// Register 10 honest peers
for id in 1..=10u8 {
reputation.register_peer(&peer_key(id));
}
let botnet_ioc = make_ja4_ioc(0xC0A80001); // 192.168.0.1
// Peer 1 submits — pending (1/3)
let r1 = consensus.submit_ioc(&botnet_ioc, &peer_key(1));
assert_eq!(r1, ConsensusResult::Pending(1));
// Peer 2 submits — pending (2/3)
let r2 = consensus.submit_ioc(&botnet_ioc, &peer_key(2));
assert_eq!(r2, ConsensusResult::Pending(2));
// Peer 1 tries again — duplicate
let dup = consensus.submit_ioc(&botnet_ioc, &peer_key(1));
assert_eq!(dup, ConsensusResult::DuplicatePeer);
// Peer 3 submits — threshold reached → Accepted(3)
let r3 = consensus.submit_ioc(&botnet_ioc, &peer_key(3));
assert_eq!(
r3,
ConsensusResult::Accepted(hm::CROSS_VALIDATION_THRESHOLD),
"Third confirmation should trigger acceptance"
);
// Drain accepted IoCs
let accepted = consensus.drain_accepted();
assert_eq!(accepted.len(), 1, "Exactly one IoC should be accepted");
assert_eq!(
accepted[0].confirmations as usize,
hm::CROSS_VALIDATION_THRESHOLD
);
assert_eq!(accepted[0].ioc_type, IoCType::Ja4Fingerprint as u8);
// Reward the 3 confirming peers
for id in 1..=3u8 {
reputation.record_accurate_report(&peer_key(id));
}
// Verify stake increased for reporters
for id in 1..=3u8 {
let stake = reputation.get_stake(&peer_key(id));
assert_eq!(
stake,
hm::INITIAL_STAKE + hm::ACCURACY_REWARD,
"Reporter {id} should have earned accuracy reward"
);
}
// Non-reporters unchanged
let stake4 = reputation.get_stake(&peer_key(4));
assert_eq!(stake4, hm::INITIAL_STAKE);
// Simulate a false reporter and verify slashing
reputation.record_false_report(&peer_key(10));
let stake10 = reputation.get_stake(&peer_key(10));
let expected_slash = hm::INITIAL_STAKE
- (hm::INITIAL_STAKE * hm::SLASHING_PENALTY_PERCENT / 100);
assert_eq!(stake10, expected_slash, "False reporter should be slashed");
// Simulate propagation to remaining 7 nodes (in-memory) and measure latency
let start = Instant::now();
for id in 4..=10u8 {
let r = consensus.submit_ioc(&accepted[0], &peer_key(id));
// After drain, re-submitting creates a fresh pending entry.
// Threshold is 3, so peers 4,5 → Pending; peer 6 → Accepted again;
// then peers 7,8 → Pending; peer 9 → Accepted; peer 10 → Pending.
match r {
ConsensusResult::Pending(_) | ConsensusResult::Accepted(_) => {}
other => panic!(
"Unexpected result for peer {id}: {other:?}"
),
}
}
let propagation = start.elapsed();
eprintln!("[BOTNET] Consensus reached in 3 confirmations, propagation sim: {propagation:.2?}");
assert!(
propagation.as_millis() < 200,
"Propagation simulation should complete in < 200ms"
);
eprintln!("[BOTNET] Consensus + reputation + propagation verified");
}
// ===========================================================================
// Scenario 4 — Federated Learning Stress: 5 rounds with Byzantine actors
// ===========================================================================
#[test]
fn scenario_4_federated_learning_stress() {
let mut aggregator = FedAvgAggregator::new();
let mut defense = GradientDefense::new();
let param_count = {
let model = LocalModel::new(0.01);
model.param_count()
};
let dim = hm::FL_FEATURE_DIM;
// Create 7 honest models and train them briefly on synthetic data
let mut honest_models: Vec<LocalModel> = (0..7)
.map(|_| LocalModel::new(0.01))
.collect();
// Simulated feature vector (mix of benign/malicious patterns)
let mut features = vec![0.0_f32; dim];
for (i, f) in features.iter_mut().enumerate() {
*f = ((i as f32) * 0.314159).sin().abs();
}
// Run 5 FL rounds
for round in 0..5u64 {
assert_eq!(aggregator.current_round(), round);
let mut honest_count = 0;
let mut malicious_rejected = 0;
// Honest peers: train model and submit gradients
for (idx, model) in honest_models.iter_mut().enumerate() {
let target = if idx % 2 == 0 { 1.0 } else { 0.0 };
let _output = model.forward(&features);
let grads = model.backward(target);
// Defense check before aggregation
let verdict = defense.check(&grads);
if verdict == GradientVerdict::Safe {
let result = aggregator.submit_gradients(
&peer_key((idx + 1) as u8),
round,
grads,
);
assert!(result.is_ok(), "Honest peer {idx} submit failed: {result:?}");
honest_count += 1;
}
}
// Byzantine peer 1: free-rider (zero gradients)
let zeros = vec![0.0_f32; param_count];
let v1 = defense.check(&zeros);
assert_eq!(
v1,
GradientVerdict::FreeRider,
"Round {round}: zero gradients should be flagged as free-rider"
);
malicious_rejected += 1;
// Byzantine peer 2: extreme norm (gradient explosion)
let extreme: Vec<f32> = (0..param_count).map(|i| (i as f32) * 100.0).collect();
let v2 = defense.check(&extreme);
assert_eq!(
v2,
GradientVerdict::NormExceeded,
"Round {round}: extreme gradients should exceed norm bound"
);
malicious_rejected += 1;
// Byzantine peer 3: NaN injection
let mut nan_grads = vec![1.0_f32; param_count];
nan_grads[param_count / 2] = f32::NAN;
let submit_nan = aggregator.submit_gradients(&peer_key(100), round, nan_grads);
assert_eq!(
submit_nan,
Err(AggregatorError::InvalidValues),
"Round {round}: NaN gradients should be rejected by aggregator"
);
malicious_rejected += 1;
// Byzantine peer 4: wrong round
let valid_grads = vec![0.5_f32; param_count];
let submit_wrong = aggregator.submit_gradients(
&peer_key(101),
round + 99,
valid_grads,
);
assert_eq!(
submit_wrong,
Err(AggregatorError::WrongRound),
"Round {round}: wrong round should be rejected"
);
assert!(
honest_count >= hm::FL_MIN_PEERS_PER_ROUND,
"Round {round}: need at least {} honest peers, got {honest_count}",
hm::FL_MIN_PEERS_PER_ROUND
);
// Aggregate with trimmed mean
let aggregated = aggregator
.aggregate()
.expect("Aggregation should succeed with enough honest peers");
// Verify aggregated gradient sanity
assert_eq!(aggregated.len(), param_count);
for (i, &val) in aggregated.iter().enumerate() {
assert!(
val.is_finite(),
"Round {round}, dim {i}: aggregated value must be finite"
);
}
// Apply aggregated gradients to each honest model
for model in &mut honest_models {
model.apply_gradients(&aggregated);
}
eprintln!(
"[FL] Round {round}: {honest_count} honest peers, \
{malicious_rejected} malicious rejected, \
aggregated {param_count} params"
);
aggregator.advance_round();
}
assert_eq!(aggregator.current_round(), 5);
eprintln!("[FL] 5 rounds completed — Byzantine resistance verified");
}
// ===========================================================================
// Scenario 5 — ZKP Proof Chain: prove → verify cycle
// ===========================================================================
#[test]
fn scenario_5_zkp_proof_chain() {
let start = Instant::now();
// Test 1: Prove a JA4 fingerprint-based threat
let ja4_fp = b"t13d1516h2_8daaf6152771_e5627efa2ab1";
let proof_ja4 = prover::prove_threat(
Some(ja4_fp),
true, // entropy exceeded
true, // classified malicious
IoCType::Ja4Fingerprint as u8,
None,
);
let result = verifier::verify_threat(&proof_ja4, None);
assert_eq!(
result,
verifier::VerifyResult::ValidStub,
"JA4 proof should verify as valid stub"
);
// Test 2: Prove an entropy anomaly without JA4
let proof_entropy = prover::prove_threat(
None,
true,
true,
IoCType::EntropyAnomaly as u8,
None,
);
let result = verifier::verify_threat(&proof_entropy, None);
assert_eq!(
result,
verifier::VerifyResult::ValidStub,
"Entropy proof should verify as valid stub"
);
// Test 3: Prove a malicious IP detection
let proof_ip = prover::prove_threat(
None,
false,
true,
IoCType::MaliciousIp as u8,
None,
);
let result = verifier::verify_threat(&proof_ip, None);
assert_eq!(
result,
verifier::VerifyResult::ValidStub,
);
// Test 4: Empty proof data
let empty_proof = hm::ThreatProof {
version: 0,
statement: hm::ProofStatement {
ja4_hash: None,
entropy_exceeded: false,
classified_malicious: false,
ioc_type: 0,
},
proof_data: Vec::new(),
created_at: now_secs(),
};
let result = verifier::verify_threat(&empty_proof, None);
assert_eq!(result, verifier::VerifyResult::EmptyProof);
// Test 5: Tampered proof data
let mut tampered = prover::prove_threat(
Some(ja4_fp),
true,
true,
IoCType::Ja4Fingerprint as u8,
None,
);
// Flip a byte in the proof
if let Some(byte) = tampered.proof_data.last_mut() {
*byte ^= 0xFF;
}
let result = verifier::verify_threat(&tampered, None);
assert_eq!(
result,
verifier::VerifyResult::CommitmentMismatch,
"Tampered proof should fail commitment check"
);
// Test 6: Unsupported version
let future_proof = hm::ThreatProof {
version: 99,
statement: hm::ProofStatement {
ja4_hash: None,
entropy_exceeded: false,
classified_malicious: false,
ioc_type: 0,
},
proof_data: vec![0u8; 100],
created_at: now_secs(),
};
let result = verifier::verify_threat(&future_proof, None);
assert_eq!(result, verifier::VerifyResult::UnsupportedVersion);
let elapsed = start.elapsed();
assert!(
elapsed.as_millis() < 50,
"6 ZKP prove/verify cycles should complete in < 50ms, took {elapsed:.2?}"
);
eprintln!(
"[ZKP] 6 prove/verify cycles completed in {elapsed:.2?} — all correct"
);
}
// ===========================================================================
// Scenario 6 — Full Pipeline: PoW → Consensus → Reputation → ZKP → FL
// ===========================================================================
#[test]
fn scenario_6_full_pipeline_integration() {
let mut guard = SybilGuard::new();
let mut reputation = ReputationStore::new();
let mut consensus = ConsensusEngine::new();
let mut aggregator = FedAvgAggregator::new();
let mut defense = GradientDefense::new();
let pipeline_start = Instant::now();
// --- Phase A: Bootstrap 5 nodes via PoW ---
let node_count = 5u8;
for id in 1..=node_count {
let pk = peer_key(id);
let challenge = SybilGuard::generate_pow(&pk, hm::POW_DIFFICULTY_BITS);
guard
.verify_registration(&challenge)
.unwrap_or_else(|e| panic!("Node {id} PoW failed: {e:?}"));
reputation.register_seed_peer(&pk);
}
eprintln!("[PIPELINE] Phase A: {node_count} nodes bootstrapped");
// --- Phase B: 3 nodes detect a DNS tunnel IoC → consensus ---
let dns_ioc = IoC {
ioc_type: IoCType::DnsTunnel as u8,
severity: ThreatSeverity::Critical as u8,
ip: 0x0A000001, // 10.0.0.1
ja4: None,
entropy_score: Some(9200),
description: "DNS tunneling detected — high entropy in TXT queries".into(),
first_seen: now_secs(),
confirmations: 0,
zkp_proof: Vec::new(),
};
for id in 1..=3u8 {
let r = consensus.submit_ioc(&dns_ioc, &peer_key(id));
if id < 3 {
assert!(matches!(r, ConsensusResult::Pending(_)));
} else {
assert_eq!(r, ConsensusResult::Accepted(3));
}
}
let accepted = consensus.drain_accepted();
assert_eq!(accepted.len(), 1);
// Reward reporters
for id in 1..=3u8 {
reputation.record_accurate_report(&peer_key(id));
}
// --- Phase C: Generate ZKP for the accepted IoC ---
let proof = prover::prove_threat(
None,
true,
true,
accepted[0].ioc_type,
None,
);
let verify = verifier::verify_threat(&proof, None);
assert_eq!(verify, verifier::VerifyResult::ValidStub);
// --- Phase D: One FL round after detection ---
let dim = hm::FL_FEATURE_DIM;
let mut features = vec![0.0_f32; dim];
for (i, f) in features.iter_mut().enumerate() {
*f = ((i as f32) * 0.271828).cos().abs();
}
for id in 1..=node_count {
let mut model = LocalModel::new(0.01);
let _out = model.forward(&features);
let grads = model.backward(1.0); // all train on "malicious"
let verdict = defense.check(&grads);
assert_eq!(verdict, GradientVerdict::Safe);
aggregator
.submit_gradients(&peer_key(id), 0, grads)
.unwrap_or_else(|e| panic!("Node {id} gradient submit failed: {e}"));
}
let global_update = aggregator.aggregate().expect("Aggregation must succeed");
assert!(
global_update.iter().all(|v| v.is_finite()),
"Aggregated model must contain only finite values"
);
// --- Phase E: Verify reputation state ---
for id in 1..=3u8 {
assert!(reputation.is_trusted(&peer_key(id)));
let s = reputation.get_stake(&peer_key(id));
assert!(
s > hm::INITIAL_STAKE,
"Reporter {id} should have earned rewards"
);
}
let pipeline_elapsed = pipeline_start.elapsed();
eprintln!(
"[PIPELINE] Full pipeline (PoW→Consensus→ZKP→FL→Reputation) in {pipeline_elapsed:.2?}"
);
}
// ===========================================================================
// Scenario 7 — Multi-IoC Consensus Storm
// ===========================================================================
#[test]
fn scenario_7_multi_ioc_consensus_storm() {
let mut consensus = ConsensusEngine::new();
let ioc_count = 50;
let start = Instant::now();
// Submit 50 distinct IoCs, each from 3 different peers → all accepted
for i in 0..ioc_count {
let ioc = make_malicious_ip_ioc(0x0A000000 + i as u32);
for peer_id in 1..=3u8 {
consensus.submit_ioc(&ioc, &peer_key(peer_id + (i as u8 * 3)));
}
}
let accepted = consensus.drain_accepted();
assert_eq!(
accepted.len(),
ioc_count,
"All {ioc_count} IoCs should reach consensus"
);
let elapsed = start.elapsed();
eprintln!(
"[STORM] {ioc_count} IoCs × 3 confirmations = {} submissions in {elapsed:.2?}",
ioc_count * 3
);
assert!(
elapsed.as_millis() < 100,
"150 consensus submissions should complete in < 100ms"
);
}
// ===========================================================================
// Scenario 8 — Reputation Slashing Cascade
// ===========================================================================
#[test]
fn scenario_8_reputation_slashing_cascade() {
let mut reputation = ReputationStore::new();
// Register a peer and slash them repeatedly
let pk = peer_key(42);
reputation.register_peer(&pk);
let initial = reputation.get_stake(&pk);
assert_eq!(initial, hm::INITIAL_STAKE);
// Slash multiple times — stake should decrease each time
let mut prev_stake = initial;
let slash_rounds = 5;
for round in 0..slash_rounds {
reputation.record_false_report(&pk);
let new_stake = reputation.get_stake(&pk);
assert!(
new_stake < prev_stake,
"Round {round}: stake should decrease after slashing"
);
prev_stake = new_stake;
}
// After multiple slashings, stake should be below trusted threshold
let final_stake = reputation.get_stake(&pk);
eprintln!(
"[SLASH] Stake after {slash_rounds} slashes: {final_stake} (threshold: {})",
hm::MIN_TRUSTED_REPUTATION
);
// At 25% slashing per round on initial stake of 100:
// 100 → 75 → 56 → 42 → 31 → 23 — below 50 threshold after round 3
assert!(
!reputation.is_trusted(&pk),
"Peer should be untrusted after cascade slashing"
);
}