diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a36d71e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## 0.4.0 + +### Added +- Multipath splitting (`routing.split_strategy`): `iid`, weighted `round_robin`, `batch` and lowest-RTT `latency`, with `routing.merge: common_exit`, per-leg observation (`adversary.observed_legs`), a merged exit-side view (`adversary.egress_observation: merged`) and measured per-leg shares in the metrics. +- Relay-side mixing at every hop (`mixing.strategy`: `constant`, `exponential`, `pool`) and random-hop cover drop (`cover_behaviour.drop_mode: random_hop`). +- A bursty traffic generator (`traffic.distribution: burst`, `burst_mean_cells`, `burst_gap_ms`). +- The `correlation_suite` adversary: training-free attackers A0 (zero-lag), A1 (lag-aware binned correlation), A2 (likelihood ratio with the known delay density) and A3 (timing window), scored on one shared calibration/test split, with confident linkage, TPR at fixed FPR, signal estimate and AUC. Raw observations and score matrices are saved to `correlation_suite.npz`. +- Closed-form model predictions for the lag-aware attacker, recorded with every suite run and available as `atl predict`. +- `atl fidelity`, a check on host-induced timing noise, and wave mode (`sessions.max_concurrent`). +- `atl paired`, paired-seed comparisons with a bootstrap interval and an equivalence margin. +- `examples/mixnet_pool.yaml`. + +### Changed +- TPR at a fixed FPR is conservative under tied scores (0 instead of 1), and the cut can be chosen on held-out calibration sessions (`adversary.evaluation.calibration_fraction`). + +## 0.3.0 + +Real-process emulator with telescoping circuits, per-hop AEAD, selectable ciphers and curves, WAN link conditions, and the `global_observer`, `path_compromise`, `watermark` and `hop_depth` adversaries. diff --git a/README.md b/README.md index fda133e..7707995 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,16 @@ A real, local network emulator and experimentation framework for evaluating anonymous communication network designs. Relay nodes run as **real OS processes**, each on its own loopback IP -(`127.0.0.1`, `127.0.0.2`, ...), connected by a genuine telescoping +(`127.0.0.1`, `127.0.0.2`, ...), connected by a telescoping (Tor-inspired) circuit with real per-hop AEAD encryption (several algorithms selectable, see Crypto below) and a real ECDHE handshake per hop (x25519 by default, x448 or p256 also selectable), with hop-local circuit IDs so no identifier is shared across two links of the same -path. Nothing here is a timing model. Latency, delivery, and correlation -numbers all come from actually running the protocol over actual sockets. +path. Latency, delivery and correlation numbers are measured by running +the protocol over sockets; there is no timing model. -This is a research harness with its own simple wire protocol, not a -protocol-level reimplementation of Tor. There are no fixed-size cells by +This is a research harness with its own simple wire protocol; it does +not reimplement Tor's protocol. There are no fixed-size cells by default (opt in via `traffic_shaping`), no directory/consensus system, and the wire format is custom. @@ -44,7 +44,7 @@ and the wire format is custom. ▼ ▼ MetricsCollector Adversary (pluggable) (real measured latency, global_observer: Observation - delivery, bandwidth) → Feature → Decision, with + delivery, bandwidth) -> Feature -> Decision, with AS-level partial visibility path_compromise: fast Monte Carlo watermark: active delay-pattern @@ -85,7 +85,7 @@ dimension below configurable. │ ▼ traffic.real_rate, cover_rate, - distribution: poisson | constant | pareto, + distribution: poisson | constant | pareto | burst, cover_behaviour.drop_probability │ ▼ @@ -145,8 +145,8 @@ Results for tor-like ──────────────────────────────────────── real_packets_sent 375 delivery_rate 1.0000 -avg_latency_s 0.0006 ← real measured localhost latency -circuit_build_delay_s 0.0315 ← real measured handshake time +avg_latency_s 0.0006 (measured localhost latency) +circuit_build_delay_s 0.0315 (measured handshake time) correlation_success_rate 1.0000 auc 1.0000 precision 1.0000 @@ -155,8 +155,8 @@ recall 1.0000 ``` The dashboard shows the same progress live: it starts the run in the -background and polls for updates, rather than blocking the page until -the whole experiment finishes. +background and polls for updates, so the page stays responsive while the +experiment runs. ## Writing an experiment @@ -172,6 +172,7 @@ network: sessions: count: 10 + max_concurrent: 20 # optional: run sessions in waves of this size (see Fidelity below) routing: paths: @@ -179,12 +180,30 @@ routing: path_length: 3 - strategy: random path_length: 4 - split_strategy: round_robin # or "random" + split_strategy: round_robin # round_robin | random | iid | batch | latency (see Multipath below) + split_weights: [0.7, 0.3] # per-leg shares for iid, batch and weighted round_robin; default even + merge: disjoint # or common_exit: every leg ends at one exit relay (Conflux-style) traffic: - distribution: pareto # "poisson" (default) | "constant" | "pareto" (bursty) + distribution: pareto # "poisson" (default) | "constant" | "pareto" (heavy-tailed gaps) | "burst" real_rate: 6 cover_rate: 0 # >0 to enable cover traffic + # burst: bursts of back-to-back cells, Poisson burst starts, geometric + # burst sizes; per-bin counts have index of dispersion about + # 2 * burst_mean_cells - 1 (real Tor downstream traffic: median about 40 at 0.1 s) + burst_mean_cells: 12 + burst_gap_ms: 2 + +cover_behaviour: + drop_mode: random_hop # "per_hop" (default: each hop drops cover with drop_probability) + # | "random_hop": each cover cell dies at one hop the client picks + +mixing: # relay-side mixing at every hop (data cells only) + strategy: pool # "none" (default) | "constant" | "exponential" (Loopix-style) | "pool" + delay_ms: 50 # constant delay, or exponential mean, per hop + pool_interval_ms: 250 # pool: flush period, shared by all circuits through a relay + release_probability: 1.0 # pool: 1.0 empties each flush; < 1 retains cells (binomial pool) + interval_jitter: 0.0 # pool: randomized flush, interval * (1 +/- U(jitter)) traffic_shaping: enabled: true # turns on fixed-size cell padding below @@ -212,6 +231,7 @@ adversary: observed_as: 1 # "all" or an AS-group count, independent entry/exit visibility observation: {bin_width_ms: 100} classifier: {type: pearson, threshold: 0.7} + evaluation: {calibration_fraction: 0.5} # hold out: cut chosen on half the sessions, TPR read on the rest compromised_fraction: 0.15 # for path_compromise compromise_trials: 3000 @@ -221,6 +241,18 @@ baseline: baseline.yaml # optional: diffs this run against another con See `examples/` for a Tor-like preset, a cover-traffic experiment, and a multi-path custom config. +## Multipath + +`routing.split_strategy` decides which leg carries each cell: + +- `round_robin`: legs in turn; with `split_weights`, a smooth weighted round-robin (deterministic, evenly spread) +- `random`: a uniformly chosen leg per cell +- `iid`: leg *i* with probability `split_weights[i]` per cell +- `batch`: batches of exponential duration (mean `split_batch_mean_s`, default 2 s), each sent entirely on one leg drawn with the weights +- `latency`: the lowest-RTT leg with room in its window (`split_leg_window` cells per RTT), using the nominal RTTs in `split_leg_rtt_ms`; modeled on the lowest-RTT-first idea in Tor's Conflux proposal. The RTTs drive the scheduling decision only. + +`routing.merge: common_exit` makes all legs of a session end at one exit relay, with no other relay shared between legs, so each leg has its own entry. On the adversary side, `observed_legs: [0]` fixes which legs are visible at the entry, and `egress_observation: merged` makes the exit side see every leg's cells (the flow after the legs rejoin). Runs with more than one leg report `leg__real_share`, the measured share of real cells per leg. + ## Crypto `crypto.algorithm` picks the per-hop AEAD: @@ -239,7 +271,7 @@ multi-path custom config. Notes: -- Both are whole-experiment settings, not negotiated per hop: every relay in a circuit must agree on them. The EXTEND cell's public-key field is length-prefixed rather than a fixed 32 bytes, so the wire format doesn't need to encode which curve is in use. +- Both are whole-experiment settings, fixed for every hop: every relay in a circuit must agree on them. The EXTEND cell's public-key field is length-prefixed rather than a fixed 32 bytes, so the wire format doesn't need to encode which curve is in use. - A larger handshake key (x448's 56 bytes, p256's 65-byte uncompressed point, vs x25519's 32) eats more of the fixed-size cell padding budget; a very small `cell_size` combined with a long path may need raising. - `aes256gcmsiv` needs `cryptography>=42.0` (pinned in pyproject.toml) built against OpenSSL 3.2+. The `cryptography` package's own prebuilt wheels satisfy this on every common platform, so a normal `pip install` needs nothing extra. @@ -248,23 +280,29 @@ Notes: - `atl run `: runs one experiment, prints one metrics table (plus a baseline diff if `baseline:` is set) - `atl compare `: runs both, diffs every metric - `atl sweep --param X --values a,b,c`: reruns one config varying a single field, one CSV row per value +- `atl paired --seeds 12 --metric tpr_at_fpr_0.001`: runs both configs under the same seeds, bootstraps the paired per-seed difference, and classifies it against an equivalence margin (`--margin`, default 0.05) as `meaningful_effect`, `no_meaningful_effect` (whole CI inside the margin) or `inconclusive` +- `atl predict `: prints the closed-form predictions for the lag-aware correlator (per-bin correlation, signal, TPR at FPR, confident linkage) for an experiment, without running it. The model takes the traffic's mean and index of dispersion per bin from the configured generator, so bursty traffic gets the dispersion forms of the split and delay factors (`model_dispersion_w` in the output) +- `atl fidelity `: reruns an experiment's load with relay mixing switched off and reports the one-way delay that remains (host and link time). It passes when the 95th percentile, minus configured link latency, is at most 10% of the smallest per-hop mixing delay (or of the smallest attacker bin width when there is no mixing). Relays share one machine, so a failing load adds timing noise that looks like a defense; lower `sessions.max_concurrent` or use a larger machine. - `atl wizard`: walks through picking `tor_like` or `custom`, filling in parameters, reviewing the assembled YAML before running - `atl dashboard` (needs `pip install -e ".[dashboard]"`): a local web UI at `http://127.0.0.1:8765`: the same form as the wizard, generating the same YAML, plus an editable textarea for advanced options the form doesn't expose (traffic shaping, link conditions, AS groups, watermarking). Same `anontestlab.experiment.run_experiment` under the hood, just reached over HTTP. ### Adversaries -- **`global_observer`**: composes swappable Observation (binning), Feature (`pearson` correlation, pluggable), and Decision stages, reporting correlation accuracy, TPR at fixed FPR, AUC, precision, and recall. Visibility can be restricted by path count (`observed_paths`) or AS-group membership (`observed_as`): entry and exit are independently visible based on which mock AS group their hop belongs to, a more structured partial-observer model than a bare fraction. +- **`global_observer`**: composes swappable Observation (binning), Feature (`pearson` correlation, pluggable), and Decision stages, reporting correlation accuracy, TPR at fixed FPR, AUC, precision, and recall. The TPR@FPR cut is conservative under tied scores (the realized FPR never exceeds the target), and each target also reports `realized_fpr_*` and `fpr_support_*` (expected impostor exceedances, i.e. how much tail backs the estimate). With `evaluation.calibration_fraction` > 0 the cut is chosen on calibration sessions and scored on disjoint test sessions. Visibility can be restricted by path count (`observed_paths`) or AS-group membership (`observed_as`): entry and exit are independently visible based on which mock AS group their hop belongs to, a more structured partial-observer model than a bare fraction. +- **`correlation_suite`**: runs training-free correlation attackers side by side on one shared calibration/test split: `a0` zero-lag and `a1` lag-aware binned-count correlation at each bin width (`bin_widths_s`, default 0.1, 0.25, 0.5, 1.0 s; lags up to max(2 s, P99 of the added delay) + 0.5 s), `a2` a likelihood-ratio attacker using the known delay distribution (Danezis's Poisson-intensity approximation), and `a3` a timing-window matcher (useful for sparse flows). Each attacker reports confident linkage `lpsi__br_` (share of true pairs the attacker would accept with posterior at least psi when that fraction of candidate pairs are true), TPR at fixed FPR with realized FPR, the signal estimate `mu_hat` and AUC. The run also reports the model's predictions (`model_*`) next to them, and saves the score matrices to `correlation_suite.npz`. Configure under `adversary.correlation_suite`. - **`path_compromise`**: an independent-compromise Monte Carlo that needs no packets to move: if an adversary controls fraction *f* of relays, what's the probability a session's path(s) are fully compromised (the textbook *f^k*, generalized empirically to multi-path sessions). Deliberately independent-only, no correlated or shared-operator modeling. - **`watermark`**: an active attack: a designated relay, always pinned to hop 1, delays every `period`-th real packet by a fixed amount, then checks whether the pattern survives to the observed exit timing. Best used with a single path per session. - **`hop_depth`**: structural like `path_compromise` (no packets need to move): once fixed-size cell padding is on, quantifies the disclosed hop-position leak directly from `cell_size`/`crypto_algorithm`. Reports whether an observer at one hop can recover its exact position from size alone (`hop_position_accuracy`, 1.0 once shaping is enabled) and whether an observer at hop 1 can tell circuits of different lengths apart from size alone (`path_length_leak_at_hop1`, 0.0 by design; `nan` if only one circuit length appears in the experiment). -## Known limitations (v0.3) +## Known limitations (v0.4) -Disclosed simplifications, not claims of security properties this doesn't have: +Simplifications of this version, stated so that results are read with them in mind: - **Fixed-size cells still leak hop position.** Cells are exactly `cell_size` bytes at hop 1 regardless of path length, but shrink by a fixed amount per hop *position* within a circuit. A single-link observer can't distinguish path lengths or real/cover/EXTEND from size alone, but a global observer watching multiple hops of the same circuit could infer hop depth from the size sequence (the `hop_depth` adversary measures exactly this). -- **Per-edge link conditions are directional-only.** `link_conditions.per_edge` scales a relay's forward-direction send to a specific peer, not a fully symmetric edge model: only the connection-initiating side is scaled (it always knows both endpoints locally); the receiving side's own upstream-facing sends on that connection still use its plain per-node value, since telling it would need a wire-protocol change this scope didn't take on. +- **Per-edge link conditions are directional-only.** `link_conditions.per_edge` scales a relay's forward-direction send to a specific peer and is directional: only the connection-initiating side is scaled (it always knows both endpoints locally); the receiving side's own upstream-facing sends on that connection still use its plain per-node value, since telling it would need a wire-protocol change this scope didn't take on. - **No relay identity or directory system.** Keys are ephemeral only, so there's no TOFU question to answer, but also no persistent relay reputation. +- **Wave mode changes the timestamp origin.** With `sessions.max_concurrent`, each session's timestamps start at that session's own start, so flows from different waves line up as if concurrent. Without it, all sessions share the experiment clock. +- **The `latency` split policy uses nominal RTTs.** Legs are not given different real latencies; only the scheduling decision follows the configured RTTs. - **`bandwidth_weighted` routing doesn't model guard/exit-flag constraints.** It selects without replacement in proportion to each node's configured weight, deliberately not replicating Tor's position rules. - **Timing varies run to run.** The experiment *design* (path choices, traffic schedule) is reproducible from the seed, but real measured latency and timing will vary like any real system's would, since sessions run concurrently over real sockets and each relay subprocess has its own independent random state for loss/drop/watermark rolls. @@ -278,28 +316,23 @@ layer follows the same pattern but isn't class-based: AEAD ciphers are registered in `anontestlab.crypto.ALGORITHMS` (plus a matching entry in `KEY_LENGTHS`), and handshake curves are a fixed if/elif in `anontestlab.emulator.crypto_layer.generate_ephemeral_keypair`/`derive_key` -rather than a dict, since each curve's key-generation and ECDH calls -have a genuinely different shape (see `KEYEXCHANGES` for the current +instead of a dict, since each curve's key-generation and ECDH calls +have a different shape (see `KEYEXCHANGES` for the current set). ## Provenance AnonTestLab began as a generalization of the evaluation methodology and -test harness built for a research project on dual-path anonymous -communication network design (Anon-Sec-Net), and reuses portions of that -codebase and test suite. This project generalizes that methodology -beyond one specific system's protocol and testbed: a protocol-agnostic -emulator with pluggable adversary models and configurable routing, -rather than a tool scoped to any single design under test. - -The originating research is described in two papers currently under -submission: - -- "Anon-Sec-Net: Anonymous and Secure Communication at the Network Layer" -- "Does Splitting Help? An Empirical Evaluation of Dual-Path Routing for - Anonymous Communication" - -Code and evaluation scripts for that project will be linked here once -those papers are published. +test harness built for Anon-Sec-Net, a research prototype for dual-path +anonymous communication, and reuses portions of that codebase and test +suite. It is a protocol-agnostic emulator with pluggable adversary models +and configurable routing, usable for designs other than the one it came +from. + +Version 0.4 adds multipath split policies, relay mixing, a bursty traffic +generator, training-free correlation attackers with model predictions, and +a host-noise fidelity check. It is the measurement instrument of a paper in +preparation on correlation risk in multipath anonymous communication; a +reference will be added here on publication. ## License diff --git a/anontestlab/adversary/__init__.py b/anontestlab/adversary/__init__.py index 11ce424..6eaed65 100644 --- a/anontestlab/adversary/__init__.py +++ b/anontestlab/adversary/__init__.py @@ -1,4 +1,5 @@ from .base import Adversary, AdversaryResult, SessionObservation, SimulationContext +from .correlation_suite import CorrelationSuite from .global_observer import GlobalPassiveObserver from .hop_depth import HopDepthAdversary from .path_compromise import PathCompromiseAdversary @@ -6,6 +7,7 @@ ADVERSARIES: dict[str, type[Adversary]] = { "global_observer": GlobalPassiveObserver, + "correlation_suite": CorrelationSuite, "path_compromise": PathCompromiseAdversary, "watermark": WatermarkAdversary, "hop_depth": HopDepthAdversary, @@ -25,6 +27,7 @@ def get_adversary(name: str, config) -> Adversary: "ADVERSARIES", "Adversary", "AdversaryResult", + "CorrelationSuite", "GlobalPassiveObserver", "HopDepthAdversary", "PathCompromiseAdversary", diff --git a/anontestlab/adversary/attacks.py b/anontestlab/adversary/attacks.py new file mode 100644 index 0000000..a20fbc0 --- /dev/null +++ b/anontestlab/adversary/attacks.py @@ -0,0 +1,139 @@ +"""Score functions for the training-free correlation attackers. Each takes +the per-session ingress (entry-side) and egress (exit-side) timestamp lists +and returns an n x n score matrix: row i is ingress session i, column j is +egress session j, and the diagonal holds the true pairs. + +- lag_correlation_scores: binned-count Pearson correlation, maximised over + non-negative lags (A1). With max_lag_bins=0 it is the zero-lag correlator + (A0). +- likelihood_ratio_scores: point-process log-likelihood ratio of the egress + times given the ingress times and a known delay density (A2), after + Danezis, "The Traffic Analysis of Continuous-Time Mixes", PET 2004. +- window_scores: share of ingress packets with an egress packet inside a + delay window, minus the share expected by chance (A3). +""" +from __future__ import annotations + +import numpy as np + + +def bin_matrix(times_per_session: list[list[float]], bin_s: float, horizon_s: float) -> np.ndarray: + n_bins = max(1, int(np.ceil(horizon_s / bin_s))) + out = np.zeros((len(times_per_session), n_bins)) + for row, times in enumerate(times_per_session): + if not times: + continue + idx = (np.asarray(times) // bin_s).astype(int) + idx = idx[(idx >= 0) & (idx < n_bins)] + np.add.at(out[row], idx, 1.0) + return out + + +def _standardise_rows(m: np.ndarray) -> np.ndarray: + centred = m - m.mean(axis=1, keepdims=True) + norm = np.linalg.norm(centred, axis=1, keepdims=True) + norm[norm == 0] = 1.0 + return centred / norm + + +def lag_correlation_scores( + ingress: list[list[float]], + egress: list[list[float]], + bin_s: float, + horizon_s: float, + max_lag_bins: int, + ingress_horizon_s: float | None = None, +) -> np.ndarray: + """Best standardised correlation over non-negative lags. + + Without ingress_horizon_s both sides are binned over horizon_s and the + lagged windows shrink as the lag grows. With it, ingress is binned over + its own (shorter) window and every lag compares it against an egress + window of the same length, so ingress never carries the empty stretch + that egress needs for delayed packets. Empty bins shared by all sessions + would otherwise make impostor pairs correlate. + """ + y = bin_matrix(egress, bin_s, horizon_s) + best = np.full((len(ingress), y.shape[0]), -np.inf) + if ingress_horizon_s is None: + x = bin_matrix(ingress, bin_s, horizon_s) + n_bins = x.shape[1] + for lag in range(0, min(max_lag_bins, n_bins - 2) + 1): + xs = _standardise_rows(x[:, : n_bins - lag]) + ys = _standardise_rows(y[:, lag:]) + np.maximum(best, xs @ ys.T, out=best) + return best + x = bin_matrix(ingress, bin_s, ingress_horizon_s) + n_x = x.shape[1] + xs = _standardise_rows(x) + for lag in range(0, min(max_lag_bins, y.shape[1] - n_x) + 1): + ys = _standardise_rows(y[:, lag : lag + n_x]) + np.maximum(best, xs @ ys.T, out=best) + return best + + +def likelihood_ratio_scores( + ingress: list[list[float]], + egress: list[list[float]], + density: np.ndarray, + step_s: float, + horizon_s: float, + floor_rate: float = 1e-3, +) -> np.ndarray: + """Score(i, j) = sum over egress packets y of j of + log((c_ij + (f * X_i)(y)) / r_j), where f * X_i is ingress i's packet + train convolved with the delay density (packets per second), r_j is + egress j's mean rate, and c_ij = max(r_j - rate of X_i, floor_rate) is + the part of j's rate left unexplained by i. Under this model the + compensator terms of the two hypotheses cancel.""" + n_bins = max(1, int(np.ceil(horizon_s / step_s))) + x = bin_matrix(ingress, step_s, horizon_s) + size = n_bins + len(density) + fft_len = 1 << (size - 1).bit_length() + kernel = np.fft.rfft(density, fft_len) + intensity = np.fft.irfft(np.fft.rfft(x, fft_len, axis=1) * kernel, fft_len, axis=1)[:, :n_bins] + np.maximum(intensity, 0.0, out=intensity) + x_rate = x.sum(axis=1) / horizon_s + scores = np.zeros((len(ingress), len(egress))) + for j, times in enumerate(egress): + if not times: + continue + idx = (np.asarray(times) // step_s).astype(int) + idx = idx[(idx >= 0) & (idx < n_bins)] + r_j = max(len(idx) / horizon_s, floor_rate) + c = np.maximum(r_j - x_rate, floor_rate)[:, None] + scores[:, j] = np.log((c + intensity[:, idx]) / r_j).sum(axis=1) + return scores + + +def window_scores( + ingress: list[list[float]], + egress: list[list[float]], + window_s: float, + horizon_s: float, + grid_s: float = 0.001, +) -> np.ndarray: + """Hit test on a grid of grid_s: an ingress packet at x counts as matched + if egress j has a packet in the grid cells from x to x + window_s. The + grid error is at most one cell at each end of the window.""" + n_in = len(ingress) + lengths = np.array([len(x) for x in ingress]) + owner = np.repeat(np.arange(n_in), lengths) + flat = np.concatenate([np.asarray(x, dtype=float) for x in ingress]) if lengths.sum() else np.zeros(0) + n_cells = int(np.ceil((horizon_s + window_s) / grid_s)) + 2 + lo = np.clip((flat / grid_s).astype(np.int64), 0, n_cells - 1) + hi = np.clip(((flat + window_s) / grid_s).astype(np.int64) + 1, 0, n_cells - 1) + safe_len = np.maximum(lengths, 1) + scores = np.zeros((n_in, len(egress))) + for j, times in enumerate(egress): + chance = 1.0 - np.exp(-len(times) / horizon_s * window_s) + if len(times) == 0 or len(flat) == 0: + scores[:, j] = -chance + continue + cells = np.clip((np.asarray(times, dtype=float) / grid_s).astype(np.int64), 0, n_cells - 1) + cumulative = np.concatenate(([0], np.cumsum(np.bincount(cells, minlength=n_cells)))) + hit = (cumulative[hi] - cumulative[lo]) > 0 + share = np.bincount(owner, weights=hit, minlength=n_in) / safe_len + share[lengths == 0] = 0.0 + scores[:, j] = share - chance + return scores diff --git a/anontestlab/adversary/base.py b/anontestlab/adversary/base.py index ed5d923..ffd3899 100644 --- a/anontestlab/adversary/base.py +++ b/anontestlab/adversary/base.py @@ -18,6 +18,7 @@ class SessionObservation: egress_seq: list[int] = field(default_factory=list) # real_seq for each egress_times entry, # same index alignment; lets a position-sensitive adversary (watermark) key off true # send order instead of arrival order, which packet loss can desynchronize + leg_real_counts: list[int] = field(default_factory=list) # real cells sent on each leg (path index) @dataclass @@ -37,6 +38,7 @@ class AdversaryResult: name: str n_sessions: int metrics: dict[str, float] + artifacts: dict[str, object] = field(default_factory=dict) # arrays saved next to the metrics (e.g. score matrices) class Adversary(ABC): diff --git a/anontestlab/adversary/correlation_suite.py b/anontestlab/adversary/correlation_suite.py new file mode 100644 index 0000000..b47692e --- /dev/null +++ b/anontestlab/adversary/correlation_suite.py @@ -0,0 +1,132 @@ +"""Runs the training-free correlation attackers side by side on the same +observations and reports, for each one, confident linkage, TPR at fixed +FPR and the signal estimate. All attackers share one calibration/test split +of the sessions, so their results are paired. + +Attacker ids: + a0_w zero-lag binned-count correlation + a1_w lag-aware binned-count correlation (non-negative lags) + a2 likelihood-ratio attacker with the known delay density + a3_q timing-window matcher, window = delay quantile + floor + +The score matrices and the split are returned as artifacts so later +analysis (strongest-attacker selection, other threshold rules) does not +need a rerun. +""" +from __future__ import annotations + +import math +import random + +import numpy as np + +from ..metrics.stats import confident_linkage, roc_auc, signal_estimate, tpr_at_fpr_detail +from . import attacks, delay_model +from .base import Adversary, AdversaryResult, SimulationContext + + +def _off_diagonal(m: np.ndarray) -> np.ndarray: + return m[~np.eye(m.shape[0], dtype=bool)] + + +class CorrelationSuite(Adversary): + name = "correlation_suite" + + def __init__(self, config): + self.config = config + self.bin_widths_s = list(config.suite_bin_widths_s) + self.attackers = set(config.suite_attackers) + self.psi = list(config.suite_psi) + self.base_rates = list(config.suite_base_rates) + self.fpr_targets = list(config.suite_fpr_targets) + self.calibration_fraction = config.observer_calibration_fraction or 0.5 + self.window_quantiles = list(config.suite_window_quantiles) + self.latency_floor_s = config.suite_latency_floor_ms / 1000.0 + self.lr_step_s = config.suite_lr_step_ms / 1000.0 + if config.suite_lag_max_s is not None: + self.lag_max_s = config.suite_lag_max_s + else: + p99 = delay_model.delay_quantiles(config, (0.99,))[0.99] + self.lag_max_s = max(2.0, p99) + 0.5 + + @classmethod + def from_config(cls, config) -> CorrelationSuite: + return cls(config) + + def _scores(self, ingress, egress, horizon) -> dict[str, np.ndarray]: + out = {} + flow = self.config.duration_s + for w in self.bin_widths_s: + if "a0" in self.attackers: + out[f"a0_w{w}"] = attacks.lag_correlation_scores(ingress, egress, w, horizon, 0, flow) + if "a1" in self.attackers: + lags = math.ceil(self.lag_max_s / w) + out[f"a1_w{w}"] = attacks.lag_correlation_scores(ingress, egress, w, horizon, lags, flow) + if "a2" in self.attackers: + density = delay_model.delay_density(self.config, self.lr_step_s) + out["a2"] = attacks.likelihood_ratio_scores(ingress, egress, density, self.lr_step_s, horizon) + if "a3" in self.attackers: + quantiles = delay_model.delay_quantiles(self.config, tuple(self.window_quantiles)) + for q, value in quantiles.items(): + out[f"a3_q{q}"] = attacks.window_scores(ingress, egress, value + self.latency_floor_s, horizon) + return out + + def attack(self, ctx: SimulationContext, rng: random.Random) -> AdversaryResult: + sids = sorted(ctx.sessions) + n = len(sids) + if n < 4: + return AdversaryResult(self.name, n, {"suite_sessions": n}) + # Every session is binned over the same configured window: the flow + # duration plus the lag range, since exit traffic trails by the added + # delay. A shared horizon taken from the latest timestamp would let one + # overrunning session pad every other series with trailing zeros, and + # that common on/off shape makes impostor pairs correlate. The binned + # attackers use only the flow duration on the ingress side for the + # same reason (see attacks.lag_correlation_scores). + horizon = self.config.duration_s + self.lag_max_s + raw_in = [ctx.sessions[s].ingress_times for s in sids] + raw_out = [ctx.sessions[s].egress_times for s in sids] + ingress = [[t for t in obs if 0.0 <= t < horizon] for obs in raw_in] + egress = [[t for t in obs if 0.0 <= t < horizon] for obs in raw_out] + dropped = sum(len(a) for a in raw_in + raw_out) - sum(len(a) for a in ingress + egress) + overrun = sum( + 1 for a, b in zip(raw_in, raw_out) if any(t >= horizon for t in a) or any(t >= horizon for t in b) + ) + + order = np.random.default_rng(rng.randrange(2**32)).permutation(n) + n_cal = min(max(2, round(self.calibration_fraction * n)), n - 2) + cal, test = np.sort(order[:n_cal]), np.sort(order[n_cal:]) + + metrics: dict[str, float] = {"suite_calibration_sessions": len(cal), "suite_test_sessions": len(test), + "suite_lag_max_s": self.lag_max_s, "suite_horizon_s": horizon, + "suite_overrun_sessions": overrun, "suite_dropped_timestamps": dropped} + artifacts: dict[str, object] = {"session_ids": np.asarray(sids), "calibration_idx": cal, "test_idx": test} + # Raw observations as flat arrays plus offsets, so a run can be + # rescored offline without rerunning the relays. + for side, series in (("ingress", raw_in), ("egress", raw_out)): + artifacts[f"{side}_times"] = np.asarray([t for obs in series for t in obs], dtype=float) + artifacts[f"{side}_offsets"] = np.cumsum([0] + [len(obs) for obs in series]) + for attacker, scores in self._scores(ingress, egress, horizon).items(): + artifacts[f"scores_{attacker}"] = scores + cal_scores = scores[np.ix_(cal, cal)] + test_scores = scores[np.ix_(test, test)] + true_cal, imp_cal = np.diag(cal_scores), _off_diagonal(cal_scores) + true_test, imp_test = np.diag(test_scores), _off_diagonal(test_scores) + detail = tpr_at_fpr_detail(true_test, imp_cal, self.fpr_targets, test_impostor_scores=imp_test) + for fpr, point in detail.items(): + metrics[f"{attacker}_tpr_at_fpr_{fpr}"] = point.tpr + metrics[f"{attacker}_realized_fpr_{fpr}"] = point.realized_fpr + metrics[f"{attacker}_fpr_support_{fpr}"] = point.expected_exceedances + for psi in self.psi: + for br in self.base_rates: + metrics[f"{attacker}_lpsi_{psi}_br_{br}"] = confident_linkage(true_cal, imp_cal, true_test, psi, br) + metrics[f"{attacker}_mu_hat"] = signal_estimate(true_test, imp_test) + metrics[f"{attacker}_auc"] = roc_auc(list(true_test), list(imp_test)) + if "a1" in self.attackers: + from ..model import predict # imported here: model imports this package's delay model + + psi = self.psi[0] if self.psi else 0.9 + base_rate = self.base_rates[0] if self.base_rates else 1e-4 + fpr = self.fpr_targets[0] if self.fpr_targets else 1e-3 + metrics.update(predict(self.config, fpr=fpr, psi=psi, base_rate=base_rate)) + return AdversaryResult(self.name, n, metrics, artifacts) diff --git a/anontestlab/adversary/decision.py b/anontestlab/adversary/decision.py index 1828a43..08ec976 100644 --- a/anontestlab/adversary/decision.py +++ b/anontestlab/adversary/decision.py @@ -4,12 +4,26 @@ import numpy as np -from ..metrics.stats import precision_recall_at_threshold, roc_auc, tpr_at_fpr +from ..metrics.stats import precision_recall_at_threshold, roc_auc, tpr_at_fpr_detail + + +def _off_diagonal(scores: np.ndarray) -> np.ndarray: + return scores[~np.eye(scores.shape[0], dtype=bool)] def evaluate_scores( - scores: np.ndarray, threshold: float, fpr_targets: tuple[float, ...] + scores: np.ndarray, + threshold: float, + fpr_targets: tuple[float, ...], + calibration_fraction: float = 0.0, + rng: np.random.Generator | None = None, ) -> dict[str, float]: + """calibration_fraction == 0 keeps the in-sample estimate: the TPR@FPR + cut is chosen on the same impostor pairs it's reported against. + calibration_fraction > 0 splits sessions into disjoint calibration and + test sets. The cut comes from calibration impostor pairs only, and TPR + and realized FPR are read from the test sessions, so the threshold is + never tuned on the pairs it's scored against.""" n = scores.shape[0] if n == 0: metrics = {"correlation_success_rate": float("nan"), "auc": float("nan"), @@ -19,14 +33,33 @@ def evaluate_scores( predicted = scores.argmax(axis=1) correct = sum(1 for i in range(n) if predicted[i] == i) - true_scores = [scores[i, i] for i in range(n)] - impostor_scores = [scores[i, j] for i in range(n) for j in range(n) if i != j] + true_scores = np.diag(scores) + impostor_scores = _off_diagonal(scores) metrics = {"correlation_success_rate": correct / n} - for fpr, tpr in tpr_at_fpr(true_scores, impostor_scores, list(fpr_targets)).items(): - metrics[f"tpr_at_fpr_{fpr}"] = tpr - metrics["auc"] = roc_auc(true_scores, impostor_scores) - precision, recall = precision_recall_at_threshold(true_scores, impostor_scores, threshold) + + if calibration_fraction > 0 and n >= 4: + rng = rng if rng is not None else np.random.default_rng(0) + order = rng.permutation(n) + n_cal = min(max(2, round(calibration_fraction * n)), n - 2) + cal, test = order[:n_cal], order[n_cal:] + detail = tpr_at_fpr_detail( + np.diag(scores)[test], + _off_diagonal(scores[np.ix_(cal, cal)]), + list(fpr_targets), + test_impostor_scores=_off_diagonal(scores[np.ix_(test, test)]), + ) + metrics["calibration_sessions"] = len(cal) + metrics["test_sessions"] = len(test) + else: + detail = tpr_at_fpr_detail(true_scores, impostor_scores, list(fpr_targets)) + + for fpr, point in detail.items(): + metrics[f"tpr_at_fpr_{fpr}"] = point.tpr + metrics[f"realized_fpr_{fpr}"] = point.realized_fpr + metrics[f"fpr_support_{fpr}"] = point.expected_exceedances + metrics["auc"] = roc_auc(list(true_scores), list(impostor_scores)) + precision, recall = precision_recall_at_threshold(list(true_scores), list(impostor_scores), threshold) metrics["precision"] = precision metrics["recall"] = recall return metrics diff --git a/anontestlab/adversary/delay_model.py b/anontestlab/adversary/delay_model.py new file mode 100644 index 0000000..78f47aa --- /dev/null +++ b/anontestlab/adversary/delay_model.py @@ -0,0 +1,62 @@ +"""Samples the added delay a packet picks up between the entry observation +point and the exit observation point, from an experiment's own mixing and +link settings. Attackers that assume knowledge of the defense (the +likelihood-ratio attacker and the timing-window matcher) use these samples +as the delay distribution, and the lag-aware correlator uses them to set its +lag range. + +The model covers the relay-side mixing in emulator/mixing.py and the +per-hop link latency. It ignores host scheduling noise, which the fidelity +check measures separately. +""" +from __future__ import annotations + +import numpy as np + + +def sample_added_delay(config, n: int, rng: np.random.Generator) -> np.ndarray: + """n draws of the total added delay in seconds over path 0's hops.""" + hops = config.path_length + total = np.zeros(n) + for _ in range(hops): + total += _mixing_delay(config, n, rng) + latency = config.link_latency_ms / 1000.0 + jitter = config.link_jitter_ms / 1000.0 + if latency > 0 or jitter > 0: + total += np.maximum(0.0, rng.normal(latency, jitter, n)) + return total + + +def _mixing_delay(config, n: int, rng: np.random.Generator) -> np.ndarray: + strategy = config.mix_strategy + if strategy == "none": + return np.zeros(n) + if strategy == "constant": + return np.full(n, config.mix_delay_ms / 1000.0) + if strategy == "exponential": + return rng.exponential(config.mix_delay_ms / 1000.0, n) + if strategy == "pool": + interval = config.pool_interval_ms / 1000.0 + jitter = config.pool_interval_jitter + # wait for the next flush (arrival uniform within the current interval), + # then a geometric number of further flushes while the cell is retained + first = rng.uniform(0.0, 1.0, n) * interval * (1 + rng.uniform(-jitter, jitter, n)) + extra_rounds = rng.geometric(config.pool_release_probability, n) - 1 + return first + extra_rounds * interval + raise ValueError(f"no delay model for mixing strategy {strategy!r}") + + +def delay_quantiles(config, qs: tuple[float, ...], n: int = 200_000, seed: int = 0) -> dict[float, float]: + samples = sample_added_delay(config, n, np.random.default_rng(seed)) + return {q: float(np.quantile(samples, q)) for q in qs} + + +def delay_density(config, step_s: float, n: int = 400_000, seed: int = 0) -> np.ndarray: + """Histogram estimate of the added-delay density on a grid of width + step_s starting at 0, in units of 1/second. The last bin reaches the + 99.99th percentile.""" + samples = sample_added_delay(config, n, np.random.default_rng(seed)) + top = float(np.quantile(samples, 0.9999)) + step_s + n_bins = max(1, int(np.ceil(top / step_s))) + hist, _ = np.histogram(samples, bins=n_bins, range=(0.0, n_bins * step_s)) + return hist / (n * step_s) diff --git a/anontestlab/adversary/global_observer.py b/anontestlab/adversary/global_observer.py index 530f0c9..c53a6de 100644 --- a/anontestlab/adversary/global_observer.py +++ b/anontestlab/adversary/global_observer.py @@ -24,11 +24,13 @@ def __init__( classifier: str = "pearson", threshold: float = 0.7, fpr_targets: tuple[float, ...] = (0.1, 0.01, 0.001), + calibration_fraction: float = 0.0, ): self.bin_size = bin_size self.classifier = classifier self.threshold = threshold self.fpr_targets = fpr_targets + self.calibration_fraction = calibration_fraction @classmethod def from_config(cls, config) -> GlobalPassiveObserver: @@ -36,6 +38,7 @@ def from_config(cls, config) -> GlobalPassiveObserver: bin_size=getattr(config, "observer_bin_width_ms", 50.0) / 1000.0, classifier=getattr(config, "observer_classifier", "pearson"), threshold=getattr(config, "observer_threshold", 0.7), + calibration_fraction=getattr(config, "observer_calibration_fraction", 0.0), ) def attack(self, ctx: SimulationContext, rng: random.Random) -> AdversaryResult: @@ -60,6 +63,9 @@ def attack(self, ctx: SimulationContext, rng: random.Random) -> AdversaryResult: extractor = features.get_feature_extractor(self.classifier) scores = extractor(ingress, egress) - metrics = decision.evaluate_scores(scores, self.threshold, self.fpr_targets) + split_rng = np.random.default_rng(rng.randrange(2**32)) if self.calibration_fraction > 0 else None + metrics = decision.evaluate_scores( + scores, self.threshold, self.fpr_targets, self.calibration_fraction, split_rng + ) return AdversaryResult(name=self.name, n_sessions=n, metrics=metrics) diff --git a/anontestlab/cli.py b/anontestlab/cli.py index 5865774..0e1f696 100644 --- a/anontestlab/cli.py +++ b/anontestlab/cli.py @@ -4,7 +4,7 @@ import sys from pathlib import Path -from .experiment import ExperimentConfig, compare_experiments, run_experiment, run_sweep +from .experiment import ExperimentConfig, compare_experiments, run_experiment, run_paired, run_sweep from .wizard import run_wizard @@ -60,6 +60,30 @@ def main(argv: list[str] | None = None) -> int: sweep_p.add_argument("--values", type=str, required=True, help="Comma-separated values") sweep_p.add_argument("--out", type=str, default=None) + paired_p = sub.add_parser( + "paired", help="Run two experiments over the same seeds and classify the paired difference" + ) + paired_p.add_argument("reference_yaml", type=str) + paired_p.add_argument("treatment_yaml", type=str) + paired_p.add_argument( + "--seeds", type=str, default="12", + help="A count N (uses the reference seed + 0..N-1) or a comma-separated seed list", + ) + paired_p.add_argument("--metric", type=str, default="tpr_at_fpr_0.001") + paired_p.add_argument("--margin", type=float, default=0.05, help="Practical-equivalence margin") + paired_p.add_argument("--out", type=str, default=None) + + predict_p = sub.add_parser( + "predict", help="Print the model's predictions for an experiment without running it" + ) + predict_p.add_argument("experiment_yaml", type=str) + + fidelity_p = sub.add_parser( + "fidelity", help="Measure host-induced timing noise for an experiment's load (mixing switched off)" + ) + fidelity_p.add_argument("experiment_yaml", type=str) + fidelity_p.add_argument("--limit-fraction", type=float, default=0.1) + sub.add_parser("wizard", help="Interactively build and run an experiment") dashboard_p = sub.add_parser("dashboard", help="Launch the local web dashboard") @@ -111,6 +135,45 @@ def main(argv: list[str] | None = None) -> int: print(f"\nWritten to {out_dir}/") return 0 + if args.command == "paired": + reference = ExperimentConfig.from_yaml(args.reference_yaml) + treatment = ExperimentConfig.from_yaml(args.treatment_yaml) + if "," in args.seeds: + seeds = [int(x) for x in args.seeds.split(",")] + else: + seeds = [reference.seed + i for i in range(int(args.seeds))] + out_dir = ( + Path(args.out) if args.out else Path("results") / f"{reference.name}_vs_{treatment.name}_paired" + ) + print(f"Running {reference.name} vs {treatment.name} over {len(seeds)} paired seeds...") + result = run_paired(reference, treatment, seeds, args.metric, args.margin, out_dir=out_dir) + e = result.effect + print(f"\n{args.metric}: mean delta {e.mean_delta:+.4f}, 95% CI [{e.ci_low:+.4f}, {e.ci_high:+.4f}]") + print(f"n = {e.n_pairs} pairs, margin +/-{args.margin}: {e.classification}") + print(f"\nWritten to {out_dir}/") + return 0 + + if args.command == "predict": + from .model import predict + + config = ExperimentConfig.from_yaml(args.experiment_yaml) + psi = config.suite_psi[0] if config.suite_psi else 0.9 + base_rate = config.suite_base_rates[0] if config.suite_base_rates else 1e-4 + fpr = config.suite_fpr_targets[0] if config.suite_fpr_targets else 1e-3 + _print_metrics(f"{config.name} (model prediction)", predict(config, fpr=fpr, psi=psi, base_rate=base_rate)) + return 0 + + if args.command == "fidelity": + from .experiment.fidelity import run_fidelity + + config = ExperimentConfig.from_yaml(args.experiment_yaml) + report = run_fidelity(config, args.limit_fraction) + print(f"\nFidelity check for {config.name}") + print("─" * 40) + for k, v in report.items(): + print(f"{k:28s} {v:.4f}" if isinstance(v, float) else f"{k:28s} {v}") + return 0 if report["passes"] else 2 + if args.command == "wizard": config = run_wizard() out_dir = Path("results") / config.name diff --git a/anontestlab/core/packet.py b/anontestlab/core/packet.py index ae3bd02..6695057 100644 --- a/anontestlab/core/packet.py +++ b/anontestlab/core/packet.py @@ -14,6 +14,7 @@ class Packet: path: list[str] created_at: float delivered_at: float | None = None + exit_at: float | None = None # when the exit hop released it (one-way delay = exit_at - created_at) real_seq: int | None = None # 1-indexed position among this circuit's real sends, # independent of cover traffic; used to align with the watermark relay's own count diff --git a/anontestlab/crypto.py b/anontestlab/crypto.py index bcebe6b..86c7cd9 100644 --- a/anontestlab/crypto.py +++ b/anontestlab/crypto.py @@ -1,6 +1,6 @@ """Selectable AEAD algorithms for the per-hop encryption layer. -Real per-hop symmetric encryption, not a modeled cost. `none` is a +Per-hop symmetric encryption, executed for real so its cost is measured. `none` is a plaintext passthrough (still real framing/transport, useful for isolating transport cost from crypto cost in benchmarks). """ diff --git a/anontestlab/emulator/__init__.py b/anontestlab/emulator/__init__.py index 52a302b..9ca9088 100644 --- a/anontestlab/emulator/__init__.py +++ b/anontestlab/emulator/__init__.py @@ -1,6 +1,6 @@ """Real localhost network emulator: relay nodes run as separate OS processes bound to real 127.0.0.1: sockets, connected by a -telescoping (Tor-style) onion circuit with genuine per-hop AEAD +telescoping (Tor-style) onion circuit with per-hop AEAD encryption. This is the primary engine for traffic/latency/correlation experiments; `anontestlab.core` (the discrete-event simulator) stays around only as a fast, transport-free utility for Monte Carlo/statistical diff --git a/anontestlab/emulator/circuit_client.py b/anontestlab/emulator/circuit_client.py index 6820db0..ae70cb2 100644 --- a/anontestlab/emulator/circuit_client.py +++ b/anontestlab/emulator/circuit_client.py @@ -17,6 +17,7 @@ def wrap_layers( algorithm: str, kind: int = wire.KIND_CONTROL, packet_id: int = 0, + terminate_at: int | None = None, ) -> bytes: """Build the onion for a cell addressed to the *deepest* established hop (`target_cell`, a raw EXTEND or DATA cell, whatever that hop @@ -33,11 +34,17 @@ def wrap_layers( that hop will actually see on its own connection (hop-local IDs, not one ID shared across the whole path), so `circuit_ids_in_order` must line up index-for-index with `keys_in_order`. + + terminate_at, if given, is the index of an intermediate hop whose own + layer is marked KIND_COVER_TERMINATE instead of `kind`, so that hop + absorbs the cell (random-hop cover drop). The deepest hop needs no + mark: it's already the terminus. """ *intermediate, (last_key, last_cid) = zip(keys_in_order, circuit_ids_in_order) layer = crypto_layer.seal(algorithm, last_key, target_cell, aad=last_cid) - for key, cid in reversed(intermediate): - cell = wire.pack_data(kind, packet_id, layer) + for hop, (key, cid) in reversed(list(enumerate(intermediate))): + layer_kind = wire.KIND_COVER_TERMINATE if hop == terminate_at else kind + cell = wire.pack_data(layer_kind, packet_id, layer) layer = crypto_layer.seal(algorithm, key, cell, aad=cid) return layer @@ -99,15 +106,17 @@ class Circuit: cell_size: int | None = None _next_id: int = field(default=1) - async def _send_cell(self, kind: int, packet_id: int, chunk: bytes) -> None: + async def _send_cell(self, kind: int, packet_id: int, chunk: bytes, terminate_at: int | None = None) -> None: target_cell = wire.pack_data(kind, packet_id, chunk) if self.cell_size is not None: target_cell = pad_to_cell_size(target_cell, self.cell_size, self.algorithm, len(self.keys_fwd)) - sealed = wrap_layers(self.keys_fwd, self.circuit_ids, target_cell, self.algorithm, kind, packet_id) + sealed = wrap_layers( + self.keys_fwd, self.circuit_ids, target_cell, self.algorithm, kind, packet_id, terminate_at + ) self.writer.write(wire.pack_frame(wire.MSG_RELAY_FWD, self.circuit_id, sealed)) await self.writer.drain() - async def send(self, kind: int, payload: bytes) -> int: + async def send(self, kind: int, payload: bytes, terminate_at: int | None = None) -> int: """Sends `payload` as one cell, or, if it doesn't fit the fixed cell_size budget, splits it across multiple cells (fragmented the same way regardless of why it didn't fit: a payload larger @@ -118,12 +127,18 @@ async def send(self, kind: int, payload: bytes) -> int: relay_process.py::process_relay_fwd). cover payloads never need the distinction: nothing confirms cover traffic either way, so every fragment can just stay KIND_COVER. + + terminate_at (cover only) makes that hop index absorb the cell; see + wrap_layers. Indices at or past the last hop mean "the exit", + which absorbs cover anyway. """ + if terminate_at is not None and (kind != wire.KIND_COVER or terminate_at >= len(self.keys_fwd) - 1): + terminate_at = None packet_id = self._next_id self._next_id += 1 if self.cell_size is None: - await self._send_cell(kind, packet_id, payload) + await self._send_cell(kind, packet_id, payload, terminate_at) return packet_id max_chunk = self.cell_size - wire.layer_overhead(self.algorithm) * len(self.keys_fwd) @@ -136,8 +151,8 @@ async def send(self, kind: int, payload: bytes) -> int: chunks = [payload[i : i + max_chunk] for i in range(0, len(payload), max_chunk)] or [b""] fragment_kind = wire.KIND_REAL_FRAGMENT if kind == wire.KIND_REAL else kind for chunk in chunks[:-1]: - await self._send_cell(fragment_kind, packet_id, chunk) - await self._send_cell(kind, packet_id, chunks[-1]) + await self._send_cell(fragment_kind, packet_id, chunk, terminate_at) + await self._send_cell(kind, packet_id, chunks[-1], terminate_at) return packet_id async def recv_delivery(self) -> tuple[int, float]: diff --git a/anontestlab/emulator/mixing.py b/anontestlab/emulator/mixing.py new file mode 100644 index 0000000..7c8f5e5 --- /dev/null +++ b/anontestlab/emulator/mixing.py @@ -0,0 +1,114 @@ +"""Relay-side mixing: when a relay actually transmits a DATA cell it has +decided to forward. One Mixer per relay process, shared by every circuit +through that relay, so a pool mixes cells from different sessions. + +Strategies: + +- none: transmit immediately and inline, exactly the pre-mixing behavior + (a circuit's cells stay in order and queue behind each other's link + delays). +- constant: every cell waits the same delay_s. Shifts timing without + reordering; included as the sanity case that a lag-aware observer should + fully undo. +- exponential: every cell waits an independent Exponential(mean=delay_s) + delay (Loopix/Nym-style continuous-time mixing). Cells can overtake each + other. +- pool: cells accumulate in one relay-wide pool that is flushed every + interval_s (optionally jittered by +/- interval_jitter, a fraction of + interval_s, for randomized flushing). At each flush every pooled cell is + independently released with release_probability. 1.0 is a timed pool + that empties completely (burst release, the ASN-style batch); < 1 is a + binomial/retained pool where a cell can survive several flushes + (geometric sojourn). Released cells go out in shuffled order. +""" +from __future__ import annotations + +import asyncio +import contextlib +import random +from collections.abc import Awaitable, Callable + +STRATEGIES = ("none", "constant", "exponential", "pool") + +Send = Callable[[], Awaitable[None]] + + +class Mixer: + def __init__( + self, + strategy: str = "none", + delay_s: float = 0.0, + interval_s: float = 0.1, + release_probability: float = 1.0, + interval_jitter: float = 0.0, + rng: random.Random | None = None, + ): + if strategy not in STRATEGIES: + raise ValueError(f"unknown mixing strategy {strategy!r}, available: {list(STRATEGIES)}") + self.strategy = strategy + self.delay_s = delay_s + self.interval_s = interval_s + self.release_probability = release_probability + self.interval_jitter = interval_jitter + self.rng = rng if rng is not None else random.Random() + self._pool: list[Send] = [] + self._flusher: asyncio.Task | None = None + self._tasks: set[asyncio.Task] = set() # strong refs so pending sends aren't garbage-collected + + @property + def pool_size(self) -> int: + return len(self._pool) + + async def submit(self, send: Send) -> None: + """Hand over one transmission. Returns once the cell is queued (or, + for strategy none, once it has been sent).""" + if self.strategy == "none": + await send() + elif self.strategy == "constant": + self._spawn(self._delayed(self.delay_s, send)) + elif self.strategy == "exponential": + self._spawn(self._delayed(self.rng.expovariate(1.0 / self.delay_s), send)) + else: + self._pool.append(send) + if self._flusher is None: + self._flusher = asyncio.create_task(self._flush_loop()) + + def flush(self) -> int: + """Release this round's cells from the pool; returns how many.""" + pool = self._pool + self.rng.shuffle(pool) + if self.release_probability >= 1.0: + released, kept = pool, [] + else: + released, kept = [], [] + for send in pool: + (released if self.rng.random() < self.release_probability else kept).append(send) + self._pool = kept + for send in released: + self._spawn(_guarded(send)) + return len(released) + + async def _flush_loop(self) -> None: + while True: + interval = self.interval_s + if self.interval_jitter > 0: + interval *= 1 + self.rng.uniform(-self.interval_jitter, self.interval_jitter) + await asyncio.sleep(interval) + self.flush() + + async def _delayed(self, delay: float, send: Send) -> None: + await asyncio.sleep(delay) + await _guarded(send) + + def _spawn(self, coro) -> None: + task = asyncio.create_task(coro) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + + +async def _guarded(send: Send) -> None: + # A deferred send can outlive its circuit (the session ended or the + # downstream connection closed while the cell sat in the mix). That + # cell is simply lost, like one still inside a real mix at shutdown. + with contextlib.suppress(ConnectionError, OSError): + await send() diff --git a/anontestlab/emulator/orchestrator.py b/anontestlab/emulator/orchestrator.py index 7d30d0a..48512ed 100644 --- a/anontestlab/emulator/orchestrator.py +++ b/anontestlab/emulator/orchestrator.py @@ -5,8 +5,8 @@ `(config.seed, session_id)`, so *which* paths are chosen and *when* packets are scheduled is fully reproducible from the seed, but sessions run concurrently over real sockets, so the actual measured latency/ -delivery timing will vary run to run like any real system's would. That's -real system behavior, not a bug; only the experiment design is pinned. +delivery timing will vary run to run like any real system's would; only +the experiment design is pinned. """ from __future__ import annotations @@ -27,6 +27,7 @@ from ..traffic import get_generator from . import wire from .circuit_client import Circuit, build_circuit +from .splitting import LegScheduler if TYPE_CHECKING: # Only needed for type hints; importing it at module level would @@ -75,6 +76,8 @@ async def spawn_relays( per_edge: bool = False, link_seed: int = 0, link_heterogeneity_spread: float = 0.5, + mixing: dict | None = None, + mix_seed: int = 0, ) -> list[RelayHandle]: """link_factors, if given, is one multiplicative scale per node index (node i's actual latency/jitter/loss/bandwidth = base * link_factors[i]), @@ -88,7 +91,13 @@ async def spawn_relays( see relay_process.py::edge_factor for the directional-only scope of this (the receiving side's own upstream-facing sends aren't scaled by it, a disclosed simplification). + + mixing, if given, holds the relay-side mixing settings (strategy, + delay_ms, pool_interval_ms, release_probability, interval_jitter), + identical on every relay; each relay's mixing RNG is seeded from + mix_seed + its index. """ + mixing = mixing or {} handles = [] for i in range(num_nodes): host = _relay_host(i) @@ -128,6 +137,18 @@ async def spawn_relays( str(link_seed), "--link-heterogeneity-spread", str(link_heterogeneity_spread), + "--mix-strategy", + str(mixing.get("strategy", "none")), + "--mix-delay-ms", + str(mixing.get("delay_ms", 0.0)), + "--pool-interval-ms", + str(mixing.get("pool_interval_ms", 100.0)), + "--pool-release-probability", + str(mixing.get("release_probability", 1.0)), + "--pool-interval-jitter", + str(mixing.get("interval_jitter", 0.0)), + "--mix-seed", + str(mix_seed + i), ] if per_edge: args.append("--per-edge") @@ -165,20 +186,34 @@ async def terminate_relays(handles: list[RelayHandle]) -> None: await h.process.wait() -class PathSplitter: - def __init__(self, strategy: str, num_paths: int): - self.strategy = strategy - self.num_paths = num_paths - self._counters: dict[int, int] = {} +def make_scheduler(config: ExperimentConfig) -> LegScheduler: + """A fresh split-policy state for one session (see emulator/splitting.py).""" + return LegScheduler( + config.split_strategy, + config.num_paths, + weights=config.split_weights, + batch_mean_s=config.split_batch_mean_s, + leg_rtt_s=[ms / 1000.0 for ms in config.split_leg_rtt_ms] if config.split_leg_rtt_ms else None, + leg_window=config.split_leg_window, + ) + - def assign(self, session_id: int, rng: random.Random) -> int: - if self.num_paths == 1: - return 0 - if self.strategy == "random": - return rng.randrange(self.num_paths) - i = self._counters.get(session_id, 0) - self._counters[session_id] = i + 1 - return i % self.num_paths +def select_node_paths(config: ExperimentConfig, node_ids: list[str], rng: random.Random, weights) -> list[list[str]]: + """Relays for every leg of one session. With merge == "common_exit" all + legs end at one exit relay (Conflux-style) and their other hops are + drawn without reuse across legs, so every leg has its own entry guard.""" + specs = config.paths + if config.merge != "common_exit" or len(specs) == 1: + return [get_strategy(s.strategy).select_path(node_ids, rng, s.path_length, weights) for s in specs] + exit_node = rng.choice(node_ids) + used = {exit_node} + paths = [] + for spec in specs: + pool = [n for n in node_ids if n not in used] + head = get_strategy(spec.strategy).select_path(pool, rng, spec.path_length - 1, weights) + used.update(head) + paths.append([*head, exit_node]) + return paths def _fixed_rate_schedule(real_times: list[float], config: ExperimentConfig) -> list[tuple[float, str]]: @@ -220,12 +255,17 @@ async def run_session( addr_paths: list[list[tuple[str, int]]], config: ExperimentConfig, rng: random.Random, - splitter: PathSplitter, + scheduler: LegScheduler, observed_entry_indices: set[int], observed_exit_indices: set[int], experiment_start: float, ) -> tuple[list[Packet], SessionObservation, float]: - real_gen = get_generator(config.real_traffic_distribution, config.real_rate) + real_gen = get_generator( + config.real_traffic_distribution, + config.real_rate, + burst_mean_cells=config.burst_mean_cells, + burst_gap_ms=config.burst_gap_ms, + ) cover_gen = ( get_generator(config.cover_traffic_distribution, config.cover_rate) if config.cover_rate > 0 @@ -241,7 +281,12 @@ async def run_session( ) build_delay = time.monotonic() - build_start - obs = SessionObservation(session_id=session_id) + obs = SessionObservation(session_id=session_id, leg_real_counts=[0] * len(circuits)) + # Timestamps are measured from the experiment start, so concurrent flows + # share one clock. In wave mode (max_concurrent_sessions) sessions run at + # different times, and each one is measured from its own start instead, + # so all flows line up on [0, duration_s] as if they had run together. + origin = time.monotonic() if config.max_concurrent_sessions else experiment_start packets: list[Packet] = [] pending: dict[tuple[int, int], Packet] = {} next_packet_id = 0 @@ -254,10 +299,11 @@ async def listen(path_idx: int, circuit: Circuit) -> None: try: while True: packet_id, exit_t = await circuit.recv_delivery() - t = time.monotonic() - experiment_start + t = time.monotonic() - origin pkt = pending.pop((path_idx, packet_id), None) if pkt is not None: pkt.delivered_at = t + pkt.exit_at = exit_t - origin if path_idx in observed_exit_indices: # exit_t, not t: t is when the confirmation finished # its own round trip back through every hop (with @@ -265,7 +311,7 @@ async def listen(path_idx: int, circuit: Circuit) -> None: # way back), not when the packet actually left the # exit hop, which is what a passive exit observer # would actually see. - obs.egress_times.append(exit_t - experiment_start) + obs.egress_times.append(exit_t - origin) obs.egress_seq.append(pkt.real_seq) except (asyncio.IncompleteReadError, ConnectionError, OSError): pass @@ -287,12 +333,17 @@ async def listen(path_idx: int, circuit: Circuit) -> None: if target > now: await asyncio.sleep(target - now) - path_idx = splitter.assign(session_id, rng) + path_idx = scheduler.assign(t, rng) + if kind == "real": + obs.leg_real_counts[path_idx] += 1 circuit = circuits[path_idx] + terminate_at = None + if kind == "cover" and config.cover_drop_mode == "random_hop": + terminate_at = rng.randrange(len(circuit.path)) # the last index means the exit, which absorbs cover anyway packet_id = await circuit.send( - wire.KIND_REAL if kind == "real" else wire.KIND_COVER, os.urandom(PAYLOAD_SIZE) + wire.KIND_REAL if kind == "real" else wire.KIND_COVER, os.urandom(PAYLOAD_SIZE), terminate_at ) - t_send = time.monotonic() - experiment_start + t_send = time.monotonic() - origin next_packet_id += 1 # noqa: SIM113 - a Packet.packet_id counter, not this loop's index if kind == "real": @@ -339,7 +390,7 @@ def emit(event_type: str, **fields) -> None: handles = await spawn_relays( config.num_nodes, config.crypto_algorithm, - config.cover_drop_probability, + config.cover_drop_probability if config.cover_drop_mode == "per_hop" else 0.0, config.watermark_period, config.watermark_delay_ms, config.link_latency_ms, @@ -351,6 +402,14 @@ def emit(event_type: str, **fields) -> None: config.link_per_edge, config.seed * 7919 + 17, # decorrelated from other seeded RNGs derived from config.seed config.link_heterogeneity_spread, + { + "strategy": config.mix_strategy, + "delay_ms": config.mix_delay_ms, + "pool_interval_ms": config.pool_interval_ms, + "release_probability": config.pool_release_probability, + "interval_jitter": config.pool_interval_jitter, + }, + config.seed * 104729 + 31, # decorrelated from the other seed-derived RNGs ) node_ids = [h.node_id for h in handles] addr_of = {h.node_id: (h.host, h.port) for h in handles} @@ -366,7 +425,6 @@ def emit(event_type: str, **fields) -> None: sessions_completed = 0 path_specs = config.paths - splitter = PathSplitter(config.split_strategy, len(path_specs)) experiment_start = time.monotonic() # AS-level partial observer: a structural property of where the @@ -381,15 +439,12 @@ def emit(event_type: str, **fields) -> None: async def run_one(session_id: int) -> None: session_rng = random.Random(config.seed * 1_000_003 + session_id + 1) - node_paths = [ - get_strategy(spec.strategy).select_path(node_ids, session_rng, spec.path_length, node_weights) - for spec in path_specs - ] + node_paths = select_node_paths(config, node_ids, session_rng, node_weights) if config.watermark_period > 0: # The watermark relay only makes sense as hop 1. Pin it # there on the first path (swap rather than overwrite, to - # keep the path's nodes distinct). It's not excluded from - # other paths/positions it might land in by chance; a + # keep the path's nodes distinct). It may still land in + # other paths/positions by chance; a # known edge case for a deliberately simple model. watermark_node_id = node_ids[WATERMARK_NODE_INDEX] first_path = node_paths[0] @@ -405,17 +460,22 @@ async def run_one(session_id: int) -> None: if config.num_as_groups > 1: observed_entry_indices = {i for i, p in enumerate(node_paths) if as_of[p[0]] in observed_as_ids} observed_exit_indices = {i for i, p in enumerate(node_paths) if as_of[p[-1]] in observed_as_ids} + elif config.observed_legs is not None: + observed_entry_indices = observed_exit_indices = set(config.observed_legs) else: k = config.observed_path_count or len(path_specs) observed_entry_indices = observed_exit_indices = set( session_rng.sample(range(len(path_specs)), min(k, len(path_specs))) ) + if config.egress_observation == "merged": + # the exit-side observer sees the whole flow after the legs merge + observed_exit_indices = set(range(len(path_specs))) # A lost handshake packet (under configured link_loss_probability) # surfaces here as a ProtocolError (see wire.read_frame_timeout) or - # a connection-level failure. That's realistic behavior worth - # seeing, not a bug, but it must fail only *this* session, not - # take down the whole experiment. session_paths above is already + # a connection-level failure. That is realistic behavior worth + # seeing; it must fail only *this* session and leave the rest of + # the experiment running. session_paths above is already # recorded regardless, so path_compromise is unaffected. nonlocal sessions_failed, sessions_completed try: @@ -424,7 +484,7 @@ async def run_one(session_id: int) -> None: addr_paths, config, session_rng, - splitter, + make_scheduler(config), observed_entry_indices, observed_exit_indices, experiment_start, @@ -457,7 +517,16 @@ async def run_one(session_id: int) -> None: build_delay_s=build_delay, ) - await asyncio.gather(*[run_one(sid) for sid in range(config.num_sessions)]) + if config.max_concurrent_sessions: + gate = asyncio.Semaphore(config.max_concurrent_sessions) + + async def gated(sid: int) -> None: + async with gate: + await run_one(sid) + + await asyncio.gather(*[gated(sid) for sid in range(config.num_sessions)]) + else: + await asyncio.gather(*[run_one(sid) for sid in range(config.num_sessions)]) emit("experiment_complete", sessions_failed=sessions_failed, total=config.num_sessions) ctx = SimulationContext(sessions=observations, session_paths=session_paths, node_ids=node_ids) diff --git a/anontestlab/emulator/relay_process.py b/anontestlab/emulator/relay_process.py index 205c9a4..f1e45c6 100644 --- a/anontestlab/emulator/relay_process.py +++ b/anontestlab/emulator/relay_process.py @@ -24,6 +24,7 @@ from dataclasses import dataclass, field from . import crypto_layer, wire +from .mixing import Mixer @dataclass @@ -55,6 +56,7 @@ class RelayState: link_jitter_s: float = 0.0 link_loss_probability: float = 0.0 link_bandwidth_kbps: float | None = None # None = unlimited + mixer: Mixer = field(default_factory=Mixer) # relay-wide, shared by every circuit (see mixing.py) circuits: dict[bytes, CircuitState] = field(default_factory=dict) @@ -172,6 +174,8 @@ async def process_relay_fwd(state: RelayState, circuit: CircuitState, plaintext: if ctype == wire.CELL_DATA: kind, packet_id, inner = wire.unpack_data(plaintext) + if kind == wire.KIND_COVER_TERMINATE: + return # random-hop cover drop: this hop is the one chosen to absorb it if circuit.downstream_writer is not None: if ( kind == wire.KIND_COVER @@ -183,10 +187,19 @@ async def process_relay_fwd(state: RelayState, circuit: CircuitState, plaintext: circuit.real_packet_count += 1 if circuit.real_packet_count % state.watermark_period == 0: await asyncio.sleep(state.watermark_delay_s) - if not await apply_link_conditions(state, len(inner), circuit.downstream_edge_factor): - return # lost on this hop's outbound link - circuit.downstream_writer.write(wire.pack_frame(wire.MSG_RELAY_FWD, circuit.downstream_cid, inner)) - await circuit.downstream_writer.drain() + + async def forward() -> None: + if not await apply_link_conditions(state, len(inner), circuit.downstream_edge_factor): + return # lost on this hop's outbound link + circuit.downstream_writer.write(wire.pack_frame(wire.MSG_RELAY_FWD, circuit.downstream_cid, inner)) + await circuit.downstream_writer.drain() + + # EXTEND cells in transit (KIND_CONTROL) bypass the mix, which + # applies to data-phase forwarding only. + if kind == wire.KIND_CONTROL: + await forward() + else: + await state.mixer.submit(forward) elif kind == wire.KIND_REAL: # Timestamped here, at the exit hop, before the confirmation's own # return trip: an adversary watching this hop's outbound wire @@ -195,12 +208,19 @@ async def process_relay_fwd(state: RelayState, circuit: CircuitState, plaintext: # return-leg latency/jitter on top). time.monotonic() is a # system-wide clock on every platform this targets, so it's # directly comparable across the relay and orchestrator processes. - confirmation = struct.pack(">Qd", packet_id, time.monotonic()) - sealed = crypto_layer.seal(state.algorithm, circuit.key_back, confirmation, aad=circuit.upstream_cid) - if not await apply_link_conditions(state, len(sealed)): - return - circuit.upstream_writer.write(wire.pack_frame(wire.MSG_RELAY_BACK, circuit.upstream_cid, sealed)) - await circuit.upstream_writer.drain() + # + # The exit is a mix like every other hop, so the cell first waits + # out this hop's mixing and the timestamp marks when it actually + # leaves. + async def emit() -> None: + confirmation = struct.pack(">Qd", packet_id, time.monotonic()) + sealed = crypto_layer.seal(state.algorithm, circuit.key_back, confirmation, aad=circuit.upstream_cid) + if not await apply_link_conditions(state, len(sealed)): + return + circuit.upstream_writer.write(wire.pack_frame(wire.MSG_RELAY_BACK, circuit.upstream_cid, sealed)) + await circuit.upstream_writer.drain() + + await state.mixer.submit(emit) # cover packet, or a non-final fragment (KIND_REAL_FRAGMENT) of a # multi-cell real payload, at the terminal hop: silently absorbed, # no confirmation. Only the final fragment arrives as plain @@ -255,6 +275,7 @@ async def run_relay( per_edge: bool = False, link_seed: int = 0, link_heterogeneity_spread: float = 0.5, + mixer: Mixer | None = None, ) -> None: state = RelayState( algorithm=algorithm, @@ -270,6 +291,7 @@ async def run_relay( link_jitter_s=link_jitter_s, link_loss_probability=link_loss_probability, link_bandwidth_kbps=link_bandwidth_kbps, + mixer=mixer if mixer is not None else Mixer(), ) server = await asyncio.start_server( lambda r, w: handle_connection(r, w, state), host=host, port=port @@ -296,7 +318,21 @@ def main() -> None: parser.add_argument("--per-edge", action="store_true") parser.add_argument("--link-seed", type=int, default=0) parser.add_argument("--link-heterogeneity-spread", type=float, default=0.5) + parser.add_argument("--mix-strategy", type=str, default="none") + parser.add_argument("--mix-delay-ms", type=float, default=0.0) + parser.add_argument("--pool-interval-ms", type=float, default=100.0) + parser.add_argument("--pool-release-probability", type=float, default=1.0) + parser.add_argument("--pool-interval-jitter", type=float, default=0.0) + parser.add_argument("--mix-seed", type=int, default=None) args = parser.parse_args() + mixer = Mixer( + args.mix_strategy, + delay_s=args.mix_delay_ms / 1000.0, + interval_s=args.pool_interval_ms / 1000.0, + release_probability=args.pool_release_probability, + interval_jitter=args.pool_interval_jitter, + rng=random.Random(args.mix_seed), + ) try: asyncio.run( run_relay( @@ -315,6 +351,7 @@ def main() -> None: args.per_edge, args.link_seed, args.link_heterogeneity_spread, + mixer, ) ) except KeyboardInterrupt: diff --git a/anontestlab/emulator/splitting.py b/anontestlab/emulator/splitting.py new file mode 100644 index 0000000..99ea7c9 --- /dev/null +++ b/anontestlab/emulator/splitting.py @@ -0,0 +1,98 @@ +"""Split policies: which leg of a multipath session carries each cell. + +- round_robin: legs in turn. With weights, a smooth weighted round-robin + (each leg earns credit equal to its weight and the leg with the most + credit sends), so the assignment stays deterministic and evenly spread. +- random: each cell to a uniformly chosen leg (the original behavior). +- iid: each cell to leg i with probability weights[i]. +- batch: time is cut into batches with exponential durations (mean + batch_mean_s); every cell in a batch goes to one leg, drawn with the + weights. TrafficSliver-style splitting at a coarser granularity. +- latency: send on the lowest-RTT leg that has room in its window, where a + leg's in-flight count is the number of cells sent on it within the last + RTT. When every leg is full, the leg with the lowest in-flight share of + its window sends. Modeled on the lowest-RTT-first idea of Tor's Conflux + (Proposal 329); the RTTs are the configured nominal values and only drive + the scheduling decision. +""" +from __future__ import annotations + +import random +from collections import deque + +POLICIES = ("round_robin", "random", "iid", "batch", "latency") + + +class LegScheduler: + """Per-session state for one split policy.""" + + def __init__( + self, + policy: str, + num_legs: int, + weights: list[float] | None = None, + batch_mean_s: float = 2.0, + leg_rtt_s: list[float] | None = None, + leg_window: int = 8, + ): + if policy not in POLICIES: + raise ValueError(f"unknown split policy {policy!r}, available: {list(POLICIES)}") + self.policy = policy + self.num_legs = num_legs + total = sum(weights) if weights else float(num_legs) + self.weights = [w / total for w in weights] if weights else [1.0 / num_legs] * num_legs + self.batch_mean_s = batch_mean_s + self.leg_rtt_s = leg_rtt_s or [0.05 * (i + 1) for i in range(num_legs)] + self.leg_window = leg_window + self._credit = [0.0] * num_legs + self._rr_next = 0 + self._batch_end = -1.0 + self._batch_leg = 0 + self._recent: list[deque] = [deque() for _ in range(num_legs)] + + def assign(self, t: float, rng: random.Random) -> int: + if self.num_legs == 1: + return 0 + if self.policy == "round_robin": + return self._round_robin() + if self.policy == "random": + return rng.randrange(self.num_legs) + if self.policy == "iid": + return self._draw(rng) + if self.policy == "batch": + if t >= self._batch_end: + self._batch_leg = self._draw(rng) + self._batch_end = t + rng.expovariate(1.0 / self.batch_mean_s) + return self._batch_leg + return self._lowest_rtt(t) + + def _draw(self, rng: random.Random) -> int: + u = rng.random() + acc = 0.0 + for leg, w in enumerate(self.weights): + acc += w + if u < acc: + return leg + return self.num_legs - 1 + + def _round_robin(self) -> int: + if len(set(self.weights)) == 1: + leg = self._rr_next + self._rr_next = (leg + 1) % self.num_legs + return leg + for leg in range(self.num_legs): + self._credit[leg] += self.weights[leg] + leg = max(range(self.num_legs), key=lambda i: self._credit[i]) + self._credit[leg] -= 1.0 + return leg + + def _lowest_rtt(self, t: float) -> int: + for leg, sent in enumerate(self._recent): + while sent and sent[0] <= t - self.leg_rtt_s[leg]: + sent.popleft() + by_rtt = sorted(range(self.num_legs), key=lambda i: self.leg_rtt_s[i]) + chosen = next((leg for leg in by_rtt if len(self._recent[leg]) < self.leg_window), None) + if chosen is None: + chosen = min(by_rtt, key=lambda i: len(self._recent[i]) / self.leg_window) + self._recent[chosen].append(t) + return chosen diff --git a/anontestlab/emulator/wire.py b/anontestlab/emulator/wire.py index b8f9fb2..81703a6 100644 --- a/anontestlab/emulator/wire.py +++ b/anontestlab/emulator/wire.py @@ -15,8 +15,8 @@ circuit_id is hop-local, not shared across the whole path: each EXTEND carries the next hop-link's ID, so a link tap can't correlate sessions by matching IDs across hops. Known simplification: RELAY_BACK bodies -aren't re-wrapped per hop on the way back. That's a disclosed scope -trim, not a claim of traffic-analysis resistance. +aren't re-wrapped per hop on the way back. That is a disclosed scope +trim and makes no claim of traffic-analysis resistance. """ from __future__ import annotations @@ -168,3 +168,9 @@ def cell_type(plaintext: bytes) -> int: # counting), but the terminal hop must not confirm it: # only the final fragment, sent as plain KIND_REAL, # triggers a delivery confirmation. +KIND_COVER_TERMINATE = 4 # cover whose route ends at this hop: the client marks + # exactly one hop's layer with this kind (see + # circuit_client.py::wrap_layers, terminate_at) and that + # hop absorbs the cell instead of forwarding it. Every other + # hop's layer says plain KIND_COVER, so no hop learns where + # a cover cell dies unless it's the one that absorbs it. diff --git a/anontestlab/experiment/__init__.py b/anontestlab/experiment/__init__.py index 2c7f1f2..e4b0f9e 100644 --- a/anontestlab/experiment/__init__.py +++ b/anontestlab/experiment/__init__.py @@ -1,5 +1,6 @@ from .compare import ComparisonResult, compare_experiments from .config import ExperimentConfig, PathSpec +from .paired import PairedResult, run_paired from .runner import ExperimentResult, run_experiment, write_results from .sweep import SweepResult, run_sweep @@ -7,10 +8,12 @@ "ComparisonResult", "ExperimentConfig", "ExperimentResult", + "PairedResult", "PathSpec", "SweepResult", "compare_experiments", "run_experiment", + "run_paired", "run_sweep", "write_results", ] diff --git a/anontestlab/experiment/config.py b/anontestlab/experiment/config.py index bc588ae..3abdf64 100644 --- a/anontestlab/experiment/config.py +++ b/anontestlab/experiment/config.py @@ -8,6 +8,8 @@ from ..crypto import ALGORITHMS as CRYPTO_ALGORITHMS from ..emulator.crypto_layer import KEYEXCHANGES +from ..emulator.mixing import STRATEGIES as MIX_STRATEGIES +from ..emulator.splitting import POLICIES as SPLIT_POLICIES from ..routing import STRATEGIES @@ -25,6 +27,7 @@ class ExperimentConfig: grace_period_s: float = 2.0 # time to wait for in-flight packets after the last emission num_nodes: int = 10 num_sessions: int = 5 + max_concurrent_sessions: int | None = None # run sessions in waves of this size; timestamps become per-session mode: str = "custom" # "tor_like" | "custom": informational preset marker baseline: str | None = None # path to a baseline experiment YAML, diffed into report.md @@ -34,13 +37,25 @@ class ExperimentConfig: path_length: int = 3 # Paths 1..N-1, for traffic-splitting across independent paths. extra_paths: list[PathSpec] = field(default_factory=list) - split_strategy: str = "round_robin" # "round_robin" | "random" + split_strategy: str = "round_robin" # see emulator/splitting.py: round_robin | random | iid | batch | latency + split_weights: list[float] | None = None # per-leg fractions (iid, batch, weighted round_robin); None = even + split_batch_mean_s: float = 2.0 # batch policy: mean batch duration + split_leg_rtt_ms: list[float] | None = None # latency policy: nominal RTT per leg; None = 50, 100, 150, ... + split_leg_window: int = 8 # latency policy: cells a leg may have in flight within one RTT + merge: str = "disjoint" # "disjoint" | "common_exit" (all legs end at one exit relay, Conflux-style) real_traffic_distribution: str = "poisson" real_rate: float = 5.0 + burst_mean_cells: float = 12.0 # "burst" distribution: mean cells per burst + burst_gap_ms: float = 2.0 # "burst" distribution: gap between cells of one burst cover_traffic_distribution: str = "poisson" cover_rate: float = 0.0 cover_drop_probability: float = 0.0 + # "per_hop": each intermediate hop drops each cover cell with + # cover_drop_probability. "random_hop": the client picks one hop per cover + # cell uniformly along the path and only that hop absorbs it (decoy + # termination); cover_drop_probability is ignored. + cover_drop_mode: str = "per_hop" crypto_algorithm: str = "none" # see anontestlab.crypto.ALGORITHMS for the full set crypto_keyexchange: str = "x25519" # "x25519" | "x448" | "p256", the handshake curve @@ -51,6 +66,8 @@ class ExperimentConfig: fixed_rate: float = 20.0 # packets/sec on the wire when traffic_mode == "fixed_rate" observed_path_count: int | None = None # None = adversary observes every path + observed_legs: list[int] | None = None # explicit entry-side legs the adversary sees; overrides observed_path_count + egress_observation: str = "per_path" # "per_path" | "merged" (exit side sees every leg's cells) # AS-level partial observer: when num_as_groups > 1, relays are split into # mock AS groups and the observer sees a path's entry/exit independently, @@ -76,7 +93,7 @@ class ExperimentConfig: # node's values by its own factor, or link_per_edge additionally # scales a relay's forward-direction send to a specific peer by a # factor for that (relay, peer) pair (see relay_process.py::edge_factor - # for why this is directional-only, not a fully symmetric edge model). + # for why this is directional only). link_latency_ms: float = 0.0 link_jitter_ms: float = 0.0 link_loss_probability: float = 0.0 @@ -85,14 +102,49 @@ class ExperimentConfig: link_heterogeneity_spread: float = 0.5 # each node's (or edge's) factor ~ Uniform(1-spread, 1+spread) link_per_edge: bool = False + # Relay-side mixing, applied at every hop to data cells (see + # emulator/mixing.py): "none" | "constant" | "exponential" | "pool". + mix_strategy: str = "none" + mix_delay_ms: float = 0.0 # constant delay, or the exponential mean, per hop + pool_interval_ms: float = 100.0 # pool flush period + pool_release_probability: float = 1.0 # 1.0 = empty the pool each flush; < 1 = retained/binomial pool + pool_interval_jitter: float = 0.0 # randomized flush: interval * (1 +/- U(jitter)), in [0, 1) + observer_bin_width_ms: float = 50.0 observer_classifier: str = "pearson" observer_threshold: float = 0.7 + # 0 = in-sample TPR@FPR (cut chosen on the same impostor pairs it's reported + # against). > 0 = hold out: this fraction of sessions calibrates the cut and + # the rest are scored, so the threshold never sees the pairs it's judged on. + observer_calibration_fraction: float = 0.0 + + # correlation_suite adversary (adversary/correlation_suite.py) + suite_attackers: list[str] = field(default_factory=lambda: ["a0", "a1", "a2", "a3"]) + suite_bin_widths_s: list[float] = field(default_factory=lambda: [0.1, 0.25, 0.5, 1.0]) + suite_lag_max_s: float | None = None # None = max(2 s, P99 of added delay) + 0.5 s + suite_psi: list[float] = field(default_factory=lambda: [0.9, 0.99]) + suite_base_rates: list[float] = field(default_factory=lambda: [1e-4, 1e-3, 1e-5]) + suite_fpr_targets: list[float] = field(default_factory=lambda: [1e-3, 1e-4]) + suite_window_quantiles: list[float] = field(default_factory=lambda: [0.5, 0.95]) + suite_latency_floor_ms: float = 20.0 # added to A3 windows for processing and link time outside the delay model + suite_lr_step_ms: float = 10.0 # time grid of the likelihood-ratio attacker @property def paths(self) -> list[PathSpec]: return [PathSpec(self.routing_strategy, self.path_length), *self.extra_paths] + @property + def expected_mix_delay_per_hop_s(self) -> float: + """Mean added delay per hop from mixing alone (0 for 'none').""" + if self.mix_strategy in ("constant", "exponential"): + return self.mix_delay_ms / 1000.0 + if self.mix_strategy == "pool" and self.pool_release_probability > 0: + # wait for the next flush (~half an interval) plus a geometric + # number of further flushes before release + interval = self.pool_interval_ms / 1000.0 + return interval * (0.5 + (1 - self.pool_release_probability) / self.pool_release_probability) + return 0.0 + @property def num_paths(self) -> int: return 1 + len(self.extra_paths) @@ -112,7 +164,14 @@ def check(condition: bool, message: str) -> None: check(self.num_nodes > 0, f"num_nodes must be positive, got {self.num_nodes}") check(self.num_nodes <= 254, f"num_nodes={self.num_nodes} exceeds the 254-relay loopback-subnet limit") check(self.num_sessions > 0, f"num_sessions must be positive, got {self.num_sessions}") + if self.max_concurrent_sessions is not None: + check( + self.max_concurrent_sessions > 0, + f"max_concurrent_sessions must be positive if set, got {self.max_concurrent_sessions}", + ) check(self.real_rate >= 0, f"real_rate must be non-negative, got {self.real_rate}") + check(self.burst_mean_cells >= 1, f"burst_mean_cells must be at least 1, got {self.burst_mean_cells}") + check(self.burst_gap_ms >= 0, f"burst_gap_ms must be non-negative, got {self.burst_gap_ms}") check(self.cover_rate >= 0, f"cover_rate must be non-negative, got {self.cover_rate}") check( 0 <= self.cover_drop_probability <= 1, @@ -144,8 +203,36 @@ def check(condition: bool, message: str) -> None: f"crypto_keyexchange {self.crypto_keyexchange!r} unknown, available: {sorted(KEYEXCHANGES)}", ) check( - self.split_strategy in ("round_robin", "random"), - f"split_strategy must be 'round_robin' or 'random', got {self.split_strategy!r}", + self.split_strategy in SPLIT_POLICIES, + f"split_strategy {self.split_strategy!r} unknown, available: {list(SPLIT_POLICIES)}", + ) + if self.split_weights is not None: + check( + len(self.split_weights) == self.num_paths and all(w > 0 for w in self.split_weights), + f"split_weights needs {self.num_paths} positive values, got {self.split_weights}", + ) + check(self.split_batch_mean_s > 0, f"split_batch_mean_s must be positive, got {self.split_batch_mean_s}") + if self.split_leg_rtt_ms is not None: + check( + len(self.split_leg_rtt_ms) == self.num_paths and all(r > 0 for r in self.split_leg_rtt_ms), + f"split_leg_rtt_ms needs {self.num_paths} positive values, got {self.split_leg_rtt_ms}", + ) + check(self.split_leg_window > 0, f"split_leg_window must be positive, got {self.split_leg_window}") + check( + self.merge in ("disjoint", "common_exit"), + f"merge must be 'disjoint' or 'common_exit', got {self.merge!r}", + ) + if self.merge == "common_exit": + needed = sum(spec.path_length - 1 for spec in self.paths) + 1 + check(needed <= self.num_nodes, f"common_exit needs {needed} distinct relays, num_nodes={self.num_nodes}") + if self.observed_legs is not None: + check( + len(self.observed_legs) > 0 and all(0 <= i < self.num_paths for i in self.observed_legs), + f"observed_legs must list leg indices in 0..{self.num_paths - 1}, got {self.observed_legs}", + ) + check( + self.egress_observation in ("per_path", "merged"), + f"egress_observation must be 'per_path' or 'merged', got {self.egress_observation!r}", ) for spec in self.paths: check( @@ -192,6 +279,55 @@ def check(condition: bool, message: str) -> None: f"link_heterogeneity_spread must be in [0, 1), got {self.link_heterogeneity_spread}", ) + check( + self.cover_drop_mode in ("per_hop", "random_hop"), + f"cover_drop_mode must be 'per_hop' or 'random_hop', got {self.cover_drop_mode!r}", + ) + check( + self.mix_strategy in MIX_STRATEGIES, + f"mix_strategy {self.mix_strategy!r} unknown, available: {list(MIX_STRATEGIES)}", + ) + if self.mix_strategy in ("constant", "exponential"): + check( + self.mix_delay_ms > 0, + f"mix_delay_ms must be positive for {self.mix_strategy}, got {self.mix_delay_ms}", + ) + if self.mix_strategy == "pool": + check( + self.pool_interval_ms > 0, f"pool_interval_ms must be positive, got {self.pool_interval_ms}" + ) + check( + 0 < self.pool_release_probability <= 1, + f"pool_release_probability must be in (0, 1], got {self.pool_release_probability}", + ) + check( + 0 <= self.pool_interval_jitter < 1, + f"pool_interval_jitter must be in [0, 1), got {self.pool_interval_jitter}", + ) + per_hop_s = self.expected_mix_delay_per_hop_s + max_hops = max(spec.path_length for spec in self.paths) + if per_hop_s * max_hops * 3 > self.grace_period_s: + warnings.warn( + f"mixing adds ~{per_hop_s * max_hops:.2f}s mean delay over {max_hops} hops, but " + f"grace_period_s={self.grace_period_s}: cells still in a mix when the session " + "closes are lost and never reach the egress observer. Raise grace_period_s to " + "a few times the mean added delay.", + stacklevel=2, + ) + check( + 0 <= self.observer_calibration_fraction < 1, + f"observer_calibration_fraction must be in [0, 1), got {self.observer_calibration_fraction}", + ) + + check( + set(self.suite_attackers) <= {"a0", "a1", "a2", "a3"}, + f"suite_attackers must be drawn from a0, a1, a2, a3, got {self.suite_attackers}", + ) + check(all(w > 0 for w in self.suite_bin_widths_s), "suite_bin_widths_s must be positive") + check(all(0 < p < 1 for p in self.suite_psi), "suite_psi values must be in (0, 1)") + check(all(0 < b < 1 for b in self.suite_base_rates), "suite_base_rates values must be in (0, 1)") + check(all(0 < q < 1 for q in self.suite_window_quantiles), "suite_window_quantiles must be in (0, 1)") + if errors: raise ValueError("invalid experiment config:\n - " + "\n - ".join(errors)) @@ -241,6 +377,7 @@ def from_yaml(cls, path: str | Path) -> ExperimentConfig: observed_paths = adversary.get("observed_paths", "all") observed_path_count = None if observed_paths == "all" else int(observed_paths) + observed_legs = adversary.get("observed_legs") observed_as = adversary.get("observed_as", "all") observed_as_count = None if observed_as == "all" else int(observed_as) @@ -248,6 +385,9 @@ def from_yaml(cls, path: str | Path) -> ExperimentConfig: observation_cfg = adversary.get("observation", {}) classifier_cfg = adversary.get("classifier", {}) watermark_cfg = adversary.get("watermark", {}) + evaluation_cfg = adversary.get("evaluation", {}) + mixing = raw.get("mixing", {}) + suite_cfg = adversary.get("correlation_suite", {}) adversary_types = adversary.get("types") if adversary_types is None: @@ -266,13 +406,21 @@ def from_yaml(cls, path: str | Path) -> ExperimentConfig: num_nodes=network.get("nodes", cls.num_nodes), num_as_groups=network.get("as_groups", cls.num_as_groups), num_sessions=sessions.get("count", cls.num_sessions), + max_concurrent_sessions=sessions.get("max_concurrent"), mode=exp.get("mode", cls.mode), routing_strategy=routing_strategy, path_length=path_length, extra_paths=extra_paths, split_strategy=routing.get("split_strategy", cls.split_strategy), + split_weights=routing.get("split_weights"), + split_batch_mean_s=routing.get("split_batch_mean_s", cls.split_batch_mean_s), + split_leg_rtt_ms=routing.get("split_leg_rtt_ms"), + split_leg_window=routing.get("split_leg_window", cls.split_leg_window), + merge=routing.get("merge", cls.merge), real_traffic_distribution=traffic.get("distribution", cls.real_traffic_distribution), real_rate=traffic.get("real_rate", cls.real_rate), + burst_mean_cells=traffic.get("burst_mean_cells", cls.burst_mean_cells), + burst_gap_ms=traffic.get("burst_gap_ms", cls.burst_gap_ms), cover_traffic_distribution=traffic.get( "cover_distribution", cls.cover_traffic_distribution ), @@ -280,12 +428,20 @@ def from_yaml(cls, path: str | Path) -> ExperimentConfig: cover_drop_probability=cover_behaviour.get( "drop_probability", cls.cover_drop_probability ), + cover_drop_mode=cover_behaviour.get("drop_mode", cls.cover_drop_mode), + mix_strategy=mixing.get("strategy", cls.mix_strategy), + mix_delay_ms=mixing.get("delay_ms", cls.mix_delay_ms), + pool_interval_ms=mixing.get("pool_interval_ms", cls.pool_interval_ms), + pool_release_probability=mixing.get("release_probability", cls.pool_release_probability), + pool_interval_jitter=mixing.get("interval_jitter", cls.pool_interval_jitter), crypto_algorithm=crypto.get("algorithm", cls.crypto_algorithm), crypto_keyexchange=crypto.get("keyexchange", cls.crypto_keyexchange), cell_size=traffic_shaping.get("cell_size") if traffic_shaping.get("enabled") else None, traffic_mode=traffic_shaping.get("mode", cls.traffic_mode), fixed_rate=traffic_shaping.get("rate", cls.fixed_rate), observed_path_count=observed_path_count, + observed_legs=observed_legs, + egress_observation=adversary.get("egress_observation", cls.egress_observation), observed_as_count=observed_as_count, adversaries=adversary_types, compromised_fraction=adversary.get("compromised_fraction", cls.compromised_fraction), @@ -293,6 +449,18 @@ def from_yaml(cls, path: str | Path) -> ExperimentConfig: observer_bin_width_ms=observation_cfg.get("bin_width_ms", cls.observer_bin_width_ms), observer_classifier=classifier_cfg.get("type", cls.observer_classifier), observer_threshold=classifier_cfg.get("threshold", cls.observer_threshold), + suite_attackers=suite_cfg.get("attackers", ["a0", "a1", "a2", "a3"]), + suite_bin_widths_s=suite_cfg.get("bin_widths_s", [0.1, 0.25, 0.5, 1.0]), + suite_lag_max_s=suite_cfg.get("lag_max_s"), + suite_psi=suite_cfg.get("psi", [0.9, 0.99]), + suite_base_rates=suite_cfg.get("base_rates", [1e-4, 1e-3, 1e-5]), + suite_fpr_targets=suite_cfg.get("fpr_targets", [1e-3, 1e-4]), + suite_window_quantiles=suite_cfg.get("window_quantiles", [0.5, 0.95]), + suite_latency_floor_ms=suite_cfg.get("latency_floor_ms", 20.0), + suite_lr_step_ms=suite_cfg.get("lr_step_ms", 10.0), + observer_calibration_fraction=evaluation_cfg.get( + "calibration_fraction", cls.observer_calibration_fraction + ), watermark_period=watermark_cfg.get("period", cls.watermark_period), watermark_delay_ms=watermark_cfg.get("delay_ms", cls.watermark_delay_ms), link_latency_ms=link_conditions.get("latency_ms", cls.link_latency_ms), diff --git a/anontestlab/experiment/fidelity.py b/anontestlab/experiment/fidelity.py new file mode 100644 index 0000000..54c81d6 --- /dev/null +++ b/anontestlab/experiment/fidelity.py @@ -0,0 +1,54 @@ +"""Host-noise check for the emulator. Relays share one machine, so under +load the operating system's scheduling adds delay that no configured +defense produced, and that delay can look like a timing defense to an +attacker. This check reruns a configuration with relay mixing switched off +and everything else (topology, load, link settings) unchanged. The one-way +entry-to-exit delay that remains is host and link time. A cell passes when +the 95th percentile of that delay, minus the configured link latency, is at +most `limit_fraction` of a reference scale: the smallest nonzero per-hop +mixing delay of the original configuration, or, if it has none, the +smallest attacker bin width. +""" +from __future__ import annotations + +import dataclasses + +from .config import ExperimentConfig +from .runner import run_experiment + + +def reference_scale_s(config: ExperimentConfig) -> tuple[float, str]: + if config.mix_strategy in ("constant", "exponential"): + return config.mix_delay_ms / 1000.0, "per-hop mixing delay" + if config.mix_strategy == "pool": + return config.pool_interval_ms / 2000.0, "mean wait for a pool flush" + return min(config.suite_bin_widths_s), "smallest attacker bin width" + + +def run_fidelity(config: ExperimentConfig, limit_fraction: float = 0.1, waves: int = 2) -> dict[str, object]: + """Reruns the cell with mixing switched off and reports the host-induced + timing noise. Host load depends on how many sessions run at once, not on + how many waves follow each other, so in wave mode the probe runs only + `waves` full waves at the cell's own concurrency instead of every session.""" + sessions = config.num_sessions + if config.max_concurrent_sessions: + sessions = min(sessions, waves * config.max_concurrent_sessions) + probe = dataclasses.replace(config, name=f"{config.name}-fidelity", mix_strategy="none", adversaries=[], + num_sessions=sessions) + metrics = run_experiment(probe).metrics + link_s = config.path_length * config.link_latency_ms / 1000.0 + noise = metrics["oneway_delay_p95_s"] - link_s + scale, scale_name = reference_scale_s(config) + return { + "oneway_delay_p50_s": metrics["oneway_delay_p50_s"], + "oneway_delay_p95_s": metrics["oneway_delay_p95_s"], + "oneway_delay_p99_s": metrics["oneway_delay_p99_s"], + "configured_link_latency_s": link_s, + "host_noise_p95_s": noise, + "reference_scale_s": scale, + "reference": scale_name, + "limit_s": limit_fraction * scale, + "passes": noise <= limit_fraction * scale, + "delivery_rate": metrics["delivery_rate"], + "probe_sessions": sessions, + } diff --git a/anontestlab/experiment/paired.py b/anontestlab/experiment/paired.py new file mode 100644 index 0000000..e9b9ec3 --- /dev/null +++ b/anontestlab/experiment/paired.py @@ -0,0 +1,71 @@ +"""Paired multi-seed comparison: run a reference and a treatment config +under the same list of seeds, pair the runs by seed, and classify the +difference in one metric with a paired bootstrap plus an explicit +equivalence margin (see metrics.stats.paired_bootstrap_delta). One run +per config is a single draw; this is the check that a difference is +bigger than seed-to-seed variation before anyone reads it as a result.""" +from __future__ import annotations + +import dataclasses +import json +from dataclasses import dataclass +from pathlib import Path + +from ..metrics.stats import PairedEffect, paired_bootstrap_delta +from .config import ExperimentConfig +from .runner import run_experiment + + +@dataclass +class PairedResult: + metric: str + seeds: list[int] + reference_name: str + treatment_name: str + reference_values: list[float] + treatment_values: list[float] + effect: PairedEffect + margin: float + + +def run_paired( + reference: ExperimentConfig, + treatment: ExperimentConfig, + seeds: list[int], + metric: str = "tpr_at_fpr_0.001", + margin: float = 0.05, + resamples: int = 10000, + bootstrap_seed: int = 0, + out_dir: Path | None = None, +) -> PairedResult: + ref_values, treat_values = [], [] + for seed in seeds: + for base, sink in ((reference, ref_values), (treatment, treat_values)): + metrics = run_experiment(dataclasses.replace(base, seed=seed)).metrics + if metric not in metrics: + raise ValueError(f"metric {metric!r} not produced by {base.name!r}; available: {sorted(metrics)}") + sink.append(float(metrics[metric])) + effect = paired_bootstrap_delta( + ref_values, treat_values, margin=margin, resamples=resamples, seed=bootstrap_seed + ) + result = PairedResult(metric, list(seeds), reference.name, treatment.name, ref_values, treat_values, effect, margin) + if out_dir is not None: + write_paired(result, out_dir) + return result + + +def write_paired(result: PairedResult, out_dir: Path) -> None: + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + lines = [f"seed,{result.reference_name},{result.treatment_name},delta"] + for s, r, t in zip(result.seeds, result.reference_values, result.treatment_values): + lines.append(f"{s},{r},{t},{t - r}") + (out_dir / "paired_runs.csv").write_text("\n".join(lines) + "\n") + summary = { + "metric": result.metric, + "reference": result.reference_name, + "treatment": result.treatment_name, + "margin": result.margin, + **dataclasses.asdict(result.effect), + } + (out_dir / "paired_summary.json").write_text(json.dumps(summary, indent=2)) diff --git a/anontestlab/experiment/runner.py b/anontestlab/experiment/runner.py index c618e87..7d1ea51 100644 --- a/anontestlab/experiment/runner.py +++ b/anontestlab/experiment/runner.py @@ -3,9 +3,11 @@ import json import random from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path +import numpy as np + from ..adversary import get_adversary from ..emulator.orchestrator import run_experiment as run_emulated_experiment from .config import ExperimentConfig @@ -16,6 +18,7 @@ class ExperimentResult: config: ExperimentConfig metrics: dict[str, float] baseline_result: ExperimentResult | None = None + artifacts: dict[str, dict] = field(default_factory=dict) # adversary name -> arrays written as .npz def run_experiment( @@ -28,14 +31,22 @@ def run_experiment( collector, ctx, avg_build_delay, sessions_failed = run_emulated_experiment(config, on_progress) metrics = collector.summary() + if config.num_paths > 1: + per_leg = [obs.leg_real_counts for obs in ctx.sessions.values() if sum(obs.leg_real_counts) > 0] + for leg in range(config.num_paths): + shares = [counts[leg] / sum(counts) for counts in per_leg] + metrics[f"leg_{leg}_real_share"] = sum(shares) / len(shares) if shares else float("nan") metrics["circuit_build_delay_s"] = avg_build_delay metrics["sessions_failed"] = sessions_failed rng = random.Random(config.seed) + artifacts: dict[str, dict] = {} for adversary_name in config.adversaries: adversary = get_adversary(adversary_name, config) result = adversary.attack(ctx, rng) metrics.update(result.metrics) + if result.artifacts: + artifacts[adversary_name] = result.artifacts baseline_result = None if config.baseline: @@ -48,7 +59,7 @@ def run_experiment( baseline_config = ExperimentConfig.from_yaml(config.baseline) baseline_result = run_experiment(baseline_config, _baseline_depth=_baseline_depth + 1) - result = ExperimentResult(config=config, metrics=metrics, baseline_result=baseline_result) + result = ExperimentResult(config=config, metrics=metrics, baseline_result=baseline_result, artifacts=artifacts) if out_dir is not None: write_results(result, out_dir) return result @@ -69,6 +80,9 @@ def write_results(result: ExperimentResult, out_dir: Path) -> None: (out_dir / "report.md").write_text(_report_md(result)) + for name, arrays in result.artifacts.items(): + np.savez_compressed(out_dir / f"{name}.npz", **arrays) + def _to_yaml(d: dict) -> str: import yaml diff --git a/anontestlab/metrics/__init__.py b/anontestlab/metrics/__init__.py index 4524079..e00a290 100644 --- a/anontestlab/metrics/__init__.py +++ b/anontestlab/metrics/__init__.py @@ -1,10 +1,29 @@ from .collector import MetricsCollector -from .stats import precision_recall_at_threshold, roc_auc, tpr_at_fpr, wilson_ci +from .stats import ( + FprPoint, + PairedEffect, + confident_linkage, + cut_at_fpr, + paired_bootstrap_delta, + precision_recall_at_threshold, + roc_auc, + signal_estimate, + tpr_at_fpr, + tpr_at_fpr_detail, + wilson_ci, +) __all__ = [ + "FprPoint", "MetricsCollector", + "PairedEffect", + "confident_linkage", + "cut_at_fpr", + "paired_bootstrap_delta", "precision_recall_at_threshold", "roc_auc", + "signal_estimate", "tpr_at_fpr", + "tpr_at_fpr_detail", "wilson_ci", ] diff --git a/anontestlab/metrics/collector.py b/anontestlab/metrics/collector.py index 1ad53e5..08eb0e7 100644 --- a/anontestlab/metrics/collector.py +++ b/anontestlab/metrics/collector.py @@ -25,6 +25,7 @@ def summary(self) -> dict[str, float]: statistics.quantiles(latencies, n=20)[18] if len(latencies) >= 20 else avg_latency ) bandwidth_overhead = (len(real) + len(cover)) / len(real) if real else float("nan") + oneway = sorted(p.exit_at - p.created_at for p in delivered_real if p.exit_at is not None) return { "real_packets_sent": len(real), @@ -34,4 +35,13 @@ def summary(self) -> dict[str, float]: "avg_latency_s": avg_latency, "p95_latency_s": p95_latency, "bandwidth_overhead_x": bandwidth_overhead, + "oneway_delay_p50_s": _quantile(oneway, 0.50), + "oneway_delay_p95_s": _quantile(oneway, 0.95), + "oneway_delay_p99_s": _quantile(oneway, 0.99), } + + +def _quantile(sorted_values: list[float], q: float) -> float: + if not sorted_values: + return float("nan") + return sorted_values[min(len(sorted_values) - 1, int(q * len(sorted_values)))] diff --git a/anontestlab/metrics/stats.py b/anontestlab/metrics/stats.py index e1ae6a7..c05a795 100644 --- a/anontestlab/metrics/stats.py +++ b/anontestlab/metrics/stats.py @@ -5,6 +5,7 @@ from __future__ import annotations import math +from dataclasses import dataclass import numpy as np @@ -23,27 +24,113 @@ def wilson_ci(successes: int, n: int, z: float = 1.96) -> tuple[float, float]: return center, half_width +@dataclass(frozen=True) +class FprPoint: + """One operating point. A pair is flagged as a match iff its score is + strictly greater than `cut`.""" + + tpr: float + cut: float + realized_fpr: float # measured on the test impostors if given, else on the calibration ones + expected_exceedances: float # fpr * calibration impostor count: how much tail supports the cut + + +def cut_at_fpr(impostor_scores: np.ndarray, fpr: float) -> float: + """Conservative threshold: the smallest cut such that the fraction of + impostor scores strictly above it is <= fpr, even when scores tie. + Ties at the boundary count as non-matches, so the realized FPR stays + at or below the target.""" + impostor = np.sort(np.asarray(impostor_scores, dtype=float))[::-1] + n = len(impostor) + k = math.floor(fpr * n + 1e-9) # impostors allowed above the cut + if k >= n: + return -math.inf + return float(impostor[k]) + + +def tpr_at_fpr_detail( + true_scores: list[float], + calibration_impostor_scores: list[float], + fpr_targets: list[float], + test_impostor_scores: list[float] | None = None, +) -> dict[float, FprPoint]: + """TPR at each target FPR, with the cut chosen on the calibration + impostors. Passing separate test impostors (and test true scores) + gives a held-out estimate instead of an in-sample one.""" + cal = np.asarray(calibration_impostor_scores, dtype=float) + true = np.asarray(true_scores, dtype=float) + test_imp = cal if test_impostor_scores is None else np.asarray(test_impostor_scores, dtype=float) + result = {} + for fpr in fpr_targets: + if len(cal) == 0 or len(true) == 0: + result[fpr] = FprPoint(float("nan"), float("nan"), float("nan"), 0.0) + continue + cut = cut_at_fpr(cal, fpr) + realized = float(np.mean(test_imp > cut)) if len(test_imp) else float("nan") + result[fpr] = FprPoint(float(np.mean(true > cut)), cut, realized, fpr * len(cal)) + return result + + def tpr_at_fpr( true_scores: list[float], impostor_scores: list[float], fpr_targets: list[float] ) -> dict[float, float]: """Standard matching-problem evaluation: for each target false-positive - rate, find the score threshold achieving it on the impostor distribution, - then report the true-positive rate at that threshold. + rate, find the conservative cut on the impostor distribution (see + `cut_at_fpr`), then report the true-positive rate above it. """ - impostor = np.sort(np.asarray(impostor_scores))[::-1] - true = np.asarray(true_scores) - n_impostor = len(impostor) - result = {} - for fpr in fpr_targets: - if n_impostor == 0: - result[fpr] = float("nan") - continue - k = max(1, round(fpr * n_impostor)) - k = min(k, n_impostor) - threshold = impostor[k - 1] - tpr = float(np.mean(true >= threshold)) if len(true) else float("nan") - result[fpr] = tpr - return result + detail = tpr_at_fpr_detail(true_scores, impostor_scores, fpr_targets) + return {fpr: point.tpr for fpr, point in detail.items()} + + +@dataclass(frozen=True) +class PairedEffect: + """Paired seed-level comparison of one metric between a reference and + a treatment condition.""" + + mean_delta: float # treatment - reference + ci_low: float + ci_high: float + n_pairs: int + classification: str # "no_meaningful_effect" | "meaningful_effect" | "inconclusive" + + +def paired_bootstrap_delta( + reference: list[float], + treatment: list[float], + *, + margin: float = 0.05, + resamples: int = 10000, + confidence: float = 0.95, + seed: int = 0, +) -> PairedEffect: + """Bootstrap the mean of the per-seed differences (seeds paired by + position) and classify it with an explicit equivalence rule: + + - no_meaningful_effect: the whole CI lies inside (-margin, +margin) + - meaningful_effect: the CI excludes zero and |mean| >= margin + - inconclusive: anything else. A CI that merely includes zero is not + evidence of no effect. + """ + ref = np.asarray(reference, dtype=float) + treat = np.asarray(treatment, dtype=float) + if ref.shape != treat.shape: + raise ValueError(f"paired inputs differ in length: {ref.shape} vs {treat.shape}") + keep = ~(np.isnan(ref) | np.isnan(treat)) + diffs = treat[keep] - ref[keep] + if len(diffs) < 2: + raise ValueError("paired bootstrap needs at least two complete pairs") + rng = np.random.default_rng(seed) + boots = diffs[rng.integers(0, len(diffs), size=(resamples, len(diffs)))].mean(axis=1) + alpha = (1 - confidence) / 2 + lo, hi = (float(q) for q in np.quantile(boots, [alpha, 1 - alpha])) + mean = float(diffs.mean()) + if lo > -margin and hi < margin: + label = "no_meaningful_effect" + elif (lo > 0 or hi < 0) and abs(mean) >= margin: + label = "meaningful_effect" + else: + label = "inconclusive" + return PairedEffect(mean, lo, hi, len(diffs), label) def roc_auc(true_scores: list[float], impostor_scores: list[float]) -> float: @@ -73,3 +160,50 @@ def precision_recall_at_threshold( precision = tp / (tp + fp) if (tp + fp) > 0 else float("nan") recall = tp / (tp + fn) if (tp + fn) > 0 else float("nan") return precision, recall + + +def confident_linkage( + true_calibration: list[float], + impostor_calibration: list[float], + true_test: list[float], + psi: float, + base_rate: float, +) -> float: + """Share of held-out true pairs that an attacker would accept with + posterior probability at least psi, when a fraction base_rate of all + candidate pairs are true pairs. + + True and impostor scores are each fitted with a normal distribution on + the calibration sessions. The cut is the smallest score above the + impostor mean at which the fitted likelihood ratio reaches + K = psi (1 - base_rate) / ((1 - psi) base_rate). Unlike TPR at a fixed + FPR, this gives no credit for chance-level matches when evidence is + weak, which matters when results are averaged over many short flows.""" + t = np.asarray(true_calibration, dtype=float) + imp = np.asarray(impostor_calibration, dtype=float) + test = np.asarray(true_test, dtype=float) + if len(t) < 2 or len(imp) < 2 or len(test) == 0: + return float("nan") + m1, s1 = float(t.mean()), max(float(t.std(ddof=1)), 1e-12) + m0, s0 = float(imp.mean()), max(float(imp.std(ddof=1)), 1e-12) + if m1 <= m0: + return 0.0 + log_k = math.log(psi * (1 - base_rate) / ((1 - psi) * base_rate)) + grid = np.linspace(m0, max(m1 + 8 * s1, m0 + 12 * s0), 20001) + log_lr = ( + -0.5 * ((grid - m1) / s1) ** 2 - math.log(s1) + + 0.5 * ((grid - m0) / s0) ** 2 + math.log(s0) + ) + above = np.nonzero(log_lr >= log_k)[0] + if len(above) == 0: + return 0.0 + return float(np.mean(test >= grid[above[0]])) + + +def signal_estimate(true_scores: list[float], impostor_scores: list[float]) -> float: + """Separation of true and impostor scores in impostor standard deviations.""" + t = np.asarray(true_scores, dtype=float) + imp = np.asarray(impostor_scores, dtype=float) + if len(t) == 0 or len(imp) < 2: + return float("nan") + return float((t.mean() - imp.mean()) / max(imp.std(ddof=1), 1e-12)) diff --git a/anontestlab/model.py b/anontestlab/model.py new file mode 100644 index 0000000..6c2d0b6 --- /dev/null +++ b/anontestlab/model.py @@ -0,0 +1,258 @@ +"""Closed-form predictions for the binned-count correlation attacker, so a +run can report predicted values next to measured ones. + +For a true pair the expected per-bin correlation between the observed +entry-side counts and the exit-side counts factorizes as + + r = policy_factor * delay_factor * cover_factor + +(delay factor after Danezis, PET 2004, in binned form). Over B bins the +attacker's signal is about sqrt(B) * r, giving fixed-FPR linkability +Phi(signal - z_{alpha/m}) with a Bonferroni allowance for m lags, and +confident linkage Phi(signal / 2 - ln K / signal) with +K = psi (1 - base_rate) / ((1 - psi) base_rate). + +These are large-sample Gaussian approximations. Traffic enters through the +mean m and the index of dispersion D = Var / m of the real cells per bin +(D = 1 for Poisson, larger for bursty traffic), measured from the +configured generator. With bursts that are short against a bin, the split +factors become + i.i.d. 1 / sqrt(1 + (1 - p) / (p D)) + round-robin 1 / sqrt(1 + k^2 / (12 m D)) + batch sqrt(p / (1 + (1 - p) m / D)) +and the delay factor is divided by sqrt((1 - 1/D) Q2 + 1/D), where Q2 is +the summed square of the per-bin landing probabilities of one cell (the +cells of a burst leave together and are delayed independently). All reduce +to the Poisson forms at D = 1. The latency split policy has no closed form +here and is reported as NaN. +""" +from __future__ import annotations + +import math +import random +from itertools import combinations +from statistics import NormalDist + +import numpy as np + +from .adversary import attacks +from .adversary.delay_model import sample_added_delay +from .emulator.splitting import LegScheduler +from .traffic import get_generator + +_N = NormalDist() + + +def delay_factors(config, bin_s: float, max_lag_bins: int, n: int = 400_000, seed: int = 0) -> np.ndarray: + """kappa(lag) for lag = 0..max_lag_bins: the chance that a packet sent at a + uniform point of an entry bin leaves in the exit bin `lag` bins later.""" + rng = np.random.default_rng(seed) + offset = rng.uniform(0.0, bin_s, n) + sample_added_delay(config, n, rng) + lags = np.floor(offset / bin_s).astype(int) + counts = np.bincount(lags[(lags >= 0) & (lags <= max_lag_bins)], minlength=max_lag_bins + 1) + return counts[: max_lag_bins + 1] / n + + +def delay_spread(config, bin_s: float, max_lag_bins: int, n_offsets: int = 200, n: int = 4000, + seed: int = 0) -> float: + """Q2: the expected sum over exit bins of the squared chance that one cell, + sent at a uniform point of an entry bin, leaves in that bin. Cells of one + burst share the send point and are delayed independently.""" + rng = np.random.default_rng(seed) + total = 0.0 + for u in rng.uniform(0.0, bin_s, n_offsets): + lags = np.floor((u + sample_added_delay(config, n, rng)) / bin_s).astype(int) + q = np.bincount(lags - lags.min()) / n + total += float(np.sum(q**2)) + return total / n_offsets + + +def traffic_moments(config, bin_s: float, seconds: float = 4000.0, seed: int = 0) -> tuple[float, float]: + """Mean real cells per bin and their index of dispersion for the configured + traffic generator (exactly lambda w and 1 for Poisson).""" + if config.real_traffic_distribution == "poisson" or config.real_rate <= 0: + return config.real_rate * bin_s, 1.0 + gen = get_generator(config.real_traffic_distribution, config.real_rate, + burst_mean_cells=config.burst_mean_cells, burst_gap_ms=config.burst_gap_ms) + times = np.asarray(gen.emission_times(random.Random(seed), seconds)) + n_bins = int(seconds / bin_s) + counts = np.bincount((times / bin_s).astype(int), minlength=n_bins)[:n_bins] + m = float(counts.mean()) + return m, (float(counts.var()) / m if m > 0 else 1.0) + + +def policy_factor( + policy: str, p: float, num_legs: int, cells_per_bin: float, batch_mean_s: float, bin_s: float, + dispersion: float = 1.0, +) -> float: + """Correlation between the counts of an observed share p of the flow and + the whole flow, in the same bin, for counts with the given index of + dispersion (1 for Poisson).""" + if p >= 1.0: + return 1.0 + if p <= 0.0: + return 0.0 + d = max(dispersion, 1e-12) + if policy in ("iid", "random"): + return 1.0 / math.sqrt(1.0 + (1.0 - p) / (p * d)) + if policy == "round_robin": + # rounding error of a deterministic interleave, variance about 1/12 per bin + return 1.0 / math.sqrt(1.0 + num_legs**2 / (12.0 * max(cells_per_bin, 1e-12) * d)) + if policy == "batch": + if batch_mean_s < 5 * bin_s: + return float("nan") # the closed form assumes batches much longer than a bin + return math.sqrt(p / (1.0 + (1.0 - p) * cells_per_bin / d)) + return float("nan") + + +def observed_share(config) -> float: + weights = config.split_weights or [1.0] * config.num_paths + legs = config.observed_legs if config.observed_legs is not None else range(config.num_paths) + return sum(weights[i] for i in legs) / sum(weights) + + +def _max_of_normals(m: int, n: int = 200_000, seed: int = 0) -> tuple[float, float]: + """Mean and standard deviation of the maximum of m independent standard + normals: the impostor score of a lag search over m lags, in units of the + per-lag null standard deviation.""" + draws = np.random.default_rng(seed).standard_normal((n, m)).max(axis=1) + return float(draws.mean()), float(draws.std()) + + +def _confident_cut(mu_true: float, mu_imp: float, sd_imp: float, log_k: float) -> float: + """The cut confident_linkage would choose from normal fits with these + parameters (true-score standard deviation 1).""" + grid = np.linspace(mu_imp, max(mu_true + 8.0, mu_imp + 12 * sd_imp), 20001) + log_lr = -0.5 * (grid - mu_true) ** 2 + 0.5 * ((grid - mu_imp) / sd_imp) ** 2 + math.log(sd_imp) + above = np.nonzero(log_lr >= log_k)[0] + return float(grid[above[0]]) if len(above) else math.inf + + +def simulated_scores(config, lag_max_s: float, n_flows: int = 100, seed: int = 0) -> dict[float, dict[str, float]]: + """Score moments of the lag-aware correlator on flows drawn from the + configured traffic generator, split with the configured policy and + delayed cell by cell with the configured mixing delay, scored with the + suite's own code (no relays, no training). Per bin width: impostor + mean and standard deviation, true-pair mean and standard deviation. + + The closed-form null (maximum of independent N(0, 1/B) lag scores) + ignores the autocorrelation of the binned counts. Bursts that straddle + bins, and the smearing of each burst by the delay, correlate + neighbouring bins on both sides, which widens the null (Bartlett) and + correlates the lag scores; this calibration captures both. Independent + Poisson cover is added on the entry side only.""" + gen = get_generator(config.real_traffic_distribution, config.real_rate, + burst_mean_cells=config.burst_mean_cells, burst_gap_ms=config.burst_gap_ms) + rng = random.Random(seed) + nrng = np.random.default_rng(seed) + observed = set(config.observed_legs) if config.observed_legs is not None else set(range(config.num_paths)) + ingress, egress = [], [] + for _ in range(n_flows): + t = np.asarray(gen.emission_times(rng, config.duration_s)) + sched = LegScheduler( + config.split_strategy if config.num_paths > 1 else "round_robin", config.num_paths, + weights=config.split_weights, batch_mean_s=config.split_batch_mean_s, + leg_rtt_s=[ms / 1000.0 for ms in config.split_leg_rtt_ms] if config.split_leg_rtt_ms else None, + leg_window=config.split_leg_window, + ) + seen = np.array([sched.assign(float(x), rng) in observed for x in t], dtype=bool) + entry = t[seen] + if config.cover_rate > 0: + cover = nrng.uniform(0.0, config.duration_s, nrng.poisson(config.cover_rate * config.duration_s)) + entry = np.sort(np.concatenate([entry, cover])) + leaving = t if config.egress_observation == "merged" else t[seen] + ingress.append(entry.tolist()) + egress.append(np.sort(leaving + sample_added_delay(config, len(leaving), nrng)).tolist()) + out = {} + off = ~np.eye(n_flows, dtype=bool) + for w in config.suite_bin_widths_s: + scores = attacks.lag_correlation_scores(ingress, egress, w, config.duration_s + lag_max_s, + math.ceil(lag_max_s / w), config.duration_s) + true, imp = np.diag(scores), scores[off] + out[w] = {"imp_mean": float(imp.mean()), "imp_sd": float(imp.std()), + "true_mean": float(true.mean()), "true_sd": float(true.std())} + return out + + +def predict(config, fpr: float = 1e-3, psi: float = 0.9, base_rate: float = 1e-4) -> dict[str, float]: + """Predicted values for the lag-aware correlator (a1) at each bin width of + the correlation suite, for the configured observed legs, computed the way + the suite measures them. The true-pair level is the closed-form r. The + impostor distribution and the spread of true-pair scores come from + simulated_scores(); mu_hat is (r - impostor mean) / impostor sd, and + TPR at FPR and confident linkage use normal fits to both, as the suite's + confident-linkage metric does. `model_*_mu_hat_gaussian` keeps the fully + closed-form value (true score N(sqrt(B) r, 1), impostor the maximum over + the searched lags of independent N(0, 1) draws), which is accurate for + Poisson traffic and too optimistic for bursty traffic. `model_*_r_sim` + is the simulated true-pair level, a check on the closed-form r, and + `model_*_mu_hat_sim` the signal computed from it (fully calibrated).""" + lam = config.real_rate + p = observed_share(config) + log_k = math.log(psi * (1 - base_rate) / ((1 - psi) * base_rate)) + lag_max_s = config.suite_lag_max_s + if lag_max_s is None: + p99 = float(np.quantile(sample_added_delay(config, 200_000, np.random.default_rng(0)), 0.99)) + lag_max_s = max(2.0, p99) + 0.5 + policy = config.split_strategy if config.num_paths > 1 else "iid" + out = {"model_observed_share": p} + sim = simulated_scores(config, lag_max_s) + for w in config.suite_bin_widths_s: + lags = math.ceil(lag_max_s / w) + bins = max(config.duration_s / w, 2.0) # every lag compares the full flow window + kappa = delay_factors(config, w, lags) + m, d = traffic_moments(config, w) + burst = 1.0 if d == 1.0 else 1.0 / math.sqrt((1.0 - 1.0 / d) * delay_spread(config, w, lags) + 1.0 / d) + gamma = policy_factor(policy, p, config.num_paths, m, config.split_batch_mean_s, w, d) + # independent Poisson cover adds variance c w next to the real variance lam w D + cover = math.sqrt(lam * d / (lam * d + config.cover_rate)) if lam > 0 else 0.0 + r = gamma * float(kappa.max()) * burst * cover + out[f"model_dispersion_w{w}"] = d + mu_true = math.sqrt(bins) * r + mu_imp, sd_imp = _max_of_normals(lags + 1) + prefix = f"model_a1_w{w}" + null = sim[w] + out[f"{prefix}_r"] = r + out[f"{prefix}_r_sim"] = null["true_mean"] + out[f"{prefix}_imp_mean"] = null["imp_mean"] + out[f"{prefix}_imp_sd"] = null["imp_sd"] + out[f"{prefix}_mu_hat_gaussian"] = (mu_true - mu_imp) / sd_imp + if not null["imp_sd"] > 0: # too few bins for a usable null (very short flows) + for key in ("mu_hat", "mu_hat_sim", f"tpr_at_fpr_{fpr}", f"lpsi_{psi}_br_{base_rate}"): + out[f"{prefix}_{key}"] = float("nan") + continue + out[f"{prefix}_mu_hat"] = (r - null["imp_mean"]) / null["imp_sd"] + out[f"{prefix}_mu_hat_sim"] = (null["true_mean"] - null["imp_mean"]) / null["imp_sd"] + if math.isnan(r): + out[f"{prefix}_tpr_at_fpr_{fpr}"] = out[f"{prefix}_lpsi_{psi}_br_{base_rate}"] = float("nan") + continue + # scores in units of the true-pair standard deviation, as _confident_cut expects + # floor: with no added delay a full observation reproduces the counts and + # the simulated spread collapses, which would make the fits degenerate + sd_true = max(null["true_sd"], 0.1 * null["imp_sd"], 1e-9) + t_mu, i_mu, i_sd = r / sd_true, null["imp_mean"] / sd_true, null["imp_sd"] / sd_true + out[f"{prefix}_tpr_at_fpr_{fpr}"] = 1 - _N.cdf(i_mu + _N.inv_cdf(1 - fpr) * i_sd - t_mu) + cut_psi = _confident_cut(t_mu, i_mu, i_sd, log_k) if t_mu > i_mu else math.inf + out[f"{prefix}_lpsi_{psi}_br_{base_rate}"] = 0.0 if math.isinf(cut_psi) else 1 - _N.cdf(cut_psi - t_mu) + return out + + +def coupled_risk(link_prob, weights: list[float], f_guard: float, f_exit: float) -> float: + """Probability that one flow is linked (model derivation, Proposition 1): + f_exit * sum over non-empty sets S of compromised legs of + P(S) * link_prob(p_S), with legs compromised independently.""" + k = len(weights) + total = sum(weights) + risk = 0.0 + for size in range(1, k + 1): + for legs in combinations(range(k), size): + p_s = sum(weights[i] for i in legs) / total + risk += f_guard**size * (1 - f_guard) ** (k - size) * link_prob(p_s) + return f_exit * risk + + +def power_mean_ratio(weights: list[float], tail_index: float) -> float: + """Duration-averaged single-leg risk of a split relative to one path under + Pareto flow durations (model derivation, Proposition 4): sum of p_i^a.""" + total = sum(weights) + return sum((w / total) ** tail_index for w in weights) diff --git a/anontestlab/traffic/__init__.py b/anontestlab/traffic/__init__.py index 592f763..06a2dcf 100644 --- a/anontestlab/traffic/__init__.py +++ b/anontestlab/traffic/__init__.py @@ -1,4 +1,7 @@ +from dataclasses import fields + from .generators import ( + BurstTraffic, ConstantRateTraffic, ParetoTraffic, PoissonTraffic, @@ -9,20 +12,27 @@ "poisson": PoissonTraffic, "constant": ConstantRateTraffic, "pareto": ParetoTraffic, + "burst": BurstTraffic, } -def get_generator(name: str, rate: float) -> TrafficGenerator: +def get_generator(name: str, rate: float, **params) -> TrafficGenerator: + """Builds the named generator. Extra keyword parameters are passed on + only when the generator has a field of that name, so callers can pass + every traffic setting without checking which one applies.""" try: - return GENERATORS[name](rate=rate) + cls = GENERATORS[name] except KeyError: raise ValueError( f"unknown traffic distribution '{name}', available: {sorted(GENERATORS)}" ) from None + accepted = {f.name for f in fields(cls)} + return cls(rate=rate, **{k: v for k, v in params.items() if k in accepted}) __all__ = [ "GENERATORS", + "BurstTraffic", "ConstantRateTraffic", "ParetoTraffic", "PoissonTraffic", diff --git a/anontestlab/traffic/generators.py b/anontestlab/traffic/generators.py index 74bd523..b835537 100644 --- a/anontestlab/traffic/generators.py +++ b/anontestlab/traffic/generators.py @@ -70,3 +70,37 @@ def emission_times(self, rng: random.Random, duration: float) -> list[float]: times.append(t) t += min_gap * rng.paretovariate(self.shape) return times + + +@dataclass +class BurstTraffic(TrafficGenerator): + """Bursts of back-to-back cells, the shape of web and bulk transfers over + Tor. Bursts start as a Poisson process of rate rate / burst_mean_cells; + each carries a geometric number of cells (mean burst_mean_cells, at least + one) sent burst_gap_ms apart, so the long-run cell rate is `rate`. For + bins much longer than a burst, the per-bin counts have index of + dispersion 2 * burst_mean_cells - 1 (1 for Poisson).""" + + rate: float # cells per second + burst_mean_cells: float = 12.0 + burst_gap_ms: float = 2.0 + + def emission_times(self, rng: random.Random, duration: float) -> list[float]: + if self.rate <= 0: + return [] + mean = max(self.burst_mean_cells, 1.0) + burst_rate = self.rate / mean + gap = self.burst_gap_ms / 1000.0 + stop = 1.0 / mean # geometric on 1, 2, ... with this mean + times = [] + t = rng.expovariate(burst_rate) + while t < duration: + cell_t = t + while cell_t < duration: + times.append(cell_t) + if rng.random() < stop: + break + cell_t += gap + t += rng.expovariate(burst_rate) + times.sort() + return times diff --git a/docs/index.html b/docs/index.html index 5b5ffdd..fd5249e 100644 --- a/docs/index.html +++ b/docs/index.html @@ -291,8 +291,8 @@

