hs simulator v1

This commit is contained in:
M Alghazwi 2026-07-03 14:40:59 +02:00
commit 754bf5cc93
No known key found for this signature in database
GPG Key ID: 646E567CAD7DB607
33 changed files with 16647 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
#IDE Related
.idea
# Cargo build
/target
Cargo.lock
/output
# Profile-guided optimization
/tmp
pgo-data.profdata
# MacOS nuisances
.DS_Store

18
Cargo.toml Normal file
View File

@ -0,0 +1,18 @@
[package]
name = "hs-mix-sim"
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"
[profile.release]
lto = true
debug = 1
codegen-units = 1

178
LICENSE-APACHE-v2.txt Normal file
View File

@ -0,0 +1,178 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

21
LICENSE-MIT.txt Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2026 Status Research & Development GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

121
README.md Normal file
View File

@ -0,0 +1,121 @@
# 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.
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.
## Current Models
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)

BIN
results/img/hs-msgs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
results/img/hs-time.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
results/img/simple.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

70
results/results.md Normal file
View File

@ -0,0 +1,70 @@
## Simple Model
### without guards
Summary:
- users=5000
- days=30
- total_messages=21616205
- compromised_users=5000
- percentage_compromised_users=100.000000
![plot](./img/simple.png)
### with guards
Summary:
- users=5000
- days=30
- total_messages=21612322
- compromised_users=1943
- percentage_compromised_users=38.860000
![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.
changing these will probably change outcome.
### with guards and vanguards
Summary:
- users=5000
- days=30
- total_messages=21616884
- compromised_users=1589
- percentage_compromised_users=31.780000
![plot](./img/simple-vanguard.png)
## hidden service Model
### without guards
Summary:
- users=5000
- days=1
- total_messages=36224095
- compromised_users=5000
- percentage_compromised_users=100.000000
![plot](./img/hs-time.png)
![plot](./img/hs-msgs.png)
### with guards
Summary:
- users=5000
- days=30
- total_messages=1090812738
- compromised_users=2165
- percentage_compromised_users=43.300000
TODO: generating figures takes too long, need to fix few things.
### with guards and vanguards
Summary:
- users=5000
- days=30
- total_messages=1090615016
- compromised_users=1720
- percentage_compromised_users=34.400000

246
scripts/plot_v2_console.py Executable file
View File

@ -0,0 +1,246 @@
#!/usr/bin/env python3
import argparse
import calendar
import csv
import math
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Optional
@dataclass
class UserStats:
messages: int = 0
bursts_seen: set[str] = field(default_factory=set)
first_compromise_seconds: float = math.inf
messages_until_compromise: float = math.inf
bursts_until_compromise: float = math.inf
def parse_bool(value: str) -> bool:
lowered = value.strip().lower()
if lowered in {"true", "1", "yes"}:
return True
if lowered in {"false", "0", "no"}:
return False
raise ValueError(f"invalid boolean: {value}")
def parse_timestamp(date_part: str, time_part: str) -> int:
dt = datetime.fromisoformat(f"{date_part} {time_part}")
return calendar.timegm(dt.timetuple())
def parse_console_line(line: str, expected_format: str):
parts = line.split()
if expected_format == "simple":
if len(parts) != 5:
return None
date_part, time_part, user, _path, compromised = parts
return parse_timestamp(date_part, time_part), int(user), None, parse_bool(compromised)
if expected_format == "hidden-service":
if len(parts) != 6:
return None
date_part, time_part, user, burst_id, _path, compromised = parts
return parse_timestamp(date_part, time_part), int(user), burst_id, parse_bool(compromised)
raise ValueError(f"unsupported format: {expected_format}")
def process_console_output(path: Path, expected_format: str, users: Optional[int]):
stats: dict[int, UserStats] = {}
skipped = 0
with path.open() as infile:
for line in infile:
line = line.strip()
if not line:
continue
parsed = parse_console_line(line, expected_format)
if parsed is None:
skipped += 1
continue
timestamp, user, burst_id, compromised = parsed
user_stats = stats.setdefault(user, UserStats())
user_stats.messages += 1
if burst_id is not None:
user_stats.bursts_seen.add(burst_id)
if compromised and math.isinf(user_stats.first_compromise_seconds):
user_stats.first_compromise_seconds = timestamp
user_stats.messages_until_compromise = user_stats.messages
if burst_id is not None:
user_stats.bursts_until_compromise = len(user_stats.bursts_seen)
if users is None:
users = max(stats.keys(), default=-1) + 1
for user in range(users):
stats.setdefault(user, UserStats())
return stats, skipped
def finite_values(values):
return sorted(value for value in values if math.isfinite(value))
def choose_time_unit(values):
finite = finite_values(values)
if not finite:
return 1, "seconds"
max_value = max(finite)
if max_value <= 7 * 24 * 60 * 60:
return 60 * 60, "hours"
if max_value <= 30 * 24 * 60 * 60:
return 24 * 60 * 60, "days"
if max_value <= 12 * 30 * 24 * 60 * 60:
return 7 * 24 * 60 * 60, "weeks"
return 30 * 24 * 60 * 60, "months"
def plot_cdf(values, total_users, xlabel, out_file):
import matplotlib
matplotlib.use("PDF")
import matplotlib.pyplot as plt
finite = finite_values(values)
fig, ax = plt.subplots(figsize=(6.4, 3.8))
if finite:
xs = []
ys = []
last_y = 0.0
for idx, value in enumerate(finite, start=1):
y = idx / total_users
xs.extend([value, value])
ys.extend([last_y, y])
last_y = y
ax.plot(xs, ys, "-o", linewidth=2, markevery=max(1, len(xs) // 10))
ax.set_xlim(left=0)
else:
ax.text(0.5, 0.5, "No compromised users", ha="center", va="center")
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.set_xlabel(xlabel)
ax.set_ylabel("Cumulative probability")
ax.grid(True)
fig.tight_layout()
fig.savefig(out_file)
plt.close(fig)
def write_per_user_csv(stats, out_file):
with out_file.open("w", newline="") as outfile:
writer = csv.writer(outfile)
writer.writerow(
[
"user",
"messages",
"bursts",
"first_compromise_seconds",
"messages_until_compromise",
"bursts_until_compromise",
]
)
for user in sorted(stats):
user_stats = stats[user]
writer.writerow(
[
user,
user_stats.messages,
len(user_stats.bursts_seen),
"inf"
if math.isinf(user_stats.first_compromise_seconds)
else int(user_stats.first_compromise_seconds),
"inf"
if math.isinf(user_stats.messages_until_compromise)
else int(user_stats.messages_until_compromise),
"inf"
if math.isinf(user_stats.bursts_until_compromise)
else int(user_stats.bursts_until_compromise),
]
)
def write_summary(stats, skipped, out_file):
users = len(stats)
compromised_users = sum(
1 for user_stats in stats.values() if math.isfinite(user_stats.first_compromise_seconds)
)
total_messages = sum(user_stats.messages for user_stats in stats.values())
compromised_pct = compromised_users / users * 100 if users else 0
with out_file.open("w") as outfile:
outfile.write(f"users={users}\n")
outfile.write(f"total_messages={total_messages}\n")
outfile.write(f"compromised_users={compromised_users}\n")
outfile.write(f"percentage_compromised_users={compromised_pct:.6f}\n")
outfile.write(f"skipped_lines={skipped}\n")
def main():
parser = argparse.ArgumentParser(
description="Parse routesim-v2 --to-console output and plot CDFs."
)
parser.add_argument("--in-file", required=True, type=Path)
parser.add_argument("--out-prefix", required=True, type=Path)
parser.add_argument("--users", type=int)
parser.add_argument(
"--format",
choices=["simple", "hidden-service"],
required=True,
help="Expected routesim-v2 console output format.",
)
parser.add_argument(
"--plots",
default=None,
help="Comma-separated plots to generate: time,messages,bursts. Defaults by format.",
)
args = parser.parse_args()
stats, skipped = process_console_output(args.in_file, args.format, args.users)
args.out_prefix.parent.mkdir(parents=True, exist_ok=True)
write_per_user_csv(stats, args.out_prefix.with_suffix(".per_user.csv"))
write_summary(stats, skipped, args.out_prefix.with_suffix(".summary.txt"))
plots = (
args.plots.split(",")
if args.plots
else (["time", "messages", "bursts"] if args.format == "hidden-service" else ["time", "messages"])
)
if "time" in plots:
raw_values = [user_stats.first_compromise_seconds for user_stats in stats.values()]
divider, unit = choose_time_unit(raw_values)
plot_cdf(
[value / divider if math.isfinite(value) else value for value in raw_values],
len(stats),
f"time to first compromise [{unit}]",
args.out_prefix.with_name(args.out_prefix.name + "_time_cdf.pdf"),
)
if "messages" in plots:
plot_cdf(
[user_stats.messages_until_compromise for user_stats in stats.values()],
len(stats),
"messages until first compromise",
args.out_prefix.with_name(args.out_prefix.name + "_messages_cdf.pdf"),
)
if "bursts" in plots:
plot_cdf(
[user_stats.bursts_until_compromise for user_stats in stats.values()],
len(stats),
"bursts until first compromise",
args.out_prefix.with_name(args.out_prefix.name + "_bursts_cdf.pdf"),
)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 2 ]]; then
echo "Usage: $0 TOPOLOGY_FILE OUT_DIR [USERS=5000] [DAYS=30] [EPOCH=3600] [ROUTESIM_FLAGS...]" >&2
echo "Example: $0 testfiles/layout_data/bow_tie/dynamic_hybrid_steady_0.03_layout.csv results 5000 30 3600 --disable-vanguards" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CALLER_CWD="$(pwd)"
TOPOLOGY_FILE="$1"
OUT_DIR="$2"
USERS="${3:-5000}"
DAYS="${4:-30}"
EPOCH="${5:-3600}"
shift $(( $# >= 5 ? 5 : $# ))
case "$TOPOLOGY_FILE" in
/*) ;;
*) TOPOLOGY_FILE="$CALLER_CWD/$TOPOLOGY_FILE" ;;
esac
case "$OUT_DIR" in
/*) ;;
*) OUT_DIR="$CALLER_CWD/$OUT_DIR" ;;
esac
mkdir -p "$OUT_DIR"
export MPLCONFIGDIR="${MPLCONFIGDIR:-$OUT_DIR/.matplotlib}"
mkdir -p "$MPLCONFIGDIR"
RAW_OUT="$OUT_DIR/hidden_service_${USERS}_${DAYS}_${EPOCH}.routes"
OUT_PREFIX="$OUT_DIR/hidden_service_${USERS}_${DAYS}_${EPOCH}"
cd "$REPO_ROOT"
cargo run --release -- \
--model simple-hidden-service \
--topology-file "$TOPOLOGY_FILE" \
--users "$USERS" \
--days "$DAYS" \
--epoch "$EPOCH" \
--to-console \
"$@" > "$RAW_OUT"
python3 "$SCRIPT_DIR/plot_v2_console.py" \
--format hidden-service \
--users "$USERS" \
--in-file "$RAW_OUT" \
--out-prefix "$OUT_PREFIX"
echo "raw_output=$RAW_OUT"
echo "per_user=$OUT_PREFIX.per_user.csv"
echo "summary=$OUT_PREFIX.summary.txt"
echo "time_plot=${OUT_PREFIX}_time_cdf.pdf"
echo "messages_plot=${OUT_PREFIX}_messages_cdf.pdf"
echo "bursts_plot=${OUT_PREFIX}_bursts_cdf.pdf"

58
scripts/run_simple_plot.sh Executable file
View File

@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 2 ]]; then
echo "Usage: $0 TOPOLOGY_FILE OUT_DIR [USERS=5000] [DAYS=30] [EPOCH=3600] [ROUTESIM_FLAGS...]" >&2
echo "Example: $0 testfiles/layout_data/bow_tie/dynamic_hybrid_steady_0.03_layout.csv results 5000 30 3600 --disable-vanguards" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CALLER_CWD="$(pwd)"
TOPOLOGY_FILE="$1"
OUT_DIR="$2"
USERS="${3:-5000}"
DAYS="${4:-30}"
EPOCH="${5:-3600}"
shift $(( $# >= 5 ? 5 : $# ))
case "$TOPOLOGY_FILE" in
/*) ;;
*) TOPOLOGY_FILE="$CALLER_CWD/$TOPOLOGY_FILE" ;;
esac
case "$OUT_DIR" in
/*) ;;
*) OUT_DIR="$CALLER_CWD/$OUT_DIR" ;;
esac
mkdir -p "$OUT_DIR"
export MPLCONFIGDIR="${MPLCONFIGDIR:-$OUT_DIR/.matplotlib}"
mkdir -p "$MPLCONFIGDIR"
RAW_OUT="$OUT_DIR/simple_${USERS}_${DAYS}_${EPOCH}.routes"
OUT_PREFIX="$OUT_DIR/simple_${USERS}_${DAYS}_${EPOCH}"
cd "$REPO_ROOT"
cargo run --release -- \
--model simple \
--topology-file "$TOPOLOGY_FILE" \
--users "$USERS" \
--days "$DAYS" \
--epoch "$EPOCH" \
--to-console \
"$@" > "$RAW_OUT"
python3 "$SCRIPT_DIR/plot_v2_console.py" \
--format simple \
--users "$USERS" \
--in-file "$RAW_OUT" \
--out-prefix "$OUT_PREFIX"
echo "raw_output=$RAW_OUT"
echo "per_user=$OUT_PREFIX.per_user.csv"
echo "summary=$OUT_PREFIX.summary.txt"
echo "time_plot=${OUT_PREFIX}_time_cdf.pdf"
echo "messages_plot=${OUT_PREFIX}_messages_cdf.pdf"

292
src/config.rs Normal file
View File

@ -0,0 +1,292 @@
use array_init::array_init;
use rand::prelude::*;
use rand_distr::weighted_alias::WeightedAliasIndex;
use rustc_hash::FxHashMap as HashMap;
use std::fs::File;
use std::io::BufRead;
use std::io::BufReader;
use std::path::Path;
use std::vec::IntoIter;
pub const PATH_LENGTH: i8 = 3;
#[allow(dead_code)]
/// in byte
pub const PAYLOAD_SIZE: usize = 2048;
/// Default sample size for guards -- todo move this in clap
pub const GUARDS_SAMPLE_SIZE: usize = 5;
/// How much do we extend the sample size each time we ran out of guard?
pub const GUARDS_SAMPLE_SIZE_EXTEND: usize = 2;
/// guards are use in layer... [0..n[
pub const GUARDS_LAYER: usize = 1;
/// Default sample size for vanguards.
pub const VANGUARDS_SAMPLE_SIZE: usize = 5;
/// How much do we extend the vanguard sample size each time we ran out.
pub const VANGUARDS_SAMPLE_SIZE_EXTEND: usize = 2;
/// vanguards are used in the first route hop.
pub const VANGUARDS_LAYER: usize = 0;
#[derive(Debug, Clone)]
pub struct Mixnode {
pub layer: i8,
pub weight: f64,
pub mixid: u32,
pub is_malicious: bool,
}
/// A config is a set of mixes for each layer
/// and a hashmap for unselected mixes.
#[derive(Default, Clone)]
pub struct TopologyConfig {
#[allow(dead_code)]
pub filename: String,
pub epoch: u32,
/// The path length
layers: [Vec<Mixnode>; PATH_LENGTH as usize],
wc_layers: [Box<Option<WeightedAliasIndex<f64>>>; PATH_LENGTH as usize],
unselected: HashMap<u32, Mixnode>,
}
impl TopologyConfig {
pub fn new(filename: String, epoch: u32) -> Self {
TopologyConfig {
filename,
epoch,
wc_layers: array_init(|_| Box::new(None)),
..Default::default()
}
}
#[allow(dead_code)]
pub fn layers(&self) -> &[Vec<Mixnode>] {
&self.layers
}
#[allow(dead_code)]
pub fn unselected(&self) -> &HashMap<u32, Mixnode> {
&self.unselected
}
pub fn has_mix_in_layer(&self, layer: usize, mixid: u32) -> bool {
self.layers[layer].iter().any(|mix| mix.mixid == mixid)
}
fn push_mix(&mut self, mut mix: Mixnode, layer: i8) {
mix.layer = layer;
match mix.layer {
// This trick gets arround the unsupported excluded range syntax for now
0..=PATH_LENGTH if mix.layer < PATH_LENGTH => self.layers[mix.layer as usize].push(mix),
_ => {
self.unselected.insert(mix.mixid, mix);
}
};
}
fn build_weighted_samplers(&mut self) {
for i in 0..PATH_LENGTH {
self.wc_layers[i as usize] = Box::new(Some(
WeightedAliasIndex::new(
self.layers[i as usize]
.iter()
.map(|item| item.weight)
.collect(),
)
.unwrap_or_else(|_| {
panic!(
"Unable to build weighted sampler for {} epoch {} layer {}",
self.filename, self.epoch, i
)
}),
));
}
}
/// Sample n mixes from layer l.
pub fn sample_layer<'a, R: Rng + ?Sized>(
&'a self,
l: usize,
n_mixes: usize,
rng: &mut R,
) -> IntoIter<&'a Mixnode> {
let mut sample_mixes = vec![];
for _ in 0..n_mixes {
if let Some(wc) = &*self.wc_layers[l] {
sample_mixes.push(&self.layers[l][wc.sample(rng)]);
}
}
sample_mixes.into_iter()
}
/// Sample a route from the network layer configuration
#[inline]
pub fn sample_path<'a, R: Rng + ?Sized>(
&'a self,
rng: &mut R,
vanguard: Option<&'a Mixnode>,
guard: Option<&'a Mixnode>,
) -> IntoIter<&'a Mixnode> {
let mut path = vec![];
// returns an owned iterator
for i in 0..PATH_LENGTH {
if let Some(wc) = &*self.wc_layers[i as usize] {
match (vanguard, guard) {
(Some(v), _) if i as usize == VANGUARDS_LAYER => path.push(v),
(_, Some(g)) if i as usize == GUARDS_LAYER => path.push(g),
_ => path.push(&self.layers[i as usize][wc.sample(rng)]),
}
}
}
path.into_iter()
}
}
/// Load all topology epochs from one layout file.
///
/// Each row must start with:
/// mixid [integer], weight [float], is_malicious [bool]
///
/// Remaining columns are layer assignments. A single `layer` column produces one topology.
/// Multiple `epoch_N` columns produce one topology per epoch.
pub fn load<P>(filename: P) -> Vec<TopologyConfig>
where
P: AsRef<Path>,
{
let file = File::open(&filename).expect("Unable to open the file");
let filename_string: String = filename.as_ref().to_str().unwrap().to_owned();
let mut line_reader = BufReader::new(file).lines();
let header = line_reader
.next()
.expect("Topology file is empty")
.expect("Unable to read topology header");
let header_cols: Vec<_> = header.split(',').map(|col| col.trim()).collect();
if header_cols.len() < 4 {
panic!(
"Topology file {} must have at least 4 columns",
filename.as_ref().display()
);
}
let epochs = parse_epoch_columns(&header_cols[3..]);
let mut configs: Vec<TopologyConfig> = epochs
.iter()
.map(|epoch| TopologyConfig::new(filename_string.clone(), *epoch))
.collect();
for line_r in line_reader {
let line = line_r.expect("Something went wrong while reading topology file");
let cols: Vec<_> = line.split(',').map(|col| col.trim()).collect();
if cols.len() < header_cols.len() {
panic!(
"Unable to parse {} into a topology row -- expected {} columns, got {}: {}",
filename.as_ref().display(),
header_cols.len(),
cols.len(),
line
);
}
let mix = parse_mix_row(&cols, filename.as_ref());
for (idx, config) in configs.iter_mut().enumerate() {
let layer = cols[idx + 3].parse::<i8>().unwrap_or_else(|_| {
panic!(
"Unable to parse layer value '{}' in {}",
cols[idx + 3],
filename.as_ref().display()
)
});
config.push_mix(mix.clone(), layer);
}
}
for config in &mut configs {
config.build_weighted_samplers();
}
configs
}
fn parse_epoch_columns(columns: &[&str]) -> Vec<u32> {
columns
.iter()
.enumerate()
.map(|(idx, column)| {
if *column == "layer" {
idx as u32
} else {
column
.split('_')
.nth(1)
.and_then(|epoch| epoch.parse::<u32>().ok())
.unwrap_or(idx as u32)
}
})
.collect()
}
fn parse_mix_row(cols: &[&str], filename: &Path) -> Mixnode {
let mixid = cols[0].parse::<u32>().unwrap_or_else(|_| {
panic!(
"Unable to parse mix id '{}' in {}",
cols[0],
filename.display()
)
});
let weight = cols[1].parse::<f64>().unwrap_or_else(|_| {
panic!(
"Unable to parse bandwidth '{}' in {}",
cols[1],
filename.display()
)
});
let is_malicious = cols[2].to_lowercase().parse::<bool>().unwrap_or(false);
Mixnode {
layer: -1,
weight,
mixid,
is_malicious,
}
}
#[test]
fn load_test_topology_config() {
let configs = load("testfiles/single_layout/1000_137_Random_BP_layout.csv");
let config = &configs[0];
let mix = &config.layers()[0][42];
//42│20.430784458454426│False│0
assert_eq!(mix.is_malicious, false);
assert_eq!(mix.weight, 1.8480844586590168);
}
#[test]
fn test_sample_path() {
let configs = load("testfiles/single_layout/1000_137_Random_BP_layout.csv");
let config = &configs[0];
let mut rng = thread_rng();
let path = config.sample_path(&mut rng, None, None);
let path = path.collect::<Vec<_>>();
assert_eq!(path.len(), 3);
}
#[test]
fn test_sample_path_uses_fixed_vanguard_and_guard() {
let configs = load("testfiles/single_layout/1000_137_Random_BP_layout.csv");
let config = &configs[0];
let mut rng = thread_rng();
let vanguard = &config.layers()[VANGUARDS_LAYER][0];
let guard = &config.layers()[GUARDS_LAYER][0];
let path = config
.sample_path(&mut rng, Some(vanguard), Some(guard))
.collect::<Vec<_>>();
assert_eq!(path.len(), 3);
assert_eq!(path[0].mixid, vanguard.mixid);
assert_eq!(path[1].mixid, guard.mixid);
assert_eq!(path[2].layer, 2);
}
#[test]
fn load_multi_epoch_layout() {
let configs = load("testfiles/layout_data/bow_tie/dynamic_hybrid_steady_0.03_layout.csv");
assert_eq!(configs.len(), 1000);
assert_eq!(configs[0].epoch, 0);
assert_eq!(configs[999].epoch, 999);
assert_eq!(configs[0].layers().len(), PATH_LENGTH as usize);
}

120
src/main.rs Normal file
View File

@ -0,0 +1,120 @@
//! Simulating behavioural activity within a Mixnet
//!
//! This tool evaluates the probability of deanonymization through time,
//! assuming some level of adversarial activity among the mixes.
//!
//! We expect to take in input mixnet [`topologies`](config::TopologyConfig).
//! The simple model samples one message per user at a time and virtually sends
//! those messages through the mixnet.
//!
//! The simulator appliess a Monte Carlo method to draw paths and outputs path
//! information for each message sent by each sample.
//!
mod config;
mod simulation;
mod simplehiddenservicemodel;
mod simplemodel;
mod usermodel;
use clap::{ArgEnum, Parser};
use config::TopologyConfig;
use simulation::Runable;
use simplehiddenservicemodel::*;
use simplemodel::*;
/// currently supported models
#[derive(Copy, Clone, Debug, ArgEnum)]
enum Model {
Simple,
SimpleHiddenService,
}
#[derive(Parser, Debug)]
#[clap(author, version, about, long_about = None)]
struct Opts {
#[clap(
short = 'i',
long,
required = true,
parse(from_os_str),
help = "MTG layout CSV containing one or more topology epochs"
)]
topology_file: std::path::PathBuf,
#[clap(long, default_value = "1", help = "Number of simulated days")]
days: u32,
#[clap(long, default_value = "5000", help = "Number of users to simulate")]
users: u32,
#[clap(
short,
long,
default_value = "86401",
help = "Validity period for a given topologies"
)]
epoch: u32,
#[clap(
short,
long,
help = "Print human-readable route output, one route per line"
)]
to_console: bool,
#[clap(long, help = "Print aggregate compromise summary after simulation")]
summary: bool,
#[clap(
long,
arg_enum,
default_value = "simple",
help = "User/service traffic model to simulate"
)]
model: Model,
#[clap(short, help = "Disable persistent guard and vanguard positions")]
disable_guards: bool,
#[clap(
long,
help = "Disable the persistent vanguard position while keeping guards enabled"
)]
disable_vanguards: bool,
}
fn main() {
let opts: Opts = Opts::parse();
let mut topologies: Vec<TopologyConfig> = config::load(&opts.topology_file);
// We need sorting the topologies for accessing the right one depending
// on the current epoch
topologies.sort_by(|a, b| a.epoch.cmp(&b.epoch));
let n = topologies.len();
let mut epoch = opts.epoch;
// check whether the parameters days; config and epoch make sense
if epoch * n as u32 <= opts.days * 24 * 60 * 60 {
eprintln!("Make sure you have enough configuration files, and that the epoch and days value make sense!");
epoch = 86400 * opts.days + 1;
eprintln!("Setting epoch to {epoch}. Maybe you want to change that");
}
let mut runner = Runable::new(opts.users, topologies, opts.days, epoch);
if !opts.disable_guards {
runner.with_guards();
}
if !opts.disable_guards && !opts.disable_vanguards {
runner.with_vanguards();
}
if opts.to_console {
runner.with_console();
}
if opts.summary {
runner.with_summary();
}
match opts.model {
Model::Simple => {
let usermodels = runner.init::<SimpleSynchronousModel>();
runner.run(usermodels);
}
Model::SimpleHiddenService => {
let usermodels = runner.init::<SimpleHiddenServiceModel>();
runner.run(usermodels);
}
}
}

View File

@ -0,0 +1,105 @@
//! A simple hidden-service model.
//!
//! Each simulated service receives a client request after a random `[INTERVAL_MIN, INTERVAL_MAX]`
//! (`default = [5, 15] minute`)
//! interval. For every request, it sends a burst of `[MESSAGES_MIN, MESSAGES_MAX]`
//! (`default = [1, 100]`) messages within the following `BURST_WINDOW_SECONDS` (default = 60 seconds).
use crate::usermodel::{RequestHandler, RouteEvent, UserModel, UserModelInfo};
use rand::distributions::{Distribution, Uniform};
use rand::rngs::SmallRng;
use rand::SeedableRng;
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 SimpleHiddenServiceModel<'a> {
/// timestamp of the current burst start, starting at 0.
current_time: u64,
/// the max value of a message timing
limit: u64,
rng: SmallRng,
interval_die: Uniform<u64>,
message_count_die: Uniform<u64>,
burst_offset_die: Uniform<u64>,
burst_messages: Vec<u64>,
next_burst_message: usize,
current_burst_id: u128,
user_info: UserModelInfo<'a>,
}
impl<'a> UserModel<'a> for SimpleHiddenServiceModel<'a> {
fn new(_tot_users: u32, _epoch: u32, user_info: UserModelInfo<'a>) -> Self {
SimpleHiddenServiceModel {
rng: SmallRng::from_entropy(),
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),
current_time: 0,
limit: 0,
burst_messages: Vec::new(),
next_burst_message: 0,
current_burst_id: 0,
user_info,
}
}
fn set_limit(&mut self, limit: u64) {
self.limit = limit;
}
}
impl SimpleHiddenServiceModel<'_> {
fn has_pending_burst_messages(&self) -> bool {
self.next_burst_message < self.burst_messages.len()
}
fn start_next_burst(&mut self) {
self.current_time += self.interval_die.sample(&mut self.rng);
self.current_burst_id += 1;
self.next_burst_message = 0;
self.burst_messages.clear();
let message_count = self.message_count_die.sample(&mut self.rng) as usize;
self.burst_messages.reserve(message_count);
for _ in 0..message_count {
let offset = self.burst_offset_die.sample(&mut self.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<'a> RequestHandler for SimpleHiddenServiceModel<'a> {
type Out = RouteEvent<'a>;
#[inline]
fn fetch_next(&mut self) -> Option<Self::Out> {
let next_timing = self.next_message_timing();
match next_timing {
currt if currt < self.limit => {
self.user_info.update(currt, &mut self.rng);
Some((
currt,
self.user_info.get_selected_vanguard(),
self.user_info.get_selected_guard(),
Some(self.current_burst_id),
))
}
_ => None,
}
}
}

68
src/simplemodel.rs Normal file
View File

@ -0,0 +1,68 @@
//! A simple user model -- It samples messages within a [5, 15min] interval.
//!
//! Currently does not send the message to any simulated user in particular, and it is one message
//! at a time.
use crate::usermodel::{RequestHandler, RouteEvent, UserModel, UserModelInfo};
use rand::distributions::{Distribution, Uniform};
use rand::rngs::SmallRng;
use rand::SeedableRng;
const INTERVAL_MAX: u64 = 900;
const INTERVAL_MIN: u64 = 300;
pub struct SimpleSynchronousModel<'a> {
/// timestamp of current time, starting at 0.
current_time: u64,
/// the max value of a timing message
limit: u64,
rng: SmallRng,
die: Uniform<u64>,
user_info: UserModelInfo<'a>,
}
/// This simple model uniformly samples a new message to send in the next [300 ... 900] second
/// interval.
impl<'a> UserModel<'a> for SimpleSynchronousModel<'a> {
fn new(_tot_users: u32, _epoch: u32, user_info: UserModelInfo<'a>) -> Self {
SimpleSynchronousModel {
rng: SmallRng::from_entropy(),
die: Uniform::from(INTERVAL_MIN..INTERVAL_MAX),
current_time: 0,
limit: 0,
user_info,
}
}
fn set_limit(&mut self, limit: u64) {
self.limit = limit;
}
}
impl SimpleSynchronousModel<'_> {
/// We simply increase the current time with the sampled value.
fn get_next_message_timing(&mut self) -> u64 {
self.current_time += self.die.sample(&mut self.rng);
self.current_time
}
}
impl<'a> RequestHandler for SimpleSynchronousModel<'a> {
type Out = RouteEvent<'a>;
#[inline]
fn fetch_next(&mut self) -> Option<Self::Out> {
let next_timing = self.get_next_message_timing();
match next_timing {
currt if currt < self.limit => {
self.user_info.update(currt, &mut self.rng);
Some((
currt,
self.user_info.get_selected_vanguard(),
self.user_info.get_selected_guard(),
None,
))
}
_ => None,
}
}
}

340
src/simulation.rs Normal file
View File

@ -0,0 +1,340 @@
use crate::config::TopologyConfig;
use crate::config::PATH_LENGTH;
use crate::config::Mixnode;
use crate::usermodel::{RequestHandler, RouteEvent, UserModel, UserModelInfo, UserModelIterator};
use chrono::{TimeZone, Utc};
use rand::prelude::*;
use rayon::prelude::*;
use std::vec::IntoIter;
/// Contains information required for running the simulation
#[derive(Default)]
pub struct Runable {
/// The number of users we want to simulate
users: u32,
/// The Network config
configs: Vec<TopologyConfig>,
/// The number of virtual days for running the experiment
days: u32,
/// Does this simulation use persistent guard positions?
use_guards: bool,
/// Does this simulation use persistent vanguard positions?
use_vanguards: bool,
/// each topology lifetime -- we assume this to be unique (e.g., 1 day)
epoch: u32,
/// print human-readable per-route output --- default: false
to_console: bool,
/// print aggregate simulation summary at the end --- default: false
summary: bool,
}
#[derive(Clone, Default)]
pub struct SimulationSummary {
users: u32,
total_messages: u64,
compromised_messages: u64,
users_with_compromise: u64,
first_compromise_timestamp: Option<u64>,
last_message_timestamp: Option<u64>,
}
impl SimulationSummary {
fn for_user() -> Self {
SimulationSummary {
users: 1,
..Default::default()
}
}
fn merge(mut self, other: Self) -> Self {
self.users += other.users;
self.total_messages += other.total_messages;
self.compromised_messages += other.compromised_messages;
self.users_with_compromise += other.users_with_compromise;
self.first_compromise_timestamp = min_option(
self.first_compromise_timestamp,
other.first_compromise_timestamp,
);
self.last_message_timestamp =
max_option(self.last_message_timestamp, other.last_message_timestamp);
self
}
}
fn min_option(a: Option<u64>, b: Option<u64>) -> Option<u64> {
match (a, b) {
(Some(a), Some(b)) => Some(a.min(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}
fn max_option(a: Option<u64>, b: Option<u64>) -> Option<u64> {
match (a, b) {
(Some(a), Some(b)) => Some(a.max(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}
impl Runable {
/// Creates a new simulation to run.
pub fn new(users: u32, configs: Vec<TopologyConfig>, days: u32, epoch: u32) -> Self {
Runable {
configs,
users,
days,
epoch,
..Default::default()
}
}
/// Do we enable persistent guards for this simulation?
pub fn with_guards(&mut self) -> &mut Self {
self.use_guards = true;
self
}
/// Do we enable persistent vanguards for this simulation?
pub fn with_vanguards(&mut self) -> &mut Self {
self.use_vanguards = true;
self
}
/// Do we print human-readable route results?
pub fn with_console(&mut self) -> &mut Self {
self.to_console = true;
self
}
/// Do we print an aggregate summary at the end?
pub fn with_summary(&mut self) -> &mut Self {
self.summary = true;
self
}
/// Get a random path from the right mixnet configuration.
///
/// A vanguard and guard are optionally given. They are persistent mixnet nodes chosen to
/// change as little as possible.
#[inline]
pub fn sample_path<'a>(
&'a self,
message_timing: u64,
rng: &mut ThreadRng,
vanguard: Option<&'a Mixnode>,
guard: Option<&'a Mixnode>,
) -> IntoIter<&'a Mixnode> {
self.configs[(message_timing / self.epoch as u64) as usize]
.sample_path(rng, vanguard, guard)
}
/// Check whether the three mixnode in path are compromised.
/// Returns true if they are, false otherwise.
pub fn is_path_malicious(&self, path: &[&Mixnode]) -> bool {
let mut mal_mix = 0;
for i in 0..PATH_LENGTH {
if path[i as usize].is_malicious {
mal_mix += 1;
}
}
mal_mix == PATH_LENGTH
}
/// Format the message's sending time as a naive time
#[inline]
fn format_message_timing(timing: u64) -> String {
let dt = Utc
.timestamp_opt(timing as i64, 0)
.single()
.expect("message timing should be a valid timestamp");
dt.format("%Y-%m-%d %H:%M:%S").to_string()
}
#[inline]
pub fn days_to_timestamp(&self) -> u64 {
u64::from(self.days) * 24 * 60 * 60
}
#[inline]
fn log_stdout(
&self,
user: u32,
strdate: &str,
path: IntoIter<&Mixnode>,
is_malicious: bool,
requestid: Option<u128>,
) {
if !self.to_console {
return;
}
let path_ids: Vec<String> = path.map(|hop| hop.mixid.to_string()).collect();
let path = path_ids.join(",");
if let Some(rid) = requestid {
println!("{strdate} {user} {rid} {path} {is_malicious}");
} else {
println!("{strdate} {user} {path} {is_malicious}");
}
}
#[inline]
fn log_summary(
&self,
summary: &mut SimulationSummary,
message_timing: u64,
is_malicious: bool,
) {
summary.total_messages += 1;
summary.last_message_timestamp = Some(
summary
.last_message_timestamp
.map_or(message_timing, |last| last.max(message_timing)),
);
if is_malicious {
summary.compromised_messages += 1;
if summary.first_compromise_timestamp.is_none() {
summary.users_with_compromise = 1;
summary.first_compromise_timestamp = Some(message_timing);
}
}
}
fn print_summary(&self, summary: &SimulationSummary) {
println!("route_summary");
println!("users={}", summary.users);
println!("days={}", self.days);
println!("epoch_seconds={}", self.epoch);
println!("topologies_loaded={}", self.configs.len());
println!("guards_enabled={}", self.use_guards);
println!("vanguards_enabled={}", self.use_vanguards);
println!("total_messages={}", summary.total_messages);
println!("compromised_messages={}", summary.compromised_messages);
println!(
"percentage_messages_compromised={:.6}",
percentage(summary.compromised_messages, summary.total_messages)
);
println!(
"users_with_at_least_one_compromised_message={}",
summary.users_with_compromise
);
println!(
"users_without_compromised_message={}",
summary
.users
.saturating_sub(summary.users_with_compromise as u32)
);
println!(
"percentage_users_with_at_least_one_compromised_message={:.6}",
percentage(summary.users_with_compromise, u64::from(summary.users))
);
println!(
"first_compromise_timestamp_seconds={}",
optional_u64(summary.first_compromise_timestamp)
);
println!(
"last_message_timestamp_seconds={}",
optional_u64(summary.last_message_timestamp)
);
}
/// Initialize user models
pub fn init<'a, T>(&'a self) -> Vec<UserModelIterator<T>>
where
T: UserModel<'a>,
{
(0..self.users)
.map(|user| {
let model = T::new(
self.users,
self.epoch,
UserModelInfo::new(
user,
&self.configs,
self.epoch,
self.use_guards,
self.use_vanguards,
),
);
UserModelIterator(model)
})
.collect()
}
/// Run the simulation -- this function should output
/// route taken for each user each time the user requires to send
/// a message, which depends of the user model through time.
pub fn run<'a, T>(&'a self, mut usermodels: Vec<UserModelIterator<T>>)
where
T: UserModel<'a> + Send,
T: RequestHandler<Out = RouteEvent<'a>>,
{
// for_each should block until they all completed
let summary = (0..self.users)
.into_par_iter()
.zip(&mut usermodels)
.map(|(user, mut usermodel)| {
let mut rng = thread_rng();
let mut user_summary = SimulationSummary::for_user();
// move this in the init part?
usermodel.set_limit(self.days_to_timestamp());
//let userinfo = &mut userinfos[user as usize];
for (message_timing, vanguard, guard, requestid) in &mut usermodel {
// do we need to update userinfo relative to the current timing?
let path = self.sample_path(message_timing, &mut rng, vanguard, guard);
let strdate = Runable::format_message_timing(message_timing);
// write out the path for this message_timing
let is_malicious = self.is_path_malicious(path.as_slice());
self.log_stdout(user, &strdate, path, is_malicious, requestid);
self.log_summary(&mut user_summary, message_timing, is_malicious);
}
user_summary
})
.reduce(SimulationSummary::default, SimulationSummary::merge);
if self.summary {
self.print_summary(&summary);
}
}
}
fn percentage(numerator: u64, denominator: u64) -> f64 {
if denominator == 0 {
0.0
} else {
numerator as f64 / denominator as f64 * 100.0
}
}
fn optional_u64(value: Option<u64>) -> String {
value.map_or_else(|| "none".to_string(), |value| value.to_string())
}
#[test]
fn test_date_formatting() {
let mut timing = 60 * 11;
let mut strdate = Runable::format_message_timing(timing);
assert_eq!(strdate, "1970-01-01 00:11:00");
timing = timing + 1;
strdate = Runable::format_message_timing(timing);
assert_eq!(strdate, "1970-01-01 00:11:01");
timing = timing + 25 * 60 * 60;
strdate = Runable::format_message_timing(timing);
assert_eq!(strdate, "1970-01-02 01:11:01");
}
#[test]
fn test_summary_counts_user_once() {
let runner = Runable::default();
let mut summary = SimulationSummary::for_user();
runner.log_summary(&mut summary, 10, false);
runner.log_summary(&mut summary, 20, true);
runner.log_summary(&mut summary, 30, true);
assert_eq!(summary.users, 1);
assert_eq!(summary.total_messages, 3);
assert_eq!(summary.compromised_messages, 2);
assert_eq!(summary.users_with_compromise, 1);
assert_eq!(summary.first_compromise_timestamp, Some(20));
assert_eq!(summary.last_message_timestamp, Some(30));
}

203
src/usermodel.rs Normal file
View File

@ -0,0 +1,203 @@
/**
* Simple trait definition for any concrete user model
*/
use crate::config::TopologyConfig;
use crate::config::{GUARDS_LAYER, GUARDS_SAMPLE_SIZE, GUARDS_SAMPLE_SIZE_EXTEND};
use crate::config::{VANGUARDS_LAYER, VANGUARDS_SAMPLE_SIZE, VANGUARDS_SAMPLE_SIZE_EXTEND};
use crate::config::Mixnode;
use rand::prelude::*;
use std::ops::{Deref, DerefMut};
pub trait RequestHandler {
type Out;
fn fetch_next(&mut self) -> Option<Self::Out>;
}
pub type RouteEvent<'a> = (u64, Option<&'a Mixnode>, Option<&'a Mixnode>, Option<u128>);
pub trait UserModel<'a> {
fn new(tot_users: u32, epoch: u32, user_info: UserModelInfo<'a>) -> Self;
fn set_limit(&mut self, limit: u64);
}
// 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<'a, T> Iterator for UserModelIterator<T>
where
T: UserModel<'a> + RequestHandler<Out = RouteEvent<'a>>,
{
type Item = RouteEvent<'a>;
fn next(&mut self) -> Option<Self::Item> {
self.fetch_next()
}
}
pub struct UserModelInfo<'a> {
/// Mixnet topology
topos: &'a [TopologyConfig],
/// Vanguards information
vanguards: Option<Vec<&'a Mixnode>>,
/// The vanguard we're currently using
selected_vanguard: Option<&'a Mixnode>,
/// Guards information
guards: Option<Vec<&'a Mixnode>>,
/// The guard we're currently using
selected_guard: Option<&'a Mixnode>,
/// epoch length in seconds
epoch: u32,
curr_idx: usize,
}
impl<'a> UserModelInfo<'a> {
pub fn new(
_userid: u32,
topos: &'a [TopologyConfig],
epoch: u32,
use_guards: bool,
use_vanguards: bool,
) -> Self {
let mut rng = rand::thread_rng();
let mut vanguards: Option<Vec<&'a Mixnode>> = None;
let mut selected_vanguard: Option<&'a Mixnode> = None;
let mut guards: Option<Vec<&'a Mixnode>> = None;
let mut selected_guard: Option<&'a Mixnode> = None;
if use_vanguards {
vanguards = Some(
topos[0]
.sample_layer(VANGUARDS_LAYER, VANGUARDS_SAMPLE_SIZE, &mut rng)
.collect(),
);
selected_vanguard = Some(vanguards.as_ref().unwrap()[0]);
}
if use_guards {
guards = Some(
topos[0]
.sample_layer(GUARDS_LAYER, GUARDS_SAMPLE_SIZE, &mut rng)
.collect(),
);
selected_guard = Some(guards.as_ref().unwrap()[0]);
}
UserModelInfo {
topos,
vanguards,
selected_vanguard,
guards,
selected_guard,
epoch,
curr_idx: 0,
}
}
#[inline]
pub fn get_selected_vanguard(&self) -> Option<&'a Mixnode> {
self.selected_vanguard
}
#[inline]
pub fn get_selected_guard(&self) -> Option<&'a Mixnode> {
self.selected_guard
}
/// Potentially changes this user's persistent vanguard and guard.
#[inline]
pub fn update<R: Rng + ?Sized>(&mut self, message_timing: u64, rng: &mut R) {
let idx = (message_timing / self.epoch as u64) as usize;
if idx > self.curr_idx {
self.curr_idx = idx;
update_persistent_selection(
self.topos,
self.curr_idx,
VANGUARDS_LAYER,
VANGUARDS_SAMPLE_SIZE_EXTEND,
&mut self.vanguards,
&mut self.selected_vanguard,
rng,
);
update_persistent_selection(
self.topos,
self.curr_idx,
GUARDS_LAYER,
GUARDS_SAMPLE_SIZE_EXTEND,
&mut self.guards,
&mut self.selected_guard,
rng,
);
}
}
}
fn update_persistent_selection<'a, R: Rng + ?Sized>(
topos: &'a [TopologyConfig],
topoidx: usize,
layer: usize,
extend_size: usize,
candidates: &mut Option<Vec<&'a Mixnode>>,
selected: &mut Option<&'a Mixnode>,
rng: &mut R,
) {
let Some(candidates) = candidates else {
return;
};
if let Some(candidate) = candidates
.iter()
.find(|candidate| topos[topoidx].has_mix_in_layer(layer, candidate.mixid))
{
*selected = Some(*candidate);
return;
}
let candidate_idx = candidates.len();
candidates.extend(topos[topoidx].sample_layer(layer, extend_size, rng));
if candidates.len() <= candidate_idx {
panic!(
"Did the persistent candidate set properly extend? len: {}",
candidates.len()
);
}
*selected = Some(candidates[candidate_idx]);
}
#[test]
fn user_model_info_can_use_guards_without_vanguards() {
let configs = crate::config::load("testfiles/single_layout/1000_137_Random_BP_layout.csv");
let user_info = UserModelInfo::new(0, &configs, 86401, true, false);
assert!(user_info.get_selected_guard().is_some());
assert!(user_info.get_selected_vanguard().is_none());
}
#[test]
fn user_model_info_can_use_guards_and_vanguards() {
let configs = crate::config::load("testfiles/single_layout/1000_137_Random_BP_layout.csv");
let user_info = UserModelInfo::new(0, &configs, 86401, true, true);
assert!(user_info.get_selected_guard().is_some());
assert!(user_info.get_selected_vanguard().is_some());
}
#[test]
fn user_model_info_can_disable_guards_and_vanguards() {
let configs = crate::config::load("testfiles/single_layout/1000_137_Random_BP_layout.csv");
let user_info = UserModelInfo::new(0, &configs, 86401, false, false);
assert!(user_info.get_selected_guard().is_none());
assert!(user_info.get_selected_vanguard().is_none());
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff