Crescent Bench Lab: Measuring ZK Presentations for Real Credentials (JWT + mDL)

Crescent Bench Lab: Measuring ZK Presentations for Real Credentials (JWT + mDL)

12/31/20258 min • cryptography
ZKZKIDCrescentJWTmDLRustBenchmarks

TL;DR — I wanted a hands-on, reproducible sense for what Crescent “costs” in practice:

  • How expensive is one-time setup (zksetup) and credential proof step (prove) per parameter?
  • How fast is proof generation (show) and verification (verify) once everything is set up?
  • How big are the resulting proofs?

Repo: https://github.com/reymom/zkid-crescent-lab

0. What this project is (and what it is not)

This is a benchmarking harness, not a production integration:

  • It vendors microsoft/crescent-credentials and uses its existing circuit + test-vector generation flow.
  • It runs Crescent’s CLI steps (zksetup, prove, show, verify) under a uniform runner.
  • It writes clean artifacts (results.csv, optional samples.csv, per-param metadata tables) and generates a small plot suite.

What it does not try to do:

  • Build a full Issuer/Client/Verifier deployment like Crescent’s sample/ folder (useful context, but not necessary for benchmarking).
  • Make security claims about end-to-end credential issuance or browser-extension UX.

1. Background: what Crescent is doing (in one paragraph)

Crescent aims to give stronger privacy for existing credentials: you start from something common like a JWT (or an mDL credential), and you generate a zero-knowledge presentation that selectively reveals only what you want, while proving the rest is valid and signed.

If you want the full story, these are the best starting points:

  • The paper: “Crescent: Stronger Privacy for Existing Credentials” (ePrint 2024/2013)
  • Microsoft’s repo + circuit setup docs
  • Christian Paquin’s write-up / talk notes (great “engineering mental model” companion)

(Links are in the References section at the end.)

2. Repository layout

The repo is intentionally small: one Rust crate + a few scripts + plotting tools.

zkid-crescent-lab/
  scripts/                # vendor + setup + vector generation + sanity testing
  src/                    # bench runner + outputs + stats + param metadata
  tools/                  # plot suite (separate venv)
  out/                    # all run artifacts + merged plots
  vendor/                 # vendored crescent-credentials (ignored in this tree)

Outputs you should expect after a run

Each run writes a folder like out/run_<timestamp>Z/:

out/run_.../
  meta.json               # basic run metadata
  results.csv             # aggregated rows (per param x step)
  samples.csv             # optional raw samples (per iteration)
  param_table.md          # per-param metadata (claims, pub IO, etc.)
  param_table.csv

And the plotting suite writes:

out/plots/
  merged_results.csv
  mean_ms_by_step.png
  show_p50_p95_mean.png
  verify_p50_p95_mean.png
  ... (optional additional plots)

3. Repro: exact steps I used

This project uses two Python environments, on purpose:

  • .venv/ — “project Python” (used by the setup/vector scripts in the root context).
  • .venv-tools/ — “plotting Python” (pandas/matplotlib only; I keep it separate so plotting deps don’t leak into whatever Crescent tooling wants).

3.1 Vendor Crescent

From repo root:

./scripts/vendor_crescent.sh

3.2 Setup Rust + Crescent build prerequisites

./scripts/setup_crescent.sh

If you want a quick sanity check that the vendored Crescent CLI builds:

cd vendor/crescent-credentials/creds
cargo build --release --bin crescent
ls -la target/release/crescent

3.3 Setup Python (project scripts)

./scripts/setup_python.sh

This creates .venv/ in the repo root and installs requirements.txt.

3.4 Generate / prepare test vectors

./scripts/setup_vectors.sh

This is where Crescent’s circuit setup + vector generation happens for the benchmark parameters.

3.5 Run a quick sanity test

./scripts/test_crescent.sh

At this point, the harness should be able to run run and bench.

3.6 Run the benches

The crisp mapping Crescent gives:

  • rs256: RSA-SHA256 JWT, hardcodes disclosure of email domain
  • rs256-sd: RSA-SHA256 JWT, selective disclosure
  • rs256-db: device-bound RSA-SHA256 JWT, selective disclosure
  • mdl1: device-bound ECDSA mDL, selective disclosure

I ran these:

cargo run --release -- bench --param rs256 --iters 30
cargo run --release -- bench --param rs256-db --iters 30
cargo run --release -- bench --param rs256-sd --iters 30
cargo run --release -- bench --param mdl1 --iters 30

This matches Crescent’s intended flow: setup once per parameter, then run show/verify repeatedly.

Important detail: bench repeats show and verify (30 times), but runs zksetup and prove once.

That’s not an accident — those first two are heavy and typically one-time-per-parameter operations. The repeated operations (client proof generation + verifier check) are where latency matters.

Benchmark environment

  • Host: Ubuntu (Linux 6.8.0-90-generic, PREEMPT_DYNAMIC)
  • CPU: Intel(R) Core(TM) i7-10510U @ 1.80GHz (4C/8T)
  • Rust: 1.88 (pinned via rust-toolchain.toml)
  • Vendored crescent-credentials: c639608

3.7 Plotting venv (tools only)

python3 -m venv .venv-tools
source .venv-tools/bin/activate
pip install -r tools/requirements-tools.txt

Then:

python3 tools/plot_suite.py --out out

That produces the images under out/plots/.

4. Harness design (what I actually built)

The Rust crate is deliberately boring: it’s a runner + output writer + stats.

4.1 Steps and policy

The harness runs Crescent as:

  • zksetup (once)
  • prove (once)
  • show (N times)
  • verify (N times)

prove generates the Groth16 credential proof and stores client state.

It also handles a real Crescent footgun: for some parameters the test vectors already embed a presentation message. Passing --presentation-message in that case makes Crescent panic with “Multiple presentation messages”, so I suppress the flag when a heuristic detects that field in the test vector JSON.

4.2 Logging policy

Default behavior:

  • No logs on success.
  • Keep logs on failure (stdout/stderr dumped under out/run_.../logs/).
  • You can force logs via --keep-logs.

This keeps run directories clean while preserving evidence when something breaks.

4.3 Proof size measurement

After each successful show, the runner scans the relevant test-vectors/<param>/ folder for a newly created artifact (heuristic: filename contains “presentation” or “proof”, and mtime is newer than the step start). It records the byte size and aggregates it.

That’s why artifact_iters is 30 for show, and 0 for the other steps.

5. Parameter metadata table (credential type, sig, claims, pub IO)

Each run writes a per-param metadata table to:

  • out/run_.../param_table.md
  • out/run_.../param_table.csv

For the blog, I keep one consolidated “human” table here.

Notes on fields:

  • Claim count: the number of claims supported by the parameter config (this is the “Claims: 2 vs 10” distinction that shows up during setup).
  • Public inputs/outputs: as reported by the circuit setup output for the parameter.
  • Credential + signature: JWT typically maps to RS256 here; mDL commonly uses ES256-style ECDSA.
param credential signature claim count
rs256 JWT RS256 2
rs256-sd JWT RS256 2
rs256-db JWT RS256 2
mdl1 mDL ES256 10

Exact circuit IO + claim wiring is in out/run_.../param_table.md (generated by the harness).

6. Results summary

Here are the merged results I got from out/plots/merged_results.csv (latest run per param), plus proof sizes.

6.1 Runtime summary (ms)

Important: zksetup and prove have n=1, so p50=p95=mean by definition in this dataset. The meaningful distributions are show and verify (n=30).

param zksetup (ms) prove (ms) show p50 / p95 verify p50 / p95
rs256 27,606 25,606 17 / 19 8 / 9
rs256-sd 31,323 27,316 17 / 18 9 / 10
rs256-db 36,648 33,265 289 / 372 140 / 171
mdl1 31,988 42,969 302 / 364 144 / 189

Note: show/verify iterations are measured after the initial setup, so they reflect steady-state behavior on this machine (not first-run compilation or cold-start provisioning).

6.2 Proof size (bytes)

These are sizes of the artifact emitted after show (n=30, so the percentiles are meaningful).

param proof size p50 (bytes) mean (bytes) max (bytes)
rs256 1,437 1,437 1,437
rs256-sd 1,648 1,648 1,648
rs256-db 15,485 15,482 15,493
mdl1 16,319 16,318 16,330

6.3 What I take away from this

A few observations that feel “portfolio-worthy” (and honest):

  1. The one-time steps are expensive, and that’s fine. zksetup and prove are on the order of tens of seconds. In an actual system, those costs are amortized per parameter/schema.
  2. Online costs (show + verify) are split into two regimes:
    • rs256 / rs256-sd: ~17ms show and ~8ms verify, with ~1.4–1.6KB proofs
    • rs256-db / mdl1: ~300ms show and ~150ms verify, with ~15–16KB proofs That’s a clean “small proof / fast verify” vs “bigger proof / slower verify” split.
  3. The proofs are stable in size for these params. In these runs, proof size variance is tiny (especially for rs256 and rs256-sd which were literally constant).

6.4 Show latency distribution

show is the online path (client generates a ZK presentation; verifier checks it). These are the only steps I repeated 30× per parameter, so these distributions are the meaningful ones.

show latency distribution

6.5 Proof size (bytes) and the latency/size tradeoff

Crescent’s show step emits a presentation artifact; I record its byte size per iteration.

proof size distribution

To sanity-check the relationship between proof size and proving latency, here is a per-iteration scatter:

show latency vs proof size

6.6 Amortization: when do the heavy steps stop mattering?

zksetup + prove are one-time per parameter/schema costs (tens of seconds).
For actual deployments, what matters is the amortized cost per presentation:

amortized(n) = (show+verify)p50 + (zksetup+prove)/n

amortized cost curve

Offline cost (one-time per parameter) is ~53–76s here, while online cost (p50 show+verify) ranges from ~25ms (rs256/rs256-sd) to ~450ms (mdl1/rs256-db).

The amortization curve below shows how quickly the one-time cost becomes negligible after repeated presentations.

9. References

Stay Updated

Get notified when I publish new articles about Web3 development, hackathon experiences, and cryptography insights.

You might also like

Baby-Ligero: Three Tiny Tests for a Tiny Circuit — ZK Hack S3M5

A mini Rust lab that implements a baby version of Ligero's three tests — proximity, multiplication, and linear — for a tiny arithmetic circuit, and uses them to see soundness amplification in action.

Norm Blowup in Lattice Folding (LatticeFold Lab) — ZK Hack S3M4

A hands-on Rust experiment exploring why folding causes norm blowup in lattice commitments, and how decomposition keeps the digits small — the core idea behind LatticeFold and LatticeFold+.

NTT Bench — BabyBear vs Goldilocks (ZK Hack S3M2)

Hands-on NTT benchmarks over BabyBear and Goldilocks fields, connecting Jim Posen’s ZK Hack talk on high-performance SNARK/STARK engineering to real Rust code.