Anonymous Protocol
Emulator.

A real, circuit-based mix network for testing anonymous communication designs against 432 configurations of crypto, routing, traffic, and - adversary models, genuine OS processes and real sockets standing in - for the real network, not a discrete-event approximation of one. + adversary models, with OS processes and sockets standing in for the + network instead of a discrete-event approximation.

View the source @@ -348,25 +348,25 @@

A gap between two t

It's a low-latency, circuit-based mix network: Tor's telescoping onion-routing design, where each relay (a mix node) - strips exactly one encryption layer and forwards, rather than the + strips exactly one encryption layer and forwards, as opposed to the high-latency batch-and-shuffle mixnets built for anonymous email. That's the specific design point every measurement on this page is testing.

Discrete-event simulator
-

Fast, but assumed

-

Latency, handshake cost, and crypto overhead are numbers someone chose. Change the assumption, get a different answer, not a different measurement.

+

Fast, with assumed costs

+

Latency, handshake cost and crypto overhead are numbers someone chose, so a changed assumption changes the answer without any new measurement.

Running real Tor
-

Real, but heavyweight

-

Every cost is genuine, but a new adversary or a swapped cipher means patching production software that was never built to be an experiment harness.

+

Measured, but costly to change

+

Costs are measured, but a new adversary or a swapped cipher means patching production software that was never built for experiments.

