I built a threshold signature library, audited my own code, and found it was forgeable. So I forged it. Then I rebuilt it properly.
What I set out to build
A t-of-n threshold Ed25519 signer. The idea is that a private key is split across several parties, and you need some threshold t of them to cooperate to produce a signature. No single party ever holds the whole key. It's the primitive behind custody systems, MPC wallets, and any place where "one machine holds the key" is an unacceptable single point of failure.
The existing reference for this on Solana is ZenGo's solana-tss, but it's MuSig2 (n-of-n, no DKG) and only signs native SOL transfers, not SPL token transactions. A real custody setup needs t-of-n, distributed key generation, and token transfers, so I started implementing one. That's where I reached for naive threshold Schnorr, and that's the scheme that turned out to be forgeable.
I got a version working end to end on devnet. Then I sat down to actually audit it and it fell apart in two places.
The first defect: the coordinator held the whole key
In my design, each node returned its final secret share to the coordinator, which collected all of them. That means the coordinator saw at least t shares and could reconstruct the private key and sign alone.
A threshold system where one party can reconstruct the key is not a threshold system. It's a key in one place with extra steps.
The fix, in the rebuild, is a Pedersen DKG where no party ever holds the group secret: each participant ends with only its own share sᵢ, and the group public key is assembled in the exponent, never the scalar. Here's the actual assembly, straight from dkg.rs` — notice it only ever adds public commitment points, never shares:
rust
// group_public = Σ_j φ_{j,0} over all participants (peers + self).
let mut group_public = secret.own_commitment.0[0];
for package in &round1_packages {
group_public = group_public + package.commitments.0[0];
}
The second defect: the signing scheme was forgeable
I'd implemented naive interactive threshold Schnorr: one nonce per signer, sum the commitments, hash for the challenge, sum the partial signatures. There's no binding factor anywhere. That construction is broken by the ROS attack: given enough concurrent signing sessions, an attacker can treat the per-session challenges as a linear system and solve it for a forgery. This isn't folklore. It's the Benhamouda–Lefranc–Loss–Orsini–Raykova result from 2020, and it runs in polynomial time.
Forging it
Saying "this is theoretically forgeable" proves nothing. So I kept the old scheme's math under legacy/, reduced to its core (the threshold aggregate reduces to single-key concurrent Schnorr, so the oracle models that single key directly), and ran the actual ROS solver against it.
The attack opens 256 concurrent sessions, uses the binary-decomposition trick to pick each session's message after seeing every commitment, and solves for a forgery.
It works. A valid signature on an unsigned message, in about 50 milliseconds. That number, and the proof that the message is outside the signed set, are committed in legacy/results/ros_forgery.txt.
The rebuild, and why it resists the attack
The rebuild follows RFC 9591 FROST (Ed25519, SHA-512), hand-rolled on curve25519-dalek. The reason FROST resists what the old scheme didn't is one line: each signer's nonce is bound by a factor that hashes in everyone's commitments before anyone reveals a partial signature.
plaintext
ρ_j = H1(group_public ‖ msg ‖ commitment_list ‖ id_j) // the binding factor
R = Σ_j (D_j + ρ_j · E_j) // bound group commitment
z_i = d_i + (ρ_i · e_i) + (λ_i · c · s_i) // partial signature
The challenge is no longer linear in quantities the attacker controls before committing, so the ROS solver has no linear system to solve.
Running the same solver against the FROST implementation returns NoSolution frost-core/tests/ros_resistance.rs. The attack fails because the binding factors remove the linear structure that the ROS solver depends on. That's the whole point of the audit-then-rebuild: the forgery is committed, reproducible evidence that the original was broken, and the new one is checked against the standard rather than asserted to be correct.
The implementation is hand-rolled on curve25519-dalek rather than wrapping an existing FROST library. That also means it needs stronger verification than "the tests pass", so the implementation is checked against independent references at every stage instead of assuming the implementation is correct.
RFC 9591 known-answer vectors are checked byte-for-byte, including intermediate values (binding factors, aggregate commitment, partial signatures, and final signature), so failures identify the first divergent step instead of only the final output.
A differential test compares the implementation against an independent FROST library across 10,000 randomized
(t, n)configurations. The reference implementation is used only during testing and is not part of the shipped dependency graph.Invalid partial signatures identify the offending participant (
Err(Culprit(id))), allowing the coordinator to distinguish protocol failures from malicious or faulty signers.
The bug the fuzzer found
After I'd frozen the code, I ran a coverage-guided fuzzer, one target per deserializer, 104 million-plus executions. It found a bug. My group.rs accepted non-canonical point encodings: two distinct byte-strings decoding to the same group element. That's a malleability vector, and curve25519-dalek's decompress() silently canonicalizes exactly those inputs, so I'd never have caught it by hand.
The fix is RFC 8032 strict decoding: re-encode the point and reject on any byte mismatch.
rustdecompress()
pub fn from_compressed(b: [u8; 32]) -> Result<Self, Error> {
let point = CompressedEdwardsY(b)
.decompress()
.ok_or(Error::InvalidPointEncoding)?;
// RFC 8032 strict decoding: reject NON-CANONICAL encodings (a y-coordinate
// >= the field prime, or a set sign bit on the x = 0 points). dalek's
//accepts and silently canonicalizes these, which would let
// two distinct byte strings denote the same point (a malleability vector).
// (Found by the Phase 4 coverage-guided fuzz run.)
if point.compress().to_bytes() != b {
return Err(Error::InvalidPointEncoding);
}
if point.is_torsion_free() {
Ok(GElement(point))
} else {
Err(Error::NonPrimeOrderPoint)
}
}
This is a deserialization malleability issue rather than a key-recovery vulnerability, so that's how it's documented. The important part wasn't the bug itself—it was that coverage-guided fuzzing found it after the implementation had already been considered complete, with the failing inputs preserved as regression tests.
What came out of it
The repository now contains both versions:
- the original threshold Schnorr implementation under
legacy/, together with a reproducible ROS forgery; - an RFC 9591 FROST implementation validated against published test vectors and differential tests;
- fuzz tests that uncovered and fixed a non-canonical point decoding issue before release.
The point wasn't simply to produce another signing library. It was to build one, break it under audit, fix it, and leave the evidence of each step in the repository.
shell
cargo run --example in_process_2of3
Repository:
https://github.com/umangPokhriyall/frost-ed25519-kit
Threat model:
https://github.com/umangPokhriyall/frost-ed25519-kit/blob/main/docs/THREAT-MODEL.md