Private age credentials on the Midnight Network.
Prove that your age satisfies a predicate — “Over 18”, “Between 18 and 35” — without disclosing your date of birth, document number, nationality, or any other identity data.
Scan ID locally → pick a predicate ("Over 18") → ZK proof generated in your browser
→ one 32-byte commitment optionally recorded on the Midnight ledger
| Stays on your device | Birth year, document number, nationality, the raw photo, your master secret |
| Goes on-chain | A single Bytes<32> commitment — computationally unlinkable to you without your private key |
Everything runs client-side. There is no backend that could ever see your data.
- How it works
- Prerequisites
- Quick start (clone & run)
- Generating a proof
- Verifying a credential on-chain
- How the zero-knowledge privacy works
- Project structure
- Command reference
- Compiling the Compact contract
- Contributing
- Troubleshooting
- Security notes
- Tech stack
- Scan your ID. Your webcam reads the MRZ strip (the rows of
<characters at the bottom of a passport or ID card). OCR runs locally via Tesseract WASM. Only the birth year is extracted; the image is never uploaded or stored. - Pick a predicate. Choose what to prove. A per-predicate secret is derived in-browser
with
HMAC-SHA256(master_secret, "minAge:maxAge")— your master secret never leaves the device and never enters a circuit. - Generate the proof. A Compact zero-knowledge circuit checks
age = currentYear − birthYearagainst the predicate and outputs a 32-byte commitment. A connected wallet is required: the proof is submitted on-chain and the commitment is inserted into the contract’s publicproofsset on the Midnight ledger. - Share & verify. Hand a verifier the commitment (e.g. as a QR code). They confirm it is recorded and not revoked — without learning anything about you.
| Tool | Required version | Install |
|---|---|---|
| Node.js | ≥ 20 | https://nodejs.org |
| pnpm | ≥ 9 | npm install -g pnpm@9 |
| Docker | any recent | https://docs.docker.com/get-docker/ — only for on-chain proofs |
Check your versions:
node --version # must be v20 or higher
pnpm --version # must be 9 or higherThe Compact compiler is not required to run the app. The
generated/directory is committed with the compiled contract bindings, ZK keys, and zkIR, so the app builds and runs out of the box. You only need the compiler if you changecontract/verime.compact.
These steps are designed to work with zero errors on a fresh machine.
# 1. Clone
git clone https://github.com/Mechack08/verime.git
cd verime
# 2. Install all workspace dependencies (a postinstall step patches a few deps automatically)
pnpm install
# 3. Start the app
pnpm startOpen http://localhost:5173 (Vite will pick the next free port — e.g. 5174 — if
5173 is taken; watch the terminal output for the exact URL).
The UI runs immediately — you can scan an ID and choose a predicate right away. Generating a proof requires a connected Midnight wallet (and the proof server), since the credential is recorded on-chain. See Generating a proof below for the one-time setup.
No camera? You still see the full flow; the scan step simply needs camera permission. Grant it via the lock icon in the address bar.
localhostis treated as a secure origin, so the camera API works without HTTPS.
Proof generation always records the credential on-chain, so it requires a Midnight wallet and the proof server (Docker). The app does not generate off-chain/local proofs.
1. Install a Midnight wallet browser extension and switch it to the pre-production
network (preprod). Any wallet that injects into window.midnight works (e.g. Lace for
Midnight). Fund it with test tokens so it can pay transaction fees.
2. Start the proof server + app together:
pnpm start:full # starts the proof server on :6300, then the app
# in another terminal, optionally watch the server logs:
pnpm proof-server:logsOr run them separately:
pnpm proof-server:start
pnpm startStop the proof server when finished:
pnpm proof-server:stop3. Connect the wallet — click Connect Wallet in the top bar and approve the request.
4. Generate & record — complete the scan flow and click Generate & Record On-Chain.
The dApp builds the transaction, the wallet balances and submits it, and the commitment is
written to the contract’s proofs set.
The network endpoints live in
sdk/src/types.tsasPREPROD_CONFIG. Change them there to target a different network.
Verification is a read-only check over public ledger state — it needs no wallet, no fees, and no proving. On the My Credentials page, click Verify on-chain on any credential. The app queries the contract state from the indexer and reports:
| Result | Meaning |
|---|---|
| Valid on-chain | The commitment is in the contract’s proofs set and not revoked |
| Revoked | The matching revoke marker is in the revoked set |
| Not found | The commitment isn’t recorded — e.g. the issuing transaction never finalized |
| Error | The contract isn’t deployed yet, or the indexer hasn’t caught up |
This is the correct way to confirm a commitment — a generic block explorer cannot, because the commitment is an element inside the contract’s state set, not a transaction or address.
master_secret (32 random bytes, localStorage only, never leaves the device)
│
├─ HMAC-SHA256(·, "18:0") → derived_secret → persistentHash(·) → commitment (Over 18)
├─ HMAC-SHA256(·, "35:0") → derived_secret' → persistentHash(·) → commitment (Over 35)
└─ HMAC-SHA256(·, "revoke") → revoke_key → persistentHash(·) → revocation marker
- Two proofs from the same person for different predicates produce different, unlinkable commitments.
- The Compact circuit only ever receives the already-derived scalar as a witness. The master secret never enters any circuit.
- Calling
revoke()inserts the revocation marker once and invalidates every credential for that user —verify_proofthen returnsfalsefor any of their commitments.
The contract (contract/verime.compact) exposes three circuits:
issue_proof (prove age + record commitment), verify_proof (read-only validity check), and
revoke (self-revoke all credentials).
verime/
├── package.json pnpm workspace root — all scripts live here
├── pnpm-workspace.yaml
│
├── contract/
│ ├── verime.compact Compact smart contract (issue_proof / verify_proof / revoke)
│ └── Makefile compile + keygen targets
│
├── generated/ committed compiler output (no compiler needed to run)
│ ├── contract/ JS + TypeScript bindings imported by the SDK
│ ├── keys/ .prover / .verifier keys (served at /zk in dev)
│ └── zkir/ compiled ZK intermediate representation
│
├── sdk/ framework-agnostic TypeScript SDK (@verime/sdk)
│ └── src/
│ ├── types.ts shared types + PREPROD_CONFIG network endpoints
│ ├── secret.ts master-secret + HMAC-SHA256 derivation
│ ├── commitment.ts persistentHash commitment helper
│ ├── storage.ts credential persistence (localStorage)
│ ├── contract-api.ts VeriMeAPI — local proof + on-chain entry point
│ ├── contract-on-chain.ts on-chain issue_proof submission flow
│ ├── contract-verify.ts read-only on-chain verification
│ └── providers.ts Midnight provider factory (ZK, indexer, wallet)
│
├── frontend/ React + Vite app (@verime/frontend)
│ └── src/
│ ├── pages/ Home, Scan (3-step flow), MyCredentials, BuildProof
│ ├── components/ Topbar, Footer, Logo, icons, ui/ (Button, Badge, …)
│ ├── hooks/ useWallet, useIdScanner, useProofGenerator
│ ├── contexts/ WalletContext
│ └── styles/globals.css Tailwind v4 + Midnight design tokens
│
└── tests/
└── contract/verime.test.ts 22 unit tests (Vitest)
Run everything from the repo root — no need to cd into sub-packages.
# ── Run ──────────────────────────────────────────────────────────────────────
pnpm start # start the app (local mode) → http://localhost:5173
pnpm start:full # start proof server + app (on-chain mode)
# ── Setup ────────────────────────────────────────────────────────────────────
pnpm install # install all workspace deps (runs the dep patch postinstall)
# ── Develop ──────────────────────────────────────────────────────────────────
pnpm dev # alias of `pnpm start`
pnpm build # production build of the SDK + frontend → frontend/dist/
pnpm preview # serve the production build locally
# ── Quality ──────────────────────────────────────────────────────────────────
pnpm test # run the unit tests (Vitest)
pnpm test:watch # re-run tests on change
pnpm typecheck # TypeScript check across sdk + frontend + tests
# ── Proof server (Docker — on-chain mode only) ───────────────────────────────
pnpm proof-server:start # start the proof server on localhost:6300
pnpm proof-server:stop # stop the container
pnpm proof-server:logs # tail the container logs
# ── Contract (only if you edit verime.compact) ───────────────────────────────
pnpm contract:build:fast # compile JS bindings only (skips ZK key generation)
pnpm contract:build # full compile including .prover / .verifier keys
pnpm contract:clean # delete generated/{contract,keys,zkir}
# ── Cleanup ──────────────────────────────────────────────────────────────────
pnpm clean # remove frontend/dist and sdk/distOnly needed if you modify
contract/verime.compact.
-
Install the Compact compiler — see the Midnight compiler docs.
-
Compile:
pnpm contract:build:fast # fast: JS bindings only, enough for local dev pnpm contract:build # full: also regenerates .prover / .verifier keys
-
Output lands in
generated/{contract,keys,zkir}. The frontend servesgenerated/keysandgenerated/zkirat the/zkroute in dev so the proving stack can fetch them.
Contributions are welcome. Please follow this workflow:
-
Fork & branch. Create a feature branch off
main:git checkout -b feat/short-description
-
Install & develop.
pnpm install pnpm dev
-
Keep the tree green. Before committing, make sure all checks pass:
pnpm typecheck # no TypeScript errors pnpm test # all unit tests pass pnpm build # production build succeeds
-
Coding conventions.
- TypeScript is strict (
strict+noUncheckedIndexedAccess). Noanyunless unavoidable, and isolate it when it is. - Shared, framework-agnostic logic belongs in
sdk/; React-specific code infrontend/. - UI uses the design tokens in
frontend/src/styles/globals.css(CSS variables and thevm-card/gradient-texthelpers) — avoid hardcoded hex colors in components. - Never log, persist, or transmit raw identity data (birth date, document number, images).
- Keep contract changes and the generated artifacts in sync (
pnpm contract:build).
- TypeScript is strict (
-
Commit style. Use clear, conventional messages:
feat:,fix:,docs:,refactor:,test:,chore:. Keep commits focused. -
Open a PR against
mainwith a description of what changed and why, plus the output of the checks above. Link any related issue.
Found a security issue? Please do not open a public issue — contact the maintainers privately first.
“No Midnight wallet detected.”
Install a Midnight wallet extension, switch it to the preprod network, and refresh the page.
The camera doesn’t appear on step 1.
Grant camera permission via the lock icon in the address bar. The camera API requires a secure
origin — localhost qualifies, but a plain http:// remote host will be blocked.
“MRZ zone not detected” after scanning. Make sure the bottom rows of the document fill the dashed box and are well-lit. Avoid glare, hold the document flat and still, and move it closer so it fills the frame.
ZKConfigurationReadError: Failed to read verifier key.
The proving stack couldn’t fetch the ZK artifacts. Confirm the dev server is running and that
generated/keys / generated/zkir exist (run pnpm contract:build if they’re missing).
“Balance failed” / transaction errors in on-chain mode.
Your wallet needs test funds on preprod to pay fees, the proof server must be running
(pnpm proof-server:start), and the wallet must be on the preprod network.
Proof server connection refused.
Run pnpm proof-server:start, wait ~10 s for the container to be ready, then verify with
curl http://localhost:6300/health.
pnpm install fails with an engine error.
You need Node.js ≥ 20. Check with node --version, then update via https://nodejs.org or
nvm use 20.
- Master secret — generated with
crypto.getRandomValues, stored inlocalStorageunderverime:master_secret. Back it up to retain your credentials; clearing site data loses them. - No images stored — the webcam frame is handed to Tesseract WASM in memory and dropped immediately after OCR.
- Birth year in
useRef— never placed in React state, so it does not surface in DevTools. - No backend — the app is entirely client-side; there is no server that could leak data.
- Pre-production by default — switch
PREPROD_CONFIGinsdk/src/types.tsfor other networks.
| Layer | Technology |
|---|---|
| Frontend | React 19, Vite 6, React Router 7 |
| Styling | Tailwind CSS v4 + custom Midnight design tokens |
| ZK contract | Compact language |
| ZK network | Midnight Network (pre-production) |
| Wallet | @midnight-ntwrk/dapp-connector-api v4 |
| Ledger / runtime | @midnight-ntwrk/ledger-v8, compact-runtime, onchain-runtime-v3 |
| OCR | Tesseract.js (WASM, Web Worker) |
| MRZ parsing | mrz |
| Secret derivation | Web Crypto API — HMAC-SHA256 |
| Language | TypeScript 5.7 (strict + noUncheckedIndexedAccess) |
| Package manager | pnpm 9 workspaces |
| Tests | Vitest |