AnonTestLab
-

Real, and a research harness

-

Real relays, real sockets, real crypto cost, and every axis worth studying is a config field, not a fork.

+

Measured and configurable

+

Relays, sockets and crypto run for real, and each experimental axis is a config field, so a study needs no fork of the code.

@@ -374,8 +374,8 @@

Real, and a research harness

-
02 · how a session actually runs
-

Five real stages, no shortcuts

+
02 · how a session runs
+

Five stages, each run for real

@@ -435,7 +435,7 @@

YAML becomes an ExperimentConfig

2

spawn_relays() forks N real subprocesses

-

Each relay is its own OS process bound to its own loopback address, 127.0.0.1 through 127.0.0.254, not a coroutine pretending to be a host.

+

Each relay is its own OS process bound to its own loopback address, 127.0.0.1 through 127.0.0.254, so each relay behaves as a separate host.

@@ -466,12 +466,12 @@

MetricsCollector meets a pluggable Adversary

03 · what this adds to the domain
-

Seven things you can't test on a discrete-event simulator

+

Eleven things you can't test on a discrete-event simulator

RUNTIME · anontestlab.emulator
-

Genuine emulation, not a timing model

-

Real subprocesses, real sockets, real crypto cost. Latency and delivery are measured outcomes, never sampled from a distribution someone had to assume first.

