mirror of
https://github.com/logos-blockchain/research.git
synced 2026-08-07 19:53:10 +00:00
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>
91 lines
3.9 KiB
Python
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
|