TL;DR. WabiSabi (Wasabi 2.0) uses keyed-verification anonymous credentials (KVACs) over secp256k1, but unlinkability rests on key consistency: every participant must be issued credentials under the same issuer parameters, committed in the Round ID. The client never checks this. The Round ID is whatever the coordinator returns to that client's own status poll, and a light client cannot independently verify other participants' ownership proofs (the coordinator supplies the very
scriptPubKeythey are checked against). So a malicious coordinator hands each target a distinct Round ID backed by a distinct issuer key; when the client presents its re-randomized credentials they verify only under that client's key, linking its whole input set to its whole output set. This PoC reproduces it with a real GGM algebraic MAC over secp256k1. Polling the Round ID from independent Tor circuits detects the equivocation and aborts, but only detects it: the coordinator never commits to the round parameters, so cheating stays unprovable and the round structure can still branch per client.
Each client registers its inputs with a
BIP-322
signed-message ownership proof that commits to the Round ID — a hash of the
round parameters, including the coordinator's KVAC issuer parameters (iparams,
analogous to a public key). It then gets its inputs' equal denomination issued
as a credential, re-randomizes it, and presents it at output registration.
Keyed verification means only the coordinator can check a credential, so
unlinkability depends on every client being issued credentials under the same
issuer key.
The credential is a GGM algebraic MAC
(Chase-Meiklejohn-Zaverucha), the family WabiSabi uses, over one attribute a
(the denomination):
issue: (U, U') = (U, (x0 + x1*a) * U) (U random != identity, issuer key (x0, x1))
rerandomize: (t*U, t*U') (t random scalar; client picks this)
verify: U' == (x0 + x1*a) * U (only the issuer holding (x0, x1) can check)
Re-randomization is what makes an honest round unlinkable: U becomes a fresh
random point every time, so the coordinator cannot tie a presentation back to
its issuance by the point value — it can only ask "which issuer key still
verifies this?" As long as there is one issuer key for the whole round, that
question is uninformative.
def mac_ggm(key: IssuerKey, amount: int, rng: random.Random) -> Mac:
u = ec.mul(_scalar(rng), ec.G)
u_prime = ec.mul((key.x0 + key.x1 * amount) % ec.N, u)
return (u, u_prime)
def mac_verify(key: IssuerKey, amount: int, mac: Mac) -> bool:
u, u_prime = mac
return u_prime == ec.mul((key.x0 + key.x1 * amount) % ec.N, u)
def mac_rerandomize(mac: Mac, factor: int) -> Mac:
u, u_prime = mac
return (ec.mul(factor, u), ec.mul(factor, u_prime))wabisabi_tagging.py#L82-L105
(docstrings and a null-point guard on mac_verify omitted for brevity) over a
self-contained
secp256k1 group
implemented for this repository's PoCs.
The Round ID is a hash of the coordinator's issuer parameters:
def compute_round_id(iparams: tuple[ec.Point, ec.Point], denomination: int, fee_rate: int) -> str:
x0_point, x1_point = iparams
scalar = ec.hash_to_scalar(
ec.point_bytes(x0_point), ec.point_bytes(x1_point),
denomination.to_bytes(8, "big"), fee_rate.to_bytes(8, "big"),
)
return format(scalar, "064x")Nothing forces the coordinator to serve the same issuer key to every connection. The PoC's malicious coordinator mints one on demand, per session:
def issuer_for_session(self, session_id: str) -> IssuerKey:
if not self.malicious:
return self._shared
key = self._sessions.get(session_id)
if key is None:
key = _random_issuer_key(self.rng)
self._sessions[session_id] = key
return keyIn WalletWasabi the client does recompute the Round ID and check it: RoundState
hashes the round parameters it was served — issuer parameters, timings, fee rate
— and asserts the served Id equals that hash
(RoundState.cs#L11-L45,
IsRoundIdMatching(); the coordinator side builds the same hash in
Round.cs#L148-L169).
But that only proves the served parameters are internally consistent, never
that they are the same parameters every other participant received: a distinct
but self-consistent Round ID per client passes the check unchanged.
Nor is this only a client bug. The ownership-proof check is non-binding for a
light client: it is verified against the scriptPubKey of a coin the
coordinator itself relays, not one the client independently confirmed is a real
unspent prevout
(MultipartyTransactionState.cs#L64-L68
verifies OwnershipProof against x.Coin.ScriptPubKey), so the coordinator can
populate a round with fake participants using throwaway keys
(WalletWasabi#5533).
The Round ID's stated purpose — catching exactly this per-client key divergence
— was raised and left unresolved before release
(WalletWasabi#5439,
WabiSabi#83).
sequenceDiagram
participant C0 as Client 0
participant C1 as Client 1
participant S as Malicious coordinator
C0->>S: status poll -> Round ID R0 (key K0, this session only)
C1->>S: status poll -> Round ID R1 (key K1, this session only)
C0->>S: register input (BIP-322 proof over R0)
C1->>S: register input (BIP-322 proof over R1)
C0->>S: present re-randomized MAC under K0
C1->>S: present re-randomized MAC under K1
Note over S: fresh Tor circuits, shuffled order
C1->>S: register output
C0->>S: register output
Note over S: each MAC verifies under ONE session key only<br/>client -> output map fully recovered
Linking tries every client's issuer key against every presented credential and keeps the ones that verify — mechanically identical to the Whirlpool per-key RSA attack, just over KVACs:
candidate_clients: list[tuple[int, ...]] = []
for _client_idx, mac in shuffled:
cands = tuple(
j for j in range(n_clients) if mac_verify(issuer_keys[j], denomination, mac)
)
candidate_clients.append(cands)The check probes the round status from several independent circuits and requires a single Round ID before any input is revealed:
def key_consistency_check(coordinator: Coordinator, *, n_probes: int = 3) -> bool:
round_ids = {
coordinator.round_id_for_session(f"probe-{i}") for i in range(max(1, n_probes))
}
return len(round_ids) > 1A malicious coordinator mints a fresh key per session (§2), so its probe
sessions return distinct Round IDs, the check fires, and run_round aborts
before any input is revealed
(wabisabi_tagging.py#L214-L229).
Running a 20-client round (python run_wabisabi_tagging_poc.py): the honest
coordinator serves one Round ID and every credential verifies under it, so the
full anonymity set holds. The malicious coordinator, nothing else changed,
serves 20 distinct Round IDs and every client's outputs verify only under its
own issuer key, recovering the exact client -> output map. With the
consistency check enabled against that same coordinator, the round aborts before
any input is revealed and nothing leaks. The tag is per-client, so a bigger
round just yields more singletons.
Per-client Round IDs are one instance of a more general gap, and the Round ID
hash makes it worse. WabiSabi reaches liveness against disruptors with blame
rounds: when a round fails, the coordinator restarts it with the honest subset,
each blame round pointing at its parent through a BlameOf field
(BlameRound.cs#L9-L17).
BlameOf is a field of the round state
(RoundState.cs#L11-L45),
but it is not among the fields hashed into the Round ID
(Round.cs#L148-L169),
so IsRoundIdMatching() accepts a blame round no matter which round it claims to
descend from.
That is the lever. A coordinator equivocates the initial rounds (per-client
issuer keys, §2), reads the input links, then fails the rounds and funnels the
isolated victims into a blame round. Because the Round ID does not commit to
BlameOf, a client cannot verify that the blame round it joined descends from
the specific round it just left; the victims forget their earlier ownership
proofs and continue as if in a valid continuation. The intended linear chain of
O(f) blame rounds becomes a tree the coordinator shapes per client, and each
branch looks internally well-formed. "Did every participant join the same
round?" is exactly what a client cannot answer alone. The cheap partial remedy
is to fold BlameOf into the hashed Round ID, so at least one victim of a
blame-round equivocation catches it through the existing IsRoundIdMatching()
check.
Two families of fix:
- Detect by redundancy (client-only). Poll the round status over several
independent, short-lived Tor circuits and require identical parameters before
revealing any input; a coordinator that equivocates cannot tell the redundant
probes apart from distinct clients. This is the
key_consistency_checkmodelled in §3. It has not been merged: proposed for WalletWasabi (WalletWasabi#14333, open) and merged into Kukks's Kompaktor (Kompaktor#4), both comparing issuer parameters across an isolated circuit and aborting on mismatch. Both are non-breaking and client-side, and neither closes theBlameOfgap of §4, since each blame round is internally self-consistent. - Prevent by commitment (protocol change). Have the coordinator commit to
and sign a single set of round parameters, including the
BlameOfancestry and its own address (WalletWasabi#5992), so every client verifies it joined the same round and an inconsistent Round ID becomes portable, non-repudiable proof of a cheating coordinator.
Only the second actually closes it. Without a coordinator commitment, detection
is neither prevention nor proof: a client that catches an inconsistency can
abort but cannot demonstrate to anyone else that the coordinator cheated. An
honest round and a tagged one remain externally identical and leave no evidence.
The commitment is a protocol-breaking change and has not been made; the deployed
and proposed mitigations are all client-side detection. A separate earlier fix
(WalletWasabi#8708)
restored ownership-proof publication that had regressed, but added no such
commitment.
Suppose every overt and covert tagging vector above were closed. A malicious coordinator still decides who participates. It can drop or "lose" messages to fail out every input except the ones it wants to track, then fill the rest of the round with its own inputs — an isolation attack that shrinks a victim's anonymity set at only mining-fee and liquidity cost, and amplifies the statistical tagging attacks by leaving fewer real targets. It is close to costless: coordination fees, or mining fees skimmed as excess, subsidize it, and a zero-fee coordinator simply lowers the opportunity cost of griefing.
This is not a protocol bug to be patched; keyed credentials cannot manufacture Sybil resistance, and in Danezis's statistical-disclosure model griefing as a means of denying privacy is inherent. The cryptography's actual job is narrower: to force any deanonymization to reduce to such a Sybil attack — whose cost is real coins and real fees — and to make coordinator equivocation leave portable proof. So the useful question is not "must you trust the coordinator?" (for liveness and Sybil resistance, you do) but "are its cheats provable?" Today, because the coordinator commits to nothing, they are not.
- Y. Kogman (nothingmuch). Reiterating centralized coinjoin (Wasabi & Samourai) deanonymization attacks. bitcoindev, 2024-12-21, plus the replies from S. Provoost, waxwing/AdamISZ and P. Todd. https://groups.google.com/g/bitcoindev/c/CbfbEGozG7c/m/hDx-EOJvCAAJ
- WabiSabi tagging gist: https://gist.github.com/nothingmuch/4d717e12e451ff4ce43474972e41c6ba
- Á. Ficsór, Y. Kogman, L. Ontivero, I. A. Seres. WabiSabi: Centrally Coordinated CoinJoins with Variable Amounts. IACR eprint 2021/206. https://eprint.iacr.org/2021/206
- M. Chase, S. Meiklejohn, G. Zaverucha. Algebraic MACs and Keyed-Verification Anonymous Credentials. ACM CCS 2014. https://eprint.iacr.org/2013/516 · doi:10.1145/2660267.2660328
- P. Todd. CoinJoin Comparison. 2025. https://petertodd.org/2025/coinjoin-comparison
- BIP-322: Generic Signed Message Format.
bip-0322.mediawiki - WalletWasabi source (commit
1e6599f2):RoundState.cs#L11-L45(IsRoundIdMatching,BlameOffield),Round.cs#L148-L169(Round ID hash, omitsBlameOf),BlameRound.cs#L9-L17(BlameOf),MultipartyTransactionState.cs#L64-L68(ownership proof verified against coordinator-suppliedscriptPubKey). - Mitigations:
WalletWasabi#14333(client-only round-state consistency verification, open),Kompaktor#4(same redundant-circuit detection). - Wasabi/WabiSabi issues:
WalletWasabi#5533(client can't verify peers' ownership proofs),WalletWasabi#5439(round-hash/tagging rationale raised pre-release),WalletWasabi#5992(round hash should commit to coordinator address),WalletWasabi#8708(ownership-proof publication restored),WabiSabi#83(partition/timing mitigation proposal). - Code (in the coinjoin-simulator
repository):
wabisabi_tagging.py,secp256k1.py,run_wabisabi_tagging_poc.py,tests/test_wabisabi_tagging.py.