+

Emulation without a timing model

+

Subprocesses, sockets and crypto run for real. Latency and delivery are measured, with no assumed distribution behind them.

CRYPTO · crypto_layer.py
@@ -486,12 +486,12 @@

A leak, measured instead of assumed

CRYPTO · anontestlab.crypto

Crypto-agility as a first-class axis

-

Six AEAD ciphers across three ECDHE curves, selectable per experiment, for studies that actually compare algorithm choice rather than assuming one.

+

Six AEAD ciphers across three ECDHE curves, selectable per experiment, for studies that compare the choice of algorithm.

NETWORK · relay_process.py

WAN realism at two granularities

-

Per-node heterogeneous conditions layer with directional per-edge conditions, so a relay's link to one peer can differ from its link to another, not just from other relays.

+

Per-node heterogeneous conditions layer with directional per-edge conditions, so a relay's link to one peer can differ from its link to another as well as from other relays.

ADVERSARY · anontestlab.adversary
@@ -500,8 +500,28 @@

A composable observer pipeline

METHOD · README.md
-

Simplifications are disclosed, not discovered

-

Every scope trim is stated in the open and, where practical, given its own regression test, so "known limitation" means audited, not unexamined.

+

Simplifications are stated

+

Every scope trim is stated in the open and, where practical, has its own regression test, so each known limitation has been checked.

