Skip to Content
⚠️ This documentation is AI-generated, for personal use only, and is not supported or endorsed by Google.
ReferenceProof SystemZK (top-level)

ZK (top-level)

The zk/ module is the entry point most custom systems call. It composes circuits/compiler/, sumcheck/, ligero/, and the Fiat-Shamir transcript into a single prover class (ZkProver) and verifier class (ZkVerifier). Typical attestation flow: compile your circuit, build a witness from a TPM2 quote or TDX report, hand both to ZkProver, ship the serialized ZkProof, verify on the far side.

When to reach for it

  • You have a compiled QuadCircuit and a witness and want a proof. Shortest path.
  • You want transcript binding and Fiat-Shamir handled for you (you still instantiate the transcript and keep prover/verifier transcripts symmetrical — see Design overview for the contract).
  • You want to pick soundness via rate / nreq without wiring up LigeroParam by hand.

Design overview

ZkProver<Field, RSFactory> and ZkVerifier<Field, RSFactory> are templates parameterised by the working field and a Reed-Solomon factory. For prime fields with a native root of unity of sufficient order (for example P-256’s base field, natural for ECDSA-over-P-256 attestation circuits) use FFTConvolutionFactory<Field>. For binary fields or prime fields that lack one, Longfellow lifts the FFT into a quadratic extension via FFTExtConvolutionFactory<Field, FieldExt>. Neither factory surfaces in user code except as a constructor argument — you build it once and pass it to both sides.

The prover is deliberately two-phase. ZkProver::commit() writes the Ligero witness commitment — a Merkle root over Reed-Solomon encodings of the private witness plus random pad. ZkProver::prove() then runs sumcheck over the circuit, derives Ligero linear constraints from the sumcheck transcript, and finalises the Ligero proof. commit() must be called before prove(): the Ligero commitment’s Merkle root is absorbed into the transcript before sumcheck challenges are derived. That ordering is the Fiat-Shamir discipline that keeps the protocol sound — a prover who saw the challenges first could pick a commitment that opens favourably.

Prover and verifier each instantiate their own Transcript seeded with the same bytestring (a protocol domain tag naming your attestation scheme) and fed the same public inputs in the same order. Challenges are derived deterministically from transcript state, so symmetric transcripts produce identical challenges; any divergence — a missed absorb, a reordered public input, a mismatched domain tag — makes the verifier reject. See Transcript & Randomness for the absorb/squeeze contract.

The witness W is a Dense<Field> holding all inputs, public prefix first then private, with the boundary at c.npub_in. The zk/ layer extracts the private portion internally and commits only that; the verifier is handed the public prefix as its own Dense<Field> and never sees the private tail.

API surface

Prover

  • Constructor: ZkProver(const Circuit<Field>& CIRCUIT, const Field& F, const RSFactory& rs_factory) — binds the prover to a specific compiled circuit and the algebra backing it.
  • void commit(ZkProof<Field>& zkp, const Dense<Field>& W, Transcript& tp, RandomEngine& rng) — commits the private portion of W via Ligero and absorbs the commitment root into tp. rng must be a SecureRandomEngine in production; only the prover consumes true randomness (for the zero-knowledge pad).
  • bool prove(ZkProof<Field>& zkp, const Dense<Field>& W, Transcript& tsp) — runs sumcheck, derives Ligero linear constraints from the sumcheck transcript, finalises the Ligero proof. Returns false if eval_circuit fails or the output layer is not all-zero; the caller is expected to have validated the witness first. Aborts if commit() has not been called on the same instance.

Verifier

  • Constructor: ZkVerifier(const Circuit<Field>& c, const RSFactory& rsf, size_t rate, size_t nreq, const Field& F)rate and nreq are the Ligero soundness knobs. See the Ligero page for how to pick them; a common starting point is the kLigeroRate = 7, kLigeroNreq = 132 pair used in upstream tests. An overload accepting block_enc exists for advanced column-packing.
  • void recv_commitment(const ZkProof<Field>& zk, Transcript& t) const — absorbs the Merkle root from zk.com into the verifier’s transcript, mirroring the prover. May be called multiple times before verify() if you are composing several proofs in parallel against a shared transcript.
  • bool verify(const ZkProof<Field>& zk, const Dense<Field>& pub, Transcript& tv) const — checks the proof against the public inputs. Returns true iff all sumcheck and Ligero constraints hold.

Proof blob

ZkProof<Field> holds the sumcheck proof, Ligero commitment root, Ligero proof, and the LigeroParam derived from rate/nreq. Construct it with ZkProof(const Circuit<Field>& c, size_t rate, size_t req) on both sides with identical parameters. Serialize with write(std::vector<uint8_t>& buf, const Field& F) and deserialize into an empty ZkProof with read(ReadBuffer &buf, const Field& F)read returns false on malformed or truncated input. Serialization preserves the subfield/full-field boundary: subfield elements serialize with Field::kSubFieldBytes instead of Field::kBytes, and opened Merkle columns use run-length encoding to exploit that. This keeps proofs small for circuits whose witness is mostly base-field scalars — common in attestation circuits that operate on bytes of a quote or report.

End-to-end guarantees

Knowledge soundness. If verify() accepts, a prover produced the proof while “knowing” a witness W that satisfies the circuit, up to the soundness error set by the Ligero rate/nreq parameters and the sumcheck round count. Sumcheck’s per-round error is roughly degree / |Field|; over a 256-bit prime field, cumulative sumcheck error is negligible compared to Ligero’s statistical error, so Ligero dominates the final bound.

Zero-knowledge. The random pad generated in commit() blinds both the witness commitment and every polynomial opening sumcheck later exposes. A verifier sees random-looking evaluations and a small set of opened Merkle columns; nothing computationally leaks about W beyond the public predicate — the TPM2 quote or TDX report stays private while the attestation statement is checked.

Non-interactivity. All challenges are derived via Fiat-Shamir from the shared transcript, which absorbs the circuit identity (see circuit_id), every public input, and the Ligero commitment root before any challenge is squeezed. A malicious verifier cannot choose challenges adversarially; a malicious prover cannot adapt the commitment to favourable ones.

Example

Adapted from lib/zk/zk_testing.h. The constants kLigeroRate and kLigeroNreq are defined there and must match on both sides.

ZkProver<Field, RSFactory> prover(*circuit, F, rs_factory); ZkProof<Field> zkpr(*circuit, kLigeroRate, kLigeroNreq); Transcript tp((uint8_t*)"my-protocol", 11); SecureRandomEngine rng; prover.commit(zkpr, W, tp, rng); prover.prove(zkpr, W, tp); std::vector<uint8_t> wire; zkpr.write(wire, F); // serialize for transport // --- verifier side (possibly a separate process) --- ZkProof<Field> zkpv(*circuit, kLigeroRate, kLigeroNreq); ReadBuffer rb(wire); zkpv.read(rb, F); ZkVerifier<Field, RSFactory> verifier(*circuit, rs_factory, kLigeroRate, kLigeroNreq, F); Transcript tv((uint8_t*)"my-protocol", 11); verifier.recv_commitment(zkpv, tv); bool ok = verifier.verify(zkpv, pub_inputs, tv);

See it used

Last updated on