Skip to content

Instantly share code, notes, and snippets.

@m0wer
Last active July 9, 2026 12:54
Show Gist options
  • Select an option

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

Select an option

Save m0wer/e701bf5934474935c459b5e84e5d4e1b to your computer and use it in GitHub Desktop.
Whirlpool blind-signing-key tagging: a working PoC

Whirlpool blind-signing-key tagging: a working PoC

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.

1. How a mix is supposed to hide the link

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.

2. The leak: the key is never pinned

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

MixProcess.java#L159-L167

The honest reference server happens to generate one keypair in the Mix constructor and reuse it for the whole round:

this.keyPair = cryptoService.generateKeyPair();

Mix.java#L50-L54

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
Loading

3. The attack and result

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.

4. The key is not the only tag

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

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

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.

References

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