Skip to content

Instantly share code, notes, and snippets.

@m0wer
Created July 9, 2026 16:33
Show Gist options
  • Select an option

  • Save m0wer/2e2f0a3241d8e7651ffdda4435e729ec to your computer and use it in GitHub Desktop.

Select an option

Save m0wer/2e2f0a3241d8e7651ffdda4435e729ec to your computer and use it in GitHub Desktop.
WabiSabi Round ID tagging: a working PoC

WabiSabi Round ID tagging: a working PoC

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 scriptPubKey they 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.

1. How a round is supposed to hide the link

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.

2. The leak: the Round ID is asserted, not verified

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")

wabisabi_tagging.py#L108-L117

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 key

wabisabi_tagging.py#L139-L147

In 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
Loading

3. The attack, and the check that detects it

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)

wabisabi_tagging.py#L245-L251

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) > 1

wabisabi_tagging.py#L155-L165

A 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.

4. The deeper problem: a tree of rounds

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.

5. State of the fix, and why it stays unprovable

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_check modelled 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 the BlameOf gap 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 BlameOf ancestry 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.

6. The irreducible floor: griefing and Sybil

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.

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment