◂ Back

How this works

Two audiences below: a plain-English overview, then a detailed technical account of every design decision, optimization, and shortcut in this build.

Overview (for everyone)

Today, proving something about yourself online usually means handing over a whole ID document — name, address, birthdate, license number — even when the site only needs one fact ("is this person over 21?"). That over-sharing is the problem.

This demo shows a better shape. A DMV issues you a mobile driver's license. Instead of showing it to a website directly, your wallet converts it — once — into a compact "session credential," with the DMV-signed original never leaving your device. After that, the website can ask targeted questions ("over 21?", "license still valid?", "resident of Maryland?") and your wallet answers each with a zero-knowledge proof: mathematical evidence that the answer is yes, revealing nothing else. Each answer takes a couple of milliseconds and can't be linked to your other visits.

You stay in control: the wallet shows you exactly what a site is asking and waits for your approval before proving anything.

The architecture (technical)

Four tiers, each doing one job:

  1. mdoc (ISO 18013-5). The DMV issues a standards-shaped mobile license: a signed Mobile Security Object over salted attribute digests, with a Document-Signer certificate chaining to an issuing-authority (IACA) root. The verifier pins only the cold root; the hot signing key rotates freely.
  2. Translation. Once per epoch, the license is turned into a Pedersen committed attribute vectorC = Com(k, birthdate, expiry, residence, …) — bound to a holder secret k. This is the privacy-critical step, and it has two backends (below).
  3. Session credential (KVAC). The site issues a keyed-verification anonymous credential (a µBBS algebraic MAC) over that committed vector. This is the fast, reusable token.
  4. Shows. Per request, the wallet presents the credential with a freshly re-randomized commitment and a Schnorr proof — plus optional predicate proofs (range proofs for "≥ 21" over a hidden birthdate; equality proofs for "residence = MD"). ~2 ms, under 1 KB, unlinkable across shows.

The translation step (real zero-knowledge)

The wallet runs a Groth16 proof (Microsoft's Crescent) that it holds a validly-signed license whose committed attributes match — the site learns nothing but the commitment. The recovered data on the server is literally empty {}. Deliberately, translation proves only possession: nothing about the license's contents — not age, not validity, not even which authority issued it — is proven or revealed at enrollment (the issuer identity rides as a hidden commitment). Every substantive fact is checked at Verify time by fast predicates over the committed values, so disclosure happens only when you approve a specific site request.

Because translation reveals nothing, it runs unconditionally and without a prompt: a wallet with no license still enrolls (against a special "null issuer" whose key is public), producing a transcript a site cannot distinguish from a real one. The resulting credential is inert — it fails every site's issuer check — so possession of a real license leaks only at Verify, at a moment you choose. There is nothing to consent to at enrollment, so there is no enrollment dialog.

Bridging Crescent to our session tier is the novel piece: Crescent exports Pedersen commitments to attributes over BN254; a same-group Schnorr "link proof" glues those to our commitment C and to the µBBS issuance request, with each attribute a shared witness, so the site's credential provably covers exactly the Crescent-attested values — without either party revealing them.

The prover-helper: the one genuinely fake part of this demo

The Crescent proof is heavy — roughly 40 seconds and several gigabytes of RAM, using a 700 MB proving key. That cannot run in a browser tab, so here the proving is done by a server-side prover-helper. This is the only part of the architecture that isn't what a real deployment would do, and it's worth being loud about rather than hiding.

What the helper needs — and what it does not. Producing the proof requires your mdoc (the signed license) but no secret: not the device private key, not the session holder key. So a machine running the prover learns your license contents (a privacy cost) but gains no ability to act as you (no security cost) — it can't forge shows or mint credentials, because those need secrets it never sees.

The demo's framing: pretend the prover runs at the issuer. Since the prover learns your mdoc, the honest question is "who is allowed to see it?" We answer by treating the prover-helper as a service operated by the issuer — the DMV that signed your license in the first place. The DMV already holds every field of your mdoc, so running your proof there discloses nothing it didn't already know: zero incremental privacy loss. (In this deployment the helper is co-located with the issuer on the same host to make that literal.) The only residual is metadata — the issuer could see when you enroll — and note the prover is relying-party-independent, so it never learns which site you enroll at.

Why this is unsatisfying, and how it goes away. Pretending the issuer runs your prover is a crutch. In the real world the proof should be produced on your own phone, by your wallet, so no server sees your mdoc at all — much as today's (non-zero-knowledge) mobile driver's licenses already work in the proximity flow, where the phone performs the presentation locally and the reader talks only to the device. Two changes close the gap:

The per-request shows (the fast part) already run locally in your browser via WebAssembly, with no server proving at all. It's only the one-time enrollment proof that we outsource — and that's the single place where "on a server" stands in for "on your device."

The device key: where it's used, and where it isn't

An ISO mobile license is device-bound: at issuance your phone generates a keypair (in a real deployment, inside the Secure Enclave / secure element, non-extractable), and the issuer signs the public half into the license. Proving control of the matching private key is how you show the license is yours and not a copy.

What matters is when that key is exercised:

The separation is deliberate: a long-term, hardware-bound device key authorizes the creation of a short-lived, software-held session credential, and all the frequent, unlinkable proving runs against that session credential. In this build the device key is a fixed demo key held by the prover-helper; making it a genuine per-user key generated and held by the wallet is a scheduled refinement — and is exactly what makes the enrollment nullifier meaningful.

Optimizations worth noting

Shortcuts and what is not real in this build

Honesty matters for a research demo. This deployment takes these documented shortcuts:

What is fully real: the µBBS KVAC session tier, the range/equality predicate proofs, the Crescent Groth16 translation and its link proof to our commitment, and the property that on the Crescent path the site sees only zero-knowledge proofs.

This page is kept current with each redeployment. Build: WC3b (Crescent backend, prover-helper, tailnet deploy; translation minimized to possession-only with a HIDDEN issuer — age, validity, and "issued by an authority this site trusts" are all checked exclusively at Verify time, the issuer via an equality proof over a committed tag). Enrollment is now unconditional and prompt-free (null issuer) AND reflects your actual edited license (real per-user proving on the server). A translate rate-limiting nullifier and per-user device keys are the next changes.