Privacy-preserving, machine-verifiable credentials — built on BBS+ and SD-JWT-VC, aligned with eIDAS 2.0 / EUDI Wallet.
StudentZK proves a predicate ("is a student", "is 18+") without revealing the holder's name, DOB, institution, or student number. The verifier is a static web page any kiosk or cashier can open — no integration, no account, no data collected.
See final_plan_md.md for the full technical spec, docs/architecture.md for the v2 diagram, docs/threat-model.md for the anti-abuse story, docs/api.md for the HTTP API reference, and TESTING.md for a single-file test plan covering the SD-JWT-VC flow, the OID4VCI handshake, and the Rust crypto-core.
| Module | Stack | Role |
|---|---|---|
issuer-backend |
Kotlin 2.1 · Spring Boot 3.4 · JVM 21 | OID4VCI issuer, credential-type registry, Play Integrity gateway |
verifier-web |
React 19 · Vite 6 · TypeScript 5.7 | OID4VP verifier (SD-JWT-VC + BBS WASM), client-side only |
holder-android |
Kotlin · Compose · StrongBox ES256 | Holder wallet (create in Android Studio) |
crypto-core |
Rust (docknetwork/crypto) · UniFFI |
Shared BBS+ core — consumed by issuer (JNA) and holder (UniFFI) |
Minimum for the Phase 1 happy path (everything in this README):
- JDK 21 (Temurin recommended). Verify:
java -versionprints21. - Node 20+ (for the verifier-web SPA). Verify:
node -vprintsv20or later. - Bash +
curl+jq(forscripts/demo.shand the OID4VCI curl recipes). On Windows, Git Bash ships withcurl;jqviawinget install jqlang.jqorchoco install jq. Or skip all three and usescripts/demo.ps1(PowerShell-native — same output, no extra tooling).
Only needed for later phases (skip for now):
- Rust 1.75+ + Android NDK r27 —
holder-android+crypto-core(Phase 2/3) - A separately-installed Postgres 16 — only if you don't want the embedded path below
- Google Cloud project with Play Integrity API — Phase 3 anti-abuse
This is the recommended way to test on your own machine. Zero Postgres install, zero Docker. The issuer boots an embedded Postgres 16 inside its own JVM under the local Spring profile.
First boot only: Zonky downloads a Postgres 16 binary (~60MB) into
~/.embedpostgresql/. This takes 15–30 s on a good connection. Subsequent boots are instant. If you're behind a corporate proxy, setHTTPS_PROXYbefore running. Windows anti-virus may scan the binary on first launch; that's fine, just slower.
Three terminals, in order.
Terminal 1 — issuer
cd issuer-backend
./gradlew bootRun --args='--spring.profiles.active=local'Wait for Started StudentZkpApplication. The issuer is now on http://localhost:8080.
Terminal 2 — verifier
cd verifier-web
cp .env.example .env.local
npm install
npm run devVite serves the SPA on http://localhost:5173.
Terminal 3 — mint + verify
bash scripts/demo.sh # bash + curl + jq
# or, on Windows / no-bash:
.\scripts\demo.ps1 # PowerShell-native, copies the SD-JWT to your clipboardThis POSTs to /dev/credential/0036123456, prints the SD-JWT-VC plus each disclosure decoded to [salt, name, value], and reminds you to paste the SD-JWT-VC into the verifier. You'll see is_student=true and age_equal_or_over.18=true; the name/DOB/student-number hashes stay bound to the credential but never leave your browser.
Stop everything with Ctrl-C in each terminal — the embedded Postgres shuts down with the issuer, nothing lingers.
Skip this unless you want to run the issuer exactly as it will run in production — against an externally-managed Postgres. The protocol flow is identical to the easy path; only the database lifecycle differs. Three backing options: Docker, native Windows, native macOS/Linux. The issuer expects a DB named studentzkp owned by a user studentzkp with password studentzkp (override via DB_USERNAME / DB_PASSWORD env vars).
Prereqs: Docker Desktop (macOS/Windows) or docker CLI + daemon (Linux). Verify: docker --version and docker ps both work.
1. Start the container. -d runs detached, --name labels it so we can reference it later, -p 5432:5432 binds the Postgres port to your host.
docker run -d --name studentzkp-pg \
-e POSTGRES_DB=studentzkp \
-e POSTGRES_USER=studentzkp \
-e POSTGRES_PASSWORD=studentzkp \
-p 5432:5432 \
postgres:16If port 5432 is already taken (another Postgres on the host?), remap to e.g. -p 5433:5432 and set DB_URL=jdbc:postgresql://localhost:5433/studentzkp before bootRun.
2. Wait for Postgres to finish starting. First boot initializes the data directory; takes ~3–5 s. Confirm:
docker logs studentzkp-pg --tail 5Healthy output ends with database system is ready to accept connections. If you see that twice (bootstrap + restart), it's fully ready.
3. Sanity check the connection. From inside the container — no host psql needed:
docker exec studentzkp-pg psql -U studentzkp -d studentzkp -c '\conninfo'Expect: You are connected to database "studentzkp" as user "studentzkp".
4. Start the issuer — without the local profile. Flyway auto-applies all migrations on first boot.
cd issuer-backend
./gradlew bootRun --args='--spring.profiles.active=dev-shortcut'The dev-shortcut profile activates the /dev/** helpers used by scripts/demo.sh. Drop it (./gradlew bootRun) when you want to confirm production-shape behavior — only the OID4VCI handshake and public endpoints will respond.
Expect log lines like Successfully applied 4 migrations to schema "public" followed by Started StudentZkpApplication. On a second run you'll see No migration necessary. You'll also see one line of the form studentzkp.admin.password not set — generated one for this boot: 'XXXX...' — note this if you want to call /integrity/** or /admin/**.
5. Start the verifier.
cd verifier-web
cp .env.example .env.local
npm install
npm run dev6. Mint + verify — same as the easy path:
bash scripts/demo.sh # or .\scripts\demo.ps1 on WindowsCopy the SD-JWT-VC → paste into http://localhost:5173 → Verify.
| What you want | Command |
|---|---|
| Stop Postgres (keeps data) | docker stop studentzkp-pg |
| Start it again | docker start studentzkp-pg |
| Tail the logs | docker logs -f studentzkp-pg |
| Open a psql shell | docker exec -it studentzkp-pg psql -U studentzkp |
| Check what credentials exist | docker exec studentzkp-pg psql -U studentzkp -d studentzkp -c 'SELECT id, status_idx, revoked FROM credential;' |
| Fully delete and reset ( |
docker rm -f studentzkp-pg then rerun step 1 |
The 7 test scenarios in "Testing it as intended" mostly work on independently-minted credentials, so you don't need to reset the DB between them. But if you want a clean slate (e.g. to re-run the revocation scenario with a fresh status_idx):
# Soft reset: truncate credentials + integrity log, keep seed data
docker exec studentzkp-pg psql -U studentzkp -d studentzkp -c \
'TRUNCATE credential, integrity_assertions RESTART IDENTITY CASCADE;'
# Hard reset: nuke the container + volume, re-run step 1
docker rm -f studentzkp-pgAfter a hard reset, restart the issuer — Flyway re-runs all migrations. The issuer's signing key is regenerated too (see ROADMAP → Known issues), so any previously-minted credentials will fail signature verification.
bootRunlogsConnection refused— container not listening yet. Rundocker psto confirm statusUp, then retry after a few seconds. If it never comes up,docker logs studentzkp-pgwill show why (common cause: port 5432 already bound on host).bootRunlogsFATAL: password authentication failed— you started the container once with a different password, then rebooted with env vars that Postgres ignores on second boot (env vars only take effect on a fresh volume). Fix:docker rm -f studentzkp-pgand rerun step 1.- Flyway reports
checksum mismatch— a migration file was edited after first apply. During development, reset the DB (hard reset above); never edit an applied migration in prod.
winget install PostgreSQL.PostgreSQL.16Then from a shell (add C:\Program Files\PostgreSQL\16\bin\ to PATH or use the full path):
psql -U postgres -c "CREATE USER studentzkp WITH PASSWORD 'studentzkp';"
psql -U postgres -c "CREATE DATABASE studentzkp OWNER studentzkp;"
psql -h localhost -U studentzkp -d studentzkp -c '\conninfo' # sanity checkThen run the issuer without the local profile: cd issuer-backend && ./gradlew bootRun.
# macOS
brew install postgresql@16 && brew services start postgresql@16
# Debian/Ubuntu
sudo apt-get install -y postgresql-16 && sudo systemctl start postgresql
# both
sudo -u postgres psql -c "CREATE USER studentzkp WITH PASSWORD 'studentzkp';"
sudo -u postgres psql -c "CREATE DATABASE studentzkp OWNER studentzkp;"
psql -h localhost -U studentzkp -d studentzkp -c '\conninfo' # sanity checkThen run the issuer without the local profile: cd issuer-backend && ./gradlew bootRun.
The end-to-end flow should convince you of four properties: (a) the issuer's signature is load-bearing, (b) selective disclosure actually hides what it says it hides, (c) disclosures can't be forged, (d) revocation propagates to verifiers without any online check against the issuer. Walk through the scenarios below after a successful happy path.
Keep the issuer and verifier running from the previous section. Every scenario starts by minting a fresh credential and copying the sdJwt string.
bash scripts/demo.sh, copy the── SD-JWT-VC (compact) ──block.- Paste into http://localhost:5173, click Verify.
- Expect: green "Credential verified" banner; the Revealed attributes table contains
is_student: trueandage_equal_or_over: {"18": true}; theHidden disclosures still bound to this credentialcount is nonzero (those are the name hashes, student ID, etc. — present in the credential but not revealed).
Decode the JWT payload manually (paste it into https://jwt.io offline or use jq):
SDJWT="<paste compact string>"
echo "${SDJWT%%~*}" | awk -F. '{print $2}' \
| tr '_-' '/+' | base64 -d 2>/dev/null | jq .Expect: the payload contains an _sd array of SHA-256 hashes, plus valid_until and status (always-disclosed). You should not see given_name, family_name, date_of_birth, or student_id in plaintext anywhere — those live only in the ~disclosure~ segments, and only the ones you choose to pass travel with the credential.
- Mint a fresh credential.
- In the pasted string, change one character in the JWT portion (before the first
~). Example: the last char of the signature. - Click Verify.
- Expect: red "Verification failed" banner, error mentions
signature verification failed—joserejected the JWS because the signature no longer matches the JWKS public key.
- Mint a fresh credential.
- Decode one disclosure (the segments after
~): base64url → JSON[salt, name, value]. Changefalse→true(or similar), re-encode to base64url, splice back into the compact string. - Click Verify.
- Expect: red banner with
Disclosure rejected: its hash is not present in the issuer-signed _sd array. The SD-JWT was tampered with or a disclosure was forged.
bash scripts/demo.sh. Note the printedCredential ID.- Revoke it:
curl -X POST http://localhost:8080/dev/credential/<credentialId>/revoke
- Re-paste the original SD-JWT-VC into the verifier, click Verify.
- Expect: red "Credential REVOKED" banner. The verifier fetched
/statuslist/uni-2026.json, inflated the bitstring, and found bitstatusIdx = 1. - Why this matters: the verifier made no authenticated request to the issuer — the status list is a public static resource that leaks nothing about which credential is being checked.
This tests the "fail soft on revocation, fail hard on signature" policy (verify.ts catches errors from the status list fetch but rethrows everything else). Because JWKS and the status list are both served by the dev issuer today, you need to block just the status list request to test this cleanly:
- Mint a fresh credential, keep the issuer running.
- Open the verifier in the browser, open devtools → Network tab.
- Right-click any request, Block request URL, enter
http://localhost:8080/statuslist/uni-2026.json, confirm. - Paste the SD-JWT-VC, click Verify.
- Expect: green "Credential verified" banner with an inline caveat
(status list unreachable — result shown against last-known state).
Signature verification still passes (JWKS fetch not blocked), so the verifier trusts the credential but flags its freshness as unknown — exactly what a kiosk with a flaky uplink should do.
curl -s http://localhost:8080/.well-known/jwks.json | jq .Expect: a JWKS with one EC P-256 key, use: sig, alg: ES256, and the same kid that appears in the JWT header of any credential from step 1. The key is now persisted to ./.studentzkp/issuer-signing-key.jwk — restart the issuer and the same kid survives, so credentials minted before the restart still verify after it.
Phase 1.5 wires the three OpenID4VCI endpoints. The dev shortcut from scenario 1 is a one-call shortcut; this is what an actual wallet does.
-
Issuer mints an offer for student
0036123456:OFFER=$(curl -sX POST http://localhost:8080/dev/credential-offer/0036123456) echo "$OFFER" | jq .
Note
offer_id,pre_authorized_code,credential_offer_uri,deep_link. The deep-link is what gets QR-encoded for the wallet. -
Wallet fetches the offer JSON by reference (would normally be triggered by the deep-link):
OFFER_ID=$(echo "$OFFER" | jq -r .offer_id) curl -s http://localhost:8080/credential-offer/$OFFER_ID | jq .
Expect a
credential_offerobject withcredential_configuration_ids: ["UniversityStudent"]and thepre-authorized_codegrant. -
Wallet redeems the pre-auth code at
/token:PRE_AUTH=$(echo "$OFFER" | jq -r .pre_authorized_code) TOK=$(curl -s -X POST http://localhost:8080/token \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code' \ --data-urlencode "pre-authorized_code=$PRE_AUTH") echo "$TOK" | jq .
Expect:
access_token,c_nonce,expires_in. Both nonces are short-lived. -
Wallet POSTs to
/credentialwith a proof JWT signed by its hardware-bound key. This is where you actually need a wallet —curlcan't generate theopenid4vci-proof+jwtJWS over{aud, iat, nonce}with ajwkin the header. Point a walt.id wallet (or your own holder code) at$deep_linkand watch it complete the flow.
If you want to simulate the wallet from a script, every JOSE library can do it — see Oid4VciService.validateProof() in the issuer for the exact structure it expects.
| Feature | Status |
|---|---|
| SD-JWT-VC issuance + selective disclosure | ✓ Phase 1 |
| ES256 JWKS endpoint, persisted signing key | ✓ Phase 1 |
| IETF Token Status List (deflate + bitstring) | ✓ Phase 1 |
| Browser-side signature + disclosure hash check | ✓ Phase 1 |
| Full OID4VCI pre-authorized-code handshake | ✓ Phase 1.5 |
BBS+ Rust core (crypto-core, sign/derive/verify, 5 tests) |
✓ Phase 2 (Rust) |
| C-ABI shim + JNA + UniFFI bindings | Phase 2 |
| KB-JWT (holder-bound proof of possession on SD-JWT-VC) | Phase 2 |
| BBS verification in the browser (WASM) | Phase 2 |
QR scan in verifier (@zxing/library) |
Phase 2 |
| Android holder wallet + StrongBox | Phase 2 |
| Play Integrity enforcement | Phase 3 |
- Hand-rolled SD-JWT-VC on nimbus-jose-jwt instead of walt.id. Same wire format (draft-ietf-oauth-sd-jwt-vc), fewer third-party moving parts, ~150 LOC that's fully auditable in
SdJwtVcService.kt/verify.ts. Swap to walt.id when Phase 1.5 adds real OID4VCI. - Postgres-only (no H2 dev profile). The credential-type-agnostic data model (
final_plan §5.9) leans on JSONB columns +gen_random_uuid(); an H2 compatibility layer was more scope than the one-linedocker runabove.
BBS+ on BLS12-381 via docknetwork/bbs_plus. Sign / derive selective-disclosure proof / verify, plus stubs for the Phase 3 accumulator-revocation path. Same .so will be consumed by the issuer JVM (JNA) and the Android holder (UniFFI) once the extern "C" shim is added.
cd crypto-core
cargo testFive tests: full disclosure, selective disclosure, replay rejection (wrong nonce), tamper rejection (modified disclosed message), and unlinkability (two proofs of the same signature differ byte-for-byte).
See holder-android/README.md — generate in Android Studio first.
StudentZK's data model is credential-type-agnostic (final_plan §5.9). Phase 0 seeds the Student type; admins register additional types (age/v1, library/v1, transit/v1, event/v1, …) at runtime. One wallet, many contexts.