blackwall/hivemind/tests/battlefield.rs

719 lines
24 KiB
Rust
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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"
);
}