+
+
+
ROUTING · splitting.py · v0.4
+

Multipath split policies

+

One flow split over several circuits that share an exit, as in Conflux: i.i.d., weighted round-robin, batch and lowest-RTT scheduling, with the adversary observing chosen entry legs and the merged exit side.

+
+
+
NETWORK · mixing.py · v0.4
+

Relay mixing and bursty traffic

+

Constant, exponential (Loopix-style) or pool mixing at every hop, and a bursty traffic generator whose dispersion can be matched to measured Tor traffic, so mix-network and onion-routing settings share one emulator.

+
+
+
ADVERSARY · correlation_suite.py · v0.4
+

Training-free correlation attackers with predictions

+

Zero-lag, lag-aware, likelihood-ratio and timing-window attackers scored side by side on one held-out split, with confident linkage at a stated base rate and closed-form model predictions recorded next to every measurement (atl predict).

+
+
+
METHOD · fidelity.py · v0.4
+

A check on host-induced timing noise

+

atl fidelity reruns an experiment's load with mixing off and reports the delay the host adds, so a run on a busy machine is flagged before its timing results are trusted; wave mode keeps large runs under that limit.

@@ -514,7 +534,7 @@

Every axis is one Y

6 AEAD ciphers × 3 ECDHE curves × 2 routing strategies × 3 traffic distributions × 4 adversary models: 432 combinations, each one - a real config field, not a code change. + set by config fields without code changes.

