Fiat-Shamir (FS) looks simple at first glance: derive each verifier message by hashing the protocol transcript. Yet one missing transcript update can leave a malicious prover enough freedom to forge a proof. We have found this pattern repeatedly while reviewing and auditing production proof systems, including failures that go beyond the familiar omission of a prover message.
This post turns those findings into a practical guide. We need to distinguish fixed protocol parameters from inputs the attacker can choose. By the end, you should know what must influence each challenge, how to spot dangerous omissions during a review, and how to avoid introducing them in an implementation.
From interaction to Fiat-Shamir
Many proof systems rely on probabilistic verifier checks. The verifier can combine several equations into one randomized check. The prover must fix its earlier messages before learning the challenge used in that check, so a false proof should pass only with negligible probability.
In a public-coin interactive proof, the verifier samples its messages using fresh public randomness. In the protocols below, these verifier messages are random challenges. The main problem is that interaction is costly in practice. Both prover and verifier must be online for the exchange, every round adds network latency, and the resulting proof cannot simply be generated once and verified independently later.
The Fiat-Shamir transformation produces a non-interactive argument.1 Instead of waiting for a verifier message, the prover derives it by hashing the transcript so far and mapping the output to the required message distribution. The hash acts as an unpredictable challenge generator: once the transcript prefix is fixed, the prover cannot independently choose the resulting challenge. The verifier later reconstructs the same transcript and derives the same challenge.
The following simplified protocol is enough background to understand the transcript omissions below. An interactive protocol proceeds as follows:
-
the prover sends a message $m_1$
-
the verifier samples a verifier message $c_1$ (here, a random challenge)
-
the prover sends a message $m_2$
-
the verifier samples a verifier message $c_2$
Let $\mathsf{instance}$ denote the public statement being proved, and let $\mathsf{domain}$ identify the protocol. After applying FS with a cryptographic hash function $H$, the challenges can be written schematically as:2
$$ c_1 = H(\mathsf{domain}, \mathsf{instance}, m_1) $$ $$ c_2 = H(\mathsf{domain}, \mathsf{instance}, m_1, c_1, m_2) $$Implementations usually keep a running transcript: protocol values are added to an evolving hash state in order, and challenges are derived from that state. Adding data to this state is called absorbing; deriving output from it is called squeezing. A duplex sponge supports alternating between these operations. Each challenge must be derived using all previous prover messages seen in the protocol.
Where Fiat-Shamir fails in practice
Can the prover change an input after learning a challenge, keep that challenge unchanged, and use the change to make the check pass? If so, the proof may be forgeable.
The advice to “just hash everything” leaves developers to decide which inputs, parameters, and labels identifying the protocol belong in the transcript.3 Many papers specify only the interactive protocol, while production code implements its non-interactive FS transformation. When the transcript schedule, encodings, domain separation, and challenge derivation are left unspecified, implementers must make security-critical decisions themselves.
A missing transcript update can allow the following attack:
-
A malicious prover starts constructing a proof for a false statement.
-
The incomplete transcript produces a challenge.
-
The prover chooses or modifies an omitted value after learning that challenge.
-
The prover uses this extra freedom to make the randomized verifier check pass.
This attack is sometimes described as “rewinding.” No network conversation needs to be rewound in a non-interactive implementation. The attacker runs the prover locally, observes the deterministic challenge, changes the omitted value, and continues or retries.
A recurring mistake is misidentifying what belongs to the instance at each verification step. A verification key may be fixed for one application and caller-selected in another. An input's shape can change its meaning. When a verifier batches checks, even the completed proofs become inputs to the randomized check. The sections below examine these boundaries, then protocol identity, challenge schedules, and challenge derivation.
We use the established term weak FS for challenges that omit the public statement. Related failures involve prover messages, verification keys, encodings, prover-supplied sub-challenges, and deterministic batching. We introduce names for these other cases below and explain what to look for.
Weak FS: What belongs to the public statement?
First check what belongs to the public statement, rather than relying only on what the implementation declares as public inputs. Depending on the protocol, it can include public inputs, commitments, verification keys, circuit identifiers, upper bounds in range proofs, table sizes, modes, or any other public value that changes the relation being verified. If an intended public input is only a private witness value, with no constraint binding it to the intended value, the prover can choose it subject to the remaining constraints. Hashing the declared public inputs will not fix that omission.
Suppose a circuit should prove authorization for a particular recipient. If the recipient is only a private witness value, with no constraint tying it to the intended public recipient, the prover can choose any recipient that satisfies the other constraints. Hashing the public inputs will not fix this missing constraint; see our post on under-constraining. First check that the circuit proves the intended claim, then that FS binds the complete public statement.
Schnorr is a well-known example of this bug. It proves knowledge of the secret exponent behind a public key, as explained in our Sigma protocol post. If the challenge hashes only the prover's commitment and omits the public key, an attacker can learn the challenge first, then choose a public key that makes verification pass without knowing its secret exponent. Absorb the complete statement before deriving the challenge, alongside the prover messages that the interactive protocol requires to be fixed at that point. The CFRG Fiat-Shamir draft, Section 5.2, specified absorbing the instance before any prover message.
Public keys are one example. Ed25519 uses SHA512(R || A || M) when deriving its challenge, where R is the nonce commitment, A is the public key, and M is the signed message. Including A makes the challenge depend on which public key the signature will be checked against.
ECDSA hashes the message without including the public key. Its verification equation allows a suitable key to be selected for an existing signature and message, which differs from forging under a fixed victim key.5 Ed25519's key-dependent challenge prevents that direct construction.
The Let's Encrypt attack exploited related key selection in RSA. ACME binds domain validation to the account key by including its thumbprint.
For structured public inputs, the statement can depend on both the values and where they belong. The Stwo-Cairo verifier absorbed public memory values before deriving the interaction challenges, but omitted the corresponding public memory IDs. Those IDs were later used in lookup relations, so a malicious prover could change them after learning the challenges and manipulate the lookup sum.
The omission is visible in the Cairo code:
for entry in data.span() {
let (_, val) = entry;
flat_data.append_span((*val).span());
}
self.mix_u32s(flat_data.span());
The _ discards the memory ID, so only val reaches the hash. The fix is to absorb each ID alongside its value.
We found missing inputs in our zkLighter audit. A GKR proof verified MiMC hash computations inside a larger circuit. Its initial randomness came from a block commitment that omitted some GKR inputs and outputs. The prover could learn the initial evaluation point, then change the claimed outputs while preserving their polynomial's evaluation there. This allowed incorrect hash results to pass and could be used to forge Merkle proofs.
The report shows the vulnerable call in block_constraints.go:
api.AssertIsEqual(commitment, block.BlockCommitment)
err = gkrMimc.VerifyGKRMimc(commitment)
if err != nil {
return err
}
The equality check validates the block commitment, but that commitment does not cover all GKR inputs and outputs. Those values formed the statement of the inner proof, even though they appeared inside the outer circuit. We recommended passing all of them to gnark's commitment mechanism before deriving the randomness. This uses the BSB22 construction, which avoids recomputing the entire initial transcript hash inside the circuit.
Write down the complete public statement for each proof, including proofs verified inside another proof. Both prover and verifier must absorb it before deriving challenges that depend on it. The API should make clear which component absorbs each input.
Circuit substitution FS: Do I need to absorb the verification key?
This is a special case of weak FS: the omitted part of the statement is easy to mistake for a fixed parameter. We call it Circuit substitution FS, and it can enable a verifier key substitution attack, as the examples below illustrate.
Some verification APIs accept a verification key alongside the proof and public inputs. If the party submitting the proof can also choose that key, it can change which circuit the verifier checks. The public input values alone do not identify the circuit, so the supplied verification key must be bound into the transcript too. If an application instead fixes one verification key independently of the attacker, its protocol identifier can identify that key, provided the verifier enforces this choice. A key registered by the attacker still counts as attacker-selected: they may have constructed the key and proof together offline.
Binding the commitments alone can still leave out the circuit they are supposed to represent. We found this omission in Aleo Synthesizer's circuit certification. A certificate was supposed to establish that a verification key's commitments represented the circuit supplied to the network. The transcript was initialized from those commitments:
let mut sponge =
Self::init_sponge_for_certificate(fs_parameters, &verifying_key.circuit_commitments);
let mut challenges =
sponge.squeeze_nonnative_field_elements(verifying_key.circuit_commitments.len());
The missing value was circuit_id, a hash of the circuit's constraints. An attacker could change the circuit and its ID after seeing the challenges, while keeping the commitments unchanged, and obtain a certificate for a key that did not correspond to the claimed circuit. Aleo fixed the omission by hashing the entire verification key.
Circuit commitments are one place to look for this confusion between fixed parameters and inputs the caller can choose. Mina's Kimchi transcript did not absorb the commitments in the verifier index, which describe the circuit, before deriving the proof's challenges. We explain the attack in Plonkish and AIR terms.
In a Plonkish proof, the circuit constraints are combined into a polynomial $f$. A satisfying witness makes $f$ divisible by the vanishing polynomial $Z_H$ of the circuit's evaluation domain. The prover commits to a quotient polynomial $t$, and the verifier checks the identity at a point $\zeta$ derived from the transcript:
$$ f(\zeta) = t(\zeta) Z_H(\zeta). $$The circuit's selector polynomials contribute to $f$. If their commitments do not influence $\zeta$, an attacker can try to choose the circuit after learning that point. They first commit to witness polynomials and a claimed quotient, then adjust the circuit's selectors so that the displayed equality holds at $\zeta$, even though the polynomial identity $f = t Z_H$ fails and the witness violates the resulting circuit's constraints. The witness and quotient commitments stay fixed; the circuit is chosen to fit the check at the known point.
For an AIR constraint with a constant $c$ chosen by the circuit author, write the constraint polynomial as $g(X)-c$, where $g$ comes from the committed trace. The verifier checks
$$ g(\zeta) - c = t(\zeta)Z_H(\zeta). $$If $c$ is omitted from the transcript, the attacker can keep the trace and quotient commitments fixed, learn $\zeta$, and choose
$$ c = g(\zeta) - t(\zeta)Z_H(\zeta). $$This passes the evaluation check. But the trace must satisfy $g(x)=c$ at every row $x \in H$, which is impossible if $g$ takes different values across those rows. In Kimchi, the attacker would adjust the circuit's selector polynomials to make the check pass at $\zeta$. Their commitments belong to the verification key.
The attacker must construct actual selector polynomials and derive the corresponding verification key from that circuit. They can prepare the circuit, key, and malicious proof together offline, then register the key before submitting the proof. Requiring registration first does not enforce the order of these offline choices. The security failure would be accepting a proof that violates the registered circuit's own rules.
This explains the circuit-selection strategy, not a complete Kimchi exploit. A concrete construction must satisfy the selector and degree restrictions, keep every relevant challenge unchanged, and pass the remaining proof checks. The team's assessment at the time concerned user-selected keys for SnarkyJS and zkApps; it explicitly excluded the Mina system deployed then.
The fix added these lines at the start of verification, with a corresponding change in the prover:
let verifier_index_digest = index.digest::<EFqSponge>();
fq_sponge.absorb_fq(&[verifier_index_digest]);
Changing the covered commitments now changes the digest and the challenges, removing the freedom to adjust the key while keeping those challenges fixed.
Even hashing the verification key may not be enough if it binds only a program that generates the constraints. Fenzi's recent work shows attacks on certain protocols where a generator controlled by the attacker can anticipate the challenges and produce a false statement that passes verification. For that setting, the paper's mitigation is to derive the first challenge from the generated statement itself.
Check who can choose or change each parameter in your application. Variable parameters are part of the statement, so validate and absorb them, directly or through a canonical, collision-resistant digest. Rely on the protocol's domain separator to identify fixed parameters only if the implementation prevents callers from changing them.
Ambiguous FS: Does my encoding preserve the complete statement?
The transcript must also preserve input structure when it changes the statement. Suppose a proof accepts two columns of public inputs. Flattening [[1, 2], [3]] and [[1], [2, 3]] produces the same sequence [1, 2, 3]. Every value reaches the hash, but the column boundaries disappear. Unless another transcript input records them, both statements produce the same challenges without any search for a hash collision.
This is a non-injective encoding: different inputs have the same representation because the encoding loses the collection boundaries. We call this Ambiguous FS.
A related example appeared in Solana's standalone range proof. The verifier absorbed only the sum of the bit lengths:
let nm: usize = bit_lengths.iter().sum();
// ...
transcript.range_proof_domain_separator(nm as u64);
The vectors [32, 32] and [31, 33] both give nm = 64, although they specify different ranges. Absorbing each bit length would preserve that distinction.
Collection boundaries are not the only information a transcript can lose. Absorbing only algebraic combinations can also make different statements indistinguishable.
We also found this problem in Signal's zkcredential in libsignal, at revision 88595dd6079572bbed404ea36a6f26237c808cbb. In presentation.rs, lines 690–693, the verifier adds only the difference between the second attribute commitment and ciphertext point:
point_args.add(
format!("C_y{second_point_index}-E_A{second_point_index}"),
C_y[second_point_index] - self.core.attr_points[second_point_index],
);
An attacker can shift the encrypted attribute and its commitments together while preserving the combinations used by the transcript. The challenge and the same poksho proof remain valid even though the encrypted attribute has changed. Both sides need to absorb the complete presentation commitments, including the attribute-commitment count.4
The later verify_proof call passes self.core.authenticated_message without adding the complete presentation commitments. The transcript therefore includes combinations such as $C_{y,2} - E_{A,2}$ and the verifier-computed value, shown here with unchanged public-attribute terms omitted:
but does not separately include all the presentation commitments. The credential public key also exposes a ladder of points for different attribute counts, whose adjacent difference is $I_2 - I_3 = y_2 G_{y,2}$. For any chosen scalar $a$, an attacker can therefore apply:
$$ E'_{A,2} = E_{A,2} + aG_{y,2}, \qquad C'_{y,2} = C_{y,2} + aG_{y,2}, \qquad C'_V = C_V + a(I_2 - I_3). $$Unchanged challenges alone do not make a proof valid for a different statement: the new equations must also pass. Moving values between columns can change the polynomial being checked, though padding or trailing zeros can sometimes preserve it.
Check each step: does the encoding collision produce identical challenges, does the same proof verify for a different statement, and is that statement false? A reused proof might also authorize an unintended action even if both statements are true. An encoding collision alone does not establish either outcome.
Statements with different meanings must have different transcript encodings. Keep the order and grouping of values, including message boundaries, wherever they affect the statement. For variable columns, absorb the column count and each column's length before its values. Check that preprocessing retains this information. If the encoding assumes a fixed structure, reject inputs that don't match it.
Lossy FS: Does each individual value have a distinct encoding?
The encoding of each individual value must preserve the meaning used by the verifier and the application. We call failures at this level Lossy FS. For example, reducing an arbitrary integer modulo $p$ before absorption merges $x$ and $x+p$. That is appropriate if both denote the same field element everywhere, but loses information if another verifier check or the application treats them as different integers.
A concrete exploit appeared in CIRCL's DLEQ proof implementation. The statement $(g, g_x, h, h_x)$ claimed knowledge of a common exponent $x$ such that $g_x = g^x$ and $h_x = h^x$ modulo $N$. The verifier accepted signed big.Int inputs, while challenge computation used Go's FillBytes, which encodes the absolute value. Thus $g_x$ and the signed integer $-g_x$ produced identical bytes.
An attacker could take an honest proof whose challenge $c$ was even and replace only $g_x$ with $-g_x$. Both the hash input and the relevant modular exponentiation stayed unchanged:
$$ (-g_x)^c \bmod N = (-1)^c g_x^c \bmod N = g_x^c \bmod N. $$The verifier therefore accepted the same proof for the altered input without the attacker knowing the witness. For square bases and a modulus $N = pq$ with both primes congruent to $3$ modulo $4$, negating a square leaves the subgroup of squares, so the altered input does not satisfy the intended relation.
The fix rejected inputs outside $0 < x < N$ before hashing. This closes the signed-input attack; the protocol's other input requirements, including group membership, still need to be enforced.
Different byte strings can also decode to the same signature or argument. The serialization is malleable if an attacker can change the encoding and it still passes verification. For example, accepting both compressed and uncompressed encodings of a curve point lets an attacker switch between them without knowing the secret key or witness.
We found a similar issue in OpenVM's proof decoder: Decode::decode_from_bytes returned without checking for trailing bytes. Appending bytes to a valid proof could therefore bypass duplicate detection based on proof bytes or hashes:
fn decode_from_bytes(bytes: &[u8]) -> Result<Self> {
let mut reader = Cursor::new(bytes);
Self::decode(&mut reader)
}
The fix rejects the input if reader.position() != bytes.len() as u64 after decoding.
Re-encoding an argument does not make a false statement pass verification. It can still break checks that compare bytes: a new accepted signature encoding breaks strong unforgeability when freshness is defined over signature bytes. If an application identifies pseudonyms by their bytes, it can treat two encodings of the same curve point as different identities within the same scope.
Check inputs before hashing them, and make sure values with different meanings don't end up as the same bytes. Accept only the expected encoding and reject extra bytes after a proof, even if the transcript is encoded correctly. Check for reused proofs too: a consistent encoding does not make each proof unique or prevent replay.
Silent FS: Do I need to absorb verifier challenges?
Once the encoding preserves the inputs, the transcript must also distinguish the operations performed on them, including the order, types, and number of challenges requested. The protocol's domain separator can identify a fixed schedule. We call omissions here Silent FS. You do not necessarily need to absorb the resulting values themselves: they are already determined by the current state and the prescribed derivation.
For example, consider two protocols that absorb the same instance and prover messages:
-
protocol A generates $c_1$ and $c'_1$, absorbs $m_2$, then generates $c_2$
-
protocol B generates only $c_1$, absorbs the same $m_2$, then generates $c_2$
If both incorrectly reuse the same domain separator and derive $c_2$ from only the absorbed messages, the extra challenge request is invisible. The two protocols reach the same later challenge without anyone finding a hash collision. Reusing the domain separator is the flaw here. Giving A and B distinct domain separators prevents this overlap without absorbing verifier challenges. Whether the overlap permits a forgery depends on their verification equations.
A related failure to distinguish contexts appeared in Aleo's native/raw Schnorr signatures. Native Aleo serialization and raw encoding fed into the same signing scheme without a domain separator identifying which format was intended. When two messages encoded to the same sequence of field elements under these different formats, a signature made for one context could verify in the other using the same public key.
The report described application-dependent risks, including a message presented to a user in one format being reinterpreted as a request authorizing an Aleo credit transfer. We recommended separating native Aleo signatures from other application signatures. This finding illustrates the need to identify the signing context; it does not demonstrate an omitted challenge-count attack.
The CFRG Fiat-Shamir draft's session-identifier rules required the identifier to distinguish the argument, hash suite, and ordered codecs, including how verifier messages are sampled.
Advancing a squeeze cursor need not preserve the number of bytes read once another value is absorbed. The draft's XOF duplex construction reset its output reader on nonempty absorption. Follow the construction's domain-separation and encoding rules; do not assume that using a sponge makes different protocol schedules distinguishable.
Specify when to absorb data and request challenges. Use domain separation to identify this schedule and the context in which the proof is used. Absorb runtime choices that change the schedule before deriving the affected challenges. Check whether the transcript API keeps different schedules distinguishable; a change in internal state alone is no guarantee.
Phantom FS: Do I need to absorb prover-supplied challenges?
Yes, before deriving a later challenge that randomizes a check involving them. Unlike the verifier-derived challenges above, these values come from the prover. Treat a prover-controlled challenge as a prover message. Omitting one can cause Phantom FS.
A Sigma OR proof proves knowledge of a witness for at least one of two statements, as explained in our Sigma protocol post. The verifier derives an overall challenge $c$, while the prover supplies a branch challenge $c_0$. The other branch uses $c_1 = c - c_0$. Knowing $c$ therefore does not determine the split.
The verifier must check both branch equations. It may combine them into one check using random coefficients, an optimization called batching. Any such coefficients must account for the prover's choice of split. We discuss how to derive them in the Batched FS section.
The Solana phantom-challenge bug shows what happens when the batching randomizer does not account for that split. The prover supplied c_max_proof, while the verifier derived another randomizer w to batch equations. Because c_max_proof was not absorbed before deriving w, the prover could choose it after seeing w and forge the final equation.
The vulnerable order was:
let c = transcript.challenge_scalar(b"c");
let c_max_proof = self.percentage_max_proof.c_max_proof;
let c_equality = c - c_max_proof;
transcript.append_scalar(b"z_max", &z_max);
transcript.append_scalar(b"z_x", &z_x);
transcript.append_scalar(b"z_delta_real", &z_delta_real);
transcript.append_scalar(b"z_claimed", &z_claimed);
// c_max_proof is missing
let w = transcript.challenge_scalar(b"w");
The fix was to absorb the branch challenge before deriving w:
transcript.append_scalar(b"c_max_proof", &c_max_proof);
Check who supplies each value and who can change it. Anything supplied by the prover is a prover message, even if an equation calls it a challenge. Absorb it before any later challenge that requires it to be fixed.
Batched FS: Do I need to absorb the proof itself, including the last prover messages?
The batching randomizer must account for the rest of the proof too, including the final responses. All of this data is now an input to the batch check. We call omissions in deterministic batching transcripts Batched FS, even when batching is only a verifier optimization.
Verifier-only batching is not FS: it removes no interaction and leaves proof generation unchanged. We use Batched FS here for missing inputs in hash-derived batching coefficients. Batching inside the proof protocol can involve FS challenges, as in the Shplonked example below.
Verifier-only batching combines verification equations using random coefficients that the prover does not need to construct the proof. It is separate from applying FS to the proof protocol. The prover learns a proof challenge before computing its response. Batching coefficients must come after all the values being checked are fixed, including that response. If the verifier derives the coefficients by hashing, it must absorb the response too. Reusing the proof challenge or its incomplete transcript can let an attacker change omitted values so that errors cancel. Individual verification can remain sound while the batch check accepts invalid proofs.
Our team reported this mistake in FastCrypto's KeyConsistencyProof. Its verifier combined several equations into one multi-scalar multiplication. The batching weights were derived from the proof challenge c:
let c = Self::challenge(
sender_public_key,
recipient_encryption_keys,
ciphertexts,
&self.a1,
&self.a2,
&self.a3,
);
// ...
let alpha = fiat_shamir_challenge(&("alpha", &c));
let beta = fiat_shamir_challenge(&("beta", &c));
The inner weights mu and rho were derived from c too. But c did not include the responses z1 and z2, so a malicious prover could modify them without changing any of those weights and make invalid checks cancel. The patch retained FS for the proof challenge and sampled the batching weights from fresh verifier randomness.
For verifier-only batching, the verifier can:
-
use fresh verifier randomness after receiving the complete batch
-
derive deterministic coefficients from all the data being checked, under a separate batching domain
-
perform the checks separately when batching provides little benefit
When fresh verifier randomness is available, using it for verifier-only batching is often simpler. In deterministic or on-chain environments, every batched term must be fixed before the coefficients are derived.
The batch verification algorithm in Section 5.6 of the CFRG Sigma-protocol draft included the session identifiers, complete instances, and complete argument strings (including responses) when deriving deterministic coefficients, using a separate batching transcript. The draft recommended this deterministic derivation and permitted fresh verifier randomness. This algorithm bound the inputs omitted in the examples above.
Batching can also be part of the proof protocol itself. An example is Aptos's Shplonked implementation, where the missing values were the externally supplied polynomial commitments commitment_msms. The verifier derived c and x, then combined those commitments with weights derived from the challenges:
append_batch_statement_to_transcript::<E>(trs, sets, y_rev, phi_y, com_y_hid);
let c: E::ScalarField = trs.challenge_scalar();
trs.append_point(pi_1);
let x: E::ScalarField = trs.challenge_scalar();
// ...
let merged = merge_msm_inputs_with_scales(&commitment_msms, &weights)?;
The helper absorbed evaluation data but not those commitments. Unless the caller had already absorbed them, an attacker could modify them without changing either challenge while preserving their weighted sum. The API needed to absorb the commitments or enforce that the caller had done so.
Shplonked's prover also uses c and x, so replacing them with private verifier randomness would require changing the protocol. The reviewed source explicitly marked this implementation as insufficiently vetted and for benchmarking only. This example concerns that API and does not establish a vulnerability in deployed Aptos validators.
For each randomized check, list all its inputs and make sure they are fixed before choosing the coefficients. For verifier-only batching, use fresh randomness after receiving the complete batch or derive coefficients from the complete batch under a separate domain. When the prover also needs the coefficients, follow the protocol's rules for what to absorb and when to derive them.
Summary
| Type | What goes wrong | What to check |
|---|---|---|
| Weak FS | The public statement does not influence the challenge | Absorb the complete statement alongside the required prior prover messages |
| Circuit substitution FS | A caller-selected verification key or parameter does not influence the challenges | Absorb changeable keys and parameters directly or through a collision-resistant digest; identify truly fixed parameters through the protocol |
| Ambiguous FS | Flattening or algebraic combinations erase distinctions between statements | Absorb individual values and preserve their order and variable shapes; validate shapes fixed by the protocol |
| Lossy FS | Encoding one value erases a distinction used by verification | Validate the input domain and encode each accepted value injectively |
| Silent FS | Different protocols or challenge schedules share the same hash inputs | Identify the full fixed schedule through domain separation; absorb variable choices that change it |
| Phantom FS | A prover-supplied challenge is omitted before a later randomizer | Treat branch challenges as prover messages and absorb them before later dependent challenges |
| Batched FS | Batching challenges omit commitments or proof values used in the combined check | Absorb all relevant values before their challenges; fresh verifier randomness is an option only for verifier-only batching |
Towards better Fiat-Shamir implementations
These bugs have motivated transcript APIs that automatically absorb proof messages and specifications that define the FS transformation.
Merlin uses an application domain separator and labels and lengths for messages and challenge requests. These distinguish protocol contexts and the roles of transcript entries, helping prevent different protocols from using the same hash inputs to derive challenges.
Halo2's transcript API couples proof I/O with absorption: reading or writing a proof point or scalar also absorbs it into the transcript. Its byte-stream format also helps avoid a separate verifier bug. The verifier reads the messages required by the protocol and verification key, so an omitted optional field in the proof cannot tell it to skip a required check.
SAFE specifies a sponge API for permutation-based schemes, including FS, and describes how to incorporate protocol metadata.
Chiesa and Orrù give concrete security bounds for a duplex-sponge FS construction. The work also provides spongefish, a Rust implementation with codecs for converting protocol messages to and from the sponge's domain.
These tools handle transcript operations, but developers must still identify the complete statement and parameters callers can choose, and follow the challenge schedule. Absorbing proof messages cannot catch missing public inputs or verification keys, and a sponge does not guarantee sound challenge derivation. Papers may leave FS to the implementer, so specifications must say what is absorbed, how it is encoded, and when challenges are derived. If a protocol calls another verifier, they must also say which component absorbs shared inputs.
We collaborated with other cryptographers on specifying Fiat-Shamir in the CFRG. This work covered message encoding, challenge decoding, session identifiers, and proof serialization. Protocol authors can use it to document their transcript operations so developers do not have to infer them from an interactive protocol.
Conclusion
After reading this post, you should now have a practical framework for reviewing Fiat-Shamir in real systems: identify what the prover can still change, determine which challenges must depend on those values, and examine how each challenge is used by the verifier. These checks are useful during design, implementation, and auditing.
Of course, none of these things replace having an expert take a look at your code! Here at zkSecurity, we have a lot of experience looking for bugs and finding them, and zkao is designed to detect these kinds of mistakes continuously. Make sure you reach out to us when the timing is right!
Acknowledgements
We would like to thank Stefanos Chaliasos, Martín Ochoa, Alin Tomescu and Michele Orrù for reviewing this post and for the feedback.
-
A proof remains sound even against a prover with unlimited computing power. An argument only guarantees soundness against provers with limited computing power. Fiat-Shamir with a concrete cryptographic hash gives an argument. ↩
-
Including $c_1$ in the hash for $c_2$ is optional: $\mathsf{domain}$, $\mathsf{instance}$, and $m_1$ already determine it. We include it to show the full exchange. The protocol must still specify the challenge schedule; see Silent FS. ↩
-
For example, the CFRG Fiat-Shamir draft suggested a label containing the application’s name and the Git commit hash of its cryptographic specification, so the transcript identifies the exact protocol rules being used. ↩
-
The ladder of points in the public key also lets a credential holder forge a credential. They can shift an attribute by $aG_{y,2}$ and its MAC component $V$ by $a(I_2-I_3)$, then generate a fresh presentation. Absorbing the complete statement does not stop this attack; the attribute MAC keys must also be separated by attribute count. ↩
-
Given a signature $(r,s)$, message representative $z$, and a compatible curve point $R$ whose x-coordinate reduces to $r$, choosing $Q = r^{-1}(sR-zG)$ makes ECDSA verification pass, subject to the usual validity checks. Changing the message gives another candidate key, without establishing knowledge of its private scalar. Ed25519 blocks this rearrangement because changing the key changes the challenge. The Let's Encrypt attack involved RSA; binding validation to the account key requires more than switching signature algorithms. ↩