Marcin Pawlowski 57be929af7
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 18:00:00 +02:00

91 lines
3.9 KiB
Python

"""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