Two Signatures, One Nonce, Zero Secrets: A FROST Crypto Bug Story
This is a sanitized technical writeup based on a responsibly disclosed and resolved bug bounty report. Vendor-identifying details, exact…
Two Signatures, One Nonce, Zero Secrets: A FROST Crypto Bug Story
This is a sanitized technical writeup based on a responsibly disclosed and resolved bug bounty report. Vendor-identifying details, exact endpoints, operational values, and private program communications have been intentionally omitted. The goal of this article is educational: to explain how a small implementation mistake in a threshold-signing system can become a serious cryptographic failure.
There are bugs that shout.
SQL injection shouts. Command injection shouts. Authentication bypasses usually walk into the room wearing a bright red jacket.
But cryptographic bugs?
Cryptographic bugs whisper.
They hide inside assumptions. They live between protocol design and implementation detail. They wait inside “safe retry” logic, inside helper functions, inside a hash that almost includes everything it should.
This is the story of how a missing field in a retry fingerprint created a dangerous nonce-reuse condition in a FROST threshold-signing implementation.
At first, it looked like a small omission.
Then it became two signatures.
Then it became algebra.
Then the secret share started falling out.

The Setting: A Threshold Signature Vault
The target was a real-world threshold-signing system using FROST, a threshold Schnorr signature scheme.
In a normal signing system, one private key signs a message.
In a threshold signing system, the private key is split into shares. Multiple participants cooperate to produce a valid signature, but no single participant should be able to sign alone. More importantly, no coordinator should be able to steal a participant’s long-term secret share.
That is the whole point of threshold cryptography.
The system should be resilient even if the coordinator is malicious.
That detail matters.
Because in FROST, the coordinator is not supposed to be blindly trusted. The implementation must assume the coordinator may control signing jobs, reorder messages, trigger retries, and attempt weird edge cases.
So when reviewing this kind of system, I was not only asking:
“Does the happy path work?”
I was asking:
“What happens when the coordinator behaves like an attacker?”
And that question changed everything.
The Ancient Rule: Never Reuse the Nonce
Schnorr-style signatures have one rule that should be written in fire:
Never reuse a nonce.
A signing nonce must be fresh, unique, and bound to one exact signing context.
Not “mostly” bound. Not “message-only” bound. Not “probably safe because this is a retry.”
One exact context.
Why?
Because Schnorr signatures are linear. That is part of their elegance, but it also means nonce reuse can be catastrophic.
A simplified signature share can be imagined like this:
s = k + c · x
Where:
k = signing nonce
c = challenge
x = secret share
s = signature share
If the same nonce k is used twice with different challenges, the attacker gets two equations:
s1 = k + c1 · x
s2 = k + c2 · x
Subtract them:
s1 - s2 = (c1 - c2) · x
Now solve for x:
x = (s1 - s2) / (c1 - c2)
That is the nightmare.
The long-term secret share becomes recoverable from two signature shares.

Then immediately after the formula:

The First Clue: Retry Logic
Distributed systems retry things.
Network calls fail. Coordinators resend requests. Operators need to handle repeated jobs without breaking the signing flow.
So the implementation had a retry mechanism.
The idea was reasonable:
- A nonce is generated.
- The nonce is used for a signing job.
- A fingerprint of that signing job is stored.
- If the same request comes again, treat it as a retry.
- If a different request tries to use the same nonce, reject it.
That is exactly the kind of protection you want.
But the entire security of that mechanism depends on one question:
What exactly goes into the fingerprint?
Because if the fingerprint forgets a field that affects the cryptographic challenge, an attacker may be able to change that field while still passing the retry check.
And that is where I found the bug.
The Missing Field
The retry fingerprint was intended to identify whether a repeated signing job was truly the same job.
In simplified form, the vulnerable logic looked like this:
func retryFingerprint(job *SigningJob) []byte {
h := sha256.New()
writeBytes(h, job.Message)
return h.Sum(nil)
}
At first glance, this looks normal.
The message is included. The hash is collision resistant. The fingerprint exists.
But the signing context was bigger than just the message.
A signing job also had fields like:
type SigningJob struct {
Message []byte
VerifyingKey []byte
AdaptorPublicKey []byte
KeyshareID string
Commitments map[string]Commitment
}
The dangerous part was that fields like VerifyingKey and AdaptorPublicKey were not included in the retry fingerprint.
That meant two signing jobs could have:
same message
same nonce commitment
different verifying key
same retry fingerprint
And that should make every cryptography engineer uncomfortable.

