mirror of
https://github.com/logos-blockchain/research.git
synced 2026-08-07 11:43:20 +00:00
blend: attribution confidence -- one adversarial peer is not an identification
full_deanon counted any sender with an adversarial peer as identified. Capturing a
cascade tells the adversary WHICH message it is following, not WHO started it:
seeing an honest X transmit is equally consistent with X having received it from a
peer the adversary cannot watch. Separating the two gives
confidence = 1/(2 - a/d) = d/(2d - a)
for a adversarial peers of degree d. The path length does not enter -- the
conditioning event already fixes the relays as adversarial, so an honest X is not
one of them for this message.
The consequence is large. One peer of eight is worth 0.53, barely above the 0.5
prior, and 90% confidence needs a >= 8: every peer, which is the ECLIPSE condition
rather than the observation condition. Measured, attributable_frac_90 equals
eclipsed_frac exactly. At f_adv = 0.2, degree 8 that is 2.6e-6 against an
observed_frac of 0.83 -- the published figure overstates confident origination by
five orders of magnitude.
Stated in the report as a bracket rather than a replacement: full_deanon is the
upper bound on adversary capability, this is the lower bound, and the truth lies
between because the adversary also learns from the sender neighbourhood. Closing
that gap needs a k-hop observability model and is recorded as open in section 5.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f51630e509
commit
3c7fef64d0
@ -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 — 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
|
||||
<a id="s5"></a>
|
||||
## 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.
|
||||
|
||||
@ -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_<t>`` 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]]
|
||||
|
||||
|
||||
@ -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 []):
|
||||
|
||||
@ -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 {}),
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user