Traffic & topology

@@ -592,19 +612,20 @@

05 · methodology
-

Disclosed simplifications, not claims

+

Disclosed simplifications

- A research harness earns trust by stating its scope trims in the open. These - are the ones that still stand, each one deliberate: + These scope trims still stand, each one deliberate:

-

Known limitations, v0.3

+

Known limitations, v0.4

  • Fixed-size cells still leak hop position. A global observer watching multiple hops of one circuit could infer depth from the size sequence, which is exactly what hop_depth measures.
  • Per-edge link conditions are directional-only. Only the connection-initiating side scales by the edge factor; the receiving side's own upstream sends still use its plain per-node value.
  • No relay identity or directory system. Keys are ephemeral only: no TOFU question to answer, but also no persistent relay reputation.
  • bandwidth_weighted routing skips guard/exit-flag constraints. It weights by node capacity alone, deliberately not replicating Tor's position rules.
  • -
  • Timing varies run to run. The experiment design is reproducible from its seed; real measured latency isn't, because it's real.
  • +
  • Wave mode changes the timestamp origin. With sessions.max_concurrent, each session's timestamps start at its own start, so flows from different waves line up as if concurrent.
  • +
  • The latency split policy uses nominal RTTs. The scheduler follows the configured leg RTTs; the legs are not given different real latencies.
  • +
  • Timing varies run to run. The experiment design is reproducible from its seed; measured latency varies because it comes from real runs.
