TL;DR. Whirlpool's (Samourai) input/output unlinkability rests on a Chaumian RSA blind signature. The client accepts the RSA public key from whatever the coordinator pushes to it each round and never checks that every participant got the same key. A malicious coordinator can hand each of the five inputs a different keypair; every unblinded output signature then verifies under exactly one of them, so the coordinator reads the input -> output mapping straight off the output registrations. Swapping one shared key for one key per input turns a full 5-of-5 anonymity set into five uniquely linked outputs. This is a protocol flaw, not a bug, and it is undetectable by clients: the coordinator never commits to a key or signs the round parameters.
Whirlpool mixes a fixed denomination among five participants: each registers its input up front, blinds a fresh output token and gets it signed by the coordinator, then presents the unblinded signature to register its output over a new Tor circuit. Unlinkability depends entirely on the blind signature: the coordinator must not be able to connect the signature it later sees to the token it blindly signed.
This is textbook Chaumian blinding
against an RSA key (n, e, d):
blind: m' = H(token) * r^e mod n (r random, coprime to n)
sign: s' = m'^d mod n (coordinator; never sees H(token))
unblind: s = s' * r^-1 mod n (= H(token)^d mod n)
verify: s^e == H(token) mod n
The r^e / r^-1 pair cancels out once the coordinator raises the blinded
value to the d-th power, so s is a valid signature that is independent of
what the coordinator actually signed. That works as advertised. The problem is
which (n, e) gets used.
The client takes (n, e) from a notification the coordinator pushes per
connection, right before blinding, and never compares it against what the other
participants received:
// MixProcess.confirmInput()
byte[] publicKey = WhirlpoolProtocol.decodeBytes(confirmInputMixStatusNotification.publicKey64);
RSAKeyParameters serverPublicKey = ClientUtils.publicKeyUnserialize(publicKey);
this.blindingParams = clientCryptoService.computeBlindingParams(serverPublicKey);The honest reference server happens to generate one keypair in the Mix
constructor and reuse it for the whole round:
this.keyPair = cryptoService.generateKeyPair();but nothing forces that. A modified coordinator calls generateKeyPair()
once per input instead of once per round, and the client cannot tell:
sequenceDiagram
participant In0 as Input 0
participant In1 as Input 1
participant S as Malicious coordinator
In0->>S: register input
In1->>S: register input
S-->>In0: confirm-input: pubkey K0
S-->>In1: confirm-input: pubkey K1
In0->>S: blind(token0) under K0 -> sig0
In1->>S: blind(token1) under K1 -> sig1
Note over S: fresh Tor circuits, shuffled order
In1->>S: register output (token1, sig1)
In0->>S: register output (token0, sig0)
Note over S: sig1 verifies ONLY under K1, sig0 ONLY under K0<br/>input -> output map fully recovered
With one private key per input, linking is brute force: for each presented
(token, signature), try every input's public key and keep the ones that
verify. When the keys differ, exactly one matches.
whirlpool_tagging.py
implements the four RSA operations from §1 and both coordinator modes; the
linking loop is the whole attack:
for _input_idx, token, signature in shuffled:
cands = tuple(i for i in range(n_participants) if rsa_verify(token, signature, given_pubs[i]))whirlpool_tagging.py#L239-L244
Running a real 5-of-5 mix with 512-bit RSA (python run_whirlpool_tagging_poc.py):
the honest shared-key coordinator leaves every output consistent with every
input, preserving the full anonymity set. Swapping in the per-input-key
coordinator, with nothing else changed, makes every output verify under exactly
one key and recovers the exact TxOut -> TxIn map:
output #0 -> input #4 [OK]
output #1 -> input #3 [OK]
output #2 -> input #1 [OK]
output #3 -> input #0 [OK]
output #4 -> input #2 [OK]
The leak is independent of pool size: the tag lives in the per-input key, so a bigger pool just yields more singletons.
The blind key is only the most obvious per-input channel. The same "coordinator hands you an opaque value, you hand it back on a fresh identity" pattern shows up elsewhere and needs no cryptography at all.
Opaque round identifiers. The coordinator assigns each client a mixId at
confirm-input
(MixProcess.java#L164)
and, at output registration, the inputsHash it must echo back
(#L203)
inside the RegisterOutputRequest submitted over the new identity
(#L207-L208).
Both are coordinator-chosen. The only consistency check happens much later, at
signing, comparing the stored inputsHash against the real transaction
(#L321-L325)
— by which point the output was already linked over the fresh circuit, and even
an aborted round has leaked the map. A distinct inputsHash per input is a tag
with zero crypto.
The signature is never verified. The client stores the blind signature it
gets
(#L187)
and unblinds it
(#L206)
but never checks it is valid: ClientCryptoService exposes only blind and
unblind, no verify
(ClientCryptoService.java).
So even with a correctly pinned key, a coordinator can embed a per-input factor
in the signature and recognize it on the output side; closing that needs the
client to actually verify (one rsa_verify line).
The maintained successor, Ashigaru Terminal, has fixed the key-pinning half: it
loads the blind-signature key from a shipped resource per network
(MixClient.java#L71-L96)
and outright rejects any server-pushed key
(MixProcess.java#L152-L159,
"not expected to receive public key for blind signature from whirlpool server").
The per-input-key attack of §2-§3 no longer applies there.
The rest is unchanged. The same build still takes a coordinator-chosen
inputsHash and checks it only at signing
(MixProcess.java#L203),
and its ClientCryptoService still offers no signature verification
(ClientCryptoService.java)
— so the §4 channels remain. More fundamentally, the coordinator still never
commits to a key or signs the round parameters, so a client cannot prove to
anyone that a round was run honestly: an honest round and a tagged one are
externally identical and leave no evidence. Key tagging is not even the subtlest
option, since the coordinator also chooses the sets of five and can simply group
a target with peers it already controls, with full deniability.
The complete fix is the standard one: commit to a single round key before any input is revealed and sign it together with the round parameters, so every client verifies it got the same key and a lying coordinator is non-repudiably caught (the WabiSabi mitigation in the sibling writeup). RSA makes per-round key derivation awkward, one of several reasons it was the wrong primitive here.
- Y. Kogman (nothingmuch). Reiterating centralized coinjoin (Wasabi & Samourai) deanonymization attacks. bitcoindev mailing list, 2024-12-21. https://groups.google.com/g/bitcoindev/c/CbfbEGozG7c/m/hDx-EOJvCAAJ
- Whirlpool tagging gist: https://gist.github.com/nothingmuch/74d0b0bd63a2d663efce5e8a82d32bca
- D. Chaum. Blind Signatures for Untraceable Payments. CRYPTO 1982, pp. 199-203. PDF.
- Archived Whirlpool client (Samourai), commit
bc4110df:MixProcess.javablind-key extraction,ClientCryptoService.java(blind/unblind, no verify). - Archived Whirlpool server,
Mix.java: one key generated per mix. - Ashigaru Terminal (maintained successor). Its official code repository is
Tor-only (
ashicode…onion/Ashigaru, linked from https://ashigaru.rs); the links here are to a clearnet mirror at commit2e6e54e2, whose Whirlpool client matches the described behavior:MixClient.javapinned key loader,MixProcess.javarejects pushed key. - Code (in the coinjoin-simulator
repository):
whirlpool_tagging.py,run_whirlpool_tagging_poc.py,tests/test_whirlpool_tagging.py.