Open this lesson in your favourite AI. It'll walk you through the why, explain the demo, and quiz you on the try-it list.
Interactive proofs are a nuisance: the prover and verifier must be online together, exchanging messages. For blockchains and offline signing, this is unworkable. Fiat-Shamir is the cryptographic trick that converts an interactive proof into a non-interactive one — by replacing the verifier's random challenge with the output of a hash function over the prover's first message. If the hash function is modeled as a random oracle, the resulting protocol inherits the original's soundness. Every SNARK, STARK, and Picnic-style signature uses Fiat-Shamir under the hood; understanding it deeply is the difference between using ZK tools and understanding them.
Schnorr's identification protocol is interactive: (commit, challenge, response). Fiat-Shamir replaces 'challenge' with H(statement || commit). The prover can compute everything offline and ship a 2-element transcript. This is a Schnorr digital signature — identical math, different narrative.
// main.go — interactive → non-interactive via hashing (Fiat-Shamir)
package main
import (
"crypto/sha256"
"crypto/rand"
"encoding/binary"
"fmt"
"math/big"
)
var (
p, _ = new(big.Int).SetString("ffffffffffffffffffffffffffffffffffffffffffffffffffffffff000003d1", 16)
q, _ = new(big.Int).SetString("3fffffffffffffffffffffffffffffffffffffffffffffffffffffff40000f4f", 16)
g, _ = new(big.Int).SetString("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", 16)
)
func randBigInt(max *big.Int) *big.Int {
n, _ := rand.Int(rand.Reader, new(big.Int).Sub(max, big.NewInt(1)))
return new(big.Int).Add(n, big.NewInt(1))
}
func H(parts ...*big.Int) *big.Int {
h := sha256.New()
for _, v := range parts {
h.Write([]byte(v.String()))
}
digest := h.Sum(nil)
return new(big.Int).SetBytes(digest)
}
func Hm(parts []*big.Int, msg []byte) *big.Int {
h := sha256.New()
for _, v := range parts {
h.Write([]byte(v.String()))
}
h.Write(msg)
_ = binary.BigEndian // used for clarity
return new(big.Int).SetBytes(h.Sum(nil))
}
func main() {
// Statement: "I know x such that y = g^x mod p"
x := randBigInt(q)
y := new(big.Int).Exp(g, x, p)
// Non-interactive Schnorr (Fiat-Shamir): derive challenge from hash(statement || commit)
nizk_prove := func(message []byte) (*big.Int, *big.Int) {
r := randBigInt(q)
commit := new(big.Int).Exp(g, r, p)
challenge := Hm([]*big.Int{y, commit}, message) // Fiat-Shamir: deterministic from commit
response := new(big.Int).Mod(new(big.Int).Add(r, new(big.Int).Mul(challenge, x)), q)
return commit, response
}
nizk_verify := func(commit, response *big.Int, message []byte) bool {
challenge := Hm([]*big.Int{y, commit}, message)
lhs := new(big.Int).Exp(g, response, p)
rhs := new(big.Int).Mod(
new(big.Int).Mul(commit, new(big.Int).Exp(y, challenge, p)),
p,
)
return lhs.Cmp(rhs) == 0
}
commit, response := nizk_prove([]byte("transfer 10 SOL"))
fmt.Println("verify:", nizk_verify(commit, response, []byte("transfer 10 SOL"))) // true
fmt.Println("tampered msg verify:", nizk_verify(commit, response, []byte("transfer 100 SOL"))) // false — binding!
}go run main.goUse these three in order. Each builds on the one before.
In one paragraph, explain Fiat-Shamir — what it converts and how, and why hash functions modeled as random oracles are the correct tool for the job.
Walk me through the Fiat-Shamir transformation applied to Schnorr: which inputs to the hash function are necessary for soundness, which are necessary for message binding, and what goes wrong if you omit either.
I'm implementing a zk-SNARK prover in production. Walk me through every category of input that must go into my Fiat-Shamir hash (statement, commitments, previous challenges, public inputs, domain separator) and explain the concrete attack each prevents.