120 lines
5.6 KiB
Python
Raw Normal View History

blend: minimum release interval, and jitter vs clock under a timing attack Two release designs at a MATCHED delay budget, so they differ only in how they delay: clock (batch at free-running ticks, the existing model) and jitter (each message waits its own exponential draw, mean set equal to the clock residual). Plus min_blend_delay, which forbids intervals shorter than it. Minimum interval -- a negative result, and provably so. A zero-length gap is instantaneous, so it never covers an arrival and is never sampled by the residual or by the size-biased interval. Excluding it therefore leaves the mean hold exactly unchanged, and with it blending and linkability; what it does change is E[S], the gap between release opportunities. Confirmed analytically and in simulation: 1.168s vs 1.167s at M=3. Timing attack -- the effective anonymity set of a release (perplexity of the observer posterior over which arrival produced it), plus MAP success, the chance its single best guess is right. The second matters because perplexity flatters a heavy tail: an exponential never fully excludes an old arrival, so it can look unlinkable while still being guessed correctly. At the baseline rate BOTH designs fail almost completely -- MAP success 0.98-0.99, effective set ~1. A relay handles so little traffic that in->out matching is trivial, which follows directly from the mixing~0 result. Traffic, not delay, is what buys timing protection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 12:41:39 +02:00
"""Release designs: a minimum interval, and jitter vs clock-tick release under a timing attack."""
import numpy as np
from blend.config import SimConfig
from blend.graph import build_graph
from blend.mixclock import mean_interval_s, mean_residual_ms, mix_wait
from blend.traffic import ReleaseClock, simulate_window, timing_linkability, traffic_metrics
def _run(mode="clock", M=30, lo=0, rate=1.0, slots=120, n=2000, seed=3):
cfg = SimConfig(n_nodes=n, degree=8, blend_hops=3, max_blend_delay=M, min_blend_delay=lo,
release_mode=mode, cover_rate_mult=rate)
g = build_graph(cfg)
w = simulate_window(g, cfg, np.random.default_rng(seed), slots)
return traffic_metrics(w, cfg), timing_linkability(w, cfg)
# --- the minimum interval -------------------------------------------------------------------------
def test_a_minimum_interval_does_not_change_the_mean_hold():
"""A zero-length gap is instantaneous, so it never covers an arrival and is never sampled.
Excluding it removes mass the residual never saw -- the mean hold is identical."""
for M in (3, 10, 30):
assert abs(mean_residual_ms(M, 0) - mean_residual_ms(M, 1)) < 1e-9
def test_a_minimum_interval_does_lengthen_the_gap_between_releases():
"""What it does change is E[S]: release opportunities become rarer."""
for M in (3, 10, 30):
assert mean_interval_s(M, 1) > mean_interval_s(M, 0)
def test_sampled_holds_match_the_analytic_mean_with_and_without_a_minimum():
rng = np.random.default_rng(0)
for M in (3, 30):
for lo in (0, 1):
got = float(np.mean(mix_wait(rng, M, 60_000, lo)))
assert abs(got - mean_residual_ms(M, lo)) < 0.05 * mean_residual_ms(M, lo)
def test_clock_respects_the_minimum_interval():
c = ReleaseClock(5, np.random.default_rng(0), min_blend_delay=2)
c.next_tick_at_or_after(200.0)
gaps = [b - a for a, b in zip(c._ticks, c._ticks[1:], strict=False)]
assert all(2 - 1e-9 <= g <= 5 + 1e-9 for g in gaps)
def test_the_minimum_does_not_measurably_change_anonymity():
"""Follows from the mean hold being unchanged: blending and linkability track it."""
a, ta = _run(lo=0)
b, tb = _run(lo=1)
assert abs(a["hold_seconds_mean"] - b["hold_seconds_mean"]) < 0.5
assert abs(ta["timing_linked_frac"] - tb["timing_linked_frac"]) < 0.05
# --- jitter vs clock ------------------------------------------------------------------------------
def test_both_designs_cost_the_same_delay():
"""The comparison is only meaningful at a matched latency budget."""
c, _ = _run("clock")
j, _ = _run("jitter")
assert abs(c["hold_seconds_mean"] - j["hold_seconds_mean"]) < 1.0
def test_timing_linkage_is_near_total_at_the_baseline_rate():
"""The headline: a relay handles so little traffic that in->out matching is trivial under
EITHER design, so neither provides timing protection at one message per second."""
for mode in ("clock", "jitter"):
_, t = _run(mode, rate=1.0)
assert t["map_success"] > 0.9
assert t["timing_set_mean"] < 1.3
def test_more_traffic_is_what_buys_timing_protection():
_, lo_rate = _run("clock", rate=1.0)
_, hi_rate = _run("clock", rate=64.0, slots=60)
assert hi_rate["timing_set_mean"] > lo_rate["timing_set_mean"]
assert hi_rate["map_success"] < lo_rate["map_success"]
def test_perplexity_flatters_jitter_more_than_the_best_guess_does():
"""A heavy tail keeps old arrivals nominally possible while contributing almost nothing, so
the effective-set advantage of jitter overstates its real advantage under a MAP attack."""
_, c = _run("clock", rate=64.0, slots=60)
_, j = _run("jitter", rate=64.0, slots=60)
set_gain = j["timing_set_mean"] / c["timing_set_mean"]
map_gain = (1 - j["map_success"]) / (1 - c["map_success"])
assert set_gain > 1.0 and map_gain > 1.0 # jitter wins on both
assert set_gain > map_gain # but the set measure overstates by how much
def test_knowing_the_tick_schedule_gains_the_adversary_nothing():
"""The clock design concedes the same whether or not the observer knows the tick times.
A silent tick implies nothing was pending at it, and any arrival older than the previous
release has demonstrably already left. So the candidate window bounded by the true previous
tick and the one bounded by the previous observed release contain the same arrivals. This
matters because it removes the obvious objection to the jitter-vs-clock comparison: the clock
design is not being handicapped by a generous adversary assumption.
"""
for n, rate, slots in ((2000, 1.0, 120), (200, 32.0, 90)): # sparse and dense
cfg = SimConfig(n_nodes=n, degree=8, blend_hops=3, max_blend_delay=30,
release_mode="clock", cover_rate_mult=rate)
g = build_graph(cfg)
w = simulate_window(g, cfg, np.random.default_rng(5), slots)
strong = timing_linkability(w, cfg, adversary_knows_schedule=True)
weak = timing_linkability(w, cfg, adversary_knows_schedule=False)
assert abs(strong["timing_set_mean"] - weak["timing_set_mean"]) < 1e-9
assert abs(strong["map_success"] - weak["map_success"]) < 1e-9
def test_jitter_beats_the_clock_at_a_matched_delay_budget():
"""The verdict, on the measure a heavy tail cannot flatter: at equal mean delay the
independent-draw design leaves the adversary's best guess wrong more often."""
_, c = _run("clock", rate=64.0, slots=60)
_, j = _run("jitter", rate=64.0, slots=60)
assert j["map_success"] < c["map_success"]
assert j["timing_set_mean"] > c["timing_set_mean"]