merge freeroutesim with routesim-v2

This commit is contained in:
M Alghazwi 2026-07-20 13:53:07 +02:00
parent 754bf5cc93
commit 1408d7e473
No known key found for this signature in database
GPG Key ID: 646E567CAD7DB607
49 changed files with 2176 additions and 133 deletions

View File

@ -1,16 +1,8 @@
[package]
name = "hs-mix-sim"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
rayon = "1.5"
rand = {version = "0.8.4", features=["small_rng"]}
rand_distr = "0.4.3"
clap = { version = "3.1.0", features = ["derive"] }
rustc-hash = "1.0"
array-init = "2.0.0"
chrono = "0.4.19"
resolver = "3"
members = ["crates/*"]
[profile.release]
lto = true

121
README.md
View File

@ -1,121 +1,10 @@
# hs-mix-sim
`hs-mix-sim` is a simplified simulator for route-compromise simulations. It is based on [routesim](https://github.com/frochet/routesim/tree/main) and currently requires the topology output from [MTG-Simulator
](https://github.com/sus0pid/MTG-Simulator), though I plan to generate these topologies here later so we don't need the MTG output. It
currently supports a simple user model and a simple hidden-service model.
This repository contains a Rust workspace for Monte Carlo route-compromise simulations in mix networks. The aim is to study and better understand the fully malicious path problem in decentralized mixnets. A sampled path is considered compromised when every node on that path is malicious. For more information on the problem, see [this research post](https://forum.research.logos.co/t/hidden-services-over-mix/706).
The simulator reads a layout CSV file, treats each epoch column as one
topology snapshot, and samples user message paths through the active topology.
A route is marked compromised when every hop in the sampled path is malicious.
## Choosing a simulator
## Current Models
- [`routesim-v2`](crates/routesim-v2/README.md) to simulate paths through existing [MTG-generated](https://github.com/sus0pid/MTG-Simulator), stratified topology layouts. This simulator is a simplified version of [routesim](https://github.com/frochet/routesim) with an extention of additional [Vanguards](https://spec.torproject.org/vanguards-spec/) similar to those use in Tor hidden services.
- [`freeroutesim`](crates/freeroutesim/README.md) to simulate free-route mixnets and study time and message count to first compromise under random, guard, or vanguard routing. This is more relevant for [the mix protocol](https://lip.logos.co/anoncomms/raw/mix.html) we consider.
The simple model:
- simulates `--users` independent users
- samples one message at a time per user
- waits a uniformly random interval in `[5, 15)` minutes between messages
- optionally uses persistent vanguard and guard selection
- emits route records until `--days` virtual days have elapsed
- can print an aggregate compromise summary with `--summary`
The simple hidden-service model:
- simulates `--users` independent hidden services
- waits a uniformly random interval in `[5, 15]` minutes between client requests
- sends a uniformly random burst of `[1, 100]` messages for each request
- places all messages in a burst within a 60 second window
- uses the console request id column to identify the burst when `--to-console` is enabled
When persistent positions are enabled, routes use:
- hop 0: persistent vanguard sampled from layer 0
- hop 1: persistent guard sampled from layer 1
- hop 2: random weighted sample from layer 2
Use `--disable-vanguards` for guard-only routes:
- hop 0: random weighted sample from layer 0
- hop 1: persistent guard sampled from layer 1
- hop 2: random weighted sample from layer 2
Use `-d` to disable both persistent guards and vanguards.
The current codebase:
- `src/main.rs`: CLI, topology loading, runner setup
- `src/simulation.rs`: simulation runner, path sampling, compromise check, output
- `src/simplemodel.rs`: simple synchronous user behavior
- `src/simplehiddenservicemodel.rs`: simple hidden-service burst behavior
- `src/usermodel.rs`: minimal user-model traits, route event type, guard state
- `src/config.rs`: topology CSV parsing and weighted path sampling
- `src/mixnodes/`: mix node parsing/types
## Build And Test
```bash
cargo test
```
```bash
cargo run -- --help
```
## Example
```bash
cargo run -- \
--topology-file testfiles/single_layout/1000_137_Random_BP_layout.csv \
--model simple \
--epoch 86401 \
--users 2 \
--days 1 \
--to-console
```
Example output line:
```text
1970-01-01 00:44:31 2538 570,260,1007 false
```
That line contains the virtual timestamp, user id, sampled path mix ids, and
whether the sampled route was fully compromised.
For large runs, avoid route output and print only the aggregate summary:
simple model:
```bash
cargo run --release -- \
--topology-file testfiles/layout_data/bow_tie/dynamic_hybrid_steady_0.03_layout.csv \
--model simple \
--epoch 3600 \
--users 5000 \
--days 30 \
--summary
```
hidden service model:
```bash
cargo run --release -- \
--topology-file testfiles/layout_data/bow_tie/dynamic_hybrid_steady_0.03_layout.csv \
--model simple-hidden-service \
--epoch 3600 \
--users 5000 \
--days 30 \
--summary
```
## TODO
- [ ] generate topologies locally (this will probably best work for free-route because of bin-packing in the stratified mix from the paper)
- [ ] simulate free-route mix
- [ ] fix the selection logic for vanguards, current logic is the same as guard, Tor spec defines differently. Also vanguards are sampled from the first hop/layer and that layer doesn't have the guard maintenance logic, i.e., active guards, backup guards, down guards, etc. so it is less effective.
- [ ] add more complex hidden services simulations
- [ ] add model for dead-drops/mailboxes, see approaches used in these paper:
- [Vuvuzela](https://dl.acm.org/doi/pdf/10.1145/2815400.2815417)
- [Alpenhorn](https://www.usenix.org/system/files/conference/osdi16/osdi16-lazar.pdf)
- [Stadium](https://dl.acm.org/doi/pdf/10.1145/3132747.3132783)
- [Karaoke](https://www.usenix.org/system/files/osdi18-lazar.pdf)
- [Groove](https://www.usenix.org/system/files/osdi22-barman.pdf)
- [PingPong](https://arxiv.org/pdf/2504.19566)
This project builds on ideas and code from [routesim](https://github.com/frochet/routesim).

View File

@ -0,0 +1,11 @@
[package]
name = "freeroutesim"
version = "0.1.0"
edition = "2024"
[dependencies]
rand_distr = "0.4.3"
rand = { version = "0.8.6", features = ["small_rng"] }
indicatif = "0.18.6"
rayon = "1.12.0"
clap = { version = "4.6.2", features = ["derive"] }

View File

@ -0,0 +1,172 @@
# freeroutesim
`freeroutesim` is a simulator for free-route mix. It generates topology internally and simulates independent users sending messages through mix. Users run in parallel, and every user owns a traffic model and a path sampler.
A path is considered compromised only when every hop is malicious (though it is possible for the defined model to implement its own logic for deciding when a path is compromised). Each path contains distinct mix nodes. A user's simulation stops at their first compromised message, so the results measure both time and number of messages to first compromise. Users that are not compromised continue until the simulation time limit.
## Running the simulator
example:
```bash
cargo run --release -- \
--mode random \
--model simple \
--hops 3 \
--days 1 \
--users 5000 \
--epoch 3600
```
The summary is always printed. To also write results in csv (for plotting later):
```bash
cargo run -p freeroutesim --release -- \
--mode bandwidth-random \
--model simple \
--hops 3 \
--days 30 \
--users 5000 \
--epoch 3600 \
--csv crates/freeroutesim/results/bandwidth_random.csv
```
View the current options with:
```bash
cargo run -p freeroutesim -- --help
```
## Command-line options
| Option | Default | Meaning |
| --- | --- |-------------------------------------------------------------------|
| `--mode MODE` | `random` | Path sampler: `random`, `bandwidth-random`, `guard`, or `vanguard` |
| `--hops N` | `3` | Number of nodes in every path |
| `--vanguards N` | none | Number of vanguard hops, required in `vanguard` mode |
| `--model MODEL` | `simple` | model: `simple` or `hidden-service` |
| `--days N` | `1` | simulation duration in days |
| `--users N` | `5000` | Number of simulated users |
| `--epoch N` | `3600` | how often each topology gets updated (with churn etc) in seconds |
| `--csv PATH` | none | Write results to csv file for plotting |
## Path-selection modes
All modes sample without repeating a node within one path.
### `random`
Selects every hop uniformly from the active nodes.
### `bandwidth-random`
Selects every hop in proportion to node bandwidth.
### `guard`
Places a persistent guard at hop 1. The guard is selected by bandwidth from nodes carrying the consensus-assigned `Guard` tag. All remaining hops are selected by bandwidth from the complete active topology.
Each user initially samples three guard candidates. It continues using the selected guard while that node is online. If it goes offline, the sampler selects another online node already in the user's guard set. The set is extended by one new candidate only when all candidates are offline.
### `vanguard`
Vanguard mode always includes a guard. Supply the number of vanguards with `--vanguards N`; it must be greater than zero and less than `hops - 1`, leaving at least one non-persistent hop (for the guard).
The guard remains at hop 1. Vanguards use the first available hops other than the guard hop. Four vanguards are selected by default and extended by one only when needed. Guard and vanguard candidate sets are kept separate, and the nodes selected for any one path are distinct.
All remaining hops are selected by bandwidth from the active topology.
Example using one guard, two vanguards, and one random hop:
```bash
cargo run -p freeroutesim --release -- \
--mode vanguard \
--hops 4 \
--vanguards 2 \
--model simple \
--days 30 \
--users 5000 \
--epoch 3600
```
## User models
### `simple`
Sends one message after each uniformly sampled interval in `[300, 900)` seconds. Every message samples a new path.
### `hidden-service`
Models bursty hidden-service traffic:
- a request arrives after each uniformly sampled interval in `[300, 900]` seconds;
- the request produces between 1 and 100 messages;
- those messages are assigned random offsets in `[0, 60]` seconds from the request time;
- every message samples its own path.
Select this model with `--model hidden-service`.
## Generated topologies
The simulator creates enough topology epochs to cover the requested duration. The following defaults are constants in [`src/params.rs`](src/params.rs), but can be easily changed.
Malicious nodes are chosen randomly until we reach the node-count and bandwidth targets.
All nodes start online. At every epoch, each online node goes offline with the defined churn probability, and each offline node returns with that same probability.
### Consensus guard selection
The consensus maintains active, backup, and offline guards as follows:
1. Online active guards remain active; unavailable guards move offline.
2. Returning offline guards become backups.
3. If active guard bandwidth is below 25% of current online bandwidth, online backups are promoted first.
4. If backups are insufficient, new online nodes are selected by bandwidth until the target is reached.
5. Only active guards receive the `Guard` tag in the generated topology.
This consensus state is shared to all users. the tag can be extended later to add something more Tor-like e.g. fast, stable tags.
## summary
Every run prints a summary similar to:
```text
simulation_summary
users=5000
days=30
epoch_seconds=3600
topologies_loaded=721
path_hops=3
path_sampler_type=BandwidthRandomPathSampler
user_model_type=SimpleModel
malicious_node_fraction=0.100000
malicious_bandwidth_fraction=0.100000
churn_rate=0.030000
total_messages=...
users_with_compromised_messages=...
users_without_compromised_messages=...
percentage_users_compromised=...
first_compromise_timestamp_seconds=...
fewest_messages_to_first_compromise=...
```
## CSV output
When `--csv` is supplied, the simulator writes one CSV containing info for two cumulative curves.
- `time_to_first compromise`
- `message_count_to_first compromise`
## Plotting CSV results
Install the Python dependency and run:
```bash
python3 ./scripts/plot_results.py \
<file_name>.csv
```
## limitations
- Guard selection is a simplified model and does not track long-term stability or online history.
- The simulator records only time and messages to first compromise.

View File

@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Plot cumulative compromise curves produced by freeroutesim."""
from __future__ import annotations
import argparse
import csv
import sys
from dataclasses import dataclass
from pathlib import Path
import matplotlib
if "--show" not in sys.argv:
matplotlib.use("Agg")
import matplotlib.pyplot as plt
@dataclass
class Curve:
x: list[float]
probability: list[float]
@dataclass
class SimulationResult:
label: str
time: Curve
messages: Curve
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Plot cumulative probability of first compromise against virtual "
"time and number of messages."
)
)
parser.add_argument(
"csv_files",
nargs="+",
type=Path,
help="One or more CSV files written by freeroutesim --csv",
)
parser.add_argument(
"-o",
"--output",
type=Path,
help="Output image path (default: <first-csv-stem>_plot.png)",
)
parser.add_argument(
"--labels",
nargs="+",
help="Legend labels in the same order as the CSV files",
)
parser.add_argument("--title", help="Optional figure title")
parser.add_argument(
"--log-messages",
action="store_true",
help="Use a logarithmic x-axis for the message-count plot",
)
parser.add_argument(
"--show",
action="store_true",
help="Open the plot after saving it",
)
args = parser.parse_args()
if args.labels is not None and len(args.labels) != len(args.csv_files):
parser.error("--labels must contain exactly one label per CSV file")
return args
def read_result(path: Path, label: str) -> SimulationResult:
time_points: list[tuple[float, float]] = []
message_points: list[tuple[float, float]] = []
with path.open(newline="", encoding="utf-8") as csv_file:
reader = csv.DictReader(csv_file)
required = {"curve", "x", "day", "cumulative_probability"}
missing = required.difference(reader.fieldnames or [])
if missing:
missing_columns = ", ".join(sorted(missing))
raise ValueError(f"{path}: missing CSV columns: {missing_columns}")
for line_number, row in enumerate(reader, start=2):
try:
probability = float(row["cumulative_probability"])
if row["curve"] == "time_seconds":
time_points.append((float(row["day"]), probability))
elif row["curve"] == "message_count":
message_points.append((float(row["x"]), probability))
except (TypeError, ValueError) as error:
raise ValueError(
f"{path}:{line_number}: invalid numeric value"
) from error
if not time_points:
raise ValueError(f"{path}: contains no time_seconds curve")
if not message_points:
raise ValueError(f"{path}: contains no message_count curve")
time_points.sort()
message_points.sort()
return SimulationResult(
label=label,
time=Curve(
x=[point[0] for point in time_points],
probability=[point[1] for point in time_points],
),
messages=Curve(
x=[point[0] for point in message_points],
probability=[point[1] for point in message_points],
),
)
def plot_results(
results: list[SimulationResult],
output: Path,
title: str | None,
log_messages: bool,
show: bool,
) -> None:
figure, (time_axis, message_axis) = plt.subplots(1, 2, figsize=(13, 5.5))
for result in results:
time_axis.step(
result.time.x,
result.time.probability,
where="post",
label=result.label,
)
message_axis.step(
result.messages.x,
result.messages.probability,
where="post",
label=result.label,
)
configure_axis(
time_axis,
xlabel="time (days)",
title="Time to first compromise",
)
configure_axis(
message_axis,
xlabel="Messages sent",
title="Messages to first compromise",
)
if log_messages:
message_axis.set_xscale("symlog", linthresh=1)
if len(results) > 1:
time_axis.legend()
message_axis.legend()
if title:
figure.suptitle(title)
figure.tight_layout()
output.parent.mkdir(parents=True, exist_ok=True)
figure.savefig(output, dpi=200, bbox_inches="tight")
print(f"wrote {output}")
if show:
plt.show()
else:
plt.close(figure)
def configure_axis(axis: plt.Axes, xlabel: str, title: str) -> None:
axis.set_title(title)
axis.set_xlabel(xlabel)
axis.set_ylabel("Cumulative probability of compromise")
axis.set_ylim(0.0, 1.01)
axis.grid(True, alpha=0.3)
def main() -> None:
args = parse_args()
labels = args.labels or [path.stem for path in args.csv_files]
results = [
read_result(path, label) for path, label in zip(args.csv_files, labels)
]
output = args.output or args.csv_files[0].with_name(
f"{args.csv_files[0].stem}_plot.png"
)
plot_results(results, output, args.title, args.log_messages, args.show)
if __name__ == "__main__":
main()

View File

@ -0,0 +1 @@
matplotlib>=3.7

View File

@ -0,0 +1,208 @@
mod params;
mod path_sampler;
mod simulator;
mod summary;
mod topologygen;
mod usermodel;
use clap::{Parser, ValueEnum};
use params::DEFAULT_PATH_HOPS;
use path_sampler::bandwidth_random::BandwidthRandomPathSampler;
use path_sampler::guard::PathSamplerWithGuards;
use path_sampler::random::RandomPathSampler;
use path_sampler::vanguard::PathSamplerWithVanguards;
use simulator::Simulator;
use std::path::PathBuf;
use topologygen::{TopologyConfig, TopologyGenerator};
use usermodel::{HiddenServiceModel, SimpleModel, UserModelInfo, UserModelIterator};
/// currently supported models
#[derive(Copy, Clone, Debug, ValueEnum)]
enum Model {
Simple,
HiddenService,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum Mode {
Random,
BandwidthRandom,
Guard,
Vanguard,
}
#[derive(Debug, Parser)]
#[command(author, version, about = "Free-route mixnet simulator")]
struct Options {
/// Path-selection mode.
#[arg(long, value_enum, default_value = "random")]
mode: Mode,
/// Number of hops in each sampled path.
#[arg(long, default_value_t = DEFAULT_PATH_HOPS)]
hops: usize,
/// Number of vanguards used in vanguard mode.
#[arg(long, required_if_eq("mode", "vanguard"))]
vanguards: Option<usize>,
/// User model.
#[arg(long, value_enum, default_value = "simple")]
model: Model,
/// Write the output to CSV file.
#[arg(long)]
csv: Option<PathBuf>,
/// Number of days to simulate.
#[arg(long, default_value_t = 1)]
days: u32,
/// Number of users to simulate.
#[arg(long, default_value_t = 5000)]
users: u32,
/// Duration of one topology epoch in seconds.
#[arg(long, default_value_t = 3600)]
epoch: u32,
}
fn main() {
let options = Options::parse();
assert!(options.hops > 0, "--hops must be greater than zero");
assert!(options.epoch > 0, "--epoch must be greater than zero");
let vanguards = options.vanguards.unwrap_or(0);
if options.mode == Mode::Vanguard {
assert!(vanguards > 0, "--vanguards must be greater than zero");
assert!(
options.hops > 1 && vanguards < options.hops - 1,
"--vanguards must be less than --hops - 1"
);
}
let topology_config = TopologyConfig {
guard_mode: matches!(options.mode, Mode::Guard | Mode::Vanguard),
epochs: epochs_needed(options.days, options.epoch),
..TopologyConfig::default()
};
let topology_generator = TopologyGenerator::new(topology_config);
let topologies = topology_generator.generate_topologies();
let sampler_type = match options.mode {
Mode::Random => "RandomPathSampler",
Mode::BandwidthRandom => "BandwidthRandomPathSampler",
Mode::Guard => "PathSamplerWithGuards",
Mode::Vanguard => "PathSamplerWithVanguards",
};
let model_type = match options.model {
Model::Simple => "SimpleModel",
Model::HiddenService => "HiddenServiceModel",
};
let mut simulator = Simulator::new(
options.users,
topology_generator,
options.days,
options.epoch,
options.hops,
options.csv,
sampler_type,
model_type,
);
match (options.model, options.mode) {
(Model::Simple, Mode::Random) => {
let models = (0..options.users)
.map(|_| {
UserModelIterator(SimpleModel::new(
UserModelInfo::new(&topologies, options.epoch),
RandomPathSampler::new(options.hops),
))
})
.collect();
simulator.simulate(models);
}
(Model::Simple, Mode::BandwidthRandom) => {
let models = (0..options.users)
.map(|_| {
UserModelIterator(SimpleModel::new(
UserModelInfo::new(&topologies, options.epoch),
BandwidthRandomPathSampler::new(options.hops),
))
})
.collect();
simulator.simulate(models);
}
(Model::Simple, Mode::Guard) => {
let models = (0..options.users)
.map(|_| {
UserModelIterator(SimpleModel::new(
UserModelInfo::new(&topologies, options.epoch),
PathSamplerWithGuards::new(options.hops),
))
})
.collect();
simulator.simulate(models);
}
(Model::Simple, Mode::Vanguard) => {
let models = (0..options.users)
.map(|_| {
UserModelIterator(SimpleModel::new(
UserModelInfo::new(&topologies, options.epoch),
PathSamplerWithVanguards::new(options.hops, vanguards),
))
})
.collect();
simulator.simulate(models);
}
(Model::HiddenService, Mode::Random) => {
let models = (0..options.users)
.map(|_| {
UserModelIterator(HiddenServiceModel::new(
UserModelInfo::new(&topologies, options.epoch),
RandomPathSampler::new(options.hops),
))
})
.collect();
simulator.simulate(models);
}
(Model::HiddenService, Mode::BandwidthRandom) => {
let models = (0..options.users)
.map(|_| {
UserModelIterator(HiddenServiceModel::new(
UserModelInfo::new(&topologies, options.epoch),
BandwidthRandomPathSampler::new(options.hops),
))
})
.collect();
simulator.simulate(models);
}
(Model::HiddenService, Mode::Guard) => {
let models = (0..options.users)
.map(|_| {
UserModelIterator(HiddenServiceModel::new(
UserModelInfo::new(&topologies, options.epoch),
PathSamplerWithGuards::new(options.hops),
))
})
.collect();
simulator.simulate(models);
}
(Model::HiddenService, Mode::Vanguard) => {
let models = (0..options.users)
.map(|_| {
UserModelIterator(HiddenServiceModel::new(
UserModelInfo::new(&topologies, options.epoch),
PathSamplerWithVanguards::new(options.hops, vanguards),
))
})
.collect();
simulator.simulate(models);
}
}
}
fn epochs_needed(days: u32, epoch_seconds: u32) -> u32 {
let duration_seconds = u64::from(days) * 24 * 60 * 60;
let epochs = duration_seconds / u64::from(epoch_seconds) + 1;
u32::try_from(epochs).expect("requested simulation duration needs too many topology epochs")
}

View File

@ -0,0 +1,17 @@
//! Constants and parameters used throughout the simulator.
pub const DEFAULT_PATH_HOPS: usize = 3;
pub const GUARD_HOP: usize = 1;
pub const GUARDS_SAMPLE_SIZE: usize = 3;
pub const GUARDS_SAMPLE_SIZE_EXTEND: usize = 1;
pub const VANGUARDS_SAMPLE_SIZE: usize = 4;
pub const VANGUARDS_SAMPLE_SIZE_EXTEND: usize = 1;
pub const DEFAULT_MIX_SIZE: usize = 1000;
pub const DEFAULT_EPOCHS: u32 = 1;
pub const DEFAULT_MALICIOUS_NODE_FRACTION: f64 = 0.10;
pub const DEFAULT_MALICIOUS_BANDWIDTH_FRACTION: f64 = 0.10;
/// churn rate is a single value for now, but we can extend this later for
/// different rates for joining nodes and leaving node (honest & malicious as well)
pub const DEFAULT_CHURN_RATE: f64 = 0.03;
pub const DEFAULT_GUARD_BANDWIDTH_FRACTION: f64 = 0.25;

View File

@ -0,0 +1,113 @@
use crate::params::DEFAULT_PATH_HOPS;
use crate::path_sampler::PathSampler;
use crate::topologygen::{MixNode, Topology};
use rand::thread_rng;
use rand_distr::Distribution;
use rand_distr::weighted_alias::WeightedAliasIndex;
use std::collections::HashSet;
pub struct BandwidthRandomPathSampler {
hops: usize,
current_topology_index: Option<usize>,
}
impl BandwidthRandomPathSampler {
pub fn new(hops: usize) -> Self {
Self {
hops,
current_topology_index: None,
}
}
}
impl Default for BandwidthRandomPathSampler {
fn default() -> Self {
Self::new(DEFAULT_PATH_HOPS)
}
}
impl PathSampler for BandwidthRandomPathSampler {
fn sample_path(&mut self, topology_index: usize, topology: &Topology) -> Vec<MixNode> {
self.current_topology_index = Some(topology_index);
sample_bandwidth_path_with_fixed_hops(topology, self.hops, &[])
}
fn hops(&self) -> usize {
self.hops
}
fn sampler_type(&self) -> &'static str {
"BandwidthRandomPathSampler"
}
}
/// Fill non-fixed positions from the active-node bandwidth sampler.
pub(crate) fn sample_bandwidth_path_with_fixed_hops(
topology: &Topology,
hops: usize,
fixed_hops: &[(usize, MixNode)],
) -> Vec<MixNode> {
let active = topology.active();
assert!(
hops <= active.len(),
"cannot sample a {hops}-hop path from {} active mix nodes",
active.len()
);
let mut path = vec![None; hops];
let mut used = HashSet::with_capacity(hops);
let mut rng = thread_rng();
for (hop, node) in fixed_hops {
assert!(*hop < hops, "fixed hop {hop} is outside a {hops}-hop path");
assert!(path[*hop].is_none(), "hop {hop} was fixed more than once");
assert!(
used.insert(node.mix_id),
"mix {} was fixed at more than one hop",
node.mix_id
);
path[*hop] = Some(node.clone());
}
for hop in &mut path {
if hop.is_none() {
loop {
let sampled = topology
.sample_active(&mut rng)
.expect("active topology should contain at least one node");
if used.insert(sampled.mix_id) {
*hop = Some(sampled.clone());
break;
}
}
}
}
path.into_iter().map(Option::unwrap).collect()
}
/// Sample up to `count` bandwidth-weighted nodes without replacement.
pub(crate) fn sample_weighted_unique<'a>(
nodes: impl IntoIterator<Item = &'a MixNode>,
count: usize,
excluded: &HashSet<u32>,
) -> Vec<MixNode> {
let mut candidates: Vec<&MixNode> = nodes
.into_iter()
.filter(|node| !excluded.contains(&node.mix_id))
.collect();
let mut sampled = Vec::with_capacity(count.min(candidates.len()));
let mut rng = thread_rng();
while sampled.len() < count && !candidates.is_empty() {
let weights = candidates
.iter()
.map(|node| node.weight.max(f64::EPSILON))
.collect();
let sampler = WeightedAliasIndex::new(weights)
.expect("mix-node weights should produce a valid sampler");
let position = sampler.sample(&mut rng);
sampled.push(candidates.swap_remove(position).clone());
}
sampled
}

View File

@ -0,0 +1,104 @@
use crate::params::{GUARD_HOP, GUARDS_SAMPLE_SIZE, GUARDS_SAMPLE_SIZE_EXTEND};
use crate::path_sampler::PathSampler;
use crate::path_sampler::bandwidth_random::{
sample_bandwidth_path_with_fixed_hops, sample_weighted_unique,
};
use crate::topologygen::{MixNode, Topology};
use std::collections::HashSet;
pub struct PathSamplerWithGuards {
hops: usize,
current_topology_index: Option<usize>,
guard_set: Vec<u32>,
selected_guard: Option<u32>,
}
impl PathSamplerWithGuards {
pub fn new(hops: usize) -> Self {
assert!(
GUARD_HOP < hops,
"guard hop {GUARD_HOP} is outside a {hops}-hop path"
);
Self {
hops,
current_topology_index: None,
guard_set: Vec::new(),
selected_guard: None,
}
}
pub(crate) fn selected_guard(&mut self, topology_index: usize, topology: &Topology) -> MixNode {
if self.current_topology_index != Some(topology_index) {
self.update_guard(topology);
self.current_topology_index = Some(topology_index);
}
let guard_id = self
.selected_guard
.expect("guard sampler should have an online selected guard");
find_active(topology, guard_id)
.expect("selected guard should be online in the current topology")
.clone()
}
pub(crate) fn guard_set(&self) -> &[u32] {
&self.guard_set
}
fn update_guard(&mut self, topology: &Topology) {
if self
.selected_guard
.is_some_and(|guard| find_active(topology, guard).is_some())
{
return;
}
self.selected_guard = self
.guard_set
.iter()
.copied()
.find(|guard| find_active(topology, *guard).is_some());
if self.selected_guard.is_some() {
return;
}
let sample_size = if self.guard_set.is_empty() {
GUARDS_SAMPLE_SIZE
} else {
GUARDS_SAMPLE_SIZE_EXTEND
};
let known: HashSet<u32> = self.guard_set.iter().copied().collect();
let additions = sample_weighted_unique(topology.guards(), sample_size, &known);
self.guard_set
.extend(additions.into_iter().map(|node| node.mix_id));
self.selected_guard = self
.guard_set
.iter()
.copied()
.find(|guard| find_active(topology, *guard).is_some());
assert!(
self.selected_guard.is_some(),
"current topology has no online guard available to this user"
);
}
}
impl PathSampler for PathSamplerWithGuards {
fn sample_path(&mut self, topology_index: usize, topology: &Topology) -> Vec<MixNode> {
let guard = self.selected_guard(topology_index, topology);
sample_bandwidth_path_with_fixed_hops(topology, self.hops, &[(GUARD_HOP, guard)])
}
fn hops(&self) -> usize {
self.hops
}
fn sampler_type(&self) -> &'static str {
"PathSamplerWithGuards"
}
}
fn find_active(topology: &Topology, mix_id: u32) -> Option<&MixNode> {
topology.active().iter().find(|node| node.mix_id == mix_id)
}

View File

@ -0,0 +1,18 @@
pub mod bandwidth_random;
pub mod guard;
pub mod random;
pub mod vanguard;
/// path sampler main job is to select a path from a given topology
/// Uniform and bandwidth-weighted random samplers are available.
/// guard and vanguard samplers use persistent client-side selections.
///
use crate::topologygen::{MixNode, Topology};
pub trait PathSampler {
fn sample_path(&mut self, topology_index: usize, topology: &Topology) -> Vec<MixNode>;
#[allow(dead_code)]
fn hops(&self) -> usize;
#[allow(dead_code)]
fn sampler_type(&self) -> &'static str;
}

View File

@ -0,0 +1,58 @@
use crate::params::DEFAULT_PATH_HOPS;
use crate::path_sampler::PathSampler;
use crate::topologygen::{MixNode, Topology};
use rand::{Rng, thread_rng};
use std::collections::HashSet;
/// Uniform random path sampler. Mix-node bandwidth is ignored.
pub struct RandomPathSampler {
hops: usize,
current_topology_index: Option<usize>,
}
impl RandomPathSampler {
pub fn new(hops: usize) -> Self {
Self {
hops,
current_topology_index: None,
}
}
}
impl Default for RandomPathSampler {
fn default() -> Self {
Self::new(DEFAULT_PATH_HOPS)
}
}
impl PathSampler for RandomPathSampler {
fn sample_path(&mut self, topology_index: usize, topology: &Topology) -> Vec<MixNode> {
self.current_topology_index = Some(topology_index);
let active = topology.active();
assert!(
self.hops <= active.len(),
"cannot sample a {}-hop path from {} active mix nodes",
self.hops,
active.len()
);
let mut rng = thread_rng();
let mut used = HashSet::with_capacity(self.hops);
let mut path = Vec::with_capacity(self.hops);
while path.len() < self.hops {
let node = &active[rng.gen_range(0..active.len())];
if used.insert(node.mix_id) {
path.push(node.clone());
}
}
path
}
fn hops(&self) -> usize {
self.hops
}
fn sampler_type(&self) -> &'static str {
"RandomPathSampler"
}
}

View File

@ -0,0 +1,133 @@
use crate::params::{GUARD_HOP, VANGUARDS_SAMPLE_SIZE, VANGUARDS_SAMPLE_SIZE_EXTEND};
use crate::path_sampler::PathSampler;
use crate::path_sampler::bandwidth_random::{
sample_bandwidth_path_with_fixed_hops, sample_weighted_unique,
};
use crate::path_sampler::guard::PathSamplerWithGuards;
use crate::topologygen::{MixNode, MixNodeTag, Topology};
use std::collections::HashSet;
#[derive(Default)]
struct VanguardSelection {
candidates: Vec<u32>,
selected: Option<u32>,
}
pub struct PathSamplerWithVanguards {
hops: usize,
guard_sampler: PathSamplerWithGuards,
current_topology_index: Option<usize>,
vanguard_hops: Vec<usize>,
vanguard_sets: Vec<VanguardSelection>,
}
impl PathSamplerWithVanguards {
pub fn new(hops: usize, vanguards: usize) -> Self {
assert!(vanguards > 0, "vanguard mode needs at least one vanguard");
assert!(
hops > 1 && vanguards < hops - 1,
"the number of vanguards ({vanguards}) must be less than hops - 1 ({})",
hops.saturating_sub(1)
);
let vanguard_hops = (0..hops)
.filter(|hop| *hop != GUARD_HOP)
.take(vanguards)
.collect();
let vanguard_sets = (0..vanguards)
.map(|_| VanguardSelection::default())
.collect();
Self {
hops,
guard_sampler: PathSamplerWithGuards::new(hops),
current_topology_index: None,
vanguard_hops,
vanguard_sets,
}
}
fn update_vanguards(&mut self, topology: &Topology, guard_id: u32) {
let mut unavailable: HashSet<u32> =
self.guard_sampler.guard_set().iter().copied().collect();
for selection in &self.vanguard_sets {
unavailable.extend(selection.candidates.iter().copied());
}
let mut selected_for_path = HashSet::from([guard_id]);
for selection in &mut self.vanguard_sets {
if !selection.selected.is_some_and(|vanguard| {
!selected_for_path.contains(&vanguard) && find_active(topology, vanguard).is_some()
}) {
selection.selected = selection.candidates.iter().copied().find(|vanguard| {
!selected_for_path.contains(vanguard)
&& find_active(topology, *vanguard).is_some()
});
}
if selection.selected.is_none() {
let sample_size = if selection.candidates.is_empty() {
VANGUARDS_SAMPLE_SIZE
} else {
VANGUARDS_SAMPLE_SIZE_EXTEND
};
let eligible = topology
.active()
.iter()
.filter(|node| !node.has_tag(MixNodeTag::Guard));
let additions = sample_weighted_unique(eligible, sample_size, &unavailable);
for addition in additions {
unavailable.insert(addition.mix_id);
selection.candidates.push(addition.mix_id);
}
selection.selected = selection.candidates.iter().copied().find(|vanguard| {
!selected_for_path.contains(vanguard)
&& find_active(topology, *vanguard).is_some()
});
}
let selected = selection
.selected
.expect("current topology has no online vanguard available to this user");
selected_for_path.insert(selected);
}
}
}
impl PathSampler for PathSamplerWithVanguards {
fn sample_path(&mut self, topology_index: usize, topology: &Topology) -> Vec<MixNode> {
let guard = self.guard_sampler.selected_guard(topology_index, topology);
if self.current_topology_index != Some(topology_index) {
self.update_vanguards(topology, guard.mix_id);
self.current_topology_index = Some(topology_index);
}
let mut fixed_hops = Vec::with_capacity(self.vanguard_sets.len() + 1);
fixed_hops.push((GUARD_HOP, guard));
for (hop, selection) in self.vanguard_hops.iter().zip(&self.vanguard_sets) {
let vanguard = find_active(
topology,
selection
.selected
.expect("vanguard sampler should have a selected vanguard"),
)
.expect("selected vanguard should be online in the current topology")
.clone();
fixed_hops.push((*hop, vanguard));
}
sample_bandwidth_path_with_fixed_hops(topology, self.hops, &fixed_hops)
}
fn hops(&self) -> usize {
self.hops
}
fn sampler_type(&self) -> &'static str {
"PathSamplerWithVanguards"
}
}
fn find_active(topology: &Topology, mix_id: u32) -> Option<&MixNode> {
topology.active().iter().find(|node| node.mix_id == mix_id)
}

View File

@ -0,0 +1,133 @@
use crate::summary::{SimulationConfigSummary, SimulationSummary};
use crate::topologygen::TopologyGenerator;
use crate::usermodel::{UserModel, UserModelIterator};
use indicatif::{ProgressBar, ProgressStyle};
use rayon::prelude::*;
use std::path::PathBuf;
/// Runtime state and output settings for one simulation run.
#[derive(Default)]
pub struct Simulator {
/// The number of users we want to simulate
users: u32,
/// The Network topology generator
topology_generator: TopologyGenerator,
/// The number of days for running the experiment
days: u32,
/// each topology lifetime
epoch_time: u32,
/// number of hops in sampled paths
path_hops: usize,
/// Optional csv output.
csv_file_path: Option<PathBuf>,
/// sampler type
sampler_type: &'static str,
/// user traffic model type
model_type: &'static str,
}
impl Simulator {
pub fn new(
users: u32,
topology_generator: TopologyGenerator,
days: u32,
epoch_time: u32,
path_hops: usize,
csv_file_path: Option<PathBuf>,
sampler_type: &'static str,
model_type: &'static str,
) -> Self {
Self {
users,
topology_generator,
days,
epoch_time,
path_hops,
csv_file_path,
sampler_type,
model_type,
}
}
/// Run every user's model
/// Parallelism is over users. Each worker owns one user model and samples all
/// of that user's messages until first compromise or the time limit.
pub fn simulate<T: UserModel + Send>(&mut self, mut user_models: Vec<UserModelIterator<T>>) {
let progress = self.make_progress_bar();
let simulation_limit = self.limit_sec();
assert_eq!(
user_models.len(),
self.users as usize,
"the number of user models must equal the configured number of users"
);
let mut summary = (0..self.users)
.into_par_iter()
.zip(&mut user_models)
.map(|(_user, mut user_models)| {
let mut user_summary = SimulationSummary::for_user();
for (message_time, is_malicious) in &mut user_models {
if message_time > simulation_limit {
break;
}
user_summary.record_message(message_time, is_malicious);
if is_malicious {
break;
}
}
if let Some(progress) = &progress {
progress.inc(1);
}
user_summary
})
.reduce(SimulationSummary::default, SimulationSummary::merge);
if let Some(progress) = &progress {
progress.finish_with_message("simulation complete");
}
let config_summary = self.summary_config();
summary.print_summary(&config_summary);
if let Some(csv_file_path) = &self.csv_file_path {
summary
.write_timeseries_csv(&config_summary, csv_file_path)
.expect("failed to write simulation CSV");
}
}
/// helper to make progress bar
fn make_progress_bar(&self) -> Option<ProgressBar> {
let bar = ProgressBar::new(u64::from(self.users));
let style = ProgressStyle::with_template(
"{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} users ({percent}%) eta {eta_precise}",
)
.unwrap_or_else(|_| ProgressStyle::default_bar())
.progress_chars("#>-");
bar.set_style(style);
Some(bar)
}
fn summary_config(&self) -> SimulationConfigSummary {
SimulationConfigSummary {
days: self.days,
epoch_seconds: self.epoch_time,
topologies_loaded: self.topology_generator.config.epochs as usize,
path_hops: self.path_hops,
path_sampler_type: self.sampler_type,
user_model_type: self.model_type,
malicious_node_fraction: self.topology_generator.config.malicious_node_fraction,
malicious_bandwidth_fraction: self
.topology_generator
.config
.malicious_bandwidth_fraction,
churn_rate: self.topology_generator.config.churn_rate,
}
}
#[inline]
pub fn limit_sec(&self) -> u64 {
u64::from(self.days) * 24 * 60 * 60
}
}

View File

@ -0,0 +1,232 @@
//! Summary and output for simulation results.
//!
//! This module only records the facts the simulation passes in and turns them into final
//! console summaries, or CSVs.
use std::fs::{File, create_dir_all};
use std::io::{BufWriter, Write};
use std::path::Path;
#[derive(Clone, Default)]
struct UserFirstCompromise {
timestamp: u64,
message_index: u64,
}
#[derive(Clone, Default)]
pub struct SimulationSummary {
users: u32,
total_messages: u64,
users_with_compromised_messages: u64,
first_compromise_timestamp: Option<u64>,
first_compromise_message_index: Option<u64>,
first_compromises: Vec<UserFirstCompromise>,
}
pub struct SimulationConfigSummary {
pub days: u32,
pub epoch_seconds: u32,
pub topologies_loaded: usize,
pub path_hops: usize,
pub path_sampler_type: &'static str,
pub user_model_type: &'static str,
pub malicious_node_fraction: f64,
pub malicious_bandwidth_fraction: f64,
pub churn_rate: f64,
}
impl SimulationSummary {
/// Create the local summary used while simulating one user.
pub fn for_user() -> Self {
Self {
users: 1,
..Default::default()
}
}
#[inline]
pub fn record_message(&mut self, message_timing: u64, is_malicious: bool) {
self.total_messages += 1;
// Each user's simulation stops at its first compromise.
if is_malicious && self.first_compromises.is_empty() {
let message_index = self.total_messages;
self.users_with_compromised_messages = 1;
self.first_compromise_timestamp = Some(message_timing);
self.first_compromise_message_index = Some(message_index);
self.first_compromises.push(UserFirstCompromise {
timestamp: message_timing,
message_index,
});
}
}
pub fn merge(mut self, other: Self) -> Self {
self.users += other.users;
self.total_messages += other.total_messages;
self.users_with_compromised_messages += other.users_with_compromised_messages;
self.first_compromise_timestamp = min_option(
self.first_compromise_timestamp,
other.first_compromise_timestamp,
);
self.first_compromise_message_index = min_option(
self.first_compromise_message_index,
other.first_compromise_message_index,
);
self.first_compromises.extend(other.first_compromises);
self
}
pub fn print_summary(&self, config_summary: &SimulationConfigSummary) {
println!("simulation_summary");
println!("users={}", self.users);
println!("days={}", config_summary.days);
println!("epoch_seconds={}", config_summary.epoch_seconds);
println!("topologies_loaded={}", config_summary.topologies_loaded);
println!("path_hops={}", config_summary.path_hops);
println!("path_sampler_type={}", config_summary.path_sampler_type);
println!("user_model_type={}", config_summary.user_model_type);
println!(
"malicious_node_fraction={:.6}",
config_summary.malicious_node_fraction
);
println!(
"malicious_bandwidth_fraction={:.6}",
config_summary.malicious_bandwidth_fraction
);
println!("churn_rate={:.6}", config_summary.churn_rate);
println!("total_messages={}", self.total_messages);
println!(
"users_with_compromised_messages={}",
self.users_with_compromised_messages
);
println!(
"users_without_compromised_messages={}",
u64::from(self.users).saturating_sub(self.users_with_compromised_messages)
);
println!(
"percentage_users_compromised={:.6}",
percentage(self.users_with_compromised_messages, u64::from(self.users))
);
println!(
"first_compromise_timestamp_seconds={}",
optional_u64(self.first_compromise_timestamp)
);
println!(
"fewest_messages_to_first_compromise={}",
optional_u64(self.first_compromise_message_index)
);
}
pub fn write_timeseries_csv(
&mut self,
config_summary: &SimulationConfigSummary,
csv_file_path: &Path,
) -> std::io::Result<()> {
if let Some(parent) = csv_file_path.parent()
&& !parent.as_os_str().is_empty()
{
create_dir_all(parent)?;
}
self.first_compromises
.sort_unstable_by_key(|compromise| compromise.timestamp);
let file = File::create(csv_file_path)?;
let mut writer = BufWriter::new(file);
writeln!(
writer,
"curve,x,day,users_with_compromise,total_users,cumulative_probability,cumulative_percentage"
)?;
let duration = u64::from(config_summary.days) * 24 * 60 * 60;
let interval = u64::from(config_summary.epoch_seconds.max(1));
let mut timestamp = 0;
let mut compromised = 0usize;
loop {
while compromised < self.first_compromises.len()
&& self.first_compromises[compromised].timestamp <= timestamp
{
compromised += 1;
}
writeln!(
writer,
"time_seconds,{},{:.6},{},{},{:.8},{:.6}",
timestamp,
timestamp as f64 / 86_400.0,
compromised,
self.users,
probability(compromised as u64, u64::from(self.users)),
percentage(compromised as u64, u64::from(self.users))
)?;
if timestamp >= duration {
break;
}
timestamp = timestamp.saturating_add(interval).min(duration);
}
let mut message_indices: Vec<u64> = self
.first_compromises
.iter()
.map(|compromise| compromise.message_index)
.collect();
message_indices.sort_unstable();
writeln!(
writer,
"message_count,0,,0,{},{:.8},{:.6}",
self.users,
probability(0, u64::from(self.users)),
percentage(0, u64::from(self.users))
)?;
let mut compromised = 0usize;
while compromised < message_indices.len() {
let message_index = message_indices[compromised];
while compromised < message_indices.len()
&& message_indices[compromised] <= message_index
{
compromised += 1;
}
writeln!(
writer,
"message_count,{},,{},{},{:.8},{:.6}",
message_index,
compromised,
self.users,
probability(compromised as u64, u64::from(self.users)),
percentage(compromised as u64, u64::from(self.users))
)?;
}
Ok(())
}
}
fn min_option(left: Option<u64>, right: Option<u64>) -> Option<u64> {
match (left, right) {
(Some(left), Some(right)) => Some(left.min(right)),
(Some(value), None) | (None, Some(value)) => Some(value),
(None, None) => None,
}
}
fn percentage(numerator: u64, denominator: u64) -> f64 {
probability(numerator, denominator) * 100.0
}
fn probability(numerator: u64, denominator: u64) -> f64 {
if denominator == 0 {
0.0
} else {
numerator as f64 / denominator as f64
}
}
fn optional_u64(value: Option<u64>) -> String {
value.map_or_else(|| "none".to_owned(), |value| value.to_string())
}

View File

@ -0,0 +1,406 @@
//! Runtime generator for free-route topology snapshots.
//!
//! It models churn, malicious node placement, and an authority/consensus-chosen
//! guard pool.
use crate::params::{
DEFAULT_CHURN_RATE, DEFAULT_EPOCHS, DEFAULT_GUARD_BANDWIDTH_FRACTION,
DEFAULT_MALICIOUS_BANDWIDTH_FRACTION, DEFAULT_MALICIOUS_NODE_FRACTION, DEFAULT_MIX_SIZE,
};
use rand::prelude::*;
use rand_distr::LogNormal;
use rand_distr::weighted_alias::WeightedAliasIndex;
#[derive(Debug, Clone)]
pub struct TopologyConfig {
/// if guard mode is on/off
pub guard_mode: bool,
/// number of mix nodes in the mixnet
pub mix_size: usize,
/// number of epochs
pub epochs: u32,
/// fraction and bandwidth of malicious nodes
pub malicious_node_fraction: f64,
pub malicious_bandwidth_fraction: f64,
pub churn_rate: f64,
/// bandwidth for the guard pool
pub guard_bandwidth_fraction: f64,
}
impl Default for TopologyConfig {
fn default() -> Self {
TopologyConfig {
guard_mode: false,
mix_size: DEFAULT_MIX_SIZE,
epochs: DEFAULT_EPOCHS,
malicious_node_fraction: DEFAULT_MALICIOUS_NODE_FRACTION,
malicious_bandwidth_fraction: DEFAULT_MALICIOUS_BANDWIDTH_FRACTION,
churn_rate: DEFAULT_CHURN_RATE,
guard_bandwidth_fraction: DEFAULT_GUARD_BANDWIDTH_FRACTION,
}
}
}
/// generate topologies
pub struct TopologyGenerator {
pub config: TopologyConfig,
}
impl Default for TopologyGenerator {
fn default() -> Self {
Self {
config: TopologyConfig::default(),
}
}
}
/// tags on the mix nodes, this can be extended later for more tag
/// which would probably mean more accurate simulation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MixNodeTag {
Guard,
}
/// represent a single mix node
#[derive(Debug, Clone)]
pub struct MixNode {
pub weight: f64,
pub mix_id: u32,
pub is_malicious: bool,
pub tags: Vec<MixNodeTag>,
}
impl MixNode {
pub fn has_tag(&self, tag: MixNodeTag) -> bool {
self.tags.contains(&tag)
}
fn add_tag(&mut self, tag: MixNodeTag) {
if !self.has_tag(tag) {
self.tags.push(tag);
}
}
fn remove_tag(&mut self, tag: MixNodeTag) {
self.tags.retain(|existing| *existing != tag);
}
}
/// this is the same an mix node but used only by the generator to mark as online/offline
#[derive(Debug, Clone)]
struct GeneratedMix {
node: MixNode,
online: bool,
}
/// One generated free-route topology snapshot for an epoch.
#[derive(Clone)]
pub struct Topology {
/// list of online/active mix nodes
active: Vec<MixNode>,
/// Cached bandwidth sampler shared by every user of this snapshot.
active_sampler: Option<WeightedAliasIndex<f64>>,
}
impl Topology {
/// New topology snapshot containing all nodes that are online in this epoch.
pub fn new(active: Vec<MixNode>) -> Self {
let active_sampler = if active.is_empty() {
None
} else {
Some(
WeightedAliasIndex::new(
active
.iter()
.map(|node| node.weight.max(f64::EPSILON))
.collect(),
)
.expect("active mix-node weights should produce a valid sampler"),
)
};
Topology {
active,
active_sampler,
}
}
pub(crate) fn active(&self) -> &[MixNode] {
&self.active
}
pub(crate) fn sample_active<R: Rng + ?Sized>(&self, rng: &mut R) -> Option<&MixNode> {
self.active_sampler
.as_ref()
.map(|sampler| &self.active[sampler.sample(rng)])
}
pub(crate) fn guards(&self) -> impl Iterator<Item = &MixNode> {
self.active
.iter()
.filter(|node| node.has_tag(MixNodeTag::Guard))
}
}
impl Default for Topology {
fn default() -> Self {
Topology::new(Vec::new())
}
}
impl TopologyGenerator {
/// new topology generator with given config
pub fn new(config: TopologyConfig) -> Self {
Self { config }
}
/// generate all `epochs` topologies at once
pub fn generate_topologies(&self) -> Vec<Topology> {
let mut rng = thread_rng();
let mut mixes = self.initial_mixes(&mut rng);
let mut consensus = Consensus::default();
let mut topologies = Vec::with_capacity(self.config.epochs as usize);
for _ in 0..self.config.epochs {
let guards = if self.config.guard_mode {
consensus.build_guard_set(&mixes, &self.config, &mut rng)
} else {
Vec::new()
};
topologies.push(snapshot(&mixes, &guards));
advance_epoch(&mut mixes, self.config.churn_rate, &mut rng);
}
topologies
}
fn initial_mixes<R: Rng + ?Sized>(&self, rng: &mut R) -> Vec<GeneratedMix> {
let bandwidth_distribution =
LogNormal::new(2.0, 1.0).expect("log-normal bandwidth distribution should be valid");
let mut mixes = Vec::with_capacity(self.config.mix_size);
for mixid in 0..self.config.mix_size {
let weight: f64 = bandwidth_distribution.sample(rng);
mixes.push(GeneratedMix {
node: MixNode {
mix_id: mixid as u32,
weight: weight.max(0.01),
is_malicious: false,
tags: Vec::new(),
},
online: true,
});
}
self.mark_malicious_nodes(&mut mixes, rng);
mixes
}
fn mark_malicious_nodes<R: Rng + ?Sized>(&self, mixes: &mut [GeneratedMix], rng: &mut R) {
let target_nodes = ((mixes.len() as f64)
* self.config.malicious_node_fraction.clamp(0.0, 1.0))
.ceil() as usize;
let total_bandwidth: f64 = mixes.iter().map(|mix| mix.node.weight).sum();
let target_bandwidth =
total_bandwidth * self.config.malicious_bandwidth_fraction.clamp(0.0, 1.0);
let mut indices: Vec<usize> = (0..mixes.len()).collect();
indices.shuffle(rng);
let mut selected_nodes = 0;
let mut selected_bandwidth = 0.0;
for index in indices {
if selected_nodes >= target_nodes && selected_bandwidth >= target_bandwidth {
break;
}
mixes[index].node.is_malicious = true;
selected_nodes += 1;
selected_bandwidth += mixes[index].node.weight;
}
}
}
fn snapshot(mixes: &[GeneratedMix], guards: &[u32]) -> Topology {
let active = mixes
.iter()
.filter(|mix| mix.online)
.map(|mix| {
let mut node = mix.node.clone();
node.remove_tag(MixNodeTag::Guard);
if guards.contains(&node.mix_id) {
node.add_tag(MixNodeTag::Guard);
}
node
})
.collect();
Topology::new(active)
}
fn advance_epoch<R: Rng + ?Sized>(mixes: &mut [GeneratedMix], churn_rate: f64, rng: &mut R) {
let churn_rate = churn_rate.clamp(0.0, 1.0);
for mix in mixes {
if mix.online {
if rng.gen_bool(churn_rate) {
mix.online = false;
}
} else if rng.gen_bool(churn_rate) {
mix.online = true;
}
}
}
/// consensus decides which nodes are eligible to be guards
/// maintains states relating to guard nodes.
#[derive(Debug, Default)]
struct Consensus {
/// Currently advertised guard nodes for this epoch.
active_guards: Vec<u32>,
/// General guards that are online but not currently needed to hit the
/// active guard bandwidth target.
backup_guards: Vec<u32>,
/// General guards that are offline and may be removed if they stay unstable.
offline_guards: Vec<u32>,
}
impl Consensus {
/// Build the guard set for the current epoch.
///
/// Selection preserves existing guard identities as much as possible:
/// 1. Online active guards remain active. unavailable guards move offline.
/// 2. Guards returning from the offline state become backups.
/// 3. If active guard bandwidth is below the configured target, backups are
/// promoted first.
/// 4. If backups are insufficient, new online nodes are selected by
/// bandwidth until the target is reached.
/// 5. When active bandwidth is already sufficient, do nothing.
fn build_guard_set<R: Rng + ?Sized>(
&mut self,
mixes: &[GeneratedMix],
config: &TopologyConfig,
rng: &mut R,
) -> Vec<u32> {
let mut active = Vec::new();
let mut backup = Vec::new();
let mut offline = Vec::new();
for guard in self.active_guards.drain(..) {
if is_online(mixes, guard) {
push_unique(&mut active, guard);
} else {
push_unique(&mut offline, guard);
}
}
for guard in self.backup_guards.drain(..) {
if is_online(mixes, guard) {
push_unique(&mut backup, guard);
} else {
push_unique(&mut offline, guard);
}
}
for guard in self.offline_guards.drain(..) {
if is_online(mixes, guard) {
push_unique(&mut backup, guard);
} else {
push_unique(&mut offline, guard);
}
}
let total_online_bandwidth: f64 = mixes
.iter()
.filter(|mix| mix.online)
.map(|mix| mix.node.weight)
.sum();
let guard_target = total_online_bandwidth * config.guard_bandwidth_fraction.clamp(0.0, 1.0);
if guard_target <= 0.0 {
self.active_guards.clear();
self.backup_guards.clear();
self.offline_guards.clear();
return Vec::new();
}
promote_until_target(mixes, &mut active, &mut backup, guard_target, rng);
if guard_bandwidth(mixes, &active) < guard_target {
let known: Vec<u32> = active
.iter()
.chain(&backup)
.chain(&offline)
.copied()
.collect();
let mut candidates: Vec<u32> = mixes
.iter()
.filter(|mix| mix.online && !known.contains(&mix.node.mix_id))
.map(|mix| mix.node.mix_id)
.collect();
promote_until_target(mixes, &mut active, &mut candidates, guard_target, rng);
}
self.active_guards = active;
self.backup_guards = backup;
self.offline_guards = offline;
self.active_guards.clone()
}
}
fn promote_until_target<R: Rng + ?Sized>(
mixes: &[GeneratedMix],
active: &mut Vec<u32>,
candidates: &mut Vec<u32>,
target: f64,
rng: &mut R,
) {
while guard_bandwidth(mixes, active) < target && !candidates.is_empty() {
let position = weighted_candidate_position(mixes, candidates, rng);
active.push(candidates.swap_remove(position));
}
}
fn weighted_candidate_position<R: Rng + ?Sized>(
mixes: &[GeneratedMix],
candidates: &[u32],
rng: &mut R,
) -> usize {
let total_weight: f64 = candidates
.iter()
.filter_map(|id| find_mix(mixes, *id))
.map(|mix| mix.node.weight)
.sum();
let mut target = rng.gen_range(0.0..total_weight);
for (position, id) in candidates.iter().enumerate() {
if let Some(mix) = find_mix(mixes, *id) {
target -= mix.node.weight;
if target <= 0.0 {
return position;
}
}
}
candidates.len() - 1
}
fn guard_bandwidth(mixes: &[GeneratedMix], guards: &[u32]) -> f64 {
guards
.iter()
.filter_map(|id| find_mix(mixes, *id))
.filter(|mix| mix.online)
.map(|mix| mix.node.weight)
.sum()
}
fn find_mix(mixes: &[GeneratedMix], mix_id: u32) -> Option<&GeneratedMix> {
mixes.iter().find(|mix| mix.node.mix_id == mix_id)
}
fn is_online(mixes: &[GeneratedMix], mix_id: u32) -> bool {
find_mix(mixes, mix_id).is_some_and(|mix| mix.online)
}
fn push_unique(guards: &mut Vec<u32>, guard: u32) {
if !guards.contains(&guard) {
guards.push(guard);
}
}

View File

@ -0,0 +1,86 @@
//! hidden-service traffic model.
//!
//! A request arrives every 5--15 minutes and produces 1--100 messages spread
//! over the following minute. Each message samples its own route.
use crate::path_sampler::PathSampler;
use crate::topologygen::MixNode;
use crate::usermodel::{RouteEvent, UserModel, UserModelInfo};
use rand::distributions::{Distribution, Uniform};
use rand::thread_rng;
const INTERVAL_MAX: u64 = 900;
const INTERVAL_MIN: u64 = 300;
const BURST_WINDOW_SECONDS: u64 = 60;
const MESSAGES_MAX: u64 = 100;
const MESSAGES_MIN: u64 = 1;
pub struct HiddenServiceModel<'a, S: PathSampler> {
model_info: UserModelInfo<'a>,
/// Start time of the current burst.
current_time: u64,
interval_die: Uniform<u64>,
message_count_die: Uniform<u64>,
burst_offset_die: Uniform<u64>,
burst_messages: Vec<u64>,
next_burst_message: usize,
path_sampler: S,
}
impl<'a, S: PathSampler> HiddenServiceModel<'a, S> {
pub fn new(model_info: UserModelInfo<'a>, path_sampler: S) -> Self {
Self {
model_info,
current_time: 0,
interval_die: Uniform::from(INTERVAL_MIN..=INTERVAL_MAX),
message_count_die: Uniform::from(MESSAGES_MIN..=MESSAGES_MAX),
burst_offset_die: Uniform::from(0..=BURST_WINDOW_SECONDS),
burst_messages: Vec::new(),
next_burst_message: 0,
path_sampler,
}
}
fn has_pending_burst_messages(&self) -> bool {
self.next_burst_message < self.burst_messages.len()
}
fn start_next_burst(&mut self) {
let mut rng = thread_rng();
self.current_time += self.interval_die.sample(&mut rng);
self.next_burst_message = 0;
self.burst_messages.clear();
let message_count = self.message_count_die.sample(&mut rng) as usize;
self.burst_messages.reserve(message_count);
for _ in 0..message_count {
let offset = self.burst_offset_die.sample(&mut rng);
self.burst_messages.push(self.current_time + offset);
}
self.burst_messages.sort_unstable();
}
fn next_message_timing(&mut self) -> u64 {
if !self.has_pending_burst_messages() {
self.start_next_burst();
}
let message_timing = self.burst_messages[self.next_burst_message];
self.next_burst_message += 1;
message_timing
}
}
impl<S: PathSampler> UserModel for HiddenServiceModel<'_, S> {
fn fetch_next(&mut self) -> Option<RouteEvent> {
let next_timing = self.next_message_timing();
let (topology_index, topology) = self.model_info.topology_at(next_timing)?;
let path = self.path_sampler.sample_path(topology_index, topology);
let is_malicious = Self::is_path_malicious(&path);
Some((next_timing, is_malicious))
}
fn is_path_malicious(path: &[MixNode]) -> bool {
!path.is_empty() && path.iter().all(|node| node.is_malicious)
}
}

View File

@ -0,0 +1,86 @@
//! Shared user-model trait
//!
//! models implement two main things
//! - `fetch_next` to say when the next message is sent.
//! - `is_path_malicious` to decide when a path is considered malicious
//!
mod hidden_service_model;
mod simple_model;
use crate::topologygen::MixNode;
use std::ops::{Deref, DerefMut};
pub use hidden_service_model::HiddenServiceModel;
pub use simple_model::SimpleModel;
/// time of the message
pub type MessageTime = u64;
/// A route event that gets fired whenever a packet is sent through the mix, containing:
/// - message time
/// - if sampled path was malicious [`true`] or not [`false`]
pub type RouteEvent = (MessageTime, bool);
pub trait UserModel {
/// Return the next route event for this user, or `None` once the model has
/// reached the simulation time limit.
fn fetch_next(&mut self) -> Option<RouteEvent>;
/// the model defines what counts as a malicious path.
fn is_path_malicious(path: &[MixNode]) -> bool;
}
pub struct UserModelInfo<'a> {
topologies: &'a [crate::topologygen::Topology],
epoch_seconds: u32,
}
impl<'a> UserModelInfo<'a> {
pub fn new(topologies: &'a [crate::topologygen::Topology], epoch_seconds: u32) -> Self {
Self {
topologies,
epoch_seconds,
}
}
fn topology_at(
&self,
message_time: MessageTime,
) -> Option<(usize, &'a crate::topologygen::Topology)> {
if self.epoch_seconds == 0 {
return None;
}
let index = usize::try_from(message_time / u64::from(self.epoch_seconds)).ok()?;
self.topologies.get(index).map(|topology| (index, topology))
}
}
/// Wrapper that gives every user model a common Iterator implementation.
pub struct UserModelIterator<T>(pub T);
impl<T> Deref for UserModelIterator<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<T> DerefMut for UserModelIterator<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl<T> Iterator for UserModelIterator<T>
where
T: UserModel,
{
type Item = RouteEvent;
fn next(&mut self) -> Option<Self::Item> {
self.fetch_next()
}
}

View File

@ -0,0 +1,46 @@
use crate::path_sampler::PathSampler;
use crate::topologygen::MixNode;
use crate::usermodel::{RouteEvent, UserModel, UserModelInfo};
use rand::distributions::{Distribution, Uniform};
use rand::thread_rng;
const INTERVAL_MAX: u64 = 900;
const INTERVAL_MIN: u64 = 300;
pub struct SimpleModel<'a, S: PathSampler> {
model_info: UserModelInfo<'a>,
/// timestamp of current time, starting at 0.
current_time: u64,
die: Uniform<u64>,
path_sampler: S,
}
impl<'a, S: PathSampler> SimpleModel<'a, S> {
pub fn new(model_info: UserModelInfo<'a>, path_sampler: S) -> Self {
Self {
model_info,
current_time: 0,
die: Uniform::from(INTERVAL_MIN..INTERVAL_MAX),
path_sampler,
}
}
fn get_next_message_timing(&mut self) -> u64 {
self.current_time += self.die.sample(&mut thread_rng());
self.current_time
}
}
impl<S: PathSampler> UserModel for SimpleModel<'_, S> {
fn fetch_next(&mut self) -> Option<RouteEvent> {
let next_timing = self.get_next_message_timing();
let (topology_index, topology) = self.model_info.topology_at(next_timing)?;
let path = self.path_sampler.sample_path(topology_index, topology);
let is_malicious = Self::is_path_malicious(&path);
Some((next_timing, is_malicious))
}
fn is_path_malicious(path: &[MixNode]) -> bool {
!path.is_empty() && path.iter().all(|node| node.is_malicious)
}
}

View File

@ -0,0 +1,13 @@
[package]
name = "routesim-v2"
version = "0.1.0"
edition = "2024"
[dependencies]
rayon = "1.5"
rand = {version = "0.8.4", features=["small_rng"]}
rand_distr = "0.4.3"
clap = { version = "3.1.0", features = ["derive"] }
rustc-hash = "1.0"
array-init = "2.0.0"
chrono = "0.4.19"

View File

@ -0,0 +1,133 @@
# routesim-v2
`routesim-v2` is a simulator for stratified mix. It reads one or more layered topology snapshots from an [MTG Simulator](https://github.com/sus0pid/MTG-Simulator) CSV file and simulates independent users sending messages.
Every path contains three hops (mtg only generates 3 layer), one from each layer. Nodes are sampled in proportion to their bandwidth, and a message path is considered compromised only when all three selected nodes are malicious.
## Topology input files
The first three CSV columns describe each mix:
```text
mix_id,bandwidth,malicious
```
- `mix_id` is an integer identifier.
- `bandwidth` is the node's sampling weight.
- `malicious` is `true` or `false`.
The remaining columns assign that mix to a layer in each topology epoch. Layer values `0`, `1`, and `2` place a node in the corresponding route layer. `-1`, mean that the node is not active/selected in that epoch.
Example file containing one epoch:
```csv
mix_id,bandwidth,malicious,layer
0,7.2,false,-1
1,13.0,false,2
2,9.5,true,0
```
For multiple epochs:
```csv
mix_id,bandwidth,malicious,epoch_0,epoch_1,epoch_2
0,7.2,false,0,-1,1
1,13.0,false,1,1,2
2,9.5,true,2,0,-1
```
Each epoch is valid for `--epoch` seconds. See [testfiles](./testfiles) for the test files used in the paper.
## Guards and vanguards
Guards and vanguards are enabled by default:
- hop 0 uses a persistent vanguard from layer 0
- hop 1 uses a persistent guard from layer 1
- hop 2 is sampled by bandwidth from layer 2
Each user maintains its own guard/vanguard sets. When the topology changes, it keeps the first candidate still present in the appropriate layer. If none are present, it extends the set from the current epoch and selects a new node.
Use `--disable-vanguards` for only the guard. Use `-d` to disable both guard and vanguards and sample all three layers by bandwidth.
## Models
### `simple`
The default model sends one message after each uniformly sampled interval in `[300, 900)` seconds.
### `simple-hidden-service`
The simple hidden-service model:
- receives a request after each uniformly sampled interval in `[300, 900]` seconds.
- sends a burst of between 1 and 100 messages for the request.
- places those messages at random offsets in the following 60 seconds.
Select it with `--model simple-hidden-service`.
## Running the simulator
Run these examples from the workspace root.
Print an aggregate summary for the included single epoch topology:
```bash
cargo run -p routesim-v2 --release -- \
--topology-file crates/routesim-v2/testfiles/single_layout/1000_137_Random_BP_layout.csv \
--model simple \
--epoch 86401 \
--users 5000 \
--days 1 \
--summary
```
Print every hidden-service route while keeping guards but disabling vanguards:
```bash
cargo run -p routesim-v2 --release -- \
--topology-file <layout.csv> \
--model simple-hidden-service \
--epoch 3600 \
--users 5000 \
--days 30 \
--disable-vanguards \
--to-console
```
View every option with:
```bash
cargo run -p routesim-v2 -- --help
```
## Plotting first-compromise results
The plotting script requires Python 3 and Matplotlib.
From the workspace root:
```bash
python3 crates/routesim-v2/scripts/plot_v2_console.py \
--format simple \
--users 5000 \
--in-file <routes.txt> \
--out-prefix <output-prefix>
```
The helper scripts run the simulation and plotting step together:
```bash
crates/routesim-v2/scripts/run_simple_plot.sh <layout.csv> <output-directory>
crates/routesim-v2/scripts/run_hidden_service_plot.sh <layout.csv> <output-directory>
```
## limitations
- only stratified mix.
- required the MTG simulator output.
- Paths have a fixed length of three hops.
- sampling is always based on bandwidth (as in the paper).
- Guard and vanguard maintenance is a simplified persistence model, not a complete Tor implementation.
Most of the code is from [routesim](https://github.com/frochet/routesim). See [freeroutesim](../freeroutesim) which doesn't have these limitation.

View File

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -8,7 +8,7 @@ Summary:
- compromised_users=5000
- percentage_compromised_users=100.000000
![plot](./img/simple.png)
![plot](img/simple.png)
### with guards
Summary:
@ -18,7 +18,7 @@ Summary:
- compromised_users=1943
- percentage_compromised_users=38.860000
![plot](./img/simple-guard.png)
![plot](img/simple-guard.png)
the result seem to be around 0.4 probably
because we have 10% adversary control and a set of 5 guards which we sample.
@ -32,7 +32,7 @@ Summary:
- compromised_users=1589
- percentage_compromised_users=31.780000
![plot](./img/simple-vanguard.png)
![plot](img/simple-vanguard.png)
## hidden service Model
@ -44,9 +44,9 @@ Summary:
- compromised_users=5000
- percentage_compromised_users=100.000000
![plot](./img/hs-time.png)
![plot](img/hs-time.png)
![plot](./img/hs-msgs.png)
![plot](img/hs-msgs.png)
### with guards