@@ -638,7 +659,7 @@

License

AnonTestLab · maintained by hashtagRR - spec revision v0.3 + spec revision v0.4
diff --git a/examples/mixnet_pool.yaml b/examples/mixnet_pool.yaml new file mode 100644 index 0000000..ae51510 --- /dev/null +++ b/examples/mixnet_pool.yaml @@ -0,0 +1,40 @@ +# Loopix/Nym-style mixing on every hop. To measure what the mixing buys, +# copy this file with `strategy: none` (everything else identical) and run: +# atl paired no_mixing.yaml examples/mixnet_pool.yaml --seeds 12 --metric auc +experiment: + name: mixnet-pool + seed: 20260903 + duration_s: 10 + grace_period_s: 3 # a few times the mean added mixing delay + +network: + nodes: 20 + +sessions: + count: 60 + +routing: + strategy: random + path_length: 3 + +traffic: + real_rate: 10 + distribution: poisson + cover_rate: 5 + +cover_behaviour: + drop_mode: random_hop # each cover cell dies at one client-chosen hop + +mixing: + strategy: pool # shared by every circuit through a relay + pool_interval_ms: 250 + release_probability: 0.7 # cells can survive several flushes + interval_jitter: 0.3 # randomized flush timing + +crypto: + algorithm: aes256gcm + +adversary: + type: global_observer + observation: {bin_width_ms: 100} + evaluation: {calibration_fraction: 0.5} diff --git a/pyproject.toml b/pyproject.toml index 96d9e79..9b60c67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "anontestlab" -version = "0.3.0" +version = "0.4.0" description = "A simulation and experimentation framework for evaluating anonymous communication network designs" readme = "README.md" requires-python = ">=3.9" diff --git a/tests/test_attacks.py b/tests/test_attacks.py new file mode 100644 index 0000000..5c22221 --- /dev/null +++ b/tests/test_attacks.py @@ -0,0 +1,188 @@ +"""Training-free correlation attackers (adversary/attacks.py), the delay +model, the confident-linkage metric and the correlation_suite adversary. +The unit tests use synthetic traces with a known pairing; the last test +runs the suite on real relays.""" +import random + +import numpy as np +import pytest + +from anontestlab.adversary import attacks, delay_model +from anontestlab.adversary.base import SessionObservation, SimulationContext +from anontestlab.adversary.correlation_suite import CorrelationSuite +from anontestlab.experiment import ExperimentConfig, run_experiment +from anontestlab.metrics.stats import confident_linkage, roc_auc + +HORIZON = 60.0 + + +def _poisson(rng, rate, horizon=HORIZON): + return sorted(rng.uniform(0, horizon, rng.poisson(rate * horizon))) + + +def _pairs(n, rate, delay_fn, seed=0): + """n independent Poisson flows; egress = ingress times plus a delay.""" + rng = np.random.default_rng(seed) + ingress = [_poisson(rng, rate) for _ in range(n)] + egress = [sorted(t + delay_fn(rng) for t in flow) for flow in ingress] + return ingress, egress + + +def _auc(scores): + n = scores.shape[0] + off = scores[~np.eye(n, dtype=bool)] + return roc_auc(list(np.diag(scores)), list(off)) + + +def _config(**overrides): + base = {"name": "t"} + base.update(overrides) + return ExperimentConfig(**base) + + +def test_lag_search_recovers_a_constant_shift_that_defeats_zero_lag(): + ingress, egress = _pairs(30, 8.0, lambda rng: 0.5) # exactly two 0.25 s bins + zero = attacks.lag_correlation_scores(ingress, egress, 0.25, HORIZON + 1, 0) + lagged = attacks.lag_correlation_scores(ingress, egress, 0.25, HORIZON + 1, 4) + assert _auc(lagged) > 0.99 + assert np.mean(np.diag(lagged)) > 0.99 + assert np.mean(np.diag(zero)) < 0.1 + + +def test_best_lag_correlation_matches_the_delay_factor(): + # A 0.6 s shift covers 2.4 bins of 0.25 s: 60% of each bin's packets land + # in one lagged bin, so the best-lag correlation is the kernel overlap 0.6 + # (model derivation, delay factor kappa at the best lag). + ingress, egress = _pairs(40, 8.0, lambda rng: 0.6, seed=2) + lagged = attacks.lag_correlation_scores(ingress, egress, 0.25, HORIZON + 1, 4) + assert float(np.mean(np.diag(lagged))) == pytest.approx(0.6, abs=0.03) + + +def test_likelihood_ratio_attacker_separates_true_pairs_under_exponential_delay(): + config = _config(mix_strategy="exponential", mix_delay_ms=100.0, path_length=3) + density = delay_model.delay_density(config, 0.01) + rng_delay = np.random.default_rng(3) + ingress, egress = _pairs(30, 8.0, lambda rng: rng_delay.exponential(0.1, 3).sum()) + scores = attacks.likelihood_ratio_scores(ingress, egress, density, 0.01, HORIZON + 5) + assert _auc(scores) > 0.99 + assert np.all(np.diag(scores) > 0) + + +def test_window_matcher_is_near_zero_for_impostors(): + ingress, egress = _pairs(30, 8.0, lambda rng: rng.uniform(0.0, 0.05)) + scores = attacks.window_scores(ingress, egress, 0.07, HORIZON + 1) + off = scores[~np.eye(30, dtype=bool)] + assert abs(float(np.mean(off))) < 0.05 + assert float(np.mean(np.diag(scores))) > 0.3 + + +def test_delay_model_means_match_the_mixing_settings(): + rng = np.random.default_rng(1) + exp = delay_model.sample_added_delay(_config(mix_strategy="exponential", mix_delay_ms=50.0), 200_000, rng) + assert exp.mean() == pytest.approx(0.15, rel=0.02) # three hops of 50 ms mean + const = delay_model.sample_added_delay(_config(mix_strategy="constant", mix_delay_ms=40.0), 1000, rng) + assert np.allclose(const, 0.12) + pool = _config(mix_strategy="pool", pool_interval_ms=200.0, pool_release_probability=1.0) + assert delay_model.sample_added_delay(pool, 200_000, rng).mean() == pytest.approx(0.3, rel=0.02) + assert np.all(delay_model.sample_added_delay(_config(), 100, rng) == 0) + + +def test_delay_density_integrates_to_one(): + density = delay_model.delay_density(_config(mix_strategy="exponential", mix_delay_ms=50.0), 0.005) + assert density.sum() * 0.005 == pytest.approx(1.0, abs=1e-3) + + +def test_confident_linkage_rejects_weak_evidence_that_fixed_fpr_still_credits(): + rng = np.random.default_rng(5) + impostors = rng.normal(0, 1, 20000) + strong = rng.normal(8, 1, 200) + weak = rng.normal(0.3, 1, 200) + assert confident_linkage(strong[:100], impostors, strong[100:], 0.9, 1e-4) > 0.95 + assert confident_linkage(weak[:100], impostors, weak[100:], 0.9, 1e-4) == 0.0 + assert confident_linkage(rng.normal(-1, 1, 100), impostors, weak, 0.9, 1e-4) == 0.0 + + +def test_confident_linkage_is_stricter_at_lower_base_rates(): + rng = np.random.default_rng(6) + impostors = rng.normal(0, 1, 20000) + true = rng.normal(4.5, 1, 400) + common = confident_linkage(true[:200], impostors, true[200:], 0.9, 1e-2) + rare = confident_linkage(true[:200], impostors, true[200:], 0.9, 1e-6) + assert common > rare + + +def _suite_context(n, delay_s): + ingress, egress = _pairs(n, 8.0, lambda rng: delay_s) + sessions = { + i: SessionObservation(session_id=i, ingress_times=list(ingress[i]), egress_times=list(egress[i])) + for i in range(n) + } + return SimulationContext(sessions=sessions, session_paths={}, node_ids=[]) + + +def test_suite_reports_every_attacker_with_a_shared_split(): + config = _config(suite_bin_widths_s=[0.25, 1.0], suite_psi=[0.9], suite_base_rates=[1e-4], + suite_fpr_targets=[1e-3], observer_calibration_fraction=0.5) + result = CorrelationSuite.from_config(config).attack(_suite_context(24, 0.4), random.Random(0)) + m = result.metrics + for attacker in ("a0_w0.25", "a0_w1.0", "a1_w0.25", "a1_w1.0", "a2", "a3_q0.5", "a3_q0.95"): + for key in ("lpsi_0.9_br_0.0001", "tpr_at_fpr_0.001", "mu_hat", "auc"): + assert f"{attacker}_{key}" in m + assert result.artifacts[f"scores_{attacker}"].shape == (24, 24) + assert m["suite_calibration_sessions"] + m["suite_test_sessions"] == 24 + assert m["a1_w0.25_mu_hat"] > m["a0_w0.25_mu_hat"] # a 0.4 s shift spans more than one 0.25 s bin + + +def test_suite_on_real_relays_writes_scores(tmp_path): + config = _config(name="suite-live", seed=9, duration_s=4.0, grace_period_s=1.0, num_nodes=6, + num_sessions=8, path_length=3, real_rate=20.0, crypto_algorithm="aes256gcm", + adversaries=["correlation_suite"], suite_bin_widths_s=[0.25], suite_psi=[0.9], + suite_base_rates=[1e-4], suite_fpr_targets=[1e-3], observer_calibration_fraction=0.5) + result = run_experiment(config, out_dir=tmp_path) + assert result.metrics["delivery_rate"] == 1.0 + assert "a1_w0.25_lpsi_0.9_br_0.0001" in result.metrics + saved = np.load(tmp_path / "correlation_suite.npz") + assert saved["scores_a1_w0.25"].shape == (8, 8) + + +def test_an_overrunning_session_does_not_inflate_impostor_scores(): + config = _config(duration_s=HORIZON, suite_attackers=["a1"], suite_bin_widths_s=[0.25], suite_psi=[0.9], + suite_base_rates=[1e-4], suite_fpr_targets=[1e-3], suite_lag_max_s=2.5, + observer_calibration_fraction=0.5) + ctx = _suite_context(24, 0.3) + late = ctx.sessions[0] + late.ingress_times = late.ingress_times + [t + 200.0 for t in late.ingress_times[:50]] + result = CorrelationSuite.from_config(config).attack(ctx, random.Random(0)) + scores = result.artifacts["scores_a1_w0.25"] + impostors = scores[~np.eye(24, dtype=bool)] + assert result.metrics["suite_overrun_sessions"] == 1 + assert result.metrics["suite_dropped_timestamps"] == 50 + assert float(np.mean(impostors)) < 0.3 + + +def test_ingress_window_without_trailing_padding_keeps_impostors_near_zero(): + # Flows fill [0, 40) and egress needs 2.5 s more for delayed packets. + # Binning ingress over the padded window gives every session the same + # empty tail, which on its own makes impostor pairs correlate. + ingress, egress = _pairs(30, 40.0, lambda rng: rng.exponential(0.075), seed=4) + ingress = [[t * 40.0 / HORIZON for t in flow] for flow in ingress] + egress = [[t * 40.0 / HORIZON for t in flow] for flow in egress] + padded = attacks.lag_correlation_scores(ingress, egress, 0.25, 42.5, 10) + trimmed = attacks.lag_correlation_scores(ingress, egress, 0.25, 42.5, 10, 40.0) + off = ~np.eye(30, dtype=bool) + # With the empty tail removed, impostors sit at the chance level of a + # maximum over 11 lags of 160-bin correlations, about 1.59 / sqrt(160). + chance = 1.59 / np.sqrt(160) + assert float(np.mean(trimmed[off])) == pytest.approx(chance, abs=0.02) + assert float(np.mean(padded[off])) > chance + 0.1 + assert _auc(trimmed) > 0.99 + + +def test_suite_saves_raw_observations_for_rescoring(): + config = _config(suite_attackers=["a1"], suite_bin_widths_s=[0.25], suite_psi=[0.9], + suite_base_rates=[1e-4], suite_fpr_targets=[1e-3], observer_calibration_fraction=0.5) + ctx = _suite_context(8, 0.3) + art = CorrelationSuite.from_config(config).attack(ctx, random.Random(0)).artifacts + off = art["egress_offsets"] + assert len(off) == 9 + assert list(art["egress_times"][off[3]:off[4]]) == ctx.sessions[3].egress_times diff --git a/tests/test_experiment.py b/tests/test_experiment.py index 0094c3f..d1456db 100644 --- a/tests/test_experiment.py +++ b/tests/test_experiment.py @@ -1,12 +1,13 @@ """Integration tests against the real emulator: these spawn real relay subprocesses on real localhost ports, so they're slower than unit tests -(seconds, not milliseconds). Kept fast by using short durations/small -networks, not by faking anything.""" +(seconds each). They stay fast through short durations and small +networks, with nothing mocked.""" from anontestlab.experiment import ( ExperimentConfig, compare_experiments, run_experiment, + run_paired, run_sweep, ) from anontestlab.experiment.config import PathSpec @@ -186,8 +187,8 @@ def test_path_compromise_adversary_full_fraction_means_near_certain_compromise() _small_config(adversaries=["path_compromise"], compromised_fraction=1.0, compromise_trials=200) ) # Wilson-center, not the raw proportion, so it's pulled slightly off - # the 1.0 boundary even when every trial was a "success". That's - # correct Wilson behavior, not a bug. + # the 1.0 boundary even when every trial was a "success", which is + # correct Wilson behavior. assert result.metrics["full_compromise_rate"] > 0.98 @@ -279,13 +280,12 @@ def test_watermark_detection_survives_packet_loss_downstream(): # anywhere downstream of the watermark relay silently desynchronizes # from true send order. This combination (watermark + real loss) was # never exercised end to end before; with real_seq-based detection it - # must keep working under actual, non-deterministic loss, not just + # must keep working under non-deterministic loss as well as in # the hand-computed synthetic case in test_watermark_adversary.py. # Per-relay loss rolls are independent random state in each subprocess # (not seeded), so a generous session count and threshold keep this - # robust to run-to-run noise without weakening what it's actually - # checking: a broken (index-based) detector would score far below - # this on the same data, not just slightly under it. + # stable under run-to-run noise without weakening the check: a broken + # (index-based) detector would score far below this on the same data. result = run_experiment( _small_config( watermark_period=3, @@ -337,7 +337,7 @@ def test_egress_timestamp_reflects_exit_hop_not_the_confirmation_round_trip(): mean_delivered_at = sum(p.delivered_at for p in delivered_real) / len(delivered_real) mean_egress_t = sum(obs.egress_times) / len(obs.egress_times) # The return leg alone is 3 more hops of ~40ms latency; a generous - # (well under that) margin keeps this robust to jitter noise while + # (well under that) margin keeps this stable under jitter noise while # still failing if egress ever regresses to being the same clock # reading as delivered_at. assert mean_delivered_at - mean_egress_t > 0.05 @@ -501,3 +501,20 @@ def test_baseline_pointing_at_a_config_with_its_own_baseline_is_rejected(tmp_pat treatment.baseline = str(chained_path) with pytest.raises(ValueError, match="baseline chains aren't supported"): run_experiment(treatment) + + +def test_paired_runs_both_configs_per_seed(): + ref = _small_config(name="ref", num_sessions=3) + treat = _small_config(name="treat", num_sessions=3) + result = run_paired(ref, treat, seeds=[1, 2], metric="delivery_rate", resamples=200) + assert result.seeds == [1, 2] + assert result.reference_values == [1.0, 1.0] + assert result.treatment_values == [1.0, 1.0] + assert result.effect.classification == "no_meaningful_effect" + + +def test_held_out_calibration_reports_realized_fpr(): + result = run_experiment(_small_config(num_sessions=6, observer_calibration_fraction=0.5)) + m = result.metrics + assert m["calibration_sessions"] + m["test_sessions"] == 6 + assert "realized_fpr_0.1" in m and "fpr_support_0.1" in m diff --git a/tests/test_hop_depth_adversary.py b/tests/test_hop_depth_adversary.py index 52c8e57..b38dbd9 100644 --- a/tests/test_hop_depth_adversary.py +++ b/tests/test_hop_depth_adversary.py @@ -1,5 +1,5 @@ """Pure-Python tests for HopDepthAdversary: no subprocesses needed, since -it's structural (like path_compromise), not timing-based. The underlying +it is structural (like path_compromise) and does not depend on timing. The underlying wire-size-per-hop math is independently verified against the real crypto/wire code in test_wire_and_crypto.py::test_fixed_cell_size_shrinks_by_a_fixed_amount_per_hop.""" diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 61a4cbb..a8d6e1e 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -1,7 +1,16 @@ +import math + +import numpy as np +import pytest + +from anontestlab.adversary.decision import evaluate_scores from anontestlab.metrics.stats import ( + cut_at_fpr, + paired_bootstrap_delta, precision_recall_at_threshold, roc_auc, tpr_at_fpr, + tpr_at_fpr_detail, wilson_ci, ) @@ -34,7 +43,10 @@ def test_tpr_at_fpr_no_separation(): true_scores = [0.5, 0.5, 0.5] impostor_scores = [0.5, 0.5, 0.5, 0.5, 0.5] result = tpr_at_fpr(true_scores, impostor_scores, [0.1]) - assert result[0.1] == 1.0 # threshold ties everything at 0.5 + # Every impostor ties the true scores, so any cut that flags a true pair + # also flags 100% of impostors, far above the 10% target. The conservative + # cut flags nothing. (This used to report 1.0 at a realized FPR of 1.0.) + assert result[0.1] == 0.0 def test_roc_auc_perfect_separation(): @@ -61,3 +73,60 @@ def test_precision_recall_no_separation(): precision, recall = precision_recall_at_threshold([0.5, 0.5], [0.5, 0.5, 0.5], threshold=0.5) assert precision == 2 / 5 # 2 true positives out of 5 flagged assert recall == 1.0 + + +def test_cut_never_exceeds_target_fpr_under_ties(): + impostor = np.array([0.9, 0.9, 0.9, 0.5, 0.5, 0.1, 0.1, 0.1, 0.1, 0.1]) + for fpr in (0.1, 0.2, 0.3, 0.5, 0.9): + cut = cut_at_fpr(impostor, fpr) + assert np.mean(impostor > cut) <= fpr + 1e-12 + + +def test_cut_allows_everything_at_fpr_one(): + assert cut_at_fpr(np.array([0.2, 0.4]), 1.0) == -math.inf + + +def test_detail_reports_realized_fpr_and_support(): + rng = np.random.default_rng(0) + cal = rng.normal(size=2000) + test = rng.normal(size=2000) + point = tpr_at_fpr_detail([3.0, 4.0], cal, [0.01], test_impostor_scores=test)[0.01] + assert point.tpr == 1.0 + assert point.expected_exceedances == pytest.approx(20.0) + assert 0.0 <= point.realized_fpr < 0.03 # held-out FPR close to the target + + +def test_held_out_split_uses_disjoint_sessions(): + rng = np.random.default_rng(1) + n = 40 + scores = rng.normal(size=(n, n)) + np.fill_diagonal(scores, 5.0) # perfectly separable true pairs + m = evaluate_scores(scores, 0.7, (0.01,), calibration_fraction=0.5, rng=np.random.default_rng(2)) + assert m["calibration_sessions"] + m["test_sessions"] == n + assert m["tpr_at_fpr_0.01"] == 1.0 + assert m["realized_fpr_0.01"] <= 0.05 + + +def test_paired_bootstrap_classifies_equivalence(): + ref = [0.50, 0.52, 0.49, 0.51, 0.50, 0.48, 0.52, 0.50, 0.51, 0.49, 0.50, 0.51] + same = [x + d for x, d in zip(ref, [0.01, -0.01, 0.0, 0.01, -0.01, 0.0, 0.01, 0.0, -0.01, 0.0, 0.01, -0.01])] + assert paired_bootstrap_delta(ref, same).classification == "no_meaningful_effect" + + +def test_paired_bootstrap_classifies_effect(): + ref = [0.50] * 12 + lower = [0.30 + 0.01 * (i % 3) for i in range(12)] + effect = paired_bootstrap_delta(ref, lower) + assert effect.classification == "meaningful_effect" + assert effect.mean_delta < -0.05 and effect.ci_high < 0 + + +def test_paired_bootstrap_noisy_null_is_inconclusive_not_null(): + ref = [0.5] * 12 + noisy = [0.5 + (0.15 if i % 2 else -0.15) for i in range(12)] + assert paired_bootstrap_delta(ref, noisy).classification == "inconclusive" + + +def test_paired_bootstrap_rejects_mismatched_lengths(): + with pytest.raises(ValueError): + paired_bootstrap_delta([0.1, 0.2], [0.1]) diff --git a/tests/test_mixing.py b/tests/test_mixing.py new file mode 100644 index 0000000..6181e7f --- /dev/null +++ b/tests/test_mixing.py @@ -0,0 +1,194 @@ +"""Relay-side mixing (emulator/mixing.py) and random-hop cover +termination. The Mixer tests run the real asyncio scheduling with short +delays and measure the resulting send times. The integration tests at +the bottom spawn real relays.""" +import asyncio +import random +import time +import warnings + +import pytest + +from anontestlab.emulator import crypto_layer, wire +from anontestlab.emulator.circuit_client import wrap_layers +from anontestlab.emulator.mixing import Mixer +from anontestlab.experiment import ExperimentConfig, run_experiment + + +def _recorder(): + sent: list[tuple[int, float]] = [] + + def make(i: int): + async def send() -> None: + sent.append((i, time.monotonic())) + + return send + + return sent, make + + +def test_none_sends_inline_in_order(): + async def main(): + sent, make = _recorder() + m = Mixer("none") + for i in range(5): + await m.submit(make(i)) + return sent + + sent = asyncio.run(main()) + assert [i for i, _ in sent] == [0, 1, 2, 3, 4] + + +def test_constant_delay_shifts_without_reordering(): + async def main(): + sent, make = _recorder() + m = Mixer("constant", delay_s=0.05) + start = time.monotonic() + for i in range(5): + await m.submit(make(i)) + assert sent == [] # nothing leaves before its delay + await asyncio.sleep(0.15) + return sent, start + + sent, start = asyncio.run(main()) + assert [i for i, _ in sent] == [0, 1, 2, 3, 4] + assert all(t - start >= 0.045 for _, t in sent) + + +def test_exponential_delay_has_roughly_the_configured_mean(): + async def main(): + sent, make = _recorder() + m = Mixer("exponential", delay_s=0.02, rng=random.Random(7)) + start = time.monotonic() + for i in range(300): + await m.submit(make(i)) + await asyncio.sleep(0.4) + return sent, start + + sent, start = asyncio.run(main()) + assert len(sent) == 300 + mean = sum(t - start for _, t in sent) / len(sent) + assert 0.012 < mean < 0.035 + assert [i for i, _ in sent] != list(range(300)) # independent delays reorder cells + + +def test_pool_holds_cells_until_flush_then_releases_a_burst(): + async def main(): + sent, make = _recorder() + m = Mixer("pool", interval_s=0.08, rng=random.Random(3)) + for i in range(20): + await m.submit(make(i)) + await asyncio.sleep(0.03) + before = len(sent) + await asyncio.sleep(0.1) + return before, sent + + before, sent = asyncio.run(main()) + assert before == 0 + assert sorted(i for i, _ in sent) == list(range(20)) + times = [t for _, t in sent] + assert max(times) - min(times) < 0.02 # released as one burst + assert [i for i, _ in sent] != list(range(20)) # shuffled on release + + +def test_retained_pool_conserves_cells_across_flushes(): + async def main(): + sent, make = _recorder() + m = Mixer("pool", interval_s=1000.0, release_probability=0.3, rng=random.Random(11)) + for i in range(200): + await m.submit(make(i)) + first = m.flush() + await asyncio.sleep(0) + assert first + m.pool_size == 200 + assert 20 < first < 100 # about 30% released, the rest retained + for _ in range(60): + m.flush() + await asyncio.sleep(0.01) + return sent, m.pool_size + + sent, remaining = asyncio.run(main()) + assert remaining == 0 + assert sorted(i for i, _ in sent) == list(range(200)) # nothing lost or duplicated + + +def test_unknown_strategy_is_rejected(): + with pytest.raises(ValueError): + Mixer("bogus") + + +def _layer_kinds(n_hops: int, terminate_at): + keys = [bytes(32)] * n_hops + cids = [bytes(wire.CIRCUIT_ID_LEN)] * n_hops + layer = wrap_layers(keys, cids, b"target", "none", wire.KIND_COVER, 9, terminate_at) + kinds = [] + for hop in range(n_hops - 1): + plaintext = crypto_layer.open_sealed("none", keys[hop], layer, aad=cids[hop]) + kind, packet_id, layer = wire.unpack_data(plaintext) + assert packet_id == 9 + kinds.append(kind) + return kinds + + +def test_random_hop_marks_exactly_the_chosen_layer(): + for hop in range(3): + kinds = _layer_kinds(4, hop) + assert kinds.count(wire.KIND_COVER_TERMINATE) == 1 + assert kinds[hop] == wire.KIND_COVER_TERMINATE + assert all(k == wire.KIND_COVER for i, k in enumerate(kinds) if i != hop) + + +def test_no_terminate_mark_by_default(): + assert wire.KIND_COVER_TERMINATE not in _layer_kinds(4, None) + + +def _small(**overrides) -> ExperimentConfig: + base = { + "name": "mix-test", + "seed": 5, + "duration_s": 1.0, + "grace_period_s": 1.5, + "num_nodes": 5, + "num_sessions": 3, + "path_length": 3, + "real_rate": 6.0, + "crypto_algorithm": "aes256gcm", + "adversaries": ["global_observer"], + } + base.update(overrides) + return ExperimentConfig(**base) + + +def test_exponential_mixing_adds_latency_on_real_relays(): + plain = run_experiment(_small()).metrics + mixed = run_experiment(_small(mix_strategy="exponential", mix_delay_ms=40.0)).metrics + assert mixed["delivery_rate"] == 1.0 + # three hops of 40 ms mean each: well above the unmixed localhost latency + assert mixed["avg_latency_s"] > plain["avg_latency_s"] + 0.05 + + +def test_pool_mixing_delivers_everything_on_real_relays(): + m = run_experiment(_small(mix_strategy="pool", pool_interval_ms=50.0)).metrics + assert m["delivery_rate"] == 1.0 + assert m["avg_latency_s"] > 0.03 + + +def test_random_hop_cover_keeps_real_traffic_intact(): + m = run_experiment(_small(cover_rate=8.0, cover_drop_mode="random_hop")).metrics + assert m["delivery_rate"] == 1.0 + assert m["cover_packets_sent"] > 0 + + +def test_mixing_config_validation(): + with pytest.raises(ValueError): + _small(mix_strategy="exponential", mix_delay_ms=0.0).validate() + with pytest.raises(ValueError): + _small(mix_strategy="pool", pool_release_probability=0.0).validate() + with pytest.raises(ValueError): + _small(cover_drop_mode="sometimes").validate() + + +def test_grace_period_warning_for_long_mixing(): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _small(mix_strategy="pool", pool_interval_ms=500.0, pool_release_probability=0.2).validate() + assert any("grace_period_s" in str(w.message) for w in caught) diff --git a/tests/test_model_and_fidelity.py b/tests/test_model_and_fidelity.py new file mode 100644 index 0000000..815bfbd --- /dev/null +++ b/tests/test_model_and_fidelity.py @@ -0,0 +1,112 @@ +"""Model predictions (anontestlab/model.py), wave mode and the fidelity +check. The model tests check the limiting cases written out in the model +derivation; the last two tests spawn real relays.""" +import math +import random + +import numpy as np +import pytest + +from anontestlab import model +from anontestlab.experiment import ExperimentConfig, run_experiment +from anontestlab.experiment.config import PathSpec +from anontestlab.experiment.fidelity import run_fidelity + + +def _config(**overrides): + base = {"name": "t"} + base.update(overrides) + return ExperimentConfig(**base) + + +def test_policy_factors(): + assert model.policy_factor("iid", 0.25, 4, 20.0, 2.0, 0.5) == pytest.approx(0.5) + assert model.policy_factor("round_robin", 0.5, 2, 20.0, 2.0, 0.5) > 0.99 + assert model.policy_factor("batch", 0.5, 2, 20.0, 5.0, 0.5) == pytest.approx(math.sqrt(0.5 / 11.0)) + assert math.isnan(model.policy_factor("batch", 0.5, 2, 20.0, 1.0, 0.5)) # batches too short for the formula + assert math.isnan(model.policy_factor("latency", 0.5, 2, 20.0, 2.0, 0.5)) + assert model.policy_factor("iid", 1.0, 2, 20.0, 2.0, 0.5) == 1.0 + + +def test_delay_factor_of_a_constant_shift(): + config = _config(mix_strategy="constant", mix_delay_ms=200.0) # 3 hops: 0.6 s + kappa = model.delay_factors(config, 0.25, 4) + assert kappa[2] == pytest.approx(0.6, abs=0.01) + assert kappa[3] == pytest.approx(0.4, abs=0.01) + assert kappa[0] == pytest.approx(0.0, abs=1e-9) + + +def test_coupled_risk_limits(): + f_g, f_x = 0.1, 0.2 + # every compromised leg links with certainty: the any-entry formula + assert model.coupled_risk(lambda p: 1.0, [0.5, 0.5], f_g, f_x) == pytest.approx(f_x * (1 - (1 - f_g) ** 2)) + # only the full set of legs links: the all-entries formula + assert model.coupled_risk(lambda p: float(p >= 1.0), [0.5, 0.5], f_g, f_x) == pytest.approx(f_x * f_g**2) + # one path + assert model.coupled_risk(lambda p: 0.7, [1.0], f_g, f_x) == pytest.approx(f_x * f_g * 0.7) + + +def test_power_mean_ratio(): + assert model.power_mean_ratio([1, 1], 1.0) == pytest.approx(1.0) + assert model.power_mean_ratio([1, 1, 1, 1], 0.5) == pytest.approx(4 ** 0.5) + assert model.power_mean_ratio([0.8, 0.2], 1.5) == pytest.approx(0.8**1.5 + 0.2**1.5) + + +def test_predict_uses_the_observed_share_and_orders_policies(): + two_legs = {"extra_paths": [PathSpec("random", 3)], "observed_legs": [0], "suite_bin_widths_s": [0.25], + "mix_strategy": "exponential", "mix_delay_ms": 50.0, "duration_s": 40.0, "real_rate": 40.0} + iid = model.predict(_config(split_strategy="iid", split_weights=[0.8, 0.2], **two_legs)) + rr = model.predict(_config(split_strategy="round_robin", **two_legs)) + full = model.predict(_config(**{k: v for k, v in two_legs.items() if k not in ("extra_paths", "observed_legs")})) + assert iid["model_observed_share"] == pytest.approx(0.8) + assert iid["model_a1_w0.25_r"] == pytest.approx(math.sqrt(0.8) * full["model_a1_w0.25_r"], rel=0.02) + assert rr["model_a1_w0.25_r"] > 0.95 * full["model_a1_w0.25_r"] + + +def test_wave_mode_aligns_session_timestamps(): + config = _config(name="waves", seed=4, duration_s=1.5, grace_period_s=0.8, num_nodes=6, num_sessions=6, + max_concurrent_sessions=2, path_length=3, real_rate=10.0, crypto_algorithm="aes256gcm", + adversaries=["global_observer"]) + result = run_experiment(config) + assert result.metrics["delivery_rate"] == 1.0 + assert 0 < result.metrics["oneway_delay_p50_s"] < 0.1 + + +def test_fidelity_check_reports_host_noise(): + config = _config(name="fid", seed=5, duration_s=1.5, grace_period_s=0.8, num_nodes=6, num_sessions=3, + path_length=3, real_rate=10.0, crypto_algorithm="aes256gcm") + report = run_fidelity(config) + assert report["reference"] == "smallest attacker bin width" + assert report["limit_s"] == pytest.approx(0.01) + assert report["host_noise_p95_s"] >= 0 + assert isinstance(report["passes"], bool) + + +def test_burst_generator_keeps_the_rate_and_is_overdispersed(): + from anontestlab.traffic import get_generator + + gen = get_generator("burst", 40.0, burst_mean_cells=12.0, burst_gap_ms=2.0, unrelated=1) + times = np.array(gen.emission_times(random.Random(1), 5000.0)) + assert len(times) / 5000.0 == pytest.approx(40.0, rel=0.03) + assert np.all(np.diff(times) >= 0) + counts = np.bincount((times / 1.0).astype(int)) + assert counts.var() / counts.mean() == pytest.approx(2 * 12 - 1, rel=0.15) + + +def test_dispersion_forms_reduce_to_poisson_and_approach_one_for_bursty_counts(): + assert model.policy_factor("iid", 0.5, 2, 10.0, 2.0, 0.25, dispersion=1.0) == pytest.approx(math.sqrt(0.5)) + assert model.policy_factor("iid", 0.5, 2, 10.0, 2.0, 0.25, dispersion=15.0) == pytest.approx( + 1 / math.sqrt(1 + 1 / 15.0)) + assert model.policy_factor("batch", 0.5, 2, 10.0, 5.0, 0.25, dispersion=20.0) == pytest.approx( + math.sqrt(0.5 / (1 + 0.5 * 10.0 / 20.0))) + + +def test_predict_uses_the_measured_dispersion_of_the_generator(): + base = {"name": "t", "duration_s": 40.0, "real_rate": 40.0, "mix_strategy": "exponential", + "mix_delay_ms": 25.0, "split_strategy": "iid", "observed_legs": [0], "suite_bin_widths_s": [0.25], + "suite_lag_max_s": 1.0, "extra_paths": [PathSpec("random", 3)]} + poisson = model.predict(ExperimentConfig(**base)) + burst = model.predict(ExperimentConfig(real_traffic_distribution="burst", burst_mean_cells=12.0, **base)) + assert poisson["model_dispersion_w0.25"] == 1.0 + assert burst["model_dispersion_w0.25"] > 15.0 + assert burst["model_a1_w0.25_r"] > poisson["model_a1_w0.25_r"] + 0.2 diff --git a/tests/test_splitting.py b/tests/test_splitting.py new file mode 100644 index 0000000..fe31462 --- /dev/null +++ b/tests/test_splitting.py @@ -0,0 +1,114 @@ +"""Split policies (emulator/splitting.py), common-exit path selection and +per-leg observation. The last test runs a two-leg session on real relays.""" +import random +from collections import Counter + +import pytest + +from anontestlab.emulator.orchestrator import select_node_paths +from anontestlab.emulator.splitting import LegScheduler +from anontestlab.experiment import ExperimentConfig, run_experiment +from anontestlab.experiment.config import PathSpec + + +def _assign_all(scheduler, times, seed=0): + rng = random.Random(seed) + return [scheduler.assign(t, rng) for t in times] + + +def _times(n, rate=40.0, seed=1): + rng = random.Random(seed) + t, out = 0.0, [] + for _ in range(n): + t += rng.expovariate(rate) + out.append(t) + return out + + +def test_round_robin_alternates_and_weighted_round_robin_is_exact(): + assert _assign_all(LegScheduler("round_robin", 2), range(6)) == [0, 1, 0, 1, 0, 1] + legs = _assign_all(LegScheduler("round_robin", 2, weights=[3, 1]), range(400)) + assert Counter(legs) == {0: 300, 1: 100} + assert max(len(run) for run in "".join(map(str, legs)).split("1")) <= 3 # spread out, no long runs + + +def test_iid_matches_the_weights(): + legs = _assign_all(LegScheduler("iid", 2, weights=[0.8, 0.2]), range(20000)) + assert Counter(legs)[0] / len(legs) == pytest.approx(0.8, abs=0.01) + + +def test_batch_keeps_consecutive_cells_together(): + times = _times(20000) + legs = _assign_all(LegScheduler("batch", 2, weights=[0.5, 0.5], batch_mean_s=2.0), times) + switches = sum(1 for a, b in zip(legs, legs[1:]) if a != b) + duration = times[-1] + # batches last 2 s on average and half of the draws repeat the leg, + # so switches happen about once every 4 s + assert switches == pytest.approx(duration / 4.0, rel=0.25) + assert Counter(legs)[0] / len(legs) == pytest.approx(0.5, abs=0.1) + + +def test_latency_policy_prefers_the_fast_leg_and_overflows_under_load(): + light = _assign_all(LegScheduler("latency", 2, leg_rtt_s=[0.05, 0.1], leg_window=8), _times(5000, rate=20)) + heavy = _assign_all(LegScheduler("latency", 2, leg_rtt_s=[0.05, 0.1], leg_window=8), _times(5000, rate=400)) + assert Counter(light)[0] / len(light) > 0.99 + assert 0.2 < Counter(heavy)[1] / len(heavy) < 0.8 + + +def test_unknown_policy_is_rejected(): + with pytest.raises(ValueError): + LegScheduler("bogus", 2) + + +def _config(**overrides): + base = {"name": "t", "num_nodes": 12, "path_length": 3, "extra_paths": [PathSpec("random", 3)]} + base.update(overrides) + return ExperimentConfig(**base) + + +def test_common_exit_shares_the_exit_and_nothing_else(): + config = _config(merge="common_exit") + nodes = [f"n{i}" for i in range(12)] + for seed in range(50): + a, b = select_node_paths(config, nodes, random.Random(seed), None) + assert a[-1] == b[-1] + assert not set(a[:-1]) & set(b[:-1]) + assert len(set(a)) == len(a) and len(set(b)) == len(b) + + +def test_multipath_config_validation(): + for bad in ( + {"split_strategy": "bogus"}, + {"split_weights": [1.0]}, + {"split_weights": [0.5, -0.5]}, + {"split_leg_rtt_ms": [50.0]}, + {"split_leg_window": 0}, + {"merge": "somewhere"}, + {"observed_legs": [2]}, + {"egress_observation": "partial"}, + {"merge": "common_exit", "num_nodes": 4}, + ): + with pytest.raises(ValueError): + _config(**bad).validate() + + +def test_common_exit_with_one_observed_leg_on_real_relays(): + config = _config( + name="conflux-like", + seed=21, + duration_s=3.0, + grace_period_s=1.0, + num_sessions=3, + real_rate=20.0, + crypto_algorithm="aes256gcm", + split_strategy="round_robin", + merge="common_exit", + observed_legs=[0], + egress_observation="merged", + adversaries=["global_observer"], + ) + result = run_experiment(config) + m = result.metrics + assert m["delivery_rate"] == 1.0 + assert m["leg_0_real_share"] == pytest.approx(0.5, abs=0.02) + assert m["leg_1_real_share"] == pytest.approx(0.5, abs=0.02) diff --git a/tests/test_wire_and_crypto.py b/tests/test_wire_and_crypto.py index 4512481..65cdc2f 100644 --- a/tests/test_wire_and_crypto.py +++ b/tests/test_wire_and_crypto.py @@ -79,8 +79,8 @@ def test_ecdh_handshake_derives_matching_keys(keyexchange): def test_forward_and_backward_keys_are_independent(): - """Reusing one key both directions would be a protocol weakness, not - just a missed optimization: confirm they're genuinely different.""" + """Reusing one key in both directions would be a protocol weakness: + confirm the two keys differ.""" priv_a, _pub_a = crypto_layer.generate_ephemeral_keypair() _priv_b, pub_b = crypto_layer.generate_ephemeral_keypair() key_fwd, key_back = crypto_layer.derive_key(priv_a, pub_b, "aes256gcm") @@ -177,8 +177,7 @@ def test_backward_layers_cannot_be_read_with_only_the_deepest_hops_key(): def test_wrap_layers_rejects_wrong_hop_local_circuit_id(): """Opening a layer with the wrong (e.g. path-wide-shared) circuit ID - as AAD must fail. This is what makes hop-local IDs actually binding - rather than cosmetic.""" + as AAD must fail. This is what makes hop-local IDs binding.""" keys = [b"0" * 32, b"1" * 32] cids = [b"11111111", b"22222222"] target_cell = wire.pack_data(wire.KIND_REAL, 1, b"payload")