I started from the three example directions in the Midnight mobile challenge — identity/eligibility, confidential payments, and private health/fitness — and picked identity, because it maps to something everyone has felt: handing over a full driver's license just to prove one fact ("I'm over 18") to a bouncer who doesn't need to know your address, your license number, or your exact birthdate.
I treated the build as three layers, and built them roughly in this order:
The contract first. contract/eligibility.compact was written before a single line of app code, because it's the part that defines the actual privacy guarantee. An issuer enrolls a commitment — a hash — of a holder's attributes against a DID. From then on, the holder's device can prove threshold facts (age, region, membership tier) as witness-backed circuit calls: the real numbers go in privately, only a boolean and a fresh commitment come out publicly. The core trick, in one line: instead of storing limit, spent, dob ∈ public ledger we store c=H (attrs∥nonce)∈public ledger and every later proof has to show it still knows an attrs and nonce that hash to that same 𝑐 — so the chain never needs to see the attributes to be sure they haven't changed. A logic-faithful simulation, before touching a real prover. We don't have a live Midnight node or proof server in our build environment, so we wrote pwa/js/ledger.js as a client-side mirror that runs the exact same assertions as the contract, backed by real SHA-256 (crypto.subtle) rather than a compiled zk-SNARK. This let us iterate on the policy logic in seconds instead of waiting on a compile/deploy/prove cycle for every change — and, as covered below, it's how we actually caught a real bug. The mobile shell. A four-screen PWA (Identity → Prove → Verify → Ledger) with a service worker for offline app-shell caching and a web manifest for home-screen install. For identity, we used WebAuthn's PRF extension — the same "one biometric mints a DID and a key" idea that Kuira's native Android SDK uses for Midnight, just implemented against the standard browser API instead of Kotlin, so it runs as an installable PWA on whatever phone you have.
Throughout, I tested against real assertions rather than eyeballing the code: a small Node harness stubbed localStorage (Node already ships a real Web Crypto implementation) and ran the full enroll → prove → verify → revoke lifecycle, including deliberately adversarial cases like a holder lying about their membership tier.
Challenges we ran into: Owner authorization looked right and wasn't. Early on (during an earlier prototype contract, before Umbra), we followed Midnight's standard lock-contract pattern of mixing a round counter into an authorization hash, so each authorized call produces an unlinkable public value. That pattern assumes the authority value itself rotates each time. Our issuer/owner field doesn't rotate — it's sealed at deployment. Mixing in a changing round meant the authorization check matched on the very first call and failed on every call after that, because the hash being recomputed no longer matched the one sealed at construction. We only caught this because we'd built the executable simulation first: the second issuer-only call threw "caller is not the issuer" immediately, which sent us back to first principles about why that lock-contract pattern rotates in the first place. We carried the fix forward into Umbra's issuerKey circuit from the start, with a comment explaining why the round is deliberately excluded.
Passkeys are not one API. Getting a deterministic secret out of a biometric prompt takes two round trips, not one: credentials.create() only tells you whether the authenticator supports the PRF extension; the actual pseudo-random output only comes back from a subsequent credentials.get() call with extensions: { prf: { eval: { first: salt } } }. Support also isn't universal yet — Safari's PRF story is different from Chromium's. That pushed us toward a design where identity creation has an explicit, visible fallback path (a locally generated secret) rather than silently failing on unsupported browsers.
Proving without a prover. The hardest conceptual challenge wasn't code, it was honesty about what we'd actually built. It would have been easy to let "proof code" quietly imply "zero-knowledge proof" in the UI copy. A nonce recorded in a lookup table and a real zk-SNARK give very different guarantees — the former requires trusting a shared list, the latter requires trusting only mathematics. We decided the README and in-app framing needed to say plainly which one we'd built, rather than let the demo's polish imply more than the cryptography underneath it.
Anti-replay needed a second look. Every proof consumes a one-time nonce so the same "I'm 18+" proof can't be reused indefinitely at a different door.
What I learned: Commitments, not encryption, are the workhorse of this design. I initially reached for "encrypt the data" as the mental model for privacy, but almost everything here is actually a commitment scheme: hash the private value with a nonce, publish only the hash, and prove later that you still know an opening for it. Encryption implies a key that could someday decrypt; commitments (paired with ZK proofs of properties, in the real system) mean the plaintext is never reconstructible from the public record at all. Build the boring simulation before the exciting proof system. The fastest way we found real bugs wasn't reading the Compact file carefully (though that matters too) — it was writing an executable, adversarial test of the same logic in a language we could run instantly. The round-mixing bug would have been much more expensive to find after a full compile-and-deploy cycle. Privacy UX has to be visible, not just true. It's not enough for the attribute data to actually stay off-chain — the Ledger screen exists because a user (or a judge) should be able to look at exactly what's public and see for themselves that there's no birthdate in it, rather than take our word for it. Say what's simulated, out loud. The single most important engineering decision in this project wasn't a line of code — it was choosing to document, clearly and repeatedly, the exact boundary between "real cryptographic hashing, tested and correct" and "stands in for a zero-knowledge proof we didn't have the infrastructure to generate here." A demo that quietly blurs that line teaches the wrong lesson about what Midnight actually provides.
Built With
- compact
- css3
- html5
- javascript
- midnight
- node.js
- python
- service-workers
- web-app-menifest
- web-authentication-prf
- web-crypto-api
- webauthn
Log in or sign up for Devpost to join the conversation.