Sigma dance: commit, challenge, respond
to help others discover it! 🥰
Table of Contents

Ever wondered what a -protocol is?
Fun fact: the name “Sigma” comes from the Greek letter , which looks a bit like a zigzag, just like the three-step process this protocol performs 😊
Sigma is probably the most basic “zero-knowledge proof of knowledge” 😁 protocol: prove you know something secret, without revealing the secret itself.
You, the prover, know some private value(s) that satisfy a relation, and want to convince a verifier that this is true, without leaking .
The three steps Sigma dance#
- Commitment: the prover sends an initial commitment to the verifier
- Challenge: the verifier replies with a random challenge
- Response: the prover answers using the secret and the challenge
From the response, the verifier becomes convinced that the prover must know a valid witness satisfying the relation, all without ever seeing it.
Later on, we'll see how step 2 can be made non-interactive with Fiat-Shamir.
Notation#
Cryptographers often write things using multiplicative notation (like ), but since most real implementations use elliptic curves, I'll use additive notation ().
Same math, different flavor.
In this article, we'll use:
- : an elliptic curve generator
- : another, independent generator
The Schnorr protocol#
The Schnorr protocol is the simplest example of a -protocol, the “hello world” of zero-knowledge proofs 😉
Here's the setup:
You, the prover, want to show that you know a witness such that
where is a scalar, and is therefore a public curve point.
In practice, this is like proving you know the private key corresponding to a public key .
Step by step:
- Commitment
You pick a random scalar and send a commitment to that randomness
- Challenge
Verifier sends back a random challenge , a random scalar (a number in the field, not an elliptic curve point).
- Response
You reply with
Finally, the verifier checks
and accepts if it holds.
Why it works?
Let's plug things in:
So if the verifier's check passes, it means you must know the correct .
At the same time, keeps hidden, the transcript reveals nothing about it.
Let's see the whole thing in action using SageMath.
We'll use a small toy elliptic curve to keep numbers manageable, but the logic is the same as in real-world cryptography.
p = 929
Fp = GF(p)
E = EllipticCurve(Fp, [5, 15])
G = E.gens()[0]
Fr = GF(G.order())
Now let's walk through the Schnorr Sigma protocol step by step:
x = Fr(123)
Y = x * G
r = Fr.random_element()
T = r * G
print("Sending commitment T to the verifier")
print("challenge should be computed as: c = hash(G, Y, T)")
c = Fr.random_element()
s = r + c * x
print("Sending s to the verifier")
assert s * G == T + c * Y
print("Proof is valid")
Witness Extraction (a.k.a Soundness)#
So, how do we know the prover really knows the witness ?
That's where knowledge soundness comes in: it means that if someone can successfully convince the verifier twice (with the same commitment), then we can extract the witness from those two runs.
Here's how it works in the Schnorr protocol:
We know that the commitment is
and that for two successful transcripts with the same , but different challenges and , we get:
If we subtract those two equations, the randomness disappears:
From which we can recover the witness:
That's it: witness extraction!
This property is called special soundness, and it's one of the defining features of Σ-protocols.
It guarantees that no one can “fake” a proof without actually knowing . In other words: if you can respond to two different challenges using the same commitment, you must know the witness.
H = E.random_point()
# known commitment
v = Fr(123)
r = Fr.random_element()
print(f"v: {v}, r: {r}")
C = v * G + r * H
v_prime = Fr.random_element()
r_prime = Fr.random_element()
A = v_prime * G + r_prime * H
print("Sending commitments C and A to verifier")
print("challenge should be computed as: hash(G, H, C, A)")
c = Fr.random_element()
s1 = v_prime + c * v
s2 = r_prime + c * r
print("Sending (c, s1, s2) to the verifier")
assert s1 * G + s2 * H == A + c * C
print("Proof is valid")
c_prime = Fr.random_element()
s1_prime = v_prime + c_prime * v
s2_prime = r_prime + c_prime * r
print("Sending (c_prime, s1_prime, s2_prime) to the verifier")
assert s1_prime * G + s2_prime * H == A + c_prime * C
print("Proof is valid")
print("\nLet's try to recover v and r")
assert s1_prime * G + s2_prime * H - c_prime * C == s1 * G + s2 * H - c * C
assert (s1_prime - s1) * G + (s2_prime - s2) * H + (c - c_prime) * C == 0
# s1 G + s2 H + cv G + cr H = 0
# (s1 + cv) G + (s2 + cr) H = 0
# so we have:
# . s1_prime - s1 + (c - c_prime) * v = 0 -> v = (s1 - s1_prime) / (c - c_prime)
# . s2_prime - s2 + (c - c_prime) * r = 0 -> r = (s2 - s2_prime) / (c - c_prime)
print("\nRecovered v and r:")
recovered_v = (s1 - s1_prime) / (c - c_prime)
recovered_r = (s2 - s2_prime) / (c - c_prime)
print(f"recovered_v: {recovered_v}, recovered_r: {recovered_r}")
assert recovered_v == v
assert recovered_r == r
Composing -proofs#
Now that we've nailed the basics, we can start doing something fun: combining Sigma proofs! 🥳
Equality proof: two values share the same secret#
Suppose you have one secret , and two public values and :
Your goal: convince the verifier that both and are derived from the same secret , that you're in possession of.
- Commitment
Pick a random :
- Challenge
Verifier sends a random challenge
- Response
- Verification
If both equations hold, you've successfully proven that the same secret is used in both public values, without revealing it.
AND proof: you know both secrets#
Now let's say there are two independent secrets and :
You want to prove that you know both of them.
Pick a random , and send:
Receive and send the final response:
Verifier checks
If both checks pass, you've proven that you know both AND at once.
OR proof: you know one of two secrets#
This one's a bit trickier, but even more fun!
We have the same setup:
but now you only know one of the secrets (say ), and you want to prove that you know either or , without revealing which.
How do you do that?
The idea is clever: we'll run two proofs in parallel, one for each relation, but we'll simulate one of them (the one we don't know).
To make the simulation indistinguishable from a real proof, we'll work with two challenges and , chosen by the prover.
The only rule: they must add up to the verifier's challenge :
This ensures the overall proof still fits the -protocol structure: the verifier only sends one challenge, but internally it's “split” between the two subproofs.
Here's the trick:
- For the secret you don't know, you'll make up a fake transcript that looks valid.
- For the secret you do know, you'll compute the response honestly using and the verifier's challenge .
That way, the verifier sees two valid-looking proofs but can't tell which one was simulated.
- Commitment
As usual, pick a random and compute:
For the secret you don't know (here ):
- pick random values and
- compute the corresponding commitment in a way that forces the final check to pass:
Send both commitments to the verifier.
Notice how only uses the public point not the secret scalar , since we don't know it.
- Challenge
Now, the verifier sends a challenge
- Response
We're not going to use directly, instead we split it:
Then computes the real response for the side you know:
Remember that we already have , previously generated randomly.
Send as your response.
- Verification
Why It Works
- The first equation is a real -proof for the secret you know ()
- The second equation is fake, but still consistent, because you built so it passes the check for your chosen
- The verifier can't tell which part was simulated
So the verifier learns exactly one thing: that you know either or , nothing more.
That's an OR proof, the foundation for “selective disclosure” in many ZK systems.
Now let's see how Sigma protocols can work together with Pedersen commitments.
H = E.random_point()
# known commitment
x = Fr(123)
P1 = x * G
# unknown commitment
P2 = E.random_point()
print("Sending P1 and P2 to the verifier")
r = Fr.random_element()
T1 = r * G
c2 = Fr.random_element()
s2 = Fr.random_element()
T2 = s2 * H - c2 * P2
print("Sending T1 and T2 to the verifier")
print("Verifier sends challenge c")
c = Fr.random_element()
c1 = c - c2
s1 = r + c1 * x
print("Sending (c1, c2, s1, s2) to the verifier")
assert s1 * G == T1 + c1 * P1
assert s2 * H == T2 + c2 * P2
assert c1 + c2 == c
print("Proof is valid")
Pedersen commitments#
Pedersen commitments show up everywhere in zero-knowledge proofs. They're simple, powerful, and they hide secrets like a cryptographic vault.
where:
- is the witness you're committing to
- is a random blinding factor
- and , you already know
Pedersen commitments are hiding, thanks to randomness no one can guess from , and binding, once you've published you can't later change your mind about (except if you can solve the discrete log problem).
The additive ➕ magic#
Pedersen commitments are also additively homomorphic, which means commitments add up nicely:
In other words, adding two commitments gives you a commitment to the sum of the underlying values.
This property makes Pedersen commitments incredibly flexible in larger ZK systems.
To open a commitment, you simply reveal both and .
Pedersen + #
Now things get interesting.
Suppose there's a public Pedersen commitment that hides your private key .
Someone asks you to prove that you actually know , but of course, you don't want to reveal it. If you just sent your key, that'd defeat the whole point!
So instead, you use a -protocol to prove knowledge of the opening of the commitment.
Prove knowledge of the opening #
Here's how it goes:
- Commitment
Pick random values and , and send:
- Challenge: verifier picks a random challenge
- Response:
Compute your responses:
- Verification
The verifier checks:
If this holds, he's convinced you know the witness , without ever revealing them.
H = E.random_point()
x = Fr(123)
r = Fr.random_element()
C = x * G + r * H
print("Sending commitment C to verifier")
r1 = Fr.random_element()
r2 = Fr.random_element()
T = r1 * G + r2 * H
print("Sending T to the verifier")
print("challenge should be computed as: c = hash(G, H, C, T)")
c = Fr.random_element()
s1 = r1 + c * x
s2 = r2 + c * r
print("Sending (s1, s2) to the verifier")
assert s1 * G + s2 * H == T + c * C
print("Proof is valid")
Combining proofs with Pedersen commitments#
Just like we combined Sigma proofs based on elliptic curve points earlier, we can do the same when using Pedersen commitments.
We'll go a bit faster this time. The pattern is the same, only with more moving parts.
Two commitments, one secret
Suppose we have two Pedersen commitments that should both hide the same secret value .
We want to prove that this is true, and that we actually know .
To keep things clear:
- will be the blinding factors inside each commitment
- values are the randomness used by the protocol itself
The two commitments are:
Pick three random values , and send:
We receive challenge from the verifier, and send responses:
Verifier checks
H = E.random_point()
print("Here we prove that the same value is encoded in 2 Pedersen commitments")
x = Fr(123)
b1 = Fr.random_element()
b2 = Fr.random_element()
P1 = x * G + b1 * H
P2 = x * G + b2 * H
print("Sending commitments P1 and P2 to the verifier")
r_x = Fr.random_element()
r_b1 = Fr.random_element()
r_b2 = Fr.random_element()
T1 = r_x * G + r_b1 * H
T2 = r_x * G + r_b2 * H
print("Sending T1 and T2 to the verifier")
print("challenge should be computed as: hash(G, H, P1, P2, T1, T2)")
c = Fr.random_element()
s1 = r_x + c * x
s2 = r_b1 + c * b1
s3 = r_b2 + c * b2
print("Sending (c, s1, s2) to the verifier")
assert s1 * G + s2 * H == T1 + c * P1
assert s1 * G + s3 * H == T2 + c * P2
print("Proof is valid")
Just like before, you can also build AND or OR proofs on top of Pedersen commitments, proving you know both openings, or that you know the opening of at least one commitment.
As a gift, here's the code for the Pedersen OR proof:
H = E.random_point()
# known commitment
x = Fr(123)
blinding = Fr.random_element()
P1 = x * G + blinding * H
# unknown commitment
P2 = E.random_point()
print("Sending P1 and P2 to the verifier")
r_x = Fr.random_element()
r_r = Fr.random_element()
T1 = r_x * G + r_r * H
c2 = Fr.random_element()
s2_x = Fr.random_element()
s2_r = Fr.random_element()
T2 = s2_x * G + s2_r * H - c2 * P2
print("Sending T1 and T2 to the verifier")
print("Verifier sends challenge c")
c = Fr.random_element()
c1 = c - c2
s1_x = r_x + c1 * x
s1_r = r_r + c1 * blinding
print("Sending (c1, s1_x, s1_r), (c2, s2_x, s2_r) to the verifier")
assert s1_x * G + s1_r * H == T1 + c1 * P1
assert s2_x * G + s2_r * H == T2 + c2 * P2
assert c1 + c2 == c
print("Proof is valid")
Fiat-Shamir: making it non-interactive#
So far, all our protocol has been interactive. The prover needs to receive random challenges from the verifier, after committing to some values.
But in many real-world settings (like digital signatures or blockchain proofs), there's no live verifier around.
That's where Fiat–Shamir comes to the rescue.
Instead of having a verifier send , we compute it deterministically using a hash function.
The hash plays the role of the verifier: it generates a challenge that's unpredictable before the commitment, yet deterministic afterwards.
But we must be very careful about what goes into the transcript.
- If you forget something, the prover could change his commitments after seeing the challenge
- If you include too much, like private data, the verifier won't be able to recompute the hash and verify the proof
What to include in the hash?#
Typically, the transcript should include:
- the protocol's public parameters: and
- the public inputs (for example the Pedersen commitment )
- the prover's initial commitment
- optionally, a domain separator to avoid cross-protocol collisions
These ensure the challenge is bound to the right context, and impossible to predict ahead of time.
Unpredictable challenge#
Why is it crucial that the prover cannot predict the challenge?
If the prover can guess the challenge before choosing , he can fake a valid proof without actually knowing the secret .
Let's see how:
The prover generates a random (the value normally sent in step 3), and computes:
The verifier cannot distinguish between a correctly constructed and a "forged" one.
The verifier checks:
which holds perfectly:
Boom 💥 the verifier is fooled, even though the prover never knew .
That's why the Fiat–Shamir challenge must be truly unpredictable from the prover's perspective.
They must commit first, and only then can the challenge be derived from the public transcript. Never before.
Schnorr signature#
Before we wrap up, let's see how Schnorr signatures are built.
Now that we understand how the challenge comes from the Fiat–Shamir transform, you'll see that a Schnorr signature is really just a non-interactive Sigma protocol, with a message attached.
Same setup as before:
- private key
- public key
- random nonce and its commitment
But this time, we're signing a message .
Compute the challenge
We derive the challenge using a hash function.
Normally, it would be:
But since we're signing something, we include the message in the hash:
This makes the signature bound to that specific message.
Compute the final value
That's what we previously called the "response". But since the protocol is not interactive anymore, it feels weird to call it that.
The final signature is the pair .
Notice that we don't need to send : the verifier can recompute it.
Verification
To verify, the verifier reconstructs , using the received value and the public key :
Then checks that:
If it matches, the signature is valid.
And that's it. A Schnorr signature is literally the Schnorr -protocol with the the signed message added to the transcript.
Generalization: prove knowledge of a homomorphism pre-image#
So far, we've seen Sigma protocols work in specific cases:
- With a simple elliptic-curve point we proved knowledge of
- With Pedersen commitments we proved knowledge of
If you noticed that both of these fit the same pattern, you're right!
What they both have in common is a homomorphism: a function that preserves the group operation.
Let's see how we can generalize Sigma protocols to any homomorphic function.
Homomorphism#
Let's define a generic homomorphism:
This simply means that the function “respects” addition:
That's, for example, elliptic curves:
where:
If we were working multiplicatively (like , here being a generator in a multiplicative group and not an elliptic curve point), it would look like this:
and the function would be defined as:
Intuition#
The idea is that a Sigma protocol doesn't really care what is. As long as it's homomorphic, the same logic applies.
You're trying to prove that you know some secret input such that
without revealing
Generic -protocol#
Here's how the generic protocol looks, abstracted over any homomorphism .
You want to prove knowledge of a secret
- Commitment
Prover picks a random and sends:
- Challenge
The verifier sends back a random scalar
(a number in the field, not a group element).
- Response
The prover computes
- Verification
The verifier checks that
If the equality holds, the verifier is convinced that the prover must know some satisfying
Examples#
Elliptic curve discrete log
Then you run the Schnorr protocol you already know.
Pedersen commitments
The prover's secret is and the commitment is .
Exponentiation
Identical structure, just changing the group operation. Which gives:
Why this matters?#
This generalization shows that a Sigma protocol is fundamentally a proof of knowledge of a pre-image under a homomorphism.
As long as the function preserves the group operation, the proof works.
That's why Sigma protocols are so flexible: they're not tied to a specific use case, but to a mathematical structure.
This exact abstraction is what Dan Boneh uses in his lecture to show how -protocols become a powerful foundation for more advanced ZK systems.
You can find all the SageMath scripts used in this article on my GitHub: sigma-proofs