Skip to content

Tutorial: prove without revealing

This tutorial walks you through the smallest meaningful zero-knowledge application: a prover convinces a verifier that they know a secret without revealing it. You’ll write the Noir circuit, prepare keys, generate a proof, and verify it from your own Rust program.

By the end you’ll understand the full ProveKit loop and be ready to apply it to real workloads like credential verification, vote eligibility, or off-chain computation attestation.

Time: about 20 minutes. Prerequisites: a working ProveKit checkout, see Installation.

A circuit that proves “I know two field elements whose Poseidon2 hash equals this public commitment”, and a Rust program that loads the resulting proof, verifies it, and prints the public commitment.

In a real application the “two field elements” might be the components of a private identity key; the “public commitment” is the hash that gets stored on-chain or in a server. Verification proves the prover holds the secret without revealing it.

  1. Create a new Noir package inside the repo for this tutorial.

    Terminal window
    cd noir-examples
    mkdir secret-knowledge && cd secret-knowledge
  2. Add a Nargo.toml that pulls in the poseidon2 dependency.

    Nargo.toml
    [package]
    name = "secret_knowledge"
    type = "bin"
    authors = [""]
    compiler_version = ">=0.22.0"
    [dependencies]
    poseidon2 = { tag = "v0.5.0-beta.0", git = "https://github.com/TaceoLabs/noir-poseidon", directory = "poseidon2" }
  3. Write the circuit. Public inputs go in the function signature after private ones, but you mark public inputs with the pub keyword.

    src/main.nr
    use dep::poseidon2;
    fn main(secret: [Field; 2], commitment: pub Field) {
    let computed = poseidon2::bn254::hash_2(secret);
    assert(computed == commitment);
    }

    This circuit enforces one constraint: that poseidon2(secret) == commitment. Because secret is private and commitment is public, the resulting proof says “the prover knows a secret that hashes to commitment”, and nothing else.

You need a valid (secret, commitment) pair where commitment == poseidon2(secret). Computing Poseidon2 outside the circuit takes an off-circuit hash implementation; for this tutorial, reuse a precomputed pair from noir-examples/basic:

Prover.toml
secret = ["1", "2"]
commitment = "0x0e90c132311e864e0c8bca37976f28579a2dd9436bbc11326e21ec7c00cea5b2"

That commitment is Poseidon2(BN254) of [1, 2], already verified by the canonical basic example. Once you’ve completed the tutorial, swap in your own (secret, commitment) pair generated however you like.

Then compile the circuit:

Terminal window
cargo run --release --bin provekit-cli -- prepare

prepare reads only the Noir source, not Prover.toml. It writes secret_knowledge.pkp (prover key) and secret_knowledge.pkv (verifier key) into the package directory.

  1. Generate a proof. ProveKit reads the prover key (.pkp) and your inputs, and writes proof.np.

    Terminal window
    cargo run --release --bin provekit-cli -- prove
  2. Verify it locally, this is the fastest sanity check before integrating elsewhere.

    Terminal window
    cargo run --release --bin provekit-cli -- verify

    Exit code 0 means the proof verifies. If you see a failure, the most common cause is a stale .pkp / .pkv pair, re-run prepare.

  3. Inspect the public inputs the proof actually exposes.

    Terminal window
    cargo run --release --bin provekit-cli -- show-inputs --hex \
    secret_knowledge.pkv \
    proof.np

    You’ll see only commitment, the secret never appears.

The CLI is great for development. In production, the verifier usually runs inside your code, a backend service, a smart-contract gateway, a CLI tool. Let’s verify the same proof from a standalone Rust binary.

  1. Create a new Cargo project alongside (not inside) the repo.

    Terminal window
    cd ../.. # back to repo root
    cd .. # leave the provekit directory
    cargo new --bin secret-verifier
    cd secret-verifier
  2. Add the dependencies.

    Cargo.toml
    [package]
    name = "secret-verifier"
    version = "0.1.0"
    edition = "2021"
    [dependencies]
    provekit-common = "1.0.0"
    provekit-verifier = "1.0.0"
    anyhow = "1"
  3. Write the verifier.

    src/main.rs
    use std::path::Path;
    use provekit_common::{file::read, NoirProof, Verifier};
    use provekit_verifier::Verify;
    fn main() -> anyhow::Result<()> {
    // Adjust these paths to wherever you ran `prepare`/`prove`.
    let pkv_path = Path::new("../provekit/noir-examples/secret-knowledge/secret_knowledge.pkv");
    let proof_path = Path::new("../provekit/noir-examples/secret-knowledge/proof.np");
    let mut verifier: Verifier = read(pkv_path)?;
    let proof: NoirProof = read(proof_path)?;
    verifier.verify(&proof)?;
    println!("Proof verified. Prover knows a Poseidon2 preimage of the published commitment.");
    Ok(())
    }
  4. Run it.

    Terminal window
    cargo run --release

    You should see Proof verified.

A user with a Prover.toml can convince anyone with the matching .pkv and proof.np that they know two field elements that hash to the commitment, without ever transmitting those elements.

That’s the whole zero-knowledge claim. Everything else in ProveKit is plumbing around this shape: bigger circuits, more complex public input contracts, different hash configurations, different transport layers.

Terminal window
cd ../provekit/noir-examples/secret-knowledge
rm -f secret_knowledge.pkp secret_knowledge.pkv proof.np
rm -rf target