Why the Verifying Key Matters
In Schnorr/FROST-style signing, the challenge is not just based on the message.
Conceptually, the challenge depends on context such as:
challenge = H(R || verifying_key || message)
Where:
R = nonce commitment / group commitment
verifying_key = public key for the signing group
message = message being signed
So if an attacker can keep the same nonce but change the verifying key, the challenge changes.
That gives:
s1 = k + H(R || VK1 || M) · x
s2 = k + H(R || VK2 || M) · x
The nonce k is the same.
The challenges are different.
The secret share x is now exposed through algebra.
This is why the verifying key is not “just metadata.”
It is part of the cryptographic reality of the signature.
If the retry protection does not bind the nonce to the verifying key, then the nonce is not safely bound to the signing context.
The Attack Shape
The exploit idea was simple and dangerous.
Imagine a malicious coordinator interacting with a signing operator.
First, the coordinator asks for a fresh nonce commitment.
Then it sends the first signing job:
{
"message": "hello-world",
"verifying_key": "VK1",
"nonce_commitment": "N"
}
The operator signs and returns a signature share:
share_1
Then the malicious coordinator sends what looks like a retry.
Same message. Same nonce commitment. But different verifying key.
{
"message": "hello-world",
"verifying_key": "VK2",
"nonce_commitment": "N"
}
Because the retry fingerprint only covered the message, both jobs produced the same fingerprint.
In simplified form:
fingerprint1 := H(message)
fingerprint2 := H(message)
fingerprint1 == fingerprint2
So the second request could be treated as a safe retry.
But cryptographically, it was not the same job.
It was a different challenge.
The signing operator produced another signature share:
share_2
Now the attacker has:
same nonce
same message
different verifying keys
different challenges
two signature shares
And that is enough to recover the secret share.

The Moment the Bug Became Real
A lot of crypto bugs die at the theory stage.
You may find suspicious logic, but then discover:
- the field is validated somewhere else,
- the endpoint is unreachable,
- the coordinator cannot actually control the value,
- the nonce cannot actually be reused,
- the second request is rejected before signing.
So I had to prove the full chain.
The real question was:
Can a signing operator be made to produce two signature shares using the same nonce but different verifying keys?
The answer was yes.
The proof followed this pattern:
1. Obtain nonce commitment N
2. Submit signing job using message M and verifying key VK1
3. Receive signature share s1
4. Submit another job using the same N and M, but verifying key VK2
5. Receive signature share s2
6. Use s1 and s2 to recover the secret share mathematically
The vulnerable behavior was not just that the second request was accepted.
The important part was that the second request produced a second signature share under a different challenge.
That is where the security boundary broke.

The Algebraic Extraction
Let’s keep the math readable.
Suppose the operator produces two signature shares:
s1 = k + c1 · x
s2 = k + c2 · x
Where:
k = reused nonce
c1 = challenge for verifying key VK1
c2 = challenge for verifying key VK2
x = operator secret share
Subtract:
s1 - s2 = (k + c1 · x) - (k + c2 · x)
The nonce cancels out:
s1 - s2 = c1 · x - c2 · x
Factor out x:
s1 - s2 = (c1 - c2) · x
Recover the secret share:
x = (s1 - s2) / (c1 - c2)
In FROST, the full equation includes additional pieces such as binding factors and Lagrange coefficients, but the core failure is the same:
Reusing nonce material across different signing challenges leaks the signer’s secret share.
That is the beauty and danger of this bug.
No brute force.
No guessing.
No “maybe.”
Just algebra.

