TL;DR. A plaintext observer of a WabiSabi round (the coordinator, its TLS-terminating CDN, or the VPS behind it) sees per-request timing. Tor pins each client to a long-lived guard, so that client's circuits share a near-constant latency offset — a fingerprint that re-links a round's anonymous outputs to its known inputs. On the live Tor network (8 guards, tor 0.4.9.8) the offset is real but small next to per-request jitter (build: 167 ms offset vs 461 ms jitter; guard RTT 23 vs 56 ms), so a single request barely beats chance — but that is a best case, measured from one fiber host: it omits each user's own last-mile latency, a stable per-client offset that only strengthens the fingerprint. The leak is amplification: a round makes many guard-pinned requests per client, and averaging recovers the offset. At 64 requests/client an 8-client round's effective anonymity set falls from ~7.5 to 4.6 (build) or 3.8 (residual guard RTT after the circuit-pool fix), ~45-50% of outputs uniquely linked; and a realistic 100 ms of last-mile latency spread alone (guard RTT, just q=8) drops it to 4.1, or 2.8 at 200 ms. It also compounds: ~84% of rounds fail and a client retries with the same inputs, so a coin's linkage rises from 27% in one round to 98% over 32 retries. The pre-established-circuit-pool fix removes build latency and reliability failures from the critical phase but not the guard fingerprint; closing that needs per-request timing randomization (restores ~7.2 of 8 at a multi-second cost) or per-request guard diversity.
The timing signal only groups requests by client. Turning that into an input-UTXO -> output-UTXO map needs the request contents, so observers differ:
| observer | sees plaintext (UTXOs)? | sees timing? | result |
|---|---|---|---|
| Tor exit node | no (TLS to coordinator) | only its sampled circuits | coarse traffic correlation, no UTXO map |
| coordinator | yes | yes | input <-> output UTXO map |
| Cloudflare / TLS terminator | yes (terminates TLS) | yes | input <-> output UTXO map |
| VPS host behind TLS termination | yes | yes | input <-> output UTXO map |
The mapping needs a plaintext observer: the coordinator, the TLS-terminating CDN (the popular Wasabi coordinators sit behind Cloudflare), or the VPS between them. Input registration reveals the outpoint, so input identities are known; output registration reveals the output script but is supposed to be unlinkable; the guard fingerprint is what re-attaches the anonymous output to its known input. A Tor exit sees only ciphertext on a sampled subset of circuits, so it can cluster circuits but not label them with coins. Everything below assumes the plaintext observer.
Tor pins a client to one guard (first hop) for months, so every circuit it builds
shares that hop: end-to-end latency has lower variance around a per-client
offset than latency across clients. Wasabi already isolates the phases onto
different circuits — ResolveLifetimeByIdentity gives the input side (alice)
1.5 h circuits, the output side (bob) 40 s circuits, and coordination
(satoshi) a persistent one
(WasabiHttpClientFactory.cs#L118-L129)
— but identity isolation does not change the guard, so a client's input and output
requests still ride the same first hop. That shared guard is the residual
fingerprint the plaintext observer measures and partitions on:
flowchart LR
subgraph CA["Client A (guard g_A)"]
A1[input registration] --> A2[reissuance x N] --> A3[output registration]
end
subgraph CB["Client B (guard g_B)"]
B1[input registration] --> B2[reissuance x N] --> B3[output registration]
end
A1 & A2 & A3 -->|"latency ~ offset(g_A) + jitter"| O[Plaintext observer]
B1 & B2 & B3 -->|"latency ~ offset(g_B) + jitter"| O
O -->|"average many samples per client"| P[Cluster requests by offset]
P --> L["Link anonymous output to the input with the closest offset"]
Two observables bound the effect:
build— full three-hop circuit build latency, what a client pays with no pre-established circuits (today's Wasabi). Large and guard-influenced, but also polluted by middle/exit choice.guard_rtt— one-hop (guard-only) handshake latency, a proxy for the residual RTT over an already established circuit. This is what is left after the circuit-pool fix: smaller, and a cleaner guard signal.
tor_probe.py
drives a real tor daemon over its control port (stem), pinning a fixed guard
per modelled client and timing two circuits: a full three-hop build through a
random middle/exit (build), and a one-hop circuit ending at the guard (guard_rtt):
dt = _timed_build(controller, [guard_fp, m, e], timeout) # build: 3-hop
...
dt = _timed_build(controller, [guard_fp], timeout) # guard_rtt: 1-hoptor_probe.py#L92-L109,
timed by
_timed_build
wrapping Controller.new_circuit(path=..., await_build=True).
The committed snapshot
(data/tor_latency_dataset.json,
tor 0.4.9.8, 8 guards, 12 build + 12 guard-RTT samples each) gives the headline
statistic — within-guard jitter over across-guard offset spread — computed in
variance_ratio:
| observable | across-guard offset spread | per-request jitter | jitter/offset ratio |
|---|---|---|---|
| build (3-hop) | 167 ms | 461 ms | 2.77 |
| guard_rtt (1-hop) | 23 ms | 56 ms | 2.48 |
The ratio > 1 is the honest headline: for a single request the jitter hides the guard. The attacker's job is to average it out.
One caveat cuts the other way, and it matters. This dataset was measured from a single, well-connected (fiber) host, so the across-guard spread above is only how far apart the guards are from that one machine — it sets every client's own last-mile latency to zero. Real users each sit behind their own link (fiber, cable, DSL, mobile, satellite, a distant continent) whose RTT is a stable per-client constant added to every guard-pinned request. That is exactly the kind of persistent per-client offset the fingerprint feeds on, and the measurement omits it entirely, so these ratios are a best case. §4 (P5) puts it back.
# fresh dataset (needs a running tor with an open control port):
python -m coinjoin_simulator.tor_timing.tor_probe --control-port 9051 \
--guards 8 --builds 12 --rtts 12 --out data/tor_latency_dataset.json
python run_tor_timing_study.py # writes tor_timing_results.jsonFor each anonymous output the attacker averages q guard-pinned requests per
client on each side, shrinking each side's jitter by sqrt(q):
input_latency = tuple(statistics.mean(draw(i) for _ in range(q_in)) for i in range(n_clients))
output_mean = tuple(statistics.mean(draw(c) for _ in range(requests_per_client)) for c in range(n_clients))It then matches every output against every known input with a softmax over the squared latency difference, and scores each output by its effective anonymity set — 2^entropy of the posterior, i.e. the equivalent number of equally likely candidate inputs (an effective set of 1 means uniquely linked):
var_in = observation.within_std_ms**2 / observation.input_requests_per_client
var_out = observation.within_std_ms**2 / observation.requests_per_client
tau2 = max(var_in + var_out, 1e-9)
...
for c in range(n):
out = observation.output_mean_ms[c]
posterior = _softmax_neg_sq([out - inputs[i] for i in range(n)], tau2)
eff_sets.append(effective_anonymity_set(posterior))q is the guard-pinned requests averaged per client per side — this is what lets
amplification beat the raw per-request jitter of §2.
P0 — amplification (no-defense build latency):
| q | effective set (of 8) | deanonymized |
|---|---|---|
| 1 | 7.52 | 23% |
| 8 | 6.72 | 22% |
| 16 | 6.18 | 32% |
| 32 | 5.63 | 37% |
| 64 | 4.61 | 45% |
P0 — amplification (pool-defense residual guard RTT):
| q | effective set (of 8) | deanonymized |
|---|---|---|
| 1 | 7.16 | 23% |
| 16 | 5.58 | 37% |
| 32 | 4.72 | 37% |
| 64 | 3.82 | 51% |
The residual guard RTT deanonymizes more than the raw build at high q, because
it is a cleaner guard signal once middle/exit jitter is averaged out. So the
circuit-pool fix does not remove this leak.
P1 — round size (synthetic, calibrated to the real dataset, fixed q): the
absolute effective set grows with the round (4.3 -> 41.9 as N goes 8 -> 100) and
the deanonymized fraction falls (38% -> 5%). Larger anonymity sets dilute the
per-request timing leak; small rounds are the worst case.
P2 — timing-randomization defense (build, q=64): a client-side random per-request delay closes the fingerprint, but must be large because amplification averages small delays away:
| extra delay | effective set (of 8) | deanonymized |
|---|---|---|
| 0 ms | 4.61 | 45% |
| 2000 ms | 5.62 | 31% |
| 8000 ms | 7.17 | 17% |
P3 — active amplification (reject weak signal): the coordinator acts only on high-confidence outputs; rejecting a round is free given the ~16% round success rate, so this is a covert amplifier:
| keep outputs with confidence >= | fraction kept | linkage on kept |
|---|---|---|
| 0.0 (all) | 100% | 39% |
| 0.3 | 42% | 62% |
| 0.6 | 15% | 94% |
| 0.9 | 4% | 97% |
P5 — access-latency sensitivity (guard RTT, q=8). The fiber measurement (§2)
sets each user's last-mile latency to zero. Adding a per-client access offset
drawn from a half-normal of the given spread — the diversity of real users' links
to their guard — leaves amplification fixed and only restores that missing stable
offset
(AccessLatencyModel):
| per-client last-mile spread | effective set (of 8) | deanonymized |
|---|---|---|
| 0 ms (the fiber measurement) | 6.40 | 22% |
| 25 ms | 6.08 | 27% |
| 50 ms | 5.41 | 30% |
| 100 ms | 4.12 | 41% |
| 150 ms | 3.32 | 50% |
| 200 ms | 2.84 | 57% |
This is the important row of the whole study: at guard RTT and only q=8 (before any retry amplification), a population with ~100 ms of last-mile spread — routine once mobile, DSL, congested Wi-Fi or geographically distant users are in the mix — already sees the effective set fall from 6.4 to 4.1, and a 200 ms spread halves it to 2.8 with 57% of outputs uniquely linked. The users with the worst connectivity, who most depend on Tor, are the most exposed.
Rounds fail ~84% of the time and a client retries with the same inputs, so one coin is re-registered many times. Each retry gives the observer another shot and sharpens its estimate of that input's offset, since it accumulates more guard samples for the same persistent client:
for r in range(n_rounds):
obs = generate_round(
model, n_clients=n_clients, requests_per_client=requests_per_client,
observable=observable, rng=rng,
input_requests_per_client=(r + 1) * requests_per_client,
)
res = attack_round(obs)
correct.append(res.output_correct[target])
confidence.append(res.output_confidence[target])An active observer need not wait passively: it commits once confidence crosses a threshold, forcing retries by dropping or delaying responses — indistinguishable from Wasabi's normal unreliability:
for r in range(n_rounds):
if confidence[r] >= confidence_threshold:
committed = True
active_linked = correct[r]
breakP4 — retry intersection, on real guards, guard RTT, q=8/round:
| retry rounds | per-round linkage | any-round linkage | commit-on-confidence linkage |
|---|---|---|---|
| 1 | 27% | 27% | 1% |
| 4 | 30% | 61% | 12% |
| 8 | 30% | 79% | 21% |
| 16 | 32% | 91% | 25% |
| 32 | 33% | 98% | 25% |
Two honest readings. Any-round (98%) is the capability when the observer can later confirm which attempt was right — realistic here, since these are its own coordinator rounds and the outputs get spent on-chain, so an independent later signal (spend clustering, amounts) confirms the winning guess. Commit-on-confidence (~25%) is what it gets acting purely on its own timing confidence with no external check: raw posterior confidence is weakly calibrated at guard RTT and q=8, so self-selection alone is limited. The active disruption (drop/delay to force retries, reject weak rounds) manufactures the many shots; it is free given Wasabi's unreliability and covert because forced failures look normal.
- The guard fingerprint is real but weak per request; the danger is the many guard-pinned requests a WabiSabi round already makes. This is the mixnet- literature pattern of exponential attacker advantage per marginal bit of leak, so the burden of proof is on the defender.
- Pre-established validated circuit pool (Kogman's transport fix): necessary — it removes build latency, SOCKS serialization, and reliability failures from the critical phase — but it leaves the guard-RTT fingerprint (P0 guard_rtt).
- Per-request timing randomization (P2) closes the fingerprint but is costly; per-request guard/circuit diversity attacks the root cause but fights Tor's guard design.
- Larger anonymity sets (P1) dilute the leak; small rounds are the worst case.
- Worse-connected users are more exposed (P5): the per-client last-mile RTT is a stable offset the fiber measurement omits, so the real-world leak is larger than the headline ratios suggest, and worst precisely for mobile, congested or geographically distant users — the ones who most rely on Tor.
- Any residual leak is covertly amplified by dropping low-confidence rounds (P3) and forcing retries of the same coin (P4), which is why "deanonymize this specific transaction or it is not a problem" is the wrong standard: the right unit is the coin over its whole retry history, not one round.
- Don't retry a failed round with the same inputs blindly: the multi-shot intersection (P4) is the strongest lever, so re-randomizing coin selection (or not re-registering the same coin after a suspicious failure) blunts it.
- Y. Kogman (nothingmuch). Reiterating centralized coinjoin ... deanonymization attacks, bitcoindev 2024-12-21, esp. the Jan 7 2025 reply on Tor circuit management, guard-node variance, and covert delay/drop amplification, and the audio description of the SOCKS stream-to-circuit serialization footgun and the pre-established-circuit-pool fix. https://groups.google.com/g/bitcoindev/c/CbfbEGozG7c/m/hDx-EOJvCAAJ
- P. Todd. CoinJoin Comparison. 2025. https://petertodd.org/2025/coinjoin-comparison
- R. Dingledine, N. Mathewson, P. Syverson. Tor: The Second-Generation Onion Router. USENIX Security 2004 (guard/path selection).
- WalletWasabi source (commit
1e6599f2): per-identity Tor circuit lifetimes (alice/bob/satoshi),WasabiHttpClientFactory.cs#L118-L129. - Wasabi transport issues:
WalletWasabi#6651(reliability, sketched fix),WalletWasabi#5441(late input registration ordering leak), PR #5889, PR #5827,WabiSabi#83; the ~16% large-round success-rate figure (bitcoin++ talk). - Code (all in the coinjoin-simulator
repo at commit
3786cbb): thetor_timing/package (tor_probemeasurement,latencymodels,round,partition,intersection,study), the runner, the dataset, and its tests.