Key recovery from state reuse: finding, fixing, and disclosing a flaw in Threshold ML-DSA's reference code
While reviewing the first fully ML-DSA-compatible threshold signature scheme, we identified a state-reuse issue in its reference implementation that could expose a signer's secret share. We worked with the team at PQShield on a fix, merged September 5.
Threshold ML-DSA (opens in a new tab) (Celi, del Pino, Espitau, Niot, Prest) is the first threshold signature scheme fully compatible with ML-DSA: T of N parties jointly produce a signature that any standard ML-DSA verifier accepts. Post-quantum threshold signing that integrates with existing verification infrastructure is closely aligned with our work at Project Eleven, so we follow this scheme closely. The authors published a Go reference implementation (opens in a new tab) alongside the paper.
Reviewing the implementation against the paper, we identified a requirement enforced by the paper's security model but not by the code: the state of a signing attempt must be used at most once. The API allowed the same round-one randomness to be used to produce responses under different challenges, which can reveal that party's secret share. We opened a PR (opens in a new tab), and after talking to Guilhem Niot at PQShield about the right design for the fix, it was merged on September 5.
The repository states, correctly, that it is an academic proof of concept and has not received careful review. The warning is accurate, but it does not change how the code is used in practice. This is the reference implementation of the first fully ML-DSA-compatible threshold scheme, which makes it the natural starting point for anyone building threshold ML-DSA, and it has already been independently reimplemented in other languages. A port may carry over the API without preserving the state-deletion discipline in the paper's pseudocode, while AI-assisted development can propagate implementation decisions between codebases faster than they are reviewed. For a new construction, the reference code functions as the practical specification, so defects should be fixed before they propagate. We have since spoken with teams that reimplemented the scheme, informed them of the issue, and helped audit their state handling.
Where the requirement comes from
Ignoring the parallel repetitions the scheme uses to improve its success probability, signing runs in three rounds. In round 1, each party samples fresh randomness r_i, computes a commitment w_i from it, and broadcasts a hash of the commitment. In round 2, the parties reveal their commitments. In round 3, each party derives a challenge c from the message and the aggregated commitment w = Σ w_i, and responds with
z_i = c · s_i + r_i
where s_i is the sum of the secret shares that party uses in the session. For readability, we use s_i here for what the paper denotes s_i^part, the partial secret used by party i in that session. Hashing before revealing prevents a rushing adversary from choosing its commitment after seeing the others. The randomness r_i serves the same purpose as a nonce in Schnorr: it masks the secret in the response. Reusing state in this scheme is analogous to nonce reuse in Schnorr. Two responses computed from the same r_i under two different challenges subtract to
z_i = c · s_i + r_i
z_i' = c' · s_i + r_i
z_i − z_i' = (c − c') · s_i + (r_i − r_i)
z_i − z_i' = (c − c') · s_i
The right-hand side no longer contains r_i: reuse cancels the masking randomness and leaves an equation directly in the secret share. ML-DSA works over the polynomial ring R_q = Z_q[X]/(X^256 + 1), so "divide by c - c'" means inversion in that ring rather than ordinary integer division. When c - c' is invertible, the equation gives s_i directly. If it is not invertible, the equation still leaks information about s_i, but does not necessarily determine it completely. None of this requires breaking the underlying lattice assumption: the information is exposed by reusing the masking randomness.
In the threshold setting, recovering one partial secret can be decisive. In the paper's dishonest-majority model, an adversary may corrupt T−1 parties. If those parties sign with one honest party, the corrupted parties collectively know every replicated base secret except the one indexed by their complement. That remaining secret is necessarily included in the honest party's partial secret. Recovering the honest partial secret therefore gives the adversary the missing contribution needed to reconstruct the aggregate ML-DSA signing secret.
The paper accounts for all of this. In its signing algorithms, the state a party keeps between rounds is deleted as it is used: round 2 replaces the round 1 entry with a round 2 entry, and round 3 deletes the entry permanently. The security proof relies on that deletion. The reduction assumes each commitment is consumed once and explicitly relies on a party's responses being used at most once.
What the code allowed
The implementation kept the round 1 state as a plain value:
type StRound1 struct {
wbuf []byte
cmtst []internal.FVec
}
The same state was an input to both Round2 and Round3. The old signature was Round3(sk, msgsrd2, strd1, strd2, params), and nothing invalidated it. Nothing prevented passing the same StRound1 to Round2 more than once. Each call could bind the same round-one commitment to a different message or set of peer commitments and return a different StRound2. Because Round3 separately accepted the original StRound1, the caller could then produce responses from the same round-one randomness under those different challenges. That is exactly the reuse in the subtraction above.
A network adversary cannot trigger this on its own; the caller has to mishandle the state. The protocol, however, makes mishandling more likely. The parameters target a success probability of at least one half for a protocol execution, so integrations need retry logic. Before this change, reusing the previous round-one state during a retry returned no error. If an integration retried using that state, a malicious co-signer could participate in the new Round2 with fresh commitments. The honest party's original randomness would then be used under a different challenge, and both responses would be visible in the transcripts.
The fix
The first revision of the PR had Round3 check and mark the round 1 state. In discussions with PQShield, we considered whether the check was worth having at all, since the library cannot fully guarantee single use: state restored from disk carries whatever "used" flag was persisted. The conclusion of that discussion was that the library should enforce what it can in memory in a more robust way: rather than round 3 "reaching" back to check round 1's state, the state should move forward one round at a time and be discarded as it is used. This mirrors how the paper's own algorithms handle state.
The attempt state, the randomness plus the commitment it opens to, is now allocated once, in Round1, and StRound1 and StRound2 each hold a pointer to it rather than a copy. On a successful Round2 call, the state is transferred into the returned StRound2 and the round 1 holder is marked spent. In Round3, the state is used through the StRound2 and the randomness is overwritten with zeros; the function no longer has a StRound1 parameter at all. A call that reuses either state fails with a new error, ErrStateAlreadyUsed.
The used flag sits behind the pointer so that every copy of a round state refers to the same flag. This is important because the repository's own examples copy states, and the libp2p chat demo keeps each attempt's state in a per-attempt map. With a by-value flag, a copy would pass a check its original had already failed. The flag is set with an atomic compare-and-swap, so if two goroutines race on copies of one attempt, exactly one of the two calls returns a response and the other fails with ErrStateAlreadyUsed. A Round3 call marks the state used even when it returns an error, so a failed attempt has to restart from Round1. The existing callers already restarted from Round1 on error, so their retry behaviour did not need to change. In Round2, input validation runs before the state is touched, so a malformed peer message there does not cost the caller an attempt.
The round states have no serialized form, which is intentional. Single use can only be enforced in memory: a caller that persists an attempt and restores it from disk, a snapshot, or a replicated process holds a state whose used flag says whatever was persisted. The invariant is the caller's responsibility, handled for instance by durably marking the state spent before a response leaves the process. The limitation raised during review is now an explicit, documented boundary in the README rather than an implicit one.
A few other fixes
While studying the implementation, we also found several malformed inputs that could panic the process instead of being rejected cleanly: a short round 1 hash could panic in Round2; wrong-length commitments could panic in Round3; wrong-length commitments or responses could panic in Combine; and an inconsistent active-signer count could make Round3 scan around the 8-bit signer mask and eventually index beyond the available hashes. These now fail cleanly instead. Peer messages are attacker-controlled input and should not be able to crash an implementation. Tests were added for all three parameter sets: reuse of each state, a state spent by a failed Round3, verification that the randomness is cleared rather than merely flagged, concurrent callers obtaining exactly one response, and the malformed-input cases across ML-DSA-44, ML-DSA-65 and ML-DSA-87.
Thanks
Thanks to PQShield, and to Guilhem Niot in particular, for the constructive discussion, review, and for merging the fix.