Why This Was Critical
This was not just a “nonce reuse may be bad” observation.
The impact was secret-share recovery.
In threshold signing, each operator’s secret share is part of the system’s long-term cryptographic trust. If an attacker recovers enough shares, the threshold security model collapses.
The consequences can include:
- recovery of a signing operator’s secret share,
- compromise of threshold signing assumptions,
- ability to forge future signatures if enough shares are compromised,
- loss of trust in the signing group,
- potentially severe asset-security impact depending on what the threshold key controls.
This is the part that makes crypto bugs different from many normal application bugs.
If a session token leaks, you rotate the session.
If a password leaks, you reset it.
If long-term signing material leaks, the system may need deeper recovery steps: key rotation, share regeneration, operational review, and careful investigation of whether signatures could have been forged.
A cryptographic secret is not like a normal variable.
Once exposed, it cannot be unexposed.

The Real Lesson: The Hash Forgot the Context
The vulnerability came down to this:
func retryFingerprint(job *SigningJob) []byte {
h := sha256.New()
writeBytes(h, job.Message)
return h.Sum(nil)
}
The function was not “wrong” because SHA-256 is weak.
SHA-256 was fine.
The problem was that the hash did not include the full security context.
A secure retry fingerprint needs to bind the nonce to every field that can affect the signing challenge or signing semantics.
A safer version looks like this:
func retryFingerprint(job *SigningJob) []byte {
h := sha256.New()
writeBytes(h, job.Message)
writeBytes(h, job.VerifyingKey)
writeBytes(h, job.AdaptorPublicKey)
return h.Sum(nil)
}
Even better, the implementation should treat the signing context as an immutable object:
type SigningContext struct {
Message []byte
VerifyingKey []byte
AdaptorPublicKey []byte
ParticipantSet []string
KeyshareID string
}
Then the nonce should be bound to that entire context:
func nonceUsageFingerprint(ctx SigningContext) []byte {
h := sha256.New()
writeBytes(h, ctx.Message)
writeBytes(h, ctx.VerifyingKey)
writeBytes(h, ctx.AdaptorPublicKey)
writeStringList(h, ctx.ParticipantSet)
writeString(h, ctx.KeyshareID)
return h.Sum(nil)
}
The security property should be:
same nonce + different signing context = reject
Not:
same nonce + same message = probably retry
That difference is everything.
How the Fix Should Behave
After the fix, the retry check should behave like this:
oldFingerprint := nonce.StoredFingerprint
newFingerprint := retryFingerprint(incomingJob)
if oldFingerprint != nil && !bytes.Equal(oldFingerprint, newFingerprint) {
return error("nonce already used for a different signing job")
}
Now the attack fails.
First request:
message = M
verifying_key = VK1
nonce = N
fingerprint = H(M || VK1 || adaptor_key)
Second request:
message = M
verifying_key = VK2
nonce = N
fingerprint = H(M || VK2 || adaptor_key)
The fingerprints differ:
H(M || VK1 || adaptor_key) != H(M || VK2 || adaptor_key)
So the operator rejects the second request.
That is the correct behavior.
A retry must be identical.
If the verifying key changes, it is not a retry anymore.
It is a different signing session wearing a fake mustache.

The Responsible Disclosure Outcome
The issue was responsibly reported through a bug bounty program.
The report was confirmed, rewarded as Critical, fixed, and later retested successfully.

The final retest showed the intended behavior:
First request:
nonce N + verifying key VK1
result: accepted
Second request:
same nonce N + verifying key VK2
result: rejected
That rejection is the important part.
The nonce was no longer reusable across distinct signing contexts.
The vulnerability was mitigated by binding the retry fingerprint to the missing context fields and rejecting nonce reuse when the fingerprint changed.

