diff --git a/reports/blend/README.md b/reports/blend/README.md index 181da7b..979fdd9 100644 --- a/reports/blend/README.md +++ b/reports/blend/README.md @@ -31,7 +31,7 @@ A node's exposure is not only *whether* it is deanonymized but *how soon*: if ea 2. **Observation rises with degree; eclipse falls with it.** With a fraction `f_adv` of adversarial peers placed at random, the share of honest nodes with at least one adversarial peer (**observed**) is `1 − (1 − f_adv)^degree` — it *increases* with degree (at `f_adv = 0.2`: 0.49 at degree 3, 0.83 at degree 8, 0.97 at degree 16). The share whose *every* peer is adversarial (**eclipsed**) is `f_adv^degree` — it *vanishes* with degree (at `f_adv = 0.5`: 0.125 at degree 3, 0.004 at degree 8, ~0 by degree 12). A worst-case (greedy-coverage) placement raises observation sharply above random — at degree 8 and `f_adv = 0.2` it takes it from 0.83 to **1.000**, meaning *every* honest node ends up with an adversarial peer (§3.3). -3. **Deanonymization is governed by path length, not degree.** A message is *deanonymized* when its whole blend path is adversarial; because relays are chosen blind to who is adversarial, this rate is the exact hypergeometric `≈ f_adv^blend_hops` — independent of peering degree, and driven down exponentially by lengthening the path. It is *fully* deanonymized when, in addition, the honest sender is directly peered with an adversary; that adds the `observed` factor, so full deanonymization — unlike whole-path capture — **rises with degree** and is amplified by a worst-case placement (§3.4). +3. **Deanonymization is governed by path length, not degree.** A message is *deanonymized* when its whole blend path is adversarial; because relays are chosen blind to who is adversarial, this rate is the exact hypergeometric `≈ f_adv^blend_hops` — independent of peering degree, and driven down exponentially by lengthening the path. It is *fully* deanonymized when, in addition, the honest sender is directly peered with an adversary; that adds the `observed` factor, so full deanonymization — unlike whole-path capture — **rises with degree** and is amplified by a worst-case placement. But capturing a cascade identifies the *message*, not the *originator*: with `a` of the sender's `d` peers adversarial the confidence that it originated rather than relayed is only `d/(2d − a)`, so one peer buys 0.53 and 90 % confidence needs essentially every peer — the eclipse condition. **Confident attribution is therefore rarer than the headline `full_deanon` figure by up to five orders of magnitude** (§3.4). 4. **Reliability degrades with churn; degree buys back coverage.** With a fraction `u` of unresponsive nodes that relay nothing, a message survives its cascade only if every relay forwards, so the delivery rate is `≈ (1 − u)^blend_hops` — longer paths are far more fragile. Unresponsive nodes are routing holes that can strand pockets during the final flood; a higher degree supplies redundant paths that keep coverage near-total. The effect is a genuine percolation threshold with a closed form: the flood travels only over the responsive sub-graph, which is site percolation on a `d`-regular graph and keeps a giant component only up to `u_c = 1 − 1/(degree − 1)`. Degree 3 therefore dies at exactly 50 % churn, degree 6 survives to 80 % and degree 8 to 86 % — each measured collapse landing on its predicted threshold (§3.5). @@ -137,7 +137,29 @@ Because relays are drawn blind to who is adversarial, the whole-path-adversarial So the blend-path length is the anonymity lever, and the number of hops needed to hold whole-path capture below a target ε is `blend_hops ≥ ln ε / ln f_adv`: to reach ε = 0.01 takes 3 hops at `f_adv = 0.2`, 5 hops at `f_adv = 0.33`, and 7 hops at `f_adv = 0.5` (**Fig 12**). Each hop, though, costs 1.5–2.7 s of latency depending on the degree (§3.1) and a `(1 − u)` reliability factor (§3.5) — the price of anonymity. -**Full** deanonymization adds the requirement that the honest sender is itself peered with an adversary, i.e. multiplies by `observed_frac`. Unlike whole-path capture, this **rises with degree** (2 hops, random placement): at `f_adv = 0.33` it climbs from 0.076 at degree 3 to 0.109 at degree 16 (approaching the whole-path ceiling of `0.33² = 0.109` as the sender becomes almost surely observed); at `f_adv = 0.5` it climbs from 0.219 to 0.250. A worst-case-coverage placement drives the `observed` factor up toward 1, pushing full deanonymization to the whole-path ceiling (**Fig 14**, **Fig 15**). This is the sharp form of the tension: a higher degree that speeds propagation and defeats eclipse *also* makes the sender almost certainly exposed whenever the path is captured. +**Full** deanonymization as measured here adds the requirement that the honest sender is itself peered with an adversary, i.e. multiplies by `observed_frac`. **That treats one adversarial peer as proof of origination, and it is not** — see the confidence analysis below, which is the more honest reading of the same event. Unlike whole-path capture, this **rises with degree** (2 hops, random placement): at `f_adv = 0.33` it climbs from 0.076 at degree 3 to 0.109 at degree 16 (approaching the whole-path ceiling of `0.33² = 0.109` as the sender becomes almost surely observed); at `f_adv = 0.5` it climbs from 0.219 to 0.250. A worst-case-coverage placement drives the `observed` factor up toward 1, pushing full deanonymization to the whole-path ceiling (**Fig 14**, **Fig 15**). This is the sharp form of the tension: a higher degree that speeds propagation and defeats eclipse *also* makes the sender almost certainly exposed whenever the path is captured. + +**How confident is the adversary, really?** Capturing the cascade tells the adversary *which* message it is following, not *who started it*. Seeing an honest node `X` transmit is consistent with two stories: `X` originated the message, or `X` received it from a peer and passed it on. The adversary separates them by not having seen the message arrive at `X` — certain if `X` originated it, but of probability `1 − a/d` if `X` merely relayed, since the delivering peer may have been one it cannot watch. With equal priors: + +**`confidence = 1 / (2 − a/d) = d / (2d − a)`** for `a` adversarial peers out of degree `d`. + +The path length does not appear: the conditioning event already fixes the relays as adversarial, so an honest `X` cannot be one of them for this message. What the formula says is that **one adversarial peer is worth very little** — 0.53 at degree 8, barely above the 0.5 prior — and confidence climbs only as the adversary comes to watch nearly all of the sender's links: + +| adversarial peers (of 8) | 1 | 2 | 4 | 6 | 7 | 8 | +|---|---|---|---|---|---|---| +| confidence | 0.53 | 0.57 | 0.67 | 0.80 | 0.89 | **1.00** | + +At degree 8, reaching 90 % confidence requires `a ≥ 8` — *every* peer adversarial. **That is the eclipse condition, not the observation condition**, and the measurement confirms it exactly: the fraction of honest nodes attributable at ≥ 0.9 equals `eclipsed_frac` to the last digit. The two differ enormously: + +| `f_adv` | observed | attributable at ≥ 0.9 | ratio | +|---|---|---|---| +| 0.10 | 0.569 | ~10⁻⁸ | 5.7 × 10⁷ | +| 0.20 | 0.832 | 2.6 × 10⁻⁶ | 3.3 × 10⁵ | +| 0.33 | 0.959 | 1.4 × 10⁻⁴ | 6.8 × 10³ | + +So **the `full_deanon` figures above overstate confident origination by up to five orders of magnitude**: they count an adversary that has glimpsed one of the sender's eight links as having identified the sender. Mean confidence over honest nodes is 0.47 at `f_adv = 0.2` — nearer a coin flip than an identification. The honest statement is that whole-path capture is common and *confident attribution of the originator is rare*, and that the second requires eclipsing the sender, which §3.3 shows a degree of 6–8 already makes negligible. + +Two caveats keep this from swinging too far the other way. This estimator uses **only the sender's own links**, so it is a *lower bound* on the adversary's capability: an honest peer that itself has adversarial peers leaks the message upstream too, and `observed_frac` is already 0.83 at `f_adv = 0.2` — most honest relays are themselves watched. The true value is bracketed by these two readings, and closing that gap is the open question flagged in §5. And confidence is a *threshold* choice: an adversary content with 0.53 attributes far more nodes than one demanding 0.9. ![Fig 12 — deanonymization vs path length](report-figures/12_deanon_vs_blendhops.png) *Fig 12 — Whole-path deanonymization rate vs blend-path length, one line per `f_adv` (log-y). Solid = simulated, dashed = the analytic `f_adv^blend_hops`; path length drives it down exponentially, independent of degree.* @@ -378,6 +400,7 @@ Because `s_max` is expressed against `D̂`, an estimator that runs low tightens ## 5. Validity and caveats +- **Attribution confidence is bracketed, not settled.** §3.4 gives two readings of the same event: `full_deanon` (any adversarial peer counts as identification) and the confidence-weighted rate (only the sender's own links count as evidence). The first is an upper bound on adversary capability, the second a lower bound, and at `f_adv = 0.2`, degree 8 they differ by five orders of magnitude. The truth lies between, because an adversary also learns from the sender's *neighbourhood* — an honest peer with adversarial peers of its own leaks the message upstream, and with `observed_frac` at 0.83 most honest relays are themselves watched. Resolving that needs a k-hop observability model rather than the 1-hop one used here; until it exists, design against the upper bound and read the lower bound as the floor. - **Structural adversary; timing correlation is the next study, and is blocked on cover traffic.** The adversary is modelled as controlling *nodes* and their peerings: it observes messages traversing relays it owns (deanonymization) and honest nodes it peers (observation). It does **not** perform timing or traffic-analysis correlation across honest relays. That is not an oversight but a sequencing constraint — a timing adversary is only meaningful against a network that emits **cover traffic**. With §3.10 that prerequisite is now in place, so the timing study is unblocked and is the next piece of work rather than a deferred one. It has a specific target: distinguishing a *relayed* message from a *blended* one by when it leaves a relay, and deciding between adding random jitter to the release and holding messages to a clock tick so they leave alongside blended traffic. §3.10 already supplies the reason to expect that comparison to be sharp — a relay holds 0.0014 messages on average, so a release almost never coincides with another, and timing carries essentially no cover of its own. An adversary that adaptively targets the transport path of a *specific* known sender is likewise outside the current model. - **Churn is modelled both ways; adversarial churn is not.** §3.5 removes nodes independently and §3.9 removes whole AS/region failure domains, at matched churn — and the correlated case turns out to be *gentler* on the live network, so the uncorrelated threshold is the conservative one. What remains outside the model is **adversarially placed** churn: an attacker who chooses which nodes to silence (a cut set rather than a random or clustered set) would be worse than either, and the worst-case placement machinery used for the adversary in §3.3 has no counterpart here. Regional churn also assumes equal-sized domains; real AS sizes are heavy-tailed, so a single dominant provider failing would remove a larger, less uniform slice than modelled. - **Exactly d-regular topology — by design, not by simplification.** Every node has exactly the same number of peers because the protocol requires it: the peer graph is derived by every node from one global seed, so the degree is a protocol constant rather than an emergent property. This is the topology the deployed network will have, so the results are not an idealisation of some heavier-tailed reality — a degree *distribution* would be a different protocol, not a more realistic model of this one. diff --git a/tools/simulators/blend/src/blend/adversary.py b/tools/simulators/blend/src/blend/adversary.py index 64224ed..b1578e8 100644 --- a/tools/simulators/blend/src/blend/adversary.py +++ b/tools/simulators/blend/src/blend/adversary.py @@ -73,6 +73,59 @@ def deanon_metrics(n: int, n_adv: int, observed_frac: float, blend_hops: int, return {"deanon_rate": float(deanon), "full_deanon_rate": float(deanon * observed_frac)} +def attribution_confidence(adv_peers: np.ndarray | int, degree: int) -> np.ndarray | float: + """How sure the adversary is that a node it saw transmitting is the message's **originator**. + + Capturing the whole cascade tells the adversary which message it is following, not who started + it. Seeing an honest node ``X`` transmit is consistent with two stories: ``X`` originated the + message, or ``X`` received it from a peer and passed it on. The adversary separates them by + *not* having seen the message arrive at ``X`` -- certain if ``X`` originated it, but of + probability ``1 - a/d`` if ``X`` relayed, since the delivering peer may simply have been one it + cannot watch. With equal priors that gives + + confidence = 1 / (2 - a/d) = d / (2d - a) + + for ``a`` adversarial peers out of degree ``d``. The relays do not enter: the conditioning event + already fixes them as adversarial, so an honest ``X`` cannot be one of them for this message. + + Note the ends: ``a = d`` (every link watched) gives certainty, and ``a = 0`` returns the 0.5 + prior -- though such a node is never observed transmitting at all, so callers should treat it as + unattributable rather than as a coin flip. + + This is a **lower bound on the adversary's capability**: it credits only the sender's own links. + An honest peer that has adversarial peers of its own also leaks the message upstream, so real + confidence is higher (see the report's caveat on neighbourhood observability). + """ + a = np.asarray(adv_peers, dtype=float) + return degree / (2.0 * degree - a) + + +def attribution_metrics(graph: Graph, adv_mask: np.ndarray, + thresholds: tuple[float, ...] = (0.5, 0.9, 0.99)) -> dict: + """Distribution of :func:`attribution_confidence` over the honest nodes of this placement. + + ``attributable_frac_`` is the share of honest nodes the adversary could name as originator + with confidence at least ``t`` -- the factor that should multiply the whole-path capture rate, + in place of the binary "has at least one adversarial peer". + """ + adv = adv_mask + honest = ~adv + n_honest = int(honest.sum()) + counts = np.add.reduceat(adv[graph.indices].astype(np.int32), graph.indptr[:-1]) + if n_honest == 0: + out = {"attribution_conf_mean": 0.0} + for t in thresholds: + out[f"attributable_frac_{int(t * 100)}"] = 0.0 + return out + a_honest = counts[honest] + conf = attribution_confidence(a_honest, graph.degree) + seen = a_honest >= 1 # never observed transmitting without a watched link + out = {"attribution_conf_mean": float(np.mean(np.where(seen, conf, 0.0)))} + for t in thresholds: + out[f"attributable_frac_{int(t * 100)}"] = float(np.mean(seen & (conf >= t - 1e-12))) + return out + + def _peers(graph: Graph, v: int) -> np.ndarray: return graph.indices[graph.indptr[v]:graph.indptr[v + 1]] diff --git a/tools/simulators/blend/src/blend/engine.py b/tools/simulators/blend/src/blend/engine.py index 5150b84..b6c2466 100644 --- a/tools/simulators/blend/src/blend/engine.py +++ b/tools/simulators/blend/src/blend/engine.py @@ -11,7 +11,12 @@ import dataclasses import numpy as np -from .adversary import adversary_metrics, deanon_metrics, place_adversary +from .adversary import ( + adversary_metrics, + attribution_metrics, + deanon_metrics, + place_adversary, +) from .config import WORSTCASE_MODES, SimConfig from .graph import build_graph from .metrics import adversary_row, deanon_row, propagation_row, traffic_row @@ -73,11 +78,13 @@ def run_graph_cell(base: SimConfig, prop_grid: list[tuple[int, int]], rng = np.random.default_rng(placement_seedseq(base, f_adv, mode, rep)) adv_mask = place_adversary(graph, f_adv, mode, rng, base.worstcase_max_n) adv = adversary_metrics(graph, adv_mask) + att = attribution_metrics(graph, adv_mask) adv_rows.append(adversary_row(base, f_adv, mode, rep, adv)) for bh in blend_hops_set: for R in redundancies: dz = deanon_metrics(graph.n, adv["n_adv"], adv["observed_frac"], bh, R) - deanon_rows.append(deanon_row(base, bh, f_adv, mode, rep, R, adv, dz)) + deanon_rows.append( + deanon_row(base, bh, f_adv, mode, rep, R, adv, dz, att)) traffic_rows: list[dict] = [] for rate in (cover_rates or []): diff --git a/tools/simulators/blend/src/blend/metrics.py b/tools/simulators/blend/src/blend/metrics.py index 5137e3f..0015e56 100644 --- a/tools/simulators/blend/src/blend/metrics.py +++ b/tools/simulators/blend/src/blend/metrics.py @@ -65,7 +65,8 @@ def adversary_row(config: SimConfig, f_adv: float, mode: str, placement_rep: int def deanon_row(config: SimConfig, blend_hops: int, f_adv: float, mode: str, - placement_rep: int, redundancy: int, adv: dict, deanon: dict) -> dict: + placement_rep: int, redundancy: int, adv: dict, deanon: dict, + att: dict | None = None) -> dict: """One row of the deanonymization table: a (placement x blend-path-length x redundancy) cell. ``blend_hops`` and ``redundancy`` come from the propagation grid, the rest from the adversary @@ -85,4 +86,10 @@ def deanon_row(config: SimConfig, blend_hops: int, f_adv: float, mode: str, "n_honest": adv["n_honest"], "observed_frac": adv["observed_frac"], **deanon, + **(att or {}), + # Confidence-weighted attribution: the whole-path capture rate times the share of nodes the + # adversary could actually name as originator at that confidence, rather than the binary + # "has an adversarial peer" that full_deanon_rate uses. + **({f"confident_deanon_{k.rsplit('_', 1)[1]}": deanon["deanon_rate"] * v + for k, v in att.items() if k.startswith("attributable_frac_")} if att else {}), } diff --git a/tools/simulators/blend/tests/test_deanon.py b/tools/simulators/blend/tests/test_deanon.py index aa42022..1b5c09d 100644 --- a/tools/simulators/blend/tests/test_deanon.py +++ b/tools/simulators/blend/tests/test_deanon.py @@ -98,3 +98,51 @@ def test_engine_emits_deanon_rows(): assert 0.0 <= row["full_deanon_rate"] <= row["deanon_rate"] + 1e-12 if row["f_adv"] == 0.0: assert row["deanon_rate"] == 0.0 # no adversary -> no deanonymization + + +# --- attribution confidence ----------------------------------------------------------------------- + +def test_attribution_confidence_endpoints_and_monotonicity(): + """d/(2d-a): the 0.5 prior with no watched links, certainty when every link is watched.""" + from blend.adversary import attribution_confidence + d = 8 + assert abs(float(attribution_confidence(0, d)) - 0.5) < 1e-12 + assert abs(float(attribution_confidence(d, d)) - 1.0) < 1e-12 + vals = [float(attribution_confidence(a, d)) for a in range(d + 1)] + assert all(b > a for a, b in zip(vals, vals[1:], strict=False)) + assert abs(vals[1] - 1 / (2 - 1 / 8)) < 1e-12 # one peer buys only ~0.53 + + +def test_confidence_does_not_depend_on_the_number_of_relays(): + """The conditioning event fixes the relays as adversarial, so an honest sender is not one of + them; the path length cannot enter the estimator.""" + import inspect + + from blend.adversary import attribution_confidence + src = inspect.getsource(attribution_confidence) + assert "blend_hops" not in src and "hops" not in src.split('"""')[2] + + +def test_high_confidence_attribution_equals_the_eclipse_condition(): + """At degree 8, 90% confidence needs a >= 8 -- every peer adversarial. So the confidence- + weighted attribution collapses onto eclipse, not onto observed.""" + from blend.adversary import adversary_metrics, attribution_metrics, place_adversary + g = build_graph(SimConfig(n_nodes=20000, degree=8, graph_seed=0)) + for f in (0.33, 0.5): + mask = place_adversary(g, f, "random", np.random.default_rng(0), 10**9) + am = adversary_metrics(g, mask) + at = attribution_metrics(g, mask) + assert abs(at["attributable_frac_90"] - am["eclipsed_frac"]) < 1e-12 + assert abs(at["attributable_frac_50"] - am["observed_frac"]) < 1e-12 # >=1 peer clears 0.5 + + +def test_confident_attribution_is_far_rarer_than_observation(): + """The correction that matters: observed_frac massively overstates confident attribution.""" + from blend.adversary import adversary_metrics, attribution_metrics, place_adversary + g = build_graph(SimConfig(n_nodes=20000, degree=8, graph_seed=1)) + mask = place_adversary(g, 0.2, "random", np.random.default_rng(1), 10**9) + am = adversary_metrics(g, mask) + at = attribution_metrics(g, mask) + assert am["observed_frac"] > 0.8 + assert at["attributable_frac_90"] < 1e-4 + assert at["attribution_conf_mean"] < 0.6 # one or two peers buys very little