A Go server that issues challenges and verifies zero-knowledge proofs of Taiwan CDC card identity, over REST and gRPC. Proofs come from the zkID circuits on top of Spartan2 with Hyrax commitments.
Every /link-verify call checks one cert-chain proof (RSA-2048 or RSA-4096) plus one user-signature proof (RSA-2048) and enforces five things server-side:
- The FFI accepts both proofs and their
pk_commitlinkage holds. - The
smt_rootpublic input matches the current revocation-list root for the issuer (moica-revocation-smt). - The
issuer_rsa_moduluspublic input matches the RSA modulus of the published MOICA-G2 (RS2048) or MOICA-G3 (RS4096) certificate — i.e. the proof was actually signed by MOICA, not an impostor. - The
app_idreconstructed from user_sig public values matches the configuredAPP_IDenv value (constant-time compare). The prover signsAPP_ID; the resulting RSA signature derives the cardholder-boundnullifierinside the same circuit. - The per-session
challengebound into the user-sig proof matches the value/challengeissued. The binding is a Semaphore-style dummy square (challengeSquared <== challenge * challenge) — see PR#60 follow-on. Stops replay of pre-generated proofs across sessions.
APP_ID is one 31-character lowercase hex string per relying party, set via env at server startup (e.g. APP_ID=$(LC_ALL=C tr -dc '0-9a-f' </dev/urandom | head -c 31)). challenge is per-session — a fresh 254-bit decimal field element issued by /challenge, bound into the user-sig proof by the prover, and extracted server-side from the proof's public inputs at /link-verify. The server looks up the challenge from the proof (normalising hex to decimal if needed) and consumes it on success.
The Rust crate fetches the zkID source via Cargo. The C++ witness-calculator artifacts are bundled inside the dependency — no local zkID clone or Yarn/circom toolchain required.
git clone https://github.com/privacy-ethereum/go-zkid-verifier.git
cd go-zkid-verifier
make build # downloads artifacts, builds Rust + Go binaries
cp .env.example .env
echo "APP_ID=$(LC_ALL=C tr -dc '0-9a-f' </dev/urandom | head -c 31)" >> .env
make serve- HTTP on
:8080, gRPC on:9090 - SQLite at
./zkid.db - SMT root fetched from Ethereum Mainnet, falling back to a pinned GitHub release
- MOICA issuer certs shipped embedded; refreshed in background from
moica.nat.gov.tw
Test a round-trip:
# Issue a challenge
curl -s -X POST http://localhost:8080/challenge | jq .
# Submit proofs (user-signs the challenge, then produces both ZK proofs)
curl -s -X POST http://localhost:8080/link-verify \
-H "Content-Type: application/json" \
-d '{"cert_chain_type":"rs2048","cert_chain_proof":"<base64>","user_sig_proof":"<base64>"}' | jq .
# Inspect trust-anchor caches
curl -s http://localhost:8080/smt-root/status | jq .
curl -s http://localhost:8080/issuer-cert/status | jq .- Go 1.25+ and Rust (stable)
- macOS or Linux
- macOS:
xcode-select --install - Linux:
sudo apt-get install -y g++ libstdc++-12-dev nasm libgmp-dev
- macOS:
protoconly if you regenerate.pb.goviamake proto
| Method | Path | Purpose |
|---|---|---|
POST |
/challenge |
Issue a fresh challenge. Returns {challenge, app_id, expires_at}, TTL 5 min. |
GET |
/challenge/{challenge} |
Fetch a challenge by value. |
POST |
/link-verify |
Verify a cert-chain + user-sig proof pair. Body limit 2 MB. |
GET |
/smt-root/status |
Trusted revocation-root cache snapshot. |
GET |
/issuer-cert/status |
Trusted MOICA issuer-cert cache snapshot. |
POST |
/debug/db/clean |
Dev only. Wipes challenges + verifications. Requires DEBUG_TOKEN env var and Authorization: Bearer <token> header. Route is unregistered (404) when DEBUG_TOKEN is unset. |
No request body — call as a bare POST. Success body (200):
{
"challenge": "<decimal field element>",
"app_id": "<31-char UTF-8 string>",
"expires_at": "2026-04-29T12:34:56Z"
}challenge is a fresh 254-bit decimal field element (32 random bytes, top 2 bits cleared, big-endian), with a 5-minute TTL. The prover folds it into the user-sig proof; the server later extracts it from the proof's public inputs to bind the proof to this session. app_id echoes the server's configured APP_ID env so the prover knows which bytes to sign.
Re-fetches a still-live challenge by value. Response shape is identical to POST /challenge. Useful for clients that want to confirm a challenge is still in TTL before kicking off proof generation.
| Code | Reason / meaning | Notes |
|---|---|---|
200 |
Success — body is {challenge, app_id, expires_at}. |
Both POST /challenge and GET /challenge/{challenge}. |
400 |
Challenge expired. | GET /challenge/{challenge} only — challenge exists but passed its 5-minute TTL. |
404 |
Challenge not found. | GET /challenge/{challenge} only — no live challenge with that value. |
500 |
Store error. | SQLite read/write failed during create or lookup. |
Request — no challenge field; the challenge is extracted server-side from the user_sig proof's public inputs:
{
"cert_chain_type": "rs2048",
"cert_chain_proof": "<base64>",
"user_sig_proof": "<base64>"
}cert_chain_type is "rs2048" (default) or "rs4096".
Success body (200):
{
"verified": true,
"nullifier": "<nullifier hex>",
"id_verified": true,
"persisted": true,
"public_signals": { "cert_chain": ["..."], "user_sig": ["..."] },
"parsed_inputs": {
"pk_commit": "...",
"nullifier": "...",
"app_id": "<31-char UTF-8 string>",
"challenge": "<decimal>",
"issuer_rsa_modulus": ["...", "..."],
"smt_root": "0x..."
},
"smt_root": { "issuer": "g2", "match": true, "expected": "0x…", "observed": "0x…", "trust_source": "onchain", "trusted_at": "…" },
"issuer_modulus": { "issuer": "g2", "match": true, "expected_sha256": "0xc4c4…", "trust_source": "embedded", "trusted_at": "…" },
"app_id": { "match": true, "expected": "<APP_ID env>", "observed": "<proof app_id>" },
"challenge": { "match": true, "expected": "<decimal>", "observed": "<decimal>" }
}The smt_root, issuer_modulus, and app_id blocks are each present whenever their respective checks ran. Only the first failing check populates reason; later blocks still report their outcome.
| Code | Reason / meaning | Notes |
|---|---|---|
200 |
verified=true — proof accepted, record persisted, challenge consumed. |
|
400 |
Request body malformed or missing cert_chain_proof / user_sig_proof / valid cert_chain_type. |
|
400 |
Challenge expired. | Challenge exists but passed its 5-minute TTL. |
404 |
Challenge not found or already consumed. | The challenge extracted from the proof's public inputs doesn't match any issued challenge. |
409 |
reason="smt_root_mismatch" |
Prover's smt_root disagrees with the trusted root — stale client. |
409 |
reason="issuer_modulus_mismatch" |
Prover's issuer modulus doesn't match MOICA-G2/G3 — wrong-issuer proof. |
409 |
reason="app_id_mismatch" |
The proof's app_id doesn't match the server's configured APP_ID — proof was minted for a different application. |
409 |
Duplicate nullifier. | Same nullifier already verified. Response echoes nullifier. |
410 |
Challenge already consumed. | |
503 |
Trust-anchor provider unavailable. | SMT root or issuer cert not cached — transient; retry. |
500 |
"proof verification failed" — FFI error or other infrastructure failure. |
POST /debug/db/clean resets the verifier's SQLite state by deleting every row from challenges and verifications in a single transaction. It exists for manual / scripted integration testing against a running file-backed server — unit tests already use in-memory SQLite. The endpoint is off by default: the route is only registered when DEBUG_TOKEN is set, and even then every request must carry Authorization: Bearer <DEBUG_TOKEN> (constant-time compared). There is no gRPC equivalent — the proto surface stays product-only.
Option A — .env file (recommended for local dev):
# Generate a token and write it to .env
echo "DEBUG_TOKEN=$(openssl rand -hex 32)" >> .env
make serve # godotenv loads .env automatically on startup
curl -X POST \
-H "Authorization: Bearer $DEBUG_TOKEN" \
http://localhost:8080/debug/db/clean
# → {"challenges_deleted":3,"verifications_deleted":2}Option B — inline environment variable:
DEBUG_TOKEN=$(openssl rand -hex 32) make serve
curl -X POST -H "Authorization: Bearer $DEBUG_TOKEN" \
http://localhost:8080/debug/db/clean
# → {"challenges_deleted":3,"verifications_deleted":2}Copy .env.example to .env to get started:
cp .env.example .env
# then fill in DEBUG_TOKEN (and any other overrides you need)| Code | Meaning |
|---|---|
200 |
Cleanup succeeded; body is {"challenges_deleted": N, "verifications_deleted": M}. |
401 |
Authorization header missing, not Bearer, or token mismatch. |
404 |
DEBUG_TOKEN is unset on the server, so the route is not registered. |
500 |
DB error during cleanup. |
Never set
DEBUG_TOKENin production. This endpoint destroys verification history.
proto/zkid/v1/zkid.proto defines ZkIDVerifier with the same verify semantics as HTTP (including smt_root_mismatch, issuer_modulus_mismatch, and app_id_mismatch fail modes). Messages up to 2 MB. Regenerate with make proto.
All via environment variables.
| Variable | Default | Purpose |
|---|---|---|
PORT |
8080 |
HTTP listen port |
GRPC_PORT |
9090 |
gRPC listen port |
DB_PATH |
./zkid.db |
SQLite database path |
KEYS_DIR |
./keys |
Verifying-key directory (auto-downloaded) |
CORS_ORIGIN |
* |
Access-Control-Allow-Origin |
APP_ID |
(required) | Exactly 31-character lowercase hex string identifying the relying party. The prover signs these bytes; the verifier hard-fails on mismatch. Generate with: APP_ID=$(LC_ALL=C tr -dc '0-9a-f' </dev/urandom | head -c 31) |
SMT_ROOT_ENFORCE |
strict |
strict = hard-fail on mismatch; disabled = skip (dev only) |
SMT_ROOT_RPC_URL |
https://ethereum-rpc.publicnode.com |
Ethereum Mainnet JSON-RPC |
SMT_ROOT_CONTRACT |
0xf3aAAe2D017dcC9cA901aDC9Da419f1C70362ab1 |
SMTRootStorage address |
SMT_ROOT_GITHUB_REPO |
privacy-ethereum/moica-revocation-smt |
Fallback repo |
SMT_ROOT_GITHUB_TAG |
snapshot-latest |
Fallback release tag |
SMT_ROOT_REFRESH_INTERVAL |
10m |
SMT refresh cadence |
SMT_ROOT_FETCH_TIMEOUT |
5s |
Per-source fetch timeout |
ISSUER_CERT_ENFORCE |
strict |
strict = hard-fail on modulus mismatch; disabled = skip (dev only) |
ISSUER_CERT_G2_URL |
https://moica.nat.gov.tw/repository/Certs/MOICA2.cer |
MOICA-G2 source (override for tests) |
ISSUER_CERT_G3_URL |
https://moica.nat.gov.tw/repository/Certs/MOICA-G3.cer |
MOICA-G3 source (override for tests) |
ISSUER_CERT_REFRESH_INTERVAL |
24h |
Issuer-cert refresh cadence |
ISSUER_CERT_FETCH_TIMEOUT |
10s |
Per-source fetch timeout |
DEBUG_TOKEN |
(unset) | When set, exposes POST /debug/db/clean. Requests must carry Authorization: Bearer <DEBUG_TOKEN>. Leave unset in production. |
Both smt_root and issuer_modulus use the same pattern: pinned trust anchors, background refresh, stale-on-error.
SMT revocation root. Primary: SMTRootStorage.getRoot(bytes32) on Ethereum Mainnet. Fallback: snapshot-latest GitHub release body. Startup is fail-closed — if neither source responds, the server refuses to boot. Set SMT_ROOT_ENFORCE=disabled for local dev.
Issuer certificates. MOICA-G2, MOICA-G3, and both of their GRCA parents ship embedded in the binary, with pinned SHA-256 fingerprints. A background fetch from moica.nat.gov.tw is best-effort: fetched certs must match the pinned fingerprint AND chain-validate to embedded GRCA before they replace the cached record. Fingerprint drift keeps the embedded copy in place and increments consecutive_fail. Rotating a cert requires a code release (new embedded bytes + new pinned fingerprint).
cmd/server ── HTTP (:8080) /challenge /link-verify /smt-root/status /issuer-cert/status
── gRPC (:9090) ZkIDVerifier
── SQLite (challenges + verification records)
── smtroot/ revocation SMT root cache (onchain + GitHub)
── issuercert/ MOICA cert cache (embedded + HTTPS, GRCA-chained)
└── verifier/ CGO → lib/<target>/libzk_verifier.a (Rust + ecdsa-spartan2)
The Rust static lib is selected by CGO per platform:
| Platform | Library directory |
|---|---|
| macOS Apple Silicon | lib/aarch64-apple-darwin/ |
| Linux x86_64 | lib/x86_64-unknown-linux-gnu/ |
cmd/server REST + gRPC server entrypoint
cmd/verifier Link-verify CLI (FFI smoke test)
httpapi/ HTTP transport (router, handlers, DTOs, error mapping)
grpc/ gRPC adapter over linkverify.Service
linkverify/ Orchestrator: FFI → parse → SMT check → issuer-modulus check → app_id check → challenge lookup → record
verifier/ CGO FFI + public-signals parser
smtroot/ Trusted revocation-root cache (onchain + GitHub fallback)
issuercert/ Trusted MOICA issuer-cert cache (embedded + HTTPS, GRCA-chained)
store/ SQLite (pure-Go modernc.org/sqlite)
keymanager/ Verifying-key auto-download
proto/zkid/v1/ zkid.proto + generated *.pb.go
rust/ Cargo.toml, Cross.toml, src/lib.rs (FFI shim)
lib/ Native libs per target (gitignored)
tests/artifacts/ Proof fixtures for RS2048 + RS4096
linkverify.Service is the transport-agnostic orchestrator:
v := &linkverify.Verifier{
KeysDir: keysDir,
SmtRoot: smtProvider, // nil disables SMT check (dev/tests only)
IssuerCert: issuerProvider, // nil disables issuer-modulus check (dev/tests only)
ExpectedAppID: appID, // 31-char UTF-8 string; empty disables app_id check (dev/tests only)
Logger: smtroot.DefaultLogger{},
}
service := linkverify.NewService(v, sqliteStore)
// Both transports route through the same call. Challenge and nullifier are
// both extracted server-side from the user_sig proof's public inputs.
res, err := service.VerifyAndRecord(ctx, linkverify.Request{...})Sentinel errors bubble unwrapped so each transport picks its own status code: store.ErrChallengeNotFound, ErrChallengeExpired, ErrChallengeConsumed, ErrDuplicateNullifier, linkverify.ErrSmtRootUnavailable, linkverify.ErrIssuerCertUnavailable.
linkverify.Verify caps concurrent ZK verifications at 10 (semaphore) and stages each proof in a temp dir with symlinked verifying keys.
# update git
git pull origin main
# clean
make clean
# build
make download-keys
make build| Target | Description |
|---|---|
make build |
Server + verifier CLI |
make serve |
Build + run the server |
make verify |
Run the verifier CLI against ./keys/ |
make test |
Full test suite |
make test-verifier |
FFI fixture tests (requires ./keys/) |
make test-linkverify |
Orchestration tests |
make download-keys |
Fetch verifying keys from the zkID GitHub release |
make proto |
Regenerate .pb.go |
make clean |
Remove build artifacts |
Integration tests gated by //go:build integration (currently ./issuercert/...) run with:
go test -tags integration ./issuercert/...cargo install cross --git https://github.com/cross-rs/cross
cross build --manifest-path rust/Cargo.toml --target x86_64-unknown-linux-gnu --release
mkdir -p lib/x86_64-unknown-linux-gnu
cp rust/target/x86_64-unknown-linux-gnu/release/libzk_verifier.a lib/x86_64-unknown-linux-gnu/rust/Cross.toml pre-installs nasm and libgmp-dev.
.github/workflows/ci.yml runs a pure-Go challenge-server job (CGO off, ./store/) and a verifier matrix (macOS + Linux) that downloads pre-built circom artifacts from the zkID GitHub release, builds the Rust lib, downloads verifying keys, and runs the full test suite including RS2048 / RS4096 FFI fixtures. Trust-anchor packages use injected static providers so CI never hits live endpoints.
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.