What Bug Bounty Hunters Can Learn from This
This bug was a reminder that high-impact findings do not always come from huge exploit chains.
Sometimes they come from one question:
“What did this hash forget?”
When reviewing cryptographic systems, look for fingerprinting and context-binding code.
Ask:
What fields are included?
What fields are omitted?
Can omitted fields change the security meaning?
Can an attacker control those omitted fields?
Can the same nonce, token, proof, or signature be replayed under a different context?
This applies beyond FROST.
The same mindset helps when reviewing:
- signature verification,
- replay protection,
- session binding,
- wallet signing flows,
- bridge messages,
- JWT audience checks,
- OAuth/SAML assertions,
- transaction digest construction,
- cross-chain message validation,
- multi-party computation protocols.
The bug class is bigger than one implementation.
It is about incomplete context binding.
A Simple Audit Checklist for Similar Bugs
When I review signing or cryptographic systems now, I like to check these patterns:
1. Nonce lifecycle
Where is the nonce generated?
Where is it stored?
When is it marked used?
Can it be reused?
What counts as a retry?
2. Retry fingerprint
Is the retry fingerprint collision resistant?
Does it include every challenge-affecting field?
Does it include public keys?
Does it include participant sets?
Does it include adaptor keys or domain separators?
3. Attacker-controlled fields
Can the coordinator supply the verifying key?
Can the coordinator alter participants?
Can the coordinator change adaptor keys?
Can the coordinator replay old commitments?
4. Cryptographic challenge
What exactly goes into H(...)?
Can any part of H(...) change while the nonce remains the same?
5. Rejection behavior
Does the system reject nonce reuse across different contexts?
Or does it silently treat near-matches as safe retries?
If the answer is “same nonce can survive a context change,” keep digging.
That is where the fun starts.

The Bigger Security Lesson
Cryptographic protocols are usually designed with very precise assumptions.
But real-world implementations are messy.
They need retries. They need APIs. They need protobufs. They need storage. They need coordinators. They need error handling. They need “try again” behavior when something fails.
And that is where bugs sneak in.
The protocol may say:
“Never reuse a nonce across different signing challenges.”
But the implementation may accidentally say:
“It is fine, the message is the same.”
Those are not equivalent.
In normal application logic, that might be a minor bug.
In cryptography, that can be the difference between a safe retry and secret-share recovery.
Final Thoughts
This vulnerability started as a missing field in a hash.
Not a broken cipher. Not a weak curve. Not a dramatic zero-day exploit.
Just a retry fingerprint that forgot to include the full signing context.
But in FROST and Schnorr-style signatures, context is everything.
A missing verifying key became nonce reuse. Nonce reuse became two signature shares. Two signature shares became an equation. The equation revealed the secret.
That is what makes cryptographic bug hunting so interesting.
You are not always smashing the door open.
Sometimes you are staring at a tiny helper function and realizing the vault key is hidden in the field it forgot to hash.

TL;DR
A retry mechanism in a FROST threshold-signing implementation failed to bind nonce reuse protection to the full signing context.
The fingerprint included the message but omitted challenge-affecting fields like the verifying key.
That allowed the same nonce to be reused across two different verifying keys, producing two signature shares with different challenges.
Because Schnorr/FROST signatures are linear, those two shares can be used to recover the signer’s long-term secret share.
The issue was responsibly disclosed, confirmed as Critical, fixed, and successfully retested.
The lesson:
In cryptographic systems, a retry is only safe if every challenge-affecting field is identical. Always ask what the hash forgot.
메타데이터
- post_id
- fcfecd69fd68
- slug
- two-signatures-one-nonce-zero-secrets-a-frost-crypto-bug-story-fcfecd69fd68
- url
- https://medium.com/@trffnsec/two-signatures-one-nonce-zero-secrets-a-frost-crypto-bug-story-fcfecd69fd68
- canonical_url
- https://medium.com/@trffnsec/two-signatures-one-nonce-zero-secrets-a-frost-crypto-bug-story-fcfecd69fd68
- author_url
- https://medium.com/@trffnsec
- status
- ok
- fetched_at
- 2026-06-09 15:37:30